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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-11 22:08:21 +01:00
co-authored by Sisyphus
parent 30ded0bb95
commit 44895a3697
3 changed files with 423 additions and 0 deletions
+159
View File
@@ -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)
}
+254
View File
@@ -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)
}
}