package payments import ( "crussell/db" "crussell/internal/validators" "crussell/mw" "encoding/json" "errors" "log" "math" "net/http" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5" ) func roundTo2(f float64) float64 { return math.Round(f*100) / 100 } // ApplyLoyaltyRedemption handles POST /api/bookings/{id}/apply-redemption. // It applies a 10% loyalty discount to the booking using a pending redemption. func ApplyLoyaltyRedemption(w http.ResponseWriter, r *http.Request) { bookingID := chi.URLParam(r, "id") if bookingID == "" || !validators.IsValidID(bookingID) { http.Error(w, "Booking not found", http.StatusNotFound) return } userID, ok := r.Context().Value(mw.UserIDKey).(string) if !ok || userID == "" { http.Error(w, "Authentication required", http.StatusUnauthorized) return } var bookingUserID string if err := db.Conn.QueryRow(r.Context(), `SELECT user_id FROM bookings WHERE id = $1`, bookingID).Scan(&bookingUserID); err != nil { if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "Booking not found", http.StatusNotFound) return } log.Printf("Failed to verify booking ownership: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if bookingUserID != userID { http.Error(w, "Unauthorized", http.StatusForbidden) return } var bookingStatus string if err := db.Conn.QueryRow(r.Context(), `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&bookingStatus); err != nil { log.Printf("Failed to get booking status: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } switch bookingStatus { case "completed", "cancelled", "no_show", "deposit_lapsed": http.Error(w, "Booking is in a terminal state and cannot accept redemptions", http.StatusBadRequest) 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.Conn.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 if err := db.Conn.QueryRow(r.Context(), `SELECT loyalty_stamps FROM users WHERE id = $1`, userID).Scan(&loyaltyStamps); err != nil { if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "User not found", http.StatusNotFound) return } log.Printf("Failed to get loyalty stamps: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if loyaltyStamps < LoyaltyStampCost { http.Error(w, "Insufficient loyalty stamps", http.StatusBadRequest) return } var redemptionID string if err := db.Conn.QueryRow(r.Context(), ` SELECT id FROM loyalty_redemptions WHERE user_id = $1 AND status = 'pending' AND expires_at > NOW() ORDER BY redeemed_at ASC LIMIT 1 `, userID).Scan(&redemptionID); err != nil { if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "No pending loyalty redemption found", http.StatusBadRequest) return } log.Printf("Failed to check loyalty redemption: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } var existingDiscount int if err := db.Conn.QueryRow(r.Context(), ` SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty' `, bookingID).Scan(&existingDiscount); err == nil { http.Error(w, "A loyalty discount has already been applied to this booking", http.StatusBadRequest) return } tx, err := db.Conn.Begin(r.Context()) if err != nil { log.Printf("Failed to begin transaction: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } defer tx.Rollback(r.Context()) var bookingTotal float64 if err := tx.QueryRow(r.Context(), ` 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 UNION ALL SELECT COALESCE(bcs.override_price, cs.price) FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = $1 ) sub `, bookingID).Scan(&bookingTotal); err != nil { log.Printf("Failed to calculate booking total: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } discountAmount := roundTo2(bookingTotal * LoyaltyDiscountPercent / 100) 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) VALUES ($1, $2, 'loyalty', $3, NULL, NULL, $6, $4, $5) `, bookingID, userID, redemptionID, bookingTotal, discountAmount, LoyaltyDiscountPercent); err != nil { log.Printf("Failed to insert booking discount: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if _, err := tx.Exec(r.Context(), ` INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) VALUES ($1, 'partial', 'discount', $2, 'completed', $3) `, bookingID, discountAmount, userID); err != nil { log.Printf("Failed to insert payment record: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if _, err := tx.Exec(r.Context(), ` UPDATE loyalty_redemptions SET status = 'applied', applied_to_booking_id = $1, applied_at = NOW() WHERE id = $2 `, bookingID, redemptionID); err != nil { log.Printf("Failed to update loyalty redemption: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if _, err := tx.Exec(r.Context(), ` UPDATE users SET loyalty_stamps = GREATEST(0, loyalty_stamps - $1) WHERE id = $2 `, LoyaltyStampCost, userID); err != nil { log.Printf("Failed to update loyalty stamps: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if err := tx.Commit(r.Context()); err != nil { log.Printf("Failed to commit transaction: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "discount_amount": discountAmount, }) }