From 92556c0e7536d28f0f5eb109264937f19e83d156 Mon Sep 17 00:00:00 2001 From: Jose Marchant Date: Mon, 22 Jun 2026 16:29:38 -0400 Subject: [PATCH 1/6] feat(client): support multipart/form-data uploads Thread a `files` argument through the client and add a `resource_upload` handler so managers can send multipart/form-data via httpx; JWS signing is skipped for multipart bodies. --- fintoc/client.py | 15 +++++++++++++-- fintoc/resource_handlers.py | 13 +++++++++++++ tests/conftest.py | 14 ++++++++++++-- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/fintoc/client.py b/fintoc/client.py index f173f92..e8c9248 100644 --- a/fintoc/client.py +++ b/fintoc/client.py @@ -61,7 +61,15 @@ def _build_jws_header(self, method, raw_body=None): return {} - def _build_request(self, method, url, params, headers, json=None): + def _build_request(self, method, url, params, headers, json=None, files=None): + if files is not None: + # Multipart uploads: let httpx set the multipart Content-Type and + # boundary automatically. Multipart bodies are not utf-8-safe, so + # JWS signing is skipped for these requests. + return httpx.Request( + method, url, headers=headers, params=params, files=files + ) + request = httpx.Request(method, url, headers=headers, params=params, json=json) request.headers.update( @@ -76,6 +84,7 @@ def request( method="get", params=None, json=None, + files=None, idempotency_key=None, ): """ @@ -97,7 +106,9 @@ def request( headers=headers, ) - _request = self._build_request(method, url, all_params, headers, json) + _request = self._build_request( + method, url, all_params, headers, json, files=files + ) response = self._client.send(_request) response.raise_for_status() try: diff --git a/fintoc/resource_handlers.py b/fintoc/resource_handlers.py index 1fa92df..db55ec5 100644 --- a/fintoc/resource_handlers.py +++ b/fintoc/resource_handlers.py @@ -59,6 +59,19 @@ def resource_create( ) +def resource_upload(client, path, klass, handlers, methods, files): + """Upload files to a resource endpoint through a multipart PUT request.""" + data = client.request(path, method="put", files=files) + return objetize( + klass, + client, + data, + handlers=handlers, + methods=methods, + path=path, + ) + + def resource_update( client, path, id_, klass, handlers, methods, params, custom_path=None ): diff --git a/tests/conftest.py b/tests/conftest.py index 871e660..2fdfe85 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -106,13 +106,23 @@ def send(self, request: httpx.Request, **_kwargs): **({} if request.url.params is None else request.url.params), } usable_url = request.url.path - raw_body = request.content.decode("utf-8") + # Materialize the (possibly streaming, e.g. multipart) request body, + # mirroring what the real httpx transport does before sending. + request.read() + content_type = request.headers.get("content-type", "") + if content_type.startswith("multipart/form-data"): + # Multipart bodies are not utf-8/JSON-parseable; surface the raw + # body length so tests can assert the upload was sent. + body = {"multipart": True, "content_length": len(request.content)} + else: + raw_body = request.content.decode("utf-8") + body = json.loads(raw_body) if raw_body else None return MockResponse( request.method.lower(), self.base_url, usable_url.lstrip("/"), complete_params, - json.loads(raw_body) if raw_body else None, + body, request.headers, ) From d223a491a49e7168db75460730a0b68c9d18a9b8 Mon Sep 17 00:00:00 2001 From: Jose Marchant Date: Mon, 22 Jun 2026 16:29:38 -0400 Subject: [PATCH 2/6] fix(utils): objetize float values objetize treated floats as resources and crashed on `**float`; add float to the scalar passthrough list. Surfaced by the onboarding shareholder percentage. --- fintoc/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fintoc/utils.py b/fintoc/utils.py index bcd33d5..29b0df8 100644 --- a/fintoc/utils.py +++ b/fintoc/utils.py @@ -102,7 +102,7 @@ def objetize(klass, client, data, handlers={}, methods=[], path=None): """Transform the :data: object into an object with class :klass:.""" if data is None: return None - if klass in [str, int, dict, bool, objetize_datetime]: + if klass in [str, int, float, dict, bool, objetize_datetime]: return klass(data) return klass(client, handlers, methods, path, **data) From 524b628e1060332cdf904eacfbb94cf2f185ac1b Mon Sep 17 00:00:00 2001 From: Jose Marchant Date: Mon, 22 Jun 2026 16:29:38 -0400 Subject: [PATCH 3/6] feat(v2): add onboarding resource types Onboarding, OnboardingShareholder and OnboardingDocument resources, with mappings so nested shareholders/documents objetize to the right classes. --- fintoc/resources/__init__.py | 3 +++ fintoc/resources/v2/__init__.py | 3 +++ fintoc/resources/v2/onboarding.py | 12 ++++++++++++ fintoc/resources/v2/onboarding_document.py | 7 +++++++ fintoc/resources/v2/onboarding_shareholder.py | 9 +++++++++ 5 files changed, 34 insertions(+) create mode 100644 fintoc/resources/v2/onboarding.py create mode 100644 fintoc/resources/v2/onboarding_document.py create mode 100644 fintoc/resources/v2/onboarding_shareholder.py diff --git a/fintoc/resources/__init__.py b/fintoc/resources/__init__.py index 8053539..ea9e9ef 100644 --- a/fintoc/resources/__init__.py +++ b/fintoc/resources/__init__.py @@ -31,6 +31,9 @@ from .v2.customer import Customer from .v2.entity import Entity from .v2.invoice import Invoice as InvoiceV2 +from .v2.onboarding import Onboarding +from .v2.onboarding_document import OnboardingDocument +from .v2.onboarding_shareholder import OnboardingShareholder from .v2.payment_method import PaymentMethod from .v2.subscription import Subscription as SubscriptionV2 from .v2.transfer import Transfer diff --git a/fintoc/resources/v2/__init__.py b/fintoc/resources/v2/__init__.py index f3b793e..b0e9e7e 100644 --- a/fintoc/resources/v2/__init__.py +++ b/fintoc/resources/v2/__init__.py @@ -7,6 +7,9 @@ from .entity import Entity from .invoice import Invoice from .movement import Movement +from .onboarding import Onboarding +from .onboarding_document import OnboardingDocument +from .onboarding_shareholder import OnboardingShareholder from .payment_method import PaymentMethod from .product import Product from .subscription import Subscription diff --git a/fintoc/resources/v2/onboarding.py b/fintoc/resources/v2/onboarding.py new file mode 100644 index 0000000..50f6083 --- /dev/null +++ b/fintoc/resources/v2/onboarding.py @@ -0,0 +1,12 @@ +"""Module to hold the Onboarding resource.""" + +from fintoc.mixins import ResourceMixin + + +class Onboarding(ResourceMixin): + """Represents a Fintoc Onboarding.""" + + mappings = { + "shareholders": "onboarding_shareholder", + "documents": "onboarding_document", + } diff --git a/fintoc/resources/v2/onboarding_document.py b/fintoc/resources/v2/onboarding_document.py new file mode 100644 index 0000000..7135da4 --- /dev/null +++ b/fintoc/resources/v2/onboarding_document.py @@ -0,0 +1,7 @@ +"""Module to hold the OnboardingDocument resource.""" + +from fintoc.mixins import ResourceMixin + + +class OnboardingDocument(ResourceMixin): + """Represents a Fintoc Onboarding Document.""" diff --git a/fintoc/resources/v2/onboarding_shareholder.py b/fintoc/resources/v2/onboarding_shareholder.py new file mode 100644 index 0000000..e435552 --- /dev/null +++ b/fintoc/resources/v2/onboarding_shareholder.py @@ -0,0 +1,9 @@ +"""Module to hold the OnboardingShareholder resource.""" + +from fintoc.mixins import ResourceMixin + + +class OnboardingShareholder(ResourceMixin): + """Represents a Fintoc Onboarding Shareholder.""" + + mappings = {"document": "onboarding_document"} From 78bb6aa11a34adb22644d3d9bf4f1a07ab2f77b4 Mon Sep 17 00:00:00 2001 From: Jose Marchant Date: Mon, 22 Jun 2026 16:29:38 -0400 Subject: [PATCH 4/6] feat(v2): add onboardings manager and expose entity creation Onboardings nested under entities (entities.onboardings), mirroring account_statements: list, get, create, submit, and slot + shareholder document uploads (via resource_upload). Also enables entities.create. --- fintoc/managers/v2/__init__.py | 1 + fintoc/managers/v2/entities_manager.py | 21 ++- fintoc/managers/v2/onboardings_manager.py | 65 +++++++ tests/test_integration.py | 198 ++++++++++++++++++++++ 4 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 fintoc/managers/v2/onboardings_manager.py diff --git a/fintoc/managers/v2/__init__.py b/fintoc/managers/v2/__init__.py index 2f00f97..5234f5f 100644 --- a/fintoc/managers/v2/__init__.py +++ b/fintoc/managers/v2/__init__.py @@ -9,6 +9,7 @@ from .entities_manager import EntitiesManager from .invoices_manager import InvoicesManager from .movements_manager import MovementsManager +from .onboardings_manager import OnboardingsManager from .payment_intents_manager import PaymentIntentsManager from .payment_methods_manager import PaymentMethodsManager from .products_manager import ProductsManager diff --git a/fintoc/managers/v2/entities_manager.py b/fintoc/managers/v2/entities_manager.py index eb58405..820e00a 100644 --- a/fintoc/managers/v2/entities_manager.py +++ b/fintoc/managers/v2/entities_manager.py @@ -1,5 +1,6 @@ """Module to hold the entities manager.""" +from fintoc.managers.v2.onboardings_manager import OnboardingsManager from fintoc.mixins import ManagerMixin @@ -7,4 +8,22 @@ class EntitiesManager(ManagerMixin): """Represents an entities manager.""" resource = "entity" - methods = ["list", "get"] + methods = ["list", "get", "create"] + + def __init__(self, path, client): + super().__init__(path, client) + self.__onboardings_manager = None + + @property + def onboardings(self): + """Proxies the onboardings manager.""" + if self.__onboardings_manager is None: + self.__onboardings_manager = OnboardingsManager( + "/v2/entities/{entity_id}/onboardings", + self._client, + ) + return self.__onboardings_manager + + @onboardings.setter + def onboardings(self, new_value): # pylint: disable=no-self-use + raise NameError("Attribute name corresponds to a manager") diff --git a/fintoc/managers/v2/onboardings_manager.py b/fintoc/managers/v2/onboardings_manager.py new file mode 100644 index 0000000..8e3844c --- /dev/null +++ b/fintoc/managers/v2/onboardings_manager.py @@ -0,0 +1,65 @@ +"""Module to hold the onboardings manager.""" + +import mimetypes +import os + +from fintoc.mixins import ManagerMixin +from fintoc.resource_handlers import resource_upload +from fintoc.utils import can_raise_fintoc_error, get_resource_class + + +class OnboardingsManager(ManagerMixin): + """Represents an onboardings manager.""" + + resource = "onboarding" + methods = [ + "list", + "get", + "create", + "submit", + "upload_document", + "upload_shareholder_document", + ] + + def _submit(self, identifier, **kwargs): + """Submit an onboarding for review.""" + path = f"{self._build_path(**kwargs)}/{identifier}/submit" + return self._create(path_=path, **kwargs) + + def _upload_document(self, identifier, slot_key, file, **kwargs): + """Upload a document to a slot, identified by :slot_key:.""" + path = f"{self._build_path(**kwargs)}/{identifier}/documents/{slot_key}" + return self._upload(path, file) + + def _upload_shareholder_document(self, identifier, shareholder_id, file, **kwargs): + """Upload a document for a shareholder of an onboarding.""" + path = ( + f"{self._build_path(**kwargs)}/{identifier}" + f"/shareholders/{shareholder_id}/document" + ) + return self._upload(path, file) + + @can_raise_fintoc_error + def _upload(self, path, file): + """Perform a multipart ``PUT`` upload of :file: and objetize the result.""" + klass = get_resource_class(self.__class__.resource) + files = {"file": self._build_file_payload(file)} + return resource_upload( + self._client, path, klass, self._handlers, self.__class__.methods, files + ) + + @staticmethod + def _build_file_payload(file): + """ + Build the ``(filename, fileobj, content_type)`` tuple expected by httpx + from either a path (``str`` / ``os.PathLike``) or a binary file-like + object. + """ + if isinstance(file, (str, os.PathLike)): + filename = os.path.basename(os.fspath(file)) + content_type = mimetypes.guess_type(filename)[0] + return (filename, open(file, "rb"), content_type) # noqa: SIM115 + + filename = os.path.basename(getattr(file, "name", "") or "") or None + content_type = mimetypes.guess_type(filename)[0] if filename else None + return (filename, file, content_type) diff --git a/tests/test_integration.py b/tests/test_integration.py index 99a7b37..cedd439 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -783,6 +783,20 @@ def test_v2_entity_get(self): assert entity.method == "get" assert entity.url == f"v2/entities/{entity_id}" + def test_v2_entity_create(self): + """Test creating an entity using v2 API.""" + entity_data = { + "holder_name": "Test Holder", + "holder_id": "12345678-9", + } + + entity = self.fintoc.v2.entities.create(**entity_data) + + assert entity.method == "post" + assert entity.url == "v2/entities" + assert entity.json.holder_name == entity_data["holder_name"] + assert entity.json.holder_id == entity_data["holder_id"] + def test_v2_transfers_list(self): """Test getting all transfers using v2 API.""" account_id = "test_account_id" @@ -1155,6 +1169,190 @@ def test_v2_account_statements_list(self): account_statement.url == f"v2/accounts/{account_id}/account_statements" ) + def test_v2_entity_onboardings_list(self): + """Test getting onboardings from an entity using v2 API.""" + entity_id = "ent_12345" + onboardings = list( + self.fintoc.v2.entities.onboardings.list(entity_id=entity_id) + ) + + assert len(onboardings) > 0 + for onboarding in onboardings: + assert onboarding.method == "get" + assert onboarding.url == f"v2/entities/{entity_id}/onboardings" + + def test_v2_entity_onboarding_get(self): + """Test getting a specific onboarding from an entity using v2 API.""" + entity_id = "ent_12345" + onboarding_id = "onbprc_12345" + + onboarding = self.fintoc.v2.entities.onboardings.get( + onboarding_id, entity_id=entity_id + ) + + assert onboarding.method == "get" + assert onboarding.url == f"v2/entities/{entity_id}/onboardings/{onboarding_id}" + + def test_v2_entity_onboarding_create(self): + """Test creating an onboarding for an entity using v2 API.""" + entity_id = "ent_12345" + company_information = {"legal_name": "Acme SpA"} + legal_representative = {"first_name": "Ada", "last_name": "Lovelace"} + transactional_profile = {"monthly_amount_range": "0-1000"} + shareholders = [ + { + "type": "natural_person", + "name": "Ada", + "last_name": "Lovelace", + "holder_id": "1-9", + "nationality": "CL", + "percentage": 100, + } + ] + + onboarding = self.fintoc.v2.entities.onboardings.create( + entity_id=entity_id, + company_information=company_information, + legal_representative=legal_representative, + transactional_profile=transactional_profile, + shareholders=shareholders, + ) + + assert onboarding.method == "post" + assert onboarding.url == f"v2/entities/{entity_id}/onboardings" + assert ( + onboarding.json.company_information.legal_name + == company_information["legal_name"] + ) + assert ( + onboarding.json.legal_representative.first_name + == legal_representative["first_name"] + ) + assert ( + onboarding.json.transactional_profile.monthly_amount_range + == transactional_profile["monthly_amount_range"] + ) + assert onboarding.json.shareholders[0].name == shareholders[0]["name"] + + def test_v2_entity_onboarding_submit(self): + """Test submitting an onboarding for an entity using v2 API.""" + entity_id = "ent_12345" + onboarding_id = "onbprc_12345" + + result = self.fintoc.v2.entities.onboardings.submit( + onboarding_id, entity_id=entity_id + ) + + assert result.method == "post" + assert ( + result.url == f"v2/entities/{entity_id}/onboardings/{onboarding_id}/submit" + ) + + def test_v2_entity_onboarding_upload_document(self, tmp_path): + """Test uploading a step document for an onboarding using v2 API.""" + entity_id = "ent_12345" + onboarding_id = "onbprc_12345" + slot_key = "company_constitution" + file_path = tmp_path / "document.pdf" + file_path.write_bytes(b"%PDF-1.4 test file") + + result = self.fintoc.v2.entities.onboardings.upload_document( + onboarding_id, slot_key, str(file_path), entity_id=entity_id + ) + + assert result.method == "put" + assert result.url == ( + f"v2/entities/{entity_id}/onboardings/{onboarding_id}" + f"/documents/{slot_key}" + ) + assert getattr(result.headers, "content-type").startswith("multipart/form-data") + assert result.json.multipart is True + + def test_v2_entity_onboarding_upload_document_file_like(self, tmp_path): + """Test uploading a step document from a file-like object.""" + entity_id = "ent_12345" + onboarding_id = "onbprc_12345" + slot_key = "company_constitution" + file_path = tmp_path / "document.pdf" + file_path.write_bytes(b"%PDF-1.4 test file") + + with open(file_path, "rb") as file_obj: + result = self.fintoc.v2.entities.onboardings.upload_document( + onboarding_id, slot_key, file_obj, entity_id=entity_id + ) + + assert result.method == "put" + assert getattr(result.headers, "content-type").startswith("multipart/form-data") + + def test_v2_entity_onboarding_upload_shareholder_document(self, tmp_path): + """Test uploading a shareholder document for an onboarding using v2 API.""" + entity_id = "ent_12345" + onboarding_id = "onbprc_12345" + shareholder_id = "onbsh_12345" + file_path = tmp_path / "id.png" + file_path.write_bytes(b"\x89PNG test file") + + result = self.fintoc.v2.entities.onboardings.upload_shareholder_document( + onboarding_id, shareholder_id, str(file_path), entity_id=entity_id + ) + + assert result.method == "put" + assert result.url == ( + f"v2/entities/{entity_id}/onboardings/{onboarding_id}" + f"/shareholders/{shareholder_id}/document" + ) + assert getattr(result.headers, "content-type").startswith("multipart/form-data") + + def test_v2_onboarding_objetization(self): + """Test that a full onboarding payload is objetized with nested resources.""" + from fintoc.resources.v2.onboarding import Onboarding + from fintoc.resources.v2.onboarding_document import OnboardingDocument + from fintoc.resources.v2.onboarding_shareholder import OnboardingShareholder + from fintoc.utils import objetize + + payload = { + "id": "onbprc_12345", + "object": "onboarding", + "entity_id": "ent_12345", + "status": "in_progress", + "source": "api", + "submitted_at": None, + "reviewed_at": None, + "submittable": True, + "data": {"company_information": {"legal_name": "Acme SpA"}}, + "shareholders": [ + { + "id": "onbsh_12345", + "object": "onboarding_shareholder", + "type": "natural_person", + "name": "Ada", + "last_name": "Lovelace", + "percentage": 100, + "holder_id": "1-9", + "parent_id": None, + "document": { + "slot_key": "identity", + "status": "uploaded", + "filename": "id.png", + }, + } + ], + "documents": [{"slot_key": "company_constitution", "status": "missing"}], + } + + onboarding = objetize(Onboarding, self.fintoc._client, payload) + + assert isinstance(onboarding, Onboarding) + assert onboarding.object == "onboarding" + assert onboarding.status == "in_progress" + assert onboarding.submittable is True + assert isinstance(onboarding.shareholders[0], OnboardingShareholder) + assert onboarding.shareholders[0].name == "Ada" + assert isinstance(onboarding.shareholders[0].document, OnboardingDocument) + assert onboarding.shareholders[0].document.slot_key == "identity" + assert isinstance(onboarding.documents[0], OnboardingDocument) + assert onboarding.documents[0].status == "missing" + if __name__ == "__main__": pytest.main() From 10e3c00f414dd95db2ebd26a9a95f3ca313e5460 Mon Sep 17 00:00:00 2001 From: Jose Marchant Date: Mon, 22 Jun 2026 17:18:11 -0400 Subject: [PATCH 5/6] fixup! feat(client): support multipart/form-data uploads --- fintoc/mixins/manager_mixin.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/fintoc/mixins/manager_mixin.py b/fintoc/mixins/manager_mixin.py index 1d7b076..2f4757e 100644 --- a/fintoc/mixins/manager_mixin.py +++ b/fintoc/mixins/manager_mixin.py @@ -8,6 +8,7 @@ resource_get, resource_list, resource_update, + resource_upload, ) from fintoc.utils import can_raise_fintoc_error, deprecate, get_resource_class @@ -117,6 +118,17 @@ def _create(self, idempotency_key=None, path_=None, **kwargs): ) return self.post_create_handler(object_, **kwargs) + @can_raise_fintoc_error + def _upload(self, path, files): + """ + Upload :files: to :path: through a multipart PUT and objetize the + result. :files: is a dict in the format expected by httpx. + """ + klass = get_resource_class(self.__class__.resource) + return resource_upload( + self._client, path, klass, self._handlers, self.__class__.methods, files + ) + @can_raise_fintoc_error def _update(self, identifier, path_=None, **kwargs): """ From c0e820b7486c0a17b2b7a1d463b4ed65336e5774 Mon Sep 17 00:00:00 2001 From: Jose Marchant Date: Mon, 22 Jun 2026 17:18:11 -0400 Subject: [PATCH 6/6] fixup! feat(v2): add onboardings manager and expose entity creation --- fintoc/managers/v2/onboardings_manager.py | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/fintoc/managers/v2/onboardings_manager.py b/fintoc/managers/v2/onboardings_manager.py index 8e3844c..ce2f3c2 100644 --- a/fintoc/managers/v2/onboardings_manager.py +++ b/fintoc/managers/v2/onboardings_manager.py @@ -4,8 +4,6 @@ import os from fintoc.mixins import ManagerMixin -from fintoc.resource_handlers import resource_upload -from fintoc.utils import can_raise_fintoc_error, get_resource_class class OnboardingsManager(ManagerMixin): @@ -29,7 +27,7 @@ def _submit(self, identifier, **kwargs): def _upload_document(self, identifier, slot_key, file, **kwargs): """Upload a document to a slot, identified by :slot_key:.""" path = f"{self._build_path(**kwargs)}/{identifier}/documents/{slot_key}" - return self._upload(path, file) + return self._upload(path, {"file": self._build_file_payload(file)}) def _upload_shareholder_document(self, identifier, shareholder_id, file, **kwargs): """Upload a document for a shareholder of an onboarding.""" @@ -37,16 +35,7 @@ def _upload_shareholder_document(self, identifier, shareholder_id, file, **kwarg f"{self._build_path(**kwargs)}/{identifier}" f"/shareholders/{shareholder_id}/document" ) - return self._upload(path, file) - - @can_raise_fintoc_error - def _upload(self, path, file): - """Perform a multipart ``PUT`` upload of :file: and objetize the result.""" - klass = get_resource_class(self.__class__.resource) - files = {"file": self._build_file_payload(file)} - return resource_upload( - self._client, path, klass, self._handlers, self.__class__.methods, files - ) + return self._upload(path, {"file": self._build_file_payload(file)}) @staticmethod def _build_file_payload(file):