Files
commerce/api/billing/payment_methods_test.go
zooqueenandHanzo Dev 767d47df92 fix(billing): one Square customer per billing subject, found or made once
saveCard resolves the subject's Square customer under a per-(namespace,
subject) lock: the customer a saved card names, else the customer Square
holds under reference_id "<namespace>/<subject>" (SearchCustomers, exact
match, oldest first), else a new one created under that reference. A
declined vault followed by a retry, and submits at once, reuse the first
customer instead of making another.

CreateCustomer sets reference_id from metadata["reference"] and sends an
idempotency key: the sha256 of the request body, cut to 45 characters, so
the same inputs always carry the same key and the same body. Square's
search is eventually consistent; the key covers a retry seconds after a
create, the reference search covers later ones. A failed search is logged
and the keyed create still runs. A reference over Square's 100-character
limit is fitted to its digest, identically in the create and the search.

Co-authored-by: Hanzo Dev <[email protected]>
2026-10-14 14:15:41 -07:00

138 lines
4.5 KiB
Go

package billing
import (
"context"
"errors"
"testing"
"github.com/hanzoai/commerce/models/types/currency"
"github.com/hanzoai/commerce/payment/processor"
)
// mockSquareProcessor implements processor.PaymentProcessor + preAuthVerifier
type mockSquareProcessor = MockSquareProcessor
func newMockSquare(authorizeErr error, authorizeID string, cancelErr error) *mockSquareProcessor {
m := &MockSquareProcessor{
BaseProcessor: *processor.NewBaseProcessor(processor.Square, []currency.Type{currency.USD}),
authorizeErr: authorizeErr,
authorizeID: authorizeID,
cancelErr: cancelErr,
}
m.SetConfigured(true)
return m
}
// registerMockSquare replaces the Square processor in the global registry for the duration of a test.
func registerMockSquare(t *testing.T, mock *mockSquareProcessor) func() {
t.Helper()
old, _ := processor.Get(processor.Square)
processor.Register(mock)
return func() {
if old != nil {
processor.Register(old)
}
}
}
// ---- Tests ------------------------------------------------------------------
func TestVerifyCardWithPreAuth_Success(t *testing.T) {
mock := newMockSquare(nil, "pay_abc123", nil)
cleanup := registerMockSquare(t, mock)
defer cleanup()
err := verifyCardWithPreAuth(context.Background(), processor.Global(), "cnon:card-nonce-ok", "user-1")
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
// Authorization should have been cancelled immediately.
if !mock.cancelCalled {
t.Error("expected CancelAuthorization to be called after successful pre-auth")
}
if mock.cancelledID != "pay_abc123" {
t.Errorf("CancelAuthorization called with wrong ID: %q, want %q", mock.cancelledID, "pay_abc123")
}
}
func TestVerifyCardWithPreAuth_CardDeclined(t *testing.T) {
mock := newMockSquare(errors.New("CARD_DECLINED"), "", nil)
cleanup := registerMockSquare(t, mock)
defer cleanup()
err := verifyCardWithPreAuth(context.Background(), processor.Global(), "cnon:card-nonce-declined", "user-2")
if err == nil {
t.Fatal("expected an error for declined card, got nil")
}
if mock.cancelCalled {
t.Error("CancelAuthorization must not be called when pre-auth fails")
}
}
func TestVerifyCardWithPreAuth_CancelFailureIsNonFatal(t *testing.T) {
// Cancel fails but the overall verification should still succeed.
mock := newMockSquare(nil, "pay_xyz789", errors.New("network timeout"))
cleanup := registerMockSquare(t, mock)
defer cleanup()
err := verifyCardWithPreAuth(context.Background(), processor.Global(), "cnon:card-nonce-ok", "user-3")
if err != nil {
t.Fatalf("cancel failure must be non-fatal, but got: %v", err)
}
if !mock.cancelCalled {
t.Error("expected CancelAuthorization to be attempted")
}
}
func TestVerifyCardWithPreAuth_NoSquareProcessor(t *testing.T) {
// Unregister Square to simulate it not being configured. Capture the
// prior registration and restore it on cleanup so this test does not
// pollute the shared global registry for tests that run after it.
old, _ := processor.Get(processor.Square)
processor.Global().Unregister(processor.Square)
defer func() {
if old != nil {
processor.Register(old)
}
}()
err := verifyCardWithPreAuth(context.Background(), processor.Global(), "cnon:card-nonce", "user-4")
if err != nil {
t.Fatalf("should skip pre-auth gracefully when Square is not registered, got: %v", err)
}
}
// ---- Card-on-file -----------------------------------------------------------
func TestAttachSquareCardOnFile_VaultsUnderTheCustomer(t *testing.T) {
m := newMockSquare(nil, "", nil)
m.addedCardID = "ccof_second"
cof, err := attachSquareCardOnFile(context.Background(), m, "cust_existing", "cnon:second-card")
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if m.createCustomerCalls != 0 {
t.Errorf("attaching a card must not create a customer, got %d calls", m.createCustomerCalls)
}
if cof.CustomerID != "cust_existing" || cof.CardID != "ccof_second" {
t.Errorf("unexpected card-on-file: %+v", cof)
}
if m.addCardCustomerID != "cust_existing" {
t.Errorf("AddPaymentMethod got customer %q, want %q", m.addCardCustomerID, "cust_existing")
}
if m.addCardNonce != "cnon:second-card" {
t.Errorf("AddPaymentMethod got nonce %q, want the single-use nonce", m.addCardNonce)
}
}
func TestAttachSquareCardOnFile_DeclinedCard(t *testing.T) {
m := newMockSquare(nil, "", nil)
m.addCardErr = errors.New("CARD_DECLINED")
if _, err := attachSquareCardOnFile(context.Background(), m, "cust_decline", "cnon:bad"); err == nil {
t.Fatal("expected an error when the card is declined, got nil")
}
}