PSR 2017 reg 100 makes SCA mandatory and non-waivable for customer-initiated stored-credential charges; a merchant-side 2FA check cannot legally substitute for it (authorising a token-less charge via 2FA leaves the MERCHANT liable for ECI 7 / SLI 210 chargebacks and reg 77(6) compensation regardless of consent). - payments/twofa.go: the homegrown 2FA fallback for token-less saved-card charges is REMOVED ENTIRELY. requireTwoFactorForCardAccess is now SCA-only: a non-empty Square verification_token (charge surfaces, token forwarded to Square) skips the gate; anything else is refused 402 verification_required. enforceSCAFallbackConsent is a compile-compatible no-op (fallback never runs). - New requireTwoFactorForCardAccessWithTokenValidation distinguishes surfaces where the token IS forwarded to Square (charge — Square validates it) from card-SAVE surfaces (token client-asserted, never forwarded: a non-empty token must NOT skip the save gate, auth-F1). - SCA tokenize-result wire contract (C1): a saved card charged with a fresh one-time tokenize-result sends the token as the charge SOURCE (new_card_token -> source_id) alongside saved_card_id, never a separate verification_token. resolveChargeSource resolves the saved-card branch FIRST (customer from the card row, token as source) so combined token+card requests are SCA-clean. - C6 consent fields (consent_version / consent_accepted) added to the booking/ tip/till/gift-card charge requests, enforced server-side before any fallback charge could reach Square and recorded on the 2fa_fallback_charge audit row; logVerificationTokenProvenance traces minted tokens to their charge. - user 2FA issuance gate refactored into pure build-agnostic functions (twoFAPepperConfigured / twoFADeliveryChannelConfigured / twoFAEnsureIssueAllowedStrict) shared with the payments re-issue path and exercised directly by the test,dev suite; TWO_FACTOR_FALLBACK switch and .env.example entry removed; startup posture notes updated. - Test coverage: fail-closed 2FA production gates (pepper/delivery), token validation on save vs charge surfaces, completion idempotency, idempotency key determinism, refund-policy 72h/24h epsilon boundaries, VAT parity.
92 lines
4.2 KiB
Go
92 lines
4.2 KiB
Go
//go:build test && dev
|
|
|
|
package payments
|
|
|
|
// M18 refund-tier epsilon boundary tests. CalculateRefundForCancellation uses
|
|
// STRICT comparisons (`> FullRefundThreshold`, `>= PartialRefundThreshold`),
|
|
// so the exact-boundary tier selection and the just-inside/just-outside
|
|
// epsilon margins are distinct behaviours. The exact 72h / exact 24h tier
|
|
// selection is already locked in refunds_test.go
|
|
// (TestCalculateRefundForCancellation_Exact72hBoundary /
|
|
// _Exact24hBoundary); these tests pin the epsilon margins AROUND both
|
|
// boundaries and assert the refundable/kept amounts at the exact boundaries
|
|
// (which the existing exact-boundary tests only check for the tier string).
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestCalculateRefundForCancellation_Boundary72h_EpsilonMargins(t *testing.T) {
|
|
t.Parallel()
|
|
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
|
|
|
// 1s past 72h → the > comparison wins: full refund of all pre-payments.
|
|
over := CalculateRefundForCancellation(100, 50, start.Add(-72*time.Hour-1*time.Second), start)
|
|
if over.Tier != FullRefundTier {
|
|
t.Errorf("expected tier %q just past 72h, got %q", FullRefundTier, over.Tier)
|
|
}
|
|
if over.RefundableAmount != 50 || over.KeptAmount != 0 {
|
|
t.Errorf("expected full refund (50 refundable / 0 kept) just past 72h, got %.2f / %.2f", over.RefundableAmount, over.KeptAmount)
|
|
}
|
|
|
|
// 1s inside 72h → the >= comparison wins: protected deposit kept.
|
|
under := CalculateRefundForCancellation(100, 50, start.Add(-72*time.Hour+1*time.Second), start)
|
|
if under.Tier != PartialRefundTier {
|
|
t.Errorf("expected tier %q 1s inside 72h, got %q", PartialRefundTier, under.Tier)
|
|
}
|
|
// Protected deposit = min(50, 100*0.5) = 50 → everything kept.
|
|
if under.KeptAmount != 50 || under.RefundableAmount != 0 {
|
|
t.Errorf("expected 50 kept / 0 refundable 1s inside 72h, got %.2f / %.2f", under.KeptAmount, under.RefundableAmount)
|
|
}
|
|
}
|
|
|
|
func TestCalculateRefundForCancellation_Boundary24h_EpsilonMargins(t *testing.T) {
|
|
t.Parallel()
|
|
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
|
|
|
// 1s past 24h → the >= comparison wins: protected deposit kept.
|
|
over := CalculateRefundForCancellation(100, 80, start.Add(-24*time.Hour-1*time.Second), start)
|
|
if over.Tier != PartialRefundTier {
|
|
t.Errorf("expected tier %q 1s past 24h, got %q", PartialRefundTier, over.Tier)
|
|
}
|
|
// Protected deposit = min(80, 50) = 50 → 30 refundable / 50 kept.
|
|
if over.RefundableAmount != 30 || over.KeptAmount != 50 {
|
|
t.Errorf("expected 30 refundable / 50 kept 1s past 24h, got %.2f / %.2f", over.RefundableAmount, over.KeptAmount)
|
|
}
|
|
|
|
// 1s inside 24h → the default wins: nothing refundable, everything kept.
|
|
under := CalculateRefundForCancellation(100, 80, start.Add(-24*time.Hour+1*time.Second), start)
|
|
if under.Tier != NoRefundTier {
|
|
t.Errorf("expected tier %q 1s inside 24h, got %q", NoRefundTier, under.Tier)
|
|
}
|
|
if under.RefundableAmount != 0 || under.KeptAmount != 80 {
|
|
t.Errorf("expected 0 refundable / 80 kept 1s inside 24h, got %.2f / %.2f", under.RefundableAmount, under.KeptAmount)
|
|
}
|
|
}
|
|
|
|
func TestCalculateRefundForCancellation_ExactBoundaries_Amounts(t *testing.T) {
|
|
t.Parallel()
|
|
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
|
|
|
// Exactly 72h: NOT > 72h → partial tier. Amounts must match the partial
|
|
// tier computation (protected deposit kept), not the full tier.
|
|
at72 := CalculateRefundForCancellation(100, 100, start.Add(-72*time.Hour), start)
|
|
if at72.Tier != PartialRefundTier {
|
|
t.Fatalf("expected tier %q at exactly 72h, got %q", PartialRefundTier, at72.Tier)
|
|
}
|
|
if at72.ProtectedDeposit != 50 || at72.KeptAmount != 50 || at72.RefundableAmount != 50 {
|
|
t.Errorf("expected protected 50 / kept 50 / refundable 50 at exactly 72h, got %.2f / %.2f / %.2f", at72.ProtectedDeposit, at72.KeptAmount, at72.RefundableAmount)
|
|
}
|
|
|
|
// Exactly 24h: >= 24h → partial tier (protected deposit kept).
|
|
at24 := CalculateRefundForCancellation(100, 60, start.Add(-24*time.Hour), start)
|
|
if at24.Tier != PartialRefundTier {
|
|
t.Fatalf("expected tier %q at exactly 24h, got %q", PartialRefundTier, at24.Tier)
|
|
}
|
|
// Protected deposit = min(60, 50) = 50 → 10 refundable / 50 kept.
|
|
if at24.RefundableAmount != 10 || at24.KeptAmount != 50 {
|
|
t.Errorf("expected 10 refundable / 50 kept at exactly 24h, got %.2f / %.2f", at24.RefundableAmount, at24.KeptAmount)
|
|
}
|
|
}
|