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