From 8aecf6f164974fb4cd4a39afc0ba02139b7e9c5c Mon Sep 17 00:00:00 2001 From: FranciscaOtero Date: Wed, 28 Oct 2026 12:55:23 -0300 Subject: [PATCH 1/8] feat: add generic request and multipart form support to managers Co-Authored-By: Claude Opus 5.5 --- fintoc/client.py | 11 +++++--- fintoc/mixins/manager_mixin.py | 38 ++++++++++++++++++++++---- fintoc/resource_handlers.py | 50 ++++++++++++++++++++++++++++++++-- 3 files changed, 86 insertions(+), 13 deletions(-) diff --git a/fintoc/client.py b/fintoc/client.py index e8c9248..ff27471 100644 --- a/fintoc/client.py +++ b/fintoc/client.py @@ -61,13 +61,15 @@ def _build_jws_header(self, method, raw_body=None): return {} - def _build_request(self, method, url, params, headers, json=None, files=None): + def _build_request( + self, method, url, params, headers, json=None, files=None, data=None + ): # pylint: disable=too-many-arguments 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 + method, url, headers=headers, params=params, files=files, data=data ) request = httpx.Request(method, url, headers=headers, params=params, json=json) @@ -85,8 +87,9 @@ def request( params=None, json=None, files=None, + data=None, idempotency_key=None, - ): + ): # pylint: disable=too-many-arguments """ Uses the internal httpx client to make a simple or paginated request. """ @@ -107,7 +110,7 @@ def request( ) _request = self._build_request( - method, url, all_params, headers, json, files=files + method, url, all_params, headers, json, files=files, data=data ) response = self._client.send(_request) response.raise_for_status() diff --git a/fintoc/mixins/manager_mixin.py b/fintoc/mixins/manager_mixin.py index 2f4757e..510e8a5 100644 --- a/fintoc/mixins/manager_mixin.py +++ b/fintoc/mixins/manager_mixin.py @@ -7,6 +7,7 @@ resource_delete, resource_get, resource_list, + resource_request, resource_update, resource_upload, ) @@ -119,14 +120,39 @@ 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. - """ + def _upload(self, path, files, method="put", data=None, idempotency_key=None): + """Upload :files: to :path: as multipart and objetize the result.""" klass = get_resource_class(self.__class__.resource) return resource_upload( - self._client, path, klass, self._handlers, self.__class__.methods, files + self._client, + path, + klass, + self._handlers, + self.__class__.methods, + files, + method=method, + data=data, + idempotency_key=idempotency_key, + ) + + @can_raise_fintoc_error + def _request_resource( + self, method, path, resource=None, idempotency_key=None, **kwargs + ): + """Make a request to :path: and objetize the result.""" + klass = get_resource_class(resource or self.__class__.resource) + methods = self.__class__.methods if resource is None else [] + handlers = self._handlers if resource is None else {} + return resource_request( + client=self._client, + path=path, + klass=klass, + handlers=handlers, + methods=methods, + method=method, + params=kwargs, + resource_path=self._build_path(**kwargs), + idempotency_key=idempotency_key, ) @can_raise_fintoc_error diff --git a/fintoc/resource_handlers.py b/fintoc/resource_handlers.py index db55ec5..9136c85 100644 --- a/fintoc/resource_handlers.py +++ b/fintoc/resource_handlers.py @@ -59,9 +59,25 @@ 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) +def resource_upload( + client, + path, + klass, + handlers, + methods, + files, + method="put", + data=None, + idempotency_key=None, +): # pylint: disable=too-many-arguments + """Upload files to a resource endpoint through a multipart request.""" + data = client.request( + path, + method=method, + files=files, + data=data, + idempotency_key=idempotency_key, + ) return objetize( klass, client, @@ -72,6 +88,34 @@ def resource_upload(client, path, klass, handlers, methods, files): ) +def resource_request( + client, + path, + klass, + handlers, + methods, + method, + params, + resource_path=None, + idempotency_key=None, +): # pylint: disable=too-many-arguments + """Make a request with an arbitrary :method: and objetize the response.""" + if method.lower() in ["get", "delete"]: + data = client.request(path, method=method, params=params) + else: + data = client.request( + path, method=method, json=params, idempotency_key=idempotency_key + ) + return objetize( + klass, + client, + data, + handlers=handlers, + methods=methods, + path=resource_path or path, + ) + + def resource_update( client, path, id_, klass, handlers, methods, params, custom_path=None ): From 8095c89fa16c34b57c2d118f49d74181c114d7d6 Mon Sep 17 00:00:00 2001 From: FranciscaOtero Date: Wed, 28 Oct 2026 12:56:49 -0300 Subject: [PATCH 2/8] feat(v1): add missing public API operations Adds charges update and cancel, institutions list, invoices get, link intents create, links exchange, payouts list, get and resources, refunds voucher URL and tax statements list and get. Co-Authored-By: Claude Opus 5.5 --- fintoc/core.py | 8 + fintoc/managers/__init__.py | 5 + fintoc/managers/charges_manager.py | 10 +- fintoc/managers/institutions_manager.py | 11 + fintoc/managers/invoices_manager.py | 2 +- fintoc/managers/link_intents_manager.py | 11 + fintoc/managers/links_manager.py | 10 +- fintoc/managers/payout_resources_manager.py | 35 +++ fintoc/managers/payouts_manager.py | 31 +++ fintoc/managers/refunds_manager.py | 7 +- fintoc/managers/tax_statements_manager.py | 11 + fintoc/resources/__init__.py | 5 + fintoc/resources/link.py | 15 ++ fintoc/resources/link_intent.py | 8 + fintoc/resources/payout.py | 8 + fintoc/resources/tax_statement.py | 8 + fintoc/resources/voucher_url.py | 8 + tests/test_integration.py | 235 ++++++++++++++++++++ 18 files changed, 424 insertions(+), 4 deletions(-) create mode 100644 fintoc/managers/institutions_manager.py create mode 100644 fintoc/managers/link_intents_manager.py create mode 100644 fintoc/managers/payout_resources_manager.py create mode 100644 fintoc/managers/payouts_manager.py create mode 100644 fintoc/managers/tax_statements_manager.py create mode 100644 fintoc/resources/link_intent.py create mode 100644 fintoc/resources/payout.py create mode 100644 fintoc/resources/tax_statement.py create mode 100644 fintoc/resources/voucher_url.py diff --git a/fintoc/core.py b/fintoc/core.py index c36631f..a59ef50 100644 --- a/fintoc/core.py +++ b/fintoc/core.py @@ -9,15 +9,19 @@ ChargesManager, CheckoutSessionsManager, DisputesManager, + InstitutionsManager, InvoicesManager, + LinkIntentsManager, LinksManager, PaymentIntentsManager, PaymentLinksManager, + PayoutsManager, RefreshIntentsManager, RefundsManager, SubscriptionIntentsManager, SubscriptionsManager, TaxReturnsManager, + TaxStatementsManager, WebhookEndpointsManager, ) from fintoc.managers.v2 import AccountNumbersManager @@ -75,6 +79,10 @@ def __init__(self, api_key, api_version=None, jws_private_key=None): self.tax_returns = TaxReturnsManager("/v1/tax_returns", self._client) self.invoices = InvoicesManager("/v1/invoices", self._client) self.disputes = DisputesManager("/v1/disputes", self._client) + self.institutions = InstitutionsManager("/v1/institutions", self._client) + self.link_intents = LinkIntentsManager("/v1/link_intents", self._client) + self.payouts = PayoutsManager("/v1/payouts", self._client) + self.tax_statements = TaxStatementsManager("/v1/tax_statements", self._client) self.v2 = _FintocV2(self._client) diff --git a/fintoc/managers/__init__.py b/fintoc/managers/__init__.py index 44e3397..3b61a48 100644 --- a/fintoc/managers/__init__.py +++ b/fintoc/managers/__init__.py @@ -5,14 +5,19 @@ from .checkout_sessions_manager import CheckoutSessionsManager from .dispute_documents_manager import DisputeDocumentsManager from .disputes_manager import DisputesManager +from .institutions_manager import InstitutionsManager from .invoices_manager import InvoicesManager +from .link_intents_manager import LinkIntentsManager from .links_manager import LinksManager from .movements_manager import MovementsManager from .payment_intents_manager import PaymentIntentsManager from .payment_links_manager import PaymentLinksManager +from .payout_resources_manager import PayoutResourcesManager +from .payouts_manager import PayoutsManager from .refresh_intents_manager import RefreshIntentsManager from .refunds_manager import RefundsManager from .subscription_intents_manager import SubscriptionIntentsManager from .subscriptions_manager import SubscriptionsManager from .tax_returns_manager import TaxReturnsManager +from .tax_statements_manager import TaxStatementsManager from .webhook_endpoints_manager import WebhookEndpointsManager diff --git a/fintoc/managers/charges_manager.py b/fintoc/managers/charges_manager.py index 4f5a653..63d8f0c 100644 --- a/fintoc/managers/charges_manager.py +++ b/fintoc/managers/charges_manager.py @@ -8,4 +8,12 @@ class ChargesManager(ManagerMixin): """Represents a charges manager.""" resource = "charge" - methods = ["list", "get", "create"] + methods = ["list", "get", "create", "update", "cancel"] + + def _cancel(self, identifier, **kwargs): + """Cancel a charge.""" + path = f"{self._build_path(**kwargs)}/{identifier}/cancel" + charge = self._create(path_=path, **kwargs) + # pylint: disable=protected-access + charge._path = self._build_path(**kwargs) + return charge diff --git a/fintoc/managers/institutions_manager.py b/fintoc/managers/institutions_manager.py new file mode 100644 index 0000000..62d90e1 --- /dev/null +++ b/fintoc/managers/institutions_manager.py @@ -0,0 +1,11 @@ +"""Module to hold the institutions manager.""" + +from fintoc.mixins import ManagerMixin + + +class InstitutionsManager(ManagerMixin): + + """Represents an institutions manager.""" + + resource = "institution" + methods = ["list"] diff --git a/fintoc/managers/invoices_manager.py b/fintoc/managers/invoices_manager.py index 1f0da36..2a57f2d 100644 --- a/fintoc/managers/invoices_manager.py +++ b/fintoc/managers/invoices_manager.py @@ -8,4 +8,4 @@ class InvoicesManager(ManagerMixin): """Represents an invoices manager.""" resource = "invoice" - methods = ["list"] + methods = ["list", "get"] diff --git a/fintoc/managers/link_intents_manager.py b/fintoc/managers/link_intents_manager.py new file mode 100644 index 0000000..9037ad6 --- /dev/null +++ b/fintoc/managers/link_intents_manager.py @@ -0,0 +1,11 @@ +"""Module to hold the link intents manager.""" + +from fintoc.mixins import ManagerMixin + + +class LinkIntentsManager(ManagerMixin): + + """Represents a link intents manager.""" + + resource = "link_intent" + methods = ["create"] diff --git a/fintoc/managers/links_manager.py b/fintoc/managers/links_manager.py index 6499e63..8b683a3 100644 --- a/fintoc/managers/links_manager.py +++ b/fintoc/managers/links_manager.py @@ -8,7 +8,15 @@ class LinksManager(ManagerMixin): """Represents a links manager.""" resource = "link" - methods = ["list", "get", "update", "delete"] + methods = ["list", "get", "update", "delete", "exchange"] + + def _exchange(self, exchange_token, **kwargs): + """Exchange the widget's :exchange_token: for its link.""" + path = f"{self._build_path(**kwargs)}/exchange" + link = self._request_resource( + "get", path, exchange_token=exchange_token, **kwargs + ) + return self.post_get_handler(link, link.link_token) def post_get_handler(self, object_, identifier, **kwargs): # pylint: disable=protected-access diff --git a/fintoc/managers/payout_resources_manager.py b/fintoc/managers/payout_resources_manager.py new file mode 100644 index 0000000..efdca05 --- /dev/null +++ b/fintoc/managers/payout_resources_manager.py @@ -0,0 +1,35 @@ +"""Module to hold the payout resources manager.""" + +from fintoc.mixins import ManagerMixin +from fintoc.resource_handlers import resource_list +from fintoc.utils import can_raise_fintoc_error, get_resource_class + + +class PayoutResourcesManager(ManagerMixin): + + """Represents a payout resources manager.""" + + resource = "generic_fintoc_resource" + methods = ["list"] + + resource_types = { + "payment_intent": "payment_intent", + "charge": "charge", + "refund": "refund", + "refund_adjustment": "refund", + } + + @can_raise_fintoc_error + def _list(self, **kwargs): + """List the resources of a payout of the given ``resource_type``.""" + resource = self.resource_types.get( + kwargs.get("resource_type"), self.__class__.resource + ) + return resource_list( + client=self._client, + path=self._build_path(**kwargs), + klass=get_resource_class(resource), + handlers={}, + methods=[], + params=kwargs, + ) diff --git a/fintoc/managers/payouts_manager.py b/fintoc/managers/payouts_manager.py new file mode 100644 index 0000000..3ff2ec2 --- /dev/null +++ b/fintoc/managers/payouts_manager.py @@ -0,0 +1,31 @@ +"""Module to hold the payouts manager.""" + +from fintoc.managers.payout_resources_manager import PayoutResourcesManager +from fintoc.mixins import ManagerMixin + + +# pylint: disable=duplicate-code +class PayoutsManager(ManagerMixin): + + """Represents a payouts manager.""" + + resource = "payout" + methods = ["list", "get"] + + def __init__(self, path, client): + super().__init__(path, client) + self.__resources_manager = None + + @property + def resources(self): + """Proxies the payout resources manager.""" + if self.__resources_manager is None: + self.__resources_manager = PayoutResourcesManager( + "/v1/payouts/{payout_id}/resources", + self._client, + ) + return self.__resources_manager + + @resources.setter + def resources(self, new_value): # pylint: disable=no-self-use + raise NameError("Attribute name corresponds to a manager") diff --git a/fintoc/managers/refunds_manager.py b/fintoc/managers/refunds_manager.py index 3a584c5..5298619 100644 --- a/fintoc/managers/refunds_manager.py +++ b/fintoc/managers/refunds_manager.py @@ -8,9 +8,14 @@ class RefundsManager(ManagerMixin): """Represents a refunds manager.""" resource = "refund" - methods = ["list", "get", "create", "cancel"] + methods = ["list", "get", "create", "cancel", "voucher_url"] def _cancel(self, identifier, **kwargs): """Expire a refund.""" path = f"{self._build_path(**kwargs)}/{identifier}/cancel" return self._create(path_=path, **kwargs) + + def _voucher_url(self, identifier, **kwargs): + """Get a presigned URL to download the PDF voucher of a refund.""" + path = f"{self._build_path(**kwargs)}/{identifier}/voucher_url" + return self._request_resource("get", path, resource="voucher_url", **kwargs) diff --git a/fintoc/managers/tax_statements_manager.py b/fintoc/managers/tax_statements_manager.py new file mode 100644 index 0000000..f050420 --- /dev/null +++ b/fintoc/managers/tax_statements_manager.py @@ -0,0 +1,11 @@ +"""Module to hold the tax_statements manager.""" + +from fintoc.mixins import ManagerMixin + + +class TaxStatementsManager(ManagerMixin): + + """Represents a tax_statements manager.""" + + resource = "tax_statement" + methods = ["list", "get"] diff --git a/fintoc/resources/__init__.py b/fintoc/resources/__init__.py index 0178dce..8f0396c 100644 --- a/fintoc/resources/__init__.py +++ b/fintoc/resources/__init__.py @@ -13,15 +13,19 @@ from .institution_tax_return import InstitutionTaxReturn from .invoice import Invoice from .link import Link +from .link_intent import LinkIntent from .movement import Movement from .other_taxes import OtherTaxes from .payment_intent import PaymentIntent from .payment_link import PaymentLink +from .payout import Payout from .refresh_intent import RefreshIntent +from .refund import Refund from .services_invoice import ServicesInvoice from .subscription import Subscription from .subscription_intent import SubscriptionIntent from .tax_return import TaxReturn +from .tax_statement import TaxStatement from .taxpayer import Taxpayer from .tobacco_taxes import TobaccoTaxes from .transfer_account import TransferAccount @@ -42,4 +46,5 @@ from .v2.subscription_item import SubscriptionItem from .v2.transfer import Transfer from .v2.transfer_batch import TransferBatch +from .voucher_url import VoucherUrl from .webhook_endpoint import WebhookEndpoint diff --git a/fintoc/resources/link.py b/fintoc/resources/link.py index aac8578..d015e14 100644 --- a/fintoc/resources/link.py +++ b/fintoc/resources/link.py @@ -6,6 +6,7 @@ RefreshIntentsManager, SubscriptionsManager, TaxReturnsManager, + TaxStatementsManager, ) from fintoc.mixins import ResourceMixin @@ -23,6 +24,7 @@ def __init__(self, client, handlers, methods, path, **kwargs): self.__tax_returns_manager = None self.__invoices_manager = None self.__refresh_intents_manager = None + self.__tax_statements_manager = None @property def accounts(self): @@ -86,3 +88,16 @@ def refresh_intents(self): @refresh_intents.setter def refresh_intents(self, new_value): # pylint: disable=no-self-use raise NameError("Attribute name corresponds to a manager") + + @property + def tax_statements(self): + """Proxies the tax_statements manager.""" + if self.__tax_statements_manager is None: + self.__tax_statements_manager = TaxStatementsManager( + "/v1/tax_statements", self._client + ) + return self.__tax_statements_manager + + @tax_statements.setter + def tax_statements(self, new_value): # pylint: disable=no-self-use + raise NameError("Attribute name corresponds to a manager") diff --git a/fintoc/resources/link_intent.py b/fintoc/resources/link_intent.py new file mode 100644 index 0000000..5015708 --- /dev/null +++ b/fintoc/resources/link_intent.py @@ -0,0 +1,8 @@ +"""Module to hold the Link Intent resource.""" + +from fintoc.mixins import ResourceMixin + + +class LinkIntent(ResourceMixin): + + """Represents a Fintoc Link Intent.""" diff --git a/fintoc/resources/payout.py b/fintoc/resources/payout.py new file mode 100644 index 0000000..2687a89 --- /dev/null +++ b/fintoc/resources/payout.py @@ -0,0 +1,8 @@ +"""Module to hold the Payout resource.""" + +from fintoc.mixins import ResourceMixin + + +class Payout(ResourceMixin): + + """Represents a Fintoc Payout.""" diff --git a/fintoc/resources/tax_statement.py b/fintoc/resources/tax_statement.py new file mode 100644 index 0000000..ad76e9f --- /dev/null +++ b/fintoc/resources/tax_statement.py @@ -0,0 +1,8 @@ +"""Module to hold the Tax Statement resource.""" + +from fintoc.mixins import ResourceMixin + + +class TaxStatement(ResourceMixin): + + """Represents a Fintoc Tax Statement.""" diff --git a/fintoc/resources/voucher_url.py b/fintoc/resources/voucher_url.py new file mode 100644 index 0000000..54f2070 --- /dev/null +++ b/fintoc/resources/voucher_url.py @@ -0,0 +1,8 @@ +"""Module to hold the Voucher URL resource.""" + +from fintoc.mixins import ResourceMixin + + +class VoucherUrl(ResourceMixin): + + """Represents a Fintoc Voucher URL.""" diff --git a/tests/test_integration.py b/tests/test_integration.py index 3bebbfd..f39f683 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,8 +1,22 @@ """Integration tests for the Fintoc core object.""" +import httpx import pytest +from fintoc.client import Client from fintoc.core import Fintoc +from fintoc.resources import ( + Charge, + Institution, + Invoice, + Link, + LinkIntent, + PaymentIntent, + Payout, + Refund, + TaxStatement, + VoucherUrl, +) class TestFintocIntegration: @@ -1586,6 +1600,227 @@ def test_v2_onboarding_objetization(self): assert isinstance(onboarding.documents[0], OnboardingDocument) assert onboarding.documents[0].status == "missing" + def test_charge_update(self): + """Test updating a charge.""" + charge_id = "test_charge_id" + + charge = self.fintoc.charges.update(charge_id, amount=2000, currency="CLP") + + assert isinstance(charge, Charge) + assert charge.method == "patch" + assert charge.url == f"v1/charges/{charge_id}" + assert charge.json.amount == 2000 + assert charge.json.currency == "CLP" + + def test_charge_cancel(self): + """Test canceling a charge, and updating the canceled charge.""" + charge_id = "test_charge_id" + + charge = self.fintoc.charges.cancel(charge_id) + + assert isinstance(charge, Charge) + assert charge.method == "post" + assert charge.url == f"v1/charges/{charge_id}/cancel" + + # The resulting charge keeps the charges collection path + charge.update(amount=3000) + assert charge.method == "patch" + assert charge.url == f"v1/charges/{charge.id}" + + def test_institutions_list(self): + """Test listing institutions.""" + institutions = list( + self.fintoc.institutions.list(country="cl", product="movements") + ) + + assert len(institutions) > 0 + for institution in institutions: + assert isinstance(institution, Institution) + assert institution.method == "get" + assert institution.url == "v1/institutions" + assert institution.params.country == "cl" + assert institution.params.product == "movements" + + def test_link_invoice_get(self): + """Test getting a specific invoice from a link.""" + + def assert_invoice(invoice): + assert isinstance(invoice, Invoice) + assert invoice.method == "get" + assert invoice.url == f"v1/invoices/{invoice_id}" + assert invoice.params.link_token == link_token + + link_token = "test_link_token" + invoice_id = "test_invoice_id" + + # Test using the resource + link = self.fintoc.links.get(link_token) + assert_invoice(link.invoices.get(invoice_id)) + + # Test using directly the manager + assert_invoice(self.fintoc.invoices.get(invoice_id, link_token=link_token)) + + def test_link_intent_create(self): + """Test creating a link intent.""" + data = {"country": "cl", "holder_type": "business", "product": "movements"} + + link_intent = self.fintoc.link_intents.create(**data) + + assert isinstance(link_intent, LinkIntent) + assert link_intent.method == "post" + assert link_intent.url == "v1/link_intents" + assert link_intent.json.country == data["country"] + assert link_intent.json.holder_type == data["holder_type"] + assert link_intent.json.product == data["product"] + + def test_payouts_list(self): + """Test listing payouts.""" + payouts = list(self.fintoc.payouts.list(status="succeeded")) + + assert len(payouts) > 0 + for payout in payouts: + assert isinstance(payout, Payout) + assert payout.method == "get" + assert payout.url == "v1/payouts" + assert payout.params.status == "succeeded" + + def test_payout_get(self): + """Test getting a specific payout.""" + payout_id = "test_payout_id" + + payout = self.fintoc.payouts.get(payout_id) + + assert isinstance(payout, Payout) + assert payout.method == "get" + assert payout.url == f"v1/payouts/{payout_id}" + + @pytest.mark.parametrize( + "resource_type,klass", + [ + ("payment_intent", PaymentIntent), + ("charge", Charge), + ("refund", Refund), + ("refund_adjustment", Refund), + ], + ) + def test_payout_resources_list(self, resource_type, klass): + """Test listing the resources of a payout, by resource type.""" + payout_id = "test_payout_id" + + resources = list( + self.fintoc.payouts.resources.list( + payout_id=payout_id, resource_type=resource_type + ) + ) + + assert len(resources) > 0 + for resource in resources: + assert isinstance(resource, klass) + assert resource.method == "get" + assert resource.url == f"v1/payouts/{payout_id}/resources" + assert resource.params.resource_type == resource_type + + def test_refund_voucher_url(self): + """Test getting the voucher URL of a refund.""" + refund_id = "test_refund_id" + + voucher_url = self.fintoc.refunds.voucher_url(refund_id) + + assert isinstance(voucher_url, VoucherUrl) + assert voucher_url.method == "get" + assert voucher_url.url == f"v1/refunds/{refund_id}/voucher_url" + + def test_link_tax_statements_list(self): + """Test listing tax statements from a link.""" + + def assert_tax_statements(tax_statements): + assert len(tax_statements) > 0 + for tax_statement in tax_statements: + assert isinstance(tax_statement, TaxStatement) + assert tax_statement.method == "get" + assert tax_statement.url == "v1/tax_statements" + assert tax_statement.params.link_token == link_token + + link_token = "test_link_token" + + # Test using the resource + link = self.fintoc.links.get(link_token) + assert_tax_statements(list(link.tax_statements.list())) + + # Test using directly the manager + assert_tax_statements( + list(self.fintoc.tax_statements.list(link_token=link_token)) + ) + + def test_link_tax_statement_get(self): + """Test getting a specific tax statement from a link.""" + + def assert_tax_statement(tax_statement): + assert isinstance(tax_statement, TaxStatement) + assert tax_statement.method == "get" + assert tax_statement.url == f"v1/tax_statements/{tax_statement_id}" + assert tax_statement.params.link_token == link_token + + link_token = "test_link_token" + tax_statement_id = "test_tax_statement_id" + + # Test using the resource + link = self.fintoc.links.get(link_token) + assert_tax_statement(link.tax_statements.get(tax_statement_id)) + + # Test using directly the manager + assert_tax_statement( + self.fintoc.tax_statements.get(tax_statement_id, link_token=link_token) + ) + + +class TestFintocCustomResponses: + """Tests for operations whose responses the shared HTTP mock can't represent.""" + + @pytest.fixture(autouse=True) + def mock_transport(self, monkeypatch): + """Record the requests sent and answer them with :self.response:.""" + self.requests = [] + self.response = {} + + def handler(request): + request.read() + self.requests.append(request) + return httpx.Response(200, json=self.response) + + monkeypatch.setattr( + Client, "_client", httpx.Client(transport=httpx.MockTransport(handler)) + ) + + def setup_method(self): + """Set up the test environment.""" + self.fintoc = Fintoc("test_api_key") + + def test_links_exchange(self): + """Test exchanging a link, and using its link token afterwards.""" + self.response = {"id": "link_12345", "link_token": "link_12345_token_abc"} + + link = self.fintoc.links.exchange("exchange_token_123") + + request = self.requests[-1] + assert request.method == "GET" + assert request.url.path == "/v1/links/exchange" + assert request.url.params["exchange_token"] == "exchange_token_123" + assert isinstance(link, Link) + assert link.link_token == "link_12345_token_abc" + + self.response = [] + list(link.accounts.list()) + request = self.requests[-1] + assert request.url.path == "/v1/accounts" + assert request.url.params["link_token"] == "link_12345_token_abc" + + self.response = {"id": "link_12345"} + link.update(active=False) + request = self.requests[-1] + assert request.method == "PATCH" + assert request.url.path == "/v1/links/link_12345_token_abc" + if __name__ == "__main__": pytest.main() From 4161d3f03c35e380a21cb2d5fb60cb3216da7944 Mon Sep 17 00:00:00 2001 From: FranciscaOtero Date: Wed, 28 Oct 2026 12:57:08 -0300 Subject: [PATCH 3/8] feat(v2): add missing public API operations Adds events list and get, entities settlement account, onboarding append and delete document, invoices create, update, finalize, mark_uncollectible, pay and void, payment intents check eligibility, cancel and voucher URL, products update, setup intents get, subscriptions detach payment method and transfers receipt. Moves the multipart file payload builder to utils so other managers can reuse it. Co-Authored-By: Claude Opus 5.5 --- fintoc/core.py | 5 +- fintoc/managers/v2/__init__.py | 2 + fintoc/managers/v2/entities_manager.py | 9 +- fintoc/managers/v2/events_manager.py | 10 + fintoc/managers/v2/invoices_manager.py | 36 ++- fintoc/managers/v2/onboardings_manager.py | 32 ++- fintoc/managers/v2/payment_intents_manager.py | 24 +- fintoc/managers/v2/products_manager.py | 2 +- fintoc/managers/v2/setup_intents_manager.py | 10 + fintoc/managers/v2/subscriptions_manager.py | 15 +- fintoc/managers/v2/transfers_manager.py | 9 +- fintoc/resources/__init__.py | 6 + fintoc/resources/v2/__init__.py | 5 + fintoc/resources/v2/event.py | 7 + fintoc/resources/v2/payment_eligibility.py | 7 + fintoc/resources/v2/settlement_account.py | 7 + fintoc/resources/v2/setup_intent.py | 7 + fintoc/resources/v2/transfer_receipt.py | 7 + fintoc/utils.py | 18 ++ tests/test_integration.py | 219 ++++++++++++++++++ 20 files changed, 418 insertions(+), 19 deletions(-) create mode 100644 fintoc/managers/v2/events_manager.py create mode 100644 fintoc/managers/v2/setup_intents_manager.py create mode 100644 fintoc/resources/v2/event.py create mode 100644 fintoc/resources/v2/payment_eligibility.py create mode 100644 fintoc/resources/v2/settlement_account.py create mode 100644 fintoc/resources/v2/setup_intent.py create mode 100644 fintoc/resources/v2/transfer_receipt.py diff --git a/fintoc/core.py b/fintoc/core.py index a59ef50..1c3d140 100644 --- a/fintoc/core.py +++ b/fintoc/core.py @@ -28,13 +28,14 @@ from fintoc.managers.v2 import AccountsManager as AccountsManagerV2 from fintoc.managers.v2 import AccountVerificationsManager from fintoc.managers.v2 import CheckoutSessionsManager as CheckoutSessionsManagerV2 -from fintoc.managers.v2 import CustomersManager, EntitiesManager +from fintoc.managers.v2 import CustomersManager, EntitiesManager, EventsManager from fintoc.managers.v2 import InvoicesManager as InvoicesManagerV2 from fintoc.managers.v2 import PaymentIntentsManager as PaymentIntentsManagerV2 from fintoc.managers.v2 import ( PaymentMethodsManager, ProductsManager, RecipientsManager, + SetupIntentsManager, SimulateManager, ) from fintoc.managers.v2 import SubscriptionsManager as SubscriptionsManagerV2 @@ -98,6 +99,7 @@ def __init__(self, client): ) self.customers = CustomersManager("/v2/customers", client) self.entities = EntitiesManager("/v2/entities", client) + self.events = EventsManager("/v2/events", client) self.recipients = RecipientsManager("/v2/recipients", client) self.simulate = SimulateManager("/v2/simulate", client) self.checkout_sessions = CheckoutSessionsManagerV2( @@ -107,4 +109,5 @@ def __init__(self, client): self.payment_intents = PaymentIntentsManagerV2("/v2/payment_intents", client) self.payment_methods = PaymentMethodsManager("/v2/payment_methods", client) self.products = ProductsManager("/v2/products", client) + self.setup_intents = SetupIntentsManager("/v2/setup_intents", client) self.subscriptions = SubscriptionsManagerV2("/v2/subscriptions", client) diff --git a/fintoc/managers/v2/__init__.py b/fintoc/managers/v2/__init__.py index e5fd81e..0a365d9 100644 --- a/fintoc/managers/v2/__init__.py +++ b/fintoc/managers/v2/__init__.py @@ -7,6 +7,7 @@ from .checkout_sessions_manager import CheckoutSessionsManager from .customers_manager import CustomersManager from .entities_manager import EntitiesManager +from .events_manager import EventsManager from .invoices_manager import InvoicesManager from .lines_manager import LinesManager from .movements_manager import MovementsManager @@ -15,6 +16,7 @@ from .payment_methods_manager import PaymentMethodsManager from .products_manager import ProductsManager from .recipients_manager import RecipientsManager +from .setup_intents_manager import SetupIntentsManager from .simulate_manager import SimulateManager from .subscription_items_manager import SubscriptionItemsManager from .subscriptions_manager import SubscriptionsManager diff --git a/fintoc/managers/v2/entities_manager.py b/fintoc/managers/v2/entities_manager.py index 820e00a..24bc37e 100644 --- a/fintoc/managers/v2/entities_manager.py +++ b/fintoc/managers/v2/entities_manager.py @@ -8,12 +8,19 @@ class EntitiesManager(ManagerMixin): """Represents an entities manager.""" resource = "entity" - methods = ["list", "get", "create"] + methods = ["list", "get", "create", "settlement_account"] def __init__(self, path, client): super().__init__(path, client) self.__onboardings_manager = None + def _settlement_account(self, identifier, **kwargs): + """Get the bank account where Fintoc settles funds for an entity.""" + path = f"{self._build_path(**kwargs)}/{identifier}/settlement_account" + return self._request_resource( + "get", path, resource="settlement_account", **kwargs + ) + @property def onboardings(self): """Proxies the onboardings manager.""" diff --git a/fintoc/managers/v2/events_manager.py b/fintoc/managers/v2/events_manager.py new file mode 100644 index 0000000..f9f57c5 --- /dev/null +++ b/fintoc/managers/v2/events_manager.py @@ -0,0 +1,10 @@ +"""Module to hold the events manager.""" + +from fintoc.mixins import ManagerMixin + + +class EventsManager(ManagerMixin): + """Represents an events manager.""" + + resource = "event" + methods = ["list", "get"] diff --git a/fintoc/managers/v2/invoices_manager.py b/fintoc/managers/v2/invoices_manager.py index 3b90d98..18943f4 100644 --- a/fintoc/managers/v2/invoices_manager.py +++ b/fintoc/managers/v2/invoices_manager.py @@ -8,7 +8,18 @@ class InvoicesManager(ManagerMixin): """Represents an invoices manager.""" resource = "invoice" - methods = ["list", "get", "add_lines", "remove_lines"] + methods = [ + "list", + "get", + "create", + "update", + "add_lines", + "remove_lines", + "finalize", + "mark_uncollectible", + "pay", + "void", + ] def __init__(self, path, client): super().__init__(path, client) @@ -37,3 +48,26 @@ def _remove_lines(self, identifier, **kwargs): """Remove line items from an invoice.""" path = f"{self._build_path(**kwargs)}/{identifier}/remove_lines" return self._create(path_=path, **kwargs) + + def _finalize(self, identifier, **kwargs): + """Finalize a draft invoice.""" + return self._invoice_action(identifier, "finalize", **kwargs) + + def _mark_uncollectible(self, identifier, **kwargs): + """Mark an open invoice as uncollectible.""" + return self._invoice_action(identifier, "mark_uncollectible", **kwargs) + + def _pay(self, identifier, **kwargs): + """Pay an open invoice.""" + return self._invoice_action(identifier, "pay", **kwargs) + + def _void(self, identifier, **kwargs): + """Void an invoice.""" + return self._invoice_action(identifier, "void", **kwargs) + + def _invoice_action(self, identifier, action, **kwargs): + path = f"{self._build_path(**kwargs)}/{identifier}/{action}" + invoice = self._create(path_=path, **kwargs) + # pylint: disable=protected-access + invoice._path = self._build_path(**kwargs) + return invoice diff --git a/fintoc/managers/v2/onboardings_manager.py b/fintoc/managers/v2/onboardings_manager.py index 3c0abbf..e1964ba 100644 --- a/fintoc/managers/v2/onboardings_manager.py +++ b/fintoc/managers/v2/onboardings_manager.py @@ -1,9 +1,7 @@ """Module to hold the onboardings manager.""" -import mimetypes -import os - from fintoc.mixins import ManagerMixin +from fintoc.utils import build_file_payload class OnboardingsManager(ManagerMixin): @@ -16,6 +14,8 @@ class OnboardingsManager(ManagerMixin): "create", "submit", "upload_document", + "append_document", + "delete_document", "upload_shareholder_document", "upload_legal_representative_document", ] @@ -26,10 +26,25 @@ def _submit(self, identifier, **kwargs): 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:.""" + """Upload a document to a slot, replacing the one already there.""" path = f"{self._build_path(**kwargs)}/{identifier}/documents/{slot_key}" return self._upload(path, {"file": self._build_file_payload(file)}) + def _append_document(self, identifier, slot_key, file, **kwargs): + """Add a document to a multi-file slot, keeping the existing ones.""" + path = f"{self._build_path(**kwargs)}/{identifier}/documents/{slot_key}" + return self._upload( + path, {"file": self._build_file_payload(file)}, method="post" + ) + + def _delete_document(self, identifier, slot_key, document_id, **kwargs): + """Delete a document from a slot.""" + path = ( + f"{self._build_path(**kwargs)}/{identifier}" + f"/documents/{slot_key}/{document_id}" + ) + return self._request_resource("delete", path, **kwargs) + def _upload_shareholder_document(self, identifier, shareholder_id, file, **kwargs): """Upload a document for a shareholder of an onboarding.""" path = ( @@ -55,11 +70,4 @@ def _build_file_payload(file): 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) + return build_file_payload(file) diff --git a/fintoc/managers/v2/payment_intents_manager.py b/fintoc/managers/v2/payment_intents_manager.py index 2d7195d..507a0e7 100644 --- a/fintoc/managers/v2/payment_intents_manager.py +++ b/fintoc/managers/v2/payment_intents_manager.py @@ -1,3 +1,4 @@ +# pylint: disable=duplicate-code """Module to hold the payment_intents manager.""" from fintoc.mixins import ManagerMixin @@ -7,4 +8,25 @@ class PaymentIntentsManager(ManagerMixin): """Represents a payment_intents manager.""" resource = "payment_intent" - methods = ["list", "get", "create"] + methods = ["list", "get", "create", "check_eligibility", "cancel", "voucher_url"] + + def _check_eligibility(self, idempotency_key=None, **kwargs): + """Check whether a payment passes the transfer limits.""" + path = f"{self._build_path(**kwargs)}/check_eligibility" + return self._request_resource( + "post", + path, + resource="payment_eligibility", + idempotency_key=idempotency_key, + **kwargs, + ) + + def _cancel(self, identifier, **kwargs): + """Cancel a payment intent.""" + path = f"{self._build_path(**kwargs)}/{identifier}/cancel" + return self._create(path_=path, **kwargs) + + def _voucher_url(self, identifier, **kwargs): + """Get a URL to download the transfer voucher of a payment intent.""" + path = f"{self._build_path(**kwargs)}/{identifier}/voucher_url" + return self._request_resource("get", path, resource="voucher_url", **kwargs) diff --git a/fintoc/managers/v2/products_manager.py b/fintoc/managers/v2/products_manager.py index ec01bd3..eaca95d 100644 --- a/fintoc/managers/v2/products_manager.py +++ b/fintoc/managers/v2/products_manager.py @@ -7,4 +7,4 @@ class ProductsManager(ManagerMixin): """Represents a products manager.""" resource = "product" - methods = ["create", "get", "list"] + methods = ["create", "get", "list", "update"] diff --git a/fintoc/managers/v2/setup_intents_manager.py b/fintoc/managers/v2/setup_intents_manager.py new file mode 100644 index 0000000..ab1be7c --- /dev/null +++ b/fintoc/managers/v2/setup_intents_manager.py @@ -0,0 +1,10 @@ +"""Module to hold the setup intents manager.""" + +from fintoc.mixins import ManagerMixin + + +class SetupIntentsManager(ManagerMixin): + """Represents a setup intents manager.""" + + resource = "setup_intent" + methods = ["get"] diff --git a/fintoc/managers/v2/subscriptions_manager.py b/fintoc/managers/v2/subscriptions_manager.py index 1ae27b6..d54b5ce 100644 --- a/fintoc/managers/v2/subscriptions_manager.py +++ b/fintoc/managers/v2/subscriptions_manager.py @@ -1,3 +1,4 @@ +# pylint: disable=duplicate-code """Module to hold the subscriptions manager.""" from fintoc.managers.v2.subscription_items_manager import SubscriptionItemsManager @@ -8,7 +9,14 @@ class SubscriptionsManager(ManagerMixin): """Represents a subscriptions manager.""" resource = "subscription" - methods = ["list", "get", "create", "update", "cancel"] + methods = [ + "list", + "get", + "create", + "update", + "cancel", + "detach_payment_method", + ] def __init__(self, path, client): super().__init__(path, client) @@ -32,3 +40,8 @@ def _cancel(self, identifier, **kwargs): """Cancel a subscription.""" path = f"{self._build_path(**kwargs)}/{identifier}/cancel" return self._create(path_=path, **kwargs) + + def _detach_payment_method(self, identifier, **kwargs): + """Detach the payment method from a subscription.""" + path = f"{self._build_path(**kwargs)}/{identifier}/payment_method" + return self._request_resource("delete", path, **kwargs) diff --git a/fintoc/managers/v2/transfers_manager.py b/fintoc/managers/v2/transfers_manager.py index 4fbfa3c..a2afd2d 100644 --- a/fintoc/managers/v2/transfers_manager.py +++ b/fintoc/managers/v2/transfers_manager.py @@ -7,9 +7,16 @@ class TransfersManager(ManagerMixin): """Represents a transfers manager.""" resource = "transfer" - methods = ["list", "get", "create", "return_"] + methods = ["list", "get", "create", "return_", "receipt"] def _return_(self, **kwargs): """Return a transfer.""" path = f"{self._build_path(**kwargs)}/return" return self._create(path_=path, **kwargs) + + def _receipt(self, identifier, **kwargs): + """Get a signed link to download the PDF receipt of a transfer.""" + path = f"{self._build_path(**kwargs)}/{identifier}/receipt" + return self._request_resource( + "get", path, resource="transfer_receipt", **kwargs + ) diff --git a/fintoc/resources/__init__.py b/fintoc/resources/__init__.py index 8f0396c..d3b2663 100644 --- a/fintoc/resources/__init__.py +++ b/fintoc/resources/__init__.py @@ -34,17 +34,23 @@ from .v2.account_verification import AccountVerification from .v2.customer import Customer from .v2.entity import Entity +from .v2.event import Event from .v2.invoice import Invoice as InvoiceV2 from .v2.line import Line from .v2.onboarding import Onboarding from .v2.onboarding_document import OnboardingDocument from .v2.onboarding_legal_representative import OnboardingLegalRepresentative from .v2.onboarding_shareholder import OnboardingShareholder +from .v2.payment_eligibility import PaymentEligibility from .v2.payment_method import PaymentMethod +from .v2.product import Product from .v2.recipient import Recipient +from .v2.settlement_account import SettlementAccount +from .v2.setup_intent import SetupIntent from .v2.subscription import Subscription as SubscriptionV2 from .v2.subscription_item import SubscriptionItem from .v2.transfer import Transfer from .v2.transfer_batch import TransferBatch +from .v2.transfer_receipt import TransferReceipt from .voucher_url import VoucherUrl from .webhook_endpoint import WebhookEndpoint diff --git a/fintoc/resources/v2/__init__.py b/fintoc/resources/v2/__init__.py index 7e9784b..97da16a 100644 --- a/fintoc/resources/v2/__init__.py +++ b/fintoc/resources/v2/__init__.py @@ -5,6 +5,7 @@ from .account_statement import AccountStatement from .account_verification import AccountVerification from .entity import Entity +from .event import Event from .invoice import Invoice from .line import Line from .movement import Movement @@ -12,10 +13,14 @@ from .onboarding_document import OnboardingDocument from .onboarding_legal_representative import OnboardingLegalRepresentative from .onboarding_shareholder import OnboardingShareholder +from .payment_eligibility import PaymentEligibility from .payment_method import PaymentMethod from .product import Product from .recipient import Recipient +from .settlement_account import SettlementAccount +from .setup_intent import SetupIntent from .subscription import Subscription from .subscription_item import SubscriptionItem from .transfer import Transfer from .transfer_batch import TransferBatch +from .transfer_receipt import TransferReceipt diff --git a/fintoc/resources/v2/event.py b/fintoc/resources/v2/event.py new file mode 100644 index 0000000..fb5326b --- /dev/null +++ b/fintoc/resources/v2/event.py @@ -0,0 +1,7 @@ +"""Module to hold the Event resource.""" + +from fintoc.mixins import ResourceMixin + + +class Event(ResourceMixin): + """Represents a Fintoc Event.""" diff --git a/fintoc/resources/v2/payment_eligibility.py b/fintoc/resources/v2/payment_eligibility.py new file mode 100644 index 0000000..e2dcd91 --- /dev/null +++ b/fintoc/resources/v2/payment_eligibility.py @@ -0,0 +1,7 @@ +"""Module to hold the Payment Eligibility resource.""" + +from fintoc.mixins import ResourceMixin + + +class PaymentEligibility(ResourceMixin): + """Represents a Fintoc Payment Eligibility.""" diff --git a/fintoc/resources/v2/settlement_account.py b/fintoc/resources/v2/settlement_account.py new file mode 100644 index 0000000..53a33e0 --- /dev/null +++ b/fintoc/resources/v2/settlement_account.py @@ -0,0 +1,7 @@ +"""Module to hold the Settlement Account resource.""" + +from fintoc.mixins import ResourceMixin + + +class SettlementAccount(ResourceMixin): + """Represents a Fintoc Settlement Account.""" diff --git a/fintoc/resources/v2/setup_intent.py b/fintoc/resources/v2/setup_intent.py new file mode 100644 index 0000000..5533c94 --- /dev/null +++ b/fintoc/resources/v2/setup_intent.py @@ -0,0 +1,7 @@ +"""Module to hold the Setup Intent resource.""" + +from fintoc.mixins import ResourceMixin + + +class SetupIntent(ResourceMixin): + """Represents a Fintoc Setup Intent.""" diff --git a/fintoc/resources/v2/transfer_receipt.py b/fintoc/resources/v2/transfer_receipt.py new file mode 100644 index 0000000..d791ca6 --- /dev/null +++ b/fintoc/resources/v2/transfer_receipt.py @@ -0,0 +1,7 @@ +"""Module to hold the Transfer Receipt resource.""" + +from fintoc.mixins import ResourceMixin + + +class TransferReceipt(ResourceMixin): + """Represents a Fintoc Transfer Receipt.""" diff --git a/fintoc/utils.py b/fintoc/utils.py index 29b0df8..1a624bf 100644 --- a/fintoc/utils.py +++ b/fintoc/utils.py @@ -2,6 +2,8 @@ import datetime import functools +import mimetypes +import os import warnings from importlib import import_module @@ -134,3 +136,19 @@ def wrapper(*args, **kwargs): return wrapper return decorator + + +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 f39f683..dc69562 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -7,14 +7,21 @@ from fintoc.core import Fintoc from fintoc.resources import ( Charge, + Event, Institution, Invoice, Link, LinkIntent, + Onboarding, + PaymentEligibility, PaymentIntent, Payout, + Product, Refund, + SettlementAccount, + SetupIntent, TaxStatement, + TransferReceipt, VoucherUrl, ) @@ -1773,6 +1780,178 @@ def assert_tax_statement(tax_statement): self.fintoc.tax_statements.get(tax_statement_id, link_token=link_token) ) + def test_v2_entity_settlement_account(self): + """Test getting the settlement account of an entity using v2 API.""" + entity_id = "ent_12345" + + account = self.fintoc.v2.entities.settlement_account(entity_id) + + assert isinstance(account, SettlementAccount) + assert account.method == "get" + assert account.url == f"v2/entities/{entity_id}/settlement_account" + + def test_v2_entity_onboarding_append_document(self, tmp_path): + """Test adding a document to a multi-file onboarding slot using v2 API.""" + entity_id = "ent_12345" + onboarding_id = "onbprc_12345" + slot_key = "incorporation_amendments" + file_path = tmp_path / "amendment.pdf" + file_path.write_bytes(b"%PDF-1.4 test file") + + result = self.fintoc.v2.entities.onboardings.append_document( + onboarding_id, slot_key, str(file_path), entity_id=entity_id + ) + + assert isinstance(result, Onboarding) + assert result.method == "post" + 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_events_list(self): + """Test listing events using v2 API.""" + events = list(self.fintoc.v2.events.list(type="payment_intent.succeeded")) + + assert len(events) > 0 + for event in events: + assert isinstance(event, Event) + assert event.method == "get" + assert event.url == "v2/events" + assert event.params.type == "payment_intent.succeeded" + + def test_v2_event_get(self): + """Test getting a specific event using v2 API.""" + event_id = "evt_12345" + + event = self.fintoc.v2.events.get(event_id) + + assert isinstance(event, Event) + assert event.method == "get" + assert event.url == f"v2/events/{event_id}" + + def test_v2_invoice_create(self): + """Test creating an invoice using v2 API.""" + data = { + "customer": "cus_12345", + "collection_method": "send_invoice", + "lines": [ + {"amount": 60000, "currency": "CLP", "name": "Design", "quantity": 1} + ], + } + + invoice = self.fintoc.v2.invoices.create(**data) + + assert invoice.method == "post" + assert invoice.url == "v2/invoices" + assert invoice.json.customer == data["customer"] + assert invoice.json.collection_method == data["collection_method"] + assert invoice.json.lines[0].name == "Design" + + def test_v2_invoice_update(self): + """Test updating an invoice using v2 API.""" + invoice_id = "test_invoice_id" + + invoice = self.fintoc.v2.invoices.update(invoice_id, metadata={"key": "value"}) + + assert invoice.method == "patch" + assert invoice.url == f"v2/invoices/{invoice_id}" + assert invoice.json.metadata.key == "value" + + @pytest.mark.parametrize("action", ["finalize", "mark_uncollectible", "void"]) + def test_v2_invoice_actions(self, action): + """Test invoice actions, and updating the resulting invoice.""" + invoice_id = "test_invoice_id" + + invoice = getattr(self.fintoc.v2.invoices, action)(invoice_id) + + assert invoice.method == "post" + assert invoice.url == f"v2/invoices/{invoice_id}/{action}" + + # The resulting invoice keeps the invoices collection path + invoice.update(metadata={"key": "value"}) + assert invoice.method == "patch" + assert invoice.url == f"v2/invoices/{invoice.id}" + + def test_v2_invoice_pay(self): + """Test paying an invoice using v2 API.""" + invoice_id = "test_invoice_id" + + invoice = self.fintoc.v2.invoices.pay(invoice_id, payment_method="pm_12345") + + assert invoice.method == "post" + assert invoice.url == f"v2/invoices/{invoice_id}/pay" + assert invoice.json.payment_method == "pm_12345" + + def test_v2_payment_intent_check_eligibility(self): + """Test checking payment eligibility using v2 API.""" + data = { + "amount": 100000, + "currency": "CLP", + "sender_account": {"institution_id": "cl_banco_de_chile"}, + } + + result = self.fintoc.v2.payment_intents.check_eligibility(**data) + + assert isinstance(result, PaymentEligibility) + assert result.method == "post" + assert result.url == "v2/payment_intents/check_eligibility" + assert result.json.amount == data["amount"] + assert result.json.sender_account.institution_id == "cl_banco_de_chile" + assert getattr(result.headers, "idempotency-key") + + def test_v2_payment_intent_cancel(self): + """Test canceling a payment intent using v2 API.""" + payment_intent_id = "pi_12345" + + payment_intent = self.fintoc.v2.payment_intents.cancel(payment_intent_id) + + assert payment_intent.method == "post" + assert payment_intent.url == f"v2/payment_intents/{payment_intent_id}/cancel" + + def test_v2_payment_intent_voucher_url(self): + """Test getting the voucher URL of a payment intent using v2 API.""" + payment_intent_id = "pi_12345" + + voucher_url = self.fintoc.v2.payment_intents.voucher_url(payment_intent_id) + + assert isinstance(voucher_url, VoucherUrl) + assert voucher_url.method == "get" + assert voucher_url.url == f"v2/payment_intents/{payment_intent_id}/voucher_url" + + def test_v2_product_update(self): + """Test updating a product using v2 API.""" + product_id = "prod_12345" + + product = self.fintoc.v2.products.update(product_id, name="New name") + + assert isinstance(product, Product) + assert product.method == "patch" + assert product.url == f"v2/products/{product_id}" + assert product.json.name == "New name" + + def test_v2_setup_intent_get(self): + """Test getting a setup intent using v2 API.""" + setup_intent_id = "seti_12345" + + setup_intent = self.fintoc.v2.setup_intents.get(setup_intent_id) + + assert isinstance(setup_intent, SetupIntent) + assert setup_intent.method == "get" + assert setup_intent.url == f"v2/setup_intents/{setup_intent_id}" + + def test_v2_transfer_receipt(self): + """Test getting the receipt of a transfer using v2 API.""" + transfer_id = "tr_12345" + + receipt = self.fintoc.v2.transfers.receipt(transfer_id) + + assert isinstance(receipt, TransferReceipt) + assert receipt.method == "get" + assert receipt.url == f"v2/transfers/{transfer_id}/receipt" + class TestFintocCustomResponses: """Tests for operations whose responses the shared HTTP mock can't represent.""" @@ -1821,6 +2000,46 @@ def test_links_exchange(self): assert request.method == "PATCH" assert request.url.path == "/v1/links/link_12345_token_abc" + def test_v2_entity_onboarding_delete_document(self): + """Test deleting a document from an onboarding slot using v2 API.""" + entity_id = "ent_12345" + onboarding_id = "onbprc_12345" + slot_key = "incorporation_amendments" + document_id = "onbdoc_12345" + self.response = {"id": onboarding_id, "object": "onboarding", "documents": []} + + onboarding = self.fintoc.v2.entities.onboardings.delete_document( + onboarding_id, slot_key, document_id, entity_id=entity_id + ) + + request = self.requests[-1] + assert request.method == "DELETE" + assert request.url.path == ( + f"/v2/entities/{entity_id}/onboardings/{onboarding_id}" + f"/documents/{slot_key}/{document_id}" + ) + assert isinstance(onboarding, Onboarding) + assert onboarding.id == onboarding_id + + def test_v2_subscription_detach_payment_method(self): + """Test detaching the payment method of a subscription, and updating it.""" + subscription_id = "sub_12345" + self.response = {"id": subscription_id, "object": "subscription"} + + subscription = self.fintoc.v2.subscriptions.detach_payment_method( + subscription_id + ) + + request = self.requests[-1] + assert request.method == "DELETE" + assert request.url.path == f"/v2/subscriptions/{subscription_id}/payment_method" + assert subscription.id == subscription_id + + subscription.update(metadata={"key": "value"}) + request = self.requests[-1] + assert request.method == "PATCH" + assert request.url.path == f"/v2/subscriptions/{subscription_id}" + if __name__ == "__main__": pytest.main() From 76ab73bac515c2a9a918b58967814c40236ab63c Mon Sep 17 00:00:00 2001 From: FranciscaOtero Date: Wed, 28 Oct 2026 12:57:24 -0300 Subject: [PATCH 4/8] feat(v2): add payment methods create, cancel and mandate document Creating a payment method is sent as multipart/form-data, with nested fields encoded with brackets and the consent documents as files. Co-Authored-By: Claude Opus 5.5 --- fintoc/managers/v2/payment_methods_manager.py | 53 ++++++++- fintoc/resources/__init__.py | 1 + fintoc/resources/v2/__init__.py | 1 + fintoc/resources/v2/mandate_document.py | 7 ++ tests/test_integration.py | 103 ++++++++++++++++++ 5 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 fintoc/resources/v2/mandate_document.py diff --git a/fintoc/managers/v2/payment_methods_manager.py b/fintoc/managers/v2/payment_methods_manager.py index 5b47495..9d33847 100644 --- a/fintoc/managers/v2/payment_methods_manager.py +++ b/fintoc/managers/v2/payment_methods_manager.py @@ -1,10 +1,61 @@ """Module to hold the payment methods manager.""" from fintoc.mixins import ManagerMixin +from fintoc.utils import build_file_payload, can_raise_fintoc_error class PaymentMethodsManager(ManagerMixin): """Represents a payment methods manager.""" resource = "payment_method" - methods = ["list", "get"] + methods = ["list", "get", "create", "cancel", "mandate_document"] + + file_parameters = ("consent_documents",) + + @can_raise_fintoc_error + def _create(self, idempotency_key=None, path_=None, **kwargs): + """Create a payment method as multipart, with bracket-nested fields.""" + if path_: + return super()._create( + idempotency_key=idempotency_key, path_=path_, **kwargs + ) + data, files = [], [] + for key, value in kwargs.items(): + self._flatten(key, value, data, files) + return self._upload( + self._build_path(**kwargs), + files, + method="post", + data=dict(data), + idempotency_key=idempotency_key, + ) + + def _cancel(self, identifier, **kwargs): + """Cancel a payment method.""" + path = f"{self._build_path(**kwargs)}/{identifier}/cancel" + return self._create(path_=path, **kwargs) + + def _mandate_document(self, identifier, **kwargs): + """Get a temporary URL to download the signed mandate contract.""" + path = f"{self._build_path(**kwargs)}/{identifier}/mandate_document" + return self._request_resource( + "get", path, resource="mandate_document", **kwargs + ) + + def _flatten(self, key, value, data, files, is_file=False): + """Flatten a nested parameter into multipart form fields and files.""" + if value is None: + return + if isinstance(value, dict): + for nested_key, nested_value in value.items(): + self._flatten( + f"{key}[{nested_key}]", + nested_value, + data, + files, + is_file=is_file or nested_key in self.file_parameters, + ) + elif is_file: + files.append((key, build_file_payload(value))) + else: + data.append((key, str(value))) diff --git a/fintoc/resources/__init__.py b/fintoc/resources/__init__.py index d3b2663..2d05872 100644 --- a/fintoc/resources/__init__.py +++ b/fintoc/resources/__init__.py @@ -37,6 +37,7 @@ from .v2.event import Event from .v2.invoice import Invoice as InvoiceV2 from .v2.line import Line +from .v2.mandate_document import MandateDocument from .v2.onboarding import Onboarding from .v2.onboarding_document import OnboardingDocument from .v2.onboarding_legal_representative import OnboardingLegalRepresentative diff --git a/fintoc/resources/v2/__init__.py b/fintoc/resources/v2/__init__.py index 97da16a..25fa6cc 100644 --- a/fintoc/resources/v2/__init__.py +++ b/fintoc/resources/v2/__init__.py @@ -8,6 +8,7 @@ from .event import Event from .invoice import Invoice from .line import Line +from .mandate_document import MandateDocument from .movement import Movement from .onboarding import Onboarding from .onboarding_document import OnboardingDocument diff --git a/fintoc/resources/v2/mandate_document.py b/fintoc/resources/v2/mandate_document.py new file mode 100644 index 0000000..5c520b3 --- /dev/null +++ b/fintoc/resources/v2/mandate_document.py @@ -0,0 +1,7 @@ +"""Module to hold the Mandate Document resource.""" + +from fintoc.mixins import ResourceMixin + + +class MandateDocument(ResourceMixin): + """Represents a Fintoc Mandate Document.""" diff --git a/tests/test_integration.py b/tests/test_integration.py index dc69562..a2bd636 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,5 +1,7 @@ """Integration tests for the Fintoc core object.""" +import io + import httpx import pytest @@ -12,9 +14,11 @@ Invoice, Link, LinkIntent, + MandateDocument, Onboarding, PaymentEligibility, PaymentIntent, + PaymentMethod, Payout, Product, Refund, @@ -1921,6 +1925,57 @@ def test_v2_payment_intent_voucher_url(self): assert voucher_url.method == "get" assert voucher_url.url == f"v2/payment_intents/{payment_intent_id}/voucher_url" + def test_v2_payment_method_create(self, tmp_path): + """Test creating a payment method using v2 API.""" + documents = {} + for name in ["contract", "selfie", "id_front", "id_back"]: + file_path = tmp_path / f"{name}.pdf" + file_path.write_bytes(b"%PDF-1.4 test file") + documents[name] = str(file_path) + + payment_method = self.fintoc.v2.payment_methods.create( + type="mx_direct_debit", + customer="cus_12345", + mx_direct_debit={ + "account_number": "000000000000000000", + "account_type": "clabe", + "interval": "month", + "max_amount": 100000, + "consent_documents": documents, + }, + ) + + assert isinstance(payment_method, PaymentMethod) + assert payment_method.method == "post" + assert payment_method.url == "v2/payment_methods" + assert getattr(payment_method.headers, "content-type").startswith( + "multipart/form-data" + ) + assert getattr(payment_method.headers, "idempotency-key") + assert payment_method.json.multipart is True + + def test_v2_payment_method_cancel(self): + """Test canceling a payment method using v2 API.""" + payment_method_id = "pm_12345" + + payment_method = self.fintoc.v2.payment_methods.cancel(payment_method_id) + + assert isinstance(payment_method, PaymentMethod) + assert payment_method.method == "post" + assert payment_method.url == f"v2/payment_methods/{payment_method_id}/cancel" + + def test_v2_payment_method_mandate_document(self): + """Test getting the mandate document of a payment method using v2 API.""" + payment_method_id = "pm_12345" + + document = self.fintoc.v2.payment_methods.mandate_document(payment_method_id) + + assert isinstance(document, MandateDocument) + assert document.method == "get" + assert document.url == ( + f"v2/payment_methods/{payment_method_id}/mandate_document" + ) + def test_v2_product_update(self): """Test updating a product using v2 API.""" product_id = "prod_12345" @@ -2000,6 +2055,54 @@ def test_links_exchange(self): assert request.method == "PATCH" assert request.url.path == "/v1/links/link_12345_token_abc" + def test_v2_payment_method_create_multipart_fields(self): + """Test the multipart encoding of a payment method creation.""" + self.response = {"id": "pm_12345", "object": "payment_method"} + + payment_method = self.fintoc.v2.payment_methods.create( + type="mx_direct_debit", + customer="cus_12345", + mx_direct_debit={ + "account_number": "000000000000000000", + "account_type": "clabe", + "interval": "month", + "max_amount": 100000, + "institution_id": None, + "account_holder": {"rfc": "AAA010101AAA"}, + "consent_documents": { + "contract": io.BytesIO(b"contract"), + "selfie": io.BytesIO(b"selfie"), + "id_front": io.BytesIO(b"id_front"), + "id_back": io.BytesIO(b"id_back"), + }, + }, + idempotency_key="idem_12345", + ) + + request = self.requests[-1] + body = request.content.decode("latin-1") + assert request.method == "POST" + assert request.url.path == "/v2/payment_methods" + assert request.headers["idempotency-key"] == "idem_12345" + assert request.headers["content-type"].startswith("multipart/form-data") + fields = { + "type": "mx_direct_debit", + "customer": "cus_12345", + "mx_direct_debit[account_number]": "000000000000000000", + "mx_direct_debit[account_type]": "clabe", + "mx_direct_debit[interval]": "month", + "mx_direct_debit[max_amount]": "100000", + "mx_direct_debit[account_holder][rfc]": "AAA010101AAA", + } + for name, value in fields.items(): + assert f'name="{name}"\r\n\r\n{value}\r\n' in body + assert "institution_id" not in body + for name in ["contract", "selfie", "id_front", "id_back"]: + assert f'name="mx_direct_debit[consent_documents][{name}]"' in body + assert f"\r\n\r\n{name}\r\n" in body + assert isinstance(payment_method, PaymentMethod) + assert payment_method.id == "pm_12345" + def test_v2_entity_onboarding_delete_document(self): """Test deleting a document from an onboarding slot using v2 API.""" entity_id = "ent_12345" From a3e09354db213f3c4cf9669f7a5a88473ac938ae Mon Sep 17 00:00:00 2001 From: FranciscaOtero Date: Wed, 28 Oct 2026 12:57:41 -0300 Subject: [PATCH 5/8] fix: send a filename for nameless file-like uploads Without a filename, Rack parses the multipart part as a string, so the API rejects payment method consent documents and onboarding documents passed as objects like io.BytesIO. Co-Authored-By: Claude Opus 5.5 --- fintoc/managers/v2/payment_methods_manager.py | 3 +- fintoc/utils.py | 14 ++++--- tests/test_integration.py | 25 +++++++++++- tests/test_utils.py | 39 +++++++++++++++++++ 4 files changed, 73 insertions(+), 8 deletions(-) diff --git a/fintoc/managers/v2/payment_methods_manager.py b/fintoc/managers/v2/payment_methods_manager.py index 9d33847..767c627 100644 --- a/fintoc/managers/v2/payment_methods_manager.py +++ b/fintoc/managers/v2/payment_methods_manager.py @@ -56,6 +56,7 @@ def _flatten(self, key, value, data, files, is_file=False): is_file=is_file or nested_key in self.file_parameters, ) elif is_file: - files.append((key, build_file_payload(value))) + filename = key.rsplit("[", 1)[-1].rstrip("]") + files.append((key, build_file_payload(value, default_filename=filename))) else: data.append((key, str(value))) diff --git a/fintoc/utils.py b/fintoc/utils.py index 1a624bf..db21310 100644 --- a/fintoc/utils.py +++ b/fintoc/utils.py @@ -138,17 +138,19 @@ def wrapper(*args, **kwargs): return decorator -def build_file_payload(file): +def build_file_payload(file, default_filename="file"): """ - Build the ``(filename, fileobj, content_type)`` tuple expected by httpx - from either a path (``str`` / ``os.PathLike``) or a binary file-like - object. + Build the httpx file tuple from a path or a binary file-like object. + Nameless file-like objects use :default_filename:, because servers only + treat multipart parts with a filename as uploaded files. """ 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 + name = getattr(file, "name", None) + filename = os.path.basename(name) if isinstance(name, str) else "" + filename = filename or default_filename + content_type = mimetypes.guess_type(filename)[0] return (filename, file, content_type) diff --git a/tests/test_integration.py b/tests/test_integration.py index a2bd636..8ecd520 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -2098,11 +2098,34 @@ def test_v2_payment_method_create_multipart_fields(self): assert f'name="{name}"\r\n\r\n{value}\r\n' in body assert "institution_id" not in body for name in ["contract", "selfie", "id_front", "id_back"]: - assert f'name="mx_direct_debit[consent_documents][{name}]"' in body + assert ( + f'name="mx_direct_debit[consent_documents][{name}]"; ' + f'filename="{name}"' + ) in body assert f"\r\n\r\n{name}\r\n" in body assert isinstance(payment_method, PaymentMethod) assert payment_method.id == "pm_12345" + @pytest.mark.parametrize( + "method,args", + [ + ("upload_document", ("company_constitution",)), + ("append_document", ("incorporation_amendments",)), + ("upload_shareholder_document", ("onbsh_12345",)), + ("upload_legal_representative_document", ("onblr_12345", "identification")), + ], + ) + def test_v2_entity_onboarding_upload_nameless_file_like(self, method, args): + """Test that nameless file-like uploads are sent with a filename.""" + self.response = {"id": "onbprc_12345", "object": "onboarding"} + + getattr(self.fintoc.v2.entities.onboardings, method)( + "onbprc_12345", *args, io.BytesIO(b"%PDF-1.4"), entity_id="ent_12345" + ) + + body = self.requests[-1].content.decode("latin-1") + assert 'name="file"; filename="file"' in body + def test_v2_entity_onboarding_delete_document(self): """Test deleting a document from an onboarding slot using v2 API.""" entity_id = "ent_12345" diff --git a/tests/test_utils.py b/tests/test_utils.py index 20d2b59..af09786 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,4 +1,5 @@ import datetime +import io from types import GeneratorType import httpx @@ -8,6 +9,7 @@ from fintoc.errors import ApiError, FintocError from fintoc.resources import GenericFintocResource, Link from fintoc.utils import ( + build_file_payload, can_raise_fintoc_error, get_error_class, get_resource_class, @@ -284,3 +286,40 @@ def test_generator_objetization(self): for object_ in objetized_generator: assert isinstance(object_, ExampleClass) + + +class TestBuildFilePayload: + def test_path(self, tmp_path): + file_path = tmp_path / "document.pdf" + file_path.write_bytes(b"%PDF-1.4") + + filename, file_obj, content_type = build_file_payload(str(file_path)) + file_obj.close() + + assert filename == "document.pdf" + assert content_type == "application/pdf" + + def test_named_file_like(self, tmp_path): + file_path = tmp_path / "id.png" + file_path.write_bytes(b"\x89PNG") + + with open(file_path, "rb") as file_obj: + payload = build_file_payload(file_obj) + + assert payload == ("id.png", file_obj, "image/png") + + def test_nameless_file_like_uses_default_filename(self): + file_obj = io.BytesIO(b"data") + + assert build_file_payload(file_obj) == ("file", file_obj, None) + assert build_file_payload(file_obj, default_filename="contract.pdf") == ( + "contract.pdf", + file_obj, + "application/pdf", + ) + + def test_non_string_name_uses_default_filename(self): + file_obj = io.BytesIO(b"data") + file_obj.name = 3 + + assert build_file_payload(file_obj)[0] == "file" From cc2b6842a8b00c809561f5780944bfd140d1dcd4 Mon Sep 17 00:00:00 2001 From: FranciscaOtero Date: Wed, 28 Oct 2026 12:57:41 -0300 Subject: [PATCH 6/8] docs: document new operations in README Co-Authored-By: Claude Opus 5.5 --- README.md | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/README.md b/README.md index 998a321..3039843 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,9 @@ - [V2 Endpoints](#v2-endpoints) - [Nested actions or resources](#nested-actions-or-resources) - [Entity onboardings](#entity-onboardings) + - [Links](#links) + - [Payouts](#payouts) + - [Payment methods](#payment-methods) - [Transfer batches](#transfer-batches) - [Webhook Signature Validation](#webhook-signature-validation) - [Idempotency Keys](#idempotency-keys) @@ -256,6 +259,22 @@ client.v2.entities.onboardings.upload_legal_representative_document( ) ``` +Slots that accept more than one file, such as `incorporation_amendments`, take additional +files with `append_document`. Each file is listed in the onboarding's `documents`, and its +`id` lets you delete it: + +```python +onboarding = client.v2.entities.onboardings.append_document( + onboarding_id, "incorporation_amendments", "amendment.pdf", entity_id=entity_id +) +onboarding = client.v2.entities.onboardings.delete_document( + onboarding_id, + "incorporation_amendments", + onboarding.documents[0].id, + entity_id=entity_id, +) +``` + Once every required field and document is complete, `submittable` turns `True` and the onboarding can be sent for review: @@ -263,6 +282,55 @@ onboarding can be sent for review: onboarding = client.v2.entities.onboardings.submit(onboarding_id, entity_id=entity_id) ``` +#### Links + +After the account holder connects their account through the widget, exchange the +`exchange_token` for the link. The resulting link includes its `link_token`, used to fetch +the link's resources: + +```python +link = client.links.exchange("exchange_token_123") +accounts = link.accounts.list() +tax_statements = link.tax_statements.list() +``` + +#### Payouts + +The resources a payout disburses or discounts are nested under the payout. Use +`resource_type` (`payment_intent`, `charge`, `refund` or `refund_adjustment`) to choose +which ones to list: + +```python +payouts = client.payouts.list(status="succeeded") +charges = client.payouts.resources.list( + payout_id="po_0ujsswThIGTUYm2K8FjOOfXtY1K", resource_type="charge" +) +``` + +#### Payment methods + +Mexican direct debit mandates are created with a `multipart/form-data` request, so the +consent documents can be paths or binary file-like objects: + +```python +payment_method = client.v2.payment_methods.create( + type="mx_direct_debit", + customer="cus_0ujsswThIGTUYm2K8FjOOfXtY1K", + mx_direct_debit={ + "account_number": "000000000000000000", + "account_type": "clabe", + "interval": "month", + "max_amount": 100000, + "consent_documents": { + "contract": "contract.pdf", + "selfie": "selfie.jpg", + "id_front": "id_front.jpg", + "id_back": "id_back.jpg", + }, + }, +) +``` + #### Transfer batches Transfer batches let you send several transfers in a single request. Creating one From d9ac6a6a7a7f8d0330d4a8a8fe6221f78cb0335c Mon Sep 17 00:00:00 2001 From: FranciscaOtero Date: Wed, 28 Oct 2026 15:27:37 -0300 Subject: [PATCH 7/8] chore: remove unneeded pylint disables too-many-arguments is already disabled in .pylintrc, and the duplicate-code disables in the payouts and subscriptions managers are no longer needed. Co-Authored-By: Claude Opus 5.5 --- fintoc/client.py | 4 ++-- fintoc/managers/payouts_manager.py | 1 - fintoc/managers/v2/subscriptions_manager.py | 10 +--------- fintoc/resource_handlers.py | 4 ++-- 4 files changed, 5 insertions(+), 14 deletions(-) diff --git a/fintoc/client.py b/fintoc/client.py index ff27471..3b43d64 100644 --- a/fintoc/client.py +++ b/fintoc/client.py @@ -63,7 +63,7 @@ def _build_jws_header(self, method, raw_body=None): def _build_request( self, method, url, params, headers, json=None, files=None, data=None - ): # pylint: disable=too-many-arguments + ): 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 @@ -89,7 +89,7 @@ def request( files=None, data=None, idempotency_key=None, - ): # pylint: disable=too-many-arguments + ): """ Uses the internal httpx client to make a simple or paginated request. """ diff --git a/fintoc/managers/payouts_manager.py b/fintoc/managers/payouts_manager.py index 3ff2ec2..bb5e1a6 100644 --- a/fintoc/managers/payouts_manager.py +++ b/fintoc/managers/payouts_manager.py @@ -4,7 +4,6 @@ from fintoc.mixins import ManagerMixin -# pylint: disable=duplicate-code class PayoutsManager(ManagerMixin): """Represents a payouts manager.""" diff --git a/fintoc/managers/v2/subscriptions_manager.py b/fintoc/managers/v2/subscriptions_manager.py index d54b5ce..3e7ba78 100644 --- a/fintoc/managers/v2/subscriptions_manager.py +++ b/fintoc/managers/v2/subscriptions_manager.py @@ -1,4 +1,3 @@ -# pylint: disable=duplicate-code """Module to hold the subscriptions manager.""" from fintoc.managers.v2.subscription_items_manager import SubscriptionItemsManager @@ -9,14 +8,7 @@ class SubscriptionsManager(ManagerMixin): """Represents a subscriptions manager.""" resource = "subscription" - methods = [ - "list", - "get", - "create", - "update", - "cancel", - "detach_payment_method", - ] + methods = ["list", "get", "create", "update", "cancel", "detach_payment_method"] def __init__(self, path, client): super().__init__(path, client) diff --git a/fintoc/resource_handlers.py b/fintoc/resource_handlers.py index 9136c85..08251e1 100644 --- a/fintoc/resource_handlers.py +++ b/fintoc/resource_handlers.py @@ -69,7 +69,7 @@ def resource_upload( method="put", data=None, idempotency_key=None, -): # pylint: disable=too-many-arguments +): """Upload files to a resource endpoint through a multipart request.""" data = client.request( path, @@ -98,7 +98,7 @@ def resource_request( params, resource_path=None, idempotency_key=None, -): # pylint: disable=too-many-arguments +): """Make a request with an arbitrary :method: and objetize the response.""" if method.lower() in ["get", "delete"]: data = client.request(path, method=method, params=params) From 6a09cd8c0a2d303855ae824fa483677b40e19410 Mon Sep 17 00:00:00 2001 From: FranciscaOtero Date: Wed, 28 Oct 2026 15:27:38 -0300 Subject: [PATCH 8/8] fix: keep the collection path on resources returned by actions Actions created through _create(path_=...) objetized the resource with the action URL as its path, so calling update() or delete() on the result hit URLs like v2/subscriptions/{id}/cancel/{id}. This removes the protected-access patches in charges and invoices. Co-Authored-By: Claude Opus 5.5 --- fintoc/managers/charges_manager.py | 5 +---- fintoc/managers/v2/invoices_manager.py | 5 +---- fintoc/mixins/manager_mixin.py | 5 +++-- fintoc/resource_handlers.py | 11 +++++++++-- fintoc/utils.py | 21 +++++++++++++++------ tests/test_integration.py | 26 ++++++++++++++++++++------ tests/test_utils.py | 22 +++++++++++++++++++++- 7 files changed, 70 insertions(+), 25 deletions(-) diff --git a/fintoc/managers/charges_manager.py b/fintoc/managers/charges_manager.py index 63d8f0c..01290fb 100644 --- a/fintoc/managers/charges_manager.py +++ b/fintoc/managers/charges_manager.py @@ -13,7 +13,4 @@ class ChargesManager(ManagerMixin): def _cancel(self, identifier, **kwargs): """Cancel a charge.""" path = f"{self._build_path(**kwargs)}/{identifier}/cancel" - charge = self._create(path_=path, **kwargs) - # pylint: disable=protected-access - charge._path = self._build_path(**kwargs) - return charge + return self._create(path_=path, **kwargs) diff --git a/fintoc/managers/v2/invoices_manager.py b/fintoc/managers/v2/invoices_manager.py index 18943f4..7a8c9aa 100644 --- a/fintoc/managers/v2/invoices_manager.py +++ b/fintoc/managers/v2/invoices_manager.py @@ -67,7 +67,4 @@ def _void(self, identifier, **kwargs): def _invoice_action(self, identifier, action, **kwargs): path = f"{self._build_path(**kwargs)}/{identifier}/{action}" - invoice = self._create(path_=path, **kwargs) - # pylint: disable=protected-access - invoice._path = self._build_path(**kwargs) - return invoice + return self._create(path_=path, **kwargs) diff --git a/fintoc/mixins/manager_mixin.py b/fintoc/mixins/manager_mixin.py index 510e8a5..3305391 100644 --- a/fintoc/mixins/manager_mixin.py +++ b/fintoc/mixins/manager_mixin.py @@ -107,15 +107,16 @@ def _create(self, idempotency_key=None, path_=None, **kwargs): Data is passed using :kwargs:, as specified by the API. """ klass = get_resource_class(self.__class__.resource) - path = path_ if path_ else self._build_path(**kwargs) + resource_path = self._build_path(**kwargs) object_ = resource_create( client=self._client, - path=path, + path=path_ or resource_path, klass=klass, handlers=self._handlers, methods=self.__class__.methods, params=kwargs, idempotency_key=idempotency_key, + resource_path=resource_path, ) return self.post_create_handler(object_, **kwargs) diff --git a/fintoc/resource_handlers.py b/fintoc/resource_handlers.py index 08251e1..ccef5af 100644 --- a/fintoc/resource_handlers.py +++ b/fintoc/resource_handlers.py @@ -43,7 +43,14 @@ def resource_get(client, path, id_, klass, handlers, methods, params): def resource_create( - client, path, klass, handlers, methods, params, idempotency_key=None + client, + path, + klass, + handlers, + methods, + params, + idempotency_key=None, + resource_path=None, ): """Create a new instance of a resource.""" data = client.request( @@ -55,7 +62,7 @@ def resource_create( data, handlers=handlers, methods=methods, - path=path, + path=resource_path or path, ) diff --git a/fintoc/utils.py b/fintoc/utils.py index db21310..6260b93 100644 --- a/fintoc/utils.py +++ b/fintoc/utils.py @@ -76,13 +76,18 @@ def wrapper(*args, **kwargs): try: return function(*args, **kwargs) except httpx.HTTPStatusError as exc: - error_data = exc.response.json() - error = get_error_class(error_data["error"]["type"]) - raise error(error_data["error"]) from None + raise fintoc_error_from(exc) from None return wrapper +def fintoc_error_from(http_error): + """Build the Fintoc error that corresponds to an HTTPStatusError.""" + error_data = http_error.response.json() + error = get_error_class(error_data["error"]["type"]) + return error(error_data["error"]) + + def serialize(object_): """Serializes an object.""" if callable(getattr(object_, "serialize", None)): @@ -112,10 +117,14 @@ def objetize(klass, client, data, handlers={}, methods=[], path=None): def objetize_generator(generator, klass, client, handlers={}, methods=[], path=None): """ Transform a generator of dictionaries into a generator of - objects with class :klass:. + objects with class :klass:. Pages are fetched while iterating, outside + the scope of :can_raise_fintoc_error:, so HTTP errors are converted here. """ - for element in generator: - yield objetize(klass, client, element, handlers, methods, path) + try: + for element in generator: + yield objetize(klass, client, element, handlers, methods, path) + except httpx.HTTPStatusError as exc: + raise fintoc_error_from(exc) from None def deprecate(message=None): diff --git a/tests/test_integration.py b/tests/test_integration.py index 8ecd520..d2db2ea 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -614,7 +614,7 @@ def test_webhook_endpoint_delete(self): assert result == webhook_endpoint_id def test_webhook_endpoint_test(self): - """Test sending a test event to a webhook endpoint.""" + """Test sending a test event to a webhook endpoint, and updating it.""" webhook_endpoint_id = "test_webhook_endpoint_id" test_data = {"type": "link.credentials_changed"} @@ -624,6 +624,10 @@ def test_webhook_endpoint_test(self): assert result.url == f"v1/webhook_endpoints/{webhook_endpoint_id}/test" assert result.json.type == test_data["type"] + result.update(disabled=True) + assert result.method == "patch" + assert result.url == f"v1/webhook_endpoints/{result.id}" + def test_v2_accounts_list(self): """Test getting all accounts using v2 API.""" accounts = list(self.fintoc.v2.accounts.list()) @@ -1186,7 +1190,7 @@ def test_v2_subscription_get(self): assert subscription.url == f"v2/subscriptions/{subscription_id}" def test_v2_subscription_cancel(self): - """Test canceling a subscription using v2 API.""" + """Test canceling a subscription, and updating the canceled subscription.""" subscription_id = "test_subscription_id" result = self.fintoc.v2.subscriptions.cancel(subscription_id) @@ -1194,6 +1198,10 @@ def test_v2_subscription_cancel(self): assert result.method == "post" assert result.url == f"v2/subscriptions/{subscription_id}/cancel" + result.update(metadata={"key": "value"}) + assert result.method == "patch" + assert result.url == f"v2/subscriptions/{result.id}" + def test_v2_subscription_create(self): """Test creating a subscription using v2 API.""" subscription_data = { @@ -1307,7 +1315,7 @@ def test_v2_invoice_get(self): assert invoice.url == f"v2/invoices/{invoice_id}" def test_v2_invoice_add_lines(self): - """Test adding line items to an invoice using v2 API.""" + """Test adding line items to an invoice, and updating the invoice.""" invoice_id = "test_invoice_id" lines = [ { @@ -1324,8 +1332,12 @@ def test_v2_invoice_add_lines(self): assert invoice.method == "post" assert invoice.url == f"v2/invoices/{invoice_id}/add_lines" + invoice.update(metadata={"key": "value"}) + assert invoice.method == "patch" + assert invoice.url == f"v2/invoices/{invoice.id}" + def test_v2_invoice_remove_lines(self): - """Test removing line items from an invoice using v2 API.""" + """Test removing line items from an invoice, and updating the invoice.""" invoice_id = "test_invoice_id" lines = ["il_test_line_id"] @@ -1334,6 +1346,10 @@ def test_v2_invoice_remove_lines(self): assert invoice.method == "post" assert invoice.url == f"v2/invoices/{invoice_id}/remove_lines" + invoice.update(metadata={"key": "value"}) + assert invoice.method == "patch" + assert invoice.url == f"v2/invoices/{invoice.id}" + def test_v2_invoice_line_update(self): """Test updating an invoice line item using v2 API.""" invoice_id = "test_invoice_id" @@ -1633,7 +1649,6 @@ def test_charge_cancel(self): assert charge.method == "post" assert charge.url == f"v1/charges/{charge_id}/cancel" - # The resulting charge keeps the charges collection path charge.update(amount=3000) assert charge.method == "patch" assert charge.url == f"v1/charges/{charge.id}" @@ -1874,7 +1889,6 @@ def test_v2_invoice_actions(self, action): assert invoice.method == "post" assert invoice.url == f"v2/invoices/{invoice_id}/{action}" - # The resulting invoice keeps the invoices collection path invoice.update(metadata={"key": "value"}) assert invoice.method == "patch" assert invoice.url == f"v2/invoices/{invoice.id}" diff --git a/tests/test_utils.py b/tests/test_utils.py index af09786..6e72e42 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -6,7 +6,7 @@ import pytest from fintoc.constants import DATE_TIME_PATTERN -from fintoc.errors import ApiError, FintocError +from fintoc.errors import ApiError, FintocError, InvalidRequestError from fintoc.resources import GenericFintocResource, Link from fintoc.utils import ( build_file_payload, @@ -287,6 +287,26 @@ def test_generator_objetization(self): for object_ in objetized_generator: assert isinstance(object_, ExampleClass) + def test_http_status_error_while_iterating(self): + def failing_generator(): + yield {"id": "obj_3nlaf830FBbfF83"} + raise httpx.HTTPStatusError( + message="HTTP Status Error", + response=httpx.Response( + status_code=400, + json={"error": {"type": "invalid_request_error"}}, + ), + request=httpx.Request("GET", "/"), + ) + + objetized_generator = objetize_generator( + failing_generator(), ExampleClass, self.client + ) + assert isinstance(next(objetized_generator), ExampleClass) + + with pytest.raises(InvalidRequestError): + next(objetized_generator) + class TestBuildFilePayload: def test_path(self, tmp_path):