OCTO Ops Help User guides and product documentation

2026 09 12 Billing 5 Dunning

On this page 5

Increment 5 — dunning: Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: When a self-serve team's card fails, retry it daily for 21 days, tell the Owners and Admins five times, give them a button that fixes it, and restrict the team on day 21 if nothing worked.

Architecture: Three sub-increments, executed and merged one at a time, each its own branch off dev: 5a makes Stripe invoices local billing_invoices rows via webhooks (the data everything else reads); 5c gives a past_due/restricted team a way to update its card and pay now; 5b adds the daily retry command and the five emails. Order is 5a → 5c → 5b so the customer's way out exists before we start emailing them about it. Every Stripe call goes through PaymentGateway, after any local transaction commits; no action or command writes billing_access_state — the webhook does, exactly as today.

Tech Stack: Laravel 13, Cashier v16, Inertia v3 + Vue 3, MySQL, database queue for mail.

Spec: docs/superpowers/specs/2026-09-09-saas-monetization-design.md — "Increment 5 in detail — dunning" (~line 1719), plus "Dunning" (~294) and "Access state machine" (~270). The spec is binding; where this plan and the spec disagree, the spec wins.

Global Constraints

  • PHP binary: plain php is MAMP's 8.2 and too old. Use herd php artisan ....
  • Tests are scoped and minimal — the user has a standing instruction against whole-suite runs and against exhaustive coverage. Test the money-and-access behaviours, one mutation check per new production rule, nothing else. Run tests/Feature/Billing, tests/Feature/Auth, tests/Feature/Settings, tests/Feature/SuperAdmin, plus tests/Unit when app/Support or app/Enums changes. Command: herd php -d memory_limit=2G artisan test --compact <paths>. Foreground only — an interrupted run corrupts shared MongoDB test state.
  • No background tasks. Nothing will notify you.
  • Never call Stripe inside a database transaction. 4a's worst bug was a Stripe call inside transaction() + lockForUpdate(); the rollback-and-retry path created a second live subscription and charged twice. Every gateway call in this plan happens after the transaction commits, or in a command that has no transaction at all.
  • No billing rule gets a second implementation. RecordPayment is the one way a payment enters the ledger. Invoice::outstandingCents() / BillingInvoiceStatus::isOutstanding() are the one balance rule. Subscriptions::forTeam() / liveFor() are the one subscription lookup (never Cashier's $team->subscription() relation property). SubscriptionStatusMap owns Stripe-status meaning. Money::format() owns money display. Seats::occupiedBy() is the seat count. Call them.
  • billing_access_state is written by the webhook. The initial value at team creation (4b/4f) is the only exception. Nothing in this plan adds another. The retry command, both recovery actions, and RecordStripeInvoice never touch it; a successful payment restores access because Stripe sends invoice.paid / invoice.payment_succeeded and the existing mapping runs.
  • No new BillingAccessState case. Day 21 ends in restricted (self-serve recovery), never suspended (superadmin only) — spec, "Vocabulary".
  • PaymentGateway is the only route to Stripe. Every method on the interface is implemented by both StripePaymentGateway and FakePaymentGateway; tests use $this->swap(PaymentGateway::class, new FakePaymentGateway). The interface docblock forbids methods without callers — amend that clause the way 4c and 4d did, in the same commit as the caller.
  • Integer cents, never floats. No .vue computes money. No new model factories. Billing tables MySQL, never MongoDB.
  • No real Stripe key, secret or whsec_ in any committed file.
  • Mail is queued: Mail::to(...)->queue(new X); every Mailable implements ShouldQueue, following app/Mail/NotificationDigest.php. Markdown views under resources/views/emails/billing/. Tests use Mail::fake() and Mail::assertQueued().
  • Formatting: vendor/bin/pint --dirty --format agent, then npm run format / npm run lint, reverting collateral outside your files. npm run build before any Inertia render test. PHPStan: the branch is at 124 pre-existing errors (ProjectManagement/Sales) — parity with base is the bar, zero in any Billing path.
  • Pre-existing failure, do not fix: Settings\Modules\Hr\DepartmentTest::test_store_allows_duplicate_code_for_a_different_team.
  • Deploy notes to carry in the final commit message of 5b: Stripe's automatic retries must be disabled in the dashboard, and the four new invoice events added to the webhook endpoint's event list if it is configured with an explicit set.

Task 1: 5a — Stripe invoices as local rows

Spec: "Approach: Stripe invoices become local rows". Read it first — it carries the column map, the terminal-states-win rule, and the payment idempotency key.

Files:

  • Create: app/Actions/Billing/RecordStripeInvoice.php
  • Modify: app/Http/Controllers/Billing/StripeWebhookController.php (INVOICE_EVENT_STATUS at ~82, statusFor() ~264, phase 3 of handleWebhook() ~142, the parent::handleWebhook() event allow-list), app/Actions/Billing/RecordPayment.php (User $actor?User $actor), app/Queries/Billing/TeamBillingSummary.php (only if the issued-only filter needs no change — verify, do not assume), docs/superpowers/specs/... no — the spec is already correct
  • Test: tests/Feature/Billing/StripeInvoiceRecordingTest.php (new), tests/Feature/Billing/StripeWebhookTest.php (extend invoiceEventPayload() to carry a full invoice object)

Interfaces:

  • Produces: RecordStripeInvoice::handle(Team $team, array $stripeInvoice): Invoice$stripeInvoice is $payload['data']['object'] of any invoice.* event. Upserts on stripe_invoice_id. Returns the local row.

  • Consumes: RecordPayment::handle(Invoice, BillingPaymentMethod::Stripe, int $amountCents, CarbonInterface $paidAt, ?User $actor, ?string $reference) — actor becomes nullable in this task (the column billing_payments.recorded_by_user_id already is). BillingInvoiceStatus (five cases map 1:1 to Stripe's draft|open|paid|void|uncollectible). BillingInvoiceSource::Stripe. Invoice model columns: team_id, number (nullable), source, stripe_invoice_id, period_start, period_end, subtotal_cents, total_cents, currency, status, issued_at, due_at, paid_at, pdf_url, notes.

  • Step 1: Read the webhook controller's three phases (its class docblock, ~41-65). Phase 1 claims the event atomically via billing_events.stripe_event_id; phase 2 is Cashier's own handling with no transaction; phase 3 is our transactional writes. RecordStripeInvoice is called in phase 3, inside the same DB::transaction() as applyAccessState(), for every invoice.* event the controller routes — it makes no Stripe call, so it is safe there. Note that parent::handleWebhook() only dispatches to handle* methods Cashier knows; the four new events need no Cashier handler, only ours.

  • Step 2: Write the failing tests in StripeInvoiceRecordingTest (extend StripeWebhookTest::invoiceEventPayload() so the invoice object carries number, status, subtotal, total, currency, due_date, invoice_pdf, status_transitions.{finalized_at,paid_at}, lines.data[0].period.{start,end}, payment_intent, amount_paid):

    • invoice.finalized with status = open creates a row: source = stripe, number set, status = Open, issued_at = finalized_at, subtotal_cents/total_cents/currency/due_at/pdf_url/period_* mapped.
    • invoice.payment_failed on the same invoice updates the row (still Open), does not create a second one.
    • invoice.paid moves it to Paid, sets paid_at, and creates exactly one billing_payments row with method = Stripe, amount_cents = amount_paid, reference = payment_intent, recorded_by_user_id = null. Then invoice.payment_succeeded for the same invoice, and a redelivery of invoice.paid under a new event id, create no second payment (the mutation check for the idempotency key).
    • Terminal states win: invoice.paid first, then a late invoice.finalized carrying status = open — the row stays Paid. Same for void and uncollectible.
    • invoice.voided on an issued invoice keeps issued_at and number, sets Void.
    • A draft invoice (invoice.created is NOT handled — assert the controller ignores it: no row) — the first row appears at finalized.
    • An invoice whose customer resolves to no team is ignored, not an error (mirror resolveTeam()'s null handling).
    • The 4e page: TeamBillingSummary::for($team)['invoices'] lists the Stripe row after finalized with amount_due set, and after paid with amount_due === null.
  • Step 3: Run them to confirm they fail for the right reason (unknown event / no row), not a fixture error.

  • Step 4: Implement RecordStripeInvoice. One updateOrCreate keyed on ['stripe_invoice_id' => $id] with the column map from the spec (subtotalsubtotal_cents, totaltotal_cents). Status is BillingInvoiceStatus::from($stripeInvoice['status']), but if the existing row is Paid, Void or Uncollectible and the incoming status is Open or Draft, keep the existing status (terminal states win) — write this as one private resolveStatus(?Invoice $existing, BillingInvoiceStatus $incoming) with a docblock citing the spec. Set issued_at from status_transitions.finalized_at whenever the resolved status is not Draft. When the incoming status is paid and amount_paid > 0: if no billing_payments row exists for this invoice with reference = payment_intent, call RecordPayment::handle($invoice, BillingPaymentMethod::Stripe, amount_paid, paid_at, null, payment_intent). RecordPayment locks the invoice and flips it to Paid when fully paid — that is the one rule, do not set Paid yourself in that branch.

  • Step 5: Wire the controller. Add invoice.finalized, invoice.paid, invoice.voided, invoice.marked_uncollectible to the events phase 3 handles for invoice recording (all six invoice.* types call RecordStripeInvoice). Do not add them to INVOICE_EVENT_STATUSpaid/voided carry no access-state opinion beyond what payment_succeeded/payment_failed already express, and adding one would be a second mapping. Make RecordPayment's actor nullable with a one-line docblock: null means the payment provider recorded it.

  • Step 6: Run the scoped set, Pint, PHPStan, commit. Subject: Billing 5a: Stripe invoices as local rows.


Task 2: 5c — the recovery path

Spec: "The recovery path" and "Closing the increment 4a hole". Read both first.

Files:

  • Modify: app/Support/Billing/PaymentGateway.php, StripePaymentGateway.php, FakePaymentGateway.php; app/Actions/Billing/StartSubscription.php (~160-176, the subscribed('default') guard); app/Http/Controllers/Billing/RestrictionController.php (Recover props); app/Queries/Billing/TeamBillingSummary.php (two booleans + client secret); resources/js/pages/billing/Recover.vue, resources/js/pages/billing/Index.vue; routes/web/billing.php; app/Http/Middleware/EnsureTeamNotRestricted.php (ALLOWED_ROUTES)
  • Create: app/Actions/Billing/UpdatePaymentMethod.php, app/Actions/Billing/PayOpenInvoice.php, app/Http/Controllers/Billing/PaymentMethodController.php (store), app/Http/Controllers/Billing/InvoicePaymentController.php (store), app/Http/Requests/Billing/UpdatePaymentMethodRequest.php, resources/js/components/billing/CardCapture.vue (the Stripe.js element + confirmCardSetup lifted out of Subscribe.vue so two pages share it — do not duplicate the Stripe.js block)
  • Test: tests/Feature/Billing/RecoveryPathTest.php (new), tests/Feature/Billing/StartSubscriptionTest.php (the hole), tests/Unit/Billing/StripePaymentGatewayTest.php (the two gateway methods against the spy)

Interfaces:

  • Produces: PaymentGateway::updateDefaultPaymentMethod(Team $team, string $paymentMethodId): void (Cashier $team->updateDefaultPaymentMethod($pm) — it attaches, sets default on the Stripe customer, and fills pm_type/pm_last_four); PaymentGateway::payInvoice(Team $team, string $stripeInvoiceId): void (Cashier $team->findInvoiceOrFail($id)->pay() — throws IncompletePayment on decline/SCA; let it propagate to the action). FakePaymentGateway records public array $paymentMethodsUpdated = [] ([team_id, payment_method_id]) and public array $invoicesPaid = [] ([team_id, stripe_invoice_id]); the fake's payInvoice may be told to throw via a public ?Throwable $payInvoiceThrows = null.

  • Produces: UpdatePaymentMethod::handle(Team $team, string $paymentMethodId): void — gateway call (no transaction needed; nothing local to write), then, if OpenStripeInvoice::for($team) is non-null, PayOpenInvoice::handle($team). PayOpenInvoice::handle(Team $team): void — resolves the open Stripe-sourced invoice locally (Invoice::where('team_id')->where('source', Stripe)->where('status', Open)->latest('issued_at')->first(); put this one query in app/Support/Billing/OpenStripeInvoice::for(Team): ?Invoice because Task 3's command uses the same lookup), calls payInvoice(), records billing_events dunning.paid_now (success) or dunning.pay_now_failed (caught IncompletePayment, re-thrown as ValidationException on payment so the page shows the decline). Neither action writes access state.

  • Routes: POST billing/payment-methodbilling.payment-method.store; POST billing/paybilling.pay.store. Both in the existing Owner/Admin group (EnsureTeamMembership::class.':admin'), both refuse enterprise (403 in the controller, same refuse() shape as ModuleController), both added to EnsureTeamNotRestricted::ALLOWED_ROUTES with a comment.

  • Props: RestrictionController and TeamBillingSummary both emit canUpdateCard: bool (self-serve, not suspended, has a Stripe customer id), canPayNow: bool (self-serve, not suspended, OpenStripeInvoice::for($team) !== null), and setupIntentClientSecret: string|null (from createSetupIntent(), only when canUpdateCard). One private helper each is fine; the rule text is identical and short — but the invoice lookup is OpenStripeInvoice::for() in both, never re-written.

  • Step 1: Close the 4a hole first, with its test. In StartSubscriptionTest: a team whose subscription is past_due (write the Cashier row with stripe_status = past_due) calling StartSubscription::handle() gets the "already has an active subscription" ValidationException and no createSubscription() call on the fake. Then change the guard at StartSubscription ~168 from $locked->subscribed('default') to Subscriptions::forTeam($locked)?->ended() === false (a subscription exists and has not ended) and keep the existing message. Mutation check: revert the guard, the test fails.

  • Step 2: Write the failing recovery tests in RecoveryPathTest:

    • A past_due self-serve Owner posts billing.payment-method.store with payment_method_id = pm_test: the fake records paymentMethodsUpdated, and because an open Stripe invoice exists (create it through RecordStripeInvoice or direct insert), invoicesPaid has exactly one entry for that invoice; billing_events has dunning.paid_now; redirect back with a success flash; billing_access_state unchanged (still past_due — the webhook flips it, and no webhook ran).
    • The same with no open invoice: card updated, invoicesPaid empty.
    • A restricted team reaches both routes (the middleware allow-list) — assert 302-to-back, not 302-to-billing.restricted.
    • A Manager gets 403; an enterprise Owner gets 403.
    • billing.pay.store when the fake's payInvoiceThrows is IncompletePayment: the response carries a payment validation error, billing_events has dunning.pay_now_failed, and billing_access_state unchanged.
    • Transaction invariant: DB::transactionLevel() is 0 at the moment the fake's payInvoice/updateDefaultPaymentMethod are called (record it in the fake, assert it), the same shape as 4c's test_the_gateway_is_never_called_from_inside_a_door_transaction.
    • Recover page props: canUpdateCard/canPayNow/setupIntentClientSecret for a restricted self-serve team with an open invoice; all false/null for a suspended team and for an enterprise team.
  • Step 3: Implement the gateway methods, the fake, the two actions, the request, the two controllers, the routes, the allow-list entries. Amend the PaymentGateway docblock's not-yet list: setDefaultPaymentMethod "earns its way back with a caller" — this is the caller; name it updateDefaultPaymentMethod to match Cashier's verb.

  • Step 4: Frontend. Extract the Stripe.js element + confirmCardSetup flow from Subscribe.vue into components/billing/CardCapture.vue (props: stripeKey, clientSecret; emits captured(paymentMethodId) and failed(message)); Subscribe.vue uses it with no behaviour change. Recover.vue: when canUpdateCard, render CardCapture + "Update card and pay"; when canPayNow (and a card is on file), a "Pay now" Link as="button" method="post" to billing.pay.store. Index.vue (4e): same two controls in the payment-card block, shown when plan.state === 'past_due' or card === null for canUpdateCard, and canPayNow for the button. Wayfinder actions, no hardcoded URLs. npm run build; no new page, so no resolveLayout case — CardCapture is a component.

  • Step 5: Run the scoped set + tests/Unit, Pint, PHPStan, commit. Subject: Billing 5c: update card and pay now.


Task 3: 5b — the retry loop and the emails

Spec: "The retry loop", "Emails" (especially "Anchoring"), and "What increment 5 does not do". Read all three first.

Files:

  • Create: app/Console/Commands/RetryPastDue.php (billing:retry-past-due), app/Support/Billing/DunningSchedule.php, app/Support/Billing/DunningRecipients.php, app/Mail/Billing/PaymentFailed.php, app/Mail/Billing/PaymentReminder.php, app/Mail/Billing/AccountRestricted.php, resources/views/emails/billing/payment-failed.blade.php, payment-reminder.blade.php, account-restricted.blade.php, app/Actions/Billing/SendDunningMail.php
  • Modify: routes/console.php (schedule the command immediately before billing:apply-access-expiry, daily()->withoutOverlapping(), with a comment on why the order matters), app/Http/Controllers/Billing/StripeWebhookController.php (first-failure mail when applyAccessState() moves a team into PastDue from another state — after the transaction commits, since queuing mail inside it is harmless but the rule is cleaner stated), app/Actions/Billing/RestrictTeam.php (restriction mail when $reason === 'grace_expired', after commit)
  • Test: tests/Feature/Billing/RetryPastDueTest.php, tests/Feature/Billing/DunningMailTest.php, tests/Unit/Billing/DunningScheduleTest.php

Interfaces:

  • Consumes: PaymentGateway::payInvoice() and OpenStripeInvoice::for() from Task 2; RestrictTeam::handle(Team, string $reason, ?User) records access.restricted with reason; ApplyAccessExpiry passes 'grace_expired' for past_due teams; ExtendGrace moves billing_grace_ends_at; StripeWebhookController::GRACE_DAYS = 21; Team::activeMembers() (the pivot has role and status).

  • Produces: DunningSchedule::stagesDue(CarbonInterface $graceEndsAt, CarbonInterface $now): list<DunningStage> where enum DunningStage: string { case FirstFailure = 'first_failure'; case Day7 = 'day_7'; case Day14 = 'day_14'; case Day19 = 'day_19'; case Restricted = 'restricted'; } and the reminders are due at graceEndsAt - 14d, - 7d, - 2d (returns every reminder stage whose due moment is <= now, in order; FirstFailure and Restricted are never returned by this method — they are event-driven). DunningSchedule::sendKey(DunningStage $stage, CarbonInterface $graceEndsAt): string = "{stage}@{graceEndsAt->toDateString()}". DunningRecipients::for(Team $team): list<string> — active Owner + Admin account emails plus billing_email when set, deduplicated case-insensitively, empty for enterprise. SendDunningMail::handle(Team $team, DunningStage $stage, Invoice $invoice): void — no-op if a billing_events row dunning.emailed with payload.key === sendKey(...) exists for this team; otherwise queues the right Mailable to every recipient and records the event with key, stage, recipients.

  • Step 1: DunningSchedule unit tests first (tests/Unit/Billing/DunningScheduleTest.php): for graceEndsAt = 2026-10-22 (21 days after a 2026-10-01 failure): on 10-07 → []; on 10-08 → [Day7]; on 10-15 → [Day7, Day14]; on 10-20 → all three; after ExtendGrace to 2026-11-05: on 10-23 → [] (re-armed — Day7 is now due 10-22 but the key differs, so it will send again, which the spec wants), and sendKey(Day19, 10-22) !== sendKey(Day19, 11-05).

  • Step 2: Failing tests for the command (RetryPastDueTest, Mail::fake(), fake gateway): a past_due self-serve team with grace in the future and an open Stripe invoice → one payInvoice() call for that invoice, one dunning.retried event; a decline (payInvoiceThrows) → dunning.retry_failed, exception reported not thrown, the command exits 0 and continues to the next team; an active team, an enterprise past_due team, a team whose grace has passed, and a team with no open invoice → no call; the command writes no access state (assert past_due after a successful call — the webhook is what would flip it); running it twice in a day makes two calls (documented, not guarded); on the −14 day it queues exactly one PaymentReminder per recipient with stage = Day7, and a second run the same day queues none (the dunning.emailed key).

  • Step 3: Failing tests for the event-driven mails (DunningMailTest): the webhook moving active → past_due queues PaymentFailed to Owner + Admin + billing_email (deduped; a Manager and a deactivated Owner get nothing), once — a second payment_failed while already past_due queues nothing; ApplyAccessExpiry restricting a past_due team queues AccountRestricted; restricting a cancelled team (period_ended) queues nothing; an enterprise team queues nothing on any path. Each Mailable's rendered content includes the formatted amount (Money::format), the invoice number, and the billing.index URL — one assertSeeInHtml each, not a copy matrix.

  • Step 4: Implement the enum, the two support classes, SendDunningMail, the three Mailables (markdown, ShouldQueue, constructor takes Team, Invoice, and for PaymentReminder the DunningStage + daysRemaining: int computed server-side), the command (selection query from the spec; per team: payInvoice(), record the event, catch IncompletePayment/Throwablereport() + dunning.retry_failed; then SendDunningMail for each stage in stagesDue(); summary table like ReconcileSeats), the webhook hook (detect the transition into PastDue inside applyAccessState()'s transaction, send after it returns), the RestrictTeam hook, and the schedule entry.

  • Step 5: Run the scoped set + tests/Unit, Pint, PHPStan, commit with the two deploy notes in the message body. Subject: Billing 5b: daily retry and dunning emails.


Self-review notes

  • Spec coverage: invoice persistence + terminal-states-win + payment idempotency → Task 1; retry loop (daily, before the sweep, no access state) → Task 3; five emails, anchoring, recipients, content → Task 3; recovery path + immediate pay after card update + allow-list + enterprise refusal → Task 2; 4a hole → Task 2 Step 1; vocabulary (restricted, never suspended) → Global Constraints; "does not do" list → nothing here writes lines, models partial payments, cancels, or links a portal.
  • Type consistency: OpenStripeInvoice::for(Team): ?Invoice is defined in Task 2 and consumed in Task 3; DunningStage and DunningSchedule::sendKey() are defined in Task 3 only; payInvoice(Team, string) is the same signature in Task 2's interface block and Task 3's command; the fake's $payInvoiceThrows is named the same in both tasks.
  • The riskiest thing in this plan is the transaction rule in Task 2, and it has an explicit invariant test. The second riskiest is double-sending mail, and the dunning.emailed key plus the "second run queues none" test covers it.
  • Deliberately deferred: Stripe Smart Retries (disabled), enterprise dunning, invoice lines, partial payments, cancellation, SMS, Customer Portal — all named in the spec's "What increment 5 does not do".