Files
commerce/billing/engine/lifecycle.go
zeekay 1943f1c093 Hanzo Commerce
Multi-tenant commerce and billing: orders, subscriptions, invoices, plans,
usage metering and a credit ledger. One Go binary. MIT OR Apache-2.0.

Claude-Session: https://claude.ai/code/session_01SAtaDFm8VDiWrhYHNNtxMw
2026-09-23 21:52:16 -07:00

479 lines
18 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package engine
import (
"context"
"fmt"
"hash/fnv"
"strconv"
"sync"
"time"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/models/billinginvoice"
"github.com/hanzoai/commerce/models/idempotencykey"
"github.com/hanzoai/commerce/models/plan"
"github.com/hanzoai/commerce/models/subscription"
"github.com/hanzoai/commerce/models/types/currency"
"github.com/hanzoai/commerce/types"
"github.com/hanzoai/money"
)
// periodLockStripes bounds the memory of the per-(subscription, period) renewal
// lock to a fixed set of mutexes (no unbounded per-period growth). Same key → same
// stripe → serialized; a rare hash collision only briefly serializes two unrelated
// renewals, which is harmless.
const periodLockStripes = 256
var periodLockMu [periodLockStripes]sync.Mutex
// lockPeriod serializes concurrent collection of the SAME (subscription, period)
// WITHIN a process, returning the unlock func. Commerce is single-writer per tenant
// (ReadWriteOnce PVC, Recreate), so this fully serializes real concurrent renewals
// (the cron sweep + a manual renew) — one invoice row, one collection — closing the
// non-atomic findInvoiceForPeriod → buildPeriodInvoice window. The idempotencykey
// guard + the per-(sub, period, attempt) gateway idempotency key remain the
// money backstops (a cross-process racer still cannot double-charge).
func lockPeriod(sub *subscription.Subscription) func() {
key := sub.Id() + "|" + strconv.FormatInt(sub.PeriodStart.Unix(), 10)
h := fnv.New32a()
_, _ = h.Write([]byte(key))
mu := &periodLockMu[h.Sum32()%periodLockStripes]
mu.Lock()
return mu.Unlock
}
// StartSubscription initializes a new subscription: sets the initial state,
// computes period dates, and handles trial logic.
func StartSubscription(sub *subscription.Subscription, p *plan.Plan) {
now := time.Now()
sub.Plan = *p
sub.PlanId = p.Id()
sub.Start = now
if p.TrialPeriodDays > 0 {
sub.Status = subscription.Trialing
sub.TrialStart = now
sub.TrialEnd = now.AddDate(0, 0, p.TrialPeriodDays)
sub.PeriodStart = sub.TrialEnd
sub.PeriodEnd = advancePeriod(sub.TrialEnd, p)
} else {
sub.Status = subscription.Active
sub.PeriodStart = now
sub.PeriodEnd = advancePeriod(now, p)
}
}
// RenewSubscription generates an invoice for the current billing period
// and attempts to collect payment. Returns the invoice and collection result.
//
// It is idempotent per (subscription, period): a PastDue renewal re-runs the
// SAME period (the period only advances on a successful collection), so this
// must NEVER mint a second invoice for a period already invoiced. If an
// invoice for this exact period already exists it is returned as-is (no
// duplicate, no re-charge); retrying collection on an unpaid invoice is the
// dunning workflow's job (billing/workflows/dunning.go), not this generator's.
func RenewSubscription(ctx context.Context, db *datastore.Datastore, sub *subscription.Subscription, burnCredits CreditBurner, chargeProvider ProviderCharger) (*billinginvoice.BillingInvoice, *CollectionResult, error) {
// Serialize concurrent collection of this (subscription, period) in-process so
// exactly ONE invoice row is built + collected for the period (books integrity).
defer lockPeriod(sub)()
// Idempotency (period): if this period already has an invoice, return it as-is
// and NEVER re-charge here. Dunning retries collection via PayInvoice, not this
// generator, so a repeated renew on an already-invoiced period is a no-op.
existing, err := findInvoiceForPeriod(db, sub)
if err != nil {
return nil, nil, fmt.Errorf("failed to look up existing invoice for period: %w", err)
}
if existing != nil {
return existing, resultFromInvoice(existing), nil
}
// Only CREATE (and charge) a new period invoice when the period is actually due
// (its end has passed) or the subscription is already PastDue. This stops a
// MANUAL renew from pre-billing a not-yet-elapsed FUTURE period — N manual
// renews must never bill N periods. The cycle already filters on IsDue; this is
// the authoritative gate for every caller.
if !IsDue(sub, time.Now()) {
return nil, &CollectionResult{Error: "subscription period is not due for renewal"}, nil
}
// Atomic per-(subscription, period) guard: two concurrent renews collapse onto
// ONE deterministic-id row (storage ON CONFLICT), so only the winner builds +
// collects; the loser re-reads the now-existing invoice. The card charge (via
// chargeProvider) ALSO carries a stable per-period Square idempotency key, so
// even the narrow concurrent-first window can never double-charge.
guardKey := "period:" + strconv.FormatInt(sub.PeriodStart.Unix(), 10)
rec, replay, gerr := idempotencykey.Begin(db, "billing-renew:"+sub.Id(), guardKey)
if gerr == nil && replay {
if again, e := findInvoiceForPeriod(db, sub); e == nil && again != nil {
return again, resultFromInvoice(again), nil
}
// Concurrent in-flight; its invoice is not yet visible. Do NOT run a second
// collection alongside it.
return nil, &CollectionResult{Error: "renewal already in progress for this period"}, nil
}
// Re-check after winning the guard: a racer in the concurrent-first window may
// have persisted this period's invoice between our findInvoiceForPeriod above and
// here. If so, use it — never build a second row for the same period.
if again, e := findInvoiceForPeriod(db, sub); e == nil && again != nil {
if rec != nil {
_ = idempotencykey.Complete(rec, again.Id())
}
return again, resultFromInvoice(again), nil
}
// Generate a fresh, sequentially-numbered invoice for this period.
inv, err := buildPeriodInvoice(db, sub)
if err != nil {
if rec != nil {
_ = rec.Delete() // release the guard so a later attempt can rebuild
}
return inv, nil, err
}
// Attempt collection: credits -> balance -> the vaulted card (chargeProvider).
result, err := CollectInvoice(ctx, db, inv, burnCredits, chargeProvider)
if err != nil {
return inv, result, fmt.Errorf("collection error: %w", err)
}
// Update invoice after collection
if err := inv.Update(); err != nil {
return inv, result, fmt.Errorf("failed to update invoice: %w", err)
}
// Update subscription period
if result.Success {
sub.CurrentInvoiceId = inv.Id()
sub.PeriodStart = sub.PeriodEnd
sub.PeriodEnd = advancePeriod(sub.PeriodEnd, &sub.Plan)
} else {
sub.Status = subscription.PastDue
}
// The invoice now exists, so findInvoiceForPeriod short-circuits every future
// renew BEFORE this guard — seal it (best-effort; it has done its job).
if rec != nil {
_ = idempotencykey.Complete(rec, inv.Id())
}
return inv, result, nil
}
// IsDue reports whether a subscription's current period has elapsed and it is
// eligible to be (re)invoiced: Active or PastDue with a PeriodEnd in the past.
// The single definition of "due", shared by the billing-cycle filter and
// RenewSubscription's new-period gate.
//
// An externally collected subscription is never due. Its customer pays the
// processor directly, so an invoice built here would bill a period nobody owes us
// for, and collecting it would burn their credits, then their balance, then a card.
// Its next period is recorded when that payment arrives, not renewed.
func IsDue(sub *subscription.Subscription, now time.Time) bool {
if sub.Type == subscription.External {
return false
}
switch sub.Status {
case subscription.Active, subscription.PastDue:
return !sub.PeriodEnd.IsZero() && now.After(sub.PeriodEnd)
default:
return false
}
}
// CreatePaidFirstInvoice builds the subscription's FIRST-period invoice, marks it
// PAID by an already-settled external charge (method + providerRef), persists it,
// and advances the subscription to the next period. It is the upfront-collection
// analogue of RenewSubscription's success path: the caller (subscribe/card) has
// already charged the customer's card for this period synchronously, so this only
// records the invoice as paid — it does NOT charge again. Idempotent per period:
// if an invoice for the current period already exists it is returned as-is (no
// duplicate, no state change), so a retried subscribe never double-invoices.
func CreatePaidFirstInvoice(db *datastore.Datastore, sub *subscription.Subscription, method, providerRef string) (*billinginvoice.BillingInvoice, error) {
if existing, err := findInvoiceForPeriod(db, sub); err != nil {
return nil, fmt.Errorf("failed to look up existing invoice for period: %w", err)
} else if existing != nil {
return existing, nil
}
inv, err := buildPeriodInvoice(db, sub)
if err != nil {
return inv, err
}
if err := inv.MarkPaid(method, providerRef); err != nil {
return inv, fmt.Errorf("failed to mark first invoice paid: %w", err)
}
if err := inv.Update(); err != nil {
return inv, fmt.Errorf("failed to persist paid first invoice: %w", err)
}
// The first period is prepaid, so the next charge falls at the start of
// period 2 — advance exactly as RenewSubscription does on a successful collect.
sub.CurrentInvoiceId = inv.Id()
sub.PeriodStart = sub.PeriodEnd
sub.PeriodEnd = advancePeriod(sub.PeriodEnd, &sub.Plan)
return inv, nil
}
// buildPeriodInvoice constructs, numbers, finalizes and persists a new invoice
// for the subscription's current period. The invoice number is a sequential
// per-org counter, mirroring the credit-note numbering in refunds.go.
func buildPeriodInvoice(db *datastore.Datastore, sub *subscription.Subscription) (*billinginvoice.BillingInvoice, error) {
inv := billinginvoice.New(db)
inv.UserId = sub.UserId
inv.SubscriptionId = sub.Id()
inv.PeriodStart = sub.PeriodStart
inv.PeriodEnd = sub.PeriodEnd
inv.Currency = sub.Plan.Currency
// Add subscription line item: plan fee × billable seats (1 for flat plans).
if sub.Plan.Price > 0 {
n := seats(&sub.Plan, sub.Quantity)
inv.LineItems = append(inv.LineItems, billinginvoice.LineItem{
Id: "li_plan_" + sub.PlanId,
Type: billinginvoice.LineSubscription,
Description: sub.Plan.Name + " subscription",
PlanId: sub.PlanId,
PlanName: sub.Plan.Name,
Quantity: n,
UnitPrice: int64(sub.Plan.Price),
Amount: int64(sub.Plan.Price) * n,
Currency: sub.Plan.Currency,
PeriodStart: sub.PeriodStart,
PeriodEnd: sub.PeriodEnd,
})
}
// Add usage line items (non-fatal: an aggregation error yields no usage).
if usageItems, _, err := AggregateUsage(db, sub.UserId, sub.PeriodStart, sub.PeriodEnd); err == nil {
inv.LineItems = append(inv.LineItems, usageItems...)
}
// Calculate totals
inv.RecalculateSubtotal()
// The promo the subscription was bought under, priced off the PLAN fee only —
// metered usage above is real consumption and is never discounted by a plan
// promo. Finalize() folds Discount into AmountDue, so this must land before it.
if inv.Subtotal > 0 && sub.DiscountPercent > 0 {
planFee := int64(0)
for _, li := range inv.LineItems {
if li.Type == billinginvoice.LineSubscription {
planFee += li.Amount
}
}
if d := DiscountCents(planFee, sub.DiscountPercent); d > 0 {
inv.Discount = d
inv.DiscountName = sub.DiscountName
}
}
// Assign a sequential per-org invoice number BEFORE persisting.
assignInvoiceNumber(db, inv)
// Finalize (draft -> open)
if err := inv.Finalize(); err != nil {
return inv, fmt.Errorf("failed to finalize invoice: %w", err)
}
// Persist invoice
if err := inv.Create(); err != nil {
return inv, fmt.Errorf("failed to create invoice: %w", err)
}
return inv, nil
}
// findInvoiceForPeriod returns the existing invoice for this subscription's
// exact billing period, or nil if none exists. Periods are months/years apart,
// so PeriodStart/PeriodEnd are matched at second precision to be robust against
// sub-second serialization differences across the storage round-trip.
func findInvoiceForPeriod(db *datastore.Datastore, sub *subscription.Subscription) (*billinginvoice.BillingInvoice, error) {
rootKey := db.NewKey("synckey", "", 1, nil)
existing := make([]*billinginvoice.BillingInvoice, 0)
q := billinginvoice.Query(db).Ancestor(rootKey).
Filter("SubscriptionId=", sub.Id()).
Filter("UserId=", sub.UserId)
if _, err := q.GetAll(&existing); err != nil {
return nil, err
}
for _, inv := range existing {
if inv.PeriodStart.Unix() == sub.PeriodStart.Unix() &&
inv.PeriodEnd.Unix() == sub.PeriodEnd.Unix() {
return inv, nil
}
}
return nil, nil
}
// assignInvoiceNumber sets a sequential per-org invoice number = (count of
// existing billing invoices) + 1. Mirrors the credit-note numbering pattern in
// refunds.go; if the count query fails it falls back to 1.
func assignInvoiceNumber(db *datastore.Datastore, inv *billinginvoice.BillingInvoice) {
rootKey := db.NewKey("synckey", "", 1, nil)
existing := make([]*billinginvoice.BillingInvoice, 0)
if _, err := billinginvoice.Query(db).Ancestor(rootKey).GetAll(&existing); err == nil {
inv.SetNumber(len(existing) + 1)
} else {
inv.SetNumber(1)
}
}
// resultFromInvoice synthesizes a collection result from an invoice's persisted
// state — used when RenewSubscription returns an already-generated invoice so
// callers (e.g. the billing cycle) get a non-nil result reflecting whether the
// period is settled.
func resultFromInvoice(inv *billinginvoice.BillingInvoice) *CollectionResult {
return &CollectionResult{
Success: inv.Status == billinginvoice.Paid,
CreditUsed: inv.CreditApplied,
AmountCharged: inv.AmountPaid,
}
}
// TransitionTrialToActive moves a trialing subscription to active.
func TransitionTrialToActive(sub *subscription.Subscription) error {
if sub.Status != subscription.Trialing {
return fmt.Errorf("subscription is not trialing, current status: %s", sub.Status)
}
sub.Status = subscription.Active
return nil
}
// CancelSubscription cancels a subscription, either immediately or at period end.
func CancelSubscription(sub *subscription.Subscription, atPeriodEnd bool) error {
if sub.Status == subscription.Canceled {
return fmt.Errorf("subscription is already canceled")
}
now := time.Now()
if atPeriodEnd {
sub.EndCancel = true
sub.CanceledAt = now
} else {
sub.Status = subscription.Canceled
sub.Canceled = true
sub.CanceledAt = now
sub.Ended = now
}
return nil
}
// ReactivateSubscription reverses a pending cancellation.
func ReactivateSubscription(sub *subscription.Subscription) error {
if sub.Status == subscription.Canceled && !sub.Ended.IsZero() {
return fmt.Errorf("cannot reactivate a fully ended subscription")
}
sub.EndCancel = false
sub.Canceled = false
sub.CanceledAt = time.Time{}
if sub.Status == subscription.Canceled {
sub.Status = subscription.Active
}
return nil
}
// ChangePlan updates a subscription to a new plan. If prorate is true,
// a proration line item will be added to the current period's invoice.
func ChangePlan(sub *subscription.Subscription, newPlan *plan.Plan, prorate bool) (*billinginvoice.LineItem, error) {
oldPlan := sub.Plan
sub.Plan = *newPlan
sub.PlanId = newPlan.Id()
if !prorate {
return nil, nil
}
// Calculate proration
now := time.Now()
totalDays := sub.PeriodEnd.Sub(sub.PeriodStart).Hours() / 24
remainingDays := sub.PeriodEnd.Sub(now).Hours() / 24
if totalDays <= 0 {
return nil, nil
}
fraction := remainingDays / totalDays
// Credit for unused portion of old plan (× its billable seats)
oldCredit, err := proration(oldPlan.Price*currency.Cents(seats(&oldPlan, sub.Quantity)), fraction)
if err != nil {
return nil, err
}
// Charge for remaining portion of new plan (× its billable seats)
newCharge, err := proration(newPlan.Price*currency.Cents(seats(newPlan, sub.Quantity)), fraction)
if err != nil {
return nil, err
}
net := newCharge - oldCredit
item := &billinginvoice.LineItem{
Id: fmt.Sprintf("li_proration_%d", now.Unix()),
Type: billinginvoice.LineProration,
Description: fmt.Sprintf("Proration: %s -> %s", oldPlan.Name, newPlan.Name),
PlanId: newPlan.Id(),
PlanName: newPlan.Name,
Amount: net,
Currency: newPlan.Currency,
PeriodStart: now,
PeriodEnd: sub.PeriodEnd,
}
return item, nil
}
// seats returns the billable multiplier for a plan on a subscription: the
// subscription quantity (floored at 1) when the plan bills per seat, else 1.
// proration returns the part of amount that fraction covers, rounded half away from zero.
//
// It is one function because a plan change computes two of these and subtracts them, and
// they have to round the same way. The spelling it replaces truncated both — the credit for
// the unused old plan, which is a cent the customer does not get back, and the charge for
// the new one, which is a cent we do not bill. Neither direction was chosen by anyone; they
// were just what int64() does to a fraction. Half away from zero is the rule the discount
// and coupon paths already use, so a plan change cannot disagree with a coupon about what a
// fraction of a cent is worth, and a downgrade credit is the upgrade charge reversed.
//
// A fraction that is not a number is REPORTED rather than converted: a period whose start
// and end are the same instant makes remaining/total a NaN, and NaN to int64 is undefined
// in Go. A proration line for an undefined number of cents is worse than no line at all.
func proration(amount currency.Cents, fraction float64) (int64, error) {
rate, err := money.RateFromFloat(fraction)
if err != nil {
return 0, err
}
return money.ScaleMinor(int64(amount), rate)
}
func seats(p *plan.Plan, quantity int) int64 {
if !p.PerSeat || quantity < 1 {
return 1
}
return int64(quantity)
}
// advancePeriod computes the next period end date based on the plan interval.
func advancePeriod(from time.Time, p *plan.Plan) time.Time {
count := p.IntervalCount
if count <= 0 {
count = 1
}
switch p.Interval {
case types.Monthly:
return from.AddDate(0, count, 0)
case types.Yearly:
return from.AddDate(count, 0, 0)
default:
// Default to monthly
return from.AddDate(0, count, 0)
}
}