Yodlee Virtual Subaccounts

Adds virtual subaccounts to Yodlee Moneycenter

  1. // ==UserScript==
  2. // @name Yodlee Virtual Subaccounts
  3. // @namespace http://www.arthaey.com
  4. // @description Adds virtual subaccounts to Yodlee Moneycenter
  5. // @include https://moneycenter.yodlee.com/moneycenter/accountSummary.moneycenter.do*
  6. // @include https://moneycenter.yodlee.com/moneycenter/networth.moneycenter.do*
  7. // @include https://moneycenter.yodlee.com/moneycenter/dashboard.moneycenter.do*
  8. // @version 1.3
  9. //
  10. // Backed up from http://userscripts.org/scripts/review/11674
  11. // Last updated on 2007-09-19
  12. // ==/UserScript==
  13.  
  14. /* HOW TO USE:
  15. *
  16. * By setting an account's caption/description with a specially formatted string,
  17. * you can have virtual subaccounts. The string format is:
  18. *
  19. * My First Subaccount XX% $X,XXX.XX max;
  20. * | | | |
  21. * '-> name | | `-> optional (see below)
  22. * | |
  23. * | `-> dollar goal
  24. * |
  25. * `-> percentage
  26. *
  27. * You can have have multiple subaccounts. You need to have either a percentage
  28. * or a specific dollar goal; you may have both, but that's optional. The
  29. * percentage limits the value of the subaccount to a fraction of the real
  30. * account's value. The dollar goal limits the value of the subaccount to a
  31. * set dollar amount.
  32. *
  33. * The "max" is optional. Without it, the subaccount's value will be the
  34. * minimum of its percentage and goal. With it, the value will be the maximum.
  35. *
  36. * The value of the real account is distributed among the virtual subaccounts
  37. * in order, trying to completely satisfy the first subaccount before
  38. * distributing any funds to the second subaccount, and so on. Keep this is
  39. * mind when you define the order of your subaccounts, especially if you use
  40. * the "max" setting.
  41. *
  42. * EXAMPLE:
  43. *
  44. * Emergency Fund 50% $12,000; Laptop 25% $2000 max; Travel 20%; Other $100;
  45. *
  46. * CHANGELOG:
  47. * v1.3 - added subaccounts to the Dashboard page's Net Worth module
  48. * v1.2 - added subaccounts to the Net Worth Statement page
  49. * v1.1 - updated to work with Yodlee 8.0
  50. * v1.0 - initial release (subaccounts only on the Accounts Summary page)
  51. *
  52. */
  53.  
  54. window.addEventListener("load", function(){
  55.  
  56. var DEBUG = false;
  57.  
  58. /* UTILITY FUNCTIONS *****************************************************/
  59.  
  60. function debug(msg) {
  61. if (DEBUG) console.log("DEBUG: " + msg);
  62. }
  63.  
  64. /* Finds elements whose id matches the given regexp. */
  65. function getElementsByIdRegExp(regex, restrict) {
  66. var matchingElements = [];
  67.  
  68. if (!regex) return matchingElements;
  69. //if (restrict != "id" && restrict != "class") restrict = null;
  70.  
  71. var elements = document.getElementsByTagName("*");
  72. var element;
  73.  
  74. for (var i = 0; i < elements.length; i++) {
  75. element = elements[i];
  76. if (element.id.match(regex)) {
  77. matchingElements.push(element);
  78. }
  79. }
  80.  
  81. return matchingElements;
  82. }
  83.  
  84. /*
  85. * Written by Jonathan Snook, http://www.snook.ca/jonathan
  86. * Add-ons by Robert Nyman, http://www.robertnyman.com
  87. */
  88. function getElementsByClassName(className, tag, elm){
  89. var testClass = new RegExp("(^|\\s)" + className + "(\\s|$)");
  90. var tag = tag || "*";
  91. var elm = elm || document;
  92. var elements = (tag == "*" && elm.all)? elm.all : elm.getElementsByTagName(tag);
  93. var returnElements = [];
  94. var current;
  95. var length = elements.length;
  96. for(var i=0; i<length; i++){
  97. current = elements[i];
  98. if(testClass.test(current.className)){
  99. returnElements.push(current);
  100. }
  101. }
  102. return returnElements;
  103. }
  104.  
  105. // returns cents
  106. function stringToMoney(moneyStr) {
  107. if (!moneyStr) return null;
  108.  
  109. // convert to string, if necessary
  110. if (!moneyStr.replace) {
  111. moneyStr = moneyStr.toString();
  112. }
  113.  
  114. // remove any non-digit characters, excepting "."
  115. moneyStr = moneyStr.replace(/[^0-9.]/g, '');
  116.  
  117. // add cents to even dollar amounts
  118. if (!moneyStr.match(/[.]/)) {
  119. moneyStr += ".00";
  120. }
  121.  
  122. // convert to an integer amount of cents
  123. return Math.round(parseFloat(moneyStr) * 100);
  124. }
  125.  
  126. function moneyToString(money) {
  127. var cents = Math.round(money);
  128. var even = (cents % 100 == 0);
  129. var moneyStr = new Number(Math.round(cents) / 100).toLocaleString();
  130. return "$" + moneyStr + (even ? ".00" : "");
  131. }
  132.  
  133. String.prototype.trim = function() { return this.replace(/^\s+|\s+$/, ''); };
  134.  
  135. /* VIRTUAL SUBACCOUNTS OBJECT ********************************************/
  136.  
  137. const Site = new Object();
  138.  
  139. Site.BASE_URL = "https://moneycenter.yodlee.com/moneycenter/";
  140. Site.ACCOUNT_SUMMARY = Site.BASE_URL + "accountSummary.moneycenter.do";
  141. Site.NET_WORTH = Site.BASE_URL + "networth.moneycenter.do";
  142. Site.DASHBOARD = Site.BASE_URL + "dashboard.moneycenter.do";
  143.  
  144. Site.page = null;
  145.  
  146. Site.determinePage = function() {
  147. var url = window.location.href;
  148. var pages = [Site.ACCOUNT_SUMMARY, Site.NET_WORTH, Site.DASHBOARD];
  149. for (var i in pages) {
  150. var page = pages[i];
  151. if (url.match('^' + page)) {
  152. Site.page = page;
  153. break;
  154. }
  155. }
  156. debug("Site.page == " + Site.page);
  157. }
  158.  
  159. const Subaccounts = new Object();
  160.  
  161. Subaccounts.all = [];
  162.  
  163. Subaccounts.parseCaption = function(fullCaption, parentAccount) {
  164. var name, percent, goal;
  165. name = percent = goal = null;
  166.  
  167. fullCaption = fullCaption.trim();
  168. var captions = fullCaption.split(";");
  169. var matches, subaccount;
  170. var thisParse = [];
  171.  
  172. for (var i = 0; i < captions.length; i++) {
  173. matches = captions[i].match(SUBACCOUNTS);
  174. if (matches) {
  175. name = matches[NAME_NDX];
  176.  
  177. goal = matches[GOAL_NDX] || matches[GOAL_ONLY_NDX];
  178. if (goal) { goal = stringToMoney(goal); }
  179.  
  180. percent = matches[PERCENT_NDX] || matches[PERCENT_ONLY_NDX];
  181. if (percent) { percent /= 100; }
  182.  
  183. subaccount = new Subaccount(name, percent, goal, parentAccount);
  184.  
  185. modifiers = matches[MODIFIERS_NDX];
  186. if (modifiers == "max") { subaccount.max = true };
  187.  
  188. thisParse.push(subaccount);
  189. this.all.push(subaccount);
  190. }
  191. }
  192.  
  193. return thisParse;
  194. };
  195.  
  196. function Subaccount(name, percent, goal, parentAccount) {
  197. this.name = name;
  198. this.percent = percent;
  199. this.goal = goal;
  200. this.parentAccount = parentAccount;
  201. this.amount = null;
  202. this.max = false;
  203.  
  204. this.toString = function() {
  205. return this.name + " " + moneyToString(this.amount);
  206. };
  207.  
  208. this.settingsHTML = function() {
  209. var content;
  210. var html = document.createElement("span");
  211.  
  212. if (!this.percent && !this.goal) return html;
  213.  
  214. if (this.percent) {
  215. var percent = (this.percent * 100) + "%";
  216. if (this.amount >= this.percent * this.parentAccount.amount) {
  217. content = document.createElement("b");
  218. content.appendChild(document.createTextNode(percent));
  219. }
  220. else {
  221. content = document.createTextNode(percent);
  222. }
  223. html.appendChild(content);
  224. }
  225.  
  226. if (this.percent && this.goal) {
  227. content = (this.max ? " or " : " only ");
  228. html.appendChild(document.createTextNode(content));
  229. }
  230.  
  231. if (this.goal) {
  232. if (this.amount >= this.goal) {
  233. content = document.createElement("b");
  234. content.appendChild(document.createTextNode(
  235. moneyToString(this.goal)));
  236. html.appendChild(document.createTextNode("up to "));
  237. html.appendChild(content);
  238. }
  239. else {
  240. var goal = "up to " + moneyToString(this.goal);
  241. html.appendChild(document.createTextNode(goal));
  242. }
  243. }
  244.  
  245. return html;
  246. };
  247. }
  248.  
  249. const Accounts = new Object();
  250.  
  251. Accounts.parseAmount = function(tableRow) {
  252. var cellNdx;
  253. switch (Site.page) {
  254. case Site.ACCOUNT_SUMMARY:
  255. cellNdx = 2;
  256. break;
  257. case Site.NET_WORTH:
  258. case Site.DASHBOARD:
  259. cellNdx = 1;
  260. break;
  261. default:
  262. return null;
  263. }
  264. var amountTD = tableRow.getElementsByTagName("td")[cellNdx];
  265. return stringToMoney(amountTD.textContent);
  266. };
  267.  
  268. function Account(name) {
  269. this.name = name;
  270. this.subaccounts = null;
  271. this.captionDiv = null;
  272. this.amount = 0;
  273. this.amountUnassigned = 0;
  274.  
  275. this.toString = function() {
  276. return this.name + " (" + this.subaccounts.length + " subaccounts)";
  277. };
  278.  
  279. this.addSubaccountRows = function() {
  280. if (this.captionDiv == null) return;
  281.  
  282. // create a fake subaccount for all unassigned, "leftover" money
  283. this.distributeFunds();
  284. var unassigned = new Subaccount("Unassigned");
  285. unassigned.amount = this.amountUnassigned;
  286. var subaccounts = Array.concat(this.subaccounts, [unassigned]);
  287.  
  288. var subaccount, row, nameCell, amountCell, settingsCell;
  289. var subaccountTable = document.createElement("table");
  290.  
  291. // create table headers
  292. row = document.createElement("tr");
  293. nameHeader = document.createElement("th");
  294. amountHeader = document.createElement("th");
  295. settingsHeader = document.createElement("th");
  296.  
  297. nameHeader.appendChild(document.createTextNode("Subaccount"));
  298. amountHeader.appendChild(document.createTextNode("Value"));
  299. settingsHeader.appendChild(document.createTextNode("Settings"));
  300.  
  301. row.appendChild(nameHeader);
  302. row.appendChild(amountHeader);
  303. row.appendChild(settingsHeader);
  304. subaccountTable.appendChild(row);
  305.  
  306. // create row for each subaccount
  307. for (var i = 0; i < subaccounts.length; i++) {
  308. subaccount = subaccounts[i];
  309. row = document.createElement("tr");
  310. nameCell = document.createElement("td");
  311. amountCell = document.createElement("td");
  312. settingsCell = document.createElement("td");
  313.  
  314. nameCell.appendChild(document.createTextNode(subaccount.name));
  315. amountCell.appendChild(document.createTextNode(
  316. moneyToString(subaccount.amount)));
  317. settingsCell.appendChild(subaccount.settingsHTML());
  318.  
  319. nameCell.style.width = "100%";
  320. if (subaccount.name == "Unassigned") {
  321. nameCell.style.fontStyle = "italic";
  322. }
  323. amountCell.style.textAlign = "right";
  324. amountCell.style.whiteSpace = "nowrap";
  325. settingsCell.style.whiteSpace = "nowrap";
  326.  
  327. row.appendChild(nameCell);
  328. row.appendChild(amountCell);
  329. row.appendChild(settingsCell);
  330. subaccountTable.appendChild(row);
  331. }
  332.  
  333. // add new subaccounts table and remove the original caption
  334. this.captionDiv.parentNode.insertBefore(
  335. subaccountTable, this.captionDiv.nextSibling);
  336. this.captionDiv.parentNode.removeChild(this.captionDiv);
  337. this.captionDiv = null;
  338. };
  339.  
  340. this.distributeFunds = function() {
  341. var amountLeft = this.amount;
  342. var subaccount, amount;
  343.  
  344. for (var i = 0; i < this.subaccounts.length; i++) {
  345. subaccount = this.subaccounts[i];
  346. amount = null;
  347.  
  348. if (subaccount.max) {
  349. var want = Math.max(subaccount.percent * this.amount, subaccount.goal);
  350. amount = Math.min(want, amountLeft);
  351. }
  352. else {
  353. if (subaccount.percent) {
  354. amount = Math.min(subaccount.percent * this.amount, amountLeft);
  355. }
  356. if (subaccount.goal) {
  357. amount = Math.min(subaccount.goal,
  358. (subaccount.percent ? amount : amountLeft));
  359. }
  360. }
  361.  
  362. amountLeft -= amount;
  363. subaccount.amount = amount;
  364. }
  365.  
  366. this.amountUnassigned = amountLeft;
  367. };
  368. }
  369.  
  370. /* VIRTUAL SUBACCOUNTS REGULAR EXPRESSIONS *******************************/
  371.  
  372. // name (maybe multi-word), not followed by '%', followed by whitespace
  373. const NAME = "(\\w+(?:\\s+\\w+)*)(?!%)(?=\\s+)";
  374.  
  375. // numbers, followed by '%'
  376. const PERCENT = "(\\d+)(?:%)";
  377.  
  378. // '$', followed by numbers (maybe comma-separated), maybe with cents
  379. const GOAL = "[$]((?:\\d{1,3},?)*\\d{1,3}(?:[.]\\d{2})?)";
  380.  
  381. // both PERCENT and GOAL, or just one or the other
  382. const PERCENT_AND_OR_GOAL = "(?:" + PERCENT + "\\s+" + GOAL + "|" +
  383. PERCENT + "|" + GOAL + ")";
  384.  
  385. const MODIFIERS = "(max)?";
  386.  
  387. // optional whitespace
  388. const WS = "\\s*";
  389.  
  390. // subaccount is "NAME PERCENT GOAL"; one of PERCENT or GOAL can be optional
  391. const SUBACCOUNT = WS + NAME + WS + PERCENT_AND_OR_GOAL + WS + MODIFIERS + WS;
  392.  
  393. // whole string is a series of subaccounts, separated by semicolons or EOL
  394. const SUBACCOUNTS = "^(?:" + SUBACCOUNT + "(?:;|$))+";
  395.  
  396. // indices for array returned by match(SUBACCOUNT)
  397. const ENTIRE_MATCH_NDX = 0;
  398. const NAME_NDX = 1;
  399. const PERCENT_NDX = 2;
  400. const GOAL_NDX = 3;
  401. const PERCENT_ONLY_NDX = 4;
  402. const GOAL_ONLY_NDX = 5;
  403. const MODIFIERS_NDX = 6;
  404.  
  405. /* VIRTUAL SUBACCOUNTS FUNCTIONS *****************************************/
  406.  
  407. var MAX_TRIES = 3;
  408. var numTries = 0;
  409.  
  410. function createSubaccounts() {
  411. var accountsWithVirtualSubaccounts = [];
  412.  
  413. var accountsTable;
  414. switch (Site.page) {
  415. case Site.DASHBOARD:
  416. var div = document.getElementById("net_worth_module_dynamic");
  417. accountsTable = getElementsByClassName("datatable", "table", div)[0];
  418. // the dynamic Net Worth module on the dashboard can take a
  419. // while to load, so we'll try again later.
  420. if (!accountsTable && numTries++ < MAX_TRIES) {
  421. debug("Expected table not found yet. Will try to create subaccounts " +
  422. (MAX_TRIES - numTries) + " more times...");
  423. window.setTimeout(doVirtualSubaccounts, 2000);
  424. return;
  425. }
  426. break;
  427. case Site.ACCOUNT_SUMMARY:
  428. case Site.DASHBOARD:
  429. accountsTable = document.getElementById("accntsummary");
  430. break;
  431. default:
  432. return;
  433. }
  434.  
  435. var accounts = getElementsByClassName("lcell", "td", accountsTable);
  436. if (!accounts) return;
  437. debug(accounts.length + " accounts found, total");
  438.  
  439. var accountTD, name, captionDivs, caption, subaccount;
  440. var pattern = new RegExp(SUBACCOUNTS);
  441.  
  442. for (var i = 0; i < accounts.length; i++) {
  443. accountTD = accounts[i];
  444. name = parseAccountName(accountTD);
  445. debug("Account " + i + " name: " + name);
  446. captionDivs = getElementsByClassName("caption", "span", accountTD);
  447.  
  448. if (captionDivs.length > 0) {
  449. // if the caption is formatted as required, assume it's meant
  450. // to be a virtual subaccount used by this Greasemonkey script
  451. caption = captionDivs[0].innerHTML.trim();
  452. debug("Account " + i + " caption: " + caption);
  453. if (pattern.test(caption)) {
  454. account = new Account(name);
  455. account.captionDiv = captionDivs[0];
  456. account.amount = Accounts.parseAmount(accountTD.parentNode);
  457. // if we couldn't parse the amount, then skip this account,
  458. // even though its description matches the correct format
  459. if (!account.amount) {
  460. debug("Could not create subaccounts for this account.");
  461. continue;
  462. }
  463. account.subaccounts = Subaccounts.parseCaption(caption, account);
  464. accountsWithVirtualSubaccounts.push(account);
  465. }
  466. }
  467. }
  468.  
  469. return accountsWithVirtualSubaccounts;
  470. }
  471.  
  472. function parseAccountName(accountTD) {
  473. var links = accountTD.getElementsByTagName("a");
  474.  
  475. if (!links) return null;
  476. var nameLink = links[0];
  477. if (!nameLink) return null;
  478.  
  479. return nameLink.textContent.trim().replace(/(\n|\r)+/g, '');
  480. }
  481.  
  482. function prettifyCaptions(captionDiv) {
  483. captionDiv.innerHTML = null;
  484. }
  485.  
  486. function doVirtualSubaccounts() {
  487. var accounts = createSubaccounts();
  488. if (!accounts) return;
  489.  
  490. for (var i = 0; i < accounts.length; i++ ) {
  491. accounts[i].addSubaccountRows();
  492. }
  493. }
  494.  
  495. Site.determinePage();
  496. doVirtualSubaccounts();
  497.  
  498. }, true);