diff --git a/checkout_sdk/checkout_api.py b/checkout_sdk/checkout_api.py index 12eb2f7..2d17b90 100644 --- a/checkout_sdk/checkout_api.py +++ b/checkout_sdk/checkout_api.py @@ -24,6 +24,7 @@ from checkout_sdk.reports.reports_client import ReportsClient from checkout_sdk.metadata.metadata_client import CardMetadataClient from checkout_sdk.forward.forward_client import ForwardClient +from checkout_sdk.payments.setups.setups_client import PaymentSetupsClient def _base_api_client(configuration: CheckoutConfiguration) -> ApiClient: @@ -76,3 +77,4 @@ def __init__(self, configuration: CheckoutConfiguration): self.contexts = PaymentContextsClient(api_client=base_api_client, configuration=configuration) self.payment_sessions = PaymentSessionsClient(api_client=base_api_client, configuration=configuration) self.forward = ForwardClient(api_client=base_api_client, configuration=configuration) + self.setups = PaymentSetupsClient(api_client=base_api_client, configuration=configuration) diff --git a/checkout_sdk/payments/payments.py b/checkout_sdk/payments/payments.py index a0b79f7..f0a1dfa 100644 --- a/checkout_sdk/payments/payments.py +++ b/checkout_sdk/payments/payments.py @@ -464,9 +464,21 @@ class ProcessingSettings: surcharge_amount: int pan_preference: PanPreference provision_network_token: bool + affiliate_id: str + affiliate_url: str + + +class ProductSubType (str, Enum): + BLOCKCHAIN = 'BLOCKCHAIN' + CBDC = 'CBDC' + CRYPTOCURRENCY = 'CRYPTOCURRENCY' + NFT = 'NFT' + STABLECOIN = 'STABLECOIN' class Product: + type: str + sub_type: list # ProductSubType name: str quantity: int unit_price: int diff --git a/checkout_sdk/payments/setups/__init__.py b/checkout_sdk/payments/setups/__init__.py new file mode 100644 index 0000000..776b534 --- /dev/null +++ b/checkout_sdk/payments/setups/__init__.py @@ -0,0 +1,4 @@ +from .setups import * +from .setups_client import PaymentSetupsClient + +__all__ = ['PaymentSetupsClient'] \ No newline at end of file diff --git a/checkout_sdk/payments/setups/setups.py b/checkout_sdk/payments/setups/setups.py new file mode 100644 index 0000000..20e46ca --- /dev/null +++ b/checkout_sdk/payments/setups/setups.py @@ -0,0 +1,355 @@ +from __future__ import absolute_import +from datetime import datetime +from enum import Enum + +from checkout_sdk.common.common import Address, Phone, CustomerRetry +from checkout_sdk.common.enums import Currency, Country +from checkout_sdk.payments.payments import PaymentType, ShippingDetails +from checkout_sdk.payments.contexts.contexts import PaymentContextsItems, PaymentContextsTicket, PaymentContextsPassenger, PaymentContextsFlightLegDetails + + +# Enums +class PaymentMethodInitialization(str, Enum): + DISABLED = 'disabled' + ENABLED = 'enabled' + + +class PaymentMethodStatus(str, Enum): + """Payment method status for responses""" + AVAILABLE = 'available' + REQUIRES_ACTION = 'requires_action' + UNAVAILABLE = 'unavailable' + + +class PaymentMethodOptionStatus(str, Enum): + """Payment method option status for responses""" + UNAVAILABLE = 'unavailable' + ACTION_REQUIRED = 'action_required' + PENDING = 'pending' + READY = 'ready' + + +class PaymentStatus(str, Enum): + """Payment status for confirm responses""" + AUTHORIZED = 'Authorized' + PENDING = 'Pending' + CARD_VERIFIED = 'Card Verified' + DECLINED = 'Declined' + RETRY_SCHEDULED = 'Retry Scheduled' + + +# Customer entities +class CustomerEmail: + def __init__(self): + self.address: str = None + self.verified: bool = None + + +class CustomerDevice: + def __init__(self): + self.locale: str = None + + +class MerchantAccount: + def __init__(self): + self.id: str = None + self.registration_date: datetime = None + self.last_modified: datetime = None + self.returning_customer: bool = None + self.first_transaction_date: datetime = None + self.last_transaction_date: datetime = None + self.total_order_count: int = None + self.last_payment_amount: int = None # Using int for consistency with amount fields + + +class Customer: + def __init__(self): + self.email: CustomerEmail = None + self.name: str = None + self.phone: Phone = None + self.device: CustomerDevice = None + self.merchant_account: MerchantAccount = None + + +# Payment Method Common entities +class PaymentMethodAction: + def __init__(self): + self.type: str = None + self.client_token: str = None + self.session_id: str = None + + +class PaymentMethodOption: + def __init__(self): + self.id: str = None + self.status: PaymentMethodOptionStatus = None # Enum: unavailable, action_required, pending, ready (for responses) + self.flags: list = None # list of str - error codes or indicators that highlight missing/invalid info + self.action: PaymentMethodAction = None + + +class PaymentMethodOptions: + def __init__(self): + self.sdk: PaymentMethodOption = None + self.pay_in_full: PaymentMethodOption = None + self.installments: PaymentMethodOption = None + self.pay_now: PaymentMethodOption = None + + +class PaymentMethodBase: + def __init__(self): + self.status: PaymentMethodStatus = None # Enum: available, requires_action, unavailable (for responses) + self.flags: list = None # list of str - error codes or indicators + self.initialization: PaymentMethodInitialization = PaymentMethodInitialization.DISABLED + + +# Klarna entities +class KlarnaAccountHolder: + def __init__(self): + self.billing_address: Address = None + + +class Klarna(PaymentMethodBase): + def __init__(self): + super().__init__() + self.account_holder: KlarnaAccountHolder = None + self.payment_method_options: PaymentMethodOptions = None + + +# Stcpay entities +class Stcpay(PaymentMethodBase): + def __init__(self): + super().__init__() + self.otp: str = None + self.payment_method_options: PaymentMethodOptions = None + + +# Tabby entities +class Tabby(PaymentMethodBase): + def __init__(self): + super().__init__() + self.payment_method_options: PaymentMethodOptions = None + + +# Bizum entities +class Bizum(PaymentMethodBase): + def __init__(self): + super().__init__() + self.payment_method_options: PaymentMethodOptions = None + + +class PaymentMethods: + def __init__(self): + self.klarna: Klarna = None + self.stcpay: Stcpay = None + self.tabby: Tabby = None + self.bizum: Bizum = None + + +# Settings entity +class Settings: + def __init__(self): + self.success_url: str = None + self.failure_url: str = None + + +# Order entities +class OrderSubMerchant: + def __init__(self): + self.id: str = None + self.product_category: str = None + self.number_of_trades: int = None + self.registration_date: datetime = None + + +class Order: + def __init__(self): + self.items: list = None # list of PaymentContextsItems (reused from contexts) + self.shipping = None # ShippingDetails (reused from common) + self.sub_merchants: list = None # list of OrderSubMerchant + self.discount_amount: int = None + + +# Industry entities (reusing from contexts where possible) +class AirlineData: + def __init__(self): + self.ticket = None # PaymentContextsTicket (reused from contexts) + self.passengers: list = None # list of PaymentContextsPassenger (reused from contexts) + self.flight_leg_details: list = None # list of PaymentContextsFlightLegDetails (reused from contexts) + + +class Industry: + def __init__(self): + self.airline_data: AirlineData = None + self.accommodation_data: list = None # list of AccommodationData (reused from contexts) + + +# Main Request and Response classes +class PaymentSetupsRequest: + def __init__(self): + self.processing_channel_id: str = None + self.amount: int = None + self.currency: Currency = None + self.payment_type: PaymentType = None + self.reference: str = None + self.description: str = None + self.payment_methods: PaymentMethods = None + self.settings: Settings = None + self.customer: Customer = None + self.order: Order = None + self.industry: Industry = None + + +class PaymentSetupsResponse: + def __init__(self): + self.id: str = None + self.processing_channel_id: str = None + self.amount: int = None + self.currency: Currency = None + self.payment_type: PaymentType = None + self.reference: str = None + self.description: str = None + self.payment_methods: PaymentMethods = None + self.settings: Settings = None + self.customer: Customer = None + self.order: Order = None + self.industry: Industry = None + + +# Customer Response for Confirm +class CustomerResponse: + def __init__(self): + self.id: str = None + self.email: str = None + self.name: str = None + self.phone: Phone = None + self.summary = None # Customer summary object for risk assessment + + +# Source for Confirm Response +class PaymentSetupSource: + def __init__(self): + self.type: str = None + self.id: str = None + self.expiry_month: int = None + self.expiry_year: int = None + self.last4: str = None + self.fingerprint: str = None + self.bin: str = None + self.billing_address: Address = None + self.phone: Phone = None + self.name: str = None + self.scheme: str = None + self.scheme_local: str = None + self.local_schemes: list = None # list of str + self.card_type: str = None + self.card_category: str = None + self.card_wallet_type: str = None + self.issuer: str = None + self.issuer_country: Country = None + self.product_id: str = None + self.product_type: str = None + self.avs_check: str = None + self.cvv_check: str = None + self.payment_account_reference: str = None + self.encrypted_card_number: str = None + self.account_update_status: str = None + self.account_update_failure_code: str = None + self.account_holder = None # Can be various types based on account holder type + + +# Nested classes for Confirm Response (reusing from payments) +class Risk: + def __init__(self): + self.flagged: bool = None + self.score: int = None + + +class ThreeDs: + def __init__(self): + self.downgraded: bool = None + self.enrolled: str = None + self.upgrade_reason: str = None + + +class Processing: + def __init__(self): + self.retrieval_reference_number: str = None + self.acquirer_transaction_id: str = None + self.recommendation_code: str = None + self.scheme: str = None # The scheme the transaction was processed with + self.partner_merchant_advice_code: str = None + self.partner_response_code: str = None + self.partner_order_id: str = None + self.partner_payment_id: str = None + self.partner_status: str = None + self.partner_transaction_id: str = None + self.partner_error_codes: list = None # list of str + self.partner_error_message: str = None + self.partner_authorization_code: str = None + self.partner_authorization_response_code: str = None + self.surcharge_amount: int = None + self.pan_type_processed: str = None + self.cko_network_token_available: bool = None + self.purchase_country: str = None + self.foreign_retailer_amount: int = None + + +# Additional classes for Confirm Response (reusing from common) +class Balances: + def __init__(self): + self.total_authorized: int = None + self.total_voided: int = None + self.available_to_void: int = None + self.total_captured: int = None + self.available_to_capture: int = None + self.total_refunded: int = None + self.available_to_refund: int = None + + +class Subscription: + def __init__(self): + self.id: str = None + + +class Retry(CustomerRetry): + def __init__(self): + super().__init__() + self.ends_on: datetime = None + self.next_attempt_on: datetime = None + + +# Confirm Response +class PaymentSetupsConfirmResponse: + def __init__(self): + self.id: str = None + self.action_id: str = None + self.amount: int = None + self.currency: Currency = None + self.approved: bool = None + self.status: str = None + self.response_code: str = None + self.processed_on: datetime = None + self.amount_requested: int = None + self.auth_code: str = None + self.response_summary: str = None + self.expires_on: str = None + self.threeds: ThreeDs = None + self.risk: Risk = None + self.source: PaymentSetupSource = None + self.customer: CustomerResponse = None + self.balances: Balances = None + self.reference: str = None + self.subscription: Subscription = None + self.processing: Processing = None + self.eci: str = None + self.scheme_id: str = None + self.retry: Retry = None + + @property + def threeds_3ds(self): + """API spec uses '3ds' as field name - this provides compatibility""" + return self.threeds + + @threeds_3ds.setter + def threeds_3ds(self, value): + self.threeds = value \ No newline at end of file diff --git a/checkout_sdk/payments/setups/setups_client.py b/checkout_sdk/payments/setups/setups_client.py new file mode 100644 index 0000000..dc53eef --- /dev/null +++ b/checkout_sdk/payments/setups/setups_client.py @@ -0,0 +1,60 @@ +from __future__ import absolute_import + +from checkout_sdk.api_client import ApiClient +from checkout_sdk.authorization_type import AuthorizationType +from checkout_sdk.checkout_configuration import CheckoutConfiguration +from checkout_sdk.client import Client +from checkout_sdk.payments.setups.setups import PaymentSetupsRequest, PaymentSetupsResponse, PaymentSetupsConfirmResponse + + +class PaymentSetupsClient(Client): + __PAYMENTS_PATH = 'payments' + __SETUPS_PATH = 'setups' + __CONFIRM_PATH = 'confirm' + + def __init__(self, api_client: ApiClient, configuration: CheckoutConfiguration): + super().__init__(api_client=api_client, + configuration=configuration, + authorization_type=AuthorizationType.SECRET_KEY_OR_OAUTH) + + def create_payment_setup(self, payment_setups_request: PaymentSetupsRequest, idempotency_key: str = None): + """ + Creates a Payment Setup + """ + return self._api_client.post( + self.build_path(self.__PAYMENTS_PATH, self.__SETUPS_PATH), + self._sdk_authorization(), + payment_setups_request, + idempotency_key + ) + + def update_payment_setup(self, setup_id: str, payment_setups_request: PaymentSetupsRequest, idempotency_key: str = None): + """ + Updates a Payment Setup + """ + return self._api_client.put( + self.build_path(self.__PAYMENTS_PATH, self.__SETUPS_PATH, setup_id), + self._sdk_authorization(), + payment_setups_request, + idempotency_key + ) + + def get_payment_setup(self, setup_id: str): + """ + Gets a Payment Setup + """ + return self._api_client.get( + self.build_path(self.__PAYMENTS_PATH, self.__SETUPS_PATH, setup_id), + self._sdk_authorization() + ) + + def confirm_payment_setup(self, setup_id: str, payment_method_option_id: str, idempotency_key: str = None): + """ + Confirms a Payment Setup + """ + return self._api_client.post( + self.build_path(self.__PAYMENTS_PATH, self.__SETUPS_PATH, setup_id, self.__CONFIRM_PATH, payment_method_option_id), + self._sdk_authorization(), + None, + idempotency_key + ) \ No newline at end of file diff --git a/checkout_sdk/properties.py b/checkout_sdk/properties.py index ba3f5b1..36cbff7 100644 --- a/checkout_sdk/properties.py +++ b/checkout_sdk/properties.py @@ -1 +1 @@ -VERSION = "3.4.3" +VERSION = "3.4.4" diff --git a/tests/checkout_api_test.py b/tests/checkout_api_test.py index 559f7b9..315a0b5 100644 --- a/tests/checkout_api_test.py +++ b/tests/checkout_api_test.py @@ -28,5 +28,6 @@ def test_should_instantiate_and_retrieve_clients_default(mock_sdk_configuration) assert api.disputes is not None assert api.forex is not None assert api.accounts is not None + assert api.setups is not None # APMs assert api.ideal is not None diff --git a/tests/payments/setups/payment_setups_client_test.py b/tests/payments/setups/payment_setups_client_test.py new file mode 100644 index 0000000..54de63f --- /dev/null +++ b/tests/payments/setups/payment_setups_client_test.py @@ -0,0 +1,28 @@ +import pytest + +from checkout_sdk.payments.setups.setups import PaymentSetupsRequest +from checkout_sdk.payments.setups.setups_client import PaymentSetupsClient + + +@pytest.fixture(scope='class') +def client(mock_sdk_configuration, mock_api_client): + return PaymentSetupsClient(api_client=mock_api_client, configuration=mock_sdk_configuration) + + +class TestPaymentSetupsClient: + + def test_should_create_payment_setup(self, mocker, client: PaymentSetupsClient): + mocker.patch('checkout_sdk.api_client.ApiClient.post', return_value='response') + assert client.create_payment_setup(PaymentSetupsRequest()) == 'response' + + def test_should_update_payment_setup(self, mocker, client: PaymentSetupsClient): + mocker.patch('checkout_sdk.api_client.ApiClient.put', return_value='response') + assert client.update_payment_setup('setup_id', PaymentSetupsRequest()) == 'response' + + def test_should_get_payment_setup(self, mocker, client: PaymentSetupsClient): + mocker.patch('checkout_sdk.api_client.ApiClient.get', return_value='response') + assert client.get_payment_setup('setup_id') == 'response' + + def test_should_confirm_payment_setup(self, mocker, client: PaymentSetupsClient): + mocker.patch('checkout_sdk.api_client.ApiClient.post', return_value='response') + assert client.confirm_payment_setup('setup_id', 'payment_method_option_id') == 'response' \ No newline at end of file diff --git a/tests/payments/setups/payment_setups_integration_test.py b/tests/payments/setups/payment_setups_integration_test.py new file mode 100644 index 0000000..002ff9b --- /dev/null +++ b/tests/payments/setups/payment_setups_integration_test.py @@ -0,0 +1,132 @@ +from __future__ import absolute_import + +from checkout_sdk.common.enums import Currency, Country +from checkout_sdk.payments.payments import PaymentType +from checkout_sdk.payments.setups.setups import ( + PaymentSetupsRequest, Settings, Customer, CustomerEmail, CustomerDevice, MerchantAccount, + Order, OrderSubMerchant, PaymentMethods, Klarna, KlarnaAccountHolder, + PaymentMethodOptions, PaymentMethodOption, PaymentMethodAction, + PaymentMethodInitialization +) +from tests.checkout_test_utils import assert_response, new_uuid, address, phone + + +def test_should_create_and_get_payment_setup_details(default_api): + request = create_payment_setups_request() + + response = default_api.setups.create_payment_setup(request) + + assert_response(response, + 'http_metadata', + 'id', + 'processing_channel_id', + 'amount', + 'currency') + + payment_setup_details = default_api.setups.get_payment_setup(response.id) + + assert_response(payment_setup_details, + 'http_metadata', + 'id', + 'processing_channel_id', + 'amount', + 'currency', + 'payment_type', + 'reference', + 'description') + + +def test_should_update_payment_setup(default_api): + # Create initial setup + request = create_payment_setups_request() + create_response = default_api.setups.create_payment_setup(request) + + assert_response(create_response, 'id') + + # Update the setup + update_request = create_payment_setups_request() + update_request.description = "Updated description" + update_request.amount = 15000 + + update_response = default_api.setups.update_payment_setup(create_response.id, update_request) + + assert_response(update_response, + 'http_metadata', + 'id', + 'amount', + 'description') + + +def create_payment_setups_request() -> PaymentSetupsRequest: + # Create customer + email = CustomerEmail() + email.address = "johnsmith@example.com" + email.verified = True + + customer_phone = phone() + + device = CustomerDevice() + device.locale = "en_GB" + + merchant_account = MerchantAccount() + merchant_account.id = "1234" + merchant_account.returning_customer = True + merchant_account.total_order_count = 6 + merchant_account.last_payment_amount = 5599 # Using int instead of float + + customer = Customer() + customer.email = email + customer.name = "John Smith" + customer.phone = customer_phone + customer.device = device + customer.merchant_account = merchant_account + + # Create settings + settings = Settings() + settings.success_url = "http://example.com/payments/success" + settings.failure_url = "http://example.com/payments/fail" + + # Create order + order = Order() + order.discount_amount = 10 + + # Create Klarna payment method + account_holder = KlarnaAccountHolder() + account_holder.billing_address = address() + + sdk_action = PaymentMethodAction() + sdk_action.type = "sdk" + sdk_action.client_token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.ewogICJzZXNzaW9uX2lkIiA6ICIw" + sdk_action.session_id = "0b1d9815-165e-42e2-8867-35bc03789e00" + + sdk_option = PaymentMethodOption() + sdk_option.id = "opt_drzstxerxrku3apsepshbslssu" + sdk_option.action = sdk_action + + klarna_options = PaymentMethodOptions() + klarna_options.sdk = sdk_option + + klarna_method = Klarna() + klarna_method.status = "available" + klarna_method.flags = ["string"] + klarna_method.initialization = PaymentMethodInitialization.DISABLED + klarna_method.account_holder = account_holder + klarna_method.payment_method_options = klarna_options + + payment_methods = PaymentMethods() + payment_methods.klarna = klarna_method + + # Create main request + request = PaymentSetupsRequest() + request.processing_channel_id = "pc_q4dbxom5jbgudnjzjpz7j2z6uq" + request.amount = 10000 + request.currency = Currency.GBP + request.payment_type = PaymentType.REGULAR + request.reference = new_uuid() + request.description = "Set of three t-shirts." + request.payment_methods = payment_methods + request.settings = settings + request.customer = customer + request.order = order + + return request \ No newline at end of file