Billing 2b — Team Billing Screen 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 one screen that answers "what is going on with this account?" and every write they need to act on the answer — over the Actions 2a already built.
Architecture: Six thin controllers under App\Http\Controllers\SuperAdmin\Billing\, each delegating to a app/Actions/Billing/ Action; no controller writes a billing_* column directly. One scrolling Inertia page of stacked Bootstrap cards, following superadmin/teams/Edit.vue exactly. Money converts dollars→cents in exactly one place.
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 "2b in detail" section.
Global Constraints
- PHP binary: plain
phpon this machine is MAMP's PHP 8.2 and too old. Every Artisan command runs asherd php artisan .... Composer isherd php /Applications/MAMP/bin/php/composer <args>. - Tests:
herd php -d memory_limit=2G artisan test --compact <path>.artisan testspawns child processes that do NOT inherit-d memory_limit; for the whole suite useherd php -d memory_limit=2G vendor/bin/phpunit. - No new model factories.
TeamFactory,UserFactoryandTeamInvitationFactoryexist and may be used. Build billing rows with explicitModel::create([...]). - All billing tables are MySQL (the default connection;
sqlitein tests). Never MongoDB. No MongoDB transactions. - Amounts are integer cents throughout — never floats. Dollars→cents conversion happens in exactly one place (
App\Support\Billing\Money), called from FormRequests. No Vue file performs arithmetic on money. - No controller writes a
billing_*column directly. Every write goes through its Action, which is what preserves theBillingEventaudit trail and the entitlement-flush rule. - No Action performs authorization.
$actoris recorded, never checked. Every route in this plan MUST sit inside the existingEnsureSuperAdminmiddleware group. - Formatting: run
vendor/bin/pint --dirty --format agentbefore every commit. For Vue/TS changes also runnpm run formatandnpm run lint. - Wayfinder: after adding routes, regenerate with
herd php artisan wayfinder:generate --with-form. The--with-formflag is mandatory — the bare command strips.form()and breaks existing pages. - Action conventions: one public
handle(),abort_if(...)guards with a 422 and a human-readable message,DB::transaction()for multi-row writes, a class docblock citing the spec section. - Model conventions:
protected $guarded = [];, aprotected $casts = []array property, full@propertydocblocks, TitleCase enum cases, explicit return types and parameter type hints, curly braces everywhere, PHPDoc over inline comments.App\Models\Teamuniquely uses acasts()method — follow the file. - Frontend conventions: copy
resources/js/pages/superadmin/teams/Edit.vue.<Form v-bind="route.form(...)">for form panels,router.patch/post/deletewithpreserveScroll: truefor row actions, browserconfirm()for destructive actions,Headingcomponent, breadcrumbs viadefineOptions. No DataTables, noFilterDrawer— those are Reports-page patterns and are not used anywhere under/superadmin.
Known pre-existing test failures
Twenty-four tests fail on main 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. tests/Feature/Billing and tests/Feature/SuperAdmin are both fully green — any failure there is yours.
One of the nine ViteExceptions matters to you: adding a new Vue page without building assets makes that page's own test fail the same way. Run npm run build once before the first Inertia render test, or those tests will fail for a reason unrelated to your code.
Task 1: VoidInvoice and Money
The eighth Action, plus the single dollars↔cents conversion point every later task depends on. No UI.
Files:
- Create:
app/Support/Billing/Money.php - Create:
app/Actions/Billing/VoidInvoice.php - Test:
tests/Unit/Billing/MoneyTest.php - Test:
tests/Feature/Billing/Actions/VoidInvoiceTest.php
Interfaces:
-
Consumes:
App\Models\Billing\Invoice,App\Enums\BillingInvoiceStatus,App\Models\Billing\BillingEvent::record(). -
Produces:
App\Support\Billing\Money::toCents(string $amount): intApp\Support\Billing\Money::format(int $cents, string $currency = 'usd'): stringApp\Actions\Billing\VoidInvoice::handle(Invoice $invoice, string $reason, User $actor): Invoice
-
Step 1: Write the failing Money test
Create tests/Unit/Billing/MoneyTest.php:
<?php
namespace Tests\Unit\Billing;
use App\Support\Billing\Money;
use PHPUnit\Framework\TestCase;
/**
* The single dollars-to-cents conversion point (design doc, "2b in detail"
* → "Money").
*/
class MoneyTest extends TestCase
{
public function test_it_converts_whole_dollars(): void
{
$this->assertSame(450000, Money::toCents('4500'));
$this->assertSame(450000, Money::toCents('4500.00'));
}
public function test_it_converts_cents_exactly(): void
{
$this->assertSame(1, Money::toCents('0.01'));
$this->assertSame(10, Money::toCents('0.10'));
$this->assertSame(99, Money::toCents('0.99'));
}
public function test_it_converts_a_value_that_a_float_would_round_wrongly(): void
{
// 1.15 * 100 is 114.99999999999999 in IEEE 754. A float-based
// conversion truncates that to 114.
$this->assertSame(115, Money::toCents('1.15'));
$this->assertSame(2029, Money::toCents('20.29'));
}
public function test_it_converts_negative_amounts_for_credit_notes(): void
{
$this->assertSame(-25000, Money::toCents('-250.00'));
$this->assertSame(-1, Money::toCents('-0.01'));
}
public function test_it_converts_zero(): void
{
$this->assertSame(0, Money::toCents('0'));
$this->assertSame(0, Money::toCents('0.00'));
}
public function test_it_tolerates_surrounding_whitespace(): void
{
$this->assertSame(450000, Money::toCents(' 4500.00 '));
}
public function test_it_formats_dollars_with_thousands_separators(): void
{
$this->assertSame('$4,500.00', Money::format(450000));
$this->assertSame('$0.01', Money::format(1));
$this->assertSame('$0.00', Money::format(0));
}
public function test_it_formats_a_credit_with_the_sign_before_the_symbol(): void
{
$this->assertSame('-$250.00', Money::format(-25000));
}
public function test_it_formats_a_non_usd_currency_with_its_code(): void
{
$this->assertSame('EUR 4,500.00', Money::format(450000, 'eur'));
}
}
- Step 2: Run it to verify it fails
Run: herd php -d memory_limit=2G artisan test --compact tests/Unit/Billing/MoneyTest.php
Expected: FAIL — Class "App\Support\Billing\Money" not found.
- Step 3: Write
Money
Create app/Support/Billing/Money.php:
<?php
namespace App\Support\Billing;
/**
* The single conversion point between the dollars an operator types and the
* integer cents everything stores (design doc, "2b in detail" → "Money").
*
* Adding a second conversion site is how a 100x error ships, so FormRequests
* call `toCents()` on the way in and controllers call `format()` on the way
* out — no Vue file ever performs arithmetic on money.
*/
class Money
{
/**
* Convert a validated decimal string to integer cents.
*
* Parsed as a string rather than multiplied as a float on purpose: in
* IEEE 754, `1.15 * 100` is 114.99999999999999, which an `(int)` cast
* truncates to 114. Callers must validate the shape first — the
* FormRequests in this subsystem use
* `regex:/^-?\d{1,12}(\.\d{1,2})?$/`.
*/
public static function toCents(string $amount): int
{
$normalised = trim($amount);
$isNegative = str_starts_with($normalised, '-');
$digits = ltrim($normalised, '+-');
[$whole, $fraction] = array_pad(explode('.', $digits, 2), 2, '');
$fraction = str_pad(substr($fraction, 0, 2), 2, '0');
$cents = ((int) $whole) * 100 + (int) $fraction;
return $isNegative ? -$cents : $cents;
}
/**
* Render integer cents for display. A credit shows its sign before the
* symbol (`-$250.00`), which is how an accountant reads it.
*/
public static function format(int $cents, string $currency = 'usd'): string
{
$prefix = $currency === 'usd' ? '$' : strtoupper($currency).' ';
$sign = $cents < 0 ? '-' : '';
return $sign.$prefix.number_format(abs($cents) / 100, 2);
}
}
- Step 4: Run it to verify it passes
Run: herd php -d memory_limit=2G artisan test --compact tests/Unit/Billing/MoneyTest.php
Expected: PASS, 9 tests.
- Step 5: Write the failing VoidInvoice test
Create tests/Feature/Billing/Actions/VoidInvoiceTest.php:
<?php
namespace Tests\Feature\Billing\Actions;
use App\Actions\Billing\IssueInvoice;
use App\Actions\Billing\VoidInvoice;
use App\Enums\BillingCycle;
use App\Enums\BillingInvoiceLineKind;
use App\Enums\BillingInvoiceSource;
use App\Enums\BillingInvoiceStatus;
use App\Enums\BillingPaymentMethod;
use App\Enums\TeamRole;
use App\Models\Billing\BillingEvent;
use App\Models\Billing\Invoice;
use App\Models\Billing\InvoiceLine;
use App\Models\Billing\Payment;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tests\Feature\Billing\BillingTestCase;
/**
* Voiding an invoice (design doc, "2b in detail" → "`VoidInvoice`, the
* eighth Action").
*/
class VoidInvoiceTest extends BillingTestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
private function draft(Team $team): Invoice
{
$invoice = Invoice::create([
'team_id' => $team->id,
'number' => null,
'source' => BillingInvoiceSource::Manual,
'period_start' => '2026-10-01',
'period_end' => '2026-10-31',
'subtotal_cents' => 450000,
'total_cents' => 450000,
'currency' => 'usd',
'status' => BillingInvoiceStatus::Draft,
]);
InvoiceLine::create([
'billing_invoice_id' => $invoice->id,
'kind' => BillingInvoiceLineKind::Module,
'description' => 'Enterprise plan — October 2026',
'quantity' => 1,
'unit_amount_cents' => 450000,
'amount_cents' => 450000,
]);
return $invoice;
}
public function test_a_draft_invoice_can_be_voided(): void
{
$team = $this->team();
$admin = $this->member($team, TeamRole::Admin);
$voided = app(VoidInvoice::class)->handle($this->draft($team), 'Raised against the wrong team.', $admin);
$this->assertSame(BillingInvoiceStatus::Void, $voided->status);
}
public function test_an_open_invoice_can_be_voided(): void
{
Carbon::setTestNow('2026-09-15 10:00:00');
$team = $this->team();
$admin = $this->member($team, TeamRole::Admin);
$invoice = app(IssueInvoice::class)->handle($this->draft($team), $admin);
$voided = app(VoidInvoice::class)->handle($invoice, 'Customer disputed the period.', $admin);
$this->assertSame(BillingInvoiceStatus::Void, $voided->status);
$this->assertSame('INV-2026-0001', $voided->number, 'voiding keeps the number so the sequence stays auditable');
}
public function test_voiding_records_the_reason_and_the_previous_status(): void
{
$team = $this->team();
$admin = $this->member($team, TeamRole::Admin);
app(VoidInvoice::class)->handle($this->draft($team), 'Raised against the wrong team.', $admin);
$event = BillingEvent::where('type', 'invoice.voided')->firstOrFail();
$this->assertSame($team->id, $event->team_id);
$this->assertSame($admin->id, $event->actor_user_id);
$this->assertSame('Raised against the wrong team.', $event->payload['reason']);
$this->assertSame('draft', $event->payload['previous_status']);
}
public function test_a_paid_invoice_cannot_be_voided(): void
{
Carbon::setTestNow('2026-09-15 10:00:00');
$team = $this->team();
$admin = $this->member($team, TeamRole::Admin);
$invoice = app(IssueInvoice::class)->handle($this->draft($team), $admin);
Payment::create([
'team_id' => $team->id,
'billing_invoice_id' => $invoice->id,
'method' => BillingPaymentMethod::BankTransfer,
'amount_cents' => 450000,
'paid_at' => '2026-09-20 14:30:00',
]);
$invoice->update(['status' => BillingInvoiceStatus::Paid, 'paid_at' => '2026-09-20 14:30:00']);
$this->expectException(HttpException::class);
$this->expectExceptionMessage('Only a draft or open invoice can be voided.');
app(VoidInvoice::class)->handle($invoice->fresh(), 'Changed my mind.', $admin);
}
public function test_an_already_void_invoice_cannot_be_voided_again(): void
{
$team = $this->team();
$admin = $this->member($team, TeamRole::Admin);
$invoice = app(VoidInvoice::class)->handle($this->draft($team), 'First void.', $admin);
$this->expectException(HttpException::class);
$this->expectExceptionMessage('Only a draft or open invoice can be voided.');
app(VoidInvoice::class)->handle($invoice, 'Second void.', $admin);
}
public function test_a_blank_reason_is_rejected(): void
{
$team = $this->team();
$admin = $this->member($team, TeamRole::Admin);
$this->expectException(HttpException::class);
$this->expectExceptionMessage('A reason is required to void an invoice.');
app(VoidInvoice::class)->handle($this->draft($team), ' ', $admin);
}
public function test_a_stale_draft_model_cannot_be_voided_after_another_call_voided_it(): void
{
$team = $this->team();
$admin = $this->member($team, TeamRole::Admin);
$invoice = $this->draft($team);
// A second reference still believing the invoice is a draft — exactly
// the state a concurrent request would hold.
$stale = Invoice::findOrFail($invoice->id);
app(VoidInvoice::class)->handle($invoice, 'First void.', $admin);
$this->expectException(HttpException::class);
$this->expectExceptionMessage('Only a draft or open invoice can be voided.');
app(VoidInvoice::class)->handle($stale, 'Racing void.', $admin);
}
}
- Step 6: Run it to verify it fails
Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing/Actions/VoidInvoiceTest.php
Expected: FAIL — Class "App\Actions\Billing\VoidInvoice" not found.
- Step 7: Write
VoidInvoice
Create app/Actions/Billing/VoidInvoice.php:
<?php
namespace App\Actions\Billing;
use App\Enums\BillingInvoiceStatus;
use App\Models\Billing\BillingEvent;
use App\Models\Billing\Invoice;
use App\Models\User;
use Illuminate\Support\Facades\DB;
/**
* Voids an invoice (design doc, "2b in detail" → "`VoidInvoice`, the eighth
* Action").
*
* Only a `draft` or `open` invoice may be voided. Reversing a settled
* invoice would leave recorded payments attached to a void one, which needs
* a refund concept this system does not have; `uncollectible` is a
* write-off, not a mistake, and is likewise not voidable.
*
* The reason is required, not optional: voiding is the one destructive act
* on the billing screen and the `BillingEvent` payload is the only record of
* why it happened.
*
* The number is deliberately kept rather than released back to the sequence
* — a void invoice that still shows `INV-2026-0007` is auditable, whereas a
* reused number is not.
*
* Does not flush the entitlement memo: an invoice cannot change what a team
* is allowed to do.
*/
class VoidInvoice
{
/**
* The statuses from which an invoice may still be voided.
*
* @var array<int, BillingInvoiceStatus>
*/
private const VOIDABLE = [BillingInvoiceStatus::Draft, BillingInvoiceStatus::Open];
public function handle(Invoice $invoice, string $reason, User $actor): Invoice
{
abort_if(trim($reason) === '', 422, 'A reason is required to void an invoice.');
abort_if(! in_array($invoice->status, self::VOIDABLE, true), 422, 'Only a draft or open invoice can be voided.');
return DB::transaction(function () use ($invoice, $reason, $actor) {
// Re-check under a row lock: the guard above read `status` in
// PHP, so a concurrent issue, payment or void could have moved it
// since. This is the same lock `IssueInvoice` and `RecordPayment`
// take, and it is what makes their own guards against a
// concurrent void meet a real counterpart.
$locked = Invoice::whereKey($invoice->getKey())->lockForUpdate()->firstOrFail();
abort_if(! in_array($locked->status, self::VOIDABLE, true), 422, 'Only a draft or open invoice can be voided.');
$invoice = $locked;
$previousStatus = $invoice->status;
$invoice->update(['status' => BillingInvoiceStatus::Void]);
BillingEvent::record(
$invoice->team,
'invoice.voided',
[
'invoice_id' => $invoice->id,
'number' => $invoice->number,
'previous_status' => $previousStatus->value,
'reason' => trim($reason),
],
$actor->id,
);
return $invoice->refresh();
});
}
}
- Step 8: Run it to verify it passes
Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing/Actions/VoidInvoiceTest.php
Expected: PASS, 7 tests.
- Step 9: Run the whole billing suite
Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing tests/Unit/Billing
Expected: PASS.
- Step 10: Format and commit
vendor/bin/pint --dirty --format agent
git add app/Support/Billing/Money.php app/Actions/Billing/VoidInvoice.php tests/Unit/Billing tests/Feature/Billing/Actions/VoidInvoiceTest.php
git commit -m "Billing: void an invoice, and convert money in one place"
Task 2: The read side — show and the five cards
Everything an operator needs to see. No writes; the action buttons arrive in Tasks 3 and 4.
Files:
- Create:
app/Http/Controllers/SuperAdmin/Billing/TeamBillingController.php - Modify:
routes/web/superadmin.php - Create:
resources/js/types/billing.ts - Create:
resources/js/pages/superadmin/billing/teams/Show.vue - Modify:
resources/js/pages/superadmin/teams/Edit.vue(add a link to the billing screen) - Test:
tests/Feature/SuperAdmin/Billing/TeamBillingScreenTest.php
Interfaces:
-
Consumes:
App\Support\Billing\Money::format()(Task 1),App\Support\Billing\Entitlements::for(), the billing models from increment 1. -
Produces:
- Route
superadmin.billing.teams.show→GET superadmin/billing/teams/{team}, bound by slug. - Inertia component
superadmin/billing/teams/Showwith propsteam,plan,modules,invoices,payments,usage,activity. - TS types in
resources/js/types/billing.ts:BillingTeam,BillingPlan,BillingModuleOption,BillingInvoiceRow,BillingPaymentRow,BillingUsageRow,BillingActivityRow.
- Route
-
Step 1: Write the failing test
Create tests/Feature/SuperAdmin/Billing/TeamBillingScreenTest.php:
<?php
namespace Tests\Feature\SuperAdmin\Billing;
use App\Enums\BillingCycle;
use App\Enums\BillingInvoiceLineKind;
use App\Enums\BillingInvoiceSource;
use App\Enums\BillingInvoiceStatus;
use App\Enums\BillingPaymentMethod;
use App\Enums\BillingType;
use App\Models\Billing\BillingEvent;
use App\Models\Billing\Invoice;
use App\Models\Billing\InvoiceLine;
use App\Models\Billing\Payment;
use App\Models\System\Module;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
/**
* The superadmin Team billing screen (design doc, "2b in detail" → "The
* screen").
*/
class TeamBillingScreenTest extends TestCase
{
use RefreshDatabase;
private function superAdmin(): User
{
return User::factory()->create(['superAdmin' => true]);
}
private function enterpriseTeam(): Team
{
$team = Team::factory()->create(['name' => 'Acme Logistics']);
$team->forceFill([
'billing_type' => BillingType::Enterprise,
'billing_enterprise_amount_cents' => 450000,
'billing_enterprise_interval' => BillingCycle::Monthly,
'billing_renewal_at' => '2027-01-01',
'billing_seat_cap' => 25,
'billing_storage_quota_bytes' => 53687091200,
'billing_email' => '[email protected]',
])->save();
return $team->refresh();
}
public function test_a_non_superadmin_is_forbidden(): void
{
$user = User::factory()->create(['superAdmin' => false]);
$team = Team::factory()->create();
$this->actingAs($user)
->get(route('superadmin.billing.teams.show', $team))
->assertForbidden();
}
public function test_it_renders_the_plan_summary(): void
{
$team = $this->enterpriseTeam();
$this->actingAs($this->superAdmin())
->get(route('superadmin.billing.teams.show', $team))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('superadmin/billing/teams/Show')
->where('team.name', 'Acme Logistics')
->where('plan.billing_type', 'enterprise')
->where('plan.access_state', 'active')
->where('plan.amount', '$4,500.00')
->where('plan.interval', 'monthly')
->where('plan.seat_cap', 25)
->where('plan.renewal_at', '2027-01-01')
->where('plan.billing_email', '[email protected]')
);
}
public function test_the_plan_summary_reports_seats_and_storage_in_use(): void
{
$team = $this->enterpriseTeam();
$admin = $this->superAdmin();
$team->members()->attach($admin, ['role' => 'owner', 'status' => 1]);
$team->forceFill(['billing_storage_used_bytes' => 26843545600])->save();
$this->actingAs($admin)
->get(route('superadmin.billing.teams.show', $team))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->where('plan.seats_used', 1)
->where('plan.storage_used', '25 GB')
->where('plan.storage_quota', '50 GB')
);
}
public function test_it_lists_every_module_with_its_grant(): void
{
$team = $this->enterpriseTeam();
Module::create(['key' => 'crm', 'name' => 'CRM', 'order' => 1, 'menu' => true, 'status' => true]);
Module::create(['key' => 'logistics3p', 'name' => '3PL', 'order' => 2, 'menu' => true, 'status' => true]);
$team->update(['modulesAllowed' => ['crm' => true, 'logistics3p' => false]]);
$this->actingAs($this->superAdmin())
->get(route('superadmin.billing.teams.show', $team))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->has('modules', 2)
->where('modules.0.key', 'crm')
->where('modules.0.allowed', true)
->where('modules.1.key', 'logistics3p')
->where('modules.1.allowed', false)
);
}
public function test_it_lists_invoices_newest_first_with_formatted_totals(): void
{
$team = $this->enterpriseTeam();
foreach ([['2026-10-01', 450000, 'INV-2026-0001'], ['2026-11-01', 500000, 'INV-2026-0002']] as [$start, $total, $number]) {
Invoice::create([
'team_id' => $team->id,
'number' => $number,
'source' => BillingInvoiceSource::Manual,
'period_start' => $start,
'period_end' => $start,
'subtotal_cents' => $total,
'total_cents' => $total,
'currency' => 'usd',
'status' => BillingInvoiceStatus::Open,
'issued_at' => $start.' 09:00:00',
]);
}
$this->actingAs($this->superAdmin())
->get(route('superadmin.billing.teams.show', $team))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->has('invoices.data', 2)
->where('invoices.data.0.number', 'INV-2026-0002')
->where('invoices.data.0.total', '$5,000.00')
->where('invoices.data.0.status', 'open')
->where('invoices.data.0.amount_due', '$5,000.00')
);
}
public function test_it_lists_payments_with_who_recorded_them(): void
{
$team = $this->enterpriseTeam();
$admin = $this->superAdmin();
Payment::create([
'team_id' => $team->id,
'billing_invoice_id' => null,
'method' => BillingPaymentMethod::BankTransfer,
'amount_cents' => 450000,
'paid_at' => '2026-09-20 14:30:00',
'reference' => 'SWIFT 8842190',
'recorded_by_user_id' => $admin->id,
]);
$this->actingAs($admin)
->get(route('superadmin.billing.teams.show', $team))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->has('payments.data', 1)
->where('payments.data.0.amount', '$4,500.00')
->where('payments.data.0.method_label', 'Bank transfer')
->where('payments.data.0.reference', 'SWIFT 8842190')
->where('payments.data.0.recorded_by', $admin->name)
);
}
public function test_usage_is_grouped_by_service_and_period(): void
{
$team = $this->enterpriseTeam();
foreach ([['sms', '2026-08', 1000], ['sms', '2026-08', 500], ['ai_tokens', '2026-08', 250000]] as [$service, $period, $amount]) {
UsageEvent::create([
'team_id' => $team->id,
'service' => $service,
'quantity' => 10,
'unit' => 'message',
'unit_amount_cents' => 50,
'amount_cents' => $amount,
'occurred_at' => '2026-08-14 10:22:00',
'billing_period' => $period,
]);
}
$this->actingAs($this->superAdmin())
->get(route('superadmin.billing.teams.show', $team))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->has('usage', 2)
->where('usage.0.service', 'ai_tokens')
->where('usage.0.billing_period', '2026-08')
->where('usage.0.amount', '$2,500.00')
->where('usage.1.service', 'sms')
->where('usage.1.amount', '$15.00')
->where('usage.1.events', 2)
);
}
public function test_usage_is_empty_when_nothing_has_been_recorded(): void
{
$team = $this->enterpriseTeam();
$this->actingAs($this->superAdmin())
->get(route('superadmin.billing.teams.show', $team))
->assertOk()
->assertInertia(fn (Assert $page) => $page->has('usage', 0));
}
public function test_it_lists_the_activity_timeline_newest_first(): void
{
$team = $this->enterpriseTeam();
$admin = $this->superAdmin();
BillingEvent::record($team, 'plan.enterprise_saved', ['amount_cents' => 450000], $admin->id);
BillingEvent::record($team, 'team.suspended', ['reason' => 'Non-payment.'], $admin->id);
$this->actingAs($admin)
->get(route('superadmin.billing.teams.show', $team))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->has('activity.data', 2)
->where('activity.data.0.type', 'team.suspended')
->where('activity.data.0.actor', $admin->name)
);
}
public function test_another_teams_records_do_not_leak_in(): void
{
$team = $this->enterpriseTeam();
$other = Team::factory()->create(['name' => 'Globex Freight']);
Invoice::create([
'team_id' => $other->id,
'number' => 'INV-2026-0009',
'source' => BillingInvoiceSource::Manual,
'period_start' => '2026-10-01',
'period_end' => '2026-10-31',
'subtotal_cents' => 1000,
'total_cents' => 1000,
'currency' => 'usd',
'status' => BillingInvoiceStatus::Open,
]);
BillingEvent::record($other, 'plan.enterprise_saved', [], null);
$this->actingAs($this->superAdmin())
->get(route('superadmin.billing.teams.show', $team))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->has('invoices.data', 0)
->has('activity.data', 0)
);
}
public function test_the_three_lists_paginate_independently(): void
{
$team = $this->enterpriseTeam();
for ($i = 1; $i <= 12; $i++) {
$invoice = Invoice::create([
'team_id' => $team->id,
'number' => sprintf('INV-2026-%04d', $i),
'source' => BillingInvoiceSource::Manual,
'period_start' => '2026-10-01',
'period_end' => '2026-10-31',
'subtotal_cents' => 1000 * $i,
'total_cents' => 1000 * $i,
'currency' => 'usd',
'status' => BillingInvoiceStatus::Open,
'issued_at' => sprintf('2026-10-%02d 09:00:00', $i),
]);
InvoiceLine::create([
'billing_invoice_id' => $invoice->id,
'kind' => BillingInvoiceLineKind::Module,
'description' => 'Line',
'quantity' => 1,
'unit_amount_cents' => 1000 * $i,
'amount_cents' => 1000 * $i,
]);
}
$response = $this->actingAs($this->superAdmin())
->get(route('superadmin.billing.teams.show', $team).'?invoices=2');
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->has('invoices.data', 2)
->where('invoices.current_page', 2)
);
}
}
- Step 2: Build the frontend assets once
A new Inertia page has no entry in the Vite manifest until the assets are built, and the render tests will fail with a ViteException that has nothing to do with your code.
Run: npm run build
Expected: completes without error. (Re-run it after creating Show.vue in Step 6.)
- Step 3: Run the test to verify it fails
Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/SuperAdmin/Billing/TeamBillingScreenTest.php
Expected: FAIL — Route [superadmin.billing.teams.show] not defined.
- Step 4: Add the route
In routes/web/superadmin.php, inside the existing Route::middleware(['auth', 'verified', EnsureSuperAdmin::class])->prefix('superadmin')->name('superadmin.')->group(...) closure, after the existing Route::resource('teams', ...) lines, add:
Route::prefix('billing')->name('billing.')->group(function () {
Route::get('teams/{team}', [TeamBillingController::class, 'show'])->name('teams.show');
});
and add the import use App\Http\Controllers\SuperAdmin\Billing\TeamBillingController; at the top of the file.
- Step 5: Write the controller
Create app/Http/Controllers/SuperAdmin/Billing/TeamBillingController.php:
<?php
namespace App\Http\Controllers\SuperAdmin\Billing;
use App\Http\Controllers\Controller;
use App\Models\Billing\BillingEvent;
use App\Models\Billing\Invoice;
use App\Models\Billing\Payment;
use App\Models\Billing\UsageEvent;
use App\Models\System\Module;
use App\Models\Team;
use App\Support\Billing\Entitlements;
use App\Support\Billing\Money;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
/**
* The superadmin's per-team billing screen (design doc, "2b in detail" →
* "The screen").
*
* Read-only. Every write on this screen posts to one of the sibling
* controllers, which delegate to `app/Actions/Billing/` — that is what keeps
* the `BillingEvent` audit trail and the entitlement-flush rule in one place.
*
* All money is formatted here rather than in Vue: each row carries both the
* integer cents for logic and a display string, so no component ever divides
* by 100.
*/
class TeamBillingController extends Controller
{
public function show(Team $team): Response
{
$entitlement = Entitlements::for($team);
return Inertia::render('superadmin/billing/teams/Show', [
'team' => [
'id' => $team->id,
'name' => $team->name,
'slug' => $team->slug,
],
'plan' => [
'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(),
'grants_app_access' => $team->billing_access_state->grantsAppAccess(),
'amount_cents' => $team->billing_enterprise_amount_cents,
'amount' => $team->billing_enterprise_amount_cents === null
? null
: Money::format($team->billing_enterprise_amount_cents),
// The same amount as an editable input value — plain digits and a
// dot, no symbol or separators, so `Money::toCents()` accepts it
// back unchanged. Emitting this is what keeps the "no money
// arithmetic in Vue" rule absolute instead of one-exception.
'amount_input' => $team->billing_enterprise_amount_cents === null
? ''
: number_format($team->billing_enterprise_amount_cents / 100, 2, '.', ''),
'interval' => $team->billing_enterprise_interval?->value,
'renewal_at' => $team->billing_renewal_at?->toDateString(),
'grace_ends_at' => $team->billing_grace_ends_at?->toDateTimeString(),
'billing_email' => $team->billing_email,
'notes' => $team->billing_notes,
'seat_cap' => $team->billing_seat_cap,
'seats_used' => $team->activeSeatCount(),
'storage_quota_bytes' => $entitlement->storageQuotaBytes,
'storage_quota' => self::formatBytes($entitlement->storageQuotaBytes),
// The EXPLICIT quota only — null when the team is on the
// per-seat default. The terms form binds to this, so saving
// the form without touching Storage cannot pin today's
// auto-computed figure as a fixed quota. `storage_quota`
// above stays the effective figure for the summary card.
'storage_quota_gb' => $team->billing_storage_quota_bytes === null
? null
: (int) round($team->billing_storage_quota_bytes / 1024 ** 3),
'storage_used' => self::formatBytes($entitlement->storageUsedBytes),
'storage_percent' => $entitlement->storagePercentUsed(),
],
'modules' => Module::query()
->whereNotNull('key')
->orderBy('order')
->get()
->map(fn (Module $module) => [
'key' => $module->key,
'name' => $module->name,
'allowed' => (bool) ($team->modulesAllowed[$module->key] ?? false),
])
->values(),
'invoices' => Invoice::query()
->where('team_id', $team->id)
->orderByDesc('period_start')
->orderByDesc('id')
->paginate(10, ['*'], 'invoices')
->through(fn (Invoice $invoice) => [
'id' => $invoice->id,
'number' => $invoice->number,
'source' => $invoice->source->value,
'period_start' => $invoice->period_start->toDateString(),
'period_end' => $invoice->period_end->toDateString(),
'total_cents' => $invoice->total_cents,
'total' => Money::format($invoice->total_cents, $invoice->currency),
'amount_due' => Money::format($invoice->amountDueCents(), $invoice->currency),
'status' => $invoice->status->value,
'issued_at' => $invoice->issued_at?->toDateString(),
'paid_at' => $invoice->paid_at?->toDateString(),
]),
'payments' => Payment::query()
->with('recordedBy')
->where('team_id', $team->id)
->orderByDesc('paid_at')
->orderByDesc('id')
->paginate(10, ['*'], 'payments')
->through(fn (Payment $payment) => [
'id' => $payment->id,
'amount' => Money::format($payment->amount_cents),
'method' => $payment->method->value,
'method_label' => $payment->method->label(),
'paid_at' => $payment->paid_at->toDateString(),
'reference' => $payment->reference,
'recorded_by' => $payment->recordedBy?->name,
'invoice_id' => $payment->billing_invoice_id,
]),
// The query builder, not the Eloquent model: these are grouped
// aggregate rows, not `UsageEvent`s, and `$row->events` is a query
// alias rather than a property the model has. `UsageEvent` carries
// no global scopes (billing models deliberately have no
// `BelongsToOrganization`), so the explicit `where('team_id', ...)`
// is all the scoping this needs.
'usage' => DB::table('billing_usage_events')
->where('team_id', $team->id)
->groupBy('service', 'billing_period')
->orderByDesc('billing_period')
->orderBy('service')
->select([
'service',
'billing_period',
DB::raw('SUM(amount_cents) as amount_cents'),
DB::raw('SUM(quantity) as quantity'),
DB::raw('COUNT(*) as events'),
])
->get()
->map(fn (object $row) => [
'service' => $row->service,
'billing_period' => $row->billing_period,
'amount' => Money::format((int) $row->amount_cents),
'quantity' => (string) $row->quantity,
'events' => (int) $row->events,
])
->values(),
'activity' => BillingEvent::query()
->with('actor')
->where('team_id', $team->id)
->orderByDesc('id')
->paginate(20, ['*'], 'activity')
->through(fn (BillingEvent $event) => [
'id' => $event->id,
'type' => $event->type,
'actor' => $event->actor?->name,
'payload' => $event->payload,
'created_at' => $event->created_at?->toDateTimeString(),
]),
]);
}
/**
* Bytes as a whole number of the largest unit that divides evenly enough
* to read — the quotas an operator sets are always round GB.
*/
private static function formatBytes(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';
}
}
Note: round() returns a float, so 26843545600 bytes renders 25 GB rather than 25.0 GB — PHP drops the trailing .0 when concatenating. The test asserts exactly that.
- Step 6: Write the TypeScript types
Create resources/js/types/billing.ts:
export type BillingTeam = {
id: number;
name: string;
slug: string;
};
export type BillingPlan = {
billing_type: string;
billing_type_label: string;
access_state: string;
access_state_label: string;
grants_app_access: boolean;
amount_cents: number | null;
amount: string | null;
amount_input: string;
interval: string | null;
renewal_at: string | null;
grace_ends_at: string | null;
billing_email: string | null;
notes: string | null;
seat_cap: number | null;
seats_used: number;
storage_quota_bytes: number;
storage_quota: string;
storage_quota_gb: number | null;
storage_used: string;
storage_percent: number;
};
export type BillingModuleOption = {
key: string;
name: string;
allowed: boolean;
};
export type BillingInvoiceRow = {
id: number;
number: string | null;
source: string;
period_start: string;
period_end: string;
total_cents: number;
total: string;
amount_due: string;
status: string;
issued_at: string | null;
paid_at: string | null;
};
export type BillingPaymentRow = {
id: number;
amount: string;
method: string;
method_label: string;
paid_at: string;
reference: string | null;
recorded_by: string | null;
invoice_id: number | null;
};
export type BillingUsageRow = {
service: string;
billing_period: string;
amount: string;
quantity: string;
events: number;
};
export type BillingActivityRow = {
id: number;
type: string;
actor: string | null;
payload: Record<string, unknown> | null;
created_at: string | null;
};
- Step 6b: Register the new type module in the barrel
resources/js/types/index.ts re-exports every file under resources/js/types/.
Add billing.ts to it, matching the surrounding entries exactly — alphabetical
position, the same export form, the same quote style. Leaving it out would make
billing.ts the only type module in the project that is not reachable through the
barrel, and this screen is the one two later sub-increments copy.
- Step 7: Write the page
Create resources/js/pages/superadmin/billing/teams/Show.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 teamsIndex, edit as teamEdit } from '@/routes/superadmin/teams';
import type {
BillingActivityRow,
BillingInvoiceRow,
BillingModuleOption,
BillingPaymentRow,
BillingPlan,
BillingTeam,
BillingUsageRow,
} from '@/types/billing';
import type { Paginated } from '@/types/superadmin';
defineProps<{
team: BillingTeam;
plan: BillingPlan;
modules: BillingModuleOption[];
invoices: Paginated<BillingInvoiceRow>;
payments: Paginated<BillingPaymentRow>;
usage: BillingUsageRow[];
activity: Paginated<BillingActivityRow>;
}>();
defineOptions({
layout: (props: { team: BillingTeam }) => ({
breadcrumbs: [
{ title: 'Superadmin', href: superadminIndex() },
{ title: 'Teams', href: teamsIndex() },
{ title: props.team.name, href: teamEdit(props.team.slug) },
{ title: 'Billing', href: '' },
],
}),
});
const statusClasses: Record<string, string> = {
draft: 'badge-light',
open: 'badge-light-warning',
paid: 'badge-light-success',
void: 'badge-light-dark',
uncollectible: 'badge-light-danger',
};
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 — ${team.name}`" />
<Heading variant="small" :title="`${team.name} — billing`" description="Plan, invoices, payments and activity for this organization" class="mb-6" />
<div class="card mb-6">
<div class="card-header">
<h3 class="card-title">Plan</h3>
</div>
<div class="card-body">
<div class="row g-6">
<div class="col-md-3">
<div class="text-muted fs-7">Type</div>
<div class="fw-semibold">{{ plan.billing_type_label }}</div>
</div>
<div class="col-md-3">
<div class="text-muted fs-7">Status</div>
<span class="badge" :class="stateClasses[plan.access_state] ?? 'badge-light'">{{ plan.access_state_label }}</span>
</div>
<div class="col-md-3">
<div class="text-muted fs-7">Amount</div>
<div class="fw-semibold">{{ plan.amount ?? '—' }}<span v-if="plan.interval" class="text-muted">/{{ plan.interval }}</span></div>
</div>
<div class="col-md-3">
<div class="text-muted fs-7">Renews</div>
<div class="fw-semibold">{{ plan.renewal_at ?? '—' }}</div>
</div>
<div class="col-md-3">
<div class="text-muted fs-7">Seats</div>
<div class="fw-semibold">{{ plan.seats_used }}<span v-if="plan.seat_cap" class="text-muted"> / {{ plan.seat_cap }}</span></div>
</div>
<div class="col-md-3">
<div class="text-muted fs-7">Storage</div>
<div class="fw-semibold">{{ plan.storage_used }} / {{ plan.storage_quota }}</div>
</div>
<div class="col-md-3">
<div class="text-muted fs-7">Billing email</div>
<div class="fw-semibold">{{ plan.billing_email ?? '—' }}</div>
</div>
<div class="col-md-3">
<div class="text-muted fs-7">Grace ends</div>
<div class="fw-semibold">{{ plan.grace_ends_at ?? '—' }}</div>
</div>
</div>
<p v-if="plan.notes" class="text-muted mt-6 mb-0">{{ plan.notes }}</p>
</div>
</div>
<div class="card mb-6">
<div class="card-header">
<h3 class="card-title">Modules</h3>
</div>
<div class="card-body d-flex flex-wrap gap-2">
<span v-for="module in modules" :key="module.key" class="badge" :class="module.allowed ? 'badge-light-success' : 'badge-light'">
{{ module.name }}
</span>
<span v-if="modules.length === 0" class="text-muted">No modules configured.</span>
</div>
</div>
<div class="card mb-6">
<div class="card-header">
<h3 class="card-title">Invoices</h3>
</div>
<div class="card-body">
<table class="table table-striped table-row-bordered gy-3 gs-3">
<thead>
<tr class="fw-semibold fs-6 text-gray-800">
<th>Number</th>
<th>Period</th>
<th class="text-end">Total</th>
<th class="text-end">Due</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr v-for="invoice in invoices.data" :key="invoice.id">
<td>{{ invoice.number ?? '—' }}</td>
<td>{{ invoice.period_start }} → {{ invoice.period_end }}</td>
<td class="text-end">{{ invoice.total }}</td>
<td class="text-end">{{ invoice.amount_due }}</td>
<td><span class="badge" :class="statusClasses[invoice.status] ?? 'badge-light'">{{ invoice.status }}</span></td>
</tr>
<tr v-if="invoices.data.length === 0">
<td colspan="5" class="text-muted">No invoices yet.</td>
</tr>
</tbody>
</table>
<nav v-if="invoices.last_page > 1">
<ul class="pagination">
<li v-for="link in invoices.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>
<div class="card mb-6">
<div class="card-header">
<h3 class="card-title">Payments</h3>
</div>
<div class="card-body">
<table class="table table-striped table-row-bordered gy-3 gs-3">
<thead>
<tr class="fw-semibold fs-6 text-gray-800">
<th>Date</th>
<th class="text-end">Amount</th>
<th>Method</th>
<th>Reference</th>
<th>Recorded by</th>
</tr>
</thead>
<tbody>
<tr v-for="payment in payments.data" :key="payment.id">
<td>{{ payment.paid_at }}</td>
<td class="text-end">{{ payment.amount }}</td>
<td>{{ payment.method_label }}</td>
<td>{{ payment.reference ?? '—' }}</td>
<td>{{ payment.recorded_by ?? 'System' }}</td>
</tr>
<tr v-if="payments.data.length === 0">
<td colspan="5" class="text-muted">No payments recorded.</td>
</tr>
</tbody>
</table>
<nav v-if="payments.last_page > 1">
<ul class="pagination">
<li v-for="link in payments.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>
<div class="row">
<div class="col-md-5">
<div class="card mb-6">
<div class="card-header">
<h3 class="card-title">Usage</h3>
</div>
<div class="card-body">
<table class="table table-striped table-row-bordered gy-3 gs-3">
<thead>
<tr class="fw-semibold fs-6 text-gray-800">
<th>Period</th>
<th>Service</th>
<th class="text-end">Amount</th>
</tr>
</thead>
<tbody>
<tr v-for="row in usage" :key="`${row.billing_period}-${row.service}`">
<td>{{ row.billing_period }}</td>
<td>{{ row.service }}</td>
<td class="text-end">{{ row.amount }}</td>
</tr>
<tr v-if="usage.length === 0">
<td colspan="3" class="text-muted">No usage recorded.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="col-md-7">
<div class="card mb-6">
<div class="card-header">
<h3 class="card-title">Activity</h3>
</div>
<div class="card-body">
<table class="table table-striped table-row-bordered gy-3 gs-3">
<thead>
<tr class="fw-semibold fs-6 text-gray-800">
<th>When</th>
<th>Event</th>
<th>By</th>
</tr>
</thead>
<tbody>
<tr v-for="event in activity.data" :key="event.id">
<td>{{ event.created_at }}</td>
<td>{{ event.type }}</td>
<td>{{ event.actor ?? 'System' }}</td>
</tr>
<tr v-if="activity.data.length === 0">
<td colspan="3" class="text-muted">Nothing recorded yet.</td>
</tr>
</tbody>
</table>
<nav v-if="activity.last_page > 1">
<ul class="pagination">
<li v-for="link in activity.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>
</div>
</div>
</template>
- Step 8: Link to the screen from the existing team edit page
In resources/js/pages/superadmin/teams/Edit.vue, add the import:
import { show as billingShow } from '@/routes/superadmin/billing/teams';
and, inside the existing "Team settings" card body after the <Form>, add:
<Link :href="billingShow(team.slug)" class="btn btn-light-primary mt-4">Billing</Link>
Link is NOT already imported in that file — add it to the existing
import { Form, Head, router } from '@inertiajs/vue3'; line.
- Step 9: Regenerate Wayfinder and rebuild
herd php artisan wayfinder:generate --with-form
npm run build
The --with-form flag is mandatory — the bare command strips .form() and breaks Login.vue and other existing pages.
- Step 10: Run the test to verify it passes
Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/SuperAdmin/Billing/TeamBillingScreenTest.php
Expected: PASS, 10 tests.
- Step 11: Confirm the existing superadmin suite is unaffected
Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/SuperAdmin tests/Feature/Billing
Expected: PASS.
- Step 12: Format and commit
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/types/billing.ts resources/js/pages/superadmin tests/Feature/SuperAdmin/Billing
git commit -m "Billing: show a team's plan, invoices, payments, usage and activity"
Task 3: Plan, module and access endpoints
Five write endpoints over the Actions that mutate a team, plus their forms on the screen.
Files:
- Create:
app/Http/Requests/SuperAdmin/Billing/SaveEnterprisePlanRequest.php - Create:
app/Http/Requests/SuperAdmin/Billing/SetTeamModulesRequest.php - Create:
app/Http/Requests/SuperAdmin/Billing/ExtendGraceRequest.php - Create:
app/Http/Requests/SuperAdmin/Billing/SuspendTeamRequest.php - Create:
app/Http/Controllers/SuperAdmin/Billing/EnterprisePlanController.php - Create:
app/Http/Controllers/SuperAdmin/Billing/TeamModuleController.php - Create:
app/Http/Controllers/SuperAdmin/Billing/TeamAccessController.php - Modify:
routes/web/superadmin.php - Modify:
resources/js/pages/superadmin/billing/teams/Show.vue - Test:
tests/Feature/SuperAdmin/Billing/TeamPlanEndpointsTest.php
Interfaces:
-
Consumes:
Money::toCents()(Task 1); the routesuperadmin.billing.teams.show(Task 2); the 2a ActionsSaveEnterprisePlan::handle(Team, int, BillingCycle, User, ?CarbonInterface, ?int, ?int, ?string, ?string): Team,SetTeamModules::handle(Team, array, User): Team,ExtendGrace::handle(Team, CarbonInterface, User): Team,SuspendTeam::handle(Team, User, ?string): Team,RestoreTeam::handle(Team, User): Team. -
Produces: routes
superadmin.billing.teams.plan.update,.modules.update,.grace.store,.suspend.store,.suspend.destroy. -
Step 1: Write the failing test
Create tests/Feature/SuperAdmin/Billing/TeamPlanEndpointsTest.php:
<?php
namespace Tests\Feature\SuperAdmin\Billing;
use App\Enums\BillingAccessState;
use App\Enums\BillingCycle;
use App\Enums\BillingType;
use App\Models\Billing\BillingEvent;
use App\Models\System\Module;
use App\Models\Team;
use App\Models\User;
use App\Support\Billing\Entitlements;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Tests\TestCase;
/**
* The plan, module and access write endpoints (design doc, "2b in detail" →
* "Routes and controllers").
*/
class TeamPlanEndpointsTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Entitlements::flush();
}
protected function tearDown(): void
{
Entitlements::flush();
Carbon::setTestNow();
parent::tearDown();
}
private function superAdmin(): User
{
return User::factory()->create(['superAdmin' => true]);
}
public function test_a_non_superadmin_cannot_change_a_plan(): void
{
$team = Team::factory()->create();
$this->actingAs(User::factory()->create(['superAdmin' => false]))
->patch(route('superadmin.billing.teams.plan.update', $team), [
'amount' => '4500.00',
'interval' => 'monthly',
])
->assertForbidden();
}
public function test_it_saves_an_enterprise_plan_converting_dollars_to_cents(): void
{
$team = Team::factory()->create();
$this->actingAs($this->superAdmin())
->patch(route('superadmin.billing.teams.plan.update', $team), [
'amount' => '4500.00',
'interval' => 'monthly',
'renewal_at' => '2027-01-01',
'seat_cap' => 25,
'storage_quota_gb' => 50,
'billing_email' => '[email protected]',
'notes' => 'Handled by the account manager.',
])
->assertRedirect(route('superadmin.billing.teams.show', $team));
$team->refresh();
$this->assertSame(BillingType::Enterprise, $team->billing_type);
$this->assertSame(450000, $team->billing_enterprise_amount_cents);
$this->assertSame(BillingCycle::Monthly, $team->billing_enterprise_interval);
$this->assertSame('2027-01-01', $team->billing_renewal_at->toDateString());
$this->assertSame(25, $team->billing_seat_cap);
$this->assertSame(50 * 1024 * 1024 * 1024, $team->billing_storage_quota_bytes);
$this->assertSame('[email protected]', $team->billing_email);
}
public function test_optional_plan_fields_may_be_omitted_to_clear_them(): void
{
$team = Team::factory()->create();
$team->forceFill(['billing_seat_cap' => 25, 'billing_storage_quota_bytes' => 1024])->save();
$this->actingAs($this->superAdmin())
->patch(route('superadmin.billing.teams.plan.update', $team), [
'amount' => '4500.00',
'interval' => 'monthly',
])
->assertRedirect();
$team->refresh();
$this->assertNull($team->billing_seat_cap);
$this->assertNull($team->billing_storage_quota_bytes);
}
public function test_a_malformed_amount_is_rejected(): void
{
$team = Team::factory()->create();
$this->actingAs($this->superAdmin())
->patch(route('superadmin.billing.teams.plan.update', $team), [
'amount' => '4,500.00',
'interval' => 'monthly',
])
->assertSessionHasErrors('amount');
}
public function test_more_than_two_decimal_places_is_rejected(): void
{
$team = Team::factory()->create();
$this->actingAs($this->superAdmin())
->patch(route('superadmin.billing.teams.plan.update', $team), [
'amount' => '4500.005',
'interval' => 'monthly',
])
->assertSessionHasErrors('amount');
}
public function test_an_unknown_interval_is_rejected(): void
{
$team = Team::factory()->create();
$this->actingAs($this->superAdmin())
->patch(route('superadmin.billing.teams.plan.update', $team), [
'amount' => '4500.00',
'interval' => 'annual',
])
->assertSessionHasErrors('interval');
}
public function test_it_sets_a_teams_modules(): void
{
$team = Team::factory()->create();
Module::create(['key' => 'crm', 'name' => 'CRM', 'order' => 1, 'menu' => true, 'status' => true]);
Module::create(['key' => 'logistics3p', 'name' => '3PL', 'order' => 2, 'menu' => true, 'status' => true]);
$this->actingAs($this->superAdmin())
->patch(route('superadmin.billing.teams.modules.update', $team), ['modules' => ['crm']])
->assertRedirect(route('superadmin.billing.teams.show', $team));
$this->assertSame(['crm' => true, 'logistics3p' => false], $team->fresh()->modulesAllowed);
}
public function test_granting_no_modules_denies_all_of_them(): void
{
$team = Team::factory()->create();
Module::create(['key' => 'crm', 'name' => 'CRM', 'order' => 1, 'menu' => true, 'status' => true]);
$this->actingAs($this->superAdmin())
->patch(route('superadmin.billing.teams.modules.update', $team), ['modules' => []])
->assertRedirect();
$this->assertSame(['crm' => false], $team->fresh()->modulesAllowed);
}
public function test_an_unknown_module_key_is_rejected(): void
{
$team = Team::factory()->create();
Module::create(['key' => 'crm', 'name' => 'CRM', 'order' => 1, 'menu' => true, 'status' => true]);
$this->actingAs($this->superAdmin())
->patch(route('superadmin.billing.teams.modules.update', $team), ['modules' => ['crm', 'nonsense']])
->assertSessionHasErrors('modules.1');
}
public function test_it_extends_a_grace_period(): void
{
Carbon::setTestNow('2026-09-15 10:00:00');
$team = Team::factory()->create();
$team->forceFill(['billing_access_state' => BillingAccessState::PastDue])->save();
$this->actingAs($this->superAdmin())
->post(route('superadmin.billing.teams.grace.store', $team), ['grace_ends_at' => '2026-10-05'])
->assertRedirect(route('superadmin.billing.teams.show', $team));
$this->assertSame('2026-10-05', $team->fresh()->billing_grace_ends_at->toDateString());
}
public function test_a_grace_date_in_the_past_is_rejected(): void
{
Carbon::setTestNow('2026-09-15 10:00:00');
$team = Team::factory()->create();
$this->actingAs($this->superAdmin())
->post(route('superadmin.billing.teams.grace.store', $team), ['grace_ends_at' => '2026-09-01'])
->assertSessionHasErrors('grace_ends_at');
}
public function test_it_suspends_and_restores_a_team(): void
{
Carbon::setTestNow('2026-09-15 10:00:00');
$team = Team::factory()->create();
$admin = $this->superAdmin();
$this->actingAs($admin)
->post(route('superadmin.billing.teams.suspend.store', $team), ['reason' => 'Non-payment after three reminders.'])
->assertRedirect(route('superadmin.billing.teams.show', $team));
$this->assertSame(BillingAccessState::Suspended, $team->fresh()->billing_access_state);
$this->assertSame('Non-payment after three reminders.', BillingEvent::where('type', 'team.suspended')->firstOrFail()->payload['reason']);
$this->actingAs($admin)
->delete(route('superadmin.billing.teams.suspend.destroy', $team))
->assertRedirect(route('superadmin.billing.teams.show', $team));
$this->assertSame(BillingAccessState::Active, $team->fresh()->billing_access_state);
}
public function test_the_action_guards_surface_as_a_422(): void
{
$team = Team::factory()->create();
// Not suspended, so `RestoreTeam` aborts.
$this->actingAs($this->superAdmin())
->delete(route('superadmin.billing.teams.suspend.destroy', $team))
->assertStatus(422);
}
}
- Step 2: Run the test to verify it fails
Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/SuperAdmin/Billing/TeamPlanEndpointsTest.php
Expected: FAIL — Route [superadmin.billing.teams.plan.update] not defined.
- Step 3: Write the FormRequests
Create app/Http/Requests/SuperAdmin/Billing/SaveEnterprisePlanRequest.php:
<?php
namespace App\Http\Requests\SuperAdmin\Billing;
use App\Enums\BillingCycle;
use App\Support\Billing\Money;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* Operators type dollars and whole gigabytes; storage is integer cents and
* bytes. The conversion happens here and nowhere else.
*
* The amount regex is what makes `Money::toCents()` safe to parse as a
* string: it rejects thousands separators, currency symbols and a third
* decimal place before the value ever reaches it.
*/
class SaveEnterprisePlanRequest extends FormRequest
{
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'amount' => ['required', 'string', 'regex:/^\d{1,12}(\.\d{1,2})?$/'],
'interval' => ['required', Rule::enum(BillingCycle::class)],
'renewal_at' => ['nullable', 'date'],
'seat_cap' => ['nullable', 'integer', 'min:1'],
'storage_quota_gb' => ['nullable', 'integer', 'min:0'],
'billing_email' => ['nullable', 'email', 'max:255'],
'notes' => ['nullable', 'string', 'max:2000'],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'amount.regex' => 'Enter an amount in dollars, with at most two decimal places and no separators — for example 4500.00.',
];
}
public function amountCents(): int
{
return Money::toCents($this->string('amount')->value());
}
public function storageQuotaBytes(): ?int
{
$gigabytes = $this->input('storage_quota_gb');
return $gigabytes === null ? null : ((int) $gigabytes) * 1024 * 1024 * 1024;
}
}
Create app/Http/Requests/SuperAdmin/Billing/SetTeamModulesRequest.php:
<?php
namespace App\Http\Requests\SuperAdmin\Billing;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class SetTeamModulesRequest extends FormRequest
{
/**
* An HTML form with no checked boxes omits the field entirely, so a
* superadmin revoking every module would otherwise fail the `present`
* rule rather than denying all of them — and denying all of them is a
* deliberate, tested capability of `SetTeamModules`.
*
* The hidden `modules_submitted` marker is what distinguishes that from a
* caller that simply forgot the field: only a submission carrying the
* marker gets the empty-array default, so a malformed request still
* errors instead of silently stripping a paying team's modules.
*/
protected function prepareForValidation(): void
{
if ($this->boolean('modules_submitted') && ! $this->has('modules')) {
$this->merge(['modules' => []]);
}
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'modules' => ['present', 'array'],
'modules.*' => ['string', Rule::exists('modules', 'key')],
];
}
/**
* @return list<string>
*/
public function moduleKeys(): array
{
return array_values($this->input('modules', []));
}
}
Create app/Http/Requests/SuperAdmin/Billing/ExtendGraceRequest.php:
<?php
namespace App\Http\Requests\SuperAdmin\Billing;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class ExtendGraceRequest extends FormRequest
{
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'grace_ends_at' => ['required', 'date', 'after:now'],
];
}
}
Create app/Http/Requests/SuperAdmin/Billing/SuspendTeamRequest.php:
<?php
namespace App\Http\Requests\SuperAdmin\Billing;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class SuspendTeamRequest extends FormRequest
{
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'reason' => ['nullable', 'string', 'max:1000'],
];
}
}
- Step 4: Write the three controllers
Create app/Http/Controllers/SuperAdmin/Billing/EnterprisePlanController.php:
<?php
namespace App\Http\Controllers\SuperAdmin\Billing;
use App\Actions\Billing\SaveEnterprisePlan;
use App\Enums\BillingCycle;
use App\Http\Controllers\Controller;
use App\Http\Requests\SuperAdmin\Billing\SaveEnterprisePlanRequest;
use App\Models\Team;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
/**
* Sets a team's negotiated enterprise terms. Converting a team to enterprise
* and editing an existing one are the same request — see the Action's own
* docblock.
*/
class EnterprisePlanController extends Controller
{
public function update(SaveEnterprisePlanRequest $request, Team $team, SaveEnterprisePlan $saveEnterprisePlan): RedirectResponse
{
$saveEnterprisePlan->handle(
$team,
$request->amountCents(),
BillingCycle::from($request->string('interval')->value()),
$request->user(),
$request->date('renewal_at'),
$request->input('seat_cap') === null ? null : $request->integer('seat_cap'),
$request->storageQuotaBytes(),
$request->input('billing_email'),
$request->input('notes'),
);
Inertia::flash('toast', ['type' => 'success', 'message' => __('Plan saved.')]);
return to_route('superadmin.billing.teams.show', $team);
}
}
Create app/Http/Controllers/SuperAdmin/Billing/TeamModuleController.php:
<?php
namespace App\Http\Controllers\SuperAdmin\Billing;
use App\Actions\Billing\SetTeamModules;
use App\Http\Controllers\Controller;
use App\Http\Requests\SuperAdmin\Billing\SetTeamModulesRequest;
use App\Models\Team;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
class TeamModuleController extends Controller
{
public function update(SetTeamModulesRequest $request, Team $team, SetTeamModules $setTeamModules): RedirectResponse
{
$setTeamModules->handle($team, $request->moduleKeys(), $request->user());
Inertia::flash('toast', ['type' => 'success', 'message' => __('Modules updated.')]);
return to_route('superadmin.billing.teams.show', $team);
}
}
Create app/Http/Controllers/SuperAdmin/Billing/TeamAccessController.php:
<?php
namespace App\Http\Controllers\SuperAdmin\Billing;
use App\Actions\Billing\ExtendGrace;
use App\Actions\Billing\RestoreTeam;
use App\Actions\Billing\SuspendTeam;
use App\Http\Controllers\Controller;
use App\Http\Requests\SuperAdmin\Billing\ExtendGraceRequest;
use App\Http\Requests\SuperAdmin\Billing\SuspendTeamRequest;
use App\Models\Team;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
/**
* The three Actions over a team's access state. Each Action carries its own
* guards and aborts 422 — this controller deliberately re-checks nothing, so
* there is exactly one place the rules live.
*/
class TeamAccessController extends Controller
{
public function extendGrace(ExtendGraceRequest $request, Team $team, ExtendGrace $extendGrace): RedirectResponse
{
$extendGrace->handle($team, $request->date('grace_ends_at'), $request->user());
Inertia::flash('toast', ['type' => 'success', 'message' => __('Grace period extended.')]);
return to_route('superadmin.billing.teams.show', $team);
}
public function suspend(SuspendTeamRequest $request, Team $team, SuspendTeam $suspendTeam): RedirectResponse
{
$suspendTeam->handle($team, $request->user(), $request->input('reason'));
Inertia::flash('toast', ['type' => 'success', 'message' => __('Team suspended.')]);
return to_route('superadmin.billing.teams.show', $team);
}
public function restore(Team $team, RestoreTeam $restoreTeam): RedirectResponse
{
$restoreTeam->handle($team, request()->user());
Inertia::flash('toast', ['type' => 'success', 'message' => __('Team restored.')]);
return to_route('superadmin.billing.teams.show', $team);
}
}
- Step 5: Add the routes
In routes/web/superadmin.php, extend the billing group added in Task 2 so it reads:
Route::prefix('billing')->name('billing.')->group(function () {
Route::get('teams/{team}', [TeamBillingController::class, 'show'])->name('teams.show');
Route::patch('teams/{team}/plan', [EnterprisePlanController::class, 'update'])->name('teams.plan.update');
Route::patch('teams/{team}/modules', [TeamModuleController::class, 'update'])->name('teams.modules.update');
Route::post('teams/{team}/grace', [TeamAccessController::class, 'extendGrace'])->name('teams.grace.store');
Route::post('teams/{team}/suspend', [TeamAccessController::class, 'suspend'])->name('teams.suspend.store');
Route::delete('teams/{team}/suspend', [TeamAccessController::class, 'restore'])->name('teams.suspend.destroy');
});
with the three new controller imports added at the top of the file.
- Step 6: Regenerate Wayfinder
herd php artisan wayfinder:generate --with-form
- Step 7: Add the forms to the page
In resources/js/pages/superadmin/billing/teams/Show.vue, add the imports:
import { Form, router } from '@inertiajs/vue3';
import InputError from '@/components/InputError.vue';
import {
update as planUpdate,
modulesUpdate,
graceStore,
suspendStore,
suspendDestroy,
} from '@/routes/superadmin/billing/teams';
(Adjust the import names to whatever wayfinder:generate actually emitted for these route names — run ls resources/js/routes/superadmin/billing/teams and read the generated file before writing this import.)
Add these handlers to the <script setup> block:
function confirmSuspend() {
const reason = prompt('Why is this team being suspended?');
if (reason !== null) {
router.post(suspendStore(props.team.slug).url, { reason }, { preserveScroll: true });
}
}
function confirmRestore() {
if (confirm(`Restore ${props.team.name}? Members will regain access immediately.`)) {
router.delete(suspendDestroy(props.team.slug).url, { preserveScroll: true });
}
}
and change defineProps to const props = defineProps<{...}>() so the handlers can read props.team.
Add a suspend/restore button to the Plan card header:
<div class="card-header">
<h3 class="card-title">Plan</h3>
<div class="card-toolbar">
<button v-if="plan.access_state !== 'suspended'" type="button" class="btn btn-sm btn-light-danger" @click="confirmSuspend">Suspend</button>
<button v-else type="button" class="btn btn-sm btn-light-success" @click="confirmRestore">Restore</button>
</div>
</div>
Add an "Edit terms" card below the Plan card:
<div class="card mb-6">
<div class="card-header">
<h3 class="card-title">Enterprise terms</h3>
</div>
<div class="card-body">
<Form v-bind="planUpdate.form(team.slug)" v-slot="{ errors, processing }">
<div class="row g-6">
<div class="col-md-3">
<label for="amount" class="form-label required">Amount</label>
<input id="amount" name="amount" class="form-control" :value="plan.amount_input" :class="{ 'is-invalid': errors.amount }" required />
<InputError :message="errors.amount" />
</div>
<div class="col-md-3">
<label for="interval" class="form-label required">Interval</label>
<select id="interval" name="interval" class="form-select" :class="{ 'is-invalid': errors.interval }">
<option value="monthly" :selected="plan.interval === 'monthly'">Monthly</option>
<option value="yearly" :selected="plan.interval === 'yearly'">Yearly</option>
</select>
<InputError :message="errors.interval" />
</div>
<div class="col-md-3">
<label for="renewal_at" class="form-label">Renews on</label>
<input id="renewal_at" name="renewal_at" type="date" class="form-control" :value="plan.renewal_at ?? ''" />
<InputError :message="errors.renewal_at" />
</div>
<div class="col-md-3">
<label for="seat_cap" class="form-label">Seat cap</label>
<input id="seat_cap" name="seat_cap" type="number" min="1" class="form-control" :value="plan.seat_cap ?? ''" placeholder="Unlimited" />
<InputError :message="errors.seat_cap" />
</div>
<div class="col-md-3">
<label for="storage_quota_gb" class="form-label">Storage (GB)</label>
<input id="storage_quota_gb" name="storage_quota_gb" type="number" min="0" class="form-control" :value="plan.storage_quota_gb ?? ''" placeholder="Per active user" />
<InputError :message="errors.storage_quota_gb" />
</div>
<div class="col-md-4">
<label for="billing_email" class="form-label">Billing email</label>
<input id="billing_email" name="billing_email" type="email" class="form-control" :value="plan.billing_email ?? ''" />
<InputError :message="errors.billing_email" />
</div>
<div class="col-md-5">
<label for="notes" class="form-label">Notes</label>
<input id="notes" name="notes" class="form-control" :value="plan.notes ?? ''" />
<InputError :message="errors.notes" />
</div>
</div>
<button type="submit" class="btn btn-primary mt-6" :disabled="processing">Save terms</button>
</Form>
</div>
</div>
Note that both the amount and the storage figure bind to values the controller already
prepared (plan.amount_input, plan.storage_quota_gb). The template performs no
arithmetic on money or bytes at all — if you find yourself dividing by 100 or by
1024 ** 3 in a .vue file, add the derived value to the controller payload instead.
Replace the read-only Modules card body with a form:
<div class="card-body">
<Form v-bind="modulesUpdate.form(team.slug)" v-slot="{ processing }">
<input type="hidden" name="modules_submitted" value="1" />
<div class="row g-3">
<div v-for="module in modules" :key="module.key" class="col-md-3">
<div class="form-check">
<input :id="`module-${module.key}`" type="checkbox" name="modules[]" :value="module.key" class="form-check-input" :checked="module.allowed" />
<label :for="`module-${module.key}`" class="form-check-label">{{ module.name }}</label>
</div>
</div>
<p v-if="modules.length === 0" class="text-muted mb-0">No modules configured.</p>
</div>
<button type="submit" class="btn btn-primary mt-6" :disabled="processing">Save modules</button>
</Form>
</div>
Add a grace form inside the Plan card body, after the <p v-if="plan.notes">:
<Form v-bind="graceStore.form(team.slug)" v-slot="{ errors, processing }" class="d-flex align-items-end gap-3 mt-6">
<div>
<label for="grace_ends_at" class="form-label">Extend grace until</label>
<input id="grace_ends_at" name="grace_ends_at" type="date" class="form-control" :class="{ 'is-invalid': errors.grace_ends_at }" required />
<InputError :message="errors.grace_ends_at" />
</div>
<button type="submit" class="btn btn-light-primary" :disabled="processing">Extend</button>
</Form>
- Step 8: Rebuild and run the test
npm run build
herd php -d memory_limit=2G artisan test --compact tests/Feature/SuperAdmin/Billing/TeamPlanEndpointsTest.php
Expected: PASS, 13 tests.
- Step 9: Confirm the read-side test still passes
Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/SuperAdmin tests/Feature/Billing
Expected: PASS.
- Step 10: Format and commit
vendor/bin/pint --dirty --format agent
npm run format
npm run lint
git add app/Http routes/web/superadmin.php resources/js tests/Feature/SuperAdmin/Billing
git commit -m "Billing: edit a team's terms, modules, grace and suspension"
Task 4: Invoice and payment endpoints
The five remaining endpoints and the invoice create page — the part an operator actually uses every month.
Files:
- Create:
app/Http/Requests/SuperAdmin/Billing/StoreInvoiceRequest.php - Create:
app/Http/Requests/SuperAdmin/Billing/VoidInvoiceRequest.php - Create:
app/Http/Requests/SuperAdmin/Billing/RecordPaymentRequest.php - Create:
app/Http/Controllers/SuperAdmin/Billing/InvoiceController.php - Create:
app/Http/Controllers/SuperAdmin/Billing/PaymentController.php - Create:
resources/js/pages/superadmin/billing/teams/invoices/Create.vue - Modify:
routes/web/superadmin.php - Modify:
resources/js/pages/superadmin/billing/teams/Show.vue - Modify:
resources/js/types/billing.ts - Test:
tests/Feature/SuperAdmin/Billing/InvoiceEndpointsTest.php
Interfaces:
-
Consumes:
Money::toCents()andVoidInvoice::handle(Invoice, string, User): Invoice(Task 1);IssueInvoice::handle(Invoice, User): InvoiceandRecordPayment::handle(Invoice, BillingPaymentMethod, int, CarbonInterface, User, ?string, ?string): Payment(2a); routesuperadmin.billing.teams.show(Task 2). -
Produces: routes
superadmin.billing.teams.invoices.create,.invoices.store,superadmin.billing.invoices.issue,.invoices.destroy,.invoices.payments.store; TS typeBillingInvoiceDraftLine. -
Step 1: Write the failing test
Create tests/Feature/SuperAdmin/Billing/InvoiceEndpointsTest.php:
<?php
namespace Tests\Feature\SuperAdmin\Billing;
use App\Enums\BillingCycle;
use App\Enums\BillingInvoiceStatus;
use App\Enums\BillingType;
use App\Models\Billing\BillingEvent;
use App\Models\Billing\Invoice;
use App\Models\Billing\Payment;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
/**
* The invoice and payment endpoints (design doc, "2b in detail" →
* "Creating an invoice").
*/
class InvoiceEndpointsTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
private function superAdmin(): User
{
return User::factory()->create(['superAdmin' => true]);
}
private function enterpriseTeam(): Team
{
$team = Team::factory()->create(['name' => 'Acme Logistics']);
$team->forceFill([
'billing_type' => BillingType::Enterprise,
'billing_enterprise_amount_cents' => 450000,
'billing_enterprise_interval' => BillingCycle::Monthly,
'billing_renewal_at' => '2026-11-01',
])->save();
return $team->refresh();
}
/**
* @param array<int, array<string, string>> $lines
*/
private function storeInvoice(Team $team, User $admin, array $lines, string $start = '2026-11-01', string $end = '2026-11-30')
{
return $this->actingAs($admin)->post(route('superadmin.billing.teams.invoices.store', $team), [
'period_start' => $start,
'period_end' => $end,
'lines' => $lines,
]);
}
public function test_a_non_superadmin_is_forbidden(): void
{
$team = $this->enterpriseTeam();
$this->actingAs(User::factory()->create(['superAdmin' => false]))
->get(route('superadmin.billing.teams.invoices.create', $team))
->assertForbidden();
}
public function test_the_create_form_is_prefilled_from_the_plan(): void
{
$team = $this->enterpriseTeam();
$this->actingAs($this->superAdmin())
->get(route('superadmin.billing.teams.invoices.create', $team))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('superadmin/billing/teams/invoices/Create')
->where('draft.period_start', '2026-11-01')
->where('draft.period_end', '2026-11-30')
->has('draft.lines', 1)
->where('draft.lines.0.kind', 'module')
->where('draft.lines.0.amount', '4500.00')
->where('draft.lines.0.quantity', '1')
);
}
public function test_a_yearly_plan_prefills_a_year_long_period(): void
{
$team = $this->enterpriseTeam();
$team->forceFill(['billing_enterprise_interval' => BillingCycle::Yearly])->save();
$this->actingAs($this->superAdmin())
->get(route('superadmin.billing.teams.invoices.create', $team))
->assertOk()
->assertInertia(fn (Assert $page) => $page->where('draft.period_end', '2027-10-31'));
}
public function test_a_team_with_no_plan_gets_an_empty_draft(): void
{
$team = Team::factory()->create();
$this->actingAs($this->superAdmin())
->get(route('superadmin.billing.teams.invoices.create', $team))
->assertOk()
->assertInertia(fn (Assert $page) => $page->has('draft.lines', 0));
}
public function test_it_stores_a_draft_and_totals_it_from_the_lines(): void
{
$team = $this->enterpriseTeam();
$this->storeInvoice($team, $this->superAdmin(), [
['kind' => 'module', 'description' => '3PL — November', 'quantity' => '1', 'amount' => '4500.00'],
['kind' => 'usage', 'description' => 'SMS — October', 'quantity' => '1500', 'amount' => '750.00'],
])->assertRedirect(route('superadmin.billing.teams.show', $team));
$invoice = Invoice::where('team_id', $team->id)->firstOrFail();
$this->assertSame(BillingInvoiceStatus::Draft, $invoice->status);
$this->assertNull($invoice->number);
$this->assertSame(525000, $invoice->total_cents);
$this->assertSame(525000, $invoice->subtotal_cents);
$this->assertCount(2, $invoice->lines);
$this->assertSame(450000, $invoice->lines->first()->amount_cents);
}
public function test_a_total_supplied_by_the_client_is_ignored(): void
{
$team = $this->enterpriseTeam();
$this->actingAs($this->superAdmin())->post(route('superadmin.billing.teams.invoices.store', $team), [
'period_start' => '2026-11-01',
'period_end' => '2026-11-30',
'total_cents' => 1,
'lines' => [['kind' => 'module', 'description' => 'Plan', 'quantity' => '1', 'amount' => '4500.00']],
])->assertRedirect();
$this->assertSame(450000, Invoice::where('team_id', $team->id)->firstOrFail()->total_cents);
}
public function test_a_credit_note_nets_negative(): void
{
$team = $this->enterpriseTeam();
$this->storeInvoice($team, $this->superAdmin(), [
['kind' => 'adjustment', 'description' => 'Goodwill credit', 'quantity' => '1', 'amount' => '-250.00'],
])->assertRedirect();
$this->assertSame(-25000, Invoice::where('team_id', $team->id)->firstOrFail()->total_cents);
}
public function test_an_invoice_with_no_lines_is_rejected(): void
{
$team = $this->enterpriseTeam();
$this->storeInvoice($team, $this->superAdmin(), [])->assertSessionHasErrors('lines');
}
public function test_a_period_ending_before_it_starts_is_rejected(): void
{
$team = $this->enterpriseTeam();
$this->storeInvoice($team, $this->superAdmin(), [
['kind' => 'module', 'description' => 'Plan', 'quantity' => '1', 'amount' => '4500.00'],
], '2026-11-30', '2026-11-01')->assertSessionHasErrors('period_end');
}
public function test_an_unknown_line_kind_is_rejected(): void
{
$team = $this->enterpriseTeam();
$this->storeInvoice($team, $this->superAdmin(), [
['kind' => 'nonsense', 'description' => 'Plan', 'quantity' => '1', 'amount' => '4500.00'],
])->assertSessionHasErrors('lines.0.kind');
}
public function test_it_issues_a_draft(): void
{
Carbon::setTestNow('2026-10-15 10:00:00');
$team = $this->enterpriseTeam();
$admin = $this->superAdmin();
$this->storeInvoice($team, $admin, [['kind' => 'module', 'description' => 'Plan', 'quantity' => '1', 'amount' => '4500.00']]);
$invoice = Invoice::where('team_id', $team->id)->firstOrFail();
$this->actingAs($admin)
->post(route('superadmin.billing.invoices.issue', $invoice))
->assertRedirect(route('superadmin.billing.teams.show', $team));
$invoice->refresh();
$this->assertSame(BillingInvoiceStatus::Open, $invoice->status);
$this->assertSame('INV-2026-0001', $invoice->number);
}
public function test_it_voids_an_invoice_with_a_reason(): void
{
$team = $this->enterpriseTeam();
$admin = $this->superAdmin();
$this->storeInvoice($team, $admin, [['kind' => 'module', 'description' => 'Plan', 'quantity' => '1', 'amount' => '4500.00']]);
$invoice = Invoice::where('team_id', $team->id)->firstOrFail();
$this->actingAs($admin)
->delete(route('superadmin.billing.invoices.destroy', $invoice), ['reason' => 'Raised against the wrong team.'])
->assertRedirect(route('superadmin.billing.teams.show', $team));
$this->assertSame(BillingInvoiceStatus::Void, $invoice->fresh()->status);
$this->assertSame('Raised against the wrong team.', BillingEvent::where('type', 'invoice.voided')->firstOrFail()->payload['reason']);
}
public function test_voiding_without_a_reason_is_rejected(): void
{
$team = $this->enterpriseTeam();
$admin = $this->superAdmin();
$this->storeInvoice($team, $admin, [['kind' => 'module', 'description' => 'Plan', 'quantity' => '1', 'amount' => '4500.00']]);
$invoice = Invoice::where('team_id', $team->id)->firstOrFail();
$this->actingAs($admin)
->delete(route('superadmin.billing.invoices.destroy', $invoice), ['reason' => ''])
->assertSessionHasErrors('reason');
}
public function test_it_records_a_payment_and_closes_the_invoice(): void
{
Carbon::setTestNow('2026-10-15 10:00:00');
$team = $this->enterpriseTeam();
$admin = $this->superAdmin();
$this->storeInvoice($team, $admin, [['kind' => 'module', 'description' => 'Plan', 'quantity' => '1', 'amount' => '4500.00']]);
$invoice = Invoice::where('team_id', $team->id)->firstOrFail();
$this->actingAs($admin)->post(route('superadmin.billing.invoices.issue', $invoice));
$this->actingAs($admin)
->post(route('superadmin.billing.invoices.payments.store', $invoice->fresh()), [
'method' => 'bank_transfer',
'amount' => '4500.00',
'paid_at' => '2026-10-20',
'reference' => 'SWIFT 8842190',
])
->assertRedirect(route('superadmin.billing.teams.show', $team));
$invoice->refresh();
$this->assertSame(BillingInvoiceStatus::Paid, $invoice->status);
$this->assertSame(450000, Payment::where('billing_invoice_id', $invoice->id)->firstOrFail()->amount_cents);
}
public function test_a_payment_against_a_draft_surfaces_the_actions_guard(): void
{
$team = $this->enterpriseTeam();
$admin = $this->superAdmin();
$this->storeInvoice($team, $admin, [['kind' => 'module', 'description' => 'Plan', 'quantity' => '1', 'amount' => '4500.00']]);
$invoice = Invoice::where('team_id', $team->id)->firstOrFail();
$this->actingAs($admin)
->post(route('superadmin.billing.invoices.payments.store', $invoice), [
'method' => 'cash',
'amount' => '4500.00',
'paid_at' => '2026-10-20',
])
->assertStatus(422);
}
public function test_an_unknown_payment_method_is_rejected(): void
{
$team = $this->enterpriseTeam();
$admin = $this->superAdmin();
$this->storeInvoice($team, $admin, [['kind' => 'module', 'description' => 'Plan', 'quantity' => '1', 'amount' => '4500.00']]);
$invoice = Invoice::where('team_id', $team->id)->firstOrFail();
$this->actingAs($admin)
->post(route('superadmin.billing.invoices.payments.store', $invoice), [
'method' => 'bitcoin',
'amount' => '4500.00',
'paid_at' => '2026-10-20',
])
->assertSessionHasErrors('method');
}
}
- Step 2: Run the test to verify it fails
Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/SuperAdmin/Billing/InvoiceEndpointsTest.php
Expected: FAIL — Route [superadmin.billing.teams.invoices.create] not defined.
- Step 3: Write the FormRequests
Create app/Http/Requests/SuperAdmin/Billing/StoreInvoiceRequest.php:
<?php
namespace App\Http\Requests\SuperAdmin\Billing;
use App\Enums\BillingInvoiceLineKind;
use App\Support\Billing\Money;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* Line amounts allow a leading `-` because an `Adjustment` line is how a
* credit note is expressed — the invoice's own total is signed for exactly
* that reason.
*
* `total_cents` is deliberately absent from the rules: the total is computed
* from the lines server-side and a client-supplied one is ignored.
*/
class StoreInvoiceRequest extends FormRequest
{
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'period_start' => ['required', 'date'],
'period_end' => ['required', 'date', 'after_or_equal:period_start'],
'lines' => ['required', 'array', 'min:1'],
'lines.*.kind' => ['required', Rule::enum(BillingInvoiceLineKind::class)],
'lines.*.description' => ['required', 'string', 'max:255'],
'lines.*.quantity' => ['required', 'numeric'],
'lines.*.amount' => ['required', 'string', 'regex:/^-?\d{1,12}(\.\d{1,2})?$/'],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'lines.*.amount.regex' => 'Enter each amount in dollars, with at most two decimal places and no separators — for example 4500.00, or -250.00 for a credit.',
];
}
/**
* The lines with their amounts converted to integer cents.
*
* @return list<array{kind: string, description: string, quantity: string, amount_cents: int}>
*/
public function lines(): array
{
return array_map(fn (array $line) => [
'kind' => $line['kind'],
'description' => $line['description'],
'quantity' => (string) $line['quantity'],
'amount_cents' => Money::toCents((string) $line['amount']),
], array_values($this->input('lines', [])));
}
}
Create app/Http/Requests/SuperAdmin/Billing/VoidInvoiceRequest.php:
<?php
namespace App\Http\Requests\SuperAdmin\Billing;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class VoidInvoiceRequest extends FormRequest
{
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'reason' => ['required', 'string', 'max:1000'],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'reason.required' => 'Say why this invoice is being voided — it is the only record of the decision.',
];
}
}
Create app/Http/Requests/SuperAdmin/Billing/RecordPaymentRequest.php:
<?php
namespace App\Http\Requests\SuperAdmin\Billing;
use App\Enums\BillingPaymentMethod;
use App\Support\Billing\Money;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class RecordPaymentRequest extends FormRequest
{
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'method' => ['required', Rule::enum(BillingPaymentMethod::class)],
'amount' => ['required', 'string', 'regex:/^\d{1,12}(\.\d{1,2})?$/'],
'paid_at' => ['required', 'date'],
'reference' => ['nullable', 'string', 'max:255'],
'notes' => ['nullable', 'string', 'max:2000'],
];
}
public function amountCents(): int
{
return Money::toCents($this->string('amount')->value());
}
}
- Step 4: Write the two controllers
Create app/Http/Controllers/SuperAdmin/Billing/InvoiceController.php:
<?php
namespace App\Http\Controllers\SuperAdmin\Billing;
use App\Actions\Billing\IssueInvoice;
use App\Actions\Billing\VoidInvoice;
use App\Enums\BillingInvoiceLineKind;
use App\Enums\BillingInvoiceSource;
use App\Enums\BillingInvoiceStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\SuperAdmin\Billing\StoreInvoiceRequest;
use App\Http\Requests\SuperAdmin\Billing\VoidInvoiceRequest;
use App\Models\Billing\Invoice;
use App\Models\Billing\InvoiceLine;
use App\Models\Team;
use App\Support\Billing\Money;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
/**
* Manual invoices (design doc, "2b in detail" → "Creating an invoice").
*
* `create()` prefills from the team's enterprise terms so the routine
* monthly invoice is one click. 2d's `billing:draft-enterprise-invoices`
* command produces the same shape — `draftLinesFor()` and `periodFor()` are
* written here so that command can reuse them rather than re-deriving the
* rules.
*/
class InvoiceController extends Controller
{
public function create(Team $team): Response
{
[$periodStart, $periodEnd] = self::periodFor($team);
return Inertia::render('superadmin/billing/teams/invoices/Create', [
'team' => [
'id' => $team->id,
'name' => $team->name,
'slug' => $team->slug,
],
'draft' => [
'period_start' => $periodStart->toDateString(),
'period_end' => $periodEnd->toDateString(),
'lines' => self::draftLinesFor($team),
],
'kinds' => array_map(
fn (BillingInvoiceLineKind $kind) => ['value' => $kind->value, 'label' => ucfirst($kind->value)],
BillingInvoiceLineKind::cases(),
),
]);
}
public function store(StoreInvoiceRequest $request, Team $team): RedirectResponse
{
$lines = $request->lines();
$total = array_sum(array_column($lines, 'amount_cents'));
DB::transaction(function () use ($request, $team, $lines, $total) {
$invoice = Invoice::create([
'team_id' => $team->id,
'number' => null,
'source' => BillingInvoiceSource::Manual,
'period_start' => $request->date('period_start'),
'period_end' => $request->date('period_end'),
'subtotal_cents' => $total,
'total_cents' => $total,
'currency' => 'usd',
'status' => BillingInvoiceStatus::Draft,
]);
foreach ($lines as $line) {
InvoiceLine::create([
'billing_invoice_id' => $invoice->id,
'kind' => BillingInvoiceLineKind::from($line['kind']),
'description' => $line['description'],
'quantity' => $line['quantity'],
'unit_amount_cents' => $line['amount_cents'],
'amount_cents' => $line['amount_cents'],
]);
}
});
Inertia::flash('toast', ['type' => 'success', 'message' => __('Draft invoice created.')]);
return to_route('superadmin.billing.teams.show', $team);
}
public function issue(Invoice $invoice, IssueInvoice $issueInvoice): RedirectResponse
{
$issueInvoice->handle($invoice, request()->user());
Inertia::flash('toast', ['type' => 'success', 'message' => __('Invoice issued.')]);
return to_route('superadmin.billing.teams.show', $invoice->team);
}
public function void(VoidInvoiceRequest $request, Invoice $invoice, VoidInvoice $voidInvoice): RedirectResponse
{
$voidInvoice->handle($invoice, $request->string('reason')->value(), $request->user());
Inertia::flash('toast', ['type' => 'success', 'message' => __('Invoice voided.')]);
return to_route('superadmin.billing.teams.show', $invoice->team);
}
/**
* The period a new invoice covers: the team's renewal date through one
* interval later, or the current calendar month for a team with no plan.
*
* Public because `billing:draft-enterprise-invoices` reuses it — changing
* its shape changes what that command generates.
*
* @return array{0: Carbon, 1: Carbon}
*/
public static function periodFor(Team $team): array
{
$start = $team->billing_renewal_at !== null
? $team->billing_renewal_at->copy()
: Carbon::now()->startOfMonth();
// `addMonth()`/`addYear()` overflow rather than clamp — a renewal on
// the 31st would otherwise produce a "monthly" period ending in the
// month after next (2026-01-31 -> 2026-03-02). The no-overflow
// variants clamp to the last valid day, which is the correct reading
// of "one interval later, minus a day".
$end = $team->billing_enterprise_interval === BillingCycle::Yearly
? $start->copy()->addYearNoOverflow()->subDay()
: $start->copy()->addMonthNoOverflow()->subDay();
return [$start, $end];
}
/**
* The prefilled lines: one for the fixed enterprise amount, or none for a
* team that has no plan yet.
*
* Public because `billing:draft-enterprise-invoices` reuses it — changing
* its shape changes what that command generates.
*
* @return list<array{kind: string, description: string, quantity: string, amount: string}>
*/
public static function draftLinesFor(Team $team): array
{
if ($team->billing_enterprise_amount_cents === null) {
return [];
}
[$periodStart] = self::periodFor($team);
return [[
'kind' => BillingInvoiceLineKind::Module->value,
'description' => sprintf('%s plan — %s', $team->name, $periodStart->format('F Y')),
'quantity' => '1',
'amount' => number_format($team->billing_enterprise_amount_cents / 100, 2, '.', ''),
]];
}
}
Note number_format(..., '.', '') with an empty thousands separator — the value seeds an input that Money::toCents() re-parses, and a comma would fail its regex.
Create app/Http/Controllers/SuperAdmin/Billing/PaymentController.php:
<?php
namespace App\Http\Controllers\SuperAdmin\Billing;
use App\Actions\Billing\RecordPayment;
use App\Enums\BillingPaymentMethod;
use App\Http\Controllers\Controller;
use App\Http\Requests\SuperAdmin\Billing\RecordPaymentRequest;
use App\Models\Billing\Invoice;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
class PaymentController extends Controller
{
public function store(RecordPaymentRequest $request, Invoice $invoice, RecordPayment $recordPayment): RedirectResponse
{
$recordPayment->handle(
$invoice,
BillingPaymentMethod::from($request->string('method')->value()),
$request->amountCents(),
$request->date('paid_at'),
$request->user(),
$request->input('reference'),
$request->input('notes'),
);
Inertia::flash('toast', ['type' => 'success', 'message' => __('Payment recorded.')]);
return to_route('superadmin.billing.teams.show', $invoice->team);
}
}
- Step 5: Add the routes
Extend the billing group in routes/web/superadmin.php with:
Route::get('teams/{team}/invoices/create', [InvoiceController::class, 'create'])->name('teams.invoices.create');
Route::post('teams/{team}/invoices', [InvoiceController::class, 'store'])->name('teams.invoices.store');
Route::post('invoices/{invoice}/issue', [InvoiceController::class, 'issue'])->name('invoices.issue');
Route::delete('invoices/{invoice}', [InvoiceController::class, 'void'])->name('invoices.destroy');
Route::post('invoices/{invoice}/payments', [PaymentController::class, 'store'])->name('invoices.payments.store');
plus the two controller imports.
- Step 6: Add the draft-line type
Append to resources/js/types/billing.ts:
export type BillingInvoiceDraftLine = {
kind: string;
description: string;
quantity: string;
amount: string;
};
export type BillingLineKindOption = {
value: string;
label: string;
};
- Step 7: Write the create page
Create resources/js/pages/superadmin/billing/teams/invoices/Create.vue:
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import { ref } from 'vue';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import { index as superadminIndex } from '@/routes/superadmin';
import { index as teamsIndex } from '@/routes/superadmin/teams';
import { show as billingShow, invoicesStore } from '@/routes/superadmin/billing/teams';
import type { BillingInvoiceDraftLine, BillingLineKindOption, BillingTeam } from '@/types/billing';
const props = defineProps<{
team: BillingTeam;
draft: { period_start: string; period_end: string; lines: BillingInvoiceDraftLine[] };
kinds: BillingLineKindOption[];
}>();
defineOptions({
layout: (props: { team: BillingTeam }) => ({
breadcrumbs: [
{ title: 'Superadmin', href: superadminIndex() },
{ title: 'Teams', href: teamsIndex() },
{ title: props.team.name, href: billingShow(props.team.slug) },
{ title: 'New invoice', href: '' },
],
}),
});
const lines = ref<BillingInvoiceDraftLine[]>(
props.draft.lines.length > 0 ? [...props.draft.lines] : [{ kind: 'module', description: '', quantity: '1', amount: '0.00' }],
);
function addLine() {
lines.value.push({ kind: 'module', description: '', quantity: '1', amount: '0.00' });
}
function removeLine(index: number) {
lines.value.splice(index, 1);
}
</script>
<template>
<Head :title="`New invoice — ${team.name}`" />
<Heading variant="small" title="New invoice" :description="`A draft invoice for ${team.name}. It gets its number when you issue it.`" class="mb-6" />
<div class="card mb-6">
<div class="card-body">
<Form v-bind="invoicesStore.form(team.slug)" v-slot="{ errors, processing }">
<div class="row g-6 mb-6">
<div class="col-md-3">
<label for="period_start" class="form-label required">Period start</label>
<input id="period_start" name="period_start" type="date" class="form-control" :class="{ 'is-invalid': errors.period_start }" :value="draft.period_start" required />
<InputError :message="errors.period_start" />
</div>
<div class="col-md-3">
<label for="period_end" class="form-label required">Period end</label>
<input id="period_end" name="period_end" type="date" class="form-control" :class="{ 'is-invalid': errors.period_end }" :value="draft.period_end" required />
<InputError :message="errors.period_end" />
</div>
</div>
<table class="table table-row-bordered gy-3 gs-3">
<thead>
<tr class="fw-semibold fs-6 text-gray-800">
<th style="width: 15%">Kind</th>
<th>Description</th>
<th style="width: 12%">Quantity</th>
<th style="width: 18%">Amount</th>
<th style="width: 5%"></th>
</tr>
</thead>
<tbody>
<tr v-for="(line, index) in lines" :key="index">
<td>
<select :name="`lines[${index}][kind]`" class="form-select form-select-sm">
<option v-for="kind in kinds" :key="kind.value" :value="kind.value" :selected="line.kind === kind.value">{{ kind.label }}</option>
</select>
<InputError :message="errors[`lines.${index}.kind`]" />
</td>
<td>
<input :name="`lines[${index}][description]`" class="form-control form-control-sm" :value="line.description" required />
<InputError :message="errors[`lines.${index}.description`]" />
</td>
<td>
<input :name="`lines[${index}][quantity]`" class="form-control form-control-sm" :value="line.quantity" required />
<InputError :message="errors[`lines.${index}.quantity`]" />
</td>
<td>
<input :name="`lines[${index}][amount]`" class="form-control form-control-sm" :value="line.amount" placeholder="4500.00" required />
<InputError :message="errors[`lines.${index}.amount`]" />
</td>
<td>
<button v-if="lines.length > 1" type="button" class="btn btn-sm btn-icon btn-light-danger" @click="removeLine(index)">×</button>
</td>
</tr>
</tbody>
</table>
<InputError :message="errors.lines" />
<button type="button" class="btn btn-sm btn-light mt-3" @click="addLine">Add line</button>
<p class="text-muted mt-6 mb-0">
Enter amounts in dollars, without separators — <code>4500.00</code>. A negative amount on an
<em>Adjustment</em> line makes this a credit note.
</p>
<button type="submit" class="btn btn-primary mt-6" :disabled="processing">Save draft</button>
</Form>
</div>
</div>
</template>
- Step 8: Add the row actions to the screen
In resources/js/pages/superadmin/billing/teams/Show.vue, add to the imports:
import { invoicesCreate, issue as invoiceIssue, destroy as invoiceDestroy, paymentsStore } from '@/routes/superadmin/billing/teams';
(again, read the generated files under resources/js/routes/superadmin/billing/ and use the names Wayfinder actually emitted)
Add these handlers:
function confirmVoid(invoice: BillingInvoiceRow) {
const reason = prompt(`Why is ${invoice.number ?? 'this draft'} being voided?`);
if (reason !== null && reason.trim() !== '') {
router.delete(invoiceDestroy(invoice.id).url, { data: { reason }, preserveScroll: true });
}
}
function issueInvoice(invoice: BillingInvoiceRow) {
if (confirm(`Issue this invoice? It will be numbered and can no longer be edited.`)) {
router.post(invoiceIssue(invoice.id).url, {}, { preserveScroll: true });
}
}
Add a New invoice button to the Invoices card header:
<div class="card-toolbar">
<Link :href="invoicesCreate(team.slug)" class="btn btn-sm btn-primary">New invoice</Link>
</div>
Add an actions column to the invoices table — a header cell <th></th> and this body cell:
<td class="text-end">
<button v-if="invoice.status === 'draft'" type="button" class="btn btn-sm btn-light-primary" @click="issueInvoice(invoice)">Issue</button>
<button v-if="invoice.status === 'draft' || invoice.status === 'open'" type="button" class="btn btn-sm btn-light-danger ms-2" @click="confirmVoid(invoice)">Void</button>
</td>
and bump the empty-row colspan from 5 to 6.
Add a payment form below the invoices table, shown only when an open invoice exists:
<Form v-for="invoice in invoices.data.filter((i) => i.status === 'open')" :key="`pay-${invoice.id}`" v-bind="paymentsStore.form(invoice.id)" v-slot="{ errors, processing }" class="d-flex align-items-end gap-3 mt-6">
<div>
<label class="form-label">Record payment for {{ invoice.number }}</label>
<input name="amount" class="form-control" placeholder="4500.00" required />
<InputError :message="errors.amount" />
</div>
<div>
<label class="form-label">Method</label>
<select name="method" class="form-select">
<option value="bank_transfer">Bank transfer</option>
<option value="cheque">Cheque</option>
<option value="cash">Cash</option>
<option value="other">Other</option>
</select>
</div>
<div>
<label class="form-label">Paid on</label>
<input name="paid_at" type="date" class="form-control" required />
<InputError :message="errors.paid_at" />
</div>
<div>
<label class="form-label">Reference</label>
<input name="reference" class="form-control" />
</div>
<button type="submit" class="btn btn-light-primary" :disabled="processing">Record</button>
</Form>
The Form and InputError imports were added in Task 3.
- Step 9: Regenerate Wayfinder, rebuild, and run the test
herd php artisan wayfinder:generate --with-form
npm run build
herd php -d memory_limit=2G artisan test --compact tests/Feature/SuperAdmin/Billing/InvoiceEndpointsTest.php
Expected: PASS, 15 tests.
- Step 10: 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 11: 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. If a new ViteException appears, npm run build was not re-run after the last Vue change.
- Step 12: Static analysis
Run: herd php -d memory_limit=1G vendor/bin/phpstan analyse
Expected: no error naming a file under app/Http/Controllers/SuperAdmin/Billing/, app/Http/Requests/SuperAdmin/Billing/, app/Actions/Billing/ or app/Support/Billing/. There are ~127 pre-existing errors elsewhere; leave them.
- Step 13: Format and commit
vendor/bin/pint --dirty --format agent
npm run format
npm run lint
git add app/Http routes/web/superadmin.php resources/js tests/Feature/SuperAdmin/Billing
git commit -m "Billing: create, issue, void and pay a team's invoices"
Done when
herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing tests/Unit/Billing tests/Feature/SuperAdminpasses.herd php -d memory_limit=2G vendor/bin/phpunitshows 24 failures, all pre-existing, none in Billing or SuperAdmin.vendor/bin/pint --testis clean on every changed PHP file;npm run lintandnpm run format:checkare clean.- A superadmin can open a team, see its plan, invoices, payments, usage and activity, and from that one screen edit terms, grant modules, extend grace, suspend or restore, and create, issue, void or pay an invoice.
Deliberately not in this sub-increment
- The Overview and Teams-list screens (2c).
- The Rates screen and
billing:draft-enterprise-invoices(2d) — thoughInvoiceController::periodFor()anddraftLinesFor()are written here for that command to reuse. - Editing a draft invoice after creation. A wrong draft is voided and recreated, which is why
VoidInvoiceaccepts drafts. - Deleting a payment. Recording one is append-only in this increment; a mistaken payment needs a reversal concept that does not exist yet.
- Any Stripe call, webhook or charge (increments 4 and 5).
- Route-level module enforcement — revoking a module hides the sidebar entry but leaves routes reachable by URL until increment 3.