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
+25 -5
View File
@@ -451,8 +451,24 @@ func verificationTokenPrefixForSource(sourceID string) string {
// frontend's MockCardForm mints for saved-card verification). A raw nonce like
// "cnon:test-card" — whatever customer_id rides along — is NOT a tokenize-result,
// and real Square rejects it as a card-on-file charge source.
//
// During the token-shape transition the frontend mock may still emit a
// verify_mock_<prefix>_<amount>[_ok|_deny] verification token in the
// tokenize-result source slot; that shape is accepted here too so dev remains
// walkable either way (parseVerifyToken resolves its binding).
func isSCATokenizeResultSource(sourceID string) bool {
return strings.HasPrefix(sourceID, "cnon:sca-")
return strings.HasPrefix(sourceID, "cnon:sca-") || strings.HasPrefix(sourceID, "verify_mock_")
}
// isTokenLikeMock is the dev mock's PCI-DSS token predicate: it accepts the
// production token shapes (cnon: nonces / ccof: card ids — isTokenLike) PLUS
// the verify_mock_<prefix>_<amount> verification-token shape the dev frontend
// mints, so dev remains walkable while the frontend's card form transitions to
// minting cnon:sca- tokenize-results. The PRODUCTION client stays strict
// (square_http_client.go isTokenLike — cnon:/ccof: only); this mock-only
// widening never reaches real Square.
func isTokenLikeMock(s string) bool {
return isTokenLike(s) || strings.HasPrefix(s, "verify_mock_")
}
// resolveVerificationToken validates a supplied 3DS/SCA verification token for
@@ -564,8 +580,10 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
body := mockPaymentWireBody(req)
// Match the real Square API: source_id must be a token (cnon:xxx nonce or
// ccof:xxx card ID). Raw PANs are rejected exactly as Square would, so the
// mock behaves identically to production (PCI-DSS parity).
if !isTokenLike(body.SourceID) {
// mock behaves identically to production (PCI-DSS parity). The mock's token
// predicate additionally accepts the verify_mock_* transition shape the dev
// frontend mints (isTokenLikeMock).
if !isTokenLikeMock(body.SourceID) {
return nil, fmt.Errorf("invalid source_id: %s — use a card nonce (cnon:xxx) or card ID (ccof:xxx)", tokenPrefix(body.SourceID))
}
// Square's CreatePayment requires a positive amount_money — a missing or
@@ -1328,8 +1346,10 @@ func (m *MockClient) CreateCardOnFile(ctx context.Context, userID, cardToken, cu
// Match the real Square API: source_id must be a token (cnon:xxx nonce or
// ccof:xxx card ID). Raw PANs are rejected exactly as Square would, so the
// mock behaves identically to production.
if !isTokenLike(cardToken) {
// mock behaves identically to production. The mock's token predicate
// additionally accepts the verify_mock_* transition shape the dev frontend
// mints (isTokenLikeMock).
if !isTokenLikeMock(cardToken) {
return nil, fmt.Errorf("invalid source_id: %s — use a card nonce (cnon:xxx) or card ID (ccof:xxx)", tokenPrefix(cardToken))
}