From 3a3e5d21f10cade2c61f8dc442aa78a9452c0879 Mon Sep 17 00:00:00 2001 From: Patrick Ruckstuhl Date: Thu, 9 Sep 2021 18:15:26 +0000 Subject: [PATCH] adding nordigen importer --- README.rst | 101 +++++-------- setup.cfg | 2 + .../importers/nordigen/importer.py | 65 +++++++++ .../importers/nordigen/nordigen_config.py | 136 ++++++++++++++++++ 4 files changed, 241 insertions(+), 63 deletions(-) create mode 100644 src/tariochbctools/importers/nordigen/importer.py create mode 100644 src/tariochbctools/importers/nordigen/nordigen_config.py diff --git a/README.rst b/README.rst index 4fe2caa..131de83 100644 --- a/README.rst +++ b/README.rst @@ -29,44 +29,8 @@ Dynamically generates prices to the base ccy by applying the fx rate to the base price fetchers -------------- -**alphavantage** -Fetches prices from `Alphavantage `_ -Requires the environment variable ``ALPHAVANTAGE_API_KEY`` to be set with your personal api key. - -:: - - 2019-01-01 commodity VWRL - price: "CHF:tariochbctools.plugins.prices.alphavantage/VWRL.SW" - -**alphavantagefx** - -Fetches fx rates from `Alphavantage `_ -Requires the environment variable ``ALPHAVANTAGE_API_KEY`` to be set with your personal api key. - -:: - - 2019-01-01 commodity BTC - price: "CHF:tariochbctools.plugins.prices.alphavantagefx/BTC" - - -**bitstamp** - -Fetches prices from `Bitstamp `_ - -:: - - 2019-01-01 commodity BTC - price: "EUR:tariochbctools.plugins.prices.bitstamp/BTC" - -**exchangeratesapi** - -Fetches prices from `ratesapi.io `_ - -:: - - 2019-01-01 commodity EUR - price: "CHF:tariochbctools.plugins.prices.exchangeratesapi/EUR" +Also see `Beanprice `_ **interactivebrokers** @@ -80,16 +44,6 @@ with a flex query that contains the open positions. 2019-01-01 commodity VWRL price: "CHF:tariochbctools.plugins.prices.ibkr/VWRL" -**coinmarketcap** - -Fetches prices from `coinmarketcap `_ -Requires the environment variable ``COINMARKETCAP_API_KEY`` to be set to your api key. - -:: - - 2019-01-01 commodity BTC - price: "CHF:tariochbctools.plugins.prices.coinmarketcap/BTC" - importers --------- @@ -161,6 +115,37 @@ Create a file called truelayer.yaml in your import location (e.g. download folde client_secret: refresh_token: +**Nordigen** + +Import from `Nordigen `_ using their api services. e.g. supports Revolut. +You need to create a free account and create a token. I've included a small cli to allow to hook up +to different banks with nordigen. If you're country is not supported you can play around with other countries +e.g. CH is not allowed but things like revolut still work. You can also create multiple links and they will +all be listed in the end. + +:: + + nordigen-conf list_banks --token YOURTOKEN --country DE + nordigen-conf create_link --token YOURTOKEN --bank REVOLUT_REVOGB21 + nordigen-conf list_accounts --token YOURTOKEN list_accounts + + +:: + + from tariochbctools.importers.nordigen import importer as nordimp + CONFIG = [nordimp.Importer()] + +Create a file called nordigen.yaml in your import location (e.g. download folder). + +:: + + token: + + accounts: + - id: + asset_account: "Assets:MyAccount:CHF" + + **zkb** Import mt940 from `Zürcher Kantonalbank `_ @@ -184,7 +169,12 @@ Create a file called ibkr.yaml in your import location (e.g. downloads folder). **zak** -**Currently not working reliably**. Import PDF from `Bank Cler ZAK `_ +Import PDF from `Bank Cler ZAK `_ + +:: + + from tariochbctools.importers.zak import importer as zakimp + CONFIG = [ zakimp.Importer(r'Kontoauszug.*\.pdf', 'Assets:ZAK:CHF') ] **mt940** @@ -283,18 +273,3 @@ Import CSV from `Neon `_ from tariochbctools.importers.neon import importer as neonimp CONFIG = [neonimp.Importer('\d\d\d\d_account_statements\.csv', 'Assets:Neon:CHF')] - - -Syncing a fork --------------- - -Details: https://docs.github.com/en/free-pro-team@latest/github/collaborating-with-issues-and-pull-requests/syncing-a-fork - -:: - - git remote add upstream https://github.com/tarioch/beancounttools.git - git remote -v - git fetch upstream - git checkout master - git merge upstream/master - git push diff --git a/setup.cfg b/setup.cfg index 696ae81..b6621fc 100644 --- a/setup.cfg +++ b/setup.cfg @@ -73,6 +73,8 @@ testing = # And any other entry points, for example: # pyscaffold.cli = # awesome = pyscaffoldext.awesome.extension:AwesomeExtension +console_scripts = + nordigen-conf = tariochbctools.importers.nordigen.nordigen_config:run [test] # py.test options when running `python setup.py test` diff --git a/src/tariochbctools/importers/nordigen/importer.py b/src/tariochbctools/importers/nordigen/importer.py new file mode 100644 index 0000000..2af7806 --- /dev/null +++ b/src/tariochbctools/importers/nordigen/importer.py @@ -0,0 +1,65 @@ +import yaml +from datetime import date +from os import path +import requests + +from beancount.ingest import importer +from beancount.core import data +from beancount.core import amount +from beancount.core.number import D + + +class HttpServiceException(Exception): + pass + + +class Importer(importer.ImporterProtocol): + """An importer for Nordigen API (e.g. for Revolut).""" + + def identify(self, file): + return 'nordigen.yaml' == path.basename(file.name) + + def file_account(self, file): + return '' + + def extract(self, file, existing_entries): + with open(file.name, 'r') as f: + config = yaml.safe_load(f) + token = config['token'] + headers = {'Authorization': 'Token ' + token} + + entries = [] + for account in config['accounts']: + accountId = account['id'] + assetAccount = account['asset_account'] + r = requests.get(f'https://ob.nordigen.com/api/accounts/{accountId}/transactions/', headers=headers) + try: + r.raise_for_status() + except requests.exceptions.HTTPError as e: + raise HttpServiceException(e, e.response.text) + + transactions = sorted(r.json()['transactions']["booked"], key=lambda trx: trx['bookingDate']) + for trx in transactions: + metakv = { + 'nordref': trx['transactionId'], + } + if 'currencyExchange' in trx: + instructedAmount = trx['currencyExchange']['instructedAmount'] + metakv['original'] = instructedAmount['currency'] + ' ' + instructedAmount['amount'] + meta = data.new_metadata('', 0, metakv) + trxDate = date.fromisoformat(trx['bookingDate']) + entry = data.Transaction( + meta, + trxDate, + '*', + '', + ' '.join(trx['remittanceInformationUnstructuredArray']), + data.EMPTY_SET, + data.EMPTY_SET, + [ + data.Posting(assetAccount, amount.Amount(D(str(trx['transactionAmount']['amount'])), trx['transactionAmount']['currency']), None, None, None, None), + ] + ) + entries.append(entry) + + return entries diff --git a/src/tariochbctools/importers/nordigen/nordigen_config.py b/src/tariochbctools/importers/nordigen/nordigen_config.py new file mode 100644 index 0000000..5291d93 --- /dev/null +++ b/src/tariochbctools/importers/nordigen/nordigen_config.py @@ -0,0 +1,136 @@ +import argparse +import requests +import sys + + +def build_header(token): + return {'Authorization': 'Token ' + token} + + +def check_result(result): + try: + result.raise_for_status() + except requests.exceptions.HTTPError as e: + raise Exception(e, e.response.text) + + +def list_bank(token, country): + r = requests.get('https://ob.nordigen.com/api/aspsps/', params={'country': country}, headers=build_header(token)) + check_result(r) + + for asp in r.json(): + print(asp['name'] + ': ' + asp['id']) + + +def create_link(token, userId, bank): + if not bank: + raise Exception('Please specify --bank it is required for create_link') + headers = build_header(token) + requisitionId = _find_requisition_id(token, userId) + if not requisitionId: + r = requests.post('https://ob.nordigen.com/api/requisitions/', data={ + 'redirect': 'http://localhost', + 'enduser_id': userId, + 'reference': userId, + }, headers=build_header(token)) + check_result(r) + requisitionId = r.json()['id'] + + r = requests.post(f'https://ob.nordigen.com/api/requisitions/{requisitionId}/links/', data={'aspsp_id': bank}, headers=headers) + check_result(r) + link = r.json()['initiate'] + print(f'Go to {link} for connecting to your bank.') + + +def list_accounts(token): + headers = build_header(token) + r = requests.get('https://ob.nordigen.com/api/requisitions/', headers=headers) + check_result(r) + for req in r.json()['results']: + print(req['enduser_id'] + ': ' + req['id']) + for account in req['accounts']: + ra = requests.get(f'https://ob.nordigen.com/api/accounts/{account}', headers=headers) + check_result(ra) + acc = ra.json() + asp = acc['aspsp_identifier'] + iban = acc['iban'] + + ra = requests.get(f'https://ob.nordigen.com/api/accounts/{account}/details', headers=headers) + check_result(ra) + accDetails = ra.json()['account'] + + currency = accDetails['currency'] + owner = accDetails['ownerName'] if 'ownerName' in accDetails else '-' + print(f'{account}: {asp} {owner} {iban} {currency}') + + +def delete_user(token, userId): + requisitionId = _find_requisition_id(token, userId) + if requisitionId: + r = requests.delete(f'https://ob.nordigen.com/api/requisitions/{requisitionId}', headers=build_header(token)) + check_result(r) + + +def _find_requisition_id(token, userId): + headers = build_header(token) + r = requests.get('https://ob.nordigen.com/api/requisitions/', headers=headers) + check_result(r) + for req in r.json()['results']: + if req['enduser_id'] == userId: + return req['id'] + + return None + + +def parse_args(args): + parser = argparse.ArgumentParser( + description="nordigen-config" + ) + parser.add_argument( + '--token', + required=True, + help='API Token, can be generated on Nordigen website', + ) + parser.add_argument( + '--country', + default='GB', + help='Country Code for list_bank', + ) + parser.add_argument( + '--userId', + default='beancount', + help='UserId for create_link and delete_user', + ) + parser.add_argument( + '--bank', + help='Bank to connect to, see list_banks', + ) + parser.add_argument( + 'mode', + choices=[ + 'list_banks', + 'create_link', + 'list_accounts', + 'delete_user', + ], + ) + return parser.parse_args(args) + + +def main(args): + args = parse_args(args) + + if args.mode == 'list_banks': + list_bank(args.token, args.country) + elif args.mode == 'create_link': + create_link(args.token, args.userId, args.bank) + elif args.mode == 'list_accounts': + list_accounts(args.token) + elif args.mode == 'delete_user': + delete_user(args.token, args.userId) + + +def run(): + """Entry point for console_scripts + """ + main(sys.argv[1:])