From 280b05bd344347e5b94e259eb528d0f3d7f10d46 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Mon, 22 Jun 2026 17:05:45 +0100 Subject: [PATCH] feat(admin): add public business info, input validation, and VAT enable Add GetPublicBusinessInfo endpoint for non-admin users. Add URL validation for website_url. Add length/bounds validation for name, address, phone, email, VAT number. Retroactively apply VAT to past payments/till sales when is_vat_registered is enabled. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- backend/handlers/admin/settings.go | 116 ++++++++++++++++++++++++++++- 1 file changed, 113 insertions(+), 3 deletions(-) diff --git a/backend/handlers/admin/settings.go b/backend/handlers/admin/settings.go index 59fc41a..107a41b 100644 --- a/backend/handlers/admin/settings.go +++ b/backend/handlers/admin/settings.go @@ -3,8 +3,10 @@ package admin import ( "crussell/db" "encoding/json" + "fmt" "log" "net/http" + "net/url" "strconv" ) @@ -22,6 +24,53 @@ type BusinessSettings struct { VoucherType string `json:"voucher_type"` } +func validateURL(rawURL string) error { + parsed, err := url.Parse(rawURL) + if err != nil { + return err + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return fmt.Errorf("must start with http:// or https://") + } + if parsed.Host == "" { + return fmt.Errorf("host is required") + } + return nil +} + +// PublicBusinessInfo contains the non-sensitive subset of business settings +// that is safe to expose to unauthenticated or non-admin users. +type PublicBusinessInfo struct { + BusinessName string `json:"business_name"` + BusinessAddress string `json:"business_address"` + BusinessPhone *string `json:"business_phone,omitempty"` + BusinessEmail *string `json:"business_email,omitempty"` + IsVATRegistered bool `json:"is_vat_registered"` + VATRegistrationNumber *string `json:"vat_registration_number,omitempty"` + DefaultVATRate float64 `json:"default_vat_rate"` +} + +func GetPublicBusinessInfo(w http.ResponseWriter, r *http.Request) { + var info PublicBusinessInfo + err := db.Conn.QueryRow(r.Context(), ` + SELECT business_name, business_address, business_phone, business_email, + is_vat_registered, vat_registration_number, default_vat_rate + FROM business_settings + LIMIT 1 + `).Scan( + &info.BusinessName, &info.BusinessAddress, &info.BusinessPhone, &info.BusinessEmail, + &info.IsVATRegistered, &info.VATRegistrationNumber, &info.DefaultVATRate, + ) + if err != nil { + log.Printf("Failed to fetch public business info: %v", err) + http.Error(w, "Failed to fetch business info", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(info) +} + func GetBusinessSettings(w http.ResponseWriter, r *http.Request) { var s BusinessSettings err := db.Conn.QueryRow(r.Context(), ` @@ -65,16 +114,40 @@ func UpdateBusinessSettings(w http.ResponseWriter, r *http.Request) { return } + if req.BusinessName != nil && (len(*req.BusinessName) == 0 || len(*req.BusinessName) > 255) { + http.Error(w, "business_name must be between 1 and 255 characters", http.StatusBadRequest) + return + } + if req.BusinessAddress != nil && len(*req.BusinessAddress) == 0 { + http.Error(w, "business_address must not be empty", http.StatusBadRequest) + return + } + if req.BusinessPhone != nil && len(*req.BusinessPhone) > 20 { + http.Error(w, "business_phone must be 20 characters or fewer", http.StatusBadRequest) + return + } + if req.BusinessEmail != nil && len(*req.BusinessEmail) > 254 { + http.Error(w, "business_email must be 254 characters or fewer", http.StatusBadRequest) + return + } + if req.VATRegistrationNumber != nil && len(*req.VATRegistrationNumber) > 20 { + http.Error(w, "vat_registration_number must be 20 characters or fewer", http.StatusBadRequest) + return + } + if req.WebsiteURL != nil && *req.WebsiteURL != "" { + if err := validateURL(*req.WebsiteURL); err != nil { + http.Error(w, "website_url: "+err.Error(), 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 @@ -148,12 +221,49 @@ func UpdateBusinessSettings(w http.ResponseWriter, r *http.Request) { query += clause } - _, err := db.Conn.Exec(r.Context(), query, args...) + tx, err := db.Conn.Begin(r.Context()) + if err != nil { + log.Printf("Failed to begin transaction: %v", err) + http.Error(w, "Failed to update settings", http.StatusInternalServerError) + return + } + defer tx.Rollback(r.Context()) + + _, err = tx.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 } + if req.IsVATRegistered != nil && *req.IsVATRegistered { + vatRate := 20.0 + if req.DefaultVATRate != nil { + vatRate = *req.DefaultVATRate + } + var payUpdated, tillUpdated int + var totalVAT, totalNet float64 + // Use '2000-01-01' to cover all historical payments. Under UK VAT law, + // the effective registration date may be backdated — this ensures all + // past completed payments and till sales get VAT applied retroactively. + vatErr := tx.QueryRow(r.Context(), + "SELECT payments_updated, till_sales_updated, total_vat_calculated, total_net_calculated FROM enable_vat_registration('2000-01-01'::date, $1, $2)", + vatRate, req.VATRegistrationNumber, + ).Scan(&payUpdated, &tillUpdated, &totalVAT, &totalNet) + if vatErr != nil { + log.Printf("Failed to enable VAT retroactively: %v", vatErr) + http.Error(w, "Business settings saved but failed to retroactively apply VAT to past payments. The database function enable_vat_registration returned an error. Please try again or contact support.", http.StatusInternalServerError) + return + } + log.Printf("VAT enabled retroactively: %d payments (+£%.2f VAT), %d till sales updated", + payUpdated, totalVAT, tillUpdated) + } + + if err := tx.Commit(r.Context()); err != nil { + log.Printf("Failed to commit settings update: %v", err) + http.Error(w, "Failed to save settings", http.StatusInternalServerError) + return + } + GetBusinessSettings(w, r) }