diff --git a/fintoc/core.py b/fintoc/core.py index 7d33a4e..c36631f 100644 --- a/fintoc/core.py +++ b/fintoc/core.py @@ -27,7 +27,12 @@ from fintoc.managers.v2 import CustomersManager, EntitiesManager from fintoc.managers.v2 import InvoicesManager as InvoicesManagerV2 from fintoc.managers.v2 import PaymentIntentsManager as PaymentIntentsManagerV2 -from fintoc.managers.v2 import PaymentMethodsManager, ProductsManager, SimulateManager +from fintoc.managers.v2 import ( + PaymentMethodsManager, + ProductsManager, + RecipientsManager, + SimulateManager, +) from fintoc.managers.v2 import SubscriptionsManager as SubscriptionsManagerV2 from fintoc.managers.v2 import TransferBatchesManager, TransfersManager from fintoc.version import __version__ @@ -85,6 +90,7 @@ def __init__(self, client): ) self.customers = CustomersManager("/v2/customers", client) self.entities = EntitiesManager("/v2/entities", client) + self.recipients = RecipientsManager("/v2/recipients", client) self.simulate = SimulateManager("/v2/simulate", client) self.checkout_sessions = CheckoutSessionsManagerV2( "/v2/checkout_sessions", client diff --git a/fintoc/managers/v2/__init__.py b/fintoc/managers/v2/__init__.py index 4abc66f..e5fd81e 100644 --- a/fintoc/managers/v2/__init__.py +++ b/fintoc/managers/v2/__init__.py @@ -14,6 +14,7 @@ from .payment_intents_manager import PaymentIntentsManager from .payment_methods_manager import PaymentMethodsManager from .products_manager import ProductsManager +from .recipients_manager import RecipientsManager from .simulate_manager import SimulateManager from .subscription_items_manager import SubscriptionItemsManager from .subscriptions_manager import SubscriptionsManager diff --git a/fintoc/managers/v2/recipients_manager.py b/fintoc/managers/v2/recipients_manager.py new file mode 100644 index 0000000..3fb640d --- /dev/null +++ b/fintoc/managers/v2/recipients_manager.py @@ -0,0 +1,10 @@ +"""Module to hold the recipients manager.""" + +from fintoc.mixins import ManagerMixin + + +class RecipientsManager(ManagerMixin): + """Represents a recipients manager.""" + + resource = "recipient" + methods = ["list", "get", "create", "update", "delete"] diff --git a/fintoc/resources/__init__.py b/fintoc/resources/__init__.py index d9037a5..0178dce 100644 --- a/fintoc/resources/__init__.py +++ b/fintoc/resources/__init__.py @@ -37,6 +37,7 @@ from .v2.onboarding_legal_representative import OnboardingLegalRepresentative from .v2.onboarding_shareholder import OnboardingShareholder from .v2.payment_method import PaymentMethod +from .v2.recipient import Recipient from .v2.subscription import Subscription as SubscriptionV2 from .v2.subscription_item import SubscriptionItem from .v2.transfer import Transfer diff --git a/fintoc/resources/v2/__init__.py b/fintoc/resources/v2/__init__.py index f17e7a1..7e9784b 100644 --- a/fintoc/resources/v2/__init__.py +++ b/fintoc/resources/v2/__init__.py @@ -14,6 +14,7 @@ from .onboarding_shareholder import OnboardingShareholder from .payment_method import PaymentMethod from .product import Product +from .recipient import Recipient from .subscription import Subscription from .subscription_item import SubscriptionItem from .transfer import Transfer diff --git a/fintoc/resources/v2/recipient.py b/fintoc/resources/v2/recipient.py new file mode 100644 index 0000000..983a33c --- /dev/null +++ b/fintoc/resources/v2/recipient.py @@ -0,0 +1,7 @@ +"""Module to hold the Recipient resource.""" + +from fintoc.mixins import ResourceMixin + + +class Recipient(ResourceMixin): + """Represents a Fintoc Recipient.""" diff --git a/tests/fixtures/recipient_cl.json b/tests/fixtures/recipient_cl.json new file mode 100644 index 0000000..02a0d94 --- /dev/null +++ b/tests/fixtures/recipient_cl.json @@ -0,0 +1,18 @@ +{ + "id": "rcp_2hQ9vBmKpR7xLtZ3sWn1fJ4dGcA", + "account_number": "000000001", + "account_type": "checking_account", + "alias": "Test recipient", + "created_at": "2026-03-01T12:00:00Z", + "email": "recipient@example.com", + "holder_id": "111111111", + "holder_name": "Test Customer 1", + "holder_type": "person", + "updated_at": "2026-03-01T12:00:00Z", + "object": "recipient", + "institution": { + "id": "cl_banco_santander", + "name": "Banco Santander", + "country": "cl" + } +} diff --git a/tests/fixtures/recipient_mx.json b/tests/fixtures/recipient_mx.json new file mode 100644 index 0000000..9f28dbf --- /dev/null +++ b/tests/fixtures/recipient_mx.json @@ -0,0 +1,18 @@ +{ + "id": "rcp_2hQ9vBmKpR7xLtZ3sWn1fJ4dGcA", + "account_number": "000000000000000000", + "account_type": "clabe", + "alias": "Test recipient", + "created_at": "2026-03-01T12:00:00Z", + "email": "recipient@example.com", + "holder_id": "XAXX010101000", + "holder_name": "Test Customer 1", + "holder_type": "person", + "updated_at": "2026-03-01T12:00:00Z", + "object": "recipient", + "institution": { + "id": "40002", + "name": "BANAMEX", + "country": "mx" + } +} diff --git a/tests/test_recipients.py b/tests/test_recipients.py new file mode 100644 index 0000000..fb76565 --- /dev/null +++ b/tests/test_recipients.py @@ -0,0 +1,132 @@ +"""Recipients requests through the public v2 client.""" + +import json +from pathlib import Path + +import httpx +import pytest + +from fintoc.client import Client +from fintoc.core import Fintoc +from fintoc.resources.v2 import Recipient + + +@pytest.fixture(params=["mx", "cl"]) +def payload(request): + path = Path(__file__).parent / "fixtures" / f"recipient_{request.param}.json" + return json.loads(path.read_text()) + + +@pytest.fixture +def api(monkeypatch, payload): + requests = [] + + def respond(request): + requests.append(request) + if request.method == "DELETE": + return httpx.Response(204) + if request.method == "GET" and request.url.path == "/v2/recipients": + headers = {} + if "starting_after" not in request.url.params: + headers["link"] = ( + "; rel="next"' + ) + return httpx.Response(200, json=[payload], headers=headers) + updates = json.loads(request.content) if request.method == "PATCH" else {} + return httpx.Response(200, json={**payload, **updates}) + + with httpx.Client(transport=httpx.MockTransport(respond)) as transport: + monkeypatch.setattr(Client, "_client", transport) + yield Fintoc("sk_test_recipients").v2.recipients, requests + + +def test_create(api, payload): + recipients, requests = api + params = { + key: payload[key] + for key in ("account_number", "holder_name", "holder_type", "holder_id") + } + params["entity_id"] = "ent_test" + if payload["institution"]["country"] == "cl": + params.update( + institution_id=payload["institution"]["id"], + account_type=payload["account_type"], + ) + recipient = recipients.create(idempotency_key="recipient-create", **params) + + assert isinstance(recipient, Recipient) + assert recipient.serialize() == payload + assert requests[0].method == "POST" + assert requests[0].url.path == "/v2/recipients" + assert requests[0].headers["Idempotency-Key"] == "recipient-create" + assert json.loads(requests[0].content) == params + + +def test_get(api, payload): + recipients, requests = api + recipient = recipients.get(payload["id"]) + + assert isinstance(recipient, Recipient) + assert recipient.serialize() == payload + assert requests[0].url.path == f'/v2/recipients/{payload["id"]}' + + +@pytest.mark.parametrize("lazy", [True, False]) +def test_list_filters_and_pagination(api, payload, lazy): + recipients, requests = api + filters = { + "entity_id": "ent_test", + "holder_type": "person", + "institution_id": payload["institution"]["id"], + "holder_id": payload["holder_id"], + "account_number": payload["account_number"], + "ending_before": "rcp_previous", + "limit": 1, + } + result = list(recipients.list(lazy=lazy, **filters)) + + assert len(result) == 2 + assert all(isinstance(recipient, Recipient) for recipient in result) + assert dict(requests[0].url.params) == {k: str(v) for k, v in filters.items()} + assert requests[1].url.params["starting_after"] == payload["id"] + + +@pytest.mark.parametrize( + "updates", + [ + {"alias": None, "email": None}, + {"alias": "", "email": ""}, + {"holder_name": "New name"}, + ], +) +@pytest.mark.parametrize("on_resource", [False, True]) +def test_update(api, payload, updates, on_resource): + recipients, requests = api + if on_resource: + recipient = recipients.get(payload["id"]) + result = recipient.update(**updates) + assert result is recipient + else: + result = recipients.update(payload["id"], **updates) + + assert isinstance(result, Recipient) + assert result.serialize() == {**payload, **updates} + assert requests[-1].method == "PATCH" + assert requests[-1].url.path == f'/v2/recipients/{payload["id"]}' + assert json.loads(requests[-1].content) == updates + + +@pytest.mark.parametrize("on_resource", [False, True]) +def test_delete_empty_response(api, payload, on_resource): + recipients, requests = api + result = ( + recipients.get(payload["id"]).delete() + if on_resource + else recipients.delete(payload["id"]) + ) + + assert result == payload["id"] + assert requests[-1].method == "DELETE" + assert requests[-1].url.path == f'/v2/recipients/{payload["id"]}'