OCTO Ops Help User guides and product documentation

2026 09 09 Billing Foundation

On this page 12

Billing Foundation (Increment 1) 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: Put the entire billing data model, the Cashier integration and the entitlement resolver in place, wired into module visibility, with every existing team landing on a manually-managed enterprise plan and no user-visible change whatsoever.

Architecture: Three layers kept separate — Stripe/DB as the plan source of truth, a cached Entitlements value object as the single thing the application asks about permissions, and append-mostly ledger tables recording what happened. This increment builds all three but wires only the entitlement layer into live code, via the existing App\Support\Modules. Nothing charges money, nothing restricts access, no route changes, no frontend changes.

Tech Stack: Laravel 13, PHP 8.4, laravel/cashier ^16.8 (Cashier Stripe), MySQL for all billing tables (never MongoDB), PHPUnit 12, Pint, Larastan.

Spec: docs/superpowers/specs/2026-09-09-saas-monetization-design.md

Global Constraints

  • PHP binary: plain php on this machine resolves to MAMP's PHP 8.2, which is too old. Every Artisan and Composer command must run under Herd's PHP: herd php artisan .... The Composer phar is at /Applications/MAMP/bin/php/composer, so Composer commands are herd php /Applications/MAMP/bin/php/composer <args>.
  • Tests: herd php -d memory_limit=2G artisan test --compact <path>. The suite needs the raised memory limit.
  • No new model factories. TeamFactory, UserFactory and TeamInvitationFactory already exist and may be used. Do not create factories for any billing model — 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 — the test mongod is a standalone node and DB::connection('mongodb')->transaction() throws.
  • No BelongsToOrganization on any billing model. Billing is platform-level data read across teams by a superadmin; the tenant global scope would hide it.
  • Never cast a MongoDB model field as 'array'. No billing model is MongoDB, so this does not bite here, but do not introduce one.
  • 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.
  • Package versions are fixed. Do not add any dependency other than laravel/cashier (which pulls stripe/stripe-php and moneyphp/money transitively).
  • Module keys: the two specialized modules are logistics3p (module id 21) and containers (module id 34), exactly as seeded in database/seeders/ModulesSeeder.php.
  • Prices: seat 10/month, specialized module750/month, annual = 10x the monthly amount. Currency USD. Amounts are stored as integer cents throughout — never floats.
  • Storage unit: uploads.fileSize is stored in kilobytes. The billing columns store bytes. 10 GB = 10 * 1024 * 1024 * 1024 bytes.

Deviation from the spec, deliberate

The spec gives teams.billing_type a default of self_serve and billing_access_state a default of trialing. This plan defaults them to enterprise and active instead.

Reason: adding a column with a default backfills every existing row with that default, which is the "every existing team becomes enterprise/active" migration the spec calls for — no separate data migration needed, and no window in which a team sits in a self-serve state that nothing manages yet. Increment 4 (self-serve subscription) is what changes the default and sets the correct initial state at signup; until then, every team in the system is enterprise/active and provably unaffected by billing.


Task 1: Install Cashier and clear the dormant Spark schema

Installs laravel/cashier, drops the three dead 2016 MySQL tables so Cashier can use its own stock table names, and registers Team as the billable model.

Files:

  • Modify: composer.json (via composer require)
  • Create: config/cashier.php (published)
  • Create: database/migrations/2026_09_09_100000_drop_dormant_spark_billing_tables.php
  • Create: database/migrations/2026_09_09_100200_create_subscriptions_table.php (published, renamed, edited)
  • Create: database/migrations/2026_09_09_100300_create_subscription_items_table.php (published, renamed)
  • Modify: app/Models/Team.php
  • Modify: app/Providers/AppServiceProvider.php
  • Modify: .env.example
  • Test: tests/Feature/Billing/CashierInstallationTest.php

Interfaces:

  • Consumes: nothing.

  • Produces: App\Models\Team uses Laravel\Cashier\Billable, so $team->subscriptions, $team->subscription('default'), $team->stripe_id and $team->hasStripeId() exist. Cashier::$customerModel === Team::class. Tables subscriptions (keyed on team_id) and subscription_items exist.

  • Step 1: Write the failing test

Create tests/Feature/Billing/CashierInstallationTest.php:

php
<?php

namespace Tests\Feature\Billing;

use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Schema;
use Laravel\Cashier\Billable;
use Laravel\Cashier\Cashier;
use Tests\TestCase;

/**
 * Cashier replaces the dormant Spark 1.x schema from 2016, and bills the
 * team rather than the user (design doc "Package choice").
 */
class CashierInstallationTest extends TestCase
{
    use RefreshDatabase;

    public function test_the_dormant_spark_tables_are_dropped(): void
    {
        $this->assertFalse(Schema::hasTable('team_subscriptions'));
        $this->assertFalse(Schema::hasTable('invoices'));
    }

    public function test_cashier_owns_the_subscriptions_tables_and_keys_them_on_the_team(): void
    {
        $this->assertTrue(Schema::hasTable('subscriptions'));
        $this->assertTrue(Schema::hasColumn('subscriptions', 'team_id'));
        $this->assertTrue(Schema::hasColumn('subscriptions', 'stripe_status'));
        $this->assertTrue(Schema::hasTable('subscription_items'));
    }

    public function test_the_team_is_the_billable_model(): void
    {
        $this->assertSame(Team::class, Cashier::$customerModel);
        $this->assertContains(Billable::class, class_uses_recursive(Team::class));
    }

    public function test_a_team_starts_with_no_stripe_customer(): void
    {
        $team = Team::factory()->create(['name' => 'Acme Logistics']);

        $this->assertFalse($team->hasStripeId());
        $this->assertCount(0, $team->subscriptions);
    }
}
  • Step 2: Run the test to verify it fails

Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing/CashierInstallationTest.php Expected: FAIL — Class "Laravel\Cashier\Cashier" not found.

  • Step 3: Install Cashier
bash
herd php /Applications/MAMP/bin/php/composer require laravel/cashier "^16.8"
herd php artisan vendor:publish --tag=cashier-config
herd php artisan vendor:publish --tag=cashier-migrations
  • Step 4: Delete the published customer-columns migration and rename the other two

Cashier publishes three migrations into database/migrations/. The customer-columns one targets the users table and is not wanted — teams already has stripe_id and trial_ends_at, and Task 2 adds pm_type / pm_last_four alongside the rest of the billing columns.

The published files are timestamped with today's date. The 2016 file 2016_11_15_074730_create_subscriptions_table.php has a colliding suffix and must NOT be touched — Step 5's drop migration is what retires its tables. So match on the 2026_ prefix explicitly:

bash
rm database/migrations/2026_*_create_customer_columns.php
mv database/migrations/2026_*_create_subscriptions_table.php \
   database/migrations/2026_09_09_100200_create_subscriptions_table.php
mv database/migrations/2026_*_create_subscription_items_table.php \
   database/migrations/2026_09_09_100300_create_subscription_items_table.php

Confirm afterwards that exactly one 2016 and one 2026 create_subscriptions_table migration remain:

bash
ls database/migrations | grep create_subscription
  • Step 5: Write the drop migration

Create database/migrations/2026_09_09_100000_drop_dormant_spark_billing_tables.php. The timestamp matters: it must run after the 2016 migrations that create these tables and before the renamed Cashier migration that recreates subscriptions.

php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\Schema;

/**
 * Retires the dormant Laravel Spark 1.x schema from 2016.
 *
 * Nothing in `app/`, `routes/`, `resources/js/`, `tests/` or
 * `database/seeders/` reads any of these tables, and there are no
 * self-service customers on the platform, so no data is lost. Dropping
 * `subscriptions` in particular is what frees the name for Cashier's own
 * stock table, which is recreated by the migration immediately after this
 * one. `App\Models\Enterprise\Invoice` is a MongoDB model on
 * `enterprises_invoices` and is unrelated to the `invoices` table here.
 */
return new class extends Migration
{
    public function up(): void
    {
        Schema::dropIfExists('team_subscriptions');
        Schema::dropIfExists('subscriptions');
        Schema::dropIfExists('invoices');
    }

    /**
     * Deliberately irreversible. Recreating empty copies of a dead schema
     * would collide with Cashier's own `subscriptions` table.
     */
    public function down(): void
    {
        //
    }
};
  • Step 6: Rekey Cashier's subscriptions migration onto the team

Open database/migrations/2026_09_09_100200_create_subscriptions_table.php. Cashier's stub names the billable foreign key user_id. Rename that single column to team_id, leaving its type, nullability and index exactly as the stub defines them. Change nothing else in the file. The result must satisfy Schema::hasColumn('subscriptions', 'team_id').

Leave 2026_09_09_100300_create_subscription_items_table.php untouched — it keys on subscription_id, not on the billable.

  • Step 7: Point Cashier at the Team model

In config/cashier.php, change the model entry's default:

php
'model' => env('CASHIER_MODEL', App\Models\Team::class),
  • Step 8: Add the Billable trait to Team

In app/Models/Team.php, add the import and the trait:

php
use Laravel\Cashier\Billable;
php
    /** @use HasFactory<TeamFactory> */
    use Billable, GeneratesUniqueTeamSlugs, HasFactory, SoftDeletes;
  • Step 9: Register the customer model

In app/Providers/AppServiceProvider.php, inside boot(), add:

php
Cashier::useCustomerModel(Team::class);

with the imports use Laravel\Cashier\Cashier; and use App\Models\Team; (the latter may already be present). This makes the binding explicit in code rather than relying only on config, which is what the test asserts.

  • Step 10: Add the Stripe environment keys

Append to .env.example:

code
STRIPE_KEY=
STRIPE_SECRET=
STRIPE_WEBHOOK_SECRET=
CASHIER_CURRENCY=usd
CASHIER_MODEL="App\Models\Team"
  • Step 11: Run the test to verify it passes

Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing/CashierInstallationTest.php Expected: PASS, 4 tests.

  • Step 12: Confirm nothing else broke

Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Teams Expected: PASS — adding a trait to Team must not change existing team behaviour.

  • Step 13: Format and commit
bash
vendor/bin/pint --dirty --format agent
git add composer.json composer.lock config/cashier.php database/migrations app/Models/Team.php app/Providers/AppServiceProvider.php .env.example tests/Feature/Billing/CashierInstallationTest.php
git commit -m "Billing: install Cashier and retire the dormant Spark schema"

Task 2: Billing columns on teams, and the billing enums

Drops the dead Spark columns, adds the billing state columns, and introduces the enums the rest of the increment depends on.

Files:

  • Create: app/Enums/BillingType.php
  • Create: app/Enums/BillingAccessState.php
  • Create: database/migrations/2026_09_09_100400_add_billing_columns_to_teams_table.php
  • Modify: app/Models/Team.php
  • Create: tests/Feature/Billing/BillingTestCase.php
  • Test: tests/Feature/Billing/TeamBillingColumnsTest.php

Interfaces:

  • Consumes: Task 1's Cashier installation.

  • Produces:

    • App\Enums\BillingType with cases SelfServe = 'self_serve', Enterprise = 'enterprise', Internal = 'internal', and isBillable(): bool.
    • App\Enums\BillingAccessState with cases Trialing = 'trialing', Active = 'active', PastDue = 'past_due', Restricted = 'restricted', Cancelled = 'cancelled', Suspended = 'suspended', and grantsAppAccess(): bool.
    • Team::$billing_type (BillingType), Team::$billing_access_state (BillingAccessState), Team::$billing_cycle, billing_email, billing_grace_ends_at, billing_restricted_at, billing_current_period_ends_at, billing_seat_cap, billing_storage_quota_bytes, billing_storage_used_bytes, billing_storage_calculated_at, billing_enterprise_amount_cents, billing_enterprise_interval, billing_renewal_at, billing_notes, pm_type, pm_last_four.
    • Team::activeSeatCount(): int — active memberships, owner included.
    • Tests\Feature\Billing\BillingTestCase with protected function team(array $attributes = []): Team.
  • Step 1: Write the failing test

Create tests/Feature/Billing/BillingTestCase.php:

php
<?php

namespace Tests\Feature\Billing;

use App\Enums\TeamRole;
use App\Models\Team;
use App\Models\User;
use Tests\TestCase;

/**
 * Shared setup for the billing suite. Every helper builds real rows with
 * explicit, realistic data — this codebase deliberately has no factories
 * beyond the starter kit's Team/User/TeamInvitation ones.
 */
