OCTO Ops Help User guides and product documentation

2026 09 09 Saas Monetization Design

On this page 103

SaaS Monetization & Billing — Design

Date: 2026-09-09 Status: Approved design, not yet implemented

Turns OCTO Ops into a billable SaaS platform: per-seat and per-module subscriptions for self-signup teams, manually-managed enterprise plans for existing customers, usage-based charges for metered services, a grace-period/restriction lifecycle, and a superadmin billing console.

Commercial rules

Rule Value
Seat price $10 / active user / month
Specialized module price $750 / module / month (logistics3p, containers today)
Standard modules Included, no limits
Annual discount Pay yearly, get 2 months free (10x the monthly price)
Trial Card required upfront; ends on the first of the month at least 30 days out
Billing date The 1st of every calendar month, for every team
Storage 10 GB per active user, pooled across the team
Adding a user Prorated and charged immediately
Removing a user No credit; the next invoice is smaller
Dropping a module Access until the end of the paid period; no refund
Cancellation Access until the end of the paid period; no refund
Failed payment 21-day grace with full access, retried daily; then restricted
Usage charges August's usage bills on 1 September, on the same invoice
Enterprise Custom fixed price, custom modules/seats/storage/renewal, paid offline

Currency is USD. No sales tax or VAT is calculated or collected — a deliberate deferral, to be revisited before selling into tax-obligating jurisdictions at scale.

Annual plans renew on the 1st as well — the 1st of their anniversary month. A user belonging to two teams occupies a billable seat in each; teams are billed independently and never share seats.

billing_type = internal is a third category alongside self-serve and enterprise: a team owned by us (demo, sales, QA, staging) that is permanently active, never billed, never dunned, and excluded from every revenue figure in the console.

Starting point

Laravel Spark is not installed, despite appearances. Neither is Cashier nor the Stripe SDK. (The Composer package for Cashier Stripe is laravel/cashier; there is no laravel/cashier-stripe package.) What exists is dormant Spark 1.x-era schema from 2016:

  • teams columns: stripe_id, current_billing_plan, card_brand, card_last_four, card_country, billing_address, billing_address_line_2, billing_city, billing_state, billing_zip, billing_country, vat_id, extra_billing_information, trial_ends_at, subscriptiontype
  • MySQL tables: subscriptions, team_subscriptions, invoices

Verified: nothing in app/, routes/, resources/js/, tests/ or database/seeders/ references any of them. App\Models\Enterprise\Invoice is a MongoDB model on enterprises_invoices and is unrelated. There are no self-service customers on the platform today, so this schema carries no data worth preserving.

Also relevant:

  • Team.modulesAllowed (JSON) plus App\Support\Modules already gate module visibility, with "empty means no restriction" semantics. This is the entitlement hook.
  • Modules are DB-driven (modules / modules_subs tables, ModulesSeeder).
  • /superadmin exists (EnsureSuperAdmin, users/teams CRUD, impersonation) with a hub page built to take more sections.
  • AccessRemoved.vue is the existing precedent for a "you cannot use the app" page.
  • uploads is a MongoDB collection; fileSize is stored in kilobytes as a 2dp float, written from App\Support\Uploads and three points in Api\V1\Files\UploadController. oid on tenant models equals Team::id.
  • Twilio is a dependency but unused; there is no AI integration yet. Usage metering must therefore be a generic ledger those services write into later.

Architecture: three layers

Billing systems rot when "what they bought", "what they may do", and "what happened" get tangled. They are kept separate here.

1. Plan. Stripe is the source of truth for self-serve teams. The database is the source of truth for enterprise teams. Nothing outside the billing subsystem reads either directly.

2. Entitlements. One cached value object per team, produced by App\Support\Billing\Entitlements::for(Team $team):

code
Entitlement {
    accessState:        BillingAccessState
    allowedModuleKeys:  array<string>|null   // null = no restriction
    seatCap:            ?int                 // null = unlimited
    storageQuotaBytes:  int
    storageUsedBytes:   int
    graceEndsAt:        ?CarbonImmutable
}

Every enforcement point in the application asks this and only this. It never calls Stripe. It is memoised per request in a static array keyed on team id — deliberately not a persistent cache store, so it can never serve a stale entitlement across requests. Any code that writes a billing_* column on teams must call Entitlements::flush() (and Product::flushCache() where the catalog changed) before anything re-reads the entitlement in the same request.

3. Ledger. Usage events, invoices, payments and state-change events. Append-mostly; nothing is derived back out of Stripe.

Package choice

laravel/cashier (Cashier Stripe, v16.8+ — it supports Laravel 13), with Team as the Billable model — the team pays, not the user. One live Stripe subscription per team carrying multiple subscription items:

  • one seat item on the seat Price, quantity = active member count
  • one item per specialized module, quantity = 1

Annual plans use the same item structure on yearly Prices priced at 10x monthly.

Because the dormant 2016 subscriptions / team_subscriptions / invoices tables are dropped (see below), Cashier's stock table names and models are used with no customization.

Schema

Dropped

  • Tables subscriptions, team_subscriptions, invoices.
  • teams columns current_billing_plan, card_brand, card_last_four, card_country, billing_address, billing_address_line_2, billing_city, billing_state, billing_zip, billing_country, vat_id, extra_billing_information, subscriptiontype.

dStore, isXoxo and photo_url are left alone — they are not billing columns and cleaning them up is separate work. Billing addresses are not stored locally; Stripe holds them on the Customer. vat_id goes with the no-tax decision and is re-added only if tax handling is later built.

Kept on teams

stripe_id and trial_ends_at — Cashier uses exactly those names.

Added to teams

code
pm_type                          string  null   -- Cashier
pm_last_four                     string  null   -- Cashier
billing_type                     enum(self_serve|enterprise|internal) default self_serve, indexed
billing_cycle                    enum(monthly|yearly) null
billing_access_state             enum(trialing|active|past_due|restricted|cancelled|suspended)
                                 default trialing, indexed
billing_email                    string  null
billing_grace_ends_at            datetime null
billing_restricted_at            datetime null
billing_current_period_ends_at   datetime null
billing_seat_cap                 unsignedInteger null   -- null = unlimited
billing_storage_quota_bytes      unsignedBigInteger null -- null = 10GB x active seats
billing_storage_used_bytes       unsignedBigInteger default 0
billing_storage_calculated_at    datetime null
billing_enterprise_amount_cents  unsignedBigInteger null
billing_enterprise_interval      enum(monthly|yearly) null
billing_renewal_at               date null
billing_notes                    text null

Named billing_access_state, not the freed-up billing_state, so it is never misread as an address field. Flattened onto teams rather than a 1:1 side table by explicit decision.

New tables (all MySQL)

billing_products — priced-item catalog, seeded. id, key (seat, module.logistics3p, module.containers, …), name, type enum(seat|module|storage), module_key (nullable, matches modules.key), stripe_price_monthly, stripe_price_yearly, unit_amount_cents, is_active, timestamps. Adding a future specialized module is a seeder row plus two Stripe Prices, not a code change.

billing_team_modules — which specialized modules a team holds. id, team_id, billing_product_id, stripe_subscription_item_id (nullable for enterprise), starts_at, ends_at (nullable), timestamps. Index (team_id, ends_at). A future ends_at is the "cancelled but paid until the 1st" state; this row, not Stripe, is the authority for access.

billing_usage_events — the metering ledger. id (ULID), team_id, service (string: sms, ai_tokens, …), quantity (decimal 20,6), unit (string), unit_amount_cents (integer), amount_cents (integer), occurred_at, billing_period (YYYY-MM), reference_type, reference_id, metadata (json), invoiced_at (nullable), billing_invoice_id (nullable), timestamps. Indexes (team_id, billing_period), (billing_period, invoiced_at). Rates are denormalized at record time so the ledger is immutable — changing the SMS rate never rewrites what August cost.

billing_usage_ratesid, service, team_id (nullable = platform default), unit, unit_amount_millicents, effective_from, timestamps. Unique (service, team_id, effective_from). Rates are stored in millicents — thousandths of a cent — because $0.045 per SMS is not an integer number of cents; unit carries a block size for services quoted per thousand or per million. The only integers in this subsystem that are not cents. See "2d in detail".

billing_invoices — our record of every invoice, Stripe-backed or manual. id, team_id, number, source enum(stripe|manual), stripe_invoice_id (nullable), period_start, period_end, subtotal_cents, total_cents, currency, status enum(draft|open|paid|void|uncollectible), issued_at, due_at, paid_at, pdf_url, notes, timestamps. Gives the superadmin console one table to read instead of two systems.

billing_invoice_linesid, billing_invoice_id, kind enum(seat|module|usage|adjustment), description, quantity, unit_amount_cents, amount_cents, meta (json).

billing_paymentsid, team_id, billing_invoice_id (nullable), method enum(stripe|bank_transfer|cheque|cash|other), amount_cents, paid_at, reference, recorded_by_user_id (nullable), notes, timestamps.

billing_events — append-only audit of every state change. id, team_id (nullable), type, actor_user_id (nullable — null means system or Stripe), stripe_event_id (nullable, unique), payload (json), created_at. A dedicated table rather than owen-it/laravel-auditing, for the same reason superadmin_impersonation_logs is: these events have two actors and are not changes to a model's own attributes. The unique stripe_event_id is also the webhook idempotency key — a replayed webhook cannot double-charge or double-transition.

Lifecycle

Signup and trial

Team created → card captured via a Stripe SetupIntent → seats and specialized modules chosen → one Stripe subscription created with a trial.

Trial ends on the first day of the month that is at least 30 days away. A team signing up on 3 September trials until 1 November (~59 days); one signing up on 28 September also trials until 1 November (~34 days). Consequences: the first invoice is always a clean full month, there is never a prorated stub, and no Stripe billing_cycle_anchor manipulation is required. "30 days" is therefore "at least 30 days" — a deliberate giveaway that removes an entire class of billing bugs.

teams.trial_ends_at mirrors the Stripe trial end.

Seat changes

Adding a member increments the seat item's quantity with proration_behavior=always_invoice, so Stripe invoices and charges the prorated amount immediately. Deactivating or removing a member decrements it with proration_behavior=none — no credit, just a smaller invoice on the 1st.

On an annual plan the same mechanism applies against the annual price: a seat added with seven months left on a 100/year seat is charged roughly58.33 immediately and renews with everything else on the annual date.

A seat is any row in team_users with status = 1, owner included. Pending invitations are not billed until accepted.

Both directions run through one SyncTeamSeats action that recomputes the count from team_users rather than incrementing or decrementing, so a dropped event self-heals on the next change or on the nightly reconciliation.

Module changes

Adding a specialized module creates a subscription item with proration_behavior=always_invoice (prorated $750 charged now) and a billing_team_modules row with starts_at = now; the entitlement cache is busted and access appears immediately.

Dropping one sets billing_team_modules.ends_at to the current period end. Access disappears on the 1st because the resolver compares ends_at — it is a data fact, not a scheduled job that might not fire. The Stripe subscription item is removed at drop time with proration_behavior=none: Stripe issues no credit for the period already paid and does not bill the next one, while ends_at keeps the module accessible locally until the period end. Re-adding a dropped module before its ends_at reopens the same row and re-creates the item with proration_behavior=none — the period is already paid, so nothing is charged until the next renewal.

(Corrected during 4d, 2026-09-12. The original text removed the item "when the renewal webhook is handled"; Stripe generates and collects the renewal invoice from the items still on the subscription before that webhook fires, so the team would have paid a full extra period for a module the resolver already denied them.)

Access state machine

code
trialing   --trial ends, paid-------->  active
trialing   --trial ends, failed------>  past_due    (grace_ends_at = now + 21 days)
active     --invoice.payment_failed-->  past_due    (grace_ends_at = now + 21 days)
past_due   --invoice paid------------>  active
past_due   --grace_ends_at passed---->  restricted
restricted --invoice paid------------>  active
active     --team cancels------------>  cancelled   (full access to period end)
cancelled  --period end-------------->  restricted
any        --superadmin-------------->  suspended   (no self-serve recovery)

Enterprise teams sit in active and can only leave it when a superadmin suspends them. Their billing_renewal_at raises a dashboard alert and nothing more. They never see a card form or receive a dunning email.

Dunning

A daily retry across a fixed 21-day window is not Stripe's smart-retry behaviour, so the platform drives it: Stripe's automatic retries are disabled for these subscriptions (a Stripe dashboard setting that must be changed at deploy, otherwise attempts double up), and a scheduled billing:retry-past-due command runs daily over every team in past_due whose billing_grace_ends_at is still in the future, re-attempting payment on the open invoice.

Owner and Admin receive email on the first failure, at day 7, day 14, day 19, and on restriction. A superadmin extending grace simply moves billing_grace_ends_at; the same loop keeps retrying. The extension is recorded in billing_events with the acting user.

Restriction

EnsureTeamNotRestricted middleware on the web module route groups and the API group, allowlisting logout, the billing recovery routes and the restricted page itself.

  • Members → Billing/Restricted.vue, modelled on AccessRemoved.vue: their organization's account is restricted, contact an administrator.
  • Owner and Admin → Billing/Recover.vue: update the payment method, view outstanding invoices, pay now. Nothing else in the application is reachable.
  • Passport API requests from a restricted team receive 402 Payment Required with a JSON body rather than a redirect.

Owner and Admin (TeamRole::Owner, TeamRole::Admin) are also the roles that may manage billing normally: plan changes, payment method, invoices.

Entitlement enforcement points

  1. Access stateEnsureTeamNotRestricted middleware (web + API).
  2. ModulesApp\Support\Modules::allowedForTeam() gains a second input, intersecting the team's own configuration with the billing entitlement. Today's "null means no restriction" semantics are preserved so enterprise and legacy teams behave exactly as they do now. A route-level EnsureModuleEntitled:{key} middleware guards routes/web/logistics3p.php and routes/web/containers.php.
  3. Seat cap — checked in the invite and membership-activation actions.
  4. StorageStorageQuota::assertCanStore() at the four upload write sites.

Usage metering

Single entry point: Usage::record($team, $service, $quantity, $reference, $metadata). It resolves the rate at record time (per-team override, else platform default) and denormalizes it onto the event. Rates are millicents and a line's cost rounds once, at the line, never per event — the formula is in "2d in detail". billing_period is stamped as YYYY-MM in one configured billing timezone so period boundaries are unambiguous.

Nothing calls this on day one. It is the seam Twilio and the AI integration plug into when they are built.

Monthly close

August's usage must land on the invoice Stripe generates on 1 September.

Primary path: the invoice.created webhook. When Stripe drafts the renewal invoice, the closed period's uninvoiced events are grouped by service and pushed on as invoice line items before the invoice finalizes. One invoice per team per month: seats and modules for the coming month, plus itemized usage for the month just ended.

