OCTO Ops Help User guides and product documentation

2026 09 10 Billing 2C Overview And List

On this page 7

Billing 2c — Overview and Teams List 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: Give a superadmin the cross-team view — a dashboard of what the platform is worth and who needs attention, and a searchable list of every billing account — without the per-row query explosion a naive implementation would cause.

Architecture: Two query objects in app/Queries/Billing/ own the reads; two thin read-only controllers render them. The organizing rule is SQL narrows, PHP applies the billing rules: no rule gets a second implementation in SQL, so MRR sums through the existing BillingCycle::monthlyEquivalentCents() and the storage-quota fallback moves into one method both the list and Entitlements::resolve() call.

Tech Stack: Laravel 13, PHP 8.4, Inertia v3 + Vue 3, Wayfinder, Bootstrap/Metronic, MySQL (SQLite in tests), PHPUnit 12, Pint, Larastan.

Spec: docs/superpowers/specs/2026-09-09-saas-monetization-design.md — see its "2c in detail" section.

Global Constraints

  • PHP binary: plain php is MAMP's PHP 8.2 and too old. Artisan runs as herd php artisan .... Composer is herd php /Applications/MAMP/bin/php/composer <args>.
  • Tests: herd php -d memory_limit=2G artisan test --compact <path>; for the whole suite use herd php -d memory_limit=2G vendor/bin/phpunit (artisan test spawns children that do NOT inherit -d memory_limit).
  • No new model factories. TeamFactory, UserFactory, TeamInvitationFactory exist and may be used. Build billing rows with explicit Model::create([...]).
  • All billing tables are MySQL (the default connection; sqlite in tests). Never MongoDB.
  • Amounts are integer cents — never floats. All formatting goes through App\Support\Billing\Money::format(). No .vue file performs arithmetic on money or bytes — the controller emits every derived value the template needs.
  • No billing rule gets a second implementation in SQL. SQL narrows the set; PHP applies the rule. If you find yourself writing CASE WHEN interval = 'yearly' THEN amount / 12 or COALESCE(quota, GREATEST(1, seats) * 10737418240) in a query, stop — fetch the columns and apply the existing PHP method instead.
  • 2c writes nothing. No Action is called, no column is written, no BillingEvent recorded. Both routes are GET.
  • No Action performs authorization. Every route MUST sit inside the existing EnsureSuperAdmin group.
  • Formatting: vendor/bin/pint --dirty --format agent, then npm run format and npm run lint. npm run lint is eslint . --fix and rewrites unrelated files repo-wide — revert that collateral so commits stay scoped.
  • Wayfinder: after adding routes, herd php artisan wayfinder:generate --with-form. The --with-form flag is mandatory — the bare command strips .form() and breaks existing pages. Import names in this plan are illustrative: the generator emits per-module files, so run ls -R resources/js/routes/superadmin/billing and import what is actually there.
  • npm run build after any .vue change, before any Inertia render test. A stale Vite manifest fails the test with a ViteException that looks like a code error but is not.
  • Frontend conventions: copy resources/js/pages/superadmin/billing/teams/Show.vue and resources/js/pages/superadmin/teams/Index.vue. Bootstrap cards, server-side paginate(), Heading component, breadcrumbs via defineOptions, types imported from @/types (the barrel, not a deep path). No DataTables, no FilterDrawer. v-html on a <Link> is an ESLint error — wrap it: <Link ...><span v-html="link.label"></span></Link>.
  • Model conventions: explicit return types and parameter type hints, curly braces everywhere, PHPDoc over inline comments.

Known pre-existing failures

24 tests fail across the suite and are NOT this plan's to fix: nine ViteExceptions needing npm run build, plus missing routes, an undefined Asset::maintenances(), int-vs-float strictness in the 3PL billable-qty tests, and empty Mongo reference tables. npm run lint also has exactly one pre-existing error, in resources/js/pages/maintenance/work-orders/Show.vue. tests/Feature/Billing, tests/Unit/Billing and tests/Feature/SuperAdmin are all green — any failure there is yours. Scoped PHPStan over the billing/superadmin paths must stay at zero.


Task 1: Shared rules and the team-list query

The storage-quota rule extracted so it has one implementation, a byte formatter both screens can reach, and the set-based query that makes a 25-team page cheap.

Files:

  • Modify: app/Support/Billing/Entitlements.php
  • Create: app/Support/Billing/Bytes.php
  • Modify: app/Http/Controllers/SuperAdmin/Billing/TeamBillingController.php
  • Create: app/Queries/Billing/TeamBillingList.php
  • Test: tests/Unit/Billing/BytesTest.php
  • Test: tests/Feature/Billing/TeamBillingListTest.php

Interfaces:

  • Consumes: App\Support\Billing\Money::format(), App\Support\Billing\Entitlements::for(), App\Enums\BillingCycle::monthlyEquivalentCents(), Team::activeSeatCount().

  • Produces:

    • Entitlements::storageQuotaBytesFor(?int $explicitQuotaBytes, int $activeSeats): int
    • App\Support\Billing\Bytes::format(int $bytes): string
    • App\Queries\Billing\TeamBillingList::query(bool $includePersonal = false): Builder<Team>
    • App\Queries\Billing\TeamBillingList::row(Team $team): array<string, mixed> — keys id, name, slug, is_personal, billing_type, billing_type_label, access_state, access_state_label, seats_used, seat_cap, modules_allowed, monthly_cents, monthly, renewal_at, storage_quota_bytes, storage_used, storage_quota, storage_percent, outstanding_cents, outstanding.
  • Step 1: Write the failing Bytes test

Create tests/Unit/Billing/BytesTest.php:

php
<?php

namespace Tests\Unit\Billing;

use App\Support\Billing\Bytes;
use PHPUnit\Framework\TestCase;

/**
 * Byte formatting shared by the billing screens (design doc, "2c in detail"
 * → "Query objects").
 */
class BytesTest extends TestCase
{
    public function test_it_formats_whole_gigabytes_without_a_trailing_zero(): void
    {
        $this->assertSame('50 GB', Bytes::format(50 * 1024 ** 3));
        $this->assertSame('25 GB', Bytes::format(25 * 1024 ** 3));
    }

    public function test_it_formats_a_fractional_gigabyte_to_one_place(): void
    {
        $this->assertSame('1.5 GB', Bytes::format((int) (1.5 * 1024 ** 3)));
    }

    public function test_it_steps_down_to_megabytes(): void
    {
        $this->assertSame('5 MB', Bytes::format(5 * 1024 ** 2));
    }

    public function test_it_reports_small_values_in_bytes(): void
    {
        $this->assertSame('512 B', Bytes::format(512));
        $this->assertSame('0 B', Bytes::format(0));
    }
}
  • Step 2: Run it to verify it fails

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

  • Step 3: Write Bytes

Create app/Support/Billing/Bytes.php:

php
<?php

namespace App\Support\Billing;

/**
 * Byte counts rendered for a human (design doc, "2c in detail" → "Query
 * objects").
 *
 * Lifted out of `SuperAdmin\Billing\TeamBillingController`, where it was
 * private, so the Teams list and the Overview share one implementation
 * rather than each growing their own.
 */
class Bytes
{
    public static function format(int $bytes): string
    {
        if ($bytes >= 1024 ** 3) {
            return round($bytes / 1024 ** 3, 1).' GB';
        }

        if ($bytes >= 1024 ** 2) {
            return round($bytes / 1024 ** 2, 1).' MB';
        }

        return $bytes.' B';
    }
}
  • Step 4: Point the detail controller at it

In app/Http/Controllers/SuperAdmin/Billing/TeamBillingController.php: delete the private formatBytes() method, add use App\Support\Billing\Bytes;, and replace both self::formatBytes(...) calls with Bytes::format(...).

  • Step 5: Run the Bytes test and the detail-screen test

Run: herd php -d memory_limit=2G artisan test --compact tests/Unit/Billing/BytesTest.php tests/Feature/SuperAdmin/Billing/TeamBillingScreenTest.php Expected: PASS. The detail-screen test already asserts '50 GB' and '25 GB', so it is what proves the move changed no behaviour.

  • Step 6: Write the failing query-object test

Create tests/Feature/Billing/TeamBillingListTest.php:

php
<?php

namespace Tests\Feature\Billing;

use App\Enums\BillingAccessState;
use App\Enums\BillingCycle;
use App\Enums\BillingInvoiceSource;
use App\Enums\BillingInvoiceStatus;
use App\Enums\BillingPaymentMethod;
use App\Enums\BillingType;
use App\Enums\TeamRole;
use App\Models\Billing\Invoice;
use App\Models\Billing\Payment;
use App\Models\Team;
use App\Queries\Billing\TeamBillingList;
use App\Support\Billing\Entitlements;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;

