mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
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]>
215 lines
7.7 KiB
Go
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())
|
|
}
|
|
}
|