Skip to content
Merged
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
18 changes: 16 additions & 2 deletions checkout_sdk/json_serializer.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import inspect
import json
from datetime import date, datetime


class JsonSerializer(json.JSONEncoder):
Expand All @@ -14,7 +15,22 @@ class JsonSerializer(json.JSONEncoder):
def default(self, obj):
if hasattr(obj, 'to_json'):
return self.default(obj.to_json())
elif isinstance(obj, date) and not isinstance(obj, datetime):
# A `format: date` value has no time component, so emit yyyy-MM-dd.
#
# datetime is excluded explicitly because it subclasses date and keeps its existing
# full-timestamp rendering below. Without this branch a plain date reaches the
# strftime branch and raises
# TypeError: replace() got an unexpected keyword argument 'microsecond',
# because date has no `microsecond` keyword on replace(). time is unaffected -- it
# does accept `microsecond` -- and still falls through.
return self.default(obj.isoformat())
elif hasattr(obj, 'strftime'):
return self.default(obj.replace(microsecond=0).isoformat())
elif hasattr(obj, '__dict__'):
# Checked after the date branches on purpose. A date/datetime/time SUBCLASS defined
# in Python has a __dict__, so reflecting first would walk its class attributes
# (min, max, resolution) instead of rendering the value, which fails outright.
props = dict(
(key, value)
for key, value in inspect.getmembers(obj)
Expand All @@ -29,8 +45,6 @@ def default(self, obj):
and not inspect.isroutine(value)
)
return self.default(self.apply_key_transformations(props))
elif hasattr(obj, 'strftime'):
return self.default(obj.replace(microsecond=0).isoformat())
return obj

def apply_key_transformations(self, props):
Expand Down
8 changes: 3 additions & 5 deletions checkout_sdk/payments/contexts/contexts.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
from datetime import datetime

from deprecated import deprecated

from checkout_sdk.common.common import Address, CustomerRequest, AccountHolder
Expand All @@ -15,7 +13,7 @@ class PaymentContextsPartnerCustomerRiskData:

class PaymentContextsTicket:
number: str
issue_date: datetime
issue_date: str # Format: yyyy-MM-dd
issuing_carrier_code: str
travel_package_indicator: str
travel_agency_name: str
Expand All @@ -25,7 +23,7 @@ class PaymentContextsTicket:
class PaymentContextsPassenger:
first_name: str
last_name: str
date_of_birth: datetime
date_of_birth: str # Format: yyyy-MM-dd
address: Address


Expand All @@ -34,7 +32,7 @@ class PaymentContextsFlightLegDetails:
carrier_code: str
class_of_travelling: str
departure_airport: str
departure_date: datetime
departure_date: str # Format: yyyy-MM-dd
departure_time: str
arrival_airport: str
stop_over_code: str
Expand Down
12 changes: 6 additions & 6 deletions checkout_sdk/payments/setups/setups.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@ class CustomerDevice:

class MerchantAccount:
id: str
registration_date: datetime
last_modified: datetime
registration_date: str # Format: yyyy-MM-dd
last_modified: str # Format: yyyy-MM-dd
returning_customer: bool
first_transaction_date: datetime
last_transaction_date: datetime
first_transaction_date: str # Format: yyyy-MM-dd
last_transaction_date: str # Format: yyyy-MM-dd
total_order_count: int
last_payment_amount: int

Expand Down Expand Up @@ -351,7 +351,7 @@ class SetupsSepaMandateType(str, Enum):
class SepaMandate:
id: str
type: SetupsSepaMandateType
date_of_signature: datetime
date_of_signature: str # Format: yyyy-MM-dd


class SepaAccountHolder:
Expand Down Expand Up @@ -552,7 +552,7 @@ class OrderSubMerchant:
id: str
product_category: str
number_of_sales: int
registration_date: datetime
registration_date: str # Format: yyyy-MM-dd


class AmountAllocationCommission:
Expand Down
121 changes: 121 additions & 0 deletions tests/json_serializer_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import json
from datetime import date, datetime, time

import pytest

from checkout_sdk.json_serializer import JsonSerializer


def _serialize(obj):
return json.loads(json.dumps(obj, cls=JsonSerializer))


class _Holder:
"""Minimal stand-in for an SDK request class: a plain attribute holder."""


def _wrap(value):
holder = _Holder()
holder.value = value
return _serialize(holder)['value']


class TestDateRendering:
"""The serializer renders date-like values, it does not reflect over them.

The specification distinguishes `format: date` (yyyy-MM-dd) from `format: date-time`
(RFC 3339). A datetime renders as a full timestamp, which is correct for a date-time
property; a date renders without a time component.
"""

def test_a_date_renders_without_a_time_component(self):
assert _wrap(date(2026, 10, 1)) == '2026-10-01'

@pytest.mark.parametrize('value,expected', [
(date(2026, 1, 9), '2026-01-09'), # single-digit month and day are padded
(date(2024, 2, 29), '2024-02-29'), # leap day
(date(2026, 12, 31), '2026-12-31'),
])
def test_every_date_renders_as_an_iso_date(self, value, expected):
assert _wrap(value) == expected

def test_a_date_used_to_raise_a_type_error(self):
# Regression guard. date has no `microsecond` keyword on replace(), so before the date
# branch existed this raised
# TypeError: replace() got an unexpected keyword argument 'microsecond'
# for any date on any field, including nested objects and lists.
assert _wrap(date.today()) == date.today().isoformat()


class TestDateTimeRenderingIsUnchanged:
"""datetime subclasses date, so it must be excluded from the date branch explicitly."""

@pytest.mark.parametrize('value,expected', [
(datetime(2026, 10, 1), '2026-10-01T00:00:00'),
(datetime(2026, 10, 1, 13, 45, 30), '2026-10-01T13:45:30'),
# microseconds are still truncated, as before
(datetime(2026, 10, 1, 13, 45, 30, 123456), '2026-10-01T13:45:30'),
])
def test_a_datetime_still_renders_a_full_timestamp(self, value, expected):
assert _wrap(value) == expected

def test_a_time_still_renders_through_the_strftime_branch(self):
assert _wrap(time(12, 30)) == '12:30:00'


class TestDateLikeSubclasses:
"""A date-like subclass defined in Python has a __dict__.

The date branches therefore sit above the __dict__ reflection: reflecting first would walk
the class attributes (min, max, resolution) instead of rendering the value, which fails.
Libraries that fake the clock, such as freezegun, hand the SDK exactly these types.
"""

def test_a_date_subclass_renders_as_a_date(self):
class FakeDate(date):
pass

assert _wrap(FakeDate(2026, 10, 1)) == '2026-10-01'

def test_a_datetime_subclass_renders_as_a_timestamp(self):
class FakeDateTime(datetime):
pass

assert _wrap(FakeDateTime(2026, 10, 1, 13, 45, 30)) == '2026-10-01T13:45:30'


class TestNestedPaths:
"""A date must render the same wherever it appears in a request body."""

def test_a_date_nested_in_an_object_renders_as_a_date(self):
inner = _Holder()
inner.value = date(2026, 10, 1)
outer = _Holder()
outer.value = inner

assert _serialize(outer)['value']['value'] == '2026-10-01'

def test_a_date_inside_a_list_renders_as_a_date(self):
holder = _Holder()
holder.value = date(2026, 10, 1)

assert _serialize({'items': [holder]})['items'][0]['value'] == '2026-10-01'

def test_a_date_as_a_bare_dict_value_renders_as_a_date(self):
assert _serialize({'when': date(2026, 10, 1)})['when'] == '2026-10-01'


class TestObjectReflectionStillWorks:
"""Moving the date branches up must not change how ordinary objects serialize."""

def test_an_object_is_still_reflected_into_its_attributes(self):
holder = _Holder()
holder.value = 'plain'

assert _serialize(holder) == {'value': 'plain'}

def test_key_transformations_are_still_applied(self):
holder = _Holder()
holder.three_ds = True

assert _serialize(holder) == {'3ds': True}
64 changes: 64 additions & 0 deletions tests/payments/contexts/payment_contexts_serialization_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import json

from checkout_sdk.json_serializer import JsonSerializer
from checkout_sdk.payments.contexts.contexts import (
PaymentContextsFlightLegDetails, PaymentContextsPassenger, PaymentContextsTicket,
)


def _serialize(obj):
return json.loads(json.dumps(obj, cls=JsonSerializer))


class TestDateFieldTypes:
"""The specification declares these fields `format: date`.

The serializer renders anything with strftime through isoformat(), so a datetime emits a full
ISO timestamp. Only a yyyy-MM-dd string produces the declared format, so these attributes are
annotated `str` -- the same convention as BacsNotificationRequest.collection_date and
SepaInstrumentData.date_of_signature.
"""

def test_airline_date_fields_are_annotated_as_strings(self):
assert PaymentContextsTicket.__annotations__['issue_date'] is str
assert PaymentContextsPassenger.__annotations__['date_of_birth'] is str
assert PaymentContextsFlightLegDetails.__annotations__['departure_date'] is str

def test_ticket_issue_date_serializes_in_the_declared_format(self):
ticket = PaymentContextsTicket()
ticket.number = '045-21351455613'
ticket.issue_date = '2023-05-20'

serialized = _serialize(ticket)

assert serialized['number'] == '045-21351455613'
assert serialized['issue_date'] == '2023-05-20'