/**
 * The set-based team list (design doc, "2c in detail" → "The organizing
 * principle").
 */
class TeamBillingListTest extends BillingTestCase
{
    use RefreshDatabase;

    /**
     * @param  array<string, mixed>  $attributes
     */
    private function billingTeam(string $name, array $attributes = []): Team
    {
        $team = $this->team(['name' => $name]);

        $team->forceFill(array_merge([
            'billing_type' => BillingType::Enterprise,
            'billing_enterprise_amount_cents' => 450000,
            'billing_enterprise_interval' => BillingCycle::Monthly,
        ], $attributes))->save();

        return $team->refresh();
    }

    /**
     * @return array<string, mixed>
     */
    private function rowFor(Team $team, bool $includePersonal = true): array
    {
        return TeamBillingList::row(
            TeamBillingList::query($includePersonal)->whereKey($team->id)->firstOrFail(),
        );
    }

    public function test_it_counts_only_active_memberships_as_seats(): void
    {
        $team = $this->billingTeam('Acme Logistics');
        $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, $this->rowFor($team)['seats_used']);
    }

    public function test_it_reports_the_monthly_equivalent_of_a_yearly_plan(): void
    {
        $team = $this->billingTeam('Globex Freight', [
            'billing_enterprise_amount_cents' => 5400000,
            'billing_enterprise_interval' => BillingCycle::Yearly,
        ]);

        $row = $this->rowFor($team);

        $this->assertSame(450000, $row['monthly_cents']);
        $this->assertSame('$4,500.00', $row['monthly']);
    }

    public function test_a_team_with_no_plan_has_no_monthly_value(): void
    {
        $team = $this->team(['name' => 'Unpriced']);

        $row = $this->rowFor($team);

        $this->assertNull($row['monthly_cents']);
        $this->assertNull($row['monthly']);
    }

    public function test_it_reports_the_outstanding_balance_across_open_invoices(): void
    {
        $team = $this->billingTeam('Acme Logistics');

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

        Payment::create([
            'team_id' => $team->id, 'billing_invoice_id' => $open->id,
            'method' => BillingPaymentMethod::BankTransfer,
            'amount_cents' => 200000, 'paid_at' => '2026-10-10 09:00:00',
        ]);

        $row = $this->rowFor($team);

        $this->assertSame(250000, $row['outstanding_cents']);
        $this->assertSame('$2,500.00', $row['outstanding']);
    }

    public function test_a_paid_invoice_does_not_count_as_outstanding(): void
    {
        $team = $this->billingTeam('Acme Logistics');

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

        $this->assertSame(0, $this->rowFor($team)['outstanding_cents']);
    }

    public function test_another_teams_invoices_do_not_leak_into_the_balance(): void
    {
        $team = $this->billingTeam('Acme Logistics');
        $other = $this->billingTeam('Globex Freight');

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

        $this->assertSame(0, $this->rowFor($team)['outstanding_cents']);
    }

    public function test_personal_teams_are_excluded_unless_asked_for(): void
    {
        $this->billingTeam('Acme Logistics');
        $personal = $this->team(['name' => 'Sam Personal', 'is_personal' => true]);

        $withoutPersonal = TeamBillingList::query()->pluck('name')->all();
        $withPersonal = TeamBillingList::query(true)->pluck('name')->all();

        $this->assertNotContains('Sam Personal', $withoutPersonal);
        $this->assertContains('Sam Personal', $withPersonal);
        $this->assertSame($personal->id, TeamBillingList::query(true)->whereKey($personal->id)->firstOrFail()->id);
    }

    public function test_it_reports_module_grants(): void
    {
        $unrestricted = $this->billingTeam('Acme Logistics');
        $restricted = $this->billingTeam('Globex Freight');
        $restricted->update(['modulesAllowed' => ['crm' => true, 'hr' => false, 'warehouse' => true]]);

        // Null means billing imposes no restriction — the team may reach
        // everything, which is not the same as holding zero modules.
        $this->assertNull($this->rowFor($unrestricted)['modules_allowed']);
        $this->assertSame(2, $this->rowFor($restricted->refresh())['modules_allowed']);
    }

    public function test_the_list_and_the_entitlement_resolver_agree(): void
    {
        $explicit = $this->billingTeam('Explicit Quota', ['billing_storage_quota_bytes' => 53687091200]);
        $this->member($explicit, TeamRole::Owner);
        $explicit->forceFill(['billing_storage_used_bytes' => 26843545600])->save();

        $implicit = $this->billingTeam('Implicit Quota');
        $this->member($implicit, TeamRole::Owner);
        $this->member($implicit, TeamRole::Member);
        $implicit->forceFill(['billing_storage_used_bytes' => 5368709120])->save();

        $seatless = $this->billingTeam('No Seats');

        $suspended = $this->billingTeam('Suspended', [
            'billing_access_state' => BillingAccessState::Suspended,
            'billing_seat_cap' => 5,
        ]);

        foreach ([$explicit, $implicit, $seatless, $suspended] as $team) {
            Entitlements::flush();

            $row = $this->rowFor($team->refresh());
            $entitlement = Entitlements::for($team->fresh());

            $this->assertSame($entitlement->storageQuotaBytes, $row['storage_quota_bytes'], "quota disagrees for {$team->name}");
            $this->assertSame($entitlement->storagePercentUsed(), $row['storage_percent'], "percent disagrees for {$team->name}");
            $this->assertSame($entitlement->seatCap, $row['seat_cap'], "seat cap disagrees for {$team->name}");
            $this->assertSame($team->fresh()->activeSeatCount(), $row['seats_used'], "seats disagree for {$team->name}");
        }
    }

    public function test_the_query_count_does_not_grow_with_the_number_of_rows(): void
    {
        foreach (range(1, 3) as $i) {
            $this->member($this->billingTeam("Team {$i}"), TeamRole::Owner);
        }

        DB::enableQueryLog();
        TeamBillingList::query()->paginate(25)->through(fn (Team $team) => TeamBillingList::row($team));
        $withThree = count(DB::getQueryLog());
        DB::flushQueryLog();

        foreach (range(4, 15) as $i) {
            $this->member($this->billingTeam("Team {$i}"), TeamRole::Owner);
        }

        DB::flushQueryLog();
        TeamBillingList::query()->paginate(25)->through(fn (Team $team) => TeamBillingList::row($team));
        $withFifteen = count(DB::getQueryLog());
        DB::disableQueryLog();

        $this->assertSame(
            $withThree,
            $withFifteen,
            'the list must cost the same number of queries regardless of how many teams it returns',
        );
    }
}
  • Step 7: Run it to verify it fails

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

  • Step 8: Extract the storage-quota rule

In app/Support/Billing/Entitlements.php, add this method after flush():

php
    /**
     * The storage a team is entitled to: its explicit quota, or 10 GB per
     * active user.
     *
     * Floored at one seat: a team with no active members cannot be used by
     * anyone, so granting it one seat's worth is inert, whereas granting it
     * zero would report a team that has stored nothing as already over quota.
     *
     * Public and static because the Teams list computes this for 25 teams a
     * page without going through `for()` — one implementation means the list
     * and the resolver cannot disagree, which
     * `TeamBillingListTest::test_the_list_and_the_entitlement_resolver_agree`
     * is what proves.
     */
    public static function storageQuotaBytesFor(?int $explicitQuotaBytes, int $activeSeats): int
    {
        return $explicitQuotaBytes ?? max(1, $activeSeats) * self::DEFAULT_STORAGE_BYTES_PER_SEAT;
    }

and change resolve()'s storageQuotaBytes: argument to call it, replacing the inline expression and its now-duplicated comment:

php
            storageQuotaBytes: self::storageQuotaBytesFor(
                $team->billing_storage_quota_bytes,
                $team->activeSeatCount(),
            ),
  • Step 9: Write the query object

Create app/Queries/Billing/TeamBillingList.php:

php
<?php

namespace App\Queries\Billing;

use App\Enums\BillingInvoiceStatus;
use App\Models\Billing\Invoice;
use App\Models\Billing\Payment;
use App\Models\Team;
use App\Support\Billing\Bytes;
use App\Support\Billing\Entitlements;
use App\Support\Billing\Money;
use Illuminate\Database\Eloquent\Builder;

/**
 * Every team, with the figures the billing console shows about it (design
 * doc, "2c in detail").
 *
 * The only entry point for the cross-team list. Deliberately does NOT go
 * through `Entitlements::for()` per row: that would cost a seat-count query
 * per team plus a `SUM` per invoice, which is the N+1 increment 1's review
 * flagged. Seats and balances come from correlated subqueries, so a page
 * costs the same handful of queries whether it returns three teams or
 * twenty-five.
 *
 * The rules themselves stay in PHP — `row()` calls
 * `Entitlements::storageQuotaBytesFor()` and
 * `BillingCycle::monthlyEquivalentCents()` rather than re-expressing either
 * in SQL. One implementation means the list and the detail screen cannot
 * disagree.
 *
 * Audience only: callers add their own search, filters, ordering and
 * pagination.
 */
