Loop B restart (money/security/dup-mod adversarial) fixes: - CRITICAL: CreateTerminalPayment rejects payment_type='tip' (mirrors CreateBookingPayment) — a tip-typed admin charge no longer records the FULL amount as a tip and double-collects (all is-paid computations exclude tip rows) - HIGH: tip refunds can no longer re-open booking capacity — refunded_total subqueries filter payment_type <> 'tip' (service.go) and RefundPayment rejects tip rows - MEDIUM: loyalty-stamp farming closed — stamp award once-per-booking via loyalty_stamp_awarded_at column (init-script.sql) + existing same-day guard - MEDIUM: CreateTipPayment/CreateBookingPayment 2FA gates moved AFTER the idempotency completed-dedup (code consumed only on new money paths; terminal path already correct) — lost-response retries return the completed payment instead of 400 - MEDIUM: replayRescueLowerBoundSkew widened to 5m (DB-clock-skew stranded originals now rescued) - MEDIUM-1: verifyFamilyAlive DB amplification reduced via 30s bounded family-alive cache; admin route group rate-limited - MEDIUM-3: admin saved-card charges now write admin_audit_log (handlers.go helper + till); [2FA] log line decoupled from user identity - LOW-1: logout scoped to the presented token's family (no cross-session kill) - LOW-2: refresh-reuse grace widened for same-IP replays - LOW-4: squareEnvironmentMismatch enforced for empty env - LOW-5: uuid.ts hard-fails on Math.random fallback (crypto.randomUUID) - Cash/giftcard tip-enabled overflow mirrors the card-terminal carve 26/26 backend packages; 72/72 frontend tests + build; env-docs 41/41.
540 lines
24 KiB
Go
540 lines
24 KiB
Go
package payments
|
|
|
|
import (
|
|
"context"
|
|
"crussell/db"
|
|
"errors"
|
|
"log"
|
|
"math"
|
|
"sort"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
// ApplyBookingCompletionSideEffects runs the post-completion business logic:
|
|
// patch tests, loyalty stamps, campaign discounts, deposits_required
|
|
// reduction, and name_history consumption. It MUST be called within the same
|
|
// transaction that set the booking to 'completed'.
|
|
//
|
|
// The helper lives in the payments package (not bookings) because both
|
|
// entry points that complete a booking — the admin progress endpoint
|
|
// (bookings.ProgressBookingHandler) and the payment paths — need it. bookings
|
|
// already imports payments, so it can call this exported function; moving the
|
|
// helper the other way (into bookings) would create a circular import because
|
|
// payments cannot import bookings.
|
|
func ApplyBookingCompletionSideEffects(ctx context.Context, tx pgx.Tx, bookingID, userID string) {
|
|
// Self-contained: if the caller does not already hold the user id,
|
|
// re-query it from the booking row.
|
|
if userID == "" {
|
|
if err := tx.QueryRow(ctx, `SELECT user_id FROM bookings WHERE id = $1`, bookingID).Scan(&userID); err != nil {
|
|
log.Printf("Failed to load user_id for completion side-effects on booking %s: %v", bookingID, err)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Collect patch test IDs first so the rows are consumed before INSERT operations.
|
|
var patchTestIDs []string
|
|
ptRows, err := tx.Query(ctx, `
|
|
SELECT DISTINCT pt.id
|
|
FROM patch_tests pt
|
|
JOIN booking_services bs ON bs.booking_id = $1
|
|
WHERE pt.id IN (
|
|
SELECT pt_inner.id FROM patch_tests pt_inner WHERE bs.service_id = ANY(pt_inner.service_ids)
|
|
)
|
|
`, bookingID)
|
|
if err != nil {
|
|
log.Printf("Failed to fetch patch tests for booking %s: %v", bookingID, err)
|
|
} else {
|
|
for ptRows.Next() {
|
|
var ptID string
|
|
if err := ptRows.Scan(&ptID); err == nil {
|
|
patchTestIDs = append(patchTestIDs, ptID)
|
|
}
|
|
}
|
|
ptRows.Close()
|
|
}
|
|
for _, ptID := range patchTestIDs {
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO user_patch_tests (user_id, patch_test_id, tested_at)
|
|
VALUES ($1, $2, NOW())
|
|
ON CONFLICT (user_id, patch_test_id) DO UPDATE SET tested_at = NOW()
|
|
`, userID, ptID); err != nil {
|
|
log.Printf("Failed to update patch test validity for user %s, patch test %s: %v", userID, ptID, err)
|
|
}
|
|
}
|
|
|
|
var bookingTotal float64
|
|
if err := tx.QueryRow(ctx, `
|
|
SELECT total_amount FROM bookings WHERE id = $1
|
|
`, bookingID).Scan(&bookingTotal); err != nil {
|
|
log.Printf("Failed to calculate booking total for %s: %v", bookingID, err)
|
|
}
|
|
|
|
// Don't award a stamp if this booking already used a loyalty redemption
|
|
// (take or receive, never both).
|
|
var loyaltyAppliedOnThisBooking bool
|
|
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty')`, bookingID).Scan(&loyaltyAppliedOnThisBooking); err != nil {
|
|
log.Printf("Failed to check loyalty applied on booking %s: %v", bookingID, err)
|
|
}
|
|
|
|
var newStampCount int
|
|
if bookingTotal > 0 && !loyaltyAppliedOnThisBooking {
|
|
// Loop B MEDIUM (stamp farming via refund + re-charge): the stamp must
|
|
// be awarded at most ONCE per booking, no matter how many times the
|
|
// booking is re-completed. A refund never moves the booking out of
|
|
// 'in_progress', so a re-payment re-completes it — without this guard
|
|
// each in_progress→completed transition would re-award a stamp with no
|
|
// net merchant cash flow. The bookings.loyalty_stamp_awarded_at marker
|
|
// blocks a booking that already earned its stamp; the marker is written
|
|
// (same tx) only when the award actually landed, so a daily-cap-blocked
|
|
// completion does not permanently forfeit the booking's stamp. The
|
|
// existing "no OTHER completed booking within a day" cap is kept.
|
|
if err := tx.QueryRow(ctx, `
|
|
UPDATE users
|
|
SET loyalty_stamps = loyalty_stamps + 1
|
|
WHERE id = $1
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM bookings b
|
|
WHERE b.user_id = users.id
|
|
AND b.status = 'completed'
|
|
AND b.updated_at >= CURRENT_DATE - INTERVAL '1 day'
|
|
AND b.id != $2
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM bookings b
|
|
WHERE b.id = $2 AND b.loyalty_stamp_awarded_at IS NOT NULL
|
|
)
|
|
RETURNING loyalty_stamps
|
|
`, userID, bookingID).Scan(&newStampCount); err != nil {
|
|
if !errors.Is(err, pgx.ErrNoRows) {
|
|
log.Printf("Failed to add loyalty stamp for booking %s: %v", bookingID, err)
|
|
}
|
|
}
|
|
}
|
|
if newStampCount > 0 {
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE bookings SET loyalty_stamp_awarded_at = NOW()
|
|
WHERE id = $1 AND loyalty_stamp_awarded_at IS NULL
|
|
`, bookingID); err != nil {
|
|
log.Printf("Failed to mark loyalty stamp awarded for booking %s: %v", bookingID, err)
|
|
}
|
|
}
|
|
|
|
// Create pending redemption when stamps reach LoyaltyStampCost
|
|
if newStampCount == LoyaltyStampCost {
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
|
|
VALUES ($1, $2, 'pending', NOW())
|
|
`, userID, LoyaltyStampCost)
|
|
if err != nil {
|
|
log.Printf("Failed to create loyalty redemption for user %s: %v", userID, err)
|
|
}
|
|
}
|
|
|
|
// Skip time-based campaign if already applied at payment time
|
|
var timeBasedApplied bool
|
|
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign' AND campaign_type = 'time_based')`, bookingID).Scan(&timeBasedApplied); err != nil {
|
|
log.Printf("Failed to check time-based campaign applied on booking %s: %v", bookingID, err)
|
|
}
|
|
if bookingTotal > 0 && !timeBasedApplied {
|
|
var campaignID string
|
|
var campaignPercent float64
|
|
if err := tx.QueryRow(ctx, `
|
|
SELECT id, discount_percent 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); err == nil && campaignID != "" {
|
|
discountAmount := roundTo2(bookingTotal * campaignPercent / 100)
|
|
|
|
// F1: never over-credit at completion. The admin "Take Payment"
|
|
// flow can charge the FULL amount while a campaign is still
|
|
// eligible — the discount must be capped (or skipped when real
|
|
// money already covers the total) so paid + discounts never exceed
|
|
// the booking total.
|
|
if capped, ok := capDiscountToRemainingObligation(ctx, tx, bookingID, discountAmount); ok {
|
|
discountAmount = capped
|
|
|
|
if _, err := tx.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, 'time_based', NULL, $4, $5, $6)
|
|
`, bookingID, userID, campaignID, campaignPercent, bookingTotal, discountAmount); err != nil {
|
|
log.Printf("ALERT: failed to insert booking discount: %v", err)
|
|
}
|
|
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
|
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
|
|
`, bookingID, discountAmount, userID); err != nil {
|
|
log.Printf("ALERT: failed to insert payment record: %v", err)
|
|
}
|
|
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
|
|
`, campaignID); err != nil {
|
|
log.Printf("ALERT: failed to update discount campaign usage: %v", err)
|
|
}
|
|
} else {
|
|
log.Printf("Skipping time_based campaign %s at completion for booking %s — obligation already covered by real money (would over-credit)", campaignID, bookingID)
|
|
}
|
|
}
|
|
}
|
|
|
|
if bookingTotal > 0 {
|
|
var userBookingCount int
|
|
if err := tx.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 string
|
|
var milestonePercent float64
|
|
if err := tx.QueryRow(ctx, `
|
|
SELECT id, discount_percent 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); err != nil {
|
|
log.Printf("Failed to query per-user milestone campaign for booking %s: %v", bookingID, err)
|
|
}
|
|
|
|
if milestoneCampaignID != "" {
|
|
discountAmount := roundTo2(bookingTotal * milestonePercent / 100)
|
|
// F1 over-credit guard — see the time-based block above.
|
|
if capped, ok := capDiscountToRemainingObligation(ctx, tx, bookingID, discountAmount); ok {
|
|
discountAmount = capped
|
|
if _, err := tx.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, 'milestone', 'per_user_booking_count', $4, $5, $6)
|
|
`, bookingID, userID, milestoneCampaignID, milestonePercent, bookingTotal, discountAmount); err != nil {
|
|
log.Printf("ALERT: failed to insert booking discount: %v", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
|
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
|
|
`, bookingID, discountAmount, userID); err != nil {
|
|
log.Printf("ALERT: failed to insert payment record: %v", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
|
|
`, milestoneCampaignID); err != nil {
|
|
log.Printf("ALERT: failed to insert payment record: %v", err)
|
|
}
|
|
} else {
|
|
log.Printf("Skipping per-user milestone campaign %s at completion for booking %s — obligation already covered by real money (would over-credit)", milestoneCampaignID, bookingID)
|
|
}
|
|
}
|
|
|
|
var globalMilestoneApplied bool
|
|
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign' AND campaign_type = 'milestone' AND milestone_type = 'global_booking_count')`, bookingID).Scan(&globalMilestoneApplied); err != nil {
|
|
log.Printf("Failed to check global milestone applied on booking %s: %v", bookingID, err)
|
|
}
|
|
if !globalMilestoneApplied {
|
|
var globalCount int
|
|
if err := tx.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 hasInPersonPayment bool
|
|
if err := tx.QueryRow(ctx, `
|
|
SELECT EXISTS(SELECT 1 FROM payments WHERE booking_id = $1 AND payment_method = 'in_person_card')`, bookingID).Scan(&hasInPersonPayment); err != nil {
|
|
log.Printf("Failed to check in-person payment on booking %s: %v", bookingID, err)
|
|
}
|
|
|
|
if hasInPersonPayment {
|
|
var globalCampaignID string
|
|
var globalPercent float64
|
|
if err := tx.QueryRow(ctx, `
|
|
SELECT id, discount_percent 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)
|
|
ORDER BY milestone_value DESC LIMIT 1
|
|
`, globalCount).Scan(&globalCampaignID, &globalPercent); err != nil {
|
|
log.Printf("Failed to query global milestone campaign for booking %s: %v", bookingID, err)
|
|
}
|
|
|
|
if globalCampaignID != "" {
|
|
discountAmount := roundTo2(bookingTotal * globalPercent / 100)
|
|
// F1 over-credit guard — see the time-based block above.
|
|
if capped, ok := capDiscountToRemainingObligation(ctx, tx, bookingID, discountAmount); ok {
|
|
discountAmount = capped
|
|
if _, err := tx.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, 'milestone', 'global_booking_count', $4, $5, $6)
|
|
`, bookingID, userID, globalCampaignID, globalPercent, bookingTotal, discountAmount); err != nil {
|
|
log.Printf("ALERT: failed to insert booking discount: %v", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
|
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
|
|
`, bookingID, discountAmount, userID); err != nil {
|
|
log.Printf("ALERT: failed to insert payment record: %v", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
|
|
`, globalCampaignID); err != nil {
|
|
log.Printf("ALERT: failed to insert payment record: %v", err)
|
|
}
|
|
} else {
|
|
log.Printf("Skipping global milestone campaign %s at completion for booking %s — obligation already covered by real money (would over-credit)", globalCampaignID, bookingID)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
var firstVisitDate time.Time
|
|
if err := tx.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() {
|
|
annRows, err := tx.Query(ctx, `
|
|
SELECT id, discount_percent, milestone_value, milestone_unit 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 {
|
|
// Collect anniversary campaigns first to avoid interleaving rows with writes.
|
|
type annCampaign struct {
|
|
id string
|
|
pct float64
|
|
value int
|
|
unit string
|
|
}
|
|
var campaigns []annCampaign
|
|
for annRows.Next() {
|
|
var c annCampaign
|
|
if annRows.Scan(&c.id, &c.pct, &c.value, &c.unit) == nil {
|
|
campaigns = append(campaigns, c)
|
|
}
|
|
}
|
|
annRows.Close()
|
|
|
|
// Sort by milestone_value descending so we apply the longest anniversary only
|
|
sort.Slice(campaigns, func(i, j int) bool {
|
|
return campaigns[i].value > campaigns[j].value
|
|
})
|
|
for _, c := range campaigns {
|
|
var matches bool
|
|
elapsed := time.Since(firstVisitDate)
|
|
switch c.unit {
|
|
case "months":
|
|
months := int(elapsed.Hours() / (30 * 24))
|
|
matches = months >= c.value
|
|
case "years":
|
|
years := int(elapsed.Hours() / (365.25 * 24))
|
|
matches = years >= c.value
|
|
}
|
|
if matches {
|
|
discountAmount := roundTo2(bookingTotal * c.pct / 100)
|
|
// F1 over-credit guard — see the time-based block above.
|
|
if capped, ok := capDiscountToRemainingObligation(ctx, tx, bookingID, discountAmount); ok {
|
|
discountAmount = capped
|
|
if _, err := tx.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, 'milestone', 'anniversary', $4, $5, $6)
|
|
`, bookingID, userID, c.id, c.pct, bookingTotal, discountAmount); err != nil {
|
|
log.Printf("ALERT: failed to insert booking discount: %v", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
|
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
|
|
`, bookingID, discountAmount, userID); err != nil {
|
|
log.Printf("ALERT: failed to insert payment record: %v", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
|
|
`, c.id); err != nil {
|
|
log.Printf("ALERT: failed to insert payment record: %v", err)
|
|
}
|
|
} else {
|
|
log.Printf("Skipping anniversary campaign %s at completion for booking %s — obligation already covered by real money (would over-credit)", c.id, bookingID)
|
|
}
|
|
break // apply longest matching only
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
var paymentExists bool
|
|
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM payments WHERE booking_id = $1)`, bookingID).Scan(&paymentExists); err == nil && paymentExists {
|
|
var newDepositsRequired int
|
|
if err := tx.QueryRow(ctx, `
|
|
UPDATE users SET deposits_required = GREATEST(0, deposits_required - 1)
|
|
WHERE id = $1
|
|
RETURNING deposits_required
|
|
`, userID).Scan(&newDepositsRequired); err != nil {
|
|
log.Printf("ALERT: failed to update deposits_required: %v", err)
|
|
} else if newDepositsRequired == 0 {
|
|
// After 3 paid bookings, forget no-shows so the counter resets.
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO forgiven_no_shows (booking_id)
|
|
SELECT id FROM bookings
|
|
WHERE user_id = $1 AND status = 'no_show'
|
|
AND start_time >= NOW() - INTERVAL '6 months'
|
|
AND NOT EXISTS (SELECT 1 FROM forgiven_no_shows WHERE booking_id = bookings.id)
|
|
`, userID); err != nil {
|
|
log.Printf("ALERT: failed to auto-forgive no-shows: %v", err)
|
|
}
|
|
}
|
|
}
|
|
// Consume unconsumed name_history entries — this booking is the "first post-name-change
|
|
// booking" that completes. After this, we no longer show "formerly" on displays.
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE name_history SET booking_id = $1
|
|
WHERE user_id = $2 AND booking_id IS NULL
|
|
`, bookingID, userID); err != nil {
|
|
log.Printf("Failed to consume name_history for user %s: %v", userID, err)
|
|
}
|
|
}
|
|
|
|
// bookingIsFullyPaid reports whether completed payments toward the booking
|
|
// (excluding tips and on-the-house rows, but INCLUDING discount rows) cover
|
|
// 100% of the booking total. A discount row represents real value applied
|
|
// toward the booking: the customer's total obligation is the DISCOUNTED total,
|
|
// so a booking is fully paid when real money + applied discounts == total
|
|
// (e.g. a 10% campaign on a £50 booking completes once £45 + £5 discount is
|
|
// recorded). Tips are excluded (gratuity, not payment toward the booking) as
|
|
// are on-the-house rows (no real value moved). This deliberately differs from
|
|
// GetBookingPaymentInfo.TotalPaid, which excludes discount rows because the
|
|
// deposit/balance SPLIT must run against the full total and real money only.
|
|
func bookingIsFullyPaid(ctx context.Context, q db.Querier, bookingID string) bool {
|
|
var fullyPaid bool
|
|
if err := q.QueryRow(ctx, `
|
|
WITH booking_total AS (
|
|
SELECT total_amount * 100 AS total_pence FROM bookings WHERE id = $1
|
|
),
|
|
paid_total AS (
|
|
SELECT COALESCE(SUM(amount), 0) * 100 AS paid_pence
|
|
FROM payments
|
|
WHERE booking_id = $1 AND status = 'completed'
|
|
AND payment_type != 'tip'
|
|
AND payment_method NOT IN ('on_the_house')
|
|
)
|
|
SELECT pt.paid_pence >= bt.total_pence AND bt.total_pence > 0
|
|
FROM booking_total bt, paid_total pt
|
|
`, bookingID).Scan(&fullyPaid); err != nil {
|
|
log.Printf("Failed to check full-payment threshold for booking %s: %v", bookingID, err)
|
|
}
|
|
return fullyPaid
|
|
}
|
|
|
|
// discountHeadroomPence returns how much of the booking's total obligation is
|
|
// still uncovered — the largest a NEW discount row may carry before the ledger
|
|
// over-credits the customer (F1). Over-credit records real money + discounts
|
|
// beyond the booking total, minting an orphaned credit the refund system can
|
|
// never return: the admin "Take Payment" flow (frontend PaymentModal) sends
|
|
// payment_type='full' with the FULL amount (subtotal minus discounts already
|
|
// applied client-side), while applyEligibleCampaignsAtPayment auto-applies any
|
|
// eligible campaign — without this guard the ledger would record £55 against a
|
|
// £50 total. The correct fix is the frontend sending the discounted amount
|
|
// (as the customer modal already does); this headroom computation is the
|
|
// server-side money-safety half that caps/skips the discount instead.
|
|
//
|
|
// Headroom is:
|
|
//
|
|
// total - (completed real payments + completed discount rows + pending charge)
|
|
//
|
|
// where "real" excludes tip / discount / on_the_house rows (the same
|
|
// classification bookingIsFullyPaid uses). The pending charge is the payment
|
|
// completing in the caller's transaction, whose amount is not yet a completed
|
|
// row when applyEligibleCampaignsAtPayment runs — it is read from the pending
|
|
// row's stored amount (the amount the charge is being recorded at, i.e.
|
|
// req.Amount, which is what the charge will settle for). A failed read returns
|
|
// 0 (conservative: skip rather than over-credit).
|
|
func discountHeadroomPence(ctx context.Context, q db.Querier, bookingID string) int64 {
|
|
var totalPence, realPaidPence, discountPence, pendingPence int64
|
|
err := q.QueryRow(ctx, `
|
|
SELECT
|
|
COALESCE(ROUND((SELECT total_amount FROM bookings WHERE id = $1) * 100), 0),
|
|
COALESCE(ROUND((SELECT SUM(amount) FROM payments WHERE booking_id = $1 AND status = 'completed'
|
|
AND payment_type != 'tip' AND payment_method NOT IN ('discount', 'on_the_house')) * 100), 0),
|
|
COALESCE(ROUND((SELECT SUM(amount) FROM payments WHERE booking_id = $1 AND status = 'completed'
|
|
AND payment_method = 'discount') * 100), 0),
|
|
COALESCE(ROUND((SELECT SUM(amount) FROM payments WHERE booking_id = $1 AND status = 'pending') * 100), 0)
|
|
`, bookingID).Scan(&totalPence, &realPaidPence, &discountPence, &pendingPence)
|
|
if err != nil {
|
|
log.Printf("Failed to compute discount headroom for booking %s: %v", bookingID, err)
|
|
return 0
|
|
}
|
|
headroom := totalPence - realPaidPence - discountPence - pendingPence
|
|
if headroom < 0 {
|
|
return 0
|
|
}
|
|
return headroom
|
|
}
|
|
|
|
// capDiscountToRemainingObligation caps a discount amount (pounds) so the
|
|
// booking's ledger never over-credits: real money paid + discounts recorded +
|
|
// the charge in flight must never exceed the booking total. Returns the capped
|
|
// amount and whether the discount may still be applied; a false second return
|
|
// means real money already covers the obligation and the discount must be
|
|
// skipped entirely (applying it would mint a phantom credit). The capped value
|
|
// is the headroom in pence, so it can never round up past the obligation.
|
|
func capDiscountToRemainingObligation(ctx context.Context, q db.Querier, bookingID string, discountAmount float64) (float64, bool) {
|
|
discountPence := int64(math.Round(discountAmount * 100))
|
|
headroom := discountHeadroomPence(ctx, q, bookingID)
|
|
if discountPence <= headroom {
|
|
return discountAmount, true
|
|
}
|
|
if headroom <= 0 {
|
|
return 0, false
|
|
}
|
|
return float64(headroom) / 100.0, true
|
|
}
|
|
|
|
// completeActiveBookingFromPayment transitions an active booking to
|
|
// 'completed' and runs the completion side-effects, all within tx. It is a
|
|
// no-op if the booking is not in an active (completable) status, so cancelled,
|
|
// no-show and deposit-lapsed bookings are never auto-completed — and once
|
|
// completed it can never re-fire, because the status filter no longer matches.
|
|
func completeActiveBookingFromPayment(ctx context.Context, tx pgx.Tx, bookingID string) {
|
|
var completedID string
|
|
err := tx.QueryRow(ctx, `
|
|
UPDATE bookings SET status = 'completed', updated_at = NOW()
|
|
WHERE id = $1 AND status IN ('pending', 'confirmed', 'in_progress', 'pending_release')
|
|
RETURNING id
|
|
`, bookingID).Scan(&completedID)
|
|
if err != nil {
|
|
if !errors.Is(err, pgx.ErrNoRows) {
|
|
log.Printf("ALERT: failed to complete fully-paid booking %s: %v", bookingID, err)
|
|
}
|
|
return
|
|
}
|
|
|
|
var userID string
|
|
if uErr := tx.QueryRow(ctx, `SELECT user_id FROM bookings WHERE id = $1`, bookingID).Scan(&userID); uErr != nil {
|
|
log.Printf("ALERT: booking %s completed by payment but failed to load user for side-effects: %v", bookingID, uErr)
|
|
return
|
|
}
|
|
ApplyBookingCompletionSideEffects(ctx, tx, bookingID, userID)
|
|
}
|
|
|
|
// completeFullyPaidBooking checks whether the booking is now fully paid and,
|
|
// if so, completes it. It runs in its OWN transaction (check + UPDATE +
|
|
// side-effects are atomic) and is used after a payment path whose recording
|
|
// transaction has already committed — currently the Square Terminal
|
|
// completion in GetCheckoutStatus.
|
|
func completeFullyPaidBooking(ctx context.Context, bookingID string) {
|
|
tx, err := db.Conn.Begin(ctx)
|
|
if err != nil {
|
|
log.Printf("ALERT: failed to begin transaction for fully-paid completion of booking %s: %v", bookingID, err)
|
|
return
|
|
}
|
|
defer func() {
|
|
if rErr := tx.Rollback(ctx); rErr != nil && !errors.Is(rErr, pgx.ErrTxClosed) {
|
|
log.Printf("Failed to rollback fully-paid completion transaction for booking %s: %v", bookingID, rErr)
|
|
}
|
|
}()
|
|
|
|
if bookingIsFullyPaid(ctx, tx, bookingID) {
|
|
completeActiveBookingFromPayment(ctx, tx, bookingID)
|
|
}
|
|
|
|
if cErr := tx.Commit(ctx); cErr != nil {
|
|
log.Printf("ALERT: failed to commit fully-paid completion transaction for booking %s: %v", bookingID, cErr)
|
|
}
|
|
}
|