Gift-card rolling expiry (setting-driven, was dead config): - GetGiftCardExpiryMonths(): single source of truth (business_settings gift_card_expiry_months, fallback 24) shared by payment handlers and the CleanupExpiredGiftCards job (was hardcoded 24). - expiry_date now maintained on ALL 9 gift-card write sites (buy, topup, transfer, redeem, terminal payment, refund credit, till) so the refund-time guard at refunds.go actually fires. Schema default 12->24 + migration note; test-DB seed aligned. Stale "expiry_date IS NULL" test rewritten; new expired-card-rejected regression test. Frontend SvelteDate purge (docs' stated convention, wide): - All 180+ raw `new SvelteDate(...)` uses across routes/components replaced with parseWallClockDate (backend UTC ISO) or new Date (wall-clock constructors). SvelteDate imports removed. timeSlots.ts getDayWithOrdinal fixed. Zero SvelteDate references remain; svelte-check clean. Strict timezone/DST testing + QA fixes: - 8 new hermetic boundary tests: clock.DST transitions (both 2026 folds), closing-hours GMT vs BST, booking date-window midnight, refund-tier elapsed-time independence, deposit-window UTC-instant, scheduling LondonDateString midnight, today AT TIME ZONE window + UTC round-trip. - today.go summary date labels fixed to London wall-clock (were showing the previous UTC day during BST) + regression test. - pgx ScanLocation fixed to UTC via AfterConnect (was host-local -> JSON offsets depended on deployment TZ, contradicting the documented UTC invariant) + regression test. Registered as a new *Type to avoid a data race on the shared type map (caught by -race). Admin Business Settings (setting now functional => legal floor): - gift_card_expiry_months validation floor raised 1 -> 12 months (CMA/ Consumer Rights Act 2015 unfair-contract-term guidance) in endpoint + UI, with rolling-expiry semantics shown in both display and edit form. - 3 new expiry validation tests; 2 pre-existing message assertions updated. Full suite 25/25 + race clean via run-tests.sh lockfile; svelte-check 0 errors/warnings; production build succeeds.
303 lines
11 KiB
Go
303 lines
11 KiB
Go
package admin
|
|
|
|
import (
|
|
"crussell/db"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
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
|
|
}
|
|
|
|
if err := json.NewEncoder(w).Encode(info); err != nil {
|
|
log.Printf("Failed to encode JSON response: %v", err)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
if err := json.NewEncoder(w).Encode(s); err != nil {
|
|
log.Printf("Failed to encode JSON response: %v", err)
|
|
}
|
|
}
|
|
|
|
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 < 12 {
|
|
// Legal floor, not arbitrary: UK Consumer Rights Act 2015 requires
|
|
// expiry terms to be "fair and transparent", and CMA guidance flags
|
|
// sub-12-month expiry windows as at risk of being an unfair contract
|
|
// term. 24 months is the documented default (matches John Lewis, M&S).
|
|
http.Error(w, "gift_card_expiry_months must be at least 12 (CMA guidance flags sub-12-month expiry as an unfair contract term; 24 is recommended)", 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 := []any{}
|
|
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)
|
|
}
|
|
|
|
if len(setClauses) == 0 {
|
|
http.Error(w, "No fields to update", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var query strings.Builder
|
|
query.WriteString("UPDATE business_settings SET ")
|
|
for i, clause := range setClauses {
|
|
if i > 0 {
|
|
query.WriteString(", ")
|
|
}
|
|
query.WriteString(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 func() {
|
|
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
|
slog.Error("failed to rollback transaction", "err", err)
|
|
}
|
|
}()
|
|
|
|
_, err = tx.Exec(r.Context(), query.String(), 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)
|
|
}
|