class TeamBillingList
{
    /**
     * @return Builder<Team>
     */
    public static function query(bool $includePersonal = false): Builder
    {
        return Team::query()
            ->select('teams.*')
            ->when(! $includePersonal, fn (Builder $query) => $query->where('is_personal', false))
            ->withCount(['activeMembers as seats_used'])
            ->addSelect([
                'open_invoiced_cents' => Invoice::query()
                    ->selectRaw('COALESCE(SUM(total_cents), 0)')
                    ->whereColumn('billing_invoices.team_id', 'teams.id')
                    ->where('status', BillingInvoiceStatus::Open->value),
                'open_paid_cents' => Payment::query()
                    ->selectRaw('COALESCE(SUM(billing_payments.amount_cents), 0)')
                    ->join('billing_invoices', 'billing_invoices.id', '=', 'billing_payments.billing_invoice_id')
                    ->whereColumn('billing_invoices.team_id', 'teams.id')
                    ->where('billing_invoices.status', BillingInvoiceStatus::Open->value),
            ]);
    }

    /**
     * Shape one row of `query()` for a payload. Performs no queries — every
     * value comes from a column the query already selected.
     *
     * @return array<string, mixed>
     */
    public static function row(Team $team): array
    {
        $seats = (int) ($team->seats_used ?? 0);
        $quotaBytes = Entitlements::storageQuotaBytesFor($team->billing_storage_quota_bytes, $seats);
        $usedBytes = $team->billing_storage_used_bytes ?? 0;

        $outstanding = max(0, (int) ($team->open_invoiced_cents ?? 0) - (int) ($team->open_paid_cents ?? 0));

        $monthly = $team->billing_enterprise_amount_cents === null || $team->billing_enterprise_interval === null
            ? null
            : $team->billing_enterprise_interval->monthlyEquivalentCents($team->billing_enterprise_amount_cents);

        return [
            'id' => $team->id,
            'name' => $team->name,
            'slug' => $team->slug,
            'is_personal' => $team->is_personal,
            'billing_type' => $team->billing_type->value,
            'billing_type_label' => $team->billing_type->label(),
            'access_state' => $team->billing_access_state->value,
            'access_state_label' => $team->billing_access_state->label(),
            'seats_used' => $seats,
            'seat_cap' => $team->billing_seat_cap,
            // Null means billing imposes no module restriction at all, which
            // is not the same as holding zero modules.
            'modules_allowed' => empty($team->modulesAllowed)
                ? null
                : count(array_filter($team->modulesAllowed)),
            'monthly_cents' => $monthly,
            'monthly' => $monthly === null ? null : Money::format($monthly),
            'renewal_at' => $team->billing_renewal_at?->toDateString(),
            'storage_quota_bytes' => $quotaBytes,
            'storage_quota' => Bytes::format($quotaBytes),
            'storage_used' => Bytes::format($usedBytes),
            'storage_percent' => $quotaBytes <= 0 ? 100.0 : round($usedBytes / $quotaBytes * 100, 2),
            'outstanding_cents' => $outstanding,
            'outstanding' => Money::format($outstanding),
        ];
    }
}
  • Step 10: Run the test to verify it passes

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

If test_the_query_count_does_not_grow_with_the_number_of_rows fails, something in row() is touching a relation lazily — every value it returns must come from a column query() already selected.

  • Step 11: Confirm nothing else moved

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

  • Step 12: Static analysis

Run: herd php -d memory_limit=1G vendor/bin/phpstan analyse app/Queries/Billing app/Support/Billing app/Http/Controllers/SuperAdmin Expected: 0 errors. $team->seats_used, $team->open_invoiced_cents and $team->open_paid_cents are query aliases, not model properties — if PHPStan objects, add them to Team's @property-read block documented as list-query aliases rather than suppressing the error.

  • Step 13: Format and commit
bash
vendor/bin/pint --dirty --format agent
git add app/Support/Billing app/Queries/Billing app/Http/Controllers/SuperAdmin/Billing/TeamBillingController.php tests/Unit/Billing/BytesTest.php tests/Feature/Billing/TeamBillingListTest.php
git commit -m "Billing: one storage-quota rule, and a set-based team list query"

Task 2: The Teams list screen

The cross-team view, searchable and filterable, every row linking to the 2b detail screen.

Files:

  • Create: app/Http/Controllers/SuperAdmin/Billing/TeamBillingListController.php
  • Modify: routes/web/superadmin.php
  • Modify: resources/js/types/billing.ts
  • Create: resources/js/pages/superadmin/billing/teams/Index.vue
  • Test: tests/Feature/SuperAdmin/Billing/TeamBillingListScreenTest.php

Interfaces:

  • Consumes: TeamBillingList::query(bool): Builder<Team> and TeamBillingList::row(Team): array (Task 1); the existing route superadmin.billing.teams.show.

  • Produces:

    • Route superadmin.billing.teams.indexGET superadmin/billing/teams.
    • Inertia component superadmin/billing/teams/Index with props teams (paginated), filters, types, states.
    • TS types BillingTeamRow and BillingListFilters in resources/js/types/billing.ts.
  • Step 1: Write the failing test

Create tests/Feature/SuperAdmin/Billing/TeamBillingListScreenTest.php:

php
<?php

namespace Tests\Feature\SuperAdmin\Billing;

use App\Enums\BillingAccessState;
use App\Enums\BillingCycle;
use App\Enums\BillingType;
use App\Models\Team;
use App\Models\User;
use App\Support\Billing\Entitlements;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;

/**
 * The cross-team billing list (design doc, "2c in detail" → "Screens").
 */
class TeamBillingListScreenTest extends TestCase
{
    use RefreshDatabase;

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

