diff --git a/README.md b/README.md index 28648ba..694fbb9 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,35 @@ 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 + -r, --reverse reverse the order of the transactions displayed + --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 diff --git a/revolut/__init__.py b/revolut/__init__.py index 83ceaf7..1f587a0 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, + amount, + fee, + description, + account_id + ): + self.transactions_type = transactions_type + self.state = state + self.started_date = started_date + self.completed_date = completed_date + self.amount = amount + self.fee = fee + self.description = description + self.account_id = account_id + + def __str__(self): + return "{description}: {amount}".format( + description=self.description, + amount=str(self.amount) + ) + + 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 \ + else self.started_date + # Convert from timestamp to datetime + dt = datetime.fromtimestamp( + timestamp / 1000 + ) + dt_str = dt.strftime(date_format) + 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.real_amount) + + +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"), + 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') + ) + for transaction in self.raw_list + ] + + def __len__(self): + return len(self.list) + + def csv(self, lang="fr", reverse=False): + lang_is_fr = lang == "fr" + if lang_is_fr: + 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 (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 + 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, + _TRANSACTION_REVERTED + ]: + + csv_str += "\n" + delimiter.join(( + account_transaction.get_datetime__str(date_format), + account_transaction.get_description(), + account_transaction.get_amount__str(), + account_transaction.amount.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..d0456a5 --- /dev/null +++ b/revolut_transactions.py @@ -0,0 +1,54 @@ +#!/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") +) +@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") + 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, reverse=reverse)) + +if __name__ == "__main__": + main()