Files
Crussell/backend/handlers/payments/dst_timezone_test.go
T
popertots 197d4c4b9b 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.
2026-08-22 00:34:49 +01:00

125 lines
5.3 KiB
Go

//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)
}
}