diff --git a/backend/handlers/payments/handlers.go b/backend/handlers/payments/handlers.go index 11d0536..6ef08b7 100644 --- a/backend/handlers/payments/handlers.go +++ b/backend/handlers/payments/handlers.go @@ -265,6 +265,28 @@ func calculateDiscountPreview(ctx context.Context, bookingID string, userID stri } } + // Check for referrer's unused referral discount + var rdID string + var rdPercent float64 + if err := db.DB.QueryRow(ctx, ` + SELECT id, discount_percent FROM referral_discounts + WHERE user_id = $1 AND used = FALSE + LIMIT 1 + `, userID).Scan(&rdID, &rdPercent); err == nil && rdID != "" { + exists := 0 + db.DB.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'referral' AND source_id = $2`, bookingID, rdID).Scan(&exists) + if exists == 0 { + amount := roundTo2(bookingTotal * rdPercent / 100) + resp.Discounts = append(resp.Discounts, DiscountPreview{ + Source: "referral", + Name: "Referral Discount (10%)", + Percent: rdPercent, + Amount: amount, + }) + discountTotal += amount + } + } + resp.Eligible = len(resp.Discounts) > 0 resp.DiscountedTotal = roundTo2(bookingTotal - discountTotal) return resp @@ -954,8 +976,12 @@ func applyEligibleCampaignsAtPayment(ctx context.Context, bookingID string, user SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house') `, bookingID).Scan(&existingPayment) - // Only block if this is the 2nd+ payment — the first payment should still - // trigger discount application. Subsequent payments should not add new discounts. + // Only block if this is the 2nd+ real payment — the first payment should still + // trigger discount application (existingPayment counts already-completed payments). + // When called before payment commit (line 850), existingPayment=0 so discounts + // are applied. When called after commit (line 952), existingPayment=1 and the + // idempotency check handles it. At the 2nd+ payment attempt, this guard prevents + // applying any new discounts. if existingPayment >= 2 { return } @@ -981,9 +1007,16 @@ func applyEligibleCampaignsAtPayment(ctx context.Context, bookingID string, user return } + tx, err := db.DB.Begin(ctx) + if err != nil { + log.Printf("Failed to begin discount application transaction: %v", err) + return + } + defer tx.Rollback(ctx) + var campaignID string var campaignPercent float64 - if err := db.DB.QueryRow(ctx, ` + if err := tx.QueryRow(ctx, ` SELECT id, discount_percent FROM discount_campaigns WHERE status = 'active' AND campaign_type = 'time_based' AND start_date <= NOW() AND end_date >= NOW() @@ -991,20 +1024,20 @@ func applyEligibleCampaignsAtPayment(ctx context.Context, bookingID string, user ORDER BY discount_percent DESC LIMIT 1 `).Scan(&campaignID, &campaignPercent); err == nil && campaignID != "" { var exists int - db.DB.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, campaignID).Scan(&exists) + tx.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, campaignID).Scan(&exists) if exists == 0 { discountAmount := roundTo2(bookingTotal * campaignPercent / 100) - if _, err := db.DB.Exec(ctx, ` + if _, err := tx.Exec(ctx, ` INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) VALUES ($1, $2, 'campaign', $3, 'time_based', NULL, $4, $5, $6) `, bookingID, userID, campaignID, campaignPercent, bookingTotal, discountAmount); err != nil { log.Printf("Failed to insert time-based campaign discount: %v", err) } else { - db.DB.Exec(ctx, ` + tx.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) VALUES ($1, 'partial', 'discount', $2, 'completed', $3) `, bookingID, discountAmount, userID) - db.DB.Exec(ctx, ` + tx.Exec(ctx, ` UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1 `, campaignID) } @@ -1012,11 +1045,11 @@ func applyEligibleCampaignsAtPayment(ctx context.Context, bookingID string, user } var userBookingCount int - db.DB.QueryRow(ctx, `SELECT COUNT(*) FROM bookings WHERE user_id = $1 AND status = 'completed'`, userID).Scan(&userBookingCount) + tx.QueryRow(ctx, `SELECT COUNT(*) FROM bookings WHERE user_id = $1 AND status = 'completed'`, userID).Scan(&userBookingCount) var milestoneCampaignID string var milestonePercent float64 - db.DB.QueryRow(ctx, ` + tx.QueryRow(ctx, ` SELECT id, discount_percent FROM discount_campaigns WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'per_user_booking_count' AND milestone_value = $1 @@ -1025,20 +1058,20 @@ func applyEligibleCampaignsAtPayment(ctx context.Context, bookingID string, user if milestoneCampaignID != "" { var exists int - db.DB.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, milestoneCampaignID).Scan(&exists) + tx.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, milestoneCampaignID).Scan(&exists) if exists == 0 { discountAmount := roundTo2(bookingTotal * milestonePercent / 100) - if _, err := db.DB.Exec(ctx, ` + if _, err := tx.Exec(ctx, ` INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) VALUES ($1, $2, 'campaign', $3, 'milestone', 'per_user_booking_count', $4, $5, $6) `, bookingID, userID, milestoneCampaignID, milestonePercent, bookingTotal, discountAmount); err != nil { log.Printf("Failed to insert per-user milestone discount: %v", err) } else { - db.DB.Exec(ctx, ` + tx.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) VALUES ($1, 'partial', 'discount', $2, 'completed', $3) `, bookingID, discountAmount, userID) - db.DB.Exec(ctx, ` + tx.Exec(ctx, ` UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1 `, milestoneCampaignID) } @@ -1046,9 +1079,9 @@ func applyEligibleCampaignsAtPayment(ctx context.Context, bookingID string, user } var firstVisitDate time.Time - db.DB.QueryRow(ctx, `SELECT MIN(start_time) FROM bookings WHERE user_id = $1 AND status = 'completed'`, userID).Scan(&firstVisitDate) + tx.QueryRow(ctx, `SELECT MIN(start_time) FROM bookings WHERE user_id = $1 AND status = 'completed'`, userID).Scan(&firstVisitDate) if !firstVisitDate.IsZero() { - annRows, err := db.DB.Query(ctx, ` + annRows, err := tx.Query(ctx, ` SELECT id, discount_percent, milestone_value, milestone_unit FROM discount_campaigns WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'anniversary' AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE user_id = $1 AND source_id = discount_campaigns.id AND milestone_type = 'anniversary') @@ -1071,7 +1104,7 @@ func applyEligibleCampaignsAtPayment(ctx context.Context, bookingID string, user for _, c := range campaigns { var exists int - db.DB.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, c.id).Scan(&exists) + tx.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, c.id).Scan(&exists) if exists > 0 { continue } @@ -1088,17 +1121,17 @@ func applyEligibleCampaignsAtPayment(ctx context.Context, bookingID string, user } if matches { discountAmount := roundTo2(bookingTotal * c.pct / 100) - if _, err := db.DB.Exec(ctx, ` + if _, err := tx.Exec(ctx, ` INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) VALUES ($1, $2, 'campaign', $3, 'milestone', 'anniversary', $4, $5, $6) `, bookingID, userID, c.id, c.pct, bookingTotal, discountAmount); err != nil { log.Printf("Failed to insert anniversary discount: %v", err) } else { - db.DB.Exec(ctx, ` + tx.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) VALUES ($1, 'partial', 'discount', $2, 'completed', $3) `, bookingID, discountAmount, userID) - db.DB.Exec(ctx, ` + tx.Exec(ctx, ` UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1 `, c.id) } @@ -1111,15 +1144,15 @@ func applyEligibleCampaignsAtPayment(ctx context.Context, bookingID string, user } var firstPaymentMethod string - if err := db.DB.QueryRow(ctx, ` + if err := tx.QueryRow(ctx, ` SELECT payment_method FROM payments WHERE booking_id = $1 AND payment_method NOT IN ('discount', 'on_the_house') ORDER BY created_at ASC LIMIT 1 `, bookingID).Scan(&firstPaymentMethod); err == nil && firstPaymentMethod == "in_person_card" { var globalCount int - db.DB.QueryRow(ctx, `SELECT COUNT(*) FROM bookings WHERE status = 'completed'`).Scan(&globalCount) + tx.QueryRow(ctx, `SELECT COUNT(*) FROM bookings WHERE status = 'completed'`).Scan(&globalCount) var globalCampaignID string var globalPercent float64 - db.DB.QueryRow(ctx, ` + tx.QueryRow(ctx, ` SELECT id, discount_percent FROM discount_campaigns WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'global_booking_count' AND milestone_value <= $1 @@ -1130,26 +1163,59 @@ func applyEligibleCampaignsAtPayment(ctx context.Context, bookingID string, user if globalCampaignID != "" { var exists int - db.DB.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, globalCampaignID).Scan(&exists) + tx.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, globalCampaignID).Scan(&exists) if exists == 0 { discountAmount := roundTo2(bookingTotal * globalPercent / 100) - if _, err := db.DB.Exec(ctx, ` + if _, err := tx.Exec(ctx, ` INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) VALUES ($1, $2, 'campaign', $3, 'milestone', 'global_booking_count', $4, $5, $6) `, bookingID, userID, globalCampaignID, globalPercent, bookingTotal, discountAmount); err != nil { log.Printf("Failed to insert global milestone discount: %v", err) } else { - db.DB.Exec(ctx, ` + tx.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) VALUES ($1, 'partial', 'discount', $2, 'completed', $3) `, bookingID, discountAmount, userID) - db.DB.Exec(ctx, ` + tx.Exec(ctx, ` UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1 `, globalCampaignID) } } } } + + // Apply referrer's referral discount if available + if bookingTotal > 0 { + var rdID string + var rdPercent float64 + if err := tx.QueryRow(ctx, ` + SELECT id, discount_percent FROM referral_discounts + WHERE user_id = $1 AND used = FALSE + LIMIT 1 + `, userID).Scan(&rdID, &rdPercent); err == nil && rdID != "" { + exists := 0 + tx.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'referral' AND source_id = $2`, bookingID, rdID).Scan(&exists) + if exists == 0 { + discountAmount := roundTo2(bookingTotal * rdPercent / 100) + if _, err := tx.Exec(ctx, ` + INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) + VALUES ($1, $2, 'referral', $3, NULL, NULL, $4, $5, $6) + `, bookingID, userID, rdID, rdPercent, bookingTotal, discountAmount); err == nil { + tx.Exec(ctx, ` + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) + VALUES ($1, 'partial', 'discount', $2, 'completed', $3) + `, bookingID, discountAmount, userID) + tx.Exec(ctx, ` + UPDATE referral_discounts SET used = TRUE, used_at = NOW() WHERE id = $1 + `, rdID) + } + } + } + } + + if err := tx.Commit(ctx); err != nil { + log.Printf("Failed to commit discount application: %v", err) + } } // buildSplitRecords determines whether to split a single Square charge into