Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 52 additions & 4 deletions code/sdk/python/ap2/sdk/constraints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -19,7 +19,7 @@
AgentRecurrence,
AllowedPayees,
AllowedPaymentInstruments,
AllowedPisps,

Check warning on line 22 in code/sdk/python/ap2/sdk/constraints.py

View workflow job for this annotation

GitHub Actions / spellcheck

Unknown word (Pisps)
AmountRange,
Budget,
ExecutionDate,
Expand Down Expand Up @@ -251,10 +251,10 @@
return [f'Payment instrument {instrument.id} not in allowed list']


class AllowedPispEvaluator(PaymentConstraintEvaluator):

Check warning on line 254 in code/sdk/python/ap2/sdk/constraints.py

View workflow job for this annotation

GitHub Actions / spellcheck

Unknown word (Pisp)
"""Evaluates if the PISP is allowed."""

Check warning on line 255 in code/sdk/python/ap2/sdk/constraints.py

View workflow job for this annotation

GitHub Actions / spellcheck

Unknown word (PISP)

def __init__(self, constraint: AllowedPisps):

Check warning on line 257 in code/sdk/python/ap2/sdk/constraints.py

View workflow job for this annotation

GitHub Actions / spellcheck

Unknown word (Pisps)
self.constraint = constraint

def evaluate(
Expand All @@ -262,17 +262,17 @@
closed_mandate: PaymentMandate,
open_checkout_hash: str | None = None,
) -> list[str]:
if not closed_mandate.pisp:

Check warning on line 265 in code/sdk/python/ap2/sdk/constraints.py

View workflow job for this annotation

GitHub Actions / spellcheck

Unknown word (pisp)
return ['Missing PISP in closed mandate']

Check warning on line 266 in code/sdk/python/ap2/sdk/constraints.py

View workflow job for this annotation

GitHub Actions / spellcheck

Unknown word (PISP)

if any(
allowed.domain_name == closed_mandate.pisp.domain_name

Check warning on line 269 in code/sdk/python/ap2/sdk/constraints.py

View workflow job for this annotation

GitHub Actions / spellcheck

Unknown word (pisp)
and allowed.legal_name == closed_mandate.pisp.legal_name

Check warning on line 270 in code/sdk/python/ap2/sdk/constraints.py

View workflow job for this annotation

GitHub Actions / spellcheck

Unknown word (pisp)
and allowed.brand_name == closed_mandate.pisp.brand_name

Check warning on line 271 in code/sdk/python/ap2/sdk/constraints.py

View workflow job for this annotation

GitHub Actions / spellcheck

Unknown word (pisp)
for allowed in self.constraint.allowed
):
return []
return [f'PISP {closed_mandate.pisp} not in allowed list']

Check warning on line 275 in code/sdk/python/ap2/sdk/constraints.py

View workflow job for this annotation

GitHub Actions / spellcheck

Unknown word (PISP)


class BudgetEvaluator(PaymentConstraintEvaluator):
Expand Down Expand Up @@ -313,6 +313,36 @@
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."""

Expand All @@ -328,16 +358,34 @@
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}'
Expand Down
86 changes: 86 additions & 0 deletions code/sdk/python/ap2/tests/constraints_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────


Expand Down
Loading