package payments import ( "crussell/db" "crussell/internal/validators" "crussell/mw" "database/sql" "encoding/json" "errors" "log" "log/slog" "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, bookingStatus string var realPaymentExists bool var loyaltyStamps int var redemptionID sql.NullString err := db.Conn.QueryRow(r.Context(), ` SELECT b.user_id, b.status, COALESCE(EXISTS(SELECT 1 FROM payments WHERE booking_id = b.id AND payment_method != 'discount'), false) AS payment_exists, u.loyalty_stamps, (SELECT lr.id FROM loyalty_redemptions lr WHERE lr.user_id = u.id AND lr.status = 'pending' AND lr.expires_at > NOW() ORDER BY lr.redeemed_at ASC LIMIT 1) AS redemption_id FROM bookings b JOIN users u ON u.id = b.user_id WHERE b.id = $1 `, bookingID).Scan(&bookingUserID, &bookingStatus, &realPaymentExists, &loyaltyStamps, &redemptionID) if 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 } // Ownership: the booking's own customer may redeem their pending // redemption, and an admin acting on ANY booking may too (the admin // "Take Payment" PaymentModal applies the customer's redemption on their // behalf — the admin route /admin/bookings/{id}/apply-redemption mounts // this same handler under RequireAdmin). All the writes below target the // BOOKING's user (bookingUserID), never the acting admin. userRole, _ := r.Context().Value(mw.UserRoleKey).(string) if userRole != "admin" && bookingUserID != userID { http.Error(w, "Unauthorized", http.StatusForbidden) 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 } if realPaymentExists { http.Error(w, "Loyalty must be redeemed on the first payment for this booking", http.StatusBadRequest) return } if loyaltyStamps < LoyaltyStampCost { http.Error(w, "Insufficient loyalty stamps", http.StatusBadRequest) return } if !redemptionID.Valid { http.Error(w, "No pending loyalty redemption found", http.StatusBadRequest) return } // Serialize redemption per booking: two concurrent redemptions could both // pass the checks above and both insert a discount (double-apply). Reuse // the booking-payment advisory lock so redemption is mutually exclusive // with payments and other redemptions on the same booking (N-6). pinConn, err := db.Conn.Acquire(r.Context()) if err != nil { log.Printf("Failed to acquire connection for loyalty redemption lock: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } defer pinConn.Release() // Bounded try-lock instead of a blocking pg_advisory_lock: the SAME // "crussell:payment:" key is held by the payment handlers across their full // Square round-trip (~30s), so a blocking acquire here would pin this pool // connection for that long — a handful of concurrent redemption requests // during an in-flight payment would exhaust the pool (max(4, numCPU)) and // hang the app. Give up after ~3s and surface 409 instead. lockOK, err := acquireAdvisoryLock(r.Context(), pinConn, "crussell:payment:"+bookingID) if err != nil { log.Printf("Failed to acquire loyalty redemption serialization lock for %s: %v", bookingID, err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if !lockOK { http.Error(w, "Another payment operation is in progress, try again", http.StatusConflict) return } defer releasePaymentLock(pinConn, "crussell:payment:"+bookingID) // Re-check inside the lock (the checks above ran before acquiring it) so a // concurrent redemption that completed while we waited is caught. 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 func() { if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() var bookingTotal float64 if err := tx.QueryRow(r.Context(), ` SELECT total_amount FROM bookings WHERE id = $1 `, 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, bookingUserID, 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, bookingUserID); 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, bookingUserID); 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 } if err := json.NewEncoder(w).Encode(map[string]any{ "success": true, "discount_amount": discountAmount, }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } }