fix: review-loop A — discount credit on admin payments, campaign over-credit cap, sweep replay window, dedup refund revalidation, duplication/modularisation, GBP pence naming
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.
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"crussell/db"
|
||||
"errors"
|
||||
"log"
|
||||
"math"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
@@ -126,24 +127,35 @@ func ApplyBookingCompletionSideEffects(ctx context.Context, tx pgx.Tx, bookingID
|
||||
`).Scan(&campaignID, &campaignPercent); err == nil && campaignID != "" {
|
||||
discountAmount := roundTo2(bookingTotal * campaignPercent / 100)
|
||||
|
||||
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)
|
||||
}
|
||||
// F1: never over-credit at completion. The admin "Take Payment"
|
||||
// flow can charge the FULL amount while a campaign is still
|
||||
// eligible — the discount must be capped (or skipped when real
|
||||
// money already covers the total) so paid + discounts never exceed
|
||||
// the booking total.
|
||||
if capped, ok := capDiscountToRemainingObligation(ctx, tx, bookingID, discountAmount); ok {
|
||||
discountAmount = capped
|
||||
|
||||
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, `
|
||||
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, `
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
} else {
|
||||
log.Printf("Skipping time_based campaign %s at completion for booking %s — obligation already covered by real money (would over-credit)", campaignID, bookingID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -167,22 +179,28 @@ func ApplyBookingCompletionSideEffects(ctx context.Context, tx pgx.Tx, bookingID
|
||||
|
||||
if milestoneCampaignID != "" {
|
||||
discountAmount := roundTo2(bookingTotal * milestonePercent / 100)
|
||||
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)
|
||||
// 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)
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,22 +235,28 @@ func ApplyBookingCompletionSideEffects(ctx context.Context, tx pgx.Tx, bookingID
|
||||
|
||||
if globalCampaignID != "" {
|
||||
discountAmount := roundTo2(bookingTotal * globalPercent / 100)
|
||||
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)
|
||||
// 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)
|
||||
}
|
||||
} else {
|
||||
log.Printf("Skipping global milestone campaign %s at completion for booking %s — obligation already covered by real money (would over-credit)", globalCampaignID, bookingID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -282,22 +306,28 @@ func ApplyBookingCompletionSideEffects(ctx context.Context, tx pgx.Tx, bookingID
|
||||
}
|
||||
if matches {
|
||||
discountAmount := roundTo2(bookingTotal * c.pct / 100)
|
||||
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)
|
||||
// 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)
|
||||
}
|
||||
} else {
|
||||
log.Printf("Skipping anniversary campaign %s at completion for booking %s — obligation already covered by real money (would over-credit)", c.id, bookingID)
|
||||
}
|
||||
break // apply longest matching only
|
||||
}
|
||||
@@ -369,6 +399,70 @@ func bookingIsFullyPaid(ctx context.Context, q db.Querier, bookingID string) boo
|
||||
return fullyPaid
|
||||
}
|
||||
|
||||
// discountHeadroomPence returns how much of the booking's total obligation is
|
||||
// still uncovered — the largest a NEW discount row may carry before the ledger
|
||||
// over-credits the customer (F1). Over-credit records real money + discounts
|
||||
// beyond the booking total, minting an orphaned credit the refund system can
|
||||
// never return: the admin "Take Payment" flow (frontend PaymentModal) sends
|
||||
// payment_type='full' with the FULL amount (subtotal minus discounts already
|
||||
// applied client-side), while applyEligibleCampaignsAtPayment auto-applies any
|
||||
// eligible campaign — without this guard the ledger would record £55 against a
|
||||
// £50 total. The correct fix is the frontend sending the discounted amount
|
||||
// (as the customer modal already does); this headroom computation is the
|
||||
// server-side money-safety half that caps/skips the discount instead.
|
||||
//
|
||||
// Headroom is:
|
||||
//
|
||||
// total - (completed real payments + completed discount rows + pending charge)
|
||||
//
|
||||
// where "real" excludes tip / discount / on_the_house rows (the same
|
||||
// classification bookingIsFullyPaid uses). The pending charge is the payment
|
||||
// completing in the caller's transaction, whose amount is not yet a completed
|
||||
// row when applyEligibleCampaignsAtPayment runs — it is read from the pending
|
||||
// row's stored amount (the amount the charge is being recorded at, i.e.
|
||||
// req.Amount, which is what the charge will settle for). A failed read returns
|
||||
// 0 (conservative: skip rather than over-credit).
|
||||
func discountHeadroomPence(ctx context.Context, q db.Querier, bookingID string) int64 {
|
||||
var totalPence, realPaidPence, discountPence, pendingPence int64
|
||||
err := q.QueryRow(ctx, `
|
||||
SELECT
|
||||
COALESCE(ROUND((SELECT total_amount FROM bookings WHERE id = $1) * 100), 0),
|
||||
COALESCE(ROUND((SELECT SUM(amount) FROM payments WHERE booking_id = $1 AND status = 'completed'
|
||||
AND payment_type != 'tip' AND payment_method NOT IN ('discount', 'on_the_house')) * 100), 0),
|
||||
COALESCE(ROUND((SELECT SUM(amount) FROM payments WHERE booking_id = $1 AND status = 'completed'
|
||||
AND payment_method = 'discount') * 100), 0),
|
||||
COALESCE(ROUND((SELECT SUM(amount) FROM payments WHERE booking_id = $1 AND status = 'pending') * 100), 0)
|
||||
`, bookingID).Scan(&totalPence, &realPaidPence, &discountPence, &pendingPence)
|
||||
if err != nil {
|
||||
log.Printf("Failed to compute discount headroom for booking %s: %v", bookingID, err)
|
||||
return 0
|
||||
}
|
||||
headroom := totalPence - realPaidPence - discountPence - pendingPence
|
||||
if headroom < 0 {
|
||||
return 0
|
||||
}
|
||||
return headroom
|
||||
}
|
||||
|
||||
// capDiscountToRemainingObligation caps a discount amount (pounds) so the
|
||||
// booking's ledger never over-credits: real money paid + discounts recorded +
|
||||
// the charge in flight must never exceed the booking total. Returns the capped
|
||||
// amount and whether the discount may still be applied; a false second return
|
||||
// means real money already covers the obligation and the discount must be
|
||||
// skipped entirely (applying it would mint a phantom credit). The capped value
|
||||
// is the headroom in pence, so it can never round up past the obligation.
|
||||
func capDiscountToRemainingObligation(ctx context.Context, q db.Querier, bookingID string, discountAmount float64) (float64, bool) {
|
||||
discountPence := int64(math.Round(discountAmount * 100))
|
||||
headroom := discountHeadroomPence(ctx, q, bookingID)
|
||||
if discountPence <= headroom {
|
||||
return discountAmount, true
|
||||
}
|
||||
if headroom <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return float64(headroom) / 100.0, true
|
||||
}
|
||||
|
||||
// completeActiveBookingFromPayment transitions an active booking to
|
||||
// 'completed' and runs the completion side-effects, all within tx. It is a
|
||||
// no-op if the booking is not in an active (completable) status, so cancelled,
|
||||
|
||||
Reference in New Issue
Block a user