package payments import ( "context" "errors" "net/http" "crussell/internal/square" "crussell/mw" ) // roundingEpsilon is the "effectively zero" guard for pound-denominated // payment splits (0.004 = 0.4 pence). Amounts at or below this threshold — // pure float64 rounding residue from dividing pence by 100 — are treated as // zero so a sub-penny slice never becomes a phantom payment row. Single // shared constant so the split builders and the cash/gift-card terminal // branches can never drift on the threshold. const roundingEpsilon = 0.004 // SquareRefundStatusToLocal maps Square's refund status to the local refunds // status enum, returning a (localStatus, terminal) pair. Square's PaymentRefund // states are PENDING, APPROVED, COMPLETED, CANCELED, FAILED and REJECTED // (developer.squareup.com/reference/square/objects/PaymentRefund). COMPLETED // and APPROVED are terminal-completed — APPROVED explicitly, because the // synchronous refund handlers resolve a returned APPROVED to 'completed' and // that behaviour must not be lost. FAILED/REJECTED are terminal-failed (Square // declined the refund and it must be surfaced as a definitive local failure). // Everything else (PENDING — money in flight, the sweep reconciles it later — // CANCELED, or any unknown status) is NON-terminal: the caller leaves the row // untouched rather than guessing. This is the single shared implementation for // both the payments refund handlers and the webhooks package, so the two can // never drift on the same Square status again. func SquareRefundStatusToLocal(status string) (string, bool) { switch status { case "COMPLETED", "APPROVED": return "completed", true case "FAILED", "REJECTED": return "failed", true default: return "", false } } // verificationRequiredCodes are Square CreatePayment error codes that mean the // buyer must complete Strong Customer Authentication (3DS/SCA) before the // charge can succeed: Square is demanding a fresh verification_token from the // cardholder's buyer-verification flow. These are NOT plain declines — the // frontend must surface the SCA challenge (the banking app / banking-app // approval) and retry the charge with the resulting verification token. This // is the SINGLE authoritative list of SCA-challenge codes; keep it in lock-step // with the dev mock's simulated SCA toggle (square_dev.go). var verificationRequiredCodes = map[string]bool{ "CARD_DECLINED_VERIFICATION_REQUIRED": true, "VERIFICATION_TOKEN_EXPIRED": true, "VERIFICATION_TOKEN_INVALID": true, "MISSING_VERIFICATION_TOKEN": true, } // isVerificationRequiredError reports whether a SquareClient.CreatePayment // error is an SCA/verification-required rejection (the charge must be retried // through the buyer-verification flow with a fresh verification_token) rather // than a plain decline. Matches square.ErrorCode against the four SCA codes; // CVV_VERIFICATION_REQUIRED / ADDRESS_VERIFICATION_REQUIRED are deliberately // excluded — those mean re-entering card data, not a 3DS challenge. func isVerificationRequiredError(err error) bool { return verificationRequiredCodes[square.ErrorCode(err)] } // writeVerificationRequiredResponse responds 402 with the structured // verification_required body the frontend keys on to trigger the SCA challenge // flow (mirrors the overflow_tip_confirmation_required / campaign_fully_redeemed // structured-error pattern — mw.RespondJSON, code + human message). The message // tells the buyer to approve the payment in their banking app. Used both by the // charge-failure paths (Square returned an SCA-required code) and by the SCA-only // saved-card gate, which refuses any token-less charge (the homegrown 2FA // fallback was removed — a token-less charge is always refused here, never // authorised by a 2FA code). func writeVerificationRequiredResponse(w http.ResponseWriter) { mw.RespondJSON(w, http.StatusPaymentRequired, map[string]string{ "error": "Your card issuer requires verification. Approve this payment in your banking app.", "code": "verification_required", }) } // 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, the // retryable 4xx statuses 429 (rate limited), 408 (request timeout), and // 425 (too early), and the structured error code IDEMPOTENCY_KEY_REUSED — // 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. // // IDEMPOTENCY_KEY_REUSED (Loop B CRITICAL-ish, finding 1) is AMBIGUOUS, never // a definitive decline: Square retains the key against the ORIGINAL request // body, so the error means a PREVIOUS attempt under this key used a different // body — the original charge may have LANDED at Square. Classifying it 402 // would make the frontend regenerate the idempotency key (the 402 branch // clears the cached key) and issue a NEW charge under a fresh key — a double // charge when the original landed. Classifying it 503 keeps the key: a // same-key retry with the ORIGINAL body makes Square dedup to the original // payment (no new charge), and a retry with a different body keeps getting // IDEMPOTENCY_KEY_REUSED while the pending row stays rescuable by the sweep — // which already treats IDEMPOTENCY_KEY_REUSED as ambiguous (sweep.go:1088, // replayErrorProvesNoCharge in square_http_client.go:681). The check keys on // the structured ErrorCode, not the HTTP status, because Square may surface it // as 400 or 409 depending on the request shape. // // 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 } if square.ErrorCode(err) == "IDEMPOTENCY_KEY_REUSED" { 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 } // Anything else (1xx/2xx/3xx — impossible in practice, but defensive) is // AMBIGUOUS: the money state at Square is unknown, so the failure must be // retryable. The default is deliberately 503, never 402 — a definitive // decline classification on an ambiguous outcome would suppress the // same-key retry that resumes the pending record. return http.StatusServiceUnavailable }