1
2
3
4
5(function (root, factory) {
6 if (typeof exports === 'object' && typeof module !== 'undefined') {
7 module.exports = factory();
8 } else if (typeof define === 'function' && define.amd) {
9 define(factory);
10 } else {
11 root.FinanceData = factory();
12 }
13})(typeof self !== 'undefined' ? self : this, function () {
14 'use strict';
15
16 const STORAGE_KEY_TX = 'pf_dashboard_transactions_v1';
17 const STORAGE_KEY_BUDGETS = 'pf_dashboard_budgets_v1';
18
19 const DEFAULT_CATEGORIES = [
20 'Housing',
21 'Food & Dining',
22 'Transportation',
23 'Utilities',
24 'Entertainment',
25 'Shopping',
26 'Healthcare',
27 'Income',
28 'Investment',
29 'Other'
30 ];
31
32 const DEFAULT_BUDGETS = {
33 'Housing': 1500,
34 'Food & Dining': 600,
35 'Transportation': 300,
36 'Utilities': 250,
37 'Entertainment': 200,
38 'Shopping': 400,
39 'Healthcare': 150,
40 'Other': 200
41 };
42
43
44 function getPastDate(daysAgo) {
45 const d = new Date();
46 d.setDate(d.getDate() - daysAgo);
47 return d.toISOString().split('T')[0];
48 }
49
50 const SAMPLE_TRANSACTIONS = [
51 { id: 'tx-1', date: getPastDate(0), description: 'Tech Corp Paycheck', amount: 3500.00, category: 'Income', type: 'income', notes: 'Bi-weekly salary' },
52 { id: 'tx-2', date: getPastDate(1), description: 'Trader Joe Groceries', amount: 142.50, category: 'Food & Dining', type: 'expense', notes: 'Weekly groceries' },
53 { id: 'tx-3', date: getPastDate(2), description: 'Apartment Rent', amount: 1400.00, category: 'Housing', type: 'expense', notes: 'Monthly rent' },
54 { id: 'tx-4', date: getPastDate(3), description: 'Electric & Gas Utility', amount: 115.20, category: 'Utilities', type: 'expense', notes: 'Power bill' },
55 { id: 'tx-5', date: getPastDate(4), description: 'Subway & Bus Fare', amount: 32.00, category: 'Transportation', type: 'expense', notes: 'Transit pass' },
56 { id: 'tx-6', date: getPastDate(5), description: 'Cinema & Snacks', amount: 45.00, category: 'Entertainment', type: 'expense', notes: 'Movie night' },
57 { id: 'tx-7', date: getPastDate(7), description: 'Online Retail Purchase', amount: 89.99, category: 'Shopping', type: 'expense', notes: 'New headphones' },
58 { id: 'tx-8', date: getPastDate(9), description: 'Pharmacy Prescription', amount: 28.40, category: 'Healthcare', type: 'expense', notes: 'Meds' },
59 { id: 'tx-9', date: getPastDate(11), description: 'Coffee Shop', amount: 18.75, category: 'Food & Dining', type: 'expense', notes: 'Meeting drinks' },
60 { id: 'tx-10', date: getPastDate(14), description: 'Freelance Design Work', amount: 850.00, category: 'Income', type: 'income', notes: 'Side project client' },
61 { id: 'tx-11', date: getPastDate(16), description: 'Whole Foods Market', amount: 178.30, category: 'Food & Dining', type: 'expense', notes: 'Organic groceries' },
62 { id: 'tx-12', date: getPastDate(18), description: 'Gas Station Refill', amount: 54.10, category: 'Transportation', type: 'expense', notes: 'Car fuel' },
63 { id: 'tx-13', date: getPastDate(20), description: 'Internet Service Provider', amount: 79.99, category: 'Utilities', type: 'expense', notes: 'Fiber internet' },
64 { id: 'tx-14', date: getPastDate(22), description: 'Dining at Italian Bistro', amount: 125.00, category: 'Food & Dining', type: 'expense', notes: 'Dinner with friends' },
65 { id: 'tx-15', date: getPastDate(25), description: 'Clothing Store', amount: 165.50, category: 'Shopping', type: 'expense', notes: 'Work wardrobe' },
66 { id: 'tx-16', date: getPastDate(28), description: 'Stock Dividend', amount: 120.00, category: 'Investment', type: 'income', notes: 'Index fund dividend' }
67 ];
68
69 class FinanceDataService {
70 constructor() {
71 this._hasLocalStorage = typeof localStorage !== 'undefined';
72 }
73
74
75
76 loadTransactions() {
77 if (!this._hasLocalStorage) return [...SAMPLE_TRANSACTIONS];
78 try {
79 const json = localStorage.getItem(STORAGE_KEY_TX);
80 if (!json) {
81 this.saveTransactions(SAMPLE_TRANSACTIONS);
82 return [...SAMPLE_TRANSACTIONS];
83 }
84 const data = JSON.parse(json);
85 return Array.isArray(data) ? data : [...SAMPLE_TRANSACTIONS];
86 } catch (e) {
87 console.error('Error loading transactions from localStorage:', e);
88 return [...SAMPLE_TRANSACTIONS];
89 }
90 }
91
92 saveTransactions(transactions) {
93 if (!this._hasLocalStorage) return;
94 try {
95 localStorage.setItem(STORAGE_KEY_TX, JSON.stringify(transactions));
96 } catch (e) {
97 console.error('Error saving transactions to localStorage:', e);
98 }
99 }
100
101 loadBudgets() {
102 if (!this._hasLocalStorage) return { ...DEFAULT_BUDGETS };
103 try {
104 const json = localStorage.getItem(STORAGE_KEY_BUDGETS);
105 if (!json) {
106 this.saveBudgets(DEFAULT_BUDGETS);
107 return { ...DEFAULT_BUDGETS };
108 }
109 const data = JSON.parse(json);
110 return typeof data === 'object' && data !== null ? data : { ...DEFAULT_BUDGETS };
111 } catch (e) {
112 console.error('Error loading budgets from localStorage:', e);
113 return { ...DEFAULT_BUDGETS };
114 }
115 }
116
117 saveBudgets(budgets) {
118 if (!this._hasLocalStorage) return;
119 try {
120 localStorage.setItem(STORAGE_KEY_BUDGETS, JSON.stringify(budgets));
121 } catch (e) {
122 console.error('Error saving budgets to localStorage:', e);
123 }
124 }
125
126 resetToSampleData() {
127 if (this._hasLocalStorage) {
128 localStorage.removeItem(STORAGE_KEY_TX);
129 localStorage.removeItem(STORAGE_KEY_BUDGETS);
130 }
131 this.saveTransactions(SAMPLE_TRANSACTIONS);
132 this.saveBudgets(DEFAULT_BUDGETS);
133 return { transactions: [...SAMPLE_TRANSACTIONS], budgets: { ...DEFAULT_BUDGETS } };
134 }
135
136
137
138 generateId() {
139 return 'tx-' + Date.now().toString(36) + '-' + Math.random().toString(36).substring(2, 7);
140 }
141
142 validateTransaction(tx) {
143 const errors = [];
144 if (!tx.date || isNaN(Date.parse(tx.date))) {
145 errors.push('Valid date is required (YYYY-MM-DD)');
146 }
147 if (!tx.description || typeof tx.description !== 'string' || tx.description.trim().length === 0) {
148 errors.push('Description is required');
149 }
150 const numAmount = parseFloat(tx.amount);
151 if (isNaN(numAmount) || numAmount <= 0) {
152 errors.push('Amount must be a positive number');
153 }
154 if (!tx.category || typeof tx.category !== 'string') {
155 errors.push('Category is required');
156 }
157 if (tx.type !== 'income' && tx.type !== 'expense') {
158 errors.push('Type must be either "income" or "expense"');
159 }
160 return {
161 isValid: errors.length === 0,
162 errors,
163 sanitized: errors.length === 0 ? {
164 id: tx.id || this.generateId(),
165 date: tx.date,
166 description: tx.description.trim(),
167 amount: parseFloat(numAmount.toFixed(2)),
168 category: tx.category.trim(),
169 type: tx.type,
170 notes: (tx.notes || '').trim()
171 } : null
172 };
173 }
174
175 addTransaction(transactions, newTx) {
176 const validation = this.validateTransaction(newTx);
177 if (!validation.isValid) {
178 throw new Error(validation.errors.join('; '));
179 }
180 const updated = [validation.sanitized, ...transactions];
181 this.saveTransactions(updated);
182 return updated;
183 }
184
185 updateTransaction(transactions, id, updatedFields) {
186 const index = transactions.findIndex(t => t.id === id);
187 if (index === -1) {
188 throw new Error(`Transaction with id "${id}" not found`);
189 }
190 const merged = { ...transactions[index], ...updatedFields, id };
191 const validation = this.validateTransaction(merged);
192 if (!validation.isValid) {
193 throw new Error(validation.errors.join('; '));
194 }
195 const updated = [...transactions];
196 updated[index] = validation.sanitized;
197 this.saveTransactions(updated);
198 return updated;
199 }
200
201 deleteTransaction(transactions, id) {
202 const updated = transactions.filter(t => t.id !== id);
203 this.saveTransactions(updated);
204 return updated;
205 }
206
207
208
209 calculateSummary(transactions) {
210 let totalIncome = 0;
211 let totalExpense = 0;
212
213 (transactions || []).forEach(tx => {
214 const amt = parseFloat(tx.amount) || 0;
215 if (tx.type === 'income') {
216 totalIncome += amt;
217 } else if (tx.type === 'expense') {
218 totalExpense += amt;
219 }
220 });
221
222 const netBalance = totalIncome - totalExpense;
223 const savingsRate = totalIncome > 0 ? ((netBalance / totalIncome) * 100) : 0;
224
225 return {
226 totalIncome: parseFloat(totalIncome.toFixed(2)),
227 totalExpense: parseFloat(totalExpense.toFixed(2)),
228 netBalance: parseFloat(netBalance.toFixed(2)),
229 savingsRate: parseFloat(Math.max(0, savingsRate).toFixed(1))
230 };
231 }
232
233 calculateCategorySpending(transactions, monthFilter = null) {
234 const spending = {};
235 DEFAULT_CATEGORIES.forEach(cat => {
236 spending[cat] = 0;
237 });
238
239 (transactions || []).forEach(tx => {
240 if (tx.type !== 'expense') return;
241 if (monthFilter) {
242 const txMonth = tx.date.substring(0, 7);
243 if (txMonth !== monthFilter) return;
244 }
245 const cat = tx.category || 'Other';
246 spending[cat] = (spending[cat] || 0) + (parseFloat(tx.amount) || 0);
247 });
248
249
250 Object.keys(spending).forEach(cat => {
251 spending[cat] = parseFloat(spending[cat].toFixed(2));
252 });
253
254 return spending;
255 }
256
257 calculateSpendingOverTime(transactions, groupMode = 'daily') {
258 if (!transactions || transactions.length === 0) {
259 return { labels: [], incomeValues: [], expenseValues: [] };
260 }
261
262
263 const sorted = [...transactions].sort((a, b) => a.date.localeCompare(b.date));
264
265 const timeMap = {};
266
267 sorted.forEach(tx => {
268 let key = tx.date;
269 if (groupMode === 'monthly') {
270 key = tx.date.substring(0, 7);
271 }
272 if (!timeMap[key]) {
273 timeMap[key] = { income: 0, expense: 0 };
274 }
275 const amt = parseFloat(tx.amount) || 0;
276 if (tx.type === 'income') {
277 timeMap[key].income += amt;
278 } else {
279 timeMap[key].expense += amt;
280 }
281 });
282
283 const keys = Object.keys(timeMap).sort();
284 const labels = keys;
285 const incomeValues = keys.map(k => parseFloat(timeMap[k].income.toFixed(2)));
286 const expenseValues = keys.map(k => parseFloat(timeMap[k].expense.toFixed(2)));
287
288 return { labels, incomeValues, expenseValues };
289 }
290
291 filterTransactions(transactions, filters = {}) {
292 const { category, search, startDate, endDate, type } = filters;
293 let result = [...(transactions || [])];
294
295 if (category && category !== 'All') {
296 result = result.filter(t => t.category === category);
297 }
298
299 if (type && type !== 'All') {
300 result = result.filter(t => t.type === type.toLowerCase());
301 }
302
303 if (startDate) {
304 result = result.filter(t => t.date >= startDate);
305 }
306
307 if (endDate) {
308 result = result.filter(t => t.date <= endDate);
309 }
310
311 if (search && search.trim() !== '') {
312 const q = search.trim().toLowerCase();
313 result = result.filter(t =>
314 (t.description && t.description.toLowerCase().includes(q)) ||
315 (t.notes && t.notes.toLowerCase().includes(q)) ||
316 (t.category && t.category.toLowerCase().includes(q)) ||
317 (t.amount && t.amount.toString().includes(q))
318 );
319 }
320
321 return result;
322 }
323
324
325
326 exportToCSV(transactions) {
327 const headers = ['ID', 'Date', 'Description', 'Amount', 'Category', 'Type', 'Notes'];
328 const rows = (transactions || []).map(t => [
329 t.id || '',
330 t.date || '',
331 `"${(t.description || '').replace(/"/g, '""')}"`,
332 t.amount !== undefined ? t.amount : 0,
333 `"${(t.category || '').replace(/"/g, '""')}"`,
334 t.type || 'expense',
335 `"${(t.notes || '').replace(/"/g, '""')}"`
336 ]);
337
338 return [headers.join(','), ...rows.map(r => r.join(','))].join('\n');
339 }
340
341 parseCSV(csvText) {
342 if (!csvText || typeof csvText !== 'string') {
343 throw new Error('CSV content must be a non-empty string');
344 }
345
346 const lines = csvText.split(/\r?\n/).filter(line => line.trim() !== '');
347 if (lines.length === 0) {
348 throw new Error('CSV file is empty');
349 }
350
351
352 const parseCSVLine = (line) => {
353 const result = [];
354 let current = '';
355 let inQuotes = false;
356 for (let i = 0; i < line.length; i++) {
357 const char = line[i];
358 if (char === '"') {
359 if (inQuotes && line[i + 1] === '"') {
360 current += '"';
361 i++;
362 } else {
363 inQuotes = !inQuotes;
364 }
365 } else if (char === ',' && !inQuotes) {
366 result.push(current.trim());
367 current = '';
368 } else {
369 current += char;
370 }
371 }
372 result.push(current.trim());
373 return result;
374 };
375
376 const headerLine = parseCSVLine(lines[0]).map(h => h.toLowerCase());
377 const dateIdx = headerLine.findIndex(h => h.includes('date'));
378 const descIdx = headerLine.findIndex(h => h.includes('desc') || h.includes('title') || h.includes('name'));
379 const amtIdx = headerLine.findIndex(h => h.includes('amount') || h.includes('price') || h.includes('val'));
380 const catIdx = headerLine.findIndex(h => h.includes('category') || h.includes('cat'));
381 const typeIdx = headerLine.findIndex(h => h.includes('type'));
382 const notesIdx = headerLine.findIndex(h => h.includes('note') || h.includes('memo'));
383
384 const parsedTransactions = [];
385 const errors = [];
386
387 for (let i = 1; i < lines.length; i++) {
388 const row = parseCSVLine(lines[i]);
389 if (row.length < 2) continue;
390
391 const rawDate = dateIdx !== -1 ? row[dateIdx] : getPastDate(0);
392 const rawDesc = descIdx !== -1 ? row[descIdx] : 'Imported Transaction';
393 let rawAmtStr = amtIdx !== -1 ? row[amtIdx] : '0';
394 rawAmtStr = rawAmtStr.replace(/[\$\,\s]/g, '');
395 const rawAmt = parseFloat(rawAmtStr);
396
397 let rawCat = catIdx !== -1 && row[catIdx] ? row[catIdx] : 'Other';
398 let rawType = typeIdx !== -1 && row[typeIdx] ? row[typeIdx].toLowerCase() : null;
399
400 if (!rawType) {
401 if (!isNaN(rawAmt) && rawAmt < 0) {
402 rawType = 'expense';
403 } else if (rawCat.toLowerCase().includes('income') || rawCat.toLowerCase().includes('salary')) {
404 rawType = 'income';
405 } else {
406 rawType = 'expense';
407 }
408 }
409
410 const absAmt = Math.abs(isNaN(rawAmt) ? 0 : rawAmt);
411
412 const txCandidate = {
413 id: this.generateId(),
414 date: rawDate,
415 description: rawDesc,
416 amount: absAmt,
417 category: rawCat,
418 type: rawType === 'income' ? 'income' : 'expense',
419 notes: notesIdx !== -1 ? row[notesIdx] : 'CSV Import'
420 };
421
422 const validation = this.validateTransaction(txCandidate);
423 if (validation.isValid) {
424 parsedTransactions.push(validation.sanitized);
425 } else {
426 errors.push(`Line ${i + 1}: ${validation.errors.join(', ')}`);
427 }
428 }
429
430 return {
431 transactions: parsedTransactions,
432 errors
433 };
434 }
435
436
437
438 formatCurrency(amount, currencySymbol = '$') {
439 const val = typeof amount === 'number' ? amount : parseFloat(amount) || 0;
440 const formatted = Math.abs(val).toLocaleString('en-US', {
441 minimumFractionDigits: 2,
442 maximumFractionDigits: 2
443 });
444 return (val < 0 ? '-' : '') + currencySymbol + formatted;
445 }
446
447 formatDate(dateStr) {
448 if (!dateStr) return '';
449 const parts = dateStr.split('-');
450 if (parts.length !== 3) return dateStr;
451 const d = new Date(parts[0], parts[1] - 1, parts[2]);
452 return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
453 }
454 }
455
456 const service = new FinanceDataService();
457 return {
458 FinanceDataService,
459 service,
460 DEFAULT_CATEGORIES,
461 DEFAULT_BUDGETS,
462 SAMPLE_TRANSACTIONS
463 };
464});
465
Discussion
No comments yet. Start the discussion. Recorded by @patrick-toulme.