        Entitlements::flush();
    }

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

        parent::tearDown();
    }

    private function superAdmin(): User
    {
        return User::factory()->create(['superAdmin' => true]);
    }

    /**
     * @param  array<string, mixed>  $attributes
     */
    private function billingTeam(string $name, array $attributes = []): Team
    {
        $team = Team::factory()->create(['name' => $name]);

        $team->forceFill(array_merge([
            'billing_type' => BillingType::Enterprise,
            'billing_access_state' => BillingAccessState::Active,
            'billing_enterprise_amount_cents' => 450000,
            'billing_enterprise_interval' => BillingCycle::Monthly,
        ], $attributes))->save();

        return $team->refresh();
    }

    public function test_a_non_superadmin_is_forbidden(): void
    {
        $this->actingAs(User::factory()->create(['superAdmin' => false]))
            ->get(route('superadmin.billing.teams.index'))
            ->assertForbidden();
    }

    public function test_it_lists_teams_with_their_billing_figures(): void
    {
        $this->billingTeam('Acme Logistics', ['billing_seat_cap' => 25]);

        $this->actingAs($this->superAdmin())
            ->get(route('superadmin.billing.teams.index'))
            ->assertOk()
            ->assertInertia(fn (Assert $page) => $page
                ->component('superadmin/billing/teams/Index')
                ->where('teams.data.0.name', 'Acme Logistics')
                ->where('teams.data.0.billing_type', 'enterprise')
                ->where('teams.data.0.access_state', 'active')
                ->where('teams.data.0.monthly', '$4,500.00')
                ->where('teams.data.0.seat_cap', 25)
                ->where('teams.data.0.outstanding', '$0.00')
            );
    }

    public function test_personal_teams_are_hidden_by_default(): void
    {
        $this->billingTeam('Acme Logistics');
        Team::factory()->create(['name' => 'Sam Personal', 'is_personal' => true]);

        // The superadmin's own personal team from UserFactory is hidden too,
        // so exactly one row comes back.
        $this->actingAs($this->superAdmin())
            ->get(route('superadmin.billing.teams.index'))
            ->assertOk()
            ->assertInertia(fn (Assert $page) => $page
                ->has('teams.data', 1)
                ->where('teams.data.0.name', 'Acme Logistics')
                ->where('filters.personal', false)
            );
    }

    public function test_personal_teams_appear_when_the_filter_asks_for_them(): void
    {
        $this->billingTeam('Acme Logistics');
        Team::factory()->create(['name' => 'Sam Personal', 'is_personal' => true]);

        $this->actingAs($this->superAdmin())
            ->get(route('superadmin.billing.teams.index', ['personal' => 1]))
            ->assertOk()
            ->assertInertia(fn (Assert $page) => $page
                ->has('teams.data', 3)
                ->where('filters.personal', true)
            );
    }

    public function test_it_searches_by_name(): void
    {
        $this->billingTeam('Acme Logistics');
        $this->billingTeam('Globex Freight');

        $this->actingAs($this->superAdmin())
            ->get(route('superadmin.billing.teams.index', ['search' => 'globex']))
            ->assertOk()
            ->assertInertia(fn (Assert $page) => $page
                ->has('teams.data', 1)
                ->where('teams.data.0.name', 'Globex Freight')
                ->where('filters.search', 'globex')
            );
    }

    public function test_it_filters_by_billing_type(): void
    {
        $this->billingTeam('Acme Logistics');
        $this->billingTeam('Demo Account', ['billing_type' => BillingType::Internal]);

        $this->actingAs($this->superAdmin())
            ->get(route('superadmin.billing.teams.index', ['type' => 'internal']))
            ->assertOk()
            ->assertInertia(fn (Assert $page) => $page
                ->has('teams.data', 1)
                ->where('teams.data.0.name', 'Demo Account')
            );
    }

    public function test_it_filters_by_access_state(): void
    {
        $this->billingTeam('Acme Logistics');
        $this->billingTeam('Behind On Payment', ['billing_access_state' => BillingAccessState::PastDue]);

        $this->actingAs($this->superAdmin())
            ->get(route('superadmin.billing.teams.index', ['state' => 'past_due']))
            ->assertOk()
            ->assertInertia(fn (Assert $page) => $page
                ->has('teams.data', 1)
                ->where('teams.data.0.name', 'Behind On Payment')
            );
    }

    public function test_filters_combine(): void
    {
        $this->billingTeam('Acme Logistics', ['billing_access_state' => BillingAccessState::PastDue]);
        $this->billingTeam('Acme Demo', [
            'billing_type' => BillingType::Internal,
            'billing_access_state' => BillingAccessState::PastDue,
        ]);
        $this->billingTeam('Globex Freight', ['billing_access_state' => BillingAccessState::PastDue]);

        $this->actingAs($this->superAdmin())
            ->get(route('superadmin.billing.teams.index', ['search' => 'acme', 'state' => 'past_due', 'type' => 'enterprise']))
            ->assertOk()
            ->assertInertia(fn (Assert $page) => $page
                ->has('teams.data', 1)
                ->where('teams.data.0.name', 'Acme Logistics')
            );
    }

    public function test_an_unknown_filter_value_is_ignored_rather_than_erroring(): void
    {
        $this->billingTeam('Acme Logistics');

        $this->actingAs($this->superAdmin())
            ->get(route('superadmin.billing.teams.index', ['state' => 'nonsense']))
            ->assertOk()
            ->assertInertia(fn (Assert $page) => $page->has('teams.data', 1));
    }

    public function test_it_offers_the_filter_options(): void
    {
        $this->actingAs($this->superAdmin())
            ->get(route('superadmin.billing.teams.index'))
            ->assertOk()
            ->assertInertia(fn (Assert $page) => $page
                ->has('types', 3)
                ->has('states', 6)
                ->where('types.0.value', 'self_serve')
                ->where('states.0.value', 'trialing')
            );
    }

    public function test_it_paginates_and_keeps_the_filters_on_the_links(): void
    {
        foreach (range(1, 30) as $i) {
            $this->billingTeam(sprintf('Team %02d', $i));
        }

        $response = $this->actingAs($this->superAdmin())
            ->get(route('superadmin.billing.teams.index', ['search' => 'Team', 'page' => 2]));

        $response->assertOk();
        $response->assertInertia(fn (Assert $page) => $page
            ->has('teams.data', 5)
            ->where('teams.current_page', 2)
            // The filter survived the page change, which is what
            // `withQueryString()` on the paginator buys.
            ->where('filters.search', 'Team')
        );
    }
}
  • Step 2: Build the frontend assets

Run: npm run build Expected: completes. (Re-run after creating Index.vue in Step 5.)

  • Step 3: Run the test to verify it fails

Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/SuperAdmin/Billing/TeamBillingListScreenTest.php Expected: FAIL — Route [superadmin.billing.teams.index] not defined.

  • Step 4: Write the controller

Create app/Http/Controllers/SuperAdmin/Billing/TeamBillingListController.php:

php
<?php

namespace App\Http\Controllers\SuperAdmin\Billing;

use App\Enums\BillingAccessState;
use App\Enums\BillingType;
use App\Http\Controllers\Controller;
use App\Models\Team;
use App\Queries\Billing\TeamBillingList;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;

/**
 * The cross-team billing list (design doc, "2c in detail" → "Screens").
 *
 * Read-only, and deliberately thin: every figure comes from
 * `App\Queries\Billing\TeamBillingList`, which is the only implementation of
 * the rules the detail screen also applies.
 *
 * Filter values are matched against the enums rather than trusted, so a
 * hand-typed query string cannot produce a database error — an unknown value
 * is simply ignored.
 */
class TeamBillingListController extends Controller
{
    public function index(Request $request): Response
    {
        $search = trim($request->string('search')->value());
        $type = BillingType::tryFrom($request->string('type')->value());
        $state = BillingAccessState::tryFrom($request->string('state')->value());
        $includePersonal = $request->boolean('personal');

        $teams = TeamBillingList::query($includePersonal)
            ->when($search !== '', fn (Builder $query) => $query->where('name', 'like', '%'.$search.'%'))
            ->when($type !== null, fn (Builder $query) => $query->where('billing_type', $type->value))
            ->when($state !== null, fn (Builder $query) => $query->where('billing_access_state', $state->value))
            ->orderBy('name')
            ->paginate(25)
            ->withQueryString()
            ->through(fn (Team $team) => TeamBillingList::row($team));

        return Inertia::render('superadmin/billing/teams/Index', [
            'teams' => $teams,
            'filters' => [
                'search' => $search === '' ? null : $search,
                'type' => $type?->value,
                'state' => $state?->value,
                'personal' => $includePersonal,
            ],
            'types' => array_map(
                fn (BillingType $case) => ['value' => $case->value, 'label' => $case->label()],
                BillingType::cases(),
            ),
            'states' => array_map(
                fn (BillingAccessState $case) => ['value' => $case->value, 'label' => $case->label()],
                BillingAccessState::cases(),
            ),
        ]);
    }
}
  • Step 5: Add the route

In routes/web/superadmin.php, inside the existing billing group, add as its first line (before teams/{team}, so the literal segment is matched first and reads in the natural order):

php
            Route::get('teams', [TeamBillingListController::class, 'index'])->name('teams.index');

and add use App\Http\Controllers\SuperAdmin\Billing\TeamBillingListController; at the top.

  • Step 6: Add the TypeScript types

Append to resources/js/types/billing.ts:

ts
export type BillingTeamRow = {
    id: number;
    name: string;
    slug: string;
    is_personal: boolean;
    billing_type: string;
    billing_type_label: string;
    access_state: string;
    access_state_label: string;
    seats_used: number;
    seat_cap: number | null;
    modules_allowed: number | null;
    monthly_cents: number | null;
    monthly: string | null;
    renewal_at: string | null;
    storage_quota_bytes: number;
    storage_quota: string;
    storage_used: string;
    storage_percent: number;
    outstanding_cents: number;
    outstanding: string;
};

export type BillingListFilters = {
    search: string | null;
    type: string | null;
    state: string | null;
    personal: boolean;
};

export type BillingFilterOption = {
    value: string;
    label: string;
};
  • Step 7: Write the page

Create resources/js/pages/superadmin/billing/teams/Index.vue:

vue
<script setup lang="ts">
import { Head, Link, router } from '@inertiajs/vue3';
import { ref, watch } from 'vue';
import Heading from '@/components/Heading.vue';
import { index as superadminIndex } from '@/routes/superadmin';
import { index as billingTeamsIndex, show as billingTeamShow } from '@/routes/superadmin/billing/teams';
import type { BillingFilterOption, BillingListFilters, BillingTeamRow, Paginated } from '@/types';

const props = defineProps<{
    teams: Paginated<BillingTeamRow>;
    filters: BillingListFilters;
    types: BillingFilterOption[];
    states: BillingFilterOption[];
}>();

defineOptions({
    layout: {
        breadcrumbs: [
            { title: 'Superadmin', href: superadminIndex() },
            { title: 'Billing', href: '' },
        ],
    },
});

const search = ref(props.filters.search ?? '');
const type = ref(props.filters.type ?? '');
const state = ref(props.filters.state ?? '');
const personal = ref(props.filters.personal);

let searchTimer: ReturnType<typeof setTimeout> | undefined;

function apply() {
    router.get(
        billingTeamsIndex().url,
        {
            search: search.value || undefined,
            type: type.value || undefined,
            state: state.value || undefined,
            personal: personal.value ? 1 : undefined,
        },
        { preserveState: true, preserveScroll: true, replace: true },
    );
}

