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