PHP/JavaScript webapp to analyse spending habits
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

data.php 7.2KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
  1. <?PHP
  2. // Description prefixes used to indicate types
  3. $types = array(
  4. 'SO - ' => 'Standing Order',
  5. 'DD - Hbos Card Services' => 'Internal Transfer',
  6. 'DD - ' => 'Direct Debit',
  7. 'CHQ - ' => 'Cheque',
  8. 'Bank Credit - ' => 'Bank Credit',
  9. 'Bill Payment - Hfx Credit Card' => 'Internal Transfer',
  10. 'Bill Payment - ' => 'Bill Payment',
  11. 'DC - ' => 'Debit Card',
  12. 'DC Cashback - ' => 'Debit Card Cashback',
  13. 'DC Refund - ' => 'Debit Card Refund',
  14. 'Link ATM - ' => 'Cash Withdrawal',
  15. 'ATM - ' => 'Cash Withdrawal',
  16. 'Faster Payment - ' => 'Transfer',
  17. 'PAYMENT REC\'D - THANK YOU' => 'Internal Transfer',
  18. 'DIRECT DEBIT THANK YOU' => 'Internal Transfer',
  19. );
  20. // Types where the real description should be discarded in favour
  21. // of the type name
  22. $genericTypes = array(
  23. 'Internal Transfer',
  24. 'Cash Withdrawal',
  25. 'Cheque',
  26. );
  27. // Custom user rules for grouping different descriptions
  28. $rules = array(
  29. '^(?i)tesco' => 'Tesco',
  30. '^(?i)(sacat )?sainsbury\'?s' => 'Sainsbury\'s',
  31. '^(?i)marks & spencer' => 'M&S',
  32. '^(?i)argos' => 'Argos',
  33. '^(?i)subway' => 'Subway',
  34. '^(?i)specsavers' => 'Specsavers',
  35. '^(?i)adsl24' => 'ADSL 24',
  36. '^(?i)foxtons' => 'Foxtons',
  37. '^(?i)(eve online|ccp games)' => 'CCP Games',
  38. '^(?i)nandos' => 'Nandos',
  39. '^(?i)pizza express' => 'Pizza Express',
  40. '^(?i)steam(games|powered)\.com' => 'Steam',
  41. '^(?i)spotify(\.com|subs|\s)' => 'Spotify',
  42. '^(?i)t-\s?mobile' => 'T-Mobile',
  43. '^(?i)TGI Friday\'s' => 'TGI Friday\'s',
  44. '^(?i)wh smith' => 'WH Smiths',
  45. '^(?i)Codeweavers' => 'Codeweavers',
  46. '^(?i)Cineworld' => 'Cineworld',
  47. '^(?i)123-reg\.co\.uk' => '123-reg.co.uk',
  48. '866-321-8851' => 'Amazon Kindle',
  49. '(?i)Amazon Digital Dwnlds\s*amazon.co.uk' => 'Amazon MP3',
  50. '^(?i)Ocado' => 'Ocado',
  51. );
  52. // Categories
  53. $categories = array(
  54. 'Groceries' => array('Tesco', 'Ocado', 'M&S'),
  55. 'Home expenses' => array('T-Mobile', 'Bt Group Plc', 'Edf Energy', 'Foxtons', 'ADSL 24', 'Lb Southwark', '(?i)0800 Repair'),
  56. 'Entertainment' => array('Amazon', 'Spotify', 'Cineworld', '(?i)sky payments', 'Steam', '(?i)play.com'),
  57. 'Online' => array('(?i)giganews', 'Shane', '(?i)^github'),
  58. 'Cash' => array('Cash Withdrawal'),
  59. 'Going out' => array('(?i)tayyab', '(?i)founders arms', 'Nandos', '(?i)all bar one', 'TGI Friday\'s', '(?i)^piccolino', 'EAT & DRINK', '(?i)www.urbanbite.com'),
  60. 'Transport' => array('(?i)ec mainline'),
  61. 'Health/Medical' => array('Specsavers'),
  62. '(Ignored)' => array('Internal Transfer'),
  63. );
  64. @include('data.local.php');
  65. if (!function_exists('parseStatementPart')) {
  66. // Formats part (one field) of a transaction
  67. function parseStatementPart($key, $value) {
  68. if ($key == 'Date') {
  69. $format = 'd/m/' . (strlen($value) == 8 ? 'y' : 'Y');
  70. return DateTime::createFromFormat($format, $value)->setTime(0, 0, 0);
  71. } else if ($key == 'Amount') {
  72. return (double) $value;
  73. }
  74. return $value;
  75. }
  76. }
  77. if (!function_exists('parseStatementLine')) {
  78. // Formats an entire transaction from a statement
  79. function parseStatementLine($line) {
  80. global $categories, $genericTypes, $types, $rules;
  81. if (!isset($line['Exchange']) || empty($line['Exchange'])) {
  82. if (preg_match('/^(.*?)\s*\((.*? @ RATE .*?)\)$/', $line['Description'], $m)) {
  83. $line['Description'] = $m[1];
  84. $line['Exchange'] = $m[2];
  85. }
  86. }
  87. if (!isset($line['Type']) || empty($line['Type'])) {
  88. foreach ($types as $prefix => $type) {
  89. if (strpos($line['Description'], $prefix) === 0) {
  90. $line['Type'] = $type;
  91. if (array_search($type, $genericTypes) === false) {
  92. $line['Description'] = substr($line['Description'], strlen($prefix));
  93. } else {
  94. $line['RawDescription'] = $line['Description'];
  95. $line['Description'] = $type;
  96. }
  97. break;
  98. }
  99. }
  100. }
  101. foreach ($rules as $regex => $replacement) {
  102. if (preg_match('(' . $regex . ')', $line['Description'])) {
  103. $line['RawDescription'] = $line['Description'];
  104. $line['Description'] = $replacement;
  105. }
  106. }
  107. if (!isset($line['Category']) || empty($line['Category'])) {
  108. foreach ($categories as $cat => $entries) {
  109. foreach ($entries as $regex) {
  110. if (preg_match('(' . $regex . ')', $line['Description'])) {
  111. $line['Category'] = $cat;
  112. break;
  113. }
  114. }
  115. }
  116. }
  117. return $line;
  118. }
  119. }
  120. if (!function_exists('loadStatements')) {
  121. // Loads statements from the specified directory
  122. function loadStatements($dir = 'Statements') {
  123. $results = array();
  124. foreach (glob($dir . '/*.csv') as $statement) {
  125. $fh = fopen($statement, 'r');
  126. $data = array();
  127. $headers = array_map('trim', fgetcsv($fh));
  128. while (!feof($fh)) {
  129. $line = parseStatementLine(array_combine($headers, array_map('parseStatementPart', $headers, array_map('trim', fgetcsv($fh)))));
  130. $data[] = $line;
  131. }
  132. fclose($fh);
  133. $results[basename($statement)] = $data;
  134. }
  135. return $results;
  136. }
  137. }
  138. $entries = array_reduce(loadStatements(), 'array_merge', array());
  139. usort($entries, function($a, $b) { return $a['Date']->getTimestamp() - $b['Date']->getTimestamp(); });
  140. $descs = array_unique(array_map(function($t) { return $t['Description']; }, $entries));
  141. sort($descs);
  142. $amounts = array();
  143. $rawmonths = array();
  144. $months = array();
  145. $bydesc = array();
  146. array_walk($entries, function($entry) use(&$months, &$bydesc, &$amounts, &$rawmonths) {
  147. $rawmonths[$entry['Date']->format('Y-m')][] = $entry;
  148. if (!isset($entry['Category']) || $entry['Category'] != '(Ignored)') {
  149. $amounts[$entry['Date']->format('Y-m')][$entry['Amount'] < 0 ? 'out' : 'in'] += $entry['Amount'];
  150. $months[$entry['Date']->format('Y-m')][$entry['Description']]['Count']++;
  151. $months[$entry['Date']->format('Y-m')][$entry['Description']]['Amount'] += $entry['Amount'];
  152. $bydesc[$entry['Description']]['Count']++;
  153. $bydesc[$entry['Description']]['Amount'] += $entry['Amount'];
  154. }
  155. });
  156. ksort($months);
  157. ksort($amounts);
  158. $monthsbydesc = array();
  159. array_walk(array_slice(array_reverse($months), 0, 6, true), function($monthentries, $month) use(&$monthsbydesc) {
  160. array_walk($monthentries, function($entry, $desc) use(&$monthsbydesc, $month) {
  161. $monthsbydesc[$desc][$month]['Count'] += $entry['Count'];
  162. $monthsbydesc[$desc][$month]['Amount'] += $entry['Amount'];
  163. });
  164. });
  165. $total = 0;
  166. array_walk($monthsbydesc, function($data, $desc) use(&$total) {
  167. $prob = count($data) / 6;
  168. $count = array_sum(array_map(function($x) { return $x['Count']; }, $data));
  169. $amount = array_sum(array_map(function($x) { return $x['Amount']; }, $data));
  170. $avgcount = $count / count($data);
  171. $avgamount = $amount / $count;
  172. $total += $prob * $avgcount * $avgamount;
  173. //echo "P($desc) = $prob, with avg of $avgcount trans/month, averaging $avgamount\n";
  174. });
  175. $transData = array(array(), array());
  176. array_walk($months, function($entries, $month) use(&$transData) {
  177. $ins = array_filter($entries, function($x) { return $x['Amount'] > 0; });
  178. $outs = array_filter($entries, function($x) { return $x['Amount'] < 0; });
  179. $totalin = array_sum(array_map(function($x) { return $x['Amount']; }, $ins));
  180. $totalout = array_sum(array_map(function($x) { return -1 * $x['Amount']; }, $outs));
  181. $time = strtotime($month . '-01') * 1000;
  182. $transData[0][] = array($time, $totalin);
  183. $transData[1][] = array($time, $totalout);
  184. });
  185. ?>
  186. var data = <?PHP echo json_encode($rawmonths); ?>;