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
+6
View File
@@ -55,6 +55,12 @@ func (p *ProdClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*
return refundPaymentHTTP(ctx, req)
}
// PaymentWasRefunded has ZERO production callers (grep across the repo
// confirms the only users are this package's tests) and is kept on the
// SquareClient interface solely so the dev mock's refund-reconciliation
// parity tests can exercise the COMPLETED/APPROVED/PENDING status set.
// Production reconciliation uses the package-level
// paymentRefundedExactlyWithClient inside refundPaymentHTTP instead.
func (p *ProdClient) PaymentWasRefunded(ctx context.Context, paymentID string) (bool, error) {
return paymentWasRefundedWithClient(ctx, paymentID, newHTTPClient())
}
+14 -6
View File
@@ -188,6 +188,9 @@ func (d *devProdClient) CancelCheckout(ctx context.Context, checkoutID string) e
func (d *devProdClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*RefundResult, error) {
return refundPaymentHTTP(ctx, req)
}
// PaymentWasRefunded has ZERO production callers — kept only to satisfy the
// SquareClient interface for the dev mock's refund-reconciliation parity
// tests. Production reconciliation uses paymentRefundedExactlyWithClient.
func (d *devProdClient) PaymentWasRefunded(ctx context.Context, paymentID string) (bool, error) {
return PaymentWasRefunded(ctx, paymentID)
}
@@ -311,11 +314,13 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
}
}
// Square's idempotency-key limit for POST /v2/payments is 45 characters
// (64 only for /v2/terminals/checkouts). Real Square rejects an oversized
// key with a 400 VALUE_TOO_LONG; the mock mirrors the rejection with the
// same structured error so dev parity catches over-length keys (the real
// client always derives ≤45-char keys, so this only fires on a caller bug).
if len(req.IdempotencyKey) > 45 {
// (64 only for /v2/terminals/checkouts) — MaxIdempotencyKeyLength
// (square_http_client.go), the single source the payments package also
// aliases. Real Square rejects an oversized key with a 400
// VALUE_TOO_LONG; the mock mirrors the rejection with the same structured
// error so dev parity catches over-length keys (the real client always
// derives ≤45-char keys, so this only fires on a caller bug).
if len(req.IdempotencyKey) > MaxIdempotencyKeyLength {
return nil, &squareAPIError{
Code: "VALUE_TOO_LONG",
Detail: "idempotency_key must be 45 characters or fewer",
@@ -872,7 +877,10 @@ func (m *MockClient) RefundKeyCount() int {
// any refund with status COMPLETED, APPROVED, or PENDING exists for the payment
// (FAILED/REJECTED refunds never moved money and are ignored). Shares the exact
// status set the real client's paymentWasRefundedWithClient uses so handler
// reconciliation behaves identically in dev/mock and production.
// reconciliation behaves identically in dev/mock and production. TEST-ONLY on
// the SquareClient interface (no production callers — reconciliation uses the
// package-level paymentRefundedExactlyWithClient); kept so this mock satisfies
// the interface and its refund-status parity tests can exercise the set.
func (m *MockClient) PaymentWasRefunded(ctx context.Context, paymentID string) (bool, error) {
m.mu.RLock()
defer m.mu.RUnlock()
+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,
}
+11 -8
View File
@@ -40,7 +40,7 @@ var ErrReplayKeyNotRetained = errors.New("square: no payment under idempotency k
// CreatePaymentReq maps to Square's CreatePayment endpoint (POST /v2/payments).
// Square API reference: https://developer.squareup.com/reference/square/payments-api/create-payment
type CreatePaymentReq struct {
Amount int64 // in pence (GBP cents)
Amount int64 // in pence
Currency string // "GBP"
SourceID string // card token ("cnon:xxx" nonce) or card-on-file ID
IdempotencyKey string
@@ -217,13 +217,16 @@ type SquareClient interface {
GetCheckout(ctx context.Context, checkoutID string) (*PaymentResult, error)
RefundPayment(ctx context.Context, req RefundPaymentReq) (*RefundResult, error)
// PaymentWasRefunded reports whether Square holds any refund for the
// payment (status COMPLETED, APPROVED, or PENDING). It is the
// reconciliation source for deciding whether a REFUND_AMOUNT_INVALID
// rejection means "already refunded" (money has already moved) vs "amount
// invalid" (nothing happened). On the interface (not just the package
// function) so handlers can reconcile through the injected client — a
// package-level call constructs a real HTTP client even in dev/mock
// builds, making the dev path dead code and untestable.
// payment (status COMPLETED, APPROVED, or PENDING). TEST-ONLY: it has ZERO
// production callers (grep across the repo confirms the only users are
// this package's tests); the production reconciliation that decides
// whether a REFUND_AMOUNT_INVALID rejection means "already refunded"
// (money has already moved) vs "amount invalid" (nothing happened) uses
// the package-level paymentRefundedExactlyWithClient inside
// refundPaymentHTTP (square_http_client.go), not this interface method. It
// is kept on the interface solely so the dev mock's refund-reconciliation
// parity tests can exercise the COMPLETED/APPROVED/PENDING status set. Do
// not add production callers without re-examining the interface surface.
PaymentWasRefunded(ctx context.Context, paymentID string) (bool, error)
CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*CardOnFile, error)
// GetCardsOnFile returns the enabled cards on file for a user. TEST-ONLY: