Files
Crussell/backend/handlers/admin/settings.go
T
popertotsandSisyphus e4b9003439 refactor(handlers): migrate remaining backend handlers to clock.Now() and transaction patterns
Apply clock.Now() migration, transaction wrapping, and minor refactors across admin, scheduling, today, user, auth handler, notifications, webhooks, services, portfolio, ratelimit, testutils, and main.go.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-24 23:43:50 +01:00

288 lines
10 KiB
Go

package admin
import (
"crussell/db"
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"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 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
}
json.NewEncoder(w).Encode(info)
}
func GetBusinessSettings(w http.ResponseWriter, r *http.Request) {
var s BusinessSettings
err := db.Conn.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
}
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.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 && *req.VATRegistrationNumber != "" {
v := *req.VATRegistrationNumber
if len(v) < 11 || len(v) > 14 {
http.Error(w, "vat_registration_number must be 11 characters (GB + 9 digits) or 14 characters (GB + 12 digits)", http.StatusBadRequest)
return
}
if v[:2] != "GB" {
http.Error(w, "vat_registration_number must start with 'GB'", http.StatusBadRequest)
return
}
digits := v[2:]
if len(digits) != 9 && len(digits) != 12 {
http.Error(w, "vat_registration_number must have 9 or 12 digits after 'GB'", http.StatusBadRequest)
return
}
for _, c := range digits {
if c < '0' || c > '9' {
http.Error(w, "vat_registration_number must contain only digits after 'GB'", 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
}
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
}
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)
}