From 02a5c78045f3f3d4c93c6433dc5fcff4cf1da6c6 Mon Sep 17 00:00:00 2001 From: Francisca Otero Date: Thu, 12 Nov 2026 12:05:31 +0000 Subject: [PATCH] Add crossborder quotes, beneficiaries and transfers, and account certificates Co-authored-by: fin-compose[bot] <255610621+fin-compose[bot]@users.noreply.github.com> --- README.md | 40 +++++ fintoc/core.py | 21 ++- fintoc/managers/v2/__init__.py | 3 + fintoc/managers/v2/account_numbers_manager.py | 9 +- fintoc/managers/v2/accounts_manager.py | 9 +- .../v2/crossborder_beneficiaries_manager.py | 10 ++ .../managers/v2/crossborder_quotes_manager.py | 10 ++ .../v2/crossborder_transfers_manager.py | 10 ++ fintoc/resources/__init__.py | 4 + fintoc/resources/v2/__init__.py | 4 + fintoc/resources/v2/account_certificate.py | 7 + .../resources/v2/crossborder_beneficiary.py | 7 + fintoc/resources/v2/crossborder_quote.py | 7 + fintoc/resources/v2/crossborder_transfer.py | 9 + tests/test_integration.py | 169 ++++++++++++++++++ 15 files changed, 316 insertions(+), 3 deletions(-) create mode 100644 fintoc/managers/v2/crossborder_beneficiaries_manager.py create mode 100644 fintoc/managers/v2/crossborder_quotes_manager.py create mode 100644 fintoc/managers/v2/crossborder_transfers_manager.py create mode 100644 fintoc/resources/v2/account_certificate.py create mode 100644 fintoc/resources/v2/crossborder_beneficiary.py create mode 100644 fintoc/resources/v2/crossborder_quote.py create mode 100644 fintoc/resources/v2/crossborder_transfer.py diff --git a/README.md b/README.md index b450404..9198bb1 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ - [Payouts](#payouts) - [Organizations](#organizations) - [Payment methods](#payment-methods) + - [Account certificates](#account-certificates) + - [Crossborder](#crossborder) - [Transfer batches](#transfer-batches) - [Webhook Signature Validation](#webhook-signature-validation) - [Idempotency Keys](#idempotency-keys) @@ -361,6 +363,44 @@ payment_method = client.v2.payment_methods.create( ) ``` +#### Account certificates + +Mexican accounts and account numbers have a certificate (constancia de cuenta) with a +temporary `download_url` for its PDF: + +```python +certificate = client.v2.accounts.certificate("acc_31yYL7h9LVPg121AgFtCyJPDsgM") +certificate = client.v2.account_numbers.certificate("acno_31yYL5uOb39vzvRIszPhoXYMjt4") +print(certificate.download_url, certificate.expires_at) +``` + +#### Crossborder + +Crossborder quotes, beneficiaries and transfers are under the `v2.crossborder` namespace. +Create a quote to lock the exchange rate, and accept it with a transfer to a beneficiary: + +```python +beneficiary = client.v2.crossborder.beneficiaries.create( + holder_type="business", + business_name="Acme LLC", + account_identifier="123456789", + routing_number="021000021", + bank_name="JPMorgan Chase Bank, N.A.", + address={ + "street_line1": "100 Main St", + "city": "New York", + "state": "NY", + "postal_code": "10001", + "country": "US", + }, +) +quote = client.v2.crossborder.quotes.create(direction="mx_to_usd", source_amount=200001) +transfer = client.v2.crossborder.transfers.create( + quote_id=quote.id, beneficiary_id=beneficiary.id, reference="Factura 123" +) +print(transfer.funding_instructions.clabe) +``` + #### Transfer batches Transfer batches let you send several transfers in a single request. Creating one diff --git a/fintoc/core.py b/fintoc/core.py index b623244..de69c6b 100644 --- a/fintoc/core.py +++ b/fintoc/core.py @@ -29,7 +29,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, EventsManager +from fintoc.managers.v2 import ( + CrossborderBeneficiariesManager, + CrossborderQuotesManager, + CrossborderTransfersManager, + CustomersManager, + EntitiesManager, + EventsManager, +) from fintoc.managers.v2 import InvoicesManager as InvoicesManagerV2 from fintoc.managers.v2 import PaymentIntentsManager as PaymentIntentsManagerV2 from fintoc.managers.v2 import ( @@ -99,6 +106,7 @@ def __init__(self, client): self.account_verifications = AccountVerificationsManager( "/v2/account_verifications", client ) + self.crossborder = _FintocV2Crossborder(client) self.customers = CustomersManager("/v2/customers", client) self.entities = EntitiesManager("/v2/entities", client) self.events = EventsManager("/v2/events", client) @@ -113,3 +121,14 @@ def __init__(self, client): self.products = ProductsManager("/v2/products", client) self.setup_intents = SetupIntentsManager("/v2/setup_intents", client) self.subscriptions = SubscriptionsManagerV2("/v2/subscriptions", client) + + +class _FintocV2Crossborder: + def __init__(self, client): + self.quotes = CrossborderQuotesManager("/v2/crossborder/quotes", client) + self.beneficiaries = CrossborderBeneficiariesManager( + "/v2/crossborder/beneficiaries", client + ) + self.transfers = CrossborderTransfersManager( + "/v2/crossborder/transfers", client + ) diff --git a/fintoc/managers/v2/__init__.py b/fintoc/managers/v2/__init__.py index 0a365d9..bd0dbd8 100644 --- a/fintoc/managers/v2/__init__.py +++ b/fintoc/managers/v2/__init__.py @@ -5,6 +5,9 @@ from .account_verifications_manager import AccountVerificationsManager from .accounts_manager import AccountsManager from .checkout_sessions_manager import CheckoutSessionsManager +from .crossborder_beneficiaries_manager import CrossborderBeneficiariesManager +from .crossborder_quotes_manager import CrossborderQuotesManager +from .crossborder_transfers_manager import CrossborderTransfersManager from .customers_manager import CustomersManager from .entities_manager import EntitiesManager from .events_manager import EventsManager diff --git a/fintoc/managers/v2/account_numbers_manager.py b/fintoc/managers/v2/account_numbers_manager.py index 30c9a58..6dd6ae5 100644 --- a/fintoc/managers/v2/account_numbers_manager.py +++ b/fintoc/managers/v2/account_numbers_manager.py @@ -7,4 +7,11 @@ class AccountNumbersManager(ManagerMixin): """Represents an account numbers manager.""" resource = "account_number" - methods = ["list", "get", "update", "create", "delete"] + methods = ["list", "get", "update", "create", "delete", "certificate"] + + def _certificate(self, identifier, **kwargs): + """Get a signed link to download the PDF certificate of an account number.""" + path = f"{self._build_path(**kwargs)}/{identifier}/certificate" + return self._request_resource( + "get", path, resource="account_certificate", **kwargs + ) diff --git a/fintoc/managers/v2/accounts_manager.py b/fintoc/managers/v2/accounts_manager.py index 299adad..15b10b9 100644 --- a/fintoc/managers/v2/accounts_manager.py +++ b/fintoc/managers/v2/accounts_manager.py @@ -10,13 +10,20 @@ class AccountsManager(ManagerMixin): """Represents an accounts manager.""" resource = "account" - methods = ["list", "get", "create", "update"] + methods = ["list", "get", "create", "update", "certificate"] def __init__(self, path, client): super().__init__(path, client) self.__account_statements_manager = None self.__movements_manager = None + def _certificate(self, identifier, **kwargs): + """Get a signed link to download the PDF certificate of an account.""" + path = f"{self._build_path(**kwargs)}/{identifier}/certificate" + return self._request_resource( + "get", path, resource="account_certificate", **kwargs + ) + @property def account_statements(self): """Proxies the account statements manager.""" diff --git a/fintoc/managers/v2/crossborder_beneficiaries_manager.py b/fintoc/managers/v2/crossborder_beneficiaries_manager.py new file mode 100644 index 0000000..d26c5a8 --- /dev/null +++ b/fintoc/managers/v2/crossborder_beneficiaries_manager.py @@ -0,0 +1,10 @@ +"""Module to hold the crossborder beneficiaries manager.""" + +from fintoc.mixins import ManagerMixin + + +class CrossborderBeneficiariesManager(ManagerMixin): + """Represents a crossborder beneficiaries manager.""" + + resource = "crossborder_beneficiary" + methods = ["list", "get", "create", "delete"] diff --git a/fintoc/managers/v2/crossborder_quotes_manager.py b/fintoc/managers/v2/crossborder_quotes_manager.py new file mode 100644 index 0000000..c3e1bf0 --- /dev/null +++ b/fintoc/managers/v2/crossborder_quotes_manager.py @@ -0,0 +1,10 @@ +"""Module to hold the crossborder quotes manager.""" + +from fintoc.mixins import ManagerMixin + + +class CrossborderQuotesManager(ManagerMixin): + """Represents a crossborder quotes manager.""" + + resource = "crossborder_quote" + methods = ["get", "create"] diff --git a/fintoc/managers/v2/crossborder_transfers_manager.py b/fintoc/managers/v2/crossborder_transfers_manager.py new file mode 100644 index 0000000..0112a42 --- /dev/null +++ b/fintoc/managers/v2/crossborder_transfers_manager.py @@ -0,0 +1,10 @@ +"""Module to hold the crossborder transfers manager.""" + +from fintoc.mixins import ManagerMixin + + +class CrossborderTransfersManager(ManagerMixin): + """Represents a crossborder transfers manager.""" + + resource = "crossborder_transfer" + methods = ["list", "get", "create"] diff --git a/fintoc/resources/__init__.py b/fintoc/resources/__init__.py index c46f711..51832ca 100644 --- a/fintoc/resources/__init__.py +++ b/fintoc/resources/__init__.py @@ -31,8 +31,12 @@ from .tobacco_taxes import TobaccoTaxes from .transfer_account import TransferAccount from .v2.account import Account as AccountV2 +from .v2.account_certificate import AccountCertificate from .v2.account_number import AccountNumber from .v2.account_verification import AccountVerification +from .v2.crossborder_beneficiary import CrossborderBeneficiary +from .v2.crossborder_quote import CrossborderQuote +from .v2.crossborder_transfer import CrossborderTransfer from .v2.customer import Customer from .v2.entity import Entity from .v2.event import Event diff --git a/fintoc/resources/v2/__init__.py b/fintoc/resources/v2/__init__.py index 25fa6cc..2fa8968 100644 --- a/fintoc/resources/v2/__init__.py +++ b/fintoc/resources/v2/__init__.py @@ -1,9 +1,13 @@ """Init file for the v2 resources module of the SDK.""" from .account import Account +from .account_certificate import AccountCertificate from .account_number import AccountNumber from .account_statement import AccountStatement from .account_verification import AccountVerification +from .crossborder_beneficiary import CrossborderBeneficiary +from .crossborder_quote import CrossborderQuote +from .crossborder_transfer import CrossborderTransfer from .entity import Entity from .event import Event from .invoice import Invoice diff --git a/fintoc/resources/v2/account_certificate.py b/fintoc/resources/v2/account_certificate.py new file mode 100644 index 0000000..5e59356 --- /dev/null +++ b/fintoc/resources/v2/account_certificate.py @@ -0,0 +1,7 @@ +"""Module to hold the Account Certificate resource.""" + +from fintoc.mixins import ResourceMixin + + +class AccountCertificate(ResourceMixin): + """Represents a Fintoc Account Certificate.""" diff --git a/fintoc/resources/v2/crossborder_beneficiary.py b/fintoc/resources/v2/crossborder_beneficiary.py new file mode 100644 index 0000000..5ac06c0 --- /dev/null +++ b/fintoc/resources/v2/crossborder_beneficiary.py @@ -0,0 +1,7 @@ +"""Module to hold the Crossborder Beneficiary resource.""" + +from fintoc.mixins import ResourceMixin + + +class CrossborderBeneficiary(ResourceMixin): + """Represents a Fintoc Crossborder Beneficiary.""" diff --git a/fintoc/resources/v2/crossborder_quote.py b/fintoc/resources/v2/crossborder_quote.py new file mode 100644 index 0000000..d8e0a31 --- /dev/null +++ b/fintoc/resources/v2/crossborder_quote.py @@ -0,0 +1,7 @@ +"""Module to hold the Crossborder Quote resource.""" + +from fintoc.mixins import ResourceMixin + + +class CrossborderQuote(ResourceMixin): + """Represents a Fintoc Crossborder Quote.""" diff --git a/fintoc/resources/v2/crossborder_transfer.py b/fintoc/resources/v2/crossborder_transfer.py new file mode 100644 index 0000000..c71de41 --- /dev/null +++ b/fintoc/resources/v2/crossborder_transfer.py @@ -0,0 +1,9 @@ +"""Module to hold the Crossborder Transfer resource.""" + +from fintoc.mixins import ResourceMixin + + +class CrossborderTransfer(ResourceMixin): + """Represents a Fintoc Crossborder Transfer.""" + + mappings = {"beneficiary": "crossborder_beneficiary"} diff --git a/tests/test_integration.py b/tests/test_integration.py index 2a7c1df..add88a9 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -8,7 +8,11 @@ from fintoc.client import Client from fintoc.core import Fintoc from fintoc.resources import ( + AccountCertificate, Charge, + CrossborderBeneficiary, + CrossborderQuote, + CrossborderTransfer, Event, Institution, Invoice, @@ -2099,6 +2103,128 @@ def test_v2_transfer_receipt(self): assert receipt.method == "get" assert receipt.url == f"v2/transfers/{transfer_id}/receipt" + def test_v2_account_certificate(self): + """Test getting the certificate of an account using v2 API.""" + account_id = "acc_12345" + + certificate = self.fintoc.v2.accounts.certificate(account_id) + + assert isinstance(certificate, AccountCertificate) + assert certificate.method == "get" + assert certificate.url == f"v2/accounts/{account_id}/certificate" + + def test_v2_account_number_certificate(self): + """Test getting the certificate of an account number using v2 API.""" + account_number_id = "acno_12345" + + certificate = self.fintoc.v2.account_numbers.certificate(account_number_id) + + assert isinstance(certificate, AccountCertificate) + assert certificate.method == "get" + assert certificate.url == f"v2/account_numbers/{account_number_id}/certificate" + + def test_v2_crossborder_quote_create(self): + """Test creating a crossborder quote using v2 API.""" + quote = self.fintoc.v2.crossborder.quotes.create( + direction="mx_to_usd", source_amount=200001 + ) + + assert isinstance(quote, CrossborderQuote) + assert quote.method == "post" + assert quote.url == "v2/crossborder/quotes" + assert quote.json.direction == "mx_to_usd" + assert quote.json.source_amount == 200001 + + def test_v2_crossborder_quote_get(self): + """Test getting a crossborder quote using v2 API.""" + quote_id = "xbq_12345" + + quote = self.fintoc.v2.crossborder.quotes.get(quote_id) + + assert isinstance(quote, CrossborderQuote) + assert quote.method == "get" + assert quote.url == f"v2/crossborder/quotes/{quote_id}" + + def test_v2_crossborder_beneficiaries_list(self): + """Test listing crossborder beneficiaries using v2 API.""" + beneficiaries = list(self.fintoc.v2.crossborder.beneficiaries.list()) + + assert len(beneficiaries) > 0 + for beneficiary in beneficiaries: + assert isinstance(beneficiary, CrossborderBeneficiary) + assert beneficiary.method == "get" + assert beneficiary.url == "v2/crossborder/beneficiaries" + + def test_v2_crossborder_beneficiary_get(self): + """Test getting a crossborder beneficiary using v2 API.""" + beneficiary_id = "xbb_12345" + + beneficiary = self.fintoc.v2.crossborder.beneficiaries.get(beneficiary_id) + + assert isinstance(beneficiary, CrossborderBeneficiary) + assert beneficiary.method == "get" + assert beneficiary.url == f"v2/crossborder/beneficiaries/{beneficiary_id}" + + def test_v2_crossborder_beneficiary_create(self): + """Test creating a crossborder beneficiary using v2 API.""" + beneficiary = self.fintoc.v2.crossborder.beneficiaries.create( + holder_type="business", + business_name="Acme LLC", + account_identifier="123456789", + routing_number="021000021", + bank_name="JPMorgan Chase Bank, N.A.", + address={"street_line1": "100 Main St", "country": "US"}, + ) + + assert isinstance(beneficiary, CrossborderBeneficiary) + assert beneficiary.method == "post" + assert beneficiary.url == "v2/crossborder/beneficiaries" + assert beneficiary.json.holder_type == "business" + assert beneficiary.json.address.country == "US" + + def test_v2_crossborder_beneficiary_delete(self): + """Test archiving a crossborder beneficiary using v2 API.""" + beneficiary_id = "xbb_12345" + + result = self.fintoc.v2.crossborder.beneficiaries.delete(beneficiary_id) + + assert result == beneficiary_id + + def test_v2_crossborder_transfers_list(self): + """Test listing crossborder transfers using v2 API.""" + transfers = list( + self.fintoc.v2.crossborder.transfers.list(direction="mx_to_usd") + ) + + assert len(transfers) > 0 + for transfer in transfers: + assert isinstance(transfer, CrossborderTransfer) + assert transfer.method == "get" + assert transfer.url == "v2/crossborder/transfers" + assert transfer.params.direction == "mx_to_usd" + + def test_v2_crossborder_transfer_get(self): + """Test getting a crossborder transfer using v2 API.""" + transfer_id = "xbt_12345" + + transfer = self.fintoc.v2.crossborder.transfers.get(transfer_id) + + assert isinstance(transfer, CrossborderTransfer) + assert transfer.method == "get" + assert transfer.url == f"v2/crossborder/transfers/{transfer_id}" + + def test_v2_crossborder_transfer_create(self): + """Test creating a crossborder transfer using v2 API.""" + transfer = self.fintoc.v2.crossborder.transfers.create( + quote_id="xbq_12345", beneficiary_id="xbb_12345", reference="Factura 123" + ) + + assert isinstance(transfer, CrossborderTransfer) + assert transfer.method == "post" + assert transfer.url == "v2/crossborder/transfers" + assert transfer.json.quote_id == "xbq_12345" + assert transfer.json.beneficiary_id == "xbb_12345" + class TestFintocCustomResponses: """Tests for operations whose responses the shared HTTP mock can't represent.""" @@ -2258,6 +2384,49 @@ def test_v2_subscription_detach_payment_method(self): assert request.method == "PATCH" assert request.url.path == f"/v2/subscriptions/{subscription_id}" + def test_v2_crossborder_transfer_get_beneficiary(self): + """Test that a crossborder transfer objetizes its beneficiary.""" + transfer_id = "xbt_12345" + self.response = { + "id": transfer_id, + "object": "crossborder_transfer", + "fees": [{"name": "fixed_fee", "amount": 27069, "currency": "MXN"}], + "beneficiary": { + "id": "xbb_12345", + "object": "crossborder_beneficiary", + "address": {"country": "US"}, + }, + } + + transfer = self.fintoc.v2.crossborder.transfers.get(transfer_id) + + request = self.requests[-1] + assert request.method == "GET" + assert request.url.path == f"/v2/crossborder/transfers/{transfer_id}" + assert isinstance(transfer, CrossborderTransfer) + assert isinstance(transfer.beneficiary, CrossborderBeneficiary) + assert transfer.beneficiary.address.country == "US" + assert transfer.fees[0].amount == 27069 + + def test_v2_account_certificate_fields(self): + """Test that an account certificate exposes its download URL.""" + account_id = "acc_12345" + self.response = { + "object": "account_certificate", + "account_id": account_id, + "account_number_id": "acno_12345", + "download_url": "https://storage.example.com/certificate.pdf", + "expires_at": "2026-11-09T18:05:00Z", + } + + certificate = self.fintoc.v2.accounts.certificate(account_id) + + request = self.requests[-1] + assert request.method == "GET" + assert request.url.path == f"/v2/accounts/{account_id}/certificate" + assert isinstance(certificate, AccountCertificate) + assert certificate.download_url == "https://storage.example.com/certificate.pdf" + if __name__ == "__main__": pytest.main()