feat(backend): add loyalty constants and first-payment guard

Extract LoyaltyStampCost and add LoyaltyDiscountPercent constant. Add first-payment guard to ApplyLoyaltyRedemption — reject if a non-discount payment already exists on the booking.

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-19 11:34:20 +01:00
co-authored by Sisyphus
parent df6c0dece5
commit 19996c1e08
3 changed files with 16 additions and 11 deletions
+13 -9
View File
@@ -60,6 +60,15 @@ func ApplyLoyaltyRedemption(w http.ResponseWriter, r *http.Request) {
return return
} }
// Loyalty redemption must be on the first payment — reject if a real payment
// (non-discount) already exists on this booking.
var realPaymentExists bool
db.DB.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM payments WHERE booking_id = $1 AND payment_method != 'discount')`, bookingID).Scan(&realPaymentExists)
if realPaymentExists {
http.Error(w, "Loyalty must be redeemed on the first payment for this booking", http.StatusBadRequest)
return
}
var loyaltyStamps int var loyaltyStamps int
if err := db.DB.QueryRow(r.Context(), `SELECT loyalty_stamps FROM users WHERE id = $1`, userID).Scan(&loyaltyStamps); err != nil { if err := db.DB.QueryRow(r.Context(), `SELECT loyalty_stamps FROM users WHERE id = $1`, userID).Scan(&loyaltyStamps); err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
@@ -70,7 +79,7 @@ func ApplyLoyaltyRedemption(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
if loyaltyStamps < 10 { if loyaltyStamps < LoyaltyStampCost {
http.Error(w, "Insufficient loyalty stamps", http.StatusBadRequest) http.Error(w, "Insufficient loyalty stamps", http.StatusBadRequest)
return return
} }
@@ -125,17 +134,12 @@ func ApplyLoyaltyRedemption(w http.ResponseWriter, r *http.Request) {
return return
} }
discountAmount := roundTo2(bookingTotal * 0.10) discountAmount := roundTo2(bookingTotal * LoyaltyDiscountPercent / 100)
if discountAmount <= 0 {
http.Error(w, "Booking total is zero, no discount applicable", http.StatusBadRequest)
return
}
if _, err := tx.Exec(r.Context(), ` if _, err := tx.Exec(r.Context(), `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) 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, 'loyalty', $3, NULL, NULL, 10.00, $4, $5) VALUES ($1, $2, 'loyalty', $3, NULL, NULL, $6, $4, $5)
`, bookingID, userID, redemptionID, bookingTotal, discountAmount); err != nil { `, bookingID, userID, redemptionID, bookingTotal, discountAmount, LoyaltyDiscountPercent); err != nil {
log.Printf("Failed to insert booking discount: %v", err) log.Printf("Failed to insert booking discount: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
+2 -1
View File
@@ -16,5 +16,6 @@ const (
ProtectedDepositMaxPct = 0.50 ProtectedDepositMaxPct = 0.50
RequiredDepositPct = 0.20 RequiredDepositPct = 0.20
LoyaltyStampCost = 10 LoyaltyStampCost = 10
LoyaltyDiscountPercent = 10.0
) )
+1 -1
View File
@@ -255,7 +255,7 @@ func ProcessCancellationRefund(
if err != nil { if err != nil {
log.Printf("Failed to refund loyalty stamps for booking %s: %v", bookingID, err) log.Printf("Failed to refund loyalty stamps for booking %s: %v", bookingID, err)
} else { } else {
log.Printf("Refunded 10 loyalty stamps to user %s after cancellation of booking %s", bookingUserID, bookingID) log.Printf("Refunded %d loyalty stamps to user %s after cancellation of booking %s", LoyaltyStampCost, bookingUserID, bookingID)
} }
} }
} }