fix: review-loop A — discount credit on admin payments, campaign over-credit cap, sweep replay window, dedup refund revalidation, duplication/modularisation, GBP pence naming

Round-A fresh review (6 agents) + fix + secondary cross-cutting + verification rounds:
- F1: campaign discounts reduce the charged amount (deposit credit + admin PaymentModal discounted total); capDiscountToRemainingObligation prevents over-credit at completion in all four campaign blocks
- F2: sweep replay rescue distinguishes legitimate same-key retries (21h window) from expired-key new charges; ccof blind-fails leave pending + CRITICAL instead of clawing back
- F3: post-start online overflow carved as a tip record (mirrors terminal split builder)
- A1: single-source Square decline-code classification (till delegates to square.IsDefinitivePaymentError)
- A2/A5: refund attempt-cap literals consolidated; refund-failure counter capped + reset on terminal resolutions + admin notifications
- A3/A9: idempotency helpers adopted across derivations; IsExplicitDevOrMockEnv relocated + all gates unified (incl. health-check)
- A7: 2FA user+IP limiter + TRUST_PROXY_HEADERS startup warning; SNAPSHOT_ENC_KEY startup validation; TWO_FACTOR_PEPPER docs corrected
- A8: snapshot encryption on all 6 write sites + marker-aware reuse paths; MPV->SPV effective voucher type (single VAT point)
- A10/A11/A12/A16: gift-card slot scan advances past failed; amount-aware refund reconciliation; completed-booking refund re-check; PaymentWasRefunded on SquareClient interface
- Dedup refund revalidation on tip/terminal/gift-card paths; sweep acknowledged_at IS NULL parity; refund-notification single source (exported payments.InsertRefundFailedNotifications)
- Duplication/modularisation round: shared frontend helpers (sanitizeDecimalInput, campaignDiscountCents, twoFactorBlocksSavedCards getter, generateUUID), single-source MaxIdempotencyKeyLength, notification-helper consolidation, snapshot-guard comments
- Cross-cutting GBP rename: Cents->Pence across backend + frontend + tests (26 identifiers, 16 files)
- Tests: 11 behavior-change tests updated to new invariants; coverage for fixed functions; frontend vitest 55 tests; docs corrected (test counts, 2FA delivery, pre-launch checklist, resolution status)
- gitleaks: allowlist backend/internal/square test fixtures (mock idempotency keys)

