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:
2026-08-22 00:34:50 +01:00
parent 78e6d00dc5
commit 6d82535780
60 changed files with 6608 additions and 801 deletions
+299
View File
@@ -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)
}
}
+7
View File
@@ -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)
}
+165 -23
View File
@@ -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) {
+332
View File
@@ -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")
})
}
+151 -13
View File
@@ -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")
}
}
+45 -4
View File
@@ -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