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.
325 lines
13 KiB
Go
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)
|
|
}
|
|
}
|