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