abstract class BillingTestCase extends TestCase
{
    /**
     * @param  array<string, mixed>  $attributes
     */
    protected function team(array $attributes = []): Team
    {
        return Team::factory()->create(array_merge(['name' => 'Acme Logistics'], $attributes));
    }

    /**
     * A user holding an active membership of the given team, with the
     * personal team the factory creates removed so it cannot compete as a
     * second membership.
     */
    protected function member(Team $team, TeamRole $role = TeamRole::Member): User
    {
        $user = User::factory()->create();
        $user->teamMemberships()->delete();

        $team->members()->attach($user, ['role' => $role->value, 'status' => 1]);
        $user->forceFill(['current_team_id' => $team->id])->save();

        return $user->refresh();
    }
}

Create tests/Feature/Billing/TeamBillingColumnsTest.php:

php
<?php

namespace Tests\Feature\Billing;

use App\Enums\BillingAccessState;
use App\Enums\BillingType;
use App\Enums\TeamRole;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Schema;

/**
 * The billing state columns on `teams` (design doc "Schema").
 */
class TeamBillingColumnsTest extends BillingTestCase
{
    use RefreshDatabase;

    public function test_the_dead_spark_columns_are_gone(): void
    {
        foreach ([
            'current_billing_plan', 'card_brand', 'card_last_four', 'card_country',
            'billing_address', 'billing_address_line_2', 'billing_city',
            'billing_state', 'billing_zip', 'billing_country', 'vat_id',
            'extra_billing_information', 'subscriptiontype',
        ] as $column) {
            $this->assertFalse(Schema::hasColumn('teams', $column), "teams.{$column} should have been dropped");
        }
    }

    public function test_cashier_still_has_the_columns_it_needs(): void
    {
        foreach (['stripe_id', 'trial_ends_at', 'pm_type', 'pm_last_four'] as $column) {
            $this->assertTrue(Schema::hasColumn('teams', $column), "teams.{$column} is required by Cashier");
        }
    }

    public function test_a_team_defaults_to_an_active_enterprise_plan(): void
    {
        $team = $this->team();

        $this->assertSame(BillingType::Enterprise, $team->billing_type);
        $this->assertSame(BillingAccessState::Active, $team->billing_access_state);
        $this->assertNull($team->billing_seat_cap);
        $this->assertNull($team->billing_storage_quota_bytes);
        $this->assertSame(0, $team->billing_storage_used_bytes);
    }

    public function test_the_billing_columns_round_trip_through_their_casts(): void
    {
        $team = $this->team();

        $team->forceFill([
            'billing_type' => BillingType::SelfServe,
            'billing_access_state' => BillingAccessState::PastDue,
            'billing_cycle' => 'yearly',
            'billing_email' => '[email protected]',
            'billing_grace_ends_at' => '2026-10-01 00:00:00',
            'billing_seat_cap' => 25,
            'billing_storage_quota_bytes' => 53687091200,
            'billing_enterprise_amount_cents' => 450000,
            'billing_renewal_at' => '2027-01-01',
            'billing_notes' => 'Renewal handled by the account manager.',
        ])->save();

        $team->refresh();

        $this->assertSame(BillingType::SelfServe, $team->billing_type);
        $this->assertSame(BillingAccessState::PastDue, $team->billing_access_state);
        $this->assertSame('[email protected]', $team->billing_email);
        $this->assertSame('2026-10-01', $team->billing_grace_ends_at->toDateString());
        $this->assertSame(25, $team->billing_seat_cap);
        $this->assertSame(53687091200, $team->billing_storage_quota_bytes);
        $this->assertSame(450000, $team->billing_enterprise_amount_cents);
        $this->assertSame('2027-01-01', $team->billing_renewal_at->toDateString());
    }

    public function test_access_states_declare_whether_they_grant_app_access(): void
    {
        $this->assertTrue(BillingAccessState::Trialing->grantsAppAccess());
        $this->assertTrue(BillingAccessState::Active->grantsAppAccess());
        $this->assertTrue(BillingAccessState::PastDue->grantsAppAccess());
        $this->assertTrue(BillingAccessState::Cancelled->grantsAppAccess());
        $this->assertFalse(BillingAccessState::Restricted->grantsAppAccess());
        $this->assertFalse(BillingAccessState::Suspended->grantsAppAccess());
    }

    public function test_only_self_serve_teams_are_billable_through_stripe(): void
    {
        $this->assertTrue(BillingType::SelfServe->isBillable());
        $this->assertFalse(BillingType::Enterprise->isBillable());
        $this->assertFalse(BillingType::Internal->isBillable());
    }

    public function test_the_seat_count_is_active_memberships_including_the_owner(): void
    {
        $team = $this->team();
        $this->member($team, TeamRole::Owner);
        $this->member($team, TeamRole::Admin);
        $deactivated = $this->member($team, TeamRole::Member);
        $team->members()->updateExistingPivot($deactivated->id, ['status' => 0]);

        $this->assertSame(2, $team->fresh()->activeSeatCount());
    }
}
  • Step 2: Run the test to verify it fails

Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing/TeamBillingColumnsTest.php Expected: FAIL — Class "App\Enums\BillingType" not found.

  • Step 3: Write the enums

Create app/Enums/BillingType.php:

php
<?php

namespace App\Enums;

/**
 * How a team's plan is administered (design doc "Commercial rules").
 */
enum BillingType: string
{
    case SelfServe = 'self_serve';
    case Enterprise = 'enterprise';
    case Internal = 'internal';

    /**
     * Whether this team is charged through Stripe. Enterprise teams pay
     * offline against manually issued invoices, and internal teams (demo,
     * sales, QA, staging) are never billed at all.
     */
    public function isBillable(): bool
    {
        return $this === self::SelfServe;
    }

    public function label(): string
    {
        return match ($this) {
            self::SelfServe => 'Self-serve',
            self::Enterprise => 'Enterprise',
            self::Internal => 'Internal',
        };
    }
}

Create app/Enums/BillingAccessState.php:

php
<?php

namespace App\Enums;

/**
 * Where a team sits in the billing lifecycle (design doc "Access state
 * machine"). `PastDue` deliberately still grants access — it is the 21-day
 * grace window, during which the team works normally while the platform
 * retries payment daily.
 */
enum BillingAccessState: string
{
    case Trialing = 'trialing';
    case Active = 'active';
    case PastDue = 'past_due';
    case Restricted = 'restricted';
    case Cancelled = 'cancelled';
    case Suspended = 'suspended';

    /**
     * Whether members of a team in this state may still use the application.
     * `Cancelled` means "cancelled but paid until the period ends"; the
     * transition to `Restricted` is what actually removes access.
     */
    public function grantsAppAccess(): bool
    {
        return match ($this) {
            self::Trialing, self::Active, self::PastDue, self::Cancelled => true,
            self::Restricted, self::Suspended => false,
        };
    }

    public function label(): string
    {
        return match ($this) {
            self::Trialing => 'Trialing',
            self::Active => 'Active',
            self::PastDue => 'Past due',
            self::Restricted => 'Restricted',
            self::Cancelled => 'Cancelled',
            self::Suspended => 'Suspended',
        };
    }
}
  • Step 4: Write the migration

Create database/migrations/2026_09_09_100400_add_billing_columns_to_teams_table.php:

php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

/**
 * Billing state on `teams`, flattened onto the tenant root table by explicit
 * decision rather than living in a 1:1 side table.
 *
 * `billing_type` and `billing_access_state` default to `enterprise` / `active`,
 * which is what puts every team already on the platform onto a manually
 * managed enterprise plan — adding a column with a default backfills every
 * existing row, so no separate data migration is needed. Increment 4
 * (self-serve subscription) is what changes these defaults; until then no
 * team can land in a self-serve state that nothing manages yet.
 *
 * `billing_access_state` is deliberately not called `billing_state` — the
 * name is free now that the legacy Spark address column is dropped, but
 * reusing it would read as an address field.
 */
return new class extends Migration
{
    public function up(): void
    {
        Schema::table('teams', function (Blueprint $table) {
            $table->dropColumn([
                'current_billing_plan', 'card_brand', 'card_last_four', 'card_country',
                'billing_address', 'billing_address_line_2', 'billing_city',
                'billing_state', 'billing_zip', 'billing_country', 'vat_id',
                'extra_billing_information', 'subscriptiontype',
            ]);
        });

        Schema::table('teams', function (Blueprint $table) {
            $table->string('pm_type')->nullable();
            $table->string('pm_last_four', 4)->nullable();

            $table->string('billing_type', 20)->default('enterprise')->index();
            $table->string('billing_cycle', 10)->nullable();
            $table->string('billing_access_state', 20)->default('active')->index();
            $table->string('billing_email')->nullable();

            $table->timestamp('billing_grace_ends_at')->nullable();
            $table->timestamp('billing_restricted_at')->nullable();
            $table->timestamp('billing_current_period_ends_at')->nullable();

            $table->unsignedInteger('billing_seat_cap')->nullable();
            $table->unsignedBigInteger('billing_storage_quota_bytes')->nullable();
            $table->unsignedBigInteger('billing_storage_used_bytes')->default(0);
            $table->timestamp('billing_storage_calculated_at')->nullable();

            $table->unsignedBigInteger('billing_enterprise_amount_cents')->nullable();
            $table->string('billing_enterprise_interval', 10)->nullable();
            $table->date('billing_renewal_at')->nullable();

            $table->text('billing_notes')->nullable();
        });
    }

    public function down(): void
    {
        Schema::table('teams', function (Blueprint $table) {
            $table->dropColumn([
                'pm_type', 'pm_last_four',
                'billing_type', 'billing_cycle', 'billing_access_state', 'billing_email',
                'billing_grace_ends_at', 'billing_restricted_at', 'billing_current_period_ends_at',
                'billing_seat_cap', 'billing_storage_quota_bytes',
                'billing_storage_used_bytes', 'billing_storage_calculated_at',
                'billing_enterprise_amount_cents', 'billing_enterprise_interval',
                'billing_renewal_at', 'billing_notes',
            ]);
        });
    }
};

Note the two separate Schema::table closures: dropping and adding in one closure is unreliable on SQLite, which the test suite uses.

Columns are declared as string with a length rather than enum — SQLite has no native enum type, and the Eloquent enum casts added in the next step are what actually enforce the values.

  • Step 5: Cast the columns on the Team model

In app/Models/Team.php, add the imports:

php
use App\Enums\BillingAccessState;
use App\Enums\BillingType;

