Files
commerce/test/api/billing/billing_test.go

820 lines
25 KiB
Go

package test
import (
nethttp "net/http"
billingApi "github.com/hanzoai/commerce/api/billing"
. "github.com/hanzoai/commerce/util/test/ginkgo"
. "github.com/hanzoai/commerce/util/test/zipclient"
)
// Response types for JSON parsing
type meterResponse struct {
Id string `json:"id"`
Name string `json:"name"`
EventName string `json:"eventName"`
AggregationType string `json:"aggregationType"`
Currency string `json:"currency"`
Dimensions []string `json:"dimensions"`
}
type meterListResponse struct {
Meters []meterResponse `json:"meters"`
Count int `json:"count"`
}
type eventResult struct {
Id string `json:"id"`
Status string `json:"status"`
}
type eventsResponse struct {
Events []eventResult `json:"events"`
Count int `json:"count"`
}
type summaryResponse struct {
MeterId string `json:"meterId"`
MeterName string `json:"meterName"`
UserId string `json:"userId"`
AggregationType string `json:"aggregationType"`
Value int64 `json:"value"`
EventCount int64 `json:"eventCount"`
}
type creditGrantResponse struct {
Id string `json:"id"`
UserId string `json:"userId"`
Name string `json:"name"`
AmountCents int64 `json:"amountCents"`
RemainingCents int64 `json:"remainingCents"`
Currency string `json:"currency"`
Priority int `json:"priority"`
Tags string `json:"tags"`
}
type creditGrantListResponse struct {
Grants []creditGrantResponse `json:"grants"`
Count int `json:"count"`
}
type creditBalanceItem struct {
Currency string `json:"currency"`
Available int64 `json:"available"`
}
type creditBalanceResponse struct {
UserId string `json:"userId"`
Balances []creditBalanceItem `json:"balances"`
}
type pricingRuleResponse struct {
Id string `json:"id"`
MeterId string `json:"meterId"`
PlanId string `json:"planId"`
Model string `json:"model"`
Currency string `json:"currency"`
UnitPrice int64 `json:"unitPrice"`
}
type pricingRuleListResponse struct {
Rules []pricingRuleResponse `json:"rules"`
Count int `json:"count"`
}
type invoiceLineItem struct {
MeterId string `json:"meterId"`
MeterName string `json:"meterName"`
Quantity int64 `json:"quantity"`
TotalCost int64 `json:"totalCost"`
}
type invoicePreviewResponse struct {
UserId string `json:"userId"`
LineItems []invoiceLineItem `json:"lineItems"`
Subtotal int64 `json:"subtotal"`
CreditApplied int64 `json:"creditApplied"`
AmountDue int64 `json:"amountDue"`
Currency string `json:"currency"`
}
// Shared state across tests — populated in order by Ginkgo's sequential execution
var (
meterId string
creditGrantId string
pricingRuleId string
)
// Ordered: this is a stateful integration suite — a meter created in "Meters" is
// reused by "Meter Events", and the allotment specs are
// a report→grant→balance sequence. Declare that ordering so `ginkgo
// --randomize-all` doesn't run a dependent spec before the one that seeds its
// state (that was the "batch events → count 0" CI failure: meterId still "").
var _ = Describe("billing", Ordered, func() {
// ─── METERS ───────────────────────────────────────────────────────
Context("Meters", func() {
It("Should create a meter", func() {
req := map[string]interface{}{
"name": "Input Tokens",
"eventName": "input_tokens",
"aggregationType": "sum",
"currency": "usd",
"dimensions": []string{"model", "region"},
}
res := &meterResponse{}
cl.Post("/billing/meters", req, res)
Expect(res.Id).NotTo(BeEmpty())
Expect(res.Name).To(Equal("Input Tokens"))
Expect(res.EventName).To(Equal("input_tokens"))
Expect(res.AggregationType).To(Equal("sum"))
Expect(res.Currency).To(Equal("usd"))
Expect(res.Dimensions).To(ConsistOf("model", "region"))
meterId = res.Id
})
It("Should fail to create meter without name", func() {
req := map[string]interface{}{
"eventName": "missing_name",
}
res := &ApiError{}
cl.Post("/billing/meters", req, res)
Expect(res.Error.Message).To(ContainSubstring("name is required"))
})
It("Should fail to create meter without eventName", func() {
req := map[string]interface{}{
"name": "Missing Event",
}
res := &ApiError{}
cl.Post("/billing/meters", req, res)
Expect(res.Error.Message).To(ContainSubstring("eventName is required"))
})
It("Should list meters", func() {
res := &meterListResponse{}
cl.Get("/billing/meters", res)
Expect(res.Count).To(BeNumerically(">=", 1))
Expect(res.Meters).NotTo(BeEmpty())
found := false
for _, m := range res.Meters {
if m.EventName == "input_tokens" {
found = true
Expect(m.Name).To(Equal("Input Tokens"))
}
}
Expect(found).To(BeTrue())
})
It("Should get meter by ID", func() {
res := &meterResponse{}
cl.Get("/billing/meters/"+meterId, res)
Expect(res.Id).To(Equal(meterId))
Expect(res.Name).To(Equal("Input Tokens"))
Expect(res.AggregationType).To(Equal("sum"))
})
It("Should return 404 for non-existent meter", func() {
res := &ApiError{}
cl.Get("/billing/meters/nonexistent-id", res)
Expect(res.Error.Message).To(ContainSubstring("not found"))
})
})
// ─── METER EVENTS ─────────────────────────────────────────────────
Context("Meter Events", func() {
It("Should record a single event", func() {
req := map[string]interface{}{
"events": []map[string]interface{}{
{
"meterId": meterId,
"userId": "acme/alice",
"value": int64(1500),
"dimensions": map[string]interface{}{
"model": "gpt-4",
},
},
},
}
res := &eventsResponse{}
cl.Post("/billing/meter-events", req, res)
Expect(res.Count).To(Equal(1))
Expect(res.Events[0].Status).To(Equal("created"))
Expect(res.Events[0].Id).NotTo(BeEmpty())
})
It("Should record batch events", func() {
req := map[string]interface{}{
"events": []map[string]interface{}{
{
"meterId": meterId,
"userId": "acme/alice",
"value": int64(500),
},
{
"meterId": meterId,
"userId": "acme/alice",
"value": int64(1000),
},
},
}
res := &eventsResponse{}
cl.Post("/billing/meter-events", req, res)
Expect(res.Count).To(Equal(2))
})
It("Should deduplicate events by idempotency key", func() {
req := map[string]interface{}{
"events": []map[string]interface{}{
{
"meterId": meterId,
"userId": "acme/alice",
"value": int64(999),
"idempotency": "dedup-key-1",
},
},
}
// First request
res := &eventsResponse{}
cl.Post("/billing/meter-events", req, res)
Expect(res.Events[0].Status).To(Equal("created"))
// Second request with same key
res2 := &eventsResponse{}
cl.Post("/billing/meter-events", req, res2)
Expect(res2.Events[0].Status).To(Equal("duplicate"))
})
It("Should fail with empty events array", func() {
req := map[string]interface{}{
"events": []map[string]interface{}{},
}
res := &ApiError{}
cl.Post("/billing/meter-events", req, res)
Expect(res.Error.Message).To(ContainSubstring("at least one event"))
})
It("Should fail when meterId is missing", func() {
req := map[string]interface{}{
"events": []map[string]interface{}{
{
"userId": "acme/alice",
"value": int64(100),
},
},
}
res := &ApiError{}
cl.Post("/billing/meter-events", req, res)
Expect(res.Error.Message).To(ContainSubstring("meterId is required"))
})
It("Should fail when userId is missing", func() {
req := map[string]interface{}{
"events": []map[string]interface{}{
{
"meterId": meterId,
"value": int64(100),
},
},
}
res := &ApiError{}
cl.Post("/billing/meter-events", req, res)
Expect(res.Error.Message).To(ContainSubstring("userId is required"))
})
It("Should get aggregated summary (sum)", func() {
res := &summaryResponse{}
cl.Get("/billing/meter-events/summary?meterId="+meterId+"&userId=acme/alice", res)
Expect(res.MeterId).To(Equal(meterId))
Expect(res.MeterName).To(Equal("Input Tokens"))
Expect(res.AggregationType).To(Equal("sum"))
// 1500 + 500 + 1000 + 999 = 3999
Expect(res.Value).To(BeNumerically(">=", 3999))
Expect(res.EventCount).To(BeNumerically(">=", 4))
})
It("Should fail summary without meterId", func() {
res := &ApiError{}
cl.Get("/billing/meter-events/summary?userId=acme/alice", res)
Expect(res.Error.Message).To(ContainSubstring("meterId"))
})
It("Should return 404 for summary with bad meterId", func() {
res := &ApiError{}
cl.Get("/billing/meter-events/summary?meterId=bad-id", res)
Expect(res.Error.Message).To(ContainSubstring("not found"))
})
})
// ─── CREDIT GRANTS ────────────────────────────────────────────────
Context("Credit Grants", func() {
It("Should create a credit grant", func() {
req := map[string]interface{}{
"userId": "acme/alice",
"name": "Starter Credit",
"amountCents": int64(500),
"currency": "usd",
"priority": 0,
"tags": "starter",
"expiresIn": "720h",
}
res := &creditGrantResponse{}
cl.Post("/billing/credits", req, res)
Expect(res.Id).NotTo(BeEmpty())
Expect(res.UserId).To(Equal("acme/alice"))
Expect(res.Name).To(Equal("Starter Credit"))
Expect(res.AmountCents).To(Equal(int64(500)))
Expect(res.RemainingCents).To(Equal(int64(500)))
Expect(res.Currency).To(Equal("usd"))
Expect(res.Priority).To(Equal(0))
creditGrantId = res.Id
})
It("Should create a second grant with lower priority", func() {
req := map[string]interface{}{
"userId": "acme/alice",
"name": "Promo $10",
"amountCents": int64(1000),
"currency": "usd",
"priority": 1,
"tags": "promo",
}
res := &creditGrantResponse{}
cl.Post("/billing/credits", req, res)
Expect(res.Id).NotTo(BeEmpty())
Expect(res.AmountCents).To(Equal(int64(1000)))
Expect(res.Priority).To(Equal(1))
})
It("Should fail to create grant without userId", func() {
req := map[string]interface{}{
"name": "Bad Grant",
"amountCents": int64(100),
}
res := &ApiError{}
cl.Post("/billing/credits", req, res)
Expect(res.Error.Message).To(ContainSubstring("userId is required"))
})
It("Should fail to create grant with zero amount", func() {
req := map[string]interface{}{
"userId": "acme/alice",
"amountCents": int64(0),
}
res := &ApiError{}
cl.Post("/billing/credits", req, res)
Expect(res.Error.Message).To(ContainSubstring("amountCents must be positive"))
})
It("Should fail with invalid expiresIn duration", func() {
req := map[string]interface{}{
"userId": "acme/alice",
"name": "Bad Expiry",
"amountCents": int64(100),
"expiresIn": "not-a-duration",
}
res := &ApiError{}
cl.Post("/billing/credits", req, res)
Expect(res.Error.Message).To(ContainSubstring("invalid expiresIn"))
})
It("Should list grants for a user", func() {
res := &creditGrantListResponse{}
cl.Get("/billing/credits?userId=acme/alice", res)
Expect(res.Count).To(BeNumerically(">=", 2))
foundStarter := false
foundPromo := false
for _, g := range res.Grants {
if g.Name == "Starter Credit" {
foundStarter = true
}
if g.Name == "Promo $10" {
foundPromo = true
}
}
Expect(foundStarter).To(BeTrue())
Expect(foundPromo).To(BeTrue())
})
It("Should fail list without userId", func() {
res := &ApiError{}
cl.Get("/billing/credits", res)
Expect(res.Error.Message).To(ContainSubstring("userId"))
})
It("Should get credit balance", func() {
res := &creditBalanceResponse{}
cl.Get("/billing/credit-balance?userId=acme/alice", res)
Expect(res.UserId).To(Equal("acme/alice"))
Expect(res.Balances).NotTo(BeEmpty())
var usdBalance int64
for _, b := range res.Balances {
if b.Currency == "usd" {
usdBalance = b.Available
}
}
// 500 + 1000 = 1500
Expect(usdBalance).To(Equal(int64(1500)))
})
It("Should void a credit grant", func() {
res := &map[string]interface{}{}
cl.Post("/billing/credits/"+creditGrantId+"/void", nil, res)
Expect((*res)["voided"]).To(Equal(true))
})
It("Should fail to void already voided grant", func() {
res := &ApiError{}
cl.Post("/billing/credits/"+creditGrantId+"/void", nil, res)
Expect(res.Error.Message).To(ContainSubstring("already voided"))
})
It("Should fail to void non-existent grant", func() {
res := &ApiError{}
cl.Post("/billing/credits/nonexistent/void", nil, res)
Expect(res.Error.Message).To(ContainSubstring("not found"))
})
It("Should reflect voided grant in balance", func() {
res := &creditBalanceResponse{}
cl.Get("/billing/credit-balance?userId=acme/alice", res)
var usdBalance int64
for _, b := range res.Balances {
if b.Currency == "usd" {
usdBalance = b.Available
}
}
// Only promo remains: 1000
Expect(usdBalance).To(Equal(int64(1000)))
})
})
// ─── PRICING RULES ───────────────────────────────────────────────
Context("Pricing Rules", func() {
It("Should create a per-unit pricing rule", func() {
req := map[string]interface{}{
"meterId": meterId,
"model": "per_unit",
"currency": "usd",
"unitPrice": int64(1), // 1 cent per unit
}
res := &pricingRuleResponse{}
cl.Post("/billing/pricing-rules", req, res)
Expect(res.Id).NotTo(BeEmpty())
Expect(res.MeterId).To(Equal(meterId))
Expect(res.Model).To(Equal("per_unit"))
Expect(res.UnitPrice).To(Equal(int64(1)))
pricingRuleId = res.Id
})
It("Should fail to create rule without meterId", func() {
req := map[string]interface{}{
"model": "per_unit",
"unitPrice": int64(1),
}
res := &ApiError{}
cl.Post("/billing/pricing-rules", req, res)
Expect(res.Error.Message).To(ContainSubstring("meterId is required"))
})
It("Should list pricing rules", func() {
res := &pricingRuleListResponse{}
cl.Get("/billing/pricing-rules", res)
Expect(res.Count).To(BeNumerically(">=", 1))
})
It("Should filter pricing rules by meterId", func() {
res := &pricingRuleListResponse{}
cl.Get("/billing/pricing-rules?meterId="+meterId, res)
Expect(res.Count).To(BeNumerically(">=", 1))
for _, r := range res.Rules {
Expect(r.MeterId).To(Equal(meterId))
}
})
It("Should delete a pricing rule", func() {
// Create a throwaway rule to delete
createReq := map[string]interface{}{
"meterId": meterId,
"model": "per_unit",
"currency": "usd",
"unitPrice": int64(99),
}
createRes := &pricingRuleResponse{}
cl.Post("/billing/pricing-rules", createReq, createRes)
Expect(createRes.Id).NotTo(BeEmpty())
// Delete it (handler returns 200)
deleteRes := &map[string]interface{}{}
cl.Delete("/billing/pricing-rules/"+createRes.Id, deleteRes, 200)
Expect((*deleteRes)["deleted"]).To(Equal(true))
})
It("Should fail to delete non-existent rule", func() {
w := cl.Delete("/billing/pricing-rules/nonexistent-id")
Expect(w.Code).To(Equal(404))
})
})
// ─── INVOICE PREVIEW ──────────────────────────────────────────────
Context("Invoice Preview", func() {
It("Should calculate invoice preview with usage and credits", func() {
req := map[string]interface{}{
"userId": "acme/alice",
"periodStart": "2020-01-01",
"periodEnd": "2099-12-31",
}
res := &invoicePreviewResponse{}
cl.Post("/billing/invoice-preview", req, res)
Expect(res.UserId).To(Equal("acme/alice"))
Expect(res.Currency).To(Equal("usd"))
// Should have line items from meter events
Expect(len(res.LineItems)).To(BeNumerically(">=", 1))
// Subtotal should be usage * pricing
Expect(res.Subtotal).To(BeNumerically(">", 0))
// Credits should be applied
Expect(res.CreditApplied).To(BeNumerically(">=", 0))
// Amount due should be subtotal - credits (but >= 0)
Expect(res.AmountDue).To(BeNumerically(">=", 0))
Expect(res.Subtotal).To(Equal(res.CreditApplied + res.AmountDue))
})
It("Should fail without userId", func() {
req := map[string]interface{}{
"periodStart": "2026-02-01",
"periodEnd": "2026-03-01",
}
res := &ApiError{}
cl.Post("/billing/invoice-preview", req, res)
Expect(res.Error.Message).To(ContainSubstring("userId is required"))
})
It("Should return empty preview for user with no usage", func() {
req := map[string]interface{}{
"userId": "acme/nobody",
"periodStart": "2026-01-01",
"periodEnd": "2026-02-01",
}
res := &invoicePreviewResponse{}
cl.Post("/billing/invoice-preview", req, res)
Expect(res.Subtotal).To(Equal(int64(0)))
Expect(res.CreditApplied).To(Equal(int64(0)))
Expect(res.AmountDue).To(Equal(int64(0)))
})
})
// ─── EXISTING ENDPOINTS (balance, usage, deposit) ─────────
Context("Existing Billing Endpoints", func() {
It("Should record usage via POST /billing/usage", func() {
req := map[string]interface{}{
"user": "acme/bob",
"currency": "usd",
"amount": int64(25),
"model": "gpt-4",
"provider": "openai",
"promptTokens": 100,
"completionTokens": 50,
"totalTokens": 150,
"requestId": "req-test-1",
}
res := &map[string]interface{}{}
cl.Post("/billing/usage", req, res)
Expect((*res)["transactionId"]).NotTo(BeEmpty())
Expect((*res)["type"]).To(Equal("withdraw"))
})
It("Should record zero-cost usage as a row", func() {
req := map[string]interface{}{
"user": "acme/bob",
"amount": int64(0),
"costMicros": int64(700),
"paidBy": "plan",
}
res := &map[string]interface{}{}
cl.Post("/billing/usage", req, res)
Expect((*res)["transactionId"]).NotTo(BeEmpty())
Expect((*res)["amount"]).To(BeNumerically("==", 0))
Expect((*res)["costMicros"]).To(BeNumerically("==", 700))
})
It("Should create a deposit", func() {
req := map[string]interface{}{
"user": "acme/bob",
"currency": "usd",
"amount": int64(1000),
"notes": "Test deposit",
"tags": "test",
}
res := &map[string]interface{}{}
// Money in names the event that caused it: the credit rides the
// settlement id, so a retried webhook is the SAME request and can
// not fund the wallet twice.
withIdem := cl.WithHeaders(nethttp.Header{"X-Idempotency-Key": {"settlement-test-deposit-1"}})
withIdem.Post("/billing/deposit", req, res)
Expect((*res)["transactionId"]).NotTo(BeEmpty())
Expect((*res)["type"]).To(Equal("deposit"))
})
It("Should refuse a deposit that names no cause", func() {
// The guard above is the whole reason a credit is safe to retry, so
// the suite asserts it directly rather than trusting the happy path.
req := map[string]interface{}{
"user": "acme/bob",
"currency": "usd",
"amount": int64(1000),
}
w := cl.PostJSON("/billing/deposit", req)
Expect(w.Code).To(Equal(400))
})
It("Should append an org credit grant (the ONE mint-gated primitive)", func() {
// POST /billing/credit is the ONLY way credit enters an org ledger.
// It is org-keyed (credit the ACCOUNT/pool, not a per-human subject)
// and mint-gated; the suite calls it as the internal service token —
// the production caller (cloud-api). "starter" is just these params.
w := cl.PostJSON("/billing/credit", map[string]interface{}{
"org": org.Name,
"amountCents": 500,
"reason": "welcome",
"tag": "starter-credit",
})
Expect(w.Code).To(Equal(201))
})
It("Should be idempotent on idempotencyKey — a repeat is one grant", func() {
// Same idempotencyKey twice ⇒ AT MOST ONE grant; the second call
// replays the first grant verbatim (200), never a double-mint.
body := map[string]interface{}{
"org": org.Name,
"amountCents": 250,
"reason": "promo",
"idempotencyKey": "promo-2026-once",
}
first := cl.PostJSON("/billing/credit", body)
Expect(first.Code).To(Equal(201))
second := cl.PostJSON("/billing/credit", body)
Expect(second.Code).To(Equal(200)) // replayed existing grant, no second credit
})
})
// ─── INCLUDED MONTHLY ALLOTMENT ──────────────────────────────────
// Proves the plan included-usage credit lands on the prepaid balance so
// the gateway gate (available > 0) honors it, is idempotent per month,
// and surfaces correctly through the usage rollup.
// Ordered: a report→grant→balance→rollup sequence sharing one user/plan.
Describe("included monthly allotment", Ordered, func() {
const user = "acme/allotuser"
// A plan the published catalog credits with included cloud credit
// (cloud.included_credits_usd), read from the catalog rather than named:
// @hanzo/plans credits none, so these specs skip until one does.
plan := allotmentPlan(true)
// The plan's DECLARED included amount, read from the catalog — never a
// hardcoded dollar figure (that brittleness is what broke these tests when
// the catalog drifted). Every downstream surface must equal it.
var wantCents float64
Before(func() {
if plan == "" {
Skip("the published catalog credits no plan with included cloud credit")
}
})
It("Should report the catalog included amount before any grant", func() {
res := &map[string]interface{}{}
cl.Get("/billing/usage/rollup?user="+user+"&plan="+plan, res)
included := (*res)["included"].(map[string]interface{})
wantCents = included["monthlyCents"].(float64)
// A credited plan declares a positive monthly allotment.
Expect(wantCents).To(BeNumerically(">", 0))
// Nothing granted yet this period.
Expect(included["grantedCents"]).To(BeEquivalentTo(0))
})
It("Should grant the plan's included allotment", func() {
w := cl.PostJSON("/billing/allotment/grant", map[string]interface{}{
"user": user,
"plan": plan,
})
Expect(w.Code).To(Equal(201))
})
It("Should reflect the allotment in the gate-relevant balance", func() {
// /billing/balance is the exact endpoint the gateway prepaid gate
// reads; available must now equal the plan's declared included credit.
res := &map[string]interface{}{}
cl.Get("/billing/balance?user="+user+"&currency=usd", res)
Expect((*res)["available"]).To(BeEquivalentTo(wantCents))
})
It("Should be idempotent within the same month", func() {
w := cl.PostJSON("/billing/allotment/grant", map[string]interface{}{
"user": user,
"plan": plan,
})
Expect(w.Code).To(Equal(200)) // already granted -> not 201
})
It("Should surface included/consumed/overage in the rollup", func() {
res := &map[string]interface{}{}
cl.Get("/billing/usage/rollup?user="+user+"&plan="+plan, res)
included := (*res)["included"].(map[string]interface{})
Expect(included["grantedCents"]).To(BeEquivalentTo(wantCents))
Expect(included["remainingCents"]).To(BeEquivalentTo(wantCents))
// No usage recorded for this user -> no overage.
Expect((*res)["overageCents"]).To(BeEquivalentTo(0))
balance := (*res)["balance"].(map[string]interface{})
Expect(balance["availableCents"]).To(BeEquivalentTo(wantCents))
})
})
It("Should grant nothing for a plan with no included allotment", func() {
// A zero-allotment plan mints nothing. Which slug that is comes from the
// catalog, and the rollup must agree it declares zero before the grant is
// asserted to be a no-op.
plan := allotmentPlan(false)
Expect(plan).NotTo(BeEmpty())
roll := &map[string]interface{}{}
cl.Get("/billing/usage/rollup?user=acme/allotnone&plan="+plan, roll)
included := (*roll)["included"].(map[string]interface{})
Expect(included["monthlyCents"]).To(BeEquivalentTo(0))
w := cl.PostJSON("/billing/allotment/grant", map[string]interface{}{
"user": "acme/allotnone",
"plan": plan,
})
Expect(w.Code).To(Equal(200)) // no_included_allotment -> not granted
})
})
// allotmentPlan is the first catalog plan whose included cloud-credit allotment
// is positive (credited) or zero (!credited), or "" when there is none.
func allotmentPlan(credited bool) string {
for _, p := range billingApi.StaticPlans() {
if (billingApi.IncludedMonthlyCents(p.Slug) > 0) == credited {
return p.Slug
}
}
return ""
}