Billing 2a — Console Foundations Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build every write path the superadmin billing console needs — seven Actions, an atomic invoice-number sequence, a BillingCycle enum, and the modulesAllowed fix — with no UI, so the three screens in 2b–2d are pure presentation over a tested backend.
Architecture: Every console write goes through an Action in app/Actions/Billing/; no controller will ever write a billing_* column directly. Each Action records a BillingEvent naming the acting user, and the five that mutate team billing state also flush the entitlement memo. Invoice numbers are allocated at issue time from a row-locked sequence table, so discarded drafts leave no gaps.
Tech Stack: Laravel 13, PHP 8.4, MySQL (SQLite in tests), PHPUnit 12, Pint, Larastan.
Spec: docs/superpowers/specs/2026-09-09-saas-monetization-design.md — see its "Increment 2 in detail" section.
Global Constraints
- PHP binary: plain
phpon this machine is MAMP's PHP 8.2 and too old. Every Artisan command runs asherd php artisan .... Composer isherd php /Applications/MAMP/bin/php/composer <args>. - Tests:
herd php -d memory_limit=2G artisan test --compact <path>. Note thatartisan testspawns child processes that do NOT inherit-d memory_limit; to run the entire suite useherd php -d memory_limit=2G vendor/bin/phpunitinstead. - No new model factories.
TeamFactory,UserFactoryandTeamInvitationFactoryalready exist and may be used. Build billing rows with explicitModel::create([...])calls carrying realistic data. - All billing tables are MySQL (the default connection;
sqlitein tests). Never MongoDB. No MongoDB transactions anywhere. - No
BelongsToOrganizationon any billing model — billing is platform-level data read across teams by a superadmin; the tenant global scope would hide it. - Amounts are integer cents throughout — never floats.
- Formatting: run
vendor/bin/pint --dirty --format agentbefore every commit. - Model conventions:
protected $guarded = [];, aprotected $casts = []array property (not a method), a full@propertydocblock, TitleCase enum cases, explicit return types and parameter type hints, curly braces on all control structures, PHPDoc blocks over inline comments.App\Models\Teamis the one exception — it uses acasts()method; follow the file. - Action conventions: a single public
handle()method, guards viaabort_if(...)with a 422 and a human-readable message,DB::transaction()where more than one row is written, and a class docblock referencing the spec section it implements. Seeapp/Actions/Sales/Quotations/AcceptQuotation.phpfor the house style.
Known pre-existing test failures
Twenty-five tests fail on main today and are NOT this plan's to fix, with one exception noted below. Nine are ViteExceptions needing npm run build; the rest are missing routes, an undefined Asset::maintenances(), int-vs-float strictness in the 3PL billable-qty tests, and empty Mongo reference tables.
The one exception: Tests\Feature\Support\ModulesMenuFilterTest::test_the_team_level_allow_list_hides_disallowed_modules currently fails because modulesAllowed is not mass-assignable. Task 1 fixes that, so this test must go green, taking the baseline from 25 failures to 24.
Refinement to the spec, deliberate
The spec says each Action "records a BillingEvent … and calls Entitlements::flush()". Applied literally that would flush on invoice and payment writes, which cannot change any entitlement — misleading rather than harmful.
This plan is precise instead: all seven Actions record a BillingEvent; only the five that mutate a billing_* column on teams or change module holdings call Entitlements::flush() — SaveEnterprisePlan, SetTeamModules, ExtendGrace, SuspendTeam, RestoreTeam. IssueInvoice and RecordPayment touch only billing_invoices / billing_payments and do not flush.
Product::flushCache() is not called by any Action here — no Action in 2a writes billing_products. The Rates screen in 2d is what needs it.
Task 1: BillingCycle enum, Team casts, and the modulesAllowed fix
Unblocks the console: a superadmin currently cannot persist a team's module set at all, and the two interval columns are untyped strings.
Files:
- Create:
app/Enums/BillingCycle.php - Modify:
app/Models/Team.php - Test:
tests/Feature/Billing/BillingCycleTest.php
Interfaces:
-
Consumes: nothing.
-
Produces:
App\Enums\BillingCyclewith casesMonthly = 'monthly',Yearly = 'yearly', and methodsmonthsPerPeriod(): int,monthlyEquivalentCents(int $amountCents): int,label(): string.Team::$billing_cycleandTeam::$billing_enterprise_intervalcast toBillingCycle.modulesAllowedpresent inTeam's#[Fillable]attribute.
-
Step 1: Write the failing test
Create tests/Feature/Billing/BillingCycleTest.php:
<?php
namespace Tests\Feature\Billing;
use App\Enums\BillingCycle;
use Illuminate\Foundation\Testing\RefreshDatabase;
/**
* The billing interval enum and the two `teams` columns it types
* (design doc, "Increment 2 in detail" → "Backend foundations").
*/
class BillingCycleTest extends BillingTestCase
{
use RefreshDatabase;
public function test_a_period_is_one_month_or_twelve(): void
{
$this->assertSame(1, BillingCycle::Monthly->monthsPerPeriod());
$this->assertSame(12, BillingCycle::Yearly->monthsPerPeriod());
}
public function test_a_monthly_amount_is_its_own_monthly_equivalent(): void
{
$this->assertSame(75000, BillingCycle::Monthly->monthlyEquivalentCents(75000));
}
public function test_a_yearly_amount_is_divided_across_twelve_months(): void
{
$this->assertSame(37500, BillingCycle::Yearly->monthlyEquivalentCents(450000));
}
public function test_an_indivisible_yearly_amount_rounds_to_whole_cents(): void
{
// 100000 / 12 = 8333.33...
$this->assertSame(8333, BillingCycle::Yearly->monthlyEquivalentCents(100000));
}
public function test_both_interval_columns_cast_to_the_enum(): void
{
$team = $this->team();
$team->forceFill([
'billing_cycle' => BillingCycle::Yearly,
'billing_enterprise_interval' => BillingCycle::Monthly,
])->save();
$team->refresh();
$this->assertSame(BillingCycle::Yearly, $team->billing_cycle);
$this->assertSame(BillingCycle::Monthly, $team->billing_enterprise_interval);
}
public function test_modules_allowed_is_mass_assignable(): void
{
$team = $this->team();
$team->update(['modulesAllowed' => ['crm' => true, 'hr' => false]]);
$this->assertSame(['crm' => true, 'hr' => false], $team->fresh()->modulesAllowed);
}
}
- Step 2: Run the test to verify it fails
Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing/BillingCycleTest.php
Expected: FAIL — Class "App\Enums\BillingCycle" not found.
- Step 3: Write the enum
Create app/Enums/BillingCycle.php:
<?php
namespace App\Enums;
/**
* How often a team is billed. Types both `teams.billing_cycle` (the
* self-serve subscription interval) and `teams.billing_enterprise_interval`
* (the interval of a negotiated fixed price), which were previously bare
* strings with nothing preventing `'yearly'` / `'annual'` / `'year'` drift.
*/
enum BillingCycle: string
{
case Monthly = 'monthly';
case Yearly = 'yearly';
public function monthsPerPeriod(): int
{
return match ($this) {
self::Monthly => 1,
self::Yearly => 12,
};
}
/**
* What an amount billed at this cycle is worth per month, for the
* console's MRR figure. Rounded to whole cents — a yearly price rarely
* divides evenly by twelve, and MRR is a summary rather than something
* anyone is invoiced for.
*/
public function monthlyEquivalentCents(int $amountCents): int
{
return (int) round($amountCents / $this->monthsPerPeriod());
}
public function label(): string
{
return match ($this) {
self::Monthly => 'Monthly',
self::Yearly => 'Yearly',
};
}
}
- Step 4: Cast the columns and make
modulesAllowedfillable
In app/Models/Team.php:
Add the import use App\Enums\BillingCycle;.
Add both entries to the existing casts() method (which is a method on this model, not a property — follow the file):
'billing_cycle' => BillingCycle::class,
'billing_enterprise_interval' => BillingCycle::class,
Change the #[Fillable] attribute to include the column:
#[Fillable(['name', 'slug', 'is_personal', 'modulesAllowed'])]
Update the @property lines for both interval columns to BillingCycle|null.
Add this note to the class docblock (create one above #[Fillable] if the class has none), because it is the reason the attribute is safe:
/**
* `modulesAllowed` is mass-assignable so the superadmin billing console can
* persist a team's module set — before this it was silently dropped by every
* `update()`, which is why `Support\ModulesMenuFilterTest`'s allow-list test
* failed. It is nonetheless a **billing-relevant** field: it decides which
* paid modules a team may reach. It must never be added to the validation
* rules of a member-facing request. Today the only mass-assignment sites are
* `SuperAdmin\TeamController::store()` (whose `SaveTeamRequest` allows only
* `name` and `is_personal`) and two explicit array literals, so no request
* payload can reach it.
*/
- Step 5: Run the test to verify it passes
Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing/BillingCycleTest.php
Expected: PASS, 6 tests.
- Step 6: Confirm the long-failing menu test is now green
Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Support
Expected: PASS, including ModulesMenuFilterTest::test_the_team_level_allow_list_hides_disallowed_modules, which failed before this task. If it still fails, the #[Fillable] change did not take effect.
- Step 7: Confirm nothing else moved
Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing tests/Feature/Teams tests/Feature/Auth
Expected: PASS.
- Step 8: Format and commit
vendor/bin/pint --dirty --format agent
git add app/Enums/BillingCycle.php app/Models/Team.php tests/Feature/Billing/BillingCycleTest.php
git commit -m "Billing: type the interval columns and let the console set modules"
Task 2: Atomic invoice numbering
Invoice numbers are allocated when an invoice is issued, not when it is drafted, so a discarded draft leaves no gap. That means a draft has no number — and the column is currently NOT NULL.
Files:
- Create:
database/migrations/2026_09_10_100000_make_billing_invoice_number_nullable.php - Create:
database/migrations/2026_09_10_100100_create_billing_number_sequences_table.php - Create:
app/Models/Billing/NumberSequence.php - Modify:
app/Models/Billing/Invoice.php(the@propertyline fornumber) - Test:
tests/Feature/Billing/InvoiceNumberSequenceTest.php
Interfaces:
-
Consumes: nothing.
-
Produces:
- Table
billing_number_sequences(id,scopeunique,next_value, timestamps). App\Models\Billing\NumberSequencewithNumberSequence::next(string $scope): intandNumberSequence::nextInvoiceNumber(CarbonInterface $issuedAt): string.billing_invoices.numbernullable.
- Table
-
Step 1: Write the failing test
Create tests/Feature/Billing/InvoiceNumberSequenceTest.php:
<?php
namespace Tests\Feature\Billing;
use App\Enums\BillingInvoiceSource;
use App\Enums\BillingInvoiceStatus;
use App\Models\Billing\Invoice;
use App\Models\Billing\NumberSequence;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Schema;
/**
* Invoice numbers are allocated at issue time from a locked sequence, so a
* discarded draft leaves no gap (design doc, "Increment 2 in detail" →
* "Backend foundations").
*/
class InvoiceNumberSequenceTest extends BillingTestCase
{
use RefreshDatabase;
public function test_a_draft_invoice_may_have_no_number(): void
{
$this->assertTrue(Schema::hasTable('billing_number_sequences'));
$team = $this->team();
$invoice = Invoice::create([
'team_id' => $team->id,
'number' => null,
'source' => BillingInvoiceSource::Manual,
'period_start' => '2026-10-01',
'period_end' => '2026-10-31',
'subtotal_cents' => 450000,
'total_cents' => 450000,
'currency' => 'usd',
'status' => BillingInvoiceStatus::Draft,
]);
$this->assertNull($invoice->fresh()->number);
}
public function test_two_drafts_without_numbers_do_not_collide(): void
{
$team = $this->team();
foreach (['2026-10-01', '2026-11-01'] as $start) {
Invoice::create([
'team_id' => $team->id,
'number' => null,
'source' => BillingInvoiceSource::Manual,
'period_start' => $start,
'period_end' => $start,
'subtotal_cents' => 1000,
'total_cents' => 1000,
'currency' => 'usd',
'status' => BillingInvoiceStatus::Draft,
]);
}
$this->assertSame(2, Invoice::whereNull('number')->count());
}
public function test_a_sequence_starts_at_one_and_increments(): void
{
$this->assertSame(1, NumberSequence::next('invoice:2026'));
$this->assertSame(2, NumberSequence::next('invoice:2026'));
$this->assertSame(3, NumberSequence::next('invoice:2026'));
}
public function test_sequences_are_independent_per_scope(): void
{
NumberSequence::next('invoice:2026');
NumberSequence::next('invoice:2026');
$this->assertSame(1, NumberSequence::next('invoice:2027'));
}
public function test_the_unique_index_survives_the_column_becoming_nullable(): void
{
$team = $this->team();
$attributes = [
'team_id' => $team->id,
'number' => 'INV-2026-0001',
'source' => BillingInvoiceSource::Manual,
'period_start' => '2026-10-01',
'period_end' => '2026-10-31',
'subtotal_cents' => 1000,
'total_cents' => 1000,
'currency' => 'usd',
'status' => BillingInvoiceStatus::Open,
];
Invoice::create($attributes);
$this->expectException(QueryException::class);
Invoice::create($attributes + ['period_start' => '2026-11-01']);
}
public function test_an_invoice_number_is_scoped_to_its_issue_year_and_zero_padded(): void
{
$first = NumberSequence::nextInvoiceNumber(Carbon::parse('2026-10-01 09:00:00'));
$second = NumberSequence::nextInvoiceNumber(Carbon::parse('2026-12-31 23:59:59'));
$nextYear = NumberSequence::nextInvoiceNumber(Carbon::parse('2027-01-01 00:00:01'));
$this->assertSame('INV-2026-0001', $first);
$this->assertSame('INV-2026-0002', $second);
$this->assertSame('INV-2027-0001', $nextYear);
}
}
- Step 2: Run the test to verify it fails
Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing/InvoiceNumberSequenceTest.php
Expected: FAIL — Class "App\Models\Billing\NumberSequence" not found.
- Step 3: Write the nullable-number migration
Create database/migrations/2026_09_10_100000_make_billing_invoice_number_nullable.php:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Invoice numbers are allocated when an invoice is issued, not when it is
* drafted, so a draft that is never issued leaves no gap in the sequence —
* which means a draft carries no number at all.
*
* The column keeps its unique index. Both MySQL and SQLite treat NULL as
* distinct for uniqueness, so any number of unnumbered drafts coexist while
* two issued invoices still cannot share a number.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('billing_invoices', function (Blueprint $table) {
$table->string('number')->nullable()->change();
});
}
// Note for the implementer: Laravel 11+ needs no doctrine/dbal for
// `change()`, but on SQLite it rebuilds the table. The test
// `test_the_unique_index_survives_the_column_becoming_nullable` is what
// proves the unique index came back — if it fails, re-add the index
// explicitly in this migration with `$table->unique('number')`.
public function down(): void
{
Schema::table('billing_invoices', function (Blueprint $table) {
$table->string('number')->nullable(false)->change();
});
}
};
- Step 4: Write the sequences migration
Create database/migrations/2026_09_10_100100_create_billing_number_sequences_table.php:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('billing_number_sequences', function (Blueprint $table) {
$table->id();
$table->string('scope')->unique();
$table->unsignedBigInteger('next_value')->default(1);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('billing_number_sequences');
}
};
- Step 5: Write the model
Create app/Models/Billing/NumberSequence.php:
<?php
namespace App\Models\Billing;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* A monotonic counter, one row per scope (`invoice:2026`).
*
* Exists so invoice numbers survive the bulk drafting command issuing
* several invoices in quick succession: reading `MAX(number)` and adding one
* would race, whereas locking a single counter row does not.
*
* @property int $id
* @property string $scope
* @property int $next_value
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
*/
class NumberSequence extends Model
{
protected $table = 'billing_number_sequences';
protected $guarded = [];
protected $casts = [
'next_value' => 'integer',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
/**
* Take the next value for a scope, creating the counter on first use.
*
* Opens its own transaction when not already inside one, so the
* `lockForUpdate()` has something to hold. Callers already in a
* transaction — `IssueInvoice` is — get the surrounding one.
*/
public static function next(string $scope): int
{
$allocate = function () use ($scope): int {
self::firstOrCreate(['scope' => $scope], ['next_value' => 1]);
$sequence = self::query()->where('scope', $scope)->lockForUpdate()->firstOrFail();
$value = $sequence->next_value;
$sequence->update(['next_value' => $value + 1]);
return $value;
};
return DB::transactionLevel() > 0 ? $allocate() : DB::transaction($allocate);
}
/**
* The next invoice number for the year an invoice is issued in, e.g.
* `INV-2026-0001`. Numbers run globally per year rather than per team,
* so a gap is visible rather than hidden inside one customer's history.
*/
public static function nextInvoiceNumber(CarbonInterface $issuedAt): string
{
$year = $issuedAt->format('Y');
return sprintf('INV-%s-%04d', $year, self::next("invoice:{$year}"));
}
}
- Step 6: Update the Invoice docblock
In app/Models/Billing/Invoice.php, change the @property string $number line to:
* @property string|null $number
and extend the class docblock with one sentence: numbers are allocated at issue time, so a draft has none.
- Step 7: Run the test to verify it passes
Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing/InvoiceNumberSequenceTest.php
Expected: PASS, 6 tests.
- Step 8: Confirm the existing invoice tests still pass
Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing
Expected: PASS.
- Step 9: Format and commit
vendor/bin/pint --dirty --format agent
git add database/migrations app/Models/Billing/NumberSequence.php app/Models/Billing/Invoice.php tests/Feature/Billing/InvoiceNumberSequenceTest.php
git commit -m "Billing: allocate invoice numbers from a locked sequence"
Task 3: IssueInvoice
Turns a draft into an open invoice: allocates its number, stamps issued_at, records the event.
Files:
- Create:
app/Actions/Billing/IssueInvoice.php - Test:
tests/Feature/Billing/Actions/IssueInvoiceTest.php
Interfaces:
-
Consumes:
NumberSequence::nextInvoiceNumber(CarbonInterface): string(Task 2). -
Produces:
App\Actions\Billing\IssueInvoice::handle(Invoice $invoice, User $actor): Invoice. -
Step 1: Write the failing test
Create tests/Feature/Billing/Actions/IssueInvoiceTest.php:
<?php
namespace Tests\Feature\Billing\Actions;
use App\Actions\Billing\IssueInvoice;
use App\Enums\BillingInvoiceLineKind;
use App\Enums\BillingInvoiceSource;
use App\Enums\BillingInvoiceStatus;
use App\Enums\TeamRole;
use App\Models\Billing\BillingEvent;
use App\Models\Billing\Invoice;
use App\Models\Billing\InvoiceLine;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tests\Feature\Billing\BillingTestCase;
/**
* Issuing a drafted invoice (design doc, "Increment 2 in detail" →
* "Enterprise invoicing").
*/
class IssueInvoiceTest extends BillingTestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
private function draft(Team $team): Invoice
{
$invoice = Invoice::create([
'team_id' => $team->id,
'number' => null,
'source' => BillingInvoiceSource::Manual,
'period_start' => '2026-10-01',
'period_end' => '2026-10-31',
'subtotal_cents' => 450000,
'total_cents' => 450000,
'currency' => 'usd',
'status' => BillingInvoiceStatus::Draft,
]);
// The Action refuses to issue an invoice with no lines, so every
// draft these tests build carries one.
InvoiceLine::create([
'billing_invoice_id' => $invoice->id,
'kind' => BillingInvoiceLineKind::Module,
'description' => 'Enterprise plan — October 2026',
'quantity' => 1,
'unit_amount_cents' => 450000,
'amount_cents' => 450000,
]);
return $invoice;
}
public function test_issuing_allocates_a_number_and_opens_the_invoice(): void
{
Carbon::setTestNow('2026-09-15 10:00:00');
$team = $this->team();
$admin = $this->member($team, TeamRole::Admin);
$invoice = $this->draft($team);
$issued = app(IssueInvoice::class)->handle($invoice, $admin);
$this->assertSame('INV-2026-0001', $issued->number);
$this->assertSame(BillingInvoiceStatus::Open, $issued->status);
$this->assertSame('2026-09-15 10:00:00', $issued->issued_at->toDateTimeString());
}
public function test_numbers_increment_across_invoices(): void
{
Carbon::setTestNow('2026-09-15 10:00:00');
$team = $this->team();
$admin = $this->member($team, TeamRole::Admin);
$first = app(IssueInvoice::class)->handle($this->draft($team), $admin);
$second = app(IssueInvoice::class)->handle($this->draft($team), $admin);
$this->assertSame('INV-2026-0001', $first->number);
$this->assertSame('INV-2026-0002', $second->number);
}
public function test_issuing_records_a_billing_event_naming_the_actor(): void
{
Carbon::setTestNow('2026-09-15 10:00:00');
$team = $this->team();
$admin = $this->member($team, TeamRole::Admin);
app(IssueInvoice::class)->handle($this->draft($team), $admin);
$event = BillingEvent::where('type', 'invoice.issued')->firstOrFail();
$this->assertSame($team->id, $event->team_id);
$this->assertSame($admin->id, $event->actor_user_id);
$this->assertSame('INV-2026-0001', $event->payload['number']);
}
public function test_an_already_issued_invoice_cannot_be_issued_again(): void
{
Carbon::setTestNow('2026-09-15 10:00:00');
$team = $this->team();
$admin = $this->member($team, TeamRole::Admin);
$invoice = app(IssueInvoice::class)->handle($this->draft($team), $admin);
$this->expectException(HttpException::class);
$this->expectExceptionMessage('Only a draft invoice can be issued.');
app(IssueInvoice::class)->handle($invoice, $admin);
}
public function test_a_failed_issue_consumes_no_number(): void
{
Carbon::setTestNow('2026-09-15 10:00:00');
$team = $this->team();
$admin = $this->member($team, TeamRole::Admin);
$invoice = app(IssueInvoice::class)->handle($this->draft($team), $admin);
try {
app(IssueInvoice::class)->handle($invoice, $admin);
} catch (HttpException) {
// expected
}
$next = app(IssueInvoice::class)->handle($this->draft($team), $admin);
$this->assertSame('INV-2026-0002', $next->number, 'the rejected re-issue must not have burned a number');
}
public function test_an_invoice_with_no_lines_cannot_be_issued(): void
{
$team = $this->team();
$admin = $this->member($team, TeamRole::Admin);
$empty = Invoice::create([
'team_id' => $team->id,
'number' => null,
'source' => BillingInvoiceSource::Manual,
'period_start' => '2026-10-01',
'period_end' => '2026-10-31',
'subtotal_cents' => 0,
'total_cents' => 0,
'currency' => 'usd',
'status' => BillingInvoiceStatus::Draft,
]);
$this->expectException(HttpException::class);
$this->expectExceptionMessage('An invoice needs at least one line before it can be issued.');
app(IssueInvoice::class)->handle($empty, $admin);
}
}
- Step 2: Run the test to verify it fails
Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing/Actions/IssueInvoiceTest.php
Expected: FAIL — Class "App\Actions\Billing\IssueInvoice" not found.
- Step 3: Write the Action
Create app/Actions/Billing/IssueInvoice.php:
<?php
namespace App\Actions\Billing;
use App\Enums\BillingInvoiceStatus;
use App\Models\Billing\BillingEvent;
use App\Models\Billing\Invoice;
use App\Models\Billing\NumberSequence;
use App\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* Issues a drafted invoice (design doc, "Increment 2 in detail" →
* "Enterprise invoicing").
*
* The number is allocated here rather than at draft time, so a draft that is
* reviewed and discarded never consumes one and the year's sequence has no
* gaps. The guard runs before the allocation for the same reason: a rejected
* issue must not burn a number.
*
* Does not flush the entitlement memo — an invoice cannot change what a team
* is allowed to do.
*/
class IssueInvoice
{
public function handle(Invoice $invoice, User $actor): Invoice
{
abort_if($invoice->status !== BillingInvoiceStatus::Draft, 422, 'Only a draft invoice can be issued.');
abort_if($invoice->lines()->count() === 0, 422, 'An invoice needs at least one line before it can be issued.');
return DB::transaction(function () use ($invoice, $actor) {
// Re-check under a row lock: the guards above read `status` in PHP,
// so two concurrent issues could both pass them. The second caller
// blocks here until the first commits, then sees `Open` and aborts
// before a number is allocated — otherwise it would burn one and
// leave a gap in the very sequence this Action keeps gapless.
$locked = Invoice::whereKey($invoice->getKey())->lockForUpdate()->firstOrFail();
abort_if($locked->status !== BillingInvoiceStatus::Draft, 422, 'Only a draft invoice can be issued.');
$issuedAt = Carbon::now();
$invoice->update([
'number' => NumberSequence::nextInvoiceNumber($issuedAt),
'status' => BillingInvoiceStatus::Open,
'issued_at' => $issuedAt,
]);
BillingEvent::record(
$invoice->team,
'invoice.issued',
['invoice_id' => $invoice->id, 'number' => $invoice->number, 'total_cents' => $invoice->total_cents],
$actor->id,
);
return $invoice->refresh();
});
}
}
- Step 4: Run the test to verify it passes
Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing/Actions/IssueInvoiceTest.php
Expected: PASS, 6 tests.
- Step 5: Format and commit
vendor/bin/pint --dirty --format agent
git add app/Actions/Billing/IssueInvoice.php tests/Feature/Billing/Actions/IssueInvoiceTest.php
git commit -m "Billing: issue an invoice and allocate its number"
Task 4: RecordPayment
Records money received against an invoice — for enterprise teams this is a bank transfer or cheque a superadmin is entering by hand — and closes the invoice once it is fully settled.
Files:
- Create:
app/Actions/Billing/RecordPayment.php - Test:
tests/Feature/Billing/Actions/RecordPaymentTest.php
Interfaces:
-
Consumes:
IssueInvoice::handle()(Task 3), used by the test to reach an open invoice. -
Produces:
App\Actions\Billing\RecordPayment::handle(Invoice $invoice, BillingPaymentMethod $method, int $amountCents, CarbonInterface $paidAt, User $actor, ?string $reference = null, ?string $notes = null): Payment. -
Step 1: Write the failing test
Create tests/Feature/Billing/Actions/RecordPaymentTest.php:
<?php
namespace Tests\Feature\Billing\Actions;
use App\Actions\Billing\IssueInvoice;
use App\Actions\Billing\RecordPayment;
use App\Enums\BillingInvoiceLineKind;
use App\Enums\BillingInvoiceSource;
use App\Enums\BillingInvoiceStatus;
use App\Enums\BillingPaymentMethod;
use App\Enums\TeamRole;
use App\Models\Billing\BillingEvent;
use App\Models\Billing\Invoice;
use App\Models\Billing\InvoiceLine;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tests\Feature\Billing\BillingTestCase;
/**
* Recording an offline payment against an invoice (design doc, "Enterprise
* plans" and "Increment 2 in detail" → "Enterprise invoicing").
*/
class RecordPaymentTest extends BillingTestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
private function openInvoice(Team $team, User $actor, int $totalCents = 450000): Invoice
{
Carbon::setTestNow('2026-09-15 10:00:00');
$invoice = Invoice::create([
'team_id' => $team->id,
'number' => null,
'source' => BillingInvoiceSource::Manual,
'period_start' => '2026-10-01',
'period_end' => '2026-10-31',
'subtotal_cents' => $totalCents,
'total_cents' => $totalCents,
'currency' => 'usd',
'status' => BillingInvoiceStatus::Draft,
]);
InvoiceLine::create([
'billing_invoice_id' => $invoice->id,
'kind' => BillingInvoiceLineKind::Module,
'description' => 'Enterprise plan — October 2026',
'quantity' => 1,
'unit_amount_cents' => $totalCents,
'amount_cents' => $totalCents,
]);
return app(IssueInvoice::class)->handle($invoice, $actor);
}
public function test_a_full_payment_settles_and_closes_the_invoice(): void
{
$team = $this->team();
$admin = $this->member($team, TeamRole::Admin);
$invoice = $this->openInvoice($team, $admin);
app(RecordPayment::class)->handle(
$invoice,
BillingPaymentMethod::BankTransfer,
450000,
Carbon::parse('2026-09-20 14:30:00'),
$admin,
'SWIFT 8842190',
);
$invoice->refresh();
$this->assertSame(BillingInvoiceStatus::Paid, $invoice->status);
$this->assertSame('2026-09-20 14:30:00', $invoice->paid_at->toDateTimeString());
$this->assertSame(0, $invoice->amountDueCents());
}
public function test_a_partial_payment_leaves_the_invoice_open(): void
{
$team = $this->team();
$admin = $this->member($team, TeamRole::Admin);
$invoice = $this->openInvoice($team, $admin);
app(RecordPayment::class)->handle(
$invoice,
BillingPaymentMethod::Cheque,
200000,
Carbon::parse('2026-09-20 14:30:00'),
$admin,
);
$invoice->refresh();
$this->assertSame(BillingInvoiceStatus::Open, $invoice->status);
$this->assertNull($invoice->paid_at);
$this->assertSame(250000, $invoice->amountDueCents());
}
public function test_two_partial_payments_together_settle_the_invoice(): void
{
$team = $this->team();
$admin = $this->member($team, TeamRole::Admin);
$invoice = $this->openInvoice($team, $admin);
app(RecordPayment::class)->handle($invoice, BillingPaymentMethod::Cheque, 200000, Carbon::parse('2026-09-20 14:30:00'), $admin);
app(RecordPayment::class)->handle($invoice->refresh(), BillingPaymentMethod::BankTransfer, 250000, Carbon::parse('2026-09-25 09:00:00'), $admin);
$invoice->refresh();
$this->assertSame(BillingInvoiceStatus::Paid, $invoice->status);
$this->assertSame('2026-09-25 09:00:00', $invoice->paid_at->toDateTimeString());
}
public function test_the_payment_records_who_entered_it(): void
{
$team = $this->team();
$admin = $this->member($team, TeamRole::Admin);
$invoice = $this->openInvoice($team, $admin);
$payment = app(RecordPayment::class)->handle(
$invoice,
BillingPaymentMethod::BankTransfer,
450000,
Carbon::parse('2026-09-20 14:30:00'),
$admin,
'SWIFT 8842190',
'Confirmed against the September statement.',
);
$this->assertSame($admin->id, $payment->recorded_by_user_id);
$this->assertSame('SWIFT 8842190', $payment->reference);
$this->assertSame('Confirmed against the September statement.', $payment->notes);
$this->assertSame($team->id, $payment->team_id);
}
public function test_recording_a_payment_writes_a_billing_event(): void
{
$team = $this->team();
$admin = $this->member($team, TeamRole::Admin);
$invoice = $this->openInvoice($team, $admin);
app(RecordPayment::class)->handle($invoice, BillingPaymentMethod::Cash, 450000, Carbon::parse('2026-09-20 14:30:00'), $admin);
$event = BillingEvent::where('type', 'payment.recorded')->firstOrFail();
$this->assertSame($admin->id, $event->actor_user_id);
$this->assertSame(450000, $event->payload['amount_cents']);
$this->assertSame('cash', $event->payload['method']);
}
public function test_a_zero_or_negative_payment_is_rejected(): void
{
$team = $this->team();
$admin = $this->member($team, TeamRole::Admin);
$invoice = $this->openInvoice($team, $admin);
$this->expectException(HttpException::class);
$this->expectExceptionMessage('A payment must be for a positive amount.');
app(RecordPayment::class)->handle($invoice, BillingPaymentMethod::Cash, 0, Carbon::parse('2026-09-20 14:30:00'), $admin);
}
public function test_a_voided_invoice_cannot_take_a_payment(): void
{
$team = $this->team();
$admin = $this->member($team, TeamRole::Admin);
$invoice = $this->openInvoice($team, $admin);
$invoice->update(['status' => BillingInvoiceStatus::Void]);
$this->expectException(HttpException::class);
$this->expectExceptionMessage('A voided invoice cannot take a payment.');
app(RecordPayment::class)->handle($invoice, BillingPaymentMethod::Cash, 450000, Carbon::parse('2026-09-20 14:30:00'), $admin);
}
}
- Step 2: Run the test to verify it fails
Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing/Actions/RecordPaymentTest.php
Expected: FAIL — Class "App\Actions\Billing\RecordPayment" not found.
- Step 3: Write the Action
Create app/Actions/Billing/RecordPayment.php:
<?php
namespace App\Actions\Billing;
use App\Enums\BillingInvoiceStatus;
use App\Enums\BillingPaymentMethod;
use App\Models\Billing\BillingEvent;
use App\Models\Billing\Invoice;
use App\Models\Billing\Payment;
use App\Models\User;
use Carbon\CarbonInterface;
use Illuminate\Support\Facades\DB;
/**
* Records money received against an invoice (design doc, "Enterprise plans").
*
* For enterprise customers this is a superadmin entering a bank transfer or
* cheque by hand; Stripe payments are mirrored the same way from webhooks in
* a later increment. The invoice closes only when the payments fully settle
* it, so partial payments accumulate rather than prematurely marking it paid.
*
* Does not flush the entitlement memo: in this increment a payment does not
* move a team's access state. Enterprise teams are always `active`, and
* self-serve recovery from `past_due` runs through Stripe webhooks in
* increment 5 — which will have to flush.
*/
class RecordPayment
{
public function handle(
Invoice $invoice,
BillingPaymentMethod $method,
int $amountCents,
CarbonInterface $paidAt,
User $actor,
?string $reference = null,
?string $notes = null,
): Payment {
abort_if($amountCents <= 0, 422, 'A payment must be for a positive amount.');
abort_if($invoice->status === BillingInvoiceStatus::Void, 422, 'A voided invoice cannot take a payment.');
return DB::transaction(function () use ($invoice, $method, $amountCents, $paidAt, $actor, $reference, $notes) {
// Serialise concurrent payments on the same invoice. Without this,
// two payments that together settle the invoice can each SUM only
// their own row — neither sees the other, neither closes the
// invoice, and it stays `Open` while being fully paid.
$locked = Invoice::whereKey($invoice->getKey())->lockForUpdate()->firstOrFail();
// Re-check the locked row, not the caller's stale copy: the Void
// guard above ran before the lock, so the invoice could have been
// voided in between. Without this a payment can resurrect a voided
// invoice to `Paid`.
abort_if($locked->status === BillingInvoiceStatus::Void, 422, 'A voided invoice cannot take a payment.');
$payment = Payment::create([
'team_id' => $invoice->team_id,
'billing_invoice_id' => $invoice->id,
'method' => $method,
'amount_cents' => $amountCents,
'paid_at' => $paidAt,
'reference' => $reference,
'recorded_by_user_id' => $actor->id,
'notes' => $notes,
]);
if ($invoice->refresh()->amountDueCents() === 0) {
$invoice->update([
'status' => BillingInvoiceStatus::Paid,
'paid_at' => $paidAt,
]);
}
BillingEvent::record(
$invoice->team,
'payment.recorded',
[
'invoice_id' => $invoice->id,
'payment_id' => $payment->id,
'amount_cents' => $amountCents,
'method' => $method->value,
],
$actor->id,
);
return $payment;
});
}
}
- Step 4: Run the test to verify it passes
Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing/Actions/RecordPaymentTest.php
Expected: PASS, 7 tests.
- Step 5: Format and commit
vendor/bin/pint --dirty --format agent
git add app/Actions/Billing/RecordPayment.php tests/Feature/Billing/Actions/RecordPaymentTest.php
git commit -m "Billing: record an offline payment against an invoice"
Task 5: SaveEnterprisePlan
The Action behind "convert to enterprise" and "edit enterprise terms" — one path, because converting is just setting the terms on a team that did not have them.
Files:
- Create:
app/Actions/Billing/SaveEnterprisePlan.php - Test:
tests/Feature/Billing/Actions/SaveEnterprisePlanTest.php
Interfaces:
-
Consumes:
App\Enums\BillingCycle(Task 1). -
Produces:
App\Actions\Billing\SaveEnterprisePlan::handle(Team $team, int $amountCents, BillingCycle $interval, User $actor, ?CarbonInterface $renewalAt = null, ?int $seatCap = null, ?int $storageQuotaBytes = null, ?string $billingEmail = null, ?string $notes = null): Team. -
Step 1: Write the failing test
Create tests/Feature/Billing/Actions/SaveEnterprisePlanTest.php:
<?php
namespace Tests\Feature\Billing\Actions;
use App\Actions\Billing\SaveEnterprisePlan;
use App\Enums\BillingAccessState;
use App\Enums\BillingCycle;
use App\Enums\BillingType;
use App\Enums\TeamRole;
use App\Models\Billing\BillingEvent;
use App\Support\Billing\Entitlements;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tests\Feature\Billing\BillingTestCase;
/**
* Setting a team's negotiated enterprise terms (design doc, "Enterprise
* plans").
*/
class SaveEnterprisePlanTest extends BillingTestCase
{
use RefreshDatabase;
public function test_it_sets_the_terms_and_marks_the_team_enterprise(): void
{
$team = $this->team();
$admin = $this->member($team, TeamRole::Admin);
$saved = app(SaveEnterprisePlan::class)->handle(
$team,
450000,
BillingCycle::Monthly,
$admin,
Carbon::parse('2027-01-01'),
25,
53687091200,
'[email protected]',
'Renewal handled by the account manager.',
);
$this->assertSame(BillingType::Enterprise, $saved->billing_type);
$this->assertSame(450000, $saved->billing_enterprise_amount_cents);
$this->assertSame(BillingCycle::Monthly, $saved->billing_enterprise_interval);
$this->assertSame('2027-01-01', $saved->billing_renewal_at->toDateString());
$this->assertSame(25, $saved->billing_seat_cap);
$this->assertSame(53687091200, $saved->billing_storage_quota_bytes);
$this->assertSame('[email protected]', $saved->billing_email);
$this->assertSame('Renewal handled by the account manager.', $saved->billing_notes);
}
public function test_converting_a_self_serve_team_switches_its_type(): void
{
$team = $this->team();
$team->forceFill(['billing_type' => BillingType::SelfServe])->save();
$admin = $this->member($team, TeamRole::Admin);
$saved = app(SaveEnterprisePlan::class)->handle($team->fresh(), 900000, BillingCycle::Yearly, $admin);
$this->assertSame(BillingType::Enterprise, $saved->billing_type);
}
public function test_optional_terms_may_be_cleared_back_to_unlimited(): void
{
$team = $this->team();
$admin = $this->member($team, TeamRole::Admin);
app(SaveEnterprisePlan::class)->handle($team, 450000, BillingCycle::Monthly, $admin, null, 25, 53687091200);
$saved = app(SaveEnterprisePlan::class)->handle($team->fresh(), 450000, BillingCycle::Monthly, $admin);
$this->assertNull($saved->billing_seat_cap);
$this->assertNull($saved->billing_storage_quota_bytes);
}
public function test_it_does_not_change_the_teams_access_state(): void
{
$team = $this->team();
$team->forceFill(['billing_access_state' => BillingAccessState::Suspended])->save();
$admin = $this->member($team, TeamRole::Admin);
$saved = app(SaveEnterprisePlan::class)->handle($team->fresh(), 450000, BillingCycle::Monthly, $admin);
$this->assertSame(BillingAccessState::Suspended, $saved->billing_access_state, 'editing terms must not silently un-suspend a team');
}
public function test_it_records_a_billing_event(): void
{
$team = $this->team();
$admin = $this->member($team, TeamRole::Admin);
app(SaveEnterprisePlan::class)->handle($team, 450000, BillingCycle::Monthly, $admin);
$event = BillingEvent::where('type', 'plan.enterprise_saved')->firstOrFail();
$this->assertSame($team->id, $event->team_id);
$this->assertSame($admin->id, $event->actor_user_id);
$this->assertSame(450000, $event->payload['amount_cents']);
$this->assertSame('monthly', $event->payload['interval']);
}
public function test_it_flushes_the_entitlement_memo(): void
{
$team = $this->team();
$admin = $this->member($team, TeamRole::Admin);
// Resolve once so the memo is populated with the pre-change quota.
$this->assertNull(Entitlements::for($team)->seatCap);
app(SaveEnterprisePlan::class)->handle($team, 450000, BillingCycle::Monthly, $admin, null, 25);
$this->assertSame(25, Entitlements::for($team->fresh())->seatCap, 'a stale memo would still report no seat cap');
}
public function test_a_negative_amount_is_rejected(): void
{
$team = $this->team();
$admin = $this->member($team, TeamRole::Admin);
$this->expectException(HttpException::class);
$this->expectExceptionMessage('An enterprise amount cannot be negative.');
app(SaveEnterprisePlan::class)->handle($team, -1, BillingCycle::Monthly, $admin);
}
}
- Step 2: Run the test to verify it fails
Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing/Actions/SaveEnterprisePlanTest.php
Expected: FAIL — Class "App\Actions\Billing\SaveEnterprisePlan" not found.
- Step 3: Write the Action
Create app/Actions/Billing/SaveEnterprisePlan.php:
<?php
namespace App\Actions\Billing;
use App\Enums\BillingCycle;
use App\Enums\BillingType;
use App\Models\Billing\BillingEvent;
use App\Models\Team;
use App\Models\User;
use App\Support\Billing\Entitlements;
use Carbon\CarbonInterface;
use Illuminate\Support\Facades\DB;
/**
* Sets a team's negotiated enterprise terms (design doc, "Enterprise plans").
*
* Converting a team to enterprise and editing an existing enterprise team's
* terms are the same operation — converting is only setting terms on a team
* that had none — so there is deliberately one Action rather than two.
*
* The optional arguments are written unconditionally, including when null,
* so an operator can clear a seat cap or a storage quota back to unlimited.
* `billing_access_state` is left alone: editing commercial terms must never
* silently un-suspend a team, which is `RestoreTeam`'s job.
*/
class SaveEnterprisePlan
{
public function handle(
Team $team,
int $amountCents,
BillingCycle $interval,
User $actor,
?CarbonInterface $renewalAt = null,
?int $seatCap = null,
?int $storageQuotaBytes = null,
?string $billingEmail = null,
?string $notes = null,
): Team {
abort_if($amountCents < 0, 422, 'An enterprise amount cannot be negative.');
abort_if($seatCap !== null && $seatCap < 1, 422, 'A seat cap must be at least one seat.');
abort_if($storageQuotaBytes !== null && $storageQuotaBytes < 0, 422, 'A storage quota cannot be negative.');
return DB::transaction(function () use (
$team, $amountCents, $interval, $actor, $renewalAt, $seatCap, $storageQuotaBytes, $billingEmail, $notes
) {
$team->forceFill([
'billing_type' => BillingType::Enterprise,
'billing_enterprise_amount_cents' => $amountCents,
'billing_enterprise_interval' => $interval,
'billing_renewal_at' => $renewalAt,
'billing_seat_cap' => $seatCap,
'billing_storage_quota_bytes' => $storageQuotaBytes,
'billing_email' => $billingEmail,
'billing_notes' => $notes,
])->save();
BillingEvent::record(
$team,
'plan.enterprise_saved',
[
'amount_cents' => $amountCents,
'interval' => $interval->value,
'renewal_at' => $renewalAt?->toDateString(),
'seat_cap' => $seatCap,
'storage_quota_bytes' => $storageQuotaBytes,
],
$actor->id,
);
Entitlements::flush();
return $team->refresh();
});
}
}
- Step 4: Run the test to verify it passes
Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing/Actions/SaveEnterprisePlanTest.php
Expected: PASS, 7 tests.
- Step 5: Format and commit
vendor/bin/pint --dirty --format agent
git add app/Actions/Billing/SaveEnterprisePlan.php tests/Feature/Billing/Actions/SaveEnterprisePlanTest.php
git commit -m "Billing: save a team's enterprise terms"
Task 6: SetTeamModules
Grants and revokes the modules an enterprise team may reach.
Files:
- Create:
app/Actions/Billing/SetTeamModules.php - Test:
tests/Feature/Billing/Actions/SetTeamModulesTest.php
Interfaces:
-
Consumes:
modulesAllowedbeing mass-assignable (Task 1). -
Produces:
App\Actions\Billing\SetTeamModules::handle(Team $team, array $moduleKeys, User $actor): Team, where$moduleKeysis alist<string>of the keys to allow. -
Step 1: Write the failing test
Create tests/Feature/Billing/Actions/SetTeamModulesTest.php:
<?php
namespace Tests\Feature\Billing\Actions;
use App\Actions\Billing\SetTeamModules;
use App\Enums\TeamRole;
use App\Models\Billing\BillingEvent;
use App\Models\System\Module;
use App\Support\Modules;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tests\Feature\Billing\BillingTestCase;
/**
* Granting and revoking a team's modules (design doc, "Enterprise plans").
*/
class SetTeamModulesTest extends BillingTestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
// `System\Module` reads the default connection, which is an empty
// in-memory SQLite database in tests.
foreach ([
['key' => 'crm', 'name' => 'CRM', 'order' => 1],
['key' => 'warehouse', 'name' => 'Warehouse', 'order' => 2],
['key' => 'logistics3p', 'name' => '3PL', 'order' => 3],
] as $module) {
Module::create($module + ['menu' => true, 'status' => true]);
}
}
public function test_granted_modules_become_allowed_and_the_rest_denied(): void
{
$team = $this->team();
$admin = $this->member($team, TeamRole::Admin);
$saved = app(SetTeamModules::class)->handle($team, ['crm', 'logistics3p'], $admin);
$this->assertSame(
['crm' => true, 'warehouse' => false, 'logistics3p' => true],
$saved->modulesAllowed,
);
}
public function test_every_known_module_is_written_explicitly_never_omitted(): void
{
$team = $this->team();
$admin = $this->member($team, TeamRole::Admin);
$saved = app(SetTeamModules::class)->handle($team, ['crm'], $admin);
$this->assertEqualsCanonicalizing(
['crm', 'warehouse', 'logistics3p'],
array_keys($saved->modulesAllowed),
'an omitted key would fall back to "no restriction" and grant the module',
);
}
public function test_the_module_gate_reflects_the_grant(): void
{
$team = $this->team();
$admin = $this->member($team, TeamRole::Admin);
app(SetTeamModules::class)->handle($team, ['crm'], $admin);
$this->assertTrue(Modules::isAllowedForTeam($team->fresh(), 'crm'));
$this->assertFalse(Modules::isAllowedForTeam($team->fresh(), 'warehouse'));
}
public function test_granting_nothing_denies_everything_rather_than_allowing_everything(): void
{
$team = $this->team();
$admin = $this->member($team, TeamRole::Admin);
$saved = app(SetTeamModules::class)->handle($team, [], $admin);
$this->assertSame([], Modules::allowedForTeam($saved));
$this->assertFalse(Modules::isAllowedForTeam($saved, 'crm'));
}
public function test_an_unknown_module_key_is_rejected(): void
{
$team = $this->team();
$admin = $this->member($team, TeamRole::Admin);
$this->expectException(HttpException::class);
$this->expectExceptionMessage('Unknown module: nonsense.');
app(SetTeamModules::class)->handle($team, ['crm', 'nonsense'], $admin);
}
public function test_it_records_a_billing_event(): void
{
$team = $this->team();
$admin = $this->member($team, TeamRole::Admin);
app(SetTeamModules::class)->handle($team, ['crm'], $admin);
$event = BillingEvent::where('type', 'plan.modules_set')->firstOrFail();
$this->assertSame($admin->id, $event->actor_user_id);
$this->assertSame(['crm'], $event->payload['allowed']);
}
}
- Step 2: Run the test to verify it fails
Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing/Actions/SetTeamModulesTest.php
Expected: FAIL — Class "App\Actions\Billing\SetTeamModules" not found.
- Step 3: Write the Action
Create app/Actions/Billing/SetTeamModules.php:
<?php
namespace App\Actions\Billing;
use App\Models\Billing\BillingEvent;
use App\Models\System\Module;
use App\Models\Team;
use App\Models\User;
use App\Support\Billing\Entitlements;
use Illuminate\Support\Facades\DB;
/**
* Sets which modules a team may reach (design doc, "Enterprise plans").
*
* Writes an explicit true/false for **every** known module rather than only
* the granted ones. That is load-bearing, not verbosity: `Modules::
* allowedForTeam()` treats an empty `modulesAllowed` as "no restriction", so
* a grant of nothing stored as `[]` would silently allow *everything*. A full
* map cannot be empty, so revoking every module denies every module.
*/
class SetTeamModules
{
/**
* @param list<string> $moduleKeys the module keys to allow; every other
* known module is explicitly denied
*/
public function handle(Team $team, array $moduleKeys, User $actor): Team
{
$known = Module::query()->whereNotNull('key')->pluck('key')->all();
foreach ($moduleKeys as $key) {
abort_if(! in_array($key, $known, true), 422, "Unknown module: {$key}.");
}
return DB::transaction(function () use ($team, $moduleKeys, $actor, $known) {
$map = [];
foreach ($known as $key) {
$map[$key] = in_array($key, $moduleKeys, true);
}
$team->update(['modulesAllowed' => $map]);
BillingEvent::record(
$team,
'plan.modules_set',
['allowed' => array_values($moduleKeys)],
$actor->id,
);
Entitlements::flush();
return $team->refresh();
});
}
}
- Step 4: Run the test to verify it passes
Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing/Actions/SetTeamModulesTest.php
Expected: PASS, 6 tests.
- Step 5: Format and commit
vendor/bin/pint --dirty --format agent
git add app/Actions/Billing/SetTeamModules.php tests/Feature/Billing/Actions/SetTeamModulesTest.php
git commit -m "Billing: grant and revoke a team's modules"
Task 7: ExtendGrace, SuspendTeam and RestoreTeam
Three small same-shape Actions over the team's access state, built and reviewed together.
Files:
- Create:
app/Actions/Billing/ExtendGrace.php - Create:
app/Actions/Billing/SuspendTeam.php - Create:
app/Actions/Billing/RestoreTeam.php - Test:
tests/Feature/Billing/Actions/TeamAccessStateActionsTest.php
Interfaces:
-
Consumes:
App\Enums\BillingAccessState(increment 1). -
Produces:
ExtendGrace::handle(Team $team, CarbonInterface $graceEndsAt, User $actor): TeamSuspendTeam::handle(Team $team, User $actor, ?string $reason = null): TeamRestoreTeam::handle(Team $team, User $actor): Team
-
Step 1: Write the failing test
Create tests/Feature/Billing/Actions/TeamAccessStateActionsTest.php:
<?php
namespace Tests\Feature\Billing\Actions;
use App\Actions\Billing\ExtendGrace;
use App\Actions\Billing\RestoreTeam;
use App\Actions\Billing\SuspendTeam;
use App\Enums\BillingAccessState;
use App\Enums\TeamRole;
use App\Models\Billing\BillingEvent;
use App\Support\Billing\Entitlements;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tests\Feature\Billing\BillingTestCase;
/**
* The three Actions over a team's access state (design doc, "Access state
* machine" and "Dunning").
*/
class TeamAccessStateActionsTest extends BillingTestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
public function test_extending_grace_moves_the_deadline(): void
{
Carbon::setTestNow('2026-09-15 10:00:00');
$team = $this->team();
$team->forceFill([
'billing_access_state' => BillingAccessState::PastDue,
'billing_grace_ends_at' => '2026-09-20 10:00:00',
])->save();
$admin = $this->member($team, TeamRole::Admin);
$saved = app(ExtendGrace::class)->handle($team->fresh(), Carbon::parse('2026-10-05 10:00:00'), $admin);
$this->assertSame('2026-10-05 10:00:00', $saved->billing_grace_ends_at->toDateTimeString());
$this->assertSame(BillingAccessState::PastDue, $saved->billing_access_state);
}
public function test_extending_grace_on_a_restricted_team_returns_it_to_past_due(): void
{
Carbon::setTestNow('2026-09-15 10:00:00');
$team = $this->team();
$team->forceFill([
'billing_access_state' => BillingAccessState::Restricted,
'billing_restricted_at' => '2026-09-14 10:00:00',
'billing_grace_ends_at' => '2026-09-14 10:00:00',
])->save();
$admin = $this->member($team, TeamRole::Admin);
// Prime the memo while the team is still Restricted — without the
// flush inside ExtendGrace, the final assertion would answer from
// this stale entitlement and still report access denied.
$this->assertFalse(Entitlements::for($team->fresh())->grantsAppAccess());
$saved = app(ExtendGrace::class)->handle($team->fresh(), Carbon::parse('2026-10-05 10:00:00'), $admin);
$this->assertSame(BillingAccessState::PastDue, $saved->billing_access_state);
$this->assertNull($saved->billing_restricted_at);
$this->assertTrue(Entitlements::for($saved)->grantsAppAccess());
}
public function test_grace_cannot_be_extended_into_the_past(): void
{
Carbon::setTestNow('2026-09-15 10:00:00');
$team = $this->team();
$admin = $this->member($team, TeamRole::Admin);
$this->expectException(HttpException::class);
$this->expectExceptionMessage('A grace period must end in the future.');
app(ExtendGrace::class)->handle($team, Carbon::parse('2026-09-14 10:00:00'), $admin);
}
public function test_suspending_removes_access_and_stamps_the_time(): void
{
Carbon::setTestNow('2026-09-15 10:00:00');
$team = $this->team();
$admin = $this->member($team, TeamRole::Admin);
// Prime the memo while the team still has access.
$this->assertTrue(Entitlements::for($team)->grantsAppAccess());
$saved = app(SuspendTeam::class)->handle($team, $admin, 'Non-payment after three reminders.');
$this->assertSame(BillingAccessState::Suspended, $saved->billing_access_state);
$this->assertSame('2026-09-15 10:00:00', $saved->billing_restricted_at->toDateTimeString());
$this->assertFalse(Entitlements::for($saved)->grantsAppAccess());
}
public function test_suspending_records_the_reason(): void
{
Carbon::setTestNow('2026-09-15 10:00:00');
$team = $this->team();
$admin = $this->member($team, TeamRole::Admin);
app(SuspendTeam::class)->handle($team, $admin, 'Non-payment after three reminders.');
$event = BillingEvent::where('type', 'team.suspended')->firstOrFail();
$this->assertSame($admin->id, $event->actor_user_id);
$this->assertSame('Non-payment after three reminders.', $event->payload['reason']);
}
public function test_an_already_suspended_team_cannot_be_suspended_again(): void
{
Carbon::setTestNow('2026-09-15 10:00:00');
$team = $this->team();
$admin = $this->member($team, TeamRole::Admin);
app(SuspendTeam::class)->handle($team, $admin);
$this->expectException(HttpException::class);
$this->expectExceptionMessage('This team is already suspended.');
app(SuspendTeam::class)->handle($team->fresh(), $admin);
}
public function test_restoring_returns_the_team_to_active(): void
{
Carbon::setTestNow('2026-09-15 10:00:00');
$team = $this->team();
$admin = $this->member($team, TeamRole::Admin);
app(SuspendTeam::class)->handle($team, $admin);
// Prime the memo while the team is suspended.
$this->assertFalse(Entitlements::for($team->fresh())->grantsAppAccess());
$saved = app(RestoreTeam::class)->handle($team->fresh(), $admin);
$this->assertSame(BillingAccessState::Active, $saved->billing_access_state);
$this->assertNull($saved->billing_restricted_at);
$this->assertTrue(Entitlements::for($saved)->grantsAppAccess());
}
public function test_only_a_suspended_team_can_be_restored(): void
{
$team = $this->team();
$admin = $this->member($team, TeamRole::Admin);
$this->expectException(HttpException::class);
$this->expectExceptionMessage('Only a suspended team can be restored.');
app(RestoreTeam::class)->handle($team, $admin);
}
}
- Step 2: Run the test to verify it fails
Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing/Actions/TeamAccessStateActionsTest.php
Expected: FAIL — Class "App\Actions\Billing\ExtendGrace" not found.
- Step 3: Write
ExtendGrace
Create app/Actions/Billing/ExtendGrace.php:
<?php
namespace App\Actions\Billing;
use App\Enums\BillingAccessState;
use App\Models\Billing\BillingEvent;
use App\Models\Team;
use App\Models\User;
use App\Support\Billing\Entitlements;
use Carbon\CarbonInterface;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* Extends a team's payment grace period (design doc, "Dunning").
*
* A team already tipped into `Restricted` comes back to `PastDue`, because
* the whole point of extending grace is to give a customer their access back
* while the payment problem is sorted out. The daily retry loop keeps running
* for as long as `billing_grace_ends_at` is in the future, so moving the date
* is all that is needed to resume it.
*/
class ExtendGrace
{
public function handle(Team $team, CarbonInterface $graceEndsAt, User $actor): Team
{
abort_if($graceEndsAt->lessThanOrEqualTo(Carbon::now()), 422, 'A grace period must end in the future.');
return DB::transaction(function () use ($team, $graceEndsAt, $actor) {
$wasRestricted = $team->billing_access_state === BillingAccessState::Restricted;
$team->forceFill([
'billing_grace_ends_at' => $graceEndsAt,
'billing_access_state' => $wasRestricted ? BillingAccessState::PastDue : $team->billing_access_state,
'billing_restricted_at' => $wasRestricted ? null : $team->billing_restricted_at,
])->save();
BillingEvent::record(
$team,
'grace.extended',
['grace_ends_at' => $graceEndsAt->toDateTimeString(), 'lifted_restriction' => $wasRestricted],
$actor->id,
);
Entitlements::flush();
return $team->refresh();
});
}
}
- Step 4: Write
SuspendTeam
Create app/Actions/Billing/SuspendTeam.php:
<?php
namespace App\Actions\Billing;
use App\Enums\BillingAccessState;
use App\Models\Billing\BillingEvent;
use App\Models\Team;
use App\Models\User;
use App\Support\Billing\Entitlements;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* Suspends a team (design doc, "Access state machine").
*
* `Suspended` is the one state a team cannot leave on its own — unlike
* `Restricted`, which a payment clears. It is the operator's manual override,
* so only `RestoreTeam` reverses it.
*/
class SuspendTeam
{
public function handle(Team $team, User $actor, ?string $reason = null): Team
{
abort_if($team->billing_access_state === BillingAccessState::Suspended, 422, 'This team is already suspended.');
return DB::transaction(function () use ($team, $actor, $reason) {
$previous = $team->billing_access_state;
$team->forceFill([
'billing_access_state' => BillingAccessState::Suspended,
'billing_restricted_at' => Carbon::now(),
])->save();
BillingEvent::record(
$team,
'team.suspended',
['reason' => $reason, 'previous_state' => $previous?->value],
$actor->id,
);
Entitlements::flush();
return $team->refresh();
});
}
}
- Step 5: Write
RestoreTeam
Create app/Actions/Billing/RestoreTeam.php:
<?php
namespace App\Actions\Billing;
use App\Enums\BillingAccessState;
use App\Models\Billing\BillingEvent;
use App\Models\Team;
use App\Models\User;
use App\Support\Billing\Entitlements;
use Illuminate\Support\Facades\DB;
/**
* Lifts a suspension (design doc, "Access state machine").
*
* Returns the team to `Active` rather than to whatever state it held before
* being suspended: an operator restoring a team is deciding it may work
* again, and reinstating a stale `PastDue` would hand it straight back to the
* dunning loop over a payment problem that may long since have been settled.
*/
class RestoreTeam
{
public function handle(Team $team, User $actor): Team
{
abort_if($team->billing_access_state !== BillingAccessState::Suspended, 422, 'Only a suspended team can be restored.');
return DB::transaction(function () use ($team, $actor) {
$team->forceFill([
'billing_access_state' => BillingAccessState::Active,
'billing_restricted_at' => null,
])->save();
BillingEvent::record($team, 'team.restored', [], $actor->id);
Entitlements::flush();
return $team->refresh();
});
}
}
- Step 6: Run the test to verify it passes
Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing/Actions/TeamAccessStateActionsTest.php
Expected: PASS, 8 tests.
- Step 7: Run the whole billing suite
Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing
Expected: PASS.
- Step 8: Prove the branch changed nothing else
Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Support tests/Feature/Teams tests/Feature/Auth tests/Feature/DashboardTest.php tests/Feature/Containers
Expected: PASS except the two Containers\CashierInvoiceDischargeListTest ViteException failures. ModulesMenuFilterTest must now be green — Task 1 fixed it.
- Step 9: Static analysis
Run: herd php -d memory_limit=1G vendor/bin/phpstan analyse
Expected: no new errors in any app/Actions/Billing/, app/Enums/BillingCycle.php, app/Models/Billing/ or app/Models/Team.php file. (There are ~127 pre-existing errors elsewhere; leave them.)
- Step 10: Format and commit
vendor/bin/pint --dirty --format agent
git add app/Actions/Billing tests/Feature/Billing/Actions
git commit -m "Billing: extend grace, suspend and restore a team"
Done when
herd php -d memory_limit=2G artisan test --compact tests/Feature/Billingpasses.Support\ModulesMenuFilterTest::test_the_team_level_allow_list_hides_disallowed_modulesis green — it fails onmaintoday and Task 1 fixes it, taking the repo baseline from 25 failures to 24.herd php -d memory_limit=2G vendor/bin/phpunitshows 24 failures, all pre-existing, none mentioning Billing.vendor/bin/pint --testis clean on every changed file.- No route, controller, or Vue file was touched — this sub-increment adds no UI.
Deliberately not in this sub-increment
- Every route, controller and Vue page of the console (2b, 2c, 2d).
billing:draft-enterprise-invoices(2d).- The MRR/ARR query —
BillingCycle::monthlyEquivalentCents()is built and tested here because it is the enum's own behaviour, but nothing sums it until 2c. - Voiding an invoice.
RecordPaymentrefuses a voided invoice, but the Action that voids one is not needed until the Team-detail screen in 2b. - Any Stripe call, webhook, or charge (increments 4 and 5).
Product::flushCache()calls — no Action here writesbilling_products; the Rates screen in 2d needs them.