scripting,automation,money
Tracking your money without breaking your banks TOS
How to get a near-realtime feed of transactions from your bank, without giving up your credentials or making them unhappy.
If you spend any amount of time trying to automate your life, then getting a feed of your transactions into your automation seems like a worthwhile step. This enabled me to keep track of transactions in a structured database, and keep a realtime view of how I was spending against my budget.
How not to do it
- Use a browser automation framework to scrape your banks website
- Reverse engineer your banks app/site and immitate their requests to get the data
- Use a 3rd-party app which does budgeting or itemization, and scrape that service/site
- They put your credentials at risk - Scripting a login into your bank implies providing your script (or worse, a 3rd-party app) with your login details. If your script/computer/the-3rd-party ever get compromised, expect your money to disappear.
- They violate your banks TOS - Going against your bank means incurring all the liability if things go wrong. Each to their own, but I want to stay on good terms with the people protecting my money.
The safer + TOS-friendly approach
Most banks (indeed Wells Fargo) have a 'transaction alert' feature, where they will send you an SMS/email when a transaction over a certain amount is made against a specific account. We leverage this feature to have the bank give us a heads up with each transaction, without compromising login credentials or breaching the terms-of-service.I did the following:
- Use Gmail filters to forward alert emails from Wells Fargo to my Nexus (think of it as a personal lambda) instance.
- Parse the emails to extract description, credit card suffix, date, and amount. Write this into a database.
- Match descriptions against hand-written rules to categorize them.
- Render everything in a pretty dashboard.
Email parsing
The following is is my lambda function to handle the forward alert emails.function contains(str, substr) {
return str.indexOf(substr) !== -1;
}
String.prototype.startsWith = function(search, pos) {
return this.substr(!pos || pos < 0 ? 0 : +pos, search.length) === search;
};
String.prototype.replaceAll = function(search, replacement) {
var target = this;
return target.split(search).join(replacement);
};
function findTrailingValue(lines, prefix) {
for (var i = 0; i < lines.length; i++) {
if (lines[i].startsWith(prefix)) {
return lines[i].substring(prefix.length).replace('\r', '');
}
}
return null;
}
function commitTransaction(acc, desc, amt, meta) {
var insert = datastore.insert("Transactions", {
account: ''+acc,
timestamp: Date.now(),
amount: amt,
description: desc,
category: '',
subcategory: '',
metadata: JSON.stringify(meta || {source: 'email'}),
});
if (insert.success) {
console.log("Successfully saved transaction: ", desc);
console.log("rowID: " + insert.rowID);
} else {
console.error("Insert failed.");
console.data(insert);
}
}
if (context.run_reason == "EMAIL") {
if (message && contains(message.subject, "Wells Fargo card purchase online")) {
var spl = message.text.replace('\r', '').split('\n');
var acc = 'wf-card-x'+findTrailingValue(spl, "Card ending in ");
var desc = findTrailingValue(spl, "Merchant details at ");
var amt = parseFloat(findTrailingValue(spl, "Purchase amount ").split(" ")[0]);
commitTransaction(acc, desc, amt);
} else if (message && contains(message.subject, "Wells Fargo card purchase exceeded preset amount")) {
var spl = message.text.replace('\r', '').split('\n');
var acc = 'wf-card-x'+findTrailingValue(spl, "Card ending in ");
var desc = findTrailingValue(spl, "Merchant details at ");
var amt = parseFloat(findTrailingValue(spl, "Purchase amount ").split(" ")[0]);
commitTransaction(acc, desc, amt, {source: 'email', card_present: true});
} else if (message && contains(message.subject, "Wells Fargo credit card purchase exceeded pre-set amount")) {
var spl = message.text.replace('\r', '').split('\n');
var acc = 'wf-card-x'+findTrailingValue(spl, "Credit card XXXX-XXXX-XXXX-");
var desc = findTrailingValue(spl, "Purchase location at ");
var amt = parseFloat(findTrailingValue(spl, "Purchase amount ").split(" ")[0]);
commitTransaction(acc, desc, amt, {source: 'email', card_present: true});
} else if (message && contains(message.subject, "Wells Fargo credit card online, phone, or mail purchase")) {
var spl = message.text.replace('\r', '').split('\n');
var acc = 'wf-card-x'+findTrailingValue(spl, "Credit card XXXX-XXXX-XXXX-");
var desc = findTrailingValue(spl, "Purchase method at ");
var amt = parseFloat(findTrailingValue(spl, "Purchase amount ").split(" ")[0]);
commitTransaction(acc, desc, amt, {source: 'email', card_present: true});
}
}
Rule-based categorization
In this step, another lambda function processes the database entries, and matches them against a structured set of categories + match rules. I can't show you the full code with expense_classes.json unfortunately; transaction descriptions are not anonymous enough to put on the internet.function categorizeTransactions() {
var q = datastore.query("Transactions", [{value: "", column: "category", condition: "=="}]);
if (!q.success)
throw "Datastore query failed";
var rules = JSON.parse(fs.read("/minifs/expense_classes.json"));
for (var i = 0; i < q.results.length; i++) {
applyTransactionRules(q.results[i], rules);
}
}