Files
Crussell/backend/handlers/payments/discounts.go
T
popertots 4d5d2cd381 fix: restart-loop-A findings — pending sweep refunds, tip carve on discounts, TOCTOU redemption, single-use 2FA code + mint endpoint, refresh-token family revocation, admin 2FA code UX
Restart of Loop A (fresh review -> fix -> verify) findings from commit 5e967fa:
- B1: sweep auto-refund treats Square PENDING refunds as NON-terminal (row stays pending, no gift-card clawback, refunds row inserted for payments AND till_sales, re-polls the deterministic sweepdup- key); Square-less pre-pass exempts square_refund_id IS NOT NULL rows
- M4: terminal tip carve accounts for pending campaign discounts (headroom = total - pending - paid) so explicit tips aren't absorbed as service revenue; no-tip case stays a single record
- max_redemptions TOCTOU closed with atomic conditional UPDATE ... RETURNING; exhausted-at-apply surfaces campaign_fully_redeemed
- 2FA: verification code is single-use on the saved-card gate (VerifyForUser consume=true, interactive flows unaffected); new POST /api/user/2fa/code mints a fresh code for enabled users (RequireAuth + RequireNonGuest + mint cooldown + per-user limiter)
- Refresh tokens: family_id + used_at columns; reuse of an already-rotated token revokes the ENTIRE family and inserts a refresh_token_reuse admin alert; rotation mints descendants in the same family
- Frontend: 2FA code input + Request-a-new-code on all saved-card surfaces; admin modal keys code input to customer 2FA + 403 self-heal; tip-display note for pending discounts; 76 frontend tests
- Verified: all 26 backend packages pass, frontend build+tests green, env-docs 41/41
2026-08-22 00:34:50 +01:00

362 lines
15 KiB
Go

