diff --git a/backend/handlers/payments/loyalty.go b/backend/handlers/payments/loyalty.go new file mode 100644 index 0000000..97a6083 --- /dev/null +++ b/backend/handlers/payments/loyalty.go @@ -0,0 +1,181 @@ +package payments + +import ( + "crussell/db" + "crussell/internal/validators" + "crussell/mw" + "encoding/json" + "errors" + "log" + "math" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5" +) + +func roundTo2(f float64) float64 { + return math.Round(f*100) / 100 +} + +// ApplyLoyaltyRedemption handles POST /api/bookings/{id}/apply-redemption. +// It applies a 10% loyalty discount to the booking using a pending redemption. +func ApplyLoyaltyRedemption(w http.ResponseWriter, r *http.Request) { + bookingID := chi.URLParam(r, "id") + if bookingID == "" || !validators.IsValidID(bookingID) { + http.Error(w, "Booking not found", http.StatusNotFound) + return + } + + userID, ok := r.Context().Value(mw.UserIDKey).(string) + if !ok || userID == "" { + http.Error(w, "Authentication required", http.StatusUnauthorized) + return + } + + var bookingUserID string + if err := db.DB.QueryRow(r.Context(), `SELECT user_id FROM bookings WHERE id = $1`, bookingID).Scan(&bookingUserID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + http.Error(w, "Booking not found", http.StatusNotFound) + return + } + log.Printf("Failed to verify booking ownership: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if bookingUserID != userID { + http.Error(w, "Unauthorized", http.StatusForbidden) + return + } + + var bookingStatus string + if err := db.DB.QueryRow(r.Context(), `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&bookingStatus); err != nil { + log.Printf("Failed to get booking status: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + switch bookingStatus { + case "completed", "cancelled", "no_show", "deposit_lapsed": + http.Error(w, "Booking is in a terminal state and cannot accept redemptions", http.StatusBadRequest) + return + } + + var loyaltyStamps int + if err := db.DB.QueryRow(r.Context(), `SELECT loyalty_stamps FROM users WHERE id = $1`, userID).Scan(&loyaltyStamps); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + http.Error(w, "User not found", http.StatusNotFound) + return + } + log.Printf("Failed to get loyalty stamps: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if loyaltyStamps < 10 { + http.Error(w, "Insufficient loyalty stamps", http.StatusBadRequest) + return + } + + var redemptionID string + if err := db.DB.QueryRow(r.Context(), ` + SELECT id FROM loyalty_redemptions + WHERE user_id = $1 AND status = 'pending' AND expires_at > NOW() + ORDER BY redeemed_at ASC LIMIT 1 + `, userID).Scan(&redemptionID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + http.Error(w, "No pending loyalty redemption found", http.StatusBadRequest) + return + } + log.Printf("Failed to check loyalty redemption: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + var existingDiscount int + if err := db.DB.QueryRow(r.Context(), ` + SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty' + `, bookingID).Scan(&existingDiscount); err == nil { + http.Error(w, "A loyalty discount has already been applied to this booking", http.StatusBadRequest) + return + } + + tx, err := db.DB.Begin(r.Context()) + if err != nil { + log.Printf("Failed to begin transaction: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + defer tx.Rollback(r.Context()) + + var bookingTotal float64 + if err := tx.QueryRow(r.Context(), ` + SELECT COALESCE(SUM(price_val), 0) FROM ( + SELECT COALESCE(bs.override_price, s.price) AS price_val + FROM booking_services bs + JOIN services s ON bs.service_id = s.id + WHERE bs.booking_id = $1 + UNION ALL + SELECT COALESCE(bcs.override_price, cs.price) + FROM booking_custom_services bcs + JOIN custom_services cs ON bcs.custom_service_id = cs.id + WHERE bcs.booking_id = $1 + ) sub + `, bookingID).Scan(&bookingTotal); err != nil { + log.Printf("Failed to calculate booking total: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + discountAmount := roundTo2(bookingTotal * 0.10) + + if discountAmount <= 0 { + http.Error(w, "Booking total is zero, no discount applicable", http.StatusBadRequest) + return + } + + 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, 'loyalty', $3, NULL, NULL, 10.00, $4, $5) + `, bookingID, userID, redemptionID, bookingTotal, discountAmount); err != nil { + log.Printf("Failed to insert booking discount: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + 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, userID); err != nil { + log.Printf("Failed to insert payment record: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + if _, err := tx.Exec(r.Context(), ` + UPDATE loyalty_redemptions SET status = 'applied', applied_to_booking_id = $1, applied_at = NOW() + WHERE id = $2 + `, bookingID, redemptionID); err != nil { + log.Printf("Failed to update loyalty redemption: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + if _, err := tx.Exec(r.Context(), ` + UPDATE users SET loyalty_stamps = GREATEST(0, loyalty_stamps - $1) WHERE id = $2 + `, LoyaltyStampCost, userID); err != nil { + log.Printf("Failed to update loyalty stamps: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + if err := tx.Commit(r.Context()); err != nil { + log.Printf("Failed to commit transaction: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "discount_amount": discountAmount, + }) +} diff --git a/backend/handlers/payments/loyalty_test.go b/backend/handlers/payments/loyalty_test.go new file mode 100644 index 0000000..8fef26a --- /dev/null +++ b/backend/handlers/payments/loyalty_test.go @@ -0,0 +1,446 @@ +//go:build test && dev +// +build test,dev + +package payments + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "crussell/db" + "crussell/mw" + "crussell/testutils/fixtures" + "crussell/testutils/jwt" + + "github.com/go-chi/chi/v5" +) + +// ============================================================================= +// ApplyLoyaltyRedemption — POST /api/bookings/{id}/apply-redemption +// ============================================================================= + +func setupLoyaltyUser(t *testing.T, stamps int) (string, string, string) { + t.Helper() + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + + _, err = db.DB.Exec(context.Background(), + "UPDATE users SET loyalty_stamps = $1 WHERE id = $2", stamps, userID) + if err != nil { + t.Fatalf("failed to set loyalty_stamps: %v", err) + } + + // Create a pending loyalty_redemption if stamps >= 10 + if stamps >= 10 { + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO loyalty_redemptions (user_id, status, redeemed_at, expires_at) + VALUES ($1, 'pending', NOW(), NOW() + INTERVAL '6 months') + `, userID) + if err != nil { + t.Fatalf("failed to create loyalty redemption: %v", err) + } + } + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + + bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + + _, err = db.DB.Exec(context.Background(), + "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) + if err != nil { + t.Fatalf("failed to set booking status: %v", err) + } + + userToken := jwt.GenerateUserToken(userID) + return userID, bookingID, userToken +} + +func makeApplyRedemptionRequest(bookingID, token string) *httptest.ResponseRecorder { + handler := http.HandlerFunc(ApplyLoyaltyRedemption) + + req := httptest.NewRequest("POST", "/api/bookings/"+bookingID+"/apply-redemption", nil) + req.Header.Set("Authorization", "Bearer "+token) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", bookingID) + ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) + + userID := extractUserFromTestJWT(token) + if userID != nil { + ctx = context.WithValue(ctx, mw.UserIDKey, userID.userID) + ctx = context.WithValue(ctx, mw.UserRoleKey, userID.role) + } + + req = req.WithContext(ctx) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + return w +} + +func TestApplyLoyaltyRedemption_Success(t *testing.T) { + resetTestData(t) + _, bookingID, userToken := setupLoyaltyUser(t, 10) + + w := makeApplyRedemptionRequest(bookingID, userToken) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + if resp["success"] != true { + t.Error("expected success=true") + } + discountAmount, ok := resp["discount_amount"].(float64) + if !ok || discountAmount <= 0 { + t.Errorf("expected positive discount_amount, got %v", resp["discount_amount"]) + } + + // Verify booking_discounts was created + var discountCount int + err := db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty'", bookingID).Scan(&discountCount) + if err != nil { + t.Fatalf("failed to query booking_discounts: %v", err) + } + if discountCount != 1 { + t.Errorf("expected 1 loyalty discount record, got %d", discountCount) + } + + // Verify stamps were deducted (10 - 10 = 0) + var stamps int + err = db.DB.QueryRow(context.Background(), "SELECT loyalty_stamps FROM users WHERE id = (SELECT user_id FROM bookings WHERE id = $1)", bookingID).Scan(&stamps) + if err != nil { + t.Fatalf("failed to query stamps: %v", err) + } + if stamps != 0 { + t.Errorf("expected 0 stamps after redemption, got %d", stamps) + } + + // Verify a discount payment record was created + var paymentCount int + err = db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'discount'", bookingID).Scan(&paymentCount) + if err != nil { + t.Fatalf("failed to query payments: %v", err) + } + if paymentCount != 1 { + t.Errorf("expected 1 discount payment record, got %d", paymentCount) + } +} + +func TestApplyLoyaltyRedemption_InsufficientStamps(t *testing.T) { + resetTestData(t) + _, bookingID, userToken := setupLoyaltyUser(t, 5) + + w := makeApplyRedemptionRequest(bookingID, userToken) + if w.Code != http.StatusBadRequest && w.Code != http.StatusConflict { + t.Fatalf("expected 4xx for insufficient stamps, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestApplyLoyaltyRedemption_AlreadyApplied(t *testing.T) { + resetTestData(t) + _, bookingID, userToken := setupLoyaltyUser(t, 10) + + // First call should succeed + w := makeApplyRedemptionRequest(bookingID, userToken) + if w.Code != http.StatusOK { + t.Fatalf("first call expected 200, got %d: %s", w.Code, w.Body.String()) + } + + // Second call should be rejected + w = makeApplyRedemptionRequest(bookingID, userToken) + if w.Code != http.StatusConflict && w.Code != http.StatusBadRequest { + t.Fatalf("second call expected 4xx, got %d: %s", w.Code, w.Body.String()) + } + + // Verify still only 1 discount record + var discountCount int + db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty'", bookingID).Scan(&discountCount) + if discountCount != 1 { + t.Errorf("expected 1 loyalty discount record, got %d", discountCount) + } +} + +func TestApplyLoyaltyRedemption_TerminalBooking(t *testing.T) { + resetTestData(t) + _, bookingID, userToken := setupLoyaltyUser(t, 10) + + // Set booking to a terminal status + _, err := db.DB.Exec(context.Background(), + "UPDATE bookings SET status = 'completed' WHERE id = $1", bookingID) + if err != nil { + t.Fatalf("failed to set booking status: %v", err) + } + + w := makeApplyRedemptionRequest(bookingID, userToken) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for terminal booking, got %d: %s", w.Code, w.Body.String()) + } +} + +// ============================================================================= +// applyEligibleCampaignsAtPayment — campaign auto-apply at payment time +// ============================================================================= + +func setupCampaignTest(t *testing.T) (string, string, string) { + t.Helper() + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + + bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + + _, err = db.DB.Exec(context.Background(), + "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) + if err != nil { + t.Fatalf("failed to set booking status: %v", err) + } + + userToken := jwt.GenerateUserToken(userID) + return userID, bookingID, userToken +} + +func TestCampaignAutoApply_TimeBased(t *testing.T) { + resetTestData(t) + userID, bookingID, _ := setupCampaignTest(t) + + // Create an active time-based campaign + now := time.Now() + var campaignID string + err := db.DB.QueryRow(context.Background(), ` + INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed) + VALUES ($1, 'time_based', 10, 'active', $2, $3, 0) + RETURNING id + `, "Early Payment Sale", now.Add(-24*time.Hour), now.Add(24*time.Hour)).Scan(&campaignID) + if err != nil { + t.Fatalf("failed to create campaign: %v", err) + } + + // Insert a deposit payment to trigger campaign auto-apply + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at) + VALUES ($1, 'deposit', 'online_square', 2500, 'completed', NOW(), NOW()) + `, bookingID) + if err != nil { + t.Fatalf("failed to create payment: %v", err) + } + + // Call applyEligibleCampaignsAtPayment + applyEligibleCampaignsAtPayment(context.Background(), bookingID, userID) + + // Verify booking_discounts was created + var discountCount int + db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign'", bookingID).Scan(&discountCount) + if discountCount != 1 { + t.Errorf("expected 1 campaign discount, got %d", discountCount) + } + + // Verify times_redeemed was incremented + var timesRedeemed int + db.DB.QueryRow(context.Background(), + "SELECT times_redeemed FROM discount_campaigns WHERE id = $1", campaignID).Scan(×Redeemed) + if timesRedeemed != 1 { + t.Errorf("expected 1 redemption, got %d", timesRedeemed) + } +} + +func TestCampaignAutoApply_UserMilestone(t *testing.T) { + resetTestData(t) + userID, bookingID, _ := setupCampaignTest(t) + + // Give user 5 completed bookings to match milestone_value=5 + for i := 0; i < 5; i++ { + var bid string + db.DB.QueryRow(context.Background(), ` + INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'completed') RETURNING id + `, userID, time.Date(2024, time.Month(i+1), 15, 10, 0, 0, 0, time.UTC)).Scan(&bid) + } + + // Create user milestone campaign for 5th booking + var campaignID string + err := db.DB.QueryRow(context.Background(), ` + INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value, max_redemptions, times_redeemed) + VALUES ($1, 'milestone', 15, 'active', NOW(), NOW() + INTERVAL '1 year', 'per_user_booking_count', 5, 1, 0) + RETURNING id + `, "5th Booking Bonus").Scan(&campaignID) + if err != nil { + t.Fatalf("failed to create campaign: %v", err) + } + + // Insert a payment + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at) + VALUES ($1, 'deposit', 'online_square', 2500, 'completed', NOW(), NOW()) + `, bookingID) + if err != nil { + t.Fatalf("failed to create payment: %v", err) + } + + applyEligibleCampaignsAtPayment(context.Background(), bookingID, userID) + + var discountCount int + db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign'", bookingID).Scan(&discountCount) + if discountCount != 1 { + t.Errorf("expected 1 campaign discount, got %d", discountCount) + } +} + +func TestCampaignAutoApply_GlobalMilestoneSkippedOnline(t *testing.T) { + resetTestData(t) + userID, bookingID, _ := setupCampaignTest(t) + + // Set global completed count high enough + now := time.Now() + for i := 0; i < 100; i++ { + db.DB.QueryRow(context.Background(), ` + INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'completed') RETURNING id + `, userID, now.Add(-time.Duration(i)*24*time.Hour)).Scan(new(string)) + } + + // Create global milestone campaign at milestone_value=100 + var campaignID string + err := db.DB.QueryRow(context.Background(), ` + INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value, max_redemptions, times_redeemed) + VALUES ($1, 'milestone', 20, 'active', NOW(), NOW() + INTERVAL '1 year', 'global_booking_count', 100, 5, 0) + RETURNING id + `, "100th Booking Celebration").Scan(&campaignID) + if err != nil { + t.Fatalf("failed to create campaign: %v", err) + } + + // Insert an ONLINE payment first (not in_person_card) + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at) + VALUES ($1, 'deposit', 'online_square', 2500, 'completed', NOW(), NOW()) + `, bookingID) + if err != nil { + t.Fatalf("failed to create payment: %v", err) + } + + applyEligibleCampaignsAtPayment(context.Background(), bookingID, userID) + + // Verify NO discount was applied (global milestone skipped for online payment) + var discountCount int + db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign'", bookingID).Scan(&discountCount) + if discountCount != 0 { + t.Errorf("expected 0 campaign discounts (global milestone skipped for online), got %d", discountCount) + } +} + +func TestCampaignAutoApply_GlobalMilestoneAppliedInPerson(t *testing.T) { + resetTestData(t) + userID, bookingID, _ := setupCampaignTest(t) + + for i := 0; i < 100; i++ { + db.DB.QueryRow(context.Background(), ` + INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'completed') RETURNING id + `, userID, time.Date(2024, time.Month(i%12+1), 15, 10, 0, 0, 0, time.UTC)).Scan(new(string)) + } + + var campaignID string + err := db.DB.QueryRow(context.Background(), ` + INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value, max_redemptions, times_redeemed) + VALUES ($1, 'milestone', 20, 'active', NOW(), NOW() + INTERVAL '1 year', 'global_booking_count', 100, 5, 0) + RETURNING id + `, "100th Booking Celebration").Scan(&campaignID) + if err != nil { + t.Fatalf("failed to create campaign: %v", err) + } + + // Insert an IN-PERSON payment + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at) + VALUES ($1, 'full', 'in_person_card', 5000, 'completed', NOW(), NOW()) + `, bookingID) + if err != nil { + t.Fatalf("failed to create payment: %v", err) + } + + // Simulate what CreateBookingPayment does: set status to confirmed after payment + db.DB.Exec(context.Background(), + "UPDATE bookings SET status = 'confirmed', updated_at = NOW() WHERE id = $1", bookingID) + + applyEligibleCampaignsAtPayment(context.Background(), bookingID, userID) + + var discountCount int + db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign'", bookingID).Scan(&discountCount) + if discountCount != 1 { + t.Errorf("expected 1 campaign discount (in-person global milestone), got %d", discountCount) + } +} + +func TestCampaignAutoApply_DoubleApplyGuard(t *testing.T) { + resetTestData(t) + userID, bookingID, _ := setupCampaignTest(t) + + now := time.Now() + var campaignID string + err := db.DB.QueryRow(context.Background(), ` + INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed) + VALUES ($1, 'time_based', 10, 'active', $2, $3, 0) + RETURNING id + `, "Test Sale", now.Add(-24*time.Hour), now.Add(24*time.Hour)).Scan(&campaignID) + if err != nil { + t.Fatalf("failed to create campaign: %v", err) + } + + // Manually insert a booking_discount to simulate it was already applied at payment time + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, discount_percent, original_total, discount_amount) + VALUES ($1, $2, 'campaign', $3, 'time_based', 10, 5000, 500) + `, bookingID, userID, campaignID) + if err != nil { + t.Fatalf("failed to insert existing discount: %v", err) + } + + // Pretend campaign was already redeemed + db.DB.Exec(context.Background(), + "UPDATE discount_campaigns SET times_redeemed = 1 WHERE id = $1", campaignID) + + // Insert payment to trigger applyEligibleCampaignsAtPayment + db.DB.Exec(context.Background(), ` + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at) + VALUES ($1, 'deposit', 'online_square', 2500, 'completed', NOW(), NOW()) + `, bookingID) + + applyEligibleCampaignsAtPayment(context.Background(), bookingID, userID) + + // Verify still only 1 discount + var discountCount int + db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign'", bookingID).Scan(&discountCount) + if discountCount != 1 { + t.Errorf("expected 1 campaign discount (no double-apply), got %d", discountCount) + } +} diff --git a/backend/handlers/payments/refund_policy.go b/backend/handlers/payments/refund_policy.go new file mode 100644 index 0000000..0736247 --- /dev/null +++ b/backend/handlers/payments/refund_policy.go @@ -0,0 +1,20 @@ +package payments + +import "time" + +const ( + FullRefundThreshold = 72 * time.Hour + PartialRefundThreshold = 24 * time.Hour + NoShowThreshold = 24 * time.Hour + DepositDeadlineWindow = 24 * time.Hour + DepositAdvanceWindow = 36 * time.Hour + + FullRefundTier = "full_refund_72h" + PartialRefundTier = "partial_refund_24h_72h" + NoRefundTier = "no_refund_under_24h" + + ProtectedDepositMaxPct = 0.50 + RequiredDepositPct = 0.20 + + LoyaltyStampCost = 10 +) diff --git a/backend/handlers/payments/refunds.go b/backend/handlers/payments/refunds.go new file mode 100644 index 0000000..12eee00 --- /dev/null +++ b/backend/handlers/payments/refunds.go @@ -0,0 +1,280 @@ +package payments + +import ( + "context" + "log" + "math" + "time" + + "crussell/db" + "crussell/internal/square" +) + +type RefundCalculationResult struct { + TotalPrePaid float64 `json:"total_pre_paid"` + ProtectedDeposit float64 `json:"protected_deposit"` + RefundableAmount float64 `json:"refundable_amount"` + KeptAmount float64 `json:"kept_amount"` + HoursUntilAppointment float64 `json:"hours_until_appointment"` + Tier string `json:"tier"` +} + +// CalculateRefundForCancellation computes the refund amounts for a cancelled booking. +// +// Track A (universal — same rules for all bookings): +// - >72 hours notice: Full refund of all pre-payments +// - 24-72 hours notice: Keep protected deposit (up to 50%), refund the rest +// - <24 hours or no-show: Keep all pre-payments +// +// The "protected deposit" is defined as min(totalPrePaid, subtotal * 0.50). +// This means up to 50% of the subtotal is always treated as a deposit for +// refund purposes, regardless of whether deposit_required was set on the booking. +func CalculateRefundForCancellation( + subtotal float64, + totalPrePaid float64, + cancellationTime time.Time, + startTime time.Time, +) RefundCalculationResult { + hoursUntilAppointment := startTime.Sub(cancellationTime).Hours() + + protectedDeposit := math.Min(totalPrePaid, subtotal*ProtectedDepositMaxPct) + + // Round to 2 decimal places + protectedDeposit = math.Round(protectedDeposit*100) / 100 + totalPrePaidRounded := math.Round(totalPrePaid*100) / 100 + + var refundableAmount, keptAmount float64 + var tier string + + fullHrs := FullRefundThreshold.Hours() + partHrs := PartialRefundThreshold.Hours() + + switch { + case hoursUntilAppointment > fullHrs: + refundableAmount = totalPrePaidRounded + keptAmount = 0 + tier = FullRefundTier + + case hoursUntilAppointment >= partHrs: + keptAmount = protectedDeposit + refundableAmount = totalPrePaidRounded - keptAmount + if refundableAmount < 0 { + refundableAmount = 0 + } + tier = PartialRefundTier + + default: + keptAmount = totalPrePaidRounded + refundableAmount = 0 + tier = NoRefundTier + } + + return RefundCalculationResult{ + TotalPrePaid: totalPrePaidRounded, + ProtectedDeposit: protectedDeposit, + RefundableAmount: refundableAmount, + KeptAmount: keptAmount, + HoursUntilAppointment: hoursUntilAppointment, + Tier: tier, + } +} + +// ProcessCancellationRefund calculates and records refunds for a cancelled booking. +// It processes refunds against completed payments on the booking up to the +// calculated refundable amount, creating refund records in the database. +// Returns the refund calculation and whether any refunds were processed. +func ProcessCancellationRefund( + ctx context.Context, + bookingID string, + subtotal float64, + totalPrePaid float64, + startTime time.Time, + cancellationTime time.Time, + reason string, + actorID *string, +) (*RefundCalculationResult, error) { + calc := CalculateRefundForCancellation(subtotal, totalPrePaid, cancellationTime, startTime) + + if calc.RefundableAmount <= 0 { + return &calc, nil + } + + // Get the booking's user info for refund routing. + var bookingUserID string + var isGuest bool + if err := db.DB.QueryRow(ctx, ` + 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 { + log.Printf("Failed to get booking user info for refund: %v", err) + // Non-fatal — we'll still process Square refunds but skip balance credits. + } + + rows, err := db.DB.Query(ctx, ` + 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') + ORDER BY created_at ASC + `, bookingID) + if err != nil { + log.Printf("Failed to fetch payments for refund: %v", err) + return &calc, nil + } + defer rows.Close() + + refundRemaining := calc.RefundableAmount + // Track which Square payment IDs have already been refunded through Square. + // Multiple split payment records (deposit + balance) can share the same + // square_payment_id — we must only refund each Square payment once. + refundedSquareIDs := make(map[string]bool) + + for rows.Next() { + if refundRemaining <= 0 { + break + } + + var paymentID, paymentMethod string + var amount float64 + var squarePaymentID *string + var giftCardID *string + if err := rows.Scan(&paymentID, &amount, &paymentMethod, &squarePaymentID, &giftCardID); err != nil { + log.Printf("Failed to scan payment row: %v", err) + continue + } + + refundThisPayment := math.Min(amount, refundRemaining) + refundCents := int64(math.Round(refundThisPayment * 100)) + var squareRefundID *string + + switch paymentMethod { + case "online_square", "in_person_card": + // Card payments can be refunded through Square if we have a payment reference. + // Skip the Square API call if this square_payment_id was already processed + // (possible when split payment records share the same charge). + if squarePaymentID != nil && *squarePaymentID != "" && !refundedSquareIDs[*squarePaymentID] { + refundReq := square.RefundPaymentReq{ + PaymentID: *squarePaymentID, + Amount: refundCents, + IdempotencyKey: paymentID + "-cancel-" + time.Now().Format("20060102150405"), + Reason: reason, + } + result, sqErr := SquareClient.RefundPayment(ctx, refundReq) + if sqErr != nil { + log.Printf("Square refund failed for payment %s (will record refund locally): %v", paymentID, sqErr) + } else { + squareRefundID = &result.ID + refundedSquareIDs[*squarePaymentID] = true + } + } else if squarePaymentID != nil && refundedSquareIDs[*squarePaymentID] { + log.Printf("Square payment %s already refunded through split record %s — crediting balance for £%.2f", *squarePaymentID, paymentID, refundThisPayment) + } + + // If Square refund failed or wasn't available, credit the user's balance. + // Guests don't get balance credits — admin handles those manually. + if squareRefundID == nil && bookingUserID != "" { + if isGuest { + log.Printf("Guest card refund (Square unavailable): booking %s, payment %s, amount £%.2f — admin must process at till", bookingID, paymentID, refundThisPayment) + } else { + log.Printf("Crediting £%.2f to user %s balance for card payment %s (Square refund unavailable)", refundThisPayment, bookingUserID, paymentID) + creditUserBalance(ctx, bookingUserID, bookingID, paymentID, refundThisPayment, reason) + } + } + + case "giftcard": + if giftCardID == nil || *giftCardID == "" { + log.Printf("Giftcard payment %s has no gift_card_id — cannot refund to card. Skipping.", paymentID) + break + } + var expired bool + if err := db.DB.QueryRow(ctx, ` + SELECT expiry_date IS NOT NULL AND expiry_date < NOW() + FROM gift_cards WHERE id = $1 + `, *giftCardID).Scan(&expired); err != nil { + log.Printf("Failed to check gift card %s expiry: %v — proceeding with refund", *giftCardID, err) + } else if expired { + log.Printf("Gift card %s has expired — money retained by salon, no refund due for booking %s", *giftCardID, bookingID) + break + } + if _, err := db.DB.Exec(ctx, ` + UPDATE gift_cards SET amount_remaining = amount_remaining + $1, last_used_at = NOW() + WHERE id = $2 + `, refundThisPayment, *giftCardID); err != nil { + log.Printf("Failed to refund £%.2f to gift card %s: %v", refundThisPayment, *giftCardID, err) + break + } + if _, err := db.DB.Exec(ctx, ` + 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) + `, *giftCardID, refundThisPayment, bookingID, bookingUserID, "Refund from cancelled booking"); err != nil { + log.Printf("Failed to create gift card transaction for refund: %v", err) + } + + case "cash": + if isGuest || bookingUserID == "" { + log.Printf("Guest cash refund: booking %s, payment %s, amount £%.2f — admin must process cash refund at till", bookingID, paymentID, refundThisPayment) + } else { + log.Printf("Crediting £%.2f to user %s balance for cash payment %s", refundThisPayment, bookingUserID, paymentID) + creditUserBalance(ctx, bookingUserID, bookingID, paymentID, refundThisPayment, reason) + } + + default: + // discount, on_the_house — no real money to refund. + log.Printf("Skipping refund for payment %s with method %q (no money exchanged)", paymentID, paymentMethod) + } + + record := RefundRecord{ + PaymentID: paymentID, + BookingID: bookingID, + Amount: refundThisPayment, + SquareRefundID: squareRefundID, + Status: "completed", + Reason: reason, + CreatedBy: actorID, + CreatedAt: time.Now(), + } + + _, dbErr := db.DB.Exec(ctx, ` + INSERT INTO refunds (payment_id, booking_id, amount, square_refund_id, status, reason, created_by, created_at) + VALUES ($1, $2, $3, $4, 'completed', $5, $6, $7) + `, record.PaymentID, record.BookingID, record.Amount, record.SquareRefundID, record.Reason, record.CreatedBy, record.CreatedAt) + if dbErr != nil { + log.Printf("Failed to create refund record for payment %s: %v", paymentID, dbErr) + continue + } + + refundRemaining -= refundThisPayment + } + + if bookingUserID != "" { + var loyaltyUsed bool + db.DB.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty')", bookingID).Scan(&loyaltyUsed) + if loyaltyUsed { + _, err := db.DB.Exec(ctx, "UPDATE users SET loyalty_stamps = loyalty_stamps + $1 WHERE id = $2", LoyaltyStampCost, bookingUserID) + if err != nil { + log.Printf("Failed to refund loyalty stamps for booking %s: %v", bookingID, err) + } else { + log.Printf("Refunded 10 loyalty stamps to user %s after cancellation of booking %s", bookingUserID, bookingID) + } + } + } + + return &calc, nil +} + +// creditUserBalance credits a refund amount to the user's gift card balance. +// The refunds table (payment_id, booking_id, amount, reason, created_by, created_at) +// provides the primary audit trail for FreeAgent reconciliation. +func creditUserBalance(ctx context.Context, userID, bookingID, paymentID string, amount float64, reason string) { + _, err := db.DB.Exec(ctx, ` + 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() + `, userID, amount) + if err != nil { + log.Printf("Failed to credit user %s balance for refund of booking %s: %v", userID, bookingID, err) + } +} diff --git a/backend/handlers/payments/refunds_test.go b/backend/handlers/payments/refunds_test.go new file mode 100644 index 0000000..4839f33 --- /dev/null +++ b/backend/handlers/payments/refunds_test.go @@ -0,0 +1,939 @@ +//go:build test && dev +// +build test,dev + +package payments + +import ( + "context" + "testing" + "time" + + "crussell/db" + "crussell/testutils/fixtures" +) + +// ============================================================================= +// CalculateRefundForCancellation - Pure function tests +// ============================================================================= + +func TestCalculateRefundForCancellation_FullRefund_Over72h(t *testing.T) { + now := time.Date(2099, 12, 28, 8, 0, 0, 0, time.UTC) + start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) // >72h away + + result := CalculateRefundForCancellation(100, 50, now, start) + + if result.Tier != "full_refund_72h" { + t.Errorf("expected tier 'full_refund_72h', got %q", result.Tier) + } + if result.RefundableAmount != 50 { + t.Errorf("expected refundable 50, got %.2f", result.RefundableAmount) + } + if result.KeptAmount != 0 { + t.Errorf("expected kept 0, got %.2f", result.KeptAmount) + } + if result.ProtectedDeposit != 50 { + t.Errorf("expected protected deposit 50, got %.2f", result.ProtectedDeposit) + } +} + +func TestCalculateRefundForCancellation_PartialRefund_24to72h(t *testing.T) { + now := time.Date(2099, 12, 30, 8, 0, 0, 0, time.UTC) // ~50h before + start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) + + result := CalculateRefundForCancellation(100, 80, now, start) + + if result.Tier != "partial_refund_24h_72h" { + t.Errorf("expected tier 'partial_refund_24h_72h', got %q", result.Tier) + } + // Protected deposit: min(80, 50) = 50 + // Refundable: 80 - 50 = 30 + if result.ProtectedDeposit != 50 { + t.Errorf("expected protected deposit 50, got %.2f", result.ProtectedDeposit) + } + if result.RefundableAmount != 30 { + t.Errorf("expected refundable 30, got %.2f", result.RefundableAmount) + } + if result.KeptAmount != 50 { + t.Errorf("expected kept 50, got %.2f", result.KeptAmount) + } +} + +func TestCalculateRefundForCancellation_NoRefund_Under24h(t *testing.T) { + now := time.Date(2099, 12, 31, 9, 0, 0, 0, time.UTC) // 1h before + start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) + + result := CalculateRefundForCancellation(100, 100, now, start) + + if result.Tier != "no_refund_under_24h" { + t.Errorf("expected tier 'no_refund_under_24h', got %q", result.Tier) + } + if result.RefundableAmount != 0 { + t.Errorf("expected refundable 0, got %.2f", result.RefundableAmount) + } + if result.KeptAmount != 100 { + t.Errorf("expected kept 100, got %.2f", result.KeptAmount) + } +} + +func TestCalculateRefundForCancellation_NoShow_KeptAll(t *testing.T) { + now := time.Date(2099, 12, 31, 12, 0, 0, 0, time.UTC) // past start + start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) + + result := CalculateRefundForCancellation(100, 50, now, start) + + if result.Tier != "no_refund_under_24h" { + t.Errorf("expected tier 'no_refund_under_24h', got %q", result.Tier) + } + if result.RefundableAmount != 0 { + t.Errorf("expected refundable 0 for no-show, got %.2f", result.RefundableAmount) + } +} + +func TestCalculateRefundForCancellation_ProtectedDepositCappedAt50Pct(t *testing.T) { + now := time.Date(2099, 12, 30, 8, 0, 0, 0, time.UTC) + start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) + + // Paid 200 on a 300 total — protected deposit caps at 150 (50% of 300) + result := CalculateRefundForCancellation(300, 200, now, start) + + if result.ProtectedDeposit != 150 { + t.Errorf("expected protected deposit 150 (50%% of 300), got %.2f", result.ProtectedDeposit) + } + if result.RefundableAmount != 50 { + t.Errorf("expected refundable 50 (200-150), got %.2f", result.RefundableAmount) + } +} + +func TestCalculateRefundForCancellation_PaidLessThan50Pct(t *testing.T) { + now := time.Date(2099, 12, 30, 8, 0, 0, 0, time.UTC) + start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) + + // Paid 30 on a 200 total — protected deposit = min(30, 100) = 30 + result := CalculateRefundForCancellation(200, 30, now, start) + + if result.ProtectedDeposit != 30 { + t.Errorf("expected protected deposit 30, got %.2f", result.ProtectedDeposit) + } + if result.RefundableAmount != 0 { + t.Errorf("expected refundable 0 (30-30), got %.2f", result.RefundableAmount) + } +} + +func TestCalculateRefundForCancellation_Exact72hBoundary(t *testing.T) { + start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) + now := start.Add(-72 * time.Hour) // exactly 72h before (not >72) + + result := CalculateRefundForCancellation(100, 100, now, start) + + // Exactly 72h is NOT >72 — falls into partial refund tier + if result.Tier != "partial_refund_24h_72h" { + t.Errorf("expected partial refund at exactly 72h, got %q", result.Tier) + } +} + +func TestCalculateRefundForCancellation_Exact24hBoundary(t *testing.T) { + start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) + now := start.Add(-24 * time.Hour) // exactly 24h before + + result := CalculateRefundForCancellation(100, 100, now, start) + + // Exactly 24h should be >=24 — partial refund + if result.Tier != "partial_refund_24h_72h" { + t.Errorf("expected partial refund at exactly 24h, got %q", result.Tier) + } +} + +// ============================================================================= +// ProcessCancellationRefund - Integration tests +// ============================================================================= + +func TestProcessCancellationRefund_CreatesRefundRecords(t *testing.T) { + resetTestData(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) + + bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, + time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) }) + + _, err = db.DB.Exec(context.Background(), + "UPDATE bookings SET deposit_required = true WHERE id = $1", bookingID) + if err != nil { + t.Fatalf("failed to set deposit_required: %v", err) + } + + // Add a completed payment + paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 50, "online_square", "deposit", "completed") + if err != nil { + t.Fatalf("failed to create payment: %v", err) + } + t.Cleanup(func() { fixtures.DeletePayment(db.DB, paymentID) }) + + // Cancel >72h before — full refund expected + now := time.Date(2099, 12, 28, 8, 0, 0, 0, time.UTC) + start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) + + result, err := ProcessCancellationRefund(context.Background(), bookingID, 50, 50, start, now, "client_cancelled", &userID) + if err != nil { + t.Fatalf("ProcessCancellationRefund failed: %v", err) + } + if result == nil { + t.Fatal("expected non-nil result") + } + if result.RefundableAmount != 50 { + t.Errorf("expected refundable 50, got %.2f", result.RefundableAmount) + } + + // Check refund record was created + var refundCount int + db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundCount) + if refundCount != 1 { + t.Errorf("expected 1 refund record, got %d", refundCount) + } +} + +func TestProcessCancellationRefund_NoRefundWhenNotNeeded(t *testing.T) { + resetTestData(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) + + bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, + time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) }) + + // Cancel <24h before — refundable should be 0 + now := time.Date(2099, 12, 31, 9, 0, 0, 0, time.UTC) + start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) + + result, err := ProcessCancellationRefund(context.Background(), bookingID, 100, 0, start, now, "no_show", &userID) + if err != nil { + t.Fatalf("ProcessCancellationRefund failed: %v", err) + } + if result == nil { + t.Fatal("expected non-nil result") + } + if result.RefundableAmount != 0 { + t.Errorf("expected refundable 0, got %.2f", result.RefundableAmount) + } +} + +func TestProcessCancellationRefund_NoPaymentsNoop(t *testing.T) { + resetTestData(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) + + bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, + time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) }) + + start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) + now := time.Date(2099, 12, 28, 8, 0, 0, 0, time.UTC) + + result, err := ProcessCancellationRefund(context.Background(), bookingID, 100, 0, start, now, "client_cancelled", &userID) + if err != nil { + t.Fatalf("ProcessCancellationRefund failed: %v", err) + } + if result.RefundableAmount != 0 { + t.Errorf("expected refundable 0 when nothing paid, got %.2f", result.RefundableAmount) + } + + var refundCount int + db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundCount) + if refundCount != 0 { + t.Errorf("expected 0 refund records, got %d", refundCount) + } +} + +// ============================================================================= +// ProcessCancellationRefund — gift card refund routing +// ============================================================================= + +func TestProcessCancellationRefund_GiftCardCreditsUserBalance(t *testing.T) { + resetTestData(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) + + bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, + time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) }) + t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) + t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) + + _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) + if err != nil { + t.Fatalf("failed to confirm booking: %v", err) + } + + var giftCardID string + if err := db.DB.QueryRow(context.Background(), ` + INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, expiry_date, last_used_at) + VALUES (100, 40, $1, false, NULL, NOW()) + RETURNING id + `, userID).Scan(&giftCardID); err != nil { + t.Fatalf("failed to create gift card: %v", err) + } + t.Cleanup(func() { + db.DB.Exec(context.Background(), "DELETE FROM gift_card_transactions WHERE gift_card_id = $1", giftCardID) + db.DB.Exec(context.Background(), "DELETE FROM gift_cards WHERE id = $1", giftCardID) + }) + + var paymentID string + if err := db.DB.QueryRow(context.Background(), ` + INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, gift_card_id, created_at, updated_at) + VALUES ($1, 'full', 'giftcard', 'completed', 60, $2, NOW(), NOW()) + RETURNING id + `, bookingID, giftCardID).Scan(&paymentID); err != nil { + t.Fatalf("failed to create giftcard payment: %v", err) + } + t.Cleanup(func() { fixtures.DeletePayment(db.DB, paymentID) }) + + // Booking is far in the future — full refund. + farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) + result, err := ProcessCancellationRefund( + context.Background(), bookingID, 100, 60, + farFuture, time.Now(), "client_cancelled", &userID, + ) + if err != nil { + t.Fatalf("ProcessCancellationRefund failed: %v", err) + } + if result.RefundableAmount != 60 { + t.Errorf("expected refundable 60 (full refund >72h), got %.2f", result.RefundableAmount) + } + + var amountRemaining float64 + err = db.DB.QueryRow(context.Background(), + "SELECT amount_remaining FROM gift_cards WHERE id = $1", giftCardID).Scan(&amountRemaining) + if err != nil { + t.Fatalf("failed to query gift card balance: %v", err) + } + if amountRemaining != 100 { + t.Errorf("expected gift card amount_remaining 100 (40 + 60), got %.2f", amountRemaining) + } + + // Verify refund record exists (primary audit trail for cancellation refunds). + var refundCount int + err = db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundCount) + if err != nil { + t.Fatalf("failed to query refunds: %v", err) + } + if refundCount != 1 { + t.Errorf("expected 1 refund record, got %d", refundCount) + } + + var txCount int + err = db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1 AND transaction_type = 'refund'", giftCardID).Scan(&txCount) + if err != nil { + t.Fatalf("failed to query gift card transactions: %v", err) + } + if txCount != 1 { + t.Errorf("expected 1 gift card refund transaction, got %d", txCount) + } +} + +func TestProcessCancellationRefund_CashCreditsUserBalance(t *testing.T) { + resetTestData(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) + + bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, + time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) }) + t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) + t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) + + _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) + if err != nil { + t.Fatalf("failed to confirm booking: %v", err) + } + + // Create a cash payment of 30. + paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 30, "cash", "deposit", "completed") + if err != nil { + t.Fatalf("failed to create cash payment: %v", err) + } + t.Cleanup(func() { fixtures.DeletePayment(db.DB, paymentID) }) + + farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) + result, err := ProcessCancellationRefund( + context.Background(), bookingID, 100, 30, + farFuture, time.Now(), "client_cancelled", &userID, + ) + if err != nil { + t.Fatalf("ProcessCancellationRefund failed: %v", err) + } + if result.RefundableAmount != 30 { + t.Errorf("expected refundable 30, got %.2f", result.RefundableAmount) + } + + // Verify user balance was credited. + var balance float64 + err = db.DB.QueryRow(context.Background(), + "SELECT COALESCE(balance, 0) FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance) + if err != nil { + t.Fatalf("failed to query balance: %v", err) + } + if balance != 30 { + t.Errorf("expected user balance 30, got %.2f", balance) + } +} + +func TestProcessCancellationRefund_CardSquareRefundWithoutBalanceCredit(t *testing.T) { + resetTestData(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) + + bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, + time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) }) + t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) + t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) + + _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) + if err != nil { + t.Fatalf("failed to confirm booking: %v", err) + } + + // Create an online_square payment — this will be handled by Square mock. + var paymentID string + paymentID, err = fixtures.CreateTestPayment(db.DB, bookingID, 100, "online_square", "full", "completed") + if err != nil { + t.Fatalf("failed to create card payment: %v", err) + } + t.Cleanup(func() { fixtures.DeletePayment(db.DB, paymentID) }) + + farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) + result, err := ProcessCancellationRefund( + context.Background(), bookingID, 100, 100, + farFuture, time.Now(), "client_cancelled", &userID, + ) + if err != nil { + t.Fatalf("ProcessCancellationRefund failed: %v", err) + } + if result.RefundableAmount != 100 { + t.Errorf("expected refundable 100, got %.2f", result.RefundableAmount) + } + + // In dev/test the payment has no square_payment_id, so Square cannot process + // the refund and the amount falls through to a balance credit. In production + // with a real square_payment_id the Square API would handle the refund instead. + var balance float64 + err = db.DB.QueryRow(context.Background(), + "SELECT COALESCE(balance, 0) FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance) + if err != nil { + t.Fatalf("failed to query balance: %v", err) + } + if balance <= 0 { + t.Errorf("expected a balance credit (Square refund unavailable in mock), got %.2f", balance) + } +} + +// ============================================================================= +// ProcessCancellationRefund — non-money payment methods (discount, on_the_house) +// ============================================================================= + +func TestProcessCancellationRefund_DiscountPaymentSkipped(t *testing.T) { + resetTestData(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) + + bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, + time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) }) + + _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) + if err != nil { + t.Fatalf("failed to confirm booking: %v", err) + } + + // Create a discount payment (no real money exchanged). + paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 20, "discount", "partial", "completed") + if err != nil { + t.Fatalf("failed to create discount payment: %v", err) + } + t.Cleanup(func() { fixtures.DeletePayment(db.DB, paymentID) }) + + farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) + result, err := ProcessCancellationRefund( + context.Background(), bookingID, 100, 20, + farFuture, time.Now(), "client_cancelled", &userID, + ) + if err != nil { + t.Fatalf("ProcessCancellationRefund failed: %v", err) + } + if result.RefundableAmount != 20 { + t.Errorf("expected refundable 20 (full refund >72h), got %.2f", result.RefundableAmount) + } + + // Discount payments should NOT create a balance credit. + var balance float64 + err = db.DB.QueryRow(context.Background(), + "SELECT COALESCE(balance, 0) FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance) + if err != nil { + balance = 0 + } + if balance != 0 { + t.Errorf("expected no balance credit for discount payment, got %.2f", balance) + } +} + +func TestProcessCancellationRefund_OnTheHousePaymentSkipped(t *testing.T) { + resetTestData(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) + + bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, + time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) }) + + _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) + if err != nil { + t.Fatalf("failed to confirm booking: %v", err) + } + + // Create an on_the_house payment (no real money exchanged). + paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 100, "on_the_house", "full", "completed") + if err != nil { + t.Fatalf("failed to create on_the_house payment: %v", err) + } + t.Cleanup(func() { fixtures.DeletePayment(db.DB, paymentID) }) + + farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) + result, err := ProcessCancellationRefund( + context.Background(), bookingID, 100, 100, + farFuture, time.Now(), "client_cancelled", &userID, + ) + if err != nil { + t.Fatalf("ProcessCancellationRefund failed: %v", err) + } + if result.RefundableAmount != 100 { + t.Errorf("expected refundable 100 (full refund >72h), got %.2f", result.RefundableAmount) + } + + // on_the_house payments should NOT create a balance credit. + var balance float64 + err = db.DB.QueryRow(context.Background(), + "SELECT COALESCE(balance, 0) FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance) + if err != nil { + balance = 0 + } + if balance != 0 { + t.Errorf("expected no balance credit for on_the_house payment, got %.2f", balance) + } +} + +// ============================================================================= +// ProcessCancellationRefund — missing user_id edge case +// ============================================================================= + +func TestProcessCancellationRefund_MissingUserID_LogsWarning(t *testing.T) { + resetTestData(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) + + bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, + time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) }) + + _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) + if err != nil { + t.Fatalf("failed to confirm booking: %v", err) + } + + // Create a cash payment. + paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 50, "cash", "deposit", "completed") + if err != nil { + t.Fatalf("failed to create cash payment: %v", err) + } + t.Cleanup(func() { fixtures.DeletePayment(db.DB, paymentID) }) + + // Set user_id to NULL on the booking to simulate a purged guest account. + _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET user_id = NULL WHERE id = $1", bookingID) + if err != nil { + t.Fatalf("failed to nullify booking user_id: %v", err) + } + + farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) + result, err := ProcessCancellationRefund( + context.Background(), bookingID, 100, 50, + farFuture, time.Now(), "client_cancelled", nil, + ) + if err != nil { + t.Fatalf("ProcessCancellationRefund failed: %v", err) + } + if result.RefundableAmount != 50 { + t.Errorf("expected refundable 50, got %.2f", result.RefundableAmount) + } + + // Refund record should still be created even without user_id. + var refundCount int + err = db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundCount) + if err != nil { + t.Fatalf("failed to query refunds: %v", err) + } + if refundCount != 1 { + t.Errorf("expected 1 refund record (user_id-less), got %d", refundCount) + } +} + +// ============================================================================= +// ProcessCancellationRefund — guest users must NOT get balance credits +// ============================================================================= + +func TestProcessCancellationRefund_GuestGiftcardDoesNotCreditBalance(t *testing.T) { + resetTestData(t) + + // Create a user and promote them to guest role. + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) + _, err = db.DB.Exec(context.Background(), "UPDATE users SET account_role = 'guest' WHERE id = $1", userID) + if err != nil { + t.Fatalf("failed to set guest role: %v", err) + } + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) + + bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, + time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) }) + + _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) + if err != nil { + t.Fatalf("failed to confirm booking: %v", err) + } + + // Create a gift card payment. + paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 50, "giftcard", "deposit", "completed") + if err != nil { + t.Fatalf("failed to create giftcard payment: %v", err) + } + t.Cleanup(func() { fixtures.DeletePayment(db.DB, paymentID) }) + + farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) + result, err := ProcessCancellationRefund( + context.Background(), bookingID, 100, 50, + farFuture, time.Now(), "client_cancelled", &userID, + ) + if err != nil { + t.Fatalf("ProcessCancellationRefund failed: %v", err) + } + if result.RefundableAmount != 50 { + t.Errorf("expected refundable 50, got %.2f", result.RefundableAmount) + } + + // Guest must NOT have a balance credit. + var balance float64 + err = db.DB.QueryRow(context.Background(), + "SELECT COALESCE(balance, 0) FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance) + if err != nil { + // No row means balance is 0 — this is the expected outcome. + balance = 0 + } + if balance != 0 { + t.Errorf("expected guest balance 0 (guests do not receive balance credits), got %.2f", balance) + } + + // Refund record should still exist. + var refundCount int + err = db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundCount) + if err != nil { + t.Fatalf("failed to query refunds: %v", err) + } + if refundCount != 1 { + t.Errorf("expected 1 refund record for guest, got %d", refundCount) + } +} + +func TestProcessCancellationRefund_GuestCashDoesNotCreditBalance(t *testing.T) { + resetTestData(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) + _, err = db.DB.Exec(context.Background(), "UPDATE users SET account_role = 'guest' WHERE id = $1", userID) + if err != nil { + t.Fatalf("failed to set guest role: %v", err) + } + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) + + bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, + time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) }) + + _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) + if err != nil { + t.Fatalf("failed to confirm booking: %v", err) + } + + // Create a cash payment. + paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 30, "cash", "full", "completed") + if err != nil { + t.Fatalf("failed to create cash payment: %v", err) + } + t.Cleanup(func() { fixtures.DeletePayment(db.DB, paymentID) }) + + farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) + result, err := ProcessCancellationRefund( + context.Background(), bookingID, 100, 30, + farFuture, time.Now(), "client_cancelled", &userID, + ) + if err != nil { + t.Fatalf("ProcessCancellationRefund failed: %v", err) + } + if result.RefundableAmount != 30 { + t.Errorf("expected refundable 30, got %.2f", result.RefundableAmount) + } + + // Guest must NOT have a balance credit. + var balance float64 + err = db.DB.QueryRow(context.Background(), + "SELECT COALESCE(balance, 0) FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance) + if err != nil { + balance = 0 + } + if balance != 0 { + t.Errorf("expected guest balance 0 (guests do not receive balance credits), got %.2f", balance) + } +} + +// ============================================================================= +// Refund with split payments — verify dedup when 2 records share square_payment_id +// ============================================================================= + +func TestProcessCancellationRefund_SplitPayment_DeduplicatesSquareRefund(t *testing.T) { + // When a single Square charge is split into 2 DB payment records (deposit + balance) + // sharing the same square_payment_id, the refund loop must only call Square once. + // The second record should be credited to the user balance instead. + resetTestData(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) + + bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, + time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) }) + + _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) + if err != nil { + t.Fatalf("failed to confirm booking: %v", err) + } + + sameSquareID := "sqp_split_dedup_test" + now := time.Now() + + // Create 2 payment records sharing the same square_payment_id — simulating a + // split charge where one Square payment was recorded as deposit + balance. + svc := NewPaymentService() + + pid1, err := svc.CreatePaymentRecord(context.Background(), PaymentRecord{ + BookingID: bookingID, + PaymentType: "deposit", + PaymentMethod: "online_square", + Status: "completed", + Amount: 25.00, + SquarePaymentID: &sameSquareID, + CreatedAt: now, + UpdatedAt: now, + }, nil) + if err != nil { + t.Fatalf("failed to create deposit record: %v", err) + } + t.Cleanup(func() { fixtures.DeletePayment(db.DB, pid1) }) + + pid2, err := svc.CreatePaymentRecord(context.Background(), PaymentRecord{ + BookingID: bookingID, + PaymentType: "balance", + PaymentMethod: "online_square", + Status: "completed", + Amount: 25.00, + SquarePaymentID: &sameSquareID, + CreatedAt: now, + UpdatedAt: now, + }, nil) + if err != nil { + t.Fatalf("failed to create balance record: %v", err) + } + t.Cleanup(func() { fixtures.DeletePayment(db.DB, pid2) }) + + // Cancel 72+ hours before → full refund of £50. + farFuture := time.Date(2099, 12, 28, 8, 0, 0, 0, time.UTC) + start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) + + result, err := ProcessCancellationRefund( + context.Background(), bookingID, 100, 50, + start, farFuture, "client_cancelled", &userID, + ) + if err != nil { + t.Fatalf("ProcessCancellationRefund failed: %v", err) + } + if result.RefundableAmount != 50 { + t.Errorf("expected refundable 50, got %.2f", result.RefundableAmount) + } + + // Should have created 1 Square refund (for the deposit record) and credited + // the balance portion via user balance. + var refundCount int + err = db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundCount) + if err != nil { + t.Fatalf("failed to query refunds: %v", err) + } + if refundCount != 2 { + t.Errorf("expected 2 refund records (1 Square + 1 balance credit), got %d", refundCount) + } + +}