Files
Crussell/backend/handlers/payments/validators.go
T
popertots 1429eddd34 fix: payments hardening — SCA wire contract (saved-card ref + tokenize-result), terminal/till token routing, tip-cap overflow carve, completion campaign atomicity, orphan B1-evidence gate, gift-card gates/locks, admin backstops
- ValidateCardInfo accepts saved-card ref + new_card_token coexistence (matches resolveChargeSource); new_card_token added to terminal/till request structs so SCA tokens are never dropped
- maxOnlineTipPence (£250) enforced on the overflow-tip carve AND buildSplitRecords (both carve paths) — closes the £10k bypass
- completion-path campaign increments made atomic reserve-first (conditional UPDATE ... RETURNING) + schema backstops (chk_times_redeemed, partial unique index on milestone redemptions)
- webhook orphan detection gated on B1 evidence (b1_attempts / sweep-duplicate refund row) so a delayed legit completion is never marked failed
- gift-card: per-user £500/day cap lock held across read-modify-write, expired-card top-up gate, NaN/Inf float bounds, refund_failed ack filter, on_the_house excluded from balance, postChargeRecheck notification
- admin apply-redemption route + admin-or-owner, in-handler isAdminRequest on 4 gift-card handlers, tip lock key aligned
- 2FA fallback machinery removed (insertTwoFAFallbackAudit/reissue/consent), dead fields stripped from charge structs
- tests: prod-tag suite, mock SCA parity, tip-cap overflow, completion races, cards pagination, ValidateCardInfo tables
2026-08-22 00:34:50 +01:00

98 lines
3.4 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 the card source shape of a payment request. Three
// shapes are legal:
//
// - saved-card only: a non-empty saved-card reference (card_id or
// saved_card_id — they are the same user_saved_cards.id, so callers pass
// the effective reference) with no new_card_token — a plain saved-card
// (ccof:) charge.
// - new-card only: a non-empty new_card_token with NO saved-card reference —
// a new-card (cnon:) one-off charge.
// - saved-card reference + new_card_token together: the SCA tokenize-result
// wire contract — the token (card.tokenize(verificationDetails, cardId)
// result) is the one-time charge SOURCE and the saved-card row supplies the
// Square customer. resolveChargeSource implements exactly this coexistence
// (see charge_helpers.go), so the validation must not reject it.
//
// Both absent is invalid ("either a card reference or new_card_token is
// required"). A bare new_card_token remains valid (the new-card path), and a
// card reference with no token remains valid (the legacy saved-card path);
// only the coexistence that used to be rejected — card ref + token — is now
// legal, resolved as the SCA tokenize-result source.
func ValidateCardInfo(cardID, savedCardID, newCardToken *string) error {
hasCardRef := (cardID != nil && *cardID != "") || (savedCardID != nil && *savedCardID != "")
hasToken := newCardToken != nil && *newCardToken != ""
if !hasCardRef && !hasToken {
return errors.New("either a saved card reference (card_id/saved_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
}