diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2c2940b..ecb0e10f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -292,17 +292,6 @@ jobs: Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value '### Windows native prerequisites' Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value '- Strawberry Perl cache hit: `${{ steps.windows-native-prerequisites.outputs.cache-hit }}`' - run: cargo check --locked --manifest-path src-tauri/Cargo.toml --workspace - - name: Check sealed canary runtime dispatch feature - working-directory: src-tauri - run: >- - cargo check --locked -p bridge --lib - --features fixture-canary-runtime-dispatch - - name: Test sealed canary runtime dispatch feature - working-directory: src-tauri - run: >- - cargo test --locked -p bridge --lib - --features fixture-canary-runtime-dispatch - sealed - run: cargo test --locked --manifest-path src-tauri/Cargo.toml --workspace - name: Test isolated native outstandings qualification feature working-directory: tools @@ -311,12 +300,6 @@ jobs: --features bills-native-outstandings-probe-runner --all-targets -- --test-threads=1 - run: cargo clippy --locked --manifest-path src-tauri/Cargo.toml --workspace --all-targets -- -D warnings - - name: Lint sealed canary runtime dispatch feature - working-directory: src-tauri - run: >- - cargo clippy --locked -p bridge --lib - --features fixture-canary-runtime-dispatch - -- -D warnings - name: Lint isolated native outstandings qualification feature working-directory: tools run: >- diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index fedbb3c0..428842fc 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": "2a1390a8607b5c8c09e362b3dea6c53bf7bbb5490a176f1d0a4a323501f70a59", + "compatibility_surface_sha256": "2c8e7e0069687f9da172d78f1211a80addf6fead80dd3d10ce2237d97d68370d", "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 38c523c5..debd8981 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -3,7 +3,7 @@ "files": [ { "path": ".github/workflows/ci.yml", - "sha256": "51a383c33594f9e884c1ff0c1f9af9f378683707b7ea3903a7e6205ff2521ba3" + "sha256": "61c283554b1e95eac6368a1aaeb6fd6691cfce9c6060aa93bb2949d281f36bad" }, { "path": ".github/workflows/dependency-security.yml", @@ -63,7 +63,7 @@ }, { "path": "docs/tally/compatibility/synthetic-write-canary-fixture.md", - "sha256": "dd2f1c68c0925523af1468dd9c61330433130c0e72b7c713dfc1ef9205b4756f" + "sha256": "9ed85e60adb7306f11496b47f5a86d49327abfdd8e2735678c521187b9ce76e4" }, { "path": "docs/tally/support-matrix.md", @@ -99,7 +99,7 @@ }, { "path": "src-tauri/Cargo.toml", - "sha256": "5134ac0c55a29f4357d2482cc5510892300974f570754968fe784a8ad14c143c" + "sha256": "7169cc1c3f6f75304daca1eea5b3d57237d88ad2575a3debc4acaf57cce23e09" }, { "path": "src-tauri/crates/bridge-tally-canonical/Cargo.toml", @@ -151,7 +151,7 @@ }, { "path": "src-tauri/crates/bridge-tally-protocol/Cargo.toml", - "sha256": "cb144c8de2662814723603c0a58e23c1cfac74a49fde3c392fceb51af0b549db" + "sha256": "2020a451726b713a636816e906ab494ee5529284c3f52126b1d7c9960f594efb" }, { "path": "src-tauri/crates/bridge-tally-protocol/src/bills_native_outstandings_probe.rs", @@ -163,7 +163,7 @@ }, { "path": "src-tauri/crates/bridge-tally-protocol/src/lib.rs", - "sha256": "7d69f93c0ebc423151f97ae3f9a3b51106a56b3c3ae561b35a482026ef9ace7f" + "sha256": "e1c9082a214a125454c2bbddef8494283d41efa025e4acfc1525f0a22aa2bd1e" }, { "path": "src-tauri/crates/bridge-tally-protocol/src/outstandings/completeness.rs", @@ -171,23 +171,23 @@ }, { "path": "src-tauri/crates/bridge-tally-protocol/src/outstandings/model.rs", - "sha256": "2964513144868895680ec125c93e22947063652b7cc414e81a4e40ca88d849db" + "sha256": "6164b99d9765009353f5e7807ea483ba80909bf981e79a9f73a50f10601c9a59" }, { "path": "src-tauri/crates/bridge-tally-protocol/src/outstandings/parser.rs", - "sha256": "0f682aec36689a2e28602ea7257913106ca8bb5346a8edc725d147ee30f6440b" + "sha256": "be3c2e63a531b63a39c8fc2a072b6c0cd3f3327bcda6268b37a224a7ea163b3c" }, { "path": "src-tauri/crates/bridge-tally-protocol/src/outstandings/request.rs", - "sha256": "400a3e97cd283fe1a6affebc0a235d2a6a1731e736747df728499dd0cd304f4f" + "sha256": "07818e208476f2b81d452643cda7383750c0b573b7350e46ae5a842f7f530b27" }, { "path": "src-tauri/crates/bridge-tally-protocol/src/outstandings/wire.rs", - "sha256": "37d1ce2de6edf1daa841715b96a44ef294dfe992b57fedf275b50eb63f93570f" + "sha256": "c181efb3ac013543a40fe78a6e1fb900021ca25c443d034cda49f2639f8f9006" }, { "path": "src-tauri/crates/bridge-tally-protocol/src/xml_read_profiles.rs", - "sha256": "9fff9b46f22be5feb4b14efed80ca79bbdc0aec09d80c649592c640f3abc5b15" + "sha256": "c6a77734b40e8c1b492a46484e3384d335f132d5434d6c53e70d2237c2b80252" }, { "path": "src-tauri/crates/bridge-tally-protocol/tests/fixtures/unit_a_optional_voucher_live.xml", @@ -195,7 +195,7 @@ }, { "path": "src-tauri/crates/bridge-tally-protocol/tests/simulator_corpus.rs", - "sha256": "553becfc2c38b31b5d50125316493e3d16c617b0cc7657fbb92c155266e28efa" + "sha256": "616eb8fa5e387bff74f62d8e775b7eb118860763b18ff63ad88ad5015f7f752f" }, { "path": "src-tauri/crates/bridge-tally-protocol/tests/stream_text_decoder.rs", @@ -211,11 +211,11 @@ }, { "path": "src-tauri/crates/bridge-tally-transport/Cargo.toml", - "sha256": "67d961f5b7352058db9790f2e690fc07fa3d440b62b094d8cc3e1663bf92b69b" + "sha256": "099ce34a52d4f3719c75dd053ef3e3c5bd8d2db1fd8587d43c1afe9ffa4d74ca" }, { "path": "src-tauri/crates/bridge-tally-transport/src/lib.rs", - "sha256": "5b3080d664ccc85f264e99c7bdc0f8d0e23e46036ad25c787b7b0f4d8439671c" + "sha256": "9b010fcb54b7646bb78bc1ca184eff818e633cae17d051831a0b25833d498f8e" }, { "path": "src-tauri/crates/bridge-tally-transport/tests/http_transport.rs", @@ -259,7 +259,7 @@ }, { "path": "src-tauri/src/commands.rs", - "sha256": "5e18b9ed4fac6622b59b4078936832cb5766411b0b3c229f530a5129e8b6efc2" + "sha256": "9ec65e68c073cc113510a5cfb11b7b973e0f8a5cc50e8ceb98954a3304e99ab4" }, { "path": "src-tauri/src/db/encrypted.rs", @@ -343,7 +343,7 @@ }, { "path": "src-tauri/src/lib.rs", - "sha256": "c84dae31b363abcee38abeac37d2e53e9d50fb76da1f4af67068351d0b7cb213" + "sha256": "5856c632b3b1c51a3d4e7b6cd408503348c487b653abe32ce71f0e5d18c6e34b" }, { "path": "src-tauri/src/sync/coordinator.rs", @@ -383,15 +383,15 @@ }, { "path": "src-tauri/src/tally/connection.rs", - "sha256": "5b3fd134988c44a4847daf3ae7396b7cc5c300ea27189307d09fcba148c1e1bb" + "sha256": "b93aa0dafbe8acadbeea5efce8919ffe3d7f24d2b306f14e3e6d0ee103e4dc53" }, { "path": "src-tauri/src/tally/connector.rs", - "sha256": "665f3c4c8549048718b55786dc5d6f93caf251e973abc95d9ab79a3f7937dcd0" + "sha256": "d9a96b3c04d13461494961fba09ae245c5e43e78df329b95e61f7d3eeb8c2f30" }, { "path": "src-tauri/src/tally/mod.rs", - "sha256": "0d44e34bfbf6443a59cbf1ddc8a18b990b5d0d997bc63a61c5f1cf08f84c0b6b" + "sha256": "d606e076d0e6e0faf07e0476ca173893cc36ea6b644a41148c4b67c0801b2d46" }, { "path": "src-tauri/src/tally/outstandings_runtime.rs", @@ -399,7 +399,7 @@ }, { "path": "src-tauri/src/tally/runtime.rs", - "sha256": "26c4547a2884b90486687aaee61d030f655215d0759d12016de4081c6952d9ca" + "sha256": "2d2ce6d7d59b4b7e7286ca0f583f87604fa86cd1a1eed1c5353f7ae487152b55" }, { "path": "src-tauri/src/tally/serial_queue.rs", @@ -494,5 +494,5 @@ "sha256": "ef1d33e90da527faa9735469ea5040c9258fbab9e4c761f2473d79e7b3dbd0c4" } ], - "manifest_sha256": "2a1390a8607b5c8c09e362b3dea6c53bf7bbb5490a176f1d0a4a323501f70a59" + "manifest_sha256": "2c8e7e0069687f9da172d78f1211a80addf6fead80dd3d10ce2237d97d68370d" } diff --git a/docs/tally/compatibility/synthetic-write-canary-fixture.md b/docs/tally/compatibility/synthetic-write-canary-fixture.md index 77c2abea..4da3f647 100644 --- a/docs/tally/compatibility/synthetic-write-canary-fixture.md +++ b/docs/tally/compatibility/synthetic-write-canary-fixture.md @@ -30,17 +30,21 @@ payload-commitment capsule. Sealing consumes the non-cloneable prepared canary, so one prepared instance cannot yield a second capsule; the capsule has no raw XML accessor or callback escape hatch. -The separately disabled runtime-dispatch feature adds one constrained internal -sequence: it derives the fixed canary only from an enrolled local company pin, +The lower-level write crate retains a separately disabled runtime-dispatch +feature for its sealed coordinator tests. The application manifest deliberately +does not forward that feature, so no supported Bridge application build exposes +a canary Tauri command or a runtime path that can send the canary. The dormant +coordinator derives the fixed canary only from an enrolled local company pin, performs the exact one-time preflight read, repeats durable admission, and then consumes the capsule once to POST through Bridge's bounded loopback transport. Its raw request and response remain sealed, it has no generic payload API, -retry loop, persistence hook, or UI route. Only a build with the explicit -non-default `fixture-canary-runtime-dispatch` feature exposes one Tauri command; -that command accepts no payload, XML, target override, retry choice, evidence, -or digest and returns only a final-verdict identifier and timestamp. It rechecks -an explicit disposable-fixture acknowledgement and backup acknowledgement before -starting the sealed sequence. The canonical loopback origin must match the +retry loop, persistence hook, or UI route. Dormant application source includes +a command adapter behind the same undeclared application feature; it is not +part of any manifest-supported build. That adapter accepts no payload, XML, +target override, retry choice, evidence, or digest and would return only a +final-verdict identifier and timestamp. The coordinator rechecks an explicit +disposable-fixture acknowledgement and backup acknowledgement before starting +the sealed sequence. The canonical loopback origin must match the enrolled source pin before the one-time reservation, and is rechecked from the prepared fixture before preflight and is revalidated on the exclusive dispatch lease immediately before import. The coordinator claims durable exact preflight diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c14e2292..ff15f229 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -11,14 +11,17 @@ rust-version = "1.96" default-run = "bridge" [features] -voucher-scan = [] -# Deliberately disabled in shipped builds. This feature enables the separately -# reviewed, one-send synthetic-canary command boundary and never a generic write API. -fixture-canary-dispatch-seam = [] -fixture-canary-runtime-dispatch = ["fixture-canary-dispatch-seam"] +# Legacy voucher-scan outstandings path. Shipped/default builds take the +# native Bills Receivable/Payable + List of Ledgers path unconditionally; +# this gate exists so the ~4,800 lines of scan machinery (date/AlterID +# partitioning, segment completeness proofs, the wildcard voucher fetch) do +# not compile into a shipped build at all. Not a default feature. +voucher-scan = ["bridge-tally-protocol/voucher-scan", "bridge-tally-transport/voucher-scan"] # Manual-only admission for the ignored, corpus-bound Unit A reconciliation # harness. Shipped/default builds have no constructor for an AlterID width. -live-calibration-harness = [] +# The harness is what actually drives a voucher scan, so it implies the scan +# machinery it needs. +live-calibration-harness = ["voucher-scan"] [workspace] members = [ diff --git a/src-tauri/crates/bridge-tally-protocol/Cargo.toml b/src-tauri/crates/bridge-tally-protocol/Cargo.toml index 73f8234c..969c673b 100644 --- a/src-tauri/crates/bridge-tally-protocol/Cargo.toml +++ b/src-tauri/crates/bridge-tally-protocol/Cargo.toml @@ -15,6 +15,12 @@ jsonex-request-builder = ["dep:serde_json"] india-tax-observation-parser = [] bills-payments-observation-parser = [] bills-native-outstandings-probe = [] +# Legacy voucher-scan outstandings path (`outstandings` module): reads every +# voucher in a date/AlterID-partitioned wildcard scan and derives outstandings +# from bill allocations. Superseded by the native Bills Receivable/Payable + +# List of Ledgers path (`native_outstandings`, always compiled). Not a +# default feature: no shipped build takes this path today. +voucher-scan = [] [dependencies] anyhow = "1" diff --git a/src-tauri/crates/bridge-tally-protocol/src/lib.rs b/src-tauri/crates/bridge-tally-protocol/src/lib.rs index 06519d5e..e10460b3 100644 --- a/src-tauri/crates/bridge-tally-protocol/src/lib.rs +++ b/src-tauri/crates/bridge-tally-protocol/src/lib.rs @@ -25,7 +25,22 @@ pub mod india_tax_observation; pub mod jsonex; #[cfg(feature = "jsonex-request-builder")] pub mod jsonex_request; +pub mod native_outstandings; +/// The legacy voucher-scan outstandings path: date/AlterID-partitioned +/// wildcard voucher fetch, segment/witness completeness proofs, and bill +/// computation from voucher allocations. Superseded by `native_outstandings`, +/// which is always compiled. Gated because it cannot execute in a shipped +/// build: the only non-test constructor for its width calibration is behind +/// `live-calibration-harness`, which implies this feature. +#[cfg(feature = "voucher-scan")] pub mod outstandings; +/// The outstandings report contract and the company-identity/book-extent +/// read shared by `outstandings` and `native_outstandings`. Deliberately +/// ungated: `native_outstandings` depends on it, so gating it with +/// `outstandings` would break the always-on native path. See the module docs +/// for why this is scoped the way it is. +pub mod outstandings_shared; +mod tolerant_xml; pub mod xml_read_profiles; pub const BRIDGE_LEDGER_EXPORT_SCHEMA: &str = "bridge.tally.ledgers/1"; @@ -782,6 +797,22 @@ pub fn parse_companies_for_interactive_discovery(xml: &str) -> anyhow::Result` +/// rows, each carrying a nested `` element). Unlike +/// `parse_companies_for_interactive_discovery`, this requires the ordinary +/// `HEADER/STATUS=1` export success envelope — the trust check is satisfied, +/// not bypassed. +/// +/// Company rows are read only from beneath `ENVELOPE/BODY/DATA/COLLECTION`. +/// A real response also carries a `BODY/DESC/CMPINFO` block of bare counter +/// elements, including a `0` row count; because that block +/// sits outside `DATA`, it is never mistaken for a company row. +pub fn parse_companies_from_collection(xml: &str) -> anyhow::Result> { + validate_export_response(xml)?; + parse_company_collection_rows(xml) +} + /// Validates the fixed, documented `List of Ledgers` collection used only to /// bootstrap a scoped company identity on responders that reject Bridge's /// custom report profile. Ledger names, balances, and identities are inspected @@ -1186,6 +1217,122 @@ fn parse_company_rows_with_limit( Ok(records) } +/// Reads `` rows strictly from beneath `ENVELOPE/BODY/DATA/COLLECTION`. +/// This scoping is deliberate: `BODY/DESC/CMPINFO` also carries a bare +/// `0` object counter, and scanning for the element name +/// anywhere in the document would misread that counter as a company row. +fn parse_company_collection_rows(xml: &str) -> anyhow::Result> { + let mut reader = configured_reader(xml); + let mut path = Vec::>::new(); + let mut collection_seen = false; + let mut records = Vec::new(); + loop { + match reader.read_event()? { + Event::Start(element) + if path_eq(&path, &[b"ENVELOPE", b"BODY", b"DATA", b"COLLECTION"]) + && element.name().as_ref().eq_ignore_ascii_case(b"COMPANY") => + { + if records.len() >= MAX_INTERACTIVE_DISCOVERY_COMPANIES { + anyhow::bail!( + "company collection exceeded the safe row limit: Tally returned more than {MAX_INTERACTIVE_DISCOVERY_COMPANIES} companies" + ); + } + records.push(parse_company_collection_row(&mut reader, &element)?); + } + Event::Empty(element) + if path_eq(&path, &[b"ENVELOPE", b"BODY", b"DATA", b"COLLECTION"]) + && element.name().as_ref().eq_ignore_ascii_case(b"COMPANY") => + { + anyhow::bail!("company collection omitted the company GUID"); + } + Event::Start(element) => { + if path_eq(&path, &[b"ENVELOPE", b"BODY", b"DATA"]) + && element.name().as_ref().eq_ignore_ascii_case(b"COLLECTION") + { + collection_seen = true; + } + path.push(element.name().as_ref().to_ascii_uppercase()); + } + Event::Empty(element) + if path_eq(&path, &[b"ENVELOPE", b"BODY", b"DATA"]) + && element.name().as_ref().eq_ignore_ascii_case(b"COLLECTION") => + { + collection_seen = true; + } + Event::End(element) => pop_expected_path(&mut path, element.name().as_ref())?, + Event::Eof => break, + _ => {} + } + } + if !path.is_empty() { + anyhow::bail!("company collection response ended before its root closed"); + } + if !collection_seen { + anyhow::bail!("company collection response omitted BODY/DATA/COLLECTION"); + } + Ok(records) +} + +fn parse_company_collection_row( + reader: &mut Reader<&[u8]>, + element: &quick_xml::events::BytesStart<'_>, +) -> anyhow::Result { + validate_only_attributes(element, &[b"NAME", b"RESERVEDNAME"])?; + let name = attr_value(reader, element, b"NAME") + .map(|value| normalized_standard_value(&value, "company name")) + .transpose()? + .ok_or_else(|| anyhow::anyhow!("company collection omitted the company name"))?; + let row_name = element.name().as_ref().to_ascii_uppercase(); + let mut guid = None::; + loop { + match reader.read_event()? { + Event::Start(child) if child.name().as_ref().eq_ignore_ascii_case(b"GUID") => { + validate_only_attributes(&child, &[b"TYPE"])?; + if guid + .replace(normalized_standard_value( + &read_required_text(reader, child.name())?, + "company GUID", + )?) + .is_some() + { + anyhow::bail!("company collection repeated the company GUID"); + } + } + Event::Empty(child) if child.name().as_ref().eq_ignore_ascii_case(b"GUID") => { + anyhow::bail!("company collection contained an empty company GUID"); + } + Event::End(end) if end.name().as_ref().eq_ignore_ascii_case(&row_name) => break, + // Tally echoes the company name as a CHILD element as well as the + // row attribute, and may carry other descriptive fields. Skipping + // an unrecognised child is safe here because identity comes from + // the NAME attribute and the GUID element, both of which are + // required below -- whereas rejecting the row outright made every + // real response unparseable while a hand-written fixture passed. + // (Measured 2026-08-07: the live row is + // ``.) + Event::Start(child) => { + let name = child.name().as_ref().to_vec(); + reader.read_to_end(quick_xml::name::QName(&name).to_owned())?; + } + Event::Empty(_) => {} + Event::Text(text) if !text.decode()?.trim().is_empty() => { + anyhow::bail!("company collection row contained unexpected text") + } + Event::CData(_) | Event::DocType(_) | Event::PI(_) => { + anyhow::bail!("company collection row contained a forbidden XML construct") + } + Event::Eof => anyhow::bail!("company collection row ended before COMPANY closed"), + _ => {} + } + } + let guid = + guid.ok_or_else(|| anyhow::anyhow!("company collection omitted the company GUID"))?; + Ok(TallyCompany { + name, + guid: Some(guid), + }) +} + pub fn parse_group_source_records_with_evidence( xml: &str, ) -> anyhow::Result>> { diff --git a/src-tauri/crates/bridge-tally-protocol/src/native_outstandings/compute.rs b/src-tauri/crates/bridge-tally-protocol/src/native_outstandings/compute.rs new file mode 100644 index 00000000..eb6515f7 --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/src/native_outstandings/compute.rs @@ -0,0 +1,386 @@ +use std::collections::BTreeMap; + +use bridge_tally_primitives::{ExactDecimal, TallyDate}; + +use crate::outstandings_shared::{ + AgeingBillCounts, AgeingBuckets, OutstandingsReport, PartyOutstanding, +}; +use crate::TallyNamedMaster; + +use super::model::{ + AgeingAnchor, LedgerSnapshotEntry, NativeBillRow, NativeOutstandingsError, + NativeOutstandingsResult, PartyResidual, +}; + +#[derive(Default)] +struct PartyAccumulator { + receivable: Option, + payable: Option, + oldest_bill_age: Option, +} + +/// Ledger and group masters captured for the same native outstandings read. +/// Group ancestry is required to classify nested party ledgers correctly. +pub struct NativeMasterSnapshot<'a> { + pub ledgers: &'a [LedgerSnapshotEntry], + pub groups: &'a [TallyNamedMaster], +} + +/// Computes a drop-in [`OutstandingsReport`] plus on-account residual +/// evidence from the native Bills Receivable/Payable rows and the ledger +/// snapshot, per TALLY_PROTOCOL_REFERENCE ground truth captured 2026-08-07. +/// +/// `source_bytes` is the caller's real encoded byte count for the responses +/// consumed; this path reads no vouchers, so `source_voucher_count` is +/// always `0`. +pub fn compute_native_outstandings( + company_name: &str, + receivable_rows: &[NativeBillRow], + payable_rows: &[NativeBillRow], + masters: NativeMasterSnapshot<'_>, + anchor: AgeingAnchor, + as_of: &TallyDate, + source_bytes: usize, +) -> Result { + let mut receivable_total = ExactDecimal::zero(); + let mut payable_total = ExactDecimal::zero(); + let mut ageing = AgeingBuckets { + days_0_30: ExactDecimal::zero(), + days_31_60: ExactDecimal::zero(), + days_61_90: ExactDecimal::zero(), + days_90_plus: ExactDecimal::zero(), + }; + let mut ageing_bill_counts = AgeingBillCounts { + days_0_30: 0, + days_31_60: 0, + days_61_90: 0, + days_90_plus: 0, + }; + let mut overdue_crosscheck_mismatches = 0_usize; + let mut parties = BTreeMap::::new(); + + for row in receivable_rows + .iter() + .filter(|row| !row.closing_balance.is_zero()) + { + if !row.closing_balance.is_negative() { + return Err(NativeOutstandingsError::InvalidResponse( + "receivable_bill_sign_contradiction", + )); + } + let amount = row + .closing_balance + .abs() + .map_err(|_| NativeOutstandingsError::ArithmeticOverflow)?; + receivable_total = add(&receivable_total, &amount)?; + + let age = overdue_days(bill_anchor_date(row, anchor), as_of)?; + if let Some(tally_overdue) = row.tally_overdue_days { + let age_from_due = overdue_days(&row.due_date, as_of)?.unwrap_or(0); + if i64::from(age_from_due) != tally_overdue { + overdue_crosscheck_mismatches += 1; + } + } + + let totals = parties.entry(row.party.clone()).or_default(); + totals.receivable = Some(add( + totals.receivable.as_ref().unwrap_or(&ExactDecimal::zero()), + &amount, + )?); + // Tally keeps a future-due open bill in its first ageing bucket even + // though BILLOVERDUE is empty and no overdue age can truthfully be + // claimed. Bucket membership and bill age are therefore distinct: + // count the bill and its amount, but retain `None` for oldest age. + let (bucket, count) = match 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, + ), + }; + *bucket = add(bucket, &amount)?; + *count = count + .checked_add(1) + .ok_or(NativeOutstandingsError::ArithmeticOverflow)?; + if let Some(age) = age { + totals.oldest_bill_age = + Some(totals.oldest_bill_age.map_or(age, |oldest| oldest.max(age))); + } + } + + for row in payable_rows + .iter() + .filter(|row| !row.closing_balance.is_zero()) + { + if row.closing_balance.is_negative() { + return Err(NativeOutstandingsError::InvalidResponse( + "payable_bill_sign_contradiction", + )); + } + let amount = row + .closing_balance + .abs() + .map_err(|_| NativeOutstandingsError::ArithmeticOverflow)?; + payable_total = add(&payable_total, &amount)?; + + let age = overdue_days(bill_anchor_date(row, anchor), as_of)?; + + let totals = parties.entry(row.party.clone()).or_default(); + totals.payable = Some(add( + totals.payable.as_ref().unwrap_or(&ExactDecimal::zero()), + &amount, + )?); + if let Some(age) = age { + totals.oldest_bill_age = + Some(totals.oldest_bill_age.map_or(age, |oldest| oldest.max(age))); + } + } + + let mut top_parties = parties + .into_iter() + .map(|(party, totals)| { + let receivable = totals.receivable.unwrap_or_else(ExactDecimal::zero); + let payable = totals.payable.unwrap_or_else(ExactDecimal::zero); + let outstanding_total = add(&receivable, &payable)?; + Ok(PartyOutstanding { + party, + receivable, + payable, + outstanding_total, + oldest_bill_age_days: totals.oldest_bill_age, + }) + }) + .collect::, NativeOutstandingsError>>()?; + top_parties.sort_by(|left, right| { + right + .outstanding_total + .cmp_magnitude(&left.outstanding_total) + .then_with(|| left.party.cmp(&right.party)) + }); + top_parties.truncate(10); + + let open_receivable_bill_count = ageing_bill_counts + .days_0_30 + .checked_add(ageing_bill_counts.days_31_60) + .and_then(|value| value.checked_add(ageing_bill_counts.days_61_90)) + .and_then(|value| value.checked_add(ageing_bill_counts.days_90_plus)) + .ok_or(NativeOutstandingsError::ArithmeticOverflow)?; + + let (residuals, residual_total, has_unaged_receivable) = compute_residuals( + receivable_rows, + payable_rows, + masters.ledgers, + masters.groups, + )?; + + let report = OutstandingsReport { + company_name: company_name.to_string(), + as_of_yyyymmdd: as_of.as_str().to_string(), + receivable_total, + payable_total, + has_unaged_receivable, + ageing, + open_receivable_bill_count, + ageing_bill_counts, + top_parties, + source_voucher_count: 0, + source_bytes, + }; + + Ok(NativeOutstandingsResult { + report, + residuals, + residual_total, + overdue_crosscheck_mismatches, + }) +} + +/// Per-party residual: `ledger CLOSINGBALANCE - sum(receivable BILLCL) - +/// sum(payable BILLCL)`. The native Bills Receivable/Payable reports only +/// ever list NAMED bills, so any non-zero residual on a party ledger is +/// exactly that party's on-account exposure — present in the ledger balance +/// but invisible to (and therefore unaged by) the bill-level reports. A +/// Sundry Debtor/Creditor with bill-wise tracking disabled has no bill rows +/// by construction, so its entire balance is such a residual. +fn compute_residuals( + receivable_rows: &[NativeBillRow], + payable_rows: &[NativeBillRow], + ledgers: &[LedgerSnapshotEntry], + groups: &[TallyNamedMaster], +) -> Result<(Vec, ExactDecimal, bool), NativeOutstandingsError> { + let mut receivable_sums = BTreeMap::<&str, ExactDecimal>::new(); + for row in receivable_rows { + let entry = receivable_sums + .entry(row.party.as_str()) + .or_insert_with(ExactDecimal::zero); + *entry = entry + .checked_add(&row.closing_balance) + .map_err(|_| NativeOutstandingsError::ArithmeticOverflow)?; + } + let mut payable_sums = BTreeMap::<&str, ExactDecimal>::new(); + for row in payable_rows { + let entry = payable_sums + .entry(row.party.as_str()) + .or_insert_with(ExactDecimal::zero); + *entry = entry + .checked_add(&row.closing_balance) + .map_err(|_| NativeOutstandingsError::ArithmeticOverflow)?; + } + + let mut residuals = Vec::new(); + let mut residual_total = ExactDecimal::zero(); + let mut has_unaged_receivable = false; + let group_parents = group_parent_map(groups)?; + for ledger in ledgers { + if !is_party_ledger(ledger, &group_parents, groups.is_empty())? { + continue; + } + let zero = ExactDecimal::zero(); + let receivable_sum = receivable_sums.get(ledger.name.as_str()).unwrap_or(&zero); + let payable_sum = payable_sums.get(ledger.name.as_str()).unwrap_or(&zero); + let residual = ledger + .closing_balance + .checked_subtract(receivable_sum) + .and_then(|value| value.checked_subtract(payable_sum)) + .map_err(|_| NativeOutstandingsError::ArithmeticOverflow)?; + if !residual.is_zero() { + let magnitude = residual + .abs() + .map_err(|_| NativeOutstandingsError::ArithmeticOverflow)?; + residual_total = add(&residual_total, &magnitude)?; + // A receivable-side (debtor) ledger reports a negative closing + // balance in this data; a non-zero residual there is exposure + // the Bills Receivable report cannot see and therefore cannot + // age. + has_unaged_receivable |= residual.is_negative(); + } + residuals.push(PartyResidual { + party: ledger.name.clone(), + amount: residual, + }); + } + Ok((residuals, residual_total, has_unaged_receivable)) +} + +fn group_parent_map( + groups: &[TallyNamedMaster], +) -> Result>, NativeOutstandingsError> { + let mut parents = BTreeMap::new(); + for group in groups { + let name = normalized_group_name(&group.name); + if name.is_empty() || parents.contains_key(&name) { + return Err(NativeOutstandingsError::InvalidResponse( + "group_name_missing_or_duplicate", + )); + } + parents.insert( + name, + group + .parent + .as_deref() + .map(normalized_group_name) + .filter(|parent| !parent.is_empty()), + ); + } + Ok(parents) +} + +fn is_party_ledger( + ledger: &LedgerSnapshotEntry, + group_parents: &BTreeMap>, + allow_unresolved_legacy_parent: bool, +) -> Result { + if ledger.bill_wise_on { + return Ok(true); + } + let Some(parent) = ledger.parent.as_deref() else { + return Ok(false); + }; + let mut current = normalized_group_name(parent); + for _ in 0..=group_parents.len() { + if matches!(current.as_str(), "sundry debtors" | "sundry creditors") { + return Ok(true); + } + if current == "primary" || current.is_empty() { + return Ok(false); + } + match group_parents.get(¤t) { + Some(Some(parent)) => current = parent.clone(), + Some(None) => return Ok(false), + None if allow_unresolved_legacy_parent => return Ok(false), + None => { + return Err(NativeOutstandingsError::InvalidResponse( + "ledger_group_parent_unresolved", + )) + } + } + } + Err(NativeOutstandingsError::InvalidResponse( + "group_parent_cycle", + )) +} + +fn normalized_group_name(value: &str) -> String { + value.trim().to_ascii_lowercase() +} + +fn bill_anchor_date(row: &NativeBillRow, anchor: AgeingAnchor) -> &TallyDate { + match anchor { + AgeingAnchor::BillDate => &row.bill_date, + AgeingAnchor::DueDate => &row.due_date, + } +} + +fn add(left: &ExactDecimal, right: &ExactDecimal) -> Result { + left.checked_add(right) + .map_err(|_| NativeOutstandingsError::ArithmeticOverflow) +} + +/// Public for tests and callers that want to cross-check or display a raw +/// bill age without going through the full report computation. +pub fn age_in_days(from: &TallyDate, to: &TallyDate) -> Result { + days_between(from, to) +} + +/// A bill whose due date has not arrived has zero overdue days in Tally's +/// `BILLOVERDUE` column, but no bill age to place into the ageing buckets. +/// Keep that state distinct from a bill due today: the latter is aged zero, +/// while the former is absent from ageing and from `oldest_bill_age_days`. +fn overdue_days(from: &TallyDate, to: &TallyDate) -> Result, NativeOutstandingsError> { + if from > to { + return Ok(None); + } + days_between(from, to).map(Some) +} + +fn days_between(from: &TallyDate, to: &TallyDate) -> Result { + let from = civil_day(from)?; + let to = civil_day(to)?; + u32::try_from(to - from) + .map_err(|_| NativeOutstandingsError::InvalidDate("native_date_after_as_of")) +} + +/// Days-since-epoch via Howard Hinnant's `days_from_civil` algorithm — the +/// same computation `outstandings::compute` uses, duplicated here because +/// that module's helper is private to its own subtree. +fn civil_day(date: &TallyDate) -> Result { + let value = date.as_str(); + let year = value[0..4] + .parse::() + .map_err(|_| NativeOutstandingsError::InvalidDate("native_date_malformed"))?; + let month = value[4..6] + .parse::() + .map_err(|_| NativeOutstandingsError::InvalidDate("native_date_malformed"))?; + let day = value[6..8] + .parse::() + .map_err(|_| NativeOutstandingsError::InvalidDate("native_date_malformed"))?; + let adjusted_year = year - i64::from(month <= 2); + let era = adjusted_year.div_euclid(400); + let year_of_era = adjusted_year - era * 400; + let shifted_month = month + if month > 2 { -3 } else { 9 }; + let day_of_year = (153 * shifted_month + 2) / 5 + day - 1; + let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; + Ok(era * 146_097 + day_of_era) +} diff --git a/src-tauri/crates/bridge-tally-protocol/src/native_outstandings/date.rs b/src-tauri/crates/bridge-tally-protocol/src/native_outstandings/date.rs new file mode 100644 index 00000000..467c839a --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/src/native_outstandings/date.rs @@ -0,0 +1,288 @@ +//! Parsing for Tally's display-formatted native dates: `1-Apr-24`, +//! `31-May-26` — day (1-2 digits), a 3-letter month abbreviation, and a +//! TWO-DIGIT year (TALLY_PROTOCOL_REFERENCE ground truth captured +//! 2026-08-07, `bills_receivable_billwise_lab.xml` / +//! `bills_receivable_ageing_lab.xml`). +//! +//! The two-digit year is resolved inside the pinned company's actual book +//! window, never against the wall clock: a Bridge process can run years after +//! the book it is reading, and the wall clock has no relationship to what +//! century that book's data lives in. + +use bridge_tally_primitives::TallyDate; + +use super::model::NativeOutstandingsError; + +const MONTH_ABBREVIATIONS: [&str; 12] = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NativeDisplayDateRole { + BillDate, + DueDate, +} + +/// Parses one native display date using the pinned company's book window. +/// The two-digit year in `raw` may be valid in more than one century, so +/// resolving against `BooksFrom`'s century alone can silently place an active +/// bill a century in the past. Exactly one valid calendar date must fall in +/// the role-appropriate portion of the window; zero or multiple candidates +/// fail closed. +/// +/// Fails closed — rather than guessing — when the lexeme does not match the +/// exact three-part `D[D]-MMM-YY` shape, or when the resolved year/month/day +/// is not a real Gregorian calendar date. +pub fn parse_native_display_date( + raw: &str, + books_from: &TallyDate, + as_of: &TallyDate, + role: NativeDisplayDateRole, +) -> Result { + let trimmed = raw.trim(); + let mut parts = trimmed.split('-'); + let (Some(day_part), Some(month_part), Some(year_part), None) = + (parts.next(), parts.next(), parts.next(), parts.next()) + else { + return Err(NativeOutstandingsError::InvalidDate( + "native_date_shape_invalid", + )); + }; + + if day_part.is_empty() + || day_part.len() > 2 + || !day_part.bytes().all(|byte| byte.is_ascii_digit()) + { + return Err(NativeOutstandingsError::InvalidDate( + "native_date_day_invalid", + )); + } + let day: u32 = day_part + .parse() + .map_err(|_| NativeOutstandingsError::InvalidDate("native_date_day_invalid"))?; + + let month_index = MONTH_ABBREVIATIONS + .iter() + .position(|candidate| *candidate == month_part) + .ok_or(NativeOutstandingsError::InvalidDate( + "native_date_month_invalid", + ))?; + let month = month_index as u32 + 1; + + if year_part.len() != 2 || !year_part.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(NativeOutstandingsError::InvalidDate( + "native_date_year_invalid", + )); + } + let two_digit_year: u32 = year_part + .parse() + .map_err(|_| NativeOutstandingsError::InvalidDate("native_date_year_invalid"))?; + + if books_from > as_of { + return Err(NativeOutstandingsError::InvalidDate( + "native_date_book_window_invalid", + )); + } + let books_from_year = parse_year(books_from)?; + let as_of_year = parse_year(as_of)?; + let first_century = (books_from_year / 100) * 100; + let last_century = (as_of_year / 100) * 100; + let mut candidates = Vec::new(); + let mut has_calendar_candidate = false; + + for century in (first_century..=last_century).step_by(100) { + let year = century + two_digit_year; + let Ok(candidate) = TallyDate::parse(format!("{year:04}{month:02}{day:02}")) else { + continue; + }; + has_calendar_candidate = true; + if &candidate >= books_from + && match role { + NativeDisplayDateRole::BillDate => &candidate <= as_of, + NativeDisplayDateRole::DueDate => true, + } + { + candidates.push(candidate); + } + } + + match candidates.as_slice() { + [candidate] => Ok(candidate.clone()), + [] if has_calendar_candidate => Err(NativeOutstandingsError::InvalidDate( + "native_date_year_outside_book_window", + )), + [] => Err(NativeOutstandingsError::InvalidDate( + "native_date_calendar_invalid", + )), + _ => Err(NativeOutstandingsError::InvalidDate( + "native_date_year_ambiguous_book_window", + )), + } +} + +fn parse_year(date: &TallyDate) -> Result { + date.as_str()[..4] + .parse() + .map_err(|_| NativeOutstandingsError::InvalidDate("native_date_year_invalid")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolves_two_digit_year_against_the_books_from_century() { + let books_from = TallyDate::parse("20240401").unwrap(); + let as_of = TallyDate::parse("20260731").unwrap(); + assert_eq!( + parse_native_display_date( + "1-Apr-24", + &books_from, + &as_of, + NativeDisplayDateRole::BillDate + ) + .unwrap() + .as_str(), + "20240401" + ); + assert_eq!( + parse_native_display_date( + "31-May-26", + &books_from, + &as_of, + NativeDisplayDateRole::BillDate + ) + .unwrap() + .as_str(), + "20260531" + ); + assert_eq!( + parse_native_display_date( + "2-Jul-26", + &books_from, + &as_of, + NativeDisplayDateRole::BillDate + ) + .unwrap() + .as_str(), + "20260702" + ); + } + + #[test] + fn resolves_a_century_boundary_year_into_the_active_book() { + let books_from = TallyDate::parse("19990401").unwrap(); + let as_of = TallyDate::parse("20260731").unwrap(); + assert_eq!( + parse_native_display_date( + "1-Apr-26", + &books_from, + &as_of, + NativeDisplayDateRole::BillDate + ) + .unwrap() + .as_str(), + "20260401", + "a 1999 book that is active in 2026 must not parse 26 as 1926" + ); + } + + #[test] + fn rejects_a_two_digit_year_with_multiple_plausible_centuries() { + let books_from = TallyDate::parse("19000101").unwrap(); + let as_of = TallyDate::parse("21001231").unwrap(); + assert_eq!( + parse_native_display_date( + "1-Apr-26", + &books_from, + &as_of, + NativeDisplayDateRole::BillDate + ), + Err(NativeOutstandingsError::InvalidDate( + "native_date_year_ambiguous_book_window" + )) + ); + } + + #[test] + fn fails_closed_on_malformed_or_impossible_dates() { + let books_from = TallyDate::parse("20240101").unwrap(); + let as_of = TallyDate::parse("20260731").unwrap(); + for raw in [ + "", + "1-Apr", + "1-Apr-24-extra", + "1-Apr-2024", + "1-Apr-2", + "1-April-24", + "32-Jan-24", + "0-Jan-24", + "29-Feb-25", + "a-Apr-24", + "1-XXX-24", + ] { + assert!( + parse_native_display_date( + raw, + &books_from, + &as_of, + NativeDisplayDateRole::BillDate + ) + .is_err(), + "expected {raw:?} to be rejected" + ); + } + assert!(parse_native_display_date( + "29-Feb-24", + &books_from, + &as_of, + NativeDisplayDateRole::BillDate + ) + .is_ok()); + } + + #[test] + fn due_date_can_fall_after_as_of_without_widening_the_bill_date_window() { + let books_from = TallyDate::parse("20260401").unwrap(); + let as_of = TallyDate::parse("20260731").unwrap(); + assert_eq!( + parse_native_display_date( + "1-Aug-26", + &books_from, + &as_of, + NativeDisplayDateRole::DueDate + ) + .unwrap() + .as_str(), + "20260801" + ); + assert_eq!( + parse_native_display_date( + "1-Aug-26", + &books_from, + &as_of, + NativeDisplayDateRole::BillDate + ), + Err(NativeOutstandingsError::InvalidDate( + "native_date_year_outside_book_window" + )) + ); + } + + #[test] + fn due_date_still_rejects_an_ambiguous_two_digit_year() { + let books_from = TallyDate::parse("19000101").unwrap(); + let as_of = TallyDate::parse("21001231").unwrap(); + assert_eq!( + parse_native_display_date( + "1-Apr-26", + &books_from, + &as_of, + NativeDisplayDateRole::DueDate + ), + Err(NativeOutstandingsError::InvalidDate( + "native_date_year_ambiguous_book_window" + )) + ); + } +} diff --git a/src-tauri/crates/bridge-tally-protocol/src/native_outstandings/mod.rs b/src-tauri/crates/bridge-tally-protocol/src/native_outstandings/mod.rs new file mode 100644 index 00000000..a9a29b83 --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/src/native_outstandings/mod.rs @@ -0,0 +1,36 @@ +//! Native `Bills Receivable`/`Bills Payable` + `List of Ledgers` outstandings +//! path. +//! +//! This is a second, independent way to reach [`crate::outstandings_shared::OutstandingsReport`]: +//! instead of scanning vouchers, it reads Tally's own bill-level reports +//! directly. Everything here was measured live against TallyPrime +//! (TALLY_PROTOCOL_REFERENCE ground truth captured 2026-08-07) and is +//! documented at each module: +//! +//! - [`request`] — exact request XML for both native reports. +//! - [`date`] — Tally's `D-MMM-YY` display dates, resolved against the +//! pinned company's `BooksFrom` century only. +//! - [`wire`] — the flat, inverted-`STATUS` Bills grammar and the +//! `DATA`-scoped Ledger collection grammar (`CMPINFO` counter trap). +//! - [`model`] — row and result types; reuses `OutstandingsReport` so this +//! path is a drop-in for the UI. +//! - [`compute`] — assembles the report and the on-account residual +//! cross-check. + +mod compute; +mod date; +mod model; +mod request; +mod wire; + +pub use compute::{age_in_days, compute_native_outstandings, NativeMasterSnapshot}; +pub use date::{parse_native_display_date, NativeDisplayDateRole}; +pub use model::{ + AgeingAnchor, CompanyCurrency, LedgerSnapshotEntry, NativeBillRow, NativeOutstandingsError, + NativeOutstandingsResult, PartyResidual, +}; +pub use request::{ + render_company_currency_request, render_native_bills_request, + render_native_ledger_snapshot_request, NativeBillsReportKind, +}; +pub use wire::{parse_company_currency, parse_native_bill_rows, parse_native_ledger_snapshot}; 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 new file mode 100644 index 00000000..a83a3af7 --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/src/native_outstandings/model.rs @@ -0,0 +1,123 @@ +use std::fmt; + +use bridge_tally_primitives::{ExactDecimal, TallyDate}; + +use crate::outstandings_shared::OutstandingsReport; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NativeOutstandingsError { + /// A two-digit display date could not be resolved to a valid calendar + /// date, or its lexeme did not match the observed `D-MMM-YY` shape. + InvalidDate(&'static str), + InvalidAmount, + /// Tally's response did not match the documented grammar. The code + /// identifies which structural rule was violated. + InvalidResponse(&'static str), + ArithmeticOverflow, + /// The response carried a `` element. Both native response + /// shapes used here (the flat Bills Receivable/Payable report and the + /// Ledger collection) only ever carry `STATUS` on failure — the flat + /// report's verification is INVERTED (no `STATUS` at all is success), + /// and the ledger collection's `STATUS` must read `1`. + TallyReportedFailure, +} + +impl fmt::Display for NativeOutstandingsError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidDate(code) => { + write!(formatter, "native outstandings date invalid ({code})") + } + Self::InvalidAmount => formatter.write_str("Tally returned an invalid native amount"), + Self::InvalidResponse(code) => { + write!(formatter, "native outstandings response invalid ({code})") + } + Self::ArithmeticOverflow => formatter + .write_str("native outstandings arithmetic exceeded the exact-decimal bound"), + Self::TallyReportedFailure => { + formatter.write_str("Tally reported failure for the native outstandings request") + } + } + } +} + +impl std::error::Error for NativeOutstandingsError {} + +/// Which of a bill's two dates ageing is measured from. +/// +/// `DueDate` is the verified default (TALLY_PROTOCOL_REFERENCE ground truth +/// captured 2026-08-07): Tally's own `BILLOVERDUE` counter ages from +/// `BILLDUE`, not `BILLDATE`, whenever a bill carries a credit period that +/// makes the two differ. `BillDate` remains selectable for callers that want +/// it explicitly. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AgeingAnchor { + BillDate, + DueDate, +} + +/// One outstanding bill row from the flat Bills Receivable/Payable report. +/// +/// `tally_overdue_days` is Tally's own `BILLOVERDUE` counter, measured +/// against the requested `SVTODATE`. Tally leaves it empty when the counter +/// is not applicable, including a future-due bill. It is retained only as an +/// independent cross-check against Bridge's own ageing computation and must +/// never be used as ageing's source of truth (it is not recomputed for an +/// as-of date other than the one that was requested). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NativeBillRow { + pub party: String, + pub reference: String, + pub bill_date: TallyDate, + pub due_date: TallyDate, + pub closing_balance: ExactDecimal, + pub tally_overdue_days: Option, +} + +/// One ledger master row from the `List of Ledgers` collection snapshot. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LedgerSnapshotEntry { + pub name: String, + pub parent: Option, + pub closing_balance: ExactDecimal, + pub opening_balance: ExactDecimal, + pub bill_wise_on: bool, +} + +/// A party's unallocated residual: the gap between the ledger's own +/// `CLOSINGBALANCE` and the sum of everything the Bills Receivable/Payable +/// reports show as open bills for that party. Because the native reports +/// only ever list named bills, a non-zero residual is exactly the party's +/// on-account exposure — money the ledger balance carries with no bill +/// reference at all, and therefore no truthful bill age. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PartyResidual { + pub party: String, + pub amount: ExactDecimal, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NativeOutstandingsResult { + pub report: OutstandingsReport, + pub residuals: Vec, + /// Sum of the absolute magnitude of every party residual: the total + /// unallocated (on-account) exposure the bill-level reports cannot see. + pub residual_total: ExactDecimal, + /// Count of receivable rows where Tally's own `BILLOVERDUE` did not + /// equal Bridge's independently computed age-from-`DueDate`. Retained as + /// a cross-check signal only; it is never used to alter computed ageing. + pub overdue_crosscheck_mismatches: usize, +} + +/// What Tally reports about a company's currencies. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct CompanyCurrency { + pub symbol: String, + pub mailing_name: String, + /// How many currency masters the company defines. INR is inferred only + /// when there is exactly one: with several defined, which is the BASE + /// currency is not determinable from this read, and guessing would put a + /// wrong currency symbol in front of a real balance. + pub currency_count: usize, + pub is_inr: bool, +} 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 new file mode 100644 index 00000000..628e9158 --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/src/native_outstandings/request.rs @@ -0,0 +1,142 @@ +//! XML request builders for Tally's native Bills Receivable/Payable reports +//! and the `List of Ledgers` collection snapshot. +//! +//! These render exact request strings; nothing in this module dispatches +//! them. The Bills Receivable/Payable shape is the WORKING shape verified +//! live against TallyPrime (TALLY_PROTOCOL_REFERENCE ground truth captured +//! 2026-08-07): `SVTODATE` controls the report's as-of date and must always +//! be present. + +use bridge_tally_primitives::TallyDate; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NativeBillsReportKind { + Receivable, + Payable, +} + +impl NativeBillsReportKind { + const fn report_id(self) -> &'static str { + match self { + Self::Receivable => "Bills Receivable", + Self::Payable => "Bills Payable", + } + } +} + +/// Renders the exact working request shape for the flat Bills +/// Receivable/Payable `Data` report. +pub fn render_native_bills_request( + kind: NativeBillsReportKind, + company: &str, + from: &TallyDate, + to: &TallyDate, +) -> String { + format!( + r#"
1ExportData{id}
$$SysName:XML{company}{from}{to}
"#, + id = kind.report_id(), + company = xml_escape(company), + from = from.as_str(), + to = to.as_str(), + ) +} + +/// Renders a request for the `List of Ledgers` collection, overridden to +/// fetch exactly the fields the on-account residual computation needs: +/// `NAME`, `PARENT`, `CLOSINGBALANCE`, `OPENINGBALANCE`, `ISBILLWISEON`. +/// +/// **`SVFROMDATE`/`SVTODATE` are load-bearing here and must match the bills +/// request exactly.** `CLOSINGBALANCE` *is* as-of scoped -- measured +/// 2026-08-07, the same collection returned a Sundry total of Rs -44,09,597 at +/// `SVTODATE=20260731` and Rs -21,19,377 at `20250401`. The bills reports are +/// as-of scoped too, so if this request omitted the period the residual +/// `CLOSINGBALANCE - sum(BILLCL)` would subtract historical bills from a +/// current balance and silently report a wrong on-account figure at every +/// as-of except today's -- the failure would be invisible in a test that only +/// ever asks for now. +/// +/// (An earlier revision of this function omitted the period and appeared +/// correct precisely because it was only exercised at the current date.) +pub fn render_native_ledger_snapshot_request( + company: &str, + from: &TallyDate, + to: &TallyDate, +) -> String { + format!( + r#"
1ExportCollectionList of Ledgers
$$SysName:XML{company}{from}{to}NAME, PARENT, CLOSINGBALANCE, OPENINGBALANCE, ISBILLWISEON
"#, + company = xml_escape(company), + from = from.as_str(), + to = to.as_str(), + ) +} + +fn xml_escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +/// Renders a request for the company's currency masters. +/// +/// 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"`. +pub fn render_company_currency_request(company: &str) -> String { + format!( + r#"
1ExportCollectionBridgeCompanyCurrencies
$$SysName:XML{company}CurrencyNAME, MAILINGNAME, DECIMALPLACES
"#, + company = xml_escape(company), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn renders_the_verified_working_bills_request_shape() { + let from = TallyDate::parse("20240401").unwrap(); + let to = TallyDate::parse("20260731").unwrap(); + let xml = render_native_bills_request( + NativeBillsReportKind::Receivable, + "Bridge Billwise Lab", + &from, + &to, + ); + assert_eq!( + xml, + r#"
1ExportDataBills Receivable
$$SysName:XMLBridge Billwise Lab2024040120260731
"# + ); + let payable = render_native_bills_request( + NativeBillsReportKind::Payable, + "Bridge Billwise Lab", + &from, + &to, + ); + assert!(payable.contains("Bills Payable")); + } + + #[test] + fn escapes_company_names_in_both_requests() { + let from = TallyDate::parse("20240401").unwrap(); + let to = TallyDate::parse("20260731").unwrap(); + let xml = render_native_bills_request( + NativeBillsReportKind::Receivable, + "A & B ", + &from, + &to, + ); + assert!(xml.contains("A & B <Co>")); + assert!(!xml.contains("A & B ")); + + let ledger_xml = render_native_ledger_snapshot_request("A & B ", &from, &to); + assert!(ledger_xml.contains("A & B <Co>")); + assert!(ledger_xml + .contains("NAME, PARENT, CLOSINGBALANCE, OPENINGBALANCE, ISBILLWISEON")); + assert!(ledger_xml.contains(r#"20240401"#)); + assert!(ledger_xml.contains(r#"20260731"#)); + } +} 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 new file mode 100644 index 00000000..67beb0ca --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/src/native_outstandings/wire.rs @@ -0,0 +1,807 @@ +//! Parsers for the two native response shapes. +//! +//! Both grammars were measured live against TallyPrime (TALLY_PROTOCOL_REFERENCE +//! ground truth captured 2026-08-07) and are documented in this crate's +//! `native_outstandings` module: +//! +//! 1. Bills Receivable/Payable is FLAT: a `` element is followed +//! by SIBLING ``, ``, `` elements directly +//! under ``, in document order, with no wrapping row element. +//! Verification is INVERTED: success carries no `` anywhere; a +//! `` element only ever appears on failure. An empty result is a +//! bare `` and is legitimate zero-row success. +//! 2. The Ledger collection is an ordinary Collection response: it DOES +//! carry `1` on success, and its rows live only under +//! `ENVELOPE/BODY/DATA/COLLECTION`. The response also carries a +//! `CMPINFO` block with bare counter elements sharing row tag names +//! (`0`) — only the `DATA` section may be scanned for +//! rows, or those counters are misread as ledgers. + +use quick_xml::events::{BytesStart, Event}; +use quick_xml::name::QName; +use quick_xml::Reader; + +use bridge_tally_primitives::ExactDecimal; + +use crate::tolerant_xml::sanitize_invalid_numeric_references; + +use super::date::{parse_native_display_date, NativeDisplayDateRole}; +use super::model::{LedgerSnapshotEntry, NativeBillRow, NativeOutstandingsError}; + +struct PendingBillRow { + party: String, + reference: String, + bill_date_raw: String, + closing_balance: Option, + due_date_raw: Option, + overdue_seen: bool, + overdue: Option, +} + +/// Parses the flat Bills Receivable/Payable response into fully resolved +/// rows. The pinned book window resolves their two-digit display dates (see +/// [`super::date::parse_native_display_date`]). +pub fn parse_native_bill_rows( + xml: &str, + books_from: &bridge_tally_primitives::TallyDate, + as_of: &bridge_tally_primitives::TallyDate, +) -> Result, NativeOutstandingsError> { + let sanitized = sanitize_invalid_numeric_references(xml); + let mut reader = Reader::from_str(&sanitized); + reader.config_mut().trim_text(true); + + let mut root_seen = false; + let mut envelope_closed = false; + let mut pending = Vec::::new(); + + loop { + let event = reader + .read_event() + .map_err(|_| NativeOutstandingsError::InvalidResponse("bills_xml_malformed"))?; + match event { + Event::Start(element) => { + let name = element.name().as_ref().to_ascii_uppercase(); + if !root_seen { + if name != b"ENVELOPE" { + return Err(NativeOutstandingsError::InvalidResponse( + "bills_root_not_envelope", + )); + } + root_seen = true; + continue; + } + if envelope_closed { + return Err(NativeOutstandingsError::InvalidResponse( + "bills_trailing_content", + )); + } + match name.as_slice() { + // The inverted rule: presence of STATUS anywhere in this + // report shape means Tally reported failure, regardless + // of the value carried. + b"STATUS" => return Err(NativeOutstandingsError::TallyReportedFailure), + b"BILLFIXED" => { + let (party, reference, bill_date_raw) = parse_bill_fixed(&mut reader)?; + pending.push(PendingBillRow { + party, + reference, + bill_date_raw, + closing_balance: None, + due_date_raw: None, + overdue_seen: false, + overdue: None, + }); + } + b"BILLCL" => { + let text = read_element_text(&mut reader, element.name())?; + let row = + pending + .last_mut() + .ok_or(NativeOutstandingsError::InvalidResponse( + "bills_scalar_before_fixed", + ))?; + if row.closing_balance.is_some() { + return Err(NativeOutstandingsError::InvalidResponse( + "bills_duplicate_billcl", + )); + } + row.closing_balance = Some( + ExactDecimal::parse(text.trim()) + .map_err(|_| NativeOutstandingsError::InvalidAmount)?, + ); + } + b"BILLDUE" => { + let text = read_element_text(&mut reader, element.name())?; + let row = + pending + .last_mut() + .ok_or(NativeOutstandingsError::InvalidResponse( + "bills_scalar_before_fixed", + ))?; + if row.due_date_raw.is_some() { + return Err(NativeOutstandingsError::InvalidResponse( + "bills_duplicate_billdue", + )); + } + row.due_date_raw = Some(text); + } + b"BILLOVERDUE" => { + let text = read_element_text(&mut reader, element.name())?; + let row = + pending + .last_mut() + .ok_or(NativeOutstandingsError::InvalidResponse( + "bills_scalar_before_fixed", + ))?; + set_bill_overdue(row, &text)?; + } + _ => { + return Err(NativeOutstandingsError::InvalidResponse( + "bills_unexpected_element", + )) + } + } + } + Event::Empty(element) => { + let name = element.name().as_ref().to_ascii_uppercase(); + if !root_seen { + return Err(NativeOutstandingsError::InvalidResponse( + "bills_root_not_envelope", + )); + } + if name.as_slice() == b"STATUS" { + return Err(NativeOutstandingsError::TallyReportedFailure); + } + if name.as_slice() == b"BILLOVERDUE" { + let row = + pending + .last_mut() + .ok_or(NativeOutstandingsError::InvalidResponse( + "bills_scalar_before_fixed", + ))?; + set_bill_overdue(row, "")?; + continue; + } + return Err(NativeOutstandingsError::InvalidResponse( + "bills_unexpected_empty_element", + )); + } + Event::End(element) => { + if root_seen + && !envelope_closed + && element.name().as_ref().eq_ignore_ascii_case(b"ENVELOPE") + { + envelope_closed = true; + continue; + } + return Err(NativeOutstandingsError::InvalidResponse( + "bills_unexpected_close", + )); + } + Event::Text(text) => { + let is_blank = text + .decode() + .map(|value| value.trim().is_empty()) + .unwrap_or(false); + if !is_blank { + return Err(NativeOutstandingsError::InvalidResponse( + "bills_unexpected_text", + )); + } + } + Event::Eof => break, + _ => {} + } + } + if !envelope_closed { + return Err(NativeOutstandingsError::InvalidResponse( + "bills_envelope_unterminated", + )); + } + + pending + .into_iter() + .map(|row| finalize_bill_row(row, books_from, as_of)) + .collect() +} + +fn set_bill_overdue(row: &mut PendingBillRow, text: &str) -> Result<(), NativeOutstandingsError> { + if row.overdue_seen { + return Err(NativeOutstandingsError::InvalidResponse( + "bills_duplicate_billoverdue", + )); + } + row.overdue_seen = true; + let text = text.trim(); + row.overdue = if text.is_empty() { + None + } else { + Some( + text.parse::() + .map_err(|_| NativeOutstandingsError::InvalidResponse("bills_overdue_invalid"))?, + ) + }; + Ok(()) +} + +fn finalize_bill_row( + row: PendingBillRow, + books_from: &bridge_tally_primitives::TallyDate, + as_of: &bridge_tally_primitives::TallyDate, +) -> Result { + let closing_balance = row + .closing_balance + .ok_or(NativeOutstandingsError::InvalidResponse( + "bills_fixed_row_missing_billcl", + ))?; + let due_date_raw = row + .due_date_raw + .ok_or(NativeOutstandingsError::InvalidResponse( + "bills_fixed_row_missing_billdue", + ))?; + if !row.overdue_seen { + return Err(NativeOutstandingsError::InvalidResponse( + "bills_fixed_row_missing_billoverdue", + )); + } + let bill_date = parse_native_display_date( + &row.bill_date_raw, + books_from, + as_of, + NativeDisplayDateRole::BillDate, + )?; + let due_date = parse_native_display_date( + &due_date_raw, + books_from, + as_of, + NativeDisplayDateRole::DueDate, + )?; + Ok(NativeBillRow { + party: row.party, + reference: row.reference, + bill_date, + due_date, + closing_balance, + tally_overdue_days: row.overdue, + }) +} + +fn parse_bill_fixed( + reader: &mut Reader<&[u8]>, +) -> Result<(String, String, String), NativeOutstandingsError> { + let mut bill_date = None; + let mut reference = None; + let mut party = None; + loop { + match reader + .read_event() + .map_err(|_| NativeOutstandingsError::InvalidResponse("bills_xml_malformed"))? + { + Event::Start(child) => { + let child_name = child.name().as_ref().to_ascii_uppercase(); + let text = read_element_text(reader, child.name())?; + match child_name.as_slice() { + b"BILLDATE" => { + set_once(&mut bill_date, text, "bills_fixed_duplicate_billdate")? + } + b"BILLREF" => set_once(&mut reference, text, "bills_fixed_duplicate_billref")?, + b"BILLPARTY" => set_once(&mut party, text, "bills_fixed_duplicate_billparty")?, + _ => { + return Err(NativeOutstandingsError::InvalidResponse( + "bills_fixed_unexpected_field", + )) + } + } + } + Event::End(end) if end.name().as_ref().eq_ignore_ascii_case(b"BILLFIXED") => break, + Event::Empty(child) => { + let code = if child.name().as_ref().eq_ignore_ascii_case(b"BILLPARTY") { + "bills_fixed_empty_billparty" + } else { + "bills_fixed_field_empty" + }; + return Err(NativeOutstandingsError::InvalidResponse(code)); + } + Event::Eof => { + return Err(NativeOutstandingsError::InvalidResponse( + "bills_fixed_unterminated", + )) + } + _ => {} + } + } + let party = party.ok_or(NativeOutstandingsError::InvalidResponse( + "bills_fixed_missing_billparty", + ))?; + if party.trim().is_empty() { + return Err(NativeOutstandingsError::InvalidResponse( + "bills_fixed_empty_billparty", + )); + } + Ok(( + party, + reference.ok_or(NativeOutstandingsError::InvalidResponse( + "bills_fixed_missing_billref", + ))?, + bill_date.ok_or(NativeOutstandingsError::InvalidResponse( + "bills_fixed_missing_billdate", + ))?, + )) +} + +fn set_once( + slot: &mut Option, + value: String, + duplicate_code: &'static str, +) -> Result<(), NativeOutstandingsError> { + if slot.replace(value).is_some() { + return Err(NativeOutstandingsError::InvalidResponse(duplicate_code)); + } + Ok(()) +} + +/// Parses the `List of Ledgers` collection response, scoping rows strictly +/// to `ENVELOPE/BODY/DATA/COLLECTION` so the `CMPINFO` bare-counter trap +/// (`0` inside `DESC/CMPINFO`) cannot be misread as rows. +/// Parses a ledger balance, treating an **empty** element as zero. +/// +/// Tally emits `` -- entirely empty, not +/// `"0"` -- for a ledger whose balance is nil. Measured 2026-08-07: 16 of the +/// 88 ledgers on the bulk demo book do this, while the small bill-wise lab +/// book has none, so a parser validated only against the latter rejects every +/// realistic book with `InvalidAmount` and takes the whole read down with it. +/// +/// Only a genuinely empty value is accepted as zero. Anything else that fails +/// to parse is still an error: this is a narrow allowance for an observed +/// encoding of zero, not a lenient number parser. +fn parse_ledger_amount(text: &str) -> Result { + if text.is_empty() { + return Ok(ExactDecimal::zero()); + } + ExactDecimal::parse(text).map_err(|_| NativeOutstandingsError::InvalidAmount) +} + +pub fn parse_native_ledger_snapshot( + xml: &str, +) -> Result, NativeOutstandingsError> { + let sanitized = sanitize_invalid_numeric_references(xml); + let mut reader = Reader::from_str(&sanitized); + reader.config_mut().trim_text(true); + + let mut path = Vec::>::new(); + let mut status_seen = false; + let mut collection_seen = false; + let mut entries = Vec::new(); + + loop { + let event = reader + .read_event() + .map_err(|_| NativeOutstandingsError::InvalidResponse("ledger_xml_malformed"))?; + match event { + Event::Start(element) => { + let name = element.name().as_ref().to_ascii_uppercase(); + if path.is_empty() && name != b"ENVELOPE" { + return Err(NativeOutstandingsError::InvalidResponse( + "ledger_root_not_envelope", + )); + } + if path_is(&path, &[b"ENVELOPE", b"HEADER"]) && name == b"STATUS" { + let text = read_element_text(&mut reader, element.name())?; + if text.trim() != "1" { + return Err(NativeOutstandingsError::TallyReportedFailure); + } + status_seen = true; + continue; + } + if path_is(&path, &[b"ENVELOPE", b"BODY", b"DATA"]) && name == b"COLLECTION" { + collection_seen = true; + } + if path_is(&path, &[b"ENVELOPE", b"BODY", b"DATA", b"COLLECTION"]) + && name == b"LEDGER" + { + entries.push(parse_ledger_row(&mut reader, &element)?); + continue; + } + path.push(name); + } + Event::Empty(element) => { + let name = element.name().as_ref().to_ascii_uppercase(); + if path_is(&path, &[b"ENVELOPE", b"BODY", b"DATA"]) && name == b"COLLECTION" { + collection_seen = true; + continue; + } + if path_is(&path, &[b"ENVELOPE", b"BODY", b"DATA", b"COLLECTION"]) + && name == b"LEDGER" + { + return Err(NativeOutstandingsError::InvalidResponse("ledger_row_empty")); + } + } + Event::End(element) => { + let name = element.name().as_ref().to_ascii_uppercase(); + let expected = path.pop().ok_or(NativeOutstandingsError::InvalidResponse( + "ledger_unexpected_close", + ))?; + if expected != name { + return Err(NativeOutstandingsError::InvalidResponse( + "ledger_unexpected_close", + )); + } + } + Event::Eof => break, + _ => {} + } + } + if !path.is_empty() { + return Err(NativeOutstandingsError::InvalidResponse( + "ledger_envelope_unterminated", + )); + } + if !status_seen { + return Err(NativeOutstandingsError::TallyReportedFailure); + } + if !collection_seen { + return Err(NativeOutstandingsError::InvalidResponse( + "ledger_collection_missing", + )); + } + Ok(entries) +} + +fn parse_ledger_row( + reader: &mut Reader<&[u8]>, + element: &BytesStart<'_>, +) -> Result { + let name = attribute_value(element, b"NAME").ok_or( + NativeOutstandingsError::InvalidResponse("ledger_name_missing"), + )?; + let mut parent = None; + let mut closing_balance = None; + let mut opening_balance = None; + let mut bill_wise_on = None; + loop { + match reader + .read_event() + .map_err(|_| NativeOutstandingsError::InvalidResponse("ledger_xml_malformed"))? + { + Event::Start(child) => { + let child_name = child.name().as_ref().to_ascii_uppercase(); + match child_name.as_slice() { + b"PARENT" => { + let text = read_element_text(reader, child.name())?; + if parent.is_some() { + return Err(NativeOutstandingsError::InvalidResponse( + "ledger_duplicate_parent", + )); + } + parent = Some((!text.is_empty()).then_some(text)); + } + b"CLOSINGBALANCE" => { + let text = read_element_text(reader, child.name())?; + if closing_balance.is_some() { + return Err(NativeOutstandingsError::InvalidResponse( + "ledger_duplicate_closing_balance", + )); + } + closing_balance = Some(parse_ledger_amount(text.trim())?); + } + b"OPENINGBALANCE" => { + let text = read_element_text(reader, child.name())?; + if opening_balance.is_some() { + return Err(NativeOutstandingsError::InvalidResponse( + "ledger_duplicate_opening_balance", + )); + } + opening_balance = Some(parse_ledger_amount(text.trim())?); + } + b"ISBILLWISEON" => { + let text = read_element_text(reader, child.name())?; + if bill_wise_on.is_some() { + return Err(NativeOutstandingsError::InvalidResponse( + "ledger_duplicate_bill_wise_flag", + )); + } + bill_wise_on = Some(parse_tally_boolean(&text)?); + } + _ => skip_subtree(reader)?, + } + } + Event::Empty(_) => {} + Event::End(end) if end.name().as_ref().eq_ignore_ascii_case(b"LEDGER") => break, + Event::Eof => { + return Err(NativeOutstandingsError::InvalidResponse( + "ledger_row_unterminated", + )) + } + _ => {} + } + } + Ok(LedgerSnapshotEntry { + name, + parent: parent.flatten(), + closing_balance: closing_balance.ok_or(NativeOutstandingsError::InvalidResponse( + "ledger_closing_balance_missing", + ))?, + opening_balance: opening_balance.ok_or(NativeOutstandingsError::InvalidResponse( + "ledger_opening_balance_missing", + ))?, + bill_wise_on: bill_wise_on.ok_or(NativeOutstandingsError::InvalidResponse( + "ledger_bill_wise_flag_missing", + ))?, + }) +} + +fn skip_subtree(reader: &mut Reader<&[u8]>) -> Result<(), NativeOutstandingsError> { + let mut depth = 1_u32; + loop { + match reader + .read_event() + .map_err(|_| NativeOutstandingsError::InvalidResponse("ledger_xml_malformed"))? + { + Event::Start(_) => depth += 1, + Event::End(_) => { + depth -= 1; + if depth == 0 { + return Ok(()); + } + } + Event::Eof => { + return Err(NativeOutstandingsError::InvalidResponse( + "ledger_subtree_unterminated", + )) + } + _ => {} + } + } +} + +fn parse_tally_boolean(value: &str) -> Result { + if value.eq_ignore_ascii_case("yes") || value.eq_ignore_ascii_case("true") || value == "1" { + Ok(true) + } else if value.eq_ignore_ascii_case("no") + || value.eq_ignore_ascii_case("false") + || value == "0" + { + Ok(false) + } else { + Err(NativeOutstandingsError::InvalidResponse( + "ledger_bill_wise_flag_invalid", + )) + } +} + +fn attribute_value(element: &BytesStart<'_>, key: &[u8]) -> Option { + element + .attributes() + .flatten() + .find(|attribute| attribute.key.as_ref().eq_ignore_ascii_case(key)) + .and_then(|attribute| { + attribute + .normalized_value(quick_xml::XmlVersion::Implicit1_0) + .ok() + }) + .map(|value| value.into_owned()) + .filter(|value| !value.trim().is_empty()) +} + +fn path_is(path: &[Vec], expected: &[&[u8]]) -> bool { + path.len() == expected.len() + && path + .iter() + .zip(expected) + .all(|(segment, name)| segment.as_slice() == *name) +} + +fn read_element_text( + reader: &mut Reader<&[u8]>, + name: QName<'_>, +) -> Result { + let raw = reader + .read_text(name) + .map_err(|_| NativeOutstandingsError::InvalidResponse("native_xml_malformed"))?; + let decoded = raw + .decode() + .map_err(|_| NativeOutstandingsError::InvalidResponse("native_xml_invalid_encoding"))?; + let unescaped = quick_xml::escape::unescape(&decoded) + .map_err(|_| NativeOutstandingsError::InvalidResponse("native_xml_invalid_escape"))?; + Ok(unescaped.trim().to_string()) +} + +use super::model::CompanyCurrency; + +/// Parses the company currency collection. +/// +/// Ordinary (non-inverted) `STATUS` applies here -- this is a `Collection` +/// request, not one of the flat `Data` reports. Rows are read only from +/// ``, because the same `CMPINFO` counter block that inflates a naive +/// ledger scan also carries a bare `0`. +pub fn parse_company_currency(xml: &str) -> Result { + let sanitized = sanitize_invalid_numeric_references(xml); + let mut reader = Reader::from_str(&sanitized); + reader.config_mut().trim_text(true); + let mut path = Vec::>::new(); + let mut status_seen = false; + let mut collection_seen = false; + let mut rows = Vec::new(); + loop { + let event = reader + .read_event() + .map_err(|_| NativeOutstandingsError::InvalidResponse("currency_xml_malformed"))?; + match event { + Event::Start(element) => { + let name = element.name().as_ref().to_ascii_uppercase(); + if path.is_empty() && name != b"ENVELOPE" { + return Err(NativeOutstandingsError::InvalidResponse( + "currency_root_not_envelope", + )); + } + if path_is(&path, &[b"ENVELOPE", b"HEADER"]) && name == b"STATUS" { + let text = read_element_text(&mut reader, element.name())?; + if text.trim() != "1" { + return Err(NativeOutstandingsError::TallyReportedFailure); + } + status_seen = true; + continue; + } + if path_is(&path, &[b"ENVELOPE", b"BODY", b"DATA"]) && name == b"COLLECTION" { + collection_seen = true; + } + if path_is(&path, &[b"ENVELOPE", b"BODY", b"DATA", b"COLLECTION"]) + && name == b"CURRENCY" + { + rows.push(parse_currency_row(&mut reader, &element)?); + continue; + } + path.push(name); + } + Event::Empty(element) => { + let name = element.name().as_ref().to_ascii_uppercase(); + if path_is(&path, &[b"ENVELOPE", b"BODY", b"DATA"]) && name == b"COLLECTION" { + collection_seen = true; + } else if path_is(&path, &[b"ENVELOPE", b"BODY", b"DATA", b"COLLECTION"]) + && name == b"CURRENCY" + { + return Err(NativeOutstandingsError::InvalidResponse( + "currency_row_empty", + )); + } + } + Event::End(element) => { + let expected = path.pop().ok_or(NativeOutstandingsError::InvalidResponse( + "currency_unexpected_close", + ))?; + if expected != element.name().as_ref().to_ascii_uppercase() { + return Err(NativeOutstandingsError::InvalidResponse( + "currency_unexpected_close", + )); + } + } + Event::Eof => break, + _ => {} + } + } + if !path.is_empty() { + return Err(NativeOutstandingsError::InvalidResponse( + "currency_envelope_unterminated", + )); + } + if !status_seen { + return Err(NativeOutstandingsError::TallyReportedFailure); + } + if !collection_seen { + return Err(NativeOutstandingsError::InvalidResponse( + "currency_collection_missing", + )); + } + + let currency_count = rows.len(); + let (symbol, mailing_name) = rows.into_iter().next().unwrap_or_default(); + // Only a single defined currency lets this read name the BASE currency. + // "Rs." is shared by several currencies, so only the observed Indian + // mailing identity is authoritative enough to put ₹ before real money. + let is_inr = currency_count == 1 && mailing_name.eq_ignore_ascii_case("Indian Rupees"); + + Ok(CompanyCurrency { + symbol, + mailing_name, + currency_count, + is_inr, + }) +} + +fn parse_currency_row( + reader: &mut Reader<&[u8]>, + element: &BytesStart<'_>, +) -> Result<(String, String), NativeOutstandingsError> { + let symbol = attribute_value(element, b"NAME").ok_or( + NativeOutstandingsError::InvalidResponse("currency_name_missing"), + )?; + let mut mailing_name = None; + loop { + match reader + .read_event() + .map_err(|_| NativeOutstandingsError::InvalidResponse("currency_xml_malformed"))? + { + Event::Start(child) if child.name().as_ref().eq_ignore_ascii_case(b"MAILINGNAME") => { + let text = read_element_text(reader, child.name())?; + if mailing_name.replace(text).is_some() { + return Err(NativeOutstandingsError::InvalidResponse( + "currency_duplicate_mailing_name", + )); + } + } + Event::Start(_) => skip_subtree(reader)?, + Event::Empty(child) if child.name().as_ref().eq_ignore_ascii_case(b"MAILINGNAME") => { + if mailing_name.replace(String::new()).is_some() { + return Err(NativeOutstandingsError::InvalidResponse( + "currency_duplicate_mailing_name", + )); + } + } + Event::End(end) if end.name().as_ref().eq_ignore_ascii_case(b"CURRENCY") => break, + Event::Eof => { + return Err(NativeOutstandingsError::InvalidResponse( + "currency_row_unterminated", + )) + } + _ => {} + } + } + Ok((symbol, mailing_name.unwrap_or_default())) +} + +#[cfg(test)] +mod currency_tests { + use super::*; + + const LIVE: &str = r#"
1
0Indian Rupees 2
"#; + + #[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" + ); + assert!(currency.is_inr); + } + + #[test] + fn several_currencies_cannot_name_the_base_currency() { + let xml = LIVE.replace( + "
", + r#"US Dollars"#, + ); + let currency = parse_company_currency(&xml).expect("parses"); + assert_eq!(currency.currency_count, 2); + assert!(!currency.is_inr, "must fall back to asking, never guess"); + } + + #[test] + fn a_non_indian_single_currency_is_not_inr() { + let xml = LIVE + .replace("Indian Rupees", "US Dollars") + .replace(r#"NAME="Rs.""#, r#"NAME="$""#); + let currency = parse_company_currency(&xml).expect("parses"); + assert!(!currency.is_inr); + } + + #[test] + fn failed_or_structurally_incomplete_currency_collections_fail_closed() { + for xml in [ + "
0
failed
", + "
1
", + ") -> fmt::Result { - formatter.write_str(match self { - Self::InvalidDateWindow => "outstandings date window is invalid", - Self::InvalidAlterIdRange => "outstandings AlterID range is invalid", - Self::InvalidCompanyIdentity => "outstandings company identity is invalid", - Self::CompanyIdentityMismatch => "Tally returned a different company identity", - Self::InvalidResponse(code) => code, - Self::InvalidAmount => "Tally returned an invalid amount", - Self::ArithmeticOverflow => "outstandings arithmetic exceeded the exact-decimal bound", - }) - } -} - -impl std::error::Error for OutstandingsError {} - /// The complete report period. It cannot be rendered directly as a segment /// request; callers must first partition it into `NarrowDateWindow` values. #[derive(Debug, Clone, PartialEq, Eq)] @@ -370,23 +345,6 @@ impl VoucherAlterId { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct VoucherAlterIdHighWater(u64); - -impl VoucherAlterIdHighWater { - pub fn parse(value: &str) -> Result { - let value = value - .trim() - .parse::() - .map_err(|_| OutstandingsError::InvalidResponse("company_altvchid_invalid"))?; - Ok(Self(value)) - } - - pub fn get(self) -> u64 { - self.0 - } -} - /// A server-side partition expressed as `$AlterID > start AND $AlterID <= end`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct AlterIdRange { @@ -426,45 +384,6 @@ impl AlterIdRange { } } -#[derive(Clone, PartialEq, Eq)] -pub struct PinnedCompany { - name: ValidatedCompanyName, - guid: Arc, -} - -impl PinnedCompany { - pub(crate) fn verified( - name: ValidatedCompanyName, - guid: String, - ) -> Result { - if guid.trim() != guid - || guid.is_empty() - || guid.len() > 255 - || guid.chars().any(char::is_control) - { - return Err(OutstandingsError::InvalidCompanyIdentity); - } - Ok(Self { - name, - guid: Arc::from(guid), - }) - } - - pub fn name(&self) -> &str { - self.name.as_str() - } - - pub fn guid(&self) -> &str { - &self.guid - } -} - -impl fmt::Debug for PinnedCompany { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("PinnedCompany([verified identity])") - } -} - /// Whether the book carries bill-wise OPENING balances on ledger masters. /// /// Those bills exist without any voucher, so a voucher-only scan cannot see @@ -500,43 +419,6 @@ impl LedgerOpeningCoverage { } } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CompanyBookExtent { - company: PinnedCompany, - books_from: TallyDate, - last_voucher_date: TallyDate, - voucher_alter_id_high_water: Option, -} - -impl CompanyBookExtent { - pub(crate) fn new( - company: PinnedCompany, - books_from: TallyDate, - last_voucher_date: TallyDate, - voucher_alter_id_high_water: Option, - ) -> Self { - Self { - company, - books_from, - last_voucher_date, - voucher_alter_id_high_water, - } - } - - pub fn company(&self) -> &PinnedCompany { - &self.company - } - pub fn books_from(&self) -> &TallyDate { - &self.books_from - } - pub fn last_voucher_date(&self) -> &TallyDate { - &self.last_voucher_date - } - pub fn voucher_alter_id_high_water(&self) -> Option { - self.voucher_alter_id_high_water - } -} - #[derive(Debug, Clone, PartialEq, Eq)] pub enum MoneyValue { Exact(ExactDecimal), @@ -874,49 +756,8 @@ pub enum ScanResult { Partial(PartialScan), } -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct AgeingBuckets { - pub days_0_30: ExactDecimal, - pub days_31_60: ExactDecimal, - pub days_61_90: ExactDecimal, - pub days_90_plus: ExactDecimal, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct AgeingBillCounts { - pub days_0_30: usize, - pub days_31_60: usize, - pub days_61_90: usize, - pub days_90_plus: usize, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct PartyOutstanding { - pub party: String, - pub receivable: ExactDecimal, - pub payable: ExactDecimal, - pub outstanding_total: ExactDecimal, - /// `None` means this party's open exposure is entirely On Account, which - /// has no bill reference and therefore no truthful bill age. - /// TALLY_PROTOCOL_REFERENCE.md §12a.2 records that On Account is not aged; - /// §12a.4 records that Tally strips its name. - pub oldest_bill_age_days: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct OutstandingsReport { - pub company_name: String, - pub as_of_yyyymmdd: String, - pub receivable_total: ExactDecimal, - pub payable_total: ExactDecimal, - /// At least one observed receivable On Account allocation is included in - /// `receivable_total` but cannot be assigned a truthful bill age. - /// TALLY_PROTOCOL_REFERENCE.md §12a.2 records that On Account is not aged. - pub has_unaged_receivable: bool, - pub ageing: AgeingBuckets, - pub open_receivable_bill_count: usize, - pub ageing_bill_counts: AgeingBillCounts, - pub top_parties: Vec, - pub source_voucher_count: usize, - pub source_bytes: usize, -} +// `AgeingBuckets`, `AgeingBillCounts`, `PartyOutstanding`, and +// `OutstandingsReport` -- the shared report contract both the voucher-scan +// and native read paths produce -- live in `crate::outstandings_shared` and +// are re-exported by `outstandings::mod` for this module's own internal +// `super::` references (see `compute.rs`). 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 653b26b6..5c4cfd0f 100644 --- a/src-tauri/crates/bridge-tally-protocol/src/outstandings/parser.rs +++ b/src-tauri/crates/bridge-tally-protocol/src/outstandings/parser.rs @@ -1,17 +1,16 @@ use bridge_tally_primitives::{ExactDecimal, TallyDate}; use std::collections::{BTreeMap, BTreeSet}; -use crate::xml_read_profiles::ValidatedCompanyName; +use crate::outstandings_shared::{OutstandingsError, PinnedCompany}; +use crate::tolerant_xml::sanitize_invalid_numeric_references; use super::{ - tolerant_xml::sanitize_invalid_numeric_references, wire::{ - CompanyCollection, Envelope, Header, LedgerCollection, RawBillAllocation, RawLedgerEntry, - RawVoucher, RawWitnessVoucher, VoucherCollection, WitnessVoucherCollection, + Envelope, Header, LedgerCollection, RawBillAllocation, RawLedgerEntry, RawVoucher, + RawWitnessVoucher, VoucherCollection, WitnessVoucherCollection, }, - AlterIdRange, BillAllocation, BillReferenceKind, CompanyBookExtent, DateWindow, LedgerEntry, - LedgerOpeningCoverage, MoneyValue, OutstandingsError, PinnedCompany, Voucher, VoucherAlterId, - VoucherAlterIdHighWater, WitnessVoucher, + AlterIdRange, BillAllocation, BillReferenceKind, DateWindow, LedgerEntry, + LedgerOpeningCoverage, MoneyValue, Voucher, VoucherAlterId, WitnessVoucher, }; pub(super) struct ParsedSegment { @@ -24,61 +23,6 @@ pub(super) struct ParsedWitnessSegment { pub(super) raw_row_count: usize, } -pub fn parse_company_book_extent( - xml: &str, - expected_name: &str, - expected_guid: &str, -) -> Result { - require_complete_envelope(xml)?; - let sanitized = sanitize_invalid_numeric_references(xml); - let parsed: Envelope = quick_xml::de::from_str(&sanitized) - .map_err(|_| OutstandingsError::InvalidResponse("company_extent_xml_invalid"))?; - require_success(&parsed.header)?; - let mut matching = parsed - .body - .data - .collection - .companies - .into_iter() - .filter(|raw| raw.guid.text.trim().eq_ignore_ascii_case(expected_guid)); - let raw = matching - .next() - .ok_or(OutstandingsError::CompanyIdentityMismatch)?; - if matching.next().is_some() { - return Err(OutstandingsError::InvalidResponse( - "company_identity_ambiguous", - )); - } - let name = required(raw.name.text, "company_name_missing")?; - let guid = required(raw.guid.text, "company_guid_missing")?; - if raw.attribute_name != name - || name != expected_name - || !guid.eq_ignore_ascii_case(expected_guid) - { - return Err(OutstandingsError::CompanyIdentityMismatch); - } - let name = - ValidatedCompanyName::new(name).map_err(|_| OutstandingsError::InvalidCompanyIdentity)?; - let company = PinnedCompany::verified(name, guid)?; - let books_from = parse_date(raw.books_from.text)?; - let last_voucher_date = parse_date(raw.last_voucher_date.text)?; - let voucher_alter_id_high_water = raw - .alter_voucher_id - .map(|value| VoucherAlterIdHighWater::parse(&value.text)) - .transpose()?; - if books_from > last_voucher_date { - return Err(OutstandingsError::InvalidResponse( - "company_extent_reversed", - )); - } - Ok(CompanyBookExtent::new( - company, - books_from, - last_voucher_date, - voucher_alter_id_high_water, - )) -} - /// Detect bill-wise OPENING balances on ledger masters. /// /// **Known limitation — offsetting opening bills are not detected.** This works diff --git a/src-tauri/crates/bridge-tally-protocol/src/outstandings/request.rs b/src-tauri/crates/bridge-tally-protocol/src/outstandings/request.rs index 5d4db0c4..86046f0e 100644 --- a/src-tauri/crates/bridge-tally-protocol/src/outstandings/request.rs +++ b/src-tauri/crates/bridge-tally-protocol/src/outstandings/request.rs @@ -4,7 +4,6 @@ use super::{AlterIdRange, NarrowDateWindow, PinnedCompany}; enum CollectionName { VoucherOutstandingsV1, VoucherEmptyPartitionWitnessV1, - CompanyBookExtentV1, LedgerOpeningCoverageV1, } @@ -13,7 +12,6 @@ impl CollectionName { match self { Self::VoucherOutstandingsV1 => "BridgeVoucherOutstandingsV1", Self::VoucherEmptyPartitionWitnessV1 => "BridgeVoucherEmptyPartitionWitnessV1", - Self::CompanyBookExtentV1 => "BridgeCompanyBookExtentV1", Self::LedgerOpeningCoverageV1 => "BridgeLedgerOpeningCoverageV1", } } @@ -22,7 +20,6 @@ impl CollectionName { #[derive(Clone, Copy)] enum ObjectType { Voucher, - Company, Ledger, } @@ -30,7 +27,6 @@ impl ObjectType { const fn as_str(self) -> &'static str { match self { Self::Voucher => "Voucher", - Self::Company => "Company", Self::Ledger => "Ledger", } } @@ -86,27 +82,6 @@ impl VoucherFetchField { } } -#[derive(Clone, Copy)] -enum CompanyFetchField { - Name, - Guid, - BooksFrom, - LastVoucherDate, - AlterVoucherId, -} - -impl CompanyFetchField { - const fn as_str(self) -> &'static str { - match self { - Self::Name => "Name", - Self::Guid => "GUID", - Self::BooksFrom => "BooksFrom", - Self::LastVoucherDate => "LastVoucherDate", - Self::AlterVoucherId => "ALTVCHID", - } - } -} - #[derive(Clone, Copy)] enum LedgerFetchField { Guid, @@ -155,12 +130,6 @@ struct VoucherCollectionDefinition { filter: FilterName, } -struct CompanyCollectionDefinition { - name: CollectionName, - object_type: ObjectType, - fetch: &'static [CompanyFetchField], -} - const OUTSTANDINGS_DEFINITION: VoucherCollectionDefinition = VoucherCollectionDefinition { name: CollectionName::VoucherOutstandingsV1, object_type: ObjectType::Voucher, @@ -194,18 +163,6 @@ const EMPTY_PARTITION_WITNESS_DEFINITION: VoucherCollectionDefinition = filter: FilterName::EmptyPartitionWitnessDateV1, }; -const COMPANY_EXTENT_DEFINITION: CompanyCollectionDefinition = CompanyCollectionDefinition { - name: CollectionName::CompanyBookExtentV1, - object_type: ObjectType::Company, - fetch: &[ - CompanyFetchField::Name, - CompanyFetchField::Guid, - CompanyFetchField::BooksFrom, - CompanyFetchField::LastVoucherDate, - CompanyFetchField::AlterVoucherId, - ], -}; - /// Wire request admitted to the outstandings-specific transport cap. Only the /// closed profile builder in this module can construct it. /// @@ -407,22 +364,6 @@ fn render_empty_partition_witness(company: &str, from: &str, to: &str) -> String ) } -pub(crate) fn render_company_book_extent(company: &str) -> String { - format!( - r#" -
1ExportCollection{collection}
- - $$SysName:XML{company} - {object_type}{fetch} - -
"#, - collection = COMPANY_EXTENT_DEFINITION.name.as_str(), - company = xml_escape(company), - object_type = COMPANY_EXTENT_DEFINITION.object_type.as_str(), - fetch = render_company_fetch(COMPANY_EXTENT_DEFINITION.fetch), - ) -} - pub(crate) fn render_ledger_opening_coverage(company: &str) -> String { format!( r#" @@ -452,14 +393,6 @@ fn render_voucher_fetch(fields: &[VoucherFetchField]) -> String { .join(", ") } -fn render_company_fetch(fields: &[CompanyFetchField]) -> String { - fields - .iter() - .map(|field| field.as_str()) - .collect::>() - .join(", ") -} - fn xml_escape(value: &str) -> String { value .replace('&', "&") @@ -484,7 +417,6 @@ mod tests { assert_eq!(xml.matches("ALLLEDGERENTRIES.*").count(), 1); assert!(xml.contains("Synthetic & Company")); assert!(xml.contains("$AlterID > 400 AND $AlterID <= 800")); - assert!(render_company_book_extent("Synthetic").contains("ALTVCHID")); } #[test] 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 5dfba87a..f003c466 100644 --- a/src-tauri/crates/bridge-tally-protocol/src/outstandings/wire.rs +++ b/src-tauri/crates/bridge-tally-protocol/src/outstandings/wire.rs @@ -1,58 +1,12 @@ use serde::Deserialize; -#[derive(Deserialize)] -pub(super) struct Envelope { - #[serde(rename = "HEADER")] - pub(super) header: Header, - #[serde(rename = "BODY")] - pub(super) body: Body, -} - -#[derive(Deserialize)] -pub(super) struct Header { - #[serde(rename = "STATUS")] - pub(super) status: String, -} - -#[derive(Deserialize)] -pub(super) struct Body { - #[serde(rename = "DATA")] - pub(super) data: Data, -} - -#[derive(Deserialize)] -pub(super) struct Data { - #[serde(rename = "COLLECTION")] - pub(super) collection: T, -} - -#[derive(Default, Deserialize)] -pub(super) struct Value { - #[serde(rename = "$text", default)] - pub(super) text: String, -} - -#[derive(Deserialize)] -pub(super) struct CompanyCollection { - #[serde(rename = "COMPANY", default)] - pub(super) companies: Vec, -} - -#[derive(Deserialize)] -pub(super) struct RawCompany { - #[serde(rename = "@NAME")] - pub(super) attribute_name: String, - #[serde(rename = "NAME")] - pub(super) name: Value, - #[serde(rename = "GUID")] - pub(super) guid: Value, - #[serde(rename = "BOOKSFROM")] - pub(super) books_from: Value, - #[serde(rename = "LASTVOUCHERDATE")] - pub(super) last_voucher_date: Value, - #[serde(rename = "ALTVCHID", default)] - pub(super) alter_voucher_id: Option, -} +// The generic envelope scaffold (`Envelope`/`Header`/`Body`/`Data`) and the +// bare-text `Value` leaf live in `crate::outstandings_shared` because +// `CompanyBookExtent` parsing -- needed by both the native and voucher-scan +// read paths -- uses them too. Re-exported here (rather than duplicated) so +// this module's own scan-only collections keep the same names they always +// had. +pub(super) use crate::outstandings_shared::{Envelope, Header, Value}; #[derive(Deserialize)] pub(super) struct RawLedgerMaster { diff --git a/src-tauri/crates/bridge-tally-protocol/src/outstandings_shared.rs b/src-tauri/crates/bridge-tally-protocol/src/outstandings_shared.rs new file mode 100644 index 00000000..f2a989cc --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/src/outstandings_shared.rs @@ -0,0 +1,390 @@ +//! The outstandings report contract, plus the company-identity/book-extent +//! read both outstandings read strategies pin against, before they diverge. +//! +//! Two independent strategies produce an [`OutstandingsReport`]: +//! +//! - `native_outstandings` -- always compiled -- reads Tally's own `Bills +//! Receivable`/`Bills Payable` and `List of Ledgers` reports directly. +//! - `outstandings` -- compiled only under the `voucher-scan` feature -- +//! scans every voucher in a date/AlterID-partitioned wildcard fetch and +//! derives outstandings from bill allocations. +//! +//! Both begin by pinning the same verified company identity and book extent +//! (`PinnedCompany`, `CompanyBookExtent`, via `parse_company_book_extent`), +//! and both end by producing the same report shape (`OutstandingsReport` and +//! its constituents). None of that is scan machinery -- no date +//! partitioning, no AlterID segmentation, no wildcard voucher fetch -- so it +//! lives here, ungated, rather than inside `outstandings`. Gating +//! `outstandings` wholesale would have taken this out with it and broken the +//! native path, which is the live product path today. +//! +//! Everything scan-specific (`DateWindow`, `AlterIdRange`, `Voucher`, +//! `LedgerEntry`, `BillAllocation`, segment/witness completeness proofs, the +//! wildcard voucher request) stays in `outstandings`, gated behind +//! `voucher-scan`. + +use std::{fmt, sync::Arc}; + +use bridge_tally_primitives::{ExactDecimal, TallyDate}; +use serde::{Deserialize, Serialize}; + +use crate::tolerant_xml::sanitize_invalid_numeric_references; +use crate::xml_read_profiles::ValidatedCompanyName; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OutstandingsError { + InvalidDateWindow, + InvalidAlterIdRange, + InvalidCompanyIdentity, + CompanyIdentityMismatch, + InvalidResponse(&'static str), + InvalidAmount, + ArithmeticOverflow, +} + +impl fmt::Display for OutstandingsError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::InvalidDateWindow => "outstandings date window is invalid", + Self::InvalidAlterIdRange => "outstandings AlterID range is invalid", + Self::InvalidCompanyIdentity => "outstandings company identity is invalid", + Self::CompanyIdentityMismatch => "Tally returned a different company identity", + Self::InvalidResponse(code) => code, + Self::InvalidAmount => "Tally returned an invalid amount", + Self::ArithmeticOverflow => "outstandings arithmetic exceeded the exact-decimal bound", + }) + } +} + +impl std::error::Error for OutstandingsError {} + +#[derive(Clone, PartialEq, Eq)] +pub struct PinnedCompany { + name: ValidatedCompanyName, + guid: Arc, +} + +impl PinnedCompany { + pub(crate) fn verified( + name: ValidatedCompanyName, + guid: String, + ) -> Result { + if guid.trim() != guid + || guid.is_empty() + || guid.len() > 255 + || guid.chars().any(char::is_control) + { + return Err(OutstandingsError::InvalidCompanyIdentity); + } + Ok(Self { + name, + guid: Arc::from(guid), + }) + } + + pub fn name(&self) -> &str { + self.name.as_str() + } + + pub fn guid(&self) -> &str { + &self.guid + } +} + +impl fmt::Debug for PinnedCompany { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("PinnedCompany([verified identity])") + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct VoucherAlterIdHighWater(u64); + +impl VoucherAlterIdHighWater { + pub fn parse(value: &str) -> Result { + let value = value + .trim() + .parse::() + .map_err(|_| OutstandingsError::InvalidResponse("company_altvchid_invalid"))?; + Ok(Self(value)) + } + + pub fn get(self) -> u64 { + self.0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CompanyBookExtent { + company: PinnedCompany, + books_from: TallyDate, + last_voucher_date: TallyDate, + voucher_alter_id_high_water: Option, +} + +impl CompanyBookExtent { + pub(crate) fn new( + company: PinnedCompany, + books_from: TallyDate, + last_voucher_date: TallyDate, + voucher_alter_id_high_water: Option, + ) -> Self { + Self { + company, + books_from, + last_voucher_date, + voucher_alter_id_high_water, + } + } + + pub fn company(&self) -> &PinnedCompany { + &self.company + } + pub fn books_from(&self) -> &TallyDate { + &self.books_from + } + pub fn last_voucher_date(&self) -> &TallyDate { + &self.last_voucher_date + } + pub fn voucher_alter_id_high_water(&self) -> Option { + self.voucher_alter_id_high_water + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AgeingBuckets { + pub days_0_30: ExactDecimal, + pub days_31_60: ExactDecimal, + pub days_61_90: ExactDecimal, + pub days_90_plus: ExactDecimal, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AgeingBillCounts { + pub days_0_30: usize, + pub days_31_60: usize, + pub days_61_90: usize, + pub days_90_plus: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PartyOutstanding { + pub party: String, + pub receivable: ExactDecimal, + pub payable: ExactDecimal, + pub outstanding_total: ExactDecimal, + /// `None` means this party's open exposure is entirely On Account, which + /// has no bill reference and therefore no truthful bill age. + /// TALLY_PROTOCOL_REFERENCE.md §12a.2 records that On Account is not aged; + /// §12a.4 records that Tally strips its name. + pub oldest_bill_age_days: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct OutstandingsReport { + pub company_name: String, + pub as_of_yyyymmdd: String, + pub receivable_total: ExactDecimal, + pub payable_total: ExactDecimal, + /// At least one observed receivable On Account allocation is included in + /// `receivable_total` but cannot be assigned a truthful bill age. + /// TALLY_PROTOCOL_REFERENCE.md §12a.2 records that On Account is not aged. + pub has_unaged_receivable: bool, + pub ageing: AgeingBuckets, + pub open_receivable_bill_count: usize, + pub ageing_bill_counts: AgeingBillCounts, + pub top_parties: Vec, + pub source_voucher_count: usize, + pub source_bytes: usize, +} + +// --- Wire scaffold + parsing for the paired `CompanyBookExtentV1` read. --- +// +// `outstandings::wire` re-exports the generic `Envelope`/`Header`/`Body`/ +// `Data`/`Value` scaffold from here rather than duplicating it, so its own +// scan-only collections (vouchers, ledger openings) keep using the same +// names they always had. + +#[derive(Deserialize)] +pub(crate) struct Envelope { + #[serde(rename = "HEADER")] + pub(crate) header: Header, + #[serde(rename = "BODY")] + pub(crate) body: Body, +} + +#[derive(Deserialize)] +pub(crate) struct Header { + #[serde(rename = "STATUS")] + pub(crate) status: String, +} + +#[derive(Deserialize)] +pub(crate) struct Body { + #[serde(rename = "DATA")] + pub(crate) data: Data, +} + +#[derive(Deserialize)] +pub(crate) struct Data { + #[serde(rename = "COLLECTION")] + pub(crate) collection: T, +} + +#[derive(Default, Deserialize)] +pub(crate) struct Value { + #[serde(rename = "$text", default)] + pub(crate) text: String, +} + +#[derive(Deserialize)] +struct CompanyCollection { + #[serde(rename = "COMPANY", default)] + companies: Vec, +} + +#[derive(Deserialize)] +struct RawCompany { + #[serde(rename = "@NAME")] + attribute_name: String, + #[serde(rename = "NAME")] + name: Value, + #[serde(rename = "GUID")] + guid: Value, + #[serde(rename = "BOOKSFROM")] + books_from: Value, + #[serde(rename = "LASTVOUCHERDATE")] + last_voucher_date: Value, + #[serde(rename = "ALTVCHID", default)] + alter_voucher_id: Option, +} + +pub fn parse_company_book_extent( + xml: &str, + expected_name: &str, + expected_guid: &str, +) -> Result { + require_complete_envelope(xml)?; + let sanitized = sanitize_invalid_numeric_references(xml); + let parsed: Envelope = quick_xml::de::from_str(&sanitized) + .map_err(|_| OutstandingsError::InvalidResponse("company_extent_xml_invalid"))?; + require_success(&parsed.header)?; + let mut matching = parsed + .body + .data + .collection + .companies + .into_iter() + .filter(|raw| raw.guid.text.trim().eq_ignore_ascii_case(expected_guid)); + let raw = matching + .next() + .ok_or(OutstandingsError::CompanyIdentityMismatch)?; + if matching.next().is_some() { + return Err(OutstandingsError::InvalidResponse( + "company_identity_ambiguous", + )); + } + let name = required(raw.name.text, "company_name_missing")?; + let guid = required(raw.guid.text, "company_guid_missing")?; + if raw.attribute_name != name + || name != expected_name + || !guid.eq_ignore_ascii_case(expected_guid) + { + return Err(OutstandingsError::CompanyIdentityMismatch); + } + let name = + ValidatedCompanyName::new(name).map_err(|_| OutstandingsError::InvalidCompanyIdentity)?; + let company = PinnedCompany::verified(name, guid)?; + let books_from = parse_date(raw.books_from.text)?; + let last_voucher_date = parse_date(raw.last_voucher_date.text)?; + let voucher_alter_id_high_water = raw + .alter_voucher_id + .map(|value| VoucherAlterIdHighWater::parse(&value.text)) + .transpose()?; + if books_from > last_voucher_date { + return Err(OutstandingsError::InvalidResponse( + "company_extent_reversed", + )); + } + Ok(CompanyBookExtent::new( + company, + books_from, + last_voucher_date, + voucher_alter_id_high_water, + )) +} + +fn require_complete_envelope(xml: &str) -> Result<(), OutstandingsError> { + if !xml.trim_end().ends_with("") { + return Err(OutstandingsError::InvalidResponse("response_truncated")); + } + Ok(()) +} + +fn require_success(header: &Header) -> Result<(), OutstandingsError> { + if header.status.trim() == "1" { + Ok(()) + } else { + Err(OutstandingsError::InvalidResponse( + "tally_status_not_success", + )) + } +} + +fn required(value: String, code: &'static str) -> Result { + let value = value.trim().to_string(); + if value.is_empty() { + Err(OutstandingsError::InvalidResponse(code)) + } else { + Ok(value) + } +} + +fn parse_date(value: String) -> Result { + TallyDate::parse(value.trim().to_string()) + .map_err(|_| OutstandingsError::InvalidResponse("tally_date_invalid")) +} + +// --- Request rendering for the paired `CompanyBookExtentV1` read. --- + +const COMPANY_EXTENT_COLLECTION_NAME: &str = "BridgeCompanyBookExtentV1"; +const COMPANY_EXTENT_FETCH: &str = "Name, GUID, BooksFrom, LastVoucherDate, ALTVCHID"; + +pub(crate) fn render_company_book_extent(company: &str) -> String { + format!( + r#" +
1ExportCollection{collection}
+ + $$SysName:XML{company} + Company{fetch} + +
"#, + collection = COMPANY_EXTENT_COLLECTION_NAME, + company = xml_escape(company), + fetch = COMPANY_EXTENT_FETCH, + ) +} + +fn xml_escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn company_book_extent_template_has_only_the_verified_fetch_list() { + let xml = render_company_book_extent("Synthetic & Company"); + assert!(xml.contains("BridgeCompanyBookExtentV1")); + assert!(xml.contains("ALTVCHID")); + assert!(xml.contains("Synthetic & Company")); + assert!(!xml.contains("")); + assert!(!xml.contains("$$NumItems")); + } +} diff --git a/src-tauri/crates/bridge-tally-protocol/src/outstandings/tolerant_xml.rs b/src-tauri/crates/bridge-tally-protocol/src/tolerant_xml.rs similarity index 98% rename from src-tauri/crates/bridge-tally-protocol/src/outstandings/tolerant_xml.rs rename to src-tauri/crates/bridge-tally-protocol/src/tolerant_xml.rs index 48902304..5d131c10 100644 --- a/src-tauri/crates/bridge-tally-protocol/src/outstandings/tolerant_xml.rs +++ b/src-tauri/crates/bridge-tally-protocol/src/tolerant_xml.rs @@ -69,7 +69,7 @@ fn find_numeric_reference_terminator(reference: &str) -> Option { search.find(';') } -pub(super) fn sanitize_invalid_numeric_references(xml: &str) -> Cow<'_, str> { +pub(crate) fn sanitize_invalid_numeric_references(xml: &str) -> Cow<'_, str> { sanitize_invalid_numeric_references_with_marker_search_observer(xml, || {}) } @@ -182,7 +182,7 @@ mod tests { #[test] fn real_invalid_character_reference_is_narrowly_repaired() { - let capture = include_str!("../../tests/fixtures/unit_a_invalid_char_ref_live.xml"); + let capture = include_str!("../tests/fixtures/unit_a_invalid_char_ref_live.xml"); assert!(capture.contains("")); let sanitized = sanitize_invalid_numeric_references(capture); assert!(!sanitized.contains("")); diff --git a/src-tauri/crates/bridge-tally-protocol/src/xml_read_profiles.rs b/src-tauri/crates/bridge-tally-protocol/src/xml_read_profiles.rs index 90644652..7b621c98 100644 --- a/src-tauri/crates/bridge-tally-protocol/src/xml_read_profiles.rs +++ b/src-tauri/crates/bridge-tally-protocol/src/xml_read_profiles.rs @@ -9,17 +9,22 @@ use std::fmt; use sha2::{Digest, Sha256}; +#[cfg(feature = "voucher-scan")] use crate::outstandings::{ - render_company_book_extent, render_empty_partition_witness_template, - render_ledger_opening_coverage, render_outstandings_template, render_outstandings_vouchers, - AlterIdRange, NarrowDateWindow, PinnedCompany, + render_empty_partition_witness_template, render_ledger_opening_coverage, + render_outstandings_template, render_outstandings_vouchers, AlterIdRange, NarrowDateWindow, }; +use crate::outstandings_shared::render_company_book_extent; +#[cfg(feature = "voucher-scan")] +use crate::outstandings_shared::PinnedCompany; use crate::{BRIDGE_LEDGER_EXPORT_SCHEMA, BRIDGE_LEDGER_WRITE_READBACK_SCHEMA}; const TEMPLATE_COMPANY: &str = "BRIDGE TEMPLATE COMPANY"; const TEMPLATE_FROM: &str = "20000101"; const TEMPLATE_TO: &str = "20000102"; +#[cfg(feature = "voucher-scan")] const TEMPLATE_ALTER_ID_START: u64 = 0; +#[cfg(feature = "voucher-scan")] const TEMPLATE_ALTER_ID_END: u64 = 1; const TEMPLATE_CANARY_LEDGER: &str = "BRIDGE-CANARY-LEDGER-001"; const BRIDGE_CANARY_LEDGER_PREFIX: &str = "BRIDGE-CANARY-"; @@ -166,7 +171,9 @@ impl ValidatedDateRange { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ReadOnlyProfileId { CompanyListV1, + CompanyListV2, CompanyBookExtentV1, + #[cfg(feature = "voucher-scan")] LedgerOpeningCoverageV1, StandardLedgerIdentityV1, StandardLedgerCatalogV1, @@ -174,7 +181,9 @@ pub enum ReadOnlyProfileId { LedgerCanaryReadbackV1, VouchersV2, VouchersV3, + #[cfg(feature = "voucher-scan")] VoucherOutstandingsV1, + #[cfg(feature = "voucher-scan")] VoucherEmptyPartitionWitnessV1, } @@ -182,7 +191,9 @@ impl ReadOnlyProfileId { pub fn as_str(self) -> &'static str { match self { Self::CompanyListV1 => "company_list_v1", + Self::CompanyListV2 => "company_list_v2", Self::CompanyBookExtentV1 => "company_book_extent_v1", + #[cfg(feature = "voucher-scan")] Self::LedgerOpeningCoverageV1 => "ledger_opening_coverage_v1", Self::StandardLedgerIdentityV1 => "standard_ledger_identity_v1", Self::StandardLedgerCatalogV1 => "standard_ledger_catalog_v1", @@ -190,7 +201,9 @@ impl ReadOnlyProfileId { Self::LedgerCanaryReadbackV1 => "ledger_canary_readback_v1", Self::VouchersV2 => "vouchers_v2", Self::VouchersV3 => "vouchers_v3", + #[cfg(feature = "voucher-scan")] Self::VoucherOutstandingsV1 => "voucher_outstandings_v1", + #[cfg(feature = "voucher-scan")] Self::VoucherEmptyPartitionWitnessV1 => "voucher_empty_partition_witness_v1", } } @@ -201,7 +214,9 @@ impl ReadOnlyProfileId { pub fn template_sha256(self) -> String { let template = match self { Self::CompanyListV1 => render_company_list(), + Self::CompanyListV2 => render_company_list_v2(), Self::CompanyBookExtentV1 => render_company_book_extent(TEMPLATE_COMPANY), + #[cfg(feature = "voucher-scan")] Self::LedgerOpeningCoverageV1 => render_ledger_opening_coverage(TEMPLATE_COMPANY), Self::StandardLedgerIdentityV1 => render_standard_ledger_identity(TEMPLATE_COMPANY), Self::StandardLedgerCatalogV1 => render_standard_ledger_identity(TEMPLATE_COMPANY), @@ -215,6 +230,7 @@ impl ReadOnlyProfileId { Self::VouchersV3 => { render_selected_vouchers(TEMPLATE_COMPANY, TEMPLATE_FROM, TEMPLATE_TO) } + #[cfg(feature = "voucher-scan")] Self::VoucherOutstandingsV1 => render_outstandings_template( TEMPLATE_COMPANY, TEMPLATE_FROM, @@ -222,6 +238,7 @@ impl ReadOnlyProfileId { TEMPLATE_ALTER_ID_START, TEMPLATE_ALTER_ID_END, ), + #[cfg(feature = "voucher-scan")] Self::VoucherEmptyPartitionWitnessV1 => render_empty_partition_witness_template( TEMPLATE_COMPANY, TEMPLATE_FROM, @@ -235,6 +252,13 @@ impl ReadOnlyProfileId { #[derive(Debug, Clone, Copy)] pub enum ReadOnlyProfile<'a> { CompanyListV1, + /// Tally's documented `Company` collection (`TYPE=Collection`), fetching + /// only `NAME` and `GUID`. Unlike `CompanyListV1`'s custom TDL report, + /// this returns the ordinary shaped `HEADER/STATUS=1` success envelope, + /// so it can satisfy the trust check instead of being parsed as an + /// unverified direct report. + CompanyListV2, + #[cfg(feature = "voucher-scan")] LedgerOpeningCoverageV1 { company: &'a ValidatedCompanyName, }, @@ -273,11 +297,13 @@ pub enum ReadOnlyProfile<'a> { company: &'a ValidatedCompanyName, range: &'a ValidatedDateRange, }, + #[cfg(feature = "voucher-scan")] VoucherOutstandingsV1 { company: &'a PinnedCompany, window: &'a NarrowDateWindow, alter_id_range: AlterIdRange, }, + #[cfg(feature = "voucher-scan")] VoucherEmptyPartitionWitnessV1 { company: &'a PinnedCompany, window: &'a NarrowDateWindow, @@ -288,7 +314,9 @@ impl ReadOnlyProfile<'_> { pub fn id(self) -> ReadOnlyProfileId { match self { Self::CompanyListV1 => ReadOnlyProfileId::CompanyListV1, + Self::CompanyListV2 => ReadOnlyProfileId::CompanyListV2, Self::CompanyBookExtentV1 { .. } => ReadOnlyProfileId::CompanyBookExtentV1, + #[cfg(feature = "voucher-scan")] Self::LedgerOpeningCoverageV1 { .. } => ReadOnlyProfileId::LedgerOpeningCoverageV1, Self::StandardLedgerIdentityV1 { .. } => ReadOnlyProfileId::StandardLedgerIdentityV1, Self::StandardLedgerCatalogV1 { .. } => ReadOnlyProfileId::StandardLedgerCatalogV1, @@ -296,7 +324,9 @@ impl ReadOnlyProfile<'_> { Self::LedgerCanaryReadbackV1 { .. } => ReadOnlyProfileId::LedgerCanaryReadbackV1, Self::VouchersV2 { .. } => ReadOnlyProfileId::VouchersV2, Self::VouchersV3 { .. } => ReadOnlyProfileId::VouchersV3, + #[cfg(feature = "voucher-scan")] Self::VoucherOutstandingsV1 { .. } => ReadOnlyProfileId::VoucherOutstandingsV1, + #[cfg(feature = "voucher-scan")] Self::VoucherEmptyPartitionWitnessV1 { .. } => { ReadOnlyProfileId::VoucherEmptyPartitionWitnessV1 } @@ -310,7 +340,9 @@ impl ReadOnlyProfile<'_> { pub fn render(self) -> String { match self { Self::CompanyListV1 => render_company_list(), + Self::CompanyListV2 => render_company_list_v2(), Self::CompanyBookExtentV1 { company } => render_company_book_extent(company.as_str()), + #[cfg(feature = "voucher-scan")] Self::LedgerOpeningCoverageV1 { company } => { render_ledger_opening_coverage(company.as_str()) } @@ -338,11 +370,13 @@ impl ReadOnlyProfile<'_> { range.from_yyyymmdd(), range.to_yyyymmdd(), ), + #[cfg(feature = "voucher-scan")] Self::VoucherOutstandingsV1 { company, window, alter_id_range, } => render_outstandings_vouchers(company, window, alter_id_range), + #[cfg(feature = "voucher-scan")] Self::VoucherEmptyPartitionWitnessV1 { company, window } => { crate::outstandings::voucher_empty_partition_witness_request(company, window) .into_xml() @@ -441,6 +475,42 @@ fn render_company_list() -> String { .to_string() } +/// Tally's documented `Company` collection, fetching only `NAME` and `GUID`. +/// Unlike `render_company_list`'s custom TDL report — which Tally answers +/// with a bare `...` document carrying no +/// `HEADER`/`STATUS` at all — a `TYPE=Collection` export returns the +/// ordinary shaped success envelope, so its response can satisfy the same +/// `HEADER/STATUS=1` trust check every other export profile requires. +fn render_company_list_v2() -> String { + r#" + +
+ 1 + Export + Collection + BridgeCompanyExtent +
+ + + + $$SysName:XML + + + + + Company + NAME,GUID + + + + + +
+"# + .trim() + .to_string() +} + fn render_standard_ledger_identity(company: &str) -> String { format!( r#" @@ -821,9 +891,10 @@ mod tests { range: &'a ValidatedDateRange, canary_ledger: &'a ValidatedCanaryLedgerName, identity_query_sha256: &'a ValidatedIdentityQuerySha256, - ) -> [ReadOnlyProfile<'a>; 7] { + ) -> [ReadOnlyProfile<'a>; 8] { [ ReadOnlyProfile::CompanyListV1, + ReadOnlyProfile::CompanyListV2, ReadOnlyProfile::StandardLedgerIdentityV1 { company }, ReadOnlyProfile::StandardLedgerCatalogV1 { company }, ReadOnlyProfile::LedgersV1 { company }, @@ -951,63 +1022,71 @@ mod tests { assert!(!bootstrap.contains("IMPORT")); assert!(bootstrap.contains("</SVCURRENTCOMPANY>")); - let pinned = PinnedCompany::verified(injection.clone(), "synthetic-guid".to_string()) - .expect("verified test identity"); - let window = crate::outstandings::DateWindow::parse( - crate::outstandings::DateBoundaryProfile::EducationRestricted, - "20260401", - "20260402", - ) - .unwrap() - .narrow_partitions() - .unwrap() - .remove(0); - for request in [ - ReadOnlyProfile::CompanyBookExtentV1 { - company: &injection, - } - .render(), - ReadOnlyProfile::VoucherOutstandingsV1 { - company: &pinned, - window: &window, - alter_id_range: AlterIdRange::new(0, 1).unwrap(), + let extent_request = ReadOnlyProfile::CompanyBookExtentV1 { + company: &injection, + } + .render(); + assert_eq!(extent_request.matches("").count(), 1); + assert!(!extent_request.contains("IMPORT")); + assert!(extent_request.contains("</SVCURRENTCOMPANY>")); + + #[cfg(feature = "voucher-scan")] + { + let pinned = PinnedCompany::verified(injection.clone(), "synthetic-guid".to_string()) + .expect("verified test identity"); + let window = crate::outstandings::DateWindow::parse( + crate::outstandings::DateBoundaryProfile::EducationRestricted, + "20260401", + "20260402", + ) + .unwrap() + .narrow_partitions() + .unwrap() + .remove(0); + for request in [ + ReadOnlyProfile::VoucherOutstandingsV1 { + company: &pinned, + window: &window, + alter_id_range: AlterIdRange::new(0, 1).unwrap(), + } + .render(), + ReadOnlyProfile::VoucherEmptyPartitionWitnessV1 { + company: &pinned, + window: &window, + } + .render(), + ] { + assert_eq!(request.matches("").count(), 1); + assert!(!request.contains("IMPORT")); + assert!(request.contains("</SVCURRENTCOMPANY>")); } - .render(), - ReadOnlyProfile::VoucherEmptyPartitionWitnessV1 { + let witness = ReadOnlyProfile::VoucherEmptyPartitionWitnessV1 { company: &pinned, window: &window, - } - .render(), - ] { - assert_eq!(request.matches("").count(), 1); - assert!(!request.contains("IMPORT")); - assert!(request.contains("</SVCURRENTCOMPANY>")); + }; + assert_eq!( + witness.id(), + ReadOnlyProfileId::VoucherEmptyPartitionWitnessV1 + ); + assert!(witness.template_sha256().len() == 64); } - let witness = ReadOnlyProfile::VoucherEmptyPartitionWitnessV1 { - company: &pinned, - window: &window, - }; - assert_eq!( - witness.id(), - ReadOnlyProfileId::VoucherEmptyPartitionWitnessV1 - ); - assert!(witness.template_sha256().len() == 64); } #[test] fn profile_ids_and_template_hashes_are_stable() { - let expected = [ + #[cfg_attr(not(feature = "voucher-scan"), allow(unused_mut))] + let mut expected: Vec<(ReadOnlyProfileId, &str)> = vec![ ( ReadOnlyProfileId::CompanyListV1, "d5c134051e1d298a278e27284fbb5ab1a9d00e0006a70f9777c4e38cebbb16de", ), ( - ReadOnlyProfileId::CompanyBookExtentV1, - "38038f96473b2bf036d78aca2eea85f96738ace3bf0e691bbfa14ddd165784f4", + ReadOnlyProfileId::CompanyListV2, + "f484b6f1e19a622d351c77dbe14b319524cb0cd5a02414dd2d9a6141721b1bad", ), ( - ReadOnlyProfileId::LedgerOpeningCoverageV1, - "64528ba2deddc0b640bc6c557d50baed252c0ba2aadc5e42fea0fd6e1c0c30bf", + ReadOnlyProfileId::CompanyBookExtentV1, + "38038f96473b2bf036d78aca2eea85f96738ace3bf0e691bbfa14ddd165784f4", ), ( ReadOnlyProfileId::StandardLedgerIdentityV1, @@ -1033,6 +1112,13 @@ mod tests { ReadOnlyProfileId::VouchersV3, "2e68f0ab8e57ded8cc1948b6785598e2f1e0947fcc431975d15fd63131df478d", ), + ]; + #[cfg(feature = "voucher-scan")] + expected.extend([ + ( + ReadOnlyProfileId::LedgerOpeningCoverageV1, + "64528ba2deddc0b640bc6c557d50baed252c0ba2aadc5e42fea0fd6e1c0c30bf", + ), ( ReadOnlyProfileId::VoucherOutstandingsV1, "7e4025038bf85345d0a55b9437be339c8829b1f699abaaa52bbdfa6affcb1dae", @@ -1041,7 +1127,7 @@ mod tests { ReadOnlyProfileId::VoucherEmptyPartitionWitnessV1, "73a9a71e437a8556d18123fad739fa10a7e859a1d462b5ce88d6e42d52811d8d", ), - ]; + ]); for (profile, digest) in expected { assert_eq!(profile.template_sha256(), digest); assert_eq!(profile.template_sha256().len(), 64); diff --git a/src-tauri/crates/bridge-tally-protocol/tests/fixtures/native/PROVENANCE.md b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/native/PROVENANCE.md new file mode 100644 index 00000000..d38439d0 --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/native/PROVENANCE.md @@ -0,0 +1,41 @@ +# Native fixture byte provenance + +The following files were normalised by Git on their first commit. Their original +captured bytes are unrecoverable: parse-level content is believed intact, but +byte-level fidelity is not. Each is pending a future re-capture from live Tally. + +- `bills_payable_aarav.xml` +- `bills_receivable_aarav.xml` +- `bills_receivable_ageing_lab.xml` +- `bills_receivable_billwise_lab.xml` +- `bills_receivable_unloaded_company_failure.xml` +- `company_collection_live.xml` +- `company_extent_9000.xml` +- `ledger_snapshot_aarav.xml` +- `ledger_snapshot_billwise_lab.xml` + +Do not establish byte-length or SHA-256 assertions for these files until their +live re-captures replace the normalised copies. The exception is +`bills_payable_billwise_lab_empty.xml`: its complete 23-byte content is +independently determined and was repaired separately; it is not a re-capture. + +## Bridge Validation Lab capture — 2026-08-17 + +These fixtures are byte-exact responses captured from the purpose-built, +synthetic `Bridge Validation Lab` on TallyPrime port 9001. Each POST was issued +alone and bracketed by successful `/status` identity checks. The existing +native Bills Receivable, Bills Payable, and `List of Ledgers` request builders +were used with `SVTODATE=20260817`; a separate existing +`CompanyBookExtentV1` read established `BOOKSFROM=20250401`. + +| Fixture | Bytes | SHA-256 | +| --- | ---: | --- | +| `bills_receivable_validation_lab.xml` | 1170 | `a7f4ff5209c98b145970112a3ba1be9e6d303008b270786e7bfb286c3a99697b` | +| `bills_payable_validation_lab.xml` | 257 | `62063a77ebaccdaebdae42a431bc8859388f415035812e82e212808c64ee83fd` | +| `ledger_snapshot_validation_lab.xml` | 7696 | `64cc585f6bfa2bdc076c2fc28e8732c26e931819dac8f53086e932daeb053a3a` | + +The captured values are synthetic. A bounded pre-commit scan found no bytes +above ASCII, email addresses, 10-digit phone patterns, GSTINs, or PANs. The +fixture names are limited to the `BVL` test namespace and Tally built-ins. +The source copies and fixture copies compared byte-for-byte before staging; +the repository fixture-integrity gate supplies the committed-object check. diff --git a/src-tauri/crates/bridge-tally-protocol/tests/fixtures/native/bills_payable_aarav.xml b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/native/bills_payable_aarav.xml new file mode 100644 index 00000000..cdd2799a --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/native/bills_payable_aarav.xml @@ -0,0 +1,170 @@ + + + 1-Apr-24 + SI/24-25/001 + Crescent Office Mart + + 26597.20 + 1-Apr-24 + 851 + + 1-May-24 + SI/24-25/002 + Disha Enterprises + + 29405.60 + 1-May-24 + 821 + + 1-Jun-24 + SI/24-25/003 + Evergreen Supplies + + 32214.00 + 1-Jun-24 + 790 + + 1-Aug-24 + SI/24-25/005 + Crescent Office Mart + + 37830.80 + 1-Aug-24 + 729 + + 1-Sep-24 + SI/24-25/006 + Disha Enterprises + + 23788.80 + 1-Sep-24 + 698 + + 1-Nov-24 + DN/008 + Prakash Logistics + + 5900.00 + 1-Nov-24 + 637 + + 2-Dec-24 + SI/24-25/009 + Crescent Office Mart + + 32214.00 + 2-Dec-24 + 606 + + 1-Jan-25 + SI/25-26/010 + Disha Enterprises + + 35022.40 + 1-Jan-25 + 576 + + 2-Feb-25 + SI/25-26/011 + Evergreen Supplies + + 37830.80 + 2-Feb-25 + 544 + + 2-Apr-25 + SI/25-26/013 + Crescent Office Mart + + 26597.20 + 2-Apr-25 + 485 + + 2-May-25 + SI/25-26/014 + Disha Enterprises + + 29405.60 + 2-May-25 + 455 + + 2-May-25 + DN/014 + Prakash Logistics + + 6608.00 + 2-May-25 + 455 + + 1-Jun-25 + SI/25-26/015 + Evergreen Supplies + + 32214.00 + 1-Jun-25 + 425 + + 1-Aug-25 + SI/25-26/017 + Crescent Office Mart + + 37830.80 + 1-Aug-25 + 364 + + 1-Sep-25 + SI/25-26/018 + Disha Enterprises + + 23788.80 + 1-Sep-25 + 333 + + 1-Oct-25 + SI/25-26/019 + Evergreen Supplies + + 26597.20 + 1-Oct-25 + 303 + + 1-Nov-25 + DN/020 + Prakash Logistics + + 7316.00 + 1-Nov-25 + 272 + + 2-Dec-25 + SI/25-26/021 + Crescent Office Mart + + 32214.00 + 2-Dec-25 + 241 + + 1-Jan-26 + SI/26-27/022 + Disha Enterprises + + 35022.40 + 1-Jan-26 + 211 + + 2-Feb-26 + SI/26-27/023 + Evergreen Supplies + + 37830.80 + 2-Feb-26 + 179 + + 1-Apr-26 + BRIDGE-OPT-0001 + BRIDGE PROBE PARTY OPT + + 9999.00 + 1-Apr-26 + 121 + diff --git a/src-tauri/crates/bridge-tally-protocol/tests/fixtures/native/bills_payable_billwise_lab_empty.xml b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/native/bills_payable_billwise_lab_empty.xml new file mode 100644 index 00000000..d37e2aa3 --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/native/bills_payable_billwise_lab_empty.xml @@ -0,0 +1 @@ + diff --git a/src-tauri/crates/bridge-tally-protocol/tests/fixtures/native/bills_payable_validation_lab.xml b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/native/bills_payable_validation_lab.xml new file mode 100644 index 00000000..9dcdab07 --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/native/bills_payable_validation_lab.xml @@ -0,0 +1,10 @@ + + + 1-Aug-26 + DELTA-PAY + BVL Delta Mixed Payable + + 66666.00 + 1-Aug-26 + 0 + diff --git a/src-tauri/crates/bridge-tally-protocol/tests/fixtures/native/bills_receivable_aarav.xml b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/native/bills_receivable_aarav.xml new file mode 100644 index 00000000..802327cb --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/native/bills_receivable_aarav.xml @@ -0,0 +1,178 @@ + + + 2-Apr-24 + PI/24-25/001 + Om Packaging House + + -21558.60 + 2-Apr-24 + 850 + + 2-May-24 + PI/24-25/002 + Prakash Logistics + + -24119.20 + 2-May-24 + 820 + + 1-Jul-24 + PI/24-25/004 + Om Packaging House + + -29240.40 + 1-Jul-24 + 760 + + 2-Aug-24 + CN/005 + Crescent Office Mart + + -8555.00 + 2-Aug-24 + 728 + + 2-Aug-24 + PI/24-25/005 + Prakash Logistics + + -18998.00 + 2-Aug-24 + 728 + + 1-Oct-24 + SI/24-25/007 + Evergreen Supplies + + -41795.60 + 1-Oct-24 + 668 + + 2-Oct-24 + PI/24-25/007 + Om Packaging House + + -24119.20 + 2-Oct-24 + 667 + + 2-Nov-24 + PI/24-25/008 + Prakash Logistics + + -26679.80 + 2-Nov-24 + 636 + + 2-Jan-25 + PI/25-26/010 + Om Packaging House + + -18998.00 + 2-Jan-25 + 575 + + 1-Feb-25 + CN/011 + Evergreen Supplies + + -9617.00 + 1-Feb-25 + 545 + + 1-Feb-25 + PI/25-26/011 + Prakash Logistics + + -21558.60 + 1-Feb-25 + 545 + + 1-Apr-25 + PI/25-26/013 + Om Packaging House + + -26679.80 + 1-Apr-25 + 486 + + 1-May-25 + PI/25-26/014 + Prakash Logistics + + -29240.40 + 1-May-25 + 456 + + 1-Jul-25 + PI/25-26/016 + Om Packaging House + + -21558.60 + 1-Jul-25 + 395 + + 2-Aug-25 + CN/017 + Crescent Office Mart + + -10679.00 + 2-Aug-25 + 363 + + 2-Aug-25 + PI/25-26/017 + Prakash Logistics + + -24119.20 + 2-Aug-25 + 363 + + 2-Oct-25 + PI/25-26/019 + Om Packaging House + + -29240.40 + 2-Oct-25 + 302 + + 2-Nov-25 + PI/25-26/020 + Prakash Logistics + + -18998.00 + 2-Nov-25 + 271 + + 2-Jan-26 + PI/26-27/022 + Om Packaging House + + -24119.20 + 2-Jan-26 + 210 + + 1-Feb-26 + CN/023 + Evergreen Supplies + + -11741.00 + 1-Feb-26 + 180 + + 1-Feb-26 + PI/26-27/023 + Prakash Logistics + + -26679.80 + 1-Feb-26 + 180 + + 1-Apr-26 + BRIDGE-INV-0001 + Bright Retail Pvt Ltd + + -1180.00 + 1-Apr-26 + 121 + diff --git a/src-tauri/crates/bridge-tally-protocol/tests/fixtures/native/bills_receivable_ageing_lab.xml b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/native/bills_receivable_ageing_lab.xml new file mode 100644 index 00000000..c114ebfe --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/native/bills_receivable_ageing_lab.xml @@ -0,0 +1,42 @@ + + + 1-May-26 + CANARY-1 + Ageing Customer A + + -1000.00 + 1-May-26 + 91 + + 1-May-26 + CREDIT-30 + Ageing Customer A + + -4000.00 + 31-May-26 + 61 + + 31-May-26 + BD-DIFF + Ageing Customer A + + -2000.00 + 31-May-26 + 61 + + 2-Jul-26 + MP-A + Ageing Customer A + + -1000.00 + 2-Jul-26 + 29 + + 31-Jul-26 + BWOFF-TEST + Ageing Customer A + + -777.00 + 31-Jul-26 + 0 + diff --git a/src-tauri/crates/bridge-tally-protocol/tests/fixtures/native/bills_receivable_billwise_lab.xml b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/native/bills_receivable_billwise_lab.xml new file mode 100644 index 00000000..4e529397 --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/native/bills_receivable_billwise_lab.xml @@ -0,0 +1,386 @@ + + + 1-Apr-24 + INV-0007 + Arora Stationers + + -58080.00 + 1-Apr-24 + 851 + + 1-Apr-24 + INV-0008 + Lotus Home Stores + + -44975.00 + 1-Apr-24 + 851 + + 1-Apr-24 + INV-0009 + Metro Trade Link + + -184000.00 + 1-Apr-24 + 851 + + 1-Apr-24 + INV-0010 + Rajasthan Digital Mart + + -242500.00 + 1-Apr-24 + 851 + + 1-Jul-24 + INV-0011 + Bright Retail Pvt Ltd + + -13250.00 + 1-Jul-24 + 760 + + 1-Jul-24 + INV-0019 + Metro Trade Link + + -117600.00 + 1-Jul-24 + 760 + + 1-Jul-24 + INV-0020 + Rajasthan Digital Mart + + -91000.00 + 1-Jul-24 + 760 + + 1-Jul-24 + INV-0012 + Sharma Traders + + -20000.00 + 1-Jul-24 + 760 + + 1-Oct-24 + INV-0021 + Bright Retail Pvt Ltd + + -8400.00 + 1-Oct-24 + 668 + + 1-Oct-24 + INV-0023 + Mehta Office Supplies + + -30000.00 + 1-Oct-24 + 668 + + 1-Oct-24 + INV-0024 + Navkar Distributors + + -44800.00 + 1-Oct-24 + 668 + + 1-Oct-24 + INV-0022 + Sharma Traders + + -7437.00 + 1-Oct-24 + 668 + + 1-Jan-25 + INV-0036 + Kaveri Enterprises + + -86000.00 + 1-Jan-25 + 576 + + 1-Jan-25 + INV-0033 + Mehta Office Supplies + + -19080.00 + 1-Jan-25 + 576 + + 1-Jan-25 + INV-0034 + Navkar Distributors + + -16555.00 + 1-Jan-25 + 576 + + 1-Jan-25 + INV-0035 + Sunrise Electronics + + -64500.00 + 1-Jan-25 + 576 + + 1-Apr-25 + INV-0047 + Arora Stationers + + -120800.00 + 1-Apr-25 + 486 + + 1-Apr-25 + INV-0046 + Kaveri Enterprises + + -31675.00 + 1-Apr-25 + 486 + + 1-Apr-25 + INV-0048 + Lotus Home Stores + + -162500.00 + 1-Apr-25 + 486 + + 1-Apr-25 + INV-0045 + Sunrise Electronics + + -40650.00 + 1-Apr-25 + 486 + + 1-Jul-25 + INV-0057 + Arora Stationers + + -76080.00 + 1-Jul-25 + 395 + + 1-Jul-25 + INV-0058 + Lotus Home Stores + + -59850.00 + 1-Jul-25 + 395 + + 1-Jul-25 + INV-0059 + Metro Trade Link + + -244000.00 + 1-Jul-25 + 395 + + 1-Jul-25 + INV-0060 + Rajasthan Digital Mart + + -330000.00 + 1-Jul-25 + 395 + + 1-Oct-25 + INV-0061 + Bright Retail Pvt Ltd + + -17000.00 + 1-Oct-25 + 303 + + 1-Oct-25 + INV-0069 + Metro Trade Link + + -153600.00 + 1-Oct-25 + 303 + + 1-Oct-25 + INV-0070 + Rajasthan Digital Mart + + -121625.00 + 1-Oct-25 + 303 + + 1-Oct-25 + INV-0062 + Sharma Traders + + -26250.00 + 1-Oct-25 + 303 + + 1-Jan-26 + INV-0071 + Bright Retail Pvt Ltd + + -10650.00 + 1-Jan-26 + 211 + + 1-Jan-26 + INV-0073 + Mehta Office Supplies + + -39000.00 + 1-Jan-26 + 211 + + 1-Jan-26 + INV-0074 + Navkar Distributors + + -57300.00 + 1-Jan-26 + 211 + + 1-Jan-26 + INV-0072 + Sharma Traders + + -9625.00 + 1-Jan-26 + 211 + + 1-Mar-26 + INV-0086 + Kaveri Enterprises + + -108500.00 + 1-Mar-26 + 152 + + 1-Mar-26 + INV-0083 + Mehta Office Supplies + + -24480.00 + 1-Mar-26 + 152 + + 1-Mar-26 + INV-0084 + Navkar Distributors + + -20930.00 + 1-Mar-26 + 152 + + 1-Mar-26 + INV-0085 + Sunrise Electronics + + -80750.00 + 1-Mar-26 + 152 + + 2-May-26 + INV-0097 + Arora Stationers + + -150800.00 + 2-May-26 + 90 + + 2-May-26 + INV-0096 + Kaveri Enterprises + + -39550.00 + 2-May-26 + 90 + + 2-May-26 + INV-0098 + Lotus Home Stores + + -205000.00 + 2-May-26 + 90 + + 2-May-26 + INV-0095 + Sunrise Electronics + + -50400.00 + 2-May-26 + 90 + + 1-Jun-26 + INV-0107 + Arora Stationers + + -94080.00 + 1-Jun-26 + 60 + + 1-Jun-26 + INV-0108 + Lotus Home Stores + + -74725.00 + 1-Jun-26 + 60 + + 1-Jun-26 + INV-0109 + Metro Trade Link + + -304000.00 + 1-Jun-26 + 60 + + 1-Jun-26 + INV-0110 + Rajasthan Digital Mart + + -417500.00 + 1-Jun-26 + 60 + + 1-Jul-26 + INV-0111 + Bright Retail Pvt Ltd + + -20750.00 + 1-Jul-26 + 30 + + 1-Jul-26 + INV-0119 + Metro Trade Link + + -189600.00 + 1-Jul-26 + 30 + + 1-Jul-26 + INV-0120 + Rajasthan Digital Mart + + -152250.00 + 1-Jul-26 + 30 + + 1-Jul-26 + INV-0112 + Sharma Traders + + -32500.00 + 1-Jul-26 + 30 + diff --git a/src-tauri/crates/bridge-tally-protocol/tests/fixtures/native/bills_receivable_unloaded_company_failure.xml b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/native/bills_receivable_unloaded_company_failure.xml new file mode 100644 index 00000000..5a989ce3 --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/native/bills_receivable_unloaded_company_failure.xml @@ -0,0 +1,11 @@ + +
+ 1 + 0 +
+ + + Could not set 'SVCurrentCompany' to 'Bridge Ageing Lab' + + +
diff --git a/src-tauri/crates/bridge-tally-protocol/tests/fixtures/native/bills_receivable_validation_lab.xml b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/native/bills_receivable_validation_lab.xml new file mode 100644 index 00000000..b0d6a5f3 --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/native/bills_receivable_validation_lab.xml @@ -0,0 +1,42 @@ + + + 1-May-26 + ALPHA-OVERDUE + BVL Alpha Traders + + -11111.00 + 1-May-26 + 92 + + 1-Aug-26 + ALPHA-FUTURE + BVL Alpha Traders + + -22222.00 + 1-Oct-26 + + + 1-Aug-26 + DELTA-REC + BVL Delta Mixed + + -55555.00 + 1-Aug-26 + 0 + + 1-Aug-26 + ZETA-REC + =BVL Zeta Formula + + -88888.00 + 1-Aug-26 + 0 + + 1-Aug-26 + UNICODE-REC + BVL ???????? ???????? + + -77777.00 + 1-Aug-26 + 0 + diff --git a/src-tauri/crates/bridge-tally-protocol/tests/fixtures/native/company_collection_live.xml b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/native/company_collection_live.xml new file mode 100644 index 00000000..5d2cf675 --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/native/company_collection_live.xml @@ -0,0 +1,63 @@ + +
+ 1 + 1 +
+ + + + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + + + + + + Aarav Trading Company Demo + bb8ad19e-6aef-4239-a917-87fec0c6215e + + + Bridge Ageing Lab + eebb9a9f-1679-4468-9e8f-814c729674cb + + + Bridge Billwise Lab + 75f7566d-7a4f-431a-9642-e93a9d06d57d + + + + +
diff --git a/src-tauri/crates/bridge-tally-protocol/tests/fixtures/native/company_extent_9000.xml b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/native/company_extent_9000.xml new file mode 100644 index 00000000..0420e673 --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/native/company_extent_9000.xml @@ -0,0 +1,75 @@ + +
+ 1 + 1 +
+ + + + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + + + + + + 20260401 + 20240401 + Aarav Trading Company Demo + bb8ad19e-6aef-4239-a917-87fec0c6215e + 101603 + 327 + + + 20260731 + 20260401 + Bridge Ageing Lab + eebb9a9f-1679-4468-9e8f-814c729674cb + 14 + 223 + + + 20260702 + 20240401 + Bridge Billwise Lab + 75f7566d-7a4f-431a-9642-e93a9d06d57d + 252 + 218 + + + + +
diff --git a/src-tauri/crates/bridge-tally-protocol/tests/fixtures/native/ledger_snapshot_aarav.xml b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/native/ledger_snapshot_aarav.xml new file mode 100644 index 00000000..1a20e8e9 --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/native/ledger_snapshot_aarav.xml @@ -0,0 +1,1021 @@ + +
+ 1 + 1 +
+ + + + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + + + + + + Sundry Creditors + Yes + 8289540.54 + 0.00 + + + Adarsh Packaging Co + + + + + Sundry Debtors + Yes + -929439.97 + 0.00 + + + Amrut Beverages + + + + + Sundry Debtors + Yes + 3281742.68 + 0.00 + + + Anand Electricals + + + + + Sundry Debtors + Yes + 1722983.25 + 0.00 + + + Balaji Traders + + + + + Sundry Debtors + Yes + -2188946.39 + 0.00 + + + Bhagwati Rubber Co + + + + + Sundry Creditors + Yes + 5539994.54 + 0.00 + + + Bharat Chemicals Ltd + + + + + Sundry Debtors + Yes + -17149.00 + 0.00 + + + BRIDGE-PROBE-LEDGER-A + + + + + Sundry Creditors + Yes + 17154.00 + 0.00 + + + BRIDGE-PROBE-LEDGER-B + + + + + Sundry Debtors + Yes + 9999.00 + 0.00 + + + BRIDGE PROBE PARTY OPT + + + + + Sundry Debtors + Yes + -1180.00 + 0.00 + + + Bright Retail Pvt Ltd + + + + + Capital Account + No + -800000.00 + -800000.00 + + + Capital Account - Arjun Mehta + + + + + Cash-in-Hand + No + -9998.00 + 0.00 + + + Cash + + 1033 + + + + Sundry Debtors + Yes + 174050.00 + 0.00 + + + Crescent Office Mart + + + + + Sundry Debtors + Yes + 3615836.98 + 0.00 + + + Deepak Hardware Mart + + + + + Indirect Expenses + No + + 0.00 + + + Discount Allowed + + + + + Sundry Debtors + Yes + 176433.60 + 0.00 + + + Disha Enterprises + + + + + Sundry Debtors + Yes + 103533.20 + 0.00 + + + Evergreen Supplies + + + + + Indirect Expenses + No + 76560.00 + 0.00 + + + Freight & Cartage + + + + + Indirect Expenses + No + + 0.00 + + + Freight Outward + + + + + Sundry Debtors + Yes + 1412341.89 + 0.00 + + + Ganesh Auto Parts + + + + + Sundry Debtors + Yes + -7857554.48 + 0.00 + + + Girnar Cotton Mills + + + + + Sundry Creditors + Yes + -2313895.10 + 0.00 + + + Gujarat Poly Industries + + + + + Bank Accounts + No + -983881.60 + 350000.00 + + + HDFC Bank Current Account + + + + + Sundry Creditors + Yes + -2955172.72 + 0.00 + + + Hind Steel Suppliers + + + + + Bank Accounts + No + 8048360.40 + 0.00 + + + ICICI Bank CA 4471 + + + + + Sundry Creditors + Yes + 374337.23 + 0.00 + + + Indus Cable Corp + + + + + Duties & Taxes + No + 125748.00 + 0.00 + + + Input CGST 9% + + + + + Duties & Taxes + No + -212597464.88 + 0.00 + + + Input CGST 9% BR + + + + + Duties & Taxes + No + 125748.00 + 0.00 + + + Input SGST 9% + + + + + Duties & Taxes + No + -212597464.88 + 0.00 + + + Input SGST 9% BR + + + + + Sundry Debtors + Yes + -5266092.06 + 0.00 + + + Jaisurya Exports + + + + + Sundry Debtors + Yes + 4140726.15 + 0.00 + + + Kalpataru Ceramics + + + + + Sundry Creditors + Yes + -16321928.42 + 0.00 + + + Kohinoor Fabrics + + + + + Sundry Debtors + Yes + 5690185.49 + 0.00 + + + Krishna Agro Industries + + + + + Sundry Debtors + Yes + 4152772.17 + 0.00 + + + Laxmi Narayan Traders + + + + + Sundry Debtors + Yes + 2662298.65 + 0.00 + + + Mahavir Packaging + + + + + Sundry Creditors + Yes + -4854545.73 + 0.00 + + + Meghdoot Transport + + + + + Sundry Debtors + Yes + -5186279.98 + 0.00 + + + Nakoda Steel Corp + + + + + Sundry Debtors + Yes + -8595811.10 + 0.00 + + + Navkar Plastics + + + + + Sundry Creditors + Yes + 14214853.71 + 0.00 + + + Neelkanth Traders + + + + + Sundry Creditors + Yes + 0.00 + 0.00 + + + Nova Components LLP + + + + + Indirect Expenses + No + 979499.00 + 0.00 + + + Office Rent + + + + + Sundry Creditors + Yes + -195514.20 + 0.00 + + + Om Packaging House + + + + + Sundry Debtors + Yes + -8836563.50 + 0.00 + + + Om Sai Distributors + + + + + Duties & Taxes + No + 213910033.89 + 0.00 + + + Output CGST 9% + + + + + Duties & Taxes + No + + 0.00 + + + Output IGST 18% + + + + + Duties & Taxes + No + 213910033.89 + 0.00 + + + Output SGST 9% + + + + + Sundry Creditors + Yes + -9999468.33 + 0.00 + + + Parekh Stationers + + + + + Cash-in-Hand + No + -51560.00 + 25000.00 + + + Petty Cash + + + + + Cash-in-Hand + No + + 0.00 + + + Petty Cash Counter + + + + + Sundry Creditors + Yes + -170569.00 + 0.00 + + + Prakash Logistics + + + + + Sundry Creditors + Yes + -4838338.78 + 0.00 + + + Prakash Raw Materials + + + + +  Primary + No + 18256536.27 + 0.00 + + + Profit & Loss A/c + + 1033 + + + + Purchase Accounts + No + + 0.00 + + + Purchase - Consumables + + + + + Purchase Accounts + No + 1414000.00 + 0.00 + + + Purchase - Electronics + + + + + Purchase Accounts + No + -2362194064.32 + 0.00 + + + Purchase - Raw Material + + + + + Purchase Accounts + No + -16800.00 + 0.00 + + + Purchase Return + + + + + Sundry Debtors + Yes + -8338023.57 + 0.00 + + + Rajhans Textiles + + + + + Indirect Expenses + No + + 0.00 + + + Repairs & Maintenance + + + + + Sundry Debtors + Yes + -11676799.08 + 0.00 + + + Riddhi Siddhi Foods + + + + + Sundry Creditors + Yes + -7496800.63 + 0.00 + + + Sagar Timber Depot + + + + + Sundry Debtors + Yes + 4220059.09 + 0.00 + + + Sai Krupa Agencies + + + + + Indirect Expenses + No + 1218000.00 + 0.00 + + + Salaries + + + + + Sales Accounts + No + -2238000.00 + 0.00 + + + Sales - Electronics + + + + + Sales Accounts + No + 34400.00 + 0.00 + + + Sales Return + + + + + Sales Accounts + No + + 0.00 + + + Sales - Services + + + + + Sales Accounts + No + 2378982941.59 + 0.00 + + + Sales - Trading + + + + + Sundry Debtors + Yes + 1713811.14 + 0.00 + + + Sanghvi Papers + + + + + Sundry Debtors + Yes + -3096526.51 + 0.00 + + + Shreeji Enterprises + + + + + Sundry Creditors + Yes + -871793.82 + 0.00 + + + Sunrise Electricals + + + + + Sundry Debtors + Yes + 6058291.31 + 0.00 + + + Suryodaya Chemicals + + + + + Sundry Debtors + Yes + -170827.51 + 0.00 + + + Tirupati Marketing + + + + + Sundry Creditors + Yes + 2934751.77 + 0.00 + + + Trishul Engineering + + + + + Sundry Creditors + Yes + 10289588.64 + 0.00 + + + Universal Components + + + + + Sundry Creditors + Yes + 9440183.32 + 0.00 + + + Vaibhav Machinery + + + + + Sundry Debtors + Yes + -1885601.33 + 0.00 + + + Vardhman Polymers + + + + + Sundry Debtors + Yes + -2269539.30 + 0.00 + + + Vishal Metal Works + + + + + Sundry Debtors + Yes + -1662193.93 + 0.00 + + + Yashoda Pharma Distributors + + + + + Sundry Debtors + Yes + + 0.00 + + + ZZ Café Naïve Ledger + + + + + Sundry Debtors + Yes + + 0.00 + + + ZZ Curly “Quoted” Ledger + + + + + Sundry Debtors + Yes + + 0.00 + + + ZZ Em—Dash … Ledger + + + + + Sundry Creditors + Yes + + 0.00 + + + ZZ-PIN-OMITTED + + + + + Sundry Debtors + Yes + -5.00 + 0.00 + + + ZZ Ram & Sons Pvt Ltd + + + + + Sundry Debtors + Yes + + 0.00 + + + ZZ Rupee Test ₹ Ledger + + + + + Sundry Debtors + Yes + + 0.00 + + + श्री गणेश ट्रेडर्स + + + + + Sundry Debtors + Yes + + 0.00 + + + রায় এন্ড সন্স + + + + + Sundry Debtors + Yes + + 0.00 + + + શ્રી કૃષ્ણ એન્ટરપ્રાઇઝ + + + + + Sundry Debtors + Yes + + 0.00 + + + முருகன் டிரேடர்ஸ் + + + + + + +
diff --git a/src-tauri/crates/bridge-tally-protocol/tests/fixtures/native/ledger_snapshot_billwise_lab.xml b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/native/ledger_snapshot_billwise_lab.xml new file mode 100644 index 00000000..08e0eac1 --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/native/ledger_snapshot_billwise_lab.xml @@ -0,0 +1,207 @@ + +
+ 1 + 1 +
+ + + + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + + + + + + Sundry Debtors + Yes + -499840.00 + 0.00 + + + Arora Stationers + + 1033 + + + + Sundry Debtors + Yes + -70050.00 + 0.00 + + + Bright Retail Pvt Ltd + + 1033 + + + + Cash-in-Hand + No + -9939703.00 + 0.00 + + + Cash + + 1033 + + + + Sundry Debtors + Yes + -265725.00 + 0.00 + + + Kaveri Enterprises + + 1033 + + + + Sundry Debtors + Yes + -509550.00 + 0.00 + + + Lotus Home Stores + + 1033 + + + + Sundry Debtors + Yes + -112560.00 + 0.00 + + + Mehta Office Supplies + + 1033 + + + + Sundry Debtors + Yes + -1177800.00 + 0.00 + + + Metro Trade Link + + 1033 + + + + Sundry Debtors + Yes + -139585.00 + 0.00 + + + Navkar Distributors + + 1033 + + + +  Primary + No + 14349300.00 + 0.00 + + + Profit & Loss A/c + + 1033 + + + + Sundry Debtors + Yes + -1354875.00 + 0.00 + + + Rajasthan Digital Mart + + 1033 + + + + Sales Accounts + No + 14349300.00 + 0.00 + + + Sales + + 1033 + + + + Sundry Debtors + Yes + -73312.00 + 0.00 + + + Sharma Traders + + 1033 + + + + Sundry Debtors + Yes + -206300.00 + 0.00 + + + Sunrise Electronics + + 1033 + + + + + +
diff --git a/src-tauri/crates/bridge-tally-protocol/tests/fixtures/native/ledger_snapshot_validation_lab.xml b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/native/ledger_snapshot_validation_lab.xml new file mode 100644 index 00000000..3229e54f --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/native/ledger_snapshot_validation_lab.xml @@ -0,0 +1,207 @@ + +
+ 1 + 1 +
+ + + + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + + + + + + Sundry Debtors + Yes + -33333.00 + 0.00 + + + BVL Alpha Traders + + 1033 + + + + Sundry Debtors + No + -33333.00 + -33333.00 + + + BVL Beta Supplies + + 1033 + + + + Sundry Debtors + Yes + -55555.00 + 0.00 + + + BVL Delta Mixed + + 1033 + + + + Sundry Creditors + Yes + 66666.00 + 0.00 + + + BVL Delta Mixed Payable + + 1033 + + + + Sundry Debtors + Yes + -44444.00 + -44444.00 + + + BVL Gamma Opening + + 1033 + + + + Purchase Accounts + No + -66666.00 + 0.00 + + + BVL Purchase Expense + + 1033 + + + + Purchase Accounts + No + + 0.00 + + + BVL Purchases + + 1033 + + + + Sundry Debtors + Yes + -88888.00 + 0.00 + + + =BVL Zeta Formula + + 1033 + + + + Sundry Debtors + Yes + -77777.00 + 0.00 + + + BVL ???????? ???????? + + 1033 + + + + Cash-in-Hand + No + + 0.00 + + + Cash + + 1033 + + + +  Primary + No + 188887.00 + 0.00 + + + Profit & Loss A/c + + 1033 + + + + Purchase Accounts + No + + 0.00 + + + Purchase + + 1033 + + + + Sales Accounts + No + 255553.00 + 0.00 + + + Sales + + 1033 + + + + + +
diff --git a/src-tauri/crates/bridge-tally-protocol/tests/native_outstandings.rs b/src-tauri/crates/bridge-tally-protocol/tests/native_outstandings.rs new file mode 100644 index 00000000..1cb32a59 --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/tests/native_outstandings.rs @@ -0,0 +1,886 @@ +//! Integration tests for `native_outstandings`, driven entirely by the real +//! fixtures captured live from TallyPrime +//! (`tests/fixtures/native/*.xml`, captured 2026-08-07). No network, no live +//! Tally: every assertion here is against bytes already checked into the +//! repository. + +use bridge_tally_primitives::{ExactDecimal, TallyDate}; +use bridge_tally_protocol::native_outstandings::{ + age_in_days, compute_native_outstandings, parse_native_bill_rows, parse_native_ledger_snapshot, + AgeingAnchor, NativeBillRow, NativeMasterSnapshot, NativeOutstandingsError, +}; +use bridge_tally_protocol::parse_group_source_records_with_evidence; + +const BILLS_RECEIVABLE_BILLWISE_LAB: &str = + include_str!("fixtures/native/bills_receivable_billwise_lab.xml"); +const BILLS_PAYABLE_BILLWISE_LAB_EMPTY: &str = + include_str!("fixtures/native/bills_payable_billwise_lab_empty.xml"); +const LEDGER_SNAPSHOT_BILLWISE_LAB: &str = + include_str!("fixtures/native/ledger_snapshot_billwise_lab.xml"); +const BILLS_RECEIVABLE_AGEING_LAB: &str = + include_str!("fixtures/native/bills_receivable_ageing_lab.xml"); +const BILLS_RECEIVABLE_VALIDATION_LAB: &str = + include_str!("fixtures/native/bills_receivable_validation_lab.xml"); +const BILLS_PAYABLE_VALIDATION_LAB: &str = + include_str!("fixtures/native/bills_payable_validation_lab.xml"); +const LEDGER_SNAPSHOT_VALIDATION_LAB: &str = + include_str!("fixtures/native/ledger_snapshot_validation_lab.xml"); + +/// `BOOKSFROM` for "Bridge Billwise Lab", from `company_extent_9000.xml` +/// (`20240401`). +const BILLWISE_LAB_BOOKS_FROM: &str = "20240401"; +/// `BOOKSFROM` for "Bridge Ageing Lab", from `company_extent_9000.xml` +/// (`20260401`). +const AGEING_LAB_BOOKS_FROM: &str = "20260401"; +const NATIVE_CAPTURE_AS_OF: &str = "20260731"; +/// `BOOKSFROM` for the purpose-built `Bridge Validation Lab`, observed via +/// the paired `CompanyBookExtentV1` read that bracketed the 2026-08-17 capture. +const VALIDATION_LAB_BOOKS_FROM: &str = "20250401"; +const VALIDATION_CAPTURE_AS_OF: &str = "20260817"; + +const BILLWISE_LAB_COMPANY: &str = "Bridge Billwise Lab"; + +fn as_of(yyyymmdd: &str) -> TallyDate { + TallyDate::parse(yyyymmdd).unwrap() +} + +/// `ExactDecimal`'s `PartialEq` is literal-lexeme equality, not numeric +/// equality (TALLY_PROTOCOL_REFERENCE: it deliberately never converts +/// through floating point, and preserves whatever scale it was constructed +/// with). Every value that has passed through `checked_add`/`checked_subtract` +/// is canonicalized (trailing fractional zeros stripped), so assertions +/// compare against that canonical form rather than the fixture's own +/// `X.00` lexeme. +fn assert_exact(actual: &ExactDecimal, canonical: &str) { + assert_eq!(actual.as_str(), canonical); +} + +#[test] +fn nested_debtor_ledger_from_raw_group_and_ledger_bytes_is_not_dropped() { + let group_bytes = r#"
1
+ + Sundry Debtors + Primary +
"#; + let ledger_bytes = r#"
1
+ North Region + -100.000.00 + No +
"#; + let groups = parse_group_source_records_with_evidence(group_bytes) + .expect("raw group hierarchy parses") + .records + .into_iter() + .map(|row| row.record) + .collect::>(); + let ledgers = parse_native_ledger_snapshot(ledger_bytes).expect("raw ledger snapshot parses"); + + let result = compute_native_outstandings( + "Synthetic Company", + &[], + &[], + NativeMasterSnapshot { + ledgers: &ledgers, + groups: &groups, + }, + AgeingAnchor::DueDate, + &as_of(NATIVE_CAPTURE_AS_OF), + group_bytes.len() + ledger_bytes.len(), + ) + .expect("nested debtor ancestry is complete"); + + assert_exact(&result.residual_total, "100"); + assert_eq!(result.residuals[0].party, "Nested Customer"); +} + +#[test] +fn not_yet_due_bill_is_reported_without_becoming_overdue() { + let receivable = [NativeBillRow { + party: "Synthetic customer".to_string(), + reference: "SYNTHETIC-FUTURE-DUE".to_string(), + bill_date: as_of("20260701"), + due_date: as_of("20260830"), + closing_balance: ExactDecimal::parse("-100.00").unwrap(), + // Some rows encode not-yet-overdue as zero; the captured validation + // book encodes it as empty. Neither representation is an overdue age. + tally_overdue_days: Some(0), + }]; + + let result = compute_native_outstandings( + "Synthetic Company", + &receivable, + &[], + NativeMasterSnapshot { + ledgers: &[], + groups: &[], + }, + AgeingAnchor::DueDate, + &as_of("20260731"), + 0, + ) + .expect("a future-due bill must not abort the report"); + + assert_exact(&result.report.receivable_total, "100"); + assert_exact(&result.report.ageing.days_0_30, "100"); + assert_eq!(result.report.ageing.days_90_plus, ExactDecimal::zero()); + assert_eq!(result.report.open_receivable_bill_count, 1); + assert_eq!(result.report.ageing_bill_counts.days_0_30, 1); + assert_eq!(result.report.ageing_bill_counts.days_90_plus, 0); + assert_eq!( + result.report.top_parties[0].oldest_bill_age_days, None, + "a future-due bill must not be presented as the oldest overdue bill" + ); + assert_eq!( + result.overdue_crosscheck_mismatches, 0, + "zero overdue days must agree with Tally's own BILLOVERDUE" + ); +} + +#[test] +fn validation_lab_empty_billoverdue_parses_as_not_applicable() { + let rows = parse_native_bill_rows( + BILLS_RECEIVABLE_VALIDATION_LAB, + &as_of(VALIDATION_LAB_BOOKS_FROM), + &as_of(VALIDATION_CAPTURE_AS_OF), + ) + .expect("the captured empty BILLOVERDUE value must not fail the read"); + + assert_eq!(rows.len(), 5); + let future = rows + .iter() + .find(|row| row.reference == "ALPHA-FUTURE") + .expect("the captured future-due bill is present"); + assert_eq!(future.due_date.as_str(), "20261001"); + assert_eq!(future.tally_overdue_days, None); +} + +#[test] +fn absent_billoverdue_is_not_the_same_as_present_but_empty() { + let xml = "\ + 1-Aug-26FUTUREP\ + -10.001-Oct-26\ + "; + assert_eq!( + parse_native_bill_rows( + xml, + &as_of(VALIDATION_LAB_BOOKS_FROM), + &as_of(VALIDATION_CAPTURE_AS_OF), + ), + Err(NativeOutstandingsError::InvalidResponse( + "bills_fixed_row_missing_billoverdue" + )) + ); +} + +#[test] +fn report_direction_contradictions_in_raw_bill_bytes_fail_closed() { + let parse = |amount: &str| { + parse_native_bill_rows( + &format!( + "1-Jul-26\ + SIGNP\ + {amount}1-Jul-26\ + 30" + ), + &as_of(VALIDATION_LAB_BOOKS_FROM), + &as_of(VALIDATION_CAPTURE_AS_OF), + ) + .expect("synthetic row parses") + }; + + let positive_receivable = parse("100.00"); + assert_eq!( + compute_native_outstandings( + "Synthetic Company", + &positive_receivable, + &[], + NativeMasterSnapshot { + ledgers: &[], + groups: &[], + }, + AgeingAnchor::DueDate, + &as_of(VALIDATION_CAPTURE_AS_OF), + 0, + ), + Err(NativeOutstandingsError::InvalidResponse( + "receivable_bill_sign_contradiction" + )) + ); + + let negative_payable = parse("-100.00"); + assert_eq!( + compute_native_outstandings( + "Synthetic Company", + &[], + &negative_payable, + NativeMasterSnapshot { + ledgers: &[], + groups: &[], + }, + AgeingAnchor::DueDate, + &as_of(VALIDATION_CAPTURE_AS_OF), + 0, + ), + Err(NativeOutstandingsError::InvalidResponse( + "payable_bill_sign_contradiction" + )) + ); +} + +#[test] +fn unaged_receivable_classification_uses_the_residual_not_the_net_ledger_sign() { + let receivable = parse_native_bill_rows( + "1-Jul-26R\ + Receivable flips payable-100.00\ + 1-Jul-2630", + &as_of(VALIDATION_LAB_BOOKS_FROM), + &as_of(VALIDATION_CAPTURE_AS_OF), + ) + .expect("raw receivable parses"); + let payable = parse_native_bill_rows( + "1-Jul-26P\ + Payable flips receivable100.00\ + 1-Jul-2630", + &as_of(VALIDATION_LAB_BOOKS_FROM), + &as_of(VALIDATION_CAPTURE_AS_OF), + ) + .expect("raw payable parses"); + let ledgers = parse_native_ledger_snapshot( + "
1
\ + Sundry Debtors\ + -50.000Yes\ + Sundry Creditors\ + 50.000Yes\ +
", + ) + .expect("raw ledgers parse"); + + let payable_residual = compute_native_outstandings( + "Synthetic Company", + &receivable, + &[], + NativeMasterSnapshot { + ledgers: &ledgers[..1], + groups: &[], + }, + AgeingAnchor::DueDate, + &as_of(VALIDATION_CAPTURE_AS_OF), + 0, + ) + .expect("positive residual computes"); + assert!(!payable_residual.report.has_unaged_receivable); + assert_eq!(payable_residual.residuals[0].amount.as_str(), "50"); + + let receivable_residual = compute_native_outstandings( + "Synthetic Company", + &[], + &payable, + NativeMasterSnapshot { + ledgers: &ledgers[1..], + groups: &[], + }, + AgeingAnchor::DueDate, + &as_of(VALIDATION_CAPTURE_AS_OF), + 0, + ) + .expect("negative residual computes"); + assert!(receivable_residual.report.has_unaged_receivable); + assert_eq!(receivable_residual.residuals[0].amount.as_str(), "-50"); +} + +#[test] +fn validation_lab_accounts_for_every_debtor_rupee_and_matches_tally_ageing() { + let receivable = parse_native_bill_rows( + BILLS_RECEIVABLE_VALIDATION_LAB, + &as_of(VALIDATION_LAB_BOOKS_FROM), + &as_of(VALIDATION_CAPTURE_AS_OF), + ) + .expect("captured receivables parse"); + let payable = parse_native_bill_rows( + BILLS_PAYABLE_VALIDATION_LAB, + &as_of(VALIDATION_LAB_BOOKS_FROM), + &as_of(VALIDATION_CAPTURE_AS_OF), + ) + .expect("captured payables parse"); + let ledgers = parse_native_ledger_snapshot(LEDGER_SNAPSHOT_VALIDATION_LAB) + .expect("captured ledger snapshot parses"); + + let result = compute_native_outstandings( + "Bridge Validation Lab", + &receivable, + &payable, + NativeMasterSnapshot { + ledgers: &ledgers, + groups: &[], + }, + AgeingAnchor::DueDate, + &as_of(VALIDATION_CAPTURE_AS_OF), + BILLS_RECEIVABLE_VALIDATION_LAB.len() + + BILLS_PAYABLE_VALIDATION_LAB.len() + + LEDGER_SNAPSHOT_VALIDATION_LAB.len(), + ) + .expect("validation-book outstandings compute"); + + assert_exact(&result.report.receivable_total, "255553"); + assert_exact(&result.report.payable_total, "66666"); + assert_exact(&result.report.ageing.days_0_30, "244442"); + assert_eq!(result.report.ageing.days_31_60, ExactDecimal::zero()); + assert_eq!(result.report.ageing.days_61_90, ExactDecimal::zero()); + assert_exact(&result.report.ageing.days_90_plus, "11111"); + assert_eq!(result.report.ageing_bill_counts.days_0_30, 4); + assert_eq!(result.report.ageing_bill_counts.days_31_60, 0); + assert_eq!(result.report.ageing_bill_counts.days_61_90, 0); + assert_eq!(result.report.ageing_bill_counts.days_90_plus, 1); + assert_eq!(result.report.open_receivable_bill_count, 5); + + let beta = result + .residuals + .iter() + .find(|residual| residual.party == "BVL Beta Supplies") + .expect("a bill-wise-off debtor must remain visible as an unaged residual"); + assert_exact(&beta.amount, "-33333"); + let gamma = result + .residuals + .iter() + .find(|residual| residual.party == "BVL Gamma Opening") + .expect("the named-opening residual remains visible"); + assert_exact(&gamma.amount, "-44444"); + assert_eq!( + result.residuals.len(), + 7, + "only Sundry Debtor/Creditor ledgers belong in party residuals" + ); + assert_exact(&result.residual_total, "77777"); + assert!(result.report.has_unaged_receivable); + + let accounted_debtor_exposure = result + .report + .receivable_total + .checked_add(&result.residual_total) + .expect("validation-book accounting remains exact"); + assert_exact(&accounted_debtor_exposure, "333330"); +} + +// --------------------------------------------------------------------- +// A: Bills Receivable on Billwise Lab — 48 rows, sum(BILLCL) = -4514597.00, +// all negative, 10 distinct parties. +// --------------------------------------------------------------------- +#[test] +fn bills_receivable_billwise_lab_matches_measured_totals() { + let rows = parse_native_bill_rows( + BILLS_RECEIVABLE_BILLWISE_LAB, + &as_of(BILLWISE_LAB_BOOKS_FROM), + &as_of(NATIVE_CAPTURE_AS_OF), + ) + .expect("the real Billwise Lab capture parses"); + + assert_eq!(rows.len(), 48); + + let mut sum = ExactDecimal::zero(); + for row in &rows { + assert!( + row.closing_balance.is_negative(), + "every Billwise Lab receivable row is negative: {} was not", + row.closing_balance.as_str() + ); + sum = sum.checked_add(&row.closing_balance).unwrap(); + } + assert_exact(&sum, "-4514597"); + + let mut parties: Vec<&str> = rows.iter().map(|row| row.party.as_str()).collect(); + parties.sort_unstable(); + parties.dedup(); + assert_eq!(parties.len(), 10); +} + +// --------------------------------------------------------------------- +// C: Ageing buckets on Billwise Lab at as-of 2026-07-31 = counts +// [4,4,4,36], amounts [395100.00, 890305.00, 445750.00, 2783442.00]. +// --------------------------------------------------------------------- +#[test] +fn ageing_buckets_billwise_lab_match_measured_values_at_as_of() { + let receivable_rows = parse_native_bill_rows( + BILLS_RECEIVABLE_BILLWISE_LAB, + &as_of(BILLWISE_LAB_BOOKS_FROM), + &as_of(NATIVE_CAPTURE_AS_OF), + ) + .unwrap(); + let payable_rows = parse_native_bill_rows( + BILLS_PAYABLE_BILLWISE_LAB_EMPTY, + &as_of(BILLWISE_LAB_BOOKS_FROM), + &as_of(NATIVE_CAPTURE_AS_OF), + ) + .unwrap(); + let ledgers = parse_native_ledger_snapshot(LEDGER_SNAPSHOT_BILLWISE_LAB).unwrap(); + + let result = compute_native_outstandings( + BILLWISE_LAB_COMPANY, + &receivable_rows, + &payable_rows, + NativeMasterSnapshot { + ledgers: &ledgers, + groups: &[], + }, + AgeingAnchor::DueDate, + &as_of("20260731"), + BILLS_RECEIVABLE_BILLWISE_LAB.len(), + ) + .expect("Billwise Lab computes"); + + let report = &result.report; + assert_eq!(report.open_receivable_bill_count, 48); + assert_eq!(report.ageing_bill_counts.days_0_30, 4); + assert_eq!(report.ageing_bill_counts.days_31_60, 4); + assert_eq!(report.ageing_bill_counts.days_61_90, 4); + assert_eq!(report.ageing_bill_counts.days_90_plus, 36); + + assert_exact(&report.ageing.days_0_30, "395100"); + assert_exact(&report.ageing.days_31_60, "890305"); + assert_exact(&report.ageing.days_61_90, "445750"); + assert_exact(&report.ageing.days_90_plus, "2783442"); + + // In this book BILLDUE == BILLDATE for all 48 rows, so both anchors + // agree; the DueDate default reproduces Tally's own BILLOVERDUE exactly. + assert_eq!(result.overdue_crosscheck_mismatches, 0); + assert_exact(&report.receivable_total, "4514597"); + assert_eq!(report.payable_total, ExactDecimal::zero()); + assert_eq!(report.source_voucher_count, 0); + assert_eq!(report.source_bytes, BILLS_RECEIVABLE_BILLWISE_LAB.len()); + + let bill_date_anchor_result = compute_native_outstandings( + BILLWISE_LAB_COMPANY, + &receivable_rows, + &payable_rows, + NativeMasterSnapshot { + ledgers: &ledgers, + groups: &[], + }, + AgeingAnchor::BillDate, + &as_of("20260731"), + BILLS_RECEIVABLE_BILLWISE_LAB.len(), + ) + .expect("Billwise Lab computes under the BillDate anchor too"); + // BILLDUE == BILLDATE everywhere in this book, so the two anchors must + // agree exactly here (the divergence is exercised on the Ageing Lab + // fixture below, which has a genuine credit-period bill). + assert_eq!(bill_date_anchor_result.report.ageing, report.ageing); + assert_eq!( + bill_date_anchor_result.report.ageing_bill_counts, + report.ageing_bill_counts + ); +} + +// --------------------------------------------------------------------- +// D: On-Account identity, per party: +// ledger CLOSINGBALANCE - sum(BILLCL receivable) - sum(BILLCL payable). +// 6 of 10 parties give exactly 0.00; 4 give non-zero (Lotus Home Stores +// 37500.00, Metro Trade Link 15000.00, Sharma Traders 22500.00, Sunrise +// Electronics 30000.00) totalling exactly 105000.00. +// --------------------------------------------------------------------- +#[test] +fn on_account_residuals_billwise_lab_match_measured_totals() { + let receivable_rows = parse_native_bill_rows( + BILLS_RECEIVABLE_BILLWISE_LAB, + &as_of(BILLWISE_LAB_BOOKS_FROM), + &as_of(NATIVE_CAPTURE_AS_OF), + ) + .unwrap(); + let payable_rows = parse_native_bill_rows( + BILLS_PAYABLE_BILLWISE_LAB_EMPTY, + &as_of(BILLWISE_LAB_BOOKS_FROM), + &as_of(NATIVE_CAPTURE_AS_OF), + ) + .unwrap(); + let ledgers = parse_native_ledger_snapshot(LEDGER_SNAPSHOT_BILLWISE_LAB).unwrap(); + + let result = compute_native_outstandings( + BILLWISE_LAB_COMPANY, + &receivable_rows, + &payable_rows, + NativeMasterSnapshot { + ledgers: &ledgers, + groups: &[], + }, + AgeingAnchor::DueDate, + &as_of("20260731"), + BILLS_RECEIVABLE_BILLWISE_LAB.len(), + ) + .unwrap(); + + // Only the 10 bill-wise (Sundry Debtor) ledgers carry a residual; Cash, + // Profit & Loss A/c, and Sales are present in the snapshot but are not + // bill-wise and must not appear here. + assert_eq!(result.residuals.len(), 10); + + let zero_count = result + .residuals + .iter() + .filter(|residual| residual.amount.is_zero()) + .count(); + assert_eq!(zero_count, 6); + + let expect_residual = |party: &str, canonical_amount: &str| { + let found = result + .residuals + .iter() + .find(|residual| residual.party == party) + .unwrap_or_else(|| panic!("residual for {party} present")); + assert_exact(&found.amount, canonical_amount); + }; + expect_residual("Lotus Home Stores", "37500"); + expect_residual("Metro Trade Link", "15000"); + expect_residual("Sharma Traders", "22500"); + expect_residual("Sunrise Electronics", "30000"); + + assert_exact(&result.residual_total, "105000"); + assert!( + !result.report.has_unaged_receivable, + "the measured Rs 1,05,000 residual is payable exposure, not receivable" + ); +} + +// --------------------------------------------------------------------- +// B: Ageing anchor — Tally ages by DUE DATE. On the Ageing Lab fixture, the +// DueDate anchor reproduces Tally's own BILLOVERDUE for all 5 bills; the +// BillDate anchor does NOT for CREDIT-30 (BILLDATE=1-May-26, +// BILLDUE=31-May-26, BILLOVERDUE=61 == age-from-DUE, not age-from-BILLDATE +// which is 91). +// --------------------------------------------------------------------- +#[test] +fn ageing_lab_due_date_anchor_reproduces_tally_overdue_bill_date_does_not() { + let rows = parse_native_bill_rows( + BILLS_RECEIVABLE_AGEING_LAB, + &as_of(AGEING_LAB_BOOKS_FROM), + &as_of(NATIVE_CAPTURE_AS_OF), + ) + .expect("the real Ageing Lab capture parses"); + assert_eq!(rows.len(), 5); + + // SVTODATE that produced these BILLOVERDUE values: 1-Apr-24 + 851 days + // == 31-Jul-26 in the sibling Billwise Lab capture, and independently + // 1-May-26 + 91 days == 31-Jul-26 here (CANARY-1). Both captures were + // taken from the same live run. + let as_of_date = as_of("20260731"); + + let mut credit_30_checked = false; + for row in &rows { + let tally_overdue = row + .tally_overdue_days + .expect("every Ageing Lab row carries BILLOVERDUE"); + + let age_from_due = age_in_days(&row.due_date, &as_of_date).unwrap(); + assert_eq!( + i64::from(age_from_due), + tally_overdue, + "DueDate anchor must reproduce Tally's own BILLOVERDUE for {}", + row.reference + ); + + let age_from_bill_date = age_in_days(&row.bill_date, &as_of_date).unwrap(); + if row.reference == "CREDIT-30" { + assert_eq!(row.bill_date.as_str(), "20260501"); + assert_eq!(row.due_date.as_str(), "20260531"); + assert_eq!(tally_overdue, 61); + assert_eq!(age_from_due, 61); + assert_eq!( + age_from_bill_date, 91, + "CREDIT-30's BillDate anchor must NOT reproduce BILLOVERDUE" + ); + assert_ne!(i64::from(age_from_bill_date), tally_overdue); + credit_30_checked = true; + } else { + // Every other Ageing Lab bill has BILLDATE == BILLDUE, so both + // anchors necessarily agree with BILLOVERDUE there too. + assert_eq!(i64::from(age_from_bill_date), tally_overdue); + } + } + assert!( + credit_30_checked, + "CREDIT-30 must be present in the fixture" + ); +} + +// --------------------------------------------------------------------- +// Grammar rule 2 / empty-response handling: a bare `` +// (23 bytes) is legitimate zero-row success, not an error. +// --------------------------------------------------------------------- +#[test] +fn empty_bills_response_is_legitimate_zero_row_success() { + assert_eq!(BILLS_PAYABLE_BILLWISE_LAB_EMPTY.len(), 23); + let rows = parse_native_bill_rows( + BILLS_PAYABLE_BILLWISE_LAB_EMPTY, + &as_of(BILLWISE_LAB_BOOKS_FROM), + &as_of(NATIVE_CAPTURE_AS_OF), + ) + .expect("a bare ENVELOPE is success, not an error"); + assert!(rows.is_empty()); +} + +// --------------------------------------------------------------------- +// CMPINFO counter trap: `ledger_snapshot_billwise_lab.xml`'s DESC/CMPINFO +// block carries a bare `0` counter, sharing its tag name +// with real rows. Scanning must be scoped to DATA/COLLECTION only. +// +// Ground-truth correction: the response actually carries 13 ledger master +// rows in DATA/COLLECTION (the 10 Sundry Debtors plus Cash, Profit & Loss +// A/c, and Sales — all present in the same "List of Ledgers" export), not +// 10. A parser that also counts the CMPINFO counter would return 14. The +// meaningful assertion is 13 (not 14), with exactly 10 of those 13 flagged +// bill-wise — matching the "10 Sundry ledgers" / "10 distinct parties" figure +// elsewhere in this suite. +// --------------------------------------------------------------------- +#[test] +fn ledger_snapshot_ignores_the_cmpinfo_counter_trap() { + let ledgers = parse_native_ledger_snapshot(LEDGER_SNAPSHOT_BILLWISE_LAB) + .expect("the real ledger snapshot capture parses"); + + assert_eq!( + ledgers.len(), + 13, + "must scan only DATA/COLLECTION rows (13), neither missing rows nor \ + double-counting the DESC/CMPINFO 0 counter (14)" + ); + + let bill_wise_count = ledgers.iter().filter(|ledger| ledger.bill_wise_on).count(); + assert_eq!(bill_wise_count, 10); + + let names: Vec<&str> = ledgers.iter().map(|ledger| ledger.name.as_str()).collect(); + for expected in ["Cash", "Sales", "Profit & Loss A/c"] { + assert!( + names.contains(&expected), + "{expected} is a real non-bill-wise row in this export and must still be returned" + ); + } +} + +// --------------------------------------------------------------------- +// Grammar rule 2, inverted verification: a `` element anywhere in +// this report shape means Tally reported failure. Success never carries one. +// --------------------------------------------------------------------- +#[test] +fn status_bearing_bills_response_fails_closed() { + let xml = "0"; + let result = parse_native_bill_rows( + xml, + &as_of(BILLWISE_LAB_BOOKS_FROM), + &as_of(NATIVE_CAPTURE_AS_OF), + ); + assert_eq!(result, Err(NativeOutstandingsError::TallyReportedFailure)); +} + +// --------------------------------------------------------------------- +// Grammar rule 1: a scalar appearing before any BILLFIXED must fail closed. +// --------------------------------------------------------------------- +#[test] +fn billcl_before_any_billfixed_fails_closed() { + let xml = "-100.00"; + let result = parse_native_bill_rows( + xml, + &as_of(BILLWISE_LAB_BOOKS_FROM), + &as_of(NATIVE_CAPTURE_AS_OF), + ); + assert_eq!( + result, + Err(NativeOutstandingsError::InvalidResponse( + "bills_scalar_before_fixed" + )) + ); +} + +// --------------------------------------------------------------------- +// Grammar rule 1: a BILLFIXED lacking a BILLCL must fail closed, even when a +// later, fully-formed row follows it. +// --------------------------------------------------------------------- +#[test] +fn billfixed_missing_billcl_fails_closed() { + let xml = "\ + 1-Apr-24INV-1P\ + 1-Apr-24INV-2P\ + -10.001-Apr-241\ + "; + let result = parse_native_bill_rows( + xml, + &as_of(BILLWISE_LAB_BOOKS_FROM), + &as_of(NATIVE_CAPTURE_AS_OF), + ); + assert_eq!( + result, + Err(NativeOutstandingsError::InvalidResponse( + "bills_fixed_row_missing_billcl" + )) + ); +} + +#[test] +fn empty_bill_party_from_raw_bytes_fails_closed_before_double_counting() { + let bills = "\ + 1-Jul-26SYNTHETIC-INV-1\ + -100.001-Jul-2630\ + "; + let ledger_bytes = "
1
\ + Sundry Debtors\ + -100.000.00\ + Yes\ +
"; + + let result = parse_native_bill_rows( + bills, + &as_of(AGEING_LAB_BOOKS_FROM), + &as_of(NATIVE_CAPTURE_AS_OF), + ) + .and_then(|receivable| { + let ledgers = parse_native_ledger_snapshot(ledger_bytes)?; + compute_native_outstandings( + "Synthetic Company", + &receivable, + &[], + NativeMasterSnapshot { + ledgers: &ledgers, + groups: &[], + }, + AgeingAnchor::DueDate, + &as_of(NATIVE_CAPTURE_AS_OF), + bills.len() + ledger_bytes.len(), + ) + }); + + match result { + Err(error) => assert_eq!( + error, + NativeOutstandingsError::InvalidResponse("bills_fixed_empty_billparty") + ), + Ok(result) => { + assert_exact(&result.report.receivable_total, "100"); + assert_exact(&result.residual_total, "100"); + let disclosed_total = result + .report + .receivable_total + .checked_add(&result.residual_total) + .expect("the pinned synthetic total is in range"); + assert_exact(&disclosed_total, "200"); + panic!( + "the raw-byte read completed with a double-counted total of {}", + disclosed_total.as_str() + ); + } + } +} + +#[test] +fn whitespace_and_self_closing_bill_party_use_the_distinct_empty_party_error() { + for party in [" \n\t ", ""] { + let xml = format!( + "1-Jul-26\ + SYNTHETIC-INV-1{party}\ + -100.001-Jul-26\ + 30" + ); + assert_eq!( + parse_native_bill_rows( + &xml, + &as_of(AGEING_LAB_BOOKS_FROM), + &as_of(NATIVE_CAPTURE_AS_OF), + ), + Err(NativeOutstandingsError::InvalidResponse( + "bills_fixed_empty_billparty" + )) + ); + } +} + +#[test] +fn self_closing_empty_billoverdue_is_none_and_still_rejects_duplicates() { + let one_empty = "\ + 1-Aug-26FUTUREP\ + -10.001-Oct-26\ + "; + let rows = parse_native_bill_rows( + one_empty, + &as_of(VALIDATION_LAB_BOOKS_FROM), + &as_of(VALIDATION_CAPTURE_AS_OF), + ) + .expect("a self-closing empty value has the same field-specific meaning"); + assert_eq!(rows[0].tally_overdue_days, None); + + let duplicate = one_empty.replace( + "", + "0", + ); + assert_eq!( + parse_native_bill_rows( + &duplicate, + &as_of(VALIDATION_LAB_BOOKS_FROM), + &as_of(VALIDATION_CAPTURE_AS_OF), + ), + Err(NativeOutstandingsError::InvalidResponse( + "bills_duplicate_billoverdue" + )) + ); +} + +#[test] +fn bills_sanitize_illegal_numeric_references_before_decoding_text_fields() { + let xml = "\ + 1-Apr-24 REFERENCE PARTY\ + -10.001-Apr-241\ + "; + let rows = parse_native_bill_rows( + xml, + &as_of(BILLWISE_LAB_BOOKS_FROM), + &as_of(NATIVE_CAPTURE_AS_OF), + ) + .expect("Tally's illegal text references are made XML-1.0-safe at the boundary"); + + assert_eq!(rows[0].party, "\u{fffd}#4; PARTY"); + assert_eq!(rows[0].reference, "\u{fffd}#4; REFERENCE"); +} + +#[test] +fn ledger_snapshot_sanitizes_illegal_numeric_references_before_decoding_text_fields() { + let xml = "
1
\ +  PARENT-10.00\ + 0.00Yes\ +
"; + let rows = parse_native_ledger_snapshot(xml) + .expect("Tally's illegal text references are made XML-1.0-safe at the boundary"); + + assert_eq!(rows[0].name, "\u{fffd}#4; LEDGER"); + assert_eq!(rows[0].parent.as_deref(), Some("\u{fffd}#4; PARENT")); +} + +#[test] +fn ledger_snapshot_requires_the_collection_even_when_status_is_success() { + assert_eq!( + parse_native_ledger_snapshot( + "
1
" + ), + Err(NativeOutstandingsError::InvalidResponse( + "ledger_collection_missing" + )) + ); +} + +#[test] +fn illegal_numeric_references_in_amounts_remain_fail_closed() { + let bills = "\ + 1-Apr-24REFERENCEPARTY\ +  -10.001-Apr-241\ + "; + assert_eq!( + parse_native_bill_rows( + bills, + &as_of(BILLWISE_LAB_BOOKS_FROM), + &as_of(NATIVE_CAPTURE_AS_OF), + ), + Err(NativeOutstandingsError::InvalidAmount) + ); + + let ledgers = "
1
\ + PARENT -10.00\ + 0.00Yes\ +
"; + assert_eq!( + parse_native_ledger_snapshot(ledgers), + Err(NativeOutstandingsError::InvalidAmount) + ); +} diff --git a/src-tauri/crates/bridge-tally-protocol/tests/native_outstandings_live_fixtures.rs b/src-tauri/crates/bridge-tally-protocol/tests/native_outstandings_live_fixtures.rs new file mode 100644 index 00000000..f22f6059 --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/tests/native_outstandings_live_fixtures.rs @@ -0,0 +1,329 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Acceptance tests for the native outstandings path, driven by responses +//! captured live from TallyPrime on 2026-08-07. +//! +//! Every expected number here is ground truth from an independent source, not +//! from this implementation: +//! +//! - The Billwise Lab figures (48 open bills, Rs 45,14,597, ageing 4/4/4/36) +//! are the Unit A exit-criterion constants, which were themselves agreed by +//! Tally's own Bills Receivable report *and* a raw-XML computation before +//! any of this code existed. +//! - The Rs 1,05,000 residual equals Unit A's independently derived payable +//! total for the same book. +//! - The Ageing Lab expectations come from Tally's own `BILLOVERDUE` column. +//! +//! That matters because `TALLY_PROTOCOL_REFERENCE.md` defines VERIFIED +//! evidence as a live observation with a captured request and response: a test +//! whose expectations are copied from the implementation it checks will +//! survive the implementation being wrong. + +use bridge_tally_primitives::{ExactDecimal, TallyDate}; +use bridge_tally_protocol::native_outstandings::{ + age_in_days, compute_native_outstandings, parse_native_bill_rows, parse_native_ledger_snapshot, + AgeingAnchor, NativeMasterSnapshot, +}; + +const FIXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/native"); + +fn fixture(name: &str) -> String { + std::fs::read_to_string(format!("{FIXTURES}/{name}")) + .unwrap_or_else(|error| panic!("fixture {name} unreadable: {error}")) +} + +fn as_of() -> TallyDate { + TallyDate::parse("20260731").expect("valid as-of") +} + +fn books_from(yyyymmdd: &str) -> TallyDate { + TallyDate::parse(yyyymmdd).expect("valid books-from date") +} + +/// The whole thesis in one assertion: one native request reproduces the +/// 54-request, 8.30 s voucher scan exactly. +#[test] +fn billwise_lab_reproduces_the_unit_a_exit_criteria_exactly() { + let receivable = parse_native_bill_rows( + &fixture("bills_receivable_billwise_lab.xml"), + &books_from("20240401"), + &as_of(), + ) + .expect("receivable rows parse"); + let payable = parse_native_bill_rows( + &fixture("bills_payable_billwise_lab_empty.xml"), + &books_from("20240401"), + &as_of(), + ) + .expect("empty payable parses as success, not failure"); + let ledgers = parse_native_ledger_snapshot(&fixture("ledger_snapshot_billwise_lab.xml")) + .expect("ledger snapshot parses"); + + assert_eq!(receivable.len(), 48, "Unit A ground truth: 48 open bills"); + assert!(payable.is_empty(), "this book has no credit-balance bills"); + + let result = compute_native_outstandings( + "Bridge Billwise Lab", + &receivable, + &payable, + NativeMasterSnapshot { + ledgers: &ledgers, + groups: &[], + }, + AgeingAnchor::DueDate, + &as_of(), + 11_030, + ) + .expect("native computation succeeds"); + + let report = &result.report; + assert_eq!(report.receivable_total.as_str(), "4514597"); + assert_eq!(report.open_receivable_bill_count, 48); + assert_eq!(report.ageing_bill_counts.days_0_30, 4); + assert_eq!(report.ageing_bill_counts.days_31_60, 4); + assert_eq!(report.ageing_bill_counts.days_61_90, 4); + assert_eq!(report.ageing_bill_counts.days_90_plus, 36); + + // The buckets must sum to the receivable total. A wrong as-of moves only + // the buckets and leaves the total right, so this is the assertion that + // catches it. + let summed = [ + &report.ageing.days_0_30, + &report.ageing.days_31_60, + &report.ageing.days_61_90, + &report.ageing.days_90_plus, + ] + .into_iter() + .try_fold(ExactDecimal::zero(), |total, bucket| { + total.checked_add(bucket) + }) + .expect("bucket sum is exact"); + assert_eq!(summed.as_str(), report.receivable_total.as_str()); + + // This path reads no vouchers. Claiming otherwise on screen would be a + // false provenance claim. + assert_eq!(report.source_voucher_count, 0); +} + +/// The named bills are not the whole exposure. The ledger read recovers the +/// rest, and it must equal Unit A's separately derived payable figure. +#[test] +fn billwise_lab_residual_equals_unit_a_payable_total_to_the_rupee() { + let receivable = parse_native_bill_rows( + &fixture("bills_receivable_billwise_lab.xml"), + &books_from("20240401"), + &as_of(), + ) + .expect("receivable rows parse"); + let payable = parse_native_bill_rows( + &fixture("bills_payable_billwise_lab_empty.xml"), + &books_from("20240401"), + &as_of(), + ) + .expect("payable parses"); + let ledgers = parse_native_ledger_snapshot(&fixture("ledger_snapshot_billwise_lab.xml")) + .expect("ledger snapshot parses"); + + let result = compute_native_outstandings( + "Bridge Billwise Lab", + &receivable, + &payable, + NativeMasterSnapshot { + ledgers: &ledgers, + groups: &[], + }, + AgeingAnchor::DueDate, + &as_of(), + 11_030, + ) + .expect("native computation succeeds"); + + assert_eq!( + result.residual_total.as_str(), + "105000", + "Unit A independently derived Rs 1,05,000 payable for this book" + ); + + let non_zero = result + .residuals + .iter() + .filter(|residual| !residual.amount.is_zero()) + .count(); + assert_eq!(non_zero, 4, "Lotus, Metro, Sharma and Sunrise carry it"); +} + +/// The opposite composition. Here the named bills are a rounding error and the +/// residual is the answer -- a report that omitted it would be short by 96%. +#[test] +fn aarav_residual_dominates_and_every_bill_carrying_party_reconciles_exactly() { + let receivable = parse_native_bill_rows( + &fixture("bills_receivable_aarav.xml"), + &books_from("20240401"), + &as_of(), + ) + .expect("parse"); + let payable = parse_native_bill_rows( + &fixture("bills_payable_aarav.xml"), + &books_from("20240401"), + &as_of(), + ) + .expect("parse"); + let ledgers = + parse_native_ledger_snapshot(&fixture("ledger_snapshot_aarav.xml")).expect("parse"); + + assert_eq!(receivable.len(), 22); + assert_eq!(payable.len(), 21); + + let result = compute_native_outstandings( + "Aarav Trading Company Demo", + &receivable, + &payable, + NativeMasterSnapshot { + ledgers: &ledgers, + groups: &[], + }, + AgeingAnchor::DueDate, + &as_of(), + 51_003, + ) + .expect("native computation succeeds"); + + // Named bills total ~Rs 10.36 lakh across 43 rows. + let named = result + .report + .receivable_total + .checked_add(&result.report.payable_total) + .expect("exact"); + assert_eq!(named.as_str(), "1035702.2"); + + // The invariant that actually matters: every party that carries bills + // reconciles to the paisa. A non-zero residual on such a party would mean + // the bill report and the ledger disagree, and no total could be trusted. + let bill_carrying_but_unreconciled = result + .residuals + .iter() + .filter(|residual| !residual.amount.is_zero()) + .filter(|residual| { + receivable + .iter() + .chain(payable.iter()) + .any(|row| row.party == residual.party) + }) + .count(); + assert_eq!( + bill_carrying_but_unreconciled, 0, + "all 7 bill-carrying parties must reconcile exactly" + ); + + // Residual is summed as gross magnitude, not net: a creditor's unallocated + // credit must not cancel a debtor's unallocated debit, because they are + // different counterparties. Net would be Rs 2,78,57,843.69; gross is + // Rs 20,74,00,748.79. Either way the named bills are a rounding error + // beside it, which is the point. + assert_eq!(result.residual_total.as_str(), "207400748.79"); + assert!( + result.report.has_unaged_receivable, + "unallocated debtor exposure must be disclosed, never silently dropped" + ); +} + +/// Settles the ageing-anchor question. Tally's own BILLOVERDUE column is the +/// oracle, and it disagrees with bill-date anchoring exactly where a credit +/// period exists. +#[test] +fn due_date_anchor_matches_tallys_own_overdue_column_where_bill_date_does_not() { + let rows = parse_native_bill_rows( + &fixture("bills_receivable_ageing_lab.xml"), + &books_from("20260401"), + &as_of(), + ) + .expect("parse"); + assert_eq!(rows.len(), 5); + let as_of = as_of(); + + let mut credit_period_bills = 0; + for row in &rows { + let tally = row + .tally_overdue_days + .expect("every Ageing Lab row carries BILLOVERDUE"); + let from_due = age_in_days(&row.due_date, &as_of).expect("age computes"); + assert_eq!( + i64::from(from_due), + tally, + "due-date anchor must reproduce Tally's own column for {}", + row.reference + ); + + if row.bill_date != row.due_date { + credit_period_bills += 1; + let from_bill_date = age_in_days(&row.bill_date, &as_of).expect("age computes"); + assert_ne!( + i64::from(from_bill_date), + tally, + "{} carries a credit period, so bill-date anchoring MUST diverge \ + -- if this ever passes, the fixture stopped proving its premise", + row.reference + ); + } + } + assert_eq!( + credit_period_bills, 1, + "CREDIT-30 is the only bill able to distinguish the two anchors; \ + without it this whole test proves nothing" + ); +} + +/// An unloaded company must refuse rather than silently answer for whichever +/// company happens to be open. This is the failure the Collection path does +/// NOT protect against. +#[test] +fn unloaded_company_response_fails_closed() { + let result = parse_native_bill_rows( + &fixture("bills_receivable_unloaded_company_failure.xml"), + &books_from("20240401"), + &as_of(), + ); + assert!( + result.is_err(), + "a STATUS-bearing response is a failure on this path, never an empty result" + ); +} + +/// The ledger collection ships a CMPINFO block full of bare counter elements +/// like `0`. Parsing outside `` picks them up as rows. +#[test] +fn ledger_parser_ignores_the_cmpinfo_counter_elements() { + let ledgers = + parse_native_ledger_snapshot(&fixture("ledger_snapshot_billwise_lab.xml")).expect("parse"); + assert_eq!( + ledgers.len(), + 13, + "13 real ledgers; a parser that scans the whole body also counts CMPINFO" + ); + assert!( + ledgers.iter().all(|ledger| !ledger.name.is_empty()), + "a counter element would parse as a nameless ledger" + ); + let sundry = ledgers + .iter() + .filter(|ledger| { + ledger + .parent + .as_deref() + .is_some_and(|parent| parent.contains("Sundry")) + }) + .count(); + assert_eq!(sundry, 10); +} +#[test] +fn real_captured_company_collection_yields_all_three() { + let xml = std::fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/native/company_collection_live.xml" + )) + .expect("fixture"); + let companies = bridge_tally_protocol::parse_companies_from_collection(&xml) + .expect("the exact live bytes must parse"); + assert_eq!(companies.len(), 3, "got: {companies:?}"); + assert!(companies.iter().all(|c| c.guid.is_some())); +} diff --git a/src-tauri/crates/bridge-tally-protocol/tests/outstandings.rs b/src-tauri/crates/bridge-tally-protocol/tests/outstandings.rs index d537c0d6..430c5dc6 100644 --- a/src-tauri/crates/bridge-tally-protocol/tests/outstandings.rs +++ b/src-tauri/crates/bridge-tally-protocol/tests/outstandings.rs @@ -1,3 +1,11 @@ +//! Exercises the legacy voucher-scan outstandings machinery end to end, so +//! this whole file is gated behind `voucher-scan`: with the feature off, +//! `bridge_tally_protocol::outstandings` does not exist and there is no scan +//! left to test. `parse_company_book_extent`'s own identity-verification +//! properties -- shared with the always-on native path -- are covered +//! separately in `tests/outstandings_shared.rs`, which is NOT gated. +#![cfg(feature = "voucher-scan")] + use bridge_tally_primitives::TallyDate; use bridge_tally_protocol::{ outstandings::{ @@ -379,59 +387,12 @@ fn voucher_guid_binding_requires_a_nonempty_master_suffix() { )); } -#[test] -fn company_pin_is_created_only_after_live_identity_matches() { - let extent = extent(); - assert_eq!(extent.company().name(), COMPANY_NAME); - assert_eq!(extent.company().guid(), COMPANY_GUID); - assert!(matches!( - parse_company_book_extent(COMPANY_EXTENT, COMPANY_NAME, "wrong-guid"), - Err(OutstandingsError::CompanyIdentityMismatch) - )); -} - -#[test] -fn company_extent_selects_the_expected_guid_in_a_multi_company_collection() { - let company_start = COMPANY_EXTENT - .find(" ") - .map(|offset| company_start + offset + " ".len()) - .expect("real capture company row is complete"); - let expected_row = &COMPANY_EXTENT[company_start..company_end]; - let unrelated_row = expected_row - .replace(COMPANY_NAME, "Earlier Loaded Synthetic Company") - .replace(COMPANY_GUID, "00000000-0000-4000-8000-000000000001"); - let response = - COMPANY_EXTENT.replacen(expected_row, &format!("{unrelated_row}\n{expected_row}"), 1); - - let selected = parse_company_book_extent(&response, COMPANY_NAME, COMPANY_GUID) - .expect("GUID selection is independent of collection order"); - assert_eq!(selected.company().name(), COMPANY_NAME); - assert_eq!(selected.company().guid(), COMPANY_GUID); -} - -#[test] -fn company_extent_rejects_duplicate_rows_for_the_expected_guid() { - let company_start = COMPANY_EXTENT - .find(" ") - .map(|offset| company_start + offset + " ".len()) - .expect("real capture company row is complete"); - let expected_row = &COMPANY_EXTENT[company_start..company_end]; - let response = - COMPANY_EXTENT.replacen(expected_row, &format!("{expected_row}\n{expected_row}"), 1); - - assert_eq!( - parse_company_book_extent(&response, COMPANY_NAME, COMPANY_GUID), - Err(OutstandingsError::InvalidResponse( - "company_identity_ambiguous" - )) - ); -} +// `company_pin_is_created_only_after_live_identity_matches`, +// `company_extent_selects_the_expected_guid_in_a_multi_company_collection`, +// and `company_extent_rejects_duplicate_rows_for_the_expected_guid` moved to +// `tests/outstandings_shared.rs`: they test `parse_company_book_extent`, +// which is shared with (and, in the default build, exercised only by) the +// always-on native outstandings path. #[test] fn final_profile_is_bounded_and_contains_no_self_reference() { diff --git a/src-tauri/crates/bridge-tally-protocol/tests/outstandings_shared.rs b/src-tauri/crates/bridge-tally-protocol/tests/outstandings_shared.rs new file mode 100644 index 00000000..7ef72b83 --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/tests/outstandings_shared.rs @@ -0,0 +1,74 @@ +//! Safety-property tests for `outstandings_shared`: the company-identity and +//! book-extent read shared by the native (always compiled) and voucher-scan +//! (feature-gated) outstandings paths. +//! +//! These moved out of `tests/outstandings.rs` -- which is gated behind +//! `voucher-scan` because it otherwise exercises only scan machinery -- so +//! that `parse_company_book_extent`'s identity-verification properties stay +//! covered in the default build too, where the native path is what actually +//! calls it. + +use bridge_tally_protocol::outstandings_shared::{parse_company_book_extent, OutstandingsError}; + +const COMPANY_EXTENT: &str = include_str!("fixtures/unit_a_company_extent_live.xml"); +const COMPANY_NAME: &str = "Aarav Trading Company Demo"; +const COMPANY_GUID: &str = "bb8ad19e-6aef-4239-a917-87fec0c6215e"; + +fn extent() -> bridge_tally_protocol::outstandings_shared::CompanyBookExtent { + parse_company_book_extent(COMPANY_EXTENT, COMPANY_NAME, COMPANY_GUID) + .expect("real company extent capture parses") +} + +#[test] +fn company_pin_is_created_only_after_live_identity_matches() { + let extent = extent(); + assert_eq!(extent.company().name(), COMPANY_NAME); + assert_eq!(extent.company().guid(), COMPANY_GUID); + assert!(matches!( + parse_company_book_extent(COMPANY_EXTENT, COMPANY_NAME, "wrong-guid"), + Err(OutstandingsError::CompanyIdentityMismatch) + )); +} + +#[test] +fn company_extent_selects_the_expected_guid_in_a_multi_company_collection() { + let company_start = COMPANY_EXTENT + .find(" ") + .map(|offset| company_start + offset + " ".len()) + .expect("real capture company row is complete"); + let expected_row = &COMPANY_EXTENT[company_start..company_end]; + let unrelated_row = expected_row + .replace(COMPANY_NAME, "Earlier Loaded Synthetic Company") + .replace(COMPANY_GUID, "00000000-0000-4000-8000-000000000001"); + let response = + COMPANY_EXTENT.replacen(expected_row, &format!("{unrelated_row}\n{expected_row}"), 1); + + let selected = parse_company_book_extent(&response, COMPANY_NAME, COMPANY_GUID) + .expect("GUID selection is independent of collection order"); + assert_eq!(selected.company().name(), COMPANY_NAME); + assert_eq!(selected.company().guid(), COMPANY_GUID); +} + +#[test] +fn company_extent_rejects_duplicate_rows_for_the_expected_guid() { + let company_start = COMPANY_EXTENT + .find(" ") + .map(|offset| company_start + offset + " ".len()) + .expect("real capture company row is complete"); + let expected_row = &COMPANY_EXTENT[company_start..company_end]; + let response = + COMPANY_EXTENT.replacen(expected_row, &format!("{expected_row}\n{expected_row}"), 1); + + assert_eq!( + parse_company_book_extent(&response, COMPANY_NAME, COMPANY_GUID), + Err(OutstandingsError::InvalidResponse( + "company_identity_ambiguous" + )) + ); +} diff --git a/src-tauri/crates/bridge-tally-protocol/tests/simulator_corpus.rs b/src-tauri/crates/bridge-tally-protocol/tests/simulator_corpus.rs index 15ce1938..531782ba 100644 --- a/src-tauri/crates/bridge-tally-protocol/tests/simulator_corpus.rs +++ b/src-tauri/crates/bridge-tally-protocol/tests/simulator_corpus.rs @@ -1,7 +1,7 @@ use bridge_tally_protocol::{ decode_xml_bytes, decode_xml_bytes_limited, export_status, parse_companies, - parse_companies_for_interactive_discovery, parse_companies_with_evidence, - parse_group_source_records_with_evidence, parse_import_result, + parse_companies_for_interactive_discovery, parse_companies_from_collection, + parse_companies_with_evidence, parse_group_source_records_with_evidence, parse_import_result, parse_ledger_source_records_with_evidence, parse_ledgers, parse_ledgers_with_evidence, parse_selected_voucher_source_records_with_evidence, parse_standard_ledger_catalog, parse_standard_ledger_identity_observation, parse_voucher_source_records_with_evidence, @@ -619,6 +619,103 @@ fn interactive_company_discovery_stops_before_materializing_an_oversized_listing assert!(error.to_string().contains("listing limit exceeded")); } +/// Measured live 2026-08-07: the `Company` collection response +/// (`ReadOnlyProfile::CompanyListV2`) for a Tally instance with three loaded +/// companies. `CMPINFO` deliberately carries a bare `0` +/// object counter ahead of `DATA` — the same trap `scripts/tally_probe.py` +/// documents — so every test below also proves that counter is never +/// mistaken for a fourth company row. +const COMPANY_COLLECTION_LIVE_RESPONSE: &str = r#" +
11
+ 000 + + bb8ad19e-6aef-4239-a917-87fec0c6215e + eebb9a9f-1679-4468-9e8f-814c729674cb + 75f7566d-7a4f-431a-9642-e93a9d06d57d + + +
"#; + +#[test] +fn company_collection_response_parses_three_companies_with_correct_guids() { + let companies = parse_companies_from_collection(COMPANY_COLLECTION_LIVE_RESPONSE) + .expect("shaped Company collection response must parse"); + assert_eq!(companies.len(), 3); + assert_eq!(companies[0].name, "Aarav Trading Company Demo"); + assert_eq!( + companies[0].guid.as_deref(), + Some("bb8ad19e-6aef-4239-a917-87fec0c6215e") + ); + assert_eq!(companies[1].name, "Bridge Ageing Lab"); + assert_eq!( + companies[1].guid.as_deref(), + Some("eebb9a9f-1679-4468-9e8f-814c729674cb") + ); + assert_eq!(companies[2].name, "Bridge Billwise Lab"); + assert_eq!( + companies[2].guid.as_deref(), + Some("75f7566d-7a4f-431a-9642-e93a9d06d57d") + ); +} + +#[test] +fn company_collection_response_never_counts_the_cmpinfo_object_counter() { + // The exact same response also proves the `CMPINFO/COMPANY` counter + // (a bare `0`, sitting under `DESC`, outside `DATA`) + // never inflates the row count above the three real `DATA/COLLECTION` + // rows. + let companies = parse_companies_from_collection(COMPANY_COLLECTION_LIVE_RESPONSE) + .expect("shaped Company collection response must parse"); + assert_eq!(companies.len(), 3); + for company in &companies { + assert_ne!(company.name, "0"); + } +} + +#[test] +fn company_collection_response_fails_closed_on_status_zero() { + let failure = r#"
10
Could not find Company ''
"#; + let error = parse_companies_from_collection(failure) + .expect_err("STATUS 0 must not be promoted to a trusted empty result"); + assert!(error.to_string().contains("export request failed")); +} + +#[test] +fn company_collection_response_parses_an_empty_collection_without_erroring() { + let empty = r#" +
11
+ 0 + + +
"#; + let companies = parse_companies_from_collection(empty) + .expect("an empty collection is a legitimate zero-row result, not an error"); + assert!(companies.is_empty()); +} + +#[test] +fn company_collection_requires_the_collection_envelope() { + let incomplete = + r#"
1
"#; + assert!(parse_companies_from_collection(incomplete).is_err()); +} + +#[test] +fn every_company_collection_row_requires_a_guid() { + for row in [ + r#""#, + r#""#, + ] { + let xml = format!( + "
1
{row}
" + ); + assert!( + parse_companies_from_collection(&xml).is_err(), + "GUID-less row must fail: {row}" + ); + } +} + #[test] fn standard_ledger_identity_bootstrap_requires_repeated_scoped_context() { let row = |tag: &str, guid: &str| { diff --git a/src-tauri/crates/bridge-tally-transport/Cargo.toml b/src-tauri/crates/bridge-tally-transport/Cargo.toml index 72da4b8f..75c7aea0 100644 --- a/src-tauri/crates/bridge-tally-transport/Cargo.toml +++ b/src-tauri/crates/bridge-tally-transport/Cargo.toml @@ -8,6 +8,14 @@ publish = false edition = "2021" rust-version = "1.96" +[features] +default = [] +# Enables the sealed 40 MiB transport exception for the voucher-scan wildcard +# outstandings request. With this off, `post_outstandings_xml_decoded` and the +# raised response cap do not compile: no request exists that could use them, +# and a raised cap nothing can reach is a widened attack surface for nothing. +voucher-scan = ["bridge-tally-protocol/voucher-scan"] + [dependencies] bridge-tally-protocol = { path = "../bridge-tally-protocol" } reqwest = { version = "0.13", features = ["stream"] } diff --git a/src-tauri/crates/bridge-tally-transport/src/lib.rs b/src-tauri/crates/bridge-tally-transport/src/lib.rs index eaf5931e..84e17f0f 100644 --- a/src-tauri/crates/bridge-tally-transport/src/lib.rs +++ b/src-tauri/crates/bridge-tally-transport/src/lib.rs @@ -6,9 +6,11 @@ use std::{net::IpAddr, time::Duration}; +#[cfg(feature = "voucher-scan")] +use bridge_tally_protocol::outstandings::VoucherOutstandingsRequestXml; use bridge_tally_protocol::{ - decode_tally_text_bytes_limited, outstandings::VoucherOutstandingsRequestXml, - TallyTextDecodeError, TallyTextEncoding, TallyTextStreamDecoder, + decode_tally_text_bytes_limited, TallyTextDecodeError, TallyTextEncoding, + TallyTextStreamDecoder, }; use reqwest::{ header::{CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE}, @@ -22,6 +24,12 @@ use thiserror::Error; pub const STATUS_RESPONSE_MAX_BYTES: usize = 1024 * 1024; pub const XML_REQUEST_MAX_BYTES: usize = 32 * 1024 * 1024; pub const XML_RESPONSE_MAX_BYTES: usize = 32 * 1024 * 1024; +/// Sealed exception for the voucher-scan wildcard outstandings request only +/// -- see `post_outstandings_xml_decoded`. Gated with the request type that +/// is the only thing admitted through it: with `voucher-scan` off, nothing +/// can construct that request, so a raised cap nothing can reach would be a +/// widened attack surface for no reason. +#[cfg(feature = "voucher-scan")] pub const OUTSTANDINGS_XML_RESPONSE_MAX_BYTES: usize = 40 * 1024 * 1024; pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(20); const MAX_REQUEST_TIMEOUT: Duration = Duration::from_secs(120); @@ -358,6 +366,7 @@ impl TallyHttpTransport { /// Closed exception for the live-verified wildcard outstandings profile. /// The general policy remains capped at 32 MiB and the same client keeps /// the immutable 20-second request deadline. + #[cfg(feature = "voucher-scan")] pub async fn post_outstandings_xml_decoded( &self, request: VoucherOutstandingsRequestXml, @@ -665,6 +674,7 @@ mod unit_tests { TransportPolicy::default().xml_response_max_bytes, 32 * 1024 * 1024 ); + #[cfg(feature = "voucher-scan")] assert_eq!(OUTSTANDINGS_XML_RESPONSE_MAX_BYTES, 40 * 1024 * 1024); let expanded = TransportPolicy { request_timeout: MAX_REQUEST_TIMEOUT + Duration::from_millis(1), diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index d85db686..7ea95575 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -6,7 +6,7 @@ use crate::db::tally_mirror::{ LocalReconciliationMismatch, ProofSummary, RedactedProofExport, ReviewedSetupInput, SelectedReadObservationCommitmentMaterial, SelectedReadObservationInput, SelectedReadScopeCommitmentMaterial, SelectedReadScopeInput, SourceIdentityInput, - TallyMirrorRepository, WriteFixtureEnrollmentInput, WriteFixtureEnrollmentStatus, + WriteFixtureEnrollmentInput, WriteFixtureEnrollmentStatus, }; use crate::gst::{GstDraftRequest, GstReturnDraft}; use crate::sync::coordinator::{SnapshotCoordinator, SnapshotJobStatus}; @@ -37,14 +37,6 @@ use sha2::{Digest, Sha256}; use tauri::State; use zeroize::Zeroizing; -#[cfg(feature = "fixture-canary-runtime-dispatch")] -use crate::tally::{ - canary_preflight_preparation::PrepareSealedCanaryPreflightRequest, - canary_runtime_dispatch_coordinator::{ - run_sealed_canary_runtime_sequence, SealedCanaryRuntimeSequenceRequest, - }, -}; - const MAX_DSC_PIN_BYTES: usize = 128; #[derive(Debug, Serialize)] @@ -222,6 +214,27 @@ fn tally_runtime_command_error(error: anyhow::Error) -> TallyCommandError { } } +/// Produces the typed command error for the encrypted Tally mirror failing to initialise on +/// first use (denied keychain authorisation, or a local disk/storage failure). The mirror was +/// never opened, so no local or Tally state changed; retrying after the operator resolves the +/// underlying keychain/disk issue is safe. +fn mirror_unavailable_command_error(_error: anyhow::Error) -> TallyCommandError { + tally_command_error( + "tally_mirror_unavailable", + "Operation", + "The encrypted Tally mirror could not be opened. Its operating-system credential may have been denied, or local storage is unavailable.", + "safe", + false, + "Approve the operating-system credential prompt for Bridge, or verify local disk access, then retry.", + ) +} + +/// Same failure as [`mirror_unavailable_command_error`], for the handful of mirror-backed +/// commands that report errors as a plain `String` rather than a [`TallyCommandError`]. +fn mirror_unavailable_string_error(_error: anyhow::Error) -> String { + "The encrypted Tally mirror could not be opened. Its operating-system credential may have been denied, or local storage is unavailable.".to_string() +} + #[tauri::command] pub async fn check_tally_connection( config: TallyConfig, @@ -831,38 +844,16 @@ pub struct TallyWriteFixtureEnrollmentResponse { pub review_cleanup_warning: Option<&'static str>, } -/// Explicit, non-default operator input for the one synthetic fixture canary. -/// The command derives its payload solely from the active enrolled company pin; -/// it accepts neither XML nor a generic write operation. -#[cfg(feature = "fixture-canary-runtime-dispatch")] -#[derive(Debug, Deserialize)] -pub struct DispatchTallySyntheticCanaryRequest { - pub config: TallyConfig, - pub mirror_company_id: String, - pub review_commitment_sha256: String, - pub explicit_dispatch_confirmation: bool, - pub synthetic_company_confirmed: bool, - pub backup_guidance_acknowledged: bool, -} - -/// Redacted terminal receipt for the one sealed synthetic fixture canary. -/// No payload, request, response, target, company identity, or digest is -/// serialized across the Tauri boundary. -#[cfg(feature = "fixture-canary-runtime-dispatch")] -#[derive(Debug, Serialize)] -pub struct DispatchTallySyntheticCanaryResponse { - pub final_verdict_id: String, - pub recorded_at_unix_ms: i64, - pub tally_requests_attempted: u8, - pub tally_writes_attempted: u8, -} - #[tauri::command] pub async fn save_tally_setup( request: SaveTallySetupRequest, - mirror: State<'_, TallyMirrorRepository>, + mirror: State<'_, crate::LazyTallyMirror>, runtime: State<'_, TallyRuntime>, ) -> Result { + let mirror = mirror + .get() + .await + .map_err(mirror_unavailable_command_error)?; let canonical_origin = EndpointKey::from_config(&request.config) .map(|endpoint| endpoint.as_str().to_string()) .map_err(|_| { @@ -1100,9 +1091,13 @@ fn reconcile_review_cleanup( #[tauri::command] pub async fn enroll_tally_write_fixture( request: EnrollTallyWriteFixtureRequest, - mirror: State<'_, TallyMirrorRepository>, + mirror: State<'_, crate::LazyTallyMirror>, runtime: State<'_, TallyRuntime>, ) -> Result { + let mirror = mirror + .get() + .await + .map_err(mirror_unavailable_command_error)?; let canonical_origin = EndpointKey::from_config(&request.config) .map(|endpoint| endpoint.as_str().to_string()) .map_err(|_| { @@ -1235,132 +1230,15 @@ pub async fn enroll_tally_write_fixture( } } -/// Executes exactly one sealed synthetic fixture canary only in a build that -/// explicitly enables the non-default runtime-dispatch feature. The durable -/// sequence reserves the fixed payload, reads its preflight state, consumes one -/// dispatch claim, sends once, and records only a digest-only verdict. It has -/// no retry path. A failure before the durable dispatch claim proves no import -/// was sent; a failure after that claim is an unknown Tally outcome. Neither -/// case may be retried automatically or manually. -#[cfg(feature = "fixture-canary-runtime-dispatch")] -#[tauri::command] -pub async fn dispatch_tally_synthetic_canary( - request: DispatchTallySyntheticCanaryRequest, - mirror: State<'_, TallyMirrorRepository>, - runtime: State<'_, TallyRuntime>, -) -> Result { - EndpointKey::from_config(&request.config).map_err(|_| { - tally_command_error( - "endpoint_configuration_invalid", - "Endpoint configuration", - "Tally endpoint validation failed before the synthetic canary started.", - "after_change", - false, - "Use the separately reviewed loopback endpoint, then start a new enrollment review.", - ) - })?; - if request.mirror_company_id.trim().is_empty() || request.mirror_company_id.len() > 128 { - return Err(tally_command_error( - "fixture_company_scope_invalid", - "Tally application", - "The persisted synthetic fixture scope is invalid.", - "after_change", - false, - "Probe, save, and enroll one GUID-bearing disposable synthetic company before retrying.", - )); - } - if request.review_commitment_sha256.len() != 64 - || !request - .review_commitment_sha256 - .bytes() - .all(|byte| byte.is_ascii_hexdigit()) - { - return Err(tally_command_error( - "fixture_review_commitment_invalid", - "Operation", - "The reviewed synthetic-fixture commitment is invalid.", - "after_change", - false, - "Probe, review, and enroll the disposable synthetic fixture again before attempting a canary.", - )); - } - if !request.explicit_dispatch_confirmation - || !request.synthetic_company_confirmed - || !request.backup_guidance_acknowledged - { - return Err(tally_command_error( - "synthetic_canary_explicit_confirmation_required", - "Operation", - "The synthetic canary requires all explicit operator confirmations before it can start.", - "safe", - false, - "Confirm the disposable synthetic company, offline backup guidance, and one-time dispatch action before retrying.", - )); - } - - let result = run_sealed_canary_runtime_sequence( - &mirror, - &runtime, - SealedCanaryRuntimeSequenceRequest { - config: request.config, - preparation: PrepareSealedCanaryPreflightRequest { - company_id: request.mirror_company_id, - review_commitment_sha256: request.review_commitment_sha256, - explicit_opt_in: request.explicit_dispatch_confirmation, - synthetic_company_confirmed: request.synthetic_company_confirmed, - backup_guidance_acknowledged: request.backup_guidance_acknowledged, - }, - }, - ) - .await - .map_err(|error| match error { - crate::tally::canary_runtime_dispatch_coordinator::SealedCanaryRuntimeSequenceError::PreDispatch => { - synthetic_canary_pre_dispatch_error() - } - crate::tally::canary_runtime_dispatch_coordinator::SealedCanaryRuntimeSequenceError::OutcomeUnknown => { - synthetic_canary_outcome_unknown_error() - } - })?; - - Ok(DispatchTallySyntheticCanaryResponse { - final_verdict_id: result.final_verdict_id, - recorded_at_unix_ms: result.recorded_at_unix_ms, - tally_requests_attempted: 4, - tally_writes_attempted: 1, - }) -} - -#[cfg(feature = "fixture-canary-runtime-dispatch")] -fn synthetic_canary_pre_dispatch_error() -> TallyCommandError { - TallyCommandError { - code: "synthetic_canary_pre_dispatch_failed", - category: "Operation", - message: "The synthetic canary did not reach its durable dispatch claim. No Tally import was sent.".to_owned(), - retry: "after_change", - local_state_changed: true, - tally_state_may_have_changed: false, - remediation: "Inspect the local fixture state and the preflight read result, then revoke the fixture and create a new reviewed enrollment before another canary.", - } -} - -#[cfg(feature = "fixture-canary-runtime-dispatch")] -fn synthetic_canary_outcome_unknown_error() -> TallyCommandError { - TallyCommandError { - code: "synthetic_canary_outcome_unknown", - category: "Tally application", - message: "The one-time synthetic canary did not return a recorded final verdict. Tally state may have changed; do not retry or send another write.".to_owned(), - retry: "never", - local_state_changed: true, - tally_state_may_have_changed: true, - remediation: "Inspect the local fixture status and the dedicated synthetic company in Tally, preserve the result for review, and revoke the fixture before any new enrollment.", - } -} - #[tauri::command] pub async fn tally_write_fixture_enrollment_status( request: TallyWriteFixtureCompanyRequest, - mirror: State<'_, TallyMirrorRepository>, + mirror: State<'_, crate::LazyTallyMirror>, ) -> Result { + let mirror = mirror + .get() + .await + .map_err(mirror_unavailable_command_error)?; mirror .write_fixture_enrollment_status(&request.mirror_company_id) .await @@ -1379,8 +1257,12 @@ pub async fn tally_write_fixture_enrollment_status( #[tauri::command] pub async fn revoke_tally_write_fixture_enrollment( request: TallyWriteFixtureCompanyRequest, - mirror: State<'_, TallyMirrorRepository>, + mirror: State<'_, crate::LazyTallyMirror>, ) -> Result { + let mirror = mirror + .get() + .await + .map_err(mirror_unavailable_command_error)?; mirror .revoke_write_fixture_enrollment( &request.mirror_company_id, @@ -1488,8 +1370,12 @@ fn capability_items(profile: &bridge_tally_core::CapabilityProfile) -> Vec, + mirror: State<'_, crate::LazyTallyMirror>, ) -> Result { + let mirror = mirror + .get() + .await + .map_err(mirror_unavailable_string_error)?; mirror .persisted_company_profiles() .await @@ -1507,8 +1393,12 @@ pub struct TallyMirrorExplorerRequest { #[tauri::command] pub async fn tally_mirror_explorer_page( request: TallyMirrorExplorerRequest, - mirror: State<'_, TallyMirrorRepository>, + mirror: State<'_, crate::LazyTallyMirror>, ) -> Result { + let mirror = mirror + .get() + .await + .map_err(mirror_unavailable_string_error)?; mirror .mirror_explorer_page( &request.mirror_company_id, @@ -1545,11 +1435,15 @@ pub struct TallyEvidenceResponse { #[tauri::command] pub async fn tally_sync_evidence( request: TallyEvidenceRequest, - mirror: State<'_, TallyMirrorRepository>, + mirror: State<'_, crate::LazyTallyMirror>, ) -> Result { if request.mirror_company_id.trim().is_empty() { return Err("Select a company with an observed stable identity".to_string()); } + let mirror = mirror + .get() + .await + .map_err(mirror_unavailable_string_error)?; mirror .snapshot_source_pin(&request.mirror_company_id) .await @@ -1611,11 +1505,15 @@ pub struct RedactedProofExportRequest { #[tauri::command] pub async fn preview_tally_redacted_proof( request: RedactedProofExportRequest, - mirror: State<'_, TallyMirrorRepository>, + mirror: State<'_, crate::LazyTallyMirror>, ) -> Result { if request.mirror_company_id.trim().is_empty() || request.proof_id.trim().is_empty() { return Err("Select a proof for an observed Tally company".to_string()); } + let mirror = mirror + .get() + .await + .map_err(mirror_unavailable_string_error)?; mirror .snapshot_source_pin(&request.mirror_company_id) .await @@ -1661,10 +1559,14 @@ fn first_calendar_day_canary_window( #[tauri::command] pub async fn start_tally_core_snapshot( request: StartCoreSnapshotRequest, - mirror: State<'_, TallyMirrorRepository>, + mirror: State<'_, crate::LazyTallyMirror>, runtime: State<'_, TallyRuntime>, coordinator: State<'_, SnapshotCoordinator>, ) -> Result { + let mirror = mirror + .get() + .await + .map_err(mirror_unavailable_string_error)?; validate_date_range(&request.from, &request.to)?; let pin = mirror .snapshot_source_pin(&request.mirror_company_id) @@ -1796,7 +1698,7 @@ pub async fn start_tally_core_snapshot( freshness_target_seconds: 86_400, }; coordinator - .start(plan, connector, mirror.inner().clone()) + .start(plan, connector, mirror.clone()) .await .map_err(str::to_string) } @@ -1804,24 +1706,29 @@ pub async fn start_tally_core_snapshot( #[tauri::command] pub async fn tally_snapshot_status( run_id: String, - mirror: State<'_, TallyMirrorRepository>, + mirror: State<'_, crate::LazyTallyMirror>, coordinator: State<'_, SnapshotCoordinator>, ) -> Result { + let mirror = mirror + .get() + .await + .map_err(mirror_unavailable_string_error)?; coordinator - .status(&run_id, mirror.inner()) + .status(&run_id, mirror) .await .map_err(str::to_string) } #[tauri::command] pub async fn tally_recent_snapshot_runs( - mirror: State<'_, TallyMirrorRepository>, + mirror: State<'_, crate::LazyTallyMirror>, coordinator: State<'_, SnapshotCoordinator>, ) -> Result, String> { - coordinator - .recent(mirror.inner(), 20) + let mirror = mirror + .get() .await - .map_err(str::to_string) + .map_err(mirror_unavailable_string_error)?; + coordinator.recent(mirror, 20).await.map_err(str::to_string) } #[derive(Debug, Deserialize)] @@ -1833,10 +1740,14 @@ pub struct ResumeCoreSnapshotRequest { #[tauri::command] pub async fn resume_tally_core_snapshot( request: ResumeCoreSnapshotRequest, - mirror: State<'_, TallyMirrorRepository>, + mirror: State<'_, crate::LazyTallyMirror>, runtime: State<'_, TallyRuntime>, coordinator: State<'_, SnapshotCoordinator>, ) -> Result { + let mirror = mirror + .get() + .await + .map_err(mirror_unavailable_string_error)?; let store = SqliteSnapshotStateStore::new(mirror.pool_clone()); store .migrate() @@ -1920,7 +1831,7 @@ pub async fn resume_tally_core_snapshot( ) .map_err(|_| "The stored Core Accounting snapshot profile is invalid".to_string())?; coordinator - .start(plan, connector, mirror.inner().clone()) + .start(plan, connector, mirror.clone()) .await .map_err(str::to_string) } @@ -2190,6 +2101,104 @@ pub async fn fetch_tally_outstandings( .map_err(tally_runtime_command_error) } +#[derive(Debug, Deserialize)] +pub struct AllCompaniesOutstandingsRequest { + pub config: TallyConfig, + pub companies: Vec, + pub currency_assertion: OutstandingsCurrencyAssertion, +} + +#[derive(Debug, Deserialize)] +pub struct AllCompaniesEntry { + pub company: String, + pub expected_company_guid: String, +} + +#[derive(Debug, Serialize)] +pub struct CompanyOutstandingsEntry { + pub company: String, + pub result: OutstandingsLoadResult, +} + +fn company_sweep_result( + result: Result, +) -> OutstandingsLoadResult { + result.unwrap_or_else(|reason_code| OutstandingsLoadResult::Partial { + reason_code: reason_code.to_string(), + synced_at_unix_ms: chrono::Utc::now().timestamp_millis(), + }) +} + +/// Reads outstandings for several companies in one action. +/// +/// This is the read Tally structurally will not do: it is per-company by +/// design, so a firm holding ten client books has no way to ask one question +/// across them. At roughly 0.35s per company on the native path, ten books +/// answer in about four seconds. +/// +/// **Reads run strictly one after another, never concurrently.** Tally's +/// gateway serialises anyway, and the project rule is one live request at a +/// time with a health check between -- issuing these in parallel is the +/// documented way to hang or crash the instance the user is working in. +/// +/// A company that fails does not abort the rest: its own typed Partial is +/// recorded and the sweep continues, because one unreadable book must not +/// hide the nine that read cleanly. +#[tauri::command] +pub async fn fetch_tally_outstandings_all_companies( + request: AllCompaniesOutstandingsRequest, + runtime: State<'_, TallyRuntime>, +) -> Result, TallyCommandError> { + if request.companies.is_empty() { + return Ok(Vec::new()); + } + let as_of = + TallyDate::parse(chrono::Local::now().format("%Y%m%d").to_string()).map_err(|_| { + tally_command_error( + "current_date_invalid", + "Bridge application", + "Bridge could not construct today's outstandings date.", + "after_change", + false, + "Check the workstation date and time, then repeat the read-only action.", + ) + })?; + + 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") + } else { + match runtime + .detect_base_currency( + request.config.clone(), + entry.company.clone(), + entry.expected_company_guid.clone(), + ) + .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, + ) + .await + .map_err(|_| "company_outstandings_read_failed"), + } + }; + entries.push(CompanyOutstandingsEntry { + company: entry.company, + result: company_sweep_result(result), + }); + } + Ok(entries) +} + #[tauri::command] pub fn cancel_tally_request( request_id: String, @@ -2355,14 +2364,14 @@ pub async fn select_document_folder() -> Result>(); + + assert_eq!( + outcomes.len(), + 4, + "one bad book must not truncate the sweep" + ); + assert!(matches!( + &outcomes[1], + OutstandingsLoadResult::Partial { reason_code, .. } + if reason_code == "company_currency_probe_failed" + )); + assert!(matches!( + &outcomes[2], + OutstandingsLoadResult::Partial { reason_code, .. } + if reason_code == "company_outstandings_read_failed" + )); + assert!(matches!( + &outcomes[3], + OutstandingsLoadResult::Partial { reason_code, .. } + if reason_code == "last_book_partial" + )); + } + + #[test] + fn export_names_are_portable_and_reserved_devices_are_neutralized() { + assert_eq!( + portable_export_file_name("outstandings-A:B?C.csv").unwrap(), + "outstandings-A-B-C.csv" + ); + assert_eq!(portable_export_file_name("CON.csv").unwrap(), "_CON.csv"); + assert!(portable_export_file_name("../outside.csv").is_err()); + assert!(portable_export_file_name("nested/report.csv").is_err()); + } + + #[test] + fn report_downloads_never_overwrite_an_existing_file() { + let directory = tempfile::tempdir().expect("temporary download directory"); + let first = write_unique_download(directory.path(), "report.csv", b"first").unwrap(); + let second = write_unique_download(directory.path(), "report.csv", b"second").unwrap(); + + assert_eq!(first.file_name().unwrap(), "report.csv"); + assert_eq!(second.file_name().unwrap(), "report-2.csv"); + assert_eq!(std::fs::read(first).unwrap(), b"first"); + assert_eq!(std::fs::read(second).unwrap(), b"second"); + } + #[test] fn snapshot_capability_canary_is_exactly_the_requested_first_calendar_day() { let canary = first_calendar_day_canary_window("20260228").unwrap(); @@ -2453,32 +2525,6 @@ mod tests { assert_eq!(discovery_limit.category, "Discovery listing"); } - #[cfg(feature = "fixture-canary-runtime-dispatch")] - #[test] - fn synthetic_canary_pre_dispatch_failure_is_truthful_and_redacted() { - let error = super::synthetic_canary_pre_dispatch_error(); - let json = serde_json::to_string(&error).expect("serialize canary error"); - assert_eq!(error.code, "synthetic_canary_pre_dispatch_failed"); - assert_eq!(error.retry, "after_change"); - assert!(error.local_state_changed); - assert!(!error.tally_state_may_have_changed); - assert!(!json.contains("127.0.0.1")); - assert!(!json.contains("xml")); - } - - #[cfg(feature = "fixture-canary-runtime-dispatch")] - #[test] - fn synthetic_canary_failure_is_terminal_and_does_not_expose_transport_detail() { - let error = super::synthetic_canary_outcome_unknown_error(); - let json = serde_json::to_string(&error).expect("serialize canary error"); - assert_eq!(error.code, "synthetic_canary_outcome_unknown"); - assert_eq!(error.retry, "never"); - assert!(error.local_state_changed); - assert!(error.tally_state_may_have_changed); - assert!(!json.contains("127.0.0.1")); - assert!(!json.contains("xml")); - } - #[test] fn explicit_tally_error_preserves_atomic_failure_truth() { let error = tally_command_error( @@ -2630,3 +2676,198 @@ mod tests { assert_eq!(populated.identity_evidence_state, "verified"); } } + +fn portable_export_file_name(file_name: &str) -> Result { + let trimmed = file_name.trim(); + if trimmed.is_empty() + || trimmed.len() > 200 + || trimmed.contains('/') + || trimmed.contains('\\') + || trimmed.contains("..") + || trimmed.starts_with('.') + { + return Err("Bridge could not build a safe file name for this export.".to_string()); + } + + let mut sanitized = trimmed + .chars() + .map(|character| { + if character.is_control() + || matches!(character, '<' | '>' | ':' | '"' | '|' | '?' | '*') + { + '-' + } else { + character + } + }) + .collect::(); + while sanitized.ends_with([' ', '.']) { + sanitized.pop(); + } + if sanitized.is_empty() { + return Err("Bridge could not build a safe file name for this export.".to_string()); + } + + let stem = sanitized + .split_once('.') + .map_or(sanitized.as_str(), |(stem, _)| stem); + let upper_stem = stem.to_ascii_uppercase(); + let reserved = matches!(upper_stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") + || upper_stem.strip_prefix("COM").is_some_and(|suffix| { + matches!(suffix, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9") + }) + || upper_stem.strip_prefix("LPT").is_some_and(|suffix| { + matches!(suffix, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9") + }); + if reserved { + sanitized.insert(0, '_'); + } + Ok(sanitized) +} + +fn write_unique_download( + directory: &std::path::Path, + file_name: &str, + contents: &[u8], +) -> std::io::Result { + use std::io::Write as _; + + let path = std::path::Path::new(file_name); + let stem = path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("report"); + let extension = path.extension().and_then(|value| value.to_str()); + for number in 1_u32..=10_000 { + let candidate_name = if number == 1 { + file_name.to_string() + } else if let Some(extension) = extension { + format!("{stem}-{number}.{extension}") + } else { + format!("{stem}-{number}") + }; + let candidate = directory.join(candidate_name); + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&candidate) + { + Ok(mut file) => { + if let Err(error) = file.write_all(contents).and_then(|()| file.sync_all()) { + drop(file); + let _ = std::fs::remove_file(&candidate); + return Err(error); + } + return Ok(candidate); + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), + } + } + Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "too many exports already use this file name", + )) +} + +/// Writes an exported report to the user's Downloads folder and returns the +/// full path. +/// +/// The browser route (`Blob` + an `` click) silently does nothing +/// inside the Tauri webview -- there is no download handler and the app +/// declares no plugin permissions -- so the button appeared to work and +/// produced no file. Writing from Rust needs no new dependency and no +/// capability grant, and returning the path lets the UI say where it went +/// instead of leaving the user to guess. +#[tauri::command] +pub async fn save_report_download( + app: tauri::AppHandle, + file_name: String, + contents: String, +) -> Result { + use tauri::Manager as _; + let file_name = portable_export_file_name(&file_name)?; + + // Tauri's own path resolver, so this needs no extra crate and no + // capability grant. + let downloads = app + .path() + .download_dir() + .or_else(|_| app.path().home_dir()) + .map_err(|_| "Bridge could not locate a folder to save into.".to_string())?; + let path = write_unique_download(&downloads, &file_name, contents.as_bytes()) + .map_err(|error| format!("Bridge could not write the export: {error}"))?; + Ok(path.to_string_lossy().into_owned()) +} + +/// Reveals an exported file in the OS file manager. +/// +/// Only ever called with a path this process just wrote, and the path is +/// re-checked as an existing file before being handed to the platform tool -- +/// so a caller cannot use this to launch an arbitrary target. +#[tauri::command] +pub async fn reveal_exported_file(path: String) -> Result<(), String> { + let target = std::path::PathBuf::from(&path); + if !target.is_file() { + return Err("Bridge could not find that export any more.".to_string()); + } + + #[cfg(target_os = "macos")] + let mut command = { + let mut command = std::process::Command::new("open"); + command.arg("-R").arg(&target); + command + }; + #[cfg(target_os = "windows")] + let mut command = { + let mut command = std::process::Command::new("explorer"); + // `explorer` wants the selector and path as one argument. + command.arg(format!("/select,{}", target.display())); + command + }; + #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] + let mut command = { + let parent = target.parent().unwrap_or(&target); + let mut command = std::process::Command::new("xdg-open"); + command.arg(parent); + command + }; + + command + .spawn() + .map(|_| ()) + .map_err(|error| format!("Bridge could not open the folder: {error}")) +} + +#[derive(Debug, Deserialize)] +pub struct BaseCurrencyRequest { + pub config: TallyConfig, + pub company: String, + pub expected_company_guid: String, +} + +/// Establishes a company's base currency from Tally. +#[tauri::command] +pub async fn detect_tally_base_currency( + request: BaseCurrencyRequest, + runtime: State<'_, TallyRuntime>, +) -> Result { + validate_company_name(&request.company).map_err(|message| { + tally_command_error( + "company_selection_invalid", + "Tally application", + message, + "after_change", + false, + "Select the intended GUID-bearing company and repeat the read-only action.", + ) + })?; + runtime + .detect_base_currency( + request.config, + request.company, + request.expected_company_guid, + ) + .await + .map_err(tally_runtime_command_error) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 41fe9e6d..a2773309 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,30 +4,67 @@ pub mod db; pub mod documents; pub mod dsc; pub mod gst; +// Crate-internal only: the previously separate `bridge-tally-observability` crate had exactly +// one consumer inside this crate, so it does not need to be reachable from outside `bridge_lib`. +mod observability; pub mod sync; pub mod tally; +use std::path::PathBuf; use tauri::Manager; +use tokio::sync::OnceCell; -fn initialize_tally_mirror(app: &tauri::App) -> anyhow::Result<()> { - let app_data_directory = app.path().app_data_dir()?; - std::fs::create_dir_all(&app_data_directory)?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&app_data_directory, std::fs::Permissions::from_mode(0o700))?; +/// Holds everything needed to open the encrypted Tally mirror without doing any of the actual +/// (keychain-touching, disk-touching) work until a caller genuinely needs the mirror. +/// +/// The mirror is expensive to open on macOS: resolving its SQLCipher key from the OS keychain +/// triggers an authorisation prompt. Most Bridge sessions (native outstandings reads, company +/// discovery/probe, base-currency detection, CSV export) never touch the mirror at all, so eagerly +/// opening it on every app start means paying that prompt for no reason. Initialising lazily, on +/// first use, means the prompt is shown only to sessions that exercise a feature that truly needs +/// the mirror (snapshot, mirror-explorer, write-fixture, proof export). +pub struct LazyTallyMirror { + app_data_directory: PathBuf, + repository: OnceCell, +} + +impl LazyTallyMirror { + pub fn new(app_data_directory: PathBuf) -> Self { + Self { + app_data_directory, + repository: OnceCell::new(), + } } - let database_path = app_data_directory.join("tally-mirror-v1.db"); - let _initialization_lock = db::encrypted::lock_mirror_initialization(&database_path)?; - let key_store = db::OsMirrorKeyStore::for_database(&database_path); - let resolved_key = db::resolve_mirror_key(&database_path, &key_store)?; - let pool = - tauri::async_runtime::block_on(db::connect_encrypted(&database_path, resolved_key.key))?; - let repository = db::tally_mirror::TallyMirrorRepository::new(pool); - tauri::async_runtime::block_on(repository.migrate())?; - app.manage(repository); - Ok(()) + /// Returns the initialised mirror repository, performing the (at most once) initialisation + /// work on first call. `OnceCell::get_or_try_init` guarantees that concurrent callers racing + /// this accessor observe exactly one initialisation attempt: the first caller runs it while + /// later callers await the same in-flight attempt rather than starting their own. + pub async fn get(&self) -> anyhow::Result<&db::tally_mirror::TallyMirrorRepository> { + self.repository + .get_or_try_init(|| async { + std::fs::create_dir_all(&self.app_data_directory)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions( + &self.app_data_directory, + std::fs::Permissions::from_mode(0o700), + )?; + } + + let database_path = self.app_data_directory.join("tally-mirror-v1.db"); + let _initialization_lock = + db::encrypted::lock_mirror_initialization(&database_path)?; + let key_store = db::OsMirrorKeyStore::for_database(&database_path); + let resolved_key = db::resolve_mirror_key(&database_path, &key_store)?; + let pool = db::connect_encrypted(&database_path, resolved_key.key).await?; + let repository = db::tally_mirror::TallyMirrorRepository::new(pool); + repository.migrate().await?; + Ok(repository) + }) + .await + } } pub fn run() { @@ -37,7 +74,8 @@ pub fn run() { .manage(tally::TallyRuntime::default()) .manage(sync::coordinator::SnapshotCoordinator::default()) .setup(|app| { - initialize_tally_mirror(app)?; + let app_data_directory = app.path().app_data_dir()?; + app.manage(LazyTallyMirror::new(app_data_directory)); Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -46,10 +84,12 @@ pub fn run() { commands::bootstrap_direct_tally_company, commands::save_tally_setup, commands::enroll_tally_write_fixture, - #[cfg(feature = "fixture-canary-runtime-dispatch")] - commands::dispatch_tally_synthetic_canary, commands::tally_write_fixture_enrollment_status, commands::revoke_tally_write_fixture_enrollment, + commands::save_report_download, + commands::reveal_exported_file, + commands::fetch_tally_outstandings_all_companies, + commands::detect_tally_base_currency, commands::tally_persisted_company_profiles, commands::tally_mirror_explorer_page, commands::tally_sync_evidence, @@ -81,6 +121,86 @@ pub fn run() { .expect("failed to run Bridge"); } +#[cfg(test)] +mod lazy_tally_mirror_concurrency_tests { + //! `LazyTallyMirror::get()` is built directly on `tokio::sync::OnceCell::get_or_try_init`, + //! which is exactly what supplies the "one initialisation, not two" guarantee under + //! concurrent callers required by this change: the first caller to reach the cell runs the + //! initialisation future while every other concurrent caller awaits that same in-flight + //! attempt instead of starting its own. + //! + //! This test proves that guarantee by racing many tasks against a shared `OnceCell` using + //! the identical `get_or_try_init` call `LazyTallyMirror::get()` makes, and asserting the + //! initialisation closure ran exactly once. + //! + //! It deliberately does NOT call `LazyTallyMirror::get()` end-to-end. A real call resolves + //! the SQLCipher key through the OS keychain (`db::OsMirrorKeyStore` -> `keyring::Entry`), + //! which would create or query a real macOS keychain item from an automated `cargo test` + //! process outside any app bundle/code signature — the very kind of environment-dependent, + //! potentially interactive behaviour this change exists to avoid triggering on every launch. + //! The `keyring` dependency is built without a mockable backend (feature `v1` only), so there + //! is no offline/deterministic way to substitute a fake credential store for that path. That + //! makes the keychain-touching portion of initialisation untestable offline; this test proves + //! the concurrency mechanism it relies on instead of asserting something it cannot honestly + //! demonstrate. + + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + use std::time::Duration; + use tokio::sync::{Barrier, OnceCell}; + + #[tokio::test(flavor = "multi_thread", worker_threads = 8)] + async fn concurrent_callers_observe_exactly_one_initialization() { + const CONCURRENT_CALLERS: usize = 16; + + let cell: Arc> = Arc::new(OnceCell::new()); + let init_calls = Arc::new(AtomicUsize::new(0)); + // Every task waits at the barrier so they all call `get_or_try_init` at (as close to) + // the same instant as possible, maximising the chance of a real race rather than an + // accidentally-serialised sequence of calls. + let barrier = Arc::new(Barrier::new(CONCURRENT_CALLERS)); + + let tasks: Vec<_> = (0..CONCURRENT_CALLERS) + .map(|_| { + let cell = Arc::clone(&cell); + let init_calls = Arc::clone(&init_calls); + let barrier = Arc::clone(&barrier); + tokio::spawn(async move { + barrier.wait().await; + cell.get_or_try_init(|| async { + init_calls.fetch_add(1, Ordering::SeqCst); + // Hold the in-flight initialisation open for long enough that, absent + // `OnceCell`'s mutual exclusion, other callers would very likely start + // their own concurrent initialisation attempt. + tokio::time::sleep(Duration::from_millis(25)).await; + Ok::(42) + }) + .await + .copied() + }) + }) + .collect(); + + let mut results = Vec::with_capacity(CONCURRENT_CALLERS); + for task in tasks { + results.push(task.await.expect("initialization task must not panic")); + } + + assert_eq!( + init_calls.load(Ordering::SeqCst), + 1, + "initialization must run at most once under concurrent access" + ); + for result in results { + assert_eq!( + result.expect("initialization must succeed"), + 42, + "every concurrent caller must observe the single initialization's value" + ); + } + } +} + #[cfg(test)] mod security_config_tests { #[test] diff --git a/src-tauri/src/observability.rs b/src-tauri/src/observability.rs new file mode 100644 index 00000000..ab3afcb4 --- /dev/null +++ b/src-tauri/src/observability.rs @@ -0,0 +1,833 @@ +//! Fixed-cardinality, local-only Tally transport observations. +//! +//! Folded from the former standalone `bridge-tally-observability` crate: it had exactly one +//! consumer (`bridge-tally-runtime`, itself folded into `crate::tally::runtime_control`), so the +//! crate boundary earned nothing and only hid this module's true dead code from the `dead_code` +//! lint (a `pub` item in a library crate is never flagged, since an external consumer might exist). +//! This module deliberately still has no runtime, HTTP, database, logging, tracing, persistence, +//! system-metrics, or exporter dependency. Its preview is a privacy-reduced operational aid, not +//! Proof of Sync or performance support. + +use std::{fmt, sync::Mutex, time::Duration}; + +use serde::Serialize; +use sha2::{Digest, Sha256}; + +pub(crate) const PREVIEW_SCHEMA: &str = "bridge.tally.telemetry-preview/2"; +pub(crate) const MAX_SERIALIZED_PREVIEW_BYTES: usize = 64 * 1024; +pub(crate) const LATENCY_UPPER_BOUNDS_MICROS: [u64; 8] = [ + 1_000, 5_000, 25_000, 100_000, 500_000, 2_000_000, 10_000_000, 30_000_000, +]; +pub(crate) const RESPONSE_BYTE_UPPER_BOUNDS: [u64; 6] = [ + 0, + 1_024, + 64 * 1_024, + 1_024 * 1_024, + 8 * 1_024 * 1_024, + 32 * 1_024 * 1_024, +]; + +const LATENCY_BUCKETS: usize = LATENCY_UPPER_BOUNDS_MICROS.len() + 1; +const BYTE_BUCKETS: usize = RESPONSE_BYTE_UPPER_BOUNDS.len() + 1; +const QUEUE_OUTCOMES: usize = QueueOutcome::ALL.len(); +const RESPONSE_OUTCOMES: usize = ResponseOutcome::ALL.len(); +const CIRCUIT_REJECT_REASONS: usize = CircuitRejectReason::ALL.len(); +const REQUEST_CLASSES: usize = RequestClass::ALL.len(); +const QUEUE_CELLS: usize = REQUEST_CLASSES * QUEUE_OUTCOMES * LATENCY_BUCKETS; +const RESPONSE_LATENCY_CELLS: usize = REQUEST_CLASSES * RESPONSE_OUTCOMES * LATENCY_BUCKETS; +const RESPONSE_BYTE_CELLS: usize = REQUEST_CLASSES * RESPONSE_OUTCOMES * BYTE_BUCKETS; +const RESPONSE_BYTE_UNAVAILABLE_CELLS: usize = REQUEST_CLASSES * RESPONSE_OUTCOMES; +const CIRCUIT_REJECTION_CELLS: usize = REQUEST_CLASSES * CIRCUIT_REJECT_REASONS; +pub(crate) const FIXED_HISTOGRAM_CELL_COUNT: usize = QUEUE_CELLS + + RESPONSE_LATENCY_CELLS + + RESPONSE_BYTE_CELLS + + RESPONSE_BYTE_UNAVAILABLE_CELLS + + CIRCUIT_REJECTION_CELLS; +const EXPORT_HASH_DOMAIN: &[u8] = b"bridge.tally.telemetry-preview-payload/2\0"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum RequestClass { + Status, + Capability, + CompanyList, + MasterExport, + VoucherExport, + ReportExport, + Import, + OtherRead, +} + +impl RequestClass { + pub(crate) const ALL: [Self; 8] = [ + Self::Status, + Self::Capability, + Self::CompanyList, + Self::MasterExport, + Self::VoucherExport, + Self::ReportExport, + Self::Import, + Self::OtherRead, + ]; + + const fn index(self) -> usize { + self as usize + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum QueueOutcome { + Acquired, + Deadline, + Cancelled, +} + +impl QueueOutcome { + pub(crate) const ALL: [Self; 3] = [Self::Acquired, Self::Deadline, Self::Cancelled]; + + const fn index(self) -> usize { + self as usize + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ResponseOutcome { + Success, + Cancelled, + Timeout, + Transport, + HttpStatus, + SizeLimit, + Decode, + Application, + Parse, + Validation, +} + +impl ResponseOutcome { + pub(crate) const ALL: [Self; 10] = [ + Self::Success, + Self::Cancelled, + Self::Timeout, + Self::Transport, + Self::HttpStatus, + Self::SizeLimit, + Self::Decode, + Self::Application, + Self::Parse, + Self::Validation, + ]; + + const fn index(self) -> usize { + self as usize + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum CircuitRejectReason { + Cooldown, + HalfOpenProbeInFlight, +} + +impl CircuitRejectReason { + pub(crate) const ALL: [Self; 2] = [Self::Cooldown, Self::HalfOpenProbeInFlight]; + + const fn index(self) -> usize { + self as usize + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum BodyBytesObservation { + Observed(u64), + Unavailable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AttemptObservation { + CircuitRejected { + class: RequestClass, + reason: CircuitRejectReason, + }, + QueueDeadline { + class: RequestClass, + queue_wait: Duration, + }, + QueueCancelled { + class: RequestClass, + queue_wait: Duration, + }, + Response { + class: RequestClass, + queue_wait: Duration, + outcome: ResponseOutcome, + /// Custom Bridge pipeline duration from send start through bounded + /// body read and decode completion. This is not an OTel HTTP duration. + response_pipeline_elapsed: Duration, + /// Bytes consumed before the terminal outcome, including partial + /// failed bodies. This is not an OTel HTTP response-body-size metric. + observed_body_bytes: BodyBytesObservation, + }, +} + +/// A sink accepts one caller-supplied terminal attempt and no dynamic labels +/// or text. It aggregates observations; it does not authenticate provenance or +/// detect duplicate calls. +pub(crate) trait ObservationSink { + fn record_attempt(&self, observation: AttemptObservation); +} + +#[derive(Clone)] +struct AggregateState { + queue_latency: [u64; QUEUE_CELLS], + response_latency: [u64; RESPONSE_LATENCY_CELLS], + response_bytes: [u64; RESPONSE_BYTE_CELLS], + response_bytes_unavailable: [u64; RESPONSE_BYTE_UNAVAILABLE_CELLS], + circuit_rejections: [u64; CIRCUIT_REJECTION_CELLS], + saturated_cell_increments: u64, +} + +impl Default for AggregateState { + fn default() -> Self { + Self { + queue_latency: [0; QUEUE_CELLS], + response_latency: [0; RESPONSE_LATENCY_CELLS], + response_bytes: [0; RESPONSE_BYTE_CELLS], + response_bytes_unavailable: [0; RESPONSE_BYTE_UNAVAILABLE_CELLS], + circuit_rejections: [0; CIRCUIT_REJECTION_CELLS], + saturated_cell_increments: 0, + } + } +} + +/// Fixed-memory coherent aggregation. Poison recovery retains observations; +/// measurement failure never changes a Tally operation result. +#[derive(Default)] +pub(crate) struct TelemetryCollector { + state: Mutex, +} + +impl fmt::Debug for TelemetryCollector { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("TelemetryCollector") + .field("fixed_histogram_cell_count", &FIXED_HISTOGRAM_CELL_COUNT) + .finish_non_exhaustive() + } +} + +impl TelemetryCollector { + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) fn preview_v2(&self) -> TallyTelemetryPreviewV2 { + let state = self.snapshot(); + let rows = RequestClass::ALL.map(|class| build_row(&state, class)); + TallyTelemetryPreviewV2 { + schema: PREVIEW_SCHEMA, + schema_version: 2, + privacy_profile: "fixed_dimensions_bucketed_values_v1", + collection_scope: "unstamped_collector_instance_lifetime", + snapshot_consistency: "coherent_mutex_snapshot", + observation_provenance: "caller_supplied_not_authenticated", + collection_completeness: "not_established", + lifecycle_consistency: "one_terminal_observation_per_attempt_duplicates_not_detected", + standards_mapping: "custom_lossy_summary_not_an_opentelemetry_histogram", + integrity_claim: "checksum_only", + authenticity_claim: "none", + collector_has_network_exporter: false, + establishes_performance_support: false, + rows_are_taxonomy_not_capability: true, + fixed_histogram_cell_count: FIXED_HISTOGRAM_CELL_COUNT as u16, + latency_upper_bounds_micros: LATENCY_UPPER_BOUNDS_MICROS, + response_byte_upper_bounds: RESPONSE_BYTE_UPPER_BOUNDS, + saturated_cell_increments: count_bucket(state.saturated_cell_increments), + rows, + } + } + + pub(crate) fn privacy_reduced_export_v2( + &self, + ) -> Result { + let preview = self.preview_v2(); + let json = + serde_json::to_string(&preview).map_err(|_| TelemetryExportError::Serialization)?; + if json.len() > MAX_SERIALIZED_PREVIEW_BYTES { + return Err(TelemetryExportError::PreviewTooLarge); + } + let payload_sha256 = hash_payload(json.as_bytes()); + Ok(PrivacyReducedTelemetryExport { + json, + payload_sha256, + }) + } + + fn snapshot(&self) -> AggregateState { + self.state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + } + + fn increment(state: &mut AggregateState, cell: &mut u64) { + if *cell == u64::MAX { + state.saturated_cell_increments = state.saturated_cell_increments.saturating_add(1); + } else { + *cell += 1; + } + } +} + +impl ObservationSink for TelemetryCollector { + fn record_attempt(&self, observation: AttemptObservation) { + let mut state = self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + match observation { + AttemptObservation::CircuitRejected { class, reason } => { + let index = class.index() * CIRCUIT_REJECT_REASONS + reason.index(); + let mut cell = state.circuit_rejections[index]; + Self::increment(&mut state, &mut cell); + state.circuit_rejections[index] = cell; + } + AttemptObservation::QueueDeadline { class, queue_wait } => { + increment_queue(&mut state, class, QueueOutcome::Deadline, queue_wait); + } + AttemptObservation::QueueCancelled { class, queue_wait } => { + increment_queue(&mut state, class, QueueOutcome::Cancelled, queue_wait); + } + AttemptObservation::Response { + class, + queue_wait, + outcome, + response_pipeline_elapsed, + observed_body_bytes, + } => { + increment_queue(&mut state, class, QueueOutcome::Acquired, queue_wait); + let latency = latency_bucket(response_pipeline_elapsed); + let series = class.index() * RESPONSE_OUTCOMES + outcome.index(); + let latency_index = series * LATENCY_BUCKETS + latency; + let mut latency_cell = state.response_latency[latency_index]; + Self::increment(&mut state, &mut latency_cell); + state.response_latency[latency_index] = latency_cell; + match observed_body_bytes { + BodyBytesObservation::Observed(bytes) => { + let byte_index = series * BYTE_BUCKETS + byte_bucket(bytes); + let mut byte_cell = state.response_bytes[byte_index]; + Self::increment(&mut state, &mut byte_cell); + state.response_bytes[byte_index] = byte_cell; + } + BodyBytesObservation::Unavailable => { + let mut cell = state.response_bytes_unavailable[series]; + Self::increment(&mut state, &mut cell); + state.response_bytes_unavailable[series] = cell; + } + } + } + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum CountBucket { + Zero, + One, + TwoToFive, + SixToTwenty, + TwentyOneToHundred, + HundredOneToThousand, + OverThousand, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct TallyTelemetryPreviewV2 { + schema: &'static str, + schema_version: u16, + privacy_profile: &'static str, + collection_scope: &'static str, + snapshot_consistency: &'static str, + observation_provenance: &'static str, + collection_completeness: &'static str, + lifecycle_consistency: &'static str, + standards_mapping: &'static str, + integrity_claim: &'static str, + authenticity_claim: &'static str, + collector_has_network_exporter: bool, + establishes_performance_support: bool, + rows_are_taxonomy_not_capability: bool, + fixed_histogram_cell_count: u16, + latency_upper_bounds_micros: [u64; 8], + response_byte_upper_bounds: [u64; 6], + saturated_cell_increments: CountBucket, + rows: [OperationTelemetryRow; 8], +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct OperationTelemetryRow { + request_class: RequestClass, + circuit_rejections: [CircuitTelemetryRow; 2], + queue: [QueueTelemetryRow; 3], + response: [ResponseTelemetryRow; 10], +} + +#[derive(Debug, Clone, Serialize)] +struct QueueTelemetryRow { + outcome: QueueOutcome, + latency_buckets: [CountBucket; LATENCY_BUCKETS], +} + +#[derive(Debug, Clone, Serialize)] +struct ResponseTelemetryRow { + outcome: ResponseOutcome, + latency_buckets: [CountBucket; LATENCY_BUCKETS], + bytes_received_buckets: [CountBucket; BYTE_BUCKETS], + bytes_measurement_unavailable: CountBucket, +} + +#[derive(Debug, Clone, Serialize)] +struct CircuitTelemetryRow { + reason: CircuitRejectReason, + count: CountBucket, +} + +#[derive(Clone)] +pub(crate) struct PrivacyReducedTelemetryExport { + json: String, + payload_sha256: String, +} + +impl fmt::Debug for PrivacyReducedTelemetryExport { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PrivacyReducedTelemetryExport") + .field("json_bytes", &self.json.len()) + .field("payload_sha256", &self.payload_sha256) + .finish() + } +} + +impl PrivacyReducedTelemetryExport { + pub(crate) fn json(&self) -> &str { + &self.json + } + + pub(crate) fn payload_sha256(&self) -> &str { + &self.payload_sha256 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TelemetryExportError { + Serialization, + PreviewTooLarge, +} + +impl TelemetryExportError { + pub(crate) const fn safe_code(self) -> &'static str { + match self { + Self::Serialization => "tally_telemetry_serialization_failed", + Self::PreviewTooLarge => "tally_telemetry_preview_too_large", + } + } +} + +impl fmt::Display for TelemetryExportError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.safe_code()) + } +} + +impl std::error::Error for TelemetryExportError {} + +fn build_row(state: &AggregateState, class: RequestClass) -> OperationTelemetryRow { + let circuit_rejections = CircuitRejectReason::ALL.map(|reason| { + let index = class.index() * CIRCUIT_REJECT_REASONS + reason.index(); + CircuitTelemetryRow { + reason, + count: count_bucket(state.circuit_rejections[index]), + } + }); + let queue = QueueOutcome::ALL.map(|outcome| { + let base = (class.index() * QUEUE_OUTCOMES + outcome.index()) * LATENCY_BUCKETS; + QueueTelemetryRow { + outcome, + latency_buckets: std::array::from_fn(|offset| { + count_bucket(state.queue_latency[base + offset]) + }), + } + }); + let response = ResponseOutcome::ALL.map(|outcome| { + let series = class.index() * RESPONSE_OUTCOMES + outcome.index(); + let latency_base = series * LATENCY_BUCKETS; + let byte_base = series * BYTE_BUCKETS; + ResponseTelemetryRow { + outcome, + latency_buckets: std::array::from_fn(|offset| { + count_bucket(state.response_latency[latency_base + offset]) + }), + bytes_received_buckets: std::array::from_fn(|offset| { + count_bucket(state.response_bytes[byte_base + offset]) + }), + bytes_measurement_unavailable: count_bucket(state.response_bytes_unavailable[series]), + } + }); + OperationTelemetryRow { + request_class: class, + circuit_rejections, + queue, + response, + } +} + +fn increment_queue( + state: &mut AggregateState, + class: RequestClass, + outcome: QueueOutcome, + elapsed: Duration, +) { + let bucket = latency_bucket(elapsed); + let index = (class.index() * QUEUE_OUTCOMES + outcome.index()) * LATENCY_BUCKETS + bucket; + let mut cell = state.queue_latency[index]; + TelemetryCollector::increment(state, &mut cell); + state.queue_latency[index] = cell; +} + +fn latency_bucket(duration: Duration) -> usize { + let micros = u64::try_from(duration.as_micros()).unwrap_or(u64::MAX); + LATENCY_UPPER_BOUNDS_MICROS + .iter() + .position(|upper| micros <= *upper) + .unwrap_or(LATENCY_BUCKETS - 1) +} + +fn byte_bucket(bytes: u64) -> usize { + RESPONSE_BYTE_UPPER_BOUNDS + .iter() + .position(|upper| bytes <= *upper) + .unwrap_or(BYTE_BUCKETS - 1) +} + +fn count_bucket(value: u64) -> CountBucket { + match value { + 0 => CountBucket::Zero, + 1 => CountBucket::One, + 2..=5 => CountBucket::TwoToFive, + 6..=20 => CountBucket::SixToTwenty, + 21..=100 => CountBucket::TwentyOneToHundred, + 101..=1_000 => CountBucket::HundredOneToThousand, + _ => CountBucket::OverThousand, + } +} + +fn hash_payload(payload: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(EXPORT_HASH_DOMAIN); + hasher.update(payload); + let digest = hasher.finalize(); + let mut encoded = String::with_capacity(digest.len() * 2); + for byte in digest { + use std::fmt::Write as _; + let _ = write!(encoded, "{byte:02x}"); + } + encoded +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{sync::Arc, thread}; + + #[test] + fn histogram_boundaries_are_inclusive_and_queue_semantics_are_explicit() { + let collector = TelemetryCollector::new(); + for micros in [1_000, 1_001, 30_000_000, 30_000_001] { + collector.record_attempt(AttemptObservation::Response { + class: RequestClass::Status, + queue_wait: Duration::from_micros(micros), + outcome: ResponseOutcome::Success, + response_pipeline_elapsed: Duration::ZERO, + observed_body_bytes: BodyBytesObservation::Observed(0), + }); + } + for bytes in [ + 0, + 1, + 1_024, + 1_025, + 32 * 1_024 * 1_024, + 32 * 1_024 * 1_024 + 1, + ] { + collector.record_attempt(AttemptObservation::Response { + class: RequestClass::VoucherExport, + queue_wait: Duration::ZERO, + outcome: ResponseOutcome::SizeLimit, + response_pipeline_elapsed: Duration::from_millis(10), + observed_body_bytes: BodyBytesObservation::Observed(bytes), + }); + } + let state = collector.snapshot(); + let queue_base = (RequestClass::Status.index() * QUEUE_OUTCOMES + + QueueOutcome::Acquired.index()) + * LATENCY_BUCKETS; + assert_eq!( + &state.queue_latency[queue_base..queue_base + LATENCY_BUCKETS], + &[1, 1, 0, 0, 0, 0, 0, 1, 1] + ); + let response_series = RequestClass::VoucherExport.index() * RESPONSE_OUTCOMES + + ResponseOutcome::SizeLimit.index(); + let latency_base = response_series * LATENCY_BUCKETS; + assert_eq!(state.response_latency[latency_base + 2], 6); + assert_eq!( + &state.response_bytes + [response_series * BYTE_BUCKETS..response_series * BYTE_BUCKETS + BYTE_BUCKETS], + &[1, 2, 1, 0, 0, 1, 1] + ); + let json = collector.privacy_reduced_export_v2().unwrap().json; + assert!(json.contains("\"unstamped_collector_instance_lifetime\"")); + assert!(json.contains("\"coherent_mutex_snapshot\"")); + assert!(!json.contains("post_request_spacing")); + } + + #[test] + fn cardinality_and_export_size_remain_fixed_after_many_observations() { + let collector = TelemetryCollector::new(); + for index in 0..100_000_u64 { + let class = RequestClass::ALL[index as usize % RequestClass::ALL.len()]; + collector.record_attempt(AttemptObservation::Response { + class, + queue_wait: Duration::from_micros(index), + outcome: ResponseOutcome::Success, + response_pipeline_elapsed: Duration::from_micros(index), + observed_body_bytes: BodyBytesObservation::Observed(index), + }); + } + let export = collector + .privacy_reduced_export_v2() + .expect("bounded export"); + assert!(export.json().len() <= MAX_SERIALIZED_PREVIEW_BYTES); + assert_eq!(FIXED_HISTOGRAM_CELL_COUNT, 1_592); + } + + #[test] + fn preview_has_no_input_surface_for_sensitive_or_high_cardinality_values() { + let collector = TelemetryCollector::new(); + collector.record_attempt(AttemptObservation::Response { + class: RequestClass::CompanyList, + queue_wait: Duration::from_millis(2), + outcome: ResponseOutcome::Decode, + response_pipeline_elapsed: Duration::from_millis(7), + observed_body_bytes: BodyBytesObservation::Observed(777), + }); + let export = collector.privacy_reduced_export_v2().unwrap(); + let debug = format!("{collector:?} {export:?}"); + for forbidden in [ + "BRIDGE SECRET COMPANY", + "27ABCDE1234F1Z5", + "ABCDE1234F", + "", + "127.0.0.1:9000", + "developer-home-path-sentinel", + "request-secret-id", + ] { + assert!(!export.json().contains(forbidden)); + assert!(!debug.contains(forbidden)); + } + assert!(!export.json().contains("company_guid")); + assert!(!export.json().contains("endpoint")); + assert!(!export.json().contains("timestamp")); + assert!(!export.json().contains("payload")); + } + + #[test] + fn preview_is_coherent_under_concurrent_recording_and_repeatable_when_idle() { + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Barrier, + }; + + let collector = Arc::new(TelemetryCollector::new()); + let start = Arc::new(Barrier::new(9)); + let active = Arc::new(AtomicUsize::new(8)); + let threads = (0..8) + .map(|_| { + let collector = Arc::clone(&collector); + let start = Arc::clone(&start); + let active = Arc::clone(&active); + thread::spawn(move || { + start.wait(); + for index in 0..20_000 { + collector.record_attempt(AttemptObservation::Response { + class: RequestClass::MasterExport, + queue_wait: Duration::from_millis(1), + outcome: ResponseOutcome::Success, + response_pipeline_elapsed: Duration::from_millis(5), + observed_body_bytes: BodyBytesObservation::Observed(1_024), + }); + if index % 100 == 0 { + thread::yield_now(); + } + } + active.fetch_sub(1, Ordering::Release); + }) + }) + .collect::>(); + start.wait(); + let response_series = RequestClass::MasterExport.index() * RESPONSE_OUTCOMES + + ResponseOutcome::Success.index(); + let mut concurrent_snapshots = 0_u64; + while active.load(Ordering::Acquire) > 0 { + let state = collector.snapshot(); + let latency_total = state.response_latency + [response_series * LATENCY_BUCKETS..(response_series + 1) * LATENCY_BUCKETS] + .iter() + .sum::(); + let byte_total = state.response_bytes + [response_series * BYTE_BUCKETS..(response_series + 1) * BYTE_BUCKETS] + .iter() + .sum::(); + assert_eq!(latency_total, byte_total); + concurrent_snapshots += 1; + thread::yield_now(); + } + for thread in threads { + thread.join().expect("observation thread"); + } + assert!(concurrent_snapshots > 0); + let first = collector.privacy_reduced_export_v2().unwrap(); + let second = collector.privacy_reduced_export_v2().unwrap(); + assert_eq!(first.json(), second.json()); + assert_eq!(first.payload_sha256(), second.payload_sha256()); + } + + #[test] + fn saturation_never_wraps_and_is_disclosed() { + let collector = TelemetryCollector::new(); + { + let mut state = collector.state.lock().unwrap(); + state.queue_latency[0] = u64::MAX; + } + collector.record_attempt(AttemptObservation::Response { + class: RequestClass::Status, + queue_wait: Duration::ZERO, + outcome: ResponseOutcome::Success, + response_pipeline_elapsed: Duration::ZERO, + observed_body_bytes: BodyBytesObservation::Observed(0), + }); + let state = collector.snapshot(); + assert_eq!(state.queue_latency[0], u64::MAX); + assert_eq!(state.saturated_cell_increments, 1); + assert!(collector + .privacy_reduced_export_v2() + .unwrap() + .json() + .contains("\"saturated_cell_increments\":\"one\"")); + } + + #[test] + fn longest_serialized_count_bucket_still_fits_the_reviewed_preview_ceiling() { + let collector = TelemetryCollector::new(); + { + let mut state = collector.state.lock().unwrap(); + state.queue_latency.fill(101); + state.response_latency.fill(101); + state.response_bytes.fill(101); + state.response_bytes_unavailable.fill(101); + state.circuit_rejections.fill(101); + state.saturated_cell_increments = 101; + } + let export = collector + .privacy_reduced_export_v2() + .expect("worst textual bucket export"); + assert!(export.json().len() <= MAX_SERIALIZED_PREVIEW_BYTES); + } + + #[test] + fn schema_v2_taxonomy_bounds_and_zero_preview_bytes_are_golden() { + assert_eq!(PREVIEW_SCHEMA, "bridge.tally.telemetry-preview/2"); + assert_eq!( + LATENCY_UPPER_BOUNDS_MICROS, + [1_000, 5_000, 25_000, 100_000, 500_000, 2_000_000, 10_000_000, 30_000_000,] + ); + assert_eq!( + RESPONSE_BYTE_UPPER_BOUNDS, + [0, 1_024, 65_536, 1_048_576, 8_388_608, 33_554_432] + ); + assert_eq!(QueueOutcome::ALL.len(), 3); + assert_eq!(ResponseOutcome::ALL.len(), 10); + assert_eq!(CircuitRejectReason::ALL.len(), 2); + assert_eq!(FIXED_HISTOGRAM_CELL_COUNT, 1_592); + let export = TelemetryCollector::new() + .privacy_reduced_export_v2() + .expect("golden zero preview"); + assert_eq!( + export.payload_sha256(), + "013e4b52577f9b89c22a31c203ec8940a783b3ada13e3f9d5e508b79a92ed37a" + ); + } + + #[test] + fn terminal_attempt_shape_keeps_queue_failures_out_of_response_histograms() { + let collector = TelemetryCollector::new(); + collector.record_attempt(AttemptObservation::QueueDeadline { + class: RequestClass::Capability, + queue_wait: Duration::from_secs(30), + }); + collector.record_attempt(AttemptObservation::QueueCancelled { + class: RequestClass::Capability, + queue_wait: Duration::from_millis(5), + }); + collector.record_attempt(AttemptObservation::Response { + class: RequestClass::Capability, + queue_wait: Duration::from_millis(1), + outcome: ResponseOutcome::Timeout, + response_pipeline_elapsed: Duration::from_secs(10), + observed_body_bytes: BodyBytesObservation::Unavailable, + }); + + let state = collector.snapshot(); + let queue_base = RequestClass::Capability.index() * QUEUE_OUTCOMES * LATENCY_BUCKETS; + let queue_total = state.queue_latency + [queue_base..queue_base + QUEUE_OUTCOMES * LATENCY_BUCKETS] + .iter() + .sum::(); + let response_base = RequestClass::Capability.index() * RESPONSE_OUTCOMES * LATENCY_BUCKETS; + let response_total = state.response_latency + [response_base..response_base + RESPONSE_OUTCOMES * LATENCY_BUCKETS] + .iter() + .sum::(); + assert_eq!(queue_total, 3); + assert_eq!(response_total, 1); + } + + #[test] + fn circuit_rejections_and_unavailable_byte_measurement_are_explicit() { + let collector = TelemetryCollector::new(); + collector.record_attempt(AttemptObservation::CircuitRejected { + class: RequestClass::CompanyList, + reason: CircuitRejectReason::Cooldown, + }); + collector.record_attempt(AttemptObservation::Response { + class: RequestClass::CompanyList, + queue_wait: Duration::ZERO, + outcome: ResponseOutcome::Application, + response_pipeline_elapsed: Duration::from_millis(2), + observed_body_bytes: BodyBytesObservation::Unavailable, + }); + let state = collector.snapshot(); + let circuit = RequestClass::CompanyList.index() * CIRCUIT_REJECT_REASONS + + CircuitRejectReason::Cooldown.index(); + let response = RequestClass::CompanyList.index() * RESPONSE_OUTCOMES + + ResponseOutcome::Application.index(); + assert_eq!(state.circuit_rejections[circuit], 1); + assert_eq!(state.response_bytes_unavailable[response], 1); + let json = collector.privacy_reduced_export_v2().unwrap().json; + assert!(json.contains("\"cooldown\"")); + assert!(json.contains("\"bytes_measurement_unavailable\":\"one\"")); + } +} diff --git a/src-tauri/src/tally/canonical_window.rs b/src-tauri/src/tally/canonical_window.rs new file mode 100644 index 00000000..084f84aa --- /dev/null +++ b/src-tauri/src/tally/canonical_window.rs @@ -0,0 +1,816 @@ +//! Deterministic conversion from strict Tally export records to Bridge canonical packs. +//! +//! Folded from the former standalone `bridge-tally-canonical` crate: it had exactly one consumer +//! inside this crate (`tally::connector` and `tally::connection`), so the crate boundary earned +//! nothing except hiding this module's true dead code from the `dead_code` lint. This module +//! deliberately still has no HTTP, database, OpenSSL, or Tauri dependency, so the complete +//! identity and reference-binding boundary remains executable on every supported development host. + +use bridge_tally_core::{ + source_count_scope_fingerprint, CanonicalPackWindow, CanonicalText, CoreAccountingBatch, + ExactDecimal, GroupRecord, LedgerEntryPolarity, LedgerEntryRecord, LedgerRecord, + ObservedSourceIdentities, PackBatch, RawSourceSha256, RequestContext, SourceAlterId, + SourceCountScope, SourceCountScopeDescriptor, SourceIdentityKind, SourceRecordEvidence, + SourceRecordId, SourceReportedCountEvidence, TallyDate, TallyError, VoucherRecord, + VoucherTypeRecord, +}; +use bridge_tally_protocol::{ + ParsedExport, ParsedSourceIdentityKind, ParsedSourceRecord, TallyLedger, TallyNamedMaster, + TallyVoucher, +}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; + +/// Converts the four exact core-accounting exports into one reference-complete canonical window. +/// Any missing/ambiguous identity, mutable-name collision, or unresolved relationship fails closed. +pub(super) fn build_core_window( + context: &RequestContext, + groups: ParsedExport>, + ledgers: ParsedExport>, + voucher_types: ParsedExport>, + vouchers: ParsedExport>, +) -> Result { + let requested_from = TallyDate::parse(context.window.from_yyyymmdd.clone()) + .map_err(|_| invalid_data("requested_window_invalid"))?; + let requested_to = TallyDate::parse(context.window.to_yyyymmdd.clone()) + .map_err(|_| invalid_data("requested_window_invalid"))?; + if requested_from.as_str() > requested_to.as_str() { + return Err(invalid_data("requested_window_invalid")); + } + let group_count = required_source_count(&groups, "group_source_count_missing")?; + let ledger_count = required_source_count(&ledgers, "ledger_source_count_missing")?; + let voucher_type_count = + required_source_count(&voucher_types, "voucher_type_source_count_missing")?; + let voucher_count = required_source_count(&vouchers, "voucher_source_count_missing")?; + validate_selected_voucher_window( + context.window.from_yyyymmdd.as_str(), + context.window.to_yyyymmdd.as_str(), + &vouchers, + )?; + let mut batch = CoreAccountingBatch::default(); + let mut record_evidence = Vec::new(); + + let group_ids_by_name = unique_source_ids_by_name( + &groups.records, + |record| &record.name, + "group_identity_missing", + "group_name_missing", + "group_name_duplicate", + )?; + for source in groups.records { + let source_id = required_source_id(&source, "group_identity_missing")?; + let evidence = source_evidence("group", source_id.clone(), &source)?; + let name = required_text(&source.record.name, "group_name_missing")?; + let parent_source_id = resolve_group_parent( + source.record.parent.as_deref(), + &group_ids_by_name, + "group_parent_missing", + )?; + batch.groups.push(GroupRecord { + source_id, + name, + parent_source_id, + }); + record_evidence.push(evidence); + } + + let ledger_ids_by_name = unique_source_ids_by_name( + &ledgers.records, + |record| &record.name, + "ledger_identity_missing", + "ledger_name_missing", + "ledger_name_duplicate", + )?; + for source in ledgers.records { + let source_id = required_source_id(&source, "ledger_identity_missing")?; + let evidence = source_evidence("ledger", source_id.clone(), &source)?; + let name = required_text(&source.record.name, "ledger_name_missing")?; + let parent_source_id = resolve_optional_reference( + source.record.parent.as_deref(), + &group_ids_by_name, + "ledger_parent_group_missing", + )?; + let opening_balance = source + .record + .opening_balance + .as_deref() + .filter(|value| !value.trim().is_empty()) + .map(|value| ExactDecimal::parse(value.to_string())) + .transpose()?; + batch.ledgers.push(LedgerRecord { + source_id, + name, + parent_source_id, + opening_balance, + }); + record_evidence.push(evidence); + } + + let voucher_type_ids_by_name = unique_source_ids_by_name( + &voucher_types.records, + |record| &record.name, + "voucher_type_identity_missing", + "voucher_type_name_missing", + "voucher_type_name_duplicate", + )?; + for source in voucher_types.records { + let source_id = required_source_id(&source, "voucher_type_identity_missing")?; + let evidence = source_evidence("voucher_type", source_id.clone(), &source)?; + let name = required_text(&source.record.name, "voucher_type_name_missing")?; + batch + .voucher_types + .push(VoucherTypeRecord { source_id, name }); + record_evidence.push(evidence); + } + + for source in vouchers.records { + let voucher_source_id = required_source_id(&source, "voucher_identity_missing")?; + let voucher_evidence = source_evidence("voucher", voucher_source_id.clone(), &source)?; + let voucher_type_name = source + .record + .voucher_type + .as_deref() + .ok_or_else(|| invalid_data("voucher_type_missing"))?; + let voucher_type_source_id = resolve_required_reference( + voucher_type_name, + &voucher_type_ids_by_name, + "voucher_type_reference_missing", + )?; + let date_yyyymmdd = required_text( + source + .record + .date + .as_deref() + .ok_or_else(|| invalid_data("voucher_date_missing"))?, + "voucher_date_missing", + )?; + let voucher_date = TallyDate::parse(date_yyyymmdd.clone()) + .map_err(|_| invalid_data("voucher_date_invalid"))?; + if voucher_date.as_str() < requested_from.as_str() + || voucher_date.as_str() > requested_to.as_str() + { + return Err(invalid_data("voucher_date_outside_requested_window")); + } + let voucher_number = source + .record + .voucher_number + .as_deref() + .filter(|value| !value.trim().is_empty()) + .map(|value| required_text(value, "voucher_number_invalid")) + .transpose()?; + let cancelled = source + .record + .cancelled + .ok_or_else(|| invalid_data("voucher_cancelled_missing"))?; + let optional = source + .record + .optional + .ok_or_else(|| invalid_data("voucher_optional_missing"))?; + + for entry in &source.record.ledger_entries { + let ledger_source_id = resolve_required_reference( + &entry.ledger_name, + &ledger_ids_by_name, + "voucher_ledger_reference_missing", + )?; + let entry_source_id = derived_ledger_entry_id( + &context.company.identity.company_guid, + &source, + entry.entry_index, + &entry.raw_source_sha256, + )?; + batch.ledger_entries.push(LedgerEntryRecord { + source_id: entry_source_id.clone(), + voucher_source_id: voucher_source_id.clone(), + ledger_source_id, + amount: ExactDecimal::parse(entry.amount.clone())?, + polarity: if entry.is_deemed_positive { + LedgerEntryPolarity::Debit + } else { + LedgerEntryPolarity::Credit + }, + }); + record_evidence.push(SourceRecordEvidence { + object_type: CanonicalText::parse("ledger_entry")?, + source_id: SourceRecordId::parse(entry_source_id)?, + identity_kind: SourceIdentityKind::Fallback, + observed_identities: ObservedSourceIdentities::default(), + // Hash of the exact decoded XML row fragment, not the HTTP transport bytes. + raw_source_sha256: RawSourceSha256::parse(entry.raw_source_sha256.clone())?, + alter_id: None, + }); + } + + batch.vouchers.push(VoucherRecord { + source_id: voucher_source_id, + date_yyyymmdd, + voucher_type_source_id, + voucher_number, + cancelled, + optional, + }); + record_evidence.push(voucher_evidence); + } + + let source_counts = vec![ + count_evidence(context, "group", group_count, SourceCountScope::Complete)?, + count_evidence(context, "ledger", ledger_count, SourceCountScope::Complete)?, + count_evidence( + context, + "voucher_type", + voucher_type_count, + SourceCountScope::Complete, + )?, + count_evidence(context, "voucher", voucher_count, SourceCountScope::Window)?, + ]; + let window = CanonicalPackWindow { + batch: PackBatch::CoreAccounting(batch), + source_counts: Some(source_counts), + record_evidence: Some(record_evidence), + }; + window.validate_source_count_evidence()?; + window.validate_record_evidence_binding()?; + Ok(window) +} + +/// Validates the exact selected voucher profile without canonicalising or retaining book data. +/// A successful zero-row response proves only execution of the selected profile, not emptiness or +/// source completeness. +pub(super) fn validate_selected_voucher_window( + from_yyyymmdd: &str, + to_yyyymmdd: &str, + vouchers: &ParsedExport>, +) -> Result<(), TallyError> { + let requested_from = TallyDate::parse(from_yyyymmdd.to_string()) + .map_err(|_| invalid_data("requested_window_invalid"))?; + let requested_to = TallyDate::parse(to_yyyymmdd.to_string()) + .map_err(|_| invalid_data("requested_window_invalid"))?; + if requested_from.as_str() > requested_to.as_str() { + return Err(invalid_data("requested_window_invalid")); + } + for source in &vouchers.records { + let source_id = required_source_id(source, "voucher_identity_missing")?; + source_evidence("voucher", source_id, source)?; + required_text( + source + .record + .voucher_type + .as_deref() + .ok_or_else(|| invalid_data("voucher_type_missing"))?, + "voucher_type_missing", + )?; + let date = required_text( + source + .record + .date + .as_deref() + .ok_or_else(|| invalid_data("voucher_date_missing"))?, + "voucher_date_missing", + )?; + let voucher_date = + TallyDate::parse(date).map_err(|_| invalid_data("voucher_date_invalid"))?; + if voucher_date.as_str() < requested_from.as_str() + || voucher_date.as_str() > requested_to.as_str() + { + return Err(invalid_data("voucher_date_outside_requested_window")); + } + source + .record + .cancelled + .ok_or_else(|| invalid_data("voucher_cancelled_missing"))?; + source + .record + .optional + .ok_or_else(|| invalid_data("voucher_optional_missing"))?; + source + .record + .voucher_number + .as_deref() + .map(|value| required_text(value, "voucher_number_invalid")) + .transpose()?; + source + .record + .party_ledger_name + .as_deref() + .map(|value| required_text(value, "voucher_party_ledger_name_invalid")) + .transpose()?; + let declared_entries = source + .record + .ledger_entry_count + .ok_or_else(|| invalid_data("voucher_ledger_entry_count_missing"))?; + if declared_entries != source.record.ledger_entries.len() as u64 { + return Err(invalid_data("voucher_ledger_entry_count_mismatch")); + } + let mut entry_indices = std::collections::BTreeSet::new(); + for entry in &source.record.ledger_entries { + if entry.entry_index == 0 || !entry_indices.insert(entry.entry_index) { + return Err(invalid_data("voucher_ledger_entry_index_invalid")); + } + required_text(&entry.ledger_name, "voucher_ledger_name_invalid")?; + ExactDecimal::parse(entry.amount.clone())?; + RawSourceSha256::parse(entry.raw_source_sha256.clone())?; + } + } + Ok(()) +} + +fn required_source_count( + export: &ParsedExport, + code: &'static str, +) -> Result { + export + .evidence + .source_record_count + .ok_or_else(|| protocol_error(code)) +} + +fn unique_source_ids_by_name( + records: &[ParsedSourceRecord], + name: F, + missing_identity_code: &'static str, + invalid_name_code: &'static str, + duplicate_name_code: &'static str, +) -> Result, TallyError> +where + F: Fn(&T) -> &str, +{ + let mut ids = BTreeMap::new(); + for source in records { + let source_id = required_source_id(source, missing_identity_code)?; + let canonical_name = required_text(name(&source.record), invalid_name_code)?; + if ids.insert(canonical_name, source_id).is_some() { + return Err(invalid_data(duplicate_name_code)); + } + } + Ok(ids) +} + +fn resolve_optional_reference( + value: Option<&str>, + ids_by_name: &BTreeMap, + missing_code: &'static str, +) -> Result, TallyError> { + value + .filter(|value| !value.trim().is_empty()) + .map(|value| resolve_required_reference(value, ids_by_name, missing_code)) + .transpose() +} + +fn resolve_group_parent( + value: Option<&str>, + ids_by_name: &BTreeMap, + missing_code: &'static str, +) -> Result, TallyError> { + let Some(value) = value.filter(|value| !value.trim().is_empty()) else { + return Ok(None); + }; + // `Primary` is Tally's reserved top-level classification, not one of the exported Group + // masters. Preserve the canonical tree root as `None`; every other named parent must resolve. + if value.trim().eq_ignore_ascii_case("primary") { + return Ok(None); + } + resolve_required_reference(value, ids_by_name, missing_code).map(Some) +} + +fn resolve_required_reference( + value: &str, + ids_by_name: &BTreeMap, + missing_code: &'static str, +) -> Result { + let name = required_text(value, missing_code)?; + ids_by_name + .get(&name) + .cloned() + .ok_or_else(|| invalid_data(missing_code)) +} + +fn count_evidence( + context: &RequestContext, + object_type: &str, + count: u64, + scope: SourceCountScope, +) -> Result { + let object_type = CanonicalText::parse(object_type)?; + let descriptor = SourceCountScopeDescriptor { + source_identity: context.company.identity.clone(), + pack: context.pack, + pack_schema_version: context.schema_version, + object_type: object_type.clone(), + query_profile: context.query_profile.clone(), + filters_sha256: context.filters_sha256.clone(), + window: (scope == SourceCountScope::Window).then(|| context.window.clone()), + }; + Ok(SourceReportedCountEvidence { + object_type, + query_profile: context.query_profile.clone(), + source_scope_fingerprint: source_count_scope_fingerprint(&descriptor, scope)?, + source_count_scope: scope, + source_reported_count: count, + }) +} + +fn source_evidence( + object_type: &str, + source_id: String, + source: &ParsedSourceRecord, +) -> Result { + let identity_kind = match source.identity_kind { + Some(ParsedSourceIdentityKind::Guid) => SourceIdentityKind::Guid, + Some(ParsedSourceIdentityKind::RemoteId) => SourceIdentityKind::RemoteId, + Some(ParsedSourceIdentityKind::MasterId) => SourceIdentityKind::MasterId, + None => return Err(invalid_data("source_identity_kind_missing")), + }; + Ok(SourceRecordEvidence { + object_type: CanonicalText::parse(object_type)?, + source_id: SourceRecordId::parse(source_id)?, + identity_kind, + observed_identities: ObservedSourceIdentities { + guid: source + .identities + .guid + .clone() + .map(SourceRecordId::parse) + .transpose()?, + remote_id: source + .identities + .remote_id + .clone() + .map(SourceRecordId::parse) + .transpose()?, + master_id: source + .identities + .master_id + .clone() + .map(SourceRecordId::parse) + .transpose()?, + }, + raw_source_sha256: RawSourceSha256::parse(source.raw_source_sha256.clone())?, + alter_id: source + .alter_id + .clone() + .map(SourceAlterId::parse) + .transpose()?, + }) +} + +fn required_source_id( + source: &ParsedSourceRecord, + code: &'static str, +) -> Result { + source + .source_id + .clone() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| invalid_data(code)) +} + +fn required_text(value: &str, code: &'static str) -> Result { + CanonicalText::parse(value.to_string()) + .map(|value| value.as_str().to_string()) + .map_err(|_| invalid_data(code)) +} + +fn derived_ledger_entry_id( + company_guid: &str, + voucher: &ParsedSourceRecord, + entry_index: u64, + entry_fragment_sha256: &str, +) -> Result { + let identity_kind = voucher + .identity_kind + .ok_or_else(|| invalid_data("voucher_identity_kind_missing"))?; + let source_id = required_source_id(voucher, "voucher_identity_missing")?; + RawSourceSha256::parse(entry_fragment_sha256.to_string())?; + + let mut digest = Sha256::new(); + digest.update(b"bridge-tally-ledger-entry-derived-id-v1\0"); + hash_field(&mut digest, company_guid.as_bytes()); + hash_field(&mut digest, parsed_identity_kind_code(identity_kind)); + hash_field(&mut digest, source_id.as_bytes()); + hash_field(&mut digest, &entry_index.to_be_bytes()); + hash_field(&mut digest, entry_fragment_sha256.as_bytes()); + Ok(format!( + "bridge-derived:ledger-entry:v1:{}", + hex_lower(&digest.finalize()) + )) +} + +fn parsed_identity_kind_code(kind: ParsedSourceIdentityKind) -> &'static [u8] { + match kind { + ParsedSourceIdentityKind::Guid => b"guid", + ParsedSourceIdentityKind::RemoteId => b"remote_id", + ParsedSourceIdentityKind::MasterId => b"master_id", + } +} + +fn hash_field(digest: &mut Sha256, value: &[u8]) { + digest.update((value.len() as u64).to_be_bytes()); + digest.update(value); +} + +fn hex_lower(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + output.push(HEX[(byte >> 4) as usize] as char); + output.push(HEX[(byte & 0x0f) as usize] as char); + } + output +} + +fn invalid_data(code: &'static str) -> TallyError { + TallyError::InvalidData { + code: code.to_string(), + } +} + +fn protocol_error(code: &'static str) -> TallyError { + TallyError::Protocol { + code: code.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bridge_tally_core::{ + CanonicalPackWindow, CanonicalText, CapabilityPackId, CompanyRef, LedgerEntryPolarity, + ObservedSourceIdentities, PackBatch, PackSchemaVersion, ReadWindow, RequestContext, + SourceIdentity, SourceIdentityKind, TallyError, + }; + use bridge_tally_protocol::{ + parse_group_source_records_with_evidence, parse_ledger_source_records_with_evidence, + parse_voucher_source_records_with_evidence, + parse_voucher_type_source_records_with_evidence, ParsedExport, ParsedSourceRecord, + TallyLedger, TallyNamedMaster, TallyVoucher, BRIDGE_GROUP_EXPORT_SCHEMA, + BRIDGE_LEDGER_EXPORT_SCHEMA, BRIDGE_VOUCHER_EXPORT_SCHEMA, + BRIDGE_VOUCHER_TYPE_EXPORT_SCHEMA, + }; + + fn context() -> RequestContext { + RequestContext { + run_id: "synthetic-run".to_string(), + company: CompanyRef { + identity: SourceIdentity { + bridge_source_lineage: "synthetic-lineage".to_string(), + company_guid: "synthetic-company-guid".to_string(), + observed_fingerprint: "synthetic-observation".to_string(), + }, + display_name: "BRIDGE SYNTHETIC BOOK".to_string(), + }, + pack: CapabilityPackId::CoreAccounting, + schema_version: PackSchemaVersion { major: 1, minor: 0 }, + window: ReadWindow { + from_yyyymmdd: "20260701".to_string(), + to_yyyymmdd: "20260731".to_string(), + }, + query_profile: CanonicalText::parse("core_accounting_v1").unwrap(), + filters_sha256: CanonicalText::parse("0".repeat(64)).unwrap(), + } + } + + fn groups() -> ParsedExport> { + parse_group_source_records_with_evidence(&format!( + r#"
1
Primary
"#, + BRIDGE_GROUP_EXPORT_SCHEMA + )) + .unwrap() + } + + fn ledgers_and_vouchers( + cash_name: &str, + entry_ledger_name: &str, + ) -> ( + ParsedExport>, + ParsedExport>, + ) { + let ledgers = parse_ledger_source_records_with_evidence(&format!( + r#"
1
Assets0Assets0
"#, + BRIDGE_LEDGER_EXPORT_SCHEMA, cash_name + )) + .unwrap(); + let vouchers = parse_voucher_source_records_with_evidence(&format!( + r#"
1
20260714ReceiptSYN-1NoNo21{}-100.00Yes2Sales100.00No
"#, + BRIDGE_VOUCHER_EXPORT_SCHEMA, entry_ledger_name + )) + .unwrap(); + (ledgers, vouchers) + } + + fn voucher_types() -> ParsedExport> { + parse_voucher_type_source_records_with_evidence(&format!( + r#"
1
Receipt
"#, + BRIDGE_VOUCHER_TYPE_EXPORT_SCHEMA + )) + .unwrap() + } + + fn valid_window() -> CanonicalPackWindow { + let (ledgers, vouchers) = ledgers_and_vouchers("Cash", "Cash"); + build_core_window(&context(), groups(), ledgers, voucher_types(), vouchers).unwrap() + } + + #[test] + fn canonicalizes_all_core_records_with_exact_reference_and_provenance_binding() { + let window = valid_window(); + window.validate_record_evidence_binding().unwrap(); + let PackBatch::CoreAccounting(batch) = &window.batch else { + panic!("wrong pack") + }; + assert_eq!( + ( + batch.groups.len(), + batch.ledgers.len(), + batch.voucher_types.len() + ), + (1, 2, 1) + ); + assert_eq!((batch.vouchers.len(), batch.ledger_entries.len()), (1, 2)); + assert_eq!( + batch.ledgers[0].parent_source_id.as_deref(), + Some("group-guid") + ); + assert_eq!(batch.groups[0].parent_source_id, None); + assert_eq!( + batch.vouchers[0].voucher_type_source_id, + "voucher-type-guid" + ); + assert_eq!(batch.ledger_entries[0].ledger_source_id, "ledger-cash"); + assert_eq!(batch.ledger_entries[0].voucher_source_id, "voucher-guid"); + assert_eq!(batch.ledger_entries[0].polarity, LedgerEntryPolarity::Debit); + assert_eq!( + batch.ledger_entries[1].polarity, + LedgerEntryPolarity::Credit + ); + assert!(batch.ledger_entries[0] + .source_id + .starts_with("bridge-derived:ledger-entry:v1:")); + assert_eq!(window.source_counts.as_ref().unwrap().len(), 4); + assert!(window + .source_counts + .as_ref() + .unwrap() + .iter() + .all(|evidence| evidence.object_type.as_str() != "ledger_entry")); + assert_eq!(window.record_evidence.as_ref().unwrap().len(), 7); + + let voucher_evidence = window + .record_evidence + .as_ref() + .unwrap() + .iter() + .find(|evidence| evidence.object_type.as_str() == "voucher") + .unwrap(); + assert_eq!(voucher_evidence.identity_kind, SourceIdentityKind::Guid); + assert_eq!( + voucher_evidence + .observed_identities + .remote_id + .as_ref() + .unwrap() + .as_str(), + "voucher-remote" + ); + assert_eq!( + voucher_evidence + .observed_identities + .master_id + .as_ref() + .unwrap() + .as_str(), + "9" + ); + } + + #[test] + fn nested_entry_totals_remain_local_and_are_never_claimed_as_source_reported() { + let window = valid_window(); + let PackBatch::CoreAccounting(batch) = &window.batch else { + panic!("wrong pack") + }; + + assert_eq!(batch.ledger_entries.len(), 2); + assert_eq!( + window + .record_evidence + .as_ref() + .unwrap() + .iter() + .filter(|evidence| evidence.object_type.as_str() == "ledger_entry") + .count(), + 2 + ); + assert!(window + .source_counts + .as_ref() + .unwrap() + .iter() + .all(|evidence| evidence.object_type.as_str() != "ledger_entry")); + } + + #[test] + fn derived_entry_ids_are_deterministic_but_never_claim_native_identity() { + fn entry_ids(window: &CanonicalPackWindow) -> Vec { + let PackBatch::CoreAccounting(batch) = &window.batch else { + panic!("wrong pack") + }; + batch + .ledger_entries + .iter() + .map(|entry| entry.source_id.clone()) + .collect() + } + let first = valid_window(); + let second = valid_window(); + assert_eq!(entry_ids(&first), entry_ids(&second)); + let entry_evidence = first + .record_evidence + .as_ref() + .unwrap() + .iter() + .filter(|evidence| evidence.object_type.as_str() == "ledger_entry") + .collect::>(); + assert_eq!(entry_evidence.len(), 2); + assert!(entry_evidence.iter().all(|evidence| { + evidence.identity_kind == SourceIdentityKind::Fallback + && evidence.observed_identities == ObservedSourceIdentities::default() + })); + } + + #[test] + fn unresolved_mutable_name_reference_fails_closed() { + let (ledgers, vouchers) = ledgers_and_vouchers("Cash", "Missing Ledger"); + let error = build_core_window(&context(), groups(), ledgers, voucher_types(), vouchers) + .unwrap_err(); + assert!(matches!( + error, + TallyError::InvalidData { code } + if code == "voucher_ledger_reference_missing" + )); + } + + #[test] + fn duplicate_mutable_names_fail_closed_even_when_native_ids_differ() { + let (ledgers, vouchers) = ledgers_and_vouchers("Sales", "Sales"); + let error = build_core_window(&context(), groups(), ledgers, voucher_types(), vouchers) + .unwrap_err(); + assert!(matches!( + error, + TallyError::InvalidData { code } if code == "ledger_name_duplicate" + )); + } + + #[test] + fn invalid_or_out_of_window_voucher_dates_fail_before_canonical_state_exists() { + for (date, expected_code) in [ + ("20260230", "voucher_date_invalid"), + ("20260630", "voucher_date_outside_requested_window"), + ("20260801", "voucher_date_outside_requested_window"), + ] { + let (ledgers, mut vouchers) = ledgers_and_vouchers("Cash", "Cash"); + vouchers.records[0].record.date = Some(date.to_string()); + let error = build_core_window(&context(), groups(), ledgers, voucher_types(), vouchers) + .unwrap_err(); + assert!(matches!( + error, + TallyError::InvalidData { code } if code == expected_code + )); + } + } + + #[test] + fn invalid_requested_window_fails_before_source_rows_are_canonicalized() { + for (from, to) in [("20260230", "20260731"), ("20260801", "20260731")] { + let mut request = context(); + request.window.from_yyyymmdd = from.to_string(); + request.window.to_yyyymmdd = to.to_string(); + let (ledgers, vouchers) = ledgers_and_vouchers("Cash", "Cash"); + let error = build_core_window(&request, groups(), ledgers, voucher_types(), vouchers) + .unwrap_err(); + assert!(matches!( + error, + TallyError::InvalidData { code } if code == "requested_window_invalid" + )); + } + } + + #[test] + fn selected_voucher_qualification_rejects_noncanonical_records_and_entries() { + let (_, vouchers) = ledgers_and_vouchers("Cash", "Cash"); + validate_selected_voucher_window("20260701", "20260731", &vouchers).unwrap(); + + let mut invalid_amount = vouchers.clone(); + invalid_amount.records[0].record.ledger_entries[0].amount = "not-an-amount".to_string(); + assert!(validate_selected_voucher_window("20260701", "20260731", &invalid_amount).is_err()); + + let mut invalid_name = vouchers.clone(); + invalid_name.records[0].record.ledger_entries[0].ledger_name = " x ".to_string(); + assert!(validate_selected_voucher_window("20260701", "20260731", &invalid_name).is_err()); + + let mut invalid_alter_id = vouchers; + invalid_alter_id.records[0].alter_id = Some("contains whitespace".to_string()); + assert!( + validate_selected_voucher_window("20260701", "20260731", &invalid_alter_id).is_err() + ); + } +} diff --git a/src-tauri/src/tally/connection.rs b/src-tauri/src/tally/connection.rs index def9cf2e..207bb2b2 100644 --- a/src-tauri/src/tally/connection.rs +++ b/src-tauri/src/tally/connection.rs @@ -6,6 +6,7 @@ use std::sync::{ atomic::{AtomicU64, AtomicU8, Ordering}, Arc, }; +#[cfg(feature = "voucher-scan")] use std::time::{Duration, Instant}; use super::xml_parser::{TallyLedger, TallyVoucher}; @@ -14,31 +15,24 @@ use super::{ validators::{normalize_company_guid, normalize_company_name}, xml_parser::{self, TallyCompany}, }; -#[cfg(feature = "fixture-canary-runtime-dispatch")] -use crate::tally::write_sandbox::{ - FixtureCanaryDispatchError, SealedFixtureCanaryDispatch, SealedFixtureCanaryReceipt, -}; use bridge_tally_core::{ CapabilityEvidence, CapabilityFeatureId, CapabilityPackId, CapabilityProfile, CapabilityState, EvidenceConfidence, TransportId, }; +#[cfg(feature = "voucher-scan")] +use bridge_tally_protocol::outstandings::{ + parse_ledger_opening_coverage, verify_empty_partition_witness_pair_with_wire_evidence, + verify_segment_pair_with_wire_evidence, voucher_empty_partition_witness_request, + voucher_outstandings_request, AlterIdRange, LedgerOpeningCoverage, NarrowDateWindow, + PinnedCompany, SegmentVerification, SegmentWireEvidence, VoucherOutstandingsRequestXml, + WitnessPairVerification, +}; use bridge_tally_protocol::{ - outstandings::{ - parse_company_book_extent, parse_ledger_opening_coverage, - verify_empty_partition_witness_pair_with_wire_evidence, - verify_segment_pair_with_wire_evidence, voucher_empty_partition_witness_request, - voucher_outstandings_request, AlterIdRange, CompanyBookExtent, LedgerOpeningCoverage, - NarrowDateWindow, PinnedCompany, SegmentVerification, SegmentWireEvidence, - VoucherOutstandingsRequestXml, WitnessPairVerification, - }, + outstandings_shared::{parse_company_book_extent, CompanyBookExtent}, parse_companies_for_interactive_discovery, parse_ledger_source_records_with_evidence, - parse_ledger_write_readback_with_evidence, parse_selected_voucher_source_records_with_evidence, - parse_standard_ledger_catalog, parse_standard_ledger_identity_observation, - verify_company_context, verify_selected_voucher_window_context, - xml_read_profiles::{ - ReadOnlyProfile, ValidatedCanaryLedgerName, ValidatedCompanyName, - ValidatedIdentityQuerySha256, - }, + parse_selected_voucher_source_records_with_evidence, parse_standard_ledger_catalog, + parse_standard_ledger_identity_observation, verify_selected_voucher_window_context, + xml_read_profiles::{ReadOnlyProfile, ValidatedCompanyName}, TallyTextEncoding, BRIDGE_LEDGER_EXPORT_SCHEMA, BRIDGE_SELECTED_VOUCHER_EXPORT_SCHEMA, }; use bridge_tally_transport::{ @@ -48,6 +42,7 @@ use bridge_tally_transport::{ pub type TallyConfig = TallyEndpointConfig; +#[cfg(feature = "voucher-scan")] #[derive(Debug)] pub(crate) struct OutstandingsSegmentObservation { pub(crate) verification: SegmentVerification, @@ -55,41 +50,26 @@ pub(crate) struct OutstandingsSegmentObservation { pub(crate) second_read_elapsed: Duration, } +#[cfg(feature = "voucher-scan")] pub(crate) enum LedgerOpeningCoverageRead { Stable(LedgerOpeningCoverage), Drifted, } +/// Outcome of a paired native-report read. `Drifted` means the two reads +/// disagreed, so the book moved between them and no total may be reported. +pub(crate) enum NativePairedRead { + Stable { body: String, encoded_bytes: usize }, + Drifted, +} + +#[cfg(feature = "voucher-scan")] struct OutstandingsWireResponse { text: String, encoded_bytes: usize, encoded_sha256: String, } -/// An exact, validated write-canary readback. Its XML remains crate-private to -/// the future write coordinator and is never returned to the UI or persisted. -#[allow( - dead_code, - reason = "the sealed runtime seam is intentionally staged before the write coordinator" -)] -pub(crate) struct LedgerCanaryReadbackXml(String); - -impl LedgerCanaryReadbackXml { - #[allow( - dead_code, - reason = "only the future crate-internal write coordinator may inspect sealed XML" - )] - pub(crate) fn as_xml(&self) -> &str { - &self.0 - } -} - -impl std::fmt::Debug for LedgerCanaryReadbackXml { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str("LedgerCanaryReadbackXml([redacted])") - } -} - #[derive(Debug, Clone, Serialize)] pub enum TallyProduct { TallyPrime, @@ -266,59 +246,9 @@ impl TallyClient { let mut packs = BTreeMap::new(); let mut companies = Vec::new(); - let xml_evidence = match self.post_xml(tdl_engine::company_list_request()).await { - Ok(xml) => match xml_parser::parse_companies(&xml) { - Ok(discovered) => { - connection.reachable = true; - if connection.error.is_some() { - connection.error = Some("status_heuristic_unavailable".to_string()); - } - match normalize_discovered_companies(discovered) { - Ok(normalized) => { - companies = normalized; - CapabilityEvidence { - state: CapabilityState::Supported, - confidence: EvidenceConfidence::Observed, - safe_reason_code: None, - } - } - Err(()) => CapabilityEvidence { - state: CapabilityState::Unknown, - confidence: EvidenceConfidence::Observed, - safe_reason_code: Some("company_identity_invalid".to_string()), - }, - } - } - Err(_) => match xml_parser::export_status(&xml) { - Ok(xml_parser::TallyExportStatus::Failure) => CapabilityEvidence { - // A shaped failure is an endpoint claim, not responder - // authenticity or proof that the read profile works. - state: CapabilityState::Unknown, - confidence: EvidenceConfidence::Observed, - safe_reason_code: Some( - xml_parser::export_failure_reason_code(&xml).to_string(), - ), - }, - _ if parse_companies_for_interactive_discovery(&xml).is_ok() => { - connection.reachable = true; - if connection.error.is_some() { - connection.error = Some("status_heuristic_unavailable".to_string()); - } - CapabilityEvidence { - state: CapabilityState::Unknown, - confidence: EvidenceConfidence::Observed, - safe_reason_code: Some("direct_company_report_untrusted".to_string()), - } - } - _ => CapabilityEvidence { - state: CapabilityState::Unknown, - confidence: EvidenceConfidence::Observed, - safe_reason_code: Some("xml_export_shape_unrecognized".to_string()), - }, - }, - }, - Err(error) => return Err(error), - }; + let xml_evidence = self + .company_discovery_evidence(&mut connection, &mut companies) + .await?; transports.insert(TransportId::XmlHttp, xml_evidence.clone()); transports.insert( TransportId::JsonEx, @@ -480,6 +410,118 @@ impl TallyClient { }) } + /// Discovers companies through Tally's documented `Company` collection + /// (`ReadOnlyProfile::CompanyListV2`). Unlike the legacy custom TDL + /// report, its response is Tally's ordinary shaped `HEADER/STATUS=1` + /// success envelope, so a successful parse directly satisfies the export + /// trust check instead of requiring the narrower, explicitly-untrusted + /// interactive compatibility parse. + /// + /// Responders that reject the collection outright — a shaped failure, an + /// unrecognized shape, or anything else the collection parser cannot + /// read — fall back to `legacy_company_discovery_evidence`, off the happy + /// path but otherwise unchanged. + async fn company_discovery_evidence( + &self, + connection: &mut ConnectionStatus, + companies: &mut Vec, + ) -> anyhow::Result { + let xml = self + .post_xml(ReadOnlyProfile::CompanyListV2.render()) + .await?; + match xml_parser::parse_companies_from_collection(&xml) { + Ok(discovered) => { + connection.reachable = true; + if connection.error.is_some() { + connection.error = Some("status_heuristic_unavailable".to_string()); + } + Ok(match normalize_discovered_companies(discovered) { + Ok(normalized) => { + *companies = normalized; + CapabilityEvidence { + state: CapabilityState::Supported, + confidence: EvidenceConfidence::Observed, + safe_reason_code: None, + } + } + Err(()) => CapabilityEvidence { + state: CapabilityState::Unknown, + confidence: EvidenceConfidence::Observed, + safe_reason_code: Some("company_identity_invalid".to_string()), + }, + }) + } + Err(_) => { + self.legacy_company_discovery_evidence(connection, companies) + .await + } + } + } + + /// The pre-`CompanyListV2` company discovery path: the custom + /// `CompanyListV1` TDL report, which most Tally responders answer with a + /// bare `...` document carrying no + /// `HEADER`/`STATUS` at all. That bare shape is accepted only through the + /// narrow, explicitly-untrusted interactive discovery parse; it can never + /// promote `CapabilityState::Supported`. + async fn legacy_company_discovery_evidence( + &self, + connection: &mut ConnectionStatus, + companies: &mut Vec, + ) -> anyhow::Result { + let xml = self.post_xml(tdl_engine::company_list_request()).await?; + Ok(match xml_parser::parse_companies(&xml) { + Ok(discovered) => { + connection.reachable = true; + if connection.error.is_some() { + connection.error = Some("status_heuristic_unavailable".to_string()); + } + match normalize_discovered_companies(discovered) { + Ok(normalized) => { + *companies = normalized; + CapabilityEvidence { + state: CapabilityState::Supported, + confidence: EvidenceConfidence::Observed, + safe_reason_code: None, + } + } + Err(()) => CapabilityEvidence { + state: CapabilityState::Unknown, + confidence: EvidenceConfidence::Observed, + safe_reason_code: Some("company_identity_invalid".to_string()), + }, + } + } + Err(_) => match xml_parser::export_status(&xml) { + Ok(xml_parser::TallyExportStatus::Failure) => CapabilityEvidence { + // A shaped failure is an endpoint claim, not responder + // authenticity or proof that the read profile works. + state: CapabilityState::Unknown, + confidence: EvidenceConfidence::Observed, + safe_reason_code: Some( + xml_parser::export_failure_reason_code(&xml).to_string(), + ), + }, + _ if parse_companies_for_interactive_discovery(&xml).is_ok() => { + connection.reachable = true; + if connection.error.is_some() { + connection.error = Some("status_heuristic_unavailable".to_string()); + } + CapabilityEvidence { + state: CapabilityState::Unknown, + confidence: EvidenceConfidence::Observed, + safe_reason_code: Some("direct_company_report_untrusted".to_string()), + } + } + _ => CapabilityEvidence { + state: CapabilityState::Unknown, + confidence: EvidenceConfidence::Observed, + safe_reason_code: Some("xml_export_shape_unrecognized".to_string()), + }, + }, + }) + } + pub(super) async fn post_xml(&self, xml: String) -> anyhow::Result { self.post_xml_with_encoded_bytes(xml) .await @@ -494,6 +536,7 @@ impl TallyClient { Ok((response.into_text(), encoded_bytes)) } + #[cfg(feature = "voucher-scan")] async fn post_outstandings_xml_with_encoded_bytes( &self, request: VoucherOutstandingsRequestXml, @@ -512,10 +555,7 @@ impl TallyClient { /// Uses the ordinary 32 MiB XML cap. Only the wildcard outstandings /// profile is allowed through `post_outstandings_xml_decoded`. - #[allow( - dead_code, - reason = "the supervised first live witness dispatch must be recorded before this ordinary-cap seam may be called by fetch_outstandings" - )] + #[cfg(feature = "voucher-scan")] async fn post_xml_with_wire_evidence( &self, request: String, @@ -532,27 +572,6 @@ impl TallyClient { }) } - /// Sends only the opaque, fixed fixture canary through the bounded - /// loopback transport. It consumes the capsule and exposes neither the - /// request XML nor the response XML to the application layer. - #[cfg(feature = "fixture-canary-runtime-dispatch")] - pub(crate) async fn dispatch_sealed_fixture_canary_once( - &self, - capsule: SealedFixtureCanaryDispatch, - ) -> Result { - let receipt_xml = self - .http - .post_xml_decoded(capsule.wire_xml) - .await - .map_err(FixtureCanaryDispatchError::Transport)? - .into_text(); - Ok(SealedFixtureCanaryReceipt { - prepared: capsule.prepared, - receipt_xml, - wire_digest: capsule.wire_digest, - }) - } - pub async fn fetch_companies(&self) -> anyhow::Result> { let xml = self.post_xml(tdl_engine::company_list_request()).await?; let companies = xml_parser::parse_companies_for_interactive_discovery(&xml)?; @@ -604,38 +623,6 @@ impl TallyClient { Ok(parsed.records) } - /// Executes the closed canary-readback profile and admits its response only - /// when the company, query commitment, and at-most-one exact ledger agree. - #[allow( - dead_code, - reason = "the sealed runtime seam is intentionally staged before the write coordinator" - )] - pub(crate) async fn fetch_ledger_canary_readback( - &self, - company: ValidatedCompanyName, - ledger_name: ValidatedCanaryLedgerName, - identity_query_sha256: ValidatedIdentityQuerySha256, - expected_company_guid: &str, - ) -> anyhow::Result { - let xml = self - .post_xml( - ReadOnlyProfile::LedgerCanaryReadbackV1 { - company: &company, - ledger_name: &ledger_name, - identity_query_sha256: &identity_query_sha256, - } - .render(), - ) - .await?; - validate_ledger_canary_readback( - &xml, - ledger_name.as_str(), - identity_query_sha256.as_str(), - expected_company_guid, - )?; - Ok(LedgerCanaryReadbackXml(xml)) - } - /// Reads the documented standard ledger collection as an explicitly limited /// compatibility catalog. It is not a fallback for Bridge's custom export /// and cannot establish snapshot, voucher, or write capability. @@ -657,6 +644,7 @@ impl TallyClient { /// The ledger profile fetches every master GUID and verifies its company /// GUID prefix, so a name-only selection cannot make another loaded /// company's coverage look like the pinned book. + #[cfg(feature = "voucher-scan")] pub(crate) async fn fetch_ledger_opening_coverage( &self, company: &PinnedCompany, @@ -712,6 +700,45 @@ impl TallyClient { Ok(first) } + /// Paired read for the native `TYPE=Data` bills reports and the ledger + /// closing snapshot. + /// + /// These responses are small — measured 11 KB for 48 bills and 41 KB for 88 + /// ledgers — so the whole-response byte comparison this performs is cheap, + /// and it replaces the date/AlterID partition-completeness machinery the + /// voucher scan needs. A drift between the two reads means the book moved + /// mid-sequence; the caller must treat that as Partial rather than pick a + /// side. + /// + /// Health checks bracket both requests and sit between them, so a gateway + /// that stalls mid-pair is distinguishable from a clean pair. See the + /// identical discipline in `fetch_company_book_extent`. + pub(crate) async fn fetch_native_report_paired( + &self, + request_xml: String, + ) -> anyhow::Result { + let (first, first_bytes) = self + .post_xml_with_encoded_bytes(request_xml.clone()) + .await?; + self.http + .get_status_decoded() + .await + .context("Tally health check between paired native report reads failed")?; + let (second, second_bytes) = self.post_xml_with_encoded_bytes(request_xml).await?; + self.http + .get_status_decoded() + .await + .context("Tally health check after paired native report reads failed")?; + if first != second || first_bytes != second_bytes { + return Ok(NativePairedRead::Drifted); + } + Ok(NativePairedRead::Stable { + body: first, + encoded_bytes: first_bytes, + }) + } + + #[cfg(feature = "voucher-scan")] pub(crate) async fn fetch_outstandings_segment_pair( &self, company: &PinnedCompany, @@ -768,6 +795,7 @@ impl TallyClient { /// live qualification is recorded in TALLY_PROTOCOL_REFERENCE.md §12.7; /// runtime may use it only for a primary-empty partition's control or /// shifted cover. + #[cfg(feature = "voucher-scan")] pub(crate) async fn fetch_empty_partition_witness_pair( &self, company: &PinnedCompany, @@ -865,7 +893,7 @@ impl TallyClient { parsed.evidence.identified_record_count, parsed.evidence.duplicate_identities.len(), )?; - bridge_tally_canonical::validate_selected_voucher_window(from, to, &parsed) + crate::tally::canonical_window::validate_selected_voucher_window(from, to, &parsed) .map_err(anyhow::Error::new)?; Ok(SelectedReadObservation { request_sha256, @@ -1033,40 +1061,6 @@ fn validate_selected_ledgers( Ok(()) } -#[allow( - dead_code, - reason = "the sealed runtime seam is intentionally staged before the write coordinator" -)] -fn validate_ledger_canary_readback( - xml: &str, - expected_ledger_name: &str, - expected_identity_query_sha256: &str, - expected_company_guid: &str, -) -> anyhow::Result<()> { - let parsed = parse_ledger_write_readback_with_evidence(xml)?; - verify_company_context(&parsed.evidence, expected_company_guid)?; - if parsed - .evidence - .company_context - .as_ref() - .and_then(|context| context.query_identity_set_sha256.as_deref()) - != Some(expected_identity_query_sha256) - { - anyhow::bail!("Tally canary readback query commitment did not match the request"); - } - if parsed.records.len() > 1 { - anyhow::bail!("Tally canary readback returned more than one ledger"); - } - if parsed - .records - .first() - .is_some_and(|record| record.record.name != expected_ledger_name) - { - anyhow::bail!("Tally canary readback ledger name did not match the request"); - } - Ok(()) -} - fn verify_selected_company_name( evidence: &bridge_tally_protocol::ExportEvidence, expected_name: &str, @@ -1144,128 +1138,20 @@ fn detect_product(text: &str) -> TallyProduct { #[cfg(test)] mod tests { + #[cfg(feature = "voucher-scan")] + use super::LedgerOpeningCoverageRead; use super::{ canonical_loopback_origin, decode_xml_bytes, detect_product, - normalize_discovered_companies, tally_endpoint, unique_company_guids, - validate_ledger_canary_readback, LedgerOpeningCoverageRead, TallyClient, TallyConfig, - TallyProduct, - }; - #[cfg(feature = "fixture-canary-runtime-dispatch")] - use crate::tally::write_sandbox::{ - authorize_fixture_canary, fixture_canary_ledger_mutation, - prepare_fixture_canary_ledger_import, preview_ledger_import, - FixtureCanaryAuthorizationRequest, IdempotencyRegistry, SyntheticCompany, - FIXTURE_CANARY_MAPPING_VERSION, + normalize_discovered_companies, tally_endpoint, unique_company_guids, TallyClient, + TallyConfig, TallyProduct, }; use bridge_tally_core::{ CapabilityFeatureId, CapabilityPackId, CapabilityState, EvidenceConfidence, TransportId, }; use std::time::Duration; - #[cfg(feature = "fixture-canary-runtime-dispatch")] - use tally_protocol_simulator::{Fixture, ScenarioPlan, Simulator}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; - const CANARY_QUERY_DIGEST: &str = - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - - #[cfg(feature = "fixture-canary-runtime-dispatch")] - const FIXTURE_COMPANY_GUID: &str = "00000000-0000-4000-8000-000000000001"; - #[cfg(feature = "fixture-canary-runtime-dispatch")] - const FIXTURE_COMMITMENT: &str = - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - - fn canary_readback(ledger_name: &str, company_guid: &str, query_digest: &str) -> String { - format!( - r#"
1
BRIDGE SYNTHETIC GROUP0
"# - ) - } - - #[cfg(feature = "fixture-canary-runtime-dispatch")] - fn sealed_fixture_canary() -> crate::tally::write_sandbox::PreparedFixtureCanary { - let company = SyntheticCompany::new("BRIDGE SYNTHETIC BOOK", FIXTURE_COMPANY_GUID) - .expect("synthetic company"); - let mutation = fixture_canary_ledger_mutation().expect("fixed canary mutation"); - let preview = preview_ledger_import(&company, &[mutation], FIXTURE_CANARY_MAPPING_VERSION) - .expect("fixed canary preview"); - let authorization = authorize_fixture_canary(FixtureCanaryAuthorizationRequest { - explicit_opt_in: true, - synthetic_company_confirmed: true, - company_guid: FIXTURE_COMPANY_GUID.to_owned(), - backup_guidance_acknowledged: true, - review_commitment_sha256: FIXTURE_COMMITMENT.to_owned(), - reservation_id: "fixture-runtime-dispatch-reservation".to_owned(), - reservation_payload_sha256: FIXTURE_COMMITMENT.to_owned(), - approved_wire_sha256: preview.wire_digest().as_hex().to_owned(), - approved_intended_state_sha256: preview.intended_state_digest().as_hex().to_owned(), - approved_identity_query_sha256: preview.identity_query_digest().as_hex().to_owned(), - idempotency_key: "fixture-runtime-dispatch-idempotency".to_owned(), - }) - .expect("authorize fixed canary"); - prepare_fixture_canary_ledger_import( - company, - authorization, - &mut IdempotencyRegistry::default(), - ) - .expect("prepare fixed canary") - } - - #[cfg(feature = "fixture-canary-runtime-dispatch")] - #[tokio::test] - async fn sealed_fixture_canary_dispatch_posts_exactly_once_without_xml_escape() { - let simulator = Simulator::spawn(ScenarioPlan::new(Fixture::ImportCounters)) - .expect("spawn synthetic loopback server"); - let client = TallyClient::new(TallyConfig { - host: simulator.address().ip().to_string(), - port: simulator.address().port(), - }) - .expect("construct bounded loopback transport"); - let receipt = client - .dispatch_sealed_fixture_canary_once( - sealed_fixture_canary() - .seal_for_dispatch() - .expect("seal exact canary"), - ) - .await - .expect("single synthetic import response"); - - assert!(format!("{receipt:?}").contains("[redacted]")); - let observed = simulator.finish().expect("observe one request"); - assert_eq!(observed.method, "POST"); - assert_eq!(observed.path, "/"); - assert!(observed.bytes_received > 0); - } - - #[test] - fn canary_readback_requires_exact_company_commitment_and_ledger_name() { - let ledger_name = "BRIDGE-CANARY-LEDGER-001"; - let xml = canary_readback(ledger_name, "company-guid", CANARY_QUERY_DIGEST); - validate_ledger_canary_readback(&xml, ledger_name, CANARY_QUERY_DIGEST, "company-guid") - .expect("exact synthetic canary readback is accepted"); - - assert!(validate_ledger_canary_readback( - &xml, - "BRIDGE-CANARY-LEDGER-002", - CANARY_QUERY_DIGEST, - "company-guid", - ) - .is_err()); - assert!(validate_ledger_canary_readback( - &xml, - ledger_name, - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "company-guid", - ) - .is_err()); - assert!(validate_ledger_canary_readback( - &xml, - ledger_name, - CANARY_QUERY_DIGEST, - "other-company-guid", - ) - .is_err()); - } - #[test] fn detects_tallyprime_status() { assert!(matches!( @@ -1484,6 +1370,7 @@ mod tests { ); } + #[cfg(feature = "voucher-scan")] #[tokio::test] async fn paired_outstandings_reads_health_check_between_and_after_requests() { const COMPANY_EXTENT: &str = include_str!( @@ -1620,6 +1507,7 @@ mod tests { server.await.expect("synthetic Tally server task"); } + #[cfg(feature = "voucher-scan")] #[tokio::test] async fn paired_ledger_opening_coverage_reports_intra_pair_drift() { const COMPANY_EXTENT: &str = include_str!( @@ -1747,7 +1635,7 @@ mod tests { let server = tokio::spawn(async move { for body in [ "LOCAL STATUS HEURISTIC UNRECOGNIZED", - "
1
Synthetic Companyguid-1
", + "
11
guid-1
", ] { let (mut socket, _) = listener.accept().await.expect("accept Tally request"); let mut request = [0_u8; 8192]; @@ -1948,9 +1836,15 @@ mod tests { .expect("bind synthetic Tally server"); let address = listener.local_addr().expect("synthetic Tally address"); let server = tokio::spawn(async move { + // This responder gives the same untrusted bare direct company + // report regardless of what is requested: once for the `V2` + // collection attempt (which the collection parser rejects, since + // it never satisfies `HEADER/STATUS`), and once more for the + // `V1` fallback that follows. for body in [ "LOCAL STATUS HEURISTIC UNRECOGNIZED", "Synthetic Companyguid-1", + "Synthetic Companyguid-1", ] { let (mut socket, _) = listener.accept().await.expect("accept Tally request"); let mut request = [0_u8; 8192]; @@ -2002,9 +1896,12 @@ mod tests { .expect("bind synthetic Tally server"); let address = listener.local_addr().expect("synthetic Tally address"); let server = tokio::spawn(async move { + // Same shaped `STATUS=0` failure for both the `V2` collection + // attempt and the `V1` fallback that follows it. for body in [ "TallyPrime Server is Running", "
0
Could not find Company ''
", + "
0
Could not find Company ''
", ] { let (mut socket, _) = listener.accept().await.expect("accept Tally request"); let mut request = [0_u8; 8192]; @@ -2045,4 +1942,74 @@ mod tests { CapabilityState::Unknown ); } + + /// Measured live 2026-08-07: the exact `Company` collection response for + /// a Tally instance with three loaded companies, `CMPINFO` counter trap + /// included. Proves `probe` requests `CompanyListV2` on the happy path + /// and trusts its success without ever falling back to the legacy + /// `CompanyListV1` report: the mock server has exactly one POST response + /// queued, so a fallback request would hang and fail this test. + #[tokio::test] + async fn capability_probe_trusts_the_company_collection_without_falling_back() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind synthetic Tally server"); + let address = listener.local_addr().expect("synthetic Tally address"); + let server = tokio::spawn(async move { + let mut requests = Vec::new(); + for body in [ + "TallyPrime Server is Running", + "\n
11
\n 0\n \n bb8ad19e-6aef-4239-a917-87fec0c6215e\n eebb9a9f-1679-4468-9e8f-814c729674cb\n 75f7566d-7a4f-431a-9642-e93a9d06d57d\n \n \n
", + ] { + let (mut socket, _) = listener.accept().await.expect("accept Tally request"); + let mut request = [0_u8; 8192]; + let bytes_read = socket.read(&mut request).await.expect("read Tally request"); + requests.push(String::from_utf8_lossy(&request[..bytes_read]).into_owned()); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/xml\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + socket + .write_all(response.as_bytes()) + .await + .expect("write Tally response"); + } + requests + }); + + let probe = TallyClient::new(TallyConfig { + host: address.ip().to_string(), + port: address.port(), + }) + .expect("build synthetic Tally client") + .probe() + .await + .expect("probe synthetic Tally endpoint"); + let requests = server.await.expect("synthetic Tally server task"); + + assert_eq!(probe.companies.len(), 3); + assert_eq!(probe.companies[0].name, "Aarav Trading Company Demo"); + assert_eq!( + probe.companies[0].guid.as_deref(), + Some("bb8ad19e-6aef-4239-a917-87fec0c6215e") + ); + assert_eq!(probe.companies[1].name, "Bridge Ageing Lab"); + assert_eq!(probe.companies[2].name, "Bridge Billwise Lab"); + assert_eq!( + probe.profile.transports[&TransportId::XmlHttp].state, + CapabilityState::Supported + ); + assert_eq!( + probe.profile.features[&CapabilityFeatureId::StableCompanyIdentity].state, + CapabilityState::Supported + ); + + let post_request = requests + .iter() + .find(|request| request.starts_with("POST")) + .expect("exactly one POST request was sent"); + assert!(post_request.contains("Collection")); + assert!(post_request.contains("BridgeCompanyExtent")); + assert!(post_request.contains("NAME,GUID")); + } } diff --git a/src-tauri/src/tally/connector.rs b/src-tauri/src/tally/connector.rs index 81b7759c..74250bfb 100644 --- a/src-tauri/src/tally/connector.rs +++ b/src-tauri/src/tally/connector.rs @@ -1,4 +1,4 @@ -use bridge_tally_canonical::build_core_window; +use crate::tally::canonical_window::build_core_window; use bridge_tally_core::report_tie_out::{LedgerPeriodBalance, LedgerPeriodBalanceReport}; use bridge_tally_core::{ CanonicalPackWindow, CapabilityEvidence, CapabilityPackId, CapabilityState, CompanyRef, @@ -774,8 +774,12 @@ mod tests { async fn duplicate_company_snapshot_probe_stops_before_core_exports() { let _simulator_guard = simulator_test_lock().lock().await; let company_guid = "synthetic-company-guid"; + // `TallyClient::probe` requests the trusted `Company` collection + // (`CompanyListV2`) first, so the two duplicate-GUID rows this test + // exercises are expressed in that shape rather than the legacy + // `CompanyListV1` direct report. let duplicate_company_xml = format!( - r#"
1
Synthetic Company A{company_guid}Synthetic Company B{company_guid}
"# + r#"
11
{company_guid}{company_guid}
"# ); let (address, server) = spawn_method_routed_server(vec![duplicate_company_xml]).await; let config = TallyConfig { @@ -827,6 +831,12 @@ mod tests { ) }; let (address, server) = spawn_method_routed_server(vec![ + // `TallyClient::probe` requests the trusted `Company` collection + // (`CompanyListV2`) first. This responder rejects it (the bare + // direct report has no `HEADER`/`STATUS` at all), so `probe` falls + // back to the legacy `CompanyListV1` report — one extra `direct` + // response ahead of the pre-existing legacy sequence below. + direct.to_string(), direct.to_string(), direct.to_string(), standard.to_string(), @@ -882,7 +892,7 @@ mod tests { let methods = server.await.expect("join routed Tally server"); assert_eq!( methods, - ["GET", "POST", "POST", "POST", "POST", "POST", "POST", "POST"] + ["GET", "POST", "POST", "POST", "POST", "POST", "POST", "POST", "POST"] ); } @@ -890,6 +900,17 @@ mod tests { async fn snapshot_start_and_end_probes_preserve_setup_review_and_read_transport_freshly() { let _simulator_guard = simulator_test_lock().lock().await; let company_guid = "synthetic-company-guid"; + // `TallyClient::probe` requests the trusted `Company` collection + // (`CompanyListV2`) first; this shape is what every `probe`/`probe_fresh` + // POST below answers with, so the trusted path resolves the company in + // one request and never falls back to the legacy, explicitly-untrusted + // direct report. + let company_collection_xml = format!( + r#"
11
{company_guid}
"# + ); + // `discover_companies` posts the legacy `CompanyListV1` report directly + // and parses it with the trusted `parse_companies` (unscoped + // `COMPANYINFO` scan), so its one response below keeps that shape. let company_xml = format!( r#"
1
Synthetic Company{company_guid}
"# ); @@ -898,10 +919,10 @@ mod tests { r#"
1
"# ) }; - let mut post_responses = vec![company_xml.clone()]; + let mut post_responses = vec![company_collection_xml.clone()]; for _ in 0..2 { post_responses.extend([ - company_xml.clone(), + company_collection_xml.clone(), empty_export(bridge_tally_protocol::BRIDGE_GROUP_EXPORT_SCHEMA, "GROUP"), empty_export(bridge_tally_protocol::BRIDGE_LEDGER_EXPORT_SCHEMA, "LEDGER"), empty_export( diff --git a/src-tauri/src/tally/mod.rs b/src-tauri/src/tally/mod.rs index c1e860b8..a7fc98e0 100644 --- a/src-tauri/src/tally/mod.rs +++ b/src-tauri/src/tally/mod.rs @@ -1,48 +1,18 @@ -#[allow( - dead_code, - reason = "the sealed coordinator is intentionally staged before its command layer" -)] -pub(crate) mod canary_preflight; -#[allow( - dead_code, - reason = "the private preflight preparation seam is staged before its command layer" -)] -pub(crate) mod canary_preflight_preparation; -#[allow( - dead_code, - reason = "the private preflight-read coordinator is staged before its command layer" -)] -pub(crate) mod canary_preflight_read_coordinator; -// This is intentionally feature-gated and has no Tauri command. It performs -// only local, read-only admission checks before a future separately reviewed -// runtime-dispatch boundary can be considered. -#[cfg(feature = "fixture-canary-dispatch-seam")] -#[allow( - dead_code, - reason = "the application admission seam is intentionally staged before its command layer" -)] -pub(crate) mod canary_dispatch_admission; -// The dispatch coordinator is compiled only with the explicit non-default -// runtime feature. Its one-use Tauri command boundary is separately feature-gated -// and exposes only a terminal digest-free receipt. -#[cfg(feature = "fixture-canary-runtime-dispatch")] -#[allow( - dead_code, - reason = "the runtime coordinator is intentionally staged before its command layer" -)] -pub(crate) mod canary_runtime_dispatch_coordinator; pub mod capability_packs; pub mod connection; pub mod connector; -pub mod incremental; +#[cfg(feature = "voucher-scan")] pub(crate) mod outstandings_runtime; pub mod runtime; +// Crate-internal only: `tally::runtime` is the sole consumer. +mod runtime_control; pub mod serial_queue; pub mod tdl_engine; pub mod validators; -pub(crate) mod write_sandbox; pub mod xml_builder; pub mod xml_parser; +// Crate-internal only: `tally::connector` and `tally::connection` are the sole consumers. +mod canonical_window; pub use bridge_tally_core as core; pub use connection::{ @@ -55,7 +25,8 @@ pub use connector::{ company_source_identity, core_snapshot_start_authorized, source_lineage, RuntimeTallyConnector, }; pub use runtime::{ - CachedProbeReservation, EndpointKey, OutstandingsCurrencyAssertion, OutstandingsLoadResult, - TallyRuntime, TallySessionSnapshot, TallyTelemetryPreviewExport, + CachedProbeReservation, EndpointKey, ExposureDirection, OpenBillRow, + OutstandingsCurrencyAssertion, OutstandingsLoadResult, TallyRuntime, TallySessionSnapshot, + TallyTelemetryPreviewExport, UnallocatedParty, }; pub use xml_parser::{TallyCompany, TallyImportResult, TallyLedger, TallyVoucher}; diff --git a/src-tauri/src/tally/runtime.rs b/src-tauri/src/tally/runtime.rs index 8fb62704..09444c73 100644 --- a/src-tauri/src/tally/runtime.rs +++ b/src-tauri/src/tally/runtime.rs @@ -1,39 +1,43 @@ use super::{ConnectionStatus, TallyClient, TallyCompany, TallyConfig, TallyLedger}; use super::{TallyProbeResult, TallyVoucher}; -use crate::tally::connection::LedgerOpeningCoverageRead; -use crate::tally::connection::{ - canonical_loopback_origin, LedgerCanaryReadbackXml, OutstandingsSegmentObservation, - SelectedReadObservation, -}; +use crate::observability::BodyBytesObservation; +use crate::tally::connection::NativePairedRead; +use crate::tally::connection::{canonical_loopback_origin, SelectedReadObservation}; +#[cfg(feature = "voucher-scan")] +use crate::tally::connection::{LedgerOpeningCoverageRead, OutstandingsSegmentObservation}; use crate::tally::connector::SealedReadRequest; +#[cfg(feature = "voucher-scan")] use crate::tally::outstandings_runtime::{ CalibratedSegmentPolicy, SegmentPlan, SegmentTrendGuard, MAX_SEGMENT_PAIRS_PER_SCAN, }; -#[cfg(feature = "fixture-canary-runtime-dispatch")] -use crate::tally::write_sandbox::{ - FixtureCanaryDispatchError, SealedFixtureCanaryDispatch, SealedFixtureCanaryReceipt, +use crate::tally::runtime_control::{ + EndpointCircuitState, EndpointIdentity, EndpointRuntimeSnapshot, PortableReadRuntime, + ReadAttempt, ReadExecutionError, ReadFailureClass, ReadOperation, ReadRetryPolicy, + TELEMETRY_PREVIEW_SCHEMA, +}; +use crate::tally::tdl_engine; +use bridge_tally_core::{ExactDecimal, TallyDate}; +use bridge_tally_protocol::native_outstandings::{ + compute_native_outstandings, parse_company_currency, parse_native_bill_rows, + parse_native_ledger_snapshot, render_company_currency_request, render_native_bills_request, + render_native_ledger_snapshot_request, AgeingAnchor, CompanyCurrency, NativeBillsReportKind, + NativeMasterSnapshot, }; -use bridge_tally_core::TallyDate; +#[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, DateBoundaryProfile, DateWindow, NarrowDateWindow, - OutstandingsReport, PartialScan, ScanResult, SegmentVerification, StrictlyWiderDateCover, - VoucherAlterIdHighWater, WitnessPairVerification, -}; -use bridge_tally_protocol::xml_read_profiles::{ - ValidatedCanaryLedgerName, ValidatedCompanyName, ValidatedIdentityQuerySha256, -}; -use bridge_tally_runtime::{ - BodyBytesObservation, EndpointCircuitState, EndpointIdentity, EndpointRuntimeSnapshot, - PortableReadRuntime, ReadAttempt, ReadExecutionError, ReadFailureClass, ReadOperation, - ReadRetryPolicy, TELEMETRY_PREVIEW_SCHEMA, + CorroboratedDatePartition, DateBoundaryProfile, DateWindow, NarrowDateWindow, PartialScan, + ScanResult, SegmentVerification, StrictlyWiderDateCover, VoucherAlterIdHighWater, + WitnessPairVerification, }; +use bridge_tally_protocol::outstandings_shared::OutstandingsReport; +use bridge_tally_protocol::{parse_group_source_records_with_evidence, verify_company_context}; use bridge_tally_transport::TallyTransportError; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::future::Future; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, RwLock}; use std::time::Instant; use tokio_util::sync::CancellationToken; @@ -44,6 +48,7 @@ const MAX_ENDPOINT_SESSIONS: usize = 32; /// implementation. There is intentionally no constructor: a future promotion /// must add the release-qualified evidence and the reconciliation itself, /// rather than merely opt the voucher scan back in. +#[cfg(feature = "voucher-scan")] #[derive(Clone)] struct QualifiedUnallocatedBalanceCoverage; @@ -105,7 +110,36 @@ pub enum OutstandingsLoadResult { Complete { report: Box, currency_assertion: OutstandingsCurrencyAssertion, + /// The date whose distance from `report.as_of_yyyymmdd` determines + /// the serialized ageing buckets. Consumers must disclose this rather + /// than inferring it from the read path. + ageing_anchor: OutstandingsAgeingAnchor, synced_at_unix_ms: i64, + /// Total exposure carrying no bill reference, when the read path can + /// establish it. `None` means "not computed", which is not the same as + /// zero and must never be rendered as zero: the voucher scan cannot + /// establish this figure, while the native path recovers it exactly + /// from the ledger closing balances. + /// + /// It matters more than its size suggests. On a bulk book measured + /// 2026-08-07 the named bills totalled Rs 10.36 lakh while the + /// unallocated remainder was Rs 2.79 crore -- so a screen showing only + /// the bills would be short by 96% with nothing to indicate it. + #[serde(skip_serializing_if = "Option::is_none")] + unallocated_total: Option, + /// Per-party unallocated exposure, largest first. + /// + /// On a book where most balances carry no bill reference, the ageing + /// buckets describe a rounding error and this list is the actual + /// answer -- so it is surfaced rather than collapsed into the single + /// total above. Empty when the path cannot establish it. + #[serde(skip_serializing_if = "Vec::is_empty")] + unallocated_by_party: Vec, + /// Every open bill the native reports returned, so the UI can answer + /// "why does this party owe so much" without a second Tally request -- + /// these rows are already in hand. + #[serde(skip_serializing_if = "Vec::is_empty")] + open_bills: Vec, }, Partial { reason_code: String, @@ -113,6 +147,139 @@ pub enum OutstandingsLoadResult { }, } +/// 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 open_bill_rows( + receivable: &[bridge_tally_protocol::native_outstandings::NativeBillRow], + payable: &[bridge_tally_protocol::native_outstandings::NativeBillRow], + as_of: &TallyDate, +) -> Vec { + let mut rows = receivable + .iter() + .map(|row| (row, "receivable")) + .chain(payable.iter().map(|row| (row, "payable"))) + .filter_map(|(row, kind)| { + let amount = row.closing_balance.abs().ok()?; + let age_days = if &row.due_date > as_of { + None + } else { + Some( + bridge_tally_protocol::native_outstandings::age_in_days(&row.due_date, as_of) + .ok()?, + ) + }; + Some(OpenBillRow { + party: row.party.clone(), + reference: if row.reference.trim().is_empty() { + MISSING_BILL_REFERENCE_LABEL.to_string() + } else { + row.reference.clone() + }, + bill_date: row.bill_date.as_str().to_string(), + due_date: row.due_date.as_str().to_string(), + amount, + age_days, + kind, + }) + }) + .collect::>(); + rows.sort_by(|left, right| { + right + .age_days + .cmp(&left.age_days) + .then_with(|| left.party.cmp(&right.party)) + .then_with(|| left.reference.cmp(&right.reference)) + }); + rows.truncate(MAX_OPEN_BILL_ROWS); + rows +} + +/// Ranks parties by unallocated exposure, largest first. +/// +/// Zero residuals are dropped rather than listed: a party whose ledger agrees +/// exactly with its bills has nothing unallocated, and showing it as a zero row +/// buries the parties that do. +fn top_unallocated_parties( + residuals: &[bridge_tally_protocol::native_outstandings::PartyResidual], +) -> Vec { + let mut ranked = residuals + .iter() + .filter(|residual| !residual.amount.is_zero()) + .filter_map(|residual| { + residual.amount.abs().ok().map(|amount| UnallocatedParty { + party: residual.party.clone(), + amount, + direction: if residual.amount.is_negative() { + ExposureDirection::Receivable + } else { + ExposureDirection::Payable + }, + }) + }) + .collect::>(); + ranked.sort_by(|left, right| { + right + .amount + .cmp_magnitude(&left.amount) + .then_with(|| left.party.cmp(&right.party)) + }); + ranked.truncate(10); + ranked +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct OpenBillRow { + pub party: String, + pub reference: String, + pub bill_date: String, + 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, +} + +/// Caps how many bill rows cross into the UI. +/// +/// Every row is already parsed, so this bounds only the serialized payload and +/// what the screen must render. A book past this many OPEN bills is well +/// outside anything measured, and silently truncating would be worse than +/// disclosing it -- see `open_bills_truncated`. +const MAX_OPEN_BILL_ROWS: usize = 2_000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExposureDirection { + Receivable, + Payable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum OutstandingsAgeingAnchor { + DueDate, + BillDate, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UnallocatedParty { + pub party: String, + pub amount: ExactDecimal, + pub direction: ExposureDirection, +} + fn partial_result(reason_code: &str) -> OutstandingsLoadResult { OutstandingsLoadResult::Partial { reason_code: reason_code.to_string(), @@ -120,6 +287,13 @@ fn partial_result(reason_code: &str) -> OutstandingsLoadResult { } } +fn native_crosscheck_partial_reason( + result: &bridge_tally_protocol::native_outstandings::NativeOutstandingsResult, +) -> Option<&'static str> { + (result.overdue_crosscheck_mismatches > 0).then_some("native_overdue_crosscheck_mismatch") +} + +#[cfg(feature = "voucher-scan")] fn closing_coverage_partial_reason( closing_coverage_matches_opening: bool, closing_coverage_is_fully_covered_by_vouchers: bool, @@ -133,6 +307,7 @@ fn closing_coverage_partial_reason( } } +#[cfg(feature = "voucher-scan")] fn paired_coverage_partial_reason(coverage: &LedgerOpeningCoverageRead) -> Option<&'static str> { match coverage { LedgerOpeningCoverageRead::Stable(_) => None, @@ -140,6 +315,7 @@ fn paired_coverage_partial_reason(coverage: &LedgerOpeningCoverageRead) -> Optio } } +#[cfg(feature = "voucher-scan")] fn select_outstandings_date_boundary_profile( profile: Option<&bridge_tally_core::CapabilityProfile>, ) -> DateBoundaryProfile { @@ -166,6 +342,7 @@ fn select_outstandings_date_boundary_profile( } } +#[cfg(feature = "voucher-scan")] fn outstandings_read_failure_reason(error: &anyhow::Error) -> &'static str { if let Some(transport) = error.downcast_ref::() { return match transport { @@ -203,6 +380,7 @@ fn outstandings_read_failure_reason(error: &anyhow::Error) -> &'static str { /// An outstandings read transport failure must cross `execute_cancellable` as an error so /// the endpoint circuit sees it. `fetch_outstandings` converts this back to /// the established typed Partial only after that health boundary. +#[cfg(feature = "voucher-scan")] #[derive(Debug, thiserror::Error)] #[error("outstandings read transport failure: {source}")] struct OutstandingsReadTransportFailure { @@ -211,6 +389,7 @@ struct OutstandingsReadTransportFailure { source: anyhow::Error, } +#[cfg(feature = "voucher-scan")] fn partial_after_outstandings_read_transport_failure( result: anyhow::Result, ) -> anyhow::Result { @@ -223,6 +402,7 @@ fn partial_after_outstandings_read_transport_failure( } } +#[cfg(feature = "voucher-scan")] fn outstandings_read_transport_failure(error: anyhow::Error) -> anyhow::Error { let reason_code = outstandings_read_failure_reason(&error); anyhow::Error::new(OutstandingsReadTransportFailure { @@ -231,12 +411,14 @@ fn outstandings_read_transport_failure(error: anyhow::Error) -> anyhow::Error { }) } +#[cfg(feature = "voucher-scan")] fn is_outstandings_transport_error(error: &anyhow::Error) -> bool { error .chain() .any(|cause| cause.downcast_ref::().is_some()) } +#[cfg(feature = "voucher-scan")] async fn fetch_empty_partition_witness( high_water: VoucherAlterIdHighWater, fetch: F, @@ -308,7 +490,6 @@ struct TallySession { health: Mutex, cached_probe: RwLock>, active_ordinary_reads: AtomicU64, - canary_dispatch_active: AtomicBool, } impl TallySession { @@ -322,7 +503,6 @@ impl TallySession { health: Mutex::new(SessionHealth::default()), cached_probe: RwLock::new(None), active_ordinary_reads: AtomicU64::new(0), - canary_dispatch_active: AtomicBool::new(false), }) } @@ -341,7 +521,6 @@ impl TallySession { health: Mutex::new(SessionHealth::default()), cached_probe: RwLock::new(None), active_ordinary_reads: AtomicU64::new(0), - canary_dispatch_active: AtomicBool::new(false), }) } @@ -460,20 +639,6 @@ struct OrdinaryReadLease { session: Arc, } -#[cfg(feature = "fixture-canary-runtime-dispatch")] -pub(crate) struct CanaryDispatchLease { - session: Arc, -} - -#[cfg(feature = "fixture-canary-runtime-dispatch")] -impl Drop for CanaryDispatchLease { - fn drop(&mut self) { - self.session - .canary_dispatch_active - .store(false, Ordering::Release); - } -} - impl Drop for OrdinaryReadLease { fn drop(&mut self) { self.session @@ -504,13 +669,16 @@ pub struct TallyRuntime { sessions: Arc>>, runtime_identity: Arc<()>, control: PortableReadRuntime, + #[cfg(feature = "voucher-scan")] outstandings_segment_policy: Option, // A complete voucher scan proves only the bill allocations it can read. // This witness may be constructed only by a qualified residual path that // independently reconciles direct postings without BILLALLOCATIONS.LIST. // Until then, returning a Complete result would turn an unknown balance // into a plausible total (TALLY_PROTOCOL_REFERENCE.md §12a.6). + #[cfg(feature = "voucher-scan")] unallocated_balance_coverage: Option, + #[cfg(feature = "voucher-scan")] outstandings_boundary_profile_override: Option, #[cfg(test)] transport_policy: Option, @@ -547,7 +715,6 @@ impl CachedProbeReservation { if !self.armed || !Arc::ptr_eq(&self.runtime_identity, &runtime.runtime_identity) || self.session.endpoint != EndpointKey::from_config(config)? - || self.session.canary_dispatch_active.load(Ordering::Acquire) { anyhow::bail!("Tally reviewed setup operation ownership changed"); } @@ -648,8 +815,11 @@ impl Default for TallyRuntime { sessions: Arc::new(Mutex::new(HashMap::new())), runtime_identity: Arc::new(()), control: PortableReadRuntime::default(), + #[cfg(feature = "voucher-scan")] outstandings_segment_policy: None, + #[cfg(feature = "voucher-scan")] unallocated_balance_coverage: None, + #[cfg(feature = "voucher-scan")] outstandings_boundary_profile_override: None, #[cfg(test)] transport_policy: None, @@ -766,105 +936,6 @@ impl TallyRuntime { .await } - /// Acquires the exclusive dispatch lease and verifies the pinned company - /// GUID through the sealed readback profile immediately before the import. - /// The returned lease must remain held for the sole import and final - /// readback; no ordinary Bridge read can interleave with that sequence. - #[cfg(feature = "fixture-canary-runtime-dispatch")] - pub(crate) async fn begin_verified_canary_dispatch( - &self, - config: TallyConfig, - company: ValidatedCompanyName, - ledger_name: ValidatedCanaryLedgerName, - identity_query_sha256: ValidatedIdentityQuerySha256, - expected_company_guid: String, - ) -> anyhow::Result { - let lease = self.begin_canary_dispatch(&config)?; - self.fetch_ledger_canary_readback_under_dispatch( - &lease, - config, - company, - ledger_name, - identity_query_sha256, - expected_company_guid, - ) - .await?; - Ok(lease) - } - - /// Issues exactly one sealed fixture-canary import while the previously - /// verified dispatch lease remains exclusive. Unlike every read path, this - /// bypasses retries: a durable dispatch claim permits no second HTTP send. - #[cfg(feature = "fixture-canary-runtime-dispatch")] - pub(crate) async fn dispatch_fixture_canary_once_under_dispatch( - &self, - lease: &CanaryDispatchLease, - config: &TallyConfig, - capsule: SealedFixtureCanaryDispatch, - ) -> Result { - if EndpointKey::from_config(config) - .map(|endpoint| endpoint != lease.session.endpoint) - .unwrap_or(true) - { - return Err(FixtureCanaryDispatchError::RuntimeUnavailable); - } - let session = Arc::clone(&lease.session); - let _request = session - .begin_request() - .map_err(|_| FixtureCanaryDispatchError::RuntimeUnavailable)?; - match session - .client - .dispatch_sealed_fixture_canary_once(capsule) - .await - { - Ok(receipt) => { - session.record_result(HealthOutcome::TransportSuccess); - Ok(receipt) - } - Err(error) => { - session.record_result(HealthOutcome::TransportFailure); - Err(error) - } - } - } - - #[cfg(feature = "fixture-canary-runtime-dispatch")] - pub(crate) async fn fetch_ledger_canary_readback_under_dispatch( - &self, - lease: &CanaryDispatchLease, - config: TallyConfig, - company: ValidatedCompanyName, - ledger_name: ValidatedCanaryLedgerName, - identity_query_sha256: ValidatedIdentityQuerySha256, - expected_company_guid: String, - ) -> anyhow::Result { - if EndpointKey::from_config(&config)? != lease.session.endpoint { - anyhow::bail!("sealed canary dispatch endpoint changed before readback"); - } - self.execute( - config, - ReadOperation::MasterExport, - ReadRetryPolicy::SINGLE_ATTEMPT, - move |client| { - let company = company.clone(); - let ledger_name = ledger_name.clone(); - let identity_query_sha256 = identity_query_sha256.clone(); - let expected_company_guid = expected_company_guid.clone(); - async move { - client - .fetch_ledger_canary_readback( - company, - ledger_name, - identity_query_sha256, - &expected_company_guid, - ) - .await - } - }, - ) - .await - } - async fn execute_cancellable( &self, config: TallyConfig, @@ -1145,39 +1216,24 @@ impl TallyRuntime { .await } - /// Executes one sealed, serial canary readback. This remains a read-only - /// internal primitive for the future write coordinator; it never exposes - /// response XML to commands, the UI, or persistence. - #[allow( - dead_code, - reason = "the sealed runtime seam is intentionally staged before the write coordinator" - )] - pub(crate) async fn fetch_ledger_canary_readback( + pub async fn qualify_selected_ledgers( &self, config: TallyConfig, - company: ValidatedCompanyName, - ledger_name: ValidatedCanaryLedgerName, - identity_query_sha256: ValidatedIdentityQuerySha256, + reservation: &CachedProbeReservation, + company: String, expected_company_guid: String, - ) -> anyhow::Result { - let _lease = self.begin_ordinary_read(&config)?; + ) -> anyhow::Result { + reservation.authorize(self, &config)?; self.execute( config, ReadOperation::MasterExport, ReadRetryPolicy::SINGLE_ATTEMPT, move |client| { let company = company.clone(); - let ledger_name = ledger_name.clone(); - let identity_query_sha256 = identity_query_sha256.clone(); let expected_company_guid = expected_company_guid.clone(); async move { client - .fetch_ledger_canary_readback( - company, - ledger_name, - identity_query_sha256, - &expected_company_guid, - ) + .qualify_selected_ledgers(&company, &expected_company_guid) .await } }, @@ -1185,24 +1241,27 @@ impl TallyRuntime { .await } - pub async fn qualify_selected_ledgers( + pub async fn fetch_vouchers( &self, config: TallyConfig, - reservation: &CachedProbeReservation, company: String, expected_company_guid: String, - ) -> anyhow::Result { - reservation.authorize(self, &config)?; + from: String, + to: String, + ) -> anyhow::Result> { + let _lease = self.begin_ordinary_read(&config)?; self.execute( config, - ReadOperation::MasterExport, - ReadRetryPolicy::SINGLE_ATTEMPT, + ReadOperation::VoucherExport, + ReadRetryPolicy::transient_default(), move |client| { let company = company.clone(); let expected_company_guid = expected_company_guid.clone(); + let from = from.clone(); + let to = to.clone(); async move { client - .qualify_selected_ledgers(&company, &expected_company_guid) + .fetch_vouchers(&company, &expected_company_guid, &from, &to) .await } }, @@ -1210,34 +1269,225 @@ impl TallyRuntime { .await } - pub async fn fetch_vouchers( + /// Outstandings via Tally's own `TYPE=Data` bills reports plus one ledger + /// snapshot. + /// + /// Four paired reads, bracketed by a GUID-pinned company extent probe + /// before and after. The extent probe is what binds identity: the native + /// report carries **no GUID anywhere**, so it cannot be identity-checked + /// from its own bytes. It does fail closed on an unloaded company + /// (`STATUS=0`, `LINEERROR: Could not set 'SVCurrentCompany'`, verified + /// live 2026-08-07), which the Collection path does not -- that path + /// silently substitutes whichever company is loaded. + /// + /// The bills reports alone are **not** complete: unallocated "on account" + /// balances carry no bill reference and appear in neither report. The + /// ledger snapshot recovers them exactly, as + /// `CLOSINGBALANCE - sum(BILLCL)` per party -- measured to 0.00 to the + /// paisa on every bill-carrying party of both a bill-dominated book (6 of + /// 10 parties exact, residual Rs 1,05,000) and an on-account-dominated one + /// (7 of 7 exact, residual Rs 2.79 crore against Rs 10.36 lakh of named + /// bills). Reporting the bills reports without that residual would show + /// 3.7% of exposure on the second book, with no error. + async fn fetch_outstandings_native( &self, config: TallyConfig, company: String, expected_company_guid: String, - from: String, - to: String, - ) -> anyhow::Result> { + as_of: TallyDate, + currency_assertion: OutstandingsCurrencyAssertion, + ) -> anyhow::Result { let _lease = self.begin_ordinary_read(&config)?; self.execute( config, ReadOperation::VoucherExport, - ReadRetryPolicy::transient_default(), + ReadRetryPolicy::SINGLE_ATTEMPT, move |client| { let company = company.clone(); let expected_company_guid = expected_company_guid.clone(); - let from = from.clone(); - let to = to.clone(); + let as_of = as_of.clone(); async move { - client - .fetch_vouchers(&company, &expected_company_guid, &from, &to) - .await + let extent = client + .fetch_company_book_extent(&company, &expected_company_guid) + .await?; + if &as_of < extent.books_from() { + return Ok(partial_result("as_of_precedes_books_from")); + } + let books_from = extent.books_from().clone(); + let mut total_bytes = 0usize; + let read = + |kind| render_native_bills_request(kind, &company, &books_from, &as_of); + let receivable = client + .fetch_native_report_paired(read(NativeBillsReportKind::Receivable)) + .await?; + let NativePairedRead::Stable { + body: receivable_body, + encoded_bytes, + } = receivable + else { + return Ok(partial_result("native_bills_report_drifted")); + }; + total_bytes += encoded_bytes; + + // A ledger's immediate parent can be an arbitrary custom + // subgroup. Reuse the existing GUID-bound group profile so + // bill-wise-off party ledgers can be resolved all the way + // to Sundry Debtors/Creditors rather than disappearing. + let groups = client + .fetch_native_report_paired(tdl_engine::groups_request(&company)) + .await?; + let NativePairedRead::Stable { + body: group_body, + encoded_bytes, + } = groups + else { + return Ok(partial_result("native_group_snapshot_drifted")); + }; + total_bytes += encoded_bytes; + + let payable = client + .fetch_native_report_paired(read(NativeBillsReportKind::Payable)) + .await?; + let NativePairedRead::Stable { + body: payable_body, + encoded_bytes, + } = payable + else { + return Ok(partial_result("native_bills_report_drifted")); + }; + total_bytes += encoded_bytes; + + let ledgers = client + .fetch_native_report_paired(render_native_ledger_snapshot_request( + &company, + &books_from, + &as_of, + )) + .await?; + let NativePairedRead::Stable { + body: ledger_body, + encoded_bytes, + } = ledgers + else { + return Ok(partial_result("native_ledger_snapshot_drifted")); + }; + total_bytes += encoded_bytes; + + // Re-pin after every read. The native rows cannot be + // GUID-checked individually, so an unchanged extent across + // the whole sequence is the only identity evidence + // available. + let closing_extent = client + .fetch_company_book_extent(&company, &expected_company_guid) + .await?; + if closing_extent != extent { + return Ok(partial_result("book_changed_during_read")); + } + + let receivable_rows = + parse_native_bill_rows(&receivable_body, &books_from, &as_of)?; + let payable_rows = parse_native_bill_rows(&payable_body, &books_from, &as_of)?; + let ledger_rows = parse_native_ledger_snapshot(&ledger_body)?; + let parsed_groups = parse_group_source_records_with_evidence(&group_body)?; + verify_company_context(&parsed_groups.evidence, &expected_company_guid)?; + let group_rows = parsed_groups + .records + .into_iter() + .map(|row| row.record) + .collect::>(); + + // Ageing anchors on the DUE date. Measured 2026-08-07: on a + // bill carrying a 30-day credit period Tally's own + // BILLOVERDUE counted 61 days, which is the age from + // BILLDUE, not the 91 days from BILLDATE. Where no credit + // period exists the two dates coincide, so this is correct + // on both books. + let result = compute_native_outstandings( + &company, + &receivable_rows, + &payable_rows, + NativeMasterSnapshot { + ledgers: &ledger_rows, + groups: &group_rows, + }, + AgeingAnchor::DueDate, + &as_of, + total_bytes, + )?; + + if let Some(reason_code) = native_crosscheck_partial_reason(&result) { + return Ok(partial_result(reason_code)); + } + + Ok(OutstandingsLoadResult::Complete { + report: Box::new(result.report), + currency_assertion, + ageing_anchor: OutstandingsAgeingAnchor::DueDate, + synced_at_unix_ms: chrono::Utc::now().timestamp_millis(), + unallocated_total: Some(result.residual_total), + unallocated_by_party: top_unallocated_parties(&result.residuals), + open_bills: open_bill_rows(&receivable_rows, &payable_rows, &as_of), + }) } }, ) .await } + /// Reads the company's currency masters so Bridge can establish the base + /// currency itself. + /// + /// The INR assertion is a real safety property -- formatting a foreign + /// balance with a rupee symbol misstates money -- but it is a fact Tally + /// holds, and making the operator click it on every company was a step the + /// product could answer for itself. This satisfies the assertion rather + /// than removing it: where the answer is not determinable (several + /// currencies defined, or a non-Indian one), the caller still has to ask. + pub async fn detect_base_currency( + &self, + config: TallyConfig, + company: String, + expected_company_guid: String, + ) -> anyhow::Result { + let _lease = self.begin_ordinary_read(&config)?; + self.execute( + config, + ReadOperation::VoucherExport, + ReadRetryPolicy::SINGLE_ATTEMPT, + move |client| { + let company = company.clone(); + let expected_company_guid = expected_company_guid.clone(); + async move { + // Pin identity first: a currency read against the wrong + // company is worse than none. + let extent = client + .fetch_company_book_extent(&company, &expected_company_guid) + .await?; + let body = client + .fetch_native_report_paired(render_company_currency_request(&company)) + .await?; + let NativePairedRead::Stable { body, .. } = body else { + anyhow::bail!("Tally currency masters changed between paired reads"); + }; + let closing_extent = client + .fetch_company_book_extent(&company, &expected_company_guid) + .await?; + if closing_extent != extent { + anyhow::bail!("Tally company book changed during currency detection"); + } + Ok(parse_company_currency(&body)?) + } + }, + ) + .await + } + + /// With `voucher-scan` off, the legacy scan cannot execute in any shipped + /// build (its only width-calibration constructors are `#[cfg(test)]` and + /// `#[cfg(feature = "live-calibration-harness")]`, and this crate's + /// default build has neither), so this simply *is* the native path: no + /// `Option`, no branch, no dead arm to compile in and never take. + #[cfg(not(feature = "voucher-scan"))] pub async fn fetch_outstandings( &self, config: TallyConfig, @@ -1246,8 +1496,50 @@ impl TallyRuntime { as_of: TallyDate, currency_assertion: OutstandingsCurrencyAssertion, ) -> anyhow::Result { + self.fetch_outstandings_native( + config, + company, + expected_company_guid, + as_of, + currency_assertion, + ) + .await + } + + #[cfg(feature = "voucher-scan")] + pub async fn fetch_outstandings( + &self, + config: TallyConfig, + company: String, + expected_company_guid: String, + as_of: TallyDate, + currency_assertion: OutstandingsCurrencyAssertion, + ) -> 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 + // calibration at all. Measured 2026-08-07 against the same book the + // voucher scan reconciles against: identical 48 open bills, + // Rs 45,14,597 receivable and 4/4/4/36 ageing, in 0.21 s and 11 KB + // against the scan's 8.30 s, 54 requests and 3.44 MB. + // + // This ordering matters for a reason that is not a preference: a + // production build has no calibrated width by construction -- + // `CalibratedSegmentPolicy`'s only constructors are `#[cfg(test)]` and + // `#[cfg(feature = "live-calibration-harness")]`, and `Self::default` + // sets the field to `None`. Before this, the command returned + // `outstandings_segment_sizing_uncalibrated` for every company on every + // book, before any request, and the screen could only ever say "No + // Tally data was read". let Some(segment_policy) = self.outstandings_segment_policy else { - return Ok(partial_result("outstandings_segment_sizing_uncalibrated")); + return self + .fetch_outstandings_native( + config, + company, + expected_company_guid, + as_of, + currency_assertion, + ) + .await; }; let Some(_coverage) = self.unallocated_balance_coverage.as_ref() else { return Ok(partial_result("unallocated_direct_postings_not_covered")); @@ -1565,7 +1857,15 @@ impl TallyRuntime { ScanResult::Complete(scan) => Ok(OutstandingsLoadResult::Complete { report: Box::new(compute_outstandings(&scan, as_of)?), currency_assertion, + ageing_anchor: OutstandingsAgeingAnchor::BillDate, synced_at_unix_ms: chrono::Utc::now().timestamp_millis(), + // The voucher scan derives bills from vouchers + // and cannot establish the unallocated + // remainder, so it must stay absent rather + // than be reported as zero. + unallocated_total: None, + unallocated_by_party: Vec::new(), + open_bills: Vec::new(), }), ScanResult::Partial(partial) => { Ok(partial_result(&partial.reason_code)) @@ -1722,16 +2022,10 @@ impl TallyRuntime { return Ok(None); }; let now = chrono::Utc::now().timestamp_millis(); - if session.canary_dispatch_active.load(Ordering::Acquire) { - anyhow::bail!("sealed canary dispatch is in progress"); - } let mut cache = session .cached_probe .write() .map_err(|_| anyhow::anyhow!("Tally capability cache is unavailable"))?; - if session.canary_dispatch_active.load(Ordering::Acquire) { - anyhow::bail!("sealed canary dispatch is in progress"); - } if session.active_ordinary_reads.load(Ordering::Acquire) != 0 { anyhow::bail!("Tally read operation is already in progress"); } @@ -1789,9 +2083,6 @@ impl TallyRuntime { &self, session: Arc, ) -> anyhow::Result { - if session.canary_dispatch_active.load(Ordering::Acquire) { - anyhow::bail!("sealed canary dispatch is in progress"); - } let cache = session .cached_probe .write() @@ -1806,54 +2097,8 @@ impl TallyRuntime { }) .map_err(|_| anyhow::anyhow!("Tally read admission capacity is unavailable"))?; drop(cache); - if session.canary_dispatch_active.load(Ordering::Acquire) { - session.active_ordinary_reads.fetch_sub(1, Ordering::AcqRel); - anyhow::bail!("sealed canary dispatch is in progress"); - } Ok(OrdinaryReadLease { session }) } - - #[cfg(feature = "fixture-canary-runtime-dispatch")] - fn begin_canary_dispatch(&self, config: &TallyConfig) -> anyhow::Result { - let session = self.session(config.clone())?; - if session - .canary_dispatch_active - .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) - .is_err() - { - anyhow::bail!("sealed canary dispatch is already in progress"); - } - let admitted = (|| -> anyhow::Result<()> { - if session.active_ordinary_reads.load(Ordering::Acquire) != 0 { - anyhow::bail!("ordinary Tally read is in progress"); - } - if !session - .active_requests - .lock() - .map_err(|_| anyhow::anyhow!("Tally cancellation registry is unavailable"))? - .is_empty() - { - anyhow::bail!("Tally endpoint request is in progress"); - } - if session - .cached_probe - .read() - .map_err(|_| anyhow::anyhow!("Tally capability cache is unavailable"))? - .as_ref() - .is_some_and(|probe| probe.reserved) - { - anyhow::bail!("Tally reviewed setup operation is in progress"); - } - Ok(()) - })(); - if let Err(error) = admitted { - session - .canary_dispatch_active - .store(false, Ordering::Release); - return Err(error); - } - Ok(CanaryDispatchLease { session }) - } } fn classify_error(error: &anyhow::Error) -> HealthOutcome { @@ -1867,7 +2112,6 @@ fn classify_error(error: &anyhow::Error) -> HealthOutcome { | ReadFailureClass::SizeLimit | ReadFailureClass::Decode | ReadFailureClass::Application - | ReadFailureClass::Parse | ReadFailureClass::Validation | ReadFailureClass::CompanyMismatch => HealthOutcome::ApplicationRejected, } @@ -1923,11 +2167,11 @@ fn map_execution_error(error: ReadExecutionError) -> anyhow::Erro anyhow::Error::new(TallyRuntimeControlError::QueueDeadline) } ReadExecutionError::CircuitRejected { - reason: bridge_tally_runtime::CircuitRejectReason::Cooldown, + reason: crate::observability::CircuitRejectReason::Cooldown, .. } => anyhow::Error::new(TallyRuntimeControlError::CircuitCooldown), ReadExecutionError::CircuitRejected { - reason: bridge_tally_runtime::CircuitRejectReason::HalfOpenProbeInFlight, + reason: crate::observability::CircuitRejectReason::HalfOpenProbeInFlight, .. } => anyhow::Error::new(TallyRuntimeControlError::HalfOpenProbeInFlight), ReadExecutionError::EndpointSessionLimit => { @@ -1942,6 +2186,187 @@ mod tests { use crate::tally::TallyProduct; use bridge_tally_core::CapabilityProfile; use std::collections::BTreeMap; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + #[test] + fn ageing_anchor_serializes_as_an_explicit_wire_contract() { + assert_eq!( + serde_json::to_value(OutstandingsAgeingAnchor::DueDate).unwrap(), + serde_json::json!("due_date") + ); + assert_eq!( + serde_json::to_value(OutstandingsAgeingAnchor::BillDate).unwrap(), + serde_json::json!("bill_date") + ); + } + + #[test] + fn validation_lab_future_bill_stays_unaged_in_open_bill_output() { + let receivable = parse_native_bill_rows( + include_str!( + "../../crates/bridge-tally-protocol/tests/fixtures/native/bills_receivable_validation_lab.xml" + ), + &TallyDate::parse("20250401").expect("captured BooksFrom"), + &TallyDate::parse("20260817").expect("capture as-of"), + ) + .expect("captured validation-book rows parse"); + let rows = open_bill_rows( + &receivable, + &[], + &TallyDate::parse("20260817").expect("capture as-of"), + ); + let future = rows + .iter() + .find(|row| row.reference == "ALPHA-FUTURE") + .expect("captured future-due bill remains present"); + assert_eq!(future.amount.as_str(), "22222.00"); + assert_eq!(future.age_days, None); + } + + #[test] + fn raw_overdue_disagreement_withholds_native_complete() { + let rows = parse_native_bill_rows( + "1-Jul-26MISMATCH\ + Synthetic Customer-100.00\ + 1-Jul-2631", + &TallyDate::parse("20250401").expect("synthetic BooksFrom"), + &TallyDate::parse("20260817").expect("synthetic as-of"), + ) + .expect("raw bill parses"); + let computed = compute_native_outstandings( + "Synthetic Company", + &rows, + &[], + NativeMasterSnapshot { + ledgers: &[], + groups: &[], + }, + AgeingAnchor::DueDate, + &TallyDate::parse("20260817").expect("synthetic as-of"), + 0, + ) + .expect("arithmetic still computes for diagnostic comparison"); + assert_eq!(computed.overdue_crosscheck_mismatches, 1); + assert_eq!( + native_crosscheck_partial_reason(&computed), + Some("native_overdue_crosscheck_mismatch") + ); + } + + #[test] + fn raw_empty_bill_references_preserve_each_amount_and_get_explicit_label() { + let raw_bills = "\ + 1-Jul-26Synthetic Customer\ + -40.001-Jul-2630\ + 2-Jul-26 \n\t Synthetic Customer\ + -60.002-Jul-2629\ + "; + let as_of = TallyDate::parse("20260731").expect("synthetic as-of"); + let parsed = parse_native_bill_rows( + raw_bills, + &TallyDate::parse("20250401").expect("synthetic BooksFrom"), + &as_of, + ) + .expect("paired empty BILLREF values remain parseable"); + let rows = open_bill_rows(&parsed, &[], &as_of); + + assert_eq!(rows.len(), 2, "empty identities must not collapse rows"); + let total = rows + .iter() + .try_fold(ExactDecimal::zero(), |sum, row| { + sum.checked_add(&row.amount) + }) + .expect("synthetic bill total remains exact"); + assert_eq!(total.as_str(), "100", "neither amount may be lost"); + assert_eq!( + rows.iter() + .map(|row| row.reference.as_str()) + .collect::>(), + vec!["No reference reported", "No reference reported"], + "client-facing rows must disclose the missing identity", + ); + } + + #[tokio::test] + async fn detect_base_currency_rejects_book_drift_after_the_currency_read() { + 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 STATUS: &str = "TallyPrime Server is Running"; + + let closing_extent = EXTENT.replace( + "20260401", + "20260402", + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind synthetic currency server"); + let address = listener.local_addr().expect("synthetic server address"); + let server = tokio::spawn(async move { + let responses = [ + EXTENT, + STATUS, + EXTENT, + STATUS, + CURRENCY, + STATUS, + CURRENCY, + STATUS, + closing_extent.as_str(), + STATUS, + closing_extent.as_str(), + STATUS, + ]; + for (index, body) in responses.into_iter().enumerate() { + let (mut socket, _) = + tokio::time::timeout(std::time::Duration::from_secs(2), listener.accept()) + .await + .expect("currency request timed out") + .expect("accept currency request"); + let mut request = [0_u8; 16 * 1024]; + let bytes_read = socket.read(&mut request).await.expect("read request"); + let expected_method = if index % 2 == 0 { + "POST /" + } else { + "GET /status" + }; + assert!( + String::from_utf8_lossy(&request[..bytes_read]).starts_with(expected_method), + "request {index} did not preserve paired-read health bracketing" + ); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/xml\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + socket + .write_all(response.as_bytes()) + .await + .expect("write response"); + } + }); + + let runtime = TallyRuntime::default(); + let result = runtime + .detect_base_currency( + TallyConfig { + host: address.ip().to_string(), + port: address.port(), + }, + "Aarav Trading Company Demo".to_string(), + "bb8ad19e-6aef-4239-a917-87fec0c6215e".to_string(), + ) + .await; + + let error = result.expect_err("closing extent drift must reject the currency"); + assert!( + error + .to_string() + .contains("book changed during currency detection"), + "unexpected error: {error:#}" + ); + server.await.expect("synthetic currency server task"); + } fn synthetic_probe_result() -> TallyProbeResult { TallyProbeResult { @@ -1970,6 +2395,7 @@ mod tests { } } + #[cfg(feature = "voucher-scan")] #[test] fn outstandings_read_failures_are_partial_and_deadlines_recommend_restart() { assert_eq!( @@ -1996,6 +2422,7 @@ mod tests { ); } + #[cfg(feature = "voucher-scan")] #[tokio::test] async fn outstandings_read_transport_failure_feeds_breaker_and_preserves_typed_partial() { let runtime = TallyRuntime::default(); @@ -2032,6 +2459,7 @@ mod tests { ); } + #[cfg(feature = "voucher-scan")] #[tokio::test] async fn verification_partial_stays_partial_without_feeding_breaker() { let runtime = TallyRuntime::default(); @@ -2062,6 +2490,7 @@ mod tests { ); } + #[cfg(feature = "voucher-scan")] #[test] fn closing_coverage_drift_is_not_reported_as_uncovered_opening_bills() { assert_eq!( @@ -2075,6 +2504,7 @@ mod tests { assert_eq!(closing_coverage_partial_reason(true, true), None); } + #[cfg(feature = "voucher-scan")] #[test] fn intra_pair_ledger_coverage_drift_is_an_in_band_partial() { assert_eq!( @@ -2083,6 +2513,7 @@ mod tests { ); } + #[cfg(feature = "voucher-scan")] #[tokio::test] async fn witness_transport_failure_feeds_breaker_and_preserves_typed_partial() { let error = fetch_empty_partition_witness( @@ -2124,6 +2555,7 @@ mod tests { ); } + #[cfg(feature = "voucher-scan")] #[tokio::test] async fn witness_non_transport_failure_remains_an_in_band_partial() { let partial = fetch_empty_partition_witness( @@ -2140,6 +2572,7 @@ mod tests { ); } + #[cfg(feature = "voucher-scan")] #[test] fn outstandings_date_boundaries_follow_detected_mode_and_fallback_to_i12() { let mut profile = synthetic_probe_result().profile; @@ -2169,9 +2602,32 @@ mod tests { ); } + /// An uncalibrated segment width no longer refuses the read -- it selects + /// the native bills path, which needs no width. What must NOT change is + /// that a non-loopback endpoint is still refused. + /// + /// This test previously asserted `outstandings_segment_sizing_uncalibrated` + /// and, by using a non-loopback host, proved the refusal happened BEFORE + /// endpoint admission. That refusal was the defect: production has no + /// calibrated width by construction, so every shipped build returned it for + /// every company on every book and the screen could only ever say "No Tally + /// data was read". The reason code is gone with it. + /// + /// The loopback guard is unchanged and still fails closed; it is simply now + /// the first guard the native path reaches. That is the property worth + /// pinning, so this asserts it directly rather than inferring it from an + /// ordering that no longer exists. #[tokio::test] - async fn uncalibrated_outstandings_returns_partial_before_endpoint_admission() { - let result = TallyRuntime::default() + async fn uncalibrated_outstandings_takes_the_native_path_and_still_refuses_a_non_loopback_endpoint( + ) { + let runtime = TallyRuntime::default(); + #[cfg(feature = "voucher-scan")] + assert!( + runtime.outstandings_segment_policy.is_none(), + "a default runtime must have no calibrated width -- that is what routes to the native path" + ); + + let error = runtime .fetch_outstandings( TallyConfig { host: "not-a-loopback-endpoint".to_string(), @@ -2183,12 +2639,11 @@ mod tests { OutstandingsCurrencyAssertion::Inr, ) .await - .expect("uncalibrated state is an in-band partial result"); - assert!(matches!( - result, - OutstandingsLoadResult::Partial { reason_code, .. } - if reason_code == "outstandings_segment_sizing_uncalibrated" - )); + .expect_err("a non-loopback endpoint must never be contacted"); + assert!( + error.to_string().contains("non_loopback_forbidden"), + "loopback-only admission must still fail closed on the native path, got: {error}" + ); } #[cfg(feature = "live-calibration-harness")] @@ -2215,7 +2670,7 @@ mod tests { )); } - #[cfg(not(feature = "live-calibration-harness"))] + #[cfg(all(feature = "voucher-scan", not(feature = "live-calibration-harness")))] #[test] fn default_build_has_no_outstandings_width_admission() { let runtime = TallyRuntime::default(); @@ -2674,85 +3129,6 @@ mod tests { .is_some()); } - #[cfg(feature = "fixture-canary-runtime-dispatch")] - #[test] - fn sealed_canary_dispatch_lease_excludes_ordinary_reads() { - let runtime = TallyRuntime::default(); - let config = TallyConfig { - host: "127.0.0.1".to_string(), - port: 9050, - }; - let lease = runtime - .begin_canary_dispatch(&config) - .expect("admit sealed canary dispatch"); - assert!(runtime.begin_ordinary_read(&config).is_err()); - drop(lease); - drop( - runtime - .begin_ordinary_read(&config) - .expect("read resumes after sealed dispatch"), - ); - } - - #[cfg(feature = "fixture-canary-runtime-dispatch")] - #[test] - fn ordinary_reads_exclude_sealed_canary_dispatch() { - let runtime = TallyRuntime::default(); - let config = TallyConfig { - host: "127.0.0.1".to_string(), - port: 9051, - }; - let read = runtime - .begin_ordinary_read(&config) - .expect("admit ordinary read"); - assert!(runtime.begin_canary_dispatch(&config).is_err()); - drop(read); - drop( - runtime - .begin_canary_dispatch(&config) - .expect("dispatch resumes after ordinary read"), - ); - } - - #[cfg(feature = "fixture-canary-runtime-dispatch")] - #[test] - fn sealed_canary_dispatch_lease_excludes_review_reservation_admission() { - let runtime = TallyRuntime::default(); - let config = TallyConfig { - host: "127.0.0.1".to_string(), - port: 9052, - }; - let session = runtime.session(config.clone()).expect("runtime session"); - let observed_at_unix_ms = chrono::Utc::now().timestamp_millis(); - *session.cached_probe.write().expect("capability cache") = Some(CachedProbe { - review_id: "review-canary-lease".to_string(), - observed_at_unix_ms, - freshness_origin_unix_ms: observed_at_unix_ms, - result: synthetic_probe_result(), - reserved: false, - }); - - let dispatch = runtime - .begin_canary_dispatch(&config) - .expect("admit sealed canary dispatch"); - assert!(runtime - .reserve_cached_probe_fresh(&config, "review-canary-lease", 300_000) - .is_err()); - drop(dispatch); - - let reservation = runtime - .reserve_cached_probe_fresh(&config, "review-canary-lease", 300_000) - .expect("admit reservation after sealed dispatch") - .expect("fresh reviewed probe"); - session - .canary_dispatch_active - .store(true, Ordering::Release); - assert!(reservation.authorize(&runtime, &config).is_err()); - session - .canary_dispatch_active - .store(false, Ordering::Release); - } - #[test] fn stale_guard_cannot_release_or_consume_a_newer_reserved_review() { let runtime = TallyRuntime::default(); diff --git a/src-tauri/src/tally/runtime_control.rs b/src-tauri/src/tally/runtime_control.rs new file mode 100644 index 00000000..bb43522b --- /dev/null +++ b/src-tauri/src/tally/runtime_control.rs @@ -0,0 +1,1243 @@ +//! Portable execution control for read-only Tally operations. +//! +//! Folded from the former standalone `bridge-tally-runtime` crate: it had exactly one consumer +//! (`crate::tally::runtime`), so the crate boundary earned nothing except hiding this module's +//! true dead code from the `dead_code` lint. This module still authenticates no endpoint and +//! establishes no accounting or support claim -- it controls local execution and emits only +//! fixed-cardinality, privacy-reduced observations. + +use std::{ + collections::{hash_map::DefaultHasher, HashMap}, + fmt, + future::Future, + hash::{Hash, Hasher}, + sync::{Arc, Mutex}, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +use crate::observability::{ + AttemptObservation, ObservationSink, RequestClass, ResponseOutcome, TelemetryCollector, +}; +use crate::observability::{BodyBytesObservation, CircuitRejectReason}; +use tokio::sync::{Mutex as AsyncMutex, MutexGuard}; +use tokio_util::sync::CancellationToken; + +const MAX_ENDPOINT_IDENTITY_BYTES: usize = 512; +const MAX_QUEUE_DEADLINE: Duration = Duration::from_secs(120); +const MAX_REQUEST_SPACING: Duration = Duration::from_secs(10); +const MAX_CIRCUIT_COOLDOWN: Duration = Duration::from_secs(10 * 60); +const MAX_RETRY_DELAY: Duration = Duration::from_secs(60); +pub(super) const TELEMETRY_PREVIEW_SCHEMA: &str = crate::observability::PREVIEW_SCHEMA; + +#[derive(Clone, PartialEq, Eq, Hash)] +pub(super) struct EndpointIdentity(String); + +impl EndpointIdentity { + pub(super) fn new(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() + || value.len() > MAX_ENDPOINT_IDENTITY_BYTES + || value.trim() != value + || value.chars().any(char::is_control) + { + return Err(RuntimeConfigurationError::EndpointIdentityInvalid); + } + Ok(Self(value)) + } + + fn private_value(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for EndpointIdentity { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("EndpointIdentity([redacted])") + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr( + not(test), + allow( + dead_code, + reason = "OtherRead is exercised by tests; no production call site issues it" + ) +)] +pub(super) enum ReadOperation { + Status, + Capability, + CompanyList, + MasterExport, + VoucherExport, + ReportExport, + OtherRead, +} + +impl ReadOperation { + pub(super) const fn request_class(self) -> RequestClass { + match self { + Self::Status => RequestClass::Status, + Self::Capability => RequestClass::Capability, + Self::CompanyList => RequestClass::CompanyList, + Self::MasterExport => RequestClass::MasterExport, + Self::VoucherExport => RequestClass::VoucherExport, + Self::ReportExport => RequestClass::ReportExport, + Self::OtherRead => RequestClass::OtherRead, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// Constructed only by tests today. Production never assigns this class, so +/// the failures it names are currently classified as something else and the +/// circuit breaker cannot treat them distinctly. That is a real gap, recorded +/// in the hub -- kept rather than deleted because removing a failure- +/// classification bucket cements the misclassification instead of fixing it. +#[cfg_attr( + not(test), + allow( + dead_code, + reason = "classification designed but not yet produced in production" + ) +)] +pub(super) enum ReadFailureClass { + Connection, + RequestTimeout, + RequestFailed, + HttpServer, + RateLimited, + HttpClient, + SizeLimit, + Decode, + Application, + Validation, + CompanyMismatch, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum EndpointCircuitState { + Closed, + Open, + HalfOpen, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct EndpointRuntimeSnapshot { + pub(super) consecutive_failures: u32, + pub(super) circuit_state: EndpointCircuitState, + pub(super) circuit_retry_after_unix_ms: Option, + pub(super) half_open_probe_in_flight: bool, + pub(super) last_failure_unix_ms: Option, +} + +impl ReadFailureClass { + pub(super) const fn retryable(self) -> bool { + matches!( + self, + Self::Connection + | Self::RequestTimeout + | Self::RequestFailed + | Self::HttpServer + | Self::RateLimited + ) + } + + const fn response_outcome(self) -> ResponseOutcome { + match self { + Self::Connection | Self::RequestFailed => ResponseOutcome::Transport, + Self::RequestTimeout => ResponseOutcome::Timeout, + Self::HttpServer | Self::RateLimited | Self::HttpClient => ResponseOutcome::HttpStatus, + Self::SizeLimit => ResponseOutcome::SizeLimit, + Self::Decode => ResponseOutcome::Decode, + Self::Application => ResponseOutcome::Application, + Self::Validation | Self::CompanyMismatch => ResponseOutcome::Validation, + } + } + + const fn circuit_outcome(self) -> CircuitOutcome { + match self { + Self::Connection + | Self::RequestTimeout + | Self::RequestFailed + | Self::HttpServer + | Self::RateLimited => CircuitOutcome::TransportFailure, + Self::HttpClient + | Self::SizeLimit + | Self::Decode + | Self::Application + | Self::Validation + | Self::CompanyMismatch => CircuitOutcome::ApplicationRejected, + } + } +} + +pub(super) enum ReadAttempt { + Success { + value: T, + observed_body_bytes: BodyBytesObservation, + }, + Failure { + error: E, + class: ReadFailureClass, + observed_body_bytes: BodyBytesObservation, + }, +} + +impl ReadAttempt { + fn observation(&self) -> (ResponseOutcome, BodyBytesObservation) { + match self { + Self::Success { + observed_body_bytes, + .. + } => (ResponseOutcome::Success, *observed_body_bytes), + Self::Failure { + class, + observed_body_bytes, + .. + } => (class.response_outcome(), *observed_body_bytes), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct ReadRetryPolicy { + maximum_attempts: u8, + base_delay: Duration, + maximum_delay: Duration, + jitter_percent: u8, +} + +#[cfg_attr( + not(test), + allow(dead_code, reason = "constructor and accessor used only by tests") +)] +impl ReadRetryPolicy { + pub(super) const SINGLE_ATTEMPT: Self = Self { + maximum_attempts: 1, + base_delay: Duration::ZERO, + maximum_delay: Duration::ZERO, + jitter_percent: 0, + }; + + pub(super) fn transient_default() -> Self { + Self { + maximum_attempts: 3, + base_delay: Duration::from_millis(250), + maximum_delay: Duration::from_secs(2), + jitter_percent: 20, + } + } + + pub(super) fn new( + maximum_attempts: u8, + base_delay: Duration, + maximum_delay: Duration, + jitter_percent: u8, + ) -> Result { + if maximum_attempts == 0 + || maximum_attempts > 5 + || base_delay > maximum_delay + || maximum_delay > MAX_RETRY_DELAY + || jitter_percent > 25 + { + return Err(RuntimeConfigurationError::RetryPolicyInvalid); + } + Ok(Self { + maximum_attempts, + base_delay, + maximum_delay, + jitter_percent, + }) + } + + fn delay_after_failure(self, completed_attempt: u8, entropy: u64) -> Duration { + if self.base_delay.is_zero() || completed_attempt >= self.maximum_attempts { + return Duration::ZERO; + } + let exponent = u32::from(completed_attempt.saturating_sub(1)); + let multiplier = 1_u32.checked_shl(exponent).unwrap_or(u32::MAX); + let base = self + .base_delay + .saturating_mul(multiplier) + .min(self.maximum_delay); + let jitter_ceiling = base + .as_millis() + .saturating_mul(u128::from(self.jitter_percent)) + / 100; + let jitter = if jitter_ceiling == 0 { + 0 + } else { + u128::from(entropy) % (jitter_ceiling + 1) + }; + base.saturating_add(Duration::from_millis( + u64::try_from(jitter).unwrap_or(u64::MAX), + )) + .min(MAX_RETRY_DELAY) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct RuntimePolicy { + pub(super) queue_deadline: Duration, + pub(super) request_spacing: Duration, + pub(super) circuit_failure_threshold: u32, + pub(super) circuit_cooldown: Duration, + pub(super) maximum_endpoint_sessions: usize, +} + +impl Default for RuntimePolicy { + fn default() -> Self { + Self { + queue_deadline: Duration::from_secs(30), + request_spacing: Duration::from_millis(500), + circuit_failure_threshold: 3, + circuit_cooldown: Duration::from_secs(10), + maximum_endpoint_sessions: 32, + } + } +} + +impl RuntimePolicy { + fn validate(self) -> Result { + if self.queue_deadline.is_zero() + || self.queue_deadline > MAX_QUEUE_DEADLINE + || self.request_spacing > MAX_REQUEST_SPACING + || self.circuit_failure_threshold == 0 + || self.circuit_failure_threshold > 100 + || self.circuit_cooldown.is_zero() + || self.circuit_cooldown > MAX_CIRCUIT_COOLDOWN + || self.maximum_endpoint_sessions == 0 + || self.maximum_endpoint_sessions > 128 + { + return Err(RuntimeConfigurationError::RuntimePolicyInvalid); + } + Ok(self) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +// `Invalid` postfix is the point: every variant names an invalid input. +#[allow(clippy::enum_variant_names)] +#[cfg_attr( + not(test), + allow( + dead_code, + reason = "RetryPolicyInvalid is unreachable while retry policies are constructed internally" + ) +)] +pub(super) enum RuntimeConfigurationError { + EndpointIdentityInvalid, + RetryPolicyInvalid, + RuntimePolicyInvalid, +} + +impl RuntimeConfigurationError { + pub(super) const fn safe_code(self) -> &'static str { + match self { + Self::EndpointIdentityInvalid => "endpoint_identity_invalid", + Self::RetryPolicyInvalid => "read_retry_policy_invalid", + Self::RuntimePolicyInvalid => "endpoint_runtime_policy_invalid", + } + } +} + +impl fmt::Display for RuntimeConfigurationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.safe_code()) + } +} + +impl std::error::Error for RuntimeConfigurationError {} + +#[derive(Debug)] +pub(super) enum ReadExecutionError { + QueueDeadline, + Cancelled, + CircuitRejected { reason: CircuitRejectReason }, + EndpointSessionLimit, + Attempt(E), +} + +impl ReadExecutionError {} + +struct GateState { + next_request_not_before: Option, +} + +struct SpacingGuard<'a> { + state: MutexGuard<'a, GateState>, + spacing: Duration, +} + +impl Drop for SpacingGuard<'_> { + fn drop(&mut self) { + self.state.next_request_not_before = Some(Instant::now() + self.spacing); + } +} + +#[derive(Default)] +struct CircuitState { + consecutive_failures: u32, + last_failure_unix_ms: Option, + half_open_probe_in_flight: bool, +} + +struct CircuitBreaker { + state: Mutex, + threshold: u32, + cooldown: Duration, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CircuitOutcome { + TransportSuccess, + TransportFailure, + ApplicationRejected, + Cancelled, +} + +struct CircuitPermit<'a> { + circuit: &'a CircuitBreaker, + half_open: bool, + completed: bool, +} + +impl CircuitBreaker { + fn admit( + &self, + now_unix_ms: i64, + ) -> Result, (CircuitRejectReason, Option)> { + let mut state = self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if state.consecutive_failures < self.threshold { + return Ok(CircuitPermit { + circuit: self, + half_open: false, + completed: false, + }); + } + let retry_after = state + .last_failure_unix_ms + .unwrap_or(now_unix_ms) + .saturating_add(duration_millis_i64(self.cooldown)); + if now_unix_ms < retry_after { + return Err((CircuitRejectReason::Cooldown, Some(retry_after))); + } + if state.half_open_probe_in_flight { + return Err((CircuitRejectReason::HalfOpenProbeInFlight, None)); + } + state.half_open_probe_in_flight = true; + Ok(CircuitPermit { + circuit: self, + half_open: true, + completed: false, + }) + } + + fn record(&self, outcome: CircuitOutcome, now_unix_ms: i64) { + let mut state = self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + state.half_open_probe_in_flight = false; + match outcome { + CircuitOutcome::TransportSuccess => { + state.consecutive_failures = 0; + } + CircuitOutcome::TransportFailure => { + state.consecutive_failures = state.consecutive_failures.saturating_add(1); + state.last_failure_unix_ms = Some(now_unix_ms); + } + CircuitOutcome::ApplicationRejected | CircuitOutcome::Cancelled => {} + } + } + + fn release_half_open(&self) { + let mut state = self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + state.half_open_probe_in_flight = false; + } + + fn snapshot(&self, now_unix_ms: i64) -> EndpointRuntimeSnapshot { + let state = self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let retry_after = state + .last_failure_unix_ms + .map(|last_failure| last_failure.saturating_add(duration_millis_i64(self.cooldown))); + let circuit_state = if state.consecutive_failures < self.threshold { + EndpointCircuitState::Closed + } else if retry_after.is_some_and(|deadline| now_unix_ms < deadline) { + EndpointCircuitState::Open + } else { + EndpointCircuitState::HalfOpen + }; + EndpointRuntimeSnapshot { + consecutive_failures: state.consecutive_failures, + circuit_state, + circuit_retry_after_unix_ms: match circuit_state { + EndpointCircuitState::Open => retry_after, + EndpointCircuitState::Closed | EndpointCircuitState::HalfOpen => None, + }, + half_open_probe_in_flight: state.half_open_probe_in_flight, + last_failure_unix_ms: state.last_failure_unix_ms, + } + } +} + +impl CircuitPermit<'_> { + fn complete(mut self, outcome: CircuitOutcome, now_unix_ms: i64) { + self.circuit.record(outcome, now_unix_ms); + self.completed = true; + } +} + +impl Drop for CircuitPermit<'_> { + fn drop(&mut self) { + if self.half_open && !self.completed { + self.circuit.release_half_open(); + } + } +} + +struct EndpointSession { + gate: AsyncMutex, + circuit: CircuitBreaker, + sequence: std::sync::atomic::AtomicU64, +} + +impl EndpointSession { + fn new(policy: RuntimePolicy) -> Self { + Self { + gate: AsyncMutex::new(GateState { + next_request_not_before: None, + }), + circuit: CircuitBreaker { + state: Mutex::new(CircuitState::default()), + threshold: policy.circuit_failure_threshold, + cooldown: policy.circuit_cooldown, + }, + sequence: std::sync::atomic::AtomicU64::new(0), + } + } +} + +struct SessionSlot { + session: Arc, + last_used: Instant, +} + +#[derive(Clone)] +pub(super) struct PortableReadRuntime { + sessions: Arc>>, + collector: Arc, + policy: RuntimePolicy, +} + +impl fmt::Debug for PortableReadRuntime { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PortableReadRuntime") + .field("policy", &self.policy) + .finish_non_exhaustive() + } +} + +impl Default for PortableReadRuntime { + fn default() -> Self { + Self::new(RuntimePolicy::default()).expect("default runtime policy is valid") + } +} + +impl PortableReadRuntime { + pub(super) fn new(policy: RuntimePolicy) -> Result { + Self::with_collector(policy, Arc::new(TelemetryCollector::new())) + } + + pub(super) fn with_collector( + policy: RuntimePolicy, + collector: Arc, + ) -> Result { + Ok(Self { + sessions: Arc::new(Mutex::new(HashMap::new())), + collector, + policy: policy.validate()?, + }) + } + + pub(super) fn collector(&self) -> Arc { + Arc::clone(&self.collector) + } + + pub(super) fn endpoint_snapshot( + &self, + endpoint: &EndpointIdentity, + ) -> Option { + let sessions = self + .sessions + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + sessions + .get(endpoint) + .map(|slot| slot.session.circuit.snapshot(now_unix_ms())) + } + + pub(super) async fn execute_read( + &self, + endpoint: EndpointIdentity, + operation: ReadOperation, + retry: ReadRetryPolicy, + cancellation: CancellationToken, + mut request: F, + ) -> Result> + where + F: FnMut(u8) -> Fut, + Fut: Future>, + { + let session = self + .session(&endpoint) + .map_err(|()| ReadExecutionError::EndpointSessionLimit)?; + let sequence = session + .sequence + .fetch_add(1, std::sync::atomic::Ordering::Relaxed) + .saturating_add(1); + let class = operation.request_class(); + let mut attempt_number = 1_u8; + loop { + let attempt = match self + .run_queued(&session, class, &cancellation, || request(attempt_number)) + .await + { + Ok(attempt) => attempt, + Err(error) => return Err(error), + }; + match attempt { + ReadAttempt::Success { value, .. } => return Ok(value), + ReadAttempt::Failure { error, class, .. } => { + if !class.retryable() || attempt_number >= retry.maximum_attempts { + return Err(ReadExecutionError::Attempt(error)); + } + let entropy = retry_entropy(&endpoint, sequence, attempt_number); + let delay = retry.delay_after_failure(attempt_number, entropy); + attempt_number = attempt_number.saturating_add(1); + if !delay.is_zero() { + tokio::select! { + _ = cancellation.cancelled() => return Err(ReadExecutionError::Cancelled), + _ = tokio::time::sleep(delay) => {} + } + } + } + } + } + } + + fn session(&self, endpoint: &EndpointIdentity) -> Result, ()> { + let mut sessions = self + .sessions + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(slot) = sessions.get_mut(endpoint) { + slot.last_used = Instant::now(); + return Ok(Arc::clone(&slot.session)); + } + if sessions.len() >= self.policy.maximum_endpoint_sessions { + let oldest = sessions + .iter() + .filter(|(_, slot)| Arc::strong_count(&slot.session) == 1) + .min_by_key(|(_, slot)| slot.last_used) + .map(|(identity, _)| identity.clone()); + if let Some(identity) = oldest { + sessions.remove(&identity); + } else { + return Err(()); + } + } + let session = Arc::new(EndpointSession::new(self.policy)); + sessions.insert( + endpoint.clone(), + SessionSlot { + session: Arc::clone(&session), + last_used: Instant::now(), + }, + ); + Ok(session) + } + + async fn run_queued( + &self, + session: &EndpointSession, + class: RequestClass, + cancellation: &CancellationToken, + request: F, + ) -> Result, ReadExecutionError> + where + F: FnOnce() -> Fut, + Fut: Future>, + { + let queued_at = Instant::now(); + let state = tokio::select! { + _ = cancellation.cancelled() => { + self.collector.record_attempt(AttemptObservation::QueueCancelled { + class, + queue_wait: queued_at.elapsed(), + }); + return Err(ReadExecutionError::Cancelled); + } + result = tokio::time::timeout(self.policy.queue_deadline, session.gate.lock()) => { + match result { + Ok(state) => state, + Err(_) => { + self.collector.record_attempt(AttemptObservation::QueueDeadline { + class, + queue_wait: queued_at.elapsed(), + }); + return Err(ReadExecutionError::QueueDeadline); + } + } + } + }; + let queue_wait = queued_at.elapsed(); + if let Some(spacing_wait) = state + .next_request_not_before + .and_then(|not_before| not_before.checked_duration_since(Instant::now())) + { + let remaining = self + .policy + .queue_deadline + .checked_sub(queued_at.elapsed()) + .ok_or_else(|| { + self.collector + .record_attempt(AttemptObservation::QueueDeadline { class, queue_wait }); + ReadExecutionError::QueueDeadline + })?; + let wait = spacing_wait.min(remaining); + tokio::select! { + _ = cancellation.cancelled() => { + self.collector.record_attempt(AttemptObservation::QueueCancelled { + class, + queue_wait, + }); + return Err(ReadExecutionError::Cancelled); + } + _ = tokio::time::sleep(wait) => {} + } + if wait < spacing_wait { + self.collector + .record_attempt(AttemptObservation::QueueDeadline { class, queue_wait }); + return Err(ReadExecutionError::QueueDeadline); + } + } + let permit = match session.circuit.admit(now_unix_ms()) { + Ok(permit) => permit, + // The circuit also yields a retry-after timestamp here. Nothing + // consumes it -- every caller destructured it away -- so it is not + // carried on the error. Surfacing "try again in N seconds" to the + // operator is a real gap, tracked in the hub; wire it back through + // when the UI can act on it rather than keeping an unread field. + Err((reason, _retry_after_unix_ms)) => { + self.collector + .record_attempt(AttemptObservation::CircuitRejected { class, reason }); + return Err(ReadExecutionError::CircuitRejected { reason }); + } + }; + let _guard = SpacingGuard { + state, + spacing: self.policy.request_spacing, + }; + let response_started = Instant::now(); + let attempt = tokio::select! { + _ = cancellation.cancelled() => { + self.collector.record_attempt(AttemptObservation::Response { + class, + queue_wait, + outcome: ResponseOutcome::Cancelled, + response_pipeline_elapsed: response_started.elapsed(), + observed_body_bytes: BodyBytesObservation::Unavailable, + }); + permit.complete(CircuitOutcome::Cancelled, now_unix_ms()); + return Err(ReadExecutionError::Cancelled); + } + attempt = request() => attempt, + }; + let (outcome, observed_body_bytes) = attempt.observation(); + self.collector.record_attempt(AttemptObservation::Response { + class, + queue_wait, + outcome, + response_pipeline_elapsed: response_started.elapsed(), + observed_body_bytes, + }); + let circuit_outcome = match &attempt { + ReadAttempt::Success { .. } => CircuitOutcome::TransportSuccess, + ReadAttempt::Failure { class, .. } => class.circuit_outcome(), + }; + permit.complete(circuit_outcome, now_unix_ms()); + Ok(attempt) + } +} + +fn now_unix_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .and_then(|duration| i64::try_from(duration.as_millis()).ok()) + .unwrap_or(i64::MAX) +} + +fn duration_millis_i64(duration: Duration) -> i64 { + i64::try_from(duration.as_millis()).unwrap_or(i64::MAX) +} + +fn retry_entropy(endpoint: &EndpointIdentity, sequence: u64, attempt: u8) -> u64 { + let mut hasher = DefaultHasher::new(); + endpoint.private_value().hash(&mut hasher); + sequence.hash(&mut hasher); + attempt.hash(&mut hasher); + hasher.finish() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + fn test_runtime(spacing: Duration, threshold: u32) -> PortableReadRuntime { + PortableReadRuntime::new(RuntimePolicy { + queue_deadline: Duration::from_secs(1), + request_spacing: spacing, + circuit_failure_threshold: threshold, + circuit_cooldown: Duration::from_millis(50), + maximum_endpoint_sessions: 4, + }) + .unwrap() + } + + fn endpoint(value: &str) -> EndpointIdentity { + EndpointIdentity::new(value).unwrap() + } + + #[tokio::test] + async fn same_endpoint_serializes_while_distinct_endpoints_are_independent() { + let runtime = test_runtime(Duration::ZERO, 3); + let in_flight = Arc::new(AtomicUsize::new(0)); + let same_max = Arc::new(AtomicUsize::new(0)); + let run = |runtime: PortableReadRuntime, + endpoint: EndpointIdentity, + in_flight: Arc, + maximum: Arc| async move { + runtime + .execute_read( + endpoint, + ReadOperation::CompanyList, + ReadRetryPolicy::SINGLE_ATTEMPT, + CancellationToken::new(), + |_| { + let in_flight = Arc::clone(&in_flight); + let maximum = Arc::clone(&maximum); + async move { + let active = in_flight.fetch_add(1, Ordering::SeqCst) + 1; + maximum.fetch_max(active, Ordering::SeqCst); + tokio::time::sleep(Duration::from_millis(20)).await; + in_flight.fetch_sub(1, Ordering::SeqCst); + ReadAttempt::<_, ()>::Success { + value: (), + observed_body_bytes: BodyBytesObservation::Observed(10), + } + } + }, + ) + .await + }; + let (first, second) = tokio::join!( + run( + runtime.clone(), + endpoint("loopback-a"), + Arc::clone(&in_flight), + Arc::clone(&same_max) + ), + run( + runtime.clone(), + endpoint("loopback-a"), + Arc::clone(&in_flight), + Arc::clone(&same_max) + ) + ); + first.unwrap(); + second.unwrap(); + assert_eq!(same_max.load(Ordering::SeqCst), 1); + + let distinct_max = Arc::new(AtomicUsize::new(0)); + let (first, second) = tokio::join!( + run( + runtime.clone(), + endpoint("loopback-a"), + Arc::clone(&in_flight), + Arc::clone(&distinct_max) + ), + run( + runtime, + endpoint("loopback-b"), + in_flight, + Arc::clone(&distinct_max) + ) + ); + first.unwrap(); + second.unwrap(); + assert_eq!(distinct_max.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn transient_reads_retry_exactly_but_validation_never_retries() { + let runtime = test_runtime(Duration::ZERO, 100); + let attempts = Arc::new(AtomicUsize::new(0)); + let observed = Arc::clone(&attempts); + let result = runtime + .execute_read( + endpoint("retry-endpoint"), + ReadOperation::VoucherExport, + ReadRetryPolicy::new(3, Duration::ZERO, Duration::ZERO, 0).unwrap(), + CancellationToken::new(), + move |_| { + let observed = Arc::clone(&observed); + async move { + let attempt = observed.fetch_add(1, Ordering::SeqCst) + 1; + if attempt < 3 { + ReadAttempt::Failure { + error: "transient", + class: ReadFailureClass::RequestTimeout, + observed_body_bytes: BodyBytesObservation::Unavailable, + } + } else { + ReadAttempt::Success { + value: "ok", + observed_body_bytes: BodyBytesObservation::Observed(12), + } + } + } + }, + ) + .await + .unwrap(); + assert_eq!(result, "ok"); + assert_eq!(attempts.load(Ordering::SeqCst), 3); + + let attempts = Arc::new(AtomicUsize::new(0)); + let observed = Arc::clone(&attempts); + let error = runtime + .execute_read( + endpoint("validation-endpoint"), + ReadOperation::MasterExport, + ReadRetryPolicy::new(3, Duration::ZERO, Duration::ZERO, 0).unwrap(), + CancellationToken::new(), + move |_| { + let observed = Arc::clone(&observed); + async move { + observed.fetch_add(1, Ordering::SeqCst); + ReadAttempt::<(), _>::Failure { + error: "company_mismatch", + class: ReadFailureClass::CompanyMismatch, + observed_body_bytes: BodyBytesObservation::Observed(100), + } + } + }, + ) + .await + .unwrap_err(); + assert!(matches!( + error, + ReadExecutionError::Attempt("company_mismatch") + )); + assert_eq!(attempts.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn cancellation_is_terminal_and_preserves_follow_up_spacing() { + let spacing = Duration::from_millis(60); + let runtime = test_runtime(spacing, 3); + let cancellation = CancellationToken::new(); + let cancel = cancellation.clone(); + let running = { + let runtime = runtime.clone(); + tokio::spawn(async move { + runtime + .execute_read( + endpoint("cancel-endpoint"), + ReadOperation::ReportExport, + ReadRetryPolicy::SINGLE_ATTEMPT, + cancellation, + |_| async { std::future::pending::>().await }, + ) + .await + }) + }; + tokio::time::sleep(Duration::from_millis(10)).await; + cancel.cancel(); + assert!(matches!( + running.await.unwrap(), + Err(ReadExecutionError::Cancelled) + )); + let started = Instant::now(); + runtime + .execute_read( + endpoint("cancel-endpoint"), + ReadOperation::ReportExport, + ReadRetryPolicy::SINGLE_ATTEMPT, + CancellationToken::new(), + |_| async { + ReadAttempt::<_, ()>::Success { + value: (), + observed_body_bytes: BodyBytesObservation::Observed(0), + } + }, + ) + .await + .unwrap(); + assert!(started.elapsed() >= spacing.saturating_sub(Duration::from_millis(10))); + } + + #[tokio::test] + async fn circuit_cooldown_and_single_half_open_probe_are_enforced() { + let runtime = test_runtime(Duration::ZERO, 1); + let fail = || async { + ReadAttempt::<(), _>::Failure { + error: "offline", + class: ReadFailureClass::Connection, + observed_body_bytes: BodyBytesObservation::Unavailable, + } + }; + let _ = runtime + .execute_read( + endpoint("circuit-endpoint"), + ReadOperation::CompanyList, + ReadRetryPolicy::SINGLE_ATTEMPT, + CancellationToken::new(), + |_| fail(), + ) + .await; + let rejected = runtime + .execute_read( + endpoint("circuit-endpoint"), + ReadOperation::CompanyList, + ReadRetryPolicy::SINGLE_ATTEMPT, + CancellationToken::new(), + |_| fail(), + ) + .await + .unwrap_err(); + assert!(matches!( + rejected, + ReadExecutionError::CircuitRejected { + reason: CircuitRejectReason::Cooldown, + .. + } + )); + tokio::time::sleep(Duration::from_millis(60)).await; + let entered = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let first = { + let runtime = runtime.clone(); + let entered = Arc::clone(&entered); + let release = Arc::clone(&release); + tokio::spawn(async move { + runtime + .execute_read( + endpoint("circuit-endpoint"), + ReadOperation::CompanyList, + ReadRetryPolicy::SINGLE_ATTEMPT, + CancellationToken::new(), + |_| { + let entered = Arc::clone(&entered); + let release = Arc::clone(&release); + async move { + entered.notify_one(); + release.notified().await; + ReadAttempt::<_, ()>::Success { + value: (), + observed_body_bytes: BodyBytesObservation::Observed(1), + } + } + }, + ) + .await + }) + }; + entered.notified().await; + let second = { + let runtime = runtime.clone(); + tokio::spawn(async move { + runtime + .execute_read( + endpoint("circuit-endpoint"), + ReadOperation::CompanyList, + ReadRetryPolicy::SINGLE_ATTEMPT, + CancellationToken::new(), + |_| async { + ReadAttempt::<_, ()>::Success { + value: (), + observed_body_bytes: BodyBytesObservation::Observed(1), + } + }, + ) + .await + }) + }; + tokio::task::yield_now().await; + assert!( + !second.is_finished(), + "a second probe must remain serialized" + ); + release.notify_one(); + first.await.unwrap().unwrap(); + second.await.unwrap().unwrap(); + } + + #[tokio::test] + async fn queued_request_cannot_use_a_stale_closed_circuit_admission() { + let runtime = test_runtime(Duration::ZERO, 1); + let entered = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let first = { + let runtime = runtime.clone(); + let entered = Arc::clone(&entered); + let release = Arc::clone(&release); + tokio::spawn(async move { + runtime + .execute_read( + endpoint("stale-admission-endpoint"), + ReadOperation::CompanyList, + ReadRetryPolicy::SINGLE_ATTEMPT, + CancellationToken::new(), + |_| { + let entered = Arc::clone(&entered); + let release = Arc::clone(&release); + async move { + entered.notify_one(); + release.notified().await; + ReadAttempt::<(), _>::Failure { + error: "offline", + class: ReadFailureClass::Connection, + observed_body_bytes: BodyBytesObservation::Unavailable, + } + } + }, + ) + .await + }) + }; + entered.notified().await; + let executed = Arc::new(AtomicUsize::new(0)); + let second = { + let runtime = runtime.clone(); + let executed = Arc::clone(&executed); + tokio::spawn(async move { + runtime + .execute_read( + endpoint("stale-admission-endpoint"), + ReadOperation::CompanyList, + ReadRetryPolicy::SINGLE_ATTEMPT, + CancellationToken::new(), + move |_| { + let executed = Arc::clone(&executed); + async move { + executed.fetch_add(1, Ordering::SeqCst); + ReadAttempt::<_, ()>::Success { + value: (), + observed_body_bytes: BodyBytesObservation::Observed(1), + } + } + }, + ) + .await + }) + }; + tokio::task::yield_now().await; + release.notify_one(); + assert!(matches!( + first.await.unwrap(), + Err(ReadExecutionError::Attempt("offline")) + )); + assert!(matches!( + second.await.unwrap(), + Err(ReadExecutionError::CircuitRejected { + reason: CircuitRejectReason::Cooldown, + .. + }) + )); + assert_eq!(executed.load(Ordering::SeqCst), 0); + } + + #[test] + fn circuit_breaker_rejects_a_concurrent_half_open_permit() { + let breaker = CircuitBreaker { + state: Mutex::new(CircuitState { + consecutive_failures: 1, + last_failure_unix_ms: Some(now_unix_ms().saturating_sub(100)), + half_open_probe_in_flight: false, + }), + threshold: 1, + cooldown: Duration::from_millis(50), + }; + let permit = breaker + .admit(now_unix_ms()) + .expect("first half-open permit"); + assert!(matches!( + breaker.admit(now_unix_ms()), + Err((CircuitRejectReason::HalfOpenProbeInFlight, None)) + )); + drop(permit); + assert!(breaker.admit(now_unix_ms()).is_ok()); + } + + #[tokio::test] + async fn deterministic_runtime_sequence_retries_server_failure_then_succeeds() { + let runtime = test_runtime(Duration::ZERO, 3); + let attempts = Arc::new(AtomicUsize::new(0)); + let observed_attempts = Arc::clone(&attempts); + let xml = runtime + .execute_read( + endpoint("sequence-endpoint"), + ReadOperation::ReportExport, + ReadRetryPolicy::new(2, Duration::ZERO, Duration::ZERO, 0).unwrap(), + CancellationToken::new(), + move |_| { + let observed_attempts = Arc::clone(&observed_attempts); + async move { + if observed_attempts.fetch_add(1, Ordering::SeqCst) == 0 { + ReadAttempt::Failure { + error: "http_server_failure", + class: ReadFailureClass::HttpServer, + observed_body_bytes: BodyBytesObservation::Observed(64), + } + } else { + ReadAttempt::Success { + value: "1", + observed_body_bytes: BodyBytesObservation::Observed(18), + } + } + } + }, + ) + .await + .expect("second deterministic response succeeds"); + assert!(xml.contains("1")); + assert_eq!(attempts.load(Ordering::SeqCst), 2); + } + + #[test] + fn operation_and_retry_surface_are_read_only_and_redacted() { + assert_eq!(ReadOperation::Status.request_class(), RequestClass::Status); + assert!(ReadFailureClass::HttpServer.retryable()); + assert!(!ReadFailureClass::Application.retryable()); + assert!(format!("{:?}", endpoint("sensitive-loopback")).contains("[redacted]")); + assert_eq!( + ReadRetryPolicy::new(0, Duration::ZERO, Duration::ZERO, 0), + Err(RuntimeConfigurationError::RetryPolicyInvalid) + ); + } +} diff --git a/src-tauri/tests/unit_a_live.rs b/src-tauri/tests/unit_a_live.rs index a6b6b76b..b5f6195b 100644 --- a/src-tauri/tests/unit_a_live.rs +++ b/src-tauri/tests/unit_a_live.rs @@ -1,3 +1,11 @@ +//! Manual, owner-authorized live-capture verification for the legacy +//! voucher-scan outstandings path. Every test here exercises `outstandings` +//! scan machinery (the wildcard voucher request, segment-pair verification, +//! or the calibration harness itself), so the whole file is gated behind +//! `voucher-scan` -- with the feature off, `bridge_tally_protocol::outstandings` +//! does not exist and there is nothing left here to run. +#![cfg(feature = "voucher-scan")] + #[cfg(feature = "live-calibration-harness")] use bridge_lib::tally::{ OutstandingsCurrencyAssertion, OutstandingsLoadResult, TallyConfig, TallyRuntime,