From 44895a3697249bbc042d29a02f029c2a8b6401d3 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Thu, 11 Jun 2026 22:08:21 +0100 Subject: [PATCH] feat(admin): add business settings API and routing for gift card config Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- backend/handlers/admin/settings.go | 159 +++++++++++++++ backend/handlers/admin/settings_test.go | 254 ++++++++++++++++++++++++ backend/main.go | 10 + 3 files changed, 423 insertions(+) create mode 100644 backend/handlers/admin/settings.go create mode 100644 backend/handlers/admin/settings_test.go diff --git a/backend/handlers/admin/settings.go b/backend/handlers/admin/settings.go new file mode 100644 index 0000000..99505af --- /dev/null +++ b/backend/handlers/admin/settings.go @@ -0,0 +1,159 @@ +package admin + +import ( + "crussell/db" + "encoding/json" + "log" + "net/http" + "strconv" +) + +type BusinessSettings struct { + BusinessName string `json:"business_name"` + BusinessAddress string `json:"business_address"` + BusinessPhone *string `json:"business_phone,omitempty"` + BusinessEmail *string `json:"business_email,omitempty"` + VATRegistrationNumber *string `json:"vat_registration_number,omitempty"` + IsVATRegistered bool `json:"is_vat_registered"` + DefaultVATRate float64 `json:"default_vat_rate"` + CurrencyCode string `json:"currency_code"` + WebsiteURL *string `json:"website_url,omitempty"` + GiftCardExpiryMonths int `json:"gift_card_expiry_months"` + VoucherType string `json:"voucher_type"` +} + +func GetBusinessSettings(w http.ResponseWriter, r *http.Request) { + var s BusinessSettings + err := db.DB.QueryRow(r.Context(), ` + SELECT business_name, business_address, business_phone, business_email, + vat_registration_number, is_vat_registered, default_vat_rate, + currency_code, website_url, gift_card_expiry_months, voucher_type + FROM business_settings + LIMIT 1 + `).Scan( + &s.BusinessName, &s.BusinessAddress, &s.BusinessPhone, &s.BusinessEmail, + &s.VATRegistrationNumber, &s.IsVATRegistered, &s.DefaultVATRate, + &s.CurrencyCode, &s.WebsiteURL, &s.GiftCardExpiryMonths, &s.VoucherType, + ) + if err != nil { + log.Printf("Failed to fetch business settings: %v", err) + http.Error(w, "Failed to fetch settings", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(s) +} + +type UpdateBusinessSettingsRequest struct { + BusinessName *string `json:"business_name,omitempty"` + BusinessAddress *string `json:"business_address,omitempty"` + BusinessPhone *string `json:"business_phone,omitempty"` + BusinessEmail *string `json:"business_email,omitempty"` + VATRegistrationNumber *string `json:"vat_registration_number,omitempty"` + IsVATRegistered *bool `json:"is_vat_registered,omitempty"` + DefaultVATRate *float64 `json:"default_vat_rate,omitempty"` + WebsiteURL *string `json:"website_url,omitempty"` + GiftCardExpiryMonths *int `json:"gift_card_expiry_months,omitempty"` + VoucherType *string `json:"voucher_type,omitempty"` +} + +func UpdateBusinessSettings(w http.ResponseWriter, r *http.Request) { + var req UpdateBusinessSettingsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.VoucherType != nil && *req.VoucherType != "SPV" && *req.VoucherType != "MPV" { + http.Error(w, "voucher_type must be 'SPV' or 'MPV'", http.StatusBadRequest) + return + } + + if req.GiftCardExpiryMonths != nil && *req.GiftCardExpiryMonths < 1 { + http.Error(w, "gift_card_expiry_months must be at least 1", http.StatusBadRequest) + return + } + + if req.DefaultVATRate != nil && (*req.DefaultVATRate < 0 || *req.DefaultVATRate > 100) { + http.Error(w, "default_vat_rate must be between 0 and 100", http.StatusBadRequest) + return + } + + setClauses := []string{} + args := []interface{}{} + argIdx := 1 + + if req.BusinessName != nil { + setClauses = append(setClauses, "business_name = $"+strconv.Itoa(argIdx)) + args = append(args, *req.BusinessName) + argIdx++ + } + if req.BusinessAddress != nil { + setClauses = append(setClauses, "business_address = $"+strconv.Itoa(argIdx)) + args = append(args, *req.BusinessAddress) + argIdx++ + } + if req.BusinessPhone != nil { + setClauses = append(setClauses, "business_phone = $"+strconv.Itoa(argIdx)) + args = append(args, *req.BusinessPhone) + argIdx++ + } + if req.BusinessEmail != nil { + setClauses = append(setClauses, "business_email = $"+strconv.Itoa(argIdx)) + args = append(args, *req.BusinessEmail) + argIdx++ + } + if req.VATRegistrationNumber != nil { + setClauses = append(setClauses, "vat_registration_number = $"+strconv.Itoa(argIdx)) + args = append(args, *req.VATRegistrationNumber) + argIdx++ + } + if req.IsVATRegistered != nil { + setClauses = append(setClauses, "is_vat_registered = $"+strconv.Itoa(argIdx)) + args = append(args, *req.IsVATRegistered) + argIdx++ + } + if req.DefaultVATRate != nil { + setClauses = append(setClauses, "default_vat_rate = $"+strconv.Itoa(argIdx)) + args = append(args, *req.DefaultVATRate) + argIdx++ + } + if req.WebsiteURL != nil { + setClauses = append(setClauses, "website_url = $"+strconv.Itoa(argIdx)) + args = append(args, *req.WebsiteURL) + argIdx++ + } + if req.GiftCardExpiryMonths != nil { + setClauses = append(setClauses, "gift_card_expiry_months = $"+strconv.Itoa(argIdx)) + args = append(args, *req.GiftCardExpiryMonths) + argIdx++ + } + if req.VoucherType != nil { + setClauses = append(setClauses, "voucher_type = $"+strconv.Itoa(argIdx)) + args = append(args, *req.VoucherType) + argIdx++ + } + + if len(setClauses) == 0 { + http.Error(w, "No fields to update", http.StatusBadRequest) + return + } + + query := "UPDATE business_settings SET " + for i, clause := range setClauses { + if i > 0 { + query += ", " + } + query += clause + } + + _, err := db.DB.Exec(r.Context(), query, args...) + if err != nil { + log.Printf("Failed to update business settings: %v", err) + http.Error(w, "Failed to update settings", http.StatusInternalServerError) + return + } + + GetBusinessSettings(w, r) +} diff --git a/backend/handlers/admin/settings_test.go b/backend/handlers/admin/settings_test.go new file mode 100644 index 0000000..ec192ad --- /dev/null +++ b/backend/handlers/admin/settings_test.go @@ -0,0 +1,254 @@ +//go:build test +// +build test + +package admin + +import ( + "context" + "net/http" + "testing" + + "crussell/db" +) + +func intPtr(i int) *int { return &i } +func float64Ptr(f float64) *float64 { return &f } +func boolPtr(b bool) *bool { return &b } + +func seedBusinessSettings(t *testing.T) { + t.Helper() + _, err := db.DB.Exec(context.Background(), ` + INSERT INTO business_settings (business_name, business_address, currency_code, gift_card_expiry_months, voucher_type) + VALUES ('Test Salon', '123 Test St', 'GBP', 12, 'SPV') + `) + if err != nil { + t.Fatalf("failed to seed business settings: %v", err) + } +} + +// TestGetBusinessSettings verifies that GET /api/admin/settings returns the +// current business settings row. +func TestGetBusinessSettings(t *testing.T) { + resetTestData(t) + seedBusinessSettings(t) + + handler := http.HandlerFunc(GetBusinessSettings) + w := makeAdminRequest(handler, "GET", "/api/admin/settings", nil) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var s BusinessSettings + if err := parseResponseBody(w, &s); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + if s.BusinessName != "Test Salon" { + t.Errorf("expected BusinessName 'Test Salon', got '%s'", s.BusinessName) + } + if s.BusinessAddress != "123 Test St" { + t.Errorf("expected BusinessAddress '123 Test St', got '%s'", s.BusinessAddress) + } + if s.CurrencyCode != "GBP" { + t.Errorf("expected CurrencyCode 'GBP', got '%s'", s.CurrencyCode) + } + if s.GiftCardExpiryMonths != 12 { + t.Errorf("expected GiftCardExpiryMonths 12, got %d", s.GiftCardExpiryMonths) + } + if s.VoucherType != "SPV" { + t.Errorf("expected VoucherType 'SPV', got '%s'", s.VoucherType) + } +} + +// TestUpdateBusinessSettings verifies that updating a single field via +// PUT /api/admin/settings returns 200 with the updated settings. +func TestUpdateBusinessSettings(t *testing.T) { + resetTestData(t) + seedBusinessSettings(t) + + handler := http.HandlerFunc(UpdateBusinessSettings) + body := UpdateBusinessSettingsRequest{ + BusinessName: stringPtr("Updated Salon Name"), + } + w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var s BusinessSettings + if err := parseResponseBody(w, &s); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + if s.BusinessName != "Updated Salon Name" { + t.Errorf("expected BusinessName 'Updated Salon Name', got '%s'", s.BusinessName) + } +} + +// TestUpdateBusinessSettings_MultipleFields verifies that updating several +// fields at once works correctly. +func TestUpdateBusinessSettings_MultipleFields(t *testing.T) { + resetTestData(t) + seedBusinessSettings(t) + + handler := http.HandlerFunc(UpdateBusinessSettings) + body := UpdateBusinessSettingsRequest{ + BusinessName: stringPtr("Multi Update Salon"), + BusinessAddress: stringPtr("456 New St"), + GiftCardExpiryMonths: intPtr(24), + VoucherType: stringPtr("MPV"), + } + w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var s BusinessSettings + if err := parseResponseBody(w, &s); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + if s.BusinessName != "Multi Update Salon" { + t.Errorf("expected BusinessName 'Multi Update Salon', got '%s'", s.BusinessName) + } + if s.BusinessAddress != "456 New St" { + t.Errorf("expected BusinessAddress '456 New St', got '%s'", s.BusinessAddress) + } + if s.GiftCardExpiryMonths != 24 { + t.Errorf("expected GiftCardExpiryMonths 24, got %d", s.GiftCardExpiryMonths) + } + if s.VoucherType != "MPV" { + t.Errorf("expected VoucherType 'MPV', got '%s'", s.VoucherType) + } +} + +// TestUpdateBusinessSettings_InvalidVoucherType verifies that an invalid +// voucher_type value returns 400. +func TestUpdateBusinessSettings_InvalidVoucherType(t *testing.T) { + resetTestData(t) + seedBusinessSettings(t) + + handler := http.HandlerFunc(UpdateBusinessSettings) + body := UpdateBusinessSettingsRequest{ + VoucherType: stringPtr("INVALID"), + } + w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) + } + if w.Body.String() != "voucher_type must be 'SPV' or 'MPV'\n" { + t.Errorf("unexpected error message: %s", w.Body.String()) + } +} + +// TestUpdateBusinessSettings_NegativeExpiryMonths verifies that a +// gift_card_expiry_months value less than 1 returns 400. +func TestUpdateBusinessSettings_NegativeExpiryMonths(t *testing.T) { + resetTestData(t) + seedBusinessSettings(t) + + handler := http.HandlerFunc(UpdateBusinessSettings) + body := UpdateBusinessSettingsRequest{ + GiftCardExpiryMonths: intPtr(0), + } + w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) + } + if w.Body.String() != "gift_card_expiry_months must be at least 1\n" { + t.Errorf("unexpected error message: %s", w.Body.String()) + } +} + +// TestUpdateBusinessSettings_InvalidVATRate verifies that a default_vat_rate +// outside the 0-100 range returns 400. +func TestUpdateBusinessSettings_InvalidVATRate(t *testing.T) { + resetTestData(t) + seedBusinessSettings(t) + + handler := http.HandlerFunc(UpdateBusinessSettings) + + body := UpdateBusinessSettingsRequest{ + DefaultVATRate: float64Ptr(-1), + } + w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400 for negative rate, got %d. body: %s", w.Code, w.Body.String()) + } + if w.Body.String() != "default_vat_rate must be between 0 and 100\n" { + t.Errorf("unexpected error message: %s", w.Body.String()) + } + body2 := UpdateBusinessSettingsRequest{ + DefaultVATRate: float64Ptr(101), + } + w2 := makeAdminRequest(handler, "PUT", "/api/admin/settings", body2) + + if w2.Code != http.StatusBadRequest { + t.Errorf("expected status 400 for rate > 100, got %d. body: %s", w2.Code, w2.Body.String()) + } + if w2.Body.String() != "default_vat_rate must be between 0 and 100\n" { + t.Errorf("unexpected error message: %s", w2.Body.String()) + } +} + +// TestUpdateBusinessSettings_NoFields verifies that an empty request body +// (no fields to update) returns 400. +func TestUpdateBusinessSettings_NoFields(t *testing.T) { + resetTestData(t) + seedBusinessSettings(t) + + handler := http.HandlerFunc(UpdateBusinessSettings) + w := makeAdminRequest(handler, "PUT", "/api/admin/settings", UpdateBusinessSettingsRequest{}) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) + } + if w.Body.String() != "No fields to update\n" { + t.Errorf("unexpected error message: %s", w.Body.String()) + } +} + +// TestUpdateBusinessSettings_PartialUpdate verifies that updating a single field +// leaves other fields unchanged. +func TestUpdateBusinessSettings_PartialUpdate(t *testing.T) { + resetTestData(t) + seedBusinessSettings(t) + + handler := http.HandlerFunc(UpdateBusinessSettings) + body := UpdateBusinessSettingsRequest{ + GiftCardExpiryMonths: intPtr(36), + } + w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var s BusinessSettings + if err := parseResponseBody(w, &s); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + if s.GiftCardExpiryMonths != 36 { + t.Errorf("expected GiftCardExpiryMonths 36, got %d", s.GiftCardExpiryMonths) + } + + if s.BusinessName != "Test Salon" { + t.Errorf("expected BusinessName 'Test Salon' (unchanged), got '%s'", s.BusinessName) + } + if s.BusinessAddress != "123 Test St" { + t.Errorf("expected BusinessAddress '123 Test St' (unchanged), got '%s'", s.BusinessAddress) + } + if s.VoucherType != "SPV" { + t.Errorf("expected VoucherType 'SPV' (unchanged), got '%s'", s.VoucherType) + } + if s.CurrencyCode != "GBP" { + t.Errorf("expected CurrencyCode 'GBP' (unchanged), got '%s'", s.CurrencyCode) + } +} diff --git a/backend/main.go b/backend/main.go index a5a3dfa..54f7b6a 100644 --- a/backend/main.go +++ b/backend/main.go @@ -225,6 +225,9 @@ func main() { // 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) @@ -357,10 +360,17 @@ r.Route("/admin/users", func(r chi.Router) { 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) + }) }) })