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