watch(search, () => {
    clearTimeout(searchTimer);
    searchTimer = setTimeout(apply, 300);
});

watch([type, state, personal], apply);

const stateClasses: Record<string, string> = {
    trialing: 'badge-light-info',
    active: 'badge-light-success',
    past_due: 'badge-light-warning',
    restricted: 'badge-light-danger',
    cancelled: 'badge-light-dark',
    suspended: 'badge-light-danger',
};
</script>

<template>
    <Head title="Billing — teams" />

    <Heading variant="small" title="Billing" description="Every organization's plan, balance and usage" class="mb-6" />

    <div class="card mb-6">
        <div class="card-body">
            <div class="row g-4 mb-6">
                <div class="col-md-4">
                    <label for="search" class="form-label">Search</label>
                    <input id="search" v-model="search" class="form-control" placeholder="Organization name" />
                </div>
                <div class="col-md-3">
                    <label for="type" class="form-label">Billing type</label>
                    <select id="type" v-model="type" class="form-select">
                        <option value="">All</option>
                        <option v-for="option in types" :key="option.value" :value="option.value">{{ option.label }}</option>
                    </select>
                </div>
                <div class="col-md-3">
                    <label for="state" class="form-label">Status</label>
                    <select id="state" v-model="state" class="form-select">
                        <option value="">All</option>
                        <option v-for="option in states" :key="option.value" :value="option.value">{{ option.label }}</option>
                    </select>
                </div>
                <div class="col-md-2 d-flex align-items-end">
                    <div class="form-check">
                        <input id="personal" v-model="personal" type="checkbox" class="form-check-input" />
                        <label for="personal" class="form-check-label">Personal teams</label>
                    </div>
                </div>
            </div>

            <table class="table table-striped table-row-bordered gy-3 gs-3">
                <thead>
                    <tr class="fw-semibold fs-6 text-gray-800">
                        <th>Organization</th>
                        <th>Type</th>
                        <th>Status</th>
                        <th class="text-end">Seats</th>
                        <th class="text-end">Modules</th>
                        <th class="text-end">Monthly</th>
                        <th>Renews</th>
                        <th class="text-end">Storage</th>
                        <th class="text-end">Outstanding</th>
                    </tr>
                </thead>
                <tbody>
                    <tr v-for="team in teams.data" :key="team.id">
                        <td>
                            <Link :href="billingTeamShow(team.slug)" class="fw-semibold">{{ team.name }}</Link>
                            <span v-if="team.is_personal" class="badge badge-light ms-2">Personal</span>
                        </td>
                        <td>{{ team.billing_type_label }}</td>
                        <td><span class="badge" :class="stateClasses[team.access_state] ?? 'badge-light'">{{ team.access_state_label }}</span></td>
                        <td class="text-end">{{ team.seats_used }}<span v-if="team.seat_cap" class="text-muted"> / {{ team.seat_cap }}</span></td>
                        <td class="text-end">{{ team.modules_allowed ?? 'All' }}</td>
                        <td class="text-end">{{ team.monthly ?? '—' }}</td>
                        <td>{{ team.renewal_at ?? '—' }}</td>
                        <td class="text-end">{{ team.storage_used }} / {{ team.storage_quota }}</td>
                        <td class="text-end">{{ team.outstanding }}</td>
                    </tr>
                    <tr v-if="teams.data.length === 0">
                        <td colspan="9" class="text-muted">No organizations match these filters.</td>
                    </tr>
                </tbody>
            </table>

            <nav v-if="teams.last_page > 1">
                <ul class="pagination">
                    <li v-for="link in teams.links" :key="link.label" class="page-item" :class="{ active: link.active, disabled: !link.url }">
                        <Link v-if="link.url" class="page-link" :href="link.url" preserve-scroll><span v-html="link.label"></span></Link>
                        <span v-else class="page-link" v-html="link.label" />
                    </li>
                </ul>
            </nav>
        </div>
    </div>
</template>
  • Step 8: Regenerate Wayfinder and rebuild
bash
herd php artisan wayfinder:generate --with-form
npm run build

Then read resources/js/routes/superadmin/billing/teams/index.ts and correct the import in Index.vue to whatever the generator actually exported. The --with-form flag is mandatory.

  • Step 9: Run the test to verify it passes

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

  • Step 10: Confirm the 2b screens still work

Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/SuperAdmin tests/Feature/Billing tests/Unit/Billing Expected: PASS. The new GET billing/teams route must not shadow 2b's GET billing/teams/{team}.

  • Step 11: Format and commit
bash
vendor/bin/pint --dirty --format agent
npm run format
npm run lint
git add app/Http/Controllers/SuperAdmin/Billing routes/web/superadmin.php resources/js tests/Feature/SuperAdmin/Billing
git commit -m "Billing: list every organization's plan, balance and usage"

Task 3: The Overview dashboard

What the platform is worth, and who needs attention.

Files:

  • Create: app/Queries/Billing/BillingOverview.php
  • Create: app/Http/Controllers/SuperAdmin/Billing/OverviewController.php
  • Modify: routes/web/superadmin.php
  • Modify: resources/js/types/billing.ts
  • Create: resources/js/pages/superadmin/billing/Overview.vue
  • Modify: resources/js/pages/superadmin/Index.vue
  • Test: tests/Feature/Billing/BillingOverviewTest.php
  • Test: tests/Feature/SuperAdmin/Billing/OverviewScreenTest.php

Interfaces:

  • Consumes: Entitlements::storageQuotaBytesFor() and Bytes::format() (Task 1); Money::format(); BillingCycle::monthlyEquivalentCents(); the routes superadmin.billing.teams.index (Task 2) and superadmin.billing.teams.show (2b).

  • Produces:

    • BillingOverview::figures(): array — keys mrr_cents, mrr, arr_cents, arr, collected_cents, collected, usage_billed_cents, usage_billed, counts (a map of access-state value → int).
    • BillingOverview::atRisk(): array — keys past_due, grace_ending, over_quota, renewals_due, each ['teams' => list<array{id,name,slug,detail}>, 'total' => int].
    • Route superadmin.billing.indexGET superadmin/billing.
  • Step 1: Write the failing query-object test

Create tests/Feature/Billing/BillingOverviewTest.php:

php
<?php

namespace Tests\Feature\Billing;

use App\Enums\BillingAccessState;
use App\Enums\BillingCycle;
use App\Enums\BillingPaymentMethod;
use App\Enums\BillingType;
use App\Enums\TeamRole;
use App\Models\Billing\Payment;
use App\Models\Billing\UsageEvent;
use App\Models\Team;
use App\Queries\Billing\BillingOverview;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;

/**
 * The dashboard aggregates (design doc, "2c in detail" → "Revenue").
 */
class BillingOverviewTest extends BillingTestCase
{
    use RefreshDatabase;

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

