Files
zeekayandz cc9b2bc77e billing: a plan coupon discounts the months it names, not the first alone
A plan coupon carries Months (1..36; 0 reads as 1, the first month).
The sale grants the subscription the coupon from the period it sells
(on a trial, the first paid period after it). An invoice, the first
included, takes the percent off the plan fee while fewer than Months
billing cycles were invoiced since then, the row bills monthly, and the
coupon names its plan and every seat type sold.

A cycle is an invoice: engine.Billed counts the distinct plan-fee
periods invoiced since the first paid one, voided ones aside. Nothing
counts down, so a renewal re-run or a row not saved after its invoice
cannot spend a month twice, and days a gift adds or a late payment
that moves the period spend none.

50OFFBETA at three months bills Pro 1000, 1000, 1000, then 2000. A
renewal reads "50OFFBETA — 50% off, month 2 of 3" and records the
coupon, percent, cents and month on its invoice as the first month
does; the ledger draws the discounted amount. A year is never
discounted, and a plan the coupon does not name pays its price.

The plans quote and the sale receipt carry couponMonths. A coupon with
a trial now waits it out instead of being refused.

The staff renewal coupon stays its own fact: it is for the next
renewal whichever period that bills, monthly or yearly, and one row
can hold both, so it stacks off what the plan coupon left. Its invoice
metadata moves to renewalCoupon* so the two never share a key.

ExtendCoupon grants a coupon's current months to every live
subscription already bought with it, from its first paid period. It
never takes a month away, writes by Swap, reports cycles billed and
missed, dry-runs unless asked to apply, and records
subscription.coupon_extended in each org it changes.

Claude-Session: https://claude.ai/code/session_01BLLMyBFSvAfCt7ZGqYuiEz
2026-10-22 10:48:56 -07:00

352 lines
13 KiB
Go