def test_passenger_date_of_birth_serializes_in_the_declared_format(self):
passenger = PaymentContextsPassenger()
passenger.first_name = 'John'
passenger.date_of_birth = '1990-05-26'

assert _serialize(passenger)['date_of_birth'] == '1990-05-26'

def test_flight_leg_departure_date_serializes_in_the_declared_format(self):
leg = PaymentContextsFlightLegDetails()
leg.flight_number = '101'
leg.departure_date = '2023-06-19'

assert _serialize(leg)['departure_date'] == '2023-06-19'

def test_unset_date_fields_are_absent(self):
ticket = PaymentContextsTicket()
ticket.number = '045-21351455613'

assert 'issue_date' not in _serialize(ticket)

def test_a_datetime_would_not_serialize_in_the_declared_format(self):
# Documents why the annotation is str: this is what a datetime produces. These fields were
# annotated `datetime`, so following the annotation put a timestamp on a date-only field.
from datetime import datetime
ticket = PaymentContextsTicket()
ticket.issue_date = datetime(2023, 5, 20)

assert _serialize(ticket)['issue_date'] == '2023-05-20T00:00:00'
74 changes: 72 additions & 2 deletions tests/payments/setups/payment_setups_serialization_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
PaymentSetupAirline, PaymentSetupAirlineTicket, PaymentSetupAirlinePassenger,
PaymentSetupAirlinePassengerAddress, PaymentSetupFlightLegDetails,
PaymentSetupAirlineInsurance, PaymentSetupAirlineInsurancePrice,
MerchantAccount, OrderSubMerchant,
)


Expand Down Expand Up @@ -370,8 +371,6 @@ def test_order_serializes_amount_allocations(self):
}]
}

# ── INT-1696 additions ───────────────────────────────────────────────────

def test_industry_accommodation_serializes_all_fields(self):
address = PaymentSetupAccommodationAddress()
address.address_line1 = '123 High Street'
Expand Down Expand Up @@ -525,3 +524,74 @@ def test_industry_airline_serializes_all_fields(self):
},
}]
}


class TestDateFieldTypes:
"""The specification declares these fields `format: date`.

The serializer renders anything with strftime through isoformat(), so a datetime emits a full
ISO timestamp. Only a yyyy-MM-dd string produces the declared format, so these attributes are
annotated `str` -- the same convention as BacsNotificationRequest.collection_date and
SepaInstrumentData.date_of_signature.
"""

def test_merchant_account_date_fields_are_annotated_as_strings(self):
for field in (
'registration_date', 'last_modified',
'first_transaction_date', 'last_transaction_date',
):
assert MerchantAccount.__annotations__[field] is str, field

def test_sub_merchant_and_mandate_date_fields_are_annotated_as_strings(self):
assert OrderSubMerchant.__annotations__['registration_date'] is str
assert SepaMandate.__annotations__['date_of_signature'] is str

def test_merchant_account_string_dates_serialize_in_the_declared_format(self):
account = MerchantAccount()
account.id = 'acct_1'
account.registration_date = '2023-05-01'
account.last_modified = '2023-05-02'
account.first_transaction_date = '2023-09-15'
account.last_transaction_date = '2025-03-28'

serialized = _serialize(account)

assert serialized['registration_date'] == '2023-05-01'
assert serialized['last_modified'] == '2023-05-02'
assert serialized['first_transaction_date'] == '2023-09-15'
assert serialized['last_transaction_date'] == '2025-03-28'

def test_sub_merchant_string_date_serializes_in_the_declared_format(self):
sub_merchant = OrderSubMerchant()
sub_merchant.id = 'sub_1'
sub_merchant.registration_date = '2023-01-15'

assert _serialize(sub_merchant)['registration_date'] == '2023-01-15'

def test_mandate_string_date_serializes_in_the_declared_format(self):
mandate = SepaMandate()
mandate.id = 'mandate_1'
mandate.date_of_signature = '2020-01-01'

assert _serialize(mandate)['date_of_signature'] == '2020-01-01'

def test_unset_date_fields_are_absent(self):
account = MerchantAccount()
account.id = 'acct_1'

serialized = _serialize(account)

for field in (
'registration_date', 'last_modified',
'first_transaction_date', 'last_transaction_date',
):
assert field not in serialized, field

def test_a_datetime_would_not_serialize_in_the_declared_format(self):
# Documents why the annotation is str: this is what a datetime produces. These fields were
# annotated `datetime`, so following the annotation put a timestamp on a date-only field.
from datetime import datetime
account = MerchantAccount()
account.registration_date = datetime(2023, 5, 1)

assert _serialize(account)['registration_date'] == '2023-05-01T00:00:00'
Loading