//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, err := buildSplitRecords(record, "full", info, 50) if err != nil { t.Fatalf("buildSplitRecords: %v", err) } 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, err2 := buildSplitRecords(record2, "full", info2, 50) if err2 != nil { t.Fatalf("buildSplitRecords: %v", err2) } 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) } }