Files
Crussell/backend/handlers/bookings/discount_test.go
T
popertotsandSisyphus 40bbd9ba49 refactor(bookings): migrate remaining handlers and tests to clock.Now()
Replace time.Now() with clock.Now() in bookings handlers and all test files. Includes deposit, discount, dedup, overlap, and edit request test updates.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-24 23:43:32 +01:00

1560 lines
57 KiB
Go

//go:build test && dev
// +build test,dev
package bookings
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"crussell/clock"
"crussell/db"
"crussell/testutils"
"crussell/handlers/payments"
"crussell/mw"
"crussell/testutils/fixtures"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func makeProgressRequest(handler http.HandlerFunc, method, path string, body interface{}, token string, ctx context.Context) *httptest.ResponseRecorder {
var req *http.Request
if body != nil {
bodyBytes, _ := json.Marshal(body)
req = httptest.NewRequest(method, path, bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, path, nil)
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rctx := chi.NewRouteContext()
parts := strings.Split(strings.Trim(path, "/"), "/")
for i, p := range parts {
if p == "bookings" && i+1 < len(parts) {
rctx.URLParams.Add("id", parts[i+1])
break
}
}
ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx)
if token != "" {
ctx = context.WithValue(ctx, mw.UserIDKey, "admin-test-001")
ctx = context.WithValue(ctx, mw.UserRoleKey, "admin")
}
req = req.WithContext(ctx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
return w
}
func createTestUser(t *testing.T, stamps int, q db.Querier, ctx context.Context) string {
t.Helper()
userID, err := fixtures.CreateTestUser(q)
require.NoError(t, err)
if stamps > 0 {
_, err := q.Exec(ctx, "UPDATE users SET loyalty_stamps = $1 WHERE id = $2", stamps, userID)
require.NoError(t, err)
}
return userID
}
func createTestService(t *testing.T, price float64, q db.Querier, ctx context.Context) string {
t.Helper()
var serviceID string
err := q.QueryRow(ctx, `
INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id
`, "Test Service", "A test service", price, 60, true, 16).Scan(&serviceID)
require.NoError(t, err)
return serviceID
}
func createTestCampaign(t *testing.T, name, campaignType string, percent float64, milestoneType, milestoneUnit *string, milestoneValue *int, maxRedemptions *int, q db.Querier, ctx context.Context) string {
t.Helper()
var id string
now := clock.Now()
startDate := now.Add(-24 * time.Hour)
endDate := now.Add(24 * time.Hour)
err := q.QueryRow(ctx, `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value, milestone_unit, max_redemptions, times_redeemed)
VALUES ($1, $2, $3, 'active', $4, $5, $6, $7, $8, $9, 0)
RETURNING id
`, name, campaignType, percent, startDate, endDate, milestoneType, milestoneValue, milestoneUnit, maxRedemptions).Scan(&id)
require.NoError(t, err)
return id
}
func insertInPersonCardPayment(t *testing.T, bookingID string, ctx context.Context) {
t.Helper()
_, err := db.Conn.Exec(ctx, `
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)
require.NoError(t, err)
}
func createCompletedBooking(t *testing.T, userID, serviceID string, startTime time.Time, price float64, ctx context.Context) string {
t.Helper()
var bookingID string
err := db.Conn.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status)
VALUES ($1, $2, 'completed')
RETURNING id
`, userID, startTime).Scan(&bookingID)
require.NoError(t, err)
_, err = db.Conn.Exec(ctx, `
INSERT INTO booking_services (booking_id, service_id, override_price)
VALUES ($1, $2, $3)
`, bookingID, serviceID, price)
require.NoError(t, err)
return bookingID
}
func createPendingBooking(t *testing.T, userID, serviceID string, startTime time.Time, q db.Querier, ctx context.Context) string {
t.Helper()
var bookingID string
err := q.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status)
VALUES ($1, $2, 'confirmed')
RETURNING id
`, userID, startTime).Scan(&bookingID)
require.NoError(t, err)
_, err = q.Exec(ctx, `
INSERT INTO booking_services (booking_id, service_id)
VALUES ($1, $2)
`, bookingID, serviceID)
require.NoError(t, err)
return bookingID
}
func completeBooking(t *testing.T, bookingID string, ctx context.Context) *httptest.ResponseRecorder {
t.Helper()
progressReq := ProgressBookingRequest{Status: "completed"}
handler := http.HandlerFunc(ProgressBookingHandler)
w := makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", progressReq, "admin-token", ctx)
require.Equal(t, http.StatusOK, w.Code, "Expected 200 on booking completion")
return w
}
func backdateBooking(t *testing.T, bookingID string, daysAgo int, ctx context.Context) {
t.Helper()
if daysAgo > 0 {
_, err := db.Conn.Exec(ctx, `
UPDATE bookings SET updated_at = NOW() - INTERVAL '1 day' * $1 WHERE id = $2
`, daysAgo, bookingID)
require.NoError(t, err)
}
}
func getStamps(t *testing.T, userID string, ctx context.Context) int {
t.Helper()
var stamps int
err := db.Conn.QueryRow(ctx, `SELECT loyalty_stamps FROM users WHERE id = $1`, userID).Scan(&stamps)
require.NoError(t, err)
return stamps
}
func getPendingRedemptions(t *testing.T, userID string, ctx context.Context) int {
t.Helper()
var count int
err := db.Conn.QueryRow(ctx, `SELECT COUNT(*) FROM loyalty_redemptions WHERE user_id = $1 AND status = 'pending'`, userID).Scan(&count)
require.NoError(t, err)
return count
}
func getDiscountForBooking(t *testing.T, bookingID string, ctx context.Context) (source string, amount float64, exists bool) {
t.Helper()
err := db.Conn.QueryRow(ctx, `
SELECT discount_source, discount_amount FROM booking_discounts WHERE booking_id = $1
`, bookingID).Scan(&source, &amount)
if err != nil {
return "", 0, false
}
return source, amount, true
}
type bookingDiscount struct {
Source string
Amount float64
CampType string
MileType string
}
func getDiscountRowCount(t *testing.T, bookingID string, q db.Querier, ctx context.Context) int {
t.Helper()
var count int
err := q.QueryRow(ctx, `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&count)
require.NoError(t, err)
return count
}
func getAllDiscountsForBooking(t *testing.T, bookingID string, ctx context.Context) []bookingDiscount {
t.Helper()
rows, err := db.Conn.Query(ctx, `
SELECT discount_source, discount_amount, COALESCE(campaign_type::text, ''), COALESCE(milestone_type::text, '')
FROM booking_discounts WHERE booking_id = $1 ORDER BY discount_source, campaign_type, milestone_type
`, bookingID)
require.NoError(t, err)
defer rows.Close()
var discounts []bookingDiscount
for rows.Next() {
var d bookingDiscount
require.NoError(t, rows.Scan(&d.Source, &d.Amount, &d.CampType, &d.MileType))
discounts = append(discounts, d)
}
return discounts
}
func getTotalDiscountAmount(t *testing.T, bookingID string, ctx context.Context) float64 {
t.Helper()
var amount float64
err := db.Conn.QueryRow(ctx, `SELECT COALESCE(SUM(discount_amount), 0) FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&amount)
require.NoError(t, err)
return amount
}
func getPaymentDiscountRowCount(t *testing.T, bookingID string, ctx context.Context) int {
t.Helper()
var count int
err := db.Conn.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'discount'`, bookingID).Scan(&count)
require.NoError(t, err)
return count
}
func getTotalPaymentCount(t *testing.T, bookingID string, ctx context.Context) int {
t.Helper()
var count int
err := db.Conn.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&count)
require.NoError(t, err)
return count
}
func getAmountPaid(t *testing.T, bookingID string, ctx context.Context) float64 {
t.Helper()
var amount float64
err := db.Conn.QueryRow(ctx, `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND status = 'completed'`, bookingID).Scan(&amount)
require.NoError(t, err)
return amount
}
func applyLoyaltyRedemption(t *testing.T, bookingID, userID string, ctx context.Context) {
t.Helper()
handler := http.HandlerFunc(payments.ApplyLoyaltyRedemption)
req := httptest.NewRequest("POST", "/api/bookings/"+bookingID+"/apply-redemption", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", bookingID)
reqCtx := context.WithValue(ctx, chi.RouteCtxKey, rctx)
reqCtx = context.WithValue(reqCtx, mw.UserIDKey, userID)
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "customer")
req = req.WithContext(reqCtx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code, "apply-redemption failed: %s", w.Body.String())
}
// =============================================================================
// Loyalty Manual-Redemption Tests
// =============================================================================
func TestDiscount_Loyalty_FullCycle(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID := createTestUser(t, 10, tx, ctx)
serviceID := createTestService(t, 50.00, tx, ctx)
_, err := tx.Exec(ctx, `
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
VALUES ($1, 10, 'pending', NOW())
`, userID)
require.NoError(t, err)
assert.Equal(t, 10, getStamps(t, userID, ctx))
assert.Equal(t, 1, getPendingRedemptions(t, userID, ctx))
// Apply loyalty redemption manually on a new booking
bookingID := createPendingBooking(t, userID, serviceID, clock.Now().AddDate(0, 0, 12), tx, ctx)
applyLoyaltyRedemption(t, bookingID, userID, ctx)
source, amount, exists := getDiscountForBooking(t, bookingID, ctx)
require.True(t, exists, "Expected discount after manual redemption")
assert.Equal(t, "loyalty", source)
assert.Equal(t, 5.00, amount, "10% of £50 = £5")
assert.Equal(t, 0, getStamps(t, userID, ctx), "Stamps deducted by 10")
assert.Equal(t, 0, getPendingRedemptions(t, userID, ctx), "Pending redemption consumed")
// Complete the booking — no stamp awarded (take or receive, never both)
completeBooking(t, bookingID, ctx)
assert.Equal(t, 0, getStamps(t, userID, ctx), "No stamp for a booking that claimed a reward")
}
func TestDiscount_Loyalty_ExistingRedemptionApplies(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID := createTestUser(t, 10, tx, ctx)
serviceID := createTestService(t, 100.00, tx, ctx)
milestoneType := "global_booking_count"
milestoneUnit := "bookings"
milestoneValue := 1
_ = createTestCampaign(t, "First Global", "milestone", 5.0, &milestoneType, &milestoneUnit, &milestoneValue, nil, tx, ctx)
_, err := tx.Exec(ctx, `
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
VALUES ($1, 10, 'pending', NOW())
`, userID)
require.NoError(t, err)
bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
// Apply loyalty redemption manually before completion
applyLoyaltyRedemption(t, bookingID, userID, ctx)
// Complete booking — milestone campaign applies at completion
insertInPersonCardPayment(t, bookingID, ctx)
completeBooking(t, bookingID, ctx)
assert.Equal(t, 2, getDiscountRowCount(t, bookingID, tx, ctx), "Expected 2 discount rows (loyalty + campaign)")
assert.Equal(t, 2, getPaymentDiscountRowCount(t, bookingID, ctx), "Expected 2 discount payment rows")
totalDiscount := getTotalDiscountAmount(t, bookingID, ctx)
assert.InDelta(t, 15.00, totalDiscount, 0.01, "Total should be £15 (10% + 5%)")
discounts := getAllDiscountsForBooking(t, bookingID, ctx)
require.Len(t, discounts, 2)
}
func TestDiscount_Stacking_LoyaltyPlusMilestone(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
mt := "per_user_booking_count"
mu := "bookings"
mv := 5
_ = createTestCampaign(t, "5th Booking", "milestone", 15.0, &mt, &mu, &mv, nil, tx, ctx)
userID := createTestUser(t, 10, tx, ctx)
_, err := tx.Exec(ctx, `
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
VALUES ($1, 10, 'pending', NOW())
`, userID)
require.NoError(t, err)
serviceID := createTestService(t, 100.00, tx, ctx)
for i := 0; i < 4; i++ {
startTime := clock.Now().AddDate(0, 0, -(i + 10))
_ = createCompletedBooking(t, userID, serviceID, startTime, 100.00, ctx)
}
bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
// Apply loyalty manually, then complete (milestone applies at completion)
applyLoyaltyRedemption(t, bookingID, userID, ctx)
completeBooking(t, bookingID, ctx)
assert.Equal(t, 2, getDiscountRowCount(t, bookingID, tx, ctx), "Expected 2 discount rows")
totalDiscount := getTotalDiscountAmount(t, bookingID, ctx)
assert.InDelta(t, 25.00, totalDiscount, 0.01, "Total should be £25 (10% + 15%)")
}
func TestDiscount_Stacking_LoyaltyPlusAnniversary(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
mt := "anniversary"
mu := "years"
mv := 1
_ = createTestCampaign(t, "1 Year Anniversary", "milestone", 10.0, &mt, &mu, &mv, nil, tx, ctx)
userID := createTestUser(t, 10, tx, ctx)
_, err := tx.Exec(ctx, `
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
VALUES ($1, 10, 'pending', NOW())
`, userID)
require.NoError(t, err)
serviceID := createTestService(t, 100.00, tx, ctx)
firstStartTime := clock.Now().AddDate(0, 0, -400)
_ = createCompletedBooking(t, userID, serviceID, firstStartTime, 100.00, ctx)
bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
applyLoyaltyRedemption(t, bookingID, userID, ctx)
completeBooking(t, bookingID, ctx)
assert.Equal(t, 2, getDiscountRowCount(t, bookingID, tx, ctx), "Expected 2 discount rows")
totalDiscount := getTotalDiscountAmount(t, bookingID, ctx)
assert.InDelta(t, 20.00, totalDiscount, 0.01, "Total should be £20 (10% loyalty + 10% anniversary)")
}
func TestDiscount_Stacking_AllThreeTypes(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil, tx, ctx)
mt := "anniversary"
mu := "years"
mv := 1
_ = createTestCampaign(t, "1 Year Anniversary", "milestone", 10.0, &mt, &mu, &mv, nil, tx, ctx)
userID := createTestUser(t, 10, tx, ctx)
_, err := tx.Exec(ctx, `
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
VALUES ($1, 10, 'pending', NOW())
`, userID)
require.NoError(t, err)
serviceID := createTestService(t, 100.00, tx, ctx)
firstStartTime := clock.Now().AddDate(0, 0, -400)
_ = createCompletedBooking(t, userID, serviceID, firstStartTime, 100.00, ctx)
bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
applyLoyaltyRedemption(t, bookingID, userID, ctx)
completeBooking(t, bookingID, ctx)
assert.Equal(t, 3, getDiscountRowCount(t, bookingID, tx, ctx), "Expected 3 discount rows")
totalDiscount := getTotalDiscountAmount(t, bookingID, ctx)
assert.InDelta(t, 25.00, totalDiscount, 0.01, "Total should be £25 (10% + 5% + 10%)")
}
func TestDiscount_Stacking_MultipleMilestones(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
mt1 := "per_user_booking_count"
mu1 := "bookings"
mv1 := 5
_ = createTestCampaign(t, "5th Booking", "milestone", 10.0, &mt1, &mu1, &mv1, nil, tx, ctx)
mt2 := "global_booking_count"
mu2 := "bookings"
mv2 := 5
_ = createTestCampaign(t, "5th Global", "milestone", 5.0, &mt2, &mu2, &mv2, nil, tx, ctx)
mt3 := "anniversary"
mu3 := "years"
mv3 := 1
_ = createTestCampaign(t, "1 Year Anniversary", "milestone", 10.0, &mt3, &mu3, &mv3, nil, tx, ctx)
userID := createTestUser(t, 0, tx, ctx)
serviceID := createTestService(t, 100.00, tx, ctx)
// 4 prior completed bookings, first one backdated 400+ days
for i := 0; i < 4; i++ {
var startTime time.Time
if i == 0 {
startTime = clock.Now().AddDate(0, 0, -400)
} else {
startTime = clock.Now().AddDate(0, 0, -(i * 7))
}
_ = createCompletedBooking(t, userID, serviceID, startTime, 100.00, ctx)
}
bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
insertInPersonCardPayment(t, bookingID, ctx)
completeBooking(t, bookingID, ctx)
assert.Equal(t, 3, getDiscountRowCount(t, bookingID, tx, ctx), "Expected 3 discount rows (all milestones)")
}
func TestDiscount_Stacking_LoyaltyPlusGlobalMilestone(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
mt := "global_booking_count"
mu := "bookings"
mv := 1
_ = createTestCampaign(t, "First Global", "milestone", 5.0, &mt, &mu, &mv, nil, tx, ctx)
userID := createTestUser(t, 10, tx, ctx)
_, err := tx.Exec(ctx, `
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
VALUES ($1, 10, 'pending', NOW())
`, userID)
require.NoError(t, err)
serviceID := createTestService(t, 100.00, tx, ctx)
bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
applyLoyaltyRedemption(t, bookingID, userID, ctx)
insertInPersonCardPayment(t, bookingID, ctx)
completeBooking(t, bookingID, ctx)
assert.Equal(t, 2, getDiscountRowCount(t, bookingID, tx, ctx), "Expected 2 discount rows")
totalDiscount := getTotalDiscountAmount(t, bookingID, ctx)
assert.InDelta(t, 15.00, totalDiscount, 0.01, "Total should be £15 (10% + 5%)")
}
func TestDiscount_Stacking_DiscountAmountsSumCorrectly(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil, tx, ctx)
mt := "per_user_booking_count"
mu := "bookings"
mv := 1
_ = createTestCampaign(t, "1st Booking", "milestone", 10.0, &mt, &mu, &mv, nil, tx, ctx)
userID := createTestUser(t, 10, tx, ctx)
_, err := tx.Exec(ctx, `
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
VALUES ($1, 10, 'pending', NOW())
`, userID)
require.NoError(t, err)
serviceID := createTestService(t, 200.00, tx, ctx)
bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
applyLoyaltyRedemption(t, bookingID, userID, ctx)
completeBooking(t, bookingID, ctx)
assert.Equal(t, 3, getDiscountRowCount(t, bookingID, tx, ctx), "Expected 3 discount rows")
totalDiscount := getTotalDiscountAmount(t, bookingID, ctx)
assert.InDelta(t, 50.00, totalDiscount, 0.01, "Total should be £50")
discounts := getAllDiscountsForBooking(t, bookingID, ctx)
require.Len(t, discounts, 3)
for _, d := range discounts {
switch d.Source {
case "loyalty":
assert.InDelta(t, 20.00, d.Amount, 0.01, "Loyalty: 10% of £200")
case "campaign":
if d.CampType == "time_based" {
assert.InDelta(t, 10.00, d.Amount, 0.01, "Time-based: 5% of £200")
} else if d.CampType == "milestone" {
assert.InDelta(t, 20.00, d.Amount, 0.01, "Milestone: 10% of £200")
}
}
}
}
func TestDiscount_Stacking_MultiplePaymentRecords(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil, tx, ctx)
mt := "per_user_booking_count"
mu := "bookings"
mv := 1
_ = createTestCampaign(t, "1st Booking", "milestone", 10.0, &mt, &mu, &mv, nil, tx, ctx)
userID := createTestUser(t, 10, tx, ctx)
_, err := tx.Exec(ctx, `
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
VALUES ($1, 10, 'pending', NOW())
`, userID)
require.NoError(t, err)
serviceID := createTestService(t, 200.00, tx, ctx)
bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
applyLoyaltyRedemption(t, bookingID, userID, ctx)
completeBooking(t, bookingID, ctx)
assert.Equal(t, 3, getPaymentDiscountRowCount(t, bookingID, ctx), "Expected 3 discount payment rows")
var totalDiscountPayment float64
err = tx.QueryRow(ctx, `
SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND payment_method = 'discount'
`, bookingID).Scan(&totalDiscountPayment)
require.NoError(t, err)
assert.InDelta(t, 50.00, totalDiscountPayment, 0.01, "Sum of discount payments should be £50")
}
func TestDiscount_Stacking_MultipleBookingDiscountRows(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil, tx, ctx)
mt := "per_user_booking_count"
mu := "bookings"
mv := 1
_ = createTestCampaign(t, "1st Booking", "milestone", 10.0, &mt, &mu, &mv, nil, tx, ctx)
userID := createTestUser(t, 10, tx, ctx)
_, err := tx.Exec(ctx, `
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
VALUES ($1, 10, 'pending', NOW())
`, userID)
require.NoError(t, err)
serviceID := createTestService(t, 200.00, tx, ctx)
bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
applyLoyaltyRedemption(t, bookingID, userID, ctx)
completeBooking(t, bookingID, ctx)
assert.Equal(t, 3, getDiscountRowCount(t, bookingID, tx, ctx), "Expected 3 booking_discounts rows")
type discountDetail struct {
Source string
CampType string
MileType string
Percent float64
OriginalTotal float64
Amount float64
}
rows, err := tx.Query(ctx, `
SELECT discount_source, COALESCE(campaign_type::text, ''), COALESCE(milestone_type::text, ''), discount_percent, original_total, discount_amount
FROM booking_discounts WHERE booking_id = $1 ORDER BY discount_source, campaign_type, milestone_type
`, bookingID)
require.NoError(t, err)
defer rows.Close()
var details []discountDetail
for rows.Next() {
var d discountDetail
require.NoError(t, rows.Scan(&d.Source, &d.CampType, &d.MileType, &d.Percent, &d.OriginalTotal, &d.Amount))
details = append(details, d)
}
require.Len(t, details, 3)
for _, d := range details {
assert.Equal(t, 200.00, d.OriginalTotal, "original_total should be £200")
switch d.Source {
case "loyalty":
assert.Equal(t, "", d.CampType)
assert.Equal(t, "", d.MileType)
assert.InDelta(t, 10.00, d.Percent, 0.01)
assert.InDelta(t, 20.00, d.Amount, 0.01)
case "campaign":
if d.CampType == "time_based" {
assert.Equal(t, "", d.MileType)
assert.InDelta(t, 5.00, d.Percent, 0.01)
assert.InDelta(t, 10.00, d.Amount, 0.01)
} else if d.CampType == "milestone" {
assert.Equal(t, "per_user_booking_count", d.MileType)
assert.InDelta(t, 10.00, d.Percent, 0.01)
assert.InDelta(t, 20.00, d.Amount, 0.01)
}
}
}
}
func TestDiscount_Stacking_TimeBasedPlusMilestone(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil, tx, ctx)
mt := "per_user_booking_count"
mu := "bookings"
mv := 3
_ = createTestCampaign(t, "3rd Booking", "milestone", 10.0, &mt, &mu, &mv, nil, tx, ctx)
userID := createTestUser(t, 0, tx, ctx)
serviceID := createTestService(t, 100.00, tx, ctx)
// 2 prior completed bookings
startTime1 := clock.Now().AddDate(0, 0, -14)
_ = createCompletedBooking(t, userID, serviceID, startTime1, 100.00, ctx)
startTime2 := clock.Now().AddDate(0, 0, -7)
_ = createCompletedBooking(t, userID, serviceID, startTime2, 100.00, ctx)
bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
completeBooking(t, bookingID, ctx)
assert.Equal(t, 2, getDiscountRowCount(t, bookingID, tx, ctx), "Expected 2 discount rows")
totalDiscount := getTotalDiscountAmount(t, bookingID, ctx)
assert.InDelta(t, 15.00, totalDiscount, 0.01, "Total should be £15 (5% + 10%)")
}
// =============================================================================
// Zero Total Edge Case Tests
// =============================================================================
func TestDiscount_NoDiscountOnZeroTotal(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID := createTestUser(t, 10, tx, ctx)
_, err := tx.Exec(ctx, `
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
VALUES ($1, 10, 'pending', NOW())
`, userID)
require.NoError(t, err)
var serviceID string
err = tx.QueryRow(ctx, `
INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id
`, "Free Service", "A free service", 0.00, 60, true, 16).Scan(&serviceID)
require.NoError(t, err)
bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
completeBooking(t, bookingID, ctx)
var paymentCount int
err = tx.QueryRow(ctx, `
SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'discount'
`, bookingID).Scan(&paymentCount)
require.NoError(t, err)
assert.Equal(t, 0, paymentCount, "No discount payment (total is 0)")
var redemptionStatus string
err = tx.QueryRow(ctx, `
SELECT status FROM loyalty_redemptions WHERE user_id = $1 AND status = 'pending'
`, userID).Scan(&redemptionStatus)
require.NoError(t, err)
assert.Equal(t, "pending", redemptionStatus, "Redemption stays pending")
assert.Equal(t, 10, getStamps(t, userID, ctx), "Stamps unchanged")
}
// =============================================================================
// Max Redemptions Tests
// =============================================================================
func TestDiscount_CampaignMaxRedemptions(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
maxRedemptions := 1
campaignID := createTestCampaign(t, "Limited Time Offer", "time_based", 10.0, nil, nil, nil, &maxRedemptions, tx, ctx)
userID1 := createTestUser(t, 0, tx, ctx)
userID2 := createTestUser(t, 0, tx, ctx)
serviceID := createTestService(t, 50.00, tx, ctx)
bookingID1 := createPendingBooking(t, userID1, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
completeBooking(t, bookingID1, ctx)
var discountCount1 int
err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID1).Scan(&discountCount1)
require.NoError(t, err)
assert.Equal(t, 1, discountCount1)
var timesRedeemed int
err = tx.QueryRow(ctx, `SELECT times_redeemed FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&timesRedeemed)
require.NoError(t, err)
assert.Equal(t, 1, timesRedeemed)
bookingID2 := createPendingBooking(t, userID2, serviceID, clock.Now().Add(48*time.Hour), tx, ctx)
completeBooking(t, bookingID2, ctx)
var discountCount2 int
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID2).Scan(&discountCount2)
require.NoError(t, err)
assert.Equal(t, 0, discountCount2, "No discount (max reached)")
}
func createTestCampaignWithStatus(t *testing.T, name, campaignType string, percent float64, status string, milestoneType, milestoneUnit *string, milestoneValue *int, maxRedemptions *int, q db.Querier, ctx context.Context) string {
t.Helper()
var id string
now := clock.Now()
startDate := now.Add(-24 * time.Hour)
endDate := now.Add(24 * time.Hour)
err := q.QueryRow(ctx, `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value, milestone_unit, max_redemptions, times_redeemed)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 0)
RETURNING id
`, name, campaignType, percent, status, startDate, endDate, milestoneType, milestoneValue, milestoneUnit, maxRedemptions).Scan(&id)
require.NoError(t, err)
return id
}
// =============================================================================
// Edge Case Tests
// =============================================================================
func TestDiscount_ExpiredRedemptionDoesNotApply(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID := createTestUser(t, 10, tx, ctx)
serviceID := createTestService(t, 100.00, tx, ctx)
// Insert pending redemption that has already expired
_, err := tx.Exec(ctx, `
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at, expires_at)
VALUES ($1, 10, 'pending', NOW(), NOW() - INTERVAL '1 day')
`, userID)
require.NoError(t, err)
bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
completeBooking(t, bookingID, ctx)
count := getDiscountRowCount(t, bookingID, tx, ctx)
assert.Equal(t, 0, count, "Expired redemption should not apply discount")
}
func TestDiscount_MultiplePendingRedemptions_UsesOldest(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID := createTestUser(t, 10, tx, ctx)
serviceID := createTestService(t, 100.00, tx, ctx)
_, err := tx.Exec(ctx, `
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
VALUES ($1, 10, 'pending', NOW() - INTERVAL '2 days')
`, userID)
require.NoError(t, err)
_, err = tx.Exec(ctx, `
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
VALUES ($1, 10, 'pending', NOW() - INTERVAL '1 day')
`, userID)
require.NoError(t, err)
bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
// Manual apply-redemption should pick the oldest pending redemption
applyLoyaltyRedemption(t, bookingID, userID, ctx)
count := getDiscountRowCount(t, bookingID, tx, ctx)
assert.Equal(t, 1, count, "Exactly 1 loyalty discount row should be applied")
type redemptionRow struct {
ID string
Status string
}
rows, err := tx.Query(ctx, `
SELECT id, status FROM loyalty_redemptions WHERE user_id = $1 ORDER BY redeemed_at ASC
`, userID)
require.NoError(t, err)
defer rows.Close()
var redemptions []redemptionRow
for rows.Next() {
var r redemptionRow
require.NoError(t, rows.Scan(&r.ID, &r.Status))
redemptions = append(redemptions, r)
}
require.NoError(t, rows.Err())
require.Equal(t, 2, len(redemptions))
assert.Equal(t, "applied", redemptions[0].Status, "Oldest redemption should be applied")
assert.Equal(t, "pending", redemptions[1].Status, "Newer redemption should remain pending")
}
func TestDiscount_StampCountAboveTen(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID := createTestUser(t, 9, tx, ctx)
serviceID := createTestService(t, 50.00, tx, ctx)
// First booking: stamps 9 → 10, pending redemption auto-created at completion
bookingID1 := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
completeBooking(t, bookingID1, ctx)
backdateBooking(t, bookingID1, 2, ctx)
assert.Equal(t, 10, getStamps(t, userID, ctx), "Stamps should be 10 after first completion")
assert.Equal(t, 1, getPendingRedemptions(t, userID, ctx), "Pending redemption should be auto-created")
// Second booking: stamps accumulate to 11 (no auto-deduct)
bookingID2 := createPendingBooking(t, userID, serviceID, clock.Now().Add(48*time.Hour), tx, ctx)
// Second booking: stamps accumulate to 11 (no auto-deduct)
// Apply redemption while booking is still confirmed (not yet completed)
applyLoyaltyRedemption(t, bookingID2, userID, ctx)
assert.Equal(t, 0, getStamps(t, userID, ctx), "Stamps: 10 - 10 = 0")
assert.Equal(t, 0, getPendingRedemptions(t, userID, ctx), "Redemption consumed")
// Complete the booking — no stamp awarded (take or receive, never both)
completeBooking(t, bookingID2, ctx)
source, amount, exists := getDiscountForBooking(t, bookingID2, ctx)
require.True(t, exists, "Expected loyalty discount after manual redemption")
assert.Equal(t, "loyalty", source)
assert.Equal(t, 5.00, amount, "10% of £50 = £5")
assert.Equal(t, 0, getStamps(t, userID, ctx), "No stamp for a booking that used a loyalty redemption")
}
func TestDiscount_RedemptionAppliedBeforeStampIncrement(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID := createTestUser(t, 10, tx, ctx)
serviceID := createTestService(t, 50.00, tx, ctx)
_, err := tx.Exec(ctx, `
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
VALUES ($1, 10, 'pending', NOW())
`, userID)
require.NoError(t, err)
bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
// Apply redemption manually before completing
applyLoyaltyRedemption(t, bookingID, userID, ctx)
// Complete the booking — should NOT give a stamp (take or receive, never both)
completeBooking(t, bookingID, ctx)
assert.Equal(t, 0, getStamps(t, userID, ctx), "No stamp awarded when loyalty redemption was used")
assert.Equal(t, 0, getPendingRedemptions(t, userID, ctx), "No pending redemptions")
}
// =============================================================================
// Additional loyalty flow tests
// =============================================================================
func TestDiscount_NormalEarn_NoRedemption(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
// User starts at 5 stamps, completes 3 bookings without ever redeeming.
// Stamps should accumulate normally with no discount interference.
userID := createTestUser(t, 5, tx, ctx)
serviceID := createTestService(t, 50.00, tx, ctx)
booking1 := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
completeBooking(t, booking1, ctx)
assert.Equal(t, 6, getStamps(t, userID, ctx), "5 + 1 = 6")
assert.Equal(t, 0, getDiscountRowCount(t, booking1, tx, ctx), "No discounts applied")
assert.Equal(t, 0, getPendingRedemptions(t, userID, ctx), "No pending redemption (< 10)")
backdateBooking(t, booking1, 2, ctx)
booking2 := createPendingBooking(t, userID, serviceID, clock.Now().Add(48*time.Hour), tx, ctx)
completeBooking(t, booking2, ctx)
assert.Equal(t, 7, getStamps(t, userID, ctx), "6 + 1 = 7")
assert.Equal(t, 0, getDiscountRowCount(t, booking2, tx, ctx), "No discounts applied")
backdateBooking(t, booking2, 2, ctx)
booking3 := createPendingBooking(t, userID, serviceID, clock.Now().Add(72*time.Hour), tx, ctx)
completeBooking(t, booking3, ctx)
assert.Equal(t, 8, getStamps(t, userID, ctx), "7 + 1 = 8")
assert.Equal(t, 0, getDiscountRowCount(t, booking3, tx, ctx), "No discounts applied")
}
func TestDiscount_DepositBeforeRedemption_Rejected(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
// User pays a deposit upfront without loyalty — the first real payment has been
// made, so loyalty redemption should be rejected by the first-payment guard.
userID := createTestUser(t, 10, tx, ctx)
serviceID := createTestService(t, 200.00, tx, ctx)
_, err := tx.Exec(ctx, `
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
VALUES ($1, 10, 'pending', NOW())
`, userID)
require.NoError(t, err)
assert.Equal(t, 1, getPendingRedemptions(t, userID, ctx))
bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
// Pay a deposit first (the first real payment)
_, err = tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'deposit', 'online_square', 4000, 'completed', $2)
`, bookingID, userID)
require.NoError(t, err)
// Later at the till, admin tries to apply loyalty — should be rejected
handler := http.HandlerFunc(payments.ApplyLoyaltyRedemption)
req := httptest.NewRequest("POST", "/api/bookings/"+bookingID+"/apply-redemption", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", bookingID)
reqCtx := context.WithValue(ctx, chi.RouteCtxKey, rctx)
reqCtx = context.WithValue(reqCtx, mw.UserIDKey, userID)
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "customer")
req = req.WithContext(reqCtx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
require.Equal(t, http.StatusBadRequest, w.Code, "Expected 400 after deposit was paid")
// Verify nothing was changed — discount not applied, stamps intact, redemption pending
assert.Equal(t, 0, getDiscountRowCount(t, bookingID, tx, ctx), "No loyalty discount applied")
assert.Equal(t, 10, getStamps(t, userID, ctx), "Stamps not deducted")
assert.Equal(t, 1, getPendingRedemptions(t, userID, ctx), "Redemption still pending")
assert.Equal(t, 1, getTotalPaymentCount(t, bookingID, ctx), "Only the deposit payment exists")
}
func TestDiscount_RedemptionBeforeDeposit_DiscountLockedIn(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
// User applies loyalty redemption online first, then pays the deposit.
// The discount locks in at 10% of total and persists through to completion.
userID := createTestUser(t, 10, tx, ctx)
serviceID := createTestService(t, 200.00, tx, ctx)
_, err := tx.Exec(ctx, `
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
VALUES ($1, 10, 'pending', NOW())
`, userID)
require.NoError(t, err)
bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
// Apply loyalty redemption (simulating online payment checkbox)
applyLoyaltyRedemption(t, bookingID, userID, ctx)
// Discount should be 10% of total
source, amount, exists := getDiscountForBooking(t, bookingID, ctx)
require.True(t, exists)
assert.Equal(t, "loyalty", source)
assert.InDelta(t, 20.00, amount, 0.01, "Discount is 10%% of £200 = £20")
// Pay a deposit after redemption (£40 deposit on the current due)
_, err = tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'deposit', 'online_square', 4000, 'completed', $2)
`, bookingID, userID)
require.NoError(t, err)
// Verify: 2 payments (discount + deposit), discount still intact
assert.Equal(t, 2, getTotalPaymentCount(t, bookingID, ctx), "Expected 2 payment records (discount + deposit)")
// Complete the booking — no stamp awarded (take or receive)
completeBooking(t, bookingID, ctx)
assert.Equal(t, 0, getStamps(t, userID, ctx), "No stamp for a booking that used loyalty")
assert.Equal(t, 1, getPaymentDiscountRowCount(t, bookingID, ctx), "Discount payment still present after completion")
// Re-read discount after completion to confirm it wasn't altered
_, amountAfter, existsAfter := getDiscountForBooking(t, bookingID, ctx)
require.True(t, existsAfter)
assert.InDelta(t, 20.00, amountAfter, 0.01, "Discount amount unchanged after completion")
}
func TestDiscount_MultipleEarnCycles(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
// Two complete earn-and-redeem cycles: earn 10 → redeem → earn 10 more → redeem
userID := createTestUser(t, 0, tx, ctx)
serviceID := createTestService(t, 50.00, tx, ctx)
// Cycle 1: reach 10 stamps
_, err := tx.Exec(ctx, `UPDATE users SET loyalty_stamps = 10 WHERE id = $1`, userID)
require.NoError(t, err)
_, err = tx.Exec(ctx, `
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
VALUES ($1, 10, 'pending', NOW())
`, userID)
require.NoError(t, err)
assert.Equal(t, 10, getStamps(t, userID, ctx))
assert.Equal(t, 1, getPendingRedemptions(t, userID, ctx))
// Redeem on first booking
booking1 := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
applyLoyaltyRedemption(t, booking1, userID, ctx)
assert.Equal(t, 0, getStamps(t, userID, ctx))
assert.Equal(t, 0, getPendingRedemptions(t, userID, ctx))
// Complete booking — no stamp (take or receive)
completeBooking(t, booking1, ctx)
assert.Equal(t, 0, getStamps(t, userID, ctx), "No stamp — this booking used loyalty")
// Cycle 2: set stamps back to 10 via direct DB update
_, err = tx.Exec(ctx, `UPDATE users SET loyalty_stamps = 10 WHERE id = $1`, userID)
require.NoError(t, err)
// Create a new pending redemption for the second cycle
_, err = tx.Exec(ctx, `
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
VALUES ($1, 10, 'pending', NOW())
`, userID)
require.NoError(t, err)
assert.Equal(t, 10, getStamps(t, userID, ctx))
assert.Equal(t, 1, getPendingRedemptions(t, userID, ctx))
// Redeem again on a new booking
booking2 := createPendingBooking(t, userID, serviceID, clock.Now().Add(48*time.Hour), tx, ctx)
applyLoyaltyRedemption(t, booking2, userID, ctx)
assert.Equal(t, 0, getStamps(t, userID, ctx), "Stamps deducted again")
assert.Equal(t, 1, getDiscountRowCount(t, booking2, tx, ctx), "Second discount applied")
source, amount, exists := getDiscountForBooking(t, booking2, ctx)
require.True(t, exists)
assert.Equal(t, "loyalty", source)
assert.InDelta(t, 5.00, amount, 0.01, "10%% of £50 = £5")
}
func TestDiscount_MixedFreeAndPaidServices(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID := createTestUser(t, 0, tx, ctx)
serviceIDFree := createTestService(t, 0, tx, ctx)
serviceIDPaid := createTestService(t, 50.00, tx, ctx)
var bookingID string
err := tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status)
VALUES ($1, $2, 'confirmed')
RETURNING id
`, userID, clock.Now().Add(24*time.Hour)).Scan(&bookingID)
require.NoError(t, err)
// Insert two booking_services rows: one free, one paid
_, err = tx.Exec(ctx, `
INSERT INTO booking_services (booking_id, service_id)
VALUES ($1, $2), ($1, $3)
`, bookingID, serviceIDFree, serviceIDPaid)
require.NoError(t, err)
completeBooking(t, bookingID, ctx)
assert.Equal(t, 1, getStamps(t, userID, ctx), "Mixed free/paid booking earns 1 stamp (total > 0)")
}
func TestDiscount_CampaignBoundaryStart(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
var campaignID string
err := tx.QueryRow(ctx, `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed)
VALUES ($1, 'time_based', 10.0, 'active', NOW(), NOW() + INTERVAL '1 day', 0)
RETURNING id
`, "Boundary Start").Scan(&campaignID)
require.NoError(t, err)
userID := createTestUser(t, 0, tx, ctx)
serviceID := createTestService(t, 50.00, tx, ctx)
bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
completeBooking(t, bookingID, ctx)
count := getDiscountRowCount(t, bookingID, tx, ctx)
assert.Equal(t, 1, count, "Campaign at start_date boundary should apply")
}
func TestDiscount_CampaignBoundaryEnd(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
var campaignID string
err := tx.QueryRow(ctx, `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed)
VALUES ($1, 'time_based', 10.0, 'active', NOW() - INTERVAL '1 day', NOW() + INTERVAL '1 minute', 0)
RETURNING id
`, "Boundary End").Scan(&campaignID)
require.NoError(t, err)
userID := createTestUser(t, 0, tx, ctx)
serviceID := createTestService(t, 50.00, tx, ctx)
bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
completeBooking(t, bookingID, ctx)
count := getDiscountRowCount(t, bookingID, tx, ctx)
assert.Equal(t, 1, count, "Campaign at end_date boundary should apply")
}
func TestDiscount_CampaignExpiredDoesNotApply(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
var campaignID string
err := tx.QueryRow(ctx, `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed)
VALUES ($1, 'time_based', 10.0, 'active', NOW() - INTERVAL '2 days', NOW() - INTERVAL '1 day', 0)
RETURNING id
`, "Expired Campaign").Scan(&campaignID)
require.NoError(t, err)
userID := createTestUser(t, 0, tx, ctx)
serviceID := createTestService(t, 50.00, tx, ctx)
bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
completeBooking(t, bookingID, ctx)
count := getDiscountRowCount(t, bookingID, tx, ctx)
assert.Equal(t, 0, count, "Expired campaign should not apply")
}
func TestDiscount_CampaignDraftDoesNotApply(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_ = createTestCampaignWithStatus(t, "Draft Campaign", "time_based", 10.0, "draft", nil, nil, nil, nil, tx, ctx)
userID := createTestUser(t, 0, tx, ctx)
serviceID := createTestService(t, 50.00, tx, ctx)
bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
completeBooking(t, bookingID, ctx)
count := getDiscountRowCount(t, bookingID, tx, ctx)
assert.Equal(t, 0, count, "Draft campaign should not apply")
}
func TestDiscount_CampaignCancelledDoesNotApply(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_ = createTestCampaignWithStatus(t, "Cancelled Campaign", "time_based", 10.0, "cancelled", nil, nil, nil, nil, tx, ctx)
userID := createTestUser(t, 0, tx, ctx)
serviceID := createTestService(t, 50.00, tx, ctx)
bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
completeBooking(t, bookingID, ctx)
count := getDiscountRowCount(t, bookingID, tx, ctx)
assert.Equal(t, 0, count, "Cancelled campaign should not apply")
}
func TestDiscount_PriceOverrideRespected(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_ = createTestCampaign(t, "10% Off", "time_based", 10.0, nil, nil, nil, nil, tx, ctx)
userID := createTestUser(t, 0, tx, ctx)
serviceID := createTestService(t, 100.00, tx, ctx)
// Create booking with override_price = £80
var bookingID string
err := tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status)
VALUES ($1, $2, 'confirmed')
RETURNING id
`, userID, clock.Now().Add(24*time.Hour)).Scan(&bookingID)
require.NoError(t, err)
_, err = tx.Exec(ctx, `
INSERT INTO booking_services (booking_id, service_id, override_price)
VALUES ($1, $2, $3)
`, bookingID, serviceID, 80.00)
require.NoError(t, err)
completeBooking(t, bookingID, ctx)
source, amount, exists := getDiscountForBooking(t, bookingID, ctx)
require.True(t, exists)
assert.Equal(t, "campaign", source)
assert.Equal(t, 8.00, amount, "10%% of £80 override = £8.00")
}
func TestDiscount_AnniversaryDedupWithStacking(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
milestoneValue := 12
milestoneType := "anniversary"
milestoneUnit := "months"
_ = createTestCampaign(t, "1 Year Anniversary", "milestone", 10.0, &milestoneType, &milestoneUnit, &milestoneValue, nil, tx, ctx)
_ = createTestCampaign(t, "Spring Sale", "time_based", 5.0, nil, nil, nil, nil, tx, ctx)
userID := createTestUser(t, 0, tx, ctx)
serviceID := createTestService(t, 60.00, tx, ctx)
// Create a completed booking 400 days ago (> 12 months)
fourHundredDaysAgo := clock.Now().AddDate(0, 0, -400)
var firstBookingID string
err := tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status)
VALUES ($1, $2, 'completed')
RETURNING id
`, userID, fourHundredDaysAgo).Scan(&firstBookingID)
require.NoError(t, err)
_, err = tx.Exec(ctx, `
INSERT INTO booking_services (booking_id, service_id, override_price)
VALUES ($1, $2, $3)
`, firstBookingID, serviceID, 60.00)
require.NoError(t, err)
// First booking after anniversary threshold: should get anniversary + time_based
bookingID1 := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
completeBooking(t, bookingID1, ctx)
discounts1 := getAllDiscountsForBooking(t, bookingID1, ctx)
assert.Equal(t, 2, len(discounts1), "First booking should get anniversary + time_based discounts")
// Second booking (different day): should only get time_based (anniversary dedup)
bookingID2 := createPendingBooking(t, userID, serviceID, clock.Now().Add(48*time.Hour), tx, ctx)
completeBooking(t, bookingID2, ctx)
discounts2 := getAllDiscountsForBooking(t, bookingID2, ctx)
assert.Equal(t, 1, len(discounts2), "Second booking should only get time_based (anniversary dedup)")
// Verify the remaining discount is time_based
foundTimeBased := false
for _, d := range discounts2 {
if d.CampType == "time_based" {
foundTimeBased = true
}
}
assert.True(t, foundTimeBased, "Remaining discount should be time_based")
}
func TestDiscount_PerUserMilestoneDedupWithStacking(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
milestoneValue := 3
milestoneType := "per_user_booking_count"
_ = createTestCampaign(t, "3rd Visit", "milestone", 10.0, &milestoneType, nil, &milestoneValue, nil, tx, ctx)
_ = createTestCampaign(t, "May Sale", "time_based", 5.0, nil, nil, nil, nil, tx, ctx)
userID := createTestUser(t, 0, tx, ctx)
serviceID := createTestService(t, 50.00, tx, ctx)
// Create 2 prior completed bookings on different days
for i := 0; i < 2; i++ {
startTime := clock.Now().AddDate(0, 0, -10-i*7)
_ = createCompletedBooking(t, userID, serviceID, startTime, 50.00, ctx)
}
// 3rd booking: milestone + time_based
bookingID3 := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
completeBooking(t, bookingID3, ctx)
discounts3 := getAllDiscountsForBooking(t, bookingID3, ctx)
assert.Equal(t, 2, len(discounts3), "3rd booking should get per-user milestone + time_based")
// 4th booking (different day): only time_based (milestone dedup)
bookingID4 := createPendingBooking(t, userID, serviceID, clock.Now().Add(48*time.Hour), tx, ctx)
completeBooking(t, bookingID4, ctx)
discounts4 := getAllDiscountsForBooking(t, bookingID4, ctx)
assert.Equal(t, 1, len(discounts4), "4th booking should only get time_based (milestone dedup)")
foundTimeBased := false
for _, d := range discounts4 {
if d.CampType == "time_based" {
foundTimeBased = true
}
}
assert.True(t, foundTimeBased, "Remaining discount should be time_based")
}
func TestDiscount_GlobalMilestoneMaxRedemptionsWithStacking(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
maxRedemptions := 1
milestoneValue := 5
milestoneType := "global_booking_count"
_ = createTestCampaign(t, "5th Customer", "milestone", 5.0, &milestoneType, nil, &milestoneValue, &maxRedemptions, tx, ctx)
_ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil, tx, ctx)
userID1 := createTestUser(t, 0, tx, ctx)
userID2 := createTestUser(t, 0, tx, ctx)
serviceID := createTestService(t, 50.00, tx, ctx)
// Create 4 completed bookings (different days, user1)
for i := 0; i < 4; i++ {
startTime := clock.Now().AddDate(0, 0, -10-i*7)
_ = createCompletedBooking(t, userID1, serviceID, startTime, 50.00, ctx)
}
// 5th global booking (user1, different day): milestone + time_based
bookingID5 := createPendingBooking(t, userID1, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
insertInPersonCardPayment(t, bookingID5, ctx)
completeBooking(t, bookingID5, ctx)
discounts5 := getAllDiscountsForBooking(t, bookingID5, ctx)
assert.Equal(t, 2, len(discounts5), "5th global booking should get milestone + time_based")
// 6th global booking (user2, different day): only time_based (max_redemptions reached)
bookingID6 := createPendingBooking(t, userID2, serviceID, clock.Now().Add(48*time.Hour), tx, ctx)
insertInPersonCardPayment(t, bookingID6, ctx)
completeBooking(t, bookingID6, ctx)
discounts6 := getAllDiscountsForBooking(t, bookingID6, ctx)
assert.Equal(t, 1, len(discounts6), "6th global booking should only get time_based (max_redemptions reached)")
foundTimeBased := false
for _, d := range discounts6 {
if d.CampType == "time_based" {
foundTimeBased = true
}
}
assert.True(t, foundTimeBased, "Remaining discount should be time_based")
}
func TestDiscount_BestTimeBasedCampaignSelected(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_ = createTestCampaign(t, "Low Sale", "time_based", 5.0, nil, nil, nil, nil, tx, ctx)
_ = createTestCampaign(t, "High Sale", "time_based", 15.0, nil, nil, nil, nil, tx, ctx)
userID := createTestUser(t, 0, tx, ctx)
serviceID := createTestService(t, 100.00, tx, ctx)
bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
completeBooking(t, bookingID, ctx)
count := getDiscountRowCount(t, bookingID, tx, ctx)
assert.Equal(t, 1, count, "Only 1 campaign discount row (best selected)")
source, amount, exists := getDiscountForBooking(t, bookingID, ctx)
require.True(t, exists)
assert.Equal(t, "campaign", source)
assert.Equal(t, 15.00, amount, "15%% of £100 = £15 (highest percent selected)")
}
func TestDiscount_FirstBookingEarnsStamp(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID := createTestUser(t, 0, tx, ctx)
serviceID := createTestService(t, 50.00, tx, ctx)
bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
completeBooking(t, bookingID, ctx)
assert.Equal(t, 1, getStamps(t, userID, ctx), "First paid booking earns 1 stamp")
}
func TestDiscount_TenStampsCreatesRedemption(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID := createTestUser(t, 9, tx, ctx)
serviceID := createTestService(t, 50.00, tx, ctx)
bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
completeBooking(t, bookingID, ctx)
assert.Equal(t, 10, getStamps(t, userID, ctx), "Stamps should reach 10")
assert.Equal(t, 1, getPendingRedemptions(t, userID, ctx), "1 pending redemption should be auto-created")
}
// =============================================================================
// Campaign Status Lifecycle Tests
// =============================================================================
func TestCampaign_CreateAsDraft(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
var campaignID string
err := tx.QueryRow(ctx, `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed)
VALUES ($1, 'time_based', 10.0, 'draft', NOW(), NOW() + INTERVAL '1 day', 0)
RETURNING id
`, "Draft Campaign").Scan(&campaignID)
require.NoError(t, err)
var status string
err = tx.QueryRow(ctx, `SELECT status FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&status)
require.NoError(t, err)
assert.Equal(t, "draft", status)
}
func TestCampaign_ActivateDraft(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
var campaignID string
err := tx.QueryRow(ctx, `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed)
VALUES ($1, 'time_based', 10.0, 'draft', NOW(), NOW() + INTERVAL '1 day', 0)
RETURNING id
`, "Draft to Active").Scan(&campaignID)
require.NoError(t, err)
_, err = tx.Exec(ctx, `UPDATE discount_campaigns SET status = 'active' WHERE id = $1`, campaignID)
require.NoError(t, err)
var status string
err = tx.QueryRow(ctx, `SELECT status FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&status)
require.NoError(t, err)
assert.Equal(t, "active", status)
userID := createTestUser(t, 0, tx, ctx)
serviceID := createTestService(t, 50.00, tx, ctx)
bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
completeBooking(t, bookingID, ctx)
count := getDiscountRowCount(t, bookingID, tx, ctx)
assert.Equal(t, 1, count, "Activated draft campaign should apply")
}
func TestCampaign_CompleteActive(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
var campaignID string
err := tx.QueryRow(ctx, `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed)
VALUES ($1, 'time_based', 10.0, 'active', NOW(), NOW() + INTERVAL '1 day', 0)
RETURNING id
`, "Active to Completed").Scan(&campaignID)
require.NoError(t, err)
_, err = tx.Exec(ctx, `UPDATE discount_campaigns SET status = 'completed' WHERE id = $1`, campaignID)
require.NoError(t, err)
userID := createTestUser(t, 0, tx, ctx)
serviceID := createTestService(t, 50.00, tx, ctx)
bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
completeBooking(t, bookingID, ctx)
count := getDiscountRowCount(t, bookingID, tx, ctx)
assert.Equal(t, 0, count, "Completed campaign should not apply")
}
func TestCampaign_CancelActive(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
var campaignID string
err := tx.QueryRow(ctx, `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed)
VALUES ($1, 'time_based', 10.0, 'active', NOW(), NOW() + INTERVAL '1 day', 0)
RETURNING id
`, "Active to Cancelled").Scan(&campaignID)
require.NoError(t, err)
_, err = tx.Exec(ctx, `UPDATE discount_campaigns SET status = 'cancelled' WHERE id = $1`, campaignID)
require.NoError(t, err)
userID := createTestUser(t, 0, tx, ctx)
serviceID := createTestService(t, 50.00, tx, ctx)
bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
completeBooking(t, bookingID, ctx)
count := getDiscountRowCount(t, bookingID, tx, ctx)
assert.Equal(t, 0, count, "Cancelled campaign should not apply")
}
func TestCampaign_RevertToDraft(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
var campaignID string
err := tx.QueryRow(ctx, `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed)
VALUES ($1, 'time_based', 10.0, 'active', NOW(), NOW() + INTERVAL '1 day', 0)
RETURNING id
`, "Active to Draft").Scan(&campaignID)
require.NoError(t, err)
_, err = tx.Exec(ctx, `UPDATE discount_campaigns SET status = 'draft' WHERE id = $1`, campaignID)
require.NoError(t, err)
userID := createTestUser(t, 0, tx, ctx)
serviceID := createTestService(t, 50.00, tx, ctx)
bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
completeBooking(t, bookingID, ctx)
count := getDiscountRowCount(t, bookingID, tx, ctx)
assert.Equal(t, 0, count, "Reverted-to-draft campaign should not apply")
}
func TestCampaign_DraftDoesNotApplyDiscounts(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_ = createTestCampaignWithStatus(t, "Draft Only", "time_based", 10.0, "draft", nil, nil, nil, nil, tx, ctx)
userID := createTestUser(t, 0, tx, ctx)
serviceID := createTestService(t, 50.00, tx, ctx)
bookingID := createPendingBooking(t, userID, serviceID, clock.Now().Add(24*time.Hour), tx, ctx)
completeBooking(t, bookingID, ctx)
count := getDiscountRowCount(t, bookingID, tx, ctx)
assert.Equal(t, 0, count, "Draft campaign should not apply discounts")
}