From c2d1db28d1e2d779b49d37fb4da0d0c58440aac7 Mon Sep 17 00:00:00 2001 From: david ruiz Date: Thu, 17 Sep 2026 11:16:32 +0200 Subject: [PATCH 1/3] Format: yyyy-MM-dd fixes --- checkout_sdk/payments/contexts/contexts.py | 8 +- checkout_sdk/payments/setups/setups.py | 12 +-- .../payment_contexts_serialization_test.py | 64 ++++++++++++++++ .../payment_setups_serialization_test.py | 73 ++++++++++++++++++- 4 files changed, 144 insertions(+), 13 deletions(-) create mode 100644 tests/payments/contexts/payment_contexts_serialization_test.py diff --git a/checkout_sdk/payments/contexts/contexts.py b/checkout_sdk/payments/contexts/contexts.py index 0842ba24..551b420e 100644 --- a/checkout_sdk/payments/contexts/contexts.py +++ b/checkout_sdk/payments/contexts/contexts.py @@ -1,5 +1,3 @@ -from datetime import datetime - from deprecated import deprecated from checkout_sdk.common.common import Address, CustomerRequest, AccountHolder @@ -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 @@ -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 @@ -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 diff --git a/checkout_sdk/payments/setups/setups.py b/checkout_sdk/payments/setups/setups.py index ed4fd97b..62e91ee0 100644 --- a/checkout_sdk/payments/setups/setups.py +++ b/checkout_sdk/payments/setups/setups.py @@ -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 @@ -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: @@ -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: diff --git a/tests/payments/contexts/payment_contexts_serialization_test.py b/tests/payments/contexts/payment_contexts_serialization_test.py new file mode 100644 index 00000000..e06178a2 --- /dev/null +++ b/tests/payments/contexts/payment_contexts_serialization_test.py @@ -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' diff --git a/tests/payments/setups/payment_setups_serialization_test.py b/tests/payments/setups/payment_setups_serialization_test.py index 6e1cd860..e1e0da7a 100644 --- a/tests/payments/setups/payment_setups_serialization_test.py +++ b/tests/payments/setups/payment_setups_serialization_test.py @@ -19,6 +19,7 @@ PaymentSetupAirline, PaymentSetupAirlineTicket, PaymentSetupAirlinePassenger, PaymentSetupAirlinePassengerAddress, PaymentSetupFlightLegDetails, PaymentSetupAirlineInsurance, PaymentSetupAirlineInsurancePrice, + MerchantAccount, OrderSubMerchant, ) @@ -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' @@ -525,3 +524,73 @@ 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' From 2383bce899776ca47bb44f8f76bb3c784bad9655 Mon Sep 17 00:00:00 2001 From: david ruiz Date: Thu, 17 Sep 2026 11:21:46 +0200 Subject: [PATCH 2/3] JsonSerializer date protection --- checkout_sdk/json_serializer.py | 18 ++++- tests/json_serializer_test.py | 121 ++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 tests/json_serializer_test.py diff --git a/checkout_sdk/json_serializer.py b/checkout_sdk/json_serializer.py index d1276ed9..e43f9586 100644 --- a/checkout_sdk/json_serializer.py +++ b/checkout_sdk/json_serializer.py @@ -1,5 +1,6 @@ import inspect import json +from datetime import date, datetime class JsonSerializer(json.JSONEncoder): @@ -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) @@ -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): diff --git a/tests/json_serializer_test.py b/tests/json_serializer_test.py new file mode 100644 index 00000000..ca535767 --- /dev/null +++ b/tests/json_serializer_test.py @@ -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} From 1e590a6626cb738f3d835ef05b40c66b0cca0fd6 Mon Sep 17 00:00:00 2001 From: david ruiz Date: Thu, 17 Sep 2026 14:15:30 +0200 Subject: [PATCH 3/3] Lint flake8 errors fix --- tests/payments/setups/payment_setups_serialization_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/payments/setups/payment_setups_serialization_test.py b/tests/payments/setups/payment_setups_serialization_test.py index e1e0da7a..7c475209 100644 --- a/tests/payments/setups/payment_setups_serialization_test.py +++ b/tests/payments/setups/payment_setups_serialization_test.py @@ -525,6 +525,7 @@ def test_industry_airline_serializes_all_fields(self): }] } + class TestDateFieldTypes: """The specification declares these fields `format: date`.