fix: adversarial review round — replay-rescue double-charge, discount credit, 2FA/per-IP limits, snapshot encryption, refund reconciliation, VAT, frontend parity, tests+docs
Addresses the adversarial fresh-eyes audit (findings A1-A20) plus review-round fixes: - CRITICAL A1: replay-by-key rescue cross-checks replayed CreatedAt; ccof blind-fail leaves pending with CRITICAL + notification instead of clawing back - A2/A3/A4: till idempotency key restored to unconditional hash; tip rejected in CreateBookingPayment; campaign discount now reduces the charged amount (deposit credit) - A5: admin notifications on blind-fail, manual-refund re-arm, cap-stranded charge-group, webhook FAILED/REJECTED refunds - A6/A10: BuyGiftCard idempotency user-scoped; gift-card slot scan advances past failed rows - A7/A14/A15: 2FA user+IP limiter, SNAPSHOT_ENC_KEY startup validation, accurate pepper/log-delivery docs - A8/A9: snapshot encryption on all write+reuse sites; MPV->SPV effective voucher type (single VAT point) - A11/A12/A13/A16: amount-aware refund reconciliation; completed-booking refund re-validation; till retry dedup; PaymentWasRefunded on SquareClient interface - A17/A18/A19/A20: CI runs npm test; confirm_overflow_tip frontend dialog; unknown-event admin notification; mock token redaction - M7 ConfirmOverflowTip, M9 snapshot encryption, C1 discount ordering regression test - Frontend vitest framework (41 tests), backend coverage for fixed functions, docs corrected (2,269 tests, SUPPORT_EMAIL tokens, resolution status) All 25 backend packages pass; frontend 41/41; build + env-docs green.
This commit is contained in:
@@ -4,10 +4,16 @@ package jobs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/handlers/payments"
|
||||
"crussell/internal/square"
|
||||
"crussell/testutils/testdb"
|
||||
)
|
||||
|
||||
@@ -323,3 +329,296 @@ func TestScanCriticalPaymentLogs_RefundBelowCapNotNotified(t *testing.T) {
|
||||
t.Errorf("expected 0 notifications for a refund below the attempt cap, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// RetryPendingSquareErasures — GDPR Square outbox job (batch-1 fix)
|
||||
// ============================================================
|
||||
|
||||
// recordingErasureClient embeds the dev mock and records every Square erasure
|
||||
// call, with opt-in failure injection, so tests can assert exactly what the
|
||||
// retry-square-erasures job calls (and doesn't call) at Square.
|
||||
type recordingErasureClient struct {
|
||||
square.SquareClient
|
||||
mu sync.Mutex
|
||||
deletedCards []string
|
||||
deletedCustomers []string
|
||||
failCards bool
|
||||
failCustomers bool
|
||||
}
|
||||
|
||||
func (c *recordingErasureClient) DeleteCardOnFile(ctx context.Context, cardID string) error {
|
||||
c.mu.Lock()
|
||||
c.deletedCards = append(c.deletedCards, cardID)
|
||||
c.mu.Unlock()
|
||||
if c.failCards {
|
||||
return fmt.Errorf("square: simulated card erasure failure")
|
||||
}
|
||||
return c.SquareClient.DeleteCardOnFile(ctx, cardID)
|
||||
}
|
||||
|
||||
func (c *recordingErasureClient) DeleteCustomer(ctx context.Context, customerID string) error {
|
||||
c.mu.Lock()
|
||||
c.deletedCustomers = append(c.deletedCustomers, customerID)
|
||||
c.mu.Unlock()
|
||||
if c.failCustomers {
|
||||
return fmt.Errorf("square: simulated customer erasure failure")
|
||||
}
|
||||
return c.SquareClient.DeleteCustomer(ctx, customerID)
|
||||
}
|
||||
|
||||
func (c *recordingErasureClient) cardDeletes() []string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return append([]string(nil), c.deletedCards...)
|
||||
}
|
||||
|
||||
func (c *recordingErasureClient) customerDeletes() []string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return append([]string(nil), c.deletedCustomers...)
|
||||
}
|
||||
|
||||
// newErasureTestClient builds a recording client over the dev mock, forcing
|
||||
// SQUARE_ENVIRONMENT=mock so a developer's production env var can never panic
|
||||
// NewDevClient mid-test.
|
||||
func newErasureTestClient(t *testing.T) *recordingErasureClient {
|
||||
t.Helper()
|
||||
t.Setenv("SQUARE_ENVIRONMENT", "mock")
|
||||
return &recordingErasureClient{SquareClient: square.NewDevClient()}
|
||||
}
|
||||
|
||||
// seedErasureOutboxRow inserts a soft-deleted user_saved_cards row that the
|
||||
// retry-square-erasures job treats as a pending Square erasure outbox entry
|
||||
// (deleted_at set + last_4 = 'XXXX' + at least one Square reference). Cleanup
|
||||
// removes the row and any critical notifications the job raised.
|
||||
func seedErasureOutboxRow(t *testing.T, squareCardID, squareCustomerID *string) string {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
var cardID, customerID any
|
||||
if squareCardID != nil {
|
||||
cardID = *squareCardID
|
||||
}
|
||||
if squareCustomerID != nil {
|
||||
customerID = *squareCustomerID
|
||||
}
|
||||
var id string
|
||||
if err := db.Conn.QueryRow(ctx, `
|
||||
INSERT INTO user_saved_cards (square_card_id, square_customer_id, brand, last_4, exp_month, exp_year, deleted_at)
|
||||
VALUES ($1, $2, 'VISA', 'XXXX', 12, 2030, NOW())
|
||||
RETURNING id
|
||||
`, cardID, customerID).Scan(&id); err != nil {
|
||||
t.Fatalf("failed to seed erasure outbox row: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.Conn.Exec(ctx, "DELETE FROM user_saved_cards WHERE id = $1", id)
|
||||
_, _ = db.Conn.Exec(ctx, "DELETE FROM admin_notifications WHERE reason = 'critical_payment_log'")
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
// querySquareCardID returns the square_card_id of a row, or nil when NULL.
|
||||
func querySquareCardID(ctx context.Context, t *testing.T, rowID string) *string {
|
||||
t.Helper()
|
||||
var id *string
|
||||
if err := db.Conn.QueryRow(ctx, "SELECT square_card_id FROM user_saved_cards WHERE id = $1", rowID).Scan(&id); err != nil {
|
||||
t.Fatalf("failed to query square_card_id for row %s: %v", rowID, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func querySquareCustomerID(ctx context.Context, t *testing.T, rowID string) *string {
|
||||
t.Helper()
|
||||
var id *string
|
||||
if err := db.Conn.QueryRow(ctx, "SELECT square_customer_id FROM user_saved_cards WHERE id = $1", rowID).Scan(&id); err != nil {
|
||||
t.Fatalf("failed to query square_customer_id for row %s: %v", rowID, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// erasureNotificationID mirrors handlers/user's deterministic notification id
|
||||
// scheme so the test can assert the exact alert row the job raised.
|
||||
func erasureNotificationID(key string) string {
|
||||
sum := sha256.Sum256([]byte("square-erasure-failure:" + key))
|
||||
return "S" + hex.EncodeToString(sum[:])[:11]
|
||||
}
|
||||
|
||||
// TestRetryPendingSquareErasures_DrainsCardOutboxRow verifies a pending card
|
||||
// erasure is retried at Square and, on success, the outbox row is drained
|
||||
// (square_card_id NULLed) and reported in the drained count.
|
||||
func TestRetryPendingSquareErasures_DrainsCardOutboxRow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
client := newErasureTestClient(t)
|
||||
|
||||
card, err := client.CreateCardOnFile(ctx, "user-delete-me", "cnon:test-card", "cus_mock_seed")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to seed mock card: %v", err)
|
||||
}
|
||||
cardID := card.CardID
|
||||
rowID := seedErasureOutboxRow(t, &cardID, nil)
|
||||
|
||||
orig := payments.SquareClient
|
||||
payments.SquareClient = client
|
||||
t.Cleanup(func() { payments.SquareClient = orig })
|
||||
|
||||
n, err := RetryPendingSquareErasures(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("RetryPendingSquareErasures failed: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("expected 1 drained row, got %d", n)
|
||||
}
|
||||
if got := client.cardDeletes(); len(got) != 1 || got[0] != cardID {
|
||||
t.Errorf("expected exactly 1 card deletion call for %q, got %v", cardID, got)
|
||||
}
|
||||
if id := querySquareCardID(ctx, t, rowID); id != nil {
|
||||
t.Errorf("expected square_card_id to be NULL after drain, got %q", *id)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetryPendingSquareErasures_DrainsCustomerOutboxRow verifies the same
|
||||
// drain for a customer-only outbox row.
|
||||
func TestRetryPendingSquareErasures_DrainsCustomerOutboxRow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
client := newErasureTestClient(t)
|
||||
|
||||
cust, err := client.CreateCustomer(ctx, "Erasure Test", "erasure-test@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to seed mock customer: %v", err)
|
||||
}
|
||||
customerID := cust.ID
|
||||
rowID := seedErasureOutboxRow(t, nil, &customerID)
|
||||
|
||||
orig := payments.SquareClient
|
||||
payments.SquareClient = client
|
||||
t.Cleanup(func() { payments.SquareClient = orig })
|
||||
|
||||
n, err := RetryPendingSquareErasures(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("RetryPendingSquareErasures failed: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("expected 1 drained row, got %d", n)
|
||||
}
|
||||
if got := client.customerDeletes(); len(got) != 1 || got[0] != customerID {
|
||||
t.Errorf("expected exactly 1 customer deletion call for %q, got %v", customerID, got)
|
||||
}
|
||||
if id := querySquareCustomerID(ctx, t, rowID); id != nil {
|
||||
t.Errorf("expected square_customer_id to be NULL after drain, got %q", *id)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetryPendingSquareErasures_KeepsFailedCardRowForRetry verifies a failed
|
||||
// Square deletion leaves the outbox row armed for the next run and raises a
|
||||
// deduped critical notification (row-scoped key).
|
||||
func TestRetryPendingSquareErasures_KeepsFailedCardRowForRetry(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
client := newErasureTestClient(t)
|
||||
client.failCards = true
|
||||
|
||||
cardID := "ccof:mock_missing_card"
|
||||
rowID := seedErasureOutboxRow(t, &cardID, nil)
|
||||
|
||||
orig := payments.SquareClient
|
||||
payments.SquareClient = client
|
||||
t.Cleanup(func() { payments.SquareClient = orig })
|
||||
|
||||
n, err := RetryPendingSquareErasures(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("RetryPendingSquareErasures failed: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("expected 0 drained rows on failure, got %d", n)
|
||||
}
|
||||
if id := querySquareCardID(ctx, t, rowID); id == nil || *id != cardID {
|
||||
t.Errorf("expected square_card_id %q to be retained for retry, got %v", cardID, id)
|
||||
}
|
||||
|
||||
var gotID string
|
||||
if err := db.Conn.QueryRow(ctx, "SELECT id FROM admin_notifications WHERE reason = 'critical_payment_log'").Scan(&gotID); err != nil {
|
||||
t.Fatalf("expected a critical_payment_log notification to be raised: %v", err)
|
||||
}
|
||||
if want := erasureNotificationID("row:" + rowID); gotID != want {
|
||||
t.Errorf("expected notification id %q, got %q", want, gotID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetryPendingSquareErasures_NoOpWithoutSquareClient verifies the job is a
|
||||
// no-op when no Square client is configured: no external call, no drain, no
|
||||
// error.
|
||||
func TestRetryPendingSquareErasures_NoOpWithoutSquareClient(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cardID := "ccof:mock_card"
|
||||
rowID := seedErasureOutboxRow(t, &cardID, nil)
|
||||
|
||||
orig := payments.SquareClient
|
||||
payments.SquareClient = nil
|
||||
t.Cleanup(func() { payments.SquareClient = orig })
|
||||
|
||||
n, err := RetryPendingSquareErasures(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("RetryPendingSquareErasures failed: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("expected 0 drained rows without a Square client, got %d", n)
|
||||
}
|
||||
if id := querySquareCardID(ctx, t, rowID); id == nil || *id != cardID {
|
||||
t.Errorf("expected outbox row to be untouched, got square_card_id %v", id)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetryPendingSquareErasures_KeepsSharedCustomerReferencedByActiveCard
|
||||
// verifies a shared Square customer is NOT deleted (and its outbox row is
|
||||
// drained as deliberately-skipped) while any active card of another account
|
||||
// still references it.
|
||||
func TestRetryPendingSquareErasures_KeepsSharedCustomerReferencedByActiveCard(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
client := newErasureTestClient(t)
|
||||
|
||||
cust, err := client.CreateCustomer(ctx, "Shared User", "shared-erasure@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to seed mock customer: %v", err)
|
||||
}
|
||||
customerID := cust.ID
|
||||
|
||||
outboxRowID := seedErasureOutboxRow(t, nil, &customerID)
|
||||
|
||||
var activeUserID string
|
||||
if err := db.Conn.QueryRow(ctx, `
|
||||
INSERT INTO users (n_first_name, n_last_name, phone, date_of_birth)
|
||||
VALUES ('Active', 'User', '+447700900127', '1990-01-01')
|
||||
RETURNING id`).Scan(&activeUserID); err != nil {
|
||||
t.Fatalf("failed to seed active user: %v", err)
|
||||
}
|
||||
var activeRowID string
|
||||
if err := db.Conn.QueryRow(ctx, `
|
||||
INSERT INTO user_saved_cards (user_id, square_card_id, square_customer_id, brand, last_4, exp_month, exp_year)
|
||||
VALUES ($1, 'ccof:mock_active', $2, 'VISA', '4242', 12, 2030)
|
||||
RETURNING id`, activeUserID, customerID).Scan(&activeRowID); err != nil {
|
||||
t.Fatalf("failed to seed active card row: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.Conn.Exec(ctx, "DELETE FROM user_saved_cards WHERE id = $1", activeRowID)
|
||||
_, _ = db.Conn.Exec(ctx, "DELETE FROM users WHERE id = $1", activeUserID)
|
||||
})
|
||||
|
||||
orig := payments.SquareClient
|
||||
payments.SquareClient = client
|
||||
t.Cleanup(func() { payments.SquareClient = orig })
|
||||
|
||||
n, err := RetryPendingSquareErasures(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("RetryPendingSquareErasures failed: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("expected 1 drained outbox row, got %d", n)
|
||||
}
|
||||
if got := client.customerDeletes(); len(got) != 0 {
|
||||
t.Errorf("expected NO DeleteCustomer call for a still-referenced customer, got %v", got)
|
||||
}
|
||||
if id := querySquareCustomerID(ctx, t, outboxRowID); id != nil {
|
||||
t.Errorf("expected outbox row square_customer_id to be drained, got %q", *id)
|
||||
}
|
||||
if id := querySquareCustomerID(ctx, t, activeRowID); id == nil || *id != customerID {
|
||||
t.Errorf("expected active row to keep its customer reference, got %v", id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,10 +55,17 @@ func (p *ProdClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*
|
||||
return refundPaymentHTTP(ctx, req)
|
||||
}
|
||||
|
||||
func (p *ProdClient) PaymentWasRefunded(ctx context.Context, paymentID string) (bool, error) {
|
||||
return paymentWasRefundedWithClient(ctx, paymentID, newHTTPClient())
|
||||
}
|
||||
|
||||
func (p *ProdClient) CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*CardOnFile, error) {
|
||||
return createCardOnFileHTTP(ctx, userID, cardToken, customerID)
|
||||
}
|
||||
|
||||
// GetCardsOnFile has ZERO production callers (grep across the repo confirms
|
||||
// the only users are this package's tests) and is kept on the SquareClient
|
||||
// interface solely so the dev mock's List Cards parity tests can exercise it.
|
||||
func (p *ProdClient) GetCardsOnFile(ctx context.Context, userID string) ([]CardOnFile, error) {
|
||||
return getCardsOnFileHTTP(ctx, userID)
|
||||
}
|
||||
|
||||
@@ -22,18 +22,21 @@ package square
|
||||
//
|
||||
// FAULT-INJECTION TOGGLES. The mock exposes opt-in toggles (ShouldFail,
|
||||
// FailRefundCode, ForceCheckoutState, ForceRefundPending, FailCreateCheckout,
|
||||
// FailAfterCommit, SimulateSourceUsed, ForcePaymentStatus) that let dev/tests
|
||||
// drive Square failure modes that are otherwise only reachable against the real
|
||||
// API. FailAfterCommit simulates the exact "charged but response lost → same-key
|
||||
// retry" prod scenario: CreatePayment COMMITS the charge (retaining the key
|
||||
// and source in the ledgers exactly like a successful charge) and THEN returns
|
||||
// a 5xx-style error to the caller. A subsequent CreatePayment with the SAME
|
||||
// key + SAME source dedups to the committed payment, proving no double charge.
|
||||
// FailAfterCommit, SimulateSourceUsed, ForcePaymentStatus,
|
||||
// SimulateVerificationRequired) that let dev/tests drive Square failure modes
|
||||
// that are otherwise only reachable against the real API. FailAfterCommit
|
||||
// simulates the exact "charged but response lost → same-key retry" prod
|
||||
// scenario: CreatePayment COMMITS the charge (retaining the key and source in
|
||||
// the ledgers exactly like a successful charge) and THEN returns a 5xx-style
|
||||
// error to the caller. A subsequent CreatePayment with the SAME key + SAME
|
||||
// source dedups to the committed payment, proving no double charge.
|
||||
// SimulateSourceUsed simulates Square's SOURCE_USED rejection of a card source
|
||||
// (cnon: nonce) reused after a previous save. ForcePaymentStatus forces
|
||||
// CreatePayment's payment status while returning nil error — the "Square
|
||||
// returned 200 with a non-terminal payment" prod scenario, so a status-blind
|
||||
// handler (records 'completed' on nil error alone) is caught in dev.
|
||||
// SimulateVerificationRequired mirrors Square's SCA enforcement on
|
||||
// customer-initiated new-card charges (see the field doc).
|
||||
//
|
||||
// REAL-API SAFETY GUARD. A `//go:build dev` build must never silently route to
|
||||
// the real PRODUCTION Square API on an env-string match alone — a typo'd or
|
||||
@@ -114,18 +117,24 @@ type MockClient struct {
|
||||
// committed payment — never a second charge — exercising the retry path
|
||||
// devs hit in prod when Square processes a charge but the response is lost.
|
||||
FailAfterCommit bool
|
||||
// SimulateSourceUsed makes CreateCardOnFile enforce Square's SOURCE_USED
|
||||
// rejection: a card source (cnon: nonce) already used to create a card on
|
||||
// this mock instance is rejected with the same structured 400 SOURCE_USED
|
||||
// error real Square's CreateCard API returns (SOURCE_USED — NOT the
|
||||
// CreatePayment code CARD_TOKEN_USED). Off by default — dev/test flows
|
||||
// reuse plain "cnon:test-card"-style tokens across requests, so enforcement
|
||||
// is enabled only in tests that exercise the reused-source rejection.
|
||||
// UsedSources() reports the sources consumed so far.
|
||||
// SimulateSourceUsed enforces Square's single-use source simulation on both
|
||||
// endpoints: CreateCardOnFile rejects a card source (cnon: nonce) already
|
||||
// used to create a card with the structured 400 SOURCE_USED error real
|
||||
// Square's CreateCard API returns, and CreatePayment rejects a cnon nonce
|
||||
// already used to create a payment or card with 400 CARD_TOKEN_USED. Off by
|
||||
// default — the handler integration suite shares ONE mock instance across
|
||||
// parallel tests (testmain_test.go) and reuses "cnon:test-card"-style
|
||||
// tokens across requests, so enforcement is enabled only in tests that
|
||||
// exercise the reused-source rejection. UsedSources() reports the sources
|
||||
// consumed so far.
|
||||
SimulateSourceUsed bool
|
||||
// usedSources records card sources consumed by CreateCardOnFile while
|
||||
// SimulateSourceUsed is enabled (Square consumes a cnon: nonce on card
|
||||
// creation, so reusing it is rejected with SOURCE_USED).
|
||||
// creation, so reusing it is rejected with SOURCE_USED). CreatePayment's
|
||||
// single-use nonce simulation shares the same map: with the toggle on, a
|
||||
// cnon consumed by either endpoint is rejected on reuse (CARD_TOKEN_USED
|
||||
// from CreatePayment, SOURCE_USED from CreateCardOnFile) — exactly like
|
||||
// real Square, which consumes a nonce regardless of which endpoint used it.
|
||||
usedSources map[string]bool
|
||||
// ForcePaymentStatus forces CreatePayment's payment status instead of the
|
||||
// default "COMPLETED" (or "APPROVED" for autocomplete=false). When set,
|
||||
@@ -137,6 +146,16 @@ type MockClient struct {
|
||||
// see square_http_client.go), so only the handler's own status check can
|
||||
// catch a FAILED/CANCELED/PENDING/APPROVED payment.
|
||||
ForcePaymentStatus string
|
||||
// SimulateVerificationRequired mirrors Square's SCA enforcement on
|
||||
// customer-initiated new-card charges: when true, CreatePayment with a
|
||||
// cnon: (new-card nonce) source that carries no VerificationToken is
|
||||
// rejected with a structured 400 CARD_DECLINED_VERIFICATION_REQUIRED —
|
||||
// the buyer must complete 3DS/SCA verification and re-tokenize, NOT retry
|
||||
// the same request (the code is in definitivePaymentCodes). A present
|
||||
// verification token (e.g. a verify_mock_... token) satisfies the gate and
|
||||
// the charge succeeds. Off by default — existing dev/test flows charge
|
||||
// plain "cnon:test-card"-style tokens without verification tokens.
|
||||
SimulateVerificationRequired bool
|
||||
}
|
||||
|
||||
type devProdClient struct{}
|
||||
@@ -169,6 +188,9 @@ func (d *devProdClient) CancelCheckout(ctx context.Context, checkoutID string) e
|
||||
func (d *devProdClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*RefundResult, error) {
|
||||
return refundPaymentHTTP(ctx, req)
|
||||
}
|
||||
func (d *devProdClient) PaymentWasRefunded(ctx context.Context, paymentID string) (bool, error) {
|
||||
return PaymentWasRefunded(ctx, paymentID)
|
||||
}
|
||||
func (d *devProdClient) CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*CardOnFile, error) {
|
||||
return createCardOnFileHTTP(ctx, userID, cardToken, customerID)
|
||||
}
|
||||
@@ -338,6 +360,47 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror Square's SCA enforcement (opt-in toggle, off by default): a
|
||||
// new-card (cnon:) charge without a 3DS/SCA verification token is rejected
|
||||
// with a structured 400 CARD_DECLINED_VERIFICATION_REQUIRED — the buyer
|
||||
// must re-verify and re-tokenize, NOT retry the same request (the code is
|
||||
// in definitivePaymentCodes). A present verification token (e.g.
|
||||
// verify_mock_...) satisfies the gate exactly as production accepts a
|
||||
// Square-issued verification_token on the CreatePayment body.
|
||||
if m.SimulateVerificationRequired && strings.HasPrefix(req.SourceID, "cnon:") && req.VerificationToken == "" {
|
||||
return nil, &squareAPIError{
|
||||
Code: "CARD_DECLINED_VERIFICATION_REQUIRED",
|
||||
Category: "PAYMENT_METHOD_ERROR",
|
||||
Detail: "card requires buyer verification (3DS/SCA); supply a verification token",
|
||||
StatusCode: http.StatusBadRequest,
|
||||
err: errors.New("square: card requires buyer verification — verification_token required for a new-card (cnon:) charge"),
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror Square's single-use card nonces: when SimulateSourceUsed is set,
|
||||
// a cnon: nonce can only be charged once on this mock instance. Square
|
||||
// consumes a nonce when it is used to create a payment, so reusing it
|
||||
// under a DIFFERENT idempotency key is rejected with CARD_TOKEN_USED (the
|
||||
// CreatePayment code for a used source) — a same-key retry already deduped
|
||||
// above and never reaches here. The consumption is OFF by default: the
|
||||
// handler integration suite shares ONE mock instance across parallel tests
|
||||
// (testmain_test.go assigns a single square.NewDevClient() to the package
|
||||
// global) and reuses "cnon:test-card"-style tokens across tests, so
|
||||
// default-on consumption would break those tests. Tests that need the
|
||||
// single-use simulation flip the toggle on.
|
||||
if strings.HasPrefix(req.SourceID, "cnon:") && m.SimulateSourceUsed {
|
||||
if m.usedSources[req.SourceID] {
|
||||
return nil, &squareAPIError{
|
||||
Code: "CARD_TOKEN_USED",
|
||||
Category: "PAYMENT_METHOD_ERROR",
|
||||
Detail: "The card nonce can no longer be used because it has been used to create a payment",
|
||||
StatusCode: http.StatusBadRequest,
|
||||
err: fmt.Errorf("square: card nonce %s has already been used to create a payment", tokenPrefix(req.SourceID)),
|
||||
}
|
||||
}
|
||||
m.usedSources[req.SourceID] = true
|
||||
}
|
||||
|
||||
now := clock.Now().UTC()
|
||||
|
||||
status := "COMPLETED"
|
||||
@@ -712,15 +775,62 @@ func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*
|
||||
return nil, fmt.Errorf("square: refund amount must be positive (amount_money is required)")
|
||||
}
|
||||
|
||||
if _, ok := m.payments[req.PaymentID]; !ok {
|
||||
// Payment not in mock map — this happens when integration tests
|
||||
// create payments via DB fixture with a square_payment_id, bypassing
|
||||
// the mock. Process the refund without full payment data.
|
||||
log.Printf("[SQUARE-MOCK] RefundPayment: payment %s not in mock map — proceeding without full payment data", req.PaymentID)
|
||||
// Reject refunds for a mock-artifact payment ID that was never created.
|
||||
// The mock mints payment IDs as "pay_mock_<n>"; a refund targeting such an
|
||||
// ID that is NOT in the ledger is a provable bug (that charge never went
|
||||
// through this mock) and real Square answers 404 NOT_FOUND. Non-"pay_mock_"
|
||||
// IDs (e.g. the DB-fixture square_payment_id values handler tests seed
|
||||
// refunds against) are payments that exist outside the mock's ledger —
|
||||
// exactly as they would at real Square — so they take the lenient path.
|
||||
if strings.HasPrefix(req.PaymentID, "pay_mock_") {
|
||||
if _, known := m.payments[req.PaymentID]; !known {
|
||||
log.Printf("[SQUARE-MOCK] RefundPayment REJECTED: payment %s not found (NOT_FOUND)", req.PaymentID)
|
||||
return nil, &squareAPIError{
|
||||
Code: "NOT_FOUND",
|
||||
Category: "INVALID_REQUEST_ERROR",
|
||||
Detail: "The payment_id in the refund request does not exist",
|
||||
StatusCode: http.StatusNotFound,
|
||||
err: fmt.Errorf("square: no payment %s exists to refund", tokenPrefix(req.PaymentID)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
amount := req.Amount
|
||||
|
||||
// Known payments get the real Square over-refund rejection: refunding more
|
||||
// than the remaining balance answers 400 REFUND_AMOUNT_INVALID. Square
|
||||
// returns that SAME code for an already-refunded payment, so — exactly like
|
||||
// the real client — the mock reconciles: an existing refund (money already
|
||||
// moved) → ErrRefundAlreadyProcessed; no refund recorded → the amount is
|
||||
// genuinely invalid → ErrRefundDeclined.
|
||||
if payment, ok := m.payments[req.PaymentID]; ok {
|
||||
remaining := payment.Amount
|
||||
for _, r := range m.refunds {
|
||||
if r.PaymentID == req.PaymentID && (r.Status == "COMPLETED" || r.Status == "APPROVED" || r.Status == "PENDING") {
|
||||
remaining -= r.Amount
|
||||
}
|
||||
}
|
||||
if req.Amount > remaining {
|
||||
apiErr := &squareAPIError{
|
||||
Code: "REFUND_AMOUNT_INVALID",
|
||||
Category: "INVALID_REQUEST_ERROR",
|
||||
Detail: "The refunded amount is more than the remaining balance",
|
||||
StatusCode: http.StatusBadRequest,
|
||||
err: fmt.Errorf("square: refund amount %d exceeds remaining balance %d for payment %s", req.Amount, remaining, req.PaymentID),
|
||||
}
|
||||
if remaining < payment.Amount {
|
||||
return nil, fmt.Errorf("%w: %v", ErrRefundAlreadyProcessed, apiErr)
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %v", ErrRefundDeclined, apiErr)
|
||||
}
|
||||
} else {
|
||||
// Payment not in mock map — this happens when integration tests create
|
||||
// payments via DB fixture with a square_payment_id, bypassing the mock.
|
||||
// Process the refund without full payment data (the balance is unknown,
|
||||
// so no over-refund check applies).
|
||||
log.Printf("[SQUARE-MOCK] RefundPayment: payment %s not in mock map — proceeding without full payment data", req.PaymentID)
|
||||
}
|
||||
|
||||
locationID := req.LocationID
|
||||
if locationID == "" {
|
||||
locationID = "L_MOCK"
|
||||
@@ -758,6 +868,26 @@ func (m *MockClient) RefundKeyCount() int {
|
||||
return len(m.refundByKey)
|
||||
}
|
||||
|
||||
// PaymentWasRefunded mirrors the real client's reconciliation source: true when
|
||||
// any refund with status COMPLETED, APPROVED, or PENDING exists for the payment
|
||||
// (FAILED/REJECTED refunds never moved money and are ignored). Shares the exact
|
||||
// status set the real client's paymentWasRefundedWithClient uses so handler
|
||||
// reconciliation behaves identically in dev/mock and production.
|
||||
func (m *MockClient) PaymentWasRefunded(ctx context.Context, paymentID string) (bool, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
for _, r := range m.refunds {
|
||||
if r.PaymentID != paymentID {
|
||||
continue
|
||||
}
|
||||
switch r.Status {
|
||||
case "COMPLETED", "APPROVED", "PENDING":
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// UsedSources returns the card sources consumed by CreateCardOnFile while
|
||||
// SimulateSourceUsed is enabled. Test accessor for asserting that a reused
|
||||
// source is rejected with SOURCE_USED after a previous save.
|
||||
@@ -883,14 +1013,26 @@ func (m *MockClient) DeleteCardOnFile(ctx context.Context, cardID string) error
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Production callers pass the DB-stored ccof: card reference
|
||||
// (CardOnFile.CardID, e.g. "ccof:mock_..."), which the mock must resolve
|
||||
// through cardByToken (keyed by the full CardID) so the deletion actually
|
||||
// finds and disables the card — previously the mock keyed only by its
|
||||
// mock-local ID (mock_card_...) and silently missed every ccof: call.
|
||||
if card, ok := m.cardByToken[cardID]; ok {
|
||||
card.Enabled = false
|
||||
log.Printf("[SQUARE-MOCK] Card disabled: id=%s (user=%s)", tokenPrefix(cardID), tokenPrefix(card.ReferenceID))
|
||||
return nil
|
||||
}
|
||||
// Fallback for the mock-local ID form (mock_card_...) still exercised by
|
||||
// this package's own tests — resolve the card through the per-user maps.
|
||||
for userID, cards := range m.cards {
|
||||
if card, ok := cards[cardID]; ok {
|
||||
card.Enabled = false
|
||||
log.Printf("[SQUARE-MOCK] Card disabled: id=%s (user=%s)", cardID, userID)
|
||||
log.Printf("[SQUARE-MOCK] Card disabled: id=%s (user=%s)", tokenPrefix(cardID), userID)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("card not found: %s", cardID)
|
||||
return fmt.Errorf("square: card not found: %s", cardID)
|
||||
}
|
||||
|
||||
func (m *MockClient) ListPaymentRefunds(ctx context.Context, paymentID string, beginTime time.Time) ([]RefundResult, error) {
|
||||
|
||||
@@ -438,6 +438,47 @@ func TestDevClient_RefundPayment_RefundAlreadyPending(t *testing.T) {
|
||||
assert.Len(t, client.refundByKey, 0, "no refund-by-key entry must be stored when a refund is already pending")
|
||||
}
|
||||
|
||||
func TestDevClient_PaymentWasRefunded_StatusSet(t *testing.T) {
|
||||
// Locks the mock's PaymentWasRefunded status set against the real client's
|
||||
// reconciliation source (COMPLETED/APPROVED/PENDING → true; FAILED/REJECTED
|
||||
// → false): handler-side REFUND_AMOUNT_INVALID reconciliation must behave
|
||||
// identically in dev/mock and production.
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
|
||||
now := time.Now().UTC()
|
||||
for _, tc := range []struct {
|
||||
status string
|
||||
want bool
|
||||
}{
|
||||
{"COMPLETED", true},
|
||||
{"APPROVED", true},
|
||||
{"PENDING", true},
|
||||
{"FAILED", false},
|
||||
{"REJECTED", false},
|
||||
} {
|
||||
client.mu.Lock()
|
||||
id := fmt.Sprintf("ref_mock_%d", now.UnixNano())
|
||||
client.refunds[id] = &RefundResult{
|
||||
ID: id,
|
||||
Status: tc.status,
|
||||
Amount: 5000,
|
||||
PaymentID: "pay_mock_was_refunded",
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
}
|
||||
client.mu.Unlock()
|
||||
|
||||
got, err := client.PaymentWasRefunded(ctx, "pay_mock_was_refunded")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.want, got, "status %s", tc.status)
|
||||
}
|
||||
|
||||
// A different payment with no refunds reports false.
|
||||
got, err := client.PaymentWasRefunded(ctx, "pay_mock_no_refunds")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, got)
|
||||
}
|
||||
|
||||
func TestDevClient_RefundPayment_FailRefundCode_OtherCode(t *testing.T) {
|
||||
// Any other code configured via FailRefundCode preserves the prior
|
||||
// ErrRefundDeclined classification (e.g. REFUND_DECLINED in prod).
|
||||
@@ -1890,3 +1931,294 @@ func TestDevClient_CreateCheckout_CompletedPaymentResolvableByID(t *testing.T) {
|
||||
assert.Equal(t, completed.ID, got.ID)
|
||||
assert.Equal(t, "COMPLETED", got.Status)
|
||||
}
|
||||
|
||||
// TestDevClient_DeleteCardOnFile_CcofResolution locks the DeleteCardOnFile
|
||||
// ccof: resolution fix: production callers pass the DB-stored ccof: card
|
||||
// reference (CardOnFile.CardID, e.g. "ccof:mock_..."), which the mock must
|
||||
// resolve through cardByToken so the deletion actually finds and disables the
|
||||
// card — previously the mock keyed only by its mock-local ID (mock_card_...)
|
||||
// and silently missed every ccof: call.
|
||||
func TestDevClient_DeleteCardOnFile_CcofResolution(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
userID := "user-ccof-delete"
|
||||
|
||||
t.Run("ccof_card_id_disables_and_hides", func(t *testing.T) {
|
||||
card, err := client.CreateCardOnFile(ctx, userID, "cnon:token-ccof", "cus_test123")
|
||||
require.NoError(t, err)
|
||||
require.True(t, strings.HasPrefix(card.CardID, "ccof:"), "mock CardID must be ccof:-prefixed to exercise the cardByToken path")
|
||||
|
||||
err = client.DeleteCardOnFile(ctx, card.CardID)
|
||||
require.NoError(t, err, "deleting by the DB-stored ccof: CardID must resolve the card through cardByToken")
|
||||
|
||||
// The card object itself must be disabled, not just hidden.
|
||||
client.mu.RLock()
|
||||
deleted := client.cardByToken[card.CardID]
|
||||
client.mu.RUnlock()
|
||||
require.NotNil(t, deleted, "the ccof: token must remain resolvable after deletion")
|
||||
assert.False(t, deleted.Enabled, "the ccof: resolved card must be disabled")
|
||||
|
||||
// Square's List Cards API excludes disabled cards by default — the
|
||||
// deleted card disappears from GetCardsOnFile.
|
||||
cards, err := client.GetCardsOnFile(ctx, userID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, cards, "a card deleted by its ccof: CardID must disappear from GetCardsOnFile")
|
||||
})
|
||||
|
||||
t.Run("mock_local_id_still_works", func(t *testing.T) {
|
||||
card, err := client.CreateCardOnFile(ctx, userID, "cnon:token-local", "cus_test123")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = client.DeleteCardOnFile(ctx, card.ID)
|
||||
require.NoError(t, err, "deleting by the mock-local ID (mock_card_...) must keep working via the per-user fallback")
|
||||
|
||||
cards, err := client.GetCardsOnFile(ctx, userID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, cards, "a card deleted by its mock-local ID must also disappear from GetCardsOnFile")
|
||||
})
|
||||
|
||||
t.Run("unknown_id_errors_not_silent", func(t *testing.T) {
|
||||
err := client.DeleteCardOnFile(ctx, "ccof:never-created")
|
||||
require.Error(t, err, "an unknown card ID must return an error, never silent success")
|
||||
assert.Contains(t, err.Error(), "card not found")
|
||||
})
|
||||
}
|
||||
|
||||
// TestDevClient_RefundPayment_UnknownPayMockID_NotFound locks the mock's
|
||||
// NOT_FOUND strictness: a refund targeting a "pay_mock_*" ID that was never
|
||||
// created is a provable bug (that charge never went through this mock) and real
|
||||
// Square answers 404 NOT_FOUND — the mock must surface the structured error,
|
||||
// never silently proceed.
|
||||
func TestDevClient_RefundPayment_UnknownPayMockID_NotFound(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
|
||||
result, err := client.RefundPayment(ctx, RefundPaymentReq{
|
||||
PaymentID: "pay_mock_never_created",
|
||||
Amount: 5000,
|
||||
IdempotencyKey: "refund-unknown-pay-mock",
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Equal(t, "NOT_FOUND", ErrorCode(err))
|
||||
assert.Equal(t, http.StatusNotFound, ErrorStatusCode(err))
|
||||
|
||||
client.mu.RLock()
|
||||
defer client.mu.RUnlock()
|
||||
assert.Len(t, client.refunds, 0, "no refund must be stored for an unknown pay_mock_* payment")
|
||||
assert.Len(t, client.refundByKey, 0, "no refund-by-key entry must be stored for an unknown pay_mock_* payment")
|
||||
}
|
||||
|
||||
// TestDevClient_RefundPayment_OverRefund locks the mock's real Square
|
||||
// over-refund rejection: refunding more than the remaining balance answers 400
|
||||
// REFUND_AMOUNT_INVALID. Square returns that SAME code for an already-refunded
|
||||
// payment, so — exactly like the real client — the mock reconciles: no refund
|
||||
// recorded yet → the amount is genuinely invalid → ErrRefundDeclined; an
|
||||
// existing refund (money already moved) → ErrRefundAlreadyProcessed. The
|
||||
// exact-remaining boundary refund succeeds.
|
||||
func TestDevClient_RefundPayment_OverRefund(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("over_refund_no_prior_refunds_is_declined", func(t *testing.T) {
|
||||
payment, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 10000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "pay-overrefund-fresh",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := client.RefundPayment(ctx, RefundPaymentReq{
|
||||
PaymentID: payment.ID, Amount: 12000, IdempotencyKey: "refund-overrefund-fresh",
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
// The mock builds a squareAPIError with Code REFUND_AMOUNT_INVALID but
|
||||
// wraps it with %v (exactly like the real client's sentinel wrap), so
|
||||
// the code is not reachable via ErrorCode(err) — the observable
|
||||
// contract is the ErrRefundDeclined sentinel.
|
||||
assert.True(t, errors.Is(err, ErrRefundDeclined), "a genuine over-refund with no prior refunds must be ErrRefundDeclined, got %v", err)
|
||||
assert.False(t, errors.Is(err, ErrRefundAlreadyProcessed))
|
||||
})
|
||||
|
||||
t.Run("exact_remaining_refund_succeeds", func(t *testing.T) {
|
||||
payment, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 10000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "pay-exact-remaining",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// A partial refund leaves 7000 remaining.
|
||||
first, err := client.RefundPayment(ctx, RefundPaymentReq{
|
||||
PaymentID: payment.ID, Amount: 3000, IdempotencyKey: "refund-partial",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "COMPLETED", first.Status)
|
||||
|
||||
// Refunding exactly the remaining balance is NOT an over-refund.
|
||||
second, err := client.RefundPayment(ctx, RefundPaymentReq{
|
||||
PaymentID: payment.ID, Amount: 7000, IdempotencyKey: "refund-exact-remaining",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(7000), second.Amount)
|
||||
assert.Equal(t, "COMPLETED", second.Status)
|
||||
})
|
||||
|
||||
t.Run("over_refund_with_existing_refund_is_already_processed", func(t *testing.T) {
|
||||
payment, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 10000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "pay-overrefund-existing",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// A COMPLETED refund moves money; the remaining balance drops to 7000.
|
||||
first, err := client.RefundPayment(ctx, RefundPaymentReq{
|
||||
PaymentID: payment.ID, Amount: 3000, IdempotencyKey: "refund-first-move",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "COMPLETED", first.Status)
|
||||
|
||||
result, err := client.RefundPayment(ctx, RefundPaymentReq{
|
||||
PaymentID: payment.ID, Amount: 8000, IdempotencyKey: "refund-overrefund-existing",
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
// The REFUND_AMOUNT_INVALID squareAPIError is hidden behind the %v
|
||||
// sentinel wrap (ErrorCode returns ""), so the observable contract is
|
||||
// the ErrRefundAlreadyProcessed sentinel.
|
||||
assert.True(t, errors.Is(err, ErrRefundAlreadyProcessed), "an over-refund on a payment that already has refunds must reconcile to ErrRefundAlreadyProcessed, got %v", err)
|
||||
assert.False(t, errors.Is(err, ErrRefundDeclined))
|
||||
})
|
||||
}
|
||||
|
||||
// TestDevClient_RefundPayment_LenientPathForNonMockIDs locks the lenient refund
|
||||
// path: payment IDs that are NOT "pay_mock_*" (e.g. the DB-fixture
|
||||
// square_payment_id values like "sqp_..." that sweep tests seed refunds
|
||||
// against) exist outside the mock's ledger — exactly as they would at real
|
||||
// Square — so RefundPayment processes them without the NOT_FOUND rejection.
|
||||
func TestDevClient_RefundPayment_LenientPathForNonMockIDs(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
|
||||
result, err := client.RefundPayment(ctx, RefundPaymentReq{
|
||||
PaymentID: "sqp_fixture_123",
|
||||
Amount: 5000,
|
||||
IdempotencyKey: "refund-lenient-sqp",
|
||||
})
|
||||
require.NoError(t, err, "a non-mock fixture payment ID must take the lenient path, not NOT_FOUND")
|
||||
assert.Equal(t, "COMPLETED", result.Status)
|
||||
assert.Equal(t, int64(5000), result.Amount)
|
||||
assert.Equal(t, "sqp_fixture_123", result.PaymentID)
|
||||
}
|
||||
|
||||
// TestDevClient_CreatePayment_SimulateVerificationRequired locks the mock's SCA
|
||||
// enforcement: with SimulateVerificationRequired=true, a new-card (cnon:) charge
|
||||
// without a 3DS/SCA verification token is rejected with a structured 400
|
||||
// CARD_DECLINED_VERIFICATION_REQUIRED (a definitive payment error the buyer must
|
||||
// resolve by re-verifying — never retried as-is); a present verification token
|
||||
// (verify_mock_...) satisfies the gate; and with the toggle off (default) no
|
||||
// verification is required.
|
||||
func TestDevClient_CreatePayment_SimulateVerificationRequired(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
client.SimulateVerificationRequired = true
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("cnon_without_verification_token_is_rejected", func(t *testing.T) {
|
||||
result, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000, Currency: "GBP", SourceID: "cnon:new-card", IdempotencyKey: "verify-req-no-token",
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err))
|
||||
assert.Equal(t, "PAYMENT_METHOD_ERROR", ErrorCategory(err))
|
||||
assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err))
|
||||
assert.True(t, IsDefinitivePaymentError(err), "CARD_DECLINED_VERIFICATION_REQUIRED must classify as a definitive payment error")
|
||||
})
|
||||
|
||||
t.Run("verification_token_satisfies_gate", func(t *testing.T) {
|
||||
result, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000, Currency: "GBP", SourceID: "cnon:new-card", IdempotencyKey: "verify-req-with-token",
|
||||
VerificationToken: "verify_mock_ok",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "COMPLETED", result.Status)
|
||||
})
|
||||
|
||||
t.Run("toggle_off_requires_no_verification", func(t *testing.T) {
|
||||
client.SimulateVerificationRequired = false
|
||||
result, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000, Currency: "GBP", SourceID: "cnon:new-card", IdempotencyKey: "verify-req-default-off",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "COMPLETED", result.Status, "with SimulateVerificationRequired off (default), no verification token is required")
|
||||
})
|
||||
}
|
||||
|
||||
// TestDevClient_CreatePayment_SimulateSourceUsed_CnonConsumption locks the
|
||||
// mock's single-use cnon: nonce simulation on CreatePayment: with
|
||||
// SimulateSourceUsed enabled, a cnon used in one CreatePayment is rejected with
|
||||
// CARD_TOKEN_USED on a second CreatePayment under a DIFFERENT idempotency key;
|
||||
// ccof: (card-on-file) sources are NEVER consumed (they are stored references,
|
||||
// not single-use nonces); and with the toggle off (default) the same cnon can
|
||||
// be reused freely.
|
||||
func TestDevClient_CreatePayment_SimulateSourceUsed_CnonConsumption(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
client.SimulateSourceUsed = true
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("cnon_reuse_rejected_with_card_token_used", func(t *testing.T) {
|
||||
source := "cnon:single-use-pay"
|
||||
first, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000, Currency: "GBP", SourceID: source, IdempotencyKey: "consumed-key-1",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "COMPLETED", first.Status)
|
||||
|
||||
// Second CreatePayment with the SAME cnon under a DIFFERENT key →
|
||||
// Square's CARD_TOKEN_USED rejection (the CreatePayment code for a used
|
||||
// source).
|
||||
result, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000, Currency: "GBP", SourceID: source, IdempotencyKey: "consumed-key-2",
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Equal(t, "CARD_TOKEN_USED", ErrorCode(err))
|
||||
assert.Equal(t, "PAYMENT_METHOD_ERROR", ErrorCategory(err))
|
||||
assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err))
|
||||
assert.Contains(t, client.UsedSources(), source, "the consumed cnon must be reported by UsedSources")
|
||||
})
|
||||
|
||||
t.Run("ccof_sources_are_never_consumed", func(t *testing.T) {
|
||||
// Create the card with the toggle off so the underlying cnon is not
|
||||
// consumed; CreatePayment charges the ccof: CardID, which is a stored
|
||||
// reference rather than a single-use nonce.
|
||||
client.SimulateSourceUsed = false
|
||||
card, err := client.CreateCardOnFile(ctx, "user-ccof-never-consumed", "cnon:card-src", "cus_test123")
|
||||
require.NoError(t, err)
|
||||
client.SimulateSourceUsed = true
|
||||
|
||||
first, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000, Currency: "GBP", SourceID: card.CardID, CustomerID: "cus_test123", IdempotencyKey: "ccof-charge-1",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "COMPLETED", first.Status)
|
||||
|
||||
second, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000, Currency: "GBP", SourceID: card.CardID, CustomerID: "cus_test123", IdempotencyKey: "ccof-charge-2",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "COMPLETED", second.Status, "a ccof: card must be chargeable again under a different key")
|
||||
assert.NotContains(t, client.UsedSources(), card.CardID, "ccof: sources must never be consumed")
|
||||
})
|
||||
|
||||
t.Run("toggle_off_allows_reuse", func(t *testing.T) {
|
||||
client.SimulateSourceUsed = false
|
||||
first, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000, Currency: "GBP", SourceID: "cnon:reused-pay", IdempotencyKey: "reuse-key-1",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "COMPLETED", first.Status)
|
||||
|
||||
second, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000, Currency: "GBP", SourceID: "cnon:reused-pay", IdempotencyKey: "reuse-key-2",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "COMPLETED", second.Status, "with SimulateSourceUsed off (default), the same cnon must be reusable")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -135,7 +135,9 @@ func (c *httpClient) doJSON(ctx context.Context, method, path string, body, targ
|
||||
respBody = respBody[:maxResponseBody]
|
||||
}
|
||||
if resp.StatusCode >= 300 {
|
||||
var errResp struct{ Errors []SquareError `json:"errors"` }
|
||||
var errResp struct {
|
||||
Errors []SquareError `json:"errors"`
|
||||
}
|
||||
if json.Unmarshal(respBody, &errResp) == nil && len(errResp.Errors) > 0 {
|
||||
se := errResp.Errors[0]
|
||||
msg := fmt.Sprintf("square: %s %s: [%s/%s] %s (field: %s)", method, path, se.Category, se.Code, capBody(se.Detail), se.Field)
|
||||
@@ -212,6 +214,11 @@ type sqCreatePaymentRequest struct {
|
||||
TipMoney *sqMoney `json:"tip_money,omitempty"`
|
||||
VerificationToken string `json:"verification_token,omitempty"`
|
||||
BuyerEmailAddress string `json:"buyer_email_address,omitempty"`
|
||||
// CustomerDetails carries customer_initiated so Square classifies the
|
||||
// charge as cardholder-initiated (SCA applies) rather than defaulting to a
|
||||
// merchant-initiated classification. Online card entry is always
|
||||
// cardholder-initiated in this app, so the flag is sent as true when set.
|
||||
CustomerDetails *CreateCustomerDetails `json:"customer_details,omitempty"`
|
||||
}
|
||||
|
||||
type sqCreatePaymentResponse struct {
|
||||
@@ -498,6 +505,7 @@ func buildCreatePaymentBody(req CreatePaymentReq, hc *httpClient) sqCreatePaymen
|
||||
Note: req.Note,
|
||||
VerificationToken: req.VerificationToken,
|
||||
BuyerEmailAddress: req.BuyerEmail,
|
||||
CustomerDetails: req.CustomerDetails,
|
||||
}
|
||||
if req.TipMoney != nil {
|
||||
body.TipMoney = &sqMoney{Amount: *req.TipMoney, Currency: req.Currency}
|
||||
@@ -614,6 +622,12 @@ func replayPaymentByKeyHTTP(ctx context.Context, snapshotJSON []byte) (*PaymentR
|
||||
// A replay body missing fields the original charge carried would return
|
||||
// IDEMPOTENCY_KEY_REUSED for a RETAINED key and strand the row pending forever,
|
||||
// so the snapshot is never reconstructed from partial row data.
|
||||
//
|
||||
// TODO (UNVERIFIED ASSUMPTION): this codebase assumes Square retains
|
||||
// idempotency keys for ~24 hours (the stale-pending sweeps use a 23h/25h age
|
||||
// guard on that window). Square's public docs no longer state the exact
|
||||
// retention window — confirm the current value with Square support and update
|
||||
// the sweep age guards and this comment when confirmed.
|
||||
func replayPaymentByKeyHTTPWithClient(ctx context.Context, snapshotJSON []byte, hc *httpClient) (*PaymentResult, error) {
|
||||
var req CreatePaymentReq
|
||||
if err := json.Unmarshal(snapshotJSON, &req); err != nil {
|
||||
@@ -687,7 +701,9 @@ func (e *squareAPIError) Unwrap() error { return e.err }
|
||||
// error it wraps) is a *squareAPIError — i.e. a structured error parsed from
|
||||
// Square's error response body. It returns "" for non-Square errors so callers
|
||||
// can classify charge failures structurally instead of substring-matching the
|
||||
// message.
|
||||
// message. This is the exported Code accessor for the error type (a direct
|
||||
// `(*SquareError).Code()` method is impossible: SquareError already declares a
|
||||
// field named Code, and Go forbids a method colliding with a struct field).
|
||||
func ErrorCode(err error) string {
|
||||
var sqErr *squareAPIError
|
||||
if errors.As(err, &sqErr) {
|
||||
@@ -719,7 +735,11 @@ func ErrorStatusCode(err error) int {
|
||||
}
|
||||
|
||||
// ErrorCategory returns the Square error Category carried by err when err (or
|
||||
// any error it wraps) is a *squareAPIError, and "" otherwise.
|
||||
// any error it wraps) is a *squareAPIError, and "" otherwise. This is the
|
||||
// exported Category accessor for the error type (a direct
|
||||
// `(*SquareError).Category()` method is impossible: SquareError already
|
||||
// declares a field named Category, and Go forbids a method colliding with a
|
||||
// struct field).
|
||||
func ErrorCategory(err error) string {
|
||||
var sqErr *squareAPIError
|
||||
if errors.As(err, &sqErr) {
|
||||
@@ -754,17 +774,65 @@ func IsNotFound(err error) bool {
|
||||
return strings.Contains(err.Error(), "HTTP 404")
|
||||
}
|
||||
|
||||
// definitivePaymentCodes are Square CreatePayment error codes that mean the
|
||||
// charge can NEVER succeed as-is. This includes the card decline/expiry codes
|
||||
// and — critically for SCA — the buyer-verification codes
|
||||
// (CARD_DECLINED_VERIFICATION_REQUIRED, VERIFICATION_TOKEN_EXPIRED,
|
||||
// VERIFICATION_TOKEN_INVALID, CVV_VERIFICATION_REQUIRED,
|
||||
// ADDRESS_VERIFICATION_REQUIRED, MISSING_PIN, MISSING_VERIFICATION_TOKEN):
|
||||
// those mean the user must re-verify (3DS/SCA) or re-tokenize the card, NOT
|
||||
// that the same request should be retried. A same-request retry with the same
|
||||
// source/token can never succeed, so the failure is DEFINITIVE. This map is
|
||||
// the package-level source of truth; handlers mirror it via
|
||||
// IsDefinitivePaymentError / square.ErrorCode (the dev mock emits the same
|
||||
// codes so dev parity holds).
|
||||
var definitivePaymentCodes = map[string]bool{
|
||||
"CARD_DECLINED": true,
|
||||
"CARD_EXPIRED": true,
|
||||
"INVALID_EXPIRATION": true,
|
||||
"INVALID_EXPIRATION_DATE": true,
|
||||
"CARD_NOT_SUPPORTED": true,
|
||||
"VERIFY_CVV_FAILURE": true,
|
||||
"AVS_FAILURE": true,
|
||||
"PAYMENT_CARD_DECLINED": true,
|
||||
"GENERIC_DECLINE": true,
|
||||
"INSUFFICIENT_FUNDS": true,
|
||||
"ADDRESS_VERIFICATION_FAILURE": true,
|
||||
"TRANSACTION_LIMIT": true,
|
||||
// SCA / buyer-verification codes — the buyer must re-verify or the card be
|
||||
// re-tokenized before the charge can succeed; retrying is pointless.
|
||||
"CARD_DECLINED_VERIFICATION_REQUIRED": true,
|
||||
"VERIFICATION_TOKEN_EXPIRED": true,
|
||||
"VERIFICATION_TOKEN_INVALID": true,
|
||||
"CVV_VERIFICATION_REQUIRED": true,
|
||||
"ADDRESS_VERIFICATION_REQUIRED": true,
|
||||
"MISSING_PIN": true,
|
||||
"MISSING_VERIFICATION_TOKEN": true,
|
||||
}
|
||||
|
||||
// IsDefinitivePaymentError reports whether err is a definitive CreatePayment
|
||||
// rejection (declined card, expired source, or an SCA/verification failure the
|
||||
// buyer must resolve) rather than an ambiguous transport/server error. Handlers
|
||||
// use this to avoid retrying a request that can never succeed as-is.
|
||||
func IsDefinitivePaymentError(err error) bool {
|
||||
return definitivePaymentCodes[ErrorCode(err)]
|
||||
}
|
||||
|
||||
// Definitive Square refund rejection codes — the refund was declined and can
|
||||
// never succeed, so retrying is pointless and the refund record should be
|
||||
// marked 'failed'. Anything else (transport errors, 5xx) is left ambiguous so
|
||||
// callers leave the refund 'pending' for a scheduler retry. Codes match
|
||||
// Square's documented Refunds error list (REFUND_DECLINED, REFUND_AMOUNT_INVALID,
|
||||
// PAYMENT_NOT_REFUNDABLE); note PAYMENT_ALREADY_REFUNDED and
|
||||
// REFUND_ALREADY_PENDING are intentionally absent — money is in flight or has
|
||||
// moved, so they map to ErrRefundAlreadyProcessed instead of ErrRefundDeclined.
|
||||
// PAYMENT_NOT_REFUNDABLE). REFUND_AMOUNT_INVALID is special: Square returns it
|
||||
// BOTH for a genuinely invalid refund amount AND for an already-refunded
|
||||
// payment, so refundPaymentHTTP reconciles it via PaymentWasRefunded before
|
||||
// classifying (an existing refund → ErrRefundAlreadyProcessed, otherwise
|
||||
// ErrRefundDeclined). REFUND_ALREADY_PENDING maps to ErrRefundAlreadyProcessed
|
||||
// (money in flight); PAYMENT_ALREADY_REFUNDED is no longer emitted by Square
|
||||
// but is kept as a defensive fallback for the same outcome.
|
||||
var definitiveRefundCodes = map[string]bool{
|
||||
"REFUND_DECLINED": true,
|
||||
"REFUND_AMOUNT_INVALID": true,
|
||||
"REFUND_DECLINED": true,
|
||||
"REFUND_AMOUNT_INVALID": true,
|
||||
"PAYMENT_NOT_REFUNDABLE": true,
|
||||
}
|
||||
|
||||
@@ -782,17 +850,87 @@ func refundPaymentHTTPWithClient(ctx context.Context, req RefundPaymentReq, hc *
|
||||
var resp sqRefundPaymentResponse
|
||||
if err := hc.doJSON(ctx, http.MethodPost, "/v2/refunds", body, &resp); err != nil {
|
||||
var sqErr *squareAPIError
|
||||
if errors.As(err, &sqErr) && definitiveRefundCodes[sqErr.Code] {
|
||||
return nil, fmt.Errorf("%w: %v", ErrRefundDeclined, err)
|
||||
}
|
||||
if errors.As(err, &sqErr) && (sqErr.Code == "PAYMENT_ALREADY_REFUNDED" || sqErr.Code == "REFUND_ALREADY_PENDING") {
|
||||
return nil, fmt.Errorf("%w: %v", ErrRefundAlreadyProcessed, err)
|
||||
if errors.As(err, &sqErr) {
|
||||
switch sqErr.Code {
|
||||
case "PAYMENT_ALREADY_REFUNDED", "REFUND_ALREADY_PENDING":
|
||||
// Money is in flight or has already moved at Square — never
|
||||
// mark 'failed' (that would let the guard over-refund).
|
||||
return nil, fmt.Errorf("%w: %v", ErrRefundAlreadyProcessed, err)
|
||||
case "REFUND_AMOUNT_INVALID":
|
||||
// Square returns REFUND_AMOUNT_INVALID both for a genuinely
|
||||
// invalid refund amount AND for an already-refunded payment
|
||||
// (Square no longer emits PAYMENT_ALREADY_REFUNDED). Reconcile
|
||||
// against the refund list to tell the two apart: money already
|
||||
// moved → ErrRefundAlreadyProcessed (resolve 'completed');
|
||||
// nothing moved → ErrRefundDeclined (mark 'failed', never
|
||||
// retry). The reconciliation is amount-aware: only an EXACT-
|
||||
// amount COMPLETED refund proves THIS requested amount already
|
||||
// moved. A smaller partial refund does NOT cover the requested
|
||||
// amount — resolving the row 'completed' against a partial
|
||||
// refund would claim the full amount was returned when only
|
||||
// part of it was, permanently blocking the remaining refund
|
||||
// (the over-refund guard excludes completed rows). If the
|
||||
// reconciliation itself fails, return the error unwrapped so
|
||||
// the caller keeps the refund pending rather than making a
|
||||
// money decision on partial data.
|
||||
exactRefund, rErr := paymentRefundedExactlyWithClient(ctx, req.PaymentID, req.Amount, hc)
|
||||
if rErr != nil {
|
||||
return nil, rErr
|
||||
}
|
||||
if exactRefund {
|
||||
return nil, fmt.Errorf("%w: %v", ErrRefundAlreadyProcessed, err)
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %v", ErrRefundDeclined, err)
|
||||
}
|
||||
if definitiveRefundCodes[sqErr.Code] {
|
||||
return nil, fmt.Errorf("%w: %v", ErrRefundDeclined, err)
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return refundFromSquare(&resp.Refund), nil
|
||||
}
|
||||
|
||||
// PaymentWasRefunded reports whether Square holds any refund for the payment
|
||||
// (status COMPLETED, APPROVED, or PENDING). It is the reconciliation source for
|
||||
// deciding whether a REFUND_AMOUNT_INVALID rejection means "already refunded"
|
||||
// (money has already moved) vs "amount invalid" (nothing happened).
|
||||
func PaymentWasRefunded(ctx context.Context, paymentID string) (bool, error) {
|
||||
return paymentWasRefundedWithClient(ctx, paymentID, newHTTPClient())
|
||||
}
|
||||
|
||||
func paymentWasRefundedWithClient(ctx context.Context, paymentID string, hc *httpClient) (bool, error) {
|
||||
refunds, err := listRefundsHTTPWithClient(ctx, paymentID, time.Time{}, hc)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, r := range refunds {
|
||||
switch r.Status {
|
||||
case "COMPLETED", "APPROVED", "PENDING":
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// paymentRefundedExactlyWithClient reports whether Square holds a COMPLETED
|
||||
// refund for the EXACT amount requested. Unlike paymentWasRefundedWithClient
|
||||
// (any refund counts), an exact-match is required so a REFUND_AMOUNT_INVALID
|
||||
// rejection can only resolve to "already refunded" when THIS requested amount
|
||||
// provably moved — a partial refund does not cover it.
|
||||
func paymentRefundedExactlyWithClient(ctx context.Context, paymentID string, amount int64, hc *httpClient) (bool, error) {
|
||||
refunds, err := listRefundsHTTPWithClient(ctx, paymentID, time.Time{}, hc)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, r := range refunds {
|
||||
if r.Status == "COMPLETED" && r.Amount == amount {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func listRefundsHTTP(ctx context.Context, paymentID string, beginTime time.Time) ([]RefundResult, error) {
|
||||
return listRefundsHTTPWithClient(ctx, paymentID, beginTime, newHTTPClient())
|
||||
}
|
||||
|
||||
@@ -518,15 +518,21 @@ func TestCreatePaymentHTTP_TipMoneyAbsentWhenNil(t *testing.T) {
|
||||
// PAYMENT_NOT_REFUNDABLE) map to ErrRefundDeclined, the money-in-flight codes
|
||||
// (PAYMENT_ALREADY_REFUNDED, REFUND_ALREADY_PENDING) map to
|
||||
// ErrRefundAlreadyProcessed, and ambiguous errors pass through unwrapped.
|
||||
// REFUND_AMOUNT_INVALID is special: Square returns it BOTH for a genuinely
|
||||
// invalid amount and for an already-refunded payment, so the client reconciles
|
||||
// via PaymentWasRefunded (GET /v2/refunds) — no existing refund → declined,
|
||||
// an existing COMPLETED/APPROVED/PENDING refund → already processed.
|
||||
func TestRefundPaymentHTTP_CodeClassification(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
code string
|
||||
wantErrIs error // nil = no sentinel expected
|
||||
refundList string // GET /v2/refunds body served to the PaymentWasRefunded reconciliation
|
||||
wantErrIs error // nil = no sentinel expected
|
||||
wantErrNil bool
|
||||
}{
|
||||
{name: "refund_declined", code: "REFUND_DECLINED", wantErrIs: ErrRefundDeclined},
|
||||
{name: "amount_invalid", code: "REFUND_AMOUNT_INVALID", wantErrIs: ErrRefundDeclined},
|
||||
{name: "amount_invalid_no_refund_reconciles_to_declined", code: "REFUND_AMOUNT_INVALID", refundList: `{"refunds":[]}`, wantErrIs: ErrRefundDeclined},
|
||||
{name: "amount_invalid_existing_refund_reconciles_to_already_processed", code: "REFUND_AMOUNT_INVALID", refundList: `{"refunds":[{"id":"ref_1","status":"COMPLETED","amount_money":{"amount":1000,"currency":"GBP"},"payment_id":"pay_1","location_id":"loc","reason":"cancellation","created_at":"2026-07-31T00:00:00Z"}]}`, wantErrIs: ErrRefundAlreadyProcessed},
|
||||
{name: "payment_not_refundable", code: "PAYMENT_NOT_REFUNDABLE", wantErrIs: ErrRefundDeclined},
|
||||
{name: "already_refunded", code: "PAYMENT_ALREADY_REFUNDED", wantErrIs: ErrRefundAlreadyProcessed},
|
||||
{name: "already_pending", code: "REFUND_ALREADY_PENDING", wantErrIs: ErrRefundAlreadyProcessed},
|
||||
@@ -537,6 +543,17 @@ func TestRefundPaymentHTTP_CodeClassification(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.Method == http.MethodGet {
|
||||
// The PaymentWasRefunded reconciliation call (GET /v2/refunds)
|
||||
// must be answered with the configured refund list so the
|
||||
// REFUND_AMOUNT_INVALID branch runs end to end.
|
||||
body := tc.refundList
|
||||
if body == "" {
|
||||
body = `{"refunds":[]}`
|
||||
}
|
||||
_, _ = w.Write([]byte(body))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
if tc.code == "" {
|
||||
_, _ = w.Write([]byte("plain text failure"))
|
||||
@@ -1783,3 +1800,232 @@ func TestDoJSON_CardProcessingNotEnabled403(t *testing.T) {
|
||||
t.Errorf("expected ErrorCategory PAYMENT_METHOD_ERROR, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreatePaymentHTTP_CustomerDetailsWireShape verifies the customer_details
|
||||
// wiring on POST /v2/payments: when CustomerDetails is set
|
||||
// (CustomerInitiated=true — online card entry is always cardholder-initiated),
|
||||
// the request body carries customer_details.customer_initiated=true; when nil,
|
||||
// the field is omitted entirely (Square's default classification applies).
|
||||
func TestCreatePaymentHTTP_CustomerDetailsWireShape(t *testing.T) {
|
||||
t.Run("customer_initiated_true_is_sent", func(t *testing.T) {
|
||||
var captured map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
|
||||
t.Errorf("failed to decode request body: %v", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"payment":{"id":"pay_cd","status":"COMPLETED","total_money":{"amount":5000,"currency":"GBP"},"source_type":"CARD","card_details":{"card":{"id":"ccof_x","card_brand":"VISA","last_4":"4242"},"entry_method":"KEYED"},"location_id":"loc","created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
||||
_, err := createPaymentHTTPWithClient(context.Background(), CreatePaymentReq{
|
||||
Amount: 5000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "ik-cd",
|
||||
CustomerDetails: &CreateCustomerDetails{CustomerInitiated: true},
|
||||
}, hc)
|
||||
if err != nil {
|
||||
t.Fatalf("createPaymentHTTP failed: %v", err)
|
||||
}
|
||||
cd, ok := captured["customer_details"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected customer_details object, got %v", captured["customer_details"])
|
||||
}
|
||||
if cd["customer_initiated"] != true {
|
||||
t.Errorf("expected customer_details.customer_initiated=true, got %v", cd["customer_initiated"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil_customer_details_is_omitted", func(t *testing.T) {
|
||||
var captured map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
|
||||
t.Errorf("failed to decode request body: %v", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"payment":{"id":"pay_cd2","status":"COMPLETED","total_money":{"amount":1000,"currency":"GBP"},"source_type":"CARD","card_details":{"card":{"id":"ccof_x","card_brand":"MASTERCARD","last_4":"4444"},"entry_method":"KEYED"},"location_id":"loc","created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
||||
_, err := createPaymentHTTPWithClient(context.Background(), CreatePaymentReq{
|
||||
Amount: 1000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "ik-cd-nil",
|
||||
}, hc)
|
||||
if err != nil {
|
||||
t.Fatalf("createPaymentHTTP failed: %v", err)
|
||||
}
|
||||
if _, present := captured["customer_details"]; present {
|
||||
t.Errorf("expected customer_details to be ABSENT when CustomerDetails is nil, got %v", captured["customer_details"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestPaymentWasRefunded verifies the exported PaymentWasRefunded reconciliation
|
||||
// helper: an existing COMPLETED/APPROVED/PENDING refund for the payment means
|
||||
// money has moved (true), a FAILED/rejected refund or an empty list means
|
||||
// nothing moved (false), and a server error propagates as an error.
|
||||
func TestPaymentWasRefunded(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
refundList string
|
||||
statusCode int
|
||||
want bool
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "completed_refund_means_money_moved", refundList: `{"refunds":[{"id":"ref_c","status":"COMPLETED","amount_money":{"amount":1000,"currency":"GBP"},"payment_id":"pay_1","created_at":"2026-07-31T00:00:00Z"}]}`, want: true},
|
||||
{name: "approved_refund_means_money_moved", refundList: `{"refunds":[{"id":"ref_a","status":"APPROVED","amount_money":{"amount":1000,"currency":"GBP"},"payment_id":"pay_1","created_at":"2026-07-31T00:00:00Z"}]}`, want: true},
|
||||
{name: "pending_refund_means_money_in_flight", refundList: `{"refunds":[{"id":"ref_p","status":"PENDING","amount_money":{"amount":1000,"currency":"GBP"},"payment_id":"pay_1","created_at":"2026-07-31T00:00:00Z"}]}`, want: true},
|
||||
{name: "failed_refund_means_nothing_moved", refundList: `{"refunds":[{"id":"ref_f","status":"FAILED","amount_money":{"amount":1000,"currency":"GBP"},"payment_id":"pay_1","created_at":"2026-07-31T00:00:00Z"}]}`, want: false},
|
||||
{name: "rejected_refund_means_nothing_moved", refundList: `{"refunds":[{"id":"ref_r","status":"REJECTED","amount_money":{"amount":1000,"currency":"GBP"},"payment_id":"pay_1","created_at":"2026-07-31T00:00:00Z"}]}`, want: false},
|
||||
{name: "empty_list_means_nothing_moved", refundList: `{"refunds":[]}`, want: false},
|
||||
{name: "other_payment_refund_is_filtered_out", refundList: `{"refunds":[{"id":"ref_o","status":"COMPLETED","amount_money":{"amount":1000,"currency":"GBP"},"payment_id":"pay_other","created_at":"2026-07-31T00:00:00Z"}]}`, want: false},
|
||||
{name: "server_error_propagates", refundList: `{"errors":[{"category":"INVALID_REQUEST_ERROR","code":"INTERNAL_SERVER_ERROR","detail":"boom"}]}`, statusCode: http.StatusInternalServerError, wantErr: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
t.Errorf("expected GET, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/v2/refunds" {
|
||||
t.Errorf("expected /v2/refunds, got %s", r.URL.Path)
|
||||
}
|
||||
if !strings.Contains(r.URL.RawQuery, "begin_time=") {
|
||||
t.Errorf("expected begin_time in query, got %q", r.URL.RawQuery)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if tc.statusCode != 0 {
|
||||
w.WriteHeader(tc.statusCode)
|
||||
}
|
||||
_, _ = w.Write([]byte(tc.refundList))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
||||
got, err := paymentWasRefundedWithClient(context.Background(), "pay_1", hc)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got wasRefunded=%v", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("paymentWasRefunded failed: %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("paymentWasRefunded = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefundPaymentHTTP_RefundAmountInvalidReconciliation locks the
|
||||
// REFUND_AMOUNT_INVALID reconciliation end-to-end: Square returns that code BOTH
|
||||
// for a genuinely invalid refund amount AND for an already-refunded payment, so
|
||||
// refundPaymentHTTP re-checks the refund list (GET /v2/refunds) before
|
||||
// classifying — an existing COMPLETED refund → ErrRefundAlreadyProcessed (money
|
||||
// already moved), an empty list → ErrRefundDeclined (mark failed, never retry).
|
||||
func TestRefundPaymentHTTP_RefundAmountInvalidReconciliation(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
refundList string
|
||||
wantErrIs error
|
||||
}{
|
||||
{name: "existing_exact_amount_completed_refund_reconciles_to_already_processed", refundList: `{"refunds":[{"id":"ref_1","status":"COMPLETED","amount_money":{"amount":5000,"currency":"GBP"},"payment_id":"pay_rec","created_at":"2026-07-31T00:00:00Z"}]}`, wantErrIs: ErrRefundAlreadyProcessed},
|
||||
{name: "partial_refund_does_not_cover_requested_amount_reconciles_to_declined", refundList: `{"refunds":[{"id":"ref_1","status":"COMPLETED","amount_money":{"amount":1000,"currency":"GBP"},"payment_id":"pay_rec","created_at":"2026-07-31T00:00:00Z"}]}`, wantErrIs: ErrRefundDeclined},
|
||||
{name: "no_refunds_reconciles_to_declined", refundList: `{"refunds":[]}`, wantErrIs: ErrRefundDeclined},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var reconcileGETs int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.Method == http.MethodGet {
|
||||
// The PaymentWasRefunded reconciliation call must hit
|
||||
// GET /v2/refunds for the payment.
|
||||
reconcileGETs++
|
||||
if r.URL.Path != "/v2/refunds" {
|
||||
t.Errorf("expected reconciliation GET /v2/refunds, got %s", r.URL.Path)
|
||||
}
|
||||
if !strings.Contains(r.URL.RawQuery, "begin_time=") {
|
||||
t.Errorf("expected begin_time in reconciliation query, got %q", r.URL.RawQuery)
|
||||
}
|
||||
_, _ = w.Write([]byte(tc.refundList))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"errors":[{"category":"INVALID_REQUEST_ERROR","code":"REFUND_AMOUNT_INVALID","detail":"The refunded amount is more than the remaining balance"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
||||
_, err := refundPaymentHTTPWithClient(context.Background(), RefundPaymentReq{
|
||||
PaymentID: "pay_rec", Amount: 5000, IdempotencyKey: "ik-rec",
|
||||
}, hc)
|
||||
if err == nil {
|
||||
t.Fatal("expected REFUND_AMOUNT_INVALID rejection error")
|
||||
}
|
||||
if reconcileGETs == 0 {
|
||||
t.Error("expected the client to reconcile against GET /v2/refunds before classifying")
|
||||
}
|
||||
if !errors.Is(err, tc.wantErrIs) {
|
||||
t.Errorf("expected errors.Is(%v), got %v", tc.wantErrIs, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSCACodes_ClassifyAsDefinitivePaymentErrors locks the SCA / buyer-verification
|
||||
// classification: the seven Square verification codes all mean the buyer must
|
||||
// re-verify (3DS/SCA) or re-tokenize the card, NOT that the same request should
|
||||
// be retried — so each must classify as a definitive payment error via
|
||||
// IsDefinitivePaymentError and surface its code through ErrorCode.
|
||||
func TestSCACodes_ClassifyAsDefinitivePaymentErrors(t *testing.T) {
|
||||
scaCodes := []string{
|
||||
"CARD_DECLINED_VERIFICATION_REQUIRED",
|
||||
"VERIFICATION_TOKEN_EXPIRED",
|
||||
"VERIFICATION_TOKEN_INVALID",
|
||||
"CVV_VERIFICATION_REQUIRED",
|
||||
"ADDRESS_VERIFICATION_REQUIRED",
|
||||
"MISSING_PIN",
|
||||
"MISSING_VERIFICATION_TOKEN",
|
||||
}
|
||||
for _, code := range scaCodes {
|
||||
t.Run(code, func(t *testing.T) {
|
||||
err := &squareAPIError{
|
||||
Code: code, Category: "PAYMENT_METHOD_ERROR", StatusCode: http.StatusBadRequest,
|
||||
err: errors.New("square: " + code),
|
||||
}
|
||||
if !IsDefinitivePaymentError(err) {
|
||||
t.Errorf("IsDefinitivePaymentError(%s) must be true — SCA codes are definitive", code)
|
||||
}
|
||||
if got := ErrorCode(err); got != code {
|
||||
t.Errorf("expected ErrorCode %s, got %q", code, got)
|
||||
}
|
||||
// Handlers wrap the client error before classifying — the accessor
|
||||
// must see through the wrap.
|
||||
if !IsDefinitivePaymentError(fmt.Errorf("wrap: %w", err)) {
|
||||
t.Errorf("IsDefinitivePaymentError must work through a wrapped error for %s", code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestInvalidRequestError_IsCategoryNotCode locks the category/code distinction:
|
||||
// INVALID_REQUEST_ERROR is a Square error CATEGORY, never an error CODE — so
|
||||
// ErrorCategory surfaces it while ErrorCode must NOT (ErrorCode returns "" for
|
||||
// a code-less squareAPIError), and it must never classify as definitive.
|
||||
func TestInvalidRequestError_IsCategoryNotCode(t *testing.T) {
|
||||
err := &squareAPIError{
|
||||
Category: "INVALID_REQUEST_ERROR", StatusCode: http.StatusBadRequest,
|
||||
err: errors.New("square: invalid request"),
|
||||
}
|
||||
if got := ErrorCategory(err); got != "INVALID_REQUEST_ERROR" {
|
||||
t.Errorf("expected ErrorCategory INVALID_REQUEST_ERROR, got %q", got)
|
||||
}
|
||||
if got := ErrorCode(err); got != "" {
|
||||
t.Errorf("expected ErrorCode \"\" for a category-only error, got %q", got)
|
||||
}
|
||||
if IsDefinitivePaymentError(err) {
|
||||
t.Error("INVALID_REQUEST_ERROR is a category, never a definitive payment code")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,10 +15,16 @@ import (
|
||||
// money has already moved, so it maps to ErrRefundAlreadyProcessed instead.
|
||||
var ErrRefundDeclined = errors.New("square: refund declined")
|
||||
|
||||
// ErrRefundAlreadyProcessed is returned by RefundPayment when Square reports
|
||||
// PAYMENT_ALREADY_REFUNDED — the payment is already fully refunded at Square,
|
||||
// so the money has already moved. Callers resolve the refund record to
|
||||
// 'completed' rather than 'failed' (which would let the guard over-refund).
|
||||
// ErrRefundAlreadyProcessed is returned by RefundPayment when the money has
|
||||
// already moved at Square — either Square reports REFUND_ALREADY_PENDING (a
|
||||
// refund for this payment is in flight) or the reconciliation performed on a
|
||||
// REFUND_AMOUNT_INVALID rejection finds an existing COMPLETED/APPROVED/PENDING
|
||||
// refund for the payment (PaymentWasRefunded). Note: Square no longer returns
|
||||
// PAYMENT_ALREADY_REFUNDED for an already-refunded payment — it returns
|
||||
// REFUND_AMOUNT_INVALID, which the client now reconciles via PaymentWasRefunded
|
||||
// (the PAYMENT_ALREADY_REFUNDED mapping is kept only as a defensive fallback).
|
||||
// Callers resolve the refund record to 'completed' rather than 'failed' (which
|
||||
// would let the guard over-refund).
|
||||
var ErrRefundAlreadyProcessed = errors.New("square: refund already processed")
|
||||
|
||||
// ErrReplayKeyNotRetained is returned by ReplayPaymentByKey when Square proves
|
||||
@@ -53,6 +59,27 @@ type CreatePaymentReq struct {
|
||||
LocationID string // Square location ID (optional; defaults to main location)
|
||||
VerificationToken string // 3DS / SCA verification token from buyer verification
|
||||
BuyerEmail string // buyer email for receipt
|
||||
// CustomerDetails classifies the charge as cardholder-initiated (true) or
|
||||
// merchant-initiated (false) for Square's SCA / liability-shift logic,
|
||||
// wired through as customer_details.customer_initiated on POST /v2/payments.
|
||||
// Online card entry in this app is always cardholder-initiated (the buyer
|
||||
// is present, typing their card details), so the flag is true when set;
|
||||
// nil omits the field from the wire body (Square's default classification).
|
||||
CustomerDetails *CreateCustomerDetails
|
||||
}
|
||||
|
||||
// CreateCustomerDetails maps to Square's customer_details object on
|
||||
// CreatePayment. customer_initiated tells Square whether the cardholder
|
||||
// initiated the transaction (true — buyer present, e.g. online card entry) or
|
||||
// the merchant initiated it on the cardholder's behalf (false — e.g. a
|
||||
// subscription/recurring charge). Square uses it to classify the charge for
|
||||
// SCA (Strong Customer Authentication) and card-scheme liability-shift rules:
|
||||
// omitting it can silently change how the transaction is classified (a missing
|
||||
// customer_initiated can be read as merchant-initiated, skipping the SCA that
|
||||
// a cardholder-present charge must undergo).
|
||||
// Reference: https://developer.squareup.com/reference/square/objects/Payment
|
||||
type CreateCustomerDetails struct {
|
||||
CustomerInitiated bool `json:"customer_initiated"`
|
||||
}
|
||||
|
||||
// CreateCheckoutReq maps to Square's CreateTerminalCheckout endpoint
|
||||
@@ -189,7 +216,21 @@ type SquareClient interface {
|
||||
CreateCheckout(ctx context.Context, req CreateCheckoutReq) (*CheckoutResult, error)
|
||||
GetCheckout(ctx context.Context, checkoutID string) (*PaymentResult, error)
|
||||
RefundPayment(ctx context.Context, req RefundPaymentReq) (*RefundResult, error)
|
||||
// PaymentWasRefunded reports whether Square holds any refund for the
|
||||
// payment (status COMPLETED, APPROVED, or PENDING). It is the
|
||||
// reconciliation source for deciding whether a REFUND_AMOUNT_INVALID
|
||||
// rejection means "already refunded" (money has already moved) vs "amount
|
||||
// invalid" (nothing happened). On the interface (not just the package
|
||||
// function) so handlers can reconcile through the injected client — a
|
||||
// package-level call constructs a real HTTP client even in dev/mock
|
||||
// builds, making the dev path dead code and untestable.
|
||||
PaymentWasRefunded(ctx context.Context, paymentID string) (bool, error)
|
||||
CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*CardOnFile, error)
|
||||
// GetCardsOnFile returns the enabled cards on file for a user. TEST-ONLY:
|
||||
// it has ZERO production callers (grep across the repo confirms the only
|
||||
// users are this package's tests) and is kept on the interface solely so
|
||||
// the dev mock's List Cards parity tests can exercise it. Do not add
|
||||
// production callers without also re-examining the interface surface.
|
||||
GetCardsOnFile(ctx context.Context, userID string) ([]CardOnFile, error)
|
||||
DeleteCardOnFile(ctx context.Context, cardID string) error
|
||||
|
||||
|
||||
Reference in New Issue
Block a user