diff --git a/checkout_sdk/common/enums.py b/checkout_sdk/common/enums.py index fdae868..025dd12 100644 --- a/checkout_sdk/common/enums.py +++ b/checkout_sdk/common/enums.py @@ -484,9 +484,20 @@ class PaymentSourceType(str, Enum): BLIK = 'blik' -# Used by ThreeDsRequest (in payments). The /sessions endpoint accepts -# additional exemption-like values — see SessionChallengeIndicator. class ChallengeIndicator(str, Enum): + """Indicates the preference for whether or not a 3DS challenge should be performed. + + The customer's bank has the final say on whether or not the customer receives the challenge. + + This is the four-value indicator accepted by the 3ds.challenge_indicator field on POST + /payments, POST /hosted-payments, POST /payment-links and POST /payment-sessions. + + For POST /sessions, which additionally supports requests for exemption, use + checkout_sdk.sessions.sessions.SessionChallengeIndicator. + + [Optional] + Default: NO_PREFERENCE + """ NO_PREFERENCE = 'no_preference' NO_CHALLENGE_REQUESTED = 'no_challenge_requested' CHALLENGE_REQUESTED = 'challenge_requested' diff --git a/checkout_sdk/sessions/sessions.py b/checkout_sdk/sessions/sessions.py index 15a33dd..cdc32e9 100644 --- a/checkout_sdk/sessions/sessions.py +++ b/checkout_sdk/sessions/sessions.py @@ -19,9 +19,9 @@ class SdkInterfaceType(str, Enum): class ThreeDsMethodCompletion(str, Enum): - Y = 'y' - N = 'n' - U = 'u' + Y = 'Y' + N = 'N' + U = 'U' class CompletionInfoType(str, Enum): @@ -49,10 +49,22 @@ class Category(str, Enum): NON_PAYMENT = 'non_payment' -# Wider variant of common.enums.ChallengeIndicator. Only used by SessionRequest -# (the /sessions 3DS endpoint), which folds exemption requests into this field -# instead of having a separate `exemption` field like ThreeDsRequest does. class SessionChallengeIndicator(str, Enum): + """Indicates whether a challenge is requested for this session. + + Used by SessionRequest.challenge_indicator for POST /sessions. This is the only field in the + API that accepts the exemption values below; the 3ds.challenge_indicator field on payments, + hosted payments, payment links and payment sessions accepts only the first four values and is + modelled by checkout_sdk.common.enums.ChallengeIndicator. + + The following are requests for exemption: LOW_VALUE, TRUSTED_LISTING, TRUSTED_LISTING_PROMPT + and TRANSACTION_RISK_ASSESSMENT. If an exemption cannot be applied, then the value + NO_CHALLENGE_REQUESTED will be used instead. + + [Optional] + Default: NO_PREFERENCE + max 50 characters + """ NO_PREFERENCE = 'no_preference' NO_CHALLENGE_REQUESTED = 'no_challenge_requested' CHALLENGE_REQUESTED = 'challenge_requested' @@ -87,6 +99,8 @@ class SessionScheme(str, Enum): AMEX = 'amex' DINERS = 'diners' CARTES_BANCAIRES = 'cartes_bancaires' + DISCOVER = 'discover' + UPI = 'upi' class AuthenticationMethod(str, Enum): @@ -106,7 +120,20 @@ class DeliveryTimeframe(str, Enum): class ShippingIndicator(str, Enum): - VISA = 'visa' + """Indicates the shipping method chosen for the transaction. + + Used by MerchantRiskInfo.shipping_indicator. Please choose an option that accurately describes + the cardholder's specific transaction. + + [Optional] + """ + BILLING_ADDRESS = 'billing_address' + ANOTHER_ADDRESS_ON_FILE = 'another_address_on_file' + NOT_ON_FILE = 'not_on_file' + STORE_PICK_UP = 'store_pick_up' + DIGITAL_GOODS = 'digital_goods' + TRAVEL_AND_EVENT_NO_SHIPPING = 'travel_and_event_no_shipping' + OTHER = 'other' class SdkEphemeralPublicKey: @@ -126,8 +153,6 @@ class SessionMarketplaceData: class SessionsBillingDescriptor: name: str - city: str - reference: str # Channel @@ -164,6 +189,13 @@ class BrowserSession(ChannelData): timezone: str user_agent: str ip_address: str + # Whether the Payment API is enabled for all parent frames. This is required for Google SPA + # support in hosted sessions. + # [Optional] + iframe_payment_allowed: bool + # The raw Sec-CH-UA header value. This can improve Google SPA support. + # [Optional] + user_agent_client_hint: str def __init__(self): super().__init__(ChannelType.BROWSER) @@ -230,12 +262,17 @@ def __init__(self, type_p: SessionSourceType): class SessionCardSource(SessionSource): + """A card source for the authentication. + + The sessions CardSource schema does not define `store_for_future_use`; that field belongs to the + payments sources, which create an instrument. A session only authenticates a card, so it is not + accepted here. + """ number: str expiry_month: int expiry_year: int name: str stored: bool = False - store_for_future_use: bool def __init__(self): super().__init__(SessionSourceType.CARD) @@ -373,6 +410,13 @@ class GoogleSpa: class SessionRequest: + """The request body for POST /sessions. + + Declares exactly the 24 properties of the SessionRequest schema. `prior_transaction_reference` + was carried here from a June 2022 sessions update but is absent from the current API Reference, + from the API schema search and from the developer documentation, so it is no longer declared. + Assigning it still serializes, should the API accept it. + """ source: SessionSource = SessionCardSource() amount: int currency: Currency @@ -385,7 +429,6 @@ class SessionRequest: billing_descriptor: SessionsBillingDescriptor reference: str merchant_risk_info: MerchantRiskInfo - prior_transaction_reference: str transaction_type: TransactionType = TransactionType.GOODS_SERVICE shipping_address: SessionAddress shipping_address_matches_billing: bool diff --git a/tests/sessions/challenge_indicator_serialization_test.py b/tests/sessions/challenge_indicator_serialization_test.py new file mode 100644 index 0000000..b21730a --- /dev/null +++ b/tests/sessions/challenge_indicator_serialization_test.py @@ -0,0 +1,87 @@ +import json + +import pytest + +from checkout_sdk.common.enums import ChallengeIndicator +from checkout_sdk.json_serializer import JsonSerializer +from checkout_sdk.payments.payments import ThreeDsRequest +from checkout_sdk.sessions.sessions import SessionChallengeIndicator, SessionRequest + +# The nine values accepted by SessionRequest.challenge_indicator, per the API Reference +# ChallengeIndicator schema, in spec order. +SESSION_VALUES = [ + 'no_preference', + 'no_challenge_requested', + 'challenge_requested', + 'challenge_requested_mandate', + 'low_value', + 'trusted_listing', + 'trusted_listing_prompt', + 'transaction_risk_assessment', + 'data_share', +] + +# The four values accepted by the 3ds.challenge_indicator field on payments, hosted payments, +# payment links and payment sessions. +PAYMENT_VALUES = [ + 'no_preference', + 'no_challenge_requested', + 'challenge_requested', + 'challenge_requested_mandate', +] + + +def _serialize(obj): + return json.loads(json.dumps(obj, cls=JsonSerializer)) + + +class TestChallengeIndicatorSerialization: + """Covers the two challenge-indicator enums and their call sites: the nine-value sessions enum + used by POST /sessions, and the four-value shared enum used by the payments 3ds field. + """ + + def test_session_enum_exposes_all_nine_spec_values_in_order(self): + assert [member.value for member in SessionChallengeIndicator] == SESSION_VALUES + + def test_shared_enum_exposes_only_the_four_payment_values(self): + assert [member.value for member in ChallengeIndicator] == PAYMENT_VALUES + + @pytest.mark.parametrize('value', SESSION_VALUES) + def test_every_session_value_serializes_on_session_request(self, value): + request = SessionRequest() + request.challenge_indicator = SessionChallengeIndicator(value) + + assert _serialize(request)['challenge_indicator'] == value + + def test_session_request_defaults_to_no_preference(self): + request = SessionRequest() + + assert request.challenge_indicator == SessionChallengeIndicator.NO_PREFERENCE + assert _serialize(request)['challenge_indicator'] == 'no_preference' + + @pytest.mark.parametrize('value', PAYMENT_VALUES) + def test_every_payment_value_serializes_on_three_ds_request(self, value): + request = ThreeDsRequest() + request.challenge_indicator = ChallengeIndicator(value) + + assert _serialize(request)['challenge_indicator'] == value + + @pytest.mark.parametrize('value', SESSION_VALUES) + def test_every_session_value_round_trips_through_the_enum(self, value): + assert SessionChallengeIndicator(value).value == value + + def test_the_five_exemption_values_are_absent_from_the_shared_enum(self): + """The exemption values must not leak onto the payments enum: the API rejects them on + 3ds.challenge_indicator. This is the guard the split exists to provide. + """ + exemptions = { + 'low_value', + 'trusted_listing', + 'trusted_listing_prompt', + 'transaction_risk_assessment', + 'data_share', + } + shared = {member.value for member in ChallengeIndicator} + + assert exemptions.isdisjoint(shared) + assert exemptions.issubset({member.value for member in SessionChallengeIndicator}) diff --git a/tests/sessions/session_request_serialization_test.py b/tests/sessions/session_request_serialization_test.py new file mode 100644 index 0000000..e75b8b7 --- /dev/null +++ b/tests/sessions/session_request_serialization_test.py @@ -0,0 +1,224 @@ +import json + +from checkout_sdk.common.enums import Currency, Country +from checkout_sdk.json_serializer import JsonSerializer +from checkout_sdk.sessions.sessions import ( + AuthenticationType, + BrowserSession, + CardholderAccountInfo, + Category, + DeviceInformation, + GoogleSpa, + InitialTransaction, + Installment, + MerchantRiskInfo, + NonHostedCompletionInfo, + Optimization, + Recurring, + SessionAddress, + SessionCardSource, + SessionChallengeIndicator, + SessionMarketplaceData, + SessionRequest, + SessionsBillingDescriptor, + ThreeDsMethodCompletion, + TransactionType, +) + +# The 24 properties of the SessionRequest schema in the Checkout.com API Reference. +EXPECTED_ATTRIBUTES = [ + 'source', + 'amount', + 'currency', + 'processing_channel_id', + 'marketplace', + 'authentication_type', + 'authentication_category', + 'account_info', + 'challenge_indicator', + 'billing_descriptor', + 'reference', + 'merchant_risk_info', + 'transaction_type', + 'shipping_address', + 'shipping_address_matches_billing', + 'completion', + 'channel_data', + 'recurring', + 'installment', + 'optimization', + 'initial_transaction', + 'device_information', + 'google_spa', + 'preferred_experiences', +] + + +def _serialize(obj): + return json.loads(json.dumps(obj, cls=JsonSerializer)) + + +def _fully_populated(): + source = SessionCardSource() + source.number = '4485040371536584' + source.expiry_month = 1 + source.expiry_year = 2030 + source.name = 'Bruce Wayne' + + billing_address = SessionAddress() + billing_address.address_line1 = 'Checkout.com' + billing_address.city = 'London' + billing_address.zip = 'W1T 4TJ' + billing_address.country = Country.GB + source.billing_address = billing_address + + shipping_address = SessionAddress() + shipping_address.address_line1 = 'Checkout.com' + shipping_address.address_line2 = '90 Tottenham Court Road' + shipping_address.city = 'London' + shipping_address.state = 'ENG' + shipping_address.zip = 'W1T 4TJ' + shipping_address.country = Country.GB + + marketplace = SessionMarketplaceData() + marketplace.sub_entity_id = 'ent_ocw5i74vowfg2edpy66izhts2u' + + account_info = CardholderAccountInfo() + account_info.purchase_count = 10 + account_info.add_card_attempts = 5 + + billing_descriptor = SessionsBillingDescriptor() + billing_descriptor.name = 'SUPERHEROES.COM' + + merchant_risk_info = MerchantRiskInfo() + merchant_risk_info.delivery_email = 'bruce@wayne-enterprises.com' + merchant_risk_info.is_preorder = False + merchant_risk_info.is_reorder = False + + completion = NonHostedCompletionInfo() + completion.callback_url = 'https://merchant.com/callback' + + channel_data = BrowserSession() + channel_data.accept_header = 'Accept: *.*, q=0.1' + channel_data.java_enabled = True + channel_data.language = 'FR-fr' + channel_data.three_ds_method_completion = ThreeDsMethodCompletion.Y + channel_data.ip_address = '1.12.123.255' + + recurring = Recurring() + recurring.days_between_payments = 30 + recurring.expiry = '99991231' + + installment = Installment() + installment.number_of_payments = 3 + installment.days_between_payments = 30 + installment.expiry = '99991231' + + optimization = Optimization() + optimization.optimized = True + optimization.framework = 'acceptance_rates' + + initial_transaction = InitialTransaction() + initial_transaction.acs_transaction_id = 'acs-txn-id' + + google_spa = GoogleSpa() + google_spa.continue_url = 'https://merchant.com/continue' + + device_information = DeviceInformation() + device_information.device_id = 'device-id' + device_information.device_session_id = 'device-session' + + request = SessionRequest() + request.source = source + request.amount = 6540 + request.currency = Currency.USD + request.processing_channel_id = 'pc_5jp2az55l3cuths25t5p3xhwru' + request.marketplace = marketplace + request.authentication_type = AuthenticationType.REGULAR + request.authentication_category = Category.PAYMENT + request.account_info = account_info + request.challenge_indicator = SessionChallengeIndicator.TRUSTED_LISTING_PROMPT + request.billing_descriptor = billing_descriptor + request.reference = 'ORD-5023-4E89' + request.merchant_risk_info = merchant_risk_info + request.transaction_type = TransactionType.GOODS_SERVICE + request.shipping_address = shipping_address + request.shipping_address_matches_billing = True + request.completion = completion + request.channel_data = channel_data + request.recurring = recurring + request.installment = installment + request.optimization = optimization + request.initial_transaction = initial_transaction + request.device_information = device_information + request.google_spa = google_spa + request.preferred_experiences = ['3ds', 'google_spa'] + + return request + + +class TestSessionRequestSerialization: + """Full-property serialization coverage for the POST /sessions request body. + + Every declared attribute is populated and asserted on the emitted payload, so adding an + attribute without extending the fixture fails the test. + """ + + def test_declared_attributes_match_the_spec_property_set(self): + """Guards both directions: a spec property missing from the SDK, and an attribute the SDK + declares that the API Reference does not define. + """ + declared = [ + name for name in SessionRequest.__annotations__ + if not name.startswith('_') + ] + + assert sorted(declared) == sorted(EXPECTED_ATTRIBUTES) + assert len(declared) == 24 + assert 'prior_transaction_reference' not in declared + + def test_serializes_every_declared_attribute(self): + payload = _serialize(_fully_populated()) + + for name in EXPECTED_ATTRIBUTES: + assert name in payload, f'attribute {name} is missing from the serialized payload' + + def test_serializes_defaults_only(self): + payload = _serialize(SessionRequest()) + + assert payload['authentication_type'] == 'regular' + assert payload['authentication_category'] == 'payment' + assert payload['challenge_indicator'] == 'no_preference' + assert payload['transaction_type'] == 'goods_service' + + def test_serializes_scalars_and_enums(self): + payload = _serialize(_fully_populated()) + + assert payload['amount'] == 6540 + assert payload['currency'] == 'USD' + assert payload['processing_channel_id'] == 'pc_5jp2az55l3cuths25t5p3xhwru' + assert payload['authentication_type'] == 'regular' + assert payload['authentication_category'] == 'payment' + assert payload['challenge_indicator'] == 'trusted_listing_prompt' + assert payload['reference'] == 'ORD-5023-4E89' + assert payload['transaction_type'] == 'goods_service' + assert payload['shipping_address_matches_billing'] is True + assert payload['preferred_experiences'] == ['3ds', 'google_spa'] + + def test_serializes_nested_object_contents(self): + payload = _serialize(_fully_populated()) + + assert payload['source']['number'] == '4485040371536584' + assert payload['source']['billing_address']['country'] == 'GB' + assert payload['marketplace']['sub_entity_id'] == 'ent_ocw5i74vowfg2edpy66izhts2u' + assert payload['account_info']['purchase_count'] == 10 + assert payload['billing_descriptor']['name'] == 'SUPERHEROES.COM' + assert payload['merchant_risk_info']['delivery_email'] == 'bruce@wayne-enterprises.com' + assert payload['shipping_address']['state'] == 'ENG' + assert payload['completion']['callback_url'] == 'https://merchant.com/callback' + assert payload['recurring']['days_between_payments'] == 30 + assert payload['installment']['number_of_payments'] == 3 + assert payload['optimization']['framework'] == 'acceptance_rates' + assert payload['initial_transaction']['acs_transaction_id'] == 'acs-txn-id' + assert payload['google_spa']['continue_url'] == 'https://merchant.com/continue' + assert payload['device_information']['device_session_id'] == 'device-session' diff --git a/tests/sessions/sessions_enums_test.py b/tests/sessions/sessions_enums_test.py new file mode 100644 index 0000000..f92a829 --- /dev/null +++ b/tests/sessions/sessions_enums_test.py @@ -0,0 +1,150 @@ +import inspect +import re +from enum import Enum + +import pytest + +from checkout_sdk.sessions import sessions as sessions_module +from checkout_sdk.sessions.sessions import ( + AuthenticationType, + BrowserSession, + Category, + ChannelData, + SessionCardSource, + SessionScheme, + SessionSource, + SessionsBillingDescriptor, + ShippingIndicator, + ThreeDsMethodCompletion, + TransactionType, +) + +# Value sets defined by the Checkout.com API Reference. These enums carry the raw wire values sent +# to and returned by the API, so a typo is invisible at development time and only fails against the +# live API. +SPEC_VALUES = { + 'Category': ['payment', 'non_payment'], + 'TransactionType': [ + 'goods_service', + 'check_acceptance', + 'account_funding', + 'quasi_card_transaction', + 'prepaid_activation_and_load', + ], + 'AuthenticationType': ['regular', 'recurring', 'installment', 'maintain_card', 'add_card'], + 'SessionScheme': [ + 'visa', + 'mastercard', + 'jcb', + 'amex', + 'diners', + 'cartes_bancaires', + 'discover', + 'upi', + ], + 'ThreeDsMethodCompletion': ['Y', 'N', 'U'], + 'ShippingIndicator': [ + 'billing_address', + 'another_address_on_file', + 'not_on_file', + 'store_pick_up', + 'digital_goods', + 'travel_and_event_no_shipping', + 'other', + ], +} + +ENUMS_UNDER_TEST = { + 'Category': Category, + 'TransactionType': TransactionType, + 'AuthenticationType': AuthenticationType, + 'SessionScheme': SessionScheme, + 'ThreeDsMethodCompletion': ThreeDsMethodCompletion, + 'ShippingIndicator': ShippingIndicator, +} + +# An API value is snake_case, or a single uppercase letter for the Y/N/U style codes. +VALID_VALUE = re.compile(r'^([a-z0-9_]+|[A-Z])$') + + +class TestSessionsEnums: + """Spec-conformance guards for the sessions enums.""" + + @pytest.mark.parametrize('name', sorted(SPEC_VALUES)) + def test_enum_matches_spec_value_set(self, name): + expected = sorted(SPEC_VALUES[name]) + actual = sorted(member.value for member in ENUMS_UNDER_TEST[name]) + + assert actual == expected + + def test_shipping_indicator_covers_all_seven_spec_values(self): + """Guards a regression where this enum held a single wrong member, VISA = 'visa', leaving + MerchantRiskInfo.shipping_indicator unusable. + """ + assert len(list(ShippingIndicator)) == 7 + assert 'visa' not in {member.value for member in ShippingIndicator} + + def test_three_ds_method_completion_is_uppercase(self): + """The spec enum is Y/N/U. Lowercase values are rejected by the API.""" + assert [member.value for member in ThreeDsMethodCompletion] == ['Y', 'N', 'U'] + + def test_browser_session_covers_every_spec_field(self): + """The spec Browser schema has 14 properties. iframe_payment_allowed and + user_agent_client_hint were previously missing, so Google SPA support could not be signalled. + """ + expected = { + 'channel', + 'three_ds_method_completion', + 'accept_header', + 'java_enabled', + 'javascript_enabled', + 'language', + 'color_depth', + 'screen_height', + 'screen_width', + 'timezone', + 'user_agent', + 'ip_address', + 'iframe_payment_allowed', + 'user_agent_client_hint', + } + declared = set(BrowserSession.__annotations__) | set(ChannelData.__annotations__) + + assert declared == expected + + def test_sessions_billing_descriptor_declares_only_name(self): + """The sessions schema defines only `name`. city and reference belong to the payments + billing descriptor and are not accepted here. + """ + assert set(SessionsBillingDescriptor.__annotations__) == {'name'} + + def test_session_card_source_does_not_declare_store_for_future_use(self): + """The sessions CardSource schema has no store_for_future_use; that field belongs to the + payments sources, which create an instrument. + """ + declared = set(SessionCardSource.__annotations__) | set(SessionSource.__annotations__) + + assert 'store_for_future_use' not in declared + assert declared == {'type', 'scheme', 'billing_address', 'home_phone', 'mobile_phone', + 'work_phone', 'email', 'number', 'expiry_month', 'expiry_year', + 'name', 'stored'} + + def test_every_sessions_enum_value_is_snake_case_or_single_uppercase_code(self): + """Structural guard across every enum in the sessions module. Catches camelCase or wrong + casing leaking into a wire value. + """ + checked = 0 + + for _, obj in inspect.getmembers(sessions_module, inspect.isclass): + if not issubclass(obj, Enum) or obj is Enum: + continue + if obj.__module__ != sessions_module.__name__: + continue + + for member in obj: + checked += 1 + assert VALID_VALUE.match(member.value), ( + f'{obj.__name__}.{member.name} = {member.value!r} is not a valid API value' + ) + + assert checked > 50, f'expected to check more than 50 session enum values, checked {checked}'