mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
292 lines
13 KiB
Go
292 lines
13 KiB
Go
package billing
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"testing"
|
|
|
|
"github.com/hanzoai/commerce/datastore"
|
|
"github.com/hanzoai/commerce/payment/processor"
|
|
"github.com/hanzoai/commerce/util/test/ae"
|
|
)
|
|
|
|
// declineOn is a Square refusal with the given code.
|
|
func declineOn(code string) *processor.Decline {
|
|
return &processor.Decline{Processor: processor.Square, Category: "PAYMENT_METHOD_ERROR", Code: code}
|
|
}
|
|
|
|
// answeredWith is a Square failure that is not a refusal, as [thirdparty/square]
|
|
// scrubs it: the HTTP status, the category and the code.
|
|
func answeredWith(status int, category, code string) *processor.PaymentError {
|
|
pe := processor.NewPaymentError(processor.Square, code, fmt.Sprintf("square answered %d %s %s", status, category, code), nil)
|
|
pe.Status = status
|
|
return pe
|
|
}
|
|
|
|
// unanswered is a Square failure that is not a refusal and not the buyer's: the
|
|
// merchant's own token has expired, and the processor never looked at the card.
|
|
var unanswered = answeredWith(401, "AUTHENTICATION_ERROR", "ACCESS_TOKEN_EXPIRED")
|
|
|
|
// rejected is Square refusing the request itself: a spent card token.
|
|
var rejected = answeredWith(400, "INVALID_REQUEST_ERROR", "CARD_TOKEN_USED")
|
|
|
|
// saveCardAs saves a fresh card for subject through CreateMethod, the card-save path.
|
|
func saveCardAs(ctx context.Context, org string, subject, nonce string) error {
|
|
_, _, err := CreateMethod(ctx, moneyOrg(org), "buyer@test", nil, CreateMethodIn{CustomerId: subject, Type: "card", ProviderRef: nonce})
|
|
return err
|
|
}
|
|
|
|
// refusalsOf is the wallet's failed-attempt count for this window.
|
|
func refusalsOf(ctx context.Context, org, subject string) int {
|
|
return count(datastore.New(moneyOrg(org).Namespaced(ctx)), walletScope+subject, windowOf())
|
|
}
|
|
|
|
// TestRefusal_CardSaveIsHeldAndCountedLikeACharge — vaulting a card validates it, so a
|
|
// card save is a card attempt: a refusal is counted against the wallet, a wallet at
|
|
// its ceiling has no card vaulted, and a processor that failed to answer is a
|
|
// processor failure, never a decline, and is not counted; a rejected token is.
|
|
func TestRefusal_CardSaveIsHeldAndCountedLikeACharge(t *testing.T) {
|
|
ctx := ae.NewContext()
|
|
defer ctx.Close()
|
|
m := squareMock("cust_s", "ccof_s", "")
|
|
m.addCardErr = declineOn("CVV_FAILURE")
|
|
withFakeSquare(t, m)
|
|
|
|
err := saveCardAs(ctx, "save-co", "save-co", "cnon:0")
|
|
if !IsCardDeclined(err) || err.Error() != "The security code (CVV) didn't match." {
|
|
t.Fatalf("a refused card save answered %v", err)
|
|
}
|
|
if n := refusalsOf(ctx, "save-co", "save-co"); n != 1 {
|
|
t.Fatalf("a refused card save counted %d refusals, want 1", n)
|
|
}
|
|
for i := 1; i < declineCeiling; i++ {
|
|
_ = saveCardAs(ctx, "save-co", "save-co", fmt.Sprintf("cnon:%d", i))
|
|
}
|
|
m.addCardErr = nil
|
|
if err := saveCardAs(ctx, "save-co", "save-co", "cnon:good"); !IsDeclineCeiling(err) {
|
|
t.Errorf("a card save at the ceiling answered %v", err)
|
|
}
|
|
|
|
m.addCardErr = unanswered
|
|
err = saveCardAs(ctx, "save-co2", "save-co2", "cnon:x")
|
|
if !IsProcessorFailed(err) || IsCardDeclined(err) {
|
|
t.Errorf("a vault the processor failed answered %v (declined %v)", err, IsCardDeclined(err))
|
|
}
|
|
if n := refusalsOf(ctx, "save-co2", "save-co2"); n != 0 {
|
|
t.Errorf("a processor failure on card save counted %d failed attempts", n)
|
|
}
|
|
m.addCardErr = rejected
|
|
if err := saveCardAs(ctx, "save-co2", "save-co2", "cnon:spent"); !IsProcessorFailed(err) || IsCardDeclined(err) {
|
|
t.Errorf("a vault that rejected the token answered %v", err)
|
|
}
|
|
if n := refusalsOf(ctx, "save-co2", "save-co2"); n != 1 {
|
|
t.Errorf("a rejected token on card save counted %d failed attempts, want 1", n)
|
|
}
|
|
}
|
|
|
|
// TestRefusal_TheSavedCardTopUpIsHeldAndCounted — the buyer's saved-card top-up is a
|
|
// card attempt: a refusal and a rejected request count, the ceiling holds, and a
|
|
// processor failure is neither a decline nor a refusal and is not counted.
|
|
func TestRefusal_TheSavedCardTopUpIsHeldAndCounted(t *testing.T) {
|
|
ctx := ae.NewContext()
|
|
defer ctx.Close()
|
|
org := moneyOrg("saved-co")
|
|
pm := seedSavedCard(t, datastore.New(org.Namespaced(ctx)), "saved-co", "ccof_sv", "cust_sv")
|
|
m := squareMock("cust_sv", "ccof_sv", "sqpay_sv")
|
|
withFakeSquare(t, m)
|
|
topup := func(i int) error {
|
|
_, err := TopupCard(ctx, org, TopupCardIn{MethodID: pm.Id(), AmountCents: 500 + int64(i), Subject: "saved-co"})
|
|
return err
|
|
}
|
|
|
|
m.chargeErr = unanswered
|
|
if err := topup(100); !IsProcessorFailed(err) {
|
|
t.Fatalf("a saved-card charge the processor failed answered %v", err)
|
|
}
|
|
if _, ok := DeclineOf(topup(101)); ok {
|
|
t.Error("a processor failure on the saved-card top-up read as a decline")
|
|
}
|
|
if n := refusalsOf(ctx, "saved-co", "saved-co"); n != 0 {
|
|
t.Fatalf("processor failures counted %d failed attempts", n)
|
|
}
|
|
m.chargeErr = rejected
|
|
for i := 102; i < 104; i++ {
|
|
if err := topup(i); !IsProcessorFailed(err) {
|
|
t.Fatalf("a saved-card charge Square rejected answered %v", err)
|
|
}
|
|
}
|
|
if n := refusalsOf(ctx, "saved-co", "saved-co"); n != 2 {
|
|
t.Fatalf("two rejected requests counted %d failed attempts, want 2", n)
|
|
}
|
|
|
|
m.chargeErr = declineOn("CARD_DECLINED")
|
|
for i := range declineCeiling - 2 {
|
|
if _, ok := DeclineOf(topup(i)); !ok {
|
|
t.Fatalf("refusal %d was not a decline", i)
|
|
}
|
|
}
|
|
if n := refusalsOf(ctx, "saved-co", "saved-co"); n != declineCeiling {
|
|
t.Fatalf("%d refusals counted, want %d", n, declineCeiling)
|
|
}
|
|
calls := m.chargeCalls
|
|
if err := topup(99); !IsDeclineCeiling(err) {
|
|
t.Errorf("a saved-card top-up at the ceiling answered %v", err)
|
|
}
|
|
if m.chargeCalls != calls {
|
|
t.Error("the card was tried after the ceiling")
|
|
}
|
|
}
|
|
|
|
// TestRefusal_APlanSaleVaultRefusalIsCounted — the card a fresh-card sale vaults is
|
|
// validated there, so a refusal at the vault counts against the wallet like a refused
|
|
// charge.
|
|
func TestRefusal_APlanSaleVaultRefusalIsCounted(t *testing.T) {
|
|
ctx := ae.NewContext()
|
|
defer ctx.Close()
|
|
m := squareMock("cust_pv", "ccof_pv", "sqpay_pv")
|
|
m.addCardErr = declineOn("VERIFY_AVS_FAILURE")
|
|
withFakeSquare(t, m)
|
|
_, err := SubscribeCard(ctx, moneyOrg("sale-vault"), SubscribeIn{SourceID: "cnon:v", PlanID: "dev", Subject: "sale-vault"})
|
|
if !IsSaleDeclined(err) {
|
|
t.Fatalf("a refused vault answered %v", err)
|
|
}
|
|
if n := refusalsOf(ctx, "sale-vault", "sale-vault"); n != 1 {
|
|
t.Errorf("a refused sale vault counted %d refusals, want 1", n)
|
|
}
|
|
}
|
|
|
|
// TestRefusal_OneCardAttemptPerWalletAtATime — an attempt the wallet already has in
|
|
// progress holds the next one, on every buyer path, so no run of parallel attempts
|
|
// reaches the processor past the ceiling.
|
|
func TestRefusal_OneCardAttemptPerWalletAtATime(t *testing.T) {
|
|
ctx := ae.NewContext()
|
|
defer ctx.Close()
|
|
org := moneyOrg("flight-co")
|
|
db := datastore.New(org.Namespaced(ctx))
|
|
pm := seedSavedCard(t, db, "flight-co", "ccof_f", "cust_f")
|
|
m := squareMock("cust_f", "ccof_f", "sqpay_f")
|
|
withFakeSquare(t, m)
|
|
|
|
release, err := attempt(db, org.Name, "flight-co")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, f := TakePayment(ctx, org, TakePaymentIn{SourceID: "cnon:t", AmountCents: 500, Subject: "flight-co"}); f == nil || f.Status != 409 || !IsAttemptInFlight(f.Err) {
|
|
t.Errorf("a token top-up during another attempt answered %+v", f)
|
|
}
|
|
if _, err := TopupCard(ctx, org, TopupCardIn{MethodID: pm.Id(), AmountCents: 500, Subject: "flight-co"}); !IsAttemptInFlight(err) {
|
|
t.Errorf("a saved-card top-up during another attempt answered %v", err)
|
|
}
|
|
if _, err := SubscribeCard(ctx, org, SubscribeIn{SourceID: "cnon:s", PlanID: "dev", Subject: "flight-co"}); !IsAttemptInFlight(err) {
|
|
t.Errorf("a plan sale during another attempt answered %v", err)
|
|
}
|
|
if err := saveCardAs(ctx, "flight-co", "flight-co", "cnon:c"); !IsAttemptInFlight(err) {
|
|
t.Errorf("a card save during another attempt answered %v", err)
|
|
}
|
|
if _, f := TakePayment(ctx, org, TakePaymentIn{SourceID: "cnon:o", AmountCents: 500, Subject: "flight-co/other"}); f != nil {
|
|
t.Errorf("another wallet in the org was held: %+v", f)
|
|
}
|
|
if m.chargeCalls != 1 {
|
|
t.Errorf("the processor was asked %d times, want once (the other wallet)", m.chargeCalls)
|
|
}
|
|
release()
|
|
if _, f := TakePayment(ctx, org, TakePaymentIn{SourceID: "cnon:after", AmountCents: 600, Subject: "flight-co"}); f != nil {
|
|
t.Errorf("after the attempt ended the wallet answered %+v", f)
|
|
}
|
|
}
|
|
|
|
// TestRefusal_APendingPaymentIsProcessingNotADecline — a payment the processor took
|
|
// and has not settled answers 409 "still processing", counts nothing against the
|
|
// wallet, and leaves the next attempt on the same gateway key; a sale in that state is
|
|
// held, not declined.
|
|
func TestRefusal_APendingPaymentIsProcessingNotADecline(t *testing.T) {
|
|
ctx := ae.NewContext()
|
|
defer ctx.Close()
|
|
pending := &processor.Decline{Processor: processor.Square, Category: processor.StatusCategory, Code: "PENDING"}
|
|
m := squareMock("cust_pp", "ccof_pp", "sqpay_pp")
|
|
m.chargeErr = pending
|
|
withFakeSquare(t, m)
|
|
|
|
org := moneyOrg("pending-co")
|
|
_, f := TakePayment(ctx, org, TakePaymentIn{SourceID: "cnon:a", AmountCents: 500, Subject: "pending-co"})
|
|
if f == nil || f.Status != 409 || f.Message != "Your payment is still processing. Please don't pay again." {
|
|
t.Fatalf("a pending payment answered %+v", f)
|
|
}
|
|
_, _ = TakePayment(ctx, org, TakePaymentIn{SourceID: "cnon:b", AmountCents: 500, Subject: "pending-co"})
|
|
if keys := chargedKeysOf(m); len(keys) != 1 {
|
|
t.Errorf("a retry of a pending payment reached the processor under %d keys, want 1", len(keys))
|
|
}
|
|
if n := refusalsOf(ctx, "pending-co", "pending-co"); n != 0 {
|
|
t.Errorf("a pending payment counted %d refusals", n)
|
|
}
|
|
saved := moneyOrg("pending-saved")
|
|
pm := seedSavedCard(t, datastore.New(saved.Namespaced(ctx)), "pending-saved", "ccof_pp", "cust_pp")
|
|
if _, err := TopupCard(ctx, saved, TopupCardIn{MethodID: pm.Id(), AmountCents: 500, Subject: "pending-saved"}); err == nil {
|
|
t.Error("a pending saved-card top-up answered as settled")
|
|
}
|
|
if n := refusalsOf(ctx, "pending-saved", "pending-saved"); n != 0 {
|
|
t.Errorf("a pending saved-card top-up counted %d failed attempts", n)
|
|
}
|
|
|
|
_, err := SubscribeCard(ctx, moneyOrg("pending-sale"), SubscribeIn{SourceID: "cnon:s", PlanID: "dev", Subject: "pending-sale"})
|
|
if !IsSaleConflict(err) || IsSaleDeclined(err) {
|
|
t.Errorf("a sale whose payment is pending answered %v (conflict %v, declined %v)", err, IsSaleConflict(err), IsSaleDeclined(err))
|
|
}
|
|
}
|
|
|
|
// TestRefusal_ARejectedRequestIsCountedOnEveryBuyerPath — a request the processor
|
|
// rejects (a spent or malformed token) holds the wallet's reservation as long as a
|
|
// refused card, so on every buyer path it spends the wallet's ceiling. A settled
|
|
// payment does not, and neither does anything that is not the buyer's doing: the
|
|
// processor unreachable, a 5xx, a 429, the merchant's own credentials refused, or a
|
|
// reused idempotency key.
|
|
func TestRefusal_ARejectedRequestIsCountedOnEveryBuyerPath(t *testing.T) {
|
|
ctx := ae.NewContext()
|
|
defer ctx.Close()
|
|
m := squareMock("cust_pf", "ccof_pf", "sqpay_pf")
|
|
withFakeSquare(t, m)
|
|
|
|
if _, f := TakePayment(ctx, moneyOrg("pf-ok"), TakePaymentIn{SourceID: "cnon:ok", AmountCents: 500, Subject: "pf-ok"}); f != nil {
|
|
t.Fatalf("a settled token top-up answered %+v", f)
|
|
}
|
|
if n := refusalsOf(ctx, "pf-ok", "pf-ok"); n != 0 {
|
|
t.Errorf("a settled payment counted %d failed attempts", n)
|
|
}
|
|
|
|
for i, failure := range []error{
|
|
fmt.Errorf("read tcp: connection reset by peer"),
|
|
answeredWith(500, "API_ERROR", "INTERNAL_SERVER_ERROR"),
|
|
answeredWith(503, "API_ERROR", "SERVICE_UNAVAILABLE"),
|
|
answeredWith(429, "RATE_LIMIT_ERROR", "RATE_LIMITED"),
|
|
unanswered,
|
|
answeredWith(403, "AUTHENTICATION_ERROR", "INSUFFICIENT_SCOPES"),
|
|
answeredWith(400, "INVALID_REQUEST_ERROR", processor.KeyReused),
|
|
} {
|
|
m.chargeErr = failure
|
|
wallet := fmt.Sprintf("pf-outage-%d", i)
|
|
if _, f := TakePayment(ctx, moneyOrg(wallet), TakePaymentIn{SourceID: "cnon:o", AmountCents: 500, Subject: wallet}); f == nil || f.Status != 502 || !IsProcessorFailed(f.Err) {
|
|
t.Errorf("%v: a token top-up answered %+v", failure, f)
|
|
}
|
|
if n := refusalsOf(ctx, wallet, wallet); n != 0 {
|
|
t.Errorf("%v: counted %d failed attempts against the buyer", failure, n)
|
|
}
|
|
}
|
|
|
|
m.chargeErr = rejected
|
|
if _, f := TakePayment(ctx, moneyOrg("pf-token"), TakePaymentIn{SourceID: "cnon:t", AmountCents: 500, Subject: "pf-token"}); f == nil || f.Status != 502 || !IsProcessorFailed(f.Err) {
|
|
t.Errorf("a token top-up Square rejected answered %+v", f)
|
|
}
|
|
if _, err := SubscribeCard(ctx, moneyOrg("pf-sale"), SubscribeIn{SourceID: "cnon:s", PlanID: "dev", Subject: "pf-sale"}); !IsProcessorFailed(err) {
|
|
t.Errorf("a plan sale Square rejected answered %v", err)
|
|
}
|
|
m.chargeErr, m.addCardErr = nil, rejected
|
|
if _, err := SubscribeCard(ctx, moneyOrg("pf-vault"), SubscribeIn{SourceID: "cnon:v", PlanID: "dev", Subject: "pf-vault"}); !IsProcessorFailed(err) {
|
|
t.Errorf("a plan sale whose vault Square rejected answered %v", err)
|
|
}
|
|
for _, wallet := range []string{"pf-token", "pf-sale", "pf-vault"} {
|
|
if n := refusalsOf(ctx, wallet, wallet); n != 1 {
|
|
t.Errorf("%s: a rejected request counted %d failed attempts, want 1", wallet, n)
|
|
}
|
|
}
|
|
}
|