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
+129 -65
View File
@@ -157,24 +157,42 @@ func ApplyBookingCompletionSideEffects(ctx context.Context, tx pgx.Tx, bookingID
if capped, ok := capDiscountToRemainingObligation(ctx, tx, bookingID, discountAmount); ok {
discountAmount = capped
if _, err := tx.Exec(ctx, `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'campaign', $3, 'time_based', NULL, $4, $5, $6)
`, bookingID, userID, campaignID, campaignPercent, bookingTotal, discountAmount); err != nil {
log.Printf("ALERT: failed to insert booking discount: %v", err)
}
// B13 atomic reservation FIRST — mirror the apply-at-payment
// path (discounts.go ApplyEligibleDiscount). The eligibility
// SELECT above is a plain read; a concurrent completion on
// another booking can exhaust the campaign between that read
// and here. The conditional UPDATE only increments while the
// campaign still has headroom (PostgreSQL re-evaluates the
// WHERE against the post-lock row), so exactly one concurrent
// completion wins the redemption. On pgx.ErrNoRows the
// discount is SKIPPED — the completion still succeeds, we just
// log and move on rather than minting a discount row for a
// redemption that never happened.
var reservedID string
if err := tx.QueryRow(ctx, `
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1
WHERE id = $1 AND (max_redemptions IS NULL OR times_redeemed < max_redemptions)
RETURNING id
`, campaignID).Scan(&reservedID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
log.Printf("Skipping time_based campaign %s at completion for booking %s — campaign fully redeemed by a concurrent redemption", campaignID, bookingID)
} else {
log.Printf("ALERT: failed to reserve redemption for campaign %s at completion for booking %s: %v", campaignID, bookingID, err)
}
} else {
if _, err := tx.Exec(ctx, `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'campaign', $3, 'time_based', NULL, $4, $5, $6)
`, bookingID, userID, campaignID, campaignPercent, bookingTotal, discountAmount); err != nil {
log.Printf("ALERT: failed to insert booking discount: %v", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
`, bookingID, discountAmount, userID); err != nil {
log.Printf("ALERT: failed to insert payment record: %v", err)
}
if _, err := tx.Exec(ctx, `
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
`, campaignID); err != nil {
log.Printf("ALERT: failed to update discount campaign usage: %v", err)
if _, err := tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
`, bookingID, discountAmount, userID); err != nil {
log.Printf("ALERT: failed to insert payment record: %v", err)
}
}
} else {
log.Printf("Skipping time_based campaign %s at completion for booking %s — obligation already covered by real money (would over-credit)", campaignID, bookingID)
@@ -204,22 +222,43 @@ func ApplyBookingCompletionSideEffects(ctx context.Context, tx pgx.Tx, bookingID
// F1 over-credit guard — see the time-based block above.
if capped, ok := capDiscountToRemainingObligation(ctx, tx, bookingID, discountAmount); ok {
discountAmount = capped
if _, err := tx.Exec(ctx, `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'campaign', $3, 'milestone', 'per_user_booking_count', $4, $5, $6)
`, bookingID, userID, milestoneCampaignID, milestonePercent, bookingTotal, discountAmount); err != nil {
log.Printf("ALERT: failed to insert booking discount: %v", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
`, bookingID, discountAmount, userID); err != nil {
log.Printf("ALERT: failed to insert payment record: %v", err)
}
if _, err := tx.Exec(ctx, `
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
`, milestoneCampaignID); err != nil {
log.Printf("ALERT: failed to insert payment record: %v", err)
// B13 atomic reservation FIRST — see the time-based block above.
var reservedID string
if err := tx.QueryRow(ctx, `
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1
WHERE id = $1 AND (max_redemptions IS NULL OR times_redeemed < max_redemptions)
RETURNING id
`, milestoneCampaignID).Scan(&reservedID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
log.Printf("Skipping per-user milestone campaign %s at completion for booking %s — campaign fully redeemed by a concurrent redemption", milestoneCampaignID, bookingID)
} else {
log.Printf("ALERT: failed to reserve redemption for campaign %s at completion for booking %s: %v", milestoneCampaignID, bookingID, err)
}
} else {
// Once-per-user backstop: the eligibility NOT EXISTS above
// is a plain read, so two concurrent completions of
// DIFFERENT bookings of this user can both pass it. The
// partial unique index uq_booking_discounts_user_milestone_campaign
// on (user_id, source_id) for milestone campaigns is the
// schema backstop — the second INSERT is suppressed by
// ON CONFLICT DO NOTHING and the discount is skipped.
tag, err := tx.Exec(ctx, `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'campaign', $3, 'milestone', 'per_user_booking_count', $4, $5, $6)
ON CONFLICT (user_id, source_id) WHERE discount_source = 'campaign' AND milestone_type IN ('per_user_booking_count', 'anniversary') DO NOTHING
`, bookingID, userID, milestoneCampaignID, milestonePercent, bookingTotal, discountAmount)
if err != nil {
log.Printf("ALERT: failed to insert booking discount: %v", err)
} else if tag.RowsAffected() == 0 {
log.Printf("Per-user milestone campaign %s already applied for user %s — skipping duplicate at completion for booking %s", milestoneCampaignID, userID, bookingID)
} else {
if _, err := tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
`, bookingID, discountAmount, userID); err != nil {
log.Printf("ALERT: failed to insert payment record: %v", err)
}
}
}
} else {
log.Printf("Skipping per-user milestone campaign %s at completion for booking %s — obligation already covered by real money (would over-credit)", milestoneCampaignID, bookingID)
@@ -260,22 +299,31 @@ func ApplyBookingCompletionSideEffects(ctx context.Context, tx pgx.Tx, bookingID
// F1 over-credit guard — see the time-based block above.
if capped, ok := capDiscountToRemainingObligation(ctx, tx, bookingID, discountAmount); ok {
discountAmount = capped
if _, err := tx.Exec(ctx, `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'campaign', $3, 'milestone', 'global_booking_count', $4, $5, $6)
`, bookingID, userID, globalCampaignID, globalPercent, bookingTotal, discountAmount); err != nil {
log.Printf("ALERT: failed to insert booking discount: %v", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
`, bookingID, discountAmount, userID); err != nil {
log.Printf("ALERT: failed to insert payment record: %v", err)
}
if _, err := tx.Exec(ctx, `
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
`, globalCampaignID); err != nil {
log.Printf("ALERT: failed to insert payment record: %v", err)
// B13 atomic reservation FIRST — see the time-based block above.
var reservedID string
if err := tx.QueryRow(ctx, `
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1
WHERE id = $1 AND (max_redemptions IS NULL OR times_redeemed < max_redemptions)
RETURNING id
`, globalCampaignID).Scan(&reservedID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
log.Printf("Skipping global milestone campaign %s at completion for booking %s — campaign fully redeemed by a concurrent redemption", globalCampaignID, bookingID)
} else {
log.Printf("ALERT: failed to reserve redemption for campaign %s at completion for booking %s: %v", globalCampaignID, bookingID, err)
}
} else {
if _, err := tx.Exec(ctx, `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'campaign', $3, 'milestone', 'global_booking_count', $4, $5, $6)
`, bookingID, userID, globalCampaignID, globalPercent, bookingTotal, discountAmount); err != nil {
log.Printf("ALERT: failed to insert booking discount: %v", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
`, bookingID, discountAmount, userID); err != nil {
log.Printf("ALERT: failed to insert payment record: %v", err)
}
}
} else {
log.Printf("Skipping global milestone campaign %s at completion for booking %s — obligation already covered by real money (would over-credit)", globalCampaignID, bookingID)
@@ -331,22 +379,38 @@ func ApplyBookingCompletionSideEffects(ctx context.Context, tx pgx.Tx, bookingID
// F1 over-credit guard — see the time-based block above.
if capped, ok := capDiscountToRemainingObligation(ctx, tx, bookingID, discountAmount); ok {
discountAmount = capped
if _, err := tx.Exec(ctx, `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'campaign', $3, 'milestone', 'anniversary', $4, $5, $6)
`, bookingID, userID, c.id, c.pct, bookingTotal, discountAmount); err != nil {
log.Printf("ALERT: failed to insert booking discount: %v", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
`, bookingID, discountAmount, userID); err != nil {
log.Printf("ALERT: failed to insert payment record: %v", err)
}
if _, err := tx.Exec(ctx, `
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
`, c.id); err != nil {
log.Printf("ALERT: failed to insert payment record: %v", err)
// B13 atomic reservation FIRST — see the time-based block above.
var reservedID string
if err := tx.QueryRow(ctx, `
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1
WHERE id = $1 AND (max_redemptions IS NULL OR times_redeemed < max_redemptions)
RETURNING id
`, c.id).Scan(&reservedID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
log.Printf("Skipping anniversary campaign %s at completion for booking %s — campaign fully redeemed by a concurrent redemption", c.id, bookingID)
} else {
log.Printf("ALERT: failed to reserve redemption for campaign %s at completion for booking %s: %v", c.id, bookingID, err)
}
} else {
// Once-per-user backstop — see the per-user
// milestone block above.
tag, err := tx.Exec(ctx, `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'campaign', $3, 'milestone', 'anniversary', $4, $5, $6)
ON CONFLICT (user_id, source_id) WHERE discount_source = 'campaign' AND milestone_type IN ('per_user_booking_count', 'anniversary') DO NOTHING
`, bookingID, userID, c.id, c.pct, bookingTotal, discountAmount)
if err != nil {
log.Printf("ALERT: failed to insert booking discount: %v", err)
} else if tag.RowsAffected() == 0 {
log.Printf("Anniversary campaign %s already applied for user %s — skipping duplicate at completion for booking %s", c.id, userID, bookingID)
} else {
if _, err := tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
`, bookingID, discountAmount, userID); err != nil {
log.Printf("ALERT: failed to insert payment record: %v", err)
}
}
}
} else {
log.Printf("Skipping anniversary campaign %s at completion for booking %s — obligation already covered by real money (would over-credit)", c.id, bookingID)