Skip to content

Instantly share code, notes, and snippets.

@xkikeg
Last active January 1, 2024 20:32
Show Gist options
  • Save xkikeg/74f923e299180ef4e8141a270cb2dbe3 to your computer and use it in GitHub Desktop.
Save xkikeg/74f923e299180ef4e8141a270cb2dbe3 to your computer and use it in GitHub Desktop.
Extract Ledger-cli format string out of Revolut transactions page
// ==UserScript==
// @name Revolut.com to ledger
// @version 1.2
// @grant GM.setClipboard
// @require https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js
// @require https://gist.github.com/raw/2625891/waitForKeyElements.js
// @match https://app.revolut.com/transactions*
// ==/UserScript==
function handleTransactionView(node) {
let r = $('<input type="button" value="copy ledger"/>');
let date = new Date(node.data('group'));
console.debug('date: ' + prettyDate(date));
node.find('div:first-child > span:first-child').first().append(r);
r.on('click', function() {
var result = [];
var targets = node.find('button');
if (targets.length == 0) {
console.warning('WARNING: no entries found on ' + date);
}
targets.each(function(unused) {
result.push(convertTransaction($(this), new Date(date)));
});
result.reverse();
console.debug('retrieved ' + result.length + ' items');
if (result.length > 0) {
let r = result.join('\n');
GM.setClipboard(r);
}
});
}
function convertTransaction(node, date) {
let txn = Txn.fromNode(node, date);
if (!txn.amount) {
return '; ignore ' + prettyDate(txn.date) + ' ' + txn.title + '\n';
}
let matchers = [
[/Sold .* to .*/, convertSoldCurrency],
[/へ売却済み*/, convertSoldCurrency],
[/Bought .* with .*/, convertBoughtCurrency],
[/で購入しました*/, convertBoughtCurrency],
[/To .*/, convertSendTo],
[/Payment from .*/, convertPaymentFrom],
[/Money added via.*/, convertMoneyAdded],
];
for (const [p, handler] of matchers) {
let m = p.exec(txn.title);
if (m) {
return handler(txn, m);
}
}
console.debug('regular txn');
return convertRegularTxn(txn);
}
function convertSoldCurrency(txn, match) {
txn.addPost(new Post('Assets:Banks:Revolut', txn.converted));
txn.addPost(new Post('Assets:Banks:Revolut', txn.amount));
return txn.print();
}
function convertBoughtCurrency(txn, match) {
// Since the conversion is already covered in sold event,
// we don't have to / must not emit the transaction.
return [
'; ' + prettyDate(txn.date) + ' ' + txn.title,
'; ' + txn.amount.print() + ' ' + txn.converted.print(),
].join('\n') + '\n';
}
function convertSendTo(txn, match) {
let a = txn.converted != null ? txn.converted : txn.amount;
txn.addPost(new Post('Assets:Wire:???', a.negate()));
txn.addPost(new Post('Assets:Banks:Revolut', txn.amount));
return txn.print();
}
function convertPaymentFrom(txn, match) {
txn.addPost(new Post('Assets:Banks:Revolut', txn.amount));
txn.addPost(new Post('Assets:Wire:Revolut', txn.amount.negate()));
return txn.print();
}
function convertMoneyAdded(txn, match) {
txn.addPost(new Post('Assets:Banks:Revolut', txn.amount));
txn.addPost(new Post('Assets:Wire:Revolut', txn.amount.negate()));
return txn.print();
}
function convertRegularTxn(txn) {
let a = txn.converted != null ? txn.converted : txn.amount;
txn.addPost(new Post('Expenses:???', a.negate()));
txn.addPost(new Post('Assets:Banks:Revolut', txn.amount));
return txn.print();
}
class Txn {
constructor(date, txnid, title, amount, converted, datetime, comment) {
this.date=date;
this.txnid=txnid;
this.title=title;
this.amount=amount;
this.converted=converted;
this.datetime=datetime;
this.comment=comment;
this.posts = new Array();
}
static fromNode(node, date) {
let txnid = node.data('transactionid');
let title = node.children('span:first-of-type').children('span:first-of-type').text();
let time_comment = node.children('span:first-of-type').children('span:nth-of-type(2)').text();
let tm = /(\d{1,2}):(\d{2}) ?(AM|PM)?(?: · (.*))?/.exec(time_comment);
var datetime, comment;
if (tm) {
var h = parseInt(tm[1], 10);
let m = parseInt(tm[2], 10);
if (tm[3] == 'PM') {
h += 12;
}
datetime = new Date(date);
datetime.setHours(h, m);
comment = tm[4];
}
if (!comment) {
comment = null;
}
let amount = parseAmount(node.find('span:nth-of-type(2) > span:first-of-type').text());
let converted = parseAmount(node.find('span:nth-of-type(2) > span:nth-of-type(2)').text());
return new Txn(date, txnid, title, amount, converted, datetime, comment);
}
addPost(post) {
this.posts.push(post);
}
print() {
var res = [prettyDate(this.date) + ' * (' + this.txnid + ') ' + this.title];
res.push(' ; datetime:: ' + prettyDatetime(this.datetime));
for (const p of this.posts) {
res.push(p.print());
}
return res.join('\n') + '\n';
}
}
class Post {
constructor(account, amount, converted=null) {
this.account = account;
this.amount = amount;
this.converted = converted;
}
print() {
var res = " " + this.account + " " + this.amount.print();
if (this.converted != null) {
res += " @@ " + this.converted.print();
}
return res;
}
}
function prettyDate(x) {
return x.getFullYear() + '/' + (x.getMonth()+1+'').padStart(2, '0') + '/' + (x.getDate()+'').padStart(2, '0');
}
function prettyDatetime(x) {
return prettyDate(x) + ' ' + (''+x.getHours()).padStart(2, '0') + ':' + (''+x.getMinutes()).padStart(2, '0');
}
class Amount {
constructor(value, commodity) {
this.value = value;
this.commodity = commodity;
}
negate() {
let value = this.value[0] == '-' ? this.value.substr(1) : '-' + this.value;
return new Amount(value, this.commodity);
}
print() {
return this.value + ' ' + this.commodity;
}
}
function parseAmount(x) {
if (x == '') {
return null;
}
let match = /(?:([+-]) )?([^ 0-9]+) ?([0-9,]+\.[0-9]+)/.exec(x);
var commodity = match[2].trim();
if (commodity == '$') {
commodity = 'USD';
} else if (commodity == '€') {
commodity = 'EUR';
} else if (commodity == '£') {
commodity = 'GBP';
} else if (commodity == '¥') {
commodity = 'JPY';
}
var value = match[3];
if (match[1] == '-') {
value = '-' + value;
}
if (commodity == 'JPY' && value.endsWith('.00')) {
value = value.substring(0, value.length - 3);
}
return new Amount(value, commodity);
}
window.addEventListener('load', init, false);
function init() {
waitForKeyElements("[role='transactions-group']", handleTransactionView);
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment