Files
commerce/billing/creditledger/creditledger.go
zeekay fa4fc53b14 Pay a recorded plan from the prepaid balance
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
2026-09-23 22:09:15 -07:00

130 lines
6.1 KiB
Go

// Package creditledger is the injection seam for the ONE double-entry ledger
// commerce's prepaid money lives in: credits go in, balances are read, and
// payments are drawn out, all at one address.
//
// Commerce runs BOTH standalone and EMBEDDED in the cloud binary. When embedded,
// the AI spend-gate reads cloud's native ledger (a per-org finance ledger);
// commerce is a SEPARATE Go module and must NOT import cloud. So the host injects
// a ledger-backed CreditLedger through commerce.EmbedConfig.Ledger, and
// commerce's credit + balance handlers route to it — a credit then lands in the
// SAME per-org account the gate reads (one ledger, no split). When no ledger is
// injected (standalone dev/test), commerce falls back to its own datastore.
//
// The contract is compiler-enforced: cloud implements this interface (a ~40-line
// adapter over its finance ledger) and sets EmbedConfig.Ledger; commerce calls it.
package creditledger
import (
"context"
"errors"
"sync"
"time"
)
// CreditInput is one idempotent credit grant. The ledger is multi-currency, so
// Currency is explicit. ExpiresAt nil = no expiry.
//
// (Org, Subject) IS the address the credit lands at: Org names the ledger, Subject
// the account within it. Subject is OPTIONAL and empty means the org's pool account
// — the whole tenant shares one balance, which is what a tenant org wants and what
// this seam has always done. It exists because that is not universally true: in a
// shared signup org the members are strangers to each other, each spends from their
// own account, and a credit keyed on the org alone lands in a pool no gate reads —
// money that is neither lost nor spendable. A host that resolves per-member accounts
// names one here, from the SAME rule its gate resolves the payer with, so a credit
// and the spend it funds address one wallet by construction.
//
// Test names WHICH BOOKS the credit lands in, and it is the third component of the
// address rather than a mode: the host keeps sandbox money in a ledger of its own, so
// (Org, Subject, Test) is the whole answer to "where". It exists because a seam that
// could not say it forced every sandbox settlement to choose between two wrong
// answers — post it through here and unspendable sandbox money lands in the LIVE
// books, or bypass the seam entirely and there are two credit paths to keep in step.
// The zero value is live money, which is what every caller that never thought about
// it meant.
//
// It is also what makes the idempotency key honest. The host dedups on the key WITHIN
// one ledger, so two callers who resolve the same payment to different books both
// credit it — no error, no duplicate detected, the money simply lands twice. The key
// is only exactly-once if everyone who can credit one payment addresses one ledger,
// and Test is a component of that address.
type CreditInput struct {
Org string
Subject string
Currency string
Reason string
Tag string
IdempotencyKey string
AmountCents int64
Test bool
ExpiresAt *time.Time
}
// DebitInput is one payment drawn from an account: (Org, Subject, Test) is the
// same address CreditInput names, so a debit spends the money a credit put there.
// Ref names the act the money pays for and is the idempotency key: the same Ref
// for the same amount debits at most once.
type DebitInput struct {
Org string
Subject string
Currency string
Reason string
Ref string
AmountCents int64
Test bool
}
// ErrShort is a debit the account cannot cover in full. It moved nothing.
var ErrShort = errors.New("insufficient balance")
// CreditLedger is the ONE ledger commerce's prepaid money lives in when commerce is
// embedded. Every method MUST resolve the account the SAME way — an empty Subject is
// the org's pool everywhere — so a Credit is immediately visible to Balance, to
// Debit, and to the cloud AI gate that reads the same ledger account.
//
// THEY TAKE THE SAME ADDRESS, and that is the point of the shapes below. A credit
// lands at (Org, Subject, Test); a read or a debit that named fewer components than
// that could not name the account the credit went to, and the one it named instead
// was a different account that answers without complaining. Every verb spells the
// whole address or none does.
type CreditLedger interface {
// Credit appends a BALANCED double-entry credit to the org's account and
// returns the posting/tx id and the account's new available balance.
// Idempotent on IdempotencyKey: the same key credits AT MOST once and returns
// the original posting's id + balance (never a second grant).
Credit(ctx context.Context, in CreditInput) (txID string, balanceCents int64, err error)
// Balance returns the available balance in cents held at (org, subject) in
// currency, from the sandbox books when test and the live ones otherwise —
// byte-for-byte the address CreditInput names. An empty subject is the org's
// pool, the same default Credit applies.
Balance(ctx context.Context, org, subject, currency string, test bool) (availableCents int64, err error)
// Debit draws a payment from the account, all or nothing: when the account
// holds less than AmountCents it moves nothing and answers ErrShort. The check
// and the posting are one transaction, so a concurrent spend cannot take the
// account below zero between them. Idempotent on Ref: a replay answers the
// original posting's id and moves nothing.
Debit(ctx context.Context, in DebitInput) (txID string, balanceCents int64, err error)
}
var (
mu sync.RWMutex
injected CreditLedger
)
// Set installs the host-injected ledger. commerce.Embed calls it from
// EmbedConfig.Ledger; passing nil clears it (→ commerce falls back to its own
// datastore). Process-wide, mirroring the host's finance.Publish/Current singleton.
func Set(l CreditLedger) {
mu.Lock()
injected = l
mu.Unlock()
}
// Get returns the injected ledger, or nil when commerce runs standalone (datastore
// fallback). The credit + balance handlers call it on every request.
func Get() CreditLedger {
mu.RLock()
defer mu.RUnlock()
return injected
}