feat(backend): add payment discount preview and status tests

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-18 16:26:34 +01:00
co-authored by Sisyphus
parent 3cb5a3e360
commit aeab289be9
3 changed files with 1274 additions and 0 deletions
@@ -0,0 +1,639 @@
//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"
)
// =============================================================================
// GetDiscountPreviewHandler — GET /api/bookings/{id}/discount-preview
// =============================================================================
// setupDiscountPreviewTest creates a user, service, and booking for discount preview tests.
func setupDiscountPreviewTest(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 confirm booking: %v", err)
}
token := jwt.GenerateUserToken(userID)
return userID, bookingID, token
}
// serveDiscountPreviewHandler wires chi context with {id} param and serves the handler.
// Pass userID explicitly so the handler sees the correct user in context.
func serveDiscountPreviewHandler(bookingID, userID, token string) *httptest.ResponseRecorder {
handler := http.HandlerFunc(GetDiscountPreviewHandler)
req := httptest.NewRequest("GET", "/api/bookings/"+bookingID+"/discount-preview", nil)
req.Header.Set("Authorization", "Bearer "+token)
ctx := req.Context()
ctx = context.WithValue(ctx, mw.UserIDKey, userID)
ctx = context.WithValue(ctx, mw.UserRoleKey, "verified_email")
req = req.WithContext(ctx)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", bookingID)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
return w
}
// TestDiscountPreview_NoCampaigns returns eligible=false when no active campaigns exist.
func TestDiscountPreview_NoCampaigns(t *testing.T) {
resetTestData(t)
userID, bookingID, token := setupDiscountPreviewTest(t)
defer fixtures.DeleteUser(db.DB, userID)
w := serveDiscountPreviewHandler(bookingID, userID, token)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp DiscountPreviewResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.Eligible {
t.Error("expected eligible=false with no active campaigns")
}
if len(resp.Discounts) != 0 {
t.Errorf("expected 0 discounts, got %d", len(resp.Discounts))
}
if resp.OriginalTotal <= 0 {
t.Errorf("expected positive original_total, got %f", resp.OriginalTotal)
}
if resp.DiscountedTotal != resp.OriginalTotal {
t.Errorf("expected discounted_total == original_total with no discounts, got %f vs %f",
resp.DiscountedTotal, resp.OriginalTotal)
}
}
// TestDiscountPreview_TimeBasedCampaign returns the correct discount for an active time-based campaign.
func TestDiscountPreview_TimeBasedCampaign(t *testing.T) {
resetTestData(t)
userID, bookingID, token := setupDiscountPreviewTest(t)
defer fixtures.DeleteUser(db.DB, userID)
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 ('Test Sale', 'time_based', 15, 'active', $1, $2, 0)
RETURNING id
`, now.Add(-24*time.Hour), now.Add(24*time.Hour)).Scan(&campaignID)
if err != nil {
t.Fatalf("failed to create campaign: %v", err)
}
w := serveDiscountPreviewHandler(bookingID, userID, token)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp DiscountPreviewResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if !resp.Eligible {
t.Fatal("expected eligible=true with active time-based campaign")
}
if len(resp.Discounts) != 1 {
t.Fatalf("expected 1 discount, got %d", len(resp.Discounts))
}
d := resp.Discounts[0]
if d.Source != "campaign" {
t.Errorf("expected source 'campaign', got %q", d.Source)
}
if d.Percent != 15 {
t.Errorf("expected 15 percent, got %f", d.Percent)
}
if d.Amount <= 0 {
t.Errorf("expected positive discount amount, got %f", d.Amount)
}
expectedDiscount := resp.OriginalTotal * 15 / 100
if d.Amount != expectedDiscount {
t.Errorf("expected discount amount %f, got %f", expectedDiscount, d.Amount)
}
if resp.DiscountedTotal != resp.OriginalTotal-d.Amount {
t.Errorf("discounted_total %f should be original %f - discount %f",
resp.DiscountedTotal, resp.OriginalTotal, d.Amount)
}
// Verify preview did NOT create any actual booking_discounts or payment records
var discountCount int
db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1", bookingID).Scan(&discountCount)
if discountCount != 0 {
t.Errorf("preview should not create booking_discounts, found %d", discountCount)
}
var paymentCount int
db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'discount'", bookingID).Scan(&paymentCount)
if paymentCount != 0 {
t.Errorf("preview should not create discount payment records, found %d", paymentCount)
}
// Verify campaign redemption count was NOT incremented
var timesRedeemed int
db.DB.QueryRow(context.Background(),
"SELECT times_redeemed FROM discount_campaigns WHERE id = $1", campaignID).Scan(&timesRedeemed)
if timesRedeemed != 0 {
t.Errorf("preview should not increment times_redeemed, got %d", timesRedeemed)
}
}
// TestDiscountPreview_CampaignExhausted returns not eligible when a campaign has reached max_redemptions.
func TestDiscountPreview_CampaignExhausted(t *testing.T) {
resetTestData(t)
userID, bookingID, token := setupDiscountPreviewTest(t)
defer fixtures.DeleteUser(db.DB, userID)
now := time.Now()
_, err := db.DB.Exec(context.Background(), `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, max_redemptions, times_redeemed)
VALUES ('Exhausted Campaign', 'time_based', 20, 'active', $1, $2, 5, 5)
`, now.Add(-24*time.Hour), now.Add(24*time.Hour))
if err != nil {
t.Fatalf("failed to create campaign: %v", err)
}
w := serveDiscountPreviewHandler(bookingID, userID, token)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp DiscountPreviewResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.Eligible {
t.Error("expected eligible=false when campaign is exhausted")
}
}
// TestDiscountPreview_CampaignExpired returns not eligible for a past campaign.
func TestDiscountPreview_CampaignExpired(t *testing.T) {
resetTestData(t)
userID, bookingID, token := setupDiscountPreviewTest(t)
defer fixtures.DeleteUser(db.DB, userID)
_, err := db.DB.Exec(context.Background(), `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date)
VALUES ('Expired Campaign', 'time_based', 10, 'active', $1, $2)
`, time.Now().Add(-72*time.Hour), time.Now().Add(-24*time.Hour))
if err != nil {
t.Fatalf("failed to create campaign: %v", err)
}
w := serveDiscountPreviewHandler(bookingID, userID, token)
var resp DiscountPreviewResponse
json.NewDecoder(w.Body).Decode(&resp)
if resp.Eligible {
t.Error("expected eligible=false when campaign has expired")
}
}
// TestDiscountPreview_CampaignNotStarted returns not eligible for a future campaign.
func TestDiscountPreview_CampaignNotStarted(t *testing.T) {
resetTestData(t)
userID, bookingID, token := setupDiscountPreviewTest(t)
defer fixtures.DeleteUser(db.DB, userID)
_, err := db.DB.Exec(context.Background(), `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date)
VALUES ('Future Campaign', 'time_based', 10, 'active', $1, $2)
`, time.Now().Add(24*time.Hour), time.Now().Add(72*time.Hour))
if err != nil {
t.Fatalf("failed to create campaign: %v", err)
}
w := serveDiscountPreviewHandler(bookingID, userID, token)
var resp DiscountPreviewResponse
json.NewDecoder(w.Body).Decode(&resp)
if resp.Eligible {
t.Error("expected eligible=false when campaign has not started")
}
}
// TestDiscountPreview_MilestoneCampaign returns the discount for a milestone campaign the user has reached.
func TestDiscountPreview_MilestoneCampaign(t *testing.T) {
resetTestData(t)
userID, bookingID, token := setupDiscountPreviewTest(t)
defer fixtures.DeleteUser(db.DB, userID)
// Create a completed booking so the user has a booking count of at least 1 (plus the current test booking)
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
pastBookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create completed booking: %v", err)
}
// The fixture creates bookings with status 'pending' — mark as completed for milestone counting
_, err = db.DB.Exec(context.Background(), `UPDATE bookings SET status = 'completed' WHERE id = $1`, pastBookingID)
if err != nil {
t.Fatalf("failed to mark booking as completed: %v", err)
}
// Create milestone campaign for 1st booking
_, err = db.DB.Exec(context.Background(), `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value)
VALUES ('First Booking Bonus', 'milestone', 25, 'active', $1, $2, 'per_user_booking_count', 1)
`, time.Now().Add(-24*time.Hour), time.Now().Add(24*time.Hour))
if err != nil {
t.Fatalf("failed to create milestone campaign: %v", err)
}
w := serveDiscountPreviewHandler(bookingID, userID, token)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp DiscountPreviewResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if !resp.Eligible {
t.Fatal("expected eligible=true for milestone campaign")
}
foundMilestone := false
for _, d := range resp.Discounts {
if d.Percent == 25 {
foundMilestone = true
break
}
}
if !foundMilestone {
t.Error("expected a 25% milestone discount in the preview")
}
}
// TestDiscountPreview_AlreadyApplied excludes discounts already applied to this booking.
func TestDiscountPreview_AlreadyApplied(t *testing.T) {
resetTestData(t)
userID, bookingID, token := setupDiscountPreviewTest(t)
defer fixtures.DeleteUser(db.DB, userID)
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 ('Already Applied', 'time_based', 10, 'active', $1, $2, 0)
RETURNING id
`, now.Add(-24*time.Hour), now.Add(24*time.Hour)).Scan(&campaignID)
if err != nil {
t.Fatalf("failed to create campaign: %v", err)
}
// Simulate the discount already being applied by inserting a booking_discounts record
var bookingTotal float64
db.DB.QueryRow(context.Background(), `
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
) sub
`, bookingID).Scan(&bookingTotal)
discountAmount := bookingTotal * 10 / 100
_, err = db.DB.Exec(context.Background(), `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'campaign', $3, 10, $4, $5)
`, bookingID, userID, campaignID, bookingTotal, discountAmount)
if err != nil {
t.Fatalf("failed to apply discount: %v", err)
}
w := serveDiscountPreviewHandler(bookingID, userID, token)
var resp DiscountPreviewResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.Eligible {
t.Error("expected eligible=false when discount already applied to this booking")
}
}
// TestDiscountPreview_InvalidBookingID returns 400 for invalid booking ID.
func TestDiscountPreview_InvalidBookingID(t *testing.T) {
handler := http.HandlerFunc(GetDiscountPreviewHandler)
req := httptest.NewRequest("GET", "/api/bookings/invalid/discount-preview", nil)
ctx := context.WithValue(req.Context(), mw.UserIDKey, "test-user-id")
ctx = context.WithValue(ctx, mw.UserRoleKey, "verified_email")
req = req.WithContext(ctx)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", "invalid-id")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for invalid booking ID, got %d", w.Code)
}
}
// TestDiscountPreview_MultipleCampaigns returns all eligible discounts.
func TestDiscountPreview_MultipleCampaigns(t *testing.T) {
resetTestData(t)
userID, bookingID, token := setupDiscountPreviewTest(t)
defer fixtures.DeleteUser(db.DB, userID)
now := time.Now()
// Create two active campaigns
_, err := db.DB.Exec(context.Background(), `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date)
VALUES ('Summer Sale', 'time_based', 15, 'active', $1, $2)
`, now.Add(-48*time.Hour), now.Add(48*time.Hour))
if err != nil {
t.Fatalf("failed to create first campaign: %v", err)
}
_, err = db.DB.Exec(context.Background(), `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, max_redemptions, times_redeemed)
VALUES ('Flash Sale', 'time_based', 20, 'active', $1, $2, 100, 0)
`, now.Add(-24*time.Hour), now.Add(24*time.Hour))
if err != nil {
t.Fatalf("failed to create second campaign: %v", err)
}
w := serveDiscountPreviewHandler(bookingID, userID, token)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp DiscountPreviewResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if !resp.Eligible {
t.Fatal("expected eligible=true with multiple active campaigns")
}
if len(resp.Discounts) < 1 {
t.Fatalf("expected at least 1 discount, got %d", len(resp.Discounts))
}
}
// TestDiscountPreview_AnniversaryCampaign returns the correct discount for an anniversary milestone.
func TestDiscountPreview_AnniversaryCampaign(t *testing.T) {
resetTestData(t)
userID, bookingID, token := setupDiscountPreviewTest(t)
defer fixtures.DeleteUser(db.DB, userID)
// Set first booking to be years ago so anniversary qualifies
db.DB.Exec(context.Background(), `
INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'completed')
`, userID, time.Date(2020, 1, 15, 10, 0, 0, 0, time.UTC))
// Create anniversary milestone campaign
_, err := db.DB.Exec(context.Background(), `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value, milestone_unit)
VALUES ($1, 'milestone', 20, 'active', NOW(), NOW() + INTERVAL '1 year', 'anniversary', 1, 'years')
`, "One Year Anniversary")
if err != nil {
t.Fatalf("failed to create anniversary campaign: %v", err)
}
w := serveDiscountPreviewHandler(bookingID, userID, token)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp DiscountPreviewResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if !resp.Eligible {
t.Fatal("expected eligible=true for anniversary campaign")
}
foundAnniversary := false
for _, d := range resp.Discounts {
if d.Source == "campaign" && d.Percent == 20 {
foundAnniversary = true
break
}
}
if !foundAnniversary {
t.Error("expected a 20% anniversary discount in the preview")
}
}
// TestDiscountPreview_PaymentLock prevents new discounts after a completed payment exists.
// After the first payment, the apply function applies discounts. After a second payment,
// no new discounts should be added.
func TestDiscountPreview_PaymentLock(t *testing.T) {
resetTestData(t)
userID, bookingID, _ := setupDiscountPreviewTest(t)
defer fixtures.DeleteUser(db.DB, userID)
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
`, "Payment Lock Test", now.Add(-24*time.Hour), now.Add(24*time.Hour)).Scan(&campaignID)
if err != nil {
t.Fatalf("failed to create campaign: %v", err)
}
// First payment — discount should be applied
_, 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', 1000, 'completed', NOW(), NOW())
`, bookingID)
if err != nil {
t.Fatalf("failed to create first 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", bookingID).Scan(&discountCount)
if discountCount != 1 {
t.Errorf("expected 1 discount after first payment, got %d", discountCount)
}
// Second payment — NO new discounts should be added (lock active)
_, err = db.DB.Exec(context.Background(), `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
VALUES ($1, 'full', 'online_square', 4000, 'completed', NOW(), NOW())
`, bookingID)
if err != nil {
t.Fatalf("failed to create second payment: %v", err)
}
applyEligibleCampaignsAtPayment(context.Background(), bookingID, userID)
db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1", bookingID).Scan(&discountCount)
if discountCount != 1 {
t.Errorf("expected still 1 discount after second payment (lock active), got %d", discountCount)
}
// Verify campaign was only redeemed once
var timesRedeemed int
db.DB.QueryRow(context.Background(),
"SELECT times_redeemed FROM discount_campaigns WHERE id = $1", campaignID).Scan(&timesRedeemed)
if timesRedeemed != 1 {
t.Errorf("expected 1 redemption, got %d", timesRedeemed)
}
}
// TestDiscountPreview_BookingNoServices returns not eligible for a booking with no services.
func TestDiscountPreview_BookingNoServices(t *testing.T) {
resetTestData(t)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
// Create a booking without any services
var bookingID string
err = db.DB.QueryRow(context.Background(), `
INSERT INTO bookings (user_id, start_time, status) VALUES ($1, NOW(), 'confirmed') RETURNING id
`, userID).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
now := time.Now()
_, err = db.DB.Exec(context.Background(), `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date)
VALUES ('Test Sale', 'time_based', 10, 'active', $1, $2)
`, now.Add(-24*time.Hour), now.Add(24*time.Hour))
if err != nil {
t.Fatalf("failed to create campaign: %v", err)
}
token := jwt.GenerateUserToken(userID)
w := serveDiscountPreviewHandler(bookingID, userID, token)
var resp DiscountPreviewResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.Eligible {
t.Error("expected eligible=false for booking with no services")
}
if resp.OriginalTotal != 0 {
t.Errorf("expected original_total=0, got %f", resp.OriginalTotal)
}
}
// TestDiscountPreview_ReturnsReadOnly confirms the preview endpoint never modifies state
// even when eligible campaigns exist.
func TestDiscountPreview_ReturnsReadOnly(t *testing.T) {
resetTestData(t)
userID, bookingID, token := setupDiscountPreviewTest(t)
defer fixtures.DeleteUser(db.DB, userID)
now := time.Now()
_, err := db.DB.Exec(context.Background(), `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date)
VALUES ('Read-Only Check', 'time_based', 10, 'active', $1, $2)
`, now.Add(-24*time.Hour), now.Add(24*time.Hour))
if err != nil {
t.Fatalf("failed to create campaign: %v", err)
}
// Call preview twice — both should return same result and neither should create records
for i := 0; i < 2; i++ {
w := serveDiscountPreviewHandler(bookingID, userID, token)
if w.Code != http.StatusOK {
t.Fatalf("attempt %d: expected 200, got %d", i+1, w.Code)
}
var resp DiscountPreviewResponse
json.NewDecoder(w.Body).Decode(&resp)
if !resp.Eligible {
t.Fatalf("attempt %d: expected eligible=true", i+1)
}
}
// Verify no records were created after two calls
var discountCount int
db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1", bookingID).Scan(&discountCount)
if discountCount != 0 {
t.Errorf("preview created %d booking_discounts (should be 0)", discountCount)
}
}
@@ -0,0 +1,525 @@
//go:build test && dev
// +build test,dev
package payments
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"crussell/db"
"crussell/mw"
"crussell/testutils/fixtures"
"crussell/testutils/jwt"
"github.com/go-chi/chi/v5"
)
// =============================================================================
// Unit tests — IsValidBookingStatusForPayment (pure function, no DB)
// =============================================================================
func TestIsValidBookingStatusForPayment_Confirmed(t *testing.T) {
if !IsValidBookingStatusForPayment("confirmed") {
t.Error("expected 'confirmed' to be valid for payment")
}
}
func TestIsValidBookingStatusForPayment_Pending(t *testing.T) {
if !IsValidBookingStatusForPayment("pending") {
t.Error("expected 'pending' to be valid for payment")
}
}
func TestIsValidBookingStatusForPayment_PendingRelease(t *testing.T) {
if !IsValidBookingStatusForPayment("pending_release") {
t.Error("expected 'pending_release' to be valid for payment")
}
}
func TestIsValidBookingStatusForPayment_InProgress(t *testing.T) {
if !IsValidBookingStatusForPayment("in_progress") {
t.Error("expected 'in_progress' to be valid for payment")
}
}
func TestIsValidBookingStatusForPayment_RejectsDepositLapsed(t *testing.T) {
if IsValidBookingStatusForPayment("deposit_lapsed") {
t.Error("expected 'deposit_lapsed' to be rejected for payment")
}
}
func TestIsValidBookingStatusForPayment_RejectsClientCancelled(t *testing.T) {
if IsValidBookingStatusForPayment("client_cancelled") {
t.Error("expected 'client_cancelled' to be rejected for payment")
}
}
func TestIsValidBookingStatusForPayment_RejectsWeCancelled(t *testing.T) {
if IsValidBookingStatusForPayment("we_cancelled") {
t.Error("expected 'we_cancelled' to be rejected for payment")
}
}
func TestIsValidBookingStatusForPayment_RejectsCompleted(t *testing.T) {
if IsValidBookingStatusForPayment("completed") {
t.Error("expected 'completed' to be rejected for payment")
}
}
func TestIsValidBookingStatusForPayment_RejectsNoShow(t *testing.T) {
if IsValidBookingStatusForPayment("no_show") {
t.Error("expected 'no_show' to be rejected for payment")
}
}
// =============================================================================
// Integration tests — CreateBookingPayment status guard
// =============================================================================
// setupPaymentStatusTest creates a user, service, and booking with the given
// status, returning the userID, bookingID, and user token.
func setupPaymentStatusTest(t *testing.T, status string) (string, string, string) {
t.Helper()
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
// Use a far-future date so the booking is never in the cleanup window.
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 test booking: %v", err)
}
_, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = $1 WHERE id = $2", status, bookingID)
if err != nil {
t.Fatalf("failed to set booking status to %q: %v", status, err)
}
userToken := jwt.GenerateUserToken(userID)
return userID, bookingID, userToken
}
func TestCreateBookingPayment_AcceptsPendingRelease(t *testing.T) {
resetTestData(t)
_, bookingID, userToken := setupPaymentStatusTest(t, "pending_release")
cardToken := "cnon:test-card-nonce"
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
NewCardToken: &cardToken,
SaveCard: true,
IdempotencyKey: "deposit-pending-release-" + bookingID,
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken)
if w.Code != http.StatusOK {
t.Errorf("expected status 200 for pending_release booking, got %d. body: %s", w.Code, w.Body.String())
}
// Verify the booking was promoted back to confirmed.
var status string
err := db.DB.QueryRow(context.Background(),
"SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status)
if err != nil {
t.Fatalf("failed to query booking status: %v", err)
}
if status != "confirmed" {
t.Errorf("expected booking promoted from pending_release to 'confirmed', got %q", status)
}
}
func TestCreateBookingPayment_ThresholdMet_SmallPaymentPromotes(t *testing.T) {
resetTestData(t)
_, bookingID, userToken := setupPaymentStatusTest(t, "pending_release")
// Pay 15% (£7.50 on a £50 booking) — below 20% threshold.
cardToken := "cnon:test-card-nonce"
req := CreateBookingPaymentRequest{
Amount: 750, // £7.50 = 15% of £50
PaymentType: "partial",
NewCardToken: &cardToken,
SaveCard: false,
IdempotencyKey: "below-threshold-" + bookingID,
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
// Payment below 20% threshold — booking should remain pending_release.
var status string
err := db.DB.QueryRow(context.Background(),
"SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status)
if err != nil {
t.Fatalf("failed to query booking: %v", err)
}
if status != "pending_release" {
t.Errorf("expected booking to remain pending_release, got %q", status)
}
}
func TestCreateBookingPayment_ThresholdMet_BalancePaymentPromotes(t *testing.T) {
resetTestData(t)
_, bookingID, userToken := setupPaymentStatusTest(t, "pending_release")
// Pay £25 via "balance" type — still should meet the 20% threshold.
cardToken := "cnon:test-card-nonce"
req := CreateBookingPaymentRequest{
Amount: 2500, // £25.00 = 50% of £50
PaymentType: "balance",
NewCardToken: &cardToken,
SaveCard: false,
IdempotencyKey: "balance-promotes-" + bookingID,
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var status string
err := db.DB.QueryRow(context.Background(),
"SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status)
if err != nil {
t.Fatalf("failed to query booking: %v", err)
}
if status != "confirmed" {
t.Errorf("expected booking promoted to 'confirmed', got %q", status)
}
}
func TestCreateBookingPayment_AcceptsConfirmed(t *testing.T) {
resetTestData(t)
_, bookingID, userToken := setupPaymentStatusTest(t, "confirmed")
cardToken := "cnon:test-card-nonce"
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
NewCardToken: &cardToken,
SaveCard: false,
IdempotencyKey: "deposit-confirmed-" + bookingID,
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken)
if w.Code != http.StatusOK {
t.Errorf("expected status 200 for confirmed booking, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestCreateBookingPayment_RejectsPending(t *testing.T) {
resetTestData(t)
_, bookingID, userToken := setupPaymentStatusTest(t, "pending")
cardToken := "cnon:test-card-nonce"
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
NewCardToken: &cardToken,
SaveCard: false,
IdempotencyKey: "deposit-pending-" + bookingID,
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken)
if w.Code != http.StatusConflict {
t.Errorf("expected status 409 for pending booking (not yet confirmed), got %d. body: %s", w.Code, w.Body.String())
}
}
func TestCreateBookingPayment_RejectsDepositLapsed(t *testing.T) {
resetTestData(t)
_, bookingID, userToken := setupPaymentStatusTest(t, "deposit_lapsed")
cardToken := "cnon:test-card-nonce"
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
NewCardToken: &cardToken,
SaveCard: false,
IdempotencyKey: "reject-lapsed-" + bookingID,
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken)
if w.Code != http.StatusConflict {
t.Errorf("expected status 409 for deposit_lapsed booking, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestCreateBookingPayment_RejectsClientCancelled(t *testing.T) {
resetTestData(t)
_, bookingID, userToken := setupPaymentStatusTest(t, "client_cancelled")
cardToken := "cnon:test-card-nonce"
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
NewCardToken: &cardToken,
SaveCard: false,
IdempotencyKey: "reject-cancelled-" + bookingID,
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken)
if w.Code != http.StatusConflict {
t.Errorf("expected status 409 for client_cancelled booking, got %d. body: %s", w.Code, w.Body.String())
}
}
// =============================================================================
// Payment Lock Tests
// =============================================================================
func paymentLockRequest(method, path string, token string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
req := httptest.NewRequest(method, path, nil)
req.Header.Set("Authorization", "Bearer "+token)
rctx := chi.NewRouteContext()
bookingID, _ := extractPaymentIDFromPath(path)
rctx.URLParams.Add("id", bookingID)
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
if token != "" {
if info := extractUserFromTestJWT(token); info != nil {
ctx = context.WithValue(ctx, mw.UserIDKey, info.userID)
ctx = context.WithValue(ctx, mw.UserRoleKey, info.role)
}
}
AcquirePaymentLock(w, req.WithContext(ctx))
return w
}
func TestAcquirePaymentLock_Confirmed(t *testing.T) {
resetTestData(t)
_, bookingID, userToken := setupPaymentStatusTest(t, "confirmed")
w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken)
if w.Code != http.StatusOK {
t.Errorf("expected 200 for confirmed booking, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestAcquirePaymentLock_InProgress(t *testing.T) {
resetTestData(t)
_, bookingID, userToken := setupPaymentStatusTest(t, "in_progress")
w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken)
if w.Code != http.StatusOK {
t.Errorf("expected 200 for in_progress booking, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestAcquirePaymentLock_PendingRelease(t *testing.T) {
resetTestData(t)
_, bookingID, userToken := setupPaymentStatusTest(t, "pending_release")
w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken)
if w.Code != http.StatusOK {
t.Errorf("expected 200 for pending_release booking, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestAcquirePaymentLock_RejectsDepositLapsed(t *testing.T) {
resetTestData(t)
_, bookingID, userToken := setupPaymentStatusTest(t, "deposit_lapsed")
w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken)
if w.Code != http.StatusConflict {
t.Errorf("expected 409 for deposit_lapsed booking, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestAcquirePaymentLock_RejectsClientCancelled(t *testing.T) {
resetTestData(t)
_, bookingID, userToken := setupPaymentStatusTest(t, "client_cancelled")
w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken)
if w.Code != http.StatusConflict {
t.Errorf("expected 409 for client_cancelled booking, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestAcquirePaymentLock_RejectsWeCancelled(t *testing.T) {
resetTestData(t)
_, bookingID, userToken := setupPaymentStatusTest(t, "we_cancelled")
w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken)
if w.Code != http.StatusConflict {
t.Errorf("expected 409 for we_cancelled booking, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestAcquirePaymentLock_RejectsNoShow(t *testing.T) {
resetTestData(t)
_, bookingID, userToken := setupPaymentStatusTest(t, "no_show")
w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken)
if w.Code != http.StatusConflict {
t.Errorf("expected 409 for no_show booking, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestAcquirePaymentLock_RejectsPending(t *testing.T) {
resetTestData(t)
_, bookingID, userToken := setupPaymentStatusTest(t, "pending")
w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken)
if w.Code != http.StatusConflict {
t.Errorf("expected 409 for pending booking, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestAcquirePaymentLock_RejectsCompleted(t *testing.T) {
resetTestData(t)
_, bookingID, userToken := setupPaymentStatusTest(t, "completed")
w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken)
if w.Code != http.StatusConflict {
t.Errorf("expected 409 for completed booking, got %d. body: %s", w.Code, w.Body.String())
}
}
// =============================================================================
// ReleasePaymentLock — DELETE /api/bookings/{id}/payment-lock
// =============================================================================
func releasePaymentLockRequest(path string, token string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
req := httptest.NewRequest("DELETE", path, nil)
req.Header.Set("Authorization", "Bearer "+token)
rctx := chi.NewRouteContext()
bookingID, _ := extractPaymentIDFromPath(path)
rctx.URLParams.Add("id", bookingID)
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
if token != "" {
if info := extractUserFromTestJWT(token); info != nil {
ctx = context.WithValue(ctx, mw.UserIDKey, info.userID)
ctx = context.WithValue(ctx, mw.UserRoleKey, info.role)
}
}
ReleasePaymentLock(w, req.WithContext(ctx))
return w
}
func TestReleasePaymentLock_HappyPath(t *testing.T) {
resetTestData(t)
_, bookingID, userToken := setupPaymentStatusTest(t, "confirmed")
// First, acquire the lock to create a PAYMENT_IN_FLIGHT time_blocker
lockW := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken)
if lockW.Code != http.StatusOK {
t.Fatalf("expected 200 when acquiring lock, got %d", lockW.Code)
}
// Verify the lock exists in the DB
var lockCount int
err := db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM time_blockers WHERE description = 'PAYMENT_IN_FLIGHT:' || $1", bookingID).Scan(&lockCount)
if err != nil {
t.Fatalf("failed to query time_blockers: %v", err)
}
if lockCount != 1 {
t.Fatalf("expected 1 time_blocker before release, got %d", lockCount)
}
// Now release the lock
w := releasePaymentLockRequest("/api/bookings/"+bookingID+"/payment-lock", userToken)
if w.Code != http.StatusNoContent {
t.Errorf("expected 204 No Content, got %d. body: %s", w.Code, w.Body.String())
}
// Verify the lock was removed from the DB
err = db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM time_blockers WHERE description = 'PAYMENT_IN_FLIGHT:' || $1", bookingID).Scan(&lockCount)
if err != nil {
t.Fatalf("failed to query time_blockers after release: %v", err)
}
if lockCount != 0 {
t.Errorf("expected 0 time_blockers after release, got %d", lockCount)
}
}
func TestReleasePaymentLock_NoExistingLock(t *testing.T) {
resetTestData(t)
_, bookingID, userToken := setupPaymentStatusTest(t, "confirmed")
// Release without acquiring first — should be idempotent (204)
w := releasePaymentLockRequest("/api/bookings/"+bookingID+"/payment-lock", userToken)
if w.Code != http.StatusNoContent {
t.Errorf("expected 204 No Content for idempotent release, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestReleasePaymentLock_InvalidBookingID(t *testing.T) {
w := releasePaymentLockRequest("/api/bookings/invalid/payment-lock", "")
if w.Code != http.StatusNotFound {
t.Errorf("expected 404 for invalid booking ID, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestReleasePaymentLock_EmptyBookingID(t *testing.T) {
w := releasePaymentLockRequest("/api/bookings//payment-lock", "")
if w.Code != http.StatusNotFound {
t.Errorf("expected 404 for empty booking ID, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestReleasePaymentLock_AfterMultipleAcquires(t *testing.T) {
resetTestData(t)
_, bookingID, userToken := setupPaymentStatusTest(t, "confirmed")
// Acquire the lock twice — AcquirePaymentLock should be idempotent
lockW := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken)
if lockW.Code != http.StatusOK {
t.Fatalf("expected 200 on first acquire, got %d", lockW.Code)
}
lockW = paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken)
if lockW.Code != http.StatusOK {
t.Fatalf("expected 200 on second acquire, got %d", lockW.Code)
}
// Release should still succeed
w := releasePaymentLockRequest("/api/bookings/"+bookingID+"/payment-lock", userToken)
if w.Code != http.StatusNoContent {
t.Errorf("expected 204 No Content after multiple acquires, got %d. body: %s", w.Code, w.Body.String())
}
// Verify all locks are gone
var lockCount int
err := db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM time_blockers WHERE description = 'PAYMENT_IN_FLIGHT:' || $1", bookingID).Scan(&lockCount)
if err != nil {
t.Fatalf("failed to query time_blockers: %v", err)
}
if lockCount != 0 {
t.Errorf("expected 0 time_blockers after release, got %d", lockCount)
}
}
@@ -0,0 +1,110 @@
//go:build test && dev
// +build test,dev
package payments
import (
"context"
"testing"
"time"
"crussell/db"
"crussell/testutils/fixtures"
)
func setupRefundTestWithDiscount(t *testing.T) (string, string, float64) {
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)
}
// Insert a real cash payment of 50
_, err = db.DB.Exec(context.Background(), `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
VALUES ($1, 'full', 'cash', 5000, 'completed', NOW(), NOW())
`, bookingID)
if err != nil {
t.Fatalf("failed to create cash payment: %v", err)
}
// Insert a discount payment record (should be excluded from refund)
_, err = db.DB.Exec(context.Background(), `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
VALUES ($1, 'partial', 'discount', 500, 'completed', NOW(), NOW())
`, bookingID)
if err != nil {
t.Fatalf("failed to create discount payment: %v", err)
}
// Insert an on_the_house payment record (should also be excluded)
_, err = db.DB.Exec(context.Background(), `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
VALUES ($1, 'partial', 'on_the_house', 1000, 'completed', NOW(), NOW())
`, bookingID)
if err != nil {
t.Fatalf("failed to create on_the_house payment: %v", err)
}
return userID, bookingID, 50.0
}
func TestProcessCancellationRefund_ExcludesDiscountPayments(t *testing.T) {
resetTestData(t)
userID, bookingID, total := setupRefundTestWithDiscount(t)
farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
now := farFuture.Add(-72 * time.Hour).Add(-1 * time.Hour) // >72h before
result, err := ProcessCancellationRefund(
context.Background(), bookingID, total, 50,
farFuture, now, "client_cancelled", &userID,
)
if err != nil {
t.Fatalf("ProcessCancellationRefund failed: %v", err)
}
// Refundable should be 50 (only the cash payment), not 65 (which would include discount + OTH)
if result.RefundableAmount != 50 {
t.Errorf("expected refundable 50 (excluding discount/OTH), got %.2f", result.RefundableAmount)
}
}
func TestProcessCancellationRefund_ExcludesOnTheHousePayments(t *testing.T) {
resetTestData(t)
userID, bookingID, total := setupRefundTestWithDiscount(t)
// Make on_the_house the only non-discount payment by marking the 50 cash as a payment that gets refunded
// but also add a pure on_the_house booking with no real money
_, err := db.DB.Exec(context.Background(), `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
VALUES ($1, 'partial', 'discount', 2500, 'completed', NOW(), NOW())
`, bookingID)
if err != nil {
t.Fatalf("failed to create extra discount: %v", err)
}
farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
now := farFuture.Add(-72 * time.Hour).Add(-1 * time.Hour)
result, err := ProcessCancellationRefund(
context.Background(), bookingID, total, 50,
farFuture, now, "client_cancelled", &userID,
)
if err != nil {
t.Fatalf("ProcessCancellationRefund failed: %v", err)
}
// Refundable should be based on the actual cash payment only
if result.RefundableAmount != 50 {
t.Errorf("expected refundable 50 (real money only), got %.2f", result.RefundableAmount)
}
}