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
362 lines
15 KiB
Go
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
|
|
}
|