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
241 lines
9.2 KiB
Go
241 lines
9.2 KiB
Go
package billing
|
|
|
|
// credit_ledger_test.go proves the INJECTED-ledger path (commerce embedded in the
|
|
// cloud binary): POST /v1/billing/credit routes to the host-injected CreditLedger,
|
|
// GET balance reads it, they key the SAME org account (account-consistency
|
|
// invariant), and the mint-gate still 403s a non-admin — all with a fake ledger.
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/zap-proto/zip"
|
|
|
|
"github.com/hanzoai/commerce/billing/creditledger"
|
|
"github.com/hanzoai/commerce/util/test/ae"
|
|
)
|
|
|
|
// fakeLedger is an in-memory CreditLedger keyed by the WHOLE address a credit names
|
|
// — (org, subject, currency, test) — because that is how the real one is keyed and a
|
|
// fake that folded any of them away would pass a test the ledger fails. The host
|
|
// holds sandbox money in a separate ledger per org and dedups an idempotency key
|
|
// WITHIN one ledger, so `test` is part of the address AND part of the dedup scope.
|
|
//
|
|
// It records the LAST CreditInput and every credit it took, so a test can assert the
|
|
// handler passed exactly the right fields and nothing credited twice.
|
|
type fakeLedger struct {
|
|
mu sync.Mutex
|
|
lastIn creditledger.CreditInput
|
|
posted []creditledger.CreditInput
|
|
debits []creditledger.DebitInput
|
|
calls int
|
|
bal map[string]int64
|
|
seen map[string]string
|
|
nextID int
|
|
}
|
|
|
|
func newFakeLedger() *fakeLedger {
|
|
return &fakeLedger{bal: map[string]int64{}, seen: map[string]string{}}
|
|
}
|
|
|
|
// account is the address a credit lands at / a balance is read from. An empty
|
|
// subject is the org's pool, the SAME default both halves of the real seam apply.
|
|
func (f *fakeLedger) account(org, subject, cur string, test bool) string {
|
|
if subject == "" {
|
|
subject = org
|
|
}
|
|
return fmt.Sprintf("%s|%s|%s|%t", org, subject, cur, test)
|
|
}
|
|
|
|
// scope is where an idempotency key is unique: one ledger, which is one (org, test)
|
|
// pair. The same key in two different ledgers is two credits — the property that
|
|
// makes an unresolved payer dangerous rather than merely wrong.
|
|
func (f *fakeLedger) scope(org string, test bool, key string) string {
|
|
return fmt.Sprintf("%s|%t|%s", org, test, key)
|
|
}
|
|
|
|
func (f *fakeLedger) Credit(_ context.Context, in creditledger.CreditInput) (string, int64, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.lastIn = in
|
|
f.calls++
|
|
k := f.account(in.Org, in.Subject, in.Currency, in.Test)
|
|
if in.IdempotencyKey != "" {
|
|
if id, ok := f.seen[f.scope(in.Org, in.Test, in.IdempotencyKey)]; ok {
|
|
return id, f.bal[k], nil // idempotent replay: no second credit
|
|
}
|
|
}
|
|
f.nextID++
|
|
id := fmt.Sprintf("post_%d", f.nextID)
|
|
f.bal[k] += in.AmountCents
|
|
f.posted = append(f.posted, in)
|
|
if in.IdempotencyKey != "" {
|
|
f.seen[f.scope(in.Org, in.Test, in.IdempotencyKey)] = id
|
|
}
|
|
return id, f.bal[k], nil
|
|
}
|
|
|
|
func (f *fakeLedger) Balance(_ context.Context, org, subject, cur string, test bool) (int64, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.bal[f.account(org, subject, cur, test)], nil
|
|
}
|
|
|
|
// Debit draws a payment from the WHOLE address, all or nothing, exactly once per
|
|
// ref within one ledger — the real ledger's contract.
|
|
func (f *fakeLedger) Debit(_ context.Context, in creditledger.DebitInput) (string, int64, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
k := f.account(in.Org, in.Subject, in.Currency, in.Test)
|
|
scope := f.scope(in.Org, in.Test, "debit:"+in.Ref)
|
|
if id, ok := f.seen[scope]; ok {
|
|
return id, f.bal[k], nil
|
|
}
|
|
if f.bal[k] < in.AmountCents {
|
|
return "", f.bal[k], creditledger.ErrShort
|
|
}
|
|
f.nextID++
|
|
id := fmt.Sprintf("debit_%d", f.nextID)
|
|
f.bal[k] -= in.AmountCents
|
|
f.debits = append(f.debits, in)
|
|
f.seen[scope] = id
|
|
return id, f.bal[k], nil
|
|
}
|
|
|
|
// credits returns the credits actually POSTED (replays excluded), for the tests that
|
|
// care about how many times money moved rather than how many calls were made.
|
|
func (f *fakeLedger) credits() []creditledger.CreditInput {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return append([]creditledger.CreditInput(nil), f.posted...)
|
|
}
|
|
|
|
// injectLedger installs a ledger for the test and clears it afterward so it never
|
|
// leaks into the datastore-path tests in this package.
|
|
func injectLedger(t *testing.T, l creditledger.CreditLedger) {
|
|
t.Helper()
|
|
creditledger.Set(l)
|
|
t.Cleanup(func() { creditledger.Set(nil) })
|
|
}
|
|
|
|
// ledgerEngine builds the test engine holding a reference to the shared test
|
|
// datastore for the life of the test.
|
|
//
|
|
// These tests exercise the INJECTED-ledger path, but a request still resolves its
|
|
// org THROUGH the datastore before the handler ever reaches the ledger — so the
|
|
// store must be open, or the org lookup fails and the route 503s. ae.NewContext is
|
|
// REFERENCE-COUNTED and closes the store when the last holder Closes it, so a test
|
|
// that never opens one is not "not using the store": it is free-riding on whichever
|
|
// other test file happens to be holding it open, and it breaks the day that file
|
|
// changes. Holding the reference here makes each test self-sufficient and
|
|
// order-independent.
|
|
func ledgerEngine(t *testing.T, seed func(*zip.Ctx)) *zip.App {
|
|
t.Helper()
|
|
ctx := ae.NewContext()
|
|
t.Cleanup(ctx.Close)
|
|
return engineWithSeed(func(c *zip.Ctx) {
|
|
c.SetContext(ctx)
|
|
if seed != nil {
|
|
seed(c)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestCredit_InjectedLedger_RoutesAndReflects is the account-consistency proof:
|
|
// the endpoint calls CreditLedger.Credit with the exact CreditInput, and
|
|
// GET /billing/balance?user=<org> reflects it via the SAME org account.
|
|
func TestCredit_InjectedLedger_RoutesAndReflects(t *testing.T) {
|
|
fake := newFakeLedger()
|
|
injectLedger(t, fake)
|
|
|
|
eng := ledgerEngine(t, platformApp)
|
|
resp := postCredit(eng, "", "acme", `{"org":"acme","amountCents":500,"reason":"welcome","tag":"starter-credit","currency":"usd"}`)
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("service-token credit (ledger): status=%d body=%s, want 201", resp.StatusCode, respBodyStr(resp))
|
|
}
|
|
|
|
// The handler called Credit exactly once with the right CreditInput.
|
|
if fake.calls != 1 {
|
|
t.Fatalf("CreditLedger.Credit calls=%d, want 1", fake.calls)
|
|
}
|
|
got := fake.lastIn
|
|
if got.Org != "acme" || got.Currency != "usd" || got.Reason != "welcome" || got.Tag != "starter-credit" || got.AmountCents != 500 {
|
|
t.Fatalf("CreditInput=%+v, want {Org:acme Currency:usd Reason:welcome Tag:starter-credit AmountCents:500}", got)
|
|
}
|
|
|
|
out := decodeJSON(t, resp)
|
|
if id, _ := out["id"].(string); id == "" {
|
|
t.Fatalf("response missing ledger tx id: %v", out)
|
|
}
|
|
if bc, _ := out["balanceCents"].(float64); int64(bc) != 500 {
|
|
t.Fatalf("response balanceCents=%v, want 500", out["balanceCents"])
|
|
}
|
|
|
|
// GET balance reads the SAME org account → reflects the credit (one ledger).
|
|
if bal := getBalanceCents(t, eng, "", "acme"); bal != 500 {
|
|
t.Fatalf("GET balance for acme=%d, want 500 (credit must be visible on the same account)", bal)
|
|
}
|
|
}
|
|
|
|
// TestCredit_InjectedLedger_OrgAdminStill403 proves the mint-gate is unchanged with
|
|
// a ledger injected: an org admin is 403 and the ledger is NEVER called (a user can
|
|
// still not credit itself, even through the injected ledger).
|
|
func TestCredit_InjectedLedger_OrgAdminStill403(t *testing.T) {
|
|
fake := newFakeLedger()
|
|
injectLedger(t, fake)
|
|
|
|
eng := ledgerEngine(t, orgAdminSeed)
|
|
resp := postCredit(eng, "", "", `{"org":"acme","amountCents":100000,"reason":"self-credit"}`)
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Fatalf("org-admin credit (ledger injected): status=%d, want 403", resp.StatusCode)
|
|
}
|
|
if fake.calls != 0 {
|
|
t.Fatalf("CreditLedger.Credit was called %d times for a 403'd org admin — the gate must block BEFORE the ledger", fake.calls)
|
|
}
|
|
}
|
|
|
|
// TestCredit_InjectedLedger_Idempotent proves idempotency is honored through the
|
|
// interface: same idempotencyKey ⇒ one credit, same tx id, balance not doubled.
|
|
func TestCredit_InjectedLedger_Idempotent(t *testing.T) {
|
|
fake := newFakeLedger()
|
|
injectLedger(t, fake)
|
|
|
|
eng := ledgerEngine(t, platformApp)
|
|
body := `{"org":"idem","amountCents":250,"reason":"promo","idempotencyKey":"once"}`
|
|
|
|
first := decodeJSON(t, postCredit(eng, "", "idem", body))
|
|
second := decodeJSON(t, postCredit(eng, "", "idem", body))
|
|
if first["id"] != second["id"] || first["id"] == "" {
|
|
t.Fatalf("idempotent replay: first id=%v second id=%v, want equal non-empty", first["id"], second["id"])
|
|
}
|
|
if bal := getBalanceCents(t, eng, "", "idem"); bal != 250 {
|
|
t.Fatalf("after two same-key credits, balance=%d, want 250 (one grant)", bal)
|
|
}
|
|
}
|
|
|
|
// TestCredit_InjectedLedger_MultiCurrency proves currency flows through to the
|
|
// ledger and keys a distinct per-currency account.
|
|
func TestCredit_InjectedLedger_MultiCurrency(t *testing.T) {
|
|
fake := newFakeLedger()
|
|
injectLedger(t, fake)
|
|
|
|
eng := ledgerEngine(t, platformApp)
|
|
if resp := postCredit(eng, "", "fx", `{"org":"fx","amountCents":900,"reason":"eur grant","currency":"eur"}`); resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("eur credit: status=%d body=%s", resp.StatusCode, respBodyStr(resp))
|
|
}
|
|
if fake.lastIn.Currency != "eur" {
|
|
t.Fatalf("CreditInput.Currency=%q, want eur", fake.lastIn.Currency)
|
|
}
|
|
// usd account is untouched; eur holds the grant.
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/billing/balance?user=fx¤cy=eur", nil)
|
|
req.Header.Set("X-Org-Id", "fx")
|
|
resp, _ := eng.Test(req)
|
|
if bc, _ := decodeJSON(t, resp)["balance"].(float64); int64(bc) != 900 {
|
|
t.Fatalf("eur balance=%v, want 900", bc)
|
|
}
|
|
}
|