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:
@@ -11,6 +11,77 @@ func TestNow_ReturnsUTC(t *testing.T) {
|
||||
if now.Location() != time.UTC {
|
||||
t.Errorf("clock.Now() returned time in %v, expected UTC", now.Location())
|
||||
}
|
||||
// UTC is a fixed-offset zone: the zone abbreviation and offset must both be
|
||||
// the canonical UTC values so SQL TIMESTAMPTZ comparisons and duration math
|
||||
// never drift.
|
||||
name, off := now.Zone()
|
||||
if name != "UTC" || off != 0 {
|
||||
t.Errorf("clock.Now() returned zone %q offset %d, expected UTC offset 0", name, off)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLondon_Location_IsEuropeLondon proves the single London location used for
|
||||
// wall-clock business decisions is the IANA Europe/London zone (which carries
|
||||
// the full DST rule table for GMT<->BST).
|
||||
func TestLondon_Location_IsEuropeLondon(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := London.String(); got != "Europe/London" {
|
||||
t.Errorf("clock.London.String() = %q, expected %q", got, "Europe/London")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLondon_DSTTransitions pins the two 2026 Europe/London transitions:
|
||||
//
|
||||
// Spring forward (2026-03-29 01:00 GMT -> 02:00 BST):
|
||||
// - 2026-03-29 00:30 UTC = 01:30 GMT, offset +0 (still winter time)
|
||||
// - 2026-03-29 01:00 UTC = 02:00 BST, offset +1 (the transition instant)
|
||||
// Fall back (2026-10-25 02:00 BST -> 01:00 GMT):
|
||||
// - 2026-10-25 00:30 UTC = 01:30 BST, offset +1 (still summer time)
|
||||
// - 2026-10-25 01:00 UTC = 01:00 GMT, offset +0 (the ambiguous hour, which
|
||||
// Go resolves to the SECOND occurrence)
|
||||
//
|
||||
// These instants are the boundary of every London wall-clock decision the app
|
||||
// makes (closing hours, default-hours effective dates, today summaries), so
|
||||
// clock.London must get them exactly right.
|
||||
func TestLondon_DSTTransitions(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
utc time.Time
|
||||
wantWall string // London wall-clock, "2006-01-02 15:04"
|
||||
wantZone string // "GMT" or "BST"
|
||||
wantOffsetH int
|
||||
}{
|
||||
{"spring before", time.Date(2026, 3, 29, 0, 30, 0, 0, time.UTC), "2026-03-29 00:30", "GMT", 0},
|
||||
{"spring transition", time.Date(2026, 3, 29, 1, 0, 0, 0, time.UTC), "2026-03-29 02:00", "BST", 1},
|
||||
{"autumn before", time.Date(2026, 10, 25, 0, 30, 0, 0, time.UTC), "2026-10-25 01:30", "BST", 1},
|
||||
{"autumn ambiguous->GMT", time.Date(2026, 10, 25, 1, 0, 0, 0, time.UTC), "2026-10-25 01:00", "GMT", 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
l := tc.utc.In(London)
|
||||
if got := l.Format("2006-01-02 15:04"); got != tc.wantWall {
|
||||
t.Errorf("%s UTC in London = %q, expected wall-clock %q", tc.utc.Format(time.RFC3339), got, tc.wantWall)
|
||||
}
|
||||
zone, off := l.Zone()
|
||||
if zone != tc.wantZone {
|
||||
t.Errorf("%s UTC in London zone = %q, expected %q", tc.utc.Format(time.RFC3339), zone, tc.wantZone)
|
||||
}
|
||||
if off != tc.wantOffsetH*3600 {
|
||||
t.Errorf("%s UTC in London offset = %ds, expected %dh", tc.utc.Format(time.RFC3339), off, tc.wantOffsetH)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Sanity: the two wall-clock times on the autumn transition day share the
|
||||
// same 01:xx wall hour (the ambiguous hour) but at different UTC instants —
|
||||
// proving Go resolves the fold to the second occurrence when converting the
|
||||
// UTC instant back to London.
|
||||
fold1 := time.Date(2026, 10, 25, 0, 30, 0, 0, time.UTC).In(London).Format("15:04") // 01:30 BST
|
||||
fold2 := time.Date(2026, 10, 25, 1, 30, 0, 0, time.UTC).In(London).Format("15:04") // 01:30 GMT
|
||||
if fold1 != "01:30" || fold2 != "01:30" {
|
||||
t.Errorf("expected both sides of the autumn fold to show 01:30, got %q and %q", fold1, fold2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNow_IsReasonable(t *testing.T) {
|
||||
|
||||
@@ -6,7 +6,10 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
@@ -27,6 +30,27 @@ func Connect() error {
|
||||
return err
|
||||
}
|
||||
poolCfg.ConnConfig.RuntimeParams["timezone"] = "UTC"
|
||||
// Scan TIMESTAMPTZ into time.Time in UTC, never the host's local timezone.
|
||||
// Without this, pgx scans into time.Local, so the JSON offset in API
|
||||
// responses silently depends on the deployment host's TZ (e.g. +01:00 on a
|
||||
// London host, Z on a UTC Docker host) — the instant is the same but the
|
||||
// documented "backend emits UTC" invariant would be violated.
|
||||
// The codec is registered as a NEW *Type rather than mutating the Type
|
||||
// returned by TypeForOID: every connection's type map shares the same
|
||||
// *Type pointers with the package default map, so mutating .Codec on the
|
||||
// shared Type would be a data race when concurrent connections establish.
|
||||
poolCfg.AfterConnect = func(_ context.Context, conn *pgx.Conn) error {
|
||||
tzType, ok := conn.TypeMap().TypeForOID(pgtype.TimestamptzOID)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
conn.TypeMap().RegisterType(&pgtype.Type{
|
||||
Codec: &pgtype.TimestamptzCodec{ScanLocation: time.UTC},
|
||||
Name: tzType.Name,
|
||||
OID: tzType.OID,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
pool, err := pgxpool.NewWithConfig(context.Background(), poolCfg)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -6,7 +6,10 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
@@ -27,6 +30,27 @@ func Connect() error {
|
||||
return err
|
||||
}
|
||||
poolCfg.ConnConfig.RuntimeParams["timezone"] = "UTC"
|
||||
// Scan TIMESTAMPTZ into time.Time in UTC, never the host's local timezone.
|
||||
// Without this, pgx scans into time.Local, so the JSON offset in API
|
||||
// responses silently depends on the deployment host's TZ (e.g. +01:00 on a
|
||||
// London host, Z on a UTC Docker host) — the instant is the same but the
|
||||
// documented "backend emits UTC" invariant would be violated.
|
||||
// The codec is registered as a NEW *Type rather than mutating the Type
|
||||
// returned by TypeForOID: every connection's type map shares the same
|
||||
// *Type pointers with the package default map, so mutating .Codec on the
|
||||
// shared Type would be a data race when concurrent connections establish.
|
||||
poolCfg.AfterConnect = func(_ context.Context, conn *pgx.Conn) error {
|
||||
tzType, ok := conn.TypeMap().TypeForOID(pgtype.TimestamptzOID)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
conn.TypeMap().RegisterType(&pgtype.Type{
|
||||
Codec: &pgtype.TimestamptzCodec{ScanLocation: time.UTC},
|
||||
Name: tzType.Name,
|
||||
OID: tzType.OID,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
pool, err := pgxpool.NewWithConfig(context.Background(), poolCfg)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func resetEnv() {
|
||||
@@ -71,6 +72,42 @@ func TestConnect_PingViaTestDB(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestScanLocation_UTC proves the AfterConnect hook: TIMESTAMPTZ values are
|
||||
// scanned into time.Time in UTC, never the host's local timezone. Without the
|
||||
// hook, pgx scans into time.Local, so a London-host dev server would emit
|
||||
// +01:00 JSON offsets while a UTC Docker host emits Z — same instant, but the
|
||||
// documented "backend emits UTC" invariant would silently depend on the
|
||||
// deployment host's TZ.
|
||||
func TestScanLocation_UTC(t *testing.T) {
|
||||
closePool()
|
||||
resetEnv()
|
||||
|
||||
err := Connect()
|
||||
if err != nil {
|
||||
t.Fatalf("Connect() failed: %v", err)
|
||||
}
|
||||
defer closePool()
|
||||
|
||||
poolConn, err := Conn.Acquire(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Acquire failed: %v", err)
|
||||
}
|
||||
defer poolConn.Release()
|
||||
|
||||
// A known instant: 2026-06-15 00:30 BST = 2026-06-14 23:30 UTC.
|
||||
instant := time.Date(2026, 6, 14, 23, 30, 0, 0, time.UTC)
|
||||
var scanned time.Time
|
||||
if err := poolConn.QueryRow(context.Background(), "SELECT $1::timestamptz", instant).Scan(&scanned); err != nil {
|
||||
t.Fatalf("timestamptz scan failed: %v", err)
|
||||
}
|
||||
if !scanned.Equal(instant) {
|
||||
t.Errorf("scanned instant = %s, expected %s", scanned.Format(time.RFC3339Nano), instant.Format(time.RFC3339Nano))
|
||||
}
|
||||
if scanned.Location() != time.UTC {
|
||||
t.Errorf("scanned time.Time location = %v, expected time.UTC (host TZ is %v)", scanned.Location(), time.Local)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Connection failure scenarios
|
||||
// =============================================================================
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//go:build test && dev
|
||||
|
||||
package bookings
|
||||
|
||||
// Strict timezone/DST/midnight-boundary tests for the booking closing-hours
|
||||
// pipeline and the date-window queries. All assertions use fixed time.Date
|
||||
// instants (no wall-clock "now" drift) so they are deterministic on every run.
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/clock"
|
||||
"crussell/testutils"
|
||||
"crussell/testutils/fixtures"
|
||||
"crussell/testutils/jwt"
|
||||
)
|
||||
|
||||
// TestCheckClosingHours_GMTvsBST proves the closing-time pipeline is
|
||||
// DST-safe: the same 16:30 wall-clock end is accepted and the same 17:30
|
||||
// wall-clock end is rejected during BOTH a GMT season (2026-01-15, offset +0)
|
||||
// and a BST season (2026-06-15, offset +1). The same wall-clock instant maps
|
||||
// to UTC instants that differ by exactly the 1h DST offset between seasons —
|
||||
// the conversion via londonLocation must absorb that shift.
|
||||
func TestCheckClosingHours_GMTvsBST(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
// 2026-01-15 is a Thursday (DB weekday 3), 2026-06-15 is a Monday (DB
|
||||
// weekday 0). Pin closing at 17:00 on both so the resolution goes through
|
||||
// the real getClosingTimeForDate -> working_hours path.
|
||||
for _, wd := range []int{0, 3} {
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO working_hours (weekday, start_time, end_time, is_open)
|
||||
VALUES ($1, '08:00', '17:00', true)
|
||||
ON CONFLICT (weekday) DO UPDATE SET start_time = '08:00', end_time = '17:00', is_open = true
|
||||
`, wd)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set working hours for weekday %d: %v", wd, err)
|
||||
}
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
localEnd time.Time // Europe/London wall-clock booking end
|
||||
wantUTC int // expected UTC hour of that same instant
|
||||
}{
|
||||
{"GMT 2026-01-15", time.Date(2026, 1, 15, 16, 30, 0, 0, clock.London), 16},
|
||||
{"BST 2026-06-15", time.Date(2026, 6, 15, 16, 30, 0, 0, clock.London), 15},
|
||||
}
|
||||
|
||||
// The SAME 16:30 wall-clock end is 16:30 UTC in GMT and 15:30 UTC in BST —
|
||||
// a shift of exactly 1h (the DST offset).
|
||||
if diff := cases[0].localEnd.UTC().Hour() - cases[1].localEnd.UTC().Hour(); diff != 1 {
|
||||
t.Fatalf("expected the two 16:30-local ends to differ by exactly 1h in UTC, got %dh", diff)
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name+" pass 16:30", func(t *testing.T) {
|
||||
weekday := int((tc.localEnd.Weekday() + 6) % 7)
|
||||
closeStr, err := getClosingTimeForDate(ctx, tx, weekday, tc.localEnd)
|
||||
if err != nil {
|
||||
t.Fatalf("getClosingTimeForDate failed: %v", err)
|
||||
}
|
||||
// The DB stores end_time as TIME so ::text returns "17:00:00".
|
||||
if closeStr != "17:00" && closeStr != "17:00:00" {
|
||||
t.Fatalf("expected closing 17:00 for %s, got %q", tc.name, closeStr)
|
||||
}
|
||||
if err := checkClosingHours(tc.localEnd, closeStr); err != nil {
|
||||
t.Errorf("16:30 local end on %s must pass closing 17:00, got: %v", tc.name, err)
|
||||
}
|
||||
if got := tc.localEnd.UTC().Hour(); got != tc.wantUTC {
|
||||
t.Errorf("16:30 local on %s should be %02d:30 UTC, got hour %d", tc.name, tc.wantUTC, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
failCases := []struct {
|
||||
name string
|
||||
localEnd time.Time
|
||||
}{
|
||||
{"GMT 2026-01-15", time.Date(2026, 1, 15, 17, 30, 0, 0, clock.London)},
|
||||
{"BST 2026-06-15", time.Date(2026, 6, 15, 17, 30, 0, 0, clock.London)},
|
||||
}
|
||||
for _, tc := range failCases {
|
||||
t.Run(tc.name+" fail 17:30", func(t *testing.T) {
|
||||
if err := checkClosingHours(tc.localEnd, "17:00"); !IsPastClosing(err) {
|
||||
t.Errorf("17:30 local end on %s must be rejected, got: %v", tc.name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBookingDateWindow_DSTMidnight proves the booking date-window queries
|
||||
// (bookings.go GetAllUserBookingsHandler start_date/end_date conversion) bucket
|
||||
// bookings by their LONDON calendar date, not their UTC date. A booking at
|
||||
// 2026-06-14 23:30 UTC is 2026-06-15 00:30 BST — inside the 00:00-01:00 BST
|
||||
// window where the UTC date differs from the London date — and must appear in
|
||||
// the "2026-06-15" window, not "2026-06-14".
|
||||
func TestBookingDateWindow_DSTMidnight(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
serviceID, err := fixtures.CreateTestService(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create service: %v", err)
|
||||
}
|
||||
|
||||
// Precondition: 2026-06-14 23:30 UTC must be 2026-06-15 00:30 BST (the
|
||||
// UTC-date != London-date window). This is the exact property under test.
|
||||
boundary := time.Date(2026, 6, 14, 23, 30, 0, 0, time.UTC)
|
||||
if got := boundary.In(clock.London).Format("2006-01-02"); got != "2026-06-15" {
|
||||
t.Fatalf("test setup invariant: 2026-06-14 23:30 UTC must be London date 2026-06-15, got %s", got)
|
||||
}
|
||||
|
||||
// Booking A: the midnight-boundary booking (00:30 BST on the 15th).
|
||||
bookingA, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, boundary)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create boundary booking: %v", err)
|
||||
}
|
||||
// Booking B: 22:30 UTC on the 14th = 23:30 BST on the 14th (clearly the 14th).
|
||||
bookingB, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Date(2026, 6, 14, 22, 30, 0, 0, time.UTC))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create day-before booking: %v", err)
|
||||
}
|
||||
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
// Window 2026-06-15: only the boundary booking belongs (00:30 BST on the
|
||||
// 15th); the 22:30-UTC booking is 23:30 BST on the 14th.
|
||||
w := makeRequest(http.HandlerFunc(GetAllUserBookingsHandler), "GET",
|
||||
"/api/bookings?start_date=2026-06-15&end_date=2026-06-15", nil, token, ctx)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp BookingListResponse
|
||||
if err := parseResponseBody(w, &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if resp.Total != 1 || len(resp.Bookings) != 1 || resp.Bookings[0].ID != bookingA {
|
||||
t.Errorf("window 2026-06-15: expected only booking A (%s) at 00:30 BST, got total=%d ids=%v",
|
||||
bookingA, resp.Total, bookingIDs(resp.Bookings))
|
||||
}
|
||||
|
||||
// Window 2026-06-14: only booking B belongs; the 23:30-UTC boundary booking
|
||||
// has already rolled over to London date 2026-06-15.
|
||||
w2 := makeRequest(http.HandlerFunc(GetAllUserBookingsHandler), "GET",
|
||||
"/api/bookings?start_date=2026-06-14&end_date=2026-06-14", nil, token, ctx)
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
var resp2 BookingListResponse
|
||||
if err := parseResponseBody(w2, &resp2); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if resp2.Total != 1 || len(resp2.Bookings) != 1 || resp2.Bookings[0].ID != bookingB {
|
||||
t.Errorf("window 2026-06-14: expected only booking B (%s), got total=%d ids=%v",
|
||||
bookingB, resp2.Total, bookingIDs(resp2.Bookings))
|
||||
}
|
||||
}
|
||||
|
||||
func bookingIDs(bookings []Booking) []string {
|
||||
ids := make([]string, len(bookings))
|
||||
for i, b := range bookings {
|
||||
ids[i] = b.ID
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
//go:build test && dev
|
||||
|
||||
package payments
|
||||
|
||||
// Strict timezone/DST tests for the payment refund tiers and the deposit
|
||||
// protection window. All assertions use fixed time.Date instants.
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/clock"
|
||||
)
|
||||
|
||||
// TestRefundTiers_TimezoneIndependent proves refund tiers depend only on the
|
||||
// ELAPSED time between cancellation and start (startTime.Sub(cancellationTime)
|
||||
// .Hours()), never on wall-clock days or the London calendar date. Two bookings
|
||||
// in different seasons — 2026-01-20 10:00 UTC (GMT) and 2026-06-20 10:00 UTC
|
||||
// (BST) — cancelled with identical notice must produce identical results for
|
||||
// every tier.
|
||||
func TestRefundTiers_TimezoneIndependent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gmtStart := time.Date(2026, 1, 20, 10, 0, 0, 0, time.UTC)
|
||||
bstStart := time.Date(2026, 6, 20, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
// Precondition: the two starts are genuinely in different seasons — same
|
||||
// UTC instant-of-day but different London wall-clock zones.
|
||||
if gz, _ := gmtStart.In(clock.London).Zone(); gz != "GMT" {
|
||||
t.Fatalf("expected 2026-01-20 in London to be GMT, got %q", gz)
|
||||
}
|
||||
if bz, _ := bstStart.In(clock.London).Zone(); bz != "BST" {
|
||||
t.Fatalf("expected 2026-06-20 in London to be BST, got %q", bz)
|
||||
}
|
||||
|
||||
notices := []struct {
|
||||
name string
|
||||
hours float64
|
||||
want string
|
||||
}{
|
||||
{"over 72h -> full refund", 72.5, FullRefundTier},
|
||||
{"at 72h -> partial (strict > boundary)", 72, PartialRefundTier},
|
||||
{"48h -> partial refund", 48, PartialRefundTier},
|
||||
{"at 24h -> partial refund", 24, PartialRefundTier},
|
||||
{"under 24h -> no refund", 23.5, NoRefundTier},
|
||||
}
|
||||
|
||||
for _, tc := range notices {
|
||||
noticeDur := time.Duration(tc.hours * float64(time.Hour))
|
||||
cancellationGmt := gmtStart.Add(-noticeDur)
|
||||
cancellationBst := bstStart.Add(-noticeDur)
|
||||
|
||||
gmtRes := CalculateRefundForCancellation(100, 50, cancellationGmt, gmtStart)
|
||||
bstRes := CalculateRefundForCancellation(100, 50, cancellationBst, bstStart)
|
||||
|
||||
if gmtRes.Tier != tc.want || bstRes.Tier != tc.want {
|
||||
t.Errorf("%s: want tier %q, got GMT=%q BST=%q", tc.name, tc.want, gmtRes.Tier, bstRes.Tier)
|
||||
}
|
||||
// Elapsed-hours logic is DST-safe: identical inputs across seasons
|
||||
// produce byte-for-byte identical outputs.
|
||||
if gmtRes != bstRes {
|
||||
t.Errorf("%s: GMT and BST bookings with identical notice produced different refunds: GMT=%+v BST=%+v",
|
||||
tc.name, gmtRes, bstRes)
|
||||
}
|
||||
if gmtRes.HoursUntilAppointment != tc.hours {
|
||||
t.Errorf("%s: expected %.1f elapsed hours, got %.1f", tc.name, tc.hours, gmtRes.HoursUntilAppointment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDepositProtectionWindow_UTCInstant proves buildSplitRecords decides
|
||||
// "after booking starts" by an ABSOLUTE UTC-instant comparison
|
||||
// (clock.Now().After(info.StartTime)), never by the London calendar date. A
|
||||
// booking at 2026-06-15 00:30 BST == 2026-06-14 23:30 UTC — where the UTC date
|
||||
// and the London date disagree — is classified purely by its UTC instant.
|
||||
func TestDepositProtectionWindow_UTCInstant(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// 2026-06-14 23:30 UTC = 2026-06-15 00:30 BST: the 00:00-01:00 BST window
|
||||
// where the UTC date (2026-06-14) is the day before the London date.
|
||||
boundaryUTC := time.Date(2026, 6, 14, 23, 30, 0, 0, time.UTC)
|
||||
if got := boundaryUTC.In(clock.London).Format("2006-01-02"); got != "2026-06-15" {
|
||||
t.Fatalf("test setup invariant: 2026-06-14 23:30 UTC must be London 2026-06-15 00:30 BST, got %s", got)
|
||||
}
|
||||
if got := boundaryUTC.Format("2006-01-02"); got != "2026-06-14" {
|
||||
t.Fatalf("test setup invariant: boundary must remain UTC date 2026-06-14, got %s", got)
|
||||
}
|
||||
|
||||
// This instant is in the past -> the booking has started -> no deposit
|
||||
// protection window -> a single unsplit record.
|
||||
record := makeTestRecord("b-boundary-past", "full", 50)
|
||||
info := &BookingPaymentInfo{StartTime: boundaryUTC, TotalAmount: 50, TotalPaid: 0}
|
||||
records := buildSplitRecords(record, "full", info, 50)
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("past BST-midnight booking: expected 1 record (no split), got %d", len(records))
|
||||
}
|
||||
if records[0].PaymentType != "full" {
|
||||
t.Errorf("past BST-midnight booking: expected single 'full' record, got %q", records[0].PaymentType)
|
||||
}
|
||||
|
||||
// Mirror case with the IDENTICAL UTC-date != London-date property but in the
|
||||
// future (2099-06-14 23:30 UTC = 2099-06-15 00:30 BST): the booking has NOT
|
||||
// started, so the deposit split must happen. Only the UTC instant differs
|
||||
// from the case above — the London date plays no role.
|
||||
futureBoundary := time.Date(2099, 6, 14, 23, 30, 0, 0, time.UTC)
|
||||
if got := futureBoundary.In(clock.London).Format("2006-01-02"); got != "2099-06-15" {
|
||||
t.Fatalf("test setup invariant: 2099-06-14 23:30 UTC must be London 2099-06-15, got %s", got)
|
||||
}
|
||||
|
||||
record2 := makeTestRecord("b-boundary-future", "full", 50)
|
||||
info2 := &BookingPaymentInfo{StartTime: futureBoundary, TotalAmount: 50, TotalPaid: 0}
|
||||
records2 := buildSplitRecords(record2, "full", info2, 50)
|
||||
if len(records2) != 2 {
|
||||
t.Fatalf("future BST-midnight booking: expected 2 records (deposit split), got %d", len(records2))
|
||||
}
|
||||
if records2[0].PaymentType != "deposit" || records2[0].Amount != 25 {
|
||||
t.Errorf("future BST-midnight booking: expected first record deposit £25, got %q £%.2f",
|
||||
records2[0].PaymentType, records2[0].Amount)
|
||||
}
|
||||
if records2[1].PaymentType != "balance" || records2[1].Amount != 25 {
|
||||
t.Errorf("future BST-midnight booking: expected second record balance £25, got %q £%.2f",
|
||||
records2[1].PaymentType, records2[1].Amount)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -25,6 +26,31 @@ import (
|
||||
|
||||
// --- Types ---
|
||||
|
||||
// defaultGiftCardExpiryMonths is the CMA-recommended rolling expiry window used
|
||||
// whenever business_settings.gift_card_expiry_months is unset or invalid (< 1).
|
||||
const defaultGiftCardExpiryMonths = 24
|
||||
|
||||
// GetGiftCardExpiryMonths returns the configured gift-card expiry window in
|
||||
// months — the SINGLE source of truth for rolling expiry. It reads
|
||||
// business_settings.gift_card_expiry_months and falls back to
|
||||
// defaultGiftCardExpiryMonths when the settings row is missing (pgx.ErrNoRows)
|
||||
// or the stored value is < 1 (a sub-1-month window would make every card
|
||||
// effectively expired on creation). Exported so the scheduling package's
|
||||
// CleanupExpiredGiftCards job and the payment handlers share one implementation.
|
||||
func GetGiftCardExpiryMonths(ctx context.Context, q db.Querier) (int, error) {
|
||||
var months int
|
||||
if err := q.QueryRow(ctx, `SELECT gift_card_expiry_months FROM business_settings LIMIT 1`).Scan(&months); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return defaultGiftCardExpiryMonths, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
if months < 1 {
|
||||
return defaultGiftCardExpiryMonths, nil
|
||||
}
|
||||
return months, nil
|
||||
}
|
||||
|
||||
type GiftCard struct {
|
||||
ID string `json:"id"`
|
||||
TotalFundsAdded float64 `json:"total_funds_added"`
|
||||
@@ -385,11 +411,16 @@ func CreateGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
if purchaseVoucherType == "" {
|
||||
purchaseVoucherType = "SPV"
|
||||
}
|
||||
expiryMonths, err := GetGiftCardExpiryMonths(ctx, tx)
|
||||
if err != nil {
|
||||
log.Printf("Failed to query gift card expiry months (using default %d): %v", defaultGiftCardExpiryMonths, err)
|
||||
expiryMonths = defaultGiftCardExpiryMonths
|
||||
}
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, last_used_at, voucher_type_at_purchase)
|
||||
VALUES ($1, $1, $2, $3, NOW(), $4)
|
||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, last_used_at, expiry_date, voucher_type_at_purchase)
|
||||
VALUES ($1, $1, $2, $3, NOW(), NOW() + ($5 * INTERVAL '1 month'), $4)
|
||||
RETURNING id, total_funds_added, amount_remaining, created_by, created_at, is_inventory, last_used_at
|
||||
`, req.Amount, adminID, req.IsInventory, purchaseVoucherType).Scan(
|
||||
`, req.Amount, adminID, req.IsInventory, purchaseVoucherType, expiryMonths).Scan(
|
||||
&gc.ID,
|
||||
&gc.TotalFundsAdded,
|
||||
&gc.AmountRemaining,
|
||||
@@ -501,6 +532,12 @@ func TopUpGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
expiryMonths, err := GetGiftCardExpiryMonths(ctx, tx)
|
||||
if err != nil {
|
||||
log.Printf("Failed to query gift card expiry months (using default %d): %v", defaultGiftCardExpiryMonths, err)
|
||||
expiryMonths = defaultGiftCardExpiryMonths
|
||||
}
|
||||
|
||||
txType := "topup"
|
||||
var notes *string
|
||||
if isInventory && currentTotalFunds == 0 {
|
||||
@@ -515,10 +552,11 @@ func TopUpGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
UPDATE gift_cards
|
||||
SET total_funds_added = total_funds_added + $1,
|
||||
amount_remaining = amount_remaining + $1,
|
||||
last_used_at = NOW()
|
||||
last_used_at = NOW(),
|
||||
expiry_date = NOW() + ($3 * INTERVAL '1 month')
|
||||
WHERE id = $2
|
||||
RETURNING id, total_funds_added, amount_remaining, created_by, created_at
|
||||
`, req.Amount, cardID).Scan(
|
||||
`, req.Amount, cardID, expiryMonths).Scan(
|
||||
&gc.ID,
|
||||
&gc.TotalFundsAdded,
|
||||
&gc.AmountRemaining,
|
||||
@@ -665,12 +703,21 @@ func TransferGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// A transfer is a "use" of BOTH cards per the rolling-expiry terms — each
|
||||
// card's timer resets at the same moment its balance moves.
|
||||
expiryMonths, err := GetGiftCardExpiryMonths(ctx, tx)
|
||||
if err != nil {
|
||||
log.Printf("Failed to query gift card expiry months (using default %d): %v", defaultGiftCardExpiryMonths, err)
|
||||
expiryMonths = defaultGiftCardExpiryMonths
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE gift_cards
|
||||
SET amount_remaining = amount_remaining - $1,
|
||||
last_used_at = NOW()
|
||||
last_used_at = NOW(),
|
||||
expiry_date = NOW() + ($3 * INTERVAL '1 month')
|
||||
WHERE id = $2
|
||||
`, req.Amount, fromCardID)
|
||||
`, req.Amount, fromCardID, expiryMonths)
|
||||
if err != nil {
|
||||
log.Printf("Failed to deduct from source: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
@@ -681,9 +728,10 @@ func TransferGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
UPDATE gift_cards
|
||||
SET amount_remaining = amount_remaining + $1,
|
||||
total_funds_added = total_funds_added + $1,
|
||||
last_used_at = NOW()
|
||||
last_used_at = NOW(),
|
||||
expiry_date = NOW() + ($3 * INTERVAL '1 month')
|
||||
WHERE id = $2
|
||||
`, req.Amount, req.ToCardID)
|
||||
`, req.Amount, req.ToCardID, expiryMonths)
|
||||
if err != nil {
|
||||
log.Printf("Failed to add to destination: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
@@ -764,13 +812,21 @@ func RedeemGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
expiryMonths, err := GetGiftCardExpiryMonths(ctx, tx)
|
||||
if err != nil {
|
||||
log.Printf("Failed to query gift card expiry months (using default %d): %v", defaultGiftCardExpiryMonths, err)
|
||||
expiryMonths = defaultGiftCardExpiryMonths
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE gift_cards
|
||||
SET amount_remaining = 0,
|
||||
redeemed_at = NOW(),
|
||||
redeemed_by = $1
|
||||
redeemed_by = $1,
|
||||
last_used_at = NOW(),
|
||||
expiry_date = NOW() + ($3 * INTERVAL '1 month')
|
||||
WHERE id = $2
|
||||
`, userID, code)
|
||||
`, userID, code, expiryMonths)
|
||||
if err != nil {
|
||||
log.Printf("Failed to update gift card: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
@@ -1165,6 +1221,12 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var cardID string
|
||||
|
||||
expiryMonths, err := GetGiftCardExpiryMonths(ctx, issueTx)
|
||||
if err != nil {
|
||||
log.Printf("Failed to query gift card expiry months (using default %d): %v", defaultGiftCardExpiryMonths, err)
|
||||
expiryMonths = defaultGiftCardExpiryMonths
|
||||
}
|
||||
|
||||
if req.RecipientType == "self" {
|
||||
var purchaseVoucherType string
|
||||
err = issueTx.QueryRow(ctx, `SELECT COALESCE(voucher_type, 'SPV') FROM business_settings LIMIT 1`).Scan(&purchaseVoucherType)
|
||||
@@ -1177,10 +1239,10 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
purchaseVoucherType = "SPV"
|
||||
}
|
||||
err = issueTx.QueryRow(ctx, `
|
||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, redeemed_at, redeemed_by, is_inventory, voucher_type_at_purchase)
|
||||
VALUES ($1, 0, $2, NOW(), $2, FALSE, $3)
|
||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, redeemed_at, redeemed_by, is_inventory, last_used_at, expiry_date, voucher_type_at_purchase)
|
||||
VALUES ($1, 0, $2, NOW(), $2, FALSE, NOW(), NOW() + ($4 * INTERVAL '1 month'), $3)
|
||||
RETURNING id
|
||||
`, amountPounds, userID, purchaseVoucherType).Scan(&cardID)
|
||||
`, amountPounds, userID, purchaseVoucherType, expiryMonths).Scan(&cardID)
|
||||
if err != nil {
|
||||
log.Printf("CRITICAL: Square payment succeeded (ID=%s) but gift card creation failed: %v — manual reconciliation required", paymentResult.SquarePayID, err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
@@ -1221,10 +1283,10 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
purchaseVoucherType = "SPV"
|
||||
}
|
||||
err = issueTx.QueryRow(ctx, `
|
||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
|
||||
VALUES ($1, $1, $2, FALSE, $3)
|
||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, last_used_at, expiry_date, voucher_type_at_purchase)
|
||||
VALUES ($1, $1, $2, FALSE, NOW(), NOW() + ($4 * INTERVAL '1 month'), $3)
|
||||
RETURNING id
|
||||
`, amountPounds, userID, purchaseVoucherType).Scan(&cardID)
|
||||
`, amountPounds, userID, purchaseVoucherType, expiryMonths).Scan(&cardID)
|
||||
if err != nil {
|
||||
log.Printf("CRITICAL: Square payment succeeded (ID=%s) but gift card creation failed: %v — manual reconciliation required", paymentResult.SquarePayID, err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
|
||||
@@ -1264,9 +1264,12 @@ func TestBuyGiftCard_RetryPending_ReattemptsCharge(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminCreateGiftCard_ExpiryDateIsNull verifies that gift cards created via
|
||||
// CreateGiftCard no longer have expiry_date set (rolling 24-month expiry via last_used_at).
|
||||
func TestAdminCreateGiftCard_ExpiryDateIsNull(t *testing.T) {
|
||||
// TestAdminCreateGiftCard_SetsRollingExpiry verifies that gift cards created via
|
||||
// CreateGiftCard get an expiry_date of last_used_at + gift_card_expiry_months
|
||||
// (the configured rolling-expiry window, default 24 months). The test DB seeds
|
||||
// business_settings.gift_card_expiry_months = 24, so the expiry must be ~24
|
||||
// months in the future.
|
||||
func TestAdminCreateGiftCard_SetsRollingExpiry(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
@@ -1299,14 +1302,25 @@ func TestAdminCreateGiftCard_ExpiryDateIsNull(t *testing.T) {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
// Verify expiry_date is NULL in the database
|
||||
// Verify expiry_date IS set to last_used_at + the configured window (24mo).
|
||||
var expiryDate *time.Time
|
||||
err = tx.QueryRow(ctx, `SELECT expiry_date FROM gift_cards WHERE id = $1`, gc.ID).Scan(&expiryDate)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query gift card expiry_date: %v", err)
|
||||
}
|
||||
if expiryDate != nil {
|
||||
t.Error("expected expiry_date to be NULL for rolling-expiry gift cards")
|
||||
if expiryDate == nil {
|
||||
t.Fatal("expected expiry_date to be set for rolling-expiry gift cards")
|
||||
}
|
||||
// The test DB seeds gift_card_expiry_months = 24; the SQL computes
|
||||
// NOW() + (24 * INTERVAL '1 month') = exactly +24 calendar months, so
|
||||
// compare against AddDate(0, 24, 0) with slack for clock skew.
|
||||
now := time.Now().UTC()
|
||||
want := now.AddDate(0, 24, 0)
|
||||
if expiryDate.Before(want.Add(-24 * time.Hour)) {
|
||||
t.Errorf("expected expiry_date ~24 calendar months in the future, got %v (now %v)", expiryDate, now)
|
||||
}
|
||||
if expiryDate.After(want.Add(24 * time.Hour)) {
|
||||
t.Errorf("expected expiry_date ~24 calendar months in the future, got %v (now %v)", expiryDate, now)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -423,8 +423,14 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
|
||||
cardVoucherType = "SPV"
|
||||
}
|
||||
|
||||
// Deduct directly from card remaining amount
|
||||
_, err = tx.Exec(r.Context(), "UPDATE gift_cards SET amount_remaining = amount_remaining - $1, last_used_at = NOW() WHERE id = $2", amountPounds, cleanCardID)
|
||||
// Deduct directly from card remaining amount. A payment is a
|
||||
// "use" per the rolling-expiry terms — reset the timer.
|
||||
gcExpiryMonths, expiryErr := GetGiftCardExpiryMonths(r.Context(), tx)
|
||||
if expiryErr != nil {
|
||||
log.Printf("Failed to query gift card expiry months (using default %d): %v", defaultGiftCardExpiryMonths, expiryErr)
|
||||
gcExpiryMonths = defaultGiftCardExpiryMonths
|
||||
}
|
||||
_, err = tx.Exec(r.Context(), "UPDATE gift_cards SET amount_remaining = amount_remaining - $1, last_used_at = NOW(), expiry_date = NOW() + ($3 * INTERVAL '1 month') WHERE id = $2", amountPounds, cleanCardID, gcExpiryMonths)
|
||||
if err != nil {
|
||||
log.Printf("Failed to deduct gift card amount: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
|
||||
@@ -283,6 +283,11 @@ func ProcessCancellationRefundTx(
|
||||
log.Printf("Giftcard payment %s has no gift_card_id — cannot refund to card. Skipping.", paymentID)
|
||||
break
|
||||
}
|
||||
// expiry_date is maintained by EVERY gift-card write that counts as
|
||||
// a "use" (balance check, top-up, transfer, redeem, payment, refund
|
||||
// credit), so a non-NULL expiry_date means the rolling timer is
|
||||
// authoritative. NULL semantics stay fail-open: an unset expiry_date
|
||||
// cannot prove the card is expired, so the refund proceeds.
|
||||
var expired bool
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT expiry_date IS NOT NULL AND expiry_date < NOW()
|
||||
@@ -293,10 +298,17 @@ func ProcessCancellationRefundTx(
|
||||
log.Printf("Gift card %s has expired — money retained by salon, no refund due for booking %s", *giftCardID, bookingID)
|
||||
break
|
||||
}
|
||||
// Refunding to the card is a "use" per the rolling-expiry terms —
|
||||
// reset the timer at the same moment the balance is credited.
|
||||
gcExpiryMonths, expiryErr := GetGiftCardExpiryMonths(ctx, tx)
|
||||
if expiryErr != nil {
|
||||
log.Printf("Failed to query gift card expiry months (using default %d): %v", defaultGiftCardExpiryMonths, expiryErr)
|
||||
gcExpiryMonths = defaultGiftCardExpiryMonths
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE gift_cards SET amount_remaining = amount_remaining + $1, last_used_at = NOW()
|
||||
UPDATE gift_cards SET amount_remaining = amount_remaining + $1, last_used_at = NOW(), expiry_date = NOW() + ($3 * INTERVAL '1 month')
|
||||
WHERE id = $2
|
||||
`, refundThisPayment, *giftCardID); err != nil {
|
||||
`, refundThisPayment, *giftCardID, gcExpiryMonths); err != nil {
|
||||
log.Printf("Failed to refund £%.2f to gift card %s: %v", refundThisPayment, *giftCardID, err)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -455,6 +455,84 @@ func TestProcessCancellationRefund_GiftCardCreditsUserBalance(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessCancellationRefund_ExpiredGiftCard_Retained verifies the
|
||||
// refunds.go expiry guard: when the payment was made with an EXPIRED gift card
|
||||
// (expiry_date in the past), the cancellation refund must NOT credit the card —
|
||||
// the money is retained by the salon. Regression for the previously-dead
|
||||
// `expiry_date IS NOT NULL AND expiry_date < NOW()` check, which never fired
|
||||
// because no write path populated expiry_date.
|
||||
func TestProcessCancellationRefund_ExpiredGiftCard_Retained(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
serviceID, err := fixtures.CreateTestService(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create service: %v", err)
|
||||
}
|
||||
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
||||
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create booking: %v", err)
|
||||
}
|
||||
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to confirm booking: %v", err)
|
||||
}
|
||||
|
||||
// Expired card: balance 40, expiry_date 1 day in the past, last used 25mo ago.
|
||||
var giftCardID string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, expiry_date, last_used_at)
|
||||
VALUES (100, 40, $1, false, NOW() - INTERVAL '1 day', NOW() - INTERVAL '25 months')
|
||||
RETURNING id
|
||||
`, userID).Scan(&giftCardID); err != nil {
|
||||
t.Fatalf("failed to create expired gift card: %v", err)
|
||||
}
|
||||
|
||||
// Payment made WITH the expired card (this can happen for legacy cards that
|
||||
// were still spendable before the expiry_date write paths were wired up).
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, gift_card_id, created_at, updated_at)
|
||||
VALUES ($1, 'full', 'giftcard', 'completed', 60, $2, NOW(), NOW())
|
||||
`, bookingID, giftCardID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create giftcard payment: %v", err)
|
||||
}
|
||||
|
||||
farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
||||
_, err = ProcessCancellationRefund(
|
||||
ctx, bookingID, 100, 60,
|
||||
farFuture, clock.Now(), "client_cancelled", &userID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessCancellationRefund failed: %v", err)
|
||||
}
|
||||
|
||||
// The expired-card guard must retain the money: balance unchanged at 40
|
||||
// (NOT credited +60), and no refund-to-card transaction recorded.
|
||||
var amountRemaining float64
|
||||
if err := tx.QueryRow(ctx,
|
||||
"SELECT amount_remaining FROM gift_cards WHERE id = $1", giftCardID).Scan(&amountRemaining); err != nil {
|
||||
t.Fatalf("failed to query gift card balance: %v", err)
|
||||
}
|
||||
if amountRemaining != 40 {
|
||||
t.Errorf("expired card must NOT be credited: expected amount_remaining 40, got %.2f", amountRemaining)
|
||||
}
|
||||
|
||||
var txCount int
|
||||
if err := tx.QueryRow(ctx,
|
||||
"SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1 AND transaction_type = 'refund'", giftCardID).Scan(&txCount); err != nil {
|
||||
t.Fatalf("failed to query gift card transactions: %v", err)
|
||||
}
|
||||
if txCount != 0 {
|
||||
t.Errorf("expected NO refund-to-expired-card transaction, got %d", txCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCancellationRefund_CashCreditsUserBalance(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
@@ -411,6 +411,11 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
||||
if existingPendingID != "" {
|
||||
giftCardID = existingPendingGiftCard
|
||||
} else {
|
||||
expiryMonths, expiryErr := GetGiftCardExpiryMonths(ctx, tx)
|
||||
if expiryErr != nil {
|
||||
log.Printf("Failed to query gift card expiry months (using default %d): %v", defaultGiftCardExpiryMonths, expiryErr)
|
||||
expiryMonths = defaultGiftCardExpiryMonths
|
||||
}
|
||||
if req.Action == "create" {
|
||||
var purchaseVoucherType string
|
||||
err = tx.QueryRow(ctx, `SELECT COALESCE(voucher_type, 'SPV') FROM business_settings LIMIT 1`).Scan(&purchaseVoucherType)
|
||||
@@ -423,10 +428,10 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
||||
purchaseVoucherType = "SPV"
|
||||
}
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
|
||||
VALUES ($1, $1, $2, FALSE, $3)
|
||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, last_used_at, expiry_date, voucher_type_at_purchase)
|
||||
VALUES ($1, $1, $2, FALSE, NOW(), NOW() + ($4 * INTERVAL '1 month'), $3)
|
||||
RETURNING id
|
||||
`, req.Amount, adminID, purchaseVoucherType).Scan(&giftCardID)
|
||||
`, req.Amount, adminID, purchaseVoucherType, expiryMonths).Scan(&giftCardID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create gift card: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
@@ -454,9 +459,10 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
||||
SET amount_remaining = 0,
|
||||
redeemed_at = NOW(),
|
||||
redeemed_by = $1,
|
||||
last_used_at = NOW()
|
||||
last_used_at = NOW(),
|
||||
expiry_date = NOW() + ($3 * INTERVAL '1 month')
|
||||
WHERE id = $2
|
||||
`, *req.RedeemToUserID, giftCardID)
|
||||
`, *req.RedeemToUserID, giftCardID, expiryMonths)
|
||||
if err != nil {
|
||||
log.Printf("Failed to redeem gift card to user account: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
@@ -507,9 +513,10 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
||||
UPDATE gift_cards
|
||||
SET total_funds_added = total_funds_added + $1,
|
||||
amount_remaining = amount_remaining + $1,
|
||||
last_used_at = NOW()
|
||||
last_used_at = NOW(),
|
||||
expiry_date = NOW() + ($3 * INTERVAL '1 month')
|
||||
WHERE id = $2
|
||||
`, req.Amount, cardID)
|
||||
`, req.Amount, cardID, expiryMonths)
|
||||
if err != nil {
|
||||
log.Printf("Failed to top up gift card: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
//go:build test
|
||||
|
||||
package scheduling
|
||||
|
||||
// Strict timezone/DST/midnight-boundary tests for the scheduling package's
|
||||
// London-date derivation. All assertions use fixed time.Date instants.
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/clock"
|
||||
)
|
||||
|
||||
// TestScheduling_DefaultHours_DST_Midnight proves ApplyScheduledDefaultHours's
|
||||
// "today" date (derived via LondonDateString) is the LONDON calendar date, not
|
||||
// the UTC date. During BST, London dates begin at 23:00 UTC the previous day:
|
||||
// both 2026-06-14 23:30 UTC (= 2026-06-15 00:30 BST) and 2026-06-15 00:30 UTC
|
||||
// (= 2026-06-15 01:30 BST) fall on London date 2026-06-15 — a naive UTC date
|
||||
// would report 2026-06-14 for the first instant and misdate the effective day
|
||||
// of a staged default-hours change.
|
||||
func TestScheduling_DefaultHours_DST_Midnight(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
utc time.Time
|
||||
want string
|
||||
}{
|
||||
{"00:30 BST window", time.Date(2026, 6, 14, 23, 30, 0, 0, time.UTC), "2026-06-15"},
|
||||
{"01:30 BST same day", time.Date(2026, 6, 15, 0, 30, 0, 0, time.UTC), "2026-06-15"},
|
||||
{"GMT midnight is UTC midnight", time.Date(2026, 1, 14, 23, 30, 0, 0, time.UTC), "2026-01-14"},
|
||||
{"GMT next day", time.Date(2026, 1, 15, 0, 30, 0, 0, time.UTC), "2026-01-15"},
|
||||
{"autumn BST->GMT transition day", time.Date(2026, 10, 25, 0, 30, 0, 0, time.UTC), "2026-10-25"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := LondonDateString(tc.utc); got != tc.want {
|
||||
t.Errorf("LondonDateString(%s UTC) = %q, expected London date %q", tc.utc.Format(time.RFC3339), got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Direct proof of the DST window: the UTC date of the first instant differs
|
||||
// from its London date — exactly the drift the helper must absorb.
|
||||
boundary := time.Date(2026, 6, 14, 23, 30, 0, 0, time.UTC)
|
||||
if boundary.Format("2006-01-02") == LondonDateString(boundary) {
|
||||
t.Fatal("test setup invariant: 2026-06-14 23:30 UTC must straddle the London midnight boundary")
|
||||
}
|
||||
|
||||
// The helper must be consistent with clock.London's own wall-clock for the
|
||||
// same instant (the production seam ApplyScheduledDefaultHours now uses).
|
||||
nowish := time.Date(2026, 6, 14, 23, 45, 0, 0, time.UTC)
|
||||
if LondonDateString(nowish) != nowish.In(clock.London).Format("2006-01-02") {
|
||||
t.Errorf("LondonDateString disagrees with the direct London conversion for %s", nowish.Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"crussell/clock"
|
||||
"crussell/db"
|
||||
@@ -256,12 +257,20 @@ func CleanupExpiredRefreshTokens(ctx context.Context) (int, error) {
|
||||
return int(result.RowsAffected()), nil
|
||||
}
|
||||
|
||||
// LondonDateString returns the Europe/London calendar date (YYYY-MM-DD) for
|
||||
// the given instant. ApplyScheduledDefaultHours uses this so staged
|
||||
// default-hours changes roll over at London midnight rather than UTC midnight:
|
||||
// during BST a London date begins at 23:00 UTC the previous day, so using the
|
||||
// UTC date would misdate the effective day inside the 00:00-01:00 BST window.
|
||||
func LondonDateString(t time.Time) string {
|
||||
return t.In(clock.London).Format("2006-01-02")
|
||||
}
|
||||
|
||||
// ApplyScheduledDefaultHours applies any pending default hours changes that
|
||||
// have reached their effective_date. Runs daily at 00:05 to catch midnight
|
||||
// roll-overs even if the cron is slightly delayed.
|
||||
func ApplyScheduledDefaultHours(ctx context.Context) (int, error) {
|
||||
londonNow := clock.Now().In(clock.London)
|
||||
todayStr := londonNow.Format("2006-01-02")
|
||||
todayStr := LondonDateString(clock.Now())
|
||||
|
||||
tx, err := db.Conn.Begin(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -907,15 +907,21 @@ func CleanupExpiredDeposits(ctx context.Context) (int, error) {
|
||||
return n, tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// CleanupExpiredGiftCards expires gift cards unused for 24 months (rolling expiry).
|
||||
// CleanupExpiredGiftCards expires gift cards unused for the configured rolling
|
||||
// window (default 24 months) after last use.
|
||||
//
|
||||
// Legal basis:
|
||||
// - UK Consumer Rights Act 2015: Expiry terms must be "fair and transparent"
|
||||
// - CMA guidance: 24 months is industry standard (John Lewis, M&S, Sainsbury's)
|
||||
// - Under 12 months risks being challenged as unfair contract term
|
||||
//
|
||||
// The window is read from business_settings.gift_card_expiry_months (the SINGLE
|
||||
// source of truth shared with the payment handlers' expiry_date writes via
|
||||
// payments.GetGiftCardExpiryMonths) so the job and the refund-time check never
|
||||
// drift apart.
|
||||
//
|
||||
// This function:
|
||||
// 1. Finds unredeemed cards (redeemed_by IS NULL) unused for 24+ months
|
||||
// 1. Finds unredeemed cards (redeemed_by IS NULL) unused for the window
|
||||
// 2. Inserts into gift_card_expired_balances for recovery claims
|
||||
// 3. Sets amount_remaining to 0
|
||||
// 4. Records transaction in gift_card_transactions
|
||||
@@ -936,14 +942,18 @@ func CleanupExpiredGiftCards(ctx context.Context) (int, error) {
|
||||
slog.Error("failed to rollback transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
expiryMonths, monthsErr := payments.GetGiftCardExpiryMonths(ctx, tx)
|
||||
if monthsErr != nil {
|
||||
return 0, fmt.Errorf("failed to read gift card expiry months: %w", monthsErr)
|
||||
}
|
||||
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT id, amount_remaining
|
||||
FROM gift_cards
|
||||
WHERE redeemed_by IS NULL
|
||||
AND amount_remaining > 0
|
||||
AND last_used_at < NOW() - INTERVAL '24 months'
|
||||
`)
|
||||
AND last_used_at < NOW() - ($1 * INTERVAL '1 month')
|
||||
`, expiryMonths)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to query expired gift cards: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
//go:build test
|
||||
|
||||
package today
|
||||
|
||||
// Strict timezone tests for today.go's `AT TIME ZONE 'Europe/London'` date
|
||||
// math. All assertions use fixed time.Date instants.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/clock"
|
||||
"crussell/db"
|
||||
"crussell/testutils"
|
||||
"crussell/testutils/fixtures"
|
||||
)
|
||||
|
||||
// TestToday_LondonDateWindow proves the SQL used by findNewBookingServices
|
||||
// (today.go) computes the London calendar date from a UTC instant. At
|
||||
// 2026-06-14 23:30 UTC (= 2026-06-15 00:30 BST) the London date is 2026-06-15
|
||||
// even though the UTC date is 2026-06-14 — the 00:00-01:00 BST window where a
|
||||
// naive UTC date would start the 14-day "last working day" scan a day early.
|
||||
func TestToday_LondonDateWindow(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, _ := testutils.SetupTestTx(t)
|
||||
|
||||
londonDate := func(utc time.Time) string {
|
||||
t.Helper()
|
||||
var date string
|
||||
err := db.Conn.QueryRow(ctx, `
|
||||
SELECT (($1::timestamptz AT TIME ZONE 'Europe/London')::date)::text
|
||||
`, utc).Scan(&date)
|
||||
if err != nil {
|
||||
t.Fatalf("London-date query failed for %s: %v", utc.Format(time.RFC3339), err)
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
utc time.Time
|
||||
want string
|
||||
}{
|
||||
{"00:30 BST window", time.Date(2026, 6, 14, 23, 30, 0, 0, time.UTC), "2026-06-15"},
|
||||
{"01:30 BST same London day", time.Date(2026, 6, 15, 0, 30, 0, 0, time.UTC), "2026-06-15"},
|
||||
{"GMT winter day", time.Date(2026, 1, 15, 16, 0, 0, 0, time.UTC), "2026-01-15"},
|
||||
{"spring-forward day", time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC), "2026-03-29"},
|
||||
{"autumn-transition day", time.Date(2026, 10, 25, 12, 0, 0, 0, time.UTC), "2026-10-25"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := londonDate(tc.utc); got != tc.want {
|
||||
t.Errorf("London date of %s UTC = %s, expected %s", tc.utc.Format(time.RFC3339), got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestToday_UTCUnchanged_Boundary pins that the same boundary instant is
|
||||
// stored as a TIMESTAMPTZ unchanged (UTC), so the Go-side clock.Now() and the
|
||||
// SQL-side NOW()/TIMESTAMPTZ never disagree about the instant itself.
|
||||
func TestToday_UTCUnchanged_Boundary(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, _ := testutils.SetupTestTx(t)
|
||||
|
||||
boundary := time.Date(2026, 6, 14, 23, 30, 0, 0, time.UTC)
|
||||
var roundTrip time.Time
|
||||
err := db.Conn.QueryRow(ctx, `SELECT $1::timestamptz`, boundary).Scan(&roundTrip)
|
||||
if err != nil {
|
||||
t.Fatalf("timestamptz round-trip failed: %v", err)
|
||||
}
|
||||
if !roundTrip.Equal(boundary) {
|
||||
t.Errorf("timestamptz round-trip altered the instant: got %s, want %s", roundTrip.Format(time.RFC3339Nano), boundary.Format(time.RFC3339Nano))
|
||||
}
|
||||
// The London date derived from the round-tripped value must still be the
|
||||
// BST-day date — pgx may decode with a nil Go location, so only the instant
|
||||
// and its London wall-clock date matter.
|
||||
zone, off := roundTrip.In(londonLocation).Zone()
|
||||
if zone != "BST" || off != 3600 {
|
||||
t.Errorf("round-tripped instant in London = zone %q offset %ds, expected BST +3600", zone, off)
|
||||
}
|
||||
if got := roundTrip.In(londonLocation).Format("2006-01-02"); got != "2026-06-15" {
|
||||
t.Errorf("round-tripped instant maps to London date %s, expected 2026-06-15", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestToday_SummaryDateLabels_LondonBusinessDay is a regression test for the
|
||||
// QA-flagged DST bug: the done-for-the-day summary header showed the PREVIOUS
|
||||
// day during BST. todayStart is London midnight converted to UTC (e.g.
|
||||
// 2026-08-04 00:00 BST = 2026-08-03 23:00 UTC), and formatting that UTC
|
||||
// instant directly with "2006-01-02" yielded "2026-08-03" for the Aug 4
|
||||
// business day. The summary labels must use the London-located instant so the
|
||||
// header shows the business day (and the same date as the UTC-derived London
|
||||
// calendar day the rest of the pipeline uses).
|
||||
func TestToday_SummaryDateLabels_LondonBusinessDay(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
svcID := createTodayService(t, ctx, tx)
|
||||
|
||||
// Seed a completed booking TODAY (London) so the handler is in the
|
||||
// "done for the day" branch with a summary.
|
||||
now := clock.Now()
|
||||
var bookingID string
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO bookings (user_id, start_time, status)
|
||||
VALUES ($1, $2, 'completed')
|
||||
RETURNING id
|
||||
`, userID, now).Scan(&bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create booking: %v", err)
|
||||
}
|
||||
addBookingService(t, ctx, tx, bookingID, svcID)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/today/current-next", nil)
|
||||
req = req.WithContext(ctx)
|
||||
rr := httptest.NewRecorder()
|
||||
GetCurrentAndNextHandler(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp CurrentNextResponse
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
if resp.Summary == nil {
|
||||
t.Fatal("expected a summary (done-for-the-day branch)")
|
||||
}
|
||||
|
||||
londonToday := now.In(londonLocation).Format("2006-01-02")
|
||||
if resp.Summary.SummaryStartDate != londonToday {
|
||||
t.Errorf("summary_start_date = %s, expected %s (the London business day — the pre-fix code emitted the previous UTC day during BST)",
|
||||
resp.Summary.SummaryStartDate, londonToday)
|
||||
}
|
||||
// End date is the London day AFTER todayStart (todayEnd = todayStart + 24h,
|
||||
// which in London wall-clock is the NEXT day).
|
||||
londonTomorrow := now.In(londonLocation).AddDate(0, 0, 1).Format("2006-01-02")
|
||||
if resp.Summary.SummaryEndDate != londonTomorrow {
|
||||
t.Errorf("summary_end_date = %s, expected %s (the London day after the business day)",
|
||||
resp.Summary.SummaryEndDate, londonTomorrow)
|
||||
}
|
||||
}
|
||||
@@ -228,11 +228,15 @@ func GetCurrentAndNextHandler(w http.ResponseWriter, r *http.Request) {
|
||||
response.DoneForDay = &done
|
||||
|
||||
if todayOpen {
|
||||
// Regular done-for-the-day: show daily summary
|
||||
// Regular done-for-the-day: show daily summary. Format the dates in
|
||||
// Europe/London: todayStart is London midnight converted to UTC (e.g.
|
||||
// 2026-08-04 00:00 BST = 2026-08-03 23:00 UTC), so formatting the UTC
|
||||
// instant directly yields the PREVIOUS day during BST. Use the
|
||||
// London-located instant so the summary header shows the business day.
|
||||
summary := computeAggregateSummary(r, todayStart, todayEnd)
|
||||
summary.SummaryScope = "day"
|
||||
summary.SummaryStartDate = todayStart.Format("2006-01-02")
|
||||
summary.SummaryEndDate = todayEnd.Format("2006-01-02")
|
||||
summary.SummaryStartDate = todayStart.In(londonLocation).Format("2006-01-02")
|
||||
summary.SummaryEndDate = todayEnd.In(londonLocation).Format("2006-01-02")
|
||||
response.Summary = summary
|
||||
|
||||
// If tomorrow is closed, also compute a week summary
|
||||
@@ -241,8 +245,8 @@ func GetCurrentAndNextHandler(w http.ResponseWriter, r *http.Request) {
|
||||
weekStart, _ := findWeekSummaryRange(r, tomorrow)
|
||||
ws := computeAggregateSummary(r, weekStart, todayEnd)
|
||||
ws.SummaryScope = "week"
|
||||
ws.SummaryStartDate = weekStart.Format("2006-01-02")
|
||||
ws.SummaryEndDate = todayStart.Format("2006-01-02")
|
||||
ws.SummaryStartDate = weekStart.In(londonLocation).Format("2006-01-02")
|
||||
ws.SummaryEndDate = todayStart.In(londonLocation).Format("2006-01-02")
|
||||
response.WeekSummary = ws
|
||||
}
|
||||
} else {
|
||||
@@ -250,8 +254,8 @@ func GetCurrentAndNextHandler(w http.ResponseWriter, r *http.Request) {
|
||||
weekStart, _ := findWeekSummaryRange(r, londonNow)
|
||||
summary := computeAggregateSummary(r, weekStart, todayEnd)
|
||||
summary.SummaryScope = "week"
|
||||
summary.SummaryStartDate = weekStart.Format("2006-01-02")
|
||||
summary.SummaryEndDate = todayStart.Format("2006-01-02")
|
||||
summary.SummaryStartDate = weekStart.In(londonLocation).Format("2006-01-02")
|
||||
summary.SummaryEndDate = todayStart.In(londonLocation).Format("2006-01-02")
|
||||
response.Summary = summary
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,7 +251,7 @@ func SeedBaseline(pool *pgxpool.Pool) {
|
||||
// Business settings: required by admin/settings tests and booking deposit logic.
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO business_settings (business_name, business_address, currency_code, gift_card_expiry_months, voucher_type)
|
||||
VALUES ('Test Salon', '123 Test St', 'GBP', 12, 'SPV')
|
||||
VALUES ('Test Salon', '123 Test St', 'GBP', 24, 'SPV')
|
||||
ON CONFLICT DO NOTHING
|
||||
`)
|
||||
if err != nil {
|
||||
@@ -292,7 +292,7 @@ func SeedBaselineScheduling(pool *pgxpool.Pool) {
|
||||
// Business settings same as baseline.
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO business_settings (business_name, business_address, currency_code, gift_card_expiry_months, voucher_type)
|
||||
VALUES ('Test Salon', '123 Test St', 'GBP', 12, 'SPV')
|
||||
VALUES ('Test Salon', '123 Test St', 'GBP', 24, 'SPV')
|
||||
ON CONFLICT DO NOTHING
|
||||
`)
|
||||
if err != nil {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
import { ensureBusinessInfo, getBusinessInfo } from '$lib/stores/businessInfo.svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { extractErrorMessage } from '$lib/utils/toast-safe';
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
@@ -56,7 +55,7 @@
|
||||
);
|
||||
|
||||
const isFutureBooking = $derived(
|
||||
selectedBooking ? new SvelteDate(selectedBooking.start_time) > new SvelteDate() : false
|
||||
selectedBooking ? parseWallClockDate(selectedBooking.start_time) > new Date() : false
|
||||
);
|
||||
|
||||
const hasPayments = $derived(
|
||||
@@ -112,7 +111,7 @@
|
||||
|
||||
const hoursUntilAppointment = $derived(
|
||||
selectedBooking
|
||||
? (new SvelteDate(selectedBooking.start_time).getTime() - new SvelteDate().getTime()) /
|
||||
? (parseWallClockDate(selectedBooking.start_time).getTime() - new Date().getTime()) /
|
||||
(1000 * 60 * 60)
|
||||
: Infinity
|
||||
);
|
||||
@@ -555,7 +554,7 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
|
||||
</div>
|
||||
|
||||
{#if selectedBooking}
|
||||
{@const isPastBooking = new SvelteDate(selectedBooking.start_time) < new SvelteDate()}
|
||||
{@const isPastBooking = parseWallClockDate(selectedBooking.start_time) < new Date()}
|
||||
{@const isUnpaid = selectedBooking.amount_due > 0}
|
||||
{@const showChip = !isPastBooking || isUnpaid}
|
||||
{@const isConfirmedOrLater = ['confirmed', 'in_progress', 'completed'].includes(
|
||||
@@ -849,7 +848,7 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="mt-1 text-xs text-gray-400">
|
||||
{new SvelteDate(payment.created_at).toLocaleString()}
|
||||
{parseWallClockDate(payment.created_at).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right font-semibold">
|
||||
@@ -875,7 +874,7 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
|
||||
{refund.reason || 'Refund processed'}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-400">
|
||||
{new SvelteDate(refund.created_at).toLocaleString()}
|
||||
{parseWallClockDate(refund.created_at).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right font-semibold text-red-600">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { extractErrorMessage, sanitizeText } from '$lib/utils/toast-safe';
|
||||
@@ -161,12 +160,12 @@
|
||||
function findOldestBooking(): { id: string; isCurrent: boolean } | null {
|
||||
if (overlappingBookings.length === 0) return null;
|
||||
|
||||
const currentCreatedAt = new SvelteDate(booking.created_at).getTime();
|
||||
const currentCreatedAt = parseWallClockDate(booking.created_at).getTime();
|
||||
let oldestId = booking.id;
|
||||
let oldestTime = currentCreatedAt;
|
||||
|
||||
for (const ob of overlappingBookings) {
|
||||
const obTime = new SvelteDate(ob.created_at).getTime();
|
||||
const obTime = parseWallClockDate(ob.created_at).getTime();
|
||||
if (obTime < oldestTime) {
|
||||
oldestTime = obTime;
|
||||
oldestId = ob.id;
|
||||
@@ -450,7 +449,7 @@
|
||||
{ob.status}
|
||||
</span>
|
||||
<div class="mt-1 text-xs text-gray-400">
|
||||
{new SvelteDate(ob.created_at).toLocaleDateString('en-GB', {
|
||||
{parseWallClockDate(ob.created_at).toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: 'numeric',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { extractErrorMessage } from '$lib/utils/toast-safe';
|
||||
import { SvelteDate, SvelteSet, SvelteURLSearchParams } from 'svelte/reactivity';
|
||||
import { SvelteSet, SvelteURLSearchParams } from 'svelte/reactivity';
|
||||
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
|
||||
import { isValidUKPhone, toE164UK } from '$lib/utils/phone';
|
||||
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||
@@ -217,7 +217,7 @@
|
||||
// Date Boundaries
|
||||
const today = getLondonTodayCalendarDate();
|
||||
const minDate = today;
|
||||
const maxDate = new SvelteDate(today.year, today.month - 1, today.day);
|
||||
const maxDate = new Date(today.year, today.month - 1, today.day);
|
||||
maxDate.setMonth(today.month - 1 + 6);
|
||||
const maxCalendarDate = new CalendarDate(
|
||||
maxDate.getFullYear(),
|
||||
@@ -397,8 +397,8 @@
|
||||
!bookingCreateAutoSelectDone
|
||||
) {
|
||||
bookingCreateAutoSelectDone = true;
|
||||
const now = new SvelteDate(getLondonTodayCalendarDate().toString() + 'T00:00:00');
|
||||
const maxDateJs = new SvelteDate(
|
||||
const now = new Date(getLondonTodayCalendarDate().toString() + 'T00:00:00');
|
||||
const maxDateJs = new Date(
|
||||
maxCalendarDate.year,
|
||||
maxCalendarDate.month - 1,
|
||||
maxCalendarDate.day
|
||||
@@ -408,7 +408,7 @@
|
||||
);
|
||||
const daysToCheck = Math.min(daysDifference, 180);
|
||||
for (let i = 0; i <= daysToCheck; i++) {
|
||||
const checkDate = new SvelteDate(now);
|
||||
const checkDate = new Date(now);
|
||||
checkDate.setDate(now.getDate() + i);
|
||||
const dateStr = checkDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
||||
const calDate = new CalendarDate(
|
||||
@@ -757,7 +757,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new SvelteDate();
|
||||
const now = new Date();
|
||||
const diff = reservationExpiresAt.getTime() - now.getTime();
|
||||
|
||||
if (diff <= 0) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { extractErrorMessage } from '$lib/utils/toast-safe';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
@@ -48,7 +48,7 @@
|
||||
const balanceDue = $derived(selectedBooking ? computeBalanceDue(selectedBooking) : 0);
|
||||
const hoursUntilAppt = $derived(
|
||||
selectedBooking
|
||||
? (new SvelteDate(selectedBooking.start_time).getTime() - new SvelteDate().getTime()) /
|
||||
? (parseWallClockDate(selectedBooking.start_time).getTime() - new Date().getTime()) /
|
||||
(1000 * 60 * 60)
|
||||
: Infinity
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
import { SvelteDate, SvelteURLSearchParams } from 'svelte/reactivity';
|
||||
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
||||
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { extractErrorMessage } from '$lib/utils/toast-safe';
|
||||
|
||||
@@ -117,10 +118,10 @@
|
||||
|
||||
// Format booking date/time
|
||||
function formatBookingDateTime(startTime: string): string {
|
||||
const date = new SvelteDate(startTime);
|
||||
const now = new SvelteDate();
|
||||
const today = new SvelteDate(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const bookingDate = new SvelteDate(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
const date = parseWallClockDate(startTime);
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const bookingDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
const daysDiff = Math.floor((bookingDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
|
||||
@@ -143,7 +143,9 @@
|
||||
if (value == null || value === '') return 'Required';
|
||||
const num = Number(value);
|
||||
if (isNaN(num)) return 'Must be a number';
|
||||
if (num < 1) return 'Must be at least 1';
|
||||
// Legal floor: CMA guidance flags sub-12-month expiry windows as an
|
||||
// unfair contract term under the Consumer Rights Act 2015.
|
||||
if (num < 12) return 'Must be at least 12 months';
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -385,6 +387,10 @@
|
||||
<div>
|
||||
<span class="text-xs text-muted-foreground">Expiry Period</span>
|
||||
<p class="text-sm font-medium">{settings.gift_card_expiry_months} months</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Rolling from last use — each balance check, top-up, redemption or payment
|
||||
resets the timer.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs text-muted-foreground">Voucher Type</span>
|
||||
@@ -609,11 +615,16 @@
|
||||
<Input
|
||||
id="gift_card_expiry_months"
|
||||
type="number"
|
||||
min="1"
|
||||
min="12"
|
||||
bind:value={form.gift_card_expiry_months}
|
||||
oninput={() => validateField('gift_card_expiry_months')}
|
||||
onblur={() => validateField('gift_card_expiry_months')}
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Rolling expiry: the timer resets on every use (balance check, top-up, redeem,
|
||||
payment). 24 months is the recommended default; CMA guidance flags sub-12-month
|
||||
windows as an unfair contract term.
|
||||
</p>
|
||||
{#if formErrors.gift_card_expiry_months}
|
||||
<p class="text-xs text-destructive">{formErrors.gift_card_expiry_months}</p>
|
||||
{/if}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
import { range } from '$lib/utils/format';
|
||||
import { toast } from 'svelte-sonner';
|
||||
@@ -92,7 +91,7 @@
|
||||
if (form.discount_percent <= 0 || form.discount_percent > 100) return false;
|
||||
if (form.campaign_type === 'time_based') {
|
||||
if (!form.start_date || !form.end_date) return false;
|
||||
if (new SvelteDate(form.end_date) < new SvelteDate(form.start_date)) return false;
|
||||
if (new Date(form.end_date) < new Date(form.start_date)) return false;
|
||||
}
|
||||
if (form.campaign_type === 'milestone') {
|
||||
if (form.milestone_value <= 0) return false;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { extractErrorMessage } from '$lib/utils/toast-safe';
|
||||
import { formatDuration } from '$lib/utils/format';
|
||||
@@ -280,10 +279,10 @@
|
||||
const maxAvailableDuration = $derived.by(() => {
|
||||
if (!booking?.start_time || !nextAppointmentStart) return null;
|
||||
|
||||
const bookingStart = new SvelteDate(booking.start_time).getTime();
|
||||
const bookingStart = parseWallClockDate(booking.start_time).getTime();
|
||||
const currentDurationMs = totalDuration * 60 * 1000;
|
||||
const bookingEnd = bookingStart + currentDurationMs;
|
||||
const nextStart = new SvelteDate(nextAppointmentStart).getTime();
|
||||
const nextStart = parseWallClockDate(nextAppointmentStart).getTime();
|
||||
|
||||
const availableMs = nextStart - bookingEnd;
|
||||
return Math.max(0, Math.floor(availableMs / (60 * 1000)));
|
||||
@@ -600,7 +599,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-400">
|
||||
{new SvelteDate(payment.created_at).toLocaleDateString('en-GB', {
|
||||
{parseWallClockDate(payment.created_at).toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
import { isSquareConfigured } from '$lib/square/square';
|
||||
import { range } from '$lib/utils/format';
|
||||
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||
import { SvelteDate, SvelteURLSearchParams } from 'svelte/reactivity';
|
||||
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
||||
|
||||
interface GiftCard {
|
||||
id: string;
|
||||
@@ -726,7 +727,7 @@
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new SvelteDate(dateStr).toLocaleDateString('en-GB', {
|
||||
return parseWallClockDate(dateStr).toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
@@ -736,14 +737,14 @@
|
||||
|
||||
function getExpiryDate(lastUsedAt?: string): Date | null {
|
||||
if (!lastUsedAt) return null;
|
||||
const date = new SvelteDate(lastUsedAt);
|
||||
const date = parseWallClockDate(lastUsedAt);
|
||||
date.setMonth(date.getMonth() + 24);
|
||||
return date;
|
||||
}
|
||||
|
||||
function isExpired(lastUsedAt?: string): boolean {
|
||||
const expiry = getExpiryDate(lastUsedAt);
|
||||
return expiry !== null && expiry < new SvelteDate();
|
||||
return expiry !== null && expiry < new Date();
|
||||
}
|
||||
|
||||
// =============== Sorting ===============
|
||||
@@ -802,7 +803,7 @@
|
||||
return (a.amount_remaining - b.amount_remaining) * mul;
|
||||
case 'created':
|
||||
return (
|
||||
(new SvelteDate(a.created_at).getTime() - new SvelteDate(b.created_at).getTime()) * mul
|
||||
(parseWallClockDate(a.created_at).getTime() - parseWallClockDate(b.created_at).getTime()) * mul
|
||||
);
|
||||
case 'status': {
|
||||
const aVal = a.redeemed_by ? 2 : a.amount_remaining === 0 ? 1 : 0;
|
||||
@@ -833,7 +834,7 @@
|
||||
return (a.balance - b.balance) * mul;
|
||||
case 'updated':
|
||||
return (
|
||||
(new SvelteDate(a.updated_at).getTime() - new SvelteDate(b.updated_at).getTime()) * mul
|
||||
(parseWallClockDate(a.updated_at).getTime() - parseWallClockDate(b.updated_at).getTime()) * mul
|
||||
);
|
||||
default:
|
||||
return 0;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { extractErrorMessage, sanitizeText } from '$lib/utils/toast-safe';
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
@@ -178,9 +177,9 @@
|
||||
}
|
||||
|
||||
function addWeeksToException(fromISO: string, toISO: string, dest: string[]) {
|
||||
const from = new SvelteDate(fromISO + 'T00:00:00Z');
|
||||
const to = new SvelteDate(toISO + 'T00:00:00Z');
|
||||
const first = new SvelteDate(from);
|
||||
const from = new Date(fromISO + 'T00:00:00Z');
|
||||
const to = new Date(toISO + 'T00:00:00Z');
|
||||
const first = new Date(from);
|
||||
const day = first.getDay();
|
||||
const daysToMonday = day === 0 ? -6 : 1 - day;
|
||||
|
||||
@@ -188,8 +187,8 @@
|
||||
first.setDate(first.getDate() + daysToMonday);
|
||||
|
||||
// Add all Mondays in the range
|
||||
for (let d = new SvelteDate(first); d <= to; d.setDate(d.getDate() + 7)) {
|
||||
dest.push(isoDateOf(new SvelteDate(d)));
|
||||
for (let d = new Date(first); d <= to; d.setDate(d.getDate() + 7)) {
|
||||
dest.push(isoDateOf(new Date(d)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,7 +486,7 @@
|
||||
{g.weekStarts
|
||||
?.slice(0, 3)
|
||||
.map((w) =>
|
||||
new SvelteDate(w).toLocaleDateString('en-GB', {
|
||||
new Date(w).toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short'
|
||||
})
|
||||
@@ -869,7 +868,7 @@
|
||||
<div class="grid grid-cols-2 gap-2 md:grid-cols-3">
|
||||
{#each viewingException.weekStarts as week (week)}
|
||||
<div class="text-sm">
|
||||
Week of {new SvelteDate(week).toLocaleDateString('en-GB', {
|
||||
Week of {new Date(week).toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { SvelteDate, SvelteSet } from 'svelte/reactivity';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { extractErrorMessage } from '$lib/utils/toast-safe';
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
@@ -58,7 +58,7 @@
|
||||
|
||||
const today = getLondonTodayCalendarDate();
|
||||
const minDate = today;
|
||||
const maxDate = new SvelteDate(today.year, today.month - 1, today.day);
|
||||
const maxDate = new Date(today.year, today.month - 1, today.day);
|
||||
maxDate.setMonth(today.month - 1 + 6);
|
||||
const maxCalendarDate = new CalendarDate(
|
||||
maxDate.getFullYear(),
|
||||
@@ -67,7 +67,7 @@
|
||||
);
|
||||
|
||||
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 showNoticeWarning = $derived(
|
||||
@@ -380,8 +380,8 @@
|
||||
!rescheduleAutoSelectDone
|
||||
) {
|
||||
rescheduleAutoSelectDone = true;
|
||||
const now = new SvelteDate(getLondonTodayCalendarDate().toString() + 'T00:00:00');
|
||||
const maxDateJs = new SvelteDate(
|
||||
const now = new Date(getLondonTodayCalendarDate().toString() + 'T00:00:00');
|
||||
const maxDateJs = new Date(
|
||||
maxCalendarDate.year,
|
||||
maxCalendarDate.month - 1,
|
||||
maxCalendarDate.day
|
||||
@@ -391,7 +391,7 @@
|
||||
);
|
||||
const daysToCheck = Math.min(daysDifference, 180);
|
||||
for (let i = 1; i <= daysToCheck; i++) {
|
||||
const checkDate = new SvelteDate(now);
|
||||
const checkDate = new Date(now);
|
||||
checkDate.setDate(now.getDate() + i);
|
||||
const dateStr = checkDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
||||
const calDate = new CalendarDate(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
import { CalendarDate } from '@internationalized/date';
|
||||
import { toast } from 'svelte-sonner';
|
||||
@@ -123,7 +122,7 @@
|
||||
|
||||
function getWorkingHoursForDate(dateStr: string): WorkingHourRow | null {
|
||||
if (!dateStr || defaultHours.length === 0) return null;
|
||||
const d = new SvelteDate(dateStr + 'T00:00:00Z');
|
||||
const d = new Date(dateStr + 'T00:00:00Z');
|
||||
const jsDay = d.getDay();
|
||||
const weekday = jsDay === 0 ? 6 : jsDay - 1;
|
||||
return defaultHours.find((h) => h.weekday === weekday) ?? null;
|
||||
@@ -186,8 +185,8 @@
|
||||
});
|
||||
|
||||
function formatRelativeTime(iso: string): string {
|
||||
const d = new SvelteDate(iso);
|
||||
const now = new SvelteDate();
|
||||
const d = parseWallClockDate(iso);
|
||||
const now = new Date();
|
||||
const diffMs = d.getTime() - now.getTime();
|
||||
if (diffMs < 0) {
|
||||
const absMin = Math.floor(Math.abs(diffMs) / 60000);
|
||||
@@ -205,7 +204,7 @@
|
||||
|
||||
const sortedBlockers = $derived.by(() => {
|
||||
return [...blockers].sort(
|
||||
(a, b) => new SvelteDate(a.start_time).getTime() - new SvelteDate(b.start_time).getTime()
|
||||
(a, b) => parseWallClockDate(a.start_time).getTime() - parseWallClockDate(b.start_time).getTime()
|
||||
);
|
||||
});
|
||||
|
||||
@@ -297,8 +296,8 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const blockerStart = new SvelteDate(startIso);
|
||||
const blockerEnd = new SvelteDate(endIso);
|
||||
const blockerStart = new Date(startIso);
|
||||
const blockerEnd = new Date(endIso);
|
||||
if (blockerEnd.getTime() <= blockerStart.getTime()) {
|
||||
overlappingBookings = [];
|
||||
hasOverlap = false;
|
||||
@@ -322,8 +321,8 @@
|
||||
const allBookings: OverlappingBooking[] = data.bookings || [];
|
||||
// Client-side filter: only bookings that overlap with the proposed blocker timespan
|
||||
const filtered = allBookings.filter((b) => {
|
||||
const bStart = new SvelteDate(b.start_time);
|
||||
const bEnd = new SvelteDate(bStart.getTime() + b.duration_minutes * 60000);
|
||||
const bStart = parseWallClockDate(b.start_time);
|
||||
const bEnd = new Date(bStart.getTime() + b.duration_minutes * 60000);
|
||||
return bStart < blockerEnd && bEnd > blockerStart;
|
||||
});
|
||||
overlappingBookings = filtered;
|
||||
@@ -774,7 +773,7 @@
|
||||
<p class="text-sm text-gray-400">Select a date first</p>
|
||||
{:else if !selectedWorkingHours.isOpen}
|
||||
<p class="text-sm text-red-500">
|
||||
Closed on {new SvelteDate(newStartDate + 'T00:00:00Z').toLocaleDateString('en-GB', {
|
||||
Closed on {new Date(newStartDate + 'T00:00:00Z').toLocaleDateString('en-GB', {
|
||||
weekday: 'long'
|
||||
})}
|
||||
</p>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { SvelteDate, SvelteURLSearchParams } from 'svelte/reactivity';
|
||||
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { extractErrorMessage } from '$lib/utils/toast-safe';
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
@@ -325,13 +325,13 @@
|
||||
<div class="font-medium">
|
||||
{#if selectedUser.dateOfBirth}
|
||||
{(() => {
|
||||
const dob = new SvelteDate(selectedUser.dateOfBirth);
|
||||
const dob = new Date(selectedUser.dateOfBirth);
|
||||
const dateStr = dob.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
const today = new SvelteDate();
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - dob.getFullYear();
|
||||
const m = today.getMonth() - dob.getMonth();
|
||||
if (m < 0 || (m === 0 && today.getDate() < dob.getDate())) age--;
|
||||
@@ -346,7 +346,7 @@
|
||||
<div class="text-xs text-gray-500">First Visit</div>
|
||||
<div class="font-medium">
|
||||
{#if customerRelationship?.firstVisitDate}
|
||||
{new SvelteDate(customerRelationship.firstVisitDate).toLocaleDateString('en-US', {
|
||||
{new Date(customerRelationship.firstVisitDate).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
@@ -360,7 +360,7 @@
|
||||
<div class="text-xs text-gray-500">Last Visit</div>
|
||||
<div class="font-medium">
|
||||
{#if customerRelationship?.lastVisitDate}
|
||||
{new SvelteDate(customerRelationship.lastVisitDate).toLocaleDateString('en-US', {
|
||||
{new Date(customerRelationship.lastVisitDate).toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import WalkInCreateModal from '$lib/components/admin/WalkInCreateModal.svelte';
|
||||
import { CalendarDate } from '@internationalized/date';
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { extractErrorMessage } from '$lib/utils/toast-safe';
|
||||
@@ -28,7 +27,7 @@
|
||||
} | null>(null);
|
||||
let loading = $state(true);
|
||||
let noSlotsToday = $state(false);
|
||||
let currentTime = new SvelteDate();
|
||||
let currentTime = new Date();
|
||||
|
||||
let _reservationId = $state<string | null>(null);
|
||||
let reservationExpiresAt = $state<Date | null>(null);
|
||||
@@ -58,7 +57,7 @@
|
||||
fetchShortestService();
|
||||
|
||||
const interval = setInterval(() => {
|
||||
currentTime = new SvelteDate();
|
||||
currentTime = new Date();
|
||||
}, 60000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
@@ -176,7 +175,7 @@
|
||||
noSlotsToday = false;
|
||||
|
||||
try {
|
||||
const now = new SvelteDate();
|
||||
const now = new Date();
|
||||
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
||||
const [y, m, d] = londonDateStr.split('-').map(Number);
|
||||
const today = new CalendarDate(y, m, d);
|
||||
@@ -275,7 +274,7 @@
|
||||
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
||||
const [y, m, d] = londonDateStr.split('-').map(Number);
|
||||
const [hours, minutes] = startTime.split(':').map(Number);
|
||||
const start = new SvelteDate(y, m - 1, d, hours, minutes, 0, 0);
|
||||
const start = new Date(y, m - 1, d, hours, minutes, 0, 0);
|
||||
const startTimeISO = formatLocalDateTime(start);
|
||||
|
||||
const response = await apiFetch('/api/admin/bookings/reserve', {
|
||||
@@ -329,7 +328,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new SvelteDate();
|
||||
const now = new Date();
|
||||
const diff = reservationExpiresAt.getTime() - now.getTime();
|
||||
|
||||
if (diff <= 0) {
|
||||
@@ -369,7 +368,7 @@
|
||||
const liveRemaining = getLiveRemainingMinutes() ?? 0;
|
||||
if (liveRemaining > RESERVATION_TTL) {
|
||||
// Available now with >15min remaining — reserve from now to slot end
|
||||
const now = new SvelteDate();
|
||||
const now = new Date();
|
||||
const currentMin = now.getHours() * 60 + now.getMinutes();
|
||||
reserveTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
||||
reserveDuration = slotInfo.slotEndMinutes! - currentMin;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { extractErrorMessage } from '$lib/utils/toast-safe';
|
||||
import { SvelteDate, SvelteURLSearchParams } from 'svelte/reactivity';
|
||||
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
||||
import { isValidUKPhone, toE164UK } from '$lib/utils/phone';
|
||||
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||
import { formatLocalDateTime } from '$lib/utils/timeSlots';
|
||||
@@ -243,7 +243,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new SvelteDate();
|
||||
const now = new Date();
|
||||
const diff = reservationExpiresAt.getTime() - now.getTime();
|
||||
|
||||
if (diff <= 0) {
|
||||
@@ -479,7 +479,7 @@
|
||||
const [hours, minutes] = availableStartTime.split(':').map(Number);
|
||||
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
||||
const [y, m, d] = londonDateStr.split('-').map(Number);
|
||||
start = new SvelteDate(y, m - 1, d, hours, minutes, 0, 0);
|
||||
start = new Date(y, m - 1, d, hours, minutes, 0, 0);
|
||||
} else {
|
||||
// Fallback: Calculate immediate start time (rounded to next 15 min)
|
||||
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
||||
@@ -491,8 +491,8 @@
|
||||
});
|
||||
const [y, m, d] = londonDateStr.split('-').map(Number);
|
||||
const [h, min] = londonTimeStr.split(':').map(Number);
|
||||
const now = new SvelteDate(y, m - 1, d, h, min, 0, 0);
|
||||
start = new SvelteDate(now);
|
||||
const now = new Date(y, m - 1, d, h, min, 0, 0);
|
||||
start = new Date(now);
|
||||
const minutes = start.getMinutes();
|
||||
const remainder = 15 - (minutes % 15);
|
||||
if (remainder !== 15 && remainder !== 0) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { extractErrorMessage } from '$lib/utils/toast-safe';
|
||||
import { browser } from '$app/environment';
|
||||
@@ -229,7 +228,7 @@
|
||||
}
|
||||
|
||||
function getDefaultEffectiveDate(): string {
|
||||
const d = new SvelteDate();
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + 1);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { extractErrorMessage } from '$lib/utils/toast-safe';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
|
||||
// Components
|
||||
import BookingActions from '$lib/components/booking/BookingActions.svelte';
|
||||
@@ -41,7 +40,7 @@
|
||||
import { POLICY } from '$lib/constants/policy';
|
||||
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
|
||||
import { extractBookedSlots, getLunchProtectionForSlots } from '$lib/lunchProtection';
|
||||
import { formatLocalDateTime, getLondonTodayCalendarDate } from '$lib/utils/timeSlots';
|
||||
import { formatLocalDateTime, getLondonTodayCalendarDate, parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
import { generateUUID } from '$lib/utils/uuid';
|
||||
|
||||
import type {
|
||||
@@ -575,7 +574,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new SvelteDate();
|
||||
const now = new Date();
|
||||
const diff = reservationExpiresAt.getTime() - now.getTime();
|
||||
|
||||
if (diff <= 0) {
|
||||
@@ -685,7 +684,7 @@
|
||||
// Initialize date boundaries
|
||||
const today = getLondonTodayCalendarDate();
|
||||
const minDate = today;
|
||||
const maxDate = new SvelteDate(today.year, today.month - 1, today.day);
|
||||
const maxDate = new Date(today.year, today.month - 1, today.day);
|
||||
maxDate.setMonth(today.month - 1 + 6);
|
||||
const maxCalendarDate = new CalendarDate(
|
||||
maxDate.getFullYear(),
|
||||
@@ -756,8 +755,8 @@
|
||||
) {
|
||||
bookingFlowAutoSelectDone = 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
|
||||
@@ -769,7 +768,7 @@
|
||||
const daysToCheck = Math.min(daysDifference, 180);
|
||||
|
||||
for (let i = 1; 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' });
|
||||
|
||||
@@ -992,7 +991,7 @@
|
||||
// =============== Time Slot Generation ===============
|
||||
function calculateEndTime(startTime: string, durationMinutes: number): string {
|
||||
const [hours, minutes] = startTime.split(':').map(Number);
|
||||
const date = new SvelteDate();
|
||||
const date = new Date();
|
||||
date.setHours(hours, minutes, 0, 0);
|
||||
date.setMinutes(date.getMinutes() + durationMinutes);
|
||||
const endHours = date.getHours().toString().padStart(2, '0');
|
||||
@@ -1253,7 +1252,7 @@
|
||||
15,
|
||||
false
|
||||
);
|
||||
const now = new SvelteDate();
|
||||
const now = new Date();
|
||||
const validSlots = availableSlots.filter((t) => {
|
||||
if (lunchProtection.get(t)?.isBlocked) return false;
|
||||
if (userDepositsRequired > 0) {
|
||||
@@ -1410,7 +1409,7 @@
|
||||
}
|
||||
|
||||
function getDayWithOrdinal(date: CalendarDate): string {
|
||||
const monthName = new SvelteDate(date.year, date.month - 1, date.day).toLocaleDateString(
|
||||
const monthName = new Date(date.year, date.month - 1, date.day).toLocaleDateString(
|
||||
'en-GB',
|
||||
{
|
||||
month: 'long'
|
||||
@@ -2131,7 +2130,7 @@
|
||||
{#if currentStep === finalStep}
|
||||
{#if confirmedBooking}
|
||||
{@const isRequested = confirmedBooking.notes && confirmedBooking.notes.length > 0}
|
||||
{@const bookingDate = new SvelteDate(confirmedBooking.start_time)}
|
||||
{@const bookingDate = parseWallClockDate(confirmedBooking.start_time)}
|
||||
{@const dateStr = bookingDate.toLocaleDateString('en-GB', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
@@ -2446,8 +2445,8 @@
|
||||
deposit_paid: bk.deposit_paid,
|
||||
payments: bk.payments,
|
||||
duration_minutes: bk.duration_minutes,
|
||||
created_at: new SvelteDate().toISOString(),
|
||||
updated_at: new SvelteDate().toISOString()
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
}}
|
||||
onClose={() => (showPayEarlyModal = false)}
|
||||
onComplete={() => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { formatTime } from '$lib/utils/timeSlots';
|
||||
|
||||
@@ -81,7 +80,7 @@
|
||||
const today = getLondonDate();
|
||||
const dayOfWeek = today.getDay(); // 0=Sun
|
||||
const offset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
|
||||
const monday = new SvelteDate(today);
|
||||
const monday = new Date(today);
|
||||
monday.setDate(today.getDate() + offset);
|
||||
return fmtDate(monday);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { extractErrorMessage } from '$lib/utils/toast-safe';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
import CardSelection from '$lib/components/payments/CardSelection.svelte';
|
||||
import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
@@ -110,7 +110,7 @@
|
||||
});
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new SvelteDate(dateStr);
|
||||
const date = parseWallClockDate(dateStr);
|
||||
return date.toLocaleDateString('en-GB', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
@@ -127,7 +127,7 @@
|
||||
services: BookingService[],
|
||||
fallbackDuration: number
|
||||
): string {
|
||||
const start = new SvelteDate(startStr);
|
||||
const start = parseWallClockDate(startStr);
|
||||
const totalMinutes =
|
||||
services?.reduce(
|
||||
(sum, s) => sum + (s.override_duration_minutes ?? s.duration_minutes ?? 0),
|
||||
@@ -135,7 +135,7 @@
|
||||
) ??
|
||||
fallbackDuration ??
|
||||
0;
|
||||
const end = new SvelteDate(start.getTime() + totalMinutes * 60000);
|
||||
const end = new Date(start.getTime() + totalMinutes * 60000);
|
||||
|
||||
const formatOpt: Intl.DateTimeFormatOptions = {
|
||||
hour: 'numeric',
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
import { ensureBusinessInfo, getBusinessInfo } from '$lib/stores/businessInfo.svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { formatDuration } from '$lib/utils/format';
|
||||
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
@@ -106,19 +106,19 @@
|
||||
const minutesUntilClosing = $derived.by(() => {
|
||||
if (!closingTime) return 0;
|
||||
const [ch, cm] = closingTime.split(':').map(Number);
|
||||
const now = new SvelteDate();
|
||||
const closing = new SvelteDate(now.getFullYear(), now.getMonth(), now.getDate(), ch, cm);
|
||||
const now = new Date();
|
||||
const closing = new Date(now.getFullYear(), now.getMonth(), now.getDate(), ch, cm);
|
||||
return Math.max(0, Math.floor((closing.getTime() - now.getTime()) / 60000));
|
||||
});
|
||||
|
||||
function calculateTimes() {
|
||||
const now = new SvelteDate();
|
||||
const now = new Date();
|
||||
|
||||
if (currentAppointment) {
|
||||
isInProgress = currentAppointment.status === 'in_progress';
|
||||
const startTime = new SvelteDate(currentAppointment.start_time);
|
||||
const startTime = parseWallClockDate(currentAppointment.start_time);
|
||||
|
||||
const endTime = new SvelteDate(
|
||||
const endTime = new Date(
|
||||
startTime.getTime() + currentAppointment.duration_minutes * 60 * 1000
|
||||
);
|
||||
|
||||
@@ -137,15 +137,15 @@
|
||||
|
||||
let rawFreeMinutes = 0;
|
||||
if (nextAppointment) {
|
||||
const nextStart = new SvelteDate(nextAppointment.start_time);
|
||||
const nextStart = parseWallClockDate(nextAppointment.start_time);
|
||||
const gapMs = nextStart.getTime() - endTime.getTime();
|
||||
rawFreeMinutes = Math.max(0, Math.floor(gapMs / 60000));
|
||||
}
|
||||
|
||||
if (closingTime) {
|
||||
const [ch, cm] = closingTime.split(':').map(Number);
|
||||
const today = new SvelteDate();
|
||||
const closing = new SvelteDate(
|
||||
const today = new Date();
|
||||
const closing = new Date(
|
||||
today.getFullYear(),
|
||||
today.getMonth(),
|
||||
today.getDate(),
|
||||
@@ -325,14 +325,14 @@
|
||||
</Card.Title>
|
||||
{#if activeAppointment}
|
||||
<Card.Description class="text-base">
|
||||
{new SvelteDate(activeAppointment.start_time).toLocaleTimeString('en-US', {
|
||||
{parseWallClockDate(activeAppointment.start_time).toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
})}
|
||||
-
|
||||
{new SvelteDate(
|
||||
new SvelteDate(activeAppointment.start_time).getTime() +
|
||||
{new Date(
|
||||
parseWallClockDate(activeAppointment.start_time).getTime() +
|
||||
activeAppointment.duration_minutes * 60 * 1000
|
||||
).toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
@@ -439,13 +439,13 @@
|
||||
|
||||
{#if summary.summary_scope === 'week'}
|
||||
<div class="text-xs text-gray-500">
|
||||
Summary: {new SvelteDate(summary.summary_start_date).toLocaleDateString('en-US', {
|
||||
Summary: {parseWallClockDate(summary.summary_start_date).toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
–
|
||||
{new SvelteDate(summary.summary_end_date).toLocaleDateString('en-US', {
|
||||
{parseWallClockDate(summary.summary_end_date).toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
@@ -552,7 +552,7 @@
|
||||
<div>
|
||||
<h4 class="mb-2 text-sm font-semibold text-gray-700">
|
||||
New bookings made since{summary.summary_scope === 'week'
|
||||
? ` opening ${new SvelteDate(summary.summary_start_date).toLocaleDateString('en-US', { weekday: 'long' })}`
|
||||
? ` opening ${parseWallClockDate(summary.summary_start_date).toLocaleDateString('en-US', { weekday: 'long' })}`
|
||||
: ' closing yesterday'}
|
||||
</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@@ -573,13 +573,13 @@
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-gray-700">This week so far</h4>
|
||||
<div class="text-xs text-gray-500">
|
||||
{new SvelteDate(weekSummary.summary_start_date).toLocaleDateString('en-US', {
|
||||
{parseWallClockDate(weekSummary.summary_start_date).toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
–
|
||||
{new SvelteDate(weekSummary.summary_end_date).toLocaleDateString('en-US', {
|
||||
{parseWallClockDate(weekSummary.summary_end_date).toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
@@ -10,6 +9,7 @@
|
||||
import EditRequestModal from '$lib/components/admin/EditRequestModal.svelte';
|
||||
import { range } from '$lib/utils/format';
|
||||
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
|
||||
interface Props {
|
||||
openBookingModal?: (bookingId: string) => void;
|
||||
@@ -105,7 +105,7 @@
|
||||
|
||||
// Helper function to format date nicely
|
||||
function formatDateTime(dateTimeString: string): string {
|
||||
const date = new SvelteDate(dateTimeString);
|
||||
const date = parseWallClockDate(dateTimeString);
|
||||
const dateStr = date.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
@@ -120,8 +120,8 @@
|
||||
}
|
||||
|
||||
function formatRelativeTime(iso: string): string {
|
||||
const d = new SvelteDate(iso);
|
||||
const now = new SvelteDate();
|
||||
const d = parseWallClockDate(iso);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
const servicesChanged = areEditServicesChanged(er);
|
||||
|
||||
if (timeChanged) {
|
||||
const d = new SvelteDate(er.proposed.start_time!);
|
||||
const d = parseWallClockDate(er.proposed.start_time!);
|
||||
const dateStr = d.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
@@ -187,7 +187,8 @@
|
||||
const data = await response.json();
|
||||
const newApprovals = (data.approvals || []).sort(
|
||||
(a: PendingApproval, b: PendingApproval) =>
|
||||
new SvelteDate(a.created_at).getTime() - new SvelteDate(b.created_at).getTime()
|
||||
parseWallClockDate(a.created_at).getTime() -
|
||||
parseWallClockDate(b.created_at).getTime()
|
||||
);
|
||||
const newJson = JSON.stringify(newApprovals);
|
||||
if (newJson !== prevApprovalsJson) {
|
||||
@@ -218,7 +219,8 @@
|
||||
const data = await response.json();
|
||||
const newEditRequests = (data.edit_requests || []).sort(
|
||||
(a: EditRequest, b: EditRequest) =>
|
||||
new SvelteDate(a.requested_at).getTime() - new SvelteDate(b.requested_at).getTime()
|
||||
parseWallClockDate(a.requested_at).getTime() -
|
||||
parseWallClockDate(b.requested_at).getTime()
|
||||
);
|
||||
const newJson = JSON.stringify(newEditRequests);
|
||||
if (newJson !== prevEditRequestsJson) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { CalendarDate } from '@internationalized/date';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { extractErrorMessage, sanitizeText } from '$lib/utils/toast-safe';
|
||||
@@ -22,7 +21,7 @@
|
||||
timeToMinutes
|
||||
} from '$lib/lunchProtection';
|
||||
import { formatDuration, formatDateISO } from '$lib/utils/format';
|
||||
import { formatLocalDateTime } from '$lib/utils/timeSlots';
|
||||
import { formatLocalDateTime, parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
|
||||
interface Props {
|
||||
openBookingModal: (bookingId: string) => void;
|
||||
@@ -83,8 +82,8 @@
|
||||
};
|
||||
|
||||
function isPastAppointment(startTime: string, durationMinutes: number): boolean {
|
||||
const start = new SvelteDate(startTime);
|
||||
const end = new SvelteDate(start.getTime() + durationMinutes * 60_000);
|
||||
const start = parseWallClockDate(startTime);
|
||||
const end = new Date(start.getTime() + durationMinutes * 60_000);
|
||||
return end.getTime() < Date.now();
|
||||
}
|
||||
|
||||
@@ -118,7 +117,7 @@
|
||||
|
||||
const weekStartStr = $derived.by(() => {
|
||||
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
||||
const d = new SvelteDate(londonDateStr + 'T00:00:00Z');
|
||||
const d = new Date(londonDateStr + 'T00:00:00Z');
|
||||
const day = d.getDay();
|
||||
const diff = day === 0 ? 6 : day - 1;
|
||||
d.setDate(d.getDate() - diff);
|
||||
@@ -126,7 +125,7 @@
|
||||
});
|
||||
|
||||
const weekEndStr = $derived.by(() => {
|
||||
const d = new SvelteDate(weekStartStr + 'T00:00:00Z');
|
||||
const d = new Date(weekStartStr + 'T00:00:00Z');
|
||||
d.setDate(d.getDate() + 6);
|
||||
return formatDateISO(d);
|
||||
});
|
||||
@@ -286,7 +285,7 @@
|
||||
const items: TimelineItem[] = [];
|
||||
|
||||
for (const apt of appointments) {
|
||||
const start = new SvelteDate(apt.start_time);
|
||||
const start = parseWallClockDate(apt.start_time);
|
||||
const startM = start.getHours() * 60 + start.getMinutes();
|
||||
items.push({
|
||||
id: `apt-${apt.id}`,
|
||||
@@ -298,7 +297,7 @@
|
||||
}
|
||||
|
||||
for (const b of blockers) {
|
||||
const start = new SvelteDate(b.start_time);
|
||||
const start = parseWallClockDate(b.start_time);
|
||||
const startM = start.getHours() * 60 + start.getMinutes();
|
||||
items.push({
|
||||
id: `blk-${b.id}`,
|
||||
@@ -440,8 +439,8 @@
|
||||
hasOverlap = false;
|
||||
return;
|
||||
}
|
||||
const blockerStart = new SvelteDate(startIso);
|
||||
const blockerEnd = new SvelteDate(endIso);
|
||||
const blockerStart = new Date(startIso);
|
||||
const blockerEnd = new Date(endIso);
|
||||
if (blockerEnd.getTime() <= blockerStart.getTime()) {
|
||||
overlappingBookings = [];
|
||||
hasOverlap = false;
|
||||
@@ -459,8 +458,8 @@
|
||||
const data = await response.json();
|
||||
const allBookings: OverlappingBooking[] = data.bookings || [];
|
||||
const filtered = allBookings.filter((b) => {
|
||||
const bStart = new SvelteDate(b.start_time);
|
||||
const bEnd = new SvelteDate(bStart.getTime() + b.duration_minutes * 60000);
|
||||
const bStart = parseWallClockDate(b.start_time);
|
||||
const bEnd = new Date(bStart.getTime() + b.duration_minutes * 60000);
|
||||
return bStart < blockerEnd && bEnd > blockerStart;
|
||||
});
|
||||
overlappingBookings = filtered;
|
||||
@@ -627,7 +626,7 @@
|
||||
}
|
||||
|
||||
function formatTime(dateString: string): string {
|
||||
const date = new SvelteDate(dateString);
|
||||
const date = parseWallClockDate(dateString);
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
@@ -1011,7 +1010,7 @@
|
||||
)}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{new SvelteDate(booking.start_time).toLocaleTimeString('en-GB', {
|
||||
{parseWallClockDate(booking.start_time).toLocaleTimeString('en-GB', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
@@ -51,11 +50,11 @@
|
||||
|
||||
async function findLastWorkingDayClose(): Promise<{ cutoff: string; spansClosed: boolean }> {
|
||||
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
||||
const now = new SvelteDate(londonDateStr + 'T00:00:00Z');
|
||||
const now = new Date(londonDateStr + 'T00:00:00Z');
|
||||
let spansClosed = false;
|
||||
|
||||
for (let i = 1; i <= 14; i++) {
|
||||
const d = new SvelteDate(now);
|
||||
const d = new Date(now);
|
||||
d.setDate(d.getDate() - i);
|
||||
const dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
|
||||
@@ -72,7 +71,7 @@
|
||||
if (day?.isOpen) {
|
||||
const closeTime = day.endTime;
|
||||
const [h, m] = closeTime.split(':').map(Number);
|
||||
const closeDate = new SvelteDate(d);
|
||||
const closeDate = new Date(d);
|
||||
closeDate.setHours(h, m, 0, 0);
|
||||
return { cutoff: formatLocalDateTime(closeDate), spansClosed };
|
||||
} else {
|
||||
@@ -81,7 +80,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = new SvelteDate(now);
|
||||
const fallback = new Date(now);
|
||||
fallback.setDate(fallback.getDate() - 1);
|
||||
fallback.setHours(17, 0, 0, 0);
|
||||
return { cutoff: formatLocalDateTime(fallback), spansClosed };
|
||||
@@ -144,7 +143,7 @@
|
||||
}
|
||||
|
||||
const { cutoff, spansClosed } = await findLastWorkingDayClose();
|
||||
const nowISO = formatLocalDateTime(new SvelteDate());
|
||||
const nowISO = formatLocalDateTime(new Date());
|
||||
let bookingsMade = 0;
|
||||
try {
|
||||
const bmRes = await apiFetch(
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { CalendarDate } from '@internationalized/date';
|
||||
import {
|
||||
extractBookedSlots,
|
||||
@@ -96,7 +95,7 @@ export function timeToMinutes(time: string): number {
|
||||
}
|
||||
|
||||
export function getDayWithOrdinal(date: CalendarDate): string {
|
||||
const monthName = new SvelteDate(date.year, date.month - 1, date.day).toLocaleDateString(
|
||||
const monthName = new Date(date.year, date.month - 1, date.day).toLocaleDateString(
|
||||
'en-GB',
|
||||
{ month: 'long' }
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { authStore, type User } from '$lib/stores/auth.svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
import { browser } from '$app/environment';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
||||
@@ -896,13 +896,13 @@
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const now = new SvelteDate();
|
||||
const now = new Date();
|
||||
|
||||
// Filter: Calculate end time (Start + Duration) and check if it's in the future
|
||||
const activeOrFutureBookings = (data.bookings || []).filter((b: Booking) => {
|
||||
const startTime = new SvelteDate(b.start_time);
|
||||
const startTime = parseWallClockDate(b.start_time);
|
||||
// Add duration (in ms)
|
||||
const endTime = new SvelteDate(startTime.getTime() + (b.duration_minutes || 0) * 60000);
|
||||
const endTime = new Date(startTime.getTime() + (b.duration_minutes || 0) * 60000);
|
||||
return endTime > now;
|
||||
});
|
||||
|
||||
@@ -938,10 +938,10 @@
|
||||
let bookings = data.bookings || [];
|
||||
|
||||
// Only include bookings that have actually finished (endTime <= now)
|
||||
const now = new SvelteDate();
|
||||
const now = new Date();
|
||||
bookings = bookings.filter((b: Booking) => {
|
||||
const startTime = new SvelteDate(b.start_time);
|
||||
const endTime = new SvelteDate(startTime.getTime() + (b.duration_minutes || 0) * 60000);
|
||||
const startTime = parseWallClockDate(b.start_time);
|
||||
const endTime = new Date(startTime.getTime() + (b.duration_minutes || 0) * 60000);
|
||||
return endTime <= now;
|
||||
});
|
||||
|
||||
@@ -968,7 +968,7 @@
|
||||
if (!aUnpaid && bUnpaid) return 1;
|
||||
|
||||
// If both have same payment status, sort by Date DESC (newest first)
|
||||
return new SvelteDate(b.start_time).getTime() - new SvelteDate(a.start_time).getTime();
|
||||
return parseWallClockDate(b.start_time).getTime() - parseWallClockDate(a.start_time).getTime();
|
||||
});
|
||||
|
||||
pastBookings = bookings;
|
||||
@@ -1120,7 +1120,7 @@
|
||||
}
|
||||
|
||||
function formatDateTime(dateString: string): string {
|
||||
const date = new SvelteDate(dateString);
|
||||
const date = parseWallClockDate(dateString);
|
||||
|
||||
// Get date parts
|
||||
const day = date.getDate();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
import { onMount } from 'svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
@@ -265,8 +265,8 @@
|
||||
}
|
||||
|
||||
function formatRelative(iso: string): string {
|
||||
const d = new SvelteDate(iso);
|
||||
const now = new SvelteDate();
|
||||
const d = parseWallClockDate(iso);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
@@ -286,12 +286,12 @@
|
||||
}
|
||||
|
||||
function formatBookingDate(iso: string): string {
|
||||
const d = new SvelteDate(iso);
|
||||
const now = new SvelteDate();
|
||||
const today = new SvelteDate(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const tomorrow = new SvelteDate(today);
|
||||
const d = parseWallClockDate(iso);
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const bookingDay = new SvelteDate(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
const bookingDay = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
const diffDays = Math.round((bookingDay.getTime() - today.getTime()) / 86400000);
|
||||
|
||||
if (diffDays === 0) return 'Today';
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { formatDuration } from '$lib/utils/format';
|
||||
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
import BookingModal from '$lib/components/admin/BookingModal.svelte';
|
||||
|
||||
// -- Types --
|
||||
@@ -83,7 +83,7 @@
|
||||
const today = getLondonToday();
|
||||
const dayOfWeek = today.getDay();
|
||||
const diff = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
|
||||
const monday = new SvelteDate(today);
|
||||
const monday = new Date(today);
|
||||
monday.setDate(monday.getDate() - diff);
|
||||
monday.setHours(0, 0, 0, 0);
|
||||
weekStart = monday;
|
||||
@@ -93,7 +93,7 @@
|
||||
// -- Helpers --
|
||||
function getWeekDays(start: Date): Date[] {
|
||||
return Array.from({ length: 7 }, (_, i) => {
|
||||
const d = new SvelteDate(start);
|
||||
const d = new Date(start);
|
||||
d.setDate(d.getDate() + i);
|
||||
return d;
|
||||
});
|
||||
@@ -106,15 +106,15 @@
|
||||
/** Return the current London date as a Date set to midnight in the local timezone.
|
||||
* Uses Intl.DateTimeFormat with Europe/London to handle BST/GMT correctly. */
|
||||
function getLondonToday(): Date {
|
||||
const dateStr = new SvelteDate().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
||||
return new SvelteDate(dateStr + 'T00:00:00');
|
||||
const dateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
||||
return new Date(dateStr + 'T00:00:00');
|
||||
}
|
||||
|
||||
/** Return the current time-of-day in London as minutes since midnight, using
|
||||
* Intl.DateTimeFormat with Europe/London so the current-time blue line is
|
||||
* positioned correctly regardless of the browser's system timezone. */
|
||||
function getLondonNowMinutes(): number {
|
||||
const timeStr = new SvelteDate().toLocaleTimeString('en-GB', {
|
||||
const timeStr = new Date().toLocaleTimeString('en-GB', {
|
||||
timeZone: 'Europe/London',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
@@ -129,11 +129,11 @@
|
||||
* at 23:30 UTC (00:30 BST next day) are grouped under the correct
|
||||
* London date column rather than the UTC date. */
|
||||
function getLondonDateKey(iso: string): string {
|
||||
return new SvelteDate(iso).toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
||||
return parseWallClockDate(iso).toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
||||
}
|
||||
|
||||
function formatWeekLabel(start: Date): string {
|
||||
const end = new SvelteDate(start);
|
||||
const end = new Date(start);
|
||||
end.setDate(end.getDate() + 6);
|
||||
const opts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric' };
|
||||
const endOpts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', year: 'numeric' };
|
||||
@@ -147,7 +147,7 @@
|
||||
}
|
||||
|
||||
function formatTimeShort(iso: string): string {
|
||||
return new SvelteDate(iso).toLocaleTimeString('en-US', {
|
||||
return parseWallClockDate(iso).toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
@@ -213,9 +213,9 @@
|
||||
}
|
||||
|
||||
function bookingsOverlap(a: ScheduleBooking, b: ScheduleBooking): boolean {
|
||||
const aStart = new SvelteDate(a.start_time).getTime();
|
||||
const aStart = parseWallClockDate(a.start_time).getTime();
|
||||
const aEnd = aStart + a.duration_minutes * 60000;
|
||||
const bStart = new SvelteDate(b.start_time).getTime();
|
||||
const bStart = parseWallClockDate(b.start_time).getTime();
|
||||
const bEnd = bStart + b.duration_minutes * 60000;
|
||||
return aStart < bEnd && bStart < aEnd;
|
||||
}
|
||||
@@ -226,7 +226,7 @@
|
||||
if (!initialized) loading = true;
|
||||
try {
|
||||
const startStr = formatDate(weekStart);
|
||||
const end = new SvelteDate(weekStart);
|
||||
const end = new Date(weekStart);
|
||||
end.setDate(end.getDate() + 6);
|
||||
const endStr = formatDate(end);
|
||||
|
||||
@@ -312,7 +312,7 @@
|
||||
|
||||
function navigateWeek(delta: number) {
|
||||
if (!weekStart) return;
|
||||
const d = new SvelteDate(weekStart);
|
||||
const d = new Date(weekStart);
|
||||
d.setDate(d.getDate() + delta * 7);
|
||||
weekStart = d;
|
||||
}
|
||||
@@ -321,7 +321,7 @@
|
||||
const today = getLondonToday();
|
||||
const dayOfWeek = today.getDay();
|
||||
const diff = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
|
||||
const monday = new SvelteDate(today);
|
||||
const monday = new Date(today);
|
||||
monday.setDate(monday.getDate() - diff);
|
||||
monday.setHours(0, 0, 0, 0);
|
||||
weekStart = monday;
|
||||
@@ -385,14 +385,14 @@
|
||||
if (ef > latest) latest = ef;
|
||||
}
|
||||
for (const b of bookingsByDate.get(dateStr) || []) {
|
||||
const d = new SvelteDate(b.start_time);
|
||||
const d = parseWallClockDate(b.start_time);
|
||||
const sf = d.getHours() + d.getMinutes() / 60;
|
||||
if (sf < earliest) earliest = sf;
|
||||
const ef = sf + b.duration_minutes / 60;
|
||||
if (ef > latest) latest = ef;
|
||||
}
|
||||
for (const b of blockersByDate.get(dateStr) || []) {
|
||||
const d = new SvelteDate(b.start_time);
|
||||
const d = parseWallClockDate(b.start_time);
|
||||
const sf = d.getHours() + d.getMinutes() / 60;
|
||||
if (sf < earliest) earliest = sf;
|
||||
const ef = sf + b.duration_minutes / 60;
|
||||
@@ -420,7 +420,7 @@
|
||||
const isCurrentWeek = $derived(
|
||||
weekStart !== undefined &&
|
||||
(() => {
|
||||
const end = new SvelteDate(weekStart);
|
||||
const end = new Date(weekStart);
|
||||
end.setDate(end.getDate() + 6);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
return today >= weekStart && today <= end;
|
||||
@@ -585,8 +585,8 @@
|
||||
<!-- Half-hour dashed line -->
|
||||
<div class="half-hour-line absolute inset-x-0 h-px" style="top: 50%;"></div>
|
||||
|
||||
{#each dayBlockers.filter((b) => new SvelteDate(b.start_time).getHours() === h) as b (b.id)}
|
||||
{@const startMinutes = new SvelteDate(b.start_time).getMinutes()}
|
||||
{#each dayBlockers.filter((b) => parseWallClockDate(b.start_time).getHours() === h) as b (b.id)}
|
||||
{@const startMinutes = parseWallClockDate(b.start_time).getMinutes()}
|
||||
{@const topOffset = (startMinutes / 60) * HOUR_HEIGHT}
|
||||
{@const heightPx = Math.max((b.duration_minutes / 60) * HOUR_HEIGHT, 20)}
|
||||
|
||||
@@ -609,8 +609,8 @@
|
||||
{/each}
|
||||
|
||||
<!-- BOOKING BLOCKS -->
|
||||
{#each dayBookings.filter((b) => new SvelteDate(b.start_time).getHours() === h) as b (b.id)}
|
||||
{@const startMinutes = new SvelteDate(b.start_time).getMinutes()}
|
||||
{#each dayBookings.filter((b) => parseWallClockDate(b.start_time).getHours() === h) as b (b.id)}
|
||||
{@const startMinutes = parseWallClockDate(b.start_time).getMinutes()}
|
||||
{@const topOffset = (startMinutes / 60) * HOUR_HEIGHT}
|
||||
{@const heightPx = Math.max((b.duration_minutes / 60) * HOUR_HEIGHT, 20)}
|
||||
{@const isCancelled =
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
@@ -116,7 +115,7 @@
|
||||
{ customer: 'Ava Davis', stamps: 7, action: 'Earned 1 stamp' }
|
||||
];
|
||||
|
||||
const today = new SvelteDate();
|
||||
const today = new Date();
|
||||
const dateString = today.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
|
||||
import RequiredLabel from '$lib/components/layout/RequiredLabel.svelte';
|
||||
import { isValidUKPhone, toE164UK } from '$lib/utils/phone';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
|
||||
// zxcvbn-ts imports
|
||||
import { ZxcvbnFactory } from '@zxcvbn-ts/core';
|
||||
@@ -81,9 +80,9 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
const dob = new SvelteDate(dateStr);
|
||||
const today = new SvelteDate();
|
||||
const sixteenYearsAgo = new SvelteDate(
|
||||
const dob = new Date(dateStr);
|
||||
const today = new Date();
|
||||
const sixteenYearsAgo = new Date(
|
||||
today.getFullYear() - 16,
|
||||
today.getMonth(),
|
||||
today.getDate()
|
||||
@@ -503,8 +502,8 @@
|
||||
type="date"
|
||||
bind:value={formData.dateOfBirth}
|
||||
onblur={() => validateAge(formData.dateOfBirth)}
|
||||
max={new SvelteDate(
|
||||
new SvelteDate().setFullYear(new SvelteDate().getFullYear() - 16)
|
||||
max={new Date(
|
||||
new Date().setFullYear(new Date().getFullYear() - 16)
|
||||
).toLocaleDateString('en-CA', { timeZone: 'Europe/London' })}
|
||||
required
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { ensureBusinessInfo, getBusinessInfo } from '$lib/stores/businessInfo.svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { range } from '$lib/utils/format';
|
||||
import UserBookingModal from '$lib/components/account/UserBookingModal.svelte';
|
||||
@@ -49,16 +49,16 @@
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
const now = new SvelteDate();
|
||||
const now = new Date();
|
||||
bookings = (data.bookings || [])
|
||||
.filter((b: Booking) => {
|
||||
const startTime = new SvelteDate(b.start_time);
|
||||
const endTime = new SvelteDate(startTime.getTime() + (b.duration_minutes || 0) * 60000);
|
||||
const startTime = parseWallClockDate(b.start_time);
|
||||
const endTime = new Date(startTime.getTime() + (b.duration_minutes || 0) * 60000);
|
||||
return endTime > now;
|
||||
})
|
||||
.sort(
|
||||
(a: Booking, b: Booking) =>
|
||||
new SvelteDate(a.start_time).getTime() - new SvelteDate(b.start_time).getTime()
|
||||
parseWallClockDate(a.start_time).getTime() - parseWallClockDate(b.start_time).getTime()
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Error fetching bookings:', err);
|
||||
@@ -87,7 +87,7 @@
|
||||
const monthKey = booking.start_time.slice(0, 7);
|
||||
if (monthKey !== currentMonth) {
|
||||
currentMonth = monthKey;
|
||||
const d = new SvelteDate(booking.start_time);
|
||||
const d = parseWallClockDate(booking.start_time);
|
||||
const label = d.toLocaleDateString('en-GB', {
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
@@ -101,14 +101,14 @@
|
||||
});
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
return new SvelteDate(iso).toLocaleTimeString('en-GB', {
|
||||
return parseWallClockDate(iso).toLocaleTimeString('en-GB', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function formatCardDate(iso: string): string {
|
||||
return new SvelteDate(iso).toLocaleDateString('en-GB', {
|
||||
return parseWallClockDate(iso).toLocaleDateString('en-GB', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short'
|
||||
@@ -116,8 +116,8 @@
|
||||
}
|
||||
|
||||
function formatEndTime(iso: string, durationMinutes: number): string {
|
||||
const start = new SvelteDate(iso);
|
||||
const end = new SvelteDate(start.getTime() + durationMinutes * 60000);
|
||||
const start = parseWallClockDate(iso);
|
||||
const end = new Date(start.getTime() + durationMinutes * 60000);
|
||||
return end.toLocaleTimeString('en-GB', { hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
@@ -83,16 +83,16 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new SvelteDate();
|
||||
const now = new Date();
|
||||
const sorted = bookings.sort((a, b) => {
|
||||
const dateA = new SvelteDate(a.start_time).getTime();
|
||||
const dateB = new SvelteDate(b.start_time).getTime();
|
||||
const dateA = parseWallClockDate(a.start_time).getTime();
|
||||
const dateB = parseWallClockDate(b.start_time).getTime();
|
||||
return dateB - dateA;
|
||||
});
|
||||
|
||||
// Find most recent past booking (start_time <= now)
|
||||
const pastBooking = sorted.find((b) => {
|
||||
const startTime = new SvelteDate(b.start_time);
|
||||
const startTime = parseWallClockDate(b.start_time);
|
||||
return startTime <= now;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { browser } from '$app/environment';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
|
||||
@@ -71,7 +70,7 @@
|
||||
}
|
||||
|
||||
// Get current date string for display
|
||||
const today = new SvelteDate();
|
||||
const today = new Date();
|
||||
const dateString = today.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
|
||||
@@ -790,7 +790,7 @@ CREATE TABLE business_settings (
|
||||
default_vat_rate NUMERIC(5,2) NOT NULL DEFAULT 20.00,
|
||||
currency_code CHAR(3) NOT NULL DEFAULT 'GBP',
|
||||
website_url TEXT,
|
||||
gift_card_expiry_months INT NOT NULL DEFAULT 12,
|
||||
gift_card_expiry_months INT NOT NULL DEFAULT 24,
|
||||
voucher_type VARCHAR(3) NOT NULL DEFAULT 'SPV',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
@@ -824,7 +824,8 @@ INSERT INTO business_settings (
|
||||
is_vat_registered,
|
||||
default_vat_rate,
|
||||
currency_code,
|
||||
website_url
|
||||
website_url,
|
||||
gift_card_expiry_months
|
||||
) VALUES (
|
||||
'Crussell Nail Art Studio',
|
||||
'Address',
|
||||
@@ -834,7 +835,8 @@ INSERT INTO business_settings (
|
||||
FALSE, -- Set to TRUE when we register for VAT
|
||||
20.00,
|
||||
'GBP',
|
||||
'https://www.website.co.uk'
|
||||
'https://www.website.co.uk',
|
||||
24
|
||||
);
|
||||
|
||||
-- 'critical_payment_log' surfaces unresolved money events (stale pending
|
||||
@@ -2152,7 +2154,10 @@ CREATE INDEX idx_affiliate_payouts_affiliate ON affiliate_payouts(affiliate_id);
|
||||
-- Gift cards are Single-Purpose Vouchers (SPVs) under UK VAT law.
|
||||
-- VAT is charged at point of sale, NOT at redemption.
|
||||
--
|
||||
-- Expiry: 24 months rolling from last usage (industry standard per CMA guidance).
|
||||
-- Expiry: rolling from last usage (industry standard per CMA guidance),
|
||||
-- default 24 months. The window is configurable via
|
||||
-- business_settings.gift_card_expiry_months (admin settings) — the SINGLE
|
||||
-- source of truth read by the expiry job and every expiry_date write.
|
||||
-- UK Consumer Rights Act 2015 requires expiry terms to be "fair and transparent".
|
||||
-- 24 months matches premium retailers (John Lewis, M&S, Sainsbury's) and is
|
||||
-- widely considered reasonable by the CMA. Under 12 months risks being challenged
|
||||
|
||||
Reference in New Issue
Block a user