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/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 5676e47..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": "431d9bdd19da89efafb9397f01a55b368840077f05a3fc11ae16833645d0d5e6", + "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 c8e005b..35cf39f 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -95,11 +95,11 @@ }, { "path": "src-tauri/Cargo.lock", - "sha256": "15d064b88c4d2cb68bcbc8a727c55702f2ad9b894363be8f8f945f79c9536357" + "sha256": "7aac9a6af2fdab6e117af7d3aa26ba04434701fe02d3010010fa3f3793236212" }, { "path": "src-tauri/Cargo.toml", - "sha256": "5c809e9170457a41ab18de4188c0af9ff06cc741e0facd293ae26f0949de2351" + "sha256": "3bb11f1d204fa55443e80686a1def19210b8756ace49585cb308b27e649b52a1" }, { "path": "src-tauri/crates/bridge-tally-core/Cargo.toml", @@ -219,7 +219,7 @@ }, { "path": "src-tauri/src/commands.rs", - "sha256": "3fd0e744e22a4078694e7c80b538a293bf4921776393d160c1d209078f03812c" + "sha256": "116c0ab468896bff2c98db3aaada794d5378ef8e32934bcad737c27fb592a5e7" }, { "path": "src-tauri/src/db/encrypted.rs", @@ -359,7 +359,7 @@ }, { "path": "src/OutstandingsScreen.tsx", - "sha256": "be442a765fbf8f3440abb6c4673e5baefec8f628743c314693f9d5ebed276dab" + "sha256": "df11bdbbbfb4f62b7738cd30ea69a2ce5b15eb8a5b5567358e67d6d04569bb90" }, { "path": "src/TallyReadinessFlow.tsx", @@ -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": "24d812389629d56f5750b2b021089c1fcf3d1a330b0bd440683aa54219b60303" } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4e9cde4..bb431a3 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", @@ -440,6 +441,7 @@ dependencies = [ "windows-sys 0.61.2", "x509-parser", "zeroize", + "zip", ] [[package]] @@ -3223,6 +3225,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.1", + "itoa", + "memchr", + "ryu", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3946,6 +3960,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..d5f044e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -84,9 +84,11 @@ 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" } +zip = { version = "8.6.0", default-features = false, features = ["deflate"] } [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 81ea4f6..f6effd9 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,9 +2787,21 @@ 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)?; + let file_name = portable_export_file_name(file_name)?; // Tauri's own path resolver, so this needs no extra crate and no // capability grant. @@ -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 @@ -2930,8 +2954,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 +2976,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 +3087,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-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..637cbcc --- /dev/null +++ b/src-tauri/src/reports/party_statement_pdf.rs @@ -0,0 +1,569 @@ +//! 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; +use crate::tally::ExposureDirection; + +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; +const BODY_LINES_PER_PAGE: usize = LINES_PER_PAGE - 1; + +#[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 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")] + 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(BODY_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(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 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(); + 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 { + HEADING_FONT_SIZE + } else { + BODY_FONT_SIZE + }; + 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]) + .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 | Direction | Amount | Age (days) | Bucket", + true, + )?; + 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, + display_date(&bill.bill_date)?, + display_date(&bill.due_date)?, + bill_direction_label(bill.kind)?, + amount, + age, + bucket, + ); + push_wrapped(&mut lines, &row, false)?; + } + + push_label_value( + &mut lines, + "Total bill magnitudes (not net)", + &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, + &format!( + "Unallocated {} (no bill reference)", + exposure_direction_label(direction) + ), + &display_amount(&statement.unallocated)?, + )?; + push_label_value( + &mut lines, + "Grand total", + &display_amount(&statement.grand_total)?, + )?; + } + Ok(lines) +} + +fn bill_direction_label(kind: &str) -> Result<&'static str, PartyStatementPdfError> { + match kind { + "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, + 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::{ExposureDirection, 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 { + 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: Some(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) + } + + 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( + "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 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"; + 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 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)]; + 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", + "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_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")); + } + + #[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}"; + 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" + )); + } +} diff --git a/src-tauri/src/reports/party_statement_xlsx.rs b/src-tauri/src/reports/party_statement_xlsx.rs index bd7821e..052e2b3 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(_)) @@ -361,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( 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; }