mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
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]>
171 lines
8.0 KiB
Go
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
|
|
}
|