Files
commerce/api/billing/seats_test.go
zooqueenandHanzo Dev c91ebb4a47 billing: a wallet sale asks Square again instead of guessing, and seats are bounded
An authorization with no answer is sent again under its key; a capture with
no answer is settled by reading the payment back, taking one still held. Only
a payment Square says it did not take lets the attempt go; one that stays
unknown seals the attempt (502), so its key never pays twice, and is named
for reconciliation. Everything after the authorization runs without the
buyer's request. Square's own 4xx is a processor.Answer; anything else may be
a call that never got one. A first invoice that is not written is tried twice
and reconciled. Nothing charged and the sale cannot go on now is 503.

Seats are at most a per-seat plan's maxMembers, else 10000, on subscribe,
create and PATCH, so price times seats never wraps.

A subscription's plan snapshot carries its catalog slug, so a reader can
match it to GET /v1/billing/plans.

Co-authored-by: Hanzo Dev <[email protected]>
2026-10-15 22:26:31 -07:00

229 lines
9.0 KiB
Go

// Copyright (c) 2014-present Hanzo AI, Inc.
// Licensed under MIT OR Apache-2.0. See LICENSE-MIT and LICENSE-APACHE.
package billing
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/zap-proto/zip"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/models/organization"
"github.com/hanzoai/commerce/models/subscription"
"github.com/hanzoai/commerce/util/test/ae"
)
// TestPlans_TeamCommercialModel — GET /v1/billing/plans serves the hanzo.team
// commercial model in cents: pro $20, plus $100, max $200, team $25/user with
// per-seat billing, minSeats 2, and the teamGuests limit on paid personal tiers.
func TestPlans_TeamCommercialModel(t *testing.T) {
a := zip.New(zip.Config{DisableStartupMessage: true})
a.Raw(http.MethodGet, "/v1/billing/plans", ListPlans)
resp, err := a.Test(httptest.NewRequest(http.MethodGet, "/v1/billing/plans", nil))
if err != nil || resp.StatusCode != 200 {
t.Fatalf("GET plans: err=%v status=%d", err, resp.StatusCode)
}
raw, _ := io.ReadAll(resp.Body)
var plans []staticPlan
if err := json.Unmarshal(raw, &plans); err != nil {
t.Fatalf("decode plans: %v", err)
}
bySlug := indexBySlug(plans)
for slug, cents := range map[string]int64{"free": 0, "dev": 2000, "max-5x": 10000, "max-20x": 20000, "team": 2500} {
p, ok := bySlug[slug]
if !ok {
t.Fatalf("plan %q missing from GET /v1/billing/plans", slug)
}
if p.Price != cents {
t.Errorf("plan %q price = %d cents, want %d", slug, p.Price, cents)
}
}
team := bySlug["team"]
if !team.PerSeat {
t.Error("team must serve perSeat=true (price_ref.recurring.per_seat)")
}
if team.Limits == nil || team.Limits.MinSeats == nil || *team.Limits.MinSeats != 2 {
t.Error("team must serve limits.minSeats=2")
}
if team.Limits.IncludedCloudCreditsPerUser != nil {
t.Error("team must not serve limits.includedCloudCreditsPerUser — a seat covers AI, it mints no credit")
}
dev := bySlug["dev"]
if dev.Limits == nil || dev.Limits.TeamGuests == nil || *dev.Limits.TeamGuests != 3 {
t.Error("dev must serve limits.teamGuests=3 (team.guests back-compat source)")
}
// The wire carries a real annual discount, not a copy of the monthly price —
// stated as a relationship so it survives a reprice and still fails the thing
// that actually went wrong once: annual silently equal to monthly.
if dev.annual() <= 0 || dev.annual() >= dev.Price {
t.Errorf("dev priceAnnual = %d cents, monthly = %d; annual must be a discount", dev.annual(), dev.Price)
}
}
// invokeSubPatch drives UpdateBillingSubscription through real routing.
func invokeSubPatch(org *organization.Organization, ctx context.Context, identity func(*zip.Ctx), id, body string) *http.Response {
req := httptest.NewRequest(http.MethodPatch, "/v1/billing/subscriptions/"+id, bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
return driveSeeded(func(c *zip.Ctx) {
c.Locals("organization", org)
c.SetContext(ctx)
identity(c)
}, "/v1/billing/subscriptions/:id", req, UpdateBillingSubscription)
}
func bodyOf(r *http.Response) string { b, _ := io.ReadAll(r.Body); return string(b) }
// liveMemberSubs returns member's live bundle-child subscriptions.
func liveMemberSubs(t *testing.T, db *datastore.Datastore, member string) []*subscription.Subscription {
t.Helper()
subs := make([]*subscription.Subscription, 0)
if _, err := subscription.Query(db).Filter("UserId=", member).GetAll(&subs); err != nil {
t.Fatalf("query member subs: %v", err)
}
out := subs[:0]
for _, s := range subs {
if s.ProviderType != "bundle" {
continue
}
switch s.Status {
case subscription.Active, subscription.Trialing:
out = append(out, s)
}
}
return out
}
// TestCreateSubscription_TeamBelowMinSeats_Rejected — the catalog's
// limits.minSeats (team: 2) is enforced on create: quantity below the floor
// (including the default 1 when omitted) is a clear 4xx, even for a mint
// principal.
func TestCreateSubscription_TeamBelowMinSeats_Rejected(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("seatsmin")
for _, body := range []string{
`{"userId":"seatsmin/owner","planId":"team","quantity":1}`,
`{"userId":"seatsmin/owner","planId":"team"}`,
} {
w := invokeSub(org, ctx, c1MintPrincipal, CreateBillingSubscription, body)
if w.StatusCode != http.StatusBadRequest {
t.Fatalf("create team below minSeats: status=%d body=%s, want 400 (%s)", w.StatusCode, bodyOf(w), body)
}
}
}
// TestCreateSubscription_TeamSeats_MembersProvisioned — a per-seat team
// subscription records its quantity, and each member gets ONE zero-price
// bundle-child row (the allotment run's per-user grant anchor). Members can
// never exceed seats.
func TestCreateSubscription_TeamSeats_MembersProvisioned(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("seatsteam")
db := datastore.New(org.Namespaced(ctx))
// Members above seats is refused outright — member rows mint per-user
// credit, so they are bounded by the seats actually paid for.
over := `{"userId":"seatsteam/owner","planId":"team","quantity":2,"members":["seatsteam/a","seatsteam/b","seatsteam/c"]}`
if w := invokeSub(org, ctx, c1MintPrincipal, CreateBillingSubscription, over); w.StatusCode != http.StatusBadRequest {
t.Fatalf("members>seats: status=%d body=%s, want 400", w.StatusCode, bodyOf(w))
}
body := `{"userId":"seatsteam/owner","planId":"team","quantity":3,"members":["seatsteam/alice","seatsteam/bob"]}`
w := invokeSub(org, ctx, c1MintPrincipal, CreateBillingSubscription, body)
if w.StatusCode != 201 {
t.Fatalf("create team: status=%d body=%s, want 201", w.StatusCode, bodyOf(w))
}
var created map[string]any
if err := json.Unmarshal([]byte(bodyOf(w)), &created); err != nil {
t.Fatalf("decode create response: %v", err)
}
if q, _ := created["quantity"].(float64); int(q) != 3 {
t.Fatalf("created quantity = %v, want 3", created["quantity"])
}
// Each member holds exactly one live zero-price bundle-child row; the
// per-user allotment amount for team is $100/mo.
for _, m := range []string{"seatsteam/alice", "seatsteam/bob"} {
kids := liveMemberSubs(t, db, m)
if len(kids) != 1 {
t.Fatalf("member %s bundle-child rows = %d, want 1", m, len(kids))
}
if kids[0].Plan.Price != 0 {
t.Fatalf("member %s child plan price = %d, want 0 (parent invoice carries the charge)", m, kids[0].Plan.Price)
}
if kids[0].Plan.Slug != "team" {
t.Fatalf("member %s child plan slug = %q, want team", m, kids[0].Plan.Slug)
}
}
if cents := listCents("team"); cents != 2500 {
t.Fatalf("listCents(team) = %d, want 2500 ($25 a seat a month)", cents)
}
}
// TestUpdateSubscription_SeatFloorAndMemberIdempotency — PATCH enforces the
// same catalog seat floor, and re-sent members are never duplicated while new
// members gain their row.
func TestUpdateSubscription_SeatFloorAndMemberIdempotency(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("seatspatch")
db := datastore.New(org.Namespaced(ctx))
w := invokeSub(org, ctx, c1MintPrincipal, CreateBillingSubscription,
`{"userId":"seatspatch/owner","planId":"team","quantity":2,"members":["seatspatch/alice"]}`)
if w.StatusCode != 201 {
t.Fatalf("create team: status=%d body=%s, want 201", w.StatusCode, bodyOf(w))
}
var created map[string]any
if err := json.Unmarshal([]byte(bodyOf(w)), &created); err != nil {
t.Fatalf("decode create response: %v", err)
}
id, _ := created["id"].(string)
if id == "" {
t.Fatal("create response has no id")
}
// Below the floor → 400, subscription unchanged.
if r := invokeSubPatch(org, ctx, c1MintPrincipal, id, `{"quantity":1}`); r.StatusCode != http.StatusBadRequest {
t.Fatalf("PATCH quantity=1 on team: status=%d body=%s, want 400", r.StatusCode, bodyOf(r))
}
// Above the most seats the plan holds → 400, so a renewal never multiplies a
// price by a count that wraps.
for _, q := range []string{"101", "7378697629483821"} {
if r := invokeSubPatch(org, ctx, c1MintPrincipal, id, `{"quantity":`+q+`}`); r.StatusCode != http.StatusBadRequest {
t.Fatalf("PATCH quantity=%s on team: status=%d body=%s, want 400", q, r.StatusCode, bodyOf(r))
}
}
// Members above the effective seats → 400.
if r := invokeSubPatch(org, ctx, c1MintPrincipal, id,
`{"quantity":2,"members":["seatspatch/alice","seatspatch/bob","seatspatch/carol"]}`); r.StatusCode != http.StatusBadRequest {
t.Fatalf("PATCH members>seats: status=%d body=%s, want 400", r.StatusCode, bodyOf(r))
}
// Grow to 3 seats, re-sending alice and adding bob: alice keeps exactly
// one row (idempotent), bob gains one.
r := invokeSubPatch(org, ctx, c1MintPrincipal, id,
`{"quantity":3,"members":["seatspatch/alice","seatspatch/bob"]}`)
if r.StatusCode != 200 {
t.Fatalf("PATCH grow seats: status=%d body=%s, want 200", r.StatusCode, bodyOf(r))
}
for _, m := range []string{"seatspatch/alice", "seatspatch/bob"} {
if kids := liveMemberSubs(t, db, m); len(kids) != 1 {
t.Fatalf("member %s bundle-child rows = %d, want exactly 1 (idempotent)", m, len(kids))
}
}
}