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
@@ -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