flows/subscription-flow.md

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 (onboardedAt set)
  • Không có Subscription row nào

Steps:

  1. TenantOnboarded event fire khi user complete wizard step 7
  2. OnTenantOnboardedListener trigger StartTrialCommand({ tenantId })
  3. Handler tạo Subscription row 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 = 1
    • status = ACTIVE
    • trialEndsAt = now + 14 days
    • currentPeriodStart = now, currentPeriodEnd = trialEndsAt
  4. Tenant read model sync:
    • Tenant.subscriptionStatus = ACTIVE
    • Tenant.trialEndsAt = trialEndsAt
    • Tenant.currentPlanKey = 'solo_monthly'
    • Tenant.seatLimit = 1
  5. Welcome email gửi qua BrandedEmailProcessor: "Bạn có 14 ngày trial — không cần thẻ"
  6. 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:

  1. OWNER click "Invite staff" → InviteStaffModal
  2. Frontend hiển thị banner: "Plan Solo giới hạn 1 staff. Upgrade Pro để mời thêm."
  3. Submit form → POST /staff-invitations
  4. SeatLimitPolicy.assertCanAddSeat(tenantId) đọc Tenant.seatLimit = 1 + đếm Resource.count({ isActive: true }) = 1
  5. Throw SEAT_LIMIT_REACHED (HTTP 403)
  6. FE catch error → redirect /admin/billing/upgrade?reason=seat_limit&intent=invite_staff
  7. Upgrade page pre-select pro_monthly_per_seat vớ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_limit qua transaction; loser nhận 409 SEAT_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:

  1. OWNER mở /admin/billing/upgrade, chọn pro_monthly_per_seat, FE tính seatQuantity = 3 (2 hiện tại + 1 dự định mời)
  2. FE POST /subscription/checkout { planKey: 'pro_monthly_per_seat', seatQuantity: 3, returnUrl: '/admin/billing?status=success' }
  3. Backend handler CreateCheckoutCommand:
    • Resolve adapter providerRegistry.get(LEMONSQUEEZY)
    • Adapter map planKey → LS variantId qua plan-mapping.config.ts
    • Call adapter.createCheckoutSession({...}) → LS API trả checkoutUrl
    • LS custom_data: { tenantId } để webhook biết route về tenant nào
  4. Backend trả { checkoutUrl } → FE window.location = checkoutUrl
  5. OWNER nhập card trên LS hosted page, submit
  6. LS process payment → tạo subscription
  7. LS POST webhook subscription_created/webhooks/subscription/lemonsqueezy
  8. WebhookController:
    • Verify HMAC signature
    • Upsert WebhookInbox row (idempotent qua (provider, providerEventId))
    • Nếu processedAt = null → trigger SyncFromWebhookCommand
  9. SyncFromWebhookHandler:
    • Adapter parse payload → DomainSubscriptionEvent.subscription.created
    • Resolve tenant qua custom_data.tenantId
    • Update Subscription row:
      • providerSubId = ls_sub_xxx
      • providerCustomerId = ls_cust_yyy
      • planKey = pro_monthly_per_seat
      • seatQuantity = 3
      • status = ACTIVE
      • trialEndsAt = NULL (Pro không có trial sau upgrade)
      • currentPeriodStart, currentPeriodEnd từ payload
    • Emit SubscriptionUpgraded event
    • Tenant.subscriptionStatus, currentPlanKey, seatLimit = NULL (unlimited) sync
  10. LS redirect OWNER về /admin/billing?status=success
  11. 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/billing nhưng status vẫn ACTIVE+trial. FE polling GET /me/subscription 5s/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:

  1. OWNER click "Manage billing" trên /admin/billing
  2. FE POST /subscription/portal { returnUrl: '/admin/billing' }
  3. Backend GetPortalUrlHandler:
    • Load Subscription cho tenant → adapter + providerCustomerId
    • Call adapter.getCustomerPortalUrl(providerCustomerId, returnUrl) → LS API trả URL signed
  4. Backend trả { portalUrl } → FE redirect
  5. Owner update card / cancel / view invoices trên LS hosted portal
  6. 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:

  1. LS tự động charge card 24h trước currentPeriodEnd
  2. Charge success → LS POST webhook subscription_payment + subscription_updated
  3. WebhookInbox + Sync handler:
    • Subscription.currentPeriodStart = oldPeriodEnd
    • Subscription.currentPeriodEnd += 1 month (hoặc 1 year)
    • paymentFailedAttempts = 0 (reset nếu có)
    • Emit SubscriptionRenewed
  4. Email gửi: "Invoice #xxx — paid 447 NOK"
  5. 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 webhook subscription_expired → flow S9