        parent::tearDown();
    }

    /**
     * @param  array<string, mixed>  $attributes
     */
    private function billingTeam(string $name, array $attributes = []): Team
    {
        $team = $this->team(['name' => $name]);

        $team->forceFill(array_merge([
            'billing_type' => BillingType::Enterprise,
            'billing_access_state' => BillingAccessState::Active,
            'billing_enterprise_amount_cents' => 450000,
            'billing_enterprise_interval' => BillingCycle::Monthly,
        ], $attributes))->save();

        return $team->refresh();
    }

    public function test_mrr_sums_the_monthly_equivalent_of_every_billable_team(): void
    {
        $this->billingTeam('Monthly Co');
        $this->billingTeam('Yearly Co', [
            'billing_enterprise_amount_cents' => 5400000,
            'billing_enterprise_interval' => BillingCycle::Yearly,
        ]);

        $figures = BillingOverview::figures();

        $this->assertSame(900000, $figures['mrr_cents']);
        $this->assertSame('$9,000.00', $figures['mrr']);
        $this->assertSame(10800000, $figures['arr_cents']);
        $this->assertSame('$108,000.00', $figures['arr']);
    }

    public function test_internal_teams_are_excluded_from_mrr(): void
    {
        $this->billingTeam('Paying Co');
        $this->billingTeam('Demo Account', ['billing_type' => BillingType::Internal]);

        $this->assertSame(450000, BillingOverview::figures()['mrr_cents']);
    }

    public function test_a_past_due_team_still_counts_toward_mrr(): void
    {
        $this->billingTeam('Behind On Payment', ['billing_access_state' => BillingAccessState::PastDue]);

        $this->assertSame(450000, BillingOverview::figures()['mrr_cents']);
    }

    public function test_a_suspended_or_restricted_team_does_not_count_toward_mrr(): void
    {
        $this->billingTeam('Suspended Co', ['billing_access_state' => BillingAccessState::Suspended]);
        $this->billingTeam('Restricted Co', ['billing_access_state' => BillingAccessState::Restricted]);
        $this->billingTeam('Cancelled Co', ['billing_access_state' => BillingAccessState::Cancelled]);

        $this->assertSame(0, BillingOverview::figures()['mrr_cents']);
    }

    public function test_a_team_with_no_plan_contributes_nothing(): void
    {
        $this->team(['name' => 'Unpriced']);

        $this->assertSame(0, BillingOverview::figures()['mrr_cents']);
    }

    public function test_collected_counts_only_payments_made_this_month(): void
    {
        Carbon::setTestNow('2026-10-15 12:00:00');
        $team = $this->billingTeam('Acme Logistics');

        foreach ([['2026-10-02 09:00:00', 200000], ['2026-10-20 09:00:00', 100000], ['2026-09-30 09:00:00', 999900]] as [$paidAt, $amount]) {
            Payment::create([
                'team_id' => $team->id,
                'billing_invoice_id' => null,
                'method' => BillingPaymentMethod::BankTransfer,
                'amount_cents' => $amount,
                'paid_at' => $paidAt,
            ]);
        }

        $figures = BillingOverview::figures();

        $this->assertSame(300000, $figures['collected_cents']);
        $this->assertSame('$3,000.00', $figures['collected']);
    }

    public function test_usage_billed_counts_events_invoiced_this_month(): void
    {
        Carbon::setTestNow('2026-10-15 12:00:00');
        $team = $this->billingTeam('Acme Logistics');

        foreach ([['2026-10-01 00:05:00', 75000], [null, 50000]] as [$invoicedAt, $amount]) {
            UsageEvent::create([
                'team_id' => $team->id,
                'service' => 'sms',
                'quantity' => 1500,
                'unit' => 'message',
                'unit_amount_cents' => 50,
                'amount_cents' => $amount,
                'occurred_at' => '2026-09-14 10:22:00',
                'billing_period' => '2026-09',
                'invoiced_at' => $invoicedAt,
            ]);
        }

        $figures = BillingOverview::figures();

        // Only the invoiced one. The uninvoiced event is usage that has been
        // recorded but not yet billed, which is a different number.
        $this->assertSame(75000, $figures['usage_billed_cents']);
        $this->assertSame('$750.00', $figures['usage_billed']);
    }

    public function test_usage_billed_is_zero_before_metering_exists(): void
    {
        $this->billingTeam('Acme Logistics');

        $this->assertSame(0, BillingOverview::figures()['usage_billed_cents']);
    }

    public function test_it_counts_teams_by_access_state(): void
    {
        $this->billingTeam('One');
        $this->billingTeam('Two');
        $this->billingTeam('Three', ['billing_access_state' => BillingAccessState::PastDue]);

        $counts = BillingOverview::figures()['counts'];

        $this->assertSame(2, $counts['active']);
        $this->assertSame(1, $counts['past_due']);
        $this->assertSame(0, $counts['suspended']);
    }

    public function test_at_risk_lists_past_due_teams(): void
    {
        $this->billingTeam('Behind On Payment', ['billing_access_state' => BillingAccessState::PastDue]);
        $this->billingTeam('Fine');

        $atRisk = BillingOverview::atRisk();

        $this->assertSame(1, $atRisk['past_due']['total']);
        $this->assertSame('Behind On Payment', $atRisk['past_due']['teams'][0]['name']);
    }

    public function test_at_risk_lists_grace_windows_ending_within_a_week(): void
    {
        Carbon::setTestNow('2026-10-15 12:00:00');
        $this->billingTeam('Ends Soon', [
            'billing_access_state' => BillingAccessState::PastDue,
            'billing_grace_ends_at' => '2026-10-19 23:59:59',
        ]);
        $this->billingTeam('Ends Later', [
            'billing_access_state' => BillingAccessState::PastDue,
            'billing_grace_ends_at' => '2026-11-30 23:59:59',
        ]);
        $this->billingTeam('Already Ended', [
            'billing_access_state' => BillingAccessState::Restricted,
            'billing_grace_ends_at' => '2026-10-01 23:59:59',
        ]);

        $atRisk = BillingOverview::atRisk();

        $this->assertSame(1, $atRisk['grace_ending']['total']);
        $this->assertSame('Ends Soon', $atRisk['grace_ending']['teams'][0]['name']);
    }

    public function test_at_risk_lists_teams_over_their_storage_quota(): void
    {
        $overByExplicitQuota = $this->billingTeam('Over Explicit', [
            'billing_storage_quota_bytes' => 1024,
            'billing_storage_used_bytes' => 2048,
        ]);
        $this->member($overByExplicitQuota, TeamRole::Owner);

        $underImplicit = $this->billingTeam('Under Implicit', ['billing_storage_used_bytes' => 1024]);
        $this->member($underImplicit, TeamRole::Owner);

        $atRisk = BillingOverview::atRisk();

        $this->assertSame(1, $atRisk['over_quota']['total']);
        $this->assertSame('Over Explicit', $atRisk['over_quota']['teams'][0]['name']);
    }

    public function test_at_risk_lists_enterprise_renewals_due_within_thirty_days(): void
    {
        Carbon::setTestNow('2026-10-15 12:00:00');
        $this->billingTeam('Renews Soon', ['billing_renewal_at' => '2026-11-01']);
        $this->billingTeam('Renews Later', ['billing_renewal_at' => '2027-01-01']);
        $this->billingTeam('Self Serve Soon', [
            'billing_type' => BillingType::SelfServe,
            'billing_renewal_at' => '2026-11-01',
        ]);

        $atRisk = BillingOverview::atRisk();

        $this->assertSame(1, $atRisk['renewals_due']['total']);
        $this->assertSame('Renews Soon', $atRisk['renewals_due']['teams'][0]['name']);
    }

    public function test_an_at_risk_list_shows_five_rows_but_reports_the_true_total(): void
    {
        foreach (range(1, 8) as $i) {
            $this->billingTeam("Behind {$i}", ['billing_access_state' => BillingAccessState::PastDue]);
        }

        $atRisk = BillingOverview::atRisk();

        $this->assertSame(8, $atRisk['past_due']['total']);
        $this->assertCount(5, $atRisk['past_due']['teams']);
    }

    public function test_personal_teams_are_excluded_from_the_dashboard(): void
    {
        $this->billingTeam('Acme Logistics');
        $personal = $this->team(['name' => 'Sam Personal', 'is_personal' => true]);
        $personal->forceFill([
            'billing_access_state' => BillingAccessState::PastDue,
            'billing_enterprise_amount_cents' => 999900,
            'billing_enterprise_interval' => BillingCycle::Monthly,
        ])->save();

        $this->assertSame(450000, BillingOverview::figures()['mrr_cents']);
        $this->assertSame(0, BillingOverview::atRisk()['past_due']['total']);
    }
}
  • Step 2: Run it to verify it fails

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

  • Step 3: Write the query object

Create app/Queries/Billing/BillingOverview.php:

php
<?php

namespace App\Queries\Billing;

use App\Enums\BillingAccessState;
use App\Enums\BillingType;
use App\Models\Billing\Payment;
use App\Models\Billing\UsageEvent;
use App\Models\Team;
use App\Support\Billing\Entitlements;
use App\Support\Billing\Money;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;

/**
 * The billing dashboard's figures (design doc, "2c in detail" → "Revenue").
 *
 * SQL narrows, PHP applies the rules. MRR is not a
 * `CASE WHEN interval = 'yearly' THEN amount / 12` in SQL — it selects two
 * columns from the (small) set of billable teams and sums them through
 * `BillingCycle::monthlyEquivalentCents()`, which already owns the rounding.
 * "Over quota" is not a `COALESCE(...)` comparison in SQL — SQL narrows to
 * teams that have stored anything at all, and PHP applies
 * `Entitlements::storageQuotaBytesFor()`. One implementation of each rule.
 *
 * Personal teams are excluded everywhere here, as they are from the Teams
 * list: they are signup artefacts, not commercial accounts.
 */
class BillingOverview
{
    /**
     * How many rows each at-risk panel shows before it just reports a count.
     */
    private const AT_RISK_ROWS = 5;

    /**
     * The access states a team pays in. `cancelled`, `restricted` and
     * `suspended` teams are not producing revenue.
     *
     * @var array<int, BillingAccessState>
     */
    private const BILLABLE_STATES = [
        BillingAccessState::Trialing,
        BillingAccessState::Active,
        BillingAccessState::PastDue,
    ];

