From c2752657941166a1c3236b0264fabc660c7f60e6 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Sat, 7 Mar 2026 17:39:32 +0000 Subject: [PATCH] refactor: update deposit system with 24h no-show rule, optional forgiveness, and admin enforcement toggle - Change no-show threshold from 12h to 24h for late cancellations - Add optional forgive_no_show boolean to cancellation endpoint - Add optional enforce_deposits boolean to admin booking creation - Set deposits_required = 3 on no-show (not +=3) to prevent escalation - Implement per-cancellation forgiveness instead of bulk forgiveness - Remove ForgiveNoShowsForUser() function (now per-event) - Admin can now bypass deposit checks when needed - All changes backward compatible (nil defaults to enforce) --- backend/handlers/bookings/bookings.go | 21 ++++--- backend/handlers/bookings/manage.go | 88 +++++++++++---------------- 2 files changed, 48 insertions(+), 61 deletions(-) diff --git a/backend/handlers/bookings/bookings.go b/backend/handlers/bookings/bookings.go index 0578a1f..75f3a37 100644 --- a/backend/handlers/bookings/bookings.go +++ b/backend/handlers/bookings/bookings.go @@ -139,6 +139,7 @@ type ServiceOverride struct { // DeleteBookingRequest represents the request payload for deleting a booking with payment type DeleteBookingRequest struct { Reason string `json:"reason" validate:"required,oneof=client_cancelled we_cancelled re-schedule no_show"` + ForgiveNoShow *bool `json:"forgive_no_show,omitempty"` // Admin-only: forgive a no-show at cancellation time } // AdminUserSummary represents a small user summary for admin views @@ -1842,13 +1843,19 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) { tx.QueryRow(r.Context(), "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&startTime) noticeHours := startTime.Sub(time.Now()).Hours() - if noticeHours < 12 { - 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 { - tx.Exec(r.Context(), ` - INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ($1, $2, $3) - `, "late_cancellation", bookingID, userID) + // < 24 hours notice: treat as no-show + if noticeHours < 24 { + // Check if admin is forgiving this no-show + isForgiving := req.ForgiveNoShow != nil && *req.ForgiveNoShow + + if !isForgiving { + // No forgiveness: apply penalty + tx.Exec(r.Context(), "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) + tx.Exec(r.Context(), "UPDATE bookings SET status = 'no_show' WHERE id = $1", bookingID) + } else { + // Forgiveness granted: treat as client_cancelled, no penalty + tx.Exec(r.Context(), "UPDATE bookings SET status = 'client_cancelled' WHERE id = $1", bookingID) + } } if _, err := tx.Exec(r.Context(), ` diff --git a/backend/handlers/bookings/manage.go b/backend/handlers/bookings/manage.go index 75a4196..546d325 100644 --- a/backend/handlers/bookings/manage.go +++ b/backend/handlers/bookings/manage.go @@ -411,6 +411,7 @@ type AdminCreateBookingForUserRequest struct { ServiceIDs []string `json:"service_ids" validate:"required,min=1"` ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"` Notes *string `json:"notes,omitempty"` // appointment notes, visible to customers and staff + EnforceDeposits *bool `json:"enforce_deposits,omitempty"` // Optional: if true, enforce outstanding deposit checks; if false or omitted, bypass checks } func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) { @@ -489,6 +490,39 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) { } } + // Enforce deposit checks if requested (default: true if not specified) + enforceDeposits := true + if req.EnforceDeposits != nil { + enforceDeposits = *req.EnforceDeposits + } + + if enforceDeposits { + // Read live deposits_required from user + var depositsRequired int + if err := db.DB.QueryRow(r.Context(), `SELECT deposits_required FROM users WHERE id = $1`, req.UserID).Scan(&depositsRequired); err != nil { + log.Printf("Failed to fetch deposits_required for user %s: %v", req.UserID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + // Check 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') + `, req.UserID).Scan(&activeCount); err != nil { + log.Printf("Failed to check active bookings for user %s: %v", req.UserID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + if activeCount > 0 { + http.Error(w, "User already has an active booking. Cannot create another until deposit requirements are cleared.", http.StatusConflict) + return + } + } + } + // Validate overrides if req.UserID == "" { http.Error(w, "User ID is required", http.StatusBadRequest) @@ -1387,58 +1421,4 @@ func ApplyDepositsIfNeeded(ctx context.Context, userID string) (bool, error) { return false, nil } -// ForgiveNoShowsForUser clears all unforgiven no-shows for a user -// by inserting them into forgiven_no_shows table and resetting deposits to 0 -func ForgiveNoShowsForUser(ctx context.Context, userID string) error { - tx, err := db.DB.Begin(ctx) - if err != nil { - return err - } - defer tx.Rollback(ctx) - // Get all unforgiven no-shows - rows, err := tx.Query(ctx, ` - SELECT id FROM bookings - WHERE user_id = $1 - AND status = 'no_show' - AND start_time >= NOW() - INTERVAL '6 months' - AND NOT EXISTS ( - SELECT 1 FROM forgiven_no_shows WHERE booking_id = bookings.id - ) - `, userID) - if err != nil { - return err - } - defer rows.Close() - - var bookingIDs []string - for rows.Next() { - var id string - if err := rows.Scan(&id); err != nil { - return err - } - bookingIDs = append(bookingIDs, id) - } - - // Insert into forgiven_no_shows for each - for _, bookingID := range bookingIDs { - _, err := tx.Exec(ctx, ` - INSERT INTO forgiven_no_shows (booking_id) - VALUES ($1) - ON CONFLICT (booking_id) DO NOTHING - `, bookingID) - if err != nil { - return err - } - } - - // Reset deposits to 0 - _, err = tx.Exec(ctx, ` - UPDATE users SET deposits_required = 0 WHERE id = $1 - `, userID) - if err != nil { - return err - } - - return tx.Commit(ctx) -}