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]>
309 lines
12 KiB
Go
309 lines
12 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
|
|
clawbacks []creditledger.ClawbackInput
|
|
restores []creditledger.ClawbackInput
|
|
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
|
|
}
|
|
|
|
// Refund gives back the debit drawn under Ref, as the real ledger does: the attempt
|
|
// that holds money is refunded once, a debit under the same Ref afterwards is a new
|
|
// payment, and a Ref with nothing outstanding answers the last refund.
|
|
func (f *fakeLedger) Refund(_ context.Context, in creditledger.RefundInput) (string, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
for i := len(f.debits) - 1; i >= 0; i-- {
|
|
d := f.debits[i]
|
|
if d.Ref == in.Ref && d.Org == in.Org && d.Subject == in.Subject && d.Test == in.Test {
|
|
f.bal[f.account(d.Org, d.Subject, d.Currency, d.Test)] += d.AmountCents
|
|
f.debits = append(f.debits[:i], f.debits[i+1:]...)
|
|
delete(f.seen, f.scope(in.Org, in.Test, "debit:"+in.Ref))
|
|
f.nextID++
|
|
id := fmt.Sprintf("refund_%d", f.nextID)
|
|
f.seen[f.scope(in.Org, in.Test, "refund:"+in.Ref)] = id
|
|
return id, nil
|
|
}
|
|
}
|
|
return f.seen[f.scope(in.Org, in.Test, "refund:"+in.Ref)], nil
|
|
}
|
|
|
|
// Restore gives back the clawback posted under Ref, once.
|
|
func (f *fakeLedger) Restore(_ context.Context, in creditledger.ClawbackInput) (string, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
scope := f.scope(in.Org, in.Test, "restore:"+in.Ref)
|
|
if id, ok := f.seen[scope]; ok {
|
|
return id, nil
|
|
}
|
|
for _, c := range f.clawbacks {
|
|
if c.Ref == in.Ref && c.Org == in.Org {
|
|
f.bal[f.account(c.Org, c.Subject, "usd", c.Test)] += c.AmountCents
|
|
f.nextID++
|
|
id := fmt.Sprintf("restore_%d", f.nextID)
|
|
f.seen[scope] = id
|
|
f.restores = append(f.restores, in)
|
|
return id, nil
|
|
}
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
// Clawback takes money back once per Ref.
|
|
func (f *fakeLedger) Clawback(_ context.Context, in creditledger.ClawbackInput) (string, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
scope := f.scope(in.Org, in.Test, "clawback:"+in.Ref)
|
|
if id, ok := f.seen[scope]; ok {
|
|
return id, nil
|
|
}
|
|
f.bal[f.account(in.Org, in.Subject, "usd", in.Test)] -= in.AmountCents
|
|
f.nextID++
|
|
id := fmt.Sprintf("clawback_%d", f.nextID)
|
|
f.seen[scope] = id
|
|
f.clawbacks = append(f.clawbacks, in)
|
|
return id, 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 != "Minted by app_platform: welcome" || got.Tag != "starter-credit" || got.AmountCents != 500 {
|
|
t.Fatalf("CreditInput=%+v, want {Org:acme Currency:usd Reason:Minted by app_platform: welcome Tag:starter-credit AmountCents:500}", got)
|
|
}
|
|
// The mint route makes money; it never records a payment, whatever the tag says.
|
|
if got.Cash {
|
|
t.Fatalf("a minted credit reached the ledger as cash: %+v", got)
|
|
}
|
|
topup := postCredit(eng, "", "acme", `{"org":"acme","amountCents":100,"reason":"looks like a top-up","tag":"topup"}`)
|
|
if topup.StatusCode != http.StatusCreated || fake.lastIn.Cash {
|
|
t.Fatalf("a minted credit tagged topup: status=%d cash=%v, want 201 and credit", topup.StatusCode, fake.lastIn.Cash)
|
|
}
|
|
|
|
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 both credits (one ledger).
|
|
if bal := getBalanceCents(t, eng, "", "acme"); bal != 600 {
|
|
t.Fatalf("GET balance for acme=%d, want 600 (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)
|
|
}
|
|
}
|