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
+136 -4
View File
@@ -40,6 +40,15 @@ func TestDevClient_CreatePayment_ReturnsCompleted(t *testing.T) {
assert.Equal(t, "4242", result.CardLast4)
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)
require.NotNil(t, 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(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.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")
}
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) {
// Any other code configured via FailRefundCode preserves the prior
// ErrRefundDeclined classification (e.g. REFUND_DECLINED in prod).
@@ -665,6 +709,89 @@ func TestDevClient_CreateCheckout_HoldCheckouts(t *testing.T) {
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) {
client := NewDevClient().(*MockClient)
ctx := context.Background()
@@ -1067,7 +1194,11 @@ func TestDevClient_RefundPayment_ZeroAmountUnknownPayment(t *testing.T) {
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)
ctx := context.Background()
@@ -1080,13 +1211,14 @@ func TestDevClient_RefundPayment_ZeroAmountFullRefundWhenPaymentExists(t *testin
})
require.NoError(t, err)
refundResult, err := client.RefundPayment(ctx, RefundPaymentReq{
result, err := client.RefundPayment(ctx, RefundPaymentReq{
PaymentID: paymentResult.ID,
Amount: 0,
IdempotencyKey: "zero-refund-known",
})
require.NoError(t, err)
assert.Equal(t, int64(10000), refundResult.Amount, "amount 0 = full refund when the payment exists")
require.Error(t, err)
assert.Nil(t, result)
assert.Contains(t, err.Error(), "amount must be positive")
}
func TestDevClient_ListPaymentRefunds_ConcurrentReads(t *testing.T) {