Extend the @property docblock with the new columns (following the existing block's style), and add the casts to the existing casts() method:

php
    protected function casts(): array
    {
        return [
            'is_personal' => 'boolean',
            'modulesAllowed' => 'array',
            'trial_ends_at' => 'datetime',
            'billing_type' => BillingType::class,
            'billing_access_state' => BillingAccessState::class,
            'billing_grace_ends_at' => 'datetime',
            'billing_restricted_at' => 'datetime',
            'billing_current_period_ends_at' => 'datetime',
            'billing_seat_cap' => 'integer',
            'billing_storage_quota_bytes' => 'integer',
            'billing_storage_used_bytes' => 'integer',
            'billing_storage_calculated_at' => 'datetime',
            'billing_enterprise_amount_cents' => 'integer',
            'billing_renewal_at' => 'date',
        ];
    }
  • Step 6: Add the seat count to the Team model

Add to app/Models/Team.php:

php
    /**
     * The number of billable seats this team occupies: every active
     * membership, the owner included. Pending invitations are not counted
     * until they are accepted.
     */
    public function activeSeatCount(): int
    {
        return $this->activeMembers()->count();
    }
  • Step 7: Run the test to verify it passes

Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing/TeamBillingColumnsTest.php Expected: PASS, 7 tests.

  • Step 8: Confirm the wider team suite still passes

Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Teams tests/Feature/Auth Expected: PASS.

  • Step 9: Format and commit
bash
vendor/bin/pint --dirty --format agent
git add app/Enums/BillingType.php app/Enums/BillingAccessState.php database/migrations app/Models/Team.php tests/Feature/Billing
git commit -m "Billing: add billing state columns and enums to teams"

Task 3: The priced-item catalog

billing_products maps a priced thing — a seat, a specialized module — to its Stripe Prices, so adding a future specialized module is a seeder row plus two Stripe Prices rather than a code change.

Files:

  • Create: app/Enums/BillingProductType.php
  • Create: app/Models/Billing/Product.php
  • Create: database/migrations/2026_09_09_100500_create_billing_products_table.php
  • Create: database/seeders/BillingProductsSeeder.php
  • Modify: database/seeders/DatabaseSeeder.php
  • Test: tests/Feature/Billing/BillingProductCatalogTest.php

Interfaces:

  • Consumes: Task 2's enums and BillingTestCase.

  • Produces:

    • App\Enums\BillingProductType with cases Seat = 'seat', Module = 'module', Storage = 'storage'.
    • App\Models\Billing\Product on table billing_products, columns id, key, name, type, module_key, stripe_price_monthly, stripe_price_yearly, unit_amount_cents, is_active, created_at, updated_at.
    • Product::specializedModuleKeys(): array<int, string> — the active module-type products' module keys, memoised per request.
    • Product::flushCache(): void — clears that memo (tests must call it).
    • Product::seat(): ?Product.
  • Step 1: Write the failing test

Create tests/Feature/Billing/BillingProductCatalogTest.php:

php
<?php

namespace Tests\Feature\Billing;

use App\Enums\BillingProductType;
use App\Models\Billing\Product;
use Illuminate\Foundation\Testing\RefreshDatabase;

/**
 * The priced-item catalog (design doc "New tables (all MySQL)").
 */
class BillingProductCatalogTest extends BillingTestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();

        Product::flushCache();
    }

    protected function tearDown(): void
    {
        Product::flushCache();

        parent::tearDown();
    }

    public function test_the_seeder_registers_the_seat_and_both_specialized_modules(): void
    {
        $this->seed(\Database\Seeders\BillingProductsSeeder::class);

        $seat = Product::where('key', 'seat')->firstOrFail();
        $this->assertSame(BillingProductType::Seat, $seat->type);
        $this->assertSame(1000, $seat->unit_amount_cents);
        $this->assertNull($seat->module_key);

        $threePl = Product::where('key', 'module.logistics3p')->firstOrFail();
        $this->assertSame(BillingProductType::Module, $threePl->type);
        $this->assertSame('logistics3p', $threePl->module_key);
        $this->assertSame(75000, $threePl->unit_amount_cents);

        $depot = Product::where('key', 'module.containers')->firstOrFail();
        $this->assertSame('containers', $depot->module_key);
        $this->assertSame(75000, $depot->unit_amount_cents);
    }

    public function test_the_seeder_is_idempotent(): void
    {
        $this->seed(\Database\Seeders\BillingProductsSeeder::class);
        $this->seed(\Database\Seeders\BillingProductsSeeder::class);

        $this->assertSame(3, Product::count());
    }

    public function test_the_specialized_module_keys_are_the_active_module_products(): void
    {
        $this->seed(\Database\Seeders\BillingProductsSeeder::class);

        $this->assertEqualsCanonicalizing(['logistics3p', 'containers'], Product::specializedModuleKeys());
    }

    public function test_a_deactivated_module_product_stops_being_specialized(): void
    {
        $this->seed(\Database\Seeders\BillingProductsSeeder::class);
        Product::where('key', 'module.containers')->update(['is_active' => false]);
        Product::flushCache();

        $this->assertSame(['logistics3p'], Product::specializedModuleKeys());
    }

    public function test_the_seat_product_is_reachable_by_helper(): void
    {
        $this->seed(\Database\Seeders\BillingProductsSeeder::class);

        $this->assertSame('seat', Product::seat()?->key);
    }
}
  • Step 2: Run the test to verify it fails

Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing/BillingProductCatalogTest.php Expected: FAIL — Class "App\Enums\BillingProductType" not found.

  • Step 3: Write the enum

Create app/Enums/BillingProductType.php:

php
<?php

namespace App\Enums;

/**
 * What kind of priced thing a `billing_products` row represents. `Storage`
 * has no rows yet — paid storage packs are deferred, and this case is the
 * slot they will occupy.
 */
enum BillingProductType: string
{
    case Seat = 'seat';
    case Module = 'module';
    case Storage = 'storage';
}
  • Step 4: Write the migration

Create database/migrations/2026_09_09_100500_create_billing_products_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_products', function (Blueprint $table) {
            $table->id();
            $table->string('key')->unique();
            $table->string('name');
            $table->string('type', 20)->index();
            $table->string('module_key')->nullable()->index();
            $table->string('stripe_price_monthly')->nullable();
            $table->string('stripe_price_yearly')->nullable();
            $table->unsignedBigInteger('unit_amount_cents');
            $table->boolean('is_active')->default(true)->index();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('billing_products');
    }
};

module_key matches modules.key by value; there is deliberately no foreign key, because modules is seeded reference data whose ids are stable but whose rows are re-seeded.

  • Step 5: Write the model

Create app/Models/Billing/Product.php:

php
<?php

namespace App\Models\Billing;

use App\Enums\BillingProductType;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;

/**
 * A priced item in the catalog: a seat, or a specialized module.
 *
 * Platform-level data, deliberately not tenant-scoped — the superadmin
 * console reads it across every team, so `BelongsToOrganization` would hide
 * it.
 *
 * @property int $id
 * @property string $key
 * @property string $name
 * @property BillingProductType $type
 * @property string|null $module_key
 * @property string|null $stripe_price_monthly
 * @property string|null $stripe_price_yearly
 * @property int $unit_amount_cents
 * @property bool $is_active
 * @property Carbon|null $created_at
 * @property Carbon|null $updated_at
 */
class Product extends Model
{
    protected $table = 'billing_products';

    protected $guarded = [];

    protected $casts = [
        'type' => BillingProductType::class,
        'unit_amount_cents' => 'integer',
        'is_active' => 'boolean',
        'created_at' => 'datetime',
        'updated_at' => 'datetime',
    ];

    /**
     * Memoised per request. Every module-visibility check consults this, so
     * it must not cost a query per module.
     *
     * @var array<int, string>|null
     */
    private static ?array $specializedModuleKeys = null;

    /**
     * The module keys that cost extra — everything else is a standard module
     * included with any plan.
     *
     * @return array<int, string>
     */
    public static function specializedModuleKeys(): array
    {
        return self::$specializedModuleKeys ??= self::query()
            ->where('type', BillingProductType::Module)
            ->where('is_active', true)
            ->whereNotNull('module_key')
            ->pluck('module_key')
            ->all();
    }

    /**
     * Clear the per-request memo. Static caches survive across tests in one
     * process, so the billing suite flushes this in `setUp()`.
     */
    public static function flushCache(): void
    {
        self::$specializedModuleKeys = null;
    }

    public static function seat(): ?self
    {
        return self::query()->where('key', 'seat')->first();
    }
}
  • Step 6: Write the seeder

Create database/seeders/BillingProductsSeeder.php:

php
<?php

namespace Database\Seeders;

use App\Enums\BillingProductType;
use App\Models\Billing\Product;
use Illuminate\Database\Seeder;

/**
 * The priced-item catalog. Stripe Price ids are environment-specific and are
 * filled in by an operator after the Prices are created in Stripe, so they
 * are left null here rather than seeded with placeholder ids that would look
 * real and silently fail at charge time.
 */
class BillingProductsSeeder extends Seeder
{
    public function run(): void
    {
        $products = [
            [
                'key' => 'seat',
                'name' => 'User seat',
                'type' => BillingProductType::Seat->value,
                'module_key' => null,
                'unit_amount_cents' => 1000,
            ],
            [
                'key' => 'module.logistics3p',
                'name' => '3PL',
                'type' => BillingProductType::Module->value,
                'module_key' => 'logistics3p',
                'unit_amount_cents' => 75000,
            ],
            [
                'key' => 'module.containers',
                'name' => 'Container Depot',
                'type' => BillingProductType::Module->value,
                'module_key' => 'containers',
                'unit_amount_cents' => 75000,
            ],
        ];

        foreach ($products as $product) {
            Product::updateOrCreate(
                ['key' => $product['key']],
                [
                    'name' => $product['name'],
                    'type' => $product['type'],
                    'module_key' => $product['module_key'],
                    'unit_amount_cents' => $product['unit_amount_cents'],
                    'is_active' => true,
                ],
            );
        }
    }
}

updateOrCreate keyed on key is what makes re-seeding safe, and deliberately does not overwrite stripe_price_monthly / stripe_price_yearly, so an operator's real Stripe Price ids survive a re-seed.

  • Step 7: Register the seeder

In database/seeders/DatabaseSeeder.php, add BillingProductsSeeder::class to the existing $this->call([...]) list, immediately after ModulesSeeder::class (the module keys it references are seeded there).

  • Step 8: Run the test to verify it passes

Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing/BillingProductCatalogTest.php Expected: PASS, 5 tests.

  • Step 9: Format and commit
bash
vendor/bin/pint --dirty --format agent
git add app/Enums/BillingProductType.php app/Models/Billing/Product.php database/migrations database/seeders tests/Feature/Billing
git commit -m "Billing: add the priced-item catalog"

Task 4: Team module holdings

billing_team_modules records which specialized modules a team holds. A future ends_at is the "cancelled but paid until the 1st" state, and this row — not Stripe — is the authority for access.

Files:

  • Create: app/Models/Billing/TeamModule.php
  • Create: database/migrations/2026_09_09_100600_create_billing_team_modules_table.php
  • Test: tests/Feature/Billing/TeamModuleHoldingTest.php

Interfaces:

  • Consumes: Task 3's App\Models\Billing\Product.

  • Produces:

    • App\Models\Billing\TeamModule on table billing_team_modules, columns id, team_id, billing_product_id, stripe_subscription_item_id, starts_at, ends_at, created_at, updated_at.
    • TeamModule::scopeActiveAt(Builder $query, CarbonInterface $moment): Builder.
    • TeamModule::activeModuleKeysFor(Team $team, ?CarbonInterface $moment = null): array<int, string>.
    • Relations TeamModule::team(): BelongsTo, TeamModule::product(): BelongsTo.
  • Step 1: Write the failing test

Create tests/Feature/Billing/TeamModuleHoldingTest.php:

php
<?php

namespace Tests\Feature\Billing;

use App\Models\Billing\Product;
use App\Models\Billing\TeamModule;
use Database\Seeders\BillingProductsSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;

/**
 * Which specialized modules a team holds, and until when (design doc
 * "Module changes").
 */
class TeamModuleHoldingTest extends BillingTestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();

        Product::flushCache();
        $this->seed(BillingProductsSeeder::class);
    }

    protected function tearDown(): void
    {
        Product::flushCache();
        Carbon::setTestNow();

        parent::tearDown();
    }

    public function test_an_open_ended_holding_is_active(): void
    {
        $team = $this->team();
        TeamModule::create([
            'team_id' => $team->id,
            'billing_product_id' => Product::where('key', 'module.logistics3p')->value('id'),
            'starts_at' => '2026-09-01 00:00:00',
            'ends_at' => null,
        ]);

        Carbon::setTestNow('2026-09-15 12:00:00');

        $this->assertSame(['logistics3p'], TeamModule::activeModuleKeysFor($team));
    }

    public function test_a_holding_scheduled_to_end_stays_active_until_that_moment(): void
    {
        $team = $this->team();
        TeamModule::create([
            'team_id' => $team->id,
            'billing_product_id' => Product::where('key', 'module.containers')->value('id'),
            'starts_at' => '2026-08-01 00:00:00',
            'ends_at' => '2026-10-01 00:00:00',
        ]);

        Carbon::setTestNow('2026-09-30 23:59:59');
        $this->assertSame(['containers'], TeamModule::activeModuleKeysFor($team));

        Carbon::setTestNow('2026-10-01 00:00:01');
        $this->assertSame([], TeamModule::activeModuleKeysFor($team));
    }

    public function test_a_holding_that_has_not_started_is_not_active(): void
    {
        $team = $this->team();
        TeamModule::create([
            'team_id' => $team->id,
            'billing_product_id' => Product::where('key', 'module.logistics3p')->value('id'),
            'starts_at' => '2026-11-01 00:00:00',
            'ends_at' => null,
        ]);

        Carbon::setTestNow('2026-09-15 12:00:00');

        $this->assertSame([], TeamModule::activeModuleKeysFor($team));
    }

    public function test_holdings_belong_to_their_team_and_product(): void
    {
        $team = $this->team();
        $holding = TeamModule::create([
            'team_id' => $team->id,
            'billing_product_id' => Product::where('key', 'module.logistics3p')->value('id'),
            'starts_at' => '2026-09-01 00:00:00',
        ]);

        $this->assertSame($team->id, $holding->team->id);
        $this->assertSame('module.logistics3p', $holding->product->key);
    }

    public function test_one_team_holdings_do_not_leak_into_another(): void
    {
        $acme = $this->team();
        $other = $this->team(['name' => 'Globex Freight']);
        TeamModule::create([
            'team_id' => $acme->id,
            'billing_product_id' => Product::where('key', 'module.logistics3p')->value('id'),
            'starts_at' => '2026-09-01 00:00:00',
        ]);

        Carbon::setTestNow('2026-09-15 12:00:00');

        $this->assertSame([], TeamModule::activeModuleKeysFor($other));
    }
}
  • Step 2: Run the test to verify it fails

Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing/TeamModuleHoldingTest.php Expected: FAIL — Class "App\Models\Billing\TeamModule" not found.

  • Step 3: Write the migration

