Files
Crussell/backend/handlers/payments/discounts.go
T
popertots 54a5b1024e Implement full Square payment review fixes + frontend polish
Implement every finding from the deep payment review (P0-P2, minors,
nitpicks), then close the post-implementation re-review items, then
align card-form typography and roll out the Square trust badge.

Backend - Square API alignment:
- tip_settings.allow_tipping nested under device_options (was top-level:
  terminal tips were silently lost in prod)
- CreateCardOnFile now accepts customerID and sends card.customer_id;
  saved-card (ccof:) charges forward square_customer_id as CustomerID
- New SquareClient methods GetPayment, CreateCustomer, CancelCheckout
- SCA verification_token accepted + forwarded in all charge paths
- ExpMonth/ExpYear -> *int; URL-path id validation; CancelCheckout
  NOT_FOUND-only no-op (dropped unverified NOOP); exported ErrorCode/
  ErrorDetail helpers; mock rejects raw PANs, RList locks, redacts
  emails, ForceRefundPending hook

Backend - money safety:
- sweepManualPendingSquareRefunds reconciles rows WITH square_refund_id
  instead of stranding them forever
- SweepStalePendingPayments reconciles at Square before failing (tri-state:
  leave pending on transport error, rescue completed, fail definitively)
- GetCheckoutStatus cancellation-recheck; terminal CANCELED resolution;
  SweepStaleTerminalCheckouts covers terminal_checkouts table
- till gift-card clawback on definitive failure incl. retry path +
  INSUFFICIENT_FUNDS/ADDRESS_VERIFICATION_FAILURE/TRANSACTION_LIMIT
- cross-user saved-card collision fixed (UNIQUE(user_id,square_card_id))
- customer provisioning (lazy, save-only); one-off/guest mint no customer
- discount preview/apply unified in discounts.go (global-milestone visible
  in preview, N+1 eliminated, redemption counter preserved on failures)
- webhook event_id dedup; refund loop dedup; stale comment fixes
- test-isolation t.Cleanup on committed sweep tests

Frontend:
- SCA tokenizeWithVerification across all charge flows (amount as
  major-units decimal), 5-min token-expiry re-tokenize, verification_token
  in request bodies
- PaymentModal synchronous double-click + zero/negative-amount guards
- till online-card UI wired to /api/admin/till/sale
- policyPopover generalised; new /privacy-policy route; consent checkbox
  copy + Square privacy link
- Square card iframe styled to app typography (Inter 14px, oklch tokens);
  mock form md:text-sm parity
- 'Secure payment powered by Square' badge on all 8 card-payment flows

Schema/docs: terminal_checkouts + square_customer_id + per-user card
constraint in init-script.sql; README migrations; P14 plan + backlog +
Technical Manual updated.

Includes 39 modified/new test files; full backend suite (25 pkgs),
-race on payments+square, and frontend build are green.
2026-08-22 00:34:49 +01:00

325 lines
13 KiB
Go

package payments
import (
"context"
"log"
"time"
"crussell/db"
)
// 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.
func ApplyEligibleDiscount(ctx context.Context, q db.Querier, bookingID, userID string, bookingTotal float64, d EligibleDiscount) {
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
}
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
}
var milestoneType any
if d.MilestoneType != nil {
milestoneType = *d.MilestoneType
}
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 {
log.Printf("Failed to insert %s campaign discount: %v", d.CampaignType, err)
return
}
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 — the times_redeemed counter must still be
// incremented below. Log ALERT and fall through to the UPDATE instead
// of returning early (a lost increment would let the campaign exceed
// its max_redemptions cap).
log.Printf("ALERT: failed to insert discount payment record for campaign %s, booking %s: %v", d.SourceID, bookingID, err)
}
if _, err := q.Exec(ctx, `
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
`, d.SourceID); err != nil {
log.Printf("ALERT: failed to increment times_redeemed for campaign %s, booking %s: %v", d.SourceID, bookingID, err)
}
}