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 = 3 as a new case on the existing common/src/Enums/UserPayment/Status
    enum (alongside UNSETTLED=0, SETTLED=1, DELETED=2), stored in the existing user_payments.status
    column. No new “subscription status” table.
  • Rationale: UserPayment.status is already the single, established source of truth every
    paid-feature check reads (User::getValidUserPlanAttribute(), User::maxUserPlanValid(),
    UserPaymentSetting, several user-app usecases). Reusing it means every existing “is this user
    paid” check only needs one additional PAST_DUE branch instead of a parallel status system that
    every consumer would need to learn about.
  • Alternatives considered: A dedicated plan_payment_dunnings table with its own status enum
    (this was the original design, commit 7036dbb7/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-routes invoice.payment_failed
    to a method of that name). Only act when invoice.billing_reason === 'subscription_cycle' (a
    recurring renewal charge), ignoring proration/one-off invoices.
  • Rationale: invoice.payment_failed fires once per failed charge attempt and carries
    hosted_invoice_url directly in the payload — exactly the link the 3 dunning emails need, with
    no extra Stripe API round-trip. customer.subscription.updated also fires around the same time
    (Stripe flips subscription.status to past_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 dedicated invoice.payment_failed event.

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 alias handleInvoicePaid to the same
    handler, since some Stripe payment paths (e.g. paying via the hosted invoice/customer portal) can
    emit invoice.paid without a separate invoice.payment_succeeded in some account configurations.
  • Rationale: Matches FR-008/FR-009 exactly — restore SETTLED and send the confirmation mail
    only when recovering from grace, not on every normal renewal.
  • Alternatives considered: Relying only on customer.subscription.updated transitioning 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-handled customer.subscription.deleted webhook. That existing handler is
    extended only to also mark UserPayment.status = DELETED via the shared PlanDunning trait
    before running the existing userDowngradePlan() 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 UserPayment still PAST_DUE past
    day 7) via a direct Stripe API lookup that the subscription is actually gone; if so it self-heals
    (marks DELETED + downgrades) in case the customer.subscription.deleted webhook 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 existing SendPaymentRemindMail/
    DowngradePlanTrialToFree cron pattern). Each run: loads all UserPayment rows 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 corresponding grace_mail_N_sent_at
    column is still null.
  • Rationale: No queue/job infrastructure exists anywhere in this codebase
    (QUEUE_CONNECTION=sync, zero ShouldQueue job 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_at timestamp on user_payments, set once
    (only when entering a new grace cycle) directly from the failed invoice’s billing period start
    (falls back to invoice period_start, then the webhook event’s created timestamp).
  • Rationale: UserPlan.billing_use_end_date is later overwritten by the normal
    customer.subscription.updated sync 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 Mailable classes under common/src/Mail/Grace/
    (FirstFailureMail, ReminderMail, FinalNoticeMail, RepaymentSuccessMail), each rendering a
    Blade view under admin/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_at guard) if the immediate send fails.
  • Rationale: The legacy EmailPackage wrapper (used by SendPaymentRemindMail) 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 typed Mailable
    constructor (new FirstFailureMail($payment, $nextPaymentDate)) is a cleaner, more testable fit
    for four structurally-similar templates.
  • Idempotency: each mail’s sent_at column 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() and User::maxUserPlanValid() — so a PAST_DUE payment is
    treated as valid regardless of billing_use_end_date (unlike SETTLED, which still requires
    the billing period to not have ended).
  • Rationale: Every paid-feature flag in the codebase (customer/admin/user apps) 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 a User +
    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 protected handle* method
    directly (via a thin test subclass exposing it, or reflection), since signature verification only
    guards the outer handleWebhook entrypoint, 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
Mail 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