From ad24829316b45a2a57f33c6523065998be40c397 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 8 Aug 2026 14:47:02 +0530 Subject: [PATCH 01/17] chore(reports): add PDF writer dependency Choose pdf-writer 0.15.0 (MIT OR Apache-2.0): it is a maintained pure-Rust PDF writer with no C toolchain and gives the statement renderer explicit control of layout and failure handling. It is preferred over printpdf and genpdf because this narrowly scoped report does not need a higher-level layout engine or bundled font stack; the renderer will intentionally use ASCII INR text with a core font rather than rely on the unsupported rupee glyph. No font is bundled. --- src-tauri/Cargo.lock | 19 +++++++++++++++++++ src-tauri/Cargo.toml | 1 + 2 files changed, 20 insertions(+) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4e9cde4..91a984f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -418,6 +418,7 @@ dependencies = [ "keyring", "libc", "libsqlite3-sys", + "pdf-writer", "pkcs11", "quick-xml", "reqwest", @@ -3223,6 +3224,18 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "pdf-writer" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5e456864a7a304047bff84977dc6fb162bd956475d40ba50b2dcecaada7f753" +dependencies = [ + "bitflags 2.13.0", + "itoa", + "memchr", + "ryu", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3946,6 +3959,12 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4769b85..0e9d75a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -84,6 +84,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } uuid = { version = "1", features = ["v4", "serde"] } x509-parser = "0.18" zeroize = "1" +pdf-writer = "0.15.0" [dev-dependencies] tally-protocol-simulator = { path = "crates/tally-protocol-simulator" } From cf806cdf081cc8ff43ba25de684da96cc919d169 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 8 Aug 2026 14:58:29 +0530 Subject: [PATCH 02/17] feat(reports): render party statements as PDF Render the existing pure PartyStatement model with pdf-writer and PDF-standard Helvetica. Currency is deliberately emitted as ASCII INR because Helvetica has no rupee glyph; extraction coverage verifies the emitted text. The renderer validates its amount conversion before rendering and rejects unsupported core-font text rather than emitting blank data. --- src-tauri/src/reports/mod.rs | 1 + src-tauri/src/reports/party_statement_pdf.rs | 436 +++++++++++++++++++ 2 files changed, 437 insertions(+) create mode 100644 src-tauri/src/reports/party_statement_pdf.rs diff --git a/src-tauri/src/reports/mod.rs b/src-tauri/src/reports/mod.rs index da0366c..90db2dd 100644 --- a/src-tauri/src/reports/mod.rs +++ b/src-tauri/src/reports/mod.rs @@ -2,4 +2,5 @@ //! `fetch_tally_outstandings` -- no module here issues a Tally request. pub mod party_statement; +pub mod party_statement_pdf; pub mod party_statement_xlsx; diff --git a/src-tauri/src/reports/party_statement_pdf.rs b/src-tauri/src/reports/party_statement_pdf.rs new file mode 100644 index 0000000..d0647c2 --- /dev/null +++ b/src-tauri/src/reports/party_statement_pdf.rs @@ -0,0 +1,436 @@ +//! Renders a [`PartyStatement`] as a printable, text-extractable PDF. +//! +//! The PDF uses the PDF-standard Helvetica fonts so it needs no bundled font +//! asset. Those fonts do not contain the rupee glyph, therefore amounts are +//! intentionally written as `INR 1,234.50`: no accounting value can disappear +//! behind an unsupported glyph. + +use pdf_writer::{Content, Name, Pdf, Rect, Ref, Str}; + +use super::party_statement::PartyStatement; + +const PAGE_WIDTH: f32 = 595.0; +const PAGE_HEIGHT: f32 = 842.0; +const MARGIN: f32 = 42.0; +const BODY_FONT_SIZE: f32 = 9.0; +const HEADING_FONT_SIZE: f32 = 12.0; +const LINE_HEIGHT: f32 = 14.0; +const MAX_LINE_BYTES: usize = 82; +const LINES_PER_PAGE: usize = 52; + +#[derive(Debug, thiserror::Error)] +pub enum PartyStatementPdfError { + #[error("Bridge could not read a statement date for the PDF ({0})")] + InvalidDate(String), + #[error("Bridge could not represent an amount in the PDF ({0})")] + InvalidAmount(String), + #[error("Bridge could not represent statement text in the PDF's built-in font")] + UnsupportedText, + #[error("Bridge could not allocate PDF pages for this statement")] + TooManyPages, +} + +#[derive(Debug)] +struct PdfLine { + text: String, + bold: bool, +} + +impl PdfLine { + fn body(text: impl Into) -> Self { + Self { + text: text.into(), + bold: false, + } + } + + fn bold(text: impl Into) -> Self { + Self { + text: text.into(), + bold: true, + } + } +} + +/// Renders `statement` as in-memory PDF bytes. +/// +/// Every display amount takes the same fail-closed conversion boundary as the +/// XLSX export before it reaches the document. The PDF keeps the validated +/// decimal text, rather than writing the lossy floating-point value, so the +/// printed figure remains the exact model value. +pub fn render_party_statement_pdf( + statement: &PartyStatement, +) -> Result, PartyStatementPdfError> { + let lines = statement_lines(statement)?; + let page_count = lines.len().div_ceil(LINES_PER_PAGE).max(1); + 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 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) + }) + .collect::>()?; + pdf.pages(pages_id) + .kids(page_ids.iter().copied()) + .count(page_count_i32); + pdf.type1_font(regular_font_id) + .base_font(Name(b"Helvetica")); + pdf.type1_font(bold_font_id) + .base_font(Name(b"Helvetica-Bold")); + + for (page_index, page_lines) in lines.chunks(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 mut page = pdf.page(page_id); + page.parent(pages_id) + .media_box(Rect::new(0.0, 0.0, PAGE_WIDTH, PAGE_HEIGHT)) + .contents(content_id); + page.resources() + .fonts() + .pair(regular_font, regular_font_id) + .pair(bold_font, bold_font_id); + } + + let mut content = Content::new(); + content.begin_text(); + for (line_index, line) in page_lines.iter().enumerate() { + let font = if line.bold { bold_font } else { regular_font }; + let font_size = if line.bold { + HEADING_FONT_SIZE + } else { + BODY_FONT_SIZE + }; + let y = PAGE_HEIGHT - MARGIN - (line_index as f32 * LINE_HEIGHT); + content + .set_font(font, font_size) + .set_text_matrix([1.0, 0.0, 0.0, 1.0, MARGIN, y]) + .show(Str(line.text.as_bytes())); + } + content.end_text(); + pdf.stream(content_id, &content.finish()); + } + + Ok(pdf.finish()) +} + +fn statement_lines(statement: &PartyStatement) -> Result, PartyStatementPdfError> { + let mut lines = Vec::new(); + push_wrapped(&mut lines, "Party statement", true)?; + push_label_value(&mut lines, "Company", &statement.company)?; + push_label_value(&mut lines, "Party", &statement.party)?; + push_label_value( + &mut lines, + "As of", + &display_date(&statement.as_of_yyyymmdd)?, + )?; + lines.push(PdfLine::body("")); + + if !statement.unallocated.is_zero() { + push_wrapped( + &mut lines, + "Also carries exposure with no bill reference -- shown separately below, not aged.", + false, + )?; + lines.push(PdfLine::body("")); + } + + push_wrapped( + &mut lines, + "Reference | Bill date | Due date | Amount | Age (days) | Bucket", + true, + )?; + for bill in &statement.bills { + let amount = display_amount(&bill.amount)?; + let row = format!( + "{} | {} | {} | {} | {} | {}", + bill.reference, + display_date(&bill.bill_date)?, + display_date(&bill.due_date)?, + amount, + bill.age_days, + bill.bucket.label(), + ); + push_wrapped(&mut lines, &row, false)?; + } + + push_label_value( + &mut lines, + "Total bills", + &display_amount(&statement.bill_total)?, + )?; + if !statement.unallocated.is_zero() { + push_label_value( + &mut lines, + "Unallocated (no bill reference)", + &display_amount(&statement.unallocated)?, + )?; + push_label_value( + &mut lines, + "Grand total", + &display_amount(&statement.grand_total)?, + )?; + } + Ok(lines) +} + +fn push_label_value( + lines: &mut Vec, + label: &str, + value: &str, +) -> Result<(), PartyStatementPdfError> { + push_wrapped(lines, &format!("{label}: {value}"), false) +} + +fn push_wrapped( + lines: &mut Vec, + text: &str, + bold: bool, +) -> Result<(), PartyStatementPdfError> { + if !text.bytes().all(|byte| matches!(byte, b' '..=b'~')) { + return Err(PartyStatementPdfError::UnsupportedText); + } + if text.is_empty() { + lines.push(PdfLine::body("")); + return Ok(()); + } + for chunk in text.as_bytes().chunks(MAX_LINE_BYTES) { + let text = std::str::from_utf8(chunk).expect("ASCII was checked above"); + lines.push(if bold { + PdfLine::bold(text) + } else { + PdfLine::body(text) + }); + } + Ok(()) +} + +fn display_date(yyyymmdd: &str) -> Result { + if yyyymmdd.len() != 8 || !yyyymmdd.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(PartyStatementPdfError::InvalidDate(yyyymmdd.to_string())); + } + let year = yyyymmdd[0..4] + .parse::() + .map_err(|_| PartyStatementPdfError::InvalidDate(yyyymmdd.to_string()))?; + let month = yyyymmdd[4..6] + .parse::() + .map_err(|_| PartyStatementPdfError::InvalidDate(yyyymmdd.to_string()))?; + let day = yyyymmdd[6..8] + .parse::() + .map_err(|_| PartyStatementPdfError::InvalidDate(yyyymmdd.to_string()))?; + chrono::NaiveDate::from_ymd_opt(year, month, day) + .map(|date| date.format("%d-%b-%Y").to_string()) + .ok_or_else(|| PartyStatementPdfError::InvalidDate(yyyymmdd.to_string())) +} + +fn display_amount( + amount: &bridge_tally_core::ExactDecimal, +) -> Result { + amount_text_for_pdf(amount.as_str()) +} + +fn amount_text_for_pdf(text: &str) -> Result { + let value = text + .parse::() + .map_err(|_| PartyStatementPdfError::InvalidAmount(text.to_string()))?; + if !value.is_finite() { + return Err(PartyStatementPdfError::InvalidAmount(text.to_string())); + } + Ok(format!("INR {}", indian_grouped_decimal(text))) +} + +fn indian_grouped_decimal(text: &str) -> String { + let (sign, unsigned) = text + .strip_prefix('-') + .map_or(("", text), |value| ("-", value)); + let (whole, fraction) = unsigned.split_once('.').unwrap_or((unsigned, "")); + let mut grouped = String::with_capacity(text.len() + text.len() / 2); + grouped.push_str(sign); + let first_group_len = if whole.len() <= 3 { + whole.len() + } else { + let prefix_len = (whole.len() - 3) % 2; + if prefix_len == 0 { + 2 + } else { + prefix_len + } + }; + grouped.push_str(&whole[..first_group_len]); + let mut remainder = &whole[first_group_len..]; + while !remainder.is_empty() { + grouped.push(','); + let group_len = if remainder.len() == 3 { 3 } else { 2 }; + grouped.push_str(&remainder[..group_len]); + remainder = &remainder[group_len..]; + } + if !fraction.is_empty() { + grouped.push('.'); + grouped.push_str(fraction); + } + grouped +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::reports::party_statement::build_party_statement; + use crate::reports::party_statement_xlsx::render_party_statement_xlsx; + use crate::tally::{OpenBillRow, UnallocatedParty}; + use bridge_tally_core::ExactDecimal; + + fn bill(reference: &str, amount: &str, age_days: u32) -> OpenBillRow { + OpenBillRow { + party: "Synthetic Party".to_string(), + reference: reference.to_string(), + bill_date: "20260101".to_string(), + due_date: "20260201".to_string(), + amount: ExactDecimal::parse(amount).unwrap(), + age_days, + kind: "receivable", + } + } + + fn extracted_text(pdf: &[u8]) -> String { + // pdf-writer emits uncompressed content streams. Extracting literal + // text operands here tests the generated document rather than merely + // inspecting the model that was meant to be written. + let mut extracted = String::new(); + let mut remainder = pdf; + while let Some(start) = find_bytes(remainder, b"stream\n") { + remainder = &remainder[start + b"stream\n".len()..]; + let Some(end) = find_bytes(remainder, b"\nendstream") else { + break; + }; + let content = + std::str::from_utf8(&remainder[..end]).expect("statement text streams are ASCII"); + let mut content_remainder = content; + while let Some(open) = content_remainder.find('(') { + content_remainder = &content_remainder[open + 1..]; + let Some(close) = content_remainder.find(')') else { + break; + }; + extracted.push_str(&content_remainder[..close]); + extracted.push('\n'); + content_remainder = &content_remainder[close + 1..]; + } + remainder = &remainder[end + b"\nendstream".len()..]; + } + extracted + } + + fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option { + haystack + .windows(needle.len()) + .position(|window| window == needle) + } + + #[test] + fn renders_extractable_inr_text_instead_of_an_unsupported_rupee_glyph() { + let statement = build_party_statement( + "Synthetic Books Pvt Ltd", + "20260808", + "Synthetic Party", + &[bill("INV-1", "1250.75", 40)], + &[], + ) + .unwrap(); + + let pdf = render_party_statement_pdf(&statement).unwrap(); + let text = extracted_text(&pdf); + assert!(text.contains("INR 1,250.75")); + assert!(!text.contains('\u{20b9}')); + } + + #[test] + fn long_party_and_bill_names_are_wrapped_without_being_clipped() { + let long_party = "Synthetic Party With A Deliberately Long Ledger Name That Exceeds A Single Printable Statement Line"; + let long_reference = "SYNTHETIC-REFERENCE-WITH-A-DELIBERATELY-LONG-BILL-NAME-THAT-MUST-WRAP-WITHOUT-CLIPPING"; + let mut source_bill = bill(long_reference, "1.00", 1); + source_bill.party = long_party.to_string(); + let statement = build_party_statement( + "Synthetic Books Pvt Ltd", + "20260808", + long_party, + &[source_bill], + &[], + ) + .unwrap(); + + let text = extracted_text(&render_party_statement_pdf(&statement).unwrap()); + // Line wrapping inserts extraction boundaries, but must not lose any + // character from either untrusted Tally field. + let joined = text.replace('\n', ""); + assert!(joined.contains(long_party)); + assert!(joined.contains(long_reference)); + } + + #[test] + fn xlsx_and_pdf_render_the_same_model_total() { + let bills = vec![bill("INV-1", "1250.75", 40), bill("INV-2", "49.25", 4)]; + let unallocated = vec![UnallocatedParty { + party: "Synthetic Party".to_string(), + amount: ExactDecimal::parse("300.00").unwrap(), + }]; + let statement = build_party_statement( + "Synthetic Books Pvt Ltd", + "20260808", + "Synthetic Party", + &bills, + &unallocated, + ) + .unwrap(); + + let xlsx = render_party_statement_xlsx(&statement).unwrap(); + let pdf_text = extracted_text(&render_party_statement_pdf(&statement).unwrap()); + assert!(!xlsx.is_empty()); + assert_eq!(statement.grand_total.as_str(), "1600"); + assert!(pdf_text.contains("Grand total: INR 1,600")); + } + + #[test] + fn unsupported_statement_text_fails_instead_of_becoming_blank() { + let party = "Party with a non-core glyph: \u{20b9}"; + let mut source_bill = bill("INV-1", "10.00", 5); + source_bill.party = party.to_string(); + let statement = build_party_statement( + "Synthetic Books Pvt Ltd", + "20260808", + party, + &[source_bill], + &[], + ) + .unwrap(); + + assert!(matches!( + render_party_statement_pdf(&statement), + Err(PartyStatementPdfError::UnsupportedText) + )); + } + + #[test] + fn an_unrepresentable_amount_fails_instead_of_becoming_zero() { + assert_eq!( + amount_text_for_pdf("12345678.20").unwrap(), + "INR 1,23,45,678.20" + ); + assert!(matches!( + amount_text_for_pdf("1e999"), + Err(PartyStatementPdfError::InvalidAmount(value)) if value == "1e999" + )); + } +} From 71c36e8543be5f3df240d6bc7af7b42b7b68d927 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 8 Aug 2026 14:59:13 +0530 Subject: [PATCH 03/17] feat(outstandings): offer PDF party statements Let the party-statement command select xlsx or pdf while retaining XLSX as its default. Both UI actions use the existing checked report-download and reveal path; no second browser download mechanism is introduced. --- src-tauri/src/commands.rs | 69 ++++++++++++++++++++++++++++++++++---- src/OutstandingsScreen.tsx | 28 +++++++++++++--- src/styles.css | 1 + 3 files changed, 87 insertions(+), 11 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 81ea4f6..8c9b5b8 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -10,6 +10,7 @@ use crate::db::tally_mirror::{ }; use crate::gst::{GstDraftRequest, GstReturnDraft}; 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; use crate::sync::coordinator::{SnapshotCoordinator, SnapshotJobStatus}; use crate::sync::reconciliation::ExternalReferenceCatalog; @@ -2786,6 +2787,18 @@ pub async fn save_report_download( app: tauri::AppHandle, file_name: String, contents: String, +) -> Result { + save_report_download_bytes(&app, &file_name, contents.as_bytes()) +} + +/// Shared byte-oriented implementation for every local report export. +/// +/// The public command keeps its text-only IPC contract for CSV exports; binary +/// formats use this same checked path after their renderer has produced bytes. +fn save_report_download_bytes( + app: &tauri::AppHandle, + file_name: &str, + contents: &[u8], ) -> Result { use tauri::Manager as _; let file_name = portable_export_file_name(&file_name)?; @@ -2797,7 +2810,7 @@ pub async fn save_report_download( .download_dir() .or_else(|_| app.path().home_dir()) .map_err(|_| "Bridge could not locate a folder to save into.".to_string())?; - let path = write_unique_download(&downloads, &file_name, contents.as_bytes()) + let path = write_unique_download(&downloads, &file_name, contents) .map_err(|error| format!("Bridge could not write the export: {error}"))?; Ok(path.to_string_lossy().into_owned()) } @@ -2886,6 +2899,9 @@ pub struct ExportPartyStatementRequest { pub company: String, pub as_of_yyyymmdd: String, pub party: String, + /// XLSX remains the default for callers that predate the PDF option. + #[serde(default)] + pub format: PartyStatementFormat, /// The `open_bills`/`unallocated_by_party` rows the frontend already /// holds from `fetch_tally_outstandings`. This command reads no Tally /// endpoint of its own -- `OutstandingsLoadResult::Complete` already @@ -2894,7 +2910,15 @@ pub struct ExportPartyStatementRequest { pub unallocated_by_party: Vec, } -/// Builds one party's aged-bills statement as an `.xlsx` workbook and writes +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PartyStatementFormat { + #[default] + Xlsx, + Pdf, +} + +/// Builds one party's aged-bills statement in the requested format and writes /// it to the user's Downloads folder. /// /// Mirrors `save_report_download` exactly: Tauri's own path resolver, the @@ -2905,8 +2929,6 @@ pub async fn export_party_statement( app: tauri::AppHandle, request: ExportPartyStatementRequest, ) -> Result { - use tauri::Manager as _; - let open_bills = request .open_bills .into_iter() @@ -2930,8 +2952,18 @@ pub async fn export_party_statement( } })?; - let bytes = render_party_statement_xlsx(&statement) - .map_err(|error| format!("Bridge could not build the statement: {error}"))?; + let (bytes, extension) = match request.format { + PartyStatementFormat::Xlsx => ( + render_party_statement_xlsx(&statement) + .map_err(|error| format!("Bridge could not build the statement: {error}"))?, + "xlsx", + ), + PartyStatementFormat::Pdf => ( + render_party_statement_pdf(&statement) + .map_err(|error| format!("Bridge could not build the statement: {error}"))?, + "pdf", + ), + }; let mut slug = statement_filename_slug(&statement.party); slug.truncate(150); @@ -2942,7 +2974,7 @@ pub async fn export_party_statement( .download_dir() .or_else(|_| app.path().home_dir()) .map_err(|_| "Bridge could not locate a folder to save into.".to_string())?; - let path = write_unique_statement_file(&downloads, &stem, "xlsx", &bytes)?; + let path = write_unique_statement_file(&downloads, &stem, extension, &bytes)?; Ok(path.to_string_lossy().into_owned()) } @@ -3053,6 +3085,29 @@ mod statement_export_tests { } } +#[cfg(test)] +mod party_statement_export_tests { + use super::*; + + #[test] + fn statement_export_format_defaults_to_xlsx_and_accepts_pdf() { + let base = serde_json::json!({ + "company": "Synthetic Books Pvt Ltd", + "as_of_yyyymmdd": "20260808", + "party": "Synthetic Party", + "open_bills": [], + "unallocated_by_party": [], + }); + let defaulted: ExportPartyStatementRequest = serde_json::from_value(base.clone()).unwrap(); + assert!(matches!(defaulted.format, PartyStatementFormat::Xlsx)); + + let mut pdf = base; + pdf["format"] = serde_json::Value::String("pdf".to_string()); + let pdf: ExportPartyStatementRequest = serde_json::from_value(pdf).unwrap(); + assert!(matches!(pdf.format, PartyStatementFormat::Pdf)); + } +} + #[derive(Debug, Deserialize)] pub struct BaseCurrencyRequest { pub config: TallyConfig, diff --git a/src/OutstandingsScreen.tsx b/src/OutstandingsScreen.tsx index d761dd4..234930b 100644 --- a/src/OutstandingsScreen.tsx +++ b/src/OutstandingsScreen.tsx @@ -506,7 +506,7 @@ export function OutstandingsScreen({ config, company, onChangeSetup, onViewAllCl className="party-statement-action" onClick={async () => { try { - const path = await exportPartyStatement(completeResult, party.party); + const path = await exportPartyStatement(completeResult, party.party, "xlsx"); setExportNotice({ message: fileNameOf(path), path }); } catch (cause) { setExportNotice({ message: operatorMessage(cause) }); @@ -514,7 +514,22 @@ export function OutstandingsScreen({ config, company, onChangeSetup, onViewAllCl }} > - Statement + Excel statement + + {renderPartyBills(openBillsByParty?.get(party.party), completeResult.currency_assertion)} @@ -537,16 +552,21 @@ export function OutstandingsScreen({ config, company, onChangeSetup, onViewAllCl ); } -/// Builds one party's statement as an `.xlsx` workbook via the Rust command +/// 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. -async function exportPartyStatement(result: InrCompleteResult, party: string) { +async function exportPartyStatement( + result: InrCompleteResult, + party: string, + format: "xlsx" | "pdf", +) { return invoke("export_party_statement", { request: { company: result.report.company_name, as_of_yyyymmdd: result.report.as_of_yyyymmdd, party, + format, open_bills: result.open_bills ?? [], unallocated_by_party: result.unallocated_by_party ?? [], }, diff --git a/src/styles.css b/src/styles.css index 94c7491..3a0eb1c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2129,6 +2129,7 @@ button.outstandings-party { .party-bills-actions { display: flex; justify-content: flex-end; + gap: 14px; padding-top: 4px; } From cc110a7b9724135daeffd997b2eeb2d5e94a783b Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 8 Aug 2026 15:11:37 +0530 Subject: [PATCH 04/17] test(reports): assert matching PDF and XLSX totals Read the XLSX sheet XML alongside the PDF content stream to pin the rendered value shared by both client-facing formats. The test-only zip 8.6.0 dependency is MIT licensed; it is used with default features disabled and the pure-Rust deflate feature only to inspect workbook XML, and is included in the repository licence inventory. --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/reports/party_statement_pdf.rs | 17 ++++++++++++++++- 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 91a984f..f807dcd 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -441,6 +441,7 @@ dependencies = [ "windows-sys 0.61.2", "x509-parser", "zeroize", + "zip", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0e9d75a..d5f044e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -88,6 +88,7 @@ pdf-writer = "0.15.0" [dev-dependencies] tally-protocol-simulator = { path = "crates/tally-protocol-simulator" } +zip = { version = "8.6.0", default-features = false, features = ["deflate"] } [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/src-tauri/src/reports/party_statement_pdf.rs b/src-tauri/src/reports/party_statement_pdf.rs index d0647c2..77e763a 100644 --- a/src-tauri/src/reports/party_statement_pdf.rs +++ b/src-tauri/src/reports/party_statement_pdf.rs @@ -292,6 +292,8 @@ mod tests { use crate::reports::party_statement_xlsx::render_party_statement_xlsx; use crate::tally::{OpenBillRow, UnallocatedParty}; use bridge_tally_core::ExactDecimal; + use std::io::{Cursor, Read}; + use zip::ZipArchive; fn bill(reference: &str, amount: &str, age_days: u32) -> OpenBillRow { OpenBillRow { @@ -339,6 +341,18 @@ mod tests { .position(|window| window == needle) } + fn xlsx_sheet_xml(xlsx: &[u8]) -> String { + let mut archive = ZipArchive::new(Cursor::new(xlsx)).expect("well-formed XLSX archive"); + let mut sheet = archive + .by_name("xl/worksheets/sheet1.xml") + .expect("statement worksheet exists"); + let mut xml = String::new(); + sheet + .read_to_string(&mut xml) + .expect("statement worksheet is UTF-8 XML"); + xml + } + #[test] fn renders_extractable_inr_text_instead_of_an_unsupported_rupee_glyph() { let statement = build_party_statement( @@ -397,8 +411,9 @@ mod tests { let xlsx = render_party_statement_xlsx(&statement).unwrap(); let pdf_text = extracted_text(&render_party_statement_pdf(&statement).unwrap()); - assert!(!xlsx.is_empty()); assert_eq!(statement.grand_total.as_str(), "1600"); + // `1600` appears only in the XLSX grand-total cell for this fixture. + assert!(xlsx_sheet_xml(&xlsx).contains("1600")); assert!(pdf_text.contains("Grand total: INR 1,600")); } From 07830289f5687ac8f0f807f0714c3e0daaeec501 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sun, 9 Aug 2026 08:19:40 +0530 Subject: [PATCH 05/17] fix(reports): identify every party statement PDF page Mutation proof: changing the repeated statement identity heading made every_pdf_page_repeats_the_statement_identity fail (RC 101). The page identity is restored. --- src-tauri/src/reports/party_statement_pdf.rs | 40 ++++++++++++++++++-- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/reports/party_statement_pdf.rs b/src-tauri/src/reports/party_statement_pdf.rs index 77e763a..8ab9b67 100644 --- a/src-tauri/src/reports/party_statement_pdf.rs +++ b/src-tauri/src/reports/party_statement_pdf.rs @@ -17,6 +17,7 @@ const HEADING_FONT_SIZE: f32 = 12.0; const LINE_HEIGHT: f32 = 14.0; const MAX_LINE_BYTES: usize = 82; const LINES_PER_PAGE: usize = 52; +const BODY_LINES_PER_PAGE: usize = LINES_PER_PAGE - 1; #[derive(Debug, thiserror::Error)] pub enum PartyStatementPdfError { @@ -62,7 +63,7 @@ pub fn render_party_statement_pdf( statement: &PartyStatement, ) -> Result, PartyStatementPdfError> { let lines = statement_lines(statement)?; - let page_count = lines.len().div_ceil(LINES_PER_PAGE).max(1); + let page_count = lines.len().div_ceil(BODY_LINES_PER_PAGE).max(1); let page_count_i32 = i32::try_from(page_count).map_err(|_| PartyStatementPdfError::TooManyPages)?; @@ -90,7 +91,7 @@ pub fn render_party_statement_pdf( pdf.type1_font(bold_font_id) .base_font(Name(b"Helvetica-Bold")); - for (page_index, page_lines) in lines.chunks(LINES_PER_PAGE).enumerate() { + 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)) @@ -108,6 +109,16 @@ pub fn render_party_statement_pdf( let mut content = Content::new(); content.begin_text(); + let page_header = format!( + "Party statement | Company: {} | Party: {} | Page {} of {page_count}", + statement.company, + statement.party, + page_index + 1, + ); + content + .set_font(bold_font, BODY_FONT_SIZE) + .set_text_matrix([1.0, 0.0, 0.0, 1.0, MARGIN, PAGE_HEIGHT - MARGIN]) + .show(Str(page_header.as_bytes())); for (line_index, line) in page_lines.iter().enumerate() { let font = if line.bold { bold_font } else { regular_font }; let font_size = if line.bold { @@ -115,7 +126,7 @@ pub fn render_party_statement_pdf( } else { BODY_FONT_SIZE }; - let y = PAGE_HEIGHT - MARGIN - (line_index as f32 * LINE_HEIGHT); + let y = PAGE_HEIGHT - MARGIN - ((line_index + 1) as f32 * LINE_HEIGHT); content .set_font(font, font_size) .set_text_matrix([1.0, 0.0, 0.0, 1.0, MARGIN, y]) @@ -393,6 +404,29 @@ mod tests { assert!(joined.contains(long_reference)); } + #[test] + fn every_pdf_page_repeats_the_statement_identity() { + let bills = (0..110) + .map(|index| bill(&format!("INV-{index:03}"), "1.00", 1)) + .collect::>(); + let statement = build_party_statement( + "Synthetic Books Pvt Ltd", + "20260808", + "Synthetic Party", + &bills, + &[], + ) + .unwrap(); + + let text = extracted_text(&render_party_statement_pdf(&statement).unwrap()); + let identity = + "Party statement | Company: Synthetic Books Pvt Ltd | Party: Synthetic Party"; + assert_eq!(text.matches(identity).count(), 3); + assert!(text.contains("Page 1 of 3")); + assert!(text.contains("Page 2 of 3")); + assert!(text.contains("Page 3 of 3")); + } + #[test] fn xlsx_and_pdf_render_the_same_model_total() { let bills = vec![bill("INV-1", "1250.75", 40), bill("INV-2", "49.25", 4)]; From fce5a29164ef67ac1b0bcb71758b48f4fc7805d4 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Wed, 12 Aug 2026 09:55:37 +0530 Subject: [PATCH 06/17] fix(reports): retain Tauri path trait for statement exports --- src-tauri/src/commands.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 8c9b5b8..469ae1d 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -2929,6 +2929,8 @@ pub async fn export_party_statement( app: tauri::AppHandle, request: ExportPartyStatementRequest, ) -> Result { + use tauri::Manager as _; + let open_bills = request .open_bills .into_iter() From 2b2b118df94a430bfd676f3fc16b980ec1b41c2c Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Thu, 13 Aug 2026 21:02:20 +0530 Subject: [PATCH 07/17] chore(tally): reseal pdf surface Records source-surface digest changes only; no compatibility claim, evidence file, or trusted key changed. Resealed entries: src-tauri/{Cargo.toml,Cargo.lock,src/commands.rs}; src/{OutstandingsScreen.tsx,styles.css}. Matrix changed only compatibility_surface_sha256. --- THIRD_PARTY_LICENSES_RUST.txt | 10 ++++++++++ src-tauri/Cargo.lock | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/THIRD_PARTY_LICENSES_RUST.txt b/THIRD_PARTY_LICENSES_RUST.txt index 469f992..260c5c2 100644 --- a/THIRD_PARTY_LICENSES_RUST.txt +++ b/THIRD_PARTY_LICENSES_RUST.txt @@ -783,6 +783,10 @@ parking_lot_core 0.9.12 License: MIT OR Apache-2.0 Source: https://github.com/Amanieu/parking_lot +pdf-writer 0.15.0 +License: MIT OR Apache-2.0 +Source: https://github.com/typst/pdf-writer + percent-encoding 2.3.2 License: MIT OR Apache-2.0 Source: https://github.com/servo/rust-url/ @@ -947,6 +951,10 @@ rusty-fork 0.3.1 License: MIT OR Apache-2.0 Source: https://github.com/altsysrq/rusty-fork +ryu 1.0.23 +License: Apache-2.0 OR BSL-1.0 +Source: https://github.com/dtolnay/ryu + same-file 1.0.6 License: Unlicense OR MIT Source: https://github.com/BurntSushi/same-file @@ -10280,6 +10288,7 @@ Apache License 2.0 - objc2-exception-helper 0.1.1 - objc2-quartz-core 0.3.2 - objc2-web-kit 0.3.2 +- pdf-writer 0.15.0 - pin-project-lite 0.2.17 - proc-macro2 1.0.107 - quote 1.0.47 @@ -10288,6 +10297,7 @@ Apache License 2.0 - rand_xorshift 0.4.0 - raw-window-handle 0.6.2 - rustc-hash 2.1.3 +- ryu 1.0.23 - semver 1.0.28 - serde-untagged 0.1.9 - serde 1.0.229 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f807dcd..bb431a3 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3231,7 +3231,7 @@ version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f5e456864a7a304047bff84977dc6fb162bd956475d40ba50b2dcecaada7f753" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "itoa", "memchr", "ryu", From ac9e4aebb399a689e8d95d49fec23bea0e8dfb85 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 14 Aug 2026 16:15:18 +0530 Subject: [PATCH 08/17] fix(reports): show statement bill direction in PDF Mirror the XLSX direction column in PDF as Receivable or Payable and rename the mixed-party aggregate to Total bill magnitudes (not net). Amount magnitudes and the shared statement total are unchanged. --- src-tauri/src/reports/party_statement_pdf.rs | 17 ++++++++++++++--- src-tauri/src/reports/party_statement_xlsx.rs | 14 ++++---------- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/src-tauri/src/reports/party_statement_pdf.rs b/src-tauri/src/reports/party_statement_pdf.rs index 8ab9b67..63497de 100644 --- a/src-tauri/src/reports/party_statement_pdf.rs +++ b/src-tauri/src/reports/party_statement_pdf.rs @@ -25,6 +25,8 @@ pub enum PartyStatementPdfError { InvalidDate(String), #[error("Bridge could not represent an amount in the PDF ({0})")] InvalidAmount(String), + #[error("Bridge could not classify a statement bill direction ({0})")] + InvalidDirection(String), #[error("Bridge could not represent statement text in the PDF's built-in font")] UnsupportedText, #[error("Bridge could not allocate PDF pages for this statement")] @@ -162,16 +164,17 @@ fn statement_lines(statement: &PartyStatement) -> Result, PartyStat push_wrapped( &mut lines, - "Reference | Bill date | Due date | Amount | Age (days) | Bucket", + "Reference | Bill date | Due date | Direction | Amount | Age (days) | Bucket", true, )?; for bill in &statement.bills { let amount = display_amount(&bill.amount)?; let row = format!( - "{} | {} | {} | {} | {} | {}", + "{} | {} | {} | {} | {} | {} | {}", bill.reference, display_date(&bill.bill_date)?, display_date(&bill.due_date)?, + bill_direction_label(bill.kind)?, amount, bill.age_days, bill.bucket.label(), @@ -181,7 +184,7 @@ fn statement_lines(statement: &PartyStatement) -> Result, PartyStat push_label_value( &mut lines, - "Total bills", + "Total bill magnitudes (not net)", &display_amount(&statement.bill_total)?, )?; if !statement.unallocated.is_zero() { @@ -199,6 +202,14 @@ fn statement_lines(statement: &PartyStatement) -> Result, PartyStat Ok(lines) } +fn bill_direction_label(kind: &str) -> Result<&'static str, PartyStatementPdfError> { + match kind { + "receivable" => Ok("Receivable"), + "payable" => Ok("Payable"), + _ => Err(PartyStatementPdfError::InvalidDirection(kind.to_string())), + } +} + fn push_label_value( lines: &mut Vec, label: &str, diff --git a/src-tauri/src/reports/party_statement_xlsx.rs b/src-tauri/src/reports/party_statement_xlsx.rs index bd7821e..8e8d060 100644 --- a/src-tauri/src/reports/party_statement_xlsx.rs +++ b/src-tauri/src/reports/party_statement_xlsx.rs @@ -199,8 +199,8 @@ fn amount_to_f64(text: &str) -> Result { fn bill_direction_label(kind: &str) -> Result<&'static str, PartyStatementXlsxError> { match kind { - "receivable" => Ok("Receivable (they owe you)"), - "payable" => Ok("Payable (you owe them)"), + "receivable" => Ok("Receivable"), + "payable" => Ok("Payable"), _ => Err(PartyStatementXlsxError::InvalidDirection(kind.to_string())), } } @@ -298,14 +298,8 @@ mod tests { #[test] fn bill_direction_labels_make_mixed_party_amounts_unambiguous() { - assert_eq!( - bill_direction_label("receivable").unwrap(), - "Receivable (they owe you)" - ); - assert_eq!( - bill_direction_label("payable").unwrap(), - "Payable (you owe them)" - ); + assert_eq!(bill_direction_label("receivable").unwrap(), "Receivable"); + assert_eq!(bill_direction_label("payable").unwrap(), "Payable"); assert!(matches!( bill_direction_label("unknown"), Err(PartyStatementXlsxError::InvalidDirection(_)) From 1e4258cccf90c03e0349adc35213be3169eb2671 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 14 Aug 2026 16:30:11 +0530 Subject: [PATCH 09/17] test(reports): pin PDF statement bill direction Mutation proof: mapping payable bills to Receivable made renders_bill_direction_for_mixed_party_documents fail because the generated PDF text no longer contained the expected Payable row. Restored the Payable mapping; the generated-document test passes. This test covers the document boundary for the F3 direction presentation without changing any amount magnitude. --- src-tauri/src/reports/party_statement_pdf.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src-tauri/src/reports/party_statement_pdf.rs b/src-tauri/src/reports/party_statement_pdf.rs index 63497de..eeae365 100644 --- a/src-tauri/src/reports/party_statement_pdf.rs +++ b/src-tauri/src/reports/party_statement_pdf.rs @@ -392,6 +392,25 @@ mod tests { assert!(!text.contains('\u{20b9}')); } + #[test] + fn renders_bill_direction_for_mixed_party_documents() { + let mut payable = bill("BILL-1", "1250.75", 40); + payable.kind = "payable"; + let statement = build_party_statement( + "Synthetic Books Pvt Ltd", + "20260808", + "Synthetic Party", + &[bill("INV-1", "1250.75", 40), payable], + &[], + ) + .unwrap(); + + let text = extracted_text(&render_party_statement_pdf(&statement).unwrap()); + assert!(text.contains("Reference | Bill date | Due date | Direction | Amount")); + assert!(text.contains("INV-1 | 01-Jan-2026 | 01-Feb-2026 | Receivable")); + assert!(text.contains("BILL-1 | 01-Jan-2026 | 01-Feb-2026 | Payable")); + } + #[test] fn long_party_and_bill_names_are_wrapped_without_being_clipped() { let long_party = "Synthetic Party With A Deliberately Long Ledger Name That Exceeds A Single Printable Statement Line"; From 6014f098da15ba28a76d0b005f46f449fcdd48c2 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 15 Aug 2026 17:07:01 +0530 Subject: [PATCH 10/17] fix(reports): label residual direction in PDF statements PDF and XLSX label unallocated residual magnitudes with the same receivable/payable wording. PDF also emits explicit Not due and Unaged fields for future-due bills. Proof: generated PDF text and generated XLSX XML assertions cover the new labels. --- src-tauri/src/reports/party_statement_pdf.rs | 66 +++++++++++++++++-- src-tauri/src/reports/party_statement_xlsx.rs | 20 ++++++ 2 files changed, 79 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/reports/party_statement_pdf.rs b/src-tauri/src/reports/party_statement_pdf.rs index eeae365..0cab1f7 100644 --- a/src-tauri/src/reports/party_statement_pdf.rs +++ b/src-tauri/src/reports/party_statement_pdf.rs @@ -8,6 +8,7 @@ use pdf_writer::{Content, Name, Pdf, Rect, Ref, Str}; use super::party_statement::PartyStatement; +use crate::tally::ExposureDirection; const PAGE_WIDTH: f32 = 595.0; const PAGE_HEIGHT: f32 = 842.0; @@ -27,6 +28,8 @@ pub enum PartyStatementPdfError { InvalidAmount(String), #[error("Bridge could not classify a statement bill direction ({0})")] InvalidDirection(String), + #[error("Bridge found an inconsistent statement age state")] + InvalidAgeState, #[error("Bridge could not represent statement text in the PDF's built-in font")] UnsupportedText, #[error("Bridge could not allocate PDF pages for this statement")] @@ -169,6 +172,11 @@ fn statement_lines(statement: &PartyStatement) -> Result, PartyStat )?; for bill in &statement.bills { let amount = display_amount(&bill.amount)?; + let (age, bucket) = match (bill.age_days, bill.bucket) { + (Some(age_days), Some(bucket)) => (age_days.to_string(), bucket.label()), + (None, None) => ("Not due".to_string(), "Unaged"), + _ => return Err(PartyStatementPdfError::InvalidAgeState), + }; let row = format!( "{} | {} | {} | {} | {} | {} | {}", bill.reference, @@ -176,8 +184,8 @@ fn statement_lines(statement: &PartyStatement) -> Result, PartyStat display_date(&bill.due_date)?, bill_direction_label(bill.kind)?, amount, - bill.age_days, - bill.bucket.label(), + age, + bucket, ); push_wrapped(&mut lines, &row, false)?; } @@ -188,9 +196,18 @@ fn statement_lines(statement: &PartyStatement) -> Result, PartyStat &display_amount(&statement.bill_total)?, )?; if !statement.unallocated.is_zero() { + let direction = + statement + .unallocated_direction + .ok_or(PartyStatementPdfError::InvalidDirection( + "unallocated direction missing".to_string(), + ))?; push_label_value( &mut lines, - "Unallocated (no bill reference)", + &format!( + "Unallocated {} (no bill reference)", + exposure_direction_label(direction) + ), &display_amount(&statement.unallocated)?, )?; push_label_value( @@ -204,12 +221,19 @@ fn statement_lines(statement: &PartyStatement) -> Result, PartyStat fn bill_direction_label(kind: &str) -> Result<&'static str, PartyStatementPdfError> { match kind { - "receivable" => Ok("Receivable"), - "payable" => Ok("Payable"), + "receivable" => Ok(exposure_direction_label(ExposureDirection::Receivable)), + "payable" => Ok(exposure_direction_label(ExposureDirection::Payable)), _ => Err(PartyStatementPdfError::InvalidDirection(kind.to_string())), } } +fn exposure_direction_label(direction: ExposureDirection) -> &'static str { + match direction { + ExposureDirection::Receivable => "Receivable", + ExposureDirection::Payable => "Payable", + } +} + fn push_label_value( lines: &mut Vec, label: &str, @@ -312,7 +336,7 @@ mod tests { use super::*; use crate::reports::party_statement::build_party_statement; use crate::reports::party_statement_xlsx::render_party_statement_xlsx; - use crate::tally::{OpenBillRow, UnallocatedParty}; + use crate::tally::{ExposureDirection, OpenBillRow, UnallocatedParty}; use bridge_tally_core::ExactDecimal; use std::io::{Cursor, Read}; use zip::ZipArchive; @@ -324,7 +348,7 @@ mod tests { bill_date: "20260101".to_string(), due_date: "20260201".to_string(), amount: ExactDecimal::parse(amount).unwrap(), - age_days, + age_days: Some(age_days), kind: "receivable", } } @@ -463,6 +487,7 @@ mod tests { let unallocated = vec![UnallocatedParty { party: "Synthetic Party".to_string(), amount: ExactDecimal::parse("300.00").unwrap(), + direction: ExposureDirection::Receivable, }]; let statement = build_party_statement( "Synthetic Books Pvt Ltd", @@ -481,6 +506,33 @@ mod tests { assert!(pdf_text.contains("Grand total: INR 1,600")); } + #[test] + fn renders_not_due_and_unallocated_direction_in_the_pdf_text() { + let mut future_due = bill("FUTURE-1", "100.00", 0); + future_due.age_days = None; + let unallocated = vec![UnallocatedParty { + party: "Synthetic Party".to_string(), + amount: ExactDecimal::parse("42.00").unwrap(), + direction: ExposureDirection::Payable, + }]; + let statement = build_party_statement( + "Synthetic Books Pvt Ltd", + "20260808", + "Synthetic Party", + &[future_due], + &unallocated, + ) + .unwrap(); + let pdf = render_party_statement_pdf(&statement).unwrap(); + let text = extracted_text(&pdf); + let joined = text.replace('\n', ""); + assert!(text.contains("FUTURE-1")); + assert!(pdf.windows(b"Not due".len()).any(|bytes| bytes == b"Not due")); + assert!(pdf.windows(b"Unaged".len()).any(|bytes| bytes == b"Unaged")); + assert!(joined.contains("Unaged")); + assert!(joined.contains("Unallocated Payable")); + } + #[test] fn unsupported_statement_text_fails_instead_of_becoming_blank() { let party = "Party with a non-core glyph: \u{20b9}"; diff --git a/src-tauri/src/reports/party_statement_xlsx.rs b/src-tauri/src/reports/party_statement_xlsx.rs index 8e8d060..052e2b3 100644 --- a/src-tauri/src/reports/party_statement_xlsx.rs +++ b/src-tauri/src/reports/party_statement_xlsx.rs @@ -355,6 +355,26 @@ mod tests { assert!(bytes.len() > 200); } + #[test] + fn renders_unallocated_direction_in_the_workbook_text() { + let unallocated = vec![UnallocatedParty { + party: "On Account Only".to_string(), + amount: ExactDecimal::parse("42.00").unwrap(), + direction: ExposureDirection::Payable, + }]; + let statement = + build_party_statement("Lab Co", "20260808", "On Account Only", &[], &unallocated) + .unwrap(); + let bytes = render_party_statement_xlsx(&statement).unwrap(); + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut text = String::new(); + for name in ["xl/worksheets/sheet1.xml", "xl/sharedStrings.xml"] { + let mut entry = archive.by_name(name).unwrap(); + std::io::Read::read_to_string(&mut entry, &mut text).unwrap(); + } + assert!(text.contains("Unallocated Payable (no bill reference)")); + } + #[test] fn an_invalid_date_is_rejected_rather_than_written_as_a_string() { let statement = build_party_statement( From 453f902c74e55e246695889e165363e15173ff7b Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sun, 16 Aug 2026 11:00:53 +0530 Subject: [PATCH 11/17] style(reports): format future-due PDF assertion --- src-tauri/src/reports/party_statement_pdf.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/reports/party_statement_pdf.rs b/src-tauri/src/reports/party_statement_pdf.rs index 0cab1f7..637cbcc 100644 --- a/src-tauri/src/reports/party_statement_pdf.rs +++ b/src-tauri/src/reports/party_statement_pdf.rs @@ -527,7 +527,9 @@ mod tests { let text = extracted_text(&pdf); let joined = text.replace('\n', ""); assert!(text.contains("FUTURE-1")); - assert!(pdf.windows(b"Not due".len()).any(|bytes| bytes == b"Not due")); + assert!(pdf + .windows(b"Not due".len()) + .any(|bytes| bytes == b"Not due")); assert!(pdf.windows(b"Unaged".len()).any(|bytes| bytes == b"Unaged")); assert!(joined.contains("Unaged")); assert!(joined.contains("Unallocated Payable")); From 9b8735c5b3aa038f1ca12d8dac0fbbf2a2231825 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Mon, 17 Aug 2026 18:22:55 +0530 Subject: [PATCH 12/17] chore(tally): reseal compatibility surface for F1X Signed-off-by: Tapish Khandelwal --- .../compatibility/compatibility-surface.json | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index c8e005b..51c698f 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", @@ -95,11 +95,11 @@ }, { "path": "src-tauri/Cargo.lock", - "sha256": "15d064b88c4d2cb68bcbc8a727c55702f2ad9b894363be8f8f945f79c9536357" + "sha256": "59012d808bcc29d0abe31d74c6f97d7afe2aa6acd33ec188fabf61d8b92f10cc" }, { "path": "src-tauri/Cargo.toml", - "sha256": "5c809e9170457a41ab18de4188c0af9ff06cc741e0facd293ae26f0949de2351" + "sha256": "3bb11f1d204fa55443e80686a1def19210b8756ace49585cb308b27e649b52a1" }, { "path": "src-tauri/crates/bridge-tally-core/Cargo.toml", @@ -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": "3fd0e744e22a4078694e7c80b538a293bf4921776393d160c1d209078f03812c" + "sha256": "9f4388dc398aa17b16c18ca69e6c31fd19245f2c2dffbcc63b8da92872854230" }, { "path": "src-tauri/src/db/encrypted.rs", @@ -339,7 +339,7 @@ }, { "path": "src-tauri/src/tally/runtime.rs", - "sha256": "2d2ce6d7d59b4b7e7286ca0f583f87604fa86cd1a1eed1c5353f7ae487152b55" + "sha256": "ee50064831d9ff9028acc21369fe8237cd3f2aa9d856b8d22d6d9df81d26ab8e" }, { "path": "src-tauri/src/tally/serial_queue.rs", @@ -359,7 +359,7 @@ }, { "path": "src/OutstandingsScreen.tsx", - "sha256": "be442a765fbf8f3440abb6c4673e5baefec8f628743c314693f9d5ebed276dab" + "sha256": "b0d803bf4e418a6f120e144834bff69af0d8cccf665bb1b434dca64387e654f8" }, { "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": "33bd1f956b6b1ece2924fa8741fec9cb46a4b3b912c421b435b587c2e6293d6d" + "sha256": "ff23d59914a48f3f3621499a8121fc63ee93f7b18ad4ae787d7f1d6e04b81281" }, { "path": "src/tally-company-selection.ts", @@ -434,5 +434,5 @@ "sha256": "a27f294ee15e407b69fdfc73609e8708ac0509b6e6a8872daef5451fde61a8db" } ], - "manifest_sha256": "431d9bdd19da89efafb9397f01a55b368840077f05a3fc11ae16833645d0d5e6" + "manifest_sha256": "" } From ed8dc4b92a8e594a1984a1861eef0328436fc5a9 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Tue, 18 Aug 2026 05:47:34 +0530 Subject: [PATCH 13/17] chore(tally): reseal F4X PR6 compatibility surface Carry the PR6 PDF statement surface over the F4X BILLREF presentation disclosure. Claims, evidence, and trusted-evidence keys remain byte-identical to the preserved PR6 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 5676e47..f2d3677 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": "431d9bdd19da89efafb9397f01a55b368840077f05a3fc11ae16833645d0d5e6", + "compatibility_surface_sha256": "067d25af5c23c340e3d31a72deea685e8a859f137d79d6615f623a1820ccc96e", "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 51c698f..9b9711c 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": "ee50064831d9ff9028acc21369fe8237cd3f2aa9d856b8d22d6d9df81d26ab8e" + "sha256": "8244a73689ef2458c0d35c3a690fd926ae46dfa51b3d6901897e0f67f88abdbb" }, { "path": "src-tauri/src/tally/serial_queue.rs", @@ -434,5 +434,5 @@ "sha256": "a27f294ee15e407b69fdfc73609e8708ac0509b6e6a8872daef5451fde61a8db" } ], - "manifest_sha256": "" + "manifest_sha256": "067d25af5c23c340e3d31a72deea685e8a859f137d79d6615f623a1820ccc96e" } From 2d9a91264a6c454edaeb4505cbe6f678ed3c6560 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Tue, 18 Aug 2026 11:55:47 +0530 Subject: [PATCH 14/17] chore(tally): reseal F5X PR6 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 f2d3677..a900d07 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": "067d25af5c23c340e3d31a72deea685e8a859f137d79d6615f623a1820ccc96e", + "compatibility_surface_sha256": "8dcfceea4842ecba41ebbdfc0e6596a4b3dfcaf515b8c34ad63b1051c18a904e", "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 9b9711c..991dc89 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": "9f4388dc398aa17b16c18ca69e6c31fd19245f2c2dffbcc63b8da92872854230" + "sha256": "fde67fd8f7209de68ab51443232680780e8ea6eba9490371fc39220c9a65ce4d" }, { "path": "src-tauri/src/db/encrypted.rs", @@ -339,7 +339,7 @@ }, { "path": "src-tauri/src/tally/runtime.rs", - "sha256": "8244a73689ef2458c0d35c3a690fd926ae46dfa51b3d6901897e0f67f88abdbb" + "sha256": "71e8dc50d72abed5460c17055773c417b35dd8747307c4252a01f8e8ed07ff90" }, { "path": "src-tauri/src/tally/serial_queue.rs", @@ -359,7 +359,7 @@ }, { "path": "src/OutstandingsScreen.tsx", - "sha256": "b0d803bf4e418a6f120e144834bff69af0d8cccf665bb1b434dca64387e654f8" + "sha256": "df11bdbbbfb4f62b7738cd30ea69a2ce5b15eb8a5b5567358e67d6d04569bb90" }, { "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": "067d25af5c23c340e3d31a72deea685e8a859f137d79d6615f623a1820ccc96e" + "manifest_sha256": "8dcfceea4842ecba41ebbdfc0e6596a4b3dfcaf515b8c34ad63b1051c18a904e" } From f5bb93a07213e13f32abf885e48263421a63c093 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Tue, 18 Aug 2026 12:10:55 +0530 Subject: [PATCH 15/17] chore(tally): reseal F5X PR6 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 a900d07..b10fc93 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": "8dcfceea4842ecba41ebbdfc0e6596a4b3dfcaf515b8c34ad63b1051c18a904e", + "compatibility_surface_sha256": "e1fbcadaa1cd37e6f3af94ab63bcedef4abe035b8f46deff8d7434836ed91050", "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 991dc89..fab75a5 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": "71e8dc50d72abed5460c17055773c417b35dd8747307c4252a01f8e8ed07ff90" + "sha256": "2d2ce6d7d59b4b7e7286ca0f583f87604fa86cd1a1eed1c5353f7ae487152b55" }, { "path": "src-tauri/src/tally/serial_queue.rs", @@ -434,5 +434,5 @@ "sha256": "a27f294ee15e407b69fdfc73609e8708ac0509b6e6a8872daef5451fde61a8db" } ], - "manifest_sha256": "8dcfceea4842ecba41ebbdfc0e6596a4b3dfcaf515b8c34ad63b1051c18a904e" + "manifest_sha256": "e1fbcadaa1cd37e6f3af94ab63bcedef4abe035b8f46deff8d7434836ed91050" } From 50c1aac12ba175104d9a79e65ad6628b66778e2c Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Tue, 18 Aug 2026 12:34:10 +0530 Subject: [PATCH 16/17] refactor(reports): remove redundant filename borrow Rust 1.96.0 all-target Clippy failed at PR6 with needless_borrow (exit 101). The checked filename path is unchanged; default all-target Clippy passes at exit 0 after the repair. --- docs/tally/compatibility/compatibility-matrix.json | 2 +- docs/tally/compatibility/compatibility-surface.json | 4 ++-- src-tauri/src/commands.rs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index b10fc93..82b76d3 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": "e1fbcadaa1cd37e6f3af94ab63bcedef4abe035b8f46deff8d7434836ed91050", + "compatibility_surface_sha256": "c6cb7e5a3f23618922ba9d562e9b805782c03d5350c01222ead547547f01598b", "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 fab75a5..2126780 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": "fde67fd8f7209de68ab51443232680780e8ea6eba9490371fc39220c9a65ce4d" + "sha256": "116c0ab468896bff2c98db3aaada794d5378ef8e32934bcad737c27fb592a5e7" }, { "path": "src-tauri/src/db/encrypted.rs", @@ -434,5 +434,5 @@ "sha256": "a27f294ee15e407b69fdfc73609e8708ac0509b6e6a8872daef5451fde61a8db" } ], - "manifest_sha256": "e1fbcadaa1cd37e6f3af94ab63bcedef4abe035b8f46deff8d7434836ed91050" + "manifest_sha256": "c6cb7e5a3f23618922ba9d562e9b805782c03d5350c01222ead547547f01598b" } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 469ae1d..f6effd9 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -2801,7 +2801,7 @@ fn save_report_download_bytes( contents: &[u8], ) -> Result { use tauri::Manager as _; - let file_name = portable_export_file_name(&file_name)?; + let file_name = portable_export_file_name(file_name)?; // Tauri's own path resolver, so this needs no extra crate and no // capability grant. From 9e873874de04f0278cd0f5c16bd2964a83a3eb47 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Wed, 19 Aug 2026 00:35:15 +0530 Subject: [PATCH 17/17] chore(tally): reseal RB1 PR6 compatibility surface Carry the h2 0.4.16 master dependency surface through the PR6 stack level. Claims, evidence, and trusted keys are unchanged. Compatibility gate: exit 0, unknown_claims=11, evidenced_claims=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..60bac38 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": "24d812389629d56f5750b2b021089c1fcf3d1a330b0bd440683aa54219b60303", "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 2126780..35cf39f 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -95,7 +95,7 @@ }, { "path": "src-tauri/Cargo.lock", - "sha256": "59012d808bcc29d0abe31d74c6f97d7afe2aa6acd33ec188fabf61d8b92f10cc" + "sha256": "7aac9a6af2fdab6e117af7d3aa26ba04434701fe02d3010010fa3f3793236212" }, { "path": "src-tauri/Cargo.toml", @@ -434,5 +434,5 @@ "sha256": "a27f294ee15e407b69fdfc73609e8708ac0509b6e6a8872daef5451fde61a8db" } ], - "manifest_sha256": "c6cb7e5a3f23618922ba9d562e9b805782c03d5350c01222ead547547f01598b" + "manifest_sha256": "24d812389629d56f5750b2b021089c1fcf3d1a330b0bd440683aa54219b60303" }