Phase 0 Research: Subscription Payment Failure Grace Period & Dunning Emails
Phase 0 Research: Subscription Payment Failure Grace Period & Dunning Emails
All items below were unknowns in the Technical Context or open design questions in the spec. Each
is resolved by grounding in the existing codebase and a prior, working (unmerged) implementation
of this exact feature on origin/feat/1023-grace-cron-userpayment (worktree
/private/tmp/kilo/wt-grace-pr), which itself evolved from an earlier, more complex
outbox/job-based design (origin/feat/1023-grace-period-7-days) that was deliberately simplified
(commit 7443a934 chore(grace): remove hybrid dunning tables/outbox/repay (replaced by cron design)). That simplification history directly validates the spec’s “make it as simple as
possible” instruction.
1. Where does “Past Due” status live?
- Decision: Add
PAST_DUE = 3as a new case on the existingcommon/src/Enums/UserPayment/Status
enum (alongsideUNSETTLED=0,SETTLED=1,DELETED=2), stored in the existinguser_payments.status
column. No new “subscription status” table. - Rationale:
UserPayment.statusis already the single, established source of truth every
paid-feature check reads (User::getValidUserPlanAttribute(),User::maxUserPlanValid(),
UserPaymentSetting, severaluser-app usecases). Reusing it means every existing “is this user
paid” check only needs one additionalPAST_DUEbranch instead of a parallel status system that
every consumer would need to learn about. - Alternatives considered: A dedicated
plan_payment_dunningstable with its own status enum
(this was the original design, commit7036dbb7/ca999751/0734d54f) — rejected/reverted
upstream for adding a parallel source of truth and extra joins with no added value for a
single-active-grace-per-subscription use case.
2. How is a payment failure detected?
- Decision: Add a new
handleInvoicePaymentFailed(array $payload)handler on the existing
PaymentWebhookController(Cashier’s webhook dispatch already auto-routesinvoice.payment_failed
to a method of that name). Only act wheninvoice.billing_reason === 'subscription_cycle'(a
recurring renewal charge), ignoring proration/one-off invoices. - Rationale:
invoice.payment_failedfires once per failed charge attempt and carries
hosted_invoice_urldirectly in the payload — exactly the link the 3 dunning emails need, with
no extra Stripe API round-trip.customer.subscription.updatedalso fires around the same time
(Stripe flipssubscription.statustopast_due) and must be explicitly “held” (skip the normal
downgrade path) while a grace period is open, so it doesn’t fight the new handler. - Alternatives considered: Driving everything off
customer.subscription.updated’s
status === 'past_due'alone — rejected because that event doesn’t carry the hosted invoice link
and fires on every subscription mutation, making “did payment just fail” harder to detect
precisely than a dedicatedinvoice.payment_failedevent.
3. How is a manual recovery payment detected?
- Decision: Add
handleInvoicePaymentSucceeded(array $payload), guarded by
UserPayment.status === PAST_DUE(ignore success events for subscriptions not currently in
grace, e.g. a brand-new subscription’s first payment). Also aliashandleInvoicePaidto the same
handler, since some Stripe payment paths (e.g. paying via the hosted invoice/customer portal) can
emitinvoice.paidwithout a separateinvoice.payment_succeededin some account configurations. - Rationale: Matches FR-008/FR-009 exactly — restore
SETTLEDand send the confirmation mail
only when recovering from grace, not on every normal renewal. - Alternatives considered: Relying only on
customer.subscription.updatedtransitioning back to
active— rejected as less direct and because the plan/payment method sync logic
(newOrUpdateUserSubscription) needs the invoice’s resolved subscription object anyway.
4. How is automatic cancellation at day 7 detected?
- Decision: No new cancellation call from the app. Rely on the Stripe dashboard setting
(“Manage failed payments” → retries = “Do not retry”; subscription cancellation = “Cancel the
subscription” after 7 days past-due) to have Stripe itself cancel the subscription, which
fires the already-handledcustomer.subscription.deletedwebhook. That existing handler is
extended only to also markUserPayment.status = DELETEDvia the sharedPlanDunningtrait
before running the existinguserDowngradePlan()cleanup. - Rationale: This is precisely FR-010/FR-011 and matches the user’s explicit ask (“Stripe should
automatically change plan to cancel… update through webhook”) — no in-app 7-day timer is needed
to trigger the cancellation, only to detect and react to it. - Safety net: The daily cron additionally verifies (once per
UserPaymentstillPAST_DUEpast
day 7) via a direct Stripe API lookup that the subscription is actually gone; if so it self-heals
(marksDELETED+ downgrades) in case thecustomer.subscription.deletedwebhook was ever missed
or delayed. This does not change what triggers cancellation (Stripe does), only guards against
webhook delivery failure.
5. Where does the day-3/day-6 timing logic run?
- Decision: A single new scheduled Console Command,
grace:send-mail, registered once daily in
admin/app/Console/Kernel.php(matching the existingSendPaymentRemindMail/
DowngradePlanTrialToFreecron pattern). Each run: loads allUserPaymentrows where
status = PAST_DUE, computes day offsets from a stored anchor timestamp
(grace_period_started_at, set once when grace begins), and sends mail 2/3 only if the
corresponding day threshold has been crossed and the correspondinggrace_mail_N_sent_at
column is still null. - Rationale: No queue/job infrastructure exists anywhere in this codebase
(QUEUE_CONNECTION=sync, zeroShouldQueuejob classes) — a Console Command + cron entry is the
established, simplest pattern for scheduled batch work, and keeps this feature consistent with
the two existing subscription-adjacent cron commands rather than introducing a new async
architecture. - Alternatives considered: A job-queue “outbox” dispatcher (the original design,
a3404bed/817a1575) — implemented and then deliberately removed for unnecessary complexity
relative to what the spec requires.
6. Why is grace_period_started_at its own column instead of reusing billing_use_end_date?
- Decision: Store a dedicated
grace_period_started_attimestamp onuser_payments, set once
(only when entering a new grace cycle) directly from the failed invoice’s billing period start
(falls back to invoiceperiod_start, then the webhook event’screatedtimestamp). - Rationale:
UserPlan.billing_use_end_dateis later overwritten by the normal
customer.subscription.updatedsync as Stripe’s period boundaries roll forward, so it cannot be
trusted as a stable “day 0” anchor for the 3/6/7-day math once other webhook traffic arrives
during the grace window. - Alternatives considered: Deriving day offsets from
now()at cron run time relative to
updated_at— rejected as non-deterministic if a webhook is retried/resent by Stripe hours or
days later.
7. Mail delivery mechanism
- Decision: Four dedicated
Mailableclasses undercommon/src/Mail/Grace/
(FirstFailureMail,ReminderMail,FinalNoticeMail,RepaymentSuccessMail), each rendering a
Blade view underadmin/resources/views/emails/html/plan-grace/, sent via Laravel’s native
Mail::to($email)->send($mailable). Mail 1 is sent immediately inside the webhook handler
(matching “right after payment fail”), with the daily cron re-attempting it as an idempotent
safety net (grace_mail_1_sent_atguard) if the immediate send fails. - Rationale: The legacy
EmailPackagewrapper (used bySendPaymentRemindMail) has no
constructor and is built purely through fluent->view()/->subject()calls tied to
__destruct()-triggered sending — workable for one-off ad-hoc mails, but a typedMailable
constructor (new FirstFailureMail($payment, $nextPaymentDate)) is a cleaner, more testable fit
for four structurally-similar templates. - Idempotency: each mail’s
sent_atcolumn is only set after a successful send; a thrown
mail exception leaves the column null so the next cron run (or immediate retry path) resends
rather than silently skipping a subscriber.
8. Full paid-feature access during PAST_DUE
- Decision: Extend the two existing plan-resolution choke points —
User::getValidUserPlanAttribute()andUser::maxUserPlanValid()— so aPAST_DUEpayment is
treated as valid regardless ofbilling_use_end_date(unlikeSETTLED, which still requires
the billing period to not have ended). - Rationale: Every paid-feature flag in the codebase (
customer/admin/userapps) already
funnels through these two accessors — no new gating logic needs to be added anywhere else, and no
existing consumer needs to change.
9. Testing approach
- Decision: Feature tests using
RefreshDatabase+ model factories, following
admin/tests/Feature/Tokushoho/TokushohoPrivacyDowngradeTest.php’s pattern (seed aUser+
UserPlan+UserPayment, invoke the trait/command/handler method directly, assert resulting
DB state and, for mail,Mail::fake()+Mail::assertSent(...)). Webhook handlers are tested by
constructing a raw Stripe-shaped payload array and invoking the protectedhandle*method
directly (via a thin test subclass exposing it, or reflection), since signature verification only
guards the outerhandleWebhookentrypoint, not the individual handler methods. - Rationale: No Stripe API mocking precedent exists in this repo; this matches the only
existing precedent for testing subscription-adjacent logic and avoids introducing a new test
double/mocking library.
Summary of decisions carried into Phase 1
| Area | Decision |
|---|---|
| Status model | Add PAST_DUE=3 to UserPayment\Status enum; no new table |
| Failure detection | New invoice.payment_failed handler, filtered to billing_reason=subscription_cycle |
| Recovery detection | New invoice.payment_succeeded (+ invoice.paid alias) handler, guarded by current status PAST_DUE |
| Cancellation | Stripe dashboard setting triggers existing customer.subscription.deleted; handler extended to mark DELETED; cron adds a self-healing safety net |
| Scheduling | One daily Console Command (grace:send-mail), no queue/job infra |
| Anchor date | New grace_period_started_at column set once per grace cycle from the failed invoice’s period start |
4 new Mailable classes + Blade views, immediate send for mail 1, idempotent sent_at guards for all 4 |
|
| Feature access | Extend getValidUserPlanAttribute()/maxUserPlanValid() only |
| Testing | Feature tests, RefreshDatabase, Mail::fake(), no Stripe mocking library |