Safety net: a daily billing:close-usage-period sweep finds closed-period events still uninvoiced after 48 hours — an enterprise team with no Stripe invoice, or a webhook that never arrived — and places them on a standalone invoice.

Double-invoicing is prevented by stamping invoiced_at and billing_invoice_id inside a MySQL transaction with the event rows locked.

Storage quota

Quota is billing_storage_quota_bytes when set (enterprise), otherwise 10 GB x active seat count.

Usage is maintained two ways: incremented cheaply at each upload, and reconciled nightly by billing:recalculate-storage, a MongoDB aggregate summing fileSize grouped by oid across the uploads collection, excluding soft-deleted rows. The nightly job is authoritative; the increment keeps the figure fresh between runs. fileSize is in kilobytes, so 10 GB is 10,485,760 in the stored unit.

Over quota blocks new uploads only. Existing files stay fully readable and the rest of the application works normally — a team that goes over quota by removing users is not otherwise punished. Banners appear at 80% and 95%.

Enterprise plans

A superadmin sets, per team: allowed modules (checkbox list from the modules table, written to modulesAllowed), seat cap, storage GB, fixed amount and interval, renewal date, and notes. Seat cap and storage are hard blocks; the renewal date raises a dashboard alert only.

Invoices are billing_invoices rows with source = manual. A scheduled billing:draft-enterprise-invoices command creates a draft for each enterprise team whose billing_renewal_at falls in the coming period; a superadmin reviews and issues it. They are settled by recording a billing_payments row (method, amount, date, reference, notes, recorded-by). Usage charges appear as lines on the same manual invoice.

All teams existing at launch become enterprise, active, with their current modules and no billing. No current customer is ever asked for a card.

Superadmin console — /superadmin/billing

Slots into the existing EnsureSuperAdmin group and hub page. Four screens:

  • Overview — MRR/ARR, team counts by state, cash collected and usage billed this month, and an at-risk list: past-due teams, grace windows expiring soon, teams over quota, enterprise renewals due. Defined precisely in "2c in detail" below.
  • Teams — billing type, state, seats, modules, monthly value, next renewal, storage percentage; search by name, filters by type and state. Follows the superadmin area's own convention — server-side paginate() with Bootstrap tables — not DataTables/FilterDrawer, which is a Reports-page pattern and is not used anywhere under /superadmin.
  • Team detail — subscription summary, invoice history, payment history, usage broken down by service and month, and the billing_events timeline. Actions: extend grace, suspend and unsuspend, convert to enterprise, edit enterprise terms, record an offline payment, issue a credit.
  • Rates — platform default price per usage service, plus per-team overrides.

Testing

Cashier's own testing approach makes live calls to Stripe's test API, which suits neither this repository's suite nor its "run the affected tests" rhythm.

Stripe access therefore sits behind a narrow PaymentGateway interface covering only the calls actually made: create subscription, sync item quantity, add item, remove item, add invoice item, retry invoice, update payment method. Feature tests run against a fake implementation, which makes the state machine, entitlement resolution, seat and module sync, grace transitions, usage close and every enforcement point fully testable offline. A small separate group of contract tests exercises Stripe test mode and stays out of the default run.

Proration arithmetic belongs to Stripe, not to us; the tests assert that the correct call is made with the correct proration behaviour, not that Stripe divides correctly.

Repository conventions apply: PHPUnit, no model factories (explicit realistic records), all billing tables in MySQL, and no MongoDB transactions — the storage aggregate is read-only, so that constraint does not bite.

Implementation sequence

Six increments, each getting its own plan document.

  1. Foundation. Cashier install, legacy schema cleanup, teams columns, the eight billing tables and models, the Entitlements resolver wired into App\Support\Modules, and every existing team landing on enterprise / active with its current modules — achieved by the column defaults, since adding a column with a default backfills every existing row, so no separate data migration is needed. No user-visible change. Ships safely alone.
  2. Enterprise and the admin console. The four superadmin screens, enterprise plan editing, manual invoices, offline payments, the event timeline. Immediately useful, and it is the state every current customer is in.
  3. Enforcement. Restricted middleware, Restricted.vue and Recover.vue, seat cap, module route guards, storage quota job and banners, the grace/cancellation expiry sweep, and the rule for what scheduled work a restricted team still gets. Driven purely by entitlements, so it is verifiable against enterprise and suspended teams before any money moves. Detailed below.
  4. Self-serve subscription. Signup flow, card capture, plan picker, trial, Stripe subscription creation, webhooks, seat sync, module add and drop, the team-facing billing settings page. Required change, carried from 2c's final review: BillingOverview::mrrCents() (app/Queries/Billing/BillingOverview.php:136-146 as reviewed) requires both billing_enterprise_amount_cents and billing_enterprise_interval to be non-null, so only enterprise plans contribute to MRR. That is correct until this increment — no self-serve price is stored anywhere reachable — but BillingType::isBillable() (app/Enums/BillingType.php:19-22) returns true for SelfServe only, the exact complement of what ships. The moment Stripe subscriptions exist, MRR silently under-reports every one of them and no test fails. Whoever builds this increment must widen mrrCents() to sum self-serve subscription value alongside the enterprise amounts, and must add a test that a self-serve team contributes to MRR. Left as-is in 2c deliberately: there is nothing to sum yet, and a stub would be a second, untested implementation of a rule.
  5. Dunning. Daily retry command, dunning emails, admin grace extension. Grace transitions moved to increment 3past_due → restricted and cancelled → restricted are dates passing, needing no Stripe and no mail, and without them increment 3's restriction flow has no trigger but a superadmin. See "Increment 3 in detail" → "The trigger".
  6. Usage metering. Recorder, rates, invoice.created handler, sweeper, usage UI. Twilio and the AI integration wire into it as they are built.

Increments 1 to 3 deliver real value with no payment risk, which is a natural point to stop and reassess before charging is switched on.

Increment 2 in detail — enterprise plans and the superadmin console

Increment 1 shipped and merged on 2026-09-09. This section records the decisions that increment 2 needed and the earlier sections did not settle. Everything above still holds; where the two disagree, this section is newer and wins.

Corrections to the sections above

Two claims made before the code existed turned out to be wrong:

  • The console's Teams screen does not use DataTables/FilterDrawer (corrected in place above). Nothing under /superadmin does.
  • There was no money-formatting helper anywhere in this codebase when this was written. 2b adds App\Support\Billing\Money as the single conversion point (see "2b in detail" below). The rule it enforces is unchanged: amounts are integer cents, every controller payload carries a preformatted display string, and no Vue file performs cents arithmetic.

Backend foundations

Four things must land before any screen is built.

billing_invoices.number becomes nullable. Invoice numbers are allocated at issue time, not draft time, so a discarded draft leaves no gap in the sequence — which means a draft has no number. The column is currently unique and NOT NULL. A unique index permits many NULLs on both MySQL and SQLite, so drafts do not collide.

Number allocation is atomic. A billing_number_sequences table (scope, next_value, timestamps; unique on scope) allocates via lockForUpdate() inside the issuing transaction. Scope values look like invoice:2026. Numbers are formatted INV-2026-0001 — global per year, not per team. The bulk drafting command makes the lock necessary rather than decorative, and the table is reusable when self-serve needs receipt numbers.

BillingCycle enum. teams.billing_cycle and teams.billing_enterprise_interval are bare string(10) with no enum and no cast — an accepted Minor from increment 1's review. This is the increment that writes billing_enterprise_interval, so the enum (Monthly = 'monthly', Yearly = 'yearly') and both casts land here, before 'yearly'/'annual'/'year' drift can start.

modulesAllowed becomes mass-assignable. Team declares #[Fillable(['name', 'slug', 'is_personal'])], so $team->update(['modulesAllowed' => …]) is silently dropped and a superadmin cannot persist a team's module set at all. This is also why Support\ModulesMenuFilterTest::test_the_team_level_allow_list_hides_disallowed_modules has been failing on main; fixing the attribute fixes that test.

Verified safe: the only three Team mass-assignment sites are SuperAdmin\TeamController::store() (passing SaveTeamRequest::validated(), which allows only name and is_personal) and two explicit array literals in Actions\Teams\CreateTeam and Teams\TeamController::update(). No endpoint passes $request->all(). The residual risk is a future validation rule adding the key to a member-facing request; Team's docblock must say that modulesAllowed is billing-relevant and must never appear in one.

Actions

Every console write goes through an Action in app/Actions/Billing/, never directly from a controller: SaveEnterprisePlan, SetTeamModules, IssueInvoice, RecordPayment, ExtendGrace, SuspendTeam, RestoreTeam — seven built in 2a — plus VoidInvoice, which 2b adds when it needs it.

"Issue a credit", listed as a Team-detail action above, deliberately gets no Action of its own: a credit note is a manual invoice carrying an Adjustment line with a negative amount, which IssueInvoice already supports now that invoice totals are signed. The Team-detail screen offers it as a distinct button that pre-fills the invoice form; the write path is the same one.

Every Action records a BillingEvent naming the acting user. Only those that write a billing_* column on teams, or change module holdings, also call Entitlements::flush()SaveEnterprisePlan, SetTeamModules, ExtendGrace, SuspendTeam, RestoreTeam. IssueInvoice and RecordPayment deliberately do not: Entitlements::resolve() reads only teams columns, so no invoice or payment can change what a team is allowed to do, and flushing there would imply otherwise.

The rule for anything added later — VoidInvoice, the drafting command, the Stripe webhook handlers — is therefore: flush if and only if you wrote a billing_* column on teams or a billing_team_modules row. Add Product::flushCache() on top when the catalog itself changed, which only the Rates screen does. Centralising this in the Actions is what establishes the habit increment 1's review warned about — nothing outside them writes a billing_* column.

Revenue

MRR is the sum, over every team whose billing_type is not internal and whose billing_access_state is trialing, active or past_due, of that team's monthly equivalent: billing_enterprise_amount_cents, divided by 12 when billing_enterprise_interval is yearly. ARR is MRR x 12.

internal teams (demo, sales, QA, staging) are excluded entirely. Usage charges are excluded from MRR because they are variable; once increment 6 lands they appear as a separate "usage billed this month" figure. Self-serve MRR (seats x price, plus held modules) is increment 4's to add — the calculation gets a seam for it, not a stub.

Enterprise invoicing

billing:draft-enterprise-invoices runs daily. For each enterprise team whose billing_renewal_at falls within the next 30 days it creates a draft invoice with no number, pre-filled with the fixed amount as a single line, whose period_start is that renewal date and whose period_end is the last day of that interval — one month or one year later, less a day, per billing_enterprise_interval, so the end date is inclusive and two consecutive periods do not overlap on a shared date. It is idempotent: a team that already has an invoice in any status whose period_start equals that renewal date is skipped, so running it daily for 30 days produces one draft, not thirty.

A superadmin reviews the draft and issues it, which is when IssueInvoice allocates the number, sets status = open and stamps issued_at. Payment is recorded offline through RecordPayment, which marks the invoice paid once the payments settle the total.

Screens

All four sit under /superadmin/billing inside the existing EnsureSuperAdmin group, reached from the existing superadmin hub page.

  • Overview (/superadmin/billing) — MRR, ARR, cash collected this month, usage billed this month, counts by access state, and an at-risk list: past-due teams, grace windows expiring within 7 days, teams over storage quota, enterprise renewals due within 30 days. See "2c in detail" for what each figure measures.
  • Teams (/superadmin/billing/teams) — the billing view of every team, searchable by name and filterable by billing type and access state. Personal teams are hidden unless a filter asks for them — see "2c in detail".
  • Team detail (/superadmin/billing/teams/{team}) — plan summary, invoice history, payment history, usage by service and month, and the BillingEvent timeline. Every Action above is reachable from here.
  • Rates (/superadmin/billing/rates) — platform default price per usage service plus per-team overrides. Nothing reads billing_usage_rates until increment 6, so this screen ships inert by design; it is built now so the price list is seeded and ready before metering lands.

Sub-increments

Four, each independently shippable, each getting its own plan document:

  1. 2a — foundations. The nullable number migration, billing_number_sequences, the BillingCycle enum and casts, modulesAllowed fillable, and the seven Actions. No UI.
  2. 2b — Team detail. The screen an operator actually needs, plus its write endpoints. Detailed below.
  3. 2c — Overview and Teams list. The cross-team view. Detailed below.
  4. 2d — Rates and the drafting command.

2b in detail — the Team billing screen

2a shipped and merged on 2026-09-10, so every write path this screen needs already exists as an Action. 2b is the first UI in the subsystem, which means the conventions it sets are the ones 2c and 2d copy.

Routes and controllers

Six thin controllers under App\Http\Controllers\SuperAdmin\Billing\, following how /superadmin already splits TeamController / TeamMemberController / UserPasswordController rather than concentrating into one:

code
GET    billing/teams/{team}                     TeamBillingController@show
PATCH  billing/teams/{team}/plan                EnterprisePlanController@update
PATCH  billing/teams/{team}/modules             TeamModuleController@update
POST   billing/teams/{team}/grace               TeamAccessController@extendGrace
POST   billing/teams/{team}/suspend             TeamAccessController@suspend
DELETE billing/teams/{team}/suspend             TeamAccessController@restore
GET    billing/teams/{team}/invoices/create     InvoiceController@create
POST   billing/teams/{team}/invoices            InvoiceController@store
POST   billing/invoices/{invoice}/issue         InvoiceController@issue
DELETE billing/invoices/{invoice}               InvoiceController@void
POST   billing/invoices/{invoice}/payments      PaymentController@store

