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
@@ -1,6 +1,6 @@
<script lang="ts">
import { apiFetch } from '$lib/utils/api';
import { SvelteDate, SvelteMap } from 'svelte/reactivity';
import { SvelteMap } from 'svelte/reactivity';
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
@@ -16,7 +16,7 @@
getLunchProtectionForSlots,
timeToMinutes
} from '$lib/lunchProtection';
import { formatLocalDateTime, getLondonTodayCalendarDate } from '$lib/utils/timeSlots';
import { formatLocalDateTime, getLondonTodayCalendarDate, parseWallClockDate } from '$lib/utils/timeSlots';
import ClockIcon from '@lucide/svelte/icons/clock';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
@@ -71,7 +71,7 @@
// ─── Date constants ─────────────────────────────────────
const todayCalendarDate = getLondonTodayCalendarDate();
const minDate = todayCalendarDate;
const maxDate = new SvelteDate(
const maxDate = new Date(
todayCalendarDate.year,
todayCalendarDate.month - 1,
todayCalendarDate.day
@@ -85,7 +85,7 @@
let placeholderDate = $state<CalendarDate>(minDate);
const hoursUntilAppointment = $derived(
(new SvelteDate(booking.start_time).getTime() - new SvelteDate().getTime()) / (1000 * 60 * 60)
(parseWallClockDate(booking.start_time).getTime() - new Date().getTime()) / (1000 * 60 * 60)
);
const hasPayments = $derived((booking.amount_paid ?? 0) > 0);
const noticePeriodBlocked = $derived(
@@ -420,7 +420,7 @@
$effect(() => {
if (editMode === 'services' && !servicesHoursFetched) {
servicesHoursFetched = true;
const bookingDate = new SvelteDate(booking.start_time);
const bookingDate = parseWallClockDate(booking.start_time);
const calDate = new CalendarDate(
bookingDate.getFullYear(),
bookingDate.getMonth() + 1,
@@ -445,8 +445,8 @@
return;
editRequestAutoSelectDone = true;
const currentDate = new SvelteDate(getLondonTodayCalendarDate().toString() + 'T00:00:00');
const maxDateJs = new SvelteDate(
const currentDate = new Date(getLondonTodayCalendarDate().toString() + 'T00:00:00');
const maxDateJs = new Date(
maxCalendarDate.year,
maxCalendarDate.month - 1,
maxCalendarDate.day
@@ -458,7 +458,7 @@
const daysToCheck = Math.min(daysDifference, 180);
for (let i = 0; i <= daysToCheck; i++) {
const nextDate = new SvelteDate(currentDate);
const nextDate = new Date(currentDate);
nextDate.setDate(currentDate.getDate() + i);
const dateStr = nextDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
@@ -637,7 +637,7 @@
function calculateRemainingTime(): number {
if (!workingHours || !availableHours) return 0;
const bookingDate = new SvelteDate(booking.start_time);
const bookingDate = parseWallClockDate(booking.start_time);
const dateStr = bookingDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
const dayWH = workingHours[dateStr];