Square client and dev mock: replay-by-key reconcile, refund classification, mock parity

ReplayPaymentByKey (POST /v2/payments re-issue with the same idempotency key
and a synthetic probe source token that can never process a real charge):
Square returns the ORIGINAL payment for a retained key and definitively
rejects an unknown/expired one, so the stale-pending sweep can rescue
lost-response charges without ever issuing a second payment. ErrReplayKeyNotRetained
marks a probe rejection as proof the charge never happened.

Refund classification: zero-amount refunds are now rejected (Square requires
amount_money) instead of lenient full-refund; REFUND_ALREADY_PENDING is
classified as already-processed to match the real contract.

Dev mock parity: SquarePayID == payment ID (was fabricated 'sqp_' prefix),
ForceCheckoutState for IN_PROGRESS/CANCEL_REQUESTED terminal states,
replay-by-key support, aligned refund error codes.
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 197d4c4b9b
commit 01ac211408
5 changed files with 310 additions and 33 deletions
+4
View File
@@ -35,6 +35,10 @@ func (p *ProdClient) GetPayment(ctx context.Context, paymentID string) (*Payment
return getPaymentHTTP(ctx, paymentID) 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) CreateCustomer(ctx context.Context, name, email string) (*CustomerResult, error) { func (p *ProdClient) CreateCustomer(ctx context.Context, name, email string) (*CustomerResult, error) {
return createCustomerHTTP(ctx, name, email) return createCustomerHTTP(ctx, name, email)
} }
+83 -28
View File
@@ -39,9 +39,19 @@ type MockClient struct {
HoldCheckouts bool HoldCheckouts bool
ShouldFail bool // if true, CreatePayment/RefundPayment return errors for testing error paths ShouldFail bool // if true, CreatePayment/RefundPayment return errors for testing error paths
// FailRefundCode simulates a specific Square refund rejection code. Empty // FailRefundCode simulates a specific Square refund rejection code. Empty
// = normal success; when set (e.g. "PAYMENT_ALREADY_REFUNDED"), // = normal success; when set, RefundPayment returns the sentinel-wrapped
// RefundPayment returns the sentinel-wrapped error for that code. // error for that code. The money-in-flight codes Square actually emits —
// REFUND_ALREADY_PENDING (real) and PAYMENT_ALREADY_REFUNDED (kept for
// resilience, matching the real client's classification) — map to
// ErrRefundAlreadyProcessed; any other code maps to ErrRefundDeclined.
FailRefundCode string FailRefundCode string
// ForceCheckoutState forces CreateCheckout's initial status instead of the
// default "PENDING" (one of "IN_PROGRESS", "CANCEL_REQUESTED", "CANCELED").
// While set, the auto-complete goroutine is suppressed so the forced state
// persists — the sweep's intermediate-state paths (isTerminalCheckoutError
// / isCheckoutDefinitivelyDead) can then be exercised in dev/tests exactly
// as they run against the real Square API.
ForceCheckoutState string
// ForceRefundPending makes RefundPayment return a PENDING refund so the // ForceRefundPending makes RefundPayment return a PENDING refund so the
// prod-only pending-refund branch (normally only reachable against the // prod-only pending-refund branch (normally only reachable against the
// real Square API) can be exercised in dev/tests. // real Square API) can be exercised in dev/tests.
@@ -66,6 +76,9 @@ func (d *devProdClient) GetCheckout(ctx context.Context, checkoutID string) (*Pa
func (d *devProdClient) GetPayment(ctx context.Context, paymentID string) (*PaymentResult, error) { func (d *devProdClient) GetPayment(ctx context.Context, paymentID string) (*PaymentResult, error) {
return getPaymentHTTP(ctx, paymentID) 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) CreateCustomer(ctx context.Context, name, email string) (*CustomerResult, error) { func (d *devProdClient) CreateCustomer(ctx context.Context, name, email string) (*CustomerResult, error) {
return createCustomerHTTP(ctx, name, email) return createCustomerHTTP(ctx, name, email)
} }
@@ -226,7 +239,7 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
TipAmount: tipAmount, TipAmount: tipAmount,
ReceiptURL: "https://squareup.com/receipt/" + paymentID, ReceiptURL: "https://squareup.com/receipt/" + paymentID,
ReceiptNumber: fmt.Sprintf("RCPT_mock_%d", now.UnixNano()), ReceiptNumber: fmt.Sprintf("RCPT_mock_%d", now.UnixNano()),
SquarePayID: "sqp_" + paymentID, SquarePayID: paymentID,
Fees: fees, Fees: fees,
BuyerEmail: req.BuyerEmail, BuyerEmail: req.BuyerEmail,
CustomerID: req.CustomerID, CustomerID: req.CustomerID,
@@ -236,6 +249,10 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
ReferenceID: req.ReferenceID, ReferenceID: req.ReferenceID,
} }
m.payments[paymentID] = result m.payments[paymentID] = result
// SquarePayID is the same ID as the payment (paymentFromSquare sets
// SquarePayID = sq.ID), so the lookup map is keyed identically to the real
// client — reconcile/sweep code that resolves a stored square_payment_id
// via GetPayment behaves the same in mock and prod.
m.payments[result.SquarePayID] = result m.payments[result.SquarePayID] = result
if req.IdempotencyKey != "" { if req.IdempotencyKey != "" {
m.paymentByKey[req.IdempotencyKey] = result m.paymentByKey[req.IdempotencyKey] = result
@@ -253,9 +270,14 @@ func (m *MockClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq)
now := clock.Now().UTC() now := clock.Now().UTC()
checkoutID := fmt.Sprintf("chk_mock_%d", now.UnixNano()) checkoutID := fmt.Sprintf("chk_mock_%d", now.UnixNano())
status := "PENDING"
if m.ForceCheckoutState != "" {
status = m.ForceCheckoutState
}
result := &CheckoutResult{ result := &CheckoutResult{
ID: checkoutID, ID: checkoutID,
Status: "PENDING", Status: status,
AmountMoney: req.Amount, AmountMoney: req.Amount,
Currency: req.Currency, Currency: req.Currency,
ReferenceID: req.ReferenceID, ReferenceID: req.ReferenceID,
@@ -273,7 +295,11 @@ func (m *MockClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq)
// The caller gets this copy; the goroutine modifies the map-stored original. // The caller gets this copy; the goroutine modifies the map-stored original.
resultCopy := *result resultCopy := *result
if !m.HoldCheckouts { // A forced checkout state must persist (the sweep's intermediate-state
// paths need a stable IN_PROGRESS / CANCEL_REQUESTED / CANCELED checkout),
// so the auto-complete goroutine is suppressed while ForceCheckoutState is
// set — exactly like HoldCheckouts.
if !m.HoldCheckouts && m.ForceCheckoutState == "" {
go func() { go func() {
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
@@ -313,7 +339,7 @@ func (m *MockClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq)
TipAmount: tipAmount, TipAmount: tipAmount,
ReceiptURL: "https://squareup.com/receipt/" + paymentID, ReceiptURL: "https://squareup.com/receipt/" + paymentID,
ReceiptNumber: fmt.Sprintf("RCPT_mock_%d", payNow.UnixNano()), ReceiptNumber: fmt.Sprintf("RCPT_mock_%d", payNow.UnixNano()),
SquarePayID: "sqp_" + paymentID, SquarePayID: paymentID,
Fees: fees, Fees: fees,
CustomerID: req.CustomerID, CustomerID: req.CustomerID,
LocationID: "L_MOCK", LocationID: "L_MOCK",
@@ -343,16 +369,25 @@ func (m *MockClient) GetCheckout(ctx context.Context, checkoutID string) (*Payme
return nil, fmt.Errorf("checkout not found: %s", checkoutID) return nil, fmt.Errorf("checkout not found: %s", checkoutID)
} }
if checkout.Status == "PENDING" { // Mirror the real client's GetCheckout state machine
// (getCheckoutHTTPWithClient): PENDING / IN_PROGRESS / CANCEL_REQUESTED are
// all still-live checkout states → ErrCheckoutPending; any other
// non-COMPLETED status (CANCELED, FAILED, expired) surfaces a plain
// "is <status> (not COMPLETED)" error so the sweep's
// isTerminalCheckoutError / isCheckoutDefinitivelyDead classification runs
// identically in mock and prod.
switch checkout.Status {
case "PENDING", "IN_PROGRESS", "CANCEL_REQUESTED":
return nil, ErrCheckoutPending return nil, ErrCheckoutPending
case "COMPLETED":
result, ok := m.completed[checkoutID]
if !ok {
return nil, fmt.Errorf("checkout result not found: %s", checkoutID)
}
return result, nil
default:
return nil, fmt.Errorf("square: checkout %s is %s (not COMPLETED)", checkoutID, checkout.Status)
} }
result, ok := m.completed[checkoutID]
if !ok {
return nil, fmt.Errorf("checkout result not found: %s", checkoutID)
}
return result, nil
} }
func (m *MockClient) GetPayment(ctx context.Context, paymentID string) (*PaymentResult, error) { func (m *MockClient) GetPayment(ctx context.Context, paymentID string) (*PaymentResult, error) {
@@ -368,14 +403,36 @@ func (m *MockClient) GetPayment(ctx context.Context, paymentID string) (*Payment
return payment, nil 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)
m.mu.RLock()
defer m.mu.RUnlock()
if existing, ok := m.paymentByKey[idempotencyKey]; ok {
log.Printf("[SQUARE-MOCK] ReplayPaymentByKey dedup hit: key=%s → id=%s", idempotencyKey, existing.ID)
return existing, nil
}
return nil, fmt.Errorf("%w: Square has no payment under idempotency key", ErrReplayKeyNotRetained)
}
func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*RefundResult, error) { func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*RefundResult, error) {
if m.ShouldFail { if m.ShouldFail {
return nil, fmt.Errorf("%w: refund declined (simulated failure)", ErrRefundDeclined) return nil, fmt.Errorf("%w: refund declined (simulated failure)", ErrRefundDeclined)
} }
if m.FailRefundCode != "" { if m.FailRefundCode != "" {
switch m.FailRefundCode { switch m.FailRefundCode {
case "PAYMENT_ALREADY_REFUNDED": case "PAYMENT_ALREADY_REFUNDED", "REFUND_ALREADY_PENDING":
return nil, fmt.Errorf("%w: payment already fully refunded (simulated)", ErrRefundAlreadyProcessed) // Both codes mean money is in flight or has already moved at
// Square — the same classification the real client applies
// (square_http_client.go:687), so the concurrent-refund dedup path
// is exercisable in dev.
return nil, fmt.Errorf("%w: %s (simulated)", ErrRefundAlreadyProcessed, m.FailRefundCode)
default: default:
return nil, fmt.Errorf("%w: %s (simulated failure)", ErrRefundDeclined, m.FailRefundCode) return nil, fmt.Errorf("%w: %s (simulated failure)", ErrRefundDeclined, m.FailRefundCode)
} }
@@ -400,15 +457,16 @@ func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*
now := clock.Now().UTC() now := clock.Now().UTC()
refundID := fmt.Sprintf("ref_mock_%d", now.UnixNano()) refundID := fmt.Sprintf("ref_mock_%d", now.UnixNano())
payment, ok := m.payments[req.PaymentID] // Square's RefundPayment requires amount_money — a missing or zero amount
if !ok { // is rejected (400 INVALID_REQUEST_ERROR / REFUND_AMOUNT_INVALID), never
if req.Amount == 0 { // treated as a "full refund" shortcut. The mock mirrors this so a
// A £0 refund resolves to a full refund only when the payment is // missing-amount bug can't be masked in dev (the real DB also has a CHECK
// known; against an unknown payment there is nothing to size it // amount > 0, so a £0 refund must fail rather than silently record nothing).
// from. The real DB has a CHECK (amount > 0), so an empty refund if req.Amount <= 0 {
// must fail rather than silently record £0. return nil, fmt.Errorf("square: refund amount must be positive (amount_money is required)")
return nil, fmt.Errorf("square: refund amount must be positive (payment %s not found, cannot resolve full refund)", req.PaymentID) }
}
if _, ok := m.payments[req.PaymentID]; !ok {
// Payment not in mock map — this happens when integration tests // Payment not in mock map — this happens when integration tests
// create payments via DB fixture with a square_payment_id, bypassing // create payments via DB fixture with a square_payment_id, bypassing
// the mock. Process the refund without full payment data. // the mock. Process the refund without full payment data.
@@ -416,9 +474,6 @@ func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*
} }
amount := req.Amount amount := req.Amount
if amount == 0 && ok {
amount = payment.Amount
}
locationID := req.LocationID locationID := req.LocationID
if locationID == "" { if locationID == "" {
+136 -4
View File
@@ -40,6 +40,15 @@ func TestDevClient_CreatePayment_ReturnsCompleted(t *testing.T) {
assert.Equal(t, "4242", result.CardLast4) assert.Equal(t, "4242", result.CardLast4)
assert.NotZero(t, result.Fees) assert.NotZero(t, result.Fees)
// The mock must set SquarePayID == ID exactly like the real client
// (paymentFromSquare: SquarePayID = sq.ID) — a fabricated "sqp_" ID would
// make reconcile/sweep code that resolves a stored square_payment_id via
// GetPayment behave differently in mock vs prod.
assert.Equal(t, result.ID, result.SquarePayID, "mock SquarePayID must equal the payment ID")
got, err := client.GetPayment(ctx, result.SquarePayID)
require.NoError(t, err)
assert.Equal(t, result.ID, got.ID, "GetPayment must resolve the payment via SquarePayID (reconcile path)")
assert.NotEmpty(t, result.CardFingerprint) assert.NotEmpty(t, result.CardFingerprint)
require.NotNil(t, result.ExpMonth) require.NotNil(t, result.ExpMonth)
assert.Equal(t, 12, *result.ExpMonth) assert.Equal(t, 12, *result.ExpMonth)
@@ -85,6 +94,11 @@ func TestDevClient_CreateCheckout_PendingThenCompleted(t *testing.T) {
assert.Equal(t, int64(8000), completed.Amount, "expected amount 8000 (7500 + 500 tip)") assert.Equal(t, int64(8000), completed.Amount, "expected amount 8000 (7500 + 500 tip)")
assert.Equal(t, int64(500), completed.TipAmount) assert.Equal(t, int64(500), completed.TipAmount)
// The completed terminal payment's SquarePayID must equal its ID too
// (parity with paymentFromSquare), so a terminal-checkout payment recorded
// via SquarePayID reconciles identically in mock and prod.
assert.Equal(t, completed.ID, completed.SquarePayID, "terminal payment SquarePayID must equal its ID")
assert.NotEmpty(t, completed.CardFingerprint) assert.NotEmpty(t, completed.CardFingerprint)
assert.NotEmpty(t, completed.EntryMethod) assert.NotEmpty(t, completed.EntryMethod)
} }
@@ -380,6 +394,36 @@ func TestDevClient_RefundPayment_PaymentAlreadyRefunded(t *testing.T) {
assert.Len(t, client.refundByKey, 0, "no refund-by-key entry must be stored when the payment is already refunded") assert.Len(t, client.refundByKey, 0, "no refund-by-key entry must be stored when the payment is already refunded")
} }
func TestDevClient_RefundPayment_RefundAlreadyPending(t *testing.T) {
// REFUND_ALREADY_PENDING is Square's REAL money-in-flight code (a refund
// for this payment is already pending at Square). The real client maps it
// to ErrRefundAlreadyProcessed (square_http_client.go:687); the mock must
// classify it identically so the concurrent-refund dedup path — where the
// caller resolves the row to 'completed' instead of retrying — is
// exercisable in dev.
client := NewDevClient().(*MockClient)
client.FailRefundCode = "REFUND_ALREADY_PENDING"
ctx := context.Background()
req := RefundPaymentReq{
PaymentID: "pay_mock_already_pending",
Amount: 5000,
IdempotencyKey: "refund-key-already-pending",
Reason: "already pending",
}
result, err := client.RefundPayment(ctx, req)
require.Error(t, err)
assert.Nil(t, result)
assert.True(t, errors.Is(err, ErrRefundAlreadyProcessed), "expected ErrRefundAlreadyProcessed, got: %v", err)
assert.False(t, errors.Is(err, ErrRefundDeclined), "already-pending refund must not be classified as declined: %v", err)
client.mu.RLock()
defer client.mu.RUnlock()
assert.Len(t, client.refunds, 0, "no refund must be stored when a refund is already pending")
assert.Len(t, client.refundByKey, 0, "no refund-by-key entry must be stored when a refund is already pending")
}
func TestDevClient_RefundPayment_FailRefundCode_OtherCode(t *testing.T) { func TestDevClient_RefundPayment_FailRefundCode_OtherCode(t *testing.T) {
// Any other code configured via FailRefundCode preserves the prior // Any other code configured via FailRefundCode preserves the prior
// ErrRefundDeclined classification (e.g. REFUND_DECLINED in prod). // ErrRefundDeclined classification (e.g. REFUND_DECLINED in prod).
@@ -665,6 +709,89 @@ func TestDevClient_CreateCheckout_HoldCheckouts(t *testing.T) {
require.Error(t, err) require.Error(t, err)
} }
func TestDevClient_GetCheckout_ForceInProgress(t *testing.T) {
// IN_PROGRESS is a real Square terminal state (the customer is tapping the
// card). The mock must hold it — never auto-complete — so the sweep's
// isTerminalCheckoutError intermediate-state path is exercisable in dev.
client := NewDevClient().(*MockClient)
client.ForceCheckoutState = "IN_PROGRESS"
ctx := context.Background()
result, err := client.CreateCheckout(ctx, CreateCheckoutReq{
Amount: 5000,
Currency: "GBP",
IdempotencyKey: "in-progress-checkout",
ReferenceID: "in-progress-ref",
})
require.NoError(t, err)
assert.Equal(t, "IN_PROGRESS", result.Status)
// Mirror the real client: IN_PROGRESS → ErrCheckoutPending (still live).
_, err = client.GetCheckout(ctx, result.ID)
require.Error(t, err)
assert.True(t, errors.Is(err, ErrCheckoutPending), "expected ErrCheckoutPending for IN_PROGRESS checkout, got: %v", err)
// The forced state must persist (no auto-complete while forced).
client.mu.RLock()
checkout := client.checkouts[result.ID]
client.mu.RUnlock()
require.NotNil(t, checkout)
assert.Equal(t, "IN_PROGRESS", checkout.Status)
}
func TestDevClient_GetCheckout_ForceCancelRequested(t *testing.T) {
// CANCEL_REQUESTED is the "customer tapped cancel on the terminal" state.
// The real client folds it into ErrCheckoutPending (Square does not
// promise non-completion), and the mock must mirror that so the sweep
// treats it as still-live rather than definitively dead.
client := NewDevClient().(*MockClient)
client.ForceCheckoutState = "CANCEL_REQUESTED"
ctx := context.Background()
result, err := client.CreateCheckout(ctx, CreateCheckoutReq{
Amount: 5000,
Currency: "GBP",
IdempotencyKey: "cancel-requested-checkout",
ReferenceID: "cancel-requested-ref",
})
require.NoError(t, err)
assert.Equal(t, "CANCEL_REQUESTED", result.Status)
_, err = client.GetCheckout(ctx, result.ID)
require.Error(t, err)
assert.True(t, errors.Is(err, ErrCheckoutPending), "expected ErrCheckoutPending for CANCEL_REQUESTED checkout, got: %v", err)
client.mu.RLock()
checkout := client.checkouts[result.ID]
client.mu.RUnlock()
require.NotNil(t, checkout)
assert.Equal(t, "CANCEL_REQUESTED", checkout.Status)
}
func TestDevClient_GetCheckout_ForceCanceled(t *testing.T) {
// CANCELED is terminal at Square. The real client surfaces it as a plain
// "is CANCELED (not COMPLETED)" error (getCheckoutHTTPWithClient), which
// the sweep classifies as definitively dead. The mock must emit the same
// shape so isCheckoutDefinitivelyDead runs identically in dev.
client := NewDevClient().(*MockClient)
client.ForceCheckoutState = "CANCELED"
ctx := context.Background()
result, err := client.CreateCheckout(ctx, CreateCheckoutReq{
Amount: 5000,
Currency: "GBP",
IdempotencyKey: "canceled-checkout",
ReferenceID: "canceled-ref",
})
require.NoError(t, err)
assert.Equal(t, "CANCELED", result.Status)
_, err = client.GetCheckout(ctx, result.ID)
require.Error(t, err)
assert.False(t, errors.Is(err, ErrCheckoutPending), "a CANCELED checkout is terminal, not pending: %v", err)
assert.Contains(t, err.Error(), "CANCELED")
}
func TestDevClient_ListPaymentRefunds_FiltersByPaymentAndTime(t *testing.T) { func TestDevClient_ListPaymentRefunds_FiltersByPaymentAndTime(t *testing.T) {
client := NewDevClient().(*MockClient) client := NewDevClient().(*MockClient)
ctx := context.Background() ctx := context.Background()
@@ -1067,7 +1194,11 @@ func TestDevClient_RefundPayment_ZeroAmountUnknownPayment(t *testing.T) {
assert.Contains(t, err.Error(), "amount must be positive") assert.Contains(t, err.Error(), "amount must be positive")
} }
func TestDevClient_RefundPayment_ZeroAmountFullRefundWhenPaymentExists(t *testing.T) { func TestDevClient_RefundPayment_ZeroAmountRejected(t *testing.T) {
// Square's RefundPayment requires amount_money — a £0 refund is a 400
// rejection even when the payment exists, never a "full refund" shortcut.
// The mock must mirror this so a missing-amount bug can't be masked in dev
// (handlers guard with ValidateAmount; the DB has a CHECK amount > 0).
client := NewDevClient().(*MockClient) client := NewDevClient().(*MockClient)
ctx := context.Background() ctx := context.Background()
@@ -1080,13 +1211,14 @@ func TestDevClient_RefundPayment_ZeroAmountFullRefundWhenPaymentExists(t *testin
}) })
require.NoError(t, err) require.NoError(t, err)
refundResult, err := client.RefundPayment(ctx, RefundPaymentReq{ result, err := client.RefundPayment(ctx, RefundPaymentReq{
PaymentID: paymentResult.ID, PaymentID: paymentResult.ID,
Amount: 0, Amount: 0,
IdempotencyKey: "zero-refund-known", IdempotencyKey: "zero-refund-known",
}) })
require.NoError(t, err) require.Error(t, err)
assert.Equal(t, int64(10000), refundResult.Amount, "amount 0 = full refund when the payment exists") assert.Nil(t, result)
assert.Contains(t, err.Error(), "amount must be positive")
} }
func TestDevClient_ListPaymentRefunds_ConcurrentReads(t *testing.T) { func TestDevClient_ListPaymentRefunds_ConcurrentReads(t *testing.T) {
@@ -41,6 +41,15 @@ const (
// Handlers log these errors verbatim, so echoing more than a snippet risks // Handlers log these errors verbatim, so echoing more than a snippet risks
// leaking PII that Square may have mirrored from the request. // leaking PII that Square may have mirrored from the request.
maxErrorBody = 500 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"
) )
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -563,6 +572,62 @@ func getPaymentHTTPWithClient(ctx context.Context, paymentID string, hc *httpCli
return paymentFromSquare(&resp.Payment), nil return paymentFromSquare(&resp.Payment), nil
} }
func replayPaymentByKeyHTTP(ctx context.Context, idempotencyKey string, amount int64) (*PaymentResult, error) {
return replayPaymentByKeyHTTPWithClient(ctx, idempotencyKey, amount, 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"},
}
var resp sqCreatePaymentResponse
if err := hc.doJSON(ctx, http.MethodPost, "/v2/payments", body, &resp); err != nil {
if replayErrorProvesNoCharge(err) {
return nil, fmt.Errorf("%w: %v", ErrReplayKeyNotRetained, err)
}
return nil, err
}
return paymentFromSquare(&resp.Payment), nil
}
// 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.
func replayErrorProvesNoCharge(err error) bool {
if err == nil {
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")
}
// squareAPIError wraps a formatted Square API error while exposing the // squareAPIError wraps a formatted Square API error while exposing the
// structured Square error code and the HTTP status code so callers can // structured Square error code and the HTTP status code so callers can
// classify definitive business rejections (e.g. ErrRefundDeclined) vs // classify definitive business rejections (e.g. ErrRefundDeclined) vs
+22 -1
View File
@@ -21,6 +21,16 @@ var ErrRefundDeclined = errors.New("square: refund declined")
// 'completed' rather than 'failed' (which would let the guard over-refund). // 'completed' rather than 'failed' (which would let the guard over-refund).
var ErrRefundAlreadyProcessed = errors.New("square: refund already processed") 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)")
// CreatePaymentReq maps to Square's CreatePayment endpoint (POST /v2/payments). // CreatePaymentReq maps to Square's CreatePayment endpoint (POST /v2/payments).
// Square API reference: https://developer.squareup.com/reference/square/payments-api/create-payment // Square API reference: https://developer.squareup.com/reference/square/payments-api/create-payment
type CreatePaymentReq struct { type CreatePaymentReq struct {
@@ -59,7 +69,7 @@ type CreateCheckoutReq struct {
// RefundPaymentReq maps to Square's RefundPayment endpoint (POST /v2/refunds). // RefundPaymentReq maps to Square's RefundPayment endpoint (POST /v2/refunds).
type RefundPaymentReq struct { type RefundPaymentReq struct {
PaymentID string PaymentID string
Amount int64 // in pence, 0 = full refund Amount int64 // in pence; REQUIRED — Square rejects a missing/zero amount_money (never "0 = full refund")
IdempotencyKey string IdempotencyKey string
Reason string Reason string
LocationID string // Square location ID (optional; defaults to main location) LocationID string // Square location ID (optional; defaults to main location)
@@ -180,6 +190,17 @@ type SquareClient interface {
// flow to check the authoritative payment status at Square. // flow to check the authoritative payment status at Square.
GetPayment(ctx context.Context, paymentID string) (*PaymentResult, error) 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
// lost-response charges whose square_payment_id was never persisted.
ReplayPaymentByKey(ctx context.Context, idempotencyKey string, amount int64) (*PaymentResult, error)
// CreateCustomer provisions a Square customer (customer provisioning for // CreateCustomer provisions a Square customer (customer provisioning for
// card-on-file payments). Square dedups on the deterministic // card-on-file payments). Square dedups on the deterministic
// idempotency key (derived from email). // idempotency key (derived from email).