Kiến trúc SaaS Subscription
BẮT BUỘC đọc trước khi viết code liên quan platform billing, plan, trial, upgrade/downgrade, hoặc provider integration. Companion:
docs/flows/subscription-flow.md(12 scenarios end-to-end).
Subscription Context là bounded context độc lập, song song với Payment Context, thiết kế theo DDD + Hexagonal Architecture, tách biệt hoàn toàn với Booking & Payment, giao tiếp qua domain events. Mục tiêu: provider-agnostic (Lemon Squeezy, Stripe Billing, Paddle, …), migrate provider không phải rebuild, multi-tenant safe, trial + dunning ready từ MVP.
1. Phạm vi
Trong phạm vi (in scope)
- Tenant trả phí định kỳ cho platform (SaaS fee, không phải salon nhận tiền khách)
- Plan catalog độc lập với provider — đổi provider, plan ID không thay đổi cho tenant
- Trial → Active → Past Due → Canceled → Expired state machine
- Per-seat pricing (staff count) cho plan Pro
- Provider-agnostic: MVP = Lemon Squeezy (merchant of record, EU VAT handled). Roadmap: Stripe Billing (control rate cao hơn, tự handle VAT khi growth lớn), Paddle (alt MoR). Drop-in qua
BillingProviderPort(zero domain change) - Suspension guard chặn login/booking/admin actions khi plan Expired hoặc Past Due quá hạn
- Webhook verification + idempotent processing
- Customer portal redirect (provider-hosted self-service cancel/update card)
- Audit trail đầy đủ + domain event log
Ngoài phạm vi (cho đến có yêu cầu cụ thể)
- Salon-as-customer payment (Bambora, deposit, refund) — đã có Payment Context riêng, xem
payment-architecture.md - Marketplace commission split (tương lai khi mở marketplace discovery)
- Usage-based billing (per-booking, per-SMS) — V2
- Coupon / promo code engine — V2 (provider built-in đủ MVP)
- Multi-currency plan — V2 (NOK/EUR đủ cho thị trường Bắc Âu)
- Affiliate / referral payouts
- Custom enterprise contracts (manual invoice, NET-30) — handle bằng tay đến khi có ≥5 enterprise
2. Bounded Context Map
flowchart LR
subgraph SC [Subscription Context — DDD, new]
S[Subscription]
Plan[Plan Catalog]
SE[SubscriptionEvent]
WI[WebhookInbox]
end
subgraph TC [Tenant Context — existing]
T[Tenant]
TS[TenantSettings]
U[User / Resource]
end
subgraph BC [Booking Context — existing]
B[Booking]
end
SC -.->|domain events| TC
SC -.->|domain events| BC
SC -->|hosted checkout / portal| Provider{{Lemon Squeezy / Stripe / Paddle}}
Provider -->|webhook| WI
Integration pattern: Pure event-driven. Tenant / Booking không biết Subscription tồn tại trừ qua subscription gate guard (đọc-only). Khi Subscription cần update Tenant (e.g., Tenant.subscriptionStatus = ACTIVE), Subscription emit SubscriptionActivated, Tenant listener update read model.
Sự kiện qua biên context
| Direction | Event | Published by | Subscriber action |
|---|---|---|---|
| Subscription → Tenant | SubscriptionCreated |
Subscription | Tenant update subscriptionStatus = ACTIVE, set trialEndsAt nếu có |
| Subscription → Tenant | SubscriptionRenewed |
Subscription | Tenant currentPeriodEnd updated, clear past-due banner |
| Subscription → Tenant | SubscriptionPaymentFailed |
Subscription | Tenant subscriptionStatus = PAST_DUE, increment paymentFailedAttempts, fire dunning email |
| Subscription → Tenant | SubscriptionCanceled |
Subscription | Tenant subscriptionStatus = CANCELED (cancelAtPeriodEnd=true → still ACTIVE đến hết kỳ) |
| Subscription → Tenant | SubscriptionExpired |
Subscription | Tenant subscriptionStatus = EXPIRED — gate guard khoá admin mutations + public booking |
| Subscription → Booking | SubscriptionExpired |
Subscription | Booking public endpoint trả 503 SUBSCRIPTION_EXPIRED |
| Tenant → Subscription | TenantOnboarded |
Tenant | Trial starts (SubscriptionStartTrialCommand) nếu chưa có sub |
| Tenant → Subscription | ResourceCreated (active) |
Tenant | Seat usage++; nếu vượt seatLimit → throw SEAT_LIMIT_REACHED |
3. Nhật ký quyết định (Decision Log)
| # | Quyết định | Lý do |
|---|---|---|
| D1 | Subscription Context tách hoàn toàn khỏi Payment Context | Hai bounded context khác nhau: Payment = salon-as-merchant (Bambora), Subscription = platform-as-merchant (LS). Khác provider, khác audit, khác compliance. Không reuse Payment model. |
| D2 | Plan catalog ở DB / config, KHÔNG hardcode trong adapter | Đổi provider, plan ID provider thay đổi nhưng planKey (vd pro_monthly_per_seat) giữ nguyên. Adapter làm lookup planKey → providerVariantId qua mapping table riêng. |
| D3 | Provider-agnostic qua BillingProviderPort |
Adapter cho LS / Stripe / Paddle implement cùng interface. Domain không bao giờ if (provider === 'lemonsqueezy'). Migration cost = viết 1 adapter mới. |
| D4 | Mỗi Subscription row gắn 1 provider duy nhất; provider field immutable |
Khi migrate provider, tạo Subscription mới (provider=stripe) + giữ row cũ (provider=lemonsqueezy) qua hết period rồi cancel. Không có "in-place provider swap" — quá risky về billing semantics. |
| D5 | Webhook Inbox + idempotent qua providerEventId |
Provider có thể retry webhook nhiều lần. Inbox row UNIQUE trên (provider, eventId). Domain handler chỉ trigger 1 lần. |
| D6 | Trial state là derived từ trialEndsAt, không phải status riêng |
Tránh state machine bị nhân lên (TRIAL × ACTIVE × PAST_DUE…). Status: ACTIVE / PAST_DUE / CANCELED / EXPIRED. Trial = status=ACTIVE + trialEndsAt > now. Đơn giản hơn, ít lỗi trạng thái. |
| D7 | Per-seat pricing tracked ở Subscription.seatQuantity, đồng bộ qua webhook + Resource event |
LS Subscription Quantity API + Stripe Subscription.items[0].quantity đều support per-seat. Khi Resource được tạo (active), command SyncSeatsCommand push count lên provider; ngược lại provider trả webhook subscription_updated → sync về DB. |
| D8 | Graceful degrade thay vì hard suspend | Past Due ≤ 7 ngày: full access + banner. Past Due > 7 ngày: read-only admin, public booking trả 503. Expired: tất cả đóng. Không xoá data trong 90 ngày — recovery window. Học từ Linear / Stripe. |
| D9 | Path-based webhook URL: /webhooks/subscription/:provider |
Rate limit per provider, debug rõ ràng, không rely vào payload introspection để route. Mirror pattern Payment webhook. |
| D10 | Customer portal = provider-hosted, không tự build | LS / Stripe / Paddle đều có hosted portal cho update card, view invoices, cancel sub. Tự build = NPCI scope nặng + maintenance. Tenant click "Manage billing" → backend gen signed URL → redirect. |
| D11 | Dunning email do provider gửi (MVP), tự gửi (V2) | LS / Stripe có built-in dunning email (3-4 retry trong 14-21 ngày). MVP dùng provider mặc định. V2 disable provider email + tự gửi qua BrandedEmailProcessor để brand đồng nhất. |
| D12 | Seat limit check tại boundary (Resource invitation), KHÔNG cron | Khi tạo Resource active hoặc invite staff → guard đọc tenant.seatLimit + đếm hiện tại. Cron sweep dễ tạo race condition, UX confusing ("hôm qua add được, hôm nay bị disable"). |
4. Domain Model
4.1. Aggregates
classDiagram
class Subscription {
+id: UUID
+tenantId: UUID
+provider: ProviderName
+providerSubId: String
+providerCustomerId: String
+planKey: String
+seatQuantity: Int
+status: SubscriptionStatus
+currentPeriodStart: DateTime
+currentPeriodEnd: DateTime
+trialEndsAt: DateTime?
+cancelAtPeriodEnd: Boolean
+canceledAt: DateTime?
+paymentFailedAttempts: Int
+activate(periodEnd) void
+markPastDue(attempt) void
+recoverFromPastDue() void
+cancel(effectiveAt) void
+expire() void
+syncSeats(newQty) void
}
class Plan {
+key: String
+displayName: String
+pricePerSeatMinor: Int?
+flatPriceMinor: Int?
+currency: String
+seatBased: Boolean
+seatLimit: Int?
+trialDays: Int
+features: PlanFeatures
}
class SubscriptionEvent {
+id: UUID
+subscriptionId: UUID
+tenantId: UUID
+eventType: String
+providerEventId: String
+payload: JSON
+processedAt: DateTime?
+createdAt: DateTime
}
class WebhookInbox {
+id: UUID
+provider: ProviderName
+providerEventId: String
+headerSignature: String
+rawPayload: JSON
+verifiedAt: DateTime?
+processedAt: DateTime?
+error: String?
}
Subscription "1" --> "*" SubscriptionEvent : audit
Subscription "*" --> "1" Plan : references by planKey
4.2. Status state machine
stateDiagram-v2
[*] --> ACTIVE : subscription.created
ACTIVE --> PAST_DUE : payment_failed (attempt ≥1)
PAST_DUE --> ACTIVE : payment.recovered (retry success)
PAST_DUE --> EXPIRED : retry_exhausted (≥4 attempts) / period_end + still past_due
ACTIVE --> CANCELED : user.cancel (cancelAtPeriodEnd=true)
CANCELED --> EXPIRED : period_end reached
ACTIVE --> EXPIRED : period_end + no_renewal (immediate cancel — rare)
EXPIRED --> ACTIVE : reactivation (new checkout, new providerSubId)
Invariants:
status=ACTIVE+trialEndsAt > now→ tenant đang trial; UI hiển thị "Trial — X days left"status=PAST_DUE+paymentFailedAttempts ≤ 3+now - lastFailedAt ≤ 7 days→ soft mode (banner, full access)status=PAST_DUE+ (≥4 attempts hoặc > 7 ngày) → hard mode (read-only admin, public 503)status=CANCELED+currentPeriodEnd > now→ vẫn full access (đã trả tiền hết kỳ)status=EXPIRED→ hard block tất cả- Reactivation = tạo
SubscriptionMỚI với providerSubId mới; row cũ giữ làm audit (immutable). Impl:SyncFromWebhookHandler.resolveExistingcoi (row mới nhất = EXPIRED) +subscription.createdlà miss →createdFromCheckoutra row mới (KHÔNG córeactivate.handler.tsriêng — một code path với webhook thường). UI: status EXPIRED →OwnerSubscriptionContentrenderPlanPicker(không phải current-sub card).
4.3. Plan catalog (MVP)
| planKey | Display | Pricing | Seats | Trial |
|---|---|---|---|---|
solo_monthly |
Solo | 199 NOK/tháng (flat) | 1 cap | 14 days |
pro_monthly_per_seat |
Pro Monthly | 149 NOK/seat/tháng | unlimited | 14 days |
pro_yearly_per_seat |
Pro Yearly | 1490 NOK/seat/năm (~17% off) | unlimited | 14 days |
enterprise_custom |
Enterprise | Manual contract | unlimited | N/A |
Plan catalog không hardcode pricing trong adapter — pricePerSeatMinor được provider trả về qua webhook, DB lưu lại để cross-check (drift detection).
4.4. Plan feature flags
PlanFeatures plain TypeScript interface (booking-api dùng class-validator chứ không phải zod; catalog là hardcoded const → type-check tại compile time + runtime invariant assertions) — drives feature gating:
{
maxBookingsPerMonth: number | null, // null = unlimited
maxStaffSeats: number | null,
smsCreditsIncluded: number,
emailCreditsIncluded: number,
loyaltyEnabled: boolean,
marketplaceEnabled: boolean, // future
customBrandingEnabled: boolean,
apiAccessEnabled: boolean, // future
}
Guard pattern: await this.planFeatures.require(tenantId, 'loyaltyEnabled') throws PLAN_FEATURE_NOT_INCLUDED nếu plan hiện tại không có.
5. Hexagonal Architecture — Ports & Adapters
5.1. Module structure
booking-api/src/core/subscription/
├── domain/
│ ├── subscription.ts # Aggregate root
│ ├── plan.ts # Plan + PlanFeatures value objects
│ ├── subscription-status.ts # Enum + transitions
│ ├── provider-name.ts # LEMONSQUEEZY | STRIPE | PADDLE
│ ├── errors.ts # Domain errors
│ └── events.ts # Domain events
├── application/
│ ├── commands/
│ │ ├── start-trial.handler.ts
│ │ ├── create-checkout.handler.ts
│ │ ├── sync-from-webhook.handler.ts
│ │ ├── cancel-subscription.handler.ts
│ │ ├── sync-seats.handler.ts
│ │ └── reactivate.handler.ts
│ ├── queries/
│ │ ├── get-current-plan.handler.ts
│ │ └── get-portal-url.handler.ts
│ ├── policies/
│ │ ├── dunning-policy.ts # Khi nào hard-block
│ │ └── seat-limit-policy.ts
│ └── listeners/
│ └── on-resource-created.listener.ts # Auto sync seats
├── infrastructure/
│ ├── ports/
│ │ ├── billing-provider.port.ts # Interface — domain depends on
│ │ └── subscription-repository.port.ts
│ ├── providers/
│ │ ├── lemonsqueezy/
│ │ │ ├── lemonsqueezy.adapter.ts # implements BillingProviderPort
│ │ │ ├── lemonsqueezy.client.ts
│ │ │ ├── lemonsqueezy.signature.ts # HMAC SHA-256
│ │ │ └── lemonsqueezy.event-mapper.ts
│ │ ├── stripe/ # V2 — empty stub
│ │ └── paddle/ # V3 — empty stub
│ ├── plan-catalog/
│ │ ├── plan-catalog.ts # In-memory plan registry
│ │ └── plan-mapping.config.ts # planKey ↔ provider IDs
│ └── persistence/
│ ├── prisma-subscription.repository.ts
│ └── prisma-webhook-inbox.repository.ts
├── interfaces/
│ ├── subscription.controller.ts # GET /me/subscription, POST /checkout, POST /cancel
│ ├── webhook.controller.ts # POST /webhooks/subscription/:provider
│ └── billing-guard.ts # NestJS guard — enforces gate
└── subscription.module.ts
5.2. BillingProviderPort interface
export interface BillingProviderPort {
readonly name: ProviderName;
// Khởi tạo checkout session — trả URL cho tenant redirect
createCheckoutSession(params: {
tenantId: string;
planKey: string;
seatQuantity: number;
customerEmail: string;
successUrl: string;
cancelUrl: string;
}): Promise<{ checkoutUrl: string; providerSessionId: string }>;
// Customer portal URL (provider-hosted, signed, short-lived)
getCustomerPortalUrl(providerCustomerId: string, returnUrl: string): Promise<string>;
// Cancel — cancelAtPeriodEnd để giữ access đến hết kỳ
cancelSubscription(providerSubId: string, cancelAtPeriodEnd: boolean): Promise<void>;
// Sync seat quantity (per-seat plans)
updateSubscriptionQuantity(providerSubId: string, newQuantity: number): Promise<void>;
// Verify + parse webhook → normalized domain event hoặc null nếu invalid
parseWebhook(
rawBody: Buffer,
headers: Record<string, string>,
): Promise<DomainSubscriptionEvent | null>;
}
5.3. Normalized domain event
export type DomainSubscriptionEvent =
| { type: 'subscription.created'; providerEventId: string; providerSubId: string;
providerCustomerId: string; tenantId: string; planKey: string; seatQuantity: number;
periodStart: Date; periodEnd: Date; trialEndsAt?: Date }
| { type: 'subscription.renewed'; providerEventId: string; providerSubId: string;
periodStart: Date; periodEnd: Date }
| { type: 'subscription.updated'; providerEventId: string; providerSubId: string;
planKey: string; seatQuantity: number }
| { type: 'subscription.payment_failed'; providerEventId: string; providerSubId: string;
attempt: number; nextRetryAt?: Date; failureCode?: string }
| { type: 'subscription.payment_recovered'; providerEventId: string; providerSubId: string }
| { type: 'subscription.canceled'; providerEventId: string; providerSubId: string;
cancelAtPeriodEnd: boolean; effectiveAt: Date }
| { type: 'subscription.expired'; providerEventId: string; providerSubId: string;
expiredAt: Date };
Đây là contract bất biến. Mọi adapter PHẢI map event provider của mình về 1 trong 7 type này. Provider thêm event mới → adapter responsibility, không leak ra domain.
5.4. Provider adapter responsibility
| Concern | Adapter làm | Domain làm |
|---|---|---|
Mapping planKey ↔ variantId/priceId |
✅ | ❌ |
| HMAC / signature verify | ✅ | ❌ |
| HTTP retry / circuit breaker | ✅ | ❌ |
| Webhook event shape normalization | ✅ | ❌ |
| Provider API client (lemonsqueezy.com/v1/...) | ✅ | ❌ |
| Status machine transitions | ❌ | ✅ |
| Dunning timing policy | ❌ | ✅ |
| Seat limit enforcement | ❌ | ✅ |
| Plan feature gating | ❌ | ✅ |
| Tenant gate guard | ❌ | ✅ |
6. Schema — Prisma
// ============================================================
// Subscription Context
// ============================================================
enum SubscriptionStatus {
ACTIVE // bao gồm trial (derived qua trialEndsAt)
PAST_DUE
CANCELED // cancelAtPeriodEnd=true, vẫn ACTIVE đến hết kỳ
EXPIRED // hard block
}
enum BillingProvider {
LEMONSQUEEZY
STRIPE
PADDLE
}
model Subscription {
id String @id @default(uuid())
tenantId String @map("tenant_id")
provider BillingProvider
providerSubId String @map("provider_sub_id")
providerCustomerId String @map("provider_customer_id")
planKey String @map("plan_key")
seatQuantity Int @default(1) @map("seat_quantity")
status SubscriptionStatus
currentPeriodStart DateTime @map("current_period_start")
currentPeriodEnd DateTime @map("current_period_end")
trialEndsAt DateTime? @map("trial_ends_at")
cancelAtPeriodEnd Boolean @default(false) @map("cancel_at_period_end")
canceledAt DateTime? @map("canceled_at")
paymentFailedAttempts Int @default(0) @map("payment_failed_attempts")
lastFailedAt DateTime? @map("last_failed_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
events SubscriptionEvent[]
@@unique([provider, providerSubId])
@@index([tenantId, status])
@@index([currentPeriodEnd])
@@index([status, lastFailedAt])
@@map("subscriptions")
}
model SubscriptionEvent {
id String @id @default(uuid())
subscriptionId String @map("subscription_id")
tenantId String @map("tenant_id")
eventType String @map("event_type")
providerEventId String @unique @map("provider_event_id")
payload Json
processedAt DateTime? @map("processed_at")
createdAt DateTime @default(now()) @map("created_at")
subscription Subscription @relation(fields: [subscriptionId], references: [id])
@@index([subscriptionId])
@@index([tenantId, createdAt])
@@map("subscription_events")
}
model SubscriptionWebhookInbox {
id String @id @default(uuid())
provider BillingProvider
providerEventId String @map("provider_event_id")
headerSignature String @map("header_signature") @db.Text
rawPayload Json @map("raw_payload")
verifiedAt DateTime? @map("verified_at")
processedAt DateTime? @map("processed_at")
error String? @db.Text
createdAt DateTime @default(now()) @map("created_at")
@@unique([provider, providerEventId])
@@index([processedAt])
@@map("subscription_webhook_inbox")
}
// Tenant model — additions
model Tenant {
// ... existing fields
subscriptionStatus SubscriptionStatus @default(ACTIVE) @map("subscription_status")
trialEndsAt DateTime? @map("trial_ends_at")
currentPlanKey String? @map("current_plan_key")
seatLimit Int? @map("seat_limit") // null = unlimited
subscription Subscription?
// ... existing relations
}
Lý do Tenant cache 4 field từ Subscription: hot-path guard read (booking creation, login) cần check status mà không JOIN. Sync từ Subscription qua listener — Subscription là source of truth, Tenant là read model.
7. Guard architecture
7.1. BillingGuard (NestJS)
Áp lên mọi mutation route admin (OwnerGuard ngay sau auth, trước BillingGuard):
flowchart TD
A[Request đến route admin] --> B{Auth OK?}
B -->|No| F1[401]
B -->|Yes| C{Tenant tồn tại?}
C -->|No| F2[404]
C -->|Yes| D{subscriptionStatus}
D -->|ACTIVE| OK[Pass]
D -->|CANCELED + period_end > now| OK
D -->|PAST_DUE| E{Soft window?}
E -->|≤7 ngày + ≤3 attempts| OK
E -->|>7 ngày hoặc ≥4 attempts| F3[403 SUBSCRIPTION_PAST_DUE_HARD]
D -->|EXPIRED| F4[403 SUBSCRIPTION_EXPIRED]
7.2. Public booking gate
POST /public/tenants/:slug/bookings trả 503 SUBSCRIPTION_INACTIVE khi tenant EXPIRED hoặc PAST_DUE hard. Customer thấy "Salon temporarily unavailable" — KHÔNG mention billing (tenant privacy).
7.3. Routes whitelist (luôn pass)
GET /me/subscription— owner phải xem được status để biết tại sao bị blockPOST /subscription/checkout— owner phải tạo checkout được để fixPOST /subscription/portal— provider portal redirectPOST /webhooks/subscription/:provider— provider gọiGET /admin/billing(web) — UI billing pagePOST /auth/logout— luôn cho phép
7.4. Read access cho EXPIRED
Tenant EXPIRED vẫn cho phép:
- Xem dashboard read-only (KHÔNG tạo/sửa)
- Export data (CSV bookings, customers) — recovery window 90 ngày
- Re-checkout → reactivation
Tenant EXPIRED chặn:
- Tạo / sửa / xoá mọi entity
- Public booking endpoint
- Staff login (chỉ OWNER login được để reactivate)
8. Provider migration strategy
8.1. Khi nào cần switch?
- LS / Paddle (Merchant of Record) ăn ~5% fee + 0.50 NOK/transaction. Khi MRR > ~50K USD, switch sang Stripe Billing tiết kiệm ~3% — bù cost handle VAT
- LS sập / acquisition / policy change
- Cần feature LS không có (vd: dunning customization, prepaid credits, complex tax rules)
8.2. Strategy chọn: Parallel forever + cohort migration
flowchart TD
A[Decision: switch LS → Stripe] --> B[Phase M1: Implement StripeAdapter]
B --> C[Phase M2: Tenant mới onboard sau ngày X → Stripe]
C --> D[Phase M3: LS tenant ACTIVE giữ nguyên cho đến hết period]
D --> E[Phase M4: Khi gần renewal — email mời tự re-checkout Stripe]
E --> F{Tenant accept?}
F -->|Yes| G[Cancel LS sub + tạo Stripe sub mới]
F -->|No| H[LS sub tự renew - acceptable]
G --> I[Khi LS tenants ≤5% → optional force migrate]
Tại sao không "big bang"?
- Card data nằm ở LS — Stripe import API yêu cầu Stripe Issuing partnership (chỉ Stripe Connect mới có), Lemon Squeezy không support export
- Tenant churn rate sẽ vọt khi force re-enter card
- VAT/billing semantics khác nhau giữa MoR providers — invoice cũ phải LS issue, invoice mới phải Stripe issue
Tại sao không "in-place swap"?
- Provider customer ID không portable
- Subscription period reset → tenant trả 2 lần / kỳ
- Audit trail bị fragment
Vì sao design hỗ trợ parallel?
Subscription.providerfield immutable, mỗi row gắn 1 providerTenantcó thể có Subscription history nhiều rows (chỉ 1 active tại một thời điểm)BillingProviderPortregistry inject động — adapter chọn theosubscription.provider- Plan catalog dùng
planKeychung — pricing có thể khác provider (Stripe có thể giảm vì fee thấp hơn)
8.3. Provider switch checklist
| # | Bước | Effort |
|---|---|---|
| 1 | Implement StripeAdapter (subclass BillingProviderPort) |
1.5 ngày |
| 2 | Map plan catalog: pro_monthly_per_seat → Stripe price_xxx |
0.5 ngày |
| 3 | Test Stripe webhook → normalized event đúng | 0.5 ngày |
| 4 | Add provider toggle: feature flag BILLING_DEFAULT_PROVIDER=stripe cho onboard mới |
0.5 ngày |
| 5 | Migration UI: banner cho LS tenants — "Move to Stripe for better rates" | 0.5 ngày |
| 6 | Email campaign: dunning-style 30/14/7-day reminder | 0.5 ngày |
| 7 | Reconciliation report: LS vs Stripe MRR breakdown | 0.5 ngày |
| Total | ~4 ngày |
Domain code: 0 thay đổi. Adapter pattern bảo đảm điều này.
9. Provider comparison matrix
| Tiêu chí | Lemon Squeezy | Stripe Billing | Paddle |
|---|---|---|---|
| Merchant of Record (xử lý VAT) | ✅ | ❌ (mình tự handle) | ✅ |
| Subscription API | ✅ | ✅ (gold standard) | ✅ |
| Per-seat quantity | ✅ via Quantity API | ✅ via items[].quantity | ✅ |
| Customer hosted portal | ✅ | ✅ | ✅ |
| Trial support | ✅ trial_ends_at | ✅ trial_end | ✅ |
| Proration on upgrade/downgrade | Basic | Excellent (auto credit) | Excellent |
| Tax handling | Auto (MoR) | Tax extension addon | Auto (MoR) |
| Fees (typical) | 5% + $0.50 | 0.4-0.8% + Stripe 2.9%+30¢ = ~3.3% | 5% + $0.50 |
| Webhook signature | HMAC SHA-256 | HMAC SHA-256 (Stripe-Signature) |
RSA signature |
| Dunning built-in | ✅ (4 retries / 21 days) | ✅ (configurable) | ✅ |
| Norway / EU coverage | ✅ | ✅ | ✅ |
| Best for | < 50K MRR, no tax burden | > 50K MRR, control + UX | LS alternative |
Khuyến nghị: bắt đầu LS (lowest setup cost, MoR handle VAT), migrate Stripe khi MRR > 50K USD/tháng (~12-18 tháng nếu growth trung bình).
10. Security
- Webhook signature verify bắt buộc trong adapter — invalid → save vào Inbox với
verifiedAt=null, không process - Idempotency: UNIQUE
(provider, providerEventId)ở Inbox + check trước insert vàoSubscriptionEvent - Replay attack: Inbox lưu nguyên
rawPayload+ timestamp; nếu provider gửi cùng eventId 2 lần → ignore lần 2 - Provider API keys ở
.env(LEMONSQUEEZY_API_KEY,LEMONSQUEEZY_WEBHOOK_SECRET), KHÔNG commit - Customer portal URL short-lived (≤30 min) — provider gen, không tự xây
- PII: Subscription chỉ lưu
providerCustomerId(opaque), không lưu card data — provider full PCI scope - Tenant isolation: webhook payload PHẢI chứa
tenantIdquacustom_datafield; nếu thiếu → reject + alert. KHÔNG suy luận tenant từ email - Rate limit: webhook endpoint không rate limit (provider retry), nhưng checkout/portal endpoints rate-limited per tenant
- Audit log: mọi state transition viết
SubscriptionEventrow
11. Observability
- Metrics:
subscription.webhook.received{provider, event_type}countersubscription.webhook.verified{provider, success}countersubscription.state_transition{from, to}countersubscription.payment_failed{provider, attempt}countersubscription.seat_drift{tenantId}gauge (provider vs local mismatch)
- Alerts:
- Webhook verify rate < 99% / 5 min → SEV-3
- Past-due tenants stuck > 21 ngày → SEV-3 (dunning broken)
- Seat drift > 5 tenants → SEV-2 (sync logic broken)
- Dashboards: MRR by plan, churn rate, trial → paid conversion, past-due recovery rate
12. Testing strategy
| Layer | Coverage target | Tools |
|---|---|---|
| Domain (Subscription aggregate, status machine, dunning policy) | 100% | Jest unit, no Prisma |
| Application handlers | ≥90% | Jest with in-memory repos |
| Adapter (per provider) | ≥80% | Jest + fixture webhook payloads from provider docs |
| Webhook controller | E2E | supertest + signed payloads |
| Guard | E2E | supertest with seeded tenants per state |
| Provider integration (real LS sandbox) | Manual + 1 happy path | Postman + LS test mode |
Fixtures: copy real webhook payloads từ LS dashboard → test/fixtures/lemonsqueezy/*.json (sanitize email, IDs). Adapter test parse từng file, assert normalized event đúng.
13. Roadmap (link tới progress/features.md)
| Phase | Scope | Effort |
|---|---|---|
| S1 | Schema + migrations + plan catalog | 0.5 ngày |
| S2 | BillingProviderPort + LS adapter |
1 ngày |
| S3 | Webhook controller + inbox + idempotency | 0.5 ngày |
| S4 | Domain aggregate + status machine + handlers | 1 ngày |
| S5 | BillingGuard + public booking 503 + seat limit |
0.5 ngày |
| S6 | Admin UI (/admin/billing, checkout, portal redirect, banners) |
1.5 ngày |
| S7 | Email templates (welcome, trial-ending, payment-failed, canceled) | 0.5 ngày |
| S8 | Reconciliation sweep (✅ shipped 2026-06-11 — §13a) + E2E tests + load test webhook idempotency | 0.5 ngày |
| Total MVP (LS only) | ~6 ngày | |
| V2 | Stripe adapter (provider migration) | +4 ngày |
| V3 | Usage-based add-ons (SMS overage, etc.) | +3 ngày |
13a. Reconciliation sweep — drift safety net (SHIPPED 2026-06-11)
Webhooks là đường sync chính, nhưng một delivery có thể bị miss (provider outage, downtime của mình, bug làm 500 inbox). Khi đó local read-model lệch với Polar. Ca nguy hiểm nhất: Polar đã canceled/expired mà local vẫn ACTIVE → BillingGuard tiếp tục cho ghi miễn phí (mất tiền).
Cơ chế: cron BullMQ subscription-reconcile (mặc định mỗi 6h, batch 100) poll provider cho từng subscription đã link provider, chưa terminal (providerSubId IS NOT NULL, status ∈ {ACTIVE, PAST_DUE, CANCELED}; EXPIRED là terminal, trial thuần do subscription-expiry lo).
ReconcileSubscriptionsService.reconcile()
→ repo.findReconcilable(100) (cross-tenant, oldest-synced first)
→ mỗi sub:
adapter.reconcileSubscription?(providerSubId, {tenantId, planKey})
→ Polar subscriptions.get → normalize status (ACTIVE/PAST_DUE/CANCELED/EXPIRED)
→ build DomainSubscriptionEvent (updated/canceled/payment_failed/expired)
→ drift check: provider.status == local.status && cancelAtPeriodEnd khớp → SKIP (no write)
→ khác → SyncFromWebhookHandler.execute({event}) ← CÙNG path với webhook thật
Quyết định thiết kế:
- Tái dùng
SyncFromWebhookHandler— reconcile apply event qua đúng handler webhook dùng → một code path, không logic phân nhánh, không bypass inbox (reconcile không phải delivery nên không ghi inbox). - Drift check rẻ trước khi ghi — row đã đồng bộ thì skip, không write, không spam domain event. Chỉ ghi khi thật sự lệch.
reconcileSubscriptionlà method OPTIONAL trênBillingProviderPort— adapter nào không poll được (LemonSqueezy) thì bỏ qua method → sweep skip sub của provider đó (vẫn dựa webhook). Không bắt LS phải implement.planKeylấy từ local row (context.planKey) — reconcile không bao giờ đổi plan, chỉ status + cancel-at-period-end.seatQuantity=1(flat MVP; per-seat reconcile defer cùng seat-based pricing).- Per-row isolation — một provider error / row unmappable không abort batch.
Hệ quả với drift đã phân tích: over-grant (Polar đã huỷ, local ACTIVE) → sweep set EXPIRED → guard chặn. Owner un-cancel qua portal mà miss webhook → sweep thấy Polar active → revert ACTIVE. Charge fail miss webhook → PAST_DUE.
Còn lại (chấp nhận cho MVP): (a) period/ngày drift chỉ tự sửa khi có renewal/created webhook (reconcile chỉ chỉnh status, không chỉnh period); (b) window vài giờ giữa miss-webhook và sweep kế tiếp — webhook vẫn là đường nhanh, reconcile là lưới an toàn.
Gap đã biết (hoãn): reconcile bỏ qua EXPIRED (terminal, không nằm trong findReconcilable), nên hướng lệch "local EXPIRED nhưng provider còn ACTIVE" KHÔNG được tự sửa. Chỉ xảy ra khi subscription.created (lúc trial→paid) bị miss đủ lâu để subscription-expiry sweep expire trial trước khi webhook tới. Low-prob nhờ Polar retry webhook (giờ→ngày) + inbox idempotency + retry phía mình. Lưu ý timezone KHÔNG phải nguyên nhân — trial expiry so instant UTC (trialEndsAt <= now), không dùng wall-clock; trial thuần là system-only (Polar chưa biết) nên không có state Polar để lệch; paid-EXPIRED 100% do webhook Polar đẩy. Unblock: nếu thực tế gặp tenant kẹt EXPIRED-nhưng-đã-trả, mở rộng reconcile poll thêm EXPIRED gần đây (updatedAt trong N giờ) có providerSubId → nếu provider active → tạo row mới (reactivation path, giữ invariant append-only, không mutate row terminal).
Trigger thủ công (ops/test): ReconcileSubscriptionsQueue.triggerReconcileNow(). Tests: reconcile-subscriptions.service.spec.ts (drift/in-sync/no-adapter/error-isolation) + polar.adapter.spec.ts (status→event mapping).
14. Resolved decisions (chốt 2026-05-18)
| # | Question | Decision | Lý do |
|---|---|---|---|
| OQ1 | Trial gating sau onboarding | Auto-start 14-day trial khi TenantOnboarded event fire |
Giảm friction, conversion cao hơn. Welcome screen hiển thị "14 days trial — no card required". Mặc định plan = solo_monthly, 1 seat. |
| OQ2 | Card-required-for-trial | KHÔNG yêu cầu card | Conversion cao hơn 2-3x. Email reminder ngày 11/13 + UI banner ngày 14 force checkout. |
| OQ3 | Seat overflow khi downgrade | Khoá tạo Resource mới, giữ existing | UX an toàn. KHÔNG tự deactivate. 30 ngày sau email cảnh báo nếu vẫn over. |
| OQ4 | Public marketplace khi EXPIRED | Ẩn khỏi / discovery + search, giữ /b/<slug> với 503 banner |
SEO + returning customer vẫn check được. Salon privacy: banner KHÔNG mention billing reason. |
Defer V2
- Bambora settlement / platform fee — model
PlatformFeeriêng (fee per transaction ≠ fee per period); KHÔNG nhét vào Subscription. Spec khi marketplace launch.
15. Multi-market strategy
15.1. Constraint cốt lõi
1 LS store = 1 currency. API LS không cho phép override currency theo checkout — custom_price cũng bị buộc theo store currency. Khách thanh toán card với local currency (SEK, DKK, EUR…) thì LS auto-convert qua tỷ giá runtime, nhưng invoice + accounting vẫn ở store currency. Stripe Billing thì khác: 1 account handle nhiều currency native trong cùng 1 product.
15.2. Phạm vi MVP
Norway-only, 1 LS store, currency NOK. Khách Thuỵ Điển/Đan Mạch/Phần Lan dùng được nhờ LS auto-convert card — UX chấp nhận được vì 4 đồng Bắc Âu giá trị tương đương + salon local quen NOK. Không tối ưu cho EU expansion từ đầu — defer cho V2.
15.3. Expansion paths
Khi mở thị trường thứ 2, 3 lựa chọn — quyết định theo profile thị trường + scale, KHÔNG quyết trước:
Path A — LS multi-store (incremental)
Tạo LS store mới per region (vd glamvoo-eu với currency EUR), variants tương ứng. Adapter pattern đã prep:
- Thêm field
regionvàoSubscription(enumNORTH_EU/EU/US…) BillingProviderPortkhông đổi- Adapter
LemonSqueezyAdapterresolve(provider, region) → credentials + variant IDsqua mapping config mở rộng - Env vars:
LEMONSQUEEZY_NORTH_EU_API_KEY,LEMONSQUEEZY_EU_API_KEY, … - Tenant
countryquyết định region khi onboard - Code domain: 0 thay đổi
- Effort: ~1 ngày + setup LS store mới
Khi nào hợp: ≤ 3 markets, mỗi market <30K MRR. LS fee 5% acceptable, không muốn handle VAT từng nước.
Path B — Stripe Billing migration (V2 — đã trong roadmap)
Migrate hoàn toàn sang Stripe. 1 Stripe account = multi-currency native, multi-tax native, fees thấp hơn (3% vs 5%). Flow migration theo S12 (cohort + parallel forever, không big-bang).
Khi nào hợp: ≥ 4 markets hoặc total MRR > 50K USD/tháng. LS fee bắt đầu đau, mình đủ scale để tự handle VAT MOSS/OSS.
Path C — Hybrid (mix LS + Stripe)
Norway/Bắc Âu giữ LS (MoR, đỡ phiền VAT regions phức tạp). EU mainland + UK + US → Stripe. 1 tenant thuộc đúng 1 provider, không cross. Adapter registry inject động theo subscription.provider.
Khi nào hợp: scale lệch — Norway core ổn định, expand mạnh ngoài Bắc Âu nhưng vẫn muốn giữ LS cho home market vì legacy.
15.4. Trigger matrix
| Trigger | Recommended path |
|---|---|
| Mở thêm SEK/DKK/EUR (Bắc Âu/EU) | A — LS multi-store |
| Mở UK/US | B — Stripe (LS fee tăng cao ở GBP/USD) |
| MRR > 50K USD/tháng | B — Stripe migration toàn bộ |
| LS chính sách thay đổi / acquisition | B — Stripe full migration (force) |
| Cần feature LS không có (vd: complex tax, multi-coupon stacking) | B — Stripe |
| Customer enterprise yêu cầu invoice EUR | C — Hybrid, route enterprise sang Stripe |
15.5. Code preparedness (đã có sẵn từ S1)
- ✅
BillingProviderenum trong Prisma có sẵnSTRIPE+PADDLEslot - ✅
BillingProviderPortinterface bất biến — adapter mới implement cùng contract - ✅ Provider registry inject động theo
subscription.provider - ✅ Plan catalog
planKeystable across providers — chỉ mapping table thay đổi - ✅
Subscription.metadata Jsonchứa adapter-specific snapshot - ✅ Provider stubs
stripe/+paddle/đã có README placeholder, không xoá
15.6. KHÔNG làm
- Hardcode currency trong domain: ❌. Plan catalog
currencyfield là canonical, mọi UI/email đọc từ đó - Multi-currency trong 1 plan record: ❌. Mỗi tenant gắn 1 plan + 1 provider + 1 region — không cross
- Big-bang switch provider: ❌. Đã có policy parallel forever trong §8
16. Liên kết
docs/flows/subscription-flow.md— 12 scenarios end-to-enddocs/architecture/payment-architecture.md— Payment Context (deposit, Bambora) — KHÔNG nhầm với Subscriptiondocs/progress/features.md— Status checklist từng phasedocs/rules/development-rules.md— Conventions chung