package bookings import ( "crussell/db" "crussell/mw" "database/sql" "encoding/json" "fmt" "log" "net/http" "strconv" "strings" "time" "github.com/go-chi/chi/v5" ) var londonLocation = func() *time.Location { loc, err := time.LoadLocation("Europe/London") if err != nil { panic("Europe/London timezone not available") } return loc }() // Booking represents a booking in the system type Booking struct { ID string `json:"id"` StartTime time.Time `json:"start_time"` Status string `json:"status"` Notes *string `json:"notes,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` CreatedBy *string `json:"created_by,omitempty"` // Joined fields User *UserSummary `json:"user,omitempty"` Services []BookingService `json:"services,omitempty"` Payments []Payment `json:"payments,omitempty"` TotalAmount float64 `json:"total_amount"` AmountPaid float64 `json:"amount_paid"` AmountDue float64 `json:"amount_due"` DurationMinutes int `json:"duration_minutes"` } // BookingService represents a service associated with a booking type BookingService struct { BookingID string `json:"booking_id"` ServiceID string `json:"service_id"` OverridePrice *float64 `json:"override_price,omitempty"` OverrideDurationMinutes *int `json:"override_duration_minutes,omitempty"` // Service details (joined) ServiceName *string `json:"service_name,omitempty"` ServiceDescription *string `json:"service_description,omitempty"` Price *float64 `json:"price,omitempty"` DurationMinutes *int `json:"duration_minutes,omitempty"` } // Payment represents a payment associated with a booking type Payment struct { ID string `json:"id"` BookingID string `json:"booking_id"` PaymentType string `json:"payment_type"` PaymentMethod string `json:"payment_method"` VendorCode *string `json:"vendor_code,omitempty"` InvoiceNumber *int `json:"invoice_number,omitempty"` Status string `json:"status"` Amount float64 `json:"amount"` IsVATApplicable bool `json:"is_vat_applicable"` VATRate *float64 `json:"vat_rate,omitempty"` VATAmount *float64 `json:"vat_amount,omitempty"` NetAmount *float64 `json:"net_amount,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` CreatedBy *string `json:"created_by,omitempty"` } // CreateBookingRequest represents the request payload for creating a new booking type CreateBookingRequest struct { StartTime time.Time `json:"start_time" validate:"required"` ServiceIDs []string `json:"service_ids" validate:"required,min=1"` Notes *string `json:"notes,omitempty"` } // EditBookingRequest represents the request payload for editing a booking's start time type EditBookingRequest struct { StartTime time.Time `json:"start_time" validate:"required"` } // ProgressBookingRequest represents the request payload for updating a booking's status type ProgressBookingRequest struct { Status string `json:"status" validate:"required,oneof=pending confirmed in_progress completed client_cancelled we_cancelled re-schedule no_show"` } // ConfirmBookingRequest represents the request payload for confirming a booking type ConfirmBookingRequest struct { ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"` Notes *string `json:"notes,omitempty"` } // ServiceOverride represents override values for a specific service in a booking type ServiceOverride struct { ServiceID string `json:"service_id" validate:"required"` OverridePrice *float64 `json:"override_price,omitempty"` OverrideDurationMinutes *int `json:"override_duration_minutes,omitempty"` } // DeleteBookingRequest represents the request payload for deleting a booking with payment type DeleteBookingRequest struct { Reason string `json:"reason" validate:"required,oneof=client_cancelled we_cancelled re-schedule no_show"` } // AdminUserSummary represents a small user summary for admin views type AdminUserSummary struct { FullName string `json:"full_name"` ProfilePicURL *string `json:"profile_pic_url,omitempty"` Notes *string `json:"notes,omitempty"` } // AdminBookingSummary represents a complete booking summary for admin view type AdminBookingSummary struct { Booking Booking `json:"booking"` User *AdminUserSummary `json:"user,omitempty"` Services []BookingServiceDetail `json:"services"` Payments []Payment `json:"payments"` TotalAmount float64 `json:"total_amount"` AmountPaid float64 `json:"amount_paid"` AmountDue float64 `json:"amount_due"` DurationMinutes int `json:"duration_minutes"` } type UserSummary struct { ID string `json:"id"` FirstName string `json:"first_name"` LastName string `json:"last_name"` FullName string `json:"full_name"` Email *string `json:"email,omitempty"` Phone *string `json:"phone,omitempty"` ProfilePicURL *string `json:"profile_pic_url,omitempty"` DateOfBirth *string `json:"date_of_birth,omitempty"` AccountRole string `json:"account_role"` LoyaltyStamps *int `json:"loyalty_stamps,omitempty"` ReferralCode *string `json:"referral_code,omitempty"` ReferralCodeUses *int `json:"referral_code_uses,omitempty"` CreatedAt string `json:"created_at"` Notes *string `json:"notes,omitempty"` } type BookingServiceDetail struct { ServiceName string `json:"service_name"` ServiceDescription *string `json:"service_description,omitempty"` BasePrice float64 `json:"base_price"` BaseDurationMinutes int `json:"base_duration_minutes"` OverridePrice *float64 `json:"override_price,omitempty"` OverrideDurationMinutes *int `json:"override_duration_minutes,omitempty"` IsActive bool `json:"is_active"` RequiresPatchTest bool `json:"requires_patch_test"` MinimumAgeRequired int `json:"minimum_age_required"` } // GetAllBookingsRequest represents query parameters for getting all bookings type GetAllBookingsRequest struct { Status *string `json:"status,omitempty"` StartDate *string `json:"start_date,omitempty"` EndDate *string `json:"end_date,omitempty"` Page int `json:"page"` PerPage int `json:"per_page"` } // BookingListResponse represents a paginated list of bookings type BookingListResponse struct { Bookings []Booking `json:"bookings"` Page int `json:"page"` PerPage int `json:"per_page"` Total int `json:"total"` } // SearchBookingsRequest represents search parameters type SearchBookingsRequest struct { Query string `json:"query"` Page int `json:"page"` PerPage int `json:"per_page"` } // SearchBookingsResponse represents search results type SearchBookingsResponse struct { Bookings []AdminBookingSummary `json:"bookings"` Page int `json:"page"` PerPage int `json:"per_page"` Total int `json:"total"` } // Enhanced booking response for user endpoints type UserBookingDetail struct { Booking Booking `json:"booking"` TotalAmount float64 `json:"total_amount"` AmountPaid float64 `json:"amount_paid"` AmountDue float64 `json:"amount_due"` DurationMinutes int `json:"duration_minutes"` } // Enhanced booking response for admin endpoints type AdminBookingDetail struct { Booking Booking `json:"booking"` User *AdminUserSummary `json:"user,omitempty"` TotalAmount float64 `json:"total_amount"` AmountPaid float64 `json:"amount_paid"` AmountDue float64 `json:"amount_due"` DurationMinutes int `json:"duration_minutes"` } // Helper function to parse query parameters func parseGetAllBookingsRequest(r *http.Request) GetAllBookingsRequest { req := GetAllBookingsRequest{ Page: 1, PerPage: 10, // default page size } if status := r.URL.Query().Get("status"); status != "" { req.Status = &status } if startDate := r.URL.Query().Get("start_date"); startDate != "" { req.StartDate = &startDate } if endDate := r.URL.Query().Get("end_date"); endDate != "" { req.EndDate = &endDate } if pageStr := r.URL.Query().Get("page"); pageStr != "" { if page, err := strconv.Atoi(pageStr); err == nil && page > 0 { req.Page = page } } if perPageStr := r.URL.Query().Get("per_page"); perPageStr != "" { if perPage, err := strconv.Atoi(perPageStr); err == nil && perPage > 0 && perPage <= 100 { req.PerPage = perPage } } return req } // GET /api/bookings func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) { userID, ok := r.Context().Value(mw.UserIDKey).(string) if !ok || userID == "" { http.Error(w, "Authentication required", http.StatusUnauthorized) return } // Parse query parameters req := parseGetAllBookingsRequest(r) // Build base query with user filter baseQuery := ` SELECT id, start_time, status, notes, created_at, updated_at, created_by FROM bookings WHERE user_id = $1 ` countQuery := `SELECT COUNT(*) FROM bookings WHERE user_id = $1` var args []interface{} var countArgs []interface{} args = append(args, userID) countArgs = append(countArgs, userID) paramCount := 2 // Add filters if req.Status != nil { baseQuery += fmt.Sprintf(" AND status = $%d", paramCount) countQuery += fmt.Sprintf(" AND status = $%d", paramCount) args = append(args, *req.Status) countArgs = append(countArgs, *req.Status) paramCount++ } if req.StartDate != nil { baseQuery += fmt.Sprintf(" AND start_time >= $%d", paramCount) countQuery += fmt.Sprintf(" AND start_time >= $%d", paramCount) startTime, err := time.ParseInLocation("2006-01-02", *req.StartDate, londonLocation) if err != nil { http.Error(w, "Invalid start_date format, use YYYY-MM-DD", http.StatusBadRequest) return } // Ensure it's at start of day in London time startTime = time.Date(startTime.Year(), startTime.Month(), startTime.Day(), 0, 0, 0, 0, londonLocation) args = append(args, startTime) countArgs = append(countArgs, startTime) paramCount++ } if req.EndDate != nil { baseQuery += fmt.Sprintf(" AND start_time <= $%d", paramCount) countQuery += fmt.Sprintf(" AND start_time <= $%d", paramCount) endTime, err := time.Parse("2006-01-02", *req.EndDate) if err != nil { http.Error(w, "Invalid end_date format, use YYYY-MM-DD", http.StatusBadRequest) return } // Add end of day endTime = endTime.Add(23*time.Hour + 59*time.Minute + 59*time.Second) args = append(args, endTime) countArgs = append(countArgs, endTime) paramCount++ } // Add ordering and pagination baseQuery += " ORDER BY start_time ASC" if req.PerPage > 0 { baseQuery += fmt.Sprintf(" LIMIT $%d OFFSET $%d", paramCount, paramCount+1) args = append(args, req.PerPage, (req.Page-1)*req.PerPage) } // Get total count var total int err := db.DB.QueryRow(r.Context(), countQuery, countArgs...).Scan(&total) if err != nil { log.Printf("Failed to get booking count for user %s: %v", userID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } // Get bookings rows, err := db.DB.Query(r.Context(), baseQuery, args...) if err != nil { log.Printf("Failed to fetch bookings for user %s: %v", userID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } defer rows.Close() var bookings []Booking for rows.Next() { var b Booking var createdBy sql.NullString err := rows.Scan(&b.ID, &b.StartTime, &b.Status, &b.Notes, &b.CreatedAt, &b.UpdatedAt, &createdBy) if err != nil { log.Printf("Failed to scan booking row: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } if createdBy.Valid { b.CreatedBy = &createdBy.String } bookings = append(bookings, b) } response := BookingListResponse{ Bookings: bookings, Page: req.Page, PerPage: req.PerPage, Total: total, } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(response); err != nil { log.Printf("Failed to encode response: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } } // GET /api/admin/bookings func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { // Parse query parameters req := parseGetAllBookingsRequest(r) // Build base query - only what the component needs baseQuery := ` WITH booking_totals AS ( SELECT bs.booking_id, SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)) as total_duration, SUM(COALESCE(bs.override_price, s.price)) as total_amount FROM booking_services bs LEFT JOIN services s ON bs.service_id = s.id GROUP BY bs.booking_id ), payment_totals AS ( SELECT booking_id, SUM(amount) as total_paid FROM payments WHERE status = 'completed' GROUP BY booking_id ) SELECT b.id, b.start_time, b.status, u.fn, COALESCE(bt.total_duration, 0) as duration_minutes, COALESCE(bt.total_amount, 0) - COALESCE(pt.total_paid, 0) as amount_due FROM bookings b LEFT JOIN users u ON b.user_id = u.id LEFT JOIN booking_totals bt ON b.id = bt.booking_id LEFT JOIN payment_totals pt ON b.id = pt.booking_id ` countQuery := `SELECT COUNT(*) FROM bookings b` var args []interface{} paramCount := 1 // Add filters whereAdded := false if req.Status != nil { baseQuery += fmt.Sprintf(" WHERE b.status = $%d", paramCount) countQuery += fmt.Sprintf(" WHERE b.status = $%d", paramCount) args = append(args, *req.Status) paramCount++ whereAdded = true } if req.StartDate != nil { if whereAdded { baseQuery += fmt.Sprintf(" AND b.start_time >= $%d", paramCount) countQuery += fmt.Sprintf(" AND b.start_time >= $%d", paramCount) } else { baseQuery += fmt.Sprintf(" WHERE b.start_time >= $%d", paramCount) countQuery += fmt.Sprintf(" WHERE b.start_time >= $%d", paramCount) whereAdded = true } startTime, err := time.Parse("2006-01-02", *req.StartDate) if err != nil { http.Error(w, "Invalid start_date format, use YYYY-MM-DD", http.StatusBadRequest) return } args = append(args, startTime) paramCount++ } if req.EndDate != nil { if whereAdded { baseQuery += fmt.Sprintf(" AND b.start_time <= $%d", paramCount) countQuery += fmt.Sprintf(" AND b.start_time <= $%d", paramCount) } else { baseQuery += fmt.Sprintf(" WHERE b.start_time <= $%d", paramCount) countQuery += fmt.Sprintf(" WHERE b.start_time <= $%d", paramCount) } endTime, err := time.Parse("2006-01-02", *req.EndDate) if err != nil { http.Error(w, "Invalid end_date format, use YYYY-MM-DD", http.StatusBadRequest) return } endTime = endTime.Add(23*time.Hour + 59*time.Minute + 59*time.Second) args = append(args, endTime) paramCount++ } // Add ordering and pagination (NO GROUP BY needed here) baseQuery += " ORDER BY b.start_time ASC" if req.PerPage > 0 { baseQuery += fmt.Sprintf(" LIMIT $%d OFFSET $%d", paramCount, paramCount+1) args = append(args, req.PerPage, (req.Page-1)*req.PerPage) paramCount += 2 } // Get total count countArgs := args if req.PerPage > 0 { countArgs = args[:len(args)-2] } var total int err := db.DB.QueryRow(r.Context(), countQuery, countArgs...).Scan(&total) if err != nil { log.Printf("Failed to get total booking count: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } // Get bookings rows, err := db.DB.Query(r.Context(), baseQuery, args...) if err != nil { log.Printf("Failed to fetch all bookings: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } defer rows.Close() var bookings []Booking bookingIDs := []string{} for rows.Next() { var b Booking var userFullName string err := rows.Scan(&b.ID, &b.StartTime, &b.Status, &userFullName, &b.DurationMinutes, &b.AmountDue) if err != nil { log.Printf("Failed to scan booking row: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } // Create minimal user with just full_name b.User = &UserSummary{ FullName: userFullName, } bookings = append(bookings, b) bookingIDs = append(bookingIDs, b.ID) } // Fetch service names only if len(bookingIDs) > 0 { servicesQuery := ` SELECT bs.booking_id, s.name FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = ANY($1) ORDER BY bs.booking_id, s.name ` serviceRows, err := db.DB.Query(r.Context(), servicesQuery, bookingIDs) if err != nil { log.Printf("Failed to fetch booking services: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } defer serviceRows.Close() servicesByBooking := make(map[string][]BookingService) for serviceRows.Next() { var bookingID string var serviceName string if err := serviceRows.Scan(&bookingID, &serviceName); err != nil { log.Printf("Failed to scan service row: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } service := BookingService{ BookingID: bookingID, ServiceName: &serviceName, } servicesByBooking[bookingID] = append(servicesByBooking[bookingID], service) } // Assign services to each booking for i := range bookings { if services, exists := servicesByBooking[bookings[i].ID]; exists { bookings[i].Services = services } } } response := BookingListResponse{ Bookings: bookings, Page: req.Page, PerPage: req.PerPage, Total: total, } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(response); err != nil { log.Printf("Failed to encode response: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } } // GET /api/admin/bookings/user/{user_id} func GetAllBookingsByUserHandler(w http.ResponseWriter, r *http.Request) { userID := chi.URLParam(r, "user_id") if userID == "" { http.Error(w, "User ID is required", http.StatusBadRequest) return } // Parse query parameters req := parseGetAllBookingsRequest(r) // Build query with specific user filter baseQuery := ` SELECT id, user_id, start_time, status, notes, created_at, updated_at, created_by, u.fn, u.profile_pic_url, u.notes as user_notes FROM bookings b LEFT JOIN users u ON b.user_id = u.id WHERE b.user_id = $1 ` countQuery := `SELECT COUNT(*) FROM bookings WHERE user_id = $1` var args []interface{} args = append(args, userID) paramCount := 2 // Add filters if req.Status != nil { baseQuery += fmt.Sprintf(" AND b.status = $%d", paramCount) countQuery += fmt.Sprintf(" AND status = $%d", paramCount) args = append(args, *req.Status) paramCount++ } if req.StartDate != nil { baseQuery += fmt.Sprintf(" AND b.start_time >= $%d", paramCount) countQuery += fmt.Sprintf(" AND start_time >= $%d", paramCount) startTime, err := time.ParseInLocation("2006-01-02", *req.StartDate, londonLocation) if err != nil { http.Error(w, "Invalid start_date format, use YYYY-MM-DD", http.StatusBadRequest) return } // Ensure it's at start of day in London time startTime = time.Date(startTime.Year(), startTime.Month(), startTime.Day(), 0, 0, 0, 0, londonLocation) args = append(args, startTime) paramCount++ } if req.EndDate != nil { baseQuery += fmt.Sprintf(" AND b.start_time <= $%d", paramCount) countQuery += fmt.Sprintf(" AND start_time <= $%d", paramCount) endTime, err := time.Parse("2006-01-02", *req.EndDate) if err != nil { http.Error(w, "Invalid end_date format, use YYYY-MM-DD", http.StatusBadRequest) return } endTime = endTime.Add(23*time.Hour + 59*time.Minute + 59*time.Second) args = append(args, endTime) paramCount++ } // Add ordering and pagination baseQuery += " ORDER BY b.start_time ASC" if req.PerPage > 0 { baseQuery += fmt.Sprintf(" LIMIT $%d OFFSET $%d", paramCount, paramCount+1) args = append(args, req.PerPage, (req.Page-1)*req.PerPage) } // Get total count var total int err := db.DB.QueryRow(r.Context(), countQuery, args[:1]...).Scan(&total) if err != nil { log.Printf("Failed to get booking count for user %s: %v", userID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } // Get bookings rows, err := db.DB.Query(r.Context(), baseQuery, args...) if err != nil { log.Printf("Failed to fetch bookings for user %s: %v", userID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } defer rows.Close() var bookings []Booking for rows.Next() { var b Booking b.User = &UserSummary{} var createdBy sql.NullString var userFullName, userPicURL, userNotes sql.NullString err := rows.Scan( &b.ID, &b.User.ID, &b.StartTime, &b.Status, &b.Notes, &b.CreatedAt, &b.UpdatedAt, &createdBy, &userFullName, &userPicURL, &userNotes, ) if userFullName.Valid { b.User.FullName = userFullName.String } if userPicURL.Valid { b.User.ProfilePicURL = &userPicURL.String } if userNotes.Valid { b.User.Notes = &userNotes.String } if err != nil { log.Printf("Failed to scan booking row: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } if createdBy.Valid { b.CreatedBy = &createdBy.String } bookings = append(bookings, b) } response := BookingListResponse{ Bookings: bookings, Page: req.Page, PerPage: req.PerPage, Total: total, } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(response); err != nil { log.Printf("Failed to encode response: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } } // GET /api/admin/bookings/{id} func GetAdminBookingHandler(w http.ResponseWriter, r *http.Request) { bookingID := chi.URLParam(r, "id") if bookingID == "" { http.Error(w, "Booking ID is required", http.StatusBadRequest) return } // ---------------------------- // 1. Fetch booking + user // ---------------------------- var booking Booking booking.User = &UserSummary{} err := db.DB.QueryRow(r.Context(), ` SELECT b.id, b.user_id, b.start_time, b.status, b.notes, b.created_at, b.updated_at, b.created_by, u.fn, u.email, u.phone, u.profile_pic_url, u.loyalty_stamps, u.referral_code, u.notes FROM bookings b LEFT JOIN users u ON b.user_id = u.id WHERE b.id = $1 `, bookingID).Scan( &booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status, &booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy, &booking.User.FullName, &booking.User.Email, &booking.User.Phone, &booking.User.ProfilePicURL, &booking.User.LoyaltyStamps, &booking.User.ReferralCode, &booking.User.Notes, ) if err != nil { if err == sql.ErrNoRows { http.Error(w, "Booking not found", http.StatusNotFound) return } log.Printf("Failed to fetch booking %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } // ---------------------------- // 1.5. Fetch referral code uses count // ---------------------------- var referralCodeUses int err = db.DB.QueryRow(r.Context(), ` SELECT COUNT(*) FROM user_referrals WHERE referrer_id = $1 `, booking.User.ID).Scan(&referralCodeUses) if err != nil { log.Printf("Failed to fetch referral code uses for user %s: %v", booking.User.ID, err) // Don't fail the entire request, just log and continue with 0 referralCodeUses = 0 } booking.User.ReferralCodeUses = &referralCodeUses // ---------------------------- // 2. Fetch services and calculate totals // ---------------------------- serviceRows, err := db.DB.Query(r.Context(), ` SELECT s.name, coalesce(bs.override_price, s.price) as price, coalesce(bs.override_duration_minutes, s.duration_minutes) as duration_minutes FROM booking_services bs LEFT JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = $1 ORDER BY s.name `, bookingID) if err != nil { log.Printf("Failed to fetch services for booking %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } defer serviceRows.Close() var totalAmount float64 var durationMinutes int for serviceRows.Next() { var name string var price float64 var durationMinutes int if err := serviceRows.Scan(&name, &price, &durationMinutes); err != nil { log.Printf("Failed to scan service for booking %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } // Calculate totals totalAmount += price durationMinutes += durationMinutes booking.Services = append(booking.Services, BookingService{ ServiceName: &name, Price: &price, DurationMinutes: &durationMinutes, }) } booking.TotalAmount = totalAmount booking.DurationMinutes = durationMinutes // ---------------------------- // 3. Fetch payments and calculate amount paid // ---------------------------- paymentRows, err := db.DB.Query(r.Context(), ` SELECT payment_type, payment_method, vendor_code, invoice_number, status, amount, created_at FROM payments WHERE booking_id = $1 ORDER BY created_at ASC `, bookingID) if err != nil { log.Printf("Failed to fetch payments for booking %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } defer paymentRows.Close() var payments []Payment var amountPaid float64 for paymentRows.Next() { var p Payment var vendorCode sql.NullString var invoiceNumber sql.NullInt32 err := paymentRows.Scan( &p.PaymentType, &p.PaymentMethod, &vendorCode, &invoiceNumber, &p.Status, &p.Amount, &p.CreatedAt, ) if err != nil { log.Printf("Failed to scan payment row for booking %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } if vendorCode.Valid && vendorCode.String != "" { p.VendorCode = &vendorCode.String } if invoiceNumber.Valid { num := int(invoiceNumber.Int32) p.InvoiceNumber = &num } payments = append(payments, p) if p.Status == "completed" { amountPaid += p.Amount } } if len(payments) > 0 { booking.Payments = payments } booking.AmountPaid = amountPaid booking.AmountDue = totalAmount - amountPaid // ---------------------------- // Return JSON response // ---------------------------- w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) if err := json.NewEncoder(w).Encode(booking); err != nil { log.Printf("Failed to encode booking response: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } } // GET /api/admin/bookings/search func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { query := r.URL.Query().Get("q") if query == "" { http.Error(w, "Search query 'q' is required", http.StatusBadRequest) return } // Parse pagination page := 1 perPage := 10 if pageStr := r.URL.Query().Get("page"); pageStr != "" { if p, err := strconv.Atoi(pageStr); err == nil && p > 0 { page = p } } if perPageStr := r.URL.Query().Get("per_page"); perPageStr != "" { if pp, err := strconv.Atoi(perPageStr); err == nil && pp > 0 && pp <= 100 { perPage = pp } } // Escape the search query to prevent SQL injection in LIKE patterns escapedQuery := strings.ReplaceAll(query, `\`, `\\`) escapedQuery = strings.ReplaceAll(escapedQuery, `%`, `\%`) escapedQuery = strings.ReplaceAll(escapedQuery, `_`, `\_`) searchPattern := "%" + escapedQuery + "%" // Build the main search query using CTEs to match your actual schema searchQuery := ` WITH booking_totals AS ( SELECT bs.booking_id, SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)) as total_duration, SUM(COALESCE(bs.override_price, s.price)) as total_amount FROM booking_services bs LEFT JOIN services s ON bs.service_id = s.id GROUP BY bs.booking_id ), payment_totals AS ( SELECT booking_id, SUM(amount) as total_paid FROM payments WHERE status = 'completed' GROUP BY booking_id ) SELECT b.id, b.start_time, b.status, u.fn as full_name, COALESCE(bt.total_duration, 0) as duration_minutes, COALESCE(bt.total_amount, 0) - COALESCE(pt.total_paid, 0) as amount_due FROM bookings b LEFT JOIN users u ON b.user_id = u.id LEFT JOIN booking_totals bt ON b.id = bt.booking_id LEFT JOIN payment_totals pt ON b.id = pt.booking_id WHERE b.id ILIKE $1 ESCAPE '\' OR b.notes ILIKE $1 ESCAPE '\' OR b.status::text ILIKE $1 ESCAPE '\' OR u.n_first_name ILIKE $1 ESCAPE '\' OR u.n_last_name ILIKE $1 ESCAPE '\' OR u.fn ILIKE $1 ESCAPE '\' OR u.email ILIKE $1 ESCAPE '\' OR u.phone ILIKE $1 ESCAPE '\' OR EXISTS ( SELECT 1 FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = b.id AND s.name ILIKE $1 ESCAPE '\' ) ORDER BY b.start_time ASC LIMIT $2 OFFSET $3 ` countQuery := ` SELECT COUNT(DISTINCT b.id) FROM bookings b LEFT JOIN users u ON b.user_id = u.id LEFT JOIN booking_services bs ON b.id = bs.booking_id LEFT JOIN services s ON bs.service_id = s.id WHERE b.id ILIKE $1 ESCAPE '\' OR b.notes ILIKE $1 ESCAPE '\' OR b.status::text ILIKE $1 ESCAPE '\' OR u.n_first_name ILIKE $1 ESCAPE '\' OR u.n_last_name ILIKE $1 ESCAPE '\' OR u.fn ILIKE $1 ESCAPE '\' OR u.email ILIKE $1 ESCAPE '\' OR u.phone ILIKE $1 ESCAPE '\' OR s.name ILIKE $1 ESCAPE '\' ` offset := (page - 1) * perPage // Get total count var total int err := db.DB.QueryRow(r.Context(), countQuery, searchPattern).Scan(&total) if err != nil { log.Printf("Failed to get search count: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } // Get bookings rows, err := db.DB.Query(r.Context(), searchQuery, searchPattern, perPage, offset) if err != nil { log.Printf("Failed to search bookings: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } defer rows.Close() var bookings []Booking bookingIDs := []string{} for rows.Next() { var b Booking var userFullName string err := rows.Scan(&b.ID, &b.StartTime, &b.Status, &userFullName, &b.DurationMinutes, &b.AmountDue) if err != nil { log.Printf("Failed to scan booking row: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } // Create minimal user with just full_name b.User = &UserSummary{ FullName: userFullName, } bookings = append(bookings, b) bookingIDs = append(bookingIDs, b.ID) } // Fetch service names only (same as GetAllAdminBookingsHandler) if len(bookingIDs) > 0 { servicesQuery := ` SELECT bs.booking_id, s.name FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = ANY($1) ORDER BY bs.booking_id, s.name ` serviceRows, err := db.DB.Query(r.Context(), servicesQuery, bookingIDs) if err != nil { log.Printf("Failed to fetch booking services: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } defer serviceRows.Close() servicesByBooking := make(map[string][]BookingService) for serviceRows.Next() { var bookingID string var serviceName string if err := serviceRows.Scan(&bookingID, &serviceName); err != nil { log.Printf("Failed to scan service row: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } service := BookingService{ BookingID: bookingID, ServiceName: &serviceName, } servicesByBooking[bookingID] = append(servicesByBooking[bookingID], service) } // Assign services to each booking for i := range bookings { if services, exists := servicesByBooking[bookings[i].ID]; exists { bookings[i].Services = services } else { // Ensure services is never nil bookings[i].Services = []BookingService{} } } } response := BookingListResponse{ Bookings: bookings, Page: page, PerPage: perPage, Total: total, } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(response); err != nil { log.Printf("Failed to encode response: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } } // POST /api/bookings func CreateBookingHandler(w http.ResponseWriter, r *http.Request) { // Get user ID from context userID, ok := r.Context().Value(mw.UserIDKey).(string) if !ok || userID == "" { http.Error(w, "Authentication required", http.StatusUnauthorized) return } // Parse and validate request var req CreateBookingRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("Failed to decode request: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) return } // Basic validation if req.StartTime.IsZero() { http.Error(w, "Start time is required", http.StatusBadRequest) return } if len(req.ServiceIDs) == 0 { http.Error(w, "At least one service is required", http.StatusBadRequest) return } // TODO: reenable start time validation before going live, disabled for testing // if req.StartTime.Before(time.Now()) { // http.Error(w, "Start time cannot be in the past", http.StatusBadRequest) // return // } // Get created by from context (if available) var createdBy *string if creatorID, ok := r.Context().Value(mw.UserIDKey).(string); ok { createdBy = &creatorID } tx, err := db.DB.Begin(r.Context()) if err != nil { log.Printf("Failed to start transaction: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } defer tx.Rollback(r.Context()) // Insert new booking bookingQuery := ` INSERT INTO bookings (user_id, start_time, notes, created_by) VALUES ($1, $2, $3, $4) RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by ` var booking Booking booking.User = &UserSummary{} err = tx.QueryRow(r.Context(), bookingQuery, userID, req.StartTime, req.Notes, createdBy, ).Scan( &booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status, &booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy, ) if err != nil { log.Printf("Failed to create booking for user %s: %v", userID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } // Insert booking services serviceQuery := ` INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2) ` for _, serviceID := range req.ServiceIDs { _, err := tx.Exec(r.Context(), serviceQuery, booking.ID, serviceID) if err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return } } // Create admin notification for pending booking notificationQuery := ` INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ($1, $2, $3) ` _, err = tx.Exec(r.Context(), notificationQuery, "pending_booking", booking.ID, userID) if err != nil { log.Printf("Failed to create admin notification for booking %s: %v", booking.ID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } if err := tx.Commit(r.Context()); err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return } // Return created booking w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) if err := json.NewEncoder(w).Encode(booking); err != nil { log.Printf("Failed to encode booking response: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } } // PUT /api/bookings/{id} func EditBookingHandler(w http.ResponseWriter, r *http.Request) { bookingID := chi.URLParam(r, "id") if bookingID == "" { http.Error(w, "Booking ID is required", http.StatusBadRequest) return } // Get user ID from context userID, ok := r.Context().Value(mw.UserIDKey).(string) if !ok || userID == "" { http.Error(w, "Authentication required", http.StatusUnauthorized) return } // Parse and validate request var req EditBookingRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("Failed to decode request: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) return } if req.StartTime.IsZero() { http.Error(w, "Start time is required", http.StatusBadRequest) return } if req.StartTime.Before(time.Now()) { http.Error(w, "Start time cannot be in the past", http.StatusBadRequest) return } // Update booking start time (only for user's own bookings) query := ` UPDATE bookings SET start_time = $1, updated_at = NOW() WHERE id = $2 AND user_id = $3 RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by ` var booking Booking booking.User = &UserSummary{} err := db.DB.QueryRow(r.Context(), query, req.StartTime, bookingID, userID, ).Scan( &booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status, &booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy, ) if err != nil { if err == sql.ErrNoRows { http.Error(w, "Booking not found or access denied", http.StatusNotFound) return } log.Printf("Failed to update booking %s for user %s: %v", bookingID, userID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } // Return updated booking w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) if err := json.NewEncoder(w).Encode(booking); err != nil { log.Printf("Failed to encode booking response: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } } // PUT /api/bookings/{id}/progress func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) { bookingID := chi.URLParam(r, "id") if bookingID == "" { http.Error(w, "Booking ID is required", http.StatusBadRequest) return } // Parse and validate request var req ProgressBookingRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("Failed to decode request: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) return } allowed := map[string]bool{ "pending": true, "confirmed": true, "in_progress": true, "completed": true, "client_cancelled": true, "we_cancelled": true, "re-schedule": true, "no_show": true, } if !allowed[req.Status] { http.Error(w, "Invalid status", http.StatusBadRequest) return } // Update booking status query := ` UPDATE bookings SET status = $1, updated_at = NOW() WHERE id = $2 RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by ` var booking Booking booking.User = &UserSummary{} err := db.DB.QueryRow(r.Context(), query, req.Status, bookingID, ).Scan( &booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status, &booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy, ) if err != nil { if err == sql.ErrNoRows { http.Error(w, "Booking not found", http.StatusNotFound) return } log.Printf("Failed to update booking status for booking %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } // Return updated booking w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) if err := json.NewEncoder(w).Encode(booking); err != nil { log.Printf("Failed to encode booking response: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } } // POST /api/bookings/{id}/confirm func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) { bookingID := chi.URLParam(r, "id") if bookingID == "" { http.Error(w, "Booking ID is required", http.StatusBadRequest) return } // Parse and validate request var req ConfirmBookingRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("Failed to decode request: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) return } // Validate override values for _, override := range req.ServiceOverrides { if override.OverridePrice != nil && *override.OverridePrice < 0 { http.Error(w, "Override price cannot be negative", http.StatusBadRequest) return } if override.OverrideDurationMinutes != nil && *override.OverrideDurationMinutes <= 0 { http.Error(w, "Override duration must be positive", http.StatusBadRequest) return } } tx, err := db.DB.Begin(r.Context()) if err != nil { log.Printf("Failed to start transaction: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } defer tx.Rollback(r.Context()) // Update booking status and notes bookingQuery := ` UPDATE bookings SET status = 'confirmed', notes = COALESCE($1, notes), updated_at = NOW() WHERE id = $2 AND status = 'pending' RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by ` var booking Booking booking.User = &UserSummary{} err = tx.QueryRow(r.Context(), bookingQuery, req.Notes, bookingID, ).Scan( &booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status, &booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy, ) if err != nil { if err == sql.ErrNoRows { http.Error(w, "Booking not found or already confirmed", http.StatusNotFound) return } log.Printf("Failed to confirm booking %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } // Update service overrides individually if len(req.ServiceOverrides) > 0 { // First, verify all service IDs belong to this booking serviceCheckQuery := ` SELECT COUNT(*) FROM booking_services WHERE booking_id = $1 AND service_id = ANY($2) ` serviceIDs := make([]string, len(req.ServiceOverrides)) for i, override := range req.ServiceOverrides { serviceIDs[i] = override.ServiceID } var count int err = tx.QueryRow(r.Context(), serviceCheckQuery, bookingID, serviceIDs).Scan(&count) if err != nil { log.Printf("Failed to verify services for booking %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } if count != len(req.ServiceOverrides) { http.Error(w, "One or more service IDs do not belong to this booking", http.StatusBadRequest) return } // Update each service override serviceUpdateQuery := ` UPDATE booking_services SET override_price = $1, override_duration_minutes = $2 WHERE booking_id = $3 AND service_id = $4 ` for _, override := range req.ServiceOverrides { _, err := tx.Exec(r.Context(), serviceUpdateQuery, override.OverridePrice, override.OverrideDurationMinutes, bookingID, override.ServiceID, ) if err != nil { log.Printf("Failed to update service override for booking %s, service %s: %v", bookingID, override.ServiceID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } } } if err := tx.Commit(r.Context()); err != nil { log.Printf("Failed to commit booking confirmation: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } // Return confirmed booking w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) if err := json.NewEncoder(w).Encode(booking); err != nil { log.Printf("Failed to encode booking response: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } } // DELETE /api/bookings/{id} func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) { bookingID := chi.URLParam(r, "id") if bookingID == "" { http.Error(w, "Booking ID is required", http.StatusBadRequest) return } // Get user ID from context userID, ok := r.Context().Value(mw.UserIDKey).(string) if !ok || userID == "" { http.Error(w, "Authentication required", http.StatusUnauthorized) return } // Check if booking has payments var paymentCount int paymentCheckQuery := "SELECT COUNT(*) FROM payments WHERE booking_id = $1" err := db.DB.QueryRow(r.Context(), paymentCheckQuery, bookingID).Scan(&paymentCount) if err != nil { log.Printf("Failed to check booking %s for user %s: %v", bookingID, userID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } if paymentCount > 0 { // Parse delete reason for bookings with payments var req DeleteBookingRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("Failed to decode request: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) return } allowed := map[string]bool{ "client_cancelled": true, "we_cancelled": true, "re-schedule": true, "no_show": true, } if !allowed[req.Reason] { http.Error(w, "Invalid reason", http.StatusBadRequest) return } // Start transaction for cancellation and notification tx, err := db.DB.Begin(r.Context()) if err != nil { log.Printf("Failed to start transaction: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } defer tx.Rollback(r.Context()) // Update booking status instead of deleting query := ` UPDATE bookings SET status = $1, updated_at = NOW() WHERE id = $2 AND user_id = $3 ` result, err := tx.Exec(r.Context(), query, req.Reason, bookingID, userID) if err != nil { log.Printf("Failed to cancel booking %s for user %s: %v", bookingID, userID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } if result.RowsAffected() == 0 { http.Error(w, "Booking not found or access denied", http.StatusNotFound) return } // Create admin notification for cancelled booking notificationQuery := ` INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ($1, $2, $3) ` _, err = tx.Exec(r.Context(), notificationQuery, "cancelled_booking", bookingID, userID) if err != nil { log.Printf("Failed to create admin notification for booking %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } if err := tx.Commit(r.Context()); err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ "message": "Booking cancelled successfully", "id": bookingID, "status": req.Reason, }) return } // Hard delete if no payments exist - use transaction for notification tx, err := db.DB.Begin(r.Context()) if err != nil { log.Printf("Failed to start transaction: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } defer tx.Rollback(r.Context()) // Create admin notification BEFORE deleting the booking notificationQuery := ` INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ($1, $2, $3) ` _, err = tx.Exec(r.Context(), notificationQuery, "cancelled_booking", bookingID, userID) if err != nil { log.Printf("Failed to create admin notification for booking %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } // Now delete the booking query := "DELETE FROM bookings WHERE id = $1 AND user_id = $2" result, err := tx.Exec(r.Context(), query, bookingID, userID) if err != nil { log.Printf("Failed to delete booking %s for user %s: %v", bookingID, userID, err) http.Error(w, "Failed to delete booking", http.StatusInternalServerError) return } if result.RowsAffected() == 0 { http.Error(w, "Booking not found or access denied", http.StatusNotFound) return } if err := tx.Commit(r.Context()); err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ "message": "Booking deleted successfully", "id": bookingID, }) } // GET /api/bookings/{id} func GetBookingHandler(w http.ResponseWriter, r *http.Request) { bookingID := chi.URLParam(r, "id") if bookingID == "" { http.Error(w, "Booking ID is required", http.StatusBadRequest) return } userID, ok := r.Context().Value(mw.UserIDKey).(string) if !ok || userID == "" { http.Error(w, "Authentication required", http.StatusUnauthorized) return } // ---------------------------- // 1. Fetch booking // ---------------------------- var booking Booking booking.Payments = []Payment{} booking.User = &UserSummary{} var createdBy sql.NullString err := db.DB.QueryRow(r.Context(), ` SELECT id, user_id, start_time, status, notes, created_at, updated_at, created_by FROM bookings WHERE id = $1 AND user_id = $2 `, bookingID, userID).Scan( &booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status, &booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &createdBy, ) if err != nil { if err == sql.ErrNoRows { http.Error(w, "Booking not found or access denied", http.StatusNotFound) return } log.Printf("Failed to fetch booking %s for user %s: %v", bookingID, userID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } if createdBy.Valid { booking.CreatedBy = &createdBy.String } // ---------------------------- // 2. Fetch services // ---------------------------- var totalAmount float64 var durationMinutes int serviceRows, err := db.DB.Query(r.Context(), ` SELECT bs.service_id, bs.override_price, bs.override_duration_minutes, s.name, s.description, s.price, s.duration_minutes FROM booking_services bs LEFT JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = $1 ORDER BY s.name `, bookingID) if err != nil { log.Printf("Failed to fetch services for booking %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } defer serviceRows.Close() for serviceRows.Next() { var s BookingService var overridePrice sql.NullFloat64 var overrideDuration sql.NullInt32 var name, description sql.NullString var basePrice sql.NullFloat64 var baseDuration sql.NullInt32 err := serviceRows.Scan( &s.ServiceID, &overridePrice, &overrideDuration, &name, &description, &basePrice, &baseDuration, ) if err != nil { log.Printf("Failed to scan service row for booking %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } if overridePrice.Valid { s.OverridePrice = &overridePrice.Float64 totalAmount += *s.OverridePrice } else if basePrice.Valid { totalAmount += basePrice.Float64 } if overrideDuration.Valid { d := int(overrideDuration.Int32) s.OverrideDurationMinutes = &d durationMinutes += *s.OverrideDurationMinutes } else if baseDuration.Valid { durationMinutes += int(baseDuration.Int32) } if name.Valid { s.ServiceName = &name.String } if description.Valid { s.ServiceDescription = &description.String } if basePrice.Valid { s.Price = &basePrice.Float64 } if baseDuration.Valid { d := int(baseDuration.Int32) s.DurationMinutes = &d } booking.Services = append(booking.Services, s) } // ---------------------------- // 3. Fetch payments // ---------------------------- var amountPaid float64 paymentRows, err := db.DB.Query(r.Context(), ` SELECT id, payment_type, payment_method, vendor_code, invoice_number, status, amount, is_vat_applicable, vat_rate, vat_amount, net_amount, created_at, updated_at, created_by FROM payments WHERE booking_id = $1 ORDER BY created_at ASC `, bookingID) if err != nil { log.Printf("Failed to fetch payments for booking %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } defer paymentRows.Close() for paymentRows.Next() { var p Payment var vendorCode sql.NullString var invoiceNumber sql.NullInt32 var vatRate, vatAmount, netAmount sql.NullFloat64 var createdBy sql.NullString err := paymentRows.Scan( &p.ID, &p.PaymentType, &p.PaymentMethod, &vendorCode, &invoiceNumber, &p.Status, &p.Amount, &p.IsVATApplicable, &vatRate, &vatAmount, &netAmount, &p.CreatedAt, &p.UpdatedAt, &createdBy, ) if err != nil { log.Printf("Failed to scan payment row for booking %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } if vendorCode.Valid { p.VendorCode = &vendorCode.String } if invoiceNumber.Valid { num := int(invoiceNumber.Int32) p.InvoiceNumber = &num } if vatRate.Valid { p.VATRate = &vatRate.Float64 } if vatAmount.Valid { p.VATAmount = &vatAmount.Float64 } if netAmount.Valid { p.NetAmount = &netAmount.Float64 } if createdBy.Valid { p.CreatedBy = &createdBy.String } if p.Status == "completed" { amountPaid += p.Amount } booking.Payments = append(booking.Payments, p) } amountDue := totalAmount - amountPaid // Create enhanced response with user-friendly totals enhancedResponse := struct { Booking Booking `json:"booking"` TotalAmount float64 `json:"total_amount"` AmountPaid float64 `json:"amount_paid"` AmountDue float64 `json:"amount_due"` DurationMinutes int `json:"duration_minutes"` }{ Booking: booking, TotalAmount: totalAmount, AmountPaid: amountPaid, AmountDue: amountDue, DurationMinutes: durationMinutes, } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(enhancedResponse); err != nil { log.Printf("Failed to encode booking response: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } }