mirror of
https://github.com/hanzoai/commerce.git
synced 2026-11-02 21:25:16 +00:00
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
479 lines
18 KiB
Go
479 lines
18 KiB
Go
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)
|
||
}
|
||
}
|