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:
teamscolumns: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) plusApp\Support\Modulesalready gate module visibility, with "empty means no restriction" semantics. This is the entitlement hook.- Modules are DB-driven (
modules/modules_substables,ModulesSeeder). /superadminexists (EnsureSuperAdmin, users/teams CRUD, impersonation) with a hub page built to take more sections.AccessRemoved.vueis the existing precedent for a "you cannot use the app" page.uploadsis a MongoDB collection;fileSizeis stored in kilobytes as a 2dp float, written fromApp\Support\Uploadsand three points inApi\V1\Files\UploadController.oidon tenant models equalsTeam::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):
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. teamscolumnscurrent_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
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_rates — id, 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_lines — id, billing_invoice_id, kind
enum(seat|module|usage|adjustment), description, quantity,
unit_amount_cents, amount_cents, meta (json).
billing_payments — id, 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
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 onAccessRemoved.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 Requiredwith 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
- Access state —
EnsureTeamNotRestrictedmiddleware (web + API). - Modules —
App\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-levelEnsureModuleEntitled:{key}middleware guardsroutes/web/logistics3p.phpandroutes/web/containers.php. - Seat cap — checked in the invite and membership-activation actions.
- Storage —
StorageQuota::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_eventstimeline. 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.
- Foundation. Cashier install, legacy schema cleanup,
teamscolumns, the eight billing tables and models, theEntitlementsresolver wired intoApp\Support\Modules, and every existing team landing onenterprise/activewith 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. - 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.
- Enforcement. Restricted middleware,
Restricted.vueandRecover.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. - 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-146as reviewed) requires bothbilling_enterprise_amount_centsandbilling_enterprise_intervalto be non-null, so only enterprise plans contribute to MRR. That is correct until this increment — no self-serve price is stored anywhere reachable — butBillingType::isBillable()(app/Enums/BillingType.php:19-22) returns true forSelfServeonly, 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 widenmrrCents()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. - Dunning. Daily retry command, dunning emails, admin grace extension.
Grace transitions moved to increment 3 —
past_due → restrictedandcancelled → restrictedare 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". - Usage metering. Recorder, rates,
invoice.createdhandler, 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/superadmindoes. - There was no money-formatting helper anywhere in this codebase when this was
written. 2b adds
App\Support\Billing\Moneyas 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 theBillingEventtimeline. Every Action above is reachable from here. - Rates (
/superadmin/billing/rates) — platform default price per usage service plus per-team overrides. Nothing readsbilling_usage_ratesuntil 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:
- 2a — foundations. The nullable
numbermigration,billing_number_sequences, theBillingCycleenum and casts,modulesAllowedfillable, and the seven Actions. No UI. - 2b — Team detail. The screen an operator actually needs, plus its write endpoints. Detailed below.
- 2c — Overview and Teams list. The cross-team view. Detailed below.
- 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:
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:
- 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.
- Invoices — number, period, total, status, with Issue / Record payment / Void per row and a New invoice button.
- Payments — date, amount, method, reference, who recorded it.
- Usage and Activity side by side — usage grouped by service and billing
period; activity is the
billing_eventstimeline.
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:
VoidInvoiceandMoney. The eighth Action and the conversion/formatting helper. No UI.- The read side.
TeamBillingController@show, its route, theShow.vuepage and all five cards, with the three paginators and the usage aggregate. - 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-columnselectover the billable teams, summed in PHP through the existingBillingCycle::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 withbilling_storage_used_bytes > 0or 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 wholeteamstable. - 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, whichEntitlements::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_personalteams 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): string — TeamBillingController'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.
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:
- Shared rules.
Entitlements::storageQuotaBytesFor(),Bytes::format(), andTeamBillingList. No UI. - The Teams list. Controller, route, page, search and filters.
- 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.
- The column name carries the unit. Never
amount, neverunit_amount_cents. Every call site readsunit_amount_millicents. App\Support\Billing\RatemirrorsMoneyexactly —toMillicents(string),toInput(int),format(int)— parsing decimal strings rather than multiplying floats, for the same reasonMoneydoes:0.045 * 1000is not 45 in IEEE 754.Rate::format()prints the significant decimals a rate needs;Money::format()always prints two.- A unit test pins the two against each other:
Rate::format(4500)is$0.045andMoney::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:
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_frommay not be in the past. Backdating would change whatresolve()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.
┌─ 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 pasteffective_from, an amount that is not a plain decimal, and a service/unit mismatch.DeleteScheduledRate— removes a future-dated row. Re-reads the row underlockForUpdate()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-runprints 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=30overrides the window, for testing and for a one-off catch-up.- Output is a table of team, renewal date, period, amount, and outcome —
created, orskippedwith the reason. A run that creates nothing says so. - Scheduled
->daily()inroutes/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:
- Rate foundations.
UsageServiceandUsageUnitenums, the millicents rename migration,Rate, theUsageRatecasts and seeder,SaveUsageRateandDeleteScheduledRate. No UI. - The Rates screen. Controller, routes, page, types, and the two write endpoints.
- The command.
EnterpriseDraftextraction, the nullable actor onCreateInvoice,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
Invoiceclasses (Billing,Enterprise,Cashier) and twoPayment. The console imports two of them; alias at the import site rather than renaming models. billing_team_moduleshas 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::$resolvedandProduct::$specializedModuleKeysare 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
EnsureModuleEntitledtoroutes/web/logistics3p.phpandroutes/web/containers.php, and that is what shipped. Butroutes/web/reports.phpcarriesreports/logistics3p/*andreports/containers/*— whichHandleInertiaRequestshides by the same module key — androutes/api/v1/logistics3p.php/routes/api/v1/containers.phpcarry 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 againstBillingProductsSeeder; 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), notisVisibleForUser()(which lets a per-user permission override win). Deliberate — a per-user override is authorization rather than entitlement, andisVisibleForUser()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 wantsModules::isAllowedForTeam(). -
UserInvitationController::resend()500s on a soft-deleted team ($invitation->teamis 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 dailybilling:apply-access-expirycommand 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 absentbilling_access_statetoActive— a fail-open. Failing closed would lock out every caller that handsEntitlementsa partially-selectedTeam, which is a worse failure than the one it prevents. Any caller passing a partial select must select that column. -
The superadmin passthrough in
EnsureTeamNotRestrictedis 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
apirequest. Fine today; revisit in increment 4, when self-serve teams multiply that traffic. -
resources/js/lib/resolveLayout.test.tsis 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_passwordcannot reachsuperadmin.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-invoicesdrafts 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. OnlySaveEnterprisePlanwrites 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 anintquantity, butbilling_usage_events.quantityisdecimal(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 (
whereNotExistsa 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'seffective_from <= nowguard and the in-force row never reachespartition()at all. What keeps the two in agreement is the test surface, not PHP's ability to correct SQL: one test assertsUsageRate::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:
CreateInvoicecomputes 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 theround()already defends the1.15 * 100 = 114.999…caseMoney'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_eventsorbilling_usage_ratesconstrainsteam_idwith a foreign key, andSuperAdmin\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_duewhosebilling_grace_ends_atis in the past →restrictedcancelledwhose 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.
- Members →
Billing/Restricted.vue, modelled on the existingAccessRemoved.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 Admin →
Billing/Recover.vue: update the payment method, view outstanding invoices, pay now. Nothing else in the application is reachable. - API (Passport) →
402 Payment Requiredwith 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:
- Sending an invitation (
App\Actions\Users\InviteTeamUsers) — the obvious one. - 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/UploadAPI'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
EnsureTeamNotRestrictedon 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.
- 3a — access.
EnsureTeamNotRestricted,Restricted.vue,Recover.vue, the API 402,billing:apply-access-expirywith its Action, and the scheduled-work rule with its shared scope. - 3b — seats and modules.
hasSeatCapacity()at the invite and acceptance paths,EnsureModuleEntitledon the two module route files. - 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
Teamas the Billable customer (useCustomerModel), thesubscriptionsandsubscription_itemstables, and the customer columns (stripe_id,pm_type,pm_last_four,trial_ends_at) onteams. Cashier::ignoreRoutes()inAppServiceProvider::register(). Increment 1 turned Cashier's own routes off becausePOST stripe/webhookwould 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
PaymentGatewayseam 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
suspendedalways 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_duearriving twice must not extend the window, or a customer failing payment repeatedly never reaches restriction. Increment 3'sExtendGraceremains 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.
- 4a — card and subscription for an existing team. The
PaymentGatewayinterface and its fake, the seeder readingconfig/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. - 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.)
- 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.
- 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_modulesrecords anends_atrather than deleting. - 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.
- 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:
- Settings → Teams creates the team
self_serve/restrictedand sends the creator tobilling.subscribe, exactly as 4b's public signup does. - 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.
- 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_duewithbilling_grace_ends_at = now + 21 days(webhook),past_due → restrictedwhen that date passes andcancelled → restrictedat period end (billing:apply-access-expiry, increment 3),restricted → activeandpast_due → activeoninvoice.payment_succeeded(webhook). ExtendGrace(increment 2). The spec's "admin grace extension" is already a superadmin action: it movesbilling_grace_ends_at, lifts a restriction back topast_due, and recordsgrace.extendedwith the acting user. Increment 5 adds nothing to it; it only has to behave well when it is used (see "Anchoring").Recover.vueandRestrictionController(increment 3), shipped with the payment controls deliberately inert until self-serve billing existed.billing_invoicesalready carriessource(stripe|manual), a nullable uniquestripe_invoice_id,due_at,paid_atandpdf_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, andInvoice::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:
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:
- 5a — Stripe invoices as local rows. The four new webhook events,
RecordStripeInvoice,RecordPaymentreuse, terminal-states-win. Changes nothing a customer can do, and fixes the empty self-serve invoice card the day it lands. - 5c — the recovery path. Gateway methods,
UpdatePaymentMethod,PayOpenInvoice, the two routes on the allow-list,Recover.vuelive, 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. - 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 fromRestrictTeam.
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
(
SendDunningMailtakes aTeam, a stage and anInvoice), so apast_dueteam 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_emailhas no self-serve writer yet. It is only ever set by the enterprise plan editor, so the "plusteams.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 isreport()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.
StartSubscriptionandSubscriptionController::show()now gate onSubscriptions::forTeam()?->ended(), which isends_at-based, so a subscription row sitting inincomplete_expired,unpaidorpaused(all with a nullends_at) is treated as live and blocks a second subscription, wheresubscribed()would have let one through.incompleteandunpaidrecover 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_eventsanamount_centscolumn. 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. quantityisdecimal(20, 6)andRate::lineCents()takes anint. Every service we quote is counted in whole units — messages, tokens — so the column becomes an unsigned integer andlineCents()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.createdas "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 —TeamBillingSummaryis 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". Anopenor settled Stripe invoice is still Stripe's to void.
What is already in place
billing_usage_eventsandbilling_usage_rates, theUsageEventandUsageRatemodels,UsageRate::resolve()(most recent row effective on or before a moment, per-team override preferred),UsageService/UsageUnit,RatewithlineCents(), the seeded platform defaults, the Rates screen and its two actions.billing_invoice_lineswithkind = usagereserved and never yet written.RecordStripeInvoice, which mirrors anyinvoice.*payload intobilling_invoicesunder a row lock, terminal states winning;RECORDED_INVOICE_EVENTScomposes the webhook endpoint's event list.CreateInvoice, which writes a local draft with lines, andIssueInvoice/RecordPaymentfor 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 denormalizesunitandunit_amount_millicentsonto 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_periodasYYYY-MMfromoccurred_atinconfig('billing.timezone'), a new config key defaulting toUTC— 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
internalones. 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:
- Read what is owed as one aggregate.
SUM(quantity)andCOUNT(*)grouped by(billing_period, service, unit, unit_amount_millicents)over the team's uninvoiced events whosebilling_periodis 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 isRate::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 conditionalUPDATEin step 3. - 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 ofusage:{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 onevoidreturn 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 asInvoicePaymentFailure::meansAlreadySettled()handles its cousin. AnIdempotencyExceptionis not that race — the same key with a different body should be unreachable, so it isreport()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. - Record that line, in a transaction, only after Stripe accepted it. Stamp its
events with
invoiced_atandbilling_invoice_idin oneUPDATE … WHERE invoiced_at IS NULLover the group's own tuple, bounded to the rows the aggregate saw by itsMAX(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 onebilling_invoice_linesrow withkind = usage,quantity= the summed count,unit_amount_cents= 0 with the millicent rate and unit inmeta(a line's unit price is not a whole number of cents; the line total is),amount_cents= the line total, andmeta.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.closedrecords 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): stringcreates an empty draft with pending invoice items excluded, for automatic collection but withauto_advanceoff, and returns its id;addInvoiceItem()— the close handler's own method — then pushes each line onto that invoice;finalizeInvoice(Team $team, string $stripeInvoiceId): arrayfinalizes it, passingauto_advance = trueas 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 throughRecordStripeInvoice, 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 goesdraft→openwith its number and totals and theinvoice.finalizedwebhook 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 throughUsage::release()so the next sweep bills them on a fresh invoice. That is whyVoidInvoiceaccepts asource = striperow while it is still a draft, the one exception to 6a's refusal: anopenStripe 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 andinvoice.deletedis 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 itsusage.closedpartial, 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
draftthroughCreateInvoice, one usage line per service per period,period_start/period_endspanning 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 isCloseUsagePeriod::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:
- 6a — the ledger and the close. The
billing.timezoneconfig, the two column migrations,Usage::record(),UsageEvent::uninvoicedClosedPeriods(),CloseUsagePeriodwithaddInvoiceItems()on the gateway and its fake,invoice.createdinRECORDED_INVOICE_EVENTS, the void release, the Issue/Void refusal for Stripe drafts, and the console's two re-pointed sums. - 6b — the sweeper.
createDraftInvoice()andfinalizeInvoice()on the gateway and its fake,billing:close-usage-periodwith both invoice shapes and--dry-run, the schedule entry, and theCreateInvoicearithmetic fix. - 6c — the customer view. The usage card, the expandable invoice lines, and the
TeamBillingSummaryprops 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--> cancelledtransition is driven by the webhook, not by the cancelling action. Cancelling at period end leaves the Stripe subscriptionactivewithcancel_at_period_end = true, so the status mapping alone would leave the teamactiveuntil the period actually ended. "Stripe status is not our access state" gains a second input:cancel_at_period_end.activeortrialingwith the flag set maps tocancelled, withbilling_current_period_ends_atfrom 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 mapsactivewithout it, and the team is back where it was with nothing charged. Acancelledteam whose period has ended isrestrictedby 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'sSubscription::cancel()(setscancel_at_period_end, recordsends_atlocally),resume()(clears both) andonGracePeriod()(ends_atin the future).- The webhook's
customer.subscription.updatedhandling andapplyPeriodEnd(), which already copiescurrent_period_endonto the team. billing:apply-access-expiry, which restricts acancelledteam oncebilling_current_period_ends_athas passed, with reasoncancellation_period_ended.UpdatePaymentMethodandbilling.payment-method.store, the SetupIntent + Stripe.js formRecoveryControls.vuerenders, andRecoveryOptions— the one place that decides which card controls a page offers.DunningRecipients::for(), which already readsteams.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
canChangeCardis 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/cancel → billing.cancel.store, DELETE billing/cancel →
billing.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
- 7a — cancel and resume.
SubscriptionStatusMapwith 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. - 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 = storageslot 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 oneUsage::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_productionand.env.for_betaand roll the webhook signing secret; disable Stripe's Smart Retries in the dashboard before the dunning loop goes live; add the seveninvoice.*events (includinginvoice.created) to the existing webhook endpoint's event list by hand; resend pastinvoice.finalized/invoice.paidevents so pre-5a invoices get local rows; setBILLING_TIMEZONEif it is not UTC; note that 6a's migration dropsbilling_usage_events.amount_cents.devhas 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 oneexists()check inRetryPastDuebeforereport(). - 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
SendDunningMailan invoice-less stage. billing_emailfor 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_expiredsubscriptions 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_advanceoff) with recorded local lines; the remedy is a hand deletion at Stripe plus a local void. The only signal today is thereport()at the moment of failure. A stripe-sourceddraftolder 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 onlyreport()ed; nothing later reconcilesbilling_team_modulesagainst the subscription's items the waybilling:reconcile-seatsdoes for seats.- Copy for a
past_dueself-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 page2,500. PromoteTeamBillingSummary::quantity(). outstandingCents()has noissued_atfilter. Consistent only becauseIssueInvoiceis the sole writer ofOpen; add the filter if a second appears.- Under a non-UTC
BILLING_TIMEZONE,period_enddisplays a day late — thebilling_invoicesperiod columns aredate, and an end-of-month instant truncates. Pre-existing since 2b; no money or selection reads it. CloseUsagePeriodis 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 lackswithTrashed()whileresolveTeam()has it (a subscription event for a soft-deleted team throws and Stripe retries for three days);billing_payments.referenceis unindexed. - Two test-suite artefacts:
Settings\Modules\Hr\DepartmentTest::test_store_allows_duplicate_code_for_a_different_teamfails on every run (pre-existing, unrelated to billing);SuperAdmin\TeamCrudTest::test_index_lists_teamsasserts 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 becauseCashier::stripe()has no seam;PaymentGatewayContractTestcovers conformance and the liveStripeContractTestgroup 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.