package billing
// prepaid.go — THE ONE WAY a subject's prepaid money is read and spent.
//
// Prepaid money is two things, drawn in one order: the subject's credit grants
// first, then its balance. The balance lives on the one ledger the host keeps —
// the ledger injected through creditledger when commerce is embedded (the same
// account an admin grant credits, the balance endpoint reads and the AI gate
// debits) — and on commerce's own "iam-user" transactions when it runs alone,
// which is where every deposit, top-up and credit it takes itself lands.
//
// A sale paid from the balance, a renewal the engine collects and the tier's
// balance all go through here, so none of them can read one ledger while another
// writes a second. The engine reaches it as an engine.Prepaid, bound to one org.
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/hanzoai/commerce/billing/creditledger"
"github.com/hanzoai/commerce/billing/engine"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/log"
"github.com/hanzoai/commerce/mintauth"
"github.com/hanzoai/commerce/models/billinginvoice"
"github.com/hanzoai/commerce/models/idempotencykey"
"github.com/hanzoai/commerce/models/organization"
"github.com/hanzoai/commerce/models/plan"
"github.com/hanzoai/commerce/models/subscription"
"github.com/hanzoai/commerce/models/transaction"
txutil "github.com/hanzoai/commerce/models/transaction/util"
"github.com/hanzoai/commerce/models/types/currency"
)
// prepaid is one org's prepaid money as an engine.Prepaid.
type prepaid struct {
org *organization.Organization
db *datastore.Datastore
}
var _ engine.Prepaid = prepaid{}
// prepaidFor binds the prepaid money of org's subjects to org's own store and
// books. The org is what names the ledger and whether its books are the sandbox.
func prepaidFor(ctx context.Context, org *organization.Organization) prepaid {
return prepaid{org: org, db: datastore.New(org.Namespaced(ctx))}
}
// errShort is a draw the subject's prepaid money cannot cover. It moved nothing.
var errShort = creditledger.ErrShort
// Available is the subject's unspent credit grants plus its balance.
func (p prepaid) Available(ctx context.Context, subject string, cur currency.Type) (int64, error) {
credits, err := creditsAvailable(p.db, subject)
if err != nil {
return 0, fmt.Errorf("read credit grants: %w", err)
}
bal, err := p.balance(ctx, subject, cur)
if err != nil {
return 0, fmt.Errorf("read balance: %w", err)
}
return credits + bal, nil
}
// Draw takes exactly amount — credit grants first, then the balance — or takes
// nothing.
//
// It is exactly once per ref. The draw is guarded on (subject, ref) and the
// guard keeps what was drawn, so a retry of an act whose answer was lost gets
// that answer back instead of paying again. A ref that already paid a different
// amount is refused: the same key is not the same act.
//
// Covered is decided before anything moves. The balance leg is a single debit
// that is itself all or nothing, so the only way it can fail after the credits
// are burned is a spend landing in between, and then the burned credits are put
// back.
//
// A debit can land and lose its answer. So the split is recorded under the ref
// before the ledger is asked for anything, and every later attempt under that ref
// posts the same split: the ledger answers a repeated (ref, amount) with the
// posting it already holds. The split is forgotten only when the ledger was never
// asked, or said the ref holds nothing.
func (p prepaid) Draw(ctx context.Context, subject string, cur currency.Type, amount int64, ref string) (engine.Drawn, error) {
if amount <= 0 {
return engine.Drawn{}, nil
}
if strings.TrimSpace(ref) == "" {
return engine.Drawn{}, errors.New("prepaid draw: a ref is required")
}
rec, replay, err := idempotencykey.Begin(p.db, "billing-draw:"+subject, ref)
switch {
case err != nil:
return engine.Drawn{}, fmt.Errorf("prepaid draw: cannot tell a retry from a first attempt: %w", err)
case replay && rec.Status == idempotencykey.StatusCompleted:
var d engine.Drawn
if err := json.Unmarshal([]byte(rec.Response), &d); err != nil {
return engine.Drawn{}, fmt.Errorf("prepaid draw %s: unreadable receipt: %w", ref, err)
}
if d.Credit+d.Balance != amount {
return engine.Drawn{}, fmt.Errorf("prepaid draw %s already paid %d cents, not %d", ref, d.Credit+d.Balance, amount)
}
return d, nil
case replay:
return engine.Drawn{}, fmt.Errorf("prepaid draw %s is already in progress", ref)
}
d, err := p.draw(ctx, subject, cur, amount, ref)
if err != nil {
_ = rec.Delete() // what the ledger may hold is the split record's to remember
return engine.Drawn{}, err
}
if body, merr := json.Marshal(d); merr == nil {
_ = idempotencykey.Complete(rec, string(body))
}
return d, nil
}
// draw is Draw without the guard: the split, then the move.
func (p prepaid) draw(ctx context.Context, subject string, cur currency.Type, amount int64, ref string) (engine.Drawn, error) {
split, recorded, err := idempotencykey.Begin(p.db, "billing-split:"+subject, ref)
switch {
case err != nil:
return engine.Drawn{}, fmt.Errorf("read the split drawn under %s: %w", ref, err)
case recorded && split.Status != idempotencykey.StatusCompleted:
return engine.Drawn{}, fmt.Errorf("the split for %s is being recorded", ref)
}
var d engine.Drawn
if recorded {
if err := json.Unmarshal([]byte(split.Response), &d); err != nil || d.Credit+d.Balance != amount {
return engine.Drawn{}, fmt.Errorf("the split recorded under %s is not a draw of %d cents", ref, amount)
}
} else {
if d, err = p.plan(ctx, subject, cur, amount); err != nil {
_ = split.Delete()
return engine.Drawn{}, err
}
body, merr := json.Marshal(d)
if merr == nil {
merr = idempotencykey.Complete(split, string(body))
}
if merr != nil {
_ = split.Delete()
return engine.Drawn{}, fmt.Errorf("record the split before it moves money: %w", merr)
}
}
burned, err := burnGrants(p.db, subject, d.Credit, "")
if err == nil && burned.total() < d.Credit {
err = fmt.Errorf("%w: credit grants moved while drawing", errShort)
}
if err != nil {
burned.restore()
if !recorded {
_ = split.Delete() // this attempt's split, and the ledger was never asked
}
return engine.Drawn{}, err
}
if d.Balance == 0 {
return d, nil
}
if d.Ref, err = p.debit(ctx, subject, cur, d.Balance, ref); err != nil {
burned.restore()
if errors.Is(err, errShort) {
_ = split.Delete() // the ledger holds nothing under this ref
}
return engine.Drawn{}, err
}
return d, nil
}
// plan splits amount between the subject's credit grants and its balance, credits
// first, and refuses — moving nothing — when the two together fall short.
func (p prepaid) plan(ctx context.Context, subject string, cur currency.Type, amount int64) (engine.Drawn, error) {
credits, err := creditsAvailable(p.db, subject)
if err != nil {
return engine.Drawn{}, fmt.Errorf("read credit grants: %w", err)
}
d := engine.Drawn{Credit: min(credits, amount)}
d.Balance = amount - d.Credit
if d.Balance > 0 {
bal, err := p.balance(ctx, subject, cur)
if err != nil {
return engine.Drawn{}, fmt.Errorf("read balance: %w", err)
}
if bal < d.Balance {
return engine.Drawn{}, fmt.Errorf("%w: holds %d cents, the draw is %d", errShort, credits+bal, amount)
}
}
return d, nil
}
// ledgerOrg is the org's name as the ledger keys it.
func ledgerOrg(org *organization.Organization) string {
return strings.ToLower(strings.TrimSpace(org.Name))
}
// balance reads the subject's balance from the one ledger.
func (p prepaid) balance(ctx context.Context, subject string, cur currency.Type) (int64, error) {
if led := creditledger.Get(); led != nil {
return led.Balance(ctx, ledgerOrg(p.org), subject, string(cur), p.org.TestMode())
}
return p.stored(ctx, subject, cur)
}
// stored is the balance on commerce's own ledger: the "iam-user" transactions
// every deposit, top-up and credit it takes itself writes, in the org's
// namespace and books, less what holds keep back.
func (p prepaid) stored(ctx context.Context, subject string, cur currency.Type) (int64, error) {
datas, err := txutil.GetTransactionsByCurrency(p.org.Namespaced(ctx), subject, transaction.IAMUserKind, cur, p.org.TestMode())
if err != nil {
return 0, err
}
if d, ok := datas.Data[cur]; ok {
return max(int64(d.Balance-d.Holds), 0), nil
}
return 0, nil
}
// debit draws amount from the subject's balance on the one ledger, all or
// nothing, and answers the ledger's id for the posting.
// Refund gives back a draw that paid for something that was then not delivered: the
// ledger returns what it debited under ref, and the draw's records are forgotten so a
// retry under the same ref draws again rather than replaying a payment that was
// returned. Credit grants the draw burned are not restored here — that part is named
// for reconciliation.
func (p prepaid) Refund(ctx context.Context, subject string, cur currency.Type, d engine.Drawn, ref string) error {
if d.Balance > 0 {
if led := creditledger.Get(); led != nil {
if _, err := led.Refund(ctx, creditledger.RefundInput{
Org: ledgerOrg(p.org), Subject: subject, Ref: ref, Test: p.org.TestMode(),
}); err != nil {
return fmt.Errorf("refund %s: %w", ref, err)
}
} else {
t := transaction.New(p.db)
t.Type = transaction.Deposit
t.DestinationId = subject
t.DestinationKind = transaction.IAMUserKind
t.Currency = cur
t.Amount = currency.Cents(d.Balance)
t.Notes = "refund of subscription " + ref
t.Tags = "refund"
t.Test = p.org.TestMode()
t.SetContext(mintauth.WithAuthorized(t.Context()))
if err := t.Create(); err != nil {
return fmt.Errorf("refund %s: %w", ref, err)
}
}
}
if d.Credit > 0 {
log.Error("RECONCILE: refund %s (subject=%s): %d cents of credit grants were burned by the draw and are not restored", ref, subject, d.Credit)
}
for _, scope := range []string{"billing-draw:" + subject, "billing-split:" + subject} {
k := idempotencykey.New(p.db)
if err := k.Get(p.db.NewKey(k.Kind(), idempotencykey.DeterministicID(scope, ref), 0, nil)); err == nil {
_ = k.Delete()
}
}
return nil
}
func (p prepaid) debit(ctx context.Context, subject string, cur currency.Type, amount int64, ref string) (string, error) {
if led := creditledger.Get(); led != nil {
id, _, err := led.Debit(ctx, creditledger.DebitInput{
Org: ledgerOrg(p.org),
Subject: subject,
Currency: string(cur),
Reason: "subscription " + ref,
Ref: ref,
AmountCents: amount,
Test: p.org.TestMode(),
})
return id, err
}
// Commerce's own ledger: a withdrawal of the kind the balance, the gate and
// every deposit count, in the same namespace and books.
avail, err := p.stored(ctx, subject, cur)
if err != nil {
return "", err
}
if avail < amount {
return "", fmt.Errorf("%w: holds %d cents, the debit is %d", errShort, avail, amount)
}
t := transaction.New(p.db)
t.Type = transaction.Withdraw
t.SourceId = subject
t.SourceKind = transaction.IAMUserKind
t.Currency = cur
t.Amount = currency.Cents(amount)
t.Notes = "subscription " + ref
t.Tags = "subscription"
t.Test = p.org.TestMode()
if err := t.Create(); err != nil {
return "", fmt.Errorf("record withdrawal: %w", err)
}
return t.Id(), nil
}
// creditsAvailable is what the subject's active credit grants still hold.
func creditsAvailable(db *datastore.Datastore, subject string) (int64, error) {
grants, err := getActiveGrants(db, subject)
if err != nil {
return 0, err
}
var n int64
for _, g := range grants {
n += g.RemainingCents
}
return n, nil
}
// openPaid opens a regular subscription whose first period prepaid money has
// already paid — drawn is what paid it — and raises that period's invoice as
// paid. The invoice is what makes the row payment-backed, so a plan the catalog
// does not publish confers its tier from the first moment, and it moves the row
// on to its next period, which the engine collects from the same money when it
// falls due.
//
// Only opening the row can fail it. The money has moved by then, so a failure to
// record the invoice after the row exists is logged for reconciliation and the
// row stands, the same answer a card sale gives.
func openPaid(db *datastore.Datastore, p *plan.Plan, req *createSubscriptionRequest, promoPercent int, promoName string, drawn engine.Drawn) (*subscription.Subscription, *billinginvoice.BillingInvoice, error) {
p.TrialPeriodDays = 0
sub, err := createSubscription(db, p, req)
if err != nil {
return nil, nil, err
}
sub.ProviderType = "credit"
// Stamped before the invoice is built: buildPeriodInvoice prices the promo
// off the row, so stamping it after would invoice the period at full price.
sub.DiscountPercent, sub.DiscountName = promoPercent, promoName
method, ref := "credit", drawn.Ref
if drawn.Balance > 0 {
method = "balance"
}
if ref == "" {
ref = "credit_burn"
}
inv, err := engine.CreatePaidFirstInvoice(db, sub, method, ref)
if err != nil {
log.Error("RECONCILE: subscription %s (subject=%s) was paid from prepaid money (ref=%s) and its first invoice was not recorded: %v",
sub.Id(), req.UserId, ref, err)
}
if err := sub.Update(); err != nil {
log.Error("RECONCILE: subscription %s (subject=%s) was not saved after its first invoice: %v", sub.Id(), req.UserId, err)
}
return sub, inv, nil
}