├── tests ├── __init__.py ├── managers │ ├── __init__.py │ └── test_links_manager.py ├── mixins │ ├── __init__.py │ ├── test_manager_mixin.py │ └── test_resource_mixin.py ├── resources │ ├── __init__.py │ ├── test_account.py │ └── test_link.py ├── test_core.py ├── test_paginator.py ├── conftest.py ├── test_client.py ├── test_jws.py ├── test_webhook.py ├── test_utils.py └── test_integration.py ├── poetry.toml ├── .coveragerc ├── fintoc ├── __init__.py ├── version.py ├── mixins │ ├── __init__.py │ ├── resource_mixin.py │ └── manager_mixin.py ├── resources │ ├── charge.py │ ├── income.py │ ├── refund.py │ ├── balance.py │ ├── v2 │ │ ├── account.py │ │ ├── entity.py │ │ ├── transfer.py │ │ ├── account_number.py │ │ ├── account_verification.py │ │ ├── movement.py │ │ └── __init__.py │ ├── taxpayer.py │ ├── tax_return.py │ ├── institution.py │ ├── other_taxes.py │ ├── payment_link.py │ ├── subscription.py │ ├── tobacco_taxes.py │ ├── refresh_intent.py │ ├── checkout_session.py │ ├── generic_fintoc_resource.py │ ├── services_invoice.py │ ├── transfer_account.py │ ├── webhook_endpoint.py │ ├── institution_invoice.py │ ├── subscription_intent.py │ ├── institution_tax_return.py │ ├── invoice.py │ ├── movement.py │ ├── payment_intent.py │ ├── account.py │ ├── __init__.py │ └── link.py ├── constants.py ├── managers │ ├── invoices_manager.py │ ├── v2 │ │ ├── entities_manager.py │ │ ├── movements_manager.py │ │ ├── account_numbers_manager.py │ │ ├── account_verifications_manager.py │ │ ├── __init__.py │ │ ├── simulate_manager.py │ │ ├── transfers_manager.py │ │ └── accounts_manager.py │ ├── charges_manager.py │ ├── movements_manager.py │ ├── tax_returns_manager.py │ ├── subscriptions_manager.py │ ├── refresh_intents_manager.py │ ├── subscription_intents_manager.py │ ├── webhook_endpoints_manager.py │ ├── refunds_manager.py │ ├── checkout_sessions_manager.py │ ├── payment_links_manager.py │ ├── payment_intents_manager.py │ ├── links_manager.py │ ├── __init__.py │ └── accounts_manager.py ├── errors.py ├── paginator.py ├── resource_handlers.py ├── core.py ├── jws.py ├── client.py ├── utils.py └── webhook.py ├── .pylintrc ├── codecov.yml ├── .flake8 ├── .gitignore ├── .github ├── PULL_REQUEST_TEMPLATE │ └── release.md ├── pull_request_template.md └── workflows │ ├── tests.yml │ ├── release.yml │ └── linters.yml ├── RELEASING.md ├── pyproject.toml ├── Makefile ├── scripts └── bump.sh ├── LICENSE.md ├── examples └── webhook.py └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/managers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/mixins/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/resources/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /poetry.toml: -------------------------------------------------------------------------------- 1 | [virtualenvs] 2 | in-project = true 3 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | fintoc/constants.py 4 | fintoc/version.py 5 | -------------------------------------------------------------------------------- /fintoc/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Init file for the Fintoc Python SDK. 3 | """ 4 | 5 | from fintoc.core import Fintoc 6 | from fintoc.version import __version__ 7 | -------------------------------------------------------------------------------- /fintoc/version.py: -------------------------------------------------------------------------------- 1 | """Module to hold the version utilities.""" 2 | 3 | version_info = (2, 14, 0) 4 | __version__ = ".".join([str(x) for x in version_info]) 5 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | disable = C0103, C0326, C0330, C0413, E1101, R0401, R0903, R0911, R0913, W0102, W0511, W0613, W0703 3 | 4 | [FORMAT] 5 | max-line-length=88 6 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | comment: 2 | behavior: default 3 | layout: reach, diff, flags, files 4 | require_head: true 5 | require_base: false 6 | require_changes: false 7 | -------------------------------------------------------------------------------- /fintoc/mixins/__init__.py: -------------------------------------------------------------------------------- 1 | """Init file for the mixins module of the SDK.""" 2 | 3 | from .manager_mixin import ManagerMixin 4 | from .resource_mixin import ResourceMixin 5 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = *.egg-info, .git, .testing-venv, .venv, __pycache__, build, dist 3 | extend-ignore = E203, W503 4 | ignore = E241, E402, F401 5 | max-line-length = 88 6 | -------------------------------------------------------------------------------- /fintoc/resources/charge.py: -------------------------------------------------------------------------------- 1 | """Module to hold the Charge resource.""" 2 | 3 | from fintoc.mixins import ResourceMixin 4 | 5 | 6 | class Charge(ResourceMixin): 7 | """Represents a Fintoc Charge.""" 8 | -------------------------------------------------------------------------------- /fintoc/resources/income.py: -------------------------------------------------------------------------------- 1 | """Module to hold the Income resource.""" 2 | 3 | from fintoc.mixins import ResourceMixin 4 | 5 | 6 | class Income(ResourceMixin): 7 | """Represents a Fintoc Income.""" 8 | -------------------------------------------------------------------------------- /fintoc/resources/refund.py: -------------------------------------------------------------------------------- 1 | """Module to hold the Refund resource.""" 2 | 3 | from fintoc.mixins import ResourceMixin 4 | 5 | 6 | class Refund(ResourceMixin): 7 | """Represents a Fintoc Refund.""" 8 | -------------------------------------------------------------------------------- /fintoc/resources/balance.py: -------------------------------------------------------------------------------- 1 | """Module to hold the Balance resource.""" 2 | 3 | from fintoc.mixins import ResourceMixin 4 | 5 | 6 | class Balance(ResourceMixin): 7 | """Represents a Fintoc Balance.""" 8 | -------------------------------------------------------------------------------- /fintoc/resources/v2/account.py: -------------------------------------------------------------------------------- 1 | """Module to hold the Account resource.""" 2 | 3 | from fintoc.mixins import ResourceMixin 4 | 5 | 6 | class Account(ResourceMixin): 7 | """Represents a Fintoc Account.""" 8 | -------------------------------------------------------------------------------- /fintoc/resources/v2/entity.py: -------------------------------------------------------------------------------- 1 | """Module to hold the Entity resource.""" 2 | 3 | from fintoc.mixins import ResourceMixin 4 | 5 | 6 | class Entity(ResourceMixin): 7 | """Represents a Fintoc Entity.""" 8 | -------------------------------------------------------------------------------- /fintoc/resources/taxpayer.py: -------------------------------------------------------------------------------- 1 | """Module to hold the Taxpayer resource.""" 2 | 3 | from fintoc.mixins import ResourceMixin 4 | 5 | 6 | class Taxpayer(ResourceMixin): 7 | """Represents a Fintoc Taxpayer.""" 8 | -------------------------------------------------------------------------------- /fintoc/resources/v2/transfer.py: -------------------------------------------------------------------------------- 1 | """Module to hold the Transfer resource.""" 2 | 3 | from fintoc.mixins import ResourceMixin 4 | 5 | 6 | class Transfer(ResourceMixin): 7 | """Represents a Fintoc Transfer.""" 8 | -------------------------------------------------------------------------------- /fintoc/resources/tax_return.py: -------------------------------------------------------------------------------- 1 | """Module to hold the TaxReturn resource.""" 2 | 3 | from fintoc.mixins import ResourceMixin 4 | 5 | 6 | class TaxReturn(ResourceMixin): 7 | """Represents a Fintoc Tax Return.""" 8 | -------------------------------------------------------------------------------- /fintoc/resources/institution.py: -------------------------------------------------------------------------------- 1 | """Module to hold the Institution resource.""" 2 | 3 | from fintoc.mixins import ResourceMixin 4 | 5 | 6 | class Institution(ResourceMixin): 7 | """Represents a Fintoc Institution.""" 8 | -------------------------------------------------------------------------------- /fintoc/resources/other_taxes.py: -------------------------------------------------------------------------------- 1 | """Module to hold the OtherTaxes resource.""" 2 | 3 | from fintoc.mixins import ResourceMixin 4 | 5 | 6 | class OtherTaxes(ResourceMixin): 7 | """Represents a Fintoc Other Taxes.""" 8 | -------------------------------------------------------------------------------- /fintoc/resources/payment_link.py: -------------------------------------------------------------------------------- 1 | """Module to hold the PaymentLink resource.""" 2 | 3 | from fintoc.mixins import ResourceMixin 4 | 5 | 6 | class PaymentLink(ResourceMixin): 7 | """Represents a Fintoc PaymentLink.""" 8 | -------------------------------------------------------------------------------- /fintoc/constants.py: -------------------------------------------------------------------------------- 1 | """Module to hold the constants of the SDK.""" 2 | 3 | API_BASE_URL = "https://api.fintoc.com" 4 | 5 | LINK_HEADER_PATTERN = r'<(?P.*)>;\s*rel="(?P.*)"' 6 | DATE_TIME_PATTERN = "%Y-%m-%dT%H:%M:%SZ" 7 | -------------------------------------------------------------------------------- /fintoc/resources/subscription.py: -------------------------------------------------------------------------------- 1 | """Module to hold the Subscription resource.""" 2 | 3 | from fintoc.mixins import ResourceMixin 4 | 5 | 6 | class Subscription(ResourceMixin): 7 | """Represents a Fintoc Subscription.""" 8 | -------------------------------------------------------------------------------- /fintoc/resources/tobacco_taxes.py: -------------------------------------------------------------------------------- 1 | """Module to hold the TobaccoTaxes resource.""" 2 | 3 | from fintoc.mixins import ResourceMixin 4 | 5 | 6 | class TobaccoTaxes(ResourceMixin): 7 | """Represents a Fintoc Tobacco Taxes.""" 8 | -------------------------------------------------------------------------------- /fintoc/resources/refresh_intent.py: -------------------------------------------------------------------------------- 1 | """Module to hold the RefreshIntent resource.""" 2 | 3 | from fintoc.mixins import ResourceMixin 4 | 5 | 6 | class RefreshIntent(ResourceMixin): 7 | """Represents a Fintoc Refresh Intent.""" 8 | -------------------------------------------------------------------------------- /fintoc/resources/v2/account_number.py: -------------------------------------------------------------------------------- 1 | """Module to hold the AccountNumber resource.""" 2 | 3 | from fintoc.mixins import ResourceMixin 4 | 5 | 6 | class AccountNumber(ResourceMixin): 7 | """Represents a Fintoc AccountNumber.""" 8 | -------------------------------------------------------------------------------- /fintoc/resources/checkout_session.py: -------------------------------------------------------------------------------- 1 | """Module to hold the CheckoutSession resource.""" 2 | 3 | from fintoc.mixins import ResourceMixin 4 | 5 | 6 | class CheckoutSession(ResourceMixin): 7 | """Represents a Fintoc CheckoutSession.""" 8 | -------------------------------------------------------------------------------- /fintoc/resources/generic_fintoc_resource.py: -------------------------------------------------------------------------------- 1 | """Module to hold a generic resource.""" 2 | 3 | from fintoc.mixins import ResourceMixin 4 | 5 | 6 | class GenericFintocResource(ResourceMixin): 7 | """Represents a Generic Fintoc Resource.""" 8 | -------------------------------------------------------------------------------- /fintoc/resources/services_invoice.py: -------------------------------------------------------------------------------- 1 | """Module to hold the ServicesInvoice resource.""" 2 | 3 | from fintoc.mixins import ResourceMixin 4 | 5 | 6 | class ServicesInvoice(ResourceMixin): 7 | """Represents a Fintoc Services Invoice.""" 8 | -------------------------------------------------------------------------------- /fintoc/resources/transfer_account.py: -------------------------------------------------------------------------------- 1 | """Module to hold the TransferAccount resource.""" 2 | 3 | from fintoc.mixins import ResourceMixin 4 | 5 | 6 | class TransferAccount(ResourceMixin): 7 | """Represents a Fintoc Transfer Account.""" 8 | -------------------------------------------------------------------------------- /fintoc/resources/webhook_endpoint.py: -------------------------------------------------------------------------------- 1 | """Module to hold the WebhookEndpoint resource.""" 2 | 3 | from fintoc.mixins import ResourceMixin 4 | 5 | 6 | class WebhookEndpoint(ResourceMixin): 7 | """Represents a Fintoc Webhook Endpoint.""" 8 | -------------------------------------------------------------------------------- /fintoc/resources/institution_invoice.py: -------------------------------------------------------------------------------- 1 | """Module to hold the InstitutionInvoice resource.""" 2 | 3 | from fintoc.mixins import ResourceMixin 4 | 5 | 6 | class InstitutionInvoice(ResourceMixin): 7 | """Represents a Fintoc Institution Invoice.""" 8 | -------------------------------------------------------------------------------- /fintoc/resources/subscription_intent.py: -------------------------------------------------------------------------------- 1 | """Module to hold the SubscriptionIntent resource.""" 2 | 3 | from fintoc.mixins import ResourceMixin 4 | 5 | 6 | class SubscriptionIntent(ResourceMixin): 7 | """Represents a Fintoc Subscription Intent.""" 8 | -------------------------------------------------------------------------------- /fintoc/resources/v2/account_verification.py: -------------------------------------------------------------------------------- 1 | """Module to hold the AccountNumber resource.""" 2 | 3 | from fintoc.mixins import ResourceMixin 4 | 5 | 6 | class AccountVerification(ResourceMixin): 7 | """Represents a Fintoc AccountVerification.""" 8 | -------------------------------------------------------------------------------- /fintoc/resources/v2/movement.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=duplicate-code 2 | """Module to hold the Movement resource.""" 3 | 4 | from fintoc.mixins import ResourceMixin 5 | 6 | 7 | class Movement(ResourceMixin): 8 | """Represents a Fintoc Movement.""" 9 | -------------------------------------------------------------------------------- /fintoc/resources/institution_tax_return.py: -------------------------------------------------------------------------------- 1 | """Module to hold the InstitutionTaxReturn resource.""" 2 | 3 | from fintoc.mixins import ResourceMixin 4 | 5 | 6 | class InstitutionTaxReturn(ResourceMixin): 7 | """Represents a Fintoc Institution Tax Return.""" 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python, linters and tests stuff 2 | __pycache__ 3 | .mypy_cache 4 | .pytest_cache 5 | .coverage 6 | coverage.xml 7 | 8 | # Build stuff 9 | build/ 10 | dist/ 11 | *.egg 12 | *.egg-info/ 13 | 14 | # Editor stuff 15 | .vscode 16 | 17 | # Virtual Environment 18 | .venv 19 | -------------------------------------------------------------------------------- /fintoc/managers/invoices_manager.py: -------------------------------------------------------------------------------- 1 | """Module to hold the invoices manager.""" 2 | 3 | from fintoc.mixins import ManagerMixin 4 | 5 | 6 | class InvoicesManager(ManagerMixin): 7 | 8 | """Represents an invoices manager.""" 9 | 10 | resource = "invoice" 11 | methods = ["list"] 12 | -------------------------------------------------------------------------------- /fintoc/managers/v2/entities_manager.py: -------------------------------------------------------------------------------- 1 | """Module to hold the entities manager.""" 2 | 3 | from fintoc.mixins import ManagerMixin 4 | 5 | 6 | class EntitiesManager(ManagerMixin): 7 | """Represents an entities manager.""" 8 | 9 | resource = "entity" 10 | methods = ["list", "get"] 11 | -------------------------------------------------------------------------------- /fintoc/managers/v2/movements_manager.py: -------------------------------------------------------------------------------- 1 | """Module to hold the movements manager.""" 2 | 3 | from fintoc.mixins import ManagerMixin 4 | 5 | 6 | class MovementsManager(ManagerMixin): 7 | """Represents a movements manager.""" 8 | 9 | resource = "movement" 10 | methods = ["list", "get"] 11 | -------------------------------------------------------------------------------- /fintoc/managers/charges_manager.py: -------------------------------------------------------------------------------- 1 | """Module to hold the charges manager.""" 2 | 3 | from fintoc.mixins import ManagerMixin 4 | 5 | 6 | class ChargesManager(ManagerMixin): 7 | 8 | """Represents a charges manager.""" 9 | 10 | resource = "charge" 11 | methods = ["list", "get", "create"] 12 | -------------------------------------------------------------------------------- /fintoc/managers/movements_manager.py: -------------------------------------------------------------------------------- 1 | """Module to hold the movements manager.""" 2 | 3 | from fintoc.mixins import ManagerMixin 4 | 5 | 6 | class MovementsManager(ManagerMixin): 7 | 8 | """Represents a movements manager.""" 9 | 10 | resource = "movement" 11 | methods = ["list", "get"] 12 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE/release.md: -------------------------------------------------------------------------------- 1 | # Version X.X.X 🎉 2 | 3 | ## Additions ➕ 4 | 5 | - Include here what was added with this version. 6 | 7 | ## Changes ➖ 8 | 9 | - Include here what was changed with this version. 10 | 11 | ## Fixes 🐛 12 | 13 | - Include here what was fixed with this version. 14 | -------------------------------------------------------------------------------- /fintoc/managers/tax_returns_manager.py: -------------------------------------------------------------------------------- 1 | """Module to hold the tax_returns manager.""" 2 | 3 | from fintoc.mixins import ManagerMixin 4 | 5 | 6 | class TaxReturnsManager(ManagerMixin): 7 | 8 | """Represents a tax_returns manager.""" 9 | 10 | resource = "tax_return" 11 | methods = ["list", "get"] 12 | -------------------------------------------------------------------------------- /fintoc/managers/subscriptions_manager.py: -------------------------------------------------------------------------------- 1 | """Module to hold the subscriptions manager.""" 2 | 3 | from fintoc.mixins import ManagerMixin 4 | 5 | 6 | class SubscriptionsManager(ManagerMixin): 7 | 8 | """Represents a subscriptions manager.""" 9 | 10 | resource = "subscription" 11 | methods = ["list", "get"] 12 | -------------------------------------------------------------------------------- /fintoc/resources/invoice.py: -------------------------------------------------------------------------------- 1 | """Module to hold the Invoice resource.""" 2 | 3 | from fintoc.mixins import ResourceMixin 4 | 5 | 6 | class Invoice(ResourceMixin): 7 | 8 | """Represents a Fintoc Invoice.""" 9 | 10 | mappings = { 11 | "issuer": "taxpayer", 12 | "receiver": "taxpayer", 13 | } 14 | -------------------------------------------------------------------------------- /fintoc/resources/v2/__init__.py: -------------------------------------------------------------------------------- 1 | """Init file for the v2 resources module of the SDK.""" 2 | 3 | from .account import Account 4 | from .account_number import AccountNumber 5 | from .account_verification import AccountVerification 6 | from .entity import Entity 7 | from .movement import Movement 8 | from .transfer import Transfer 9 | -------------------------------------------------------------------------------- /fintoc/managers/refresh_intents_manager.py: -------------------------------------------------------------------------------- 1 | """Module to hold the refresh_intents manager.""" 2 | 3 | from fintoc.mixins import ManagerMixin 4 | 5 | 6 | class RefreshIntentsManager(ManagerMixin): 7 | 8 | """Represents a refresh_intents manager.""" 9 | 10 | resource = "refresh_intent" 11 | methods = ["list", "get", "create"] 12 | -------------------------------------------------------------------------------- /fintoc/managers/v2/account_numbers_manager.py: -------------------------------------------------------------------------------- 1 | """Module to hold the account numbers manager.""" 2 | 3 | from fintoc.mixins import ManagerMixin 4 | 5 | 6 | class AccountNumbersManager(ManagerMixin): 7 | """Represents an account numbers manager.""" 8 | 9 | resource = "account_number" 10 | methods = ["list", "get", "update", "create"] 11 | -------------------------------------------------------------------------------- /fintoc/resources/movement.py: -------------------------------------------------------------------------------- 1 | """Module to hold the Movement resource.""" 2 | 3 | from fintoc.mixins import ResourceMixin 4 | 5 | 6 | class Movement(ResourceMixin): 7 | 8 | """Represents a Fintoc Movement.""" 9 | 10 | mappings = { 11 | "recipient_account": "transfer_account", 12 | "sender_account": "transfer_account", 13 | } 14 | -------------------------------------------------------------------------------- /fintoc/managers/subscription_intents_manager.py: -------------------------------------------------------------------------------- 1 | """Module to hold the subscription_intents manager.""" 2 | 3 | from fintoc.mixins import ManagerMixin 4 | 5 | 6 | class SubscriptionIntentsManager(ManagerMixin): 7 | 8 | """Represents a subscription_intents manager.""" 9 | 10 | resource = "subscription_intent" 11 | methods = ["list", "get", "create"] 12 | -------------------------------------------------------------------------------- /fintoc/managers/v2/account_verifications_manager.py: -------------------------------------------------------------------------------- 1 | """Module to hold the account verification manager.""" 2 | 3 | from fintoc.mixins import ManagerMixin 4 | 5 | 6 | class AccountVerificationsManager(ManagerMixin): 7 | """Represents an account verification manager.""" 8 | 9 | resource = "account_verification" 10 | methods = ["list", "get", "create"] 11 | -------------------------------------------------------------------------------- /fintoc/managers/webhook_endpoints_manager.py: -------------------------------------------------------------------------------- 1 | """Module to hold the webhook_endpoints manager.""" 2 | 3 | from fintoc.mixins import ManagerMixin 4 | 5 | 6 | class WebhookEndpointsManager(ManagerMixin): 7 | 8 | """Represents a webhook_endpoints manager.""" 9 | 10 | resource = "webhook_endpoint" 11 | methods = ["list", "get", "create", "update", "delete"] 12 | -------------------------------------------------------------------------------- /fintoc/resources/payment_intent.py: -------------------------------------------------------------------------------- 1 | """Module to hold the PaymentIntent resource.""" 2 | 3 | from fintoc.mixins import ResourceMixin 4 | 5 | 6 | class PaymentIntent(ResourceMixin): 7 | 8 | """Represents a Fintoc Payment Intent.""" 9 | 10 | mappings = { 11 | "recipient_account": "transfer_account", 12 | "sender_account": "transfer_account", 13 | } 14 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | Releasing 2 | ========= 3 | 4 | 1. From `master`, bump the package version, using `make bump! minor` (you can bump `patch`, `minor` or `major`). 5 | 2. Push the new branch to `origin`. 6 | 3. After merging the bumped version to `master`, make a Pull Request from `master` to `stable`. Make sure to include every change, using the template located at `.github/PULL_REQUEST_TEMPLATE/release.md`. 7 | 4. Merge the Pull Request. 8 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | [Description of the pull request] 4 | 5 | ## Requirements 6 | 7 | [Additional actions that have to be done for the pull request to work (such as adding a secret to the repository)] 8 | 9 | None. 10 | 11 | ## Additional changes 12 | 13 | [Changes done in this pull request that are additional to the purpose of the branch (such as formatting code or deleting files)] 14 | 15 | None. 16 | -------------------------------------------------------------------------------- /fintoc/managers/v2/__init__.py: -------------------------------------------------------------------------------- 1 | """Init file for the v2 managers module of the SDK.""" 2 | 3 | from .account_numbers_manager import AccountNumbersManager 4 | from .account_verifications_manager import AccountVerificationsManager 5 | from .accounts_manager import AccountsManager 6 | from .entities_manager import EntitiesManager 7 | from .movements_manager import MovementsManager 8 | from .simulate_manager import SimulateManager 9 | from .transfers_manager import TransfersManager 10 | -------------------------------------------------------------------------------- /fintoc/managers/v2/simulate_manager.py: -------------------------------------------------------------------------------- 1 | """Module to hold the simulate manager.""" 2 | 3 | from fintoc.mixins import ManagerMixin 4 | 5 | 6 | class SimulateManager(ManagerMixin): 7 | """Represents a simulate manager for testing purposes.""" 8 | 9 | resource = "transfer" 10 | methods = ["receive_transfer"] 11 | 12 | def _receive_transfer(self, **kwargs): 13 | path = f"{self._build_path(**kwargs)}/receive_transfer" 14 | return self._create(path_=path, **kwargs) 15 | -------------------------------------------------------------------------------- /fintoc/managers/v2/transfers_manager.py: -------------------------------------------------------------------------------- 1 | """Module to hold the transfers manager.""" 2 | 3 | from fintoc.mixins import ManagerMixin 4 | 5 | 6 | class TransfersManager(ManagerMixin): 7 | """Represents a transfers manager.""" 8 | 9 | resource = "transfer" 10 | methods = ["list", "get", "create", "return_"] 11 | 12 | def _return_(self, **kwargs): 13 | """Return a transfer.""" 14 | path = f"{self._build_path(**kwargs)}/return" 15 | return self._create(path_=path, **kwargs) 16 | -------------------------------------------------------------------------------- /fintoc/managers/refunds_manager.py: -------------------------------------------------------------------------------- 1 | """Module to hold the refunds manager.""" 2 | 3 | from fintoc.mixins import ManagerMixin 4 | 5 | 6 | class RefundsManager(ManagerMixin): 7 | 8 | """Represents a refunds manager.""" 9 | 10 | resource = "refund" 11 | methods = ["list", "get", "create", "cancel"] 12 | 13 | def _cancel(self, identifier, **kwargs): 14 | """Expire a refund.""" 15 | path = f"{self._build_path(**kwargs)}/{identifier}/cancel" 16 | return self._create(path_=path, **kwargs) 17 | -------------------------------------------------------------------------------- /fintoc/managers/checkout_sessions_manager.py: -------------------------------------------------------------------------------- 1 | """Module to hold the checkout sessions manager.""" 2 | 3 | from fintoc.mixins import ManagerMixin 4 | 5 | 6 | class CheckoutSessionsManager(ManagerMixin): 7 | """Represents a checkout sessions manager.""" 8 | 9 | resource = "checkout_session" 10 | methods = ["create", "get", "expire"] 11 | 12 | def _expire(self, identifier, **kwargs): 13 | """Expire a checkout session.""" 14 | path = f"{self._build_path(**kwargs)}/{identifier}/expire" 15 | return self._create(path_=path, **kwargs) 16 | -------------------------------------------------------------------------------- /fintoc/managers/payment_links_manager.py: -------------------------------------------------------------------------------- 1 | """Module to hold the payment_links manager.""" 2 | 3 | from fintoc.mixins import ManagerMixin 4 | 5 | 6 | class PaymentLinksManager(ManagerMixin): 7 | 8 | """Represents a payment_links manager.""" 9 | 10 | resource = "payment_link" 11 | methods = ["list", "get", "create", "cancel"] 12 | 13 | def _cancel(self, identifier, **kwargs): 14 | """Cancel a payment link.""" 15 | path = f"{self._build_path(**kwargs)}/{identifier}/cancel" 16 | return self._update(identifier, path_=path, **kwargs) 17 | -------------------------------------------------------------------------------- /fintoc/managers/payment_intents_manager.py: -------------------------------------------------------------------------------- 1 | """Module to hold the payment_intents manager.""" 2 | 3 | from fintoc.mixins import ManagerMixin 4 | 5 | 6 | class PaymentIntentsManager(ManagerMixin): 7 | 8 | """Represents a payment_intents manager.""" 9 | 10 | resource = "payment_intent" 11 | methods = ["list", "get", "create", "expire", "check_eligibility"] 12 | 13 | def _expire(self, identifier, **kwargs): 14 | """Expire a payment intent.""" 15 | path = f"{self._build_path(**kwargs)}/{identifier}/expire" 16 | return self._create(path_=path, **kwargs) 17 | 18 | def _check_eligibility(self, **kwargs): 19 | """Check eligibility for a payment intent.""" 20 | path = f"{self._build_path(**kwargs)}/check_eligibility" 21 | return self._create(path_=path, **kwargs) 22 | -------------------------------------------------------------------------------- /fintoc/managers/links_manager.py: -------------------------------------------------------------------------------- 1 | """Module to hold the links manager.""" 2 | 3 | from fintoc.mixins import ManagerMixin 4 | 5 | 6 | class LinksManager(ManagerMixin): 7 | 8 | """Represents a links manager.""" 9 | 10 | resource = "link" 11 | methods = ["list", "get", "update", "delete"] 12 | 13 | def post_get_handler(self, object_, identifier, **kwargs): 14 | # pylint: disable=protected-access 15 | object_._client = self._client.extend(params={"link_token": identifier}) 16 | object_._link_token = identifier 17 | return object_ 18 | 19 | def post_update_handler(self, object_, identifier, **kwargs): 20 | # pylint: disable=protected-access 21 | object_._client = self._client.extend(params={"link_token": identifier}) 22 | object_._link_token = identifier 23 | return object_ 24 | -------------------------------------------------------------------------------- /fintoc/managers/__init__.py: -------------------------------------------------------------------------------- 1 | """Init file for the managers module of the SDK.""" 2 | 3 | from .accounts_manager import AccountsManager 4 | from .charges_manager import ChargesManager 5 | from .checkout_sessions_manager import CheckoutSessionsManager 6 | from .invoices_manager import InvoicesManager 7 | from .links_manager import LinksManager 8 | from .movements_manager import MovementsManager 9 | from .payment_intents_manager import PaymentIntentsManager 10 | from .payment_links_manager import PaymentLinksManager 11 | from .refresh_intents_manager import RefreshIntentsManager 12 | from .refunds_manager import RefundsManager 13 | from .subscription_intents_manager import SubscriptionIntentsManager 14 | from .subscriptions_manager import SubscriptionsManager 15 | from .tax_returns_manager import TaxReturnsManager 16 | from .webhook_endpoints_manager import WebhookEndpointsManager 17 | -------------------------------------------------------------------------------- /fintoc/resources/account.py: -------------------------------------------------------------------------------- 1 | """Module to hold the Account resource.""" 2 | 3 | from fintoc.managers import MovementsManager 4 | from fintoc.mixins import ResourceMixin 5 | 6 | 7 | class Account(ResourceMixin): 8 | 9 | """Represents a Fintoc Account.""" 10 | 11 | def __init__(self, client, handlers, methods, path, **kwargs): 12 | super().__init__(client, handlers, methods, path, **kwargs) 13 | self.__movements_manager = None 14 | 15 | @property 16 | def movements(self): 17 | """Proxies the movements manager.""" 18 | if self.__movements_manager is None: 19 | self.__movements_manager = MovementsManager( 20 | f"/v1/accounts/{self.id}/movements", self._client 21 | ) 22 | return self.__movements_manager 23 | 24 | @movements.setter 25 | def movements(self, new_value): # pylint: disable=no-self-use 26 | raise NameError("Attribute name corresponds to a manager") 27 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "fintoc" 3 | version = "2.14.0" 4 | description = "The official Python client for the Fintoc API." 5 | authors = ["Daniel Leal ", "Nebil Kawas "] 6 | maintainers = ["Daniel Leal "] 7 | license = "BSD-3-Clause" 8 | readme = "README.md" 9 | homepage = "https://fintoc.com/" 10 | repository = "https://github.com/fintoc-com/fintoc-python" 11 | exclude = [ 12 | ".github", 13 | ".coveragerc", 14 | ".flake8", 15 | ".pylintrc", 16 | "Makefile", 17 | "scripts", 18 | "tests" 19 | ] 20 | 21 | [tool.poetry.dependencies] 22 | python = "^3.7" 23 | httpx = ">=0.16, < 1.0" 24 | cryptography = "<44.0.0" 25 | 26 | [tool.poetry.dev-dependencies] 27 | black = "^22.10.0" 28 | flake8 = "^3.8.4" 29 | isort = "^5.6.4" 30 | pylint = "^2.6.0" 31 | pytest = "^6.1.1" 32 | pytest-cov = "^2.12.1" 33 | 34 | [tool.poetry.urls] 35 | "Issue Tracker" = "https://github.com/fintoc-com/fintoc-python/issues" 36 | 37 | [tool.poetry.group.test.dependencies] 38 | freezegun = "^1.5.1" 39 | -------------------------------------------------------------------------------- /fintoc/managers/accounts_manager.py: -------------------------------------------------------------------------------- 1 | """Module to hold the accounts manager.""" 2 | 3 | from fintoc.managers.movements_manager import MovementsManager 4 | from fintoc.mixins import ManagerMixin 5 | 6 | 7 | # pylint: disable=duplicate-code 8 | class AccountsManager(ManagerMixin): 9 | 10 | """Represents an accounts manager.""" 11 | 12 | resource = "account" 13 | methods = ["list", "get"] 14 | 15 | def __init__(self, path, client): 16 | super().__init__(path, client) 17 | self.__movements_manager = None 18 | 19 | @property 20 | def movements(self): 21 | """Proxies the movements manager.""" 22 | if self.__movements_manager is None: 23 | self.__movements_manager = MovementsManager( 24 | "/v1/accounts/{account_id}/movements", 25 | self._client, 26 | ) 27 | return self.__movements_manager 28 | 29 | @movements.setter 30 | def movements(self, new_value): # pylint: disable=no-self-use 31 | raise NameError("Attribute name corresponds to a manager") 32 | -------------------------------------------------------------------------------- /fintoc/managers/v2/accounts_manager.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=duplicate-code 2 | """Module to hold the accounts manager.""" 3 | 4 | from fintoc.managers.v2.movements_manager import MovementsManager 5 | from fintoc.mixins import ManagerMixin 6 | 7 | 8 | class AccountsManager(ManagerMixin): 9 | """Represents an accounts manager.""" 10 | 11 | resource = "account" 12 | methods = ["list", "get", "create", "update"] 13 | 14 | def __init__(self, path, client): 15 | super().__init__(path, client) 16 | self.__movements_manager = None 17 | 18 | @property 19 | def movements(self): 20 | """Proxies the movements manager.""" 21 | if self.__movements_manager is None: 22 | self.__movements_manager = MovementsManager( 23 | "/v2/accounts/{account_id}/movements", 24 | self._client, 25 | ) 26 | return self.__movements_manager 27 | 28 | @movements.setter 29 | def movements(self, new_value): # pylint: disable=no-self-use 30 | raise NameError("Attribute name corresponds to a manager") 31 | -------------------------------------------------------------------------------- /tests/test_core.py: -------------------------------------------------------------------------------- 1 | from fintoc.client import Client 2 | from fintoc.core import Fintoc 3 | from fintoc.mixins import ManagerMixin 4 | 5 | 6 | class TestCoreFintocObject: 7 | def test_object_creations(self): 8 | # pylint: disable=protected-access 9 | api_key = "super_secret_api_key" 10 | fintoc = Fintoc(api_key) 11 | assert isinstance(fintoc._client, Client) 12 | assert isinstance(fintoc.links, ManagerMixin) 13 | assert isinstance(fintoc.webhook_endpoints, ManagerMixin) 14 | 15 | def test_fintoc_creation_with_api_version(self): 16 | # pylint: disable=protected-access 17 | api_key = "super_secret_api_key" 18 | api_version = "2023-01-01" 19 | fintoc = Fintoc(api_key, api_version) 20 | assert fintoc._client.headers["Fintoc-Version"] == api_version 21 | 22 | def test_fintoc_creation_without_api_version(self): 23 | # pylint: disable=protected-access 24 | api_key = "super_secret_api_key" 25 | fintoc = Fintoc(api_key) 26 | assert "Fintoc-Version" not in fintoc._client.headers 27 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - stable 8 | pull_request: 9 | paths: 10 | - ".github/workflows/tests.yml" 11 | - "Makefile" 12 | - "fintoc/**/*.py" 13 | - "tests/**/*.py" 14 | 15 | jobs: 16 | pytest: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout to commit code 20 | uses: actions/checkout@v3 21 | 22 | - name: Set up Python 23 | uses: actions/setup-python@v4 24 | with: 25 | python-version: "3.10.6" 26 | 27 | - name: Install Poetry 28 | run: | 29 | make get-poetry 30 | echo $HOME/.poetry/bin >> $GITHUB_PATH 31 | 32 | - name: Set up environment cache 33 | uses: actions/cache@v3 34 | id: environment-cache 35 | with: 36 | key: environment-cache-v1-${{ hashFiles('**/poetry.lock') }} 37 | path: .venv 38 | 39 | - name: Install dependencies 40 | if: steps.poetry-cache.outputs.cache-hit != 'true' 41 | run: poetry install 42 | 43 | - name: Run Pytest 44 | run: make tests 45 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | POETRY_VERSION = 2.1.2 2 | 3 | # Env stuff 4 | .PHONY: get-poetry 5 | get-poetry: 6 | curl -sSL https://install.python-poetry.org | python3 - --version $(POETRY_VERSION) 7 | 8 | .PHONY: build-env 9 | build-env: 10 | python3 -m venv .venv 11 | poetry run pip install --upgrade pip 12 | poetry run poetry install 13 | 14 | # Tests 15 | .PHONY: tests 16 | tests: 17 | poetry run pytest -rP --cov=fintoc --cov-report=term-missing --cov-report=xml tests 18 | 19 | # Passive linters 20 | .PHONY: black 21 | black: 22 | poetry run black fintoc tests --check 23 | 24 | .PHONY: flake8 25 | flake8: 26 | poetry run flake8 fintoc tests 27 | 28 | .PHONY: isort 29 | isort: 30 | poetry run isort fintoc tests --profile=black --check 31 | 32 | .PHONY: pylint 33 | pylint: 34 | poetry run pylint fintoc 35 | 36 | # Aggresive linters 37 | .PHONY: black! 38 | black!: 39 | poetry run black fintoc tests 40 | 41 | .PHONY: isort! 42 | isort!: 43 | poetry run isort fintoc tests --profile=black 44 | 45 | # Utilities 46 | .PHONY: bump! 47 | bump!: 48 | sh scripts/bump.sh $(filter-out $@,$(MAKECMDGOALS)) 49 | 50 | # Receive args (use like `$(filter-out $@,$(MAKECMDGOALS))`) 51 | %: 52 | @: 53 | -------------------------------------------------------------------------------- /fintoc/resources/__init__.py: -------------------------------------------------------------------------------- 1 | """Init file for the resources module of the SDK.""" 2 | 3 | from .account import Account 4 | from .balance import Balance 5 | from .charge import Charge 6 | from .checkout_session import CheckoutSession 7 | from .generic_fintoc_resource import GenericFintocResource 8 | from .income import Income 9 | from .institution import Institution 10 | from .institution_invoice import InstitutionInvoice 11 | from .institution_tax_return import InstitutionTaxReturn 12 | from .invoice import Invoice 13 | from .link import Link 14 | from .movement import Movement 15 | from .other_taxes import OtherTaxes 16 | from .payment_intent import PaymentIntent 17 | from .payment_link import PaymentLink 18 | from .refresh_intent import RefreshIntent 19 | from .services_invoice import ServicesInvoice 20 | from .subscription import Subscription 21 | from .subscription_intent import SubscriptionIntent 22 | from .tax_return import TaxReturn 23 | from .taxpayer import Taxpayer 24 | from .tobacco_taxes import TobaccoTaxes 25 | from .transfer_account import TransferAccount 26 | from .v2.account import Account as AccountV2 27 | from .v2.account_number import AccountNumber 28 | from .v2.account_verification import AccountVerification 29 | from .v2.entity import Entity 30 | from .v2.transfer import Transfer 31 | from .webhook_endpoint import WebhookEndpoint 32 | -------------------------------------------------------------------------------- /scripts/bump.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ $(git symbolic-ref --short HEAD) != master ]; then 4 | echo "This script is supposed to be run on the \"master\" branch." 5 | exit 1 6 | fi 7 | 8 | if [ -z $1 ]; then 9 | echo "A bump rule (\"patch\", \"minor\", \"major\") must be passed as a parameter." 10 | exit 1 11 | fi 12 | 13 | # Get old version 14 | OLD_VERSION=$(poetry version | rev | cut -d' ' -f1 | rev) 15 | 16 | # Bump up pyproject version and get new version 17 | poetry version $1 && NEW_VERSION=$(poetry version | rev | cut -d' ' -f1 | rev) 18 | 19 | # Get the scripts directory name and the base directory name 20 | SCRIPTS=$(cd $(dirname $0) && pwd) 21 | BASEDIR=$(dirname $SCRIPTS) 22 | 23 | # Get the version file 24 | VERSION_FILE="$BASEDIR/fintoc/version.py" 25 | 26 | # Get substitution strings 27 | OLD_VERSION_SUBSTITUTION=$(echo $OLD_VERSION | sed "s/\./, /g") 28 | NEW_VERSION_SUBSTITUTION=$(echo $NEW_VERSION | sed "s/\./, /g") 29 | 30 | # Substitute the version in the python version file 31 | sed -i.tmp "s#$OLD_VERSION_SUBSTITUTION#$NEW_VERSION_SUBSTITUTION#g" $VERSION_FILE && rm $VERSION_FILE.tmp 32 | 33 | # Commit changes into release branch 34 | git add $BASEDIR/pyproject.toml $BASEDIR/fintoc/version.py && 35 | git checkout -b release/prepare-$NEW_VERSION && 36 | git commit --message "pre-release: prepare $NEW_VERSION release" 37 | -------------------------------------------------------------------------------- /fintoc/errors.py: -------------------------------------------------------------------------------- 1 | """Module to hold the Fintoc custom errors.""" 2 | 3 | 4 | class FintocError(Exception): 5 | 6 | """Represents the base custom error.""" 7 | 8 | def __init__(self, error_data): 9 | error_type = error_data.get("type") 10 | error_code = error_data.get("code") 11 | error_message = error_data.get("message") 12 | error_param = error_data.get("param") 13 | error_doc_url = error_data.get("doc_url") 14 | message = error_type 15 | message += f": {error_code}" if error_code is not None else "" 16 | message += f" ({error_param})" if error_param is not None else "" 17 | message += f"\n{error_message}" 18 | message += ( 19 | f"\nCheck the docs for more info: {error_doc_url}" 20 | if error_doc_url is not None 21 | else "" 22 | ) 23 | super().__init__(message) 24 | 25 | 26 | class ApiError(FintocError): 27 | """Represents an error with the API server.""" 28 | 29 | 30 | class AuthenticationError(FintocError): 31 | """Represents an error with the authentication.""" 32 | 33 | 34 | class LinkError(FintocError): 35 | """Represents an error with a Link object.""" 36 | 37 | 38 | class InstitutionError(FintocError): 39 | """Represents an error with an Institution object.""" 40 | 41 | 42 | class InvalidRequestError(FintocError): 43 | """Represents an error because of an invalid request.""" 44 | 45 | 46 | class WebhookSignatureError(Exception): 47 | """Exception raised for webhook signature validation errors.""" 48 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright © 2020, [Fintoc](https://fintoc.com/). 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of the author nor the names of contributors may be used to 15 | endorse or promote products derived from this software without specific prior 16 | written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /tests/managers/test_links_manager.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from fintoc.client import Client 4 | from fintoc.managers import LinksManager 5 | 6 | 7 | class TestLinksManagerHandlers: 8 | @pytest.fixture(autouse=True) 9 | def patch_http_client(self, patch_http_client): 10 | pass 11 | 12 | def setup_method(self): 13 | self.base_url = "https://test.com" 14 | self.api_key = "super_secret_api_key" 15 | self.api_version = None 16 | self.user_agent = "fintoc-python/test" 17 | self.params = {"first_param": "first_value", "second_param": "second_value"} 18 | self.client = Client( 19 | self.base_url, 20 | self.api_key, 21 | self.api_version, 22 | self.user_agent, 23 | params=self.params, 24 | ) 25 | self.path = "/links" 26 | self.manager = LinksManager(self.path, self.client) 27 | 28 | def test_post_get_handler(self): 29 | # pylint: disable=protected-access 30 | id_ = "idx" 31 | object_ = self.manager.get(id_) 32 | assert object_._client is not self.manager._client 33 | assert "link_token" not in self.manager._client.params 34 | assert "link_token" in object_._client.params 35 | 36 | def test_post_update_handler(self): 37 | # pylint: disable=protected-access 38 | id_ = "idx" 39 | object_ = self.manager.update(id_) 40 | assert object_._client is not self.manager._client 41 | assert "link_token" not in self.manager._client.params 42 | assert "link_token" in object_._client.params 43 | -------------------------------------------------------------------------------- /tests/resources/test_account.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from fintoc.client import Client 4 | from fintoc.mixins import ManagerMixin 5 | from fintoc.resources import Account 6 | 7 | 8 | class TestAccountResource: 9 | def setup_method(self): 10 | self.base_url = "https://test.com" 11 | self.api_key = "super_secret_api_key" 12 | self.api_version = None 13 | self.user_agent = "fintoc-python/test" 14 | self.params = {"first_param": "first_value", "second_param": "second_value"} 15 | self.client = Client( 16 | self.base_url, 17 | self.api_key, 18 | self.api_version, 19 | self.user_agent, 20 | params=self.params, 21 | ) 22 | self.path = "/accounts" 23 | self.handlers = { 24 | "update": lambda object_, identifier: object_, 25 | "delete": lambda identifier: identifier, 26 | } 27 | 28 | def test_movements_manager(self): 29 | # pylint: disable=protected-access 30 | account = Account(self.client, self.handlers, [], self.path, **{"id": "idx"}) 31 | 32 | assert account._Account__movements_manager is None 33 | assert isinstance(account.movements, ManagerMixin) 34 | assert account._Account__movements_manager is not None 35 | assert isinstance(account._Account__movements_manager, ManagerMixin) 36 | 37 | with pytest.raises(NameError): 38 | account.movements = None 39 | 40 | assert account.movements is not None 41 | assert isinstance(account.movements, ManagerMixin) 42 | assert isinstance(account._Account__movements_manager, ManagerMixin) 43 | -------------------------------------------------------------------------------- /examples/webhook.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from flask import Flask, request 4 | 5 | from fintoc.webhook import WebhookSignature 6 | from fintoc.errors import WebhookSignatureError 7 | 8 | app = Flask(__name__) 9 | 10 | # Find your endpoint's secret in your webhook settings in the Fintoc Dashboard 11 | WEBHOOK_SECRET = os.getenv('FINTOC_WEBHOOK_SECRET') 12 | 13 | @app.route('/webhook', methods=['POST']) 14 | def handle_webhook(): 15 | """ 16 | Handle incoming webhooks from Fintoc. 17 | 18 | Validates the webhook signature and processes the payload if valid. 19 | """ 20 | # Get the signature header 21 | signature = request.headers.get('Fintoc-Signature') 22 | if not signature: 23 | return 'No signature header', 400 24 | 25 | # Get the raw request payload 26 | payload = request.get_data().decode('utf-8') 27 | 28 | try: 29 | # Verify the webhook signature 30 | WebhookSignature.verify_header( 31 | payload=payload, 32 | header=signature, 33 | secret=WEBHOOK_SECRET 34 | ) 35 | 36 | # If verification passes, process the webhook 37 | data = request.json 38 | 39 | # Here you can handle different webhook types 40 | webhook_type = data.get('type') 41 | if webhook_type == 'payment_intent.succeeded': 42 | print('Payment was succeeded!') 43 | elif webhook_type == 'payment_intent.failed': 44 | print('Payment failed') 45 | # Add more webhook types as needed 46 | 47 | return 'success', 200 48 | 49 | except WebhookSignatureError as e: 50 | print('Invalid signature!') 51 | return str(e), 400 52 | except Exception as e: 53 | return 'Internal server error', 500 54 | 55 | if __name__ == '__main__': 56 | # For development only - use proper WSGI server in production 57 | app.run(port=5000, debug=True) 58 | -------------------------------------------------------------------------------- /fintoc/paginator.py: -------------------------------------------------------------------------------- 1 | """Module to hold every utility used for pagination purposes.""" 2 | 3 | import re 4 | from functools import reduce 5 | 6 | import httpx 7 | 8 | from fintoc.constants import LINK_HEADER_PATTERN 9 | 10 | 11 | def paginate( 12 | client: httpx.Client, 13 | url: str, 14 | params: dict[str, str] = {}, 15 | headers: dict[str, str] = {}, 16 | ): 17 | """ 18 | Fetch a paginated resource and return a generator with all of 19 | its instances. 20 | """ 21 | response = request(client, url, params=params, headers=headers) 22 | elements = response["elements"] 23 | for element in elements: 24 | yield element 25 | while response.get("next"): 26 | response = request(client, response.get("next"), headers=headers) 27 | elements = response["elements"] 28 | for element in elements: 29 | yield element 30 | 31 | 32 | def request( 33 | client: httpx.Client, 34 | url: str, 35 | params: dict[str, str] = {}, 36 | headers: dict[str, str] = {}, 37 | ): 38 | """ 39 | Fetch a page of a resource and return its elements and the next 40 | page of the resource. 41 | """ 42 | _request = httpx.Request("get", url, params=params, headers=headers) 43 | response = client.send(_request) 44 | response.raise_for_status() 45 | headers = parse_link_headers(response.headers.get("link")) 46 | next_ = headers and headers.get("next") 47 | elements = response.json() 48 | return { 49 | "next": next_, 50 | "elements": elements, 51 | } 52 | 53 | 54 | def parse_link_headers(link_header: str): 55 | """ 56 | Receive the 'link' header and return a dictionary with 57 | every key: value instance present on the header. 58 | """ 59 | if not link_header: 60 | return None 61 | return reduce(parse_link, link_header.split(","), {}) 62 | 63 | 64 | def parse_link(dictionary: dict[str, str], link: str): 65 | """ 66 | Receive a dictionary with already parsed key: values from the 67 | 'link' header along with another link and return the original 68 | dictionary with a new entry corresponding to the link received. 69 | """ 70 | matches = re.match(LINK_HEADER_PATTERN, link.strip()).groupdict() 71 | dictionary[matches["rel"]] = matches["url"] 72 | return {**dictionary, matches["rel"]: matches["url"]} 73 | -------------------------------------------------------------------------------- /fintoc/resource_handlers.py: -------------------------------------------------------------------------------- 1 | """Module for the methods that handle te resources.""" 2 | 3 | from fintoc.utils import objetize, objetize_generator 4 | 5 | 6 | def resource_list(client, path, klass, handlers, methods, params): 7 | """List all the instances of a resource.""" 8 | lazy = params.pop("lazy", True) 9 | data = client.request(path, paginated=True, params=params) 10 | if lazy: 11 | return objetize_generator( 12 | data, 13 | klass, 14 | client, 15 | handlers=handlers, 16 | methods=methods, 17 | path=path, 18 | ) 19 | return [ 20 | objetize( 21 | klass, 22 | client, 23 | element, 24 | handlers=handlers, 25 | methods=methods, 26 | path=path, 27 | ) 28 | for element in data 29 | ] 30 | 31 | 32 | def resource_get(client, path, id_, klass, handlers, methods, params): 33 | """Fetch a specific instance of a resource.""" 34 | data = client.request(f"{path}/{id_}", method="get", params=params) 35 | return objetize( 36 | klass, 37 | client, 38 | data, 39 | handlers=handlers, 40 | methods=methods, 41 | path=path, 42 | ) 43 | 44 | 45 | def resource_create( 46 | client, path, klass, handlers, methods, params, idempotency_key=None 47 | ): 48 | """Create a new instance of a resource.""" 49 | data = client.request( 50 | path, method="post", json=params, idempotency_key=idempotency_key 51 | ) 52 | return objetize( 53 | klass, 54 | client, 55 | data, 56 | handlers=handlers, 57 | methods=methods, 58 | path=path, 59 | ) 60 | 61 | 62 | def resource_update( 63 | client, path, id_, klass, handlers, methods, params, custom_path=None 64 | ): 65 | """Update a specific instance of a resource.""" 66 | update_path = custom_path if custom_path else f"{path}/{id_}" 67 | data = client.request(update_path, method="patch", json=params) 68 | return objetize( 69 | klass, 70 | client, 71 | data, 72 | handlers, 73 | methods, 74 | path, 75 | ) 76 | 77 | 78 | def resource_delete(client, path, id_, params): 79 | """Delete an instance of a resource.""" 80 | return client.request(f"{path}/{id_}", method="delete", params=params) 81 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | branches: 6 | - stable 7 | 8 | jobs: 9 | pypi-release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout to commit code 13 | uses: actions/checkout@v3 14 | 15 | - name: Set up Python 16 | uses: actions/setup-python@v4 17 | with: 18 | python-version: "3.10.6" 19 | 20 | - name: Install Poetry 21 | run: | 22 | make get-poetry 23 | echo $HOME/.poetry/bin >> $GITHUB_PATH 24 | 25 | - name: Set up environment cache 26 | uses: actions/cache@v3 27 | id: environment-cache 28 | with: 29 | key: environment-cache-v1-${{ hashFiles('**/poetry.lock') }} 30 | path: .venv 31 | 32 | - name: Install dependencies 33 | if: steps.poetry-cache.outputs.cache-hit != 'true' 34 | run: poetry install 35 | 36 | - name: Build the package 37 | run: poetry build 38 | 39 | - name: Publish the package 40 | uses: pypa/gh-action-pypi-publish@release/v1 41 | with: 42 | user: __token__ 43 | password: ${{ secrets.PYPI_API_TOKEN }} 44 | 45 | github-release: 46 | needs: pypi-release 47 | 48 | runs-on: ubuntu-latest 49 | steps: 50 | - name: Checkout to commit code 51 | uses: actions/checkout@v3 52 | 53 | - name: Set up Python 54 | uses: actions/setup-python@v4 55 | with: 56 | python-version: "3.10.6" 57 | 58 | - name: Install Poetry 59 | run: | 60 | make get-poetry 61 | echo $HOME/.poetry/bin >> $GITHUB_PATH 62 | 63 | - name: Set up environment cache 64 | uses: actions/cache@v3 65 | id: environment-cache 66 | with: 67 | key: environment-cache-v1-${{ hashFiles('**/poetry.lock') }} 68 | path: .venv 69 | 70 | - name: Install dependencies 71 | if: steps.poetry-cache.outputs.cache-hit != 'true' 72 | run: poetry install 73 | 74 | - name: Get version 75 | id: version 76 | run: echo ::set-output name=version::$(poetry version | rev | cut -d' ' -f1 | rev) 77 | 78 | - name: Get Pull Request data 79 | uses: jwalton/gh-find-current-pr@v1 80 | id: find-pr 81 | with: 82 | state: all 83 | 84 | - name: Tag and Release 85 | uses: actions/create-release@latest 86 | env: 87 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 88 | with: 89 | tag_name: ${{ steps.version.outputs.version }} 90 | release_name: ${{ steps.version.outputs.version }} 91 | body: | 92 | ${{ steps.find-pr.outputs.body }} 93 | draft: false 94 | prerelease: false 95 | -------------------------------------------------------------------------------- /fintoc/core.py: -------------------------------------------------------------------------------- 1 | """ 2 | Core module to house the Fintoc object of the Fintoc Python SDK. 3 | """ 4 | 5 | from fintoc.client import Client 6 | from fintoc.constants import API_BASE_URL 7 | from fintoc.managers import ( 8 | AccountsManager, 9 | ChargesManager, 10 | CheckoutSessionsManager, 11 | InvoicesManager, 12 | LinksManager, 13 | PaymentIntentsManager, 14 | PaymentLinksManager, 15 | RefreshIntentsManager, 16 | RefundsManager, 17 | SubscriptionIntentsManager, 18 | SubscriptionsManager, 19 | TaxReturnsManager, 20 | WebhookEndpointsManager, 21 | ) 22 | from fintoc.managers.v2 import AccountNumbersManager 23 | from fintoc.managers.v2 import AccountsManager as AccountsManagerV2 24 | from fintoc.managers.v2 import ( 25 | AccountVerificationsManager, 26 | EntitiesManager, 27 | SimulateManager, 28 | TransfersManager, 29 | ) 30 | from fintoc.version import __version__ 31 | 32 | 33 | # pylint: disable=too-many-instance-attributes 34 | class Fintoc: 35 | 36 | """Encapsulates the core object's behaviour and methods.""" 37 | 38 | def __init__(self, api_key, api_version=None, jws_private_key=None): 39 | self._client = Client( 40 | base_url=f"{API_BASE_URL}", 41 | api_key=api_key, 42 | api_version=api_version, 43 | user_agent=f"fintoc-python/{__version__}", 44 | jws_private_key=jws_private_key, 45 | ) 46 | self.charges = ChargesManager("/v1/charges", self._client) 47 | self.checkout_sessions = CheckoutSessionsManager( 48 | "/v1/checkout_sessions", self._client 49 | ) 50 | self.links = LinksManager("/v1/links", self._client) 51 | self.payment_intents = PaymentIntentsManager( 52 | "/v1/payment_intents", self._client 53 | ) 54 | self.payment_links = PaymentLinksManager("/v1/payment_links", self._client) 55 | self.refunds = RefundsManager("/v1/refunds", self._client) 56 | self.subscriptions = SubscriptionsManager("/v1/subscriptions", self._client) 57 | self.subscription_intents = SubscriptionIntentsManager( 58 | "/v1/subscription_intents", self._client 59 | ) 60 | self.webhook_endpoints = WebhookEndpointsManager( 61 | "/v1/webhook_endpoints", self._client 62 | ) 63 | self.accounts = AccountsManager("/v1/accounts", self._client) 64 | self.refresh_intents = RefreshIntentsManager( 65 | "/v1/refresh_intents", self._client 66 | ) 67 | self.tax_returns = TaxReturnsManager("/v1/tax_returns", self._client) 68 | self.invoices = InvoicesManager("/v1/invoices", self._client) 69 | 70 | self.v2 = _FintocV2(self._client) 71 | 72 | 73 | class _FintocV2: 74 | def __init__(self, client): 75 | self.transfers = TransfersManager("/v2/transfers", client) 76 | self.accounts = AccountsManagerV2("/v2/accounts", client) 77 | self.account_numbers = AccountNumbersManager("/v2/account_numbers", client) 78 | self.account_verifications = AccountVerificationsManager( 79 | "/v2/account_verifications", client 80 | ) 81 | self.entities = EntitiesManager("/v2/entities", client) 82 | self.simulate = SimulateManager("/v2/simulate", client) 83 | -------------------------------------------------------------------------------- /fintoc/resources/link.py: -------------------------------------------------------------------------------- 1 | """Module to hold the Link resource.""" 2 | 3 | from fintoc.managers import ( 4 | AccountsManager, 5 | InvoicesManager, 6 | RefreshIntentsManager, 7 | SubscriptionsManager, 8 | TaxReturnsManager, 9 | ) 10 | from fintoc.mixins import ResourceMixin 11 | 12 | 13 | class Link(ResourceMixin): 14 | 15 | """Represents a Fintoc Link.""" 16 | 17 | resource_identifier = "_link_token" 18 | 19 | def __init__(self, client, handlers, methods, path, **kwargs): 20 | super().__init__(client, handlers, methods, path, **kwargs) 21 | self.__accounts_manager = None 22 | self.__subscriptions_manager = None 23 | self.__tax_returns_manager = None 24 | self.__invoices_manager = None 25 | self.__refresh_intents_manager = None 26 | 27 | @property 28 | def accounts(self): 29 | """Proxies the accounts manager.""" 30 | if self.__accounts_manager is None: 31 | self.__accounts_manager = AccountsManager("/v1/accounts", self._client) 32 | return self.__accounts_manager 33 | 34 | @accounts.setter 35 | def accounts(self, new_value): # pylint: disable=no-self-use 36 | raise NameError("Attribute name corresponds to a manager") 37 | 38 | @property 39 | def subscriptions(self): 40 | # TODO: this method should be deprecated as it's no longer allowed 41 | # in our API 42 | """Proxies the subscriptions manager.""" 43 | if self.__subscriptions_manager is None: 44 | self.__subscriptions_manager = SubscriptionsManager( 45 | "/v1/subscriptions", self._client 46 | ) 47 | return self.__subscriptions_manager 48 | 49 | @subscriptions.setter 50 | def subscriptions(self, new_value): # pylint: disable=no-self-use 51 | raise NameError("Attribute name corresponds to a manager") 52 | 53 | @property 54 | def tax_returns(self): 55 | """Proxies the tax_returns manager.""" 56 | if self.__tax_returns_manager is None: 57 | self.__tax_returns_manager = TaxReturnsManager( 58 | "/v1/tax_returns", self._client 59 | ) 60 | return self.__tax_returns_manager 61 | 62 | @tax_returns.setter 63 | def tax_returns(self, new_value): # pylint: disable=no-self-use 64 | raise NameError("Attribute name corresponds to a manager") 65 | 66 | @property 67 | def invoices(self): 68 | """Proxies the invoices manager.""" 69 | if self.__invoices_manager is None: 70 | self.__invoices_manager = InvoicesManager("/v1/invoices", self._client) 71 | return self.__invoices_manager 72 | 73 | @invoices.setter 74 | def invoices(self, new_value): # pylint: disable=no-self-use 75 | raise NameError("Attribute name corresponds to a manager") 76 | 77 | @property 78 | def refresh_intents(self): 79 | """Proxies the refresh_intents manager.""" 80 | if self.__refresh_intents_manager is None: 81 | self.__refresh_intents_manager = RefreshIntentsManager( 82 | "/v1/refresh_intents", self._client 83 | ) 84 | return self.__refresh_intents_manager 85 | 86 | @refresh_intents.setter 87 | def refresh_intents(self, new_value): # pylint: disable=no-self-use 88 | raise NameError("Attribute name corresponds to a manager") 89 | -------------------------------------------------------------------------------- /fintoc/mixins/resource_mixin.py: -------------------------------------------------------------------------------- 1 | """Module to hold the mixin for the resources.""" 2 | 3 | from abc import ABCMeta 4 | 5 | from fintoc.resource_handlers import resource_delete, resource_update 6 | from fintoc.utils import ( 7 | can_raise_fintoc_error, 8 | get_resource_class, 9 | objetize, 10 | serialize, 11 | singularize, 12 | ) 13 | 14 | 15 | class ResourceMixin(metaclass=ABCMeta): 16 | 17 | """Represents the mixin for the resources.""" 18 | 19 | mappings = {} 20 | resource_identifier = "id" 21 | 22 | def __init__(self, client, handlers, methods, path, **kwargs): 23 | self._client = client 24 | self._handlers = handlers 25 | self._methods = methods 26 | self._path = path 27 | self._attributes = [] 28 | 29 | for key, value in kwargs.items(): 30 | try: 31 | resource = self.__class__.mappings.get(key, key) 32 | if isinstance(value, list): 33 | resource = singularize(resource) 34 | element = {} if not value else value[0] 35 | klass = get_resource_class(resource, value=element) 36 | setattr(self, key, [objetize(klass, client, x) for x in value]) 37 | else: 38 | klass = get_resource_class(resource, value=value) 39 | setattr(self, key, objetize(klass, client, value)) 40 | self._attributes.append(key) 41 | except NameError: # pragma: no cover 42 | pass 43 | 44 | def __getattr__(self, attr): 45 | if attr not in self._methods: 46 | raise AttributeError( 47 | f"{self.__class__.__name__} has no attribute '{attr.lstrip('_')}'" 48 | ) 49 | return getattr(self, f"_{attr}") 50 | 51 | def serialize(self): 52 | """Serialize the resource.""" 53 | serialized = {} 54 | for key in self._attributes: 55 | element = ( 56 | [serialize(x) for x in self.__dict__[key]] 57 | if isinstance(self.__dict__[key], list) 58 | else serialize(self.__dict__[key]) 59 | ) 60 | serialized = {**serialized, key: element} 61 | return serialized 62 | 63 | @can_raise_fintoc_error 64 | def _update(self, path_=None, **kwargs): 65 | """Update the resource.""" 66 | id_ = getattr(self, self.__class__.resource_identifier) 67 | custom_path = path_ if path_ else None 68 | object_ = resource_update( 69 | client=self._client, 70 | path=self._path, 71 | id_=id_, 72 | klass=self.__class__, 73 | handlers=self._handlers, 74 | methods=self._methods, 75 | params=kwargs, 76 | custom_path=custom_path, 77 | ) 78 | object_ = self._handlers.get("update")(object_, id_, **kwargs) 79 | self.__dict__.update(object_.__dict__) 80 | return self 81 | 82 | @can_raise_fintoc_error 83 | def _delete(self, **kwargs): 84 | identifier = getattr(self, self.__class__.resource_identifier) 85 | resource_delete( 86 | client=self._client, 87 | path=self._path, 88 | id_=self.id, 89 | params=kwargs, 90 | ) 91 | return self._handlers.get("delete")(identifier, **kwargs) 92 | -------------------------------------------------------------------------------- /.github/workflows/linters.yml: -------------------------------------------------------------------------------- 1 | name: linters 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - ".github/workflows/linters.yml" 7 | - ".flake8" 8 | - ".pylintrc" 9 | - "mypy.ini" 10 | - "Makefile" 11 | - "fintoc/**/*.py" 12 | - "tests/**/*.py" 13 | 14 | jobs: 15 | black: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout to commit code 19 | uses: actions/checkout@v3 20 | 21 | - name: Set up Python 22 | uses: actions/setup-python@v4 23 | with: 24 | python-version: "3.10.6" 25 | 26 | - name: Install Poetry 27 | run: | 28 | make get-poetry 29 | echo $HOME/.poetry/bin >> $GITHUB_PATH 30 | 31 | - name: Set up environment cache 32 | uses: actions/cache@v3 33 | id: environment-cache 34 | with: 35 | key: environment-cache-v1-${{ hashFiles('**/poetry.lock') }} 36 | path: .venv 37 | 38 | - name: Install dependencies 39 | if: steps.environment-cache.outputs.cache-hit != 'true' 40 | run: poetry install 41 | 42 | - name: Lint with Black 43 | run: make black 44 | 45 | flake8: 46 | runs-on: ubuntu-latest 47 | steps: 48 | - name: Checkout to commit code 49 | uses: actions/checkout@v3 50 | 51 | - name: Set up Python 52 | uses: actions/setup-python@v4 53 | with: 54 | python-version: "3.10.6" 55 | 56 | - name: Install Poetry 57 | run: | 58 | make get-poetry 59 | echo $HOME/.poetry/bin >> $GITHUB_PATH 60 | 61 | - name: Set up environment cache 62 | uses: actions/cache@v3 63 | id: environment-cache 64 | with: 65 | key: environment-cache-v1-${{ hashFiles('**/poetry.lock') }} 66 | path: .venv 67 | 68 | - name: Install dependencies 69 | if: steps.environment-cache.outputs.cache-hit != 'true' 70 | run: poetry install 71 | 72 | - name: Lint with Flake8 73 | run: make flake8 74 | 75 | isort: 76 | runs-on: ubuntu-latest 77 | steps: 78 | - name: Checkout to commit code 79 | uses: actions/checkout@v3 80 | 81 | - name: Set up Python 82 | uses: actions/setup-python@v4 83 | with: 84 | python-version: "3.10.6" 85 | 86 | - name: Install Poetry 87 | run: | 88 | make get-poetry 89 | echo $HOME/.poetry/bin >> $GITHUB_PATH 90 | 91 | - name: Set up environment cache 92 | uses: actions/cache@v3 93 | id: environment-cache 94 | with: 95 | key: environment-cache-v1-${{ hashFiles('**/poetry.lock') }} 96 | path: .venv 97 | 98 | - name: Install dependencies 99 | if: steps.environment-cache.outputs.cache-hit != 'true' 100 | run: poetry install 101 | 102 | - name: Lint with Isort 103 | run: make isort 104 | 105 | pylint: 106 | runs-on: ubuntu-latest 107 | steps: 108 | - name: Checkout to commit code 109 | uses: actions/checkout@v3 110 | 111 | - name: Set up Python 112 | uses: actions/setup-python@v4 113 | with: 114 | python-version: "3.10.6" 115 | 116 | - name: Install Poetry 117 | run: | 118 | make get-poetry 119 | echo $HOME/.poetry/bin >> $GITHUB_PATH 120 | 121 | - name: Set up environment cache 122 | uses: actions/cache@v3 123 | id: environment-cache 124 | with: 125 | key: environment-cache-v1-${{ hashFiles('**/poetry.lock') }} 126 | path: .venv 127 | 128 | - name: Install dependencies 129 | if: steps.environment-cache.outputs.cache-hit != 'true' 130 | run: poetry install 131 | 132 | - name: Lint with Pylint 133 | run: make pylint 134 | -------------------------------------------------------------------------------- /tests/test_paginator.py: -------------------------------------------------------------------------------- 1 | from types import GeneratorType 2 | 3 | import httpx 4 | import pytest 5 | 6 | from fintoc.paginator import paginate, parse_link, parse_link_headers, request 7 | 8 | 9 | class TestParseLink: 10 | def test_link_over_empty_dictionary(self): 11 | next_url = "https://api.fintoc.com/v1/links?page=2" 12 | dictionary = {} 13 | link = f'<{next_url}>; rel="next"' 14 | parsed = parse_link(dictionary, link) 15 | assert isinstance(parsed, dict) 16 | assert "next" in parsed 17 | assert len(parsed.keys()) == 1 18 | assert parsed["next"] == next_url 19 | 20 | def test_link_over_used_dictionary(self): 21 | next_url = "https://api.fintoc.com/v1/links?page=2" 22 | last_url = "https://api.fintoc.com/v1/links?page=13" 23 | dictionary = {"last": last_url} 24 | link = f'<{next_url}>; rel="next"' 25 | parsed = parse_link(dictionary, link) 26 | assert isinstance(parsed, dict) 27 | assert "last" in parsed 28 | assert "next" in parsed 29 | assert len(parsed.keys()) == 2 30 | assert parsed["last"] == last_url 31 | assert parsed["next"] == next_url 32 | 33 | def test_overwrite_dictionary(self): 34 | curr_url = "https://api.fintoc.com/v1/links?page=2" 35 | next_url = "https://api.fintoc.com/v1/links?page=4" 36 | last_url = "https://api.fintoc.com/v1/links?page=13" 37 | dictionary = {"next": curr_url, "last": last_url} 38 | link = f'<{next_url}>; rel="next"' 39 | parsed = parse_link(dictionary, link) 40 | assert isinstance(parsed, dict) 41 | assert "last" in parsed 42 | assert "next" in parsed 43 | assert len(parsed.keys()) == 2 44 | assert parsed["last"] == last_url 45 | assert parsed["next"] != curr_url 46 | assert parsed["next"] == next_url 47 | 48 | 49 | class TestParseLinkHeaders: 50 | def test_empty_header(self): 51 | parsed = parse_link_headers(None) 52 | assert parsed is None 53 | 54 | def test_parse_header(self): 55 | next_url = "https://api.fintoc.com/v1/links?page=2" 56 | last_url = "https://api.fintoc.com/v1/links?page=13" 57 | link_header = f'<{next_url}>; rel="next", <{last_url}>; rel="last"' 58 | parsed = parse_link_headers(link_header) 59 | assert isinstance(parsed, dict) 60 | assert "next" in parsed 61 | assert "last" in parsed 62 | assert len(parsed.keys()) == 2 63 | assert parsed["next"] == next_url 64 | assert parsed["last"] == last_url 65 | 66 | 67 | class TestRequest: 68 | @pytest.fixture(autouse=True) 69 | def patch_http_client(self, patch_http_client): 70 | pass 71 | 72 | def test_request_response(self): 73 | client = httpx.Client(base_url="https://test.com") 74 | data = request(client, "/movements") 75 | assert "next" in data 76 | assert "elements" in data 77 | assert isinstance(data["elements"], list) 78 | 79 | def test_request_params_get_passed_to_next_url(self): 80 | client = httpx.Client(base_url="https://test.com") 81 | data = request(client, "/movements", params={"link_token": "sample_link_token"}) 82 | assert "next" in data 83 | assert "link_token=sample_link_token" in data["next"] 84 | 85 | 86 | class TestPaginate: 87 | @pytest.fixture(autouse=True) 88 | def patch_http_client(self, patch_http_client): 89 | pass 90 | 91 | def test_pagination(self): 92 | client = httpx.Client(base_url="https://test.com") 93 | data = paginate(client, "/movements", {}, {}) 94 | assert isinstance(data, GeneratorType) 95 | 96 | elements = list(data) 97 | assert len(elements) == 100 98 | 99 | for element in elements: 100 | assert isinstance(element, dict) 101 | -------------------------------------------------------------------------------- /fintoc/jws.py: -------------------------------------------------------------------------------- 1 | """Module to handle JWS signature generation for Fintoc API requests.""" 2 | 3 | import base64 4 | import json 5 | import os 6 | import secrets 7 | import time 8 | from pathlib import Path 9 | from typing import Union 10 | 11 | from cryptography.hazmat.primitives import hashes, serialization 12 | from cryptography.hazmat.primitives.asymmetric import padding 13 | 14 | 15 | class JWSSignature: 16 | """Class to handle JWS signature generation for Fintoc API requests.""" 17 | 18 | def __init__(self, private_key: Union[str, bytes, Path]): 19 | """ 20 | Initialize the JWSSignature with a private key. 21 | 22 | Args: 23 | private_key: The RSA private key in one of these formats: 24 | - String containing PEM-formatted key 25 | - Bytes containing PEM-formatted key 26 | - Path object or string path to a PEM key file 27 | 28 | Example: 29 | >>> # From a string 30 | >>> with open('private_key.pem', 'r') as f: 31 | ... key_str = f.read() 32 | >>> jws = JWSSignature(key_str) 33 | >>> 34 | >>> # From a file path 35 | >>> jws = JWSSignature('private_key.pem') 36 | >>> 37 | >>> # From environment variable 38 | >>> jws = JWSSignature(os.environ.get('PRIVATE_KEY')) 39 | """ 40 | if isinstance(private_key, (str, Path)) and os.path.isfile(str(private_key)): 41 | with open(private_key, "rb") as key_file: 42 | private_key_bytes = key_file.read() 43 | elif isinstance(private_key, str): 44 | private_key_bytes = private_key.encode() 45 | elif isinstance(private_key, bytes): 46 | private_key_bytes = private_key 47 | else: 48 | raise ValueError( 49 | "private_key must be a PEM string, bytes, or a path to a key file" 50 | ) 51 | 52 | self.private_key = serialization.load_pem_private_key( 53 | private_key_bytes, password=None 54 | ) 55 | 56 | def generate_header(self, raw_body: Union[str, dict]) -> str: 57 | """ 58 | Generate a JWS signature header for Fintoc API requests. 59 | 60 | Args: 61 | raw_body: The request body as a string or dict. If dict, it will be 62 | converted to JSON. 63 | 64 | Returns: 65 | str: The JWS signature header to be used in the 'Fintoc-JWS-Signature' 66 | header. 67 | 68 | Example: 69 | >>> jws = JWSSignature(private_key) 70 | >>> body = {"amount": 1000, "currency": "CLP"} 71 | >>> jws_header = jws.generate_header(body) 72 | >>> # Use in request headers: 73 | >>> headers = { 74 | ... 'Fintoc-JWS-Signature': jws_header, 75 | ... 'Authorization': 'Bearer sk_test...' 76 | ... } 77 | """ 78 | if isinstance(raw_body, dict): 79 | raw_body = json.dumps(raw_body) 80 | 81 | headers = { 82 | "alg": "RS256", 83 | "nonce": secrets.token_hex(16), 84 | "ts": int(time.time()), 85 | "crit": ["ts", "nonce"], 86 | } 87 | 88 | protected_base64 = ( 89 | base64.urlsafe_b64encode(json.dumps(headers).encode()).rstrip(b"=").decode() 90 | ) 91 | payload_base64 = ( 92 | base64.urlsafe_b64encode(raw_body.encode()).rstrip(b"=").decode() 93 | ) 94 | signing_input = f"{protected_base64}.{payload_base64}" 95 | signature_raw = self.private_key.sign( 96 | signing_input.encode(), padding.PKCS1v15(), hashes.SHA256() 97 | ) 98 | signature_base64 = base64.urlsafe_b64encode(signature_raw).rstrip(b"=").decode() 99 | 100 | return f"{protected_base64}.{signature_base64}" 101 | -------------------------------------------------------------------------------- /fintoc/client.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module to house the Client object of the Fintoc Python SDK. 3 | """ 4 | 5 | import urllib 6 | import uuid 7 | from json.decoder import JSONDecodeError 8 | 9 | import httpx 10 | 11 | from fintoc.jws import JWSSignature 12 | from fintoc.paginator import paginate 13 | 14 | 15 | class Client: 16 | """Encapsulates the client behaviour and methods.""" 17 | 18 | _client = httpx.Client() 19 | 20 | def __init__( 21 | self, 22 | base_url, 23 | api_key, 24 | api_version, 25 | user_agent, 26 | jws_private_key=None, 27 | params={}, 28 | ): 29 | self.base_url = base_url 30 | self.api_key = api_key 31 | self.user_agent = user_agent 32 | self.params = params 33 | self.api_version = api_version 34 | self.headers = self._get_static_headers() 35 | self.__jws = JWSSignature(jws_private_key) if jws_private_key else None 36 | 37 | def _get_static_headers(self): 38 | """Return the headers that do not change per request.""" 39 | headers = { 40 | "Authorization": self.api_key, 41 | "User-Agent": self.user_agent, 42 | } 43 | 44 | if self.api_version is not None: 45 | headers["Fintoc-Version"] = self.api_version 46 | 47 | return headers 48 | 49 | def _get_base_headers(self, method, idempotency_key=None): 50 | headers = dict(self.headers) 51 | 52 | if method.lower() == "post": 53 | headers["Idempotency-Key"] = idempotency_key or str(uuid.uuid4()) 54 | 55 | return headers 56 | 57 | def _build_jws_header(self, method, raw_body=None): 58 | if self.__jws and raw_body and method.lower() in ["post", "put", "patch"]: 59 | jws_header = self.__jws.generate_header(raw_body) 60 | return {"Fintoc-JWS-Signature": jws_header} 61 | 62 | return {} 63 | 64 | def _build_request(self, method, url, params, headers, json=None): 65 | request = httpx.Request(method, url, headers=headers, params=params, json=json) 66 | 67 | request.headers.update( 68 | self._build_jws_header(method, request.content.decode("utf-8")) 69 | ) 70 | return request 71 | 72 | def request( 73 | self, 74 | path, 75 | paginated=False, 76 | method="get", 77 | params=None, 78 | json=None, 79 | idempotency_key=None, 80 | ): 81 | """ 82 | Uses the internal httpx client to make a simple or paginated request. 83 | """ 84 | url = ( 85 | path 86 | if urllib.parse.urlparse(path).scheme 87 | else f"{self.base_url}/{path.lstrip('/')}" 88 | ) 89 | headers = self._get_base_headers(method, idempotency_key=idempotency_key) 90 | all_params = {**self.params, **params} if params else self.params 91 | 92 | if paginated: 93 | return paginate( 94 | self._client, 95 | url, 96 | params=all_params, 97 | headers=headers, 98 | ) 99 | 100 | _request = self._build_request(method, url, all_params, headers, json) 101 | response = self._client.send(_request) 102 | response.raise_for_status() 103 | try: 104 | return response.json() 105 | except JSONDecodeError: 106 | return {} 107 | 108 | def extend( 109 | self, 110 | base_url=None, 111 | api_key=None, 112 | user_agent=None, 113 | params=None, 114 | ): 115 | """ 116 | Creates a new instance using the data of the current object, 117 | overwriting parts of it using the method parameters. 118 | """ 119 | return Client( 120 | base_url=base_url or self.base_url, 121 | api_key=api_key or self.api_key, 122 | api_version=self.api_version, 123 | user_agent=user_agent or self.user_agent, 124 | params={**self.params, **params} if params else self.params, 125 | ) 126 | -------------------------------------------------------------------------------- /tests/resources/test_link.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from fintoc.client import Client 4 | from fintoc.mixins import ManagerMixin 5 | from fintoc.resources import Link 6 | 7 | 8 | class TestLinkResource: 9 | def setup_method(self): 10 | self.base_url = "https://test.com" 11 | self.api_key = "super_secret_api_key" 12 | self.api_version = None 13 | self.user_agent = "fintoc-python/test" 14 | self.params = {"first_param": "first_value", "second_param": "second_value"} 15 | self.client = Client( 16 | self.base_url, 17 | self.api_key, 18 | self.api_version, 19 | self.user_agent, 20 | params=self.params, 21 | ) 22 | self.path = "/links" 23 | self.handlers = { 24 | "update": lambda object_, identifier: object_, 25 | "delete": lambda identifier: identifier, 26 | } 27 | 28 | def test_accounts_manager(self): 29 | # pylint: disable=protected-access 30 | link = Link(self.client, self.handlers, [], self.path, **{}) 31 | 32 | assert link._Link__accounts_manager is None 33 | assert isinstance(link.accounts, ManagerMixin) 34 | assert link._Link__accounts_manager is not None 35 | assert isinstance(link._Link__accounts_manager, ManagerMixin) 36 | 37 | with pytest.raises(NameError): 38 | link.accounts = None 39 | 40 | assert link.accounts is not None 41 | assert isinstance(link.accounts, ManagerMixin) 42 | assert isinstance(link._Link__accounts_manager, ManagerMixin) 43 | 44 | def test_subscriptions_manager(self): 45 | # pylint: disable=protected-access 46 | link = Link(self.client, self.handlers, [], self.path, **{}) 47 | 48 | assert link._Link__subscriptions_manager is None 49 | assert isinstance(link.subscriptions, ManagerMixin) 50 | assert link._Link__subscriptions_manager is not None 51 | assert isinstance(link._Link__subscriptions_manager, ManagerMixin) 52 | 53 | with pytest.raises(NameError): 54 | link.subscriptions = None 55 | 56 | assert link.subscriptions is not None 57 | assert isinstance(link.subscriptions, ManagerMixin) 58 | assert isinstance(link._Link__subscriptions_manager, ManagerMixin) 59 | 60 | def test_tax_returns_manager(self): 61 | # pylint: disable=protected-access 62 | link = Link(self.client, self.handlers, [], self.path, **{}) 63 | 64 | assert link._Link__tax_returns_manager is None 65 | assert isinstance(link.tax_returns, ManagerMixin) 66 | assert link._Link__tax_returns_manager is not None 67 | assert isinstance(link._Link__tax_returns_manager, ManagerMixin) 68 | 69 | with pytest.raises(NameError): 70 | link.tax_returns = None 71 | 72 | assert link.tax_returns is not None 73 | assert isinstance(link.tax_returns, ManagerMixin) 74 | assert isinstance(link._Link__tax_returns_manager, ManagerMixin) 75 | 76 | def test_invoices_manager(self): 77 | # pylint: disable=protected-access 78 | link = Link(self.client, self.handlers, [], self.path, **{}) 79 | 80 | assert link._Link__invoices_manager is None 81 | assert isinstance(link.invoices, ManagerMixin) 82 | assert link._Link__invoices_manager is not None 83 | assert isinstance(link._Link__invoices_manager, ManagerMixin) 84 | 85 | with pytest.raises(NameError): 86 | link.invoices = None 87 | 88 | assert link.invoices is not None 89 | assert isinstance(link.invoices, ManagerMixin) 90 | assert isinstance(link._Link__invoices_manager, ManagerMixin) 91 | 92 | def test_refresh_intents_manager(self): 93 | # pylint: disable=protected-access 94 | link = Link(self.client, self.handlers, [], self.path, **{}) 95 | 96 | assert link._Link__refresh_intents_manager is None 97 | assert isinstance(link.refresh_intents, ManagerMixin) 98 | assert link._Link__refresh_intents_manager is not None 99 | assert isinstance(link._Link__refresh_intents_manager, ManagerMixin) 100 | 101 | with pytest.raises(NameError): 102 | link.refresh_intents = None 103 | 104 | assert link.refresh_intents is not None 105 | assert isinstance(link.refresh_intents, ManagerMixin) 106 | assert isinstance(link._Link__refresh_intents_manager, ManagerMixin) 107 | -------------------------------------------------------------------------------- /fintoc/utils.py: -------------------------------------------------------------------------------- 1 | """Module to hold every generalized utility on the SDK.""" 2 | 3 | import datetime 4 | import functools 5 | import warnings 6 | from importlib import import_module 7 | 8 | import httpx 9 | 10 | from fintoc.constants import DATE_TIME_PATTERN 11 | from fintoc.errors import FintocError 12 | 13 | 14 | def snake_to_pascal(snake_string): 15 | """Return the snake-cased string as pascal case.""" 16 | return "".join(word.title() for word in snake_string.split("_")) 17 | 18 | 19 | def singularize(string): 20 | """Remove the last 's' from a string if exists.""" 21 | return string.rstrip("s") 22 | 23 | 24 | def is_iso_datetime(string): 25 | """ 26 | Try to parse a string as an ISO date. If it succeeds, return True. 27 | Otherwise, return False. 28 | """ 29 | try: 30 | datetime.datetime.strptime(string, DATE_TIME_PATTERN) 31 | return True 32 | except ValueError: 33 | pass 34 | try: 35 | datetime.datetime.strptime(string, "%Y-%m-%dT%H:%M:%S.%fZ") 36 | return True 37 | except ValueError: 38 | pass 39 | return False 40 | 41 | 42 | def get_resource_class(snake_resource_name, value={}): 43 | """ 44 | Get the class that corresponds to a resource using its 45 | name (in snake case) and its value. 46 | """ 47 | if isinstance(value, dict): 48 | module = import_module("fintoc.resources") 49 | try: 50 | return getattr(module, snake_to_pascal(snake_resource_name)) 51 | except AttributeError: 52 | return getattr(module, "GenericFintocResource") 53 | if isinstance(value, str) and is_iso_datetime(value): 54 | return objetize_datetime 55 | return type(value) 56 | 57 | 58 | def get_error_class(snake_error_name): 59 | """ 60 | Given an error name (in snake case), return the appropriate 61 | error class. 62 | """ 63 | module = import_module("fintoc.errors") 64 | return getattr(module, snake_to_pascal(snake_error_name), FintocError) 65 | 66 | 67 | def can_raise_fintoc_error(function): 68 | """ 69 | Decorator that catches HTTPStatusError exceptions and raises custom 70 | Fintoc errors instead. 71 | """ 72 | 73 | def wrapper(*args, **kwargs): 74 | try: 75 | return function(*args, **kwargs) 76 | except httpx.HTTPStatusError as exc: 77 | error_data = exc.response.json() 78 | error = get_error_class(error_data["error"]["type"]) 79 | raise error(error_data["error"]) from None 80 | 81 | return wrapper 82 | 83 | 84 | def serialize(object_): 85 | """Serializes an object.""" 86 | if callable(getattr(object_, "serialize", None)): 87 | return object_.serialize() 88 | if isinstance(object_, datetime.datetime): 89 | return object_.strftime(DATE_TIME_PATTERN) 90 | return object_ 91 | 92 | 93 | def objetize_datetime(string): 94 | """Objetizes a datetime string without checking for correctness.""" 95 | try: 96 | return datetime.datetime.strptime(string, DATE_TIME_PATTERN) 97 | except ValueError: 98 | return datetime.datetime.strptime(string, "%Y-%m-%dT%H:%M:%S.%fZ") 99 | 100 | 101 | def objetize(klass, client, data, handlers={}, methods=[], path=None): 102 | """Transform the :data: object into an object with class :klass:.""" 103 | if data is None: 104 | return None 105 | if klass in [str, int, dict, bool, objetize_datetime]: 106 | return klass(data) 107 | return klass(client, handlers, methods, path, **data) 108 | 109 | 110 | def objetize_generator(generator, klass, client, handlers={}, methods=[], path=None): 111 | """ 112 | Transform a generator of dictionaries into a generator of 113 | objects with class :klass:. 114 | """ 115 | for element in generator: 116 | yield objetize(klass, client, element, handlers, methods, path) 117 | 118 | 119 | def deprecate(message=None): 120 | """ 121 | Decorator to mark functions or methods as deprecated. 122 | """ 123 | 124 | def decorator(func): 125 | @functools.wraps(func) 126 | def wrapper(*args, **kwargs): 127 | warning_message = message or ( 128 | f"{func.__name__} is deprecated and will be removed in a" 129 | " future version." 130 | ) 131 | warnings.warn(warning_message, category=DeprecationWarning, stacklevel=2) 132 | return func(*args, **kwargs) 133 | 134 | return wrapper 135 | 136 | return decorator 137 | -------------------------------------------------------------------------------- /fintoc/webhook.py: -------------------------------------------------------------------------------- 1 | """ 2 | Webhook signature validation module for Fintoc's webhooks. 3 | 4 | This module provides functionality to verify webhook signatures using HMAC-SHA256, 5 | following Fintoc's webhook validation specification. It ensures that webhooks 6 | are authentic and haven't been tampered with during transmission. 7 | """ 8 | 9 | import hmac 10 | import time 11 | from hashlib import sha256 12 | from typing import Dict, Optional, Tuple 13 | 14 | from fintoc.errors import WebhookSignatureError 15 | 16 | 17 | class WebhookSignature: 18 | """ 19 | Handles webhook signature validation for Fintoc webhooks. 20 | """ 21 | 22 | EXPECTED_SCHEME = "v1" 23 | DEFAULT_TOLERANCE = 300 # 5 minutes in seconds 24 | 25 | @staticmethod 26 | def verify_header( 27 | payload: str, 28 | header: str, 29 | secret: str, 30 | tolerance: Optional[int] = DEFAULT_TOLERANCE, 31 | ) -> bool: 32 | """ 33 | Verify the webhook signature header. 34 | 35 | Args: 36 | payload: The raw request body as a string 37 | header: The Fintoc-Signature header value 38 | secret: The webhook secret key 39 | tolerance: Number of seconds to tolerate when checking timestamp 40 | 41 | Returns: 42 | bool: True if the signature is valid 43 | 44 | Raises: 45 | WebhookSignatureError: If the signature is invalid 46 | """ 47 | timestamp, signatures = WebhookSignature._parse_header(header) 48 | 49 | if tolerance: 50 | WebhookSignature._verify_timestamp(timestamp, tolerance) 51 | 52 | expected_sig = WebhookSignature._compute_signature( 53 | payload=payload, timestamp=timestamp, secret=secret 54 | ) 55 | 56 | # Get the v1 signature from parsed signatures 57 | signature = signatures.get(WebhookSignature.EXPECTED_SCHEME) 58 | if not signature: 59 | raise WebhookSignatureError( 60 | f"No {WebhookSignature.EXPECTED_SCHEME} signature found" 61 | ) 62 | 63 | if not hmac.compare_digest(expected_sig, signature): 64 | raise WebhookSignatureError("Signature mismatch") 65 | 66 | return True 67 | 68 | @staticmethod 69 | def _parse_header(header: str) -> Tuple[int, Dict[str, str]]: 70 | """ 71 | Parse the webhook signature header. 72 | 73 | Args: 74 | header: The Fintoc-Signature header value 75 | 76 | Returns: 77 | Tuple containing timestamp and dict of signature schemes 78 | 79 | Raises: 80 | WebhookSignatureError: If header format is invalid 81 | """ 82 | try: 83 | pairs = dict(part.split("=", 1) for part in header.split(",")) 84 | 85 | if "t" not in pairs: 86 | raise WebhookSignatureError("Missing timestamp in header") 87 | 88 | timestamp = int(pairs["t"]) 89 | signatures = {k: v for k, v in pairs.items() if k != "t"} 90 | 91 | return timestamp, signatures 92 | 93 | except (ValueError, KeyError) as e: 94 | raise WebhookSignatureError( 95 | "Unable to extract timestamp and signatures from header" 96 | ) from e 97 | 98 | @staticmethod 99 | def _compute_signature(payload: str, timestamp: int, secret: str) -> str: 100 | """ 101 | Compute the expected signature for a payload. 102 | 103 | Args: 104 | payload: The raw request body 105 | timestamp: Unix timestamp 106 | secret: Webhook secret key 107 | 108 | Returns: 109 | str: The computed signature 110 | """ 111 | signed_payload = f"{timestamp}.{payload}" 112 | return hmac.new( 113 | secret.encode("utf-8"), signed_payload.encode("utf-8"), sha256 114 | ).hexdigest() 115 | 116 | @staticmethod 117 | def _verify_timestamp(timestamp: int, tolerance: int) -> None: 118 | """ 119 | Verify that the timestamp is within tolerance. 120 | 121 | Args: 122 | timestamp: Unix timestamp to verify 123 | tolerance: Number of seconds to tolerate 124 | 125 | Raises: 126 | WebhookSignatureError: If timestamp is outside tolerance window 127 | """ 128 | now = int(time.time()) 129 | 130 | if timestamp < (now - tolerance): 131 | raise WebhookSignatureError( 132 | f"Timestamp outside the tolerance zone ({timestamp})" 133 | ) 134 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module to hold all the fixtures and stuff that needs to get auto-imported 3 | by PyTest. 4 | """ 5 | 6 | import json 7 | from json.decoder import JSONDecodeError 8 | 9 | import httpx 10 | import pytest 11 | 12 | 13 | @pytest.fixture 14 | def patch_http_error(monkeypatch): 15 | class MockResponse: 16 | def __init__(self): 17 | pass 18 | 19 | def json(self): 20 | return { 21 | "error": { 22 | "type": "api_error", 23 | "message": "This is a test error message", 24 | } 25 | } 26 | 27 | class MockHTTPError(httpx.HTTPError): 28 | def __init__(self, message): 29 | super().__init__(message) 30 | self.response = MockResponse() 31 | 32 | monkeypatch.setattr(httpx, "HTTPError", MockHTTPError) 33 | 34 | 35 | @pytest.fixture 36 | def patch_http_client(monkeypatch): 37 | class MockResponse: 38 | def __init__(self, method, base_url, url, params, json, headers): 39 | self._base_url = base_url 40 | self._params = params 41 | page = None 42 | if method == "get" and url[-1] == "s": 43 | page = int(self._params.pop("page", 1)) 44 | self._page = page 45 | self._method = method 46 | self._url = url 47 | self._json = json 48 | self._headers = headers 49 | 50 | # Extract the ID from the URL if it's a specific resource request 51 | self._id = None 52 | if "/" in url and not url.endswith("s"): 53 | self._id = url.split("/")[-1] 54 | 55 | @property 56 | def headers(self): 57 | resp_headers = dict(self._headers) 58 | if self._page is not None and self._page < 10: 59 | params = "&".join([*self.formatted_params, f"page={self._page + 1}"]) 60 | url = self._url.lstrip("/") 61 | resp_headers["link"] = ( 62 | f"<{self._base_url}/{url}?{params}>; " 'rel="next"' 63 | ) 64 | return resp_headers 65 | 66 | @property 67 | def formatted_params(self): 68 | return [f"{k}={v}" for k, v in self._params.items()] 69 | 70 | def raise_for_status(self): 71 | pass 72 | 73 | def json(self): 74 | if self._method == "delete": 75 | raise JSONDecodeError("Expecting value", "doc", 0) 76 | if self._method == "get" and self._url[-1] == "s": 77 | return [ 78 | { 79 | "id": "idx", 80 | "method": self._method, 81 | "url": self._url, 82 | "params": self._params, 83 | "json": self._json, 84 | "page": self._page, 85 | "headers": self.headers, 86 | } 87 | for _ in range(10) 88 | ] 89 | return { 90 | # Use the ID from the URL if available, otherwise use "idx" 91 | "id": self._id if self._id else "idx", 92 | "method": self._method, 93 | "url": self._url, 94 | "params": self._params, 95 | "json": self._json, 96 | "headers": self.headers, 97 | } 98 | 99 | class MockClient(httpx.Client): 100 | def send(self, request: httpx.Request, **_kwargs): 101 | query_string = request.url.query.decode("utf-8") 102 | query = query_string.split("&") if query_string else [] 103 | inner_params = {y[0]: y[1] for y in (x.split("=") for x in query)} 104 | complete_params = { 105 | **inner_params, 106 | **({} if request.url.params is None else request.url.params), 107 | } 108 | usable_url = request.url.path 109 | raw_body = request.content.decode("utf-8") 110 | return MockResponse( 111 | request.method.lower(), 112 | self.base_url, 113 | usable_url.lstrip("/"), 114 | complete_params, 115 | json.loads(raw_body) if raw_body else None, 116 | request.headers, 117 | ) 118 | 119 | monkeypatch.setattr(httpx, "Client", MockClient) 120 | 121 | from fintoc.client import Client 122 | 123 | mock_client = MockClient() 124 | monkeypatch.setattr(Client, "_client", mock_client) 125 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | from types import GeneratorType 2 | 3 | import httpx 4 | import pytest 5 | 6 | from fintoc.client import Client 7 | 8 | 9 | class TestClientCreationFunctionality: 10 | def setup_method(self): 11 | self.base_url = "https://test.com" 12 | self.api_key = "super_secret_api_key" 13 | self.user_agent = "fintoc-python/test" 14 | self.params = {"first_param": "first_value", "second_param": "second_value"} 15 | self.api_version = None 16 | 17 | def create_client(self, params=False, api_version=None): 18 | if not params: 19 | return Client(self.base_url, self.api_key, api_version, self.user_agent) 20 | 21 | return Client( 22 | self.base_url, 23 | self.api_key, 24 | self.api_version, 25 | self.user_agent, 26 | params=self.params, 27 | ) 28 | 29 | def test_client_creation_without_params(self): 30 | client = self.create_client() 31 | assert isinstance(client, Client) 32 | assert client.base_url == self.base_url 33 | assert client.api_key == self.api_key 34 | assert client.user_agent == self.user_agent 35 | assert client.params == {} 36 | 37 | def test_client_creation_with_params(self): 38 | client = self.create_client(params=True) 39 | assert isinstance(client, Client) 40 | assert client.base_url == self.base_url 41 | assert client.api_key == self.api_key 42 | assert client.user_agent == self.user_agent 43 | assert client.params == self.params 44 | 45 | def test_client_headers_with_api_version(self): 46 | client = self.create_client(api_version="2023-01-01") 47 | assert isinstance(client.headers, dict) 48 | assert len(client.headers.keys()) == 3 49 | assert "Authorization" in client.headers 50 | assert "User-Agent" in client.headers 51 | assert client.headers["Authorization"] == self.api_key 52 | assert client.headers["User-Agent"] == self.user_agent 53 | assert client.headers["Fintoc-Version"] == "2023-01-01" 54 | 55 | def test_client_headers_without_api_version(self): 56 | client = self.create_client() 57 | assert isinstance(client.headers, dict) 58 | assert len(client.headers.keys()) == 2 59 | assert "Authorization" in client.headers 60 | assert "User-Agent" in client.headers 61 | assert client.headers["Authorization"] == self.api_key 62 | assert client.headers["User-Agent"] == self.user_agent 63 | 64 | def test_client_extension(self): 65 | # pylint: disable=protected-access 66 | client = self.create_client() 67 | assert isinstance(client._client, httpx.Client) # Has httpx sub client 68 | assert client._client is not None 69 | 70 | new_url = "https://new-test.com" 71 | new_api_key = "new_super_secret_api_key" 72 | new_client = client.extend(base_url=new_url, api_key=new_api_key) 73 | assert isinstance(new_client, Client) 74 | assert new_client is not client 75 | assert new_client._client is client._client 76 | 77 | def test_client_params_extension(self): 78 | # pylint: disable=protected-access 79 | client = self.create_client(params=True) 80 | 81 | new_params = {"link_token": "link_token", "first_param": "new_first_value"} 82 | new_client = client.extend(params=new_params) 83 | assert len(new_client.params) == len(client.params) + 1 84 | assert new_client.params["first_param"] != client.params["first_param"] 85 | 86 | 87 | class TestClientRequestFunctionality: 88 | @pytest.fixture(autouse=True) 89 | def patch_http_client(self, patch_http_client): 90 | pass 91 | 92 | def setup_method(self): 93 | self.base_url = "https://test.com" 94 | self.api_key = "super_secret_api_key" 95 | self.api_version = None 96 | self.user_agent = "fintoc-python/test" 97 | self.params = {"first_param": "first_value", "second_param": "second_value"} 98 | self.client = Client( 99 | self.base_url, 100 | self.api_key, 101 | self.api_version, 102 | self.user_agent, 103 | params=self.params, 104 | ) 105 | 106 | def test_paginated_request(self): 107 | data = self.client.request("/movements", paginated=True) 108 | assert isinstance(data, GeneratorType) 109 | 110 | def test_get_request(self): 111 | data = self.client.request("/movements/3", method="get") 112 | assert isinstance(data, dict) 113 | assert len(data.keys()) > 0 114 | 115 | def test_delete_request(self): 116 | data = self.client.request("/movements/3", method="delete") 117 | assert isinstance(data, dict) 118 | assert len(data.keys()) == 0 119 | 120 | def test_post_request(self): 121 | data = self.client.request("/v2/transfers", method="post") 122 | assert isinstance(data, dict) 123 | 124 | idempotency_key = data["headers"]["idempotency-key"] 125 | assert idempotency_key is not None and idempotency_key != "" 126 | 127 | def test_post_request_with_custom_idempotency_key(self): 128 | data = self.client.request( 129 | "/v2/transfers", method="post", idempotency_key="1234" 130 | ) 131 | assert isinstance(data, dict) 132 | 133 | idempotency_key = data["headers"]["idempotency-key"] 134 | assert idempotency_key == "1234" 135 | -------------------------------------------------------------------------------- /tests/test_jws.py: -------------------------------------------------------------------------------- 1 | """Tests for the JWS signature generation.""" 2 | 3 | import base64 4 | import json 5 | import time 6 | 7 | import pytest 8 | from cryptography.hazmat.primitives import hashes, serialization 9 | from cryptography.hazmat.primitives.asymmetric import padding, rsa 10 | 11 | from fintoc.jws import JWSSignature 12 | 13 | 14 | @pytest.fixture(name="private_key") 15 | def fixture_private_key(): 16 | """Generate a test RSA private key.""" 17 | key = rsa.generate_private_key(public_exponent=65537, key_size=2048) 18 | pem = key.private_bytes( 19 | encoding=serialization.Encoding.PEM, 20 | format=serialization.PrivateFormat.PKCS8, 21 | encryption_algorithm=serialization.NoEncryption(), 22 | ) 23 | return pem 24 | 25 | 26 | @pytest.fixture(name="private_key_file") 27 | def fixture_private_key_file(private_key, tmp_path): 28 | """Create a temporary file with the test private key.""" 29 | key_path = tmp_path / "private_key.pem" 30 | key_path.write_bytes(private_key) 31 | return key_path 32 | 33 | 34 | class TestJWSSignature: 35 | """Test cases for JWSSignature class.""" 36 | 37 | def test_init_with_string(self, private_key): 38 | """Test initializing with PEM string.""" 39 | jws = JWSSignature(private_key.decode()) 40 | assert jws.private_key is not None 41 | 42 | def test_init_with_bytes(self, private_key): 43 | """Test initializing with PEM bytes.""" 44 | jws = JWSSignature(private_key) 45 | assert jws.private_key is not None 46 | 47 | def test_init_with_path_string(self, private_key_file): 48 | """Test initializing with path as string.""" 49 | jws = JWSSignature(str(private_key_file)) 50 | assert jws.private_key is not None 51 | 52 | def test_init_with_path_object(self, private_key_file): 53 | """Test initializing with Path object.""" 54 | jws = JWSSignature(private_key_file) 55 | assert jws.private_key is not None 56 | 57 | def test_init_with_invalid_type(self): 58 | """Test initializing with invalid type raises ValueError.""" 59 | with pytest.raises(ValueError): 60 | JWSSignature(123) 61 | 62 | def test_init_with_invalid_path(self): 63 | """Test initializing with non-existent file path.""" 64 | with pytest.raises(ValueError): 65 | JWSSignature("nonexistent.pem") 66 | 67 | def test_init_with_invalid_key_format(self): 68 | """Test initializing with invalid key format.""" 69 | with pytest.raises(ValueError): 70 | JWSSignature("not a valid key") 71 | 72 | def test_generate_header_with_dict(self, private_key): 73 | """Test generating header with dict payload.""" 74 | jws = JWSSignature(private_key) 75 | payload = {"amount": 1000, "currency": "CLP"} 76 | header = jws.generate_header(payload) 77 | 78 | parts = header.split(".") 79 | assert len(parts) == 2 80 | 81 | protected = json.loads( 82 | base64.urlsafe_b64decode(parts[0] + "=" * (4 - len(parts[0]) % 4)).decode() 83 | ) 84 | 85 | assert protected["alg"] == "RS256" 86 | assert "nonce" in protected 87 | assert "ts" in protected 88 | assert protected["crit"] == ["ts", "nonce"] 89 | 90 | def test_generate_header_with_string(self, private_key): 91 | """Test generating header with string payload.""" 92 | jws = JWSSignature(private_key) 93 | payload = json.dumps({"amount": 1000, "currency": "CLP"}) 94 | header = jws.generate_header(payload) 95 | 96 | parts = header.split(".") 97 | assert len(parts) == 2 98 | 99 | protected = json.loads( 100 | base64.urlsafe_b64decode(parts[0] + "=" * (4 - len(parts[0]) % 4)).decode() 101 | ) 102 | 103 | assert protected["alg"] == "RS256" 104 | assert "nonce" in protected 105 | assert "ts" in protected 106 | assert protected["crit"] == ["ts", "nonce"] 107 | 108 | def test_header_verification(self, private_key): 109 | """Test that generated headers can be verified.""" 110 | jws = JWSSignature(private_key) 111 | payload = {"amount": 1000, "currency": "CLP"} 112 | header = jws.generate_header(payload) 113 | 114 | protected_b64, signature_b64 = header.split(".") 115 | payload_b64 = ( 116 | base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b"=").decode() 117 | ) 118 | signing_input = f"{protected_b64}.{payload_b64}" 119 | signature = base64.urlsafe_b64decode( 120 | signature_b64 + "=" * (4 - len(signature_b64) % 4) 121 | ) 122 | 123 | public_key = serialization.load_pem_private_key( 124 | private_key, password=None 125 | ).public_key() 126 | 127 | # This should not raise an exception 128 | public_key.verify( 129 | signature, signing_input.encode(), padding.PKCS1v15(), hashes.SHA256() 130 | ) 131 | 132 | def test_timestamp_is_current(self, private_key): 133 | """Test that generated timestamp is current.""" 134 | jws = JWSSignature(private_key) 135 | payload = {"test": "data"} 136 | header = jws.generate_header(payload) 137 | 138 | protected_b64 = header.split(".")[0] 139 | protected = json.loads( 140 | base64.urlsafe_b64decode( 141 | protected_b64 + "=" * (4 - len(protected_b64) % 4) 142 | ).decode() 143 | ) 144 | 145 | assert abs(protected["ts"] - int(time.time())) < 10 146 | 147 | def test_nonce_is_random(self, private_key): 148 | """Test that nonce is random for each call.""" 149 | jws = JWSSignature(private_key) 150 | payload = {"test": "data"} 151 | 152 | header1 = jws.generate_header(payload) 153 | header2 = jws.generate_header(payload) 154 | 155 | protected1 = json.loads( 156 | base64.urlsafe_b64decode(header1.split(".")[0] + "====").decode() 157 | ) 158 | protected2 = json.loads( 159 | base64.urlsafe_b64decode(header2.split(".")[0] + "====").decode() 160 | ) 161 | 162 | assert protected1["nonce"] != protected2["nonce"] 163 | -------------------------------------------------------------------------------- /tests/test_webhook.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta, timezone 2 | 3 | import httpx 4 | import pytest 5 | from freezegun import freeze_time 6 | 7 | from fintoc.errors import WebhookSignatureError 8 | from fintoc.webhook import WebhookSignature 9 | 10 | # Test constants from the real webhook 11 | SECRET = "whsec_test_secret" 12 | PAYLOAD = """ 13 | { 14 | "id": "evt_2AaZeLCz0GjOW5zj", 15 | "type": "payment_intent.succeeded", 16 | "mode": "test", 17 | "created_at": "2025-04-05T21:57:31.834Z", 18 | "data": { 19 | "id": "pi_2vKOKniSGXRhXTKrJ67VXZxGCVt", 20 | "mode": "test", 21 | "amount": 1, 22 | "object": "payment_intent", 23 | "status": "succeeded", 24 | "currency": "MXN", 25 | "metadata": {}, 26 | "created_at": "2025-04-05T21:57:17Z", 27 | "expires_at": null, 28 | "error_reason": null, 29 | "payment_type": "bank_transfer", 30 | "reference_id": null, 31 | "widget_token": null, 32 | "customer_email": null, 33 | "sender_account": { 34 | "type": "checking_account", 35 | "number": "501514890244223279", 36 | "holder_id": "mfiu593501oe4", 37 | "institution_id": "mx_stp" 38 | }, 39 | "business_profile": null, 40 | "transaction_date": null, 41 | "recipient_account": { 42 | "type": "checking_account", 43 | "number": "646180357600000000", 44 | "holder_id": "fsm211008hz9", 45 | "institution_id": "mx_stp" 46 | }, 47 | "payment_type_options": {} 48 | }, 49 | "object": "event" 50 | } 51 | """ 52 | 53 | # Remove whitespace and line returns from PAYLOAD 54 | PAYLOAD = "".join(PAYLOAD.split()) 55 | 56 | TIMESTAMP = 1743890251 57 | HEADER = ( 58 | f"t={TIMESTAMP},v1=11b98dd8f5500109246aa4d9875fad2e97d462560b012a5f50ff924411de0b0f" 59 | ) 60 | SIGNATURE_DATETIME = datetime.fromtimestamp(TIMESTAMP, tz=timezone.utc) 61 | 62 | 63 | class TestWebhookSignature: 64 | @freeze_time(SIGNATURE_DATETIME) 65 | def test_valid_signature(self): 66 | assert ( 67 | WebhookSignature.verify_header( 68 | payload=PAYLOAD, header=HEADER, secret=SECRET 69 | ) 70 | is True 71 | ) 72 | 73 | @freeze_time(SIGNATURE_DATETIME) 74 | def test_invalid_secret(self): 75 | """Test that an incorrect secret key fails verification""" 76 | with pytest.raises(WebhookSignatureError, match="Signature mismatch"): 77 | WebhookSignature.verify_header( 78 | payload=PAYLOAD, header=HEADER, secret="whsec_wrong_secret" 79 | ) 80 | 81 | @freeze_time(SIGNATURE_DATETIME) 82 | def test_modified_payload(self): 83 | """Test that a modified payload fails verification""" 84 | modified_payload = PAYLOAD.replace( 85 | "payment_intent.succeeded", "payment_intent.failed" 86 | ) 87 | 88 | with pytest.raises(WebhookSignatureError, match="Signature mismatch"): 89 | WebhookSignature.verify_header( 90 | payload=modified_payload, header=HEADER, secret=SECRET 91 | ) 92 | 93 | # Should fail with no tolerance 94 | with pytest.raises(WebhookSignatureError, match="Signature mismatch"): 95 | WebhookSignature.verify_header( 96 | payload=modified_payload, header=HEADER, secret=SECRET, tolerance=None 97 | ) 98 | 99 | def test_malformed_header(self): 100 | malformed_headers = [ 101 | "", 102 | "invalid_format", 103 | "t=1743890251", 104 | "v1=11b98dd8f5500109246aa4d9875fad2e97d462560b012a5f50ff924411de0b0f", 105 | "t=invalid,v1=11b98dd8f5500109246aa4d9875fad2e97d462560b012a5f50ff924411de0b0f", # noqa: E501 106 | ] 107 | 108 | for header in malformed_headers: 109 | with pytest.raises(WebhookSignatureError): 110 | WebhookSignature.verify_header( 111 | payload=PAYLOAD, header=header, secret=SECRET 112 | ) 113 | 114 | @freeze_time(SIGNATURE_DATETIME) 115 | def test_header_contains_valid_signature(self): 116 | header = HEADER + ",v2=bad_signature" 117 | assert ( 118 | WebhookSignature.verify_header( 119 | payload=PAYLOAD, header=header, secret=SECRET 120 | ) 121 | is True 122 | ) 123 | 124 | def test_timestamp_validation(self): 125 | # Should pass with custom tolerance and timestamp on 126 | with freeze_time(SIGNATURE_DATETIME + timedelta(seconds=500)): 127 | assert ( 128 | WebhookSignature.verify_header( 129 | payload=PAYLOAD, header=HEADER, secret=SECRET, tolerance=600 130 | ) 131 | is True 132 | ) 133 | 134 | # Should pass with default tolerance and timestamp on 135 | with freeze_time(SIGNATURE_DATETIME + timedelta(seconds=200)): 136 | assert ( 137 | WebhookSignature.verify_header( 138 | payload=PAYLOAD, header=HEADER, secret=SECRET 139 | ) 140 | is True 141 | ) 142 | 143 | # Should fail with default tolerance and timestamp off 144 | with freeze_time(SIGNATURE_DATETIME + timedelta(seconds=400)): 145 | with pytest.raises( 146 | WebhookSignatureError, match="Timestamp outside the tolerance zone" 147 | ): 148 | WebhookSignature.verify_header( 149 | payload=PAYLOAD, 150 | header=HEADER, 151 | secret=SECRET, 152 | ) 153 | 154 | # Should fail with custom tolerance and timestamp off 155 | with freeze_time(SIGNATURE_DATETIME + timedelta(seconds=100)): 156 | with pytest.raises( 157 | WebhookSignatureError, match="Timestamp outside the tolerance zone" 158 | ): 159 | WebhookSignature.verify_header( 160 | payload=PAYLOAD, header=HEADER, secret=SECRET, tolerance=10 161 | ) 162 | 163 | # Should pass with no tolerance and timestamp off 164 | with freeze_time(SIGNATURE_DATETIME + timedelta(seconds=10100)): 165 | WebhookSignature.verify_header( 166 | payload=PAYLOAD, header=HEADER, secret=SECRET, tolerance=None 167 | ) 168 | 169 | def test_empty_values(self): 170 | """Test handling of empty values""" 171 | with pytest.raises(WebhookSignatureError): 172 | WebhookSignature.verify_header(payload=PAYLOAD, header="", secret=SECRET) 173 | 174 | with pytest.raises(WebhookSignatureError): 175 | WebhookSignature.verify_header(payload="", header=HEADER, secret=SECRET) 176 | 177 | with pytest.raises(WebhookSignatureError): 178 | WebhookSignature.verify_header(payload=PAYLOAD, header=HEADER, secret="") 179 | -------------------------------------------------------------------------------- /fintoc/mixins/manager_mixin.py: -------------------------------------------------------------------------------- 1 | """Module to hold the mixin for the managers.""" 2 | 3 | from abc import ABCMeta, abstractmethod 4 | 5 | from fintoc.resource_handlers import ( 6 | resource_create, 7 | resource_delete, 8 | resource_get, 9 | resource_list, 10 | resource_update, 11 | ) 12 | from fintoc.utils import can_raise_fintoc_error, deprecate, get_resource_class 13 | 14 | 15 | class ManagerMixin(metaclass=ABCMeta): # pylint: disable=no-self-use 16 | 17 | """Represents the mixin for the managers.""" 18 | 19 | def __init__(self, path, client): 20 | self._path = path 21 | self._client = client 22 | self._handlers = { 23 | "update": self.post_update_handler, 24 | "delete": self.post_delete_handler, 25 | } 26 | 27 | def __getattr__(self, attr): 28 | if attr not in self.__class__.methods: 29 | if attr == "all" and "list" in self.__class__.methods: 30 | return getattr(self, "_all") 31 | raise AttributeError( 32 | f"{self.__class__.__name__} has no attribute '{attr.lstrip('_')}'" 33 | ) 34 | return getattr(self, f"_{attr}") 35 | 36 | @property 37 | @abstractmethod 38 | def resource(self): 39 | """ 40 | This abstract property must be instanced as a class attribute 41 | when subclassing this mixin. It represents the name of the resource 42 | using snake_case. 43 | """ 44 | 45 | @property 46 | @abstractmethod 47 | def methods(self): 48 | """ 49 | This abstract property must be instanced as a class attribute 50 | when subclassing this mixin. It represents the methods that can 51 | be accessed using the manager. Must be an array with at least 52 | one of: ['all', 'get', 'create', 'update', 'delete']. 53 | """ 54 | 55 | @can_raise_fintoc_error 56 | def _list(self, **kwargs): 57 | """ 58 | List all instances of the resource being handled by the manager. 59 | :kwargs: can be used to filter the results, using the API parameters. 60 | """ 61 | klass = get_resource_class(self.__class__.resource) 62 | objects = resource_list( 63 | client=self._client, 64 | path=self._build_path(**kwargs), 65 | klass=klass, 66 | handlers=self._handlers, 67 | methods=self.__class__.methods, 68 | params=kwargs, 69 | ) 70 | return self.post_list_handler(objects, **kwargs) 71 | 72 | @deprecate( 73 | "all() is deprecated and will be removed in a future version. Use " 74 | "list() instead" 75 | ) 76 | def _all(self, **kwargs): 77 | """ 78 | Return all instances of the resource being handled by the manager. 79 | :kwargs: can be used to filter the results, using the API parameters. 80 | """ 81 | return self._list(**kwargs) 82 | 83 | @can_raise_fintoc_error 84 | def _get(self, identifier, **kwargs): 85 | """ 86 | Return an instance of the resource being handled by the manager, 87 | identified by :identifier:. 88 | """ 89 | klass = get_resource_class(self.__class__.resource) 90 | object_ = resource_get( 91 | client=self._client, 92 | path=self._build_path(**kwargs), 93 | id_=identifier, 94 | klass=klass, 95 | handlers=self._handlers, 96 | methods=self.__class__.methods, 97 | params=kwargs, 98 | ) 99 | return self.post_get_handler(object_, identifier, **kwargs) 100 | 101 | @can_raise_fintoc_error 102 | def _create(self, idempotency_key=None, path_=None, **kwargs): 103 | """ 104 | Create an instance of the resource being handled by the manager. 105 | Data is passed using :kwargs:, as specified by the API. 106 | """ 107 | klass = get_resource_class(self.__class__.resource) 108 | path = path_ if path_ else self._build_path(**kwargs) 109 | object_ = resource_create( 110 | client=self._client, 111 | path=path, 112 | klass=klass, 113 | handlers=self._handlers, 114 | methods=self.__class__.methods, 115 | params=kwargs, 116 | idempotency_key=idempotency_key, 117 | ) 118 | return self.post_create_handler(object_, **kwargs) 119 | 120 | @can_raise_fintoc_error 121 | def _update(self, identifier, path_=None, **kwargs): 122 | """ 123 | Update an instance of the resource being handled by the manager, 124 | identified by :identifier:. Data is passed using :kwargs:, as 125 | specified by the API. 126 | """ 127 | klass = get_resource_class(self.__class__.resource) 128 | custom_path = path_ if path_ else None 129 | object_ = resource_update( 130 | client=self._client, 131 | path=self._build_path(**kwargs), 132 | id_=identifier, 133 | klass=klass, 134 | handlers=self._handlers, 135 | methods=self.__class__.methods, 136 | params=kwargs, 137 | custom_path=custom_path, 138 | ) 139 | return self.post_update_handler(object_, identifier, **kwargs) 140 | 141 | @can_raise_fintoc_error 142 | def _delete(self, identifier, **kwargs): 143 | """ 144 | Delete an instance of the resource being handled by the manager, 145 | identified by :identifier:. 146 | """ 147 | resource_delete( 148 | client=self._client, 149 | path=self._build_path(**kwargs), 150 | id_=identifier, 151 | params=kwargs, 152 | ) 153 | return self.post_delete_handler(identifier, **kwargs) 154 | 155 | def _build_path(self, **kwargs): 156 | """ 157 | Replaces placeholders in the path template with the corresponding 158 | values. 159 | """ 160 | path = self._path 161 | for key, value in kwargs.items(): 162 | path = path.replace("{" + key + "}", str(value)) 163 | return path 164 | 165 | def post_list_handler(self, objects, **kwargs): 166 | """ 167 | Hook that runs after the :list: method. Receives the objects fetched 168 | and **must** return them (either modified or as they came). 169 | """ 170 | return objects 171 | 172 | def post_get_handler(self, object_, identifier, **kwargs): 173 | """ 174 | Hook that runs after the :get: method. Receives the object fetched 175 | with its identifier and **must** return the object (either modified 176 | or as it came). 177 | """ 178 | return object_ 179 | 180 | def post_create_handler(self, object_, **kwargs): 181 | """ 182 | Hook that runs after the :create: method. Receives the object fetched 183 | and **must** return the it (either modified or as it came). 184 | """ 185 | return object_ 186 | 187 | def post_update_handler(self, object_, identifier, **kwargs): 188 | """ 189 | Hook that runs after the :update: method. Receives the object fetched 190 | with its identifier and **must** return the object (either modified 191 | or as it came). 192 | """ 193 | return object_ 194 | 195 | def post_delete_handler(self, identifier, **kwargs): 196 | """ 197 | Hook that runs after the :create: method. Receives the identifier 198 | and **must** return it (either modified or as it came). 199 | """ 200 | return identifier 201 | -------------------------------------------------------------------------------- /tests/mixins/test_manager_mixin.py: -------------------------------------------------------------------------------- 1 | from types import GeneratorType 2 | 3 | import pytest 4 | 5 | from fintoc.client import Client 6 | from fintoc.mixins import ManagerMixin, ResourceMixin 7 | 8 | 9 | class InvalidMethodsMockManager(ManagerMixin): 10 | resource = "this_resource_does_not_exist" 11 | 12 | 13 | class InvalidResourceMockManager(ManagerMixin): 14 | methods = ["list", "get", "create", "update", "delete"] 15 | 16 | 17 | class IncompleteMockManager(ManagerMixin): 18 | resource = "this_resource_does_not_exist" 19 | methods = ["get", "update"] 20 | 21 | 22 | class EmptyMockManager(ManagerMixin): 23 | resource = "this_resource_does_not_exist" 24 | methods = ["list", "get", "create", "update", "delete"] 25 | 26 | 27 | class ComplexMockManager(ManagerMixin): 28 | resource = "this_resource_does_not_exist" 29 | methods = ["list", "get", "create", "update", "delete"] 30 | 31 | def post_list_handler(self, objects, **kwargs): 32 | print("Executing the 'post list' handler") 33 | return objects 34 | 35 | def post_get_handler(self, object_, identifier, **kwargs): 36 | print("Executing the 'post get' handler") 37 | return object_ 38 | 39 | def post_create_handler(self, object_, **kwargs): 40 | print("Executing the 'post create' handler") 41 | return object_ 42 | 43 | def post_update_handler(self, object_, identifier, **kwargs): 44 | print("Executing the 'post update' handler") 45 | return object_ 46 | 47 | def post_delete_handler(self, identifier, **kwargs): 48 | print("Executing the 'post delete' handler") 49 | return identifier 50 | 51 | 52 | class TestManagerMixinCreation: 53 | @pytest.fixture(autouse=True) 54 | def patch_http_client(self, patch_http_client): 55 | pass 56 | 57 | def setup_method(self): 58 | self.base_url = "https://test.com" 59 | self.api_key = "super_secret_api_key" 60 | self.api_version = None 61 | self.user_agent = "fintoc-python/test" 62 | self.params = {"first_param": "first_value", "second_param": "second_value"} 63 | self.client = Client( 64 | self.base_url, 65 | self.api_key, 66 | self.api_version, 67 | self.user_agent, 68 | params=self.params, 69 | ) 70 | self.path = "/resources" 71 | 72 | def test_invalid_methods(self): 73 | # pylint: disable=abstract-class-instantiated 74 | with pytest.raises(TypeError): 75 | InvalidMethodsMockManager(self.path, self.client) 76 | 77 | def test_invalid_resource(self): 78 | # pylint: disable=abstract-class-instantiated 79 | with pytest.raises(TypeError): 80 | InvalidResourceMockManager(self.path, self.client) 81 | 82 | def test_calling_invalid_methods(self): 83 | manager = IncompleteMockManager(self.path, self.client) 84 | with pytest.raises(AttributeError): 85 | manager.all() 86 | 87 | def test_calling_valid_methods(self): 88 | manager = IncompleteMockManager(self.path, self.client) 89 | manager.get("id") 90 | 91 | 92 | class TestManagerMixinMethods: 93 | @pytest.fixture(autouse=True) 94 | def patch_http_client(self, patch_http_client): 95 | pass 96 | 97 | def setup_method(self): 98 | self.base_url = "https://test.com" 99 | self.api_key = "super_secret_api_key" 100 | self.api_version = None 101 | self.user_agent = "fintoc-python/test" 102 | self.params = {"first_param": "first_value", "second_param": "second_value"} 103 | self.client = Client( 104 | self.base_url, 105 | self.api_key, 106 | self.api_version, 107 | self.user_agent, 108 | params=self.params, 109 | ) 110 | self.path = "/resources" 111 | self.manager = EmptyMockManager(self.path, self.client) 112 | 113 | def test_list_lazy_method(self): 114 | objects = self.manager.list() 115 | assert isinstance(objects, GeneratorType) 116 | for object_ in objects: 117 | assert isinstance(object_, ResourceMixin) 118 | 119 | def test_list_not_lazy_method(self): 120 | objects = self.manager.list(lazy=False) 121 | assert isinstance(objects, list) 122 | for object_ in objects: 123 | assert isinstance(object_, ResourceMixin) 124 | 125 | def test_all_still_works_for_backwards_compatibility(self): 126 | objects = self.manager.all() 127 | assert isinstance(objects, GeneratorType) 128 | for object_ in objects: 129 | assert isinstance(object_, ResourceMixin) 130 | 131 | objects = self.manager.all(lazy=False) 132 | assert isinstance(objects, list) 133 | for object_ in objects: 134 | assert isinstance(object_, ResourceMixin) 135 | 136 | def test_get_method(self): 137 | id_ = "my_id" 138 | object_ = self.manager.get(id_) 139 | assert isinstance(object_, ResourceMixin) 140 | assert object_.method == "get" 141 | assert id_ in object_.url 142 | 143 | def test_create_method(self): 144 | object_ = self.manager.create() 145 | assert isinstance(object_, ResourceMixin) 146 | assert object_.method == "post" 147 | assert object_.url == self.path.lstrip("/") 148 | 149 | object_ = self.manager.create(path_="/resources/custom_path") 150 | assert object_.url == "resources/custom_path" 151 | 152 | def test_update_method(self): 153 | object_ = self.manager.update("my_id") 154 | assert isinstance(object_, ResourceMixin) 155 | assert object_.method == "patch" 156 | 157 | def test_update_update_method_with_custom_path(self): 158 | object_ = self.manager.update("my_id", path_="/resources/my_id/cancel") 159 | assert isinstance(object_, ResourceMixin) 160 | assert object_.method == "patch" 161 | assert object_.url == "resources/my_id/cancel" 162 | 163 | def test_delete_method(self): 164 | id_ = self.manager.delete("my_id") 165 | isinstance(id_, str) 166 | 167 | 168 | class TestManagerMixinHandlers: 169 | @pytest.fixture(autouse=True) 170 | def patch_http_client(self, patch_http_client): 171 | pass 172 | 173 | def setup_method(self): 174 | self.base_url = "https://test.com" 175 | self.api_key = "super_secret_api_key" 176 | self.api_version = None 177 | self.user_agent = "fintoc-python/test" 178 | self.params = {"first_param": "first_value", "second_param": "second_value"} 179 | self.client = Client( 180 | self.base_url, 181 | self.api_key, 182 | self.api_version, 183 | self.user_agent, 184 | params=self.params, 185 | ) 186 | self.path = "/resources" 187 | self.manager = ComplexMockManager(self.path, self.client) 188 | 189 | def test_list_handler(self, capsys): 190 | self.manager.list() 191 | captured = capsys.readouterr().out 192 | assert "list" in captured 193 | 194 | def test_get_handler(self, capsys): 195 | self.manager.get("my_id") 196 | captured = capsys.readouterr().out 197 | assert "get" in captured 198 | 199 | def test_create_handler(self, capsys): 200 | self.manager.create() 201 | captured = capsys.readouterr().out 202 | assert "create" in captured 203 | 204 | def test_update_handler(self, capsys): 205 | self.manager.update("my_id") 206 | captured = capsys.readouterr().out 207 | assert "update" in captured 208 | 209 | def test_delete_handler(self, capsys): 210 | self.manager.delete("my_id") 211 | captured = capsys.readouterr().out 212 | assert "delete" in captured 213 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from types import GeneratorType 3 | 4 | import httpx 5 | import pytest 6 | 7 | from fintoc.constants import DATE_TIME_PATTERN 8 | from fintoc.errors import ApiError, FintocError 9 | from fintoc.resources import GenericFintocResource, Link 10 | from fintoc.utils import ( 11 | can_raise_fintoc_error, 12 | get_error_class, 13 | get_resource_class, 14 | is_iso_datetime, 15 | objetize, 16 | objetize_datetime, 17 | objetize_generator, 18 | serialize, 19 | singularize, 20 | snake_to_pascal, 21 | ) 22 | 23 | 24 | class TestSnakeToPascal: 25 | def test_simple_string(self): 26 | snake = "this_is_a_test" 27 | pascal = snake_to_pascal(snake) 28 | assert pascal == "ThisIsATest" 29 | 30 | def test_complex_string(self): 31 | snake = "thIs_is_a_TEST" 32 | pascal = snake_to_pascal(snake) 33 | assert pascal == "ThisIsATest" 34 | 35 | def test_pascale_cased_string(self): 36 | initial = "ThisIsATest" 37 | pascal = snake_to_pascal(initial) 38 | assert pascal == "Thisisatest" 39 | 40 | 41 | class TestSingularize: 42 | def test_plural_string(self): 43 | string = "movements" 44 | singular = singularize(string) 45 | assert singular == "movement" 46 | 47 | def test_singular_string(self): 48 | string = "movement" 49 | singular = singularize(string) 50 | assert singular == "movement" 51 | 52 | def test_complex_plural_does_not_work(self): 53 | complex_plural = "formulae" 54 | singular = singularize(complex_plural) 55 | assert singular != "formula" 56 | 57 | 58 | class TestIsISODateTime: 59 | def test_valid_iso_format(self): 60 | valid_iso_datetime_string = "2021-08-13T13:40:40Z" 61 | assert is_iso_datetime(valid_iso_datetime_string) 62 | 63 | def test_valid_iso_miliseconds_format(self): # TEMPORARY 64 | valid_iso_miliseconds_datetime_string = "2021-08-13T13:40:40.811Z" 65 | assert is_iso_datetime(valid_iso_miliseconds_datetime_string) 66 | 67 | def test_invalid_iso_string_format(self): 68 | invalid_iso_datetime_string = "This is not a date" 69 | assert not is_iso_datetime(invalid_iso_datetime_string) 70 | 71 | def test_invalid_iso_number_format(self): 72 | invalid_iso_datetime_string = "1105122" 73 | assert not is_iso_datetime(invalid_iso_datetime_string) 74 | 75 | 76 | class TestGetResourceClass: 77 | def test_default_valid_resource(self): 78 | resource = "link" 79 | klass = get_resource_class(resource) 80 | assert klass is Link 81 | 82 | def test_default_invalid_resource(self): 83 | resource = "this_resource_does_not_exist" 84 | klass = get_resource_class(resource) 85 | assert klass is GenericFintocResource 86 | 87 | def test_iso_datetime_resource(self): 88 | resource = "any_resource" 89 | klass = get_resource_class(resource, value="2021-08-13T13:40:40.811Z") 90 | assert klass is objetize_datetime 91 | 92 | def test_string_resource(self): 93 | resource = "any_resource" 94 | klass = get_resource_class(resource, value="test-value") 95 | assert klass is str 96 | 97 | def test_int_resource(self): 98 | resource = "any_resource" 99 | klass = get_resource_class(resource, value=15) 100 | assert klass is int 101 | 102 | def test_bool_resource(self): 103 | resource = "any_resource" 104 | klass = get_resource_class(resource, value=True) 105 | assert klass is bool 106 | 107 | 108 | class TestGetErrorClass: 109 | def test_valid_error(self): 110 | error_name = "api_error" 111 | error = get_error_class(error_name) 112 | assert error is ApiError 113 | 114 | def test_invalid_error(self): 115 | error_name = "this_error_does_not_exist" 116 | error = get_error_class(error_name) 117 | assert error is FintocError 118 | 119 | 120 | class TestCanRaiseFintocError: 121 | @pytest.fixture(autouse=True) 122 | def patch_http_error(self, patch_http_error): 123 | pass 124 | 125 | def setup_method(self): 126 | def no_error(): 127 | pass 128 | 129 | def raise_http_status_error(): 130 | raise httpx.HTTPStatusError( 131 | message="HTTP Status Error", 132 | response=httpx.Response( 133 | status_code=400, json={"error": {"type": "api_error"}} 134 | ), 135 | request=httpx.Request("GET", "/"), 136 | ) 137 | 138 | def raise_connect_error(): 139 | raise httpx.ConnectError(message="Connection Error") 140 | 141 | def raise_generic_error(): 142 | raise ValueError("Not HTTP Error") 143 | 144 | self.no_error = no_error 145 | self.raise_http_status_error = raise_http_status_error 146 | self.raise_connect_error = raise_connect_error 147 | self.raise_generic_error = raise_generic_error 148 | 149 | def test_no_error(self): 150 | wrapped = can_raise_fintoc_error(self.no_error) 151 | wrapped() 152 | 153 | def test_http_status_error(self): 154 | wrapped = can_raise_fintoc_error(self.raise_http_status_error) 155 | with pytest.raises(Exception) as execinfo: 156 | wrapped() 157 | assert isinstance(execinfo.value, FintocError) 158 | 159 | def test_connect_error(self): 160 | wrapped = can_raise_fintoc_error(self.raise_connect_error) 161 | with pytest.raises(Exception) as execinfo: 162 | wrapped() 163 | assert not isinstance(execinfo.value, FintocError) 164 | 165 | def test_generic_error(self): 166 | wrapped = can_raise_fintoc_error(self.raise_generic_error) 167 | with pytest.raises(Exception) as execinfo: 168 | wrapped() 169 | assert not isinstance(execinfo.value, FintocError) 170 | 171 | 172 | # Example class for the objetize tests 173 | class ExampleClass: 174 | def __init__(self, client, handlers, methods, path, **kwargs): 175 | self.client = client 176 | self.handlers = handlers 177 | self.methods = methods 178 | self.path = path 179 | self.data = kwargs 180 | 181 | def serialize(self): 182 | return self.data 183 | 184 | 185 | class TestSerialize: 186 | def test_string_serialization(self): 187 | string = "This is a string" 188 | assert serialize(string) == string 189 | 190 | def test_boolean_serialization(self): 191 | boolean = True 192 | assert serialize(boolean) == boolean 193 | 194 | def test_int_serialization(self): 195 | integer = 3 196 | assert serialize(integer) == integer 197 | 198 | def test_none_serialization(self): 199 | none = None 200 | assert serialize(none) == none 201 | 202 | def test_datetime_serialization(self): 203 | now = datetime.datetime.now() 204 | assert isinstance(now, datetime.datetime) 205 | assert isinstance(serialize(now), str) 206 | assert serialize(now) == now.strftime(DATE_TIME_PATTERN) 207 | 208 | def test_object_with_serialize_method_serialization(self): 209 | data = {"a": "b", "c": "d"} 210 | object_ = ExampleClass("client", ["handler"], ["method"], "path", **data) 211 | assert serialize(object_) == object_.serialize() 212 | 213 | 214 | class TestObjetize: 215 | def setup_method(self): 216 | self.client = "This is a client" 217 | self.data = { 218 | "id": "obj_3nlaf830FBbfF83", 219 | "name": "Sample Name", 220 | "number": 47, 221 | } 222 | 223 | def test_string_objetization(self): 224 | data = "This is data" 225 | object_ = objetize(str, self.client, data) 226 | assert isinstance(object_, str) 227 | assert object_ == data 228 | 229 | def test_dictionary_objetization(self): 230 | object_ = objetize(dict, self.client, self.data) 231 | assert isinstance(object_, dict) 232 | assert object_ == self.data 233 | 234 | def test_complete_objetization(self): 235 | object_ = objetize(ExampleClass, self.client, self.data) 236 | assert isinstance(object_, ExampleClass) 237 | assert object_.data["id"] == self.data["id"] 238 | 239 | 240 | class TestObjetizeDateTime: 241 | def setup_method(self): 242 | self.valid_string = "2021-12-16T12:24:44Z" 243 | self.valid_miliseconds_string = "2021-12-16T12:24:44.397Z" 244 | self.obviously_invalid_string = "This is not a date" 245 | self.deceptively_invalid_string = "1105122" 246 | 247 | def test_valid_string(self): 248 | parsed = objetize_datetime(self.valid_string) 249 | assert isinstance(parsed, datetime.datetime) 250 | 251 | def test_valid_miliseconds_string(self): # TEMPORARY 252 | parsed = objetize_datetime(self.valid_miliseconds_string) 253 | assert isinstance(parsed, datetime.datetime) 254 | 255 | def test_obviously_invalid_string(self): 256 | with pytest.raises(ValueError): 257 | objetize_datetime(self.obviously_invalid_string) 258 | 259 | def test_deceptively_invalid_string(self): 260 | with pytest.raises(ValueError): 261 | objetize_datetime(self.deceptively_invalid_string) 262 | 263 | 264 | class TestObjetizeGenerator: 265 | def setup_method(self): 266 | self.client = "This is a client" 267 | 268 | def get_generator(): 269 | for iii in range(10): 270 | yield { 271 | "id": "obj_3nlaf830FBbfF83", 272 | "name": "Sample Name", 273 | "number": iii, 274 | } 275 | 276 | self.get_generator = get_generator 277 | 278 | def test_generator_objetization(self): 279 | generator = self.get_generator() 280 | assert isinstance(generator, GeneratorType) 281 | 282 | objetized_generator = objetize_generator(generator, ExampleClass, self.client) 283 | assert isinstance(objetized_generator, GeneratorType) 284 | 285 | for object_ in objetized_generator: 286 | assert isinstance(object_, ExampleClass) 287 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Fintoc meets Python 🐍