All sit inside the existing EnsureSuperAdmin group. {team} binds by slug (Team's route key); {invoice} by id.

No controller writes a billing_* column directly. Each delegates to its Action. That is what keeps the BillingEvent audit trail and the entitlement-flush rule intact — the Actions are the only place either happens, and no Action performs authorization, so the middleware group is the whole access control story.

VoidInvoice, the eighth Action

app/Actions/Billing/VoidInvoice.php, built like its siblings: fast-path guard, DB::transaction(), a lockForUpdate() re-check on the locked row, then the write.

An invoice may be voided while draft or open, never once paid — reversing a settled invoice would leave recorded payments attached to a void invoice, which needs a refund concept this system does not have. A reason is required and is carried in the invoice.voided event payload: voiding is the one destructive act on this screen, and the audit trail is the only record of why.

This Action is also what makes 2a's two locked re-checks reachable rather than theoretical — until now nothing could void an invoice, so neither IssueInvoice's nor RecordPayment's guard against a concurrent void had a real counterpart.

The screen

resources/js/pages/superadmin/billing/teams/Show.vue — a single scrolling page of stacked Bootstrap cards, top to bottom:

  1. Plan summary — billing type, access state, renewal date, amount, seats used against cap, storage used against quota. Actions: Edit terms, Modules, Extend grace, and Suspend or Restore.
  2. Invoices — number, period, total, status, with Issue / Record payment / Void per row and a New invoice button.
  3. Payments — date, amount, method, reference, who recorded it.
  4. Usage and Activity side by side — usage grouped by service and billing period; activity is the billing_events timeline.

It follows superadmin/teams/Edit.vue exactly: <Form v-bind="update.form(...)"> for the panels that are forms, router.patch/post/delete with preserveScroll: true for row-level actions, and browser confirm() for suspend and void — the same guard confirmDelete() already uses there. No DataTables, no FilterDrawer.

Creating an invoice

Its own page (invoices/Create.vue), not a modal — it carries a repeatable line editor, and superadmin/teams/Create.vue already establishes the separate-create-page convention.

The form opens prefilled from the team's enterprise terms: one Module-kind line at the fixed amount, period_start set to the renewal date and period_end one interval later. The operator may edit, add or remove lines before saving. That covers the routine monthly invoice in one click, and it is the same shape 2d's billing:draft-enterprise-invoices command needs — so the prefill logic is written once here and reused there.

A credit note is the same form carrying a negative Adjustment line. There is no separate credit flow; this is what increment 1's signed invoice totals were for.

store writes the invoice and its lines in one transaction and computes total_cents and subtotal_cents from the lines server-side, never trusting a total from the payload.

Money

A new App\Support\Billing\Money with toCents(string $amount): int and format(int $cents, string $currency): string.

Operators type dollars; storage is integer cents. FormRequests convert on the way in, controllers format on the way out, and no Vue file performs arithmetic on money — each payload carries both amount_cents for logic and a preformatted display string. This is the single conversion point; adding a second one is how a 100x error ships.

Pagination

The page carries three independent paginators, each with its own page name — ->paginate(10, ['*'], 'invoices'), 'payments' and 'activity' respectively. Sharing the default page parameter would make paging one list reset the other two.

The usage panel is not paginated. It is a grouped aggregate over billing_usage_events by service and billing period. Nothing writes that table until increment 6, so it renders empty in production — but it is fully testable now by inserting rows directly, and is built and tested that way rather than shipped as a placeholder.

Testing

Per endpoint: a 403 for a non-superadmin, the happy path asserting the Action's effect, and the validation failures. Plus Inertia prop assertions on show for every card. No factories for billing models.

Tasks

Three, in order:

  1. VoidInvoice and Money. The eighth Action and the conversion/formatting helper. No UI.
  2. The read side. TeamBillingController@show, its route, the Show.vue page and all five cards, with the three paginators and the usage aggregate.
  3. The write side. The remaining ten endpoints, their FormRequests, and the invoice create page.

2c in detail — the Overview and the Teams list

2b shipped and merged on 2026-09-10. It gave an operator one team's whole billing picture; 2c gives them every team's. Both screens are read-only — 2c writes nothing.

The organizing principle: SQL narrows, PHP applies the rules

The Teams list needs per-team figures (seats used, storage percentage, outstanding balance) for 25 teams a page. Resolving them row by row through Entitlements::for() would cost a seat-count query per team plus a SUM per invoice — the N+1 increment 1's review already flagged. So the list is set-based.

That creates a drift risk: the list and the detail screen would compute the same numbers by two code paths. The design attacks it directly rather than hoping tests catch it — no billing rule gets a second implementation in SQL.

  • MRR is not SUM(CASE WHEN interval = 'yearly' THEN amount / 12 ...). It is a two-column select over the billable teams, summed in PHP through the existing BillingCycle::monthlyEquivalentCents(), which already owns the rounding rule.
  • "Over quota" is not billing_storage_used_bytes >= COALESCE(quota, GREATEST(1, seats) * 10737418240). SQL narrows to teams with billing_storage_used_bytes > 0 or an explicit quota of 0 — a team allowed no storage is over quota before it stores a byte — and PHP applies the rule to what comes back. Because the rule finishes in PHP, that panel is the one figure whose row count SQL cannot produce, so it streams: lazyById() in chunks, with a display buffer that never exceeds the five rows shown. Nothing populates that column until increment 3, but increment 3 populates it for every team that has ever uploaded a file, and this is the superadmin's landing page — it must never be able to hydrate the whole teams table.
  • The one rule both screens genuinely share — the 10 GB-per-seat fallback with its one-seat floor — is extracted into Entitlements::storageQuotaBytesFor(?int $explicitQuotaBytes, int $activeSeats): int, which Entitlements::resolve() then calls too. One implementation, two callers.

What is set-based is the cheap part: seat counts via withCount(['activeMembers as seats_used']), and outstanding invoices via a single eager load carrying each invoice's payments already summed. A page of 25 teams costs a handful of queries regardless of page size.

Corrected after 2c's final review. This section originally specified the outstanding balance as "two correlated subquery selects — open-invoice totals, and payments against open invoices — subtracted in PHP". That is one team-level subtraction, and it is a second implementation of a rule that is defined per invoice: Invoice::amountDueCents() clamps each invoice at zero. Netting the two totals instead made the Teams list report 750.00 for a team holding a1,000.00 open invoice and a $250.00 open credit note, while the team detail screen — reading the same two rows through the per-invoice rule — reported $1,000.00. Credit notes are a designed case here, not a hypothetical: invoice totals are signed precisely so a credit round-trips as one.

So the rule is extracted the same way the quota fallback was: Invoice::outstandingCents(int $totalCents, int $paidCents): int, which Invoice::amountDueCents() calls too. The list eager loads a team's outstanding invoices — narrowed by BillingInvoiceStatus::outstandingValues(), itself derived from isOutstanding() rather than naming open a second time — with withSum('payments as paid_cents', 'amount_cents'), and sums that one rule across them in PHP. Still one query for the whole page; no clamp is applied to the team-level total, because the per-invoice rule has already decided the answer.

Two known gaps this leaves, both deferred deliberately.

A team in credit is invisible. Because the rule clamps each invoice at zero, summing it can never produce a negative, so a team whose only open invoice is a $250.00 credit note reads $0.00 on both screens. That is consistent — which is the property 2c needed — but the console still cannot say a team is owed money. The fix is a separate "unapplied credit" figure, not a signed net: netting it back into the outstanding balance would recreate the exact disagreement this section exists to prevent. Whoever adds credit handling owns it.

Outstanding sums across currencies and formats as USD. TeamBillingList adds every outstanding invoice's cents together and hands the total to Money::format() with the default currency, so a team invoiced in two currencies reads a meaningless number. This predates 2c — the correlated subqueries it replaced summed the same way — and it is unreachable while every team is billed in one currency. The eager load already selects per-invoice rows, so the fix is to carry currency into that select and refuse to sum across currencies. Required before any team is invoiced in a second currency.

The defence against drift is a named test, test_the_list_and_the_entitlement_resolver_agree, which builds teams in several states (explicit quota, implicit quota, zero seats, suspended) and asserts the list's figures match Entitlements::for() team by team.

Query objects

app/Queries/Billing/, following the convention App\Queries\ProjectManagement\VisibleTasks sets — a static entry point returning a builder, and a docblock explaining what one implementation of the rules buys.

  • TeamBillingList — the paginated, searchable, filterable team query with its subquery selects. is_personal teams are excluded unless a filter asks for them.
  • BillingOverview — MRR, ARR, cash collected this month, usage billed this month, counts by access state, and the four at-risk lists.

Plus App\Support\Billing\Bytes::format(int $bytes): stringTeamBillingController's currently-private formatBytes(), promoted so both screens share it rather than duplicating it, which 2b's final review flagged as a forward risk.

Revenue

The console section above promises a "revenue this month" tile without defining it. It means cash collected: the sum of billing_payments.amount_cents whose paid_at falls in the current calendar month. That is the natural complement to MRR — MRR is the run-rate, this is what actually arrived — and it is unarguable in a way "invoiced" is not, since enterprise customers routinely pay late.

A usage billed this month tile ships alongside it, wired to billing_usage_events the same way 2b's usage panel is: real and fully testable now by inserting rows, and returning zero until increment 6 writes to that table.

Which organizations the Overview counts

Not asked for above, and done anyway: every figure on the Overview describes one population — commercial teams, meaning neither is_personal nor billing_type = internal. The money tiles, the counts-by-access-state card and all four at-risk panels narrow through the same BillingOverview::commercialTeams() and nothing else.

The reason is that the page pairs them visually. MRR sits directly above "Organizations by status", so scoping the money to commercial accounts while counting internal ones in the card beneath it would print "$0 contributed" and "Active: 14" side by side and invite the reader to relate them. Internal teams (demo, sales, QA, staging) never pay, so an operational queue that includes them is a queue of work nobody will do. The page states the scope in a line under its heading, because a scoping rule the reader cannot see is a rule they will misread.

This deliberately differs from the Teams list, which includes internal teams: that screen is a directory an operator searches and filters, not a set of figures to compare. Both hide personal teams.

Personal teams

is_personal teams are hidden from the billing Teams list by default, with a filter to show them. They are single-user artefacts of signup, never commercial accounts, and at one per user they would drown the real customers.

This deliberately diverges from /superadmin/teams, which includes them: that panel manages users, this one manages money. The divergence is the point, not an oversight.

Screens

Both under /superadmin/billing, inside the existing EnsureSuperAdmin group, following 2b's conventions exactly — Bootstrap cards, server-side paginate(), no DataTables, no FilterDrawer.

code
GET  billing              OverviewController@index         superadmin.billing.index
GET  billing/teams        TeamBillingListController@index  superadmin.billing.teams.index

Neither collides with 2b's GET billing/teams/{team}.

  • Overview — tiles for MRR, ARR, cash collected this month and usage billed this month; a count per access state; and four at-risk lists, each capped at five rows with a total count: past-due teams, grace windows ending within 7 days, teams over quota, and enterprise renewals due within 30 days.
  • Teams — billing type, access state, seats used against cap, modules held, monthly value, next renewal, storage percentage and outstanding balance. Search by name; filter by billing type, access state, and whether to include personal teams.

Every at-risk row and every Teams row links to the 2b detail screen. That is what makes the console navigable for the first time — until now the detail screen was reachable only from the team edit page. The superadmin hub page gains a Billing card.

The deferred N+1 stays deferred

Invoice::amountPaidCents() re-queries per call, which 2b's review flagged as biting here. The list does not call it — the outstanding-balance subqueries make it irrelevant — so it is left alone rather than half-fixed. 2a's documented rationale for it ("a financial figure that cannot go stale") stays intact for the detail screen, and one per-invoice query on a ten-row page is not worth changing those semantics for.

Testing

403 for a non-superadmin on both routes; Inertia prop assertions per tile and per column; search and each filter; personal teams hidden by default and visible when filtered; the agreement test above; and a query-count test using DB::enableQueryLog() proving the list's query count does not grow with the number of rows.

Tasks

Three, in order:

  1. Shared rules. Entitlements::storageQuotaBytesFor(), Bytes::format(), and TeamBillingList. No UI.
  2. The Teams list. Controller, route, page, search and filters.
  3. The Overview. BillingOverview, controller, route, dashboard page, and the hub card.

2d in detail — Rates and the drafting command

2d closes increment 2. It is two unrelated pieces of work that share a sub-increment only because each is too small to ship alone: the Rates screen, which prices the services increment 6 will meter, and billing:draft-enterprise-invoices, which spares a superadmin from remembering that Acme renews on the 14th.

Neither is reachable from a customer. Nothing outside the console reads a rate until increment 6, and the command writes drafts a human still has to issue.

The precision problem, and the answer

billing_usage_rates.unit_amount_cents cannot hold the prices we intend to charge. SMS at $0.045 per message is four and a half cents; an integer number of cents cannot express it. Tokens are worse — $3.00 per million input tokens is 0.0003 of a cent each.

Rates are stored in millicents: thousandths of a cent. The column is renamed unit_amount_millicents. The table is empty and nothing reads it, so this is a rename migration and not a data migration.

This is the one place in the subsystem where an integer is not cents, and that is a real hazard: Money::format(4500) prints $45.00, while the same 4500 as a rate means $0.045. Three things guard it, and all three are load-bearing.

  1. The column name carries the unit. Never amount, never unit_amount_cents. Every call site reads unit_amount_millicents.
  2. App\Support\Billing\Rate mirrors Money exactlytoMillicents(string), toInput(int), format(int) — parsing decimal strings rather than multiplying floats, for the same reason Money does: 0.045 * 1000 is not 45 in IEEE 754. Rate::format() prints the significant decimals a rate needs; Money::format() always prints two.
  3. A unit test pins the two against each other: Rate::format(4500) is $0.045 and Money::format(4500) is $45.00, asserted side by side in one test, so that anyone "correcting" one of them into the other has to delete an assertion that says in words why they differ.

Units and block sizes

Millicents make SMS exact but still cannot price a single token, so the unit carries a block size. App\Enums\UsageUnit:

Case Value Block A rate of 4,500 millicents reads
Message message 1 $0.045 per message
ThousandMessages 1k_messages 1,000 $0.045 per 1,000 messages
MillionTokens 1m_tokens 1,000,000 $0.045 per 1M tokens

App\Enums\UsageService names what is metered and which unit it is quoted in, so a rate row cannot pair SMS with a token unit:

Case Value Unit Seeded default
Sms sms message $0.045 per message
AiTokensInput ai_tokens_input 1m_tokens $3.00 per 1M tokens
AiTokensOutput ai_tokens_output 1m_tokens $12.00 per 1M tokens

Input and output are separate services rather than one blended rate because every model provider bills us that way. A blended price would be a guess at the mix, and a customer whose usage skews to output would quietly become unprofitable.

The seeded figures are placeholders in the sense that a superadmin can change them on the screen without a deploy — but they are seeded as real, resolvable rates, not zeros, so increment 6 has something to meter against on day one.

billing_usage_rates.unit still stores the unit string rather than deriving it from the service at read time. The rate row is what a usage event denormalizes, and a service that is ever re-quoted in a different unit must not silently restate history.

What a line costs

A usage line's total, in cents, from a quantity and a rate:

code
numerator   = quantity * unit_amount_millicents
denominator = block * 1000
cents       = intdiv(numerator * 2 + denominator, denominator * 2)

Integer arithmetic throughout — the doubling is round-half-up without touching a float. 2,500 messages at 4,500 millicents per message is 11,250 cents, $112.50. 250,000 input tokens at 300,000 millicents per million is 75 cents.

Rounding happens once, at the line, never per event. Rounding each of 2,500 SMS events to the nearest cent would bill 25.00 for112.50 of traffic. This rule lives in Rate beside the formatting, and increment 6 calls it rather than restating it.

Changing a rate

Rates are append-only and effective-dated. UsageRate::resolve() already shipped in increment 1 and takes the most recent row effective on or before a given moment, so a change is a new row, not an edit — and a change can be scheduled ahead of the day it takes effect.

  • effective_from may not be in the past. Backdating would change what resolve() answers for a moment that has already passed. Recorded usage is safe regardless, because events denormalize their rate, but the screen would then disagree with the ledger about what August cost.
  • A row already in force is immutable. Superseding it is what the new row is for.
  • A future-dated row that has not taken effect may be edited or deleted, because it has priced nothing yet. This is the only delete in the subsystem, and it is safe precisely because nothing can have resolved to it.
  • The unique index on (service, team_id, effective_from) means two changes to the same rate on the same date collide. The screen reports that as a validation error against the date field, not a 500.

Per-team overrides

A row with a team_id is that team's negotiated price, and resolve() prefers it over the platform default. Overrides obey the same rules as defaults — appended, effective-dated, immutable once in force.

An override is scoped to one service. A team with a negotiated SMS price and no token price resolves SMS to its own row and tokens to the platform default, which is the behaviour resolve() already implements and which the screen must not appear to contradict: a team's override list shows only what that team actually overrides.

The Rates screen

/superadmin/billing/rates, inside EnsureSuperAdmin like every other console route. One page, two cards, matching the conventions 2b set.

code
┌─ Platform defaults ─────────────────────────────────────┐
│ SMS           $0.045 / message      since 1 Jan 2026    │
│               $0.050 / message      from 1 Nov 2026  ⏳ │
│ AI input      $3.00 / 1M tokens     since 1 Jan 2026    │
│ AI output     $12.00 / 1M tokens    since 1 Jan 2026    │
│                                        [Change a rate]  │
└─────────────────────────────────────────────────────────┘
┌─ Team overrides ────────────────────────────────────────┐
│ Acme Logistics   SMS   $0.040 / message  since 1 Feb    │
│                                         [Add override]  │
└─────────────────────────────────────────────────────────┘

Each service shows the rate in force and any scheduled successor, marked as scheduled with its start date. History older than the rate in force is not shown; it is one query away and no operator has asked to see it.

The .vue files format nothing. Rate::format() and the unit label arrive from the server as strings, for the same reason money does everywhere else in this console.

Actions

Two, following the shape 2a set — one public handle(), abort_if() guards, DB::transaction(), a docblock citing this section.

  • SaveUsageRate — appends a rate row for a service, optional team, amount and effective date. Rejects a past effective_from, an amount that is not a plain decimal, and a service/unit mismatch.
  • DeleteScheduledRate — removes a future-dated row. Re-reads the row under lockForUpdate() and re-checks that it is still in the future, because a row that took effect between the click and the write is no longer deletable.

Neither writes a billing_* column on teams, so neither flushes Entitlements.

billing:draft-enterprise-invoices

The rules are stated under "Enterprise invoicing" above. What 2d adds is where the logic lives.

2b deliberately left InvoiceController::draftLinesFor() and periodFor() public and static so this command could reuse them rather than re-derive the rules. Reuse is right; the direction is not — a console command must not depend on an HTTP controller. Both move to App\Support\Billing\EnterpriseDraft, and the controller becomes a caller like the command. They move rather than delegate: a forwarding static left behind would be a second name for one rule, which is the defect this subsystem keeps finding.

The command creates its drafts through the existing CreateInvoice action rather than writing rows itself, so a drafted invoice is indistinguishable from a hand-made one. CreateInvoice currently requires a User actor and reads $actor->id; after 2d the actor is nullable and it reads $actor?->id. BillingEvent::record() already accepts a null actor id, so the only other change is the event payload, which names the command as the origin — an invoice nobody can be shown to have created must still say what made it.

  • --dry-run prints the drafts it would create and writes nothing. A command that creates money documents on a schedule earns a way to be run and read first.
  • --days=30 overrides the window, for testing and for a one-off catch-up.
  • Output is a table of team, renewal date, period, amount, and outcome — created, or skipped with the reason. A run that creates nothing says so.
  • Scheduled ->daily() in routes/console.php, alongside the existing entries.

Idempotency is the property worth testing hardest: running it thirty days running must produce one draft per team, not thirty. The test runs it twice on the same day and once on each of three consecutive days, and asserts one invoice.

Sequence

Three tasks, each independently shippable:

  1. Rate foundations. UsageService and UsageUnit enums, the millicents rename migration, Rate, the UsageRate casts and seeder, SaveUsageRate and DeleteScheduledRate. No UI.
  2. The Rates screen. Controller, routes, page, types, and the two write endpoints.
  3. The command. EnterpriseDraft extraction, the nullable actor on CreateInvoice, billing:draft-enterprise-invoices, and the schedule entry.

What 2d does not do

Nothing meters anything. No rate is read outside the console, no usage event is written, and no draft is issued, sent, or charged. 2d makes the price list real and the monthly draft automatic; increments 4 and 6 are what make either of them move money.

Carried into later increments

From increment 1's review, still open and belonging elsewhere:

  • Invoice::amountPaidCents() re-queries rather than reading a loaded relation — a latent N+1 the moment the console renders an invoice list. Watch it in 2b/2c.
  • Three reachable Invoice classes (Billing, Enterprise, Cashier) and two Payment. The console imports two of them; alias at the import site rather than renaming models.
  • billing_team_modules has no uniqueness constraint, so two overlapping active holdings of the same product are possible. Harmless for entitlements, but it would mean a double Stripe subscription item — increment 4 enforces the invariant in its add/drop actions.
  • Entitlements::$resolved and Product::$specializedModuleKeys are process-static. Correct under php-fpm, stale under Octane or in a long-lived queue worker.

From 3b's final review:

  • The module guard covers two route files; the same modules are reachable through three more. The spec scopes EnsureModuleEntitled to routes/web/logistics3p.php and routes/web/containers.php, and that is what shipped. But routes/web/reports.php carries reports/logistics3p/* and reports/containers/* — which HandleInertiaRequests hides by the same module key — and routes/api/v1/logistics3p.php / routes/api/v1/containers.php carry the entire v1 surface for both modules, mutations included. None of these has been decided against; they were simply out of 3b's scope. Whoever widens the guard should decide the API surface deliberately, since an API client is exactly who bookmarks a URL.

  • An unrecognised module key fails OPEN for any team with no module restriction — which today is every enterprise team, since allowedForTeam() returns null for them. So a typo'd key in a route guard is a silent no-op across the current customer base rather than a loud failure. The keys in use are verified against BillingProductsSeeder; anything added later needs the same check.

  • Invite-time seat counting takes no row lock. Two concurrent invitation batches can over-promise seats. Deliberate: the acceptance path locks, so a team cannot exceed its cap in occupied seats — only in outstanding promises, which resolve themselves as invitations are accepted or expire.

  • A per-user module override hides a menu item without closing the URL. The guard enforces Modules::isAllowedForTeam() (team level), not isVisibleForUser() (which lets a per-user permission override win). Deliberate — a per-user override is authorization rather than entitlement, and isVisibleForUser() has exactly one caller, sidebar rendering. Per-user route enforcement, if ever wanted, belongs in a separately named policy.

  • Entitlement::allowsModule() now has no callers. It answers the billing half of the module question only, which was a real defect when the guard briefly used it. Left in place, but a caller reaching for it almost certainly wants Modules::isAllowedForTeam().

  • UserInvitationController::resend() 500s on a soft-deleted team ($invitation->team is null) rather than refusing cleanly. It writes nothing, so it is ugly rather than dangerous.

From 3a's final review:

  • A team keeps access for up to 24 hours after its grace expires. The middleware never reads billing_grace_ends_at; only the daily billing:apply-access-expiry command does. All three enforcement surfaces agree, so this is coherent rather than a disagreement — and having the middleware evaluate dates too would be a second implementation of the transition the command owns. Deliberate. Do not "tighten" it into a duplicated rule.

  • Entitlements::resolve() defaults an absent billing_access_state to Active — a fail-open. Failing closed would lock out every caller that hands Entitlements a partially-selected Team, which is a worse failure than the one it prevents. Any caller passing a partial select must select that column.

  • The superadmin passthrough in EnsureTeamNotRestricted is total, not scoped to /superadmin/*: a superadmin is never restricted, anywhere. Intentional — restriction must not lock out the only person who can lift it — and it does not leak through impersonation, because the impersonated user is the one authenticated. Deliberately broader than the spec asked for.

  • An entitlement resolve (~4 queries, memoised per request) now runs on every api request. Fine today; revisit in increment 4, when self-serve teams multiply that traffic.

  • resources/js/lib/resolveLayout.test.ts is the only guard on the auth-shell layout fix, and Vitest does not run in CI. That fix is what keeps the restricted and access-removed pages out of the full application chrome. Until Vitest is wired into CI, it can regress silently.

  • Pre-existing, surfaced here, not fixed: a superadmin impersonating a user with must_change_password cannot reach superadmin.impersonate.stop, so they cannot stop impersonating. Unrelated to billing; worth its own ticket.

From 2d's final review:

  • Nothing advances billing_renewal_at. billing:draft-enterprise-invoices drafts a team's renewal once and then never drafts the following period, because the renewal date never moves and the idempotency check keeps finding that period's invoice. Only SaveEnterprisePlan writes the column, so today an operator must edit the plan to get the next draft. Advancing it when a draft is created would be wrong — a draft can be voided — so the write belongs with whatever issues or settles the invoice, in the subscription lifecycle of increments 4/5. Until then the command is one-shot per renewal date, which is safe but incomplete.

  • The command deliberately has no lower bound on billing_renewal_at. The prose above says "within the next 30 days"; the query matches any renewal at or before the horizon, including one already past. That is intentional and pinned by a test: a renewal that was missed still gets drafted, because silence about a missed renewal is this command's worst failure mode — it produces no error, no output and no invoice. Do not "fix" the code to match the narrower wording.

  • Rate::lineCents() takes an int quantity, but billing_usage_events.quantity is decimal(20, 6). Increment 6 must widen it when it knows what a metered quantity actually looks like; doing so now, with no consumer, would be guesswork.

  • The Rates screen's team picker silently truncates at 200 teams. It excludes personal teams and bounds the query, but a plain <select> with no count and no search means that past roughly that many commercial teams an operator cannot add an override for a team and is given no sign that anything was omitted. The exposure is an operator who cannot act, never a wrong price. The cheap improvement is to ship the unfiltered count so the control can say "first 200 of N"; the real fix is a searchable picker, which is a screen change.

  • How the Rates screen stays honest about "in force". The screen narrows in SQL (whereNotExists a newer row already effective) and classifies in PHP (partition()). It is worth being precise about why that is not the rule implemented twice: PHP can re-classify rows it receives, but it cannot resurrect rows SQL dropped — remove the narrowing's effective_from <= now guard and the in-force row never reaches partition() at all. What keeps the two in agreement is the test surface, not PHP's ability to correct SQL: one test asserts UsageRate::resolve()'s answer and the screen's together so they cannot drift, and the narrowing itself is pinned by a hydration count. Any change to either side must keep those.

From 2d's Task 3 review:

  • CreateInvoice computes a line total in floating point. (int) round(((float) $line['quantity']) * $unit) casts a decimal quantity to a float and multiplies it by integer cents, which this subsystem's own "integers, never floats" rule forbids. It is not a live money bug: every quantity written so far is '1', and the round() already defends the 1.15 * 100 = 114.999… case Money's docblock warns about. It is purity debt with a known expiry — the first non-unit quantity arrives with usage metering, where a line reads "2,500 messages" and the float becomes load-bearing. Fixing it means scaled-integer or bcmath arithmetic and re-testing 2b's line rule, so it belongs to increment 6, with the code that first depends on it.

From 2d's Task 2 review:

  • Deleting a team orphans its billing rows. None of billing_team_modules, billing_invoices, billing_usage_events or billing_usage_rates constrains team_id with a foreign key, and SuperAdmin\TeamController::destroy() hard-deletes a team without touching any of them. Orphaned rows are reachable state today, not a hypothetical — an invoice whose team no longer exists is a financial record with nothing to attribute it to. 2d matched the existing convention rather than diverging from its three siblings in one table; the fix belongs to whichever increment decides the policy, and it is a policy question before it is a schema one: billing history for a deleted team should almost certainly be retained and re-pointed, not cascaded away. See also the deferred "data retention and deletion policy" item below.

Increment 3 in detail — enforcement

Increments 1 and 2 built the rules and the console that sets them. Nothing enforces any of it: a restricted team's members still reach every page, a team past its seat cap still invites, and storage is a number on a dashboard. Increment 3 is where billing state starts saying no.

It moves no money and touches no Stripe. Every decision it makes is read from columns increment 1 added and increment 2's console already writes, which is why it can be verified end to end against enterprise and suspended teams before a single card is charged. That property is the reason the spec's sequence stops to reassess after this increment.

Most of the rules already exist. Entitlement::hasSeatCapacity(), Entitlements::storageQuotaBytesFor(), BillingAccessState::grantsAppAccess() and Modules::allowedForTeam() all shipped in increments 1 and 2. Increment 3 is mostly about calling them at the right places — which makes "no billing rule gets a second implementation" the constraint that matters most here. Every temptation in this increment is to re-derive a rule at a call site because the existing one is a function call away.

The trigger

The state machine says past_due → restricted when billing_grace_ends_at passes, and cancelled → restricted at period end. The original sequence put both in increment 5 with dunning. That leaves increment 3 shipping a restriction flow whose only reachable trigger is a superadmin manually suspending a team — the centrepiece tested against everything except the thing that actually causes it.

So the transition moves here. billing:apply-access-expiry runs daily and makes both moves, because they are the same move: a date has passed and the state that date governed is over.

  • past_due whose billing_grace_ends_at is in the past → restricted
  • cancelled whose paid period has ended → restricted

It needs no Stripe, sends no email and retries nothing — those are increment 5's. It reads two dates and writes one column, through an Action so the BillingEvent trail matches every other state change in the subsystem. Enterprise teams are unaffected: they sit in active and only a superadmin moves them.

Restriction

EnsureTeamNotRestricted on the web module route groups and the API group. The decision is Entitlements::for($team)->grantsAppAccess() — not a list of states re-spelled in the middleware, because BillingAccessState::grantsAppAccess() already owns that question and a second copy is how suspended eventually gets forgotten in one of them.

Allowlisted: logout, the billing recovery routes, and the restricted page itself. A restricted team must always be able to leave, pay, or see why.

  • MembersBilling/Restricted.vue, modelled on the existing AccessRemoved.vue: the organization's account is restricted, contact an administrator. No figures, no invoice totals — a member cannot act on them and they are not that member's business.
  • Owner and AdminBilling/Recover.vue: update the payment method, view outstanding invoices, pay now. Nothing else in the application is reachable.
  • API (Passport) → 402 Payment Required with a JSON body, never a redirect. An integration that follows a 302 to an HTML page fails in a way nobody can debug.

Owner and Admin (TeamRole::Owner, TeamRole::Admin) are the roles that may manage billing normally, so they are the same two that see Recover.vue. That is one rule, not two.

Scheduled work for a restricted team

Restriction blocks people. Left alone, it does not block the scheduler — so a team restricted for three weeks returns to three weeks of auto-generated recurring tasks and snapshots nobody could have acted on, and the first thing they see after paying is a backlog they did not create.

The rule: a scheduled job that creates domain records for a team skips restricted teams. Jobs that release, expire, clean up, or serve billing and recovery keep running. Releasing is not creating — it frees something the team is not using, and a restricted team holding stock reservations forever punishes everyone else.

Applied to what exists today:

Command Creates records? Restricted team
tasks:generate-recurring-occurrences yes skipped
octo:sales-capture-opportunity-snapshots yes skipped
octo:sales-expire-quotations no — a state change runs
octo:sales-release-expired-reservations no — frees stock runs
notifications:send-digests no — and silencing it would silence the mail that prompts someone to pay runs
billing:draft-enterprise-invoices billing runs
the TeamInvitation expiry sweep cleanup runs

Each skipping command narrows with one shared scope, not its own state list: Team::scopeWithAppAccess(), built from BillingAccessState::grantingAppAccess(). When a future state is added, one place changes. A command that cannot reach a team through that scope — because it starts from a record rather than a team — resolves the team and asks Entitlements.

Seat cap

Entitlement::hasSeatCapacity() exists and is correct. Increment 3 calls it in two places, and both are necessary:

  1. Sending an invitation (App\Actions\Users\InviteTeamUsers) — the obvious one.
  2. Accepting one — the one that is easy to miss. An invitation sent while a team had room can be accepted days later, after the last seat went. Checking only at send time means the cap is advisory: a team at 25 of 25 with three outstanding invitations quietly becomes 28.

Blocked with a clear message naming what to do — contact the platform, since a seat cap is contractual and a team admin cannot raise their own. Only enterprise teams have a cap today (billing_seat_cap is null otherwise, and hasSeatCapacity() already returns true for null), so self-serve teams are unaffected until increment 4 gives them a seat price.

Module entitlement

Modules::allowedForTeam() already intersects a team's configuration with its billing entitlement, so the sidebar has hidden unentitled modules since increment 1. What is missing is the door behind the hidden menu item: a EnsureModuleEntitled:{key} middleware on routes/web/logistics3p.php and routes/web/containers.php, so a bookmarked URL is refused the same way the menu refuses it.

This is defence in depth, not a new rule — the middleware asks Modules::allowedForTeam() the same question the menu asks. It must not grow its own notion of entitlement.

Storage quota

Quota comes from Entitlements::storageQuotaBytesFor(), which already handles the enterprise override and the 10 GB × active-seats fallback with its max(1, …) floor.

StorageQuota::assertCanStore(Team $team, int $bytes) at the four sites that write fileSize today — App\Support\Uploads::… and the three in Api\V1\Files\UploadController. Four sites, one rule; nothing at a call site decides for itself what over quota means.

The unit seam is the hazard here. uploads.fileSize is kilobytes, stored as a 2dp float; teams.billing_storage_quota_bytes and billing_storage_used_bytes are bytes, stored as integers. StorageQuota owns that conversion and is the only place it happens — the same discipline Money and Rate enforce for currency. Bytes are not money, so rounding to the nearest byte on conversion is fine; silently mixing the units is not.

Usage is maintained two ways, as specified above: incremented at each upload, and reconciled nightly by billing:recalculate-storage, a MongoDB aggregate summing fileSize grouped by oid, excluding soft-deleted rows. The nightly job is authoritative and the increment keeps the figure fresh between runs, so an increment that drifts is corrected within a day rather than compounding.

This job reads MongoDB and writes MySQL. There is no transaction spanning both, and it must not pretend otherwise: it computes per team, then writes each team's total on its own. A partial run leaves some teams reconciled and some not, which is exactly what the next run fixes.

Over quota blocks new uploads only. Existing files stay readable and the rest of the application works normally. A team that goes over quota by removing users is not otherwise punished.

The per-file upload limit

Separate from the quota, and not a billing rule: no single file may exceed 1 GB, anywhere a user can upload one. The quota is cumulative and tolerates one overshoot; this is a hard ceiling on an individual file, and it applies to every team regardless of plan.

One definition, used two ways:

  • A shared validation rule at every upload entry point, so the person gets an error against the field they used. There are ten such points today — the Files/Upload API's four, the four container Excel imports, the project-management task attachment, and the system module upload.
  • A global middleware backstop, refusing any request carrying an oversized file regardless of route. Eleven entry points guarded one at a time is how a twelfth ends up unlimited; the backstop means a new upload path is covered by default rather than by memory. This is the same reasoning that put EnsureTeamNotRestricted on the global stack rather than on 36 route files.

The limit is not currently reachable, and that must not be hidden. PHP's own upload_max_filesize and post_max_size cap uploads at 20 MB in development, so a larger file is discarded before Laravel sees it — arriving as an empty $_FILES and reading, unhelpfully, as "the file field is required". The application therefore detects that case explicitly and reports too large rather than missing, so the message is truthful whatever the server is configured to allow.

Reaching 1 GB needs, per environment: upload_max_filesize = 1G, post_max_size at or above it, and the web server's own body limit (client_max_body_size on nginx). Those are deployment changes, not application ones. Until they are made the effective limit is whatever PHP allows, and the application will say so accurately.

The banner

A shared Inertia prop, rendered globally in AppLayout — at 80% a warning, at 95% a stronger one, dismissible for the session. Being near quota is an organization-wide condition, and the admin who can act on it is rarely on an upload screen when it matters.

The prop carries formatted strings and a level, never raw numbers for the frontend to divide: Bytes::format() and the percentage are computed server-side, exactly as every money figure in this subsystem is. No .vue file computes a percentage of a quota.

It is a lazy prop. A closure that runs only when the page needs it keeps the cost off every request that does not render a layout.

Sub-increments

Three, each independently shippable, each getting its own plan document.

  1. 3a — access. EnsureTeamNotRestricted, Restricted.vue, Recover.vue, the API 402, billing:apply-access-expiry with its Action, and the scheduled-work rule with its shared scope.
  2. 3b — seats and modules. hasSeatCapacity() at the invite and acceptance paths, EnsureModuleEntitled on the two module route files.
  3. 3c — storage. StorageQuota::assertCanStore() at the four upload sites, billing:recalculate-storage, and the global banner.

What increment 3 does not do

No retries, no dunning email, no card form, no Stripe call. A team reaches restricted only by a date passing or a superadmin acting, and leaves it only by a superadmin acting — because paying is increment 4's, and Recover.vue ships with its payment controls inert, showing outstanding invoices and telling the admin how to settle them offline. That is the honest state of the system until self-serve billing exists, and a button that appears to take a card and does not would be worse than none.

Increment 4 in detail — self-serve subscription

Increments 1 to 3 built the rules, the console that sets them and the enforcement that applies them — all provable against a local database, none of it touching money. Increment 4 is where a customer's card is charged, and that changes what "correct" costs to establish: from here, behaviour depends on a system we do not control.

What is already in place

  • Cashier v16 with Team as the Billable customer (useCustomerModel), the subscriptions and subscription_items tables, and the customer columns (stripe_id, pm_type, pm_last_four, trial_ends_at) on teams.
  • Cashier::ignoreRoutes() in AppServiceProvider::register(). Increment 1 turned Cashier's own routes off because POST stripe/webhook would otherwise have been a public unauthenticated endpoint before anything was ready to receive it. Removing that line is the moment the endpoint goes live, and it belongs to 4a alone.
  • The Stripe price catalog in config/billing.php, env-driven because Price ids differ between test and live mode. Six prices: a seat at 10/month and100/year, and each specialized module at 750/month and7,500/year. Yearly is ten times monthly throughout — the "two months free" of the commercial rules.
  • billing_events.stripe_event_id, unique, already documented as the webhook idempotency key.
  • The PaymentGateway seam described under "Testing": a narrow interface over the calls actually made, with a fake for feature tests and a separate contract-test group against Stripe test mode.

Outstanding, and required before 4a can be verified end to end: STRIPE_WEBHOOK_SECRET per environment, and a webhook endpoint registered at /stripe/webhook. Local verification uses stripe listen, which issues its own temporary secret distinct from any deployed one.

Per-module prices, and the constraint that forced them

Each specialized module has its own Stripe Price rather than sharing one $750 price. Stripe rejects a subscription containing two items with the same price, so a team holding both modules would otherwise be unrepresentable — or collapsed into a single item with quantity 2, which no invoice line could name. Distinct prices mean a customer reads "3PL 750" and "Container Yard750" and can check the bill against what they use.

The shape of a subscription

One Stripe subscription per team, Cashier type default, carrying:

  • one seat item, quantity = the team's active member count;
  • one module item per specialized module the team holds, each quantity 1.

Every price in one Stripe subscription must share a billing interval. A monthly seat alongside a yearly module is not a thing Stripe will create. So teams.billing_cycle is the team-level choice it already looks like: a team is monthly or yearly, and every item uses that interval's price. Changing cycle means replacing every item, which is why the spec defers mid-term switching (see "Deferred, deliberately").

The trial

30 days, card captured up front — the commercial rules' choice, and the reason the trial is created through Stripe (trial_period_days) rather than tracked locally. A trialing team is fully functional: BillingAccessState::Trialing already grants access.

Capturing the card up front means the transition out of trial is a payment attempt Stripe makes on its own schedule, and we learn the outcome by webhook. Nothing local counts down.

Stripe status is not our access state

Stripe's status and BillingAccessState are different vocabularies, and the mapping is a rule — so it gets exactly one implementation, beside the state machine it feeds:

Stripe subscription status billing_access_state
trialing trialing
active active
past_due past_due — and billing_grace_ends_at set to now + 21 days only if not already set
unpaid restricted
canceled cancelled, with billing_current_period_ends_at from the subscription
incomplete unchanged — the first payment has not resolved; nothing has been granted or lost
incomplete_expired restricted
paused restricted

Two rules govern this table and matter more than the rows:

  • A superadmin's suspended always wins. No webhook may lift a suspension; the mapping is skipped entirely for a suspended team. Increment 3 made the same decision for the expiry sweep, and for the same reason.
  • Grace is set once. past_due arriving twice must not extend the window, or a customer failing payment repeatedly never reaches restriction. Increment 3's ExtendGrace remains the only deliberate way to move that date.

Webhooks

The endpoint is Cashier's, re-enabled: POST /stripe/webhook, signature-verified, outside auth and outside EnsureTeamNotRestricted (a restricted team's payment succeeding is precisely the event that must get through).

Handled events, and what each is for:

Event Effect
customer.subscription.created / .updated / .deleted Cashier syncs its own tables; we then apply the status mapping above
invoice.payment_succeeded active, and clear billing_grace_ends_at
invoice.payment_failed past_due with the grace window (increment 5 does the retrying)
invoice.payment_action_required Cashier's own handling; the customer is emailed by Stripe
customer.updated / .deleted, payment_method.automatically_updated Cashier's own handling
invoice.created increment 6 pushes closed-period usage onto it

Every handler is idempotent, keyed on billing_events.stripe_event_id. Stripe retries on any non-2xx and may deliver out of order; a handler that runs twice must leave the same state. The unique index is what enforces this rather than a check-then-act, for the same reason increment 2's number sequence uses insertOrIgnore.

Order is not guaranteed. A handler must not assume an earlier event arrived; it reads the current subscription state from the payload rather than inferring it from history.

The MRR correction, carried from 2c

BillingOverview::mrrCents() counts only enterprise amounts, because no self-serve price was reachable when it was written. The moment subscriptions exist it under-reports every one of them, silently, with no failing test. Increment 4 widens it and adds a test that a self-serve team contributes to MRR — recorded against this increment since 2c's review and repeated here because it is the one defect in this subsystem that shipping increment 4 would activate rather than introduce.

Sub-increments

Six, each independently shippable, each getting its own plan document.

  1. 4a — card and subscription for an existing team. The PaymentGateway interface and its fake, the seeder reading config/billing.php, payment-method capture, subscription creation with the trial, the webhook endpoint and the status mapping. No new signup surface: an existing team's owner adds a card and subscribes. This proves the money path end to end, and everything after it builds on a mechanism already known to work.
  2. 4b — public signup. The funnel that creates a team already on a plan: account, team, cycle and module selection, card, trial. (Shipped. Its original note — that the existing authenticated team-creation flow stays untouched and keeps producing enterprise teams — was corrected by 4f, which found that flow was the hole this increment's fail-closed default exists to prevent.)
  3. 4c — seat sync. Active member count to subscription quantity, prorated and charged immediately on addition. Increment 3's seat cap and this are the two halves of a seat: one refuses, the other bills.
  4. 4d — module add and drop. Adding a module adds its item with immediate proration; dropping removes the item at drop time with no proration (nothing credited, nothing billed next period) while access runs to period end, which is why billing_team_modules records an ends_at rather than deleting.
  5. 4e — the team-facing billing page. What a customer sees: plan, card, invoices, next charge. Everything before it is reachable only by a superadmin or a webhook.
  6. 4f — enterprise by invitation only. Every door a user can create a team through produces a self-serve team that starts restricted; only the superadmin console creates an enterprise one. Found during 4b's review, and it ships next, before 4c — until it does, the fail-closed default 4b built sits one route away from a door handing out the opposite.

What increment 4 does not do

No dunning — retries, dunning email and admin grace extension are increment 5, and Stripe's own automatic retries must be disabled in the dashboard before that increment ships, or attempts double up. No usage billing. No mid-term cycle switching. No cancellation flow beyond what the status mapping records.

Increment 4f in detail — enterprise by invitation only

Discovered during 4b's whole-branch review, and it undoes much of what increments 1-4 were for, so it ships next, before 4c.

The hole

Three doors create teams, and until now only one of them was thought about commercially:

Door Creates Billing shape
Teams\TeamController::store() — Settings → Teams a non-personal team column defaults: enterprise / active
CreateNewUser — Fortify registration, no invitation a personal team column defaults: enterprise / active
SuperAdmin\TeamController::store() a non-personal team column defaults: enterprise / active

Entitlements::allowedModuleKeys() returns null for an enterprise team, which means "billing imposes no restriction" — the module set of an enterprise team is administered directly through Team::$modulesAllowed, because enterprise terms are negotiated offline. That is correct for a real enterprise account and catastrophic as a default for one anybody can mint.

And Features::registration() is enabled, so the second door is open to the public. The fail-closed default 4b built for public signup sat one route away from a door that handed out the opposite.

This was not a regression introduced by any increment. billing_type defaulted to enterprise so that the teams already in the database migrated correctly (increment 1), and nothing since changed what a new team gets.

The rule

Only a superadmin creates an enterprise team. Every team a user creates for themselves is self-serve, and starts without access until it is paid for.

That is one rule with three consequences, one per door:

  1. Settings → Teams creates the team self_serve / restricted and sends the creator to billing.subscribe, exactly as 4b's public signup does.
  2. Registration without an invitation does the same with the personal team it creates. Registering and looking around for free is no longer a thing that happens.
  3. The superadmin console keeps creating enterprise / active, and is now the only thing that can. It should say so where it is doing it, rather than relying on a column default to carry the meaning.

Registration with an invitation is untouched. Accepting an invitation creates no team — the invitee joins one that already exists and is already paid for, under increment 3's seat cap. That path keeps working exactly as it does today, and it is the answer to "how does my colleague get in without a second card".

Why the card cannot come first

The same constraint 4b documented. A card field needs a Stripe SetupIntent, a SetupIntent needs a customer, and the customer is the team — so the team must exist before any card can render. "You need billing information to create a team" is therefore implemented as: the team is created, and it is worth nothing until a card is on it. The team row exists; the access does not.

Grandfathering

No backfill. The change is confined to the three creation paths; every team already in the database keeps the access it has. A migration that restricted existing teams could lock out a real customer whose terms were agreed offline and never recorded in billing_enterprise_amount_cents, and that risk is not worth the revenue it would recover. Converting an existing team is a deliberate act, done from the superadmin console one team at a time.

This means the database will hold two populations that look alike — teams on the old default and real enterprise accounts — and nothing distinguishes them automatically. That is the accepted cost of not locking anybody out.

What 4f does not do

It does not disable public registration; registering is still free, it just no longer comes with an unrestricted organization. It does not add a trial or a look-around grace period — a team without a card has no access, which is increment 3's existing restricted, not a new state. It does not backfill, report on, or convert existing teams. It does not change the seat cap, the invitation flow, or anything a paid team experiences.

Increment 5 in detail — dunning

Increments 1 to 4 built the rules, the console, the enforcement, and the money path. What none of them built is the answer to the question a subscription business is actually run on: what happens when the card fails? Today the webhook moves a team to past_due, the daily sweep restricts it 21 days later, and in between nothing is attempted, nobody is told, and the customer has no button to press. Increment 5 is those three things.

What is already in place

  • The access state machine and its transitions: invoice.payment_failed → past_due with billing_grace_ends_at = now + 21 days (webhook), past_due → restricted when that date passes and cancelled → restricted at period end (billing:apply-access-expiry, increment 3), restricted → active and past_due → active on invoice.payment_succeeded (webhook).
  • ExtendGrace (increment 2). The spec's "admin grace extension" is already a superadmin action: it moves billing_grace_ends_at, lifts a restriction back to past_due, and records grace.extended with the acting user. Increment 5 adds nothing to it; it only has to behave well when it is used (see "Anchoring").
  • Recover.vue and RestrictionController (increment 3), shipped with the payment controls deliberately inert until self-serve billing existed.
  • billing_invoices already carries source (stripe|manual), a nullable unique stripe_invoice_id, due_at, paid_at and pdf_url — the schema anticipated Stripe-sourced rows, and nothing has written one yet.
  • RecordPayment (increment 2) is the one way a payment enters the ledger, and Invoice::outstandingCents() / BillingInvoiceStatus::isOutstanding() are the one way a balance is computed. Both are reused, not reimplemented.
  • The mail convention: Mail::to()->queue(Mailable) on the database queue (SendNotificationDigests), so a webhook or a command can hand off a message without doing SMTP inline.

Approach: Stripe invoices become local rows

Two alternatives were considered and rejected. Letting Stripe Smart Retries and the hosted Customer Portal do the work contradicts the commercial rule this document sets — a fixed 21-day window the platform drives, with our emails and a superadmin who can move the date. Retrying against a live Stripe lookup every night keeps the diff small but leaves the customer's invoice list empty, makes every sweep N Stripe reads, and gives an attempt nothing local to attach to. So the retry loop reads local rows, and the rows come from webhooks.

StripeWebhookController handles four more invoice events — invoice.finalized, invoice.paid, invoice.voided, invoice.marked_uncollectible — alongside the two it already routes (invoice.payment_succeeded, invoice.payment_failed). Every one carries the full invoice object, and one action, RecordStripeInvoice, upserts a billing_invoices row keyed on stripe_invoice_id:

column from the Stripe invoice
source stripe
number number — null until finalized; the column is already nullable
period_start, period_end the subscription line's period
subtotal_cents, total_cents, currency subtotal, total, currency
status draft / open / paid / void / uncollectible → the same five BillingInvoiceStatus cases
issued_at, paid_at, due_at status_transitions.finalized_at, status_transitions.paid_at, due_date
pdf_url invoice_pdf

Status is absolute, never a transition. The row takes the status the payload carries, so the handler for any of the six events is the same upsert. Stripe does not guarantee delivery order: billing_events.stripe_event_id (unique) already drops exact duplicates, and for reordering the rule is terminal states win — a row at paid, void or uncollectible is never moved back to open or draft by a late finalized. issued_at is set whenever status becomes open, which makes explicit what outstandingCents() has so far relied on IssueInvoice for.

On invoice.paid the action calls RecordPayment with Stripe's amount_paid and the payment-intent id as the reference. Stripe sends invoice.paid and invoice.payment_succeeded for the same payment, and a webhook can be redelivered, so the reference is the idempotency key: a payment whose reference already exists is not recorded twice. That keeps the console ledger, the outstanding balance and the team billing page derive "paid" the way they already do for offline payments. No billing_invoice_lines rows are written — nothing renders them, and increment 6's usage lines are where that table earns its keep. Partial payments do not occur on Stripe subscription invoices and are not modelled.

This is what makes increment 4e's self-serve invoice card stop being empty, and what lets the outstanding banner see Stripe debt.

The retry loop

billing:retry-past-due runs daily, scheduled before billing:apply-access-expiry so that the day-21 attempt runs before the sweep that would restrict the team, with withoutOverlapping() like billing:reconcile-seats. It selects teams where billing_type = self_serve, billing_access_state = past_due, billing_grace_ends_at > now, and an open Stripe-sourced invoice exists, and for each calls one new gateway method:

code
PaymentGateway::payInvoice(Team $team, string $stripeInvoiceId): void

— Cashier's Invoice::pay() against the team's default payment method. The command writes no access state. Success arrives as invoice.paid / invoice.payment_succeeded and the webhook moves past_due → active exactly as it does today; a decline is report()ed and recorded as billing_events dunning.retry_failed, an attempt as dunning.retried. A team whose trial ended on a failed charge is the same path — its first invoice is simply an open one. A command has no transaction to hide a Stripe call in, but the after-commit rule from increment 4 still applies to every action this increment adds.

Running the command twice in a day retries twice; Stripe declines twice; nothing else happens. That is stated in the docblock rather than guarded against, because a guard would be a second implementation of "did we try today" next to the billing_events row that already records it.

Stripe's own automatic retries must be disabled in the dashboard before this ships, or attempts double up (see "Dunning"). The four new events must also be added to the webhook endpoint's event list if it is configured with an explicit set.

Emails

Five messages, three Mailables, all queued:

stage sent by Mailable
first failure the webhook, at the moment it moves a team into past_due PaymentFailed
day 7, day 14, day 19 billing:retry-past-due, after that day's attempt PaymentReminder (stage as a parameter)
restriction RestrictTeam, only when the reason is grace_expired AccountRestricted

Anchoring. "Day 19" means two days before access ends, so the reminders are computed backwards from billing_grace_ends_at: −14, −7 and −2 days. For an unextended 21-day window those are days 7, 14 and 19 exactly. The reason to anchor on the end rather than the start is ExtendGrace: moving the date re-arms the later reminders against the new end, so an extended team gets a final warning two days before the new cutoff rather than none. Each send is a billing_events row dunning.emailed keyed on (stage, the grace end it was computed against); the sweep skips a stage already recorded for that grace end.

Recipients. Every Owner and Admin user's account email plus teams.billing_email when set, deduplicated. Enterprise teams are excluded at the selection query, never at the mailer, and never receive dunning mail. A cancelled team never enters this loop: the restriction mail is for grace_expired, and a team that chose its end date gets no warning about it.

Content. Amount and invoice number from the local row, days remaining, and one link to billing.index. A restricted team following it is redirected to Recover.vue by the middleware that already exists; a past_due team lands on its billing page, which now has the buttons below.

The recovery path

The gateway gains two methods that arrive with their callers: updateDefaultPaymentMethod(Team $team, string $paymentMethodId): void — the method increment 4a removed pending a caller — and payInvoice() above, reused for pay-now.

Two actions. UpdatePaymentMethod takes a payment-method id captured by the same SetupIntent + Stripe.js flow Subscribe.vue uses, calls the gateway, and — if the team has an open Stripe invoice — immediately calls payInvoice(): a customer who has just fixed their card should not wait for tomorrow's sweep. PayOpenInvoice retries the open invoice against the card on file. Neither writes access state; success arrives as a webhook and the webhook restores access. Both follow increment 4's rule: the gateway call happens after any local transaction commits, never inside it.

Surfaces: Recover.vue's inert controls go live; the increment 4e billing page shows "Update card" when the card is missing or the team is past_due, and "Pay now" when an open Stripe invoice exists. Routes billing.payment-method.store and billing.pay.store are Owner/Admin only, refuse enterprise teams, and sit on EnsureTeamNotRestricted::ALLOWED_ROUTES — a restricted team paying is the point.

Closing the increment 4a hole. StartSubscription refuses a second subscription only on Cashier's subscribed(), which is false for past_due under Cashier::$deactivatePastDue — so a delinquent team could open a second live subscription. It now refuses whenever Subscriptions::forTeam() returns a subscription that has not ended(), which is what RestrictionController::canSubscribe and the billing page already say.

Vocabulary

Day 21 ends in restricted, the state a self-serve team can pay its way out of. suspended is the superadmin-only state with no self-serve recovery, and nothing in this increment produces it.

Sub-increments

Three, each independently shippable, each its own branch off dev, in this order:

  1. 5a — Stripe invoices as local rows. The four new webhook events, RecordStripeInvoice, RecordPayment reuse, terminal-states-win. Changes nothing a customer can do, and fixes the empty self-serve invoice card the day it lands.
  2. 5c — the recovery path. Gateway methods, UpdatePaymentMethod, PayOpenInvoice, the two routes on the allow-list, Recover.vue live, the billing-page buttons, and the 4a hole closed. Ships before the loop so the customer's way out exists before we start emailing them about it.
  3. 5b — the retry loop and emails. billing:retry-past-due, the three Mailables, the grace-anchored schedule, first-failure mail from the webhook, restriction mail from RestrictTeam.

Testing

Scoped, as throughout. FakePaymentGateway records payInvoice() and updateDefaultPaymentMethod() calls. Webhook tests post real-shaped invoice payloads and assert the row, the terminal-states-win rule under reordering, and the RecordPayment call. Mail::fake() asserts recipients (Owner + Admin + billing_email, deduplicated, never enterprise) and that a second sweep on the same day sends nothing. Carbon time-travel crosses −14, −7 and −2 and crosses an ExtendGrace. The standing invariant that no gateway call happens inside a transaction is asserted for both new actions. One test proves a past_due team can no longer start a second subscription.

What increment 5 does not do

No Stripe Smart Retries (disabled at deploy). No dunning for enterprise teams. No billing_invoice_lines rows (increment 6). No partial payments. No self-serve cancellation flow. No SMS. No Stripe Customer Portal.

Four consequences of how it was built, recorded here rather than discovered later:

  • No open Stripe invoice, no dunning mail at all — not the first failure, not the reminders, not the restriction notice. Every dunning message is about one invoice (SendDunningMail takes a Team, a stage and an Invoice), so a past_due team whose invoice a superadmin voided loses access on day 21 with no notice. The retry loop is unaffected: it had nothing to charge either.
  • billing_email has no self-serve writer yet. It is only ever set by the enterprise plan editor, so the "plus teams.billing_email" half of the recipient rule is dormant for exactly the teams dunning applies to; in practice a self-serve team's dunning goes to its active Owners and Admins. The rule stays implemented because the column is the natural place a "send bills to accounts@" setting will land.
  • Every nightly decline is report()ed, as this section's own "a decline is report()ed and recorded" requires — which for one delinquent team is one alert a night for twenty-one nights, and for a platform's worth of them is a nightly wall of identical alerts. Reporting only the first decline per invoice and logging the rest is the obvious amendment, and is left as a product decision rather than taken quietly here.
  • The tightened duplicate-subscription guard refuses a few Stripe states the old one allowed. StartSubscription and SubscriptionController::show() now gate on Subscriptions::forTeam()?->ended(), which is ends_at-based, so a subscription row sitting in incomplete_expired, unpaid or paused (all with a null ends_at) is treated as live and blocks a second subscription, where subscribed() would have let one through. incomplete and unpaid recover through pay-now, which is the intended route; incomplete_expired — the first invoice voided before it was ever paid — has no self-serve way back, and a superadmin action is the only remedy. Not reachable today, because 4a always opens a 30-day trial.

Increment 6 in detail — usage metering

Increment 6 is the last of the six. It makes the metering ledger real: one way to record a metered event, one way for a closed month's usage to land on the invoice Stripe generates on the 1st, a sweeper for the usage that invoice cannot carry, and the two places a customer sees it. Nothing in this increment produces a usage event. Twilio is installed and unused and there is no AI integration; the recorder is the seam they plug into when they are built, and everything downstream of it is tested by inserting rows, the way 2b's usage panel and 2c's usage tile already are.

Corrections to the sections above

  • "Usage metering" says a rate is denormalized onto the event and the schema gives billing_usage_events an amount_cents column. The rate is denormalized; the amount is not. A line's cost rounds once, at the line ("2d in detail" → "What a line costs"), and a per-event cent figure would be a second, wrong answer to the same question — 2,500 SMS events each rounded to 5 cents is 125.00 for112.50 of traffic, or $0.00 if each rounds down. The column is dropped. The console's usage panel and Overview tile, which sum it today, are re-pointed at the lines that invoice it.
  • quantity is decimal(20, 6) and Rate::lineCents() takes an int. Every service we quote is counted in whole units — messages, tokens — so the column becomes an unsigned integer and lineCents() keeps its signature. This is the decision 2d's review deferred to "when it knows what a metered quantity looks like"; a fractional quantity has no service to belong to.
  • The webhook table lists invoice.created as "increment 6 pushes closed-period usage onto it." Still true, with one consequence 5a did not want: the invoice gets a local row while it is still a draft, because the usage events need an invoice id to be stamped with. Customers never see it — TeamBillingSummary is already issued-only — and the superadmin console must refuse to issue a Stripe-sourced row in any status, since Stripe numbers its own documents. 6b narrowed the Void half of that rule: a Stripe draft may be voided locally, because that is the only way to release the usage events of a draft the sweeper abandoned and an operator then deleted at Stripe — see "The sweeper". An open or settled Stripe invoice is still Stripe's to void.

What is already in place

  • billing_usage_events and billing_usage_rates, the UsageEvent and UsageRate models, UsageRate::resolve() (most recent row effective on or before a moment, per-team override preferred), UsageService / UsageUnit, Rate with lineCents(), the seeded platform defaults, the Rates screen and its two actions.
  • billing_invoice_lines with kind = usage reserved and never yet written.
  • RecordStripeInvoice, which mirrors any invoice.* payload into billing_invoices under a row lock, terminal states winning; RECORDED_INVOICE_EVENTS composes the webhook endpoint's event list.
  • CreateInvoice, which writes a local draft with lines, and IssueInvoice / RecordPayment for the offline path.
  • PaymentGateway, the only route to Stripe, with a fake for tests.

The recorder

App\Support\Billing\Usage::record(Team <span class="laradocs-katex-inline" data-laradocs-katex="inline" data-expr="team, UsageService">team, UsageService</span>service, int $quantity, ?Model <span class="laradocs-katex-inline" data-laradocs-katex="inline" data-expr="reference = null, array">reference = null, array</span>metadata = []): UsageEvent is the single write path into the ledger. It:

  • resolves the rate with UsageRate::resolve($service->value, $team) at the moment of the event and denormalizes unit and unit_amount_millicents onto the row, so a later rate change never restates what August cost;
  • throws when no rate resolves. A service with no price is a configuration failure to surface at the call site, not a free event to discover on the invoice;
  • stamps billing_period as YYYY-MM from occurred_at in config('billing.timezone'), a new config key defaulting to UTC — the timezone Stripe anchors every subscription to, so "the month that just ended" means the same thing to the close handler and to Stripe;
  • records every team, including internal ones. The ledger says what happened; whether a team is billed for it is decided at close time, where internal teams are excluded along with everything else that is never invoiced;
  • rejects a quantity below 1. A zero-quantity event prices nothing and an integer ledger has no use for it.

occurred_at defaults to now and may be passed for a producer that batches. The reference is stored as a morph pair, the way the migration already indexes it, so a producer can answer "was this message already metered?" without a second table.

Monthly close, on the renewal invoice

invoice.created joins RECORDED_INVOICE_EVENTS. When it arrives for a self-serve team's subscription invoice (billing_reason = subscription_cycle or subscription_create), CloseUsagePeriod::onRenewalInvoice() runs as a new phase of the webhook, after the event claim, and does three things in this order — the second and third per line, alternating:

  1. Read what is owed as one aggregate. SUM(quantity) and COUNT(*) grouped by (billing_period, service, unit, unit_amount_millicents) over the team's uninvoiced events whose billing_period is strictly before the period the invoice was created in — every closed month, not only the last one, so a period the sweeper has not reached yet is carried rather than skipped. One row per invoice line, so a month of SMS traffic costs one row rather than one per message; this runs synchronously inside a webhook, and hydrating every event would make the handler's memory a function of a team's traffic. Each line's cost is Rate::lineCents() over the summed quantity at the rate denormalized on the events. No lock is held: nothing is written on this read, and what serializes the close against a concurrent sweeper run is the conditional UPDATE in step 3.
  2. Push one item to Stripe, outside any transaction. One new gateway method, PaymentGateway::addInvoiceItem(Team <span class="laradocs-katex-inline" data-laradocs-katex="inline" data-expr="team, string">team, string</span>stripeInvoiceId, array $line): void, creates a single invoice item on the draft invoice, with an idempotency key of usage:{team_id}:{billing_period}:{service}:{unit}:{unit_amount_millicents}:{stripe_invoice_id} so that a retry after a lost response cannot add a line twice. The key tuple is the grouping tuple because a line is one (service, unit, rate) — a mid-month change splits a service into two lines, and a key missing either would hand Stripe two different amounts under one key. One item per call, not a batch, because Stripe has no batch create: n requests behind one void return cannot say which items were accepted when the invoice finalizes part-way, and an item Stripe has charged with no local line is one the sweeper bills a second time. If Stripe refuses because the invoice is no longer editable — the finalized event overtook this one, or the one-hour auto-advance window closed — the handler stops where it stands, records what Stripe accepted, and leaves the rest for the sweeper. It reports nothing: that is the benign race, and it is handled exactly as InvoicePaymentFailure::meansAlreadySettled() handles its cousin. An IdempotencyException is not that race — the same key with a different body should be unreachable, so it is report()ed once, the close stops, and the line waits for the sweeper; propagating it would make Stripe redeliver the same colliding event for three days.
  3. Record that line, in a transaction, only after Stripe accepted it. Stamp its events with invoiced_at and billing_invoice_id in one UPDATE … WHERE invoiced_at IS NULL over the group's own tuple, bounded to the rows the aggregate saw by its MAX(id) — ids are ULIDs, so a producer backdating an event into a closed period after the read gets a higher one and waits for the sweeper rather than being stamped to a line whose quantity never included it. The affected-row count is the re-check, and MySQL's row locks on that UPDATE are what serialize this against a concurrent sweeper — then write one billing_invoice_lines row with kind = usage, quantity = the summed count, unit_amount_cents = 0 with the millicent rate and unit in meta (a line's unit price is not a whole number of cents; the line total is), amount_cents = the line total, and meta.events = how many events Stripe was sent, not how many rows were stamped. Stamping fewer than that means a concurrent writer took some: the line is still written for the full quantity Stripe was sent, and the shortfall is reported. Stamping none means it took all of them: no line is written, because nothing local backs it, and the report carries the amount and the idempotency key so Stripe's orphaned item can be reconciled by hand. usage.closed records the periods, the line count and the total that were recorded.

The handler runs synchronously in the webhook rather than on the queue because Stripe finalizes a subscription invoice about an hour after creating it, and a queue backlog must not turn the primary path into the safety net.

Why the draft row exists: an event is "invoiced" when it carries an invoice id, and the only honest id is the invoice it is on. 5a's rule that Stripe drafts stay invisible is kept where it matters — the customer's list is issued-only — and the superadmin console's Issue action refuses source = stripe rows in any status, because issuing is what IssueInvoice does to our drafts and Stripe's are not ours to number. Void refuses them too, with one exception 6b added for a reason it alone has: a Stripe draft may be voided, which is how an abandoned sweep's events get back into the pool (see "The sweeper").

A void releases the events. invoice.voided already flows through RecordStripeInvoice; when the voided invoice carries usage lines, the same transaction clears invoiced_at and billing_invoice_id on the events stamped to it and records usage.released. The lines stay on the voided invoice as the record of what was attempted; the events go back to the pool and the sweeper bills them. This is the only path that un-invoices usage.

The sweeper

billing:close-usage-period runs daily, scheduled after billing:draft-enterprise-invoices. It finds every team with uninvoiced events in a period that ended more than 48 hours ago — on or after the 3rd, in the billing timezone — excludes internal teams, and bills each team's entire backlog of closed periods on one standalone invoice. It has no transaction around any Stripe call and it writes no access state. Two shapes of team, two shapes of invoice:

  • Self-serve with a Stripe customer: three gateway calls, not one. createDraftInvoice(Team $team, string $description, string $idempotencyKey): string creates an empty draft with pending invoice items excluded, for automatic collection but with auto_advance off, and returns its id; addInvoiceItem() — the close handler's own method — then pushes each line onto that invoice; finalizeInvoice(Team $team, string $stripeInvoiceId): array finalizes it, passing auto_advance = true as it does so, and returns the finalized payload. Advancing is switched on at finalization rather than at creation because Stripe finalizes an auto-advancing draft by itself about an hour later, which would make every draft this command abandons charge the customer an hour after it was decided it must not — and "an unfinalized invoice charges nobody" is what the whole failure path below rests on. The order matters too: an item created before the invoice exists is a pending item, and a failure between the two steps would leave it to be swept onto the next renewal invoice with no local record. The split into three matters for a different reason: a single method composing the per-line idempotency keys, sequencing the pushes and deciding what to do about a partial failure would be this seam carrying billing rules, which it does not. Between them the sweeper does what the close handler's steps 2 and 3 do, per line and in the same order — with two post-Stripe transactions of its own around the run: the first mirrors the draft payload through RecordStripeInvoice, before any line, because a line needs an invoice id to hang off and an event is only "invoiced" once it carries one; the last mirrors the finalized payload through the same Action, so the row goes draftopen with its number and totals and the invoice.finalized webhook that follows updates it idempotently rather than creating it. An open standalone invoice that fails to collect enters dunning exactly as a renewal does; increment 5 needs nothing new. A failure anywhere leaves the Stripe draft exactly as it is — it charges nobody until it is finalized, and the reported id is what deleting it by hand needs — and does not release the lines already recorded, because those items are on that draft and freeing their events would bill the same usage again tomorrow. An abandoned draft carrying recorded lines is a human-reconciliation case, named by the report, and the way out is exact: delete the draft at Stripe — harmless if it is left, since it cannot advance — then void the local draft in the console, which releases its events through Usage::release() so the next sweep bills them on a fresh invoice. That is why VoidInvoice accepts a source = stripe row while it is still a draft, the one exception to 6a's refusal: an open Stripe invoice is collecting, and releasing its events would have the sweeper bill the month a second time on top of it, while a draft is not a document Stripe has issued and invoice.deleted is not a webhook this application records — so without the exception those events would be stranded and the month would silently never be billed. A close that recorded fewer lines than it selected marks its usage.closed partial, so reconciliation can tell it from a team that genuinely owed that many. Recording no line at all is the one case that stops before finalizing: every event behind every line was claimed while the items were being pushed, so the usage is already on somebody else's invoice and finalizing would collect for it twice.
  • Enterprise, and self-serve with no Stripe customer: a local draft through CreateInvoice, one usage line per service per period, period_start / period_end spanning the closed periods, with the events stamped in the same transaction. A superadmin issues it like a renewal draft. Nothing is charged automatically, which is the enterprise convention everywhere else.

Failures follow 5b's split. A Stripe error is report()ed and the team is skipped until tomorrow; nothing is stamped until Stripe has accepted. A team with a stale Stripe customer, or an invoice item Stripe rejects, is reported by name. Output is a table of team, periods, lines, total and outcome — stripe, draft, skipped, with the reason — and --dry-run prints it and writes nothing, as billing:draft-enterprise-invoices does.

Idempotency is the property worth testing hardest, as it was for the drafting command: the close handler and the sweeper share one stamp, one lock, and one query for "uninvoiced events in a closed period", so running either twice, or both on the same night, produces one line per service per period. The shared query lives in UsageEvent::uninvoicedClosedPeriods() and both callers use it; "uninvoiced" is invoiced_at IS NULL, and billing_invoice_id is always written in the same statement, so an event is never half-stamped.

The floating point debt

CreateInvoice computes a line total as (int) round(((float) $quantity) * $unit), which 2d's review flagged as purity debt that comes due with the first non-unit quantity. It comes due here. The line total becomes scaled-integer arithmetic in Money::lineCents(): the decimal quantity string is parsed to millionths the way Money::toCents() parses dollars, the product is rounded once with the same integer doubling Rate::lineCents() uses, and no float is touched. Half away from zero rather than half up, which is the one place it differs from Rate::lineCents(): an Adjustment line carries a negative unit amount, and rounding a negative product toward +∞ would make a credit note a cent lighter than the charge it reverses. The magnitude is rounded and then signed, which is what round() did. 2b's line rule is re-tested with a fractional quantity. Usage lines do not go through this path at all: they arrive with amount_cents precomputed by Rate::lineCents(), so the two rounding rules never meet on one line.

What the customer sees

Two additions to the team billing page, both produced by TeamBillingSummary, both formatted server-side, neither adding a page:

  • "Usage this month." One row per service with events in the current period: the label, the quantity with its unit, the rate in force (Rate::format()), and the cost so far (Rate::lineCents() over the period's events, Money::format()). Hidden entirely when the team has no events this period, which is every team until a producer exists, or when the team is internal — its usage is never invoiced, and a card promising one that it bills would state a charge no invoice carries. Labelled "so far" and "bills on the 1st", because the number moves; for a team whose usage would be drawn onto a local draft rather than a Stripe invoice — every enterprise team, and a self-serve one with no Stripe customer — the card reads "bills with your next invoice" instead, since that draft is issued by a superadmin and no renewal arrives on the 1st. Which of the two is CloseUsagePeriod::billsThroughStripe(), the same rule the sweeper picks the invoice shape with.
  • Invoice lines. Each issued invoice in the existing list can expand to its lines, usage lines included — description, quantity, amount — so "$112.50 for 2,500 messages" is one click away from the invoice it is on rather than a support ticket. Stripe invoices that pre-date this increment have no lines and show none.

The superadmin console changes only where the dropped column forces it: the usage panel on the team screen and the "usage billed this month" tile sum billing_invoice_lines of kind = usage joined to their invoices, which is also the more honest figure — what was billed, not what was recorded.

Sub-increments

Three, each its own branch off dev, in this order:

  1. 6a — the ledger and the close. The billing.timezone config, the two column migrations, Usage::record(), UsageEvent::uninvoicedClosedPeriods(), CloseUsagePeriod with addInvoiceItems() on the gateway and its fake, invoice.created in RECORDED_INVOICE_EVENTS, the void release, the Issue/Void refusal for Stripe drafts, and the console's two re-pointed sums.
  2. 6b — the sweeper. createDraftInvoice() and finalizeInvoice() on the gateway and its fake, billing:close-usage-period with both invoice shapes and --dry-run, the schedule entry, and the CreateInvoice arithmetic fix.
  3. 6c — the customer view. The usage card, the expandable invoice lines, and the TeamBillingSummary props behind them.

Testing

The scoped set, as increment 5 ran it. What matters most: Usage::record() resolves the override over the default and throws without either; the close handler leaves the events unstamped when Stripe refuses and stamps them only after it accepts; a redelivered invoice.created adds nothing; a void releases exactly the events on that invoice; the sweeper and the close handler on the same night bill each period once; the sweeper creates the Stripe invoice before its items; enterprise gets a draft and never a charge; internal teams are never invoiced; the gateway is never called inside a transaction, asserted with the fake's transactionLevel capture as 5c and 5b do; and the customer card's figure equals Rate::lineCents() over the same rows, asserted side by side.

What increment 6 does not do

No producer — no SMS is sent and no model is called. No Stripe Billing Meters; the rate of record stays in billing_usage_rates, where per-team overrides live. No prepaid credits, no usage caps, no usage alerts, no per-service enable switch. No usage billing for internal teams. No retroactive re-pricing of recorded events. No customer usage dashboard beyond the card and the invoice lines. No change to when usage bills: a closed month lands on the 1st, or by the 3rd on its own invoice.

Increment 7 in detail — self-serve account management

The six planned increments left a paying customer with two things it cannot do for itself: end its subscription, and change the card on file while the account is healthy. Increment 7 adds both, an undo for the first, and the one field dunning has been waiting for — a billing email a team sets itself. Nothing here charges, refunds, or moves a team between plans; it lets a team say what it wants and lets the webhook, as ever, record what Stripe did about it.

Corrections to the sections above

  • The access state machine's active --team cancels--> cancelled transition is driven by the webhook, not by the cancelling action. Cancelling at period end leaves the Stripe subscription active with cancel_at_period_end = true, so the status mapping alone would leave the team active until the period actually ended. "Stripe status is not our access state" gains a second input: cancel_at_period_end. active or trialing with the flag set maps to cancelled, with billing_current_period_ends_at from the payload as today; with the flag clear they map as before. SubscriptionStatusMap::toAccessState() takes the flag as a second argument. It remains the only implementation.
  • The machine gains one transition: cancelled --team resumes--> active, for a team that reverses its cancellation before the period ends. Stripe clears the flag, the webhook maps active without it, and the team is back where it was with nothing charged. A cancelled team whose period has ended is restricted by the sweep and recovers through the existing subscribe path; it cannot resume.
  • "Owner and Admin manage billing normally" stays true for the card, modules, invoices and the billing email. Ending the subscription, and undoing that, is the Owner's alone.

What is already in place

  • Subscriptions::forTeam() / liveFor(), the one subscription lookup; Cashier's Subscription::cancel() (sets cancel_at_period_end, records ends_at locally), resume() (clears both) and onGracePeriod() (ends_at in the future).
  • The webhook's customer.subscription.updated handling and applyPeriodEnd(), which already copies current_period_end onto the team.
  • billing:apply-access-expiry, which restricts a cancelled team once billing_current_period_ends_at has passed, with reason cancellation_period_ended.
  • UpdatePaymentMethod and billing.payment-method.store, the SetupIntent + Stripe.js form RecoveryControls.vue renders, and RecoveryOptions — the one place that decides which card controls a page offers.
  • DunningRecipients::for(), which already reads teams.billing_email.

Cancelling

CancelSubscription::handle(Team $team, User $actor): ?CarbonInterface — the date access ends, returned so that the ledger row, the toast and the page all quote the one value Cashier chose. Preconditions, each a 422 with a sentence, as a ValidationException on the subscription key rather than an abort(): this application renders no error view for a 422, so only a validation error's sentence actually reaches the person who pressed the button. The actor is the team's Owner; the team is self-serve; the access state is active or trialing; Subscriptions::cancellationEndsAt() is null, so nothing is already scheduled to end; and Subscriptions::liveFor() returns a subscription. A past_due or restricted team cannot cancel — it settles first, through the recovery path, which keeps the invoice-event mapping and the retry loop exactly as increment 5 left them. A trialing team may cancel; the trial runs out and nothing is ever charged.

The action calls PaymentGateway::cancelAtPeriodEnd(Team $team): void — Cashier's cancel(), never inside a local transaction — and records subscription.cancelled with the actor and the period end after Stripe has accepted it. That order is not interchangeable: an event recorded first would claim the customer had ended a subscription Stripe is still billing, and a gateway failure must leave nothing behind.

It writes no access state. Stripe's customer.subscription.updated arrives with the flag set and the webhook records cancelled and the period end. Until it does, the team's billing_access_state still reads active — but the customer sees the cancellation immediately all the same, because Cashier's cancel() writes the local subscriptions.ends_at synchronously and Subscriptions::cancellationEndsAt() is what the page reads: the "your subscription ends on date" note, the "Access ends" label on that date, and the undo button all appear at once. Only the access state waits for the webhook, and nothing the customer reads is derived from it.

Resuming

ResumeSubscription::handle(Team $team, User $actor): void. Preconditions: Owner; self-serve; Subscriptions::cancellationEndsAt() is set — the subscription is scheduled to stop and that date has not passed; and the access state is active, trialing or cancelled. The state is a list here, where cancelling takes only the first two, precisely because of the window above: a team that cancelled a second ago is still active, and it must be able to change its mind before the webhook lands. The refusal is a ValidationException on the same key, for the same reason. Calls PaymentGateway::resumeSubscription(Team $team): void, Cashier's resume(), and records subscription.resumed after Stripe accepted it — the same order, for the same reason, as cancelling. Writes no access state; the webhook maps the cleared flag back to active. Nothing is charged: the subscription never stopped.

Once the period has ended the subscription is gone at Stripe and the team is restricted; the button is not offered and the action refuses. The way back is StartSubscription, which already exists.

Changing the card

The 4a decision that a healthy team sees no card form stands for rendering: the page still makes no Stripe call to draw itself. What changes is that the Payment method card shows Change card for a self-serve team with a live subscription whenever the recovery form is not already offered. Clicking it POSTs to billing.payment-method.intent, which returns a SetupIntent client secret as JSON, and the page reveals the same Stripe.js form RecoveryControls.vue uses; submission goes to the existing billing.payment-method.store, so UpdatePaymentMethod — which also pays an open invoice if one exists — stays the one way a card changes.

RecoveryOptions::for() gains canChangeCard: self-serve, not suspended, live subscription. shouldOfferCard (card missing or past_due) and canChangeCard are never both true on a rendered page: when the recovery form is up, the button is not.

The billing email

An inline field on the Plan card, editable by an Owner or Admin of a self-serve team. SetBillingEmail::handle(Team $team, ?string $email, User $actor): void validates a plain address or accepts null to clear, writes teams.billing_email, and records billing.email_changed with the old and new values. DunningRecipients::for() picks it up on the next send with no other change. It does not update the Stripe customer's email: Stripe's own receipts keep going to the address the subscription was opened with, and one field driving two systems' recipients is a rule this increment does not want to own yet.

Enterprise teams keep the superadmin plan editor as the writer of that column; the field is read-only for them on the billing page, as the whole plan card already is.

What the customer sees

All of it on the existing billing page, all produced by TeamBillingSummary:

  • Plan card. For an Owner of a self-serve team with nothing scheduled to end: Cancel subscription, behind a confirmation that names the date access ends (billing_current_period_ends_at, or the trial end) and says nothing is refunded. For a team whose subscription is scheduled to end — from the moment it is asked for, not from the moment the webhook lands: "Your subscription ends on date." and, for the Owner, Keep my subscription. Admins see the state and the date, not the buttons.
  • Payment method card. Change card when canChangeCard is true and the recovery form is not shown.
  • Billing email, inline on the Plan card, with save and clear.

Props: canCancel, canResume, canChangeCard, canEditBillingEmail, and plan.cancellation_ends_at (a formatted date or null, from Subscriptions::cancellationEndsAt()) — which is also what the "Access ends" label is chosen by, in place of the access state. No .vue decides any of them.

Routes

All under the existing Owner/Admin billing group with verified: POST billing/cancelbilling.cancel.store, DELETE billing/cancelbilling.cancel.destroy (the resume: what it deletes is the cancellation), billing.payment-method.intent, billing.email.update. Cancel and resume additionally require the Owner role inside the action, not only the route, so a request that reaches the action with an Admin is refused with the same sentence the page would have shown. None of the four is on EnsureTeamNotRestricted::ALLOWED_ROUTES: a restricted team recovers through the recovery routes and nothing here applies to it.

Sub-increments

  1. 7a — cancel and resume. SubscriptionStatusMap with the flag, the two actions, the two gateway methods (interface, Stripe, fake), the two routes, the plan card's buttons and note, and the spec corrections above.
  2. 7b — the card and the email. canChangeCard, the intent endpoint, the Change card button and form reuse, SetBillingEmail, the route, the field.

Testing

The scoped set, as increment 6 ran it. What matters most: a cancel writes no access state and the webhook with the flag does; a resume before period end returns the team to active with nothing charged; a past_due team cannot cancel; an Admin cannot cancel or resume, an Owner can; a cancelled team whose period has ended cannot resume; the sweep still restricts a cancelled team at period end; the change-card intent endpoint is the only place a SetupIntent is created outside recovery and the page render still creates none; shouldOfferCard and canChangeCard are never both true; a saved billing email reaches DunningRecipients::for(); every gateway call happens outside a transaction, asserted with the fake's transactionLevel capture.

What increment 7 does not do

No immediate cancellation and no refunds — access runs to the end of the paid period, as the commercial rules say. It does not stop a team scheduled to end from adding or dropping modules: the proration covers the remaining days, and a proration invoice paid during the grace period leaves the cancellation in place (the webhook's local-grace flag source). No cancellation confirmation email; Stripe sends none for a period-end cancellation either, and the page states the date. No cycle switching. No sync of the billing email to the Stripe customer. No data deletion or export for a team that has cancelled; it becomes restricted and keeps its data, per "Deferred, deliberately". No enterprise self-service: enterprise teams are managed by a superadmin.

Deferred, deliberately

  • Sales tax and VAT calculation or collection.
  • Paid storage add-on packs (the billing_products.type = storage slot exists for it).
  • Prepaid usage credits.
  • Multi-currency pricing.
  • Self-serve annual/monthly switching mid-term.
  • A customer-facing usage dashboard beyond the invoice line items.
  • Data retention and deletion policy for teams that stay restricted indefinitely. Restricted teams keep their data intact with no expiry until this is decided.

Remaining work, after increment 7

Recorded on 2026-09-15, when increments 1 to 7 were merged to dev and work paused. Everything below is known, deliberately not built, and waiting for a decision or a customer. Each item says what it is, why it was left, and what building it would touch, so that whoever returns to this can pick one up without re-deriving the reasoning. The "What increment N does not do" sections above stay the authority on each increment's boundaries; this section is the consolidated list.

Before real customers are charged

  • A usage producer. Usage::record() has no caller. The meter, the monthly close, the sweeper and the customer card are built and tested against inserted rows, and bill nothing until SMS (Twilio is installed and unused) or an AI integration calls the recorder. Wiring the first one is a product feature plus one Usage::record() call per metered event, with the reference set so the producer can answer "was this already metered?". Everything downstream is untested against live traffic until then.
  • Deploy actions, all outstanding. Rotate the live Stripe keys tracked in .env.for_production and .env.for_beta and roll the webhook signing secret; disable Stripe's Smart Retries in the dashboard before the dunning loop goes live; add the seven invoice.* events (including invoice.created) to the existing webhook endpoint's event list by hand; resend past invoice.finalized / invoice.paid events so pre-5a invoices get local rows; set BILLING_TIMEZONE if it is not UTC; note that 6a's migration drops billing_usage_events.amount_cents. dev has never been pushed.
  • Change card outside recovery is done (7b); a card-less healthy team is not. A self-serve team with a live subscription and no card on file sees the recovery form, not "Change card" — correct, and worth knowing.

Product decisions the spec has recorded but not taken

  • Report only the first decline per invoice. Every nightly retry decline is report()ed, so one delinquent team is 21 alerts. Increment 5's section names the amendment; it is one exists() check in RetryPastDue before report().
  • A past-due team whose invoice was voided gets no dunning mail and loses access on day 21 silently, because every dunning message is about one invoice. Either accept it, or give SendDunningMail an invoice-less stage.
  • billing_email for enterprise teams is written only by the superadmin plan editor, and 7b's self-serve writer lowercases while that one writes as typed. Dunning dedupes case-insensitively, so nothing breaks; the column's spelling is two rules.
  • The trial is a flat 30 days; the commercial rules say the first of the month at least 30 days out. 4a's divergence, never corrected.
  • incomplete_expired subscriptions have no self-serve way back. Not reachable while every signup opens a trial; a superadmin action is the remedy if it ever is.
  • A cancelling team may still add or drop modules (increment 7). Deliberate; the proration covers the remaining days.

Operational follow-ups

  • An abandoned-draft detector. A sweep that fails mid-push leaves a Stripe draft (never charges, auto_advance off) with recorded local lines; the remedy is a hand deletion at Stripe plus a local void. The only signal today is the report() at the moment of failure. A stripe-sourced draft older than 48 hours carrying usage lines is a precise query; surface it on the superadmin billing screen or in the sweeper's own table.
  • billing:reconcile-modules. A failed module add or drop at Stripe is only report()ed; nothing later reconciles billing_team_modules against the subscription's items the way billing:reconcile-seats does for seats.
  • Copy for a past_due self-serve team on the billing page, beyond the recovery controls it already gets.
  • One quantity formatter for invoice lines: the superadmin usage panel prints 2500, the customer page 2,500. Promote TeamBillingSummary::quantity().
  • outstandingCents() has no issued_at filter. Consistent only because IssueInvoice is the sole writer of Open; add the filter if a second appears.
  • Under a non-UTC BILLING_TIMEZONE, period_end displays a day late — the billing_invoices period columns are date, and an end-of-month instant truncates. Pre-existing since 2b; no money or selection reads it.
  • CloseUsagePeriod is about 960 lines behind two entry points. Cohesive; split it into the close and the sweep over shared helpers if a third entry point appears.
  • No throttle on billing.payment-method.intent; an Owner can loop it and create SetupIntents at Stripe (free, and they expire). The sibling endpoints are equally unthrottled.
  • Two Vue nits on the change-card panel (7b): the failed-fetch copy says "try again" where the only control is "Keep current card", and the billing-email input has no label association.
  • Pre-5a parked items: applyAccessState()'s locked re-read lacks withTrashed() while resolveTeam() has it (a subscription event for a soft-deleted team throws and Stripe retries for three days); billing_payments.reference is unindexed.
  • Two test-suite artefacts: Settings\Modules\Hr\DepartmentTest::test_store_allows_duplicate_code_for_a_different_team fails on every run (pre-existing, unrelated to billing); SuperAdmin\TeamCrudTest::test_index_lists_teams asserts the first-listed team's name and fails when leftover fixture state sorts ahead of it — order-dependent, passes alone. StripePaymentGateway's methods have no offline unit coverage because Cashier::stripe() has no seam; PaymentGatewayContractTest covers conformance and the live StripeContractTest group is excluded from the default run.

Still deferred, deliberately

The list under "Deferred, deliberately" stands unchanged: sales tax and VAT, paid storage packs, prepaid usage credits, multi-currency, mid-term annual/monthly switching, a customer usage dashboard beyond the card and the invoice lines, and a data retention and deletion policy for teams that stay restricted indefinitely. Increment 7 added two of its own: no immediate cancellation or refunds, and no cancellation confirmation email.

How to resume

Each increment above was built the same way, and the next should be: a section of this document first, a plan under docs/superpowers/plans/, then subagent-driven execution with a task review, a whole-branch review and a --no-ff merge to dev per sub-increment. The standing constraints are in the increment sections: no billing rule gets a second implementation; billing_access_state is the webhook's column; integer cents and millicent rates; billing tables in MySQL; never a Stripe call inside a transaction; every gateway method on the interface, the Stripe implementation and the fake in one commit.