    /**
     * @return array<string, mixed>
     */
    public static function figures(): array
    {
        $mrr = self::mrrCents();
        $collected = (int) Payment::query()
            ->whereBetween('paid_at', [Carbon::now()->startOfMonth(), Carbon::now()->endOfMonth()])
            ->sum('amount_cents');

        $usageBilled = (int) UsageEvent::query()
            ->whereNotNull('invoiced_at')
            ->whereBetween('invoiced_at', [Carbon::now()->startOfMonth(), Carbon::now()->endOfMonth()])
            ->sum('amount_cents');

        return [
            'mrr_cents' => $mrr,
            'mrr' => Money::format($mrr),
            'arr_cents' => $mrr * 12,
            'arr' => Money::format($mrr * 12),
            'collected_cents' => $collected,
            'collected' => Money::format($collected),
            'usage_billed_cents' => $usageBilled,
            'usage_billed' => Money::format($usageBilled),
            'counts' => self::countsByState(),
        ];
    }

    /**
     * @return array<string, array{teams: list<array<string, mixed>>, total: int}>
     */
    public static function atRisk(): array
    {
        $now = Carbon::now();

        return [
            'past_due' => self::panel(
                self::teams()->where('billing_access_state', BillingAccessState::PastDue->value),
            ),
            'grace_ending' => self::panel(
                self::teams()->whereBetween('billing_grace_ends_at', [$now, $now->copy()->addDays(7)]),
            ),
            'over_quota' => self::overQuotaPanel(),
            'renewals_due' => self::panel(
                self::teams()
                    ->where('billing_type', BillingType::Enterprise->value)
                    ->whereBetween('billing_renewal_at', [$now->copy()->startOfDay(), $now->copy()->addDays(30)->endOfDay()]),
            ),
        ];
    }

    /**
     * Every team the console counts: commercial accounts only.
     *
     * @return Builder<Team>
     */
    private static function teams(): Builder
    {
        return Team::query()->where('is_personal', false);
    }

    private static function mrrCents(): int
    {
        return self::teams()
            ->where('billing_type', '!=', BillingType::Internal->value)
            ->whereIn('billing_access_state', array_map(fn (BillingAccessState $state) => $state->value, self::BILLABLE_STATES))
            ->whereNotNull('billing_enterprise_amount_cents')
            ->whereNotNull('billing_enterprise_interval')
            ->get(['billing_enterprise_amount_cents', 'billing_enterprise_interval'])
            ->sum(fn (Team $team) => $team->billing_enterprise_interval->monthlyEquivalentCents(
                $team->billing_enterprise_amount_cents,
            ));
    }

    /**
     * @return array<string, int>
     */
    private static function countsByState(): array
    {
        // `toBase()` on purpose: `billing_access_state` is cast to
        // `BillingAccessState` on the model, and a PHP array cannot take an
        // enum as a key — plucking through Eloquent would produce a map this
        // method could never look up by `->value`.
        $counts = self::teams()
            ->toBase()
            ->selectRaw('billing_access_state, COUNT(*) as total')
            ->groupBy('billing_access_state')
            ->pluck('total', 'billing_access_state');

        $byState = [];

        foreach (BillingAccessState::cases() as $state) {
            $byState[$state->value] = (int) ($counts[$state->value] ?? 0);
        }

        return $byState;
    }

    /**
     * Teams whose storage exceeds what they are entitled to.
     *
     * SQL narrows to teams that have stored anything at all — nothing
     * populates `billing_storage_used_bytes` until increment 3, so this is an
     * empty set in production today — and PHP applies the quota rule, so the
     * 10 GB-per-seat fallback keeps exactly one implementation.
     *
     * @return array{teams: list<array<string, mixed>>, total: int}
     */
    private static function overQuotaPanel(): array
    {
        $over = self::teams()
            ->where('billing_storage_used_bytes', '>', 0)
            ->withCount(['activeMembers as seats_used'])
            ->orderBy('name')
            ->get()
            ->filter(fn (Team $team) => $team->billing_storage_used_bytes >= Entitlements::storageQuotaBytesFor(
                $team->billing_storage_quota_bytes,
                (int) ($team->seats_used ?? 0),
            ))
            ->values();

        return [
            'teams' => $over->take(self::AT_RISK_ROWS)->map(fn (Team $team) => self::teamRow($team))->all(),
            'total' => $over->count(),
        ];
    }

    /**
     * @param  Builder<Team>  $query
     * @return array{teams: list<array<string, mixed>>, total: int}
     */
    private static function panel(Builder $query): array
    {
        return [
            'teams' => (clone $query)
                ->orderBy('name')
                ->limit(self::AT_RISK_ROWS)
                ->get()
                ->map(fn (Team $team) => self::teamRow($team))
                ->all(),
            'total' => $query->count(),
        ];
    }

    /**
     * @return array<string, mixed>
     */
    private static function teamRow(Team $team): array
    {
        return [
            'id' => $team->id,
            'name' => $team->name,
            'slug' => $team->slug,
            'access_state' => $team->billing_access_state->value,
            'access_state_label' => $team->billing_access_state->label(),
            'grace_ends_at' => $team->billing_grace_ends_at?->toDateString(),
            'renewal_at' => $team->billing_renewal_at?->toDateString(),
        ];
    }
}
  • Step 4: Run the test to verify it passes

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

  • Step 5: Write the failing screen test

Create tests/Feature/SuperAdmin/Billing/OverviewScreenTest.php:

php
<?php

namespace Tests\Feature\SuperAdmin\Billing;

use App\Enums\BillingAccessState;
use App\Enums\BillingCycle;
use App\Enums\BillingType;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;

/**
 * The billing dashboard (design doc, "2c in detail" → "Screens").
 */
class OverviewScreenTest extends TestCase
{
    use RefreshDatabase;

    private function superAdmin(): User
    {
        return User::factory()->create(['superAdmin' => true]);
    }

    /**
     * @param  array<string, mixed>  $attributes
     */
    private function billingTeam(string $name, array $attributes = []): Team
    {
        $team = Team::factory()->create(['name' => $name]);

        $team->forceFill(array_merge([
            'billing_type' => BillingType::Enterprise,
            'billing_access_state' => BillingAccessState::Active,
            'billing_enterprise_amount_cents' => 450000,
            'billing_enterprise_interval' => BillingCycle::Monthly,
        ], $attributes))->save();

        return $team->refresh();
    }

    public function test_a_non_superadmin_is_forbidden(): void
    {
        $this->actingAs(User::factory()->create(['superAdmin' => false]))
            ->get(route('superadmin.billing.index'))
            ->assertForbidden();
    }

    public function test_it_renders_the_revenue_tiles(): void
    {
        $this->billingTeam('Acme Logistics');

        $this->actingAs($this->superAdmin())
            ->get(route('superadmin.billing.index'))
            ->assertOk()
            ->assertInertia(fn (Assert $page) => $page
                ->component('superadmin/billing/Overview')
                ->where('figures.mrr', '$4,500.00')
                ->where('figures.arr', '$54,000.00')
                ->where('figures.collected', '$0.00')
                ->where('figures.usage_billed', '$0.00')
            );
    }

    public function test_it_renders_the_counts_by_state(): void
    {
        $this->billingTeam('Acme Logistics');
        $this->billingTeam('Behind On Payment', ['billing_access_state' => BillingAccessState::PastDue]);

        $this->actingAs($this->superAdmin())
            ->get(route('superadmin.billing.index'))
            ->assertOk()
            ->assertInertia(fn (Assert $page) => $page
                ->where('figures.counts.active', 1)
                ->where('figures.counts.past_due', 1)
            );
    }

    public function test_it_renders_the_four_at_risk_panels(): void
    {
        $this->billingTeam('Behind On Payment', ['billing_access_state' => BillingAccessState::PastDue]);

        $this->actingAs($this->superAdmin())
            ->get(route('superadmin.billing.index'))
            ->assertOk()
            ->assertInertia(fn (Assert $page) => $page
                ->has('atRisk.past_due')
                ->has('atRisk.grace_ending')
                ->has('atRisk.over_quota')
                ->has('atRisk.renewals_due')
                ->where('atRisk.past_due.total', 1)
                ->where('atRisk.past_due.teams.0.name', 'Behind On Payment')
            );
    }
}
  • Step 6: Write the controller

Create app/Http/Controllers/SuperAdmin/Billing/OverviewController.php:

php
<?php

namespace App\Http\Controllers\SuperAdmin\Billing;

use App\Http\Controllers\Controller;
use App\Queries\Billing\BillingOverview;
use Inertia\Inertia;
use Inertia\Response;

/**
 * The billing dashboard (design doc, "2c in detail" → "Screens").
 *
 * Read-only and deliberately thin: every figure comes from
 * `App\Queries\Billing\BillingOverview`.
 */
class OverviewController extends Controller
{
    public function index(): Response
    {
        return Inertia::render('superadmin/billing/Overview', [
            'figures' => BillingOverview::figures(),
            'atRisk' => BillingOverview::atRisk(),
        ]);
    }
}
  • Step 7: Add the route

