Files
commerce/api/billing/ecosystem_test.go
89a7c70a89 A member subscribes only their own account, and the edge strips every authority header
A card subscription took any in-org userId, so a member could open a plan for
another member and pay with that member's credits or balance. A caller who is
not an org admin, SuperAdmin or service now subscribes their own account
whatever userId names; the org's own account stays its admins'.

EdgeAuth's strip list is authz.Headers plus authz.Retired, so X-User-IsOrgAdmin
and X-Billing-Account-Id can no longer be set by a client; X-User-IsOrgAdmin is
minted only from a verified token whose own org the request acts in.

Every site the ecosystem rule decides has a test that fails under the old
prefix rule, including red's repro tests.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-23 21:54:41 -07:00

274 lines
9.4 KiB
Go

package billing
import (
"io"
"net/http"
"strings"
"testing"
"github.com/hanzoai/commerce/billing/tier"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/models/subscription"
"github.com/hanzoai/commerce/util/test/ae"
)
func TestPaidEcosystemOrgs_Entitlements(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
orgs := []string{"admin", "hanzo", "lux", "zoo", "adnexus", "bootnode", "osage", "pars"}
for _, orgName := range orgs {
t.Run(orgName, func(t *testing.T) {
org := moneyOrg(orgName)
tv, err := ReadTier(ctx, org, orgName, tier.Free)
if err != nil {
t.Fatalf("ReadTier error for %s: %v", orgName, err)
}
if tv.Tier.Name != tier.Enterprise {
t.Errorf("tier name = %s, want %s", tv.Tier.Name, tier.Enterprise)
}
if !tv.Tier.UnlimitedAgents {
t.Errorf("expected unlimitedAgents=true for %s", orgName)
}
if len(tv.Tier.AllowedModels) == 0 || tv.Tier.AllowedModels[0] != "*" {
t.Errorf("expected allowedModels=['*'] for %s, got %v", orgName, tv.Tier.AllowedModels)
}
if tv.Balance.EffectiveAvailable < DefaultEcosystemCreditCents {
t.Errorf("effectiveAvailable = %d, want at least %d", tv.Balance.EffectiveAvailable, DefaultEcosystemCreditCents)
}
// TierOf check
resolvedTier, err := TierOf(ctx, org, orgName)
if err != nil {
t.Fatalf("TierOf error for %s: %v", orgName, err)
}
if resolvedTier != tier.Enterprise {
t.Errorf("TierOf = %s, want %s", resolvedTier, tier.Enterprise)
}
})
}
}
func TestPaidEcosystemOrgs_EnsureCreditsTopup(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("hanzo")
db := datastore.New(org.Namespaced(ctx))
// Initial grant
if err := EnsureEcosystemCredits(ctx, db, "hanzo"); err != nil {
t.Fatalf("EnsureEcosystemCredits failed: %v", err)
}
grants, err := getActiveGrants(db, "hanzo")
if err != nil {
t.Fatalf("getActiveGrants failed: %v", err)
}
var total int64
for _, g := range grants {
total += g.RemainingCents
}
if total != DefaultEcosystemCreditCents {
t.Fatalf("total credits = %d, want %d", total, DefaultEcosystemCreditCents)
}
// Burn 30000 credits
rem, err := BurnCredits(db, "hanzo", 30000, "")
if err != nil || rem != 0 {
t.Fatalf("BurnCredits failed: rem=%d, err=%v", rem, err)
}
// Verify balance after burn
grantsAfter, _ := getActiveGrants(db, "hanzo")
t.Logf("grantsAfter count=%d", len(grantsAfter))
for idx, g := range grantsAfter {
t.Logf("grant %d: id=%s amount=%d remaining=%d", idx, g.Id(), g.AmountCents, g.RemainingCents)
}
var remaining int64
for _, g := range grantsAfter {
remaining += g.RemainingCents
}
if remaining != 70000 {
t.Fatalf("remaining credits = %d, want 70000", remaining)
}
// Topup back to 100000
if err := EnsureEcosystemCredits(ctx, db, "hanzo"); err != nil {
t.Fatalf("EnsureEcosystemCredits topup failed: %v", err)
}
grantsTopup, _ := getActiveGrants(db, "hanzo")
var toppedUpTotal int64
for _, g := range grantsTopup {
toppedUpTotal += g.RemainingCents
}
if toppedUpTotal != DefaultEcosystemCreditCents {
t.Fatalf("after topup credits = %d, want %d", toppedUpTotal, DefaultEcosystemCreditCents)
}
}
func TestPaidEcosystemOrgs_SubscribeWithCredits_PerSeat(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("lux")
// team = 2500c/mo per seat, minSeats 2. quantity 2 -> 5000c
resp := invokeSubscribeCard(org, ctx, `{"sourceId":"credits","planId":"team","quantity":2}`, nil)
if resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("status=%d body=%s, want 201", resp.StatusCode, string(body))
}
out := jsonBody(t, resp)
if out["amountCents"].(float64) != 5000 {
t.Fatalf("amountCents = %v, want 5000", out["amountCents"])
}
if out["paymentMethodId"] != "credits" {
t.Fatalf("paymentMethodId = %v, want credits", out["paymentMethodId"])
}
db := datastore.New(org.Namespaced(ctx))
sub := parentSub(t, db, "lux", "team")
if sub == nil {
t.Fatal("no team subscription created for lux")
}
if sub.ProviderType != "credit" {
t.Fatalf("sub ProviderType = %s, want credit", sub.ProviderType)
}
invs := invoicesForSub(t, db, sub.Id())
if len(invs) != 1 {
t.Fatalf("invoices count = %d, want 1", len(invs))
}
if invs[0].AmountDue != 5000 || invs[0].AmountPaid != 5000 {
t.Fatalf("invoice amountDue=%d amountPaid=%d, want 5000/5000", invs[0].AmountDue, invs[0].AmountPaid)
}
}
func TestPaidEcosystemOrgs_SubscribeNoCardNeeded(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("zoo")
// Ecosystem partner org subscribing with no card nonce at all -> defaults to operating credits!
resp := invokeSubscribeCard(org, ctx, `{"planId":"team","quantity":2}`, nil)
if resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("status=%d body=%s, want 201", resp.StatusCode, string(body))
}
out := jsonBody(t, resp)
if out["paymentMethodId"] != "credits" {
t.Fatalf("paymentMethodId = %v, want credits", out["paymentMethodId"])
}
db := datastore.New(org.Namespaced(ctx))
sub := parentSub(t, db, "zoo", "team")
if sub == nil {
t.Fatal("no team subscription created for zoo")
}
if sub.ProviderType != "credit" {
t.Fatalf("sub ProviderType = %s, want credit", sub.ProviderType)
}
}
func TestSquareSandbox_SubscribeWithCardNonceOk(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("acme-square-sandbox")
m := squareMock("cust_sandbox", "ccof_sandbox", "sqpay_sandbox_123")
withFakeSquare(t, m)
// Using Square sandbox test card nonce "cnon:card-nonce-ok"
resp := invokeSubscribeCard(org, ctx, `{"sourceId":"cnon:card-nonce-ok","planId":"team","quantity":2}`, nil)
if resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("status=%d body=%s, want 201", resp.StatusCode, string(body))
}
out := jsonBody(t, resp)
if out["amountCents"].(float64) != 5000 {
t.Fatalf("amountCents = %v, want 5000", out["amountCents"])
}
if m.lastChargeAmount != 5000 {
t.Fatalf("charged amount = %d, want 5000", m.lastChargeAmount)
}
}
// A person's wallet inside an ecosystem org is a customer's: it is tiered by its own
// subscriptions, its balance is its own money, and nothing tops it up. Only the org's
// own account — the payer IAM names for the org's machines, owners and admins — is
// Enterprise.
func TestAPersonInAnEcosystemOrgIsTieredByTheirOwnPlan(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
for _, orgName := range []string{"hanzo", "lux", "zoo", "admin"} {
t.Run(orgName, func(t *testing.T) {
org := moneyOrg(orgName)
person := orgName + "/alice"
if got, err := TierOf(ctx, org, person); err != nil || got != tier.Free {
t.Fatalf("TierOf(%s) = %s,%v, want free", person, got, err)
}
tv, err := ReadTier(ctx, org, person, tier.Free)
if err != nil {
t.Fatal(err)
}
if tv.Tier.Name != tier.Free || tv.Balance.EffectiveAvailable != 0 {
t.Errorf("ReadTier(%s): tier %s effectiveAvailable %d, want free and 0", person, tv.Tier.Name, tv.Balance.EffectiveAvailable)
}
if len(tv.Tier.AllowedModels) == 1 && tv.Tier.AllowedModels[0] == "*" {
t.Errorf("ReadTier(%s) allows every model", person)
}
db := datastore.New(org.Namespaced(ctx))
if err := EnsureEcosystemCredits(ctx, db, person); err != nil {
t.Fatal(err)
}
if grants, _ := getActiveGrants(db, person); len(grants) != 0 {
t.Errorf("%s was granted ecosystem credit: %d grant(s)", person, len(grants))
}
if got, _ := TierOf(ctx, org, orgName); got != tier.Enterprise {
t.Errorf("the org's own account %s = %s, want enterprise", orgName, got)
}
})
}
if IsEcosystemAccount(moneyOrg("acme"), "hanzo") {
t.Error("an ecosystem slug read inside another org's books is not that org's account")
}
}
// The org's own account is subscribed by its admins; a plain member is refused, and
// in an ecosystem org that is what keeps a member from buying the org a plan with
// no card. A member naming another member subscribes only their own account.
func TestOnlyAnOrgAdminSubscribesTheOrgAccount(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("hanzo")
member := map[string]string{"X-User-IsOrgAdmin": "false", "X-User-Id": "alice"}
// With no userId a member of the signup org subscribes their OWN account, which
// is a customer's: it needs a card, and the org account gets nothing.
resp := invokeSubscribeCard(org, ctx, `{"planId":"max-20x"}`, member)
if resp.StatusCode < 400 {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("a member subscribed with no card: %d %s", resp.StatusCode, body)
}
resp = invokeSubscribeCard(org, ctx, `{"planId":"max-20x","userId":"hanzo/bob","sourceId":"credits"}`, member)
body, _ := io.ReadAll(resp.Body)
if strings.Contains(string(body), "hanzo/bob") {
t.Fatalf("a member acted on another member's account: %d %s", resp.StatusCode, body)
}
db := datastore.New(org.Namespaced(ctx))
for _, who := range []string{"hanzo/bob", "hanzo"} {
if subs, _ := subscription.Query(db).Filter("UserId=", who).Count(); subs != 0 {
t.Fatalf("a member opened %d subscription(s) on %s", subs, who)
}
}
pooled := moneyOrg("acme")
resp = invokeSubscribeCard(pooled, ctx, `{"planId":"team","quantity":2}`, map[string]string{"X-User-IsOrgAdmin": "false", "X-User-Id": "carol"})
if resp.StatusCode != http.StatusForbidden {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("a plain member of a pooled org subscribing its account: %d %s, want 403", resp.StatusCode, b)
}
}