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
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent 1d9c87d6d6
commit 1429eddd34
43 changed files with 2211 additions and 1275 deletions
+24 -9
View File
@@ -53,16 +53,31 @@ func ValidateRefundReason(reason string) error {
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 != ""
// 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 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")
if !hasCardRef && !hasToken {
return errors.New("either a saved card reference (card_id/saved_card_id) or new_card_token is required")
}
return nil
}