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:
2026-03-07 17:39:32 +00:00
parent faa4d89152
commit c275265794
2 changed files with 48 additions and 61 deletions
+13 -6
View File
@@ -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(), `
+34 -54
View File
@@ -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)
}