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

Gift-card rolling expiry (setting-driven, was dead config):
- GetGiftCardExpiryMonths(): single source of truth (business_settings
  gift_card_expiry_months, fallback 24) shared by payment handlers and the
  CleanupExpiredGiftCards job (was hardcoded 24).
- expiry_date now maintained on ALL 9 gift-card write sites (buy, topup,
  transfer, redeem, terminal payment, refund credit, till) so the refund-time
  guard at refunds.go actually fires. Schema default 12->24 + migration note;
  test-DB seed aligned. Stale "expiry_date IS NULL" test rewritten; new
  expired-card-rejected regression test.

Frontend SvelteDate purge (docs' stated convention, wide):
- All 180+ raw `new SvelteDate(...)` uses across routes/components replaced
  with parseWallClockDate (backend UTC ISO) or new Date (wall-clock
  constructors). SvelteDate imports removed. timeSlots.ts getDayWithOrdinal
  fixed. Zero SvelteDate references remain; svelte-check clean.

Strict timezone/DST testing + QA fixes:
- 8 new hermetic boundary tests: clock.DST transitions (both 2026 folds),
  closing-hours GMT vs BST, booking date-window midnight, refund-tier
  elapsed-time independence, deposit-window UTC-instant, scheduling
  LondonDateString midnight, today AT TIME ZONE window + UTC round-trip.
- today.go summary date labels fixed to London wall-clock (were showing the
  previous UTC day during BST) + regression test.
- pgx ScanLocation fixed to UTC via AfterConnect (was host-local -> JSON
  offsets depended on deployment TZ, contradicting the documented UTC
  invariant) + regression test. Registered as a new *Type to avoid a data
  race on the shared type map (caught by -race).

Admin Business Settings (setting now functional => legal floor):
- gift_card_expiry_months validation floor raised 1 -> 12 months (CMA/
  Consumer Rights Act 2015 unfair-contract-term guidance) in endpoint + UI,
  with rolling-expiry semantics shown in both display and edit form.
- 3 new expiry validation tests; 2 pre-existing message assertions updated.

Full suite 25/25 + race clean via run-tests.sh lockfile; svelte-check 0
errors/warnings; production build succeeds.
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 7f1c649f1e
commit 197d4c4b9b
54 changed files with 1204 additions and 275 deletions
@@ -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 {
+14 -4
View File
@@ -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)
}