├── .github ├── FUNDING.yml ├── stale.yml └── workflows │ ├── docker-latest.yml │ ├── python-app.yml │ └── python-publish.yml ├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── Makefile ├── Pipfile ├── Pipfile.lock ├── README.md ├── example.py ├── n26.tml.example ├── n26.yml.example ├── n26 ├── __init__.py ├── __main__.py ├── api.py ├── cli.py ├── config.py ├── const.py └── util.py ├── requirements.txt ├── setup.cfg ├── setup.py └── tests ├── .gitkeep ├── __init__.py ├── api_responses ├── account_info.json ├── account_limits.json ├── account_statuses.json ├── addresses.json ├── auth_token.json ├── balance.json ├── card_block_single.json ├── card_unblock_single.json ├── cards.json ├── contacts.json ├── refresh_token.json ├── spaces.json ├── standing_orders.json ├── statement.pdf ├── statements.json ├── statistics.json └── transactions.json ├── test_account.py ├── test_api.py ├── test_api_base.py ├── test_balance.py ├── test_cards.py ├── test_creds.yml ├── test_spaces.py ├── test_standing_orders.py ├── test_statistics.py └── test_transactions.py /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [markusressel] -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Configuration for probot-stale - https://github.com/probot/stale 2 | 3 | # Number of days of inactivity before an Issue or Pull Request becomes stale 4 | daysUntilStale: 30 5 | 6 | # Number of days of inactivity before an Issue or Pull Request with the stale label is closed. 7 | # Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale. 8 | daysUntilClose: 7 9 | 10 | # Only issues or pull requests with all of these labels are check if stale. Defaults to `[]` (disabled) 11 | onlyLabels: [] 12 | 13 | # Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable 14 | exemptLabels: 15 | - pinned 16 | - security 17 | - "[Status] Maybe Later" 18 | 19 | # Set to true to ignore issues in a project (defaults to false) 20 | exemptProjects: false 21 | 22 | # Set to true to ignore issues in a milestone (defaults to false) 23 | exemptMilestones: false 24 | 25 | # Set to true to ignore issues with an assignee (defaults to false) 26 | exemptAssignees: false 27 | 28 | # Label to use when marking as stale 29 | staleLabel: stale 30 | 31 | # Comment to post when marking as stale. Set to `false` to disable 32 | markComment: > 33 | This issue has been automatically marked as stale because it has not had 34 | recent activity. It will be closed if no further activity occurs. Thank you 35 | for your contributions. 36 | 37 | # Comment to post when removing the stale label. 38 | # unmarkComment: > 39 | # Your comment here. 40 | 41 | # Comment to post when closing a stale Issue or Pull Request. 42 | # closeComment: > 43 | # Your comment here. 44 | 45 | # Limit the number of actions per hour, from 1-30. Default is 30 46 | limitPerRun: 30 47 | 48 | # Limit to only `issues` or `pulls` 49 | # only: issues 50 | 51 | # Optionally, specify configuration settings that are specific to just 'issues' or 'pulls': 52 | # pulls: 53 | # daysUntilStale: 30 54 | # markComment: > 55 | # This pull request has been automatically marked as stale because it has not had 56 | # recent activity. It will be closed if no further activity occurs. Thank you 57 | # for your contributions. 58 | 59 | # issues: 60 | # exemptLabels: 61 | # - confirmed 62 | -------------------------------------------------------------------------------- /.github/workflows/docker-latest.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image latest 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | 7 | jobs: 8 | 9 | dockerhub: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Build the Docker image 14 | run: docker build . --file Dockerfile --tag femueller/python-n26:latest 15 | -------------------------------------------------------------------------------- /.github/workflows/python-app.yml: -------------------------------------------------------------------------------- 1 | name: Python application build&test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | matrix: 12 | # Supported Python versions according to https://devguide.python.org/versions/ 13 | python-version: [ "3.7", "3.8", "3.9", "3.10", "3.11" ] 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Set up Python 17 | uses: actions/setup-python@v2 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install flake8 pytest pytest-cov 24 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 25 | - name: Lint with flake8 26 | run: | 27 | # stop the build if there are Python syntax errors or undefined names 28 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 29 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 30 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 31 | - name: Test with pytest 32 | run: | 33 | pytest tests/ --doctest-modules --junitxml=junit/test-results.xml --cov=com --cov-report=xml --cov-report=html 34 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | name: Python package upload 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions/setup-python@v4 15 | - name: Install dependencies 16 | run: | 17 | python -m pip install --upgrade pip 18 | pip install setuptools wheel twine 19 | - name: Build and publish 20 | env: 21 | TWINE_USERNAME: __token__ 22 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 23 | run: | 24 | python setup.py sdist bdist_wheel 25 | twine upload dist/* 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | __pycache__ 3 | .DS_Store 4 | *.pyc 5 | venv/ 6 | MANIFEST 7 | *.egg-info 8 | dist/ 9 | *.sublime-workspace 10 | *.sublime-project 11 | .idea -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### 0.2.2 (10-03-2019) 2 | 3 | FIXES: 4 | 5 | * [Reworked token authentication](https://github.com/femueller/python-n26/pull/15) 6 | 7 | ## 0.2.1 (09-03-2019) 8 | 9 | FEATURES: 10 | 11 | * [Add unit](https://github.com/femueller/python-n26/pull/8) [and API tests](https://github.com/femueller/python-n26/pull/11) 12 | * [Add basic spaces support](https://github.com/femueller/python-n26/pull/13) 13 | * [Add many missing API methods](https://github.com/femueller/python-n26/pull/14) 14 | 15 | ## 0.1.4 (06-08-2018) 16 | 17 | BUG FIXES: 18 | 19 | * Fix [Typo for unlimited transaction call](https://github.com/femueller/python-n26/issues/7) -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Docker image for n26 2 | 3 | # dont use alpine for python builds: https://pythonspeed.com/articles/alpine-docker-python/ 4 | FROM python:3.11-slim-buster 5 | 6 | WORKDIR /app 7 | 8 | COPY . . 9 | 10 | RUN apt-get update \ 11 | && apt-get -y install sudo python3-pip \ 12 | && apt-get clean && rm -rf /var/lib/apt/lists/* 13 | RUN pip install --upgrade pip;\ 14 | pip install pipenv;\ 15 | PIP_IGNORE_INSTALLED=1 pipenv install --system --deploy;\ 16 | pip install . 17 | 18 | ENTRYPOINT [ "n26" ] 19 | CMD [ "-h" ] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Felix Mueller 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include requirements.txt 2 | recursive-exclude tests * -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PROJECT = "n26" 2 | 3 | current-version: 4 | @echo "Current version is `cat ${PROJECT}/__init__.py | grep '__version__' | cut -d ' ' -f3 | sed s/\'//g`" 5 | 6 | build: 7 | git stash 8 | python setup.py sdist 9 | - git stash pop 10 | 11 | test: 12 | pipenv run pytest 13 | 14 | upload: 15 | # Upload to PyPI: https://pypi.org/project/n26/ 16 | python setup.py sdist upload -r pypi 17 | 18 | git-release: 19 | git add ${PROJECT}/__init__.py 20 | git commit -m "Bumped version to `cat ${PROJECT}/__init__.py | grep '__version__' | cut -d ' ' -f3 | sed s/\'//g`" 21 | git tag `cat ${PROJECT}/__init__.py | grep '__version__' | cut -d ' ' -f3 | sed s/\'//g` 22 | git push 23 | git push --tags 24 | 25 | _release-patch: 26 | @echo "version = \"`cat ${PROJECT}/__init__.py | awk -F '("|")' '{ print($$2)}' | awk -F. '{$$NF = $$NF + 1;} 1' | sed 's/ /./g'`\"" > ${PROJECT}/__init__.py 27 | release-patch: _release-patch git-release build upload current-version 28 | 29 | _release-minor: 30 | @echo "version = \"`cat ${PROJECT}/__init__.py | awk -F '("|")' '{ print($$2)}' | awk -F. '{$$(NF-1) = $$(NF-1) + 1;} 1' | sed 's/ /./g' | awk -F. '{$$(NF) = 0;} 1' | sed 's/ /./g' `\"" > ${PROJECT}/__init__.py 31 | release-minor: _release-minor git-release build upload current-version 32 | 33 | _release-major: 34 | @echo "version = \"`cat ${PROJECT}/__init__.py | awk -F '("|")' '{ print($$2)}' | awk -F. '{$$(NF-2) = $$(NF-2) + 1;} 1' | sed 's/ /./g' | awk -F. '{$$(NF-1) = 0;} 1' | sed 's/ /./g' | awk -F. '{$$(NF) = 0;} 1' | sed 's/ /./g' `\"" > ${PROJECT}/__init__.py 35 | release-major: _release-major git-release build upload current-version 36 | 37 | release: release-patch 38 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | requests = "~=2.31" 8 | click = "*" 9 | tabulate = ">=0.9.0" 10 | PyYAML = "*" 11 | inflect = "*" 12 | urllib3 = "~=1.26" 13 | tenacity = "~=8.1" 14 | container-app-conf = "~=5.2" 15 | pycryptodome = ">=3.9" 16 | 17 | [dev-packages] 18 | pytest = "*" 19 | mock = "*" 20 | flake8 = "*" 21 | 22 | [requires] 23 | python_version = "3.11" 24 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "e8ae48b288d4e87f4590ad4d99e28828b537f6a593fd7a90f92cd0c389c0c573" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.10" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "certifi": { 20 | "hashes": [ 21 | "sha256:0f0d56dc5a6ad56fd4ba36484d6cc34451e1c6548c61daad8c320169f91eddc7", 22 | "sha256:c6c2e98f5c7869efca1f8916fed228dd91539f9f1b444c314c06eef02980c716" 23 | ], 24 | "markers": "python_version >= '3.6'", 25 | "version": "==2023.5.7" 26 | }, 27 | "charset-normalizer": { 28 | "hashes": [ 29 | "sha256:04afa6387e2b282cf78ff3dbce20f0cc071c12dc8f685bd40960cc68644cfea6", 30 | "sha256:04eefcee095f58eaabe6dc3cc2262f3bcd776d2c67005880894f447b3f2cb9c1", 31 | "sha256:0be65ccf618c1e7ac9b849c315cc2e8a8751d9cfdaa43027d4f6624bd587ab7e", 32 | "sha256:0c95f12b74681e9ae127728f7e5409cbbef9cd914d5896ef238cc779b8152373", 33 | "sha256:0ca564606d2caafb0abe6d1b5311c2649e8071eb241b2d64e75a0d0065107e62", 34 | "sha256:10c93628d7497c81686e8e5e557aafa78f230cd9e77dd0c40032ef90c18f2230", 35 | "sha256:11d117e6c63e8f495412d37e7dc2e2fff09c34b2d09dbe2bee3c6229577818be", 36 | "sha256:11d3bcb7be35e7b1bba2c23beedac81ee893ac9871d0ba79effc7fc01167db6c", 37 | "sha256:12a2b561af122e3d94cdb97fe6fb2bb2b82cef0cdca131646fdb940a1eda04f0", 38 | "sha256:12d1a39aa6b8c6f6248bb54550efcc1c38ce0d8096a146638fd4738e42284448", 39 | "sha256:1435ae15108b1cb6fffbcea2af3d468683b7afed0169ad718451f8db5d1aff6f", 40 | "sha256:1c60b9c202d00052183c9be85e5eaf18a4ada0a47d188a83c8f5c5b23252f649", 41 | "sha256:1e8fcdd8f672a1c4fc8d0bd3a2b576b152d2a349782d1eb0f6b8e52e9954731d", 42 | "sha256:20064ead0717cf9a73a6d1e779b23d149b53daf971169289ed2ed43a71e8d3b0", 43 | "sha256:21fa558996782fc226b529fdd2ed7866c2c6ec91cee82735c98a197fae39f706", 44 | "sha256:22908891a380d50738e1f978667536f6c6b526a2064156203d418f4856d6e86a", 45 | "sha256:3160a0fd9754aab7d47f95a6b63ab355388d890163eb03b2d2b87ab0a30cfa59", 46 | "sha256:322102cdf1ab682ecc7d9b1c5eed4ec59657a65e1c146a0da342b78f4112db23", 47 | "sha256:34e0a2f9c370eb95597aae63bf85eb5e96826d81e3dcf88b8886012906f509b5", 48 | "sha256:3573d376454d956553c356df45bb824262c397c6e26ce43e8203c4c540ee0acb", 49 | "sha256:3747443b6a904001473370d7810aa19c3a180ccd52a7157aacc264a5ac79265e", 50 | "sha256:38e812a197bf8e71a59fe55b757a84c1f946d0ac114acafaafaf21667a7e169e", 51 | "sha256:3a06f32c9634a8705f4ca9946d667609f52cf130d5548881401f1eb2c39b1e2c", 52 | "sha256:3a5fc78f9e3f501a1614a98f7c54d3969f3ad9bba8ba3d9b438c3bc5d047dd28", 53 | "sha256:3d9098b479e78c85080c98e1e35ff40b4a31d8953102bb0fd7d1b6f8a2111a3d", 54 | "sha256:3dc5b6a8ecfdc5748a7e429782598e4f17ef378e3e272eeb1340ea57c9109f41", 55 | "sha256:4155b51ae05ed47199dc5b2a4e62abccb274cee6b01da5b895099b61b1982974", 56 | "sha256:49919f8400b5e49e961f320c735388ee686a62327e773fa5b3ce6721f7e785ce", 57 | "sha256:53d0a3fa5f8af98a1e261de6a3943ca631c526635eb5817a87a59d9a57ebf48f", 58 | "sha256:5f008525e02908b20e04707a4f704cd286d94718f48bb33edddc7d7b584dddc1", 59 | "sha256:628c985afb2c7d27a4800bfb609e03985aaecb42f955049957814e0491d4006d", 60 | "sha256:65ed923f84a6844de5fd29726b888e58c62820e0769b76565480e1fdc3d062f8", 61 | "sha256:6734e606355834f13445b6adc38b53c0fd45f1a56a9ba06c2058f86893ae8017", 62 | "sha256:6baf0baf0d5d265fa7944feb9f7451cc316bfe30e8df1a61b1bb08577c554f31", 63 | "sha256:6f4f4668e1831850ebcc2fd0b1cd11721947b6dc7c00bf1c6bd3c929ae14f2c7", 64 | "sha256:6f5c2e7bc8a4bf7c426599765b1bd33217ec84023033672c1e9a8b35eaeaaaf8", 65 | "sha256:6f6c7a8a57e9405cad7485f4c9d3172ae486cfef1344b5ddd8e5239582d7355e", 66 | "sha256:7381c66e0561c5757ffe616af869b916c8b4e42b367ab29fedc98481d1e74e14", 67 | "sha256:73dc03a6a7e30b7edc5b01b601e53e7fc924b04e1835e8e407c12c037e81adbd", 68 | "sha256:74db0052d985cf37fa111828d0dd230776ac99c740e1a758ad99094be4f1803d", 69 | "sha256:75f2568b4189dda1c567339b48cba4ac7384accb9c2a7ed655cd86b04055c795", 70 | "sha256:78cacd03e79d009d95635e7d6ff12c21eb89b894c354bd2b2ed0b4763373693b", 71 | "sha256:80d1543d58bd3d6c271b66abf454d437a438dff01c3e62fdbcd68f2a11310d4b", 72 | "sha256:830d2948a5ec37c386d3170c483063798d7879037492540f10a475e3fd6f244b", 73 | "sha256:891cf9b48776b5c61c700b55a598621fdb7b1e301a550365571e9624f270c203", 74 | "sha256:8f25e17ab3039b05f762b0a55ae0b3632b2e073d9c8fc88e89aca31a6198e88f", 75 | "sha256:9a3267620866c9d17b959a84dd0bd2d45719b817245e49371ead79ed4f710d19", 76 | "sha256:a04f86f41a8916fe45ac5024ec477f41f886b3c435da2d4e3d2709b22ab02af1", 77 | "sha256:aaf53a6cebad0eae578f062c7d462155eada9c172bd8c4d250b8c1d8eb7f916a", 78 | "sha256:abc1185d79f47c0a7aaf7e2412a0eb2c03b724581139193d2d82b3ad8cbb00ac", 79 | "sha256:ac0aa6cd53ab9a31d397f8303f92c42f534693528fafbdb997c82bae6e477ad9", 80 | "sha256:ac3775e3311661d4adace3697a52ac0bab17edd166087d493b52d4f4f553f9f0", 81 | "sha256:b06f0d3bf045158d2fb8837c5785fe9ff9b8c93358be64461a1089f5da983137", 82 | "sha256:b116502087ce8a6b7a5f1814568ccbd0e9f6cfd99948aa59b0e241dc57cf739f", 83 | "sha256:b82fab78e0b1329e183a65260581de4375f619167478dddab510c6c6fb04d9b6", 84 | "sha256:bd7163182133c0c7701b25e604cf1611c0d87712e56e88e7ee5d72deab3e76b5", 85 | "sha256:c36bcbc0d5174a80d6cccf43a0ecaca44e81d25be4b7f90f0ed7bcfbb5a00909", 86 | "sha256:c3af8e0f07399d3176b179f2e2634c3ce9c1301379a6b8c9c9aeecd481da494f", 87 | "sha256:c84132a54c750fda57729d1e2599bb598f5fa0344085dbde5003ba429a4798c0", 88 | "sha256:cb7b2ab0188829593b9de646545175547a70d9a6e2b63bf2cd87a0a391599324", 89 | "sha256:cca4def576f47a09a943666b8f829606bcb17e2bc2d5911a46c8f8da45f56755", 90 | "sha256:cf6511efa4801b9b38dc5546d7547d5b5c6ef4b081c60b23e4d941d0eba9cbeb", 91 | "sha256:d16fd5252f883eb074ca55cb622bc0bee49b979ae4e8639fff6ca3ff44f9f854", 92 | "sha256:d2686f91611f9e17f4548dbf050e75b079bbc2a82be565832bc8ea9047b61c8c", 93 | "sha256:d7fc3fca01da18fbabe4625d64bb612b533533ed10045a2ac3dd194bfa656b60", 94 | "sha256:dd5653e67b149503c68c4018bf07e42eeed6b4e956b24c00ccdf93ac79cdff84", 95 | "sha256:de5695a6f1d8340b12a5d6d4484290ee74d61e467c39ff03b39e30df62cf83a0", 96 | "sha256:e0ac8959c929593fee38da1c2b64ee9778733cdf03c482c9ff1d508b6b593b2b", 97 | "sha256:e1b25e3ad6c909f398df8921780d6a3d120d8c09466720226fc621605b6f92b1", 98 | "sha256:e633940f28c1e913615fd624fcdd72fdba807bf53ea6925d6a588e84e1151531", 99 | "sha256:e89df2958e5159b811af9ff0f92614dabf4ff617c03a4c1c6ff53bf1c399e0e1", 100 | "sha256:ea9f9c6034ea2d93d9147818f17c2a0860d41b71c38b9ce4d55f21b6f9165a11", 101 | "sha256:f645caaf0008bacf349875a974220f1f1da349c5dbe7c4ec93048cdc785a3326", 102 | "sha256:f8303414c7b03f794347ad062c0516cee0e15f7a612abd0ce1e25caf6ceb47df", 103 | "sha256:fca62a8301b605b954ad2e9c3666f9d97f63872aa4efcae5492baca2056b74ab" 104 | ], 105 | "markers": "python_version >= '3.7'", 106 | "version": "==3.1.0" 107 | }, 108 | "click": { 109 | "hashes": [ 110 | "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e", 111 | "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48" 112 | ], 113 | "index": "pypi", 114 | "version": "==8.1.3" 115 | }, 116 | "container-app-conf": { 117 | "hashes": [ 118 | "sha256:50e20bd0f8f124391769831272748a00f135dbb68592813fbb0c11ba6c437dd6", 119 | "sha256:7b31a0f0501bf489dcab9a08df119ac63f387c49be65ed5a0914b6492d6f5bfd" 120 | ], 121 | "index": "pypi", 122 | "version": "==5.2.2" 123 | }, 124 | "idna": { 125 | "hashes": [ 126 | "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4", 127 | "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2" 128 | ], 129 | "markers": "python_version >= '3.5'", 130 | "version": "==3.4" 131 | }, 132 | "inflect": { 133 | "hashes": [ 134 | "sha256:182741ec7e9e4c8f7f55b01fa6d80bcd3c4a183d349dfa6d9abbff0a1279e98f", 135 | "sha256:f1a6bcb0105046f89619fde1a7d044c612c614c2d85ef182582d9dc9b86d309a" 136 | ], 137 | "index": "pypi", 138 | "version": "==6.0.2" 139 | }, 140 | "py-range-parse": { 141 | "hashes": [ 142 | "sha256:15cf56ab4483814162f57f3b2bfd3ae68f6c4ea4cd7e710c881a5ce97f516c2c", 143 | "sha256:a9d6b66f8e10dd26f5ff9726daef5b70bb32368a00a4563bd65d062c73d7c979" 144 | ], 145 | "version": "==1.0.5" 146 | }, 147 | "pycryptodome": { 148 | "hashes": [ 149 | "sha256:04779cc588ad8f13c80a060b0b1c9d1c203d051d8a43879117fe6b8aaf1cd3fa", 150 | "sha256:121d61663267f73692e8bde5ec0d23c9146465a0d75cad75c34f75c752527b01", 151 | "sha256:1a30f51b990994491cec2d7d237924e5b6bd0d445da9337d77de384ad7f254f9", 152 | "sha256:2c5631204ebcc7ae33d11c43037b2dafe25e2ab9c1de6448eb6502ac69c19a56", 153 | "sha256:333306eaea01fde50a73c4619e25631e56c4c61bd0fb0a2346479e67e3d3a820", 154 | "sha256:38bbd6717eac084408b4094174c0805bdbaba1f57fc250fd0309ae5ec9ed7e09", 155 | "sha256:3a232474cd89d3f51e4295abe248a8b95d0332d153bf46444e415409070aae1e", 156 | "sha256:4992ec965606054e8326e83db1c8654f0549cdb26fce1898dc1a20bc7684ec1c", 157 | "sha256:53068e33c74f3b93a8158dacaa5d0f82d254a81b1002e0cd342be89fcb3433eb", 158 | "sha256:5587803d5b66dfd99e7caa31ed91fba0fdee3661c5d93684028ad6653fce725f", 159 | "sha256:5a790bc045003d89d42e3b9cb3cc938c8561a57a88aaa5691512e8540d1ae79c", 160 | "sha256:74794a2e2896cd0cf56fdc9db61ef755fa812b4a4900fa46c49045663a92b8d0", 161 | "sha256:80ea8333b6a5f2d9e856ff2293dba2e3e661197f90bf0f4d5a82a0a6bc83a626", 162 | "sha256:8198f2b04c39d817b206ebe0db25a6653bb5f463c2319d6f6d9a80d012ac1e37", 163 | "sha256:87e2ca3aa557781447428c4b6c8c937f10ff215202ab40ece5c13a82555c10d6", 164 | "sha256:909e36a43fe4a8a3163e9c7fc103867825d14a2ecb852a63d3905250b308a4e5", 165 | "sha256:9453b4e21e752df8737fdffac619e93c9f0ec55ead9a45df782055eb95ef37d9", 166 | "sha256:9ec565e89a6b400eca814f28d78a9ef3f15aea1df74d95b28b7720739b28f37f", 167 | "sha256:a3228728a3808bc9f18c1797ec1179a0efb5068c817b2ffcf6bcd012494dffb2", 168 | "sha256:a74f45aee8c5cc4d533e585e0e596e9f78521e1543a302870a27b0ae2106381e", 169 | "sha256:afbcdb0eda20a0e1d44e3a1ad6d4ec3c959210f4b48cabc0e387a282f4c7deb8", 170 | "sha256:ba2d4fcb844c6ba5df4bbfee9352ad5352c5ae939ac450e06cdceff653280450", 171 | "sha256:bce2e2d8e82fcf972005652371a3e8731956a0c1fbb719cc897943b3695ad91b", 172 | "sha256:c133f6721fba313722a018392a91e3c69d3706ae723484841752559e71d69dc6", 173 | "sha256:ca1ceb6303be1282148f04ac21cebeebdb4152590842159877778f9cf1634f09", 174 | "sha256:d086d46774e27b280e4cece8ab3d87299cf0d39063f00f1e9290d096adc5662a", 175 | "sha256:dc22cc00f804485a3c2a7e2010d9f14a705555f67020eb083e833cabd5bd82e4", 176 | "sha256:e1819b67bcf6ca48341e9b03c2e45b1c891fa8eb1a8458482d14c2805c9616f2", 177 | "sha256:e7debd9c439e7b84f53be3cf4ba8b75b3d0b6e6015212355d6daf44ac672e210", 178 | "sha256:f44c0d28716d950135ff21505f2c764498eda9d8806b7c78764165848aa419bc", 179 | "sha256:f68d6c8ea2974a571cacb7014dbaada21063a0375318d88ac1f9300bc81e93c3", 180 | "sha256:f812d58c5af06d939b2baccdda614a3ffd80531a26e5faca2c9f8b1770b2b7af", 181 | "sha256:f8e550caf52472ae9126953415e4fc554ab53049a5691c45b8816895c632e4d7" 182 | ], 183 | "index": "pypi", 184 | "version": "==3.17" 185 | }, 186 | "pydantic": { 187 | "hashes": [ 188 | "sha256:01aea3a42c13f2602b7ecbbea484a98169fb568ebd9e247593ea05f01b884b2e", 189 | "sha256:0cd181f1d0b1d00e2b705f1bf1ac7799a2d938cce3376b8007df62b29be3c2c6", 190 | "sha256:10a86d8c8db68086f1e30a530f7d5f83eb0685e632e411dbbcf2d5c0150e8dcd", 191 | "sha256:193924c563fae6ddcb71d3f06fa153866423ac1b793a47936656e806b64e24ca", 192 | "sha256:464855a7ff7f2cc2cf537ecc421291b9132aa9c79aef44e917ad711b4a93163b", 193 | "sha256:516f1ed9bc2406a0467dd777afc636c7091d71f214d5e413d64fef45174cfc7a", 194 | "sha256:6434b49c0b03a51021ade5c4daa7d70c98f7a79e95b551201fff682fc1661245", 195 | "sha256:64d34ab766fa056df49013bb6e79921a0265204c071984e75a09cbceacbbdd5d", 196 | "sha256:670bb4683ad1e48b0ecb06f0cfe2178dcf74ff27921cdf1606e527d2617a81ee", 197 | "sha256:68792151e174a4aa9e9fc1b4e653e65a354a2fa0fed169f7b3d09902ad2cb6f1", 198 | "sha256:701daea9ffe9d26f97b52f1d157e0d4121644f0fcf80b443248434958fd03dc3", 199 | "sha256:7d45fc99d64af9aaf7e308054a0067fdcd87ffe974f2442312372dfa66e1001d", 200 | "sha256:80b1fab4deb08a8292d15e43a6edccdffa5377a36a4597bb545b93e79c5ff0a5", 201 | "sha256:82dffb306dd20bd5268fd6379bc4bfe75242a9c2b79fec58e1041fbbdb1f7914", 202 | "sha256:8c7f51861d73e8b9ddcb9916ae7ac39fb52761d9ea0df41128e81e2ba42886cd", 203 | "sha256:950ce33857841f9a337ce07ddf46bc84e1c4946d2a3bba18f8280297157a3fd1", 204 | "sha256:976cae77ba6a49d80f461fd8bba183ff7ba79f44aa5cfa82f1346b5626542f8e", 205 | "sha256:9f6f0fd68d73257ad6685419478c5aece46432f4bdd8d32c7345f1986496171e", 206 | "sha256:a7cd2251439988b413cb0a985c4ed82b6c6aac382dbaff53ae03c4b23a70e80a", 207 | "sha256:abfb7d4a7cd5cc4e1d1887c43503a7c5dd608eadf8bc615413fc498d3e4645cd", 208 | "sha256:ae150a63564929c675d7f2303008d88426a0add46efd76c3fc797cd71cb1b46f", 209 | "sha256:b0f85904f73161817b80781cc150f8b906d521fa11e3cdabae19a581c3606209", 210 | "sha256:b4a849d10f211389502059c33332e91327bc154acc1845f375a99eca3afa802d", 211 | "sha256:c15582f9055fbc1bfe50266a19771bbbef33dd28c45e78afbe1996fd70966c2a", 212 | "sha256:c230c0d8a322276d6e7b88c3f7ce885f9ed16e0910354510e0bae84d54991143", 213 | "sha256:cc1dde4e50a5fc1336ee0581c1612215bc64ed6d28d2c7c6f25d2fe3e7c3e918", 214 | "sha256:cf135c46099ff3f919d2150a948ce94b9ce545598ef2c6c7bf55dca98a304b52", 215 | "sha256:cfc83c0678b6ba51b0532bea66860617c4cd4251ecf76e9846fa5a9f3454e97e", 216 | "sha256:d2a5ebb48958754d386195fe9e9c5106f11275867051bf017a8059410e9abf1f", 217 | "sha256:d71e69699498b020ea198468e2480a2f1e7433e32a3a99760058c6520e2bea7e", 218 | "sha256:d75ae19d2a3dbb146b6f324031c24f8a3f52ff5d6a9f22f0683694b3afcb16fb", 219 | "sha256:dfe2507b8ef209da71b6fb5f4e597b50c5a34b78d7e857c4f8f3115effaef5fe", 220 | "sha256:e0cfe895a504c060e5d36b287ee696e2fdad02d89e0d895f83037245218a87fe", 221 | "sha256:e79e999e539872e903767c417c897e729e015872040e56b96e67968c3b918b2d", 222 | "sha256:ecbbc51391248116c0a055899e6c3e7ffbb11fb5e2a4cd6f2d0b93272118a209", 223 | "sha256:f4a2b50e2b03d5776e7f21af73e2070e1b5c0d0df255a827e7c632962f8315af" 224 | ], 225 | "markers": "python_version >= '3.7'", 226 | "version": "==1.10.7" 227 | }, 228 | "python-dateutil": { 229 | "hashes": [ 230 | "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", 231 | "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" 232 | ], 233 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 234 | "version": "==2.8.2" 235 | }, 236 | "pytimeparse": { 237 | "hashes": [ 238 | "sha256:04b7be6cc8bd9f5647a6325444926c3ac34ee6bc7e69da4367ba282f076036bd", 239 | "sha256:e86136477be924d7e670646a98561957e8ca7308d44841e21f5ddea757556a0a" 240 | ], 241 | "version": "==1.1.8" 242 | }, 243 | "pyyaml": { 244 | "hashes": [ 245 | "sha256:01b45c0191e6d66c470b6cf1b9531a771a83c1c4208272ead47a3ae4f2f603bf", 246 | "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293", 247 | "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b", 248 | "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57", 249 | "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b", 250 | "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4", 251 | "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07", 252 | "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba", 253 | "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9", 254 | "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287", 255 | "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513", 256 | "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0", 257 | "sha256:432557aa2c09802be39460360ddffd48156e30721f5e8d917f01d31694216782", 258 | "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0", 259 | "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92", 260 | "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f", 261 | "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2", 262 | "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc", 263 | "sha256:81957921f441d50af23654aa6c5e5eaf9b06aba7f0a19c18a538dc7ef291c5a1", 264 | "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c", 265 | "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86", 266 | "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4", 267 | "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c", 268 | "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34", 269 | "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b", 270 | "sha256:afa17f5bc4d1b10afd4466fd3a44dc0e245382deca5b3c353d8b757f9e3ecb8d", 271 | "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c", 272 | "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb", 273 | "sha256:bfaef573a63ba8923503d27530362590ff4f576c626d86a9fed95822a8255fd7", 274 | "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737", 275 | "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3", 276 | "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d", 277 | "sha256:d4b0ba9512519522b118090257be113b9468d804b19d63c71dbcf4a48fa32358", 278 | "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53", 279 | "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78", 280 | "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803", 281 | "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a", 282 | "sha256:dbad0e9d368bb989f4515da330b88a057617d16b6a8245084f1b05400f24609f", 283 | "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174", 284 | "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5" 285 | ], 286 | "index": "pypi", 287 | "version": "==6.0" 288 | }, 289 | "requests": { 290 | "hashes": [ 291 | "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", 292 | "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1" 293 | ], 294 | "index": "pypi", 295 | "version": "==2.31.0" 296 | }, 297 | "ruamel.yaml": { 298 | "hashes": [ 299 | "sha256:25d0ee82a0a9a6f44683dcf8c282340def4074a4562f3a24f55695bb254c1693", 300 | "sha256:baa2d0a5aad2034826c439ce61c142c07082b76f4791d54145e131206e998059" 301 | ], 302 | "markers": "python_version >= '3'", 303 | "version": "==0.17.26" 304 | }, 305 | "ruamel.yaml.clib": { 306 | "hashes": [ 307 | "sha256:045e0626baf1c52e5527bd5db361bc83180faaba2ff586e763d3d5982a876a9e", 308 | "sha256:15910ef4f3e537eea7fe45f8a5d19997479940d9196f357152a09031c5be59f3", 309 | "sha256:184faeaec61dbaa3cace407cffc5819f7b977e75360e8d5ca19461cd851a5fc5", 310 | "sha256:1f08fd5a2bea9c4180db71678e850b995d2a5f4537be0e94557668cf0f5f9497", 311 | "sha256:2aa261c29a5545adfef9296b7e33941f46aa5bbd21164228e833412af4c9c75f", 312 | "sha256:3110a99e0f94a4a3470ff67fc20d3f96c25b13d24c6980ff841e82bafe827cac", 313 | "sha256:3243f48ecd450eddadc2d11b5feb08aca941b5cd98c9b1db14b2fd128be8c697", 314 | "sha256:370445fd795706fd291ab00c9df38a0caed0f17a6fb46b0f607668ecb16ce763", 315 | "sha256:40d030e2329ce5286d6b231b8726959ebbe0404c92f0a578c0e2482182e38282", 316 | "sha256:41d0f1fa4c6830176eef5b276af04c89320ea616655d01327d5ce65e50575c94", 317 | "sha256:4a4d8d417868d68b979076a9be6a38c676eca060785abaa6709c7b31593c35d1", 318 | "sha256:4b3a93bb9bc662fc1f99c5c3ea8e623d8b23ad22f861eb6fce9377ac07ad6072", 319 | "sha256:5bc0667c1eb8f83a3752b71b9c4ba55ef7c7058ae57022dd9b29065186a113d9", 320 | "sha256:721bc4ba4525f53f6a611ec0967bdcee61b31df5a56801281027a3a6d1c2daf5", 321 | "sha256:763d65baa3b952479c4e972669f679fe490eee058d5aa85da483ebae2009d231", 322 | "sha256:7bdb4c06b063f6fd55e472e201317a3bb6cdeeee5d5a38512ea5c01e1acbdd93", 323 | "sha256:8831a2cedcd0f0927f788c5bdf6567d9dc9cc235646a434986a852af1cb54b4b", 324 | "sha256:91a789b4aa0097b78c93e3dc4b40040ba55bef518f84a40d4442f713b4094acb", 325 | "sha256:92460ce908546ab69770b2e576e4f99fbb4ce6ab4b245345a3869a0a0410488f", 326 | "sha256:99e77daab5d13a48a4054803d052ff40780278240a902b880dd37a51ba01a307", 327 | "sha256:a234a20ae07e8469da311e182e70ef6b199d0fbeb6c6cc2901204dd87fb867e8", 328 | "sha256:a7b301ff08055d73223058b5c46c55638917f04d21577c95e00e0c4d79201a6b", 329 | "sha256:be2a7ad8fd8f7442b24323d24ba0b56c51219513cfa45b9ada3b87b76c374d4b", 330 | "sha256:bf9a6bc4a0221538b1a7de3ed7bca4c93c02346853f44e1cd764be0023cd3640", 331 | "sha256:c3ca1fbba4ae962521e5eb66d72998b51f0f4d0f608d3c0347a48e1af262efa7", 332 | "sha256:d000f258cf42fec2b1bbf2863c61d7b8918d31ffee905da62dede869254d3b8a", 333 | "sha256:d5859983f26d8cd7bb5c287ef452e8aacc86501487634573d260968f753e1d71", 334 | "sha256:d5e51e2901ec2366b79f16c2299a03e74ba4531ddcfacc1416639c557aef0ad8", 335 | "sha256:da538167284de58a52109a9b89b8f6a53ff8437dd6dc26d33b57bf6699153122", 336 | "sha256:debc87a9516b237d0466a711b18b6ebeb17ba9f391eb7f91c649c5c4ec5006c7", 337 | "sha256:df5828871e6648db72d1c19b4bd24819b80a755c4541d3409f0f7acd0f335c80", 338 | "sha256:ecdf1a604009bd35c674b9225a8fa609e0282d9b896c03dd441a91e5f53b534e", 339 | "sha256:efa08d63ef03d079dcae1dfe334f6c8847ba8b645d08df286358b1f5293d24ab", 340 | "sha256:f01da5790e95815eb5a8a138508c01c758e5f5bc0ce4286c4f7028b8dd7ac3d0", 341 | "sha256:f34019dced51047d6f70cb9383b2ae2853b7fc4dce65129a5acd49f4f9256646", 342 | "sha256:f6d3d39611ac2e4f62c3128a9eed45f19a6608670c5a2f4f07f24e8de3441d38" 343 | ], 344 | "markers": "python_version >= '3.5'", 345 | "version": "==0.2.7" 346 | }, 347 | "six": { 348 | "hashes": [ 349 | "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", 350 | "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" 351 | ], 352 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 353 | "version": "==1.16.0" 354 | }, 355 | "tabulate": { 356 | "hashes": [ 357 | "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c", 358 | "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f" 359 | ], 360 | "index": "pypi", 361 | "version": "==0.9.0" 362 | }, 363 | "tenacity": { 364 | "hashes": [ 365 | "sha256:35525cd47f82830069f0d6b73f7eb83bc5b73ee2fff0437952cedf98b27653ac", 366 | "sha256:e48c437fdf9340f5666b92cd7990e96bc5fc955e1298baf4a907e3972067a445" 367 | ], 368 | "index": "pypi", 369 | "version": "==8.1.0" 370 | }, 371 | "toml": { 372 | "hashes": [ 373 | "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", 374 | "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" 375 | ], 376 | "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", 377 | "version": "==0.10.2" 378 | }, 379 | "typing-extensions": { 380 | "hashes": [ 381 | "sha256:6ad00b63f849b7dcc313b70b6b304ed67b2b2963b3098a33efe18056b1a9a223", 382 | "sha256:ff6b238610c747e44c268aa4bb23c8c735d665a63726df3f9431ce707f2aa768" 383 | ], 384 | "markers": "python_version >= '3.7'", 385 | "version": "==4.6.0" 386 | }, 387 | "urllib3": { 388 | "hashes": [ 389 | "sha256:076907bf8fd355cde77728471316625a4d2f7e713c125f51953bb5b3eecf4f72", 390 | "sha256:75edcdc2f7d85b137124a6c3c9fc3933cdeaa12ecb9a6a959f22797a0feca7e1" 391 | ], 392 | "index": "pypi", 393 | "version": "==1.26.14" 394 | }, 395 | "voluptuous": { 396 | "hashes": [ 397 | "sha256:4b838b185f5951f2d6e8752b68fcf18bd7a9c26ded8f143f92d6d28f3921a3e6", 398 | "sha256:e8d31c20601d6773cb14d4c0f42aee29c6821bbd1018039aac7ac5605b489723" 399 | ], 400 | "version": "==0.13.1" 401 | } 402 | }, 403 | "develop": { 404 | "attrs": { 405 | "hashes": [ 406 | "sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04", 407 | "sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015" 408 | ], 409 | "markers": "python_version >= '3.7'", 410 | "version": "==23.1.0" 411 | }, 412 | "flake8": { 413 | "hashes": [ 414 | "sha256:3833794e27ff64ea4e9cf5d410082a8b97ff1a06c16aa3d2027339cd0f1195c7", 415 | "sha256:c61007e76655af75e6785a931f452915b371dc48f56efd765247c8fe68f2b181" 416 | ], 417 | "index": "pypi", 418 | "version": "==6.0.0" 419 | }, 420 | "iniconfig": { 421 | "hashes": [ 422 | "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", 423 | "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" 424 | ], 425 | "markers": "python_version >= '3.7'", 426 | "version": "==2.0.0" 427 | }, 428 | "mccabe": { 429 | "hashes": [ 430 | "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", 431 | "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e" 432 | ], 433 | "markers": "python_version >= '3.6'", 434 | "version": "==0.7.0" 435 | }, 436 | "mock": { 437 | "hashes": [ 438 | "sha256:c41cfb1e99ba5d341fbcc5308836e7d7c9786d302f995b2c271ce2144dece9eb", 439 | "sha256:e3ea505c03babf7977fd21674a69ad328053d414f05e6433c30d8fa14a534a6b" 440 | ], 441 | "index": "pypi", 442 | "version": "==5.0.1" 443 | }, 444 | "packaging": { 445 | "hashes": [ 446 | "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61", 447 | "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f" 448 | ], 449 | "markers": "python_version >= '3.7'", 450 | "version": "==23.1" 451 | }, 452 | "pluggy": { 453 | "hashes": [ 454 | "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159", 455 | "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3" 456 | ], 457 | "markers": "python_version >= '3.6'", 458 | "version": "==1.0.0" 459 | }, 460 | "pycodestyle": { 461 | "hashes": [ 462 | "sha256:347187bdb476329d98f695c213d7295a846d1152ff4fe9bacb8a9590b8ee7053", 463 | "sha256:8a4eaf0d0495c7395bdab3589ac2db602797d76207242c17d470186815706610" 464 | ], 465 | "markers": "python_version >= '3.6'", 466 | "version": "==2.10.0" 467 | }, 468 | "pyflakes": { 469 | "hashes": [ 470 | "sha256:ec55bf7fe21fff7f1ad2f7da62363d749e2a470500eab1b555334b67aa1ef8cf", 471 | "sha256:ec8b276a6b60bd80defed25add7e439881c19e64850afd9b346283d4165fd0fd" 472 | ], 473 | "markers": "python_version >= '3.6'", 474 | "version": "==3.0.1" 475 | }, 476 | "pytest": { 477 | "hashes": [ 478 | "sha256:c7c6ca206e93355074ae32f7403e8ea12163b1163c976fee7d4d84027c162be5", 479 | "sha256:d45e0952f3727241918b8fd0f376f5ff6b301cc0777c6f9a556935c92d8a7d42" 480 | ], 481 | "index": "pypi", 482 | "version": "==7.2.1" 483 | } 484 | } 485 | } 486 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # N26 Python CLI/API 2 | 3 | ## 2023-10-29 - Archiving the repository 4 | 5 | Today, I am marking this repository as archived, as I am not making use of the API itself anymore. 6 | If you'e interested in taking over the maintenance of the repository, please reach out to me. 7 | 8 | [![Build Status](https://github.com/femueller/python-n26/actions/workflows/python-app.yml/badge.svg)](https://github.com/femueller/python-n26/actions/workflows/python-app.yml) 9 | [![PyPI version](https://img.shields.io/github/pipenv/locked/python-version/femueller/python-n26)](https://img.shields.io/github/pipenv/locked/python-version/femueller/python-n26) 10 | [![PyPI version](https://badge.fury.io/py/n26.svg)](https://badge.fury.io/py/n26) 11 | [![Downloads](https://img.shields.io/pypi/dm/n26.svg)](https://img.shields.io/pypi/dm/n26.svg) 12 | 13 | [![asciicast](https://asciinema.org/a/260083.svg)](https://asciinema.org/a/260083) 14 | 15 | ## About 16 | 17 | [python-n26](https://github.com/femueller/python-n26) is a Python library and Command Line Interface to request information from N26 bank accounts. You can use it to check your balance from the terminal or include it in your own Python projects. 18 | 19 | **Disclaimer:** This is an unofficial community project which is not affiliated with N26 GmbH/N26 Inc. 20 | 21 | ## Install 22 | 23 | ```shell 24 | pip3 install n26 25 | wget https://raw.githubusercontent.com/femueller/python-n26/master/n26.yml.example -O ~/.config/n26.yml 26 | # configure username and password 27 | vim ~/.config/n26.yml 28 | ``` 29 | 30 | ## Configuration 31 | 32 | python-n26 uses [container-app-conf](https://github.com/markusressel/container-app-conf) to provide different options for configuration. 33 | You can place a YAML (`n26.yaml` or `n26.yml`) or TOML (`n26.toml` or `n26.tml`) configuration file in `./`, `~/` or `~/.config/`. Have a look at the [YAML example](n26.yml.example) and [TOML example](n26.tml.example). 34 | If you want to use environment variables: 35 | 36 | - `N26_USERNAME`: username 37 | - `N26_PASSWORD`: password 38 | - `N26_DEVICE_TOKEN`: random [uuid](https://de.wikipedia.org/wiki/Universally_Unique_Identifier) to identify the device 39 | - `N26_LOGIN_DATA_STORE_PATH`: optional **file** path to store login data (recommended for cli usage) 40 | - `N26_MFA_TYPE`: `app` will use the paired app as 2 factor authentication, `sms` will use SMS to the registered number. 41 | 42 | Note that **when specifying both** environment variables as well as a config file and a key is present in both locations the **enviroment variable values will be preferred**. 43 | 44 | ## Authentication 45 | 46 | ### Device Token 47 | 48 | Since 17th of June 2020 N26 requires a device_token to differentiate clients. This requires you to specify the `DEVICE_TOKEN` 49 | config option with a UUID of your choice. To generate a UUID you can use f.ex. one of the following options: 50 | 51 | Using python: 52 | 53 | ```python 54 | python -c 'import uuid; print(uuid.uuid4())' 55 | ``` 56 | 57 | Using linux built-in tools: 58 | 59 | ```shell 60 | > uuidgen 61 | ``` 62 | 63 | Using a website: 64 | [https://www.uuidgenerator.net/](https://www.uuidgenerator.net/) 65 | 66 | ### 2FA 67 | 68 | Since 14th of September 2019 N26 requires a login confirmation (2 factor authentication). 69 | 70 | There are two options here: 71 | 72 | 1. Using the paired phone N26 app to approve login on devices that are not paired. This can be configured by setting `app` as the `mfa_type`. You will receive a notification on your phone when you start using this library to request data. python-n26 checks for your login confirmation every 5 seconds. If you fail to approve the login request within 60 seconds an exception is raised. 73 | 2. Using a code delivered via SMS to your registered phone number as 2 factor authentication. This can be configured by setting `sms` as the `mfa_type`. 74 | 75 | If you do not specify a `login_data_store_path` this login information is only stored in memory. In order to avoid that every CLI command requires a new confirmation, the login data retrieved in the above process can be stored on the file system. Please note that **this information must be protected** from the eyes of third parties **at all costs**. You can specify the location to store this data in the [Configuration](#Configuration). 76 | 77 | ## Usage 78 | 79 | ### CLI example 80 | 81 | ```shell 82 | > n26 balance 83 | 123.45 EUR 84 | ``` 85 | 86 | Or if using environment variables: 87 | 88 | ```bash 89 | > N26_USER=user N26_PASSWORD=passwd N26_DEVICE_TOKEN=00000000-0000-0000-0000-000000000000 N26_MFA_TYPE=app n26 balance 90 | 123.45 EUR 91 | ``` 92 | 93 | ### JSON output 94 | 95 | If you would like to work with the raw `JSON` rather than the pretty table 96 | layout you can use the global `-json` parameter: 97 | 98 | ```bash 99 | > n26 -json balance 100 | { 101 | "id": "12345678-1234-1234-1234-123456789012", 102 | "physicalBalance": null, 103 | "availableBalance": 123.45, 104 | "usableBalance": 123.45, 105 | "bankBalance": 123.45, 106 | "iban": "DE12345678901234567890", 107 | "bic": "NTSBDEB1XXX", 108 | "bankName": "N26 Bank", 109 | "seized": false, 110 | "currency": "EUR", 111 | "legalEntity": "EU", 112 | "users": [ 113 | { 114 | "userId": "12345678-1234-1234-1234-123456789012", 115 | "userRole": "OWNER" 116 | } 117 | ], 118 | "externalId": { 119 | "iban": "DE12345678901234567890" 120 | } 121 | } 122 | ``` 123 | 124 | ### Docker 125 | 126 | ```shell 127 | # ensure the n26 folder exists 128 | mkdir ~/.config/n26 129 | # mount the config and launch the command 130 | sudo docker run -it --rm \ 131 | -v "/home/markus/.config/n26.yaml:/app/n26.yaml" \ 132 | -v "/home/markus/.config/n26:/.config/n26" \ 133 | -u 1000:1000 \ 134 | femueller/python-n26 135 | ``` 136 | 137 | ### API example 138 | 139 | ```python 140 | from n26.api import Api 141 | api_client = Api() 142 | print(api_client.get_balance()) 143 | ``` 144 | 145 | This is going to use the same mechanism to load configuration as the CLI tool, to specify your own configuration you can use it as: 146 | 147 | ```python 148 | from n26.api import Api 149 | from n26.config import Config 150 | 151 | conf = Config(validate=False) 152 | conf.USERNAME.value = "john.doe@example.com" 153 | conf.PASSWORD.value = "$upersecret" 154 | conf.LOGIN_DATA_STORE_PATH.value = None 155 | conf.MFA_TYPE.value = "app" 156 | conf.validate() 157 | 158 | api_client = Api(conf) 159 | print(api_client.get_balance()) 160 | ``` 161 | 162 | ## Contribute 163 | 164 | If there are any issues, bugs or missing API endpoints, feel free to contribute by forking the project and creating a Pull-Request. 165 | 166 | ### Run locally 167 | 168 | Prerequirements: [Pipenv](https://pipenv.readthedocs.io/) 169 | 170 | ```shell 171 | git clone git@github.com:femueller/python-n26.git 172 | cd python-n26 173 | pipenv shell 174 | pipenv install 175 | python3 -m n26 balance 176 | ``` 177 | 178 | ### Creating a new release (only for maintainers) 179 | 180 | 1. Increment version number in `n26/__init__.py` according to desired [SemVer](https://semver.org/#summary) release version 181 | 2. Create a new release using the `Makefile`. This creates a new git tag, which triggers the "Upload Python Package" GitHub Action. 182 | 1. Run `make git-release`, this triggers: [https://github.com/femueller/python-n26/actions/workflows/python-publish.yml]() 183 | 2. New releases end up at: [https://pypi.org/project/n26/]() 184 | 185 | ## Maintainers 186 | 187 | - [Markus Ressel](https://github.com/markusressel) 188 | - [Felix Mueller](https://github.com/femueller) 189 | 190 | ## Credits 191 | 192 | - [Nick Jüttner](https://github.com/njuettner) for providing [the API authentication flow](https://github.com/njuettner/alexa/blob/master/n26/app.py) 193 | - [Pierrick Paul](https://github.com/PierrickP/) for providing [the API endpoints](https://github.com/PierrickP/n26/blob/develop/lib/api.js) 194 | 195 | ## Similar projects 196 | 197 | - Go: https://github.com/guitmz/n26 by [Guilherme Thomazi Bonicontro](https://github.com/guitmz) 198 | - Go: https://github.com/njuettner/n26 by [Nick Jüttner](https://github.com/njuettner) (unmaintained) 199 | - Node https://github.com/PierrickP/n26 by [Pierrick Paul](https://github.com/PierrickP/) (unmaintained) 200 | 201 | ## Disclaimer 202 | 203 | This project is not affiliated with N26 GmbH/N26 Inc. if you want to learn more about it, visit https://n26.com/. 204 | 205 | We've been trying [hard to collaborate with N26](https://github.com/femueller/python-n26/issues/107#issuecomment-1008825746) however, it's been always really challenging. 206 | There is no guarantee that this project continues to work at any point, since none of the API endpoints are really documented. 207 | -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | from n26 import api 2 | 3 | if __name__ == '__main__': 4 | API_CLIENT = api.Api() 5 | -------------------------------------------------------------------------------- /n26.tml.example: -------------------------------------------------------------------------------- 1 | [n26] 2 | auth_base_url = "https://api.tech26.de" 3 | username = "john.doe@example.com" 4 | password = "$upersecret" 5 | device_token = "00000000-0000-0000-0000-000000000000" 6 | login_data_store_path = "~/.config/n26/token_data" 7 | mfa_type = "app" -------------------------------------------------------------------------------- /n26.yml.example: -------------------------------------------------------------------------------- 1 | n26: 2 | auth_base_url: https://api.tech26.de 3 | username: john.doe@example.com 4 | password: $upersecret 5 | device_token: 00000000-0000-0000-0000-000000000000 6 | login_data_store_path: "~/.config/n26/token_data" 7 | mfa_type: app 8 | -------------------------------------------------------------------------------- /n26/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '3.3.1' 2 | -------------------------------------------------------------------------------- /n26/__main__.py: -------------------------------------------------------------------------------- 1 | from n26.cli import cli 2 | 3 | if __name__ == '__main__': 4 | cli() 5 | -------------------------------------------------------------------------------- /n26/api.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | import logging 4 | import os 5 | import time 6 | from pathlib import Path 7 | 8 | import click 9 | import requests 10 | from Crypto import Random 11 | from Crypto.Cipher import AES, PKCS1_v1_5 12 | from Crypto.Hash import SHA512 13 | from Crypto.Protocol.KDF import PBKDF2 14 | from Crypto.PublicKey import RSA 15 | from Crypto.Util.Padding import pad 16 | from requests import HTTPError 17 | from tenacity import retry, stop_after_delay, wait_fixed 18 | 19 | from n26.config import Config, MFA_TYPE_SMS 20 | from n26.const import DAILY_WITHDRAWAL_LIMIT, DAILY_PAYMENT_LIMIT 21 | from n26.util import create_request_url 22 | 23 | LOGGER = logging.getLogger(__name__) 24 | 25 | BASE_URL_DE = 'https://api.tech26.de' 26 | BASIC_AUTH_HEADERS = {"Authorization": "Basic bmF0aXZld2ViOg=="} 27 | USER_AGENT = ("Mozilla/5.0 (X11; Linux x86_64) " 28 | "AppleWebKit/537.36 (KHTML, like Gecko) " 29 | "Chrome/59.0.3071.86 Safari/537.36") 30 | 31 | GET = "get" 32 | POST = "post" 33 | 34 | EXPIRATION_TIME_KEY = "expiration_time" 35 | ACCESS_TOKEN_KEY = "access_token" 36 | REFRESH_TOKEN_KEY = "refresh_token" 37 | 38 | GRANT_TYPE_PASSWORD = "password" 39 | GRANT_TYPE_REFRESH_TOKEN = "refresh_token" 40 | 41 | 42 | class Api(object): 43 | """ 44 | Api class can be imported as a library in order to use it within applications 45 | """ 46 | 47 | def __init__(self, cfg: Config = None): 48 | """ 49 | Constructor accepting None to maintain backward compatibility 50 | 51 | :param cfg: configuration object 52 | """ 53 | if not cfg: 54 | cfg = Config() 55 | self.config = cfg 56 | self._token_data = {} 57 | BASIC_AUTH_HEADERS["device-token"] = self.config.DEVICE_TOKEN.value 58 | 59 | @property 60 | def token_data(self) -> dict: 61 | if self.config.LOGIN_DATA_STORE_PATH.value is None: 62 | return self._token_data 63 | else: 64 | return self._read_token_file(self.config.LOGIN_DATA_STORE_PATH.value) 65 | 66 | @token_data.setter 67 | def token_data(self, data: dict): 68 | if self.config.LOGIN_DATA_STORE_PATH.value is None: 69 | self._token_data = data 70 | else: 71 | self._write_token_file(data, self.config.LOGIN_DATA_STORE_PATH.value) 72 | 73 | @staticmethod 74 | def _read_token_file(path: str) -> dict: 75 | """ 76 | :return: the stored token data or an empty dict 77 | """ 78 | LOGGER.debug("Reading token data from {}".format(path)) 79 | path = Path(path).expanduser().resolve() 80 | if not path.exists(): 81 | return {} 82 | 83 | if not path.is_file(): 84 | raise IsADirectoryError("File path exists and is not a file: {}".format(path)) 85 | 86 | if path.stat().st_size <= 0: 87 | # file is empty 88 | return {} 89 | 90 | with open(path, "r") as file: 91 | return json.loads(file.read()) 92 | 93 | @staticmethod 94 | def _write_token_file(token_data: dict, path: str): 95 | LOGGER.debug("Writing token data to {}".format(path)) 96 | path = Path(path).expanduser().resolve() 97 | 98 | # delete existing file if permissions don't match or file size is abnormally small 99 | if path.exists() and (path.stat().st_mode != 0o100600 or path.stat().st_size < 10): 100 | path.unlink() 101 | 102 | path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) 103 | with os.fdopen(os.open(path, os.O_WRONLY | os.O_CREAT, 0o600), 'w') as file: 104 | file.seek(0) 105 | file.write(json.dumps(token_data, indent=2)) 106 | file.truncate() 107 | 108 | # IDEA: @get_token decorator 109 | def get_account_info(self) -> dict: 110 | """ 111 | Retrieves basic account information 112 | """ 113 | return self._do_request(GET, BASE_URL_DE + '/api/me') 114 | 115 | def get_account_statuses(self) -> dict: 116 | """ 117 | Retrieves additional account information 118 | """ 119 | return self._do_request(GET, BASE_URL_DE + '/api/me/statuses') 120 | 121 | def get_addresses(self) -> dict: 122 | """ 123 | Retrieves a list of addresses of the account owner 124 | """ 125 | return self._do_request(GET, BASE_URL_DE + '/api/addresses') 126 | 127 | def get_balance(self) -> dict: 128 | """ 129 | Retrieves the current balance 130 | """ 131 | return self._do_request(GET, BASE_URL_DE + '/api/accounts') 132 | 133 | def get_spaces(self) -> dict: 134 | """ 135 | Retrieves a list of all spaces 136 | """ 137 | return self._do_request(GET, BASE_URL_DE + '/api/spaces') 138 | 139 | def barzahlen_check(self) -> dict: 140 | return self._do_request(GET, BASE_URL_DE + '/api/barzahlen/check') 141 | 142 | def get_cards(self): 143 | """ 144 | Retrieves a list of all cards 145 | """ 146 | return self._do_request(GET, BASE_URL_DE + '/api/v2/cards') 147 | 148 | def get_account_limits(self) -> list: 149 | """ 150 | Retrieves a list of all active account limits 151 | """ 152 | return self._do_request(GET, BASE_URL_DE + '/api/settings/account/limits') 153 | 154 | def set_account_limits(self, daily_withdrawal_limit: int = None, daily_payment_limit: int = None) -> None: 155 | """ 156 | Sets account limits 157 | 158 | :param daily_withdrawal_limit: daily withdrawal limit 159 | :param daily_payment_limit: daily payment limit 160 | """ 161 | if daily_withdrawal_limit is not None: 162 | self._do_request(POST, BASE_URL_DE + '/api/settings/account/limits', json={ 163 | "limit": DAILY_WITHDRAWAL_LIMIT, 164 | "amount": daily_withdrawal_limit 165 | }) 166 | 167 | if daily_payment_limit is not None: 168 | self._do_request(POST, BASE_URL_DE + '/api/settings/account/limits', json={ 169 | "limit": DAILY_PAYMENT_LIMIT, 170 | "amount": daily_payment_limit 171 | }) 172 | 173 | def get_contacts(self): 174 | """ 175 | Retrieves a list of all contacts 176 | """ 177 | return self._do_request(GET, BASE_URL_DE + '/api/smrt/contacts') 178 | 179 | def get_standing_orders(self) -> dict: 180 | """ 181 | Get a list of standing orders 182 | """ 183 | return self._do_request(GET, BASE_URL_DE + '/api/transactions/so') 184 | 185 | def get_transactions(self, from_time: int = None, to_time: int = None, limit: int = 20, pending: bool = None, 186 | categories: str = None, text_filter: str = None, last_id: str = None) -> dict: 187 | """ 188 | Get a list of transactions. 189 | 190 | Note that some parameters can not be combined in a single request (like text_filter and pending) and 191 | will result in a bad request (400) error. 192 | 193 | :param from_time: earliest transaction time as a Timestamp > 0 - milliseconds since 1970 in CET 194 | :param to_time: latest transaction time as a Timestamp > 0 - milliseconds since 1970 in CET 195 | :param limit: Limit the number of transactions to return to the given amount - default 20 as the n26 API returns 196 | only the last 20 transactions by default 197 | :param pending: show only pending transactions 198 | :param categories: Comma separated list of category IDs 199 | :param text_filter: Query string to search for 200 | :param last_id: ?? 201 | :return: list of transactions 202 | """ 203 | if pending and limit: 204 | # pending does not support limit 205 | limit = None 206 | 207 | return self._do_request(GET, BASE_URL_DE + '/api/smrt/transactions', { 208 | 'from': from_time, 209 | 'to': to_time, 210 | 'limit': limit, 211 | 'pending': pending, 212 | 'categories': categories, 213 | 'textFilter': text_filter, 214 | 'lastId': last_id 215 | }) 216 | 217 | def get_transactions_limited(self, limit: int = 5) -> dict: 218 | import warnings 219 | warnings.warn( 220 | "get_transactions_limited is deprecated, use get_transactions(limit=5) instead", 221 | DeprecationWarning 222 | ) 223 | return self.get_transactions(limit=limit) 224 | 225 | def get_balance_statement(self, statement_url: str): 226 | """ 227 | Retrieves a balance statement as pdf content 228 | :param statement_url: Download URL of a balance statement document 229 | """ 230 | return self._do_request(GET, BASE_URL_DE + statement_url) 231 | 232 | def get_statements(self) -> list: 233 | """ 234 | Retrieves a list of all statements 235 | """ 236 | return self._do_request(GET, BASE_URL_DE + '/api/statements') 237 | 238 | def block_card(self, card_id: str) -> dict: 239 | """ 240 | Blocks a card. 241 | If the card is already blocked this will have no effect. 242 | 243 | :param card_id: the id of the card to block 244 | :return: some info about the card (not including it's blocked state... thanks n26!) 245 | """ 246 | return self._do_request(POST, BASE_URL_DE + '/api/cards/%s/block' % card_id) 247 | 248 | def unblock_card(self, card_id: str) -> dict: 249 | """ 250 | Unblocks a card. 251 | If the card is already unblocked this will have no effect. 252 | 253 | :param card_id: the id of the card to block 254 | :return: some info about the card (not including it's unblocked state... thanks n26!) 255 | """ 256 | return self._do_request(POST, BASE_URL_DE + '/api/cards/%s/unblock' % card_id) 257 | 258 | def get_savings(self) -> dict: 259 | return self._do_request(GET, BASE_URL_DE + '/api/hub/savings/accounts') 260 | 261 | def get_statistics(self, from_time: int = 0, to_time: int = int(time.time()) * 1000) -> dict: 262 | """ 263 | Get statistics in a given time frame 264 | 265 | :param from_time: Timestamp - milliseconds since 1970 in CET 266 | :param to_time: Timestamp - milliseconds since 1970 in CET 267 | """ 268 | 269 | if not from_time: 270 | from_time = 0 271 | 272 | if not to_time: 273 | to_time = int(time.time()) * 1000 274 | 275 | return self._do_request(GET, BASE_URL_DE + '/api/smrt/statistics/categories/%s/%s' % (from_time, to_time)) 276 | 277 | def get_available_categories(self) -> list: 278 | return self._do_request(GET, BASE_URL_DE + '/api/smrt/categories') 279 | 280 | def get_invitations(self) -> list: 281 | return self._do_request(GET, BASE_URL_DE + '/api/aff/invitations') 282 | 283 | def _do_request(self, method: str = GET, url: str = "/", params: dict = None, 284 | json: dict = None, headers: dict = None) -> list or dict or None: 285 | """ 286 | Executes a http request based on the given parameters 287 | 288 | :param method: the method to use (GET, POST) 289 | :param url: the url to use 290 | :param params: query parameters that will be appended to the url 291 | :param json: request body 292 | :param headers: custom headers 293 | :return: the response parsed as a json 294 | """ 295 | access_token = self.get_token() 296 | _headers = {'Authorization': 'Bearer {}'.format(access_token)} 297 | if headers is not None: 298 | _headers.update(headers) 299 | 300 | url = create_request_url(url, params) 301 | 302 | if method is GET: 303 | response = requests.get(url, headers=_headers, json=json) 304 | elif method is POST: 305 | response = requests.post(url, headers=_headers, json=json) 306 | else: 307 | raise ValueError("Unsupported method: {}".format(method)) 308 | 309 | response.raise_for_status() 310 | # some responses do not return data so we just ignore the body in that case 311 | if len(response.content) > 0: 312 | if "application/json" in response.headers.get("Content-Type", ""): 313 | return response.json() 314 | else: 315 | return response.content 316 | 317 | def get_encryption_key(self, public_key: str = None) -> dict: 318 | """ 319 | Receive public encryption key for the JSON String containing the PIN encryption key 320 | """ 321 | return self._do_request(GET, BASE_URL_DE + '/api/encryption/key', params={ 322 | 'publicKey': public_key 323 | }) 324 | 325 | def encrypt_user_pin(self, pin: str): 326 | """ 327 | Encrypts user PIN and prepares it in a format required for a transaction order 328 | 329 | :return: encrypted and base64 encoded PIN as well as an 330 | encrypted and base64 encoded JSON containing the PIN encryption key 331 | """ 332 | # generate AES256 key and IV 333 | random_password = Random.get_random_bytes(32) 334 | salt = Random.get_random_bytes(16) 335 | # noinspection PyTypeChecker 336 | key = PBKDF2(random_password, salt, 32, count=1000000, hmac_hash_module=SHA512) 337 | iv = Random.new().read(AES.block_size) 338 | key64 = base64.b64encode(key).decode('utf-8') 339 | iv64 = base64.b64encode(iv).decode('utf-8') 340 | # encode the key and iv as a json string 341 | aes_secret = { 342 | 'secretKey': key64, 343 | 'iv': iv64 344 | } 345 | # json string has to be represented in byte form for encryption 346 | unencrypted_aes_secret = bytes(json.dumps(aes_secret), 'utf-8') 347 | # Encrypt the secret JSON with RSA using the provided public key 348 | public_key = self.get_encryption_key() 349 | public_key_non64 = base64.b64decode(public_key['publicKey']) 350 | public_key_object = RSA.importKey(public_key_non64) 351 | public_key_cipher = PKCS1_v1_5.new(public_key_object) 352 | encrypted_secret = public_key_cipher.encrypt(unencrypted_aes_secret) 353 | encrypted_secret64 = base64.b64encode(encrypted_secret) 354 | # Encrypt user's pin 355 | private_key_cipher = AES.new(key=key, mode=AES.MODE_CBC, iv=iv) 356 | # the pin has to be padded and transformed into bytes for a correct ecnryption format 357 | encrypted_pin = private_key_cipher.encrypt(pad(bytes(pin, 'utf-8'), 16)) 358 | encrypted_pin64 = base64.b64encode(encrypted_pin) 359 | 360 | return encrypted_secret64, encrypted_pin64 361 | 362 | def create_transaction(self, iban: str, bic: str, name: str, reference: str, amount: float, pin: str): 363 | """ 364 | Creates a bank transfer order 365 | 366 | :param iban: recipient IBAN 367 | :param bic: recipient BIC 368 | :param name: recipient name 369 | :param reference: transaction reference 370 | :param amount: money amount 371 | :param pin: user PIN required for the transaction approval 372 | """ 373 | encrypted_secret, encrypted_pin = self.encrypt_user_pin(pin) 374 | pin_headers = { 375 | 'encrypted-secret': encrypted_secret, 376 | 'encrypted-pin': encrypted_pin 377 | } 378 | 379 | # Prepare headers as a json for a transaction call 380 | data = { 381 | "transaction": { 382 | "amount": amount, 383 | "partnerBic": bic, 384 | "partnerIban": iban, 385 | "partnerName": name, 386 | "referenceText": reference, 387 | "type": "DT" 388 | } 389 | } 390 | 391 | return self._do_request(POST, BASE_URL_DE + '/api/transactions', json=data, headers=pin_headers) 392 | 393 | def is_authenticated(self) -> bool: 394 | """ 395 | :return: whether valid token data exists 396 | """ 397 | return self._validate_token(self.token_data) 398 | 399 | def authenticate(self): 400 | """ 401 | Starts a new authentication flow with the N26 servers. 402 | 403 | This method requires user interaction to approve a 2FA request. 404 | Therefore you should make sure if you can bypass this 405 | by refreshing or reusing an existing token by calling is_authenticated() 406 | and refresh_authentication() respectively. 407 | 408 | :raises PermissionError: if the token is invalid even after the refresh 409 | """ 410 | LOGGER.debug("Requesting token for username: {}".format(self.config.USERNAME.value)) 411 | token_data = self._request_token(self.config.USERNAME.value, self.config.PASSWORD.value) 412 | 413 | # add expiration time to expiration in _validate_token() 414 | token_data[EXPIRATION_TIME_KEY] = time.time() + token_data["expires_in"] 415 | 416 | # if it's still not valid, raise an exception 417 | if not self._validate_token(token_data): 418 | raise PermissionError("Unable to request authentication token") 419 | 420 | # save token data 421 | self.token_data = token_data 422 | 423 | def refresh_authentication(self): 424 | """ 425 | Refreshes an existing authentication using a (possibly expired) token. 426 | :raises AssertionError: if no existing token data was found 427 | :raises PermissionError: if the token is invalid even after the refresh 428 | """ 429 | token_data = self.token_data 430 | if REFRESH_TOKEN_KEY in token_data: 431 | LOGGER.debug("Trying to refresh existing token") 432 | refresh_token = token_data[REFRESH_TOKEN_KEY] 433 | token_data = self._refresh_token(refresh_token) 434 | else: 435 | raise AssertionError("Cant refresh token since no existing token data was found. " 436 | "Please initiate a new authentication instead.") 437 | 438 | # add expiration time to expiration in _validate_token() 439 | token_data[EXPIRATION_TIME_KEY] = time.time() + token_data["expires_in"] 440 | 441 | # if it's still not valid, raise an exception 442 | if not self._validate_token(token_data): 443 | raise PermissionError("Unable to refresh authentication token") 444 | 445 | # save token data 446 | self.token_data = token_data 447 | 448 | def get_token(self): 449 | """ 450 | Returns the access token to use for api authentication. 451 | If a token has been requested before it will be reused if it is still valid. 452 | If the previous token has expired it will be refreshed. 453 | If no token has been requested it will be requested from the server. 454 | 455 | :return: the access token 456 | """ 457 | new_auth = False 458 | if not self._validate_token(self.token_data): 459 | try: 460 | self.refresh_authentication() 461 | except HTTPError as http_error: 462 | if http_error.response.status_code != 401: 463 | raise http_error 464 | new_auth = True 465 | except AssertionError: 466 | new_auth = True 467 | 468 | if new_auth: 469 | self.authenticate() 470 | 471 | return self.token_data[ACCESS_TOKEN_KEY] 472 | 473 | def _request_token(self, username: str, password: str) -> dict: 474 | """ 475 | Request an authentication token from the server 476 | :return: the token or None if the response did not contain a token 477 | """ 478 | mfa_token = self._initiate_authentication_flow(username, password) 479 | self._request_mfa_approval(mfa_token) 480 | return self._complete_authentication_flow(mfa_token) 481 | 482 | def _initiate_authentication_flow(self, username: str, password: str) -> str: 483 | LOGGER.debug("Requesting authentication flow for user {}".format(username)) 484 | values_token = { 485 | "grant_type": GRANT_TYPE_PASSWORD, 486 | "username": username, 487 | "password": password 488 | } 489 | # TODO: Seems like the user-agent is not necessary but might be a good idea anyway 490 | response = requests.post(f"{self.config.AUTH_BASE_URL.value}/oauth2/token", data=values_token, 491 | headers=BASIC_AUTH_HEADERS) 492 | if response.status_code != 403: 493 | raise ValueError("Unexpected response for initial auth request: {}".format(response.text)) 494 | 495 | response_data = response.json() 496 | if response_data.get("error", "") == "mfa_required": 497 | return response_data["mfaToken"] 498 | else: 499 | raise ValueError("Unexpected response data") 500 | 501 | def _refresh_token(self, refresh_token: str): 502 | """ 503 | Refreshes an authentication token 504 | :param refresh_token: the refresh token issued by the server when requesting a token 505 | :return: the refreshed token data 506 | """ 507 | LOGGER.debug("Requesting token refresh using refresh_token {}".format(refresh_token)) 508 | values_token = { 509 | 'grant_type': GRANT_TYPE_REFRESH_TOKEN, 510 | 'refresh_token': refresh_token, 511 | } 512 | 513 | response = requests.post(f"{self.config.AUTH_BASE_URL.value}/oauth2/token", data=values_token, 514 | headers=BASIC_AUTH_HEADERS) 515 | response.raise_for_status() 516 | return response.json() 517 | 518 | def _request_mfa_approval(self, mfa_token: str): 519 | LOGGER.debug("Requesting MFA approval using mfa_token {}".format(mfa_token)) 520 | mfa_data = { 521 | "mfaToken": mfa_token 522 | } 523 | 524 | if self.config.MFA_TYPE.value == MFA_TYPE_SMS: 525 | mfa_data['challengeType'] = "otp" 526 | else: 527 | mfa_data['challengeType'] = "oob" 528 | 529 | response = requests.post( 530 | BASE_URL_DE + "/api/mfa/challenge", 531 | json=mfa_data, 532 | headers={ 533 | **BASIC_AUTH_HEADERS, 534 | "User-Agent": USER_AGENT, 535 | "Content-Type": "application/json" 536 | }) 537 | response.raise_for_status() 538 | 539 | @retry(wait=wait_fixed(5), stop=stop_after_delay(60)) 540 | def _complete_authentication_flow(self, mfa_token: str) -> dict: 541 | LOGGER.debug("Completing authentication flow for mfa_token {}".format(mfa_token)) 542 | mfa_response_data = { 543 | "mfaToken": mfa_token 544 | } 545 | 546 | if self.config.MFA_TYPE.value == MFA_TYPE_SMS: 547 | mfa_response_data['grant_type'] = "mfa_otp" 548 | 549 | hint = click.style("Enter the 6 digit SMS OTP code", fg="yellow") 550 | 551 | # type=str because it can have significant leading zeros 552 | mfa_response_data['otp'] = click.prompt(hint, type=str) 553 | else: 554 | mfa_response_data['grant_type'] = "mfa_oob" 555 | 556 | response = requests.post(BASE_URL_DE + "/oauth2/token", data=mfa_response_data, headers=BASIC_AUTH_HEADERS) 557 | response.raise_for_status() 558 | tokens = response.json() 559 | return tokens 560 | 561 | @staticmethod 562 | def _validate_token(token_data: dict): 563 | """ 564 | Checks if a token is valid 565 | :param token_data: the token data to check 566 | :return: true if valid, false otherwise 567 | """ 568 | if EXPIRATION_TIME_KEY not in token_data: 569 | # there was a problem adding the expiration_time property 570 | return False 571 | elif time.time() >= token_data[EXPIRATION_TIME_KEY]: 572 | # token has expired 573 | return False 574 | 575 | return ACCESS_TOKEN_KEY in token_data and token_data[ACCESS_TOKEN_KEY] 576 | -------------------------------------------------------------------------------- /n26/cli.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import logging 3 | import webbrowser 4 | from datetime import datetime, timezone 5 | from pathlib import Path 6 | from typing import Tuple 7 | 8 | import click 9 | from requests import HTTPError 10 | from tabulate import tabulate 11 | 12 | import n26.api as api 13 | from n26.config import Config 14 | from n26.const import AMOUNT, CURRENCY, REFERENCE_TEXT, ATM_WITHDRAW, CARD_STATUS_ACTIVE, DATETIME_FORMATS 15 | 16 | LOGGER = logging.getLogger(__name__) 17 | 18 | API_CLIENT = api.Api() 19 | 20 | JSON_OUTPUT = False 21 | CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) 22 | 23 | 24 | def auth_decorator(func: callable): 25 | """ 26 | Decorator ensuring authentication before making api requests 27 | :param func: function to patch 28 | """ 29 | 30 | @functools.wraps(func) 31 | def wrapper(*args, **kwargs): 32 | new_auth = False 33 | try: 34 | API_CLIENT.refresh_authentication() 35 | except HTTPError as http_error: 36 | if http_error.response.status_code != 401: 37 | raise http_error 38 | new_auth = True 39 | except AssertionError: 40 | new_auth = True 41 | 42 | if new_auth: 43 | hint = click.style("Initiating authentication flow, please check your phone to approve login.", fg="yellow") 44 | click.echo(hint) 45 | 46 | API_CLIENT.authenticate() 47 | 48 | success = click.style("Authentication successful :)", fg="green") 49 | click.echo(success) 50 | 51 | return func(*args, **kwargs) 52 | 53 | return wrapper 54 | 55 | 56 | # Cli returns command line requests 57 | @click.group(context_settings=CONTEXT_SETTINGS) 58 | @click.option("-json", default=False, type=bool, is_flag=True) 59 | @click.version_option() 60 | def cli(json: bool): 61 | """Interact with the https://n26.com API via the command line.""" 62 | global JSON_OUTPUT 63 | JSON_OUTPUT = json 64 | 65 | 66 | @cli.command() 67 | def logout(): 68 | """ Logout """ 69 | cfg = Config() 70 | login_data_file = cfg.LOGIN_DATA_STORE_PATH.value 71 | if login_data_file is not None: 72 | login_data_file = login_data_file.expanduser().resolve() 73 | login_data_file.unlink(missing_ok=True) 74 | 75 | 76 | @cli.command() 77 | @auth_decorator 78 | def addresses(): 79 | """ Show account addresses """ 80 | addresses_data = API_CLIENT.get_addresses().get('data') 81 | if JSON_OUTPUT: 82 | _print_json(addresses_data) 83 | return 84 | 85 | headers = ['Type', 'Country', 'City', 'Zip code', 'Street', 'Number', 86 | 'Address line 1', 'Address line 2', 87 | 'Created', 'Updated'] 88 | keys = ['type', 'countryName', 'cityName', 'zipCode', 'streetName', 'houseNumberBlock', 89 | 'addressLine1', 'addressLine2', 90 | _datetime_extractor('created'), _datetime_extractor('updated')] 91 | table = _create_table_from_dict(headers, keys, addresses_data, numalign='right') 92 | click.echo(table) 93 | 94 | 95 | @cli.command() 96 | @auth_decorator 97 | def info(): 98 | """ Get account information """ 99 | account_info = API_CLIENT.get_account_info() 100 | if JSON_OUTPUT: 101 | _print_json(account_info) 102 | return 103 | 104 | lines = [ 105 | ["Name:", "{} {}".format(account_info.get('firstName'), account_info.get('lastName'))], 106 | ["Email:", account_info.get('email')], 107 | ["Gender:", account_info.get('gender')], 108 | ["Nationality:", account_info.get('nationality')], 109 | ["Phone:", account_info.get('mobilePhoneNumber')] 110 | ] 111 | 112 | text = tabulate(lines, [], tablefmt="plain", colalign=["right", "left"]) 113 | 114 | click.echo(text) 115 | 116 | 117 | @cli.command() 118 | @auth_decorator 119 | def status(): 120 | """ Get account statuses """ 121 | account_statuses = API_CLIENT.get_account_statuses() 122 | if JSON_OUTPUT: 123 | _print_json(account_statuses) 124 | return 125 | 126 | lines = [ 127 | ["Account created:", _timestamp_ms_to_date(account_statuses.get('created'))], 128 | ["Account updated:", _timestamp_ms_to_date(account_statuses.get('updated'))], 129 | ["Account closed:", account_statuses.get('accountClosed')], 130 | ["Card activation completed:", _timestamp_ms_to_date(account_statuses.get('cardActivationCompleted'))], 131 | ["Card issued:", _timestamp_ms_to_date(account_statuses.get('cardIssued'))], 132 | ["Core data updated:", _timestamp_ms_to_date(account_statuses.get('coreDataUpdated'))], 133 | ["Email validation initiated:", _timestamp_ms_to_date(account_statuses.get('emailValidationInitiated'))], 134 | ["Email validation completed:", _timestamp_ms_to_date(account_statuses.get('emailValidationCompleted'))], 135 | ["First incoming transaction:", _timestamp_ms_to_date(account_statuses.get('firstIncomingTransaction'))], 136 | ["Flex account:", account_statuses.get('flexAccount')], 137 | ["Flex account confirmed:", _timestamp_ms_to_date(account_statuses.get('flexAccountConfirmed'))], 138 | ["Is deceased:", account_statuses.get('isDeceased')], 139 | ["Pairing State:", account_statuses.get('pairingState')], 140 | ["Phone pairing initiated:", _timestamp_ms_to_date(account_statuses.get('phonePairingInitiated'))], 141 | ["Phone pairing completed:", _timestamp_ms_to_date(account_statuses.get('phonePairingCompleted'))], 142 | ["Pin definition completed:", _timestamp_ms_to_date(account_statuses.get('pinDefinitionCompleted'))], 143 | ["Product selection completed:", _timestamp_ms_to_date(account_statuses.get('productSelectionCompleted'))], 144 | ["Signup step:", account_statuses.get('signupStep')], 145 | ["Single step signup:", _timestamp_ms_to_date(account_statuses.get('singleStepSignup'))], 146 | ["Unpairing process status:", account_statuses.get('unpairingProcessStatus')], 147 | ] 148 | 149 | text = tabulate(lines, [], tablefmt="plain", colalign=["right", "left"]) 150 | 151 | click.echo(text) 152 | 153 | 154 | @cli.command() 155 | @auth_decorator 156 | def balance(): 157 | """ Show account balance """ 158 | balance_data = API_CLIENT.get_balance() 159 | if JSON_OUTPUT: 160 | _print_json(balance_data) 161 | return 162 | 163 | amount = balance_data.get('availableBalance') 164 | currency = balance_data.get('currency') 165 | click.echo("{} {}".format(amount, currency)) 166 | 167 | 168 | @cli.command() 169 | def browse(): 170 | """ Browse on the web https://app.n26.com/ """ 171 | webbrowser.open('https://app.n26.com/') 172 | 173 | 174 | @cli.command() 175 | @auth_decorator 176 | def spaces(): 177 | """ Show spaces """ 178 | spaces_data = API_CLIENT.get_spaces()["spaces"] 179 | if JSON_OUTPUT: 180 | _print_json(spaces_data) 181 | return 182 | 183 | lines = [] 184 | for i, space in enumerate(spaces_data): 185 | line = [] 186 | available_balance = space['balance']['availableBalance'] 187 | currency = space['balance']['currency'] 188 | name = space['name'] 189 | 190 | line.append(name) 191 | line.append("{} {}".format(available_balance, currency)) 192 | 193 | if 'goal' in space: 194 | goal = space['goal']['amount'] 195 | percentage = available_balance / goal 196 | 197 | line.append("{} {}".format(goal, currency)) 198 | line.append('{:.2%}'.format(percentage)) 199 | else: 200 | line.append("-") 201 | line.append("-") 202 | 203 | lines.append(line) 204 | 205 | headers = ['Name', 'Balance', 'Goal', 'Progress'] 206 | text = tabulate(lines, headers, colalign=['left', 'right', 'right', 'right'], numalign='right') 207 | 208 | click.echo(text) 209 | 210 | 211 | @cli.command() 212 | @auth_decorator 213 | def cards(): 214 | """ Shows a list of cards """ 215 | cards_data = API_CLIENT.get_cards() 216 | if JSON_OUTPUT: 217 | _print_json(cards_data) 218 | return 219 | 220 | headers = ['Id', 'Masked Pan', 'Type', 'Design', 'Status', 'Activated', 'Pin defined', 'Expires'] 221 | keys = [ 222 | 'id', 223 | 'maskedPan', 224 | 'cardType', 225 | 'design', 226 | lambda x: "active" if (x.get('status') == CARD_STATUS_ACTIVE) else x.get('status'), 227 | _datetime_extractor('cardActivated'), 228 | _datetime_extractor('pinDefined'), 229 | _datetime_extractor('expirationDate', date_only=True), 230 | ] 231 | text = _create_table_from_dict(headers=headers, value_functions=keys, data=cards_data, numalign='right') 232 | 233 | click.echo(text.strip()) 234 | 235 | 236 | @cli.command() 237 | @click.option('--card', default=None, type=str, help='ID of the card to block. Omitting this will block all cards.') 238 | @auth_decorator 239 | def card_block(card: str): 240 | """ Blocks the card/s """ 241 | if card: 242 | card_ids = [card] 243 | else: 244 | card_ids = [card['id'] for card in API_CLIENT.get_cards()] 245 | 246 | for card_id in card_ids: 247 | API_CLIENT.block_card(card_id) 248 | click.echo('Blocked card: ' + card_id) 249 | 250 | 251 | @cli.command() 252 | @click.option('--card', default=None, type=str, help='ID of the card to unblock. Omitting this will unblock all cards.') 253 | @auth_decorator 254 | def card_unblock(card: str): 255 | """ Unblocks the card/s """ 256 | if card: 257 | card_ids = [card] 258 | else: 259 | card_ids = [card['id'] for card in API_CLIENT.get_cards()] 260 | 261 | for card_id in card_ids: 262 | API_CLIENT.unblock_card(card_id) 263 | click.echo('Unblocked card: ' + card_id) 264 | 265 | 266 | @cli.command() 267 | @auth_decorator 268 | def limits(): 269 | """ Show n26 account limits """ 270 | _limits() 271 | 272 | 273 | @cli.command() 274 | @click.option('--withdrawal', default=None, type=int, help='Daily withdrawal limit.') 275 | @click.option('--payment', default=None, type=int, help='Daily payment limit.') 276 | @auth_decorator 277 | def set_limits(withdrawal: int, payment: int): 278 | """ Set n26 account limits """ 279 | API_CLIENT.set_account_limits(withdrawal, payment) 280 | _limits() 281 | 282 | 283 | def _limits(): 284 | limits_data = API_CLIENT.get_account_limits() 285 | 286 | if JSON_OUTPUT: 287 | _print_json(limits_data) 288 | return 289 | 290 | headers = ['Name', 'Amount', 'Country List'] 291 | keys = ['limit', 'amount', 'countryList'] 292 | text = _create_table_from_dict(headers=headers, value_functions=keys, data=limits_data, numalign='right') 293 | 294 | click.echo(text) 295 | 296 | 297 | @cli.command() 298 | @auth_decorator 299 | def contacts(): 300 | """ Show your n26 contacts """ 301 | contacts_data = API_CLIENT.get_contacts() 302 | 303 | if JSON_OUTPUT: 304 | _print_json(contacts_data) 305 | return 306 | 307 | headers = ['Id', 'Name', 'IBAN'] 308 | keys = ['id', 'name', 'subtitle'] 309 | text = _create_table_from_dict(headers=headers, value_functions=keys, data=contacts_data, numalign='right') 310 | 311 | click.echo(text.strip()) 312 | 313 | 314 | @cli.command() 315 | @click.option('--id', default=None, type=str, 316 | help='Id of a single statement') 317 | @click.option('--from', 'param_from', default=None, type=click.DateTime(DATETIME_FORMATS), 318 | help='Start time limit for statements.') 319 | @click.option('--to', 'param_to', default=None, type=click.DateTime(DATETIME_FORMATS), 320 | help='End time limit for statements.') 321 | @click.option('--download', default=None, type=str, 322 | help='Download statements as pdf to this dir.') 323 | @auth_decorator 324 | def statements(id: str or None, param_from: datetime or None, param_to: datetime or None, download: str or None): 325 | """ Show your n26 statements """ 326 | statements_data = API_CLIENT.get_statements() 327 | statements_filter = None 328 | 329 | if id: 330 | statements_filter = lambda statement: statement['id'] == id 331 | elif param_from or param_to: 332 | statement_from = param_from if param_from else datetime.fromtimestamp(0) 333 | statement_to = param_to if param_to else datetime.utcnow() 334 | statements_filter = lambda statement: statement_from <= datetime(int(statement['year']), int(statement['month']), 1) <= statement_to 335 | 336 | if statements_filter: 337 | statements_data = list(filter(statements_filter, statements_data)) 338 | 339 | if JSON_OUTPUT: 340 | _print_json(statements_data) 341 | return 342 | 343 | headers = ['Id', 'Url', 'Visible TS', 'Month', 'Year'] 344 | keys = ['id', 'url', 'visibleTS', 'month', 'year'] 345 | text = _create_table_from_dict(headers=headers, value_functions=keys, data=statements_data, numalign='right') 346 | 347 | click.echo(text.strip()) 348 | 349 | if not download: 350 | return 351 | 352 | output_path = Path(download).expanduser().resolve() 353 | if not output_path.is_dir(): 354 | click.echo("Target path doesn't exist or is not a folder, skipping download.") 355 | return 356 | 357 | for statement in statements_data: 358 | filepath = Path.joinpath(output_path, f'{statement["id"]}.pdf') 359 | click.echo(f"Downloading {filepath}...") 360 | statement_data = API_CLIENT.get_balance_statement(statement['url']) 361 | with open(filepath, 'wb') as f: 362 | f.write(statement_data) 363 | 364 | 365 | @cli.command() 366 | @click.option('--categories', default=None, type=str, 367 | help='Comma separated list of category IDs.') 368 | @click.option('--pending', default=None, type=bool, 369 | help='Whether to show only pending transactions.') 370 | @click.option('--from', 'param_from', default=None, type=click.DateTime(DATETIME_FORMATS), 371 | help='Start time limit for transactions.') 372 | @click.option('--to', 'param_to', default=None, type=click.DateTime(DATETIME_FORMATS), 373 | help='End time limit for transactions.') 374 | @click.option('--text-filter', default=None, type=str, help='Text filter.') 375 | @click.option('--limit', default=None, type=click.IntRange(1, 10000), help='Limit transaction output.') 376 | @auth_decorator 377 | def transactions(categories: str, pending: bool, param_from: datetime or None, param_to: datetime or None, 378 | text_filter: str, limit: int): 379 | """ Show transactions (default: 5) """ 380 | if not JSON_OUTPUT and not pending and not param_from and not limit: 381 | limit = 5 382 | click.echo(click.style("Output is limited to {} entries.".format(limit), fg="yellow")) 383 | 384 | from_timestamp, to_timestamp = _parse_from_to_timestamps(param_from, param_to) 385 | transactions_data = API_CLIENT.get_transactions(from_time=from_timestamp, to_time=to_timestamp, 386 | limit=limit, pending=pending, text_filter=text_filter, 387 | categories=categories) 388 | 389 | if JSON_OUTPUT: 390 | _print_json(transactions_data) 391 | return 392 | 393 | lines = [] 394 | for i, transaction in enumerate(transactions_data): 395 | amount = transaction.get(AMOUNT, 0) 396 | currency = transaction.get(CURRENCY, None) 397 | 398 | if amount < 0: 399 | sender_name = "You" 400 | sender_iban = "" 401 | recipient_name = transaction.get('merchantName', transaction.get('partnerName', '')) 402 | recipient_iban = transaction.get('partnerIban', '') 403 | else: 404 | sender_name = transaction.get('partnerName', '') 405 | sender_iban = transaction.get('partnerIban', '') 406 | recipient_name = "You" 407 | recipient_iban = "" 408 | 409 | recurring = transaction.get('recurring', '') 410 | 411 | if transaction['type'] == ATM_WITHDRAW: 412 | message = "ATM Withdrawal" 413 | else: 414 | message = transaction.get(REFERENCE_TEXT) 415 | 416 | lines.append([ 417 | _datetime_extractor('visibleTS')(transaction), 418 | "{} {}".format(amount, currency), 419 | "{}\n{}".format(sender_name, sender_iban), 420 | "{}\n{}".format(recipient_name, recipient_iban), 421 | _insert_newlines(message), 422 | recurring 423 | ]) 424 | 425 | headers = ['Date', 'Amount', 'From', 'To', 'Message', 'Recurring'] 426 | text = tabulate(lines, headers, numalign='right') 427 | 428 | click.echo(text.strip()) 429 | 430 | 431 | @cli.command("transaction") 432 | @auth_decorator 433 | def transaction(): 434 | """Create a bank transfer""" 435 | # Get all the necessary transfer information from user's input 436 | iban = click.prompt("Recipient's IBAN (spaces are allowed): ", type=str) 437 | bic = click.prompt("Recipient's BIC (optional): ", type=str, default="", show_default=False) 438 | name = click.prompt("Recipient's name: ", type=str) 439 | reference = click.prompt("Transfer reference (optional): ", type=str, default="", show_default=False) 440 | amount = click.prompt("Transfer amount (only numeric value, dot separated): ", type=str) 441 | pin = click.prompt("Please enter your PIN (input is hidden): ", hide_input=True, type=str) 442 | 443 | response = API_CLIENT.create_transaction(iban, bic, name, reference, amount, pin) 444 | 445 | if JSON_OUTPUT: 446 | _print_json(response) 447 | 448 | 449 | @cli.command("standing-orders") 450 | @auth_decorator 451 | def standing_orders(): 452 | """Show your standing orders""" 453 | standing_orders_data = API_CLIENT.get_standing_orders() 454 | 455 | if JSON_OUTPUT: 456 | _print_json(standing_orders_data) 457 | return 458 | 459 | headers = ['To', 460 | 'Amount', 461 | 'Frequency', 462 | 'Until', 463 | 'Every', 464 | 'First execution', 'Next execution', 465 | 'Executions', 466 | 'Created', 'Updated'] 467 | values = ['partnerName', 468 | lambda x: "{} {}".format(x.get('amount'), x.get('currencyCode').get('currencyCode')), 469 | 'executionFrequency', 470 | _datetime_extractor('stopTS', date_only=True), 471 | _day_of_month_extractor('initialDayOfMonth'), 472 | _datetime_extractor('firstExecutingTS', date_only=True), 473 | _datetime_extractor('nextExecutingTS', date_only=True), 474 | 'executionCounter', 475 | _datetime_extractor('created'), 476 | _datetime_extractor('updated')] 477 | text = _create_table_from_dict(headers, value_functions=values, data=standing_orders_data['data']) 478 | 479 | click.echo(text.strip()) 480 | 481 | 482 | @cli.command() 483 | @click.option('--from', 'param_from', default=None, type=click.DateTime(DATETIME_FORMATS), 484 | help='Start time limit for statistics.') 485 | @click.option('--to', 'param_to', default=None, type=click.DateTime(DATETIME_FORMATS), 486 | help='End time limit for statistics.') 487 | @auth_decorator 488 | def statistics(param_from: datetime or None, param_to: datetime or None): 489 | """Show your n26 statistics""" 490 | 491 | from_timestamp, to_timestamp = _parse_from_to_timestamps(param_from, param_to) 492 | statistics_data = API_CLIENT.get_statistics(from_time=from_timestamp, to_time=to_timestamp) 493 | 494 | if JSON_OUTPUT: 495 | _print_json(statistics_data) 496 | return 497 | 498 | text = "From: %s\n" % (_timestamp_ms_to_date(statistics_data["from"])) 499 | text += "To: %s\n\n" % (_timestamp_ms_to_date(statistics_data["to"])) 500 | 501 | headers = ['Total', 'Income', 'Expense', '#IncomeCategories', '#ExpenseCategories'] 502 | values = ['total', 'totalIncome', 'totalExpense', 503 | lambda x: len(x.get('incomeItems')), 504 | lambda x: len(x.get('expenseItems'))] 505 | 506 | text += _create_table_from_dict(headers, value_functions=values, data=[statistics_data]) 507 | 508 | text += "\n\n" 509 | 510 | headers = ['Category', 'Income', 'Expense', 'Total'] 511 | keys = ['id', 'income', 'expense', 'total'] 512 | text += _create_table_from_dict(headers, keys, statistics_data["items"], numalign='right') 513 | 514 | click.echo(text.strip()) 515 | 516 | 517 | def _print_json(data: dict or list): 518 | """ 519 | Pretty-Prints the given object to the console 520 | :param data: data to print 521 | """ 522 | import json 523 | json_data = json.dumps(data, indent=2) 524 | click.echo(json_data) 525 | 526 | 527 | def _parse_from_to_timestamps(param_from: datetime or None, param_to: datetime or None) -> Tuple[int, int]: 528 | """ 529 | Parses cli datetime inputs for "from" and "to" parameters 530 | :param param_from: "from" input 531 | :param param_to: "to" input 532 | :return: timestamps ready to be used by the api 533 | """ 534 | from_timestamp = None 535 | to_timestamp = None 536 | if param_from is not None: 537 | from_timestamp = int(param_from.timestamp() * 1000) 538 | if param_to is None: 539 | # if --from is set, --to must also be set 540 | param_to = datetime.utcnow() 541 | if param_to is not None: 542 | if param_from is None: 543 | # if --to is set, --from must also be set 544 | from_timestamp = 1 545 | to_timestamp = int(param_to.timestamp() * 1000) 546 | 547 | return from_timestamp, to_timestamp 548 | 549 | 550 | def _timestamp_ms_to_date(epoch_ms: int) -> datetime or None: 551 | """ 552 | Convert millisecond timestamp to UTC datetime. 553 | 554 | :param epoch_ms: milliseconds since 1970 in CET 555 | :return: a UTC datetime object 556 | """ 557 | if epoch_ms: 558 | return datetime.fromtimestamp(epoch_ms / 1000, timezone.utc) 559 | 560 | 561 | def _create_table_from_dict(headers: list, value_functions: list, data: list, **tabulate_args) -> str: 562 | """ 563 | Helper function to turn a list of dictionaries into a table. 564 | 565 | Note: This method does NOT work with nested dictionaries and will only inspect top-level keys 566 | 567 | :param headers: the headers to use for the columns 568 | :param value_functions: function that extracts the value for a given key. Can also be a simple string that 569 | will be used as dictionary key. 570 | :param data: a list of dictionaries containing the data 571 | :return: a table 572 | """ 573 | 574 | if len(headers) != len(value_functions): 575 | raise AttributeError("Number of headers does not match number of keys!") 576 | 577 | lines = [] 578 | if isinstance(data, list): 579 | for dictionary in data: 580 | line = [] 581 | for value_function in value_functions: 582 | if callable(value_function): 583 | value = value_function(dictionary) 584 | line.append(value) 585 | else: 586 | # try to use is as a dict key 587 | line.append(dictionary.get(str(value_function))) 588 | 589 | lines.append(line) 590 | 591 | return tabulate(tabular_data=lines, headers=headers, **tabulate_args) 592 | 593 | 594 | def _datetime_extractor(key: str, date_only: bool = False): 595 | """ 596 | Helper function to extract a datetime value from a dict 597 | :param key: the dictionary key used to access the value 598 | :param date_only: removes the time from the output 599 | :return: an extractor function 600 | """ 601 | 602 | if date_only: 603 | fmt = "%x" 604 | else: 605 | fmt = "%x %X" 606 | 607 | def extractor(dictionary: dict): 608 | value = dictionary.get(key) 609 | time = _timestamp_ms_to_date(value) 610 | if time is None: 611 | return None 612 | else: 613 | time = time.astimezone() 614 | return time.strftime(fmt) 615 | 616 | return extractor 617 | 618 | 619 | def _day_of_month_extractor(key: str): 620 | def extractor(dictionary: dict): 621 | value = dictionary.get(key) 622 | if value is None: 623 | return None 624 | else: 625 | import inflect 626 | engine = inflect.engine() 627 | return engine.ordinal(value) 628 | 629 | return extractor 630 | 631 | 632 | def _insert_newlines(text: str, n=40): 633 | """ 634 | Inserts a newline into the given text every n characters. 635 | :param text: the text to break 636 | :param n: 637 | :return: 638 | """ 639 | if not text: 640 | return "" 641 | 642 | lines = [] 643 | for i in range(0, len(text), n): 644 | lines.append(text[i:i + n]) 645 | return '\n'.join(lines) 646 | 647 | 648 | if __name__ == '__main__': 649 | cli() 650 | -------------------------------------------------------------------------------- /n26/config.py: -------------------------------------------------------------------------------- 1 | from container_app_conf import ConfigBase 2 | from container_app_conf.entry.file import FileConfigEntry 3 | from container_app_conf.entry.string import StringConfigEntry 4 | from container_app_conf.source.env_source import EnvSource 5 | from container_app_conf.source.toml_source import TomlSource 6 | from container_app_conf.source.yaml_source import YamlSource 7 | 8 | NODE_ROOT = "n26" 9 | 10 | MFA_TYPE_APP = "app" 11 | MFA_TYPE_SMS = "sms" 12 | 13 | 14 | class Config(ConfigBase): 15 | 16 | def __new__(cls, *args, **kwargs): 17 | if "data_sources" not in kwargs.keys(): 18 | yaml_source = YamlSource("n26") 19 | toml_source = TomlSource("n26") 20 | data_sources = [ 21 | EnvSource(), 22 | yaml_source, 23 | toml_source 24 | ] 25 | kwargs["data_sources"] = data_sources 26 | 27 | return super(Config, cls).__new__(cls, *args, **kwargs) 28 | 29 | AUTH_BASE_URL = StringConfigEntry( 30 | description="Base URL for N26 Authentication", 31 | example="", 32 | default="https://api.tech26.de", 33 | key_path=[ 34 | NODE_ROOT, 35 | "auth_base_url" 36 | ], 37 | required=True 38 | ) 39 | 40 | USERNAME = StringConfigEntry( 41 | description="N26 account username", 42 | example="john.doe@example.com", 43 | key_path=[ 44 | NODE_ROOT, 45 | "username" 46 | ], 47 | required=True 48 | ) 49 | 50 | PASSWORD = StringConfigEntry( 51 | description="N26 account password", 52 | example="$upersecret", 53 | key_path=[ 54 | NODE_ROOT, 55 | "password" 56 | ], 57 | required=True, 58 | secret=True 59 | ) 60 | 61 | DEVICE_TOKEN = StringConfigEntry( 62 | description="N26 device token", 63 | example="00000000-0000-0000-0000-000000000000", 64 | key_path=[ 65 | NODE_ROOT, 66 | "device_token" 67 | ], 68 | required=True, 69 | regex="[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}", 70 | ) 71 | 72 | LOGIN_DATA_STORE_PATH = FileConfigEntry( 73 | description="File path to store login data", 74 | example="~/.config/n26/token_data", 75 | key_path=[ 76 | NODE_ROOT, 77 | "login_data_store_path" 78 | ], 79 | required=False, 80 | default=None 81 | ) 82 | 83 | MFA_TYPE = StringConfigEntry( 84 | description="Multi-Factor-Authentication type to use", 85 | example=MFA_TYPE_APP, 86 | key_path=[ 87 | NODE_ROOT, 88 | "mfa_type" 89 | ], 90 | regex="^({})$".format("|".join([MFA_TYPE_APP, MFA_TYPE_SMS])), 91 | default=MFA_TYPE_APP 92 | ) 93 | -------------------------------------------------------------------------------- /n26/const.py: -------------------------------------------------------------------------------- 1 | """ 2 | transaction types and their meaning 3 | """ 4 | DEBIT_PAYMENT = "AA" 5 | SEPA_WITHDRAW = "DD" 6 | INCOMING_TRANSFER = "CT" 7 | ATM_WITHDRAW = "PT" 8 | 9 | """ 10 | transaction item keys 11 | """ 12 | CURRENCY = 'currencyCode' 13 | AMOUNT = 'amount' 14 | REFERENCE_TEXT = 'referenceText' 15 | 16 | CARD_STATUS_ACTIVE = 'M_ACTIVE' 17 | 18 | DAILY_WITHDRAWAL_LIMIT = 'ATM_DAILY_ACCOUNT' 19 | DAILY_PAYMENT_LIMIT = 'POS_DAILY_ACCOUNT' 20 | 21 | DATETIME_FORMATS = [ 22 | '%m/%d/%Y %H:%M:%S', 23 | '%m/%d/%Y', 24 | '%Y-%m-%d', 25 | '%Y-%m-%dT%H:%M:%S', 26 | '%Y-%m-%d %H:%M:%S', 27 | '%d.%m.%Y', 28 | '%d.%m.%Y %H:%M:%S' 29 | ] 30 | -------------------------------------------------------------------------------- /n26/util.py: -------------------------------------------------------------------------------- 1 | def create_request_url(url: str, params: dict = None): 2 | """ 3 | Adds query params to the given url 4 | 5 | :param url: the url to extend 6 | :param params: query params as a keyed dictionary 7 | :return: the url including the given query params 8 | """ 9 | 10 | if params: 11 | first_param = True 12 | for k, v in sorted(params.items(), key=lambda entry: entry[0]): 13 | if not v: 14 | # skip None values 15 | continue 16 | 17 | if first_param: 18 | url += '?' 19 | first_param = False 20 | else: 21 | url += '&' 22 | 23 | url += "%s=%s" % (k, v) 24 | 25 | return url 26 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | certifi==2022.12.7 2 | chardet==5.1.0 3 | click==8.1.3 4 | container-app-conf==5.2.2 5 | idna==3.4 6 | importlib-metadata==6.0.0 7 | inflect==6.0.2 8 | more-itertools==9.0.0 9 | pycryptodome==3.17 10 | py-range-parse==1.0.5 11 | python-dateutil==2.8.2 12 | pytimeparse==1.1.8 13 | pyyaml==6.0 14 | requests==2.28.2 15 | ruamel.yaml==0.17.21 16 | ruamel.yaml.clib==0.2.7 17 | six==1.16.0 18 | tabulate==0.9.0 19 | tenacity==8.1.0 20 | toml==0.10.2 21 | urllib3>=1.26.5 22 | zipp==3.12.0 23 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import os 3 | 4 | from setuptools import setup 5 | 6 | __location__ = os.path.join(os.getcwd(), os.path.dirname(inspect.getfile(inspect.currentframe()))) 7 | 8 | 9 | def read_version(package): 10 | with open(os.path.join(package, '__init__.py'), 'r') as fd: 11 | for line in fd: 12 | if line.startswith('__version__ = '): 13 | return line.split()[-1].strip().strip("'") 14 | 15 | 16 | def read_requirements(): 17 | with open("requirements.txt", "r") as fh: 18 | requirements = fh.readlines() 19 | return [req.split("==")[0] for req in requirements if req.strip() != '' and not req.strip().startswith("#")] 20 | 21 | 22 | def get_install_requirements(path): 23 | content = open(os.path.join(__location__, path)).read() 24 | return [req for req in content.split('\\n') if req != ''] 25 | 26 | 27 | def read(fname): 28 | return open(os.path.join(__location__, fname)).read() 29 | 30 | 31 | VERSION = read_version('n26') 32 | 33 | setup( 34 | description='API and command line tools to interact with the https://n26.com/ API', 35 | long_description='API and command line tools to interact with the https://n26.com/ API', 36 | author='Felix Mueller', 37 | author_email='felix@s4ku.com', 38 | url='https://github.com/femueller/python-n26', 39 | download_url='https://github.com/femueller/python-n26/tarball/{version}'.format(version=VERSION), 40 | version=VERSION, 41 | install_requires=read_requirements(), 42 | test_requires=['mock', 'pytest'], 43 | packages=[ 44 | 'n26' 45 | ], 46 | scripts=[], 47 | name='n26', 48 | entry_points={ 49 | 'console_scripts': ['n26 = n26.cli:cli'] 50 | } 51 | ) 52 | -------------------------------------------------------------------------------- /tests/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/femueller/python-n26/b988aa021f89fa2d37764e3f01bd7e9980d4834d/tests/.gitkeep -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/femueller/python-n26/b988aa021f89fa2d37764e3f01bd7e9980d4834d/tests/__init__.py -------------------------------------------------------------------------------- /tests/api_responses/account_info.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "12345678-abcd-1234-abcd-1234567890ab", 3 | "email": "john.doe@example.com", 4 | "firstName": "John", 5 | "lastName": "Doe", 6 | "kycFirstName": "John Dee", 7 | "kycLastName": "Doe", 8 | "title": "", 9 | "gender": "MALE", 10 | "birthDate": 687916800000, 11 | "signupCompleted": true, 12 | "nationality": "DEU", 13 | "mobilePhoneNumber": "+491234567890", 14 | "shadowUserId": "12345678-1234-1234-1234-1234567890ab", 15 | "transferWiseTermsAccepted": false, 16 | "idNowToken": null 17 | } -------------------------------------------------------------------------------- /tests/api_responses/account_limits.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "limit": "POS_DAILY_ACCOUNT", 4 | "amount": 2500.00, 5 | "countryList": null 6 | }, 7 | { 8 | "limit": "ATM_DAILY_ACCOUNT", 9 | "amount": 2500.00, 10 | "countryList": null 11 | } 12 | ] -------------------------------------------------------------------------------- /tests/api_responses/account_statuses.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "12345678-1234-abcd-abcd-1234567890ab", 3 | "created": 1464196274939, 4 | "updated": 1541587937999, 5 | "singleStepSignup": 1464196273794, 6 | "emailValidationInitiated": 1464196273794, 7 | "emailValidationCompleted": 1464196346996, 8 | "productSelectionCompleted": 1464196864041, 9 | "phonePairingInitiated": 1543915572133, 10 | "phonePairingCompleted": 1543915572133, 11 | "userStatusCol": null, 12 | "kycInitiated": 1464196864041, 13 | "kycCompleted": 1464197135809, 14 | "kycPersonalCompleted": null, 15 | "kycPostIdentInitiated": null, 16 | "kycPostIdentCompleted": null, 17 | "kycWebIDInitiated": null, 18 | "kycWebIDCompleted": null, 19 | "kycDetails": { 20 | "status": "COMPLETED", 21 | "provider": "IDNOW" 22 | }, 23 | "cardActivationCompleted": 1479469342232, 24 | "cardIssued": 1464197136297, 25 | "pinDefinitionCompleted": 1472762906790, 26 | "accountClosed": null, 27 | "coreDataUpdated": 1464332400691, 28 | "unpairingProcessStatus": null, 29 | "isDeceased": null, 30 | "firstIncomingTransaction": 1464362123423, 31 | "flexAccount": false, 32 | "flexAccountConfirmed": 0, 33 | "signupStep": null, 34 | "unpairTokenCreation": null, 35 | "pairingState": "PAIRED" 36 | } -------------------------------------------------------------------------------- /tests/api_responses/addresses.json: -------------------------------------------------------------------------------- 1 | { 2 | "paging": { 3 | "previous": null, 4 | "next": null, 5 | "totalResults": 3 6 | }, 7 | "data": [ 8 | { 9 | "id": "12345678-1234-abcd-abcd-1234567890ab", 10 | "created": 1464196274025, 11 | "updated": 1497862816796, 12 | "addressLine1": "", 13 | "addressLine2": null, 14 | "streetName": "Einbahnstraße", 15 | "houseNumberBlock": "1", 16 | "zipCode": "12345", 17 | "cityName": "Berlin", 18 | "state": null, 19 | "countryName": "DEU", 20 | "type": "SHIPPING", 21 | "userId": "12345678-1234-abcd-abcd-1234567890ab", 22 | "fromAllowedCountry": true 23 | }, 24 | { 25 | "id": "22345678-1234-abcd-abcd-1234567890ab", 26 | "created": 1464197135809, 27 | "updated": 1497862816833, 28 | "addressLine1": "Markus Karl-Heinz Andreas Ressel", 29 | "addressLine2": null, 30 | "streetName": "EINBAHNSTRAßE", 31 | "houseNumberBlock": "1", 32 | "zipCode": "12345", 33 | "cityName": "BERLIN", 34 | "state": null, 35 | "countryName": "DEU", 36 | "type": "PASSPORT", 37 | "userId": "12345678-1234-abcd-abcd-1234567890ab", 38 | "fromAllowedCountry": true 39 | }, 40 | { 41 | "id": "32345678-1234-abcd-abcd-1234567890ab", 42 | "created": 1488552934000, 43 | "updated": 1497865225100, 44 | "addressLine1": "", 45 | "addressLine2": null, 46 | "streetName": "Einbahnstraße", 47 | "houseNumberBlock": "1", 48 | "zipCode": "12345", 49 | "cityName": "Berlin", 50 | "state": null, 51 | "countryName": "DEU", 52 | "type": "LEGAL", 53 | "userId": "12345678-1234-abcd-abcd-1234567890ab", 54 | "fromAllowedCountry": true 55 | } 56 | ] 57 | } -------------------------------------------------------------------------------- /tests/api_responses/auth_token.json: -------------------------------------------------------------------------------- 1 | { 2 | "access_token": "12345678-1234-1234-1234-123456789012", 3 | "token_type": "bearer", 4 | "refresh_token": "99999999-9999-9999-9999-999999999999", 5 | "expires_in": 1798, 6 | "scope": "trust", 7 | "host_url": "https://api.tech26.de" 8 | } -------------------------------------------------------------------------------- /tests/api_responses/balance.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "12345678-235b-1234-1234-1234567890ab", 3 | "physicalBalance": null, 4 | "availableBalance": 100.00, 5 | "usableBalance": 1000.00, 6 | "bankBalance": 500.50, 7 | "iban": "DE12345678901234567890", 8 | "bic": "NTSBDEB1XXX", 9 | "bankName": "N26 Bank", 10 | "seized": false, 11 | "currency": "EUR", 12 | "legalEntity": "EU", 13 | "externalId": { 14 | "iban": "DE12345678901234567890" 15 | } 16 | } -------------------------------------------------------------------------------- /tests/api_responses/card_block_single.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "12345678-1234-abcd-abcd-1234567890ab", 3 | "created": null, 4 | "updated": null, 5 | "publicToken": null, 6 | "maskedPan": "123456******1234", 7 | "expirationDate": 1638230400000, 8 | "cardType": "MASTERCARD", 9 | "membership": null, 10 | "exceetExpectedDeliveryDate": null, 11 | "exceetActualDeliveryDate": null, 12 | "exceetExpressCardDeliveryTrackingId": null, 13 | "exceetExpressCardDelivery": false, 14 | "exceetExpressCardDeliveryEmailSent": false, 15 | "userId": null, 16 | "accountId": null, 17 | "pinDefined": 1479469330508, 18 | "cardActivated": 1479469342108 19 | } -------------------------------------------------------------------------------- /tests/api_responses/card_unblock_single.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "12345678-1234-abcd-abcd-1234567890ab", 3 | "created": null, 4 | "updated": null, 5 | "publicToken": null, 6 | "maskedPan": "123456******1234", 7 | "expirationDate": 1638230400000, 8 | "cardType": "MASTERCARD", 9 | "membership": null, 10 | "exceetExpectedDeliveryDate": null, 11 | "exceetActualDeliveryDate": null, 12 | "exceetExpressCardDeliveryTrackingId": null, 13 | "exceetExpressCardDelivery": false, 14 | "exceetExpressCardDeliveryEmailSent": false, 15 | "userId": null, 16 | "accountId": null, 17 | "pinDefined": 1479469330508, 18 | "cardActivated": 1479469342108 19 | } -------------------------------------------------------------------------------- /tests/api_responses/cards.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "12345678-1234-abcd-abcd-1234567890ab", 4 | "publicToken": null, 5 | "pan": null, 6 | "maskedPan": "123456******1234", 7 | "expirationDate": 1638230400000, 8 | "cardType": "MASTERCARD", 9 | "status": "M_ACTIVE", 10 | "cardProduct": null, 11 | "cardProductType": "STANDARD", 12 | "pinDefined": 1479469330508, 13 | "cardActivated": 1479469342108, 14 | "usernameOnCard": "JOHN DOE", 15 | "exceetExpressCardDelivery": null, 16 | "membership": null, 17 | "exceetActualDeliveryDate": null, 18 | "exceetExpressCardDeliveryEmailSent": null, 19 | "exceetCardStatus": null, 20 | "exceetExpectedDeliveryDate": null, 21 | "exceetExpressCardDeliveryTrackingId": null, 22 | "cardSettingsId": null, 23 | "applePayEligible": true, 24 | "googlePayEligible": true, 25 | "design": "WORLD", 26 | "orderId": null, 27 | "mptsCard": true 28 | }, 29 | { 30 | "id": "22345678-1234-abcd-abcd-1234567890ab", 31 | "publicToken": null, 32 | "pan": null, 33 | "maskedPan": "765432******1234", 34 | "expirationDate": 1635638400000, 35 | "cardType": "MAESTRO", 36 | "status": "M_ACTIVE", 37 | "cardProduct": null, 38 | "cardProductType": "MAESTRO", 39 | "pinDefined": 1480439684277, 40 | "cardActivated": 1480439686272, 41 | "usernameOnCard": "JOHN DOE", 42 | "exceetExpressCardDelivery": null, 43 | "membership": null, 44 | "exceetActualDeliveryDate": null, 45 | "exceetExpressCardDeliveryEmailSent": null, 46 | "exceetCardStatus": null, 47 | "exceetExpectedDeliveryDate": null, 48 | "exceetExpressCardDeliveryTrackingId": null, 49 | "cardSettingsId": null, 50 | "applePayEligible": false, 51 | "googlePayEligible": false, 52 | "design": "MAESTRO", 53 | "orderId": null, 54 | "mptsCard": true 55 | } 56 | ] -------------------------------------------------------------------------------- /tests/api_responses/contacts.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "userId": "12345678-1234-abcd-abcd-1234567890ab", 4 | "id": "0fffffff-1234-abcd-abcd-1234567890ab", 5 | "name": "ADAC Berlin-Brandenburg", 6 | "subtitle": "DE84 1008 0000 0616 2162 00", 7 | "account": { 8 | "accountType": "sepa", 9 | "iban": "DE84100800000616216200", 10 | "bic": "DRESDEFF100" 11 | } 12 | }, 13 | { 14 | "userId": "12345678-1234-abcd-abcd-1234567890ab", 15 | "id": "1fffffff-1234-abcd-abcd-1234567890ab", 16 | "name": "Cyberport GmbH", 17 | "subtitle": "DE73 6808 0030 0723 3036 00", 18 | "account": { 19 | "accountType": "sepa", 20 | "iban": "DE73680800300723303600", 21 | "bic": "DRESDEFF680" 22 | } 23 | }, 24 | { 25 | "userId": "12345678-1234-abcd-abcd-1234567890ab", 26 | "id": "2fffffff-1234-abcd-abcd-1234567890ab", 27 | "name": "DB Vertrieb GmbH", 28 | "subtitle": "DE02 1001 0010 0152 5171 08", 29 | "account": { 30 | "accountType": "sepa", 31 | "iban": "DE02100100100152517108", 32 | "bic": "PBNKDEFFXXX" 33 | } 34 | }, 35 | { 36 | "userId": "12345678-1234-abcd-abcd-1234567890ab", 37 | "id": "3fffffff-1234-abcd-abcd-1234567890ab", 38 | "name": "ELV Elektronik www.elv.de", 39 | "subtitle": "DE96 2859 0075 0012 7744 00", 40 | "account": { 41 | "accountType": "sepa", 42 | "iban": "DE96285900750012774400", 43 | "bic": "GENODEF1LER" 44 | } 45 | }, 46 | { 47 | "userId": "12345678-1234-abcd-abcd-1234567890ab", 48 | "id": "4fffffff-1234-abcd-abcd-1234567890ab", 49 | "name": "Mindfactory AG", 50 | "subtitle": "DE91 2824 0023 0335 6334 02", 51 | "account": { 52 | "accountType": "sepa", 53 | "iban": "DE91282400230335633402", 54 | "bic": "COBADEFFXXX" 55 | } 56 | }, 57 | { 58 | "userId": "12345678-1234-abcd-abcd-1234567890ab", 59 | "id": "5fffffff-1234-abcd-abcd-1234567890ab", 60 | "name": "S. Seegel - Netbank", 61 | "subtitle": "DE07 2009 0500 0008 0049 35", 62 | "account": { 63 | "accountType": "sepa", 64 | "iban": "DE07200905000008004935", 65 | "bic": "GENODEF1S15" 66 | } 67 | } 68 | ] -------------------------------------------------------------------------------- /tests/api_responses/refresh_token.json: -------------------------------------------------------------------------------- 1 | { 2 | "access_token": "12345678-1234-abcd-abcd-1234567890ab", 3 | "token_type": "bearer", 4 | "refresh_token": "12345678-1234-abcd-abcd-1234567890ab", 5 | "expires_in": 1798, 6 | "scope": "trust", 7 | "host_url": "https://api.tech26.de" 8 | } -------------------------------------------------------------------------------- /tests/api_responses/spaces.json: -------------------------------------------------------------------------------- 1 | { 2 | "totalBalance": 5000.12, 3 | "visibleBalance": 5000.12, 4 | "spaces": [ 5 | { 6 | "id": "12345678-1234-abcd-abcd-1234567890ab", 7 | "accountId": "12345678-1234-abcd-abcd-1234567890ab", 8 | "name": "Main Account", 9 | "imageUrl": "https://cdn.number26.de/spaces/default-images/account_cards.jpg?version=1", 10 | "backgroundImageUrl": "https://cdn.number26.de/spaces/background-images/account_cards_background.jpg?version=1", 11 | "balance": { 12 | "availableBalance": 4850.12, 13 | "currency": "EUR", 14 | "overdraftAmount": 500.0 15 | }, 16 | "isPrimary": true, 17 | "isHiddenFromBalance": false, 18 | "isCardAttached": true, 19 | "goal": { 20 | "id": "12345678-1234-abcd-abcd-1234567890ab", 21 | "amount": 2000.0 22 | }, 23 | "isLocked": false 24 | }, 25 | { 26 | "id": "22345678-1234-abcd-abcd-1234567890ab", 27 | "accountId": "12345678-1234-abcd-abcd-1234567890ab", 28 | "name": "Vacation", 29 | "imageUrl": "https://cdn.number26.de/spaces/default-images/social_wine.jpg?version=1", 30 | "backgroundImageUrl": "https://cdn.number26.de/spaces/background-images/social_wine_background.jpg?version=1", 31 | "balance": { 32 | "availableBalance": 0.0, 33 | "currency": "EUR" 34 | }, 35 | "isPrimary": false, 36 | "isHiddenFromBalance": false, 37 | "isCardAttached": false, 38 | "goal": { 39 | "id": "22345678-1234-abcd-abcd-1234567890ab", 40 | "amount": 150.0 41 | }, 42 | "isLocked": false 43 | }, 44 | { 45 | "id": "32345678-1234-abcd-abcd-1234567890ab", 46 | "accountId": "12345678-1234-abcd-abcd-1234567890ab", 47 | "name": "Tech", 48 | "imageUrl": "https://cdn.number26.de/spaces/default-images/invest_calculator.jpg?version=1", 49 | "backgroundImageUrl": "https://cdn.number26.de/spaces/background-images/invest_calculator_background.jpg?version=1", 50 | "balance": { 51 | "availableBalance": 150.0, 52 | "currency": "EUR" 53 | }, 54 | "isPrimary": false, 55 | "isHiddenFromBalance": false, 56 | "isCardAttached": false, 57 | "goal": { 58 | "id": "32345678-1234-abcd-abcd-1234567890ab", 59 | "amount": 500.0 60 | }, 61 | "isLocked": false 62 | } 63 | ], 64 | "userFeatures": { 65 | "availableSpaces": 0, 66 | "canUpgrade": true 67 | } 68 | } -------------------------------------------------------------------------------- /tests/api_responses/standing_orders.json: -------------------------------------------------------------------------------- 1 | { 2 | "paging": { 3 | "previous": null, 4 | "next": null, 5 | "totalResults": 6 6 | }, 7 | "data": [ 8 | { 9 | "id": "12345678-1234-abcd-abcd-1234567890ab", 10 | "created": 1530443906352, 11 | "updated": 1554078589461, 12 | "amount": 123.45, 13 | "currencyCode": { 14 | "currencyCode": "EUR" 15 | }, 16 | "partnerIban": "DE12345678901234567890", 17 | "partnerBic": "ABCDEFGH123", 18 | "partnerAccountIsSepa": true, 19 | "partnerAccountBan": "1234567890", 20 | "partnerBcn": "12345678", 21 | "userCertified": 1530443939838, 22 | "userCanceled": null, 23 | "n26Iban": "DE12345678901234567890", 24 | "bankTransferTypeText": null, 25 | "firstExecutingTS": 1530403200000, 26 | "nextExecutingTS": 1556668800000, 27 | "stopTS": null, 28 | "referenceText": "This is a text", 29 | "partnerBankName": "ING-DiBa Frankfurt am Main", 30 | "partnerName": "Someone", 31 | "initialDayOfMonth": 1, 32 | "linkId": null, 33 | "internal": false, 34 | "referenceToOriginalOperation": null, 35 | "executionFrequency": "MONTHLY", 36 | "executionCounter": 10, 37 | "userId": "12345678-1234-abcd-abcd-1234567890ab", 38 | "accountId": "12345678-1234-abcd-abcd-1234567890ab" 39 | }, 40 | { 41 | "id": "22345678-1234-abcd-abcd-1234567890ab", 42 | "created": 1482798657697, 43 | "updated": 1484006544113, 44 | "amount": 150.00, 45 | "currencyCode": { 46 | "currencyCode": "EUR" 47 | }, 48 | "partnerIban": "DE12345678901234567890", 49 | "partnerBic": "ABCDEFGH123", 50 | "partnerAccountIsSepa": true, 51 | "partnerAccountBan": "123456789", 52 | "partnerBcn": "12345678", 53 | "userCertified": 1482798661529, 54 | "userCanceled": null, 55 | "n26Iban": "DE12345678901234567890", 56 | "bankTransferTypeText": null, 57 | "firstExecutingTS": 1484006400000, 58 | "nextExecutingTS": null, 59 | "stopTS": 1484092800000, 60 | "referenceText": "This is a text", 61 | "partnerBankName": "Postbank Stuttgart", 62 | "partnerName": "Mr. Anderson", 63 | "initialDayOfMonth": 10, 64 | "linkId": null, 65 | "internal": false, 66 | "referenceToOriginalOperation": null, 67 | "executionFrequency": "WEEKLY", 68 | "executionCounter": 1, 69 | "userId": "12345678-1234-abcd-abcd-1234567890ab", 70 | "accountId": "12345678-1234-abcd-abcd-1234567890ab" 71 | }, 72 | { 73 | "id": "32345678-1234-abcd-abcd-1234567890ab", 74 | "created": 1540242505911, 75 | "updated": 1540242600456, 76 | "amount": 12.00, 77 | "currencyCode": { 78 | "currencyCode": "EUR" 79 | }, 80 | "partnerIban": "DE12345678901234567890", 81 | "partnerBic": "ABCDEFGH123", 82 | "partnerAccountIsSepa": true, 83 | "partnerAccountBan": "1234567890", 84 | "partnerBcn": "12345678", 85 | "userCertified": 1540242519277, 86 | "userCanceled": null, 87 | "n26Iban": "DE12345678901234567890", 88 | "bankTransferTypeText": null, 89 | "firstExecutingTS": null, 90 | "nextExecutingTS": 1569888000000, 91 | "stopTS": null, 92 | "referenceText": "This is a text", 93 | "partnerBankName": "Bank für Sozialwirtschaft", 94 | "partnerName": "mailbox.org", 95 | "initialDayOfMonth": 1, 96 | "linkId": null, 97 | "internal": false, 98 | "referenceToOriginalOperation": null, 99 | "executionFrequency": "YEARLY", 100 | "executionCounter": 0, 101 | "userId": "12345678-1234-abcd-abcd-1234567890ab", 102 | "accountId": "12345678-1234-abcd-abcd-1234567890ab" 103 | }, 104 | { 105 | "id": "42345678-1234-abcd-abcd-1234567890ab", 106 | "created": 1542144475361, 107 | "updated": 1554082094139, 108 | "amount": 20.00, 109 | "currencyCode": { 110 | "currencyCode": "EUR" 111 | }, 112 | "partnerIban": "DE12345678901234567890", 113 | "partnerBic": "ABCDEFGH123", 114 | "partnerAccountIsSepa": true, 115 | "partnerAccountBan": "123456789", 116 | "partnerBcn": "12345678", 117 | "userCertified": 1542144500291, 118 | "userCanceled": null, 119 | "n26Iban": "DE12345678901234567890", 120 | "bankTransferTypeText": null, 121 | "firstExecutingTS": 1543622400000, 122 | "nextExecutingTS": 1556668800000, 123 | "stopTS": null, 124 | "referenceText": "This is a text", 125 | "partnerBankName": "ING-DiBa Frankfurt am Main", 126 | "partnerName": "Someone ", 127 | "initialDayOfMonth": 1, 128 | "linkId": null, 129 | "internal": false, 130 | "referenceToOriginalOperation": null, 131 | "executionFrequency": "MONTHLY", 132 | "executionCounter": 5, 133 | "userId": "12345678-1234-abcd-abcd-1234567890ab", 134 | "accountId": "12345678-1234-abcd-abcd-1234567890ab" 135 | }, 136 | { 137 | "id": "52345678-1234-abcd-abcd-1234567890ab", 138 | "created": 1530444127426, 139 | "updated": 1554082943538, 140 | "amount": 150.00, 141 | "currencyCode": { 142 | "currencyCode": "EUR" 143 | }, 144 | "partnerIban": "DE12345678901234567890", 145 | "partnerBic": "ABCDEFGH123", 146 | "partnerAccountIsSepa": true, 147 | "partnerAccountBan": "123456789", 148 | "partnerBcn": "12345678", 149 | "userCertified": 1530444143654, 150 | "userCanceled": null, 151 | "n26Iban": "DE12345678901234567890", 152 | "bankTransferTypeText": null, 153 | "firstExecutingTS": 1530403200000, 154 | "nextExecutingTS": 1556668800000, 155 | "stopTS": null, 156 | "referenceText": "This is a text", 157 | "partnerBankName": "ING-DiBa Frankfurt am Main", 158 | "partnerName": "Someone else", 159 | "initialDayOfMonth": 1, 160 | "linkId": null, 161 | "internal": false, 162 | "referenceToOriginalOperation": null, 163 | "executionFrequency": "MONTHLY", 164 | "executionCounter": 10, 165 | "userId": "12345678-1234-abcd-abcd-1234567890ab", 166 | "accountId": "12345678-1234-abcd-abcd-1234567890ab" 167 | }, 168 | { 169 | "id": "62345678-1234-abcd-abcd-1234567890ab", 170 | "created": 1540242322783, 171 | "updated": 1540860232451, 172 | "amount": 5.00, 173 | "currencyCode": { 174 | "currencyCode": "EUR" 175 | }, 176 | "partnerIban": "DE12345678901234567890", 177 | "partnerBic": "ABCDEFGH123", 178 | "partnerAccountIsSepa": true, 179 | "partnerAccountBan": "123456789", 180 | "partnerBcn": "12345678", 181 | "userCertified": 1540242338451, 182 | "userCanceled": null, 183 | "n26Iban": "DE12345678901234567890", 184 | "bankTransferTypeText": null, 185 | "firstExecutingTS": 1540857600000, 186 | "nextExecutingTS": 1572393600000, 187 | "stopTS": null, 188 | "referenceText": "KdNr 123456", 189 | "partnerBankName": "Commerzbank Freiburg i Br", 190 | "partnerName": "INWX GmbH & Co. KG", 191 | "initialDayOfMonth": 30, 192 | "linkId": null, 193 | "internal": false, 194 | "referenceToOriginalOperation": null, 195 | "executionFrequency": "YEARLY", 196 | "executionCounter": 1, 197 | "userId": "12345678-1234-abcd-abcd-1234567890ab", 198 | "accountId": "12345678-1234-abcd-abcd-1234567890ab" 199 | } 200 | ] 201 | } -------------------------------------------------------------------------------- /tests/api_responses/statement.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/femueller/python-n26/b988aa021f89fa2d37764e3f01bd7e9980d4834d/tests/api_responses/statement.pdf -------------------------------------------------------------------------------- /tests/api_responses/statements.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "statement-2019-04", 4 | "url": "/api/statements/statement-2019-04", 5 | "visibleTS": 1554076800000, 6 | "month": 4, 7 | "year": 2019 8 | }, 9 | { 10 | "id": "statement-2019-03", 11 | "url": "/api/statements/statement-2019-03", 12 | "visibleTS": 1551398400000, 13 | "month": 3, 14 | "year": 2019 15 | }, 16 | { 17 | "id": "statement-2019-02", 18 | "url": "/api/statements/statement-2019-02", 19 | "visibleTS": 1548979200000, 20 | "month": 2, 21 | "year": 2019 22 | }, 23 | { 24 | "id": "statement-2019-01", 25 | "url": "/api/statements/statement-2019-01", 26 | "visibleTS": 1546300800000, 27 | "month": 1, 28 | "year": 2019 29 | }, 30 | { 31 | "id": "statement-2018-12", 32 | "url": "/api/statements/statement-2018-12", 33 | "visibleTS": 1543622400000, 34 | "month": 12, 35 | "year": 2018 36 | }, 37 | { 38 | "id": "statement-2018-11", 39 | "url": "/api/statements/statement-2018-11", 40 | "visibleTS": 1541030400000, 41 | "month": 11, 42 | "year": 2018 43 | }, 44 | { 45 | "id": "statement-2018-10", 46 | "url": "/api/statements/statement-2018-10", 47 | "visibleTS": 1538352000000, 48 | "month": 10, 49 | "year": 2018 50 | }, 51 | { 52 | "id": "statement-2018-09", 53 | "url": "/api/statements/statement-2018-09", 54 | "visibleTS": 1535760000000, 55 | "month": 9, 56 | "year": 2018 57 | }, 58 | { 59 | "id": "statement-2018-08", 60 | "url": "/api/statements/statement-2018-08", 61 | "visibleTS": 1533081600000, 62 | "month": 8, 63 | "year": 2018 64 | }, 65 | { 66 | "id": "statement-2018-07", 67 | "url": "/api/statements/statement-2018-07", 68 | "visibleTS": 1530403200000, 69 | "month": 7, 70 | "year": 2018 71 | }, 72 | { 73 | "id": "statement-2018-06", 74 | "url": "/api/statements/statement-2018-06", 75 | "visibleTS": 1527811200000, 76 | "month": 6, 77 | "year": 2018 78 | }, 79 | { 80 | "id": "statement-2018-05", 81 | "url": "/api/statements/statement-2018-05", 82 | "visibleTS": 1525132800000, 83 | "month": 5, 84 | "year": 2018 85 | }, 86 | { 87 | "id": "statement-2018-04", 88 | "url": "/api/statements/statement-2018-04", 89 | "visibleTS": 1522540800000, 90 | "month": 4, 91 | "year": 2018 92 | }, 93 | { 94 | "id": "statement-2018-03", 95 | "url": "/api/statements/statement-2018-03", 96 | "visibleTS": 1519862400000, 97 | "month": 3, 98 | "year": 2018 99 | }, 100 | { 101 | "id": "statement-2018-02", 102 | "url": "/api/statements/statement-2018-02", 103 | "visibleTS": 1517443200000, 104 | "month": 2, 105 | "year": 2018 106 | }, 107 | { 108 | "id": "statement-2018-01", 109 | "url": "/api/statements/statement-2018-01", 110 | "visibleTS": 1514764800000, 111 | "month": 1, 112 | "year": 2018 113 | }, 114 | { 115 | "id": "statement-2017-12", 116 | "url": "/api/statements/statement-2017-12", 117 | "visibleTS": 1512086400000, 118 | "month": 12, 119 | "year": 2017 120 | }, 121 | { 122 | "id": "statement-2017-11", 123 | "url": "/api/statements/statement-2017-11", 124 | "visibleTS": 1509494400000, 125 | "month": 11, 126 | "year": 2017 127 | }, 128 | { 129 | "id": "statement-2017-10", 130 | "url": "/api/statements/statement-2017-10", 131 | "visibleTS": 1506816000000, 132 | "month": 10, 133 | "year": 2017 134 | }, 135 | { 136 | "id": "statement-2017-09", 137 | "url": "/api/statements/statement-2017-09", 138 | "visibleTS": 1504224000000, 139 | "month": 9, 140 | "year": 2017 141 | }, 142 | { 143 | "id": "statement-2017-08", 144 | "url": "/api/statements/statement-2017-08", 145 | "visibleTS": 1501545600000, 146 | "month": 8, 147 | "year": 2017 148 | }, 149 | { 150 | "id": "statement-2017-07", 151 | "url": "/api/statements/statement-2017-07", 152 | "visibleTS": 1498867200000, 153 | "month": 7, 154 | "year": 2017 155 | }, 156 | { 157 | "id": "statement-2017-06", 158 | "url": "/api/statements/statement-2017-06", 159 | "visibleTS": 1496275200000, 160 | "month": 6, 161 | "year": 2017 162 | }, 163 | { 164 | "id": "statement-2017-05", 165 | "url": "/api/statements/statement-2017-05", 166 | "visibleTS": 1493596800000, 167 | "month": 5, 168 | "year": 2017 169 | }, 170 | { 171 | "id": "statement-2017-04", 172 | "url": "/api/statements/statement-2017-04", 173 | "visibleTS": 1491004800000, 174 | "month": 4, 175 | "year": 2017 176 | }, 177 | { 178 | "id": "statement-2017-03", 179 | "url": "/api/statements/statement-2017-03", 180 | "visibleTS": 1488326400000, 181 | "month": 3, 182 | "year": 2017 183 | }, 184 | { 185 | "id": "statement-2017-02", 186 | "url": "/api/statements/statement-2017-02", 187 | "visibleTS": 1485907200000, 188 | "month": 2, 189 | "year": 2017 190 | }, 191 | { 192 | "id": "statement-2017-01", 193 | "url": "/api/statements/statement-2017-01", 194 | "visibleTS": 1483228800000, 195 | "month": 1, 196 | "year": 2017 197 | }, 198 | { 199 | "id": "statement-2016-12", 200 | "url": "/api/statements/statement-2016-12", 201 | "visibleTS": 1480550400000, 202 | "month": 12, 203 | "year": 2016 204 | }, 205 | { 206 | "id": "statement-2016-11", 207 | "url": "/api/statements/statement-2016-11", 208 | "visibleTS": 1477958400000, 209 | "month": 11, 210 | "year": 2016 211 | }, 212 | { 213 | "id": "Wirecard-2016-11", 214 | "url": "/api/statements/Wirecard-2016-11", 215 | "visibleTS": 1477958400000, 216 | "month": 11, 217 | "year": 2016 218 | } 219 | ] -------------------------------------------------------------------------------- /tests/api_responses/statistics.json: -------------------------------------------------------------------------------- 1 | { 2 | "from": 0, 3 | "to": 1554236823000, 4 | "total": 649.0200000000048, 5 | "totalIncome": 32929.41999999999, 6 | "totalExpense": 32280.399999999994, 7 | "items": [ 8 | { 9 | "id": "micro-v2-income", 10 | "income": 10600.950000000003, 11 | "expense": 0.0, 12 | "total": 10600.950000000003 13 | }, 14 | { 15 | "id": "micro-v2-salary", 16 | "income": 20148.399999999998, 17 | "expense": 0.0, 18 | "total": 20148.399999999998 19 | }, 20 | { 21 | "id": "micro-v2-miscellaneous", 22 | "income": 1854.2499999999998, 23 | "expense": 11335.769999999995, 24 | "total": -9481.519999999995 25 | }, 26 | { 27 | "id": "micro-v2-bars-restaurants", 28 | "income": 0.0, 29 | "expense": 899.0300000000001, 30 | "total": -899.0300000000001 31 | }, 32 | { 33 | "id": "micro-v2-media-electronics", 34 | "income": 89.30000000000001, 35 | "expense": 3514.9799999999996, 36 | "total": -3425.6799999999994 37 | }, 38 | { 39 | "id": "micro-v2-household-utilities", 40 | "income": 0.0, 41 | "expense": 5835.219999999997, 42 | "total": -5835.219999999997 43 | }, 44 | { 45 | "id": "micro-v2-transport-car", 46 | "income": 0.5, 47 | "expense": 1884.0199999999998, 48 | "total": -1883.5199999999998 49 | }, 50 | { 51 | "id": "micro-v2-atm", 52 | "income": 0.0, 53 | "expense": 2564.95, 54 | "total": -2564.95 55 | }, 56 | { 57 | "id": "micro-v2-tax-fines", 58 | "income": 0.0, 59 | "expense": 276.90000000000003, 60 | "total": -276.90000000000003 61 | }, 62 | { 63 | "id": "micro-v2-business", 64 | "income": 1.0, 65 | "expense": 581.5, 66 | "total": -580.5 67 | }, 68 | { 69 | "id": "micro-v2-food-groceries", 70 | "income": 0.0, 71 | "expense": 1611.9500000000003, 72 | "total": -1611.9500000000003 73 | }, 74 | { 75 | "id": "micro-v2-insurances-finances", 76 | "income": 108.94, 77 | "expense": 425.96, 78 | "total": -317.02 79 | }, 80 | { 81 | "id": "micro-v2-shopping", 82 | "income": 68.34, 83 | "expense": 1687.0600000000002, 84 | "total": -1618.7200000000003 85 | }, 86 | { 87 | "id": "micro-v2-healthcare-drugstores", 88 | "income": 0.0, 89 | "expense": 80.99000000000001, 90 | "total": -80.99000000000001 91 | }, 92 | { 93 | "id": "micro-v2-subscriptions-donations", 94 | "income": 0.0, 95 | "expense": 155.51, 96 | "total": -155.51 97 | }, 98 | { 99 | "id": "micro-v2-leisure-entertainment", 100 | "income": 57.74, 101 | "expense": 167.09000000000003, 102 | "total": -109.35000000000002 103 | }, 104 | { 105 | "id": "micro-v2-education", 106 | "income": 0.0, 107 | "expense": 3.42, 108 | "total": -3.42 109 | }, 110 | { 111 | "id": "micro-v2-family-friends", 112 | "income": 0.0, 113 | "expense": 413.27, 114 | "total": -413.27 115 | }, 116 | { 117 | "id": "micro-v2-travel-holidays", 118 | "income": 0.0, 119 | "expense": 762.78, 120 | "total": -762.78 121 | }, 122 | { 123 | "id": "micro-v2-cash26", 124 | "income": 0.0, 125 | "expense": 80.0, 126 | "total": -80.0 127 | } 128 | ], 129 | "incomeItems": [ 130 | { 131 | "id": "micro-v2-income", 132 | "income": 10600.950000000003, 133 | "expense": 0.0, 134 | "total": 10600.950000000003 135 | }, 136 | { 137 | "id": "micro-v2-salary", 138 | "income": 20148.399999999998, 139 | "expense": 0.0, 140 | "total": 20148.399999999998 141 | }, 142 | { 143 | "id": "micro-v2-miscellaneous", 144 | "income": 1854.2499999999998, 145 | "expense": 11335.769999999995, 146 | "total": -9481.519999999995 147 | }, 148 | { 149 | "id": "micro-v2-media-electronics", 150 | "income": 89.30000000000001, 151 | "expense": 3514.9799999999996, 152 | "total": -3425.6799999999994 153 | }, 154 | { 155 | "id": "micro-v2-transport-car", 156 | "income": 0.5, 157 | "expense": 1884.0199999999998, 158 | "total": -1883.5199999999998 159 | }, 160 | { 161 | "id": "micro-v2-business", 162 | "income": 1.0, 163 | "expense": 581.5, 164 | "total": -580.5 165 | }, 166 | { 167 | "id": "micro-v2-insurances-finances", 168 | "income": 108.94, 169 | "expense": 425.96, 170 | "total": -317.02 171 | }, 172 | { 173 | "id": "micro-v2-shopping", 174 | "income": 68.34, 175 | "expense": 1687.0600000000002, 176 | "total": -1618.7200000000003 177 | }, 178 | { 179 | "id": "micro-v2-leisure-entertainment", 180 | "income": 57.74, 181 | "expense": 167.09000000000003, 182 | "total": -109.35000000000002 183 | } 184 | ], 185 | "expenseItems": [ 186 | { 187 | "id": "micro-v2-miscellaneous", 188 | "income": 1854.2499999999998, 189 | "expense": 11335.769999999995, 190 | "total": -9481.519999999995 191 | }, 192 | { 193 | "id": "micro-v2-bars-restaurants", 194 | "income": 0.0, 195 | "expense": 899.0300000000001, 196 | "total": -899.0300000000001 197 | }, 198 | { 199 | "id": "micro-v2-media-electronics", 200 | "income": 89.30000000000001, 201 | "expense": 3514.9799999999996, 202 | "total": -3425.6799999999994 203 | }, 204 | { 205 | "id": "micro-v2-household-utilities", 206 | "income": 0.0, 207 | "expense": 5835.219999999997, 208 | "total": -5835.219999999997 209 | }, 210 | { 211 | "id": "micro-v2-transport-car", 212 | "income": 0.5, 213 | "expense": 1884.0199999999998, 214 | "total": -1883.5199999999998 215 | }, 216 | { 217 | "id": "micro-v2-atm", 218 | "income": 0.0, 219 | "expense": 2564.95, 220 | "total": -2564.95 221 | }, 222 | { 223 | "id": "micro-v2-tax-fines", 224 | "income": 0.0, 225 | "expense": 276.90000000000003, 226 | "total": -276.90000000000003 227 | }, 228 | { 229 | "id": "micro-v2-business", 230 | "income": 1.0, 231 | "expense": 581.5, 232 | "total": -580.5 233 | }, 234 | { 235 | "id": "micro-v2-food-groceries", 236 | "income": 0.0, 237 | "expense": 1611.9500000000003, 238 | "total": -1611.9500000000003 239 | }, 240 | { 241 | "id": "micro-v2-insurances-finances", 242 | "income": 108.94, 243 | "expense": 425.96, 244 | "total": -317.02 245 | }, 246 | { 247 | "id": "micro-v2-shopping", 248 | "income": 68.34, 249 | "expense": 1687.0600000000002, 250 | "total": -1618.7200000000003 251 | }, 252 | { 253 | "id": "micro-v2-healthcare-drugstores", 254 | "income": 0.0, 255 | "expense": 80.99000000000001, 256 | "total": -80.99000000000001 257 | }, 258 | { 259 | "id": "micro-v2-subscriptions-donations", 260 | "income": 0.0, 261 | "expense": 155.51, 262 | "total": -155.51 263 | }, 264 | { 265 | "id": "micro-v2-leisure-entertainment", 266 | "income": 57.74, 267 | "expense": 167.09000000000003, 268 | "total": -109.35000000000002 269 | }, 270 | { 271 | "id": "micro-v2-education", 272 | "income": 0.0, 273 | "expense": 3.42, 274 | "total": -3.42 275 | }, 276 | { 277 | "id": "micro-v2-family-friends", 278 | "income": 0.0, 279 | "expense": 413.27, 280 | "total": -413.27 281 | }, 282 | { 283 | "id": "micro-v2-travel-holidays", 284 | "income": 0.0, 285 | "expense": 762.78, 286 | "total": -762.78 287 | }, 288 | { 289 | "id": "micro-v2-cash26", 290 | "income": 0.0, 291 | "expense": 80.0, 292 | "total": -80.0 293 | } 294 | ] 295 | } -------------------------------------------------------------------------------- /tests/api_responses/transactions.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "12345678-1234-abcd-abcd-1234567890ab", 4 | "userId": "12345678-1234-abcd-abcd-1234567890ab", 5 | "type": "CT", 6 | "amount": 40.0, 7 | "currencyCode": "EUR", 8 | "visibleTS": 1554188463459, 9 | "mcc": 0, 10 | "mccGroup": 12, 11 | "recurring": false, 12 | "partnerBic": "ABCDEFGH123", 13 | "partnerAccountIsSepa": true, 14 | "partnerName": "Partner Name", 15 | "accountId": "12345678-1234-abcd-abcd-1234567890ab", 16 | "partnerIban": "DE12345678901234567890", 17 | "category": "micro-v2-income", 18 | "referenceText": "Message of this transaction", 19 | "userAccepted": 1554188463459, 20 | "userCertified": 1554188463459, 21 | "pending": false, 22 | "transactionNature": "NORMAL", 23 | "transactionTerminal": "ATM", 24 | "createdTS": 1554188463490, 25 | "smartLinkId": "12345678-1234-abcd-abcd-1234567890ab", 26 | "linkId": "12345678-1234-abcd-abcd-1234567890ab", 27 | "confirmed": 1554188463459 28 | }, 29 | { 30 | "id": "12345678-1234-abcd-abcd-1234567890ab", 31 | "userId": "12345678-1234-abcd-abcd-1234567890ab", 32 | "type": "CT", 33 | "amount": 10.0, 34 | "currencyCode": "EUR", 35 | "visibleTS": 1554188428868, 36 | "mcc": 0, 37 | "mccGroup": 12, 38 | "recurring": false, 39 | "partnerBic": "ABCDEFGH123", 40 | "partnerAccountIsSepa": true, 41 | "partnerName": "Partner Name", 42 | "accountId": "12345678-1234-abcd-abcd-1234567890ab", 43 | "partnerIban": "DE12345678901234567890", 44 | "category": "micro-v2-salary", 45 | "referenceText": "Message of this transaction", 46 | "userAccepted": 1554188428868, 47 | "userCertified": 1554188428868, 48 | "pending": false, 49 | "transactionNature": "NORMAL", 50 | "transactionTerminal": "ATM", 51 | "createdTS": 1554188428899, 52 | "purposeCode": "RINP", 53 | "smartLinkId": "12345678-1234-abcd-abcd-1234567890ab", 54 | "linkId": "12345678-1234-abcd-abcd-1234567890ab", 55 | "confirmed": 1554188428868 56 | }, 57 | { 58 | "id": "12345678-1234-abcd-abcd-1234567890ab", 59 | "userId": "12345678-1234-abcd-abcd-1234567890ab", 60 | "type": "CT", 61 | "amount": 3.0, 62 | "currencyCode": "EUR", 63 | "visibleTS": 1554188428822, 64 | "mcc": 0, 65 | "mccGroup": 12, 66 | "recurring": false, 67 | "partnerBic": "ABCDEFGH123", 68 | "partnerAccountIsSepa": true, 69 | "partnerName": "Partner Name", 70 | "accountId": "12345678-1234-abcd-abcd-1234567890ab", 71 | "partnerIban": "DE12345678901234567890", 72 | "category": "micro-v2-salary", 73 | "referenceText": "Message of this transaction", 74 | "userAccepted": 1554188428822, 75 | "userCertified": 1554188428822, 76 | "pending": false, 77 | "transactionNature": "NORMAL", 78 | "transactionTerminal": "ATM", 79 | "createdTS": 1554188428859, 80 | "purposeCode": "RINP", 81 | "smartLinkId": "12345678-1234-abcd-abcd-1234567890ab", 82 | "linkId": "12345678-1234-abcd-abcd-1234567890ab", 83 | "confirmed": 1554188428822 84 | }, 85 | { 86 | "id": "12345678-1234-abcd-abcd-1234567890ab", 87 | "userId": "12345678-1234-abcd-abcd-1234567890ab", 88 | "type": "DD", 89 | "amount": -52.5, 90 | "currencyCode": "EUR", 91 | "visibleTS": 1554139826875, 92 | "mcc": 0, 93 | "mccGroup": 12, 94 | "recurring": false, 95 | "partnerBic": "ABCDEFGH123", 96 | "partnerAccountIsSepa": false, 97 | "partnerName": "Partner Name", 98 | "accountId": "12345678-1234-abcd-abcd-1234567890ab", 99 | "partnerIban": "DE12345678901234567890", 100 | "category": "micro-v2-miscellaneous", 101 | "referenceText": "Message of this transaction", 102 | "userCertified": 1554139826875, 103 | "pending": false, 104 | "transactionNature": "NORMAL", 105 | "transactionTerminal": "ATM", 106 | "createdTS": 1554139826876, 107 | "mandateId": "6733274121701", 108 | "creditorIdentifier": "DE12345678901234567", 109 | "creditorName": "Creditor Name", 110 | "smartLinkId": "12345678-1234-abcd-abcd-1234567890ab", 111 | "linkId": "12345678-1234-abcd-abcd-1234567890ab", 112 | "confirmed": 1554076800000 113 | }, 114 | { 115 | "id": "12345678-1234-abcd-abcd-1234567890ab", 116 | "userId": "12345678-1234-abcd-abcd-1234567890ab", 117 | "type": "CT", 118 | "amount": 50.0, 119 | "currencyCode": "EUR", 120 | "visibleTS": 1554099697139, 121 | "mcc": 0, 122 | "mccGroup": 12, 123 | "recurring": false, 124 | "partnerBic": "ABCDEFGH123", 125 | "partnerAccountIsSepa": true, 126 | "partnerName": "Partner Name", 127 | "accountId": "12345678-1234-abcd-abcd-1234567890ab", 128 | "partnerIban": "DE12345678901234567890", 129 | "category": "micro-v2-income", 130 | "referenceText": "Message of this transaction", 131 | "userAccepted": 1554099697139, 132 | "userCertified": 1554099697139, 133 | "pending": false, 134 | "transactionNature": "NORMAL", 135 | "transactionTerminal": "ATM", 136 | "createdTS": 1554099697174, 137 | "smartLinkId": "12345678-1234-abcd-abcd-1234567890ab", 138 | "linkId": "12345678-1234-abcd-abcd-1234567890ab", 139 | "confirmed": 1554099697139 140 | }, 141 | { 142 | "id": "12345678-1234-abcd-abcd-1234567890ab", 143 | "userId": "12345678-1234-abcd-abcd-1234567890ab", 144 | "type": "CT", 145 | "amount": 300.0, 146 | "currencyCode": "EUR", 147 | "visibleTS": 1554099689992, 148 | "mcc": 0, 149 | "mccGroup": 12, 150 | "recurring": false, 151 | "partnerBic": "ABCDEFGH123", 152 | "partnerAccountIsSepa": true, 153 | "partnerName": "Partner Name", 154 | "accountId": "12345678-1234-abcd-abcd-1234567890ab", 155 | "partnerIban": "DE12345678901234567890", 156 | "category": "micro-v2-income", 157 | "referenceText": "Message of this transaction", 158 | "userAccepted": 1554099689992, 159 | "userCertified": 1554099689992, 160 | "pending": false, 161 | "transactionNature": "NORMAL", 162 | "transactionTerminal": "ATM", 163 | "createdTS": 1554099690023, 164 | "smartLinkId": "12345678-1234-abcd-abcd-1234567890ab", 165 | "linkId": "12345678-1234-abcd-abcd-1234567890ab", 166 | "confirmed": 1554099689992 167 | }, 168 | { 169 | "id": "12345678-1234-abcd-abcd-1234567890ab", 170 | "userId": "12345678-1234-abcd-abcd-1234567890ab", 171 | "type": "DT", 172 | "amount": -43.0, 173 | "currencyCode": "EUR", 174 | "visibleTS": 1554090067244, 175 | "mcc": 0, 176 | "mccGroup": 12, 177 | "recurring": false, 178 | "partnerBic": "ABCDEFGH123", 179 | "partnerBcn": "50010517", 180 | "partnerAccountIsSepa": true, 181 | "partnerBankName": "ING-DiBa", 182 | "partnerName": "Partner Name", 183 | "accountId": "12345678-1234-abcd-abcd-1234567890ab", 184 | "partnerIban": "DE12345678901234567890", 185 | "partnerAccountBan": "5426551349", 186 | "category": "micro-v2-bars-restaurants", 187 | "referenceText": "Message of this transaction", 188 | "userAccepted": 1554090067244, 189 | "userCertified": 1554090067244, 190 | "pending": false, 191 | "transactionNature": "NORMAL", 192 | "smartContactId": "12345678-1234-abcd-abcd-1234567890ab", 193 | "transactionTerminal": "ATM", 194 | "createdTS": 1554090067257, 195 | "smartLinkId": "12345678-1234-abcd-abcd-1234567890ab", 196 | "linkId": "12345678-1234-abcd-abcd-1234567890ab", 197 | "confirmed": 1554090067244 198 | }, 199 | { 200 | "id": "12345678-1234-abcd-abcd-1234567890ab", 201 | "userId": "12345678-1234-abcd-abcd-1234567890ab", 202 | "type": "DT", 203 | "amount": -50.0, 204 | "currencyCode": "EUR", 205 | "visibleTS": 1554085849711, 206 | "mcc": 0, 207 | "mccGroup": 12, 208 | "recurring": false, 209 | "partnerBic": "ABCDEFGH123", 210 | "partnerBcn": "50010517", 211 | "partnerAccountIsSepa": true, 212 | "partnerBankName": "ING-DiBa", 213 | "partnerName": "Partner Name", 214 | "accountId": "12345678-1234-abcd-abcd-1234567890ab", 215 | "partnerIban": "DE12345678901234567890", 216 | "partnerAccountBan": "5426551349", 217 | "category": "micro-v2-miscellaneous", 218 | "referenceText": "Message of this transaction", 219 | "userAccepted": 1554085849711, 220 | "userCertified": 1554085849711, 221 | "pending": false, 222 | "transactionNature": "NORMAL", 223 | "smartContactId": "12345678-1234-abcd-abcd-1234567890ab", 224 | "transactionTerminal": "ATM", 225 | "createdTS": 1554085849727, 226 | "smartLinkId": "12345678-1234-abcd-abcd-1234567890ab", 227 | "linkId": "12345678-1234-abcd-abcd-1234567890ab", 228 | "confirmed": 1554085849711 229 | }, 230 | { 231 | "id": "12345678-1234-abcd-abcd-1234567890ab", 232 | "userId": "12345678-1234-abcd-abcd-1234567890ab", 233 | "type": "DT", 234 | "amount": -150.0, 235 | "currencyCode": "EUR", 236 | "visibleTS": 1554082943260, 237 | "mcc": 0, 238 | "mccGroup": 12, 239 | "recurring": false, 240 | "partnerBic": "ABCDEFGH123", 241 | "partnerBcn": "50010517", 242 | "partnerAccountIsSepa": true, 243 | "partnerBankName": "ING-DiBa", 244 | "partnerName": "Partner Name", 245 | "accountId": "12345678-1234-abcd-abcd-1234567890ab", 246 | "partnerIban": "DE12345678901234567890", 247 | "partnerAccountBan": "5426551349", 248 | "category": "micro-v2-miscellaneous", 249 | "referenceText": "Message of this transaction", 250 | "userAccepted": 1554082943260, 251 | "userCertified": 1554082943260, 252 | "pending": false, 253 | "transactionNature": "NORMAL", 254 | "smartContactId": "12345678-1234-abcd-abcd-1234567890ab", 255 | "transactionTerminal": "ATM", 256 | "createdTS": 1554082943274, 257 | "smartLinkId": "12345678-1234-abcd-abcd-1234567890ab", 258 | "linkId": "12345678-1234-abcd-abcd-1234567890ab", 259 | "confirmed": 1554082943260 260 | }, 261 | { 262 | "id": "12345678-1234-abcd-abcd-1234567890ab", 263 | "userId": "12345678-1234-abcd-abcd-1234567890ab", 264 | "type": "DT", 265 | "amount": -20.0, 266 | "currencyCode": "EUR", 267 | "visibleTS": 1554082093870, 268 | "mcc": 0, 269 | "mccGroup": 12, 270 | "recurring": false, 271 | "partnerBic": "ABCDEFGH123", 272 | "partnerBcn": "50010517", 273 | "partnerAccountIsSepa": true, 274 | "partnerBankName": "ING-DiBa", 275 | "partnerName": "Partner Name", 276 | "accountId": "12345678-1234-abcd-abcd-1234567890ab", 277 | "partnerIban": "DE12345678901234567890", 278 | "partnerAccountBan": "5426551349", 279 | "category": "micro-v2-media-electronics", 280 | "referenceText": "Message of this transaction", 281 | "userAccepted": 1554082093870, 282 | "userCertified": 1554082093870, 283 | "pending": false, 284 | "transactionNature": "NORMAL", 285 | "smartContactId": "12345678-1234-abcd-abcd-1234567890ab", 286 | "transactionTerminal": "ATM", 287 | "createdTS": 1554082093884, 288 | "smartLinkId": "12345678-1234-abcd-abcd-1234567890ab", 289 | "linkId": "12345678-1234-abcd-abcd-1234567890ab", 290 | "confirmed": 1554082093870 291 | }, 292 | { 293 | "id": "12345678-1234-abcd-abcd-1234567890ab", 294 | "userId": "12345678-1234-abcd-abcd-1234567890ab", 295 | "type": "DT", 296 | "amount": -290.53, 297 | "currencyCode": "EUR", 298 | "visibleTS": 1554078589178, 299 | "mcc": 0, 300 | "mccGroup": 12, 301 | "recurring": false, 302 | "partnerBic": "ABCDEFGH123", 303 | "partnerBcn": "50010517", 304 | "partnerAccountIsSepa": true, 305 | "partnerBankName": "ING-DiBa", 306 | "partnerName": "Partner Name", 307 | "accountId": "12345678-1234-abcd-abcd-1234567890ab", 308 | "partnerIban": "DE12345678901234567890", 309 | "partnerAccountBan": "5426551349", 310 | "category": "micro-v2-household-utilities", 311 | "referenceText": "Message of this transaction", 312 | "userAccepted": 1554078589178, 313 | "userCertified": 1554078589178, 314 | "pending": false, 315 | "transactionNature": "NORMAL", 316 | "smartContactId": "12345678-1234-abcd-abcd-1234567890ab", 317 | "transactionTerminal": "ATM", 318 | "createdTS": 1554078589191, 319 | "smartLinkId": "12345678-1234-abcd-abcd-1234567890ab", 320 | "linkId": "12345678-1234-abcd-abcd-1234567890ab", 321 | "confirmed": 1554078589178 322 | }, 323 | { 324 | "id": "12345678-1234-abcd-abcd-1234567890ab", 325 | "userId": "12345678-1234-abcd-abcd-1234567890ab", 326 | "type": "AA", 327 | "amount": -23.65, 328 | "currencyCode": "EUR", 329 | "originalAmount": -23.65, 330 | "originalCurrency": "EUR", 331 | "exchangeRate": 1.0, 332 | "merchantCity": "Berlin", 333 | "visibleTS": 1553989562000, 334 | "mcc": 5541, 335 | "mccGroup": 16, 336 | "partnerBic": "Merchant Name", 337 | "recurring": false, 338 | "partnerAccountIsSepa": false, 339 | "accountId": "12345678-1234-abcd-abcd-1234567890ab", 340 | "category": "micro-v2-transport-car", 341 | "cardId": "12345678-1234-abcd-abcd-1234567890ab", 342 | "userCertified": 1553989562859, 343 | "pending": false, 344 | "transactionNature": "NORMAL", 345 | "transactionTerminal": "ATM", 346 | "createdTS": 1553989562859, 347 | "merchantCountry": 0, 348 | "smartLinkId": "12345678-1234-abcd-abcd-1234567890ab", 349 | "linkId": "12345678-1234-abcd-abcd-1234567890ab", 350 | "confirmed": 1553989562859 351 | }, 352 | { 353 | "id": "12345678-1234-abcd-abcd-1234567890ab", 354 | "userId": "12345678-1234-abcd-abcd-1234567890ab", 355 | "type": "AA", 356 | "amount": -50.0, 357 | "currencyCode": "EUR", 358 | "originalAmount": -50.0, 359 | "originalCurrency": "EUR", 360 | "exchangeRate": 1.0, 361 | "merchantCity": "Berlin", 362 | "visibleTS": 1553869968000, 363 | "mcc": 6011, 364 | "mccGroup": 18, 365 | "partnerBic": "Merchant Name", 366 | "recurring": false, 367 | "partnerAccountIsSepa": false, 368 | "accountId": "12345678-1234-abcd-abcd-1234567890ab", 369 | "category": "micro-v2-atm", 370 | "cardId": "12345678-1234-abcd-abcd-1234567890ab", 371 | "userCertified": 1553869968290, 372 | "pending": false, 373 | "transactionNature": "NORMAL", 374 | "transactionTerminal": "ATM", 375 | "createdTS": 1553869968290, 376 | "merchantCountry": 0, 377 | "smartLinkId": "12345678-1234-abcd-abcd-1234567890ab", 378 | "linkId": "12345678-1234-abcd-abcd-1234567890ab", 379 | "confirmed": 1553869968290 380 | }, 381 | { 382 | "id": "12345678-1234-abcd-abcd-1234567890ab", 383 | "userId": "12345678-1234-abcd-abcd-1234567890ab", 384 | "type": "CT", 385 | "amount": 762.38, 386 | "currencyCode": "EUR", 387 | "visibleTS": 1553855031265, 388 | "mcc": 0, 389 | "mccGroup": 12, 390 | "recurring": false, 391 | "partnerBic": "ABCDEFGH123", 392 | "partnerAccountIsSepa": true, 393 | "partnerName": "Partner Name", 394 | "accountId": "12345678-1234-abcd-abcd-1234567890ab", 395 | "partnerIban": "DE12345678901234567890", 396 | "category": "micro-v2-salary", 397 | "referenceText": "Message of this transaction", 398 | "userAccepted": 1553855031265, 399 | "userCertified": 1553855031265, 400 | "pending": false, 401 | "transactionNature": "NORMAL", 402 | "transactionTerminal": "ATM", 403 | "createdTS": 1553855031302, 404 | "purposeCode": "SALA", 405 | "smartLinkId": "12345678-1234-abcd-abcd-1234567890ab", 406 | "linkId": "12345678-1234-abcd-abcd-1234567890ab", 407 | "confirmed": 1553855031265 408 | }, 409 | { 410 | "id": "12345678-1234-abcd-abcd-1234567890ab", 411 | "userId": "12345678-1234-abcd-abcd-1234567890ab", 412 | "type": "PT", 413 | "amount": -40.0, 414 | "currencyCode": "EUR", 415 | "originalAmount": -40.0, 416 | "originalCurrency": "EUR", 417 | "exchangeRate": 1.0, 418 | "merchantCity": "BERLIN-X-S", 419 | "visibleTS": 1553537513000, 420 | "mcc": 6011, 421 | "mccGroup": 18, 422 | "partnerBic": "Merchant Name", 423 | "recurring": false, 424 | "partnerAccountIsSepa": false, 425 | "accountId": "12345678-1234-abcd-abcd-1234567890ab", 426 | "category": "micro-v2-atm", 427 | "cardId": "12345678-1234-abcd-abcd-1234567890ab", 428 | "userCertified": 1553682902223, 429 | "pending": false, 430 | "transactionNature": "NORMAL", 431 | "transactionTerminal": "ATM", 432 | "createdTS": 1553682902229, 433 | "merchantCountry": 0, 434 | "smartLinkId": "12345678-1234-abcd-abcd-1234567890ab", 435 | "linkId": "12345678-1234-abcd-abcd-1234567890ab", 436 | "confirmed": 1553682902223 437 | }, 438 | { 439 | "id": "12345678-1234-abcd-abcd-1234567890ab", 440 | "userId": "12345678-1234-abcd-abcd-1234567890ab", 441 | "type": "CT", 442 | "amount": 35.0, 443 | "currencyCode": "EUR", 444 | "visibleTS": 1553498818087, 445 | "mcc": 0, 446 | "mccGroup": 12, 447 | "recurring": false, 448 | "partnerBic": "ABCDEFGH123", 449 | "partnerAccountIsSepa": true, 450 | "partnerName": "Partner Name", 451 | "accountId": "12345678-1234-abcd-abcd-1234567890ab", 452 | "partnerIban": "DE12345678901234567890", 453 | "category": "micro-v2-income", 454 | "referenceText": "Message of this transaction", 455 | "userAccepted": 1553498818087, 456 | "userCertified": 1553498818087, 457 | "pending": false, 458 | "transactionNature": "NORMAL", 459 | "transactionTerminal": "ATM", 460 | "createdTS": 1553498818121, 461 | "smartLinkId": "12345678-1234-abcd-abcd-1234567890ab", 462 | "linkId": "12345678-1234-abcd-abcd-1234567890ab", 463 | "confirmed": 1553498818087 464 | }, 465 | { 466 | "id": "12345678-1234-abcd-abcd-1234567890ab", 467 | "userId": "12345678-1234-abcd-abcd-1234567890ab", 468 | "type": "PT", 469 | "amount": -30.0, 470 | "currencyCode": "EUR", 471 | "originalAmount": -30.0, 472 | "originalCurrency": "EUR", 473 | "exchangeRate": 1.0, 474 | "merchantCity": "BERLIN", 475 | "visibleTS": 1553363167000, 476 | "mcc": 6011, 477 | "mccGroup": 18, 478 | "partnerBic": "Merchant Name", 479 | "recurring": false, 480 | "partnerAccountIsSepa": false, 481 | "accountId": "12345678-1234-abcd-abcd-1234567890ab", 482 | "category": "micro-v2-atm", 483 | "cardId": "12345678-1234-abcd-abcd-1234567890ab", 484 | "userCertified": 1553658221542, 485 | "pending": false, 486 | "transactionNature": "NORMAL", 487 | "transactionTerminal": "ATM", 488 | "createdTS": 1553658221548, 489 | "merchantCountry": 0, 490 | "smartLinkId": "12345678-1234-abcd-abcd-1234567890ab", 491 | "linkId": "12345678-1234-abcd-abcd-1234567890ab", 492 | "confirmed": 1553658221542 493 | }, 494 | { 495 | "id": "12345678-1234-abcd-abcd-1234567890ab", 496 | "userId": "12345678-1234-abcd-abcd-1234567890ab", 497 | "type": "PT", 498 | "amount": -50.0, 499 | "currencyCode": "EUR", 500 | "originalAmount": -50.0, 501 | "originalCurrency": "EUR", 502 | "exchangeRate": 1.0, 503 | "merchantCity": "BERLIN", 504 | "visibleTS": 1552701969000, 505 | "mcc": 6011, 506 | "mccGroup": 18, 507 | "partnerBic": "Merchant Name", 508 | "recurring": false, 509 | "partnerAccountIsSepa": false, 510 | "accountId": "12345678-1234-abcd-abcd-1234567890ab", 511 | "category": "micro-v2-atm", 512 | "cardId": "12345678-1234-abcd-abcd-1234567890ab", 513 | "userCertified": 1553035421360, 514 | "pending": false, 515 | "transactionNature": "NORMAL", 516 | "transactionTerminal": "ATM", 517 | "createdTS": 1553035421360, 518 | "merchantCountry": 0, 519 | "smartLinkId": "12345678-1234-abcd-abcd-1234567890ab", 520 | "linkId": "12345678-1234-abcd-abcd-1234567890ab", 521 | "confirmed": 1553035421360 522 | }, 523 | { 524 | "id": "12345678-1234-abcd-abcd-1234567890ab", 525 | "userId": "12345678-1234-abcd-abcd-1234567890ab", 526 | "type": "PT", 527 | "amount": -6.5, 528 | "currencyCode": "EUR", 529 | "originalAmount": -6.5, 530 | "originalCurrency": "EUR", 531 | "exchangeRate": 1.0, 532 | "merchantCity": "BERLIN", 533 | "visibleTS": 1552695923000, 534 | "mcc": 5541, 535 | "mccGroup": 16, 536 | "partnerBic": "Merchant Name", 537 | "recurring": false, 538 | "partnerAccountIsSepa": false, 539 | "accountId": "12345678-1234-abcd-abcd-1234567890ab", 540 | "category": "micro-v2-transport-car", 541 | "cardId": "12345678-1234-abcd-abcd-1234567890ab", 542 | "userCertified": 1553035421340, 543 | "pending": false, 544 | "transactionNature": "NORMAL", 545 | "transactionTerminal": "ATM", 546 | "createdTS": 1553035421348, 547 | "merchantCountry": 0, 548 | "smartLinkId": "12345678-1234-abcd-abcd-1234567890ab", 549 | "linkId": "12345678-1234-abcd-abcd-1234567890ab", 550 | "confirmed": 1553035421342 551 | }, 552 | { 553 | "id": "12345678-1234-abcd-abcd-1234567890ab", 554 | "userId": "12345678-1234-abcd-abcd-1234567890ab", 555 | "type": "DD", 556 | "amount": -92.29, 557 | "currencyCode": "EUR", 558 | "visibleTS": 1552670544571, 559 | "mcc": 0, 560 | "mccGroup": 12, 561 | "recurring": false, 562 | "partnerBic": "ABCDEFGH123", 563 | "partnerAccountIsSepa": false, 564 | "partnerName": "Partner Name", 565 | "accountId": "12345678-1234-abcd-abcd-1234567890ab", 566 | "partnerIban": "DE12345678901234567890", 567 | "category": "micro-v2-tax-fines", 568 | "referenceText": "Message of this transaction", 569 | "userCertified": 1552670544571, 570 | "pending": false, 571 | "transactionNature": "NORMAL", 572 | "transactionTerminal": "ATM", 573 | "createdTS": 1552670544571, 574 | "mandateId": "MD4208404S2", 575 | "creditorIdentifier": "DE1234AB78901234567", 576 | "creditorName": "Creditor Name", 577 | "smartLinkId": "12345678-1234-abcd-abcd-1234567890ab", 578 | "linkId": "12345678-1234-abcd-abcd-1234567890ab", 579 | "confirmed": 1552608000000 580 | } 581 | ] -------------------------------------------------------------------------------- /tests/test_account.py: -------------------------------------------------------------------------------- 1 | from n26.api import GET, POST 2 | from tests.test_api_base import N26TestBase, mock_requests, read_response_file 3 | 4 | 5 | class AccountTests(N26TestBase): 6 | """Account tests""" 7 | 8 | @mock_requests(method=GET, response_file="account_info.json") 9 | def test_get_account_info_cli(self): 10 | from n26.cli import info 11 | result = self._run_cli_cmd(info) 12 | self.assertIsNotNone(result.output) 13 | 14 | @mock_requests(method=GET, response_file="account_statuses.json") 15 | def test_get_account_statuses_cli(self): 16 | from n26.cli import status 17 | result = self._run_cli_cmd(status) 18 | self.assertIn("PAIRED", result.output) 19 | 20 | @mock_requests(method=GET, response_file="account_limits.json") 21 | def test_limits_cli(self): 22 | from n26.cli import limits 23 | result = self._run_cli_cmd(limits) 24 | self.assertIn("POS_DAILY_ACCOUNT", result.output) 25 | self.assertIn("ATM_DAILY_ACCOUNT", result.output) 26 | self.assertIn("2500", result.output) 27 | 28 | @mock_requests(method=POST, response_file=None) 29 | @mock_requests(method=GET, response_file="account_limits.json") 30 | def test_set_limits_cli(self): 31 | from n26.cli import set_limits 32 | result = self._run_cli_cmd(set_limits, ["--withdrawal", 2500, "--payment", 2500]) 33 | self.assertIn("POS_DAILY_ACCOUNT", result.output) 34 | self.assertIn("ATM_DAILY_ACCOUNT", result.output) 35 | self.assertIn("2500", result.output) 36 | 37 | @mock_requests(method=GET, response_file="addresses.json") 38 | def test_addresses_cli(self): 39 | from n26.cli import addresses 40 | result = self._run_cli_cmd(addresses) 41 | self.assertIn("Einbahnstraße", result.output) 42 | self.assertIn("SHIPPING", result.output) 43 | self.assertIn("PASSPORT", result.output) 44 | self.assertIn("LEGAL", result.output) 45 | 46 | @mock_requests(method=GET, response_file="contacts.json") 47 | def test_get_contacts(self): 48 | result = self._underTest.get_contacts() 49 | self.assertIsNotNone(result) 50 | 51 | @mock_requests(method=GET, response_file="contacts.json") 52 | def test_contacts_cli(self): 53 | from n26.cli import contacts 54 | result = self._run_cli_cmd(contacts) 55 | self.assertIn("ADAC", result.output) 56 | self.assertIn("Cyberport", result.output) 57 | self.assertIn("DB", result.output) 58 | self.assertIn("ELV", result.output) 59 | self.assertIn("Mindfactory", result.output) 60 | self.assertIn("Seegel", result.output) 61 | 62 | @mock_requests(method=GET, response_file="statements.json") 63 | def test_get_statements_cli(self): 64 | from n26.cli import statements 65 | result = self._run_cli_cmd(statements) 66 | self.assertIn("2016-11", result.output) 67 | self.assertIn("2017-01", result.output) 68 | self.assertIn("2018-01", result.output) 69 | self.assertIn("2019-01", result.output) 70 | self.assertIn("/api/statements/statement-2019-04", result.output) 71 | self.assertIn("1554076800000", result.output) 72 | 73 | @mock_requests(method=GET, response_file="statements.json") 74 | def test_get_statements_by_id_cli(self): 75 | from n26.cli import statements 76 | result = self._run_cli_cmd(statements, ["--id", "statement-2017-01"]) 77 | self.assertEqual(len(result.output.split("\n")), 4) 78 | self.assertNotIn("2016-11", result.output) 79 | self.assertIn("/api/statements/statement-2017-01", result.output) 80 | self.assertNotIn("2018-01", result.output) 81 | self.assertNotIn("2019-01", result.output) 82 | self.assertNotIn("1554076800000", result.output) 83 | 84 | @mock_requests(method=GET, response_file="statements.json") 85 | def test_get_statements_by_date_cli(self): 86 | from n26.cli import statements 87 | result = self._run_cli_cmd(statements, ["--from", "2017-01-01", "--to", "2017-04-01"]) 88 | self.assertEqual(len(result.output.split("\n")), 7) 89 | self.assertNotIn("2016-11", result.output) 90 | self.assertIn("/api/statements/statement-2017-01", result.output) 91 | self.assertIn("2017-02", result.output) 92 | self.assertIn("2017-03", result.output) 93 | self.assertIn("2017-04", result.output) 94 | self.assertNotIn("2018-01", result.output) 95 | self.assertNotIn("2019-01", result.output) 96 | self.assertNotIn("1554076800000", result.output) 97 | 98 | @mock_requests(method=GET, response_file="statement.pdf", url_regex=r"/api/statements/statement-2017-01$") 99 | @mock_requests(method=GET, response_file="statements.json", url_regex=r"/api/statements$") 100 | def test_get_statements_download_cli(self): 101 | from filecmp import cmp 102 | from glob import glob 103 | from os import path 104 | from tempfile import TemporaryDirectory 105 | from n26.cli import statements 106 | id = "statement-2017-01" 107 | with TemporaryDirectory() as dir: 108 | result = self._run_cli_cmd(statements, ["--id", id, "--download", dir]) 109 | self.assertIn(id, result.output) 110 | files = glob(f"{dir}/*.pdf") 111 | self.assertTrue(len(files) == 1) 112 | directory = path.dirname(__file__) 113 | file_path = path.join(directory, 'api_responses', 'statement.pdf') 114 | self.assertTrue(cmp(file_path, files[0])) 115 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | from n26 import api, config 2 | from n26.api import BASE_URL_DE, POST, GET 3 | from tests.test_api_base import N26TestBase, mock_auth_token, mock_requests 4 | 5 | 6 | class ApiTests(N26TestBase): 7 | """Common Api tests""" 8 | 9 | def test_create_request_url(self): 10 | from n26.util import create_request_url 11 | expected = "https://api.tech26.de?bar=baz&foo=bar" 12 | result = create_request_url(BASE_URL_DE, { 13 | "foo": "bar", 14 | "bar": "baz" 15 | }) 16 | self.assertEqual(result, expected) 17 | 18 | @mock_requests(method=GET, response_file="refresh_token.json") 19 | def test_do_request(self): 20 | result = self._underTest._do_request(GET, "/something") 21 | self.assertIsNotNone(result) 22 | 23 | @mock_auth_token 24 | def test_get_token(self): 25 | expected = '12345678-1234-1234-1234-123456789012' 26 | api_client = api.Api(self.config) 27 | result = api_client.get_token() 28 | self.assertEqual(result, expected) 29 | 30 | @mock_requests(url_regex=".*/token", method=POST, response_file="refresh_token.json") 31 | def test_refresh_token(self): 32 | refresh_token = "12345678-1234-abcd-abcd-1234567890ab" 33 | expected = "12345678-1234-abcd-abcd-1234567890ab" 34 | result = self._underTest._refresh_token(refresh_token) 35 | self.assertEqual(result['access_token'], expected) 36 | 37 | def test_init_without_config(self): 38 | api_client = api.Api() 39 | self.assertIsNotNone(api_client.config) 40 | 41 | def test_init_with_config(self): 42 | from container_app_conf.source.yaml_source import YamlSource 43 | conf = config.Config(singleton=False, data_sources=[ 44 | YamlSource("test_creds", "./tests/") 45 | ]) 46 | api_client = api.Api(conf) 47 | self.assertIsNotNone(api_client.config) 48 | self.assertEqual(api_client.config, conf) 49 | -------------------------------------------------------------------------------- /tests/test_api_base.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import json 3 | import logging 4 | import re 5 | import unittest 6 | from copy import deepcopy 7 | from unittest import mock 8 | from unittest.mock import Mock, DEFAULT 9 | 10 | 11 | def read_response_file(file_name: str or None, to_json: bool = True) -> json or bytes or None: 12 | """ 13 | Reads a JSON file and returns it's content as a string 14 | :param file_name: the name of the file 15 | :param to_json: whether to parse the file to a json object or not 16 | :return: file contents 17 | """ 18 | 19 | if file_name is None: 20 | return None 21 | 22 | import os 23 | directory = os.path.dirname(__file__) 24 | file_path = os.path.join(directory, 'api_responses', file_name) 25 | 26 | if not os.path.isfile(file_path): 27 | raise AttributeError("Couldn't find file containing response mock data: {}".format(file_path)) 28 | 29 | mode = 'r' if to_json else 'rb' 30 | with open(file_path, mode) as myfile: 31 | api_response_text = myfile.read() 32 | return json.loads(api_response_text) if to_json else api_response_text 33 | 34 | 35 | def mock_auth_token(func: callable): 36 | """ 37 | Decorator for mocking the auth token returned by the N26 api 38 | 39 | :param func: function to patch 40 | :return: 41 | """ 42 | 43 | @functools.wraps(func) 44 | def wrapper(*args, **kwargs): 45 | with mock.patch('n26.api.Api._request_token') as request_token: 46 | request_token.return_value = read_response_file("auth_token.json") 47 | with mock.patch('n26.api.Api._refresh_token') as refresh_token: 48 | refresh_token.return_value = read_response_file("refresh_token.json") 49 | return func(*args, **kwargs) 50 | 51 | return wrapper 52 | 53 | 54 | def mock_requests(method: str, response_file: str or None, url_regex: str = None): 55 | """ 56 | Decorator to mock the http response 57 | 58 | :param method: the http method to mock 59 | :param response_file: optional file name of the file containing the json response to use for the mock 60 | :param url_regex: optional regex to match the called url against. Only matching urls will be mocked. 61 | :return: the decorated method 62 | """ 63 | 64 | def decorator(function: callable): 65 | if not callable(function): 66 | raise AttributeError("Unsupported type: {}".format(function)) 67 | 68 | def add_side_effects(mock_request, original): 69 | new_mock = Mock() 70 | 71 | def side_effect(*args, **kwargs): 72 | args = deepcopy(args) 73 | kwargs = deepcopy(kwargs) 74 | new_mock(*args, **kwargs) 75 | 76 | if not url_regex or re.findall(url_regex, args[0]): 77 | return DEFAULT 78 | else: 79 | return original(*args, **kwargs) 80 | 81 | mock_request.side_effect = side_effect 82 | 83 | is_json = response_file.endswith('.json') if response_file else False 84 | response = read_response_file(response_file, to_json=is_json) 85 | content = "" if response is None else response 86 | mock_request.return_value.content = content if not is_json else str(content) 87 | mock_request.return_value.json.return_value = response 88 | mock_request.return_value.headers = { 89 | "Content-Type": "application/json" if is_json else "" 90 | } 91 | return new_mock 92 | 93 | @mock_auth_token 94 | @functools.wraps(function) 95 | def wrapper(*args, **kwargs): 96 | import n26 97 | from n26.api import GET, POST 98 | if method is GET: 99 | original = n26.api.requests.get 100 | elif method is POST: 101 | original = n26.api.requests.post 102 | else: 103 | raise AttributeError("Unsupported method: {}".format(method)) 104 | 105 | with mock.patch('n26.api.requests.{}'.format(method)) as mock_request: 106 | add_side_effects(mock_request, original) 107 | result = function(*args, **kwargs) 108 | return result 109 | 110 | return wrapper 111 | 112 | return decorator 113 | 114 | 115 | class N26TestBase(unittest.TestCase): 116 | """Base class for N26 api tests""" 117 | 118 | from n26.config import Config 119 | from container_app_conf.source.yaml_source import YamlSource 120 | 121 | # create custom config from "test_creds.yml" 122 | config = Config( 123 | singleton=True, 124 | data_sources=[ 125 | YamlSource("test_creds", "./tests/") 126 | ] 127 | ) 128 | 129 | # this is the Api client 130 | _underTest = None 131 | 132 | def setUp(self): 133 | """ 134 | This method is called BEFORE each individual test case 135 | """ 136 | from n26 import api 137 | 138 | self._underTest = api.Api(self.config) 139 | 140 | logger = logging.getLogger("n26") 141 | logger.setLevel(logging.DEBUG) 142 | ch = logging.StreamHandler() 143 | ch.setLevel(logging.DEBUG) 144 | formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") 145 | ch.setFormatter(formatter) 146 | logger.addHandler(ch) 147 | 148 | def tearDown(self): 149 | """ 150 | This method is called AFTER each individual test case 151 | """ 152 | pass 153 | 154 | @staticmethod 155 | def get_api_response(filename: str) -> dict or None: 156 | """ 157 | Read an api response from a file 158 | 159 | :param filename: the file in the "api_responses" subfolder to read 160 | :return: the api response dict 161 | """ 162 | file = read_response_file(filename) 163 | if file is None: 164 | return None 165 | else: 166 | return json.loads(file) 167 | 168 | @staticmethod 169 | def _run_cli_cmd(command: callable, args: list = None, ignore_exceptions: bool = False) -> any: 170 | """ 171 | Runs a cli command and returns it's output. 172 | If running the command results in an exception it is automatically rethrown by this method. 173 | 174 | :param command: the command to execute 175 | :param args: command arguments as a list 176 | :param ignore_exceptions: if set to true exceptions originating from running the command 177 | will not be rethrown and the result object from the cli call will 178 | be returned instead. 179 | :return: the result of the call 180 | """ 181 | from click.testing import CliRunner 182 | runner = CliRunner(echo_stdin=False) 183 | result = runner.invoke(command, args=args or ()) 184 | 185 | if not ignore_exceptions and result.exception: 186 | raise result.exception 187 | 188 | return result 189 | 190 | if __name__ == '__main__': 191 | unittest.main() 192 | -------------------------------------------------------------------------------- /tests/test_balance.py: -------------------------------------------------------------------------------- 1 | from n26.api import GET 2 | from tests.test_api_base import N26TestBase, mock_requests 3 | 4 | 5 | class BalanceTest(N26TestBase): 6 | """Balance tests""" 7 | 8 | @mock_requests(method=GET, response_file="balance.json") 9 | def test_balance_cli(self): 10 | from n26.cli import balance 11 | result = self._run_cli_cmd(balance) 12 | self.assertRegex(result.output, r"\d*\.\d* \w*.*") 13 | -------------------------------------------------------------------------------- /tests/test_cards.py: -------------------------------------------------------------------------------- 1 | from n26.api import GET, POST 2 | from tests.test_api_base import N26TestBase, mock_requests 3 | 4 | 5 | class CardsTests(N26TestBase): 6 | """Cards tests""" 7 | 8 | @mock_requests(method=GET, response_file="cards.json") 9 | def test_cards_cli(self): 10 | from n26.cli import cards 11 | result = self._run_cli_cmd(cards) 12 | self.assertIn('MASTERCARD', result.output) 13 | self.assertIn('MAESTRO', result.output) 14 | self.assertIn('active', result.output) 15 | self.assertIn('123456******1234', result.output) 16 | 17 | @mock_requests(method=GET, response_file="cards.json") 18 | @mock_requests(method=POST, response_file="card_block_single.json") 19 | def test_block_card_cli_single(self): 20 | from n26.cli import card_block 21 | card_id = "12345678-1234-abcd-abcd-1234567890ab" 22 | result = self._run_cli_cmd(card_block, ["--card", card_id]) 23 | self.assertEqual(result.output, "Blocked card: {}\n".format(card_id)) 24 | 25 | @mock_requests(method=GET, response_file="cards.json") 26 | @mock_requests(method=POST, response_file="card_block_single.json") 27 | def test_block_card_cli_all(self): 28 | from n26.cli import card_block 29 | card_id_1 = "12345678-1234-abcd-abcd-1234567890ab" 30 | card_id_2 = "22345678-1234-abcd-abcd-1234567890ab" 31 | 32 | result = self._run_cli_cmd(card_block) 33 | self.assertEqual(result.output, "Blocked card: {}\nBlocked card: {}\n".format(card_id_1, card_id_2)) 34 | 35 | @mock_requests(method=GET, response_file="cards.json") 36 | @mock_requests(method=POST, response_file="card_unblock_single.json") 37 | def test_unblock_card_cli_single(self): 38 | from n26.cli import card_unblock 39 | card_id = "12345678-1234-abcd-abcd-1234567890ab" 40 | result = self._run_cli_cmd(card_unblock, ["--card", card_id]) 41 | self.assertEqual(result.output, "Unblocked card: {}\n".format(card_id)) 42 | 43 | @mock_requests(method=GET, response_file="cards.json") 44 | @mock_requests(method=POST, response_file="card_unblock_single.json") 45 | def test_unblock_card_cli_all(self): 46 | from n26.cli import card_unblock 47 | card_id_1 = "12345678-1234-abcd-abcd-1234567890ab" 48 | card_id_2 = "22345678-1234-abcd-abcd-1234567890ab" 49 | 50 | result = self._run_cli_cmd(card_unblock) 51 | self.assertEqual(result.output, "Unblocked card: {}\nUnblocked card: {}\n".format(card_id_1, card_id_2)) 52 | -------------------------------------------------------------------------------- /tests/test_creds.yml: -------------------------------------------------------------------------------- 1 | n26: 2 | username: john.doe@example.com 3 | password: $upersecret 4 | device_token: 5a136085-abd8-4e71-9402-e0a61dd1dc81 5 | mfa_type: app 6 | -------------------------------------------------------------------------------- /tests/test_spaces.py: -------------------------------------------------------------------------------- 1 | from n26.api import GET 2 | from tests.test_api_base import N26TestBase, mock_requests 3 | 4 | 5 | class SpacesTests(N26TestBase): 6 | """Spaces tests""" 7 | 8 | @mock_requests(method=GET, response_file="spaces.json") 9 | def test_spaces_cli(self): 10 | from n26.cli import spaces 11 | result = self._run_cli_cmd(spaces) 12 | self.assertRegex(result.output, r"\d*\.\d* \w*.*") 13 | -------------------------------------------------------------------------------- /tests/test_standing_orders.py: -------------------------------------------------------------------------------- 1 | from n26.api import GET 2 | 3 | from tests.test_api_base import N26TestBase, mock_requests 4 | 5 | 6 | class StandingOrdersTests(N26TestBase): 7 | """Standing orders tests""" 8 | 9 | @mock_requests(method=GET, response_file="standing_orders.json") 10 | def test_standing_orders_cli(self): 11 | from n26.cli import standing_orders 12 | result = self._run_cli_cmd(standing_orders) 13 | self.assertIsNotNone(result.output) 14 | self.assertIn('Mr. Anderson', result.output) 15 | self.assertIn('INWX', result.output) 16 | self.assertIn('1st', result.output) 17 | self.assertIn('30th', result.output) 18 | self.assertIn('WEEKLY', result.output) 19 | self.assertIn('MONTHLY', result.output) 20 | self.assertIn('YEARLY', result.output) 21 | self.assertIn('10/30/18', result.output) 22 | -------------------------------------------------------------------------------- /tests/test_statistics.py: -------------------------------------------------------------------------------- 1 | from n26.api import GET 2 | 3 | from tests.test_api_base import N26TestBase, mock_requests 4 | 5 | 6 | class StatisticsTests(N26TestBase): 7 | """Statistics tests""" 8 | 9 | @mock_requests(method=GET, response_file="statistics.json") 10 | def test_statistics_cli(self): 11 | from n26.cli import statistics 12 | result = self._run_cli_cmd(statistics) 13 | self.assertIsNotNone(result.output) 14 | -------------------------------------------------------------------------------- /tests/test_transactions.py: -------------------------------------------------------------------------------- 1 | from n26.api import GET 2 | 3 | from tests.test_api_base import N26TestBase, mock_requests 4 | 5 | 6 | class TransactionsTests(N26TestBase): 7 | """Transactions tests""" 8 | 9 | @mock_requests(method=GET, response_file="transactions.json") 10 | def test_transactions_cli(self): 11 | from n26.cli import transactions 12 | result = self._run_cli_cmd(transactions, ["--from", "01/30/2019", "--to", "30.01.2020"]) 13 | self.assertIsNotNone(result.output) 14 | --------------------------------------------------------------------------------