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 // B13 atomic reservation FIRST — mirror the apply-at-payment // path (discounts.go ApplyEligibleDiscount). The eligibility // SELECT above is a plain read; a concurrent completion on // another booking can exhaust the campaign between that read // and here. The conditional UPDATE only increments while the // campaign still has headroom (PostgreSQL re-evaluates the // WHERE against the post-lock row), so exactly one concurrent // completion wins the redemption. On pgx.ErrNoRows the // discount is SKIPPED — the completion still succeeds, we just // log and move on rather than minting a discount row for a // redemption that never happened. var reservedID string if err := tx.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 `, campaignID).Scan(&reservedID); err != nil { if errors.Is(err, pgx.ErrNoRows) { log.Printf("Skipping time_based campaign %s at completion for booking %s — campaign fully redeemed by a concurrent redemption", campaignID, bookingID) } else { log.Printf("ALERT: failed to reserve redemption for campaign %s at completion for booking %s: %v", campaignID, bookingID, err) } } else { 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) } } } 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 // B13 atomic reservation FIRST — see the time-based block above. var reservedID string if err := tx.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 `, milestoneCampaignID).Scan(&reservedID); err != nil { if errors.Is(err, pgx.ErrNoRows) { log.Printf("Skipping per-user milestone campaign %s at completion for booking %s — campaign fully redeemed by a concurrent redemption", milestoneCampaignID, bookingID) } else { log.Printf("ALERT: failed to reserve redemption for campaign %s at completion for booking %s: %v", milestoneCampaignID, bookingID, err) } } else { // Once-per-user backstop: the eligibility NOT EXISTS above // is a plain read, so two concurrent completions of // DIFFERENT bookings of this user can both pass it. The // partial unique index uq_booking_discounts_user_milestone_campaign // on (user_id, source_id) for milestone campaigns is the // schema backstop — the second INSERT is suppressed by // ON CONFLICT DO NOTHING and the discount is skipped. tag, 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) ON CONFLICT (user_id, source_id) WHERE discount_source = 'campaign' AND milestone_type IN ('per_user_booking_count', 'anniversary') DO NOTHING `, bookingID, userID, milestoneCampaignID, milestonePercent, bookingTotal, discountAmount) if err != nil { log.Printf("ALERT: failed to insert booking discount: %v", err) } else if tag.RowsAffected() == 0 { log.Printf("Per-user milestone campaign %s already applied for user %s — skipping duplicate at completion for booking %s", milestoneCampaignID, userID, bookingID) } else { 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) } } } } 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 // B13 atomic reservation FIRST — see the time-based block above. var reservedID string if err := tx.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 `, globalCampaignID).Scan(&reservedID); err != nil { if errors.Is(err, pgx.ErrNoRows) { log.Printf("Skipping global milestone campaign %s at completion for booking %s — campaign fully redeemed by a concurrent redemption", globalCampaignID, bookingID) } else { log.Printf("ALERT: failed to reserve redemption for campaign %s at completion for booking %s: %v", globalCampaignID, bookingID, err) } } else { 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) } } } 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 // B13 atomic reservation FIRST — see the time-based block above. var reservedID string if err := tx.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 `, c.id).Scan(&reservedID); err != nil { if errors.Is(err, pgx.ErrNoRows) { log.Printf("Skipping anniversary campaign %s at completion for booking %s — campaign fully redeemed by a concurrent redemption", c.id, bookingID) } else { log.Printf("ALERT: failed to reserve redemption for campaign %s at completion for booking %s: %v", c.id, bookingID, err) } } else { // Once-per-user backstop — see the per-user // milestone block above. tag, 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) ON CONFLICT (user_id, source_id) WHERE discount_source = 'campaign' AND milestone_type IN ('per_user_booking_count', 'anniversary') DO NOTHING `, bookingID, userID, c.id, c.pct, bookingTotal, discountAmount) if err != nil { log.Printf("ALERT: failed to insert booking discount: %v", err) } else if tag.RowsAffected() == 0 { log.Printf("Anniversary campaign %s already applied for user %s — skipping duplicate at completion for booking %s", c.id, userID, bookingID) } else { 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) } } } } 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) } }