From 861dd11c5bd83f5f6744007588f37a64a5936f03 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Tue, 3 Mar 2026 21:40:39 +0000 Subject: [PATCH] Deposit tracking --- backend/handlers/admin/bookings_test.go | 150 +++ backend/handlers/bookings/bookings.go | 1070 +++++++------------- backend/handlers/bookings/bookings_test.go | 216 +++- init-scripts/init-script.sql | 1 + local-dev-2.sh | 25 +- 5 files changed, 780 insertions(+), 682 deletions(-) diff --git a/backend/handlers/admin/bookings_test.go b/backend/handlers/admin/bookings_test.go index 17bee85..f693cc1 100644 --- a/backend/handlers/admin/bookings_test.go +++ b/backend/handlers/admin/bookings_test.go @@ -1488,3 +1488,153 @@ func TestAdminBookings_ApproveEditRequest(t *testing.T) { t.Errorf("expected notification to be acknowledged after approve, but acknowledged_at is still NULL") } } + +// ============================================================================= +// Deposit System Tests +// ============================================================================= + +// TestAdminBookings_Get_DepositFields verifies that admin booking endpoints return +// deposit-related fields (deposit_required, deposit_amount, deposit_paid, deposit_deadline) +// for bookings that have deposit_required=true. +func TestAdminBookings_Get_DepositFields(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + adminID, err := fixtures.CreateTestAdminUser(db.DB) + if err != nil { + t.Fatalf("failed to create admin user: %v", err) + } + defer fixtures.DeleteUser(db.DB, adminID) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, serviceID) + + // Create booking via SQL with deposit_required=true (simulating user-created booking) + futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) + var bookingID string + err = db.DB.QueryRow(context.Background(), ` + INSERT INTO bookings (user_id, start_time, status, deposit_required) + VALUES ($1, $2, 'confirmed', true) + RETURNING id + `, userID, futureTime).Scan(&bookingID) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + defer fixtures.DeleteBooking(db.DB, bookingID) + + // Link service to booking + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO booking_services (booking_id, service_id) + VALUES ($1, $2) + `, bookingID, serviceID) + if err != nil { + t.Fatalf("failed to link service: %v", err) + } + + // GET single booking via admin endpoint + w := makeAdminRequest(http.HandlerFunc(bookings.GetAdminBookingHandler), "GET", "/api/admin/bookings/"+bookingID, nil) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var fetchedBooking bookings.Booking + if err := parseResponseBody(w, &fetchedBooking); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + // Verify deposit fields are populated + if !fetchedBooking.DepositRequired { + t.Error("expected DepositRequired to be true") + } + if fetchedBooking.DepositAmount <= 0 { + t.Error("expected DepositAmount to be positive") + } + // DepositPaid is a bool, just verify it exists + _ = fetchedBooking.DepositPaid + if fetchedBooking.DepositDeadline == nil { + t.Error("expected DepositDeadline to be set") + } +} + +// TestAdminBookings_List_DepositFields verifies that admin booking list returns +// deposit-related fields for each booking. +func TestAdminBookings_List_DepositFields(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + adminID, err := fixtures.CreateTestAdminUser(db.DB) + if err != nil { + t.Fatalf("failed to create admin user: %v", err) + } + defer fixtures.DeleteUser(db.DB, adminID) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, serviceID) + + // Create booking via SQL with deposit_required=true + futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) + var bookingID string + err = db.DB.QueryRow(context.Background(), ` + INSERT INTO bookings (user_id, start_time, status, deposit_required) + VALUES ($1, $2, 'pending', true) + RETURNING id + `, userID, futureTime).Scan(&bookingID) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + defer fixtures.DeleteBooking(db.DB, bookingID) + + // Link service to booking + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO booking_services (booking_id, service_id) + VALUES ($1, $2) + `, bookingID, serviceID) + if err != nil { + t.Fatalf("failed to link service: %v", err) + } + + // GET all bookings via admin endpoint + w := makeAdminRequest(http.HandlerFunc(bookings.GetAllAdminBookingsHandler), "GET", "/api/admin/bookings", nil) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var resp bookings.BookingListResponse + if err := parseResponseBody(w, &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + if len(resp.Bookings) != 1 { + t.Fatalf("expected 1 booking, got %d", len(resp.Bookings)) + } + + booking := resp.Bookings[0] + + // Verify deposit fields are populated in list + if !booking.DepositRequired { + t.Error("expected DepositRequired to be true in list") + } + if booking.DepositAmount <= 0 { + t.Error("expected DepositAmount to be positive in list") + } +} diff --git a/backend/handlers/bookings/bookings.go b/backend/handlers/bookings/bookings.go index 3469530..4e5daf6 100644 --- a/backend/handlers/bookings/bookings.go +++ b/backend/handlers/bookings/bookings.go @@ -37,7 +37,10 @@ type Booking struct { UpdatedAt time.Time `json:"updated_at"` CreatedBy *string `json:"created_by,omitempty"` - // Deposit fields + // Deposit fields. + // DepositRequired is snapshotted at creation from users.deposits_required > 0 + // and stored on the bookings row — so historic bookings reflect the obligation + // that existed when they were made, not the user's current standing. DepositRequired bool `json:"deposit_required"` DepositAmount float64 `json:"deposit_amount,omitempty"` DepositPaid bool `json:"deposit_paid"` @@ -53,6 +56,22 @@ type Booking struct { DurationMinutes int `json:"duration_minutes"` } +// populateDepositFields sets the computed deposit fields on a Booking. +// It must be called after TotalAmount, AmountPaid, and StartTime are already set. +// +// - depositRequired: snapshotted value from bookings.deposit_required (set at creation). +// - preStartAmountPaid: sum of completed payments whose created_at < booking.start_time. +func populateDepositFields(b *Booking, depositRequired bool, preStartAmountPaid float64) { + b.DepositRequired = depositRequired + b.DepositAmount = b.TotalAmount * 0.20 + // A deposit is considered paid when pre-start payments cover the deposit amount. + // We only declare it paid when a deposit was actually required, so that + // bookings with no deposit obligation don't incorrectly show DepositPaid: true. + b.DepositPaid = depositRequired && preStartAmountPaid >= b.DepositAmount + deadline := b.StartTime.Add(-24 * time.Hour).Format(time.RFC3339) + b.DepositDeadline = &deadline +} + // BookingService represents a service associated with a booking type BookingService struct { BookingID string `json:"booking_id"` @@ -225,33 +244,27 @@ type AdminBookingDetail struct { func parseGetAllBookingsRequest(r *http.Request) GetAllBookingsRequest { req := GetAllBookingsRequest{ Page: 1, - PerPage: 10, // default page size + PerPage: 10, } - 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 } @@ -263,101 +276,91 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) { return } - // Parse query parameters req := parseGetAllBookingsRequest(r) - // Build base query with user filter - // We now calculate Total Amount, Amount Paid, AND Duration + // deposit_required is read from the bookings row (snapshotted at creation). + // pre_start_amount_paid sums completed payments created before start_time. baseQuery := ` SELECT - id, start_time, status, notes, created_at, updated_at, created_by, - -- Total Amount + b.id, b.start_time, b.status, b.notes, b.created_at, b.updated_at, b.created_by, (SELECT COALESCE(SUM(CASE WHEN bs.override_price IS NOT NULL THEN bs.override_price ELSE s.price END), 0) FROM booking_services bs JOIN services s ON bs.service_id = s.id - WHERE bs.booking_id = bookings.id) as total_amount, - -- Amount Paid + WHERE bs.booking_id = b.id) AS total_amount, (SELECT COALESCE(SUM(amount), 0) FROM payments - WHERE booking_id = bookings.id AND status = 'completed') as amount_paid, - -- Duration Minutes + WHERE booking_id = b.id AND status = 'completed') AS amount_paid, (SELECT COALESCE(SUM(CASE WHEN bs.override_duration_minutes IS NOT NULL THEN bs.override_duration_minutes ELSE s.duration_minutes END), 0) FROM booking_services bs JOIN services s ON bs.service_id = s.id - WHERE bs.booking_id = bookings.id) as duration_minutes - FROM bookings - WHERE user_id = $1 + WHERE bs.booking_id = b.id) AS duration_minutes, + b.deposit_required, + (SELECT COALESCE(SUM(amount), 0) + FROM payments + WHERE booking_id = b.id AND status = 'completed' AND created_at < b.start_time) AS pre_start_amount_paid + FROM bookings b + WHERE b.user_id = $1 ` countQuery := `SELECT COUNT(*) FROM bookings WHERE user_id = $1` - var args []interface{} - var countArgs []interface{} + var args, 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) + baseQuery += fmt.Sprintf(" AND b.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) + 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) countArgs = append(countArgs, startTime) paramCount++ } - if req.EndDate != nil { - baseQuery += fmt.Sprintf(" AND start_time <= $%d", paramCount) + 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 } - // 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" + 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, countArgs...).Scan(&total) - if err != nil { + if err := db.DB.QueryRow(r.Context(), countQuery, countArgs...).Scan(&total); 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) @@ -370,17 +373,15 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) { for rows.Next() { var b Booking var createdBy sql.NullString - // Scan duration_minutes as well - var totalAmount, amountPaid float64 + var totalAmount, amountPaid, preStartAmountPaid float64 var durationMinutes int + var depositRequired bool - err := rows.Scan( + if err := rows.Scan( &b.ID, &b.StartTime, &b.Status, &b.Notes, &b.CreatedAt, &b.UpdatedAt, &createdBy, - &totalAmount, - &amountPaid, - &durationMinutes, - ) - if err != nil { + &totalAmount, &amountPaid, &durationMinutes, + &depositRequired, &preStartAmountPaid, + ); err != nil { log.Printf("Failed to scan booking row: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return @@ -388,42 +389,36 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) { if createdBy.Valid { b.CreatedBy = &createdBy.String } - b.TotalAmount = totalAmount b.AmountPaid = amountPaid b.AmountDue = totalAmount - amountPaid - b.DurationMinutes = durationMinutes // Populate the struct - + b.DurationMinutes = durationMinutes + populateDepositFields(&b, depositRequired, preStartAmountPaid) bookings = append(bookings, b) } - response := BookingListResponse{ + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(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 { + }); 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 + 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 @@ -431,7 +426,7 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { payment_totals AS ( SELECT booking_id, - SUM(amount) as total_paid + SUM(amount) AS total_paid FROM payments WHERE status = 'completed' GROUP BY booking_id @@ -441,8 +436,14 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { 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 + COALESCE(bt.total_duration, 0) AS duration_minutes, + COALESCE(bt.total_amount, 0) AS total_amount, + COALESCE(pt.total_paid, 0) AS amount_paid, + COALESCE(bt.total_amount, 0) - COALESCE(pt.total_paid, 0) AS amount_due, + b.deposit_required, + (SELECT COALESCE(SUM(p2.amount), 0) + FROM payments p2 + WHERE p2.booking_id = b.id AND p2.status = 'completed' AND p2.created_at < b.start_time) AS pre_start_amount_paid FROM bookings b LEFT JOIN users u ON b.user_id = u.id LEFT JOIN booking_totals bt ON b.id = bt.booking_id @@ -452,26 +453,26 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { 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 { + addWhereClause := func(condition string) { if whereAdded { - baseQuery += fmt.Sprintf(" AND b.start_time >= $%d", paramCount) - countQuery += fmt.Sprintf(" AND b.start_time >= $%d", paramCount) + baseQuery += " AND " + condition + countQuery += " AND " + condition } else { - baseQuery += fmt.Sprintf(" WHERE b.start_time >= $%d", paramCount) - countQuery += fmt.Sprintf(" WHERE b.start_time >= $%d", paramCount) + baseQuery += " WHERE " + condition + countQuery += " WHERE " + condition whereAdded = true } + } + + if req.Status != nil { + addWhereClause(fmt.Sprintf("b.status = $%d", paramCount)) + args = append(args, *req.Status) + paramCount++ + } + if req.StartDate != nil { + addWhereClause(fmt.Sprintf("b.start_time >= $%d", paramCount)) 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) @@ -480,57 +481,40 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { 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) - } + addWhereClause(fmt.Sprintf("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) + args = append(args, endTime.Add(23*time.Hour+59*time.Minute+59*time.Second)) paramCount++ } - // Add ordering and pagination (NO GROUP BY needed here) baseQuery += " ORDER BY b.start_time DESC" 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 { + if err := db.DB.QueryRow(r.Context(), countQuery, countArgs...).Scan(&total); err != nil { log.Printf("Failed to get total booking count: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } - // Calculate total pages - var totalPages int - if req.PerPage > 0 { - totalPages = (total + req.PerPage - 1) / req.PerPage - } + totalPages := (total + req.PerPage - 1) / req.PerPage if totalPages == 0 { totalPages = 1 } - // Get bookings rows, err := db.DB.Query(r.Context(), baseQuery, args...) if err != nil { log.Printf("Failed to fetch all bookings: %v", err) @@ -540,39 +524,40 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { defer rows.Close() var bookings []Booking - bookingIDs := []string{} + var bookingIDs []string for rows.Next() { var b Booking var userFullName string + var totalAmount, amountPaid, amountDue, preStartAmountPaid float64 + var depositRequired bool - err := rows.Scan(&b.ID, &b.StartTime, &b.Status, &userFullName, &b.DurationMinutes, &b.AmountDue) - if err != nil { + if err := rows.Scan( + &b.ID, &b.StartTime, &b.Status, &userFullName, + &b.DurationMinutes, &totalAmount, &amountPaid, &amountDue, + &depositRequired, &preStartAmountPaid, + ); 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, - } - + b.User = &UserSummary{FullName: userFullName} + b.TotalAmount = totalAmount + b.AmountPaid = amountPaid + b.AmountDue = amountDue + populateDepositFields(&b, depositRequired, preStartAmountPaid) bookings = append(bookings, b) bookingIDs = append(bookingIDs, b.ID) } - // Fetch service names only if len(bookingIDs) > 0 { - servicesQuery := ` + serviceRows, err := db.DB.Query(r.Context(), ` 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) + `, bookingIDs) if err != nil { log.Printf("Failed to fetch booking services: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) @@ -581,25 +566,19 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { defer serviceRows.Close() servicesByBooking := make(map[string][]BookingService) - for serviceRows.Next() { - var bookingID string - var serviceName string - + var bookingID, 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{ + n := serviceName + servicesByBooking[bookingID] = append(servicesByBooking[bookingID], BookingService{ BookingID: bookingID, - ServiceName: &serviceName, - } - servicesByBooking[bookingID] = append(servicesByBooking[bookingID], service) + ServiceName: &n, + }) } - - // Assign services to each booking for i := range bookings { if services, exists := servicesByBooking[bookings[i].ID]; exists { bookings[i].Services = services @@ -607,19 +586,16 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { } } - response := BookingListResponse{ + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(BookingListResponse{ Bookings: bookings, Page: req.Page, PerPage: req.PerPage, Total: total, TotalPages: totalPages, - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(response); err != nil { + }); err != nil { log.Printf("Failed to encode response: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) - return } } @@ -631,53 +607,34 @@ func GetAllBookingsByUserHandler(w http.ResponseWriter, r *http.Request) { return } - // Parse pagination parameters query := r.URL.Query() - page := 1 - perPage := 5 - + page, perPage := 1, 5 if pageStr := query.Get("page"); pageStr != "" { if p, err := strconv.Atoi(pageStr); err == nil && p > 0 { page = p } } - if perPageStr := query.Get("per_page"); perPageStr != "" { if pp, err := strconv.Atoi(perPageStr); err == nil && pp > 0 && pp <= 100 { perPage = pp } } - offset := (page - 1) * perPage - - // Get total count var total int - err := db.DB.QueryRow(r.Context(), ` - SELECT COUNT(*) - FROM bookings - WHERE user_id = $1 - `, userID).Scan(&total) - if err != nil { + if err := db.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM bookings WHERE user_id = $1`, userID).Scan(&total); err != nil { log.Printf("Failed to count bookings for user %s: %v", userID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } - // Fetch bookings with pagination, ordered by start_time DESC (future to past) rows, err := db.DB.Query(r.Context(), ` - SELECT - b.id, - b.start_time, - b.status, - b.notes, - b.created_at, - b.updated_at, - b.created_by + SELECT b.id, b.start_time, b.status, b.notes, b.created_at, b.updated_at, b.created_by, + b.deposit_required FROM bookings b WHERE b.user_id = $1 ORDER BY b.start_time DESC LIMIT $2 OFFSET $3 - `, userID, perPage, offset) + `, userID, perPage, (page-1)*perPage) if err != nil { log.Printf("Failed to fetch bookings for user %s: %v", userID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) @@ -688,27 +645,22 @@ func GetAllBookingsByUserHandler(w http.ResponseWriter, r *http.Request) { var bookings []Booking for rows.Next() { var b Booking - err := rows.Scan( - &b.ID, - &b.StartTime, - &b.Status, - &b.Notes, - &b.CreatedAt, - &b.UpdatedAt, - &b.CreatedBy, - ) - if err != nil { + var depositRequired bool + if err := rows.Scan( + &b.ID, &b.StartTime, &b.Status, &b.Notes, + &b.CreatedAt, &b.UpdatedAt, &b.CreatedBy, + &depositRequired, + ); err != nil { log.Printf("Failed to scan booking row: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } - // Fetch services for this booking 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 + 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 @@ -720,24 +672,34 @@ func GetAllBookingsByUserHandler(w http.ResponseWriter, r *http.Request) { var totalAmount float64 for serviceRows.Next() { - var service BookingService + var svc BookingService var price float64 - var durationMinutes int - - if err := serviceRows.Scan(&service.ServiceName, &price, &durationMinutes); err != nil { + var dur int + if err := serviceRows.Scan(&svc.ServiceName, &price, &dur); err != nil { log.Printf("Failed to scan service: %v", err) continue } - - service.Price = &price - service.DurationMinutes = &durationMinutes + svc.Price = &price + svc.DurationMinutes = &dur totalAmount += price - - b.Services = append(b.Services, service) + b.Services = append(b.Services, svc) } serviceRows.Close() + // Fetch both all-time and pre-start paid amounts in one query + var amountPaid, preStartAmountPaid float64 + db.DB.QueryRow(r.Context(), ` + SELECT + COALESCE(SUM(amount) FILTER (WHERE status = 'completed'), 0), + COALESCE(SUM(amount) FILTER (WHERE status = 'completed' AND created_at < $2), 0) + FROM payments + WHERE booking_id = $1 + `, b.ID, b.StartTime).Scan(&amountPaid, &preStartAmountPaid) + b.TotalAmount = totalAmount + b.AmountPaid = amountPaid + b.AmountDue = totalAmount - amountPaid + populateDepositFields(&b, depositRequired, preStartAmountPaid) bookings = append(bookings, b) } @@ -745,26 +707,22 @@ func GetAllBookingsByUserHandler(w http.ResponseWriter, r *http.Request) { bookings = []Booking{} } - // Calculate total pages totalPages := (total + perPage - 1) / perPage if totalPages == 0 { totalPages = 1 } - response := BookingListResponse{ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(BookingListResponse{ Bookings: bookings, Total: total, Page: page, PerPage: perPage, TotalPages: totalPages, - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - if err := json.NewEncoder(w).Encode(response); err != nil { + }); err != nil { log.Printf("Failed to encode bookings response: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) - return } } @@ -776,26 +734,27 @@ func GetAdminBookingHandler(w http.ResponseWriter, r *http.Request) { return } - // ---------------------------- - // 1. Fetch booking + user - // ---------------------------- var booking Booking booking.User = &UserSummary{} + var depositRequired bool 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 + u.referral_code, u.notes, + b.deposit_required 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.FullName, &booking.User.Email, &booking.User.Phone, + &booking.User.ProfilePicURL, &booking.User.LoyaltyStamps, &booking.User.ReferralCode, &booking.User.Notes, + &depositRequired, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -807,31 +766,19 @@ func GetAdminBookingHandler(w http.ResponseWriter, r *http.Request) { 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 { + if err := db.DB.QueryRow(r.Context(), ` + SELECT COUNT(*) FROM user_referrals WHERE referrer_id = $1 + `, booking.User.ID).Scan(&referralCodeUses); 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 - bs.service_id, - s.name, - coalesce(bs.override_price, s.price) as price, - coalesce(bs.override_duration_minutes, s.duration_minutes) as duration_minutes + bs.service_id, 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 @@ -845,41 +792,30 @@ func GetAdminBookingHandler(w http.ResponseWriter, r *http.Request) { defer serviceRows.Close() var totalAmount float64 - var durationMinutesTotal int - for serviceRows.Next() { - var serviceID string - var name string + var serviceID, name string var price float64 - var durationMinutes int - - if err := serviceRows.Scan(&serviceID, &name, &price, &durationMinutes); err != nil { + var dur int + if err := serviceRows.Scan(&serviceID, &name, &price, &dur); 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 - durationMinutesTotal += durationMinutes + booking.DurationMinutes += dur + n, p, d := name, price, dur booking.Services = append(booking.Services, BookingService{ - ServiceID: serviceID, // now set - ServiceName: &name, - Price: &price, - DurationMinutes: &durationMinutes, + ServiceID: serviceID, + ServiceName: &n, + Price: &p, + DurationMinutes: &d, }) } - booking.TotalAmount = totalAmount - booking.DurationMinutes = durationMinutesTotal - // ---------------------------- - // 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 + SELECT payment_type, payment_method, vendor_code, invoice_number, + status, amount, created_at FROM payments WHERE booking_id = $1 ORDER BY created_at ASC @@ -891,24 +827,19 @@ func GetAdminBookingHandler(w http.ResponseWriter, r *http.Request) { } defer paymentRows.Close() - var payments []Payment - var amountPaid float64 - + var amountPaid, preStartAmountPaid float64 for paymentRows.Next() { var p Payment var vendorCode sql.NullString var invoiceNumber sql.NullInt32 - - err := paymentRows.Scan( + if err := paymentRows.Scan( &p.PaymentType, &p.PaymentMethod, &vendorCode, &invoiceNumber, &p.Status, &p.Amount, &p.CreatedAt, - ) - if err != nil { + ); 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 } @@ -916,29 +847,24 @@ func GetAdminBookingHandler(w http.ResponseWriter, r *http.Request) { num := int(invoiceNumber.Int32) p.InvoiceNumber = &num } - - payments = append(payments, p) - + booking.Payments = append(booking.Payments, p) if p.Status == "completed" { amountPaid += p.Amount + if p.CreatedAt.Before(booking.StartTime) { + preStartAmountPaid += p.Amount + } } } - if len(payments) > 0 { - booking.Payments = payments - } booking.AmountPaid = amountPaid booking.AmountDue = totalAmount - amountPaid + populateDepositFields(&booking, depositRequired, preStartAmountPaid) - // ---------------------------- - // 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 } } @@ -950,9 +876,7 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { return } - // Parse pagination - page := 1 - perPage := 10 + page, perPage := 1, 10 if pageStr := r.URL.Query().Get("page"); pageStr != "" { if p, err := strconv.Atoi(pageStr); err == nil && p > 0 { page = p @@ -964,19 +888,17 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { } } - // 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 + 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 @@ -984,7 +906,7 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { payment_totals AS ( SELECT booking_id, - SUM(amount) as total_paid + SUM(amount) AS total_paid FROM payments WHERE status = 'completed' GROUP BY booking_id @@ -993,9 +915,15 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { 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 + u.fn AS full_name, + COALESCE(bt.total_duration, 0) AS duration_minutes, + COALESCE(bt.total_amount, 0) AS total_amount, + COALESCE(pt.total_paid, 0) AS amount_paid, + COALESCE(bt.total_amount, 0) - COALESCE(pt.total_paid, 0) AS amount_due, + b.deposit_required, + (SELECT COALESCE(SUM(p2.amount), 0) + FROM payments p2 + WHERE p2.booking_id = b.id AND p2.status = 'completed' AND p2.created_at < b.start_time) AS pre_start_amount_paid FROM bookings b LEFT JOIN users u ON b.user_id = u.id LEFT JOIN booking_totals bt ON b.id = bt.booking_id @@ -1036,28 +964,19 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { 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 { + if err := db.DB.QueryRow(r.Context(), countQuery, searchPattern).Scan(&total); err != nil { log.Printf("Failed to get search count: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } - // Calculate total pages - var totalPages int - if perPage > 0 { - totalPages = (total + perPage - 1) / perPage - } + totalPages := (total + perPage - 1) / perPage if totalPages == 0 { totalPages = 1 } - // Get bookings - rows, err := db.DB.Query(r.Context(), searchQuery, searchPattern, perPage, offset) + rows, err := db.DB.Query(r.Context(), searchQuery, searchPattern, perPage, (page-1)*perPage) if err != nil { log.Printf("Failed to search bookings: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) @@ -1066,39 +985,40 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { defer rows.Close() var bookings []Booking - bookingIDs := []string{} + var bookingIDs []string for rows.Next() { var b Booking var userFullName string + var totalAmount, amountPaid, amountDue, preStartAmountPaid float64 + var depositRequired bool - err := rows.Scan(&b.ID, &b.StartTime, &b.Status, &userFullName, &b.DurationMinutes, &b.AmountDue) - if err != nil { + if err := rows.Scan( + &b.ID, &b.StartTime, &b.Status, &userFullName, + &b.DurationMinutes, &totalAmount, &amountPaid, &amountDue, + &depositRequired, &preStartAmountPaid, + ); 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, - } - + b.User = &UserSummary{FullName: userFullName} + b.TotalAmount = totalAmount + b.AmountPaid = amountPaid + b.AmountDue = amountDue + populateDepositFields(&b, depositRequired, preStartAmountPaid) bookings = append(bookings, b) bookingIDs = append(bookingIDs, b.ID) } - // Fetch service names only (same as GetAllAdminBookingsHandler) if len(bookingIDs) > 0 { - servicesQuery := ` + serviceRows, err := db.DB.Query(r.Context(), ` 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) + `, bookingIDs) if err != nil { log.Printf("Failed to fetch booking services: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) @@ -1107,61 +1027,49 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { defer serviceRows.Close() servicesByBooking := make(map[string][]BookingService) - for serviceRows.Next() { - var bookingID string - var serviceName string - + var bookingID, 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{ + n := serviceName + servicesByBooking[bookingID] = append(servicesByBooking[bookingID], BookingService{ BookingID: bookingID, - ServiceName: &serviceName, - } - servicesByBooking[bookingID] = append(servicesByBooking[bookingID], service) + ServiceName: &n, + }) } - - // 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{ + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(BookingListResponse{ Bookings: bookings, Page: page, PerPage: perPage, Total: total, TotalPages: totalPages, - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(response); err != nil { + }); 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) @@ -1169,7 +1077,6 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) { return } - // Basic validation if req.StartTime.IsZero() { http.Error(w, "Start time is required", http.StatusBadRequest) return @@ -1179,9 +1086,45 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) { return } + // Read deposits_required live from the user — this is the enforcement value, + // not a display value, so it must reflect current standing. + var depositsRequired int + if err := db.DB.QueryRow(r.Context(), `SELECT deposits_required FROM users WHERE id = $1`, userID).Scan(&depositsRequired); err != nil { + log.Printf("Failed to fetch deposits_required for user %s: %v", userID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + // Enforce one-active-booking limit when deposits are outstanding + if depositsRequired > 0 { + var activeCount int + if err := db.DB.QueryRow(r.Context(), ` + SELECT COUNT(*) FROM bookings + WHERE user_id = $1 AND status IN ('pending', 'confirmed') + `, userID).Scan(&activeCount); err != nil { + log.Printf("Failed to check active bookings for user %s: %v", userID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + if activeCount > 0 { + http.Error(w, "You already have an active booking. Complete or cancel it before creating a new one.", http.StatusConflict) + return + } + + // Also enforce minimum 48h advance notice + if req.StartTime.Before(time.Now().Add(48 * time.Hour)) { + http.Error(w, "You must book at least 48 hours in advance. Complete more appointments to remove this requirement.", http.StatusBadRequest) + return + } + } + + // Snapshot whether a deposit is required at the moment of booking creation. + // Stored on the bookings row so historic GET responses are accurate regardless + // of the user's future deposits_required changes. + depositRequiredSnapshot := depositsRequired > 0 + // Check patch test requirements for all services for _, serviceID := range req.ServiceIDs { - // Find patch test for this service var patchTestID string var noticeHours int err := db.DB.QueryRow(r.Context(), ` @@ -1191,21 +1134,17 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) { `, serviceID).Scan(&patchTestID, ¬iceHours) if err == nil { - // Service requires a patch test - check if user has valid record var testedAt time.Time err = db.DB.QueryRow(r.Context(), ` SELECT tested_at FROM user_patch_tests WHERE user_id = $1 AND patch_test_id = $2 `, userID, patchTestID).Scan(&testedAt) - if err != nil { - // No valid patch test record http.Error(w, "Patch test required for this service. Please complete a patch test first.", http.StatusBadRequest) return } - // Check if notice period has passed eligibleFrom := testedAt.Add(time.Duration(noticeHours) * time.Hour) if time.Now().Before(eligibleFrom) { hoursLeft := time.Until(eligibleFrom).Hours() @@ -1213,12 +1152,9 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) { return } - // Check if patch test has expired var expiryMonths int - err = db.DB.QueryRow(r.Context(), `SELECT expiry_months FROM patch_tests WHERE id = $1`, patchTestID).Scan(&expiryMonths) - if err == nil { - expiresAt := testedAt.AddDate(0, expiryMonths, 0) - if time.Now().After(expiresAt) { + if err := db.DB.QueryRow(r.Context(), `SELECT expiry_months FROM patch_tests WHERE id = $1`, patchTestID).Scan(&expiryMonths); err == nil { + if time.Now().After(testedAt.AddDate(0, expiryMonths, 0)) { http.Error(w, "Your patch test has expired. Please complete a new patch test.", http.StatusBadRequest) return } @@ -1226,23 +1162,11 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) { } } - // Validate start time is not in the past - 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 - } - - // Validate start time is not in the past if req.StartTime.Before(time.Now()) { http.Error(w, "Start time cannot be in the past", http.StatusBadRequest) return } - // Validate booking fits within operating hours for regular users var svcDuration int if err := db.DB.QueryRow(r.Context(), ` SELECT COALESCE(SUM(duration_minutes), 0) FROM services WHERE id = ANY($1) @@ -1267,18 +1191,20 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) { return } - // Check for overlapping bookings with confirmed/in_progress/completed status - // Users cannot book a time slot that overlaps with these statuses var cnt int db.DB.QueryRow(r.Context(), ` - SELECT COUNT(*) FROM bookings WHERE status IN ('confirmed','in_progress','completed') AND start_time < $2 AND start_time + (INTERVAL '1 minute' * (SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes,s.duration_minutes)),60) FROM booking_services bs JOIN services s ON bs.service_id=s.id WHERE bs.booking_id=bookings.id)) > $1 + SELECT COUNT(*) FROM bookings WHERE status IN ('confirmed','in_progress','completed') + AND start_time < $2 + AND start_time + (INTERVAL '1 minute' * ( + SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes,s.duration_minutes)),60) + FROM booking_services bs JOIN services s ON bs.service_id=s.id WHERE bs.booking_id=bookings.id + )) > $1 `, req.StartTime, endTime).Scan(&cnt) if cnt > 0 { http.Error(w, "Cannot book this time - slot overlaps with an existing booking", http.StatusConflict) return } - // Get created by from context (if available) var createdBy *string if creatorID, ok := r.Context().Value(mw.UserIDKey).(string); ok { createdBy = &creatorID @@ -1292,81 +1218,33 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) { } 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 - ` - + // Insert booking with snapshotted deposit_required 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 { + if err := tx.QueryRow(r.Context(), ` + INSERT INTO bookings (user_id, start_time, notes, created_by, deposit_required) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by, deposit_required + `, userID, req.StartTime, req.Notes, createdBy, depositRequiredSnapshot).Scan( + &booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status, + &booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy, + &booking.DepositRequired, + ); err != nil { log.Printf("Failed to create booking for user %s: %v", userID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } - // Calculate total price and check deposit requirements - var totalPrice float64 - tx.QueryRow(r.Context(), ` - SELECT COALESCE(SUM(COALESCE(bs.override_price, s.price)), 0) - FROM booking_services bs - JOIN services s ON bs.service_id = s.id - WHERE bs.booking_id = $1 - `, booking.ID).Scan(&totalPrice) - - // Check if user needs to pay deposit (deposits_required > 0) - var depositsRequired int - tx.QueryRow(r.Context(), `SELECT deposits_required FROM users WHERE id = $1`, userID).Scan(&depositsRequired) - - // If deposits_required > 0, require min 48h notice - if depositsRequired > 0 { - minStartTime := time.Now().Add(48 * time.Hour) - if req.StartTime.Before(minStartTime) { - tx.Rollback(r.Context()) - http.Error(w, "You must book at least 48 hours in advance. Complete more appointments to remove this requirement.", http.StatusBadRequest) - 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 { + if _, err := tx.Exec(r.Context(), `INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2)`, booking.ID, serviceID); 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 { + if _, err := tx.Exec(r.Context(), ` + INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ($1, $2, $3) + `, "pending_booking", booking.ID, userID); 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 @@ -1381,7 +1259,7 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) { booking.Services = []BookingService{} rows, err := db.DB.Query(r.Context(), ` SELECT bs.booking_id, bs.service_id, bs.override_price, bs.override_duration_minutes, - s.name, s.description, s.price, s.duration_minutes + s.name, s.description, s.price, s.duration_minutes FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = $1 @@ -1390,24 +1268,25 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) { defer rows.Close() for rows.Next() { var bs BookingService - err := rows.Scan( + if err := rows.Scan( &bs.BookingID, &bs.ServiceID, &bs.OverridePrice, &bs.OverrideDurationMinutes, &bs.ServiceName, &bs.ServiceDescription, &bs.Price, &bs.DurationMinutes, - ) - if err != nil { + ); err != nil { break } booking.Services = append(booking.Services, bs) } } - // Return created booking + // Populate deposit display fields on the creation response. + // No payments exist yet so pre-start paid is 0 and DepositPaid will be false. + populateDepositFields(&booking, depositRequiredSnapshot, 0) + 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 } } @@ -1419,14 +1298,12 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) { 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) @@ -1443,10 +1320,8 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) { return } - // Check if user owns this booking and get current status var currentStatus string - err := db.DB.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(¤tStatus) - if err != nil { + if err := db.DB.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(¤tStatus); err != nil { if errors.Is(err, sql.ErrNoRows) { http.Error(w, "Booking not found or access denied", http.StatusNotFound) return @@ -1456,29 +1331,25 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) { return } - // Users cannot edit completed or cancelled bookings if currentStatus == "completed" || currentStatus == "client_cancelled" || currentStatus == "we_cancelled" { http.Error(w, "Cannot edit a completed or cancelled booking", http.StatusForbidden) return } - // Get booking duration for overlap check var durationMinutes int - err = db.DB.QueryRow(r.Context(), ` + if err := db.DB.QueryRow(r.Context(), ` SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60) FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = $1 - `, bookingID).Scan(&durationMinutes) - if err != nil { + `, bookingID).Scan(&durationMinutes); err != nil { log.Printf("Failed to get booking duration %s: %v", bookingID, err) durationMinutes = 60 } - // Check for overlapping bookings (user is blocked if overlap exists) - var overlapCount int newEndTime := req.StartTime.Add(time.Duration(durationMinutes) * time.Minute) - err = db.DB.QueryRow(r.Context(), ` + var overlapCount int + if err := db.DB.QueryRow(r.Context(), ` SELECT COUNT(*) FROM bookings WHERE id != $1 AND status NOT IN ('completed', 'client_cancelled', 'we_cancelled') @@ -1489,8 +1360,7 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) { JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = bookings.id )) > $2 - `, bookingID, req.StartTime, newEndTime).Scan(&overlapCount) - if err != nil { + `, bookingID, req.StartTime, newEndTime).Scan(&overlapCount); err != nil { log.Printf("Failed to check overlap %s: %v", bookingID, err) } if overlapCount > 0 { @@ -1498,7 +1368,6 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) { return } - // Check if salon is closed (exceptional hours) - user is blocked on closed days weekday := int(req.StartTime.Weekday()) bookingTime := req.StartTime.Format("15:04:05") daysToMonday := weekday @@ -1507,9 +1376,8 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) { } weekStart := req.StartTime.AddDate(0, 0, -daysToMonday+1).Truncate(24 * time.Hour) - // Check if salon is closed (exceptional hours) var isClosed bool - err = db.DB.QueryRow(r.Context(), ` + if err := db.DB.QueryRow(r.Context(), ` SELECT EXISTS ( SELECT 1 FROM exceptional_working_hours ewh JOIN exceptional_group_applications ega ON ewh.group_id = ega.group_id @@ -1519,43 +1387,25 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) { AND ewh.start_time <= $3 AND ewh.end_time >= $3 ) - `, weekStart, weekday, bookingTime).Scan(&isClosed) - if err != nil { + `, weekStart, weekday, bookingTime).Scan(&isClosed); err != nil { log.Printf("Failed to check exceptional hours: %v", err) } - if isClosed { http.Error(w, "Cannot book on a closed day", http.StatusBadRequest) return } - // Update booking start time (only for user's own bookings) - query := ` + var booking Booking + booking.User = &UserSummary{} + if err := db.DB.QueryRow(r.Context(), ` 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 { + `, req.StartTime, bookingID, userID).Scan( + &booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status, + &booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy, + ); err != nil { if errors.Is(err, sql.ErrNoRows) { http.Error(w, "Booking not found or access denied", http.StatusNotFound) return @@ -1565,13 +1415,11 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) { 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 } } @@ -1583,7 +1431,6 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) { 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) @@ -1600,32 +1447,17 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) { return } - // Update booking status - query := ` + var booking Booking + booking.User = &UserSummary{} + if err := db.DB.QueryRow(r.Context(), ` 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 { + `, req.Status, bookingID).Scan( + &booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status, + &booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy, + ); err != nil { if errors.Is(err, sql.ErrNoRows) { http.Error(w, "Booking not found", http.StatusNotFound) return @@ -1636,16 +1468,12 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) { } if req.Status == "completed" { - // When a booking is completed, extend patch test validity for any related patch tests - // Get all services in this booking rows, err := db.DB.Query(r.Context(), ` SELECT DISTINCT pt.id FROM patch_tests pt JOIN booking_services bs ON bs.booking_id = $1 WHERE pt.id IN ( - SELECT pt_inner.id - FROM patch_tests pt_inner - WHERE bs.service_id = ANY(pt_inner.service_ids) + SELECT pt_inner.id FROM patch_tests pt_inner WHERE bs.service_id = ANY(pt_inner.service_ids) ) `, bookingID) if err != nil { @@ -1658,37 +1486,31 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) { log.Printf("Failed to scan patch test: %v", err) continue } - // Update or insert user_patch_tests record - _, err := db.DB.Exec(r.Context(), ` + if _, err := db.DB.Exec(r.Context(), ` INSERT INTO user_patch_tests (user_id, patch_test_id, tested_at) VALUES ($1, $2, NOW()) ON CONFLICT (user_id, patch_test_id) DO UPDATE SET tested_at = NOW() - `, booking.User.ID, patchTestID) - if err != nil { + `, booking.User.ID, patchTestID); err != nil { log.Printf("Failed to update patch test validity for user %s, patch test %s: %v", booking.User.ID, patchTestID, err) } } } - // Add loyalty stamp when booking completed - max 1 per day per user - _, err = db.DB.Exec(r.Context(), - `UPDATE users - SET loyalty_stamps = loyalty_stamps + 1 - WHERE id = $1 - AND NOT EXISTS ( - SELECT 1 FROM bookings b - WHERE b.user_id = users.id - AND b.status = 'completed' - AND b.updated_at >= CURRENT_DATE - INTERVAL '1 day' - AND b.id != $2 - )`, - booking.User.ID, bookingID, - ) - if err != nil { + if _, err := db.DB.Exec(r.Context(), ` + UPDATE users + SET loyalty_stamps = loyalty_stamps + 1 + WHERE id = $1 + AND NOT EXISTS ( + SELECT 1 FROM bookings b + WHERE b.user_id = users.id + AND b.status = 'completed' + AND b.updated_at >= CURRENT_DATE - INTERVAL '1 day' + AND b.id != $2 + ) + `, booking.User.ID, bookingID); err != nil { log.Printf("Failed to add loyalty stamp for booking %s: %v", bookingID, err) } - // Reduce deposits_required if payment was made for this booking var paymentCount int db.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&paymentCount) if paymentCount > 0 { @@ -1696,14 +1518,11 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) { } } - // Return updated booking - w.Header().Set("Content-Type", "application/json") 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 } } @@ -1722,7 +1541,6 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) { 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) @@ -1734,7 +1552,6 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) { } } - // Check for overlapping confirmed/in_progress/completed bookings before confirming var bkStart time.Time if err := db.DB.QueryRow(r.Context(), "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&bkStart); err != nil { log.Printf("Failed to get start time: %v", err) @@ -1751,7 +1568,12 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) { var cnt int db.DB.QueryRow(r.Context(), ` - SELECT COUNT(*) FROM bookings WHERE id != $1 AND status IN ('confirmed','in_progress','completed') AND start_time < $3 AND start_time + (INTERVAL '1 minute' * (SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes,s.duration_minutes)),60) FROM booking_services bs JOIN services s ON bs.service_id=s.id WHERE bs.booking_id=bookings.id)) > $2 + SELECT COUNT(*) FROM bookings WHERE id != $1 AND status IN ('confirmed','in_progress','completed') + AND start_time < $3 + AND start_time + (INTERVAL '1 minute' * ( + SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes,s.duration_minutes)),60) + FROM booking_services bs JOIN services s ON bs.service_id=s.id WHERE bs.booking_id=bookings.id + )) > $2 `, bookingID, bkStart, newEnd).Scan(&cnt) if cnt > 0 { http.Error(w, "Cannot confirm - time slot overlaps with existing booking", http.StatusConflict) @@ -1766,32 +1588,17 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) { } defer tx.Rollback(r.Context()) - // Update booking status and notes - bookingQuery := ` + var booking Booking + booking.User = &UserSummary{} + if err := tx.QueryRow(r.Context(), ` 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 { + `, req.Notes, bookingID).Scan( + &booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status, + &booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy, + ); err != nil { if errors.Is(err, sql.ErrNoRows) { http.Error(w, "Booking not found or already confirmed", http.StatusNotFound) return @@ -1806,21 +1613,15 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) { 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 + for i, o := range req.ServiceOverrides { + serviceIDs[i] = o.ServiceID } - var count int - err = tx.QueryRow(r.Context(), serviceCheckQuery, bookingID, serviceIDs).Scan(&count) - if err != nil { + if err := tx.QueryRow(r.Context(), ` + SELECT COUNT(*) FROM booking_services WHERE booking_id = $1 AND service_id = ANY($2) + `, bookingID, serviceIDs).Scan(&count); err != nil { log.Printf("Failed to verify services for booking %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return @@ -1829,26 +1630,13 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) { 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) + if _, err := tx.Exec(r.Context(), ` + UPDATE booking_services + SET override_price = $1, override_duration_minutes = $2 + WHERE booking_id = $3 AND service_id = $4 + `, override.OverridePrice, override.OverrideDurationMinutes, bookingID, override.ServiceID); 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 } @@ -1861,10 +1649,12 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) { return } - // Sync to CalDAV when booking confirmed - DB is source of truth if dav.Service != nil { var durationMinutes int - db.DB.QueryRow(r.Context(), `SELECT COALESCE(SUM(override_duration_minutes), (SELECT SUM(duration_minutes) FROM booking_services WHERE booking_id = $1)) FROM booking_services WHERE booking_id = $1`, bookingID).Scan(&durationMinutes) + db.DB.QueryRow(r.Context(), ` + SELECT COALESCE(SUM(override_duration_minutes), (SELECT SUM(duration_minutes) FROM booking_services WHERE booking_id = $1)) + FROM booking_services WHERE booking_id = $1 + `, bookingID).Scan(&durationMinutes) if durationMinutes == 0 { durationMinutes = 60 } @@ -1875,13 +1665,11 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) { }) } - // 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 } } @@ -1901,27 +1689,17 @@ func CancelBookingHandler(w http.ResponseWriter, r *http.Request) { } defer tx.Rollback(r.Context()) - updateQuery := ` + var booking Booking + booking.User = &UserSummary{} + if err := tx.QueryRow(r.Context(), ` UPDATE bookings SET status = 'we_cancelled', updated_at = NOW() WHERE id = $1 AND status NOT IN ('completed', 'cancelled', 'client_cancelled', 'we_cancelled') 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(), updateQuery, bookingID).Scan( - &booking.ID, - &booking.User.ID, - &booking.StartTime, - &booking.Status, - &booking.Notes, - &booking.CreatedAt, - &booking.UpdatedAt, - &booking.CreatedBy, - ) - - if err != nil { + `, bookingID).Scan( + &booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status, + &booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy, + ); err != nil { if errors.Is(err, sql.ErrNoRows) { http.Error(w, "Booking not found or cannot be cancelled", http.StatusNotFound) return @@ -1931,11 +1709,9 @@ func CancelBookingHandler(w http.ResponseWriter, r *http.Request) { return } - notificationQuery := ` - INSERT INTO admin_notifications (reason, booking_id, user_id) - VALUES ('cancelled_booking', $1, $2) - ` - tx.Exec(r.Context(), notificationQuery, bookingID, booking.User.ID) + tx.Exec(r.Context(), ` + INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ('cancelled_booking', $1, $2) + `, bookingID, booking.User.ID) if err := tx.Commit(r.Context()); err != nil { log.Printf("Failed to commit booking cancellation: %v", err) @@ -1956,25 +1732,20 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) { 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 { + if err := db.DB.QueryRow(r.Context(), "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&paymentCount); 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) @@ -1989,7 +1760,6 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) { 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) @@ -1998,10 +1768,8 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) { } defer tx.Rollback(r.Context()) - // Get current status before updating var originalStatus string - err = tx.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(&originalStatus) - if err != nil { + if err := tx.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(&originalStatus); err != nil { if errors.Is(err, sql.ErrNoRows) { http.Error(w, "Booking not found or access denied", http.StatusNotFound) return @@ -2011,57 +1779,41 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) { return } - // 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) + result, err := tx.Exec(r.Context(), ` + UPDATE bookings SET status = $1, updated_at = NOW() WHERE id = $2 AND user_id = $3 + `, 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 } - // Acknowledge pending notification if exists if err := notifications.AcknowledgePendingBookingNotification(tx, r.Context(), bookingID); err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return } - // Only notify on cancellation if booking was confirmed (not pending) if originalStatus == "confirmed" { - // Check notice period var startTime time.Time tx.QueryRow(r.Context(), "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&startTime) - noticeHours := startTime.Sub(time.Now()).Hours() if noticeHours < 12 { - // Less than 12h notice = count as no-show, add 3 deposits required tx.Exec(r.Context(), "UPDATE users SET deposits_required = deposits_required + 3 WHERE id = $1", userID) tx.Exec(r.Context(), "UPDATE bookings SET status = 'no_show' WHERE id = $1", bookingID) } else if noticeHours < 24 { - // Less than 24h notice - create admin notification about potential deposit requirement - notificationQuery := ` - INSERT INTO admin_notifications (reason, booking_id, user_id) - VALUES ($1, $2, $3) - ` - tx.Exec(r.Context(), notificationQuery, "late_cancellation", bookingID, userID) + tx.Exec(r.Context(), ` + INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ($1, $2, $3) + `, "late_cancellation", bookingID, userID) } - 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 { + if _, err := tx.Exec(r.Context(), ` + INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ($1, $2, $3) + `, "cancelled_booking", bookingID, userID); err != nil { log.Printf("Failed to create admin notification for booking %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return @@ -2083,7 +1835,7 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) { return } - // Hard delete if no payments exist - use transaction for notification + // Hard delete — no payments exist tx, err := db.DB.Begin(r.Context()) if err != nil { log.Printf("Failed to start transaction: %v", err) @@ -2092,10 +1844,8 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) { } defer tx.Rollback(r.Context()) - // Get current status before deleting var originalStatus string - err = tx.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(&originalStatus) - if err != nil { + if err := tx.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(&originalStatus); err != nil { if errors.Is(err, sql.ErrNoRows) { http.Error(w, "Booking not found or access denied", http.StatusNotFound) return @@ -2105,35 +1855,27 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) { return } - // Acknowledge pending notification if exists if err := notifications.AcknowledgePendingBookingNotification(tx, r.Context(), bookingID); err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return } - // Only notify on cancellation if booking was not pending (e.g. confirmed, in_progress) if originalStatus != "pending" { - 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 { + if _, err := tx.Exec(r.Context(), ` + INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ($1, $2, $3) + `, "cancelled_booking", bookingID, userID); 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) + result, err := tx.Exec(r.Context(), "DELETE FROM bookings WHERE id = $1 AND user_id = $2", 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 @@ -2166,25 +1908,24 @@ func GetBookingHandler(w http.ResponseWriter, r *http.Request) { return } - // ---------------------------- - // 1. Fetch booking - // ---------------------------- var booking Booking - // Initialize slices/maps to avoid null in JSON booking.Payments = []Payment{} booking.Services = []BookingService{} 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 + var depositRequired bool + + if 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, + b.deposit_required + FROM bookings b + WHERE b.id = $1 AND b.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 { + &depositRequired, + ); err != nil { if errors.Is(err, sql.ErrNoRows) { http.Error(w, "Booking not found or access denied", http.StatusNotFound) return @@ -2197,12 +1938,6 @@ func GetBookingHandler(w http.ResponseWriter, r *http.Request) { 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, @@ -2219,6 +1954,8 @@ func GetBookingHandler(w http.ResponseWriter, r *http.Request) { } defer serviceRows.Close() + var totalAmount float64 + var durationMinutes int for serviceRows.Next() { var s BookingService var overridePrice sql.NullFloat64 @@ -2227,28 +1964,24 @@ func GetBookingHandler(w http.ResponseWriter, r *http.Request) { var basePrice sql.NullFloat64 var baseDuration sql.NullInt32 - err := serviceRows.Scan( + if err := serviceRows.Scan( &s.ServiceID, &overridePrice, &overrideDuration, &name, &description, &basePrice, &baseDuration, - ) - if err != nil { + ); err != nil { log.Printf("Failed to scan service row for booking %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } - // Calculate Totals based on overrides or base values var priceToAdd float64 var durationToAdd int - if overridePrice.Valid { s.OverridePrice = &overridePrice.Float64 priceToAdd = overridePrice.Float64 } else if basePrice.Valid { priceToAdd = basePrice.Float64 } - if overrideDuration.Valid { d := int(overrideDuration.Int32) s.OverrideDurationMinutes = &d @@ -2260,7 +1993,6 @@ func GetBookingHandler(w http.ResponseWriter, r *http.Request) { totalAmount += priceToAdd durationMinutes += durationToAdd - // Map nullable strings to pointers if name.Valid { s.ServiceName = &name.String } @@ -2274,14 +2006,9 @@ func GetBookingHandler(w http.ResponseWriter, r *http.Request) { 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, @@ -2298,24 +2025,23 @@ func GetBookingHandler(w http.ResponseWriter, r *http.Request) { } defer paymentRows.Close() + var amountPaid, preStartAmountPaid float64 for paymentRows.Next() { var p Payment var vendorCode sql.NullString var invoiceNumber sql.NullInt32 var vatRate, vatAmount, netAmount sql.NullFloat64 - var createdBy sql.NullString + var pCreatedBy sql.NullString - err := paymentRows.Scan( + if 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 { + &p.CreatedAt, &p.UpdatedAt, &pCreatedBy, + ); 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 } @@ -2332,32 +2058,27 @@ func GetBookingHandler(w http.ResponseWriter, r *http.Request) { if netAmount.Valid { p.NetAmount = &netAmount.Float64 } - if createdBy.Valid { - p.CreatedBy = &createdBy.String + if pCreatedBy.Valid { + p.CreatedBy = &pCreatedBy.String } if p.Status == "completed" { amountPaid += p.Amount + if p.CreatedAt.Before(booking.StartTime) { + preStartAmountPaid += p.Amount + } } - booking.Payments = append(booking.Payments, p) } - // ---------------------------- - // 4. Assign calculated totals to Booking Struct - // ---------------------------- booking.TotalAmount = totalAmount booking.AmountPaid = amountPaid booking.AmountDue = totalAmount - amountPaid booking.DurationMinutes = durationMinutes - // ---------------------------- - // 5. Return Response - // ---------------------------- - w.Header().Set("Content-Type", "application/json") + populateDepositFields(&booking, depositRequired, preStartAmountPaid) - // We encode the 'booking' object directly. - // This matches the frontend expectation: selectedBooking = data; + w.Header().Set("Content-Type", "application/json") 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) @@ -2372,27 +2093,24 @@ func GetBookingCalendarHandler(w http.ResponseWriter, r *http.Request) { return } - // Check auth first (security by design - don't reveal if booking exists to unauthenticated users) userID, ok := r.Context().Value(mw.UserIDKey).(string) if !ok || userID == "" { http.Error(w, "Booking not found", http.StatusNotFound) return } - // Now check if booking exists and belongs to user var bookingIDDB, userIDDB, status, notes, createdBy string var startTime, createdAt, updatedAt time.Time var durationMinutes int - err := db.DB.QueryRow(r.Context(), ` + if err := db.DB.QueryRow(r.Context(), ` SELECT id, user_id, start_time, status, COALESCE(notes, ''), COALESCE(created_by, ''), created_at, updated_at, COALESCE((SELECT SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)) FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = bookings.id), 60) FROM bookings WHERE id = $1 AND user_id = $2 - `, bookingID, userID).Scan(&bookingIDDB, &userIDDB, &startTime, &status, ¬es, &createdBy, &createdAt, &updatedAt, &durationMinutes) - if err != nil { + `, bookingID, userID).Scan(&bookingIDDB, &userIDDB, &startTime, &status, ¬es, &createdBy, &createdAt, &updatedAt, &durationMinutes); err != nil { if errors.Is(err, sql.ErrNoRows) { http.Error(w, "Booking not found", http.StatusNotFound) return @@ -2425,7 +2143,6 @@ func GetBookingCalendarHandler(w http.ResponseWriter, r *http.Request) { serviceList := strings.Join(services, ", ") endTime := startTime.Add(time.Duration(durationMinutes) * time.Minute) - icalContent := generateICS(serviceList, startTime, endTime, status, notes, totalPrice) w.Header().Set("Content-Type", "text/calendar; charset=utf-8") @@ -2437,7 +2154,6 @@ func GetBookingCalendarHandler(w http.ResponseWriter, r *http.Request) { func generateICS(serviceList string, start, end time.Time, status, notes string, price float64) string { uid := fmt.Sprintf("booking-%d@crussell.com", time.Now().UnixNano()) dtstamp := time.Now().UTC().Format("20060102T150405Z") - dtstart := start.Format("20060102T150405") dtend := end.Format("20060102T150405") diff --git a/backend/handlers/bookings/bookings_test.go b/backend/handlers/bookings/bookings_test.go index d032c61..a22267b 100644 --- a/backend/handlers/bookings/bookings_test.go +++ b/backend/handlers/bookings/bookings_test.go @@ -2629,10 +2629,220 @@ func TestBookings_Create_NoDepositRequired_Within48Hours(t *testing.T) { t.Errorf("expected status 201 for booking within 48h with no deposit required, got %d. body: %s", w.Code, w.Body.String()) } } +// ============================================================================= +// Deposit Snapshot and Field Tests +// ============================================================================= + +// TestBookings_Create_DepositSnapshot verifies that deposit_required is snapshotted +// at booking creation time from user's current deposits_required value. +func TestBookings_Create_DepositSnapshot(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + seedDefaultWorkingHours(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + // Set deposits_required=3 BEFORE creating booking + _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) + if err != nil { + t.Fatalf("failed to set deposits_required: %v", err) + } + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, serviceID) + + token := jwt.GenerateUserToken(userID) + + // Create booking after deposits_required is set + after48h := time.Now().Add(72 * time.Hour).Truncate(time.Second) + after48h = time.Date(after48h.Year(), after48h.Month(), after48h.Day(), 10, 0, 0, 0, after48h.Location()) + req := CreateBookingRequest{ + StartTime: after48h, + ServiceIDs: []string{serviceID}, + } + + handler := http.HandlerFunc(CreateBookingHandler) + w := makeRequest(handler, "POST", "/api/bookings", req, token) + + if w.Code != http.StatusCreated { + t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) + } + + // Verify deposit_required was snapshotted on the booking + var depositRequired bool + err = db.DB.QueryRow(context.Background(), + "SELECT deposit_required FROM bookings WHERE user_id = $1", userID).Scan(&depositRequired) + if err != nil { + t.Fatalf("failed to query booking: %v", err) + } + if !depositRequired { + t.Error("expected deposit_required=true to be snapshotted on booking") + } + + // Now change user's deposits_required to 0 + _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + if err != nil { + t.Fatalf("failed to update deposits_required: %v", err) + } + + // Verify the booking's deposit_required is still true (snapshot is not updated) + err = db.DB.QueryRow(context.Background(), + "SELECT deposit_required FROM bookings WHERE user_id = $1", userID).Scan(&depositRequired) + if err != nil { + t.Fatalf("failed to query booking: %v", err) + } + if !depositRequired { + t.Error("expected deposit_required to remain true after user's deposits_required changed") + } +} + +// TestBookings_Create_DepositRequired_OneActiveBookingLimit verifies that a user +// with deposits_required > 0 can only have ONE active booking at a time. +func TestBookings_Create_DepositRequired_OneActiveBookingLimit(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + seedDefaultWorkingHours(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + // Set deposits_required=3 (triggers one-active-booking limit) + _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) + if err != nil { + t.Fatalf("failed to set deposits_required: %v", err) + } + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, serviceID) + + token := jwt.GenerateUserToken(userID) + + // Create first booking (should succeed) + after48h := time.Now().Add(72 * time.Hour).Truncate(time.Second) + after48h = time.Date(after48h.Year(), after48h.Month(), after48h.Day(), 10, 0, 0, 0, after48h.Location()) + req1 := CreateBookingRequest{ + StartTime: after48h, + ServiceIDs: []string{serviceID}, + } + + handler := http.HandlerFunc(CreateBookingHandler) + w := makeRequest(handler, "POST", "/api/bookings", req1, token) + + if w.Code != http.StatusCreated { + t.Fatalf("expected first booking to succeed, got %d. body: %s", w.Code, w.Body.String()) + } + + // Try to create second booking (should fail - one active booking limit) + after72h := time.Now().Add(96 * time.Hour).Truncate(time.Second) + after72h = time.Date(after72h.Year(), after72h.Month(), after72h.Day(), 10, 0, 0, 0, after72h.Location()) + req2 := CreateBookingRequest{ + StartTime: after72h, + ServiceIDs: []string{serviceID}, + } + + w = makeRequest(handler, "POST", "/api/bookings", req2, token) + + if w.Code != http.StatusConflict { + t.Errorf("expected status 409 for second booking attempt, got %d. body: %s", w.Code, w.Body.String()) + } + + if !bytes.Contains(w.Body.Bytes(), []byte("active booking")) { + t.Errorf("expected error about active booking, got: %s", w.Body.String()) + } +} + +// TestBookings_Get_DepositFieldsReturned verifies that GET /api/bookings returns +// the deposit-related fields (deposit_required, deposit_amount, deposit_paid, deposit_deadline). +func TestBookings_Get_DepositFieldsReturned(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + seedDefaultWorkingHours(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + // Set deposits_required=3 and create booking + _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) + if err != nil { + t.Fatalf("failed to set deposits_required: %v", err) + } + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, serviceID) + + token := jwt.GenerateUserToken(userID) + + // Create booking with deposit requirement + after48h := time.Now().Add(72 * time.Hour).Truncate(time.Second) + after48h = time.Date(after48h.Year(), after48h.Month(), after48h.Day(), 10, 0, 0, 0, after48h.Location()) + req := CreateBookingRequest{ + StartTime: after48h, + ServiceIDs: []string{serviceID}, + } + + handler := http.HandlerFunc(CreateBookingHandler) + w := makeRequest(handler, "POST", "/api/bookings", req, token) + + if w.Code != http.StatusCreated { + t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) + } + + // GET the booking and verify deposit fields + w = makeRequest(http.HandlerFunc(GetAllUserBookingsHandler), "GET", "/api/bookings", nil, token) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var resp BookingListResponse + if err := parseResponseBody(w, &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + if len(resp.Bookings) != 1 { + t.Fatalf("expected 1 booking, got %d", len(resp.Bookings)) + } + + booking := resp.Bookings[0] + + // Verify deposit fields exist + // Verify deposit fields exist + if !booking.DepositRequired { + t.Error("expected DepositRequired to be true") + } + if booking.DepositAmount <= 0 { + t.Error("expected DepositAmount to be positive") + } + // DepositPaid is a bool, check it's set (should be false for new booking) + // Just verify the field exists by accessing it + _ = booking.DepositPaid + if booking.DepositDeadline == nil { + t.Error("expected DepositDeadline to be set") + } +} -// ============================================================================= -// Holiday/Closed Day Booking Tests -// ============================================================================= // TestBookings_Edit_ClosedDay_UserBlocked verifies that a regular user cannot edit a booking // to fall on a closed day (exceptional hours marked as is_open=false). diff --git a/init-scripts/init-script.sql b/init-scripts/init-script.sql index d18fc9c..38c0517 100644 --- a/init-scripts/init-script.sql +++ b/init-scripts/init-script.sql @@ -208,6 +208,7 @@ CREATE TABLE bookings ( start_time TIMESTAMPTZ NOT NULL, status booking_status NOT NULL DEFAULT 'pending', notes TEXT, + deposit_required BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_by CHAR(12) diff --git a/local-dev-2.sh b/local-dev-2.sh index 1704a76..068f8c2 100755 --- a/local-dev-2.sh +++ b/local-dev-2.sh @@ -264,8 +264,10 @@ if api_post "$BASE_URL/register" "{\"firstName\":\"Oscar\",\"lastName\":\"Hughes # Promote Admin docker exec postgres psql -U myuser -d mydb -c "UPDATE users SET account_role = 'admin' WHERE email = '$ADMIN_EMAIL'" > /dev/null 2>&1 -# Set deposits_required=0 for all users (so they can confirm bookings without deposit issues) -docker exec postgres psql -U myuser -d mydb -c "UPDATE users SET deposits_required = 0" > /dev/null 2>&1 +# Set deposits_required=0 for main test user (so bookings can be created without 48h restriction) +# Set deposits_required=3 for some users to demonstrate deposit snapshot behavior +docker exec postgres psql -U myuser -d mydb -c "UPDATE users SET deposits_required = 0 WHERE email = '$USER_EMAIL'" > /dev/null 2>&1 +docker exec postgres psql -U myuser -d mydb -c "UPDATE users SET deposits_required = 3 WHERE email IN ('poppy.thompson@example.com', 'mia.white@example.com')" > /dev/null 2>&1 echo "${C_GREEN}✅ Registered $success/$total Users${C_RESET}" # 2. Login @@ -454,6 +456,25 @@ echo "${C_GREEN}✅ Created $count_future/30 Bookings (Future)${C_RESET}" echo "${C_GREEN}✅ Created $count_past/8 Bookings (Past)${C_RESET}" echo "${C_GREEN}✅ Created $TOTAL/45 Bookings (Total)${C_RESET}" +# 4b. Create Booking for Deposit-Required User +echo -e "\n${C_BLUE}💰 Creating Booking for Deposit-Required User...${C_RESET}" + +# Login as Poppy (deposits_required=3) +POPPY_LOGIN_RESP=$(curl -s -X POST -H 'Content-Type: application/json' -d '{"email":"poppy.thompson@example.com","password":"password"}' "$BASE_URL/login") +POPPY_TOKEN=$(echo "$POPPY_LOGIN_RESP" | tr -d '\r\n\t ' | sed -n 's/.*"token":"\([^"]*\).*/\1/p') + +if [[ -n "$POPPY_TOKEN" ]]; then + # Book 3 days ahead (50h+ to satisfy 48h requirement for deposit-required users) + DEPOSIT_TIME=$(TZ=Europe/London date -d "3 days 10:00" +"%Y-%m-%dT%H:%M:%S%:z") + if create_booking "$POPPY_TOKEN" "$DEPOSIT_TIME" "[\"$(get_svc 0)\"]" "" "Poppy (deposit required)"; then + echo "${C_GREEN}✅ Created deposit-required booking (deposit_required=true snapshotted)${C_RESET}" + else + echo "${C_YELLOW}⚠️ Could not create deposit-required booking${C_RESET}" + fi +else + echo "${C_YELLOW}⚠️ Could not login as Poppy to create deposit-required booking${C_RESET}" +fi + # 5. Confirm Random Half of Upcoming Bookings echo -e "\n${C_BLUE}🔒 Confirming Random Upcoming Bookings...${C_RESET}" confirmed_count=0