From 433a8caf60aee1d2e74948fd50a2d08ccc48aca8 Mon Sep 17 00:00:00 2001 From: Ilja Leiko Date: Mon, 28 Oct 2019 19:20:36 +0100 Subject: [PATCH 1/4] Adding transaction support --- revolut/__init__.py | 132 ++++++++++++++++++++++++++++++++++++++++ revolut_transactions.py | 49 +++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 revolut_transactions.py diff --git a/revolut/__init__.py b/revolut/__init__.py index 83ceaf7..f211f9f 100644 --- a/revolut/__init__.py +++ b/revolut/__init__.py @@ -12,6 +12,7 @@ __version__ = '0.0.9' # Should be the same in setup.py _URL_GET_ACCOUNTS = "https://api.revolut.com/user/current/wallet" +_URL_GET_TRANSACTIONS = 'https://api.revolut.com/user/current/transactions' _URL_QUOTE = "https://api.revolut.com/quote/" _URL_EXCHANGE = "https://api.revolut.com/exchange" _URL_GET_TOKEN_STEP1 = "https://api.revolut.com/signin" @@ -27,6 +28,12 @@ _VAULT_ACCOUNT_TYPE = "SAVINGS" _ACTIVE_ACCOUNT = "ACTIVE" +_TRANSACTION_COMPLETED = "COMPLETED" +_TRANSACTION_FAILED = "FAILED" +_TRANSACTION_PENDING = "PENDING" +_TRANSACTION_REVERTED = "REVERTED" +_TRANSACTION_DECLINED = "DECLINED" + # The amounts are stored as integer on Revolut. # They apply a scale factor depending on the currency @@ -175,6 +182,25 @@ def get_account_balances(self): self.account_balances = Accounts(account_balances) return self.account_balances + def get_account_transactions(self, from_date): + """ Get the account transactions and return as json """ + wallet_id = self.get_wallet_id() + from_date_ts = from_date.timestamp() + path = _URL_GET_TRANSACTIONS + '?from={from_date_ts}&walletId={wallet_id}'.format( + from_date_ts=int(from_date_ts), + wallet_id=self.get_wallet_id() + ) + ret = self.client._get(path) + raw_transactions = json.loads(ret.text) + transactions = AccountTransactions(raw_transactions) + return transactions + + def get_wallet_id(self): + """ Get the main wallet_id """ + ret = self.client._get(_URL_GET_ACCOUNTS) + raw = json.loads(ret.text) + return raw.get('id') + def quote(self, from_amount, to_currency): if type(from_amount) != Amount: raise TypeError("from_amount must be with the Amount type") @@ -328,6 +354,112 @@ def csv(self, lang="fr"): return csv_str.replace(".", ",") if lang_is_fr else csv_str +class AccountTransaction: + """ Class to handle an account transaction """ + def __init__( + self, + transactions_type, + state, + started_date, + completed_date, + currency, + amount, + fee, + description, + account_id + ): + self.transactions_type = transactions_type + self.state = state + self.started_date = started_date + self.completed_date = completed_date + self.currency = currency + self.amount = amount + self.fee = fee + self.description = description + self.account_id = account_id + + def __str__(self): + return "{description}: {amount}{currency}".format( + description=self.description, + amount=str(self.amount), + currency=self.currency + ) + + def get_datetime__str(self): + """ 'Pending' transactions do not have 'completed_date' yet + so return 'started_date' instead """ + timestamp = self.completed_date if self.completed_date \ + else self.started_date + # Convert from timestamp to datetime + dt = datetime.fromtimestamp( + timestamp / 1000 + ) + dt_str = dt.strftime("%m/%d/%Y %H:%M:%S") + return dt_str + + def get_description(self): + # Adding 'pending' for processing transactions + description = self.description + if self.state == _TRANSACTION_PENDING: + description = '{} **pending**'.format(description) + return description + + def get_amount__str(self): + """ Convert amount to float and return string representation """ + return str(self.amount / 100) + + +class AccountTransactions: + """ Class to handle the account transactions """ + + def __init__(self, account_transactions): + self.raw_list = account_transactions + self.list = [ + AccountTransaction( + transactions_type=transaction.get("type"), + state=transaction.get("state"), + started_date=transaction.get("startedDate"), + completed_date=transaction.get("completedDate"), + currency=transaction.get('currency'), + amount=transaction.get('amount'), + fee=transaction.get('fee'), + description=transaction.get('description'), + account_id=transaction.get('account').get('id') + ) + for transaction in self.raw_list + ] + + def __len__(self): + return len(self.list) + + def csv(self, lang="fr"): + lang_is_fr = lang == "fr" + if lang_is_fr: + csv_str = "Date-heure,Description,Montant,Devise" + else: + csv_str = "Date-time,Description,Amount,Currency" + + # Europe uses 'comma' as decimal separator, + # so it can't be used as delimiter: + delimiter = ";" if lang_is_fr else "," + + # Do not export declined or failed payments + for account_transaction in self.list: + if account_transaction.state not in [ + _TRANSACTION_DECLINED, + _TRANSACTION_FAILED, + _TRANSACTION_REVERTED + ]: + + csv_str += "\n" + delimiter.join(( + account_transaction.get_datetime__str(), + account_transaction.get_description(), + account_transaction.get_amount__str(), + account_transaction.currency + )) + return csv_str.replace(".", ",") if lang_is_fr else csv_str + + def get_token_step1(device_id, phone, password, simulate=False): """ Function to obtain a Revolut token (step 1 : send a code by sms) """ if not simulate: diff --git a/revolut_transactions.py b/revolut_transactions.py new file mode 100644 index 0000000..bcaecb4 --- /dev/null +++ b/revolut_transactions.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import click +import json +import sys + +from datetime import datetime +from datetime import timedelta +from getpass import getpass + +from revolut import Revolut, __version__, get_token_step1, get_token_step2 + + +_CLI_DEVICE_ID = 'revolut_cli' +_URL_GET_TRANSACTIONS = 'https://api.revolut.com/user/current/transactions' + + +@click.command() +@click.option( + '--token', '-t', + envvar="REVOLUT_TOKEN", + type=str, + help='your Revolut token (or set the env var REVOLUT_TOKEN)', +) +@click.option( + '--language', '-l', + type=str, + help='language ("fr" or "en"), for the csv header and separator', + default='fr' +) +@click.option( + '--from_date', '-t', + type=click.DateTime(formats=["%Y-%m-%d"]), + help='transactions lookback date in YYYY-MM-DD format (ex: "2019-10-26"). Default 30 days back', + default=(datetime.now()-timedelta(days=30)).strftime("%Y-%m-%d") + ) +def main(token, language, from_date): + """ Get the account balances on Revolut """ + if token is None: + print("You don't seem to have a Revolut token. Use 'revolut_cli' to obtain one") + sys.exit() + + rev = Revolut(device_id=_CLI_DEVICE_ID, token=token) + account_transactions = rev.get_account_transactions(from_date) + print(account_transactions.csv(lang=language)) + +if __name__ == "__main__": + main() From 79c6b4405fa4e77bd807ed45b768db01018a8355 Mon Sep 17 00:00:00 2001 From: Ilja Leiko Date: Mon, 28 Oct 2019 19:42:40 +0100 Subject: [PATCH 2/4] Readme updated for pulling transactions functionality --- README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/README.md b/README.md index 28648ba..cbdbc03 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,34 @@ If you don't have a Revolut token yet, the tool will allow you to obtain one. ⚠️ **If you don't receive a SMS when trying to get a token, you need to logout from the app on your Smartphone.** +## Pulling transactions + +```bash +Usage: revolut_transactions.py [OPTIONS] + + Get the account balances on Revolut + +Options: + -t, --token TEXT your Revolut token (or set the env var + REVOLUT_TOKEN) + -l, --language TEXT language ("fr" or "en"), for the csv header and + separator + -t, --from_date [%Y-%m-%d] transactions lookback date in YYYY-MM-DD format + (ex: "2019-10-26"). Default 30 days back + --help Show this message and exit. +``` + + Example output : + + ```csv +Date-time,Description,Amount,Currency +08/26/2019 21:31:00,Card Delivery Fee,-59.99,SEK +09/14/2019 12:50:07,donkey.bike **pending**,0.0,SEK +09/14/2019 13:03:15,Top-Up by *6458,200.0,SEK +09/30/2019 16:19:19,Reward user for the invite,200.0,SEK +10/12/2019 23:51:02,Tiptapp Reservation,-250.0,SEK +``` + ## TODO - [ ] Document revolutbot.py From 95c9c6474cfe13161fca4fe8db09d849a5221105 Mon Sep 17 00:00:00 2001 From: Thibault Ducret Date: Wed, 30 Oct 2019 07:36:16 +0100 Subject: [PATCH 3/4] Use Amount class to get the proper transaction amount (scaled for different currencies) + add the french datetime format --- revolut/__init__.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/revolut/__init__.py b/revolut/__init__.py index f211f9f..1f587a0 100644 --- a/revolut/__init__.py +++ b/revolut/__init__.py @@ -362,7 +362,6 @@ def __init__( state, started_date, completed_date, - currency, amount, fee, description, @@ -372,20 +371,18 @@ def __init__( self.state = state self.started_date = started_date self.completed_date = completed_date - self.currency = currency self.amount = amount self.fee = fee self.description = description self.account_id = account_id def __str__(self): - return "{description}: {amount}{currency}".format( + return "{description}: {amount}".format( description=self.description, - amount=str(self.amount), - currency=self.currency + amount=str(self.amount) ) - def get_datetime__str(self): + def get_datetime__str(self, date_format="%d/%m/%Y %H:%M:%S"): """ 'Pending' transactions do not have 'completed_date' yet so return 'started_date' instead """ timestamp = self.completed_date if self.completed_date \ @@ -394,7 +391,7 @@ def get_datetime__str(self): dt = datetime.fromtimestamp( timestamp / 1000 ) - dt_str = dt.strftime("%m/%d/%Y %H:%M:%S") + dt_str = dt.strftime(date_format) return dt_str def get_description(self): @@ -406,7 +403,7 @@ def get_description(self): def get_amount__str(self): """ Convert amount to float and return string representation """ - return str(self.amount / 100) + return str(self.amount.real_amount) class AccountTransactions: @@ -420,8 +417,8 @@ def __init__(self, account_transactions): state=transaction.get("state"), started_date=transaction.get("startedDate"), completed_date=transaction.get("completedDate"), - currency=transaction.get('currency'), - amount=transaction.get('amount'), + amount=Amount(revolut_amount=transaction.get('amount'), + currency=transaction.get('currency')), fee=transaction.get('fee'), description=transaction.get('description'), account_id=transaction.get('account').get('id') @@ -432,19 +429,22 @@ def __init__(self, account_transactions): def __len__(self): return len(self.list) - def csv(self, lang="fr"): + def csv(self, lang="fr", reverse=False): lang_is_fr = lang == "fr" if lang_is_fr: - csv_str = "Date-heure,Description,Montant,Devise" + csv_str = "Date-heure (DD/MM/YYYY HH:MM:ss);Description;Montant;Devise" + date_format = "%d/%m/%Y %H:%M:%S" else: - csv_str = "Date-time,Description,Amount,Currency" + csv_str = "Date-time (MM/DD/YYYY HH:MM:ss),Description,Amount,Currency" + date_format = "%m/%d/%Y %H:%M:%S" # Europe uses 'comma' as decimal separator, # so it can't be used as delimiter: delimiter = ";" if lang_is_fr else "," # Do not export declined or failed payments - for account_transaction in self.list: + transaction_list = list(reversed(self.list)) if reverse else self.list + for account_transaction in transaction_list: if account_transaction.state not in [ _TRANSACTION_DECLINED, _TRANSACTION_FAILED, @@ -452,10 +452,10 @@ def csv(self, lang="fr"): ]: csv_str += "\n" + delimiter.join(( - account_transaction.get_datetime__str(), + account_transaction.get_datetime__str(date_format), account_transaction.get_description(), account_transaction.get_amount__str(), - account_transaction.currency + account_transaction.amount.currency )) return csv_str.replace(".", ",") if lang_is_fr else csv_str From dc158160b00af92f578691ce6ddeafb466e5a67c Mon Sep 17 00:00:00 2001 From: Thibault Ducret Date: Wed, 30 Oct 2019 07:37:28 +0100 Subject: [PATCH 4/4] Add a --reverse option to display the transactions (latest first) --- README.md | 1 + revolut_transactions.py | 11 ++++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index cbdbc03..694fbb9 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ Options: separator -t, --from_date [%Y-%m-%d] transactions lookback date in YYYY-MM-DD format (ex: "2019-10-26"). Default 30 days back + -r, --reverse reverse the order of the transactions displayed --help Show this message and exit. ``` diff --git a/revolut_transactions.py b/revolut_transactions.py index bcaecb4..d0456a5 100644 --- a/revolut_transactions.py +++ b/revolut_transactions.py @@ -34,8 +34,13 @@ type=click.DateTime(formats=["%Y-%m-%d"]), help='transactions lookback date in YYYY-MM-DD format (ex: "2019-10-26"). Default 30 days back', default=(datetime.now()-timedelta(days=30)).strftime("%Y-%m-%d") - ) -def main(token, language, from_date): +) +@click.option( + '--reverse', '-r', + is_flag=True, + help='reverse the order of the transactions displayed', +) +def main(token, language, from_date, reverse): """ Get the account balances on Revolut """ if token is None: print("You don't seem to have a Revolut token. Use 'revolut_cli' to obtain one") @@ -43,7 +48,7 @@ def main(token, language, from_date): rev = Revolut(device_id=_CLI_DEVICE_ID, token=token) account_transactions = rev.get_account_transactions(from_date) - print(account_transactions.csv(lang=language)) + print(account_transactions.csv(lang=language, reverse=reverse)) if __name__ == "__main__": main()