From 5ae81028c10f820c43cd59b93a3010cc09bdfd56 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 8 Aug 2026 18:54:20 +0530 Subject: [PATCH 01/12] refactor(reports): allocate PDF object references Replace page/content offset arithmetic with one checked allocator used for every indirect PDF object. This removes the fragile assumption that page references start at 10 and content streams at 11. Mutation proof: replacing the allocator increment with a self-assignment made pdf_object_allocator_issues_unique_sequential_references fail with [1, 1, 1, 1, 1, 1, 1, 1] instead of [1, 2, 3, 4, 5, 6, 7, 8]. Restored implementation passes. --- src-tauri/src/reports/party_statement_pdf.rs | 56 +++++++++++++++----- 1 file changed, 44 insertions(+), 12 deletions(-) diff --git a/src-tauri/src/reports/party_statement_pdf.rs b/src-tauri/src/reports/party_statement_pdf.rs index 637cbcc..050be7e 100644 --- a/src-tauri/src/reports/party_statement_pdf.rs +++ b/src-tauri/src/reports/party_statement_pdf.rs @@ -42,6 +42,28 @@ struct PdfLine { bold: bool, } +/// Allocates indirect PDF object references without making later document +/// layouts depend on hand-maintained numeric offsets. +#[derive(Debug)] +struct PdfObjectAllocator { + next_id: i32, +} + +impl PdfObjectAllocator { + fn new() -> Self { + Self { next_id: 1 } + } + + fn allocate(&mut self) -> Result { + let id = self.next_id; + self.next_id = self + .next_id + .checked_add(1) + .ok_or(PartyStatementPdfError::TooManyPages)?; + Ok(Ref::new(id)) + } +} + impl PdfLine { fn body(text: impl Into) -> Self { Self { @@ -72,21 +94,18 @@ pub fn render_party_statement_pdf( let page_count_i32 = i32::try_from(page_count).map_err(|_| PartyStatementPdfError::TooManyPages)?; - let catalog_id = Ref::new(1); - let pages_id = Ref::new(2); - let regular_font_id = Ref::new(3); - let bold_font_id = Ref::new(4); + let mut object_ids = PdfObjectAllocator::new(); + let catalog_id = object_ids.allocate()?; + let pages_id = object_ids.allocate()?; + let regular_font_id = object_ids.allocate()?; + let bold_font_id = object_ids.allocate()?; let regular_font = Name(b"F1"); let bold_font = Name(b"F2"); let mut pdf = Pdf::new(); pdf.catalog(catalog_id).pages(pages_id); let page_ids: Vec<_> = (0..page_count) - .map(|index| { - i32::try_from(index) - .map(|index| Ref::new(10 + index * 2)) - .map_err(|_| PartyStatementPdfError::TooManyPages) - }) + .map(|_| object_ids.allocate()) .collect::>()?; pdf.pages(pages_id) .kids(page_ids.iter().copied()) @@ -98,9 +117,7 @@ pub fn render_party_statement_pdf( for (page_index, page_lines) in lines.chunks(BODY_LINES_PER_PAGE).enumerate() { let page_id = page_ids[page_index]; - let content_id = i32::try_from(page_index) - .map(|index| Ref::new(11 + index * 2)) - .map_err(|_| PartyStatementPdfError::TooManyPages)?; + let content_id = object_ids.allocate()?; { let mut page = pdf.page(page_id); page.parent(pages_id) @@ -341,6 +358,21 @@ mod tests { use std::io::{Cursor, Read}; use zip::ZipArchive; + #[test] + fn pdf_object_allocator_issues_unique_sequential_references() { + let mut allocator = PdfObjectAllocator::new(); + let ids = (0..8) + .map(|_| { + allocator + .allocate() + .expect("eight PDF references fit") + .get() + }) + .collect::>(); + + assert_eq!(ids, (1..=8).collect::>()); + } + fn bill(reference: &str, amount: &str, age_days: u32) -> OpenBillRow { OpenBillRow { party: "Synthetic Party".to_string(), From 714a5121a9035e000112c59071d8e302cfc3a824 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 8 Aug 2026 19:15:14 +0530 Subject: [PATCH 02/12] feat(reports): export overdue party statements in bulk Write one XLSX or PDF per party into an operator-selected folder, with a JSON manifest that records each original party, generated file, exact total, as-of date, and any failure. Excel remains the default action. The dashboard keeps its display caps while the completed outstandings result carries uncapped statement-source rows; the batch never rereads Tally and cannot silently omit a party past the top-ten or 2,000-row UI projections. Partial rendering failures are best-effort but loud: remaining parties continue, while the command result and manifest retain every failed party and error. Mutation proof: returning the raw traversal party name caused the traversal batch test to write zero statements; dropping renderer-failure recording made its expected failure count 0; replacing create_new with truncating writes made colliding names identical; forcing a manifest amount to 0 failed its exact-total assertion. Each mutation was restored and the focused test passed. --- src-tauri/src/commands.rs | 67 ++++ src-tauri/src/lib.rs | 2 + src-tauri/src/reports/bulk_party_statement.rs | 315 ++++++++++++++++++ src-tauri/src/reports/mod.rs | 1 + src-tauri/src/tally/runtime.rs | 47 ++- src/OutstandingsScreen.tsx | 95 +++++- src/styles.css | 8 + 7 files changed, 522 insertions(+), 13 deletions(-) create mode 100644 src-tauri/src/reports/bulk_party_statement.rs diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index f6effd9..dff5b06 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -9,6 +9,7 @@ use crate::db::tally_mirror::{ WriteFixtureEnrollmentInput, WriteFixtureEnrollmentStatus, }; use crate::gst::{GstDraftRequest, GstReturnDraft}; +use crate::reports::bulk_party_statement::write_bulk_party_statements; use crate::reports::party_statement::{build_party_statement, PartyStatementError}; use crate::reports::party_statement_pdf::render_party_statement_pdf; use crate::reports::party_statement_xlsx::render_party_statement_xlsx; @@ -2918,6 +2919,72 @@ pub enum PartyStatementFormat { Pdf, } +#[derive(Debug, Deserialize)] +pub struct ExportBulkPartyStatementsRequest { + pub company: String, + pub as_of_yyyymmdd: String, + pub format: PartyStatementFormat, + /// Chosen by the native folder picker. The command still checks that it + /// exists and is a directory before any statement name is joined to it. + pub destination: String, + /// These are complete statement-source rows from the finished local read, + /// not the top-ten/2,000-row projections rendered in the dashboard. + pub open_bills: Vec, + pub unallocated_by_party: Vec, +} + +/// Lets the operator choose where the whole statement batch will be written. +#[tauri::command] +pub async fn select_party_statement_destination() -> Result, String> { + tokio::task::spawn_blocking(|| { + Ok(rfd::FileDialog::new() + .set_title("Choose a folder for party statements") + .pick_folder() + .map(|path| path.to_string_lossy().into_owned())) + }) + .await + .map_err(|_| "Bridge could not open the statement destination picker.".to_string())? +} + +/// Writes a separate statement for every party in the completed source rows. +/// A failed party remains visible in the returned result and manifest while +/// the remaining parties continue, so an operator cannot mistake a partial +/// batch for a complete send-ready set. +#[tauri::command] +pub async fn export_bulk_party_statements( + request: ExportBulkPartyStatementsRequest, +) -> Result { + let open_bills = request + .open_bills + .into_iter() + .map(into_open_bill_row) + .collect::, _>>()?; + let destination = std::path::PathBuf::from(request.destination); + + match request.format { + PartyStatementFormat::Xlsx => write_bulk_party_statements( + &destination, + &request.company, + &request.as_of_yyyymmdd, + "xlsx", + "xlsx", + &open_bills, + &request.unallocated_by_party, + |statement| render_party_statement_xlsx(statement).map_err(|error| error.to_string()), + ), + PartyStatementFormat::Pdf => write_bulk_party_statements( + &destination, + &request.company, + &request.as_of_yyyymmdd, + "pdf", + "pdf", + &open_bills, + &request.unallocated_by_party, + |statement| render_party_statement_pdf(statement).map_err(|error| error.to_string()), + ), + } +} + /// Builds one party's aged-bills statement in the requested format and writes /// it to the user's Downloads folder. /// diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 12e9790..4dcb27b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -90,6 +90,8 @@ pub fn run() { commands::save_report_download, commands::reveal_exported_file, commands::export_party_statement, + commands::select_party_statement_destination, + commands::export_bulk_party_statements, commands::fetch_tally_outstandings_all_companies, commands::detect_tally_base_currency, commands::tally_persisted_company_profiles, diff --git a/src-tauri/src/reports/bulk_party_statement.rs b/src-tauri/src/reports/bulk_party_statement.rs new file mode 100644 index 0000000..9048be9 --- /dev/null +++ b/src-tauri/src/reports/bulk_party_statement.rs @@ -0,0 +1,315 @@ +//! Writes one statement file per party from an already-complete local result. +//! +//! This module deliberately has no Tally transport dependency. The caller +//! supplies the rows obtained during the completed outstandings read. + +use std::collections::BTreeSet; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use serde::Serialize; + +use super::party_statement::{build_party_statement, PartyStatement}; +use crate::tally::{OpenBillRow, UnallocatedParty}; + +#[derive(Debug, Clone, Serialize)] +pub struct BulkPartyStatementResult { + pub destination: String, + pub manifest_path: String, + pub written: Vec, + pub failures: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct WrittenStatement { + pub party: String, + pub file_name: String, + pub amount: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct StatementFailure { + pub party: String, + pub error: String, +} + +#[derive(Serialize)] +struct StatementManifest<'a> { + as_of_yyyymmdd: &'a str, + format: &'a str, + written: &'a [WrittenStatement], + failures: &'a [StatementFailure], +} + +/// Produces a separate file for every party represented in the complete rows. +/// +/// Per-party failures are retained and reported after every remaining party is +/// attempted. That makes a partially completed batch explicit, while still +/// giving the operator usable files for parties whose statements rendered. +pub fn write_bulk_party_statements( + destination: &Path, + company: &str, + as_of_yyyymmdd: &str, + format: &str, + extension: &str, + open_bills: &[OpenBillRow], + unallocated_by_party: &[UnallocatedParty], + render: impl Fn(&PartyStatement) -> Result, String>, +) -> Result { + if !destination.is_dir() { + return Err("Bridge could not use that statement destination folder.".to_string()); + } + + let parties = statement_parties(open_bills, unallocated_by_party); + let mut written = Vec::with_capacity(parties.len()); + let mut failures = Vec::new(); + for party in parties { + let statement = match build_party_statement( + company, + as_of_yyyymmdd, + &party, + open_bills, + unallocated_by_party, + ) { + Ok(statement) => statement, + Err(error) => { + failures.push(StatementFailure { + party, + error: error.to_string(), + }); + continue; + } + }; + let amount = statement.grand_total.as_str().to_string(); + let bytes = match render(&statement) { + Ok(bytes) => bytes, + Err(error) => { + failures.push(StatementFailure { party, error }); + continue; + } + }; + let stem = format!( + "statement-{}-{as_of_yyyymmdd}", + safe_party_slug(&statement.party) + ); + match write_unique_file(destination, &stem, extension, &bytes) { + Ok(path) => written.push(WrittenStatement { + party, + file_name: file_name(&path)?, + amount, + }), + Err(error) => failures.push(StatementFailure { party, error }), + } + } + + let manifest = StatementManifest { + as_of_yyyymmdd, + format, + written: &written, + failures: &failures, + }; + let manifest_bytes = serde_json::to_vec_pretty(&manifest) + .map_err(|error| format!("Bridge could not build the statement manifest: {error}"))?; + let manifest_path = write_unique_file( + destination, + &format!("statement-manifest-{as_of_yyyymmdd}"), + "json", + &manifest_bytes, + )?; + + Ok(BulkPartyStatementResult { + destination: destination.to_string_lossy().into_owned(), + manifest_path: manifest_path.to_string_lossy().into_owned(), + written, + failures, + }) +} + +fn statement_parties( + open_bills: &[OpenBillRow], + unallocated_by_party: &[UnallocatedParty], +) -> BTreeSet { + open_bills + .iter() + .filter(|row| !row.amount.is_zero()) + .map(|row| row.party.clone()) + .chain( + unallocated_by_party + .iter() + .filter(|entry| !entry.amount.is_zero()) + .map(|entry| entry.party.clone()), + ) + .collect() +} + +/// Converts arbitrary ledger text to a portable ASCII filename component. +/// Separators, controls, Windows-reserved punctuation, leading dots, trailing +/// spaces, and non-ASCII characters all become collapsed hyphens. +fn safe_party_slug(party: &str) -> String { + let mut slug = String::with_capacity(party.len()); + let mut previous_was_dash = false; + for ch in party.chars() { + if ch.is_ascii_alphanumeric() { + slug.push(ch.to_ascii_lowercase()); + previous_was_dash = false; + } else if !previous_was_dash { + slug.push('-'); + previous_was_dash = true; + } + } + let trimmed = slug.trim_matches('-'); + if trimmed.is_empty() { + "party".to_string() + } else { + trimmed.chars().take(120).collect() + } +} + +/// Creates a previously unused filename. `create_new` closes the race between +/// candidate selection and writing, so neither a same-run slug collision nor +/// a pre-existing file can be silently overwritten. +fn write_unique_file( + destination: &Path, + stem: &str, + extension: &str, + bytes: &[u8], +) -> Result { + if Path::new(stem).components().count() != 1 { + return Err("Bridge could not build a safe statement filename.".to_string()); + } + for sequence in 1..=10_000_u32 { + let suffix = if sequence == 1 { + String::new() + } else { + format!("-{sequence}") + }; + let path = destination.join(format!("{stem}{suffix}.{extension}")); + let mut file = match OpenOptions::new().write(true).create_new(true).open(&path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => { + return Err(format!( + "Bridge could not create {}: {error}", + path.display() + )) + } + }; + if let Err(error) = file.write_all(bytes) { + drop(file); + let _ = fs::remove_file(&path); + return Err(format!( + "Bridge could not finish writing {}: {error}", + path.display() + )); + } + return Ok(path); + } + Err("Bridge could not find an unused statement filename after 10,000 attempts.".to_string()) +} + +fn file_name(path: &Path) -> Result { + path.file_name() + .and_then(|name| name.to_str()) + .map(str::to_owned) + .ok_or_else(|| "Bridge could not represent the statement filename.".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use bridge_tally_core::ExactDecimal; + + fn bill(party: &str, amount: &str) -> OpenBillRow { + OpenBillRow { + party: party.to_string(), + reference: "SYNTHETIC-1".to_string(), + bill_date: "20260101".to_string(), + due_date: "20260201".to_string(), + amount: ExactDecimal::parse(amount).expect("synthetic decimal"), + age_days: 40, + kind: "receivable", + } + } + + #[test] + fn traversal_like_party_name_cannot_escape_the_selected_directory() { + let destination = tempfile::tempdir().expect("temporary destination"); + let result = write_bulk_party_statements( + destination.path(), + "Synthetic Books Pvt Ltd", + "20260808", + "xlsx", + "xlsx", + &[bill("../../etc/passwd", "15.00")], + &[], + |_| Ok(b"synthetic workbook".to_vec()), + ) + .expect("statement batch succeeds"); + + assert_eq!(result.written.len(), 1); + let file = destination.path().join(&result.written[0].file_name); + assert!(file.starts_with(destination.path())); + assert!(file.is_file()); + assert_eq!( + result.written[0].file_name, + "statement-etc-passwd-20260808.xlsx" + ); + } + + #[test] + fn renderer_failure_is_recorded_in_result_and_manifest_while_other_parties_write() { + let destination = tempfile::tempdir().expect("temporary destination"); + let result = write_bulk_party_statements( + destination.path(), + "Synthetic Books Pvt Ltd", + "20260808", + "pdf", + "pdf", + &[bill("Good Party", "10.00"), bill("Broken Party", "20.00")], + &[], + |statement| { + if statement.party == "Broken Party" { + Err("synthetic renderer failure".to_string()) + } else { + Ok(b"synthetic PDF".to_vec()) + } + }, + ) + .expect("partial batch result is returned"); + + assert_eq!(result.written.len(), 1); + assert_eq!(result.written[0].party, "Good Party"); + assert_eq!(result.written[0].amount, "10"); + assert_eq!(result.failures.len(), 1); + assert_eq!(result.failures[0].party, "Broken Party"); + let manifest = fs::read_to_string(&result.manifest_path).expect("manifest is written"); + assert!(manifest.contains("20260808")); + assert!(manifest.contains("\"amount\": \"10\"")); + assert!(manifest.contains("Broken Party")); + assert!(manifest.contains("synthetic renderer failure")); + } + + #[test] + fn colliding_safe_names_are_written_to_distinct_files() { + let destination = tempfile::tempdir().expect("temporary destination"); + let result = write_bulk_party_statements( + destination.path(), + "Synthetic Books Pvt Ltd", + "20260808", + "xlsx", + "xlsx", + &[bill("A/B", "10.00"), bill("A:B", "20.00")], + &[], + |_| Ok(b"synthetic workbook".to_vec()), + ) + .expect("statements write"); + + assert_eq!(result.written.len(), 2); + assert_ne!(result.written[0].file_name, result.written[1].file_name); + assert!(result + .written + .iter() + .all(|entry| destination.path().join(&entry.file_name).is_file())); + } +} diff --git a/src-tauri/src/reports/mod.rs b/src-tauri/src/reports/mod.rs index 90db2dd..096442e 100644 --- a/src-tauri/src/reports/mod.rs +++ b/src-tauri/src/reports/mod.rs @@ -1,6 +1,7 @@ //! Report exports built from data Bridge already holds after //! `fetch_tally_outstandings` -- no module here issues a Tally request. +pub mod bulk_party_statement; pub mod party_statement; pub mod party_statement_pdf; pub mod party_statement_xlsx; diff --git a/src-tauri/src/tally/runtime.rs b/src-tauri/src/tally/runtime.rs index 09444c7..85e0d84 100644 --- a/src-tauri/src/tally/runtime.rs +++ b/src-tauri/src/tally/runtime.rs @@ -140,6 +140,16 @@ pub enum OutstandingsLoadResult { /// these rows are already in hand. #[serde(skip_serializing_if = "Vec::is_empty")] open_bills: Vec, + /// Complete, uncapped statement source rows. Kept separate from the + /// display projection so a bulk statement action cannot silently omit + /// a party outside the dashboard's UI caps. + #[serde(skip_serializing_if = "Vec::is_empty")] + statement_open_bills: Vec, + /// Complete, uncapped unallocated statement source rows; see + /// `statement_open_bills` for why this must not reuse the top-ten UI + /// projection. + #[serde(skip_serializing_if = "Vec::is_empty")] + statement_unallocated_by_party: Vec, }, Partial { reason_code: String, @@ -153,7 +163,7 @@ pub enum OutstandingsLoadResult { /// `BILLOVERDUE` column; where no credit period exists the two dates coincide. const MISSING_BILL_REFERENCE_LABEL: &str = "No reference reported"; -fn open_bill_rows( +fn all_open_bill_rows( receivable: &[bridge_tally_protocol::native_outstandings::NativeBillRow], payable: &[bridge_tally_protocol::native_outstandings::NativeBillRow], as_of: &TallyDate, @@ -194,16 +204,15 @@ fn open_bill_rows( .then_with(|| left.party.cmp(&right.party)) .then_with(|| left.reference.cmp(&right.reference)) }); - rows.truncate(MAX_OPEN_BILL_ROWS); rows } -/// Ranks parties by unallocated exposure, largest first. +/// Returns every unallocated party ranked by exposure, largest first. /// /// Zero residuals are dropped rather than listed: a party whose ledger agrees /// exactly with its bills has nothing unallocated, and showing it as a zero row /// buries the parties that do. -fn top_unallocated_parties( +fn all_unallocated_parties( residuals: &[bridge_tally_protocol::native_outstandings::PartyResidual], ) -> Vec { let mut ranked = residuals @@ -227,7 +236,6 @@ fn top_unallocated_parties( .cmp_magnitude(&left.amount) .then_with(|| left.party.cmp(&right.party)) }); - ranked.truncate(10); ranked } @@ -255,8 +263,8 @@ pub struct OpenBillRow { /// /// Every row is already parsed, so this bounds only the serialized payload and /// what the screen must render. A book past this many OPEN bills is well -/// outside anything measured, and silently truncating would be worse than -/// disclosing it -- see `open_bills_truncated`. +/// outside anything measured, so the uncapped statement source is sent +/// separately rather than letting the bulk action reuse this display cap. const MAX_OPEN_BILL_ROWS: usize = 2_000; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -1419,14 +1427,29 @@ impl TallyRuntime { return Ok(partial_result(reason_code)); } + let statement_open_bills = + all_open_bill_rows(&receivable_rows, &payable_rows, &as_of); + let statement_unallocated_by_party = all_unallocated_parties(&result.residuals); + let open_bills = statement_open_bills + .iter() + .take(MAX_OPEN_BILL_ROWS) + .cloned() + .collect(); + let unallocated_by_party = statement_unallocated_by_party + .iter() + .take(10) + .cloned() + .collect(); Ok(OutstandingsLoadResult::Complete { report: Box::new(result.report), currency_assertion, ageing_anchor: OutstandingsAgeingAnchor::DueDate, synced_at_unix_ms: chrono::Utc::now().timestamp_millis(), unallocated_total: Some(result.residual_total), - unallocated_by_party: top_unallocated_parties(&result.residuals), - open_bills: open_bill_rows(&receivable_rows, &payable_rows, &as_of), + unallocated_by_party, + open_bills, + statement_open_bills, + statement_unallocated_by_party, }) } }, @@ -1866,6 +1889,8 @@ impl TallyRuntime { unallocated_total: None, unallocated_by_party: Vec::new(), open_bills: Vec::new(), + statement_open_bills: Vec::new(), + statement_unallocated_by_party: Vec::new(), }), ScanResult::Partial(partial) => { Ok(partial_result(&partial.reason_code)) @@ -2210,7 +2235,7 @@ mod tests { &TallyDate::parse("20260817").expect("capture as-of"), ) .expect("captured validation-book rows parse"); - let rows = open_bill_rows( + let rows = all_open_bill_rows( &receivable, &[], &TallyDate::parse("20260817").expect("capture as-of"), @@ -2268,7 +2293,7 @@ mod tests { &as_of, ) .expect("paired empty BILLREF values remain parseable"); - let rows = open_bill_rows(&parsed, &[], &as_of); + let rows = all_open_bill_rows(&parsed, &[], &as_of); assert_eq!(rows.len(), 2, "empty identities must not collapse rows"); let total = rows diff --git a/src/OutstandingsScreen.tsx b/src/OutstandingsScreen.tsx index 234930b..b711a79 100644 --- a/src/OutstandingsScreen.tsx +++ b/src/OutstandingsScreen.tsx @@ -74,6 +74,10 @@ type LoadResult = // Absent must never render as "no open bills" -- it means the // party row cannot be expanded at all. open_bills?: Array; + // Complete, uncapped source rows reserved for the batch statement + // action. Dashboard projections stay capped independently. + statement_open_bills?: Array; + statement_unallocated_by_party?: Array<{ party: string; amount: string }>; } | { state: "partial"; reason_code: string; synced_at_unix_ms: number }; @@ -85,8 +89,14 @@ export function OutstandingsScreen({ config, company, onChangeSetup, onViewAllCl const [loading, setLoading] = React.useState(false); const [inrAssertedCompanyGuid, setInrAssertedCompanyGuid] = React.useState(null); const [view, setView] = React.useState<"ageing" | "unallocated">("ageing"); - const [exportNotice, setExportNotice] = React.useState<{ message: string; path?: string } | null>(null); + const [exportNotice, setExportNotice] = React.useState<{ + message: string; + path?: string; + location?: string; + failures?: Array<{ party: string; error: string }>; + } | null>(null); const [expandedParty, setExpandedParty] = React.useState(null); + const [bulkStatementExporting, setBulkStatementExporting] = React.useState(false); const [partySort, setPartySort] = React.useState(null); const [currencyCheck, setCurrencyCheck] = React.useState<"idle" | "checking" | "inr" | "undetermined">("idle"); const [, refreshClock] = React.useReducer((value) => value + 1, 0); @@ -262,6 +272,29 @@ export function OutstandingsScreen({ config, company, onChangeSetup, onViewAllCl const ageingDisclosure = report && completeResult?.unallocated_total === undefined && outstandingsAgeingDisclosure(report.has_unaged_receivable); const unsupportedCurrencyAssertion = result?.state === "complete" && !completeResult; + const batchStatementRowsAvailable = completeResult !== null + && (completeResult.statement_open_bills !== undefined + || completeResult.statement_unallocated_by_party !== undefined); + const exportAllPartyStatements = async (format: "xlsx" | "pdf") => { + if (!completeResult) return; + try { + const destination = await invoke("select_party_statement_destination"); + if (!destination) return; + setBulkStatementExporting(true); + const batch = await exportBulkPartyStatements(completeResult, destination, format); + const label = format === "xlsx" ? "Excel" : "PDF"; + setExportNotice({ + message: `${batch.written.length} ${label} statement${batch.written.length === 1 ? "" : "s"} written`, + path: batch.manifest_path, + location: batch.destination, + failures: batch.failures, + }); + } catch (cause) { + setExportNotice({ message: operatorMessage(cause) }); + } finally { + setBulkStatementExporting(false); + } + }; return (
@@ -302,6 +335,28 @@ export function OutstandingsScreen({ config, company, onChangeSetup, onViewAllCl Export )} + {batchStatementRowsAvailable && ( + <> + + + + )} {!outstandingsUnavailable && ( + {exportNotice.failures && exportNotice.failures.length > 0 && ( +
    + {exportNotice.failures.map((failure) => ( +
  • {failure.party}: {failure.error}
  • + ))} +
+ )}
)} {error &&
Read failed{error}
} @@ -573,6 +637,33 @@ async function exportPartyStatement( }); } +type BulkPartyStatementResult = { + destination: string; + manifest_path: string; + written: Array<{ party: string; file_name: string; amount: string }>; + failures: Array<{ party: string; error: string }>; +}; + +/// Uses the complete statement-source rows returned by the finished read. The +/// dashboard's top-ten and drill-down projections are intentionally not used: +/// a batch must not silently omit a party beyond a display cap. +async function exportBulkPartyStatements( + result: InrCompleteResult, + destination: string, + format: "xlsx" | "pdf", +) { + return invoke("export_bulk_party_statements", { + request: { + company: result.report.company_name, + as_of_yyyymmdd: result.report.as_of_yyyymmdd, + destination, + format, + open_bills: result.statement_open_bills ?? [], + unallocated_by_party: result.statement_unallocated_by_party ?? [], + }, + }); +} + /// Builds the report as CSV. /// /// Amounts are written as raw decimal strings, never the rupee-formatted diff --git a/src/styles.css b/src/styles.css index 3a0eb1c..ac9b9e2 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1982,6 +1982,7 @@ summary:focus-visible, .outstandings-export-notice { display: flex; + flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 16px; @@ -2001,6 +2002,13 @@ summary:focus-visible, text-decoration: underline; } +.outstandings-export-failures { + flex-basis: 100%; + margin: 0; + padding-left: 18px; + color: #8f2e27; +} + .view-switch { display: inline-flex; gap: 2px; From 28d28d395c6678ee01ebc2450d5668bf74df2311 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 8 Aug 2026 22:55:12 +0530 Subject: [PATCH 03/12] fix(reports): make bulk statement scope explicit Collapse the duplicate format and extension parameter, send one complete statement-source payload, and confirm the backend-derived full party count before writing files.\n\nTest proof: party_count_deduplicates_nonzero_bill_and_unallocated_parties passes on the intended implementation. Mutating the preview count to subtract one made it fail 2 != 3, then restoring it passed.\n\nMigration: the Tauri load result no longer serializes capped open_bills or unallocated_by_party projections; the frontend locally applies its display caps to the complete statement-source rows.\n\nSecurity: no Tally endpoint is read by preview or export; both consume the already-complete local result. --- src-tauri/src/commands.rs | 38 ++++++++-- src-tauri/src/lib.rs | 1 + src-tauri/src/reports/bulk_party_statement.rs | 46 ++++++++++-- src-tauri/src/tally/runtime.rs | 41 +++-------- src/OutstandingsScreen.tsx | 72 +++++++++++++------ 5 files changed, 137 insertions(+), 61 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index dff5b06..6447c90 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -9,7 +9,9 @@ use crate::db::tally_mirror::{ WriteFixtureEnrollmentInput, WriteFixtureEnrollmentStatus, }; use crate::gst::{GstDraftRequest, GstReturnDraft}; -use crate::reports::bulk_party_statement::write_bulk_party_statements; +use crate::reports::bulk_party_statement::{ + bulk_party_statement_party_count, write_bulk_party_statements, +}; use crate::reports::party_statement::{build_party_statement, PartyStatementError}; use crate::reports::party_statement_pdf::render_party_statement_pdf; use crate::reports::party_statement_xlsx::render_party_statement_xlsx; @@ -2928,11 +2930,24 @@ pub struct ExportBulkPartyStatementsRequest { /// exists and is a directory before any statement name is joined to it. pub destination: String, /// These are complete statement-source rows from the finished local read, - /// not the top-ten/2,000-row projections rendered in the dashboard. + /// not the dashboard's display projections. + pub open_bills: Vec, + pub unallocated_by_party: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct PreviewBulkPartyStatementsRequest { + /// The same complete rows the export command will consume. This command + /// performs no I/O or Tally read; it only makes the pending scope explicit. pub open_bills: Vec, pub unallocated_by_party: Vec, } +#[derive(Debug, Serialize)] +pub struct BulkPartyStatementsPreview { + pub party_count: usize, +} + /// Lets the operator choose where the whole statement batch will be written. #[tauri::command] pub async fn select_party_statement_destination() -> Result, String> { @@ -2946,6 +2961,23 @@ pub async fn select_party_statement_destination() -> Result, Stri .map_err(|_| "Bridge could not open the statement destination picker.".to_string())? } +/// Counts the unique, non-zero parties that the bulk writer will process. +/// Kept beside the writer's source conversion so the confirmation cannot use +/// a separately implemented frontend approximation of the export scope. +#[tauri::command] +pub async fn preview_bulk_party_statements( + request: PreviewBulkPartyStatementsRequest, +) -> Result { + let open_bills = request + .open_bills + .into_iter() + .map(into_open_bill_row) + .collect::, _>>()?; + Ok(BulkPartyStatementsPreview { + party_count: bulk_party_statement_party_count(&open_bills, &request.unallocated_by_party), + }) +} + /// Writes a separate statement for every party in the completed source rows. /// A failed party remains visible in the returned result and manifest while /// the remaining parties continue, so an operator cannot mistake a partial @@ -2967,7 +2999,6 @@ pub async fn export_bulk_party_statements( &request.company, &request.as_of_yyyymmdd, "xlsx", - "xlsx", &open_bills, &request.unallocated_by_party, |statement| render_party_statement_xlsx(statement).map_err(|error| error.to_string()), @@ -2977,7 +3008,6 @@ pub async fn export_bulk_party_statements( &request.company, &request.as_of_yyyymmdd, "pdf", - "pdf", &open_bills, &request.unallocated_by_party, |statement| render_party_statement_pdf(statement).map_err(|error| error.to_string()), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4dcb27b..ad69e4e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -91,6 +91,7 @@ pub fn run() { commands::reveal_exported_file, commands::export_party_statement, commands::select_party_statement_destination, + commands::preview_bulk_party_statements, commands::export_bulk_party_statements, commands::fetch_tally_outstandings_all_companies, commands::detect_tally_base_currency, diff --git a/src-tauri/src/reports/bulk_party_statement.rs b/src-tauri/src/reports/bulk_party_statement.rs index 9048be9..e422a4c 100644 --- a/src-tauri/src/reports/bulk_party_statement.rs +++ b/src-tauri/src/reports/bulk_party_statement.rs @@ -52,7 +52,6 @@ pub fn write_bulk_party_statements( company: &str, as_of_yyyymmdd: &str, format: &str, - extension: &str, open_bills: &[OpenBillRow], unallocated_by_party: &[UnallocatedParty], render: impl Fn(&PartyStatement) -> Result, String>, @@ -93,7 +92,7 @@ pub fn write_bulk_party_statements( "statement-{}-{as_of_yyyymmdd}", safe_party_slug(&statement.party) ); - match write_unique_file(destination, &stem, extension, &bytes) { + match write_unique_file(destination, &stem, format, &bytes) { Ok(path) => written.push(WrittenStatement { party, file_name: file_name(&path)?, @@ -143,6 +142,16 @@ fn statement_parties( .collect() } +/// Counts the non-zero, exact-name parties that a bulk statement would cover. +/// This is shared by the operator preview and the writer so their scopes +/// cannot diverge. +pub fn bulk_party_statement_party_count( + open_bills: &[OpenBillRow], + unallocated_by_party: &[UnallocatedParty], +) -> usize { + statement_parties(open_bills, unallocated_by_party).len() +} + /// Converts arbitrary ledger text to a portable ASCII filename component. /// Separators, controls, Windows-reserved punctuation, leading dots, trailing /// spaces, and non-ASCII characters all become collapsed hyphens. @@ -240,7 +249,6 @@ mod tests { "Synthetic Books Pvt Ltd", "20260808", "xlsx", - "xlsx", &[bill("../../etc/passwd", "15.00")], &[], |_| Ok(b"synthetic workbook".to_vec()), @@ -265,7 +273,6 @@ mod tests { "Synthetic Books Pvt Ltd", "20260808", "pdf", - "pdf", &[bill("Good Party", "10.00"), bill("Broken Party", "20.00")], &[], |statement| { @@ -298,7 +305,6 @@ mod tests { "Synthetic Books Pvt Ltd", "20260808", "xlsx", - "xlsx", &[bill("A/B", "10.00"), bill("A:B", "20.00")], &[], |_| Ok(b"synthetic workbook".to_vec()), @@ -312,4 +318,34 @@ mod tests { .iter() .all(|entry| destination.path().join(&entry.file_name).is_file())); } + + #[test] + fn party_count_deduplicates_nonzero_bill_and_unallocated_parties() { + let unallocated = vec![ + UnallocatedParty { + party: "Bill and On Account".to_string(), + amount: ExactDecimal::parse("25.00").expect("synthetic decimal"), + }, + UnallocatedParty { + party: "On Account Only".to_string(), + amount: ExactDecimal::parse("10.00").expect("synthetic decimal"), + }, + UnallocatedParty { + party: "Zero Balance".to_string(), + amount: ExactDecimal::zero(), + }, + ]; + + assert_eq!( + bulk_party_statement_party_count( + &[ + bill("Bill and On Account", "15.00"), + bill("Bill Only", "5.00"), + bill("Zero Balance", "0"), + ], + &unallocated, + ), + 3 + ); + } } diff --git a/src-tauri/src/tally/runtime.rs b/src-tauri/src/tally/runtime.rs index 85e0d84..c18c5e3 100644 --- a/src-tauri/src/tally/runtime.rs +++ b/src-tauri/src/tally/runtime.rs @@ -127,29 +127,21 @@ pub enum OutstandingsLoadResult { /// the bills would be short by 96% with nothing to indicate it. #[serde(skip_serializing_if = "Option::is_none")] unallocated_total: Option, - /// Per-party unallocated exposure, largest first. + /// Complete per-party unallocated exposure, largest first. The + /// frontend applies its display limit locally so the same data can + /// also power complete statement exports without a duplicate payload. /// /// On a book where most balances carry no bill reference, the ageing /// buckets describe a rounding error and this list is the actual /// answer -- so it is surfaced rather than collapsed into the single /// total above. Empty when the path cannot establish it. #[serde(skip_serializing_if = "Vec::is_empty")] - unallocated_by_party: Vec, - /// Every open bill the native reports returned, so the UI can answer - /// "why does this party owe so much" without a second Tally request -- - /// these rows are already in hand. - #[serde(skip_serializing_if = "Vec::is_empty")] - open_bills: Vec, - /// Complete, uncapped statement source rows. Kept separate from the - /// display projection so a bulk statement action cannot silently omit - /// a party outside the dashboard's UI caps. + statement_unallocated_by_party: Vec, + /// Every open bill the native reports returned. The frontend applies + /// its display limit locally; this uncapped source is also what the + /// complete statement export consumes. #[serde(skip_serializing_if = "Vec::is_empty")] statement_open_bills: Vec, - /// Complete, uncapped unallocated statement source rows; see - /// `statement_open_bills` for why this must not reuse the top-ten UI - /// projection. - #[serde(skip_serializing_if = "Vec::is_empty")] - statement_unallocated_by_party: Vec, }, Partial { reason_code: String, @@ -273,7 +265,6 @@ pub enum ExposureDirection { Receivable, Payable, } - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum OutstandingsAgeingAnchor { @@ -1430,26 +1421,14 @@ impl TallyRuntime { let statement_open_bills = all_open_bill_rows(&receivable_rows, &payable_rows, &as_of); let statement_unallocated_by_party = all_unallocated_parties(&result.residuals); - let open_bills = statement_open_bills - .iter() - .take(MAX_OPEN_BILL_ROWS) - .cloned() - .collect(); - let unallocated_by_party = statement_unallocated_by_party - .iter() - .take(10) - .cloned() - .collect(); Ok(OutstandingsLoadResult::Complete { report: Box::new(result.report), currency_assertion, ageing_anchor: OutstandingsAgeingAnchor::DueDate, synced_at_unix_ms: chrono::Utc::now().timestamp_millis(), unallocated_total: Some(result.residual_total), - unallocated_by_party, - open_bills, - statement_open_bills, statement_unallocated_by_party, + statement_open_bills, }) } }, @@ -1887,10 +1866,8 @@ impl TallyRuntime { // remainder, so it must stay absent rather // than be reported as zero. unallocated_total: None, - unallocated_by_party: Vec::new(), - open_bills: Vec::new(), - statement_open_bills: Vec::new(), statement_unallocated_by_party: Vec::new(), + statement_open_bills: Vec::new(), }), ScanResult::Partial(partial) => { Ok(partial_result(&partial.reason_code)) diff --git a/src/OutstandingsScreen.tsx b/src/OutstandingsScreen.tsx index b711a79..1ed4e16 100644 --- a/src/OutstandingsScreen.tsx +++ b/src/OutstandingsScreen.tsx @@ -59,6 +59,12 @@ type OpenBill = { kind: "receivable" | "payable"; }; +// These limits bound only what the dashboard renders. The Tauri result keeps +// complete statement-source rows so an export is never silently narrowed by a +// presentation cap. +const DISPLAY_OPEN_BILL_ROW_LIMIT = 2_000; +const DISPLAY_UNALLOCATED_PARTY_LIMIT = 10; + type LoadResult = | { state: "complete"; @@ -69,13 +75,8 @@ type LoadResult = // Absent when the read path cannot establish it. Absent is not zero and // must never render as zero. unallocated_total?: string; - unallocated_by_party?: Array<{ party: string; amount: string }>; - // Absent on the voucher-scan path, which reads no per-bill detail. - // Absent must never render as "no open bills" -- it means the - // party row cannot be expanded at all. - open_bills?: Array; - // Complete, uncapped source rows reserved for the batch statement - // action. Dashboard projections stay capped independently. + // Complete, uncapped rows. The dashboard applies its own display caps; + // statement exports always use these complete sources. statement_open_bills?: Array; statement_unallocated_by_party?: Array<{ party: string; amount: string }>; } @@ -188,12 +189,16 @@ export function OutstandingsScreen({ config, company, onChangeSetup, onViewAllCl // some renders and not others, which React rejects outright with "Rendered // more hooks than during the previous render" -- and the whole screen blanks. const inrCompleteResult = isInrCompleteResult(result) ? result : null; - // null when open_bills is absent entirely (voucher-scan path) -- distinct - // from a party simply having no rows in a present-but-partial array. - const openBillsByParty = React.useMemo( - () => billsByParty(inrCompleteResult?.open_bills), + const displayOpenBills = React.useMemo( + () => inrCompleteResult?.statement_open_bills?.slice(0, DISPLAY_OPEN_BILL_ROW_LIMIT), [inrCompleteResult], ); + // null when statement rows are absent entirely (voucher-scan path) -- + // distinct from a party simply having no rows in the display projection. + const openBillsByParty = React.useMemo( + () => billsByParty(displayOpenBills), + [displayOpenBills], + ); // On a book where most balances carry no bill reference, the ageing panel // can describe a rounding error against total exposure. Default to @@ -243,7 +248,8 @@ export function OutstandingsScreen({ config, company, onChangeSetup, onViewAllCl const composition = report ? exposureComposition(report, completeResult?.unallocated_total) : null; - const unallocatedParties = completeResult?.unallocated_by_party ?? []; + const unallocatedParties = completeResult?.statement_unallocated_by_party + ?.slice(0, DISPLAY_UNALLOCATED_PARTY_LIMIT) ?? []; const largestUnallocated = Math.max(...unallocatedParties.map((entry) => amountOf(entry.amount)), 0); const largestExposure = report ? Math.max(...report.top_parties.map((party) => amountOf(party.outstanding_total)), 0) @@ -280,9 +286,18 @@ export function OutstandingsScreen({ config, company, onChangeSetup, onViewAllCl try { const destination = await invoke("select_party_statement_destination"); if (!destination) return; + const preview = await previewBulkPartyStatements(completeResult); + if (preview.party_count === 0) { + setExportNotice({ message: "No parties with outstanding balances are available for statements." }); + return; + } + const label = format === "xlsx" ? "Excel" : "PDF"; + const confirmed = window.confirm( + `Create ${preview.party_count} ${label} statement${preview.party_count === 1 ? "" : "s"} in:\n${destination}\n\nThe dashboard shows only its largest parties. This batch includes every party with a non-zero outstanding balance.`, + ); + if (!confirmed) return; setBulkStatementExporting(true); const batch = await exportBulkPartyStatements(completeResult, destination, format); - const label = format === "xlsx" ? "Excel" : "PDF"; setExportNotice({ message: `${batch.written.length} ${label} statement${batch.written.length === 1 ? "" : "s"} written`, path: batch.manifest_path, @@ -617,9 +632,9 @@ export function OutstandingsScreen({ config, company, onChangeSetup, onViewAllCl } /// Builds one party's statement in the selected format via the Rust command -/// and writes it to Downloads. Sends the `open_bills`/`unallocated_by_party` -/// rows this screen already holds from `fetch_tally_outstandings` -- Bridge -/// never reads Tally a second time to produce a statement. +/// and writes it to Downloads. Sends the complete statement source rows this +/// screen already holds from `fetch_tally_outstandings` -- Bridge never reads +/// Tally a second time to produce a statement. async function exportPartyStatement( result: InrCompleteResult, party: string, @@ -631,8 +646,8 @@ async function exportPartyStatement( as_of_yyyymmdd: result.report.as_of_yyyymmdd, party, format, - open_bills: result.open_bills ?? [], - unallocated_by_party: result.unallocated_by_party ?? [], + open_bills: result.statement_open_bills ?? [], + unallocated_by_party: result.statement_unallocated_by_party ?? [], }, }); } @@ -644,6 +659,8 @@ type BulkPartyStatementResult = { failures: Array<{ party: string; error: string }>; }; +type BulkPartyStatementsPreview = { party_count: number }; + /// Uses the complete statement-source rows returned by the finished read. The /// dashboard's top-ten and drill-down projections are intentionally not used: /// a batch must not silently omit a party beyond a display cap. @@ -664,6 +681,17 @@ async function exportBulkPartyStatements( }); } +/// Uses the same complete source rows and backend counting rule as the writer, +/// so the confirmation names the exact scope before any files are created. +async function previewBulkPartyStatements(result: InrCompleteResult) { + return invoke("preview_bulk_party_statements", { + request: { + open_bills: result.statement_open_bills ?? [], + unallocated_by_party: result.statement_unallocated_by_party ?? [], + }, + }); +} + /// Builds the report as CSV. /// /// Amounts are written as raw decimal strings, never the rupee-formatted @@ -672,7 +700,11 @@ async function exportBulkPartyStatements( /// because a figure that needs a caveat on screen needs it in the file too -- /// the file is what gets forwarded. async function exportCsv(result: InrCompleteResult) { - const csv = reportToCsv(result.report, result.unallocated_total, result.unallocated_by_party); + const csv = reportToCsv( + result.report, + result.unallocated_total, + result.statement_unallocated_by_party, + ); const slug = result.report.company_name.replace(/[^a-z0-9]+/gi, "-").toLowerCase(); // A BOM so Excel reads UTF-8 party names instead of mojibake -- Indian // ledger names routinely carry non-ASCII characters. @@ -788,7 +820,7 @@ function ageingRows(report: Report) { } /// Groups open bills by exact party name for the drill-down. Returns null -/// when `open_bills` is absent entirely (the voucher-scan path never sends +/// when statement rows are absent entirely (the voucher-scan path never sends /// it) -- that null is what tells a row it must not be expandable at all, /// distinct from a present array that simply has no rows for this party. function billsByParty(openBills: Array | undefined): Map> | null { From 2b345d17710ff87fa5686d654fa3375d6d475f13 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sun, 9 Aug 2026 08:15:39 +0530 Subject: [PATCH 04/12] fix(reports): identify bulk manifests by company Mutation proof: removing the manifest company field made renderer_failure_is_recorded_in_result_and_manifest_while_other_parties_write fail (RC 101). The field is restored. --- src-tauri/src/reports/bulk_party_statement.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src-tauri/src/reports/bulk_party_statement.rs b/src-tauri/src/reports/bulk_party_statement.rs index e422a4c..95300ce 100644 --- a/src-tauri/src/reports/bulk_party_statement.rs +++ b/src-tauri/src/reports/bulk_party_statement.rs @@ -36,6 +36,7 @@ pub struct StatementFailure { #[derive(Serialize)] struct StatementManifest<'a> { + company: &'a str, as_of_yyyymmdd: &'a str, format: &'a str, written: &'a [WrittenStatement], @@ -103,6 +104,7 @@ pub fn write_bulk_party_statements( } let manifest = StatementManifest { + company, as_of_yyyymmdd, format, written: &written, @@ -292,6 +294,7 @@ mod tests { assert_eq!(result.failures[0].party, "Broken Party"); let manifest = fs::read_to_string(&result.manifest_path).expect("manifest is written"); assert!(manifest.contains("20260808")); + assert!(manifest.contains("Synthetic Books Pvt Ltd")); assert!(manifest.contains("\"amount\": \"10\"")); assert!(manifest.contains("Broken Party")); assert!(manifest.contains("synthetic renderer failure")); From 99bd104bf8d228776bbe96936de1b3324f3973a5 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 15 Aug 2026 17:11:39 +0530 Subject: [PATCH 05/12] test(reports): cover future-due bulk statements from raw XML Exercises synthetic raw Bills and Ledger envelopes through parsing, native outstandings computation, the uncapped statement source, and actual XLSX bulk output. Mutation proof: routing BILLDUE through BillDate failed at native_date_year_outside_book_window; restoring DueDate and mutating the future-due branch to return None failed at the zero-row statement-source assertion. Both mutations were restored, then this test passed. --- src-tauri/src/reports/bulk_party_statement.rs | 6 +- src-tauri/src/tally/runtime.rs | 91 +++++++++++++++++-- 2 files changed, 88 insertions(+), 9 deletions(-) diff --git a/src-tauri/src/reports/bulk_party_statement.rs b/src-tauri/src/reports/bulk_party_statement.rs index 95300ce..b217409 100644 --- a/src-tauri/src/reports/bulk_party_statement.rs +++ b/src-tauri/src/reports/bulk_party_statement.rs @@ -229,6 +229,7 @@ fn file_name(path: &Path) -> Result { #[cfg(test)] mod tests { use super::*; + use crate::tally::ExposureDirection; use bridge_tally_core::ExactDecimal; fn bill(party: &str, amount: &str) -> OpenBillRow { @@ -238,7 +239,7 @@ mod tests { bill_date: "20260101".to_string(), due_date: "20260201".to_string(), amount: ExactDecimal::parse(amount).expect("synthetic decimal"), - age_days: 40, + age_days: Some(40), kind: "receivable", } } @@ -328,14 +329,17 @@ mod tests { UnallocatedParty { party: "Bill and On Account".to_string(), amount: ExactDecimal::parse("25.00").expect("synthetic decimal"), + direction: ExposureDirection::Receivable, }, UnallocatedParty { party: "On Account Only".to_string(), amount: ExactDecimal::parse("10.00").expect("synthetic decimal"), + direction: ExposureDirection::Receivable, }, UnallocatedParty { party: "Zero Balance".to_string(), amount: ExactDecimal::zero(), + direction: ExposureDirection::Receivable, }, ]; diff --git a/src-tauri/src/tally/runtime.rs b/src-tauri/src/tally/runtime.rs index c18c5e3..eb9ea2e 100644 --- a/src-tauri/src/tally/runtime.rs +++ b/src-tauri/src/tally/runtime.rs @@ -251,14 +251,6 @@ pub struct OpenBillRow { pub kind: &'static str, } -/// Caps how many bill rows cross into the UI. -/// -/// Every row is already parsed, so this bounds only the serialized payload and -/// what the screen must render. A book past this many OPEN bills is well -/// outside anything measured, so the uncapped statement source is sent -/// separately rather than letting the bulk action reuse this display cap. -const MAX_OPEN_BILL_ROWS: usize = 2_000; - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ExposureDirection { @@ -2397,6 +2389,89 @@ mod tests { } } + #[test] + fn future_due_bill_from_raw_native_bytes_reaches_the_statement_source() { + let books_from = TallyDate::parse("20260401").expect("synthetic book start"); + let as_of = TallyDate::parse("20260731").expect("synthetic as-of date"); + let bills_xml = "\ + 1-Jul-26FUTURE-1Synthetic Party\ + -100.001-Aug-260\ + "; + let ledger_xml = "
1
"; + let receivable = parse_native_bill_rows(bills_xml, &books_from, &as_of) + .expect("a future due date in raw native bytes must parse"); + let ledgers = parse_native_ledger_snapshot(ledger_xml) + .expect("the synthetic raw ledger response must parse"); + let computed = compute_native_outstandings( + "Synthetic Company", + &receivable, + &[], + &ledgers, + AgeingAnchor::DueDate, + &as_of, + bills_xml.len() + ledger_xml.len(), + ) + .expect("a future-due bill must not abort the native computation"); + assert_eq!(computed.report.receivable_total.as_str(), "100"); + assert_eq!(computed.report.top_parties[0].oldest_bill_age_days, None); + assert_eq!(computed.report.ageing.days_0_30, ExactDecimal::zero()); + assert_eq!(computed.report.ageing.days_31_60, ExactDecimal::zero()); + assert_eq!(computed.report.ageing.days_61_90, ExactDecimal::zero()); + assert_eq!(computed.report.ageing.days_90_plus, ExactDecimal::zero()); + + let statement_rows = all_open_bill_rows(&receivable, &[], &as_of); + assert_eq!( + statement_rows.len(), + 1, + "a future-due bill must remain available to the statement source" + ); + assert_eq!(statement_rows[0].amount.as_str(), "100.00"); + assert_eq!(statement_rows[0].age_days, None); + let statement = crate::reports::party_statement::build_party_statement( + "Synthetic Company", + as_of.as_str(), + "Synthetic Party", + &statement_rows, + &[], + ) + .expect("the future-due bill must build a party statement"); + assert_eq!(statement.bills.len(), 1); + assert_eq!(statement.bills[0].reference, "FUTURE-1"); + assert_eq!(statement.bills[0].age_days, None); + assert_eq!(statement.bills[0].bucket, None); + assert_eq!(statement.bill_total.as_str(), "100"); + + let destination = tempfile::tempdir().expect("synthetic destination"); + let bulk = crate::reports::bulk_party_statement::write_bulk_party_statements( + destination.path(), + "Synthetic Company", + as_of.as_str(), + "xlsx", + &statement_rows, + &[], + |party_statement| { + crate::reports::party_statement_xlsx::render_party_statement_xlsx(party_statement) + .map_err(|error| error.to_string()) + }, + ) + .expect("a bulk run must write the future-due party statement"); + assert_eq!(bulk.written.len(), 1); + let workbook = std::fs::File::open(destination.path().join(&bulk.written[0].file_name)) + .expect("the bulk statement file exists"); + let mut archive = zip::ZipArchive::new(workbook).expect("bulk output is an XLSX archive"); + let mut workbook_text = String::new(); + for entry_name in ["xl/worksheets/sheet1.xml", "xl/sharedStrings.xml"] { + let mut entry = archive + .by_name(entry_name) + .expect("the XLSX statement entry exists"); + std::io::Read::read_to_string(&mut entry, &mut workbook_text) + .expect("the workbook XML is readable"); + } + assert!(workbook_text.contains("FUTURE-1")); + assert!(workbook_text.contains("Not due")); + assert!(workbook_text.contains("Unaged")); + } + #[cfg(feature = "voucher-scan")] #[test] fn outstandings_read_failures_are_partial_and_deadlines_recommend_restart() { From acbc3c33572cd98a4f01325bb420de54456e3ea8 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Mon, 17 Aug 2026 18:23:19 +0530 Subject: [PATCH 06/12] test(tally): carry F1X future semantics through bulk level Adapt the validation-book runtime assertion to the PR7 all_open_bill_rows helper name and update the existing raw-byte bulk test to the measured rule: a future-due bill remains age_days=None but contributes 100 to the first ageing bucket and one to the open-receivable count. Mutation proof: with the inherited zero-bucket assertion left in place, the focused raw-byte test failed with exit 101 (ExactDecimal 100 != 0). After restoring the F1X expectation, that test passed 1/1 with exit 0 and the validation-lab runtime test passed 1/1 with exit 0. Signed-off-by: Tapish Khandelwal --- .../compatibility/compatibility-surface.json | 22 +++++++++---------- src-tauri/src/tally/runtime.rs | 3 ++- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 2126780..b4bbd16 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -63,7 +63,7 @@ }, { "path": "docs/tally/compatibility/synthetic-write-canary-fixture.md", - "sha256": "9ed85e60adb7306f11496b47f5a86d49327abfdd8e2735678c521187b9ce76e4" + "sha256": "dd2f1c68c0925523af1468dd9c61330433130c0e72b7c713dfc1ef9205b4756f" }, { "path": "docs/tally/support-matrix.md", @@ -79,7 +79,7 @@ }, { "path": "scripts/outstandings-copy.test.mjs", - "sha256": "8c9adb100a45704ca8ba72203a397defca3e8b01a297e96043c543cc052b980a" + "sha256": "58ca3cb255a8e54dc3a4590146b908b800fd4186d474eb05c3e1a46ae6c005a0" }, { "path": "scripts/tally-company-selection.test.mjs", @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-protocol/src/lib.rs", - "sha256": "e1c9082a214a125454c2bbddef8494283d41efa025e4acfc1525f0a22aa2bd1e" + "sha256": "c5c61049fdbedf31cfafb43d349961e736eb7c6a361ec08e410f2f94099b1200" }, { "path": "src-tauri/crates/bridge-tally-protocol/src/outstandings/completeness.rs", @@ -175,7 +175,7 @@ }, { "path": "src-tauri/crates/bridge-tally-protocol/tests/simulator_corpus.rs", - "sha256": "616eb8fa5e387bff74f62d8e775b7eb118860763b18ff63ad88ad5015f7f752f" + "sha256": "65da6e2a0cf543c46e1834d82f836b2966f9f574952b991259179cf743f907d6" }, { "path": "src-tauri/crates/bridge-tally-protocol/tests/stream_text_decoder.rs", @@ -219,7 +219,7 @@ }, { "path": "src-tauri/src/commands.rs", - "sha256": "116c0ab468896bff2c98db3aaada794d5378ef8e32934bcad737c27fb592a5e7" + "sha256": "ff39c070fcc66bff364f7ccd41f3cebd795c5cea0fba9c67022ac050f2ff3859" }, { "path": "src-tauri/src/db/encrypted.rs", @@ -303,7 +303,7 @@ }, { "path": "src-tauri/src/lib.rs", - "sha256": "33bdd1cf9cf4a1e945db9b7eaec0f037b1122e5bafd013c208254b9ad3817196" + "sha256": "62c950aacaff7b1b431b65f32e14ead421d87a0bd3cd44a580912adfb5bf3730" }, { "path": "src-tauri/src/sync/coordinator.rs", @@ -339,7 +339,7 @@ }, { "path": "src-tauri/src/tally/runtime.rs", - "sha256": "2d2ce6d7d59b4b7e7286ca0f583f87604fa86cd1a1eed1c5353f7ae487152b55" + "sha256": "4a4cb2bc46cfcadbb082550c8f24e735d8bfb9206f524d6bee4db8bbf69ef95d" }, { "path": "src-tauri/src/tally/serial_queue.rs", @@ -359,7 +359,7 @@ }, { "path": "src/OutstandingsScreen.tsx", - "sha256": "df11bdbbbfb4f62b7738cd30ea69a2ce5b15eb8a5b5567358e67d6d04569bb90" + "sha256": "0e2c7160bd2e14a985f76cf3caeec9351527c0d236575321eecabdbc67ea037b" }, { "path": "src/TallyReadinessFlow.tsx", @@ -371,7 +371,7 @@ }, { "path": "src/outstandings-copy.ts", - "sha256": "a2bcf820f694443ac122e91aa98fb354b6e2d5c6be3e1e9503b368a3a0a4f696" + "sha256": "0e78dd97d79c0166fe17a4e933733c369a7e4be3046ddbe3e38122ad5e234216" }, { "path": "src/outstandings-csv.ts", @@ -379,7 +379,7 @@ }, { "path": "src/styles.css", - "sha256": "ff23d59914a48f3f3621499a8121fc63ee93f7b18ad4ae787d7f1d6e04b81281" + "sha256": "b488d5305557b4c356e4d2e16971d387724e9be17ede1d8c05213f64c9661146" }, { "path": "src/tally-company-selection.ts", @@ -434,5 +434,5 @@ "sha256": "a27f294ee15e407b69fdfc73609e8708ac0509b6e6a8872daef5451fde61a8db" } ], - "manifest_sha256": "c6cb7e5a3f23618922ba9d562e9b805782c03d5350c01222ead547547f01598b" + "manifest_sha256": "" } diff --git a/src-tauri/src/tally/runtime.rs b/src-tauri/src/tally/runtime.rs index eb9ea2e..1699e62 100644 --- a/src-tauri/src/tally/runtime.rs +++ b/src-tauri/src/tally/runtime.rs @@ -2414,10 +2414,11 @@ mod tests { .expect("a future-due bill must not abort the native computation"); assert_eq!(computed.report.receivable_total.as_str(), "100"); assert_eq!(computed.report.top_parties[0].oldest_bill_age_days, None); - assert_eq!(computed.report.ageing.days_0_30, ExactDecimal::zero()); + assert_eq!(computed.report.ageing.days_0_30.as_str(), "100"); assert_eq!(computed.report.ageing.days_31_60, ExactDecimal::zero()); assert_eq!(computed.report.ageing.days_61_90, ExactDecimal::zero()); assert_eq!(computed.report.ageing.days_90_plus, ExactDecimal::zero()); + assert_eq!(computed.report.open_receivable_bill_count, 1); let statement_rows = all_open_bill_rows(&receivable, &[], &as_of); assert_eq!( From 80d6cfc49a6d77d00911be0a878e482057338834 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Tue, 18 Aug 2026 05:48:43 +0530 Subject: [PATCH 07/12] chore(tally): reseal F4X PR7 compatibility surface Carry the PR7 bulk-statement surface over the F4X BILLREF presentation disclosure. Claims, evidence, and trusted-evidence keys remain byte-identical to the preserved PR7 head. Compatibility gate: exit 0, unknown_claims=11, evidenced_claims=0. Claims/evidence/trusted identity checks: exit 0. --- docs/tally/compatibility/compatibility-matrix.json | 2 +- docs/tally/compatibility/compatibility-surface.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 82b76d3..f93fc77 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "c6cb7e5a3f23618922ba9d562e9b805782c03d5350c01222ead547547f01598b", + "compatibility_surface_sha256": "6e5b018fc5de0c6bc89b7baa1970d0f52be050d5542716b986e31ab8fb7b5e8b", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index b4bbd16..acc8078 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -339,7 +339,7 @@ }, { "path": "src-tauri/src/tally/runtime.rs", - "sha256": "4a4cb2bc46cfcadbb082550c8f24e735d8bfb9206f524d6bee4db8bbf69ef95d" + "sha256": "f6c4a1507b00c2689c128be4991af14af7cbd3ad2cec684673bebff9bb24a3c9" }, { "path": "src-tauri/src/tally/serial_queue.rs", @@ -434,5 +434,5 @@ "sha256": "a27f294ee15e407b69fdfc73609e8708ac0509b6e6a8872daef5451fde61a8db" } ], - "manifest_sha256": "" + "manifest_sha256": "6e5b018fc5de0c6bc89b7baa1970d0f52be050d5542716b986e31ab8fb7b5e8b" } From 9a5ff833d2864a1fc4b54e793b233bd310aa4ea6 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Tue, 18 Aug 2026 11:35:12 +0530 Subject: [PATCH 08/12] fix(reports): preserve directions in batch manifest Replace the ambiguous unsigned amount with exact receivable_amount and payable_amount fields derived from bill kinds and unallocated direction. Mixed parties now remain auditable without consumers inferring polarity.Red before fix: the regression did not compile because WrittenStatement exposed only amount (exit 101). Mutation proof: routing payable unallocated exposure into receivable failed the mixed-party assertion with 13 vs 10 (exit 101).Verified with Rust 1.96.0: focused bulk statement tests 5/5 and bridge --lib 267/267, exit 0. --- src-tauri/src/reports/bulk_party_statement.rs | 80 +++++++++++++++++-- 1 file changed, 74 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/reports/bulk_party_statement.rs b/src-tauri/src/reports/bulk_party_statement.rs index b217409..69399e1 100644 --- a/src-tauri/src/reports/bulk_party_statement.rs +++ b/src-tauri/src/reports/bulk_party_statement.rs @@ -11,7 +11,7 @@ use std::path::{Path, PathBuf}; use serde::Serialize; use super::party_statement::{build_party_statement, PartyStatement}; -use crate::tally::{OpenBillRow, UnallocatedParty}; +use crate::tally::{ExposureDirection, OpenBillRow, UnallocatedParty}; #[derive(Debug, Clone, Serialize)] pub struct BulkPartyStatementResult { @@ -25,7 +25,8 @@ pub struct BulkPartyStatementResult { pub struct WrittenStatement { pub party: String, pub file_name: String, - pub amount: String, + pub receivable_amount: String, + pub payable_amount: String, } #[derive(Debug, Clone, Serialize)] @@ -81,7 +82,13 @@ pub fn write_bulk_party_statements( continue; } }; - let amount = statement.grand_total.as_str().to_string(); + let (receivable_amount, payable_amount) = match statement_directional_totals(&statement) { + Ok(totals) => totals, + Err(error) => { + failures.push(StatementFailure { party, error }); + continue; + } + }; let bytes = match render(&statement) { Ok(bytes) => bytes, Err(error) => { @@ -97,7 +104,8 @@ pub fn write_bulk_party_statements( Ok(path) => written.push(WrittenStatement { party, file_name: file_name(&path)?, - amount, + receivable_amount, + payable_amount, }), Err(error) => failures.push(StatementFailure { party, error }), } @@ -127,6 +135,37 @@ pub fn write_bulk_party_statements( }) } +fn statement_directional_totals(statement: &PartyStatement) -> Result<(String, String), String> { + let mut receivable = bridge_tally_core::ExactDecimal::zero(); + let mut payable = bridge_tally_core::ExactDecimal::zero(); + for bill in &statement.bills { + let total = match bill.kind { + "receivable" => &mut receivable, + "payable" => &mut payable, + _ => return Err("Bridge found an unknown statement direction.".to_string()), + }; + *total = total + .checked_add(&bill.amount) + .map_err(|_| "Bridge could not total a statement direction exactly.".to_string())?; + } + if !statement.unallocated.is_zero() { + let total = match statement.unallocated_direction { + Some(ExposureDirection::Receivable) => &mut receivable, + Some(ExposureDirection::Payable) => &mut payable, + None => { + return Err("Bridge found an unallocated amount without a direction.".to_string()) + } + }; + *total = total + .checked_add(&statement.unallocated) + .map_err(|_| "Bridge could not total a statement direction exactly.".to_string())?; + } + Ok(( + receivable.as_str().to_string(), + payable.as_str().to_string(), + )) +} + fn statement_parties( open_bills: &[OpenBillRow], unallocated_by_party: &[UnallocatedParty], @@ -290,17 +329,46 @@ mod tests { assert_eq!(result.written.len(), 1); assert_eq!(result.written[0].party, "Good Party"); - assert_eq!(result.written[0].amount, "10"); + assert_eq!(result.written[0].receivable_amount, "10"); + assert_eq!(result.written[0].payable_amount, "0"); assert_eq!(result.failures.len(), 1); assert_eq!(result.failures[0].party, "Broken Party"); let manifest = fs::read_to_string(&result.manifest_path).expect("manifest is written"); assert!(manifest.contains("20260808")); assert!(manifest.contains("Synthetic Books Pvt Ltd")); - assert!(manifest.contains("\"amount\": \"10\"")); + assert!(manifest.contains("\"receivable_amount\": \"10\"")); + assert!(manifest.contains("\"payable_amount\": \"0\"")); + assert!(!manifest.contains("\"amount\":")); assert!(manifest.contains("Broken Party")); assert!(manifest.contains("synthetic renderer failure")); } + #[test] + fn manifest_totals_keep_receivable_and_payable_directions_separate() { + let destination = tempfile::tempdir().expect("temporary destination"); + let mut payable_bill = bill("Mixed Party", "4.00"); + payable_bill.kind = "payable"; + let unallocated = [UnallocatedParty { + party: "Mixed Party".to_string(), + amount: ExactDecimal::parse("3.00").expect("synthetic decimal"), + direction: ExposureDirection::Payable, + }]; + + let result = write_bulk_party_statements( + destination.path(), + "Synthetic Books Pvt Ltd", + "20260808", + "pdf", + &[bill("Mixed Party", "10.00"), payable_bill], + &unallocated, + |_| Ok(b"synthetic PDF".to_vec()), + ) + .expect("mixed statement writes"); + + assert_eq!(result.written[0].receivable_amount, "10"); + assert_eq!(result.written[0].payable_amount, "7"); + } + #[test] fn colliding_safe_names_are_written_to_distinct_files() { let destination = tempfile::tempdir().expect("temporary destination"); From c508b3a6ece81881f7dffa86ada66ee28b1f35df Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Tue, 18 Aug 2026 11:56:19 +0530 Subject: [PATCH 09/12] chore(tally): reseal F5X PR7 compatibility surface --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index f93fc77..89ddf4f 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "6e5b018fc5de0c6bc89b7baa1970d0f52be050d5542716b986e31ab8fb7b5e8b", + "compatibility_surface_sha256": "e1aa95b29718edcdce1f8a648e42249ee45fe9509334a6722ddfdbe70ec3eda4", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index acc8078..0b06ea2 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -63,7 +63,7 @@ }, { "path": "docs/tally/compatibility/synthetic-write-canary-fixture.md", - "sha256": "dd2f1c68c0925523af1468dd9c61330433130c0e72b7c713dfc1ef9205b4756f" + "sha256": "9ed85e60adb7306f11496b47f5a86d49327abfdd8e2735678c521187b9ce76e4" }, { "path": "docs/tally/support-matrix.md", @@ -79,7 +79,7 @@ }, { "path": "scripts/outstandings-copy.test.mjs", - "sha256": "58ca3cb255a8e54dc3a4590146b908b800fd4186d474eb05c3e1a46ae6c005a0" + "sha256": "8c9adb100a45704ca8ba72203a397defca3e8b01a297e96043c543cc052b980a" }, { "path": "scripts/tally-company-selection.test.mjs", @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-protocol/src/lib.rs", - "sha256": "c5c61049fdbedf31cfafb43d349961e736eb7c6a361ec08e410f2f94099b1200" + "sha256": "e1c9082a214a125454c2bbddef8494283d41efa025e4acfc1525f0a22aa2bd1e" }, { "path": "src-tauri/crates/bridge-tally-protocol/src/outstandings/completeness.rs", @@ -175,7 +175,7 @@ }, { "path": "src-tauri/crates/bridge-tally-protocol/tests/simulator_corpus.rs", - "sha256": "65da6e2a0cf543c46e1834d82f836b2966f9f574952b991259179cf743f907d6" + "sha256": "616eb8fa5e387bff74f62d8e775b7eb118860763b18ff63ad88ad5015f7f752f" }, { "path": "src-tauri/crates/bridge-tally-protocol/tests/stream_text_decoder.rs", @@ -219,7 +219,7 @@ }, { "path": "src-tauri/src/commands.rs", - "sha256": "ff39c070fcc66bff364f7ccd41f3cebd795c5cea0fba9c67022ac050f2ff3859" + "sha256": "be27371a934e0f8b825f64d11f7c7e8a3a1bea39e78b085a9263dac34125c739" }, { "path": "src-tauri/src/db/encrypted.rs", @@ -339,7 +339,7 @@ }, { "path": "src-tauri/src/tally/runtime.rs", - "sha256": "f6c4a1507b00c2689c128be4991af14af7cbd3ad2cec684673bebff9bb24a3c9" + "sha256": "ac652a70cd3b0b7184733ea08f6d8967f5b25f357d171d96a5c6d96f818d0563" }, { "path": "src-tauri/src/tally/serial_queue.rs", @@ -359,7 +359,7 @@ }, { "path": "src/OutstandingsScreen.tsx", - "sha256": "0e2c7160bd2e14a985f76cf3caeec9351527c0d236575321eecabdbc67ea037b" + "sha256": "5fbb3bc7a47487cf5a5d9f5bc527c5a7e01940589fd519c4a07c1e6c6f094cf3" }, { "path": "src/TallyReadinessFlow.tsx", @@ -371,7 +371,7 @@ }, { "path": "src/outstandings-copy.ts", - "sha256": "0e78dd97d79c0166fe17a4e933733c369a7e4be3046ddbe3e38122ad5e234216" + "sha256": "a2bcf820f694443ac122e91aa98fb354b6e2d5c6be3e1e9503b368a3a0a4f696" }, { "path": "src/outstandings-csv.ts", @@ -434,5 +434,5 @@ "sha256": "a27f294ee15e407b69fdfc73609e8708ac0509b6e6a8872daef5451fde61a8db" } ], - "manifest_sha256": "6e5b018fc5de0c6bc89b7baa1970d0f52be050d5542716b986e31ab8fb7b5e8b" + "manifest_sha256": "e1aa95b29718edcdce1f8a648e42249ee45fe9509334a6722ddfdbe70ec3eda4" } From 26288d1fc7672bb7bfc9dd85c520b5a9aec1ff91 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Tue, 18 Aug 2026 12:10:56 +0530 Subject: [PATCH 10/12] chore(tally): reseal F5X PR7 after clippy repair --- docs/tally/compatibility/compatibility-matrix.json | 2 +- docs/tally/compatibility/compatibility-surface.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 89ddf4f..88a681e 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "e1aa95b29718edcdce1f8a648e42249ee45fe9509334a6722ddfdbe70ec3eda4", + "compatibility_surface_sha256": "b0ff0c32648483fab96569fedf3ef4784afd4e48396ccf9731f1508c9753fe90", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 0b06ea2..1486f3d 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -339,7 +339,7 @@ }, { "path": "src-tauri/src/tally/runtime.rs", - "sha256": "ac652a70cd3b0b7184733ea08f6d8967f5b25f357d171d96a5c6d96f818d0563" + "sha256": "893bf325dcd614d49ac661127333c0cf714a937776c609a442fbccc240d06da1" }, { "path": "src-tauri/src/tally/serial_queue.rs", @@ -434,5 +434,5 @@ "sha256": "a27f294ee15e407b69fdfc73609e8708ac0509b6e6a8872daef5451fde61a8db" } ], - "manifest_sha256": "e1aa95b29718edcdce1f8a648e42249ee45fe9509334a6722ddfdbe70ec3eda4" + "manifest_sha256": "b0ff0c32648483fab96569fedf3ef4784afd4e48396ccf9731f1508c9753fe90" } From 9b440ca317f654e5edf87df54b5f9948db1c8a79 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Tue, 18 Aug 2026 12:34:44 +0530 Subject: [PATCH 11/12] chore(tally): reseal F5X PR7 after PR6 clippy repair --- docs/tally/compatibility/compatibility-matrix.json | 2 +- docs/tally/compatibility/compatibility-surface.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 88a681e..b7dc775 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "b0ff0c32648483fab96569fedf3ef4784afd4e48396ccf9731f1508c9753fe90", + "compatibility_surface_sha256": "0777cedfa36d2591a467cc0387826da0c41462c5f4ab46f37e119b5dd9428fac", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 1486f3d..672622e 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -219,7 +219,7 @@ }, { "path": "src-tauri/src/commands.rs", - "sha256": "be27371a934e0f8b825f64d11f7c7e8a3a1bea39e78b085a9263dac34125c739" + "sha256": "d12c341e5f5fecd6e41436293699bd8f318f0ae2127ccd7ede29e0af2db9d19e" }, { "path": "src-tauri/src/db/encrypted.rs", @@ -434,5 +434,5 @@ "sha256": "a27f294ee15e407b69fdfc73609e8708ac0509b6e6a8872daef5451fde61a8db" } ], - "manifest_sha256": "b0ff0c32648483fab96569fedf3ef4784afd4e48396ccf9731f1508c9753fe90" + "manifest_sha256": "0777cedfa36d2591a467cc0387826da0c41462c5f4ab46f37e119b5dd9428fac" } From 01c77ea4a87c672d63496836d5a691d6bcd68e90 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Tue, 18 Aug 2026 12:40:39 +0530 Subject: [PATCH 12/12] fix(tally): adapt bulk raw-byte proof to master snapshot The PR7 raw future-due statement proof still used the pre-A1 computation signature and failed the default workspace build at exit 101. It now supplies the ledger and empty group set as one NativeMasterSnapshot; the focused raw-byte test and default all-target Clippy pass at exit 0. --- docs/tally/compatibility/compatibility-matrix.json | 2 +- docs/tally/compatibility/compatibility-surface.json | 4 ++-- src-tauri/src/tally/runtime.rs | 5 ++++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index b7dc775..9f05e3a 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "0777cedfa36d2591a467cc0387826da0c41462c5f4ab46f37e119b5dd9428fac", + "compatibility_surface_sha256": "75387c68586650f337502929bf9ad6dcbd2d3b2f99e1641d9652395a806f22d8", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 672622e..fcbfde5 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -339,7 +339,7 @@ }, { "path": "src-tauri/src/tally/runtime.rs", - "sha256": "893bf325dcd614d49ac661127333c0cf714a937776c609a442fbccc240d06da1" + "sha256": "1d2ae890a240344ad9a2a59b4624be2726d13c11e5e529a0b084e355f865deec" }, { "path": "src-tauri/src/tally/serial_queue.rs", @@ -434,5 +434,5 @@ "sha256": "a27f294ee15e407b69fdfc73609e8708ac0509b6e6a8872daef5451fde61a8db" } ], - "manifest_sha256": "0777cedfa36d2591a467cc0387826da0c41462c5f4ab46f37e119b5dd9428fac" + "manifest_sha256": "75387c68586650f337502929bf9ad6dcbd2d3b2f99e1641d9652395a806f22d8" } diff --git a/src-tauri/src/tally/runtime.rs b/src-tauri/src/tally/runtime.rs index 1699e62..909e557 100644 --- a/src-tauri/src/tally/runtime.rs +++ b/src-tauri/src/tally/runtime.rs @@ -2406,7 +2406,10 @@ mod tests { "Synthetic Company", &receivable, &[], - &ledgers, + NativeMasterSnapshot { + ledgers: &ledgers, + groups: &[], + }, AgeingAnchor::DueDate, &as_of, bills_xml.len() + ledger_xml.len(),