package payments
import (
"context"
"errors"
"log"
"math"
"time"
"crussell/db"
"github.com/jackc/pgx/v5"
)
// EligibleDiscount describes a single discount that is currently eligible for a
// booking, computed identically for the discount preview and the
// apply-at-payment path so the preview shows exactly what payment will apply.
// Amount is the discounted value in pounds.
type EligibleDiscount struct {
Source string // "campaign" or "referral"
Name string
Percent float64
Amount float64
SourceID string // discount_campaigns.id or referral_discounts.id
CampaignType string // "time_based", "milestone", or "" for referral
MilestoneType *string // "per_user_booking_count", "anniversary", "global_booking_count", or nil
IsReferral bool
}
// ComputeEligibleDiscounts returns every campaign/referral discount currently
// eligible for the booking, using the same queries the apply-at-payment path
// runs (including the global in-person milestone discount that was previously
// only computed at payment time). It is read-only: it never writes
// booking_discounts, payments, or campaign counters. Callers pass the querier
// that matches their context — db.Conn for the preview, the payment
// transaction for the apply path.
//
// Existing booking_discounts for the booking are collected in ONE query up
// front and checked in-memory, replacing the previous per-campaign
// "SELECT 1 FROM booking_discounts WHERE booking_id=$1 AND source_id=$2" that
// produced an N+1 inside the anniversary loop.
func ComputeEligibleDiscounts(ctx context.Context, q db.Querier, bookingID, userID string, bookingTotal float64) []EligibleDiscount {
// The apply path refuses to apply NEW discounts once a booking has 2+
// completed real payments (the customer has already paid) — mirror that
// here so the preview does not promise a discount apply will refuse.
var existingPayment int
if err := q.QueryRow(ctx, `
SELECT COUNT(*) FROM payments
WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house')
`, bookingID).Scan(&existingPayment); err != nil {
log.Printf("Failed to scan existing payment count: %v", err)
}
if existingPayment >= 2 {
return nil
}
if bookingTotal <= 0 {
return nil
}
// Existing booking_discounts for THIS booking, keyed by source so a
// campaign id can never collide with a referral id. Single query replaces
// the N+1 per-campaign existence checks (both files).
existingSources := map[string]bool{}
{
rows, err := q.Query(ctx, `
SELECT COALESCE(discount_source, ''), COALESCE(source_id, '')
FROM booking_discounts WHERE booking_id = $1
`, bookingID)
if err == nil {
for rows.Next() {
var src, sid string
if rows.Scan(&src, &sid) == nil {
existingSources[src+"|"+sid] = true
}
}
rows.Close()
} else {
log.Printf("Failed to query existing booking discounts for booking %s: %v", bookingID, err)
}
}
var discounts []EligibleDiscount
// Time-based campaign: the highest-percent active time_based campaign.
var campaignID, campaignName string
var campaignPercent float64
if err := q.QueryRow(ctx, `
SELECT id, discount_percent, name FROM discount_campaigns
WHERE status = 'active' AND campaign_type = 'time_based'
AND start_date <= NOW() AND end_date >= NOW()
AND (max_redemptions IS NULL OR times_redeemed < max_redemptions)
ORDER BY discount_percent DESC LIMIT 1
`).Scan(&campaignID, &campaignPercent, &campaignName); err == nil && campaignID != "" {
if !existingSources["campaign|"+campaignID] {
discounts = append(discounts, EligibleDiscount{
Source: "campaign",
Name: campaignName,
Percent: campaignPercent,
Amount: roundTo2(bookingTotal * campaignPercent / 100),
SourceID: campaignID,
CampaignType: "time_based",
})
}
}
// Per-user booking-count milestone: the campaign matching the user's
// completed-booking count that has not yet been used for this user.
var userBookingCount int
if err := q.QueryRow(ctx, `
SELECT COUNT(*) FROM bookings WHERE user_id = $1 AND status = 'completed'
`, userID).Scan(&userBookingCount); err != nil {
log.Printf("Failed to scan user completed booking count: %v", err)
}
var milestoneCampaignID, milestoneName string
var milestonePercent float64
if err := q.QueryRow(ctx, `
SELECT id, discount_percent, name FROM discount_campaigns
WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'per_user_booking_count'
AND milestone_value = $1
AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE user_id = $2 AND source_id = discount_campaigns.id)
`, userBookingCount, userID).Scan(&milestoneCampaignID, &milestonePercent, &milestoneName); err != nil {
log.Printf("Failed to query milestone campaign for user %s, count %d: %v", userID, userBookingCount, err)
}
if milestoneCampaignID != "" && !existingSources["campaign|"+milestoneCampaignID] {
mt := "per_user_booking_count"
discounts = append(discounts, EligibleDiscount{
Source: "campaign",
Name: milestoneName,
Percent: milestonePercent,
Amount: roundTo2(bookingTotal * milestonePercent / 100),
SourceID: milestoneCampaignID,
CampaignType: "milestone",
MilestoneType: &mt,
})
}
// Anniversary milestone: the first qualifying campaign for the user's
// first visit, matched by elapsed time. Only the FIRST match is applied
// (the apply path historically broke after one anniversary discount).
var firstVisitDate time.Time
if err := q.QueryRow(ctx, `
SELECT MIN(start_time) FROM bookings WHERE user_id = $1 AND status = 'completed'
`, userID).Scan(&firstVisitDate); err != nil {
log.Printf("Failed to scan first visit date: %v", err)
}
if !firstVisitDate.IsZero() {
type annCamp struct {
id string
pct float64
value int
unit string
name string
}
annRows, err := q.Query(ctx, `
SELECT id, discount_percent, milestone_value, milestone_unit, name FROM discount_campaigns
WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'anniversary'
AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE user_id = $1 AND source_id = discount_campaigns.id AND milestone_type = 'anniversary')
`, userID)
if err == nil {
var campaigns []annCamp
for annRows.Next() {
var c annCamp
if annRows.Scan(&c.id, &c.pct, &c.value, &c.unit, &c.name) == nil {
campaigns = append(campaigns, c)
}
}
annRows.Close()
for _, c := range campaigns {
if existingSources["campaign|"+c.id] {
continue
}
var matches bool
elapsed := time.Since(firstVisitDate)
switch c.unit {
case "months":
matches = int(elapsed.Hours()/(30*24)) >= c.value
case "years":
matches = int(elapsed.Hours()/(365.25*24)) >= c.value
}
if matches {
mt := "anniversary"
discounts = append(discounts, EligibleDiscount{
Source: "campaign",
Name: c.name,
Percent: c.pct,
Amount: roundTo2(bookingTotal * c.pct / 100),
SourceID: c.id,
CampaignType: "milestone",
MilestoneType: &mt,
})
break
}
}
} else {
log.Printf("Failed to query anniversary campaigns: %v", err)
}
}
// Global booking-count milestone — only applies when the booking's first
// real payment was taken in person (in_person_card).
var firstPaymentMethod string
if err := q.QueryRow(ctx, `
SELECT payment_method FROM payments WHERE booking_id = $1 AND payment_method NOT IN ('discount', 'on_the_house') ORDER BY created_at ASC LIMIT 1
`, bookingID).Scan(&firstPaymentMethod); err == nil && firstPaymentMethod == "in_person_card" {
var globalCount int
if err := q.QueryRow(ctx, `
SELECT COUNT(*) FROM bookings WHERE status = 'completed'
`).Scan(&globalCount); err != nil {
log.Printf("Failed to scan global completed booking count: %v", err)
}
var globalCampaignID, globalName string
var globalPercent float64
if err := q.QueryRow(ctx, `
SELECT id, discount_percent, name FROM discount_campaigns
WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'global_booking_count'
AND milestone_value <= $1
AND (max_redemptions IS NULL OR times_redeemed < max_redemptions)
AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE source_id = discount_campaigns.id AND booking_id = $2)
ORDER BY milestone_value DESC LIMIT 1
`, globalCount, bookingID).Scan(&globalCampaignID, &globalPercent, &globalName); err != nil {
log.Printf("Failed to query global milestone campaign: %v", err)
}
if globalCampaignID != "" && !existingSources["campaign|"+globalCampaignID] {
mt := "global_booking_count"
discounts = append(discounts, EligibleDiscount{
Source: "campaign",
Name: globalName,
Percent: globalPercent,
Amount: roundTo2(bookingTotal * globalPercent / 100),
SourceID: globalCampaignID,
CampaignType: "milestone",
MilestoneType: &mt,
})
}
}
// Referrer's unused referral discount.
var rdID string
var rdPercent float64
if err := q.QueryRow(ctx, `
SELECT id, discount_percent FROM referral_discounts
WHERE user_id = $1 AND used = FALSE
LIMIT 1
`, userID).Scan(&rdID, &rdPercent); err == nil && rdID != "" {
if !existingSources["referral|"+rdID] {
discounts = append(discounts, EligibleDiscount{
Source: "referral",
Name: "Referral Discount (10%)",
Percent: rdPercent,
Amount: roundTo2(bookingTotal * rdPercent / 100),
SourceID: rdID,
IsReferral: true,
})
}
}
return discounts
}
// ApplyEligibleDiscount persists a single eligible discount for the booking:
// the booking_discounts row, the discount payment record, and the campaign
// redemption counter (or the referral used flag). The caller holds the payment
// transaction so these writes commit atomically with the payment. It is
// idempotent per booking because ComputeEligibleDiscounts excludes discounts
// whose source_id is already recorded for the booking.
//
// For campaign discounts the redemption counter is incremented FIRST, as an
// ATOMIC CONDITIONAL UPDATE guarded by max_redemptions (B13): two concurrent
// payments on different bookings for the same campaign can both pass the
// caller's unlocked "is it exhausted?" read, but only the first conditional
// increment matches — the loser's UPDATE affects zero rows (a 0-row result is
// returned) and this function returns a *campaignExhaustedAtApplyError with
// NOTHING written, so the caller can surface campaign_fully_redeemed. Doing the
// reservation before the booking_discounts/payment inserts keeps the
// transaction clean when a campaign is exhausted at apply time: no discount
// rows are minted for a redemption that never happened.
func ApplyEligibleDiscount(ctx context.Context, q db.Querier, bookingID, userID string, bookingTotal float64, d EligibleDiscount) error {
if d.IsReferral {
if _, err := q.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, 'referral', $3, NULL, NULL, $4, $5, $6)
`, bookingID, userID, d.SourceID, d.Percent, bookingTotal, d.Amount); err != nil {
log.Printf("Failed to insert referral discount: %v", err)
return nil
}
if _, err := q.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
`, bookingID, d.Amount, userID); err != nil {
// The booking_discounts row was already inserted in this tx, so the
// referral discount WAS redeemed — the used flag must still be set
// below. Log ALERT and fall through to the UPDATE instead of
// returning early (a lost used-flag would let the same referral
// discount apply to a future booking).
log.Printf("ALERT: failed to insert discount payment record for referral %s, booking %s: %v", d.SourceID, bookingID, err)
}
if _, err := q.Exec(ctx, `
UPDATE referral_discounts SET used = TRUE, used_at = NOW() WHERE id = $1
`, d.SourceID); err != nil {
log.Printf("ALERT: failed to mark referral discount as used, booking %s: %v", bookingID, err)
}
return nil
}
var milestoneType any
if d.MilestoneType != nil {
milestoneType = *d.MilestoneType
}
// B13: atomic conditional reservation. The UPDATE increments the counter
// ONLY while the campaign still has headroom; PostgreSQL makes this safe
// under READ COMMITTED — a concurrent same-row UPDATE blocks, then
// re-evaluates this WHERE against the post-increment row, so the loser
// matches zero rows instead of over-redeeming past max_redemptions. Zero
// rows means a concurrent redemption on another booking exhausted the
// campaign between the caller's preview computation and this apply-time
// re-check; nothing has been written yet, so the caller surfaces the
// campaign_fully_redeemed path (B13).
var reservedID string
if err := q.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
`, d.SourceID).Scan(&reservedID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &campaignExhaustedAtApplyError{campaignID: d.SourceID, lostPence: int64(math.Round(d.Amount * 100))}
}
log.Printf("ALERT: failed to reserve redemption for campaign %s, booking %s: %v — discount NOT applied", d.SourceID, bookingID, err)
return nil
}
if _, err := q.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, $4, $5, $6, $7, $8)
`, bookingID, userID, d.SourceID, d.CampaignType, milestoneType, d.Percent, bookingTotal, d.Amount); err != nil {
// The reservation (counter increment) already stands in this tx, so the
// redemption was consumed. Log ALERT and skip the payment record — a
// discount payment row without a booking_discounts row would be a
// ledger anomaly. max_redemptions bounds the lost reservation: the next
// eligible booking finds the campaign with one fewer redemption.
log.Printf("ALERT: failed to insert campaign discount for campaign %s, booking %s: %v", d.SourceID, bookingID, err)
return nil
}
if _, err := q.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
`, bookingID, d.Amount, userID); err != nil {
// The booking_discounts row was already inserted in this tx, so the
// campaign WAS redeemed and the reservation already stands. Log ALERT
// and return (a lost record would hide the discount from the ledger).
log.Printf("ALERT: failed to insert discount payment record for campaign %s, booking %s: %v", d.SourceID, bookingID, err)
}
return nil
}