Files
Crussell/backend/handlers/payments/errors.go
T
popertots 3866cc5963 fix: round-2 loop-A fresh review (503c326 baseline) — B1 replay cap, A6 discount record, 2FA reissue+cooldown, notification flood, lockout saturation, VAT/refund-status consolidation
Round 2 Loop A fresh money/security/dup-mod review. 23 findings fixed:

MONEY:
- CRITICAL: B1 duplicate auto-refund gains an attempt cap (b1_attempts col, cap 3) —
  a rejected auto-refund no longer re-replays the expired key every sweep run
  (which minted a stacking unauthorized charge each time); FAILED-webhook
  demotion respects the cap; never re-replay a key whose B1 refund failed
- HIGH: A6 deposit_covered_by_discount skip path now APPLIES the eligible
  campaign discount rows immediately (capped) instead of skipping with no
  discount recorded — no more promised-discount-not-recorded overcharge
- MEDIUM: 2FA code burned by the SAVE gate is re-issued on failed
  new-card+save_card charges (re-issue guard now covers req.SaveCard)
- LOW: GetBookingPaymentSummary excludes tip rows from paidAmount (remaining
  now matches the authoritative tip-excluded balance)

SECURITY:
- MEDIUM: unacknowledged CRITICAL admin-notification flood capped (global cap
  on critical_payment_log + refresh_token_reuse rows)
- MEDIUM: 2FA reissue no longer bypasses the mint cooldown (Check no longer
  clears LastMintAt on gate-verify; cleared on terminal charge success)
- MEDIUM: twofa.StateFor map-saturation returns a shared permanently-locked
  state instead of a fresh 5-guess budget per request
- MEDIUM: ProgressiveRateLimit rejects 429 past maxProgressiveSleepDelayMs
  instead of sleeping unboundedly; login bcrypt concurrency semaphore added
- LOW: loginInProgress 409->429; webhook key-set/URL-unset startup check;
  email-verification per-user attempt counter

DUP/MOD:
- formatCurrency single source (frontend format.ts, 7 files consolidated);
  SquareRefundStatusToLocal single source (errors.go, all sites); admin
  audit-log helper dedup; SCA retry model unified (proactive on all 6
  surfaces); buyDailyTotal/daily-cap mirror via backend; lock TTL from
  backend; generateUUID at all card-form sites; magic numbers named
  (defaultPostgresHost, epsilon, fee constants); admin CASH + gift-card
  terminal charges now audited; DAV_SKIP_INIT documented in manuals

Verified: 26/26 dev + 24/24 prod (GO_TESTING=1, the CI condition), both vet
tags, frontend tests+build, env-docs 42/42.
2026-08-22 00:34:50 +01:00

150 lines
7.6 KiB
Go

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 2FA
// gate when the SCA-only posture has no fallback for a token-less charge.
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
}