mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
RecordSubscription takes processor "balance": it draws the period from the subject's prepaid money now, raises and settles that period's invoice, and opens a regular subscription the engine renews from the same money. The period must be the one running now and one of the plan's; a subject holding a plan, or one gone past due, is refused. A balance that cannot cover the period is refused with nothing written. The ledger entry and the invoice are the reference. Prepaid money is read and spent one way. The creditledger seam gains Debit, all or nothing at the address Credit and Balance already name, so with a host ledger injected the draw lands where a grant credits and the AI gate reads, and the tier reads its balance, beside the credit grants, from there too. Standalone, the draw is an "iam-user" withdrawal, the kind every deposit writes. The self-serve balance purchase, the record and every renewal draw through it, credit grants first, exactly once per ref. The split is recorded under the ref before the ledger is asked, so a debit whose answer was lost is posted again as it was and the ledger answers with the posting it holds. The collector reads the allocation first and charges the card before any prepaid money moves, so an invoice that cannot be paid in full takes nothing from credits or balance. What an attempt does collect accumulates on AmountPaid, and the draw is keyed on the subscription's period, so two invoices raised for one period draw once between them. Periods are billed in advance. A subscription holds the period it paid for; when that period ends the next one is invoiced, fee in advance and metered usage in arrears, and the row moves onto it only once it is paid. An unpaid one leaves the row past due on the period it owes; once that period has gone by unserved it is owed no longer, its invoice is void, and the row owes the period running now. A row that was served nothing until it paid, past due or late, is served a whole period from the payment. An invoice is voided only under the guard its payment takes, so a payment landing at once is never voided over. A row whose renewal was missed for whole periods is billed the period running now, never every period since. A row canceled at the end of its period ends with the last period it paid for, and an ended row's unpaid invoices are void. Invoices are read by their filters rather than under the synckey ancestor an invoice loses once it is saved again, so a paid one stays listed and found by its renewal, and a new one is numbered one past the highest issued. The billing cycle reads subscriptions the same way, renews only rows of the org's own mode, and RenewDue is the sweep as a value core a schedule can call. Claude-Session: https://claude.ai/code/session_016hTDbBTaq6r3xTQ5RAVVtF
310 lines
12 KiB
Go
310 lines
12 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/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.
|
|
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
|
|
}
|