diff --git a/code/sdk/python/ap2/sdk/constraints.py b/code/sdk/python/ap2/sdk/constraints.py index 48aed798..fe84992d 100644 --- a/code/sdk/python/ap2/sdk/constraints.py +++ b/code/sdk/python/ap2/sdk/constraints.py @@ -8,7 +8,7 @@ from __future__ import annotations from abc import ABC, abstractmethod -from datetime import datetime, timedelta +from datetime import UTC, date, datetime, timedelta from ap2.sdk.generated.open_checkout_mandate import ( AllowedMerchants, @@ -313,6 +313,36 @@ def evaluate( return [] +_ISO_DATE_LENGTH = len('YYYY-MM-DD') + + +def _parse_iso8601(value: str) -> date | datetime: + """Parse an ISO 8601 date (``YYYY-MM-DD``) or date-time. + + Naive date-times are treated as UTC. Raises ``ValueError`` when the value + is not ISO 8601. + """ + if len(value) == _ISO_DATE_LENGTH: + return date.fromisoformat(value) + parsed = datetime.fromisoformat(value) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=UTC) + return parsed + + +def _compare_iso8601(a: date | datetime, b: date | datetime) -> int: + """Return -1/0/1 comparing ``a`` and ``b``. + + If either side is a calendar date, both sides are compared at day + granularity (a date-time is reduced to its calendar date in its own + offset); otherwise instants are compared. + """ + if not isinstance(a, datetime) or not isinstance(b, datetime): + a = a.date() if isinstance(a, datetime) else a + b = b.date() if isinstance(b, datetime) else b + return (a > b) - (a < b) + + class ExecutionDateEvaluator(PaymentConstraintEvaluator): """Evaluates if the execution date is within the allowed window.""" @@ -328,16 +358,34 @@ def evaluate( if not exec_date: return [] + try: + exec_value = _parse_iso8601(exec_date) + not_before = ( + _parse_iso8601(self.constraint.not_before) + if self.constraint.not_before + else None + ) + not_after = ( + _parse_iso8601(self.constraint.not_after) + if self.constraint.not_after + else None + ) + except ValueError as e: + return [f'Invalid ISO 8601 date in execution_date check: {e}'] + violations = [] if ( - self.constraint.not_before - and exec_date < self.constraint.not_before + not_before is not None + and _compare_iso8601(exec_value, not_before) < 0 ): violations.append( f'Execution date {exec_date} is before allowed window ' f'{self.constraint.not_before}' ) - if self.constraint.not_after and exec_date > self.constraint.not_after: + if ( + not_after is not None + and _compare_iso8601(exec_value, not_after) > 0 + ): violations.append( f'Execution date {exec_date} is after allowed window ' f'{self.constraint.not_after}' diff --git a/code/sdk/python/ap2/tests/constraints_tests.py b/code/sdk/python/ap2/tests/constraints_tests.py index 910c97fc..b42d5649 100644 --- a/code/sdk/python/ap2/tests/constraints_tests.py +++ b/code/sdk/python/ap2/tests/constraints_tests.py @@ -547,6 +547,92 @@ def test_payment_execution_date_missing_passes(): assert violations == [] +def test_payment_execution_date_on_datetime_window_start(): + """A date-only execution on the window's first day passes. + + Mirrors the spec example window (``not_before`` with a time component). + """ + violations = check_payment_constraints( + _open_payment( + constraints=[ + ExecutionDate( + not_before='2026-03-31T00:00:00Z', + not_after='2026-04-30T23:59:59Z', + ), + ] + ), + _closed_payment(execution_date='2026-03-31'), + ) + assert violations == [] + + +def test_payment_execution_date_offset_compared_as_instant(): + """Offsets are honoured: 2026-05-01T08:00+09:00 is 2026-04-30T23:00Z.""" + violations = check_payment_constraints( + _open_payment( + constraints=[ + ExecutionDate(not_after='2026-04-30T23:59:59Z'), + ] + ), + _closed_payment(execution_date='2026-05-01T08:00:00+09:00'), + ) + assert violations == [] + + +def test_payment_execution_date_offset_after_window(): + """An instant after ``not_after`` is a violation regardless of format.""" + violations = check_payment_constraints( + _open_payment( + constraints=[ + ExecutionDate(not_after='2026-04-30T23:59:59Z'), + ] + ), + _closed_payment(execution_date='2026-05-01T09:30:00+09:00'), + ) + assert any('after allowed window' in v for v in violations) + + + +@pytest.mark.parametrize( + 'execution_date', + [ + pytest.param('2026-04-30T23:59:59-09:00', id='negative_offset_after'), + pytest.param('2026-03-31T08:00:00+09:00', id='positive_offset_before'), + pytest.param('2026-04-30T23:59:59.5Z', id='fractional_second_after'), + ], +) +def test_payment_execution_date_outside_window_is_rejected(execution_date): + """Instants outside the window are rejected even when they sort inside. + + Raw string comparison let all three through: '-' and '.' sort before 'Z', + and a later local date-time with a positive offset is an earlier instant. + """ + violations = check_payment_constraints( + _open_payment( + constraints=[ + ExecutionDate( + not_before='2026-03-31T00:00:00Z', + not_after='2026-04-30T23:59:59Z', + ), + ] + ), + _closed_payment(execution_date=execution_date), + ) + assert violations + +def test_payment_execution_date_invalid_fails_closed(): + """A non-ISO-8601 execution date is reported, not silently compared.""" + violations = check_payment_constraints( + _open_payment( + constraints=[ + ExecutionDate(not_before='2025-01-01', not_after='2025-12-31'), + ] + ), + _closed_payment(execution_date='06/01/2025'), + ) + assert any('Invalid ISO 8601 date' in v for v in violations) + + # ── check_payment_constraints – allowed_pisp ─────────────────────────────