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.
55 lines
2.3 KiB
Go
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
|
|
}
|