2 | 3 |

4 | 5 | You have just found the Python-flavored client of Fintoc. 6 | 7 |

8 | 9 |

10 | 11 | PyPI - Version 12 | 13 | 14 | 15 | Tests 16 | 17 | 18 | 19 | Coverage 20 | 21 | 22 | 23 | Linters 24 | 25 |

26 | 27 | ## Table of Contents 28 | - [Installation](#installation) 29 | - [Usage](#usage) 30 | - [Quickstart](#quickstart) 31 | - [Calling endpoints](#calling-endpoints) 32 | - [list](#list) 33 | - [get](#get) 34 | - [create](#create) 35 | - [update](#update) 36 | - [delete](#delete) 37 | - [V2 Endpoints](#v2-endpoints) 38 | - [Nested actions or resources](#nested-actions-or-resources) 39 | - [Webhook Signature Validation](#webhook-signature-validation) 40 | - [Idempotency Keys](#idempotency-keys) 41 | - [Generate the JWS Signature](#gnerate-the-jws-signature) 42 | - [Serialization](#serialization) 43 | - [Acknowledgements](#acknowledgements) 44 | 45 | ## Installation 46 | 47 | Install using pip! 48 | 49 | ```sh 50 | pip install fintoc 51 | ``` 52 | 53 | **Note:** This SDK requires [**Python 3.6+**](https://docs.python.org/3/whatsnew/3.6.html). 54 | 55 | ## Usage 56 | 57 | The idea behind this SDK is to stick to the API design as much as possible, so that it feels ridiculously natural to use even while only reading the raw API documentation. 58 | 59 | ### Quickstart 60 | 61 | To be able to use this SDK, you first need to get your secret API Key from the [Fintoc Dashboard](https://dashboard.fintoc.com/login). Once you have your API key, all you need to do is initialize a `Fintoc` object with it and you're ready to start enjoying Fintoc! 62 | 63 | ```python 64 | from fintoc import Fintoc 65 | 66 | client = Fintoc("your_api_key") 67 | 68 | # list all succeeded payment intents since the beginning of 2025 69 | payment_intents = client.payment_intents.list(since="2025-01-01", status="succeeded") 70 | for pi in payment_intents: 71 | print(pi.created_at, pi.amount, pi.customer_email) 72 | 73 | # Get a specific payment intent 74 | payment_intent = client.payment_intents.get("pi_12345235412") 75 | print(payment_intent.customer_email) 76 | ``` 77 | 78 | ### Calling endpoints 79 | 80 | The SDK provides direct access to Fintoc API resources following the API structure. Simply use the resource name and follow it by the appropriate action you want. 81 | 82 | Notice that **not every resource has all of the methods**, as they correspond to the API capabilities. 83 | 84 | #### `list` 85 | 86 | You can use the `list` method to list all the instances of the resource: 87 | 88 | ```python 89 | webhook_endpoints = client.webhook_endpoints.list() 90 | ``` 91 | 92 | The `list` method returns **a generator** with all the instances of the resource. This method can also receive the arguments that the API receives for that specific resource. For example, the `PaymentIntent` resource can be filtered using `since` and `until`, so if you wanted to get a range of `payment intents`, all you need to do is to pass the parameters to the method: 93 | 94 | ```python 95 | payment_intents = client.payment_intents.list(since="2025-01-01", until="2025-02-01") 96 | ``` 97 | 98 | You can also pass the `lazy=False` parameter to the method to force the SDK to return a list of all the instances of the resource instead of the generator. **Beware**: this could take **very long**, depending on the amount of instances that exist of said resource: 99 | 100 | ```python 101 | payment_intents = client.payment_intents.list(since="2025-01-01", until="2025-02-01", lazy=False) 102 | 103 | isinstance(payment_intents, list) # True 104 | ``` 105 | 106 | #### `get` 107 | 108 | You can use the `get` method to get a specific instance of the resource: 109 | 110 | ```python 111 | payment_intent = client.payment_intents.get("pi_8anqVLlBC8ROodem") 112 | ``` 113 | 114 | #### `create` 115 | 116 | You can use the `create` method to create an instance of the resource: 117 | 118 | ```python 119 | webhook_endpoint = client.webhook_endpoints.create( 120 | url="https://webhook.site/58gfb429-c33c-20c7-584b-d5ew3y3202a0", 121 | enabled_events=["link.credentials_changed"], 122 | description="Fantasting webhook endpoint", 123 | ) 124 | ``` 125 | 126 | The `create` method of the managers creates and returns a new instance of the resource. The attributes used for creating the object are passed as `kwargs`, and correspond to the parameters specified by the API documentation for the creation of said resource. 127 | 128 | #### `update` 129 | 130 | You can use the `update` method to update an instance of the resource: 131 | 132 | ```python 133 | webhook_endpoint = client.webhook_endpoints.update( 134 | "we_8anqVLlBC8ROodem", 135 | enabled_events=["account.refresh_intent.succeeded"], 136 | disabled=True, 137 | ) 138 | ``` 139 | 140 | The `update` method updates and returns an existing instance of the resource using its identifier to find it. The first parameter of the method corresponds to the identifier being used to find the existing instance of the resource. The attributes to be modified are passed as `kwargs`, and correspond to the parameters specified by the API documentation for the update action of said resource. 141 | 142 | #### `delete` 143 | 144 | You can use the `delete` method to delete an instance of the resource: 145 | 146 | ```python 147 | deleted_identifier = client.webhook_endpoints.delete("we_8anqVLlBC8ROodem") 148 | ``` 149 | 150 | The `delete` method deletes an existing instance of the resource using its identifier to find it and returns the identifier. 151 | 152 | #### v2 Endpoints 153 | 154 | To call v2 API endpoints, like the [Transfers API](https://docs.fintoc.com/reference/transfers), you need to prepend the resource name with the `v2` namespace, the same as the API does it: 155 | 156 | ```python 157 | transfer = client.v2.transfers.create( 158 | amount=49523, 159 | currency="mxn", 160 | account_id="acc_123545", 161 | counterparty={"account_number": "014180655091438298"}, 162 | metadata={"factura": "14814"}, 163 | ) 164 | ``` 165 | 166 | #### Nested actions or resources 167 | 168 | To call nested actions just call the method as it appears in the API. For example to [simulate receiving a transfer for the Transfers](https://docs.fintoc.com/reference/receive-an-inbound-transfer) product you can do: 169 | 170 | ```python 171 | transfer = client.v2.simulate.receive_transfer( 172 | amount=9912400, 173 | currency="mxn", 174 | account_number_id="acno_2vF18OHZdXXxPJTLJ5qghpo1pdU", 175 | ) 176 | ``` 177 | 178 | ### Webhook Signature Validation 179 | 180 | To ensure the authenticity of incoming webhooks from Fintoc, you should always validate the signature. The SDK provides a `WebhookSignature` class to verify the `Fintoc-Signature` header 181 | 182 | ```python 183 | WebhookSignature.verify_header( 184 | payload=request.get_data().decode('utf-8'), 185 | header=request.headers.get('Fintoc-Signature'), 186 | secret='your_webhook_secret' 187 | ) 188 | ``` 189 | 190 | The `verify_header` method takes the following parameters: 191 | - `payload`: The raw request body as a string 192 | - `header`: The Fintoc-Signature header value 193 | - `secret`: Your webhook secret key (found in your Fintoc dashboard) 194 | - `tolerance`: (Optional) Number of seconds to tolerate when checking timestamp (default: 300) 195 | 196 | If the signature is invalid or the timestamp is outside the tolerance window, a `WebhookSignatureError` will be raised with a descriptive message. 197 | 198 | For a complete example of handling webhooks, see [examples/webhook.py](examples/webhook.py). 199 | 200 | ### Idempotency Keys 201 | 202 | You can provide an [Idempotency Key](https://docs.fintoc.com/reference/idempotent-requests) using the `idempotency_key` argument. For example: 203 | 204 | ```python 205 | transfer = client.v2.transfers.create( 206 | idempotency_key="12345678910" 207 | amount=49523, 208 | currency="mxn", 209 | account_id="acc_123545", 210 | counterparty={"account_number": "014180655091438298"}, 211 | metadata={"factura": "14814"}, 212 | ) 213 | ``` 214 | 215 | ### Generate the JWS Signature 216 | 217 | Some endpoints need a [JWS Signature](https://docs.fintoc.com/docs/setting-up-jws-keys), in addition to your API Key, to verify the integrity and authenticity of API requests. To generate the signature, initialize the Fintoc client with the `jws_private_key` argument, and the SDK will handle the rest: 218 | 219 | ```python 220 | import os 221 | 222 | from fintoc import Fintoc 223 | 224 | # Provide a path to your PEM file 225 | client = Fintoc("your_api_key", jws_private_key="private_key.pem") 226 | 227 | # Or pass the PEM key directly as a string 228 | client = Fintoc("your_api_key", jws_private_key=os.environ.get('JWS_PRIVATE_KEY')) 229 | 230 | # You can now create transfers securely 231 | ``` 232 | 233 | 234 | ### Serialization 235 | 236 | Any resource of the SDK can be serialized! To get the serialized resource, just call the `serialize` method! 237 | 238 | ```python 239 | payment_intent = client.payment_intents.list(lazy=False)[0] 240 | 241 | serialization = payment_intent.serialize() 242 | ``` 243 | 244 | The serialization corresponds to a dictionary with only simple types, that can be JSON-serialized. 245 | 246 | ## Acknowledgements 247 | 248 | The first version of this SDK was originally designed and handcrafted by [**@nebil**](https://github.com/nebil), 249 | [ad](https://en.wikipedia.org/wiki/Ad_honorem) [piscolem](https://en.wiktionary.org/wiki/piscola). 250 | He built it with the help of Gianni Roberto's [Picchi 2](https://www.youtube.com/watch?v=WqjUlmkYr2g). 251 | -------------------------------------------------------------------------------- /tests/mixins/test_resource_mixin.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from fintoc.client import Client 4 | from fintoc.mixins import ResourceMixin 5 | from fintoc.resources import GenericFintocResource, Link 6 | 7 | 8 | class EmptyMockResource(ResourceMixin): 9 | pass 10 | 11 | 12 | class ComplexMockResource(ResourceMixin): 13 | mappings = {"resource": "link"} 14 | resource_identifier = "identifier" 15 | 16 | 17 | class TestResourceMixinCreation: 18 | @pytest.fixture(autouse=True) 19 | def patch_http_client(self, patch_http_client): 20 | pass 21 | 22 | def setup_method(self): 23 | self.base_url = "https://test.com" 24 | self.api_key = "super_secret_api_key" 25 | self.api_version = None 26 | self.user_agent = "fintoc-python/test" 27 | self.params = {"first_param": "first_value", "second_param": "second_value"} 28 | self.client = Client( 29 | self.base_url, 30 | self.api_key, 31 | self.api_version, 32 | self.user_agent, 33 | params=self.params, 34 | ) 35 | self.path = "/resources" 36 | self.handlers = { 37 | "update": lambda object_, identifier: print("Calling update...") or object_, 38 | "delete": lambda identifier: print("Calling delete...") or identifier, 39 | } 40 | 41 | def test_empty_mock_resource(self): 42 | methods = [] 43 | data = { 44 | "id": "id0", 45 | "identifier": "identifier0", 46 | "resources": [ 47 | {"id": "id1", "identifier": "identifier1"}, 48 | {"id": "id2", "identifier": "identifier2"}, 49 | ], 50 | "resource": {"id": "id3", "identifier": "identifier3"}, 51 | } 52 | resource = EmptyMockResource( 53 | self.client, self.handlers, methods, self.path, **data 54 | ) 55 | assert isinstance(resource, ResourceMixin) 56 | assert isinstance(resource.resource, GenericFintocResource) 57 | assert resource.resource.id == data["resource"]["id"] 58 | assert isinstance(resource.resources, list) 59 | for sub_resource in resource.resources: 60 | assert isinstance(sub_resource, GenericFintocResource) 61 | 62 | def test_complex_mock_resource(self): 63 | methods = [] 64 | data = { 65 | "id": "id0", 66 | "identifier": "identifier0", 67 | "resources": [ 68 | {"id": "id1", "identifier": "identifier1"}, 69 | {"id": "id2", "identifier": "identifier2"}, 70 | ], 71 | "resource": {"id": "id3", "identifier": "identifier3"}, 72 | } 73 | resource = ComplexMockResource( 74 | self.client, self.handlers, methods, self.path, **data 75 | ) 76 | assert isinstance(resource, ResourceMixin) 77 | assert isinstance(resource.resource, Link) 78 | assert resource.resource.id == data["resource"]["id"] 79 | assert isinstance(resource.resources, list) 80 | for sub_resource in resource.resources: 81 | assert isinstance(sub_resource, GenericFintocResource) 82 | 83 | def test_update_delete_methods_access(self): 84 | methods = ["delete"] 85 | data = { 86 | "id": "id0", 87 | "identifier": "identifier0", 88 | "resources": [ 89 | {"id": "id1", "identifier": "identifier1"}, 90 | {"id": "id2", "identifier": "identifier2"}, 91 | ], 92 | "resource": {"id": "id3", "identifier": "identifier3"}, 93 | } 94 | resource = EmptyMockResource( 95 | self.client, self.handlers, methods, self.path, **data 96 | ) 97 | assert isinstance(resource, ResourceMixin) 98 | 99 | with pytest.raises(AttributeError): 100 | resource.update() 101 | 102 | resource.delete() 103 | 104 | 105 | class TestMixinSerializeMethod: 106 | @pytest.fixture(autouse=True) 107 | def patch_http_client(self, patch_http_client): 108 | pass 109 | 110 | def setup_method(self): 111 | self.base_url = "https://test.com" 112 | self.api_key = "super_secret_api_key" 113 | self.api_version = None 114 | self.user_agent = "fintoc-python/test" 115 | self.params = {"first_param": "first_value", "second_param": "second_value"} 116 | self.client = Client( 117 | self.base_url, 118 | self.api_key, 119 | self.api_version, 120 | self.user_agent, 121 | params=self.params, 122 | ) 123 | self.path = "/resources" 124 | self.handlers = { 125 | "update": lambda object_, identifier: print("Calling update...") or object_, 126 | "delete": lambda identifier: print("Calling delete...") or identifier, 127 | } 128 | 129 | def test_serialization_method(self): 130 | methods = ["delete"] 131 | data = { 132 | "id": "id0", 133 | "identifier": "identifier0", 134 | "resource": {"id": "id3", "identifier": "identifier3"}, 135 | } 136 | resource = EmptyMockResource( 137 | self.client, self.handlers, methods, self.path, **data 138 | ) 139 | assert resource.serialize() == data 140 | 141 | def test_array_serialization_method(self): 142 | methods = ["delete"] 143 | data = { 144 | "id": "id0", 145 | "identifier": "identifier0", 146 | "resources": [ 147 | {"id": "id1", "identifier": "identifier1"}, 148 | {"id": "id2", "identifier": "identifier2"}, 149 | ], 150 | "resource": {"id": "id3", "identifier": "identifier3"}, 151 | } 152 | resource = EmptyMockResource( 153 | self.client, self.handlers, methods, self.path, **data 154 | ) 155 | assert resource.serialize() == data 156 | 157 | 158 | class TestMixinUpdateAndDeleteMethods: 159 | @pytest.fixture(autouse=True) 160 | def patch_http_client(self, patch_http_client): 161 | pass 162 | 163 | def setup_method(self): 164 | self.base_url = "https://test.com" 165 | self.api_key = "super_secret_api_key" 166 | self.api_version = None 167 | self.user_agent = "fintoc-python/test" 168 | self.params = {"first_param": "first_value", "second_param": "second_value"} 169 | self.client = Client( 170 | self.base_url, 171 | self.api_key, 172 | self.api_version, 173 | self.user_agent, 174 | params=self.params, 175 | ) 176 | self.path = "/resources" 177 | self.handlers = { 178 | "update": lambda object_, identifier: print("Calling update...") or object_, 179 | "delete": lambda identifier: print("Calling delete...") or identifier, 180 | } 181 | 182 | def test_complex_mock_resource_delete_method(self, capsys): 183 | methods = ["delete"] 184 | data = { 185 | "id": "id0", 186 | "identifier": "identifier0", 187 | "resources": [ 188 | {"id": "id1", "identifier": "identifier1"}, 189 | {"id": "id2", "identifier": "identifier2"}, 190 | ], 191 | "resource": {"id": "id3", "identifier": "identifier3"}, 192 | } 193 | resource = EmptyMockResource( 194 | self.client, self.handlers, methods, self.path, **data 195 | ) 196 | identifier = resource.delete() 197 | 198 | captured = capsys.readouterr().out 199 | assert "delete" in captured 200 | 201 | assert identifier != data["identifier"] 202 | assert identifier == data["id"] 203 | 204 | def test_empty_mock_resource_delete_method(self, capsys): 205 | methods = ["delete"] 206 | data = { 207 | "id": "id0", 208 | "identifier": "identifier0", 209 | "resources": [ 210 | {"id": "id1", "identifier": "identifier1"}, 211 | {"id": "id2", "identifier": "identifier2"}, 212 | ], 213 | "resource": {"id": "id3", "identifier": "identifier3"}, 214 | } 215 | resource = ComplexMockResource( 216 | self.client, self.handlers, methods, self.path, **data 217 | ) 218 | identifier = resource.delete() 219 | 220 | captured = capsys.readouterr().out 221 | assert "delete" in captured 222 | 223 | assert identifier != data["id"] 224 | assert identifier == data["identifier"] 225 | 226 | def test_complex_mock_resource_update_method(self, capsys): 227 | methods = ["update"] 228 | data = { 229 | "id": "id0", 230 | "identifier": "identifier0", 231 | "resources": [ 232 | {"id": "id1", "identifier": "identifier1"}, 233 | {"id": "id2", "identifier": "identifier2"}, 234 | ], 235 | "resource": {"id": "id3", "identifier": "identifier3"}, 236 | } 237 | resource = EmptyMockResource( 238 | self.client, self.handlers, methods, self.path, **data 239 | ) 240 | 241 | resource.update() 242 | 243 | captured = capsys.readouterr().out 244 | assert "update" in captured 245 | 246 | assert data["identifier"] not in resource.url 247 | assert data["id"] in resource.url 248 | 249 | def test_empty_mock_resource_update_method(self, capsys): 250 | methods = ["update"] 251 | data = { 252 | "id": "id0", 253 | "identifier": "identifier0", 254 | "resources": [ 255 | {"id": "id1", "identifier": "identifier1"}, 256 | {"id": "id2", "identifier": "identifier2"}, 257 | ], 258 | "resource": {"id": "id3", "identifier": "identifier3"}, 259 | } 260 | resource = ComplexMockResource( 261 | self.client, self.handlers, methods, self.path, **data 262 | ) 263 | resource.update() 264 | 265 | captured = capsys.readouterr().out 266 | assert "update" in captured 267 | 268 | assert data["id"] not in resource.url 269 | assert data["identifier"] in resource.url 270 | 271 | def test_resource_update_with_custom_path(self, capsys): 272 | methods = ["update"] 273 | data = { 274 | "id": "id0", 275 | "identifier": "identifier0", 276 | } 277 | resource = EmptyMockResource( 278 | self.client, self.handlers, methods, self.path, **data 279 | ) 280 | 281 | custom_path = f"{self.path}/id0/cancel" 282 | resource.update(path_=custom_path) 283 | 284 | captured = capsys.readouterr().out 285 | assert "update" in captured 286 | assert resource.url == custom_path.lstrip("/") 287 | -------------------------------------------------------------------------------- /tests/test_integration.py: -------------------------------------------------------------------------------- 1 | """Integration tests for the Fintoc core object.""" 2 | 3 | import pytest 4 | 5 | from fintoc.core import Fintoc 6 | 7 | 8 | class TestFintocIntegration: 9 | """Test class to verify Fintoc core object integration with managers.""" 10 | 11 | @pytest.fixture(autouse=True) 12 | def patch_http_client(self, patch_http_client): 13 | """Use the mock HTTP client from conftest.py.""" 14 | pass 15 | 16 | def setup_method(self): 17 | """Set up the test environment.""" 18 | self.api_key = "test_api_key" 19 | self.fintoc = Fintoc(self.api_key) 20 | 21 | def test_links_list(self): 22 | """Test that fintoc.links.list() calls the correct URL.""" 23 | links = list(self.fintoc.links.list()) 24 | 25 | assert len(links) > 0 26 | for link in links: 27 | assert link.method == "get" 28 | assert link.url == "v1/links" 29 | 30 | def test_links_get(self): 31 | """Test that fintoc.links.get(link_id) calls the correct URL.""" 32 | link_token = "test_link_token" 33 | 34 | link = self.fintoc.links.get(link_token) 35 | 36 | assert link.method == "get" 37 | assert link.url == f"v1/links/{link_token}" 38 | 39 | def test_links_update(self): 40 | """Test that fintoc.links.update() calls the correct URL.""" 41 | link_token = "test_link_token" 42 | update_data = {"active": False} 43 | 44 | updated_link = self.fintoc.links.update(link_token, **update_data) 45 | 46 | assert updated_link.method == "patch" 47 | assert updated_link.url == f"v1/links/{link_token}" 48 | assert updated_link.json.active == update_data["active"] 49 | 50 | def test_links_delete(self): 51 | """Test that fintoc.links.delete() calls the correct URL.""" 52 | link_id = "test_link_id" 53 | 54 | result = self.fintoc.links.delete(link_id) 55 | 56 | assert result == link_id 57 | 58 | def test_link_accounts_list(self): 59 | """Test getting accounts from a link.""" 60 | 61 | def assert_accounts(accounts): 62 | assert len(accounts) > 0 63 | for account in accounts: 64 | assert account.method == "get" 65 | assert account.url == "v1/accounts" 66 | assert account.params.link_token == link_token 67 | 68 | link_token = "test_link_token" 69 | 70 | # Test using the resource 71 | link = self.fintoc.links.get(link_token) 72 | assert_accounts(list(link.accounts.list())) 73 | 74 | # Test using directly the manager 75 | assert_accounts(list(self.fintoc.accounts.list(link_token=link_token))) 76 | 77 | def test_link_account_get(self): 78 | """Test getting a specific account from a link.""" 79 | 80 | def assert_account(account): 81 | assert account.method == "get" 82 | assert account.params.link_token == link_token 83 | assert account.url == f"v1/accounts/{account_id}" 84 | 85 | link_token = "test_link_token" 86 | account_id = "test_account_id" 87 | 88 | # Test using the resource 89 | link = self.fintoc.links.get(link_token) 90 | account = link.accounts.get(account_id) 91 | assert_account(account) 92 | 93 | # Test using directly the manager 94 | account = self.fintoc.accounts.get(account_id, link_token=link_token) 95 | assert_account(account) 96 | 97 | def test_account_movements_list(self): 98 | """Test getting movements from an account.""" 99 | 100 | def assert_movements(movements): 101 | assert len(movements) > 0 102 | for movement in movements: 103 | assert movement.method == "get" 104 | assert movement.url == f"v1/accounts/{account_id}/movements" 105 | assert movement.params.link_token == link_token 106 | 107 | link_token = "test_link_token" 108 | account_id = "test_account_id" 109 | 110 | # Test using the resource 111 | link = self.fintoc.links.get(link_token) 112 | account = link.accounts.get(account_id) 113 | assert_movements(list(account.movements.list())) 114 | 115 | # Test using directly the manager 116 | assert_movements( 117 | list( 118 | self.fintoc.accounts.movements.list( 119 | account_id=account_id, link_token=link_token 120 | ) 121 | ) 122 | ) 123 | 124 | def test_account_movement_get(self): 125 | """Test getting a specific movement from an account.""" 126 | 127 | def assert_movement(movement): 128 | assert movement.method == "get" 129 | assert movement.url == f"v1/accounts/{account_id}/movements/{movement_id}" 130 | assert movement.params.link_token == link_token 131 | 132 | link_token = "test_link_token" 133 | account_id = "test_account_id" 134 | movement_id = "test_movement_id" 135 | 136 | # Test using the resource 137 | link = self.fintoc.links.get(link_token) 138 | account = link.accounts.get(account_id) 139 | movement = account.movements.get(movement_id) 140 | assert_movement(movement) 141 | 142 | # Test using directly the manager 143 | movement = self.fintoc.accounts.movements.get( 144 | movement_id, account_id=account_id, link_token=link_token 145 | ) 146 | assert_movement(movement) 147 | 148 | def test_link_subscriptions_list(self): 149 | """Test getting all subscriptions from a link.""" 150 | 151 | def assert_subscriptions(subscriptions): 152 | assert len(subscriptions) > 0 153 | for subscription in subscriptions: 154 | assert subscription.method == "get" 155 | assert subscription.url == "v1/subscriptions" 156 | 157 | link_token = "test_link_token" 158 | 159 | # Test using the resource. This actually should be removed, because 160 | # subscriptions do not depend on a link. 161 | link = self.fintoc.links.get(link_token) 162 | assert_subscriptions(list(link.subscriptions.list())) 163 | 164 | # Test using directly the manager 165 | assert_subscriptions(list(self.fintoc.subscriptions.list())) 166 | 167 | def test_link_subscription_get(self): 168 | """Test getting a specific subscription from a link.""" 169 | 170 | def assert_subscription(subscription): 171 | assert subscription.method == "get" 172 | assert subscription.url == f"v1/subscriptions/{subscription_id}" 173 | 174 | link_token = "test_link_token" 175 | subscription_id = "test_subscription_id" 176 | 177 | # Test using the resource 178 | link = self.fintoc.links.get(link_token) 179 | subscription = link.subscriptions.get(subscription_id) 180 | assert_subscription(subscription) 181 | 182 | # Test using directly the manager 183 | subscription = self.fintoc.subscriptions.get( 184 | subscription_id, link_token=link_token 185 | ) 186 | assert_subscription(subscription) 187 | 188 | def test_link_tax_returns_list(self): 189 | """Test getting all tax returns from a link.""" 190 | 191 | def assert_tax_returns(tax_returns): 192 | assert len(tax_returns) > 0 193 | for tax_return in tax_returns: 194 | assert tax_return.method == "get" 195 | assert tax_return.url == "v1/tax_returns" 196 | assert tax_return.params.link_token == link_token 197 | 198 | link_token = "test_link_token" 199 | 200 | # Test using the resource 201 | link = self.fintoc.links.get(link_token) 202 | assert_tax_returns(list(link.tax_returns.list())) 203 | 204 | # Test using directly the manager 205 | assert_tax_returns(list(self.fintoc.tax_returns.list(link_token=link_token))) 206 | 207 | def test_link_tax_return_get(self): 208 | """Test getting a specific tax return from a link.""" 209 | 210 | def assert_tax_return(tax_return): 211 | assert tax_return.method == "get" 212 | assert tax_return.url == f"v1/tax_returns/{tax_return_id}" 213 | assert tax_return.params.link_token == link_token 214 | 215 | link_token = "test_link_token" 216 | tax_return_id = "test_tax_return_id" 217 | 218 | # Test using the resource 219 | link = self.fintoc.links.get(link_token) 220 | tax_return = link.tax_returns.get(tax_return_id) 221 | assert_tax_return(tax_return) 222 | 223 | # Test using directly the manager 224 | tax_return = self.fintoc.tax_returns.get(tax_return_id, link_token=link_token) 225 | assert_tax_return(tax_return) 226 | 227 | def test_link_invoices_list(self): 228 | """Test getting all invoices from a link.""" 229 | 230 | def assert_invoices(invoices): 231 | assert len(invoices) > 0 232 | for invoice in invoices: 233 | assert invoice.method == "get" 234 | assert invoice.url == "v1/invoices" 235 | assert invoice.params.link_token == link_token 236 | 237 | link_token = "test_link_token" 238 | 239 | # Test using the resource 240 | link = self.fintoc.links.get(link_token) 241 | assert_invoices(list(link.invoices.list())) 242 | 243 | # Test using directly the manager 244 | assert_invoices(list(self.fintoc.invoices.list(link_token=link_token))) 245 | 246 | def test_link_refresh_intents_list(self): 247 | """Test getting all refresh intents from a link.""" 248 | 249 | def assert_refresh_intents(refresh_intents): 250 | assert len(refresh_intents) > 0 251 | for refresh_intent in refresh_intents: 252 | assert refresh_intent.method == "get" 253 | assert refresh_intent.url == "v1/refresh_intents" 254 | assert refresh_intent.params.link_token == link_token 255 | 256 | link_token = "test_link_token" 257 | 258 | # Test using the resource 259 | link = self.fintoc.links.get(link_token) 260 | assert_refresh_intents(list(link.refresh_intents.list())) 261 | 262 | # Test using directly the manager 263 | assert_refresh_intents( 264 | list(self.fintoc.refresh_intents.list(link_token=link_token)) 265 | ) 266 | 267 | def test_link_refresh_intent_get(self): 268 | """Test getting a specific refresh intent from a link.""" 269 | 270 | def assert_refresh_intent(refresh_intent): 271 | assert refresh_intent.method == "get" 272 | assert refresh_intent.url == f"v1/refresh_intents/{refresh_intent_id}" 273 | assert refresh_intent.params.link_token == link_token 274 | 275 | link_token = "test_link_token" 276 | refresh_intent_id = "test_refresh_intent_id" 277 | 278 | # Test using the resource 279 | link = self.fintoc.links.get(link_token) 280 | refresh_intent = link.refresh_intents.get(refresh_intent_id) 281 | assert_refresh_intent(refresh_intent) 282 | 283 | # Test using directly the manager 284 | refresh_intent = self.fintoc.refresh_intents.get( 285 | refresh_intent_id, link_token=link_token 286 | ) 287 | assert_refresh_intent(refresh_intent) 288 | 289 | def test_link_refresh_intent_create(self): 290 | """Test creating a refresh intent for a link.""" 291 | 292 | def assert_refresh_intent(refresh_intent): 293 | assert refresh_intent.method == "post" 294 | assert refresh_intent.url == "v1/refresh_intents" 295 | assert refresh_intent.json.refresh_type == "only_last" 296 | # Check link_token in either params or json 297 | assert ( 298 | hasattr(refresh_intent.json, "link_token") 299 | and refresh_intent.json.link_token == link_token 300 | ) or ( 301 | hasattr(refresh_intent.params, "link_token") 302 | and refresh_intent.params.link_token == link_token 303 | ) 304 | 305 | link_token = "test_link_token" 306 | 307 | # Test using the resource 308 | link = self.fintoc.links.get(link_token) 309 | refresh_intent = link.refresh_intents.create(refresh_type="only_last") 310 | assert_refresh_intent(refresh_intent) 311 | 312 | # Test using directly the manager 313 | refresh_intent = self.fintoc.refresh_intents.create( 314 | refresh_type="only_last", link_token=link_token 315 | ) 316 | assert_refresh_intent(refresh_intent) 317 | 318 | def test_charges_list(self): 319 | """Test getting all charges.""" 320 | charges = list(self.fintoc.charges.list()) 321 | 322 | assert len(charges) > 0 323 | for charge in charges: 324 | assert charge.method == "get" 325 | assert charge.url == "v1/charges" 326 | 327 | def test_charge_get(self): 328 | """Test getting a specific charge.""" 329 | charge_id = "test_charge_id" 330 | 331 | charge = self.fintoc.charges.get(charge_id) 332 | 333 | assert charge.method == "get" 334 | assert charge.url == f"v1/charges/{charge_id}" 335 | 336 | def test_charge_create(self): 337 | """Test creating a charge.""" 338 | charge_data = { 339 | "amount": 1000, 340 | "currency": "CLP", 341 | "payment_method": "bank_transfer", 342 | } 343 | 344 | charge = self.fintoc.charges.create(**charge_data) 345 | 346 | assert charge.method == "post" 347 | assert charge.url == "v1/charges" 348 | assert charge.json.amount == charge_data["amount"] 349 | assert charge.json.currency == charge_data["currency"] 350 | assert charge.json.payment_method == charge_data["payment_method"] 351 | 352 | def test_payment_intents_list(self): 353 | """Test getting all payment intents.""" 354 | payment_intents = list(self.fintoc.payment_intents.list()) 355 | 356 | assert len(payment_intents) > 0 357 | for payment_intent in payment_intents: 358 | assert payment_intent.method == "get" 359 | assert payment_intent.url == "v1/payment_intents" 360 | 361 | def test_payment_intent_get(self): 362 | """Test getting a specific payment intent.""" 363 | payment_intent_id = "test_payment_intent_id" 364 | 365 | payment_intent = self.fintoc.payment_intents.get(payment_intent_id) 366 | 367 | assert payment_intent.method == "get" 368 | assert payment_intent.url == f"v1/payment_intents/{payment_intent_id}" 369 | 370 | def test_payment_intent_create(self): 371 | """Test creating a payment intent.""" 372 | payment_intent_data = { 373 | "amount": 1000, 374 | "currency": "CLP", 375 | "payment_type": "bank_transfer", 376 | } 377 | 378 | payment_intent = self.fintoc.payment_intents.create(**payment_intent_data) 379 | 380 | assert payment_intent.method == "post" 381 | assert payment_intent.url == "v1/payment_intents" 382 | assert payment_intent.json.amount == payment_intent_data["amount"] 383 | assert payment_intent.json.currency == payment_intent_data["currency"] 384 | assert payment_intent.json.payment_type == payment_intent_data["payment_type"] 385 | 386 | def test_payment_intent_expire(self): 387 | """Test expiring a payment intent.""" 388 | payment_intent_id = "test_payment_intent_id" 389 | 390 | result = self.fintoc.payment_intents.expire(payment_intent_id) 391 | 392 | assert result.method == "post" 393 | assert result.url == f"v1/payment_intents/{payment_intent_id}/expire" 394 | 395 | def test_payment_intent_check_eligibility(self): 396 | """Test checking eligibility for a payment intent.""" 397 | eligibility_data = { 398 | "amount": 1000, 399 | "currency": "CLP", 400 | } 401 | 402 | result = self.fintoc.payment_intents.check_eligibility(**eligibility_data) 403 | 404 | assert result.method == "post" 405 | assert result.url == "v1/payment_intents/check_eligibility" 406 | 407 | def test_payment_links_list(self): 408 | """Test getting all payment links.""" 409 | payment_links = list(self.fintoc.payment_links.list()) 410 | 411 | assert len(payment_links) > 0 412 | for payment_link in payment_links: 413 | assert payment_link.method == "get" 414 | assert payment_link.url == "v1/payment_links" 415 | 416 | def test_payment_link_get(self): 417 | """Test getting a specific payment link.""" 418 | payment_link_id = "test_payment_link_id" 419 | 420 | payment_link = self.fintoc.payment_links.get(payment_link_id) 421 | 422 | assert payment_link.method == "get" 423 | assert payment_link.url == f"v1/payment_links/{payment_link_id}" 424 | 425 | def test_payment_link_create(self): 426 | """Test creating a payment link.""" 427 | payment_link_data = { 428 | "amount": 1000, 429 | "currency": "CLP", 430 | "description": "Test payment link", 431 | } 432 | 433 | payment_link = self.fintoc.payment_links.create(**payment_link_data) 434 | 435 | assert payment_link.method == "post" 436 | assert payment_link.url == "v1/payment_links" 437 | assert payment_link.json.amount == payment_link_data["amount"] 438 | assert payment_link.json.currency == payment_link_data["currency"] 439 | assert payment_link.json.description == payment_link_data["description"] 440 | 441 | def test_payment_link_cancel(self): 442 | """Test canceling a payment link.""" 443 | payment_link_id = "test_payment_link_id" 444 | 445 | result = self.fintoc.payment_links.cancel(payment_link_id) 446 | 447 | assert result.method == "patch" 448 | assert result.url == f"v1/payment_links/{payment_link_id}/cancel" 449 | 450 | def test_refund_list(self): 451 | """Test getting all refunds.""" 452 | refunds = list(self.fintoc.refunds.list()) 453 | 454 | assert len(refunds) > 0 455 | for refund in refunds: 456 | assert refund.method == "get" 457 | assert refund.url == "v1/refunds" 458 | 459 | def test_refund_get(self): 460 | """Test getting a specific refund.""" 461 | refund_id = "test_refund_id" 462 | 463 | refund = self.fintoc.refunds.get(refund_id) 464 | 465 | assert refund.method == "get" 466 | assert refund.url == f"v1/refunds/{refund_id}" 467 | 468 | def test_refund_create(self): 469 | """Test creating a refund.""" 470 | refund_data = { 471 | "resource_type": "payment_intent", 472 | "resource_id": "pi_30yWq311fOLrAAKkSH1bvODVLGa", 473 | "amount": 1000, 474 | } 475 | 476 | refund = self.fintoc.refunds.create(**refund_data) 477 | 478 | assert refund.method == "post" 479 | assert refund.url == "v1/refunds" 480 | assert refund.json.resource_type == refund_data["resource_type"] 481 | assert refund.json.resource_id == refund_data["resource_id"] 482 | assert refund.json.amount == refund_data["amount"] 483 | 484 | def test_refund_cancel(self): 485 | """Test canceling a refund.""" 486 | refund_id = "ref_QmbpWzP1HOngN3X7" 487 | 488 | refund = self.fintoc.refunds.cancel(refund_id) 489 | 490 | assert refund.method == "post" 491 | assert refund.url == f"v1/refunds/{refund_id}/cancel" 492 | 493 | def test_subscription_intents_list(self): 494 | """Test getting all subscription intents.""" 495 | subscription_intents = list(self.fintoc.subscription_intents.list()) 496 | 497 | assert len(subscription_intents) > 0 498 | for subscription_intent in subscription_intents: 499 | assert subscription_intent.method == "get" 500 | assert subscription_intent.url == "v1/subscription_intents" 501 | 502 | def test_subscription_intent_get(self): 503 | """Test getting a specific subscription intent.""" 504 | subscription_intent_id = "test_subscription_intent_id" 505 | 506 | subscription_intent = self.fintoc.subscription_intents.get( 507 | subscription_intent_id 508 | ) 509 | 510 | assert subscription_intent.method == "get" 511 | assert ( 512 | subscription_intent.url 513 | == f"v1/subscription_intents/{subscription_intent_id}" 514 | ) 515 | 516 | def test_subscription_intent_create(self): 517 | """Test creating a subscription intent.""" 518 | subscription_intent_data = {"amount": 1000, "currency": "CLP"} 519 | 520 | subscription_intent = self.fintoc.subscription_intents.create( 521 | **subscription_intent_data 522 | ) 523 | 524 | assert subscription_intent.method == "post" 525 | assert subscription_intent.url == "v1/subscription_intents" 526 | assert subscription_intent.json.amount == subscription_intent_data["amount"] 527 | assert subscription_intent.json.currency == subscription_intent_data["currency"] 528 | 529 | def test_webhook_endpoints_list(self): 530 | """Test getting all webhook endpoints.""" 531 | webhook_endpoints = list(self.fintoc.webhook_endpoints.list()) 532 | 533 | assert len(webhook_endpoints) > 0 534 | for webhook_endpoint in webhook_endpoints: 535 | assert webhook_endpoint.method == "get" 536 | assert webhook_endpoint.url == "v1/webhook_endpoints" 537 | 538 | def test_webhook_endpoint_get(self): 539 | """Test getting a specific webhook endpoint.""" 540 | webhook_endpoint_id = "test_webhook_endpoint_id" 541 | 542 | webhook_endpoint = self.fintoc.webhook_endpoints.get(webhook_endpoint_id) 543 | 544 | assert webhook_endpoint.method == "get" 545 | assert webhook_endpoint.url == f"v1/webhook_endpoints/{webhook_endpoint_id}" 546 | 547 | def test_webhook_endpoint_create(self): 548 | """Test creating a webhook endpoint.""" 549 | webhook_endpoint_data = { 550 | "url": "https://example.com/webhook", 551 | "enabled_events": ["movement.created", "link.updated"], 552 | } 553 | 554 | webhook_endpoint = self.fintoc.webhook_endpoints.create(**webhook_endpoint_data) 555 | 556 | assert webhook_endpoint.method == "post" 557 | assert webhook_endpoint.url == "v1/webhook_endpoints" 558 | assert webhook_endpoint.json.url == webhook_endpoint_data["url"] 559 | assert ( 560 | webhook_endpoint.json.enabled_events 561 | == webhook_endpoint_data["enabled_events"] 562 | ) 563 | 564 | def test_webhook_endpoint_update(self): 565 | """Test updating a webhook endpoint.""" 566 | webhook_endpoint_id = "test_webhook_endpoint_id" 567 | update_data = { 568 | "enabled_events": [ 569 | "refund.succeeded", 570 | "link.updated", 571 | "payment_intent.failed", 572 | ] 573 | } 574 | 575 | webhook_endpoint = self.fintoc.webhook_endpoints.update( 576 | webhook_endpoint_id, **update_data 577 | ) 578 | 579 | assert webhook_endpoint.method == "patch" 580 | assert webhook_endpoint.url == f"v1/webhook_endpoints/{webhook_endpoint_id}" 581 | assert webhook_endpoint.json.enabled_events == update_data["enabled_events"] 582 | 583 | def test_webhook_endpoint_delete(self): 584 | """Test deleting a webhook endpoint.""" 585 | webhook_endpoint_id = "test_webhook_endpoint_id" 586 | 587 | result = self.fintoc.webhook_endpoints.delete(webhook_endpoint_id) 588 | 589 | assert result == webhook_endpoint_id 590 | 591 | def test_v2_accounts_list(self): 592 | """Test getting all accounts using v2 API.""" 593 | accounts = list(self.fintoc.v2.accounts.list()) 594 | 595 | assert len(accounts) > 0 596 | for account in accounts: 597 | assert account.method == "get" 598 | assert account.url == "v2/accounts" 599 | 600 | def test_v2_account_get(self): 601 | """Test getting a specific account using v2 API.""" 602 | account_id = "test_account_id" 603 | 604 | account = self.fintoc.v2.accounts.get(account_id) 605 | 606 | assert account.method == "get" 607 | assert account.url == f"v2/accounts/{account_id}" 608 | 609 | def test_v2_account_create(self): 610 | """Test creating an account using v2 API.""" 611 | account_data = {"description": "New test account"} 612 | 613 | account = self.fintoc.v2.accounts.create(**account_data) 614 | 615 | assert account.method == "post" 616 | assert account.url == "v2/accounts" 617 | assert account.json.description == account_data["description"] 618 | 619 | def test_v2_account_update(self): 620 | """Test updating an account using v2 API.""" 621 | account_id = "test_account_id" 622 | update_data = {"description": "Updated test account"} 623 | 624 | account = self.fintoc.v2.accounts.update(account_id, **update_data) 625 | 626 | assert account.method == "patch" 627 | assert account.url == f"v2/accounts/{account_id}" 628 | assert account.json.description == update_data["description"] 629 | 630 | def test_v2_account_numbers_list(self): 631 | """Test getting all account numbers using v2 API.""" 632 | account_id = "test_account_id" 633 | account_numbers = list( 634 | self.fintoc.v2.account_numbers.list(account_id=account_id) 635 | ) 636 | 637 | assert len(account_numbers) > 0 638 | for account_number in account_numbers: 639 | assert account_number.method == "get" 640 | assert account_number.url == "v2/account_numbers" 641 | assert account_number.params.account_id == account_id 642 | 643 | def test_v2_account_number_get(self): 644 | """Test getting a specific account number using v2 API.""" 645 | account_number_id = "test_account_number_id" 646 | 647 | account_number = self.fintoc.v2.account_numbers.get(account_number_id) 648 | 649 | assert account_number.method == "get" 650 | assert account_number.url == f"v2/account_numbers/{account_number_id}" 651 | 652 | def test_v2_account_number_create(self): 653 | """Test creating an account number using v2 API.""" 654 | account_id = "test_account_id" 655 | 656 | description = "Test account number" 657 | metadata = {"test_key": "test_value"} 658 | 659 | account_number = self.fintoc.v2.account_numbers.create( 660 | account_id=account_id, description=description, metadata=metadata 661 | ) 662 | 663 | assert account_number.method == "post" 664 | assert account_number.url == "v2/account_numbers" 665 | assert account_number.json.description == description 666 | assert account_number.json.metadata.test_key == metadata["test_key"] 667 | assert account_number.json.account_id == account_id 668 | 669 | def test_v2_account_number_update(self): 670 | """Test updating an account number using v2 API.""" 671 | account_number_id = "test_account_number_id" 672 | metadata = {"test_key": "test_value"} 673 | 674 | account_number = self.fintoc.v2.account_numbers.update( 675 | account_number_id, metadata=metadata 676 | ) 677 | 678 | assert account_number.method == "patch" 679 | assert account_number.url == f"v2/account_numbers/{account_number_id}" 680 | assert account_number.json.metadata.test_key == metadata["test_key"] 681 | 682 | def test_v2_account_verification_list(self): 683 | """Test getting all account verifications using v2 API.""" 684 | account_verifications = list(self.fintoc.v2.account_verifications.list()) 685 | 686 | assert len(account_verifications) > 0 687 | for account_verification in account_verifications: 688 | assert account_verification.method == "get" 689 | assert account_verification.url == "v2/account_verifications" 690 | 691 | def test_v2_account_verification_get(self): 692 | """Test getting a specific account number using v2 API.""" 693 | account_verification_id = "test_account_verification_id" 694 | 695 | account_verification = self.fintoc.v2.account_verifications.get( 696 | account_verification_id 697 | ) 698 | 699 | assert account_verification.method == "get" 700 | assert ( 701 | account_verification.url 702 | == f"v2/account_verifications/{account_verification_id}" 703 | ) 704 | 705 | def test_v2_account_verification_create(self): 706 | """Test creating an account number using v2 API.""" 707 | account_number = "123456789" 708 | 709 | account_verification = self.fintoc.v2.account_verifications.create( 710 | account_number=account_number 711 | ) 712 | 713 | assert account_verification.method == "post" 714 | assert account_verification.url == "v2/account_verifications" 715 | assert account_verification.json.account_number == account_number 716 | 717 | def test_v2_entities_list(self): 718 | """Test getting all entities using v2 API.""" 719 | entities = list(self.fintoc.v2.entities.list()) 720 | 721 | assert len(entities) > 0 722 | for entity in entities: 723 | assert entity.method == "get" 724 | assert entity.url == "v2/entities" 725 | 726 | def test_v2_entity_get(self): 727 | """Test getting a specific entity using v2 API.""" 728 | entity_id = "test_entity_id" 729 | 730 | entity = self.fintoc.v2.entities.get(entity_id) 731 | 732 | assert entity.method == "get" 733 | assert entity.url == f"v2/entities/{entity_id}" 734 | 735 | def test_v2_transfers_list(self): 736 | """Test getting all transfers using v2 API.""" 737 | account_id = "test_account_id" 738 | transfers = list(self.fintoc.v2.transfers.list(account_id=account_id)) 739 | 740 | assert len(transfers) > 0 741 | for transfer in transfers: 742 | assert transfer.method == "get" 743 | assert transfer.url == "v2/transfers" 744 | assert transfer.params.account_id == account_id 745 | 746 | def test_v2_transfer_get(self): 747 | """Test getting a specific transfer using v2 API.""" 748 | transfer_id = "test_transfer_id" 749 | 750 | transfer = self.fintoc.v2.transfers.get(transfer_id) 751 | 752 | assert transfer.method == "get" 753 | assert transfer.url == f"v2/transfers/{transfer_id}" 754 | 755 | def test_v2_transfer_create(self): 756 | """Test creating a transfer using v2 API.""" 757 | account_id = "test_account_id" 758 | amount = 10000 759 | currency = "MXN" 760 | description = "Test transfer" 761 | metadata = {"test_key": "test_value"} 762 | 763 | transfer = self.fintoc.v2.transfers.create( 764 | account_id=account_id, 765 | amount=amount, 766 | currency=currency, 767 | description=description, 768 | metadata=metadata, 769 | ) 770 | 771 | assert transfer.method == "post" 772 | assert transfer.url == "v2/transfers" 773 | assert transfer.json.amount == amount 774 | assert transfer.json.currency == currency 775 | assert transfer.json.description == description 776 | assert transfer.json.metadata.test_key == metadata["test_key"] 777 | 778 | idempotency_key_header = getattr(transfer.headers, "idempotency-key") 779 | assert idempotency_key_header is not None and idempotency_key_header != "" 780 | 781 | idempotency_key = "123456" 782 | transfer = self.fintoc.v2.transfers.create( 783 | account_id=account_id, 784 | amount=amount, 785 | currency=currency, 786 | description=description, 787 | metadata=metadata, 788 | idempotency_key=idempotency_key, 789 | ) 790 | idempotency_key_header = getattr(transfer.headers, "idempotency-key") 791 | assert idempotency_key_header == "123456" 792 | 793 | def test_v2_simulate_receive_transfer(self): 794 | """Test simulating receiving a transfer using v2 API.""" 795 | account_number_id = "test_account_number_id" 796 | amount = 10000 797 | currency = "MXN" 798 | 799 | transfer = self.fintoc.v2.simulate.receive_transfer( 800 | account_number_id=account_number_id, amount=amount, currency=currency 801 | ) 802 | 803 | assert transfer.method == "post" 804 | assert transfer.url == "v2/simulate/receive_transfer" 805 | assert transfer.json.amount == amount 806 | assert transfer.json.currency == currency 807 | assert transfer.json.account_number_id == account_number_id 808 | 809 | def test_checkout_session_create(self): 810 | """Test creating a checkout session.""" 811 | checkout_session_data = { 812 | "amount": 5000, 813 | "currency": "CLP", 814 | "success_url": "https://example.com/success", 815 | "cancel_url": "https://example.com/cancel", 816 | } 817 | 818 | checkout_session = self.fintoc.checkout_sessions.create(**checkout_session_data) 819 | 820 | assert checkout_session.method == "post" 821 | assert checkout_session.url == "v1/checkout_sessions" 822 | assert checkout_session.json.amount == checkout_session_data["amount"] 823 | assert checkout_session.json.currency == checkout_session_data["currency"] 824 | assert checkout_session.json.success_url == checkout_session_data["success_url"] 825 | assert checkout_session.json.cancel_url == checkout_session_data["cancel_url"] 826 | 827 | def test_checkout_session_get(self): 828 | """Test getting a specific checkout session.""" 829 | checkout_session_id = "test_checkout_session_id" 830 | 831 | checkout_session = self.fintoc.checkout_sessions.get(checkout_session_id) 832 | 833 | assert checkout_session.method == "get" 834 | assert checkout_session.url == f"v1/checkout_sessions/{checkout_session_id}" 835 | 836 | def test_checkout_session_expire(self): 837 | """Test expiring a checkout session.""" 838 | checkout_session_id = "test_checkout_session_id" 839 | 840 | result = self.fintoc.checkout_sessions.expire(checkout_session_id) 841 | 842 | assert result.method == "post" 843 | assert result.url == f"v1/checkout_sessions/{checkout_session_id}/expire" 844 | 845 | def test_v2_transfer_return(self): 846 | """Test returning a transfer using v2 API.""" 847 | transfer_id = "test_transfer_id" 848 | 849 | result = self.fintoc.v2.transfers.return_(transfer_id=transfer_id) 850 | 851 | assert result.method == "post" 852 | assert result.url == "v2/transfers/return" 853 | assert result.json.transfer_id == transfer_id 854 | 855 | def test_v2_account_movements_list(self): 856 | """Test getting movements from an account using v2 API.""" 857 | account_id = "test_account_id" 858 | movements = list(self.fintoc.v2.accounts.movements.list(account_id=account_id)) 859 | 860 | assert len(movements) > 0 861 | for movement in movements: 862 | assert movement.method == "get" 863 | assert movement.url == f"v2/accounts/{account_id}/movements" 864 | 865 | def test_v2_account_movement_get(self): 866 | """Test getting a specific movement from an account using v2 API.""" 867 | account_id = "test_account_id" 868 | movement_id = "test_movement_id" 869 | 870 | movement = self.fintoc.v2.accounts.movements.get( 871 | movement_id, account_id=account_id 872 | ) 873 | 874 | assert movement.method == "get" 875 | assert movement.url == f"v2/accounts/{account_id}/movements/{movement_id}" 876 | 877 | 878 | if __name__ == "__main__": 879 | pytest.main() 880 | --------------------------------------------------------------------------------