Files
Crussell/backend/handlers/payments/dst_timezone_test.go
T
popertots 1429eddd34 fix: payments hardening — SCA wire contract (saved-card ref + tokenize-result), terminal/till token routing, tip-cap overflow carve, completion campaign atomicity, orphan B1-evidence gate, gift-card gates/locks, admin backstops
- ValidateCardInfo accepts saved-card ref + new_card_token coexistence (matches resolveChargeSource); new_card_token added to terminal/till request structs so SCA tokens are never dropped
- maxOnlineTipPence (£250) enforced on the overflow-tip carve AND buildSplitRecords (both carve paths) — closes the £10k bypass
- completion-path campaign increments made atomic reserve-first (conditional UPDATE ... RETURNING) + schema backstops (chk_times_redeemed, partial unique index on milestone redemptions)
- webhook orphan detection gated on B1 evidence (b1_attempts / sweep-duplicate refund row) so a delayed legit completion is never marked failed
- gift-card: per-user £500/day cap lock held across read-modify-write, expired-card top-up gate, NaN/Inf float bounds, refund_failed ack filter, on_the_house excluded from balance, postChargeRecheck notification
- admin apply-redemption route + admin-or-owner, in-handler isAdminRequest on 4 gift-card handlers, tip lock key aligned
- 2FA fallback machinery removed (insertTwoFAFallbackAudit/reissue/consent), dead fields stripped from charge structs
- tests: prod-tag suite, mock SCA parity, tip-cap overflow, completion races, cards pagination, ValidateCardInfo tables
2026-08-22 00:34:50 +01:00

131 lines
5.5 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, 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)
}
}