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