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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user