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.
83 lines
2.3 KiB
Go
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
|
|
}
|