Tách catalog, commerce và access
Billing dễ bảo trì hơn khi ba mối quan tâm không bị gộp chung:
- Catalog: Product, ProductPrice và Benefit mô tả thứ được bán.
- Commerce: Checkout, Order, Payment và Transaction mô tả việc thu tiền.
- Access: Subscription và BenefitGrant mô tả thời gian và quyền sử dụng.
Product 1─* ProductPrice
Product *─* Benefit
Customer 1─* Checkout 1─0..1 Order 1─* Payment
Customer 1─* Subscription 1─* BenefitGrant
Subscription *─1 Product
ProductPrice phải bất biến theo lịch sử
Khi thay giá 99.000đ thành 149.000đ, đừng update row giá cũ. Archive giá đó và tạo ProductPrice mới. Order và Subscription giữ snapshot hoặc tham chiếu tới version đã mua, nên báo cáo lịch sử không đổi theo catalog hiện tại.
type ProductPrice = {
id: string
productId: string
amount: number
currency: "VND"
interval: "month" | "year"
archivedAt: string | null
}
Amount nên là integer VND, không dùng floating point. Currency và interval là dữ liệu bắt buộc dù hiện tại chỉ hỗ trợ VND; điều này làm invariant rõ hơn.
Checkout không phải Payment
Checkout là orchestration record: idempotency key, provider reference, redirect URLs và state tạo link. Payment là kết quả tiền tệ có thể được ghi nhận sau. Tách hai thực thể giúp biểu diễn timeout sau khi provider đã tạo link nhưng ứng dụng chưa nhận response.
Order lưu snapshot sản phẩm, giá và period được mua. Transaction là sổ ghi bất biến của thay đổi tiền; đừng sửa transaction cũ để phản ánh trạng thái mới.
Subscription là state machine
Một enum rõ ràng tốt hơn nhiều boolean:
incomplete → active → past_due → canceled
↘ active after renewal
active + cancel_at_period_end → canceled at period end
Mọi transition phải đi qua billing engine, kiểm tra state hiện tại và chạy trong transaction. Controller, webhook và cron không nên tự update status riêng.
Benefit và entitlement
Product có thể gắn nhiều Benefit như feature access hoặc customer limit. Khi subscription active, hệ thống materialize BenefitGrant với thời gian hiệu lực. Ứng dụng của merchant đọc grant hoặc đồng bộ nó qua signed webhook.
Tách Benefit khỏi Subscription giúp thay cấu trúc plan mà không nhét từng cột feature vào subscription. Đồng thời, quyền truy cập không phụ thuộc trực tiếp vào row payment cuối cùng.
Tenant boundary và khóa
Mọi business entity phải thuộc đúng Organization. Public API key xác định Organization; URL public không nhận organization ID từ client. Unique constraint nên bao gồm tenant khi identifier chỉ unique trong tenant.
Các khóa quan trọng:
(organization_id, external_customer_id)cho Customer mapping;(organization_id, idempotency_key, operation)cho retry;- provider payment reference unique toàn hệ thống hoặc theo credential scope;
(subscription_id, period_start)cho renewal order.
Migration và kiểm thử
Thêm constraint sau khi đã query dữ liệu trùng và có kế hoạch backfill. Test ít nhất: thay giá không sửa lịch sử, retry không nhân bản, hai tenant không đọc chéo, payment muộn không revive subscription sai và grant bị revoke đúng lúc.