Create database/migrations/2026_09_09_100600_create_billing_team_modules_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_team_modules', function (Blueprint $table) {
            $table->id();
            $table->unsignedBigInteger('team_id');
            $table->unsignedBigInteger('billing_product_id');
            $table->string('stripe_subscription_item_id')->nullable();
            $table->timestamp('starts_at');
            $table->timestamp('ends_at')->nullable();
            $table->timestamps();

            $table->index(['team_id', 'ends_at']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('billing_team_modules');
    }
};

stripe_subscription_item_id is nullable because enterprise teams hold modules without any Stripe subscription behind them.

  • Step 4: Write the model

Create app/Models/Billing/TeamModule.php:

php
<?php

namespace App\Models\Billing;

use App\Models\Team;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
use Carbon\CarbonInterface;

/**
 * A specialized module a team holds.
 *
 * This row, not Stripe, is the authority for whether the team may reach the
 * module: dropping a module sets `ends_at` to the end of the paid period, so
 * "keeps access until the 1st" is a data fact rather than a scheduled job
 * that might not fire.
 *
 * @property int $id
 * @property int $team_id
 * @property int $billing_product_id
 * @property string|null $stripe_subscription_item_id
 * @property Carbon $starts_at
 * @property Carbon|null $ends_at
 * @property Carbon|null $created_at
 * @property Carbon|null $updated_at
 * @property-read Team $team
 * @property-read Product $product
 */
class TeamModule extends Model
{
    protected $table = 'billing_team_modules';

    protected $guarded = [];

    protected $casts = [
        'team_id' => 'integer',
        'billing_product_id' => 'integer',
        'starts_at' => 'datetime',
        'ends_at' => 'datetime',
        'created_at' => 'datetime',
        'updated_at' => 'datetime',
    ];

    /**
     * @return BelongsTo<Team, $this>
     */
    public function team(): BelongsTo
    {
        return $this->belongsTo(Team::class);
    }

    /**
     * @return BelongsTo<Product, $this>
     */
    public function product(): BelongsTo
    {
        return $this->belongsTo(Product::class, 'billing_product_id');
    }

    /**
     * Holdings in force at the given moment: started, and either open-ended
     * or not yet expired.
     *
     * @param  Builder<self>  $query
     * @return Builder<self>
     */
    public function scopeActiveAt(Builder $query, CarbonInterface $moment): Builder
    {
        return $query
            ->where('starts_at', '<=', $moment)
            ->where(function (Builder $query) use ($moment) {
                $query->whereNull('ends_at')->orWhere('ends_at', '>', $moment);
            });
    }

    /**
     * The module keys this team may currently reach through its holdings.
     *
     * @return array<int, string>
     */
    public static function activeModuleKeysFor(Team $team, ?CarbonInterface $moment = null): array
    {
        return self::query()
            ->where('team_id', $team->id)
            ->activeAt($moment ?? Carbon::now())
            ->join('billing_products', 'billing_products.id', '=', 'billing_team_modules.billing_product_id')
            ->where('billing_products.is_active', true)
            ->whereNotNull('billing_products.module_key')
            ->pluck('billing_products.module_key')
            ->all();
    }
}
  • Step 5: Run the test to verify it passes

Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing/TeamModuleHoldingTest.php Expected: PASS, 5 tests.

  • Step 6: Format and commit
bash
vendor/bin/pint --dirty --format agent
git add app/Models/Billing/TeamModule.php database/migrations tests/Feature/Billing
git commit -m "Billing: record which specialized modules a team holds"

Task 5: Invoices, invoice lines and payments

Our own record of every invoice, Stripe-backed or manual, plus the payments settling them. Enterprise invoices never touch Stripe, so the superadmin console needs one table to read rather than two systems.

Files:

  • Create: app/Enums/BillingInvoiceSource.php
  • Create: app/Enums/BillingInvoiceStatus.php
  • Create: app/Enums/BillingInvoiceLineKind.php
  • Create: app/Enums/BillingPaymentMethod.php
  • Create: app/Models/Billing/Invoice.php
  • Create: app/Models/Billing/InvoiceLine.php
  • Create: app/Models/Billing/Payment.php
  • Create: database/migrations/2026_09_09_100700_create_billing_invoices_table.php
  • Create: database/migrations/2026_09_09_100800_create_billing_invoice_lines_table.php
  • Create: database/migrations/2026_09_09_100900_create_billing_payments_table.php
  • Test: tests/Feature/Billing/BillingInvoiceTest.php

Interfaces:

  • Consumes: Task 2's BillingTestCase.

  • Produces:

    • App\Enums\BillingInvoiceSource: Stripe = 'stripe', Manual = 'manual'.
    • App\Enums\BillingInvoiceStatus: Draft = 'draft', Open = 'open', Paid = 'paid', Void = 'void', Uncollectible = 'uncollectible'.
    • App\Enums\BillingInvoiceLineKind: Seat = 'seat', Module = 'module', Usage = 'usage', Adjustment = 'adjustment'.
    • App\Enums\BillingPaymentMethod: Stripe = 'stripe', BankTransfer = 'bank_transfer', Cheque = 'cheque', Cash = 'cash', Other = 'other'.
    • App\Models\Billing\Invoice on billing_invoices, with lines(): HasMany, payments(): HasMany, team(): BelongsTo, amountPaidCents(): int, amountDueCents(): int.
    • App\Models\Billing\InvoiceLine on billing_invoice_lines, with invoice(): BelongsTo.
    • App\Models\Billing\Payment on billing_payments, with invoice(): BelongsTo, team(): BelongsTo, recordedBy(): BelongsTo.
  • Step 1: Write the failing test

Create tests/Feature/Billing/BillingInvoiceTest.php:

php
<?php

namespace Tests\Feature\Billing;

use App\Enums\BillingInvoiceLineKind;
use App\Enums\BillingInvoiceSource;
use App\Enums\BillingInvoiceStatus;
use App\Enums\BillingPaymentMethod;
use App\Enums\TeamRole;
use App\Models\Billing\Invoice;
use App\Models\Billing\InvoiceLine;
use App\Models\Billing\Payment;
use Illuminate\Foundation\Testing\RefreshDatabase;

/**
 * Invoices, their lines, and the payments settling them (design doc
 * "New tables (all MySQL)" and "Enterprise plans").
 */
class BillingInvoiceTest extends BillingTestCase
{
    use RefreshDatabase;

    public function test_a_manual_enterprise_invoice_carries_its_lines(): void
    {
        $team = $this->team();

        $invoice = Invoice::create([
            'team_id' => $team->id,
            'number' => 'INV-2026-0001',
            'source' => BillingInvoiceSource::Manual,
            'period_start' => '2026-09-01',
            'period_end' => '2026-09-30',
            'subtotal_cents' => 450000,
            'total_cents' => 450000,
            'currency' => 'usd',
            'status' => BillingInvoiceStatus::Open,
            'issued_at' => '2026-09-01 09:00:00',
            'due_at' => '2026-09-15 09:00:00',
        ]);

        InvoiceLine::create([
            'billing_invoice_id' => $invoice->id,
            'kind' => BillingInvoiceLineKind::Module,
            'description' => '3PL — September 2026',
            'quantity' => 1,
            'unit_amount_cents' => 375000,
            'amount_cents' => 375000,
        ]);

        InvoiceLine::create([
            'billing_invoice_id' => $invoice->id,
            'kind' => BillingInvoiceLineKind::Usage,
            'description' => 'SMS — August 2026',
            'quantity' => 1500,
            'unit_amount_cents' => 50,
            'amount_cents' => 75000,
        ]);

        $invoice->refresh();

        $this->assertCount(2, $invoice->lines);
        $this->assertSame(450000, $invoice->lines->sum('amount_cents'));
        $this->assertNull($invoice->stripe_invoice_id);
        $this->assertSame(BillingInvoiceSource::Manual, $invoice->source);
    }

    public function test_an_offline_payment_settles_an_invoice(): void
    {
        $team = $this->team();
        $admin = $this->member($team, TeamRole::Admin);

        $invoice = Invoice::create([
            'team_id' => $team->id,
            'number' => 'INV-2026-0002',
            'source' => BillingInvoiceSource::Manual,
            'period_start' => '2026-09-01',
            'period_end' => '2026-09-30',
            'subtotal_cents' => 450000,
            'total_cents' => 450000,
            'currency' => 'usd',
            'status' => BillingInvoiceStatus::Open,
            'issued_at' => '2026-09-01 09:00:00',
        ]);

        Payment::create([
            'team_id' => $team->id,
            'billing_invoice_id' => $invoice->id,
            'method' => BillingPaymentMethod::BankTransfer,
            'amount_cents' => 450000,
            'paid_at' => '2026-09-12 14:30:00',
            'reference' => 'SWIFT 8842190',
            'recorded_by_user_id' => $admin->id,
            'notes' => 'Confirmed against the September statement.',
        ]);

        $invoice->refresh();

        $this->assertSame(450000, $invoice->amountPaidCents());
        $this->assertSame(0, $invoice->amountDueCents());
        $this->assertSame($admin->id, $invoice->payments->first()->recordedBy->id);
    }

    public function test_a_partially_paid_invoice_still_has_an_amount_due(): void
    {
        $team = $this->team();

        $invoice = Invoice::create([
            'team_id' => $team->id,
            'number' => 'INV-2026-0003',
            'source' => BillingInvoiceSource::Manual,
            'period_start' => '2026-09-01',
            'period_end' => '2026-09-30',
            'subtotal_cents' => 450000,
            'total_cents' => 450000,
            'currency' => 'usd',
            'status' => BillingInvoiceStatus::Open,
            'issued_at' => '2026-09-01 09:00:00',
        ]);

        Payment::create([
            'team_id' => $team->id,
            'billing_invoice_id' => $invoice->id,
            'method' => BillingPaymentMethod::Cheque,
            'amount_cents' => 200000,
            'paid_at' => '2026-09-12 14:30:00',
        ]);

        $this->assertSame(250000, $invoice->refresh()->amountDueCents());
    }

    public function test_a_stripe_invoice_keeps_its_stripe_identifier(): void
    {
        $team = $this->team();

        $invoice = Invoice::create([
            'team_id' => $team->id,
            'number' => 'INV-2026-0004',
            'source' => BillingInvoiceSource::Stripe,
            'stripe_invoice_id' => 'in_1QcExAmPlE0001',
            'period_start' => '2026-09-01',
            'period_end' => '2026-09-30',
            'subtotal_cents' => 85000,
            'total_cents' => 85000,
            'currency' => 'usd',
            'status' => BillingInvoiceStatus::Paid,
            'issued_at' => '2026-09-01 00:05:00',
            'paid_at' => '2026-09-01 00:05:12',
        ]);

        $this->assertSame('in_1QcExAmPlE0001', $invoice->fresh()->stripe_invoice_id);
        $this->assertSame(BillingInvoiceStatus::Paid, $invoice->status);
    }

    public function test_a_payment_may_exist_without_an_invoice(): void
    {
        $team = $this->team();

        $payment = Payment::create([
            'team_id' => $team->id,
            'billing_invoice_id' => null,
            'method' => BillingPaymentMethod::BankTransfer,
            'amount_cents' => 100000,
            'paid_at' => '2026-09-12 14:30:00',
            'reference' => 'Advance against Q4',
        ]);

        $this->assertNull($payment->fresh()->invoice);
        $this->assertSame($team->id, $payment->team->id);
    }
}
  • Step 2: Run the test to verify it fails

Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing/BillingInvoiceTest.php Expected: FAIL — Class "App\Enums\BillingInvoiceLineKind" not found.

  • Step 3: Write the four enums

Create app/Enums/BillingInvoiceSource.php:

php
<?php

namespace App\Enums;

/**
 * Where an invoice came from. Enterprise invoices are issued by a superadmin
 * and settled offline, so they never have a Stripe counterpart.
 */
