Gói tháng/năm, tự gia hạn, hết hạn, nâng/hạ góiMonthly/yearly plans, auto-renewal, expiry, up/downgrades
Renewal Scheduler định kỳ nhặt các thuê bao đến hạn bằng SKIP LOCKED và giao cho Billing Service tạo hoá đơn, gọi cổng thanh toán với idempotency key theo hoá đơn. Lần trừ tiền thất bại chuyển sang Dunning Worker. Chọn “Thẻ bị từ chối” để xem lịch thử lại và lúc thuê bao hết hạn.The renewal scheduler periodically claims due subscriptions with SKIP LOCKED and hands them to the billing service, which creates the invoice and calls the payment gateway with a per-invoice idempotency key. Failed charges move to the dunning worker. Choose “Card declined” to see the retry schedule and the point where the subscription expires.
1Đến 00:01 UTC, scheduler tìm các thuê bao ACTIVE có current_period_end đã qua, lấy 100 dòng và khoá bằng SKIP LOCKED.At 00:01 UTC the scheduler looks for ACTIVE subscriptions whose current_period_end has passed, takes 100 rows and locks them with SKIP LOCKED.
Dùng State pattern với các chuyển trạng thái hợp lệ: TRIALING → ACTIVE → PAST_DUE → ACTIVE khi trả được, hoặc EXPIRED khi hết ân hạn; ACTIVE → CANCELED khi huỷ có hiệu lực cuối kỳ. Mọi chuyển khác bị từ chối và mỗi lần chuyển được ghi lịch sử.Use the State pattern with explicit transitions: TRIALING → ACTIVE → PAST_DUE → back to ACTIVE when paid, or EXPIRED when the grace period ends; ACTIVE → CANCELED when a cancellation takes effect at period end. Any other transition is rejected and every transition is logged.
Scheduler lấy việc bằng SELECT … FOR UPDATE SKIP LOCKED theo lô nhỏ, nên mỗi instance nhận các dòng khác nhau. Thêm unique(subscription_id, period_start) cho hoá đơn để chạy lại vẫn không sinh hoá đơn thứ hai.The scheduler claims work with SELECT … FOR UPDATE SKIP LOCKED in small batches, so each instance gets different rows. unique(subscription_id, period_start) on invoices means a rerun never creates a second invoice.
Gọi cổng thanh toán với idempotency key gắn với hoá đơn và lần thử. Timeout thì gọi lại với cùng key; cổng trả kết quả cũ. Ghi payment_attempt trước khi gọi để đối soát được khi service chết giữa chừng.Call the gateway with an idempotency key tied to the invoice and the attempt. On a timeout, call again with the same key and the gateway replays the original result. Write a payment_attempt row before calling so a crash mid-call can be reconciled.
Dunning thử lại theo lịch ngày 1, 3, 7 thay vì liên tục, vì ngân hàng thường từ chối lặp lại trong cùng ngày. Trong thời gian ân hạn khách vẫn dùng được dịch vụ; hết lịch thì EXPIRED. Lỗi cứng như thẻ bị báo mất thì dừng ngay, không thử lại.Dunning retries on day 1, 3 and 7 rather than continuously, because banks tend to decline again on the same day. The customer keeps access during the grace period; once the schedule runs out the subscription expires. Hard declines such as a lost card stop immediately with no retry.
Nâng gói có hiệu lực ngay, tính phần chênh theo số ngày còn lại. Hạ gói lưu pending_plan_id và áp dụng khi gia hạn, tránh phải hoàn tiền. Mỗi chính sách giá là một Strategy: tính theo ngày, theo giây hoặc không tính tỷ lệ.Upgrades take effect now and charge the difference for the remaining days. Downgrades store pending_plan_id and apply at renewal, which avoids refunds. Each proration policy is a Strategy: per day, per second, or none.
Lưu ngày neo (billing_anchor) và tính kỳ thứ n từ neo, không cộng dồn từ kỳ trước: neo 31/1 cho ra 28/2 (29/2 năm nhuận), rồi 31/3. Cộng dồn sẽ trôi về ngày 28 mãi mãi. Lưu thời điểm ở UTC, tính ranh giới ngày theo múi giờ của khách.Store a billing_anchor and compute period n from it instead of adding a month to the previous period: an anchor of 31 Jan gives 28 Feb (29 in a leap year), then 31 Mar. Chaining from the previous period drifts to the 28th forever. Store instants in UTC and compute day boundaries in the customer's time zone.
| BảngTable | Cột chínhKey columns | Ghi chúNotes |
|---|---|---|
plan | id, code, interval (MONTH|YEAR), price_minor, currency, trial_days | Giá theo đơn vị nhỏ nhất của tiền tệ, không dùng số thựcPrices in minor units, never floating point |
subscription | id, customer_id, plan_id, pending_plan_id, status, billing_anchor, current_period_start, current_period_end, cancel_at_period_end, grace_until, timezone, version | TRIALING → ACTIVE → PAST_DUE → ACTIVE · EXPIRED; ACTIVE → CANCELED; index(status, current_period_end)TRIALING → ACTIVE → PAST_DUE → ACTIVE · EXPIRED; ACTIVE → CANCELED; index(status, current_period_end) |
invoice | id, subscription_id, kind (RENEWAL|PRORATION), period_start, period_end, amount_minor, status, next_retry_at, attempt_count | unique(subscription_id, period_start, kind); OPEN → PAID · UNCOLLECTIBLE · VOIDunique(subscription_id, period_start, kind); OPEN → PAID · UNCOLLECTIBLE · VOID |
payment_attempt | id, invoice_id, attempt_no, idempotency_key, gateway_charge_id, result, decline_code, created_at | unique(idempotency_key); ghi trước khi gọi cổng thanh toánunique(idempotency_key); written before calling the gateway |
Java: job gia hạn nhặt thuê bao đến hạn bằng SKIP LOCKED và trừ tiền theo hoá đơn. Go: tính kỳ từ ngày neo và tính tiền chênh khi nâng gói.Java: the renewal job claims due subscriptions with SKIP LOCKED and charges per invoice. Go: computes periods from the anchor date and the prorated amount for an upgrade.
@Component
@RequiredArgsConstructor
public class RenewalJob {
private final SubscriptionRepository subs;
private final InvoiceService invoices;
private final PaymentGateway gateway;
private final Clock clock;
@Scheduled(fixedDelay = 60_000)
@Transactional
public void renewDue() {
List<Subscription> due = subs.lockDue(clock.instant(), 20);
for (Subscription s : due) {
Invoice inv = invoices.openFor(s, s.nextPeriod());
ChargeResult r = gateway.charge(
s.paymentMethodId(), inv.amountMinor(),
inv.idempotencyKey()); // "inv-7720-a1"
if (r.succeeded()) {
inv.markPaid(r.chargeId());
s.renew(); // state: ACTIVE, next period
} else if (r.isHardDecline()) {
s.expire(); // lost or stolen card
} else {
inv.scheduleRetry(DunningPlan.DAYS_1_3_7);
s.markPastDue(clock.instant().plus(Duration.ofDays(7)));
}
}
}
}
interface SubscriptionRepository extends JpaRepository<Subscription, Long> {
@Query(value = """
SELECT * FROM subscription
WHERE status = 'ACTIVE' AND current_period_end <= :now
ORDER BY current_period_end
LIMIT :limit
FOR UPDATE SKIP LOCKED""", nativeQuery = true)
List<Subscription> lockDue(@Param("now") Instant now, @Param("limit") int limit);
}// PeriodStart returns the start of period n, counted from the anchor.
// Jan 31 → Feb 28 (29 in leap years) → Mar 31: never drifts to the 28th.
func PeriodStart(anchor time.Time, n int, loc *time.Location) time.Time {
a := anchor.In(loc)
first := time.Date(a.Year(), a.Month()+time.Month(n), 1,
a.Hour(), a.Minute(), a.Second(), 0, loc) // month overflow normalised
lastDay := first.AddDate(0, 1, -1).Day()
return time.Date(first.Year(), first.Month(), min(a.Day(), lastDay),
a.Hour(), a.Minute(), a.Second(), 0, loc)
}
// ProrateUpgrade returns the amount to charge now, in minor units.
func ProrateUpgrade(oldPrice, newPrice int64, now, start, end time.Time) int64 {
if newPrice <= oldPrice || !now.Before(end) {
return 0 // downgrades wait for the period end
}
total := int64(end.Sub(start) / time.Second)
left := int64(end.Sub(now) / time.Second)
return (newPrice - oldPrice) * left / total
}
func (s *BillingService) Upgrade(ctx context.Context, subID int64, to Plan) error {
return s.tx.Do(ctx, func(tx Tx) error {
sub, err := tx.LockSubscription(ctx, subID) // FOR UPDATE
if err != nil {
return err
}
amount := ProrateUpgrade(sub.Plan.PriceMinor, to.PriceMinor,
s.clock.Now(), sub.PeriodStart, sub.PeriodEnd)
inv, err := tx.CreateInvoice(ctx, Invoice{
SubscriptionID: sub.ID, Kind: KindProration,
PeriodStart: sub.PeriodStart, AmountMinor: amount,
})
if err != nil {
return err
}
if err := tx.SetPlan(ctx, sub.ID, to.ID); err != nil {
return err
}
return s.outbox.Add(ctx, tx, ChargeInvoice{InvoiceID: inv.ID})
})
}| Tự xâyBuild it | Stripe Billing / ChargebeeStripe Billing / Chargebee | |
|---|---|---|
| Kiểm soátControl | Toàn quyền luật giá, proration, dunningFull control over pricing, proration and dunning | Theo mô hình của nhà cung cấp, tuỳ biến qua cấu hìnhFollows the vendor's model, customised through configuration |
| Công sứcEffort | Phải tự lo thuế, hoá đơn, đối soát, các ca biênYou own tax, invoices, reconciliation and edge cases | Có sẵn, chỉ cần đồng bộ qua webhookBuilt in; you sync state through webhooks |
| Rủi roRisk | Lỗi tính tiền do chính mình gây raBilling bugs are your own | Phụ thuộc nhà cung cấp, webhook đến trễ hoặc trùngVendor lock-in, late or duplicate webhooks |
| Chọn khiPick it when | Cổng thanh toán nội địa, luật giá đặc thùLocal payment gateways, unusual pricing rules | Cần ra mắt nhanh với thẻ quốc tếYou need to launch fast with international cards |
Mỗi pattern mở bài học tương ứng trong menu Pattern.Each one opens its lesson in the Patterns menu.
Hai lớp. Lấy việc bằng FOR UPDATE SKIP LOCKED để các instance nhận dòng khác nhau. Mỗi hoá đơn là duy nhất theo (subscription_id, period_start) và gọi cổng thanh toán với idempotency key theo hoá đơn, nên kể cả khi job chạy lại cũng chỉ trừ một lần.Two layers. Claim rows with FOR UPDATE SKIP LOCKED so instances get different rows. Each invoice is unique per (subscription_id, period_start) and the gateway is called with a per-invoice idempotency key, so even a rerun charges once.
28/2, hoặc 29/2 năm nhuận, rồi 31/3. Phải tính từ ngày neo chứ không cộng tháng từ kỳ trước, nếu không ngày gia hạn sẽ trôi về 28 mãi mãi. Ranh giới ngày tính theo múi giờ của khách.28 Feb, or 29 Feb in a leap year, then 31 Mar. Compute from the anchor rather than adding a month to the previous period, or the renewal day drifts to the 28th forever. Day boundaries follow the customer's time zone.
Nâng gói thì khách trả thêm và muốn dùng ngay, tính tiền chênh theo ngày còn lại là đủ. Hạ gói ngay thì phải hoàn tiền hoặc ghi có, phức tạp hơn và dễ bị lạm dụng (nâng lên dùng vài ngày rồi hạ xuống).An upgrade means paying more and wanting it now; charging the difference for the remaining days is enough. An immediate downgrade needs a refund or a credit, which is more complex and easy to abuse (upgrade for a few days, then downgrade).
Không. Ngân hàng thường từ chối lặp lại trong ngày và có thể đánh dấu là hành vi bất thường. Thử lại theo lịch giãn cách (ngày 1, 3, 7), báo khách cập nhật thẻ, và dừng ngay với lỗi cứng như thẻ bị mất.No. Banks tend to decline again the same day and may flag the pattern. Retry on a spaced schedule (day 1, 3, 7), ask the customer to update the card, and stop at once on hard declines such as a lost card.
Phím 1–4 để chọn đáp án, Enter để kiểm tra.Press 1–4 to choose an answer, Enter to check.
Ôn tập ngắt quãng (spaced repetition): thẻ được lặp lại sau 1 → 3 → 7 → 14 → 30 ngày để củng cố trí nhớ dài hạn.Spaced repetition: cards are repeated after 1 → 3 → 7 → 14 → 30 days to build long-term memory.