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
+83 -28
View File
@@ -39,9 +39,19 @@ type MockClient struct {
HoldCheckouts bool
ShouldFail bool // if true, CreatePayment/RefundPayment return errors for testing error paths
// FailRefundCode simulates a specific Square refund rejection code. Empty
// = normal success; when set (e.g. "PAYMENT_ALREADY_REFUNDED"),
// RefundPayment returns the sentinel-wrapped error for that code.
// = normal success; when set, RefundPayment returns the sentinel-wrapped
// 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
// 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
// prod-only pending-refund branch (normally only reachable against the
// 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) {
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) {
return createCustomerHTTP(ctx, name, email)
}
@@ -226,7 +239,7 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
TipAmount: tipAmount,
ReceiptURL: "https://squareup.com/receipt/" + paymentID,
ReceiptNumber: fmt.Sprintf("RCPT_mock_%d", now.UnixNano()),
SquarePayID: "sqp_" + paymentID,
SquarePayID: paymentID,
Fees: fees,
BuyerEmail: req.BuyerEmail,
CustomerID: req.CustomerID,
@@ -236,6 +249,10 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
ReferenceID: req.ReferenceID,
}
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
if req.IdempotencyKey != "" {
m.paymentByKey[req.IdempotencyKey] = result
@@ -253,9 +270,14 @@ func (m *MockClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq)
now := clock.Now().UTC()
checkoutID := fmt.Sprintf("chk_mock_%d", now.UnixNano())
status := "PENDING"
if m.ForceCheckoutState != "" {
status = m.ForceCheckoutState
}
result := &CheckoutResult{
ID: checkoutID,
Status: "PENDING",
Status: status,
AmountMoney: req.Amount,
Currency: req.Currency,
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.
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() {
defer func() {
if r := recover(); r != nil {
@@ -313,7 +339,7 @@ func (m *MockClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq)
TipAmount: tipAmount,
ReceiptURL: "https://squareup.com/receipt/" + paymentID,
ReceiptNumber: fmt.Sprintf("RCPT_mock_%d", payNow.UnixNano()),
SquarePayID: "sqp_" + paymentID,
SquarePayID: paymentID,
Fees: fees,
CustomerID: req.CustomerID,
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)
}
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
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) {
@@ -368,14 +403,36 @@ 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)
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) {
if m.ShouldFail {
return nil, fmt.Errorf("%w: refund declined (simulated failure)", ErrRefundDeclined)
}
if m.FailRefundCode != "" {
switch m.FailRefundCode {
case "PAYMENT_ALREADY_REFUNDED":
return nil, fmt.Errorf("%w: payment already fully refunded (simulated)", ErrRefundAlreadyProcessed)
case "PAYMENT_ALREADY_REFUNDED", "REFUND_ALREADY_PENDING":
// 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:
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()
refundID := fmt.Sprintf("ref_mock_%d", now.UnixNano())
payment, ok := m.payments[req.PaymentID]
if !ok {
if req.Amount == 0 {
// A £0 refund resolves to a full refund only when the payment is
// known; against an unknown payment there is nothing to size it
// from. The real DB has a CHECK (amount > 0), so an empty refund
// must fail rather than silently record £0.
return nil, fmt.Errorf("square: refund amount must be positive (payment %s not found, cannot resolve full refund)", req.PaymentID)
}
// Square's RefundPayment requires amount_money — a missing or zero amount
// is rejected (400 INVALID_REQUEST_ERROR / REFUND_AMOUNT_INVALID), never
// treated as a "full refund" shortcut. The mock mirrors this so a
// missing-amount bug can't be masked in dev (the real DB also has a CHECK
// amount > 0, so a £0 refund must fail rather than silently record nothing).
if req.Amount <= 0 {
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.
@@ -416,9 +474,6 @@ func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*
}
amount := req.Amount
if amount == 0 && ok {
amount = payment.Amount
}
locationID := req.LocationID
if locationID == "" {