Files
Crussell/backend/handlers/payments/completion.go
T
popertots 4b28e93710 fix: tip double-count, fully-paid auto-completion, discount-refund hardening
Tip double-count (root cause of £33.75 vs £28.75 display):
- Remove mock's fixed +500p auto-tip when AllowTipping is true (square_dev.go) —
  real Square only enables a terminal prompt, it never adds a tip to the amount
- Set AllowTipping=false in CreateTerminalPayment: the frontend already embeds
  the tip in the amount, so the terminal must not prompt for a second tip
- M4 tip split now derives the tip as charged amount minus remaining booking
  value ('after 100% is tips'), not from Square's TipAmount field
- Success screens divide paymentResult.amount by 100 (pence -> pounds) in both
  PaymentModal and UserPaymentModal

Fully-paid bookings auto-complete:
- Extract ApplyBookingCompletionSideEffects into payments package (shared by
  admin progress endpoint and payment paths; avoids circular import)
- Add bookingIsFullyPaid + completeFullyPaidBooking: when completed non-tip
  payments reach 100% of the booking total, an active booking transitions to
  'completed' so it leaves the admin Current Appointment view
- Wired into CreateBookingPayment (inside tx) and GetCheckoutStatus (terminal,
  after commit); completion side-effects (loyalty, campaigns, deposits_required)
  fire identically to the manual progress endpoint
- Add /admin/bookings/{id}/refund route (AdminRefundBooking)

Discount-refund hardening:
- RefundPayment explicitly rejects discount/on_the_house payments (was relying
  on the incidental NULL-square_payment_id guard)
- Hide the Refund button for discount/on_the_house payments in EditBookingModal
- Cancel-refund estimate in BookingModal also excludes on_the_house
- Cancellation refund loop + GetBookingPaymentInfo + GetBookingRefundableAmountCents
  exclude payment_type='tip' from refundable totals

Tip flow (start-time guard) fixes tests:
- Tip tests updated to use past-dated bookings (tips now require booking started)

Tests:
- m4_tip_refund_redesign_test.go (tip split, refund exclusion, admin refund cap)
- m5_fully_paid_completion_test.go (online + terminal full-payment completion,
  partial stays active, tip excluded, cancelled stays cancelled)
- Full suite passes with -race (25 packages)
2026-08-22 00:34:49 +01:00

417 lines
18 KiB
Go

package payments
import (
"context"
"crussell/db"
"errors"
"log"
"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)
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)
}
}
}
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)
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)
}
}
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)
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)
}
}
}
}
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)
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)
}
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, discounts and on-the-house rows — the same definition as
// GetBookingPaymentInfo.TotalPaid) cover 100% of the booking total.
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 ('discount', '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
}
// 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)
}
}