Phase 1 Data Model: Subscription Payment Failure Grace Period & Dunning Emails
Phase 1 Data Model: Subscription Payment Failure Grace Period & Dunning Emails
Entity: UserPayment (existing table user_payments, extended)
Represents one subscription’s current billing-cycle payment record. This feature reuses the
existing model and adds grace-tracking columns; it does not introduce a new table.
| Field | Type | Notes |
|---|---|---|
status |
tinyint (existing) | Now has 4 values: UNSETTLED=0, SETTLED=1, DELETED=2, PAST_DUE=3 (new) |
subscription_id |
string (existing) | Stripe subscription ID; unchanged |
hosted_invoice_url |
string, nullable (new) | Stripe’s hosted invoice payment link for the failed invoice; embedded in all 3 dunning emails |
grace_period_started_at |
timestamp, nullable (new) | “Day 0” anchor for this grace cycle; set once when entering PAST_DUE, cleared/reset only when a new grace cycle begins |
grace_mail_1_sent_at |
timestamp, nullable (new) | Guards against duplicate send of the immediate failure-notice email |
grace_mail_2_sent_at |
timestamp, nullable (new) | Guards the day-3 reminder email |
grace_mail_3_sent_at |
timestamp, nullable (new) | Guards the day-6 final-notice email |
grace_success_mail_sent_at |
timestamp, nullable (new) | Guards the payment-completed confirmation email |
State Transitions (status)
invoice.payment_failed (subscription_cycle)
SETTLED ──────────────────────────────────────────────────▶ PAST_DUE
▲ │
│ invoice.payment_succeeded / invoice.paid │
└──────────────────────────────────────────────────────────────┘
│
customer.subscription.deleted │
(Stripe auto-cancel at day 7, ▼
or manual cancel) DELETED
SETTLED → PAST_DUE: oninvoice.payment_failedfor asubscription_cycleinvoice. Resets all
4grace_*_sent_atcolumns to null and setsgrace_period_started_atto the failed invoice’s
billing period start — but only when this is a new grace cycle (i.e., status was not
alreadyPAST_DUE); repeated automatic retry failures within the same cycle do not reset the
anchor or already-sent flags.PAST_DUE → SETTLED: oninvoice.payment_succeeded/invoice.paid, guarded so it only fires
when the current status isPAST_DUE(ignores unrelated success events).PAST_DUE → DELETED: oncustomer.subscription.deleted(Stripe’s own day-7 no-payment
cancellation, or a manual subscriber cancellation during grace). Existing free-plan downgrade
cleanup runs unchanged.- Any status →
DELETEDremains possible directly fromSETTLEDtoo (pre-existing behavior,
unchanged by this feature).
Validation / Invariants
grace_period_started_at, and the four*_sent_atcolumns are only ever set from server-side
webhook/cron logic — never user-writable.- A
grace_mail_N_sent_atcolumn, once set, is never cleared except when a brand-new grace cycle
begins (newPAST_DUEentry after a prior recovery). - Mail 2/3 must only be sent when
status === PAST_DUEat send time (re-checked at cron
execution, not just at scheduling time), per FR-007.
Entity: Notification Email (behavioral entity — implemented as 4 Mailable classes, not a DB table)
| Class | Trigger | Guard column | |
|---|---|---|---|
| 1st failure notice | FirstFailureMail |
Immediately on entering PAST_DUE (webhook), cron re-attempts as safety net |
grace_mail_1_sent_at |
| 2nd reminder | ReminderMail |
Daily cron, day 3 after grace_period_started_at, only if still PAST_DUE |
grace_mail_2_sent_at |
| 3rd final notice | FinalNoticeMail |
Daily cron, day 6 after grace_period_started_at, only if still PAST_DUE |
grace_mail_3_sent_at |
| Payment-completed confirmation | RepaymentSuccessMail |
On invoice.payment_succeeded/invoice.paid while PAST_DUE |
grace_success_mail_sent_at |
Each Mailable receives the UserPayment (for plan/contract/fee/hosted-invoice-URL data) plus any
mail-specific computed value (e.g., the next scheduled payment-retry date shown in the body copy).
No new persistence entity is required for “which emails were sent” — the 4 guard columns on
UserPayment are sufficient given at most one open grace cycle per subscription at a time.
Relationships
UserPayment.subscription_id↔UserPlan.subscription_id(existing, unchanged) — used to look
up plan name/contract period/fee for the email body.UserPayment.user_id↔User(existing) — used to resolve the recipient’s login email via
User.userLogin.
Out of scope for this data model
- No new “dunning mail history” table — the spec does not require an auditable log/history UI
(unlike the unrelated, pre-existing order/reservation “payment dunning mail” feature which does
have its own history table/endpoint). If a future requirement needs a full audit trail, that is a
separate feature. - No changes to Cashier’s native
subscriptions/subscription_itemstables — those continue to be
synced exactly as they are today.