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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user