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