Plan Payment Hybrid (Stripe Retry + Webhook Mail) — Implementation Plan

Goal: Grace-period 7 ngày cho gói trả phí khi thẻ gia hạn fail, theo mô hình hybrid: Stripe sở hữu retry schedule (T+3/T+6/T+7) + hủy subscription; hệ thống chỉ phản ứng webhook để gửi 5 mail Watanabe, giữ paid access trong grace, và hạ Free khi Stripe hủy.

Spec nguồn: 2026-07-01-plan-payment-hybrid-stripe-retry-design.md — mọi quyết định (Q1–Q9, H1–H3) chốt ở đó.

Tech stack: Laravel 10 monorepo yoyacoo_be/{admin,user,common}, Cashier 14 + Stripe, PHPUnit; FE Next.js/TS (yoyacoo_fe/supplier).

Global Constraints

  • Enum PlanDunning\Status = OPEN=1, RESOLVED=2, CANCELED=3. Enum UserPayment\Status::PAST_DUE=3.
  • Mail outbox: ①②③⑤ dùng attempt_no=0; chỉ ④ dùng attempt_no = attempt_count (dedup unique (dunning_id, mail_key, attempt_no)).
  • Email tiếng Nhật giữ nguyên gốc Watanabe.
  • Trait PlanDunning — mọi method public (Mockery mock được, vd resolveGraceSuccess).
  • Test user: KHÔNG User::factory() (phụ thuộc master data). Dùng minimalUser() = User::create(['last_name'=>'T','first_name'=>'U','status'=>UserStatus::INDIVIDUAL->value]).
  • Test chạy trong app dir: webhook/mail/job/factory/model ở admin; repay/guard/resource ở user.
  • userDowngradePlan trả bool; caller cũ bỏ qua return → tương thích ngược.
  • Commit theo path cụ thể (KHÔNG git add -A — repo root có nhiều untracked). FE scoped yoyacoo_fe/....

File Structure

