From 8236a7b92470f2accb12190d34dbe06c69936191 Mon Sep 17 00:00:00 2001 From: David Amunga Date: Fri, 26 Sep 2025 18:12:25 +0300 Subject: [PATCH 1/2] refactor: Extract XLSX sheet generators into modular exports structure --- package.json | 3 +- pnpm-lock.yaml | 26 ++ src-tauri/Cargo.lock | 2 +- src-tauri/tauri.conf.json | 4 +- src/App.tsx | 73 +++- src/services/exports/chargesSheet.ts | 89 ++++ .../exports/dailyBalanceTrackerSheet.ts | 329 +++++++++++++++ src/services/exports/financialSummarySheet.ts | 300 +++++++++++++ src/services/exports/index.ts | 4 + .../exports/monthlyWeeklyBreakdownSheet.ts | 353 ++++++++++++++++ src/services/xlsxService.ts | 393 +----------------- src/types/index.ts | 2 + 12 files changed, 1196 insertions(+), 382 deletions(-) create mode 100644 src/services/exports/chargesSheet.ts create mode 100644 src/services/exports/dailyBalanceTrackerSheet.ts create mode 100644 src/services/exports/financialSummarySheet.ts create mode 100644 src/services/exports/index.ts create mode 100644 src/services/exports/monthlyWeeklyBreakdownSheet.ts diff --git a/package.json b/package.json index 621058d..09afed6 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "pdfjs-dist": "5.1.91", "react": "^19.1.0", "react-dom": "^19.1.0", - "tailwind-merge": "^3.3.1" + "tailwind-merge": "^3.3.1", + "zustand": "^5.0.8" }, "devDependencies": { "@changesets/cli": "^2.29.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 487a73a..9520015 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: tailwind-merge: specifier: ^3.3.1 version: 3.3.1 + zustand: + specifier: ^5.0.8 + version: 5.0.8(@types/react@19.1.13)(react@19.1.1) devDependencies: '@changesets/cli': specifier: ^2.29.7 @@ -2072,6 +2075,24 @@ packages: resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} engines: {node: '>= 10'} + zustand@5.0.8: + resolution: {integrity: sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: '@babel/code-frame@7.27.1': @@ -3908,3 +3929,8 @@ snapshots: archiver-utils: 3.0.4 compress-commons: 4.1.2 readable-stream: 3.6.2 + + zustand@5.0.8(@types/react@19.1.13)(react@19.1.1): + optionalDependencies: + '@types/react': 19.1.13 + react: 19.1.1 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9b3d4e0..df1fe24 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2122,7 +2122,7 @@ dependencies = [ [[package]] name = "mpesa2csv" -version = "0.1.0" +version = "0.2.0" dependencies = [ "serde", "serde_json", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2d987fb..71e2b65 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -14,7 +14,7 @@ { "title": "mpesa2csv - Convert M-PESA Statements to CSV/Excel", "width": 600, - "height": 550, + "height": 630, "minWidth": 500, "minHeight": 400, "resizable": true @@ -64,7 +64,7 @@ }, "windowSize": { "width": 660, - "height": 550 + "height": 630 } } }, diff --git a/src/App.tsx b/src/App.tsx index baadf00..352d248 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -36,6 +36,8 @@ function App() { const [exportOptions, setExportOptions] = useState({ includeChargesSheet: false, includeSummarySheet: false, + includeBreakdownSheet: false, + includeDailyBalanceSheet: false, }); const [currentFileIndex, setCurrentFileIndex] = useState(0); const [isDownloading, setIsDownloading] = useState(false); @@ -270,6 +272,7 @@ function App() { setError(undefined); } catch (error: any) { + console.log(error); if (error.includes("cancelled")) { setError(undefined); } else { @@ -455,6 +458,72 @@ function App() { flow, spending patterns, and insights

+ +
+ +

+ Creates a pivot-like table with monthly and weekly + aggregations showing inflows, outflows, net change, + and average transaction size +

+
+ +
+ +

+ Creates a day-by-day balance tracker showing your + highest and lowest balances with spending pattern + insights +

+
)} @@ -515,9 +584,7 @@ function App() {

- {appVersion && ( - v{appVersion} - )} + {appVersion && v{appVersion}}
diff --git a/src/services/exports/chargesSheet.ts b/src/services/exports/chargesSheet.ts new file mode 100644 index 0000000..9f8e02e --- /dev/null +++ b/src/services/exports/chargesSheet.ts @@ -0,0 +1,89 @@ +import { MPesaStatement } from "../../types"; +import * as ExcelJS from "exceljs"; + +export function addChargesSheet( + workbook: ExcelJS.Workbook, + statement: MPesaStatement +): void { + // Filter transactions that contain "charge" in the details (case insensitive) + const chargeTransactions = statement.transactions.filter((transaction) => + transaction.details.toLowerCase().includes("charge") + ); + + if (chargeTransactions.length === 0) { + return; // No charges found, don't create the sheet + } + + // Create the charges worksheet + const chargesWorksheet = workbook.addWorksheet("Charges & Fees"); + + // Define columns for charges sheet + chargesWorksheet.columns = [ + { header: "Receipt No", key: "receiptNo", width: 12 }, + { header: "Date", key: "date", width: 12 }, + { header: "Full Details", key: "fullDetails", width: 40 }, + { header: "Amount", key: "amount", width: 12 }, + ]; + + // Style the header row + const headerRow = chargesWorksheet.getRow(1); + headerRow.font = { bold: true }; + headerRow.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFFFD700" }, + }; + headerRow.alignment = { horizontal: "center" }; + + // Process and add charge transactions + chargeTransactions.forEach((transaction) => { + const amount = transaction.withdrawn || transaction.paidIn || 0; + + chargesWorksheet.addRow({ + receiptNo: transaction.receiptNo, + date: transaction.completionTime, + fullDetails: transaction.details, + amount: amount, + }); + }); + + // Add borders to all cells + const dataRange = chargesWorksheet.getRows(1, chargesWorksheet.rowCount); + if (dataRange) { + dataRange.forEach((row) => { + if (row) { + row.eachCell((cell) => { + cell.border = { + top: { style: "thin" }, + left: { style: "thin" }, + bottom: { style: "thin" }, + right: { style: "thin" }, + }; + }); + } + }); + } + + // Add summary at the bottom + const summaryStartRow = chargesWorksheet.rowCount + 2; + const totalCharges = chargeTransactions.reduce((sum, transaction) => { + return sum + (transaction.withdrawn || transaction.paidIn || 0); + }, 0); + + chargesWorksheet.getCell(`A${summaryStartRow}`).value = "Total Charges:"; + chargesWorksheet.getCell(`A${summaryStartRow}`).font = { bold: true }; + chargesWorksheet.getCell(`D${summaryStartRow}`).value = totalCharges; + chargesWorksheet.getCell(`D${summaryStartRow}`).font = { bold: true }; + chargesWorksheet.getCell(`D${summaryStartRow}`).fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFFFE4B5" }, + }; + + chargesWorksheet.getCell(`A${summaryStartRow + 1}`).value = + "Number of Charge Transactions:"; + chargesWorksheet.getCell(`A${summaryStartRow + 1}`).font = { bold: true }; + chargesWorksheet.getCell(`D${summaryStartRow + 1}`).value = + chargeTransactions.length; + chargesWorksheet.getCell(`D${summaryStartRow + 1}`).font = { bold: true }; +} diff --git a/src/services/exports/dailyBalanceTrackerSheet.ts b/src/services/exports/dailyBalanceTrackerSheet.ts new file mode 100644 index 0000000..806448c --- /dev/null +++ b/src/services/exports/dailyBalanceTrackerSheet.ts @@ -0,0 +1,329 @@ +import { MPesaStatement } from "../../types"; +import * as ExcelJS from "exceljs"; + +interface DailyBalanceData { + date: string; + endBalance: number; + transactionCount: number; + rawDate: Date; +} + +interface BalanceAnalysis { + startDate: string; + endDate: string; + totalDays: number; + highestBalance: number; + lowestBalance: number; + averageBalance: number; + bestDay: string; + worstDay: string; + volatilityPercentage: number; +} + +export function addDailyBalanceTrackerSheet( + workbook: ExcelJS.Workbook, + statement: MPesaStatement +): void { + if (statement.transactions.length === 0) return; + + const balanceWorksheet = workbook.addWorksheet("Daily Balance Tracker"); + + // Generate daily balance data + const dailyBalanceData = generateDailyBalanceData(statement.transactions); + const balanceAnalysis = analyzeDailyBalances(dailyBalanceData); + + let currentRow = 1; + + // Title + balanceWorksheet.getCell(`A${currentRow}`).value = "DAILY BALANCE TRACKER"; + balanceWorksheet.getCell(`A${currentRow}`).font = { bold: true, size: 16 }; + balanceWorksheet.getCell(`A${currentRow}`).fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FF8E44AD" }, + }; + balanceWorksheet.getCell(`A${currentRow}`).font.color = { + argb: "FFFFFFFF", + }; + balanceWorksheet.mergeCells(`A${currentRow}:F${currentRow}`); + currentRow += 2; + + // Summary Section + balanceWorksheet.getCell(`A${currentRow}`).value = "BALANCE ANALYSIS SUMMARY"; + balanceWorksheet.getCell(`A${currentRow}`).font = { bold: true, size: 14 }; + balanceWorksheet.getCell(`A${currentRow}`).fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFE7E6E6" }, + }; + balanceWorksheet.mergeCells(`A${currentRow}:F${currentRow}`); + currentRow++; + + const summaryData = [ + ["Period:", `${balanceAnalysis.startDate} to ${balanceAnalysis.endDate}`], + ["Total Days Tracked:", balanceAnalysis.totalDays], + [ + "Highest Balance:", + `KES ${balanceAnalysis.highestBalance.toLocaleString()}`, + ], + [ + "Lowest Balance:", + `KES ${balanceAnalysis.lowestBalance.toLocaleString()}`, + ], + [ + "Average Daily Balance:", + `KES ${balanceAnalysis.averageBalance.toLocaleString()}`, + ], + ["Best Day:", balanceAnalysis.bestDay], + ["Worst Day:", balanceAnalysis.worstDay], + ["Balance Volatility:", `${balanceAnalysis.volatilityPercentage}%`], + ]; + + summaryData.forEach(([label, value]) => { + balanceWorksheet.getCell(`A${currentRow}`).value = label; + balanceWorksheet.getCell(`A${currentRow}`).font = { bold: true }; + balanceWorksheet.getCell(`B${currentRow}`).value = value; + + // Color code high and low indicators + if (typeof label === "string" && label.includes("Highest Balance")) { + balanceWorksheet.getCell(`B${currentRow}`).font = { + color: { argb: "FF008000" }, + bold: true, + }; + } else if (typeof label === "string" && label.includes("Lowest Balance")) { + balanceWorksheet.getCell(`B${currentRow}`).font = { + color: { argb: "FFFF0000" }, + bold: true, + }; + } + currentRow++; + }); + + currentRow += 2; + + // Daily Balance Table + balanceWorksheet.getCell(`A${currentRow}`).value = "DAILY BALANCE HISTORY"; + balanceWorksheet.getCell(`A${currentRow}`).font = { bold: true, size: 14 }; + balanceWorksheet.getCell(`A${currentRow}`).fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFE7E6E6" }, + }; + balanceWorksheet.mergeCells(`A${currentRow}:F${currentRow}`); + currentRow++; + + // Table headers + const headers = [ + "Date", + "End of Day Balance (KES)", + "Daily Change (KES)", + "Transactions Count", + "High/Low", + "Notes", + ]; + headers.forEach((header, index) => { + const cell = balanceWorksheet.getCell(currentRow, index + 1); + cell.value = header; + cell.font = { bold: true }; + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFD9E2F3" }, + }; + cell.alignment = { horizontal: "center" }; + cell.border = { + top: { style: "thin" }, + left: { style: "thin" }, + bottom: { style: "thin" }, + right: { style: "thin" }, + }; + }); + currentRow++; + + // Daily balance rows + dailyBalanceData.forEach((dayData, index) => { + const previousBalance = + index > 0 ? dailyBalanceData[index - 1].endBalance : dayData.endBalance; + const dailyChange = dayData.endBalance - previousBalance; + + let highLowIndicator = ""; + let notes = ""; + + if (dayData.endBalance === balanceAnalysis.highestBalance) { + highLowIndicator = "HIGHEST"; + notes = "Best balance recorded"; + } else if (dayData.endBalance === balanceAnalysis.lowestBalance) { + highLowIndicator = "LOWEST"; + notes = "Lowest balance recorded"; + } else if (Math.abs(dailyChange) > balanceAnalysis.averageBalance * 0.1) { + notes = dailyChange > 0 ? "Big increase" : "Big decrease"; + } + + const rowData = [ + dayData.date, + dayData.endBalance, + index > 0 ? dailyChange : 0, + dayData.transactionCount, + highLowIndicator, + notes, + ]; + + rowData.forEach((value, colIndex) => { + const cell = balanceWorksheet.getCell(currentRow, colIndex + 1); + cell.value = value; + cell.border = { + top: { style: "thin" }, + left: { style: "thin" }, + bottom: { style: "thin" }, + right: { style: "thin" }, + }; + + // Format currency values + if (colIndex === 1 || colIndex === 2) { + cell.numFmt = "#,##0.00"; + } + + // Color code high/low indicators + if (colIndex === 4 && highLowIndicator) { + cell.font = { + color: { + argb: highLowIndicator === "HIGHEST" ? "FF008000" : "FFFF0000", + }, + bold: true, + }; + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { + argb: highLowIndicator === "HIGHEST" ? "FFE8F5E8" : "FFFFEAEA", + }, + }; + } + + // Color code daily changes + if (colIndex === 2 && index > 0) { + cell.font = { + color: { argb: dailyChange >= 0 ? "FF008000" : "FFFF0000" }, + }; + } + }); + currentRow++; + }); + + // Set column widths + balanceWorksheet.columns = [ + { width: 12 }, // Date + { width: 20 }, // End of Day Balance + { width: 18 }, // Daily Change + { width: 16 }, // Transaction Count + { width: 15 }, // High/Low + { width: 25 }, // Notes + ]; +} + +function generateDailyBalanceData(transactions: any[]): DailyBalanceData[] { + // Sort transactions by completion time + const sortedTransactions = [...transactions].sort( + (a, b) => + new Date(a.completionTime).getTime() - + new Date(b.completionTime).getTime() + ); + + const dailyMap = new Map< + string, + { + endBalance: number; + transactionCount: number; + transactions: any[]; + } + >(); + + // Group transactions by date and track end-of-day balances + sortedTransactions.forEach((transaction) => { + const date = new Date(transaction.completionTime); + const dateKey = date.toISOString().split("T")[0]; + + if (!dailyMap.has(dateKey)) { + dailyMap.set(dateKey, { + endBalance: 0, + transactionCount: 0, + transactions: [], + }); + } + + const dayData = dailyMap.get(dateKey)!; + dayData.transactionCount++; + dayData.transactions.push(transaction); + // Use the balance from this transaction as it represents the balance after this transaction + dayData.endBalance = transaction.balance; + }); + + // Convert to array and sort by date + return Array.from(dailyMap.entries()) + .map(([dateKey, data]) => ({ + date: new Date(dateKey).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }), + endBalance: data.endBalance, + transactionCount: data.transactionCount, + rawDate: new Date(dateKey), + })) + .sort((a, b) => a.rawDate.getTime() - b.rawDate.getTime()); +} + +function analyzeDailyBalances( + dailyBalanceData: DailyBalanceData[] +): BalanceAnalysis { + if (dailyBalanceData.length === 0) { + return { + startDate: "", + endDate: "", + totalDays: 0, + highestBalance: 0, + lowestBalance: 0, + averageBalance: 0, + bestDay: "", + worstDay: "", + volatilityPercentage: 0, + }; + } + + const balances = dailyBalanceData.map((d) => d.endBalance); + const highestBalance = Math.max(...balances); + const lowestBalance = Math.min(...balances); + const averageBalance = + balances.reduce((sum, balance) => sum + balance, 0) / balances.length; + + const bestEntry = dailyBalanceData.find( + (d) => d.endBalance === highestBalance + ); + const worstEntry = dailyBalanceData.find( + (d) => d.endBalance === lowestBalance + ); + + // Calculate volatility as coefficient of variation + const variance = + balances.reduce( + (sum, balance) => sum + Math.pow(balance - averageBalance, 2), + 0 + ) / balances.length; + const standardDeviation = Math.sqrt(variance); + const volatilityPercentage = + averageBalance > 0 + ? Math.round((standardDeviation / averageBalance) * 100) + : 0; + + return { + startDate: dailyBalanceData[0].date, + endDate: dailyBalanceData[dailyBalanceData.length - 1].date, + totalDays: dailyBalanceData.length, + highestBalance, + lowestBalance, + averageBalance: Math.round(averageBalance), + bestDay: bestEntry?.date || "", + worstDay: worstEntry?.date || "", + volatilityPercentage, + }; +} diff --git a/src/services/exports/financialSummarySheet.ts b/src/services/exports/financialSummarySheet.ts new file mode 100644 index 0000000..9a46283 --- /dev/null +++ b/src/services/exports/financialSummarySheet.ts @@ -0,0 +1,300 @@ +import { MPesaStatement } from "../../types"; +import * as ExcelJS from "exceljs"; + +interface FinancialMetrics { + startDate: string; + endDate: string; + totalDays: number; + totalTransactions: number; + totalMoneyIn: number; + totalMoneyOut: number; + netCashFlow: number; + avgDailyIncome: number; + avgDailySpending: number; + startingBalance: number; + endingBalance: number; + highestBalance: number; + lowestBalance: number; + avgBalance: number; + highestSingleIncome: number; + highestSingleExpense: number; + mostActiveDay: string; + busiestDayOfWeek: string; + avgTransactionsPerDay: number; +} + +export function addFinancialSummarySheet( + workbook: ExcelJS.Workbook, + statement: MPesaStatement +): void { + if (statement.transactions.length === 0) return; + + const summaryWorksheet = workbook.addWorksheet("Financial Summary"); + + // Calculate financial metrics + const metrics = calculateFinancialMetrics(statement.transactions); + + // Set up the layout + let currentRow = 1; + + // Title + summaryWorksheet.getCell(`A${currentRow}`).value = "FINANCIAL SUMMARY REPORT"; + summaryWorksheet.getCell(`A${currentRow}`).font = { bold: true, size: 16 }; + summaryWorksheet.getCell(`A${currentRow}`).fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FF4472C4" }, + }; + summaryWorksheet.getCell(`A${currentRow}`).font.color = { + argb: "FFFFFFFF", + }; + summaryWorksheet.mergeCells(`A${currentRow}:C${currentRow}`); + currentRow += 2; + + // Period Overview + summaryWorksheet.getCell(`A${currentRow}`).value = "PERIOD OVERVIEW"; + summaryWorksheet.getCell(`A${currentRow}`).font = { bold: true, size: 14 }; + summaryWorksheet.getCell(`A${currentRow}`).fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFE7E6E6" }, + }; + summaryWorksheet.mergeCells(`A${currentRow}:C${currentRow}`); + currentRow++; + + const periodData = [ + ["Start Date:", metrics.startDate], + ["End Date:", metrics.endDate], + ["Total Days:", metrics.totalDays], + ["Total Transactions:", metrics.totalTransactions], + ]; + + periodData.forEach(([label, value]) => { + summaryWorksheet.getCell(`A${currentRow}`).value = label; + summaryWorksheet.getCell(`A${currentRow}`).font = { bold: true }; + summaryWorksheet.getCell(`B${currentRow}`).value = value; + currentRow++; + }); + currentRow++; + + // Cash Flow Summary + summaryWorksheet.getCell(`A${currentRow}`).value = "CASH FLOW SUMMARY"; + summaryWorksheet.getCell(`A${currentRow}`).font = { bold: true, size: 14 }; + summaryWorksheet.getCell(`A${currentRow}`).fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFE7E6E6" }, + }; + summaryWorksheet.mergeCells(`A${currentRow}:C${currentRow}`); + currentRow++; + + const cashFlowData = [ + [ + "Total Money In (Income):", + `KSh ${metrics.totalMoneyIn.toLocaleString()}`, + ], + [ + "Total Money Out (Expenses):", + `KSh ${metrics.totalMoneyOut.toLocaleString()}`, + ], + ["Net Cash Flow:", `KSh ${metrics.netCashFlow.toLocaleString()}`], + ["Average Daily Income:", `KSh ${metrics.avgDailyIncome.toLocaleString()}`], + [ + "Average Daily Spending:", + `KSh ${metrics.avgDailySpending.toLocaleString()}`, + ], + ]; + + cashFlowData.forEach(([label, value]) => { + summaryWorksheet.getCell(`A${currentRow}`).value = label; + summaryWorksheet.getCell(`A${currentRow}`).font = { bold: true }; + summaryWorksheet.getCell(`B${currentRow}`).value = value; + + // Color coding for net cash flow + if (label.includes("Net Cash Flow")) { + summaryWorksheet.getCell(`B${currentRow}`).font = { + color: { argb: metrics.netCashFlow >= 0 ? "FF008000" : "FFFF0000" }, + bold: true, + }; + } + currentRow++; + }); + currentRow++; + + // Balance Analysis + summaryWorksheet.getCell(`A${currentRow}`).value = "BALANCE ANALYSIS"; + summaryWorksheet.getCell(`A${currentRow}`).font = { bold: true, size: 14 }; + summaryWorksheet.getCell(`A${currentRow}`).fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFE7E6E6" }, + }; + summaryWorksheet.mergeCells(`A${currentRow}:C${currentRow}`); + currentRow++; + + const balanceData = [ + ["Starting Balance:", `KSh ${metrics.startingBalance.toLocaleString()}`], + ["Ending Balance:", `KSh ${metrics.endingBalance.toLocaleString()}`], + ["Highest Balance:", `KSh ${metrics.highestBalance.toLocaleString()}`], + ["Lowest Balance:", `KSh ${metrics.lowestBalance.toLocaleString()}`], + ["Average Balance:", `KSh ${metrics.avgBalance.toLocaleString()}`], + ]; + + balanceData.forEach(([label, value]) => { + summaryWorksheet.getCell(`A${currentRow}`).value = label; + summaryWorksheet.getCell(`A${currentRow}`).font = { bold: true }; + summaryWorksheet.getCell(`B${currentRow}`).value = value; + currentRow++; + }); + currentRow++; + + // Transaction Patterns + summaryWorksheet.getCell(`A${currentRow}`).value = "TRANSACTION PATTERNS"; + summaryWorksheet.getCell(`A${currentRow}`).font = { bold: true, size: 14 }; + summaryWorksheet.getCell(`A${currentRow}`).fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFE7E6E6" }, + }; + summaryWorksheet.mergeCells(`A${currentRow}:C${currentRow}`); + currentRow++; + + const patternsData = [ + [ + "Highest Single Income:", + `KSh ${metrics.highestSingleIncome.toLocaleString()}`, + ], + [ + "Highest Single Expense:", + `KSh ${metrics.highestSingleExpense.toLocaleString()}`, + ], + ["Most Active Day:", metrics.mostActiveDay], + ["Busiest Day of Week:", metrics.busiestDayOfWeek], + ["Average Transactions/Day:", metrics.avgTransactionsPerDay.toFixed(1)], + ]; + + patternsData.forEach(([label, value]) => { + summaryWorksheet.getCell(`A${currentRow}`).value = label; + summaryWorksheet.getCell(`A${currentRow}`).font = { bold: true }; + summaryWorksheet.getCell(`B${currentRow}`).value = value; + currentRow++; + }); + currentRow++; + + // Format columns + summaryWorksheet.getColumn("A").width = 25; + summaryWorksheet.getColumn("B").width = 20; + summaryWorksheet.getColumn("C").width = 15; + + // Add borders to all used cells + for (let row = 1; row <= currentRow - 1; row++) { + for (let col = 1; col <= 3; col++) { + const cell = summaryWorksheet.getCell(row, col); + cell.border = { + top: { style: "thin" }, + left: { style: "thin" }, + bottom: { style: "thin" }, + right: { style: "thin" }, + }; + } + } +} + +function calculateFinancialMetrics(transactions: any[]): FinancialMetrics { + // Sort transactions by date + const sortedTransactions = [...transactions].sort( + (a, b) => + new Date(a.completionTime).getTime() - + new Date(b.completionTime).getTime() + ); + + const startDate = new Date( + sortedTransactions[0].completionTime + ).toDateString(); + const endDate = new Date( + sortedTransactions[sortedTransactions.length - 1].completionTime + ).toDateString(); + const totalDays = + Math.ceil( + (new Date( + sortedTransactions[sortedTransactions.length - 1].completionTime + ).getTime() - + new Date(sortedTransactions[0].completionTime).getTime()) / + (1000 * 60 * 60 * 24) + ) + 1; + + const totalMoneyIn = transactions.reduce( + (sum, t) => sum + (t.paidIn || 0), + 0 + ); + const totalMoneyOut = transactions.reduce( + (sum, t) => sum + (t.withdrawn || 0), + 0 + ); + const netCashFlow = totalMoneyIn - totalMoneyOut; + + const balances = transactions.map((t) => t.balance); + const highestBalance = Math.max(...balances); + const lowestBalance = Math.min(...balances); + const avgBalance = balances.reduce((sum, b) => sum + b, 0) / balances.length; + + const incomes = transactions.filter((t) => t.paidIn > 0).map((t) => t.paidIn); + const expenses = transactions + .filter((t) => t.withdrawn > 0) + .map((t) => t.withdrawn); + + const highestSingleIncome = incomes.length > 0 ? Math.max(...incomes) : 0; + const highestSingleExpense = expenses.length > 0 ? Math.max(...expenses) : 0; + + // Find most active day + const dailyTransactions: { [key: string]: number } = {}; + const dayOfWeekCounts: { [key: string]: number } = {}; + const daysOfWeek = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + ]; + + transactions.forEach((t) => { + const date = new Date(t.completionTime); + const dateStr = date.toDateString(); + const dayOfWeek = daysOfWeek[date.getDay()]; + + dailyTransactions[dateStr] = (dailyTransactions[dateStr] || 0) + 1; + dayOfWeekCounts[dayOfWeek] = (dayOfWeekCounts[dayOfWeek] || 0) + 1; + }); + + const mostActiveDay = Object.keys(dailyTransactions).reduce((a, b) => + dailyTransactions[a] > dailyTransactions[b] ? a : b + ); + + const busiestDayOfWeek = Object.keys(dayOfWeekCounts).reduce((a, b) => + dayOfWeekCounts[a] > dayOfWeekCounts[b] ? a : b + ); + + return { + startDate, + endDate, + totalDays, + totalTransactions: transactions.length, + totalMoneyIn, + totalMoneyOut, + netCashFlow, + avgDailyIncome: totalMoneyIn / totalDays, + avgDailySpending: totalMoneyOut / totalDays, + startingBalance: sortedTransactions[0].balance, + endingBalance: sortedTransactions[sortedTransactions.length - 1].balance, + highestBalance, + lowestBalance, + avgBalance, + highestSingleIncome, + highestSingleExpense, + mostActiveDay, + busiestDayOfWeek, + avgTransactionsPerDay: transactions.length / totalDays, + }; +} diff --git a/src/services/exports/index.ts b/src/services/exports/index.ts new file mode 100644 index 0000000..29880c5 --- /dev/null +++ b/src/services/exports/index.ts @@ -0,0 +1,4 @@ +export { addChargesSheet } from "./chargesSheet"; +export { addFinancialSummarySheet } from "./financialSummarySheet"; +export { addMonthlyWeeklyBreakdownSheet } from "./monthlyWeeklyBreakdownSheet"; +export { addDailyBalanceTrackerSheet } from "./dailyBalanceTrackerSheet"; diff --git a/src/services/exports/monthlyWeeklyBreakdownSheet.ts b/src/services/exports/monthlyWeeklyBreakdownSheet.ts new file mode 100644 index 0000000..19f3665 --- /dev/null +++ b/src/services/exports/monthlyWeeklyBreakdownSheet.ts @@ -0,0 +1,353 @@ +import { MPesaStatement } from "../../types"; +import * as ExcelJS from "exceljs"; + +interface AggregatedData { + period: string; + inflows: number; + outflows: number; + netChange: number; + avgTransaction: number; + transactionCount: number; + weekStart?: Date; +} + +export function addMonthlyWeeklyBreakdownSheet( + workbook: ExcelJS.Workbook, + statement: MPesaStatement +): void { + if (statement.transactions.length === 0) return; + + const breakdownWorksheet = workbook.addWorksheet( + "Monthly & Weekly Breakdown" + ); + + // Group transactions by month and week + const monthlyData = aggregateTransactionsByMonth(statement.transactions); + const weeklyData = aggregateTransactionsByWeek(statement.transactions); + + let currentRow = 1; + + // Title + breakdownWorksheet.getCell(`A${currentRow}`).value = + "MONTHLY & WEEKLY BREAKDOWN"; + breakdownWorksheet.getCell(`A${currentRow}`).font = { + bold: true, + size: 16, + }; + breakdownWorksheet.getCell(`A${currentRow}`).fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FF2E75B6" }, + }; + breakdownWorksheet.getCell(`A${currentRow}`).font.color = { + argb: "FFFFFFFF", + }; + breakdownWorksheet.mergeCells(`A${currentRow}:F${currentRow}`); + currentRow += 2; + + // Monthly Breakdown Section + breakdownWorksheet.getCell(`A${currentRow}`).value = "MONTHLY BREAKDOWN"; + breakdownWorksheet.getCell(`A${currentRow}`).font = { + bold: true, + size: 14, + }; + breakdownWorksheet.getCell(`A${currentRow}`).fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFE7E6E6" }, + }; + breakdownWorksheet.mergeCells(`A${currentRow}:F${currentRow}`); + currentRow++; + + // Monthly headers + const monthlyHeaders = [ + "Month", + "Inflows (KES)", + "Outflows (KES)", + "Net Change (KES)", + "Avg Transaction (KES)", + "Transaction Count", + ]; + monthlyHeaders.forEach((header, index) => { + const cell = breakdownWorksheet.getCell(currentRow, index + 1); + cell.value = header; + cell.font = { bold: true }; + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFD9E2F3" }, + }; + cell.alignment = { horizontal: "center" }; + cell.border = { + top: { style: "thin" }, + left: { style: "thin" }, + bottom: { style: "thin" }, + right: { style: "thin" }, + }; + }); + currentRow++; + + // Monthly data rows + monthlyData.forEach((monthData) => { + const rowData = [ + monthData.period, + monthData.inflows, + monthData.outflows, + monthData.netChange, + monthData.avgTransaction, + monthData.transactionCount, + ]; + + rowData.forEach((value, index) => { + const cell = breakdownWorksheet.getCell(currentRow, index + 1); + cell.value = value; + cell.border = { + top: { style: "thin" }, + left: { style: "thin" }, + bottom: { style: "thin" }, + right: { style: "thin" }, + }; + + // Format currency values + if (index >= 1 && index <= 4) { + cell.numFmt = "#,##0.00"; + } + + // Color code net change + if (index === 3) { + cell.font = { + color: { argb: monthData.netChange >= 0 ? "FF008000" : "FFFF0000" }, + bold: true, + }; + } + }); + currentRow++; + }); + + currentRow += 2; + + // Weekly Breakdown Section + breakdownWorksheet.getCell(`A${currentRow}`).value = "WEEKLY BREAKDOWN"; + breakdownWorksheet.getCell(`A${currentRow}`).font = { + bold: true, + size: 14, + }; + breakdownWorksheet.getCell(`A${currentRow}`).fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFE7E6E6" }, + }; + breakdownWorksheet.mergeCells(`A${currentRow}:F${currentRow}`); + currentRow++; + + // Weekly headers + const weeklyHeaders = [ + "Week", + "Inflows (KES)", + "Outflows (KES)", + "Net Change (KES)", + "Avg Transaction (KES)", + "Transaction Count", + ]; + weeklyHeaders.forEach((header, index) => { + const cell = breakdownWorksheet.getCell(currentRow, index + 1); + cell.value = header; + cell.font = { bold: true }; + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFD9E2F3" }, + }; + cell.alignment = { horizontal: "center" }; + cell.border = { + top: { style: "thin" }, + left: { style: "thin" }, + bottom: { style: "thin" }, + right: { style: "thin" }, + }; + }); + currentRow++; + + // Weekly data rows (show only last 12 weeks to keep it manageable) + const recentWeeks = weeklyData.slice(-12); + recentWeeks.forEach((weekData) => { + const rowData = [ + weekData.period, + weekData.inflows, + weekData.outflows, + weekData.netChange, + weekData.avgTransaction, + weekData.transactionCount, + ]; + + rowData.forEach((value, index) => { + const cell = breakdownWorksheet.getCell(currentRow, index + 1); + cell.value = value; + cell.border = { + top: { style: "thin" }, + left: { style: "thin" }, + bottom: { style: "thin" }, + right: { style: "thin" }, + }; + + // Format currency values + if (index >= 1 && index <= 4) { + cell.numFmt = "#,##0.00"; + } + + // Color code net change + if (index === 3) { + cell.font = { + color: { argb: weekData.netChange >= 0 ? "FF008000" : "FFFF0000" }, + bold: true, + }; + } + }); + currentRow++; + }); + + // Set column widths + breakdownWorksheet.columns = [ + { width: 20 }, // Period + { width: 15 }, // Inflows + { width: 15 }, // Outflows + { width: 15 }, // Net Change + { width: 18 }, // Avg Transaction + { width: 15 }, // Transaction Count + ]; +} + +function aggregateTransactionsByMonth(transactions: any[]): AggregatedData[] { + const monthlyMap = new Map< + string, + { + inflows: number; + outflows: number; + transactionCount: number; + transactions: any[]; + } + >(); + + transactions.forEach((transaction) => { + const date = new Date(transaction.completionTime); + const monthKey = `${date.getFullYear()}-${String( + date.getMonth() + 1 + ).padStart(2, "0")}`; + + if (!monthlyMap.has(monthKey)) { + monthlyMap.set(monthKey, { + inflows: 0, + outflows: 0, + transactionCount: 0, + transactions: [], + }); + } + + const monthData = monthlyMap.get(monthKey)!; + monthData.transactionCount++; + monthData.transactions.push(transaction); + + if (transaction.paidIn && transaction.paidIn > 0) { + monthData.inflows += transaction.paidIn; + } + if (transaction.withdrawn && transaction.withdrawn > 0) { + monthData.outflows += transaction.withdrawn; + } + }); + + return Array.from(monthlyMap.entries()) + .map(([monthKey, data]) => { + const date = new Date(monthKey + "-01"); + const monthName = date.toLocaleDateString("en-US", { + year: "numeric", + month: "long", + }); + const netChange = data.inflows - data.outflows; + const avgTransaction = + data.transactionCount > 0 + ? (data.inflows + data.outflows) / data.transactionCount + : 0; + + return { + period: monthName, + inflows: data.inflows, + outflows: data.outflows, + netChange, + avgTransaction, + transactionCount: data.transactionCount, + }; + }) + .sort((a, b) => a.period.localeCompare(b.period)); +} + +function aggregateTransactionsByWeek(transactions: any[]): AggregatedData[] { + const weeklyMap = new Map< + string, + { + inflows: number; + outflows: number; + transactionCount: number; + weekStart: Date; + } + >(); + + transactions.forEach((transaction) => { + const date = new Date(transaction.completionTime); + const weekStart = new Date(date); + weekStart.setDate(date.getDate() - date.getDay()); // Start of week (Sunday) + weekStart.setHours(0, 0, 0, 0); + + const weekKey = weekStart.toISOString().split("T")[0]; + + if (!weeklyMap.has(weekKey)) { + weeklyMap.set(weekKey, { + inflows: 0, + outflows: 0, + transactionCount: 0, + weekStart: new Date(weekStart), + }); + } + + const weekData = weeklyMap.get(weekKey)!; + weekData.transactionCount++; + + if (transaction.paidIn && transaction.paidIn > 0) { + weekData.inflows += transaction.paidIn; + } + if (transaction.withdrawn && transaction.withdrawn > 0) { + weekData.outflows += transaction.withdrawn; + } + }); + + return Array.from(weeklyMap.entries()) + .map(([, data]) => { + const weekEnd = new Date(data.weekStart); + weekEnd.setDate(data.weekStart.getDate() + 6); + + const periodLabel = `${data.weekStart.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + })} - ${weekEnd.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })}`; + + const netChange = data.inflows - data.outflows; + const avgTransaction = + data.transactionCount > 0 + ? (data.inflows + data.outflows) / data.transactionCount + : 0; + + return { + period: periodLabel, + inflows: data.inflows, + outflows: data.outflows, + netChange, + avgTransaction, + transactionCount: data.transactionCount, + weekStart: data.weekStart, + }; + }) + .sort((a, b) => a.weekStart!.getTime() - b.weekStart!.getTime()); +} diff --git a/src/services/xlsxService.ts b/src/services/xlsxService.ts index a0214e9..f635f56 100644 --- a/src/services/xlsxService.ts +++ b/src/services/xlsxService.ts @@ -1,5 +1,11 @@ import { MPesaStatement, ExportOptions } from "../types"; import * as ExcelJS from "exceljs"; +import { + addChargesSheet, + addFinancialSummarySheet, + addMonthlyWeeklyBreakdownSheet, + addDailyBalanceTrackerSheet, +} from "./exports"; export class XlsxService { static async convertStatementToXlsx( @@ -70,12 +76,22 @@ export class XlsxService { // Add Charges/Fees sheet if requested if (options?.includeChargesSheet) { - this.addChargesSheet(workbook, statement); + addChargesSheet(workbook, statement); } // Add Financial Summary sheet if requested if (options?.includeSummarySheet) { - this.addFinancialSummarySheet(workbook, statement); + addFinancialSummarySheet(workbook, statement); + } + + // Add Monthly/Weekly Breakdown sheet if requested + if (options?.includeBreakdownSheet) { + addMonthlyWeeklyBreakdownSheet(workbook, statement); + } + + // Add Daily Balance Tracker sheet if requested + if (options?.includeDailyBalanceSheet) { + addDailyBalanceTrackerSheet(workbook, statement); } const buffer = await workbook.xlsx.writeBuffer(); @@ -93,379 +109,6 @@ export class XlsxService { return URL.createObjectURL(blob); } - private static addChargesSheet( - workbook: ExcelJS.Workbook, - statement: MPesaStatement - ): void { - // Filter transactions that contain "charge" in the details (case insensitive) - const chargeTransactions = statement.transactions.filter((transaction) => - transaction.details.toLowerCase().includes("charge") - ); - - if (chargeTransactions.length === 0) { - return; // No charges found, don't create the sheet - } - - // Create the charges worksheet - const chargesWorksheet = workbook.addWorksheet("Charges & Fees"); - - // Define columns for charges sheet - chargesWorksheet.columns = [ - { header: "Receipt No", key: "receiptNo", width: 12 }, - { header: "Date", key: "date", width: 12 }, - { header: "Full Details", key: "fullDetails", width: 40 }, - { header: "Amount", key: "amount", width: 12 }, - ]; - - // Style the header row - const headerRow = chargesWorksheet.getRow(1); - headerRow.font = { bold: true }; - headerRow.fill = { - type: "pattern", - pattern: "solid", - fgColor: { argb: "FFFFD700" }, - }; - headerRow.alignment = { horizontal: "center" }; - - // Process and add charge transactions - chargeTransactions.forEach((transaction) => { - const amount = transaction.withdrawn || transaction.paidIn || 0; - - chargesWorksheet.addRow({ - receiptNo: transaction.receiptNo, - date: transaction.completionTime, - fullDetails: transaction.details, - amount: amount, - }); - }); - - // Add borders to all cells - const dataRange = chargesWorksheet.getRows(1, chargesWorksheet.rowCount); - if (dataRange) { - dataRange.forEach((row) => { - if (row) { - row.eachCell((cell) => { - cell.border = { - top: { style: "thin" }, - left: { style: "thin" }, - bottom: { style: "thin" }, - right: { style: "thin" }, - }; - }); - } - }); - } - - // Add summary at the bottom - const summaryStartRow = chargesWorksheet.rowCount + 2; - const totalCharges = chargeTransactions.reduce((sum, transaction) => { - return sum + (transaction.withdrawn || transaction.paidIn || 0); - }, 0); - - chargesWorksheet.getCell(`A${summaryStartRow}`).value = "Total Charges:"; - chargesWorksheet.getCell(`A${summaryStartRow}`).font = { bold: true }; - chargesWorksheet.getCell(`D${summaryStartRow}`).value = totalCharges; - chargesWorksheet.getCell(`D${summaryStartRow}`).font = { bold: true }; - chargesWorksheet.getCell(`D${summaryStartRow}`).fill = { - type: "pattern", - pattern: "solid", - fgColor: { argb: "FFFFE4B5" }, - }; - - chargesWorksheet.getCell(`A${summaryStartRow + 1}`).value = - "Number of Charge Transactions:"; - chargesWorksheet.getCell(`A${summaryStartRow + 1}`).font = { bold: true }; - chargesWorksheet.getCell(`D${summaryStartRow + 1}`).value = - chargeTransactions.length; - chargesWorksheet.getCell(`D${summaryStartRow + 1}`).font = { bold: true }; - } - - private static addFinancialSummarySheet( - workbook: ExcelJS.Workbook, - statement: MPesaStatement - ): void { - if (statement.transactions.length === 0) return; - - const summaryWorksheet = workbook.addWorksheet("Financial Summary"); - - // Calculate financial metrics - const metrics = this.calculateFinancialMetrics(statement.transactions); - - // Set up the layout - let currentRow = 1; - - // Title - summaryWorksheet.getCell(`A${currentRow}`).value = - "FINANCIAL SUMMARY REPORT"; - summaryWorksheet.getCell(`A${currentRow}`).font = { bold: true, size: 16 }; - summaryWorksheet.getCell(`A${currentRow}`).fill = { - type: "pattern", - pattern: "solid", - fgColor: { argb: "FF4472C4" }, - }; - summaryWorksheet.getCell(`A${currentRow}`).font.color = { - argb: "FFFFFFFF", - }; - summaryWorksheet.mergeCells(`A${currentRow}:C${currentRow}`); - currentRow += 2; - - // Period Overview - summaryWorksheet.getCell(`A${currentRow}`).value = "PERIOD OVERVIEW"; - summaryWorksheet.getCell(`A${currentRow}`).font = { bold: true, size: 14 }; - summaryWorksheet.getCell(`A${currentRow}`).fill = { - type: "pattern", - pattern: "solid", - fgColor: { argb: "FFE7E6E6" }, - }; - summaryWorksheet.mergeCells(`A${currentRow}:C${currentRow}`); - currentRow++; - - const periodData = [ - ["Start Date:", metrics.startDate], - ["End Date:", metrics.endDate], - ["Total Days:", metrics.totalDays], - ["Total Transactions:", metrics.totalTransactions], - ]; - - periodData.forEach(([label, value]) => { - summaryWorksheet.getCell(`A${currentRow}`).value = label; - summaryWorksheet.getCell(`A${currentRow}`).font = { bold: true }; - summaryWorksheet.getCell(`B${currentRow}`).value = value; - currentRow++; - }); - currentRow++; - - // Cash Flow Summary - summaryWorksheet.getCell(`A${currentRow}`).value = "CASH FLOW SUMMARY"; - summaryWorksheet.getCell(`A${currentRow}`).font = { bold: true, size: 14 }; - summaryWorksheet.getCell(`A${currentRow}`).fill = { - type: "pattern", - pattern: "solid", - fgColor: { argb: "FFE7E6E6" }, - }; - summaryWorksheet.mergeCells(`A${currentRow}:C${currentRow}`); - currentRow++; - - const cashFlowData = [ - [ - "Total Money In (Income):", - `KSh ${metrics.totalMoneyIn.toLocaleString()}`, - ], - [ - "Total Money Out (Expenses):", - `KSh ${metrics.totalMoneyOut.toLocaleString()}`, - ], - ["Net Cash Flow:", `KSh ${metrics.netCashFlow.toLocaleString()}`], - [ - "Average Daily Income:", - `KSh ${metrics.avgDailyIncome.toLocaleString()}`, - ], - [ - "Average Daily Spending:", - `KSh ${metrics.avgDailySpending.toLocaleString()}`, - ], - ]; - - cashFlowData.forEach(([label, value]) => { - summaryWorksheet.getCell(`A${currentRow}`).value = label; - summaryWorksheet.getCell(`A${currentRow}`).font = { bold: true }; - summaryWorksheet.getCell(`B${currentRow}`).value = value; - - // Color coding for net cash flow - if (label.includes("Net Cash Flow")) { - summaryWorksheet.getCell(`B${currentRow}`).font = { - color: { argb: metrics.netCashFlow >= 0 ? "FF008000" : "FFFF0000" }, - bold: true, - }; - } - currentRow++; - }); - currentRow++; - - // Balance Analysis - summaryWorksheet.getCell(`A${currentRow}`).value = "BALANCE ANALYSIS"; - summaryWorksheet.getCell(`A${currentRow}`).font = { bold: true, size: 14 }; - summaryWorksheet.getCell(`A${currentRow}`).fill = { - type: "pattern", - pattern: "solid", - fgColor: { argb: "FFE7E6E6" }, - }; - summaryWorksheet.mergeCells(`A${currentRow}:C${currentRow}`); - currentRow++; - - const balanceData = [ - ["Starting Balance:", `KSh ${metrics.startingBalance.toLocaleString()}`], - ["Ending Balance:", `KSh ${metrics.endingBalance.toLocaleString()}`], - ["Highest Balance:", `KSh ${metrics.highestBalance.toLocaleString()}`], - ["Lowest Balance:", `KSh ${metrics.lowestBalance.toLocaleString()}`], - ["Average Balance:", `KSh ${metrics.avgBalance.toLocaleString()}`], - ]; - - balanceData.forEach(([label, value]) => { - summaryWorksheet.getCell(`A${currentRow}`).value = label; - summaryWorksheet.getCell(`A${currentRow}`).font = { bold: true }; - summaryWorksheet.getCell(`B${currentRow}`).value = value; - currentRow++; - }); - currentRow++; - - // Transaction Patterns - summaryWorksheet.getCell(`A${currentRow}`).value = "TRANSACTION PATTERNS"; - summaryWorksheet.getCell(`A${currentRow}`).font = { bold: true, size: 14 }; - summaryWorksheet.getCell(`A${currentRow}`).fill = { - type: "pattern", - pattern: "solid", - fgColor: { argb: "FFE7E6E6" }, - }; - summaryWorksheet.mergeCells(`A${currentRow}:C${currentRow}`); - currentRow++; - - const patternsData = [ - [ - "Highest Single Income:", - `KSh ${metrics.highestSingleIncome.toLocaleString()}`, - ], - [ - "Highest Single Expense:", - `KSh ${metrics.highestSingleExpense.toLocaleString()}`, - ], - ["Most Active Day:", metrics.mostActiveDay], - ["Busiest Day of Week:", metrics.busiestDayOfWeek], - ["Average Transactions/Day:", metrics.avgTransactionsPerDay.toFixed(1)], - ]; - - patternsData.forEach(([label, value]) => { - summaryWorksheet.getCell(`A${currentRow}`).value = label; - summaryWorksheet.getCell(`A${currentRow}`).font = { bold: true }; - summaryWorksheet.getCell(`B${currentRow}`).value = value; - currentRow++; - }); - currentRow++; - - - - // Format columns - summaryWorksheet.getColumn("A").width = 25; - summaryWorksheet.getColumn("B").width = 20; - summaryWorksheet.getColumn("C").width = 15; - - // Add borders to all used cells - for (let row = 1; row <= currentRow - 1; row++) { - for (let col = 1; col <= 3; col++) { - const cell = summaryWorksheet.getCell(row, col); - cell.border = { - top: { style: "thin" }, - left: { style: "thin" }, - bottom: { style: "thin" }, - right: { style: "thin" }, - }; - } - } - } - - private static calculateFinancialMetrics(transactions: any[]) { - // Sort transactions by date - const sortedTransactions = [...transactions].sort( - (a, b) => - new Date(a.completionTime).getTime() - - new Date(b.completionTime).getTime() - ); - - const startDate = new Date( - sortedTransactions[0].completionTime - ).toDateString(); - const endDate = new Date( - sortedTransactions[sortedTransactions.length - 1].completionTime - ).toDateString(); - const totalDays = - Math.ceil( - (new Date( - sortedTransactions[sortedTransactions.length - 1].completionTime - ).getTime() - - new Date(sortedTransactions[0].completionTime).getTime()) / - (1000 * 60 * 60 * 24) - ) + 1; - - const totalMoneyIn = transactions.reduce( - (sum, t) => sum + (t.paidIn || 0), - 0 - ); - const totalMoneyOut = transactions.reduce( - (sum, t) => sum + (t.withdrawn || 0), - 0 - ); - const netCashFlow = totalMoneyIn - totalMoneyOut; - - const balances = transactions.map((t) => t.balance); - const highestBalance = Math.max(...balances); - const lowestBalance = Math.min(...balances); - const avgBalance = - balances.reduce((sum, b) => sum + b, 0) / balances.length; - - const incomes = transactions - .filter((t) => t.paidIn > 0) - .map((t) => t.paidIn); - const expenses = transactions - .filter((t) => t.withdrawn > 0) - .map((t) => t.withdrawn); - - const highestSingleIncome = incomes.length > 0 ? Math.max(...incomes) : 0; - const highestSingleExpense = - expenses.length > 0 ? Math.max(...expenses) : 0; - - // Find most active day - const dailyTransactions: { [key: string]: number } = {}; - const dayOfWeekCounts: { [key: string]: number } = {}; - const daysOfWeek = [ - "Sunday", - "Monday", - "Tuesday", - "Wednesday", - "Thursday", - "Friday", - "Saturday", - ]; - - transactions.forEach((t) => { - const date = new Date(t.completionTime); - const dateStr = date.toDateString(); - const dayOfWeek = daysOfWeek[date.getDay()]; - - dailyTransactions[dateStr] = (dailyTransactions[dateStr] || 0) + 1; - dayOfWeekCounts[dayOfWeek] = (dayOfWeekCounts[dayOfWeek] || 0) + 1; - }); - - const mostActiveDay = Object.keys(dailyTransactions).reduce((a, b) => - dailyTransactions[a] > dailyTransactions[b] ? a : b - ); - - const busiestDayOfWeek = Object.keys(dayOfWeekCounts).reduce((a, b) => - dayOfWeekCounts[a] > dayOfWeekCounts[b] ? a : b - ); - - return { - startDate, - endDate, - totalDays, - totalTransactions: transactions.length, - totalMoneyIn, - totalMoneyOut, - netCashFlow, - avgDailyIncome: totalMoneyIn / totalDays, - avgDailySpending: totalMoneyOut / totalDays, - startingBalance: sortedTransactions[0].balance, - endingBalance: sortedTransactions[sortedTransactions.length - 1].balance, - highestBalance, - lowestBalance, - avgBalance, - highestSingleIncome, - highestSingleExpense, - mostActiveDay, - busiestDayOfWeek, - avgTransactionsPerDay: transactions.length / totalDays, - }; - } - static getFileName(statement: MPesaStatement, timestamp?: string): string { const baseFileName = statement.fileName ? statement.fileName.replace(/\.[^/.]+$/, "") // Remove extension diff --git a/src/types/index.ts b/src/types/index.ts index 405414f..a930b83 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -31,4 +31,6 @@ export enum ExportFormat { export interface ExportOptions { includeChargesSheet?: boolean; includeSummarySheet?: boolean; + includeBreakdownSheet?: boolean; + includeDailyBalanceSheet?: boolean; } From 43a16f2b43463e2c4a9677c346baced19141891a Mon Sep 17 00:00:00 2001 From: David Amunga Date: Fri, 26 Sep 2025 18:17:14 +0300 Subject: [PATCH 2/2] chore: added changeset --- .changeset/bright-turtles-type.md | 5 +++++ .changeset/full-carrots-shave.md | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 .changeset/bright-turtles-type.md create mode 100644 .changeset/full-carrots-shave.md diff --git a/.changeset/bright-turtles-type.md b/.changeset/bright-turtles-type.md new file mode 100644 index 0000000..9da1922 --- /dev/null +++ b/.changeset/bright-turtles-type.md @@ -0,0 +1,5 @@ +--- +"mpesa2csv": minor +--- + +feat: add daily balance tracker sheet diff --git a/.changeset/full-carrots-shave.md b/.changeset/full-carrots-shave.md new file mode 100644 index 0000000..b4649a4 --- /dev/null +++ b/.changeset/full-carrots-shave.md @@ -0,0 +1,5 @@ +--- +"mpesa2csv": minor +--- + +feat: add monthly & weekly breakdown sheet