[
  {
    "path": ".github/FUNDING.yml",
    "content": "github: [markusressel]"
  },
  {
    "path": ".github/stale.yml",
    "content": "# Configuration for probot-stale - https://github.com/probot/stale\n\n# Number of days of inactivity before an Issue or Pull Request becomes stale\ndaysUntilStale: 30\n\n# Number of days of inactivity before an Issue or Pull Request with the stale label is closed.\n# Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale.\ndaysUntilClose: 7\n\n# Only issues or pull requests with all of these labels are check if stale. Defaults to `[]` (disabled)\nonlyLabels: []\n\n# Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable\nexemptLabels:\n  - pinned\n  - security\n  - \"[Status] Maybe Later\"\n\n# Set to true to ignore issues in a project (defaults to false)\nexemptProjects: false\n\n# Set to true to ignore issues in a milestone (defaults to false)\nexemptMilestones: false\n\n# Set to true to ignore issues with an assignee (defaults to false)\nexemptAssignees: false\n\n# Label to use when marking as stale\nstaleLabel: stale\n\n# Comment to post when marking as stale. Set to `false` to disable\nmarkComment: >\n  This issue has been automatically marked as stale because it has not had\n  recent activity. It will be closed if no further activity occurs. Thank you\n  for your contributions.\n\n# Comment to post when removing the stale label.\n# unmarkComment: >\n#   Your comment here.\n\n# Comment to post when closing a stale Issue or Pull Request.\n# closeComment: >\n#   Your comment here.\n\n# Limit the number of actions per hour, from 1-30. Default is 30\nlimitPerRun: 30\n\n# Limit to only `issues` or `pulls`\n# only: issues\n\n# Optionally, specify configuration settings that are specific to just 'issues' or 'pulls':\n# pulls:\n#   daysUntilStale: 30\n#   markComment: >\n#     This pull request has been automatically marked as stale because it has not had\n#     recent activity. It will be closed if no further activity occurs. Thank you\n#     for your contributions.\n\n# issues:\n#   exemptLabels:\n#     - confirmed\n"
  },
  {
    "path": ".github/workflows/docker-latest.yml",
    "content": "name: Docker Image latest\n\non:\n  push:\n    branches: [ master ]\n\njobs:\n\n  dockerhub:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v2\n      - name: Build the Docker image\n        run: docker build . --file Dockerfile --tag femueller/python-n26:latest\n"
  },
  {
    "path": ".github/workflows/python-app.yml",
    "content": "name: Python application build&test\n\non: [push]\n\njobs:\n  build:\n\n    runs-on: ubuntu-latest\n\n    strategy:\n      matrix:\n        # Supported Python versions according to https://devguide.python.org/versions/\n        python-version: [ \"3.7\", \"3.8\", \"3.9\", \"3.10\", \"3.11\" ]\n    steps:\n    - uses: actions/checkout@v2\n    - name: Set up Python\n      uses: actions/setup-python@v2\n      with:\n        python-version: ${{ matrix.python-version }}\n    - name: Install dependencies\n      run: |\n        python -m pip install --upgrade pip\n        pip install flake8 pytest pytest-cov\n        if [ -f requirements.txt ]; then pip install -r requirements.txt; fi\n    - name: Lint with flake8\n      run: |\n        # stop the build if there are Python syntax errors or undefined names\n        flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics\n        # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide\n        flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics\n    - name: Test with pytest\n      run: |\n        pytest tests/ --doctest-modules --junitxml=junit/test-results.xml --cov=com --cov-report=xml --cov-report=html\n"
  },
  {
    "path": ".github/workflows/python-publish.yml",
    "content": "name: Python package upload\n\non:\n  push:\n    tags:\n      - '*'\n\njobs:\n  deploy:\n    runs-on: ubuntu-latest\n\n    steps:\n    - uses: actions/checkout@v3\n    - uses: actions/setup-python@v4\n    - name: Install dependencies\n      run: |\n        python -m pip install --upgrade pip\n        pip install setuptools wheel twine\n    - name: Build and publish\n      env:\n        TWINE_USERNAME: __token__\n        TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}\n      run: |\n        python setup.py sdist bdist_wheel\n        twine upload dist/*\n"
  },
  {
    "path": ".gitignore",
    "content": ".cache\n__pycache__\n.DS_Store\n*.pyc\nvenv/\nMANIFEST\n*.egg-info\ndist/\n*.sublime-workspace\n*.sublime-project\n.idea"
  },
  {
    "path": "CHANGELOG.md",
    "content": "### 0.2.2 (10-03-2019)\n\nFIXES:\n\n* [Reworked token authentication](https://github.com/femueller/python-n26/pull/15)\n\n## 0.2.1 (09-03-2019)\n\nFEATURES:\n\n* [Add unit](https://github.com/femueller/python-n26/pull/8) [and API tests](https://github.com/femueller/python-n26/pull/11)\n* [Add basic spaces support](https://github.com/femueller/python-n26/pull/13)\n* [Add many missing API methods](https://github.com/femueller/python-n26/pull/14)\n\n## 0.1.4 (06-08-2018)\n\nBUG FIXES:\n\n* Fix [Typo for unlimited transaction call](https://github.com/femueller/python-n26/issues/7)"
  },
  {
    "path": "Dockerfile",
    "content": "# Docker image for n26\n\n# dont use alpine for python builds: https://pythonspeed.com/articles/alpine-docker-python/\nFROM python:3.11-slim-buster\n\nWORKDIR /app\n\nCOPY . .\n\nRUN apt-get update \\\n    && apt-get -y install sudo python3-pip \\\n    && apt-get clean && rm -rf /var/lib/apt/lists/*\nRUN pip install --upgrade pip;\\\n    pip install pipenv;\\\n    PIP_IGNORE_INSTALLED=1 pipenv install --system --deploy;\\\n    pip install .\n\nENTRYPOINT [ \"n26\" ]\nCMD [ \"-h\" ]"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2017 Felix Mueller\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "MANIFEST.in",
    "content": "include requirements.txt\nrecursive-exclude tests *"
  },
  {
    "path": "Makefile",
    "content": "PROJECT = \"n26\"\n\ncurrent-version:\n\t@echo \"Current version is `cat ${PROJECT}/__init__.py | grep '__version__' | cut -d ' ' -f3 | sed s/\\'//g`\"\n\nbuild:\n\tgit stash\n\tpython setup.py sdist\n\t- git stash pop\n\ntest:\n\tpipenv run pytest\n\nupload:\n\t# Upload to PyPI: https://pypi.org/project/n26/\n\tpython setup.py sdist upload -r pypi\n\ngit-release:\n\tgit add ${PROJECT}/__init__.py\n\tgit commit -m \"Bumped version to `cat ${PROJECT}/__init__.py | grep '__version__' | cut -d ' ' -f3 | sed s/\\'//g`\"\n\tgit tag `cat ${PROJECT}/__init__.py | grep '__version__' | cut -d ' ' -f3 | sed s/\\'//g`\n\tgit push\n\tgit push --tags\n\n_release-patch:\n\t@echo \"version = \\\"`cat ${PROJECT}/__init__.py | awk -F '(\"|\")' '{ print($$2)}' | awk -F. '{$$NF = $$NF + 1;} 1' | sed 's/ /./g'`\\\"\" > ${PROJECT}/__init__.py\nrelease-patch: _release-patch git-release build upload current-version\n\n_release-minor:\n\t@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\nrelease-minor: _release-minor git-release build upload current-version\n\n_release-major:\n\t@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\nrelease-major: _release-major git-release build upload current-version\n\nrelease: release-patch\n"
  },
  {
    "path": "Pipfile",
    "content": "[[source]]\nurl = \"https://pypi.org/simple\"\nverify_ssl = true\nname = \"pypi\"\n\n[packages]\nrequests = \"~=2.31\"\nclick = \"*\"\ntabulate = \">=0.9.0\"\nPyYAML = \"*\"\ninflect = \"*\"\nurllib3 = \"~=1.26\"\ntenacity = \"~=8.1\"\ncontainer-app-conf = \"~=5.2\"\npycryptodome = \">=3.9\"\n\n[dev-packages]\npytest = \"*\"\nmock = \"*\"\nflake8 = \"*\"\n\n[requires]\npython_version = \"3.11\"\n"
  },
  {
    "path": "README.md",
    "content": "# N26 Python CLI/API\n\n## 2023-10-29 - Archiving the repository\n\nToday, I am marking this repository as archived, as I am not making use of the API itself anymore.  \nIf you'e interested in taking over the maintenance of the repository, please reach out to me.\n\n[![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)\n[![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)\n[![PyPI version](https://badge.fury.io/py/n26.svg)](https://badge.fury.io/py/n26)\n[![Downloads](https://img.shields.io/pypi/dm/n26.svg)](https://img.shields.io/pypi/dm/n26.svg)\n\n[![asciicast](https://asciinema.org/a/260083.svg)](https://asciinema.org/a/260083)\n\n## About\n\n[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.\n\n**Disclaimer:** This is an unofficial community project which is not affiliated with N26 GmbH/N26 Inc.\n\n## Install\n\n```shell\npip3 install n26\nwget https://raw.githubusercontent.com/femueller/python-n26/master/n26.yml.example -O ~/.config/n26.yml\n# configure username and password\nvim ~/.config/n26.yml\n```\n\n## Configuration\n\npython-n26 uses [container-app-conf](https://github.com/markusressel/container-app-conf) to provide different options for configuration.\nYou 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).\nIf you want to use environment variables:\n\n-   `N26_USERNAME`: username\n-   `N26_PASSWORD`: password\n-   `N26_DEVICE_TOKEN`: random [uuid](https://de.wikipedia.org/wiki/Universally_Unique_Identifier) to identify the device\n-   `N26_LOGIN_DATA_STORE_PATH`: optional **file** path to store login data (recommended for cli usage)\n-   `N26_MFA_TYPE`: `app` will use the paired app as 2 factor authentication, `sms` will use SMS to the registered number.\n\nNote 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**.\n\n## Authentication\n\n### Device Token\n\nSince 17th of June 2020 N26 requires a device_token to differentiate clients. This requires you to specify the `DEVICE_TOKEN`\nconfig option with a UUID of your choice. To generate a UUID you can use f.ex. one of the following options:\n\nUsing python:\n\n```python\npython -c 'import uuid; print(uuid.uuid4())'\n```\n\nUsing linux built-in tools:\n\n```shell\n> uuidgen\n```\n\nUsing a website:\n[https://www.uuidgenerator.net/](https://www.uuidgenerator.net/)\n\n### 2FA\n\nSince 14th of September 2019 N26 requires a login confirmation (2 factor authentication).\n\nThere are two options here:\n\n1. 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.\n2. 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`.\n\nIf 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).\n\n## Usage\n\n### CLI example\n\n```shell\n> n26 balance\n123.45 EUR\n```\n\nOr if using environment variables:\n\n```bash\n> N26_USER=user N26_PASSWORD=passwd N26_DEVICE_TOKEN=00000000-0000-0000-0000-000000000000 N26_MFA_TYPE=app n26 balance\n123.45 EUR\n```\n\n### JSON output\n\nIf you would like to work with the raw `JSON` rather than the pretty table\nlayout you can use the global `-json` parameter:\n\n```bash\n> n26 -json balance\n{\n  \"id\": \"12345678-1234-1234-1234-123456789012\",\n  \"physicalBalance\": null,\n  \"availableBalance\": 123.45,\n  \"usableBalance\": 123.45,\n  \"bankBalance\": 123.45,\n  \"iban\": \"DE12345678901234567890\",\n  \"bic\": \"NTSBDEB1XXX\",\n  \"bankName\": \"N26 Bank\",\n  \"seized\": false,\n  \"currency\": \"EUR\",\n  \"legalEntity\": \"EU\",\n  \"users\": [\n    {\n      \"userId\": \"12345678-1234-1234-1234-123456789012\",\n      \"userRole\": \"OWNER\"\n    }\n  ],\n  \"externalId\": {\n    \"iban\": \"DE12345678901234567890\"\n  }\n}\n```\n\n### Docker\n\n```shell\n# ensure the n26 folder exists\nmkdir ~/.config/n26\n# mount the config and launch the command\nsudo docker run -it --rm \\\n  -v \"/home/markus/.config/n26.yaml:/app/n26.yaml\" \\\n  -v \"/home/markus/.config/n26:/.config/n26\" \\\n  -u 1000:1000 \\\n  femueller/python-n26\n```\n\n### API example\n\n```python\nfrom n26.api import Api\napi_client = Api()\nprint(api_client.get_balance())\n```\n\nThis is going to use the same mechanism to load configuration as the CLI tool, to specify your own configuration you can use it as:\n\n```python\nfrom n26.api import Api\nfrom n26.config import Config\n\nconf = Config(validate=False)\nconf.USERNAME.value = \"john.doe@example.com\"\nconf.PASSWORD.value = \"$upersecret\"\nconf.LOGIN_DATA_STORE_PATH.value = None\nconf.MFA_TYPE.value = \"app\"\nconf.validate()\n\napi_client = Api(conf)\nprint(api_client.get_balance())\n```\n\n## Contribute\n\nIf there are any issues, bugs or missing API endpoints, feel free to contribute by forking the project and creating a Pull-Request.\n\n### Run locally\n\nPrerequirements: [Pipenv](https://pipenv.readthedocs.io/)\n\n```shell\ngit clone git@github.com:femueller/python-n26.git\ncd python-n26\npipenv shell\npipenv install\npython3 -m n26 balance\n```\n\n### Creating a new release (only for maintainers)\n\n1. Increment version number in `n26/__init__.py` according to desired [SemVer](https://semver.org/#summary) release version\n2. Create a new release using the `Makefile`. This creates a new git tag, which triggers the \"Upload Python Package\" GitHub Action.\n    1. Run `make git-release`, this triggers: [https://github.com/femueller/python-n26/actions/workflows/python-publish.yml]()\n    2. New releases end up at: [https://pypi.org/project/n26/]()\n\n## Maintainers\n\n-   [Markus Ressel](https://github.com/markusressel)\n-   [Felix Mueller](https://github.com/femueller)\n\n## Credits\n\n-   [Nick Jüttner](https://github.com/njuettner) for providing [the API authentication flow](https://github.com/njuettner/alexa/blob/master/n26/app.py)\n-   [Pierrick Paul](https://github.com/PierrickP/) for providing [the API endpoints](https://github.com/PierrickP/n26/blob/develop/lib/api.js)\n\n## Similar projects\n\n-   Go: https://github.com/guitmz/n26 by [Guilherme Thomazi Bonicontro](https://github.com/guitmz)\n-   Go: https://github.com/njuettner/n26 by [Nick Jüttner](https://github.com/njuettner) (unmaintained)\n-   Node https://github.com/PierrickP/n26 by [Pierrick Paul](https://github.com/PierrickP/) (unmaintained)\n\n## Disclaimer\n\nThis project is not affiliated with N26 GmbH/N26 Inc. if you want to learn more about it, visit https://n26.com/.\n\nWe'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.  \nThere is no guarantee that this project continues to work at any point, since none of the API endpoints are really documented.\n"
  },
  {
    "path": "example.py",
    "content": "from n26 import api\n\nif __name__ == '__main__':\n    API_CLIENT = api.Api()\n"
  },
  {
    "path": "n26/__init__.py",
    "content": "__version__ = '3.3.1'\n"
  },
  {
    "path": "n26/__main__.py",
    "content": "from n26.cli import cli\n\nif __name__ == '__main__':\n    cli()\n"
  },
  {
    "path": "n26/api.py",
    "content": "import base64\nimport json\nimport logging\nimport os\nimport time\nfrom pathlib import Path\n\nimport click\nimport requests\nfrom Crypto import Random\nfrom Crypto.Cipher import AES, PKCS1_v1_5\nfrom Crypto.Hash import SHA512\nfrom Crypto.Protocol.KDF import PBKDF2\nfrom Crypto.PublicKey import RSA\nfrom Crypto.Util.Padding import pad\nfrom requests import HTTPError\nfrom tenacity import retry, stop_after_delay, wait_fixed\n\nfrom n26.config import Config, MFA_TYPE_SMS\nfrom n26.const import DAILY_WITHDRAWAL_LIMIT, DAILY_PAYMENT_LIMIT\nfrom n26.util import create_request_url\n\nLOGGER = logging.getLogger(__name__)\n\nBASE_URL_DE = 'https://api.tech26.de'\nBASIC_AUTH_HEADERS = {\"Authorization\": \"Basic bmF0aXZld2ViOg==\"}\nUSER_AGENT = (\"Mozilla/5.0 (X11; Linux x86_64) \"\n              \"AppleWebKit/537.36 (KHTML, like Gecko) \"\n              \"Chrome/59.0.3071.86 Safari/537.36\")\n\nGET = \"get\"\nPOST = \"post\"\n\nEXPIRATION_TIME_KEY = \"expiration_time\"\nACCESS_TOKEN_KEY = \"access_token\"\nREFRESH_TOKEN_KEY = \"refresh_token\"\n\nGRANT_TYPE_PASSWORD = \"password\"\nGRANT_TYPE_REFRESH_TOKEN = \"refresh_token\"\n\n\nclass Api(object):\n    \"\"\"\n    Api class can be imported as a library in order to use it within applications\n    \"\"\"\n\n    def __init__(self, cfg: Config = None):\n        \"\"\"\n        Constructor accepting None to maintain backward compatibility\n\n        :param cfg: configuration object\n        \"\"\"\n        if not cfg:\n            cfg = Config()\n        self.config = cfg\n        self._token_data = {}\n        BASIC_AUTH_HEADERS[\"device-token\"] = self.config.DEVICE_TOKEN.value\n\n    @property\n    def token_data(self) -> dict:\n        if self.config.LOGIN_DATA_STORE_PATH.value is None:\n            return self._token_data\n        else:\n            return self._read_token_file(self.config.LOGIN_DATA_STORE_PATH.value)\n\n    @token_data.setter\n    def token_data(self, data: dict):\n        if self.config.LOGIN_DATA_STORE_PATH.value is None:\n            self._token_data = data\n        else:\n            self._write_token_file(data, self.config.LOGIN_DATA_STORE_PATH.value)\n\n    @staticmethod\n    def _read_token_file(path: str) -> dict:\n        \"\"\"\n        :return: the stored token data or an empty dict\n        \"\"\"\n        LOGGER.debug(\"Reading token data from {}\".format(path))\n        path = Path(path).expanduser().resolve()\n        if not path.exists():\n            return {}\n\n        if not path.is_file():\n            raise IsADirectoryError(\"File path exists and is not a file: {}\".format(path))\n\n        if path.stat().st_size <= 0:\n            # file is empty\n            return {}\n\n        with open(path, \"r\") as file:\n            return json.loads(file.read())\n\n    @staticmethod\n    def _write_token_file(token_data: dict, path: str):\n        LOGGER.debug(\"Writing token data to {}\".format(path))\n        path = Path(path).expanduser().resolve()\n\n        # delete existing file if permissions don't match or file size is abnormally small\n        if path.exists() and (path.stat().st_mode != 0o100600 or path.stat().st_size < 10):\n            path.unlink()\n\n        path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)\n        with os.fdopen(os.open(path, os.O_WRONLY | os.O_CREAT, 0o600), 'w') as file:\n            file.seek(0)\n            file.write(json.dumps(token_data, indent=2))\n            file.truncate()\n\n    # IDEA: @get_token decorator\n    def get_account_info(self) -> dict:\n        \"\"\"\n        Retrieves basic account information\n        \"\"\"\n        return self._do_request(GET, BASE_URL_DE + '/api/me')\n\n    def get_account_statuses(self) -> dict:\n        \"\"\"\n        Retrieves additional account information\n        \"\"\"\n        return self._do_request(GET, BASE_URL_DE + '/api/me/statuses')\n\n    def get_addresses(self) -> dict:\n        \"\"\"\n        Retrieves a list of addresses of the account owner\n        \"\"\"\n        return self._do_request(GET, BASE_URL_DE + '/api/addresses')\n\n    def get_balance(self) -> dict:\n        \"\"\"\n        Retrieves the current balance\n        \"\"\"\n        return self._do_request(GET, BASE_URL_DE + '/api/accounts')\n\n    def get_spaces(self) -> dict:\n        \"\"\"\n        Retrieves a list of all spaces\n        \"\"\"\n        return self._do_request(GET, BASE_URL_DE + '/api/spaces')\n\n    def barzahlen_check(self) -> dict:\n        return self._do_request(GET, BASE_URL_DE + '/api/barzahlen/check')\n\n    def get_cards(self):\n        \"\"\"\n        Retrieves a list of all cards\n        \"\"\"\n        return self._do_request(GET, BASE_URL_DE + '/api/v2/cards')\n\n    def get_account_limits(self) -> list:\n        \"\"\"\n        Retrieves a list of all active account limits\n        \"\"\"\n        return self._do_request(GET, BASE_URL_DE + '/api/settings/account/limits')\n\n    def set_account_limits(self, daily_withdrawal_limit: int = None, daily_payment_limit: int = None) -> None:\n        \"\"\"\n        Sets account limits\n\n        :param daily_withdrawal_limit: daily withdrawal limit\n        :param daily_payment_limit: daily payment limit\n        \"\"\"\n        if daily_withdrawal_limit is not None:\n            self._do_request(POST, BASE_URL_DE + '/api/settings/account/limits', json={\n                \"limit\": DAILY_WITHDRAWAL_LIMIT,\n                \"amount\": daily_withdrawal_limit\n            })\n\n        if daily_payment_limit is not None:\n            self._do_request(POST, BASE_URL_DE + '/api/settings/account/limits', json={\n                \"limit\": DAILY_PAYMENT_LIMIT,\n                \"amount\": daily_payment_limit\n            })\n\n    def get_contacts(self):\n        \"\"\"\n        Retrieves a list of all contacts\n        \"\"\"\n        return self._do_request(GET, BASE_URL_DE + '/api/smrt/contacts')\n\n    def get_standing_orders(self) -> dict:\n        \"\"\"\n        Get a list of standing orders\n        \"\"\"\n        return self._do_request(GET, BASE_URL_DE + '/api/transactions/so')\n\n    def get_transactions(self, from_time: int = None, to_time: int = None, limit: int = 20, pending: bool = None,\n                         categories: str = None, text_filter: str = None, last_id: str = None) -> dict:\n        \"\"\"\n        Get a list of transactions.\n\n        Note that some parameters can not be combined in a single request (like text_filter and pending) and\n        will result in a bad request (400) error.\n\n        :param from_time: earliest transaction time as a Timestamp > 0 - milliseconds since 1970 in CET\n        :param to_time: latest transaction time as a Timestamp > 0 - milliseconds since 1970 in CET\n        :param limit: Limit the number of transactions to return to the given amount - default 20 as the n26 API returns\n        only the last 20 transactions by default\n        :param pending: show only pending transactions\n        :param categories: Comma separated list of category IDs\n        :param text_filter: Query string to search for\n        :param last_id: ??\n        :return: list of transactions\n        \"\"\"\n        if pending and limit:\n            # pending does not support limit\n            limit = None\n\n        return self._do_request(GET, BASE_URL_DE + '/api/smrt/transactions', {\n            'from': from_time,\n            'to': to_time,\n            'limit': limit,\n            'pending': pending,\n            'categories': categories,\n            'textFilter': text_filter,\n            'lastId': last_id\n        })\n\n    def get_transactions_limited(self, limit: int = 5) -> dict:\n        import warnings\n        warnings.warn(\n            \"get_transactions_limited is deprecated, use get_transactions(limit=5) instead\",\n            DeprecationWarning\n        )\n        return self.get_transactions(limit=limit)\n\n    def get_balance_statement(self, statement_url: str):\n        \"\"\"\n        Retrieves a balance statement as pdf content\n        :param statement_url: Download URL of a balance statement document\n        \"\"\"\n        return self._do_request(GET, BASE_URL_DE + statement_url)\n\n    def get_statements(self) -> list:\n        \"\"\"\n        Retrieves a list of all statements\n        \"\"\"\n        return self._do_request(GET, BASE_URL_DE + '/api/statements')\n\n    def block_card(self, card_id: str) -> dict:\n        \"\"\"\n        Blocks a card.\n        If the card is already blocked this will have no effect.\n\n        :param card_id: the id of the card to block\n        :return: some info about the card (not including it's blocked state... thanks n26!)\n        \"\"\"\n        return self._do_request(POST, BASE_URL_DE + '/api/cards/%s/block' % card_id)\n\n    def unblock_card(self, card_id: str) -> dict:\n        \"\"\"\n        Unblocks a card.\n        If the card is already unblocked this will have no effect.\n\n        :param card_id: the id of the card to block\n        :return: some info about the card (not including it's unblocked state... thanks n26!)\n        \"\"\"\n        return self._do_request(POST, BASE_URL_DE + '/api/cards/%s/unblock' % card_id)\n\n    def get_savings(self) -> dict:\n        return self._do_request(GET, BASE_URL_DE + '/api/hub/savings/accounts')\n\n    def get_statistics(self, from_time: int = 0, to_time: int = int(time.time()) * 1000) -> dict:\n        \"\"\"\n        Get statistics in a given time frame\n\n        :param from_time: Timestamp - milliseconds since 1970 in CET\n        :param to_time: Timestamp - milliseconds since 1970 in CET\n        \"\"\"\n\n        if not from_time:\n            from_time = 0\n\n        if not to_time:\n            to_time = int(time.time()) * 1000\n\n        return self._do_request(GET, BASE_URL_DE + '/api/smrt/statistics/categories/%s/%s' % (from_time, to_time))\n\n    def get_available_categories(self) -> list:\n        return self._do_request(GET, BASE_URL_DE + '/api/smrt/categories')\n\n    def get_invitations(self) -> list:\n        return self._do_request(GET, BASE_URL_DE + '/api/aff/invitations')\n\n    def _do_request(self, method: str = GET, url: str = \"/\", params: dict = None,\n                    json: dict = None, headers: dict = None) -> list or dict or None:\n        \"\"\"\n        Executes a http request based on the given parameters\n\n        :param method: the method to use (GET, POST)\n        :param url: the url to use\n        :param params: query parameters that will be appended to the url\n        :param json: request body\n        :param headers: custom headers\n        :return: the response parsed as a json\n        \"\"\"\n        access_token = self.get_token()\n        _headers = {'Authorization': 'Bearer {}'.format(access_token)}\n        if headers is not None:\n            _headers.update(headers)\n\n        url = create_request_url(url, params)\n\n        if method is GET:\n            response = requests.get(url, headers=_headers, json=json)\n        elif method is POST:\n            response = requests.post(url, headers=_headers, json=json)\n        else:\n            raise ValueError(\"Unsupported method: {}\".format(method))\n\n        response.raise_for_status()\n        # some responses do not return data so we just ignore the body in that case\n        if len(response.content) > 0:\n            if \"application/json\" in response.headers.get(\"Content-Type\", \"\"):\n                return response.json()\n            else:\n                return response.content\n\n    def get_encryption_key(self, public_key: str = None) -> dict:\n        \"\"\"\n        Receive public encryption key for the JSON String containing the PIN encryption key\n        \"\"\"\n        return self._do_request(GET, BASE_URL_DE + '/api/encryption/key', params={\n            'publicKey': public_key\n        })\n\n    def encrypt_user_pin(self, pin: str):\n        \"\"\"\n        Encrypts user PIN and prepares it in a format required for a transaction order\n\n        :return: encrypted and base64 encoded PIN as well as an\n                 encrypted and base64 encoded JSON containing the PIN encryption key\n        \"\"\"\n        # generate AES256 key and IV\n        random_password = Random.get_random_bytes(32)\n        salt = Random.get_random_bytes(16)\n        # noinspection PyTypeChecker\n        key = PBKDF2(random_password, salt, 32, count=1000000, hmac_hash_module=SHA512)\n        iv = Random.new().read(AES.block_size)\n        key64 = base64.b64encode(key).decode('utf-8')\n        iv64 = base64.b64encode(iv).decode('utf-8')\n        # encode the key and iv as a json string\n        aes_secret = {\n            'secretKey': key64,\n            'iv': iv64\n        }\n        # json string has to be represented in byte form for encryption\n        unencrypted_aes_secret = bytes(json.dumps(aes_secret), 'utf-8')\n        # Encrypt the secret JSON with RSA using the provided public key\n        public_key = self.get_encryption_key()\n        public_key_non64 = base64.b64decode(public_key['publicKey'])\n        public_key_object = RSA.importKey(public_key_non64)\n        public_key_cipher = PKCS1_v1_5.new(public_key_object)\n        encrypted_secret = public_key_cipher.encrypt(unencrypted_aes_secret)\n        encrypted_secret64 = base64.b64encode(encrypted_secret)\n        # Encrypt user's pin\n        private_key_cipher = AES.new(key=key, mode=AES.MODE_CBC, iv=iv)\n        # the pin has to be padded and transformed into bytes for a correct ecnryption format\n        encrypted_pin = private_key_cipher.encrypt(pad(bytes(pin, 'utf-8'), 16))\n        encrypted_pin64 = base64.b64encode(encrypted_pin)\n\n        return encrypted_secret64, encrypted_pin64\n\n    def create_transaction(self, iban: str, bic: str, name: str, reference: str, amount: float, pin: str):\n        \"\"\"\n        Creates a bank transfer order\n\n        :param iban: recipient IBAN\n        :param bic: recipient BIC\n        :param name: recipient name\n        :param reference: transaction reference\n        :param amount: money amount\n        :param pin: user PIN required for the transaction approval\n        \"\"\"\n        encrypted_secret, encrypted_pin = self.encrypt_user_pin(pin)\n        pin_headers = {\n            'encrypted-secret': encrypted_secret,\n            'encrypted-pin': encrypted_pin\n        }\n\n        # Prepare headers as a json for a transaction call\n        data = {\n            \"transaction\": {\n                \"amount\": amount,\n                \"partnerBic\": bic,\n                \"partnerIban\": iban,\n                \"partnerName\": name,\n                \"referenceText\": reference,\n                \"type\": \"DT\"\n            }\n        }\n\n        return self._do_request(POST, BASE_URL_DE + '/api/transactions', json=data, headers=pin_headers)\n\n    def is_authenticated(self) -> bool:\n        \"\"\"\n        :return: whether valid token data exists\n        \"\"\"\n        return self._validate_token(self.token_data)\n\n    def authenticate(self):\n        \"\"\"\n        Starts a new authentication flow with the N26 servers.\n\n        This method requires user interaction to approve a 2FA request.\n        Therefore you should make sure if you can bypass this\n        by refreshing or reusing an existing token by calling is_authenticated()\n        and refresh_authentication() respectively.\n\n        :raises PermissionError: if the token is invalid even after the refresh\n        \"\"\"\n        LOGGER.debug(\"Requesting token for username: {}\".format(self.config.USERNAME.value))\n        token_data = self._request_token(self.config.USERNAME.value, self.config.PASSWORD.value)\n\n        # add expiration time to expiration in _validate_token()\n        token_data[EXPIRATION_TIME_KEY] = time.time() + token_data[\"expires_in\"]\n\n        # if it's still not valid, raise an exception\n        if not self._validate_token(token_data):\n            raise PermissionError(\"Unable to request authentication token\")\n\n        # save token data\n        self.token_data = token_data\n\n    def refresh_authentication(self):\n        \"\"\"\n        Refreshes an existing authentication using a (possibly expired) token.\n        :raises AssertionError: if no existing token data was found\n        :raises PermissionError: if the token is invalid even after the refresh\n        \"\"\"\n        token_data = self.token_data\n        if REFRESH_TOKEN_KEY in token_data:\n            LOGGER.debug(\"Trying to refresh existing token\")\n            refresh_token = token_data[REFRESH_TOKEN_KEY]\n            token_data = self._refresh_token(refresh_token)\n        else:\n            raise AssertionError(\"Cant refresh token since no existing token data was found. \"\n                                 \"Please initiate a new authentication instead.\")\n\n        # add expiration time to expiration in _validate_token()\n        token_data[EXPIRATION_TIME_KEY] = time.time() + token_data[\"expires_in\"]\n\n        # if it's still not valid, raise an exception\n        if not self._validate_token(token_data):\n            raise PermissionError(\"Unable to refresh authentication token\")\n\n        # save token data\n        self.token_data = token_data\n\n    def get_token(self):\n        \"\"\"\n        Returns the access token to use for api authentication.\n        If a token has been requested before it will be reused if it is still valid.\n        If the previous token has expired it will be refreshed.\n        If no token has been requested it will be requested from the server.\n\n        :return: the access token\n        \"\"\"\n        new_auth = False\n        if not self._validate_token(self.token_data):\n            try:\n                self.refresh_authentication()\n            except HTTPError as http_error:\n                if http_error.response.status_code != 401:\n                    raise http_error\n                new_auth = True\n            except AssertionError:\n                new_auth = True\n\n        if new_auth:\n            self.authenticate()\n\n        return self.token_data[ACCESS_TOKEN_KEY]\n\n    def _request_token(self, username: str, password: str) -> dict:\n        \"\"\"\n        Request an authentication token from the server\n        :return: the token or None if the response did not contain a token\n        \"\"\"\n        mfa_token = self._initiate_authentication_flow(username, password)\n        self._request_mfa_approval(mfa_token)\n        return self._complete_authentication_flow(mfa_token)\n\n    def _initiate_authentication_flow(self, username: str, password: str) -> str:\n        LOGGER.debug(\"Requesting authentication flow for user {}\".format(username))\n        values_token = {\n            \"grant_type\": GRANT_TYPE_PASSWORD,\n            \"username\": username,\n            \"password\": password\n        }\n        # TODO: Seems like the user-agent is not necessary but might be a good idea anyway\n        response = requests.post(f\"{self.config.AUTH_BASE_URL.value}/oauth2/token\", data=values_token,\n                                 headers=BASIC_AUTH_HEADERS)\n        if response.status_code != 403:\n            raise ValueError(\"Unexpected response for initial auth request: {}\".format(response.text))\n\n        response_data = response.json()\n        if response_data.get(\"error\", \"\") == \"mfa_required\":\n            return response_data[\"mfaToken\"]\n        else:\n            raise ValueError(\"Unexpected response data\")\n\n    def _refresh_token(self, refresh_token: str):\n        \"\"\"\n        Refreshes an authentication token\n        :param refresh_token: the refresh token issued by the server when requesting a token\n        :return: the refreshed token data\n        \"\"\"\n        LOGGER.debug(\"Requesting token refresh using refresh_token {}\".format(refresh_token))\n        values_token = {\n            'grant_type': GRANT_TYPE_REFRESH_TOKEN,\n            'refresh_token': refresh_token,\n        }\n\n        response = requests.post(f\"{self.config.AUTH_BASE_URL.value}/oauth2/token\", data=values_token,\n                                 headers=BASIC_AUTH_HEADERS)\n        response.raise_for_status()\n        return response.json()\n\n    def _request_mfa_approval(self, mfa_token: str):\n        LOGGER.debug(\"Requesting MFA approval using mfa_token {}\".format(mfa_token))\n        mfa_data = {\n            \"mfaToken\": mfa_token\n        }\n\n        if self.config.MFA_TYPE.value == MFA_TYPE_SMS:\n            mfa_data['challengeType'] = \"otp\"\n        else:\n            mfa_data['challengeType'] = \"oob\"\n\n        response = requests.post(\n            BASE_URL_DE + \"/api/mfa/challenge\",\n            json=mfa_data,\n            headers={\n                **BASIC_AUTH_HEADERS,\n                \"User-Agent\": USER_AGENT,\n                \"Content-Type\": \"application/json\"\n            })\n        response.raise_for_status()\n\n    @retry(wait=wait_fixed(5), stop=stop_after_delay(60))\n    def _complete_authentication_flow(self, mfa_token: str) -> dict:\n        LOGGER.debug(\"Completing authentication flow for mfa_token {}\".format(mfa_token))\n        mfa_response_data = {\n            \"mfaToken\": mfa_token\n        }\n\n        if self.config.MFA_TYPE.value == MFA_TYPE_SMS:\n            mfa_response_data['grant_type'] = \"mfa_otp\"\n\n            hint = click.style(\"Enter the 6 digit SMS OTP code\", fg=\"yellow\")\n\n            # type=str because it can have significant leading zeros\n            mfa_response_data['otp'] = click.prompt(hint, type=str)\n        else:\n            mfa_response_data['grant_type'] = \"mfa_oob\"\n\n        response = requests.post(BASE_URL_DE + \"/oauth2/token\", data=mfa_response_data, headers=BASIC_AUTH_HEADERS)\n        response.raise_for_status()\n        tokens = response.json()\n        return tokens\n\n    @staticmethod\n    def _validate_token(token_data: dict):\n        \"\"\"\n        Checks if a token is valid\n        :param token_data: the token data to check\n        :return: true if valid, false otherwise\n        \"\"\"\n        if EXPIRATION_TIME_KEY not in token_data:\n            # there was a problem adding the expiration_time property\n            return False\n        elif time.time() >= token_data[EXPIRATION_TIME_KEY]:\n            # token has expired\n            return False\n\n        return ACCESS_TOKEN_KEY in token_data and token_data[ACCESS_TOKEN_KEY]\n"
  },
  {
    "path": "n26/cli.py",
    "content": "import functools\nimport logging\nimport webbrowser\nfrom datetime import datetime, timezone\nfrom pathlib import Path\nfrom typing import Tuple\n\nimport click\nfrom requests import HTTPError\nfrom tabulate import tabulate\n\nimport n26.api as api\nfrom n26.config import Config\nfrom n26.const import AMOUNT, CURRENCY, REFERENCE_TEXT, ATM_WITHDRAW, CARD_STATUS_ACTIVE, DATETIME_FORMATS\n\nLOGGER = logging.getLogger(__name__)\n\nAPI_CLIENT = api.Api()\n\nJSON_OUTPUT = False\nCONTEXT_SETTINGS = dict(help_option_names=['-h', '--help'])\n\n\ndef auth_decorator(func: callable):\n    \"\"\"\n    Decorator ensuring authentication before making api requests\n    :param func: function to patch\n    \"\"\"\n\n    @functools.wraps(func)\n    def wrapper(*args, **kwargs):\n        new_auth = False\n        try:\n            API_CLIENT.refresh_authentication()\n        except HTTPError as http_error:\n            if http_error.response.status_code != 401:\n                raise http_error\n            new_auth = True\n        except AssertionError:\n            new_auth = True\n\n        if new_auth:\n            hint = click.style(\"Initiating authentication flow, please check your phone to approve login.\", fg=\"yellow\")\n            click.echo(hint)\n\n            API_CLIENT.authenticate()\n\n            success = click.style(\"Authentication successful :)\", fg=\"green\")\n            click.echo(success)\n\n        return func(*args, **kwargs)\n\n    return wrapper\n\n\n# Cli returns command line requests\n@click.group(context_settings=CONTEXT_SETTINGS)\n@click.option(\"-json\", default=False, type=bool, is_flag=True)\n@click.version_option()\ndef cli(json: bool):\n    \"\"\"Interact with the https://n26.com API via the command line.\"\"\"\n    global JSON_OUTPUT\n    JSON_OUTPUT = json\n\n\n@cli.command()\ndef logout():\n    \"\"\" Logout \"\"\"\n    cfg = Config()\n    login_data_file = cfg.LOGIN_DATA_STORE_PATH.value\n    if login_data_file is not None:\n        login_data_file = login_data_file.expanduser().resolve()\n        login_data_file.unlink(missing_ok=True)\n\n\n@cli.command()\n@auth_decorator\ndef addresses():\n    \"\"\" Show account addresses \"\"\"\n    addresses_data = API_CLIENT.get_addresses().get('data')\n    if JSON_OUTPUT:\n        _print_json(addresses_data)\n        return\n\n    headers = ['Type', 'Country', 'City', 'Zip code', 'Street', 'Number',\n               'Address line 1', 'Address line 2',\n               'Created', 'Updated']\n    keys = ['type', 'countryName', 'cityName', 'zipCode', 'streetName', 'houseNumberBlock',\n            'addressLine1', 'addressLine2',\n            _datetime_extractor('created'), _datetime_extractor('updated')]\n    table = _create_table_from_dict(headers, keys, addresses_data, numalign='right')\n    click.echo(table)\n\n\n@cli.command()\n@auth_decorator\ndef info():\n    \"\"\" Get account information \"\"\"\n    account_info = API_CLIENT.get_account_info()\n    if JSON_OUTPUT:\n        _print_json(account_info)\n        return\n\n    lines = [\n        [\"Name:\", \"{} {}\".format(account_info.get('firstName'), account_info.get('lastName'))],\n        [\"Email:\", account_info.get('email')],\n        [\"Gender:\", account_info.get('gender')],\n        [\"Nationality:\", account_info.get('nationality')],\n        [\"Phone:\", account_info.get('mobilePhoneNumber')]\n    ]\n\n    text = tabulate(lines, [], tablefmt=\"plain\", colalign=[\"right\", \"left\"])\n\n    click.echo(text)\n\n\n@cli.command()\n@auth_decorator\ndef status():\n    \"\"\" Get account statuses \"\"\"\n    account_statuses = API_CLIENT.get_account_statuses()\n    if JSON_OUTPUT:\n        _print_json(account_statuses)\n        return\n\n    lines = [\n        [\"Account created:\", _timestamp_ms_to_date(account_statuses.get('created'))],\n        [\"Account updated:\", _timestamp_ms_to_date(account_statuses.get('updated'))],\n        [\"Account closed:\", account_statuses.get('accountClosed')],\n        [\"Card activation completed:\", _timestamp_ms_to_date(account_statuses.get('cardActivationCompleted'))],\n        [\"Card issued:\", _timestamp_ms_to_date(account_statuses.get('cardIssued'))],\n        [\"Core data updated:\", _timestamp_ms_to_date(account_statuses.get('coreDataUpdated'))],\n        [\"Email validation initiated:\", _timestamp_ms_to_date(account_statuses.get('emailValidationInitiated'))],\n        [\"Email validation completed:\", _timestamp_ms_to_date(account_statuses.get('emailValidationCompleted'))],\n        [\"First incoming transaction:\", _timestamp_ms_to_date(account_statuses.get('firstIncomingTransaction'))],\n        [\"Flex account:\", account_statuses.get('flexAccount')],\n        [\"Flex account confirmed:\", _timestamp_ms_to_date(account_statuses.get('flexAccountConfirmed'))],\n        [\"Is deceased:\", account_statuses.get('isDeceased')],\n        [\"Pairing State:\", account_statuses.get('pairingState')],\n        [\"Phone pairing initiated:\", _timestamp_ms_to_date(account_statuses.get('phonePairingInitiated'))],\n        [\"Phone pairing completed:\", _timestamp_ms_to_date(account_statuses.get('phonePairingCompleted'))],\n        [\"Pin definition completed:\", _timestamp_ms_to_date(account_statuses.get('pinDefinitionCompleted'))],\n        [\"Product selection completed:\", _timestamp_ms_to_date(account_statuses.get('productSelectionCompleted'))],\n        [\"Signup step:\", account_statuses.get('signupStep')],\n        [\"Single step signup:\", _timestamp_ms_to_date(account_statuses.get('singleStepSignup'))],\n        [\"Unpairing process status:\", account_statuses.get('unpairingProcessStatus')],\n    ]\n\n    text = tabulate(lines, [], tablefmt=\"plain\", colalign=[\"right\", \"left\"])\n\n    click.echo(text)\n\n\n@cli.command()\n@auth_decorator\ndef balance():\n    \"\"\" Show account balance \"\"\"\n    balance_data = API_CLIENT.get_balance()\n    if JSON_OUTPUT:\n        _print_json(balance_data)\n        return\n\n    amount = balance_data.get('availableBalance')\n    currency = balance_data.get('currency')\n    click.echo(\"{} {}\".format(amount, currency))\n\n\n@cli.command()\ndef browse():\n    \"\"\" Browse on the web https://app.n26.com/ \"\"\"\n    webbrowser.open('https://app.n26.com/')\n\n\n@cli.command()\n@auth_decorator\ndef spaces():\n    \"\"\" Show spaces \"\"\"\n    spaces_data = API_CLIENT.get_spaces()[\"spaces\"]\n    if JSON_OUTPUT:\n        _print_json(spaces_data)\n        return\n\n    lines = []\n    for i, space in enumerate(spaces_data):\n        line = []\n        available_balance = space['balance']['availableBalance']\n        currency = space['balance']['currency']\n        name = space['name']\n\n        line.append(name)\n        line.append(\"{} {}\".format(available_balance, currency))\n\n        if 'goal' in space:\n            goal = space['goal']['amount']\n            percentage = available_balance / goal\n\n            line.append(\"{} {}\".format(goal, currency))\n            line.append('{:.2%}'.format(percentage))\n        else:\n            line.append(\"-\")\n            line.append(\"-\")\n\n        lines.append(line)\n\n    headers = ['Name', 'Balance', 'Goal', 'Progress']\n    text = tabulate(lines, headers, colalign=['left', 'right', 'right', 'right'], numalign='right')\n\n    click.echo(text)\n\n\n@cli.command()\n@auth_decorator\ndef cards():\n    \"\"\" Shows a list of cards \"\"\"\n    cards_data = API_CLIENT.get_cards()\n    if JSON_OUTPUT:\n        _print_json(cards_data)\n        return\n\n    headers = ['Id', 'Masked Pan', 'Type', 'Design', 'Status', 'Activated', 'Pin defined', 'Expires']\n    keys = [\n        'id',\n        'maskedPan',\n        'cardType',\n        'design',\n        lambda x: \"active\" if (x.get('status') == CARD_STATUS_ACTIVE) else x.get('status'),\n        _datetime_extractor('cardActivated'),\n        _datetime_extractor('pinDefined'),\n        _datetime_extractor('expirationDate', date_only=True),\n    ]\n    text = _create_table_from_dict(headers=headers, value_functions=keys, data=cards_data, numalign='right')\n\n    click.echo(text.strip())\n\n\n@cli.command()\n@click.option('--card', default=None, type=str, help='ID of the card to block. Omitting this will block all cards.')\n@auth_decorator\ndef card_block(card: str):\n    \"\"\" Blocks the card/s \"\"\"\n    if card:\n        card_ids = [card]\n    else:\n        card_ids = [card['id'] for card in API_CLIENT.get_cards()]\n\n    for card_id in card_ids:\n        API_CLIENT.block_card(card_id)\n        click.echo('Blocked card: ' + card_id)\n\n\n@cli.command()\n@click.option('--card', default=None, type=str, help='ID of the card to unblock. Omitting this will unblock all cards.')\n@auth_decorator\ndef card_unblock(card: str):\n    \"\"\" Unblocks the card/s \"\"\"\n    if card:\n        card_ids = [card]\n    else:\n        card_ids = [card['id'] for card in API_CLIENT.get_cards()]\n\n    for card_id in card_ids:\n        API_CLIENT.unblock_card(card_id)\n        click.echo('Unblocked card: ' + card_id)\n\n\n@cli.command()\n@auth_decorator\ndef limits():\n    \"\"\" Show n26 account limits \"\"\"\n    _limits()\n\n\n@cli.command()\n@click.option('--withdrawal', default=None, type=int, help='Daily withdrawal limit.')\n@click.option('--payment', default=None, type=int, help='Daily payment limit.')\n@auth_decorator\ndef set_limits(withdrawal: int, payment: int):\n    \"\"\" Set n26 account limits \"\"\"\n    API_CLIENT.set_account_limits(withdrawal, payment)\n    _limits()\n\n\ndef _limits():\n    limits_data = API_CLIENT.get_account_limits()\n\n    if JSON_OUTPUT:\n        _print_json(limits_data)\n        return\n\n    headers = ['Name', 'Amount', 'Country List']\n    keys = ['limit', 'amount', 'countryList']\n    text = _create_table_from_dict(headers=headers, value_functions=keys, data=limits_data, numalign='right')\n\n    click.echo(text)\n\n\n@cli.command()\n@auth_decorator\ndef contacts():\n    \"\"\" Show your n26 contacts \"\"\"\n    contacts_data = API_CLIENT.get_contacts()\n\n    if JSON_OUTPUT:\n        _print_json(contacts_data)\n        return\n\n    headers = ['Id', 'Name', 'IBAN']\n    keys = ['id', 'name', 'subtitle']\n    text = _create_table_from_dict(headers=headers, value_functions=keys, data=contacts_data, numalign='right')\n\n    click.echo(text.strip())\n\n\n@cli.command()\n@click.option('--id', default=None, type=str,\n              help='Id of a single statement')\n@click.option('--from', 'param_from', default=None, type=click.DateTime(DATETIME_FORMATS),\n              help='Start time limit for statements.')\n@click.option('--to', 'param_to', default=None, type=click.DateTime(DATETIME_FORMATS),\n              help='End time limit for statements.')\n@click.option('--download', default=None, type=str,\n              help='Download statements as pdf to this dir.')\n@auth_decorator\ndef statements(id: str or None, param_from: datetime or None, param_to: datetime or None, download: str or None):\n    \"\"\" Show your n26 statements  \"\"\"\n    statements_data = API_CLIENT.get_statements()\n    statements_filter = None\n\n    if id:\n        statements_filter = lambda statement: statement['id'] == id\n    elif param_from or param_to:\n        statement_from = param_from if param_from else datetime.fromtimestamp(0)\n        statement_to = param_to if param_to else datetime.utcnow()\n        statements_filter = lambda statement: statement_from <= datetime(int(statement['year']), int(statement['month']), 1) <= statement_to\n\n    if statements_filter:\n        statements_data = list(filter(statements_filter, statements_data))\n\n    if JSON_OUTPUT:\n        _print_json(statements_data)\n        return\n\n    headers = ['Id', 'Url', 'Visible TS', 'Month', 'Year']\n    keys = ['id', 'url', 'visibleTS', 'month', 'year']\n    text = _create_table_from_dict(headers=headers, value_functions=keys, data=statements_data, numalign='right')\n\n    click.echo(text.strip())\n\n    if not download:\n        return\n\n    output_path = Path(download).expanduser().resolve()\n    if not output_path.is_dir():\n        click.echo(\"Target path doesn't exist or is not a folder, skipping download.\")\n        return\n\n    for statement in statements_data:\n        filepath = Path.joinpath(output_path, f'{statement[\"id\"]}.pdf')\n        click.echo(f\"Downloading {filepath}...\")\n        statement_data = API_CLIENT.get_balance_statement(statement['url'])\n        with open(filepath, 'wb') as f:\n            f.write(statement_data)\n\n\n@cli.command()\n@click.option('--categories', default=None, type=str,\n              help='Comma separated list of category IDs.')\n@click.option('--pending', default=None, type=bool,\n              help='Whether to show only pending transactions.')\n@click.option('--from', 'param_from', default=None, type=click.DateTime(DATETIME_FORMATS),\n              help='Start time limit for transactions.')\n@click.option('--to', 'param_to', default=None, type=click.DateTime(DATETIME_FORMATS),\n              help='End time limit for transactions.')\n@click.option('--text-filter', default=None, type=str, help='Text filter.')\n@click.option('--limit', default=None, type=click.IntRange(1, 10000), help='Limit transaction output.')\n@auth_decorator\ndef transactions(categories: str, pending: bool, param_from: datetime or None, param_to: datetime or None,\n                 text_filter: str, limit: int):\n    \"\"\" Show transactions (default: 5) \"\"\"\n    if not JSON_OUTPUT and not pending and not param_from and not limit:\n        limit = 5\n        click.echo(click.style(\"Output is limited to {} entries.\".format(limit), fg=\"yellow\"))\n\n    from_timestamp, to_timestamp = _parse_from_to_timestamps(param_from, param_to)\n    transactions_data = API_CLIENT.get_transactions(from_time=from_timestamp, to_time=to_timestamp,\n                                                    limit=limit, pending=pending, text_filter=text_filter,\n                                                    categories=categories)\n\n    if JSON_OUTPUT:\n        _print_json(transactions_data)\n        return\n\n    lines = []\n    for i, transaction in enumerate(transactions_data):\n        amount = transaction.get(AMOUNT, 0)\n        currency = transaction.get(CURRENCY, None)\n\n        if amount < 0:\n            sender_name = \"You\"\n            sender_iban = \"\"\n            recipient_name = transaction.get('merchantName', transaction.get('partnerName', ''))\n            recipient_iban = transaction.get('partnerIban', '')\n        else:\n            sender_name = transaction.get('partnerName', '')\n            sender_iban = transaction.get('partnerIban', '')\n            recipient_name = \"You\"\n            recipient_iban = \"\"\n\n        recurring = transaction.get('recurring', '')\n\n        if transaction['type'] == ATM_WITHDRAW:\n            message = \"ATM Withdrawal\"\n        else:\n            message = transaction.get(REFERENCE_TEXT)\n\n        lines.append([\n            _datetime_extractor('visibleTS')(transaction),\n            \"{} {}\".format(amount, currency),\n            \"{}\\n{}\".format(sender_name, sender_iban),\n            \"{}\\n{}\".format(recipient_name, recipient_iban),\n            _insert_newlines(message),\n            recurring\n        ])\n\n    headers = ['Date', 'Amount', 'From', 'To', 'Message', 'Recurring']\n    text = tabulate(lines, headers, numalign='right')\n\n    click.echo(text.strip())\n\n\n@cli.command(\"transaction\")\n@auth_decorator\ndef transaction():\n    \"\"\"Create a bank transfer\"\"\"\n    # Get all the necessary transfer information from user's input\n    iban = click.prompt(\"Recipient's IBAN (spaces are allowed): \", type=str)\n    bic = click.prompt(\"Recipient's BIC (optional): \", type=str, default=\"\", show_default=False)\n    name = click.prompt(\"Recipient's name: \", type=str)\n    reference = click.prompt(\"Transfer reference (optional): \", type=str, default=\"\", show_default=False)\n    amount = click.prompt(\"Transfer amount (only numeric value, dot separated): \", type=str)\n    pin = click.prompt(\"Please enter your PIN (input is hidden): \", hide_input=True, type=str)\n\n    response = API_CLIENT.create_transaction(iban, bic, name, reference, amount, pin)\n\n    if JSON_OUTPUT:\n        _print_json(response)\n\n\n@cli.command(\"standing-orders\")\n@auth_decorator\ndef standing_orders():\n    \"\"\"Show your standing orders\"\"\"\n    standing_orders_data = API_CLIENT.get_standing_orders()\n\n    if JSON_OUTPUT:\n        _print_json(standing_orders_data)\n        return\n\n    headers = ['To',\n               'Amount',\n               'Frequency',\n               'Until',\n               'Every',\n               'First execution', 'Next execution',\n               'Executions',\n               'Created', 'Updated']\n    values = ['partnerName',\n              lambda x: \"{} {}\".format(x.get('amount'), x.get('currencyCode').get('currencyCode')),\n              'executionFrequency',\n              _datetime_extractor('stopTS', date_only=True),\n              _day_of_month_extractor('initialDayOfMonth'),\n              _datetime_extractor('firstExecutingTS', date_only=True),\n              _datetime_extractor('nextExecutingTS', date_only=True),\n              'executionCounter',\n              _datetime_extractor('created'),\n              _datetime_extractor('updated')]\n    text = _create_table_from_dict(headers, value_functions=values, data=standing_orders_data['data'])\n\n    click.echo(text.strip())\n\n\n@cli.command()\n@click.option('--from', 'param_from', default=None, type=click.DateTime(DATETIME_FORMATS),\n              help='Start time limit for statistics.')\n@click.option('--to', 'param_to', default=None, type=click.DateTime(DATETIME_FORMATS),\n              help='End time limit for statistics.')\n@auth_decorator\ndef statistics(param_from: datetime or None, param_to: datetime or None):\n    \"\"\"Show your n26 statistics\"\"\"\n\n    from_timestamp, to_timestamp = _parse_from_to_timestamps(param_from, param_to)\n    statistics_data = API_CLIENT.get_statistics(from_time=from_timestamp, to_time=to_timestamp)\n\n    if JSON_OUTPUT:\n        _print_json(statistics_data)\n        return\n\n    text = \"From: %s\\n\" % (_timestamp_ms_to_date(statistics_data[\"from\"]))\n    text += \"To:   %s\\n\\n\" % (_timestamp_ms_to_date(statistics_data[\"to\"]))\n\n    headers = ['Total', 'Income', 'Expense', '#IncomeCategories', '#ExpenseCategories']\n    values = ['total', 'totalIncome', 'totalExpense',\n              lambda x: len(x.get('incomeItems')),\n              lambda x: len(x.get('expenseItems'))]\n\n    text += _create_table_from_dict(headers, value_functions=values, data=[statistics_data])\n\n    text += \"\\n\\n\"\n\n    headers = ['Category', 'Income', 'Expense', 'Total']\n    keys = ['id', 'income', 'expense', 'total']\n    text += _create_table_from_dict(headers, keys, statistics_data[\"items\"], numalign='right')\n\n    click.echo(text.strip())\n\n\ndef _print_json(data: dict or list):\n    \"\"\"\n    Pretty-Prints the given object to the  console\n    :param data: data to print\n    \"\"\"\n    import json\n    json_data = json.dumps(data, indent=2)\n    click.echo(json_data)\n\n\ndef _parse_from_to_timestamps(param_from: datetime or None, param_to: datetime or None) -> Tuple[int, int]:\n    \"\"\"\n    Parses cli datetime inputs for \"from\" and \"to\" parameters\n    :param param_from: \"from\" input\n    :param param_to: \"to\" input\n    :return: timestamps ready to be used by the api\n    \"\"\"\n    from_timestamp = None\n    to_timestamp = None\n    if param_from is not None:\n        from_timestamp = int(param_from.timestamp() * 1000)\n        if param_to is None:\n            # if --from is set, --to must also be set\n            param_to = datetime.utcnow()\n    if param_to is not None:\n        if param_from is None:\n            # if --to is set, --from must also be set\n            from_timestamp = 1\n        to_timestamp = int(param_to.timestamp() * 1000)\n\n    return from_timestamp, to_timestamp\n\n\ndef _timestamp_ms_to_date(epoch_ms: int) -> datetime or None:\n    \"\"\"\n    Convert millisecond timestamp to UTC datetime.\n\n    :param epoch_ms: milliseconds since 1970 in CET\n    :return: a UTC datetime object\n    \"\"\"\n    if epoch_ms:\n        return datetime.fromtimestamp(epoch_ms / 1000, timezone.utc)\n\n\ndef _create_table_from_dict(headers: list, value_functions: list, data: list, **tabulate_args) -> str:\n    \"\"\"\n    Helper function to turn a list of dictionaries into a table.\n\n    Note: This method does NOT work with nested dictionaries and will only inspect top-level keys\n\n    :param headers: the headers to use for the columns\n    :param value_functions: function that extracts the value for a given key. Can also be a simple string that\n                            will be used as dictionary key.\n    :param data: a list of dictionaries containing the data\n    :return: a table\n    \"\"\"\n\n    if len(headers) != len(value_functions):\n        raise AttributeError(\"Number of headers does not match number of keys!\")\n\n    lines = []\n    if isinstance(data, list):\n        for dictionary in data:\n            line = []\n            for value_function in value_functions:\n                if callable(value_function):\n                    value = value_function(dictionary)\n                    line.append(value)\n                else:\n                    # try to use is as a dict key\n                    line.append(dictionary.get(str(value_function)))\n\n            lines.append(line)\n\n    return tabulate(tabular_data=lines, headers=headers, **tabulate_args)\n\n\ndef _datetime_extractor(key: str, date_only: bool = False):\n    \"\"\"\n    Helper function to extract a datetime value from a dict\n    :param key: the dictionary key used to access the value\n    :param date_only: removes the time from the output\n    :return: an extractor function\n    \"\"\"\n\n    if date_only:\n        fmt = \"%x\"\n    else:\n        fmt = \"%x %X\"\n\n    def extractor(dictionary: dict):\n        value = dictionary.get(key)\n        time = _timestamp_ms_to_date(value)\n        if time is None:\n            return None\n        else:\n            time = time.astimezone()\n            return time.strftime(fmt)\n\n    return extractor\n\n\ndef _day_of_month_extractor(key: str):\n    def extractor(dictionary: dict):\n        value = dictionary.get(key)\n        if value is None:\n            return None\n        else:\n            import inflect\n            engine = inflect.engine()\n            return engine.ordinal(value)\n\n    return extractor\n\n\ndef _insert_newlines(text: str, n=40):\n    \"\"\"\n    Inserts a newline into the given text every n characters.\n    :param text: the text to break\n    :param n:\n    :return:\n    \"\"\"\n    if not text:\n        return \"\"\n\n    lines = []\n    for i in range(0, len(text), n):\n        lines.append(text[i:i + n])\n    return '\\n'.join(lines)\n\n\nif __name__ == '__main__':\n    cli()\n"
  },
  {
    "path": "n26/config.py",
    "content": "from container_app_conf import ConfigBase\nfrom container_app_conf.entry.file import FileConfigEntry\nfrom container_app_conf.entry.string import StringConfigEntry\nfrom container_app_conf.source.env_source import EnvSource\nfrom container_app_conf.source.toml_source import TomlSource\nfrom container_app_conf.source.yaml_source import YamlSource\n\nNODE_ROOT = \"n26\"\n\nMFA_TYPE_APP = \"app\"\nMFA_TYPE_SMS = \"sms\"\n\n\nclass Config(ConfigBase):\n\n    def __new__(cls, *args, **kwargs):\n        if \"data_sources\" not in kwargs.keys():\n            yaml_source = YamlSource(\"n26\")\n            toml_source = TomlSource(\"n26\")\n            data_sources = [\n                EnvSource(),\n                yaml_source,\n                toml_source\n            ]\n            kwargs[\"data_sources\"] = data_sources\n\n        return super(Config, cls).__new__(cls, *args, **kwargs)\n\n    AUTH_BASE_URL = StringConfigEntry(\n        description=\"Base URL for N26 Authentication\",\n        example=\"\",\n        default=\"https://api.tech26.de\",\n        key_path=[\n            NODE_ROOT,\n            \"auth_base_url\"\n        ],\n        required=True\n    )\n\n    USERNAME = StringConfigEntry(\n        description=\"N26 account username\",\n        example=\"john.doe@example.com\",\n        key_path=[\n            NODE_ROOT,\n            \"username\"\n        ],\n        required=True\n    )\n\n    PASSWORD = StringConfigEntry(\n        description=\"N26 account password\",\n        example=\"$upersecret\",\n        key_path=[\n            NODE_ROOT,\n            \"password\"\n        ],\n        required=True,\n        secret=True\n    )\n\n    DEVICE_TOKEN = StringConfigEntry(\n        description=\"N26 device token\",\n        example=\"00000000-0000-0000-0000-000000000000\",\n        key_path=[\n            NODE_ROOT,\n            \"device_token\"\n        ],\n        required=True,\n        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}\",\n    )\n\n    LOGIN_DATA_STORE_PATH = FileConfigEntry(\n        description=\"File path to store login data\",\n        example=\"~/.config/n26/token_data\",\n        key_path=[\n            NODE_ROOT,\n            \"login_data_store_path\"\n        ],\n        required=False,\n        default=None\n    )\n\n    MFA_TYPE = StringConfigEntry(\n        description=\"Multi-Factor-Authentication type to use\",\n        example=MFA_TYPE_APP,\n        key_path=[\n            NODE_ROOT,\n            \"mfa_type\"\n        ],\n        regex=\"^({})$\".format(\"|\".join([MFA_TYPE_APP, MFA_TYPE_SMS])),\n        default=MFA_TYPE_APP\n    )\n"
  },
  {
    "path": "n26/const.py",
    "content": "\"\"\"\ntransaction types and their meaning\n\"\"\"\nDEBIT_PAYMENT = \"AA\"\nSEPA_WITHDRAW = \"DD\"\nINCOMING_TRANSFER = \"CT\"\nATM_WITHDRAW = \"PT\"\n\n\"\"\"\ntransaction item keys\n\"\"\"\nCURRENCY = 'currencyCode'\nAMOUNT = 'amount'\nREFERENCE_TEXT = 'referenceText'\n\nCARD_STATUS_ACTIVE = 'M_ACTIVE'\n\nDAILY_WITHDRAWAL_LIMIT = 'ATM_DAILY_ACCOUNT'\nDAILY_PAYMENT_LIMIT = 'POS_DAILY_ACCOUNT'\n\nDATETIME_FORMATS = [\n    '%m/%d/%Y %H:%M:%S',\n    '%m/%d/%Y',\n    '%Y-%m-%d',\n    '%Y-%m-%dT%H:%M:%S',\n    '%Y-%m-%d %H:%M:%S',\n    '%d.%m.%Y',\n    '%d.%m.%Y %H:%M:%S'\n]\n"
  },
  {
    "path": "n26/util.py",
    "content": "def create_request_url(url: str, params: dict = None):\n    \"\"\"\n    Adds query params to the given url\n\n    :param url: the url to extend\n    :param params: query params as a keyed dictionary\n    :return: the url including the given query params\n    \"\"\"\n\n    if params:\n        first_param = True\n        for k, v in sorted(params.items(), key=lambda entry: entry[0]):\n            if not v:\n                # skip None values\n                continue\n\n            if first_param:\n                url += '?'\n                first_param = False\n            else:\n                url += '&'\n\n            url += \"%s=%s\" % (k, v)\n\n    return url\n"
  },
  {
    "path": "n26.tml.example",
    "content": "[n26]\nauth_base_url = \"https://api.tech26.de\"\nusername = \"john.doe@example.com\"\npassword = \"$upersecret\"\ndevice_token = \"00000000-0000-0000-0000-000000000000\"\nlogin_data_store_path = \"~/.config/n26/token_data\"\nmfa_type = \"app\""
  },
  {
    "path": "n26.yml.example",
    "content": "n26:\n    auth_base_url: https://api.tech26.de\n    username: john.doe@example.com\n    password: $upersecret\n    device_token: 00000000-0000-0000-0000-000000000000\n    login_data_store_path: \"~/.config/n26/token_data\"\n    mfa_type: app\n"
  },
  {
    "path": "requirements.txt",
    "content": "certifi==2022.12.7\nchardet==5.1.0\nclick==8.1.3\ncontainer-app-conf==5.2.2\nidna==3.4\nimportlib-metadata==6.0.0\ninflect==6.0.2\nmore-itertools==9.0.0\npycryptodome==3.17\npy-range-parse==1.0.5\npython-dateutil==2.8.2\npytimeparse==1.1.8\npyyaml==6.0\nrequests==2.28.2\nruamel.yaml==0.17.21\nruamel.yaml.clib==0.2.7\nsix==1.16.0\ntabulate==0.9.0\ntenacity==8.1.0\ntoml==0.10.2\nurllib3>=1.26.5\nzipp==3.12.0\n"
  },
  {
    "path": "setup.cfg",
    "content": "[metadata]\ndescription-file = README.md\n"
  },
  {
    "path": "setup.py",
    "content": "import inspect\nimport os\n\nfrom setuptools import setup\n\n__location__ = os.path.join(os.getcwd(), os.path.dirname(inspect.getfile(inspect.currentframe())))\n\n\ndef read_version(package):\n    with open(os.path.join(package, '__init__.py'), 'r') as fd:\n        for line in fd:\n            if line.startswith('__version__ = '):\n                return line.split()[-1].strip().strip(\"'\")\n\n\ndef read_requirements():\n    with open(\"requirements.txt\", \"r\") as fh:\n        requirements = fh.readlines()\n        return [req.split(\"==\")[0] for req in requirements if req.strip() != '' and not req.strip().startswith(\"#\")]\n\n\ndef get_install_requirements(path):\n    content = open(os.path.join(__location__, path)).read()\n    return [req for req in content.split('\\\\n') if req != '']\n\n\ndef read(fname):\n    return open(os.path.join(__location__, fname)).read()\n\n\nVERSION = read_version('n26')\n\nsetup(\n    description='API and command line tools to interact with the https://n26.com/ API',\n    long_description='API and command line tools to interact with the https://n26.com/ API',\n    author='Felix Mueller',\n    author_email='felix@s4ku.com',\n    url='https://github.com/femueller/python-n26',\n    download_url='https://github.com/femueller/python-n26/tarball/{version}'.format(version=VERSION),\n    version=VERSION,\n    install_requires=read_requirements(),\n    test_requires=['mock', 'pytest'],\n    packages=[\n        'n26'\n    ],\n    scripts=[],\n    name='n26',\n    entry_points={\n        'console_scripts': ['n26 = n26.cli:cli']\n    }\n)\n"
  },
  {
    "path": "tests/.gitkeep",
    "content": ""
  },
  {
    "path": "tests/__init__.py",
    "content": ""
  },
  {
    "path": "tests/api_responses/account_info.json",
    "content": "{\n  \"id\": \"12345678-abcd-1234-abcd-1234567890ab\",\n  \"email\": \"john.doe@example.com\",\n  \"firstName\": \"John\",\n  \"lastName\": \"Doe\",\n  \"kycFirstName\": \"John Dee\",\n  \"kycLastName\": \"Doe\",\n  \"title\": \"\",\n  \"gender\": \"MALE\",\n  \"birthDate\": 687916800000,\n  \"signupCompleted\": true,\n  \"nationality\": \"DEU\",\n  \"mobilePhoneNumber\": \"+491234567890\",\n  \"shadowUserId\": \"12345678-1234-1234-1234-1234567890ab\",\n  \"transferWiseTermsAccepted\": false,\n  \"idNowToken\": null\n}"
  },
  {
    "path": "tests/api_responses/account_limits.json",
    "content": "[\n  {\n    \"limit\": \"POS_DAILY_ACCOUNT\",\n    \"amount\": 2500.00,\n    \"countryList\": null\n  },\n  {\n    \"limit\": \"ATM_DAILY_ACCOUNT\",\n    \"amount\": 2500.00,\n    \"countryList\": null\n  }\n]"
  },
  {
    "path": "tests/api_responses/account_statuses.json",
    "content": "{\n  \"id\": \"12345678-1234-abcd-abcd-1234567890ab\",\n  \"created\": 1464196274939,\n  \"updated\": 1541587937999,\n  \"singleStepSignup\": 1464196273794,\n  \"emailValidationInitiated\": 1464196273794,\n  \"emailValidationCompleted\": 1464196346996,\n  \"productSelectionCompleted\": 1464196864041,\n  \"phonePairingInitiated\": 1543915572133,\n  \"phonePairingCompleted\": 1543915572133,\n  \"userStatusCol\": null,\n  \"kycInitiated\": 1464196864041,\n  \"kycCompleted\": 1464197135809,\n  \"kycPersonalCompleted\": null,\n  \"kycPostIdentInitiated\": null,\n  \"kycPostIdentCompleted\": null,\n  \"kycWebIDInitiated\": null,\n  \"kycWebIDCompleted\": null,\n  \"kycDetails\": {\n    \"status\": \"COMPLETED\",\n    \"provider\": \"IDNOW\"\n  },\n  \"cardActivationCompleted\": 1479469342232,\n  \"cardIssued\": 1464197136297,\n  \"pinDefinitionCompleted\": 1472762906790,\n  \"accountClosed\": null,\n  \"coreDataUpdated\": 1464332400691,\n  \"unpairingProcessStatus\": null,\n  \"isDeceased\": null,\n  \"firstIncomingTransaction\": 1464362123423,\n  \"flexAccount\": false,\n  \"flexAccountConfirmed\": 0,\n  \"signupStep\": null,\n  \"unpairTokenCreation\": null,\n  \"pairingState\": \"PAIRED\"\n}"
  },
  {
    "path": "tests/api_responses/addresses.json",
    "content": "{\n  \"paging\": {\n    \"previous\": null,\n    \"next\": null,\n    \"totalResults\": 3\n  },\n  \"data\": [\n    {\n      \"id\": \"12345678-1234-abcd-abcd-1234567890ab\",\n      \"created\": 1464196274025,\n      \"updated\": 1497862816796,\n      \"addressLine1\": \"\",\n      \"addressLine2\": null,\n      \"streetName\": \"Einbahnstraße\",\n      \"houseNumberBlock\": \"1\",\n      \"zipCode\": \"12345\",\n      \"cityName\": \"Berlin\",\n      \"state\": null,\n      \"countryName\": \"DEU\",\n      \"type\": \"SHIPPING\",\n      \"userId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n      \"fromAllowedCountry\": true\n    },\n    {\n      \"id\": \"22345678-1234-abcd-abcd-1234567890ab\",\n      \"created\": 1464197135809,\n      \"updated\": 1497862816833,\n      \"addressLine1\": \"Markus Karl-Heinz Andreas Ressel\",\n      \"addressLine2\": null,\n      \"streetName\": \"EINBAHNSTRAßE\",\n      \"houseNumberBlock\": \"1\",\n      \"zipCode\": \"12345\",\n      \"cityName\": \"BERLIN\",\n      \"state\": null,\n      \"countryName\": \"DEU\",\n      \"type\": \"PASSPORT\",\n      \"userId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n      \"fromAllowedCountry\": true\n    },\n    {\n      \"id\": \"32345678-1234-abcd-abcd-1234567890ab\",\n      \"created\": 1488552934000,\n      \"updated\": 1497865225100,\n      \"addressLine1\": \"\",\n      \"addressLine2\": null,\n      \"streetName\": \"Einbahnstraße\",\n      \"houseNumberBlock\": \"1\",\n      \"zipCode\": \"12345\",\n      \"cityName\": \"Berlin\",\n      \"state\": null,\n      \"countryName\": \"DEU\",\n      \"type\": \"LEGAL\",\n      \"userId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n      \"fromAllowedCountry\": true\n    }\n  ]\n}"
  },
  {
    "path": "tests/api_responses/auth_token.json",
    "content": "{\n  \"access_token\": \"12345678-1234-1234-1234-123456789012\",\n  \"token_type\": \"bearer\",\n  \"refresh_token\": \"99999999-9999-9999-9999-999999999999\",\n  \"expires_in\": 1798,\n  \"scope\": \"trust\",\n  \"host_url\": \"https://api.tech26.de\"\n}"
  },
  {
    "path": "tests/api_responses/balance.json",
    "content": "{\n  \"id\": \"12345678-235b-1234-1234-1234567890ab\",\n  \"physicalBalance\": null,\n  \"availableBalance\": 100.00,\n  \"usableBalance\": 1000.00,\n  \"bankBalance\": 500.50,\n  \"iban\": \"DE12345678901234567890\",\n  \"bic\": \"NTSBDEB1XXX\",\n  \"bankName\": \"N26 Bank\",\n  \"seized\": false,\n  \"currency\": \"EUR\",\n  \"legalEntity\": \"EU\",\n  \"externalId\": {\n    \"iban\": \"DE12345678901234567890\"\n  }\n}"
  },
  {
    "path": "tests/api_responses/card_block_single.json",
    "content": "{\n  \"id\": \"12345678-1234-abcd-abcd-1234567890ab\",\n  \"created\": null,\n  \"updated\": null,\n  \"publicToken\": null,\n  \"maskedPan\": \"123456******1234\",\n  \"expirationDate\": 1638230400000,\n  \"cardType\": \"MASTERCARD\",\n  \"membership\": null,\n  \"exceetExpectedDeliveryDate\": null,\n  \"exceetActualDeliveryDate\": null,\n  \"exceetExpressCardDeliveryTrackingId\": null,\n  \"exceetExpressCardDelivery\": false,\n  \"exceetExpressCardDeliveryEmailSent\": false,\n  \"userId\": null,\n  \"accountId\": null,\n  \"pinDefined\": 1479469330508,\n  \"cardActivated\": 1479469342108\n}"
  },
  {
    "path": "tests/api_responses/card_unblock_single.json",
    "content": "{\n  \"id\": \"12345678-1234-abcd-abcd-1234567890ab\",\n  \"created\": null,\n  \"updated\": null,\n  \"publicToken\": null,\n  \"maskedPan\": \"123456******1234\",\n  \"expirationDate\": 1638230400000,\n  \"cardType\": \"MASTERCARD\",\n  \"membership\": null,\n  \"exceetExpectedDeliveryDate\": null,\n  \"exceetActualDeliveryDate\": null,\n  \"exceetExpressCardDeliveryTrackingId\": null,\n  \"exceetExpressCardDelivery\": false,\n  \"exceetExpressCardDeliveryEmailSent\": false,\n  \"userId\": null,\n  \"accountId\": null,\n  \"pinDefined\": 1479469330508,\n  \"cardActivated\": 1479469342108\n}"
  },
  {
    "path": "tests/api_responses/cards.json",
    "content": "[\n  {\n    \"id\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"publicToken\": null,\n    \"pan\": null,\n    \"maskedPan\": \"123456******1234\",\n    \"expirationDate\": 1638230400000,\n    \"cardType\": \"MASTERCARD\",\n    \"status\": \"M_ACTIVE\",\n    \"cardProduct\": null,\n    \"cardProductType\": \"STANDARD\",\n    \"pinDefined\": 1479469330508,\n    \"cardActivated\": 1479469342108,\n    \"usernameOnCard\": \"JOHN DOE\",\n    \"exceetExpressCardDelivery\": null,\n    \"membership\": null,\n    \"exceetActualDeliveryDate\": null,\n    \"exceetExpressCardDeliveryEmailSent\": null,\n    \"exceetCardStatus\": null,\n    \"exceetExpectedDeliveryDate\": null,\n    \"exceetExpressCardDeliveryTrackingId\": null,\n    \"cardSettingsId\": null,\n    \"applePayEligible\": true,\n    \"googlePayEligible\": true,\n    \"design\": \"WORLD\",\n    \"orderId\": null,\n    \"mptsCard\": true\n  },\n  {\n    \"id\": \"22345678-1234-abcd-abcd-1234567890ab\",\n    \"publicToken\": null,\n    \"pan\": null,\n    \"maskedPan\": \"765432******1234\",\n    \"expirationDate\": 1635638400000,\n    \"cardType\": \"MAESTRO\",\n    \"status\": \"M_ACTIVE\",\n    \"cardProduct\": null,\n    \"cardProductType\": \"MAESTRO\",\n    \"pinDefined\": 1480439684277,\n    \"cardActivated\": 1480439686272,\n    \"usernameOnCard\": \"JOHN DOE\",\n    \"exceetExpressCardDelivery\": null,\n    \"membership\": null,\n    \"exceetActualDeliveryDate\": null,\n    \"exceetExpressCardDeliveryEmailSent\": null,\n    \"exceetCardStatus\": null,\n    \"exceetExpectedDeliveryDate\": null,\n    \"exceetExpressCardDeliveryTrackingId\": null,\n    \"cardSettingsId\": null,\n    \"applePayEligible\": false,\n    \"googlePayEligible\": false,\n    \"design\": \"MAESTRO\",\n    \"orderId\": null,\n    \"mptsCard\": true\n  }\n]"
  },
  {
    "path": "tests/api_responses/contacts.json",
    "content": "[\n  {\n    \"userId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"id\": \"0fffffff-1234-abcd-abcd-1234567890ab\",\n    \"name\": \"ADAC Berlin-Brandenburg\",\n    \"subtitle\": \"DE84 1008 0000 0616 2162 00\",\n    \"account\": {\n      \"accountType\": \"sepa\",\n      \"iban\": \"DE84100800000616216200\",\n      \"bic\": \"DRESDEFF100\"\n    }\n  },\n  {\n    \"userId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"id\": \"1fffffff-1234-abcd-abcd-1234567890ab\",\n    \"name\": \"Cyberport GmbH\",\n    \"subtitle\": \"DE73 6808 0030 0723 3036 00\",\n    \"account\": {\n      \"accountType\": \"sepa\",\n      \"iban\": \"DE73680800300723303600\",\n      \"bic\": \"DRESDEFF680\"\n    }\n  },\n  {\n    \"userId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"id\": \"2fffffff-1234-abcd-abcd-1234567890ab\",\n    \"name\": \"DB Vertrieb GmbH\",\n    \"subtitle\": \"DE02 1001 0010 0152 5171 08\",\n    \"account\": {\n      \"accountType\": \"sepa\",\n      \"iban\": \"DE02100100100152517108\",\n      \"bic\": \"PBNKDEFFXXX\"\n    }\n  },\n  {\n    \"userId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"id\": \"3fffffff-1234-abcd-abcd-1234567890ab\",\n    \"name\": \"ELV Elektronik  www.elv.de\",\n    \"subtitle\": \"DE96 2859 0075 0012 7744 00\",\n    \"account\": {\n      \"accountType\": \"sepa\",\n      \"iban\": \"DE96285900750012774400\",\n      \"bic\": \"GENODEF1LER\"\n    }\n  },\n  {\n    \"userId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"id\": \"4fffffff-1234-abcd-abcd-1234567890ab\",\n    \"name\": \"Mindfactory AG\",\n    \"subtitle\": \"DE91 2824 0023 0335 6334 02\",\n    \"account\": {\n      \"accountType\": \"sepa\",\n      \"iban\": \"DE91282400230335633402\",\n      \"bic\": \"COBADEFFXXX\"\n    }\n  },\n  {\n    \"userId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"id\": \"5fffffff-1234-abcd-abcd-1234567890ab\",\n    \"name\": \"S. Seegel - Netbank\",\n    \"subtitle\": \"DE07 2009 0500 0008 0049 35\",\n    \"account\": {\n      \"accountType\": \"sepa\",\n      \"iban\": \"DE07200905000008004935\",\n      \"bic\": \"GENODEF1S15\"\n    }\n  }\n]"
  },
  {
    "path": "tests/api_responses/refresh_token.json",
    "content": "{\n  \"access_token\": \"12345678-1234-abcd-abcd-1234567890ab\",\n  \"token_type\": \"bearer\",\n  \"refresh_token\": \"12345678-1234-abcd-abcd-1234567890ab\",\n  \"expires_in\": 1798,\n  \"scope\": \"trust\",\n  \"host_url\": \"https://api.tech26.de\"\n}"
  },
  {
    "path": "tests/api_responses/spaces.json",
    "content": "{\n  \"totalBalance\": 5000.12,\n  \"visibleBalance\": 5000.12,\n  \"spaces\": [\n    {\n      \"id\": \"12345678-1234-abcd-abcd-1234567890ab\",\n      \"accountId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n      \"name\": \"Main Account\",\n      \"imageUrl\": \"https://cdn.number26.de/spaces/default-images/account_cards.jpg?version=1\",\n      \"backgroundImageUrl\": \"https://cdn.number26.de/spaces/background-images/account_cards_background.jpg?version=1\",\n      \"balance\": {\n        \"availableBalance\": 4850.12,\n        \"currency\": \"EUR\",\n        \"overdraftAmount\": 500.0\n      },\n      \"isPrimary\": true,\n      \"isHiddenFromBalance\": false,\n      \"isCardAttached\": true,\n      \"goal\": {\n        \"id\": \"12345678-1234-abcd-abcd-1234567890ab\",\n        \"amount\": 2000.0\n      },\n      \"isLocked\": false\n    },\n    {\n      \"id\": \"22345678-1234-abcd-abcd-1234567890ab\",\n      \"accountId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n      \"name\": \"Vacation\",\n      \"imageUrl\": \"https://cdn.number26.de/spaces/default-images/social_wine.jpg?version=1\",\n      \"backgroundImageUrl\": \"https://cdn.number26.de/spaces/background-images/social_wine_background.jpg?version=1\",\n      \"balance\": {\n        \"availableBalance\": 0.0,\n        \"currency\": \"EUR\"\n      },\n      \"isPrimary\": false,\n      \"isHiddenFromBalance\": false,\n      \"isCardAttached\": false,\n      \"goal\": {\n        \"id\": \"22345678-1234-abcd-abcd-1234567890ab\",\n        \"amount\": 150.0\n      },\n      \"isLocked\": false\n    },\n    {\n      \"id\": \"32345678-1234-abcd-abcd-1234567890ab\",\n      \"accountId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n      \"name\": \"Tech\",\n      \"imageUrl\": \"https://cdn.number26.de/spaces/default-images/invest_calculator.jpg?version=1\",\n      \"backgroundImageUrl\": \"https://cdn.number26.de/spaces/background-images/invest_calculator_background.jpg?version=1\",\n      \"balance\": {\n        \"availableBalance\": 150.0,\n        \"currency\": \"EUR\"\n      },\n      \"isPrimary\": false,\n      \"isHiddenFromBalance\": false,\n      \"isCardAttached\": false,\n      \"goal\": {\n        \"id\": \"32345678-1234-abcd-abcd-1234567890ab\",\n        \"amount\": 500.0\n      },\n      \"isLocked\": false\n    }\n  ],\n  \"userFeatures\": {\n    \"availableSpaces\": 0,\n    \"canUpgrade\": true\n  }\n}"
  },
  {
    "path": "tests/api_responses/standing_orders.json",
    "content": "{\n  \"paging\": {\n    \"previous\": null,\n    \"next\": null,\n    \"totalResults\": 6\n  },\n  \"data\": [\n    {\n      \"id\": \"12345678-1234-abcd-abcd-1234567890ab\",\n      \"created\": 1530443906352,\n      \"updated\": 1554078589461,\n      \"amount\": 123.45,\n      \"currencyCode\": {\n        \"currencyCode\": \"EUR\"\n      },\n      \"partnerIban\": \"DE12345678901234567890\",\n      \"partnerBic\": \"ABCDEFGH123\",\n      \"partnerAccountIsSepa\": true,\n      \"partnerAccountBan\": \"1234567890\",\n      \"partnerBcn\": \"12345678\",\n      \"userCertified\": 1530443939838,\n      \"userCanceled\": null,\n      \"n26Iban\": \"DE12345678901234567890\",\n      \"bankTransferTypeText\": null,\n      \"firstExecutingTS\": 1530403200000,\n      \"nextExecutingTS\": 1556668800000,\n      \"stopTS\": null,\n      \"referenceText\": \"This is a text\",\n      \"partnerBankName\": \"ING-DiBa Frankfurt am Main\",\n      \"partnerName\": \"Someone\",\n      \"initialDayOfMonth\": 1,\n      \"linkId\": null,\n      \"internal\": false,\n      \"referenceToOriginalOperation\": null,\n      \"executionFrequency\": \"MONTHLY\",\n      \"executionCounter\": 10,\n      \"userId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n      \"accountId\": \"12345678-1234-abcd-abcd-1234567890ab\"\n    },\n    {\n      \"id\": \"22345678-1234-abcd-abcd-1234567890ab\",\n      \"created\": 1482798657697,\n      \"updated\": 1484006544113,\n      \"amount\": 150.00,\n      \"currencyCode\": {\n        \"currencyCode\": \"EUR\"\n      },\n      \"partnerIban\": \"DE12345678901234567890\",\n      \"partnerBic\": \"ABCDEFGH123\",\n      \"partnerAccountIsSepa\": true,\n      \"partnerAccountBan\": \"123456789\",\n      \"partnerBcn\": \"12345678\",\n      \"userCertified\": 1482798661529,\n      \"userCanceled\": null,\n      \"n26Iban\": \"DE12345678901234567890\",\n      \"bankTransferTypeText\": null,\n      \"firstExecutingTS\": 1484006400000,\n      \"nextExecutingTS\": null,\n      \"stopTS\": 1484092800000,\n      \"referenceText\": \"This is a text\",\n      \"partnerBankName\": \"Postbank Stuttgart\",\n      \"partnerName\": \"Mr. Anderson\",\n      \"initialDayOfMonth\": 10,\n      \"linkId\": null,\n      \"internal\": false,\n      \"referenceToOriginalOperation\": null,\n      \"executionFrequency\": \"WEEKLY\",\n      \"executionCounter\": 1,\n      \"userId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n      \"accountId\": \"12345678-1234-abcd-abcd-1234567890ab\"\n    },\n    {\n      \"id\": \"32345678-1234-abcd-abcd-1234567890ab\",\n      \"created\": 1540242505911,\n      \"updated\": 1540242600456,\n      \"amount\": 12.00,\n      \"currencyCode\": {\n        \"currencyCode\": \"EUR\"\n      },\n      \"partnerIban\": \"DE12345678901234567890\",\n      \"partnerBic\": \"ABCDEFGH123\",\n      \"partnerAccountIsSepa\": true,\n      \"partnerAccountBan\": \"1234567890\",\n      \"partnerBcn\": \"12345678\",\n      \"userCertified\": 1540242519277,\n      \"userCanceled\": null,\n      \"n26Iban\": \"DE12345678901234567890\",\n      \"bankTransferTypeText\": null,\n      \"firstExecutingTS\": null,\n      \"nextExecutingTS\": 1569888000000,\n      \"stopTS\": null,\n      \"referenceText\": \"This is a text\",\n      \"partnerBankName\": \"Bank für Sozialwirtschaft\",\n      \"partnerName\": \"mailbox.org\",\n      \"initialDayOfMonth\": 1,\n      \"linkId\": null,\n      \"internal\": false,\n      \"referenceToOriginalOperation\": null,\n      \"executionFrequency\": \"YEARLY\",\n      \"executionCounter\": 0,\n      \"userId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n      \"accountId\": \"12345678-1234-abcd-abcd-1234567890ab\"\n    },\n    {\n      \"id\": \"42345678-1234-abcd-abcd-1234567890ab\",\n      \"created\": 1542144475361,\n      \"updated\": 1554082094139,\n      \"amount\": 20.00,\n      \"currencyCode\": {\n        \"currencyCode\": \"EUR\"\n      },\n      \"partnerIban\": \"DE12345678901234567890\",\n      \"partnerBic\": \"ABCDEFGH123\",\n      \"partnerAccountIsSepa\": true,\n      \"partnerAccountBan\": \"123456789\",\n      \"partnerBcn\": \"12345678\",\n      \"userCertified\": 1542144500291,\n      \"userCanceled\": null,\n      \"n26Iban\": \"DE12345678901234567890\",\n      \"bankTransferTypeText\": null,\n      \"firstExecutingTS\": 1543622400000,\n      \"nextExecutingTS\": 1556668800000,\n      \"stopTS\": null,\n      \"referenceText\": \"This is a text\",\n      \"partnerBankName\": \"ING-DiBa Frankfurt am Main\",\n      \"partnerName\": \"Someone \",\n      \"initialDayOfMonth\": 1,\n      \"linkId\": null,\n      \"internal\": false,\n      \"referenceToOriginalOperation\": null,\n      \"executionFrequency\": \"MONTHLY\",\n      \"executionCounter\": 5,\n      \"userId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n      \"accountId\": \"12345678-1234-abcd-abcd-1234567890ab\"\n    },\n    {\n      \"id\": \"52345678-1234-abcd-abcd-1234567890ab\",\n      \"created\": 1530444127426,\n      \"updated\": 1554082943538,\n      \"amount\": 150.00,\n      \"currencyCode\": {\n        \"currencyCode\": \"EUR\"\n      },\n      \"partnerIban\": \"DE12345678901234567890\",\n      \"partnerBic\": \"ABCDEFGH123\",\n      \"partnerAccountIsSepa\": true,\n      \"partnerAccountBan\": \"123456789\",\n      \"partnerBcn\": \"12345678\",\n      \"userCertified\": 1530444143654,\n      \"userCanceled\": null,\n      \"n26Iban\": \"DE12345678901234567890\",\n      \"bankTransferTypeText\": null,\n      \"firstExecutingTS\": 1530403200000,\n      \"nextExecutingTS\": 1556668800000,\n      \"stopTS\": null,\n      \"referenceText\": \"This is a text\",\n      \"partnerBankName\": \"ING-DiBa Frankfurt am Main\",\n      \"partnerName\": \"Someone else\",\n      \"initialDayOfMonth\": 1,\n      \"linkId\": null,\n      \"internal\": false,\n      \"referenceToOriginalOperation\": null,\n      \"executionFrequency\": \"MONTHLY\",\n      \"executionCounter\": 10,\n      \"userId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n      \"accountId\": \"12345678-1234-abcd-abcd-1234567890ab\"\n    },\n    {\n      \"id\": \"62345678-1234-abcd-abcd-1234567890ab\",\n      \"created\": 1540242322783,\n      \"updated\": 1540860232451,\n      \"amount\": 5.00,\n      \"currencyCode\": {\n        \"currencyCode\": \"EUR\"\n      },\n      \"partnerIban\": \"DE12345678901234567890\",\n      \"partnerBic\": \"ABCDEFGH123\",\n      \"partnerAccountIsSepa\": true,\n      \"partnerAccountBan\": \"123456789\",\n      \"partnerBcn\": \"12345678\",\n      \"userCertified\": 1540242338451,\n      \"userCanceled\": null,\n      \"n26Iban\": \"DE12345678901234567890\",\n      \"bankTransferTypeText\": null,\n      \"firstExecutingTS\": 1540857600000,\n      \"nextExecutingTS\": 1572393600000,\n      \"stopTS\": null,\n      \"referenceText\": \"KdNr 123456\",\n      \"partnerBankName\": \"Commerzbank Freiburg i Br\",\n      \"partnerName\": \"INWX GmbH & Co. KG\",\n      \"initialDayOfMonth\": 30,\n      \"linkId\": null,\n      \"internal\": false,\n      \"referenceToOriginalOperation\": null,\n      \"executionFrequency\": \"YEARLY\",\n      \"executionCounter\": 1,\n      \"userId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n      \"accountId\": \"12345678-1234-abcd-abcd-1234567890ab\"\n    }\n  ]\n}"
  },
  {
    "path": "tests/api_responses/statements.json",
    "content": "[\n  {\n    \"id\": \"statement-2019-04\",\n    \"url\": \"/api/statements/statement-2019-04\",\n    \"visibleTS\": 1554076800000,\n    \"month\": 4,\n    \"year\": 2019\n  },\n  {\n    \"id\": \"statement-2019-03\",\n    \"url\": \"/api/statements/statement-2019-03\",\n    \"visibleTS\": 1551398400000,\n    \"month\": 3,\n    \"year\": 2019\n  },\n  {\n    \"id\": \"statement-2019-02\",\n    \"url\": \"/api/statements/statement-2019-02\",\n    \"visibleTS\": 1548979200000,\n    \"month\": 2,\n    \"year\": 2019\n  },\n  {\n    \"id\": \"statement-2019-01\",\n    \"url\": \"/api/statements/statement-2019-01\",\n    \"visibleTS\": 1546300800000,\n    \"month\": 1,\n    \"year\": 2019\n  },\n  {\n    \"id\": \"statement-2018-12\",\n    \"url\": \"/api/statements/statement-2018-12\",\n    \"visibleTS\": 1543622400000,\n    \"month\": 12,\n    \"year\": 2018\n  },\n  {\n    \"id\": \"statement-2018-11\",\n    \"url\": \"/api/statements/statement-2018-11\",\n    \"visibleTS\": 1541030400000,\n    \"month\": 11,\n    \"year\": 2018\n  },\n  {\n    \"id\": \"statement-2018-10\",\n    \"url\": \"/api/statements/statement-2018-10\",\n    \"visibleTS\": 1538352000000,\n    \"month\": 10,\n    \"year\": 2018\n  },\n  {\n    \"id\": \"statement-2018-09\",\n    \"url\": \"/api/statements/statement-2018-09\",\n    \"visibleTS\": 1535760000000,\n    \"month\": 9,\n    \"year\": 2018\n  },\n  {\n    \"id\": \"statement-2018-08\",\n    \"url\": \"/api/statements/statement-2018-08\",\n    \"visibleTS\": 1533081600000,\n    \"month\": 8,\n    \"year\": 2018\n  },\n  {\n    \"id\": \"statement-2018-07\",\n    \"url\": \"/api/statements/statement-2018-07\",\n    \"visibleTS\": 1530403200000,\n    \"month\": 7,\n    \"year\": 2018\n  },\n  {\n    \"id\": \"statement-2018-06\",\n    \"url\": \"/api/statements/statement-2018-06\",\n    \"visibleTS\": 1527811200000,\n    \"month\": 6,\n    \"year\": 2018\n  },\n  {\n    \"id\": \"statement-2018-05\",\n    \"url\": \"/api/statements/statement-2018-05\",\n    \"visibleTS\": 1525132800000,\n    \"month\": 5,\n    \"year\": 2018\n  },\n  {\n    \"id\": \"statement-2018-04\",\n    \"url\": \"/api/statements/statement-2018-04\",\n    \"visibleTS\": 1522540800000,\n    \"month\": 4,\n    \"year\": 2018\n  },\n  {\n    \"id\": \"statement-2018-03\",\n    \"url\": \"/api/statements/statement-2018-03\",\n    \"visibleTS\": 1519862400000,\n    \"month\": 3,\n    \"year\": 2018\n  },\n  {\n    \"id\": \"statement-2018-02\",\n    \"url\": \"/api/statements/statement-2018-02\",\n    \"visibleTS\": 1517443200000,\n    \"month\": 2,\n    \"year\": 2018\n  },\n  {\n    \"id\": \"statement-2018-01\",\n    \"url\": \"/api/statements/statement-2018-01\",\n    \"visibleTS\": 1514764800000,\n    \"month\": 1,\n    \"year\": 2018\n  },\n  {\n    \"id\": \"statement-2017-12\",\n    \"url\": \"/api/statements/statement-2017-12\",\n    \"visibleTS\": 1512086400000,\n    \"month\": 12,\n    \"year\": 2017\n  },\n  {\n    \"id\": \"statement-2017-11\",\n    \"url\": \"/api/statements/statement-2017-11\",\n    \"visibleTS\": 1509494400000,\n    \"month\": 11,\n    \"year\": 2017\n  },\n  {\n    \"id\": \"statement-2017-10\",\n    \"url\": \"/api/statements/statement-2017-10\",\n    \"visibleTS\": 1506816000000,\n    \"month\": 10,\n    \"year\": 2017\n  },\n  {\n    \"id\": \"statement-2017-09\",\n    \"url\": \"/api/statements/statement-2017-09\",\n    \"visibleTS\": 1504224000000,\n    \"month\": 9,\n    \"year\": 2017\n  },\n  {\n    \"id\": \"statement-2017-08\",\n    \"url\": \"/api/statements/statement-2017-08\",\n    \"visibleTS\": 1501545600000,\n    \"month\": 8,\n    \"year\": 2017\n  },\n  {\n    \"id\": \"statement-2017-07\",\n    \"url\": \"/api/statements/statement-2017-07\",\n    \"visibleTS\": 1498867200000,\n    \"month\": 7,\n    \"year\": 2017\n  },\n  {\n    \"id\": \"statement-2017-06\",\n    \"url\": \"/api/statements/statement-2017-06\",\n    \"visibleTS\": 1496275200000,\n    \"month\": 6,\n    \"year\": 2017\n  },\n  {\n    \"id\": \"statement-2017-05\",\n    \"url\": \"/api/statements/statement-2017-05\",\n    \"visibleTS\": 1493596800000,\n    \"month\": 5,\n    \"year\": 2017\n  },\n  {\n    \"id\": \"statement-2017-04\",\n    \"url\": \"/api/statements/statement-2017-04\",\n    \"visibleTS\": 1491004800000,\n    \"month\": 4,\n    \"year\": 2017\n  },\n  {\n    \"id\": \"statement-2017-03\",\n    \"url\": \"/api/statements/statement-2017-03\",\n    \"visibleTS\": 1488326400000,\n    \"month\": 3,\n    \"year\": 2017\n  },\n  {\n    \"id\": \"statement-2017-02\",\n    \"url\": \"/api/statements/statement-2017-02\",\n    \"visibleTS\": 1485907200000,\n    \"month\": 2,\n    \"year\": 2017\n  },\n  {\n    \"id\": \"statement-2017-01\",\n    \"url\": \"/api/statements/statement-2017-01\",\n    \"visibleTS\": 1483228800000,\n    \"month\": 1,\n    \"year\": 2017\n  },\n  {\n    \"id\": \"statement-2016-12\",\n    \"url\": \"/api/statements/statement-2016-12\",\n    \"visibleTS\": 1480550400000,\n    \"month\": 12,\n    \"year\": 2016\n  },\n  {\n    \"id\": \"statement-2016-11\",\n    \"url\": \"/api/statements/statement-2016-11\",\n    \"visibleTS\": 1477958400000,\n    \"month\": 11,\n    \"year\": 2016\n  },\n  {\n    \"id\": \"Wirecard-2016-11\",\n    \"url\": \"/api/statements/Wirecard-2016-11\",\n    \"visibleTS\": 1477958400000,\n    \"month\": 11,\n    \"year\": 2016\n  }\n]"
  },
  {
    "path": "tests/api_responses/statistics.json",
    "content": "{\n  \"from\": 0,\n  \"to\": 1554236823000,\n  \"total\": 649.0200000000048,\n  \"totalIncome\": 32929.41999999999,\n  \"totalExpense\": 32280.399999999994,\n  \"items\": [\n    {\n      \"id\": \"micro-v2-income\",\n      \"income\": 10600.950000000003,\n      \"expense\": 0.0,\n      \"total\": 10600.950000000003\n    },\n    {\n      \"id\": \"micro-v2-salary\",\n      \"income\": 20148.399999999998,\n      \"expense\": 0.0,\n      \"total\": 20148.399999999998\n    },\n    {\n      \"id\": \"micro-v2-miscellaneous\",\n      \"income\": 1854.2499999999998,\n      \"expense\": 11335.769999999995,\n      \"total\": -9481.519999999995\n    },\n    {\n      \"id\": \"micro-v2-bars-restaurants\",\n      \"income\": 0.0,\n      \"expense\": 899.0300000000001,\n      \"total\": -899.0300000000001\n    },\n    {\n      \"id\": \"micro-v2-media-electronics\",\n      \"income\": 89.30000000000001,\n      \"expense\": 3514.9799999999996,\n      \"total\": -3425.6799999999994\n    },\n    {\n      \"id\": \"micro-v2-household-utilities\",\n      \"income\": 0.0,\n      \"expense\": 5835.219999999997,\n      \"total\": -5835.219999999997\n    },\n    {\n      \"id\": \"micro-v2-transport-car\",\n      \"income\": 0.5,\n      \"expense\": 1884.0199999999998,\n      \"total\": -1883.5199999999998\n    },\n    {\n      \"id\": \"micro-v2-atm\",\n      \"income\": 0.0,\n      \"expense\": 2564.95,\n      \"total\": -2564.95\n    },\n    {\n      \"id\": \"micro-v2-tax-fines\",\n      \"income\": 0.0,\n      \"expense\": 276.90000000000003,\n      \"total\": -276.90000000000003\n    },\n    {\n      \"id\": \"micro-v2-business\",\n      \"income\": 1.0,\n      \"expense\": 581.5,\n      \"total\": -580.5\n    },\n    {\n      \"id\": \"micro-v2-food-groceries\",\n      \"income\": 0.0,\n      \"expense\": 1611.9500000000003,\n      \"total\": -1611.9500000000003\n    },\n    {\n      \"id\": \"micro-v2-insurances-finances\",\n      \"income\": 108.94,\n      \"expense\": 425.96,\n      \"total\": -317.02\n    },\n    {\n      \"id\": \"micro-v2-shopping\",\n      \"income\": 68.34,\n      \"expense\": 1687.0600000000002,\n      \"total\": -1618.7200000000003\n    },\n    {\n      \"id\": \"micro-v2-healthcare-drugstores\",\n      \"income\": 0.0,\n      \"expense\": 80.99000000000001,\n      \"total\": -80.99000000000001\n    },\n    {\n      \"id\": \"micro-v2-subscriptions-donations\",\n      \"income\": 0.0,\n      \"expense\": 155.51,\n      \"total\": -155.51\n    },\n    {\n      \"id\": \"micro-v2-leisure-entertainment\",\n      \"income\": 57.74,\n      \"expense\": 167.09000000000003,\n      \"total\": -109.35000000000002\n    },\n    {\n      \"id\": \"micro-v2-education\",\n      \"income\": 0.0,\n      \"expense\": 3.42,\n      \"total\": -3.42\n    },\n    {\n      \"id\": \"micro-v2-family-friends\",\n      \"income\": 0.0,\n      \"expense\": 413.27,\n      \"total\": -413.27\n    },\n    {\n      \"id\": \"micro-v2-travel-holidays\",\n      \"income\": 0.0,\n      \"expense\": 762.78,\n      \"total\": -762.78\n    },\n    {\n      \"id\": \"micro-v2-cash26\",\n      \"income\": 0.0,\n      \"expense\": 80.0,\n      \"total\": -80.0\n    }\n  ],\n  \"incomeItems\": [\n    {\n      \"id\": \"micro-v2-income\",\n      \"income\": 10600.950000000003,\n      \"expense\": 0.0,\n      \"total\": 10600.950000000003\n    },\n    {\n      \"id\": \"micro-v2-salary\",\n      \"income\": 20148.399999999998,\n      \"expense\": 0.0,\n      \"total\": 20148.399999999998\n    },\n    {\n      \"id\": \"micro-v2-miscellaneous\",\n      \"income\": 1854.2499999999998,\n      \"expense\": 11335.769999999995,\n      \"total\": -9481.519999999995\n    },\n    {\n      \"id\": \"micro-v2-media-electronics\",\n      \"income\": 89.30000000000001,\n      \"expense\": 3514.9799999999996,\n      \"total\": -3425.6799999999994\n    },\n    {\n      \"id\": \"micro-v2-transport-car\",\n      \"income\": 0.5,\n      \"expense\": 1884.0199999999998,\n      \"total\": -1883.5199999999998\n    },\n    {\n      \"id\": \"micro-v2-business\",\n      \"income\": 1.0,\n      \"expense\": 581.5,\n      \"total\": -580.5\n    },\n    {\n      \"id\": \"micro-v2-insurances-finances\",\n      \"income\": 108.94,\n      \"expense\": 425.96,\n      \"total\": -317.02\n    },\n    {\n      \"id\": \"micro-v2-shopping\",\n      \"income\": 68.34,\n      \"expense\": 1687.0600000000002,\n      \"total\": -1618.7200000000003\n    },\n    {\n      \"id\": \"micro-v2-leisure-entertainment\",\n      \"income\": 57.74,\n      \"expense\": 167.09000000000003,\n      \"total\": -109.35000000000002\n    }\n  ],\n  \"expenseItems\": [\n    {\n      \"id\": \"micro-v2-miscellaneous\",\n      \"income\": 1854.2499999999998,\n      \"expense\": 11335.769999999995,\n      \"total\": -9481.519999999995\n    },\n    {\n      \"id\": \"micro-v2-bars-restaurants\",\n      \"income\": 0.0,\n      \"expense\": 899.0300000000001,\n      \"total\": -899.0300000000001\n    },\n    {\n      \"id\": \"micro-v2-media-electronics\",\n      \"income\": 89.30000000000001,\n      \"expense\": 3514.9799999999996,\n      \"total\": -3425.6799999999994\n    },\n    {\n      \"id\": \"micro-v2-household-utilities\",\n      \"income\": 0.0,\n      \"expense\": 5835.219999999997,\n      \"total\": -5835.219999999997\n    },\n    {\n      \"id\": \"micro-v2-transport-car\",\n      \"income\": 0.5,\n      \"expense\": 1884.0199999999998,\n      \"total\": -1883.5199999999998\n    },\n    {\n      \"id\": \"micro-v2-atm\",\n      \"income\": 0.0,\n      \"expense\": 2564.95,\n      \"total\": -2564.95\n    },\n    {\n      \"id\": \"micro-v2-tax-fines\",\n      \"income\": 0.0,\n      \"expense\": 276.90000000000003,\n      \"total\": -276.90000000000003\n    },\n    {\n      \"id\": \"micro-v2-business\",\n      \"income\": 1.0,\n      \"expense\": 581.5,\n      \"total\": -580.5\n    },\n    {\n      \"id\": \"micro-v2-food-groceries\",\n      \"income\": 0.0,\n      \"expense\": 1611.9500000000003,\n      \"total\": -1611.9500000000003\n    },\n    {\n      \"id\": \"micro-v2-insurances-finances\",\n      \"income\": 108.94,\n      \"expense\": 425.96,\n      \"total\": -317.02\n    },\n    {\n      \"id\": \"micro-v2-shopping\",\n      \"income\": 68.34,\n      \"expense\": 1687.0600000000002,\n      \"total\": -1618.7200000000003\n    },\n    {\n      \"id\": \"micro-v2-healthcare-drugstores\",\n      \"income\": 0.0,\n      \"expense\": 80.99000000000001,\n      \"total\": -80.99000000000001\n    },\n    {\n      \"id\": \"micro-v2-subscriptions-donations\",\n      \"income\": 0.0,\n      \"expense\": 155.51,\n      \"total\": -155.51\n    },\n    {\n      \"id\": \"micro-v2-leisure-entertainment\",\n      \"income\": 57.74,\n      \"expense\": 167.09000000000003,\n      \"total\": -109.35000000000002\n    },\n    {\n      \"id\": \"micro-v2-education\",\n      \"income\": 0.0,\n      \"expense\": 3.42,\n      \"total\": -3.42\n    },\n    {\n      \"id\": \"micro-v2-family-friends\",\n      \"income\": 0.0,\n      \"expense\": 413.27,\n      \"total\": -413.27\n    },\n    {\n      \"id\": \"micro-v2-travel-holidays\",\n      \"income\": 0.0,\n      \"expense\": 762.78,\n      \"total\": -762.78\n    },\n    {\n      \"id\": \"micro-v2-cash26\",\n      \"income\": 0.0,\n      \"expense\": 80.0,\n      \"total\": -80.0\n    }\n  ]\n}"
  },
  {
    "path": "tests/api_responses/transactions.json",
    "content": "[\n  {\n    \"id\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"userId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"type\": \"CT\",\n    \"amount\": 40.0,\n    \"currencyCode\": \"EUR\",\n    \"visibleTS\": 1554188463459,\n    \"mcc\": 0,\n    \"mccGroup\": 12,\n    \"recurring\": false,\n    \"partnerBic\": \"ABCDEFGH123\",\n    \"partnerAccountIsSepa\": true,\n    \"partnerName\": \"Partner Name\",\n    \"accountId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"partnerIban\": \"DE12345678901234567890\",\n    \"category\": \"micro-v2-income\",\n    \"referenceText\": \"Message of this transaction\",\n    \"userAccepted\": 1554188463459,\n    \"userCertified\": 1554188463459,\n    \"pending\": false,\n    \"transactionNature\": \"NORMAL\",\n    \"transactionTerminal\": \"ATM\",\n    \"createdTS\": 1554188463490,\n    \"smartLinkId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"linkId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"confirmed\": 1554188463459\n  },\n  {\n    \"id\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"userId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"type\": \"CT\",\n    \"amount\": 10.0,\n    \"currencyCode\": \"EUR\",\n    \"visibleTS\": 1554188428868,\n    \"mcc\": 0,\n    \"mccGroup\": 12,\n    \"recurring\": false,\n    \"partnerBic\": \"ABCDEFGH123\",\n    \"partnerAccountIsSepa\": true,\n    \"partnerName\": \"Partner Name\",\n    \"accountId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"partnerIban\": \"DE12345678901234567890\",\n    \"category\": \"micro-v2-salary\",\n    \"referenceText\": \"Message of this transaction\",\n    \"userAccepted\": 1554188428868,\n    \"userCertified\": 1554188428868,\n    \"pending\": false,\n    \"transactionNature\": \"NORMAL\",\n    \"transactionTerminal\": \"ATM\",\n    \"createdTS\": 1554188428899,\n    \"purposeCode\": \"RINP\",\n    \"smartLinkId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"linkId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"confirmed\": 1554188428868\n  },\n  {\n    \"id\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"userId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"type\": \"CT\",\n    \"amount\": 3.0,\n    \"currencyCode\": \"EUR\",\n    \"visibleTS\": 1554188428822,\n    \"mcc\": 0,\n    \"mccGroup\": 12,\n    \"recurring\": false,\n    \"partnerBic\": \"ABCDEFGH123\",\n    \"partnerAccountIsSepa\": true,\n    \"partnerName\": \"Partner Name\",\n    \"accountId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"partnerIban\": \"DE12345678901234567890\",\n    \"category\": \"micro-v2-salary\",\n    \"referenceText\": \"Message of this transaction\",\n    \"userAccepted\": 1554188428822,\n    \"userCertified\": 1554188428822,\n    \"pending\": false,\n    \"transactionNature\": \"NORMAL\",\n    \"transactionTerminal\": \"ATM\",\n    \"createdTS\": 1554188428859,\n    \"purposeCode\": \"RINP\",\n    \"smartLinkId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"linkId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"confirmed\": 1554188428822\n  },\n  {\n    \"id\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"userId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"type\": \"DD\",\n    \"amount\": -52.5,\n    \"currencyCode\": \"EUR\",\n    \"visibleTS\": 1554139826875,\n    \"mcc\": 0,\n    \"mccGroup\": 12,\n    \"recurring\": false,\n    \"partnerBic\": \"ABCDEFGH123\",\n    \"partnerAccountIsSepa\": false,\n    \"partnerName\": \"Partner Name\",\n    \"accountId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"partnerIban\": \"DE12345678901234567890\",\n    \"category\": \"micro-v2-miscellaneous\",\n    \"referenceText\": \"Message of this transaction\",\n    \"userCertified\": 1554139826875,\n    \"pending\": false,\n    \"transactionNature\": \"NORMAL\",\n    \"transactionTerminal\": \"ATM\",\n    \"createdTS\": 1554139826876,\n    \"mandateId\": \"6733274121701\",\n    \"creditorIdentifier\": \"DE12345678901234567\",\n    \"creditorName\": \"Creditor Name\",\n    \"smartLinkId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"linkId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"confirmed\": 1554076800000\n  },\n  {\n    \"id\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"userId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"type\": \"CT\",\n    \"amount\": 50.0,\n    \"currencyCode\": \"EUR\",\n    \"visibleTS\": 1554099697139,\n    \"mcc\": 0,\n    \"mccGroup\": 12,\n    \"recurring\": false,\n    \"partnerBic\": \"ABCDEFGH123\",\n    \"partnerAccountIsSepa\": true,\n    \"partnerName\": \"Partner Name\",\n    \"accountId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"partnerIban\": \"DE12345678901234567890\",\n    \"category\": \"micro-v2-income\",\n    \"referenceText\": \"Message of this transaction\",\n    \"userAccepted\": 1554099697139,\n    \"userCertified\": 1554099697139,\n    \"pending\": false,\n    \"transactionNature\": \"NORMAL\",\n    \"transactionTerminal\": \"ATM\",\n    \"createdTS\": 1554099697174,\n    \"smartLinkId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"linkId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"confirmed\": 1554099697139\n  },\n  {\n    \"id\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"userId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"type\": \"CT\",\n    \"amount\": 300.0,\n    \"currencyCode\": \"EUR\",\n    \"visibleTS\": 1554099689992,\n    \"mcc\": 0,\n    \"mccGroup\": 12,\n    \"recurring\": false,\n    \"partnerBic\": \"ABCDEFGH123\",\n    \"partnerAccountIsSepa\": true,\n    \"partnerName\": \"Partner Name\",\n    \"accountId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"partnerIban\": \"DE12345678901234567890\",\n    \"category\": \"micro-v2-income\",\n    \"referenceText\": \"Message of this transaction\",\n    \"userAccepted\": 1554099689992,\n    \"userCertified\": 1554099689992,\n    \"pending\": false,\n    \"transactionNature\": \"NORMAL\",\n    \"transactionTerminal\": \"ATM\",\n    \"createdTS\": 1554099690023,\n    \"smartLinkId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"linkId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"confirmed\": 1554099689992\n  },\n  {\n    \"id\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"userId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"type\": \"DT\",\n    \"amount\": -43.0,\n    \"currencyCode\": \"EUR\",\n    \"visibleTS\": 1554090067244,\n    \"mcc\": 0,\n    \"mccGroup\": 12,\n    \"recurring\": false,\n    \"partnerBic\": \"ABCDEFGH123\",\n    \"partnerBcn\": \"50010517\",\n    \"partnerAccountIsSepa\": true,\n    \"partnerBankName\": \"ING-DiBa\",\n    \"partnerName\": \"Partner Name\",\n    \"accountId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"partnerIban\": \"DE12345678901234567890\",\n    \"partnerAccountBan\": \"5426551349\",\n    \"category\": \"micro-v2-bars-restaurants\",\n    \"referenceText\": \"Message of this transaction\",\n    \"userAccepted\": 1554090067244,\n    \"userCertified\": 1554090067244,\n    \"pending\": false,\n    \"transactionNature\": \"NORMAL\",\n    \"smartContactId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"transactionTerminal\": \"ATM\",\n    \"createdTS\": 1554090067257,\n    \"smartLinkId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"linkId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"confirmed\": 1554090067244\n  },\n  {\n    \"id\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"userId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"type\": \"DT\",\n    \"amount\": -50.0,\n    \"currencyCode\": \"EUR\",\n    \"visibleTS\": 1554085849711,\n    \"mcc\": 0,\n    \"mccGroup\": 12,\n    \"recurring\": false,\n    \"partnerBic\": \"ABCDEFGH123\",\n    \"partnerBcn\": \"50010517\",\n    \"partnerAccountIsSepa\": true,\n    \"partnerBankName\": \"ING-DiBa\",\n    \"partnerName\": \"Partner Name\",\n    \"accountId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"partnerIban\": \"DE12345678901234567890\",\n    \"partnerAccountBan\": \"5426551349\",\n    \"category\": \"micro-v2-miscellaneous\",\n    \"referenceText\": \"Message of this transaction\",\n    \"userAccepted\": 1554085849711,\n    \"userCertified\": 1554085849711,\n    \"pending\": false,\n    \"transactionNature\": \"NORMAL\",\n    \"smartContactId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"transactionTerminal\": \"ATM\",\n    \"createdTS\": 1554085849727,\n    \"smartLinkId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"linkId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"confirmed\": 1554085849711\n  },\n  {\n    \"id\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"userId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"type\": \"DT\",\n    \"amount\": -150.0,\n    \"currencyCode\": \"EUR\",\n    \"visibleTS\": 1554082943260,\n    \"mcc\": 0,\n    \"mccGroup\": 12,\n    \"recurring\": false,\n    \"partnerBic\": \"ABCDEFGH123\",\n    \"partnerBcn\": \"50010517\",\n    \"partnerAccountIsSepa\": true,\n    \"partnerBankName\": \"ING-DiBa\",\n    \"partnerName\": \"Partner Name\",\n    \"accountId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"partnerIban\": \"DE12345678901234567890\",\n    \"partnerAccountBan\": \"5426551349\",\n    \"category\": \"micro-v2-miscellaneous\",\n    \"referenceText\": \"Message of this transaction\",\n    \"userAccepted\": 1554082943260,\n    \"userCertified\": 1554082943260,\n    \"pending\": false,\n    \"transactionNature\": \"NORMAL\",\n    \"smartContactId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"transactionTerminal\": \"ATM\",\n    \"createdTS\": 1554082943274,\n    \"smartLinkId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"linkId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"confirmed\": 1554082943260\n  },\n  {\n    \"id\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"userId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"type\": \"DT\",\n    \"amount\": -20.0,\n    \"currencyCode\": \"EUR\",\n    \"visibleTS\": 1554082093870,\n    \"mcc\": 0,\n    \"mccGroup\": 12,\n    \"recurring\": false,\n    \"partnerBic\": \"ABCDEFGH123\",\n    \"partnerBcn\": \"50010517\",\n    \"partnerAccountIsSepa\": true,\n    \"partnerBankName\": \"ING-DiBa\",\n    \"partnerName\": \"Partner Name\",\n    \"accountId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"partnerIban\": \"DE12345678901234567890\",\n    \"partnerAccountBan\": \"5426551349\",\n    \"category\": \"micro-v2-media-electronics\",\n    \"referenceText\": \"Message of this transaction\",\n    \"userAccepted\": 1554082093870,\n    \"userCertified\": 1554082093870,\n    \"pending\": false,\n    \"transactionNature\": \"NORMAL\",\n    \"smartContactId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"transactionTerminal\": \"ATM\",\n    \"createdTS\": 1554082093884,\n    \"smartLinkId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"linkId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"confirmed\": 1554082093870\n  },\n  {\n    \"id\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"userId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"type\": \"DT\",\n    \"amount\": -290.53,\n    \"currencyCode\": \"EUR\",\n    \"visibleTS\": 1554078589178,\n    \"mcc\": 0,\n    \"mccGroup\": 12,\n    \"recurring\": false,\n    \"partnerBic\": \"ABCDEFGH123\",\n    \"partnerBcn\": \"50010517\",\n    \"partnerAccountIsSepa\": true,\n    \"partnerBankName\": \"ING-DiBa\",\n    \"partnerName\": \"Partner Name\",\n    \"accountId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"partnerIban\": \"DE12345678901234567890\",\n    \"partnerAccountBan\": \"5426551349\",\n    \"category\": \"micro-v2-household-utilities\",\n    \"referenceText\": \"Message of this transaction\",\n    \"userAccepted\": 1554078589178,\n    \"userCertified\": 1554078589178,\n    \"pending\": false,\n    \"transactionNature\": \"NORMAL\",\n    \"smartContactId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"transactionTerminal\": \"ATM\",\n    \"createdTS\": 1554078589191,\n    \"smartLinkId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"linkId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"confirmed\": 1554078589178\n  },\n  {\n    \"id\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"userId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"type\": \"AA\",\n    \"amount\": -23.65,\n    \"currencyCode\": \"EUR\",\n    \"originalAmount\": -23.65,\n    \"originalCurrency\": \"EUR\",\n    \"exchangeRate\": 1.0,\n    \"merchantCity\": \"Berlin\",\n    \"visibleTS\": 1553989562000,\n    \"mcc\": 5541,\n    \"mccGroup\": 16,\n    \"partnerBic\": \"Merchant Name\",\n    \"recurring\": false,\n    \"partnerAccountIsSepa\": false,\n    \"accountId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"category\": \"micro-v2-transport-car\",\n    \"cardId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"userCertified\": 1553989562859,\n    \"pending\": false,\n    \"transactionNature\": \"NORMAL\",\n    \"transactionTerminal\": \"ATM\",\n    \"createdTS\": 1553989562859,\n    \"merchantCountry\": 0,\n    \"smartLinkId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"linkId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"confirmed\": 1553989562859\n  },\n  {\n    \"id\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"userId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"type\": \"AA\",\n    \"amount\": -50.0,\n    \"currencyCode\": \"EUR\",\n    \"originalAmount\": -50.0,\n    \"originalCurrency\": \"EUR\",\n    \"exchangeRate\": 1.0,\n    \"merchantCity\": \"Berlin\",\n    \"visibleTS\": 1553869968000,\n    \"mcc\": 6011,\n    \"mccGroup\": 18,\n    \"partnerBic\": \"Merchant Name\",\n    \"recurring\": false,\n    \"partnerAccountIsSepa\": false,\n    \"accountId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"category\": \"micro-v2-atm\",\n    \"cardId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"userCertified\": 1553869968290,\n    \"pending\": false,\n    \"transactionNature\": \"NORMAL\",\n    \"transactionTerminal\": \"ATM\",\n    \"createdTS\": 1553869968290,\n    \"merchantCountry\": 0,\n    \"smartLinkId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"linkId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"confirmed\": 1553869968290\n  },\n  {\n    \"id\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"userId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"type\": \"CT\",\n    \"amount\": 762.38,\n    \"currencyCode\": \"EUR\",\n    \"visibleTS\": 1553855031265,\n    \"mcc\": 0,\n    \"mccGroup\": 12,\n    \"recurring\": false,\n    \"partnerBic\": \"ABCDEFGH123\",\n    \"partnerAccountIsSepa\": true,\n    \"partnerName\": \"Partner Name\",\n    \"accountId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"partnerIban\": \"DE12345678901234567890\",\n    \"category\": \"micro-v2-salary\",\n    \"referenceText\": \"Message of this transaction\",\n    \"userAccepted\": 1553855031265,\n    \"userCertified\": 1553855031265,\n    \"pending\": false,\n    \"transactionNature\": \"NORMAL\",\n    \"transactionTerminal\": \"ATM\",\n    \"createdTS\": 1553855031302,\n    \"purposeCode\": \"SALA\",\n    \"smartLinkId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"linkId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"confirmed\": 1553855031265\n  },\n  {\n    \"id\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"userId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"type\": \"PT\",\n    \"amount\": -40.0,\n    \"currencyCode\": \"EUR\",\n    \"originalAmount\": -40.0,\n    \"originalCurrency\": \"EUR\",\n    \"exchangeRate\": 1.0,\n    \"merchantCity\": \"BERLIN-X-S\",\n    \"visibleTS\": 1553537513000,\n    \"mcc\": 6011,\n    \"mccGroup\": 18,\n    \"partnerBic\": \"Merchant Name\",\n    \"recurring\": false,\n    \"partnerAccountIsSepa\": false,\n    \"accountId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"category\": \"micro-v2-atm\",\n    \"cardId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"userCertified\": 1553682902223,\n    \"pending\": false,\n    \"transactionNature\": \"NORMAL\",\n    \"transactionTerminal\": \"ATM\",\n    \"createdTS\": 1553682902229,\n    \"merchantCountry\": 0,\n    \"smartLinkId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"linkId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"confirmed\": 1553682902223\n  },\n  {\n    \"id\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"userId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"type\": \"CT\",\n    \"amount\": 35.0,\n    \"currencyCode\": \"EUR\",\n    \"visibleTS\": 1553498818087,\n    \"mcc\": 0,\n    \"mccGroup\": 12,\n    \"recurring\": false,\n    \"partnerBic\": \"ABCDEFGH123\",\n    \"partnerAccountIsSepa\": true,\n    \"partnerName\": \"Partner Name\",\n    \"accountId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"partnerIban\": \"DE12345678901234567890\",\n    \"category\": \"micro-v2-income\",\n    \"referenceText\": \"Message of this transaction\",\n    \"userAccepted\": 1553498818087,\n    \"userCertified\": 1553498818087,\n    \"pending\": false,\n    \"transactionNature\": \"NORMAL\",\n    \"transactionTerminal\": \"ATM\",\n    \"createdTS\": 1553498818121,\n    \"smartLinkId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"linkId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"confirmed\": 1553498818087\n  },\n  {\n    \"id\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"userId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"type\": \"PT\",\n    \"amount\": -30.0,\n    \"currencyCode\": \"EUR\",\n    \"originalAmount\": -30.0,\n    \"originalCurrency\": \"EUR\",\n    \"exchangeRate\": 1.0,\n    \"merchantCity\": \"BERLIN\",\n    \"visibleTS\": 1553363167000,\n    \"mcc\": 6011,\n    \"mccGroup\": 18,\n    \"partnerBic\": \"Merchant Name\",\n    \"recurring\": false,\n    \"partnerAccountIsSepa\": false,\n    \"accountId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"category\": \"micro-v2-atm\",\n    \"cardId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"userCertified\": 1553658221542,\n    \"pending\": false,\n    \"transactionNature\": \"NORMAL\",\n    \"transactionTerminal\": \"ATM\",\n    \"createdTS\": 1553658221548,\n    \"merchantCountry\": 0,\n    \"smartLinkId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"linkId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"confirmed\": 1553658221542\n  },\n  {\n    \"id\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"userId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"type\": \"PT\",\n    \"amount\": -50.0,\n    \"currencyCode\": \"EUR\",\n    \"originalAmount\": -50.0,\n    \"originalCurrency\": \"EUR\",\n    \"exchangeRate\": 1.0,\n    \"merchantCity\": \"BERLIN\",\n    \"visibleTS\": 1552701969000,\n    \"mcc\": 6011,\n    \"mccGroup\": 18,\n    \"partnerBic\": \"Merchant Name\",\n    \"recurring\": false,\n    \"partnerAccountIsSepa\": false,\n    \"accountId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"category\": \"micro-v2-atm\",\n    \"cardId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"userCertified\": 1553035421360,\n    \"pending\": false,\n    \"transactionNature\": \"NORMAL\",\n    \"transactionTerminal\": \"ATM\",\n    \"createdTS\": 1553035421360,\n    \"merchantCountry\": 0,\n    \"smartLinkId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"linkId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"confirmed\": 1553035421360\n  },\n  {\n    \"id\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"userId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"type\": \"PT\",\n    \"amount\": -6.5,\n    \"currencyCode\": \"EUR\",\n    \"originalAmount\": -6.5,\n    \"originalCurrency\": \"EUR\",\n    \"exchangeRate\": 1.0,\n    \"merchantCity\": \"BERLIN\",\n    \"visibleTS\": 1552695923000,\n    \"mcc\": 5541,\n    \"mccGroup\": 16,\n    \"partnerBic\": \"Merchant Name\",\n    \"recurring\": false,\n    \"partnerAccountIsSepa\": false,\n    \"accountId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"category\": \"micro-v2-transport-car\",\n    \"cardId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"userCertified\": 1553035421340,\n    \"pending\": false,\n    \"transactionNature\": \"NORMAL\",\n    \"transactionTerminal\": \"ATM\",\n    \"createdTS\": 1553035421348,\n    \"merchantCountry\": 0,\n    \"smartLinkId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"linkId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"confirmed\": 1553035421342\n  },\n  {\n    \"id\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"userId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"type\": \"DD\",\n    \"amount\": -92.29,\n    \"currencyCode\": \"EUR\",\n    \"visibleTS\": 1552670544571,\n    \"mcc\": 0,\n    \"mccGroup\": 12,\n    \"recurring\": false,\n    \"partnerBic\": \"ABCDEFGH123\",\n    \"partnerAccountIsSepa\": false,\n    \"partnerName\": \"Partner Name\",\n    \"accountId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"partnerIban\": \"DE12345678901234567890\",\n    \"category\": \"micro-v2-tax-fines\",\n    \"referenceText\": \"Message of this transaction\",\n    \"userCertified\": 1552670544571,\n    \"pending\": false,\n    \"transactionNature\": \"NORMAL\",\n    \"transactionTerminal\": \"ATM\",\n    \"createdTS\": 1552670544571,\n    \"mandateId\": \"MD4208404S2\",\n    \"creditorIdentifier\": \"DE1234AB78901234567\",\n    \"creditorName\": \"Creditor Name\",\n    \"smartLinkId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"linkId\": \"12345678-1234-abcd-abcd-1234567890ab\",\n    \"confirmed\": 1552608000000\n  }\n]"
  },
  {
    "path": "tests/test_account.py",
    "content": "from n26.api import GET, POST\nfrom tests.test_api_base import N26TestBase, mock_requests, read_response_file\n\n\nclass AccountTests(N26TestBase):\n    \"\"\"Account tests\"\"\"\n\n    @mock_requests(method=GET, response_file=\"account_info.json\")\n    def test_get_account_info_cli(self):\n        from n26.cli import info\n        result = self._run_cli_cmd(info)\n        self.assertIsNotNone(result.output)\n\n    @mock_requests(method=GET, response_file=\"account_statuses.json\")\n    def test_get_account_statuses_cli(self):\n        from n26.cli import status\n        result = self._run_cli_cmd(status)\n        self.assertIn(\"PAIRED\", result.output)\n\n    @mock_requests(method=GET, response_file=\"account_limits.json\")\n    def test_limits_cli(self):\n        from n26.cli import limits\n        result = self._run_cli_cmd(limits)\n        self.assertIn(\"POS_DAILY_ACCOUNT\", result.output)\n        self.assertIn(\"ATM_DAILY_ACCOUNT\", result.output)\n        self.assertIn(\"2500\", result.output)\n\n    @mock_requests(method=POST, response_file=None)\n    @mock_requests(method=GET, response_file=\"account_limits.json\")\n    def test_set_limits_cli(self):\n        from n26.cli import set_limits\n        result = self._run_cli_cmd(set_limits, [\"--withdrawal\", 2500, \"--payment\", 2500])\n        self.assertIn(\"POS_DAILY_ACCOUNT\", result.output)\n        self.assertIn(\"ATM_DAILY_ACCOUNT\", result.output)\n        self.assertIn(\"2500\", result.output)\n\n    @mock_requests(method=GET, response_file=\"addresses.json\")\n    def test_addresses_cli(self):\n        from n26.cli import addresses\n        result = self._run_cli_cmd(addresses)\n        self.assertIn(\"Einbahnstraße\", result.output)\n        self.assertIn(\"SHIPPING\", result.output)\n        self.assertIn(\"PASSPORT\", result.output)\n        self.assertIn(\"LEGAL\", result.output)\n\n    @mock_requests(method=GET, response_file=\"contacts.json\")\n    def test_get_contacts(self):\n        result = self._underTest.get_contacts()\n        self.assertIsNotNone(result)\n\n    @mock_requests(method=GET, response_file=\"contacts.json\")\n    def test_contacts_cli(self):\n        from n26.cli import contacts\n        result = self._run_cli_cmd(contacts)\n        self.assertIn(\"ADAC\", result.output)\n        self.assertIn(\"Cyberport\", result.output)\n        self.assertIn(\"DB\", result.output)\n        self.assertIn(\"ELV\", result.output)\n        self.assertIn(\"Mindfactory\", result.output)\n        self.assertIn(\"Seegel\", result.output)\n\n    @mock_requests(method=GET, response_file=\"statements.json\")\n    def test_get_statements_cli(self):\n        from n26.cli import statements\n        result = self._run_cli_cmd(statements)\n        self.assertIn(\"2016-11\", result.output)\n        self.assertIn(\"2017-01\", result.output)\n        self.assertIn(\"2018-01\", result.output)\n        self.assertIn(\"2019-01\", result.output)\n        self.assertIn(\"/api/statements/statement-2019-04\", result.output)\n        self.assertIn(\"1554076800000\", result.output)\n\n    @mock_requests(method=GET, response_file=\"statements.json\")\n    def test_get_statements_by_id_cli(self):\n        from n26.cli import statements\n        result = self._run_cli_cmd(statements, [\"--id\", \"statement-2017-01\"])\n        self.assertEqual(len(result.output.split(\"\\n\")), 4)\n        self.assertNotIn(\"2016-11\", result.output)\n        self.assertIn(\"/api/statements/statement-2017-01\", result.output)\n        self.assertNotIn(\"2018-01\", result.output)\n        self.assertNotIn(\"2019-01\", result.output)\n        self.assertNotIn(\"1554076800000\", result.output)\n\n    @mock_requests(method=GET, response_file=\"statements.json\")\n    def test_get_statements_by_date_cli(self):\n        from n26.cli import statements\n        result = self._run_cli_cmd(statements, [\"--from\", \"2017-01-01\", \"--to\", \"2017-04-01\"])\n        self.assertEqual(len(result.output.split(\"\\n\")), 7)\n        self.assertNotIn(\"2016-11\", result.output)\n        self.assertIn(\"/api/statements/statement-2017-01\", result.output)\n        self.assertIn(\"2017-02\", result.output)\n        self.assertIn(\"2017-03\", result.output)\n        self.assertIn(\"2017-04\", result.output)\n        self.assertNotIn(\"2018-01\", result.output)\n        self.assertNotIn(\"2019-01\", result.output)\n        self.assertNotIn(\"1554076800000\", result.output)\n\n    @mock_requests(method=GET, response_file=\"statement.pdf\", url_regex=r\"/api/statements/statement-2017-01$\")\n    @mock_requests(method=GET, response_file=\"statements.json\", url_regex=r\"/api/statements$\")\n    def test_get_statements_download_cli(self):\n        from filecmp import cmp\n        from glob import glob\n        from os import path\n        from tempfile import TemporaryDirectory\n        from n26.cli import statements\n        id = \"statement-2017-01\"\n        with TemporaryDirectory() as dir:\n            result = self._run_cli_cmd(statements, [\"--id\", id, \"--download\", dir])\n            self.assertIn(id, result.output)\n            files = glob(f\"{dir}/*.pdf\")\n            self.assertTrue(len(files) == 1)\n            directory = path.dirname(__file__)\n            file_path = path.join(directory, 'api_responses', 'statement.pdf')\n            self.assertTrue(cmp(file_path, files[0]))\n"
  },
  {
    "path": "tests/test_api.py",
    "content": "from n26 import api, config\nfrom n26.api import BASE_URL_DE, POST, GET\nfrom tests.test_api_base import N26TestBase, mock_auth_token, mock_requests\n\n\nclass ApiTests(N26TestBase):\n    \"\"\"Common Api tests\"\"\"\n\n    def test_create_request_url(self):\n        from n26.util import create_request_url\n        expected = \"https://api.tech26.de?bar=baz&foo=bar\"\n        result = create_request_url(BASE_URL_DE, {\n            \"foo\": \"bar\",\n            \"bar\": \"baz\"\n        })\n        self.assertEqual(result, expected)\n\n    @mock_requests(method=GET, response_file=\"refresh_token.json\")\n    def test_do_request(self):\n        result = self._underTest._do_request(GET, \"/something\")\n        self.assertIsNotNone(result)\n\n    @mock_auth_token\n    def test_get_token(self):\n        expected = '12345678-1234-1234-1234-123456789012'\n        api_client = api.Api(self.config)\n        result = api_client.get_token()\n        self.assertEqual(result, expected)\n\n    @mock_requests(url_regex=\".*/token\", method=POST, response_file=\"refresh_token.json\")\n    def test_refresh_token(self):\n        refresh_token = \"12345678-1234-abcd-abcd-1234567890ab\"\n        expected = \"12345678-1234-abcd-abcd-1234567890ab\"\n        result = self._underTest._refresh_token(refresh_token)\n        self.assertEqual(result['access_token'], expected)\n\n    def test_init_without_config(self):\n        api_client = api.Api()\n        self.assertIsNotNone(api_client.config)\n\n    def test_init_with_config(self):\n        from container_app_conf.source.yaml_source import YamlSource\n        conf = config.Config(singleton=False, data_sources=[\n            YamlSource(\"test_creds\", \"./tests/\")\n        ])\n        api_client = api.Api(conf)\n        self.assertIsNotNone(api_client.config)\n        self.assertEqual(api_client.config, conf)\n"
  },
  {
    "path": "tests/test_api_base.py",
    "content": "import functools\nimport json\nimport logging\nimport re\nimport unittest\nfrom copy import deepcopy\nfrom unittest import mock\nfrom unittest.mock import Mock, DEFAULT\n\n\ndef read_response_file(file_name: str or None, to_json: bool = True) -> json or bytes or None:\n    \"\"\"\n    Reads a JSON file and returns it's content as a string\n    :param file_name: the name of the file\n    :param to_json: whether to parse the file to a json object or not\n    :return: file contents\n    \"\"\"\n\n    if file_name is None:\n        return None\n\n    import os\n    directory = os.path.dirname(__file__)\n    file_path = os.path.join(directory, 'api_responses', file_name)\n\n    if not os.path.isfile(file_path):\n        raise AttributeError(\"Couldn't find file containing response mock data: {}\".format(file_path))\n\n    mode = 'r' if to_json else 'rb'\n    with open(file_path, mode) as myfile:\n        api_response_text = myfile.read()\n    return json.loads(api_response_text) if to_json else api_response_text\n\n\ndef mock_auth_token(func: callable):\n    \"\"\"\n    Decorator for mocking the auth token returned by the N26 api\n\n    :param func: function to patch\n    :return:\n    \"\"\"\n\n    @functools.wraps(func)\n    def wrapper(*args, **kwargs):\n        with mock.patch('n26.api.Api._request_token') as request_token:\n            request_token.return_value = read_response_file(\"auth_token.json\")\n            with mock.patch('n26.api.Api._refresh_token') as refresh_token:\n                refresh_token.return_value = read_response_file(\"refresh_token.json\")\n                return func(*args, **kwargs)\n\n    return wrapper\n\n\ndef mock_requests(method: str, response_file: str or None, url_regex: str = None):\n    \"\"\"\n    Decorator to mock the http response\n\n    :param method: the http method to mock\n    :param response_file: optional file name of the file containing the json response to use for the mock\n    :param url_regex: optional regex to match the called url against. Only matching urls will be mocked.\n    :return: the decorated method\n    \"\"\"\n\n    def decorator(function: callable):\n        if not callable(function):\n            raise AttributeError(\"Unsupported type: {}\".format(function))\n\n        def add_side_effects(mock_request, original):\n            new_mock = Mock()\n\n            def side_effect(*args, **kwargs):\n                args = deepcopy(args)\n                kwargs = deepcopy(kwargs)\n                new_mock(*args, **kwargs)\n\n                if not url_regex or re.findall(url_regex, args[0]):\n                    return DEFAULT\n                else:\n                    return original(*args, **kwargs)\n\n            mock_request.side_effect = side_effect\n\n            is_json = response_file.endswith('.json') if response_file else False\n            response = read_response_file(response_file, to_json=is_json)\n            content = \"\" if response is None else response\n            mock_request.return_value.content = content if not is_json else str(content)\n            mock_request.return_value.json.return_value = response\n            mock_request.return_value.headers = {\n                \"Content-Type\": \"application/json\" if is_json else \"\"\n            }\n            return new_mock\n\n        @mock_auth_token\n        @functools.wraps(function)\n        def wrapper(*args, **kwargs):\n            import n26\n            from n26.api import GET, POST\n            if method is GET:\n                original = n26.api.requests.get\n            elif method is POST:\n                original = n26.api.requests.post\n            else:\n                raise AttributeError(\"Unsupported method: {}\".format(method))\n\n            with mock.patch('n26.api.requests.{}'.format(method)) as mock_request:\n                add_side_effects(mock_request, original)\n                result = function(*args, **kwargs)\n                return result\n\n        return wrapper\n\n    return decorator\n\n\nclass N26TestBase(unittest.TestCase):\n    \"\"\"Base class for N26 api tests\"\"\"\n\n    from n26.config import Config\n    from container_app_conf.source.yaml_source import YamlSource\n\n    # create custom config from \"test_creds.yml\"\n    config = Config(\n        singleton=True,\n        data_sources=[\n            YamlSource(\"test_creds\", \"./tests/\")\n        ]\n    )\n\n    # this is the Api client\n    _underTest = None\n\n    def setUp(self):\n        \"\"\"\n        This method is called BEFORE each individual test case\n        \"\"\"\n        from n26 import api\n\n        self._underTest = api.Api(self.config)\n\n        logger = logging.getLogger(\"n26\")\n        logger.setLevel(logging.DEBUG)\n        ch = logging.StreamHandler()\n        ch.setLevel(logging.DEBUG)\n        formatter = logging.Formatter(\"%(asctime)s - %(name)s - %(levelname)s - %(message)s\")\n        ch.setFormatter(formatter)\n        logger.addHandler(ch)\n\n    def tearDown(self):\n        \"\"\"\n        This method is called AFTER each individual test case\n        \"\"\"\n        pass\n\n    @staticmethod\n    def get_api_response(filename: str) -> dict or None:\n        \"\"\"\n        Read an api response from a file\n\n        :param filename: the file in the \"api_responses\" subfolder to read\n        :return: the api response dict\n        \"\"\"\n        file = read_response_file(filename)\n        if file is None:\n            return None\n        else:\n            return json.loads(file)\n\n    @staticmethod\n    def _run_cli_cmd(command: callable, args: list = None, ignore_exceptions: bool = False) -> any:\n        \"\"\"\n        Runs a cli command and returns it's output.\n        If running the command results in an exception it is automatically rethrown by this method.\n\n        :param command: the command to execute\n        :param args: command arguments as a list\n        :param ignore_exceptions: if set to true exceptions originating from running the command\n                                  will not be rethrown and the result object from the cli call will\n                                  be returned instead.\n        :return: the result of the call\n        \"\"\"\n        from click.testing import CliRunner\n        runner = CliRunner(echo_stdin=False)\n        result = runner.invoke(command, args=args or ())\n\n        if not ignore_exceptions and result.exception:\n            raise result.exception\n\n        return result\n\n    if __name__ == '__main__':\n        unittest.main()\n"
  },
  {
    "path": "tests/test_balance.py",
    "content": "from n26.api import GET\nfrom tests.test_api_base import N26TestBase, mock_requests\n\n\nclass BalanceTest(N26TestBase):\n    \"\"\"Balance tests\"\"\"\n\n    @mock_requests(method=GET, response_file=\"balance.json\")\n    def test_balance_cli(self):\n        from n26.cli import balance\n        result = self._run_cli_cmd(balance)\n        self.assertRegex(result.output, r\"\\d*\\.\\d* \\w*.*\")\n"
  },
  {
    "path": "tests/test_cards.py",
    "content": "from n26.api import GET, POST\nfrom tests.test_api_base import N26TestBase, mock_requests\n\n\nclass CardsTests(N26TestBase):\n    \"\"\"Cards tests\"\"\"\n\n    @mock_requests(method=GET, response_file=\"cards.json\")\n    def test_cards_cli(self):\n        from n26.cli import cards\n        result = self._run_cli_cmd(cards)\n        self.assertIn('MASTERCARD', result.output)\n        self.assertIn('MAESTRO', result.output)\n        self.assertIn('active', result.output)\n        self.assertIn('123456******1234', result.output)\n\n    @mock_requests(method=GET, response_file=\"cards.json\")\n    @mock_requests(method=POST, response_file=\"card_block_single.json\")\n    def test_block_card_cli_single(self):\n        from n26.cli import card_block\n        card_id = \"12345678-1234-abcd-abcd-1234567890ab\"\n        result = self._run_cli_cmd(card_block, [\"--card\", card_id])\n        self.assertEqual(result.output, \"Blocked card: {}\\n\".format(card_id))\n\n    @mock_requests(method=GET, response_file=\"cards.json\")\n    @mock_requests(method=POST, response_file=\"card_block_single.json\")\n    def test_block_card_cli_all(self):\n        from n26.cli import card_block\n        card_id_1 = \"12345678-1234-abcd-abcd-1234567890ab\"\n        card_id_2 = \"22345678-1234-abcd-abcd-1234567890ab\"\n\n        result = self._run_cli_cmd(card_block)\n        self.assertEqual(result.output, \"Blocked card: {}\\nBlocked card: {}\\n\".format(card_id_1, card_id_2))\n\n    @mock_requests(method=GET, response_file=\"cards.json\")\n    @mock_requests(method=POST, response_file=\"card_unblock_single.json\")\n    def test_unblock_card_cli_single(self):\n        from n26.cli import card_unblock\n        card_id = \"12345678-1234-abcd-abcd-1234567890ab\"\n        result = self._run_cli_cmd(card_unblock, [\"--card\", card_id])\n        self.assertEqual(result.output, \"Unblocked card: {}\\n\".format(card_id))\n\n    @mock_requests(method=GET, response_file=\"cards.json\")\n    @mock_requests(method=POST, response_file=\"card_unblock_single.json\")\n    def test_unblock_card_cli_all(self):\n        from n26.cli import card_unblock\n        card_id_1 = \"12345678-1234-abcd-abcd-1234567890ab\"\n        card_id_2 = \"22345678-1234-abcd-abcd-1234567890ab\"\n\n        result = self._run_cli_cmd(card_unblock)\n        self.assertEqual(result.output, \"Unblocked card: {}\\nUnblocked card: {}\\n\".format(card_id_1, card_id_2))\n"
  },
  {
    "path": "tests/test_creds.yml",
    "content": "n26:\n  username: john.doe@example.com\n  password: $upersecret\n  device_token: 5a136085-abd8-4e71-9402-e0a61dd1dc81\n  mfa_type: app\n"
  },
  {
    "path": "tests/test_spaces.py",
    "content": "from n26.api import GET\nfrom tests.test_api_base import N26TestBase, mock_requests\n\n\nclass SpacesTests(N26TestBase):\n    \"\"\"Spaces tests\"\"\"\n\n    @mock_requests(method=GET, response_file=\"spaces.json\")\n    def test_spaces_cli(self):\n        from n26.cli import spaces\n        result = self._run_cli_cmd(spaces)\n        self.assertRegex(result.output, r\"\\d*\\.\\d* \\w*.*\")\n"
  },
  {
    "path": "tests/test_standing_orders.py",
    "content": "from n26.api import GET\n\nfrom tests.test_api_base import N26TestBase, mock_requests\n\n\nclass StandingOrdersTests(N26TestBase):\n    \"\"\"Standing orders tests\"\"\"\n\n    @mock_requests(method=GET, response_file=\"standing_orders.json\")\n    def test_standing_orders_cli(self):\n        from n26.cli import standing_orders\n        result = self._run_cli_cmd(standing_orders)\n        self.assertIsNotNone(result.output)\n        self.assertIn('Mr. Anderson', result.output)\n        self.assertIn('INWX', result.output)\n        self.assertIn('1st', result.output)\n        self.assertIn('30th', result.output)\n        self.assertIn('WEEKLY', result.output)\n        self.assertIn('MONTHLY', result.output)\n        self.assertIn('YEARLY', result.output)\n        self.assertIn('10/30/18', result.output)\n"
  },
  {
    "path": "tests/test_statistics.py",
    "content": "from n26.api import GET\n\nfrom tests.test_api_base import N26TestBase, mock_requests\n\n\nclass StatisticsTests(N26TestBase):\n    \"\"\"Statistics tests\"\"\"\n\n    @mock_requests(method=GET, response_file=\"statistics.json\")\n    def test_statistics_cli(self):\n        from n26.cli import statistics\n        result = self._run_cli_cmd(statistics)\n        self.assertIsNotNone(result.output)\n"
  },
  {
    "path": "tests/test_transactions.py",
    "content": "from n26.api import GET\n\nfrom tests.test_api_base import N26TestBase, mock_requests\n\n\nclass TransactionsTests(N26TestBase):\n    \"\"\"Transactions tests\"\"\"\n\n    @mock_requests(method=GET, response_file=\"transactions.json\")\n    def test_transactions_cli(self):\n        from n26.cli import transactions\n        result = self._run_cli_cmd(transactions, [\"--from\", \"01/30/2019\", \"--to\", \"30.01.2020\"])\n        self.assertIsNotNone(result.output)\n"
  }
]