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
+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()