From 25597981d8eb0492b62586880704c3af208cbff9 Mon Sep 17 00:00:00 2001 From: rodrigojara-cko Date: Thu, 15 Oct 2026 10:10:27 +0200 Subject: [PATCH 1/2] fix(setups): align the payment setup request shape with the spec The PaymentSetup schema (POST /payments/setups, PUT and GET /payments/setups/{id}, POST /payments/setups/{id}/confirm/{payment_method_name}) nests the card fields under payment_methods.card.details and declares the Apple Pay and Google Pay token as an object. The SDK sent the card fields at the top level of the card object and the wallet token as a string, so the API ignored them. - Card: add details (CardDetails), token (CardToken), schemes and preferred_scheme. The flat card attributes (number, last4, bin, scheme, expiry_month, expiry_year, name, cvv, stored, expires_on, allow_update) are deprecated: they keep working, emit a DeprecationWarning, and store their value under details, so it is sent where the API reads it. - ApplePay and GooglePay: token is now a WalletToken object (id, expires_on), the shape the API expects; the flat expires_on is deprecated and moves under token. Add schemes and preferred_scheme. - PaymentMethods: add auto_confirm. PaymentSetupsRequest: add metadata. - PaymentSetupInstrument: add the read-only type. - New enums PaymentSetupCardScheme and GooglePayPreferredScheme; card and Apple Pay reuse PreferredSchema, whose values match. The wallet token type change from str to object is forced by the API: a string token was never accepted, so this ships in a minor release. --- checkout_sdk/payments/setups/setups.py | 277 +++++++++-- .../payment_setups_serialization_test.py | 431 +++++++++++++++++- 2 files changed, 655 insertions(+), 53 deletions(-) diff --git a/checkout_sdk/payments/setups/setups.py b/checkout_sdk/payments/setups/setups.py index c8ccbdc..6773653 100644 --- a/checkout_sdk/payments/setups/setups.py +++ b/checkout_sdk/payments/setups/setups.py @@ -1,10 +1,49 @@ from __future__ import absolute_import +import warnings from datetime import datetime from enum import Enum from checkout_sdk.common.common import Address, Phone from checkout_sdk.common.enums import Currency -from checkout_sdk.payments.payments import PaymentType, ShippingDetails +from checkout_sdk.payments.payments import PaymentType, PreferredSchema, ShippingDetails + + +# Deprecated attributes that moved into a nested object +def _moved_attribute(name: str, container: str, container_type: type): + """Builds a deprecated attribute kept for existing code after the spec moved it into a nested + object. Reading or writing it emits a DeprecationWarning and uses container.name instead, so a + value set through the old attribute is serialized under the nested object.""" + + def _warn(obj): + warnings.warn( + '{0}.{1} is deprecated and will be removed in the next major version. Use ' + '{0}.{2}.{1} instead.'.format(type(obj).__name__, name, container), + DeprecationWarning, + stacklevel=3) + + def getter(self): + _warn(self) + return getattr(getattr(self, container), name) + + def setter(self, value): + _warn(self) + if not hasattr(self, container): + setattr(self, container, container_type()) + setattr(getattr(self, container), name, value) + + return property(getter, setter) + + +class _SerializesOwnAttributes: + """Serializes only the attributes set on the instance. + + JsonSerializer reflects every readable attribute, which would also emit the deprecated + attributes built by _moved_attribute at their old position. Their values are stored on the + nested object, so serializing the instance attributes sends each value once, where the API + reads it.""" + + def to_json(self): + return dict(vars(self)) # Enums @@ -28,6 +67,34 @@ class OsType(str, Enum): IOS = 'ios' +class PaymentSetupCardScheme(str, Enum): + """A supported card scheme name, as listed in the schemes of the card and wallet payment + methods.""" + ACCEL = 'accel' + AMEX = 'amex' + CARTES_BANCAIRES = 'cartes_bancaires' + CHINA_UNION_PAY = 'china_union_pay' + DINERS = 'diners' + DISCOVER = 'discover' + JCB = 'jcb' + MADA = 'mada' + MAESTRO = 'maestro' + MASTERCARD = 'mastercard' + NYCE = 'nyce' + OMANNET = 'omannet' + PULSE = 'pulse' + SHAZAM = 'shazam' + STAR = 'star' + UPI = 'upi' + VISA = 'visa' + + +class GooglePayPreferredScheme(str, Enum): + """The preferred card scheme to process a Google Pay payment.""" + VISA = 'visa' + MASTERCARD = 'mastercard' + + # Customer entities class CustomerEmail: """Details of the customer's email.""" @@ -367,9 +434,14 @@ class PaymentSetupAccountHolder: # Instrument entities class PaymentSetupInstrument(PaymentSetupPaymentMethod): """The instrument payment method's details and configuration.""" - # The unique identifier of the selected instrument. + # The unique identifier of the selected instrument. For example, + # src_wmlfc3zttb4uzmk6snpwb43jbi. # [Optional] id: str + # The type of the instrument. For example, bacs. + # [Optional] + # readOnly + type: str # The customer's phone number. Not present in the current instrument schema. # [Optional] phone: Phone @@ -380,7 +452,7 @@ class PaymentSetupInstrument(PaymentSetupPaymentMethod): # Not present in the current instrument schema. # [Optional] allow_update: bool - # The next available action for the payment method. + # The next available action for the payment method. Its type is set_instrument. # [Optional] # readOnly action: PaymentMethodAction @@ -730,29 +802,53 @@ class GooglePayTokenData: tokenization_key: str -class GooglePay(PaymentSetupPaymentMethod): +class WalletToken: + """The Checkout.com token of a wallet payment method (Apple Pay, Google Pay).""" + # The token identifier, prefixed by tok_. + # [Required] + # ^(tok)_(\w{26})$ + id: str + # The date and time the token expires, in ISO 8601 format. + # [Optional] + # Format: date-time (RFC 3339) + expires_on: datetime + + +class GooglePay(_SerializesOwnAttributes, PaymentSetupPaymentMethod): """The Google Pay payment method's details and configuration.""" # The Google Pay token data. # [Optional] + # writeOnly token_data: GooglePayTokenData - # The Checkout.com Google Pay token. The spec declares token as an object with id and - # expires_on; this SDK field carries the token identifier only. - # [Optional] - token: str - # The date and time the token expires, in ISO 8601 format. The spec nests it under token. + # The Checkout.com Google Pay token. # [Optional] - # Format: date-time (RFC 3339) - expires_on: datetime + token: WalletToken # Set to true if you intend to reuse the payment credentials in subsequent payments. If set # to false, a payment instrument will not be included in the payment response. # [Optional] + # writeOnly store_for_future_use: bool - # The customer's phone number. + # The customer's phone number. country_code is min 1, max 7 characters; number is min 6, + # max 25 characters. # [Optional] phone: Phone # Account holder details for the payment. # [Optional] account_holder: PaymentSetupAccountHolder + # The list of supported card scheme names. The Google Pay schema lists every + # PaymentSetupCardScheme value except cartes_bancaires. + # [Optional] + # readOnly + schemes: list # list of PaymentSetupCardScheme + # The preferred card scheme to process the payment. + # [Optional] + preferred_scheme: GooglePayPreferredScheme + # Deprecated: use token.expires_on instead. Will be removed in the next major version. + # Reading or writing it uses token.expires_on, so the value is sent under token. + # The date and time the token expires, in ISO 8601 format. + # [Optional] + # Format: date-time (RFC 3339) + expires_on = _moved_attribute('expires_on', 'token', WalletToken) # Apple Pay entities @@ -787,29 +883,40 @@ class ApplePayTokenData: header: ApplePayTokenDataHeader -class ApplePay(PaymentSetupPaymentMethod): +class ApplePay(_SerializesOwnAttributes, PaymentSetupPaymentMethod): """The Apple Pay payment method's details and configuration.""" # The Apple Pay token data. # [Optional] + # writeOnly token_data: ApplePayTokenData - # The Checkout.com Apple Pay token. The spec declares token as an object with id and - # expires_on; this SDK field carries the token identifier only. - # [Optional] - token: str - # The date and time the token expires, in ISO 8601 format. The spec nests it under token. + # The Checkout.com Apple Pay token. # [Optional] - # Format: date-time (RFC 3339) - expires_on: datetime + token: WalletToken # Set to true if you intend to reuse the payment credentials in subsequent payments. If set # to false, a payment instrument will not be included in the payment response. # [Optional] + # writeOnly store_for_future_use: bool - # The customer's phone number. + # The customer's phone number. country_code is min 1, max 7 characters; number is min 6, + # max 25 characters. # [Optional] phone: Phone # Account holder details for the payment. # [Optional] account_holder: PaymentSetupAccountHolder + # The list of supported card scheme names. + # [Optional] + # readOnly + schemes: list # list of PaymentSetupCardScheme + # The preferred card scheme to process the payment. + # [Optional] + preferred_scheme: PreferredSchema + # Deprecated: use token.expires_on instead. Will be removed in the next major version. + # Reading or writing it uses token.expires_on, so the value is sent under token. + # The date and time the token expires, in ISO 8601 format. + # [Optional] + # Format: date-time (RFC 3339) + expires_on = _moved_attribute('expires_on', 'token', WalletToken) # Bacs entities @@ -1111,68 +1218,152 @@ class Stablecoin(PaymentSetupPaymentMethod): # Card entities -class Card(PaymentSetupPaymentMethod): - """The Card payment method's details and configuration. +class CardToken: + """The Checkout.com card token of the card payment method.""" + # The Checkout.com card token identifier. + # [Optional] + id: str - The spec nests the card fields under details (and the token under token.id); this class - keeps them at the top level of the card object. - """ - # The card number (without separators). The spec nests it under details. + +class CardDetails: + """The card details of the card payment method.""" + # The card number (without separators). # [Optional] + # writeOnly number: str - # The last four digits of the card number. The spec nests it under details. + # The last four digits of the card number. # [Optional] # readOnly last4: str - # The card issuer's Bank Identification Number (BIN). The spec nests it under details. + # The card issuer's Bank Identification Number (BIN). # [Optional] # readOnly bin: str - # The card scheme. The spec nests it under details. + # The card scheme. # [Optional] # readOnly scheme: str - # The expiry month of the card. The spec nests it under details. + # The expiry month of the card. # [Optional] expiry_month: int - # The expiry year of the card. The spec nests it under details. + # The expiry year of the card. # [Optional] expiry_year: int - # The cardholder's name. The spec nests it under details. + # The cardholder's name. # [Optional] + # writeOnly name: str # The card verification value/code. 3 digits, except for American Express (4 digits). - # The spec nests it under details. # [Optional] + # writeOnly cvv: str # Indicates whether this card is being submitted from your own stored card-on-file system, - # rather than being entered by the customer at the time of payment. The spec nests it - # under details. + # rather than being entered by the customer at the time of payment. Set to true when you + # provide card details that you already have on file for the customer. # [Optional] + # writeOnly stored: bool - # The time by which the card details must be confirmed. The spec nests it under details. + # The time by which the card details must be confirmed. # [Optional] # readOnly # Format: date-time (RFC 3339) expires_on: datetime + # Indicates whether to use the Real-Time Account Updater to update the card information. + # [Optional] + allow_update: bool + + +class Card(_SerializesOwnAttributes, PaymentSetupPaymentMethod): + """The Card payment method's details and configuration. + + The card fields live under details. The attributes of the same name directly on Card + (number, last4, bin, scheme, expiry_month, expiry_year, name, cvv, stored, expires_on, + allow_update) are deprecated: they read and write details, so their values are sent under + details and never at the top level of the card object. + """ + # The Checkout.com card token. + # [Optional] + token: CardToken + # The card details. + # [Optional] + details: CardDetails # Set to true if you intend to reuse the payment credentials in subsequent payments. If set # to false, a payment instrument will not be included in the payment response. # [Optional] + # writeOnly store_for_future_use: bool - # The customer's phone number. + # The customer's phone number. country_code is min 1, max 7 characters; number is min 6, + # max 25 characters. # [Optional] phone: Phone # Account holder details for the payment. # [Optional] account_holder: PaymentSetupAccountHolder + # The list of supported card scheme names. + # [Optional] + # readOnly + schemes: list # list of PaymentSetupCardScheme + # The preferred card scheme to process the payment. + # [Optional] + preferred_scheme: PreferredSchema + + # Deprecated: use details.number instead. Will be removed in the next major version. + # The card number (without separators). + # [Optional] + number = _moved_attribute('number', 'details', CardDetails) + # Deprecated: use details.last4 instead. Will be removed in the next major version. + # The last four digits of the card number. + # [Optional] + # readOnly + last4 = _moved_attribute('last4', 'details', CardDetails) + # Deprecated: use details.bin instead. Will be removed in the next major version. + # The card issuer's Bank Identification Number (BIN). + # [Optional] + # readOnly + bin = _moved_attribute('bin', 'details', CardDetails) + # Deprecated: use details.scheme instead. Will be removed in the next major version. + # The card scheme. + # [Optional] + # readOnly + scheme = _moved_attribute('scheme', 'details', CardDetails) + # Deprecated: use details.expiry_month instead. Will be removed in the next major version. + # The expiry month of the card. + # [Optional] + expiry_month = _moved_attribute('expiry_month', 'details', CardDetails) + # Deprecated: use details.expiry_year instead. Will be removed in the next major version. + # The expiry year of the card. + # [Optional] + expiry_year = _moved_attribute('expiry_year', 'details', CardDetails) + # Deprecated: use details.name instead. Will be removed in the next major version. + # The cardholder's name. + # [Optional] + name = _moved_attribute('name', 'details', CardDetails) + # Deprecated: use details.cvv instead. Will be removed in the next major version. + # The card verification value/code. 3 digits, except for American Express (4 digits). + # [Optional] + cvv = _moved_attribute('cvv', 'details', CardDetails) + # Deprecated: use details.stored instead. Will be removed in the next major version. + # Indicates whether this card is being submitted from your own stored card-on-file system. + # [Optional] + stored = _moved_attribute('stored', 'details', CardDetails) + # Deprecated: use details.expires_on instead. Will be removed in the next major version. + # The time by which the card details must be confirmed. + # [Optional] + # readOnly + # Format: date-time (RFC 3339) + expires_on = _moved_attribute('expires_on', 'details', CardDetails) + # Deprecated: use details.allow_update instead. Will be removed in the next major version. # Indicates whether to use the Real-Time Account Updater to update the card information. - # The spec nests it under details. # [Optional] - allow_update: bool + allow_update = _moved_attribute('allow_update', 'details', CardDetails) class PaymentMethods: """The payment methods that are enabled on your account and available for use.""" + # The payment method that automatically confirms the payment when it is ready. For example, + # card. + # [Optional] + auto_confirm: str # The instrument payment method's details and configuration. # [Optional] instrument: PaymentSetupInstrument @@ -1915,3 +2106,9 @@ class PaymentSetupsRequest: # Account funding transaction details for the payment. # [Optional] account_funding_transaction: PaymentSetupAccountFundingTransaction + # A set of key/value pairs you can attach to a payment. It can be useful for storing + # additional information about the payment. Each value is a string (max 255 characters), + # a number or a boolean. + # [Optional] + # max 20 properties + metadata: dict diff --git a/tests/payments/setups/payment_setups_serialization_test.py b/tests/payments/setups/payment_setups_serialization_test.py index 076d5fc..857499f 100644 --- a/tests/payments/setups/payment_setups_serialization_test.py +++ b/tests/payments/setups/payment_setups_serialization_test.py @@ -1,4 +1,5 @@ import json +from datetime import datetime import pytest @@ -25,8 +26,10 @@ MerchantAccount, OrderSubMerchant, PaymentSetupsRequest, Customer, CustomerEmail, CustomerDevice, CustomerDeviceClient, PaymentMethodInitialization, CashApp, CashAppAction, CashAppActionType, CashAppAddress, - CashAppCustomerProfile, PaymentSetupRisk, + CashAppCustomerProfile, PaymentSetupRisk, CardDetails, CardToken, WalletToken, PaymentSetupCardScheme, + GooglePayPreferredScheme, ) +from checkout_sdk.payments.payments import PreferredSchema def _serialize(obj): @@ -159,7 +162,9 @@ def test_sepa_serializes_account_holder_and_mandate(self): def test_googlepay_serializes_token_data(self): googlepay = GooglePay() - googlepay.token = 'tok_x' + token = WalletToken() + token.id = 'tok_x' + googlepay.token = token token_data = GooglePayTokenData() token_data.protocol_version = 'ECv2' token_data.signature = 'sig' @@ -168,7 +173,7 @@ def test_googlepay_serializes_token_data(self): googlepay.token_data = token_data assert _serialize(googlepay) == { - 'token': 'tok_x', + 'token': {'id': 'tok_x'}, 'token_data': { 'protocol_version': 'ECv2', 'signature': 'sig', @@ -204,12 +209,14 @@ def test_applepay_serializes_token_data_with_header(self): } def test_card_serializes_writable_fields_and_account_holder(self): + details = CardDetails() + details.number = '4242424242424242' + details.expiry_month = 12 + details.expiry_year = 2030 + details.name = 'John Smith' + details.cvv = '100' card = Card() - card.number = '4242424242424242' - card.expiry_month = 12 - card.expiry_year = 2030 - card.name = 'John Smith' - card.cvv = '100' + card.details = details holder = PaymentSetupAccountHolder() holder.type = PaymentSetupAccountHolderType.INDIVIDUAL @@ -218,11 +225,13 @@ def test_card_serializes_writable_fields_and_account_holder(self): card.account_holder = holder assert _serialize(card) == { - 'number': '4242424242424242', - 'expiry_month': 12, - 'expiry_year': 2030, - 'name': 'John Smith', - 'cvv': '100', + 'details': { + 'number': '4242424242424242', + 'expiry_month': 12, + 'expiry_year': 2030, + 'name': 'John Smith', + 'cvv': '100', + }, 'account_holder': {'type': 'individual', 'first_name': 'John', 'last_name': 'Smith'}, } @@ -1035,3 +1044,399 @@ def test_response_reads_paybybank_action(self): assert bank.logo_url == 'https://example.com/natwest.png' assert bank.available is True assert response.risk.enabled is True + + +_CARD_DETAIL_FIELDS = ('number', 'last4', 'bin', 'scheme', 'expiry_month', 'expiry_year', 'name', + 'cvv', 'stored', 'expires_on', 'allow_update') + +_CARD_DETAIL_VALUES = { + 'number': '4242424242424242', + 'last4': '4242', + 'bin': '424242', + 'scheme': 'Visa', + 'expiry_month': 12, + 'expiry_year': 2030, + 'name': 'John Smith', + 'cvv': '100', + 'stored': True, + 'expires_on': datetime(2026, 6, 1, 10, 0, 0), + 'allow_update': True, +} + +_CARD_DETAIL_WIRE = dict(_CARD_DETAIL_VALUES, expires_on='2026-06-01T10:00:00') + +_WALLET_TOKEN_ID = 'tok_ubfj2q76miwundwlk72vxt2i7q' + + +def _phone(): + phone = Phone() + phone.country_code = '+44' + phone.number = '207 946 0000' + return phone + + +def _account_holder(): + holder = PaymentSetupAccountHolder() + holder.type = PaymentSetupAccountHolderType.INDIVIDUAL + holder.first_name = 'John' + holder.last_name = 'Smith' + holder.middle_name = 'Paul' + holder.company_name = 'ACME Ltd' + holder.account_name_inquiry = True + return holder + + +_ACCOUNT_HOLDER_WIRE = { + 'type': 'individual', 'first_name': 'John', 'last_name': 'Smith', 'middle_name': 'Paul', + 'company_name': 'ACME Ltd', 'account_name_inquiry': True, +} + + +class TestPaymentSetupCardSerialization: + """payment_methods.card: token, details and the card-level properties, all of them.""" + + def _card(self): + details = CardDetails() + for field, value in _CARD_DETAIL_VALUES.items(): + setattr(details, field, value) + token = CardToken() + token.id = 'tok_4gzeau5o2uqubbk6fufs3m7p54' + card = Card() + card.status = 'ready' + card.flags = ['flag_a'] + card.token = token + card.details = details + card.store_for_future_use = True + card.phone = _phone() + card.account_holder = _account_holder() + card.schemes = [PaymentSetupCardScheme.VISA, PaymentSetupCardScheme.CARTES_BANCAIRES] + card.preferred_scheme = PreferredSchema.CARTES_BANCAIRES + return card + + def test_card_serializes_every_property_under_the_spec_keys(self): + body = json.dumps(self._card(), cls=JsonSerializer) + + for key in ('"token"', '"details"', '"store_for_future_use"', '"schemes"', + '"preferred_scheme"', '"expiry_month"', '"allow_update"'): + assert key in body, key + assert json.loads(body) == { + 'status': 'ready', + 'flags': ['flag_a'], + 'token': {'id': 'tok_4gzeau5o2uqubbk6fufs3m7p54'}, + 'details': _CARD_DETAIL_WIRE, + 'store_for_future_use': True, + 'phone': {'country_code': '+44', 'number': '207 946 0000'}, + 'account_holder': _ACCOUNT_HOLDER_WIRE, + 'schemes': ['visa', 'cartes_bancaires'], + 'preferred_scheme': 'cartes_bancaires', + } + + def test_card_round_trip_keeps_every_property(self): + card = self._card() + + read = ResponseWrapper(None, _serialize(card)) + + assert read.status == 'ready' + assert read.flags == ['flag_a'] + assert read.token.id == card.token.id + for field in _CARD_DETAIL_FIELDS: + assert getattr(read.details, field) == _CARD_DETAIL_WIRE[field], field + assert read.store_for_future_use is True + assert read.phone.country_code == '+44' + assert read.phone.number == '207 946 0000' + for field, value in _ACCOUNT_HOLDER_WIRE.items(): + assert getattr(read.account_holder, field) == value, field + assert read.schemes == [PaymentSetupCardScheme.VISA, PaymentSetupCardScheme.CARTES_BANCAIRES] + assert read.preferred_scheme == PreferredSchema.CARTES_BANCAIRES + + def test_card_spec_example_reads(self): + read = ResponseWrapper(None, { + 'status': 'ready', + 'flags': [], + 'token': {'id': 'tok_4gzeau5o2uqubbk6fufs3m7p54'}, + 'details': { + 'last4': '4242', 'bin': '424242', 'scheme': 'Visa', 'expiry_month': 12, + 'expiry_year': 2030, 'expires_on': '2026-06-01T10:00:00Z', 'allow_update': True, + }, + 'phone': {'country_code': '+44', 'number': '207 946 0000'}, + 'account_holder': {'type': 'individual', 'first_name': 'John', 'last_name': 'Smith'}, + 'schemes': ['visa', 'mastercard'], + 'preferred_scheme': 'visa', + }) + + assert read.status == 'ready' + assert read.token.id == 'tok_4gzeau5o2uqubbk6fufs3m7p54' + assert read.details.last4 == '4242' + assert read.details.bin == '424242' + assert read.details.scheme == 'Visa' + assert read.details.expiry_month == 12 + assert read.details.expiry_year == 2030 + assert read.details.expires_on == '2026-06-01T10:00:00Z' + assert read.details.allow_update is True + assert read.phone.number == '207 946 0000' + assert read.account_holder.type == PaymentSetupAccountHolderType.INDIVIDUAL + assert read.schemes == [PaymentSetupCardScheme.VISA, PaymentSetupCardScheme.MASTERCARD] + assert read.preferred_scheme == PreferredSchema.VISA + + @pytest.mark.parametrize('field', _CARD_DETAIL_FIELDS) + def test_deprecated_flat_card_attribute_is_sent_under_details(self, field): + card = Card() + + with pytest.deprecated_call(): + setattr(card, field, _CARD_DETAIL_VALUES[field]) + with pytest.deprecated_call(): + assert getattr(card, field) == _CARD_DETAIL_VALUES[field] + + assert getattr(card.details, field) == _CARD_DETAIL_VALUES[field] + assert _serialize(card) == {'details': {field: _CARD_DETAIL_WIRE[field]}} + + def test_deprecated_flat_card_attributes_write_into_existing_details(self): + details = CardDetails() + details.number = '4242424242424242' + card = Card() + card.details = details + + with pytest.deprecated_call(): + card.cvv = '100' + + assert card.details is details + assert _serialize(card) == {'details': {'number': '4242424242424242', 'cvv': '100'}} + + def test_unset_deprecated_flat_card_attribute_reads_as_missing(self): + card = Card() + + with pytest.deprecated_call(), pytest.raises(AttributeError): + _ = card.number + assert _serialize(card) == {} + + +class TestPaymentSetupWalletSerialization: + """payment_methods.applepay and payment_methods.googlepay: token object, schemes and + preferred_scheme. Neither wallet has a details object.""" + + @staticmethod + def _token(): + token = WalletToken() + token.id = _WALLET_TOKEN_ID + token.expires_on = datetime(2026, 6, 1, 10, 0, 0) + return token + + def _applepay(self): + header = ApplePayTokenDataHeader() + header.ephemeral_public_key = 'key' + header.public_key_hash = 'hash' + header.transaction_id = 'txn' + token_data = ApplePayTokenData() + token_data.version = 'EC_v1' + token_data.data = 'encrypted' + token_data.signature = 'sig' + token_data.header = header + applepay = ApplePay() + applepay.status = 'ready' + applepay.flags = ['flag_a'] + applepay.token_data = token_data + applepay.token = self._token() + applepay.store_for_future_use = True + applepay.phone = _phone() + applepay.account_holder = _account_holder() + applepay.schemes = [PaymentSetupCardScheme.VISA, PaymentSetupCardScheme.CARTES_BANCAIRES] + applepay.preferred_scheme = PreferredSchema.CARTES_BANCAIRES + return applepay + + def _googlepay(self): + token_data = GooglePayTokenData() + token_data.protocol_version = 'ECv2' + token_data.signature = 'sig' + token_data.signed_message = 'msg' + token_data.tokenization_key = 'pk_x' + googlepay = GooglePay() + googlepay.status = 'ready' + googlepay.flags = ['flag_a'] + googlepay.token_data = token_data + googlepay.token = self._token() + googlepay.store_for_future_use = True + googlepay.phone = _phone() + googlepay.account_holder = _account_holder() + googlepay.schemes = [PaymentSetupCardScheme.VISA, PaymentSetupCardScheme.MASTERCARD] + googlepay.preferred_scheme = GooglePayPreferredScheme.MASTERCARD + return googlepay + + def test_applepay_serializes_every_property_with_the_token_as_an_object(self): + body = json.dumps(self._applepay(), cls=JsonSerializer) + + assert '"token": {' in body + assert '"id": "%s"' % _WALLET_TOKEN_ID in body + assert '"details"' not in body + assert json.loads(body) == { + 'status': 'ready', + 'flags': ['flag_a'], + 'token_data': { + 'version': 'EC_v1', 'data': 'encrypted', 'signature': 'sig', + 'header': {'ephemeral_public_key': 'key', 'public_key_hash': 'hash', + 'transaction_id': 'txn'}, + }, + 'token': {'id': _WALLET_TOKEN_ID, 'expires_on': '2026-06-01T10:00:00'}, + 'store_for_future_use': True, + 'phone': {'country_code': '+44', 'number': '207 946 0000'}, + 'account_holder': _ACCOUNT_HOLDER_WIRE, + 'schemes': ['visa', 'cartes_bancaires'], + 'preferred_scheme': 'cartes_bancaires', + } + + def test_googlepay_serializes_every_property_with_the_token_as_an_object(self): + body = json.dumps(self._googlepay(), cls=JsonSerializer) + + assert '"token": {' in body + assert '"id": "%s"' % _WALLET_TOKEN_ID in body + assert '"details"' not in body + assert json.loads(body) == { + 'status': 'ready', + 'flags': ['flag_a'], + 'token_data': { + 'protocol_version': 'ECv2', 'signature': 'sig', 'signed_message': 'msg', + 'tokenization_key': 'pk_x', + }, + 'token': {'id': _WALLET_TOKEN_ID, 'expires_on': '2026-06-01T10:00:00'}, + 'store_for_future_use': True, + 'phone': {'country_code': '+44', 'number': '207 946 0000'}, + 'account_holder': _ACCOUNT_HOLDER_WIRE, + 'schemes': ['visa', 'mastercard'], + 'preferred_scheme': 'mastercard', + } + + @pytest.mark.parametrize('wallet', ['_applepay', '_googlepay']) + def test_wallet_round_trip_keeps_every_property(self, wallet): + method = getattr(self, wallet)() + + read = ResponseWrapper(None, _serialize(method)) + + assert read.status == 'ready' + assert read.flags == ['flag_a'] + assert read.token.id == _WALLET_TOKEN_ID + assert read.token.expires_on == '2026-06-01T10:00:00' + for field in vars(method.token_data): + if field != 'header': + assert getattr(read.token_data, field) == getattr(method.token_data, field), field + assert read.store_for_future_use is True + assert read.phone.number == '207 946 0000' + for field, value in _ACCOUNT_HOLDER_WIRE.items(): + assert getattr(read.account_holder, field) == value, field + assert read.schemes == method.schemes + assert read.preferred_scheme == method.preferred_scheme + + def test_wallet_spec_example_reads(self): + read = ResponseWrapper(None, { + 'status': 'ready', + 'flags': [], + 'token': {'id': _WALLET_TOKEN_ID, 'expires_on': '2026-06-01T10:00:00Z'}, + 'schemes': ['visa', 'mastercard'], + 'preferred_scheme': 'visa', + }) + + assert read.status == 'ready' + assert read.token.id == _WALLET_TOKEN_ID + assert read.token.expires_on == '2026-06-01T10:00:00Z' + assert read.schemes == [PaymentSetupCardScheme.VISA, PaymentSetupCardScheme.MASTERCARD] + assert read.preferred_scheme == GooglePayPreferredScheme.VISA + + @pytest.mark.parametrize('wallet_type', [ApplePay, GooglePay]) + def test_deprecated_wallet_expires_on_is_sent_under_token(self, wallet_type): + wallet = wallet_type() + expires_on = datetime(2026, 6, 1, 10, 0, 0) + + with pytest.deprecated_call(): + wallet.expires_on = expires_on + with pytest.deprecated_call(): + assert wallet.expires_on == expires_on + + assert wallet.token.expires_on == expires_on + assert _serialize(wallet) == {'token': {'expires_on': '2026-06-01T10:00:00'}} + + def test_deprecated_wallet_expires_on_writes_into_existing_token(self): + token = WalletToken() + token.id = _WALLET_TOKEN_ID + applepay = ApplePay() + applepay.token = token + + with pytest.deprecated_call(): + applepay.expires_on = datetime(2026, 6, 1, 10, 0, 0) + + assert applepay.token is token + assert _serialize(applepay) == { + 'token': {'id': _WALLET_TOKEN_ID, 'expires_on': '2026-06-01T10:00:00'} + } + + +class TestPaymentSetupSchemeEnums: + + def test_card_scheme_has_every_spec_value(self): + assert [s.value for s in PaymentSetupCardScheme] == [ + 'accel', 'amex', 'cartes_bancaires', 'china_union_pay', 'diners', 'discover', 'jcb', + 'mada', 'maestro', 'mastercard', 'nyce', 'omannet', 'pulse', 'shazam', 'star', 'upi', + 'visa', + ] + + def test_preferred_scheme_enums_have_every_spec_value(self): + assert [s.value for s in PreferredSchema] == ['visa', 'mastercard', 'cartes_bancaires'] + assert [s.value for s in GooglePayPreferredScheme] == ['visa', 'mastercard'] + + +class TestPaymentSetupRequestTopLevelSerialization: + """payment_methods.auto_confirm, payment_methods.instrument and the request metadata.""" + + def test_instrument_round_trip_keeps_every_spec_property(self): + instrument = PaymentSetupInstrument() + instrument.status = 'ready' + instrument.flags = [] + instrument.id = 'src_wmlfc3zttb4uzmk6snpwb43jbi' + instrument.type = 'bacs' + + body = json.dumps(instrument, cls=JsonSerializer) + + assert '"type": "bacs"' in body + read = ResponseWrapper(None, json.loads(body)) + assert read.status == 'ready' + assert read.flags == [] + assert read.id == 'src_wmlfc3zttb4uzmk6snpwb43jbi' + assert read.type == 'bacs' + + def test_instrument_spec_example_reads(self): + read = ResponseWrapper(None, { + 'status': 'ready', + 'id': 'src_wmlfc3zttb4uzmk6snpwb43jbi', + 'type': 'bacs', + 'action': {'type': 'set_instrument'}, + }) + + assert read.id == 'src_wmlfc3zttb4uzmk6snpwb43jbi' + assert read.type == 'bacs' + assert read.action.type == 'set_instrument' + + def test_auto_confirm_and_metadata_serialize_under_the_spec_keys(self): + payment_methods = PaymentMethods() + payment_methods.auto_confirm = 'card' + request = PaymentSetupsRequest() + request.processing_channel_id = 'pc_aaaaaaaaaaaaaaaaaaaaaaaaaa' + request.amount = 1000 + request.currency = Currency.GBP + request.payment_methods = payment_methods + request.metadata = {'coupon_code': 'NY2018', 'partner_id': 123989, 'vip': True} + + body = json.dumps(request, cls=JsonSerializer) + + assert '"auto_confirm": "card"' in body + assert 'autoConfirm' not in body + assert '"metadata"' in body + assert json.loads(body) == { + 'processing_channel_id': 'pc_aaaaaaaaaaaaaaaaaaaaaaaaaa', + 'amount': 1000, + 'currency': 'GBP', + 'payment_methods': {'auto_confirm': 'card'}, + 'metadata': {'coupon_code': 'NY2018', 'partner_id': 123989, 'vip': True}, + } + + read = ResponseWrapper(None, json.loads(body)) + assert read.payment_methods.auto_confirm == 'card' + assert read.metadata.coupon_code == 'NY2018' + assert read.metadata.partner_id == 123989 + assert read.metadata.vip is True From d09df0f4b4aa8df0695782e6caade7ea66477172 Mon Sep 17 00:00:00 2001 From: rodrigojara-cko Date: Mon, 19 Oct 2026 09:56:40 +0200 Subject: [PATCH 2/2] refactor(setups): keep the moved card and wallet attributes as plain deprecated fields The flat Card attributes and the wallet expires_on were deprecated properties that wrote into details and token. That redirection is not needed, since the API never read those attributes at that level, and it raised an AttributeError when token was still set as a string. They are now plain attributes documented as deprecated and ignored by the API, as they were before this change, and will be removed in the next major version. Also document excluded_payment_methods as payment method names and check the Apple Pay token_data header in the wallet roundtrip. --- checkout_sdk/payments/setups/setups.py | 92 +++++++------------ .../payment_setups_serialization_test.py | 76 +++++---------- 2 files changed, 55 insertions(+), 113 deletions(-) diff --git a/checkout_sdk/payments/setups/setups.py b/checkout_sdk/payments/setups/setups.py index 6773653..661a7e0 100644 --- a/checkout_sdk/payments/setups/setups.py +++ b/checkout_sdk/payments/setups/setups.py @@ -1,5 +1,4 @@ from __future__ import absolute_import -import warnings from datetime import datetime from enum import Enum @@ -8,44 +7,6 @@ from checkout_sdk.payments.payments import PaymentType, PreferredSchema, ShippingDetails -# Deprecated attributes that moved into a nested object -def _moved_attribute(name: str, container: str, container_type: type): - """Builds a deprecated attribute kept for existing code after the spec moved it into a nested - object. Reading or writing it emits a DeprecationWarning and uses container.name instead, so a - value set through the old attribute is serialized under the nested object.""" - - def _warn(obj): - warnings.warn( - '{0}.{1} is deprecated and will be removed in the next major version. Use ' - '{0}.{2}.{1} instead.'.format(type(obj).__name__, name, container), - DeprecationWarning, - stacklevel=3) - - def getter(self): - _warn(self) - return getattr(getattr(self, container), name) - - def setter(self, value): - _warn(self) - if not hasattr(self, container): - setattr(self, container, container_type()) - setattr(getattr(self, container), name, value) - - return property(getter, setter) - - -class _SerializesOwnAttributes: - """Serializes only the attributes set on the instance. - - JsonSerializer reflects every readable attribute, which would also emit the deprecated - attributes built by _moved_attribute at their old position. Their values are stored on the - nested object, so serializing the instance attributes sends each value once, where the API - reads it.""" - - def to_json(self): - return dict(vars(self)) - - # Enums class PaymentMethodInitialization(str, Enum): """The initialization state of a payment method on a Payment Setup.""" @@ -814,7 +775,7 @@ class WalletToken: expires_on: datetime -class GooglePay(_SerializesOwnAttributes, PaymentSetupPaymentMethod): +class GooglePay(PaymentSetupPaymentMethod): """The Google Pay payment method's details and configuration.""" # The Google Pay token data. # [Optional] @@ -844,11 +805,11 @@ class GooglePay(_SerializesOwnAttributes, PaymentSetupPaymentMethod): # [Optional] preferred_scheme: GooglePayPreferredScheme # Deprecated: use token.expires_on instead. Will be removed in the next major version. - # Reading or writing it uses token.expires_on, so the value is sent under token. + # The API does not read expires_on at this level, so a value set here is ignored. # The date and time the token expires, in ISO 8601 format. # [Optional] # Format: date-time (RFC 3339) - expires_on = _moved_attribute('expires_on', 'token', WalletToken) + expires_on: datetime # Apple Pay entities @@ -883,7 +844,7 @@ class ApplePayTokenData: header: ApplePayTokenDataHeader -class ApplePay(_SerializesOwnAttributes, PaymentSetupPaymentMethod): +class ApplePay(PaymentSetupPaymentMethod): """The Apple Pay payment method's details and configuration.""" # The Apple Pay token data. # [Optional] @@ -912,11 +873,11 @@ class ApplePay(_SerializesOwnAttributes, PaymentSetupPaymentMethod): # [Optional] preferred_scheme: PreferredSchema # Deprecated: use token.expires_on instead. Will be removed in the next major version. - # Reading or writing it uses token.expires_on, so the value is sent under token. + # The API does not read expires_on at this level, so a value set here is ignored. # The date and time the token expires, in ISO 8601 format. # [Optional] # Format: date-time (RFC 3339) - expires_on = _moved_attribute('expires_on', 'token', WalletToken) + expires_on: datetime # Bacs entities @@ -1273,13 +1234,13 @@ class CardDetails: allow_update: bool -class Card(_SerializesOwnAttributes, PaymentSetupPaymentMethod): +class Card(PaymentSetupPaymentMethod): """The Card payment method's details and configuration. The card fields live under details. The attributes of the same name directly on Card (number, last4, bin, scheme, expiry_month, expiry_year, name, cvv, stored, expires_on, - allow_update) are deprecated: they read and write details, so their values are sent under - details and never at the top level of the card object. + allow_update) are deprecated: the API does not read them at this level, so set them on + details instead. """ # The Checkout.com card token. # [Optional] @@ -1308,54 +1269,65 @@ class Card(_SerializesOwnAttributes, PaymentSetupPaymentMethod): preferred_scheme: PreferredSchema # Deprecated: use details.number instead. Will be removed in the next major version. + # The API does not read it at the top level of the card, so a value set here is ignored. # The card number (without separators). # [Optional] - number = _moved_attribute('number', 'details', CardDetails) + number: str # Deprecated: use details.last4 instead. Will be removed in the next major version. + # The API does not read it at the top level of the card, so a value set here is ignored. # The last four digits of the card number. # [Optional] # readOnly - last4 = _moved_attribute('last4', 'details', CardDetails) + last4: str # Deprecated: use details.bin instead. Will be removed in the next major version. + # The API does not read it at the top level of the card, so a value set here is ignored. # The card issuer's Bank Identification Number (BIN). # [Optional] # readOnly - bin = _moved_attribute('bin', 'details', CardDetails) + bin: str # Deprecated: use details.scheme instead. Will be removed in the next major version. + # The API does not read it at the top level of the card, so a value set here is ignored. # The card scheme. # [Optional] # readOnly - scheme = _moved_attribute('scheme', 'details', CardDetails) + scheme: str # Deprecated: use details.expiry_month instead. Will be removed in the next major version. + # The API does not read it at the top level of the card, so a value set here is ignored. # The expiry month of the card. # [Optional] - expiry_month = _moved_attribute('expiry_month', 'details', CardDetails) + expiry_month: int # Deprecated: use details.expiry_year instead. Will be removed in the next major version. + # The API does not read it at the top level of the card, so a value set here is ignored. # The expiry year of the card. # [Optional] - expiry_year = _moved_attribute('expiry_year', 'details', CardDetails) + expiry_year: int # Deprecated: use details.name instead. Will be removed in the next major version. + # The API does not read it at the top level of the card, so a value set here is ignored. # The cardholder's name. # [Optional] - name = _moved_attribute('name', 'details', CardDetails) + name: str # Deprecated: use details.cvv instead. Will be removed in the next major version. + # The API does not read it at the top level of the card, so a value set here is ignored. # The card verification value/code. 3 digits, except for American Express (4 digits). # [Optional] - cvv = _moved_attribute('cvv', 'details', CardDetails) + cvv: str # Deprecated: use details.stored instead. Will be removed in the next major version. + # The API does not read it at the top level of the card, so a value set here is ignored. # Indicates whether this card is being submitted from your own stored card-on-file system. # [Optional] - stored = _moved_attribute('stored', 'details', CardDetails) + stored: bool # Deprecated: use details.expires_on instead. Will be removed in the next major version. + # The API does not read it at the top level of the card, so a value set here is ignored. # The time by which the card details must be confirmed. # [Optional] # readOnly # Format: date-time (RFC 3339) - expires_on = _moved_attribute('expires_on', 'details', CardDetails) + expires_on: datetime # Deprecated: use details.allow_update instead. Will be removed in the next major version. + # The API does not read it at the top level of the card, so a value set here is ignored. # Indicates whether to use the Real-Time Account Updater to update the card information. # [Optional] - allow_update = _moved_attribute('allow_update', 'details', CardDetails) + allow_update: bool class PaymentMethods: @@ -1539,7 +1511,7 @@ class Settings: capture: bool # The list of payment methods excluded from the Payment Setup. # [Optional] - excluded_payment_methods: list # list of str (PaymentSourceType values) + excluded_payment_methods: list # list of str (payment method names, for example card) # Order entities diff --git a/tests/payments/setups/payment_setups_serialization_test.py b/tests/payments/setups/payment_setups_serialization_test.py index 857499f..cdcfe0c 100644 --- a/tests/payments/setups/payment_setups_serialization_test.py +++ b/tests/payments/setups/payment_setups_serialization_test.py @@ -1178,36 +1178,17 @@ def test_card_spec_example_reads(self): assert read.schemes == [PaymentSetupCardScheme.VISA, PaymentSetupCardScheme.MASTERCARD] assert read.preferred_scheme == PreferredSchema.VISA - @pytest.mark.parametrize('field', _CARD_DETAIL_FIELDS) - def test_deprecated_flat_card_attribute_is_sent_under_details(self, field): - card = Card() - - with pytest.deprecated_call(): - setattr(card, field, _CARD_DETAIL_VALUES[field]) - with pytest.deprecated_call(): - assert getattr(card, field) == _CARD_DETAIL_VALUES[field] - - assert getattr(card.details, field) == _CARD_DETAIL_VALUES[field] - assert _serialize(card) == {'details': {field: _CARD_DETAIL_WIRE[field]}} - - def test_deprecated_flat_card_attributes_write_into_existing_details(self): - details = CardDetails() - details.number = '4242424242424242' - card = Card() - card.details = details - - with pytest.deprecated_call(): - card.cvv = '100' - - assert card.details is details - assert _serialize(card) == {'details': {'number': '4242424242424242', 'cvv': '100'}} + def test_deprecated_flat_card_attributes_are_still_declared(self): + for field in _CARD_DETAIL_FIELDS: + assert field in Card.__annotations__, field + assert field in CardDetails.__annotations__, field - def test_unset_deprecated_flat_card_attribute_reads_as_missing(self): + def test_deprecated_flat_card_attribute_does_not_write_into_details(self): card = Card() + card.number = '4242424242424242' - with pytest.deprecated_call(), pytest.raises(AttributeError): - _ = card.number - assert _serialize(card) == {} + assert not hasattr(card, 'details') + assert 'details' not in _serialize(card) class TestPaymentSetupWalletSerialization: @@ -1314,9 +1295,12 @@ def test_wallet_round_trip_keeps_every_property(self, wallet): assert read.flags == ['flag_a'] assert read.token.id == _WALLET_TOKEN_ID assert read.token.expires_on == '2026-06-01T10:00:00' - for field in vars(method.token_data): - if field != 'header': - assert getattr(read.token_data, field) == getattr(method.token_data, field), field + for field, value in vars(method.token_data).items(): + if field == 'header': + for header_field, header_value in vars(value).items(): + assert getattr(read.token_data.header, header_field) == header_value, header_field + else: + assert getattr(read.token_data, field) == value, field assert read.store_for_future_use is True assert read.phone.number == '207 946 0000' for field, value in _ACCOUNT_HOLDER_WIRE.items(): @@ -1340,31 +1324,17 @@ def test_wallet_spec_example_reads(self): assert read.preferred_scheme == GooglePayPreferredScheme.VISA @pytest.mark.parametrize('wallet_type', [ApplePay, GooglePay]) - def test_deprecated_wallet_expires_on_is_sent_under_token(self, wallet_type): - wallet = wallet_type() - expires_on = datetime(2026, 6, 1, 10, 0, 0) - - with pytest.deprecated_call(): - wallet.expires_on = expires_on - with pytest.deprecated_call(): - assert wallet.expires_on == expires_on - - assert wallet.token.expires_on == expires_on - assert _serialize(wallet) == {'token': {'expires_on': '2026-06-01T10:00:00'}} + def test_deprecated_wallet_expires_on_is_still_declared(self, wallet_type): + assert 'expires_on' in wallet_type.__annotations__ + assert 'expires_on' in WalletToken.__annotations__ - def test_deprecated_wallet_expires_on_writes_into_existing_token(self): - token = WalletToken() - token.id = _WALLET_TOKEN_ID - applepay = ApplePay() - applepay.token = token - - with pytest.deprecated_call(): - applepay.expires_on = datetime(2026, 6, 1, 10, 0, 0) + @pytest.mark.parametrize('wallet_type', [ApplePay, GooglePay]) + def test_wallet_token_set_as_a_legacy_string_does_not_raise(self, wallet_type): + wallet = wallet_type() + wallet.token = _WALLET_TOKEN_ID + wallet.expires_on = datetime(2026, 6, 1, 10, 0, 0) - assert applepay.token is token - assert _serialize(applepay) == { - 'token': {'id': _WALLET_TOKEN_ID, 'expires_on': '2026-06-01T10:00:00'} - } + assert _serialize(wallet)['token'] == _WALLET_TOKEN_ID class TestPaymentSetupSchemeEnums: