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 diff --git a/fintoc/client.py b/fintoc/client.py index e8c9248..3b43d64 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 + ): 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,6 +87,7 @@ def request( params=None, json=None, files=None, + data=None, idempotency_key=None, ): """ @@ -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/core.py b/fintoc/core.py index c36631f..1c3d140 100644 --- a/fintoc/core.py +++ b/fintoc/core.py @@ -9,28 +9,33 @@ ChargesManager, CheckoutSessionsManager, DisputesManager, + InstitutionsManager, InvoicesManager, + LinkIntentsManager, LinksManager, PaymentIntentsManager, PaymentLinksManager, + PayoutsManager, RefreshIntentsManager, RefundsManager, SubscriptionIntentsManager, SubscriptionsManager, TaxReturnsManager, + TaxStatementsManager, WebhookEndpointsManager, ) from fintoc.managers.v2 import AccountNumbersManager 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 @@ -75,6 +80,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) @@ -90,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( @@ -99,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/__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..01290fb 100644 --- a/fintoc/managers/charges_manager.py +++ b/fintoc/managers/charges_manager.py @@ -8,4 +8,9 @@ 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" + return self._create(path_=path, **kwargs) 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..bb5e1a6 --- /dev/null +++ b/fintoc/managers/payouts_manager.py @@ -0,0 +1,30 @@ +"""Module to hold the payouts manager.""" + +from fintoc.managers.payout_resources_manager import PayoutResourcesManager +from fintoc.mixins import ManagerMixin + + +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/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..7a8c9aa 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,23 @@ 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}" + return self._create(path_=path, **kwargs) 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/payment_methods_manager.py b/fintoc/managers/v2/payment_methods_manager.py index 5b47495..767c627 100644 --- a/fintoc/managers/v2/payment_methods_manager.py +++ b/fintoc/managers/v2/payment_methods_manager.py @@ -1,10 +1,62 @@ """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: + 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/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..3e7ba78 100644 --- a/fintoc/managers/v2/subscriptions_manager.py +++ b/fintoc/managers/v2/subscriptions_manager.py @@ -8,7 +8,7 @@ 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 +32,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/mixins/manager_mixin.py b/fintoc/mixins/manager_mixin.py index 2f4757e..3305391 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, ) @@ -106,27 +107,53 @@ 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) @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..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,13 +62,29 @@ def resource_create( data, handlers=handlers, methods=methods, - path=path, + path=resource_path or path, ) -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, +): + """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 +95,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, +): + """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 ): diff --git a/fintoc/resources/__init__.py b/fintoc/resources/__init__.py index 0178dce..2d05872 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 @@ -30,16 +34,24 @@ 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.mandate_document import MandateDocument 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/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/v2/__init__.py b/fintoc/resources/v2/__init__.py index 7e9784b..25fa6cc 100644 --- a/fintoc/resources/v2/__init__.py +++ b/fintoc/resources/v2/__init__.py @@ -5,17 +5,23 @@ 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 .mandate_document import MandateDocument from .movement import Movement from .onboarding import Onboarding 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/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/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/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/fintoc/utils.py b/fintoc/utils.py index 29b0df8..6260b93 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 @@ -74,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)): @@ -110,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): @@ -134,3 +145,21 @@ def wrapper(*args, **kwargs): return wrapper return decorator + + +def build_file_payload(file, default_filename="file"): + """ + 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 + + 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 3bebbfd..d2db2ea 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,8 +1,33 @@ """Integration tests for the Fintoc core object.""" +import io + +import httpx import pytest +from fintoc.client import Client from fintoc.core import Fintoc +from fintoc.resources import ( + Charge, + Event, + Institution, + Invoice, + Link, + LinkIntent, + MandateDocument, + Onboarding, + PaymentEligibility, + PaymentIntent, + PaymentMethod, + Payout, + Product, + Refund, + SettlementAccount, + SetupIntent, + TaxStatement, + TransferReceipt, + VoucherUrl, +) class TestFintocIntegration: @@ -589,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"} @@ -599,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()) @@ -1161,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) @@ -1169,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 = { @@ -1282,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 = [ { @@ -1299,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"] @@ -1309,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" @@ -1586,6 +1627,559 @@ 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" + + 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) + ) + + 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}" + + 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_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" + + 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.""" + + @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" + + 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}]"; ' + 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" + 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() diff --git a/tests/test_utils.py b/tests/test_utils.py index 20d2b59..6e72e42 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,13 +1,15 @@ import datetime +import io from types import GeneratorType import httpx 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, can_raise_fintoc_error, get_error_class, get_resource_class, @@ -284,3 +286,60 @@ 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): + 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"