package billing import ( "errors" "testing" ) func TestRemainingCreditsClamped(t *testing.T) { cases := []struct { total, used, want int }{ {100, 40, 60}, {100, 100, 0}, {100, 150, 0}, // used > total must not report negative {0, 0, 0}, {0, 5, 0}, } for _, c := range cases { got := RemainingCreditsClamped(c.total, c.used) if got != c.want { t.Fatalf("total=%d used=%d got=%d want=%d", c.total, c.used, got, c.want) } } } func TestApplyCreditDeltaPreventsNegativeRemaining(t *testing.T) { cases := []struct { total, used, amount, want int }{ {100, 20, 50, 150}, {100, 80, -50, 80}, // clawback stops at used {100, 80, -200, 80}, {10, 0, -5, 5}, {10, 0, -20, 0}, {0, 0, 25, 25}, } for _, c := range cases { got := ApplyCreditDelta(c.total, c.used, c.amount) if got != c.want { t.Fatalf("total=%d used=%d amount=%d got=%d want=%d", c.total, c.used, c.amount, got, c.want) } if got < c.used { t.Fatalf("total after delta below used: got=%d used=%d", got, c.used) } } } func TestComputeEntitlementsClampsNegativeRemaining(t *testing.T) { ent := ComputeEntitlements("Free", 0, -10, false) if ent.RemainingCredits != 0 { t.Fatalf("remaining=%d want 0", ent.RemainingCredits) } if ent.CanUseAI { t.Fatal("negative remaining on Free must not unlock AI") } } func TestConsumeCreditsContractErrors(t *testing.T) { // Document sentinel used by processOne / ProcessJob abort path. if !errors.Is(ErrInsufficientCredits, ErrInsufficientCredits) { t.Fatal("sentinel self-match") } wrapped := errors.New("x") if errors.Is(wrapped, ErrInsufficientCredits) { t.Fatal("unrelated error must not match") } } func TestDebitFloorAndNegativeTokens(t *testing.T) { if got := DebitAmount(1, 1, -5); got != 1 { t.Fatalf("DebitAmount negative tokens=%d want 1", got) } } func TestDebitAmount(t *testing.T) { cases := []struct { feature, tokenK, tokens, want int }{ {1, 1, 0, 1}, {1, 1, 1, 2}, {1, 1, 1000, 2}, {1, 1, 1001, 3}, {2, 3, 2500, 2 + 3*3}, // base 2 + 3 packs * 3 {0, 0, 0, 1}, // floors } for _, c := range cases { got := DebitAmount(c.feature, c.tokenK, c.tokens) if got != c.want { t.Fatalf("DebitAmount(%d,%d,%d)=%d want %d", c.feature, c.tokenK, c.tokens, got, c.want) } } } func TestDebitAmountNVsPerProduct(t *testing.T) { // Exact parity when each item's tokens don't leave partial packs that merge. sum := DebitAmount(1, 1, 1000) + DebitAmount(1, 1, 1000) batchedExact := DebitAmountN(1, 1, 2000, 2) if sum != batchedExact { t.Fatalf("aligned packs: sum=%d batch=%d", sum, batchedExact) } // Combined packs can undercharge vs per-item ceil. perItem := DebitAmount(1, 1, 500) + DebitAmount(1, 1, 500) // 2+2=4 batched := DebitAmountN(1, 1, 1000, 2) // 2*1 + 1 = 3 if perItem <= batched { t.Fatalf("expected batch undercharge: perItem=%d batched=%d", perItem, batched) } } func TestConsumeCreditsSkipsEntitlementsOnAITokens(t *testing.T) { // Document hot-path contract: tokenCount > 0 skips EntitlementsForCompany. // Flat (0-token) Free-plan burn still gates via !CanUseAI. tokenCount := 1200 needEntitlements := tokenCount == 0 if needEntitlements { t.Fatal("AI token debit must not require entitlements preflight") } tokenCount = 0 if !(tokenCount == 0) { t.Fatal("flat debit still gates entitlements") } }