mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
Multi-tenant commerce and billing: orders, subscriptions, invoices, plans, usage metering and a credit ledger. One Go binary. MIT OR Apache-2.0. Claude-Session: https://claude.ai/code/session_01SAtaDFm8VDiWrhYHNNtxMw
223 lines
9.1 KiB
Go
223 lines
9.1 KiB
Go
package billing
|
|
|
|
// credit_test.go is the acceptance suite for POST /v1/billing/credit — the ONE
|
|
// mint-gated way credit enters an org ledger. It proves the money-critical core:
|
|
// - service token → grant (the cloud-api path)
|
|
// - global admin → grant (the human superadmin path)
|
|
// - org admin / plain user / no auth → 403/401, NO grant (a user cannot
|
|
// credit themselves — the whole point of the consolidation)
|
|
// - idempotencyKey → at most ONE grant
|
|
// - starter params (billing/credit constants) compose into a starter grant
|
|
// - the grant is READ by the balance path (spendable immediately)
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/zap-proto/zip"
|
|
|
|
"github.com/hanzoai/commerce/auth"
|
|
"github.com/hanzoai/commerce/billing/credit"
|
|
"github.com/hanzoai/commerce/util/bit"
|
|
"github.com/hanzoai/commerce/util/permission"
|
|
"github.com/hanzoai/commerce/util/test/ae"
|
|
)
|
|
|
|
// postCredit drives POST /v1/billing/credit through the REAL Route() behind seed.
|
|
func postCredit(eng *zip.App, tok, orgHdr, body string) *http.Response {
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/billing/credit", bytes.NewBufferString(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if tok != "" {
|
|
req.Header.Set("Authorization", "Bearer "+tok)
|
|
}
|
|
if orgHdr != "" {
|
|
req.Header.Set("X-Org-Id", orgHdr)
|
|
}
|
|
resp, _ := eng.Test(req)
|
|
return resp
|
|
}
|
|
|
|
func decodeJSON(t *testing.T, resp *http.Response) map[string]any {
|
|
t.Helper()
|
|
var out map[string]any
|
|
raw := respBodyStr(resp)
|
|
if err := json.Unmarshal([]byte(raw), &out); err != nil {
|
|
t.Fatalf("bad json (%d): %v (%s)", resp.StatusCode, err, raw)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ── the money assertion: only a mint principal may credit ────────────────────
|
|
|
|
// TestCredit_PlatformAppGrants proves the legitimate money path: the internal
|
|
// service token (cloud-api → commerce) appends a grant (201) end-to-end.
|
|
func TestCredit_PlatformAppGrants(t *testing.T) {
|
|
ctx := ae.NewContext()
|
|
defer ctx.Close()
|
|
|
|
eng := engineWithSeed(func(c *zip.Ctx) { c.SetContext(ctx); platformApp(c) })
|
|
resp := postCredit(eng, "", "creditorg", `{"org":"creditorg","amountCents":500,"reason":"welcome","tag":"starter-credit"}`)
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("service-token credit: status=%d body=%s, want 201", resp.StatusCode, respBodyStr(resp))
|
|
}
|
|
out := decodeJSON(t, resp)
|
|
if id, _ := out["id"].(string); id == "" {
|
|
t.Fatalf("credit response missing id: %v", out)
|
|
}
|
|
if amt, _ := out["amountCents"].(float64); int64(amt) != 500 {
|
|
t.Fatalf("amountCents=%v, want 500", out["amountCents"])
|
|
}
|
|
}
|
|
|
|
// TestCredit_GlobalAdminGrants proves the human superadmin (owner=="admin") may
|
|
// credit any org named in the body — no X-Org-Id needed; the org is the account.
|
|
func TestCredit_GlobalAdminGrants(t *testing.T) {
|
|
ctx := ae.NewContext()
|
|
defer ctx.Close()
|
|
|
|
eng := engineWithSeed(func(c *zip.Ctx) {
|
|
c.SetContext(ctx)
|
|
c.Locals("iam_authenticated", true)
|
|
c.Locals("permissions", bit.Field(permission.Admin|permission.Live))
|
|
c.Locals("iam_claims", &auth.IAMClaims{Owner: "admin", IsAdmin: true}) // global admin
|
|
})
|
|
resp := postCredit(eng, "", "", `{"org":"adminorg","amountCents":1000,"reason":"comp"}`)
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("global-admin credit: status=%d body=%s, want 201", resp.StatusCode, respBodyStr(resp))
|
|
}
|
|
}
|
|
|
|
// TestCredit_OrgAdminDenied is THE money assertion: an org-level admin (org owner,
|
|
// NOT a global admin, NOT the service token) is 403 — it can never self-credit,
|
|
// even though it holds the org Admin bit and passes TokenRequired(Admin).
|
|
func TestCredit_OrgAdminDenied(t *testing.T) {
|
|
eng := engineWithSeed(orgAdminSeed) // owner="acme", IsAdmin=true (org-level)
|
|
resp := postCredit(eng, "", "", `{"org":"acme","amountCents":100000,"reason":"self-credit-attempt"}`)
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Fatalf("org-admin self-credit: status=%d body=%s, want 403 (a user must NOT credit itself)",
|
|
resp.StatusCode, respBodyStr(resp))
|
|
}
|
|
}
|
|
|
|
// TestCredit_PlainUserDenied proves a normal authenticated user (no Admin bit) is
|
|
// refused (never a 2xx grant).
|
|
func TestCredit_PlainUserDenied(t *testing.T) {
|
|
eng := engineWithSeed(func(c *zip.Ctx) {
|
|
c.Locals("iam_authenticated", true)
|
|
c.Locals("permissions", bit.Field(permission.None))
|
|
c.Locals("iam_claims", &auth.IAMClaims{Owner: "acme"})
|
|
})
|
|
resp := postCredit(eng, "", "acme", `{"org":"acme","amountCents":500,"reason":"x"}`)
|
|
if resp.StatusCode < 400 {
|
|
t.Fatalf("plain user credit: status=%d body=%s, want a rejection (>=400), never a grant",
|
|
resp.StatusCode, respBodyStr(resp))
|
|
}
|
|
}
|
|
|
|
// TestCredit_NoAuthDenied proves an unauthenticated request never grants.
|
|
func TestCredit_NoAuthDenied(t *testing.T) {
|
|
eng := engineWithSeed(nil)
|
|
resp := postCredit(eng, "", "", `{"org":"acme","amountCents":500,"reason":"x"}`)
|
|
if resp.StatusCode < 400 {
|
|
t.Fatalf("no-auth credit: status=%d body=%s, want a rejection (>=400)", resp.StatusCode, respBodyStr(resp))
|
|
}
|
|
}
|
|
|
|
// ── idempotency ──────────────────────────────────────────────────────────────
|
|
|
|
// TestCredit_IdempotentOnKey proves the same idempotencyKey credits AT MOST ONCE:
|
|
// the second call replays the first grant (200, same id) and the balance holds
|
|
// exactly one grant.
|
|
func TestCredit_IdempotentOnKey(t *testing.T) {
|
|
ctx := ae.NewContext()
|
|
defer ctx.Close()
|
|
|
|
eng := engineWithSeed(func(c *zip.Ctx) { c.SetContext(ctx); platformApp(c) })
|
|
body := `{"org":"idemorg","amountCents":250,"reason":"promo","idempotencyKey":"once-2026"}`
|
|
|
|
first := postCredit(eng, "", "idemorg", body)
|
|
if first.StatusCode != http.StatusCreated {
|
|
t.Fatalf("first credit: status=%d body=%s, want 201", first.StatusCode, respBodyStr(first))
|
|
}
|
|
firstID, _ := decodeJSON(t, first)["id"].(string)
|
|
|
|
second := postCredit(eng, "", "idemorg", body)
|
|
if second.StatusCode != http.StatusOK {
|
|
t.Fatalf("replayed credit: status=%d body=%s, want 200 (idempotent replay)", second.StatusCode, respBodyStr(second))
|
|
}
|
|
secondID, _ := decodeJSON(t, second)["id"].(string)
|
|
if firstID == "" || firstID != secondID {
|
|
t.Fatalf("idempotent replay returned a different grant: first=%q second=%q", firstID, secondID)
|
|
}
|
|
|
|
// The money invariant: balance == exactly ONE grant, never doubled.
|
|
if bal := getBalanceCents(t, eng, "", "idemorg"); bal != 250 {
|
|
t.Fatalf("after two same-key credits, balance=%d cents, want exactly 250 (no double-grant)", bal)
|
|
}
|
|
}
|
|
|
|
// ── starter composition + ledger read ────────────────────────────────────────
|
|
|
|
// TestCredit_StarterComposition proves the starter credit is now just a
|
|
// parameterized call driven by the billing/credit constants, and it lands and is
|
|
// readable by the balance path (spendable).
|
|
func TestCredit_StarterComposition(t *testing.T) {
|
|
ctx := ae.NewContext()
|
|
defer ctx.Close()
|
|
|
|
eng := engineWithSeed(func(c *zip.Ctx) { c.SetContext(ctx); platformApp(c) })
|
|
expiry := time.Now().AddDate(0, 0, credit.StarterCreditDays).UTC().Format(time.RFC3339)
|
|
body := fmt.Sprintf(`{"org":"starterorg","amountCents":%d,"reason":"welcome","tag":%q,"expiresAt":%q}`,
|
|
credit.StarterCreditCents, credit.StarterCreditTag, expiry)
|
|
|
|
resp := postCredit(eng, "", "starterorg", body)
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("starter credit: status=%d body=%s, want 201", resp.StatusCode, respBodyStr(resp))
|
|
}
|
|
out := decodeJSON(t, resp)
|
|
if tag, _ := out["tag"].(string); tag != credit.StarterCreditTag {
|
|
t.Fatalf("tag=%q, want %q (domain constant drives it)", out["tag"], credit.StarterCreditTag)
|
|
}
|
|
|
|
// Readable by the balance path = spendable.
|
|
if bal := getBalanceCents(t, eng, "", "starterorg"); bal != int64(credit.StarterCreditCents) {
|
|
t.Fatalf("starter credit not visible in balance: got %d cents, want %d", bal, credit.StarterCreditCents)
|
|
}
|
|
}
|
|
|
|
// TestCredit_LedgerReadableByBalance proves a plain (non-starter) grant nets into
|
|
// the org balance the gateway gate reads.
|
|
func TestCredit_LedgerReadableByBalance(t *testing.T) {
|
|
ctx := ae.NewContext()
|
|
defer ctx.Close()
|
|
|
|
eng := engineWithSeed(func(c *zip.Ctx) { c.SetContext(ctx); platformApp(c) })
|
|
if resp := postCredit(eng, "", "ledgerorg", `{"org":"ledgerorg","amountCents":700,"reason":"grant"}`); resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("credit: status=%d body=%s, want 201", resp.StatusCode, respBodyStr(resp))
|
|
}
|
|
if bal := getBalanceCents(t, eng, "", "ledgerorg"); bal != 700 {
|
|
t.Fatalf("credit not readable by balance: got %d cents, want 700", bal)
|
|
}
|
|
}
|
|
|
|
// getBalanceCents reads GET /v1/billing/balance for the org pool (subject == org).
|
|
func getBalanceCents(t *testing.T, eng *zip.App, tok, orgID string) int64 {
|
|
t.Helper()
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/billing/balance?user="+orgID+"¤cy=usd", nil)
|
|
req.Header.Set("X-Org-Id", orgID)
|
|
resp, terr := eng.Test(req)
|
|
if terr != nil {
|
|
t.Fatalf("balance Test: %v", terr)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("GET /balance: status=%d body=%s", resp.StatusCode, respBodyStr(resp))
|
|
}
|
|
bal, _ := decodeJSON(t, resp)["balance"].(float64)
|
|
return int64(bal)
|
|
}
|