In routes/web/superadmin.php, inside the existing billing group, add this above the teams index route Task 2 added, so the group reads dashboard → list → detail:

php
            Route::get('/', [OverviewController::class, 'index'])->name('index');

and add use App\Http\Controllers\SuperAdmin\Billing\OverviewController; at the top.

  • Step 8: Add the TypeScript types

Append to resources/js/types/billing.ts:

ts
export type BillingOverviewFigures = {
    mrr_cents: number;
    mrr: string;
    arr_cents: number;
    arr: string;
    collected_cents: number;
    collected: string;
    usage_billed_cents: number;
    usage_billed: string;
    counts: Record<string, number>;
};

export type BillingAtRiskTeam = {
    id: number;
    name: string;
    slug: string;
    access_state: string;
    access_state_label: string;
    grace_ends_at: string | null;
    renewal_at: string | null;
};

export type BillingAtRiskPanel = {
    teams: BillingAtRiskTeam[];
    total: number;
};

export type BillingAtRisk = {
    past_due: BillingAtRiskPanel;
    grace_ending: BillingAtRiskPanel;
    over_quota: BillingAtRiskPanel;
    renewals_due: BillingAtRiskPanel;
};
  • Step 9: Write the page

Create resources/js/pages/superadmin/billing/Overview.vue:

vue
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import Heading from '@/components/Heading.vue';
import { index as superadminIndex } from '@/routes/superadmin';
import { index as billingTeamsIndex, show as billingTeamShow } from '@/routes/superadmin/billing/teams';
import type { BillingAtRisk, BillingOverviewFigures } from '@/types';

defineProps<{
    figures: BillingOverviewFigures;
    atRisk: BillingAtRisk;
}>();

defineOptions({
    layout: {
        breadcrumbs: [
            { title: 'Superadmin', href: superadminIndex() },
            { title: 'Billing', href: '' },
        ],
    },
});

const panels: { key: keyof BillingAtRisk; title: string; empty: string }[] = [
    { key: 'past_due', title: 'Past due', empty: 'Nobody is behind on payment.' },
    { key: 'grace_ending', title: 'Grace ending within 7 days', empty: 'No grace windows closing soon.' },
    { key: 'over_quota', title: 'Over storage quota', empty: 'Nobody is over quota.' },
    { key: 'renewals_due', title: 'Renewals within 30 days', empty: 'No renewals due.' },
];

const stateClasses: Record<string, string> = {
    trialing: 'badge-light-info',
    active: 'badge-light-success',
    past_due: 'badge-light-warning',
    restricted: 'badge-light-danger',
    cancelled: 'badge-light-dark',
    suspended: 'badge-light-danger',
};
</script>

<template>
    <Head title="Billing" />

    <Heading variant="small" title="Billing" description="What the platform is worth, and who needs attention" class="mb-6" />

    <div class="row g-6 mb-6">
        <div v-for="tile in [
            { label: 'MRR', value: figures.mrr },
            { label: 'ARR', value: figures.arr },
            { label: 'Collected this month', value: figures.collected },
            { label: 'Usage billed this month', value: figures.usage_billed },
        ]" :key="tile.label" class="col-md-3">
            <div class="card h-100">
                <div class="card-body">
                    <div class="text-muted fs-7">{{ tile.label }}</div>
                    <div class="fs-2 fw-bold">{{ tile.value }}</div>
                </div>
            </div>
        </div>
    </div>

    <div class="card mb-6">
        <div class="card-header">
            <h3 class="card-title">Organizations by status</h3>
            <div class="card-toolbar">
                <Link :href="billingTeamsIndex()" class="btn btn-sm btn-light-primary">View all</Link>
            </div>
        </div>
        <div class="card-body d-flex flex-wrap gap-6">
            <div v-for="(count, state) in figures.counts" :key="state">
                <div class="text-muted fs-7">
                    <span class="badge" :class="stateClasses[state] ?? 'badge-light'">{{ state }}</span>
                </div>
                <div class="fs-4 fw-semibold">{{ count }}</div>
            </div>
        </div>
    </div>

    <div class="row">
        <div v-for="panel in panels" :key="panel.key" class="col-md-6">
            <div class="card mb-6">
                <div class="card-header">
                    <h3 class="card-title">{{ panel.title }}</h3>
                    <div class="card-toolbar">
                        <span class="badge badge-light">{{ atRisk[panel.key].total }}</span>
                    </div>
                </div>
                <div class="card-body">
                    <ul class="list-unstyled mb-0">
                        <li v-for="team in atRisk[panel.key].teams" :key="team.id" class="d-flex justify-content-between align-items-center py-2">
                            <Link :href="billingTeamShow(team.slug)">{{ team.name }}</Link>
                            <span class="badge" :class="stateClasses[team.access_state] ?? 'badge-light'">{{ team.access_state_label }}</span>
                        </li>
                        <li v-if="atRisk[panel.key].teams.length === 0" class="text-muted py-2">{{ panel.empty }}</li>
                        <li v-else-if="atRisk[panel.key].total > atRisk[panel.key].teams.length" class="text-muted py-2">
                            and {{ atRisk[panel.key].total - atRisk[panel.key].teams.length }} more
                        </li>
                    </ul>
                </div>
            </div>
        </div>
    </div>
</template>

Note the and N more line uses subtraction on two counts, not on money or bytes — the constraint is about currency and storage arithmetic, and a row count is neither.

  • Step 10: Add a Billing card to the superadmin hub

In resources/js/pages/superadmin/Index.vue, add a third card matching the two already there, and import the route:

ts
import { index as billingIndex } from '@/routes/superadmin/billing';
vue
        <div class="col-md-4">
            <div class="card mb-6">
                <div class="card-header">
                    <h3 class="card-title">Billing</h3>
                </div>
                <div class="card-body d-flex flex-column gap-3">
                    <Link :href="billingIndex()" class="btn btn-light text-start">Billing overview</Link>
                </div>
            </div>
        </div>
  • Step 11: Regenerate Wayfinder, rebuild, and run both tests
bash
herd php artisan wayfinder:generate --with-form
npm run build
herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing/BillingOverviewTest.php tests/Feature/SuperAdmin/Billing/OverviewScreenTest.php

Read the generated files under resources/js/routes/superadmin/billing/ first and correct both pages' imports to the real export names. Expected: PASS, 19 tests.

  • Step 12: Run the whole affected surface

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

  • Step 13: Prove nothing else broke

Run: herd php -d memory_limit=2G vendor/bin/phpunit Expected: 24 failures, all pre-existing, none naming a Billing or SuperAdmin test.

  • Step 14: Static analysis

Run: herd php -d memory_limit=1G vendor/bin/phpstan analyse app/Queries/Billing app/Support/Billing app/Models/Billing app/Http/Controllers/SuperAdmin app/Http/Requests/SuperAdmin app/Enums app/Actions/Billing Expected: 0 errors.

  • Step 15: Format and commit
bash
vendor/bin/pint --dirty --format agent
npm run format
npm run lint
git add app/Queries/Billing app/Http/Controllers/SuperAdmin/Billing routes/web/superadmin.php resources/js tests/Feature/Billing tests/Feature/SuperAdmin/Billing
git commit -m "Billing: a dashboard of what the platform is worth and who needs attention"

Done when

  • herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing tests/Unit/Billing tests/Feature/SuperAdmin passes.
  • herd php -d memory_limit=2G vendor/bin/phpunit shows 24 failures, all pre-existing, none in Billing or SuperAdmin.
  • Scoped PHPStan over the billing and superadmin paths is 0.
  • vendor/bin/pint --test is clean on every changed PHP file; npm run format:check and npm run lint are clean apart from the one pre-existing work-orders/Show.vue error.
  • test_the_list_and_the_entitlement_resolver_agree and test_the_query_count_does_not_grow_with_the_number_of_rows both pass — those two are what the whole design rests on.
  • A superadmin can reach the dashboard from the hub, see MRR and who is at risk, click through to any team, and search and filter the full list.

Deliberately not in this sub-increment

  • The Rates screen and billing:draft-enterprise-invoices (2d).
  • Any write. Both routes are GET; no Action is called.
  • Fixing Invoice::amountPaidCents()'s per-call query. The list does not use it — the outstanding-balance subqueries make it irrelevant — so 2a's documented "a financial figure that cannot go stale" rationale stays intact for the detail screen.
  • Self-serve MRR (seats x price plus held modules). BillingOverview::mrrCents() sums enterprise amounts only; increment 4 adds the self-serve term to that one method.
  • Sorting the Teams list by any column. It orders by name; column sorting is a bigger UI change than this screen needs to be useful.