From 66919a402e51a0e7e139093bced426df1e2dba03 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sun, 23 Aug 2026 15:53:58 +0530 Subject: [PATCH 01/10] fix(reports): type party statement bill direction (#164) --- src-tauri/src/commands.rs | 94 ++++-------- src-tauri/src/reports/bulk_party_statement.rs | 9 +- src-tauri/src/reports/party_statement.rs | 143 ++++++++++++++---- src-tauri/src/reports/party_statement_pdf.rs | 16 +- src-tauri/src/reports/party_statement_xlsx.rs | 23 ++- src-tauri/src/tally/runtime.rs | 29 ++-- 6 files changed, 183 insertions(+), 131 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 6548cb91..e2f1638d 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -2961,46 +2961,6 @@ pub async fn reveal_exported_file(path: String) -> Result<(), String> { .map_err(|error| format!("Bridge could not open the folder: {error}")) } -/// Wire counterpart of [`OpenBillRow`] for this command's argument only. -/// -/// `OpenBillRow::kind` is `&'static str`, which makes `OpenBillRow` itself -/// unable to derive `Deserialize` -- embedding it in another struct's derive -/// would need a `'de: 'static` bound the Tauri IPC deserializer (borrowing -/// from a short-lived request buffer) cannot satisfy. This struct carries -/// `kind` as a plain `String` instead and is validated into an `OpenBillRow` -/// by `into_open_bill_row` below. -#[derive(Debug, Deserialize)] -pub struct OpenBillRowInput { - pub party: String, - pub reference: String, - pub bill_date: String, - pub due_date: String, - pub amount: bridge_tally_core::ExactDecimal, - pub age_days: Option, - pub kind: String, -} - -fn into_open_bill_row(input: OpenBillRowInput) -> Result { - let kind: &'static str = match input.kind.as_str() { - "receivable" => "receivable", - "payable" => "payable", - other => { - return Err(format!( - "Bridge received an unrecognised bill kind ({other}) and could not build the statement." - )) - } - }; - Ok(OpenBillRow { - party: input.party, - reference: input.reference, - bill_date: input.bill_date, - due_date: input.due_date, - amount: input.amount, - age_days: input.age_days, - kind, - }) -} - #[derive(Debug, Deserialize)] pub struct ExportPartyStatementRequest { pub company: String, @@ -3013,7 +2973,7 @@ pub struct ExportPartyStatementRequest { /// holds from `fetch_tally_outstandings`. This command reads no Tally /// endpoint of its own -- `OutstandingsLoadResult::Complete` already /// carries every fact a statement needs. - pub open_bills: Vec, + pub open_bills: Vec, pub unallocated_by_party: Vec, } @@ -3035,7 +2995,7 @@ pub struct ExportBulkPartyStatementsRequest { pub destination: String, /// These are complete statement-source rows from the finished local read, /// not the dashboard's display projections. - pub open_bills: Vec, + pub open_bills: Vec, pub unallocated_by_party: Vec, } @@ -3043,7 +3003,7 @@ pub struct ExportBulkPartyStatementsRequest { pub struct PreviewBulkPartyStatementsRequest { /// The same complete rows the export command will consume. This command /// performs no I/O or Tally read; it only makes the pending scope explicit. - pub open_bills: Vec, + pub open_bills: Vec, pub unallocated_by_party: Vec, } @@ -3090,13 +3050,11 @@ fn require_utf8_destination(path: std::path::PathBuf) -> Result pub async fn preview_bulk_party_statements( request: PreviewBulkPartyStatementsRequest, ) -> Result { - let open_bills = request - .open_bills - .into_iter() - .map(into_open_bill_row) - .collect::, _>>()?; Ok(BulkPartyStatementsPreview { - party_count: bulk_party_statement_party_count(&open_bills, &request.unallocated_by_party), + party_count: bulk_party_statement_party_count( + &request.open_bills, + &request.unallocated_by_party, + ), }) } @@ -3108,11 +3066,6 @@ pub async fn preview_bulk_party_statements( pub async fn export_bulk_party_statements( request: ExportBulkPartyStatementsRequest, ) -> Result { - let open_bills = request - .open_bills - .into_iter() - .map(into_open_bill_row) - .collect::, _>>()?; let destination = std::path::PathBuf::from(request.destination); match request.format { @@ -3121,7 +3074,7 @@ pub async fn export_bulk_party_statements( &request.company, &request.as_of_yyyymmdd, "xlsx", - &open_bills, + &request.open_bills, &request.unallocated_by_party, |statement| render_party_statement_xlsx(statement).map_err(|error| error.to_string()), ), @@ -3130,7 +3083,7 @@ pub async fn export_bulk_party_statements( &request.company, &request.as_of_yyyymmdd, "pdf", - &open_bills, + &request.open_bills, &request.unallocated_by_party, |statement| render_party_statement_pdf(statement).map_err(|error| error.to_string()), ), @@ -3150,17 +3103,11 @@ pub async fn export_party_statement( ) -> Result { use tauri::Manager as _; - let open_bills = request - .open_bills - .into_iter() - .map(into_open_bill_row) - .collect::, _>>()?; - let statement = build_party_statement( &request.company, &request.as_of_yyyymmdd, &request.party, - &open_bills, + &request.open_bills, &request.unallocated_by_party, ) .map_err(|error| match error { @@ -3328,6 +3275,27 @@ mod party_statement_export_tests { assert!(matches!(pdf.format, PartyStatementFormat::Pdf)); } + #[test] + fn statement_export_rejects_unknown_bill_direction_at_the_ipc_boundary() { + let request = serde_json::json!({ + "company": "Synthetic Books Pvt Ltd", + "as_of_yyyymmdd": "20260808", + "party": "Synthetic Party", + "open_bills": [{ + "party": "Synthetic Party", + "reference": "INV-1", + "bill_date": "20260801", + "due_date": "20260831", + "amount": "100.00", + "age_days": 7, + "kind": "unknown" + }], + "unallocated_by_party": [] + }); + + assert!(serde_json::from_value::(request).is_err()); + } + #[test] fn local_export_file_names_reject_path_like_and_hidden_values() { assert_eq!( diff --git a/src-tauri/src/reports/bulk_party_statement.rs b/src-tauri/src/reports/bulk_party_statement.rs index 6457cf2e..9af46b6b 100644 --- a/src-tauri/src/reports/bulk_party_statement.rs +++ b/src-tauri/src/reports/bulk_party_statement.rs @@ -177,9 +177,8 @@ fn statement_directional_totals(statement: &PartyStatement) -> Result<(String, S let mut payable = bridge_tally_core::ExactDecimal::zero(); for bill in &statement.bills { let total = match bill.kind { - "receivable" => &mut receivable, - "payable" => &mut payable, - _ => return Err("Bridge found an unknown statement direction.".to_string()), + ExposureDirection::Receivable => &mut receivable, + ExposureDirection::Payable => &mut payable, }; *total = total .checked_add(&bill.amount) @@ -326,7 +325,7 @@ mod tests { due_date: "20260201".to_string(), amount: ExactDecimal::parse(amount).expect("synthetic decimal"), age_days: Some(40), - kind: "receivable", + kind: ExposureDirection::Receivable, } } @@ -400,7 +399,7 @@ mod tests { fn manifest_totals_keep_receivable_and_payable_directions_separate() { let destination = tempfile::tempdir().expect("temporary destination"); let mut payable_bill = bill("Mixed Party", "4.00"); - payable_bill.kind = "payable"; + payable_bill.kind = ExposureDirection::Payable; let unallocated = [UnallocatedParty { party: "Mixed Party".to_string(), amount: ExactDecimal::parse("3.00").expect("synthetic decimal"), diff --git a/src-tauri/src/reports/party_statement.rs b/src-tauri/src/reports/party_statement.rs index f808e3b6..b78596fb 100644 --- a/src-tauri/src/reports/party_statement.rs +++ b/src-tauri/src/reports/party_statement.rs @@ -52,8 +52,8 @@ pub struct StatementBill { pub due_date: String, pub amount: ExactDecimal, pub age_days: Option, - /// `receivable` or `payable` -- see `OpenBillRow::kind`. - pub kind: &'static str, + /// Balance direction -- see `OpenBillRow::kind`. + pub kind: ExposureDirection, pub bucket: Option, } @@ -217,13 +217,8 @@ pub fn build_party_statement( .checked_add(&bill.amount) .map_err(|_| PartyStatementError::ArithmeticOverflow)?; let directional_subtotals = match bill.kind { - "receivable" => &mut subtotals.receivable, - "payable" => &mut subtotals.payable, - // The PDF/XLSX writers retain the existing explicit invalid-kind - // rejection before emitting a document. Keep this builder's - // public error surface stable rather than misreporting an - // unsupported direction as arithmetic overflow. - _ => &mut subtotals.receivable, + ExposureDirection::Receivable => &mut subtotals.receivable, + ExposureDirection::Payable => &mut subtotals.payable, }; let bucket_subtotal = match bill.bucket { Some(AgeingBucket::Days0To30) => &mut directional_subtotals.days_0_30, @@ -263,7 +258,7 @@ mod tests { reference: &str, amount: &str, age_days: Option, - kind: &'static str, + kind: ExposureDirection, ) -> OpenBillRow { OpenBillRow { party: party.to_string(), @@ -300,10 +295,34 @@ mod tests { #[test] fn builds_a_statement_sorted_oldest_first_and_filtered_to_the_party() { let bills = vec![ - bill("Aarav Textiles", "INV-3", "1000.00", Some(10), "receivable"), - bill("Aarav Textiles", "INV-1", "2500.50", Some(95), "receivable"), - bill("Aarav Textiles", "INV-2", "300.00", Some(45), "receivable"), - bill("Other Party", "INV-9", "999.00", Some(200), "receivable"), + bill( + "Aarav Textiles", + "INV-3", + "1000.00", + Some(10), + ExposureDirection::Receivable, + ), + bill( + "Aarav Textiles", + "INV-1", + "2500.50", + Some(95), + ExposureDirection::Receivable, + ), + bill( + "Aarav Textiles", + "INV-2", + "300.00", + Some(45), + ExposureDirection::Receivable, + ), + bill( + "Other Party", + "INV-9", + "999.00", + Some(200), + ExposureDirection::Receivable, + ), ]; let unallocated_rows = vec![unallocated("Aarav Textiles", "150.25")]; @@ -344,14 +363,62 @@ mod tests { #[test] fn aged_bucket_subtotals_sum_to_exactly_the_bill_total() { let bills = vec![ - bill("Party", "A", "10.10", Some(5), "receivable"), - bill("Party", "B", "20.20", Some(30), "receivable"), - bill("Party", "C", "30.30", Some(31), "receivable"), - bill("Party", "D", "40.40", Some(60), "receivable"), - bill("Party", "E", "50.50", Some(61), "receivable"), - bill("Party", "F", "60.60", Some(90), "receivable"), - bill("Party", "G", "70.70", Some(91), "receivable"), - bill("Party", "H", "80.80", Some(500), "receivable"), + bill( + "Party", + "A", + "10.10", + Some(5), + ExposureDirection::Receivable, + ), + bill( + "Party", + "B", + "20.20", + Some(30), + ExposureDirection::Receivable, + ), + bill( + "Party", + "C", + "30.30", + Some(31), + ExposureDirection::Receivable, + ), + bill( + "Party", + "D", + "40.40", + Some(60), + ExposureDirection::Receivable, + ), + bill( + "Party", + "E", + "50.50", + Some(61), + ExposureDirection::Receivable, + ), + bill( + "Party", + "F", + "60.60", + Some(90), + ExposureDirection::Receivable, + ), + bill( + "Party", + "G", + "70.70", + Some(91), + ExposureDirection::Receivable, + ), + bill( + "Party", + "H", + "80.80", + Some(500), + ExposureDirection::Receivable, + ), ]; let statement = build_party_statement("Lab Co", "20260808", "Party", &bills, &[]) .expect("party has exposure"); @@ -386,8 +453,20 @@ mod tests { #[test] fn aged_and_unaged_subtotals_reconcile_to_the_exact_bill_total() { let bills = vec![ - bill("Party", "AGED", "10.10", Some(5), "receivable"), - bill("Party", "UNAGED", "20.20", None, "receivable"), + bill( + "Party", + "AGED", + "10.10", + Some(5), + ExposureDirection::Receivable, + ), + bill( + "Party", + "UNAGED", + "20.20", + None, + ExposureDirection::Receivable, + ), ]; let statement = build_party_statement("Lab Co", "20260808", "Party", &bills, &[]) .expect("party has exposure"); @@ -418,7 +497,13 @@ mod tests { #[test] fn an_unknown_party_is_rejected_rather_than_producing_an_empty_statement() { - let bills = vec![bill("Known Party", "INV-1", "10.00", Some(5), "receivable")]; + let bills = vec![bill( + "Known Party", + "INV-1", + "10.00", + Some(5), + ExposureDirection::Receivable, + )]; let error = build_party_statement("Lab Co", "20260808", "Unknown Party", &bills, &[]).unwrap_err(); assert_eq!(error, PartyStatementError::PartyNotFound); @@ -429,7 +514,13 @@ mod tests { // `unallocated_by_party` already drops zero residuals upstream (see // `top_unallocated_parties`), but this guards the statement builder // itself against ever surfacing a zero as if it were real exposure. - let bills = vec![bill("Party", "INV-1", "10.00", Some(5), "receivable")]; + let bills = vec![bill( + "Party", + "INV-1", + "10.00", + Some(5), + ExposureDirection::Receivable, + )]; let unallocated_rows = vec![unallocated("Party", "0")]; let statement = build_party_statement("Lab Co", "20260808", "Party", &bills, &unallocated_rows) diff --git a/src-tauri/src/reports/party_statement_pdf.rs b/src-tauri/src/reports/party_statement_pdf.rs index c93277d7..7b85e4fd 100644 --- a/src-tauri/src/reports/party_statement_pdf.rs +++ b/src-tauri/src/reports/party_statement_pdf.rs @@ -344,7 +344,7 @@ fn statement_lines(statement: &PartyStatement) -> Result, PartyStat display_pdf_text("bill reference", &bill.reference), display_date(&bill.bill_date)?, display_date(&bill.due_date)?, - bill_direction_label(bill.kind)?, + bill_direction_label(bill.kind), amount, age, bucket, @@ -401,12 +401,8 @@ fn statement_lines(statement: &PartyStatement) -> Result, PartyStat 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 bill_direction_label(direction: ExposureDirection) -> &'static str { + exposure_direction_label(direction) } fn exposure_direction_label(direction: ExposureDirection) -> &'static str { @@ -614,7 +610,7 @@ mod tests { due_date: "20260201".to_string(), amount: ExactDecimal::parse(amount).unwrap(), age_days: Some(age_days), - kind: "receivable", + kind: ExposureDirection::Receivable, } } @@ -756,7 +752,7 @@ mod tests { #[test] fn renders_bill_direction_for_mixed_party_documents() { let mut payable = bill("BILL-1", "1250.75", 40); - payable.kind = "payable"; + payable.kind = ExposureDirection::Payable; let statement = build_party_statement( "Synthetic Books Pvt Ltd", "20260808", @@ -775,7 +771,7 @@ mod tests { #[test] fn mixed_direction_bucket_subtotals_are_explicit_in_both_formats() { let mut payable = bill("BILL-80", "80.00", 20); - payable.kind = "payable"; + payable.kind = ExposureDirection::Payable; let statement = build_party_statement( "Synthetic Books Pvt Ltd", "20260808", diff --git a/src-tauri/src/reports/party_statement_xlsx.rs b/src-tauri/src/reports/party_statement_xlsx.rs index 9d796520..d9cd671e 100644 --- a/src-tauri/src/reports/party_statement_xlsx.rs +++ b/src-tauri/src/reports/party_statement_xlsx.rs @@ -92,7 +92,7 @@ pub fn render_party_statement_xlsx( worksheet.write_string(row, 0, bill.reference.as_str())?; worksheet.write_datetime_with_format(row, 1, excel_date(&bill.bill_date)?, &date_format)?; worksheet.write_datetime_with_format(row, 2, excel_date(&bill.due_date)?, &date_format)?; - worksheet.write_string(row, 3, bill_direction_label(bill.kind)?)?; + worksheet.write_string(row, 3, bill_direction_label(bill.kind))?; worksheet.write_number_with_format( row, 4, @@ -223,12 +223,8 @@ fn amount_to_f64(text: &str) -> Result { Ok(value) } -fn bill_direction_label(kind: &str) -> Result<&'static str, PartyStatementXlsxError> { - match kind { - "receivable" => Ok("Receivable"), - "payable" => Ok("Payable"), - _ => Err(PartyStatementXlsxError::InvalidDirection(kind.to_string())), - } +fn bill_direction_label(direction: ExposureDirection) -> &'static str { + exposure_direction_label(direction) } fn exposure_direction_label(direction: ExposureDirection) -> &'static str { @@ -324,12 +320,11 @@ mod tests { #[test] fn bill_direction_labels_make_mixed_party_amounts_unambiguous() { - 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(_)) - )); + assert_eq!( + bill_direction_label(ExposureDirection::Receivable), + "Receivable" + ); + assert_eq!(bill_direction_label(ExposureDirection::Payable), "Payable"); } use super::*; use crate::reports::party_statement::build_party_statement; @@ -344,7 +339,7 @@ mod tests { due_date: "20260201".to_string(), amount: ExactDecimal::parse(amount).unwrap(), age_days: Some(age_days), - kind: "receivable", + kind: ExposureDirection::Receivable, } } diff --git a/src-tauri/src/tally/runtime.rs b/src-tauri/src/tally/runtime.rs index 3497560a..775fc5b2 100644 --- a/src-tauri/src/tally/runtime.rs +++ b/src-tauri/src/tally/runtime.rs @@ -193,8 +193,8 @@ fn all_open_bill_rows( ) -> Vec { let mut rows = receivable .iter() - .map(|row| (row, "receivable")) - .chain(payable.iter().map(|row| (row, "payable"))) + .map(|row| (row, ExposureDirection::Receivable)) + .chain(payable.iter().map(|row| (row, ExposureDirection::Payable))) .filter_map(|(row, kind)| { let amount = row.closing_balance.abs().ok()?; let age_days = if &row.due_date > as_of { @@ -262,7 +262,7 @@ fn all_unallocated_parties( ranked } -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct OpenBillRow { pub party: String, pub reference: String, @@ -270,16 +270,10 @@ pub struct OpenBillRow { pub due_date: String, pub amount: ExactDecimal, pub age_days: Option, - /// `receivable` for a debit-balance bill, `payable` for a credit one. - /// Named by balance direction because that is what Tally's two reports - /// actually scope by -- a supplier advance is a receivable bill. - /// - /// Not `Deserialize`: a `&'static str` field forces any struct that - /// embeds this one into a `'de: 'static` bound on its own derive, which - /// a Tauri command argument (deserialized from a short-lived JSON - /// buffer) cannot satisfy. `commands::OpenBillRowInput` is the - /// deserializable counterpart used at that boundary instead. - pub kind: &'static str, + /// Direction of the native report that returned this bill. A supplier + /// advance can still be receivable, so this is balance direction rather + /// than party role. + pub kind: ExposureDirection, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -288,6 +282,15 @@ pub enum ExposureDirection { Receivable, Payable, } + +impl ExposureDirection { + pub const fn label(self) -> &'static str { + match self { + Self::Receivable => "Receivable", + Self::Payable => "Payable", + } + } +} #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum OutstandingsAgeingAnchor { From 81b4380de112bd409882c609289407c9156c62c8 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sun, 23 Aug 2026 16:00:01 +0530 Subject: [PATCH 02/10] feat(outstandings): support due-date ageing (#114) --- .../src/outstandings/compute.rs | 35 +++++++- .../src/outstandings/mod.rs | 4 +- .../src/outstandings/model.rs | 14 ++++ .../src/outstandings/parser.rs | 29 +++++++ .../src/outstandings/wire.rs | 6 ++ .../tests/outstandings.rs | 79 ++++++++++++++++--- src-tauri/src/reports/party_statement_pdf.rs | 1 + src-tauri/src/reports/party_statement_xlsx.rs | 3 + src-tauri/src/tally/runtime.rs | 2 +- src/OutstandingsScreen.tsx | 3 + 10 files changed, 160 insertions(+), 16 deletions(-) diff --git a/src-tauri/crates/bridge-tally-protocol/src/outstandings/compute.rs b/src-tauri/crates/bridge-tally-protocol/src/outstandings/compute.rs index 7b29967a..5a28a291 100644 --- a/src-tauri/crates/bridge-tally-protocol/src/outstandings/compute.rs +++ b/src-tauri/crates/bridge-tally-protocol/src/outstandings/compute.rs @@ -3,7 +3,7 @@ use std::collections::BTreeMap; use bridge_tally_primitives::{ExactDecimal, TallyDate}; use super::{ - AgeingBillCounts, AgeingBuckets, BillReferenceKind, CompleteScan, MoneyValue, + AgeingAnchor, AgeingBillCounts, AgeingBuckets, BillReferenceKind, CompleteScan, MoneyValue, OutstandingsError, OutstandingsReport, PartyOutstanding, }; @@ -42,6 +42,17 @@ struct PartyTotals { pub fn compute_outstandings( scan: &CompleteScan, as_of: TallyDate, +) -> Result { + compute_outstandings_with_ageing_anchor(scan, as_of, AgeingAnchor::DueDate) +} + +/// Computes aged outstandings using the caller-selected bill or due-date +/// basis. The default entry point uses `DueDate`, which matches Tally's +/// native overdue report where credit periods exist. +pub fn compute_outstandings_with_ageing_anchor( + scan: &CompleteScan, + as_of: TallyDate, + ageing_anchor: AgeingAnchor, ) -> Result { if &as_of < scan.window().to() { return Err(OutstandingsError::InvalidDateWindow); @@ -79,7 +90,7 @@ pub fn compute_outstandings( Some(name) => ( BillKey::Named(name.to_string()), OpenBillKind::Named { - oldest_date: bill_age_date(allocation, voucher)?, + oldest_date: bill_age_date(allocation, voucher, ageing_anchor)?, }, ), None if matches!(allocation.bill_type, BillReferenceKind::OnAccount) => { @@ -110,7 +121,7 @@ pub fn compute_outstandings( .map_err(|_| OutstandingsError::ArithmeticOverflow)?; if previous_balance.is_zero() { if let OpenBillKind::Named { oldest_date } = &mut bill.kind { - *oldest_date = bill_age_date(allocation, voucher)?; + *oldest_date = bill_age_date(allocation, voucher, ageing_anchor)?; } } else if !next_balance.is_zero() && previous_balance.is_negative() != next_balance.is_negative() @@ -242,8 +253,9 @@ pub fn compute_outstandings( fn bill_age_date( allocation: &super::BillAllocation, voucher: &super::Voucher, + ageing_anchor: AgeingAnchor, ) -> Result { - match allocation.bill_type { + let bill_date = match allocation.bill_type { // TALLY_PROTOCOL_REFERENCE §12a.2 (PR #117): Tally reported a 1-Jun // bill settled to zero and re-opened by a 1-Jul Agst Ref as due on // 1-Jun, 60 days overdue; zero re-opens age from the original @@ -256,7 +268,21 @@ fn bill_age_date( BillReferenceKind::OnAccount => Err(OutstandingsError::InvalidResponse( "bill_reference_forbidden", )), + }?; + match ageing_anchor { + AgeingAnchor::BillDate => Ok(bill_date), + AgeingAnchor::DueDate => add_days(&bill_date, allocation.credit_period_days), + } +} + +fn add_days(date: &TallyDate, days: u32) -> Result { + let mut due_date = date.clone(); + for _ in 0..days { + due_date = due_date + .next_day() + .map_err(|_| OutstandingsError::InvalidDateWindow)?; } + Ok(due_date) } fn exact(value: &MoneyValue) -> Result<&ExactDecimal, OutstandingsError> { @@ -545,6 +571,7 @@ mod tests { _ => panic!("synthetic test must use a known kind"), }, amount: MoneyValue::Exact(amount), + credit_period_days: 0, }], }], } diff --git a/src-tauri/crates/bridge-tally-protocol/src/outstandings/mod.rs b/src-tauri/crates/bridge-tally-protocol/src/outstandings/mod.rs index da72d903..2985c0fc 100644 --- a/src-tauri/crates/bridge-tally-protocol/src/outstandings/mod.rs +++ b/src-tauri/crates/bridge-tally-protocol/src/outstandings/mod.rs @@ -14,9 +14,9 @@ pub use completeness::{ verify_segment_pair_with_encoded_bytes, verify_segment_pair_with_wire_evidence, SegmentWireEvidence, }; -pub use compute::compute_outstandings; +pub use compute::{compute_outstandings, compute_outstandings_with_ageing_anchor}; pub use model::{ - AlterIdRange, BillAllocation, BillReferenceKind, CompanyBookExtent, CompleteScan, + AgeingAnchor, AlterIdRange, BillAllocation, BillReferenceKind, CompanyBookExtent, CompleteScan, CompleteSegment, CompleteWitnessPair, CorroboratedDatePartition, DateBoundaryProfile, DateWindow, EmptyDateWindowVerification, EmptyDateWindowWitness, EmptyPartitionControlProvenance, EmptyPartitionWitness, LedgerEntry, LedgerOpeningCoverage, diff --git a/src-tauri/crates/bridge-tally-protocol/src/outstandings/model.rs b/src-tauri/crates/bridge-tally-protocol/src/outstandings/model.rs index 730ddbbc..dfc49344 100644 --- a/src-tauri/crates/bridge-tally-protocol/src/outstandings/model.rs +++ b/src-tauri/crates/bridge-tally-protocol/src/outstandings/model.rs @@ -385,6 +385,16 @@ pub enum BillReferenceKind { OnAccount, } +/// The explicit date basis used to place named bills into ageing buckets. +/// +/// Tally offers both bases. Neither can be inferred from the response path: +/// callers must select one and disclose it with the resulting report. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AgeingAnchor { + BillDate, + DueDate, +} + impl BillReferenceKind { pub(crate) fn parse(value: &str) -> Result { match value.trim() { @@ -412,6 +422,10 @@ pub struct BillAllocation { /// a bill's date can differ from the date of the voucher that opened it, /// and using the voucher date then puts the balance in the wrong bucket. pub bill_date: Option, + /// Number of calendar days Tally adds to the bill date for due-date + /// ageing. The parser accepts only the measured `N Days` wire grammar; + /// a missing or malformed value cannot silently become a different basis. + pub credit_period_days: u32, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/src-tauri/crates/bridge-tally-protocol/src/outstandings/parser.rs b/src-tauri/crates/bridge-tally-protocol/src/outstandings/parser.rs index 5c4cfd0f..85a36fd4 100644 --- a/src-tauri/crates/bridge-tally-protocol/src/outstandings/parser.rs +++ b/src-tauri/crates/bridge-tally-protocol/src/outstandings/parser.rs @@ -393,6 +393,15 @@ fn convert_bill_allocation( } _ => None, }; + let credit_period_days = match raw.bill_credit_period { + Some(value) => parse_credit_period_days(&value.text)?, + None if bill_type.requires_named_reference() => { + return Err(OutstandingsError::InvalidResponse( + "bill_credit_period_missing", + )) + } + None => 0, + }; Ok(Some(BillAllocation { name, bill_type, @@ -402,9 +411,29 @@ fn convert_bill_allocation( .text, )?, bill_date, + credit_period_days, })) } +fn parse_credit_period_days(value: &str) -> Result { + let value = value.trim(); + if value.is_empty() { + return Ok(0); + } + let Some(days) = value.strip_suffix(" Days") else { + return Err(OutstandingsError::InvalidResponse( + "bill_credit_period_invalid", + )); + }; + if days.is_empty() || !days.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(OutstandingsError::InvalidResponse( + "bill_credit_period_invalid", + )); + } + days.parse::() + .map_err(|_| OutstandingsError::InvalidResponse("bill_credit_period_invalid")) +} + fn parse_money(value: String) -> Result { let value = value.trim(); if value.is_empty() { diff --git a/src-tauri/crates/bridge-tally-protocol/src/outstandings/wire.rs b/src-tauri/crates/bridge-tally-protocol/src/outstandings/wire.rs index f003c466..29b254e0 100644 --- a/src-tauri/crates/bridge-tally-protocol/src/outstandings/wire.rs +++ b/src-tauri/crates/bridge-tally-protocol/src/outstandings/wire.rs @@ -84,6 +84,10 @@ pub(super) struct RawLedgerEntry { #[derive(Deserialize)] pub(super) struct RawBillAllocation { + // BILLID and BILLCREATIONDATE are deliberately not modeled: the scan's + // bill identity is the Tally ledger plus NAME, and neither field changes + // ageing or reconciliation. Carrying unused identifiers would create an + // alternate, unverified key without improving either contract. #[serde(rename = "NAME", default)] pub(super) name: Option, #[serde(rename = "BILLTYPE", default)] @@ -94,4 +98,6 @@ pub(super) struct RawBillAllocation { /// the enclosing voucher's date. The wildcard fetch already returns it. #[serde(rename = "BILLDATE", default)] pub(super) bill_date: Option, + #[serde(rename = "BILLCREDITPERIOD", default)] + pub(super) bill_credit_period: Option, } diff --git a/src-tauri/crates/bridge-tally-protocol/tests/outstandings.rs b/src-tauri/crates/bridge-tally-protocol/tests/outstandings.rs index 430c5dc6..d381df21 100644 --- a/src-tauri/crates/bridge-tally-protocol/tests/outstandings.rs +++ b/src-tauri/crates/bridge-tally-protocol/tests/outstandings.rs @@ -958,10 +958,10 @@ fn report_for_named_references_as_of( "
11
", "{COMPANY_GUID}-0000000111", "20260401SalesNoNoNo", - "Customer{first}New Ref20260401-100", + "Customer{first}New Ref20260401-100", "{COMPANY_GUID}-0000000222", "20260401SalesNoNoNo", - "Customer{second}New Ref20260401-100", + "Customer{second}New Ref20260401-100", "
" ), COMPANY_GUID = COMPANY_GUID, @@ -1026,7 +1026,7 @@ fn bill_vouchers_xml(vouchers: &[(u64, &str, &str, &str, i64)]) -> String { concat!( "{COMPANY_GUID}-{alter_id:08}{alter_id}{alter_id}", "{date}SalesNoNoNo", - "Customer{reference}{bill_type}{date}{amount}" + "Customer{reference}{bill_type}{date}{amount}" ), COMPANY_GUID = COMPANY_GUID, alter_id = alter_id, @@ -1039,6 +1039,67 @@ fn bill_vouchers_xml(vouchers: &[(u64, &str, &str, &str, i64)]) -> String { format!("
11
{vouchers}
") } +fn ageing_vouchers_xml(vouchers: &[(&str, &str, u32)]) -> String { + let vouchers = vouchers + .iter() + .enumerate() + .map(|(index, (reference, date, credit_period_days))| { + let alter_id = index + 1; + format!( + concat!( + "{COMPANY_GUID}-{alter_id:08}{alter_id}{alter_id}", + "{date}SalesNoNoNo", + "Ageing Customer{reference}New Ref{date}{credit_period_days} Days-1" + ), + COMPANY_GUID = COMPANY_GUID, + alter_id = alter_id, + date = date, + reference = reference, + credit_period_days = credit_period_days, + ) + }) + .collect::(); + format!("
11
{vouchers}
") +} + +#[test] +fn ageing_corpus_moves_seven_of_eight_bills_between_bill_and_due_date_buckets() { + use bridge_tally_protocol::outstandings::{ + compute_outstandings_with_ageing_anchor, AgeingAnchor, + }; + + let xml = ageing_vouchers_xml(&[ + ("AGE-INV-07", "20251201", 90), + ("AGE-INV-06", "20251226", 60), + ("AGE-INV-05", "20260105", 45), + ("AGE-INV-04", "20260120", 30), + ("AGE-INV-03", "20260214", 30), + ("AGE-INV-02", "20260224", 15), + ("AGE-INV-08", "20251221", 15), + ("AGE-INV-01", "20260306", 0), + ]); + let window = + DateWindow::parse(DateBoundaryProfile::ModeAgnostic, "20250401", "20260331").unwrap(); + let scan = complete_scan_for_vouchers(&xml, window, 8); + let as_of = TallyDate::parse("20260331").unwrap(); + + let bill_date = + compute_outstandings_with_ageing_anchor(&scan, as_of.clone(), AgeingAnchor::BillDate) + .expect("bill-date ageing computes"); + let due_date = compute_outstandings_with_ageing_anchor(&scan, as_of, AgeingAnchor::DueDate) + .expect("due-date ageing computes"); + + assert_eq!(bill_date.ageing_bill_counts.days_0_30, 1); + assert_eq!(bill_date.ageing_bill_counts.days_31_60, 2); + assert_eq!(bill_date.ageing_bill_counts.days_61_90, 2); + assert_eq!(bill_date.ageing_bill_counts.days_90_plus, 3); + assert_eq!(due_date.ageing_bill_counts.days_0_30, 4); + assert_eq!(due_date.ageing_bill_counts.days_31_60, 3); + assert_eq!(due_date.ageing_bill_counts.days_61_90, 1); + assert_eq!(due_date.ageing_bill_counts.days_90_plus, 0); + assert_ne!(bill_date.ageing, due_date.ageing); +} + fn complete_scan_for_vouchers( xml: &str, window: DateWindow, @@ -1075,13 +1136,13 @@ fn a_bill_literally_named_on_account_does_not_merge_with_the_aggregate() { "20260401Sales1", "TrackedNoNoNo", "Tracked", - "On AccountNew Ref20260401-100.00", + "On AccountNew Ref20260401-100.00", "", "{guid}-0000000222", "20260401Sales2", "TrackedNoNoNo", "Tracked", - "On Account20260401-50.00", + "On Account20260401-50.00", "", "" ), @@ -1169,7 +1230,7 @@ fn ageing_runs_from_tallys_bill_date_not_the_voucher_date() { "NoNoNo", "Aged Customer", "AGED-1New Ref", - "20260101-5000.00", + "20260101-5000.00", "", "" ), @@ -1287,7 +1348,7 @@ fn named_on_account_fails_closed_at_the_parser_boundary() { fn against_ref_reopened_after_zero_balance_ages_from_original_bill_date() { let voucher = |guid_suffix: u8, date: &str, bill_type: &str, amount: &str| { format!( - "{company_guid}-0000000{guid_suffix}{guid_suffix}{guid_suffix}{date}ReceiptCustomerNoNoNoCustomerREF-1{bill_type}20260601{amount}", + "{company_guid}-0000000{guid_suffix}{guid_suffix}{guid_suffix}{date}ReceiptCustomerNoNoNoCustomerREF-1{bill_type}20260601{amount}", company_guid = COMPANY_GUID ) }; @@ -1336,7 +1397,7 @@ fn against_ref_reopened_after_zero_balance_ages_from_original_bill_date() { fn against_ref_sign_flip_ages_from_voucher_date() { let voucher = |guid_suffix: u8, date: &str, bill_type: &str, amount: &str| { format!( - "{company_guid}-0000000{guid_suffix}{guid_suffix}{guid_suffix}{date}ReceiptCustomerNoNoNoCustomerREF-1{bill_type}20260601{amount}", + "{company_guid}-0000000{guid_suffix}{guid_suffix}{guid_suffix}{date}ReceiptCustomerNoNoNoCustomerREF-1{bill_type}20260601{amount}", company_guid = COMPANY_GUID ) }; @@ -1449,7 +1510,7 @@ fn advance_scan(bill_date: Option<&str>) -> ScanResult { "20260415Receipt", "CustomerNoNoNo", "Customer", - "ADV-1Advance{bill_date}25", + "ADV-1Advance{bill_date}25", "" ), guid = COMPANY_GUID, diff --git a/src-tauri/src/reports/party_statement_pdf.rs b/src-tauri/src/reports/party_statement_pdf.rs index 7b85e4fd..d03ff386 100644 --- a/src-tauri/src/reports/party_statement_pdf.rs +++ b/src-tauri/src/reports/party_statement_pdf.rs @@ -316,6 +316,7 @@ fn statement_lines(statement: &PartyStatement) -> Result, PartyStat "As of", &display_date(&statement.as_of_yyyymmdd)?, )?; + push_label_value(&mut lines, "Ageing basis", "Due date")?; lines.push(PdfLine::body("")); if !statement.unallocated.is_zero() { diff --git a/src-tauri/src/reports/party_statement_xlsx.rs b/src-tauri/src/reports/party_statement_xlsx.rs index d9cd671e..37ab6337 100644 --- a/src-tauri/src/reports/party_statement_xlsx.rs +++ b/src-tauri/src/reports/party_statement_xlsx.rs @@ -59,6 +59,9 @@ pub fn render_party_statement_xlsx( &date_format, )?; row += 1; + worksheet.write_string(row, 0, "Ageing basis")?; + worksheet.write_string(row, 1, "Due date")?; + row += 1; let has_unallocated = !statement.unallocated.is_zero(); if has_unallocated { diff --git a/src-tauri/src/tally/runtime.rs b/src-tauri/src/tally/runtime.rs index 775fc5b2..cebc1666 100644 --- a/src-tauri/src/tally/runtime.rs +++ b/src-tauri/src/tally/runtime.rs @@ -1909,7 +1909,7 @@ impl TallyRuntime { ScanResult::Complete(scan) => Ok(OutstandingsLoadResult::Complete { report: Box::new(compute_outstandings(&scan, as_of)?), currency_assertion, - ageing_anchor: OutstandingsAgeingAnchor::BillDate, + ageing_anchor: OutstandingsAgeingAnchor::DueDate, synced_at_unix_ms: chrono::Utc::now().timestamp_millis(), // The voucher scan derives bills from vouchers // and cannot establish the unallocated diff --git a/src/OutstandingsScreen.tsx b/src/OutstandingsScreen.tsx index 04529dd7..0c5f71dc 100644 --- a/src/OutstandingsScreen.tsx +++ b/src/OutstandingsScreen.tsx @@ -761,6 +761,7 @@ async function previewBulkPartyStatements(result: InrCompleteResult) { async function exportCsv(result: InrCompleteResult) { const csv = reportToCsv( result.report, + result.ageing_anchor, result.unallocated_total, result.statement_unallocated_by_party, ); @@ -775,6 +776,7 @@ async function exportCsv(result: InrCompleteResult) { function reportToCsv( report: Report, + ageingAnchor: OutstandingsAgeingAnchor, unallocatedTotal: string | undefined, unallocatedByParty: Array<{ party: string; amount: string }> | undefined, ) { @@ -787,6 +789,7 @@ function reportToCsv( row(text("Company"), text(report.company_name)), row(text("As of"), text(formatDate(report.as_of_yyyymmdd))), row(text("Currency"), text("INR")), + row(text("Ageing basis"), text(outstandingsAgeingAnchorLabel(ageingAnchor))), "", row(text("Measure"), text("Amount")), row(text("Receivable"), number(report.receivable_total)), From e530418356a513ed15ac26d761ff319c279fd18b Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sun, 23 Aug 2026 16:02:24 +0530 Subject: [PATCH 03/10] chore(compat): reseal N16 report surfaces --- docs/tally/compatibility/compatibility-matrix.json | 2 +- .../tally/compatibility/compatibility-surface.json | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 99e397dd..0c9687fa 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": "c26058d0396d7fe63d18e43e0d15f49ef5e10019f1088513c7cd3ee4d3977933", + "compatibility_surface_sha256": "372c7fc76cbf278b5cd4516a11306c04c76af979525ae7fdd067c96123f3fe2c", "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 f37a0901..e2835f3f 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -171,11 +171,11 @@ }, { "path": "src-tauri/crates/bridge-tally-protocol/src/outstandings/model.rs", - "sha256": "a62437fb389b3c2908bdbbc70bbd1bc50dadca232257484bda0fcf7dc9bed6d7" + "sha256": "4521b8573c1f639dca0ba63d28c8bfeb69a9dab9f4bafb2c7565d7ea0f5f0566" }, { "path": "src-tauri/crates/bridge-tally-protocol/src/outstandings/parser.rs", - "sha256": "be3c2e63a531b63a39c8fc2a072b6c0cd3f3327bcda6268b37a224a7ea163b3c" + "sha256": "8dc5761e7eb3ee2180cd0ebb5daf5bdb62978da3ecbaa1bdf58d06d94fc233d2" }, { "path": "src-tauri/crates/bridge-tally-protocol/src/outstandings/request.rs", @@ -183,7 +183,7 @@ }, { "path": "src-tauri/crates/bridge-tally-protocol/src/outstandings/wire.rs", - "sha256": "c181efb3ac013543a40fe78a6e1fb900021ca25c443d034cda49f2639f8f9006" + "sha256": "bfefafd5e7c6e106ca1fccd42240f68a5d6c18762fc3d74eb0e725baf525683c" }, { "path": "src-tauri/crates/bridge-tally-protocol/src/outstandings_shared.rs", @@ -247,7 +247,7 @@ }, { "path": "src-tauri/src/commands.rs", - "sha256": "90c7ba9036384bfb8bffb82e7f87b59c2cbf7f44a92f9216a720a0ab2fd8f44d" + "sha256": "76208e443b2ffce53b7888f6869c2349fbcc492d8737ec80cf06cf86ddde8679" }, { "path": "src-tauri/src/db/encrypted.rs", @@ -371,7 +371,7 @@ }, { "path": "src-tauri/src/tally/runtime.rs", - "sha256": "1cfbbbe99f301736f74f02f5c154c879f35437b45f1b870993562a5154909880" + "sha256": "f294d12253d3e92f96b0ab6db14cb7108c8efe9da58cd6cd0c1260ad7303de03" }, { "path": "src-tauri/src/tally/serial_queue.rs", @@ -399,7 +399,7 @@ }, { "path": "src/OutstandingsScreen.tsx", - "sha256": "a0f470b148be06af61dfac233989d482e4751589512a6c1f7047d01ccc91e305" + "sha256": "444b363f385e433b6f3d64ca870b6d7d54ef1b8915e8847a23400b5bc6875dae" }, { "path": "src/TallyReadinessFlow.tsx", @@ -478,5 +478,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "c26058d0396d7fe63d18e43e0d15f49ef5e10019f1088513c7cd3ee4d3977933" + "manifest_sha256": "372c7fc76cbf278b5cd4516a11306c04c76af979525ae7fdd067c96123f3fe2c" } From 4b5eade6accb418416899fb9aeb185240747e02a Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sun, 23 Aug 2026 17:02:21 +0530 Subject: [PATCH 04/10] fix(outstandings): close N16b ageing findings --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 22 ++--- scripts/outstandings-as-of.test.mjs | 9 +- .../crates/bridge-tally-primitives/src/lib.rs | 54 ++++++++++ .../src/outstandings/compute.rs | 65 ++++++++++-- .../src/outstandings/mod.rs | 4 +- .../src/outstandings/model.rs | 15 ++- .../src/outstandings/parser.rs | 62 +++++++++--- src-tauri/src/commands.rs | 27 ++++- src-tauri/src/reports/bulk_party_statement.rs | 30 +++++- src-tauri/src/reports/party_statement.rs | 27 ++++- src-tauri/src/reports/party_statement_pdf.rs | 27 ++++- src-tauri/src/reports/party_statement_xlsx.rs | 2 +- src-tauri/src/tally/mod.rs | 2 +- src-tauri/src/tally/runtime.rs | 99 +++++++++++++++---- src-tauri/tests/unit_a_live.rs | 4 +- src/AllClientsScreen.tsx | 31 +++++- src/OutstandingsScreen.tsx | 33 ++++++- src/outstandings-as-of.ts | 9 ++ src/styles.css | 6 +- 20 files changed, 449 insertions(+), 81 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 0c9687fa..a6a0ed48 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": "372c7fc76cbf278b5cd4516a11306c04c76af979525ae7fdd067c96123f3fe2c", + "compatibility_surface_sha256": "2466876e6fcf9a8e5d32547dfa9f0f2f726892b6a3e81ce67f6736a680f65fc2", "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 e2835f3f..db76e49b 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -135,7 +135,7 @@ }, { "path": "src-tauri/crates/bridge-tally-primitives/src/lib.rs", - "sha256": "48469d58b3a169d4cf34b80cfc28c491bb8d930cccbba847736520b72b78c130" + "sha256": "1c4b32a1c1cce2470633217e2d45960d41197884447c44b6b968a9bf29f36f1d" }, { "path": "src-tauri/crates/bridge-tally-protocol/Cargo.toml", @@ -171,11 +171,11 @@ }, { "path": "src-tauri/crates/bridge-tally-protocol/src/outstandings/model.rs", - "sha256": "4521b8573c1f639dca0ba63d28c8bfeb69a9dab9f4bafb2c7565d7ea0f5f0566" + "sha256": "308f1694ce35587c31f85c0f55bec78f7d59d818d62da6302213b1e80c9ecc2f" }, { "path": "src-tauri/crates/bridge-tally-protocol/src/outstandings/parser.rs", - "sha256": "8dc5761e7eb3ee2180cd0ebb5daf5bdb62978da3ecbaa1bdf58d06d94fc233d2" + "sha256": "dc21ba2f2c311c5b4c07e1544d571dedff625a63b183590b72118e305961833b" }, { "path": "src-tauri/crates/bridge-tally-protocol/src/outstandings/request.rs", @@ -247,7 +247,7 @@ }, { "path": "src-tauri/src/commands.rs", - "sha256": "76208e443b2ffce53b7888f6869c2349fbcc492d8737ec80cf06cf86ddde8679" + "sha256": "5d0f4da7f34f49f522fa35bfefe1d39b7fe32fecce916a68f0861dbfd735cef5" }, { "path": "src-tauri/src/db/encrypted.rs", @@ -363,7 +363,7 @@ }, { "path": "src-tauri/src/tally/mod.rs", - "sha256": "e89a2f07121984cc4d5975bbaafc40865ea95fb0ee0a98c3f72ed3773e26f4ca" + "sha256": "8c98424344229cc81dd75e4f77977c8cf754a9adac69308566b28bcce8d30dc2" }, { "path": "src-tauri/src/tally/outstandings_runtime.rs", @@ -371,7 +371,7 @@ }, { "path": "src-tauri/src/tally/runtime.rs", - "sha256": "f294d12253d3e92f96b0ab6db14cb7108c8efe9da58cd6cd0c1260ad7303de03" + "sha256": "760394d90156acb720a2a377959a8c356d5fc4061612b9e4d2d772ce374631a2" }, { "path": "src-tauri/src/tally/serial_queue.rs", @@ -395,11 +395,11 @@ }, { "path": "src/AllClientsScreen.tsx", - "sha256": "96d5fb74131379ea808d6fc66827a3fd3191b6ba7e0a67bcb91c08c187bbbf72" + "sha256": "f4cd60e3e777c71daa5eff330574a1988606f012da92133fd4b4f5ed8a27d8b7" }, { "path": "src/OutstandingsScreen.tsx", - "sha256": "444b363f385e433b6f3d64ca870b6d7d54ef1b8915e8847a23400b5bc6875dae" + "sha256": "d743d03cc9eac7223a1d8dae807463aaf6505c2244c2e09d8680b8db6ef300f9" }, { "path": "src/TallyReadinessFlow.tsx", @@ -411,7 +411,7 @@ }, { "path": "src/outstandings-as-of.ts", - "sha256": "56d6452ad228b0e1177c50df4a133263b61bb545850769fb84fde41e5da09eb0" + "sha256": "d584d1d41414b5a7a2a31efb5d5cd7fd7db35e81415b1aaef82d7f111a1e8cf0" }, { "path": "src/outstandings-copy.ts", @@ -423,7 +423,7 @@ }, { "path": "src/styles.css", - "sha256": "b102be4dcdcbb197394e7cb85bbb3843f87861674c524dbece34a29e97d7843e" + "sha256": "eb68d453a222ba7901eccd1a317f6e2a16b1d4e172ecfa937d50ff51980d3020" }, { "path": "src/tally-company-selection.ts", @@ -478,5 +478,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "372c7fc76cbf278b5cd4516a11306c04c76af979525ae7fdd067c96123f3fe2c" + "manifest_sha256": "2466876e6fcf9a8e5d32547dfa9f0f2f726892b6a3e81ce67f6736a680f65fc2" } diff --git a/scripts/outstandings-as-of.test.mjs b/scripts/outstandings-as-of.test.mjs index 512ebce0..78afa093 100644 --- a/scripts/outstandings-as-of.test.mjs +++ b/scripts/outstandings-as-of.test.mjs @@ -119,6 +119,7 @@ test("the single-company request emits the selected canonical as-of date", () => { host: "127.0.0.1", port: 9000 }, { name: "Bridge Validation Lab", guid: "guid-1" }, "2026-08-17", + "bill_date", ), { request: { @@ -127,10 +128,11 @@ test("the single-company request emits the selected canonical as-of date", () => expected_company_guid: "guid-1", currency_assertion: "INR", as_of_yyyymmdd: "20260817", + ageing_anchor: "bill_date", }, }, ); - assert.equal(singleCompanyOutstandingsInvokeArgument({ host: "127.0.0.1", port: 9000 }, { name: "Lab", guid: "guid-1" }, "2026-8-17"), null); + assert.equal(singleCompanyOutstandingsInvokeArgument({ host: "127.0.0.1", port: 9000 }, { name: "Lab", guid: "guid-1" }, "2026-8-17", "due_date"), null); }); test("compare clients emits the same selected canonical as-of date", () => { @@ -142,6 +144,7 @@ test("compare clients emits the same selected canonical as-of date", () => { { name: "Bridge Ageing Lab", guid: "guid-2" }, ], "2026-08-17", + "bill_date", ), { request: { @@ -152,6 +155,7 @@ test("compare clients emits the same selected canonical as-of date", () => { ], currency_assertion: "INR", as_of_yyyymmdd: "20260817", + ageing_anchor: "bill_date", }, }, ); @@ -160,6 +164,7 @@ test("compare clients emits the same selected canonical as-of date", () => { test("Excel and PDF statement builders emit the report's actual as-of date", () => { const result = { report: { company_name: "Bridge Validation Lab", as_of_yyyymmdd: "20260801" }, + ageing_anchor: "bill_date", statement_open_bills: [{ party: "Alpha", amount: "1" }], statement_unallocated_by_party: [{ party: "Alpha", amount: "2" }], }; @@ -172,6 +177,7 @@ test("Excel and PDF statement builders emit the report's actual as-of date", () as_of_yyyymmdd: "20260801", party: "Alpha", format: "xlsx", + ageing_anchor: "bill_date", open_bills: [{ party: "Alpha", amount: "1" }], unallocated_by_party: [{ party: "Alpha", amount: "2" }], }, @@ -184,6 +190,7 @@ test("Excel and PDF statement builders emit the report's actual as-of date", () as_of_yyyymmdd: "20260801", destination: "/tmp/statements", format: "pdf", + ageing_anchor: "bill_date", open_bills: [{ party: "Alpha", amount: "1" }], unallocated_by_party: [{ party: "Alpha", amount: "2" }], }, diff --git a/src-tauri/crates/bridge-tally-primitives/src/lib.rs b/src-tauri/crates/bridge-tally-primitives/src/lib.rs index 05003ddd..53bdbfe7 100644 --- a/src-tauri/crates/bridge-tally-primitives/src/lib.rs +++ b/src-tauri/crates/bridge-tally-primitives/src/lib.rs @@ -190,6 +190,35 @@ impl TallyDate { "{previous_year:04}{previous_month:02}{previous_day:02}" )) } + + /// Adds calendar months, clamping a month-end source date to the last + /// valid day of the target month. Thus 31-Jan + 1 month is 28-Feb (or + /// 29-Feb in a leap year), rather than an implicit fixed-day conversion. + pub fn add_months_clamped(&self, months: u32) -> Result { + let year = self.0[0..4] + .parse::() + .map_err(|_| invalid_data("invalid_tally_date"))?; + let month = self.0[4..6] + .parse::() + .map_err(|_| invalid_data("invalid_tally_date"))?; + let day = self.0[6..8] + .parse::() + .map_err(|_| invalid_data("invalid_tally_date"))?; + let month_index = month + .checked_sub(1) + .and_then(|value| value.checked_add(months)) + .ok_or_else(|| invalid_data("tally_date_overflow"))?; + let target_year = year + .checked_add(month_index / 12) + .filter(|value| *value <= 9999) + .ok_or_else(|| invalid_data("tally_date_overflow"))?; + let target_month = month_index % 12 + 1; + let target_day = day.min( + gregorian_month_days(target_year, target_month) + .ok_or_else(|| invalid_data("invalid_tally_date"))?, + ); + Self::parse(format!("{target_year:04}{target_month:02}{target_day:02}")) + } } impl<'de> Deserialize<'de> for TallyDate { @@ -229,3 +258,28 @@ fn invalid_data(code: &'static str) -> TallyError { code: code.to_string(), } } + +#[cfg(test)] +mod tests { + use super::TallyDate; + + #[test] + fn adding_calendar_months_clamps_to_the_target_month_end() { + assert_eq!( + TallyDate::parse("20260131") + .unwrap() + .add_months_clamped(1) + .unwrap() + .as_str(), + "20260228" + ); + assert_eq!( + TallyDate::parse("20240131") + .unwrap() + .add_months_clamped(1) + .unwrap() + .as_str(), + "20240229" + ); + } +} diff --git a/src-tauri/crates/bridge-tally-protocol/src/outstandings/compute.rs b/src-tauri/crates/bridge-tally-protocol/src/outstandings/compute.rs index 5a28a291..516fbded 100644 --- a/src-tauri/crates/bridge-tally-protocol/src/outstandings/compute.rs +++ b/src-tauri/crates/bridge-tally-protocol/src/outstandings/compute.rs @@ -3,8 +3,8 @@ use std::collections::BTreeMap; use bridge_tally_primitives::{ExactDecimal, TallyDate}; use super::{ - AgeingAnchor, AgeingBillCounts, AgeingBuckets, BillReferenceKind, CompleteScan, MoneyValue, - OutstandingsError, OutstandingsReport, PartyOutstanding, + AgeingAnchor, AgeingBillCounts, AgeingBuckets, BillReferenceKind, CompleteScan, CreditPeriod, + MoneyValue, OutstandingsError, OutstandingsReport, PartyOutstanding, }; /// How a bill is identified within one ledger. @@ -271,7 +271,27 @@ fn bill_age_date( }?; match ageing_anchor { AgeingAnchor::BillDate => Ok(bill_date), - AgeingAnchor::DueDate => add_days(&bill_date, allocation.credit_period_days), + AgeingAnchor::DueDate => add_credit_period(&bill_date, &allocation.credit_period), + } +} + +fn add_credit_period( + date: &TallyDate, + period: &CreditPeriod, +) -> Result { + match period { + CreditPeriod::Days(days) => add_days(date, *days), + CreditPeriod::Weeks(weeks) => add_days( + date, + weeks + .checked_mul(7) + .ok_or(OutstandingsError::InvalidResponse( + "bill_credit_period_invalid", + ))?, + ), + CreditPeriod::Months(months) => date + .add_months_clamped(*months) + .map_err(|_| OutstandingsError::InvalidDateWindow), } } @@ -329,14 +349,45 @@ mod tests { use crate::{ outstandings::{ - BillAllocation, BillReferenceKind, CompleteScan, DateBoundaryProfile, DateWindow, - LedgerEntry, MoneyValue, PinnedCompany, Voucher, VoucherAlterId, + BillAllocation, BillReferenceKind, CompleteScan, CreditPeriod, DateBoundaryProfile, + DateWindow, LedgerEntry, MoneyValue, PinnedCompany, Voucher, VoucherAlterId, VoucherAlterIdHighWater, }, xml_read_profiles::ValidatedCompanyName, }; - use super::compute_outstandings; + use super::{add_credit_period, compute_outstandings}; + + #[test] + fn credit_periods_produce_calendar_due_dates_without_unit_guessing() { + assert_eq!( + add_credit_period( + &TallyDate::parse("20260131").unwrap(), + &CreditPeriod::Months(1) + ) + .unwrap() + .as_str(), + "20260228" + ); + assert_eq!( + add_credit_period( + &TallyDate::parse("20260101").unwrap(), + &CreditPeriod::Weeks(3) + ) + .unwrap() + .as_str(), + "20260122" + ); + assert_eq!( + add_credit_period( + &TallyDate::parse("20260101").unwrap(), + &CreditPeriod::Days(45) + ) + .unwrap() + .as_str(), + "20260215" + ); + } #[test] fn exact_bill_balances_age_and_split_receivable_from_payable() { @@ -571,7 +622,7 @@ mod tests { _ => panic!("synthetic test must use a known kind"), }, amount: MoneyValue::Exact(amount), - credit_period_days: 0, + credit_period: CreditPeriod::Days(0), }], }], } diff --git a/src-tauri/crates/bridge-tally-protocol/src/outstandings/mod.rs b/src-tauri/crates/bridge-tally-protocol/src/outstandings/mod.rs index 2985c0fc..948ba477 100644 --- a/src-tauri/crates/bridge-tally-protocol/src/outstandings/mod.rs +++ b/src-tauri/crates/bridge-tally-protocol/src/outstandings/mod.rs @@ -17,8 +17,8 @@ pub use completeness::{ pub use compute::{compute_outstandings, compute_outstandings_with_ageing_anchor}; pub use model::{ AgeingAnchor, AlterIdRange, BillAllocation, BillReferenceKind, CompanyBookExtent, CompleteScan, - CompleteSegment, CompleteWitnessPair, CorroboratedDatePartition, DateBoundaryProfile, - DateWindow, EmptyDateWindowVerification, EmptyDateWindowWitness, + CompleteSegment, CompleteWitnessPair, CorroboratedDatePartition, CreditPeriod, + DateBoundaryProfile, DateWindow, EmptyDateWindowVerification, EmptyDateWindowWitness, EmptyPartitionControlProvenance, EmptyPartitionWitness, LedgerEntry, LedgerOpeningCoverage, MoneyValue, NarrowDateWindow, OutstandingsError, PartialScan, PinnedCompany, ScanResult, SegmentVerification, StrictlyWiderDateCover, Voucher, VoucherAlterId, VoucherAlterIdHighWater, diff --git a/src-tauri/crates/bridge-tally-protocol/src/outstandings/model.rs b/src-tauri/crates/bridge-tally-protocol/src/outstandings/model.rs index dfc49344..270ac8a2 100644 --- a/src-tauri/crates/bridge-tally-protocol/src/outstandings/model.rs +++ b/src-tauri/crates/bridge-tally-protocol/src/outstandings/model.rs @@ -413,6 +413,13 @@ impl BillReferenceKind { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CreditPeriod { + Days(u32), + Weeks(u32), + Months(u32), +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct BillAllocation { pub name: Option, @@ -422,10 +429,10 @@ pub struct BillAllocation { /// a bill's date can differ from the date of the voucher that opened it, /// and using the voucher date then puts the balance in the wrong bucket. pub bill_date: Option, - /// Number of calendar days Tally adds to the bill date for due-date - /// ageing. The parser accepts only the measured `N Days` wire grammar; - /// a missing or malformed value cannot silently become a different basis. - pub credit_period_days: u32, + /// Tally's credit-period unit and magnitude. It stays typed until the + /// due date is calculated: months are calendar operations, not a guessed + /// number of days. Unknown wire units fail at the parser boundary. + pub credit_period: CreditPeriod, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/src-tauri/crates/bridge-tally-protocol/src/outstandings/parser.rs b/src-tauri/crates/bridge-tally-protocol/src/outstandings/parser.rs index 85a36fd4..3823db99 100644 --- a/src-tauri/crates/bridge-tally-protocol/src/outstandings/parser.rs +++ b/src-tauri/crates/bridge-tally-protocol/src/outstandings/parser.rs @@ -9,7 +9,7 @@ use super::{ Envelope, Header, LedgerCollection, RawBillAllocation, RawLedgerEntry, RawVoucher, RawWitnessVoucher, VoucherCollection, WitnessVoucherCollection, }, - AlterIdRange, BillAllocation, BillReferenceKind, DateWindow, LedgerEntry, + AlterIdRange, BillAllocation, BillReferenceKind, CreditPeriod, DateWindow, LedgerEntry, LedgerOpeningCoverage, MoneyValue, Voucher, VoucherAlterId, WitnessVoucher, }; @@ -393,14 +393,14 @@ fn convert_bill_allocation( } _ => None, }; - let credit_period_days = match raw.bill_credit_period { - Some(value) => parse_credit_period_days(&value.text)?, + let credit_period = match raw.bill_credit_period { + Some(value) => parse_credit_period(&value.text)?, None if bill_type.requires_named_reference() => { return Err(OutstandingsError::InvalidResponse( "bill_credit_period_missing", )) } - None => 0, + None => CreditPeriod::Days(0), }; Ok(Some(BillAllocation { name, @@ -411,26 +411,41 @@ fn convert_bill_allocation( .text, )?, bill_date, - credit_period_days, + credit_period, })) } -fn parse_credit_period_days(value: &str) -> Result { +fn parse_credit_period(value: &str) -> Result { let value = value.trim(); if value.is_empty() { - return Ok(0); + return Ok(CreditPeriod::Days(0)); } - let Some(days) = value.strip_suffix(" Days") else { + let Some((magnitude, period)) = [ + (" Months", CreditPeriod::Months as fn(u32) -> CreditPeriod), + (" Month", CreditPeriod::Months as fn(u32) -> CreditPeriod), + (" Weeks", CreditPeriod::Weeks as fn(u32) -> CreditPeriod), + (" Week", CreditPeriod::Weeks as fn(u32) -> CreditPeriod), + (" Days", CreditPeriod::Days as fn(u32) -> CreditPeriod), + (" Day", CreditPeriod::Days as fn(u32) -> CreditPeriod), + ] + .into_iter() + .find_map(|(suffix, period)| { + value + .strip_suffix(suffix) + .map(|magnitude| (magnitude, period)) + }) else { return Err(OutstandingsError::InvalidResponse( "bill_credit_period_invalid", )); }; - if days.is_empty() || !days.bytes().all(|byte| byte.is_ascii_digit()) { + if magnitude.is_empty() || !magnitude.bytes().all(|byte| byte.is_ascii_digit()) { return Err(OutstandingsError::InvalidResponse( "bill_credit_period_invalid", )); } - days.parse::() + magnitude + .parse::() + .map(period) .map_err(|_| OutstandingsError::InvalidResponse("bill_credit_period_invalid")) } @@ -544,7 +559,32 @@ fn trimmed_optional(value: Option) -> Option { #[cfg(test)] mod tests { - use super::count_voucher_start_elements; + use super::{count_voucher_start_elements, parse_credit_period}; + use crate::outstandings::{CreditPeriod, OutstandingsError}; + + #[test] + fn credit_period_accepts_verified_units_and_rejects_unknown_ones() { + assert_eq!( + parse_credit_period("45 Days").unwrap(), + CreditPeriod::Days(45) + ); + assert_eq!(parse_credit_period("1 Day").unwrap(), CreditPeriod::Days(1)); + assert_eq!( + parse_credit_period("3 Weeks").unwrap(), + CreditPeriod::Weeks(3) + ); + assert_eq!( + parse_credit_period("2 Months").unwrap(), + CreditPeriod::Months(2) + ); + assert_eq!(parse_credit_period(" ").unwrap(), CreditPeriod::Days(0)); + assert_eq!( + parse_credit_period("2 Fortnights"), + Err(OutstandingsError::InvalidResponse( + "bill_credit_period_invalid" + )) + ); + } #[test] fn voucher_rows_are_counted_structurally_not_by_one_textual_spelling() { diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index e2f1638d..0e16ae1d 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -11,9 +11,11 @@ use crate::db::tally_mirror::{ }; use crate::gst::{GstDraftRequest, GstReturnDraft}; use crate::reports::bulk_party_statement::{ - bulk_party_statement_party_count, write_bulk_party_statements, + bulk_party_statement_party_count, write_bulk_party_statements_with_ageing_anchor, +}; +use crate::reports::party_statement::{ + build_party_statement_with_ageing_anchor, PartyStatementError, }; -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}; @@ -1977,6 +1979,11 @@ pub struct OutstandingsRequest { /// today's date for existing callers and licensed Tally users. #[serde(default)] pub as_of_yyyymmdd: Option, + /// Existing callers predate an operator-visible selector and therefore + /// retain the established due-date report. New callers must send their + /// selection so every rendered/exported figure names the same basis. + #[serde(default)] + pub ageing_anchor: crate::tally::OutstandingsAgeingAnchor, } #[derive(Debug, Deserialize)] @@ -2116,6 +2123,7 @@ pub async fn fetch_tally_outstandings( request.expected_company_guid, as_of, request.currency_assertion, + request.ageing_anchor, ) .await .map_err(tally_runtime_command_error) @@ -2187,6 +2195,8 @@ pub struct AllCompaniesOutstandingsRequest { pub currency_assertion: OutstandingsCurrencyAssertion, #[serde(default)] pub as_of_yyyymmdd: Option, + #[serde(default)] + pub ageing_anchor: crate::tally::OutstandingsAgeingAnchor, } #[derive(Debug, Deserialize)] @@ -2258,6 +2268,7 @@ pub async fn fetch_tally_outstandings_all_companies( entry.expected_company_guid.clone(), as_of.clone(), request.currency_assertion, + request.ageing_anchor, ) .await .map_err(|_| "company_outstandings_read_failed"), @@ -2969,6 +2980,7 @@ pub struct ExportPartyStatementRequest { /// XLSX remains the default for callers that predate the PDF option. #[serde(default)] pub format: PartyStatementFormat, + pub ageing_anchor: crate::tally::OutstandingsAgeingAnchor, /// 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 @@ -2990,6 +3002,7 @@ pub struct ExportBulkPartyStatementsRequest { pub company: String, pub as_of_yyyymmdd: String, pub format: PartyStatementFormat, + pub ageing_anchor: crate::tally::OutstandingsAgeingAnchor, /// Chosen by the native folder picker. The command still checks that it /// exists and is a directory before any statement name is joined to it. pub destination: String, @@ -3069,22 +3082,24 @@ pub async fn export_bulk_party_statements( let destination = std::path::PathBuf::from(request.destination); match request.format { - PartyStatementFormat::Xlsx => write_bulk_party_statements( + PartyStatementFormat::Xlsx => write_bulk_party_statements_with_ageing_anchor( &destination, &request.company, &request.as_of_yyyymmdd, "xlsx", &request.open_bills, &request.unallocated_by_party, + request.ageing_anchor, |statement| render_party_statement_xlsx(statement).map_err(|error| error.to_string()), ), - PartyStatementFormat::Pdf => write_bulk_party_statements( + PartyStatementFormat::Pdf => write_bulk_party_statements_with_ageing_anchor( &destination, &request.company, &request.as_of_yyyymmdd, "pdf", &request.open_bills, &request.unallocated_by_party, + request.ageing_anchor, |statement| render_party_statement_pdf(statement).map_err(|error| error.to_string()), ), } @@ -3103,12 +3118,13 @@ pub async fn export_party_statement( ) -> Result { use tauri::Manager as _; - let statement = build_party_statement( + let statement = build_party_statement_with_ageing_anchor( &request.company, &request.as_of_yyyymmdd, &request.party, &request.open_bills, &request.unallocated_by_party, + request.ageing_anchor, ) .map_err(|error| match error { PartyStatementError::PartyNotFound => { @@ -3263,6 +3279,7 @@ mod party_statement_export_tests { "company": "Synthetic Books Pvt Ltd", "as_of_yyyymmdd": "20260808", "party": "Synthetic Party", + "ageing_anchor": "due_date", "open_bills": [], "unallocated_by_party": [], }); diff --git a/src-tauri/src/reports/bulk_party_statement.rs b/src-tauri/src/reports/bulk_party_statement.rs index 9af46b6b..fb97ccf6 100644 --- a/src-tauri/src/reports/bulk_party_statement.rs +++ b/src-tauri/src/reports/bulk_party_statement.rs @@ -10,8 +10,8 @@ use std::path::{Path, PathBuf}; use serde::Serialize; -use super::party_statement::{build_party_statement, PartyStatement}; -use crate::tally::{ExposureDirection, OpenBillRow, UnallocatedParty}; +use super::party_statement::{build_party_statement_with_ageing_anchor, PartyStatement}; +use crate::tally::{ExposureDirection, OpenBillRow, OutstandingsAgeingAnchor, UnallocatedParty}; #[derive(Debug, Clone, Serialize)] pub struct BulkPartyStatementResult { @@ -81,6 +81,29 @@ pub fn write_bulk_party_statements( open_bills: &[OpenBillRow], unallocated_by_party: &[UnallocatedParty], render: impl Fn(&PartyStatement) -> Result, String>, +) -> Result { + write_bulk_party_statements_with_ageing_anchor( + destination, + company, + as_of_yyyymmdd, + format, + open_bills, + unallocated_by_party, + OutstandingsAgeingAnchor::DueDate, + render, + ) +} + +/// Writes statements while retaining the selected ageing basis in every file. +pub fn write_bulk_party_statements_with_ageing_anchor( + destination: &Path, + company: &str, + as_of_yyyymmdd: &str, + format: &str, + open_bills: &[OpenBillRow], + unallocated_by_party: &[UnallocatedParty], + ageing_anchor: OutstandingsAgeingAnchor, + render: impl Fn(&PartyStatement) -> Result, String>, ) -> Result { if !destination.is_dir() { return Err("Bridge could not use that statement destination folder.".to_string()); @@ -90,12 +113,13 @@ pub fn write_bulk_party_statements( let mut written = Vec::with_capacity(parties.len()); let mut failures = Vec::new(); for party in parties { - let statement = match build_party_statement( + let statement = match build_party_statement_with_ageing_anchor( company, as_of_yyyymmdd, &party, open_bills, unallocated_by_party, + ageing_anchor, ) { Ok(statement) => statement, Err(error) => { diff --git a/src-tauri/src/reports/party_statement.rs b/src-tauri/src/reports/party_statement.rs index b78596fb..c2c46ef5 100644 --- a/src-tauri/src/reports/party_statement.rs +++ b/src-tauri/src/reports/party_statement.rs @@ -7,7 +7,7 @@ use bridge_tally_core::ExactDecimal; -use crate::tally::{ExposureDirection, OpenBillRow, UnallocatedParty}; +use crate::tally::{ExposureDirection, OpenBillRow, OutstandingsAgeingAnchor, UnallocatedParty}; /// Which ageing bucket a bill's age falls into. Boundaries match /// `bridge_tally_protocol::native_outstandings::compute` exactly, so a @@ -129,6 +129,9 @@ pub struct PartyStatement { pub company: String, pub party: String, pub as_of_yyyymmdd: String, + /// The operator-selected basis that produced every bill age and bucket in + /// this statement. Renderers must disclose this rather than assume due date. + pub ageing_anchor: OutstandingsAgeingAnchor, /// Oldest bill first (largest `age_days` first), matching the order the /// party's drill-down panel already shows. pub bills: Vec, @@ -172,6 +175,27 @@ pub fn build_party_statement( party: &str, open_bills: &[OpenBillRow], unallocated_by_party: &[UnallocatedParty], +) -> Result { + build_party_statement_with_ageing_anchor( + company, + as_of_yyyymmdd, + party, + open_bills, + unallocated_by_party, + OutstandingsAgeingAnchor::DueDate, + ) +} + +/// Builds a statement with the selected report basis retained for every +/// client-facing renderer. The compatibility wrapper above is due-date only +/// for existing internal callers that predate the selector. +pub fn build_party_statement_with_ageing_anchor( + company: &str, + as_of_yyyymmdd: &str, + party: &str, + open_bills: &[OpenBillRow], + unallocated_by_party: &[UnallocatedParty], + ageing_anchor: OutstandingsAgeingAnchor, ) -> Result { let mut bills: Vec = open_bills .iter() @@ -240,6 +264,7 @@ pub fn build_party_statement( company: company.to_string(), party: party.to_string(), as_of_yyyymmdd: as_of_yyyymmdd.to_string(), + ageing_anchor, bills, subtotals, bill_total, diff --git a/src-tauri/src/reports/party_statement_pdf.rs b/src-tauri/src/reports/party_statement_pdf.rs index d03ff386..8c6f820b 100644 --- a/src-tauri/src/reports/party_statement_pdf.rs +++ b/src-tauri/src/reports/party_statement_pdf.rs @@ -316,7 +316,7 @@ fn statement_lines(statement: &PartyStatement) -> Result, PartyStat "As of", &display_date(&statement.as_of_yyyymmdd)?, )?; - push_label_value(&mut lines, "Ageing basis", "Due date")?; + push_label_value(&mut lines, "Ageing basis", statement.ageing_anchor.label())?; lines.push(PdfLine::body("")); if !statement.unallocated.is_zero() { @@ -582,7 +582,9 @@ 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 crate::tally::{ + ExposureDirection, OpenBillRow, OutstandingsAgeingAnchor, UnallocatedParty, + }; use bridge_tally_core::ExactDecimal; use bridge_tally_protocol::native_outstandings::parse_native_ledger_snapshot; use std::io::{Cursor, Read}; @@ -750,6 +752,27 @@ mod tests { assert!(!text.contains('\u{20b9}')); } + #[test] + fn pdf_and_xlsx_disclose_the_statement_selected_ageing_basis() { + let mut statement = build_party_statement( + "Synthetic Books Pvt Ltd", + "20260808", + "Synthetic Party", + &[bill("INV-1", "1250.75", 40)], + &[], + ) + .unwrap(); + statement.ageing_anchor = OutstandingsAgeingAnchor::BillDate; + + let xlsx_text = xlsx_sheet_xml(&render_party_statement_xlsx(&statement).unwrap()); + assert!(statement_lines(&statement) + .unwrap() + .iter() + .any(|line| line.text == b"Ageing basis: Bill date")); + assert!(xlsx_text.contains("Ageing basis")); + assert!(xlsx_text.contains("Bill date")); + } + #[test] fn renders_bill_direction_for_mixed_party_documents() { let mut payable = bill("BILL-1", "1250.75", 40); diff --git a/src-tauri/src/reports/party_statement_xlsx.rs b/src-tauri/src/reports/party_statement_xlsx.rs index 37ab6337..878543a1 100644 --- a/src-tauri/src/reports/party_statement_xlsx.rs +++ b/src-tauri/src/reports/party_statement_xlsx.rs @@ -60,7 +60,7 @@ pub fn render_party_statement_xlsx( )?; row += 1; worksheet.write_string(row, 0, "Ageing basis")?; - worksheet.write_string(row, 1, "Due date")?; + worksheet.write_string(row, 1, statement.ageing_anchor.label())?; row += 1; let has_unallocated = !statement.unallocated.is_zero(); diff --git a/src-tauri/src/tally/mod.rs b/src-tauri/src/tally/mod.rs index 5fcd39d5..7e21de20 100644 --- a/src-tauri/src/tally/mod.rs +++ b/src-tauri/src/tally/mod.rs @@ -25,7 +25,7 @@ pub use connector::{ company_source_identity, core_snapshot_start_authorized, source_lineage, RuntimeTallyConnector, }; pub use runtime::{ - CachedProbeReservation, EndpointKey, ExposureDirection, OpenBillRow, + CachedProbeReservation, EndpointKey, ExposureDirection, OpenBillRow, OutstandingsAgeingAnchor, OutstandingsCurrencyAssertion, OutstandingsLoadResult, OutstandingsPartialReason, TallyRuntime, TallySessionSnapshot, TallyTelemetryPreviewExport, UnallocatedParty, }; diff --git a/src-tauri/src/tally/runtime.rs b/src-tauri/src/tally/runtime.rs index cebc1666..93ab966d 100644 --- a/src-tauri/src/tally/runtime.rs +++ b/src-tauri/src/tally/runtime.rs @@ -21,15 +21,16 @@ use bridge_tally_protocol::native_outstandings::{ compute_native_outstandings, parse_company_currency, parse_native_bill_rows, parse_native_group_snapshot, parse_native_ledger_snapshot, render_company_currency_request, render_native_bills_request, render_native_group_snapshot_request, - render_native_ledger_snapshot_request, AgeingAnchor, CompanyCurrency, NativeBillsReportKind, - NativeGroupSnapshot, NativeMasterSnapshot, NativeOverdueCrosscheck, + render_native_ledger_snapshot_request, AgeingAnchor as NativeAgeingAnchor, CompanyCurrency, + NativeBillsReportKind, NativeGroupSnapshot, NativeMasterSnapshot, NativeOverdueCrosscheck, }; #[cfg(feature = "voucher-scan")] use bridge_tally_protocol::outstandings::{ - assemble_partitioned_scan, assemble_scan, compute_outstandings, - corroborate_empty_date_partition, nearest_non_empty_primary_partition, CompleteWitnessPair, - CorroboratedDatePartition, DateWindow, NarrowDateWindow, PartialScan, ScanResult, - SegmentVerification, StrictlyWiderDateCover, VoucherAlterIdHighWater, WitnessPairVerification, + assemble_partitioned_scan, assemble_scan, compute_outstandings_with_ageing_anchor, + corroborate_empty_date_partition, nearest_non_empty_primary_partition, + AgeingAnchor as LegacyAgeingAnchor, CompleteWitnessPair, CorroboratedDatePartition, DateWindow, + NarrowDateWindow, PartialScan, ScanResult, SegmentVerification, StrictlyWiderDateCover, + VoucherAlterIdHighWater, WitnessPairVerification, }; use bridge_tally_protocol::outstandings_shared::{DateBoundaryProfile, OutstandingsReport}; use bridge_tally_transport::TallyTransportError; @@ -182,13 +183,12 @@ impl OutstandingsPartialReason { /// Flattens both native reports into displayable bill rows, oldest first. /// -/// Ageing anchors on the DUE date to match the report and Tally's own -/// `BILLOVERDUE` column; where no credit period exists the two dates coincide. const MISSING_BILL_REFERENCE_LABEL: &str = "No reference reported"; fn all_open_bill_rows( receivable: &[bridge_tally_protocol::native_outstandings::NativeBillRow], payable: &[bridge_tally_protocol::native_outstandings::NativeBillRow], + ageing_anchor: OutstandingsAgeingAnchor, as_of: &TallyDate, ) -> Vec { let mut rows = receivable @@ -197,11 +197,15 @@ fn all_open_bill_rows( .chain(payable.iter().map(|row| (row, ExposureDirection::Payable))) .filter_map(|(row, kind)| { let amount = row.closing_balance.abs().ok()?; - let age_days = if &row.due_date > as_of { + let anchor_date = match ageing_anchor { + OutstandingsAgeingAnchor::DueDate => &row.due_date, + OutstandingsAgeingAnchor::BillDate => &row.bill_date, + }; + let age_days = if anchor_date > as_of { None } else { Some( - bridge_tally_protocol::native_outstandings::age_in_days(&row.due_date, as_of) + bridge_tally_protocol::native_outstandings::age_in_days(anchor_date, as_of) .ok()?, ) }; @@ -291,13 +295,38 @@ impl ExposureDirection { } } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub enum OutstandingsAgeingAnchor { + #[default] DueDate, BillDate, } +impl OutstandingsAgeingAnchor { + pub const fn label(self) -> &'static str { + match self { + Self::DueDate => "Due date", + Self::BillDate => "Bill date", + } + } + + const fn native_anchor(self) -> NativeAgeingAnchor { + match self { + Self::DueDate => NativeAgeingAnchor::DueDate, + Self::BillDate => NativeAgeingAnchor::BillDate, + } + } + + #[cfg(feature = "voucher-scan")] + const fn legacy_anchor(self) -> LegacyAgeingAnchor { + match self { + Self::DueDate => LegacyAgeingAnchor::DueDate, + Self::BillDate => LegacyAgeingAnchor::BillDate, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct UnallocatedParty { pub party: String, @@ -1350,6 +1379,7 @@ impl TallyRuntime { expected_company_guid: String, as_of: TallyDate, currency_assertion: OutstandingsCurrencyAssertion, + ageing_anchor: OutstandingsAgeingAnchor, ) -> anyhow::Result { let _lease = self.begin_ordinary_read(&config)?; self.execute( @@ -1459,7 +1489,7 @@ impl TallyRuntime { ledgers: &ledger_rows, groups: NativeGroupSnapshot::Complete(&group_rows), }, - AgeingAnchor::DueDate, + ageing_anchor.native_anchor(), &as_of, total_bytes, )?; @@ -1469,12 +1499,12 @@ impl TallyRuntime { } let statement_open_bills = - all_open_bill_rows(&receivable_rows, &payable_rows, &as_of); + all_open_bill_rows(&receivable_rows, &payable_rows, ageing_anchor, &as_of); let statement_unallocated_by_party = all_unallocated_parties(&result.residuals); Ok(OutstandingsLoadResult::Complete { report: Box::new(result.report), currency_assertion, - ageing_anchor: OutstandingsAgeingAnchor::DueDate, + ageing_anchor, synced_at_unix_ms: chrono::Utc::now().timestamp_millis(), unallocated_total: Some(result.residual_total), statement_unallocated_by_party, @@ -1547,6 +1577,7 @@ impl TallyRuntime { expected_company_guid: String, as_of: TallyDate, currency_assertion: OutstandingsCurrencyAssertion, + ageing_anchor: OutstandingsAgeingAnchor, ) -> anyhow::Result { self.fetch_outstandings_native( config, @@ -1554,6 +1585,7 @@ impl TallyRuntime { expected_company_guid, as_of, currency_assertion, + ageing_anchor, ) .await } @@ -1566,6 +1598,7 @@ impl TallyRuntime { expected_company_guid: String, as_of: TallyDate, currency_assertion: OutstandingsCurrencyAssertion, + ageing_anchor: OutstandingsAgeingAnchor, ) -> anyhow::Result { // Tally's own Bills Receivable/Payable reports answer this question in // O(open bills) instead of O(vouchers), so they need no segment @@ -1590,6 +1623,7 @@ impl TallyRuntime { expected_company_guid, as_of, currency_assertion, + ageing_anchor, ) .await; }; @@ -1907,9 +1941,13 @@ impl TallyRuntime { corroborated_date_partitions, ) { ScanResult::Complete(scan) => Ok(OutstandingsLoadResult::Complete { - report: Box::new(compute_outstandings(&scan, as_of)?), + report: Box::new(compute_outstandings_with_ageing_anchor( + &scan, + as_of, + ageing_anchor.legacy_anchor(), + )?), currency_assertion, - ageing_anchor: OutstandingsAgeingAnchor::DueDate, + ageing_anchor, synced_at_unix_ms: chrono::Utc::now().timestamp_millis(), // The voucher scan derives bills from vouchers // and cannot establish the unallocated @@ -2300,6 +2338,7 @@ mod tests { let rows = all_open_bill_rows( &receivable, &[], + OutstandingsAgeingAnchor::DueDate, &TallyDate::parse("20260817").expect("capture as-of"), ); let future = rows @@ -2308,6 +2347,21 @@ mod tests { .expect("captured future-due bill remains present"); assert_eq!(future.amount.as_str(), "22222.00"); assert_eq!(future.age_days, None); + + let bill_date_rows = all_open_bill_rows( + &receivable, + &[], + OutstandingsAgeingAnchor::BillDate, + &TallyDate::parse("20260817").expect("capture as-of"), + ); + let bill_date_future = bill_date_rows + .iter() + .find(|row| row.reference == "ALPHA-FUTURE") + .expect("captured future-due bill remains present for bill-date ageing"); + assert!( + bill_date_future.age_days.is_some(), + "the selected bill-date basis must not reuse the future due date" + ); } #[test] @@ -2328,7 +2382,7 @@ mod tests { ledgers: &[], groups: NativeGroupSnapshot::LegacyFixtureWithoutGroups, }, - AgeingAnchor::DueDate, + NativeAgeingAnchor::DueDate, &TallyDate::parse("20260817").expect("synthetic as-of"), 0, ) @@ -2369,7 +2423,7 @@ mod tests { ledgers: &ledgers, groups: NativeGroupSnapshot::Complete(&groups), }, - AgeingAnchor::DueDate, + NativeAgeingAnchor::DueDate, &requested_as_of, 0, ) @@ -2402,7 +2456,7 @@ mod tests { &as_of, ) .expect("paired empty BILLREF values remain parseable"); - let rows = all_open_bill_rows(&parsed, &[], &as_of); + let rows = all_open_bill_rows(&parsed, &[], OutstandingsAgeingAnchor::DueDate, &as_of); assert_eq!(rows.len(), 2, "empty identities must not collapse rows"); let total = rows @@ -2560,7 +2614,7 @@ mod tests { ledgers: &ledgers, groups: NativeGroupSnapshot::LegacyFixtureWithoutGroups, }, - AgeingAnchor::DueDate, + NativeAgeingAnchor::DueDate, &as_of, bills_xml.len() + ledger_xml.len(), ) @@ -2573,7 +2627,8 @@ mod tests { assert_eq!(computed.report.ageing.days_90_plus, ExactDecimal::zero()); assert_eq!(computed.report.open_receivable_bill_count, 1); - let statement_rows = all_open_bill_rows(&receivable, &[], &as_of); + let statement_rows = + all_open_bill_rows(&receivable, &[], OutstandingsAgeingAnchor::DueDate, &as_of); assert_eq!( statement_rows.len(), 1, @@ -2868,6 +2923,7 @@ mod tests { "synthetic-guid".to_string(), TallyDate::parse("20260731").unwrap(), OutstandingsCurrencyAssertion::Inr, + OutstandingsAgeingAnchor::DueDate, ) .await .expect_err("a non-loopback endpoint must never be contacted"); @@ -2891,6 +2947,7 @@ mod tests { "synthetic-guid".to_string(), TallyDate::parse("20260731").unwrap(), OutstandingsCurrencyAssertion::Inr, + OutstandingsAgeingAnchor::DueDate, ) .await .expect("missing coverage is an in-band partial result"); diff --git a/src-tauri/tests/unit_a_live.rs b/src-tauri/tests/unit_a_live.rs index 9b4c07c6..bf65734b 100644 --- a/src-tauri/tests/unit_a_live.rs +++ b/src-tauri/tests/unit_a_live.rs @@ -8,7 +8,8 @@ #[cfg(feature = "live-calibration-harness")] use bridge_lib::tally::{ - OutstandingsCurrencyAssertion, OutstandingsLoadResult, TallyConfig, TallyRuntime, + OutstandingsAgeingAnchor, OutstandingsCurrencyAssertion, OutstandingsLoadResult, TallyConfig, + TallyRuntime, }; #[cfg(feature = "live-calibration-harness")] use bridge_tally_core::TallyDate; @@ -56,6 +57,7 @@ async fn unit_a_outstandings_live_exit_check_withholds_without_residual_coverage company_guid, TallyDate::parse(EXIT_AS_OF).expect("fixed reconciliation as-of date is valid"), OutstandingsCurrencyAssertion::Inr, + OutstandingsAgeingAnchor::DueDate, ) .await .expect("live outstandings request completes"); diff --git a/src/AllClientsScreen.tsx b/src/AllClientsScreen.tsx index 469d220d..f0170e9b 100644 --- a/src/AllClientsScreen.tsx +++ b/src/AllClientsScreen.tsx @@ -4,7 +4,11 @@ import React from "react"; import { ChevronRight, RefreshCw } from "lucide-react"; import { invoke } from "@tauri-apps/api/core"; import { applyClientGroupLabel, ClientGroupLabelSaveSequence, ClientGroupLabels, groupClientRows, isLatestClientGroupLabelSave, issueClientGroupLabelSave, reconcileLoadedSortPreference, rollbackFailedClientGroupLabel } from "./client-grouping"; -import { outstandingsPartialState } from "./outstandings-copy"; +import { + outstandingsAgeingAnchorLabel, + outstandingsPartialState, + type OutstandingsAgeingAnchor, +} from "./outstandings-copy"; import { allCompaniesOutstandingsInvokeArgument, asOfBoundValueForAsOf, @@ -104,6 +108,7 @@ export function AllClientsScreen({ config, companies, onOpenCompany, onBack, asO const [loadedEntries, setLoadedEntries] = React.useState | null>(null); const [loading, setLoading] = React.useState(false); const [error, setError] = React.useState(null); + const [ageingAnchor, setAgeingAnchor] = React.useState("due_date"); const [groupLabels, setGroupLabels] = React.useState({}); const [groupLabelError, setGroupLabelError] = React.useState(null); const persistedGroupLabels = React.useRef({}); @@ -131,6 +136,13 @@ export function AllClientsScreen({ config, companies, onOpenCompany, onBack, asO setError(null); }, [requestedAsOf]); + React.useEffect(() => { + requestVersion.current += 1; + setLoadedEntries(null); + setLoading(false); + setError(null); + }, [ageingAnchor]); + React.useEffect(() => { let active = true; void invoke("load_client_group_labels") @@ -174,7 +186,7 @@ export function AllClientsScreen({ config, companies, onOpenCompany, onBack, asO }, []); const load = React.useCallback(async () => { - const argument = allCompaniesOutstandingsInvokeArgument(config, companies, asOf); + const argument = allCompaniesOutstandingsInvokeArgument(config, companies, asOf, ageingAnchor); if (companies.length === 0 || !argument) return; const requestedAsOfYyyymmdd = argument.request.as_of_yyyymmdd; const version = requestVersion.current + 1; @@ -202,7 +214,7 @@ export function AllClientsScreen({ config, companies, onOpenCompany, onBack, asO } finally { if (requestVersion.current === version) setLoading(false); } - }, [asOf, config.host, config.port, companies.map((company) => company.guid).join("|")]); + }, [ageingAnchor, asOf, config.host, config.port, companies.map((company) => company.guid).join("|")]); const rows = React.useMemo(() => { if (!entries) return []; @@ -380,8 +392,21 @@ export function AllClientsScreen({ config, companies, onOpenCompany, onBack, asO : `${companies.length} ${companies.length === 1 ? "book" : "books"} open in Tally`}

As of {asOf}

+

{outstandingsAgeingAnchorLabel(ageingAnchor)}

+ {onBack && (