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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-22 17:05:45 +01:00
co-authored by Sisyphus
parent 6561ceb7a9
commit 280b05bd34
+113 -3
View File
@@ -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)
}