Files
commerce/api/billing/default_choice_test.go
zooqueenandHanzo Dev 2944b955a4 billing: only an org's admins change its auto-reload and cards, and auto-reload is bounded
Auto-reload charges the org's card with nobody present, so the rule is its
admins' to set — and a SuperAdmin's or a service's. The amount is a top-up's
own range ($5 to $5,000) and the balance it reloads below is $0 to $5,000,
refused with the range in the sentence. The sweep asks the processor to
charge one org at most three times a UTC day, a decline included, and names
the limit in the row it skips.

The day's reload is reserved under compare-and-swap before the card is
charged, against the rule as it stands, so a rule switched off mid-sweep is
not charged or re-armed, and overlapping sweeps share the limit. A charge
refused before the processor was asked, or a replay of one already counted,
gives its reload back. An admin's save swaps the rule's own fields and keeps
the day's count.

Saving, changing, choosing the default of and removing the org's cards, and
charging its card on file, are its admins' too. A member whose wallet is
their own person account still manages that wallet's cards.

A loaded rule is bound to its key, so an edit is the rule on file. Before,
an update wrote a second, parentless row the next read never found. The first
read now removes such a row, and honours it when it switched the rule off;
it never arms a rule or raises what it charges.

Co-authored-by: Hanzo Dev <[email protected]>
2026-10-16 02:55:31 -07:00

215 lines
7.7 KiB
Go

