OCTO Ops Help User guides and product documentation

2026 09 14 Billing 6 Usage Metering

On this page 5

Increment 6 — usage metering: 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: Make the metering ledger real — one way to record a metered event, one way for a closed month's usage to land on the Stripe renewal invoice, a sweeper for what that invoice cannot carry, and the two places a customer sees it — with no producer wired.

Architecture: Three sub-increments, executed and merged one at a time, each its own branch off dev: 6a is the ledger and the invoice.created close (the data everything else reads); 6b is the daily sweeper and the CreateInvoice arithmetic fix; 6c is the customer view. Every Stripe call goes through PaymentGateway, after any local transaction has ended; nothing here writes billing_access_state; RecordStripeInvoice stays the one writer of Stripe-sourced billing_invoices rows; CreateInvoice stays the one writer of local drafts.

Tech Stack: Laravel 13, Cashier v16 (Laravel\Cashier\Cashier::stripe() for the raw client), Inertia v3 + Vue 3, MySQL in production / SQLite in-memory in tests (phpunit.xml), database queue.

Spec: docs/superpowers/specs/2026-09-09-saas-monetization-design.md — "Increment 6 in detail — usage metering" (~line 1955), plus "Usage metering" (~334), "2d in detail — Rates" (~929, the rate, unit and lineCents() rules), and the "Carried into later increments" items that name increment 6 (~1230, ~1250). The spec is binding; where this plan and the spec disagree, the spec wins.

Global Constraints

  • PHP binary: plain php is MAMP's 8.2 and too old. Use herd php artisan ....
  • Tests are scoped and minimal — the user has a standing instruction against whole-suite runs and against exhaustive coverage. Test the money-and-ledger behaviours the task names, one mutation check per new production rule, nothing else. Run tests/Feature/Billing, tests/Feature/Auth, tests/Feature/Settings, tests/Feature/SuperAdmin, plus tests/Unit when app/Support or app/Enums changes. Command: herd php -d memory_limit=2G artisan test --compact <paths>. Foreground only — an interrupted run corrupts shared MongoDB test state.
  • No background tasks. Nothing will notify you.
  • Never call Stripe inside a database transaction. 4a's worst bug was a Stripe call inside transaction() + lockForUpdate(). Every gateway call in this plan happens after a transaction has ended or in a command that opens none; FakePaymentGateway records DB::transactionLevel() on every call and the tests assert it equals the level the test itself runs at.
  • No billing rule gets a second implementation. Rate::lineCents() is the one line-total rule for usage; Money::lineCents() (new in 6b) is the one for manual lines; Usage::periodFor() is the one YYYY-MM rule; UsageEvent::uninvoicedClosedPeriods() is the one "what is still owed" query; UsageLines::for() is the one grouping; Usage::release() is the one un-invoice; RecordStripeInvoice is the one Stripe-row writer; CreateInvoice the one local-draft writer; Money::format() / Rate::format() own display. Call them.
  • billing_access_state is written by the webhook's applyAccessState() only. Nothing in this plan adds a writer.
  • Rates are millicents; everything else is cents; quantities are integers. No float touches money. No .vue computes money or dates. No new model factories. Billing tables MySQL, never MongoDB.
  • PaymentGateway is the only route to Stripe. Every method on the interface is implemented by both StripePaymentGateway and FakePaymentGateway; tests use $this->swap(PaymentGateway::class, new FakePaymentGateway). The interface docblock forbids methods without callers — amend that clause the way 4c, 4d and 5c did, in the same commit as the caller.
  • No real Stripe key, secret or whsec_ in any committed file.
  • Failure split, as 5b ruled it: a Stripe refusal that means "the invoice is no longer editable" is benign and silent; any other ApiErrorException or Throwable is report()ed; nothing local is written until Stripe has accepted.
  • Formatting: vendor/bin/pint --dirty --format agent, then npm run format / npm run lint for .vue/.ts, reverting collateral outside your files. npm run build before any Inertia render test. PHPStan: the branch is at 124 pre-existing errors (ProjectManagement/Sales) — parity with base is the bar, zero in any Billing path.
  • Pre-existing failure, do not fix: Settings\Modules\Hr\DepartmentTest::test_store_allows_duplicate_code_for_a_different_team.
  • Deploy notes to carry in 6a's commit body: invoice.created must be added by hand to the existing Stripe webhook endpoint's event list; BILLING_TIMEZONE is a new optional env key defaulting to UTC.