S6. Payment failed — First attempt, soft past_due

Actors: PROVIDER, SYSTEM, OWNER

Preconditions: Renewal charge fail (card expired / insufficient funds)

Steps:

  1. LS charge fail → POST webhook subscription_payment_failed
  2. WebhookInbox + Sync handler:
    • Subscription.status = PAST_DUE
    • Subscription.paymentFailedAttempts = 1
    • Subscription.lastFailedAt = now
    • Emit SubscriptionPaymentFailed
  3. Tenant.subscriptionStatus = PAST_DUE sync (read model)
  4. Dunning email gửi (qua LS hoặc custom): "Payment failed — update card within 7 days"
  5. BillingGuard đọc DunningPolicy.assess(tenant):
    • paymentFailedAttempts = 1 + now - lastFailedAt < 7 dayssoft mode
    • Admin mutations vẫn pass
    • Public booking vẫn pass
  6. 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:

  1. LS retry charge → success
  2. Webhook subscription_payment_success + subscription_updated
  3. Sync handler:
    • Subscription.status = ACTIVE
    • Subscription.paymentFailedAttempts = 0
    • Subscription.lastFailedAt = NULL
    • currentPeriodEnd extended
    • Emit SubscriptionRecovered
  4. Tenant.subscriptionStatus = ACTIVE sync
  5. Banner clear khỏi UI
  6. 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:

  1. OWNER click "Cancel subscription" trong customer portal (S4) — KHÔNG tự build cancel UI ở MVP
  2. LS process cancel → set cancel_at_period_end=true trên LS sub
  3. LS POST webhook subscription_updated
  4. Sync handler:
    • Subscription.cancelAtPeriodEnd = true
    • Subscription.canceledAt = now
    • Subscription.status = ACTIVE (KHÔNG đổi sang CANCELED ngay — vẫn còn period)
  5. Admin UI hiển thị banner: "Subscription canceled — access until DD/MM/YYYY · [Resubscribe]"
  6. 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 call adapter.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:

  1. LS skip auto-charge tại currentPeriodEnd
  2. LS POST webhook subscription_expired
  3. Sync handler:
    • Subscription.status = EXPIRED
    • Emit SubscriptionExpired
  4. Tenant.subscriptionStatus = EXPIRED sync
  5. BillingGuard chặn tất cả mutation routes → 403 SUBSCRIPTION_EXPIRED
  6. Public booking endpoint 503 SUBSCRIPTION_INACTIVE
  7. Email gửi: "Access ended — your data is preserved for 90 days · [Resubscribe]"
  8. Admin dashboard chỉ còn /admin/billing accessible (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:

  1. LS exhaust dunning retries → POST webhook subscription_expired
  2. Sync handler: flow giống S9, status = EXPIRED
  3. 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:

  1. OWNER login → BillingGuard pass cho /admin/billing only → page hiển thị "Resubscribe" CTA
  2. Click Resubscribe → checkout flow S3 với plan picker pre-fill last plan
  3. LS tạo subscription MỚI (providerSubId mới — KHÔNG reactivate sub cũ)
  4. Webhook subscription_created đến
  5. Sync handler:
    • Tạo Subscription row MỚI (KHÔNG mutate row cũ)
    • Row cũ giữ làm audit
    • Tenant.subscriptionStatus = ACTIVE + sync read model từ row mới
  6. 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:

  1. ADMIN flip feature flag BILLING_DEFAULT_PROVIDER=stripe cho tenant mới onboard
  2. Cron subscription-migration-reminder.cron.ts 30 ngày trước LS renewal:
    • Email tenant: "Migrate to Stripe for lower fees · [Migrate now]"
    • Admin UI banner: same message
  3. OWNER click → modal giải thích: "Cancel LS sub at period end + start Stripe sub today"
  4. Flow:
    • Step A: backend call adapter[LS].cancelSubscription(providerSubId, cancelAtPeriodEnd=true) → LS webhook subscription_updated → cập nhật cancelAtPeriodEnd=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ạo Subscription row MỚI với provider=STRIPE
  5. Trong giai đoạn overlap (LS chưa hết period + Stripe đã active):
    • Tenant.subscription HEAD = Stripe row (newest)
    • BillingGuard đọc HEAD → pass
    • LS row sẽ tự EXPIRED cuối kỳ qua S9
  6. 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 theo updatedAt 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/HARD derived qua DunningPolicy.


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