From 65c8984b738ad2b2d67b8f3dcb66397e2eec7b3e Mon Sep 17 00:00:00 2001 From: David Amunga Date: Tue, 14 Jul 2026 21:40:55 +0300 Subject: [PATCH 1/2] fix: analytics sheets now respect active export filters Pass filteredStatement to all optional Excel analytics sheets so filters like "exclude charges" and sort order are consistently applied. The Charges & Fees sheet intentionally retains the unfiltered statement since its sole purpose is to display charge transactions. --- .../fix-analytics-sheets-respect-filters.md | 5 +++ src/services/xlsxService.ts | 33 ++++++++----------- 2 files changed, 19 insertions(+), 19 deletions(-) create mode 100644 .changeset/fix-analytics-sheets-respect-filters.md diff --git a/.changeset/fix-analytics-sheets-respect-filters.md b/.changeset/fix-analytics-sheets-respect-filters.md new file mode 100644 index 0000000..af02dff --- /dev/null +++ b/.changeset/fix-analytics-sheets-respect-filters.md @@ -0,0 +1,5 @@ +--- +"mpesa2csv": patch +--- + +Fix analytics sheets ignoring active export filters (e.g. exclude charges, sort order). diff --git a/src/services/xlsxService.ts b/src/services/xlsxService.ts index 95b84bc..62063b2 100644 --- a/src/services/xlsxService.ts +++ b/src/services/xlsxService.ts @@ -112,54 +112,49 @@ export class XlsxService { }); } - // Add Charges/Fees sheet if requested + // Add Charges/Fees sheet if requested. + // Always uses the unfiltered statement so the sheet has data even when + // "filter out charges" is active on the main sheet. if (options?.includeChargesSheet) { addChargesSheet(workbook, statement); } - // Add Financial Summary sheet if requested + // All remaining analytics sheets use filteredStatement so active filters + // (e.g. exclude charges, sort order) are consistently reflected. if (options?.includeSummarySheet) { - addFinancialSummarySheet(workbook, statement); + addFinancialSummarySheet(workbook, filteredStatement); } - // Add Monthly/Weekly Breakdown sheet if requested if (options?.includeBreakdownSheet) { - addMonthlyWeeklyBreakdownSheet(workbook, statement); + addMonthlyWeeklyBreakdownSheet(workbook, filteredStatement); } - // Add Daily Balance Tracker sheet if requested if (options?.includeDailyBalanceSheet) { - addDailyBalanceTrackerSheet(workbook, statement); + addDailyBalanceTrackerSheet(workbook, filteredStatement); } - // Add Transaction Amount Distribution sheet if requested if (options?.includeAmountDistributionSheet) { - addTransactionAmountDistributionSheet(workbook, statement); + addTransactionAmountDistributionSheet(workbook, filteredStatement); } - // Add Top Contacts sheet if requested if (options?.includeTopContactsSheet) { - addTopContactsSheet(workbook, statement); + addTopContactsSheet(workbook, filteredStatement); } - // Add Money In sheet if requested if (options?.includeMoneyInSheet) { - addMoneyInSheet(workbook, statement); + addMoneyInSheet(workbook, filteredStatement); } - // Add Money Out sheet if requested if (options?.includeMoneyOutSheet) { - addMoneyOutSheet(workbook, statement); + addMoneyOutSheet(workbook, filteredStatement); } - // Add Recurring Transactions sheet if requested if (options?.includeRecurringTransactionsSheet) { - addRecurringTransactionsSheet(workbook, statement); + addRecurringTransactionsSheet(workbook, filteredStatement); } - // Add Time-of-Day Activity sheet if requested if (options?.includeTimeOfDaySheet) { - addTimeOfDayActivitySheet(workbook, statement); + addTimeOfDayActivitySheet(workbook, filteredStatement); } const buffer = await workbook.xlsx.writeBuffer(); From 98f56be85d73804181da370bfbb4a3eff1f38670 Mon Sep 17 00:00:00 2001 From: David Amunga Date: Tue, 14 Jul 2026 21:49:43 +0300 Subject: [PATCH 2/2] fix: multi-file password state loss, silent empty success, timeout leaves Java running MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1 – preserve already-processed statements in a ref when a mid-batch file triggers a password prompt so handlePasswordSubmit doesn't rebuild the list from stale (empty) React state. Bug 4 – throw an error after parseTabulaCSV when zero transactions are found so the UI reaches ERROR rather than a silent SUCCESS screen. Bug 5 – switch Tabula spawn from output() to spawn()+wait_with_output(), store the child PID in Tauri managed state, and add a cancel_pdf_extraction command. The TS timeout now calls that command before rejecting so the Java process is killed rather than left running in the background. --- .../fix-multi-file-password-state-loss.md | 5 ++ .changeset/fix-silent-empty-success.md | 5 ++ .changeset/fix-timeout-kills-java.md | 5 ++ src-tauri/src/lib.rs | 50 +++++++++++++++- src/App.tsx | 60 +++++++++++++------ 5 files changed, 104 insertions(+), 21 deletions(-) create mode 100644 .changeset/fix-multi-file-password-state-loss.md create mode 100644 .changeset/fix-silent-empty-success.md create mode 100644 .changeset/fix-timeout-kills-java.md diff --git a/.changeset/fix-multi-file-password-state-loss.md b/.changeset/fix-multi-file-password-state-loss.md new file mode 100644 index 0000000..07621c7 --- /dev/null +++ b/.changeset/fix-multi-file-password-state-loss.md @@ -0,0 +1,5 @@ +--- +"mpesa2csv": patch +--- + +Fix multi-file batch losing already-processed statements when a mid-batch file requires a password. diff --git a/.changeset/fix-silent-empty-success.md b/.changeset/fix-silent-empty-success.md new file mode 100644 index 0000000..b698ca0 --- /dev/null +++ b/.changeset/fix-silent-empty-success.md @@ -0,0 +1,5 @@ +--- +"mpesa2csv": patch +--- + +Treat a parsed PDF with zero transactions as an error instead of showing a silent success screen. diff --git a/.changeset/fix-timeout-kills-java.md b/.changeset/fix-timeout-kills-java.md new file mode 100644 index 0000000..d5c5a16 --- /dev/null +++ b/.changeset/fix-timeout-kills-java.md @@ -0,0 +1,5 @@ +--- +"mpesa2csv": patch +--- + +Kill the Java/Tabula process when PDF extraction times out instead of leaving it running in the background. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2801eab..7396032 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,8 +1,33 @@ +/// Shared state that holds the PID of the currently running Java/Tabula process +/// so it can be killed if the frontend times out. +pub struct ActivePid(pub std::sync::Mutex>); + #[tauri::command] fn greet(name: &str) -> String { format!("Hello, {}! You've been greeted from Rust!", name) } +#[tauri::command] +fn cancel_pdf_extraction(active_pid: tauri::State<'_, ActivePid>) { + let pid = active_pid.0.lock().unwrap().take(); + if let Some(pid) = pid { + #[cfg(unix)] + { + std::process::Command::new("kill") + .args(["-9", &pid.to_string()]) + .output() + .ok(); + } + #[cfg(windows)] + { + std::process::Command::new("taskkill") + .args(["/F", "/PID", &pid.to_string()]) + .output() + .ok(); + } + } +} + #[cfg(target_os = "windows")] fn normalize_windows_path(path: std::path::PathBuf) -> std::path::PathBuf { use std::path::PathBuf; @@ -24,6 +49,7 @@ fn normalize_windows_path(path: std::path::PathBuf) -> std::path::PathBuf { #[tauri::command] async fn extract_pdf_tables( app_handle: tauri::AppHandle, + active_pid: tauri::State<'_, ActivePid>, pdf_path: String, output_path: String, password: Option, @@ -136,8 +162,8 @@ async fn extract_pdf_tables( cmd.arg("--password").arg(pwd); } - let output = cmd - .output() + let child = cmd + .spawn() .map_err(|e| { format!( "Failed to execute Java (using {}): {}\n\ @@ -157,6 +183,23 @@ async fn extract_pdf_tables( e ) })?; + + // Store the PID so cancel_pdf_extraction can kill this process if the + // frontend times out. + let pid = child.id(); + *active_pid.0.lock().unwrap() = Some(pid); + + let output = child + .wait_with_output() + .map_err(|e| format!("Failed waiting for Java process: {}", e))?; + + // Clear the PID — process has finished. + { + let mut guard = active_pid.0.lock().unwrap(); + if *guard == Some(pid) { + *guard = None; + } + } if output.status.success() { Ok(format!("Tables extracted successfully to: {}", output_path)) @@ -332,12 +375,13 @@ async fn save_file( #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + .manage(ActivePid(std::sync::Mutex::new(None))) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_updater::Builder::new().build()) - .invoke_handler(tauri::generate_handler![greet, extract_pdf_tables, save_csv_file, save_file, get_app_version, open_file]) + .invoke_handler(tauri::generate_handler![greet, extract_pdf_tables, cancel_pdf_extraction, save_csv_file, save_file, get_app_version, open_file]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 508f23e..15f311f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { invoke } from "@tauri-apps/api/core"; import { platform } from "@tauri-apps/plugin-os"; import { Download, RotateCcw, ExternalLink, MessageSquare } from "lucide-react"; @@ -39,6 +39,9 @@ function App() { includeTopContactsSheet: false, }); const [currentFileIndex, setCurrentFileIndex] = useState(0); + // Holds statements processed before a mid-batch password prompt so they + // are not lost when handlePasswordSubmit runs (statements state is still []). + const pendingStatements = useRef([]); const [isDownloading, setIsDownloading] = useState(false); const [appVersion, setAppVersion] = useState(""); const [downloadSuccess, setDownloadSuccess] = useState(false); @@ -109,7 +112,9 @@ function App() { try { const result = await processFiles(selectedFiles); - if (result?.error) { + if (result?.needsPassword) { + pendingStatements.current = result.processedStatements; + } else if (result?.error) { setStatus(FileStatus.ERROR); setError(result.error); } @@ -135,24 +140,36 @@ function App() { try { setStatus(FileStatus.PROCESSING); + let timeoutHandle: ReturnType | undefined; + const extractionPromise = TabulaService.extractTablesFromPdf(file); + const timeoutPromise = new Promise((_, reject) => { + timeoutHandle = setTimeout(async () => { + try { + await invoke("cancel_pdf_extraction"); + } catch { + // best-effort cancel — ignore errors + } + reject( + new Error( + `PDF processing timeout after ${ + TIMEOUTS.PDF_PROCESSING / 1000 + } seconds` + ) + ); + }, TIMEOUTS.PDF_PROCESSING); + }); + const csvContent = await Promise.race([ - TabulaService.extractTablesFromPdf(file), - new Promise((_, reject) => - setTimeout( - () => - reject( - new Error( - `PDF processing timeout after ${ - TIMEOUTS.PDF_PROCESSING / 1000 - } seconds` - ) - ), - TIMEOUTS.PDF_PROCESSING - ) - ), + extractionPromise.finally(() => clearTimeout(timeoutHandle)), + timeoutPromise, ]); const statement = TabulaService.parseTabulaCSV(csvContent); + if (statement.transactions.length === 0) { + throw new Error( + `No transactions found in "${file.name}". The file may not be a valid M-PESA statement.` + ); + } statement.fileName = file.name; processedStatements.push(statement); } catch (err: any) { @@ -242,13 +259,19 @@ function App() { const statement = TabulaService.parseTabulaCSV(csvContent); statement.fileName = currentFile.name; - const updatedStatements = [...statements, statement]; + // Use the ref so already-processed files are not lost (statements state + // is still [] when processFiles returned early for a password prompt). + const updatedStatements = [...pendingStatements.current, statement]; + pendingStatements.current = []; setStatements(updatedStatements); const nextIndex = currentFileIndex + 1; if (nextIndex < files.length) { const result = await processFiles(files, nextIndex, updatedStatements); - if (result?.error) { + if (result?.needsPassword) { + // Another file in the batch needs a password; persist current progress. + pendingStatements.current = result.processedStatements; + } else if (result?.error) { setStatus(FileStatus.ERROR); setError(result.error); } @@ -320,6 +343,7 @@ function App() { setError(undefined); setStatements([]); setCurrentFileIndex(0); + pendingStatements.current = []; setIsDownloading(false); setDownloadSuccess(false); setSavedFilePath("");