Gift-card rolling expiry, SvelteDate→Date purge, strict DST tests, UTC scan-location + settings legal floor

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.
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 7f1c649f1e
commit 197d4c4b9b
54 changed files with 1204 additions and 275 deletions
+6 -2
View File
@@ -169,8 +169,12 @@ func UpdateBusinessSettings(w http.ResponseWriter, r *http.Request) {
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)
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) {
+62 -3
View File
@@ -725,6 +725,65 @@ func TestUpdateBusinessSettings_VoucherType_SPV(t *testing.T) {
}
}
// TestUpdateBusinessSettings_Expiry_RejectsSub12 rejects gift_card_expiry_months
// below the legal floor. The setting now actually drives gift-card expiry (the
// cleanup job + every expiry_date write), so a sub-12-month window — flagged by
// CMA guidance as an unfair contract term under the Consumer Rights Act 2015 —
// must not be settable.
func TestUpdateBusinessSettings_Expiry_RejectsSub12(t *testing.T) {
ctx, _ := testutils.SetupTestTx(t)
handler := http.HandlerFunc(UpdateBusinessSettings)
body := UpdateBusinessSettingsRequest{
GiftCardExpiryMonths: intPtr(6),
}
w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400 for 6-month expiry, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestUpdateBusinessSettings_Expiry_RejectsZero rejects gift_card_expiry_months
// of 0 (previously the only invalid value, now the legal floor subsumes it).
func TestUpdateBusinessSettings_Expiry_RejectsZero(t *testing.T) {
ctx, _ := testutils.SetupTestTx(t)
handler := http.HandlerFunc(UpdateBusinessSettings)
body := UpdateBusinessSettingsRequest{
GiftCardExpiryMonths: intPtr(0),
}
w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400 for 0-month expiry, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestUpdateBusinessSettings_Expiry_Accepts24 verifies a 24-month window (the
// documented default) is accepted and persisted.
func TestUpdateBusinessSettings_Expiry_Accepts24(t *testing.T) {
ctx, _ := testutils.SetupTestTx(t)
handler := http.HandlerFunc(UpdateBusinessSettings)
body := UpdateBusinessSettingsRequest{
GiftCardExpiryMonths: intPtr(24),
}
w := makeAdminRequest(handler, "PUT", "/api/admin/settings", body, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200 for 24-month expiry, got %d. body: %s", w.Code, w.Body.String())
}
var s BusinessSettings
if err := parseResponseBody(w, &s); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if s.GiftCardExpiryMonths != 24 {
t.Errorf("expected GiftCardExpiryMonths 24, got %d", s.GiftCardExpiryMonths)
}
}
// TestUpdateBusinessSettings_VoucherType_MPV accepts MPV.
func TestUpdateBusinessSettings_VoucherType_MPV(t *testing.T) {
ctx, _ := testutils.SetupTestTx(t)
@@ -764,7 +823,7 @@ func TestUpdateBusinessSettings_NegativeExpiryMonths(t *testing.T) {
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
if w.Body.String() != "gift_card_expiry_months must be at least 1\n" {
if !strings.Contains(w.Body.String(), "must be at least 12") {
t.Errorf("unexpected error message: %s", w.Body.String())
}
}
@@ -782,8 +841,8 @@ func TestUpdateBusinessSettings_ExpiryMonths_Negative(t *testing.T) {
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400 for negative expiry, got %d. body: %s", w.Code, w.Body.String())
}
if w.Body.String() != "gift_card_expiry_months must be at least 1\n" {
t.Errorf("unexpected error message: %s", w.Body.String())
if !strings.Contains(w.Body.String(), "must be at least 12") {
t.Errorf("expected legal-floor error message, got: %s", w.Body.String())
}
}