OCTO Ops Help User guides and product documentation

2026 09 10 Billing 2A Foundations

On this page 12

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 php on this machine is MAMP's PHP 8.2 and too old. Every Artisan command runs as herd php artisan .... Composer is herd php /Applications/MAMP/bin/php/composer <args>.
  • Tests: herd php -d memory_limit=2G artisan test --compact <path>. Note that artisan test spawns child processes that do NOT inherit -d memory_limit; to run the entire suite use herd php -d memory_limit=2G vendor/bin/phpunit instead.
  • No new model factories. TeamFactory, UserFactory and TeamInvitationFactory already exist and may be used. Build billing rows with explicit Model::create([...]) calls carrying realistic data.
  • All billing tables are MySQL (the default connection; sqlite in tests). Never MongoDB. No MongoDB transactions anywhere.
  • No BelongsToOrganization on 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 agent before every commit.
  • Model conventions: protected $guarded = [];, a protected $casts = [] array property (not a method), a full @property docblock, TitleCase enum cases, explicit return types and parameter type hints, curly braces on all control structures, PHPDoc blocks over inline comments. App\Models\Team is the one exception — it uses a casts() method; follow the file.
  • Action conventions: a single public handle() method, guards via abort_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. See app/Actions/Sales/Quotations/AcceptQuotation.php for 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\BillingCycle with cases Monthly = 'monthly', Yearly = 'yearly', and methods monthsPerPeriod(): int, monthlyEquivalentCents(int $amountCents): int, label(): string.
    • Team::$billing_cycle and Team::$billing_enterprise_interval cast to BillingCycle.
    • modulesAllowed present in Team's #[Fillable] attribute.
  • Step 1: Write the failing test

Create tests/Feature/Billing/BillingCycleTest.php:

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
<?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 modulesAllowed fillable

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):

php
            'billing_cycle' => BillingCycle::class,
            'billing_enterprise_interval' => BillingCycle::class,

Change the #[Fillable] attribute to include the column:

php
#[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:

php
/**
 * `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
bash
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 @property line for number)
  • Test: tests/Feature/Billing/InvoiceNumberSequenceTest.php

Interfaces:

  • Consumes: nothing.

  • Produces:

    • Table billing_number_sequences (id, scope unique, next_value, timestamps).
    • App\Models\Billing\NumberSequence with NumberSequence::next(string $scope): int and NumberSequence::nextInvoiceNumber(CarbonInterface $issuedAt): string.
    • billing_invoices.number nullable.
  • Step 1: Write the failing test

Create tests/Feature/Billing/InvoiceNumberSequenceTest.php:

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
<?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
<?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
<?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:

php
 * @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
bash
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
<?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
<?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
bash
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
<?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
<?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
bash
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
<?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
<?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
bash
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: modulesAllowed being mass-assignable (Task 1).

  • Produces: App\Actions\Billing\SetTeamModules::handle(Team $team, array $moduleKeys, User $actor): Team, where $moduleKeys is a list<string> of the keys to allow.

  • Step 1: Write the failing test

Create tests/Feature/Billing/Actions/SetTeamModulesTest.php:

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
<?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
bash
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): Team
    • SuspendTeam::handle(Team $team, User $actor, ?string $reason = null): Team
    • RestoreTeam::handle(Team $team, User $actor): Team
  • Step 1: Write the failing test

Create tests/Feature/Billing/Actions/TeamAccessStateActionsTest.php:

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
<?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
<?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
<?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
bash
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/Billing passes.
  • Support\ModulesMenuFilterTest::test_the_team_level_allow_list_hides_disallowed_modules is green — it fails on main today and Task 1 fixes it, taking the repo baseline from 25 failures to 24.
  • herd php -d memory_limit=2G vendor/bin/phpunit shows 24 failures, all pre-existing, none mentioning Billing.
  • vendor/bin/pint --test is 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. RecordPayment refuses 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 writes billing_products; the Rates screen in 2d needs them.