diff --git a/.github/workflows/test_integration.yml b/.github/workflows/test_integration.yml index 521b0d6..ff8ca43 100644 --- a/.github/workflows/test_integration.yml +++ b/.github/workflows/test_integration.yml @@ -77,6 +77,9 @@ jobs: #----------------------------------------------- - name: 🧪 Run tests if: always() + env: + CREDENTIALS_JSON: ${{ secrets.PYTEST_CREDENTIALS }} run: | source $VENV + echo $CREDENTIALS_JSON | base64 -d > ./tests/test_creds.json poetry run pytest \ No newline at end of file diff --git a/.gitignore b/.gitignore index cc0982d..a8cc475 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,7 @@ __pycache__ */keys/*.json .env test_init.py -.vscode \ No newline at end of file +.vscode +credentials/** +test_init.py +**.json \ No newline at end of file diff --git a/Makefile b/Makefile index 93ad79e..ec61cc7 100644 --- a/Makefile +++ b/Makefile @@ -20,6 +20,9 @@ setup: $(MAKE) setup-dev ### Commands to run the tests ### +test-gen-creds: + @poetry run pyrevolut auth-manual --credentials-json tests/test_creds.json + test-lint: @echo "Running lint tests..." @poetry run ruff check diff --git a/README.md b/README.md index 0953430..4e8e3f2 100644 --- a/README.md +++ b/README.md @@ -13,15 +13,13 @@ pip install TO-BE-DEFINED ### Basic Usage ```python -from pyrevolut.client import Client, Environment +from pyrevolut.client import Client -ACCESS_TOKEN = "YOUR-ACCESS-TOKEN" -REFRESH_TOKEN = "YOUR-REFRESH-TOKEN" +CREDS_JSON_LOC = "path/to/creds.json" client = Client( - access_token=ACCESS_TOKEN, - refresh_token=REFRESH_TOKEN, - environment=Environment.SANDBOX, + creds_loc=CREDS_JSON_LOC, + sandbox=True, ) # Initialize the client @@ -35,42 +33,38 @@ client.close() # You can also use the client as a context manager with Client( - access_token=ACCESS_TOKEN, - refresh_token=REFRESH_TOKEN, - environment=Environment.SANDBOX + creds_loc=CREDS_JSON_LOC, + sandbox=True ) as client: accounts = client.Accounts.get_all_accounts() ``` ### Advanced Usage -It is possible to use the client asynchronously by using the `async` keyword. -All synchronous methods have an asynchronous counterpart with the `a` prefix. +It is possible to use the client library asynchronously by using the `AsyncClient` object. ```python import asyncio -from pyrevolut.client import Client, Environment +from pyrevolut.client import AsyncClient -ACCESS_TOKEN = "YOUR-ACCESS-TOKEN" -REFRESH_TOKEN = "YOUR-REFRESH-TOKEN" +CREDS_JSON_LOC = "path/to/creds.json" -client = Client( - access_token=ACCESS_TOKEN, - refresh_token=REFRESH_TOKEN, - environment=Environment.SANDBOX, +client = AsyncClient( + creds_loc=CREDS_JSON_LOC, + sandbox=True, ) # Run without context manager async def run(): - await client.aopen() # <-- Note the `a` prefix - accounts = await client.Accounts.aget_all_accounts() # <-- Note the `a` prefix - await client.aclose() # <-- Note the `a` prefix + await client.open() + accounts = await client.Accounts.get_all_accounts() + await client.close() return accounts # Run with context manager async def run_context_manager(): async with client: - accounts = await client.Accounts.aget_all_accounts() # <-- Note the `a` prefix + accounts = await client.Accounts.get_all_accounts() return accounts # List all accounts for the authenticated user @@ -79,6 +73,26 @@ accounts_context_manager = asyncio.run(run_context_manager()) ``` +## Authentication + +In order to make use of the Revolut Business API, you will need to go through several steps to authenticate your application. The basic guide can be found [here](https://developer.revolut.com/docs/guides/manage-accounts/get-started/make-your-first-api-request). We have provided a simple CLI tool to help you generate the necessary credentials. This tool follows the steps outlined in the guide. + +```bash + +pyrevolut auth-manual + +``` + +Alternatively, you can use call the CLI via Python. + +```bash + +python -m pyrevolut auth-manual + +``` + +Upon completion, you will have a `.json` file that you can use to authenticate your application. + ## API Support Status The SDK currently supports the following APIs: diff --git a/poetry.lock b/poetry.lock index c1e9d2b..5ff4c18 100644 --- a/poetry.lock +++ b/poetry.lock @@ -74,6 +74,20 @@ six = ">=1.12.0" astroid = ["astroid (>=1,<2)", "astroid (>=2,<4)"] test = ["astroid (>=1,<2)", "astroid (>=2,<4)", "pytest"] +[[package]] +name = "authlib" +version = "1.3.0" +description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients." +optional = false +python-versions = ">=3.8" +files = [ + {file = "Authlib-1.3.0-py2.py3-none-any.whl", hash = "sha256:9637e4de1fb498310a56900b3e2043a206b03cb11c05422014b0302cbc814be3"}, + {file = "Authlib-1.3.0.tar.gz", hash = "sha256:959ea62a5b7b5123c5059758296122b57cd2585ae2ed1c0622c21b371ffdae06"}, +] + +[package.dependencies] +cryptography = "*" + [[package]] name = "blinker" version = "1.8.2" @@ -406,13 +420,13 @@ test = ["pytest"] [[package]] name = "commitizen" -version = "3.25.0" +version = "3.26.0" description = "Python commitizen client tool" optional = false python-versions = ">=3.8" files = [ - {file = "commitizen-3.25.0-py3-none-any.whl", hash = "sha256:46b7f2a5a846df7414440d069aaa43b5d6c5a7f8840e68ae4c541492e93cd086"}, - {file = "commitizen-3.25.0.tar.gz", hash = "sha256:65c9c5114ac2ded5ab1e1a75c2540adc27ae7291ed2db9290f9ed208178d1e99"}, + {file = "commitizen-3.26.0-py3-none-any.whl", hash = "sha256:582437b9c95f41adb96cb7d4afee508f9925625b5f7179ce092899622be4da8b"}, + {file = "commitizen-3.26.0.tar.gz", hash = "sha256:9c2fd63117b3b352e9553246efd29a7779f565f5d84b3f18a79ff3f50fef3104"}, ] [package.dependencies] @@ -507,6 +521,60 @@ files = [ [package.extras] toml = ["tomli"] +[[package]] +name = "cryptography" +version = "42.0.7" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = ">=3.7" +files = [ + {file = "cryptography-42.0.7-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:a987f840718078212fdf4504d0fd4c6effe34a7e4740378e59d47696e8dfb477"}, + {file = "cryptography-42.0.7-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:bd13b5e9b543532453de08bcdc3cc7cebec6f9883e886fd20a92f26940fd3e7a"}, + {file = "cryptography-42.0.7-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a79165431551042cc9d1d90e6145d5d0d3ab0f2d66326c201d9b0e7f5bf43604"}, + {file = "cryptography-42.0.7-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a47787a5e3649008a1102d3df55424e86606c9bae6fb77ac59afe06d234605f8"}, + {file = "cryptography-42.0.7-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:02c0eee2d7133bdbbc5e24441258d5d2244beb31da5ed19fbb80315f4bbbff55"}, + {file = "cryptography-42.0.7-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:5e44507bf8d14b36b8389b226665d597bc0f18ea035d75b4e53c7b1ea84583cc"}, + {file = "cryptography-42.0.7-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:7f8b25fa616d8b846aef64b15c606bb0828dbc35faf90566eb139aa9cff67af2"}, + {file = "cryptography-42.0.7-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:93a3209f6bb2b33e725ed08ee0991b92976dfdcf4e8b38646540674fc7508e13"}, + {file = "cryptography-42.0.7-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e6b8f1881dac458c34778d0a424ae5769de30544fc678eac51c1c8bb2183e9da"}, + {file = "cryptography-42.0.7-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3de9a45d3b2b7d8088c3fbf1ed4395dfeff79d07842217b38df14ef09ce1d8d7"}, + {file = "cryptography-42.0.7-cp37-abi3-win32.whl", hash = "sha256:789caea816c6704f63f6241a519bfa347f72fbd67ba28d04636b7c6b7da94b0b"}, + {file = "cryptography-42.0.7-cp37-abi3-win_amd64.whl", hash = "sha256:8cb8ce7c3347fcf9446f201dc30e2d5a3c898d009126010cbd1f443f28b52678"}, + {file = "cryptography-42.0.7-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:a3a5ac8b56fe37f3125e5b72b61dcde43283e5370827f5233893d461b7360cd4"}, + {file = "cryptography-42.0.7-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:779245e13b9a6638df14641d029add5dc17edbef6ec915688f3acb9e720a5858"}, + {file = "cryptography-42.0.7-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d563795db98b4cd57742a78a288cdbdc9daedac29f2239793071fe114f13785"}, + {file = "cryptography-42.0.7-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:31adb7d06fe4383226c3e963471f6837742889b3c4caa55aac20ad951bc8ffda"}, + {file = "cryptography-42.0.7-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:efd0bf5205240182e0f13bcaea41be4fdf5c22c5129fc7ced4a0282ac86998c9"}, + {file = "cryptography-42.0.7-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a9bc127cdc4ecf87a5ea22a2556cab6c7eda2923f84e4f3cc588e8470ce4e42e"}, + {file = "cryptography-42.0.7-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:3577d029bc3f4827dd5bf8bf7710cac13527b470bbf1820a3f394adb38ed7d5f"}, + {file = "cryptography-42.0.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2e47577f9b18723fa294b0ea9a17d5e53a227867a0a4904a1a076d1646d45ca1"}, + {file = "cryptography-42.0.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1a58839984d9cb34c855197043eaae2c187d930ca6d644612843b4fe8513c886"}, + {file = "cryptography-42.0.7-cp39-abi3-win32.whl", hash = "sha256:e6b79d0adb01aae87e8a44c2b64bc3f3fe59515280e00fb6d57a7267a2583cda"}, + {file = "cryptography-42.0.7-cp39-abi3-win_amd64.whl", hash = "sha256:16268d46086bb8ad5bf0a2b5544d8a9ed87a0e33f5e77dd3c3301e63d941a83b"}, + {file = "cryptography-42.0.7-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2954fccea107026512b15afb4aa664a5640cd0af630e2ee3962f2602693f0c82"}, + {file = "cryptography-42.0.7-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:362e7197754c231797ec45ee081f3088a27a47c6c01eff2ac83f60f85a50fe60"}, + {file = "cryptography-42.0.7-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:4f698edacf9c9e0371112792558d2f705b5645076cc0aaae02f816a0171770fd"}, + {file = "cryptography-42.0.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5482e789294854c28237bba77c4c83be698be740e31a3ae5e879ee5444166582"}, + {file = "cryptography-42.0.7-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e9b2a6309f14c0497f348d08a065d52f3020656f675819fc405fb63bbcd26562"}, + {file = "cryptography-42.0.7-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d8e3098721b84392ee45af2dd554c947c32cc52f862b6a3ae982dbb90f577f14"}, + {file = "cryptography-42.0.7-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c65f96dad14f8528a447414125e1fc8feb2ad5a272b8f68477abbcc1ea7d94b9"}, + {file = "cryptography-42.0.7-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:36017400817987670037fbb0324d71489b6ead6231c9604f8fc1f7d008087c68"}, + {file = "cryptography-42.0.7.tar.gz", hash = "sha256:ecbfbc00bf55888edda9868a4cf927205de8499e7fabe6c050322298382953f2"}, +] + +[package.dependencies] +cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] +docstest = ["pyenchant (>=1.6.11)", "readme-renderer", "sphinxcontrib-spelling (>=4.0.1)"] +nox = ["nox"] +pep8test = ["check-sdist", "click", "mypy", "ruff"] +sdist = ["build"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["certifi", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test-randomorder = ["pytest-randomly"] + [[package]] name = "debugpy" version = "1.8.1" @@ -1670,13 +1738,13 @@ ptyprocess = ">=0.5" [[package]] name = "phonenumbers" -version = "8.13.36" +version = "8.13.37" description = "Python version of Google's common library for parsing, formatting, storing and validating international phone numbers." optional = false python-versions = "*" files = [ - {file = "phonenumbers-8.13.36-py2.py3-none-any.whl", hash = "sha256:68e06d20ae2f8fe5c7c7fd5b433f4257bc3cc747dc5196a029c7898ea449b012"}, - {file = "phonenumbers-8.13.36.tar.gz", hash = "sha256:b4e2371e35a1172aa2c91c9200b1e48e87b9355eb575768dd38058fc8d72c9ff"}, + {file = "phonenumbers-8.13.37-py2.py3-none-any.whl", hash = "sha256:4ea00ef5012422c08c7955c21131e7ae5baa9a3ef52cf2d561e963f023006b80"}, + {file = "phonenumbers-8.13.37.tar.gz", hash = "sha256:bd315fed159aea0516f7c367231810fe8344d5bec26156b88fa18374c11d1cf2"}, ] [[package]] @@ -1970,6 +2038,26 @@ files = [ [package.extras] windows-terminal = ["colorama (>=0.4.6)"] +[[package]] +name = "pyjwt" +version = "2.8.0" +description = "JSON Web Token implementation in Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "PyJWT-2.8.0-py3-none-any.whl", hash = "sha256:59127c392cc44c2da5bb3192169a91f429924e17aff6534d70fdc02ab3e04320"}, + {file = "PyJWT-2.8.0.tar.gz", hash = "sha256:57e28d156e3d5c10088e0c68abb90bfac3df82b40a71bd0daa20c65ccd5c23de"}, +] + +[package.dependencies] +cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"crypto\""} + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] +dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"] +docs = ["sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"] +tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] + [[package]] name = "pyright" version = "1.1.363" @@ -2533,6 +2621,17 @@ docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments testing = ["build[virtualenv]", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mypy (==1.9)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6,!=8.1.1)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] +[[package]] +name = "shellingham" +version = "1.5.4" +description = "Tool to Detect Surrounding Shell" +optional = false +python-versions = ">=3.7" +files = [ + {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, + {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, +] + [[package]] name = "six" version = "1.16.0" @@ -2590,13 +2689,13 @@ tests = ["pytest", "pytest-cov"] [[package]] name = "textual" -version = "0.60.1" +version = "0.61.0" description = "Modern Text User Interface framework" optional = false python-versions = "<4.0,>=3.8" files = [ - {file = "textual-0.60.1-py3-none-any.whl", hash = "sha256:701715dfe000396c226dccaedd52fd0f56071bbdef2a1497a4f0211063ceba19"}, - {file = "textual-0.60.1.tar.gz", hash = "sha256:258565923f55487876b48b53c1104ad660355e1853af60381ef6b10b3ed3723e"}, + {file = "textual-0.61.0-py3-none-any.whl", hash = "sha256:176ac3aa5427fc076492d16afd20ea5c508605c2826cd176c8f5ac2589a1ee46"}, + {file = "textual-0.61.0.tar.gz", hash = "sha256:91c83a659da40b227eced4fa749026a236b493cc5911a9bedd990ad5f0786be2"}, ] [package.dependencies] @@ -2653,6 +2752,23 @@ files = [ docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] test = ["argcomplete (>=3.0.3)", "mypy (>=1.7.0)", "pre-commit", "pytest (>=7.0,<8.2)", "pytest-mock", "pytest-mypy-testing"] +[[package]] +name = "typer" +version = "0.12.3" +description = "Typer, build great CLIs. Easy to code. Based on Python type hints." +optional = false +python-versions = ">=3.7" +files = [ + {file = "typer-0.12.3-py3-none-any.whl", hash = "sha256:070d7ca53f785acbccba8e7d28b08dcd88f79f1fbda035ade0aecec71ca5c914"}, + {file = "typer-0.12.3.tar.gz", hash = "sha256:49e73131481d804288ef62598d97a1ceef3058905aa536a1134f90891ba35482"}, +] + +[package.dependencies] +click = ">=8.0.0" +rich = ">=10.11.0" +shellingham = ">=1.3.0" +typing-extensions = ">=3.7.4.3" + [[package]] name = "typing-extensions" version = "4.11.0" @@ -2835,4 +2951,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "e70a358fa5ae5391a317908fc5fc87262d084c1a356d5f2898dcdd0c0018d684" +content-hash = "362617195f839d8d75945175bc5b6f4d7a082c5c0b9a6861e2f8a055ee7ff3d9" diff --git a/pyproject.toml b/pyproject.toml index 0c365be..1c5d9c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,9 @@ description = "" authors = ["Trevor Visser "] readme = "README.md" +[tool.poetry.scripts] +pyrevolut = "pyrevolut.cli.main:app" + [tool.poetry.dependencies] python = "^3.11" httpx = "^0.27.0" @@ -13,11 +16,15 @@ pydantic-extra-types = "^2.7.0" pycountry = "^23.12.11" phonenumbers = "^8.13.36" pendulum = "^3.0.0" +pyjwt = { extras = ["crypto"], version = "^2.8.0" } +cryptography = "^42.0.7" +authlib = "^1.3.0" +typer = "^0.12.3" [tool.poetry.group.dev.dependencies] pre-commit = "^3.7.0" -commitizen = "^3.24.0" ipykernel = "^6.29.4" +commitizen = "^3.25.0" [tool.poetry.group.test.dependencies] pytest-asyncio = "^0.21.0" diff --git a/pyrevolut/api/accounts/__init__.py b/pyrevolut/api/accounts/__init__.py index 1a1f58f..b5d7844 100644 --- a/pyrevolut/api/accounts/__init__.py +++ b/pyrevolut/api/accounts/__init__.py @@ -3,4 +3,4 @@ """ # flake8: noqa: F401 -from .endpoint import EndpointAccounts +from .endpoint import EndpointAccountsSync, EndpointAccountsAsync diff --git a/pyrevolut/api/accounts/endpoint.py b/pyrevolut/api/accounts/endpoint.py deleted file mode 100644 index 3e5d457..0000000 --- a/pyrevolut/api/accounts/endpoint.py +++ /dev/null @@ -1,187 +0,0 @@ -from uuid import UUID - -from pyrevolut.api.common import BaseEndpoint - -from .get import RetrieveAllAccounts, RetrieveAnAccount, RetrieveFullBankDetails - - -class EndpointAccounts(BaseEndpoint): - """The Accounts API - Get the balances, full banking details, and other details of your business accounts. - """ - - def get_all_accounts( - self, - **kwargs, - ): - """ - Get a list of all your accounts. - - Parameters - ---------- - None - - Returns - ------- - list - The list of all your accounts - """ - endpoint = RetrieveAllAccounts - path = endpoint.ROUTE - params = endpoint.Params() - - response = self.client.get( - path=path, - params=params, - **kwargs, - ) - - return [endpoint.Response(**resp).model_dump() for resp in response.json()] - - def get_account( - self, - account_id: UUID, - **kwargs, - ): - """ - Get the information about one of your accounts. Specify the account by its ID. - - Parameters - ---------- - account_id : UUID - The account ID. - - Returns - ------- - dict - The information about the account - """ - endpoint = RetrieveAnAccount - path = endpoint.ROUTE.format(account_id=account_id) - params = endpoint.Params() - - response = self.client.get( - path=path, - params=params, - **kwargs, - ) - - return endpoint.Response(**response.json()).model_dump() - - def get_full_bank_details( - self, - account_id: UUID, - **kwargs, - ): - """ - Get all the bank details of one of your accounts. Specify the account by its ID. - - Parameters - ---------- - account_id : UUID - The account ID. - - Returns - ------- - dict - The bank details of the account - """ - endpoint = RetrieveFullBankDetails - path = endpoint.ROUTE.format(account_id=account_id) - params = endpoint.Params() - - response = self.client.get( - path=path, - params=params, - **kwargs, - ) - - return endpoint.Response(**response.json()).model_dump() - - async def aget_all_accounts( - self, - **kwargs, - ): - """ - Get a list of all your accounts. - - Parameters - ---------- - None - - Returns - ------- - list - The list of all your accounts - """ - endpoint = RetrieveAllAccounts - path = endpoint.ROUTE - params = endpoint.Params() - - response = await self.client.aget( - path=path, - params=params, - **kwargs, - ) - - return [endpoint.Response(**resp).model_dump() for resp in response.json()] - - async def aget_account( - self, - account_id: UUID, - **kwargs, - ): - """ - Get the information about one of your accounts. Specify the account by its ID. - - Parameters - ---------- - account_id : UUID - The account ID. - - Returns - ------- - dict - The information about the account - """ - endpoint = RetrieveAnAccount - path = endpoint.ROUTE.format(account_id=account_id) - params = endpoint.Params() - - response = await self.client.aget( - path=path, - params=params, - **kwargs, - ) - - return endpoint.Response(**response.json()).model_dump() - - async def aget_full_bank_details( - self, - account_id: UUID, - **kwargs, - ): - """ - Get all the bank details of one of your accounts. Specify the account by its ID. - - Parameters - ---------- - account_id : UUID - The account ID. - - Returns - ------- - dict - The bank details of the account - """ - endpoint = RetrieveFullBankDetails - path = endpoint.ROUTE.format(account_id=account_id) - params = endpoint.Params() - - response = await self.client.aget( - path=path, - params=params, - **kwargs, - ) - - return endpoint.Response(**response.json()).model_dump() diff --git a/pyrevolut/api/accounts/endpoint/__init__.py b/pyrevolut/api/accounts/endpoint/__init__.py new file mode 100644 index 0000000..8d56b22 --- /dev/null +++ b/pyrevolut/api/accounts/endpoint/__init__.py @@ -0,0 +1,5 @@ +"""This module holds the accounts endpoints handlers.""" + +# flake8: noqa: F401 +from .synchronous import EndpointAccountsSync +from .asynchronous import EndpointAccountsAsync diff --git a/pyrevolut/api/accounts/endpoint/asynchronous.py b/pyrevolut/api/accounts/endpoint/asynchronous.py new file mode 100644 index 0000000..35c9006 --- /dev/null +++ b/pyrevolut/api/accounts/endpoint/asynchronous.py @@ -0,0 +1,103 @@ +from uuid import UUID + +from pyrevolut.api.common import BaseEndpointAsync + +from pyrevolut.api.accounts.get import ( + RetrieveAllAccounts, + RetrieveAnAccount, + RetrieveFullBankDetails, +) + + +class EndpointAccountsAsync(BaseEndpointAsync): + """The async Accounts API + Get the balances, full banking details, and other details of your business accounts. + """ + + async def get_all_accounts( + self, + **kwargs, + ): + """ + Get a list of all your accounts. + + Parameters + ---------- + None + + Returns + ------- + list + The list of all your accounts + """ + endpoint = RetrieveAllAccounts + path = endpoint.ROUTE + params = endpoint.Params() + + response = await self.client.get( + path=path, + params=params, + **kwargs, + ) + + return [endpoint.Response(**resp).model_dump() for resp in response.json()] + + async def get_account( + self, + account_id: UUID, + **kwargs, + ): + """ + Get the information about one of your accounts. Specify the account by its ID. + + Parameters + ---------- + account_id : UUID + The account ID. + + Returns + ------- + dict + The information about the account + """ + endpoint = RetrieveAnAccount + path = endpoint.ROUTE.format(account_id=account_id) + params = endpoint.Params() + + response = await self.client.get( + path=path, + params=params, + **kwargs, + ) + + return endpoint.Response(**response.json()).model_dump() + + async def get_full_bank_details( + self, + account_id: UUID, + **kwargs, + ): + """ + Get all the bank details of one of your accounts. Specify the account by its ID. + + Parameters + ---------- + account_id : UUID + The account ID. + + Returns + ------- + dict + The bank details of the account + """ + endpoint = RetrieveFullBankDetails + path = endpoint.ROUTE.format(account_id=account_id) + params = endpoint.Params() + + response = await self.client.get( + path=path, + params=params, + **kwargs, + ) + + return endpoint.Response(**response.json()).model_dump() diff --git a/pyrevolut/api/accounts/endpoint/synchronous.py b/pyrevolut/api/accounts/endpoint/synchronous.py new file mode 100644 index 0000000..9a58f58 --- /dev/null +++ b/pyrevolut/api/accounts/endpoint/synchronous.py @@ -0,0 +1,103 @@ +from uuid import UUID + +from pyrevolut.api.common import BaseEndpointSync + +from pyrevolut.api.accounts.get import ( + RetrieveAllAccounts, + RetrieveAnAccount, + RetrieveFullBankDetails, +) + + +class EndpointAccountsSync(BaseEndpointSync): + """The Accounts API + Get the balances, full banking details, and other details of your business accounts. + """ + + def get_all_accounts( + self, + **kwargs, + ): + """ + Get a list of all your accounts. + + Parameters + ---------- + None + + Returns + ------- + list + The list of all your accounts + """ + endpoint = RetrieveAllAccounts + path = endpoint.ROUTE + params = endpoint.Params() + + response = self.client.get( + path=path, + params=params, + **kwargs, + ) + + return [endpoint.Response(**resp).model_dump() for resp in response.json()] + + def get_account( + self, + account_id: UUID, + **kwargs, + ): + """ + Get the information about one of your accounts. Specify the account by its ID. + + Parameters + ---------- + account_id : UUID + The account ID. + + Returns + ------- + dict + The information about the account + """ + endpoint = RetrieveAnAccount + path = endpoint.ROUTE.format(account_id=account_id) + params = endpoint.Params() + + response = self.client.get( + path=path, + params=params, + **kwargs, + ) + + return endpoint.Response(**response.json()).model_dump() + + def get_full_bank_details( + self, + account_id: UUID, + **kwargs, + ): + """ + Get all the bank details of one of your accounts. Specify the account by its ID. + + Parameters + ---------- + account_id : UUID + The account ID. + + Returns + ------- + dict + The bank details of the account + """ + endpoint = RetrieveFullBankDetails + path = endpoint.ROUTE.format(account_id=account_id) + params = endpoint.Params() + + response = self.client.get( + path=path, + params=params, + **kwargs, + ) + + return endpoint.Response(**response.json()).model_dump() diff --git a/pyrevolut/api/accounts/resources/account.py b/pyrevolut/api/accounts/resources/account.py index 1d3280f..af9a9fb 100644 --- a/pyrevolut/api/accounts/resources/account.py +++ b/pyrevolut/api/accounts/resources/account.py @@ -19,9 +19,9 @@ class ResourceAccount(BaseModel): Field(description="The account ID."), ] name: Annotated[ - str, + str | None, Field(description="The account name."), - ] + ] = None balance: Annotated[ Decimal, Field(description="The current balance on the account."), diff --git a/pyrevolut/api/cards/__init__.py b/pyrevolut/api/cards/__init__.py index db2dc93..4e732a8 100644 --- a/pyrevolut/api/cards/__init__.py +++ b/pyrevolut/api/cards/__init__.py @@ -8,4 +8,4 @@ """ # flake8: noqa: F401 -from .endpoint import EndpointCards +from .endpoint import EndpointCardsSync, EndpointCardsAsync diff --git a/pyrevolut/api/cards/endpoint/__init__.py b/pyrevolut/api/cards/endpoint/__init__.py new file mode 100644 index 0000000..c6431eb --- /dev/null +++ b/pyrevolut/api/cards/endpoint/__init__.py @@ -0,0 +1,5 @@ +"""This module holds the cards endpoints handlers.""" + +# flake8: noqa: F401 +from .synchronous import EndpointCardsSync +from .asynchronous import EndpointCardsAsync diff --git a/pyrevolut/api/cards/endpoint/asynchronous.py b/pyrevolut/api/cards/endpoint/asynchronous.py new file mode 100644 index 0000000..18843ce --- /dev/null +++ b/pyrevolut/api/cards/endpoint/asynchronous.py @@ -0,0 +1,575 @@ +from typing import Literal, Type +from uuid import UUID +from decimal import Decimal +from datetime import datetime + +from pydantic import BaseModel + +from pyrevolut.api.common import BaseEndpointAsync, EnumMerchantCategory +from pyrevolut.utils import DateTime + +from pyrevolut.api.cards.get import ( + RetrieveListOfCards, + RetrieveCardDetails, + RetrieveSensitiveCardDetails, +) +from pyrevolut.api.cards.post import CreateCard, FreezeCard, UnfreezeCard +from pyrevolut.api.cards.patch import UpdateCardDetails +from pyrevolut.api.cards.delete import TerminateCard + + +class EndpointCardsAsync(BaseEndpointAsync): + """The async Cards API + Manage cards for the business team members, freeze, unfreeze, + terminate and update card settings, such as transaction limits. + + This feature is available in the UK, US and the EEA. + This feature is not available in Sandbox. + """ + + async def get_all_cards( + self, + created_before: datetime | DateTime | str | int | float | None = None, + limit: int | None = None, + **kwargs, + ): + """ + Get the list of all cards in your organisation. + The results are paginated and sorted by the created_at date in reverse chronological order. + + Parameters + ---------- + created_before : datetime | DateTime | str | int | float | None + Retrieves cards with created_at < created_before. + The default value is the current date and time at which you are calling the endpoint. + Provided in ISO 8601 format. + limit : int | None + The maximum number of cards returned per page. + To get to the next page, make a new request and use the + created_at date of the last card returned in the previous + response as the value for created_before. + + If not provided, the default value is 100. + + Returns + ------- + list + The list of all cards in your organisation. + """ + endpoint = RetrieveListOfCards + path = endpoint.ROUTE + params = endpoint.Params( + created_before=created_before, + limit=limit, + ) + + response = await self.client.get( + path=path, + params=params, + **kwargs, + ) + + return [endpoint.Response(**resp).model_dump() for resp in response.json()] + + async def get_card( + self, + card_id: UUID, + **kwargs, + ): + """ + Get the details of a specific card, based on its ID. + + Parameters + ---------- + card_id : UUID + The card ID. + + Returns + ------- + dict + The details of the card. + """ + endpoint = RetrieveCardDetails + path = endpoint.ROUTE.format(card_id=card_id) + params = endpoint.Params() + + response = await self.client.get( + path=path, + params=params, + **kwargs, + ) + + return endpoint.Response(**response.json()).model_dump() + + async def get_card_sensitive_details( + self, + card_id: UUID, + **kwargs, + ): + """ + Get sensitive details of a specific card, based on its ID. + Requires the READ_SENSITIVE_CARD_DATA token scope. + + Parameters + ---------- + card_id : UUID + The card ID. + + Returns + ------- + dict + The sensitive details of the card. + """ + endpoint = RetrieveSensitiveCardDetails + path = endpoint.ROUTE.format(card_id=card_id) + params = endpoint.Params() + + response = await self.client.get( + path=path, + params=params, + **kwargs, + ) + + return endpoint.Response(**response.json()).model_dump() + + async def create_card( + self, + request_id: str, + holder_id: UUID, + label: str | None = None, + accounts: list[UUID] | None = None, + categories: list[EnumMerchantCategory] | None = None, + single_limit_amount: Decimal | None = None, + single_limit_currency: str | None = None, + day_limit_amount: Decimal | None = None, + day_limit_currency: str | None = None, + week_limit_amount: Decimal | None = None, + week_limit_currency: str | None = None, + month_limit_amount: Decimal | None = None, + month_limit_currency: str | None = None, + quarter_limit_amount: Decimal | None = None, + quarter_limit_currency: str | None = None, + year_limit_amount: Decimal | None = None, + year_limit_currency: str | None = None, + all_time_limit_amount: Decimal | None = None, + all_time_limit_currency: str | None = None, + **kwargs, + ): + """ + Create a new card for an existing member of your Revolut Business team. + + When using the API, you can create only virtual cards. + To create a physical card, use the Revolut Business app. + + Parameters + ---------- + request_id : str + A unique ID of the request that you provide. + This ID is used to prevent duplicate card creation requests in case + of a lost connection or client error, so make sure you use the same + request_id for requests related to the same card. + The deduplication is limited to 24 hours counting from the first request + using a given ID. + holder_id : UUID + The ID of the team member who will be the holder of the card. + label : str | None + The label for the issued card, displayed in the UI to help distinguish between cards. + If not specified, no label will be added. + accounts : list[UUID] | None + The list of accounts to link to the card. If not specified, all accounts will be linked. + categories : list[EnumMerchantCategory] | None + The list of merchant categories to link to the card. If not specified, all categories will be linked. + single_limit_amount : Decimal | None + The maximum amount for a single transaction. + single_limit_currency : str | None + The currency of the single transaction limit. + day_limit_amount : Decimal | None + The maximum amount for transactions in a day. + day_limit_currency : str | None + The currency of the day limit. + week_limit_amount : Decimal | None + The maximum amount for transactions in a week. + week_limit_currency : str | None + The currency of the week limit. + month_limit_amount : Decimal | None + The maximum amount for transactions in a month. + month_limit_currency : str | None + The currency of the month limit. + quarter_limit_amount : Decimal | None + The maximum amount for transactions in a quarter. + quarter_limit_currency : str | None + The currency of the quarter limit. + year_limit_amount : Decimal | None + The maximum amount for transactions in a year. + year_limit_currency : str | None + The currency of the year limit. + all_time_limit_amount : Decimal | None + The maximum amount for transactions in the card's lifetime. + all_time_limit_currency : str | None + The currency of the all-time limit. + + Returns + ------- + dict + The details of the created card. + """ + endpoint = CreateCard + path = endpoint.ROUTE + + # Create the SpendingLimits model (if applicable) + spending_limits = endpoint.Body.ModelSpendingLimits( + single=endpoint.Body.ModelSpendingLimits.ModelSingle( + amount=single_limit_amount, + currency=single_limit_currency, + ) + if single_limit_amount is not None and single_limit_currency is not None + else None, + day=endpoint.Body.ModelSpendingLimits.ModelDay( + amount=day_limit_amount, + currency=day_limit_currency, + ) + if day_limit_amount is not None and day_limit_currency is not None + else None, + week=endpoint.Body.ModelSpendingLimits.ModelWeek( + amount=week_limit_amount, + currency=week_limit_currency, + ) + if week_limit_amount is not None and week_limit_currency is not None + else None, + month=endpoint.Body.ModelSpendingLimits.ModelMonth( + amount=month_limit_amount, + currency=month_limit_currency, + ) + if month_limit_amount is not None and month_limit_currency is not None + else None, + quarter=endpoint.Body.ModelSpendingLimits.ModelQuarter( + amount=quarter_limit_amount, + currency=quarter_limit_currency, + ) + if quarter_limit_amount is not None and quarter_limit_currency is not None + else None, + year=endpoint.Body.ModelSpendingLimits.ModelYear( + amount=year_limit_amount, + currency=year_limit_currency, + ) + if year_limit_amount is not None and year_limit_currency is not None + else None, + all_time=endpoint.Body.ModelSpendingLimits.ModelAllTime( + amount=all_time_limit_amount, + currency=all_time_limit_currency, + ) + if all_time_limit_amount is not None and all_time_limit_currency is not None + else None, + ) + if not any( + [ + spending_limits.single is not None, + spending_limits.day is not None, + spending_limits.week is not None, + spending_limits.month is not None, + spending_limits.quarter is not None, + spending_limits.year is not None, + spending_limits.all_time is not None, + ] + ): + spending_limits = None + + body = endpoint.Body( + request_id=request_id, + virtual=True, + holder_id=holder_id, + label=label, + accounts=accounts, + categories=categories, + spending_limits=spending_limits, + ) + + response = await self.client.post( + path=path, + body=body, + **kwargs, + ) + + return endpoint.Response(**response.json()).model_dump() + + async def freeze_card( + self, + card_id: UUID, + **kwargs, + ): + """ + Freeze a card to make it temporarily unavailable for spending. + You can only freeze a card that is in the state active. + + A successful freeze changes the card's state to frozen, + and no content is returned in the response. + + Parameters + ---------- + card_id : UUID + The card ID. + + Returns + ------- + dict + An empty dictionary. + """ + endpoint = FreezeCard + path = endpoint.ROUTE.format(card_id=card_id) + body = endpoint.Body() + + await self.client.post( + path=path, + body=body, + **kwargs, + ) + + return endpoint.Response().model_dump() + + async def unfreeze_card( + self, + card_id: UUID, + **kwargs, + ): + """ + Unfreeze a card to make it available for spending again. + You can only unfreeze a card that is in the state frozen. + + A successful unfreeze changes the card's state to active, + and no content is returned in the response. + + Parameters + ---------- + card_id : UUID + The card ID. + + Returns + ------- + dict + An empty dictionary. + """ + endpoint = UnfreezeCard + path = endpoint.ROUTE.format(card_id=card_id) + body = endpoint.Body() + + await self.client.post( + path=path, + body=body, + **kwargs, + ) + + return endpoint.Response().model_dump() + + async def update_card( + self, + card_id: UUID, + label: str | None = None, + categories: list[EnumMerchantCategory] | Literal["null"] | None = None, + single_limit_amount: Decimal | Literal["null"] | None = None, + single_limit_currency: str | Literal["null"] | None = None, + day_limit_amount: Decimal | Literal["null"] | None = None, + day_limit_currency: str | Literal["null"] | None = None, + week_limit_amount: Decimal | Literal["null"] | None = None, + week_limit_currency: str | Literal["null"] | None = None, + month_limit_amount: Decimal | Literal["null"] | None = None, + month_limit_currency: str | Literal["null"] | None = None, + quarter_limit_amount: Decimal | Literal["null"] | None = None, + quarter_limit_currency: str | Literal["null"] | None = None, + year_limit_amount: Decimal | Literal["null"] | None = None, + year_limit_currency: str | Literal["null"] | None = None, + all_time_limit_amount: Decimal | Literal["null"] | None = None, + all_time_limit_currency: str | Literal["null"] | None = None, + **kwargs, + ): + """ + Update details of a specific card, based on its ID. + Updating a spending limit does not reset the spending counter. + + Parameters + ---------- + card_id : UUID + The card ID. + label : str | None + The label of the card. + categories : list[EnumMerchantCategory] | Literal["null"] | None + The list of merchant categories to link to the card. + If set to 'null', all categories will be linked. + single_limit_amount : Decimal | Literal["null"] | None + The maximum amount for a single transaction. + If set to 'null', the limit will be removed. + single_limit_currency : str | Literal["null"] | None + The currency of the single transaction limit. + If set to 'null', the limit will be removed. + day_limit_amount : Decimal | Literal["null"] | None + The maximum amount for transactions in a day. + If set to 'null', the limit will be removed. + day_limit_currency : str | Literal["null"] | None + The currency of the day limit. + If set to 'null', the limit will be removed. + week_limit_amount : Decimal | Literal["null"] | None + The maximum amount for transactions in a week. + If set to 'null', the limit will be removed. + week_limit_currency : str | Literal["null"] | None + The currency of the week limit. + If set to 'null', the limit will be removed. + month_limit_amount : Decimal | Literal["null"] | None + The maximum amount for transactions in a month. + If set to 'null', the limit will be removed. + month_limit_currency : str | Literal["null"] | None + The currency of the month limit. + If set to 'null', the limit will be removed. + quarter_limit_amount : Decimal | Literal["null"] | None + The maximum amount for transactions in a quarter. + If set to 'null', the limit will be removed. + quarter_limit_currency : str | Literal["null"] | None + The currency of the quarter limit. + If set to 'null', the limit will be removed. + year_limit_amount : Decimal | Literal["null"] | None + The maximum amount for transactions in a year. + If set to 'null', the limit will be removed. + year_limit_currency : str | Literal["null"] | None + The currency of the year limit. + If set to 'null', the limit will be removed. + all_time_limit_amount : Decimal | Literal["null"] | None + The maximum amount for transactions in the card's lifetime. + If set to 'null', the limit will be removed. + all_time_limit_currency : str | Literal["null"] | None + The currency of the all-time limit. + If set to 'null', the limit will be removed. + + Returns + ------- + dict + The updated details of the card. + """ + endpoint = UpdateCardDetails + path = endpoint.ROUTE.format(card_id=card_id) + + # Create the SpendingLimits model (if applicable) + spending_limits = endpoint.Body.ModelSpendingLimits( + single=self.__process_limit_model( + model=endpoint.Body.ModelSpendingLimits.ModelSingle, + amount=single_limit_amount, + currency=single_limit_currency, + ), + day=self.__process_limit_model( + model=endpoint.Body.ModelSpendingLimits.ModelDay, + amount=day_limit_amount, + currency=day_limit_currency, + ), + week=self.__process_limit_model( + model=endpoint.Body.ModelSpendingLimits.ModelWeek, + amount=week_limit_amount, + currency=week_limit_currency, + ), + month=self.__process_limit_model( + model=endpoint.Body.ModelSpendingLimits.ModelMonth, + amount=month_limit_amount, + currency=month_limit_currency, + ), + quarter=self.__process_limit_model( + model=endpoint.Body.ModelSpendingLimits.ModelQuarter, + amount=quarter_limit_amount, + currency=quarter_limit_currency, + ), + year=self.__process_limit_model( + model=endpoint.Body.ModelSpendingLimits.ModelYear, + amount=year_limit_amount, + currency=year_limit_currency, + ), + all_time=self.__process_limit_model( + model=endpoint.Body.ModelSpendingLimits.ModelAllTime, + amount=all_time_limit_amount, + currency=all_time_limit_currency, + ), + ) + if not any( + [ + spending_limits.single is not None, + spending_limits.day is not None, + spending_limits.week is not None, + spending_limits.month is not None, + spending_limits.quarter is not None, + spending_limits.year is not None, + spending_limits.all_time is not None, + ] + ): + spending_limits = None + elif all( + [ + spending_limits.single == "null", + spending_limits.day == "null", + spending_limits.week == "null", + spending_limits.month == "null", + spending_limits.quarter == "null", + spending_limits.year == "null", + spending_limits.all_time == "null", + ] + ): + spending_limits = "null" + + body = endpoint.Body( + label=label, + categories=categories, + spending_limits=spending_limits, + ) + + response = await self.client.patch( + path=path, + body=body, + **kwargs, + ) + + return endpoint.Response(**response.json()).model_dump() + + async def delete_card( + self, + card_id: UUID, + **kwargs, + ): + """ + Terminate a specific card, based on its ID. + + Once the card is terminated, it will not be returned by the API. + + A successful response does not get any content in return. + + Parameters + ---------- + card_id : UUID + The card ID. + + Returns + ------- + dict + An empty dictionary. + """ + endpoint = TerminateCard + path = endpoint.ROUTE.format(card_id=card_id) + params = endpoint.Params() + + await self.client.delete( + path=path, + params=params, + **kwargs, + ) + + return endpoint.Response().model_dump() + + def __process_limit_model( + self, + model: Type[BaseModel], + amount: Decimal | None, + currency: str | None, + ): + """ + Process the limit model. + """ + if amount is not None and currency is not None: + return model( + amount=amount, + currency=currency, + ) + elif amount == "null" and currency == "null": + return "null" + return None diff --git a/pyrevolut/api/cards/endpoint.py b/pyrevolut/api/cards/endpoint/synchronous.py similarity index 50% rename from pyrevolut/api/cards/endpoint.py rename to pyrevolut/api/cards/endpoint/synchronous.py index ee38258..ea0055e 100644 --- a/pyrevolut/api/cards/endpoint.py +++ b/pyrevolut/api/cards/endpoint/synchronous.py @@ -5,16 +5,20 @@ from pydantic import BaseModel -from pyrevolut.api.common import BaseEndpoint, EnumMerchantCategory +from pyrevolut.api.common import BaseEndpointSync, EnumMerchantCategory from pyrevolut.utils import DateTime -from .get import RetrieveListOfCards, RetrieveCardDetails, RetrieveSensitiveCardDetails -from .post import CreateCard, FreezeCard, UnfreezeCard -from .patch import UpdateCardDetails -from .delete import TerminateCard +from pyrevolut.api.cards.get import ( + RetrieveListOfCards, + RetrieveCardDetails, + RetrieveSensitiveCardDetails, +) +from pyrevolut.api.cards.post import CreateCard, FreezeCard, UnfreezeCard +from pyrevolut.api.cards.patch import UpdateCardDetails +from pyrevolut.api.cards.delete import TerminateCard -class EndpointCards(BaseEndpoint): +class EndpointCardsSync(BaseEndpointSync): """The Cards API Manage cards for the business team members, freeze, unfreeze, terminate and update card settings, such as transaction limits. @@ -552,535 +556,6 @@ def delete_card( return endpoint.Response().model_dump() - async def aget_all_cards( - self, - created_before: datetime | DateTime | str | int | float | None = None, - limit: int | None = None, - **kwargs, - ): - """ - Get the list of all cards in your organisation. - The results are paginated and sorted by the created_at date in reverse chronological order. - - Parameters - ---------- - created_before : datetime | DateTime | str | int | float | None - Retrieves cards with created_at < created_before. - The default value is the current date and time at which you are calling the endpoint. - Provided in ISO 8601 format. - limit : int | None - The maximum number of cards returned per page. - To get to the next page, make a new request and use the - created_at date of the last card returned in the previous - response as the value for created_before. - - If not provided, the default value is 100. - - Returns - ------- - list - The list of all cards in your organisation. - """ - endpoint = RetrieveListOfCards - path = endpoint.ROUTE - params = endpoint.Params( - created_before=created_before, - limit=limit, - ) - - response = await self.client.aget( - path=path, - params=params, - **kwargs, - ) - - return [endpoint.Response(**resp).model_dump() for resp in response.json()] - - async def aget_card( - self, - card_id: UUID, - **kwargs, - ): - """ - Get the details of a specific card, based on its ID. - - Parameters - ---------- - card_id : UUID - The card ID. - - Returns - ------- - dict - The details of the card. - """ - endpoint = RetrieveCardDetails - path = endpoint.ROUTE.format(card_id=card_id) - params = endpoint.Params() - - response = await self.client.aget( - path=path, - params=params, - **kwargs, - ) - - return endpoint.Response(**response.json()).model_dump() - - async def aget_card_sensitive_details( - self, - card_id: UUID, - **kwargs, - ): - """ - Get sensitive details of a specific card, based on its ID. - Requires the READ_SENSITIVE_CARD_DATA token scope. - - Parameters - ---------- - card_id : UUID - The card ID. - - Returns - ------- - dict - The sensitive details of the card. - """ - endpoint = RetrieveSensitiveCardDetails - path = endpoint.ROUTE.format(card_id=card_id) - params = endpoint.Params() - - response = await self.client.aget( - path=path, - params=params, - **kwargs, - ) - - return endpoint.Response(**response.json()).model_dump() - - async def acreate_card( - self, - request_id: str, - holder_id: UUID, - label: str | None = None, - accounts: list[UUID] | None = None, - categories: list[EnumMerchantCategory] | None = None, - single_limit_amount: Decimal | None = None, - single_limit_currency: str | None = None, - day_limit_amount: Decimal | None = None, - day_limit_currency: str | None = None, - week_limit_amount: Decimal | None = None, - week_limit_currency: str | None = None, - month_limit_amount: Decimal | None = None, - month_limit_currency: str | None = None, - quarter_limit_amount: Decimal | None = None, - quarter_limit_currency: str | None = None, - year_limit_amount: Decimal | None = None, - year_limit_currency: str | None = None, - all_time_limit_amount: Decimal | None = None, - all_time_limit_currency: str | None = None, - **kwargs, - ): - """ - Create a new card for an existing member of your Revolut Business team. - - When using the API, you can create only virtual cards. - To create a physical card, use the Revolut Business app. - - Parameters - ---------- - request_id : str - A unique ID of the request that you provide. - This ID is used to prevent duplicate card creation requests in case - of a lost connection or client error, so make sure you use the same - request_id for requests related to the same card. - The deduplication is limited to 24 hours counting from the first request - using a given ID. - holder_id : UUID - The ID of the team member who will be the holder of the card. - label : str | None - The label for the issued card, displayed in the UI to help distinguish between cards. - If not specified, no label will be added. - accounts : list[UUID] | None - The list of accounts to link to the card. If not specified, all accounts will be linked. - categories : list[EnumMerchantCategory] | None - The list of merchant categories to link to the card. If not specified, all categories will be linked. - single_limit_amount : Decimal | None - The maximum amount for a single transaction. - single_limit_currency : str | None - The currency of the single transaction limit. - day_limit_amount : Decimal | None - The maximum amount for transactions in a day. - day_limit_currency : str | None - The currency of the day limit. - week_limit_amount : Decimal | None - The maximum amount for transactions in a week. - week_limit_currency : str | None - The currency of the week limit. - month_limit_amount : Decimal | None - The maximum amount for transactions in a month. - month_limit_currency : str | None - The currency of the month limit. - quarter_limit_amount : Decimal | None - The maximum amount for transactions in a quarter. - quarter_limit_currency : str | None - The currency of the quarter limit. - year_limit_amount : Decimal | None - The maximum amount for transactions in a year. - year_limit_currency : str | None - The currency of the year limit. - all_time_limit_amount : Decimal | None - The maximum amount for transactions in the card's lifetime. - all_time_limit_currency : str | None - The currency of the all-time limit. - - Returns - ------- - dict - The details of the created card. - """ - endpoint = CreateCard - path = endpoint.ROUTE - - # Create the SpendingLimits model (if applicable) - spending_limits = endpoint.Body.ModelSpendingLimits( - single=endpoint.Body.ModelSpendingLimits.ModelSingle( - amount=single_limit_amount, - currency=single_limit_currency, - ) - if single_limit_amount is not None and single_limit_currency is not None - else None, - day=endpoint.Body.ModelSpendingLimits.ModelDay( - amount=day_limit_amount, - currency=day_limit_currency, - ) - if day_limit_amount is not None and day_limit_currency is not None - else None, - week=endpoint.Body.ModelSpendingLimits.ModelWeek( - amount=week_limit_amount, - currency=week_limit_currency, - ) - if week_limit_amount is not None and week_limit_currency is not None - else None, - month=endpoint.Body.ModelSpendingLimits.ModelMonth( - amount=month_limit_amount, - currency=month_limit_currency, - ) - if month_limit_amount is not None and month_limit_currency is not None - else None, - quarter=endpoint.Body.ModelSpendingLimits.ModelQuarter( - amount=quarter_limit_amount, - currency=quarter_limit_currency, - ) - if quarter_limit_amount is not None and quarter_limit_currency is not None - else None, - year=endpoint.Body.ModelSpendingLimits.ModelYear( - amount=year_limit_amount, - currency=year_limit_currency, - ) - if year_limit_amount is not None and year_limit_currency is not None - else None, - all_time=endpoint.Body.ModelSpendingLimits.ModelAllTime( - amount=all_time_limit_amount, - currency=all_time_limit_currency, - ) - if all_time_limit_amount is not None and all_time_limit_currency is not None - else None, - ) - if not any( - [ - spending_limits.single is not None, - spending_limits.day is not None, - spending_limits.week is not None, - spending_limits.month is not None, - spending_limits.quarter is not None, - spending_limits.year is not None, - spending_limits.all_time is not None, - ] - ): - spending_limits = None - - body = endpoint.Body( - request_id=request_id, - virtual=True, - holder_id=holder_id, - label=label, - accounts=accounts, - categories=categories, - spending_limits=spending_limits, - ) - - response = await self.client.apost( - path=path, - body=body, - **kwargs, - ) - - return endpoint.Response(**response.json()).model_dump() - - async def afreeze_card( - self, - card_id: UUID, - **kwargs, - ): - """ - Freeze a card to make it temporarily unavailable for spending. - You can only freeze a card that is in the state active. - - A successful freeze changes the card's state to frozen, - and no content is returned in the response. - - Parameters - ---------- - card_id : UUID - The card ID. - - Returns - ------- - dict - An empty dictionary. - """ - endpoint = FreezeCard - path = endpoint.ROUTE.format(card_id=card_id) - body = endpoint.Body() - - await self.client.apost( - path=path, - body=body, - **kwargs, - ) - - return endpoint.Response().model_dump() - - async def aunfreeze_card( - self, - card_id: UUID, - **kwargs, - ): - """ - Unfreeze a card to make it available for spending again. - You can only unfreeze a card that is in the state frozen. - - A successful unfreeze changes the card's state to active, - and no content is returned in the response. - - Parameters - ---------- - card_id : UUID - The card ID. - - Returns - ------- - dict - An empty dictionary. - """ - endpoint = UnfreezeCard - path = endpoint.ROUTE.format(card_id=card_id) - body = endpoint.Body() - - await self.client.apost( - path=path, - body=body, - **kwargs, - ) - - return endpoint.Response().model_dump() - - async def aupdate_card( - self, - card_id: UUID, - label: str | None = None, - categories: list[EnumMerchantCategory] | Literal["null"] | None = None, - single_limit_amount: Decimal | Literal["null"] | None = None, - single_limit_currency: str | Literal["null"] | None = None, - day_limit_amount: Decimal | Literal["null"] | None = None, - day_limit_currency: str | Literal["null"] | None = None, - week_limit_amount: Decimal | Literal["null"] | None = None, - week_limit_currency: str | Literal["null"] | None = None, - month_limit_amount: Decimal | Literal["null"] | None = None, - month_limit_currency: str | Literal["null"] | None = None, - quarter_limit_amount: Decimal | Literal["null"] | None = None, - quarter_limit_currency: str | Literal["null"] | None = None, - year_limit_amount: Decimal | Literal["null"] | None = None, - year_limit_currency: str | Literal["null"] | None = None, - all_time_limit_amount: Decimal | Literal["null"] | None = None, - all_time_limit_currency: str | Literal["null"] | None = None, - **kwargs, - ): - """ - Update details of a specific card, based on its ID. - Updating a spending limit does not reset the spending counter. - - Parameters - ---------- - card_id : UUID - The card ID. - label : str | None - The label of the card. - categories : list[EnumMerchantCategory] | Literal["null"] | None - The list of merchant categories to link to the card. - If set to 'null', all categories will be linked. - single_limit_amount : Decimal | Literal["null"] | None - The maximum amount for a single transaction. - If set to 'null', the limit will be removed. - single_limit_currency : str | Literal["null"] | None - The currency of the single transaction limit. - If set to 'null', the limit will be removed. - day_limit_amount : Decimal | Literal["null"] | None - The maximum amount for transactions in a day. - If set to 'null', the limit will be removed. - day_limit_currency : str | Literal["null"] | None - The currency of the day limit. - If set to 'null', the limit will be removed. - week_limit_amount : Decimal | Literal["null"] | None - The maximum amount for transactions in a week. - If set to 'null', the limit will be removed. - week_limit_currency : str | Literal["null"] | None - The currency of the week limit. - If set to 'null', the limit will be removed. - month_limit_amount : Decimal | Literal["null"] | None - The maximum amount for transactions in a month. - If set to 'null', the limit will be removed. - month_limit_currency : str | Literal["null"] | None - The currency of the month limit. - If set to 'null', the limit will be removed. - quarter_limit_amount : Decimal | Literal["null"] | None - The maximum amount for transactions in a quarter. - If set to 'null', the limit will be removed. - quarter_limit_currency : str | Literal["null"] | None - The currency of the quarter limit. - If set to 'null', the limit will be removed. - year_limit_amount : Decimal | Literal["null"] | None - The maximum amount for transactions in a year. - If set to 'null', the limit will be removed. - year_limit_currency : str | Literal["null"] | None - The currency of the year limit. - If set to 'null', the limit will be removed. - all_time_limit_amount : Decimal | Literal["null"] | None - The maximum amount for transactions in the card's lifetime. - If set to 'null', the limit will be removed. - all_time_limit_currency : str | Literal["null"] | None - The currency of the all-time limit. - If set to 'null', the limit will be removed. - - Returns - ------- - dict - The updated details of the card. - """ - endpoint = UpdateCardDetails - path = endpoint.ROUTE.format(card_id=card_id) - - # Create the SpendingLimits model (if applicable) - spending_limits = endpoint.Body.ModelSpendingLimits( - single=self.__process_limit_model( - model=endpoint.Body.ModelSpendingLimits.ModelSingle, - amount=single_limit_amount, - currency=single_limit_currency, - ), - day=self.__process_limit_model( - model=endpoint.Body.ModelSpendingLimits.ModelDay, - amount=day_limit_amount, - currency=day_limit_currency, - ), - week=self.__process_limit_model( - model=endpoint.Body.ModelSpendingLimits.ModelWeek, - amount=week_limit_amount, - currency=week_limit_currency, - ), - month=self.__process_limit_model( - model=endpoint.Body.ModelSpendingLimits.ModelMonth, - amount=month_limit_amount, - currency=month_limit_currency, - ), - quarter=self.__process_limit_model( - model=endpoint.Body.ModelSpendingLimits.ModelQuarter, - amount=quarter_limit_amount, - currency=quarter_limit_currency, - ), - year=self.__process_limit_model( - model=endpoint.Body.ModelSpendingLimits.ModelYear, - amount=year_limit_amount, - currency=year_limit_currency, - ), - all_time=self.__process_limit_model( - model=endpoint.Body.ModelSpendingLimits.ModelAllTime, - amount=all_time_limit_amount, - currency=all_time_limit_currency, - ), - ) - if not any( - [ - spending_limits.single is not None, - spending_limits.day is not None, - spending_limits.week is not None, - spending_limits.month is not None, - spending_limits.quarter is not None, - spending_limits.year is not None, - spending_limits.all_time is not None, - ] - ): - spending_limits = None - elif all( - [ - spending_limits.single == "null", - spending_limits.day == "null", - spending_limits.week == "null", - spending_limits.month == "null", - spending_limits.quarter == "null", - spending_limits.year == "null", - spending_limits.all_time == "null", - ] - ): - spending_limits = "null" - - body = endpoint.Body( - label=label, - categories=categories, - spending_limits=spending_limits, - ) - - response = await self.client.apatch( - path=path, - body=body, - **kwargs, - ) - - return endpoint.Response(**response.json()).model_dump() - - async def adelete_card( - self, - card_id: UUID, - **kwargs, - ): - """ - Terminate a specific card, based on its ID. - - Once the card is terminated, it will not be returned by the API. - - A successful response does not get any content in return. - - Parameters - ---------- - card_id : UUID - The card ID. - - Returns - ------- - dict - An empty dictionary. - """ - endpoint = TerminateCard - path = endpoint.ROUTE.format(card_id=card_id) - params = endpoint.Params() - - await self.client.adelete( - path=path, - params=params, - **kwargs, - ) - - return endpoint.Response().model_dump() - def __process_limit_model( self, model: Type[BaseModel], diff --git a/pyrevolut/api/common/__init__.py b/pyrevolut/api/common/__init__.py index 44489f8..d538bce 100644 --- a/pyrevolut/api/common/__init__.py +++ b/pyrevolut/api/common/__init__.py @@ -4,4 +4,4 @@ from .enums import * from .models import * -from .endpoint import BaseEndpoint +from .endpoint import BaseEndpointSync, BaseEndpointAsync diff --git a/pyrevolut/api/common/endpoint.py b/pyrevolut/api/common/endpoint.py index b91d9a7..240328c 100644 --- a/pyrevolut/api/common/endpoint.py +++ b/pyrevolut/api/common/endpoint.py @@ -1,10 +1,10 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from pyrevolut.client import Client + from pyrevolut.client import Client, AsyncClient -class BaseEndpoint: +class BaseEndpointSync: """Base class for all endpoints.""" def __init__(self, client: "Client"): @@ -16,3 +16,17 @@ def __init__(self, client: "Client"): The client to use for the endpoint """ self.client = client + + +class BaseEndpointAsync: + """Base class for all async endpoints.""" + + def __init__(self, client: "AsyncClient"): + """Create a new Base endpoint handler + + Parameters + ---------- + client : AsyncClient + The async client to use for the endpoint + """ + self.client = client diff --git a/pyrevolut/api/counterparties/__init__.py b/pyrevolut/api/counterparties/__init__.py index f73126f..ac6ad37 100644 --- a/pyrevolut/api/counterparties/__init__.py +++ b/pyrevolut/api/counterparties/__init__.py @@ -18,4 +18,4 @@ """ # flake8: noqa: F401 -from .endpoint import EndpointCounterparties +from .endpoint import EndpointCounterpartiesSync, EndpointCounterpartiesAsync diff --git a/pyrevolut/api/counterparties/endpoint/__init__.py b/pyrevolut/api/counterparties/endpoint/__init__.py new file mode 100644 index 0000000..59a84d1 --- /dev/null +++ b/pyrevolut/api/counterparties/endpoint/__init__.py @@ -0,0 +1,5 @@ +"""This module holds the coutnerparties endpoints handlers.""" + +# flake8: noqa: F401 +from .synchronous import EndpointCounterpartiesSync +from .asynchronous import EndpointCounterpartiesAsync diff --git a/pyrevolut/api/counterparties/endpoint/asynchronous.py b/pyrevolut/api/counterparties/endpoint/asynchronous.py new file mode 100644 index 0000000..fe4c2bf --- /dev/null +++ b/pyrevolut/api/counterparties/endpoint/asynchronous.py @@ -0,0 +1,351 @@ +from uuid import UUID +from datetime import datetime + +from pyrevolut.api.common import BaseEndpointAsync, EnumProfileType +from pyrevolut.utils import DateTime + +from pyrevolut.api.counterparties.get import RetrieveListOfCounterparties, RetrieveCounterparty +from pyrevolut.api.counterparties.post import CreateCounterparty, ValidateAccountName +from pyrevolut.api.counterparties.delete import DeleteCounterparty + + +class EndpointCounterpartiesAsync(BaseEndpointAsync): + """The async Counterparties API + + Manage counterparties that you intend to transact with. + + Request and response examples can vary based on the account provider's + location and type of the counterparty. + + In the Sandbox environment, you cannot add real people and businesses as Revolut counterparties. + Therefore, to help you simulate Create a counterparty requests, we have created some + test accounts for counterparties of profile type personal. + + To add a counterparty via Revtag, use one of these pairs for the name and revtag fields respectively: + + Test User 1 & john1pvki + Test User 2 & john2pvki + ... + Test User 9 & john9pvki + """ + + async def get_all_counterparties( + self, + name: str | None = None, + account_no: str | None = None, + sort_code: str | None = None, + iban: str | None = None, + bic: str | None = None, + created_before: datetime | DateTime | str | int | float | None = None, + limit: int | None = None, + **kwargs, + ): + """ + Get all the counterparties that you have created, or use the query parameters to filter the results. + + The counterparties are sorted by the created_at date in reverse chronological order. + + The returned counterparties are paginated. The maximum number of counterparties returned per page + is specified by the limit parameter. To get to the next page, make a new request and use the + created_at date of the last counterparty returned in the previous response. + + Parameters + ---------- + name : str | None + The name of the counterparty to retrieve. It does not need to be an exact match, + partial match is also supported. + account_no : str | None + The exact account number of the counterparty to retrieve. + sort_code : str | None + The exact sort code of the counterparty to retrieve. + Only allowed in combination with the account_no parameter. + iban : str | None + The exact IBAN of the counterparty to retrieve. + bic : str | None + The exact BIC of the counterparty to retrieve. Only allowed in combination with the iban parameter. + created_before : datetime | DateTime | str | int | float | None + Retrieves counterparties with created_at < created_before. + The default value is the current date and time at which you are calling the endpoint. + Provided in ISO 8601 format. + limit : int | None + The maximum number of counterparties returned per page. + To get to the next page, make a new request and use the + created_at date of the last card returned in the previous + response as the value for created_before. + + Returns + ------- + list + The list of all counterparties that you have created. + """ + endpoint = RetrieveListOfCounterparties + path = endpoint.ROUTE + params = endpoint.Params( + name=name, + account_no=account_no, + sort_code=sort_code, + iban=iban, + bic=bic, + created_before=created_before, + limit=limit, + ) + + response = await self.client.get( + path=path, + params=params, + **kwargs, + ) + + return [endpoint.Response(**resp).model_dump() for resp in response.json()] + + async def get_counterparty( + self, + counterparty_id: UUID, + **kwargs, + ): + """Get the information about a specific counterparty by ID.""" + endpoint = RetrieveCounterparty + path = endpoint.ROUTE.format(counterparty_id=counterparty_id) + params = endpoint.Params() + + response = await self.client.get( + path=path, + params=params, + **kwargs, + ) + + return endpoint.Response(**response.json()) + + async def create_counterparty( + self, + company_name: str | None = None, + profile_type: EnumProfileType | None = None, + name: str | None = None, + individual_first_name: str | None = None, + individual_last_name: str | None = None, + bank_country: str | None = None, + currency: str | None = None, + revtag: str | None = None, + account_no: str | None = None, + iban: str | None = None, + sort_code: str | None = None, + routing_number: str | None = None, + bic: str | None = None, + clabe: str | None = None, + isfc: str | None = None, + bsb_code: str | None = None, + address_street_line1: str | None = None, + address_street_line2: str | None = None, + address_region: str | None = None, + address_city: str | None = None, + address_country: str | None = None, + address_postcode: str | None = None, + **kwargs, + ): + """ + Create a new counterparty to transact with. + + In the Sandbox environment, you cannot add real people and businesses as Revolut counterparties. + To help you simulate Create a counterparty requests for counterparties of profile type personal, + we created some test accounts. Look inside for test Revtags. + + To add a counterparty via Revtag, use one of these pairs for the name and revtag fields respectively: + + Test User 1 & john1pvki + Test User 2 & john2pvki + ... + Test User 9 & john9pvki + + Parameters + ---------- + company_name : str | None + The name of the company counterparty. + Use when individual_name or name isn't specified and profile_type is business. + profile_type : EnumProfileType | None + The type of the Revolut profile. Used when adding an existing Revolut user via Revtag. + name : str | None + The name of the counterparty that you create for an existing Revolut user via Revtag. + Provide the value only when you specify personal for profile_type. + individual_first_name : str | None + The first name of the individual counterparty. + Use when company_name isn't specified. + individual_last_name : str | None + The last name of the individual counterparty. + Use when company_name isn't specified. + bank_country : str | None + The country of the counterparty's bank as the 2-letter ISO 3166 code. + currency : str | None + ISO 4217 currency code in upper case. + revtag : str | None + The Revtag of the counterparty to add. + account_no : str | None + The bank account number of the counterparty. + iban : str | None + The IBAN number of the counterparty's account. This field is displayed for IBAN countries. + sort_code : str | None + The sort code of the counterparty's bank. This field is displayed for GBP accounts. + routing_number : str | None + The routing number of the counterparty's bank. This field is displayed for USD accounts. + bic : str | None + The BIC number of the counterparty's account. This field is required for non-SEPA IBAN/SWIFT. + clabe : str | None + The CLABE number of the counterparty's account. This field is required for SWIFT MX. + isfc : str | None + The ISFC number of the counterparty's account. This field is required for INR accounts. + bsb_code : str | None + The BSB code of the counterparty's account. This field is required for AUD accounts. + address_street_line1 : str | None + Street line 1 information. + address_street_line2 : str | None + Street line 2 information. + address_region : str | None + The name of the region. + address_city : str | None + The name of the city. + address_country : str | None + The country of the counterparty's address as the 2-letter ISO 3166 code. + address_postcode : str | None + The postcode of the counterparty's address. + + Returns + ------- + dict + A dict with the information about the created counterparty. + """ + endpoint = CreateCounterparty + path = endpoint.ROUTE + body = endpoint.Body( + company_name=company_name, + profile_type=profile_type, + name=name, + individual_name=endpoint.Body.ModelIndividualName( + first_name=individual_first_name, + last_name=individual_last_name, + ), + bank_country=bank_country, + currency=currency, + revtag=revtag, + account_no=account_no, + iban=iban, + sort_code=sort_code, + routing_number=routing_number, + bic=bic, + clabe=clabe, + isfc=isfc, + bsb_code=bsb_code, + address=endpoint.Body.ModelAddress( + street_line1=address_street_line1, + street_line2=address_street_line2, + region=address_region, + city=address_city, + country=address_country, + postcode=address_postcode, + ), + ) + + response = await self.client.post( + path=path, + body=body, + **kwargs, + ) + + return endpoint.Response(**response.json()) + + async def validate_account_name( + self, + account_no: str, + sort_code: str, + company_name: str | None = None, + individual_first_name: str | None = None, + individual_last_name: str | None = None, + **kwargs, + ): + """ + Use Confirmation of Payee (CoP) to validate a UK counterparty's account name + against their account number and sort code when adding a counterparty or making a + transfer to a new or existing counterparty. + + Note + ---- + Confirmation of Payee is an account name checking system in the UK that helps clients + to make sure payments aren't sent to the wrong bank or building society account. + + When performing the check, you must specify the account type by providing the name for either + an individual (personal account) or a company (business account). + + Caution + ------- + The CoP check does not protect you against all kinds of fraud. It only checks if the name you provided for an account matches that account's details. + Even if the counterparty's details match, you should still exercise due caution when transferring funds. + + This functionality is only available to UK-based businesses. + + Parameters + ---------- + account_no : str + The account number of the counterparty. + sort_code : str + The sort code of the counterparty's account. + company_name : str | None + The name of the business counterparty. Use when individual_name is not specified. + individual_first_name : str | None + The first name of the individual counterparty. + Use when company_name isn't specified. + individual_last_name : str | None + The last name of the individual counterparty. + Use when company_name isn't specified. + + Returns + ------- + dict + A dict with the information about the validated account name. + """ + endpoint = ValidateAccountName + path = endpoint.ROUTE + body = endpoint.Body( + account_no=account_no, + sort_code=sort_code, + company_name=company_name, + individual_name=endpoint.Body.ModelIndividualName( + first_name=individual_first_name, + last_name=individual_last_name, + ), + ) + + response = await self.client.post( + path=path, + body=body, + **kwargs, + ) + + return endpoint.Response(**response.json()) + + async def delete_counterparty( + self, + counterparty_id: UUID, + **kwargs, + ): + """Delete a counterparty with the given ID. + When a counterparty is deleted, you cannot make any payments to the counterparty. + + Parameters + ---------- + counterparty_id : UUID + The ID of the counterparty to delete. + + Returns + ------- + dict + An empty dict. + """ + endpoint = DeleteCounterparty + path = endpoint.ROUTE.format(counterparty_id=counterparty_id) + params = endpoint.Params() + + await self.client.delete( + path=path, + params=params, + **kwargs, + ) + + return endpoint.Response().model_dump() diff --git a/pyrevolut/api/counterparties/endpoint.py b/pyrevolut/api/counterparties/endpoint/synchronous.py similarity index 50% rename from pyrevolut/api/counterparties/endpoint.py rename to pyrevolut/api/counterparties/endpoint/synchronous.py index 1ec6768..3f8795c 100644 --- a/pyrevolut/api/counterparties/endpoint.py +++ b/pyrevolut/api/counterparties/endpoint/synchronous.py @@ -1,15 +1,15 @@ from uuid import UUID from datetime import datetime -from pyrevolut.api.common import BaseEndpoint, EnumProfileType +from pyrevolut.api.common import BaseEndpointSync, EnumProfileType from pyrevolut.utils import DateTime -from .get import RetrieveListOfCounterparties, RetrieveCounterparty -from .post import CreateCounterparty, ValidateAccountName -from .delete import DeleteCounterparty +from pyrevolut.api.counterparties.get import RetrieveListOfCounterparties, RetrieveCounterparty +from pyrevolut.api.counterparties.post import CreateCounterparty, ValidateAccountName +from pyrevolut.api.counterparties.delete import DeleteCounterparty -class EndpointCounterparties(BaseEndpoint): +class EndpointCounterpartiesSync(BaseEndpointSync): """The Counterparties API Manage counterparties that you intend to transact with. @@ -349,324 +349,3 @@ def delete_counterparty( ) return endpoint.Response().model_dump() - - async def aget_all_counterparties( - self, - name: str | None = None, - account_no: str | None = None, - sort_code: str | None = None, - iban: str | None = None, - bic: str | None = None, - created_before: datetime | DateTime | str | int | float | None = None, - limit: int | None = None, - **kwargs, - ): - """ - Get all the counterparties that you have created, or use the query parameters to filter the results. - - The counterparties are sorted by the created_at date in reverse chronological order. - - The returned counterparties are paginated. The maximum number of counterparties returned per page - is specified by the limit parameter. To get to the next page, make a new request and use the - created_at date of the last counterparty returned in the previous response. - - Parameters - ---------- - name : str | None - The name of the counterparty to retrieve. It does not need to be an exact match, - partial match is also supported. - account_no : str | None - The exact account number of the counterparty to retrieve. - sort_code : str | None - The exact sort code of the counterparty to retrieve. - Only allowed in combination with the account_no parameter. - iban : str | None - The exact IBAN of the counterparty to retrieve. - bic : str | None - The exact BIC of the counterparty to retrieve. Only allowed in combination with the iban parameter. - created_before : datetime | DateTime | str | int | float | None - Retrieves counterparties with created_at < created_before. - The default value is the current date and time at which you are calling the endpoint. - Provided in ISO 8601 format. - limit : int | None - The maximum number of counterparties returned per page. - To get to the next page, make a new request and use the - created_at date of the last card returned in the previous - response as the value for created_before. - - Returns - ------- - list - The list of all counterparties that you have created. - """ - endpoint = RetrieveListOfCounterparties - path = endpoint.ROUTE - params = endpoint.Params( - name=name, - account_no=account_no, - sort_code=sort_code, - iban=iban, - bic=bic, - created_before=created_before, - limit=limit, - ) - - response = await self.client.aget( - path=path, - params=params, - **kwargs, - ) - - return [endpoint.Response(**resp).model_dump() for resp in response.json()] - - async def aget_counterparty( - self, - counterparty_id: UUID, - **kwargs, - ): - """Get the information about a specific counterparty by ID.""" - endpoint = RetrieveCounterparty - path = endpoint.ROUTE.format(counterparty_id=counterparty_id) - params = endpoint.Params() - - response = await self.client.aget( - path=path, - params=params, - **kwargs, - ) - - return endpoint.Response(**response.json()) - - async def acreate_counterparty( - self, - company_name: str | None = None, - profile_type: EnumProfileType | None = None, - name: str | None = None, - individual_first_name: str | None = None, - individual_last_name: str | None = None, - bank_country: str | None = None, - currency: str | None = None, - revtag: str | None = None, - account_no: str | None = None, - iban: str | None = None, - sort_code: str | None = None, - routing_number: str | None = None, - bic: str | None = None, - clabe: str | None = None, - isfc: str | None = None, - bsb_code: str | None = None, - address_street_line1: str | None = None, - address_street_line2: str | None = None, - address_region: str | None = None, - address_city: str | None = None, - address_country: str | None = None, - address_postcode: str | None = None, - **kwargs, - ): - """ - Create a new counterparty to transact with. - - In the Sandbox environment, you cannot add real people and businesses as Revolut counterparties. - To help you simulate Create a counterparty requests for counterparties of profile type personal, - we created some test accounts. Look inside for test Revtags. - - To add a counterparty via Revtag, use one of these pairs for the name and revtag fields respectively: - - Test User 1 & john1pvki - Test User 2 & john2pvki - ... - Test User 9 & john9pvki - - Parameters - ---------- - company_name : str | None - The name of the company counterparty. - Use when individual_name or name isn't specified and profile_type is business. - profile_type : EnumProfileType | None - The type of the Revolut profile. Used when adding an existing Revolut user via Revtag. - name : str | None - The name of the counterparty that you create for an existing Revolut user via Revtag. - Provide the value only when you specify personal for profile_type. - individual_first_name : str | None - The first name of the individual counterparty. - Use when company_name isn't specified. - individual_last_name : str | None - The last name of the individual counterparty. - Use when company_name isn't specified. - bank_country : str | None - The country of the counterparty's bank as the 2-letter ISO 3166 code. - currency : str | None - ISO 4217 currency code in upper case. - revtag : str | None - The Revtag of the counterparty to add. - account_no : str | None - The bank account number of the counterparty. - iban : str | None - The IBAN number of the counterparty's account. This field is displayed for IBAN countries. - sort_code : str | None - The sort code of the counterparty's bank. This field is displayed for GBP accounts. - routing_number : str | None - The routing number of the counterparty's bank. This field is displayed for USD accounts. - bic : str | None - The BIC number of the counterparty's account. This field is required for non-SEPA IBAN/SWIFT. - clabe : str | None - The CLABE number of the counterparty's account. This field is required for SWIFT MX. - isfc : str | None - The ISFC number of the counterparty's account. This field is required for INR accounts. - bsb_code : str | None - The BSB code of the counterparty's account. This field is required for AUD accounts. - address_street_line1 : str | None - Street line 1 information. - address_street_line2 : str | None - Street line 2 information. - address_region : str | None - The name of the region. - address_city : str | None - The name of the city. - address_country : str | None - The country of the counterparty's address as the 2-letter ISO 3166 code. - address_postcode : str | None - The postcode of the counterparty's address. - - Returns - ------- - dict - A dict with the information about the created counterparty. - """ - endpoint = CreateCounterparty - path = endpoint.ROUTE - body = endpoint.Body( - company_name=company_name, - profile_type=profile_type, - name=name, - individual_name=endpoint.Body.ModelIndividualName( - first_name=individual_first_name, - last_name=individual_last_name, - ), - bank_country=bank_country, - currency=currency, - revtag=revtag, - account_no=account_no, - iban=iban, - sort_code=sort_code, - routing_number=routing_number, - bic=bic, - clabe=clabe, - isfc=isfc, - bsb_code=bsb_code, - address=endpoint.Body.ModelAddress( - street_line1=address_street_line1, - street_line2=address_street_line2, - region=address_region, - city=address_city, - country=address_country, - postcode=address_postcode, - ), - ) - - response = await self.client.apost( - path=path, - body=body, - **kwargs, - ) - - return endpoint.Response(**response.json()) - - async def avalidate_account_name( - self, - account_no: str, - sort_code: str, - company_name: str | None = None, - individual_first_name: str | None = None, - individual_last_name: str | None = None, - **kwargs, - ): - """ - Use Confirmation of Payee (CoP) to validate a UK counterparty's account name - against their account number and sort code when adding a counterparty or making a - transfer to a new or existing counterparty. - - Note - ---- - Confirmation of Payee is an account name checking system in the UK that helps clients - to make sure payments aren't sent to the wrong bank or building society account. - - When performing the check, you must specify the account type by providing the name for either - an individual (personal account) or a company (business account). - - Caution - ------- - The CoP check does not protect you against all kinds of fraud. It only checks if the name you provided for an account matches that account's details. - Even if the counterparty's details match, you should still exercise due caution when transferring funds. - - This functionality is only available to UK-based businesses. - - Parameters - ---------- - account_no : str - The account number of the counterparty. - sort_code : str - The sort code of the counterparty's account. - company_name : str | None - The name of the business counterparty. Use when individual_name is not specified. - individual_first_name : str | None - The first name of the individual counterparty. - Use when company_name isn't specified. - individual_last_name : str | None - The last name of the individual counterparty. - Use when company_name isn't specified. - - Returns - ------- - dict - A dict with the information about the validated account name. - """ - endpoint = ValidateAccountName - path = endpoint.ROUTE - body = endpoint.Body( - account_no=account_no, - sort_code=sort_code, - company_name=company_name, - individual_name=endpoint.Body.ModelIndividualName( - first_name=individual_first_name, - last_name=individual_last_name, - ), - ) - - response = await self.client.apost( - path=path, - body=body, - **kwargs, - ) - - return endpoint.Response(**response.json()) - - async def adelete_counterparty( - self, - counterparty_id: UUID, - **kwargs, - ): - """Delete a counterparty with the given ID. - When a counterparty is deleted, you cannot make any payments to the counterparty. - - Parameters - ---------- - counterparty_id : UUID - The ID of the counterparty to delete. - - Returns - ------- - dict - An empty dict. - """ - endpoint = DeleteCounterparty - path = endpoint.ROUTE.format(counterparty_id=counterparty_id) - params = endpoint.Params() - - await self.client.adelete( - path=path, - params=params, - **kwargs, - ) - - return endpoint.Response().model_dump() diff --git a/pyrevolut/api/foreign_exchange/__init__.py b/pyrevolut/api/foreign_exchange/__init__.py index 7626eb4..bd3da95 100644 --- a/pyrevolut/api/foreign_exchange/__init__.py +++ b/pyrevolut/api/foreign_exchange/__init__.py @@ -4,4 +4,4 @@ """ # flake8: noqa: F401 -from .endpoint import EndpointForeignExchange +from .endpoint import EndpointForeignExchangeSync, EndpointForeignExchangeAsync diff --git a/pyrevolut/api/foreign_exchange/endpoint.py b/pyrevolut/api/foreign_exchange/endpoint.py deleted file mode 100644 index 2e1f668..0000000 --- a/pyrevolut/api/foreign_exchange/endpoint.py +++ /dev/null @@ -1,256 +0,0 @@ -from uuid import UUID -from decimal import Decimal - -from pyrevolut.api.common import BaseEndpoint - -from .get import GetExchangeRate -from .post import ExchangeMoney - - -class EndpointForeignExchange(BaseEndpoint): - """The Foreign Exchange API - - Retrieve information on exchange rates between currencies, buy and sell currencies. - """ - - def get_exchange_rate( - self, - from_currency: str, - to_currency: str, - amount: Decimal | None = None, - **kwargs, - ): - """ - Get the sell exchange rate between two currencies. - - Parameters - ---------- - from_currency : str - The currency that you exchange from in ISO 4217 format. - to_currency : str - The currency that you exchange to in ISO 4217 format. - amount : Decimal | None - The amount of the currency to exchange from. - The default value is 1.00 if not provided. - - Returns - ------- - dict - A dict with the information about the exchange rate. - """ - endpoint = GetExchangeRate - path = endpoint.ROUTE - params = endpoint.Params( - from_=from_currency, - to=to_currency, - amount=amount, - ) - - response = self.client.get( - path=path, - params=params, - **kwargs, - ) - - return endpoint.Response(**response.json()) - - def exchange_money( - self, - request_id: str, - from_account_id: UUID, - from_currency: str, - to_account_id: UUID, - to_currency: str, - from_amount: Decimal | None = None, - to_amount: Decimal | None = None, - reference: str | None = None, - **kwargs, - ): - """ - Exchange money using one of these methods: - - Sell currency: - You know the amount of currency to sell. - For example, you want to exchange 135.5 USD to some EUR. - Specify the amount in the from object. - - Buy currency: - You know the amount of currency to buy. - For example, you want to exchange some USD to 200 EUR. - Specify the amount in the to object. - - Parameters - ---------- - request_id : str - The ID of the request, provided by you. - It helps you identify the transaction in your system. - - To ensure that an exchange transaction is not processed multiple - times if there are network or system errors, the same request_id - should be used for requests related to the same transaction. - from_account_id : UUID - The ID of the account to sell currency from. - from_currency : str - The currency to sell in ISO 4217 format. - to_account_id : UUID - The ID of the account to receive exchanged currency into. - to_currency : str - The currency to buy in ISO 4217 format. - from_amount : Decimal | None - The amount of currency. Specify ONLY if you want to sell currency. - to_amount : Decimal | None - The amount of currency. Specify ONLY if you want to buy currency. - reference : str | None - The reference for the exchange transaction, provided by you. - It helps you to identify the transaction if you want to look it up later. - - Returns - ------- - dict - A dict with the information about the exchange transaction. - """ - endpoint = ExchangeMoney - path = endpoint.ROUTE - body = endpoint.Body( - from_=endpoint.Body.ModelFrom( - account_id=from_account_id, - currency=from_currency, - amount=from_amount, - ), - to=endpoint.Body.ModelTo( - account_id=to_account_id, - currency=to_currency, - amount=to_amount, - ), - reference=reference, - request_id=request_id, - ) - - response = self.client.post( - path=path, - body=body, - **kwargs, - ) - - return endpoint.Response(**response.json()) - - async def aget_exchange_rate( - self, - from_currency: str, - to_currency: str, - amount: Decimal | None = None, - **kwargs, - ): - """ - Get the sell exchange rate between two currencies. - - Parameters - ---------- - from_currency : str - The currency that you exchange from in ISO 4217 format. - to_currency : str - The currency that you exchange to in ISO 4217 format. - amount : Decimal | None - The amount of the currency to exchange from. - The default value is 1.00 if not provided. - - Returns - ------- - dict - A dict with the information about the exchange rate. - """ - endpoint = GetExchangeRate - path = endpoint.ROUTE - params = endpoint.Params( - from_=from_currency, - to=to_currency, - amount=amount, - ) - - response = await self.client.aget( - path=path, - params=params, - **kwargs, - ) - - return endpoint.Response(**response.json()) - - async def aexchange_money( - self, - request_id: str, - from_account_id: UUID, - from_currency: str, - to_account_id: UUID, - to_currency: str, - from_amount: Decimal | None = None, - to_amount: Decimal | None = None, - reference: str | None = None, - **kwargs, - ): - """ - Exchange money using one of these methods: - - Sell currency: - You know the amount of currency to sell. - For example, you want to exchange 135.5 USD to some EUR. - Specify the amount in the from object. - - Buy currency: - You know the amount of currency to buy. - For example, you want to exchange some USD to 200 EUR. - Specify the amount in the to object. - - Parameters - ---------- - request_id : str - The ID of the request, provided by you. - It helps you identify the transaction in your system. - - To ensure that an exchange transaction is not processed multiple - times if there are network or system errors, the same request_id - should be used for requests related to the same transaction. - from_account_id : UUID - The ID of the account to sell currency from. - from_currency : str - The currency to sell in ISO 4217 format. - to_account_id : UUID - The ID of the account to receive exchanged currency into. - to_currency : str - The currency to buy in ISO 4217 format. - from_amount : Decimal | None - The amount of currency. Specify ONLY if you want to sell currency. - to_amount : Decimal | None - The amount of currency. Specify ONLY if you want to buy currency. - reference : str | None - The reference for the exchange transaction, provided by you. - It helps you to identify the transaction if you want to look it up later. - - Returns - ------- - dict - A dict with the information about the exchange transaction. - """ - endpoint = ExchangeMoney - path = endpoint.ROUTE - body = endpoint.Body( - from_=endpoint.Body.ModelFrom( - account_id=from_account_id, - currency=from_currency, - amount=from_amount, - ), - to=endpoint.Body.ModelTo( - account_id=to_account_id, - currency=to_currency, - amount=to_amount, - ), - reference=reference, - request_id=request_id, - ) - - response = await self.client.apost( - path=path, - body=body, - **kwargs, - ) - - return endpoint.Response(**response.json()) diff --git a/pyrevolut/api/foreign_exchange/endpoint/__init__.py b/pyrevolut/api/foreign_exchange/endpoint/__init__.py new file mode 100644 index 0000000..8365245 --- /dev/null +++ b/pyrevolut/api/foreign_exchange/endpoint/__init__.py @@ -0,0 +1,5 @@ +"""This module holds the foreign exchange endpoints handlers.""" + +# flake8: noqa: F401 +from .synchronous import EndpointForeignExchangeSync +from .asynchronous import EndpointForeignExchangeAsync diff --git a/pyrevolut/api/foreign_exchange/endpoint/asynchronous.py b/pyrevolut/api/foreign_exchange/endpoint/asynchronous.py new file mode 100644 index 0000000..e610c5a --- /dev/null +++ b/pyrevolut/api/foreign_exchange/endpoint/asynchronous.py @@ -0,0 +1,135 @@ +from uuid import UUID +from decimal import Decimal + +from pyrevolut.api.common import BaseEndpointAsync + +from pyrevolut.api.foreign_exchange.get import GetExchangeRate +from pyrevolut.api.foreign_exchange.post import ExchangeMoney + + +class EndpointForeignExchangeAsync(BaseEndpointAsync): + """The async Foreign Exchange API + + Retrieve information on exchange rates between currencies, buy and sell currencies. + """ + + async def get_exchange_rate( + self, + from_currency: str, + to_currency: str, + amount: Decimal | None = None, + **kwargs, + ): + """ + Get the sell exchange rate between two currencies. + + Parameters + ---------- + from_currency : str + The currency that you exchange from in ISO 4217 format. + to_currency : str + The currency that you exchange to in ISO 4217 format. + amount : Decimal | None + The amount of the currency to exchange from. + The default value is 1.00 if not provided. + + Returns + ------- + dict + A dict with the information about the exchange rate. + """ + endpoint = GetExchangeRate + path = endpoint.ROUTE + params = endpoint.Params( + from_=from_currency, + to=to_currency, + amount=amount, + ) + + response = await self.client.get( + path=path, + params=params, + **kwargs, + ) + + return endpoint.Response(**response.json()) + + async def exchange_money( + self, + request_id: str, + from_account_id: UUID, + from_currency: str, + to_account_id: UUID, + to_currency: str, + from_amount: Decimal | None = None, + to_amount: Decimal | None = None, + reference: str | None = None, + **kwargs, + ): + """ + Exchange money using one of these methods: + + Sell currency: + You know the amount of currency to sell. + For example, you want to exchange 135.5 USD to some EUR. + Specify the amount in the from object. + + Buy currency: + You know the amount of currency to buy. + For example, you want to exchange some USD to 200 EUR. + Specify the amount in the to object. + + Parameters + ---------- + request_id : str + The ID of the request, provided by you. + It helps you identify the transaction in your system. + + To ensure that an exchange transaction is not processed multiple + times if there are network or system errors, the same request_id + should be used for requests related to the same transaction. + from_account_id : UUID + The ID of the account to sell currency from. + from_currency : str + The currency to sell in ISO 4217 format. + to_account_id : UUID + The ID of the account to receive exchanged currency into. + to_currency : str + The currency to buy in ISO 4217 format. + from_amount : Decimal | None + The amount of currency. Specify ONLY if you want to sell currency. + to_amount : Decimal | None + The amount of currency. Specify ONLY if you want to buy currency. + reference : str | None + The reference for the exchange transaction, provided by you. + It helps you to identify the transaction if you want to look it up later. + + Returns + ------- + dict + A dict with the information about the exchange transaction. + """ + endpoint = ExchangeMoney + path = endpoint.ROUTE + body = endpoint.Body( + from_=endpoint.Body.ModelFrom( + account_id=from_account_id, + currency=from_currency, + amount=from_amount, + ), + to=endpoint.Body.ModelTo( + account_id=to_account_id, + currency=to_currency, + amount=to_amount, + ), + reference=reference, + request_id=request_id, + ) + + response = await self.client.post( + path=path, + body=body, + **kwargs, + ) + + return endpoint.Response(**response.json()) diff --git a/pyrevolut/api/foreign_exchange/endpoint/synchronous.py b/pyrevolut/api/foreign_exchange/endpoint/synchronous.py new file mode 100644 index 0000000..64a4c11 --- /dev/null +++ b/pyrevolut/api/foreign_exchange/endpoint/synchronous.py @@ -0,0 +1,135 @@ +from uuid import UUID +from decimal import Decimal + +from pyrevolut.api.common import BaseEndpointSync + +from pyrevolut.api.foreign_exchange.get import GetExchangeRate +from pyrevolut.api.foreign_exchange.post import ExchangeMoney + + +class EndpointForeignExchangeSync(BaseEndpointSync): + """The Foreign Exchange API + + Retrieve information on exchange rates between currencies, buy and sell currencies. + """ + + def get_exchange_rate( + self, + from_currency: str, + to_currency: str, + amount: Decimal | None = None, + **kwargs, + ): + """ + Get the sell exchange rate between two currencies. + + Parameters + ---------- + from_currency : str + The currency that you exchange from in ISO 4217 format. + to_currency : str + The currency that you exchange to in ISO 4217 format. + amount : Decimal | None + The amount of the currency to exchange from. + The default value is 1.00 if not provided. + + Returns + ------- + dict + A dict with the information about the exchange rate. + """ + endpoint = GetExchangeRate + path = endpoint.ROUTE + params = endpoint.Params( + from_=from_currency, + to=to_currency, + amount=amount, + ) + + response = self.client.get( + path=path, + params=params, + **kwargs, + ) + + return endpoint.Response(**response.json()) + + def exchange_money( + self, + request_id: str, + from_account_id: UUID, + from_currency: str, + to_account_id: UUID, + to_currency: str, + from_amount: Decimal | None = None, + to_amount: Decimal | None = None, + reference: str | None = None, + **kwargs, + ): + """ + Exchange money using one of these methods: + + Sell currency: + You know the amount of currency to sell. + For example, you want to exchange 135.5 USD to some EUR. + Specify the amount in the from object. + + Buy currency: + You know the amount of currency to buy. + For example, you want to exchange some USD to 200 EUR. + Specify the amount in the to object. + + Parameters + ---------- + request_id : str + The ID of the request, provided by you. + It helps you identify the transaction in your system. + + To ensure that an exchange transaction is not processed multiple + times if there are network or system errors, the same request_id + should be used for requests related to the same transaction. + from_account_id : UUID + The ID of the account to sell currency from. + from_currency : str + The currency to sell in ISO 4217 format. + to_account_id : UUID + The ID of the account to receive exchanged currency into. + to_currency : str + The currency to buy in ISO 4217 format. + from_amount : Decimal | None + The amount of currency. Specify ONLY if you want to sell currency. + to_amount : Decimal | None + The amount of currency. Specify ONLY if you want to buy currency. + reference : str | None + The reference for the exchange transaction, provided by you. + It helps you to identify the transaction if you want to look it up later. + + Returns + ------- + dict + A dict with the information about the exchange transaction. + """ + endpoint = ExchangeMoney + path = endpoint.ROUTE + body = endpoint.Body( + from_=endpoint.Body.ModelFrom( + account_id=from_account_id, + currency=from_currency, + amount=from_amount, + ), + to=endpoint.Body.ModelTo( + account_id=to_account_id, + currency=to_currency, + amount=to_amount, + ), + reference=reference, + request_id=request_id, + ) + + response = self.client.post( + path=path, + body=body, + **kwargs, + ) + + return endpoint.Response(**response.json()) diff --git a/pyrevolut/api/payment_drafts/__init__.py b/pyrevolut/api/payment_drafts/__init__.py index 6227fb3..3714e63 100644 --- a/pyrevolut/api/payment_drafts/__init__.py +++ b/pyrevolut/api/payment_drafts/__init__.py @@ -9,4 +9,4 @@ """ # flake8: noqa: F401 -from .endpoint import EndpointPaymentDrafts +from .endpoint import EndpointPaymentDraftsSync, EndpointPaymentDraftsAsync diff --git a/pyrevolut/api/payment_drafts/endpoint/__init__.py b/pyrevolut/api/payment_drafts/endpoint/__init__.py new file mode 100644 index 0000000..b413671 --- /dev/null +++ b/pyrevolut/api/payment_drafts/endpoint/__init__.py @@ -0,0 +1,5 @@ +"""This module holds the payment drafts endpoints handlers.""" + +# flake8: noqa: F401 +from .synchronous import EndpointPaymentDraftsSync +from .asynchronous import EndpointPaymentDraftsAsync diff --git a/pyrevolut/api/payment_drafts/endpoint/asynchronous.py b/pyrevolut/api/payment_drafts/endpoint/asynchronous.py new file mode 100644 index 0000000..150607e --- /dev/null +++ b/pyrevolut/api/payment_drafts/endpoint/asynchronous.py @@ -0,0 +1,210 @@ +from uuid import UUID +from decimal import Decimal +from datetime import date + +from pyrevolut.utils import Date +from pyrevolut.api.common import BaseEndpointAsync + +from pyrevolut.api.payment_drafts.get import RetrieveAllPaymentDrafts, RetrievePaymentDraft +from pyrevolut.api.payment_drafts.post import CreatePaymentDraft +from pyrevolut.api.payment_drafts.delete import DeletePaymentDraft + + +class EndpointPaymentDraftsAsync(BaseEndpointAsync): + """The async Payment Drafts API + + Create a payment draft to request an approval for a payment from a + business owner or admin before the payment is executed. + The business owner or admin must manually approve it in the + Revolut Business User Interface. + + You can also retrieve one or all payment drafts, and delete a payment draft. + """ + + async def get_all_payment_drafts( + self, + **kwargs, + ): + """ + Get a list of all the payment drafts that aren't processed. + + Parameters + ---------- + None + + Returns + ------- + dict + A dict with the information about the payment drafts. + """ + endpoint = RetrieveAllPaymentDrafts + path = endpoint.ROUTE + params = endpoint.Params() + + response = await self.client.get( + path=path, + params=params, + **kwargs, + ) + + return endpoint.Response(**response.json()) + + async def get_payment_draft( + self, + payment_draft_id: UUID, + **kwargs, + ): + """ + Get the information about a specific payment draft by ID. + + Parameters + ---------- + payment_draft_id : UUID + The ID of the payment draft. + + Returns + ------- + dict + A dict with the information about the payment draft. + """ + endpoint = RetrievePaymentDraft + path = endpoint.ROUTE.format(payment_draft_id=payment_draft_id) + params = endpoint.Params() + + response = await self.client.get( + path=path, + params=params, + **kwargs, + ) + + return endpoint.Response(**response.json()) + + async def create_payment_draft( + self, + account_id: UUID, + counterparty_ids: list[UUID] = [], + counterparty_account_ids: list[UUID | None] = [], + counterparty_card_ids: list[UUID | None] = [], + amounts: list[Decimal] = [], + currencies: list[str] = [], + references: list[str] = [], + title: str | None = None, + schedule_for: date | Date | str | None = None, + **kwargs, + ): + """ + Create a payment draft. + + Parameters + ---------- + account_id : UUID + The ID of the account to pay from. + counterparty_ids : list[UUID] + The IDs of the counterparty accounts. Each ID corresponds to a payment. + counterparty_account_ids : list[UUID | None] + The IDs of the counterparty accounts. Each ID corresponds to a payment. + If the counterparty has multiple payment methods available, use it to + specify the account to which you want to send the money. Otherwise, use None. + counterparty_card_ids : list[UUID | None] + The IDs of the counterparty cards. Each ID corresponds to a payment. + If the counterparty has multiple payment methods available, use it to + specify the card to which you want to send the money. Otherwise, use None. + amounts : list[Decimal] + The amounts of the payments. + currencies : list[str] + The ISO 4217 currency codes in upper case. + references : list[str] + The references for the payments. + title : str, optional + The title of the payment draft. + schedule_for : date | Date | str, optional + The scheduled date of the payment draft in ISO 8601 format. + + Returns + ------- + dict + A dict with the information about the payment draft created. + """ + assert ( + len(counterparty_ids) + == len(counterparty_account_ids) + == len(counterparty_card_ids) + == len(amounts) + == len(currencies) + == len(references) + ), ( + "The number of elements in the lists must be equal. " + f"Got {len(counterparty_ids)} counterparty_ids, " + f"{len(counterparty_account_ids)} counterparty_account_ids, " + f"{len(counterparty_card_ids)} counterparty_card_ids, " + f"{len(amounts)} amounts, " + f"{len(currencies)} currencies, " + f"and {len(references)} references." + ) + + endpoint = CreatePaymentDraft + path = endpoint.ROUTE + body = endpoint.Body( + title=title, + schedule_for=schedule_for, + payments=[ + endpoint.Body.ModelPayment( + account_id=account_id, + receiver=endpoint.Body.ModelPayment.ModelReceiver( + counterparty_id=counterparty_id, + account_id=counterparty_account_id, + card_id=counterparty_card_id, + ), + amount=amount, + currency=currency, + reference=reference, + ) + for counterparty_id, counterparty_account_id, counterparty_card_id, amount, currency, reference in zip( + counterparty_ids, + counterparty_account_ids, + counterparty_card_ids, + amounts, + currencies, + references, + ) + ], + ) + + response = await self.client.post( + path=path, + body=body, + **kwargs, + ) + + return endpoint.Response(**response.json()) + + async def delete_payment_draft( + self, + payment_draft_id: UUID, + **kwargs, + ): + """ + Delete a payment draft with the given ID. + You can delete a payment draft only if it isn't processed. + + Parameters + ---------- + payment_draft_id : UUID + The ID of the payment draft. + + Returns + ------- + dict + A dict with the information about the payment draft deleted. + """ + endpoint = DeletePaymentDraft + path = endpoint.ROUTE.format(payment_draft_id=payment_draft_id) + params = endpoint.Params() + + response = await self.client.delete( + path=path, + params=params, + **kwargs, + ) + + return endpoint.Response(**response.json()) diff --git a/pyrevolut/api/payment_drafts/endpoint.py b/pyrevolut/api/payment_drafts/endpoint/synchronous.py similarity index 50% rename from pyrevolut/api/payment_drafts/endpoint.py rename to pyrevolut/api/payment_drafts/endpoint/synchronous.py index d099a60..a64a34e 100644 --- a/pyrevolut/api/payment_drafts/endpoint.py +++ b/pyrevolut/api/payment_drafts/endpoint/synchronous.py @@ -3,14 +3,14 @@ from datetime import date from pyrevolut.utils import Date -from pyrevolut.api.common import BaseEndpoint +from pyrevolut.api.common import BaseEndpointSync -from .get import RetrieveAllPaymentDrafts, RetrievePaymentDraft -from .post import CreatePaymentDraft -from .delete import DeletePaymentDraft +from pyrevolut.api.payment_drafts.get import RetrieveAllPaymentDrafts, RetrievePaymentDraft +from pyrevolut.api.payment_drafts.post import CreatePaymentDraft +from pyrevolut.api.payment_drafts.delete import DeletePaymentDraft -class EndpointPaymentDrafts(BaseEndpoint): +class EndpointPaymentDraftsSync(BaseEndpointSync): """The Payment Drafts API Create a payment draft to request an approval for a payment from a @@ -208,191 +208,3 @@ def delete_payment_draft( ) return endpoint.Response(**response.json()) - - async def aget_all_payment_drafts( - self, - **kwargs, - ): - """ - Get a list of all the payment drafts that aren't processed. - - Parameters - ---------- - None - - Returns - ------- - dict - A dict with the information about the payment drafts. - """ - endpoint = RetrieveAllPaymentDrafts - path = endpoint.ROUTE - params = endpoint.Params() - - response = await self.client.aget( - path=path, - params=params, - **kwargs, - ) - - return endpoint.Response(**response.json()) - - async def aget_payment_draft( - self, - payment_draft_id: UUID, - **kwargs, - ): - """ - Get the information about a specific payment draft by ID. - - Parameters - ---------- - payment_draft_id : UUID - The ID of the payment draft. - - Returns - ------- - dict - A dict with the information about the payment draft. - """ - endpoint = RetrievePaymentDraft - path = endpoint.ROUTE.format(payment_draft_id=payment_draft_id) - params = endpoint.Params() - - response = await self.client.aget( - path=path, - params=params, - **kwargs, - ) - - return endpoint.Response(**response.json()) - - async def acreate_payment_draft( - self, - account_id: UUID, - counterparty_ids: list[UUID] = [], - counterparty_account_ids: list[UUID | None] = [], - counterparty_card_ids: list[UUID | None] = [], - amounts: list[Decimal] = [], - currencies: list[str] = [], - references: list[str] = [], - title: str | None = None, - schedule_for: date | Date | str | None = None, - **kwargs, - ): - """ - Create a payment draft. - - Parameters - ---------- - account_id : UUID - The ID of the account to pay from. - counterparty_ids : list[UUID] - The IDs of the counterparty accounts. Each ID corresponds to a payment. - counterparty_account_ids : list[UUID | None] - The IDs of the counterparty accounts. Each ID corresponds to a payment. - If the counterparty has multiple payment methods available, use it to - specify the account to which you want to send the money. Otherwise, use None. - counterparty_card_ids : list[UUID | None] - The IDs of the counterparty cards. Each ID corresponds to a payment. - If the counterparty has multiple payment methods available, use it to - specify the card to which you want to send the money. Otherwise, use None. - amounts : list[Decimal] - The amounts of the payments. - currencies : list[str] - The ISO 4217 currency codes in upper case. - references : list[str] - The references for the payments. - title : str, optional - The title of the payment draft. - schedule_for : date | Date | str, optional - The scheduled date of the payment draft in ISO 8601 format. - - Returns - ------- - dict - A dict with the information about the payment draft created. - """ - assert ( - len(counterparty_ids) - == len(counterparty_account_ids) - == len(counterparty_card_ids) - == len(amounts) - == len(currencies) - == len(references) - ), ( - "The number of elements in the lists must be equal. " - f"Got {len(counterparty_ids)} counterparty_ids, " - f"{len(counterparty_account_ids)} counterparty_account_ids, " - f"{len(counterparty_card_ids)} counterparty_card_ids, " - f"{len(amounts)} amounts, " - f"{len(currencies)} currencies, " - f"and {len(references)} references." - ) - - endpoint = CreatePaymentDraft - path = endpoint.ROUTE - body = endpoint.Body( - title=title, - schedule_for=schedule_for, - payments=[ - endpoint.Body.ModelPayment( - account_id=account_id, - receiver=endpoint.Body.ModelPayment.ModelReceiver( - counterparty_id=counterparty_id, - account_id=counterparty_account_id, - card_id=counterparty_card_id, - ), - amount=amount, - currency=currency, - reference=reference, - ) - for counterparty_id, counterparty_account_id, counterparty_card_id, amount, currency, reference in zip( - counterparty_ids, - counterparty_account_ids, - counterparty_card_ids, - amounts, - currencies, - references, - ) - ], - ) - - response = await self.client.apost( - path=path, - body=body, - **kwargs, - ) - - return endpoint.Response(**response.json()) - - async def adelete_payment_draft( - self, - payment_draft_id: UUID, - **kwargs, - ): - """ - Delete a payment draft with the given ID. - You can delete a payment draft only if it isn't processed. - - Parameters - ---------- - payment_draft_id : UUID - The ID of the payment draft. - - Returns - ------- - dict - A dict with the information about the payment draft deleted. - """ - endpoint = DeletePaymentDraft - path = endpoint.ROUTE.format(payment_draft_id=payment_draft_id) - params = endpoint.Params() - - response = await self.client.adelete( - path=path, - params=params, - **kwargs, - ) - - return endpoint.Response(**response.json()) diff --git a/pyrevolut/api/payout_links/__init__.py b/pyrevolut/api/payout_links/__init__.py index 4a8bcf5..ae8faa5 100644 --- a/pyrevolut/api/payout_links/__init__.py +++ b/pyrevolut/api/payout_links/__init__.py @@ -5,4 +5,4 @@ """ # flake8: noqa: F401 -from .endpoint import EndpointPayoutLinks +from .endpoint import EndpointPayoutLinksSync, EndpointPayoutLinksAsync diff --git a/pyrevolut/api/payout_links/endpoint/__init__.py b/pyrevolut/api/payout_links/endpoint/__init__.py new file mode 100644 index 0000000..13d5b84 --- /dev/null +++ b/pyrevolut/api/payout_links/endpoint/__init__.py @@ -0,0 +1,5 @@ +"""This module holds the payout links endpoints handlers.""" + +# flake8: noqa: F401 +from .synchronous import EndpointPayoutLinksSync +from .asynchronous import EndpointPayoutLinksAsync diff --git a/pyrevolut/api/payout_links/endpoint/asynchronous.py b/pyrevolut/api/payout_links/endpoint/asynchronous.py new file mode 100644 index 0000000..2f2ec7a --- /dev/null +++ b/pyrevolut/api/payout_links/endpoint/asynchronous.py @@ -0,0 +1,269 @@ +from uuid import UUID +from decimal import Decimal +from datetime import datetime + +from pydantic_extra_types.pendulum_dt import Duration +from pyrevolut.utils import DateTime +from pyrevolut.api.common import ( + BaseEndpointAsync, + EnumPayoutLinkState, + EnumPayoutLinkPaymentMethod, + EnumTransferReasonCode, +) + +from pyrevolut.api.payout_links.get import RetrieveListOfPayoutLinks, RetrievePayoutLink +from pyrevolut.api.payout_links.post import CreatePayoutLink, CancelPayoutLink + + +class EndpointPayoutLinksAsync(BaseEndpointAsync): + """The async Payout Links API + + Use payout links to send money without having to request full + banking details of the recipient. + The recipient must claim the money before the link expires. + """ + + async def get_all_payout_links( + self, + state: EnumPayoutLinkState | None = None, + created_before: datetime | DateTime | str | int | float | None = None, + limit: int | None = None, + **kwargs, + ): + """ + Get all the links that you have created, or use the query parameters to filter the results. + + The links are sorted by the created_at date in reverse chronological order. + + The returned links are paginated. The maximum number of payout links returned per + page is specified by the limit parameter. To get to the next page, make a + new request and use the created_at date of the last payout link returned in the previous response. + + Note + ---- + This feature is available in the UK and the EEA. + + Parameters + ---------- + state : EnumPayoutLinkState, optional + The state that the payout link is in. Possible states are: + + created: + The payout link has been created, but the amount has not yet been blocked. + failed: + The payout link couldn't be generated due to a failure during transaction booking. + awaiting: + The payout link is awaiting approval. + active: + The payout link can be redeemed. + expired: + The payout link cannot be redeemed because it wasn't claimed before its expiry date. + cancelled: + The payout link cannot be redeemed because it was cancelled. + processing: + The payout link has been redeemed and is being processed. + processed: + The payout link has been redeemed and the money has been transferred to the recipient. + created_before : datetime | DateTime | str | int | float, optional + Retrieves links with created_at < created_before. + The default value is the current date and time at which you are calling the endpoint. + + Provided in ISO 8601 format. + limit : int, optional + The maximum number of links returned per page. + To get to the next page, make a new request and use the + created_at date of the last payout link returned in the previous + response as the value for created_before. + + If not provided, the default value is 100. + + Returns + ------- + list[dict] + A list of payout links. + """ + endpoint = RetrieveListOfPayoutLinks + path = endpoint.ROUTE + params = endpoint.Params( + state=state, + created_before=created_before, + limit=limit, + ) + + response = await self.client.get( + path=path, + params=params, + **kwargs, + ) + + return [endpoint.Response(**resp).model_dump() for resp in response.json()] + + async def get_payout_link( + self, + payout_link_id: UUID, + **kwargs, + ): + """ + Get the information about a specific link by its ID. + + Note + ---- + This feature is available in the UK and the EEA. + + Parameters + ---------- + payout_link_id : UUID + The ID of the payout link. + + Returns + ------- + dict + The payout link information. + """ + endpoint = RetrievePayoutLink + path = endpoint.ROUTE.format(payout_link_id=payout_link_id) + params = endpoint.Params() + + response = await self.client.get( + path=path, + params=params, + **kwargs, + ) + + return endpoint.Response(**response.json()).model_dump() + + async def create_payout_link( + self, + counterparty_name: str, + request_id: str, + account_id: UUID, + amount: Decimal, + currency: str, + reference: str, + payout_methods: list[EnumPayoutLinkPaymentMethod], + save_counterparty: bool | None = None, + expiry_period: Duration | str | None = None, + transfer_reason_code: EnumTransferReasonCode | None = None, + **kwargs, + ): + """ + Create a payout link to send money even when you don't have the full + banking details of the counterparty. + After you have created the link, send it to the recipient so that + they can claim the payment. + + Note + ---- + This feature is available in the UK and the EEA. + + Parameters + ---------- + counterparty_name : str + The name of the counterparty provided by the sender. + request_id : str + The ID of the request, provided by the sender. + + To ensure that a link payment is not processed multiple times if there + are network or system errors, the same request_id should be used for + requests related to the same link. + account_id : UUID + The ID of the sender's account. + amount : Decimal + The amount of money to be sent. + currency : str + The currency of the amount to be sent. + reference : str + A reference for the payment. + payout_methods : list[EnumPayoutLinkPaymentMethod] + The payout methods that the recipient can use to claim the payment. + save_counterparty : bool, optional + Indicates whether to save the recipient as your counterparty upon link claim. + If false then the counterparty will not show up on your counterparties list, + for example, when you retrieve your counterparties. + However, you will still be able to retrieve this counterparty by its ID. + + If you don't choose to save the counterparty on link creation, you can do it later + from your transactions list in the Business app. + + If not provided, the default value is false. + expiry_period : Duration | str, optional + Possible values: >= P1D and <= P7D + + Default value: P7D + + The period after which the payout link expires if not claimed before, + provided in ISO 8601 format. + + The default and maximum value is 7 days from the link creation. + transfer_reason_code : EnumTransferReasonCode, optional + The reason code for the transaction. + Transactions to certain countries and currencies might require you to + provide a transfer reason. + You can check available reason codes with the getTransferReasons operation. + + If a transfer reason is not required for the given currency and country, + this field is ignored. + + Returns + ------- + dict + The payout link information. + """ + endpoint = CreatePayoutLink + path = endpoint.ROUTE + body = endpoint.Body( + counterparty_name=counterparty_name, + save_counterparty=save_counterparty, + request_id=request_id, + account_id=account_id, + amount=amount, + currency=currency, + reference=reference, + payout_methods=payout_methods, + expiry_period=expiry_period, + transfer_reasion_code=transfer_reason_code, + ) + + response = await self.client.post( + path=path, + body=body, + **kwargs, + ) + + return endpoint.Response(**response.json()).model_dump() + + async def cancel_payout_link( + self, + payout_link_id: UUID, + **kwargs, + ): + """ + Cancel a payout link. + You can only cancel a link that hasn't been claimed yet. + A successful request does not get any content in response. + + Note + ---- + This feature is available in the UK and the EEA. + + Parameters + ---------- + payout_link_id : UUID + The ID of the payout link. + + Returns + ------- + dict + An empty dictionary. + """ + endpoint = CancelPayoutLink + path = endpoint.ROUTE.format(payout_link_id=payout_link_id) + body = endpoint.Body() + + await self.client.post( + path=path, + body=body, + **kwargs, + ) + + return endpoint.Response().model_dump() diff --git a/pyrevolut/api/payout_links/endpoint.py b/pyrevolut/api/payout_links/endpoint/synchronous.py similarity index 50% rename from pyrevolut/api/payout_links/endpoint.py rename to pyrevolut/api/payout_links/endpoint/synchronous.py index 0a7d517..baf814b 100644 --- a/pyrevolut/api/payout_links/endpoint.py +++ b/pyrevolut/api/payout_links/endpoint/synchronous.py @@ -5,17 +5,17 @@ from pydantic_extra_types.pendulum_dt import Duration from pyrevolut.utils import DateTime from pyrevolut.api.common import ( - BaseEndpoint, + BaseEndpointSync, EnumPayoutLinkState, EnumPayoutLinkPaymentMethod, EnumTransferReasonCode, ) -from .get import RetrieveListOfPayoutLinks, RetrievePayoutLink -from .post import CreatePayoutLink, CancelPayoutLink +from pyrevolut.api.payout_links.get import RetrieveListOfPayoutLinks, RetrievePayoutLink +from pyrevolut.api.payout_links.post import CreatePayoutLink, CancelPayoutLink -class EndpointPayoutLinks(BaseEndpoint): +class EndpointPayoutLinksSync(BaseEndpointSync): """The Payout Links API Use payout links to send money without having to request full @@ -267,248 +267,3 @@ def cancel_payout_link( ) return endpoint.Response().model_dump() - - async def aget_all_payout_links( - self, - state: EnumPayoutLinkState | None = None, - created_before: datetime | DateTime | str | int | float | None = None, - limit: int | None = None, - **kwargs, - ): - """ - Get all the links that you have created, or use the query parameters to filter the results. - - The links are sorted by the created_at date in reverse chronological order. - - The returned links are paginated. The maximum number of payout links returned per - page is specified by the limit parameter. To get to the next page, make a - new request and use the created_at date of the last payout link returned in the previous response. - - Note - ---- - This feature is available in the UK and the EEA. - - Parameters - ---------- - state : EnumPayoutLinkState, optional - The state that the payout link is in. Possible states are: - - created: - The payout link has been created, but the amount has not yet been blocked. - failed: - The payout link couldn't be generated due to a failure during transaction booking. - awaiting: - The payout link is awaiting approval. - active: - The payout link can be redeemed. - expired: - The payout link cannot be redeemed because it wasn't claimed before its expiry date. - cancelled: - The payout link cannot be redeemed because it was cancelled. - processing: - The payout link has been redeemed and is being processed. - processed: - The payout link has been redeemed and the money has been transferred to the recipient. - created_before : datetime | DateTime | str | int | float, optional - Retrieves links with created_at < created_before. - The default value is the current date and time at which you are calling the endpoint. - - Provided in ISO 8601 format. - limit : int, optional - The maximum number of links returned per page. - To get to the next page, make a new request and use the - created_at date of the last payout link returned in the previous - response as the value for created_before. - - If not provided, the default value is 100. - - Returns - ------- - list[dict] - A list of payout links. - """ - endpoint = RetrieveListOfPayoutLinks - path = endpoint.ROUTE - params = endpoint.Params( - state=state, - created_before=created_before, - limit=limit, - ) - - response = await self.client.aget( - path=path, - params=params, - **kwargs, - ) - - return [endpoint.Response(**resp).model_dump() for resp in response.json()] - - async def aget_payout_link( - self, - payout_link_id: UUID, - **kwargs, - ): - """ - Get the information about a specific link by its ID. - - Note - ---- - This feature is available in the UK and the EEA. - - Parameters - ---------- - payout_link_id : UUID - The ID of the payout link. - - Returns - ------- - dict - The payout link information. - """ - endpoint = RetrievePayoutLink - path = endpoint.ROUTE.format(payout_link_id=payout_link_id) - params = endpoint.Params() - - response = await self.client.aget( - path=path, - params=params, - **kwargs, - ) - - return endpoint.Response(**response.json()).model_dump() - - async def acreate_payout_link( - self, - counterparty_name: str, - request_id: str, - account_id: UUID, - amount: Decimal, - currency: str, - reference: str, - payout_methods: list[EnumPayoutLinkPaymentMethod], - save_counterparty: bool | None = None, - expiry_period: Duration | str | None = None, - transfer_reason_code: EnumTransferReasonCode | None = None, - **kwargs, - ): - """ - Create a payout link to send money even when you don't have the full - banking details of the counterparty. - After you have created the link, send it to the recipient so that - they can claim the payment. - - Note - ---- - This feature is available in the UK and the EEA. - - Parameters - ---------- - counterparty_name : str - The name of the counterparty provided by the sender. - request_id : str - The ID of the request, provided by the sender. - - To ensure that a link payment is not processed multiple times if there - are network or system errors, the same request_id should be used for - requests related to the same link. - account_id : UUID - The ID of the sender's account. - amount : Decimal - The amount of money to be sent. - currency : str - The currency of the amount to be sent. - reference : str - A reference for the payment. - payout_methods : list[EnumPayoutLinkPaymentMethod] - The payout methods that the recipient can use to claim the payment. - save_counterparty : bool, optional - Indicates whether to save the recipient as your counterparty upon link claim. - If false then the counterparty will not show up on your counterparties list, - for example, when you retrieve your counterparties. - However, you will still be able to retrieve this counterparty by its ID. - - If you don't choose to save the counterparty on link creation, you can do it later - from your transactions list in the Business app. - - If not provided, the default value is false. - expiry_period : Duration | str, optional - Possible values: >= P1D and <= P7D - - Default value: P7D - - The period after which the payout link expires if not claimed before, - provided in ISO 8601 format. - - The default and maximum value is 7 days from the link creation. - transfer_reason_code : EnumTransferReasonCode, optional - The reason code for the transaction. - Transactions to certain countries and currencies might require you to - provide a transfer reason. - You can check available reason codes with the getTransferReasons operation. - - If a transfer reason is not required for the given currency and country, - this field is ignored. - - Returns - ------- - dict - The payout link information. - """ - endpoint = CreatePayoutLink - path = endpoint.ROUTE - body = endpoint.Body( - counterparty_name=counterparty_name, - save_counterparty=save_counterparty, - request_id=request_id, - account_id=account_id, - amount=amount, - currency=currency, - reference=reference, - payout_methods=payout_methods, - expiry_period=expiry_period, - transfer_reasion_code=transfer_reason_code, - ) - - response = await self.client.apost( - path=path, - body=body, - **kwargs, - ) - - return endpoint.Response(**response.json()).model_dump() - - async def acancel_payout_link( - self, - payout_link_id: UUID, - **kwargs, - ): - """ - Cancel a payout link. - You can only cancel a link that hasn't been claimed yet. - A successful request does not get any content in response. - - Note - ---- - This feature is available in the UK and the EEA. - - Parameters - ---------- - payout_link_id : UUID - The ID of the payout link. - - Returns - ------- - dict - An empty dictionary. - """ - endpoint = CancelPayoutLink - path = endpoint.ROUTE.format(payout_link_id=payout_link_id) - body = endpoint.Body() - - await self.client.apost( - path=path, - body=body, - **kwargs, - ) - - return endpoint.Response().model_dump() diff --git a/pyrevolut/api/simulations/__init__.py b/pyrevolut/api/simulations/__init__.py index 4f13315..d2da213 100644 --- a/pyrevolut/api/simulations/__init__.py +++ b/pyrevolut/api/simulations/__init__.py @@ -6,4 +6,4 @@ """ # flake8: noqa: F401 -from .endpoint import EndpointSimulations +from .endpoint import EndpointSimulationsSync, EndpointSimulationsAsync diff --git a/pyrevolut/api/simulations/endpoint/__init__.py b/pyrevolut/api/simulations/endpoint/__init__.py new file mode 100644 index 0000000..8a7e248 --- /dev/null +++ b/pyrevolut/api/simulations/endpoint/__init__.py @@ -0,0 +1,5 @@ +"""This module holds the simulations endpoints handlers.""" + +# flake8: noqa: F401 +from .synchronous import EndpointSimulationsSync +from .asynchronous import EndpointSimulationsAsync diff --git a/pyrevolut/api/simulations/endpoint/asynchronous.py b/pyrevolut/api/simulations/endpoint/asynchronous.py new file mode 100644 index 0000000..991702d --- /dev/null +++ b/pyrevolut/api/simulations/endpoint/asynchronous.py @@ -0,0 +1,135 @@ +from uuid import UUID +from decimal import Decimal + +from pyrevolut.api.common import ( + BaseEndpointAsync, + EnumTransactionState, + EnumSimulateTransferStateAction, +) + +from pyrevolut.api.simulations.post import SimulateAccountTopup, SimulateTransferStateUpdate + + +class EndpointSimulationsAsync(BaseEndpointAsync): + """The async Simulations API + + The Simulations API is only available in the Sandbox environment. + It lets you simulate certain events that are otherwise only possible in the production environment, + such as your account's top-up and transfer state changes. + """ + + async def simulate_account_topup( + self, + account_id: UUID, + amount: Decimal, + currency: str, + reference: str | None = None, + state: EnumTransactionState | None = None, + **kwargs, + ): + """ + Simulate a top-up of your account in the Sandbox environment. + + This is useful during testing, when you run out of money in your test account + and need to add more. + + Parameters + ---------- + account_id : UUID + The ID of the account that you want to top up. + amount : Decimal + The amount with which you want to top up the account. Must be <= 10000 + currency : str + The currency of the top-up amount. Must be a valid ISO 4217 currency code. + reference : str, optional + A short description for your top up. + Default value: 'Test Top-up' if not provided. + state : EnumTransactionState, optional + The state to which you want to set the top-up transaction. + + If not provided, the default value is 'completed'. + + Possible values: + + pending: + The transaction is pending until it's being processed. + If the transfer is made between Revolut accounts, + this state is skipped and the transaction is executed instantly. + completed: + The transaction was successful. + failed: + The transaction was unsuccessful. This can happen for a variety of reasons, + for example, invalid API calls, blocked payments, etc. + reverted: + The transaction was reverted. This can happen for a variety of reasons, + for example, the receiver being inaccessible. + + Returns + ------- + dict + The top-up transaction information. + """ + endpoint = SimulateAccountTopup + path = endpoint.ROUTE + body = endpoint.Body( + account_id=account_id, + amount=amount, + currency=currency, + reference=reference, + state=state, + ) + + response = await self.client.post( + path=path, + body=body, + **kwargs, + ) + + return endpoint.Response(**response.json()).model_dump() + + async def simulate_transfer_state_update( + self, + transfer_id: UUID, + action: EnumSimulateTransferStateAction, + **kwargs, + ): + """ + Simulate a transfer state change in the Sandbox environment. + + For example, after you make a transfer in Sandbox, you can change its + state to completed. + + The resulting state is final and cannot be changed. + + Parameters + ---------- + transfer_id : UUID + The ID of the transfer whose state you want to update. + action : EnumSimulateTransferStateAction + The action you want to perform on the transfer. Possible values: + + complete: + Simulate a completed transfer. + revert: + Simulate a reverted transfer. + decline: + Simulate a declined transfer. + fail: + Simulate a failed transfer. + + Returns + ------- + dict + The updated transfer information. + """ + endpoint = SimulateTransferStateUpdate + path = endpoint.ROUTE.format(transfer_id=transfer_id, action=action) + body = endpoint.Body() + + response = await self.client.post( + path=path, + body=body, + **kwargs, + ) + + return endpoint.Response(**response.json()).model_dump() diff --git a/pyrevolut/api/simulations/endpoint.py b/pyrevolut/api/simulations/endpoint/synchronous.py similarity index 51% rename from pyrevolut/api/simulations/endpoint.py rename to pyrevolut/api/simulations/endpoint/synchronous.py index 568d39c..89d7e6e 100644 --- a/pyrevolut/api/simulations/endpoint.py +++ b/pyrevolut/api/simulations/endpoint/synchronous.py @@ -2,15 +2,15 @@ from decimal import Decimal from pyrevolut.api.common import ( - BaseEndpoint, + BaseEndpointSync, EnumTransactionState, EnumSimulateTransferStateAction, ) -from .post import SimulateAccountTopup, SimulateTransferStateUpdate +from pyrevolut.api.simulations.post import SimulateAccountTopup, SimulateTransferStateUpdate -class EndpointSimulations(BaseEndpoint): +class EndpointSimulationsSync(BaseEndpointSync): """The Simulations API The Simulations API is only available in the Sandbox environment. @@ -133,119 +133,3 @@ def simulate_transfer_state_update( ) return endpoint.Response(**response.json()).model_dump() - - async def asimulate_account_topup( - self, - account_id: UUID, - amount: Decimal, - currency: str, - reference: str | None = None, - state: EnumTransactionState | None = None, - **kwargs, - ): - """ - Simulate a top-up of your account in the Sandbox environment. - - This is useful during testing, when you run out of money in your test account - and need to add more. - - Parameters - ---------- - account_id : UUID - The ID of the account that you want to top up. - amount : Decimal - The amount with which you want to top up the account. Must be <= 10000 - currency : str - The currency of the top-up amount. Must be a valid ISO 4217 currency code. - reference : str, optional - A short description for your top up. - Default value: 'Test Top-up' if not provided. - state : EnumTransactionState, optional - The state to which you want to set the top-up transaction. - - If not provided, the default value is 'completed'. - - Possible values: - - pending: - The transaction is pending until it's being processed. - If the transfer is made between Revolut accounts, - this state is skipped and the transaction is executed instantly. - completed: - The transaction was successful. - failed: - The transaction was unsuccessful. This can happen for a variety of reasons, - for example, invalid API calls, blocked payments, etc. - reverted: - The transaction was reverted. This can happen for a variety of reasons, - for example, the receiver being inaccessible. - - Returns - ------- - dict - The top-up transaction information. - """ - endpoint = SimulateAccountTopup - path = endpoint.ROUTE - body = endpoint.Body( - account_id=account_id, - amount=amount, - currency=currency, - reference=reference, - state=state, - ) - - response = await self.client.apost( - path=path, - body=body, - **kwargs, - ) - - return endpoint.Response(**response.json()).model_dump() - - async def asimulate_transfer_state_update( - self, - transfer_id: UUID, - action: EnumSimulateTransferStateAction, - **kwargs, - ): - """ - Simulate a transfer state change in the Sandbox environment. - - For example, after you make a transfer in Sandbox, you can change its - state to completed. - - The resulting state is final and cannot be changed. - - Parameters - ---------- - transfer_id : UUID - The ID of the transfer whose state you want to update. - action : EnumSimulateTransferStateAction - The action you want to perform on the transfer. Possible values: - - complete: - Simulate a completed transfer. - revert: - Simulate a reverted transfer. - decline: - Simulate a declined transfer. - fail: - Simulate a failed transfer. - - Returns - ------- - dict - The updated transfer information. - """ - endpoint = SimulateTransferStateUpdate - path = endpoint.ROUTE.format(transfer_id=transfer_id, action=action) - body = endpoint.Body() - - response = await self.client.apost( - path=path, - body=body, - **kwargs, - ) - - return endpoint.Response(**response.json()).model_dump() diff --git a/pyrevolut/api/team_members/__init__.py b/pyrevolut/api/team_members/__init__.py index a05d941..bf1a00d 100644 --- a/pyrevolut/api/team_members/__init__.py +++ b/pyrevolut/api/team_members/__init__.py @@ -8,4 +8,4 @@ """ # flake8: noqa: F401 -from .endpoint import EndpointTeamMembers +from .endpoint import EndpointTeamMembersSync, EndpointTeamMembersAsync diff --git a/pyrevolut/api/team_members/endpoint/__init__.py b/pyrevolut/api/team_members/endpoint/__init__.py new file mode 100644 index 0000000..0c66c3c --- /dev/null +++ b/pyrevolut/api/team_members/endpoint/__init__.py @@ -0,0 +1,5 @@ +"""This module holds the team members endpoints handlers.""" + +# flake8: noqa: F401 +from .synchronous import EndpointTeamMembersSync +from .asynchronous import EndpointTeamMembersAsync diff --git a/pyrevolut/api/team_members/endpoint/asynchronous.py b/pyrevolut/api/team_members/endpoint/asynchronous.py new file mode 100644 index 0000000..21e9199 --- /dev/null +++ b/pyrevolut/api/team_members/endpoint/asynchronous.py @@ -0,0 +1,165 @@ +from uuid import UUID +from datetime import datetime + +from pyrevolut.utils.datetime import DateTime +from pyrevolut.api.common import BaseEndpointAsync + +from pyrevolut.api.team_members.get import RetrieveListOfTeamMembers, RetrieveTeamRoles +from pyrevolut.api.team_members.post import InviteTeamMember + + +class EndpointTeamMembersAsync(BaseEndpointAsync): + """The async Team Members API + + Retrieve information on existing team members of your organisation and invite new members. + + This feature is available in the UK, US and the EEA. + + This feature is not available in Sandbox. + """ + + async def get_team_members( + self, + created_before: datetime | DateTime | str | int | float | None = None, + limit: int | None = None, + **kwargs, + ): + """ + Get information about all the team members of your business. + + The results are paginated and sorted by the created_at date in reverse chronological order. + + Note + ---- + This feature is available in the UK, US and the EEA. + + This feature is not available in Sandbox. + + Parameters + ---------- + created_before : datetime | DateTime | str | int | float | None + Retrieves team members with created_at < created_before. + The default value is the current date and time at which you are calling the endpoint. + Provided in ISO 8601 format. + limit : int | None + The maximum number of team members returned per page. + To get to the next page, make a new request and use the + created_at date of the last team member returned in the previous + response as the value for created_before. + + If not provided, the default value is 100. + + Returns + ------- + list + The list of all team members in your organisation. + """ + endpoint = RetrieveListOfTeamMembers + path = endpoint.ROUTE + params = endpoint.Params( + created_before=created_before, + limit=limit, + ) + + response = await self.client.get( + path=path, + params=params, + **kwargs, + ) + + return [endpoint.Response(**resp).model_dump() for resp in response.json()] + + async def get_team_roles( + self, + created_before: datetime | DateTime | str | int | float | None = None, + limit: int | None = None, + **kwargs, + ): + """ + Get the list of roles for your business. + + The results are paginated and sorted by the created_at date in reverse chronological order. + + This feature is available in the UK, US and the EEA. + + This feature is not available in Sandbox. + + Parameters + ---------- + created_before : datetime | DateTime | str | int | float | None + Retrieves team roles with created_at < created_before. + The default value is the current date and time at which you are calling the endpoint. + Provided in ISO 8601 format. + limit : int | None + The maximum number of team roles returned per page. + To get to the next page, make a new request and use the + created_at date of the last role returned in the previous + response as the value for created_before. + + If not provided, the default value is 100. + + Returns + ------- + list + The list of all team roles in your organisation. + """ + endpoint = RetrieveTeamRoles + path = endpoint.ROUTE + params = endpoint.Params( + created_before=created_before, + limit=limit, + ) + + response = await self.client.get( + path=path, + params=params, + **kwargs, + ) + + return [endpoint.Response(**resp).model_dump() for resp in response.json()] + + async def invite_team_member( + self, + email: str, + role_id: UUID | str, + **kwargs, + ): + """ + Invite a new member to your business account. + + When you invite a new team member to your business account, + an invitation is sent to their email address that you provided in this request. + To join your business account, the new team member has to accept this invitation. + + Note + ---- + This feature is available in the UK, US and the EEA. + + This feature is not available in Sandbox. + + Parameters + ---------- + email : str + The email address of the invited member. + role_id : UUID | str + The ID of the role to assign to the new member. + + Returns + ------- + dict + The response model. + """ + endpoint = InviteTeamMember + path = endpoint.ROUTE + body = endpoint.Body( + email=email, + role_id=role_id, + ) + + response = await self.client.post( + path=path, + body=body, + **kwargs, + ) + + return endpoint.Response(**response.json()).model_dump() diff --git a/pyrevolut/api/team_members/endpoint.py b/pyrevolut/api/team_members/endpoint/synchronous.py similarity index 50% rename from pyrevolut/api/team_members/endpoint.py rename to pyrevolut/api/team_members/endpoint/synchronous.py index ac4c9b8..9bc19b0 100644 --- a/pyrevolut/api/team_members/endpoint.py +++ b/pyrevolut/api/team_members/endpoint/synchronous.py @@ -2,13 +2,13 @@ from datetime import datetime from pyrevolut.utils.datetime import DateTime -from pyrevolut.api.common import BaseEndpoint +from pyrevolut.api.common import BaseEndpointSync -from .get import RetrieveListOfTeamMembers, RetrieveTeamRoles -from .post import InviteTeamMember +from pyrevolut.api.team_members.get import RetrieveListOfTeamMembers, RetrieveTeamRoles +from pyrevolut.api.team_members.post import InviteTeamMember -class EndpointTeamMembers(BaseEndpoint): +class EndpointTeamMembersSync(BaseEndpointSync): """The Team Members API Retrieve information on existing team members of your organisation and invite new members. @@ -163,149 +163,3 @@ def invite_team_member( ) return endpoint.Response(**response.json()).model_dump() - - async def aget_team_members( - self, - created_before: datetime | DateTime | str | int | float | None = None, - limit: int | None = None, - **kwargs, - ): - """ - Get information about all the team members of your business. - - The results are paginated and sorted by the created_at date in reverse chronological order. - - Note - ---- - This feature is available in the UK, US and the EEA. - - This feature is not available in Sandbox. - - Parameters - ---------- - created_before : datetime | DateTime | str | int | float | None - Retrieves team members with created_at < created_before. - The default value is the current date and time at which you are calling the endpoint. - Provided in ISO 8601 format. - limit : int | None - The maximum number of team members returned per page. - To get to the next page, make a new request and use the - created_at date of the last team member returned in the previous - response as the value for created_before. - - If not provided, the default value is 100. - - Returns - ------- - list - The list of all team members in your organisation. - """ - endpoint = RetrieveListOfTeamMembers - path = endpoint.ROUTE - params = endpoint.Params( - created_before=created_before, - limit=limit, - ) - - response = await self.client.aget( - path=path, - params=params, - **kwargs, - ) - - return [endpoint.Response(**resp).model_dump() for resp in response.json()] - - async def aget_team_roles( - self, - created_before: datetime | DateTime | str | int | float | None = None, - limit: int | None = None, - **kwargs, - ): - """ - Get the list of roles for your business. - - The results are paginated and sorted by the created_at date in reverse chronological order. - - This feature is available in the UK, US and the EEA. - - This feature is not available in Sandbox. - - Parameters - ---------- - created_before : datetime | DateTime | str | int | float | None - Retrieves team roles with created_at < created_before. - The default value is the current date and time at which you are calling the endpoint. - Provided in ISO 8601 format. - limit : int | None - The maximum number of team roles returned per page. - To get to the next page, make a new request and use the - created_at date of the last role returned in the previous - response as the value for created_before. - - If not provided, the default value is 100. - - Returns - ------- - list - The list of all team roles in your organisation. - """ - endpoint = RetrieveTeamRoles - path = endpoint.ROUTE - params = endpoint.Params( - created_before=created_before, - limit=limit, - ) - - response = await self.client.aget( - path=path, - params=params, - **kwargs, - ) - - return [endpoint.Response(**resp).model_dump() for resp in response.json()] - - async def ainvite_team_member( - self, - email: str, - role_id: UUID | str, - **kwargs, - ): - """ - Invite a new member to your business account. - - When you invite a new team member to your business account, - an invitation is sent to their email address that you provided in this request. - To join your business account, the new team member has to accept this invitation. - - Note - ---- - This feature is available in the UK, US and the EEA. - - This feature is not available in Sandbox. - - Parameters - ---------- - email : str - The email address of the invited member. - role_id : UUID | str - The ID of the role to assign to the new member. - - Returns - ------- - dict - The response model. - """ - endpoint = InviteTeamMember - path = endpoint.ROUTE - body = endpoint.Body( - email=email, - role_id=role_id, - ) - - response = await self.client.apost( - path=path, - body=body, - **kwargs, - ) - - return endpoint.Response(**response.json()).model_dump() diff --git a/pyrevolut/api/transactions/__init__.py b/pyrevolut/api/transactions/__init__.py index 15dccd0..7b9ee8c 100644 --- a/pyrevolut/api/transactions/__init__.py +++ b/pyrevolut/api/transactions/__init__.py @@ -8,4 +8,4 @@ """ # flake8: noqa: F401 -from .endpoint import EndpointTransactions +from .endpoint import EndpointTransactionsSync, EndpointTransactionsAsync diff --git a/pyrevolut/api/transactions/endpoint/__init__.py b/pyrevolut/api/transactions/endpoint/__init__.py new file mode 100644 index 0000000..c5578b9 --- /dev/null +++ b/pyrevolut/api/transactions/endpoint/__init__.py @@ -0,0 +1,5 @@ +"""This module holds the transactions endpoints handlers.""" + +# flake8: noqa: F401 +from .synchronous import EndpointTransactionsSync +from .asynchronous import EndpointTransactionsAsync diff --git a/pyrevolut/api/transactions/endpoint/asynchronous.py b/pyrevolut/api/transactions/endpoint/asynchronous.py new file mode 100644 index 0000000..90fbd86 --- /dev/null +++ b/pyrevolut/api/transactions/endpoint/asynchronous.py @@ -0,0 +1,154 @@ +from uuid import UUID +from datetime import datetime + +from pyrevolut.utils import DateTime +from pyrevolut.api.common import ( + BaseEndpointAsync, + EnumTransactionType, +) + +from pyrevolut.api.transactions.get import RetrieveListOfTransactions, RetrieveTransaction + + +class EndpointTransactionsAsync(BaseEndpointAsync): + """The async Transactions API + + Get the details of your transactions. + + Note + ---- + An incoming or outgoing payment is represented as a transaction. + """ + + async def get_all_transactions( + self, + from_datetime: datetime | DateTime | str | int | float | None = None, + to_datetime: datetime | DateTime | str | int | float | None = None, + account_id: UUID | None = None, + limit: int | None = None, + transaction_type: EnumTransactionType | None = None, + **kwargs, + ): + """ + Retrieve the historical transactions based on the provided query criteria. + + The transactions are sorted by the created_at date in reverse chronological order, + and they're paginated. The maximum number of transactions returned per page is specified by the + count parameter. To get the next page of results, make a new request and use the created_at date + from the last item of the previous page as the value for the to parameter. + + Note + ---- + The API returns a maximum of 1,000 transactions per request. + + Note + ---- + To be compliant with PSD2 SCA regulations, businesses on the Revolut Business Freelancer + plans can only access information older than 90 days within 5 minutes of the first authorisation. + + Parameters + ---------- + from_datetime : datetime | DateTime | str | int | float, optional + The date and time you retrieve the historical transactions from, including + this date-time. + Corresponds to the created_at value of the transaction. + Provided in ISO 8601 format. + + Used also for pagination. To get back to the previous page of results, + make a new request and use the created_at date from the first item of the + current page as the value for the from parameter. + to_datetime : datetime | DateTime | str | int | float, optional + The date and time you retrieve the historical transactions to, excluding + this date-time. + Corresponds to the created_at value of the transaction. + Provided in ISO 8601 format. + The default value is the date and time at which you're calling the endpoint. + + Used also for pagination. + To get the next page of results, make a new request and use the created_at + date from the last item of the previous (current) page as the value for the + to parameter. + account_id : UUID, optional + The ID of the account for which you want to retrieve the transactions. + limit : int, optional + The maximum number of transactions returned per page. + To get the next page of results, make a new request and use the created_at + date from the last item of the previous page as the value for the to parameter. + transaction_type : EnumTransactionType, optional + The type of the transaction. + + Returns + ------- + list[dict] + A list of transactions. + """ + endpoint = RetrieveListOfTransactions + path = endpoint.ROUTE + params = endpoint.Params( + from_=from_datetime, + to=to_datetime, + account=account_id, + count=limit, + type=transaction_type, + ) + + response = await self.client.get( + path=path, + params=params, + **kwargs, + ) + + return [endpoint.Response(**resp).model_dump() for resp in response.json()] + + async def get_transaction( + self, + transaction_id: UUID | None = None, + request_id: str | None = None, + **kwargs, + ): + """ + Retrieve the details of a specific transaction. + The details can include, for example, cardholder details for card payments. + + You can retrieve a transaction with its details either by its transaction ID + or by the request ID that was provided for this transaction at the time of its + creation, for example, when you created a payment. + + To retrieve a transaction by its transaction ID, use: + + /transaction/{transaction_id} + + To retrieve a transaction by a request ID provided at transaction creation, use: + + /transaction/{request_id}?id_type=request_id + + Parameters + ---------- + transaction_id : UUID, optional + The ID of the transaction. + Specify either transaction_id or request_id. + request_id : str, optional + The request ID of the transaction. + Specify either transaction_id or request_id. + + Returns + ------- + dict + The details of the transaction. + """ + assert transaction_id or request_id, "Either transaction_id or request_id must be provided." + assert not ( + transaction_id and request_id + ), "Either transaction_id or request_id must be provided, not both." + + endpoint = RetrieveTransaction + path = endpoint.ROUTE.format(id=transaction_id or request_id) + params = endpoint.Params(id_type="request_id" if request_id else None) + + response = await self.client.get( + path=path, + params=params, + **kwargs, + ) + + return endpoint.Response(**response.json()).model_dump() diff --git a/pyrevolut/api/transactions/endpoint.py b/pyrevolut/api/transactions/endpoint/synchronous.py similarity index 50% rename from pyrevolut/api/transactions/endpoint.py rename to pyrevolut/api/transactions/endpoint/synchronous.py index cfc4180..3f50cd6 100644 --- a/pyrevolut/api/transactions/endpoint.py +++ b/pyrevolut/api/transactions/endpoint/synchronous.py @@ -3,14 +3,14 @@ from pyrevolut.utils import DateTime from pyrevolut.api.common import ( - BaseEndpoint, + BaseEndpointSync, EnumTransactionType, ) -from .get import RetrieveListOfTransactions, RetrieveTransaction +from pyrevolut.api.transactions.get import RetrieveListOfTransactions, RetrieveTransaction -class EndpointTransactions(BaseEndpoint): +class EndpointTransactionsSync(BaseEndpointSync): """The Transactions API Get the details of your transactions. @@ -152,136 +152,3 @@ def get_transaction( ) return endpoint.Response(**response.json()).model_dump() - - async def aget_all_transactions( - self, - from_datetime: datetime | DateTime | str | int | float | None = None, - to_datetime: datetime | DateTime | str | int | float | None = None, - account_id: UUID | None = None, - limit: int | None = None, - transaction_type: EnumTransactionType | None = None, - **kwargs, - ): - """ - Retrieve the historical transactions based on the provided query criteria. - - The transactions are sorted by the created_at date in reverse chronological order, - and they're paginated. The maximum number of transactions returned per page is specified by the - count parameter. To get the next page of results, make a new request and use the created_at date - from the last item of the previous page as the value for the to parameter. - - Note - ---- - The API returns a maximum of 1,000 transactions per request. - - Note - ---- - To be compliant with PSD2 SCA regulations, businesses on the Revolut Business Freelancer - plans can only access information older than 90 days within 5 minutes of the first authorisation. - - Parameters - ---------- - from_datetime : datetime | DateTime | str | int | float, optional - The date and time you retrieve the historical transactions from, including - this date-time. - Corresponds to the created_at value of the transaction. - Provided in ISO 8601 format. - - Used also for pagination. To get back to the previous page of results, - make a new request and use the created_at date from the first item of the - current page as the value for the from parameter. - to_datetime : datetime | DateTime | str | int | float, optional - The date and time you retrieve the historical transactions to, excluding - this date-time. - Corresponds to the created_at value of the transaction. - Provided in ISO 8601 format. - The default value is the date and time at which you're calling the endpoint. - - Used also for pagination. - To get the next page of results, make a new request and use the created_at - date from the last item of the previous (current) page as the value for the - to parameter. - account_id : UUID, optional - The ID of the account for which you want to retrieve the transactions. - limit : int, optional - The maximum number of transactions returned per page. - To get the next page of results, make a new request and use the created_at - date from the last item of the previous page as the value for the to parameter. - transaction_type : EnumTransactionType, optional - The type of the transaction. - - Returns - ------- - list[dict] - A list of transactions. - """ - endpoint = RetrieveListOfTransactions - path = endpoint.ROUTE - params = endpoint.Params( - from_=from_datetime, - to=to_datetime, - account=account_id, - count=limit, - type=transaction_type, - ) - - response = await self.client.aget( - path=path, - params=params, - **kwargs, - ) - - return [endpoint.Response(**resp).model_dump() for resp in response.json()] - - async def aget_transaction( - self, - transaction_id: UUID | None = None, - request_id: str | None = None, - **kwargs, - ): - """ - Retrieve the details of a specific transaction. - The details can include, for example, cardholder details for card payments. - - You can retrieve a transaction with its details either by its transaction ID - or by the request ID that was provided for this transaction at the time of its - creation, for example, when you created a payment. - - To retrieve a transaction by its transaction ID, use: - - /transaction/{transaction_id} - - To retrieve a transaction by a request ID provided at transaction creation, use: - - /transaction/{request_id}?id_type=request_id - - Parameters - ---------- - transaction_id : UUID, optional - The ID of the transaction. - Specify either transaction_id or request_id. - request_id : str, optional - The request ID of the transaction. - Specify either transaction_id or request_id. - - Returns - ------- - dict - The details of the transaction. - """ - assert transaction_id or request_id, "Either transaction_id or request_id must be provided." - assert not ( - transaction_id and request_id - ), "Either transaction_id or request_id must be provided, not both." - - endpoint = RetrieveTransaction - path = endpoint.ROUTE.format(id=transaction_id or request_id) - params = endpoint.Params(id_type="request_id" if request_id else None) - - response = await self.client.aget( - path=path, - params=params, - **kwargs, - ) - - return endpoint.Response(**response.json()).model_dump() diff --git a/pyrevolut/api/transfers/__init__.py b/pyrevolut/api/transfers/__init__.py index 1923435..4c91913 100644 --- a/pyrevolut/api/transfers/__init__.py +++ b/pyrevolut/api/transfers/__init__.py @@ -5,4 +5,4 @@ """ # flake8: noqa: F401 -from .endpoint import EndpointTransfers +from .endpoint import EndpointTransfersSync, EndpointTransfersAsync diff --git a/pyrevolut/api/transfers/endpoint/__init__.py b/pyrevolut/api/transfers/endpoint/__init__.py new file mode 100644 index 0000000..e1ccdfb --- /dev/null +++ b/pyrevolut/api/transfers/endpoint/__init__.py @@ -0,0 +1,5 @@ +"""This module holds the transfers endpoints handlers.""" + +# flake8: noqa: F401 +from .synchronous import EndpointTransfersSync +from .asynchronous import EndpointTransfersAsync diff --git a/pyrevolut/api/transfers/endpoint/asynchronous.py b/pyrevolut/api/transfers/endpoint/asynchronous.py new file mode 100644 index 0000000..1fa3851 --- /dev/null +++ b/pyrevolut/api/transfers/endpoint/asynchronous.py @@ -0,0 +1,217 @@ +from uuid import UUID +from decimal import Decimal + +from pyrevolut.api.common import ( + BaseEndpointAsync, + EnumChargeBearer, + EnumTransferReasonCode, +) + +from pyrevolut.api.transfers.get import GetTransferReasons +from pyrevolut.api.transfers.post import CreateTransferToAnotherAccount, MoveMoneyBetweenAccounts + + +class EndpointTransfersAsync(BaseEndpointAsync): + """The async Transfers API + + Move funds in the same currency between accounts of your business, + or make payments to your counterparties. + """ + + async def get_transfer_reasons( + self, + **kwargs, + ): + """ + In order to initiate a transfer in certain currencies and countries, + you must provide a transfer reason. + With this endpoint you can retrieve all transfer reasons available to your business account + per country and currency. + + After you retrieve the results, use the appropriate reason code in the transfer_reason_code + field when making a transfer to a counterparty or creating a payout link. + + Parameters + ---------- + None + + Returns + ------- + list[dict] + A list of transfer reasons. + """ + endpoint = GetTransferReasons + path = endpoint.ROUTE + params = endpoint.Params() + + response = await self.client.get( + path=path, + params=params, + **kwargs, + ) + + return [endpoint.Response(**resp).model_dump() for resp in response.json()] + + async def create_transfer_to_another_account( + self, + request_id: str, + account_id: UUID, + counterparty_id: UUID, + amount: Decimal, + currency: str, + counterparty_account_id: UUID | None = None, + counterparty_card_id: UUID | None = None, + reference: str | None = None, + charge_bearer: EnumChargeBearer | None = None, + transfer_reason_code: EnumTransferReasonCode | None = None, + **kwargs, + ): + """ + Make a payment to a counterparty. + The resulting transaction has the type transfer. + + If you make the payment to another Revolut account, either business or personal, + the transaction is executed instantly. + + If the counterparty has multiple payment methods available, for example, 2 accounts, + or 1 account and 1 card, you must specify the account or card to which you want to + transfer the money (receiver.account_id or receiver.card_id respectively). + + Caution + ------- + Due to PSD2 Strong Customer Authentication regulations, this endpoint is + only available for customers on Revolut Business Company plans. If you're a + freelancer and wish to make payments via our API, we advise that you instead + leverage our Payment drafts (/payment-drafts) endpoint. + + Parameters + ---------- + request_id : str + The ID of the request, provided by you. + It helps you identify the transaction in your system. + To ensure that a transfer is not processed multiple times if + there are network or system errors, the same request_id should be used + for requests related to the same transfer. + account_id : UUID + The ID of the account that you transfer the funds from. + counterparty_id : UUID + The ID of the receiving counterparty. + amount : Decimal + The amount of money to transfer. + currency : str + The currency of the transfer. + counterparty_account_id : UUID, optional + The ID of the receiving counterparty's account, which can be own account. + Used for bank transfers. + If the counterparty has multiple payment methods available, use it to + specify the account to which you want to send the money. + counterparty_card_id : UUID, optional + The ID of the receiving counterparty's card. Used for card transfers. + If the counterparty has multiple payment methods available, use it to + specify the card to which you want to send the money. + reference : str, optional + A reference for the transfer. + charge_bearer : EnumChargeBearer, optional + The party to which any transaction fees are charged if the resulting + transaction route has associated fees. Some transactions with fees might + not be possible with the specified option, in which case error 3287 is returned. + + Possible values: + - shared: The transaction fees are shared between the sender and the receiver. + - debtor: The sender pays the transaction fees. + transfer_reason_code : EnumTransferReasonCode, optional + The reason code for the transaction. + Transactions to certain countries and currencies might require + you to provide a transfer reason. + You can check available reason codes with the getTransferReasons operation. + + If a transfer reason is not required for the given currency and country, + this field is ignored. + + Returns + ------- + dict + The details of the transfer. + """ + endpoint = CreateTransferToAnotherAccount + path = endpoint.ROUTE + body = endpoint.Body( + request_id=request_id, + account_id=account_id, + receiver=endpoint.Body.ModelReceiver( + counterparty_id=counterparty_id, + account_id=counterparty_account_id, + card_id=counterparty_card_id, + ), + amount=amount, + currency=currency, + reference=reference, + charge_bearer=charge_bearer, + transfer_reason_code=transfer_reason_code, + ) + + response = await self.client.post( + path=path, + body=body, + **kwargs, + ) + + return endpoint.Response(**response.json()).model_dump() + + async def move_money_between_accounts( + self, + request_id: str, + source_account_id: UUID, + target_account_id: UUID, + amount: Decimal, + currency: str, + reference: str | None = None, + **kwargs, + ): + """ + Move money between the Revolut accounts of the business in the same currency. + + The resulting transaction has the type transfer. + + Parameters + ---------- + request_id : str + The ID of the request, provided by you. + It helps you identify the transaction in your system. + To ensure that a transfer is not processed multiple times if + there are network or system errors, the same request_id should be used + for requests related to the same transfer. + source_account_id : UUID + The ID of the source account that you transfer the funds from. + target_account_id : UUID + The ID of the target account that you transfer the funds to. + amount : Decimal + The amount of the funds to be transferred. + currency : str + The ISO 4217 currency of the funds to be transferred. + reference : str, optional + The reference for the funds transfer. + + Returns + ------- + dict + The details of the transfer. + """ + endpoint = MoveMoneyBetweenAccounts + path = endpoint.ROUTE + body = endpoint.Body( + request_id=request_id, + source_account_id=source_account_id, + target_account_id=target_account_id, + amount=amount, + currency=currency, + reference=reference, + ) + + response = await self.client.post( + path=path, + body=body, + **kwargs, + ) + + return endpoint.Response(**response.json()).model_dump() diff --git a/pyrevolut/api/transfers/endpoint.py b/pyrevolut/api/transfers/endpoint/synchronous.py similarity index 50% rename from pyrevolut/api/transfers/endpoint.py rename to pyrevolut/api/transfers/endpoint/synchronous.py index abb068e..132c805 100644 --- a/pyrevolut/api/transfers/endpoint.py +++ b/pyrevolut/api/transfers/endpoint/synchronous.py @@ -2,16 +2,16 @@ from decimal import Decimal from pyrevolut.api.common import ( - BaseEndpoint, + BaseEndpointSync, EnumChargeBearer, EnumTransferReasonCode, ) -from .get import GetTransferReasons -from .post import CreateTransferToAnotherAccount, MoveMoneyBetweenAccounts +from pyrevolut.api.transfers.get import GetTransferReasons +from pyrevolut.api.transfers.post import CreateTransferToAnotherAccount, MoveMoneyBetweenAccounts -class EndpointTransfers(BaseEndpoint): +class EndpointTransfersSync(BaseEndpointSync): """The Transfers API Move funds in the same currency between accounts of your business, @@ -215,201 +215,3 @@ def move_money_between_accounts( ) return endpoint.Response(**response.json()).model_dump() - - async def aget_transfer_reasons( - self, - **kwargs, - ): - """ - In order to initiate a transfer in certain currencies and countries, - you must provide a transfer reason. - With this endpoint you can retrieve all transfer reasons available to your business account - per country and currency. - - After you retrieve the results, use the appropriate reason code in the transfer_reason_code - field when making a transfer to a counterparty or creating a payout link. - - Parameters - ---------- - None - - Returns - ------- - list[dict] - A list of transfer reasons. - """ - endpoint = GetTransferReasons - path = endpoint.ROUTE - params = endpoint.Params() - - response = await self.client.aget( - path=path, - params=params, - **kwargs, - ) - - return [endpoint.Response(**resp).model_dump() for resp in response.json()] - - async def acreate_transfer_to_another_account( - self, - request_id: str, - account_id: UUID, - counterparty_id: UUID, - amount: Decimal, - currency: str, - counterparty_account_id: UUID | None = None, - counterparty_card_id: UUID | None = None, - reference: str | None = None, - charge_bearer: EnumChargeBearer | None = None, - transfer_reason_code: EnumTransferReasonCode | None = None, - **kwargs, - ): - """ - Make a payment to a counterparty. - The resulting transaction has the type transfer. - - If you make the payment to another Revolut account, either business or personal, - the transaction is executed instantly. - - If the counterparty has multiple payment methods available, for example, 2 accounts, - or 1 account and 1 card, you must specify the account or card to which you want to - transfer the money (receiver.account_id or receiver.card_id respectively). - - Caution - ------- - Due to PSD2 Strong Customer Authentication regulations, this endpoint is - only available for customers on Revolut Business Company plans. If you're a - freelancer and wish to make payments via our API, we advise that you instead - leverage our Payment drafts (/payment-drafts) endpoint. - - Parameters - ---------- - request_id : str - The ID of the request, provided by you. - It helps you identify the transaction in your system. - To ensure that a transfer is not processed multiple times if - there are network or system errors, the same request_id should be used - for requests related to the same transfer. - account_id : UUID - The ID of the account that you transfer the funds from. - counterparty_id : UUID - The ID of the receiving counterparty. - amount : Decimal - The amount of money to transfer. - currency : str - The currency of the transfer. - counterparty_account_id : UUID, optional - The ID of the receiving counterparty's account, which can be own account. - Used for bank transfers. - If the counterparty has multiple payment methods available, use it to - specify the account to which you want to send the money. - counterparty_card_id : UUID, optional - The ID of the receiving counterparty's card. Used for card transfers. - If the counterparty has multiple payment methods available, use it to - specify the card to which you want to send the money. - reference : str, optional - A reference for the transfer. - charge_bearer : EnumChargeBearer, optional - The party to which any transaction fees are charged if the resulting - transaction route has associated fees. Some transactions with fees might - not be possible with the specified option, in which case error 3287 is returned. - - Possible values: - - shared: The transaction fees are shared between the sender and the receiver. - - debtor: The sender pays the transaction fees. - transfer_reason_code : EnumTransferReasonCode, optional - The reason code for the transaction. - Transactions to certain countries and currencies might require - you to provide a transfer reason. - You can check available reason codes with the getTransferReasons operation. - - If a transfer reason is not required for the given currency and country, - this field is ignored. - - Returns - ------- - dict - The details of the transfer. - """ - endpoint = CreateTransferToAnotherAccount - path = endpoint.ROUTE - body = endpoint.Body( - request_id=request_id, - account_id=account_id, - receiver=endpoint.Body.ModelReceiver( - counterparty_id=counterparty_id, - account_id=counterparty_account_id, - card_id=counterparty_card_id, - ), - amount=amount, - currency=currency, - reference=reference, - charge_bearer=charge_bearer, - transfer_reason_code=transfer_reason_code, - ) - - response = await self.client.apost( - path=path, - body=body, - **kwargs, - ) - - return endpoint.Response(**response.json()).model_dump() - - async def amove_money_between_accounts( - self, - request_id: str, - source_account_id: UUID, - target_account_id: UUID, - amount: Decimal, - currency: str, - reference: str | None = None, - **kwargs, - ): - """ - Move money between the Revolut accounts of the business in the same currency. - - The resulting transaction has the type transfer. - - Parameters - ---------- - request_id : str - The ID of the request, provided by you. - It helps you identify the transaction in your system. - To ensure that a transfer is not processed multiple times if - there are network or system errors, the same request_id should be used - for requests related to the same transfer. - source_account_id : UUID - The ID of the source account that you transfer the funds from. - target_account_id : UUID - The ID of the target account that you transfer the funds to. - amount : Decimal - The amount of the funds to be transferred. - currency : str - The ISO 4217 currency of the funds to be transferred. - reference : str, optional - The reference for the funds transfer. - - Returns - ------- - dict - The details of the transfer. - """ - endpoint = MoveMoneyBetweenAccounts - path = endpoint.ROUTE - body = endpoint.Body( - request_id=request_id, - source_account_id=source_account_id, - target_account_id=target_account_id, - amount=amount, - currency=currency, - reference=reference, - ) - - response = await self.client.apost( - path=path, - body=body, - **kwargs, - ) - - return endpoint.Response(**response.json()).model_dump() diff --git a/pyrevolut/api/webhooks/__init__.py b/pyrevolut/api/webhooks/__init__.py index 5a11b1e..d64c8ec 100644 --- a/pyrevolut/api/webhooks/__init__.py +++ b/pyrevolut/api/webhooks/__init__.py @@ -17,4 +17,4 @@ """ # flake8: noqa: F401 -from .endpoint import EndpointWebhooks +from .endpoint import EndpointWebhooksSync, EndpointWebhooksAsync diff --git a/pyrevolut/api/webhooks/endpoint/__init__.py b/pyrevolut/api/webhooks/endpoint/__init__.py new file mode 100644 index 0000000..aa8d12c --- /dev/null +++ b/pyrevolut/api/webhooks/endpoint/__init__.py @@ -0,0 +1,5 @@ +"""This module holds the webhooks endpoints handlers.""" + +# flake8: noqa: F401 +from .synchronous import EndpointWebhooksSync +from .asynchronous import EndpointWebhooksAsync diff --git a/pyrevolut/api/webhooks/endpoint/asynchronous.py b/pyrevolut/api/webhooks/endpoint/asynchronous.py new file mode 100644 index 0000000..a4ff1ac --- /dev/null +++ b/pyrevolut/api/webhooks/endpoint/asynchronous.py @@ -0,0 +1,297 @@ +from uuid import UUID +from datetime import datetime + +from pydantic_extra_types.pendulum_dt import Duration + +from pyrevolut.utils import DateTime +from pyrevolut.api.common import ( + BaseEndpointAsync, + EnumWebhookEvent, +) + +from pyrevolut.api.webhooks.get import ( + RetrieveListOfWebhooks, + RetrieveWebhook, + RetrieveListOfFailedWebhooks, +) +from pyrevolut.api.webhooks.post import CreateWebhook, RotateWebhookSecret +from pyrevolut.api.webhooks.patch import UpdateWebhook +from pyrevolut.api.webhooks.delete import DeleteWebhook + + +class EndpointWebhooksAsync(BaseEndpointAsync): + """The async Webhooks API + + A webhook (also called a web callback) allows your system to receive + updates about your account to an HTTPS endpoint that you provide. + When a supported event occurs, a notification is posted via HTTP POST method + to the specified endpoint. + + If the receiver returns an HTTP error response, Revolut will retry the webhook + event three more times, each with a 10-minute interval. + + The following events are supported: + + TransactionCreated + TransactionStateChanged + PayoutLinkCreated + PayoutLinkStateChanged + """ + + async def get_all_webhooks( + self, + **kwargs, + ): + """ + Get the list of all your existing webhooks and their details. + + Parameters + ---------- + None + + Returns + ------- + list + The list of all your existing webhooks and their details. + """ + endpoint = RetrieveListOfWebhooks + path = endpoint.ROUTE + params = endpoint.Params() + + response = await self.client.get( + path=path, + params=params, + **kwargs, + ) + + return [endpoint.Response(**resp).model_dump() for resp in response.json()] + + async def get_webhook( + self, + webhook_id: UUID, + **kwargs, + ): + """ + Get the information about a specific webhook by ID. + """ + endpoint = RetrieveWebhook + path = endpoint.ROUTE.format(webhook_id=webhook_id) + params = endpoint.Params() + + response = await self.client.get( + path=path, + params=params, + **kwargs, + ) + + return endpoint.Response(**response.json()).model_dump() + + async def get_failed_webhook_events( + self, + webhook_id: UUID, + limit: int | None = None, + created_before: datetime | DateTime | str | int | float | None = None, + **kwargs, + ): + """ + Get the list of all your failed webhook events, or use the query + parameters to filter the results. + + The events are sorted by the created_at date in reverse chronological order. + + The returned failed events are paginated. The maximum number of events returned + per page is specified by the limit parameter. + To get to the next page, make a new request and use the created_at date of the + last event returned in the previous response. + + Parameters + ---------- + webhook_id : UUID + The ID of the webhook. + limit : int, optional + The maximum number of events returned per page. + To get to the next page, make a new request and use the created_at date of + the last event returned in the previous response as value for created_before. + If not specified, the default value is 100. + created_before : datetime | DateTime | str | int | float, optional + Retrieves events with created_at < created_before. + Cannot be older than the current date minus 21 days. + The default value is the current date and time at which you are calling the endpoint. + Provided in ISO 8601 format. + + Returns + ------- + list + The list of all your failed webhook events. + """ + endpoint = RetrieveListOfFailedWebhooks + path = endpoint.ROUTE.format(webhook_id=webhook_id) + params = endpoint.Params( + limit=limit, + created_before=created_before, + ) + + response = await self.client.get( + path=path, + params=params, + **kwargs, + ) + + return [endpoint.Response(**resp).model_dump() for resp in response.json()] + + async def create_webhook( + self, + url: str, + events: list[EnumWebhookEvent] | None = None, + **kwargs, + ): + """ + Create a new webhook to receive event notifications to the specified URL. + Provide a list of event types that you want to subscribe to and a URL for the webhook. + Only HTTPS URLs are supported. + + Parameters + ---------- + url : str + A valid webhook URL to which to send event notifications. + The supported protocol is https. + events : list[EnumWebhookEvent], optional + A list of event types to subscribe to. + If you don't provide it, you're automatically subscribed to the default event types: + - TransactionCreated + - TransactionStateChanged + + Returns + ------- + dict + The response model for the request. + """ + endpoint = CreateWebhook + path = endpoint.ROUTE + body = endpoint.Body( + url=url, + events=events, + ) + + response = await self.client.post( + path=path, + body=body, + **kwargs, + ) + + return endpoint.Response(**response.json()).model_dump() + + async def rotate_webhook_secret( + self, + webhook_id: UUID, + expiration_period: Duration | None = None, + **kwargs, + ): + """ + Rotate a signing secret for a specific webhook. + + Parameters + ---------- + webhook_id : UUID + The ID of the webhook. + expiration_period : Duration, optional + The expiration period for the signing secret in ISO 8601 format. + If set, when you rotate the secret, it continues to be valid until the + expiration period has passed. + Otherwise, on rotation, the secret is invalidated immediately. + The maximum value is 7 days. + + Returns + ------- + dict + The response model for the request. + """ + endpoint = RotateWebhookSecret + path = endpoint.ROUTE.format(webhook_id=webhook_id) + body = endpoint.Body( + expiration_period=expiration_period, + ) + + response = await self.client.post( + path=path, + body=body, + **kwargs, + ) + + return endpoint.Response(**response.json()).model_dump() + + async def update_webhook( + self, + webhook_id: UUID, + url: str | None = None, + events: list[EnumWebhookEvent] | None = None, + **kwargs, + ): + """ + Update an existing webhook. Change the URL to which event notifications are + sent or the list of event types to be notified about. + + You must specify at least one of these two. + The fields that you don't specify are not updated. + + Parameters + ---------- + webhook_id : UUID + The ID of the webhook. + url : str, optional + A valid webhook URL to which to send event notifications. + The supported protocol is https. + events : list[EnumWebhookEvent], optional + A list of event types to subscribe to. + + Returns + ------- + dict + The response model for the request. + """ + endpoint = UpdateWebhook + path = endpoint.ROUTE.format(webhook_id=webhook_id) + body = endpoint.Body( + url=url, + events=events, + ) + + response = await self.client.patch( + path=path, + body=body, + **kwargs, + ) + + return endpoint.Response(**response.json()).model_dump() + + async def delete_webhook( + self, + webhook_id: UUID, + **kwargs, + ): + """ + Delete a specific webhook. + + A successful response does not get any content in return. + + Parameters + ---------- + webhook_id : UUID + The ID of the webhook. + + Returns + ------- + dict + An empty dictionary. + """ + endpoint = DeleteWebhook + path = endpoint.ROUTE.format(webhook_id=webhook_id) + params = endpoint.Params() + + await self.client.delete( + path=path, + params=params, + **kwargs, + ) + + return endpoint.Response().model_dump() diff --git a/pyrevolut/api/webhooks/endpoint.py b/pyrevolut/api/webhooks/endpoint/synchronous.py similarity index 51% rename from pyrevolut/api/webhooks/endpoint.py rename to pyrevolut/api/webhooks/endpoint/synchronous.py index d87776f..42072cf 100644 --- a/pyrevolut/api/webhooks/endpoint.py +++ b/pyrevolut/api/webhooks/endpoint/synchronous.py @@ -5,17 +5,21 @@ from pyrevolut.utils import DateTime from pyrevolut.api.common import ( - BaseEndpoint, + BaseEndpointSync, EnumWebhookEvent, ) -from .get import RetrieveListOfWebhooks, RetrieveWebhook, RetrieveListOfFailedWebhooks -from .post import CreateWebhook, RotateWebhookSecret -from .patch import UpdateWebhook -from .delete import DeleteWebhook +from pyrevolut.api.webhooks.get import ( + RetrieveListOfWebhooks, + RetrieveWebhook, + RetrieveListOfFailedWebhooks, +) +from pyrevolut.api.webhooks.post import CreateWebhook, RotateWebhookSecret +from pyrevolut.api.webhooks.patch import UpdateWebhook +from pyrevolut.api.webhooks.delete import DeleteWebhook -class EndpointWebhooks(BaseEndpoint): +class EndpointWebhooksSync(BaseEndpointSync): """The Webhooks API A webhook (also called a web callback) allows your system to receive @@ -291,261 +295,3 @@ def delete_webhook( ) return endpoint.Response().model_dump() - - async def aget_all_webhooks( - self, - **kwargs, - ): - """ - Get the list of all your existing webhooks and their details. - - Parameters - ---------- - None - - Returns - ------- - list - The list of all your existing webhooks and their details. - """ - endpoint = RetrieveListOfWebhooks - path = endpoint.ROUTE - params = endpoint.Params() - - response = await self.client.aget( - path=path, - params=params, - **kwargs, - ) - - return [endpoint.Response(**resp).model_dump() for resp in response.json()] - - async def aget_webhook( - self, - webhook_id: UUID, - **kwargs, - ): - """ - Get the information about a specific webhook by ID. - """ - endpoint = RetrieveWebhook - path = endpoint.ROUTE.format(webhook_id=webhook_id) - params = endpoint.Params() - - response = await self.client.aget( - path=path, - params=params, - **kwargs, - ) - - return endpoint.Response(**response.json()).model_dump() - - async def aget_failed_webhook_events( - self, - webhook_id: UUID, - limit: int | None = None, - created_before: datetime | DateTime | str | int | float | None = None, - **kwargs, - ): - """ - Get the list of all your failed webhook events, or use the query - parameters to filter the results. - - The events are sorted by the created_at date in reverse chronological order. - - The returned failed events are paginated. The maximum number of events returned - per page is specified by the limit parameter. - To get to the next page, make a new request and use the created_at date of the - last event returned in the previous response. - - Parameters - ---------- - webhook_id : UUID - The ID of the webhook. - limit : int, optional - The maximum number of events returned per page. - To get to the next page, make a new request and use the created_at date of - the last event returned in the previous response as value for created_before. - If not specified, the default value is 100. - created_before : datetime | DateTime | str | int | float, optional - Retrieves events with created_at < created_before. - Cannot be older than the current date minus 21 days. - The default value is the current date and time at which you are calling the endpoint. - Provided in ISO 8601 format. - - Returns - ------- - list - The list of all your failed webhook events. - """ - endpoint = RetrieveListOfFailedWebhooks - path = endpoint.ROUTE.format(webhook_id=webhook_id) - params = endpoint.Params( - limit=limit, - created_before=created_before, - ) - - response = await self.client.aget( - path=path, - params=params, - **kwargs, - ) - - return [endpoint.Response(**resp).model_dump() for resp in response.json()] - - async def acreate_webhook( - self, - url: str, - events: list[EnumWebhookEvent] | None = None, - **kwargs, - ): - """ - Create a new webhook to receive event notifications to the specified URL. - Provide a list of event types that you want to subscribe to and a URL for the webhook. - Only HTTPS URLs are supported. - - Parameters - ---------- - url : str - A valid webhook URL to which to send event notifications. - The supported protocol is https. - events : list[EnumWebhookEvent], optional - A list of event types to subscribe to. - If you don't provide it, you're automatically subscribed to the default event types: - - TransactionCreated - - TransactionStateChanged - - Returns - ------- - dict - The response model for the request. - """ - endpoint = CreateWebhook - path = endpoint.ROUTE - body = endpoint.Body( - url=url, - events=events, - ) - - response = await self.client.apost( - path=path, - body=body, - **kwargs, - ) - - return endpoint.Response(**response.json()).model_dump() - - async def arotate_webhook_secret( - self, - webhook_id: UUID, - expiration_period: Duration | None = None, - **kwargs, - ): - """ - Rotate a signing secret for a specific webhook. - - Parameters - ---------- - webhook_id : UUID - The ID of the webhook. - expiration_period : Duration, optional - The expiration period for the signing secret in ISO 8601 format. - If set, when you rotate the secret, it continues to be valid until the - expiration period has passed. - Otherwise, on rotation, the secret is invalidated immediately. - The maximum value is 7 days. - - Returns - ------- - dict - The response model for the request. - """ - endpoint = RotateWebhookSecret - path = endpoint.ROUTE.format(webhook_id=webhook_id) - body = endpoint.Body( - expiration_period=expiration_period, - ) - - response = await self.client.apost( - path=path, - body=body, - **kwargs, - ) - - return endpoint.Response(**response.json()).model_dump() - - async def aupdate_webhook( - self, - webhook_id: UUID, - url: str | None = None, - events: list[EnumWebhookEvent] | None = None, - **kwargs, - ): - """ - Update an existing webhook. Change the URL to which event notifications are - sent or the list of event types to be notified about. - - You must specify at least one of these two. - The fields that you don't specify are not updated. - - Parameters - ---------- - webhook_id : UUID - The ID of the webhook. - url : str, optional - A valid webhook URL to which to send event notifications. - The supported protocol is https. - events : list[EnumWebhookEvent], optional - A list of event types to subscribe to. - - Returns - ------- - dict - The response model for the request. - """ - endpoint = UpdateWebhook - path = endpoint.ROUTE.format(webhook_id=webhook_id) - body = endpoint.Body( - url=url, - events=events, - ) - - response = await self.client.apatch( - path=path, - body=body, - **kwargs, - ) - - return endpoint.Response(**response.json()).model_dump() - - async def adelete_webhook( - self, - webhook_id: UUID, - **kwargs, - ): - """ - Delete a specific webhook. - - A successful response does not get any content in return. - - Parameters - ---------- - webhook_id : UUID - The ID of the webhook. - - Returns - ------- - dict - An empty dictionary. - """ - endpoint = DeleteWebhook - path = endpoint.ROUTE.format(webhook_id=webhook_id) - params = endpoint.Params() - - await self.client.adelete( - path=path, - params=params, - **kwargs, - ) - - return endpoint.Response().model_dump() diff --git a/pyrevolut/cli/__init__.py b/pyrevolut/cli/__init__.py new file mode 100644 index 0000000..e68fb26 --- /dev/null +++ b/pyrevolut/cli/__init__.py @@ -0,0 +1 @@ +"""This module contains all CLI commands for the pyrevolut package.""" diff --git a/pyrevolut/cli/__main__.py b/pyrevolut/cli/__main__.py new file mode 100644 index 0000000..a667c9b --- /dev/null +++ b/pyrevolut/cli/__main__.py @@ -0,0 +1,3 @@ +from .main import app + +app(prog_name="pyrevolut") diff --git a/pyrevolut/cli/main.py b/pyrevolut/cli/main.py new file mode 100644 index 0000000..df5377d --- /dev/null +++ b/pyrevolut/cli/main.py @@ -0,0 +1,84 @@ +import os +from typing import Annotated + +import typer + +from pydantic import BaseModel +from pyrevolut.utils.auth import EnumAuthScope, auth_manual_flow + +app = typer.Typer() + + +class AuthManualParams(BaseModel): + """Pydantic model for the auth_manual CLI command.""" + + credentials_json: str + sandbox: bool = True + scopes: list[EnumAuthScope] | None = None + + +@app.callback() +def callback(): + """ + pyrevolut CLI tool to interact with the Revolut Business API. Primarily + used for authentication and authorization. + """ + + +@app.command(name="auth-manual") +def auth_manual( + credentials_json: str = "credentials/creds.json", + sandbox: bool = True, + scopes: Annotated[list[str], typer.Option()] = None, +): + """ + Method to run the manual authorization flow to get the client assertion JWT and the access and refresh tokens. + + Parameters + ---------- + credentials_json : str, optional + The location to save the credentials JSON file. + Will overwrite the file if it already exists. + Default is "credentials/creds.json". + sandbox : bool, optional + Whether to use the sandbox environment. + Default is True. + scopes : list[str] | None, optional + The list of scopes to request. If not provided, the default scopes will be used. + + Access tokens can be issued with four security scopes and require a JWT (JSON Web Token) + signature to be obtained: + + READ: Permissions for GET operations. + + WRITE: Permissions to update counterparties, webhooks, and issue payment drafts. + + PAY: Permissions to initiate or cancel transactions and currency exchanges. + + READ_SENSITIVE_CARD_DATA: Permissions to retrieve sensitive card details. + + Caution + ------- + If you enable the READ_SENSITIVE_CARD_DATA scope for your access token, you must + set up IP whitelisting. + Failing to do so will prevent you from accessing any Business API endpoint. + + Default is None. + + Returns + ------- + None + """ + params = AuthManualParams( + credentials_json=credentials_json, + sandbox=sandbox, + scopes=scopes, + ) + + # Create the directories if they do not exist + os.makedirs(os.path.dirname(params.credentials_json), exist_ok=True) + + # Run the manual authorization flow + auth_manual_flow( + credentials_json=params.credentials_json, sandbox=params.sandbox, scopes=params.scopes + ) diff --git a/pyrevolut/client.py b/pyrevolut/client.py deleted file mode 100644 index 221e8df..0000000 --- a/pyrevolut/client.py +++ /dev/null @@ -1,645 +0,0 @@ -from typing import Type, TypeVar -from enum import StrEnum -import logging - -from pydantic import BaseModel - -from httpx import AsyncClient -from httpx import Client as SyncClient -from httpx import HTTPStatusError, Response - -from .api import ( - EndpointAccounts, - EndpointCards, - EndpointCounterparties, - EndpointForeignExchange, - EndpointPaymentDrafts, - EndpointPayoutLinks, - EndpointSimulations, - EndpointTeamMembers, - EndpointTransactions, - EndpointTransfers, - EndpointWebhooks, -) - - -D = TypeVar("D", dict, list) # TypeVar for dictionary or list - - -class Environment(StrEnum): - SANDBOX = "sandbox" - LIVE = "live" - - -class Client: - access_token: str - refresh_token: str - environment: Environment - domain: str - async_client: AsyncClient | None = None - sync_client: SyncClient | None = None - Accounts: EndpointAccounts | None = None - Cards: EndpointCards | None = None - Counterparties: EndpointCounterparties | None = None - ForeignExchange: EndpointForeignExchange | None = None - PaymentDrafts: EndpointPaymentDrafts | None = None - PayoutLinks: EndpointPayoutLinks | None = None - Simulations: EndpointSimulations | None = None - TeamMembers: EndpointTeamMembers | None = None - Transactions: EndpointTransactions | None = None - Transfers: EndpointTransfers | None = None - Webhooks: EndpointWebhooks | None = None - - def __init__( - self, - access_token: str, - refresh_token: str, - environment: Environment = Environment.SANDBOX, - ): - """Create a new Revolut client - - Parameters - ---------- - access_token : str - The access token to use - refresh_token : str - The refresh token to use - environment : Environment - The environment to use, either Environment.SANDBOX or Environment.LIVE - """ - self.access_token = access_token - self.refresh_token = refresh_token - self.environment = environment - - # Set domain based on environment - if self.environment == Environment.SANDBOX: - self.domain = "https://sandbox-b2b.revolut.com/api/1.0/" - else: - self.domain = "https://b2b.revolut.com/api/1.0/" - - def open(self): - """Opens the client connection""" - if self.sync_client is not None: - return - - self.sync_client = SyncClient() - self.__load_resources() - - def close(self): - """Closes the client connection""" - if self.sync_client is None: - return - - self.sync_client.close() - self.sync_client = None - - async def aopen(self): - """Opens the async client connection""" - if self.async_client is not None: - return - - self.async_client = AsyncClient() - self.__load_resources() - - async def aclose(self): - """Closes the async client connection""" - if self.async_client is None: - return - - await self.async_client.aclose() - self.async_client = None - - def get(self, path: str, params: Type[BaseModel] | None = None, **kwargs): - """Send a GET request to the Revolut API - - Parameters - ---------- - path : str - The path to send the request to - params : Type[BaseModel] | None - The parameters to add to the request route - - Returns - ------- - Response - The response from the request - """ - self.__check_sync_client() - url = f"{self.domain}/{path}" - headers = self.__create_headers(kwargs.pop("headers", {})) - params = ( - self.__replace_null_with_none( - data=params.model_dump(mode="json", exclude_none=True, by_alias=True) - ) - if params is not None - else None - ) - resp = self.sync_client.get( - url=url, - params=params, - headers=headers, - **kwargs, - ) - self.log_response(response=resp) - return resp - - def post(self, path: str, body: Type[BaseModel] | None = None, **kwargs): - """Send a POST request to the Revolut API - - Parameters - ---------- - path : str - The path to send the request to - body : Type[BaseModel] | None - The body to send in the request - - Returns - ------- - Response - The response from the request - """ - self.__check_sync_client() - url = f"{self.domain}/{path}" - headers = self.__create_headers(kwargs.pop("headers", {})) - json = ( - self.__replace_null_with_none( - data=body.model_dump(mode="json", exclude_none=True, by_alias=True) - ) - if body is not None - else None - ) - resp = self.sync_client.post( - url=url, - json=json, - headers=headers, - **kwargs, - ) - self.log_response(response=resp) - return resp - - def patch(self, path: str, body: Type[BaseModel] | None = None, **kwargs): - """Send a PATCH request to the Revolut API - - Parameters - ---------- - path : str - The path to send the request to - body : Type[BaseModel] - The body to send in the request - - Returns - ------- - Response - The response from the request - """ - self.__check_sync_client() - path = self.__process_path(path) - url = f"{self.domain}/{path}" - headers = self.__create_headers(kwargs.pop("headers", {})) - json = ( - self.__replace_null_with_none( - data=body.model_dump(mode="json", exclude_none=True, by_alias=True) - ) - if body is not None - else None - ) - resp = self.sync_client.patch( - url=url, - json=json, - headers=headers, - **kwargs, - ) - self.log_response(response=resp) - return resp - - def delete( - self, - path: str, - params: Type[BaseModel] | None = None, - **kwargs, - ): - """Send a DELETE request to the Revolut API - - Parameters - ---------- - path : str - The path to send the request to - params : Type[BaseModel] | None - The parameters to add to the request route - - Returns - ------- - Response - The response from the request - """ - self.__check_sync_client() - path = self.__process_path(path) - url = f"{self.domain}/{path}" - headers = self.__create_headers(kwargs.pop("headers", {})) - params = ( - self.__replace_null_with_none( - data=params.model_dump(mode="json", exclude_none=True, by_alias=True) - ) - if params is not None - else None - ) - resp = self.sync_client.delete( - url=url, - params=params, - headers=headers, - **kwargs, - ) - self.log_response(response=resp) - return resp - - def put(self, path: str, body: Type[BaseModel] | None = None, **kwargs): - """Send a PUT request to the Revolut API - - Parameters - ---------- - path : str - The path to send the request to - body : Type[BaseModel] | None - The body to send in the request - - Returns - ------- - Response - The response from the request - """ - self.__check_sync_client() - path = self.__process_path(path) - url = f"{self.domain}/{path}" - headers = self.__create_headers(kwargs.pop("headers", {})) - json = ( - self.__replace_null_with_none( - data=body.model_dump(mode="json", exclude_none=True, by_alias=True) - ) - if body is not None - else None - ) - resp = self.sync_client.put( - url=url, - json=json, - headers=headers, - **kwargs, - ) - self.log_response(response=resp) - return resp - - async def aget(self, path: str, params: Type[BaseModel] | None = None, **kwargs): - """Send an async GET request to the Revolut API - - Parameters - ---------- - path : str - The path to send the request to - params : Type[BaseModel] | None - The parameters to send in the request - - """ - self.__check_async_client() - url = f"{self.domain}/{path}" - headers = self.__create_headers(kwargs.pop("headers", {})) - params = ( - self.__replace_null_with_none( - data=params.model_dump(mode="json", exclude_none=True, by_alias=True) - ) - if params is not None - else None - ) - resp = await self.async_client.get( - url=url, - params=params, - headers=headers, - **kwargs, - ) - self.log_response(response=resp) - return resp - - async def apost(self, path: str, body: Type[BaseModel] | None = None, **kwargs): - """Send an async POST request to the Revolut API - - Parameters - ---------- - path : str - The path to send the request to - body : Type[BaseModel] | None - The body to send in the request - - Returns - ------- - Response - The response from the request - """ - self.__check_async_client() - url = f"{self.domain}/{path}" - headers = self.__create_headers(kwargs.pop("headers", {})) - json = ( - self.__replace_null_with_none( - data=body.model_dump(mode="json", exclude_none=True, by_alias=True) - ) - if body is not None - else None - ) - resp = await self.async_client.post( - url=url, - json=json, - headers=headers, - **kwargs, - ) - self.log_response(response=resp) - return resp - - async def apatch(self, path: str, body: Type[BaseModel] | None = None, **kwargs): - """Send an async PATCH request to the Revolut API - - Parameters - ---------- - path : str - The path to send the request to - body : Type[BaseModel] | None - The body to send in the request - - Returns - ------- - Response - The response from the request - """ - self.__check_async_client() - path = self.__process_path(path) - url = f"{self.domain}/{path}" - headers = self.__create_headers(kwargs.pop("headers", {})) - json = ( - self.__replace_null_with_none( - data=body.model_dump(mode="json", exclude_none=True, by_alias=True) - ) - if body is not None - else None - ) - resp = await self.async_client.patch( - url=url, - json=json, - headers=headers, - **kwargs, - ) - self.log_response(response=resp) - return resp - - async def adelete( - self, - path: str, - params: Type[BaseModel] | None = None, - **kwargs, - ): - """Send an async DELETE request to the Revolut API - - Parameters - ---------- - path : str - The path to send the request to - params : Type[BaseModel] | None - The parameters to add to the request route - - Returns - ------- - Response - The response from the request - """ - self.__check_async_client() - path = self.__process_path(path) - url = f"{self.domain}/{path}" - headers = self.__create_headers(kwargs.pop("headers", {})) - params = ( - self.__replace_null_with_none( - data=params.model_dump(mode="json", exclude_none=True, by_alias=True) - ) - if params is not None - else None - ) - resp = await self.async_client.delete( - url=url, - params=params, - headers=headers, - **kwargs, - ) - self.log_response(response=resp) - return resp - - async def aput(self, path: str, body: Type[BaseModel] | None = None, **kwargs): - """Send an async PUT request to the Revolut API - - Parameters - ---------- - path : str - The path to send the request to - body : Type[BaseModel] | None - The body to send in the request - - Returns - ------- - Response - The response from the request - """ - self.__check_async_client() - path = self.__process_path(path) - url = f"{self.domain}/{path}" - headers = self.__create_headers(kwargs.pop("headers", {})) - json = ( - self.__replace_null_with_none( - data=body.model_dump(mode="json", exclude_none=True, by_alias=True) - ) - if body is not None - else None - ) - resp = await self.async_client.put( - url=url, - json=json, - headers=headers, - **kwargs, - ) - self.log_response(response=resp) - return resp - - @property - def required_headers(self) -> dict[str, str]: - """The headers to be attached to each request - - Returns - ------- - dict[str, str] - The headers to be attached to each request - """ - if self.access_token is None: - raise {} - return { - "Accept": "application/json", - "Authorization": f"Bearer {self.access_token}", - } - - def __create_headers(self, headers: dict[str, str] = {}) -> dict[str, str]: - """Create the headers for the request by adding the required headers - - Parameters - ---------- - headers : dict[str, str] - The headers for the request - - Returns - ------- - dict[str, str] - The headers for the request - """ - headers.update(self.required_headers) - return headers - - def __check_sync_client(self): - """Check if the sync client is open - - Raises - ------ - ValueError - If the client is not open - """ - if self.sync_client is None: - raise ValueError("Sync client is not open") - - def __check_async_client(self): - """Check if the async client is open - - Raises - ------ - ValueError - If the client is not open - """ - if self.async_client is None: - raise ValueError("Async client is not open") - - def __process_path(self, path: str) -> str: - """Process the path. - - If 'http' not in the path: - Removing the leading slash if it exists - Else: - Return the path as is - - Parameters - ---------- - path : str - The path to process - - Returns - ------- - str - The processed path - """ - if "http" in path: - return path - - return self.__remove_leading_slash(path) - - def __remove_leading_slash(self, path: str) -> str: - """Remove the leading slash from a path if it exists and - return it without the leading slash - - Parameters - ---------- - path : str - The path to remove the leading slash from - - Returns - ------- - str - The path without the leading slash - """ - if path.startswith("/"): - return path[1:] - return path - - def __replace_null_with_none(self, data: D) -> D: - """ - Method that replaces all 'null' strings with None in a provided dictionary or list. - - Must be called with either a dictionary or a list, not both. - - Parameters - ---------- - data : dict | list - The dictionary or list to replace 'null' strings with None - - Returns - ------- - dict | list - The dictionary or list with 'null' strings replaced with None - """ - if isinstance(data, dict): - for k, v in data.items(): - if isinstance(v, dict): - self.__replace_null_with_none(data_dict=v, data_list=None) - elif isinstance(v, list): - self.__replace_null_with_none(data_dict=None, data_list=v) - elif v == "null": - data[k] = None - elif isinstance(data, list): - for i in range(len(data)): - if isinstance(data[i], dict): - self.__replace_null_with_none(data_dict=data[i], data_list=None) - elif isinstance(data[i], list): - self.__replace_null_with_none(data_dict=None, data_list=data[i]) - elif data[i] == "null": - data[i] = None - else: - raise ValueError("Data must be either a dictionary or a list") - - return data - - def __load_resources(self): - """Loads all the resources from the resources directory""" - self.Accounts = EndpointAccounts(client=self) - self.Cards = EndpointCards(client=self) - self.Counterparties = EndpointCounterparties(client=self) - self.ForeignExchange = EndpointForeignExchange(client=self) - self.PaymentDrafts = EndpointPaymentDrafts(client=self) - self.PayoutLinks = EndpointPayoutLinks(client=self) - self.Simulations = EndpointSimulations(client=self) - self.TeamMembers = EndpointTeamMembers(client=self) - self.Transactions = EndpointTransactions(client=self) - self.Transfers = EndpointTransfers(client=self) - self.Webhooks = EndpointWebhooks(client=self) - - def __enter__(self): - """Open the client connection""" - self.open() - return self - - def __exit__(self, *args, **kwargs): - """Close the client connection""" - self.close() - - async def __aenter__(self): - """Open the async client connection""" - await self.aopen() - return self - - async def __aexit__(self, *args, **kwargs): - """Close the async client connection""" - await self.aclose() - - def log_response(self, response: Response): - """Log the response from the API. - If the response is an error, raise an error - - Parameters - ---------- - response : Response - The response from the API - """ - if not response.is_error: - logging.info(f"Response: {response.status_code} - {response.text}") - else: - logging.error(f"Response: {response.status_code} - {response.text}") - - try: - response.raise_for_status() - except HTTPStatusError as exc: - raise ValueError(f"Error {response.status_code}: {response.text}") from exc diff --git a/pyrevolut/client/__init__.py b/pyrevolut/client/__init__.py new file mode 100644 index 0000000..4988a62 --- /dev/null +++ b/pyrevolut/client/__init__.py @@ -0,0 +1,5 @@ +"""This module contains the client implementation for the Revolut API.""" + +# flake8: noqa: F401 +from .synchronous import Client +from .asynchronous import AsyncClient diff --git a/pyrevolut/client/asynchronous.py b/pyrevolut/client/asynchronous.py new file mode 100644 index 0000000..3b9dd34 --- /dev/null +++ b/pyrevolut/client/asynchronous.py @@ -0,0 +1,207 @@ +from typing import Type + +from pydantic import BaseModel + +from httpx import AsyncClient as HTTPClient + +from pyrevolut.api import ( + EndpointAccountsAsync, + EndpointCardsAsync, + EndpointCounterpartiesAsync, + EndpointForeignExchangeAsync, + EndpointPaymentDraftsAsync, + EndpointPayoutLinksAsync, + EndpointSimulationsAsync, + EndpointTeamMembersAsync, + EndpointTransactionsAsync, + EndpointTransfersAsync, + EndpointWebhooksAsync, +) + + +from .base import BaseClient + + +class AsyncClient(BaseClient): + """The asynchronous client for the Revolut API""" + + Accounts: EndpointAccountsAsync | None = None + Cards: EndpointCardsAsync | None = None + Counterparties: EndpointCounterpartiesAsync | None = None + ForeignExchange: EndpointForeignExchangeAsync | None = None + PaymentDrafts: EndpointPaymentDraftsAsync | None = None + PayoutLinks: EndpointPayoutLinksAsync | None = None + Simulations: EndpointSimulationsAsync | None = None + TeamMembers: EndpointTeamMembersAsync | None = None + Transactions: EndpointTransactionsAsync | None = None + Transfers: EndpointTransfersAsync | None = None + Webhooks: EndpointWebhooksAsync | None = None + + async def open(self): + """Opens the client connection""" + if self.client is not None: + return + + self.client = HTTPClient() + self.__load_resources() + + async def close(self): + """Closes the client connection""" + if self.client is None: + return + + await self.client.aclose() + self.client = None + + async def get(self, path: str, params: Type[BaseModel] | None = None, **kwargs): + """Send an async GET request to the Revolut API + + Parameters + ---------- + path : str + The path to send the request to + params : Type[BaseModel] | None + The parameters to send in the request + + Returns + ------- + Response + The response from the request + """ + resp = await self.client.get( + **self._prep_get( + path=path, + params=params, + **kwargs, + ) + ) + self.log_response(response=resp) + return resp + + async def post(self, path: str, body: Type[BaseModel] | None = None, **kwargs): + """Send an async POST request to the Revolut API + + Parameters + ---------- + path : str + The path to send the request to + body : Type[BaseModel] | None + The body to send in the request + + Returns + ------- + Response + The response from the request + """ + resp = await self.client.post( + **self._prep_post( + path=path, + body=body, + **kwargs, + ) + ) + self.log_response(response=resp) + return resp + + async def patch(self, path: str, body: Type[BaseModel] | None = None, **kwargs): + """Send an async PATCH request to the Revolut API + + Parameters + ---------- + path : str + The path to send the request to + body : Type[BaseModel] | None + The body to send in the request + + Returns + ------- + Response + The response from the request + """ + resp = await self.client.patch( + **self._prep_patch( + path=path, + body=body, + **kwargs, + ) + ) + self.log_response(response=resp) + return resp + + async def delete( + self, + path: str, + params: Type[BaseModel] | None = None, + **kwargs, + ): + """Send an async DELETE request to the Revolut API + + Parameters + ---------- + path : str + The path to send the request to + params : Type[BaseModel] | None + The parameters to add to the request route + + Returns + ------- + Response + The response from the request + """ + resp = await self.client.delete( + **self._prep_delete( + path=path, + params=params, + **kwargs, + ) + ) + self.log_response(response=resp) + return resp + + async def put(self, path: str, body: Type[BaseModel] | None = None, **kwargs): + """Send an async PUT request to the Revolut API + + Parameters + ---------- + path : str + The path to send the request to + body : Type[BaseModel] | None + The body to send in the request + + Returns + ------- + Response + The response from the request + """ + resp = await self.client.put( + **self._prep_put( + path=path, + body=body, + **kwargs, + ) + ) + self.log_response(response=resp) + return resp + + def __load_resources(self): + """Loads all the resources from the resources directory""" + self.Accounts = EndpointAccountsAsync(client=self) + self.Cards = EndpointCardsAsync(client=self) + self.Counterparties = EndpointCounterpartiesAsync(client=self) + self.ForeignExchange = EndpointForeignExchangeAsync(client=self) + self.PaymentDrafts = EndpointPaymentDraftsAsync(client=self) + self.PayoutLinks = EndpointPayoutLinksAsync(client=self) + self.Simulations = EndpointSimulationsAsync(client=self) + self.TeamMembers = EndpointTeamMembersAsync(client=self) + self.Transactions = EndpointTransactionsAsync(client=self) + self.Transfers = EndpointTransfersAsync(client=self) + self.Webhooks = EndpointWebhooksAsync(client=self) + + async def __aenter__(self): + """Open the async client connection""" + await self.open() + return self + + async def __aexit__(self, *args, **kwargs): + """Close the async client connection""" + await self.close() diff --git a/pyrevolut/client/base.py b/pyrevolut/client/base.py new file mode 100644 index 0000000..c7a6b6c --- /dev/null +++ b/pyrevolut/client/base.py @@ -0,0 +1,464 @@ +from typing import Type, TypeVar +import logging + +from pydantic import BaseModel +import pendulum + +from httpx import AsyncClient +from httpx import Client as SyncClient +from httpx import HTTPStatusError, Response + +from pyrevolut.utils.auth import ( + ModelCreds, + refresh_access_token, + save_creds, + load_creds, +) + + +D = TypeVar("D", dict, list) # TypeVar for dictionary or list + + +class BaseClient: + creds_loc: str + credentials: ModelCreds + domain: str + sandbox: bool + client: SyncClient | AsyncClient | None = None + + def __init__( + self, + creds_loc: str = "credentials/creds.json", + sandbox: bool = True, + ): + """Create a new Revolut client + + Parameters + ---------- + creds_loc : str, optional + sandbox : bool, optional + Whether to use the sandbox environment, by default True + """ + self.creds_loc = creds_loc + self.sandbox = sandbox + + # Set domain based on environment + if self.sandbox: + self.domain = "https://sandbox-b2b.revolut.com/api/1.0/" + else: + self.domain = "https://b2b.revolut.com/api/1.0/" + + # Load the credentials + self.__load_credentials() + + def log_response(self, response: Response): + """Log the response from the API. + If the response is an error, raise an error + + Parameters + ---------- + response : Response + The response from the API + """ + if not response.is_error: + logging.info(f"Response: {response.status_code} - {response.text}") + else: + logging.error(f"Response: {response.status_code} - {response.text}") + + try: + response.raise_for_status() + except HTTPStatusError as exc: + raise ValueError(f"Error {response.status_code}: {response.text}") from exc + + def _prep_get( + self, + path: str, + params: Type[BaseModel] | None = None, + **kwargs, + ): + """ + Method to prepare the GET request inputs for the HTTPX client. + + Parameters + ---------- + path : str + The path to send the request to + params : Type[BaseModel] | None + The parameters to add to the request route + **kwargs + Additional keyword arguments to pass to the HTTPX client + + Returns + ------- + dict + The prepared inputs for the HTTPX client + """ + self.__check_client() + path = self.__process_path(path) + url = f"{self.domain}/{path}" + headers = self.__create_headers(kwargs.pop("headers", {})) + params = ( + self.__replace_null_with_none( + data=params.model_dump(mode="json", exclude_none=True, by_alias=True) + ) + if params is not None + else None + ) + return { + "url": url, + "params": params, + "headers": headers, + **kwargs, + } + + def _prep_post( + self, + path: str, + body: Type[BaseModel] | None = None, + **kwargs, + ): + """ + Method to prepare the POST request inputs for the HTTPX client. + + Parameters + ---------- + path : str + The path to send the request to + body : Type[BaseModel] | None + The body to send in the request + **kwargs + Additional keyword arguments to pass to the HTTPX client + + Returns + ------- + dict + The prepared inputs for the HTTPX client + """ + self.__check_client() + path = self.__process_path(path) + url = f"{self.domain}/{path}" + headers = self.__create_headers(kwargs.pop("headers", {})) + json = ( + self.__replace_null_with_none( + data=body.model_dump(mode="json", exclude_none=True, by_alias=True) + ) + if body is not None + else None + ) + return { + "url": url, + "json": json, + "headers": headers, + **kwargs, + } + + def _prep_patch( + self, + path: str, + body: Type[BaseModel] | None = None, + **kwargs, + ): + """ + Method to prepare the PATCH request inputs for the HTTPX client. + + Parameters + ---------- + path : str + The path to send the request to + body : Type[BaseModel] | None + The body to send in the request + **kwargs + Additional keyword arguments to pass to the HTTPX client + + Returns + ------- + dict + The prepared inputs for the HTTPX client + """ + self.__check_client() + path = self.__process_path(path) + url = f"{self.domain}/{path}" + headers = self.__create_headers(kwargs.pop("headers", {})) + json = ( + self.__replace_null_with_none( + data=body.model_dump(mode="json", exclude_none=True, by_alias=True) + ) + if body is not None + else None + ) + return { + "url": url, + "json": json, + "headers": headers, + **kwargs, + } + + def _prep_delete( + self, + path: str, + params: Type[BaseModel] | None = None, + **kwargs, + ): + """ + Method to prepare the DELETE request inputs for the HTTPX client. + + Parameters + ---------- + path : str + The path to send the request to + params : Type[BaseModel] | None + The parameters to add to the request route + **kwargs + Additional keyword arguments to pass to the HTTPX client + + Returns + ------- + dict + The prepared inputs for the HTTPX client + """ + self.__check_client() + path = self.__process_path(path) + url = f"{self.domain}/{path}" + headers = self.__create_headers(kwargs.pop("headers", {})) + params = ( + self.__replace_null_with_none( + data=params.model_dump(mode="json", exclude_none=True, by_alias=True) + ) + if params is not None + else None + ) + return { + "url": url, + "params": params, + "headers": headers, + **kwargs, + } + + def _prep_put( + self, + path: str, + body: Type[BaseModel] | None = None, + **kwargs, + ): + """ + Method to prepare the PUT request inputs for the HTTPX client. + + Parameters + ---------- + path : str + The path to send the request to + body : Type[BaseModel] | None + The body to send in the request + **kwargs + Additional keyword arguments to pass to the HTTPX client + + Returns + ------- + dict + The prepared inputs for the HTTPX client + """ + self.__check_client() + path = self.__process_path(path) + url = f"{self.domain}/{path}" + headers = self.__create_headers(kwargs.pop("headers", {})) + json = ( + self.__replace_null_with_none( + data=body.model_dump(mode="json", exclude_none=True, by_alias=True) + ) + if body is not None + else None + ) + return { + "url": url, + "json": json, + "headers": headers, + **kwargs, + } + + @property + def required_headers(self) -> dict[str, str]: + """The headers to be attached to each request + + Returns + ------- + dict[str, str] + The headers to be attached to each request + """ + return { + "Accept": "application/json", + "Authorization": f"Bearer {self.credentials.tokens.access_token.get_secret_value()}", + } + + def __create_headers(self, headers: dict[str, str] = {}) -> dict[str, str]: + """Create the headers for the request by adding the required headers + + Parameters + ---------- + headers : dict[str, str] + The headers for the request + + Returns + ------- + dict[str, str] + The headers for the request + """ + headers.update(self.required_headers) + return headers + + def __check_client(self): + """Check if the client is open and that the credentials are still valid. + + Raises + ------ + ValueError + If the client is not open or if the long-term credentials have expired + """ + if self.client is None: + raise ValueError("Client is not open") + + if self.credentials.credentials_expired: + raise ValueError( + "Long-term credentials have expired. " + "\n\nPlease reauthenticate using the `pyrevolut auth-manual` command." + ) + + if self.credentials.access_token_expired: + self.__refresh_access_token() + + def __process_path(self, path: str) -> str: + """Process the path. + + If 'http' not in the path: + Removing the leading slash if it exists + Else: + Return the path as is + + Parameters + ---------- + path : str + The path to process + + Returns + ------- + str + The processed path + """ + if "http" in path: + return path + + return self.__remove_leading_slash(path) + + def __remove_leading_slash(self, path: str) -> str: + """Remove the leading slash from a path if it exists and + return it without the leading slash + + Parameters + ---------- + path : str + The path to remove the leading slash from + + Returns + ------- + str + The path without the leading slash + """ + if path.startswith("/"): + return path[1:] + return path + + def __replace_null_with_none(self, data: D) -> D: + """ + Method that replaces all 'null' strings with None in a provided dictionary or list. + + Must be called with either a dictionary or a list, not both. + + Parameters + ---------- + data : dict | list + The dictionary or list to replace 'null' strings with None + + Returns + ------- + dict | list + The dictionary or list with 'null' strings replaced with None + """ + if isinstance(data, dict): + for k, v in data.items(): + if isinstance(v, dict): + self.__replace_null_with_none(data_dict=v, data_list=None) + elif isinstance(v, list): + self.__replace_null_with_none(data_dict=None, data_list=v) + elif v == "null": + data[k] = None + elif isinstance(data, list): + for i in range(len(data)): + if isinstance(data[i], dict): + self.__replace_null_with_none(data_dict=data[i], data_list=None) + elif isinstance(data[i], list): + self.__replace_null_with_none(data_dict=None, data_list=data[i]) + elif data[i] == "null": + data[i] = None + else: + raise ValueError("Data must be either a dictionary or a list") + + return data + + def __load_credentials(self): + """Load the credentials from the credentials file. + + - If the credentials file does not exist, raise an error. + - If the credentials file is invalid, raise an error. + - If the credentials are expired, raise an error. + - If the access token is expired, refresh it. + + """ + solution_msg = "\n\nPlease reauthenticate using the `pyrevolut auth-manual` command." + + try: + self.credentials = load_creds(location=self.creds_loc) + except FileNotFoundError as exc: + raise ValueError(f"Credentials file not found: {exc}. {solution_msg}") from exc + except Exception as exc: + raise ValueError(f"Error loading credentials: {exc}.") from exc + + # Check if the credentials are still valid + if self.credentials.credentials_expired: + raise ValueError(f"Credentials are expired. {solution_msg}") + + # Check if the access token is expired + if self.credentials.access_token_expired: + self.__refresh_access_token() + + def __refresh_access_token(self): + """Refresh the access token using the refresh token. + Will call the endpoint to refresh the access token. + Then it will save the new access token to the credentials file. + + Parameters + ---------- + None + + Raises + ------ + ValueError + If there is an error refreshing the access token. + + Returns + ------- + None + """ + try: + resp = refresh_access_token( + client=SyncClient(), + refresh_token=self.credentials.tokens.refresh_token.get_secret_value(), + client_assert_jwt=self.credentials.client_assert_jwt.jwt.get_secret_value(), + sandbox=self.sandbox, + ) + self.credentials.tokens.access_token = resp.access_token.get_secret_value() + self.credentials.tokens.token_type = resp.token_type + self.credentials.tokens.access_token_expiration_dt = pendulum.now(tz="UTC").add( + seconds=resp.expires_in + ) + save_creds(creds=self.credentials, location=self.creds_loc, indent=4) + except Exception as exc: + raise ValueError(f"Error refreshing access token: {exc}.") from exc diff --git a/pyrevolut/client/synchronous.py b/pyrevolut/client/synchronous.py new file mode 100644 index 0000000..b6307cd --- /dev/null +++ b/pyrevolut/client/synchronous.py @@ -0,0 +1,206 @@ +from typing import Type + +from pydantic import BaseModel + +from httpx import Client as HTTPClient + +from pyrevolut.api import ( + EndpointAccountsSync, + EndpointCardsSync, + EndpointCounterpartiesSync, + EndpointForeignExchangeSync, + EndpointPaymentDraftsSync, + EndpointPayoutLinksSync, + EndpointSimulationsSync, + EndpointTeamMembersSync, + EndpointTransactionsSync, + EndpointTransfersSync, + EndpointWebhooksSync, +) + +from .base import BaseClient + + +class Client(BaseClient): + """The synchronous client for the Revolut API""" + + Accounts: EndpointAccountsSync | None = None + Cards: EndpointCardsSync | None = None + Counterparties: EndpointCounterpartiesSync | None = None + ForeignExchange: EndpointForeignExchangeSync | None = None + PaymentDrafts: EndpointPaymentDraftsSync | None = None + PayoutLinks: EndpointPayoutLinksSync | None = None + Simulations: EndpointSimulationsSync | None = None + TeamMembers: EndpointTeamMembersSync | None = None + Transactions: EndpointTransactionsSync | None = None + Transfers: EndpointTransfersSync | None = None + Webhooks: EndpointWebhooksSync | None = None + + def open(self): + """Opens the client connection""" + if self.client is not None: + return + + self.client = HTTPClient() + self.__load_resources() + + def close(self): + """Closes the client connection""" + if self.client is None: + return + + self.client.close() + self.client = None + + def get(self, path: str, params: Type[BaseModel] | None = None, **kwargs): + """Send a GET request to the Revolut API + + Parameters + ---------- + path : str + The path to send the request to + params : Type[BaseModel] | None + The parameters to add to the request route + + Returns + ------- + Response + The response from the request + """ + resp = self.client.get( + **self._prep_get( + path=path, + params=params, + **kwargs, + ) + ) + self.log_response(response=resp) + return resp + + def post(self, path: str, body: Type[BaseModel] | None = None, **kwargs): + """Send a POST request to the Revolut API + + Parameters + ---------- + path : str + The path to send the request to + body : Type[BaseModel] | None + The body to send in the request + + Returns + ------- + Response + The response from the request + """ + resp = self.client.post( + **self._prep_post( + path=path, + body=body, + **kwargs, + ) + ) + self.log_response(response=resp) + return resp + + def patch(self, path: str, body: Type[BaseModel] | None = None, **kwargs): + """Send a PATCH request to the Revolut API + + Parameters + ---------- + path : str + The path to send the request to + body : Type[BaseModel] + The body to send in the request + + Returns + ------- + Response + The response from the request + """ + resp = self.client.patch( + **self._prep_patch( + path=path, + body=body, + **kwargs, + ) + ) + self.log_response(response=resp) + return resp + + def delete( + self, + path: str, + params: Type[BaseModel] | None = None, + **kwargs, + ): + """Send a DELETE request to the Revolut API + + Parameters + ---------- + path : str + The path to send the request to + params : Type[BaseModel] | None + The parameters to add to the request route + + Returns + ------- + Response + The response from the request + """ + resp = self.client.delete( + **self._prep_delete( + path=path, + params=params, + **kwargs, + ) + ) + self.log_response(response=resp) + return resp + + def put(self, path: str, body: Type[BaseModel] | None = None, **kwargs): + """Send a PUT request to the Revolut API + + Parameters + ---------- + path : str + The path to send the request to + body : Type[BaseModel] | None + The body to send in the request + + Returns + ------- + Response + The response from the request + """ + resp = self.client.put( + **self._prep_put( + path=path, + body=body, + **kwargs, + ) + ) + self.log_response(response=resp) + return resp + + def __load_resources(self): + """Loads all the resources from the resources directory""" + self.Accounts = EndpointAccountsSync(client=self) + self.Cards = EndpointCardsSync(client=self) + self.Counterparties = EndpointCounterpartiesSync(client=self) + self.ForeignExchange = EndpointForeignExchangeSync(client=self) + self.PaymentDrafts = EndpointPaymentDraftsSync(client=self) + self.PayoutLinks = EndpointPayoutLinksSync(client=self) + self.Simulations = EndpointSimulationsSync(client=self) + self.TeamMembers = EndpointTeamMembersSync(client=self) + self.Transactions = EndpointTransactionsSync(client=self) + self.Transfers = EndpointTransfersSync(client=self) + self.Webhooks = EndpointWebhooksSync(client=self) + + def __enter__(self): + """Open the client connection""" + self.open() + return self + + def __exit__(self, *args, **kwargs): + """Close the client connection""" + self.close() diff --git a/pyrevolut/utils/auth/__init__.py b/pyrevolut/utils/auth/__init__.py new file mode 100644 index 0000000..1df67a0 --- /dev/null +++ b/pyrevolut/utils/auth/__init__.py @@ -0,0 +1,12 @@ +"""This module contains the authentication methods.""" + +# flake8: noqa: F401 +from .auth_manual import auth_manual_flow +from .creds import ModelCreds, load_creds, save_creds +from .enum_auth_scope import EnumAuthScope +from .get_auth_tokens import ModelGetAuthTokensResponse, get_auth_tokens, aget_auth_tokens +from .refresh_access_token import ( + ModelRefreshAccessTokenResponse, + refresh_access_token, + arefresh_access_token, +) diff --git a/pyrevolut/utils/auth/auth_manual.py b/pyrevolut/utils/auth/auth_manual.py new file mode 100644 index 0000000..17d57ef --- /dev/null +++ b/pyrevolut/utils/auth/auth_manual.py @@ -0,0 +1,255 @@ +import base64 +import json + +import pendulum +from httpx import Client +import typer +from rich import print as console + +from pyrevolut.utils.datetime import to_datetime + +from .enum_auth_scope import EnumAuthScope +from .creds import ModelCreds, save_creds +from .gen_public_private_cert import gen_public_private_cert +from .create_client_assert_jwt import create_client_assert_jwt +from .get_auth_tokens import get_auth_tokens + + +def auth_manual_flow( + credentials_json: str = "credentials/creds.json", + sandbox: bool = True, + scopes: list[EnumAuthScope] | None = None, +): + """ + Method to run the manual authorization flow to get the client assertion JWT and the access and refresh tokens. + + Parameters + ---------- + credentials_json : str, optional + The location to save the credentials JSON file. + Default is "credentials/creds.json". + sandbox : bool, optional + Whether to use the sandbox environment. + Default is True. + scopes : list[EnumAuthScope] | None, optional + The list of scopes to request. If not provided, the default scopes will be used. + + Access tokens can be issued with four security scopes and require a JWT (JSON Web Token) + signature to be obtained: + + READ: Permissions for GET operations. + + WRITE: Permissions to update counterparties, webhooks, and issue payment drafts. + + PAY: Permissions to initiate or cancel transactions and currency exchanges. + + READ_SENSITIVE_CARD_DATA: Permissions to retrieve sensitive card details. + + Caution + ------- + If you enable the READ_SENSITIVE_CARD_DATA scope for your access token, you must + set up IP whitelisting. + Failing to do so will prevent you from accessing any Business API endpoint. + + Default is None. + + Returns + ------- + None + """ + + # Final credentials dictionary to be saved to a JSON file + creds = {} + + console("======================================================================") + console("= Revolut Client from Manual Flow =") + console("======================================================================") + + # Check if the credentials file already exists + try: + with open(credentials_json, "r") as f: + creds = json.load(f) + console("Credentials file found. Loading credentials...") + + # Check if the credentials file is valid + try: + creds_model = ModelCreds(**creds) + + # Check for any expired fields + if not creds_model.credentials_expired: + console("Credentials loaded successfully.") + console("To re-authenticate, please delete the credentials file.") + return + else: + console("Credentials have expired. Re-authenticating...") + except Exception as exc: + console(f"An error occurred: {exc}") + console( + "Credentials file is invalid. Please delete the credentials file and re-authenticate." + ) + return + except FileNotFoundError: + pass + + # Generate the Public and Private Certificates + console("\n-----------------------------------------------------------") + console("--- Step (1/4) Generate Public and Private Certificates ---") + console("-----------------------------------------------------------") + while True: + expiration_dt = to_datetime(typer.prompt("Expiration datetime of the certificates (UTC)")) + country = typer.prompt("Country (2-letter code)") + email_address = typer.prompt(text="Email Address (can be left blank)", default="") + common_name = typer.prompt( + text="Common Name (fully qualified host name, can be left blank)", default="" + ) + state = typer.prompt(text="State or Province (full name, can be left blank)", default="") + locality = typer.prompt(text="Locality (city, can be left blank)", default="") + organization = typer.prompt(text="Organization (company, can be left blank)", default="") + organization_unit = typer.prompt( + text="Organization Unit (section, can be left blank)", default="" + ) + + try: + keys = gen_public_private_cert( + expiration_dt=expiration_dt, + country=country, + email_address=email_address, + common_name=common_name, + state=state, + locality=locality, + organization=organization, + organization_unit=organization_unit, + save_location_private=None, + save_location_public=None, + ) + public_key = keys["public"] + private_key = keys["private"] + + # Store in the creds dictionary as base64 encoded strings + creds["certificate"] = { + "public": base64.b64encode(public_key).decode("utf-8"), + "private": base64.b64encode(private_key).decode("utf-8"), + "expiration_dt": expiration_dt.to_iso8601_string(), + } + break + except Exception as exc: + console(f"An error occurred: {exc}") + + console("\n-------------------------------------------------------") + console("--- Step (2/4) Upload Public Certificate to Revolut ---") + console("-------------------------------------------------------") + upload_url = ( + "https://sandbox-business.revolut.com/settings/api" + if sandbox + else "https://business.revolut.com/settings/api" + ) + public_key_string = public_key.decode("utf-8").replace("\\n", "") + console( + "Please specify your OAuth redirect URI. " + "This is the URL where you are redirected after you consent for " + "the application to access your Revolut Business account." + ) + while True: + try: + redirect_url: str = typer.prompt( + "OAuth redirect URI (e.g: https://example.com)", default="https://example.com" + ) + if redirect_url.startswith("http://"): + raise ValueError(f"WARNING: Your redirect URL ({redirect_url}) is not secure.") + break + except ValueError: + pass + + console("\nPlease follow the instructions exactly:") + console(f"Step (2.1) Navigate to the following URL: {upload_url}") + console( + "Step (2.2) In the [bold red]API Certificates[/bold red] section, " + "click [bold red]Add API certificate[/bold red]. " + "If you already have other certificates added, click [bold red]Add new[/bold red]." + ) + console( + "Step (2.3) Give your certificate a meaningful title. It will help you later to distinguish " + "it from other certificates." + ) + console( + "Step (2.4) Copy and paste the following url to the OAuth redirect URI field: " + f"\n\n[bold]{redirect_url}[/bold]\n" + ) + console( + "Step (2.5) Copy and paste the following public key to the X509 public key field " + "(including the -----BEGIN CERTIFICATE----- and -----END CERTIFICATE----- lines): " + f"\n\n[bold]{public_key_string}[/bold]\n", + end="", + ) + console( + "Step (2.6) Click [bold red]Continue[/bold red]. " + "This takes you to the [bold red]API Certificate[/bold red] page with the parameters " + "of your application." + ) + console("Step (2.7) Copy the [bold red]Client ID[/bold red] provided by Revolut.") + client_id = typer.prompt(text="Client ID") + + console("\n------------------------------------------------------") + console("--- Step (3/4) Generate Client Assertion JWT Token ---") + console("------------------------------------------------------") + client_assert_jwt = create_client_assert_jwt( + client_id=client_id, + expiration_dt=expiration_dt, + private_credentials_key=private_key, + issuer_url=redirect_url, + save_location=None, + ) + creds["client_assert_jwt"] = { + "jwt": client_assert_jwt, + "expiration_dt": expiration_dt.to_iso8601_string(), + } + + console("\n---------------------------------------------") + console("--- Step (4/4) Consent to the application ---") + console("---------------------------------------------") + suffix = f"app-confirm?client_id={client_id}&redirect_uri={redirect_url}&response_type=code" + if scopes is not None: + suffix += f"&scope={','.join([scope for scope in scopes])}" + suffix += "#authorize" + consent_url = ( + "https://sandbox-business.revolut.com/" if sandbox else "https://business.revolut.com/" + ) + suffix + + console("\nPlease follow the instructions exactly:") + console(f"Step (4.1) Navigate to the following URL: {consent_url}") + console( + "Step (4.2) Click on [bold red]Authorize[/bold red] and upon success, " + "you will be redirected to your OAuth redirect URI." + ) + console("Step (4.3) Copy the entire redirect URI and paste it below.") + auth_url: str = typer.prompt("OAuth redirect URI") + auth_code = auth_url.split("code=")[1] + + console("\n-----------------------------------------------") + console("--- Step (5/5) Get Access and Refresh Token ---") + console("-----------------------------------------------") + resp = get_auth_tokens( + client=Client(), + auth_code=auth_code, + client_assert_jwt=client_assert_jwt, + sandbox=sandbox, + ) + dt_now = pendulum.now(tz="UTC") + creds["tokens"] = { + "access_token": resp.access_token.get_secret_value(), + "refresh_token": resp.refresh_token.get_secret_value(), + "token_type": resp.token_type, + "access_token_expiration_dt": dt_now.add(seconds=resp.expires_in).to_iso8601_string(), + # To comply with PSD2 SCA regulations, refresh tokens expire after 90 days + "refresh_token_expiration_dt": dt_now.add(days=90).to_iso8601_string(), + } + + # Store the credentials to a JSON file + creds_model = ModelCreds(**creds) + save_creds(creds=creds_model, location=credentials_json, indent=4) + + console( + "\n=====================================================================================" + ) + console(f"= Credentials Saved to {credentials_json} =") + console("=====================================================================================") diff --git a/pyrevolut/utils/auth/create_client_assert_jwt.py b/pyrevolut/utils/auth/create_client_assert_jwt.py new file mode 100644 index 0000000..f00e383 --- /dev/null +++ b/pyrevolut/utils/auth/create_client_assert_jwt.py @@ -0,0 +1,74 @@ +from datetime import datetime + +import jwt + +from pyrevolut.utils.datetime import DateTime, to_datetime + + +def create_client_assert_jwt( + client_id: str, + expiration_dt: datetime | DateTime | str | int | float, + private_credentials_key: bytes, + issuer_url: str = "https://example.com", + save_location: str | None = "client_assertion.jwt", +): + """ + Method to create a client-assertion JWT for Revolut. + The JWT is used to authenticate the client to the Revolut API. + + Parameters + ---------- + client_id : str + The client ID. + This is the client ID provided by Revolut when you upload your public key. + expiration_dt : datetime | DateTime | str | int | float + The expiration datetime (UTC) of the JWT. + This can be a datetime object, a DateTime object, a string, an integer, or a float. + private_credentials_key : bytes + The private key used to sign the JWT. + issuer_url : str, optional + The issuer URL. + This is the URL of the client. + When you finalize the authentication using the JWT, Revolut will redirect you to this URL + with an attached authorization code in the query string. + + For example if the URL is "https://example.com", + the redirect URL will be https://example.com?code=oa_prod_vYo3mAI9TmJuo2_ukYlHVZMh3OiszmfQdgVqk_gLSkU + + Default is "https://example.com". + save_location : str | None, optional + The location to save the JWT assertion. + If None, the assertion will not be saved. + Default is "client_assertion.jwt". + + Returns + ------- + str + The JWT assertion string. + """ + + # Remove the https:// from the issuer_url if it exists + issuer_url = issuer_url.replace("https://", "") + + # Convert the expiration_dt to a UNIX timestamp if it is not already + expiration_dt = to_datetime(dt=expiration_dt) + expiration_ts = expiration_dt.int_timestamp + + # Create the JWT payload + payload = { + "iss": issuer_url, # The URL of the client + "sub": client_id, # The client ID + "aud": "https://revolut.com", # The URL of the authorization server + "exp": expiration_ts, # UNIX timestamp (integer) + } + + # Create the JWT assertion using the private key + assertion_string = jwt.encode(payload=payload, key=private_credentials_key, algorithm="RS256") + + # Save the JWT assertion to a file if save_location is provided + if save_location is not None: + with open(save_location, "w") as f: + f.write(assertion_string) + + # Return the JWT assertion + return assertion_string diff --git a/pyrevolut/utils/auth/creds.py b/pyrevolut/utils/auth/creds.py new file mode 100644 index 0000000..59cb3f1 --- /dev/null +++ b/pyrevolut/utils/auth/creds.py @@ -0,0 +1,137 @@ +from typing import Annotated +import json + +import pendulum +from pydantic import BaseModel, Field, SecretStr, field_serializer + +from pyrevolut.utils.datetime import DateTime + + +class ModelCreds(BaseModel): + """The model that represents the credentials JSON file.""" + + class ModelCertificate(BaseModel): + """The model that represents the certificate information""" + + public: Annotated[ + SecretStr, Field(description="The public certificate in base64 encoded format") + ] + private: Annotated[ + SecretStr, Field(description="The private certificate in base64 encoded format") + ] + expiration_dt: Annotated[ + DateTime, Field(description="The expiration datetime of the certificates") + ] + + @field_serializer("public", "private", when_used="json") + def dump_secret(self, value: SecretStr) -> str: + """Serialize the secret value to a string""" + return value.get_secret_value() + + class ModelClientAssertJWT(BaseModel): + """The model that represents the client assertion JWT information""" + + jwt: Annotated[SecretStr, Field(description="The JWT assertion string")] + expiration_dt: Annotated[DateTime, Field(description="The expiration datetime of the JWT")] + + @field_serializer("jwt", when_used="json") + def dump_secret(self, value: SecretStr) -> str: + """Serialize the secret value to a string""" + return value.get_secret_value() + + class ModelTokens(BaseModel): + """The model that represents the tokens information""" + + access_token: Annotated[SecretStr, Field(description="The access token")] + refresh_token: Annotated[SecretStr, Field(description="The refresh token")] + token_type: Annotated[str, Field(description="The token type")] + access_token_expiration_dt: Annotated[ + DateTime, Field(description="The expiration datetime of the access token") + ] + refresh_token_expiration_dt: Annotated[ + DateTime, Field(description="The expiration datetime of the refresh token") + ] + + @field_serializer("access_token", "refresh_token", when_used="json") + def dump_secret(self, value: SecretStr) -> str: + """Serialize the secret value to a string""" + return value.get_secret_value() + + certificate: Annotated[ModelCertificate, Field(description="The certificate information")] + client_assert_jwt: Annotated[ + ModelClientAssertJWT, Field(description="The client assertion JWT information") + ] + tokens: Annotated[ModelTokens, Field(description="The tokens information")] + + @property + def access_token_expired(self) -> bool: + """Check if the access token has expired. + This means that the token is no longer valid and a new one should be requested. + + Returns + ------- + bool + """ + # Subtract 1 minute to ensure that the token still works for a little bit + return self.tokens.access_token_expiration_dt.subtract(minutes=1) < pendulum.now(tz="UTC") + + @property + def credentials_expired(self) -> bool: + """Check if any of the long term credentials have expired. + This means that the certificate, client assertion JWT, or refresh token has expired and + a new one should be requested. + + Returns + ------- + bool + True if any of the credentials have expired, False otherwise. + """ + dt_now = pendulum.now(tz="UTC") + if self.certificate.expiration_dt < dt_now: + return True + if self.client_assert_jwt.expiration_dt < dt_now: + return True + if self.tokens.refresh_token_expiration_dt < dt_now: + return True + return False + + +def save_creds( + creds: ModelCreds, + location: str = "credentials.json", + indent: int = 4, +): + """Save the credentials to the provided location. + + Parameters + ---------- + creds : ModelCreds + The credentials model + location : str, optional + The location to save the credentials to, by default "credentials.json" + indent : int, optional + The indentation level to use, by default 4 + + Returns + ------- + None + """ + with open(location, "w") as file: + json.dump(creds.model_dump(mode="json"), file, indent=indent) + + +def load_creds(location: str = "credentials.json") -> ModelCreds: + """Load the credentials from the provided location. + + Parameters + ---------- + location : str, optional + The location to load the credentials from, by default "credentials.json" + + Returns + ------- + ModelCreds + The credentials model + """ + with open(location, "r") as file: + return ModelCreds(**json.load(file)) diff --git a/pyrevolut/utils/auth/enum_auth_scope.py b/pyrevolut/utils/auth/enum_auth_scope.py new file mode 100644 index 0000000..7abce5e --- /dev/null +++ b/pyrevolut/utils/auth/enum_auth_scope.py @@ -0,0 +1,28 @@ +from enum import StrEnum + + +class EnumAuthScope(StrEnum): + """Enum class for the scopes + + Access tokens can be issued with four security scopes and require a JWT (JSON Web Token) + signature to be obtained: + + READ: Permissions for GET operations. + + WRITE: Permissions to update counterparties, webhooks, and issue payment drafts. + + PAY: Permissions to initiate or cancel transactions and currency exchanges. + + READ_SENSITIVE_CARD_DATA: Permissions to retrieve sensitive card details. + + Caution + ------- + If you enable the READ_SENSITIVE_CARD_DATA scope for your access token, you must + set up IP whitelisting. + Failing to do so will prevent you from accessing any Business API endpoint. + """ + + READ = "READ" + WRITE = "WRITE" + PAY = "PAY" + READ_SENSITIVE_CARD_DATA = "READ_SENSITIVE_CARD_DATA" diff --git a/pyrevolut/utils/auth/gen_public_private_cert.py b/pyrevolut/utils/auth/gen_public_private_cert.py new file mode 100644 index 0000000..67dc082 --- /dev/null +++ b/pyrevolut/utils/auth/gen_public_private_cert.py @@ -0,0 +1,114 @@ +from datetime import datetime +import pendulum + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID + +from pyrevolut.utils.datetime import DateTime, to_datetime + + +def gen_public_private_cert( + expiration_dt: datetime | DateTime | str | int | float, + country: str, + email_address: str | None = None, + common_name: str | None = None, + state: str | None = None, + locality: str | None = None, + organization: str | None = None, + organization_unit: str | None = None, + save_location_public: str | None = "publiccert.cer", + save_location_private: str | None = "privatecert.pem", +): + """ + Method to generate a X509 RSA key pair and save it to a file. + The key pair is used to create a client-assertion JWT for Revolut. + + Parameters + ---------- + expiration_dt : datetime | DateTime | str | int | float + The expiration datetime (UTC) of the certificate. + This can be a datetime object, a DateTime object, a string, an integer, or a float. + country : str + The country of the certificate (2-letter code) + email_address : str | None, optional + The email address of the certificate. + common_name : str | None, optional + The common name (eg, fully qualified host name) of the certificate. + state : str | None, optional + The state (full name) of the certificate. + locality : str | None, optional + The locality (eg, city) of the certificate. + organization : str | None, optional + The organization (eg, company) of the certificate. + organization_unit : str | None, optional + The organization unit (eg, section) of the certificate. + save_location_public : str | None, optional + The location to save the public key. + If None, the public key will not be saved. + Default is "publiccert.cer". + save_location_private : str | None, optional + The location to save the private key. + If None, the private key will not be saved. + Default is "privatecert.pem". + + Returns + ------- + dict + A dictionary containing the public and private keys (in bytes) + """ + # Generate an RSA key pair + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + + # Create a certificate + names = [x509.NameAttribute(NameOID.COUNTRY_NAME, country)] + if email_address is not None: + names.append(x509.NameAttribute(NameOID.EMAIL_ADDRESS, email_address)) + if common_name is not None: + names.append(x509.NameAttribute(NameOID.COMMON_NAME, common_name)) + if state is not None: + names.append(x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, state)) + if locality is not None: + names.append(x509.NameAttribute(NameOID.LOCALITY_NAME, locality)) + if organization is not None: + names.append(x509.NameAttribute(NameOID.ORGANIZATION_NAME, organization)) + if organization_unit is not None: + names.append(x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, organization_unit)) + subject = issuer = x509.Name(names) + + # Create a self-signed certificate + cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(private_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(pendulum.now(tz="UTC")) + .not_valid_after(to_datetime(dt=expiration_dt)) + .sign(private_key, hashes.SHA256()) + ) + + # Get the public/private key in bytes + public_key = cert.public_bytes(serialization.Encoding.PEM) + private_key = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + + # Save the private key to a file if save_location is provided + if save_location_private is not None: + with open(save_location_private, "wb") as f: + f.write(private_key) + + # Save the public key to a file if save_location is provided + if save_location_public is not None: + with open(save_location_public, "wb") as f: + f.write(public_key) + + # Return the keys + return { + "public": public_key, + "private": private_key, + } diff --git a/pyrevolut/utils/auth/get_auth_tokens.py b/pyrevolut/utils/auth/get_auth_tokens.py new file mode 100644 index 0000000..1cea5e4 --- /dev/null +++ b/pyrevolut/utils/auth/get_auth_tokens.py @@ -0,0 +1,127 @@ +from typing import Annotated + +from httpx import Client, AsyncClient +from pydantic import BaseModel, Field, SecretStr + + +class ModelGetAuthTokensResponse(BaseModel): + """The model that represents the response from the get auth tokens endpoint.""" + + access_token: Annotated[SecretStr, Field(description="The access token")] + refresh_token: Annotated[SecretStr, Field(description="The refresh token")] + token_type: Annotated[str, Field(description="The token type")] + expires_in: Annotated[int, Field(description="The expiration time in seconds")] + + +def get_auth_tokens( + client: Client, + auth_code: str, + client_assert_jwt: str, + sandbox: bool = True, +): + """ + Method to get the access and refresh token from the Revolut API. + + Parameters + ---------- + client : Client + The HTTPX client. + auth_code : str + The authorization code. + client_assert_jwt : str + The client assertion JWT. + sandbox : bool, optional + Whether to use the sandbox environment. + Default is True. + + Returns + ------- + dict + The access and refresh token response. + """ + response = client.post( + **prep_get_auth_tokens( + auth_code=auth_code, + client_assert_jwt=client_assert_jwt, + sandbox=sandbox, + ), + ) + return ModelGetAuthTokensResponse(**response.json()) + + +async def aget_auth_tokens( + client: AsyncClient, + auth_code: str, + client_assert_jwt: str, + sandbox: bool = True, +): + """ + Method to get the access and refresh token from the Revolut API. + + Parameters + ---------- + client : Client + The async HTTPX client. + auth_code : str + The authorization code. + client_assert_jwt : str + The client assertion JWT. + sandbox : bool, optional + Whether to use the sandbox environment. + Default is True. + + Returns + ------- + dict + The access and refresh token response. + """ + response = await client.post( + **prep_get_auth_tokens( + auth_code=auth_code, + client_assert_jwt=client_assert_jwt, + sandbox=sandbox, + ), + ) + return ModelGetAuthTokensResponse(**response.json()) + + +def prep_get_auth_tokens( + auth_code: str, + client_assert_jwt: str, + sandbox: bool = True, +): + """ + Method to prepare the arguments for getting the auth token functions. + + Parameters + ---------- + auth_code : str + The authorization code. + client_assert_jwt : str + The client assertion JWT. + sandbox : bool, optional + Whether to use the sandbox environment. + Default is True. + + Returns + ------- + dict + The arguments to be passed to the HTTPX client POST method. + """ + if sandbox: + url = "https://sandbox-b2b.revolut.com/api/1.0/auth/token" + else: + url = "https://b2b.revolut.com/api/1.0/auth/token" + headers = {"Content-Type": "application/x-www-form-urlencoded"} + data = { + "grant_type": "authorization_code", + "code": auth_code, + "client_id": "client_id", + "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + "client_assertion": client_assert_jwt, + } + return { + "url": url, + "headers": headers, + "data": data, + } diff --git a/pyrevolut/utils/auth/refresh_access_token.py b/pyrevolut/utils/auth/refresh_access_token.py new file mode 100644 index 0000000..6bb62bd --- /dev/null +++ b/pyrevolut/utils/auth/refresh_access_token.py @@ -0,0 +1,125 @@ +from typing import Annotated + +from httpx import Client, AsyncClient +from pydantic import BaseModel, Field, SecretStr + + +class ModelRefreshAccessTokenResponse(BaseModel): + """The model that represents the response from the refresh access token endpoint.""" + + access_token: Annotated[SecretStr, Field(description="The access token")] + token_type: Annotated[str, Field(description="The token type")] + expires_in: Annotated[int, Field(description="The expiration time in seconds")] + + +def refresh_access_token( + client: Client, + refresh_token: str, + client_assert_jwt: str, + sandbox: bool = True, +): + """ + Method to get a new access token via the refresh token. + + Parameters + ---------- + client : Client + The HTTPX client. + refresh_token : str + The refresh token. + client_assert_jwt : str + The client assertion JWT. + sandbox : bool, optional + Whether to use the sandbox environment. + Default is True. + + Returns + ------- + dict + The new access token response. + """ + response = client.post( + **prep_refresh_access_token( + refresh_token=refresh_token, + client_assert_jwt=client_assert_jwt, + sandbox=sandbox, + ) + ) + return ModelRefreshAccessTokenResponse(**response.json()) + + +async def arefresh_access_token( + client: AsyncClient, + refresh_token: str, + client_assert_jwt: str, + sandbox: bool = True, +): + """ + Method to get a new access token via the refresh token. + + Parameters + ---------- + client : Client + The HTTPX client. + refresh_token : str + The refresh token. + client_assert_jwt : str + The client assertion JWT. + sandbox : bool, optional + Whether to use the sandbox environment. + Default is True. + + Returns + ------- + dict + The new access token response. + """ + response = await client.post( + **prep_refresh_access_token( + refresh_token=refresh_token, + client_assert_jwt=client_assert_jwt, + sandbox=sandbox, + ) + ) + return ModelRefreshAccessTokenResponse(**response.json()) + + +def prep_refresh_access_token( + refresh_token: str, + client_assert_jwt: str, + sandbox: bool = True, +): + """ + Method to prepare the arguments for refreshing the access token functions. + + Parameters + ---------- + refresh_token : str + The refresh token. + client_assert_jwt : str + The client assertion JWT. + sandbox : bool, optional + Whether to use the sandbox environment. + Default is True. + + Returns + ------- + dict + The arguments to be passed to the HTTPX client POST method. + """ + if sandbox: + url = "https://sandbox-b2b.revolut.com/api/1.0/auth/token" + else: + url = "https://b2b.revolut.com/api/1.0/auth/token" + headers = {"Content-Type": "application/x-www-form-urlencoded"} + data = { + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + "client_assertion": client_assert_jwt, + } + return { + "url": url, + "headers": headers, + "data": data, + } diff --git a/tests/conftest.py b/tests/conftest.py index 5ed138e..087b916 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,7 @@ import pytest import pytest_asyncio -from pyrevolut.client import Client, Environment +from pyrevolut.client import Client, AsyncClient """ Pytest Fixture Scopes @@ -14,8 +14,7 @@ 5. session: the fixture is destroyed at the end of the test session. """ -ACCESS_TOKEN = "TO BE FILLED" -REFRESH_TOKEN = "TO BE FILLED" +CREDENTIALS_LOC = "tests/test_creds.json" @pytest.fixture(scope="session", autouse=True) @@ -29,8 +28,8 @@ def event_loop(): @pytest.fixture(scope="session") -def base_client(): - """Context manager that initializes the client +def base_sync_client(): + """Context manager that initializes the sync client Yields ------ @@ -38,9 +37,8 @@ def base_client(): """ # Initialize the client client = Client( - access_token=ACCESS_TOKEN, - refresh_token=REFRESH_TOKEN, - environment=Environment.SANDBOX, + creds_loc=CREDENTIALS_LOC, + sandbox=True, ) # Yield for test @@ -48,7 +46,25 @@ def base_client(): @pytest.fixture(scope="session") -def sync_client(base_client: Client): +def base_async_client(): + """Context manager that initializes the async client + + Yields + ------ + None + """ + # Initialize the client + client = AsyncClient( + creds_loc=CREDENTIALS_LOC, + sandbox=True, + ) + + # Yield for test + yield client + + +@pytest.fixture(scope="session") +def sync_client(base_sync_client: Client): """Context manager that initializes the sync client Parameters @@ -62,34 +78,34 @@ def sync_client(base_client: Client): The client to use for the endpoint """ # Initialize the sync client - base_client.open() + base_sync_client.open() # Yield for test - yield base_client + yield base_sync_client # Close the sync client - base_client.close() + base_sync_client.close() @pytest_asyncio.fixture(scope="session") -async def async_client(base_client: Client): +async def async_client(base_async_client: AsyncClient): """Context manager that initializes the async client Parameters ---------- - base_client : Client - The client to use for the endpoint + base_async_client : AsyncClient + The async client to use for the endpoint Yields ------ - Client - The client to use for the endpoint + AsyncClient + The async client to use for the endpoint """ # Initialize the async client - await base_client.aopen() + await base_async_client.open() # Yield for test - yield base_client + yield base_async_client # Close the async client - await base_client.aclose() + await base_async_client.close() diff --git a/tests/test_accounts.py b/tests/test_accounts.py new file mode 100644 index 0000000..5d29ad5 --- /dev/null +++ b/tests/test_accounts.py @@ -0,0 +1,22 @@ +import pytest + +from pyrevolut.client import Client, AsyncClient + + +@pytest.mark.asyncio +async def test_async_get_all_accounts(async_client: AsyncClient): + """Test the async `get_all_accounts` accounts method""" + # Get Accounts + accounts_all = await async_client.Accounts.get_all_accounts() + assert isinstance(accounts_all, list) + for account in accounts_all: + assert isinstance(account, dict) + + +def test_sync_get_all_accounts(sync_client: Client): + """Test the sync `get_all_accounts` accounts method""" + # Get Accounts + accounts_all = sync_client.Accounts.get_all_accounts() + assert isinstance(accounts_all, list) + for account in accounts_all: + assert isinstance(account, dict)