Files
Crussell/backend/handlers/payments/errors.go
T
popertots e8abf7b4b3 Refactor payment charge paths into shared helpers; classify Square failures
Extracts resolveChargeSource (new-card vs saved-card vs one-off nonce, with Square customer provisioning) and shared advisory-lock + post-charge recheck helpers into charge_helpers.go. Adds errors.go with chargeFailureStatus: transport/5xx/context and 429/408/425 map to 503 (retryable), structured 4xx declines map to 402, used across all four charge paths. Also fixes the no-client-key refund fallback to append a crypto/rand suffix (distinct same-amount partial refunds no longer collide) and adds refundResumeKey so legacy NULL idempotency_key rows resume with a derived key instead of an empty one.
2026-08-22 00:34:49 +01:00

55 lines
2.3 KiB
Go

package payments
import (
"context"
"errors"
"net/http"
"crussell/internal/square"
)
// chargeFailureStatus classifies a SquareClient.CreatePayment error into the
// HTTP status a payment handler should return:
//
// - 503 (Service Unavailable) for AMBIGUOUS failures: transport/network
// errors, Square 5xx responses, context cancellation/deadline, and the
// retryable 4xx statuses 429 (rate limited), 408 (request timeout), and
// 425 (too early) — the money state at Square is unknown, so the frontend
// should treat it as a retry (the pending record is resumed on a same-key
// retry). Square's own docs treat 429 as "retry later"; mapping it (or a
// timeout/early request) to 402 would mislabel a retryable condition as a
// permanent decline.
// - 402 (Payment Required) for DEFINITIVE declines: a structured Square
// error (squareAPIError) carrying any OTHER 4xx status (400/402/422 etc.)
// means Square positively rejected the charge (card declined/expired,
// AVS/CVV failure) — retrying with the same inputs cannot succeed.
//
// A nil error is never expected (callers only invoke this on the error path);
// it maps to 402 defensively. The dev mock returns plain errors for simulated
// failures, which classify as 503 (ambiguous) — correct for a mock standing in
// for an unreachable Square.
func chargeFailureStatus(err error) int {
if err == nil {
return http.StatusPaymentRequired
}
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return http.StatusServiceUnavailable
}
status := square.ErrorStatusCode(err)
if status == 0 || status >= 500 {
return http.StatusServiceUnavailable
}
// Retryable/ambiguous 4xx carve-outs: 429 (RATE_LIMITED), 408 (request
// timeout), and 425 (too early) are not definitive declines — Square's
// docs tell clients to retry later. Classify them as 503 so the pending
// record stays resumable on a same-key retry instead of being labelled a
// permanent decline. True declines (400/402/422 etc.) fall through to 402.
if status == http.StatusTooManyRequests || status == http.StatusRequestTimeout || status == http.StatusTooEarly {
return http.StatusServiceUnavailable
}
if status >= 400 && status < 500 {
return http.StatusPaymentRequired
}
return http.StatusServiceUnavailable
}