1
2
3
4
5
6(function (root, factory) {
7 if (typeof module === 'object' && module.exports) {
8 module.exports = factory();
9 } else {
10 root.Finance = factory();
11 }
12})(typeof self !== 'undefined' ? self : this, function () {
13 'use strict';
14
15 var CATEGORIES = [
16 'Groceries', 'Dining', 'Transport', 'Housing', 'Utilities',
17 'Entertainment', 'Health', 'Shopping', 'Income', 'Other'
18 ];
19
20
21 var DEFAULT_BUDGETS = {
22 Groceries: 500,
23 Dining: 250,
24 Transport: 160,
25 Housing: 1800,
26 Utilities: 220,
27 Entertainment: 150,
28 Health: 120,
29 Shopping: 200,
30 Other: 150
31 };
32
33 var DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
34
35 function isValidDate(str) {
36 var m = DATE_RE.exec(str);
37 if (!m) return false;
38 var y = +m[1], mo = +m[2], d = +m[3];
39 if (mo < 1 || mo > 12 || d < 1) return false;
40 var days = [31, (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0 ? 29 : 28,
41 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
42 return d <= days[mo - 1];
43 }
44
45 function validateTransaction(tx) {
46 var errors = [];
47 if (!tx || typeof tx !== 'object') {
48 return { ok: false, errors: ['not an object'] };
49 }
50 if (typeof tx.date !== 'string' || !isValidDate(tx.date)) {
51 errors.push('date must be a valid YYYY-MM-DD date');
52 }
53 if (typeof tx.description !== 'string' || tx.description.trim() === '') {
54 errors.push('description is required');
55 }
56 if (CATEGORIES.indexOf(tx.category) === -1) {
57 errors.push('unknown category "' + tx.category + '"');
58 }
59 var amt = Number(tx.amount);
60 if (typeof tx.amount === 'string' && tx.amount.trim() === '') amt = NaN;
61 if (!isFinite(amt) || amt === 0) {
62 errors.push('amount must be a non-zero number');
63 }
64 return { ok: errors.length === 0, errors: errors };
65 }
66
67 function normalize(tx) {
68 return {
69 id: tx.id,
70 date: tx.date,
71 description: tx.description.trim(),
72 category: tx.category,
73 amount: Math.round(Number(tx.amount) * 100) / 100
74 };
75 }
76
77 function nextId(txs) {
78 var max = 0;
79 for (var i = 0; i < txs.length; i++) {
80 if (typeof txs[i].id === 'number' && txs[i].id > max) max = txs[i].id;
81 }
82 return max + 1;
83 }
84
85
86 function addTransaction(txs, tx) {
87 var v = validateTransaction(tx);
88 if (!v.ok) throw new Error('invalid transaction: ' + v.errors.join('; '));
89 var withId = normalize(tx);
90 withId.id = nextId(txs);
91 return txs.concat([withId]);
92 }
93
94 function updateTransaction(txs, id, patch) {
95 var found = false;
96 var out = txs.map(function (t) {
97 if (t.id !== id) return t;
98 found = true;
99 var merged = {
100 id: t.id,
101 date: patch.date !== undefined ? patch.date : t.date,
102 description: patch.description !== undefined ? patch.description : t.description,
103 category: patch.category !== undefined ? patch.category : t.category,
104 amount: patch.amount !== undefined ? patch.amount : t.amount
105 };
106 var v = validateTransaction(merged);
107 if (!v.ok) throw new Error('invalid transaction: ' + v.errors.join('; '));
108 return normalize(merged);
109 });
110 if (!found) throw new Error('no transaction with id ' + id);
111 return out;
112 }
113
114 function deleteTransaction(txs, id) {
115 var out = txs.filter(function (t) { return t.id !== id; });
116 if (out.length === txs.length) throw new Error('no transaction with id ' + id);
117 return out;
118 }
119
120 function monthKey(date) {
121 return String(date).slice(0, 7);
122 }
123
124 function monthLabel(month) {
125 var names = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
126 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
127 var parts = month.split('-');
128 return names[+parts[1] - 1] + ' ' + parts[0];
129 }
130
131 function shiftMonth(month, delta) {
132 var parts = month.split('-');
133 var idx = +parts[0] * 12 + (+parts[1] - 1) + delta;
134 var y = Math.floor(idx / 12);
135 var m = (idx % 12 + 12) % 12 + 1;
136 return y + '-' + (m < 10 ? '0' + m : '' + m);
137 }
138
139
140 function monthRange(endMonth, count) {
141 var out = [];
142 for (var i = count - 1; i >= 0; i--) out.push(shiftMonth(endMonth, -i));
143 return out;
144 }
145
146
147 function monthlyTotals(txs) {
148 var map = {};
149 for (var i = 0; i < txs.length; i++) {
150 var t = txs[i];
151 var key = monthKey(t.date);
152 if (!map[key]) map[key] = { month: key, spent: 0, income: 0, net: 0 };
153 if (t.amount < 0) map[key].spent += -t.amount;
154 else map[key].income += t.amount;
155 map[key].net += t.amount;
156 }
157 Object.keys(map).forEach(function (k) {
158 map[k].spent = Math.round(map[k].spent * 100) / 100;
159 map[k].income = Math.round(map[k].income * 100) / 100;
160 map[k].net = Math.round(map[k].net * 100) / 100;
161 });
162 return map;
163 }
164
165
166 function monthlySeries(txs, endMonth, count) {
167 var totals = monthlyTotals(txs);
168 return monthRange(endMonth, count).map(function (m) {
169 return totals[m] || { month: m, spent: 0, income: 0, net: 0 };
170 });
171 }
172
173
174 function categoryTotals(txs, month) {
175 var out = {};
176 for (var i = 0; i < txs.length; i++) {
177 var t = txs[i];
178 if (t.amount >= 0) continue;
179 if (month && monthKey(t.date) !== month) continue;
180 out[t.category] = Math.round(((out[t.category] || 0) + -t.amount) * 100) / 100;
181 }
182 return out;
183 }
184
185
186 function budgetStatus(txs, budgets, month) {
187 var spentBy = categoryTotals(txs, month);
188 return Object.keys(budgets).map(function (cat) {
189 var budget = budgets[cat];
190 var spent = spentBy[cat] || 0;
191 var pct = budget > 0 ? spent / budget : 0;
192 return {
193 category: cat,
194 budget: budget,
195 spent: spent,
196 pct: pct,
197 remaining: Math.round((budget - spent) * 100) / 100,
198 over: spent > budget
199 };
200 }).sort(function (a, b) { return b.pct - a.pct || (a.category < b.category ? -1 : 1); });
201 }
202
203 function filterTransactions(txs, filters) {
204 filters = filters || {};
205 var cat = filters.category && filters.category !== 'All' ? filters.category : null;
206 var q = filters.search ? String(filters.search).toLowerCase() : null;
207 var month = filters.month && filters.month !== 'All' ? filters.month : null;
208 return txs.filter(function (t) {
209 if (cat && t.category !== cat) return false;
210 if (month && monthKey(t.date) !== month) return false;
211 if (q && t.description.toLowerCase().indexOf(q) === -1) return false;
212 return true;
213 });
214 }
215
216 function sortByDateDesc(txs) {
217 return txs.slice().sort(function (a, b) {
218 if (a.date !== b.date) return a.date < b.date ? 1 : -1;
219 return b.id - a.id;
220 });
221 }
222
223
224
225 var CSV_HEADER = 'date,description,category,amount';
226
227 function csvEscape(field) {
228 var s = String(field);
229 if (/[",\n\r]/.test(s)) return '"' + s.replace(/"/g, '""') + '"';
230 return s;
231 }
232
233 function toCSV(txs) {
234 var lines = [CSV_HEADER];
235 for (var i = 0; i < txs.length; i++) {
236 var t = txs[i];
237 lines.push([t.date, csvEscape(t.description), t.category, t.amount].join(','));
238 }
239 return lines.join('\n');
240 }
241
242
243 function csvRows(text) {
244 var rows = [];
245 var row = [];
246 var field = '';
247 var inQuotes = false;
248 var i = 0;
249 while (i < text.length) {
250 var ch = text[i];
251 if (inQuotes) {
252 if (ch === '"') {
253 if (text[i + 1] === '"') { field += '"'; i += 2; continue; }
254 inQuotes = false; i++; continue;
255 }
256 field += ch; i++; continue;
257 }
258 if (ch === '"') { inQuotes = true; i++; continue; }
259 if (ch === ',') { row.push(field); field = ''; i++; continue; }
260 if (ch === '\r') { i++; continue; }
261 if (ch === '\n') { row.push(field); rows.push(row); row = []; field = ''; i++; continue; }
262 field += ch; i++;
263 }
264 if (field !== '' || row.length > 0) { row.push(field); rows.push(row); }
265 return rows.filter(function (r) { return !(r.length === 1 && r[0].trim() === ''); });
266 }
267
268
269 function parseCSV(text) {
270 var rows = csvRows(String(text || ''));
271 var transactions = [];
272 var errors = [];
273 var start = 0;
274 if (rows.length && rows[0].join(',').toLowerCase().replace(/\s/g, '') === CSV_HEADER) {
275 start = 1;
276 }
277 for (var r = start; r < rows.length; r++) {
278 var cells = rows[r];
279 if (cells.length !== 4) {
280 errors.push('row ' + (r + 1) + ': expected 4 fields, got ' + cells.length);
281 continue;
282 }
283 var tx = {
284 date: cells[0].trim(),
285 description: cells[1],
286 category: cells[2].trim(),
287 amount: Number(cells[3])
288 };
289 var v = validateTransaction(tx);
290 if (!v.ok) {
291 errors.push('row ' + (r + 1) + ': ' + v.errors.join('; '));
292 continue;
293 }
294 transactions.push(tx);
295 }
296 return { transactions: transactions, errors: errors };
297 }
298
299
300 function importCSV(existing, text) {
301 var parsed = parseCSV(text);
302 var txs = existing;
303 for (var i = 0; i < parsed.transactions.length; i++) {
304 txs = addTransaction(txs, parsed.transactions[i]);
305 }
306 return { txs: txs, added: parsed.transactions.length, errors: parsed.errors };
307 }
308
309
310
311 function formatMoney(n) {
312 var neg = n < 0;
313 var abs = Math.abs(Math.round(n * 100) / 100);
314 var fixed = abs.toFixed(2);
315 var parts = fixed.split('.');
316 var intPart = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
317 return (neg ? '-$' : '$') + intPart + '.' + parts[1];
318 }
319
320
321 function formatMoneyShort(n) {
322 var abs = Math.abs(n);
323 var sign = n < 0 ? '-' : '';
324 if (abs >= 1000) {
325 var k = abs / 1000;
326 return sign + '$' + (k >= 10 ? Math.round(k) : Math.round(k * 10) / 10) + 'k';
327 }
328 return sign + '$' + Math.round(abs);
329 }
330
331
332
333
334 function lcg(seed) {
335 var s = seed >>> 0;
336 return function () {
337 s = (s * 1664525 + 1013904223) >>> 0;
338 return s / 4294967296;
339 };
340 }
341
342
343 function sampleData(today) {
344 var rand = lcg(42);
345 var endMonth = monthKey(today);
346 var months = monthRange(endMonth, 6);
347 var txs = [];
348 var merchants = {
349 Groceries: ['Fresh Mart', 'Corner Grocer', 'Farm Box'],
350 Dining: ['Noodle House', 'Cafe Lumen', 'Taqueria Sol'],
351 Transport: ['Metro Card', 'City Fuel', 'RideShare'],
352 Utilities: ['Power & Light', 'City Water', 'Fiber Internet'],
353 Entertainment: ['Streamly', 'Cinema Row', 'Vinyl Attic'],
354 Health: ['Pharmacy Plus', 'Yoga Loft'],
355 Shopping: ['Thread & Co', 'Gadget Bin'],
356 Other: ['Post Office', 'Gift Shop']
357 };
358 months.forEach(function (m, mi) {
359 var isCurrent = m === endMonth;
360 var dayCap = isCurrent ? Math.max(2, +today.slice(8, 10)) : 28;
361 function day(d) {
362 var dd = Math.min(d, dayCap);
363 return m + '-' + (dd < 10 ? '0' + dd : dd);
364 }
365 txs.push({ date: day(1), description: 'Salary', category: 'Income', amount: 4200 });
366 txs.push({ date: day(1), description: 'Rent', category: 'Housing', amount: -1800 });
367 Object.keys(merchants).forEach(function (cat) {
368 if (cat === 'Housing') return;
369 var base = { Groceries: 4, Dining: 3, Transport: 2, Utilities: 2,
370 Entertainment: 1, Health: 1, Shopping: 1, Other: 1 }[cat] || 1;
371 var n = Math.max(1, Math.round(base * (0.6 + rand())));
372 for (var i = 0; i < n; i++) {
373 var names = merchants[cat];
374 var typical = { Groceries: 55, Dining: 32, Transport: 40, Utilities: 85,
375 Entertainment: 25, Health: 45, Shopping: 70, Other: 30 }[cat];
376 var amount = -Math.round(typical * (0.5 + rand()) * 100) / 100;
377 var d = 2 + Math.floor(rand() * 26);
378 txs.push({
379 date: day(d),
380 description: names[Math.floor(rand() * names.length)],
381 category: cat,
382 amount: amount
383 });
384 }
385 });
386 if (mi % 2 === 0) {
387 txs.push({ date: day(15), description: 'Freelance project', category: 'Income', amount: Math.round(300 + rand() * 400) });
388 }
389 });
390 var out = [];
391 for (var i = 0; i < txs.length; i++) out = addTransaction(out, txs[i]);
392 return out;
393 }
394
395 return {
396 CATEGORIES: CATEGORIES,
397 DEFAULT_BUDGETS: DEFAULT_BUDGETS,
398 isValidDate: isValidDate,
399 validateTransaction: validateTransaction,
400 nextId: nextId,
401 addTransaction: addTransaction,
402 updateTransaction: updateTransaction,
403 deleteTransaction: deleteTransaction,
404 monthKey: monthKey,
405 monthLabel: monthLabel,
406 shiftMonth: shiftMonth,
407 monthRange: monthRange,
408 monthlyTotals: monthlyTotals,
409 monthlySeries: monthlySeries,
410 categoryTotals: categoryTotals,
411 budgetStatus: budgetStatus,
412 filterTransactions: filterTransactions,
413 sortByDateDesc: sortByDateDesc,
414 toCSV: toCSV,
415 parseCSV: parseCSV,
416 importCSV: importCSV,
417 formatMoney: formatMoney,
418 formatMoneyShort: formatMoneyShort,
419 sampleData: sampleData
420 };
421});
422
Discussion
1 comment on this trajectory. Recorded by @patrick-toulme.