Plan Payment Grace Period — Design
Status: Approved design — chờ implement.
Created: 2026-07-04
Overview
Khi gia hạn gói trả phí (有料プラン / Stripe Subscription) thanh toán thất bại, user được 7 ngày grace: giữ nguyên quyền gói trả phí, nhận 3 email nhắc + 1 email xác nhận, và trả lại qua trang thanh toán Stripe (hosted invoice deeplink). Hết 7 ngày chưa trả → Stripe hủy subscription → hệ thống hạ user về Free.
Trạng thái grace được quản lý hoàn toàn qua UserPayment.status (SETTLED / PAST_DUE / DELETED). Email nhắc nợ (①②③) do cron gửi theo mốc UserPlan.billing_use_end_date trên DB. Webhook chỉ đồng bộ trạng thái và gửi email xác nhận thanh toán (④).
Goals
- Gia hạn fail →
PAST_DUE, giữ đầy đủ quyền gói trả phí đến T+7. - Gửi 4 email (nội dung/biến nguyên gốc Watanabe): ① first-failure, ② reminder, ③ final-notice, ④ repayment-success.
- T+7 chưa trả → Stripe hủy → hạ Free.
- User trả lại qua Stripe hosted invoice deeplink.
- Cấm đổi/hủy gói trong grace; chỉ được trả lại.
- Cron idempotent — không gửi mail trùng.
Out of scope
- Stripe Connect (PaymentIntent one-time của buyer trả service owner).
- Đăng ký mới / trial fail hóa đơn đầu (
billing_reason != subscription_cycle). - Bảng dunning riêng, outbox mail, enum grace riêng.
Architecture
| Phần | Trách nhiệm |
|---|---|
| Lịch retry T+3/T+6/T+7 + hủy subscription | Stripe — Custom retries 3/3/1 + “cancel the subscription” |
| Trang trả lại (đổi thẻ + 3DS) | Stripe — hosted_invoice_url |
Đánh dấu grace (PAST_DUE) + lưu deeplink |
Webhook invoice.payment_failed |
| Gửi email ①②③ | Cron SendPlanGraceMail — 09:00 JST hàng ngày |
Gửi email ④ + khôi phục SETTLED |
Webhook invoice.payment_succeeded |
| Giữ paid access trong grace | Resolver — PAST_DUE = paid |
| Hạ Free T+7 | Webhook customer.subscription.deleted |
| Cấm đổi/hủy gói trong grace | BE assertNotInGrace + FE block |
Cấu hình Stripe (bắt buộc)
- Billing → Manage subscription retries → Custom retries: 1st = 3 days, 2nd = 3 days, 3rd = 1 day.
- “If all retries fail → cancel the subscription”.
- Tắt toàn bộ customer email của Stripe (failed / reminder / receipt).
- Webhook đăng ký:
invoice.payment_failed,invoice.payment_succeeded,customer.subscription.deleted,customer.subscription.updated.
Giữ Paid Access Trong Grace
Hệ thống quyết quyền paid bằng DB local — UserPayment.status + UserPlan.billing_use_end_date — không dựa status subscription Stripe.
Khi gia hạn fail đúng lúc kỳ cũ kết thúc, billing_use_end_date đã qua; nếu resolver chỉ check ngày kỳ, user tụt Free ngay trong grace.
Cơ chế:
- Webhook
payment_failed→UserPayment.status = PAST_DUE. getValidUserPlanAttribute()+maxUserPlanValid()(common/src/Models/User.php) coiSETTLEDvàPAST_DUElà paid.PAST_DUE: giữ plan bất kểbilling_use_end_date.SETTLED: vẫn yêu cầu còn trong kỳ.
subscription.deleted→UserPayment = DELETEDtrước →userDowngradePlan→ resolver trả FREE.
Cấm Đổi & Hủy Gói Trong Grace
Khi UserPayment.status == PAST_DUE:
- Cấm đổi gói (アップグレード/ダウングレード) và hủy gói (解約).
- BE:
assertNotInGrace($userId)ởPaymentInteractor,OrderInteractor,CancelInteractor→ 422 (message JP). - FE:
ChosePlan/ChangeCurrentPlan/AccountCancelPlan— disable + banner khirequires_repayment. requires_repayment=PAST_DUE+ cóhosted_invoice_url; expose quaCurrentPlanResource+PlanPurchaseLastResource(kèmsubscription_id).
State Machine
Trạng thái duy nhất cho grace nằm trên UserPayment:
SETTLED
│
│ invoice.payment_failed (billing_reason = subscription_cycle)
▼
PAST_DUE ─────────────────────────────────────────────┐
│ │
│ invoice.payment_succeeded │ customer.subscription.deleted (T+7)
▼ ▼
SETTLED (+ mail ④) DELETED (→ FREE)
| Status | Value | Ý nghĩa |
|---|---|---|
UNSETTLED |
0 | Chưa thanh toán |
SETTLED |
1 | Đang active / đã trả |
DELETED |
2 | Subscription đã hủy |
PAST_DUE |
3 | Đang grace — vẫn coi paid |
Data Model
Cột mới trên user_payments
$table->string('hosted_invoice_url')->nullable();
$table->timestamp('grace_next_payment_attempt_at')->nullable();
$table->timestamp('grace_mail_1_sent_at')->nullable();
$table->timestamp('grace_mail_2_sent_at')->nullable();
$table->timestamp('grace_mail_3_sent_at')->nullable();
$table->timestamp('grace_success_mail_sent_at')->nullable();
| Cột | Mục đích |
|---|---|
hosted_invoice_url |
Deeplink trả lại — mail + dashboard |
grace_next_payment_attempt_at |
Biến {{決済予定日}} — từ invoice.next_payment_attempt |
grace_mail_1_sent_at |
Dedup mail ① |
grace_mail_2_sent_at |
Dedup mail ② |
grace_mail_3_sent_at |
Dedup mail ③ |
grace_success_mail_sent_at |
Dedup mail ④ |
Khi vào grace mới (webhook payment_failed):
- Set
status = PAST_DUE. - Cập nhật
hosted_invoice_url,grace_next_payment_attempt_at. - Reset
grace_mail_1/2/3_sent_at = null,grace_success_mail_sent_at = null.
Mốc Gửi Mail
Neo thời gian = UserPlan.billing_use_end_date (timezone JST).
Điều kiện chung: UserPayment.status = PAST_DUE.
| # | mail_key | 件名 | Trigger | Điều kiện gửi |
|---|---|---|---|---|
| ① | first-failure | お支払い方法のご確認をお願いいたします | Cron 09:00 JST | today >= end_date |
| ② | reminder | お支払い方法の更新をお願いいたします | Cron 09:00 JST | today >= end_date + 3 days |
| ③ | final-notice | 【重要】有料プランのキャンセル前の最終確認です | Cron 09:00 JST | today >= end_date + 6 days |
| ④ | repayment-success | お支払いが完了しました | Webhook | payment_succeeded khi đang PAST_DUE |
Hạ Free tại T+7 (end_date + 7): Stripe cancel → webhook subscription.deleted — không do cron.
Cron SendPlanGraceMail
Lịch: 09:00 JST mỗi ngày (Kernel.php → dailyAt('09:00')->timezone('Asia/Tokyo')->withoutOverlapping()).
Luồng:
today = startOfDay('Asia/Tokyo')
payments = UserPayment
.where('status', PAST_DUE)
.whereHas('userPlan', billing_use_end_date NOT NULL)
.with(user, userPlan, user.userLogin)
foreach payment:
endDate = payment.userPlan.billing_use_end_date (JST, startOfDay)
if today >= endDate AND grace_mail_1_sent_at IS NULL:
send ①; set grace_mail_1_sent_at = now()
if today >= endDate + 3 days AND grace_mail_2_sent_at IS NULL:
send ②; set grace_mail_2_sent_at = now()
if today >= endDate + 6 days AND grace_mail_3_sent_at IS NULL:
send ③; set grace_mail_3_sent_at = now()
Gửi mail sync (Mail::send / Mailable). Set sent_at trong cùng transaction với gửi.
Độ trễ chấp nhận: webhook payment_failed đến sau 09:00 ngày end_date → mail ① gửi sáng hôm sau.
Webhook Handlers
File: admin/app/Domains/Subscription/Controllers/PaymentWebhookController.php
handleInvoicePaymentFailed
IF billing_reason != 'subscription_cycle' → return 200
user = resolve by invoice.customer
IF !user → return 200
UPDATE user_payments WHERE subscription_id = invoice.subscription:
status = PAST_DUE
hosted_invoice_url = invoice.hosted_invoice_url
grace_next_payment_attempt_at = invoice.next_payment_attempt (nullable)
grace_mail_1_sent_at = null
grace_mail_2_sent_at = null
grace_mail_3_sent_at = null
grace_success_mail_sent_at = null
return 200
Không gửi mail. Không tạo bản ghi phụ.
handleInvoicePaymentSucceeded
payment = UserPayment WHERE subscription_id = invoice.subscription
IF payment.status != PAST_DUE → return 200
BEGIN tx:
plan = map product → plan config
IF !plan → abort(500) // Stripe retry
newOrUpdateUserSubscription(...) // status → SETTLED, advance billing_use_end_date
IF grace_success_mail_sent_at IS NULL:
send ④
grace_success_mail_sent_at = now()
COMMIT
return 200
handleCustomerSubscriptionDeleted
user = resolve by subscription.customer
IF !user → return 200
newOrUpdateSubscription(user, subscription) // sync canceled
markUserPaymentDeleted(user, subscription.id) // → DELETED (trước downgrade)
ok = userDowngradePlan(user.id)
IF !ok → abort(500) // Stripe retry cleanup
return 200
handleCustomerSubscriptionUpdated
IF UserPayment.status == PAST_DUE cho subscription này
OR (stripe status == past_due AND latest_invoice.billing_reason == subscription_cycle):
newOrUpdateSubscription only — KHÔNG downgrade
return 200
// nhánh thường: advance plan + userDowngradePlan nếu cần
Trả Lại — Stripe Hosted Invoice
- Deeplink lưu tại
user_payments.hosted_invoice_url— cập nhật mỗi lầnpayment_failed. - Email ①②③ + dashboard banner đọc trực tiếp từ DB, không re-fetch Stripe.
- User trả xong →
payment_succeeded→SETTLED+ mail ④. - Hủy 3DS / không hoàn tất → vẫn
PAST_DUE, grace tiếp tục.
Email Templates
4 Mailable + 4 blade: admin/resources/views/emails/html/plan-dunning/
Biến template:
| Biến | Nguồn |
|---|---|
{{ユーザー名}} |
User->userLogin->name |
{{プラン名}} |
UserPlan.plan |
{{契約期間}} |
billing_use_start_date ~ billing_use_end_date |
{{利用料金}} |
UserPayment.expense |
{{決済予定日}} |
grace_next_payment_attempt_at |
{{決済日}} |
Thời điểm gửi ④ |
{{支払いリンク}} |
hosted_invoice_url |
Tương Tác Cron Khác
UpdateContentByUserPlan dùng resolver → PAST_DUE vẫn là paid → không cần sửa. Chỉ giảm lợi ích sau DELETED.
Edge Cases
| Case | Xử lý |
|---|---|
billing_reason != subscription_cycle |
Bỏ qua — không vào grace |
| User trả giữa grace | SETTLED; cron skip (không còn PAST_DUE) |
| Mail ④ trùng | grace_success_mail_sent_at guard |
| Cron miss 1 ngày | Ngày sau vẫn gửi (today >= milestone + sent_at null) |
| Chu kỳ fail lại | Webhook reset sent_at + PAST_DUE lại |
| Downgrade cleanup fail | abort(500) — Stripe retry idempotent |
| Stripe auto-retry thành công T+3/T+6 | payment_succeeded → ④ + SETTLED |
Test Strategy
payment_failed→ PAST_DUE + URL + reset sent_at; không mail.- Cron: đúng mốc end_date/+3/+6 → ①②③; đã sent → skip; không PAST_DUE → skip.
payment_succeededtừ PAST_DUE → SETTLED + ④; idempotent.- PAST_DUE + kỳ hết → resolver vẫn paid (CRITICAL).
subscription.deleted→ DELETED + downgrade + FREE (CRITICAL).- PAST_DUE → đổi/hủy gói 422; FE block.
- Chu kỳ fail lại → reset + mail lại đúng mốc.
Rollback
- Tắt cron
SendPlanGraceMail. - Revert webhook handlers (không set PAST_DUE).
- Stripe Dashboard: đổi retry policy hoặc bật lại email Stripe.
- Cột
user_paymentsnullable — giữ lại, không ảnh hưởng hệ thống cũ.
Quyết Định Thiết Kế
| # | Câu hỏi | Quyết định |
|---|---|---|
| 1 | State grace lưu ở đâu? | UserPayment.status — không bảng dunning |
| 2 | Ai retry/cancel T+7? | Stripe Custom retries 3/3/1 |
| 3 | Mốc mail ①②③? | billing_use_end_date + 0 / +3 / +6 (JST) |
| 4 | Ai gửi ①②③? | Cron 09:00 JST |
| 5 | Ai gửi ④? | Webhook payment_succeeded |
| 6 | Ai set PAST_DUE? | Webhook payment_failed |
| 7 | Ai hạ Free? | Webhook subscription.deleted |
| 8 | Deeplink lưu đâu? | user_payments.hosted_invoice_url |
| 9 | Dedup mail? | Cột grace_mail_*_sent_at |
| 10 | {{決済予定日}}? |
grace_next_payment_attempt_at |
| 11 | Trial/đăng ký mới fail? | Không grace — lọc subscription_cycle |
| 12 | Cấm đổi/hủy grace? | Có — tới khi SETTLED |