Files
Crussell/backend/main.go
T
popertots 4b28e93710 fix: tip double-count, fully-paid auto-completion, discount-refund hardening
Tip double-count (root cause of £33.75 vs £28.75 display):
- Remove mock's fixed +500p auto-tip when AllowTipping is true (square_dev.go) —
  real Square only enables a terminal prompt, it never adds a tip to the amount
- Set AllowTipping=false in CreateTerminalPayment: the frontend already embeds
  the tip in the amount, so the terminal must not prompt for a second tip
- M4 tip split now derives the tip as charged amount minus remaining booking
  value ('after 100% is tips'), not from Square's TipAmount field
- Success screens divide paymentResult.amount by 100 (pence -> pounds) in both
  PaymentModal and UserPaymentModal

Fully-paid bookings auto-complete:
- Extract ApplyBookingCompletionSideEffects into payments package (shared by
  admin progress endpoint and payment paths; avoids circular import)
- Add bookingIsFullyPaid + completeFullyPaidBooking: when completed non-tip
  payments reach 100% of the booking total, an active booking transitions to
  'completed' so it leaves the admin Current Appointment view
- Wired into CreateBookingPayment (inside tx) and GetCheckoutStatus (terminal,
  after commit); completion side-effects (loyalty, campaigns, deposits_required)
  fire identically to the manual progress endpoint
- Add /admin/bookings/{id}/refund route (AdminRefundBooking)

Discount-refund hardening:
- RefundPayment explicitly rejects discount/on_the_house payments (was relying
  on the incidental NULL-square_payment_id guard)
- Hide the Refund button for discount/on_the_house payments in EditBookingModal
- Cancel-refund estimate in BookingModal also excludes on_the_house
- Cancellation refund loop + GetBookingPaymentInfo + GetBookingRefundableAmountCents
  exclude payment_type='tip' from refundable totals

Tip flow (start-time guard) fixes tests:
- Tip tests updated to use past-dated bookings (tips now require booking started)

Tests:
- m4_tip_refund_redesign_test.go (tip split, refund exclusion, admin refund cap)
- m5_fully_paid_completion_test.go (online + terminal full-payment completion,
  partial stays active, tip excluded, cancelled stays cancelled)
- Full suite passes with -race (25 packages)
2026-08-22 00:34:49 +01:00

595 lines
21 KiB
Go

