Last active
January 1, 2024 20:32
-
-
Save xkikeg/74f923e299180ef4e8141a270cb2dbe3 to your computer and use it in GitHub Desktop.
Extract Ledger-cli format string out of Revolut transactions page
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==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