Files
zooqueenandHanzo Dev e1271578a2 A subscription says how its period was paid, a refund or dispute finds its payment's org from the payment, and a dispute won gives its money back
Subscriptions carry Settled: the current period's paid-invoice method (card only when
a card paid all of it; mixed beside prepaid money) or external:<processor> for a
recorded plan. Every settled payment is indexed by payment id to the org and wallet
its receipt lives in (models/paymentorg), and the refund/dispute webhook resolves from
that index, never the request's org. CreditLedger gains Restore for a dispute won.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-30 09:13:36 -07:00

171 lines
8.0 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.
//
// Cash says a SETTLED PAYMENT funded the credit: money the customer paid, which the
// host keeps apart from money it minted, and which alone buys paid-model work. It is
// set only where a payment was observed settling — the provider webhook — and then
// IdempotencyKey is the payment's own processor reference, so the payment credits once
// whichever path records it. The zero value is credit the platform minted (a grant, a
// starter or promo credit), and Reason says who minted it and why.
type CreditInput struct {
Org string
Subject string
Currency string
Reason string
Tag string
IdempotencyKey string
AmountCents int64
Test bool
Cash 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)
// Refund gives back the payment Debit drew under in.Ref at the same address,
// exactly: every account it came from gets back what it gave. Idempotent: a
// second refund moves nothing, and a ref that drew nothing refunds nothing.
Refund(ctx context.Context, in RefundInput) (txID string, err error)
// Clawback takes back money a processor returned to the payer — a refund, or a
// dispute that holds or takes the funds — from the wallet the payment credited:
// out of cash first, then credit, which may go below zero, since the money is gone
// either way. Posted once per in.Ref (the processor's refund or dispute id).
Clawback(ctx context.Context, in ClawbackInput) (txID string, err error)
// Restore gives back what the clawback posted under in.Ref took — a dispute the
// merchant won — to the accounts it came from. Once per in.Ref.
Restore(ctx context.Context, in ClawbackInput) (txID string, err error)
}
// ClawbackInput names the payment (its processor reference, the deposit's key), the
// processor's own id for the refund or dispute, and the amount taken back.
type ClawbackInput struct {
Org string
Subject string
Payment string
Ref string
AmountCents int64
Reason string
Test bool
}
// RefundInput names the payment to give back: the address and the Ref it was drawn
// under.
type RefundInput struct {
Org string
Subject string
Ref string
Test bool
}
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
}