// ==UserScript==
// @name Yodlee Virtual Subaccounts
// @namespace http://www.arthaey.com
// @description Adds virtual subaccounts to Yodlee Moneycenter
// @include https://moneycenter.yodlee.com/moneycenter/accountSummary.moneycenter.do*
// @include https://moneycenter.yodlee.com/moneycenter/networth.moneycenter.do*
// @include https://moneycenter.yodlee.com/moneycenter/dashboard.moneycenter.do*
// @version 1.3
//
// Backed up from http://userscripts.org/scripts/review/11674
// Last updated on 2007-09-19
// ==/UserScript==
/* HOW TO USE:
*
* By setting an account's caption/description with a specially formatted string,
* you can have virtual subaccounts. The string format is:
*
* My First Subaccount XX% $X,XXX.XX max;
* | | | |
* '-> name | | `-> optional (see below)
* | |
* | `-> dollar goal
* |
* `-> percentage
*
* You can have have multiple subaccounts. You need to have either a percentage
* or a specific dollar goal; you may have both, but that's optional. The
* percentage limits the value of the subaccount to a fraction of the real
* account's value. The dollar goal limits the value of the subaccount to a
* set dollar amount.
*
* The "max" is optional. Without it, the subaccount's value will be the
* minimum of its percentage and goal. With it, the value will be the maximum.
*
* The value of the real account is distributed among the virtual subaccounts
* in order, trying to completely satisfy the first subaccount before
* distributing any funds to the second subaccount, and so on. Keep this is
* mind when you define the order of your subaccounts, especially if you use
* the "max" setting.
*
* EXAMPLE:
*
* Emergency Fund 50% $12,000; Laptop 25% $2000 max; Travel 20%; Other $100;
*
* CHANGELOG:
* v1.3 - added subaccounts to the Dashboard page's Net Worth module
* v1.2 - added subaccounts to the Net Worth Statement page
* v1.1 - updated to work with Yodlee 8.0
* v1.0 - initial release (subaccounts only on the Accounts Summary page)
*
*/
window.addEventListener("load", function(){
var DEBUG = false;
/* UTILITY FUNCTIONS *****************************************************/
function debug(msg) {
if (DEBUG) console.log("DEBUG: " + msg);
}
/* Finds elements whose id matches the given regexp. */
function getElementsByIdRegExp(regex, restrict) {
var matchingElements = [];
if (!regex) return matchingElements;
//if (restrict != "id" && restrict != "class") restrict = null;
var elements = document.getElementsByTagName("*");
var element;
for (var i = 0; i < elements.length; i++) {
element = elements[i];
if (element.id.match(regex)) {
matchingElements.push(element);
}
}
return matchingElements;
}
/*
* Written by Jonathan Snook, http://www.snook.ca/jonathan
* Add-ons by Robert Nyman, http://www.robertnyman.com
*/
function getElementsByClassName(className, tag, elm){
var testClass = new RegExp("(^|\\s)" + className + "(\\s|$)");
var tag = tag || "*";
var elm = elm || document;
var elements = (tag == "*" && elm.all)? elm.all : elm.getElementsByTagName(tag);
var returnElements = [];
var current;
var length = elements.length;
for(var i=0; i<length; i++){
current = elements[i];
if(testClass.test(current.className)){
returnElements.push(current);
}
}
return returnElements;
}
// returns cents
function stringToMoney(moneyStr) {
if (!moneyStr) return null;
// convert to string, if necessary
if (!moneyStr.replace) {
moneyStr = moneyStr.toString();
}
// remove any non-digit characters, excepting "."
moneyStr = moneyStr.replace(/[^0-9.]/g, '');
// add cents to even dollar amounts
if (!moneyStr.match(/[.]/)) {
moneyStr += ".00";
}
// convert to an integer amount of cents
return Math.round(parseFloat(moneyStr) * 100);
}
function moneyToString(money) {
var cents = Math.round(money);
var even = (cents % 100 == 0);
var moneyStr = new Number(Math.round(cents) / 100).toLocaleString();
return "$" + moneyStr + (even ? ".00" : "");
}
String.prototype.trim = function() { return this.replace(/^\s+|\s+$/, ''); };
/* VIRTUAL SUBACCOUNTS OBJECT ********************************************/
const Site = new Object();
Site.BASE_URL = "https://moneycenter.yodlee.com/moneycenter/";
Site.ACCOUNT_SUMMARY = Site.BASE_URL + "accountSummary.moneycenter.do";
Site.NET_WORTH = Site.BASE_URL + "networth.moneycenter.do";
Site.DASHBOARD = Site.BASE_URL + "dashboard.moneycenter.do";
Site.page = null;
Site.determinePage = function() {
var url = window.location.href;
var pages = [Site.ACCOUNT_SUMMARY, Site.NET_WORTH, Site.DASHBOARD];
for (var i in pages) {
var page = pages[i];
if (url.match('^' + page)) {
Site.page = page;
break;
}
}
debug("Site.page == " + Site.page);
}
const Subaccounts = new Object();
Subaccounts.all = [];
Subaccounts.parseCaption = function(fullCaption, parentAccount) {
var name, percent, goal;
name = percent = goal = null;
fullCaption = fullCaption.trim();
var captions = fullCaption.split(";");
var matches, subaccount;
var thisParse = [];
for (var i = 0; i < captions.length; i++) {
matches = captions[i].match(SUBACCOUNTS);
if (matches) {
name = matches[NAME_NDX];
goal = matches[GOAL_NDX] || matches[GOAL_ONLY_NDX];
if (goal) { goal = stringToMoney(goal); }
percent = matches[PERCENT_NDX] || matches[PERCENT_ONLY_NDX];
if (percent) { percent /= 100; }
subaccount = new Subaccount(name, percent, goal, parentAccount);
modifiers = matches[MODIFIERS_NDX];
if (modifiers == "max") { subaccount.max = true };
thisParse.push(subaccount);
this.all.push(subaccount);
}
}
return thisParse;
};
function Subaccount(name, percent, goal, parentAccount) {
this.name = name;
this.percent = percent;
this.goal = goal;
this.parentAccount = parentAccount;
this.amount = null;
this.max = false;
this.toString = function() {
return this.name + " " + moneyToString(this.amount);
};
this.settingsHTML = function() {
var content;
var html = document.createElement("span");
if (!this.percent && !this.goal) return html;
if (this.percent) {
var percent = (this.percent * 100) + "%";
if (this.amount >= this.percent * this.parentAccount.amount) {
content = document.createElement("b");
content.appendChild(document.createTextNode(percent));
}
else {
content = document.createTextNode(percent);
}
html.appendChild(content);
}
if (this.percent && this.goal) {
content = (this.max ? " or " : " only ");
html.appendChild(document.createTextNode(content));
}
if (this.goal) {
if (this.amount >= this.goal) {
content = document.createElement("b");
content.appendChild(document.createTextNode(
moneyToString(this.goal)));
html.appendChild(document.createTextNode("up to "));
html.appendChild(content);
}
else {
var goal = "up to " + moneyToString(this.goal);
html.appendChild(document.createTextNode(goal));
}
}
return html;
};
}
const Accounts = new Object();
Accounts.parseAmount = function(tableRow) {
var cellNdx;
switch (Site.page) {
case Site.ACCOUNT_SUMMARY:
cellNdx = 2;
break;
case Site.NET_WORTH:
case Site.DASHBOARD:
cellNdx = 1;
break;
default:
return null;
}
var amountTD = tableRow.getElementsByTagName("td")[cellNdx];
return stringToMoney(amountTD.textContent);
};
function Account(name) {
this.name = name;
this.subaccounts = null;
this.captionDiv = null;
this.amount = 0;
this.amountUnassigned = 0;
this.toString = function() {
return this.name + " (" + this.subaccounts.length + " subaccounts)";
};
this.addSubaccountRows = function() {
if (this.captionDiv == null) return;
// create a fake subaccount for all unassigned, "leftover" money
this.distributeFunds();
var unassigned = new Subaccount("Unassigned");
unassigned.amount = this.amountUnassigned;
var subaccounts = Array.concat(this.subaccounts, [unassigned]);
var subaccount, row, nameCell, amountCell, settingsCell;
var subaccountTable = document.createElement("table");
// create table headers
row = document.createElement("tr");
nameHeader = document.createElement("th");
amountHeader = document.createElement("th");
settingsHeader = document.createElement("th");
nameHeader.appendChild(document.createTextNode("Subaccount"));
amountHeader.appendChild(document.createTextNode("Value"));
settingsHeader.appendChild(document.createTextNode("Settings"));
row.appendChild(nameHeader);
row.appendChild(amountHeader);
row.appendChild(settingsHeader);
subaccountTable.appendChild(row);
// create row for each subaccount
for (var i = 0; i < subaccounts.length; i++) {
subaccount = subaccounts[i];
row = document.createElement("tr");
nameCell = document.createElement("td");
amountCell = document.createElement("td");
settingsCell = document.createElement("td");
nameCell.appendChild(document.createTextNode(subaccount.name));
amountCell.appendChild(document.createTextNode(
moneyToString(subaccount.amount)));
settingsCell.appendChild(subaccount.settingsHTML());
nameCell.style.width = "100%";
if (subaccount.name == "Unassigned") {
nameCell.style.fontStyle = "italic";
}
amountCell.style.textAlign = "right";
amountCell.style.whiteSpace = "nowrap";
settingsCell.style.whiteSpace = "nowrap";
row.appendChild(nameCell);
row.appendChild(amountCell);
row.appendChild(settingsCell);
subaccountTable.appendChild(row);
}
// add new subaccounts table and remove the original caption
this.captionDiv.parentNode.insertBefore(
subaccountTable, this.captionDiv.nextSibling);
this.captionDiv.parentNode.removeChild(this.captionDiv);
this.captionDiv = null;
};
this.distributeFunds = function() {
var amountLeft = this.amount;
var subaccount, amount;
for (var i = 0; i < this.subaccounts.length; i++) {
subaccount = this.subaccounts[i];
amount = null;
if (subaccount.max) {
var want = Math.max(subaccount.percent * this.amount, subaccount.goal);
amount = Math.min(want, amountLeft);
}
else {
if (subaccount.percent) {
amount = Math.min(subaccount.percent * this.amount, amountLeft);
}
if (subaccount.goal) {
amount = Math.min(subaccount.goal,
(subaccount.percent ? amount : amountLeft));
}
}
amountLeft -= amount;
subaccount.amount = amount;
}
this.amountUnassigned = amountLeft;
};
}
/* VIRTUAL SUBACCOUNTS REGULAR EXPRESSIONS *******************************/
// name (maybe multi-word), not followed by '%', followed by whitespace
const NAME = "(\\w+(?:\\s+\\w+)*)(?!%)(?=\\s+)";
// numbers, followed by '%'
const PERCENT = "(\\d+)(?:%)";
// '$', followed by numbers (maybe comma-separated), maybe with cents
const GOAL = "[$]((?:\\d{1,3},?)*\\d{1,3}(?:[.]\\d{2})?)";
// both PERCENT and GOAL, or just one or the other
const PERCENT_AND_OR_GOAL = "(?:" + PERCENT + "\\s+" + GOAL + "|" +
PERCENT + "|" + GOAL + ")";
const MODIFIERS = "(max)?";
// optional whitespace
const WS = "\\s*";
// subaccount is "NAME PERCENT GOAL"; one of PERCENT or GOAL can be optional
const SUBACCOUNT = WS + NAME + WS + PERCENT_AND_OR_GOAL + WS + MODIFIERS + WS;
// whole string is a series of subaccounts, separated by semicolons or EOL
const SUBACCOUNTS = "^(?:" + SUBACCOUNT + "(?:;|$))+";
// indices for array returned by match(SUBACCOUNT)
const ENTIRE_MATCH_NDX = 0;
const NAME_NDX = 1;
const PERCENT_NDX = 2;
const GOAL_NDX = 3;
const PERCENT_ONLY_NDX = 4;
const GOAL_ONLY_NDX = 5;
const MODIFIERS_NDX = 6;
/* VIRTUAL SUBACCOUNTS FUNCTIONS *****************************************/
var MAX_TRIES = 3;
var numTries = 0;
function createSubaccounts() {
var accountsWithVirtualSubaccounts = [];
var accountsTable;
switch (Site.page) {
case Site.DASHBOARD:
var div = document.getElementById("net_worth_module_dynamic");
accountsTable = getElementsByClassName("datatable", "table", div)[0];
// the dynamic Net Worth module on the dashboard can take a
// while to load, so we'll try again later.
if (!accountsTable && numTries++ < MAX_TRIES) {
debug("Expected table not found yet. Will try to create subaccounts " +
(MAX_TRIES - numTries) + " more times...");
window.setTimeout(doVirtualSubaccounts, 2000);
return;
}
break;
case Site.ACCOUNT_SUMMARY:
case Site.DASHBOARD:
accountsTable = document.getElementById("accntsummary");
break;
default:
return;
}
var accounts = getElementsByClassName("lcell", "td", accountsTable);
if (!accounts) return;
debug(accounts.length + " accounts found, total");
var accountTD, name, captionDivs, caption, subaccount;
var pattern = new RegExp(SUBACCOUNTS);
for (var i = 0; i < accounts.length; i++) {
accountTD = accounts[i];
name = parseAccountName(accountTD);
debug("Account " + i + " name: " + name);
captionDivs = getElementsByClassName("caption", "span", accountTD);
if (captionDivs.length > 0) {
// if the caption is formatted as required, assume it's meant
// to be a virtual subaccount used by this Greasemonkey script
caption = captionDivs[0].innerHTML.trim();
debug("Account " + i + " caption: " + caption);
if (pattern.test(caption)) {
account = new Account(name);
account.captionDiv = captionDivs[0];
account.amount = Accounts.parseAmount(accountTD.parentNode);
// if we couldn't parse the amount, then skip this account,
// even though its description matches the correct format
if (!account.amount) {
debug("Could not create subaccounts for this account.");
continue;
}
account.subaccounts = Subaccounts.parseCaption(caption, account);
accountsWithVirtualSubaccounts.push(account);
}
}
}
return accountsWithVirtualSubaccounts;
}
function parseAccountName(accountTD) {
var links = accountTD.getElementsByTagName("a");
if (!links) return null;
var nameLink = links[0];
if (!nameLink) return null;
return nameLink.textContent.trim().replace(/(\n|\r)+/g, '');
}
function prettifyCaptions(captionDiv) {
captionDiv.innerHTML = null;
}
function doVirtualSubaccounts() {
var accounts = createSubaccounts();
if (!accounts) return;
for (var i = 0; i < accounts.length; i++ ) {
accounts[i].addSubaccountRows();
}
}
Site.determinePage();
doVirtualSubaccounts();
}, true);