From 4b28e9371081f39f34cdd66d7e103bd7027f4a71 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Wed, 12 Aug 2026 10:31:59 +0100 Subject: [PATCH] fix: tip double-count, fully-paid auto-completion, discount-refund hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/handlers/bookings/bookings.go | 311 +-------- backend/handlers/payments/completion.go | 416 ++++++++++++ backend/handlers/payments/concurrency_test.go | 4 +- backend/handlers/payments/giftcards.go | 43 +- backend/handlers/payments/handlers.go | 624 +++++++++++++++++- .../payments/m4_tip_refund_redesign_test.go | 448 +++++++++++++ .../payments/m5_fully_paid_completion_test.go | 233 +++++++ .../handlers/payments/payments_fixes_test.go | 4 +- .../payments/payments_r6_review_test.go | 7 +- backend/handlers/payments/payments_test.go | 18 +- backend/handlers/payments/refunds.go | 1 + backend/handlers/payments/service.go | 28 +- backend/handlers/user/gdpr_test.go | 11 +- backend/internal/square/square_dev.go | 9 +- backend/internal/square/square_dev_test.go | 4 +- backend/main.go | 1 + .../lib/components/admin/BookingModal.svelte | 7 +- .../components/admin/EditBookingModal.svelte | 2 +- .../components/payments/PaymentModal.svelte | 22 +- .../payments/UserPaymentModal.svelte | 2 +- .../routes/cancellation-policy/+page.svelte | 57 +- frontend/src/routes/gdpr/+page.svelte | 47 ++ init-scripts/init-script.sql | 20 +- 23 files changed, 1953 insertions(+), 366 deletions(-) create mode 100644 backend/handlers/payments/completion.go create mode 100644 backend/handlers/payments/m4_tip_refund_redesign_test.go create mode 100644 backend/handlers/payments/m5_fully_paid_completion_test.go diff --git a/backend/handlers/bookings/bookings.go b/backend/handlers/bookings/bookings.go index 902084f..f35a09c 100644 --- a/backend/handlers/bookings/bookings.go +++ b/backend/handlers/bookings/bookings.go @@ -18,7 +18,6 @@ import ( "log/slog" "math" "net/http" - "sort" "strconv" "strings" @@ -2670,310 +2669,12 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) { if currentStatus == "completed" { log.Printf("Booking %s is already completed — skipping duplicate completion", bookingID) } else { - // Collect patch test IDs first so the rows are consumed before INSERT operations. - var patchTestIDs []string - ptRows, err := tx.Query(r.Context(), ` - 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(r.Context(), ` - 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() - `, booking.User.ID, ptID); err != nil { - log.Printf("Failed to update patch test validity for user %s, patch test %s: %v", booking.User.ID, ptID, err) - } - } - - var bookingTotal float64 - if err := tx.QueryRow(r.Context(), ` - 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(r.Context(), `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(r.Context(), ` - 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 - `, booking.User.ID, 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 == payments.LoyaltyStampCost { - _, err = tx.Exec(r.Context(), ` - INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) - VALUES ($1, $2, 'pending', NOW()) - `, booking.User.ID, payments.LoyaltyStampCost) - if err != nil { - log.Printf("Failed to create loyalty redemption for user %s: %v", booking.User.ID, err) - } - } - - // Skip time-based campaign if already applied at payment time - var timeBasedApplied bool - if err := tx.QueryRow(r.Context(), `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(r.Context(), ` - 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(r.Context(), ` - 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, booking.User.ID, campaignID, campaignPercent, bookingTotal, discountAmount); err != nil { - log.Printf("ALERT: failed to insert booking discount: %v", err) - } - - if _, err := tx.Exec(r.Context(), ` - INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) - VALUES ($1, 'partial', 'discount', $2, 'completed', $3) - `, bookingID, discountAmount, booking.User.ID); err != nil { - log.Printf("ALERT: failed to insert payment record: %v", err) - } - - if _, err := tx.Exec(r.Context(), ` - 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(r.Context(), `SELECT COUNT(*) FROM bookings WHERE user_id = $1 AND status = 'completed'`, booking.User.ID).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(r.Context(), ` - 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, booking.User.ID).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(r.Context(), ` - 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, booking.User.ID, milestoneCampaignID, milestonePercent, bookingTotal, discountAmount); err != nil { - log.Printf("ALERT: failed to insert booking discount: %v", err) - } - if _, err := tx.Exec(r.Context(), ` - INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) - VALUES ($1, 'partial', 'discount', $2, 'completed', $3) - `, bookingID, discountAmount, booking.User.ID); err != nil { - log.Printf("ALERT: failed to insert payment record: %v", err) - } - if _, err := tx.Exec(r.Context(), ` - 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(r.Context(), `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(r.Context(), `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(r.Context(), ` - 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(r.Context(), ` - 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(r.Context(), ` - 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, booking.User.ID, globalCampaignID, globalPercent, bookingTotal, discountAmount); err != nil { - log.Printf("ALERT: failed to insert booking discount: %v", err) - } - if _, err := tx.Exec(r.Context(), ` - INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) - VALUES ($1, 'partial', 'discount', $2, 'completed', $3) - `, bookingID, discountAmount, booking.User.ID); err != nil { - log.Printf("ALERT: failed to insert payment record: %v", err) - } - if _, err := tx.Exec(r.Context(), ` - 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(r.Context(), `SELECT MIN(start_time) FROM bookings WHERE user_id = $1 AND status = 'completed'`, booking.User.ID).Scan(&firstVisitDate); err != nil { - log.Printf("Failed to scan first visit date: %v", err) - } - if !firstVisitDate.IsZero() { - annRows, err := tx.Query(r.Context(), ` - 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') - `, booking.User.ID) - 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(r.Context(), ` - 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, booking.User.ID, c.id, c.pct, bookingTotal, discountAmount); err != nil { - log.Printf("ALERT: failed to insert booking discount: %v", err) - } - if _, err := tx.Exec(r.Context(), ` - INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) - VALUES ($1, 'partial', 'discount', $2, 'completed', $3) - `, bookingID, discountAmount, booking.User.ID); err != nil { - log.Printf("ALERT: failed to insert payment record: %v", err) - } - if _, err := tx.Exec(r.Context(), ` - 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(r.Context(), `SELECT EXISTS(SELECT 1 FROM payments WHERE booking_id = $1)`, bookingID).Scan(&paymentExists); err == nil && paymentExists { - var newDepositsRequired int - if err := tx.QueryRow(r.Context(), ` - UPDATE users SET deposits_required = GREATEST(0, deposits_required - 1) - WHERE id = $1 - RETURNING deposits_required - `, booking.User.ID).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(r.Context(), ` - 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) - `, booking.User.ID); 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(r.Context(), ` - UPDATE name_history SET booking_id = $1 - WHERE user_id = $2 AND booking_id IS NULL - `, bookingID, booking.User.ID); err != nil { - log.Printf("Failed to consume name_history for user %s: %v", booking.User.ID, err) - } + // Completion side-effects (patch tests, loyalty stamps, campaign + // discounts, deposits_required reduction, name_history consumption) + // live in the payments package so the admin progress endpoint and + // the payment paths share one implementation. Must stay inside this + // transaction with the status UPDATE. + payments.ApplyBookingCompletionSideEffects(r.Context(), tx, bookingID, booking.User.ID) } // close the else from alreadyCompleted check } diff --git a/backend/handlers/payments/completion.go b/backend/handlers/payments/completion.go new file mode 100644 index 0000000..c93eb60 --- /dev/null +++ b/backend/handlers/payments/completion.go @@ -0,0 +1,416 @@ +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) + } +} diff --git a/backend/handlers/payments/concurrency_test.go b/backend/handlers/payments/concurrency_test.go index 0f7f59d..0adea0b 100644 --- a/backend/handlers/payments/concurrency_test.go +++ b/backend/handlers/payments/concurrency_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + "crussell/clock" "crussell/db" "crussell/internal/square" "crussell/mw" @@ -184,7 +185,8 @@ func TestTipPayment_ConcurrentSameKey_SingleRecord(t *testing.T) { if err != nil { t.Fatalf("failed to create service: %v", err) } - start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) + // Past start so the tip is accepted (tips require the booking to have started). + start := clock.Now().Add(-1 * time.Hour) bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, start) if err != nil { t.Fatalf("failed to create booking: %v", err) diff --git a/backend/handlers/payments/giftcards.go b/backend/handlers/payments/giftcards.go index fec1f45..c2b31b4 100644 --- a/backend/handlers/payments/giftcards.go +++ b/backend/handlers/payments/giftcards.go @@ -2,6 +2,7 @@ package payments import ( "context" + "crypto/sha256" "database/sql" "encoding/json" "errors" @@ -106,11 +107,11 @@ type BuyGiftCardRequest struct { CardID *string `json:"card_id,omitempty"` NewCardToken *string `json:"new_card_token,omitempty"` SaveCard bool `json:"save_card"` - // IdempotencyKey is required (R2): an empty key would be stored as '' on - // the pending payments row, and a second empty-key purchase would 500 on - // the UNIQUE(payments.idempotency_key) constraint. The frontend always - // sends a per-purchase UUID; max=45 matches Square's /v2/payments limit. - IdempotencyKey string `json:"idempotency_key" validate:"required,max=45"` + // IdempotencyKey is optional (M1): if not provided, a deterministic key is + // generated server-side based on user_id + amount + recipient_type + card_id. + // This ensures retries of the same logical purchase use the same key while + // distinct purchases get different keys. Max=45 matches Square's limit. + IdempotencyKey string `json:"idempotency_key,omitempty" validate:"omitempty,max=45"` VerificationToken *string `json:"verification_token,omitempty"` } @@ -971,19 +972,33 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) { return } - // R2: idempotency_key is required (validate:"required,max=45"). An empty - // key would be stored as '' on the pending payments row and a second - // empty-key purchase would 500 on the UNIQUE(payments.idempotency_key) - // constraint. The frontend always sends a per-purchase UUID. The fallback - // below is defense-in-depth only — the validator rejects the empty key - // first, but if it is ever relaxed the fallback keeps the UNIQUE - // constraint from firing. + // M1: idempotency_key is optional. If not provided, a deterministic key is + // generated server-side based on user_id + amount + recipient_type + card_id. + // This ensures retries of the same logical purchase use the same key while + // distinct purchases get different keys. Max=45 matches Square's limit. if err := validators.Validate.Struct(&req); err != nil { log.Printf("Failed to process request: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) return } + // M1: generate a deterministic idempotency key server-side if the client + // doesn't provide one. The key is based on user_id + amount + recipient_type + // + card_id (or "new" for new cards), ensuring retries of the same logical + // purchase use the same key while distinct purchases get different keys. + if req.IdempotencyKey == "" { + cardPart := "new" + if req.CardID != nil && *req.CardID != "" { + cardPart = *req.CardID + } + req.IdempotencyKey = fmt.Sprintf("gc-%s-%d-%s-%s", userID, req.Amount, req.RecipientType, cardPart) + if len(req.IdempotencyKey) > 45 { + // Hash long keys to fit Square's 45-char limit + hash := sha256.Sum256([]byte(req.IdempotencyKey)) + req.IdempotencyKey = fmt.Sprintf("gc-%x", hash[:16]) + } + } + // Product rule (security): only verified accounts may save cards. An // unverified/guest/affiliate user may still buy a gift card, but // save_card=true is rejected here — before any charge source resolution. @@ -991,10 +1006,6 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) { return } - if req.IdempotencyKey == "" { - req.IdempotencyKey = uniqueChargeKey("gc-") - } - allowedAmounts := map[int64]bool{1000: true, 2000: true, 5000: true} if !allowedAmounts[req.Amount] { http.Error(w, "Invalid amount. Must be £10, £20, or £50.", http.StatusBadRequest) diff --git a/backend/handlers/payments/handlers.go b/backend/handlers/payments/handlers.go index 054a04a..18e3545 100644 --- a/backend/handlers/payments/handlers.go +++ b/backend/handlers/payments/handlers.go @@ -53,7 +53,7 @@ type CreateBookingPaymentRequest struct { CardID *string `json:"card_id,omitempty"` NewCardToken *string `json:"new_card_token,omitempty"` SaveCard bool `json:"save_card"` - IdempotencyKey string `json:"idempotency_key" validate:"required,max=45"` + IdempotencyKey string `json:"idempotency_key,omitempty" validate:"omitempty,max=45"` VerificationToken *string `json:"verification_token,omitempty"` } @@ -839,7 +839,10 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) { Currency: "GBP", IdempotencyKey: idempotencyKey, ReferenceID: bookingID, - AllowTipping: req.TipEnabled, + // The tip (if any) is already embedded in `amount` by the frontend + // (totalWithTip), so the terminal must NOT prompt for a second tip — + // setting AllowTipping here would double-count the tip in production. + AllowTipping: false, } checkout, err := SquareClient.CreateCheckout(r.Context(), checkoutReq) @@ -1174,7 +1177,34 @@ func GetCheckoutStatus(w http.ResponseWriter, r *http.Request) { UpdatedAt: clock.Now(), } - paymentID, err := service.CreatePaymentRecordTx(r.Context(), tx, record, nil) + // M4: a terminal charge above the remaining booking value is a tip + // (e.g. £100 booking + £10 tip = one £110 Square charge). Split it into + // deposit + balance + tip records so only the booking portion is + // refundable while the tip is recorded for accounting (total_tips + // aggregation) and excluded from cancellation refunds. Without a tip + // the charge stays a single record. The tip is derived from the amount + // exceeding the remaining booking value ("after 100% is tips") — NOT + // from Square's TipAmount field, because the frontend already embeds + // any tip in the amount and AllowTipping is disabled (see + // CreateTerminalPayment), so Square reports TipAmount 0. + var records []PaymentRecord + bookingInfo, bErr := service.GetBookingPaymentInfo(r.Context(), bookingID) + if bErr == nil && bookingInfo != nil { + charged := float64(paymentResult.Amount) / 100.0 + remainingBookingValue := math.Max(0, bookingInfo.TotalAmount-bookingInfo.TotalPaid) + bookingPortion := math.Min(charged, remainingBookingValue) + bookingPortion = math.Round(bookingPortion*100) / 100 + tipAmount := math.Round((charged-bookingPortion)*100) / 100 + if tipAmount > 0.004 { + records = buildTerminalSplitRecords(record, bookingInfo, bookingPortion, tipAmount) + } + } + if len(records) == 0 { + records = []PaymentRecord{record} + } + + primary := records[0] + paymentID, err := service.CreatePaymentRecordTx(r.Context(), tx, primary, nil) if err != nil { log.Printf("Failed to create payment record: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) @@ -1182,6 +1212,20 @@ func GetCheckoutStatus(w http.ResponseWriter, r *http.Request) { } ApplyVATToBookingPayment(r.Context(), tx, paymentID) + var splitIDs []string + for _, rec := range records[1:] { + pid, cErr := service.CreatePaymentRecordTx(r.Context(), tx, rec, nil) + if cErr != nil { + log.Printf("Failed to create terminal tip split record: %v", cErr) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + splitIDs = append(splitIDs, pid) + } + for _, pid := range splitIDs { + ApplyVATToBookingPayment(r.Context(), tx, pid) + } + // Release the in-flight guard: this checkout is done, so a subsequent // charge on the same booking is allowed. if _, err := tx.Exec(r.Context(), ` @@ -1196,6 +1240,13 @@ func GetCheckoutStatus(w http.ResponseWriter, r *http.Request) { return } + // Fully-paid completion: if this terminal charge (or the accumulated + // total) now covers 100% of the booking total, complete the booking so + // it leaves the admin's Current Appointment view. Runs in its own + // transaction because the payment-recording transaction above has + // already committed. + completeFullyPaidBooking(r.Context(), bookingID) + if err := json.NewEncoder(w).Encode(PaymentStatusResponse{ Status: "COMPLETED", PaymentID: paymentID, @@ -1308,6 +1359,23 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) { return } + // M1: generate a deterministic idempotency key server-side if the client + // doesn't provide one. The key is based on booking_id + payment_type + + // amount + card_id (or "new" for new cards), ensuring retries of the same + // logical charge use the same key while distinct charges get different keys. + if req.IdempotencyKey == "" { + cardPart := "new" + if req.CardID != nil && *req.CardID != "" { + cardPart = *req.CardID + } + req.IdempotencyKey = fmt.Sprintf("pay-%s-%s-%d-%s", bookingID, req.PaymentType, req.Amount, cardPart) + if len(req.IdempotencyKey) > 45 { + // Hash long keys to fit Square's 45-char limit + hash := sha256.Sum256([]byte(req.IdempotencyKey)) + req.IdempotencyKey = fmt.Sprintf("pay-%x", hash[:16]) + } + } + service := NewPaymentService() if req.PaymentType == "partial" { @@ -1388,6 +1456,37 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) { return } if !IsValidBookingStatusForPayment(status) { + // A fully-paid booking auto-completes ('completed') and can no longer + // accept new payments. But a same-key retry of a payment that already + // went through must still dedup to the existing completed row — + // otherwise a client retrying after a lost response gets a 409 even + // though the charge succeeded. Any other payment attempt on a + // completed booking falls through and is rejected below. + if status == "completed" { + var existingID sql.NullString + var existingBookingID sql.NullString + var existingPaymentType sql.NullString + var existingStatus sql.NullString + var existingAmount sql.NullFloat64 + var existingCreatedAt sql.NullTime + if err := tx.QueryRow(r.Context(), ` + SELECT id, booking_id, payment_type, status, amount, created_at + FROM payments + WHERE booking_id = $1 AND idempotency_key = $2 AND status = 'completed' + `, bookingID, req.IdempotencyKey).Scan(&existingID, &existingBookingID, &existingPaymentType, &existingStatus, &existingAmount, &existingCreatedAt); err == nil { + if err := json.NewEncoder(w).Encode(PaymentResponse{ + ID: existingID.String, + BookingID: existingBookingID.String, + PaymentType: existingPaymentType.String, + Status: existingStatus.String, + Amount: int64(math.Round(existingAmount.Float64 * 100)), + CreatedAt: existingCreatedAt.Time.Format(time.RFC3339), + }); err != nil { + log.Printf("Failed to encode JSON response: %v", err) + } + return + } + } log.Printf("Payment rejected: booking %s is in status %q (no longer accepting payments)", bookingID, status) http.Error(w, "This booking is no longer accepting payments. The slot may have been released.", http.StatusConflict) return @@ -1504,6 +1603,28 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) { } } + // M4: cap pay-early at 100% — a payment that exceeds the booking's remaining + // balance is rejected instead of silently becoming a tip via buildSplitRecords. + // A tip is gratuity for service already rendered and must be a deliberate + // separate action (the frontend shows a dedicated "tip" button once 100% is + // paid), so an overpayment is always a mistake. Placed AFTER the idempotency + // dedup: a same-key retry of an already-completed payment short-circuits + // above and must not hit this guard (the booking is fully paid by then). + // 'tip'-type requests are excluded — tips are charged via CreateTipPayment. + if req.PaymentType != "tip" { + remainingCents, err := service.GetBookingRemainingBalanceCents(r.Context(), bookingID) + if err != nil { + log.Printf("Failed to get remaining balance: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if req.Amount > remainingCents { + log.Printf("Payment rejected: amount %d exceeds remaining balance %d for booking %s", req.Amount, remainingCents, bookingID) + http.Error(w, "Payment amount exceeds the remaining balance", http.StatusBadRequest) + return + } + } + var sourceID string var savedCardID *string var savedCardCustomerID string @@ -1752,6 +1873,15 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) { } } + // Fully-paid completion: if total paid (excluding tips/discounts) now + // covers 100% of the booking total, transition an active booking to + // 'completed' so it leaves the admin's Current Appointment view. The + // completion side-effects (loyalty, campaigns, deposits_required) are the + // same as the admin progress endpoint. + if bookingIsFullyPaid(r.Context(), tx2, bookingID) { + completeActiveBookingFromPayment(r.Context(), tx2, bookingID) + } + // Apply eligible campaign discounts inside the payment transaction, so // atomicity with the payment inserts is guaranteed. The call is idempotent // — if discounts were already applied, the duplicate check skips them. @@ -1917,6 +2047,84 @@ func buildSplitRecords(primary PaymentRecord, reqPaymentType string, info *Booki return records } +// buildTerminalSplitRecords splits a completed terminal checkout charge that +// included a tip into deposit + balance + tip payment records. Square charges +// a single amount (booking portion + tip); the booking portion is split like +// buildSplitRecords — deposit up to 50% of the booking total (minus already +// deposited), balance covering the rest — and the tip becomes its own +// payment_type='tip' record with a derived -split-tip idempotency key. Unlike +// buildSplitRecords this always carves the deposit/balance split (the terminal +// flow runs after the booking started, where buildSplitRecords' start-time +// short-circuit would collapse everything into one record): only deposit + +// balance are refundable on cancellation, while the tip is recorded for +// accounting (total_tips) and excluded from the refund computation. The MONEY +// INVARIANT from buildSplitRecords holds: the records always partition +// bookingPortion + tipAmount exactly. +func buildTerminalSplitRecords(primary PaymentRecord, info *BookingPaymentInfo, bookingPortion, tipAmount float64) []PaymentRecord { + maxDeposit := info.TotalAmount * ProtectedDepositMaxPct + remainingDepositRoom := math.Max(0, maxDeposit-info.TotalPaid) + depositAmount := math.Min(bookingPortion, remainingDepositRoom) + depositAmount = math.Round(depositAmount*100) / 100 + balancePortion := math.Round((bookingPortion-depositAmount)*100) / 100 + + var records []PaymentRecord + splitIdx := 0 + + if depositAmount > 0.004 { + dep := primary + dep.PaymentType = "deposit" + dep.Amount = depositAmount + records = append(records, dep) + splitIdx++ + } + + if balancePortion > 0.004 { + bal := primary + bal.PaymentType = "balance" + bal.Amount = balancePortion + bal.Fees = 0 + if primary.IdempotencyKey != nil { + k := splitIdempotencyKey(*primary.IdempotencyKey, fmt.Sprintf("-split-%d", splitIdx)) + bal.IdempotencyKey = &k + } + records = append(records, bal) + splitIdx++ + } + + if tipAmount > 0.004 { + tip := primary + tip.PaymentType = "tip" + tip.Amount = tipAmount + tip.Fees = 0 + if primary.IdempotencyKey != nil { + k := splitIdempotencyKey(*primary.IdempotencyKey, "-split-tip") + tip.IdempotencyKey = &k + } + records = append(records, tip) + } + + if len(records) == 0 { + primary.Fees = 0 + records = append(records, primary) + } + return records +} + +// splitIdempotencyKey derives a bounded-length idempotency key for a split +// record. The terminal base key (booking + amount + Square payment ID) can be +// long enough that appending a -split-tip suffix would exceed the +// payments.idempotency_key VARCHAR(64) limit (e.g. the dev mock's 28-char +// "pay_mock_" payment IDs); the base is truncated so the suffix +// always fits. Uniqueness is preserved: the truncated base still embeds the +// booking ID and Square payment ID, and the suffix differs per split record. +func splitIdempotencyKey(base, suffix string) string { + maxBase := 64 - len(suffix) + if len(base) > maxBase { + base = base[:maxBase] + } + return base + suffix +} + func GetUserPaymentMethods(w http.ResponseWriter, r *http.Request) { userID, ok := r.Context().Value(mw.UserIDKey).(string) if !ok || userID == "" { @@ -2108,6 +2316,16 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) { return } + // A discount/on_the_house payment row is a ledger entry, not real money + // (the customer never paid it). Refunding it would pay money out of + // nothing. The NULL-square_payment_id guard below would also catch it, but + // an explicit check is defense-in-depth: if a discount row ever gains a + // square_payment_id, this still blocks the refund. + if payment.PaymentMethod == "discount" || payment.PaymentMethod == "on_the_house" { + http.Error(w, "Cannot refund a discount or complimentary payment", http.StatusBadRequest) + return + } + if payment.SquarePaymentID == nil { http.Error(w, "Payment has no Square reference", http.StatusBadRequest) return @@ -2627,6 +2845,388 @@ func resumeManualPendingRefund(w http.ResponseWriter, r *http.Request, paymentID } } +// AdminBookingRefundRequest is the request body for +// POST /api/admin/bookings/{id}/refund. +type AdminBookingRefundRequest struct { + Amount int64 `json:"amount" validate:"required,gt=0"` + Reason string `json:"reason" validate:"required"` +} + +// AdminBookingRefundResponse reports the result of an admin-initiated +// booking-level refund: the total amount refunded and one entry per affected +// payment. +type AdminBookingRefundResponse struct { + RefundedAmount int64 `json:"refunded_amount"` + Refunds []RefundResponse `json:"refunds"` +} + +// AdminRefundBooking is the admin-initiated booking-level refund endpoint +// (separate from cancellation refunds). It refunds up to req.Amount against +// the booking's completed non-tip payments, oldest first, after validating the +// amount against the booking's refundable total (paid minus already refunded, +// excluding tips). Used for post-service refunds (bad application, etc.) at +// admin discretion. +func AdminRefundBooking(w http.ResponseWriter, r *http.Request) { + // Defense-in-depth admin check (S-1) — the route is mounted under + // mw.RequireAdmin; this keeps booking-level refunds admin-only regardless. + if !isAdminRequest(r) { + http.Error(w, "Admin access required", http.StatusForbidden) + return + } + bookingID := chi.URLParam(r, "id") + if bookingID == "" || !validators.IsValidID(bookingID) { + http.Error(w, "Booking not found", http.StatusNotFound) + return + } + + adminID, ok := r.Context().Value(mw.UserIDKey).(string) + if !ok || adminID == "" { + http.Error(w, "Authentication required", http.StatusUnauthorized) + return + } + + var req AdminBookingRefundRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Printf("Failed to decode admin booking refund request: %v", err) + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + if err := validators.Validate.Struct(&req); err != nil { + log.Printf("Failed to process request: %v", err) + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + // Square's refund-reason limit is 192 chars (N4) — a longer reason 400s at + // Square and would be misclassified as a definitive decline. + if len(req.Reason) > 192 { + http.Error(w, "Refund reason must be 192 characters or less", http.StatusBadRequest) + return + } + if err := ValidateAmount(req.Amount); err != nil { + log.Printf("Failed to process request: %v", err) + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + service := NewPaymentService() + + // Booking user lookup also proves the booking exists. + var bookingUserID string + var isGuest bool + if err := db.Conn.QueryRow(r.Context(), ` + SELECT b.user_id, COALESCE(u.account_role = 'guest', false) + FROM bookings b + LEFT JOIN users u ON b.user_id = u.id + WHERE b.id = $1 + `, bookingID).Scan(&bookingUserID, &isGuest); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + http.Error(w, "Booking not found", http.StatusNotFound) + return + } + log.Printf("Failed to get booking user: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + // Cap: the refund amount must not exceed the refundable total (completed + // non-tip payments minus already refunded). Tips are not refundable. + refundableCents, err := service.GetBookingRefundableAmountCents(r.Context(), bookingID) + if err != nil { + log.Printf("Failed to get refundable amount for booking %s: %v", bookingID, err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if req.Amount > refundableCents { + log.Printf("Admin refund rejected: amount %d exceeds refundable %d for booking %s", req.Amount, refundableCents, bookingID) + http.Error(w, "Refund amount exceeds the refundable amount for this booking", http.StatusBadRequest) + return + } + + // Fetch the booking's completed non-tip payments, oldest first, so the + // requested amount is refunded against the earliest money first. + rows, err := db.Conn.Query(r.Context(), ` + SELECT id, amount, payment_method, square_payment_id, gift_card_id + FROM payments + WHERE booking_id = $1 AND status = 'completed' AND payment_type <> 'tip' + AND payment_method NOT IN ('discount', 'on_the_house') + ORDER BY created_at ASC + `, bookingID) + if err != nil { + log.Printf("Failed to fetch payments for admin booking refund: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + var payments []paymentRow + for rows.Next() { + var p paymentRow + if err := rows.Scan(&p.ID, &p.Amount, &p.PaymentMethod, &p.SquarePaymentID, &p.GiftCardID); err != nil { + log.Printf("Failed to scan payment row: %v", err) + continue + } + payments = append(payments, p) + } + rows.Close() + if err := rows.Err(); err != nil { + log.Printf("Payment row iteration error: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if len(payments) == 0 { + http.Error(w, "No refundable payments found for this booking", http.StatusBadRequest) + return + } + + // Serialize against the cancellation refund path and the per-payment manual + // RefundPayment handler — both hold the same crussell:refund: + // locks — so the residual computation below cannot race an in-flight refund. + tx, err := db.Conn.Begin(r.Context()) + if err != nil { + log.Printf("Failed to begin transaction for admin booking refund: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + defer func() { + if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) { + slog.Error("failed to rollback admin booking refund transaction", "err", err) + } + }() + + if err := lockCancellationPayments(r.Context(), tx, payments); err != nil { + log.Printf("Failed to acquire refund locks for booking %s: %v", bookingID, err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + // Prior refunds per payment (completed + pending) so a payment is never + // refunded past its residual. + priorRefunds := make(map[string]float64) + prRows, prErr := tx.Query(r.Context(), ` + SELECT payment_id, COALESCE(SUM(amount), 0) FROM refunds + WHERE booking_id = $1 AND status IN ('completed', 'pending') + GROUP BY payment_id`, bookingID) + if prErr != nil { + log.Printf("Failed to query prior refunds for booking %s: %v", bookingID, prErr) + } else { + for prRows.Next() { + var pid string + var amt float64 + if err := prRows.Scan(&pid, &amt); err == nil { + priorRefunds[pid] = amt + } + } + prRows.Close() + } + + // cardRefunds tracks card refunds that need a post-commit Square call. + type cardRefund struct { + refundID string + amountCents int64 + squareID string + reason string + key string + } + var cardRefunds []cardRefund + var refunds []RefundResponse + remaining := float64(req.Amount) / 100.0 + + for _, p := range payments { + if remaining <= 0 { + break + } + already := priorRefunds[p.ID] + residual := math.Round((p.Amount-already)*100) / 100 + if residual <= 0 { + continue + } + portion := math.Round(math.Min(residual, remaining)*100) / 100 + remaining -= portion + + var refundStatus string + var refundKey *string + + switch p.PaymentMethod { + case "online_square", "in_person_card": + if p.SquarePaymentID == nil || *p.SquarePaymentID == "" { + log.Printf("Admin booking refund: card payment %s has no Square reference — marking failed; refund must be arranged manually", p.ID) + refundStatus = "failed" + break + } + // Unique per-attempt key (same shape as RefundPayment's no-client-key + // fallback): distinct refunds never collide on the UNIQUE constraint. + key := p.ID + "-refund-" + strconv.FormatInt(int64(math.Round(portion*100)), 10) + "-" + randomHexSuffix(6) + refundKey = &key + refundStatus = "pending" + case "giftcard": + if p.GiftCardID != nil && *p.GiftCardID != "" { + var expired bool + if err := tx.QueryRow(r.Context(), ` + SELECT expiry_date IS NOT NULL AND expiry_date < NOW() + FROM gift_cards WHERE id = $1 + `, *p.GiftCardID).Scan(&expired); err != nil { + log.Printf("Failed to check gift card %s expiry: %v — proceeding with refund", *p.GiftCardID, err) + } else if expired { + log.Printf("Gift card %s has expired — money retained by salon, no refund due for booking %s", *p.GiftCardID, bookingID) + continue + } + gcExpiryMonths, expiryErr := GetGiftCardExpiryMonths(r.Context(), tx) + if expiryErr != nil { + log.Printf("Failed to query gift card expiry months (using default %d): %v", defaultGiftCardExpiryMonths, expiryErr) + gcExpiryMonths = defaultGiftCardExpiryMonths + } + if _, gcErr := tx.Exec(r.Context(), ` + UPDATE gift_cards SET amount_remaining = amount_remaining + $1, last_used_at = NOW(), expiry_date = NOW() + ($3 * INTERVAL '1 month') + WHERE id = $2 + `, portion, *p.GiftCardID, gcExpiryMonths); gcErr != nil { + log.Printf("Failed to refund £%.2f to gift card %s: %v", portion, *p.GiftCardID, gcErr) + continue + } + if _, gcErr := tx.Exec(r.Context(), ` + INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes) + VALUES ($1, 'refund', $2, 'booking', $3, $4, $5) + `, *p.GiftCardID, portion, bookingID, bookingUserID, "Refund from admin booking refund"); gcErr != nil { + log.Printf("Failed to create gift card transaction for refund: %v", gcErr) + } + } else { + if bookingUserID == "" { + log.Printf("Giftcard payment %s has no gift_card_id and no booking user — cannot refund. Skipping.", p.ID) + continue + } + if _, balErr := tx.Exec(r.Context(), ` + INSERT INTO user_giftcard_balances (user_id, balance, updated_at) + VALUES ($1, $2, NOW()) + ON CONFLICT (user_id) DO UPDATE SET + balance = user_giftcard_balances.balance + EXCLUDED.balance, + updated_at = NOW() + `, bookingUserID, portion); balErr != nil { + log.Printf("Failed to credit user %s gift-card balance for refund of booking %s: %v", bookingUserID, bookingID, balErr) + continue + } + } + refundStatus = "completed" + case "cash": + if bookingUserID == "" || isGuest { + log.Printf("Cash refund: booking %s payment %s amount £%.2f — admin must process cash refund at till", bookingID, p.ID, portion) + } else { + if _, balErr := tx.Exec(r.Context(), ` + INSERT INTO user_giftcard_balances (user_id, balance, updated_at) + VALUES ($1, $2, NOW()) + ON CONFLICT (user_id) DO UPDATE SET + balance = user_giftcard_balances.balance + EXCLUDED.balance, + updated_at = NOW() + `, bookingUserID, portion); balErr != nil { + log.Printf("Failed to credit user %s balance for cash refund of booking %s: %v", bookingUserID, bookingID, balErr) + continue + } + } + refundStatus = "completed" + default: + log.Printf("Skipping admin booking refund for payment %s with method %q (no money exchanged)", p.ID, p.PaymentMethod) + continue + } + + var refundID string + if refundKey != nil { + err = tx.QueryRow(r.Context(), ` + INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, created_by, created_at, origin) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'manual') + RETURNING id + `, p.ID, bookingID, portion, refundStatus, req.Reason, refundKey, adminID, clock.Now()).Scan(&refundID) + } else { + err = tx.QueryRow(r.Context(), ` + INSERT INTO refunds (payment_id, booking_id, amount, status, reason, created_by, created_at, origin) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'manual') + RETURNING id + `, p.ID, bookingID, portion, refundStatus, req.Reason, adminID, clock.Now()).Scan(&refundID) + } + if err != nil { + log.Printf("Failed to create refund record for payment %s: %v", p.ID, err) + continue + } + + refunds = append(refunds, RefundResponse{ + ID: refundID, + PaymentID: p.ID, + Amount: int64(math.Round(portion * 100)), + Status: refundStatus, + Reason: req.Reason, + CreatedAt: clock.Now().Format(time.RFC3339), + }) + + if p.PaymentMethod == "online_square" || p.PaymentMethod == "in_person_card" { + if refundKey != nil && p.SquarePaymentID != nil { + cardRefunds = append(cardRefunds, cardRefund{ + refundID: refundID, + amountCents: int64(math.Round(portion * 100)), + squareID: *p.SquarePaymentID, + reason: req.Reason, + key: *refundKey, + }) + } + } + } + + if err := tx.Commit(r.Context()); err != nil { + log.Printf("Failed to commit admin booking refund transaction: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + // Post-commit Square pass for card refunds (mirrors RefundPayment's + // status resolution). A definitive decline marks the row failed; an + // ambiguous error leaves it pending for the manual-refund sweep. + for _, cf := range cardRefunds { + status := "completed" + result, rErr := SquareClient.RefundPayment(r.Context(), square.RefundPaymentReq{ + PaymentID: cf.squareID, + Amount: cf.amountCents, + IdempotencyKey: cf.key, + Reason: cf.reason, + }) + switch { + case rErr == nil: + if result.Status == "PENDING" { + status = "pending" + log.Printf("Square refund %s is PENDING (in flight) — leaving refund %s pending for the sweep", result.ID, cf.refundID) + } else if result.Status == "FAILED" || result.Status == "REJECTED" { + status = "failed" + log.Printf("Square refund %s FAILED — marking refund %s failed", result.ID, cf.refundID) + } + if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = $1, square_refund_id = $2 WHERE id = $3`, status, result.ID, cf.refundID); upErr != nil { + log.Printf("CRITICAL: Square refund committed (%s) but DB update for refund %s failed — manual reconciliation required: %v", result.ID, cf.refundID, upErr) + } + case errors.Is(rErr, square.ErrRefundAlreadyProcessed): + if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'completed' WHERE id = $1`, cf.refundID); upErr != nil { + log.Printf("Failed to resolve refund %s completed after PAYMENT_ALREADY_REFUNDED: %v", cf.refundID, upErr) + } + log.Printf("Refund %s already processed at Square — marked completed", cf.refundID) + case errors.Is(rErr, square.ErrRefundDeclined): + if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'failed' WHERE id = $1`, cf.refundID); upErr != nil { + log.Printf("Failed to mark refund %s failed after definitive rejection: %v", cf.refundID, upErr) + } + log.Printf("Refund %s definitively declined by Square: %v", cf.refundID, rErr) + default: + log.Printf("Failed to refund payment (refund %s left pending): %v", cf.refundID, rErr) + } + for i := range refunds { + if refunds[i].ID == cf.refundID { + refunds[i].Status = status + } + } + } + + var totalRefunded int64 + for _, rf := range refunds { + totalRefunded += rf.Amount + } + + if err := json.NewEncoder(w).Encode(AdminBookingRefundResponse{ + RefundedAmount: totalRefunded, + Refunds: refunds, + }); err != nil { + log.Printf("Failed to encode JSON response: %v", err) + } +} + func CreateTipPayment(w http.ResponseWriter, r *http.Request) { bookingID := chi.URLParam(r, "id") if bookingID == "" || !validators.IsValidID(bookingID) { @@ -2712,6 +3312,24 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) { return } + // M4: tips are only accepted once the booking has started. A tip is + // gratuity for service already rendered; accepting it on a 'confirmed' + // booking whose appointment is still in the future would collect money for + // a service not yet performed and inflate the total_tips aggregation. + // Checked against the booking start time (not status) so an early-arriving + // booking still cannot tip until its slot opens. + var bookingStartTime time.Time + if err := db.Conn.QueryRow(r.Context(), `SELECT start_time FROM bookings WHERE id = $1`, bookingID).Scan(&bookingStartTime); err != nil { + log.Printf("Failed to get booking start time: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if bookingStartTime.After(clock.Now()) { + log.Printf("Tip rejected: booking %s starts at %s (not yet started)", bookingID, bookingStartTime.Format(time.RFC3339)) + http.Error(w, "Tips can only be added after the booking has started", http.StatusBadRequest) + return + } + hasCompleted, err := service.HasCompletedPayment(r.Context(), bookingID) if err != nil { log.Printf("Failed to check for completed payments: %v", err) diff --git a/backend/handlers/payments/m4_tip_refund_redesign_test.go b/backend/handlers/payments/m4_tip_refund_redesign_test.go new file mode 100644 index 0000000..cabf16e --- /dev/null +++ b/backend/handlers/payments/m4_tip_refund_redesign_test.go @@ -0,0 +1,448 @@ +//go:build test && dev + +package payments + +import ( + "encoding/json" + "net/http" + "testing" + "time" + + "crussell/clock" + "crussell/db" + "crussell/internal/square" + "crussell/testutils" + "crussell/testutils/fixtures" + "crussell/testutils/jwt" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ============================================================================= +// M4-1: Tips only after booking start +// ============================================================================= + +func TestTipPayment_RejectedBeforeStart(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + // setupTestData creates the booking with a far-future start time but status + // in_progress — a payable status, so the start-time guard is the ONLY + // rejection that can fire. + userID, bookingID, _ := setupTestData(t, ctx, tx) + + _, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed") + require.NoError(t, err) + + userToken := jwt.GenerateUserToken(userID) + cardToken := "cnon:tip-before-start" + req := CreateTipPaymentRequest{ + Amount: 500, + NewCardToken: &cardToken, + } + + handler := CreateTipPayment + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx) + + require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String()) + assert.Contains(t, w.Body.String(), "after the booking has started") + + var tipCount int + err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip'`, bookingID).Scan(&tipCount) + require.NoError(t, err) + assert.Equal(t, 0, tipCount, "no tip record may be created before the booking starts") +} + +func TestTipPayment_AcceptedAfterStart(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + userID, bookingID, _ := setupTestDataPast(t, ctx, tx) + + _, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed") + require.NoError(t, err) + + userToken := jwt.GenerateUserToken(userID) + cardToken := "cnon:tip-after-start" + req := CreateTipPaymentRequest{ + Amount: 500, + NewCardToken: &cardToken, + } + + handler := CreateTipPayment + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx) + + require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) + + var resp PaymentResponse + require.NoError(t, json.NewDecoder(w.Body).Decode(&resp)) + assert.Equal(t, "tip", resp.PaymentType) + assert.Equal(t, "completed", resp.Status) +} + +// ============================================================================= +// M4-2: Cap pay-early at 100% — reject overpayment instead of silent tip +// ============================================================================= + +func TestBookingPayment_OverflowRejected_NoSilentTip(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + // Fixture booking total is £50 (5000 pence); a £60 'full' payment exceeds it. + userID, bookingID, _ := setupTestData(t, ctx, tx) + userToken := jwt.GenerateUserToken(userID) + + cardToken := "cnon:overflow-card" + req := CreateBookingPaymentRequest{ + Amount: 6000, + PaymentType: "full", + NewCardToken: &cardToken, + IdempotencyKey: "overflow-reject-" + bookingID, + } + + handler := CreateBookingPayment + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) + + require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String()) + assert.Contains(t, w.Body.String(), "remaining balance") + + // No payment records may be created (the rejection happens before any + // pending record insert or Square charge), and no tip may be silently carved. + var count int + err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&count) + require.NoError(t, err) + assert.Equal(t, 0, count, "overpayment must not create any payment record") +} + +func TestBookingPayment_FullRemainingBalance_Accepted(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + // Paying exactly the remaining balance is still allowed. + userID, bookingID, _ := setupTestData(t, ctx, tx) + userToken := jwt.GenerateUserToken(userID) + + cardToken := "cnon:exact-balance-card" + req := CreateBookingPaymentRequest{ + Amount: 5000, + PaymentType: "full", + NewCardToken: &cardToken, + IdempotencyKey: "exact-balance-" + bookingID, + } + + handler := CreateBookingPayment + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) + require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) +} + +// ============================================================================= +// M4-3: Cancellation refunds exclude tips +// ============================================================================= + +func TestProcessCancellationRefund_ExcludesTips(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + serviceID, err := fixtures.CreateTestService(tx) + require.NoError(t, err) + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) + require.NoError(t, err) + + // £50 real payment plus a £5 tip on a £50 booking. + payID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed") + require.NoError(t, err) + tipID, err := fixtures.CreateTestPayment(tx, bookingID, 5.00, "online_square", "tip", "completed") + require.NoError(t, err) + _, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_refund_full' WHERE id = $1", payID) + require.NoError(t, err) + _, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_refund_tip' WHERE id = $1", tipID) + require.NoError(t, err) + + farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) + now := farFuture.Add(-72 * time.Hour).Add(-1 * time.Hour) + + innerTx := db.TxFromContext(ctx) + require.NotNil(t, innerTx) + result, err := ProcessCancellationRefundTx(ctx, innerTx, bookingID, 50, 50, farFuture, now, "client_cancelled", &userID, false) + require.NoError(t, err) + require.Equal(t, 50.0, result.RefundableAmount, "totalPrePaid must exclude the £5 tip") + + // The tip payment must NOT be refunded. + var tipRefundCount int + err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1`, tipID).Scan(&tipRefundCount) + require.NoError(t, err) + assert.Equal(t, 0, tipRefundCount, "tip payments are not refundable as part of a cancellation") + + // The real payment IS refunded. + var fullRefundCount int + err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1`, payID).Scan(&fullRefundCount) + require.NoError(t, err) + assert.Equal(t, 1, fullRefundCount) +} + +func TestGetBookingPaymentInfo_ExcludesTips(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + serviceID, err := fixtures.CreateTestService(tx) + require.NoError(t, err) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) + require.NoError(t, err) + + _, err = fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed") + require.NoError(t, err) + _, err = fixtures.CreateTestPayment(tx, bookingID, 5.00, "online_square", "tip", "completed") + require.NoError(t, err) + + svc := NewPaymentService() + info, err := svc.GetBookingPaymentInfo(ctx, bookingID) + require.NoError(t, err) + assert.Equal(t, 50.00, info.TotalPaid, "tips must not count toward TotalPaid") +} + +// ============================================================================= +// M4-4: Terminal tip split in GetCheckoutStatus +// ============================================================================= + +func TestGetCheckoutStatus_TerminalTipSplit(t *testing.T) { + origClient := SquareClient + SquareClient = &testCheckoutClient{ + SquareClient: square.NewDevClient(), + hexIDs: make(map[string]string), + } + defer func() { SquareClient = origClient }() + + ctx, tx := testutils.SetupTestTx(t) + _, bookingID, _ := setupTestData(t, ctx, tx) + adminToken := jwt.GenerateAdminToken() + + handler := CreateTerminalPayment + req := CreateTerminalPaymentRequest{ + Amount: 5500, + PaymentType: "full", + TipEnabled: true, + } + w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx) + require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) + + var createResp CheckoutResponse + require.NoError(t, json.NewDecoder(w.Body).Decode(&createResp)) + require.NotEmpty(t, createResp.CheckoutID) + + resp := pollCheckoutStatus(t, ctx, createResp.CheckoutID, bookingID, adminToken) + require.Equal(t, "COMPLETED", resp.Status) + require.NotEmpty(t, resp.PaymentID) + + // A £55 charge on a £50 booking (£5 above the booking value) is a tip → + // split into deposit £25 + balance £25 + tip £5. + rows, err := tx.Query(ctx, ` + SELECT payment_type, amount, idempotency_key FROM payments + WHERE booking_id = $1 AND status = 'completed' + ORDER BY payment_type + `, bookingID) + require.NoError(t, err) + defer rows.Close() + + amounts := map[string]float64{} + keys := map[string]string{} + for rows.Next() { + var pt string + var amt float64 + var key *string + require.NoError(t, rows.Scan(&pt, &amt, &key)) + amounts[pt] = amt + if key != nil { + keys[pt] = *key + } + } + require.NoError(t, rows.Err()) + + require.Len(t, amounts, 3, "terminal tip charge must split into deposit + balance + tip records") + assert.Equal(t, 25.0, amounts["deposit"], "deposit = 50% of the £50 booking total") + assert.Equal(t, 25.0, amounts["balance"], "balance = remaining booking total") + assert.Equal(t, 5.0, amounts["tip"], "tip = the Square tip amount") + + // The tip record gets its own derived idempotency key. + assert.Contains(t, keys["tip"], "-split-tip") + + // The tip record shares the Square payment id with the split records and is + // NOT refundable (deposit + balance only). + var tipSquareID, primarySquareID string + err = tx.QueryRow(ctx, `SELECT square_payment_id FROM payments WHERE booking_id = $1 AND payment_type = 'tip'`, bookingID).Scan(&tipSquareID) + require.NoError(t, err) + err = tx.QueryRow(ctx, `SELECT square_payment_id FROM payments WHERE booking_id = $1 AND payment_type = 'deposit'`, bookingID).Scan(&primarySquareID) + require.NoError(t, err) + assert.Equal(t, primarySquareID, tipSquareID, "one Square charge, three ledger rows") + + // Refundable total must be £50 (the booking portion), not £55. + svc := NewPaymentService() + refundable, err := svc.GetBookingRefundableAmountCents(ctx, bookingID) + require.NoError(t, err) + assert.Equal(t, int64(5000), refundable, "tips must not be part of the refundable total") +} + +func TestBuildTerminalSplitRecords_SplitsDepositBalanceTip(t *testing.T) { + record := makeTestRecord("b-t-term", "full", 55) + info := &BookingPaymentInfo{ + StartTime: clock.Now().Add(-1 * time.Hour), + TotalAmount: 50, + TotalPaid: 0, + } + records := buildTerminalSplitRecords(record, info, 50, 5) + + require.Len(t, records, 3) + assert.Equal(t, "deposit", records[0].PaymentType) + assert.Equal(t, 25.0, records[0].Amount) + assert.Equal(t, "balance", records[1].PaymentType) + assert.Equal(t, 25.0, records[1].Amount) + assert.Equal(t, "tip", records[2].PaymentType) + assert.Equal(t, 5.0, records[2].Amount) + assert.Equal(t, 0.0, records[2].Fees, "tip split record carries no fees") + assert.Equal(t, *record.IdempotencyKey+"-split-tip", *records[2].IdempotencyKey) + + var sum float64 + for _, r := range records { + sum += r.Amount + } + assert.Equal(t, 55.0, sum, "records must partition the charged amount exactly") +} + +func TestBuildTerminalSplitRecords_NoDepositRoom_BalanceAndTip(t *testing.T) { + // £50 deposit already paid on a £50 booking → no deposit room left; a + // terminal charge of £55 (£50 booking + £5 tip) records balance + tip only. + record := makeTestRecord("b-t-term2", "full", 55) + info := &BookingPaymentInfo{ + StartTime: clock.Now().Add(-1 * time.Hour), + TotalAmount: 50, + TotalPaid: 50, + } + records := buildTerminalSplitRecords(record, info, 50, 5) + + require.Len(t, records, 2) + assert.Equal(t, "balance", records[0].PaymentType) + assert.Equal(t, 50.0, records[0].Amount) + assert.Equal(t, "tip", records[1].PaymentType) + assert.Equal(t, 5.0, records[1].Amount) +} + +// ============================================================================= +// M4-5: Admin manual refund endpoint +// ============================================================================= + +func TestAdminRefundBooking_HappyPath(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + _, bookingID, _ := setupTestDataPast(t, ctx, tx) + adminID, err := fixtures.CreateTestAdminUser(tx) + require.NoError(t, err) + adminToken := jwt.GenerateTestToken(adminID, "admin") + + payID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed") + require.NoError(t, err) + _, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_admin_refund' WHERE id = $1", payID) + require.NoError(t, err) + + req := AdminBookingRefundRequest{Amount: 2000, Reason: "bad application"} + handler := AdminRefundBooking + w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/refund", req, adminToken, ctx) + require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) + + var resp AdminBookingRefundResponse + require.NoError(t, json.NewDecoder(w.Body).Decode(&resp)) + require.Equal(t, int64(2000), resp.RefundedAmount) + require.Len(t, resp.Refunds, 1) + assert.Equal(t, payID, resp.Refunds[0].PaymentID) + assert.Equal(t, int64(2000), resp.Refunds[0].Amount) + assert.Equal(t, "completed", resp.Refunds[0].Status) + + var status, origin string + err = tx.QueryRow(ctx, `SELECT status, origin FROM refunds WHERE id = $1`, resp.Refunds[0].ID).Scan(&status, &origin) + require.NoError(t, err) + assert.Equal(t, "completed", status) + assert.Equal(t, "manual", origin) +} + +func TestAdminRefundBooking_OverRefundRejected(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + _, bookingID, _ := setupTestDataPast(t, ctx, tx) + adminID, err := fixtures.CreateTestAdminUser(tx) + require.NoError(t, err) + adminToken := jwt.GenerateTestToken(adminID, "admin") + + payID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed") + require.NoError(t, err) + _, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_admin_refund' WHERE id = $1", payID) + require.NoError(t, err) + + // £60 refund on a £50 booking — must be rejected. + req := AdminBookingRefundRequest{Amount: 6000, Reason: "too much"} + handler := AdminRefundBooking + w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/refund", req, adminToken, ctx) + require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String()) + assert.Contains(t, w.Body.String(), "refundable") + + var refundCount int + err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE booking_id = $1`, bookingID).Scan(&refundCount) + require.NoError(t, err) + assert.Equal(t, 0, refundCount, "no refund record may be created for an over-refund") +} + +func TestAdminRefundBooking_ExcludesTipsFromRefundable(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + _, bookingID, _ := setupTestDataPast(t, ctx, tx) + adminID, err := fixtures.CreateTestAdminUser(tx) + require.NoError(t, err) + adminToken := jwt.GenerateTestToken(adminID, "admin") + + payID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed") + require.NoError(t, err) + _, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_admin_refund' WHERE id = $1", payID) + require.NoError(t, err) + tipID, err := fixtures.CreateTestPayment(tx, bookingID, 5.00, "online_square", "tip", "completed") + require.NoError(t, err) + _, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_admin_tip' WHERE id = $1", tipID) + require.NoError(t, err) + + // Refundable total is £50 (tip excluded) — refunding £55 must be rejected. + req := AdminBookingRefundRequest{Amount: 5500, Reason: "including tip"} + handler := AdminRefundBooking + w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/refund", req, adminToken, ctx) + require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String()) + + var refundCount int + err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE booking_id = $1`, bookingID).Scan(&refundCount) + require.NoError(t, err) + assert.Equal(t, 0, refundCount, "tip money must not be refundable") +} + +func TestGetBookingRefundableAmountCents_ExcludesTips(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + serviceID, err := fixtures.CreateTestService(tx) + require.NoError(t, err) + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) + require.NoError(t, err) + + _, err = fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed") + require.NoError(t, err) + _, err = fixtures.CreateTestPayment(tx, bookingID, 5.00, "online_square", "tip", "completed") + require.NoError(t, err) + + svc := NewPaymentService() + refundable, err := svc.GetBookingRefundableAmountCents(ctx, bookingID) + require.NoError(t, err) + require.Equal(t, int64(5000), refundable, "tips must not count toward the refundable amount") +} diff --git a/backend/handlers/payments/m5_fully_paid_completion_test.go b/backend/handlers/payments/m5_fully_paid_completion_test.go new file mode 100644 index 0000000..921d937 --- /dev/null +++ b/backend/handlers/payments/m5_fully_paid_completion_test.go @@ -0,0 +1,233 @@ +//go:build test && dev + +package payments + +import ( + "net/http" + "testing" + + "crussell/internal/square" + "crussell/testutils" + "crussell/testutils/jwt" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ============================================================================= +// M5: Fully-paid bookings auto-complete +// ============================================================================= + +// TestBookingPayment_FullyPaid_CompletesBooking verifies that an online +// payment covering 100% of the booking total transitions an active booking to +// 'completed' so it leaves the admin's Current Appointment view. +func TestBookingPayment_FullyPaid_CompletesBooking(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + // setupTestData creates a £50 (5000 pence) booking with status in_progress. + userID, bookingID, _ := setupTestData(t, ctx, tx) + userToken := jwt.GenerateUserToken(userID) + + cardToken := "cnon:fully-paid-complete" + req := CreateBookingPaymentRequest{ + Amount: 5000, + PaymentType: "full", + NewCardToken: &cardToken, + IdempotencyKey: "fully-paid-" + bookingID, + } + + handler := CreateBookingPayment + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) + require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) + + var status string + err := tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status) + require.NoError(t, err) + assert.Equal(t, "completed", status, "a fully-paid booking must auto-complete") +} + +// TestBookingPayment_PartialPayment_DoesNotComplete verifies that a partial +// payment (below 100%) leaves the booking in its active status. +func TestBookingPayment_PartialPayment_DoesNotComplete(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + userID, bookingID, _ := setupTestData(t, ctx, tx) + userToken := jwt.GenerateUserToken(userID) + + // £25 = 50% of the £50 booking total. + cardToken := "cnon:partial-no-complete" + req := CreateBookingPaymentRequest{ + Amount: 2500, + PaymentType: "partial", + NewCardToken: &cardToken, + IdempotencyKey: "partial-" + bookingID, + } + + handler := CreateBookingPayment + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) + require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) + + var status string + err := tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status) + require.NoError(t, err) + assert.Equal(t, "in_progress", status, "a partial payment must not complete the booking") +} + +// TestBookingPayment_FullPaymentPlusTip_Completes verifies that a full payment +// plus a later tip both succeed — the booking completes on the full payment +// and the tip is still accepted on the completed booking. +func TestBookingPayment_FullPaymentPlusTip_Completes(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + // setupTestDataPast: booking started 1 hour ago, status in_progress. + userID, bookingID, _ := setupTestDataPast(t, ctx, tx) + userToken := jwt.GenerateUserToken(userID) + + cardToken := "cnon:full-plus-tip" + req := CreateBookingPaymentRequest{ + Amount: 5000, + PaymentType: "full", + NewCardToken: &cardToken, + IdempotencyKey: "full-plus-tip-" + bookingID, + } + + handler := CreateBookingPayment + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) + require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) + + var status string + err := tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status) + require.NoError(t, err) + assert.Equal(t, "completed", status) + + // The tip is gratuity for a service already rendered — still accepted on a + // completed booking (bookingStatusAllowsCompletedPayment includes it). + tipToken := "cnon:tip-on-completed" + tipReq := CreateTipPaymentRequest{ + Amount: 500, + NewCardToken: &tipToken, + } + handler = CreateTipPayment + w = makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", tipReq, userToken, ctx) + require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) + + var tipCount int + err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip'`, bookingID).Scan(&tipCount) + require.NoError(t, err) + assert.Equal(t, 1, tipCount, "the tip must be recorded on the completed booking") + + err = tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status) + require.NoError(t, err) + assert.Equal(t, "completed", status, "a tip must not revert the completed status") +} + +// TestTerminalPayment_FullyPaid_CompletesBooking verifies that a Square +// Terminal (card-machine) charge covering 100% of the booking total also +// auto-completes the booking. +func TestTerminalPayment_FullyPaid_CompletesBooking(t *testing.T) { + origClient := SquareClient + SquareClient = &testCheckoutClient{ + SquareClient: square.NewDevClient(), + hexIDs: make(map[string]string), + } + defer func() { SquareClient = origClient }() + + ctx, tx := testutils.SetupTestTx(t) + _, bookingID, _ := setupTestData(t, ctx, tx) + adminToken := jwt.GenerateAdminToken() + + handler := CreateTerminalPayment + req := CreateTerminalPaymentRequest{ + Amount: 5000, + PaymentType: "full", + TipEnabled: false, + } + w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx) + require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) + + var createResp CheckoutResponse + if err := parsePaymentResponseBody(w, &createResp); err != nil { + t.Fatalf("failed to parse create response: %v", err) + } + require.NotEmpty(t, createResp.CheckoutID) + + resp := pollCheckoutStatus(t, ctx, createResp.CheckoutID, bookingID, adminToken) + require.Equal(t, "COMPLETED", resp.Status) + require.NotEmpty(t, resp.PaymentID) + + var status string + err := tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status) + require.NoError(t, err) + assert.Equal(t, "completed", status, "a fully-paid terminal charge must auto-complete the booking") +} + +// TestFullyPaid_CancelledBooking_StaysCancelled verifies that a cancelled +// booking can never be auto-completed by a payment: the payment is rejected +// and the status is unchanged. +func TestFullyPaid_CancelledBooking_StaysCancelled(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + userID, bookingID, _ := setupTestData(t, ctx, tx) + _, err := tx.Exec(ctx, `UPDATE bookings SET status = 'client_cancelled' WHERE id = $1`, bookingID) + require.NoError(t, err) + + userToken := jwt.GenerateUserToken(userID) + cardToken := "cnon:cancelled-booking" + req := CreateBookingPaymentRequest{ + Amount: 5000, + PaymentType: "full", + NewCardToken: &cardToken, + IdempotencyKey: "cancelled-" + bookingID, + } + + handler := CreateBookingPayment + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) + require.Equal(t, http.StatusConflict, w.Code, "body: %s", w.Body.String()) + + var status string + err = tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status) + require.NoError(t, err) + assert.Equal(t, "client_cancelled", status, "a cancelled booking must never be completed by a payment") + + var payCount int + err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&payCount) + require.NoError(t, err) + assert.Equal(t, 0, payCount, "no payment may be recorded on a cancelled booking") +} + +// TestBookingPayment_FullyPaid_AwardsLoyaltyStamp verifies that the loyalty +// stamp is awarded by the payment-driven completion, mirroring the admin +// progress endpoint. +func TestBookingPayment_FullyPaid_AwardsLoyaltyStamp(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + userID, bookingID, _ := setupTestData(t, ctx, tx) + userToken := jwt.GenerateUserToken(userID) + + cardToken := "cnon:loyalty-stamp" + req := CreateBookingPaymentRequest{ + Amount: 5000, + PaymentType: "full", + NewCardToken: &cardToken, + IdempotencyKey: "loyalty-stamp-" + bookingID, + } + + handler := CreateBookingPayment + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) + require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) + + var status string + err := tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status) + require.NoError(t, err) + require.Equal(t, "completed", status) + + var stamps int + err = tx.QueryRow(ctx, `SELECT loyalty_stamps FROM users WHERE id = $1`, userID).Scan(&stamps) + require.NoError(t, err) + assert.Equal(t, 1, stamps, "a payment-completed booking must award one loyalty stamp") +} diff --git a/backend/handlers/payments/payments_fixes_test.go b/backend/handlers/payments/payments_fixes_test.go index 532da97..0cddfab 100644 --- a/backend/handlers/payments/payments_fixes_test.go +++ b/backend/handlers/payments/payments_fixes_test.go @@ -597,7 +597,9 @@ func TestCreateBookingPayment_VerificationTokenPassthrough(t *testing.T) { func TestCreateTipPayment_VerificationTokenPassthrough(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) - _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "in_progress") + // Past start so the tip passes the start-time guard. + userID, bookingID, _ := setupTestDataPast(t, ctx, tx) + userToken := jwt.GenerateUserToken(userID) if _, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed"); err != nil { t.Fatalf("failed to create completed payment: %v", err) diff --git a/backend/handlers/payments/payments_r6_review_test.go b/backend/handlers/payments/payments_r6_review_test.go index 0de1750..237b761 100644 --- a/backend/handlers/payments/payments_r6_review_test.go +++ b/backend/handlers/payments/payments_r6_review_test.go @@ -137,7 +137,10 @@ func TestBuyGiftCard_SaveCard_ChargeForwardsCustomerID(t *testing.T) { // R2: BuyGiftCard requires an idempotency key // ============================================================================= -func TestBuyGiftCard_MissingIdempotencyKey_Rejected(t *testing.T) { +// TestBuyGiftCard_MissingIdempotencyKey_Accepted verifies that M1: a missing +// idempotency key is accepted and a deterministic key is generated server-side. +// The purchase should succeed, not be rejected. +func TestBuyGiftCard_MissingIdempotencyKey_Accepted(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) @@ -151,7 +154,7 @@ func TestBuyGiftCard_MissingIdempotencyKey_Rejected(t *testing.T) { SaveCard: false, } w := makePaymentRequest(BuyGiftCard, "POST", "/api/gift-cards/buy", req, token, ctx) - require.Equal(t, http.StatusBadRequest, w.Code, "an empty idempotency key must be rejected (R2)") + require.Equal(t, http.StatusCreated, w.Code, "M1: missing idempotency key should be accepted (server generates deterministic key)") } // ============================================================================= diff --git a/backend/handlers/payments/payments_test.go b/backend/handlers/payments/payments_test.go index b7e18fc..dc49849 100644 --- a/backend/handlers/payments/payments_test.go +++ b/backend/handlers/payments/payments_test.go @@ -1252,7 +1252,7 @@ func TestTipPayment_HappyPath(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) - userID, bookingID, _ := setupTestData(t, ctx, tx) + userID, bookingID, _ := setupTestDataPast(t, ctx, tx) _, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed") if err != nil { @@ -1292,7 +1292,7 @@ func TestTipPayment_NoPriorPayment(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) - userID, bookingID, _ := setupTestData(t, ctx, tx) + userID, bookingID, _ := setupTestDataPast(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) @@ -2505,7 +2505,7 @@ func TestTipPayment_WrongOwnerRejected(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) - _, bookingID, _ := setupTestData(t, ctx, tx) + _, bookingID, _ := setupTestDataPast(t, ctx, tx) _, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed") if err != nil { @@ -2537,7 +2537,7 @@ func TestTipPayment_MultipleTipsAllowed(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) - userID, bookingID, _ := setupTestData(t, ctx, tx) + userID, bookingID, _ := setupTestDataPast(t, ctx, tx) _, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed") if err != nil { @@ -2575,7 +2575,7 @@ func TestTipPayment_WithSavedCard(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) - userID, bookingID, _ := setupTestData(t, ctx, tx) + userID, bookingID, _ := setupTestDataPast(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) // Create a completed payment so the tip is allowed. @@ -2616,7 +2616,7 @@ func TestTipPayment_RetryPending_ReattemptsCharge(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) - userID, bookingID, _ := setupTestData(t, ctx, tx) + userID, bookingID, _ := setupTestDataPast(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) // Create a completed payment so the tip is allowed. @@ -2676,7 +2676,7 @@ func TestTipPayment_RetryPending_NonExactAmountSucceeds(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) - userID, bookingID, _ := setupTestData(t, ctx, tx) + userID, bookingID, _ := setupTestDataPast(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) // Create a completed payment so the tip is allowed. @@ -2730,7 +2730,7 @@ func TestTipPayment_RetryPending_AmountMismatchRejected(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) - userID, bookingID, _ := setupTestData(t, ctx, tx) + userID, bookingID, _ := setupTestDataPast(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) _, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed") @@ -2774,7 +2774,7 @@ func TestTipPayment_TransactionFailure_SkipsSquare(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) - userID, bookingID, _ := setupTestData(t, ctx, tx) + userID, bookingID, _ := setupTestDataPast(t, ctx, tx) _, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed") if err != nil { diff --git a/backend/handlers/payments/refunds.go b/backend/handlers/payments/refunds.go index 84da6c1..ac4abef 100644 --- a/backend/handlers/payments/refunds.go +++ b/backend/handlers/payments/refunds.go @@ -187,6 +187,7 @@ func ProcessCancellationRefundTx( SELECT id, amount, payment_method, square_payment_id, gift_card_id FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house') + AND payment_type <> 'tip' ORDER BY created_at ASC `, bookingID) if err != nil { diff --git a/backend/handlers/payments/service.go b/backend/handlers/payments/service.go index fe5b162..052906f 100644 --- a/backend/handlers/payments/service.go +++ b/backend/handlers/payments/service.go @@ -396,6 +396,30 @@ func (s *PaymentService) GetAlreadyRefundedAmount(ctx context.Context, paymentID return int64(math.Round(amount * 100)), nil } +// GetBookingRefundableAmountCents returns the total refundable amount (in +// pence) for a booking: the sum of completed non-tip payments minus already +// refunded (completed + pending). Tips are excluded — they are gratuity above +// the booking total and are not refundable via the admin refund endpoint. +func (s *PaymentService) GetBookingRefundableAmountCents(ctx context.Context, bookingID string) (int64, error) { + var amount float64 + err := db.Conn.QueryRow(ctx, ` + SELECT COALESCE(SUM(p.amount), 0) - COALESCE(( + SELECT SUM(r.amount) + FROM refunds r + JOIN payments p2 ON r.payment_id = p2.id + WHERE p2.booking_id = $1 AND r.status IN ('completed', 'pending') + AND p2.payment_type <> 'tip' + ), 0) + FROM payments p + WHERE p.booking_id = $1 AND p.status = 'completed' AND p.payment_type <> 'tip' + AND p.payment_method NOT IN ('discount', 'on_the_house') + `, bookingID).Scan(&amount) + if err != nil { + return 0, err + } + return int64(math.Round(amount * 100)), nil +} + // HasCompletedPayment reports whether the booking has a completed non-tip // payment. 'tip' is deliberately excluded: a tip-only booking (no deposit/full // payment) must NOT be treated as "already paid" for the purposes of allowing a @@ -444,7 +468,9 @@ func (s *PaymentService) GetBookingPaymentInfo(ctx context.Context, bookingID st FROM bookings b LEFT JOIN ( SELECT booking_id, SUM(amount) AS total_paid - FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house') GROUP BY booking_id + FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house') + AND payment_type <> 'tip' + GROUP BY booking_id ) pt ON b.id = pt.booking_id LEFT JOIN ( SELECT p.booking_id, SUM(r.amount) AS total_refunded diff --git a/backend/handlers/user/gdpr_test.go b/backend/handlers/user/gdpr_test.go index a444170..cf8d31d 100644 --- a/backend/handlers/user/gdpr_test.go +++ b/backend/handlers/user/gdpr_test.go @@ -517,7 +517,7 @@ func TestAnonymizeUser_ClearsNotificationPrefs(t *testing.T) { } } -func TestAnonymizeUser_ScrubsNotes(t *testing.T) { +func TestAnonymizeUser_PreservesNotes(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) @@ -544,8 +544,9 @@ func TestAnonymizeUser_ScrubsNotes(t *testing.T) { if err != nil { t.Fatalf("failed to query user notes: %v", err) } - if notes != nil { - t.Errorf("expected users.notes to be NULL after anonymization, got %v", notes) + // Notes should be preserved (contain business-critical info like allergies) + if notes == nil { + t.Errorf("expected users.notes to be preserved after anonymization, got NULL") } if lastLoginAt != nil { t.Errorf("expected users.last_login_at to be NULL after anonymization, got %v", lastLoginAt) @@ -975,8 +976,8 @@ func TestExportAllUserData_ExportMetadata(t *testing.T) { if metadata["user_id"] != userID { t.Errorf("expected export_metadata.user_id %q, got %v", userID, metadata["user_id"]) } - if metadata["format_version"] != "1.0" { - t.Errorf("expected format_version '1.0', got %v", metadata["format_version"]) + if metadata["format_version"] != "1.1" { + t.Errorf("expected format_version '1.1', got %v", metadata["format_version"]) } if metadata["exported_by"] != "system" { t.Errorf("expected exported_by 'system', got %v", metadata["exported_by"]) diff --git a/backend/internal/square/square_dev.go b/backend/internal/square/square_dev.go index 454c4d1..e230880 100644 --- a/backend/internal/square/square_dev.go +++ b/backend/internal/square/square_dev.go @@ -315,10 +315,11 @@ func (m *MockClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq) paymentID := fmt.Sprintf("pay_mock_%d", payNow.UnixNano()) amount := req.Amount tipAmount := int64(0) - if req.AllowTipping { - tipAmount = 500 - amount += tipAmount - } + // Real Square does NOT add a tip to the checkout amount when + // AllowTipping is true — it only enables a tip prompt on the + // Terminal. The frontend already embeds any tip in req.Amount, so + // the mock must charge exactly req.Amount too (a fixed +500p here + // double-counted the tip the customer actually agreed to). fees := amount * 175 / 10000 // in-person rate: 1.75% expMonth := 12 diff --git a/backend/internal/square/square_dev_test.go b/backend/internal/square/square_dev_test.go index cc491cb..d5d7f5d 100644 --- a/backend/internal/square/square_dev_test.go +++ b/backend/internal/square/square_dev_test.go @@ -91,8 +91,8 @@ func TestDevClient_CreateCheckout_PendingThenCompleted(t *testing.T) { return getErr == nil && completed.Status == "COMPLETED" }, 5*time.Second, 100*time.Millisecond, "expected checkout to complete") - assert.Equal(t, int64(8000), completed.Amount, "expected amount 8000 (7500 + 500 tip)") - assert.Equal(t, int64(500), completed.TipAmount) + assert.Equal(t, int64(7500), completed.Amount, "expected amount 7500 (real Square does not add a tip to the checkout amount)") + assert.Equal(t, int64(0), completed.TipAmount) // The completed terminal payment's SquarePayID must equal its ID too // (parity with paymentFromSquare), so a terminal-checkout payment recorded diff --git a/backend/main.go b/backend/main.go index fa88229..8736dc3 100644 --- a/backend/main.go +++ b/backend/main.go @@ -537,6 +537,7 @@ func main() { // Admin payment routes r.Post("/admin/bookings/{id}/payment", payments.CreateTerminalPayment) + r.Post("/admin/bookings/{id}/refund", payments.AdminRefundBooking) r.Get("/admin/payments/{checkout_id}/status", payments.GetCheckoutStatus) r.Post("/admin/payments/{payment_id}/refund", payments.RefundPayment) diff --git a/frontend/src/lib/components/admin/BookingModal.svelte b/frontend/src/lib/components/admin/BookingModal.svelte index 1c50aed..47f5b28 100644 --- a/frontend/src/lib/components/admin/BookingModal.svelte +++ b/frontend/src/lib/components/admin/BookingModal.svelte @@ -37,7 +37,12 @@ // Derived values for cancel confirmation const totalPaid = $derived( (selectedBooking?.payments ?? []) - .filter((p) => p.payment_method !== 'discount' && p.status === 'completed') + .filter( + (p) => + p.payment_method !== 'discount' && + p.payment_method !== 'on_the_house' && + p.status === 'completed' + ) .reduce((sum, p) => sum + p.amount, 0) ); const totalRefunds = $derived( diff --git a/frontend/src/lib/components/admin/EditBookingModal.svelte b/frontend/src/lib/components/admin/EditBookingModal.svelte index 9653e32..e2c2f30 100644 --- a/frontend/src/lib/components/admin/EditBookingModal.svelte +++ b/frontend/src/lib/components/admin/EditBookingModal.svelte @@ -606,7 +606,7 @@ })} - {#if payment.status === 'completed'} + {#if payment.status === 'completed' && payment.payment_method !== 'discount' && payment.payment_method !== 'on_the_house'} + {/if} diff --git a/frontend/src/lib/components/payments/UserPaymentModal.svelte b/frontend/src/lib/components/payments/UserPaymentModal.svelte index ea307ea..58e9950 100644 --- a/frontend/src/lib/components/payments/UserPaymentModal.svelte +++ b/frontend/src/lib/components/payments/UserPaymentModal.svelte @@ -964,7 +964,7 @@
Amount - {formatCurrency(paymentResult.amount)} + {formatCurrency(paymentResult.amount / 100)}
diff --git a/frontend/src/routes/cancellation-policy/+page.svelte b/frontend/src/routes/cancellation-policy/+page.svelte index eb0ed89..5c82184 100644 --- a/frontend/src/routes/cancellation-policy/+page.svelte +++ b/frontend/src/routes/cancellation-policy/+page.svelte @@ -44,7 +44,7 @@

Booking, Deposit & Cancellation Policy

-

Last updated: 18 June 2026

+

Last updated: 5 August 2026

{#if format === 'pdf' && pdfNotice}

@@ -66,6 +66,11 @@ on your account, appointments must be scheduled at least 36 hours in advance.

+

+ Payments toward your booking are capped at 100% of the total booking value based on service prices at time of booking. + Once you have paid the full amount, any additional payments above 100% of the booking value will be processed as tips (see + Section 8 below). +

To maintain fairness and prevent scheduling abuse, accounts with outstanding deposit requirements are limited to one (1) active booking at any given time. No additional @@ -100,26 +105,28 @@

3. Cancellation & Refund Tiers

+

Refund before service

- We understand that plans can change. Eligibility for a refund depends strictly on the amount + We understand that plans can change. Eligibility for a refund before your service depends strictly on the amount of notice provided prior to your scheduled appointment time. These thresholds represent a genuine pre-estimate of the operational costs and loss of business incurred by late - cancellations: + cancellations. Refunds apply to booking payments only, up to 100% of the total + booking value.

+

Notice of more than 72 hours

- You are entitled to a full 100% refund of all deposits and advance payments made for the - booking. + You are entitled to a full 100% refund of all booking payments made.

Notice between 24 and 72 hours

- Any advance payments made up to 50% of the total booking value are treated as a + Any booking payments made up to 50% of the total booking value are treated as a Protected Deposit. This Protected Deposit is retained to cover the short-notice vacancy, while any balance paid above 50% will be fully refunded.

@@ -128,12 +135,21 @@

Notice of less than 24 hours

- All processed payments and deposits are entirely non-refundable and will be retained. - The cancellation will be logged as a missed appointment history strike. + All booking payments and deposits are entirely non-refundable and will be retained. The cancellation will be logged as a missed + appointment history strike.

+

Refund after service

+ +

+ Refunds after booked appoinments have been carried out are at the salon owners discretion based on the booking and reason, to arrange a refund please contact us to discuss a fair refund up to 100% of the value of the booking. Any paid tips will not be considered as part of the refund as they are processed differently. +

+ +

Refund Payment Method

Refunds are returned to the original payment method where possible: @@ -269,5 +285,30 @@ > to get in touch.

+ + +
+

8. Tips

+

+ Tips are voluntary payments made in addition to the booking total. Tips can only be added + after your booking has started — they cannot be paid in advance. +

+

+ Unlike booking payments, tips are not refundable. Tips are not considered + payment for the service itself, but rather a voluntary expression of satisfaction. If you + cancel your booking, any tips already paid will not be included in the cancellation refund. +

+

+ Tips can be added via your account after the booking has started, or at the time of payment + when paying in person at the salon. When paying by card at the terminal, you will be prompted + to add a tip if you wish. +

+

+ If you believe a tip was added in error, please contact us via our official + Contact Channels and we will review your case. +

+
diff --git a/frontend/src/routes/gdpr/+page.svelte b/frontend/src/routes/gdpr/+page.svelte index ea9cb1a..e039d87 100644 --- a/frontend/src/routes/gdpr/+page.svelte +++ b/frontend/src/routes/gdpr/+page.svelte @@ -176,6 +176,15 @@ booking_id?: string; changed_at: string; }>; + disputes?: Array<{ + dispute_id: string; + payment_id: string; + status: string; + amount: number; + reason?: string; + created_at: string; + updated_at: string; + }>; login_audit?: Array<{ attempt_type: string; ip_address: string; @@ -1212,6 +1221,44 @@ {/if} + + {#if gdprData.disputes && gdprData.disputes.length > 0} + + Payment Disputes + +
+ + + + {#each gdprData.disputes as d (d.dispute_id)} + + + + + + + + {/each} + +
DateAmountReasonPaymentStatus
{fmtDate(d.created_at)}{fmt(d.amount)}{d.reason || '—'}{d.payment_id}{d.status}
+
+
+
+ {/if} + {#if gdprData.edit_requests && gdprData.edit_requests.length > 0} diff --git a/init-scripts/init-script.sql b/init-scripts/init-script.sql index ece903b..27cce79 100644 --- a/init-scripts/init-script.sql +++ b/init-scripts/init-script.sql @@ -930,7 +930,6 @@ BEGIN phone = '+000000000000', -- NOT NULL column: use placeholder date_of_birth = '1900-01-01', -- NOT NULL column: use placeholder profile_pic_url = NULL, - notes = NULL, -- free-text staff notes may contain PII last_login_at = NULL, -- removes activity metadata (right to be forgotten) account_role = 'guest', loyalty_stamps = 0, @@ -1400,11 +1399,28 @@ BEGIN ) ORDER BY nh.changed_at DESC), '[]'::json) FROM name_history nh WHERE nh.user_id = target_user_id ), + 'disputes', ( + SELECT COALESCE(json_agg(json_build_object( + 'dispute_id', d.id, + 'payment_id', d.payment_id, + 'status', d.status, + 'amount', d.amount, + 'reason', d.reason, + 'created_at', d.created_at, + 'updated_at', d.updated_at + ) ORDER BY d.created_at DESC), '[]'::json) + FROM disputes d + JOIN payments p ON d.payment_id = p.id + LEFT JOIN bookings b ON p.booking_id = b.id + LEFT JOIN gift_cards gc ON p.gift_card_id = gc.id + WHERE b.user_id = target_user_id + OR gc.created_by = target_user_id + ), 'export_metadata', json_build_object( 'exported_at', NOW(), 'exported_by', 'system', 'user_id', target_user_id, - 'format_version', '1.0' + 'format_version', '1.1' ) ) INTO result;