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)
This commit is contained in:
@@ -139,6 +139,7 @@ type ServiceOverride struct {
|
|||||||
// DeleteBookingRequest represents the request payload for deleting a booking with payment
|
// DeleteBookingRequest represents the request payload for deleting a booking with payment
|
||||||
type DeleteBookingRequest struct {
|
type DeleteBookingRequest struct {
|
||||||
Reason string `json:"reason" validate:"required,oneof=client_cancelled we_cancelled re-schedule no_show"`
|
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
|
// 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)
|
tx.QueryRow(r.Context(), "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&startTime)
|
||||||
noticeHours := startTime.Sub(time.Now()).Hours()
|
noticeHours := startTime.Sub(time.Now()).Hours()
|
||||||
|
|
||||||
if noticeHours < 12 {
|
// < 24 hours notice: treat as no-show
|
||||||
tx.Exec(r.Context(), "UPDATE users SET deposits_required = deposits_required + 3 WHERE id = $1", userID)
|
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)
|
tx.Exec(r.Context(), "UPDATE bookings SET status = 'no_show' WHERE id = $1", bookingID)
|
||||||
} else if noticeHours < 24 {
|
} else {
|
||||||
tx.Exec(r.Context(), `
|
// Forgiveness granted: treat as client_cancelled, no penalty
|
||||||
INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ($1, $2, $3)
|
tx.Exec(r.Context(), "UPDATE bookings SET status = 'client_cancelled' WHERE id = $1", bookingID)
|
||||||
`, "late_cancellation", bookingID, userID)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := tx.Exec(r.Context(), `
|
if _, err := tx.Exec(r.Context(), `
|
||||||
|
|||||||
@@ -411,6 +411,7 @@ type AdminCreateBookingForUserRequest struct {
|
|||||||
ServiceIDs []string `json:"service_ids" validate:"required,min=1"`
|
ServiceIDs []string `json:"service_ids" validate:"required,min=1"`
|
||||||
ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"`
|
ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"`
|
||||||
Notes *string `json:"notes,omitempty"` // appointment notes, visible to customers and staff
|
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) {
|
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
|
// Validate overrides
|
||||||
if req.UserID == "" {
|
if req.UserID == "" {
|
||||||
http.Error(w, "User ID is required", http.StatusBadRequest)
|
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
|
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)
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user