Files
commerce/api/billing/subscribe_card_test.go
zooqueenandHanzo Dev 20e7f29c65 A new subscription holds the period its first charge paid for
The sale path is held to it for a month and for a year: the row and the paid
first invoice name one period, it starts at the purchase, and it runs one
interval from there.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-24 02:40:55 -07:00

893 lines
35 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package billing
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/zap-proto/zip"
"github.com/hanzoai/commerce/billing/engine"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/models/billinginvoice"
"github.com/hanzoai/commerce/models/organization"
"github.com/hanzoai/commerce/models/paymentmethod"
"github.com/hanzoai/commerce/models/plan"
storemodel "github.com/hanzoai/commerce/models/store"
"github.com/hanzoai/commerce/models/subscription"
"github.com/hanzoai/commerce/models/types/currency"
"github.com/hanzoai/commerce/payment/processor"
"github.com/hanzoai/commerce/util/test/ae"
)
// TRUE card-on-file monthly subscription (POST /v1/billing/subscribe/card).
// These pin the money invariants Red verifies:
//
// nonce is VAULTED, the SAVED card (card-on-file id) is what's charged
// the charge amount is the SERVER-AUTHORITATIVE catalog price (never a client value)
// the subscription is created + a PAID first invoice references the processor ref
// the plan fee is NOT credited to the spendable AI-credit wallet
// a retry / double-submit charges + subscribes AT MOST once (idempotent)
// a declined card creates NO subscription and NO saved payment method
// renewal re-charges the SAME vaulted card; a decline leaves the invoice open
// and never double-charges
// withFakeSquare points the saved-card processor seam (processorsForOrg) at a
// registry holding m for the duration of the test — Square with real behavior,
// no network. DefaultConfig prefers Square (and disables Stripe), so SelectProcessor
// picks m for a USD card charge and squareCustomerProcessorFrom(reg) resolves it.
func withFakeSquare(t *testing.T, m *mockSquareProcessor) {
t.Helper()
old := processorsForOrg
processorsForOrg = func(*organization.Organization) *processor.Registry {
reg := processor.NewRegistry(processor.DefaultConfig())
reg.Register(m)
return reg
}
t.Cleanup(func() { processorsForOrg = old })
}
// squareMock builds a Square mock that vaults to (customerID, cardID) and charges
// to chargeRef.
func squareMock(customerID, cardID, chargeRef string) *mockSquareProcessor {
m := newMockSquare(nil, "", nil)
m.createdCustomerID = customerID
m.addedCardID = cardID
m.chargeRef = chargeRef
return m
}
func invokeSubscribeCard(org *organization.Organization, ctx context.Context, body string, headers map[string]string) *http.Response {
req := httptest.NewRequest(http.MethodPost, "/v1/billing/subscribe/card", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
// The buyer administers the org, as an org buying its own plan does; a test
// about a plain member states "false".
req.Header.Set("X-User-IsOrgAdmin", "true")
for k, v := range headers {
req.Header.Set(k, v)
}
return driveSeeded(func(c *zip.Ctx) {
c.Locals("organization", org)
c.SetContext(ctx)
c.Locals("iam_email", "[email protected]")
}, "/v1/billing/subscribe/card", req, SubscribeWithCard)
}
func jsonBody(t *testing.T, r *http.Response) map[string]any {
t.Helper()
raw, _ := io.ReadAll(r.Body)
var out map[string]any
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("bad response json: %v (%s)", err, string(raw))
}
return out
}
// parentSub returns the non-bundle subscription for subject on planId (the paid
// parent, not a $0 bundle child).
func parentSub(t *testing.T, db *datastore.Datastore, subject, planId string) *subscription.Subscription {
t.Helper()
subs := make([]*subscription.Subscription, 0)
if _, err := subscription.Query(db).Filter("UserId=", subject).GetAll(&subs); err != nil {
t.Fatalf("query subscriptions: %v", err)
}
for _, s := range subs {
if s.ProviderType != "bundle" && (s.PlanId == planId || s.Plan.Slug == planId) {
return s
}
}
return nil
}
func pmsFor(t *testing.T, db *datastore.Datastore, subject string) []*paymentmethod.PaymentMethod {
t.Helper()
rootKey := db.NewKey("synckey", "", 1, nil)
out := make([]*paymentmethod.PaymentMethod, 0)
if _, err := paymentmethod.Query(db).Ancestor(rootKey).Filter("CustomerId=", subject).GetAll(&out); err != nil {
t.Fatalf("query payment methods: %v", err)
}
return out
}
func invoicesForSub(t *testing.T, db *datastore.Datastore, subID string) []*billinginvoice.BillingInvoice {
t.Helper()
out := make([]*billinginvoice.BillingInvoice, 0)
if _, err := billinginvoice.Query(db).Filter("SubscriptionId=", subID).GetAll(&out); err != nil {
t.Fatalf("query invoices: %v", err)
}
return out
}
// TestSubscribeWithCard_VaultChargeCreateInvoice is the end-to-end happy path:
// vault the nonce, charge the SAVED card at the catalog price, create the
// subscription, record a PAID first invoice — and leave the spendable wallet
// untouched.
func TestSubscribeWithCard_VaultChargeCreateInvoice(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("subcard")
m := squareMock("cust_1", "ccof_1", "sqpay_1")
withFakeSquare(t, m)
// Flat plan, no amount in the body — the price is the catalog's, always. Read
// rather than restated, so a reprice does not fail a test about authority.
devCents := float64(lookupPlan("dev").Price)
resp := invokeSubscribeCard(org, ctx, `{"sourceId":"cnon:ok","planId":"dev"}`, nil)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("status=%d body=%s, want 201", resp.StatusCode, func() string { b, _ := io.ReadAll(resp.Body); return string(b) }())
}
out := jsonBody(t, resp)
if out["amountCents"].(float64) != devCents {
t.Fatalf("response amountCents=%v, want %v (catalog pro price)", out["amountCents"], devCents)
}
if out["subscriptionId"] == "" || out["invoiceId"] == "" {
t.Fatalf("response missing ids: %+v", out)
}
// The NONCE was vaulted, the SAVED card-on-file id was charged (never the nonce),
// in the Square customer's context, for the catalog price — exactly once.
if m.createCustomerCalls != 1 {
t.Fatalf("CreateCustomer calls=%d, want 1", m.createCustomerCalls)
}
if m.addCardNonce != "cnon:ok" {
t.Fatalf("vaulted nonce=%q, want cnon:ok", m.addCardNonce)
}
if m.chargeCalls != 1 {
t.Fatalf("charge calls=%d, want 1", m.chargeCalls)
}
if m.lastChargeToken != "ccof_1" {
t.Fatalf("charged token=%q, want the card-on-file id ccof_1 (NOT the nonce)", m.lastChargeToken)
}
if m.lastChargeCustomer != "cust_1" {
t.Fatalf("charge customer=%q, want cust_1", m.lastChargeCustomer)
}
if float64(m.lastChargeAmount) != devCents {
t.Fatalf("charged amount=%d, want %v (server-authoritative)", m.lastChargeAmount, devCents)
}
db := datastore.New(org.Namespaced(ctx))
// A vaulted payment method persisted: ProviderRef = card-on-file id, Square
// customer id in metadata (so renewals can re-charge it), default.
pms := pmsFor(t, db, "subcard")
if len(pms) != 1 {
t.Fatalf("payment methods for subject=%d, want 1", len(pms))
}
if pms[0].ProviderRef != "ccof_1" {
t.Fatalf("pm.ProviderRef=%q, want ccof_1 (card-on-file id, not the nonce)", pms[0].ProviderRef)
}
if squareCustomerIDOf(pms[0]) != "cust_1" {
t.Fatalf("pm squareCustomerId=%q, want cust_1", squareCustomerIDOf(pms[0]))
}
if !pms[0].IsDefault {
t.Fatal("vaulted card should be the default payment method")
}
// The subscription: active, Square-backed, pointing at the vaulted card.
sub := parentSub(t, db, "subcard", "dev")
if sub == nil {
t.Fatal("no 'pro' subscription created for subject")
}
if sub.Status != subscription.Active {
t.Fatalf("subscription status=%s, want active", sub.Status)
}
if sub.ProviderType != string(processor.Square) {
t.Fatalf("subscription providerType=%q, want square (payment-backed → allotment flows)", sub.ProviderType)
}
if sub.DefaultPaymentMethod != pms[0].Id() {
t.Fatalf("subscription DefaultPaymentMethod=%q, want the vaulted pm id %q", sub.DefaultPaymentMethod, pms[0].Id())
}
if sub.CurrentInvoiceId == "" {
t.Fatal("subscription CurrentInvoiceId empty — first invoice not linked (would clamp the allotment)")
}
// A PAID first invoice referencing the processor ref.
invs := invoicesForSub(t, db, sub.Id())
if len(invs) != 1 {
t.Fatalf("invoices for subscription=%d, want exactly 1", len(invs))
}
inv := invs[0]
if inv.Status != billinginvoice.Paid {
t.Fatalf("first invoice status=%s, want paid", inv.Status)
}
if inv.PaymentMethod != "card" {
t.Fatalf("first invoice paymentMethod=%q, want card", inv.PaymentMethod)
}
if inv.PaymentRef != "sqpay_1" {
t.Fatalf("first invoice paymentRef=%q, want the charge ref sqpay_1", inv.PaymentRef)
}
if float64(inv.AmountPaid) != devCents || float64(inv.AmountDue) != devCents {
t.Fatalf("first invoice amountDue=%d amountPaid=%d, want %v/%v", inv.AmountDue, inv.AmountPaid, devCents, devCents)
}
heldIsPaid(t, sub, inv, 0, 1)
// The plan FEE must NOT have been credited to the spendable AI-credit wallet —
// a subscription is not a top-up. The wallet the LLM gate reads stays $0.
if bal := balanceOf(t, ctx, org, "subcard"); bal != 0 {
t.Fatalf("spendable wallet balance=%d, want 0 — the plan fee must NEVER credit the AI-credit wallet", bal)
}
}
// heldIsPaid holds a new subscription to the period its first charge paid for:
// the row and the paid invoice name the same period, it began at the purchase,
// and it runs one interval (years, months) from there. A row that moved on past
// the invoice at purchase is a free period for every subscriber.
func heldIsPaid(t *testing.T, sub *subscription.Subscription, inv *billinginvoice.BillingInvoice, years, months int) {
t.Helper()
if !sub.PeriodStart.Equal(inv.PeriodStart) || !sub.PeriodEnd.Equal(inv.PeriodEnd) {
t.Fatalf("row holds %s..%s, first invoice paid %s..%s; want the row on the paid period",
sub.PeriodStart, sub.PeriodEnd, inv.PeriodStart, inv.PeriodEnd)
}
if since := time.Since(sub.PeriodStart); since < 0 || since > time.Minute {
t.Fatalf("period starts %s, %s from now; want the purchase", sub.PeriodStart, -since)
}
if want := sub.PeriodStart.AddDate(years, months, 0); !sub.PeriodEnd.Equal(want) {
t.Fatalf("period ends %s, want %s: one interval after the purchase", sub.PeriodEnd, want)
}
}
// TestSubscribeWithCard_AnnualHoldsThePaidYear: a year bought by card is charged
// the annual total once, and the row holds that year from the purchase.
func TestSubscribeWithCard_AnnualHoldsThePaidYear(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("subyear")
m := squareMock("cust_y", "ccof_y", "sqpay_y")
withFakeSquare(t, m)
year := lookupPlan("dev").AnnualTotal
if year <= 0 {
t.Fatal("the dev plan publishes no annual total")
}
resp := invokeSubscribeCard(org, ctx, `{"sourceId":"cnon:ok","planId":"dev","interval":"year"}`, nil)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("status=%d body=%s, want 201", resp.StatusCode, func() string { b, _ := io.ReadAll(resp.Body); return string(b) }())
}
if m.chargeCalls != 1 || int64(m.lastChargeAmount) != int64(year) {
t.Fatalf("charged %d time(s), last %d; want once for the annual total %d", m.chargeCalls, m.lastChargeAmount, year)
}
db := datastore.New(org.Namespaced(ctx))
sub := parentSub(t, db, "subyear", "dev")
if sub == nil {
t.Fatal("no subscription created for the annual purchase")
}
invs := invoicesForSub(t, db, sub.Id())
if len(invs) != 1 || invs[0].Status != billinginvoice.Paid {
t.Fatalf("invoices=%d, want exactly one paid first invoice", len(invs))
}
heldIsPaid(t, sub, invs[0], 1, 0)
}
func TestSubscribeWithCardBindsStore(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("substore")
db := datastore.New(org.Namespaced(ctx))
store := storemodel.New(db)
store.Name = "Store A"
store.Slug = "store-a"
if err := store.Create(); err != nil {
t.Fatalf("create store: %v", err)
}
m := squareMock("cust_store", "ccof_store", "sqpay_store")
withFakeSquare(t, m)
body := fmt.Sprintf(`{"sourceId":"cnon:store","planId":"dev","storeId":%q}`, store.Id())
resp := invokeSubscribeCard(org, ctx, body, map[string]string{"X-Idempotency-Key": "store-a-first-period"})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("status=%d body=%s", resp.StatusCode, func() string { b, _ := io.ReadAll(resp.Body); return string(b) }())
}
sub := parentSub(t, db, "substore", "dev")
if sub == nil || sub.StoreId != store.Id() {
t.Fatalf("subscription store = %v, want %q", sub, store.Id())
}
}
// TestSubscribeWithCard_ServerAuthoritativePrice proves the charge is the catalog
// price and a client-supplied amount is ignored (the request has no amount field,
// so a hostile amountCents in the body cannot underpay/overcharge).
func TestSubscribeWithCard_ServerAuthoritativePrice(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("sc-price")
m := squareMock("cust_p", "ccof_p", "sqpay_p")
withFakeSquare(t, m)
// A hostile client tries to pay 1 cent for the top "max-20x" plan. The amount is
// dropped (unknown field) and the server charges the catalog price.
maxCents := float64(lookupPlan("max-20x").Price)
resp := invokeSubscribeCard(org, ctx, `{"sourceId":"cnon:ok","planId":"max-20x","amountCents":1,"amount":"0.01","price":1}`, nil)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("status=%d body=%s, want 201", resp.StatusCode, func() string { b, _ := io.ReadAll(resp.Body); return string(b) }())
}
if float64(m.lastChargeAmount) != maxCents {
t.Fatalf("charged amount=%d, want %v (catalog 'max' price) — a client amount must NEVER set the charge", m.lastChargeAmount, maxCents)
}
out := jsonBody(t, resp)
if out["amountCents"].(float64) != maxCents {
t.Fatalf("response amountCents=%v, want %v", out["amountCents"], maxCents)
}
}
// TestSubscribeWithCard_ForeignCurrencyRejected proves the currency is
// SERVER-authoritative: a client that swaps the currency to a weaker unit
// ("jpy" for the USD-priced $20 pro plan) is rejected 400 with NO charge and NO
// subscription — otherwise 2000 JPY (~$13) would settle the $20 plan (underpay).
func TestSubscribeWithCard_ForeignCurrencyRejected(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("sc-jpy")
m := squareMock("cust_j", "ccof_j", "sqpay_j")
withFakeSquare(t, m)
resp := invokeSubscribeCard(org, ctx, `{"sourceId":"cnon:ok","planId":"dev","currency":"jpy"}`, nil)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status=%d body=%s, want 400 (foreign currency rejected)", resp.StatusCode, func() string { b, _ := io.ReadAll(resp.Body); return string(b) }())
}
if m.chargeCalls != 0 {
t.Fatalf("charge calls=%d for a rejected currency, want 0 — no money must move", m.chargeCalls)
}
if m.createCustomerCalls != 0 {
t.Fatalf("CreateCustomer calls=%d for a rejected currency, want 0 (reject BEFORE vaulting)", m.createCustomerCalls)
}
db := datastore.New(org.Namespaced(ctx))
if sub := parentSub(t, db, "sc-jpy", "dev"); sub != nil {
t.Fatal("a rejected-currency request must NOT create a subscription")
}
}
// TestSubscribeWithCard_Idempotent proves a retry with the same idempotency key
// charges + subscribes AT MOST once: the second call replays the first response and
// the card is charged exactly once.
func TestSubscribeWithCard_Idempotent(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("sc-idem")
m := squareMock("cust_i", "ccof_i", "sqpay_i")
withFakeSquare(t, m)
hdr := map[string]string{"X-Idempotency-Key": "sub-key-1"}
body := `{"sourceId":"cnon:ok","planId":"dev"}`
r1 := invokeSubscribeCard(org, ctx, body, hdr)
if r1.StatusCode != http.StatusCreated {
t.Fatalf("first status=%d, want 201", r1.StatusCode)
}
b1, _ := io.ReadAll(r1.Body)
// Retry (same key) — must replay, not re-charge.
r2 := invokeSubscribeCard(org, ctx, body, hdr)
if r2.StatusCode != http.StatusOK {
t.Fatalf("replay status=%d, want 200 (idempotent replay)", r2.StatusCode)
}
b2, _ := io.ReadAll(r2.Body)
if string(b1) != string(b2) {
t.Fatalf("replay body differs:\n first=%s\n second=%s", b1, b2)
}
if m.chargeCalls != 1 {
t.Fatalf("charge calls=%d after retry, want 1 — DOUBLE-CHARGE on idempotent retry", m.chargeCalls)
}
if m.createCustomerCalls != 1 {
t.Fatalf("CreateCustomer calls=%d after retry, want 1 (no second vault)", m.createCustomerCalls)
}
// Exactly one paid invoice for the subject's subscription (no duplicate).
db := datastore.New(org.Namespaced(ctx))
sub := parentSub(t, db, "sc-idem", "dev")
if sub == nil {
t.Fatal("subscription not created")
}
if invs := invoicesForSub(t, db, sub.Id()); len(invs) != 1 {
t.Fatalf("invoices=%d after retry, want 1", len(invs))
}
}
// TestSubscribeWithCard_DeclinedNoSubscription proves a declined card creates NO
// subscription and NO saved payment method — money-in fails closed.
func TestSubscribeWithCard_DeclinedNoSubscription(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("sc-decline")
m := squareMock("cust_d", "ccof_d", "")
m.chargeErr = errors.New("CARD_DECLINED")
withFakeSquare(t, m)
resp := invokeSubscribeCard(org, ctx, `{"sourceId":"cnon:bad","planId":"dev"}`, nil)
if resp.StatusCode != http.StatusPaymentRequired {
t.Fatalf("status=%d, want 402 (declined)", resp.StatusCode)
}
if m.chargeCalls != 1 {
t.Fatalf("charge calls=%d, want 1", m.chargeCalls)
}
db := datastore.New(org.Namespaced(ctx))
if sub := parentSub(t, db, "sc-decline", "dev"); sub != nil {
t.Fatal("a declined card must NOT create a subscription")
}
if pms := pmsFor(t, db, "sc-decline"); len(pms) != 0 {
t.Fatalf("declined card persisted %d payment methods, want 0", len(pms))
}
}
// TestSubscribeWithCard_FreePlanRejected proves the endpoint refuses a $0 plan (no
// card charge to make — free tiers are self-serve via POST /v1/billing/subscriptions).
func TestSubscribeWithCard_FreePlanRejected(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("sc-free")
m := squareMock("cust_f", "ccof_f", "sqpay_f")
withFakeSquare(t, m)
// The published ladder has no $0 tier, so a free plan is created here rather
// than borrowed from the catalog. The guard under test belongs to the endpoint,
// not to whatever the catalog happens to be selling this quarter.
freeDB := plan.AuthorityDB(ctx)
free := plan.New(freeDB)
free.Slug, free.Name, free.Price, free.Category, free.Managed = "sc-free-tier", "Free", 0, "personal", true
if err := free.Create(); err != nil {
t.Fatalf("create free plan: %v", err)
}
resp := invokeSubscribeCard(org, ctx, `{"sourceId":"cnon:ok","planId":"sc-free-tier"}`, nil)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status=%d, want 400 (free plan needs no card)", resp.StatusCode)
}
if m.chargeCalls != 0 {
t.Fatalf("charge calls=%d for a free plan, want 0", m.chargeCalls)
}
}
// seedCardBackedSub creates a vaulted card + an active subscription (its plan from
// the real catalog) pointing at it — the setup a renewal collects against. It uses
// the SAME plan.New + StartSubscription path production does, so sub.Plan is a fully
// initialized entity (subscriptionResponse reads sub.Plan.Id()).
func seedCardBackedSub(t *testing.T, db *datastore.Datastore, subject, planSlug, cardID, customerID string) *subscription.Subscription {
t.Helper()
pm := paymentmethod.New(db)
pm.CustomerId = subject
pm.UserId = subject
pm.Type = "card"
pm.ProviderRef = cardID
pm.ProviderType = string(processor.Square)
pm.IsDefault = true
pm.Metadata = map[string]interface{}{"squareCustomerId": customerID, "squareCardId": cardID}
if err := pm.Create(); err != nil {
t.Fatalf("seed payment method: %v", err)
}
p, err := resolveSubscriptionPlan(db, planSlug)
if err != nil {
t.Fatalf("resolve plan %q: %v", planSlug, err)
}
s := subscription.New(db)
s.UserId = subject
s.DefaultPaymentMethod = pm.Id()
s.ProviderType = string(processor.Square)
s.Quantity = 1
engine.StartSubscription(s, p) // sets s.Plan, s.PlanId, Status=Active, current period
// Make the current period DUE (already ended) so a renewal actually collects —
// the is-due gate only bills an elapsed period (never a future one).
s.PeriodStart = time.Now().AddDate(0, -2, 0)
s.PeriodEnd = time.Now().AddDate(0, -1, 0)
if err := s.Create(); err != nil {
t.Fatalf("seed subscription: %v", err)
}
return s
}
func invokeRenew(org *organization.Organization, ctx context.Context, subID string) *http.Response {
req := httptest.NewRequest(http.MethodPost, "/v1/billing/subscriptions/"+subID+"/renew", nil)
return driveSeeded(func(c *zip.Ctx) {
c.Locals("organization", org)
c.SetContext(ctx)
}, "/v1/billing/subscriptions/:id/renew", req, RenewBillingSubscription)
}
func invokePay(org *organization.Organization, ctx context.Context, invID string) *http.Response {
req := httptest.NewRequest(http.MethodPost, "/v1/billing/invoices/"+invID+"/pay", nil)
return driveSeeded(func(c *zip.Ctx) {
c.Locals("organization", org)
c.SetContext(ctx)
}, "/v1/billing/invoices/:id/pay", req, PayInvoice)
}
// seedOpenInvoice persists one OPEN invoice (a single subscription line item) for
// the subscription's period — what a PayInvoice / dunning retry collects against.
func seedOpenInvoice(t *testing.T, db *datastore.Datastore, sub *subscription.Subscription, amountCents int64) *billinginvoice.BillingInvoice {
t.Helper()
inv := billinginvoice.New(db)
inv.UserId = sub.UserId
inv.SubscriptionId = sub.Id()
inv.PeriodStart = sub.PeriodStart
inv.PeriodEnd = sub.PeriodEnd
inv.Currency = currency.USD
inv.LineItems = []billinginvoice.LineItem{{
Id: "li_plan", Type: billinginvoice.LineSubscription, Amount: amountCents, Currency: currency.USD,
}}
inv.RecalculateSubtotal()
inv.SetNumber(1)
if err := inv.Finalize(); err != nil {
t.Fatalf("finalize invoice: %v", err)
}
if err := inv.Create(); err != nil {
t.Fatalf("create invoice: %v", err)
}
return inv
}
// TestRenewSubscription_ChargesVaultedCard proves a manual renewal collects the
// period fee from the subscription's SAVED card and marks the invoice paid.
func TestRenewSubscription_ChargesVaultedCard(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("sc-renew")
m := squareMock("", "", "sqpay_renew")
withFakeSquare(t, m)
db := datastore.New(org.Namespaced(ctx))
sub := seedCardBackedSub(t, db, "sc-renew", "dev", "ccof_r", "cust_r")
resp := invokeRenew(org, ctx, sub.Id())
if resp.StatusCode != http.StatusOK {
t.Fatalf("renew status=%d body=%s, want 200", resp.StatusCode, func() string { b, _ := io.ReadAll(resp.Body); return string(b) }())
}
if m.chargeCalls != 1 {
t.Fatalf("charge calls=%d, want 1", m.chargeCalls)
}
if m.lastChargeToken != "ccof_r" {
t.Fatalf("renewal charged token=%q, want the vaulted card ccof_r", m.lastChargeToken)
}
if m.lastChargeCustomer != "cust_r" {
t.Fatalf("renewal charge customer=%q, want cust_r", m.lastChargeCustomer)
}
if int64(m.lastChargeAmount) != lookupPlan("dev").Price {
t.Fatalf("renewal charged amount=%d, want %d (catalog pro price)", m.lastChargeAmount, lookupPlan("dev").Price)
}
invs := invoicesForSub(t, db, sub.Id())
if len(invs) != 1 {
t.Fatalf("invoices=%d, want 1", len(invs))
}
if invs[0].Status != billinginvoice.Paid || invs[0].PaymentMethod != "card" {
t.Fatalf("renewal invoice status=%s method=%q, want paid/card", invs[0].Status, invs[0].PaymentMethod)
}
}
// TestRenewSubscription_DeclineLeavesOpenNoDoubleCharge proves a declined renewal
// leaves the invoice OPEN (subscription PastDue) and a repeated renew does NOT
// re-charge (the existing open invoice is returned; dunning, not renew, retries).
func TestRenewSubscription_DeclineLeavesOpenNoDoubleCharge(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("sc-renew-decline")
m := squareMock("", "", "")
m.chargeErr = errors.New("CARD_DECLINED")
withFakeSquare(t, m)
db := datastore.New(org.Namespaced(ctx))
sub := seedCardBackedSub(t, db, "sc-renew-decline", "dev", "ccof_rd", "cust_rd")
// First renew: declines → invoice OPEN, subscription PastDue, charged once.
if resp := invokeRenew(org, ctx, sub.Id()); resp.StatusCode != http.StatusOK {
t.Fatalf("first renew status=%d, want 200 (a decline is a handled outcome, not a 500)", resp.StatusCode)
}
if m.chargeCalls != 1 {
t.Fatalf("charge calls after first renew=%d, want 1", m.chargeCalls)
}
invs := invoicesForSub(t, db, sub.Id())
if len(invs) != 1 || invs[0].Status != billinginvoice.Open {
t.Fatalf("after decline: %d invoices, status=%v; want 1 OPEN invoice", len(invs), func() billinginvoice.Status {
if len(invs) > 0 {
return invs[0].Status
}
return ""
}())
}
got := subscription.New(db)
if err := got.GetById(sub.Id()); err != nil {
t.Fatalf("reload subscription: %v", err)
}
if got.Status != subscription.PastDue {
t.Fatalf("subscription status after decline=%s, want past_due", got.Status)
}
// Second renew on the SAME (unchanged) period: the existing open invoice is
// returned WITHOUT a second charge — no double-charge.
if resp := invokeRenew(org, ctx, got.Id()); resp.StatusCode != http.StatusOK {
t.Fatalf("second renew status=%d, want 200", resp.StatusCode)
}
if m.chargeCalls != 1 {
t.Fatalf("charge calls after second renew=%d, want STILL 1 — a repeated renew must NOT re-charge the open period", m.chargeCalls)
}
if invs := invoicesForSub(t, db, got.Id()); len(invs) != 1 {
t.Fatalf("invoices after second renew=%d, want 1 (no duplicate)", len(invs))
}
}
// TestRenewSubscription_ParallelExactlyOneCharge drives N concurrent renewals of
// the SAME due subscription and proves the vaulted card is charged EXACTLY once —
// the per-period guard + the stable per-period Square idempotency key together
// defeat the TOCTOU double-charge.
func TestRenewSubscription_ParallelExactlyOneCharge(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("sc-par-renew")
m := squareMock("", "", "sqpay_parren")
withFakeSquare(t, m)
db := datastore.New(org.Namespaced(ctx))
sub := seedCardBackedSub(t, db, "sc-par-renew", "dev", "ccof_pr", "cust_pr")
charger := chargeProviderForOrg(org)
const N = 8
var wg sync.WaitGroup
for i := 0; i < N; i++ {
wg.Add(1)
go func() {
defer wg.Done()
// Each goroutine renews its OWN loaded instance (no shared struct races).
s := subscription.New(db)
if err := s.GetById(sub.Id()); err != nil {
return
}
_, _, _ = engine.RenewSubscription(ctx, db, s, prepaidFor(ctx, org), charger)
}()
}
wg.Wait()
if m.chargeCalls != 1 {
t.Fatalf("parallel renew charged the card %d times, want exactly 1 (double-charge under concurrency)", m.chargeCalls)
}
// Books integrity: exactly ONE invoice row for the period — the deterministic
// per-period id collapses concurrent builds, so the invoice ledger / MRR can't
// over-count by (N-1)×Price even though several racers passed the local guard.
if invs := invoicesForSub(t, db, sub.Id()); len(invs) != 1 {
t.Fatalf("parallel renew created %d invoice rows for the period, want exactly 1 (ledger over-count)", len(invs))
}
}
// TestPayInvoice_ConcurrentCollect_OneCharge seeds an OPEN invoice for a
// card-backed subscription and runs N concurrent collections; the stable
// per-period Square idempotency key makes the vaulted-card charge exactly once.
func TestPayInvoice_ConcurrentCollect_OneCharge(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("sc-par-pay")
m := squareMock("", "", "sqpay_parpay")
withFakeSquare(t, m)
db := datastore.New(org.Namespaced(ctx))
sub := seedCardBackedSub(t, db, "sc-par-pay", "dev", "ccof_pp", "cust_pp")
inv := seedOpenInvoice(t, db, sub, 2000)
charger := chargeProviderForOrg(org)
const N = 8
var wg sync.WaitGroup
for i := 0; i < N; i++ {
wg.Add(1)
go func() {
defer wg.Done()
i2 := billinginvoice.New(db)
if err := i2.GetById(inv.Id()); err != nil {
return
}
if i2.Status != billinginvoice.Open {
return
}
_, _ = engine.CollectInvoice(ctx, db, i2, prepaidFor(ctx, org), charger)
}()
}
wg.Wait()
if m.chargeCalls != 1 {
t.Fatalf("concurrent invoice collection charged the card %d times, want exactly 1", m.chargeCalls)
}
}
// TestSubscribeWithCard_NewNonceRetry_OneCharge proves the server-side (subject,
// planId) fallback guard: a retry with a DIFFERENT single-use nonce and NO
// X-Idempotency-Key (the exact SPA "Start over → re-tokenize" shape) charges +
// subscribes exactly once, not twice.
func TestSubscribeWithCard_NewNonceRetry_OneCharge(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("sc-newnonce")
m := squareMock("cust_nn", "ccof_nn", "sqpay_nn")
withFakeSquare(t, m)
// First attempt (nonce A), no X-Idempotency-Key → guard falls back to (subject, plan).
r1 := invokeSubscribeCard(org, ctx, `{"sourceId":"cnon:A","planId":"dev"}`, nil)
if r1.StatusCode != http.StatusCreated {
t.Fatalf("first status=%d, want 201", r1.StatusCode)
}
// Retry with a FRESH nonce B (re-tokenized), still no header.
r2 := invokeSubscribeCard(org, ctx, `{"sourceId":"cnon:B","planId":"dev"}`, nil)
if r2.StatusCode != http.StatusOK {
t.Fatalf("new-nonce retry status=%d, want 200 (idempotent replay on (subject,planId))", r2.StatusCode)
}
if m.chargeCalls != 1 {
t.Fatalf("new-nonce retry charged %d times, want 1 — a re-tokenized retry double-charged", m.chargeCalls)
}
db := datastore.New(org.Namespaced(ctx))
subs := make([]*subscription.Subscription, 0)
if _, err := subscription.Query(db).Filter("UserId=", "sc-newnonce").GetAll(&subs); err != nil {
t.Fatalf("query subs: %v", err)
}
parents := 0
for _, s := range subs {
if s.ProviderType != "bundle" {
parents++
}
}
if parents != 1 {
t.Fatalf("new-nonce retry created %d subscriptions, want 1", parents)
}
}
// TestSubscribeWithCard_PerSeatQuantity proves per-seat first-period collection is
// exact: a per-seat plan charges Price × quantity and the recorded invoice's
// AmountDue == AmountPaid == that amount (never charge one seat yet record N).
func TestSubscribeWithCard_PerSeatQuantity(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("sc-seats")
m := squareMock("cust_s", "ccof_s", "sqpay_s")
withFakeSquare(t, m)
// team = $25/mo (2500c) per seat, minSeats 2. quantity 2 → charge 5000c.
resp := invokeSubscribeCard(org, ctx, `{"sourceId":"cnon:ok","planId":"team","quantity":2}`, nil)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("status=%d body=%s, want 201", resp.StatusCode, func() string { b, _ := io.ReadAll(resp.Body); return string(b) }())
}
out := jsonBody(t, resp)
if out["amountCents"].(float64) != 5000 {
t.Fatalf("response amountCents=%v, want 5000 (2500 x 2 seats)", out["amountCents"])
}
if m.lastChargeAmount != 5000 {
t.Fatalf("charged amount=%d, want 5000 (Price x quantity)", m.lastChargeAmount)
}
db := datastore.New(org.Namespaced(ctx))
sub := parentSub(t, db, "sc-seats", "team")
if sub == nil {
t.Fatal("no team subscription created")
}
invs := invoicesForSub(t, db, sub.Id())
if len(invs) != 1 {
t.Fatalf("invoices=%d, want 1", len(invs))
}
if invs[0].AmountDue != 5000 || invs[0].AmountPaid != 5000 {
t.Fatalf("invoice amountDue=%d amountPaid=%d, want 5000/5000 (charge must equal the recorded paid amount)", invs[0].AmountDue, invs[0].AmountPaid)
}
}
// TestPayInvoice_DeclineThenRetryReCollects proves fix A: a declined PayInvoice
// leaves the invoice OPEN, does NOT seal the guard, and a later PayInvoice (once the
// card works) actually RE-COLLECTS — dunning is not wedged by a sealed decline. The
// re-collection runs at a higher AttemptCount → a FRESH gateway key → the processor
// lets it through instead of replaying the first decline.
func TestPayInvoice_DeclineThenRetryReCollects(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("sc-pay-dun")
m := squareMock("", "", "sqpay_dun")
m.chargeErr = errors.New("CARD_DECLINED")
withFakeSquare(t, m)
db := datastore.New(org.Namespaced(ctx))
sub := seedCardBackedSub(t, db, "sc-pay-dun", "dev", "ccof_dun", "cust_dun")
inv := seedOpenInvoice(t, db, sub, 2000)
// First pay: declines → invoice stays OPEN, guard released (not sealed).
r1 := invokePay(org, ctx, inv.Id())
if r1.StatusCode != http.StatusOK {
t.Fatalf("declined pay status=%d, want 200 (a decline is a handled outcome)", r1.StatusCode)
}
if m.chargeCalls != 1 {
t.Fatalf("charge calls after decline=%d, want 1", m.chargeCalls)
}
got := billinginvoice.New(db)
if err := got.GetById(inv.Id()); err != nil {
t.Fatalf("reload invoice: %v", err)
}
if got.Status != billinginvoice.Open {
t.Fatalf("invoice status after decline=%s, want OPEN (a declined pay must not close the invoice)", got.Status)
}
// Card now works: a subsequent pay must actually RE-COLLECT (not replay the sealed
// decline) and mark the invoice paid.
m.chargeErr = nil
r2 := invokePay(org, ctx, inv.Id())
if r2.StatusCode != http.StatusOK {
t.Fatalf("retry pay status=%d, want 200", r2.StatusCode)
}
if m.chargeCalls != 2 {
t.Fatalf("charge calls after retry=%d, want 2 — the retry did NOT re-collect (dunning wedged by a sealed decline)", m.chargeCalls)
}
paid := billinginvoice.New(db)
if err := paid.GetById(inv.Id()); err != nil {
t.Fatalf("reload invoice: %v", err)
}
if paid.Status != billinginvoice.Paid || paid.PaymentMethod != "card" {
t.Fatalf("invoice after retry status=%s method=%q, want paid/card", paid.Status, paid.PaymentMethod)
}
}
// TestSubscribeWithCard_DistinctKeyRetriesAFailedAttempt proves fix B: the
// idempotency guard is not a permanent per-plan LOCK. A first attempt that took no
// money must leave nothing wedged, so a fresh key genuinely subscribes.
//
// It used to prove that by subscribing TWICE successfully to "dev" and asserting two
// charges and two parent subscriptions — which is the double-billing this endpoint now
// refuses (see TestSubscribeWithCard_SecondPaidTierRefusedBeforeCharge). The
// property worth protecting was never "sell the same plan twice"; it was "a spent
// key does not brick the plan". So the first attempt DECLINES here, which is the
// case the guard actually had to survive.
func TestSubscribeWithCard_DistinctKeyRetriesAFailedAttempt(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("sc-resub")
m := squareMock("cust_rs", "ccof_rs", "sqpay_rs")
m.chargeErr = errors.New("CARD_DECLINED")
withFakeSquare(t, m)
r1 := invokeSubscribeCard(org, ctx, `{"sourceId":"cnon:A","planId":"dev"}`, map[string]string{"X-Idempotency-Key": "attempt-1"})
if r1.StatusCode != http.StatusPaymentRequired {
t.Fatalf("first subscribe status=%d, want 402 (declined)", r1.StatusCode)
}
// A real card, a new checkout attempt (fresh key) → this one must go through.
m.chargeErr = nil
r2 := invokeSubscribeCard(org, ctx, `{"sourceId":"cnon:B","planId":"dev"}`, map[string]string{"X-Idempotency-Key": "attempt-2"})
if r2.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(r2.Body)
t.Fatalf("re-subscribe status=%d body=%s, want 201 (a spent key must not brick the plan)", r2.StatusCode, string(body))
}
db := datastore.New(org.Namespaced(ctx))
subs := make([]*subscription.Subscription, 0)
if _, err := subscription.Query(db).Filter("UserId=", "sc-resub").GetAll(&subs); err != nil {
t.Fatalf("query subs: %v", err)
}
parents := 0
for _, s := range subs {
if s.ProviderType != "bundle" {
parents++
}
}
if parents != 1 {
t.Fatalf("retry after a decline produced %d parent subscriptions, want 1", parents)
}
}