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
phpis MAMP's 8.2 and too old. Useherd php artisan .... - Tests are scoped and minimal — the user has a standing instruction against whole-suite runs and against exhaustive coverage. Test the money-and-access behaviours, one mutation check per new production rule, nothing else. Run
tests/Feature/Billing,tests/Feature/Auth,tests/Feature/Settings,tests/Feature/SuperAdmin, plustests/Unitwhenapp/Supportorapp/Enumschanges. Command:herd php -d memory_limit=2G artisan test --compact <paths>. Foreground only — an interrupted run corrupts shared MongoDB test state. - No background tasks. Nothing will notify you.
- Never call Stripe inside a database transaction. 4a's worst bug was a Stripe call inside
transaction()+lockForUpdate(); 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.
RecordPaymentis 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).SubscriptionStatusMapowns Stripe-status meaning.Money::format()owns money display.Seats::occupiedBy()is the seat count. Call them. billing_access_stateis 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, andRecordStripeInvoicenever touch it; a successful payment restores access because Stripe sendsinvoice.paid/invoice.payment_succeededand the existing mapping runs.- No new
BillingAccessStatecase. Day 21 ends inrestricted(self-serve recovery), neversuspended(superadmin only) — spec, "Vocabulary". PaymentGatewayis the only route to Stripe. Every method on the interface is implemented by bothStripePaymentGatewayandFakePaymentGateway; tests use$this->swap(PaymentGateway::class, new FakePaymentGateway). The interface docblock forbids methods without callers — amend that clause the way 4c and 4d did, in the same commit as the caller.- Integer cents, never floats. No
.vuecomputes 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 Mailableimplements ShouldQueue, followingapp/Mail/NotificationDigest.php. Markdown views underresources/views/emails/billing/. Tests useMail::fake()andMail::assertQueued(). - Formatting:
vendor/bin/pint --dirty --format agent, thennpm run format/npm run lint, reverting collateral outside your files.npm run buildbefore any Inertia render test. PHPStan: the branch is at 124 pre-existing errors (ProjectManagement/Sales) — parity with base is the bar, zero in any Billing path. - Pre-existing failure, do not fix:
Settings\Modules\Hr\DepartmentTest::test_store_allows_duplicate_code_for_a_different_team. - Deploy notes to carry in 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_STATUSat ~82,statusFor()~264, phase 3 ofhandleWebhook()~142, theparent::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(extendinvoiceEventPayload()to carry a full invoice object)
Interfaces:
-
Produces:
RecordStripeInvoice::handle(Team $team, array $stripeInvoice): Invoice—$stripeInvoiceis$payload['data']['object']of anyinvoice.*event. Upserts onstripe_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 columnbilling_payments.recorded_by_user_idalready is).BillingInvoiceStatus(five cases map 1:1 to Stripe'sdraft|open|paid|void|uncollectible).BillingInvoiceSource::Stripe.Invoicemodel 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.RecordStripeInvoiceis called in phase 3, inside the sameDB::transaction()asapplyAccessState(), for everyinvoice.*event the controller routes — it makes no Stripe call, so it is safe there. Note thatparent::handleWebhook()only dispatches tohandle*methods Cashier knows; the four new events need no Cashier handler, only ours. -
Step 2: Write the failing tests in
StripeInvoiceRecordingTest(extendStripeWebhookTest::invoiceEventPayload()so the invoice object carriesnumber,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.finalizedwithstatus = opencreates a row:source = stripe,numberset,status = Open,issued_at = finalized_at,subtotal_cents/total_cents/currency/due_at/pdf_url/period_*mapped.invoice.payment_failedon the same invoice updates the row (stillOpen), does not create a second one.invoice.paidmoves it toPaid, setspaid_at, and creates exactly onebilling_paymentsrow withmethod = Stripe,amount_cents = amount_paid,reference = payment_intent,recorded_by_user_id = null. Theninvoice.payment_succeededfor the same invoice, and a redelivery ofinvoice.paidunder a new event id, create no second payment (the mutation check for the idempotency key).- Terminal states win:
invoice.paidfirst, then a lateinvoice.finalizedcarryingstatus = open— the row staysPaid. Same forvoidanduncollectible. invoice.voidedon an issued invoice keepsissued_atandnumber, setsVoid.- A
draftinvoice (invoice.createdis NOT handled — assert the controller ignores it: no row) — the first row appears atfinalized. - 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 afterfinalizedwithamount_dueset, and afterpaidwithamount_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. OneupdateOrCreatekeyed on['stripe_invoice_id' => $id]with the column map from the spec (subtotal→subtotal_cents,total→total_cents). Status isBillingInvoiceStatus::from($stripeInvoice['status']), but if the existing row isPaid,VoidorUncollectibleand the incoming status isOpenorDraft, keep the existing status (terminal states win) — write this as one privateresolveStatus(?Invoice $existing, BillingInvoiceStatus $incoming)with a docblock citing the spec. Setissued_atfromstatus_transitions.finalized_atwhenever the resolved status is notDraft. When the incoming status ispaidandamount_paid > 0: if nobilling_paymentsrow exists for this invoice withreference = payment_intent, callRecordPayment::handle($invoice, BillingPaymentMethod::Stripe, amount_paid, paid_at, null, payment_intent).RecordPaymentlocks the invoice and flips it toPaidwhen fully paid — that is the one rule, do not setPaidyourself in that branch. -
Step 5: Wire the controller. Add
invoice.finalized,invoice.paid,invoice.voided,invoice.marked_uncollectibleto the events phase 3 handles for invoice recording (all sixinvoice.*types callRecordStripeInvoice). Do not add them toINVOICE_EVENT_STATUS—paid/voidedcarry no access-state opinion beyond whatpayment_succeeded/payment_failedalready express, and adding one would be a second mapping. MakeRecordPayment's actor nullable with a one-line docblock:nullmeans 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, thesubscribed('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 +confirmCardSetuplifted out ofSubscribe.vueso 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 fillspm_type/pm_last_four);PaymentGateway::payInvoice(Team $team, string $stripeInvoiceId): void(Cashier$team->findInvoiceOrFail($id)->pay()— throwsIncompletePaymenton decline/SCA; let it propagate to the action).FakePaymentGatewayrecordspublic array $paymentMethodsUpdated = []([team_id, payment_method_id]) andpublic array $invoicesPaid = []([team_id, stripe_invoice_id]); the fake'spayInvoicemay 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, ifOpenStripeInvoice::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 inapp/Support/Billing/OpenStripeInvoice::for(Team): ?Invoicebecause Task 3's command uses the same lookup), callspayInvoice(), recordsbilling_eventsdunning.paid_now(success) ordunning.pay_now_failed(caughtIncompletePayment, re-thrown asValidationExceptiononpaymentso the page shows the decline). Neither action writes access state. -
Routes:
POST billing/payment-method→billing.payment-method.store;POST billing/pay→billing.pay.store. Both in the existing Owner/Admin group (EnsureTeamMembership::class.':admin'), both refuse enterprise (403 in the controller, samerefuse()shape asModuleController), both added toEnsureTeamNotRestricted::ALLOWED_ROUTESwith a comment. -
Props:
RestrictionControllerandTeamBillingSummaryboth emitcanUpdateCard: bool(self-serve, not suspended, has a Stripe customer id),canPayNow: bool(self-serve, not suspended,OpenStripeInvoice::for($team) !== null), andsetupIntentClientSecret: string|null(fromcreateSetupIntent(), only whencanUpdateCard). One private helper each is fine; the rule text is identical and short — but the invoice lookup isOpenStripeInvoice::for()in both, never re-written. -
Step 1: Close the 4a hole first, with its test. In
StartSubscriptionTest: a team whose subscription ispast_due(write the Cashier row withstripe_status = past_due) callingStartSubscription::handle()gets the "already has an active subscription"ValidationExceptionand nocreateSubscription()call on the fake. Then change the guard atStartSubscription~168 from$locked->subscribed('default')toSubscriptions::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_dueself-serve Owner postsbilling.payment-method.storewithpayment_method_id = pm_test: the fake recordspaymentMethodsUpdated, and because an open Stripe invoice exists (create it throughRecordStripeInvoiceor direct insert),invoicesPaidhas exactly one entry for that invoice;billing_eventshasdunning.paid_now; redirect back with a success flash;billing_access_stateunchanged (stillpast_due— the webhook flips it, and no webhook ran). - The same with no open invoice: card updated,
invoicesPaidempty. - A
restrictedteam 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.storewhen the fake'spayInvoiceThrowsisIncompletePayment: the response carries apaymentvalidation error,billing_eventshasdunning.pay_now_failed, andbilling_access_stateunchanged.- Transaction invariant:
DB::transactionLevel()is 0 at the moment the fake'spayInvoice/updateDefaultPaymentMethodare called (record it in the fake, assert it), the same shape as 4c'stest_the_gateway_is_never_called_from_inside_a_door_transaction. - Recover page props:
canUpdateCard/canPayNow/setupIntentClientSecretfor a restricted self-serve team with an open invoice; all false/null for a suspended team and for an enterprise team.
- A
-
Step 3: Implement the gateway methods, the fake, the two actions, the request, the two controllers, the routes, the allow-list entries. Amend the
PaymentGatewaydocblock's not-yet list:setDefaultPaymentMethod"earns its way back with a caller" — this is the caller; name itupdateDefaultPaymentMethodto match Cashier's verb. -
Step 4: Frontend. Extract the Stripe.js element +
confirmCardSetupflow fromSubscribe.vueintocomponents/billing/CardCapture.vue(props:stripeKey,clientSecret; emitscaptured(paymentMethodId)andfailed(message));Subscribe.vueuses it with no behaviour change.Recover.vue: whencanUpdateCard, renderCardCapture+ "Update card and pay"; whencanPayNow(and a card is on file), a "Pay now"Link as="button" method="post"tobilling.pay.store.Index.vue(4e): same two controls in the payment-card block, shown whenplan.state === 'past_due'orcard === nullforcanUpdateCard, andcanPayNowfor the button. Wayfinder actions, no hardcoded URLs.npm run build; no new page, so noresolveLayoutcase —CardCaptureis 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 beforebilling:apply-access-expiry,daily()->withoutOverlapping(), with a comment on why the order matters),app/Http/Controllers/Billing/StripeWebhookController.php(first-failure mail whenapplyAccessState()moves a team intoPastDuefrom 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()andOpenStripeInvoice::for()from Task 2;RestrictTeam::handle(Team, string $reason, ?User)recordsaccess.restrictedwithreason;ApplyAccessExpirypasses'grace_expired'forpast_dueteams;ExtendGracemovesbilling_grace_ends_at;StripeWebhookController::GRACE_DAYS = 21;Team::activeMembers()(the pivot hasroleandstatus). -
Produces:
DunningSchedule::stagesDue(CarbonInterface $graceEndsAt, CarbonInterface $now): list<DunningStage>whereenum 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 atgraceEndsAt - 14d,- 7d,- 2d(returns every reminder stage whose due moment is<= now, in order;FirstFailureandRestrictedare 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 plusbilling_emailwhen set, deduplicated case-insensitively, empty for enterprise.SendDunningMail::handle(Team $team, DunningStage $stage, Invoice $invoice): void— no-op if abilling_eventsrowdunning.emailedwithpayload.key === sendKey(...)exists for this team; otherwise queues the right Mailable to every recipient and records the event withkey,stage,recipients. -
Step 1:
DunningScheduleunit tests first (tests/Unit/Billing/DunningScheduleTest.php): forgraceEndsAt = 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; afterExtendGraceto 2026-11-05: on 10-23 →[](re-armed —Day7is now due 10-22 but the key differs, so it will send again, which the spec wants), andsendKey(Day19, 10-22) !== sendKey(Day19, 11-05). -
Step 2: Failing tests for the command (
RetryPastDueTest,Mail::fake(), fake gateway): apast_dueself-serve team with grace in the future and an open Stripe invoice → onepayInvoice()call for that invoice, onedunning.retriedevent; a decline (payInvoiceThrows) →dunning.retry_failed, exception reported not thrown, the command exits 0 and continues to the next team; anactiveteam, an enterprisepast_dueteam, a team whose grace has passed, and a team with no open invoice → no call; the command writes no access state (assertpast_dueafter 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 onePaymentReminderper recipient withstage = Day7, and a second run the same day queues none (thedunning.emailedkey). -
Step 3: Failing tests for the event-driven mails (
DunningMailTest): the webhook movingactive → past_duequeuesPaymentFailedto Owner + Admin +billing_email(deduped; a Manager and a deactivated Owner get nothing), once — a secondpayment_failedwhile alreadypast_duequeues nothing;ApplyAccessExpiryrestricting apast_dueteam queuesAccountRestricted; restricting acancelledteam (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 thebilling.indexURL — oneassertSeeInHtmleach, not a copy matrix. -
Step 4: Implement the enum, the two support classes,
SendDunningMail, the three Mailables (markdown,ShouldQueue, constructor takesTeam,Invoice, and forPaymentRemindertheDunningStage+daysRemaining: intcomputed server-side), the command (selection query from the spec; per team:payInvoice(), record the event, catchIncompletePayment/Throwable→report()+dunning.retry_failed; thenSendDunningMailfor each stage instagesDue(); summary table likeReconcileSeats), the webhook hook (detect the transition intoPastDueinsideapplyAccessState()'s transaction, send after it returns), theRestrictTeamhook, 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, neversuspended) → Global Constraints; "does not do" list → nothing here writes lines, models partial payments, cancels, or links a portal. - Type consistency:
OpenStripeInvoice::for(Team): ?Invoiceis defined in Task 2 and consumed in Task 3;DunningStageandDunningSchedule::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$payInvoiceThrowsis 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.emailedkey 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".