Subscription Flow — End-to-End Scenarios
BẮT BUỘC đọc trước khi build/sửa subscription, trial, billing UI hoặc dunning logic. Companion:
docs/architecture/subscription-architecture.md(DDD model, ports, provider matrix).
12 scenarios end-to-end cover toàn bộ subscription lifecycle: signup → trial → upgrade → downgrade → past_due → recovery → cancel → churn → provider switch. Mỗi scenario có actors, preconditions, steps, state transitions, side effects, error edges.
Quy ước
- Actor: OWNER, ADMIN (super-admin platform), STAFF, CUSTOMER, SYSTEM (cron/webhook), PROVIDER (LS/Stripe/Paddle)
- State:
[ACTIVE | PAST_DUE | CANCELED | EXPIRED] + trialEndsAt? + seatQuantity - Event: domain event tên đầy đủ (
SubscriptionCreated,SubscriptionPaymentFailed, ...)
S1. Welcome trial — Tenant onboard mới, auto-start 14-day trial
Actors: OWNER, SYSTEM, PROVIDER
Preconditions:
- Tenant vừa hoàn thành onboarding wizard (
onboardedAtset) - Không có
Subscriptionrow nào
Steps:
TenantOnboardedevent fire khi user complete wizard step 7OnTenantOnboardedListenertriggerStartTrialCommand({ tenantId })- Handler tạo
Subscriptionrow local:provider = LEMONSQUEEZY(default từ env)providerSubId = NULL(chưa tạo trên LS — trial không cần)planKey = 'solo_monthly'(smallest plan)seatQuantity = 1status = ACTIVEtrialEndsAt = now + 14 dayscurrentPeriodStart = now,currentPeriodEnd = trialEndsAt
- Tenant read model sync:
Tenant.subscriptionStatus = ACTIVETenant.trialEndsAt = trialEndsAtTenant.currentPlanKey = 'solo_monthly'Tenant.seatLimit = 1
- Welcome email gửi qua
BrandedEmailProcessor: "Bạn có 14 ngày trial — không cần thẻ" - Admin dashboard hiển thị banner: "Trial — còn 14 ngày · [Upgrade now]"
State after: ACTIVE + trialEndsAt = +14d + seats=1
Edges:
- Tenant re-onboard (rare — soft-delete tenant rồi tạo lại): KHÔNG auto-trial nếu đã có Subscription history (defer abuse). Owner phải click "Start trial" manually
- ADMIN-tạo tenant: skip auto-trial, ADMIN có thể gán plan custom qua super-admin
S2. Add staff trong trial — seat limit kick in
Actors: OWNER
Preconditions: Tenant ACTIVE, planKey = solo_monthly, seatLimit = 1, 1 staff hiện hữu
Steps:
- OWNER click "Invite staff" →
InviteStaffModal - Frontend hiển thị banner: "Plan Solo giới hạn 1 staff. Upgrade Pro để mời thêm."
- Submit form →
POST /staff-invitations SeatLimitPolicy.assertCanAddSeat(tenantId)đọcTenant.seatLimit = 1+ đếmResource.count({ isActive: true })= 1- Throw
SEAT_LIMIT_REACHED(HTTP 403) - FE catch error → redirect
/admin/billing/upgrade?reason=seat_limit&intent=invite_staff - Upgrade page pre-select
pro_monthly_per_seatvới seatQuantity = 2 (current + 1 invitee)
State after: unchanged (block xảy ra trước mutation)
Edges:
- Race condition (2 invites cùng lúc): atomic
INSERT WHERE (SELECT COUNT(*) FROM resources WHERE tenant_id=? AND is_active=true) < seat_limitqua transaction; loser nhận 409SEAT_LIMIT_RACE - Owner re-invite staff đã pending: KHÔNG count vào seat (chưa accept,
Resource.isActive=false)
S3. Checkout flow — Upgrade từ trial → Pro
Actors: OWNER, PROVIDER (LS), SYSTEM
Preconditions: Tenant ACTIVE + trial day 10, 2 staff active, muốn invite thêm
Steps:
- OWNER mở
/admin/billing/upgrade, chọnpro_monthly_per_seat, FE tínhseatQuantity = 3(2 hiện tại + 1 dự định mời) - FE POST
/subscription/checkout{ planKey: 'pro_monthly_per_seat', seatQuantity: 3, returnUrl: '/admin/billing?status=success' } - Backend handler
CreateCheckoutCommand:- Resolve adapter
providerRegistry.get(LEMONSQUEEZY) - Adapter map
planKey → LS variantIdquaplan-mapping.config.ts - Call
adapter.createCheckoutSession({...})→ LS API trảcheckoutUrl - LS
custom_data: { tenantId }để webhook biết route về tenant nào
- Resolve adapter
- Backend trả
{ checkoutUrl }→ FEwindow.location = checkoutUrl - OWNER nhập card trên LS hosted page, submit
- LS process payment → tạo subscription
- LS POST webhook
subscription_created→/webhooks/subscription/lemonsqueezy WebhookController:- Verify HMAC signature
- Upsert
WebhookInboxrow (idempotent qua(provider, providerEventId)) - Nếu
processedAt = null→ triggerSyncFromWebhookCommand
SyncFromWebhookHandler:- Adapter parse payload →
DomainSubscriptionEvent.subscription.created - Resolve tenant qua
custom_data.tenantId - Update
Subscriptionrow:providerSubId = ls_sub_xxxproviderCustomerId = ls_cust_yyyplanKey = pro_monthly_per_seatseatQuantity = 3status = ACTIVEtrialEndsAt = NULL(Pro không có trial sau upgrade)currentPeriodStart,currentPeriodEndtừ payload
- Emit
SubscriptionUpgradedevent Tenant.subscriptionStatus,currentPlanKey,seatLimit = NULL(unlimited) sync
- Adapter parse payload →
- LS redirect OWNER về
/admin/billing?status=success - UI hiển thị toast "Welcome to Pro · 3 seats"
State after: ACTIVE + trialEndsAt=NULL + seats=3 + plan=pro_monthly_per_seat
Edges:
- Owner đóng tab trước khi return → webhook vẫn xử lý, sub vẫn active. Lần next OWNER login → banner "Upgrade hoàn tất"
- Webhook trễ hơn return URL: FE redirect về
/admin/billingnhưng status vẫnACTIVE+trial. FE pollingGET /me/subscription5s/lần trong 60s đầu sau return → update khi webhook đến - LS payment fail (card declined): LS hiển thị error trên hosted page, không tạo sub. Owner retry hoặc cancel
- Webhook double-fire: Inbox unique
(provider, providerEventId)→ second insert fail, skip
S4. Customer portal — Update card / view invoices
Actors: OWNER, PROVIDER
Preconditions: Tenant có Subscription.providerSubId (đã checkout)
Steps:
- OWNER click "Manage billing" trên
/admin/billing - FE POST
/subscription/portal{ returnUrl: '/admin/billing' } - Backend
GetPortalUrlHandler:- Load
Subscriptioncho tenant → adapter +providerCustomerId - Call
adapter.getCustomerPortalUrl(providerCustomerId, returnUrl)→ LS API trả URL signed
- Load
- Backend trả
{ portalUrl }→ FE redirect - Owner update card / cancel / view invoices trên LS hosted portal
- Click "Back to app" → LS redirect về
returnUrl
State after: unchanged (changes from portal đến qua webhook subsequent)
Edges:
- Portal URL expired (>30 min từ khi gen): provider hiển thị error, owner phải refresh từ app
- Owner cancel trong portal → flow S8 trigger
- Owner update card thành công khi đang past-due → flow S6 trigger
S5. Renewal — Tự động charge end of period
Actors: PROVIDER, SYSTEM
Preconditions: ACTIVE, currentPeriodEnd ≤ now + 24h, card valid
Steps:
- LS tự động charge card 24h trước
currentPeriodEnd - Charge success → LS POST webhook
subscription_payment+subscription_updated - WebhookInbox + Sync handler:
Subscription.currentPeriodStart = oldPeriodEndSubscription.currentPeriodEnd += 1 month (hoặc 1 year)paymentFailedAttempts = 0(reset nếu có)- Emit
SubscriptionRenewed
- Email gửi: "Invoice #xxx — paid 447 NOK"
- KHÔNG ảnh hưởng UX — invisible to tenant
State after: ACTIVE + period rolled forward
Edges:
- Renewal charge fail → flow S6 (past_due)
- Tenant đã cancel
cancelAtPeriodEnd=true: LS skip charge, gửi webhooksubscription_expired→ flow S9
S6. Payment failed — First attempt, soft past_due
Actors: PROVIDER, SYSTEM, OWNER
Preconditions: Renewal charge fail (card expired / insufficient funds)
Steps:
- LS charge fail → POST webhook
subscription_payment_failed - WebhookInbox + Sync handler:
Subscription.status = PAST_DUESubscription.paymentFailedAttempts = 1Subscription.lastFailedAt = now- Emit
SubscriptionPaymentFailed
Tenant.subscriptionStatus = PAST_DUEsync (read model)- Dunning email gửi (qua LS hoặc custom): "Payment failed — update card within 7 days"
BillingGuardđọcDunningPolicy.assess(tenant):paymentFailedAttempts = 1+now - lastFailedAt < 7 days→ soft mode- Admin mutations vẫn pass
- Public booking vẫn pass
- Admin UI hiển thị banner màu amber: "Payment failed — please update card · [Update billing]"
State after: PAST_DUE + attempts=1 + soft mode
Edges:
- Owner click banner → portal flow S4 → update card → next retry success → flow S7 (recovery)
- LS retry tự động sau 3 ngày: nếu success → S7; nếu fail → attempts=2, vẫn soft
- LS retry exhausted (≥4 attempts hoặc >21 days): → flow S10 (hard expire)
S7. Payment recovered — Card updated, retry success
Actors: PROVIDER, SYSTEM
Preconditions: PAST_DUE, owner đã update card qua portal
Steps:
- LS retry charge → success
- Webhook
subscription_payment_success+subscription_updated - Sync handler:
Subscription.status = ACTIVESubscription.paymentFailedAttempts = 0Subscription.lastFailedAt = NULLcurrentPeriodEndextended- Emit
SubscriptionRecovered
Tenant.subscriptionStatus = ACTIVEsync- Banner clear khỏi UI
- Email gửi: "Payment recovered — thanks!"
State after: ACTIVE + attempts=0
Edges:
- Race với manual cancel (owner click cancel khi đang past-due): cancel intent →
cancelAtPeriodEnd=true, sub vẫn renew success lần này nhưng sẽ expire cuối kỳ → flow S9
S8. Owner self-cancel — Cancel at period end
Actors: OWNER, PROVIDER
Preconditions: ACTIVE
Steps:
- OWNER click "Cancel subscription" trong customer portal (S4) — KHÔNG tự build cancel UI ở MVP
- LS process cancel → set
cancel_at_period_end=truetrên LS sub - LS POST webhook
subscription_updated - Sync handler:
Subscription.cancelAtPeriodEnd = trueSubscription.canceledAt = nowSubscription.status = ACTIVE(KHÔNG đổi sang CANCELED ngay — vẫn còn period)
- Admin UI hiển thị banner: "Subscription canceled — access until DD/MM/YYYY · [Resubscribe]"
- Tenant vẫn full access đến
currentPeriodEnd
State after: ACTIVE + cancelAtPeriodEnd=true + canceledAt=now
Edges:
- Owner resubscribe trước period end: portal "Reactivate" hoặc app-side
POST /subscription/reactivate→ backend calladapter.cancelSubscription(providerSubId, cancelAtPeriodEnd=false)→ LS clear cancel flag → flow S5 resumes normally - Owner click cancel ngay trong trial:
Subscription.providerSubId = NULL(chưa LS) → backend handle local cancel → status thành EXPIRED ngay (no period paid for)
S9. Period end after cancel — Move to EXPIRED
Actors: PROVIDER, SYSTEM
Preconditions: ACTIVE + cancelAtPeriodEnd=true, now ≥ currentPeriodEnd
Steps:
- LS skip auto-charge tại
currentPeriodEnd - LS POST webhook
subscription_expired - Sync handler:
Subscription.status = EXPIRED- Emit
SubscriptionExpired
Tenant.subscriptionStatus = EXPIREDsyncBillingGuardchặn tất cả mutation routes → 403SUBSCRIPTION_EXPIRED- Public booking endpoint 503
SUBSCRIPTION_INACTIVE - Email gửi: "Access ended — your data is preserved for 90 days · [Resubscribe]"
- Admin dashboard chỉ còn
/admin/billingaccessible (re-checkout) + read-only data views
State after: EXPIRED + hard block
Edges:
- Customer đến
/b/<slug>→ page hiển thị "Salon temporarily unavailable" (không mention billing — tenant privacy) - Staff thử login → từ chối với message "Contact owner — subscription inactive"
- OWNER vẫn login được để re-checkout
S10. Hard past_due — Retries exhausted
Actors: PROVIDER, SYSTEM
Preconditions: PAST_DUE + attempts ≥ 4 hoặc now - lastFailedAt > 21 days
Steps:
- LS exhaust dunning retries → POST webhook
subscription_expired - Sync handler: flow giống S9,
status = EXPIRED - Hard block kick in
State after: EXPIRED
Edges:
- Owner update card vào ngày 22 (sau LS đã expire): provider portal cho phép "Reactivate" → flow S11
S11. Reactivation — Sau EXPIRED, mua lại
Actors: OWNER, PROVIDER, SYSTEM
Preconditions: Subscription.status = EXPIRED, tenant data vẫn còn (chưa quá 90 ngày)
Steps:
- OWNER login → BillingGuard pass cho
/admin/billingonly → page hiển thị "Resubscribe" CTA - Click Resubscribe → checkout flow S3 với plan picker pre-fill last plan
- LS tạo subscription MỚI (providerSubId mới — KHÔNG reactivate sub cũ)
- Webhook
subscription_createdđến - Sync handler:
- Tạo
Subscriptionrow MỚI (KHÔNG mutate row cũ) - Row cũ giữ làm audit
Tenant.subscriptionStatus = ACTIVE+ sync read model từ row mới
- Tạo
- Tenant unlock toàn bộ access
State after: 2 Subscription rows: cũ EXPIRED (audit), mới ACTIVE
Edges:
- Tenant đã quá 90 ngày data retention: data đã hard-delete (cron) → reactivation không khả thi, phải tạo tenant mới
- Provider khác (LS expired → owner muốn dùng Stripe): flow S12
S12. Provider migration — LS subscription chuyển sang Stripe
Actors: ADMIN (platform), OWNER, PROVIDER (cả LS lẫn Stripe), SYSTEM
Preconditions: Cohort migration phase active, tenant LS sub gần renewal
Steps:
- ADMIN flip feature flag
BILLING_DEFAULT_PROVIDER=stripecho tenant mới onboard - Cron
subscription-migration-reminder.cron.ts30 ngày trước LS renewal:- Email tenant: "Migrate to Stripe for lower fees · [Migrate now]"
- Admin UI banner: same message
- OWNER click → modal giải thích: "Cancel LS sub at period end + start Stripe sub today"
- Flow:
- Step A: backend call
adapter[LS].cancelSubscription(providerSubId, cancelAtPeriodEnd=true)→ LS webhooksubscription_updated→ cập nhậtcancelAtPeriodEnd=true - Step B: backend trigger checkout flow S3 nhưng force
provider=STRIPE→ adapter[STRIPE] tạo session - OWNER nhập card lại trên Stripe hosted page
- Stripe webhook
customer.subscription.created→ tạoSubscriptionrow MỚI vớiprovider=STRIPE
- Step A: backend call
- Trong giai đoạn overlap (LS chưa hết period + Stripe đã active):
Tenant.subscriptionHEAD = Stripe row (newest)BillingGuardđọc HEAD → pass- LS row sẽ tự
EXPIREDcuối kỳ qua S9
- Reconciliation report cuối tháng: ADMIN xem MRR LS vs Stripe
State after: 1 tenant có 2 subs (LS cũ → sẽ EXPIRED, Stripe mới → ACTIVE)
Edges:
- OWNER không migrate: LS tự renew bình thường — acceptable, không force
- LS cancel succeed nhưng Stripe checkout fail: LS sub đã cancel pending → owner phải reactivate LS qua portal hoặc retry Stripe. Edge case rare nhưng UI phải có "Restore LS subscription" fallback button trong 24h sau cancel
- Stripe đang ACTIVE nhưng LS chưa cancel: 2 ACTIVE subs —
BillingGuardđọc HEAD theoupdatedAt DESC→ Stripe thắng. Cron flagging double-charge alert → ADMIN xử lý refund manual
State machine summary
stateDiagram-v2
[*] --> ACTIVE_TRIAL : S1 onboard
ACTIVE_TRIAL --> ACTIVE_PAID : S3 checkout
ACTIVE_TRIAL --> EXPIRED : trial_ended + no_checkout
ACTIVE_PAID --> ACTIVE_PAID : S5 renewal
ACTIVE_PAID --> PAST_DUE_SOFT : S6 payment_failed
PAST_DUE_SOFT --> ACTIVE_PAID : S7 recovered
PAST_DUE_SOFT --> PAST_DUE_HARD : >7 days OR attempts ≥4
PAST_DUE_HARD --> ACTIVE_PAID : S7 recovered (rare)
PAST_DUE_HARD --> EXPIRED : S10 exhausted
ACTIVE_PAID --> ACTIVE_CANCELING : S8 cancel
ACTIVE_CANCELING --> EXPIRED : S9 period_end
ACTIVE_CANCELING --> ACTIVE_PAID : reactivate before period_end
EXPIRED --> ACTIVE_PAID : S11 reactivation (new sub)
Trên DB chỉ có 4 status (ACTIVE / PAST_DUE / CANCELED / EXPIRED). Diagram trên gộp thêm sub-state cho dễ đọc —
ACTIVE_TRIAL=ACTIVE + trialEndsAt > now,PAST_DUE_SOFT/HARDderived quaDunningPolicy.
Error reference
| Error code | HTTP | Khi nào |
|---|---|---|
SUBSCRIPTION_EXPIRED |
403 | Admin mutation khi Tenant.subscriptionStatus = EXPIRED |
SUBSCRIPTION_PAST_DUE_HARD |
403 | Past due > 7 ngày |
SUBSCRIPTION_INACTIVE |
503 | Public booking endpoint khi tenant EXPIRED hoặc past-due hard |
SEAT_LIMIT_REACHED |
403 | Tạo Resource khi current ≥ seatLimit |
SEAT_LIMIT_RACE |
409 | Race condition tạo cùng lúc nhiều invite |
PLAN_FEATURE_NOT_INCLUDED |
403 | Try dùng feature không có trong plan (vd loyalty trên Solo) |
CHECKOUT_FAILED |
502 | Provider API error khi tạo checkout |
WEBHOOK_SIGNATURE_INVALID |
401 | Webhook signature verify fail |
WEBHOOK_ALREADY_PROCESSED |
200 | Idempotent skip — luôn trả 200 |
PROVIDER_NOT_AVAILABLE |
503 | Adapter registry không tìm thấy provider |
UI map
| Surface | Path | Role |
|---|---|---|
| Trial banner | Admin layout global | OWNER |
| Past-due banner | Admin layout global | OWNER |
| Expired wall | Auto-redirect mọi route → /admin/billing |
OWNER |
| Billing dashboard | /admin/billing |
OWNER |
| Upgrade page | /admin/billing/upgrade |
OWNER |
| Plan picker modal | Triggered từ seat-limit / feature gate | OWNER |
| Portal redirect | Click "Manage billing" → external | OWNER |
| Reactivate CTA | /admin/billing khi EXPIRED |
OWNER |
| Public unavailable page | /b/<slug> khi tenant EXPIRED |
CUSTOMER |
| Super-admin tenant billing tab | /admin/superadmin/tenants/:id/billing |
ADMIN |
Cron jobs cần thiết
| Job | Schedule | Purpose |
|---|---|---|
subscription-trial-reminder |
daily 09:00 UTC | Email ngày 11/13/14 trial gần hết |
subscription-dunning-escalate |
hourly | Flip PAST_DUE_SOFT → HARD khi >7 days hoặc attempts ≥4 |
subscription-period-end-sweep |
hourly | Backup nếu webhook miss — flip CANCELING → EXPIRED |
subscription-data-retention-sweep |
daily 03:00 UTC | Hard-delete tenant EXPIRED > 90 ngày |
subscription-seat-drift-check |
daily 04:00 UTC | So sánh local seatQuantity vs provider — alert nếu lệch |
subscription-migration-reminder |
daily 10:00 UTC | (Khi migration phase active) email tenant trước renewal |
Liên kết
docs/architecture/subscription-architecture.md— Bounded context, schema, portsdocs/flows/payment-flow.md— Booking deposit flow (KHÔNG nhầm)docs/progress/features.md— Phase checklistdocs/rules/development-rules.md— Code conventions