Files
commerce/api/billing/prepaid.go
zooqueenandHanzo Dev abab16618d feat(billing): plan coupon for a percent off the first month
POST /v1/billing/subscribe/card takes an optional coupon code. The coupon
is a models/coupon row (Filter "plan", Type percent) in the reserved
platform namespace "admin", beside the platform promo. It must be enabled,
inside [StartDate, EndDate], under Limit and cover the plan; an org and a
card each redeem a code once (models/couponredemption rows), and the use is
reserved under the code's lock before the card is charged and given back on
a decline. Any refusal is a 400 or 409 before the card is touched.

The percent comes off the first charge only, through engine.Discounted,
the same arithmetic the first invoice uses, so charge == AmountDue. The
first invoice names the coupon; the subscription never carries it, so
renewals bill the catalog price (less only a promo the row carries).

GET /v1/billing/plans?coupon=CODE quotes couponCode, couponPercent,
couponFirstCents and couponEnds on covered paid rows and couponError
(unknown, expired, exhausted, not_applicable) on the rest. Without the
parameter the response is unchanged.

SuperAdmin: GET/PUT /v1/platform/coupons/:code (RequirePlatformAdmin),
audited as coupon.created/coupon.updated billing events naming the actor.
middleware.Actor is the one actor-naming rule, moved from billing's minter.

Co-authored-by: Hanzo Dev <[email protected]>
2026-10-09 12:18:05 -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, engine.Coupon{})
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
}