From 56c886765a7337a210fc98b24ec587bd05b95fdb Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sun, 23 Aug 2026 22:53:25 +0530 Subject: [PATCH 1/6] fix(outstandings): recognize live INR currency identities Accept both observed single-currency Indian mailing names, INR and Indian Rupees, while retaining the count-one guard. Preserve failure-closed foreign-currency ledger parsing but name the ledger in a typed screen error. Replace self-authored currency shapes with captured UTF-16LE evidence and add captured Agst Ref allocation coverage. The All Clients symbol change is latent consistency work: the sweep currently refuses non-INR books, so no foreign balance reaches that screen today. #107 item 4 no longer needs its formal-name-export deferral because render_company_currency_request reaches it. No dependency added; no licence change. --- docs/tally/TALLY_PROTOCOL_REFERENCE.md | 2 +- .../src/native_outstandings/model.rs | 9 ++ .../src/native_outstandings/request.rs | 2 +- .../src/native_outstandings/wire.rs | 150 ++++++++++++++++-- .../fixtures/AGEING_CAPTURE_PROVENANCE.md | 23 +++ .../fixtures/CURRENCY_CAPTURE_PROVENANCE.md | 50 ++++++ .../currency_inr_legacy_live.utf16le.xml | Bin 0 -> 3428 bytes .../currency_inr_modern_live.utf16le.xml | Bin 0 -> 3404 bytes .../fixtures/currency_multi_live.utf16le.xml | Bin 0 -> 3800 bytes .../vouchers_agst_ref_reopen_live.utf16le.xml | Bin 0 -> 127166 bytes .../tests/outstandings.rs | 129 +++++++++++++-- src-tauri/src/commands.rs | 29 ++++ src-tauri/src/tally/runtime.rs | 17 +- src/AllClientsScreen.tsx | 26 +-- src/OutstandingsScreen.tsx | 12 +- src/outstandings-currency.ts | 10 ++ 16 files changed, 406 insertions(+), 53 deletions(-) create mode 100644 src-tauri/crates/bridge-tally-protocol/tests/fixtures/CURRENCY_CAPTURE_PROVENANCE.md create mode 100644 src-tauri/crates/bridge-tally-protocol/tests/fixtures/currency_inr_legacy_live.utf16le.xml create mode 100644 src-tauri/crates/bridge-tally-protocol/tests/fixtures/currency_inr_modern_live.utf16le.xml create mode 100644 src-tauri/crates/bridge-tally-protocol/tests/fixtures/currency_multi_live.utf16le.xml create mode 100644 src-tauri/crates/bridge-tally-protocol/tests/fixtures/vouchers_agst_ref_reopen_live.utf16le.xml diff --git a/docs/tally/TALLY_PROTOCOL_REFERENCE.md b/docs/tally/TALLY_PROTOCOL_REFERENCE.md index fcacf8df..84f09a67 100644 --- a/docs/tally/TALLY_PROTOCOL_REFERENCE.md +++ b/docs/tally/TALLY_PROTOCOL_REFERENCE.md @@ -884,7 +884,7 @@ single token **`Import Data`**, there is no ``/``, and the body is the Bridge Billwise Lab 2024040120240401 - Rs.Indian Rupees + Rs.INR Yes ``` diff --git a/src-tauri/crates/bridge-tally-protocol/src/native_outstandings/model.rs b/src-tauri/crates/bridge-tally-protocol/src/native_outstandings/model.rs index b431140d..43c4a944 100644 --- a/src-tauri/crates/bridge-tally-protocol/src/native_outstandings/model.rs +++ b/src-tauri/crates/bridge-tally-protocol/src/native_outstandings/model.rs @@ -10,6 +10,11 @@ pub enum NativeOutstandingsError { /// date, or its lexeme did not match the observed `D-MMM-YY` shape. InvalidDate(&'static str), InvalidAmount, + /// A ledger's `CLOSINGBALANCE` was a foreign-currency display expression + /// rather than the base-currency decimal this read requires. + ForeignCurrencyLedgerBalance { + ledger_name: String, + }, /// Tally's response did not match the documented grammar. The code /// identifies which structural rule was violated. InvalidResponse(&'static str), @@ -29,6 +34,10 @@ impl fmt::Display for NativeOutstandingsError { write!(formatter, "native outstandings date invalid ({code})") } Self::InvalidAmount => formatter.write_str("Tally returned an invalid native amount"), + Self::ForeignCurrencyLedgerBalance { ledger_name } => write!( + formatter, + "Tally reported a foreign-currency closing balance for ledger {ledger_name}" + ), Self::InvalidResponse(code) => { write!(formatter, "native outstandings response invalid ({code})") } diff --git a/src-tauri/crates/bridge-tally-protocol/src/native_outstandings/request.rs b/src-tauri/crates/bridge-tally-protocol/src/native_outstandings/request.rs index 9a5a3be4..fb53faea 100644 --- a/src-tauri/crates/bridge-tally-protocol/src/native_outstandings/request.rs +++ b/src-tauri/crates/bridge-tally-protocol/src/native_outstandings/request.rs @@ -250,7 +250,7 @@ fn xml_escape(value: &str) -> String { /// A company's base currency is a fact Tally holds, so asking the operator to /// assert it is a step the product can answer for itself. Measured /// 2026-08-07 on three lab companies: one `CURRENCY` row each, `NAME` `"Rs."`, -/// `MAILINGNAME` `"Indian Rupees"`. +/// `MAILINGNAME` `"Indian Rupees"` or `"INR"`. pub fn render_company_currency_request(company: &str) -> String { format!( r#"
1ExportCollectionBridgeCompanyCurrencies
$$SysName:XML{company}CurrencyNAME, MAILINGNAME, DECIMALPLACES
"#, diff --git a/src-tauri/crates/bridge-tally-protocol/src/native_outstandings/wire.rs b/src-tauri/crates/bridge-tally-protocol/src/native_outstandings/wire.rs index b936121d..d1afa296 100644 --- a/src-tauri/crates/bridge-tally-protocol/src/native_outstandings/wire.rs +++ b/src-tauri/crates/bridge-tally-protocol/src/native_outstandings/wire.rs @@ -365,6 +365,61 @@ fn parse_ledger_amount(text: &str) -> Result Result { + if text.is_empty() { + return Ok(ExactDecimal::zero()); + } + ExactDecimal::parse(text).map_err(|_| { + if is_foreign_currency_balance(text) { + NativeOutstandingsError::ForeignCurrencyLedgerBalance { + ledger_name: ledger_name.to_string(), + } + } else { + NativeOutstandingsError::InvalidAmount + } + }) +} + +/// A foreign-currency ledger balance is a display expression, not a decimal: +/// ` @ = `. Keep +/// this structural so the diagnostic does not depend on a particular symbol. +fn is_foreign_currency_balance(text: &str) -> bool { + let mut parts = text.split('@'); + let Some(foreign_amount) = parts.next() else { + return false; + }; + let Some(rate_and_base) = parts.next() else { + return false; + }; + if parts.next().is_some() { + return false; + } + let mut rate_parts = rate_and_base.split('='); + let Some(rate) = rate_parts.next() else { + return false; + }; + let Some(base_amount) = rate_parts.next() else { + return false; + }; + rate_parts.next().is_none() + && is_currency_qualified_numeric(foreign_amount) + && is_currency_qualified_numeric(rate) + && is_currency_qualified_numeric(base_amount) +} + +fn is_currency_qualified_numeric(value: &str) -> bool { + let value = value.trim(); + !value.is_empty() + && value.chars().any(|character| character.is_ascii_digit()) + && value.chars().any(|character| { + !character.is_ascii_digit() + && !matches!(character, '+' | '-' | '.' | ',' | '/' | ' ' | '\t') + }) +} + pub fn parse_native_ledger_snapshot( xml: &str, ) -> Result, NativeOutstandingsError> { @@ -761,7 +816,7 @@ fn parse_ledger_row( "ledger_duplicate_closing_balance", )); } - closing_balance = Some(parse_ledger_amount(text.trim())?); + closing_balance = Some(parse_ledger_closing_balance(text.trim(), &name)?); } b"OPENINGBALANCE" => { let text = read_element_text(reader, child.name())?; @@ -997,7 +1052,9 @@ pub fn parse_company_currency(xml: &str) -> Result
1
0Indian Rupees 2"#; + const MODERN_LIVE: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/currency_inr_modern_live.utf16le.xml" + )); + const LEGACY_LIVE: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/currency_inr_legacy_live.utf16le.xml" + )); + const MULTI_LIVE: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/currency_multi_live.utf16le.xml" + )); - #[test] - fn reads_the_live_indian_rupee_shape_and_ignores_the_cmpinfo_counter() { - let currency = parse_company_currency(LIVE).expect("parses"); - assert_eq!(currency.symbol, "Rs."); - assert_eq!(currency.mailing_name, "Indian Rupees"); - assert_eq!( - currency.currency_count, 1, - "the CMPINFO counter is not a row" + fn decode_utf16le(bytes: &[u8]) -> String { + let (units, remainder) = bytes.as_chunks::<2>(); + assert!( + remainder.is_empty(), + "captured UTF-16LE must have whole units" ); - assert!(currency.is_inr); + String::from_utf16( + &units + .iter() + .map(|unit| u16::from_le_bytes(*unit)) + .collect::>(), + ) + .expect("captured UTF-16LE must decode") + } + + #[test] + fn captured_currency_collections_recognize_both_indian_spellings_without_guessing() { + for (bytes, sha256, symbol, mailing_name, count, is_inr) in [ + ( + MODERN_LIVE, + "0dc84aa287cab1e1922db7e99a01f9f2b0bacd0d777fdd0b080adedc6622ed22", + "I₹", + "INR", + 1, + true, + ), + ( + LEGACY_LIVE, + "dcc3539205080c4272b42d333b693e6c90e1cdd6b9e9e080d4ea6b8ae2abb06e", + "Rs.", + "Indian Rupees", + 1, + true, + ), + ( + MULTI_LIVE, + "b64c0d5feb528fa02f81de576de5c766a95e1da1000975b1e2932868ae34118b", + "$", + "USD", + 2, + false, + ), + ] { + assert_eq!(sha256_hex(bytes), sha256, "captured wire bytes changed"); + let currency = parse_company_currency(&decode_utf16le(bytes)).expect("parses"); + assert_eq!(currency.symbol, symbol); + assert_eq!(currency.mailing_name, mailing_name); + assert_eq!(currency.currency_count, count, "CMPINFO is not a row"); + assert_eq!(currency.is_inr, is_inr); + } } #[test] fn several_currencies_cannot_name_the_base_currency() { - let xml = LIVE.replace( + let xml = decode_utf16le(LEGACY_LIVE).replace( "", r#"US Dollars"#, ); @@ -1079,7 +1187,8 @@ mod currency_tests { #[test] fn a_non_indian_single_currency_is_not_inr() { - let xml = LIVE + // Constructed: no captured company has this single-currency shape. + let xml = decode_utf16le(LEGACY_LIVE) .replace("Indian Rupees", "US Dollars") .replace(r#"NAME="Rs.""#, r#"NAME="$""#); let currency = parse_company_currency(&xml).expect("parses"); @@ -1099,10 +1208,21 @@ mod currency_tests { #[test] fn common_rs_symbol_does_not_prove_indian_rupees() { - let xml = LIVE.replace("Indian Rupees", "Pakistani Rupees"); + let xml = decode_utf16le(LEGACY_LIVE).replace("Indian Rupees", "Pakistani Rupees"); let currency = parse_company_currency(&xml).expect("shaped collection parses"); assert!(!currency.is_inr); } + + #[test] + fn foreign_currency_closing_balance_names_the_ledger_without_parsing_it() { + let xml = r#"
1
Sundry Debtors-$ 2000.00 @ I₹ 84/$ = -I₹ 168000.000Yes
"#; + assert_eq!( + parse_native_ledger_snapshot(xml), + Err(NativeOutstandingsError::ForeignCurrencyLedgerBalance { + ledger_name: "FX USD Debtor 02".to_string(), + }) + ); + } } #[cfg(test)] 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 index 8bf6fa3d..a953b4d3 100644 --- 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 @@ -63,3 +63,26 @@ GSTINs held on the master records are deliberately non-conforming in any case - 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. + +## Addendum 2026-08-23 — `vouchers_agst_ref_reopen_live` + +- **Host / gateway:** TallyPrime **Silver (licensed)**, `http://localhost:9001`, TallyPrime 7.1. +- **Date / request:** 2026-08-23; production `BridgeVoucherExport` collection with its + `SYSTEM TYPE="Formulae"` filter over 2024-04-01 through 2026-08-31; `STATUS 1`. +- **Encoding:** BOM-less UTF-16LE, exactly as received. + +| file | bytes | sha256 | company | +|---|---:|---|---| +| `vouchers_agst_ref_reopen_live.utf16le.xml` | 127,166 | `6c2978198a4fe802ea211dc8d7a7d0402331bd5b37319adf181c7015fcf10125` | `BRIDGE CORPUS SETTLED` | + +The capture has 24 vouchers and 48 bill allocations: 12 `New Ref` and 12 `Agst Ref`, all +with `30 Days`. Tally supplied `BILLDATE` and `BILLCREDITPERIOD` on each `Agst Ref` without +those fields being sent by the generator. For example, the 2025-04-28 receipt allocation for +`SET-INV-001` carries `20250408`, +`30 Days`, and +`Agst Ref`. The generator call was +`entry(party, False, amount, ref, "Agst Ref")`, with neither field supplied. + +Limits: one release and one machine; every bill carries `30 Days`; no reopening against a +zero-credit-period bill; each `Agst Ref` is a Receipt settling the bill exactly to zero; no +partial settlement or sign-flip reopening is represented. diff --git a/src-tauri/crates/bridge-tally-protocol/tests/fixtures/CURRENCY_CAPTURE_PROVENANCE.md b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/CURRENCY_CAPTURE_PROVENANCE.md new file mode 100644 index 00000000..7420c70a --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/CURRENCY_CAPTURE_PROVENANCE.md @@ -0,0 +1,50 @@ +# `currency_inr_modern_live`, `currency_inr_legacy_live`, `currency_multi_live` — provenance + +Three captures of the base-currency collection, taken to replace the hand-authored `const LIVE` in +`native_outstandings/wire.rs` and `const CURRENCY` in `tally/runtime.rs`. Those constants were +written by hand — including the `MAILINGNAME` spelling the rule tests for — and so could only ever +prove that Bridge agrees with itself. See issue #172. + +## Provenance + +- **Host / gateway:** TallyPrime **Silver (licensed)**, `http://localhost:9001`, TallyPrime 7.1. +- **Date:** 2026-08-23 +- **Encoding:** **BOM-less UTF-16LE**, exactly as received — undecoded wire bytes, not decoded + text. `.gitattributes` marks this tree `-text`, so they are safe from newline rewriting. +- **Request shape:** the production `render_company_currency_request` output, verbatim. +- **`/status`:** healthy before and after each request. + +| file | bytes | sha256 | company | symbol | `MAILINGNAME` | count | +|---|---|---|---|---|---|---| +| `currency_inr_modern_live.utf16le.xml` | 3,404 | `0dc84aa287cab1e1922db7e99a01f9f2b0bacd0d777fdd0b080adedc6622ed22` | `Bridge Validation Lab` | `I₹` | `INR` | 1 | +| `currency_inr_legacy_live.utf16le.xml` | 3,428 | `dcc3539205080c4272b42d333b693e6c90e1cdd6b9e9e080d4ea6b8ae2abb06e` | `Bridge Billwise Lab` | `Rs.` | `Indian Rupees` | 1 | +| `currency_multi_live.utf16le.xml` | 3,800 | `b64c0d5feb528fa02f81de576de5c766a95e1da1000975b1e2932868ae34118b` | `BRIDGE CORPUS FOREX` | `$` | `USD` | 2 | + +## What each capture establishes + +### `currency_inr_modern_live` — the form the rule currently rejects + +An ordinary single-currency Indian company as **TallyPrime 7.1 creates it**: symbol `I₹` +(`U+0049` then `U+20B9`), mailing name `INR`. `is_inr` evaluates to `false` against this, which is +the defect in #172. This is Bridge's own long-standing reference company, not a book built for +corpus work — it predates the 2026-08 corpus generators entirely. + +### `currency_inr_legacy_live` — the form the rule was written for + +The older spelling, symbol `Rs.` and mailing name `Indian Rupees`. This capture is why the fix is +to *widen* the accepted set rather than replace one literal with another: both forms are live on +the same machine, so a customer base spanning Tally versions will present both. + +### `currency_multi_live` — the case that must keep failing + +Two currency masters defined, so which one is BASE is not determinable from this read. +`currency_count == 1` must continue to gate, and `is_inr` must stay `false` here. Guessing would +put a rupee symbol in front of a dollar balance. + +## Known limits + +- One product tier (licensed Silver), one release (7.1), one machine. +- Six of the fourteen books on this machine were sampled for the table in #172; the three captured + here are the distinct shapes among them. +- No company on this machine defines a single **non-Indian** currency, so that case + (`count == 1`, mailing name `US Dollars`) remains covered only by a constructed variant. diff --git a/src-tauri/crates/bridge-tally-protocol/tests/fixtures/currency_inr_legacy_live.utf16le.xml b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/currency_inr_legacy_live.utf16le.xml new file mode 100644 index 0000000000000000000000000000000000000000..1eb0bdfac50510e983cf99fc39ddcf1c9e429893 GIT binary patch literal 3428 zcmb7H$&S-N5Un#3|6uU}42(E%2uO+T1cS54P6p-(0wE3{G{FD!UROHZWp_IsMKg|T zd9_z}7E;Sj?(n~ry&Uj-D^KN#6ta+acw0(^|J{X3I;A`5rGdr{oR^^8jHrr2`Cg8| zIm!v&ytJixi#JJ|3t7QZCHKY)`qfJr(P*obEuv`P*BUxLmICo8cklR1@QL=qd0s-2 zTAu1XSDg)F-NX08MLwoj*%m%kvN>l=(Jjr&(=szry8P78+{2pnEX~j1u$o6;u#O=* z#fW)UpLQ$!eRTCyJxjCl#M{H`5Ab-Hois0}pNl-8j3shd4ZNyRNgsK@2pp?>hbT^n zgPq<%`~Exunt>eGVw6DWT*h}mifcEdTfGxlyb$o^NqBPIEdF>dzcW zi)~N6xNChkbNYP;&y}9Ck;aPeZoFUSZg%92?}7`rIawo%!)|Wzse$LuivknaqaLgV zkq&t3-hLy`@a>!Y!L!Ip++vzv$!o~Ow)DPf?*IkrzH}2zv=Euuh|A@pcY>*Gm+2$fQjoZM&SJ?TCXMrgG$#3L{S)#2p z;@C1XUBWKqTjZQG%5$yW@PEn|;6J#i3b}Q5^7_`6_KvMXETQ~yR|FYK@Enf2 Pi8TAGgcNS(@o%aBZAGaP literal 0 HcmV?d00001 diff --git a/src-tauri/crates/bridge-tally-protocol/tests/fixtures/currency_inr_modern_live.utf16le.xml b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/currency_inr_modern_live.utf16le.xml new file mode 100644 index 0000000000000000000000000000000000000000..c78b2985bcdfdeb5f3e760ca8435b24a00c539d9 GIT binary patch literal 3404 zcmb7HO>fgc5S=p;|H0x9lvW%#1f;}vg28bjJ1O*9sX`p!P~gY$?|5$~o85_b9amMG zdgjfW?_FmhwRCcizpdQGY?im(@w@a`w5*1IrkZ<7yC9jZXT^14qzU+k0ej zL>`><9@Y=14bTeYw3ed;L+>iyfhq3YrQF&bQESHKqYcj%w*tS;RKQx9#h2LR2Kne5 z9Pt#-LP}@t2UiQ-@gBZo`?T)TUbj7XWgocz;cW5yctBk(Qg0j^|C;(mHF;GpTYD#cXS^*@?>QEe~@cLkBjVN@ap;8e{p^ zI6?E7<+LddtvOF#&cZlD<;tThCwtku4z6c+bRORNsolghE@t+JeVH>sFSoOPbzd4} z*`kX_oJ}j#jJK(uDL$q2mWQ|SXV`NFw_xCI&x!x#cGfTNQN116X>QZt_HK2~U^m z!ETUgk54_fKgb*W_awjZS!5A+*yeZg9+=pdzHi!hfC7DAx(z0CBmWBWvg_eytqkUp zldPVjFmVp@N1o+}{F3i360);49&KYI4=1oeEx2QwGwvE|hj_`GwjO(7MN9Y~zC~@h zi@eVUWFFVKlfUv)9#QRQ7k?pl&QIRn`qCM(^^Os$A9p`sh~Rx2PYq@E+Xp2)fydvO F{sUc|rE&lO literal 0 HcmV?d00001 diff --git a/src-tauri/crates/bridge-tally-protocol/tests/fixtures/currency_multi_live.utf16le.xml b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/currency_multi_live.utf16le.xml new file mode 100644 index 0000000000000000000000000000000000000000..4b5c9a53f0271b450ddb5fe36e640f928fd6791b GIT binary patch literal 3800 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 M&*{k({pNQ5A8CE>k^lez literal 0 HcmV?d00001 diff --git a/src-tauri/crates/bridge-tally-protocol/tests/outstandings.rs b/src-tauri/crates/bridge-tally-protocol/tests/outstandings.rs index b2479f84..05d61db6 100644 --- a/src-tauri/crates/bridge-tally-protocol/tests/outstandings.rs +++ b/src-tauri/crates/bridge-tally-protocol/tests/outstandings.rs @@ -8,21 +8,28 @@ use bridge_tally_primitives::TallyDate; use bridge_tally_protocol::{ + decode_tally_xml_response_bytes_limited, outstandings::{ assemble_partitioned_scan, assemble_scan, compute_outstandings, parse_company_book_extent, parse_ledger_opening_coverage, verify_segment_pair, AlterIdRange, BillReferenceKind, - CorroboratedDatePartition, DateBoundaryProfile, DateWindow, MoneyValue, NarrowDateWindow, - OutstandingsError, ScanResult, SegmentVerification, StrictlyWiderDateCover, - VoucherAlterIdHighWater, + CorroboratedDatePartition, CreditPeriod, DateBoundaryProfile, DateWindow, MoneyValue, + NarrowDateWindow, OutstandingsError, ScanResult, SegmentVerification, + StrictlyWiderDateCover, VoucherAlterIdHighWater, }, xml_read_profiles::ReadOnlyProfile, + ExpectedTallyTextEncoding, }; use proptest::{ prelude::*, test_runner::{Config as ProptestConfig, RngSeed}, }; +use sha2::{Digest, Sha256}; const COMPANY_EXTENT: &str = include_str!("fixtures/unit_a_company_extent_live.xml"); const VOUCHERS_LEGACY_SHAPE: &str = include_str!("fixtures/unit_a_vouchers_wildcard_live.xml"); +const AGST_REF_REOPEN_LIVE: &[u8] = + include_bytes!("fixtures/vouchers_agst_ref_reopen_live.utf16le.xml"); +const AGST_REF_REOPEN_COMPANY: &str = "BRIDGE CORPUS SETTLED"; +const AGST_REF_REOPEN_GUID: &str = "74d7e825-396a-4667-90b2-83f593f06a36"; /// The retained wildcard capture predates `ISOPTIONAL` joining the sealed /// request's FETCH list, so it carries no such element and the parser now fails @@ -59,6 +66,29 @@ fn capture_high_water() -> VoucherAlterIdHighWater { VoucherAlterIdHighWater::parse("440").unwrap() } +fn decode_utf16le_capture(bytes: &[u8]) -> String { + 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 +} + +fn agst_ref_reopen_extent() -> bridge_tally_protocol::outstandings::CompanyBookExtent { + // The production response does not carry company metadata. This minimal + // identity companion binds the captured voucher GUID prefix for the parser. + let xml = COMPANY_EXTENT + .replace(COMPANY_NAME, AGST_REF_REOPEN_COMPANY) + .replace(COMPANY_GUID, AGST_REF_REOPEN_GUID) + .replace("20260401", "20260831") + .replace("20240401", "20250401"); + parse_company_book_extent(&xml, AGST_REF_REOPEN_COMPANY, AGST_REF_REOPEN_GUID) + .expect("synthetic identity companion parses") +} + fn parse_coverage( xml: &str, ) -> Result { @@ -542,6 +572,83 @@ fn wildcard_live_capture_preserves_named_bill_type_distribution() { .all(|allocation| matches!(&allocation.amount, MoneyValue::Exact(_)))); } +#[test] +fn captured_agst_ref_settlements_preserve_original_bill_date_and_credit_period() { + let expected_sha256 = "6c2978198a4fe802ea211dc8d7a7d0402331bd5b37319adf181c7015fcf10125"; + let observed_sha256: String = Sha256::digest(AGST_REF_REOPEN_LIVE) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + assert_eq!( + observed_sha256, expected_sha256, + "captured wire bytes changed" + ); + + let xml = decode_utf16le_capture(AGST_REF_REOPEN_LIVE); + let extent = agst_ref_reopen_extent(); + let window = DateWindow::parse(DateBoundaryProfile::ModeAgnostic, "20250401", "20260831") + .expect("captured date window"); + let SegmentVerification::Complete(segment) = verify_segment_pair( + &xml, + &xml, + extent.company(), + window.clone(), + AlterIdRange::new(0, 24).expect("captured AlterID range"), + ) + .expect("paired captured bytes verify") else { + panic!("identical captured responses must be complete") + }; + assert_eq!(segment.vouchers().len(), 24); + let agst_refs = segment + .vouchers() + .iter() + .flat_map(|voucher| { + voucher + .ledger_entries + .iter() + .map(move |entry| (voucher, entry)) + }) + .flat_map(|(voucher, entry)| { + entry + .bill_allocations + .iter() + .map(move |allocation| (voucher, allocation)) + }) + .filter(|(_, allocation)| allocation.bill_type == BillReferenceKind::AgstRef) + .collect::>(); + assert_eq!(agst_refs.len(), 12); + assert!(agst_refs.iter().all(|(_, allocation)| { + allocation.bill_date.is_some() && allocation.credit_period == CreditPeriod::Days(30) + })); + let (receipt, allocation) = agst_refs + .iter() + .find(|(_, allocation)| allocation.name.as_deref() == Some("SET-INV-001")) + .expect("captured Agst Ref allocation"); + assert_eq!(receipt.date.as_str(), "20250428"); + assert_eq!( + allocation + .bill_date + .as_ref() + .expect("captured BILLDATE") + .as_str(), + "20250408" + ); + + let ScanResult::Complete(scan) = assemble_scan( + extent.company().clone(), + window, + VoucherAlterIdHighWater::parse("24").expect("captured high-water"), + vec![SegmentVerification::Complete(segment)], + ) else { + panic!("captured scan assembles") + }; + let report = compute_outstandings(&scan, TallyDate::parse("20260831").unwrap()) + .expect("fully settled capture computes"); + assert_eq!(report.open_receivable_bill_count, 0); + assert_eq!(report.receivable_total.as_str(), "0"); + assert_eq!(report.payable_total.as_str(), "0"); +} + #[test] fn paired_row_difference_is_partial_not_complete() { let extent = extent(); @@ -1287,22 +1394,22 @@ 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}2025040830 Days{amount}", company_guid = COMPANY_GUID ) }; let xml = format!( "
11
{}
", [ - voucher(1, "20260601", "New Ref", "-3000"), - voucher(2, "20260602", "Agst Ref", "3000"), - voucher(3, "20260701", "Agst Ref", "1500"), + voucher(1, "20250408", "New Ref", "-3000"), + voucher(2, "20250428", "Agst Ref", "3000"), + voucher(3, "20250701", "Agst Ref", "1500"), ] .join("") ); let extent = extent(); let window = - DateWindow::parse(DateBoundaryProfile::ModeAgnostic, "20260601", "20260701").unwrap(); + DateWindow::parse(DateBoundaryProfile::ModeAgnostic, "20250408", "20250701").unwrap(); let SegmentVerification::Complete(segment) = verify_segment_pair( &xml, &xml, @@ -1323,12 +1430,12 @@ fn against_ref_reopened_after_zero_balance_ages_from_original_bill_date() { }; let report = - compute_outstandings(&scan, TallyDate::parse("20260731").unwrap()).expect("computes"); + compute_outstandings(&scan, TallyDate::parse("20250807").unwrap()).expect("computes"); assert_eq!(report.payable_total.as_str(), "1500"); assert_eq!( report.top_parties[0].oldest_bill_age_days, - Some(60), - "an Agst Ref after full settlement must age from the original bill's BILLDATE" + Some(91), + "an Agst Ref after full settlement must age from the captured original due date, 2025-05-08" ); } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 284b3871..e183e746 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -42,6 +42,7 @@ use bridge_tally_core::{ CompanyRef as CoreCompanyRef, EvidenceConfidence, ReadWindow, RequestContext, TallyConnector, TallyDate, TransportId, CORE_ACCOUNTING_SCHEMA_VERSION, }; +use bridge_tally_protocol::native_outstandings::NativeOutstandingsError; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use tauri::{AppHandle, Manager, State}; @@ -80,6 +81,21 @@ fn tally_command_error( } fn tally_runtime_command_error(error: anyhow::Error) -> TallyCommandError { + if let Some(NativeOutstandingsError::ForeignCurrencyLedgerBalance { ledger_name }) = error + .chain() + .find_map(|cause| cause.downcast_ref::()) + { + return tally_command_error( + "company_foreign_currency_ledger_balance", + "Tally application", + format!( + "Tally reported a foreign-currency closing balance for ledger {ledger_name}; Bridge left the company unread rather than guessing a base-currency amount." + ), + "after_change", + true, + "Inspect the named ledger in Tally and use a base-currency company for the native outstandings read.", + ); + } if let Some(control) = error.downcast_ref::() { return match control { TallyRuntimeControlError::Cancelled => tally_command_error( @@ -2642,6 +2658,19 @@ mod tests { )); assert_eq!(discovery_limit.code, "untrusted_discovery_limit_exceeded"); assert_eq!(discovery_limit.category, "Discovery listing"); + + let foreign_currency = tally_runtime_command_error(anyhow::Error::new( + bridge_tally_protocol::native_outstandings::NativeOutstandingsError::ForeignCurrencyLedgerBalance { + ledger_name: "Synthetic FX Debtor".to_string(), + }, + )); + assert_eq!( + foreign_currency.code, + "company_foreign_currency_ledger_balance" + ); + assert_eq!(foreign_currency.category, "Tally application"); + assert!(foreign_currency.message.contains("Synthetic FX Debtor")); + assert!(foreign_currency.message.contains("foreign-currency")); } #[test] diff --git a/src-tauri/src/tally/runtime.rs b/src-tauri/src/tally/runtime.rs index 93ab966d..9e0a6bbc 100644 --- a/src-tauri/src/tally/runtime.rs +++ b/src-tauri/src/tally/runtime.rs @@ -2480,9 +2480,20 @@ mod tests { const EXTENT: &str = include_str!( "../../crates/bridge-tally-protocol/tests/fixtures/unit_a_company_extent_live.xml" ); - const CURRENCY: &str = r#"
1
0Indian Rupees
"#; + const CURRENCY: &[u8] = include_bytes!( + "../../crates/bridge-tally-protocol/tests/fixtures/currency_inr_modern_live.utf16le.xml" + ); const STATUS: &str = "TallyPrime Server is Running"; + let currency = bridge_tally_protocol::decode_tally_xml_response_bytes_limited( + CURRENCY, + "text/xml; charset=utf-16", + bridge_tally_protocol::ExpectedTallyTextEncoding::Utf16Le, + CURRENCY.len(), + ) + .expect("captured currency response decodes") + .text; + // The captured fixture predates the ALTMSTID fetch. The outstandings bracket // (`fetch_company_book_extent`) now requires that witness, so inject it into this // in-memory copy -- the committed fixture bytes are left untouched. @@ -2509,9 +2520,9 @@ mod tests { STATUS, opening_extent.as_str(), STATUS, - CURRENCY, + currency.as_str(), STATUS, - CURRENCY, + currency.as_str(), STATUS, closing_extent.as_str(), STATUS, diff --git a/src/AllClientsScreen.tsx b/src/AllClientsScreen.tsx index f0170e9b..f5339ed7 100644 --- a/src/AllClientsScreen.tsx +++ b/src/AllClientsScreen.tsx @@ -16,6 +16,7 @@ import { settleAsOfBoundValue, type AsOfBoundValue, } from "./outstandings-as-of"; +import { outstandingsCurrencySymbol } from "./outstandings-currency"; type CompanyRef = { name: string; guid: string }; @@ -54,27 +55,28 @@ function amountOf(value: string | undefined) { return Number.isFinite(parsed) ? parsed : null; } -function formatMoney(value: string) { +function formatMoney(value: string, currencyAssertion: "INR") { const negative = value.startsWith("-"); const unsigned = negative ? value.slice(1) : value; const [whole, fraction] = unsigned.split("."); const tail = whole.slice(-3); const head = whole.slice(0, -3).replace(/\B(?=(\d{2})+(?!\d))/g, ","); const grouped = head ? `${head},${tail}` : tail; - return `${negative ? "−" : ""}₹${grouped}${fraction ? `.${fraction.padEnd(2, "0")}` : ""}`; + return `${negative ? "−" : ""}${outstandingsCurrencySymbol(currencyAssertion)}${grouped}${fraction ? `.${fraction.padEnd(2, "0")}` : ""}`; } /// Compact form for a wide table: a crore figure at full precision makes every /// column unreadable, and at this altitude the reader is comparing clients, not /// reconciling paise. The exact figure is one click away on the client's own /// screen, and in the export. -function formatCompact(value: string | undefined) { +function formatCompact(value: string | undefined, currencyAssertion: "INR") { const amount = amountOf(value); if (amount === null) return "Amount unavailable"; if (amount === 0) return "—"; - if (amount >= 10_000_000) return `₹${(amount / 10_000_000).toFixed(2)} cr`; - if (amount >= 100_000) return `₹${(amount / 100_000).toFixed(2)} L`; - return `₹${Math.round(amount).toLocaleString("en-IN")}`; + const symbol = outstandingsCurrencySymbol(currencyAssertion); + if (amount >= 10_000_000) return `${symbol}${(amount / 10_000_000).toFixed(2)} cr`; + if (amount >= 100_000) return `${symbol}${(amount / 100_000).toFixed(2)} L`; + return `${symbol}${Math.round(amount).toLocaleString("en-IN")}`; } type SortKey = "client" | "receivable" | "overdue" | "unallocated" | "oldest"; @@ -366,11 +368,11 @@ export function AllClientsScreen({ config, companies, onOpenCompany, onBack, asO {row.unallocatedShare}% carries no bill reference )} - {row.complete ? formatCompact(row.exactAmounts.receivable) : "—"} + {row.complete ? formatCompact(row.exactAmounts.receivable, "INR") : "—"} 0 ? "is-overdue" : undefined}> - {row.complete ? formatCompact(row.exactAmounts.overdue) : "—"} + {row.complete ? formatCompact(row.exactAmounts.overdue, "INR") : "—"} - {row.complete ? formatCompact(row.exactAmounts.unallocated) : "—"} + {row.complete ? formatCompact(row.exactAmounts.unallocated, "INR") : "—"} {row.oldest === null ? none @@ -484,11 +486,11 @@ export function AllClientsScreen({ config, companies, onOpenCompany, onBack, asO
{group.label}Group total - {formatCompact(group.totals.receivable)} + {formatCompact(group.totals.receivable, "INR")} 0 ? "is-overdue" : undefined}> - {formatCompact(group.totals.overdue)} + {formatCompact(group.totals.overdue, "INR")} - {formatCompact(group.totals.unallocated)} + {formatCompact(group.totals.unallocated, "INR")}
{group.rows.map(renderRow)} diff --git a/src/OutstandingsScreen.tsx b/src/OutstandingsScreen.tsx index 9b493d05..daa7f062 100644 --- a/src/OutstandingsScreen.tsx +++ b/src/OutstandingsScreen.tsx @@ -3,7 +3,7 @@ import { Building2, ChevronRight, Download, RefreshCw } from "lucide-react"; import { invoke } from "@tauri-apps/api/core"; import { isNonRetryableOutstandingsBoundary, outstandingsAgeingAnchorLabel, outstandingsAgeingDisclosure, outstandingsPartialState, type OutstandingsAgeingAnchor } from "./outstandings-copy"; import { csvNumericCell, csvRow, csvTextCell, type CsvCell } from "./outstandings-csv"; -import { canStartOutstandingsRead } from "./outstandings-currency"; +import { canStartOutstandingsRead, outstandingsCurrencySymbol } from "./outstandings-currency"; import { groupOpenBillsByParty, type OpenBill, type PartyBillsState } from "./outstandings-bills"; import { asOfBoundValueForAsOf, @@ -1027,21 +1027,13 @@ function formatMoney(value: string, currencyAssertion: "INR") { const tail = whole.slice(-3); const head = whole.slice(0, -3).replace(/\B(?=(\d{2})+(?!\d))/g, ","); const grouped = head ? `${head},${tail}` : tail; - return `${negative ? "−" : ""}${currencySymbol(currencyAssertion)}${grouped}${fraction ? `.${fraction.padEnd(2, "0")}` : ""}`; + return `${negative ? "−" : ""}${outstandingsCurrencySymbol(currencyAssertion)}${grouped}${fraction ? `.${fraction.padEnd(2, "0")}` : ""}`; } function isInrCompleteResult(result: LoadResult | null): result is InrCompleteResult { return result?.state === "complete" && result.currency_assertion === "INR"; } -function currencySymbol(currencyAssertion: "INR") { - return currencyAssertion === "INR" ? "₹" : unreachableCurrencyAssertion(currencyAssertion); -} - -function unreachableCurrencyAssertion(currencyAssertion: never): never { - throw new Error(`Unsupported outstandings currency assertion: ${currencyAssertion}`); -} - function formatDate(value: string) { // `new Date("2026-07-01")` parses as UTC midnight, so west of UTC it renders // the PREVIOUS day -- and with a day-01 string, the previous MONTH. Report diff --git a/src/outstandings-currency.ts b/src/outstandings-currency.ts index bb505fa6..715111b1 100644 --- a/src/outstandings-currency.ts +++ b/src/outstandings-currency.ts @@ -6,3 +6,13 @@ export function canStartOutstandingsRead( ) { return company?.guid === inrAssertedCompanyGuid; } + +export type OutstandingsCurrencyAssertion = "INR"; + +export function outstandingsCurrencySymbol(currencyAssertion: OutstandingsCurrencyAssertion) { + return currencyAssertion === "INR" ? "₹" : unreachableCurrencyAssertion(currencyAssertion); +} + +function unreachableCurrencyAssertion(currencyAssertion: never): never { + throw new Error(`Unsupported outstandings currency assertion: ${currencyAssertion}`); +} From fcdef6ff6b43826a697ad469932afb81f9a2be2f Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sun, 23 Aug 2026 23:41:16 +0530 Subject: [PATCH 2/6] docs(tally): restore the verbatim FORMALNAME in the 9.10d incident record --- docs/tally/TALLY_PROTOCOL_REFERENCE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tally/TALLY_PROTOCOL_REFERENCE.md b/docs/tally/TALLY_PROTOCOL_REFERENCE.md index 84f09a67..fcacf8df 100644 --- a/docs/tally/TALLY_PROTOCOL_REFERENCE.md +++ b/docs/tally/TALLY_PROTOCOL_REFERENCE.md @@ -884,7 +884,7 @@ single token **`Import Data`**, there is no ``/``, and the body is the Bridge Billwise Lab 2024040120240401 - Rs.INR + Rs.Indian Rupees Yes ``` From c547610bb2aa8e165ad4d16b485901896ed0fc6c Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Mon, 24 Aug 2026 00:11:14 +0530 Subject: [PATCH 3/6] fix(outstandings): retain forex ledger sweep diagnostics --- docs/tally/TALLY_PROTOCOL_REFERENCE.md | 18 +++++++ scripts/outstandings-copy.test.mjs | 15 ++++++ src-tauri/src/commands.rs | 75 ++++++++++++++++++++++---- src-tauri/src/tally/runtime.rs | 17 +++++- src/AllClientsScreen.tsx | 9 +++- src/OutstandingsScreen.tsx | 2 + src/outstandings-copy.ts | 15 ++++++ 7 files changed, 137 insertions(+), 14 deletions(-) diff --git a/docs/tally/TALLY_PROTOCOL_REFERENCE.md b/docs/tally/TALLY_PROTOCOL_REFERENCE.md index fcacf8df..89fbf650 100644 --- a/docs/tally/TALLY_PROTOCOL_REFERENCE.md +++ b/docs/tally/TALLY_PROTOCOL_REFERENCE.md @@ -802,6 +802,24 @@ with the gateway healthy): **Batching is valid here because unknown elements are silently ignored** — a batch that still reports the same "required" error disproves every name in it at once. +#### Correction — 2026-08-23: base-currency fields are Currency-master properties + +The negative object-export result above remains valid, but its conclusion was too broad. +`TYPE=Object` / `SUBTYPE=Company` with `*` does **not** emit the base-currency +fields: they are not properties of the `Company` object. It does not establish that Tally never +exports them. + +The Currency master collection does return them. The request rendered by +`render_company_currency_request` is exactly `TYPE=Collection` with `Currency` and +fetches `NAME`, `MAILINGNAME`, and `DECIMALPLACES`. Three captures committed on this PR establish +that response shape. On the same licensed TallyPrime Silver 7.1 machine on 2026-08-23, current books +reported `I₹` (U+0049 followed by U+20B9) with `MAILINGNAME` `INR`; older books reported `Rs.` +with `MAILINGNAME` `Indian Rupees`. + +The Company Creation formal name remains a property on a different master. That boundary explains +why the 19 earlier probes, sent as `Company` children, all failed; it does not justify treating +the fields as form-local or relying on a Company object export to recover them. + ### 9.10b `ORIGINALNAME` at `COMPANY` level hangs the gateway — **TRAP** **VERIFIED 2026-07-30.** A flat combination of diff --git a/scripts/outstandings-copy.test.mjs b/scripts/outstandings-copy.test.mjs index 873c66a4..2f976e8e 100644 --- a/scripts/outstandings-copy.test.mjs +++ b/scripts/outstandings-copy.test.mjs @@ -34,6 +34,21 @@ test("new native and sweep boundaries have operator-readable reasons", () => { assert.match(outstandingsPartialReason("company_outstandings_read_failed"), /company read failed/i); }); +test("a foreign-currency ledger names the blocked book without inviting a repeat", () => { + const state = outstandingsPartialState( + "company_foreign_currency_ledger_balance", + undefined, + undefined, + "Synthetic FX Debtor", + ); + + assert.match(state.title, /not available for this company/i); + assert.match(state.message, /Synthetic FX Debtor/); + assert.match(state.message, /rather than guessing a base-currency amount/i); + assert.equal(state.retryable, false); + assert.equal(state.tallyReadAttempted, true); +}); + test("missing, empty, or zero-only counters name the unconfirmed effective-date boundary", () => { const state = outstandingsPartialState( "native_outstandings_as_of_unconfirmed_without_effective_date_evidence", diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index e183e746..0dcd4d52 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -2230,12 +2230,40 @@ pub struct CompanyOutstandingsEntry { } fn company_sweep_result( - result: Result, + result: Result, ) -> OutstandingsLoadResult { - result.unwrap_or_else(|reason_code| OutstandingsLoadResult::Partial { - reason: crate::tally::OutstandingsPartialReason::code(reason_code), + let reason = match result { + Ok(result) => return result, + Err(CompanySweepFailure::ReasonCode(reason_code)) => { + crate::tally::OutstandingsPartialReason::code(reason_code) + } + Err(CompanySweepFailure::OutstandingsRead(error)) => { + company_sweep_outstandings_partial_reason(&error) + } + }; + OutstandingsLoadResult::Partial { + reason, synced_at_unix_ms: chrono::Utc::now().timestamp_millis(), - }) + } +} + +enum CompanySweepFailure { + ReasonCode(&'static str), + OutstandingsRead(anyhow::Error), +} + +fn company_sweep_outstandings_partial_reason( + error: &anyhow::Error, +) -> crate::tally::OutstandingsPartialReason { + if let Some(NativeOutstandingsError::ForeignCurrencyLedgerBalance { ledger_name }) = error + .chain() + .find_map(|cause| cause.downcast_ref::()) + { + return crate::tally::OutstandingsPartialReason::foreign_currency_ledger_balance( + ledger_name.clone(), + ); + } + crate::tally::OutstandingsPartialReason::code("company_outstandings_read_failed") } /// Reads outstandings for several companies in one action. @@ -2266,7 +2294,7 @@ pub async fn fetch_tally_outstandings_all_companies( let mut entries = Vec::with_capacity(request.companies.len()); for entry in request.companies { let result = if validate_company_name(&entry.company).is_err() { - Err("company_selection_invalid") + Err(CompanySweepFailure::ReasonCode("company_selection_invalid")) } else { match runtime .detect_base_currency( @@ -2276,8 +2304,12 @@ pub async fn fetch_tally_outstandings_all_companies( ) .await { - Err(_) => Err("company_currency_probe_failed"), - Ok(currency) if !currency.is_inr => Err("company_base_currency_not_inr"), + Err(_) => Err(CompanySweepFailure::ReasonCode( + "company_currency_probe_failed", + )), + Ok(currency) if !currency.is_inr => Err(CompanySweepFailure::ReasonCode( + "company_base_currency_not_inr", + )), Ok(_) => runtime .fetch_outstandings( request.config.clone(), @@ -2288,7 +2320,7 @@ pub async fn fetch_tally_outstandings_all_companies( request.ageing_anchor, ) .await - .map_err(|_| "company_outstandings_read_failed"), + .map_err(CompanySweepFailure::OutstandingsRead), } }; entries.push(CompanyOutstandingsEntry { @@ -2468,7 +2500,7 @@ mod tests { company_sweep_result, first_calendar_day_canary_window, portable_export_file_name, reconcile_review_cleanup, reviewed_probe_commitment_sha256, selected_read_observation, tally_command_error, tally_runtime_command_error, validate_dsc_pins, write_unique_download, - OutstandingsRequest, PersistedTallyCompany, SavedTallySetup, + CompanySweepFailure, OutstandingsRequest, PersistedTallyCompany, SavedTallySetup, }; // Used only by the `#[cfg(unix)]` non-UTF-8 destination test — an invalid-byte // path cannot be constructed portably. The import must carry the same gate as @@ -2480,6 +2512,7 @@ mod tests { SelectedReadObservation, TallyCompany, TallyProbeResult, TallyProduct, }; use bridge_tally_core::CapabilityProfile; + use bridge_tally_protocol::native_outstandings::NativeOutstandingsError; use std::collections::BTreeMap; /// Regression for the destination-picker leak: `select_party_statement_ @@ -2553,8 +2586,12 @@ mod tests { reason: crate::tally::OutstandingsPartialReason::code("first_book_partial"), synced_at_unix_ms: 1, }), - Err("company_currency_probe_failed"), - Err("company_outstandings_read_failed"), + Err(CompanySweepFailure::ReasonCode( + "company_currency_probe_failed", + )), + Err(CompanySweepFailure::ReasonCode( + "company_outstandings_read_failed", + )), Ok(OutstandingsLoadResult::Partial { reason: crate::tally::OutstandingsPartialReason::code("last_book_partial"), synced_at_unix_ms: 2, @@ -2586,6 +2623,22 @@ mod tests { )); } + #[test] + fn company_sweep_preserves_foreign_currency_ledger_diagnostic() { + let outcome = company_sweep_result(Err(CompanySweepFailure::OutstandingsRead( + anyhow::Error::new(NativeOutstandingsError::ForeignCurrencyLedgerBalance { + ledger_name: "Synthetic FX Debtor".to_string(), + }), + ))); + + assert!(matches!( + outcome, + OutstandingsLoadResult::Partial { reason, .. } + if reason.reason_code == "company_foreign_currency_ledger_balance" + && reason.foreign_currency_ledger_name.as_deref() == Some("Synthetic FX Debtor") + )); + } + #[test] fn export_names_are_portable_and_reserved_devices_are_neutralized() { assert_eq!( diff --git a/src-tauri/src/tally/runtime.rs b/src-tauri/src/tally/runtime.rs index 9e0a6bbc..7e4533dc 100644 --- a/src-tauri/src/tally/runtime.rs +++ b/src-tauri/src/tally/runtime.rs @@ -152,8 +152,8 @@ pub enum OutstandingsLoadResult { /// A machine-readable reason for withholding outstandings totals. The stable /// `reason_code` serialization stays compatible with the frontend while the -/// refused-period variant carries both dates as separate, typed fields rather -/// than asking presentation code to parse a message. +/// exceptional variants carry their diagnostic values as separate, typed +/// fields rather than asking presentation code to parse a message. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct OutstandingsPartialReason { pub reason_code: String, @@ -161,6 +161,8 @@ pub struct OutstandingsPartialReason { pub requested_as_of_yyyymmdd: Option, #[serde(skip_serializing_if = "Option::is_none")] pub tally_as_of_yyyymmdd: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub foreign_currency_ledger_name: Option, } impl OutstandingsPartialReason { @@ -169,6 +171,7 @@ impl OutstandingsPartialReason { reason_code: reason_code.into(), requested_as_of_yyyymmdd: None, tally_as_of_yyyymmdd: None, + foreign_currency_ledger_name: None, } } @@ -177,6 +180,16 @@ impl OutstandingsPartialReason { reason_code: "native_outstandings_as_of_refused".to_string(), requested_as_of_yyyymmdd: Some(requested_as_of.clone()), tally_as_of_yyyymmdd: Some(tally_as_of.clone()), + foreign_currency_ledger_name: None, + } + } + + pub fn foreign_currency_ledger_balance(ledger_name: String) -> Self { + Self { + reason_code: "company_foreign_currency_ledger_balance".to_string(), + requested_as_of_yyyymmdd: None, + tally_as_of_yyyymmdd: None, + foreign_currency_ledger_name: Some(ledger_name), } } } diff --git a/src/AllClientsScreen.tsx b/src/AllClientsScreen.tsx index f5339ed7..c1ce8436 100644 --- a/src/AllClientsScreen.tsx +++ b/src/AllClientsScreen.tsx @@ -45,6 +45,7 @@ type LoadResult = reason_code: string; requested_as_of_yyyymmdd?: string; tally_as_of_yyyymmdd?: string; + foreign_currency_ledger_name?: string; }; type Entry = { company: string; company_guid: string; result: LoadResult }; @@ -239,6 +240,7 @@ export function AllClientsScreen({ config, companies, onOpenCompany, onBack, asO reasonCode: entry.result.state === "partial" ? entry.result.reason_code : null, requestedAsOf: entry.result.state === "partial" ? entry.result.requested_as_of_yyyymmdd : undefined, tallyAsOf: entry.result.state === "partial" ? entry.result.tally_as_of_yyyymmdd : undefined, + foreignCurrencyLedgerName: entry.result.state === "partial" ? entry.result.foreign_currency_ledger_name : undefined, receivable: complete ? amountOf(complete.report.receivable_total) : null, overdue: complete ? amountOf(complete.report.ageing.days_90_plus) : null, unallocated: complete ? amountOf(complete.unallocated_total) : null, @@ -340,7 +342,12 @@ export function AllClientsScreen({ config, companies, onOpenCompany, onBack, asO const renderRow = (row: (typeof rows)[number]) => { const partial = row.reasonCode - ? outstandingsPartialState(row.reasonCode, row.requestedAsOf, row.tallyAsOf) + ? outstandingsPartialState( + row.reasonCode, + row.requestedAsOf, + row.tallyAsOf, + row.foreignCurrencyLedgerName, + ) : null; return (