enum BillingInvoiceSource: string
{
    case Stripe = 'stripe';
    case Manual = 'manual';
}

Create app/Enums/BillingInvoiceStatus.php:

php
<?php

namespace App\Enums;

enum BillingInvoiceStatus: string
{
    case Draft = 'draft';
    case Open = 'open';
    case Paid = 'paid';
    case Void = 'void';
    case Uncollectible = 'uncollectible';

    /**
     * Whether this invoice is still expected to be paid.
     */
    public function isOutstanding(): bool
    {
        return $this === self::Open;
    }
}

Create app/Enums/BillingInvoiceLineKind.php:

php
<?php

namespace App\Enums;

enum BillingInvoiceLineKind: string
{
    case Seat = 'seat';
    case Module = 'module';
    case Usage = 'usage';
    case Adjustment = 'adjustment';
}

Create app/Enums/BillingPaymentMethod.php:

php
<?php

namespace App\Enums;

enum BillingPaymentMethod: string
{
    case Stripe = 'stripe';
    case BankTransfer = 'bank_transfer';
    case Cheque = 'cheque';
    case Cash = 'cash';
    case Other = 'other';

    public function label(): string
    {
        return match ($this) {
            self::Stripe => 'Stripe',
            self::BankTransfer => 'Bank transfer',
            self::Cheque => 'Cheque',
            self::Cash => 'Cash',
            self::Other => 'Other',
        };
    }
}
  • Step 4: Write the three migrations

Create database/migrations/2026_09_09_100700_create_billing_invoices_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_invoices', function (Blueprint $table) {
            $table->id();
            $table->unsignedBigInteger('team_id')->index();
            $table->string('number')->unique();
            $table->string('source', 10)->index();
            $table->string('stripe_invoice_id')->nullable()->unique();
            $table->date('period_start');
            $table->date('period_end');
            $table->unsignedBigInteger('subtotal_cents');
            $table->unsignedBigInteger('total_cents');
            $table->string('currency', 3)->default('usd');
            $table->string('status', 20)->index();
            $table->timestamp('issued_at')->nullable();
            $table->timestamp('due_at')->nullable();
            $table->timestamp('paid_at')->nullable();
            $table->string('pdf_url')->nullable();
            $table->text('notes')->nullable();
            $table->timestamps();

            $table->index(['team_id', 'status']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('billing_invoices');
    }
};

Create database/migrations/2026_09_09_100800_create_billing_invoice_lines_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_invoice_lines', function (Blueprint $table) {
            $table->id();
            $table->unsignedBigInteger('billing_invoice_id')->index();
            $table->string('kind', 20);
            $table->string('description');
            $table->decimal('quantity', 20, 6)->default(1);
            $table->bigInteger('unit_amount_cents');
            $table->bigInteger('amount_cents');
            $table->json('meta')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('billing_invoice_lines');
    }
};

unit_amount_cents and amount_cents are signed here, unlike on the invoice — an Adjustment line is how a credit is expressed, and a credit is negative.

Create database/migrations/2026_09_09_100900_create_billing_payments_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_payments', function (Blueprint $table) {
            $table->id();
            $table->unsignedBigInteger('team_id')->index();
            $table->unsignedBigInteger('billing_invoice_id')->nullable()->index();
            $table->string('method', 20);
            $table->bigInteger('amount_cents');
            $table->timestamp('paid_at');
            $table->string('reference')->nullable();
            $table->unsignedBigInteger('recorded_by_user_id')->nullable();
            $table->text('notes')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('billing_payments');
    }
};

billing_invoice_id is nullable so an advance payment can be recorded before the invoice it will settle exists.

  • Step 5: Write the three models

Create app/Models/Billing/Invoice.php:

php
<?php

namespace App\Models\Billing;

use App\Enums\BillingInvoiceSource;
use App\Enums\BillingInvoiceStatus;
use App\Models\Team;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;

/**
 * An invoice issued to a team, whether Stripe generated it or a superadmin
 * did. Holding both in one table is what lets the billing console read a
 * team's history without reconciling two systems.
 *
 * Not tenant-scoped: the console reads across every team.
 *
 * @property int $id
 * @property int $team_id
 * @property string $number
 * @property BillingInvoiceSource $source
 * @property string|null $stripe_invoice_id
 * @property Carbon $period_start
 * @property Carbon $period_end
 * @property int $subtotal_cents
 * @property int $total_cents
 * @property string $currency
 * @property BillingInvoiceStatus $status
 * @property Carbon|null $issued_at
 * @property Carbon|null $due_at
 * @property Carbon|null $paid_at
 * @property string|null $pdf_url
 * @property string|null $notes
 * @property Carbon|null $created_at
 * @property Carbon|null $updated_at
 * @property-read Team $team
 * @property-read Collection<int, InvoiceLine> $lines
 * @property-read Collection<int, Payment> $payments
 */
class Invoice extends Model
{
    protected $table = 'billing_invoices';

    protected $guarded = [];

    protected $casts = [
        'team_id' => 'integer',
        'source' => BillingInvoiceSource::class,
        'period_start' => 'date',
        'period_end' => 'date',
        'subtotal_cents' => 'integer',
        'total_cents' => 'integer',
        'status' => BillingInvoiceStatus::class,
        'issued_at' => 'datetime',
        'due_at' => 'datetime',
        'paid_at' => 'datetime',
        'created_at' => 'datetime',
        'updated_at' => 'datetime',
    ];

    /**
     * @return BelongsTo<Team, $this>
     */
    public function team(): BelongsTo
    {
        return $this->belongsTo(Team::class);
    }

    /**
     * @return HasMany<InvoiceLine, $this>
     */
    public function lines(): HasMany
    {
        return $this->hasMany(InvoiceLine::class, 'billing_invoice_id');
    }

    /**
     * @return HasMany<Payment, $this>
     */
    public function payments(): HasMany
    {
        return $this->hasMany(Payment::class, 'billing_invoice_id');
    }

    public function amountPaidCents(): int
    {
        return (int) $this->payments()->sum('amount_cents');
    }

    public function amountDueCents(): int
    {
        return max(0, $this->total_cents - $this->amountPaidCents());
    }
}

Create app/Models/Billing/InvoiceLine.php:

php
<?php

namespace App\Models\Billing;

use App\Enums\BillingInvoiceLineKind;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;

/**
 * One line on an invoice. Amounts are signed so an `Adjustment` line can
 * carry a credit.
 *
 * @property int $id
 * @property int $billing_invoice_id
 * @property BillingInvoiceLineKind $kind
 * @property string $description
 * @property string $quantity
 * @property int $unit_amount_cents
 * @property int $amount_cents
 * @property array<string, mixed>|null $meta
 * @property Carbon|null $created_at
 * @property Carbon|null $updated_at
 * @property-read Invoice $invoice
 */
class InvoiceLine extends Model
{
    protected $table = 'billing_invoice_lines';

    protected $guarded = [];

    protected $casts = [
        'billing_invoice_id' => 'integer',
        'kind' => BillingInvoiceLineKind::class,
        'unit_amount_cents' => 'integer',
        'amount_cents' => 'integer',
        'meta' => 'array',
        'created_at' => 'datetime',
        'updated_at' => 'datetime',
    ];

    /**
     * @return BelongsTo<Invoice, $this>
     */
    public function invoice(): BelongsTo
    {
        return $this->belongsTo(Invoice::class, 'billing_invoice_id');
    }
}

quantity is left uncast: it is a decimal(20,6) and casting it to float would lose precision on large token counts.

Create app/Models/Billing/Payment.php:

php
<?php

namespace App\Models\Billing;

use App\Enums\BillingPaymentMethod;
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;

/**
 * A payment received from a team. Offline payments (bank transfer, cheque,
 * cash) are recorded here by a superadmin; Stripe payments are mirrored from
 * webhooks.
 *
 * @property int $id
 * @property int $team_id
 * @property int|null $billing_invoice_id
 * @property BillingPaymentMethod $method
 * @property int $amount_cents
 * @property Carbon $paid_at
 * @property string|null $reference
 * @property int|null $recorded_by_user_id
 * @property string|null $notes
 * @property Carbon|null $created_at
 * @property Carbon|null $updated_at
 * @property-read Team $team
 * @property-read Invoice|null $invoice
 * @property-read User|null $recordedBy
 */
class Payment extends Model
{
    protected $table = 'billing_payments';

    protected $guarded = [];

    protected $casts = [
        'team_id' => 'integer',
        'billing_invoice_id' => 'integer',
        'method' => BillingPaymentMethod::class,
        'amount_cents' => 'integer',
        'paid_at' => 'datetime',
        'recorded_by_user_id' => 'integer',
        'created_at' => 'datetime',
        'updated_at' => 'datetime',
    ];

    /**
     * @return BelongsTo<Team, $this>
     */
    public function team(): BelongsTo
    {
        return $this->belongsTo(Team::class);
    }

    /**
     * @return BelongsTo<Invoice, $this>
     */
    public function invoice(): BelongsTo
    {
        return $this->belongsTo(Invoice::class, 'billing_invoice_id');
    }

    /**
     * @return BelongsTo<User, $this>
     */
    public function recordedBy(): BelongsTo
    {
        return $this->belongsTo(User::class, 'recorded_by_user_id');
    }
}
  • Step 6: Run the test to verify it passes

Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing/BillingInvoiceTest.php Expected: PASS, 5 tests.

  • Step 7: Format and commit
bash
vendor/bin/pint --dirty --format agent
git add app/Enums app/Models/Billing database/migrations tests/Feature/Billing
git commit -m "Billing: add invoices, invoice lines and payments"

Task 6: The usage and audit ledger

billing_usage_events is where metered services record what a team consumed, billing_usage_rates prices them, and billing_events is the append-only audit of every billing state change. Nothing calls the recorder yet — this is the seam Twilio and the AI integration plug into.

Files:

  • Create: app/Models/Billing/UsageEvent.php
  • Create: app/Models/Billing/UsageRate.php
  • Create: app/Models/Billing/BillingEvent.php
  • Create: database/migrations/2026_09_09_101000_create_billing_usage_rates_table.php
  • Create: database/migrations/2026_09_09_101100_create_billing_usage_events_table.php
  • Create: database/migrations/2026_09_09_101200_create_billing_events_table.php
  • Test: tests/Feature/Billing/BillingLedgerTest.php

Interfaces:

  • Consumes: Task 5's App\Models\Billing\Invoice.

  • Produces:

    • App\Models\Billing\UsageEvent on billing_usage_events (ULID key), with scopeForPeriod, scopeUninvoiced, team(): BelongsTo, invoice(): BelongsTo.
    • App\Models\Billing\UsageRate on billing_usage_rates, with UsageRate::resolve(string $service, Team $team, ?CarbonInterface $moment = null): ?UsageRate — the team override wins over the platform default, most recent effective_from on or before the moment wins.
    • App\Models\Billing\BillingEvent on billing_events, with BillingEvent::record(?Team $team, string $type, array $payload = [], ?int $actorUserId = null, ?string $stripeEventId = null): BillingEvent and BillingEvent::hasHandledStripeEvent(string $stripeEventId): bool.
  • Step 1: Write the failing test

Create tests/Feature/Billing/BillingLedgerTest.php:

php
<?php

namespace Tests\Feature\Billing;

use App\Enums\TeamRole;
use App\Models\Billing\BillingEvent;
use App\Models\Billing\UsageEvent;
use App\Models\Billing\UsageRate;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;

/**
 * The usage ledger and the billing audit trail (design doc "Usage metering").
 */
class BillingLedgerTest extends BillingTestCase
{
    use RefreshDatabase;

    protected function tearDown(): void
    {
        Carbon::setTestNow();

        parent::tearDown();
    }

    public function test_a_usage_event_keeps_the_rate_it_was_priced_at(): void
    {
        $team = $this->team();

        $event = UsageEvent::create([
            'team_id' => $team->id,
            'service' => 'sms',
            'quantity' => 1500,
            'unit' => 'message',
            'unit_amount_cents' => 50,
            'amount_cents' => 75000,
            'occurred_at' => '2026-08-14 10:22:00',
            'billing_period' => '2026-08',
        ]);

        UsageRate::create([
            'service' => 'sms',
            'team_id' => null,
            'unit' => 'message',
            'unit_amount_cents' => 90,
            'effective_from' => '2026-09-01 00:00:00',
        ]);

        $this->assertSame(50, $event->fresh()->unit_amount_cents);
        $this->assertSame(75000, $event->fresh()->amount_cents);
    }

