Files
commerce/api/billing/refusal_paths_test.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)
}
}
}