feat(backend): add loyalty and refund features
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user