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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user