fix: remove discount_eligible from booking creation, fix modal scrolling, lower default to 5%
- Remove all discount_eligible checks from CreateBookingHandler and AdminCreateBookingForUserHandler - Discounts are only calculated at completion/payment time, not at booking time - Remove discount_eligible from frontend Booking type and pending_redemption from BookingUser - Remove TestDiscount_EligibilityFlag test (no longer relevant) - Fix modal scrolling: add max-h-[90vh] overflow-y-auto to match ServicesManagement pattern - Lower default discount from 10% to 5% - Clarify max_redemptions label as 'campaign total' (per campaign, not per person)
This commit is contained in:
@@ -1307,24 +1307,6 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
createdBy = &creatorID
|
createdBy = &creatorID
|
||||||
}
|
}
|
||||||
|
|
||||||
var discountEligible bool
|
|
||||||
if !isGuest {
|
|
||||||
var hasPendingRedemption bool
|
|
||||||
_ = db.DB.QueryRow(r.Context(), `
|
|
||||||
SELECT EXISTS(SELECT 1 FROM loyalty_redemptions WHERE user_id = $1 AND status = 'pending' AND expires_at > NOW())
|
|
||||||
`, userID).Scan(&hasPendingRedemption)
|
|
||||||
|
|
||||||
var activeCampaigns int
|
|
||||||
_ = db.DB.QueryRow(r.Context(), `
|
|
||||||
SELECT COUNT(*) FROM discount_campaigns
|
|
||||||
WHERE status = 'active' AND campaign_type = 'time_based'
|
|
||||||
AND start_date <= NOW() AND end_date >= NOW()
|
|
||||||
AND (max_redemptions IS NULL OR times_redeemed < max_redemptions)
|
|
||||||
`).Scan(&activeCampaigns)
|
|
||||||
|
|
||||||
discountEligible = hasPendingRedemption || activeCampaigns > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
tx, err := db.DB.Begin(r.Context())
|
tx, err := db.DB.Begin(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to start transaction: %v", err)
|
log.Printf("Failed to start transaction: %v", err)
|
||||||
@@ -1346,10 +1328,10 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
var booking Booking
|
var booking Booking
|
||||||
booking.User = &UserSummary{}
|
booking.User = &UserSummary{}
|
||||||
if err := tx.QueryRow(r.Context(), `
|
if err := tx.QueryRow(r.Context(), `
|
||||||
INSERT INTO bookings (user_id, start_time, notes, created_by, deposit_required, status, idempotency_key, discount_eligible)
|
INSERT INTO bookings (user_id, start_time, notes, created_by, deposit_required, status, idempotency_key)
|
||||||
VALUES ($1, $2, $3::text, $4, $5, CASE WHEN $3::text IS NOT NULL AND $3::text != '' THEN 'pending'::booking_status ELSE 'confirmed'::booking_status END, $6, $7)
|
VALUES ($1, $2, $3::text, $4, $5, CASE WHEN $3::text IS NOT NULL AND $3::text != '' THEN 'pending'::booking_status ELSE 'confirmed'::booking_status END, $6)
|
||||||
RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by, deposit_required
|
RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by, deposit_required
|
||||||
`, userID, req.StartTime, req.Notes, createdBy, depositRequiredSnapshot, sql.NullString{String: idempotencyKey, Valid: idempotencyKey != ""}, discountEligible).Scan(
|
`, userID, req.StartTime, req.Notes, createdBy, depositRequiredSnapshot, sql.NullString{String: idempotencyKey, Valid: idempotencyKey != ""}).Scan(
|
||||||
&booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status,
|
&booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status,
|
||||||
&booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy,
|
&booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy,
|
||||||
&booking.DepositRequired,
|
&booking.DepositRequired,
|
||||||
|
|||||||
@@ -688,63 +688,6 @@ func TestDiscount_CampaignMaxRedemptions(t *testing.T) {
|
|||||||
assert.Equal(t, 0, discountCount2, "Expected no discount on second booking (max reached)")
|
assert.Equal(t, 0, discountCount2, "Expected no discount on second booking (max reached)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Discount Eligibility Flag Tests
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
// TestDiscount_EligibilityFlag tests that booking.discount_eligible is set correctly
|
|
||||||
func TestDiscount_EligibilityFlag(t *testing.T) {
|
|
||||||
cleanup := setupTestDB(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
seedDefaultWorkingHours(t)
|
|
||||||
|
|
||||||
// Test case 1: User with pending redemption should be eligible
|
|
||||||
userID1 := createTestUser(t, 10)
|
|
||||||
|
|
||||||
// Create pending redemption
|
|
||||||
ctx := context.Background()
|
|
||||||
_, err := db.DB.Exec(ctx, `
|
|
||||||
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
|
|
||||||
VALUES ($1, 10, 'pending', NOW())
|
|
||||||
`, userID1)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
serviceID := createTestService(t, 50.00)
|
|
||||||
|
|
||||||
// Create a new booking
|
|
||||||
bookingID1 := createPendingBooking(t, userID1, serviceID, time.Now().Add(24*time.Hour))
|
|
||||||
|
|
||||||
// Check discount_eligible flag
|
|
||||||
var discountEligible1 bool
|
|
||||||
err = db.DB.QueryRow(context.Background(), `SELECT discount_eligible FROM bookings WHERE id = $1`, bookingID1).Scan(&discountEligible1)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.True(t, discountEligible1, "Expected discount_eligible = true for user with pending redemption")
|
|
||||||
|
|
||||||
// Test case 2: User with no pending redemption and no active campaigns should NOT be eligible
|
|
||||||
userID2 := createTestUser(t, 0) // No stamps, no pending redemption
|
|
||||||
|
|
||||||
bookingID2 := createPendingBooking(t, userID2, serviceID, time.Now().Add(48*time.Hour))
|
|
||||||
|
|
||||||
var discountEligible2 bool
|
|
||||||
err = db.DB.QueryRow(context.Background(), `SELECT discount_eligible FROM bookings WHERE id = $1`, bookingID2).Scan(&discountEligible2)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.False(t, discountEligible2, "Expected discount_eligible = false for user with no pending redemption and no campaigns")
|
|
||||||
|
|
||||||
// Test case 3: User with active time-based campaign should be eligible
|
|
||||||
userID3 := createTestUser(t, 0)
|
|
||||||
|
|
||||||
// Create active time-based campaign
|
|
||||||
_ = createTestCampaign(t, "Active Campaign", "time_based", 5.0, nil, nil, nil, nil)
|
|
||||||
|
|
||||||
bookingID3 := createPendingBooking(t, userID3, serviceID, time.Now().Add(72*time.Hour))
|
|
||||||
|
|
||||||
var discountEligible3 bool
|
|
||||||
err = db.DB.QueryRow(context.Background(), `SELECT discount_eligible FROM bookings WHERE id = $1`, bookingID3).Scan(&discountEligible3)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.True(t, discountEligible3, "Expected discount_eligible = true when active time-based campaign exists")
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Helper: Truncate discount-specific tables
|
// Helper: Truncate discount-specific tables
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -667,21 +667,6 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Check discount eligibility
|
|
||||||
var discountEligible bool
|
|
||||||
var hasPendingRedemption bool
|
|
||||||
_ = db.DB.QueryRow(r.Context(), `
|
|
||||||
SELECT EXISTS(SELECT 1 FROM loyalty_redemptions WHERE user_id = $1 AND status = 'pending' AND expires_at > NOW())
|
|
||||||
`, req.UserID).Scan(&hasPendingRedemption)
|
|
||||||
var activeCampaigns int
|
|
||||||
_ = db.DB.QueryRow(r.Context(), `
|
|
||||||
SELECT COUNT(*) FROM discount_campaigns
|
|
||||||
WHERE status = 'active' AND campaign_type = 'time_based'
|
|
||||||
AND start_date <= NOW() AND end_date >= NOW()
|
|
||||||
AND (max_redemptions IS NULL OR times_redeemed < max_redemptions)
|
|
||||||
`).Scan(&activeCampaigns)
|
|
||||||
discountEligible = hasPendingRedemption || activeCampaigns > 0
|
|
||||||
|
|
||||||
tx, err := db.DB.Begin(r.Context())
|
tx, err := db.DB.Begin(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to start transaction: %v", err)
|
log.Printf("Failed to start transaction: %v", err)
|
||||||
@@ -698,10 +683,9 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
status,
|
status,
|
||||||
notes,
|
notes,
|
||||||
created_by,
|
created_by,
|
||||||
idempotency_key,
|
idempotency_key
|
||||||
discount_eligible
|
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, 'confirmed', $3, $4, $5, $6)
|
VALUES ($1, $2, 'confirmed', $3, $4, $5)
|
||||||
RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by
|
RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -716,7 +700,6 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
req.Notes,
|
req.Notes,
|
||||||
adminID,
|
adminID,
|
||||||
sql.NullString{String: idempotencyKey, Valid: idempotencyKey != ""},
|
sql.NullString{String: idempotencyKey, Valid: idempotencyKey != ""},
|
||||||
discountEligible,
|
|
||||||
).Scan(
|
).Scan(
|
||||||
&booking.ID,
|
&booking.ID,
|
||||||
&booking.User.ID,
|
&booking.User.ID,
|
||||||
|
|||||||
@@ -69,7 +69,7 @@
|
|||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
campaign_type: 'time_based' as 'time_based' | 'milestone',
|
campaign_type: 'time_based' as 'time_based' | 'milestone',
|
||||||
discount_percent: 10,
|
discount_percent: 5,
|
||||||
scope: 'all_bookings',
|
scope: 'all_bookings',
|
||||||
start_date: '',
|
start_date: '',
|
||||||
end_date: '',
|
end_date: '',
|
||||||
@@ -124,7 +124,7 @@
|
|||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
campaign_type: 'time_based',
|
campaign_type: 'time_based',
|
||||||
discount_percent: 10,
|
discount_percent: 5,
|
||||||
scope: 'all_bookings',
|
scope: 'all_bookings',
|
||||||
start_date: '',
|
start_date: '',
|
||||||
end_date: '',
|
end_date: '',
|
||||||
@@ -439,7 +439,7 @@
|
|||||||
|
|
||||||
<!-- Create/Edit Modal -->
|
<!-- Create/Edit Modal -->
|
||||||
<Modal.Root bind:open={showModal}>
|
<Modal.Root bind:open={showModal}>
|
||||||
<Modal.Content class="sm:max-w-lg">
|
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto md:max-w-lg">
|
||||||
<Modal.Header>
|
<Modal.Header>
|
||||||
<Modal.Title>{editingCampaign ? 'Edit Campaign' : 'Create Campaign'}</Modal.Title>
|
<Modal.Title>{editingCampaign ? 'Edit Campaign' : 'Create Campaign'}</Modal.Title>
|
||||||
<Modal.Description>Configure discount rules and campaign parameters</Modal.Description>
|
<Modal.Description>Configure discount rules and campaign parameters</Modal.Description>
|
||||||
@@ -577,9 +577,9 @@
|
|||||||
|
|
||||||
<!-- Max Redemptions -->
|
<!-- Max Redemptions -->
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label.Root for="dc-max">Max Redemptions</Label.Root>
|
<Label.Root for="dc-max">Max Redemptions (campaign total)</Label.Root>
|
||||||
<Input id="dc-max" type="number" min="0" bind:value={form.max_redemptions} class="w-32" />
|
<Input id="dc-max" type="number" min="0" bind:value={form.max_redemptions} class="w-32" />
|
||||||
<p class="text-xs text-gray-500">0 = unlimited</p>
|
<p class="text-xs text-gray-500">0 = unlimited across all users</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ export interface BookingUser {
|
|||||||
date_of_birth?: string;
|
date_of_birth?: string;
|
||||||
account_role: string;
|
account_role: string;
|
||||||
loyalty_stamps?: number;
|
loyalty_stamps?: number;
|
||||||
pending_redemption?: boolean;
|
|
||||||
referral_code?: string;
|
referral_code?: string;
|
||||||
referral_code_uses?: number;
|
referral_code_uses?: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -121,15 +120,6 @@ export interface Booking {
|
|||||||
amount_paid: number;
|
amount_paid: number;
|
||||||
amount_due: number;
|
amount_due: number;
|
||||||
duration_minutes: number;
|
duration_minutes: number;
|
||||||
discount_eligible?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BookingListResponse {
|
|
||||||
bookings: Booking[];
|
|
||||||
page: number;
|
|
||||||
per_page: number;
|
|
||||||
total: number;
|
|
||||||
total_pages: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoyaltyRedemption {
|
export interface LoyaltyRedemption {
|
||||||
|
|||||||
Reference in New Issue
Block a user