Files
Crussell/backend/handlers/payments/validators.go
T
popertots faceb9809c 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.
2026-08-22 00:34:50 +01:00

83 lines
2.3 KiB
Go

package payments
import (
"errors"
"fmt"
)
// Valid payment types
var validPaymentTypes = map[string]bool{
"deposit": true,
"full": true,
"tip": true,
"balance": true,
"partial": true,
}
// ValidateAmount checks that amount is greater than 0 and has valid precision (max 2 decimal places when in pence)
func ValidateAmount(amount int64) error {
if amount <= 0 {
return errors.New("amount must be greater than 0")
}
if amount > 1_000_000 { // £10,000 in pence
return errors.New("amount exceeds maximum (£10,000)")
}
return nil
}
// ValidatePartialAmount checks that the partial amount doesn't exceed the remaining balance
func ValidatePartialAmount(amountPence int64, remainingPence int64) error {
if amountPence > remainingPence {
return fmt.Errorf("partial amount (£%.2f) exceeds remaining balance (£%.2f)",
float64(amountPence)/100, float64(remainingPence)/100)
}
if amountPence <= 0 {
return errors.New("amount must be greater than 0")
}
return nil
}
// ValidatePaymentType checks that payment type is valid
func ValidatePaymentType(pt string) error {
if !validPaymentTypes[pt] {
return errors.New("invalid payment type")
}
return nil
}
// ValidateRefundReason checks that refund reason is not empty
func ValidateRefundReason(reason string) error {
if reason == "" {
return errors.New("refund reason is required")
}
return nil
}
// ValidateCardInfo checks that exactly one of cardID or newCardToken is provided,
// non-nil, and non-empty.
func ValidateCardInfo(cardID, newCardToken *string) error {
hasCardID := cardID != nil && *cardID != ""
hasToken := newCardToken != nil && *newCardToken != ""
if hasCardID && hasToken {
return errors.New("provide either card_id or new_card_token, not both")
}
if !hasCardID && !hasToken {
return errors.New("either card_id or new_card_token is required")
}
return nil
}
// ValidateVerificationToken checks that a Square 3DS/SCA verification token is
// non-empty when present and within a sane length. Square's tokens are short
// opaque strings; the bound guards against absurd/malformed payloads before
// the token is forwarded to Square's API.
func ValidateVerificationToken(token *string) error {
if token == nil || *token == "" {
return nil
}
if len(*token) > 512 {
return errors.New("verification_token is too long")
}
return nil
}