Files
Crussell/backend/handlers/payments/loyalty_test.go
T
popertotsandSisyphus 7b24f8e484 refactor(payments): integrate VAT into gift card buy flow and wrap in transactions
Refactor BuyGiftCard to insert pending payment before Square call with VAT applied. Add transaction wrapping to gift card handlers. Remove redundant Content-Type header sets. Migrate all time.Now() to clock.Now().

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

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

619 lines
21 KiB
Go

//go:build test && dev
// +build test,dev
package payments
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"crussell/clock"
"crussell/db"
"crussell/testutils"
"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, ctx context.Context, q db.Querier, stamps int) (string, string, string) {
t.Helper()
userID, err := fixtures.CreateTestUser(q)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, err = q.Exec(ctx,
"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 = q.Exec(ctx, `
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(q)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(q, 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 = q.Exec(ctx,
"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, ctx context.Context) *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(ctx, 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) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, userToken := setupLoyaltyUser(t, ctx, tx, 10)
w := makeApplyRedemptionRequest(bookingID, userToken, ctx)
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 := tx.QueryRow(ctx,
"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 = tx.QueryRow(ctx, "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 = tx.QueryRow(ctx,
"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) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, userToken := setupLoyaltyUser(t, ctx, tx, 5)
w := makeApplyRedemptionRequest(bookingID, userToken, ctx)
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) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, userToken := setupLoyaltyUser(t, ctx, tx, 10)
// First call should succeed
w := makeApplyRedemptionRequest(bookingID, userToken, ctx)
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, ctx)
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
tx.QueryRow(ctx,
"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) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, userToken := setupLoyaltyUser(t, ctx, tx, 10)
// Set booking to a terminal status
_, err := tx.Exec(ctx,
"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, ctx)
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, ctx context.Context, q db.Querier) (string, string, string) {
t.Helper()
userID, err := fixtures.CreateTestUser(q)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(q)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(q, 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 = q.Exec(ctx,
"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) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupCampaignTest(t, ctx, tx)
// Create an active time-based campaign
now := clock.Now()
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, '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 = tx.Exec(ctx, `
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(ctx, tx, bookingID, userID)
// Verify booking_discounts was created
var discountCount int
tx.QueryRow(ctx,
"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
tx.QueryRow(ctx,
"SELECT times_redeemed FROM discount_campaigns WHERE id = $1", campaignID).Scan(&timesRedeemed)
if timesRedeemed != 1 {
t.Errorf("expected 1 redemption, got %d", timesRedeemed)
}
}
func TestCampaignAutoApply_UserMilestone(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupCampaignTest(t, ctx, tx)
// Give user 5 completed bookings to match milestone_value=5
for i := 0; i < 5; i++ {
var bid string
tx.QueryRow(ctx, `
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 := tx.QueryRow(ctx, `
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 = tx.Exec(ctx, `
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(ctx, tx, bookingID, userID)
var discountCount int
tx.QueryRow(ctx,
"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) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupCampaignTest(t, ctx, tx)
// Set global completed count high enough
now := clock.Now()
for i := 0; i < 100; i++ {
tx.QueryRow(ctx, `
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 := tx.QueryRow(ctx, `
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 = tx.Exec(ctx, `
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(ctx, tx, bookingID, userID)
// Verify NO discount was applied (global milestone skipped for online payment)
var discountCount int
tx.QueryRow(ctx,
"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) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupCampaignTest(t, ctx, tx)
for i := 0; i < 100; i++ {
tx.QueryRow(ctx, `
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 := tx.QueryRow(ctx, `
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 = tx.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)
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
// Simulate what CreateBookingPayment does: set status to confirmed after payment
tx.Exec(ctx,
"UPDATE bookings SET status = 'confirmed', updated_at = NOW() WHERE id = $1", bookingID)
applyEligibleCampaignsAtPayment(ctx, tx, bookingID, userID)
var discountCount int
tx.QueryRow(ctx,
"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) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupCampaignTest(t, ctx, tx)
now := clock.Now()
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, '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 = tx.Exec(ctx, `
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
tx.Exec(ctx,
"UPDATE discount_campaigns SET times_redeemed = 1 WHERE id = $1", campaignID)
// Insert payment to trigger applyEligibleCampaignsAtPayment
tx.Exec(ctx, `
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(ctx, tx, bookingID, userID)
// Verify still only 1 discount
var discountCount int
tx.QueryRow(ctx,
"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)
}
}
// =============================================================================
// Referral discount via applyEligibleCampaignsAtPayment
// =============================================================================
func TestCampaignAutoApply_ReferralDiscount(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupCampaignTest(t, ctx, tx)
// Create the referral + discount
var refID string
err := tx.QueryRow(ctx, `
INSERT INTO user_referrals (referrer_id, referred_id)
VALUES ($1, $2)
RETURNING id
`, userID, userID).Scan(&refID)
if err != nil {
t.Fatalf("failed to create referral: %v", err)
}
var rdID string
err = tx.QueryRow(ctx, `
INSERT INTO referral_discounts (user_id, referral_id, discount_percent, used)
VALUES ($1, $2, 10.00, false)
RETURNING id
`, userID, refID).Scan(&rdID)
if err != nil {
t.Fatalf("failed to insert referral discount: %v", err)
}
// Insert payment to trigger auto-apply
_, err = tx.Exec(ctx, `
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(ctx, tx, bookingID, userID)
// Verify referral discount was applied
var discountCount int
err = tx.QueryRow(ctx,
`SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'referral'`, bookingID).Scan(&discountCount)
if err != nil {
t.Fatalf("failed to count referral discounts: %v", err)
}
if discountCount != 1 {
t.Errorf("expected 1 referral discount, got %d", discountCount)
}
// Verify referral discount was marked as used
var used bool
err = tx.QueryRow(ctx,
"SELECT used FROM referral_discounts WHERE id = $1", rdID).Scan(&used)
if err != nil {
t.Fatalf("failed to query referral discount: %v", err)
}
if !used {
t.Error("expected referral discount to be marked as used")
}
// Verify discount payment was created
var paymentCount int
tx.QueryRow(ctx,
`SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'discount'`, bookingID).Scan(&paymentCount)
if paymentCount == 0 {
t.Error("expected at least 1 discount payment to be created")
}
}
func TestCampaignAutoApply_ReferralDiscount_DoubleApplyGuard(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupCampaignTest(t, ctx, tx)
var refID string
err := tx.QueryRow(ctx, `
INSERT INTO user_referrals (referrer_id, referred_id)
VALUES ($1, $2)
RETURNING id
`, userID, userID).Scan(&refID)
if err != nil {
t.Fatalf("failed to create referral: %v", err)
}
var rdID string
err = tx.QueryRow(ctx, `
INSERT INTO referral_discounts (user_id, referral_id, discount_percent, used)
VALUES ($1, $2, 10.00, false)
RETURNING id
`, userID, refID).Scan(&rdID)
if err != nil {
t.Fatalf("failed to insert referral discount: %v", err)
}
// Pre-apply the referral discount to simulate it was applied on a previous attempt
_, err = tx.Exec(ctx, `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'referral', $3, 10.00, 5000, 500)
`, bookingID, userID, rdID)
if err != nil {
t.Fatalf("failed to insert existing booking discount: %v", err)
}
applyEligibleCampaignsAtPayment(ctx, tx, bookingID, userID)
// Verify no second referral discount was applied
var discountCount int
tx.QueryRow(ctx,
`SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'referral'`, bookingID).Scan(&discountCount)
if discountCount != 1 {
t.Errorf("expected 1 referral discount (no double-apply), got %d", discountCount)
}
// Verify referral discount is still unused (since the function should skip it)
var used bool
tx.QueryRow(ctx,
"SELECT used FROM referral_discounts WHERE id = $1", rdID).Scan(&used)
if used {
t.Error("expected referral discount to remain unused (skipped by double-apply guard)")
}
}
func TestCampaignAutoApply_ReferralDiscount_AlreadyUsed(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupCampaignTest(t, ctx, tx)
var refID string
err := tx.QueryRow(ctx, `
INSERT INTO user_referrals (referrer_id, referred_id)
VALUES ($1, $2)
RETURNING id
`, userID, userID).Scan(&refID)
if err != nil {
t.Fatalf("failed to create referral: %v", err)
}
// Create an already-used referral discount
var rdID string
err = tx.QueryRow(ctx, `
INSERT INTO referral_discounts (user_id, referral_id, discount_percent, used)
VALUES ($1, $2, 10.00, true)
RETURNING id
`, userID, refID).Scan(&rdID)
if err != nil {
t.Fatalf("failed to insert used referral discount: %v", err)
}
applyEligibleCampaignsAtPayment(ctx, tx, bookingID, userID)
var discountCount int
tx.QueryRow(ctx,
`SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'referral'`, bookingID).Scan(&discountCount)
if discountCount != 0 {
t.Errorf("expected 0 referral discounts (already used), got %d", discountCount)
}
}