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

  1. Gia hạn fail → PAST_DUE, giữ đầy đủ quyền gói trả phí đến T+7.
  2. Gửi 4 email (nội dung/biến nguyên gốc Watanabe): ① first-failure, ② reminder, ③ final-notice, ④ repayment-success.
  3. T+7 chưa trả → Stripe hủy → hạ Free.
  4. User trả lại qua Stripe hosted invoice deeplink.
  5. Cấm đổi/hủy gói trong grace; chỉ được trả lại.
  6. 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) Stripehosted_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 ResolverPAST_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 localUserPayment.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ế:

  1. Webhook payment_failedUserPayment.status = PAST_DUE.
  2. getValidUserPlanAttribute() + maxUserPlanValid() (common/src/Models/User.php) coi SETTLEDPAST_DUE là paid.
    • PAST_DUE: giữ plan bất kể billing_use_end_date.
    • SETTLED: vẫn yêu cầu còn trong kỳ.
  3. subscription.deletedUserPayment = DELETED trướcuserDowngradePlan → 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 khi requires_repayment.
  • requires_repayment = PAST_DUE + có hosted_invoice_url; expose qua CurrentPlanResource + PlanPurchaseLastResource (kèm subscription_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.deletedkhông do cron.

Cron SendPlanGraceMail

Lịch: 09:00 JST mỗi ngày (Kernel.phpdailyAt('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ần payment_failed.
  • Email ①②③ + dashboard banner đọc trực tiếp từ DB, không re-fetch Stripe.
  • User trả xong → payment_succeededSETTLED + 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_datebilling_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_succeeded từ 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_payments nullable — 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