    public function test_a_usage_event_gets_a_ulid_key(): void
    {
        $team = $this->team();

        $event = UsageEvent::create([
            'team_id' => $team->id,
            'service' => 'ai_tokens',
            'quantity' => 250000,
            'unit' => 'token',
            'unit_amount_cents' => 1,
            'amount_cents' => 250000,
            'occurred_at' => '2026-08-14 10:22:00',
            'billing_period' => '2026-08',
        ]);

        $this->assertIsString($event->id);
        $this->assertSame(26, strlen($event->id));
    }

    public function test_uninvoiced_events_for_a_period_can_be_found(): void
    {
        $team = $this->team();

        UsageEvent::create([
            'team_id' => $team->id, 'service' => 'sms', 'quantity' => 10, 'unit' => 'message',
            'unit_amount_cents' => 50, 'amount_cents' => 500,
            'occurred_at' => '2026-08-02 09:00:00', 'billing_period' => '2026-08',
        ]);
        UsageEvent::create([
            'team_id' => $team->id, 'service' => 'sms', 'quantity' => 20, 'unit' => 'message',
            'unit_amount_cents' => 50, 'amount_cents' => 1000,
            'occurred_at' => '2026-08-03 09:00:00', 'billing_period' => '2026-08',
            'invoiced_at' => '2026-09-01 00:05:00',
        ]);
        UsageEvent::create([
            'team_id' => $team->id, 'service' => 'sms', 'quantity' => 30, 'unit' => 'message',
            'unit_amount_cents' => 50, 'amount_cents' => 1500,
            'occurred_at' => '2026-09-02 09:00:00', 'billing_period' => '2026-09',
        ]);

        $pending = UsageEvent::query()->forPeriod('2026-08')->uninvoiced()->get();

        $this->assertCount(1, $pending);
        $this->assertSame(500, $pending->sum('amount_cents'));
    }

    public function test_a_team_rate_override_beats_the_platform_default(): void
    {
        $team = $this->team();

        UsageRate::create([
            'service' => 'sms', 'team_id' => null, 'unit' => 'message',
            'unit_amount_cents' => 50, 'effective_from' => '2026-01-01 00:00:00',
        ]);
        UsageRate::create([
            'service' => 'sms', 'team_id' => $team->id, 'unit' => 'message',
            'unit_amount_cents' => 35, 'effective_from' => '2026-06-01 00:00:00',
        ]);

        $rate = UsageRate::resolve('sms', $team, Carbon::parse('2026-08-14 10:00:00'));

        $this->assertSame(35, $rate?->unit_amount_cents);
    }

    public function test_the_most_recent_rate_in_force_wins(): void
    {
        $team = $this->team();

        UsageRate::create([
            'service' => 'sms', 'team_id' => null, 'unit' => 'message',
            'unit_amount_cents' => 50, 'effective_from' => '2026-01-01 00:00:00',
        ]);
        UsageRate::create([
            'service' => 'sms', 'team_id' => null, 'unit' => 'message',
            'unit_amount_cents' => 90, 'effective_from' => '2026-09-01 00:00:00',
        ]);

        $this->assertSame(50, UsageRate::resolve('sms', $team, Carbon::parse('2026-08-14 10:00:00'))?->unit_amount_cents);
        $this->assertSame(90, UsageRate::resolve('sms', $team, Carbon::parse('2026-09-14 10:00:00'))?->unit_amount_cents);
    }

    public function test_an_unpriced_service_resolves_to_nothing(): void
    {
        $team = $this->team();

        $this->assertNull(UsageRate::resolve('carrier_pigeon', $team, Carbon::parse('2026-08-14 10:00:00')));
    }

    public function test_a_billing_event_records_who_did_it(): void
    {
        $team = $this->team();
        $admin = $this->member($team, TeamRole::Admin);

        $event = BillingEvent::record($team, 'grace.extended', ['days' => 14], $admin->id);

        $this->assertSame('grace.extended', $event->type);
        $this->assertSame($admin->id, $event->actor_user_id);
        $this->assertSame(14, $event->payload['days']);
    }

    public function test_a_system_billing_event_has_no_actor(): void
    {
        $team = $this->team();

        $event = BillingEvent::record($team, 'invoice.payment_failed', ['attempt' => 1]);

        $this->assertNull($event->actor_user_id);
    }

    public function test_a_stripe_event_is_only_handled_once(): void
    {
        $team = $this->team();

        $this->assertFalse(BillingEvent::hasHandledStripeEvent('evt_1QcExAmPlE'));

        BillingEvent::record($team, 'invoice.paid', [], null, 'evt_1QcExAmPlE');

        $this->assertTrue(BillingEvent::hasHandledStripeEvent('evt_1QcExAmPlE'));
    }
}
  • Step 2: Run the test to verify it fails

Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing/BillingLedgerTest.php Expected: FAIL — Class "App\Models\Billing\UsageEvent" not found.

  • Step 3: Write the three migrations

Create database/migrations/2026_09_09_101000_create_billing_usage_rates_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_usage_rates', function (Blueprint $table) {
            $table->id();
            $table->string('service')->index();
            $table->unsignedBigInteger('team_id')->nullable()->index();
            $table->string('unit', 30);
            $table->unsignedBigInteger('unit_amount_cents');
            $table->timestamp('effective_from');
            $table->timestamps();

            $table->unique(['service', 'team_id', 'effective_from'], 'billing_usage_rates_scope_unique');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('billing_usage_rates');
    }
};

A null team_id is the platform default rate; a set one is a negotiated override.

Create database/migrations/2026_09_09_101100_create_billing_usage_events_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_usage_events', function (Blueprint $table) {
            $table->ulid('id')->primary();
            $table->unsignedBigInteger('team_id');
            $table->string('service')->index();
            $table->decimal('quantity', 20, 6);
            $table->string('unit', 30);
            $table->unsignedBigInteger('unit_amount_cents');
            $table->unsignedBigInteger('amount_cents');
            $table->timestamp('occurred_at');
            $table->string('billing_period', 7);
            $table->string('reference_type')->nullable();
            $table->string('reference_id')->nullable();
            $table->json('metadata')->nullable();
            $table->timestamp('invoiced_at')->nullable();
            $table->unsignedBigInteger('billing_invoice_id')->nullable()->index();
            $table->timestamps();

            $table->index(['team_id', 'billing_period']);
            $table->index(['billing_period', 'invoiced_at']);
            $table->index(['reference_type', 'reference_id']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('billing_usage_events');
    }
};

Create database/migrations/2026_09_09_101200_create_billing_events_table.php:

php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

/**
 * The append-only audit of billing state changes.
 *
 * A dedicated table rather than `owen-it/laravel-auditing`, for the same
 * reason `superadmin_impersonation_logs` is one: these events have two
 * actors (or none — Stripe is not a user) and are not changes to a model's
 * own attributes, so forcing them through `Auditable` would misrepresent
 * them.
 *
 * The unique `stripe_event_id` doubles as the webhook idempotency key: a
 * replayed webhook cannot double-charge or double-transition.
 */
