Implementation Plan: Subscription Payment Failure Grace Period & Dunning Emails

Branch: 001-payment-failure-grace-period | Date: 2026-07-31 | Spec: spec.md

Input: Feature specification from /specs/001-payment-failure-grace-period/spec.md

Note: This template is filled in by the /speckit.plan command; its definition describes the execution workflow.

Summary

Add a 7-day, no-auto-retry Stripe grace period for failed subscription renewal payments. On
invoice.payment_failed, mark the subscriber’s UserPayment record PAST_DUE (a new status value)
while every plan-resolution check that already gates paid features continues to treat PAST_DUE
as fully paid. Send email 1 immediately, and a daily scheduled command re-checks and sends emails
2 (day 3) and 3 (day 6) only while still PAST_DUE. A successful manual payment
(invoice.payment_succeeded/invoice.paid) restores SETTLED and sends a success email. If day 7
passes unpaid, Stripe’s own dashboard-configured cancellation fires customer.subscription.deleted,
which the existing handler uses to mark the payment DELETED and run the existing free-plan
downgrade. A working, tested implementation of this exact design already exists on the unmerged
branch origin/feat/1023-grace-cron-userpayment
(checked out in worktree
/private/tmp/kilo/wt-grace-pr); this plan documents that design as the technical approach so the
task/implementation phase reviews, adapts, and merges it rather than re-deriving it from scratch.

Technical Context

Language/Version: PHP 8.2, Laravel 10 (yoyacoo_be)

Primary Dependencies: laravel/cashier ^14.14 (Stripe subscription webhooks), stripe/stripe-php (direct SDK calls via StripePaymentSubscription trait), Laravel Mail (native Mailable), Laravel Console scheduler (admin/app/Console/Kernel.php)

Storage: MySQL/MariaDB — extend the existing user_payments table with nullable grace-tracking columns; no new tables

Testing: PHPUnit (Feature tests with RefreshDatabase + model factories), following the existing admin/tests/Feature/Tokushoho/TokushohoPrivacyDowngradeTest.php pattern; no live Stripe calls in tests

Target Platform: Laravel backend only (admin app owns the Stripe webhook route and the scheduled command); user app (supplier API) exposes two additional read-only response fields consumed by the existing frontend subscription screen

Project Type: Web service (backend-only feature; no new frontend pages — an existing Next.js subscription screen may optionally surface requires_repayment/hosted_invoice_url, but that is out of scope for this plan unless requested)

Performance Goals: N/A beyond existing webhook/cron performance — payment-failure volume is low (bounded by number of subscribers, not by request traffic); no new perf targets needed

Constraints: Must not auto-retry payments (Stripe dashboard setting, not app code); must not send day-3/day-6 mail once status has left PAST_DUE; must not double-send any of the 4 emails (idempotency via per-mail sent_at timestamp columns)

Scale/Scope: One new webhook event handler set (invoice.payment_failed, invoice.payment_succeeded, invoice.paid alias), one new enum case, ~5 nullable columns on one existing table, one new scheduled Console Command, 4 Mailable classes + Blade views, extension of 2 existing plan-resolution methods

Constitution Check

GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.

Principle Check Status
I. Domain-Driven Architecture Business logic (grace state transitions) lives in a PlanDunning trait in common/src/Packages/Util/, reused by both the webhook controller and the cron command — mirrors the existing Subscription/UserDowngradePlan trait pattern already used by PaymentWebhookController. Eloquent UserPayment model gains only new columns/casts, no new business methods. PASS
II. API-First & Contract-Driven The only user-facing API change is two additional optional fields (requires_repayment, hosted_invoice_url) on the existing supplier “current plan” response. This is additive (non-breaking) but the OpenAPI spec (yoyacoo_fe/swagger/api/supplier/) MUST still be updated and the client regenerated before merge — tracked as an explicit task, not skipped. PASS (with task)
III. Test & Quality Gates Feature tests required for: webhook payment_failed/payment_succeeded/subscription_deleted handling, the grace:send-mail command timing logic, and the PAST_DUE-is-paid plan resolution. PHPStan and composer fix must pass on all new/changed files. PASS (with task)
IV. Security & Multi-Tenancy Stripe webhook signature verification is inherited unchanged from Cashier’s WebhookController (already enforced for the existing customer.subscription.* handlers on the same controller) — no new unauthenticated surface is introduced. No secrets added to code; STRIPE_WEBHOOK_SECRET already exists as an env var. PASS
V. Performance & Observability Mail is sent synchronously (matches existing codebase convention — no queue job precedent exists; QUEUE_CONNECTION=sync). Existing Logger::Stripe(...) calls are extended to the two new webhook handlers for structured, correlatable logging, consistent with existing handlers on the same controller. PASS

No constitution violations requiring justification — no entry needed in Complexity Tracking.

Project Structure

Documentation (this feature)

specs/001-payment-failure-grace-period/
├── plan.md              # This file (/speckit.plan command output)
├── research.md          # Phase 0 output (/speckit.plan command)
├── data-model.md         # Phase 1 output (/speckit.plan command)
├── quickstart.md         # Phase 1 output (/speckit.plan command)
├── contracts/            # Phase 1 output (/speckit.plan command)
│   ├── stripe-webhook-events.md
│   ├── grace-send-mail-command.md
│   └── current-plan-api-extension.md
└── tasks.md              # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)

Source Code (repository root)

Structure Decision: Web service — Laravel monorepo at yoyacoo_be/ with three apps (admin, user, customer) sharing packages/common/. This feature touches admin (webhook + cron) and common (models/enums/mail/traits), with a minimal, optional read-model addition in user (supplier API). No customer-app or frontend changes are required by the spec.

yoyacoo_be/
├── admin/
│   ├── app/Console/Commands/SendPlanGraceMail.php        # new: daily grace mail + safety-net cancel check
│   ├── app/Console/Kernel.php                            # add scheduled entry for grace:send-mail
│   ├── app/Domains/Subscription/Controllers/
│   │   └── PaymentWebhookController.php                  # add handleInvoicePaymentFailed/Succeeded(/Paid)
│   ├── resources/views/emails/html/plan-grace/            # new: 4 Blade templates + shared header/footer partials
│   └── tests/Feature/Grace/                                # new: webhook + cron + plan-resolution tests
├── common/src/
│   ├── Enums/UserPayment/Status.php                       # add PAST_DUE = 3 case + label()
│   ├── Mail/Grace/*.php                                    # new: FirstFailureMail, ReminderMail, FinalNoticeMail, RepaymentSuccessMail
│   ├── Models/UserPayment.php (+ Base/UserPayment.php)      # add new nullable column casts
│   ├── Models/User.php                                     # extend getValidUserPlanAttribute()/maxUserPlanValid() for PAST_DUE
│   └── Packages/Util/PlanDunning.php                        # new: shared grace-state helper trait
├── common/database/migrations/
│   └── *_add_grace_columns_to_user_payments.php             # new: hosted_invoice_url, grace_period_started_at, grace_mail_1/2/3_sent_at, grace_success_mail_sent_at
└── user/app/Domains/Subscription/                           # optional: expose requires_repayment/hosted_invoice_url on current-plan response

yoyacoo_fe/
└── swagger/api/supplier/components/_subscription.yaml       # optional: document the 2 new response fields, then regenerate client

Complexity Tracking

No entries — Constitution Check has no unjustified violations.

Violation Why Needed Simpler Alternative Rejected Because
N/A N/A N/A