diff --git a/backend/internal/square/square.go b/backend/internal/square/square.go index 7f8d314..95f5769 100644 --- a/backend/internal/square/square.go +++ b/backend/internal/square/square.go @@ -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) } diff --git a/backend/internal/square/square_dev.go b/backend/internal/square/square_dev.go index 84bb5a3..454c4d1 100644 --- a/backend/internal/square/square_dev.go +++ b/backend/internal/square/square_dev.go @@ -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 (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 == "" { diff --git a/backend/internal/square/square_dev_test.go b/backend/internal/square/square_dev_test.go index 2f79329..cc491cb 100644 --- a/backend/internal/square/square_dev_test.go +++ b/backend/internal/square/square_dev_test.go @@ -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) { diff --git a/backend/internal/square/square_http_client.go b/backend/internal/square/square_http_client.go index e6e6b0d..4109cb1 100644 --- a/backend/internal/square/square_http_client.go +++ b/backend/internal/square/square_http_client.go @@ -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 diff --git a/backend/internal/square/types.go b/backend/internal/square/types.go index ef7232a..8cb6fdb 100644 --- a/backend/internal/square/types.go +++ b/backend/internal/square/types.go @@ -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).