package billing
// An explicit choice moves a subject's default to one of its own chargeable
// cards and to nothing else.
import (
"bytes"
"net/http"
"net/http/httptest"
"sync"
"testing"
"github.com/zap-proto/zip"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/models/paymentmethod"
"github.com/hanzoai/commerce/util/test/ae"
)
func TestSetDefaultMethod_MovesTheDefault(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("defmove")
db := datastore.New(org.Namespaced(ctx))
a := card(t, db, "defmove", "ccof_a", true)
b := card(t, db, "defmove", "ccof_b", false)
ch, err := SetDefaultMethod(ctx, org, b.Id(), "defmove")
if err != nil {
t.Fatalf("SetDefaultMethod: %v", err)
}
if ch.Method.Id != b.Id() || !ch.Method.IsDefault {
t.Fatalf("answered %+v, want %s as the default", ch.Method, b.Id())
}
if len(ch.Previous) != 1 || ch.Previous[0] != a.Id() {
t.Fatalf("previous=%v, want [%s]", ch.Previous, a.Id())
}
if got := defaults(t, db, "defmove"); len(got) != 1 || got[0] != b.Id() {
t.Fatalf("defaults=%v, want [%s]", got, b.Id())
}
if def := defaultPaymentMethod(db, "defmove"); def == nil || def.Id() != b.Id() {
t.Fatalf("auto-recharge reads default %v, want %s", def, b.Id())
}
}
func TestSetDefaultMethod_TheDefaultAgainChangesNothing(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("defsame")
db := datastore.New(org.Namespaced(ctx))
a := card(t, db, "defsame", "ccof_a", true)
card(t, db, "defsame", "ccof_b", false)
ch, err := SetDefaultMethod(ctx, org, a.Id(), "defsame")
if err != nil {
t.Fatalf("SetDefaultMethod: %v", err)
}
if len(ch.Previous) != 1 || ch.Previous[0] != a.Id() {
t.Fatalf("previous=%v, want [%s]: the card was already the default", ch.Previous, a.Id())
}
if got := defaults(t, db, "defsame"); len(got) != 1 || got[0] != a.Id() {
t.Fatalf("defaults=%v, want [%s]", got, a.Id())
}
}
func TestSetDefaultMethod_ACardOfAnotherOrgIsNotFound(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
orgA, orgB := moneyOrg("defowna"), moneyOrg("defownb")
dbA, dbB := datastore.New(orgA.Namespaced(ctx)), datastore.New(orgB.Namespaced(ctx))
a := card(t, dbA, "defowna", "ccof_a", true)
card(t, dbA, "defowna", "ccof_a2", false)
b := card(t, dbB, "defownb", "ccof_b", false)
if _, err := SetDefaultMethod(ctx, orgA, b.Id(), "defowna"); !IsMethodNotFound(err) {
t.Fatalf("org A naming org B's card answered %v, want not found", err)
}
if got := defaults(t, dbA, "defowna"); len(got) != 1 || got[0] != a.Id() {
t.Fatalf("org A defaults=%v, want [%s] unchanged", got, a.Id())
}
if got := defaults(t, dbB, "defownb"); len(got) != 0 {
t.Fatalf("org B defaults=%v, want none: org A must not touch org B's cards", got)
}
}
func TestSetDefaultMethod_ACardOfAnotherSubjectIsNotFound(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("defsubj")
db := datastore.New(org.Namespaced(ctx))
own := card(t, db, "defsubj", "ccof_own", true)
theirs := card(t, db, "defsubj/alice", "ccof_alice", false)
if _, err := SetDefaultMethod(ctx, org, theirs.Id(), "defsubj"); !IsMethodNotFound(err) {
t.Fatalf("naming another subject's card answered %v, want not found", err)
}
if _, err := SetDefaultMethod(ctx, org, own.Id(), ""); !IsMethodNotFound(err) {
t.Fatalf("a caller with no subject answered %v, want not found", err)
}
if _, err := SetDefaultMethod(ctx, org, "pm_nothing", "defsubj"); !IsMethodNotFound(err) {
t.Fatalf("an id naming nothing answered %v, want not found", err)
}
if got := defaults(t, db, "defsubj"); len(got) != 1 || got[0] != own.Id() {
t.Fatalf("defaults=%v, want [%s] unchanged", got, own.Id())
}
if got := defaults(t, db, "defsubj/alice"); len(got) != 0 {
t.Fatalf("alice's defaults=%v, want none", got)
}
}
func TestSetDefaultMethod_OnlyAChargeableCard(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("defwire")
db := datastore.New(org.Namespaced(ctx))
own := card(t, db, "defwire", "ccof_own", true)
wire := paymentmethod.New(db)
wire.CustomerId, wire.UserId, wire.Type = "defwire", "defwire", "wire"
wire.Wire = &paymentmethod.WireDetails{BankName: "First"}
if err := wire.Create(); err != nil {
t.Fatalf("seed wire: %v", err)
}
bare := card(t, db, "defwire", "", false)
for _, id := range []string{wire.Id(), bare.Id()} {
if _, err := SetDefaultMethod(ctx, org, id, "defwire"); !IsMethodUnchargeable(err) {
t.Fatalf("method %s answered %v, want unchargeable", id, err)
}
}
if got := defaults(t, db, "defwire"); len(got) != 1 || got[0] != own.Id() {
t.Fatalf("defaults=%v, want [%s] unchanged", got, own.Id())
}
}
func TestSetDefaultMethod_ChoicesAtOnceLeaveOneDefault(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("defchoice")
db := datastore.New(org.Namespaced(ctx))
ids := []string{
card(t, db, "defchoice", "ccof_1", true).Id(),
card(t, db, "defchoice", "ccof_2", false).Id(),
card(t, db, "defchoice", "ccof_3", false).Id(),
}
var wg sync.WaitGroup
for i := 0; i < 24; i++ {
wg.Add(1)
go func(id string) {
defer wg.Done()
if _, err := SetDefaultMethod(ctx, org, id, "defchoice"); err != nil {
t.Errorf("SetDefaultMethod: %v", err)
}
}(ids[i%len(ids)])
}
wg.Wait()
if got := defaults(t, db, "defchoice"); len(got) != 1 {
t.Fatalf("defaults=%v after concurrent choices, want exactly one", got)
}
}
// The commerce route names the customer in its path; a payment method id that is
// not that customer's must leave the customer's default where it was.
func TestSetDefaultPaymentMethod_AnUnknownIdKeepsTheDefault(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("defroute")
db := datastore.New(org.Namespaced(ctx))
held := card(t, db, "defroute", "ccof_held", true)
req := httptest.NewRequest(http.MethodPost, "/v1/billing/customers/defroute/default-payment-method",
bytes.NewBufferString(`{"paymentMethodId":"pm_nothing"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-IsOrgAdmin", "true") // the org's default card is its admin's to choose
resp := driveSeeded(func(c *zip.Ctx) {
c.Locals("organization", org)
c.SetContext(ctx)
}, "/v1/billing/customers/:id/default-payment-method", req, SetDefaultPaymentMethod)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status=%d, want 404", resp.StatusCode)
}
if got := defaults(t, db, "defroute"); len(got) != 1 || got[0] != held.Id() {
t.Fatalf("defaults=%v after a refused choice, want [%s] unchanged", got, held.Id())
}
}
// A choice made at the commerce route moves the default and leaves both cards
// listed where every reader finds them.
func TestSetDefaultPaymentMethod_MovesTheDefaultAndKeepsTheCardsListed(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("defroutemove")
db := datastore.New(org.Namespaced(ctx))
a := card(t, db, "defroutemove", "ccof_a", true)
b := card(t, db, "defroutemove", "ccof_b", false)
req := httptest.NewRequest(http.MethodPost, "/v1/billing/customers/defroutemove/default-payment-method",
bytes.NewBufferString(`{"paymentMethodId":"`+b.Id()+`"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-IsOrgAdmin", "true") // the org's default card is its admin's to choose
resp := driveSeeded(func(c *zip.Ctx) {
c.Locals("organization", org)
c.SetContext(ctx)
}, "/v1/billing/customers/:id/default-payment-method", req, SetDefaultPaymentMethod)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status=%d, want 200", resp.StatusCode)
}
if rows := pmsFor(t, db, "defroutemove"); len(rows) != 2 {
t.Fatalf("listed cards=%d after the choice, want both", len(rows))
}
if got := defaults(t, db, "defroutemove"); len(got) != 1 || got[0] != b.Id() {
t.Fatalf("defaults=%v, want [%s] (was %s)", got, b.Id(), a.Id())
}
}