Task 1: 6a — the ledger and the close

Spec: "Corrections to the sections above", "The recorder", "Monthly close, on the renewal invoice" (all three numbered steps and the void paragraph). Read them first.

Files:

  • Create: database/migrations/2026_09_14_100000_make_usage_events_integer_and_dropped_amount.php, app/Support/Billing/Usage.php, app/Support/Billing/UsageLines.php, app/Actions/Billing/CloseUsagePeriod.php
  • Modify: config/billing.php (add 'timezone' => env('BILLING_TIMEZONE', 'UTC') with a comment saying it is the period-boundary timezone and must match Stripe's anchor), app/Models/Billing/UsageEvent.php (casts: drop amount_cents, add 'quantity' => 'integer'; add the uninvoicedClosedPeriods() scope), app/Support/Billing/PaymentGateway.php + StripePaymentGateway.php + FakePaymentGateway.php (addInvoiceItems()), app/Support/Billing/InvoicePaymentFailure.php (meansInvoiceNotEditable()), app/Http/Controllers/Billing/StripeWebhookController.php (invoice.created in RECORDED_INVOICE_EVENTS; the close as a new phase inside the claim-releasing try), app/Actions/Billing/RecordStripeInvoice.php (release on void), app/Actions/Billing/VoidInvoice.php (release on void; refuse Stripe rows), app/Actions/Billing/IssueInvoice.php (refuse Stripe rows), app/Queries/Billing/BillingOverview.php (usage tile from lines), app/Http/Controllers/SuperAdmin/Billing/TeamBillingController.php (usage panel from lines, same output shape), database/seeders/BillingUsageRatesSeeder.php no — already correct
  • Test: tests/Unit/Billing/UsageTest.php (new), tests/Unit/Billing/UsageLinesTest.php (new), tests/Feature/Billing/UsageRecordingTest.php (new), tests/Feature/Billing/UsageCloseTest.php (new), tests/Feature/Billing/BillingOverviewTest.php + tests/Feature/SuperAdmin/... team-billing test (re-point the usage fixtures from events to lines), tests/Feature/Billing/StripeInvoiceRecordingTest.php (the invoice.created ignore test flips to "creates a draft row")

Interfaces:

  • Consumes: UsageRate::resolve(string $service, Team $team, ?CarbonInterface $moment = null): ?UsageRate (columns unit, unit_amount_millicents); Rate::lineCents(int $quantity, int $unitAmountMillicents, UsageUnit $unit): int; Rate::format(int $millicents): string; UsageService::unit(): UsageUnit, UsageService::label(): string, UsageUnit::blockSize(): int, UsageUnit::perLabel(): string; RecordStripeInvoice::handle(Team, array $stripeInvoice, ?string $stripeEventId): ?Invoice (already records any status, draft included); BillingEvent::record(?Team $team, string $type, array $payload = [], ...); InvoicePaymentFailure::CODE_INVOICE_NOT_EDITABLE; the webhook's phases (class docblock of StripeWebhookController): phase 1 claims the event by stripe_event_id, phase 2 is Cashier's own handling, phase 3 is DB::transaction(recordInvoice + applyAccessState), phase 4 is mailFirstFailure().

  • Produces:

    • Usage::periodFor(CarbonInterface $moment): stringYYYY-MM of the moment in config('billing.timezone').
    • Usage::record(Team $team, UsageService $service, int $quantity, ?Model $reference = null, array $metadata = [], ?CarbonInterface $occurredAt = null): UsageEvent — throws InvalidArgumentException for $quantity < 1, RuntimeException when no rate resolves. Writes team_id, service, quantity, unit, unit_amount_millicents, occurred_at, billing_period, reference_type, reference_id, metadata.
    • Usage::release(Invoice $invoice): int — clears invoiced_at and billing_invoice_id on every event stamped to that invoice, records usage.released with the count when > 0, returns the count. Must be called inside the caller's transaction.
    • UsageEvent::uninvoicedClosedPeriods(Team $team, string $beforePeriod): Builderteam_id, invoiced_at IS NULL, billing_period < $beforePeriod, ordered by billing_period, service, occurred_at.
    • UsageLines::for(Collection $events): list<array{billing_period: string, service: UsageService, unit: UsageUnit, unit_amount_millicents: int, quantity: int, amount_cents: int, description: string, event_ids: list<string>}> — grouped by (billing_period, service, unit, unit_amount_millicents) in that order, amount_cents from Rate::lineCents() over the summed quantity, description = "{service label} — {Month YYYY}: {quantity:n,nnn} {unit perLabel plural} at {Rate::format()} / {unit perLabel}", e.g. SMS — September 2026: 2,500 messages at $0.045 / message. Pure; no queries.
    • UsageLines::idempotencyKey(int $teamId, array $line, string $stripeInvoiceId): string = "usage:{teamId}:{billing_period}:{service value}:{stripeInvoiceId}".
    • PaymentGateway::addInvoiceItems(Team $team, string $stripeInvoiceId, array $lines): void$lines is list<array{description: string, amount_cents: int, idempotency_key: string}>; throws Stripe's ApiErrorException subclasses raw.
    • InvoicePaymentFailure::meansInvoiceNotEditable(InvalidRequestException $e): bool — true when the error code is CODE_INVOICE_NOT_EDITABLE; meansAlreadySettled() is unchanged.
    • CloseUsagePeriod::onRenewalInvoice(Team $team, array $stripeInvoice, Invoice $localDraft): ?array{lines: int, total_cents: int} — the three-step close; returns null when nothing was owed or Stripe refused as not-editable.
    • FakePaymentGateway: public array $invoiceItemsAdded = [] ({team, stripeInvoiceId, lines, transactionLevel, sequence}), public ?Throwable $addInvoiceItemsThrows = null.
    • Local usage line rows: InvoiceLine with kind = Usage, description, quantity = summed count, unit_amount_cents = 0, amount_cents = line total, meta = {service, billing_period, unit, unit_amount_millicents, events: int}.
  • Step 1: Migration, config and casts. Schema::table('billing_usage_events', fn (Blueprint $t) => $t->dropColumn('amount_cents')) and, in a second Schema::table call, $t->unsignedBigInteger('quantity')->change() (down() restores decimal('quantity', 20, 6) and unsignedBigInteger('amount_cents')->default(0)). Add the config key. Update UsageEvent::$casts. Run herd php artisan migrate --database=mysql --path=database/migrations/2026_09_14_100000_make_usage_events_integer_and_dropped_amount.php against octo_testing once (the MySQL test schema), then confirm tests/Feature/Billing/BillingLedgerTest.php still passes on SQLite — it seeds events; fix its fixtures to the new columns (drop amount_cents, integer quantity).

  • Step 2: Unit tests first — UsageTest and UsageLinesTest (tests/Unit/Billing, no database for UsageLines; UsageTest::periodFor only):

    • Usage::periodFor(Carbon::parse('2026-09-30 23:30:00', 'UTC')) is 2026-09 with the config at UTC, and 2026-10 with the config set to Asia/Colombo — the timezone is load-bearing and this is the one test that says so.
    • UsageLines::for() on five SMS events of 500 each at 4,500 millicents/message in 2026-09 → one line: quantity 2500, amount_cents 11250, description SMS — September 2026: 2,500 messages at $0.045 / message, five event_ids. Two of the five at a different rate (5,000) → two lines for the same service (the rate changed mid-month; each line states one price). Events in two periods → lines ordered 2026-08 before 2026-09. 250,000 input tokens at 300,000 millicents per 1m_tokensamount_cents 75. An empty collection → [].
    • UsageLines::idempotencyKey(7, $line, 'in_x') is usage:7:2026-09:sms:in_x.
  • Step 3: Failing feature tests — UsageRecordingTest (RefreshDatabase, seed BillingUsageRatesSeeder, teams via BillingTestCase::team()):

    • Usage::record($team, UsageService::Sms, 3) writes one row with unit = message, unit_amount_millicents = 4500 (the seeded default), billing_period from occurred_at in the billing timezone, invoiced_at null, billing_invoice_id null.
    • A per-team override (UsageRate row with team_id, effective yesterday) is what the event denormalizes, not the default; a future-dated override is not.
    • No rate for the service (delete the seeded rows) → RuntimeException, no row written. quantity 0InvalidArgumentException, no row.
    • An internal team is recorded like any other (the ledger rule; exclusion happens at close).
    • reference stores the morph pair (reference_type = the model's class, reference_id = its key).
  • Step 4: Failing feature tests — UsageCloseTest (RefreshDatabase, PostsStripeWebhooks, fake gateway swapped, Carbon::setTestNow('2026-10-01 00:05:00'), a self-serve team with stripe_id and a live subscription as StripeInvoiceRecordingTest::customerTeam() / StripeWebhookTest::withLiveSubscription() build one; invoiceEventPayload('evt_created', 'invoice.created', $customer, ['id' => 'in_oct', 'status' => 'draft', 'billing_reason' => 'subscription_cycle', 'created' => now()->timestamp])):

    • The primary path: 2,500 September SMS events + 250,000 September input-token events, uninvoiced → posting invoice.created calls addInvoiceItems() once with two lines (amounts 11250 and 75, keys usage:{id}:2026-09:sms:in_oct / ...:ai_tokens_input:in_oct), the call's transactionLevel equals the test's baseline (DB::transactionLevel() captured before the post), a local billing_invoices row exists with source = stripe, status = draft, stripe_invoice_id = in_oct; two billing_invoice_lines rows with kind = usage, the same amounts, unit_amount_cents = 0, meta.service/meta.billing_period/meta.events; every September event stamped with invoiced_at and that billing_invoice_id; a usage.closed event with lines: 2, total_cents: 11325; response 200.
    • August is carried too: an uninvoiced August event alongside September → three lines, August's first.
    • Nothing owed: a team with only October events → no gateway call, no lines, the draft row still recorded (phase 3 does that regardless).
    • Redelivery adds nothing: posting the same event id again, and a fresh event id for the same invoice → invoiceItemsAdded still has one entry.
    • Stripe accepts, local write fails: bind CloseUsagePeriod to a subclass whose step 3 throws after the gateway call → the response is 5xx, the billing_events claim row for evt_created is gone (released, so Stripe redelivers), events unstamped; a second post with the same event id calls the gateway again with the same idempotency keys and then stamps. (The mutation check for "the close runs inside the claim-releasing try".)
    • Not editable is silent: addInvoiceItemsThrows = InvalidRequestException with code invoice_not_editable → 200, nothing stamped, no lines, Exceptions::assertNothingReported(). Any other ApiErrorException → the exception propagates (5xx), claim released, nothing stamped.
    • Gates: billing_reason = manual → no call; an enterprise team (no Stripe customer, so no invoice.created ever arrives — assert by posting one whose customer resolves to an enterprise team's stripe_id) → no call; internal team → no call.
    • Void releases: after the primary path, post invoice.voided for in_oct → every event's invoiced_at/billing_invoice_id null again, the two line rows still exist on the voided invoice, a usage.released event with events: N. Then VoidInvoice on a local draft carrying usage lines (create one directly with Invoice::create + InvoiceLine::create + stamped events) releases the same way — one implementation, two callers.
    • Stripe rows are Stripe's: IssueInvoice::handle() on the in_oct draft → 422; VoidInvoice::handle() on it → 422, and no release happens.
    • StripeInvoiceRecordingTest's existing "invoice.created is not handled — no row" test flips to "creates a draft row with issued_at null", and TeamBillingSummary::for($team)['invoices'] still does not list it.
  • Step 5: Run them to confirm they fail for the right reason (missing class / no gateway method / invoice.created ignored), not a fixture error.

  • Step 6: Implement.

    • Usage (periodFor, record, release) with a class docblock citing "The recorder". record() resolves via UsageRate::resolve($service->value, $team, $occurredAt).
    • UsageEvent::scopeUninvoicedClosedPeriods() — write it as a query scope but name the public entry the way the plan spells it: public static function uninvoicedClosedPeriods(Team $team, string $beforePeriod): Builder.
    • UsageLines::for() and idempotencyKey(); description via UsageService::label(), number_format(), Str::plural($unit->perLabel(), $quantity), Rate::format(), Carbon::createFromFormat('Y-m', $period)->format('F Y').
    • Gateway: interface docblock + StripePaymentGateway::addInvoiceItems() — for each line Cashier::stripe()->invoiceItems->create(['customer' => $team->stripe_id, 'invoice' => $stripeInvoiceId, 'amount' => $line['amount_cents'], 'currency' => 'usd', 'description' => $line['description']], ['idempotency_key' => $line['idempotency_key']]). Fake records and optionally throws, mirroring payInvoice().
    • InvoicePaymentFailure::meansInvoiceNotEditable().
    • CloseUsagePeriod::onRenewalInvoice(): gate on $team->billing_type === BillingType::SelfServe and billing_reason ∈ {subscription_cycle, subscription_create}; step 1 DB::transaction(fn () => UsageEvent::uninvoicedClosedPeriods($team, Usage::periodFor(Carbon::createFromTimestamp($stripeInvoice['created'])))->lockForUpdate()->get()) then UsageLines::for() outside it; return null on []; step 2 the gateway call in try with catch (InvalidRequestException $e) { if (InvoicePaymentFailure::meansInvoiceNotEditable($e)) return null; throw $e; }; step 3 DB::transaction: re-select the same ids whereNull('invoiced_at')->lockForUpdate(), if the count is short report(new RuntimeException(...naming the invoice and the stamped ids...)) and continue with what is left, write InvoiceLine rows against $localDraft->id, UsageEvent::whereIn('id', ...)->update([...]), BillingEvent::record($team, 'usage.closed', [...]).
    • Webhook: add 'invoice.created' to RECORDED_INVOICE_EVENTS (rewrite the "absent on purpose" docblock paragraph — it now records a draft so the close has an invoice id, and the customer never sees drafts). Inside the existing try, after mailFirstFailure(): if ($type === 'invoice.created' && $team !== null && $recorded !== null) { app(CloseUsagePeriod::class)->onRenewalInvoice($team, $payload['data']['object'], $recorded); } with a comment saying why this phase, unlike phase 4, stays inside the claim-releasing try: Cashier has no invoice.created handler, so a redelivery re-runs nothing live, and the redelivery is the recovery.
    • RecordStripeInvoice::handle(): after the row write, if ($incoming === BillingInvoiceStatus::Void) { app(Usage::class)::release($invoice); } (static call — keep Usage static like Money). VoidInvoice::handle(): abort_if($invoice->source === BillingInvoiceSource::Stripe, 422, 'Stripe invoices are voided in Stripe.') before the lock, and Usage::release($locked) inside its transaction after the status write. IssueInvoice::handle(): abort_if($invoice->source === BillingInvoiceSource::Stripe, 422, 'Stripe issues its own invoices.').
    • BillingOverview: usage_billed_cents = InvoiceLine::query()->where('kind', BillingInvoiceLineKind::Usage)->whereHas('invoice', fn ($q) => $q->whereIn('team_id', self::commercialTeams()->select('id'))->whereBetween('issued_at', [$monthStart, $monthEnd]))->sum('amount_cents') (add InvoiceLine::invoice() if missing). TeamBillingController: fetch the team's usage lines whose invoice has issued_at, group in PHP by meta.service + meta.billing_period, emit the same keys the Vue reads (service, billing_period, amount, quantity, events); the stdClass comment goes.
  • Step 7: Run the scoped set + tests/Unit, Pint, PHPStan, commit with the two deploy notes in the message body. Subject: Billing 6a: the usage ledger and the monthly close.


Task 2: 6b — the sweeper and the arithmetic fix

Superseded in execution: createStandaloneInvoice() was split into createDraftInvoice() / addInvoiceItem() / finalizeInvoice() by controller ruling; the spec's "The sweeper" section is the binding text.

Spec: "The sweeper" and "The floating point debt". Read both first.

Files:

  • Create: app/Console/Commands/CloseUsagePeriodSweep.php (billing:close-usage-period)
  • Modify: app/Actions/Billing/CloseUsagePeriod.php (onSweep()), app/Support/Billing/PaymentGateway.php + StripePaymentGateway.php + FakePaymentGateway.php (createStandaloneInvoice()), app/Actions/Billing/CreateInvoice.php (precomputed usage lines; integer arithmetic), app/Support/Billing/Money.php (lineCents()), routes/console.php (schedule after billing:draft-enterprise-invoices)
  • Test: tests/Feature/Billing/UsageSweepTest.php (new), tests/Unit/Billing/MoneyTest.php (extend), tests/Feature/Billing/BillingInvoiceTest.php (a fractional-quantity manual line)

Interfaces:

  • Consumes: everything Task 1 produces; CreateInvoice::handle(Team, CarbonInterface $periodStart, CarbonInterface $periodEnd, array $lines, ?User $actor): Invoice; DraftEnterpriseInvoices for the command shape (--dry-run, $this->table([...]), exit 0).

  • Produces:

    • Money::lineCents(string $quantity, int $unitAmountCents): int$quantity a plain decimal with up to six fractional digits (regex /^\d{1,12}(\.\d{1,6})?$/, else InvalidArgumentException), parsed to millionths as an integer, intdiv($micros * $unitAmountCents * 2 + 1_000_000, 2_000_000).
    • CreateInvoice line shape becomes array{kind: string, description: string, quantity: string, amount_cents: int, line_amount_cents?: int, meta?: array} — when line_amount_cents is present it is the line total verbatim (usage lines), otherwise the total is Money::lineCents($quantity, $amount_cents).
    • PaymentGateway::createStandaloneInvoice(Team $team, array $lines, string $description): array$lines as for addInvoiceItems(); creates the draft invoice first (pending_invoice_items_behavior = exclude, collection_method = charge_automatically, auto_advance = true, idempotency key usage-invoice:{team_id}:{first period}-{last period}), then the items against it, then finalizeInvoice(); returns the finalized invoice as an array (->toArray()), which is a valid RecordStripeInvoice payload.
    • CloseUsagePeriod::onSweep(Team $team, CarbonInterface $now): array{outcome: 'stripe'|'draft'|'skipped'|'error', invoice: ?Invoice, lines: int, total_cents: int, reason: ?string}.
    • FakePaymentGateway: public array $standaloneInvoicesCreated = [] ({team, lines, description, transactionLevel, sequence}), public ?Throwable $createStandaloneInvoiceThrows = null; returns a payload {id: 'in_fake_{n}', object: 'invoice', status: 'open', number: 'FAKE-{n}', customer: $team->stripe_id, created, subtotal, total (sum of the lines), currency: 'usd', status_transitions: {finalized_at}, lines: {data: []}}.
  • Step 1: Money::lineCents() unit tests first (MoneyTest): ('2.5', 1000) → 2500; ('1.15', 100) → 115 (the float trap Money's docblock warns about); ('0.333333', 300) → 100; ('1', 75000) → 75000; ('3', 333) → 999; ('2.5000001', 1)InvalidArgumentException.

  • Step 2: Failing feature tests — UsageSweepTest (fake gateway; Carbon::setTestNow('2026-10-03 06:00:00') so September is 48 hours closed; teams with September events built as in Task 1):

    • Self-serve with a Stripe customer, no renewal invoice took the usage: one createStandaloneInvoice() call with two lines and transactionLevel at baseline; a local row from the fake payload (source = stripe, status = open, stripe_invoice_id = in_fake_1); two usage line rows; events stamped; usage.closed recorded with origin: 'billing:close-usage-period'; outcome stripe.
    • The 48-hour rule: at 2026-10-02 23:00:00 nothing is billed; at 2026-10-03 00:00:00 it is (mutation check for the cutoff arithmetic).
    • Enterprise: a local draft via CreateInvoicesource = manual, number null, period_start = 2026-09-01, period_end = 2026-09-30, one usage line per service with line_amount_cents honoured (11250, not quantity × 0), events stamped in the same transaction, no gateway call, outcome draft. Two closed periods → period_start of the earlier, period_end of the later, lines for both.
    • Self-serve without a Stripe customer → the same draft path.
    • Internal team → skipped, nothing written, no call.
    • Already invoiced (stamped events only) → skipped, no call.
    • Idempotent with the close handler: run the sweep, then post invoice.created for a renewal → no gateway addInvoiceItems call, no second line; and the reverse order → the sweep finds nothing.
    • Stripe error: createStandaloneInvoiceThrows = ApiErrorException → reported, nothing stamped, no local row, outcome error, command still exits 0 and the next team is processed. An InvalidRequestException is not special-cased here (there is no not-editable race on an invoice we just created).
    • --dry-run prints the table and writes nothing, calls nothing.
    • Command output is a table of Organization, Periods, Lines, Total, Outcome; Exceptions::assertNothingReported() on the happy path.
  • Step 3: BillingInvoiceTest: a manual draft with a line of quantity 2.5 at $10.00 has amount_cents 2500 and total_cents 2500 (the 2d debt, now paid).

  • Step 4: Run them to confirm they fail for the right reason.

  • Step 5: Implement.

    • Money::lineCents() beside toCents(), with a docblock naming the rounding rule and pointing at Rate::lineCents() as its usage-rate sibling.
    • CreateInvoice: replace the float line with $amount = $line['line_amount_cents'] ?? Money::lineCents($line['quantity'], $unit); and pass 'meta' => $line['meta'] ?? null into InvoiceLine::create. Update the @param shape.
    • Gateway: interface docblock + StripePaymentGateway::createStandaloneInvoice()$stripe = Cashier::stripe(); $invoice = $stripe->invoices->create([...], ['idempotency_key' => ...]); foreach ($lines) $stripe->invoiceItems->create([... 'invoice' => $invoice->id ...], ['idempotency_key' => $line['idempotency_key']]); return $stripe->invoices->finalizeInvoice($invoice->id)->toArray();. Idempotency keys for the items: UsageLines::idempotencyKey($team->id, $line, $invoice->id) — computed by the gateway after it knows the invoice id, so the action passes lines without keys for this path; make idempotency_key optional in the shape and document it. Fake as specified.
    • CloseUsagePeriod::onSweep(): cutoff Usage::periodFor($now->copy()->subHours(48)); internal → skipped; step 1 as in onRenewalInvoice(); self-serve with stripe_id → gateway, then one transaction: RecordStripeInvoice::handle($team, $payload, null), line rows, stamps, usage.closed; otherwise → one transaction: CreateInvoice::handle($team, periodStart, periodEnd, $lines, null) with line_amount_cents and meta per line, stamps, usage.closed. Period bounds from the first/last billing_period via Carbon::createFromFormat('Y-m', ...)->startOfMonth() / endOfMonth() in the billing timezone. Failures: catch (Throwable $e) { report($e); return ['outcome' => 'error', ...]; } — nothing was stamped because the stamp is after the call.
    • The command: billing:close-usage-period {--dry-run}; selects Team::query()->whereExists(uninvoiced events with billing_period < cutoff)->where('billing_type', '!=', BillingType::Internal); per team onSweep() (or, on --dry-run, step 1 + UsageLines::for() only, printing what it would bill); table; exit 0.
    • Schedule entry directly after billing:draft-enterprise-invoices: ->daily()->withoutOverlapping(), comment: the 48-hour rule means a renewal invoice created on the 1st has had two days to carry the usage before this command bills what it did not.
  • Step 6: Run the scoped set + tests/Unit, Pint, PHPStan, commit. Subject: Billing 6b: the usage sweeper and integer invoice lines.


Task 3: 6c — what the customer sees

Spec: "What the customer sees". Read it first, and the 4e billing page section it extends ("Increment 4 in detail" → the team-facing billing page, plus TeamBillingSummary's class docblock).

Files:

  • Modify: app/Queries/Billing/TeamBillingSummary.php (usage block; lines on each invoice), resources/js/pages/billing/Index.vue (usage card; expandable invoice lines), resources/js/types/billing.ts (BillingSummaryUsage, BillingSummaryInvoiceLine, lines on BillingSummaryInvoice)
  • Test: tests/Feature/Billing/TeamBillingPageTest.php (extend)

Interfaces:

  • Consumes: UsageLines::for(), Usage::periodFor(), Rate::format(), Money::format(), UsageService::label(), UsageUnit::perLabel(); TeamBillingSummary::for(Team): array and its issuedInvoices() mapping; the existing invoice table markup at Index.vue ~252-276.

  • Produces, in TeamBillingSummary::for():

    • 'usage' => null | array{period_label: string, rows: list<array{service: string, label: string, quantity: string, unit: string, rate: string, amount: string}>, total: string}null when the team has no events in the current period; rows from UsageLines::for() over UsageEvent::where('team_id')->where('billing_period', Usage::periodFor(now())) (invoiced or not — the month is what it is), quantity via number_format, unit = Str::plural($unit->perLabel(), $quantity), rate = Rate::format(...) . ' / ' . $unit->perLabel(), amount = Money::format($line['amount_cents']), total = the sum formatted, period_label = September 2026.
    • each invoice row gains 'lines' => list<array{description: string, quantity: string, amount: string}> from Invoice::lines (eager-loaded with with('lines') in issuedInvoices()), ordered by id; [] for Stripe invoices that pre-date increment 6.
  • Step 1: Failing tests in TeamBillingPageTest:

    • A self-serve team with 2,500 September SMS events, Carbon::setTestNow('2026-09-20')for($team)['usage'] is {period_label: 'September 2026', rows: [{service: 'sms', label: 'SMS', quantity: '2,500', unit: 'messages', rate: '$0.045 / message', amount: '$112.50'}], total: '$112.50'} and rows[0].amount equals Money::format(Rate::lineCents(2500, 4500, UsageUnit::Message)) asserted in the same test — the spec's "asserted side by side".
    • No events this month → usage === null (October events on 2026-09-20 count as none).
    • An issued invoice with two usage lines → invoices[0].lines has two entries with description, quantity '2,500', amount '$112.50'; a Stripe invoice with no lines → lines === [].
    • The Inertia render (npm run build first): GET route('billing.index') as an Owner → assertInertia(fn ($page) => $page->component('billing/Index')->has('usage.rows', 1)->has('invoices.0.lines', 2)).
  • Step 2: Run them to confirm they fail.

  • Step 3: Implement TeamBillingSummary — a private usageThisMonth(Team): ?array and the lines mapping inside the existing invoice map; no other query changes.

  • Step 4: Index.vue and types. A "Usage this month" card between the storage card and the invoices card, rendered v-if="usage", data-test="usage-card": a small table (Service, Quantity, Rate, Cost so far) with a footer total and the note "Bills on the 1st, on your next invoice." In the invoices table, a chevron button on each row with lines.length > 0 toggles a second <tr> (data-test="invoice-lines-{id}") listing description, quantity, amount. State is one ref<Set<number>> of expanded ids. No computed money, no dates parsed. Copy the Bootstrap/Metronic classes the existing card and table use.

  • Step 5: Run the render test and the scoped set, npm run lint, npm run format, vue-tsc --noEmit, Pint, PHPStan, commit. Subject: Billing 6c: usage on the billing page.


Self-review notes

  • Spec coverage: corrections (column drop, integer quantity, draft row + Issue/Void refusal) → Task 1 Steps 1, 4, 6; recorder rules (rate at record time, throws unpriced, timezone period, internal recorded, quantity ≥ 1, morph reference) → Task 1; the three-step close, idempotency keys, not-editable silence, void release → Task 1; the sweeper's two invoice shapes, invoice-before-items ordering, 48-hour rule, internal exclusion, dry-run, failure split → Task 2; the float debt → Task 2; the usage card and invoice lines → Task 3; the console re-point → Task 1 Step 6; "does not do" → nothing here produces an event, uses Meters, caps, alerts, or bills internal teams.
  • Type consistency: UsageLines::for() returns the same shape in Tasks 1, 2 and 3; CloseUsagePeriod::onRenewalInvoice(Team, array, Invoice) is called with phase 3's $recorded in Task 1 and untouched in Task 2; onSweep(Team, CarbonInterface) is defined in Task 2 and consumed only by its command; addInvoiceItems()'s line shape gains an optional idempotency_key in Task 2 and createStandaloneInvoice() fills it; Money::lineCents(string, int) and Rate::lineCents(int, int, UsageUnit) are deliberately different signatures for different inputs.
  • The riskiest thing in this plan is step 3 of the close — writing locally only after Stripe accepted, with the claim released on failure so the redelivery re-pushes under the same idempotency keys. It has an explicit test. The second riskiest is the sweeper creating a Stripe invoice before its items; the fake cannot prove the ordering against Stripe, so StripePaymentGateway::createStandaloneInvoice() is written in that order with a comment, and the reviewer reads it.
  • Deliberately deferred (in the spec's "does not do"): producers, Stripe Meters, credits, caps, alerts, a usage dashboard, internal-team usage billing, re-pricing.