diff --git a/docs/tally/TALLY_PROTOCOL_REFERENCE.md b/docs/tally/TALLY_PROTOCOL_REFERENCE.md index 5c04615f..fcacf8df 100644 --- a/docs/tally/TALLY_PROTOCOL_REFERENCE.md +++ b/docs/tally/TALLY_PROTOCOL_REFERENCE.md @@ -1261,10 +1261,48 @@ changes the buckets. On a bill dated 1-May-26 with a 30-day credit period, due 3 Both are correct. Neither is "the" answer, so **any tool computing ageing must state which basis it used.** -`BILLCREDITPERIOD` tags appear in the ordinary wildcard voucher fetch — 19 occurrences in a -single partition capture of a book that sets none. That establishes serialization of the empty -field only, not the usable value or format of a configured credit period. Capture and read back -a nonzero period before claiming that due-date ageing needs no request-shape change. +Licensed-gateway capture/read-back evidence on **2026-08-23** establishes these accepted units: + +| entered value | returned `BILLCREDITPERIOD` | +| --- | --- | +| `2 Months` | `2 Months` | +| `3 Weeks` | `3 Weeks` | +| `1 Day` | `1 Days` | + +The same licensed TallyPrime 7.1 import/read-back on **2026-08-23** measured the +day-unit persistence ceiling and confirms that it is not a shared ceiling for +other units: + +| entered value | returned `BILLCREDITPERIOD` | +| --- | --- | +| `365 Days` | `365 Days` | +| `3650 Days` | `3650 Days` | +| `9999 Days` | `9999 Days` | +| `10000 Days` | empty | +| `100 Months` | `100 Months` | +| `1000 Months` | `1000 Months` | + +Tally silently discards a day count above `9999` to an **empty** credit period. +That is not neutral: due-date ageing then uses the bill date as the due date, +which can move the bill to a different ageing bucket. The parser therefore +rejects day counts above the measured `9999` ceiling, but does not invent a +matching weeks or months ceiling; those units are constrained by checked +resulting-date arithmetic instead. + +The voucher parser accepts explicit `N Day(s)`, `N Week(s)`, and `N Month(s)` forms only. It +rejects an unknown unit rather than guessing a day count. Weeks add exactly seven days each; +months use a calendar-month operation that preserves the day of month when possible and otherwise +clamps to the target month's last day. The same licensed-gateway evidence measured: + +| bill date and period | due date | +| --- | --- | +| 15-Jan-26 + 4 Weeks | 12-Feb-26 | +| 30-Jan-26 + 1 Month | 28-Feb-26 | +| 31-Jan-26 + 1 Month | 28-Feb-26 | +| 31-Mar-26 + 2 Months | 31-May-26 | + +2026 is not a leap year. The 29-Feb clamp is implemented from the Gregorian calendar rule but was +not directly observed in this capture. **`BILLOVERDUE` in the native report is not usable as an ageing oracle.** Its as-of is Tally's own period date, not the caller's, and it does **not** follow the `F6` setting: diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 99e397dd..f84a0996 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": "a4b79de59c448ee291b02dc5a660e069c65f2e9b9218e6ea0a9c20eaf378c367", "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..9cbbb15c 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": "cc28ce7ba675e7db3efd418f7215244b5f9a563b80a90846dbb67515b9a76512" }, { "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": "a62437fb389b3c2908bdbbc70bbd1bc50dadca232257484bda0fcf7dc9bed6d7" + "sha256": "308f1694ce35587c31f85c0f55bec78f7d59d818d62da6302213b1e80c9ecc2f" }, { "path": "src-tauri/crates/bridge-tally-protocol/src/outstandings/parser.rs", - "sha256": "be3c2e63a531b63a39c8fc2a072b6c0cd3f3327bcda6268b37a224a7ea163b3c" + "sha256": "ad6f2c7b033947bdc476f94c917abe9b018bb821ce9f6b12f883bf8784488732" }, { "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": "b0f1607b947b13066bb9030632bfe9f0c05ea9d710b007c4e4ff37a2a9ecd25c" }, { "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": "1cfbbbe99f301736f74f02f5c154c879f35437b45f1b870993562a5154909880" + "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": "a0f470b148be06af61dfac233989d482e4751589512a6c1f7047d01ccc91e305" + "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": "c26058d0396d7fe63d18e43e0d15f49ef5e10019f1088513c7cd3ee4d3977933" + "manifest_sha256": "a4b79de59c448ee291b02dc5a660e069c65f2e9b9218e6ea0a9c20eaf378c367" } 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..41ad1da0 100644 --- a/src-tauri/crates/bridge-tally-primitives/src/lib.rs +++ b/src-tauri/crates/bridge-tally-primitives/src/lib.rs @@ -190,6 +190,54 @@ impl TallyDate { "{previous_year:04}{previous_month:02}{previous_day:02}" )) } + + /// Adds calendar days in constant time. This deliberately does not step + /// one day at a time: callers can be parsing an untrusted wire magnitude. + pub fn add_days(&self, days: u32) -> Result { + let (year, month, day) = self.parts()?; + let target = civil_day(year, month, day) + .checked_add(i64::from(days)) + .ok_or_else(|| invalid_data("tally_date_overflow"))?; + let (year, month, day) = civil_date(target); + if !(1..=9999).contains(&year) { + return Err(invalid_data("tally_date_overflow")); + } + Self::parse(format!("{year:04}{month:02}{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, month, day) = self.parts()?; + 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}")) + } + + fn parts(&self) -> Result<(u32, u32, u32), TallyError> { + 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"))?; + Ok((year, month, day)) + } } impl<'de> Deserialize<'de> for TallyDate { @@ -229,3 +277,68 @@ fn invalid_data(code: &'static str) -> TallyError { code: code.to_string(), } } + +fn civil_day(year: u32, month: u32, day: u32) -> i64 { + let year = i64::from(year) - i64::from(month <= 2); + let era = year.div_euclid(400); + let year_of_era = year - era * 400; + let shifted_month = i64::from(month) + if month > 2 { -3 } else { 9 }; + let day_of_year = (153 * shifted_month + 2) / 5 + i64::from(day) - 1; + let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; + era * 146_097 + day_of_era +} + +fn civil_date(days: i64) -> (i64, i64, i64) { + let era = days.div_euclid(146_097); + let day_of_era = days - era * 146_097; + let year_of_era = + (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; + let mut year = year_of_era + era * 400; + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); + let month_prime = (5 * day_of_year + 2) / 153; + let day = day_of_year - (153 * month_prime + 2) / 5 + 1; + let month = month_prime + if month_prime < 10 { 3 } else { -9 }; + year += i64::from(month <= 2); + (year, month, day) +} + +#[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" + ); + } + + #[test] + fn adding_days_is_constant_time_calendar_arithmetic() { + assert_eq!( + TallyDate::parse("20260228") + .unwrap() + .add_days(1) + .unwrap() + .as_str(), + "20260301" + ); + assert!(TallyDate::parse("20260101") + .unwrap() + .add_days(u32::MAX) + .is_err()); + } +} 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..dadf9707 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::{ - 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. @@ -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() @@ -154,7 +165,7 @@ pub fn compute_outstandings( .map_err(|_| OutstandingsError::ArithmeticOverflow)?; let totals = parties.entry(party).or_default(); let bill_age = match &bill.kind { - OpenBillKind::Named { oldest_date } => Some(days_between(oldest_date, &as_of)?), + OpenBillKind::Named { oldest_date } => overdue_days(oldest_date, &as_of)?, OpenBillKind::OnAccount => None, }; if bill.balance.is_negative() { @@ -164,14 +175,18 @@ pub fn compute_outstandings( totals.receivable.as_ref().unwrap_or(&ExactDecimal::zero()), &amount, )?); - if let Some(age) = bill_age { - totals.oldest_bill_age = - Some(totals.oldest_bill_age.map_or(age, |oldest| oldest.max(age))); - let (bucket, bill_count) = match age { - 0..=30 => (&mut ageing.days_0_30, &mut ageing_bill_counts.days_0_30), - 31..=60 => (&mut ageing.days_31_60, &mut ageing_bill_counts.days_31_60), - 61..=90 => (&mut ageing.days_61_90, &mut ageing_bill_counts.days_61_90), - _ => ( + if matches!(&bill.kind, OpenBillKind::Named { .. }) { + // Match the native report: a future-due named bill remains in + // the first bucket, but has no truthful overdue age and cannot + // become a party's oldest aged bill. On Account remains a + // separate unaged aggregate and never enters bill buckets. + let (bucket, bill_count) = match bill_age { + None | Some(0..=30) => { + (&mut ageing.days_0_30, &mut ageing_bill_counts.days_0_30) + } + Some(31..=60) => (&mut ageing.days_31_60, &mut ageing_bill_counts.days_31_60), + Some(61..=90) => (&mut ageing.days_61_90, &mut ageing_bill_counts.days_61_90), + Some(_) => ( &mut ageing.days_90_plus, &mut ageing_bill_counts.days_90_plus, ), @@ -180,6 +195,10 @@ pub fn compute_outstandings( *bill_count = bill_count .checked_add(1) .ok_or(OutstandingsError::ArithmeticOverflow)?; + if let Some(age) = bill_age { + totals.oldest_bill_age = + Some(totals.oldest_bill_age.map_or(age, |oldest| oldest.max(age))); + } } } else { payable_total = add(&payable_total, &amount)?; @@ -242,8 +261,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,9 +276,38 @@ fn bill_age_date( BillReferenceKind::OnAccount => Err(OutstandingsError::InvalidResponse( "bill_reference_forbidden", )), + }?; + match ageing_anchor { + AgeingAnchor::BillDate => Ok(bill_date), + 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), + } +} + +fn add_days(date: &TallyDate, days: u32) -> Result { + date.add_days(days) + .map_err(|_| OutstandingsError::InvalidDateWindow) +} + fn exact(value: &MoneyValue) -> Result<&ExactDecimal, OutstandingsError> { match value { MoneyValue::Exact(value) => Ok(value), @@ -277,6 +326,16 @@ fn days_between(from: &TallyDate, to: &TallyDate) -> Result Result, OutstandingsError> { + if from > to { + return Ok(None); + } + days_between(from, to).map(Some) +} + fn civil_day(date: &TallyDate) -> Result { let value = date.as_str(); let year = value[0..4] @@ -300,17 +359,121 @@ fn civil_day(date: &TallyDate) -> Result { #[cfg(test)] mod tests { use bridge_tally_primitives::{ExactDecimal, TallyDate}; + use sha2::{Digest, Sha256}; use crate::{ + decode_tally_xml_response_bytes_limited, outstandings::{ - BillAllocation, BillReferenceKind, CompleteScan, DateBoundaryProfile, DateWindow, - LedgerEntry, MoneyValue, PinnedCompany, Voucher, VoucherAlterId, - VoucherAlterIdHighWater, + AlterIdRange, BillAllocation, BillReferenceKind, CompleteScan, CreditPeriod, + DateBoundaryProfile, DateWindow, LedgerEntry, MoneyValue, PinnedCompany, Voucher, + VoucherAlterId, VoucherAlterIdHighWater, }, xml_read_profiles::ValidatedCompanyName, + ExpectedTallyTextEncoding, }; - use super::compute_outstandings; + use super::super::parser::parse_segment; + use super::{add_credit_period, compute_outstandings, compute_outstandings_with_ageing_anchor}; + + const AGEING_CORPUS: &[u8] = + include_bytes!("../../tests/fixtures/vouchers_ageing_corpus_live.utf16le.xml"); + const GST_CREDIT_PERIOD_CORPUS: &[u8] = + include_bytes!("../../tests/fixtures/vouchers_gst_credit_periods_live.utf16le.xml"); + const AGEING_CORPUS_GUID: &str = "2f65b86f-edf4-471c-99ed-da0de7163836"; + const GST_CREDIT_PERIOD_CORPUS_GUID: &str = "46faa869-1208-4119-8961-f28db4df3b8e"; + + #[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 captured_ageing_corpus_moves_seven_of_eight_bills_between_anchors() { + let scan = captured_scan( + AGEING_CORPUS, + "BRIDGE CORPUS AGEING", + AGEING_CORPUS_GUID, + "20250401", + "20260331", + 8, + "497aec1804603b5c79a6ece404554c1f0ee1fb005ce3187b627f3292c51605f6", + ); + let as_of = TallyDate::parse("20260331").unwrap(); + let bill_date = compute_outstandings_with_ageing_anchor( + &scan, + as_of.clone(), + crate::outstandings::AgeingAnchor::BillDate, + ) + .expect("captured bill-date ageing computes"); + let due_date = compute_outstandings_with_ageing_anchor( + &scan, + as_of, + crate::outstandings::AgeingAnchor::DueDate, + ) + .expect("captured 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); + } + + #[test] + fn captured_gst_corpus_parses_week_and_month_credit_period_units() { + let scan = captured_scan( + GST_CREDIT_PERIOD_CORPUS, + "BRIDGE CORPUS GST", + GST_CREDIT_PERIOD_CORPUS_GUID, + "20250401", + "20250420", + 40, + "1e340126eda8e767d2b53cab8bb2086add1ed35f53f7216a16edbc16624b30b8", + ); + let periods = scan + .vouchers() + .iter() + .flat_map(|voucher| voucher.ledger_entries.iter()) + .flat_map(|entry| entry.bill_allocations.iter()) + .map(|allocation| &allocation.credit_period) + .collect::>(); + + assert!(periods.contains(&&CreditPeriod::Days(15))); + assert!(periods.contains(&&CreditPeriod::Days(30))); + assert!(periods.contains(&&CreditPeriod::Weeks(2))); + assert!(periods.contains(&&CreditPeriod::Months(1))); + assert!(periods.contains(&&CreditPeriod::Months(2))); + } #[test] fn exact_bill_balances_age_and_split_receivable_from_payable() { @@ -454,6 +617,97 @@ mod tests { assert_eq!(report.top_parties[0].oldest_bill_age_days, Some(1)); } + #[test] + fn future_due_open_bill_is_bucketed_without_claiming_an_overdue_age() { + let company = PinnedCompany::verified( + ValidatedCompanyName::new("Synthetic Company").unwrap(), + "synthetic-guid".to_string(), + ) + .unwrap(); + let mut future_due = voucher( + "future-due", + "20260315", + "Customer", + "Invoice-future", + "New Ref", + "-100.00", + ); + future_due.ledger_entries[0].bill_allocations[0].credit_period = CreditPeriod::Days(30); + let scan = CompleteScan { + company, + reporting_window: DateWindow::parse( + DateBoundaryProfile::ModeAgnostic, + "20260101", + "20260331", + ) + .unwrap(), + voucher_alter_id_high_water: VoucherAlterIdHighWater::parse("1").unwrap(), + vouchers: vec![future_due], + encoded_bytes: 1024, + empty_partition_witnesses: Vec::new(), + }; + + let report = compute_outstandings(&scan, TallyDate::parse("20260331").unwrap()) + .expect("a future-due bill must not fail the complete report"); + + assert_eq!(report.receivable_total.as_str(), "100"); + assert_eq!(report.ageing.days_0_30.as_str(), "100"); + assert_eq!(report.open_receivable_bill_count, 1); + assert_eq!(report.top_parties[0].oldest_bill_age_days, None); + } + + fn captured_scan( + bytes: &[u8], + company_name: &str, + company_guid: &str, + from: &str, + to: &str, + high_water: u64, + expected_sha256: &str, + ) -> CompleteScan { + let observed_sha256 = Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + assert_eq!( + observed_sha256, expected_sha256, + "captured wire bytes changed" + ); + let xml = decode_tally_xml_response_bytes_limited( + bytes, + "text/xml; charset=utf-16", + ExpectedTallyTextEncoding::Utf16Le, + bytes.len(), + ) + .expect("captured BOM-less UTF-16LE response decodes") + .text; + let company = PinnedCompany::verified( + ValidatedCompanyName::new(company_name) + .expect("synthetic capture company name is valid"), + company_guid.to_string(), + ) + .expect("captured company identity is pinned"); + let window = DateWindow::parse(DateBoundaryProfile::ModeAgnostic, from, to) + .expect("captured date window is valid"); + let parsed = parse_segment( + &xml, + &company, + &window, + AlterIdRange::new(0, high_water).expect("captured AlterID range is valid"), + ) + .expect("captured response parses"); + assert_eq!(parsed.raw_row_count, parsed.vouchers.len()); + CompleteScan { + company, + reporting_window: window, + voucher_alter_id_high_water: VoucherAlterIdHighWater::parse(&high_water.to_string()) + .unwrap(), + vouchers: parsed.vouchers, + encoded_bytes: bytes.len(), + empty_partition_witnesses: Vec::new(), + } + } + #[test] fn optional_vouchers_are_excluded_from_ordinary_book_totals() { // Optional vouchers are non-posting in Tally. Tally's own @@ -545,6 +799,7 @@ mod tests { _ => panic!("synthetic test must use a known kind"), }, amount: MoneyValue::Exact(amount), + 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 da72d903..948ba477 100644 --- a/src-tauri/crates/bridge-tally-protocol/src/outstandings/mod.rs +++ b/src-tauri/crates/bridge-tally-protocol/src/outstandings/mod.rs @@ -14,11 +14,11 @@ 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, - CompleteSegment, CompleteWitnessPair, CorroboratedDatePartition, DateBoundaryProfile, - DateWindow, EmptyDateWindowVerification, EmptyDateWindowWitness, + AgeingAnchor, AlterIdRange, BillAllocation, BillReferenceKind, CompanyBookExtent, CompleteScan, + 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 730ddbbc..270ac8a2 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() { @@ -403,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, @@ -412,6 +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, + /// 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 5c4cfd0f..aea8dab0 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,6 +393,15 @@ fn convert_bill_allocation( } _ => None, }; + 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 => CreditPeriod::Days(0), + }; Ok(Some(BillAllocation { name, bill_type, @@ -402,9 +411,82 @@ fn convert_bill_allocation( .text, )?, bill_date, + credit_period, })) } +// Licensed TallyPrime 7.1 read-back on 2026-08-23 retained up to 9999 days, +// but silently discarded 10000 days to an empty credit period. This is a +// measured wire-format ceiling, not a business-term policy. Weeks and months +// have no equivalent measured ceiling, so their checked resulting date is the +// bound instead. +const MAX_TALLY_CREDIT_PERIOD_DAYS: u32 = 9999; + +fn parse_credit_period(value: &str) -> Result { + let value = value.trim(); + if value.is_empty() { + return Ok(CreditPeriod::Days(0)); + } + let Some((magnitude, period, maximum)) = [ + ( + " Months", + CreditPeriod::Months as fn(u32) -> CreditPeriod, + None, + ), + ( + " Month", + CreditPeriod::Months as fn(u32) -> CreditPeriod, + None, + ), + ( + " Weeks", + CreditPeriod::Weeks as fn(u32) -> CreditPeriod, + None, + ), + ( + " Week", + CreditPeriod::Weeks as fn(u32) -> CreditPeriod, + None, + ), + ( + " Days", + CreditPeriod::Days as fn(u32) -> CreditPeriod, + Some(MAX_TALLY_CREDIT_PERIOD_DAYS), + ), + ( + " Day", + CreditPeriod::Days as fn(u32) -> CreditPeriod, + Some(MAX_TALLY_CREDIT_PERIOD_DAYS), + ), + ] + .into_iter() + .find_map(|(suffix, period, maximum)| { + value + .strip_suffix(suffix) + .map(|magnitude| (magnitude, period, maximum)) + }) else { + return Err(OutstandingsError::InvalidResponse( + "bill_credit_period_invalid", + )); + }; + if magnitude.is_empty() || !magnitude.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(OutstandingsError::InvalidResponse( + "bill_credit_period_invalid", + )); + } + let magnitude = magnitude + .parse::() + .map_err(|_| OutstandingsError::InvalidResponse("bill_credit_period_invalid"))?; + if let Some(maximum) = maximum { + if magnitude > maximum { + return Err(OutstandingsError::InvalidResponse( + "bill_credit_period_invalid", + )); + } + } + Ok(period(magnitude)) +} + fn parse_money(value: String) -> Result { let value = value.trim(); if value.is_empty() { @@ -515,7 +597,57 @@ 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)); + // Licensed TallyPrime 7.1 read-back on 2026-08-23 retained all four + // values. Only the day-unit ceiling is measured: 10000 Days comes + // back empty from Tally rather than as an over-limit period. + assert_eq!( + parse_credit_period("9999 Days").unwrap(), + CreditPeriod::Days(9999) + ); + assert_eq!( + parse_credit_period("3650 Days").unwrap(), + CreditPeriod::Days(3650) + ); + assert_eq!( + parse_credit_period("100 Months").unwrap(), + CreditPeriod::Months(100) + ); + assert_eq!( + parse_credit_period("1000 Months").unwrap(), + CreditPeriod::Months(1000) + ); + assert_eq!( + parse_credit_period("2 Fortnights"), + Err(OutstandingsError::InvalidResponse( + "bill_credit_period_invalid" + )) + ); + assert_eq!( + parse_credit_period("10000 Days"), + Err(OutstandingsError::InvalidResponse( + "bill_credit_period_invalid" + )) + ); + } #[test] fn voucher_rows_are_counted_structurally_not_by_one_textual_spelling() { 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/fixtures/AGEING_CAPTURE_PROVENANCE.md b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/AGEING_CAPTURE_PROVENANCE.md new file mode 100644 index 00000000..8bf6fa3d --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/AGEING_CAPTURE_PROVENANCE.md @@ -0,0 +1,65 @@ +# `vouchers_ageing_corpus_live` and `vouchers_gst_credit_periods_live` — provenance + +Two captures taken to replace the hand-authored `ageing_vouchers_xml` helper in +`tests/outstandings.rs`, which built the response by hand — including the exact `BILLCREDITPERIOD` +spelling the parser expects — and so could only ever prove that Bridge agrees with itself. + +## Provenance + +- **Host / gateway:** TallyPrime **Silver (licensed)**, `http://localhost:9001`. + Response header reports `PRODMAJORREL 7 / PRODMINORREL 1`, i.e. TallyPrime 7.1. +- **Date:** 2026-08-23 +- **Encoding:** **BOM-less UTF-16LE**, exactly as received. These are the undecoded wire bytes, not + decoded text. `.gitattributes` marks this tree `-text`, so they are safe from newline rewriting. +- **Request shape:** the production `BridgeVoucherExport` collection — `SYSTEM TYPE="Formulae"` plus + ``, with `ALLLEDGERENTRIES.BILLALLOCATIONS.*` fetched. +- **`/status`:** healthy before and after each request. + +## Companies — synthetic, built for this purpose + +Both books were created over the XML import path on 2026-08-23. No PII, no real GSTIN, no real +company data. + +| file | bytes | sha256 | company | +|---|---|---|---| +| `vouchers_ageing_corpus_live.utf16le.xml` | 44,156 | `497aec1804603b5c79a6ece404554c1f0ee1fb005ce3187b627f3292c51605f6` | `BRIDGE CORPUS AGEING` | +| `vouchers_gst_credit_periods_live.utf16le.xml` | 267,252 | `1e340126eda8e767d2b53cab8bb2086add1ed35f53f7216a16edbc16624b30b8` | `BRIDGE CORPUS GST` | + +## What each capture establishes + +### `vouchers_ageing_corpus_live` — the bucket straddle + +Eight bills whose `(age, credit period)` pairs deliberately straddle the 30/60/90 boundaries in +opposite directions. As of 2026-03-31, **seven of eight fall in different buckets** under bill-date +and due-date ageing; the eighth has a zero credit period and is the control. + +Before this book existed, issue #114 was unfalsifiable: every bill in the reference corpus had an +empty credit period, so the two methods coincided and any implementation passed. + +### `vouchers_gst_credit_periods_live` — the unit variants and multi-entry vouchers + +Carries **five distinct credit-period serialisations as Tally actually writes them**: + +``` +'15 Days' '30 Days' '2 Weeks' '1 Months' '2 Months' +``` + +This is the only artefact in the project containing real **month** and **week** serialisations. A +parser requiring a `" Days"` suffix fails the entire outstandings read on any of them — which is a +defect that shipped into review on 2026-08-23 precisely because every generated corpus emitted only +`" Days"`. + +It also carries vouchers with **both 3 and 4 ledger entries** (inter-state IGST, and intra-state +CGST+SGST). Every other book in the project has exactly two entries per voucher, so this is the only +fixture that can expose an assumption about voucher arity. + +**No GSTIN appears in the voucher payload**, so no sanitisation was applied or required. The party +GSTINs held on the master records are deliberately non-conforming in any case — PAN block +`ZZZZZ0000Z`, checksum not computed — and cannot collide with a real registration. + +## Known limits + +- One product tier (licensed Silver), one release (7.1), one machine. +- 2026 is not a leap year, so no 29-February clamp appears in these bytes. +- Recorded but **not** present here: Tally normalises a submitted `"1 Day"` to `"1 Days"` on write. + That was measured separately; no bill in these captures carries a singular form. diff --git a/src-tauri/crates/bridge-tally-protocol/tests/fixtures/vouchers_ageing_corpus_live.utf16le.xml b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/vouchers_ageing_corpus_live.utf16le.xml new file mode 100644 index 00000000..ca28fdb4 Binary files /dev/null and b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/vouchers_ageing_corpus_live.utf16le.xml differ diff --git a/src-tauri/crates/bridge-tally-protocol/tests/fixtures/vouchers_gst_credit_periods_live.utf16le.xml b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/vouchers_gst_credit_periods_live.utf16le.xml new file mode 100644 index 00000000..faf22c86 Binary files /dev/null and b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/vouchers_gst_credit_periods_live.utf16le.xml differ diff --git a/src-tauri/crates/bridge-tally-protocol/tests/outstandings.rs b/src-tauri/crates/bridge-tally-protocol/tests/outstandings.rs index 430c5dc6..b2479f84 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, @@ -1075,13 +1075,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 +1169,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 +1287,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 +1336,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 +1449,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/commands.rs b/src-tauri/src/commands.rs index 6548cb91..284b3871 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -11,9 +11,12 @@ 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, + BulkPartyStatementRequest, +}; +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 +1980,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 +2124,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 +2196,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 +2269,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"), @@ -2961,46 +2973,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, @@ -3009,11 +2981,13 @@ pub struct ExportPartyStatementRequest { /// XLSX remains the default for callers that predate the PDF option. #[serde(default)] pub format: PartyStatementFormat, + #[serde(default)] + 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 /// carries every fact a statement needs. - pub open_bills: Vec, + pub open_bills: Vec, pub unallocated_by_party: Vec, } @@ -3030,12 +3004,14 @@ pub struct ExportBulkPartyStatementsRequest { pub company: String, pub as_of_yyyymmdd: String, pub format: PartyStatementFormat, + #[serde(default)] + 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, /// 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 +3019,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 +3066,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,32 +3082,37 @@ 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 { - PartyStatementFormat::Xlsx => write_bulk_party_statements( - &destination, - &request.company, - &request.as_of_yyyymmdd, - "xlsx", - &open_bills, - &request.unallocated_by_party, - |statement| render_party_statement_xlsx(statement).map_err(|error| error.to_string()), - ), - PartyStatementFormat::Pdf => write_bulk_party_statements( - &destination, - &request.company, - &request.as_of_yyyymmdd, - "pdf", - &open_bills, - &request.unallocated_by_party, - |statement| render_party_statement_pdf(statement).map_err(|error| error.to_string()), - ), + PartyStatementFormat::Xlsx => { + write_bulk_party_statements_with_ageing_anchor(BulkPartyStatementRequest { + destination: &destination, + company: &request.company, + as_of_yyyymmdd: &request.as_of_yyyymmdd, + format: "xlsx", + open_bills: &request.open_bills, + unallocated_by_party: &request.unallocated_by_party, + ageing_anchor: request.ageing_anchor, + render: |statement: &crate::reports::party_statement::PartyStatement| { + render_party_statement_xlsx(statement).map_err(|error| error.to_string()) + }, + }) + } + PartyStatementFormat::Pdf => { + write_bulk_party_statements_with_ageing_anchor(BulkPartyStatementRequest { + destination: &destination, + company: &request.company, + as_of_yyyymmdd: &request.as_of_yyyymmdd, + format: "pdf", + open_bills: &request.open_bills, + unallocated_by_party: &request.unallocated_by_party, + ageing_anchor: request.ageing_anchor, + render: |statement: &crate::reports::party_statement::PartyStatement| { + render_party_statement_pdf(statement).map_err(|error| error.to_string()) + }, + }) + } } } @@ -3150,18 +3129,13 @@ 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( + let statement = build_party_statement_with_ageing_anchor( &request.company, &request.as_of_yyyymmdd, &request.party, - &open_bills, + &request.open_bills, &request.unallocated_by_party, + request.ageing_anchor, ) .map_err(|error| match error { PartyStatementError::PartyNotFound => { @@ -3321,6 +3295,10 @@ mod party_statement_export_tests { }); let defaulted: ExportPartyStatementRequest = serde_json::from_value(base.clone()).unwrap(); assert!(matches!(defaulted.format, PartyStatementFormat::Xlsx)); + assert!(matches!( + defaulted.ageing_anchor, + crate::tally::OutstandingsAgeingAnchor::DueDate + )); let mut pdf = base; pdf["format"] = serde_json::Value::String("pdf".to_string()); @@ -3328,6 +3306,45 @@ mod party_statement_export_tests { assert!(matches!(pdf.format, PartyStatementFormat::Pdf)); } + #[test] + fn bulk_statement_export_defaults_the_ageing_anchor_for_legacy_callers() { + let request: ExportBulkPartyStatementsRequest = serde_json::from_value(serde_json::json!({ + "company": "Synthetic Books Pvt Ltd", + "as_of_yyyymmdd": "20260808", + "format": "xlsx", + "destination": "/tmp/statements", + "open_bills": [], + "unallocated_by_party": [], + })) + .unwrap(); + + assert!(matches!( + request.ageing_anchor, + crate::tally::OutstandingsAgeingAnchor::DueDate + )); + } + + #[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..f6fe180a 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 { @@ -21,6 +21,22 @@ pub struct BulkPartyStatementResult { pub failures: Vec, } +/// All inputs needed to write a batch from a completed outstandings result. +/// +/// Grouping the destination, report identity, selected ageing basis, and +/// renderer keeps the write contract cohesive as client-facing statement +/// metadata grows. +pub struct BulkPartyStatementRequest<'a, Render> { + pub destination: &'a Path, + pub company: &'a str, + pub as_of_yyyymmdd: &'a str, + pub format: &'a str, + pub open_bills: &'a [OpenBillRow], + pub unallocated_by_party: &'a [UnallocatedParty], + pub ageing_anchor: OutstandingsAgeingAnchor, + pub render: Render, +} + #[derive(Debug, Clone, Serialize)] pub struct WrittenStatement { pub party: String, @@ -64,6 +80,7 @@ struct StatementManifest<'a> { company: &'a str, as_of_yyyymmdd: &'a str, format: &'a str, + ageing_anchor: OutstandingsAgeingAnchor, written: &'a [WrittenStatement], failures: &'a [StatementFailure], } @@ -82,6 +99,35 @@ pub fn write_bulk_party_statements( unallocated_by_party: &[UnallocatedParty], render: impl Fn(&PartyStatement) -> Result, String>, ) -> Result { + write_bulk_party_statements_with_ageing_anchor(BulkPartyStatementRequest { + destination, + company, + as_of_yyyymmdd, + format, + open_bills, + unallocated_by_party, + ageing_anchor: OutstandingsAgeingAnchor::DueDate, + render, + }) +} + +/// Writes statements while retaining the selected ageing basis in every file. +pub fn write_bulk_party_statements_with_ageing_anchor( + request: BulkPartyStatementRequest<'_, Render>, +) -> Result +where + Render: for<'statement> Fn(&'statement PartyStatement) -> Result, String>, +{ + let BulkPartyStatementRequest { + destination, + company, + as_of_yyyymmdd, + format, + open_bills, + unallocated_by_party, + ageing_anchor, + render, + } = request; if !destination.is_dir() { return Err("Bridge could not use that statement destination folder.".to_string()); } @@ -90,12 +136,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) => { @@ -152,6 +199,7 @@ pub fn write_bulk_party_statements( company, as_of_yyyymmdd, format, + ageing_anchor, written: &written, failures: &failures, }; @@ -177,9 +225,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 +373,7 @@ mod tests { due_date: "20260201".to_string(), amount: ExactDecimal::parse(amount).expect("synthetic decimal"), age_days: Some(40), - kind: "receivable", + kind: ExposureDirection::Receivable, } } @@ -390,6 +437,7 @@ mod tests { assert!(manifest.contains("Synthetic Books Pvt Ltd")); assert!(manifest.contains("\"receivable_amount\": \"10\"")); assert!(manifest.contains("\"payable_amount\": \"0\"")); + assert!(manifest.contains("\"ageing_anchor\": \"due_date\"")); assert!(!manifest.contains("\"amount\":")); assert!(manifest.contains("Broken Party")); assert!(manifest.contains("\"code\": \"rendering\"")); @@ -400,7 +448,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"), @@ -422,6 +470,25 @@ mod tests { assert_eq!(result.written[0].payable_amount, "7"); } + #[test] + fn manifest_discloses_the_selected_ageing_anchor() { + let destination = tempfile::tempdir().expect("temporary destination"); + let result = write_bulk_party_statements_with_ageing_anchor(BulkPartyStatementRequest { + destination: destination.path(), + company: "Synthetic Books Pvt Ltd", + as_of_yyyymmdd: "20260808", + format: "xlsx", + open_bills: &[bill("Selected basis", "10.00")], + unallocated_by_party: &[], + ageing_anchor: OutstandingsAgeingAnchor::BillDate, + render: |_: &PartyStatement| Ok(b"synthetic workbook".to_vec()), + }) + .expect("statement batch succeeds"); + + let manifest = fs::read_to_string(&result.manifest_path).expect("manifest is written"); + assert!(manifest.contains("\"ageing_anchor\": \"bill_date\"")); + } + #[test] fn per_party_file_creation_failure_is_retained_in_the_manifest() { let destination = tempfile::tempdir().expect("temporary destination"); diff --git a/src-tauri/src/reports/party_statement.rs b/src-tauri/src/reports/party_statement.rs index f808e3b6..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 @@ -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, } @@ -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() @@ -217,13 +241,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, @@ -245,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, @@ -263,7 +283,7 @@ mod tests { reference: &str, amount: &str, age_days: Option, - kind: &'static str, + kind: ExposureDirection, ) -> OpenBillRow { OpenBillRow { party: party.to_string(), @@ -300,10 +320,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 +388,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 +478,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 +522,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 +539,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..8c6f820b 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", statement.ageing_anchor.label())?; lines.push(PdfLine::body("")); if !statement.unallocated.is_zero() { @@ -344,7 +345,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 +402,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 { @@ -585,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}; @@ -614,7 +613,7 @@ mod tests { due_date: "20260201".to_string(), amount: ExactDecimal::parse(amount).unwrap(), age_days: Some(age_days), - kind: "receivable", + kind: ExposureDirection::Receivable, } } @@ -753,10 +752,31 @@ 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); - payable.kind = "payable"; + payable.kind = ExposureDirection::Payable; let statement = build_party_statement( "Synthetic Books Pvt Ltd", "20260808", @@ -775,7 +795,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..878543a1 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, statement.ageing_anchor.label())?; + row += 1; let has_unallocated = !statement.unallocated.is_zero(); if has_unallocated { @@ -92,7 +95,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 +226,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 +323,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 +342,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/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 3497560a..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,26 +183,29 @@ 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 .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 { + 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()?, ) }; @@ -262,7 +266,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 +274,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,13 +286,47 @@ pub enum ExposureDirection { Receivable, Payable, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] + +impl ExposureDirection { + pub const fn label(self) -> &'static str { + match self { + Self::Receivable => "Receivable", + Self::Payable => "Payable", + } + } +} +#[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, @@ -1347,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( @@ -1456,7 +1489,7 @@ impl TallyRuntime { ledgers: &ledger_rows, groups: NativeGroupSnapshot::Complete(&group_rows), }, - AgeingAnchor::DueDate, + ageing_anchor.native_anchor(), &as_of, total_bytes, )?; @@ -1466,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, @@ -1544,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, @@ -1551,6 +1585,7 @@ impl TallyRuntime { expected_company_guid, as_of, currency_assertion, + ageing_anchor, ) .await } @@ -1563,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 @@ -1587,6 +1623,7 @@ impl TallyRuntime { expected_company_guid, as_of, currency_assertion, + ageing_anchor, ) .await; }; @@ -1904,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::BillDate, + ageing_anchor, synced_at_unix_ms: chrono::Utc::now().timestamp_millis(), // The voucher scan derives bills from vouchers // and cannot establish the unallocated @@ -2297,6 +2338,7 @@ mod tests { let rows = all_open_bill_rows( &receivable, &[], + OutstandingsAgeingAnchor::DueDate, &TallyDate::parse("20260817").expect("capture as-of"), ); let future = rows @@ -2305,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] @@ -2325,7 +2382,7 @@ mod tests { ledgers: &[], groups: NativeGroupSnapshot::LegacyFixtureWithoutGroups, }, - AgeingAnchor::DueDate, + NativeAgeingAnchor::DueDate, &TallyDate::parse("20260817").expect("synthetic as-of"), 0, ) @@ -2366,7 +2423,7 @@ mod tests { ledgers: &ledgers, groups: NativeGroupSnapshot::Complete(&groups), }, - AgeingAnchor::DueDate, + NativeAgeingAnchor::DueDate, &requested_as_of, 0, ) @@ -2399,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 @@ -2557,7 +2614,7 @@ mod tests { ledgers: &ledgers, groups: NativeGroupSnapshot::LegacyFixtureWithoutGroups, }, - AgeingAnchor::DueDate, + NativeAgeingAnchor::DueDate, &as_of, bills_xml.len() + ledger_xml.len(), ) @@ -2570,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, @@ -2865,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"); @@ -2888,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 && (