Files
Crussell/backend/handlers/payments/loyalty.go
T
popertotsandSisyphus 7b24f8e484 refactor(payments): integrate VAT into gift card buy flow and wrap in transactions
Refactor BuyGiftCard to insert pending payment before Square call with VAT applied. Add transaction wrapping to gift card handlers. Remove redundant Content-Type header sets. Migrate all time.Now() to clock.Now().

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-24 23:43:40 +01:00

159 lines
5.2 KiB
Go

package payments
import (
"crussell/db"
"crussell/internal/validators"
"crussell/mw"
"database/sql"
"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, 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
}
if 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
}
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 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, 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
}
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"discount_amount": discountAmount,
})
}