return new class extends Migration
{
    public function up(): void
    {
        Schema::create('billing_events', function (Blueprint $table) {
            $table->id();
            $table->unsignedBigInteger('team_id')->nullable()->index();
            $table->string('type')->index();
            $table->unsignedBigInteger('actor_user_id')->nullable()->index();
            $table->string('stripe_event_id')->nullable()->unique();
            $table->json('payload')->nullable();
            $table->timestamp('created_at')->nullable();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('billing_events');
    }
};
  • Step 4: Write the three models

Create app/Models/Billing/UsageEvent.php:

php
<?php

namespace App\Models\Billing;

use App\Models\Team;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;

/**
 * One metered thing a team consumed.
 *
 * The rate is denormalized onto the row at record time, which is what makes
 * the ledger immutable: changing the SMS price next month never rewrites
 * what August cost.
 *
 * @property string $id
 * @property int $team_id
 * @property string $service
 * @property string $quantity
 * @property string $unit
 * @property int $unit_amount_cents
 * @property int $amount_cents
 * @property Carbon $occurred_at
 * @property string $billing_period
 * @property string|null $reference_type
 * @property string|null $reference_id
 * @property array<string, mixed>|null $metadata
 * @property Carbon|null $invoiced_at
 * @property int|null $billing_invoice_id
 * @property Carbon|null $created_at
 * @property Carbon|null $updated_at
 * @property-read Team $team
 * @property-read Invoice|null $invoice
 */
class UsageEvent extends Model
{
    use HasUlids;

    protected $table = 'billing_usage_events';

    protected $guarded = [];

    protected $casts = [
        'team_id' => 'integer',
        'unit_amount_cents' => 'integer',
        'amount_cents' => 'integer',
        'occurred_at' => 'datetime',
        'metadata' => 'array',
        'invoiced_at' => 'datetime',
        'billing_invoice_id' => 'integer',
        'created_at' => 'datetime',
        'updated_at' => 'datetime',
    ];

    /**
     * @return BelongsTo<Team, $this>
     */
    public function team(): BelongsTo
    {
        return $this->belongsTo(Team::class);
    }

    /**
     * @return BelongsTo<Invoice, $this>
     */
    public function invoice(): BelongsTo
    {
        return $this->belongsTo(Invoice::class, 'billing_invoice_id');
    }

    /**
     * @param  Builder<self>  $query
     * @return Builder<self>
     */
    public function scopeForPeriod(Builder $query, string $period): Builder
    {
        return $query->where('billing_period', $period);
    }

    /**
     * @param  Builder<self>  $query
     * @return Builder<self>
     */
    public function scopeUninvoiced(Builder $query): Builder
    {
        return $query->whereNull('invoiced_at');
    }
}

quantity is deliberately uncast — it is a decimal(20,6) and a float cast would lose precision on large token counts.

Create app/Models/Billing/UsageRate.php:

php
<?php

namespace App\Models\Billing;

use App\Models\Team;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;

/**
 * What a metered service costs. A row with a null `team_id` is the platform
 * default; one with a team is that team's negotiated override.
 *
 * @property int $id
 * @property string $service
 * @property int|null $team_id
 * @property string $unit
 * @property int $unit_amount_cents
 * @property Carbon $effective_from
 * @property Carbon|null $created_at
 * @property Carbon|null $updated_at
 */
class UsageRate extends Model
{
    protected $table = 'billing_usage_rates';

    protected $guarded = [];

    protected $casts = [
        'team_id' => 'integer',
        'unit_amount_cents' => 'integer',
        'effective_from' => 'datetime',
        'created_at' => 'datetime',
        'updated_at' => 'datetime',
    ];

    /**
     * The rate in force for this team and service at the given moment: the
     * team's own override if it has one, otherwise the platform default,
     * taking the most recent row effective on or before that moment.
     */
    public static function resolve(string $service, Team $team, ?CarbonInterface $moment = null): ?self
    {
        $moment ??= Carbon::now();

        $find = fn (?int $teamId) => self::query()
            ->where('service', $service)
            ->where('team_id', $teamId)
            ->where('effective_from', '<=', $moment)
            ->orderByDesc('effective_from')
            ->first();

        return $find($team->id) ?? $find(null);
    }
}

Create app/Models/Billing/BillingEvent.php:

php
<?php

namespace App\Models\Billing;

use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;

/**
 * An append-only record of a billing state change.
 *
 * Named `BillingEvent` rather than `Event` despite already living in the
 * `Billing` namespace, so a file importing it alongside Laravel's `Event`
 * facade never has to alias one of them.
 *
 * @property int $id
 * @property int|null $team_id
 * @property string $type
 * @property int|null $actor_user_id
 * @property string|null $stripe_event_id
 * @property array<string, mixed>|null $payload
 * @property Carbon|null $created_at
 * @property-read Team|null $team
 * @property-read User|null $actor
 */
class BillingEvent extends Model
{
    protected $table = 'billing_events';

    public const UPDATED_AT = null;

    protected $guarded = [];

    protected $casts = [
        'team_id' => 'integer',
        'actor_user_id' => 'integer',
        'payload' => 'array',
        'created_at' => 'datetime',
    ];

    /**
     * @return BelongsTo<Team, $this>
     */
    public function team(): BelongsTo
    {
        return $this->belongsTo(Team::class);
    }

    /**
     * @return BelongsTo<User, $this>
     */
    public function actor(): BelongsTo
    {
        return $this->belongsTo(User::class, 'actor_user_id');
    }

    /**
     * @param  array<string, mixed>  $payload
     */
    public static function record(
        ?Team $team,
        string $type,
        array $payload = [],
        ?int $actorUserId = null,
        ?string $stripeEventId = null,
    ): self {
        return self::create([
            'team_id' => $team?->id,
            'type' => $type,
            'payload' => $payload,
            'actor_user_id' => $actorUserId,
            'stripe_event_id' => $stripeEventId,
        ]);
    }

    /**
     * Whether this Stripe event has already been processed. The unique index
     * on `stripe_event_id` is the real guarantee; this is the cheap check
     * before doing the work.
     */
    public static function hasHandledStripeEvent(string $stripeEventId): bool
    {
        return self::query()->where('stripe_event_id', $stripeEventId)->exists();
    }
}
  • Step 5: Run the test to verify it passes

Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing/BillingLedgerTest.php Expected: PASS, 9 tests.

  • Step 6: Format and commit
bash
vendor/bin/pint --dirty --format agent
git add app/Models/Billing database/migrations tests/Feature/Billing
git commit -m "Billing: add the usage ledger and billing audit trail"

Task 7: The entitlement resolver

The single thing the application asks about billing permissions. It reads the database, never Stripe, and is cached.

Files:

  • Create: app/Support/Billing/Entitlement.php
  • Create: app/Support/Billing/Entitlements.php
  • Test: tests/Feature/Billing/EntitlementsTest.php

Interfaces:

  • Consumes: Tasks 2–4 (BillingType, BillingAccessState, Team::activeSeatCount(), Product::specializedModuleKeys(), TeamModule::activeModuleKeysFor()).

  • Produces:

    • App\Support\Billing\Entitlement — a readonly value object with public properties accessState: BillingAccessState, allowedModuleKeys: ?array<int, string>, seatCap: ?int, storageQuotaBytes: int, storageUsedBytes: int, graceEndsAt: ?CarbonImmutable; and methods grantsAppAccess(): bool, allowsModule(string $moduleKey): bool, hasSeatCapacity(int $currentSeats): bool, storagePercentUsed(): float, isOverStorageQuota(): bool.
    • App\Support\Billing\Entitlements::for(?Team $team): Entitlement
    • App\Support\Billing\Entitlements::flush(): void
    • App\Support\Billing\Entitlements::DEFAULT_STORAGE_BYTES_PER_SEAT (int, 10 GB).
  • Step 1: Write the failing test

Create tests/Feature/Billing/EntitlementsTest.php:

php
<?php

namespace Tests\Feature\Billing;

use App\Enums\BillingAccessState;
use App\Enums\BillingType;
use App\Enums\TeamRole;
use App\Models\Billing\Product;
use App\Models\Billing\TeamModule;
use App\Models\System\Module;
use App\Support\Billing\Entitlements;
use Database\Seeders\BillingProductsSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;

/**
 * The one thing the application asks about billing permissions (design doc
 * "Architecture: three layers").
 */
class EntitlementsTest extends BillingTestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();

        Product::flushCache();
        Entitlements::flush();
        $this->seed(BillingProductsSeeder::class);

        // `System\Module` reads the default connection, which is an empty
        // in-memory SQLite database in tests. The resolver enumerates standard
        // modules from this table, so the rows these tests reason about have to
        // exist. Created explicitly rather than by running `ModulesSeeder`,
        // matching how `tests/Feature/Support/ModulesMenuFilterTest` does it.
        foreach ([
            ['key' => 'crm', 'name' => 'CRM', 'order' => 1],
            ['key' => 'warehouse', 'name' => 'Warehouse', 'order' => 2],
            ['key' => 'logistics3p', 'name' => '3PL', 'order' => 3],
            ['key' => 'containers', 'name' => 'Container Depot', 'order' => 4],
        ] as $module) {
            Module::create($module + ['menu' => true, 'status' => true]);
        }
    }

    protected function tearDown(): void
    {
        Product::flushCache();
        Entitlements::flush();
        Carbon::setTestNow();

        parent::tearDown();
    }

    public function test_an_enterprise_team_has_no_billing_module_restriction(): void
    {
        $team = $this->team();

        $entitlement = Entitlements::for($team);

        $this->assertNull($entitlement->allowedModuleKeys);
        $this->assertTrue($entitlement->allowsModule('logistics3p'));
        $this->assertTrue($entitlement->allowsModule('crm'));
    }

    public function test_a_self_serve_team_gets_standard_modules_but_not_specialized_ones(): void
    {
        $team = $this->team();
        $team->forceFill(['billing_type' => BillingType::SelfServe])->save();

        $entitlement = Entitlements::for($team->fresh());

        $this->assertTrue($entitlement->allowsModule('crm'));
        $this->assertTrue($entitlement->allowsModule('warehouse'));
        $this->assertFalse($entitlement->allowsModule('logistics3p'));
        $this->assertFalse($entitlement->allowsModule('containers'));
    }

    public function test_a_self_serve_team_holding_a_module_may_reach_it(): void
    {
        $team = $this->team();
        $team->forceFill(['billing_type' => BillingType::SelfServe])->save();
        TeamModule::create([
            'team_id' => $team->id,
            'billing_product_id' => Product::where('key', 'module.logistics3p')->value('id'),
            'starts_at' => '2026-09-01 00:00:00',
        ]);
        Carbon::setTestNow('2026-09-15 12:00:00');

        $entitlement = Entitlements::for($team->fresh());

        $this->assertTrue($entitlement->allowsModule('logistics3p'));
        $this->assertFalse($entitlement->allowsModule('containers'));
    }

    public function test_a_dropped_module_stops_being_allowed_once_the_period_ends(): void
    {
        $team = $this->team();
        $team->forceFill(['billing_type' => BillingType::SelfServe])->save();
        TeamModule::create([
            'team_id' => $team->id,
            'billing_product_id' => Product::where('key', 'module.containers')->value('id'),
            'starts_at' => '2026-08-01 00:00:00',
            'ends_at' => '2026-10-01 00:00:00',
        ]);

        Carbon::setTestNow('2026-09-30 23:59:59');
        Entitlements::flush();
        $this->assertTrue(Entitlements::for($team->fresh())->allowsModule('containers'));

        Carbon::setTestNow('2026-10-01 00:00:01');
        Entitlements::flush();
        $this->assertFalse(Entitlements::for($team->fresh())->allowsModule('containers'));
    }

    public function test_access_follows_the_teams_state(): void
    {
        $team = $this->team();

        $this->assertTrue(Entitlements::for($team)->grantsAppAccess());

        $team->forceFill(['billing_access_state' => BillingAccessState::Restricted])->save();
        Entitlements::flush();

        $this->assertFalse(Entitlements::for($team->fresh())->grantsAppAccess());
    }

    public function test_storage_defaults_to_ten_gigabytes_per_active_seat(): void
    {
        $team = $this->team();
        $this->member($team, TeamRole::Owner);
        $this->member($team, TeamRole::Member);
        $this->member($team, TeamRole::Member);

        $entitlement = Entitlements::for($team->fresh());

        $this->assertSame(3 * 10 * 1024 * 1024 * 1024, $entitlement->storageQuotaBytes);
    }

    public function test_an_explicit_storage_quota_overrides_the_per_seat_default(): void
    {
        $team = $this->team();
        $this->member($team, TeamRole::Owner);
        $team->forceFill(['billing_storage_quota_bytes' => 53687091200])->save();

        $this->assertSame(53687091200, Entitlements::for($team->fresh())->storageQuotaBytes);
    }

    public function test_storage_usage_is_reported_against_the_quota(): void
    {
        $team = $this->team();
        $this->member($team, TeamRole::Owner);
        $team->forceFill([
            'billing_storage_quota_bytes' => 1000,
            'billing_storage_used_bytes' => 850,
        ])->save();

        $entitlement = Entitlements::for($team->fresh());

        $this->assertSame(85.0, $entitlement->storagePercentUsed());
        $this->assertFalse($entitlement->isOverStorageQuota());

        $team->forceFill(['billing_storage_used_bytes' => 1200])->save();
        Entitlements::flush();

        $this->assertTrue(Entitlements::for($team->fresh())->isOverStorageQuota());
    }

    public function test_a_seat_cap_is_enforced_only_when_set(): void
    {
        $team = $this->team();

        $this->assertNull(Entitlements::for($team)->seatCap);
        $this->assertTrue(Entitlements::for($team)->hasSeatCapacity(9999));

        $team->forceFill(['billing_seat_cap' => 3])->save();
        Entitlements::flush();

        $entitlement = Entitlements::for($team->fresh());
        $this->assertTrue($entitlement->hasSeatCapacity(2));
        $this->assertFalse($entitlement->hasSeatCapacity(3));
    }

    public function test_a_null_team_is_fully_unrestricted(): void
    {
        $entitlement = Entitlements::for(null);

        $this->assertTrue($entitlement->grantsAppAccess());
        $this->assertNull($entitlement->allowedModuleKeys);
        $this->assertTrue($entitlement->allowsModule('logistics3p'));
    }

    public function test_the_resolver_does_not_query_per_call(): void
    {
        $team = $this->team();
        Entitlements::for($team);

        \Illuminate\Support\Facades\DB::enableQueryLog();
        Entitlements::for($team);
        $queries = \Illuminate\Support\Facades\DB::getQueryLog();
        \Illuminate\Support\Facades\DB::disableQueryLog();

        $this->assertCount(0, $queries);
    }
}
  • Step 2: Run the test to verify it fails

Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing/EntitlementsTest.php Expected: FAIL — Class "App\Support\Billing\Entitlements" not found.

  • Step 3: Write the value object

Create app/Support/Billing/Entitlement.php:

php
<?php

namespace App\Support\Billing;

use App\Enums\BillingAccessState;
use Carbon\CarbonImmutable;

/**
 * What a team may do right now, according to billing.
 *
 * Deliberately a plain immutable object with no database access of its own:
 * everything it needs is resolved once, by `Entitlements`, and every
 * enforcement point in the application reads this rather than reaching for
 * Stripe or for `teams` columns directly.
 */
readonly class Entitlement
{
    /**
     * @param  array<int, string>|null  $allowedModuleKeys  Null means billing
     *                                                      imposes no module restriction at all — the team's own
     *                                                      `modulesAllowed` configuration still applies on top.
     */
    public function __construct(
        public BillingAccessState $accessState,
        public ?array $allowedModuleKeys,
        public ?int $seatCap,
        public int $storageQuotaBytes,
        public int $storageUsedBytes,
        public ?CarbonImmutable $graceEndsAt = null,
    ) {}

    public function grantsAppAccess(): bool
    {
        return $this->accessState->grantsAppAccess();
    }

    public function allowsModule(string $moduleKey): bool
    {
        return $this->allowedModuleKeys === null
            || in_array($moduleKey, $this->allowedModuleKeys, true);
    }

    /**
     * Whether one more seat may be added, given how many are occupied now.
     * A null cap means unlimited.
     */
    public function hasSeatCapacity(int $currentSeats): bool
    {
        return $this->seatCap === null || $currentSeats < $this->seatCap;
    }

    public function storagePercentUsed(): float
    {
        if ($this->storageQuotaBytes <= 0) {
            return 100.0;
        }

        return round($this->storageUsedBytes / $this->storageQuotaBytes * 100, 2);
    }

    public function isOverStorageQuota(): bool
    {
        return $this->storageUsedBytes >= $this->storageQuotaBytes;
    }
}
  • Step 4: Write the resolver

Create app/Support/Billing/Entitlements.php:

php
<?php

namespace App\Support\Billing;

use App\Enums\BillingAccessState;
use App\Enums\BillingType;
use App\Models\Billing\Product;
use App\Models\Billing\TeamModule;
use App\Models\System\Module;
use App\Models\Team;
use Carbon\CarbonImmutable;

/**
 * Resolves what a team may do, from the database only — never from Stripe.
 *
 * Memoised per request by team id. Module visibility consults this on every
 * module of every page render, so it must not cost a query per call; the
 * memo is static and therefore survives across tests in one process, so the
 * billing suite flushes it in `setUp()` the same way
 * `ProjectPermissions`/`ProjectAccess` are flushed.
 */
class Entitlements
{
    /**
     * 10 GB per active user, pooled across the team, for any team without an
     * explicit quota. The per-seat calculation is floored at one seat: a team
     * with no active members cannot be used by anyone, so one seat's worth is
     * inert, whereas a quota of zero would make `isOverStorageQuota()` answer
     * true for a team that has stored nothing. Seatless teams are reachable —
     * `SuperAdmin\TeamController::store()` creates a team with no membership
     * and adds members on the next screen.
     */
    public const DEFAULT_STORAGE_BYTES_PER_SEAT = 10 * 1024 * 1024 * 1024;

