Loop A fresh money/security/dup-mod review of the whole payments overhaul. 28 consolidated findings fixed:
MONEY:
- HIGH-1: B12 overflow guard now uses the discounted obligation — a pre-start deposit can never mint an unintended tip; the discount is never truncated to £0 when the customer pays the discounted deposit
- HIGH-2: discounted-deposit pending-reuse retry compares pendingStoredAmountPence vs chargeAmount (the actual Square amount), not req.Amount — no more permanent amount_mismatch 400 on lost-response retries
- MEDIUM-3: sweep rescue now carves overflow as a tip record + runs completion side-effects (was booking overflow as service revenue, skipping completion)
- MEDIUM-4 (shared w/ security): admin_audit_log.admin_id made nullable + anonymize_user/delete_guest_user NULL it + scrub details.card_last4 — 2fa_fallback_charge PII no longer survives account deletion
- MEDIUM-5: till gift-card payment now passes the £5,000/day admin cap (giftcard_limits)
- LOW-6: expired gift-card balance surfaced as expired/zero in GetUserGiftCardBalance
SECURITY:
- 2FA single-use consume made atomic at verify time for all 5 saved-card gates (fresh charges consume; pending-reuse retries don't); deferred consumption removed
- reissueTwoFACodeAfterFailedCharge routed through the fail-closed issuance gate (pepper check, cooldown) + fresh-only semantics (only when a code was actually consumed)
- family-alive cache invalidated on the stale-family cleanup DELETE (no 30s warm window after expiry)
- frontend 503-retry no longer reuses a consumed 2FA code — aligns with backend re-issue
DUP/MOD:
- reissue helper single-sourced (5 call sites), squareRefundStatusToLocal (10 inline switches), writeChargeSnapshot (7 sites, immutability guard on gift-card/till), postChargeRecheck (3+1 sites), scanIdempotencySlot (2), applyVATToChargeRecord (3 patterns), user_saved_cards upsert (2), BuyGiftCard pending INSERT via service
- till completed-dedup now re-validates paymentHasLiveRefund (aligns with booking/tip/gift-card)
- frontend 402 idempotency-key regeneration added to PaymentModal (aligns with other CIT surfaces)
- PAYMENT_METHOD_SAVED_CARD constant standardised ('saved_card' everywhere)
- admin audit coverage added for AdminRefundBooking + gift-card buy/top-up
- audit-helper cross-package dedup (user/twofa.go now calls payments' exported insert)
Verified: 26/26 dev + 24/24 prod packages, both vet tags, frontend tests + build, gitleaks clean.
122 lines
5.8 KiB
Go
122 lines
5.8 KiB
Go
package payments
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
|
|
"crussell/internal/square"
|
|
"crussell/mw"
|
|
)
|
|
|
|
// squareRefundStatusToLocal maps Square's refund status to the local refunds
|
|
// status enum, consolidating the inline PENDING/FAILED/REJECTED resolutions
|
|
// scattered across the refund handlers and sweeps. Square's PaymentRefund
|
|
// states are PENDING, APPROVED, COMPLETED, CANCELED, FAILED and REJECTED
|
|
// (developer.squareup.com/reference/square/objects/PaymentRefund). A
|
|
// synchronous COMPLETED (or any non-PENDING/FAILED/REJECTED status — APPROVED,
|
|
// CANCELED, unknown) resolves to 'completed'; PENDING stays 'pending' (money in
|
|
// flight — the sweep reconciles it later); FAILED/REJECTED is a definitive
|
|
// 'failed'. Mirrors the webhooks package's squareRefundStatusToLocal, but that
|
|
// helper returns a (status, terminal) pair for webhook semantics while this one
|
|
// returns the plain local status for the refund-handler paths.
|
|
func squareRefundStatusToLocal(status string) string {
|
|
switch status {
|
|
case "PENDING":
|
|
return "pending"
|
|
case "FAILED", "REJECTED":
|
|
return "failed"
|
|
default:
|
|
return "completed"
|
|
}
|
|
}
|
|
|
|
// 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, 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
|
|
}
|
|
// 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
|
|
}
|