1
2
3
4
5
6
7
8
9
10
11(function (global, factory) {
12 if (typeof module === 'object' && module.exports) {
13 module.exports = factory();
14 } else {
15 global.FinanceData = factory();
16 }
17})(typeof self !== 'undefined' ? self : this, function () {
18 'use strict';
19
20 var CATEGORIES = ['Groceries', 'Rent', 'Transport', 'Dining', 'Entertainment', 'Utilities', 'Shopping', 'Other'];
21 var INCOME_CATEGORY = 'Income';
22 var ALL_CATEGORIES = CATEGORIES.concat([INCOME_CATEGORY]);
23
24 var DEFAULT_BUDGETS = {
25 Groceries: 500,
26 Rent: 1600,
27 Transport: 200,
28 Dining: 250,
29 Entertainment: 150,
30 Utilities: 250,
31 Shopping: 200,
32 Other: 150
33 };
34
35 var STORAGE_KEYS = {
36 transactions: 'vibefin.transactions.v1',
37 budgets: 'vibefin.budgets.v1',
38 theme: 'vibefin.theme'
39 };
40
41 var MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
42
43 var uidCounter = 0;
44 function uid() {
45 uidCounter += 1;
46 return 'txn-' + Date.now().toString(36) + '-' + uidCounter.toString(36) + '-' + Math.random().toString(36).slice(2, 8);
47 }
48
49 function round2(n) {
− return Math.round((n + Number.EPSILON) * 100) / 100;
50
51
52 return Math.round(n * 100 + (n < 0 ? -1e-6 : 1e-6)) / 100;
53 }
54
55
56
57 function normalizeTransaction(input) {
58 var category = String(input.category == null ? '' : input.category).trim();
59 var rawAmount = typeof input.amount === 'number' ? input.amount : Number(input.amount);
60 var amount;
61 if (typeof rawAmount !== 'number' || !isFinite(rawAmount)) {
62 amount = NaN;
63 } else {
64 var mag = round2(Math.abs(rawAmount));
65 amount = category === INCOME_CATEGORY ? mag : -mag;
66 }
67 return {
68 date: String(input.date == null ? '' : input.date).trim(),
69 description: String(input.description == null ? '' : input.description).trim(),
70 category: category,
71 amount: amount
72 };
73 }
74
75 function validateTransaction(t) {
76 var errors = [];
77 if (!/^\d{4}-\d{2}-\d{2}$/.test(t.date)) {
78 errors.push('date must be YYYY-MM-DD');
79 } else if (isNaN(Date.parse(t.date))) {
80 errors.push('date is not a real calendar date');
81 }
82 if (!t.description) {
83 errors.push('description is required');
84 }
85 if (ALL_CATEGORIES.indexOf(t.category) === -1) {
86 errors.push('unknown category "' + t.category + '"');
87 }
88 if (typeof t.amount !== 'number' || !isFinite(t.amount)) {
89 errors.push('amount must be a number');
90 } else if (t.amount === 0) {
91 errors.push('amount must be non-zero');
92 }
93 return errors;
94 }
95
96 function addTransaction(list, input) {
97 var t = normalizeTransaction(input);
98 var errors = validateTransaction(t);
99 if (errors.length) {
100 throw new Error(errors.join('; '));
101 }
102 t.id = typeof input.id === 'string' && input.id ? input.id : uid();
103 return list.concat([t]);
104 }
105
106 function updateTransaction(list, id, patch) {
107 var idx = -1;
108 for (var i = 0; i < list.length; i++) {
109 if (list[i].id === id) { idx = i; break; }
110 }
111 if (idx === -1) {
112 throw new Error('Transaction not found: ' + id);
113 }
114 var merged = {};
115 var existing = list[idx];
116 for (var k in existing) { merged[k] = existing[k]; }
117 for (var p in patch) { if (p !== 'id') { merged[p] = patch[p]; } }
118 var t = normalizeTransaction(merged);
119 var errors = validateTransaction(t);
120 if (errors.length) {
121 throw new Error(errors.join('; '));
122 }
123 t.id = id;
124 var next = list.slice();
125 next[idx] = t;
126 return next;
127 }
128
129 function deleteTransaction(list, id) {
130 return list.filter(function (t) { return t.id !== id; });
131 }
132
133 function getTransaction(list, id) {
134 for (var i = 0; i < list.length; i++) {
135 if (list[i].id === id) { return list[i]; }
136 }
137 return null;
138 }
139
140 function sortTransactions(list) {
141
142 return list.slice().sort(function (a, b) {
143 return a.date < b.date ? 1 : a.date > b.date ? -1 : 0;
144 });
145 }
146
147 function filterTransactions(list, opts) {
148 opts = opts || {};
149 var category = opts.category || 'all';
150 var search = String(opts.search || '').trim().toLowerCase();
151 var from = opts.from || null;
152 var to = opts.to || null;
153 return list.filter(function (t) {
154 if (category !== 'all' && t.category !== category) { return false; }
155 if (from && t.date < from) { return false; }
156 if (to && t.date > to) { return false; }
157 if (search) {
158 var hay = (t.description + ' ' + t.category).toLowerCase();
159 if (hay.indexOf(search) === -1) { return false; }
160 }
161 return true;
162 });
163 }
164
165
166
167 function monthKey(dateStr) {
168 return String(dateStr).slice(0, 7);
169 }
170
171 function monthLabel(key) {
172 var y = key.slice(0, 4);
173 var m = parseInt(key.slice(5, 7), 10);
174 return (MONTH_NAMES[m - 1] || '?') + ' ' + y;
175 }
176
177 function monthAdd(key, n) {
178 var y = parseInt(key.slice(0, 4), 10);
179 var m = parseInt(key.slice(5, 7), 10);
180 var total = y * 12 + (m - 1) + n;
181 var ny = Math.floor(total / 12);
182 var nm = (total % 12 + 12) % 12 + 1;
183 return String(ny).padStart(4, '0') + '-' + String(nm).padStart(2, '0');
184 }
185
186 function monthRange(fromKey, toKey) {
187 if (!fromKey || !toKey || fromKey > toKey) { return []; }
188 var out = [];
189 var k = fromKey;
190 while (k <= toKey && out.length < 1200) {
191 out.push(k);
192 k = monthAdd(k, 1);
193 }
194 return out;
195 }
196
197
198
199 function monthlySeries(list, opts) {
200 opts = opts || {};
201 var byMonth = {};
202 var minK = null;
203 var maxK = null;
204 list.forEach(function (t) {
205 var k = monthKey(t.date);
206 if (minK === null || k < minK) { minK = k; }
207 if (maxK === null || k > maxK) { maxK = k; }
208 if (t.amount < 0) {
209 byMonth[k] = (byMonth[k] || 0) + (-t.amount);
210 }
211 });
212 var fromK = opts.fromMonth || minK;
213 var toK = opts.toMonth || maxK;
214 if (!fromK || !toK) { return []; }
215 return monthRange(fromK, toK).map(function (k) {
216 return { month: k, total: round2(byMonth[k] || 0) };
217 });
218 }
219
220 function spendingByCategory(list, mKey) {
221 var out = {};
222 list.forEach(function (t) {
223 if (t.amount >= 0) { return; }
224 if (mKey && monthKey(t.date) !== mKey) { return; }
225 out[t.category] = round2((out[t.category] || 0) + (-t.amount));
226 });
227 return out;
228 }
229
230 function totals(list, mKey) {
231 var spent = 0;
232 var income = 0;
233 list.forEach(function (t) {
234 if (mKey && monthKey(t.date) !== mKey) { return; }
235 if (t.amount > 0) { income += t.amount; } else { spent += -t.amount; }
236 });
237 return { spent: round2(spent), income: round2(income), net: round2(income - spent) };
238 }
239
240
241
242
243 function budgetStatus(spent, budget) {
244 if (!(typeof budget === 'number' && isFinite(budget) && budget > 0)) {
245 return { ratio: null, level: 'none' };
246 }
247 var ratio = spent / budget;
248 var level = ratio > 1 ? 'over' : ratio >= 0.8 ? 'warning' : 'ok';
249 return { ratio: ratio, level: level };
250 }
251
252
253
254
255
256 function parseCSV(text) {
257 var rows = [];
258 if (typeof text !== 'string' || text === '') { return rows; }
259 var row = [];
260 var field = '';
261 var inQuotes = false;
262 var fieldQuoted = false;
263 function endField() { row.push(field); field = ''; fieldQuoted = false; }
264 function endRow() { endField(); rows.push(row); row = []; }
265 for (var i = 0; i < text.length; i++) {
266 var ch = text[i];
267 if (inQuotes) {
268 if (ch === '"') {
269 if (text[i + 1] === '"') { field += '"'; i++; } else { inQuotes = false; }
270 } else {
271 field += ch;
272 }
273 } else if (ch === '"') {
274 inQuotes = true;
275 fieldQuoted = true;
276 } else if (ch === ',') {
277 endField();
278 } else if (ch === '\n') {
279 endRow();
280 } else if (ch === '\r') {
281 if (text[i + 1] === '\n') { i++; }
282 endRow();
283 } else {
284 field += ch;
285 }
286 }
287 if (field !== '' || row.length || fieldQuoted) { endRow(); }
288 return rows;
289 }
290
291 function csvEscape(value) {
292 var s = String(value == null ? '' : value);
293 if (/[",\r\n]/.test(s)) {
294 return '"' + s.replace(/"/g, '""') + '"';
295 }
296 return s;
297 }
298
299 var CSV_HEADER = ['date', 'description', 'category', 'amount'];
300
301 function toCSV(list) {
302 var lines = [CSV_HEADER.join(',')];
303 list.forEach(function (t) {
304 lines.push([
305 csvEscape(t.date),
306 csvEscape(t.description),
307 csvEscape(t.category),
308 csvEscape(t.amount.toFixed(2))
309 ].join(','));
310 });
311 return lines.join('\n') + '\n';
312 }
313
314
315 function parseAmount(s) {
316 if (typeof s === 'number') { return s; }
317 if (typeof s !== 'string') { return NaN; }
318 var t = s.trim();
319 if (!t) { return NaN; }
320 var neg = false;
321 var m = /^\((.*)\)$/.exec(t);
322 if (m) { neg = true; t = m[1]; }
323 t = t.replace(/[$\s,]/g, '');
324 if (t === '' || !/^[-+]?\d*\.?\d+$/.test(t)) { return NaN; }
325 var n = Number(t);
326 return neg ? -Math.abs(n) : n;
327 }
328
329
330
331
332
333 function transactionsFromCSV(text) {
334 var rows = parseCSV(text);
335 if (!rows.length) {
336 return { transactions: [], errors: ['File is empty'] };
337 }
338 var header = rows[0].map(function (h) { return h.trim().toLowerCase(); });
339 function col(names) {
340 for (var i = 0; i < names.length; i++) {
341 var idx = header.indexOf(names[i]);
342 if (idx !== -1) { return idx; }
343 }
344 return -1;
345 }
346 var dateIdx = col(['date']);
347 var amtIdx = col(['amount']);
348 var descIdx = col(['description', 'memo', 'payee', 'name']);
349 var catIdx = col(['category']);
350 if (dateIdx === -1 || amtIdx === -1) {
351 return { transactions: [], errors: ['Missing required column(s): a header row with "date" and "amount" is required'] };
352 }
353
354 var lowerAll = ALL_CATEGORIES.map(function (c) { return c.toLowerCase(); });
355 var transactions = [];
356 var errors = [];
357 for (var r = 1; r < rows.length; r++) {
358 var row = rows[r];
359 var allEmpty = row.every(function (f) { return String(f).trim() === ''; });
360 if (allEmpty) { continue; }
361 var amount = parseAmount(row[amtIdx]);
362 var category;
363 if (catIdx === -1) {
364 category = amount > 0 ? INCOME_CATEGORY : 'Other';
365 } else {
366 var catRaw = String(row[catIdx] == null ? '' : row[catIdx]).trim().toLowerCase();
367 var ci = lowerAll.indexOf(catRaw);
368 category = ci === -1 ? 'Other' : ALL_CATEGORIES[ci];
369 }
370 var t = normalizeTransaction({
371 date: row[dateIdx],
372 description: descIdx === -1 ? 'Imported transaction' : row[descIdx],
373 category: category,
374 amount: amount
375 });
376 var errs = validateTransaction(t);
377 if (errs.length) {
378 errors.push('Row ' + (r + 1) + ': ' + errs.join('; '));
379 } else {
380 t.id = uid();
381 transactions.push(t);
382 }
383 }
384 return { transactions: transactions, errors: errors };
385 }
386
387
388
389 var currencyFmt = null;
390 function formatCurrency(n) {
391 if (currencyFmt === null) {
392 currencyFmt = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
393 }
394 return currencyFmt.format(n);
395 }
396
397
398 function formatCompactCurrency(n) {
399 var abs = Math.abs(n);
400 var sign = n < 0 ? '-' : '';
401 if (abs >= 1000) {
402 var k = round2(abs / 1000);
403 var s = (k % 1 === 0 ? String(k) : k.toFixed(1)) + 'K';
404 return sign + '$' + s;
405 }
406 return sign + '$' + String(round2(abs));
407 }
408
409
410
411
412
413
414 function loadTransactions(storage) {
415 var raw;
416 try {
417 raw = storage.getItem(STORAGE_KEYS.transactions);
418 } catch (e) {
419 return [];
420 }
421 if (raw == null) { return null; }
422 var parsed;
423 try {
424 parsed = JSON.parse(raw);
425 } catch (e) {
426 return [];
427 }
428 if (!Array.isArray(parsed)) { return []; }
429 var out = [];
430 parsed.forEach(function (entry) {
431 if (!entry || typeof entry !== 'object' || typeof entry.id !== 'string') { return; }
432 var t = normalizeTransaction(entry);
433 if (validateTransaction(t).length === 0) {
434 t.id = entry.id;
435 out.push(t);
436 }
437 });
438 return out;
439 }
440
441 function saveTransactions(storage, list) {
442 storage.setItem(STORAGE_KEYS.transactions, JSON.stringify(list));
443 }
444
445 function loadBudgets(storage) {
446 var out = {};
447 for (var k in DEFAULT_BUDGETS) { out[k] = DEFAULT_BUDGETS[k]; }
448 var raw;
449 try {
450 raw = storage.getItem(STORAGE_KEYS.budgets);
451 } catch (e) {
452 return out;
453 }
454 if (raw == null) { return out; }
455 var parsed;
456 try {
457 parsed = JSON.parse(raw);
458 } catch (e) {
459 return out;
460 }
461 if (!parsed || typeof parsed !== 'object') { return out; }
462 CATEGORIES.forEach(function (cat) {
463 var v = parsed[cat];
464 if (typeof v === 'number' && isFinite(v) && v >= 0) { out[cat] = v; }
465 });
466 return out;
467 }
468
469 function saveBudgets(storage, budgets) {
470 storage.setItem(STORAGE_KEYS.budgets, JSON.stringify(budgets));
471 }
472
473
474
475 function mulberry32(seed) {
476 var a = seed >>> 0;
477 return function () {
478 a = (a + 0x6D2B79F5) >>> 0;
479 var t = a;
480 t = Math.imul(t ^ (t >>> 15), t | 1);
481 t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
482 return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
483 };
484 }
485
486
487
488 function demoData(todayIso) {
489 var rng = mulberry32(42);
490 function amt(min, max) { return round2(min + rng() * (max - min)); }
491 var curKey = monthKey(todayIso);
492 var months = monthRange(monthAdd(curKey, -5), curKey);
493 var out = [];
494 function pad2(n) { return String(n).padStart(2, '0'); }
495 function push(k, day, description, category, amount) {
496 var date = k + '-' + pad2(day);
497 if (date > todayIso) { return; }
498 out.push({
499 id: uid(),
500 date: date,
501 description: description,
502 category: category,
503 amount: category === INCOME_CATEGORY ? Math.abs(amount) : -Math.abs(amount)
504 });
505 }
506 months.forEach(function (k) {
507 push(k, 1, 'Salary', INCOME_CATEGORY, 3800);
508 push(k, 1, 'Rent', 'Rent', 1600);
509 push(k, 5, 'Electric & internet', 'Utilities', amt(170, 245));
510 [3, 9, 16, 23].forEach(function (day) {
511 push(k, day, 'Groceries', 'Groceries', amt(70, 140));
512 });
513 [7, 19].forEach(function (day) {
514 push(k, day, 'Transit & fuel', 'Transport', amt(18, 60));
515 });
516 [6, 13, 26].forEach(function (day) {
517 push(k, day, 'Restaurants & coffee', 'Dining', amt(16, 70));
518 });
519 push(k, 11, 'Streaming & events', 'Entertainment', amt(10, 55));
520 if (rng() > 0.5) { push(k, 24, 'Concert tickets', 'Entertainment', amt(20, 60)); }
521 if (rng() > 0.35) { push(k, 15, 'Online shopping', 'Shopping', amt(25, 130)); }
522 if (rng() > 0.6) { push(k, 20, 'Miscellaneous', 'Other', amt(10, 80)); }
523 });
524 return out;
525 }
526
527 return {
528 CATEGORIES: CATEGORIES,
529 INCOME_CATEGORY: INCOME_CATEGORY,
530 ALL_CATEGORIES: ALL_CATEGORIES,
531 DEFAULT_BUDGETS: DEFAULT_BUDGETS,
532 STORAGE_KEYS: STORAGE_KEYS,
533 uid: uid,
534 round2: round2,
535 normalizeTransaction: normalizeTransaction,
536 validateTransaction: validateTransaction,
537 addTransaction: addTransaction,
538 updateTransaction: updateTransaction,
539 deleteTransaction: deleteTransaction,
540 getTransaction: getTransaction,
541 sortTransactions: sortTransactions,
542 filterTransactions: filterTransactions,
543 monthKey: monthKey,
544 monthLabel: monthLabel,
545 monthAdd: monthAdd,
546 monthRange: monthRange,
547 monthlySeries: monthlySeries,
548 spendingByCategory: spendingByCategory,
549 totals: totals,
550 budgetStatus: budgetStatus,
551 parseCSV: parseCSV,
552 csvEscape: csvEscape,
553 toCSV: toCSV,
554 parseAmount: parseAmount,
555 transactionsFromCSV: transactionsFromCSV,
556 formatCurrency: formatCurrency,
557 formatCompactCurrency: formatCompactCurrency,
558 loadTransactions: loadTransactions,
559 saveTransactions: saveTransactions,
560 loadBudgets: loadBudgets,
561 saveBudgets: saveBudgets,
562 demoData: demoData
563 };
564});
565
Discussion
1 comment on this trajectory. Recorded by @patrick-toulme.