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
phpis MAMP's 8.2 and too old. Useherd 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, plustests/Unitwhenapp/Supportorapp/Enumschanges. 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;FakePaymentGatewayrecordsDB::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 oneYYYY-MMrule;UsageEvent::uninvoicedClosedPeriods()is the one "what is still owed" query;UsageLines::for()is the one grouping;Usage::release()is the one un-invoice;RecordStripeInvoiceis the one Stripe-row writer;CreateInvoicethe one local-draft writer;Money::format()/Rate::format()own display. Call them. billing_access_stateis written by the webhook'sapplyAccessState()only. Nothing in this plan adds a writer.- Rates are millicents; everything else is cents; quantities are integers. No float touches money. No
.vuecomputes money or dates. No new model factories. Billing tables MySQL, never MongoDB. PaymentGatewayis the only route to Stripe. Every method on the interface is implemented by bothStripePaymentGatewayandFakePaymentGateway; 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
ApiErrorExceptionorThrowableisreport()ed; nothing local is written until Stripe has accepted. - Formatting:
vendor/bin/pint --dirty --format agent, thennpm run format/npm run lintfor.vue/.ts, reverting collateral outside your files.npm run buildbefore 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.createdmust be added by hand to the existing Stripe webhook endpoint's event list;BILLING_TIMEZONEis a new optional env key defaulting toUTC.
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: dropamount_cents, add'quantity' => 'integer'; add theuninvoicedClosedPeriods()scope),app/Support/Billing/PaymentGateway.php+StripePaymentGateway.php+FakePaymentGateway.php(addInvoiceItems()),app/Support/Billing/InvoicePaymentFailure.php(meansInvoiceNotEditable()),app/Http/Controllers/Billing/StripeWebhookController.php(invoice.createdinRECORDED_INVOICE_EVENTS; the close as a new phase inside the claim-releasingtry),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.phpno — 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(theinvoice.createdignore test flips to "creates a draft row")
Interfaces:
-
Consumes:
UsageRate::resolve(string $service, Team $team, ?CarbonInterface $moment = null): ?UsageRate(columnsunit,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,draftincluded);BillingEvent::record(?Team $team, string $type, array $payload = [], ...);InvoicePaymentFailure::CODE_INVOICE_NOT_EDITABLE; the webhook's phases (class docblock ofStripeWebhookController): phase 1 claims the event bystripe_event_id, phase 2 is Cashier's own handling, phase 3 isDB::transaction(recordInvoice + applyAccessState), phase 4 ismailFirstFailure(). -
Produces:
Usage::periodFor(CarbonInterface $moment): string—YYYY-MMof the moment inconfig('billing.timezone').Usage::record(Team $team, UsageService $service, int $quantity, ?Model $reference = null, array $metadata = [], ?CarbonInterface $occurredAt = null): UsageEvent— throwsInvalidArgumentExceptionfor$quantity < 1,RuntimeExceptionwhen no rate resolves. Writesteam_id, service, quantity, unit, unit_amount_millicents, occurred_at, billing_period, reference_type, reference_id, metadata.Usage::release(Invoice $invoice): int— clearsinvoiced_atandbilling_invoice_idon every event stamped to that invoice, recordsusage.releasedwith the count when > 0, returns the count. Must be called inside the caller's transaction.UsageEvent::uninvoicedClosedPeriods(Team $team, string $beforePeriod): Builder—team_id,invoiced_at IS NULL,billing_period < $beforePeriod, ordered bybilling_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_centsfromRate::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—$linesislist<array{description: string, amount_cents: int, idempotency_key: string}>; throws Stripe'sApiErrorExceptionsubclasses raw.InvoicePaymentFailure::meansInvoiceNotEditable(InvalidRequestException $e): bool— true when the error code isCODE_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:
InvoiceLinewithkind = 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 secondSchema::tablecall,$t->unsignedBigInteger('quantity')->change()(down()restoresdecimal('quantity', 20, 6)andunsignedBigInteger('amount_cents')->default(0)). Add the config key. UpdateUsageEvent::$casts. Runherd php artisan migrate --database=mysql --path=database/migrations/2026_09_14_100000_make_usage_events_integer_and_dropped_amount.phpagainstocto_testingonce (the MySQL test schema), then confirmtests/Feature/Billing/BillingLedgerTest.phpstill passes on SQLite — it seeds events; fix its fixtures to the new columns (dropamount_cents, integerquantity). -
Step 2: Unit tests first —
UsageTestandUsageLinesTest(tests/Unit/Billing, no database forUsageLines;UsageTest::periodForonly):Usage::periodFor(Carbon::parse('2026-09-30 23:30:00', 'UTC'))is2026-09with the config atUTC, and2026-10with the config set toAsia/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 in2026-09→ one line:quantity 2500,amount_cents 11250, descriptionSMS — September 2026: 2,500 messages at $0.045 / message, fiveevent_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 ordered2026-08before2026-09. 250,000 input tokens at 300,000 millicents per1m_tokens→amount_cents 75. An empty collection →[].UsageLines::idempotencyKey(7, $line, 'in_x')isusage:7:2026-09:sms:in_x.
-
Step 3: Failing feature tests —
UsageRecordingTest(RefreshDatabase, seedBillingUsageRatesSeeder, teams viaBillingTestCase::team()):Usage::record($team, UsageService::Sms, 3)writes one row withunit = message,unit_amount_millicents = 4500(the seeded default),billing_periodfromoccurred_atin the billing timezone,invoiced_at null,billing_invoice_id null.- A per-team override (
UsageRaterow withteam_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 0→InvalidArgumentException, no row. - An
internalteam is recorded like any other (the ledger rule; exclusion happens at close). referencestores 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 withstripe_idand a live subscription asStripeInvoiceRecordingTest::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.createdcallsaddInvoiceItems()once with two lines (amounts 11250 and 75, keysusage:{id}:2026-09:sms:in_oct/...:ai_tokens_input:in_oct), the call'stransactionLevelequals the test's baseline (DB::transactionLevel()captured before the post), a localbilling_invoicesrow exists withsource = stripe,status = draft,stripe_invoice_id = in_oct; twobilling_invoice_linesrows withkind = usage, the same amounts,unit_amount_cents = 0,meta.service/meta.billing_period/meta.events; every September event stamped withinvoiced_atand thatbilling_invoice_id; ausage.closedevent withlines: 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 →
invoiceItemsAddedstill has one entry. - Stripe accepts, local write fails: bind
CloseUsagePeriodto a subclass whose step 3 throws after the gateway call → the response is 5xx, thebilling_eventsclaim row forevt_createdis 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 = InvalidRequestExceptionwith codeinvoice_not_editable→ 200, nothing stamped, no lines,Exceptions::assertNothingReported(). Any otherApiErrorException→ the exception propagates (5xx), claim released, nothing stamped. - Gates:
billing_reason = manual→ no call; an enterprise team (no Stripe customer, so noinvoice.createdever arrives — assert by posting one whose customer resolves to an enterprise team'sstripe_id) → no call;internalteam → no call. - Void releases: after the primary path, post
invoice.voidedforin_oct→ every event'sinvoiced_at/billing_invoice_idnull again, the two line rows still exist on the voided invoice, ausage.releasedevent withevents: N. ThenVoidInvoiceon a local draft carrying usage lines (create one directly withInvoice::create+InvoiceLine::create+ stamped events) releases the same way — one implementation, two callers. - Stripe rows are Stripe's:
IssueInvoice::handle()on thein_octdraft → 422;VoidInvoice::handle()on it → 422, and no release happens. StripeInvoiceRecordingTest's existing "invoice.createdis not handled — no row" test flips to "creates adraftrow withissued_at null", andTeamBillingSummary::for($team)['invoices']still does not list it.
- The primary path: 2,500 September SMS events + 250,000 September input-token events, uninvoiced → posting
-
Step 5: Run them to confirm they fail for the right reason (missing class / no gateway method /
invoice.createdignored), not a fixture error. -
Step 6: Implement.
Usage(periodFor,record,release) with a class docblock citing "The recorder".record()resolves viaUsageRate::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()andidempotencyKey(); description viaUsageService::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 lineCashier::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, mirroringpayInvoice(). InvoicePaymentFailure::meansInvoiceNotEditable().CloseUsagePeriod::onRenewalInvoice(): gate on$team->billing_type === BillingType::SelfServeandbilling_reason ∈ {subscription_cycle, subscription_create}; step 1DB::transaction(fn () => UsageEvent::uninvoicedClosedPeriods($team, Usage::periodFor(Carbon::createFromTimestamp($stripeInvoice['created'])))->lockForUpdate()->get())thenUsageLines::for()outside it; return null on[]; step 2 the gateway call intrywithcatch (InvalidRequestException $e) { if (InvoicePaymentFailure::meansInvoiceNotEditable($e)) return null; throw $e; }; step 3DB::transaction: re-select the same idswhereNull('invoiced_at')->lockForUpdate(), if the count is shortreport(new RuntimeException(...naming the invoice and the stamped ids...))and continue with what is left, writeInvoiceLinerows against$localDraft->id,UsageEvent::whereIn('id', ...)->update([...]),BillingEvent::record($team, 'usage.closed', [...]).- Webhook: add
'invoice.created'toRECORDED_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 existingtry, aftermailFirstFailure():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-releasingtry: Cashier has noinvoice.createdhandler, 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 — keepUsagestatic likeMoney).VoidInvoice::handle():abort_if($invoice->source === BillingInvoiceSource::Stripe, 422, 'Stripe invoices are voided in Stripe.')before the lock, andUsage::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')(addInvoiceLine::invoice()if missing).TeamBillingController: fetch the team's usage lines whose invoice hasissued_at, group in PHP bymeta.service+meta.billing_period, emit the same keys the Vue reads (service,billing_period,amount,quantity,events); thestdClasscomment 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 intocreateDraftInvoice()/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 afterbilling: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;DraftEnterpriseInvoicesfor the command shape (--dry-run,$this->table([...]), exit 0). -
Produces:
Money::lineCents(string $quantity, int $unitAmountCents): int—$quantitya plain decimal with up to six fractional digits (regex/^\d{1,12}(\.\d{1,6})?$/, elseInvalidArgumentException), parsed to millionths as an integer,intdiv($micros * $unitAmountCents * 2 + 1_000_000, 2_000_000).CreateInvoiceline shape becomesarray{kind: string, description: string, quantity: string, amount_cents: int, line_amount_cents?: int, meta?: array}— whenline_amount_centsis present it is the line total verbatim (usage lines), otherwise the total isMoney::lineCents($quantity, $amount_cents).PaymentGateway::createStandaloneInvoice(Team $team, array $lines, string $description): array—$linesas foraddInvoiceItems(); creates the draft invoice first (pending_invoice_items_behavior = exclude,collection_method = charge_automatically,auto_advance = true, idempotency keyusage-invoice:{team_id}:{first period}-{last period}), then the items against it, thenfinalizeInvoice(); returns the finalized invoice as an array (->toArray()), which is a validRecordStripeInvoicepayload.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 trapMoney'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 andtransactionLevelat 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.closedrecorded withorigin: 'billing:close-usage-period'; outcomestripe. - The 48-hour rule: at
2026-10-02 23:00:00nothing is billed; at2026-10-03 00:00:00it is (mutation check for the cutoff arithmetic). - Enterprise: a local
draftviaCreateInvoice—source = manual,number null,period_start = 2026-09-01,period_end = 2026-09-30, one usage line per service withline_amount_centshonoured (11250, not quantity × 0), events stamped in the same transaction, no gateway call, outcomedraft. Two closed periods →period_startof the earlier,period_endof 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.createdfor a renewal → no gatewayaddInvoiceItemscall, no second line; and the reverse order → the sweep finds nothing. - Stripe error:
createStandaloneInvoiceThrows = ApiErrorException→ reported, nothing stamped, no local row, outcomeerror, command still exits 0 and the next team is processed. AnInvalidRequestExceptionis not special-cased here (there is no not-editable race on an invoice we just created). --dry-runprints the table and writes nothing, calls nothing.- Command output is a table of Organization, Periods, Lines, Total, Outcome;
Exceptions::assertNothingReported()on the happy path.
- Self-serve with a Stripe customer, no renewal invoice took the usage: one
-
Step 3:
BillingInvoiceTest: a manual draft with a line of quantity2.5at $10.00 hasamount_cents 2500andtotal_cents 2500(the 2d debt, now paid). -
Step 4: Run them to confirm they fail for the right reason.
-
Step 5: Implement.
Money::lineCents()besidetoCents(), with a docblock naming the rounding rule and pointing atRate::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'] ?? nullintoInvoiceLine::create. Update the@paramshape.- 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; makeidempotency_keyoptional in the shape and document it. Fake as specified. CloseUsagePeriod::onSweep(): cutoffUsage::periodFor($now->copy()->subHours(48));internal→ skipped; step 1 as inonRenewalInvoice(); self-serve withstripe_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)withline_amount_centsandmetaper line, stamps,usage.closed. Period bounds from the first/lastbilling_periodviaCarbon::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}; selectsTeam::query()->whereExists(uninvoiced events with billing_period < cutoff)->where('billing_type', '!=', BillingType::Internal); per teamonSweep()(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(usageblock;lineson each invoice),resources/js/pages/billing/Index.vue(usage card; expandable invoice lines),resources/js/types/billing.ts(BillingSummaryUsage,BillingSummaryInvoiceLine,linesonBillingSummaryInvoice) - Test:
tests/Feature/Billing/TeamBillingPageTest.php(extend)
Interfaces:
-
Consumes:
UsageLines::for(),Usage::periodFor(),Rate::format(),Money::format(),UsageService::label(),UsageUnit::perLabel();TeamBillingSummary::for(Team): arrayand itsissuedInvoices()mapping; the existing invoice table markup atIndex.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}—nullwhen the team has no events in the current period;rowsfromUsageLines::for()overUsageEvent::where('team_id')->where('billing_period', Usage::periodFor(now()))(invoiced or not — the month is what it is),quantityvianumber_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}>fromInvoice::lines(eager-loaded withwith('lines')inissuedInvoices()), 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'}androws[0].amountequalsMoney::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 on2026-09-20count as none). - An issued invoice with two usage lines →
invoices[0].lineshas two entries withdescription,quantity'2,500',amount'$112.50'; a Stripe invoice with no lines →lines === []. - The Inertia render (
npm run buildfirst):GET route('billing.index')as an Owner →assertInertia(fn ($page) => $page->component('billing/Index')->has('usage.rows', 1)->has('invoices.0.lines', 2)).
- A self-serve team with 2,500 September SMS events,
-
Step 2: Run them to confirm they fail.
-
Step 3: Implement
TeamBillingSummary— a privateusageThisMonth(Team): ?arrayand thelinesmapping inside the existing invoice map; no other query changes. -
Step 4:
Index.vueand types. A "Usage this month" card between the storage card and the invoices card, renderedv-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 withlines.length > 0toggles a second<tr>(data-test="invoice-lines-{id}") listing description, quantity, amount. State is oneref<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$recordedin 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 optionalidempotency_keyin Task 2 andcreateStandaloneInvoice()fills it;Money::lineCents(string, int)andRate::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.