package main
import (
"context"
"crussell/auth"
"crussell/internal/dav"
"crussell/internal/jobs"
"crussell/internal/logutil"
"crussell/internal/s3"
"crussell/internal/square"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strings"
"sync/atomic"
"syscall"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"crussell/db"
"crussell/mw"
"crussell/handlers/admin"
authHandlers "crussell/handlers/auth"
"crussell/handlers/bookings"
"crussell/handlers/notifications"
"crussell/handlers/payments"
"crussell/handlers/portfolio"
"crussell/handlers/scheduling"
"crussell/handlers/services"
"crussell/handlers/today"
"crussell/handlers/user"
"crussell/handlers/webhooks"
)
func init() {
// The testing framework passes -test.* to the binary; skip JWT init
// because test packages handle it via testutils/jwt. This is more
// precise than checking GO_TESTING env var, which can leak from the
// test runner into the environment during seeding.
for _, arg := range os.Args {
if strings.HasPrefix(arg, "-test.") {
return
}
}
jwtSecret := os.Getenv("JWT_SECRET_KEY")
if jwtSecret == "" {
log.Fatal("FATAL: JWT_SECRET_KEY environment variable not set. Application cannot start.")
}
auth.InitJWT(jwtSecret)
}
func limitBody(limit int64) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Body != nil {
r.Body = http.MaxBytesReader(w, r.Body, limit)
}
next.ServeHTTP(w, r)
})
}
}
const (
defaultBodyLimit int64 = 1 * 1024 * 1024 // 1MB
uploadBodyLimit int64 = 20 * 1024 * 1024 // 20MB
portfolioBodyLimit int64 = 30 * 1024 * 1024 // 30MB (7 variants)
)
// nColor / bColor — Chi-style ANSI colors for request logging.
type nColor string
type bColor string
var (
reset = nColor(logutil.Reset)
nYellow = nColor(logutil.Yellow)
nCyan = nColor(logutil.Cyan)
bGreen = bColor(logutil.BoldGreen)
bYellow = bColor(logutil.BoldYellow)
bRed = bColor(logutil.BoldRed)
bBlue = bColor(logutil.BoldBlue)
bMagenta = bColor(logutil.BoldMagenta)
debugLvl = nColor(logutil.DebugLvl)
warnLvl = nColor(logutil.WarnLvl)
errorLvl = nColor(logutil.ErrorLvl)
)
var apiLog = log.New(os.Stdout, "", log.LstdFlags)
func initDB() {
if err := db.Connect(); err != nil {
log.Fatal("Failed to connect to DB:", err)
}
fmt.Println("Connected to DB successfully")
}
func initDav() {
if dav.Service == nil {
log.Fatal("Failed to initialize DAV service")
}
fmt.Println("DAV Service connected successfully")
}
func initS3() {
if err := s3.Connect(); err != nil {
log.Printf("WARNING: Failed to connect to S3: %v", err)
} else {
fmt.Println("S3 client initialized")
}
}
func initSquare() {
payments.SquareClient = square.NewClient()
if env := os.Getenv("SQUARE_ENVIRONMENT"); env == "sandbox" || env == "production" {
fmt.Printf("Square client initialized (%s, real API)\n", env)
} else {
fmt.Println("Square client initialized (dev mock)")
}
}
func healthCheckHandler(w http.ResponseWriter, r *http.Request) {
status := "ok"
services := map[string]string{
"backend": "ok",
"database": "ok",
"s3_storage": "ok",
"square_payments": "ok",
"frontend": "unknown",
}
if db.Conn != nil {
if err := db.Conn.Ping(r.Context()); err != nil {
services["database"] = "error"
status = "degraded"
}
} else {
services["database"] = "error"
status = "degraded"
}
if s3.Client == nil {
services["s3_storage"] = "not_configured"
}
if env := os.Getenv("SQUARE_ENVIRONMENT"); env == "" || env == "mock" {
services["square_payments"] = "mock"
}
if status == "degraded" {
w.WriteHeader(http.StatusServiceUnavailable)
} else {
w.WriteHeader(http.StatusOK)
}
if err := json.NewEncoder(w).Encode(map[string]any{
"status": status,
"services": services,
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
}
// corsAllowedOrigins returns the frontend origins permitted to call the API,
// read from the comma-separated FRONTEND_ORIGIN env var. Entries are trimmed
// and blanks dropped; an unset/empty var falls back to the local dev origin.
func corsAllowedOrigins() []string {
var allowed []string
for _, o := range strings.Split(os.Getenv("FRONTEND_ORIGIN"), ",") {
if o = strings.TrimSpace(o); o != "" {
allowed = append(allowed, o)
}
}
if len(allowed) == 0 {
allowed = []string{"http://localhost:5173"}
}
return allowed
}
// originAllowed reports whether origin is exactly in the allowlist.
func originAllowed(origin string, allowed []string) bool {
for _, o := range allowed {
if origin == o {
return true
}
}
return false
}
// corsMiddleware sets security headers plus a CORS allowlist so credentialed
// cross-origin requests (Authorization: Bearer) work only from configured
// frontend origins.
func corsMiddleware(next http.Handler) http.Handler {
allowedOrigins := corsAllowedOrigins()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "1; mode=block")
// TODO: Enable HSTS in production
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
// TODO: Enable Referrer-Policy in production
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
origin := r.Header.Get("Origin")
if origin != "" && originAllowed(origin, allowedOrigins) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
}
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, Idempotency-Key")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func main() {
initDB()
initDav()
initS3()
initSquare()
sched := jobs.New()
jobs.RegisterAll(sched)
sched.Start()
r := chi.NewRouter()
// --- Global Middleware ---
// Custom RequestID using shared random prefix (matches jobs scheduler)
var reqCounter atomic.Uint64
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
myid := reqCounter.Add(1)
requestID := fmt.Sprintf("%s/%s-%06d", jobs.Hostname(), jobs.RandomPrefix(), myid)
ctx := context.WithValue(r.Context(), middleware.RequestIDKey, requestID)
next.ServeHTTP(w, r.WithContext(ctx))
})
})
r.Use(middleware.ClientIPFromHeader("X-Real-IP"))
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
t1 := time.Now()
defer func() {
status := ww.Status()
bytes := ww.BytesWritten()
reqID := middleware.GetReqID(r.Context())
var level nColor
var statusColor bColor
switch {
case status >= 500:
level, statusColor = errorLvl, bRed
case status >= 400:
level, statusColor = warnLvl, bYellow
default:
level, statusColor = debugLvl, bGreen
}
clientIP := middleware.GetClientIP(r.Context())
if clientIP == "" {
clientIP = r.RemoteAddr
}
apiLog.Printf("%s %s%s%s \"%s%s %s%s %s%s\" from %s - %s %s%03d%s %s%dB%s in %s",
level,
nYellow, reqID, reset,
bMagenta, r.Method, nCyan, r.URL.String(), nCyan, r.Proto, reset,
clientIP,
statusColor, status, reset,
bBlue, bytes, reset,
logutil.ColoredDuration(time.Since(t1)),
)
}()
next.ServeHTTP(ww, r)
})
})
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(15 * time.Second))
// CORS + security headers: credentialed cross-origin requests
// (Authorization: Bearer) are only answered for origins in the configured
// FRONTEND_ORIGIN allowlist — never reflected blindly, so a leaked JWT
// cannot be used from a rogue site.
r.Use(corsMiddleware)
// All API routes grouped under /api for clarity
r.Route("/api", func(r chi.Router) {
r.Use(mw.JsonContentType)
// Public read-only (but check auth context if present for eligibility)
r.Group(func(r chi.Router) {
r.Use(mw.RateLimit(120, time.Minute))
r.Use(mw.OptionalAuth)
r.Get("/services", services.ServicesHandler)
r.Get("/services/popular", services.PopularServicesHandler)
r.Get("/services/eligible-for/{user_id}", services.ServicesEligibleForUserHandler)
})
// Registration: 10/min to prevent spam + progressive per-IP backoff
r.With(mw.ProgressiveRateLimit, mw.RateLimit(10, time.Minute), limitBody(defaultBodyLimit)).Post("/register", authHandlers.RegisterHandler)
// Login: Has its own internal rate limiting + progressive per-IP backoff
r.With(mw.ProgressiveRateLimit, mw.RateLimit(10, time.Minute), limitBody(defaultBodyLimit)).Post("/login", authHandlers.LoginHandler)
// Logout: requires valid token
r.With(mw.RequireAuth).Post("/logout", authHandlers.LogoutHandler)
// Email verification
r.With(mw.RateLimit(10, time.Minute), limitBody(defaultBodyLimit)).Post("/verify/generate", authHandlers.GenerateVerificationCodeHandler)
r.With(mw.RateLimit(20, time.Minute), limitBody(defaultBodyLimit)).Post("/verify/check", authHandlers.VerifyCodeHandler)
// Health check
r.Get("/health", healthCheckHandler)
// Public contact info
r.Get("/contact", user.GetContactInfoHandler)
// Public contact-availability (no auth required)
r.With(mw.RateLimit(120, time.Minute)).Get("/contact-availability", scheduling.GetContactAvailability)
// Public business info (limited, safe for non-admin users)
r.Get("/business-info", admin.GetPublicBusinessInfo)
// Portfolio
r.Route("/portfolio", func(r chi.Router) {
r.Get("/images", portfolio.ListImages)
r.Get("/tags", portfolio.ListTags)
r.With(mw.RateLimit(60, time.Minute)).Get("/filters", portfolio.ListFilters)
r.With(mw.RateLimit(120, time.Minute)).Get("/images/{id}", portfolio.GetImage)
r.Group(func(r chi.Router) {
r.Use(mw.RequireAuth)
r.Use(mw.RequireAdmin)
r.Use(mw.RateLimit(60, time.Minute))
r.With(limitBody(portfolioBodyLimit)).Post("/images", portfolio.UploadImage)
r.Delete("/images/{id}", portfolio.DeleteImage)
})
})
// Scheduling
r.Route("/scheduling", func(r chi.Router) {
r.Use(mw.RateLimit(120, time.Minute))
r.Use(mw.OptionalAuth)
r.Get("/default-hours", scheduling.GetDefaultHours)
r.Get("/exceptional-groups", scheduling.ListExceptionalGroups)
r.Get("/working-hours", scheduling.GetWorkingHours)
r.Get("/available-hours", scheduling.GetAvailableHours)
r.Group(func(r chi.Router) {
r.Use(mw.RequireAuth)
r.Use(mw.RequireAdmin)
r.Use(mw.RateLimit(60, time.Minute))
r.Get("/preview-available-hours", scheduling.GetPreviewAvailableHours)
r.Put("/default-hours", scheduling.UpdateDefaultHours)
r.Post("/default-hours/conflicting", scheduling.GetDefaultHoursConflictingBookings)
r.Post("/default-hours/schedule", scheduling.ScheduleDefaultHoursChange)
r.Get("/default-hours/scheduled", scheduling.GetScheduledDefaultHoursChange)
r.Delete("/default-hours/scheduled", scheduling.CancelScheduledDefaultHoursChange)
r.Post("/exceptional-groups", scheduling.CreateExceptionalGroup)
r.Delete("/exceptional-groups", scheduling.DeleteExceptionalGroup)
r.Put("/exceptional-applications", scheduling.UpdateExceptionalApplications)
})
})
// Public booking endpoints (optional auth for slot reservation and guest bookings)
r.Group(func(r chi.Router) {
r.Use(mw.RateLimit(30, time.Minute), mw.OptionalAuth)
r.Use(limitBody(defaultBodyLimit))
r.Post("/bookings/reserve", bookings.ReserveSlotHandler)
r.Post("/bookings", bookings.CreateBookingHandler)
})
// Guest user creation (public, no auth required)
r.With(mw.RateLimit(10, time.Minute), limitBody(defaultBodyLimit)).Post("/users/guest", user.CreateGuestUserHandler)
// Public email check (used by BookingFlow for proactive registered-email detection)
r.With(mw.RateLimit(60, time.Minute)).Get("/check-email", user.CheckEmailHandler)
// Authenticated users
r.Group(func(r chi.Router) {
r.Use(mw.RequireAuth)
r.Use(mw.RateLimit(120, time.Minute))
r.Use(limitBody(defaultBodyLimit))
r.Post("/refresh-token", authHandlers.RefreshTokenHandler)
r.Get("/user/profile", user.GetProfileHandler)
r.Put("/user/profile", user.UpdateProfileHandler)
r.Put("/user/change-password", user.ChangePasswordHandler)
r.Get("/user/notification-preferences", user.GetNotificationPreferencesHandler)
r.Put("/user/notification-preferences", user.UpdateNotificationPreferencesHandler)
r.Delete("/user/account", user.DeleteAccountHandler)
r.Get("/user/gdpr-export", user.GetGDPRExportHandler)
r.Get("/user/loyalty", user.GetLoyaltyHandler)
r.Get("/bookings", bookings.GetAllUserBookingsHandler)
r.Get("/bookings/{id}", bookings.GetBookingHandler)
r.Get("/bookings/{id}/calendar", bookings.GetBookingCalendarHandler)
r.Put("/bookings/{id}", bookings.EditBookingHandler)
r.Delete("/bookings/{id}", bookings.DeleteBookingHandler)
r.Post("/bookings/{id}/edit-request", bookings.RequestEditHandler)
r.Delete("/bookings/{id}/edit-request", bookings.DeleteEditRequestHandler)
r.Get("/bookings/{id}/edit-request", bookings.GetMyEditRequestHandler)
r.Get("/bookings/edit-requests", bookings.GetMyEditRequestsHandler)
r.Delete("/bookings/reserve", bookings.CancelReservationHandler)
// User payment routes
// Product rule (security): guests (account_role='guest') must not
// pay online — RequireNonGuest 403s any token with a guest role
// claim before the money handlers run.
r.With(mw.RequireNonGuest).Post("/bookings/{id}/payment", payments.CreateBookingPayment)
r.With(mw.RequireNonGuest).Post("/bookings/{id}/apply-redemption", payments.ApplyLoyaltyRedemption)
r.With(mw.RequireNonGuest).Post("/bookings/{id}/payment-lock", payments.AcquirePaymentLock)
r.With(mw.RequireNonGuest).Delete("/bookings/{id}/payment-lock", payments.ReleasePaymentLock)
// Product rule (security): cards may only be saved by verified
// accounts — RequireAuth (group mw above) runs first and injects
// userID/role into ctx, then RequireVerified 403s the rest.
r.With(mw.RequireVerified).Get("/user/payment-methods", payments.GetUserPaymentMethods)
r.With(mw.RequireVerified).Post("/user/payment-methods", payments.CreatePaymentMethod)
r.With(mw.RequireVerified).Delete("/user/payment-methods/{id}", payments.DeletePaymentMethod)
r.With(mw.RequireNonGuest).Post("/bookings/{id}/tip", payments.CreateTipPayment)
r.Get("/bookings/{id}/payment-summary", payments.GetBookingPaymentSummary)
r.With(mw.RateLimit(10, time.Minute), limitBody(defaultBodyLimit)).
Get("/bookings/{id}/discount-preview", payments.GetDiscountPreviewHandler)
// User gift card routes
r.With(mw.RequireNonGuest).Post("/user/giftcards/redeem", payments.RedeemGiftCard)
r.Get("/user/giftcards/balance", payments.GetGiftCardBalance)
r.With(mw.RequireNonGuest).Post("/user/giftcards/buy", payments.BuyGiftCard)
})
r.With(mw.RequireAuth, mw.RequireVerified, limitBody(uploadBodyLimit)).Post("/user/profile-picture", user.UploadProfilePictureHandler)
// Admin-only (no rate limit - trusted users with authenticated sessions)
r.Group(func(r chi.Router) {
r.Use(mw.RequireAuth)
r.Use(mw.RequireAdmin)
r.Use(limitBody(defaultBodyLimit))
r.Route("/admin/services", func(r chi.Router) {
r.Post("/", services.CreateServiceHandler)
r.Delete("/{id}", services.DeleteServiceHandler)
r.Get("/", services.AllServicesHandler)
r.Put("/{id}/toggle", services.ToggleService)
})
r.Route("/admin/patch-tests", func(r chi.Router) {
r.Get("/", admin.GetPatchTests)
r.Post("/", admin.CreatePatchTest)
r.Put("/{id}", admin.UpdatePatchTest)
r.Delete("/{id}", admin.DeletePatchTest)
})
r.Route("/admin/custom-services", func(r chi.Router) {
r.Get("/", admin.GetCustomServices)
r.Post("/", admin.CreateCustomService)
r.Get("/{id}", admin.GetCustomService)
r.Put("/{id}", admin.UpdateCustomService)
r.Post("/{id}/promote", admin.PromoteCustomService)
r.Delete("/{id}", admin.DeleteCustomService)
})
r.Route("/admin/bookings", func(r chi.Router) {
r.Get("/", bookings.GetAllAdminBookingsHandler)
r.Post("/", bookings.AdminCreateBookingForUserHandler)
r.With(mw.RateLimit(60, time.Minute)).Get("/search", bookings.SearchAdminBookingsHandler)
r.Get("/user/{user_id}", bookings.GetAllBookingsByUserHandler)
r.Get("/{id}", bookings.GetAdminBookingHandler)
r.Put("/{id}", bookings.UpdateBookingServicesHandler)
r.Get("/{id}/overlapping", bookings.GetOverlappingBookingsHandler)
r.Get("/overlapping", bookings.GetOverlappingBookingsByTimeHandler)
r.Get("/by-date-range", bookings.GetBookingsByDateRangeHandler)
r.Post("/conflicting-for-exception", scheduling.GetConflictingBookingsForExceptionHandler)
r.Get("/by-created-range", bookings.GetBookingsByCreatedRangeHandler)
r.Put("/{id}/reschedule", bookings.AdminRescheduleBookingHandler)
r.Put("/{id}/progress", bookings.ProgressBookingHandler)
r.Post("/{id}/confirm", bookings.ConfirmBookingHandler)
r.Post("/{id}/cancel", bookings.AdminCancelBookingHandler)
r.Post("/reserve", bookings.AdminReserveSlotHandler)
r.Delete("/reserve", bookings.AdminCancelReservationHandler)
// Edit request endpoints
r.Get("/edit-requests", bookings.AdminListAllEditRequestsHandler)
r.Get("/{id}/edit-request", bookings.AdminGetBookingEditRequestHandler)
r.Post("/{id}/edit-requests/{request_id}/approve", bookings.AdminApproveEditRequestHandler)
r.Post("/{id}/edit-requests/{request_id}/deny", bookings.AdminRejectEditRequestHandler)
})
r.Route("/admin/users", func(r chi.Router) {
r.Get("/", user.ListAdminUsersHandler)
r.Get("/{id}", user.GetAdminUserHandler)
r.Get("/{id}/relationship", user.GetCustomerRelationshipHandler)
r.Get("/{id}/patch-tests/eligible", user.GetEligiblePatchTestServicesHandler)
r.Post("/{id}/patch-tests", user.AddPatchTestHandler)
r.Get("/{id}/giftcard-balance", payments.GetUserGiftCardBalanceAdmin)
r.Get("/{id}/payment-methods", payments.AdminGetUserPaymentMethods)
})
r.Route("/admin/today", func(r chi.Router) {
r.Get("/current-next", today.GetCurrentAndNextHandler)
r.Get("/appointments", today.GetTodayAppointmentsHandler)
r.Get("/pending-approvals", today.GetPendingApprovalsHandler)
})
r.Route("/admin/notifications", func(r chi.Router) {
r.Get("/", notifications.GetNotifications)
r.Get("/unread-count", notifications.GetUnreadCount)
r.Post("/{id}/acknowledge", notifications.AcknowledgeNotification)
})
r.Route("/admin/time-blockers", func(r chi.Router) {
r.Get("/", scheduling.ListTimeBlockers)
r.Post("/", scheduling.CreateTimeBlocker)
r.Delete("/{id}", scheduling.DeleteTimeBlocker)
})
r.Route("/admin/discount-campaigns", func(r chi.Router) {
r.Get("/", admin.GetDiscountCampaigns)
r.Post("/", admin.CreateDiscountCampaign)
r.Put("/{id}", admin.UpdateDiscountCampaign)
r.Delete("/{id}", admin.DeleteDiscountCampaign)
r.Get("/{id}/stats", admin.GetCampaignStats)
})
// Admin payment routes
r.Post("/admin/bookings/{id}/payment", payments.CreateTerminalPayment)
r.Post("/admin/bookings/{id}/refund", payments.AdminRefundBooking)
r.Get("/admin/payments/{checkout_id}/status", payments.GetCheckoutStatus)
r.Post("/admin/payments/{payment_id}/refund", payments.RefundPayment)
// Admin gift card routes
r.Get("/admin/gift-cards", payments.GetGiftCards)
r.Post("/admin/gift-cards", payments.CreateGiftCard)
r.Put("/admin/gift-cards/{id}/topup", payments.TopUpGiftCard)
r.Post("/admin/gift-cards/{from}/transfer", payments.TransferGiftCard)
r.Get("/admin/gift-cards/expired-balances", payments.GetExpiredBalances)
r.Post("/admin/gift-cards/expired-balances/claim", payments.ClaimExpiredBalance)
// Admin till sale routes (POS transactions not linked to bookings)
r.Post("/admin/till/sale", payments.CreateTillSale)
r.Get("/admin/till/sale/checkout/{checkout_id}/status", payments.GetTillCheckoutStatus)
r.Route("/admin/settings", func(r chi.Router) {
r.Get("/", admin.GetBusinessSettings)
r.Put("/", admin.UpdateBusinessSettings)
})
})
})
// Webhooks (no auth - Square sends to base path)
r.Post("/webhooks/square", webhooks.HandleSquareWebhook)
srv := &http.Server{
Addr: ":8080",
Handler: r,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT)
go func() {
<-quit
log.Println("Shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("Server forced to shutdown: %v", err)
}
<-sched.Shutdown()
log.Println("Background jobs stopped")
}()
fmt.Println("Server is listening on :8080")
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server failed to start: %v", err)
}
log.Println("Server exited")
}