    /** @var array<int, Entitlement> */
    private static array $resolved = [];

    public static function for(?Team $team): Entitlement
    {
        if ($team === null) {
            return self::unrestricted();
        }

        return self::$resolved[$team->id] ??= self::resolve($team);
    }

    public static function flush(): void
    {
        self::$resolved = [];
    }

    private static function resolve(Team $team): Entitlement
    {
        $seats = $team->activeSeatCount();

        return new Entitlement(
            accessState: $team->billing_access_state ?? BillingAccessState::Active,
            allowedModuleKeys: self::allowedModuleKeys($team),
            seatCap: $team->billing_seat_cap,
            storageQuotaBytes: $team->billing_storage_quota_bytes
                ?? max(1, $seats) * self::DEFAULT_STORAGE_BYTES_PER_SEAT,
            storageUsedBytes: $team->billing_storage_used_bytes ?? 0,
            graceEndsAt: $team->billing_grace_ends_at?->toImmutable(),
        );
    }

    /**
     * The module keys billing permits.
     *
     * Enterprise and internal teams return null — billing imposes no
     * restriction on them, and their module set is administered directly
     * through `Team::$modulesAllowed`. Self-serve teams get every standard
     * module plus whichever specialized modules they currently hold.
     *
     * @return array<int, string>|null
     */
    private static function allowedModuleKeys(Team $team): ?array
    {
        if (($team->billing_type ?? BillingType::Enterprise) !== BillingType::SelfServe) {
            return null;
        }

        $specialized = Product::specializedModuleKeys();

        $standard = Module::query()
            ->pluck('key')
            ->reject(fn (string $key) => in_array($key, $specialized, true))
            ->values()
            ->all();

        return array_values(array_unique([
            ...$standard,
            ...TeamModule::activeModuleKeysFor($team),
        ]));
    }

    private static function unrestricted(): Entitlement
    {
        return new Entitlement(
            accessState: BillingAccessState::Active,
            allowedModuleKeys: null,
            seatCap: null,
            storageQuotaBytes: self::DEFAULT_STORAGE_BYTES_PER_SEAT,
            storageUsedBytes: 0,
        );
    }
}
  • Step 5: Run the test to verify it passes

Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing/EntitlementsTest.php Expected: PASS, 11 tests.

If test_the_resolver_does_not_query_per_call fails, the memo is not being hit — check that self::$resolved[$team->id] ??= ... is keyed on the id and not on the model instance.

  • Step 6: Format and commit
bash
vendor/bin/pint --dirty --format agent
git add app/Support/Billing tests/Feature/Billing
git commit -m "Billing: add the entitlement resolver"

Task 8: Wire entitlements into module visibility

The one place this increment touches live behaviour. App\Support\Modules gains the billing layer, intersecting the team's own module configuration with what billing permits. Every existing team is enterprise, whose entitlement is null (no restriction), so nothing visibly changes.

Files:

  • Modify: app/Support/Modules.php
  • Modify: tests/Feature/Billing/BillingTestCase.php (flush the resolver in setUp)
  • Test: tests/Feature/Billing/ModuleVisibilityTest.php

Interfaces:

  • Consumes: Task 7's Entitlements::for().

  • Produces: Modules::allowedForTeam() and Modules::isAllowedForTeam() keep their existing signatures and existing "null means no restriction" semantics, now intersected with the billing entitlement. Modules::isVisibleForUser() is unchanged and picks the new behaviour up through isAllowedForTeam().

  • Step 1: Write the failing test

Create tests/Feature/Billing/ModuleVisibilityTest.php:

php
<?php

namespace Tests\Feature\Billing;

use App\Enums\BillingType;
use App\Models\Billing\Product;
use App\Models\Billing\TeamModule;
use App\Models\System\Module;
use App\Support\Billing\Entitlements;
use App\Support\Modules;
use Database\Seeders\BillingProductsSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;

/**
 * Billing entitlements layered onto the existing module-visibility gate
 * (design doc "Entitlement enforcement points").
 */
class ModuleVisibilityTest extends BillingTestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();

        Product::flushCache();
        Entitlements::flush();
        $this->seed(BillingProductsSeeder::class);

        // See the note in EntitlementsTest: the resolver enumerates standard
        // modules from the `modules` table, which is empty in the test SQLite
        // database unless the test populates it.
        foreach ([
            ['key' => 'crm', 'name' => 'CRM', 'order' => 1],
            ['key' => 'warehouse', 'name' => 'Warehouse', 'order' => 2],
            ['key' => 'logistics3p', 'name' => '3PL', 'order' => 3],
            ['key' => 'containers', 'name' => 'Container Depot', 'order' => 4],
        ] as $module) {
            Module::create($module + ['menu' => true, 'status' => true]);
        }
    }

    protected function tearDown(): void
    {
        Product::flushCache();
        Entitlements::flush();
        Carbon::setTestNow();

        parent::tearDown();
    }

    public function test_an_enterprise_team_with_no_configuration_is_unrestricted_as_before(): void
    {
        $team = $this->team();

        $this->assertNull(Modules::allowedForTeam($team));
        $this->assertTrue(Modules::isAllowedForTeam($team, 'logistics3p'));
        $this->assertTrue(Modules::isAllowedForTeam($team, 'crm'));
    }

    public function test_an_enterprise_teams_own_configuration_still_decides(): void
    {
        $team = $this->team();
        $team->forceFill(['modulesAllowed' => ['crm' => true, 'logistics3p' => false]])->save();

        $this->assertSame(['crm'], Modules::allowedForTeam($team->fresh()));
        $this->assertFalse(Modules::isAllowedForTeam($team->fresh(), 'logistics3p'));
    }

    public function test_a_self_serve_team_cannot_reach_a_specialized_module_it_has_not_bought(): void
    {
        $team = $this->team();
        $team->forceFill(['billing_type' => BillingType::SelfServe])->save();

        $this->assertFalse(Modules::isAllowedForTeam($team->fresh(), 'logistics3p'));
        $this->assertTrue(Modules::isAllowedForTeam($team->fresh(), 'crm'));
    }

    public function test_a_self_serve_team_reaches_a_specialized_module_it_holds(): void
    {
        $team = $this->team();
        $team->forceFill(['billing_type' => BillingType::SelfServe])->save();
        TeamModule::create([
            'team_id' => $team->id,
            'billing_product_id' => Product::where('key', 'module.logistics3p')->value('id'),
            'starts_at' => '2026-09-01 00:00:00',
        ]);
        Carbon::setTestNow('2026-09-15 12:00:00');

        $this->assertTrue(Modules::isAllowedForTeam($team->fresh(), 'logistics3p'));
    }

    public function test_billing_and_team_configuration_are_intersected_not_replaced(): void
    {
        $team = $this->team();
        $team->forceFill([
            'billing_type' => BillingType::SelfServe,
            'modulesAllowed' => ['crm' => true, 'warehouse' => true, 'logistics3p' => true],
        ])->save();

        $allowed = Modules::allowedForTeam($team->fresh());

        $this->assertContains('crm', $allowed);
        $this->assertContains('warehouse', $allowed);
        $this->assertNotContains('logistics3p', $allowed, 'the team configured it, but billing has not sold it');
    }

    public function test_a_null_team_is_still_unrestricted(): void
    {
        $this->assertNull(Modules::allowedForTeam(null));
        $this->assertTrue(Modules::isAllowedForTeam(null, 'logistics3p'));
    }
}
  • Step 2: Run the test to verify it fails

Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing/ModuleVisibilityTest.php Expected: FAIL on test_a_self_serve_team_cannot_reach_a_specialized_module_it_has_not_bought — billing is not consulted yet, so it returns true.

  • Step 3: Flush the resolver in the shared test case

In tests/Feature/Billing/BillingTestCase.php, add setUp/tearDown so every billing test starts from a clean memo, rather than each test class repeating it:

php
    protected function setUp(): void
    {
        parent::setUp();

        Entitlements::flush();
        Product::flushCache();
    }

    protected function tearDown(): void
    {
        Entitlements::flush();
        Product::flushCache();

        parent::tearDown();
    }

with the imports use App\Models\Billing\Product; and use App\Support\Billing\Entitlements;. The per-class setUp/tearDown overrides written in earlier tasks still call parent:: first, so they keep working.

  • Step 4: Layer billing into Modules::allowedForTeam()

In app/Support/Modules.php, add the import:

php
use App\Support\Billing\Entitlements;

Replace the body of allowedForTeam() with:

php
    /**
     * The module keys enabled for a team, or null if neither the team's own
     * configuration nor its billing entitlement restricts anything.
     *
     * Two independent layers, intersected: the team's `modulesAllowed`
     * (administered by an operator) and what billing has actually sold them.
     * Either being null means "that layer has no opinion", not "everything
     * is disabled" — the same semantics this method has always had, now
     * applied to both inputs. An enterprise team's billing layer is always
     * null, so those teams behave exactly as they did before billing
     * existed.
     *
     * @return array<int, string>|null
     */
    public static function allowedForTeam(?Team $team): ?array
    {
        if (! $team) {
            return null;
        }

        $configured = empty($team->modulesAllowed)
            ? null
            : collect($team->modulesAllowed)
                ->filter(fn (bool $allowed) => $allowed)
                ->keys()
                ->values()
                ->all();

        $entitled = Entitlements::for($team)->allowedModuleKeys;

        if ($configured === null) {
            return $entitled;
        }

        if ($entitled === null) {
            return $configured;
        }

        return array_values(array_intersect($configured, $entitled));
    }

isAllowedForTeam() and isVisibleForUser() are unchanged — they already read through allowedForTeam().

Update the class docblock's paragraph about Team::$modulesAllowed to note that billing entitlements are now the second layer, referencing docs/superpowers/specs/2026-09-09-saas-monetization-design.md.

  • Step 5: Run the test to verify it passes

Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing/ModuleVisibilityTest.php Expected: PASS, 6 tests.

  • Step 6: Run the whole billing suite

Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing Expected: PASS, 52 tests.

  • Step 7: Prove nothing else changed

This is the increment's central claim — no user-visible change. tests/Feature/Support/ModulesTest.php and ModulesMenuFilterTest.php are the existing tests for the gate being modified and matter most here; there is no tests/Feature/Logistics3P directory, so Containers is the only specialized-module suite to run.

bash
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, with no test newly failing compared to main.

ModulesTest::test_allowed_for_team_is_null_when_modules_allowed_is_not_configured is the one to watch: it must still return null. It does, because a team defaults to enterprise, whose entitlement imposes no module restriction. If it fails, the default in Task 2's migration is wrong.

  • Step 8: Run static analysis

Run: herd php /Applications/MAMP/bin/php/composer types:check Expected: no new errors introduced by any billing file. Fix any that are.

  • Step 9: Format and commit
bash
vendor/bin/pint --dirty --format agent
git add app/Support/Modules.php tests/Feature/Billing
git commit -m "Billing: gate specialized modules on the billing entitlement"

Done when

  • herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing passes.
  • The full suite passes with no new failures relative to main.
  • herd php /Applications/MAMP/bin/php/composer types:check reports no new errors.
  • vendor/bin/pint --test --parallel is clean.
  • Every team in the database reads as billing_type = enterprise, billing_access_state = active, and behaves exactly as it did before.

Deliberately not in this increment

Each is owned by a later increment of the spec, and none of it should be built here:

  • Any Stripe API call, webhook route, or charge of any kind (increment 4).
  • The PaymentGateway interface and its fake (increment 4 — nothing calls Stripe yet, so there is nothing to abstract).
  • EnsureTeamNotRestricted, EnsureModuleEntitled, Restricted.vue, Recover.vue (increment 3).
  • Seat-cap enforcement in the invite/activate actions, and storage-quota enforcement at the upload sites — the resolver exposes seatCap, hasSeatCapacity(), storageQuotaBytes and isOverStorageQuota(), but nothing calls them yet (increment 3).
  • billing:recalculate-storagebilling_storage_used_bytes stays 0 until increment 3 populates it (increment 3).
  • Usage::record(), billing:close-usage-period, the invoice.created handler (increment 6).
  • The superadmin billing console and its routes (increment 2).
  • billing:retry-past-due, dunning emails, grace transitions (increment 5).
  • Seeding real Stripe Price ids into billing_products — an operator does that once the Prices exist in Stripe.