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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user