fix: review-loop hardening — identical-body replay, 2FA gates, webhook at-least-once, GDPR scrub
Follow-up to the comprehensive payment-system review. Fixes the issues the review found in the initial integration, plus the rough edges it introduced. Money-safety: - Replay-by-key now replays the FULL original request verbatim from a stored square_request_snapshot, so a retained idempotency key returns the original payment instead of IDEMPOTENCY_KEY_REUSED (previously the row sat pending forever). IDEMPOTENCY_KEY_REUSED remains ambiguous (never proof of no charge). - Dev mock mirrors real Square for unknown-key replays: ccof: saved-card sources are charged and rescued; spent cnon: nonces surface ErrReplayKeyNotRetained. (Fixes dev/prod parity divergence.) - Webhook dedup row committed AFTER dispatch (at-least-once); FAILED till sales claw back gift-card funding; event-type strings match Square's real catalog. - Expired-gift-card cancellation refunds set creditFailed (never a phantom 'completed' refund); cancellation refunds lock all payment rows ascending. - Sweep never rescue-completes a gift-card purchase without delivering the card. - Tip no-client-key fallback is a deterministic count-based key under the booking advisory lock (retry-safe, distinct tips don't collapse). - M-cap subtracts completed refunds, clamped to [0, total]. 2FA (PSD2 SCA stand-in) for online saved-card payments: - Full feature: status/setup/verify/disable endpoints, gating helper wired into all 7 saved-card charge paths (incl. BuyGiftCard + admin saved-card), account admin-tab settings UI, frontend gating across all payment surfaces. - Enforcement is FAIL-CLOSED: on unless REQUIRE_2FA=false or an explicit mock/dev SQUARE_ENVIRONMENT; startup warning when off in a non-dev env. - Verify is brute-force hardened (5-attempt lockout, timing-safe compare); plaintext codes only logged when enforcement is off (dev). - GDPR: anonymize_user also scrubs 2FA columns and staff notes. Infra/docs: - nginx: /api/ response cache removed (cross-user disclosure); port 80 redirects to HTTPS (localhost/RFC1918 exempt, end-anchored regexes); HSTS; separate webhook rate-limit zone. - Schema: users 2FA columns; payments/till_sales square_source_id + square_request_snapshot. - Legal docs: gift-card cooling-off, international-transfers section, tips policy; Gap Backlog P3 webhooks marked done; stale counts/wording corrected. - Flaky test race fixed (t.Parallel + global mock mutation); suite 26/26 packages green, 2,142 tests, svelte-check clean.
This commit is contained in:
@@ -35,8 +35,8 @@ func (p *ProdClient) GetPayment(ctx context.Context, paymentID string) (*Payment
|
||||
return getPaymentHTTP(ctx, paymentID)
|
||||
}
|
||||
|
||||
func (p *ProdClient) ReplayPaymentByKey(ctx context.Context, idempotencyKey string, amount int64) (*PaymentResult, error) {
|
||||
return replayPaymentByKeyHTTP(ctx, idempotencyKey, amount)
|
||||
func (p *ProdClient) ReplayPaymentByKey(ctx context.Context, snapshotJSON []byte) (*PaymentResult, error) {
|
||||
return replayPaymentByKeyHTTP(ctx, snapshotJSON)
|
||||
}
|
||||
|
||||
func (p *ProdClient) CreateCustomer(ctx context.Context, name, email string) (*CustomerResult, error) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"crussell/clock"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -29,9 +30,11 @@ func mockSleep(d time.Duration) {
|
||||
type MockClient struct {
|
||||
mu sync.RWMutex
|
||||
cards map[string]map[string]*CardOnFile
|
||||
cardByToken map[string]*CardOnFile // ccof: token (CardOnFile.CardID) → the saved card, for replay-by-key rescue
|
||||
checkouts map[string]*CheckoutResult
|
||||
payments map[string]*PaymentResult
|
||||
paymentByKey map[string]*PaymentResult
|
||||
paymentSource map[string]string // idempotency key → the source_id the original CreatePayment used
|
||||
refunds map[string]*RefundResult
|
||||
refundByKey map[string]*RefundResult
|
||||
customers map[string]*CustomerResult
|
||||
@@ -76,8 +79,8 @@ func (d *devProdClient) GetCheckout(ctx context.Context, checkoutID string) (*Pa
|
||||
func (d *devProdClient) GetPayment(ctx context.Context, paymentID string) (*PaymentResult, error) {
|
||||
return getPaymentHTTP(ctx, paymentID)
|
||||
}
|
||||
func (d *devProdClient) ReplayPaymentByKey(ctx context.Context, idempotencyKey string, amount int64) (*PaymentResult, error) {
|
||||
return replayPaymentByKeyHTTP(ctx, idempotencyKey, amount)
|
||||
func (d *devProdClient) ReplayPaymentByKey(ctx context.Context, snapshotJSON []byte) (*PaymentResult, error) {
|
||||
return replayPaymentByKeyHTTP(ctx, snapshotJSON)
|
||||
}
|
||||
func (d *devProdClient) CreateCustomer(ctx context.Context, name, email string) (*CustomerResult, error) {
|
||||
return createCustomerHTTP(ctx, name, email)
|
||||
@@ -117,14 +120,16 @@ func NewDevClient() SquareClient {
|
||||
}
|
||||
log.Println("[SQUARE-MOCK] Using in-memory mock client")
|
||||
return &MockClient{
|
||||
cards: make(map[string]map[string]*CardOnFile),
|
||||
checkouts: make(map[string]*CheckoutResult),
|
||||
payments: make(map[string]*PaymentResult),
|
||||
paymentByKey: make(map[string]*PaymentResult),
|
||||
refunds: make(map[string]*RefundResult),
|
||||
refundByKey: make(map[string]*RefundResult),
|
||||
customers: make(map[string]*CustomerResult),
|
||||
completed: make(map[string]*PaymentResult),
|
||||
cards: make(map[string]map[string]*CardOnFile),
|
||||
cardByToken: make(map[string]*CardOnFile),
|
||||
checkouts: make(map[string]*CheckoutResult),
|
||||
payments: make(map[string]*PaymentResult),
|
||||
paymentByKey: make(map[string]*PaymentResult),
|
||||
paymentSource: make(map[string]string),
|
||||
refunds: make(map[string]*RefundResult),
|
||||
refundByKey: make(map[string]*RefundResult),
|
||||
customers: make(map[string]*CustomerResult),
|
||||
completed: make(map[string]*PaymentResult),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,6 +261,7 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
|
||||
m.payments[result.SquarePayID] = result
|
||||
if req.IdempotencyKey != "" {
|
||||
m.paymentByKey[req.IdempotencyKey] = result
|
||||
m.paymentSource[req.IdempotencyKey] = req.SourceID
|
||||
}
|
||||
log.Printf("[SQUARE-MOCK] Payment created: id=%s, status=%s, amount=%d, fees=%d", paymentID, status, amount, fees)
|
||||
return result, nil
|
||||
@@ -404,22 +410,72 @@ func (m *MockClient) GetPayment(ctx context.Context, paymentID string) (*Payment
|
||||
return payment, nil
|
||||
}
|
||||
|
||||
// ReplayPaymentByKey mirrors the real client's replay-by-key reconcile
|
||||
// (POST /v2/payments with the same idempotency key): the dedup map returns the
|
||||
// ORIGINAL payment for a retained key — never a second charge — and an unknown
|
||||
// key is rejected with ErrReplayKeyNotRetained, exactly as the real client
|
||||
// rejects the synthetic probe source token it sends for an unknown key.
|
||||
func (m *MockClient) ReplayPaymentByKey(ctx context.Context, idempotencyKey string, amount int64) (*PaymentResult, error) {
|
||||
log.Printf("[SQUARE-MOCK] ReplayPaymentByKey: key=%s", idempotencyKey)
|
||||
// ReplayPaymentByKey mirrors the real client's IDENTICAL-body replay-by-key
|
||||
// reconcile (POST /v2/payments with the full stored request snapshot): a
|
||||
// retained key with the matching stored source returns the ORIGINAL payment
|
||||
// (never a second charge); a retained key with a DIFFERENT source returns a
|
||||
// structured 400 IDEMPOTENCY_KEY_REUSED — exactly what Square returns when an
|
||||
// idempotency key is reused with a different request body (the stored source
|
||||
// must never differ from the original, so the sweep treats it as ambiguous);
|
||||
// an unknown key makes Square attempt a real charge with the stored source: a
|
||||
// still-valid ccof: saved-card token CHARGES successfully (returning a new
|
||||
// COMPLETED payment the sweep rescues), while a spent/expired cnon: nonce is
|
||||
// rejected with a 4xx — surfaced as ErrReplayKeyNotRetained.
|
||||
func (m *MockClient) ReplayPaymentByKey(ctx context.Context, snapshotJSON []byte) (*PaymentResult, error) {
|
||||
var req CreatePaymentReq
|
||||
if err := json.Unmarshal(snapshotJSON, &req); err != nil {
|
||||
return nil, fmt.Errorf("square: replay-by-key cannot parse stored request snapshot: %w", err)
|
||||
}
|
||||
log.Printf("[SQUARE-MOCK] ReplayPaymentByKey: key=%s, source=%s", req.IdempotencyKey, tokenPrefix(req.SourceID))
|
||||
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
existing, ok := m.paymentByKey[req.IdempotencyKey]
|
||||
storedSource := m.paymentSource[req.IdempotencyKey]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if existing, ok := m.paymentByKey[idempotencyKey]; ok {
|
||||
log.Printf("[SQUARE-MOCK] ReplayPaymentByKey dedup hit: key=%s → id=%s", idempotencyKey, existing.ID)
|
||||
if ok {
|
||||
if storedSource != "" && storedSource != req.SourceID {
|
||||
// Same key, different body — Square's documented IDEMPOTENCY_KEY_REUSED
|
||||
// rejection. A data bug (the stored source differs from the original
|
||||
// charge), NOT proof the charge never happened.
|
||||
return nil, &squareAPIError{
|
||||
Code: "IDEMPOTENCY_KEY_REUSED",
|
||||
Detail: "idempotency key was reused with a different request body",
|
||||
StatusCode: http.StatusBadRequest,
|
||||
err: fmt.Errorf("square: idempotency key %s reused with a different source_id", req.IdempotencyKey),
|
||||
}
|
||||
}
|
||||
log.Printf("[SQUARE-MOCK] ReplayPaymentByKey dedup hit: key=%s → id=%s", req.IdempotencyKey, existing.ID)
|
||||
return existing, nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: Square has no payment under idempotency key", ErrReplayKeyNotRetained)
|
||||
|
||||
// Unknown key — mirror real Square: it attempts a real charge with the
|
||||
// stored source. A still-valid ccof: saved-card token charges successfully
|
||||
// (the sweep then rescues the row); a spent/expired cnon: nonce (or any
|
||||
// unchargeable source) is rejected with a definitive 4xx.
|
||||
if strings.HasPrefix(req.SourceID, "ccof:") {
|
||||
m.mu.RLock()
|
||||
_, cardOK := m.cardByToken[req.SourceID]
|
||||
m.mu.RUnlock()
|
||||
if !cardOK {
|
||||
// The saved card is not in the mock ledger — mirror real Square
|
||||
// rejecting a deleted/disabled card with a definitive 4xx.
|
||||
return nil, fmt.Errorf("%w: Square has no saved card %s to charge", ErrReplayKeyNotRetained, tokenPrefix(req.SourceID))
|
||||
}
|
||||
if req.Currency == "" {
|
||||
req.Currency = gbpCurrency
|
||||
}
|
||||
pr, err := m.CreatePayment(ctx, req)
|
||||
if err != nil {
|
||||
if replayErrorProvesNoCharge(err) {
|
||||
return nil, fmt.Errorf("%w: %v", ErrReplayKeyNotRetained, err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
log.Printf("[SQUARE-MOCK] ReplayPaymentByKey charged saved card for unknown key: key=%s → id=%s", req.IdempotencyKey, pr.ID)
|
||||
return pr, nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: Square has no payment under idempotency key (HTTP 400: source rejected)", ErrReplayKeyNotRetained)
|
||||
}
|
||||
|
||||
func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*RefundResult, error) {
|
||||
@@ -568,6 +624,7 @@ func (m *MockClient) CreateCardOnFile(ctx context.Context, userID, cardToken, cu
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
}
|
||||
m.cards[userID][cardID] = card
|
||||
m.cardByToken[card.CardID] = card
|
||||
log.Printf("[SQUARE-MOCK] Card created: id=%s, brand=%s, last4=%s", cardID, card.Brand, card.Last4)
|
||||
return card, nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package square
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -19,6 +20,15 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// replaySnapshotReq marshals a CreatePaymentReq into the stored
|
||||
// square_request_snapshot shape (domain JSON) that ReplayPaymentByKey parses.
|
||||
func replaySnapshotReq(t *testing.T, req CreatePaymentReq) []byte {
|
||||
t.Helper()
|
||||
snap, err := json.Marshal(req)
|
||||
require.NoError(t, err)
|
||||
return snap
|
||||
}
|
||||
|
||||
func TestDevClient_CreatePayment_ReturnsCompleted(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
|
||||
@@ -672,6 +682,157 @@ func TestDevClient_GetCardsOnFile_Empty(t *testing.T) {
|
||||
assert.Empty(t, cards)
|
||||
}
|
||||
|
||||
func TestDevClient_ReplayPaymentByKey_MatchingSource_ReturnsOriginal(t *testing.T) {
|
||||
// Identical-body replay contract: a retained key with the MATCHING stored
|
||||
// source returns the ORIGINAL payment (Square's idempotency guarantee) —
|
||||
// never a second charge and never IDEMPOTENCY_KEY_REUSED.
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
|
||||
orig, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000,
|
||||
Currency: "GBP",
|
||||
SourceID: "cnon:test-card",
|
||||
IdempotencyKey: "replay-match-key",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := client.ReplayPaymentByKey(ctx, replaySnapshotReq(t, CreatePaymentReq{
|
||||
Amount: 5000,
|
||||
Currency: "GBP",
|
||||
SourceID: "cnon:test-card",
|
||||
IdempotencyKey: "replay-match-key",
|
||||
}))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, orig.ID, got.ID, "identical-body replay must return the original payment")
|
||||
}
|
||||
|
||||
func TestDevClient_ReplayPaymentByKey_SourceMismatch_ReturnsKeyReused(t *testing.T) {
|
||||
// Identical-body replay contract: reusing a retained key with a DIFFERENT
|
||||
// source is Square's documented IDEMPOTENCY_KEY_REUSED rejection — a data
|
||||
// bug, NOT proof the charge never happened. The mock must carry the
|
||||
// structured code so ErrorCode(err) can read it (the sweep treats it as
|
||||
// ambiguous).
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000,
|
||||
Currency: "GBP",
|
||||
SourceID: "cnon:test-card",
|
||||
IdempotencyKey: "replay-mismatch-key",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.ReplayPaymentByKey(ctx, replaySnapshotReq(t, CreatePaymentReq{
|
||||
Amount: 5000,
|
||||
Currency: "GBP",
|
||||
SourceID: "cnon:different",
|
||||
IdempotencyKey: "replay-mismatch-key",
|
||||
}))
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, "IDEMPOTENCY_KEY_REUSED", ErrorCode(err), "source-mismatch replay must carry IDEMPOTENCY_KEY_REUSED")
|
||||
assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err))
|
||||
assert.False(t, errors.Is(err, ErrReplayKeyNotRetained), "IDEMPOTENCY_KEY_REUSED is NOT proof the charge never happened")
|
||||
}
|
||||
|
||||
func TestDevClient_ReplayPaymentByKey_UnknownKey_NotRetained(t *testing.T) {
|
||||
// Identical-body replay contract: an unknown key makes Square attempt a
|
||||
// real charge with the (expired/used) cnon: nonce, which is rejected with a
|
||||
// 4xx — surfaced as ErrReplayKeyNotRetained (proof the charge never
|
||||
// happened).
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := client.ReplayPaymentByKey(ctx, replaySnapshotReq(t, CreatePaymentReq{
|
||||
Amount: 5000,
|
||||
Currency: "GBP",
|
||||
SourceID: "cnon:test-card",
|
||||
IdempotencyKey: "key-never-seen",
|
||||
}))
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrReplayKeyNotRetained), "unknown-key cnon replay must surface ErrReplayKeyNotRetained, got %v", err)
|
||||
}
|
||||
|
||||
func TestDevClient_ReplayPaymentByKey_UnknownKey_CcofSavedCard_ChargesAndRescues(t *testing.T) {
|
||||
// B3 dev/prod parity: an unknown key with a STILL-VALID ccof: saved-card
|
||||
// token makes real Square attempt a REAL charge that succeeds — the sweep
|
||||
// must RESCUE such rows, never fail them. The mock mirrors this by looking
|
||||
// up the saved card and creating a new COMPLETED payment under the key.
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
|
||||
card, err := client.CreateCardOnFile(ctx, "user-replay-rescue", "cnon:test-token", "cus_replay123")
|
||||
require.NoError(t, err)
|
||||
|
||||
snapshot := replaySnapshotReq(t, CreatePaymentReq{
|
||||
Amount: 5000,
|
||||
Currency: "GBP",
|
||||
SourceID: card.CardID,
|
||||
CustomerID: "cus_replay123",
|
||||
IdempotencyKey: "key-never-seen-ccof",
|
||||
})
|
||||
got, err := client.ReplayPaymentByKey(ctx, snapshot)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "COMPLETED", got.Status, "a still-valid ccof: source must charge successfully on an unknown key")
|
||||
assert.Equal(t, int64(5000), got.Amount)
|
||||
assert.Equal(t, "ON_FILE", got.EntryMethod)
|
||||
|
||||
// The charge must be recorded under the key so a later identical replay
|
||||
// returns the SAME payment (Square's dedup) instead of charging twice.
|
||||
got2, err := client.ReplayPaymentByKey(ctx, snapshot)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, got.ID, got2.ID, "a replayed ccof: charge under the same key must dedup, never charge twice")
|
||||
}
|
||||
|
||||
func TestDevClient_ReplayPaymentByKey_UnknownKey_UnregisteredCcof_NotRetained(t *testing.T) {
|
||||
// A ccof: token that is NOT in the saved-card ledger mirrors real Square
|
||||
// rejecting a deleted/disabled card with a definitive 4xx — the charge
|
||||
// never happened.
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := client.ReplayPaymentByKey(ctx, replaySnapshotReq(t, CreatePaymentReq{
|
||||
Amount: 5000,
|
||||
Currency: "GBP",
|
||||
SourceID: "ccof:never-registered",
|
||||
CustomerID: "cus_replay123",
|
||||
IdempotencyKey: "key-never-seen-ccof-deleted",
|
||||
}))
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrReplayKeyNotRetained), "an unregistered ccof: token must surface ErrReplayKeyNotRetained, got %v", err)
|
||||
}
|
||||
|
||||
func TestDevClient_ReplayPaymentByKey_DedupSourceTracked(t *testing.T) {
|
||||
// The mock must record the source used by each CreatePayment so a later
|
||||
// identical-body replay can verify the source matches (the "works in dev ==
|
||||
// works in prod" guarantee for the reconcile sweep).
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000,
|
||||
Currency: "GBP",
|
||||
SourceID: "cnon:dedup-src",
|
||||
IdempotencyKey: "replay-dedup-key",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
client.mu.RLock()
|
||||
stored := client.paymentSource["replay-dedup-key"]
|
||||
client.mu.RUnlock()
|
||||
assert.Equal(t, "cnon:dedup-src", stored, "the source of each keyed payment must be stored for replay parity")
|
||||
|
||||
got, err := client.ReplayPaymentByKey(ctx, replaySnapshotReq(t, CreatePaymentReq{
|
||||
Amount: 5000,
|
||||
Currency: "GBP",
|
||||
SourceID: "cnon:dedup-src",
|
||||
IdempotencyKey: "replay-dedup-key",
|
||||
}))
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, got.ID)
|
||||
}
|
||||
|
||||
func TestDevClient_GetCheckout_StillPending(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
client.HoldCheckouts = true
|
||||
|
||||
@@ -41,21 +41,17 @@ const (
|
||||
// Handlers log these errors verbatim, so echoing more than a snippet risks
|
||||
// leaking PII that Square may have mirrored from the request.
|
||||
maxErrorBody = 500
|
||||
|
||||
// probePaymentSourceID is the synthetic Square source token carried by the
|
||||
// sweep's replay-by-key reconcile (ReplayPaymentByKey). It uses the cnon:
|
||||
// prefix so it passes this client's PCI token validation (isTokenLike), but
|
||||
// it is NOT a real Square-issued nonce and can never be processed into a
|
||||
// charge. When the replayed idempotency key is unknown at Square, Square
|
||||
// therefore definitively rejects the request instead of creating a new
|
||||
// payment — the replay can never charge a customer.
|
||||
probePaymentSourceID = "cnon:sqr-reconcile-probe"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP client — shared by ProdClient (!dev) and devProdClient (dev).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// gbpCurrency is the currency sent in every Square money amount. UK-only app,
|
||||
// so GBP is the only currency ever used; the named constant keeps the wire
|
||||
// bodies (including the identical-body replay) consistent.
|
||||
const gbpCurrency = "GBP"
|
||||
|
||||
type httpClient struct {
|
||||
baseURL string
|
||||
token string
|
||||
@@ -458,6 +454,19 @@ func createPaymentHTTPWithClient(ctx context.Context, req CreatePaymentReq, hc *
|
||||
if !isTokenLike(req.SourceID) {
|
||||
return nil, fmt.Errorf("square: invalid card token %s — use a card nonce (cnon:xxx) or card ID (ccof:xxx)", tokenPrefix(req.SourceID))
|
||||
}
|
||||
var resp sqCreatePaymentResponse
|
||||
if err := hc.doJSON(ctx, http.MethodPost, "/v2/payments", buildCreatePaymentBody(req, hc), &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return paymentFromSquare(&resp.Payment), nil
|
||||
}
|
||||
|
||||
// buildCreatePaymentBody converts a CreatePaymentReq into the exact POST
|
||||
// /v2/payments wire body. Shared by createPaymentHTTPWithClient (the original
|
||||
// charge) and replayPaymentByKeyHTTPWithClient (the identical-body replay), so
|
||||
// a charge replayed from the stored snapshot produces BYTE-IDENTICAL JSON to
|
||||
// the original — Square's idempotency dedup compares the full request body.
|
||||
func buildCreatePaymentBody(req CreatePaymentReq, hc *httpClient) sqCreatePaymentRequest {
|
||||
body := sqCreatePaymentRequest{
|
||||
SourceID: req.SourceID,
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
@@ -473,11 +482,7 @@ func createPaymentHTTPWithClient(ctx context.Context, req CreatePaymentReq, hc *
|
||||
if req.TipMoney != nil {
|
||||
body.TipMoney = &sqMoney{Amount: *req.TipMoney, Currency: req.Currency}
|
||||
}
|
||||
var resp sqCreatePaymentResponse
|
||||
if err := hc.doJSON(ctx, http.MethodPost, "/v2/payments", body, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return paymentFromSquare(&resp.Payment), nil
|
||||
return body
|
||||
}
|
||||
|
||||
func createCheckoutHTTP(ctx context.Context, req CreateCheckoutReq) (*CheckoutResult, error) {
|
||||
@@ -572,25 +577,38 @@ func getPaymentHTTPWithClient(ctx context.Context, paymentID string, hc *httpCli
|
||||
return paymentFromSquare(&resp.Payment), nil
|
||||
}
|
||||
|
||||
func replayPaymentByKeyHTTP(ctx context.Context, idempotencyKey string, amount int64) (*PaymentResult, error) {
|
||||
return replayPaymentByKeyHTTPWithClient(ctx, idempotencyKey, amount, newHTTPClient())
|
||||
func replayPaymentByKeyHTTP(ctx context.Context, snapshotJSON []byte) (*PaymentResult, error) {
|
||||
return replayPaymentByKeyHTTPWithClient(ctx, snapshotJSON, newHTTPClient())
|
||||
}
|
||||
|
||||
// replayPaymentByKeyHTTPWithClient re-issues POST /v2/payments with the same
|
||||
// idempotency key and amount. Square's documented idempotency behavior returns
|
||||
// the ORIGINAL payment object when the key is reused — never a second charge.
|
||||
// The body's source_id is probePaymentSourceID, a synthetic token that cannot
|
||||
// be processed into a charge, so a key Square does not retain makes Square
|
||||
// reject the request instead of creating a new payment; that rejection is
|
||||
// surfaced as ErrReplayKeyNotRetained (proof the charge never happened).
|
||||
func replayPaymentByKeyHTTPWithClient(ctx context.Context, idempotencyKey string, amount int64, hc *httpClient) (*PaymentResult, error) {
|
||||
body := sqCreatePaymentRequest{
|
||||
SourceID: probePaymentSourceID,
|
||||
IdempotencyKey: idempotencyKey,
|
||||
AmountMoney: sqMoney{Amount: amount, Currency: "GBP"},
|
||||
// replayPaymentByKeyHTTPWithClient re-issues POST /v2/payments with an
|
||||
// IDENTICAL body to the original charge: the square_request_snapshot stored on
|
||||
// the pending row is the verbatim CreatePaymentReq JSON captured at charge
|
||||
// time, and buildCreatePaymentBody reproduces the exact wire request the
|
||||
// original charge sent (source_id, key, amount, customer_id, reference_id,
|
||||
// note, buyer_email_address, verification_token, tip, location). Square's
|
||||
// idempotency guarantee returns the ORIGINAL payment for a retained key (never
|
||||
// a second charge); a key Square no longer retains makes Square attempt a real
|
||||
// charge with the (expired/used) source, which Square rejects with a definitive
|
||||
// 4xx — surfaced as ErrReplayKeyNotRetained (proof the charge never happened).
|
||||
// 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.
|
||||
func replayPaymentByKeyHTTPWithClient(ctx context.Context, snapshotJSON []byte, hc *httpClient) (*PaymentResult, error) {
|
||||
var req CreatePaymentReq
|
||||
if err := json.Unmarshal(snapshotJSON, &req); err != nil {
|
||||
// An unparsable snapshot must never look like proof of no charge — the
|
||||
// sweep leaves such rows pending for manual reconciliation.
|
||||
return nil, fmt.Errorf("square: replay-by-key cannot parse stored request snapshot: %w", err)
|
||||
}
|
||||
if req.SourceID == "" || req.IdempotencyKey == "" {
|
||||
return nil, fmt.Errorf("square: replay-by-key snapshot missing source_id/idempotency_key")
|
||||
}
|
||||
if req.Currency == "" {
|
||||
req.Currency = gbpCurrency
|
||||
}
|
||||
var resp sqCreatePaymentResponse
|
||||
if err := hc.doJSON(ctx, http.MethodPost, "/v2/payments", body, &resp); err != nil {
|
||||
if err := hc.doJSON(ctx, http.MethodPost, "/v2/payments", buildCreatePaymentBody(req, hc), &resp); err != nil {
|
||||
if replayErrorProvesNoCharge(err) {
|
||||
return nil, fmt.Errorf("%w: %v", ErrReplayKeyNotRetained, err)
|
||||
}
|
||||
@@ -600,32 +618,30 @@ func replayPaymentByKeyHTTPWithClient(ctx context.Context, idempotencyKey string
|
||||
}
|
||||
|
||||
// replayErrorProvesNoCharge reports whether a ReplayPaymentByKey error
|
||||
// definitively proves Square has no payment under the key. A retained key
|
||||
// makes Square return the original payment (HTTP 2xx); every other DEFINITIVE
|
||||
// business rejection must therefore be Square attempting to process the
|
||||
// synthetic probe source for an unknown key — which can never succeed, so the
|
||||
// charge never happened. Auth (401/403 — affects every Square call, must not
|
||||
// fail rows) and rate-limit (429 — transient) are deliberately NOT proof; a
|
||||
// 5xx / transport error is ambiguous by definition.
|
||||
// definitively proves Square has no payment under the key. The replay carries
|
||||
// the ORIGINAL source_id (identical-body retry), so a retained key makes Square
|
||||
// return the original payment (HTTP 2xx); any definitive 4xx business rejection
|
||||
// must therefore be Square attempting a REAL charge with the expired/used
|
||||
// source — which can never succeed, so the charge never happened under that
|
||||
// key. IDEMPOTENCY_KEY_REUSED is the exception: it can only occur when the
|
||||
// stored source differs from the original charge's source (a data bug), so it
|
||||
// proves NOTHING about whether the original charge landed — it is AMBIGUOUS,
|
||||
// never proof of no charge. Auth (401/403 — affects every Square call, must
|
||||
// not fail rows), rate-limit (429 — transient), 5xx and transport errors are
|
||||
// ambiguous by definition.
|
||||
func replayErrorProvesNoCharge(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if ErrorCode(err) == "IDEMPOTENCY_KEY_REUSED" {
|
||||
return false
|
||||
}
|
||||
switch ErrorStatusCode(err) {
|
||||
case http.StatusUnauthorized, http.StatusForbidden, http.StatusTooManyRequests:
|
||||
return false
|
||||
}
|
||||
if status := ErrorStatusCode(err); status >= 400 && status < 500 {
|
||||
return true
|
||||
}
|
||||
// Errors without a structured HTTP status: a structured Square error code
|
||||
// is a definitive business response; the message match covers the dev mock's
|
||||
// plain rejection wording.
|
||||
if ErrorCode(err) != "" {
|
||||
return true
|
||||
}
|
||||
msg := strings.ToUpper(err.Error())
|
||||
return strings.Contains(msg, "INVALID_REQUEST") || strings.Contains(msg, "SOURCE_ID")
|
||||
status := ErrorStatusCode(err)
|
||||
return status >= 400 && status < 500
|
||||
}
|
||||
|
||||
// squareAPIError wraps a formatted Square API error while exposing the
|
||||
@@ -740,7 +756,7 @@ func refundPaymentHTTPWithClient(ctx context.Context, req RefundPaymentReq, hc *
|
||||
body := sqRefundPaymentRequest{
|
||||
PaymentID: req.PaymentID,
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
AmountMoney: sqMoney{Amount: req.Amount, Currency: "GBP"},
|
||||
AmountMoney: sqMoney{Amount: req.Amount, Currency: gbpCurrency},
|
||||
Reason: req.Reason,
|
||||
}
|
||||
var resp sqRefundPaymentResponse
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
package square
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -16,6 +18,130 @@ import (
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// TestReplayPaymentByKeyHTTP_IdenticalBody verifies the replay-by-key sends the
|
||||
// FULL ORIGINAL request body (identical-body replay): a snapshot carrying
|
||||
// customer_id, reference_id, note and buyer_email_address — the fields the
|
||||
// original charge sends that a key+source+amount reconstruction would DROP —
|
||||
// must reach Square verbatim. Square's idempotency dedup compares the whole
|
||||
// request, so a partial replay body returns IDEMPOTENCY_KEY_REUSED for a
|
||||
// retained key and the row stays pending forever.
|
||||
func TestReplayPaymentByKeyHTTP_IdenticalBody(t *testing.T) {
|
||||
var capturedRaw []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/v2/payments" {
|
||||
t.Errorf("expected /v2/payments, got %s", r.URL.Path)
|
||||
}
|
||||
var err error
|
||||
capturedRaw, err = io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Errorf("failed to read request body: %v", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"payment":{"id":"pay_orig","status":"COMPLETED","total_money":{"amount":5000,"currency":"GBP"},"source_type":"CARD","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()}
|
||||
req := CreatePaymentReq{
|
||||
Amount: 5000,
|
||||
Currency: "GBP",
|
||||
SourceID: "cnon:original-source",
|
||||
IdempotencyKey: "ik-replay",
|
||||
ReferenceID: "booking-123",
|
||||
Note: "deposit",
|
||||
CustomerID: "cus_123",
|
||||
VerificationToken: "verify-token-abc",
|
||||
BuyerEmail: "buyer@example.com",
|
||||
}
|
||||
snapshot, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build snapshot: %v", err)
|
||||
}
|
||||
res, err := replayPaymentByKeyHTTPWithClient(context.Background(), snapshot, hc)
|
||||
if err != nil {
|
||||
t.Fatalf("replayPaymentByKeyHTTP failed: %v", err)
|
||||
}
|
||||
// The wire body must be BYTE-IDENTICAL to the original charge's body
|
||||
// (both go through buildCreatePaymentBody from the same CreatePaymentReq).
|
||||
expectedWire, err := json.Marshal(buildCreatePaymentBody(req, hc))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal expected wire body: %v", err)
|
||||
}
|
||||
if !bytes.Equal(capturedRaw, expectedWire) {
|
||||
t.Errorf("replay body is not byte-identical to the original charge body:\n got %s\n want %s", capturedRaw, expectedWire)
|
||||
}
|
||||
var captured map[string]any
|
||||
if err := json.Unmarshal(capturedRaw, &captured); err != nil {
|
||||
t.Fatalf("failed to decode request body: %v", err)
|
||||
}
|
||||
if captured["source_id"] != "cnon:original-source" {
|
||||
t.Errorf("expected the ORIGINAL source_id in the replay body, got %v", captured["source_id"])
|
||||
}
|
||||
if captured["idempotency_key"] != "ik-replay" {
|
||||
t.Errorf("expected idempotency_key ik-replay, got %v", captured["idempotency_key"])
|
||||
}
|
||||
// The extra fields the original charge sent must survive the replay —
|
||||
// dropping them would make Square return IDEMPOTENCY_KEY_REUSED.
|
||||
if captured["customer_id"] != "cus_123" {
|
||||
t.Errorf("expected customer_id cus_123 in the replay body, got %v", captured["customer_id"])
|
||||
}
|
||||
if captured["reference_id"] != "booking-123" {
|
||||
t.Errorf("expected reference_id booking-123 in the replay body, got %v", captured["reference_id"])
|
||||
}
|
||||
if captured["note"] != "deposit" {
|
||||
t.Errorf("expected note deposit in the replay body, got %v", captured["note"])
|
||||
}
|
||||
if captured["buyer_email_address"] != "buyer@example.com" {
|
||||
t.Errorf("expected buyer_email_address buyer@example.com in the replay body, got %v", captured["buyer_email_address"])
|
||||
}
|
||||
if captured["verification_token"] != "verify-token-abc" {
|
||||
t.Errorf("expected verification_token verify-token-abc in the replay body, got %v", captured["verification_token"])
|
||||
}
|
||||
amt, ok := captured["amount_money"].(map[string]any)
|
||||
if !ok || amt["amount"] != float64(5000) || amt["currency"] != "GBP" {
|
||||
t.Errorf("expected amount_money {5000 GBP} (identical to the original charge), got %v", captured["amount_money"])
|
||||
}
|
||||
if res.ID != "pay_orig" {
|
||||
t.Errorf("expected the original payment returned, got %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReplayErrorProvesNoCharge_Classification locks the identical-body replay
|
||||
// error classification: a definitive 4xx (minus 401/403/429) proves the charge
|
||||
// never happened; IDEMPOTENCY_KEY_REUSED is AMBIGUOUS (a data bug, never proof
|
||||
// of no charge); 401/403/429/5xx/transport are ambiguous.
|
||||
func TestReplayErrorProvesNoCharge_Classification(t *testing.T) {
|
||||
badRequest := func(code string) error {
|
||||
return &squareAPIError{Code: code, StatusCode: http.StatusBadRequest, err: errors.New("square: boom")}
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{name: "card_declined_4xx_proves_no_charge", err: badRequest("CARD_DECLINED"), want: true},
|
||||
{name: "invalid_request_4xx_proves_no_charge", err: badRequest("INVALID_REQUEST_ERROR"), want: true},
|
||||
{name: "plain_400_proves_no_charge", err: &squareAPIError{StatusCode: http.StatusBadRequest, err: errors.New("square: HTTP 400")}, want: true},
|
||||
{name: "idempotency_key_reused_is_ambiguous", err: badRequest("IDEMPOTENCY_KEY_REUSED"), want: false},
|
||||
{name: "unauthorized_is_ambiguous", err: &squareAPIError{StatusCode: http.StatusUnauthorized, err: errors.New("square: 401")}, want: false},
|
||||
{name: "forbidden_is_ambiguous", err: &squareAPIError{StatusCode: http.StatusForbidden, err: errors.New("square: 403")}, want: false},
|
||||
{name: "rate_limited_is_ambiguous", err: &squareAPIError{StatusCode: http.StatusTooManyRequests, err: errors.New("square: 429")}, want: false},
|
||||
{name: "server_error_is_ambiguous", err: &squareAPIError{StatusCode: http.StatusInternalServerError, err: errors.New("square: 500")}, want: false},
|
||||
{name: "transport_error_is_ambiguous", err: errors.New("network error: connection reset"), want: false},
|
||||
{name: "nil_is_ambiguous", err: nil, want: false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := replayErrorProvesNoCharge(tc.err); got != tc.want {
|
||||
t.Errorf("replayErrorProvesNoCharge(%v) = %v, want %v", tc.err, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentFromSquare_ElseBranch_SurfacesBrandWithoutCardID(t *testing.T) {
|
||||
p := &sqPayment{
|
||||
ID: "pay_1",
|
||||
|
||||
@@ -22,14 +22,14 @@ var ErrRefundDeclined = errors.New("square: refund declined")
|
||||
var ErrRefundAlreadyProcessed = errors.New("square: refund already processed")
|
||||
|
||||
// ErrReplayKeyNotRetained is returned by ReplayPaymentByKey when Square proves
|
||||
// it holds NO payment under the idempotency key — either the original
|
||||
// CreatePayment never reached Square (connect/DNS failure before the request
|
||||
// was processed) or the key's 24h retention window has closed. The replay
|
||||
// carries a synthetic probe source token that can never process a real charge,
|
||||
// so Square's rejection of the probe is definitive: the charge never happened.
|
||||
// Callers treat this error as proof the payment was never made — never as an
|
||||
// ambiguous "maybe charged" state.
|
||||
var ErrReplayKeyNotRetained = errors.New("square: no payment under idempotency key (replay probe rejected)")
|
||||
// it holds NO payment under the idempotency key. The replay re-issues POST
|
||||
// /v2/payments with an IDENTICAL body to the original charge (the stored
|
||||
// square_request_snapshot), so a key Square no longer retains makes Square
|
||||
// attempt a REAL charge with the (expired/used) source — which Square rejects
|
||||
// with a definitive 4xx. That rejection is proof the charge never happened
|
||||
// under the key. Callers treat this error as proof the payment was never made —
|
||||
// never as an ambiguous "maybe charged" state.
|
||||
var ErrReplayKeyNotRetained = errors.New("square: no payment under idempotency key (identical-body replay rejected)")
|
||||
|
||||
// CreatePaymentReq maps to Square's CreatePayment endpoint (POST /v2/payments).
|
||||
// Square API reference: https://developer.squareup.com/reference/square/payments-api/create-payment
|
||||
@@ -191,15 +191,18 @@ type SquareClient interface {
|
||||
GetPayment(ctx context.Context, paymentID string) (*PaymentResult, error)
|
||||
|
||||
// ReplayPaymentByKey asks Square whether a payment exists under an
|
||||
// idempotency key by re-issuing POST /v2/payments with the same key and
|
||||
// amount. Square's idempotency guarantee returns the ORIGINAL payment for a
|
||||
// retained key and NEVER issues a second charge. The request carries a
|
||||
// synthetic probe source token that cannot process a real charge, so when
|
||||
// the key is unknown/expired Square definitively rejects the request
|
||||
// (ErrReplayKeyNotRetained) instead of creating a new payment — the replay
|
||||
// can never charge a customer. Used by the stale-pending sweep to rescue
|
||||
// idempotency key by re-issuing POST /v2/payments with the FULL ORIGINAL
|
||||
// request body — the square_request_snapshot stored on the pending row at
|
||||
// charge time (same source_id, key, amount and every other field the
|
||||
// original charge carried). Square's idempotency guarantee returns the
|
||||
// ORIGINAL payment for a retained key and NEVER issues a second charge. A
|
||||
// key Square does not retain makes Square attempt a real charge with the
|
||||
// (expired/used) source, which is rejected with a definitive 4xx — surfaced
|
||||
// as ErrReplayKeyNotRetained (proof the charge never happened). A reused key
|
||||
// with a DIFFERENT source returns IDEMPOTENCY_KEY_REUSED — a data bug that is
|
||||
// NOT proof of no charge. Used by the stale-pending sweep to rescue
|
||||
// lost-response charges whose square_payment_id was never persisted.
|
||||
ReplayPaymentByKey(ctx context.Context, idempotencyKey string, amount int64) (*PaymentResult, error)
|
||||
ReplayPaymentByKey(ctx context.Context, snapshotJSON []byte) (*PaymentResult, error)
|
||||
|
||||
// CreateCustomer provisions a Square customer (customer provisioning for
|
||||
// card-on-file payments). Square dedups on the deterministic
|
||||
|
||||
Reference in New Issue
Block a user