File Trách nhiệm
common/src/Enums/PlanDunning/Status.php Enum OPEN/RESOLVED/CANCELED
common/src/Enums/UserPayment/Status.php Thêm PAST_DUE
common/database/migrations/..._create_plan_payment_dunnings_table.php Schema dunning
common/database/migrations/..._create_plan_dunning_mail_outbox_table.php Schema outbox
common/src/Models/{PlanPaymentDunning,PlanDunningMailOutbox}.php (+ factory) Model
common/src/Packages/Util/PlanDunning.php Trait helpers webhook + repay
common/src/Packages/Util/PlanGraceGuard.php assertNotInGrace
common/src/Support/PlanDunningMailFactory.php Build Mailable + stillValid
common/src/Mail/* + admin/resources/views/emails/html/plan-dunning/* 5 Mailable + 5 blade
common/src/Jobs/DispatchPlanDunningMailJob.php Gửi mail (ShouldQueue, ShouldBeUnique)
common/src/Models/User.php Resolver PAST_DUE (getValidUserPlanAttribute, maxUserPlanValid)
common/src/Packages/Util/UserDowngradePlan.php userDowngradePlan(): bool
admin/app/Domains/Subscription/Controllers/PaymentWebhookController.php 4 webhook handler
admin/app/Console/Commands/DispatchPlanDunningOutbox.php (+ Kernel) Backstop outbox ->everyMinute()
user/app/Domains/Subscription/Usecase/RepayInteractor.php (+ RepayAction) /subscriptions/repay + marker ④
user/.../Usecase/{CurrentPlan,PlanPurchaseLast}Interactor.php + resource requires_repayment
user/.../Usecase/{Payment,Order,Cancel}Interactor.php assertNotInGrace
FE plan/{ModalChangePaymentMethod,change-plan/ChosePlan,edit/ChangeCurrentPlan,cancel/AccountCancelPlan,payment-history/PaymentHistory} Chặn đổi/hủy + repay

Task 1 — Data model

Files: enum PlanDunning\Status (OPEN/RESOLVED/CANCELED) + UserPayment\Status::PAST_DUE=3; 2 migration (plan_payment_dunnings, plan_dunning_mail_outbox); model PlanPaymentDunning (+ factory) + PlanDunningMailOutbox.

  • plan_payment_dunnings: user_id, subscription_id, invoice_id (unique, NOT NULL), status (default OPEN), failed_at, last_failed_attempt, next_payment_attempt_at, repay_initiated_at, success_notified_at, resolved_at, canceled_at, timestamps.
  • plan_dunning_mail_outbox: dunning_id, mail_key, attempt_no (default 0), lease dispatching_at, enqueued_at, terminal sent_at/skipped_at/skipped_reason, timestamps; unique(dunning_id, mail_key, attempt_no).
  • Model casts: int cho status/last_failed_attempt; datetime cho các timestamp. Factory PlanPaymentDunningFactory default OPEN + failed_at=now + invoice_id unique (KHÔNG default user_id — test truyền vào).

Tests: schema có đủ cột hybrid; invoice_id unique; tạo dunning + relation outbox.

Task 2 — Trait PlanDunning

File: common/src/Packages/Util/PlanDunning.php (methods public).

  • resolveUserByCustomer, setUserPaymentPastDue, enqueuePlanMail($dunningId,$mailKey,$attemptNo) (INSERT outbox intent idempotent trong tx), dispatchOutbox($dunningId?) (lease-claim + dispatch job).
  • firstOrCreateDunning($invoice,$user)Cache::lock + bắt 1062; tạo mới → OPEN + failed_at + setUserPaymentPastDue.
  • markUserPaymentDeleted($userId,$subscriptionId) — UserPayment(sub) → DELETED.
  • resolveGraceSuccess($dunningId,$subscriptionId): bool — Stripe reads NGOÀI transaction; tx ngắn: lock dunning, guard OPEN + chưa notified, map product→plan, newOrUpdateSubscription+newOrUpdateUserSubscription (SETTLED), set RESOLVED + success_notified_at, enqueue ⑤; dispatch outbox sau commit. (Chi tiết code: spec.)

Tests: ResolveGraceSuccessTest chạy method thật (Stripe faked) → RESOLVED + UserPayment SETTLED (plan/expense đúng) + ⑤ outbox + idempotent.

Task 3 — Mail (outbox + job + factory + templates)

Files: 5 Mailable (common/src/Mail/*) + 5 blade (admin/resources/views/emails/html/plan-dunning/*, nội dung Watanabe); PlanDunningMailFactory (static make/stillValid); DispatchPlanDunningMailJob; command DispatchPlanDunningOutbox (->everyMinute() trong admin Kernel).

  • stillValid: first-failure/reminder/final-notice/repayment-failed chỉ khi OPEN; repayment-success chỉ khi RESOLVED.
  • nextPaymentDate = $d->next_payment_attempt_at?->format('Y年n月j日') ?? '' (đọc từ cột, KHÔNG re-fetch invoice).
  • Job ShouldQueue, ShouldBeUnique theo outbox_id, uniqueFor=600 < LEASE_TTL=900, tries=5: re-check stale → Mail::send → set sent_at.

Tests: outbox enqueue idempotent; job gửi + set terminal; dispatcher reclaim stale; factory validity matrix; nextPaymentDate từ marker.

Task 4 — Giữ paid access (resolver)

Files: common/src/Models/User.php, common/src/Packages/Util/UserDowngradePlan.php, UserPaymentSetting.

  • getValidUserPlanAttribute() + relation maxUserPlanValid(): coi SETTLED PAST_DUE là paid. PAST_DUE giữ plan bất kể billing_use_end_date (grace); SETTLED vẫn gate period. Đồng nhất 2 nơi (accessor + relation dùng bởi admin filter/dashboard).
  • userDowngradePlan(): bool. updateAllSubscriptions lọc thêm PAST_DUE.

Tests: GraceValidUserPlanTest — PAST_DUE+kỳ đã hết → valid; DELETED/SETTLED+hết → null; relation + findByPlan + dashboard count phản ánh grace, drop sau DELETED.

Task 5 — Webhook handlers (PaymentWebhookController)

File: admin/app/Domains/Subscription/Controllers/PaymentWebhookController.php. (Code đầy đủ trong spec — Webhook Handlers.)

  • handleInvoicePaymentFailed: guard billing_reason==subscription_cycle; firstOrCreateDunning; nhánh ④ (marker repay_initiated_at ≤15′, check TRƯỚC dedup); dedup attempt<=last_failed_attempt; map attempt 1/2/3 → ①②③, lưu next_payment_attempt_at.
  • handleInvoicePaymentSucceeded: lookup OPEN + invoice_id + chưa notified → resolveGraceSuccess (bool); sync-fail & còn OPEN → abort(500) (Stripe retry).
  • handleCustomerSubscriptionDeleted: newOrUpdateSubscriptionmarkUserPaymentDeleteduserDowngradePlanLUÔN OPEN→CANCELED(+canceled_at); if(!$ok) abort(500) (đặt trước dispatch fee-job).
  • handleCustomerSubscriptionUpdated guard: hold (KHÔNG downgrade) khi có dunning OPEN hoặc (past_due & billing_reason==subscription_cycle).

Tests: harness (anon subclass expose proxy + Bus::fake + stub trait) cho từng handler; map ①②③, ④ marker, dedup, RESOLVED+⑤, CANCELED+downgrade+abort500, guard hold.

Task 6 — Repay + cấm đổi/hủy

Files: RepayInteractor + RepayAction (POST /subscriptions/repay); PlanGraceGuard::assertNotInGrace (dunning OPEN → 422 JP) đăng ký ở Payment/Order/Cancel Interactor; requires_repayment (=OPEN) tính ở CurrentPlanInteractor/PlanPurchaseLastInteractor + 2 resource (PlanPurchaseLast kèm subscription_id).

  • RepayInteractor: lọc dunning OPEN; invoice open+PI → set repay_initiated_at + trả client_secret; invoice paid → self-heal resolveGraceSuccess, repayment_required = !$healed. Trả 2 khóa.

Tests: RepayInteractorTest (open→marker+secret; paid+heal→false; paid+heal-fail→true); PlanGraceGuardTest (422 khi OPEN).

Task 7 — FE (yoyacoo_fe supplier)

Files: ModalChangePaymentMethod (repay + 3DS khi requiresRepayment+subscriptionId); ChosePlan (chặn redirect + disable nút + banner khi requires_repayment); ChangeCurrentPlan (disable nút đổi thẻ tới khi planLoaded + khi grace); AccountCancelPlan (disable + banner); PaymentHistory (truyền requiresRepayment+subscriptionId — lối repay).

Verify: npx tsc --noEmit + eslint sạch.

Task 8 — Stripe Dashboard + verify (deploy-time, không code)

  • Custom retries +3/+3/+1 + “cancel the subscription”; tắt email khách Stripe; đăng ký webhook invoice.payment_failed/invoice.payment_succeeded/customer.subscription.deleted (giữ customer.subscription.updated).
  • Env/config: Stripe keys + webhook secret đúng mode; config/subscription_setting.phpproduct_id khớp mode đang chạy (test↔live), nếu không resolveGraceSuccess không map được plan.
  • Điền bảng Checklist verify trong spec (cả test lẫn live), gồm runtime verify: fail→OPEN+①+PAST_DUE; repay→RESOLVED+SETTLED (check DB)+⑤; hết retry→CANCELED+Free.

Trigger bằng Stripe CLI phải khớp customer local (--override invoice:customer=cus_xxx + billing_reason=subscription_cycle), nếu không resolveUserByCustomer trả null → webhook bị ignore.

Deploy trên staging

Sau khi merge + deploy: php artisan migrate (2 bảng mới) → đảm bảo scheduler cron (schedule:run) + queue worker chạy → cấu hình Stripe Dashboard (Task 8) → verify runtime.

Test Strategy

Xem “Test Strategy” trong spec. Trọng tâm: giữ paid trong grace (CRITICAL), hạ Free ở T+7 (CRITICAL), map attempt→①②③, ④ marker, ⑤/resolveGraceSuccess, downgrade-fail→webhook retry, cấm đổi/hủy, mail outbox không mất/trùng.

Status

Đã implement + review + merge lên nhánh feat/1023-grace-period-7-days-stg (BE + FE). Còn lại: cấu hình + verify Stripe trên môi trường (Task 8).