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. EnumUserPayment\Status::PAST_DUE=3. - Mail outbox: ①②③⑤ dùng
attempt_no=0; chỉ ④ dùngattempt_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 methodpublic(Mockery mock được, vdresolveGraceSuccess). - Test user: KHÔNG
User::factory()(phụ thuộc master data). DùngminimalUser()=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.
userDowngradePlantrả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 scopedyoyacoo_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), leasedispatching_at,enqueued_at, terminalsent_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. FactoryPlanPaymentDunningFactorydefault OPEN +failed_at=now +invoice_idunique (KHÔNG defaultuser_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-failedchỉ khi OPEN;repayment-successchỉ khi RESOLVED.nextPaymentDate=$d->next_payment_attempt_at?->format('Y年n月j日') ?? ''(đọc từ cột, KHÔNG re-fetch invoice).- Job
ShouldQueue, ShouldBeUniquetheooutbox_id,uniqueFor=600 < LEASE_TTL=900,tries=5: re-check stale →Mail::send→ setsent_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()+ relationmaxUserPlanValid(): coiSETTLEDvàPAST_DUElà 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.updateAllSubscriptionslọ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: guardbilling_reason==subscription_cycle;firstOrCreateDunning; nhánh ④ (markerrepay_initiated_at≤15′, check TRƯỚC dedup); dedupattempt<=last_failed_attempt; map attempt 1/2/3 → ①②③, lưunext_payment_attempt_at.handleInvoicePaymentSucceeded: lookup OPEN +invoice_id+ chưa notified →resolveGraceSuccess(bool); sync-fail & còn OPEN →abort(500)(Stripe retry).handleCustomerSubscriptionDeleted:newOrUpdateSubscription→markUserPaymentDeleted→userDowngradePlan→ LUÔN OPEN→CANCELED(+canceled_at);if(!$ok) abort(500)(đặt trước dispatch fee-job).handleCustomerSubscriptionUpdatedguard: 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; invoiceopen+PI → setrepay_initiated_at+ trảclient_secret; invoicepaid→ self-healresolveGraceSuccess,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.php→product_idkhớp mode đang chạy (test↔live), nếu khôngresolveGraceSuccesskhô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
customerlocal (--override invoice:customer=cus_xxx+billing_reason=subscription_cycle), nếu khôngresolveUserByCustomertrả 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).