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 }