fix(loyalty): correct stamp accumulation, one-per-day guard, and test coverage

- Handler: apply existing redemption BEFORE incrementing stamps (was creating
  and applying redemption to same booking)
- Handler: guard stamp increment behind bookingTotal > 0 (free bookings don't
  earn stamps)
- Handler: fix global milestone off-by-one (globalCount already includes
  current booking since status updated before discount logic)
- Schema: chk_milestone constraint only requires milestone_unit for anniversary
  type, not per_user_booking_count or global_booking_count
- Tests: rewrite from scratch with 12 focused tests:
  - FullCycle, ExistingRedemptionApplies, OneStampPerDay, ZeroTotalNoStamp,
    CycleRepeats, TimeBasedCampaign, PerUserMilestone, GlobalMilestone,
    AnniversaryMilestone, LoyaltyPriority, NoDiscountOnZeroTotal,
    CampaignMaxRedemptions
- Tests: fix path parameter extraction in makeProgressRequest
- Vendor: go mod tidy + vendor for testify dependency
This commit is contained in:
2026-05-10 13:17:08 +01:00
parent e7d3a83867
commit e1c815ed09
5 changed files with 322 additions and 275 deletions
+32 -27
View File
@@ -1626,32 +1626,6 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
}
}
if _, err := db.DB.Exec(r.Context(), `
UPDATE users
SET loyalty_stamps = loyalty_stamps + 1
WHERE id = $1
AND NOT EXISTS (
SELECT 1 FROM bookings b
WHERE b.user_id = users.id
AND b.status = 'completed'
AND b.updated_at >= CURRENT_DATE - INTERVAL '1 day'
AND b.id != $2
)
`, booking.User.ID, bookingID); err != nil {
log.Printf("Failed to add loyalty stamp for booking %s: %v", bookingID, err)
}
var newStampCount int
if err := db.DB.QueryRow(r.Context(), `SELECT loyalty_stamps FROM users WHERE id = $1`, booking.User.ID).Scan(&newStampCount); err == nil && newStampCount == 10 {
_, err = db.DB.Exec(r.Context(), `
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
VALUES ($1, 10, 'pending', NOW())
`, booking.User.ID)
if err != nil {
log.Printf("Failed to create loyalty redemption for user %s: %v", booking.User.ID, err)
}
}
var bookingTotal float64
if err := db.DB.QueryRow(r.Context(), `
SELECT COALESCE(SUM(COALESCE(bs.override_price, s.price)), 0)
@@ -1662,6 +1636,7 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
log.Printf("Failed to calculate booking total for %s: %v", bookingID, err)
}
// Apply existing pending loyalty redemption (earned from previous 10 bookings)
if bookingTotal > 0 {
var redemptionID string
if err := db.DB.QueryRow(r.Context(), `
@@ -1692,6 +1667,36 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
}
}
// Increment stamps (max 1 per day, only for paid bookings)
if bookingTotal > 0 {
if _, err := db.DB.Exec(r.Context(), `
UPDATE users
SET loyalty_stamps = loyalty_stamps + 1
WHERE id = $1
AND NOT EXISTS (
SELECT 1 FROM bookings b
WHERE b.user_id = users.id
AND b.status = 'completed'
AND b.updated_at >= CURRENT_DATE - INTERVAL '1 day'
AND b.id != $2
)
`, booking.User.ID, bookingID); err != nil {
log.Printf("Failed to add loyalty stamp for booking %s: %v", bookingID, err)
}
}
// Create pending redemption when stamps reach 10
var newStampCount int
if err := db.DB.QueryRow(r.Context(), `SELECT loyalty_stamps FROM users WHERE id = $1`, booking.User.ID).Scan(&newStampCount); err == nil && newStampCount == 10 {
_, err = db.DB.Exec(r.Context(), `
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
VALUES ($1, 10, 'pending', NOW())
`, booking.User.ID)
if err != nil {
log.Printf("Failed to create loyalty redemption for user %s: %v", booking.User.ID, err)
}
}
if bookingTotal > 0 {
var hasLoyaltyDiscount bool
_ = db.DB.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty')`, bookingID).Scan(&hasLoyaltyDiscount)
@@ -1767,7 +1772,7 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'global_booking_count'
AND milestone_value = $1
AND (max_redemptions IS NULL OR times_redeemed < max_redemptions)
`, globalCount+1).Scan(&globalCampaignID, &globalPercent)
`, globalCount).Scan(&globalCampaignID, &globalPercent)
if globalCampaignID != "" {
discountAmount := roundTo2(bookingTotal * globalPercent / 100)