fix: tip double-count, fully-paid auto-completion, discount-refund hardening

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

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

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

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

Tests:
- m4_tip_refund_redesign_test.go (tip split, refund exclusion, admin refund cap)
- m5_fully_paid_completion_test.go (online + terminal full-payment completion,
  partial stays active, tip excluded, cancelled stays cancelled)
- Full suite passes with -race (25 packages)
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 5e3dc9b428
commit 4b28e93710
23 changed files with 1953 additions and 366 deletions
+6 -305
View File
@@ -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
}
+416
View File
@@ -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)
}
}
@@ -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)
+27 -16
View File
@@ -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)
+621 -3
View File
@@ -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_<nanosecond>" 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:<payment_id>
// 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)
@@ -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")
}
@@ -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")
}
@@ -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)
@@ -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)")
}
// =============================================================================
+9 -9
View File
@@ -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 {
+1
View File
@@ -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 {
+27 -1
View File
@@ -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
+6 -5
View File
@@ -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"])