diff --git a/checkout_sdk/accounts/accounts.py b/checkout_sdk/accounts/accounts.py index 1dd6dba..e2d1ae7 100644 --- a/checkout_sdk/accounts/accounts.py +++ b/checkout_sdk/accounts/accounts.py @@ -23,18 +23,57 @@ class DaySchedule(str, Enum): class BusinessType(str, Enum): + INDIVIDUAL_OR_SOLE_PROPRIETORSHIP = 'individual_or_sole_proprietorship' GENERAL_PARTNERSHIP = 'general_partnership' LIMITED_PARTNERSHIP = 'limited_partnership' + SCOTTISH_LIMITED_PARTNERSHIP = 'scottish_limited_partnership' PUBLIC_LIMITED_COMPANY = 'public_limited_company' LIMITED_COMPANY = 'limited_company' + LIMITED_LIABILITY_CORPORATION = 'limited_liability_corporation' + PRIVATE_CORPORATION = 'private_corporation' + PUBLICLY_TRADED_CORPORATION = 'publicly_traded_corporation' PROFESSIONAL_ASSOCIATION = 'professional_association' UNINCORPORATED_ASSOCIATION = 'unincorporated_association' AUTO_ENTREPRENEUR = 'auto_entrepreneur' + GOVERNMENT_AGENCY = 'government_agency' + NON_PROFIT_ENTITY = 'non_profit_entity' + TRUST = 'trust' + CLUB_OR_SOCIETY = 'club_or_society' + REGULATED_FINANCIAL_INSTITUTION = 'regulated_financial_institution' + CFTC_REGISTERED_ENTITY = 'cftc_registered_entity' + SEC_REGISTERED_ENTITY = 'sec_registered_entity' class EntityRoles(str, Enum): UBO = 'ubo' LEGAL_REPRESENTATIVE = 'legal_representative' + AUTHORISED_SIGNATORY = 'authorised_signatory' + DIRECTOR = 'director' + CONTROL_PERSON = 'control_person' + + +class CompanyPosition(str, Enum): + CEO = 'ceo' + CFO = 'cfo' + COO = 'coo' + MANAGING_MEMBER = 'managing_member' + GENERAL_PARTNER = 'general_partner' + PRESIDENT = 'president' + VICE_PRESIDENT = 'vice_president' + TREASURER = 'treasurer' + OTHER_SENIOR_MANAGEMENT = 'other_senior_management' + OTHER_EXECUTIVE_OFFICER = 'other_executive_officer' + OTHER_NON_EXECUTIVE_NON_SENIOR = 'other_non_executive_non_senior' + + +class NationalIdType(str, Enum): + SSN = 'ssn' + ITIN = 'itin' + PASSPORT = 'passport' + DRIVING_LICENSE = 'driving_license' + NATIONAL_ID_CARD = 'national_id_card' + RESIDENCE_PERMIT = 'residence_permit' + OTHER = 'other' class EntityEmailAddresses: @@ -108,6 +147,11 @@ class ArticlesOfAssociationType(str, Enum): ARTICLES_OF_ASSOCIATION = "articles_of_association" +class ArticlesOfAssociation: + type: ArticlesOfAssociationType + front: str + + class BankVerificationType(str, Enum): BANK_STATEMENT = 'bank_statement' @@ -157,10 +201,19 @@ class FinancialVerification: front: str +class FinancialStatementsType(str, Enum): + FINANCIAL_STATEMENTS = 'financial_statements' + + +class FinancialStatements: + type: FinancialStatementsType + front: str + + class OnboardSubEntityDocuments: identity_verification: EntityIdentificationDocument company_verification: CompanyVerification - articles_of_association: ArticlesOfAssociationType + articles_of_association: ArticlesOfAssociation bank_verification: BankVerification shareholder_structure: ShareholderStructure proof_of_legality: ProofOfLegality @@ -170,9 +223,74 @@ class OnboardSubEntityDocuments: additional_document3: AdditionalDocument tax_verification: TaxVerification financial_verification: FinancialVerification + financial_statements: FinancialStatements + + +class CertifiedAuthorisedSignatoryType(str, Enum): + POWER_OF_ATTORNEY = 'power_of_attorney' + + +class CertifiedAuthorisedSignatory: + type: CertifiedAuthorisedSignatoryType + front: str + + +class ProofOfResidentialAddressType(str, Enum): + PROOF_OF_ADDRESS = 'proof_of_address' + + +class ProofOfResidentialAddress: + type: ProofOfResidentialAddressType + front: str + + +class ProofOfRegistrationType(str, Enum): + EXTRACT_FROM_TRADE_REGISTER = 'extract_from_trade_register' + OTHER = 'other' + + +class ProofOfRegistration: + type: ProofOfRegistrationType + front: str + + +# The representative-level documents union (Accounts API v3.0) is a distinct, smaller shape than the +# entity-level OnboardSubEntityDocuments — per shared/swagger-latest.json it carries only these fields. +class RepresentativeDocuments: + identity_verification: EntityIdentificationDocument + certified_authorised_signatory: CertifiedAuthorisedSignatory + proof_of_residential_address: ProofOfResidentialAddress + proof_of_registration: ProofOfRegistration + + +class Citizenship: + type: str + country: Country + + +class RepresentativeIndividual: + first_name: str + middle_name: str + last_name: str + date_of_birth: DateOfBirth + place_of_birth: PlaceOfBirth + citizenships: list # Citizenship + national_id_type: NationalIdType + national_id_number: str + email_address: str + phone: Phone + address: Address class EntityRepresentative: + # v3.0 (Accounts API v3.0) + id: str + individual: RepresentativeIndividual + roles: list # accounts.EntityRoles + company_position: CompanyPosition + ownership_percentage: int + documents: RepresentativeDocuments + # v2.0 only — deprecated; use `individual` for v3.0 first_name: str middle_name: str last_name: str @@ -181,8 +299,6 @@ class EntityRepresentative: phone: Phone date_of_birth: DateOfBirth place_of_birth: PlaceOfBirth - roles: list # accounts.EntityRoles - documents: OnboardSubEntityDocuments class EntityFinancialDocuments: @@ -199,6 +315,7 @@ class EntityFinancialDetails: class DateOfIncorporation: + day: int month: int year: int @@ -208,6 +325,8 @@ class Company: business_type: BusinessType legal_name: str trading_name: str + additional_trading_names: list # str + is_registered_company: bool date_of_incorporation: DateOfIncorporation regulatory_licence_number: str principal_address: Address @@ -235,13 +354,26 @@ class Individual: financial_details: EntityFinancialDetails +class ProcessingDetailsAch: + annual_ach_volume: int + average_ach_transaction_size: int + estimated_monthly_credit_volume: int + average_credit_amount: int + + +class ProcessingDetailsPayments: + ach: ProcessingDetailsAch + + class ProcessingDetails: settlement_country: str target_countries: list # str annual_processing_volume: int average_transaction_value: int + average_order_fulfillment_time: int highest_transaction_value: int currency: Currency + payments: ProcessingDetailsPayments class AdditionalInfo: @@ -250,6 +382,23 @@ class AdditionalInfo: field3: str +class AgreedTerms: + date: str + ip_address: str + name: str + email: str + version: str + + +class SchemaVersionHeader: + accept: str + + def get_header_mappings(self) -> Dict[str, str]: + return { + 'accept': 'Accept' + } + + class OnboardEntityRequest: reference: str is_draft: bool @@ -257,9 +406,12 @@ class OnboardEntityRequest: contact_details: ContactDetails company: Company processing_details: ProcessingDetails - individual: Individual + agreed_terms: AgreedTerms + seller_category: str documents: OnboardSubEntityDocuments additional_info: AdditionalInfo + # v2.0 only — deprecated; a v3.0 sole trader is onboarded as a `company` with representatives + individual: Individual class InstrumentDocument: diff --git a/checkout_sdk/accounts/accounts_client.py b/checkout_sdk/accounts/accounts_client.py index 3b0b3c4..24b8ad2 100644 --- a/checkout_sdk/accounts/accounts_client.py +++ b/checkout_sdk/accounts/accounts_client.py @@ -5,7 +5,7 @@ from checkout_sdk.accounts.accounts import ( EtagHeader, OnboardEntityRequest, UpdateScheduleRequest, AccountsPaymentInstrument, PaymentInstrumentRequest, PaymentInstrumentsQuery, UpdatePaymentInstrumentRequest, - ReserveRuleRequest, EntityFileRequest, EntityRequirementUpdateRequest + ReserveRuleRequest, EntityFileRequest, EntityRequirementUpdateRequest, SchemaVersionHeader ) from checkout_sdk.api_client import ApiClient from checkout_sdk.authorization_type import AuthorizationType @@ -14,6 +14,8 @@ from checkout_sdk.common.enums import Currency from checkout_sdk.files.files import FileRequest +DEFAULT_SCHEMA_VERSION = '3.0' + class AccountsClient(Client): __ACCOUNTS_PATH = 'accounts' @@ -34,6 +36,12 @@ def __init__(self, api_client: ApiClient, authorization_type=AuthorizationType.SECRET_KEY_OR_OAUTH) self.__files_client = files_client + @staticmethod + def __build_schema_version_headers(schema_version: str): + headers = SchemaVersionHeader() + headers.accept = 'application/json;schema_version=' + schema_version + return headers + def upload_file(self, file_request: FileRequest): return self.__files_client.submit_file( self.__FILES_PATH, @@ -41,11 +49,13 @@ def upload_file(self, file_request: FileRequest): file_request, multipart_file='path') - def create_entity(self, onboard_entity_request: OnboardEntityRequest): + def create_entity(self, onboard_entity_request: OnboardEntityRequest, + schema_version: str = DEFAULT_SCHEMA_VERSION): return self._api_client.post( self.build_path(self.__ACCOUNTS_PATH, self.__ENTITIES_PATH), self._sdk_authorization(), - onboard_entity_request) + onboard_entity_request, + headers=self.__build_schema_version_headers(schema_version)) def retrieve_payment_instrument_details(self, entity_id: str, payment_instrument_id: str): return self._api_client.get( @@ -54,16 +64,19 @@ def retrieve_payment_instrument_details(self, entity_id: str, payment_instrument self._sdk_authorization() ) - def get_entity(self, entity_id: str): + def get_entity(self, entity_id: str, schema_version: str = DEFAULT_SCHEMA_VERSION): return self._api_client.get( self.build_path(self.__ACCOUNTS_PATH, self.__ENTITIES_PATH, entity_id), - self._sdk_authorization()) + self._sdk_authorization(), + headers=self.__build_schema_version_headers(schema_version)) - def update_entity(self, entity_id: str, onboard_entity_request: OnboardEntityRequest): + def update_entity(self, entity_id: str, onboard_entity_request: OnboardEntityRequest, + schema_version: str = DEFAULT_SCHEMA_VERSION): return self._api_client.put( self.build_path(self.__ACCOUNTS_PATH, self.__ENTITIES_PATH, entity_id), self._sdk_authorization(), - onboard_entity_request) + onboard_entity_request, + headers=self.__build_schema_version_headers(schema_version)) def create_payment_instrument(self, entity_id: str, accounts_payment_instrument: AccountsPaymentInstrument): warn('Deprecated use add_payment_instrument instead', DeprecationWarning, @@ -149,10 +162,11 @@ def update_reserve_rule(self, entity_id: str, reserve_rule_id: str, etag: str, u entity_id, self.__RESERVE_RULES_PATH, reserve_rule_id) return self._api_client.put(path, self._sdk_authorization(), update_request, headers=headers) - def get_entity_requirements(self, entity_id: str): + def get_entity_requirements(self, entity_id: str, schema_version: str = DEFAULT_SCHEMA_VERSION): return self._api_client.get( self.build_path(self.__ACCOUNTS_PATH, self.__ENTITIES_PATH, entity_id, self.__REQUIREMENTS_PATH), - self._sdk_authorization()) + self._sdk_authorization(), + headers=self.__build_schema_version_headers(schema_version)) def get_entity_requirement_details(self, entity_id: str, requirement_id: str): return self._api_client.get( diff --git a/checkout_sdk/api_client.py b/checkout_sdk/api_client.py index 7da4712..64e605d 100644 --- a/checkout_sdk/api_client.py +++ b/checkout_sdk/api_client.py @@ -36,8 +36,9 @@ def __init__(self, configuration: CheckoutConfiguration, base_uri: str): def get(self, path, authorization: SdkAuthorization, - params=None): - return self.invoke(method='GET', path=path, authorization=authorization, params=params) + params=None, + headers=None): + return self.invoke(method='GET', path=path, authorization=authorization, params=params, headers=headers) def post(self, path, diff --git a/tests/accounts/accounts_client_test.py b/tests/accounts/accounts_client_test.py index 3feb1f1..5749279 100644 --- a/tests/accounts/accounts_client_test.py +++ b/tests/accounts/accounts_client_test.py @@ -168,3 +168,39 @@ def test_should_resolve_entity_requirement(self, mocker, client: AccountsClient) assert client.resolve_entity_requirement('entity_id', 'requirement_id', body) == 'response' assert_api_call(mock, 'accounts/entities/entity_id/requirements/requirement_id', body) + + # Accounts API v3.0 — schema_version negotiation via the Accept header + + @staticmethod + def _accept_header(mock): + return mock.call_args.kwargs['headers'].accept + + def test_should_create_entity_with_default_schema_version(self, mocker, client: AccountsClient): + mock = mocker.patch('checkout_sdk.api_client.ApiClient.post', return_value='response') + + assert client.create_entity(OnboardEntityRequest()) == 'response' + assert self._accept_header(mock) == 'application/json;schema_version=3.0' + + def test_should_get_entity_with_default_schema_version(self, mocker, client: AccountsClient): + mock = mocker.patch('checkout_sdk.api_client.ApiClient.get', return_value='response') + + assert client.get_entity('entity_id') == 'response' + assert self._accept_header(mock) == 'application/json;schema_version=3.0' + + def test_should_update_entity_with_default_schema_version(self, mocker, client: AccountsClient): + mock = mocker.patch('checkout_sdk.api_client.ApiClient.put', return_value='response') + + assert client.update_entity('entity_id', OnboardEntityRequest()) == 'response' + assert self._accept_header(mock) == 'application/json;schema_version=3.0' + + def test_should_get_entity_requirements_with_default_schema_version(self, mocker, client: AccountsClient): + mock = mocker.patch('checkout_sdk.api_client.ApiClient.get', return_value='response') + + assert client.get_entity_requirements('entity_id') == 'response' + assert self._accept_header(mock) == 'application/json;schema_version=3.0' + + def test_should_override_schema_version(self, mocker, client: AccountsClient): + mock = mocker.patch('checkout_sdk.api_client.ApiClient.get', return_value='response') + + assert client.get_entity('entity_id', schema_version='2.0') == 'response' + assert self._accept_header(mock) == 'application/json;schema_version=2.0' diff --git a/tests/accounts/accounts_integration_test.py b/tests/accounts/accounts_integration_test.py index fb846ed..3fd14c1 100644 --- a/tests/accounts/accounts_integration_test.py +++ b/tests/accounts/accounts_integration_test.py @@ -9,7 +9,10 @@ from checkout_sdk.accounts.accounts import OnboardEntityRequest, ContactDetails, Profile, Individual, \ DateOfBirth, Identification, EntityEmailAddresses, Company, EntityRepresentative, PaymentInstrumentRequest, \ InstrumentDocument, InstrumentDetailsFasterPayments, ReserveRuleRequest, RollingReserveRule, \ - HoldingDuration, EntityFileRequest, FilePurpose + HoldingDuration, EntityFileRequest, FilePurpose, RepresentativeIndividual, PlaceOfBirth, EntityRoles, \ + CompanyPosition, BusinessType, DateOfIncorporation, ProcessingDetails, ProcessingDetailsPayments, \ + ProcessingDetailsAch +from checkout_sdk.common.common import Phone from checkout_sdk.common.enums import Currency, Country, InstrumentType from checkout_sdk.files.files import FileRequest from checkout_sdk.oauth_scopes import OAuthScopes @@ -51,11 +54,12 @@ def test_should_create_get_and_update_onboard_entity(accounts_checkout_api): onboard_entity_request.individual.identification = Identification() onboard_entity_request.individual.identification.national_id_number = 'AB123456C' - create_entity_response = accounts_checkout_api.accounts.create_entity(onboard_entity_request) + # v2.0 payload (top-level individual) — pin to schema_version 2.0 (SDK now defaults to 3.0) + create_entity_response = accounts_checkout_api.accounts.create_entity(onboard_entity_request, '2.0') assert_response(create_entity_response, 'id', 'reference') - get_entity_response = accounts_checkout_api.accounts.get_entity(create_entity_response.id) + get_entity_response = accounts_checkout_api.accounts.get_entity(create_entity_response.id, '2.0') assert_response(get_entity_response, 'id', @@ -72,13 +76,87 @@ def test_should_create_get_and_update_onboard_entity(accounts_checkout_api): onboard_entity_request.individual.first_name = 'John' - update_response = accounts_checkout_api.accounts.update_entity(create_entity_response.id, onboard_entity_request) + update_response = accounts_checkout_api.accounts.update_entity(create_entity_response.id, onboard_entity_request, + '2.0') assert_response(update_response, 'id') assert create_entity_response.id == update_response.id +@pytest.mark.skip(reason='Schema 3.0 onboarding pending sandbox account currency-scope confirmation') +def test_should_onboard_company_v3(accounts_checkout_api): + entity_request = OnboardEntityRequest() + entity_request.reference = new_uuid()[:14] + + # v3.0: phone.country_code is an ISO 3166-1 alpha-2 code (finding 3), not a dialing code + entity_request.contact_details = ContactDetails() + v3_phone = Phone() + v3_phone.country_code = 'GB' + v3_phone.number = '2072343000' + entity_request.contact_details.phone = v3_phone + entity_request.contact_details.email_addresses = EntityEmailAddresses() + entity_request.contact_details.email_addresses.primary = random_email() + + # v3.0: profile currencies are validated against the platform scope (finding 4) + entity_request.profile = Profile() + entity_request.profile.urls = ['https://www.superheroexample.com'] + entity_request.profile.mccs = ['0742'] + entity_request.profile.default_holding_currency = Currency.USD + entity_request.profile.holding_currencies = [Currency.USD] + + entity_request.company = Company() + entity_request.company.legal_name = 'Super Hero Masks Inc.' + entity_request.company.trading_name = 'Super Hero Masks' + entity_request.company.business_registration_number = '01234567' + entity_request.company.business_type = BusinessType.LIMITED_COMPANY + entity_request.company.principal_address = address() + entity_request.company.registered_address = address() + entity_request.company.date_of_incorporation = DateOfIncorporation() + entity_request.company.date_of_incorporation.day = 1 + entity_request.company.date_of_incorporation.month = 6 + entity_request.company.date_of_incorporation.year = 2010 + + # v3.0: representative is a "person of interest" with a nested individual + roles (finding 2) + representative = EntityRepresentative() + representative.roles = [EntityRoles.UBO, EntityRoles.AUTHORISED_SIGNATORY] + representative.company_position = CompanyPosition.CEO + representative.ownership_percentage = 100 + representative.individual = RepresentativeIndividual() + representative.individual.first_name = 'John' + representative.individual.last_name = 'Doe' + representative.individual.national_id_number = 'AB123456C' + representative.individual.email_address = random_email() + representative.individual.address = address() + representative.individual.date_of_birth = DateOfBirth() + representative.individual.date_of_birth.day = 5 + representative.individual.date_of_birth.month = 6 + representative.individual.date_of_birth.year = 1996 + representative.individual.place_of_birth = PlaceOfBirth() + representative.individual.place_of_birth.country = Country.GB + entity_request.company.representatives = [representative] + + # v3.0: processing currency reflects the sub-entity region and can differ from profile scope (finding 4) + entity_request.processing_details = ProcessingDetails() + entity_request.processing_details.settlement_country = 'GB' + entity_request.processing_details.target_countries = ['GB'] + entity_request.processing_details.annual_processing_volume = 1000000 + entity_request.processing_details.average_transaction_value = 5000 + entity_request.processing_details.highest_transaction_value = 25000 + entity_request.processing_details.currency = Currency.GBP + entity_request.processing_details.payments = ProcessingDetailsPayments() + entity_request.processing_details.payments.ach = ProcessingDetailsAch() + entity_request.processing_details.payments.ach.annual_ach_volume = 1000000 + entity_request.processing_details.payments.ach.average_ach_transaction_size = 5000 + + # default schema_version is 3.0 + create_response = accounts_checkout_api.accounts.create_entity(entity_request) + assert_response(create_response, 'id', 'reference') + + get_response = accounts_checkout_api.accounts.get_entity(create_response.id) + assert_response(get_response, 'id', 'reference', 'company', 'company.representatives') + + def test_should_upload_file(accounts_checkout_api): upload_file(accounts_checkout_api) @@ -107,7 +185,8 @@ def test_should_create_and_retrieve_payment_instrument(accounts_checkout_api): representative.identification.national_id_number = 'AB123456C' entity_request.company.representatives = [representative] - entity_response = accounts_checkout_api.accounts.create_entity(entity_request) + # v2.0 payload (flat representative) — pin to schema_version 2.0 + entity_response = accounts_checkout_api.accounts.create_entity(entity_request, '2.0') file = upload_file(accounts_checkout_api) @@ -258,7 +337,8 @@ def create_test_entity(api): representative.address = address() entity_request.company.representatives = [representative] - entity_response = api.accounts.create_entity(entity_request) + # v2.0 payload (flat representative) — pin to schema_version 2.0 + entity_response = api.accounts.create_entity(entity_request, '2.0') assert_response(entity_response, 'id') return entity_response.id diff --git a/tests/accounts/accounts_schema_version_header_test.py b/tests/accounts/accounts_schema_version_header_test.py new file mode 100644 index 0000000..8806fbf --- /dev/null +++ b/tests/accounts/accounts_schema_version_header_test.py @@ -0,0 +1,68 @@ +from unittest.mock import MagicMock + +import pytest + +from checkout_sdk.accounts.accounts import OnboardEntityRequest +from checkout_sdk.accounts.accounts_client import AccountsClient +from checkout_sdk.api_client import ApiClient + + +# Exercises the REAL ApiClient header path (_process_custom_headers -> invoke), unlike +# accounts_client_test which mocks ApiClient. Asserts the Accept header actually reaching the wire. +@pytest.fixture +def client_and_http(mock_sdk_configuration): + api_client = ApiClient(configuration=mock_sdk_configuration, + base_uri=mock_sdk_configuration.environment.base_uri) + http_client = MagicMock() + response = MagicMock() + response.text = '' + response.raise_for_status.return_value = None + http_client.request.return_value = response + api_client._http_client = http_client + + client = AccountsClient(api_client=api_client, + files_client=api_client, + configuration=mock_sdk_configuration) + # the empty mock credentials resolve to no authorization; stub it (irrelevant to the header assertion) + authorization = MagicMock() + authorization.get_authorization_header.return_value = 'Bearer test' + client._sdk_authorization = lambda *args, **kwargs: authorization + return client, http_client + + +def _sent_accept(http_client): + return http_client.request.call_args.kwargs['headers']['Accept'] + + +class TestAccountsSchemaVersionHeader: + + def test_create_entity_sends_default_schema_version(self, client_and_http): + client, http_client = client_and_http + client.create_entity(OnboardEntityRequest()) + assert _sent_accept(http_client) == 'application/json;schema_version=3.0' + + def test_get_entity_sends_default_schema_version(self, client_and_http): + client, http_client = client_and_http + client.get_entity('entity_id') + assert _sent_accept(http_client) == 'application/json;schema_version=3.0' + + def test_update_entity_sends_default_schema_version(self, client_and_http): + client, http_client = client_and_http + client.update_entity('entity_id', OnboardEntityRequest()) + assert _sent_accept(http_client) == 'application/json;schema_version=3.0' + + def test_get_entity_requirements_sends_default_schema_version(self, client_and_http): + client, http_client = client_and_http + client.get_entity_requirements('entity_id') + assert _sent_accept(http_client) == 'application/json;schema_version=3.0' + + def test_override_schema_version(self, client_and_http): + client, http_client = client_and_http + client.get_entity('entity_id', schema_version='2.0') + assert _sent_accept(http_client) == 'application/json;schema_version=2.0' + + def test_default_accept_untouched_for_other_operations(self, client_and_http): + client, http_client = client_and_http + # an operation without schema negotiation must keep the global default Accept + client.get_sub_entity_members('entity_id') + assert _sent_accept(http_client) == 'application/json' diff --git a/tests/accounts/accounts_v3_serialization_test.py b/tests/accounts/accounts_v3_serialization_test.py new file mode 100644 index 0000000..8905824 --- /dev/null +++ b/tests/accounts/accounts_v3_serialization_test.py @@ -0,0 +1,338 @@ +import json + +from checkout_sdk.json_serializer import JsonSerializer +from checkout_sdk.common.enums import Country, Currency, DocumentType +from checkout_sdk.accounts.accounts import ( + ProcessingDetails, ProcessingDetailsPayments, ProcessingDetailsAch, + AgreedTerms, Company, BusinessType, DateOfIncorporation, + EntityRepresentative, RepresentativeIndividual, Citizenship, NationalIdType, + CompanyPosition, EntityRoles, FinancialStatements, FinancialStatementsType, + OnboardSubEntityDocuments, OnboardEntityRequest, + EntityIdentificationDocument, CompanyVerification, CompanyVerificationType, + ArticlesOfAssociation, ArticlesOfAssociationType, BankVerification, BankVerificationType, + ShareholderStructure, ShareholderStructureType, ProofOfLegality, ProofOfLegalityType, + ProofOfPrincipalAddress, ProofOfPrincipalAddressType, AdditionalDocument, + TaxVerification, TaxVerificationType, FinancialVerification, FinancialVerificationType, + RepresentativeDocuments, CertifiedAuthorisedSignatory, CertifiedAuthorisedSignatoryType, + ProofOfResidentialAddress, ProofOfResidentialAddressType, ProofOfRegistration, ProofOfRegistrationType, +) + + +def _serialize(obj): + return json.loads(json.dumps(obj, cls=JsonSerializer)) + + +class TestAccountsV3Serialization: + + def test_serializes_processing_details_with_payments(self): + ach = ProcessingDetailsAch() + ach.annual_ach_volume = 1000000 + ach.average_ach_transaction_size = 5000 + ach.estimated_monthly_credit_volume = 100000 + ach.average_credit_amount = 5000 + payments = ProcessingDetailsPayments() + payments.ach = ach + details = ProcessingDetails() + details.annual_processing_volume = 1000000 + details.average_transaction_value = 5000 + details.average_order_fulfillment_time = 3 + details.highest_transaction_value = 25000 + details.currency = Currency.GBP + details.settlement_country = 'GB' + details.target_countries = ['GB'] + details.payments = payments + + assert _serialize(details) == { + 'annual_processing_volume': 1000000, + 'average_transaction_value': 5000, + 'average_order_fulfillment_time': 3, + 'highest_transaction_value': 25000, + 'currency': 'GBP', + 'settlement_country': 'GB', + 'target_countries': ['GB'], + 'payments': { + 'ach': { + 'annual_ach_volume': 1000000, + 'average_ach_transaction_size': 5000, + 'estimated_monthly_credit_volume': 100000, + 'average_credit_amount': 5000, + } + }, + } + + def test_serializes_agreed_terms(self): + agreed_terms = AgreedTerms() + agreed_terms.date = '2026-07-20T10:00:00Z' + agreed_terms.ip_address = '203.0.113.42' + agreed_terms.name = 'John Representative' + agreed_terms.email = 'john@example.com' + agreed_terms.version = '1.0' + + assert _serialize(agreed_terms) == { + 'date': '2026-07-20T10:00:00Z', + 'ip_address': '203.0.113.42', + 'name': 'John Representative', + 'email': 'john@example.com', + 'version': '1.0', + } + + def test_serializes_company_v3_fields(self): + date_of_incorporation = DateOfIncorporation() + date_of_incorporation.day = 1 + date_of_incorporation.month = 6 + date_of_incorporation.year = 2010 + company = Company() + company.legal_name = 'Super Hero Masks Inc.' + company.trading_name = 'Super Hero Masks' + company.business_registration_number = '01234567' + company.business_type = BusinessType.LIMITED_COMPANY + company.additional_trading_names = ['SHM'] + company.is_registered_company = True + company.date_of_incorporation = date_of_incorporation + + assert _serialize(company) == { + 'legal_name': 'Super Hero Masks Inc.', + 'trading_name': 'Super Hero Masks', + 'business_registration_number': '01234567', + 'business_type': 'limited_company', + 'additional_trading_names': ['SHM'], + 'is_registered_company': True, + 'date_of_incorporation': {'day': 1, 'month': 6, 'year': 2010}, + } + + def test_serializes_representative_v3_fields(self): + citizenship = Citizenship() + citizenship.type = 'citizenship' + citizenship.country = Country.US + individual = RepresentativeIndividual() + individual.first_name = 'John' + individual.last_name = 'Doe' + individual.citizenships = [citizenship] + individual.national_id_type = NationalIdType.SSN + individual.national_id_number = 'AB123456C' + individual.email_address = 'john@example.com' + representative = EntityRepresentative() + representative.id = 'rep_00000000000000000000000000' + representative.individual = individual + representative.company_position = CompanyPosition.CEO + representative.ownership_percentage = 100 + representative.roles = [EntityRoles.UBO, EntityRoles.AUTHORISED_SIGNATORY, + EntityRoles.DIRECTOR, EntityRoles.CONTROL_PERSON] + + assert _serialize(representative) == { + 'id': 'rep_00000000000000000000000000', + 'individual': { + 'first_name': 'John', + 'last_name': 'Doe', + 'citizenships': [{'type': 'citizenship', 'country': 'US'}], + 'national_id_type': 'ssn', + 'national_id_number': 'AB123456C', + 'email_address': 'john@example.com', + }, + 'company_position': 'ceo', + 'ownership_percentage': 100, + 'roles': ['ubo', 'authorised_signatory', 'director', 'control_person'], + } + + def test_serializes_representative_documents(self): + identity = EntityIdentificationDocument() + identity.type = DocumentType.PASSPORT + identity.front = 'file_identity_front' + identity.back = 'file_identity_back' + signatory = CertifiedAuthorisedSignatory() + signatory.type = CertifiedAuthorisedSignatoryType.POWER_OF_ATTORNEY + signatory.front = 'file_signatory' + residential = ProofOfResidentialAddress() + residential.type = ProofOfResidentialAddressType.PROOF_OF_ADDRESS + residential.front = 'file_residential' + registration = ProofOfRegistration() + registration.type = ProofOfRegistrationType.EXTRACT_FROM_TRADE_REGISTER + registration.front = 'file_registration' + documents = RepresentativeDocuments() + documents.identity_verification = identity + documents.certified_authorised_signatory = signatory + documents.proof_of_residential_address = residential + documents.proof_of_registration = registration + + assert _serialize(documents) == { + 'identity_verification': {'type': 'passport', 'front': 'file_identity_front', 'back': 'file_identity_back'}, + 'certified_authorised_signatory': {'type': 'power_of_attorney', 'front': 'file_signatory'}, + 'proof_of_residential_address': {'type': 'proof_of_address', 'front': 'file_residential'}, + 'proof_of_registration': {'type': 'extract_from_trade_register', 'front': 'file_registration'}, + } + + def test_serializes_financial_statements_document(self): + financial_statements = FinancialStatements() + financial_statements.type = FinancialStatementsType.FINANCIAL_STATEMENTS + financial_statements.front = 'file_00000000000000000000000000' + documents = OnboardSubEntityDocuments() + documents.financial_statements = financial_statements + + assert _serialize(documents) == { + 'financial_statements': { + 'type': 'financial_statements', + 'front': 'file_00000000000000000000000000', + } + } + + def test_serializes_onboard_entity_request_with_agreed_terms_and_seller_category(self): + agreed_terms = AgreedTerms() + agreed_terms.date = '2026-07-20T10:00:00Z' + agreed_terms.ip_address = '203.0.113.42' + agreed_terms.name = 'John Representative' + agreed_terms.email = 'john@example.com' + agreed_terms.version = '1.0' + request = OnboardEntityRequest() + request.reference = 'ref_1' + request.is_draft = False + request.agreed_terms = agreed_terms + request.seller_category = 'saas' + + assert _serialize(request) == { + 'reference': 'ref_1', + 'is_draft': False, + 'seller_category': 'saas', + 'agreed_terms': { + 'date': '2026-07-20T10:00:00Z', + 'ip_address': '203.0.113.42', + 'name': 'John Representative', + 'email': 'john@example.com', + 'version': '1.0', + }, + } + + def test_serializes_full_onboard_entity_request_v3(self): + individual = RepresentativeIndividual() + individual.first_name = 'John' + individual.last_name = 'Doe' + representative = EntityRepresentative() + representative.individual = individual + representative.roles = [EntityRoles.UBO] + company = Company() + company.legal_name = 'Super Hero Masks Inc.' + company.business_type = BusinessType.LIMITED_COMPANY + company.representatives = [representative] + payments = ProcessingDetailsPayments() + payments.ach = ProcessingDetailsAch() + payments.ach.annual_ach_volume = 1000000 + processing_details = ProcessingDetails() + processing_details.currency = Currency.GBP + processing_details.payments = payments + request = OnboardEntityRequest() + request.reference = 'ref_1' + request.company = company + request.processing_details = processing_details + + result = _serialize(request) + + assert result['reference'] == 'ref_1' + assert result['company']['legal_name'] == 'Super Hero Masks Inc.' + assert result['company']['business_type'] == 'limited_company' + assert result['company']['representatives'][0]['individual']['first_name'] == 'John' + assert result['company']['representatives'][0]['roles'] == ['ubo'] + assert result['processing_details']['currency'] == 'GBP' + assert result['processing_details']['payments']['ach']['annual_ach_volume'] == 1000000 + + def test_serializes_all_thirteen_documents_fields(self): + documents = OnboardSubEntityDocuments() + + identity_verification = EntityIdentificationDocument() + identity_verification.type = DocumentType.NATIONAL_IDENTITY_CARD + identity_verification.front = 'file_identity_front' + identity_verification.back = 'file_identity_back' + documents.identity_verification = identity_verification + + company_verification = CompanyVerification() + company_verification.type = CompanyVerificationType.INCORPORATION_DOCUMENT + company_verification.front = 'file_company_verification' + documents.company_verification = company_verification + + articles_of_association = ArticlesOfAssociation() + articles_of_association.type = ArticlesOfAssociationType.ARTICLES_OF_ASSOCIATION + articles_of_association.front = 'file_articles_of_association' + documents.articles_of_association = articles_of_association + + bank_verification = BankVerification() + bank_verification.type = BankVerificationType.BANK_STATEMENT + bank_verification.front = 'file_bank_verification' + documents.bank_verification = bank_verification + + shareholder_structure = ShareholderStructure() + shareholder_structure.type = ShareholderStructureType.CERTIFIED_SHAREHOLDER_STRUCTURE + shareholder_structure.front = 'file_shareholder_structure' + documents.shareholder_structure = shareholder_structure + + proof_of_legality = ProofOfLegality() + proof_of_legality.type = ProofOfLegalityType.PROOF_OF_LEGALITY + proof_of_legality.front = 'file_proof_of_legality' + documents.proof_of_legality = proof_of_legality + + proof_of_principal_address = ProofOfPrincipalAddress() + proof_of_principal_address.type = ProofOfPrincipalAddressType.PROOF_OF_ADDRESS + proof_of_principal_address.front = 'file_proof_of_principal_address' + documents.proof_of_principal_address = proof_of_principal_address + + tax_verification = TaxVerification() + tax_verification.type = TaxVerificationType.EIN_LETTER + tax_verification.front = 'file_tax_verification' + documents.tax_verification = tax_verification + + financial_verification = FinancialVerification() + financial_verification.type = FinancialVerificationType.FINANCIAL_STATEMENT + financial_verification.front = 'file_financial_verification' + documents.financial_verification = financial_verification + + financial_statements = FinancialStatements() + financial_statements.type = FinancialStatementsType.FINANCIAL_STATEMENTS + financial_statements.front = 'file_financial_statements' + documents.financial_statements = financial_statements + + additional_document1 = AdditionalDocument() + additional_document1.front = 'file_additional_document1' + documents.additional_document1 = additional_document1 + additional_document2 = AdditionalDocument() + additional_document2.front = 'file_additional_document2' + documents.additional_document2 = additional_document2 + additional_document3 = AdditionalDocument() + additional_document3.front = 'file_additional_document3' + documents.additional_document3 = additional_document3 + + result = _serialize(documents) + + expected_fields = [ + 'identity_verification', 'company_verification', 'articles_of_association', + 'bank_verification', 'shareholder_structure', 'proof_of_legality', + 'proof_of_principal_address', 'tax_verification', 'financial_verification', + 'financial_statements', 'additional_document1', 'additional_document2', + 'additional_document3', + ] + assert sorted(result.keys()) == sorted(expected_fields) + for field in expected_fields: + assert 'front' in result[field], f'{field} must serialize a front' + # identity_verification is the only document that carries a back + assert result['identity_verification']['back'] == 'file_identity_back' + assert result['articles_of_association'] == { + 'type': 'articles_of_association', 'front': 'file_articles_of_association' + } + + def test_entity_roles_enum_values(self): + assert [r.value for r in EntityRoles] == [ + 'ubo', 'legal_representative', 'authorised_signatory', 'director', 'control_person', + ] + + def test_company_position_enum_values(self): + assert [p.value for p in CompanyPosition] == [ + 'ceo', 'cfo', 'coo', 'managing_member', 'general_partner', 'president', + 'vice_president', 'treasurer', 'other_senior_management', + 'other_executive_officer', 'other_non_executive_non_senior', + ] + + def test_business_type_enum_values(self): + assert [b.value for b in BusinessType] == [ + 'individual_or_sole_proprietorship', 'general_partnership', 'limited_partnership', + 'scottish_limited_partnership', 'public_limited_company', 'limited_company', + 'limited_liability_corporation', 'private_corporation', 'publicly_traded_corporation', + 'professional_association', 'unincorporated_association', 'auto_entrepreneur', + 'government_agency', 'non_profit_entity', 'trust', 'club_or_society', + 'regulated_financial_institution', 'cftc_registered_entity', 'sec_registered_entity', + ]