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:
@@ -35,6 +35,10 @@ 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) CreateCustomer(ctx context.Context, name, email string) (*CustomerResult, error) {
|
||||
return createCustomerHTTP(ctx, name, email)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
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 == "" {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -41,6 +41,15 @@ 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"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -563,6 +572,62 @@ 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())
|
||||
}
|
||||
|
||||
// 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
|
||||
// structured Square error code and the HTTP status code so callers can
|
||||
// classify definitive business rejections (e.g. ErrRefundDeclined) vs
|
||||
|
||||
@@ -21,6 +21,16 @@ var ErrRefundDeclined = errors.New("square: refund declined")
|
||||
// 'completed' rather than 'failed' (which would let the guard over-refund).
|
||||
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).
|
||||
// Square API reference: https://developer.squareup.com/reference/square/payments-api/create-payment
|
||||
type CreatePaymentReq struct {
|
||||
@@ -59,7 +69,7 @@ type CreateCheckoutReq struct {
|
||||
// RefundPaymentReq maps to Square's RefundPayment endpoint (POST /v2/refunds).
|
||||
type RefundPaymentReq struct {
|
||||
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
|
||||
Reason string
|
||||
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.
|
||||
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
|
||||
// card-on-file payments). Square dedups on the deterministic
|
||||
// idempotency key (derived from email).
|
||||
|
||||
Reference in New Issue
Block a user