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