Files
Crussell/backend/handlers/payments/completion.go
T
popertots faceb9809c fix: review-loop A — discount credit on admin payments, campaign over-credit cap, sweep replay window, dedup refund revalidation, duplication/modularisation, GBP pence naming
Round-A fresh review (6 agents) + fix + secondary cross-cutting + verification rounds:
- F1: campaign discounts reduce the charged amount (deposit credit + admin PaymentModal discounted total); capDiscountToRemainingObligation prevents over-credit at completion in all four campaign blocks
- F2: sweep replay rescue distinguishes legitimate same-key retries (21h window) from expired-key new charges; ccof blind-fails leave pending + CRITICAL instead of clawing back
- F3: post-start online overflow carved as a tip record (mirrors terminal split builder)
- A1: single-source Square decline-code classification (till delegates to square.IsDefinitivePaymentError)
- A2/A5: refund attempt-cap literals consolidated; refund-failure counter capped + reset on terminal resolutions + admin notifications
- A3/A9: idempotency helpers adopted across derivations; IsExplicitDevOrMockEnv relocated + all gates unified (incl. health-check)
- A7: 2FA user+IP limiter + TRUST_PROXY_HEADERS startup warning; SNAPSHOT_ENC_KEY startup validation; TWO_FACTOR_PEPPER docs corrected
- A8: snapshot encryption on all 6 write sites + marker-aware reuse paths; MPV->SPV effective voucher type (single VAT point)
- A10/A11/A12/A16: gift-card slot scan advances past failed; amount-aware refund reconciliation; completed-booking refund re-check; PaymentWasRefunded on SquareClient interface
- Dedup refund revalidation on tip/terminal/gift-card paths; sweep acknowledged_at IS NULL parity; refund-notification single source (exported payments.InsertRefundFailedNotifications)
- Duplication/modularisation round: shared frontend helpers (sanitizeDecimalInput, campaignDiscountCents, twoFactorBlocksSavedCards getter, generateUUID), single-source MaxIdempotencyKeyLength, notification-helper consolidation, snapshot-guard comments
- Cross-cutting GBP rename: Cents->Pence across backend + frontend + tests (26 identifiers, 16 files)
- Tests: 11 behavior-change tests updated to new invariants; coverage for fixed functions; frontend vitest 55 tests; docs corrected (test counts, 2FA delivery, pre-launch checklist, resolution status)
- gitleaks: allowlist backend/internal/square test fixtures (mock idempotency keys)

All 25 backend packages pass; frontend 55/55 + build clean; env-docs 41/41.
2026-08-22 00:34:50 +01:00

518 lines
23 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 {
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
)
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)
}
}
}
// 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_cents FROM bookings WHERE id = $1
),
paid_total AS (
SELECT COALESCE(SUM(amount), 0) * 100 AS paid_cents
FROM payments
WHERE booking_id = $1 AND status = 'completed'
AND payment_type != 'tip'
AND payment_method NOT IN ('on_the_house')
)
SELECT pt.paid_cents >= bt.total_cents AND bt.total_cents > 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)
}
}