All 25 backend packages pass; frontend 55/55 + build clean; env-docs 41/41.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent 6d82535780
commit faceb9809c
49 changed files with 2006 additions and 1074 deletions
+58 -23
View File
@@ -41,6 +41,17 @@ const (
// Handlers log these errors verbatim, so echoing more than a snippet risks
// leaking PII that Square may have mirrored from the request.
maxErrorBody = 500
// MaxIdempotencyKeyLength is Square's 45-character idempotency-key limit for
// /v2/payments, /v2/cards and /v2/refunds (64 only for
// /v2/terminals/checkouts). The square package is the client to Square, so
// THIS is the SINGLE SOURCE of the cap: square_dev.go's mock rejection and
// the card/customer key builders route through it, and the payments package
// aliases it (handlers/payments/idempotency_helpers.go's
// maxIdempotencyKeyLength = square.MaxIdempotencyKeyLength) rather than
// declaring a second, drifting 45. Update this one constant if Square ever
// changes the limit.
MaxIdempotencyKeyLength = 45
)
// ---------------------------------------------------------------------------
@@ -624,10 +635,14 @@ func replayPaymentByKeyHTTP(ctx context.Context, snapshotJSON []byte) (*PaymentR
// so the snapshot is never reconstructed from partial row data.
//
// TODO (UNVERIFIED ASSUMPTION): this codebase assumes Square retains
// idempotency keys for ~24 hours (the stale-pending sweeps use a 23h/25h age
// guard on that window). Square's public docs no longer state the exact
// retention window — confirm the current value with Square support and update
// the sweep age guards and this comment when confirmed.
// idempotency keys for ~24 hours. The stale-pending sweeps guard on that
// window with three named constants (defined in handlers/payments): the keyed
// reconcile cutoff stalePendingKeyedAge (22h, sweep.go), the pass-2
// blind-fail cutoff stalePendingPaymentAge (24h, sweep.go), and the refund
// age guard stalePendingRefundAge (23h, refunds.go). Square's public docs no
// longer state the exact retention window — confirm the current value with
// Square support and update the sweep age guards and this comment when
// confirmed.
func replayPaymentByKeyHTTPWithClient(ctx context.Context, snapshotJSON []byte, hc *httpClient) (*PaymentResult, error) {
var req CreatePaymentReq
if err := json.Unmarshal(snapshotJSON, &req); err != nil {
@@ -775,17 +790,23 @@ func IsNotFound(err error) bool {
}
// definitivePaymentCodes are Square CreatePayment error codes that mean the
// charge can NEVER succeed as-is. This includes the card decline/expiry codes
// and — critically for SCA — the buyer-verification codes
// (CARD_DECLINED_VERIFICATION_REQUIRED, VERIFICATION_TOKEN_EXPIRED,
// VERIFICATION_TOKEN_INVALID, CVV_VERIFICATION_REQUIRED,
// ADDRESS_VERIFICATION_REQUIRED, MISSING_PIN, MISSING_VERIFICATION_TOKEN):
// those mean the user must re-verify (3DS/SCA) or re-tokenize the card, NOT
// that the same request should be retried. A same-request retry with the same
// source/token can never succeed, so the failure is DEFINITIVE. This map is
// the package-level source of truth; handlers mirror it via
// IsDefinitivePaymentError / square.ErrorCode (the dev mock emits the same
// codes so dev parity holds).
// charge can NEVER succeed as-is. This is the SINGLE authoritative list of
// definitive payment rejections — the exported
// IsDefinitivePaymentError/square.ErrorCode accessors classify through it, the
// dev mock (square_dev.go) emits the same codes so dev parity holds, and the
// payments package (handlers/payments/till.go) is being migrated to delegate
// to square.IsDefinitivePaymentError instead of its own legacy list. Do not
// maintain a second decline-code list anywhere else: add codes HERE.
//
// The list is the COMPLETE union of Square's decline/expiry codes (including
// the specific CARD_DECLINED_* decline reasons) and — critically for SCA —
// the buyer-verification codes (CARD_DECLINED_VERIFICATION_REQUIRED,
// VERIFICATION_TOKEN_EXPIRED, VERIFICATION_TOKEN_INVALID,
// CVV_VERIFICATION_REQUIRED, ADDRESS_VERIFICATION_REQUIRED, MISSING_PIN,
// MISSING_VERIFICATION_TOKEN): those mean the user must re-verify (3DS/SCA)
// or re-tokenize the card, NOT that the same request should be retried. A
// same-request retry with the same source/token can never succeed, so the
// failure is DEFINITIVE.
var definitivePaymentCodes = map[string]bool{
"CARD_DECLINED": true,
"CARD_EXPIRED": true,
@@ -799,6 +820,17 @@ var definitivePaymentCodes = map[string]bool{
"INSUFFICIENT_FUNDS": true,
"ADDRESS_VERIFICATION_FAILURE": true,
"TRANSACTION_LIMIT": true,
// Square's specific CARD_DECLINED_* decline reasons — each is a definitive
// rejection of the charge as-is (the issuer declined for a specific
// reason), so retrying the same request is pointless.
"CARD_DECLINED_CALL_ISSUER": true,
"CARD_DECLINED_AVS_FAILURE": true,
"CARD_DECLINED_CVV_FAILURE": true,
"CARD_DECLINED_INSUFFICIENT_FUNDS": true,
"CARD_DECLINED_INVALID_ACCOUNT": true,
"CARD_DECLINED_INVALID_AMOUNT": true,
"CARD_DECLINED_CARD_EXPIRED": true,
"CARD_DECLINED_PIN_RETRIES_EXCEEDED": true,
// SCA / buyer-verification codes — the buyer must re-verify or the card be
// re-tokenized before the charge can succeed; retrying is pointless.
"CARD_DECLINED_VERIFICATION_REQUIRED": true,
@@ -979,12 +1011,13 @@ func createCardOnFileHTTPWithClient(ctx context.Context, userID, cardToken, cust
// Deterministic idempotency key derived from user + card (not time-based)
// so that retries with the same details don't create duplicate cards.
// SHA-256 hash prevents recovering the card token from the key itself.
// Truncated to ≤45 chars — Square's idempotency-key limit is 45 chars for
// /v2/cards, /v2/payments, and /v2/refunds (64 only for
// /v2/terminals/checkouts).
// The 38-hex tail (2-char margin: 5 prefix chars + 38 hex = 43, under the
// 45-char limit via MaxIdempotencyKeyLength) is byte-identical to the
// historical fixed slice — Square's limit for /v2/cards, /v2/payments, and
// /v2/refunds is 45 chars (64 only for /v2/terminals/checkouts).
ikHash := sha256.Sum256([]byte(userID + "|" + cardToken))
body := sqCreateCardRequest{
IdempotencyKey: "card-" + fmt.Sprintf("%x", ikHash)[:38],
IdempotencyKey: "card-" + fmt.Sprintf("%x", ikHash)[:MaxIdempotencyKeyLength-2-len("card-")],
SourceID: cardToken,
Card: sqCardPayload{
// reference_id is Square's free-form client reference, used to link
@@ -1090,12 +1123,14 @@ func createCustomerHTTP(ctx context.Context, name, email string) (*CustomerResul
func createCustomerHTTPWithClient(ctx context.Context, name, email string, hc *httpClient) (*CustomerResult, error) {
// Deterministic idempotency key derived from the email (not time-based)
// so retries with the same email don't create duplicate customers. SHA-256
// prevents recovering the email from the key. Truncated to ≤45 chars —
// Square's idempotency-key limit is 45 chars for /v2/cards, /v2/payments,
// and /v2/refunds (64 only for /v2/terminals/checkouts).
// prevents recovering the email from the key. The 35-hex tail (1-char
// margin: 9 prefix chars + 35 hex = 44, under the 45-char limit via
// MaxIdempotencyKeyLength) is byte-identical to the historical fixed slice —
// Square's limit for /v2/cards, /v2/payments, and /v2/refunds is 45 chars
// (64 only for /v2/terminals/checkouts).
ikHash := sha256.Sum256([]byte(email))
body := sqCreateCustomerRequest{
IdempotencyKey: "customer-" + fmt.Sprintf("%x", ikHash)[:35],
IdempotencyKey: "customer-" + fmt.Sprintf("%x", ikHash)[:MaxIdempotencyKeyLength-1-len("customer-")],
EmailAddress: email,
GivenName: name,
}