diff --git a/docs/tally/TALLY_PROTOCOL_REFERENCE.md b/docs/tally/TALLY_PROTOCOL_REFERENCE.md index fcacf8d..89fbf65 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/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 52593d7..e728a68 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": "c0d63b2ac1014e421c32f53b2087176901a3fbec3c59f52c1e91fccb6dc364f9", + "compatibility_surface_sha256": "7e034f2dc2438952c9d6b0fd842ea4a3627f54d5f805adb5483aa40b5caf3e6c", "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 617e716..2f824b5 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -83,7 +83,7 @@ }, { "path": "scripts/outstandings-copy.test.mjs", - "sha256": "0f9b53d4b3e352fe5fff3c909cea5d10336cc07dc6db817a05761e2afc191463" + "sha256": "1fc0bc399a7ca6e70b1e8ba5dff83f40d214a8ca256e928d7c39979fd42fac98" }, { "path": "scripts/tally-company-selection.test.mjs", @@ -159,11 +159,11 @@ }, { "path": "src-tauri/crates/bridge-tally-protocol/src/native_outstandings/request.rs", - "sha256": "92d77ef47933a52510eb186768f550546b36de385eb15b1d019d065346d72bb6" + "sha256": "b909d3f9eb620c50365341528c1211568afa7690a3442653993f65a334438ec3" }, { "path": "src-tauri/crates/bridge-tally-protocol/src/native_outstandings/wire.rs", - "sha256": "266d4f04ac46452213e5dcfe41693272115c0f18704632119e2315dfca5ac4b8" + "sha256": "bf74bb87d9579ee43eca8b825ce05ab7f565ddc67262ac76fede6a4fa8fb47a6" }, { "path": "src-tauri/crates/bridge-tally-protocol/src/outstandings/completeness.rs", @@ -247,7 +247,7 @@ }, { "path": "src-tauri/src/commands.rs", - "sha256": "b0f1607b947b13066bb9030632bfe9f0c05ea9d710b007c4e4ff37a2a9ecd25c" + "sha256": "2feef66eaeff0eed11d4cc544e0d343fa7b023e9382add210ce79f134376ac1c" }, { "path": "src-tauri/src/db/encrypted.rs", @@ -371,7 +371,7 @@ }, { "path": "src-tauri/src/tally/runtime.rs", - "sha256": "760394d90156acb720a2a377959a8c356d5fc4061612b9e4d2d772ce374631a2" + "sha256": "cc1a81ddca0afd4bea56cee465cff19dc5c11d67f5b30d57619e773cc857aad4" }, { "path": "src-tauri/src/tally/serial_queue.rs", @@ -395,11 +395,11 @@ }, { "path": "src/AllClientsScreen.tsx", - "sha256": "f4cd60e3e777c71daa5eff330574a1988606f012da92133fd4b4f5ed8a27d8b7" + "sha256": "a376a574481347427ca986001effa2a3687bacf8f3082f4feaa2466818a505a8" }, { "path": "src/OutstandingsScreen.tsx", - "sha256": "d743d03cc9eac7223a1d8dae807463aaf6505c2244c2e09d8680b8db6ef300f9" + "sha256": "75a80eb258ebb78070b519637d5a8a62861d630554f652a064ee7764d48f37c0" }, { "path": "src/TallyReadinessFlow.tsx", @@ -415,7 +415,7 @@ }, { "path": "src/outstandings-copy.ts", - "sha256": "61811b284283bd3f7c0b8744c16fbc537295aa07bf5add3822cb85f775f8ee17" + "sha256": "ccf31b5ec13f731b272e4c1805bb4a6cd2b668276a5760ac58ec520cc2780ed5" }, { "path": "src/outstandings-csv.ts", @@ -478,5 +478,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "c0d63b2ac1014e421c32f53b2087176901a3fbec3c59f52c1e91fccb6dc364f9" + "manifest_sha256": "7e034f2dc2438952c9d6b0fd842ea4a3627f54d5f805adb5483aa40b5caf3e6c" } diff --git a/scripts/outstandings-copy.test.mjs b/scripts/outstandings-copy.test.mjs index 873c66a..cab47f4 100644 --- a/scripts/outstandings-copy.test.mjs +++ b/scripts/outstandings-copy.test.mjs @@ -31,9 +31,28 @@ test("new native and sweep boundaries have operator-readable reasons", () => { ); assert.match(outstandingsPartialReason("company_currency_probe_failed"), /base currency/i); assert.match(outstandingsPartialReason("company_base_currency_not_inr"), /not INR/i); + assert.match( + outstandingsPartialReason("company_base_currency_undetermined"), + /multiple Currency masters.*confirm INR manually.*single-company read.*foreign-currency ledger/i, + ); 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/crates/bridge-tally-protocol/src/native_outstandings/model.rs b/src-tauri/crates/bridge-tally-protocol/src/native_outstandings/model.rs index b431140..43c4a94 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 9a5a3be..fb53fae 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 b936121..1f0a9b1 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" + )); + const FOREX_COMPOSITE_LIVE: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/ledgers_forex_composite_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 +1191,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 +1212,51 @@ 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 captured_forex_composite_closing_balance_names_the_ledger_without_parsing_it() { + assert_eq!( + sha256_hex(FOREX_COMPOSITE_LIVE), + "4941f30826ec51da9ab1c834abb1abcd711ffec22464044d5c669b77aaa313f8", + "captured wire bytes changed" + ); + let xml = decode_utf16le(FOREX_COMPOSITE_LIVE); + assert_eq!(xml.matches("` containers because the other 24 are empty +counterpart-ledger entries with no `BILLTYPE`; counting containers as allocations would double the +actual populated rows. 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 0000000..7420c70 --- /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/FOREX_LEDGER_CAPTURE_PROVENANCE.md b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/FOREX_LEDGER_CAPTURE_PROVENANCE.md new file mode 100644 index 0000000..93b5d2a --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/FOREX_LEDGER_CAPTURE_PROVENANCE.md @@ -0,0 +1,56 @@ +# `ledgers_forex_composite_live` — provenance + +The composite foreign-currency `CLOSINGBALANCE` shape, captured rather than constructed. + +Before this file, the only occurrence of that shape in the repository was a hand-authored string in +`native_outstandings/wire.rs`. It **did not match what Tally emits** — measured 2026-08-24: + +``` +Tally: '-$ 2000.00 @ I₹ 84/$ = -I₹ 168000.00' <- two spaces before '=' +authored: '-$ 2000.00 @ I₹ 84/$ = -I₹ 168000.00' <- one +``` + +The classifier accepts both — it splits on `@` and `=` and trims — so no defect shipped. But the +authored fixture could never have detected a spacing, qualification, or encoding mismatch, because it +*was* the mismatch. That is the reason this capture exists. + +## Provenance + +- **Host / gateway:** TallyPrime **Silver (licensed)** 7.1, `http://localhost:9001` +- **Date:** 2026-08-24 +- **Company:** `BRIDGE CORPUS FOREX` (synthetic; base INR, one `$` currency master) +- **Request:** the production `List of Ledgers` collection — + `FETCH NAME, PARENT, CLOSINGBALANCE, OPENINGBALANCE, ISBILLWISEON`, `SVFROMDATE 20250401`, + `SVTODATE 20250730` (the book's own extent) +- **Encoding:** BOM-less UTF-16LE, undecoded wire bytes. `.gitattributes` marks this tree `-text`. +- **`/status`:** healthy before and after, gated (aborts rather than proceeding on an unhealthy check) + +| file | bytes | sha256 | +|---|---|---| +| `ledgers_forex_composite_live.utf16le.xml` | 10,580 | `4941f30826ec51da9ab1c834abb1abcd711ffec22464044d5c669b77aaa313f8` | + +`STATUS 1`, 8 ledgers, exactly one of which carries a composite balance. + +## What it establishes + +Fed to the production `parse_native_ledger_snapshot`, these bytes produce: + +``` +Err(ForeignCurrencyLedgerBalance { ledger_name: "FX USD Debtor 02" }) +"Tally reported a foreign-currency closing balance for ledger FX USD Debtor 02" +``` + +So the typed diagnostic and the ledger name are confirmed against real wire bytes, not against a +string written to match the parser. + +**Bridge still fails closed here, deliberately.** No attempt is made to parse the composite string +or to fall back to its trailing base-currency figure. `BILLCL` on the same bill remains a plain +decimal; only the ledger balance changes shape. + +## Known limits + +- One release (7.1), one machine, one currency pair (`$` against `I₹`). +- Only a *negative* composite balance appears. A positive one, and a ledger whose foreign balance is + zero, are not represented. +- The seven other ledgers in this book carry ordinary decimal balances, so the capture also serves as + its own control: the classifier must reject exactly one row, not all eight. 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 0000000..1eb0bdf Binary files /dev/null and b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/currency_inr_legacy_live.utf16le.xml differ 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 0000000..c78b298 Binary files /dev/null and b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/currency_inr_modern_live.utf16le.xml differ 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 0000000..4b5c9a5 Binary files /dev/null and b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/currency_multi_live.utf16le.xml differ diff --git a/src-tauri/crates/bridge-tally-protocol/tests/fixtures/ledgers_forex_composite_live.utf16le.xml b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/ledgers_forex_composite_live.utf16le.xml new file mode 100644 index 0000000..db4753e Binary files /dev/null and b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/ledgers_forex_composite_live.utf16le.xml differ diff --git a/src-tauri/crates/bridge-tally-protocol/tests/fixtures/vouchers_agst_ref_reopen_live.utf16le.xml b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/vouchers_agst_ref_reopen_live.utf16le.xml new file mode 100644 index 0000000..d451eac Binary files /dev/null and b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/vouchers_agst_ref_reopen_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 b2479f8..03e7a5a 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,99 @@ 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, "20240401", "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 allocations = 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)) + }) + .collect::>(); + assert_eq!(xml.matches("").count(), 48); + assert_eq!( + allocations.len(), + 24, + "only populated allocation rows parse" + ); + assert_eq!( + allocations + .iter() + .filter(|(_, allocation)| allocation.bill_type == BillReferenceKind::NewRef) + .count(), + 12 + ); + let agst_refs = allocations + .into_iter() + .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 +1410,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 +1446,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 284b387..e00df6d 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( @@ -2214,12 +2230,56 @@ 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") +} + +fn company_sweep_currency_preflight_failure( + currency_count: usize, + is_inr: bool, +) -> Option<&'static str> { + if currency_count > 1 { + return Some("company_base_currency_undetermined"); + } + if currency_count == 1 && !is_inr { + return Some("company_base_currency_not_inr"); + } + if currency_count == 1 { + return None; + } + Some("company_currency_probe_failed") } /// Reads outstandings for several companies in one action. @@ -2250,7 +2310,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( @@ -2260,19 +2320,26 @@ 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"), - Ok(_) => runtime - .fetch_outstandings( - request.config.clone(), - entry.company.clone(), - entry.expected_company_guid.clone(), - as_of.clone(), - request.currency_assertion, - request.ageing_anchor, - ) - .await - .map_err(|_| "company_outstandings_read_failed"), + Err(_) => Err(CompanySweepFailure::ReasonCode( + "company_currency_probe_failed", + )), + Ok(currency) => match company_sweep_currency_preflight_failure( + currency.currency_count, + currency.is_inr, + ) { + Some(reason_code) => Err(CompanySweepFailure::ReasonCode(reason_code)), + None => runtime + .fetch_outstandings( + request.config.clone(), + entry.company.clone(), + entry.expected_company_guid.clone(), + as_of.clone(), + request.currency_assertion, + request.ageing_anchor, + ) + .await + .map_err(CompanySweepFailure::OutstandingsRead), + }, } }; entries.push(CompanyOutstandingsEntry { @@ -2449,9 +2516,10 @@ pub async fn select_document_folder() -> Result, #[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), } } } @@ -2480,9 +2493,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 +2533,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 f0170e9..c1ce843 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 }; @@ -44,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 }; @@ -54,27 +56,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"; @@ -237,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, @@ -338,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 (