diff --git a/docs/tally/TALLY_PROTOCOL_REFERENCE.md b/docs/tally/TALLY_PROTOCOL_REFERENCE.md index abf452b9..82de0f2a 100644 --- a/docs/tally/TALLY_PROTOCOL_REFERENCE.md +++ b/docs/tally/TALLY_PROTOCOL_REFERENCE.md @@ -1802,7 +1802,9 @@ carries no such flag, and adding one would mean authoring a request shape with n behind it. Every party amount therefore lands On Account, exactly as the measured import did, and every build naming a counterparty says so in its warnings. -**Two written elements are not verified: `EFFECTIVEDATE` and `PARTYLEDGERNAME`.** The +**Two written elements are not verified: `EFFECTIVEDATE` and `PARTYLEDGERNAME`.** *(Written before +#467 and #469. `EFFECTIVEDATE` is now verified and `PARTYLEDGERNAME` is still not; see the two +scoped corrections below.)* The verification collection of §9.8 fetches neither, so `verify_import` compares the date, voucher type and signed entries and cannot see whether Tally kept, rewrote or dropped either — nor whether an operator later edited them. A readback with a wrong effective date, or a party @@ -1841,7 +1843,8 @@ the `FETCH` list and looks at what arrives. > | Contra | `20250423` / `20250423` | `20250423`, `TYPE="Date"` | none written | the debit bank ledger (`HDFC CC`) | > > - **`EFFECTIVEDATE` is returned, equal to `DATE`**, on all three types. Comparing it is now -> possible. `verify_import` still does not fetch it, so the limit above still holds for the code. +> possible. `verify_import` did not fetch it when this read was taken; #469 closed that, see the +> next correction. > - **`PARTYLEDGERNAME` is returned but does not echo what was written.** On these vouchers it held > a cash or bank ledger that was on the voucher, not the counterparty Bridge wrote. **Do not > compare it with the written value:** that comparison would refuse every one of these legitimate @@ -1852,6 +1855,19 @@ the `FETCH` list and looks at what arrives. > One read, one company, one release, three Bridge-built vouchers in a six-voucher window. It does not establish > what a Tally UI edit to either field returns. +> **Scoped correction, 2026-09-17 — `EFFECTIVEDATE` is now verified (#469).** A live read +> (#467: licensed 7.1 Silver, synthetic lab) returned `EFFECTIVEDATE` with `TYPE="Date"`, equal +> to `DATE`, on a Bridge-built Receipt, Payment and Contra. The committed capture +> `native-three-vouchers.utf16le.xml` also carries it on two of its three vouchers. The §9.8 +> verification read now appends `EFFECTIVEDATE` to its `FETCH`. For Payment, Receipt and Contra, a +> returned value that differs from the written date is an `effective_date` diff, in `verify_import` +> and in the amendment compare-and-swap. An absent or empty element is **not** a diff, because +> requiring it would refuse every verification on a release that does not return it; the row +> reports `"not_observed": ["effective_date"]` instead. A Journal is written without the element +> and is neither compared nor flagged. The public voucher tools do not fetch it or return it. +> `PARTYLEDGERNAME` stays unfetched: on the same read it held a bank ledger, not the written +> counterparty. + ### 9.9 Bulk import throughput **VERIFIED.** One import request may carry many `` elements; the counters aggregate. diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index a4066dae..c9d891ac 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": "dd09546c46de8262ea8249e614748c89224b42746863d21dbb0e305f976b7478", + "compatibility_surface_sha256": "b44131d63a4d7384808982ceedb19ae19dce525f55c8ac349e5b270a3ac9b9d6", "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 5cc8414a..12e9abe2 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -39,7 +39,7 @@ }, { "path": "docs/tally/TALLY_PROTOCOL_REFERENCE.md", - "sha256": "e0b65e7530f876f351b4a1a970ded87dca5d0a757e53c8a03823add0a5d84874" + "sha256": "75bc5a116ad025cd5cc05e731ce4b9d1b967390df0f6010e71bd2548cb59908f" }, { "path": "docs/tally/compatibility/README.md", @@ -431,11 +431,11 @@ }, { "path": "src-tauri/src/agent_import.rs", - "sha256": "0f8c49d42191fe3ebd52ee71e966be77ef8369f5ac2eebd1ab705dbf51d765c6" + "sha256": "248276bac362d6aabc4476289da77ffe411f4292469700b8560ffb67bd14ad8c" }, { "path": "src-tauri/src/agent_import_amend.rs", - "sha256": "7c4093d938d62b2fcf297c72e5e6acd59a9875ac8df6b3186c24b226c515b83c" + "sha256": "4a64f56f5a7392fdeeaf3955eb1e957778fdd9e99e92a2b0c148521c27ec339a" }, { "path": "src-tauri/src/agent_import_cash_bank.rs", @@ -511,15 +511,15 @@ }, { "path": "src-tauri/src/agent_voucher_parse.rs", - "sha256": "c3dced60925238d8721f1a5f5130f0ab01b7feda58f1e4c1d3c9af78066c4abc" + "sha256": "8bef4ec351614a9169fc3009eea23c815db223afdb8bc8c94d8f2724a518b797" }, { "path": "src-tauri/src/agent_voucher_parse_tests.rs", - "sha256": "58209e929c0643bd54767193f6561a89bdfa9626680c80829ca043e0d9c54bf6" + "sha256": "841d79fec36e78fed33d9ed871d965033a6a21d414013337408038b338e1cc59" }, { "path": "src-tauri/src/agent_voucher_scalars.rs", - "sha256": "464226a1eff411777c6a6f775e83ade3e2f8c38ed9489056c671b1516a3e77e8" + "sha256": "0bfcc56dc814553adc302897fc56eb299549ceab1f782ee8211730694f7e390a" }, { "path": "src-tauri/src/agent_vouchers.rs", @@ -1066,5 +1066,5 @@ "sha256": "0ab5bfbe6d81b1e10530744c338fb91efa57fe5a66fb4bc0cfd382495eaf02a4" } ], - "manifest_sha256": "dd09546c46de8262ea8249e614748c89224b42746863d21dbb0e305f976b7478" + "manifest_sha256": "b44131d63a4d7384808982ceedb19ae19dce525f55c8ac349e5b270a3ac9b9d6" } \ No newline at end of file diff --git a/src-tauri/src/agent_import.rs b/src-tauri/src/agent_import.rs index cc7a89ba..0b8c95a5 100644 --- a/src-tauri/src/agent_import.rs +++ b/src-tauri/src/agent_import.rs @@ -273,6 +273,9 @@ struct ReadVoucher { master_id: Option, cancelled: Option, optional: Option, + /// Absent when the response carried no `EFFECTIVEDATE` (or an empty one). + #[serde(default)] + effective_date: Option, #[serde(rename = "amounts")] entries: Vec, } @@ -2004,7 +2007,7 @@ fn render_voucher_xml(voucher: &ImportVoucher, remote_id: Uuid, attribution_id: } fn render_import_verification_read(company: &str, from: &str, to: &str) -> String { - format!("
1ExportCollectionBridge Agent Import Verification
$$SysName:XML{}{from}{to}$Date >= $$Date:\"{from}\" AND $Date <= $$Date:\"{to}\"VoucherDATE,VOUCHERNUMBER,VOUCHERTYPENAME,REMOTEID,GUID,MASTERID,ALTERID,NARRATION,ISCANCELLED,ISOPTIONAL,ALLLEDGERENTRIES.LEDGERNAME,ALLLEDGERENTRIES.AMOUNT,ALLLEDGERENTRIES.ISDEEMEDPOSITIVEBridgeImportWindow
", xml_escape(company)) + format!("
1ExportCollectionBridge Agent Import Verification
$$SysName:XML{}{from}{to}$Date >= $$Date:\"{from}\" AND $Date <= $$Date:\"{to}\"VoucherDATE,VOUCHERNUMBER,VOUCHERTYPENAME,REMOTEID,GUID,MASTERID,ALTERID,NARRATION,ISCANCELLED,ISOPTIONAL,ALLLEDGERENTRIES.LEDGERNAME,ALLLEDGERENTRIES.AMOUNT,ALLLEDGERENTRIES.ISDEEMEDPOSITIVE,EFFECTIVEDATEBridgeImportWindow
", xml_escape(company)) } fn xml_escape(value: &str) -> String { @@ -2017,7 +2020,7 @@ fn xml_escape(value: &str) -> String { } fn parse_import_vouchers(xml: &str, company_guid: &str) -> Result { - let parsed = super::parse_agent_changed_rows(xml, company_guid).map_err(|code| { + let parsed = super::parse_import_verification_rows(xml, company_guid).map_err(|code| { match code.as_str() { // Preserve the import error contract while sharing scalar admission. "agent_read_protocol_invalid" if super::validate_agent_envelope(xml).is_err() => { @@ -2026,6 +2029,7 @@ fn parse_import_vouchers(xml: &str, company_guid: &str) -> Result "import_verification_export_invalid", "change_row_identity_invalid" | "voucher_source_identity_invalid" @@ -2276,12 +2280,13 @@ fn verify_batch(line: &ImportLedgerLine, observed: &ImportReadSource) -> Result< group.consume(matched_index); } let matched = &observed[matched_index]; + let effective_date_unobserved = effective_date_not_observed(expected, matched); let diffs = voucher_diffs( expected, matched, expected_key.2 == observed_fingerprints[matched_index].2, ); - if fingerprint_fallback { + let mut matched_value = if fingerprint_fallback { counts .entry("matching_content_observed") .and_modify(|count| *count += 1); @@ -2302,7 +2307,11 @@ fn verify_batch(line: &ImportLedgerLine, observed: &ImportReadSource) -> Result< .entry("posted_divergent") .and_modify(|count| *count += 1); json!({"bridge_txn_id":expected.bridge_txn_id,"status":"posted_divergent","marker":marker,"diffs":diffs,"voucher_number":matched.voucher_number,"guid":matched.guid,"master_id":matched.master_id}) + }; + if effective_date_unobserved { + matched_value["not_observed"] = json!(["effective_date"]); } + matched_value }; if fingerprint_fallback && fingerprint_ambiguous_within_batch { value["ambiguous_within_batch"] = Value::Bool(true); @@ -2403,15 +2412,34 @@ fn batch_duplicate_sets( ) } +/// A bank voucher whose readback carried no `EFFECTIVEDATE`: its effective +/// date was written but could not be compared, which a clean status must not hide. +fn effective_date_not_observed(expected: &ImportVoucher, actual: &ReadVoucher) -> bool { + expected.voucher_type != VoucherType::Journal && actual.effective_date.is_none() +} + fn voucher_diffs( expected: &ImportVoucher, actual: &ReadVoucher, entries_match: bool, ) -> Vec { let mut diffs = Vec::new(); - if actual.date.as_deref() != normalized_date(&expected.date).ok().as_deref() { + let expected_date = normalized_date(&expected.date).ok(); + if actual.date.as_deref() != expected_date.as_deref() { diffs.push(json!("date")); } + // A bank voucher is written with EFFECTIVEDATE equal to DATE (§9.13), and + // the verification read returns it (§9.8 scoped correction). Only a value + // that came back and differs is a diff: a response without the element + // is reported as not observed, never refused. + if expected.voucher_type != VoucherType::Journal + && actual + .effective_date + .as_deref() + .is_some_and(|observed| Some(observed) != expected_date.as_deref()) + { + diffs.push(json!("effective_date")); + } if actual.voucher_type.as_deref() != Some(expected.voucher_type.as_str()) { diffs.push(json!("voucher_type")); } diff --git a/src-tauri/src/agent_import_amend.rs b/src-tauri/src/agent_import_amend.rs index 36ec5423..8242b02f 100644 --- a/src-tauri/src/agent_import_amend.rs +++ b/src-tauri/src/agent_import_amend.rs @@ -184,9 +184,18 @@ impl Lineage { last_diffs = diffs; } match matched { - Some(batch_id) => admitted.push(json!({"bridge_txn_id":txn_id, + Some(batch_id) => { + let mut entry = json!({"bridge_txn_id":txn_id, "book_matches_batch_id":batch_id,"guid":row.guid,"master_id":row.master_id, - "alter_id":row.alter_id})), + "alter_id":row.alter_id}); + // the compare-and-swap could not see an edit to it + if row.effective_date.is_none() + && row.voucher_type.as_deref() != Some("Journal") + { + entry["not_observed"] = json!(["effective_date"]); + } + admitted.push(entry); + } None => refused.push( json!({"bridge_txn_id":txn_id,"reason":"book_voucher_diverged", "diffs_from_latest_build":last_diffs,"guid":row.guid,"alter_id":row.alter_id}), diff --git a/src-tauri/src/agent_import_amend_tests.rs b/src-tauri/src/agent_import_amend_tests.rs index f5a36862..d744b758 100644 --- a/src-tauri/src/agent_import_amend_tests.rs +++ b/src-tauri/src/agent_import_amend_tests.rs @@ -80,6 +80,7 @@ fn book_row(line: &ImportLedgerLine) -> ReadVoucher { voucher_number: Some("7".into()), cancelled: Some(false), optional: Some(false), + effective_date: None, // Tally does not promise the order it was sent (§12a.4). entries: voucher .entries @@ -310,6 +311,32 @@ fn an_edited_missing_or_cancelled_voucher_refuses_the_amendment() { let mut redated = book_row(&original); redated.date = Some("20260905".into()); assert_eq!(reason(vec![redated]), "book_voucher_diverged"); + // an edited effective date is a change the amendment would overwrite + let mut effective_redated = book_row(&original); + effective_redated.effective_date = Some("20260905".into()); + let refused = lineage + .compare_and_swap(&proposal, &book(vec![effective_redated])) + .unwrap() + .expect_err("refused"); + assert_eq!(refused[0]["reason"], "book_voucher_diverged"); + assert_eq!( + refused[0]["diffs_from_latest_build"], + json!(["effective_date"]) + ); + // one that came back unchanged is admitted without a caveat; one that did + // not come back is admitted with it + let mut effective_kept = book_row(&original); + effective_kept.effective_date = effective_kept.date.clone(); + let admitted = lineage + .compare_and_swap(&proposal, &book(vec![effective_kept])) + .unwrap() + .unwrap(); + assert!(admitted[0].get("not_observed").is_none()); + let admitted = lineage + .compare_and_swap(&proposal, &book(vec![book_row(&original)])) + .unwrap() + .unwrap(); + assert_eq!(admitted[0]["not_observed"], json!(["effective_date"])); assert_eq!(reason(vec![]), "not_in_book"); // A voucher carrying another batch's marker is not this one. diff --git a/src-tauri/src/agent_import_bank_tests.rs b/src-tauri/src/agent_import_bank_tests.rs index 37a4992f..8ab8970e 100644 --- a/src-tauri/src/agent_import_bank_tests.rs +++ b/src-tauri/src/agent_import_bank_tests.rs @@ -1270,12 +1270,44 @@ async fn a_bank_batch_verifies_through_the_rewrites_tally_makes_to_it() { voucher_number: Some("463".into()), cancelled: Some(false), optional: Some(false), + effective_date: None, entries, }]; let result = verify_observed_batch(&line, &observed).unwrap(); assert_eq!(result["counts"]["posted_verified"], 1); assert_eq!(verification_status(&result, 1), "posted_verified"); assert_eq!(result["duplicates"], json!([])); + // A response without EFFECTIVEDATE still verifies, but says the written + // effective date was not compared. + assert_eq!( + result["vouchers"][0]["not_observed"], + json!(["effective_date"]) + ); + // Returned equal to DATE, as measured on 7.1 (§9.8 scoped correction). + let mut returned = observed.clone(); + returned[0].effective_date = returned[0].date.clone(); + let result = verify_observed_batch(&line, &returned).unwrap(); + assert_eq!(verification_status(&result, 1), "posted_verified"); + assert!(result["vouchers"][0].get("not_observed").is_none()); + // Returned and different: Tally rewrote it or someone edited it. + let mut rewritten = observed.clone(); + rewritten[0].effective_date = Some("20260902".into()); + let result = verify_observed_batch(&line, &rewritten).unwrap(); + assert_eq!(result["vouchers"][0]["status"], "posted_divergent"); + assert_eq!(result["vouchers"][0]["diffs"], json!(["effective_date"])); + assert!(result["vouchers"][0].get("not_observed").is_none()); + + // A Journal is written without EFFECTIVEDATE, so it is neither compared + // nor reported missing. + let mut journal_line = line.clone(); + journal_line.vouchers[0].voucher_type = VoucherType::Journal; + let mut journal = rewritten.clone(); + journal[0].voucher_type = Some("Journal".into()); + let result = verify_observed_batch(&journal_line, &journal).unwrap(); + assert_eq!(verification_status(&result, 1), "posted_verified"); + journal[0].effective_date = None; + let result = verify_observed_batch(&journal_line, &journal).unwrap(); + assert!(result["vouchers"][0].get("not_observed").is_none()); // A readback that disagrees on the type is a different voucher, and // says so rather than passing on matching amounts alone. let mut mistyped = observed.clone(); @@ -1585,3 +1617,18 @@ async fn a_parsed_statement_builds_an_import_file_by_proposals_id() { ); assert_eq!(simulator.finish().expect("requests").len(), 44); } + +#[test] +fn the_verification_read_fetches_the_effective_date_and_not_the_party() { + let request = render_import_verification_read("Synthetic Book", "20260901", "20260901"); + let fetch = request + .split_once("") + .and_then(|(_, rest)| rest.split_once("")) + .map(|(fetch, _)| fetch.split(',').collect::>()) + .unwrap(); + assert!(fetch.contains(&"EFFECTIVEDATE")); + // Read back on 7.1, PARTYLEDGERNAME held a bank ledger rather than the + // written counterparty (§9.8 scoped correction): comparing it would refuse + // every legitimate bank voucher. + assert!(!fetch.contains(&"PARTYLEDGERNAME")); +} diff --git a/src-tauri/src/agent_import_boundary_tests.rs b/src-tauri/src/agent_import_boundary_tests.rs index 3ab7369f..a5158e47 100644 --- a/src-tauri/src/agent_import_boundary_tests.rs +++ b/src-tauri/src/agent_import_boundary_tests.rs @@ -84,3 +84,25 @@ fn import_boundary_rejects_malformed_accounting_scalars_in_captured_vouchers() { ); } } + +#[test] +fn import_verification_carries_the_captured_effective_date() { + // The capture returns EFFECTIVEDATE on its first two vouchers only. + let rows = parse_import_vouchers(&captured_vouchers(), CAPTURED_GUID) + .unwrap() + .rows; + let effective: Vec> = rows + .iter() + .map(|row| row.effective_date.as_deref()) + .collect(); + assert_eq!(effective, [Some("20260801"), Some("20260801"), None]); + let invalid = captured_vouchers().replacen( + "20260801", + "20261345", + 1, + ); + assert_eq!( + parse_import_vouchers(&invalid, CAPTURED_GUID), + Err("import_verification_export_invalid".to_string()) + ); +} diff --git a/src-tauri/src/agent_import_multiplicity_tests.rs b/src-tauri/src/agent_import_multiplicity_tests.rs index a82062ab..4e46a8ac 100644 --- a/src-tauri/src/agent_import_multiplicity_tests.rs +++ b/src-tauri/src/agent_import_multiplicity_tests.rs @@ -19,6 +19,7 @@ fn identical_batch() -> (ImportLedgerLine, Vec) { voucher_number: None, cancelled: Some(false), optional: Some(false), + effective_date: None, entries: voucher .entries .iter() diff --git a/src-tauri/src/agent_import_tests.rs b/src-tauri/src/agent_import_tests.rs index 5b086fe9..2021704c 100644 --- a/src-tauri/src/agent_import_tests.rs +++ b/src-tauri/src/agent_import_tests.rs @@ -410,6 +410,7 @@ fn duplicate_detection_uses_stable_voucher_identity_independently_of_remote_id() voucher_number: None, cancelled: Some(false), optional: Some(false), + effective_date: None, entries: vec![ReadEntry { ledger: "Expense".into(), amount: "-12.50".into(), @@ -481,6 +482,7 @@ fn verification_masks_entry_diffs_and_duplicate_fingerprints_before_release() { voucher_number: None, cancelled: Some(false), optional: Some(false), + effective_date: None, entries: vec![ReadEntry { ledger: "Private Synthetic Party".into(), amount: "-12.50".into(), @@ -572,6 +574,7 @@ fn verification_reports_absence_divergence_and_duplicate_fingerprints() { master_id: None, cancelled: Some(false), optional: Some(false), + effective_date: None, entries: vec![ ReadEntry { ledger: "Expense".to_string(), @@ -596,6 +599,7 @@ fn verification_reports_absence_divergence_and_duplicate_fingerprints() { master_id: None, cancelled: Some(false), optional: Some(false), + effective_date: None, entries: vec![ ReadEntry { ledger: "Expense".to_string(), @@ -637,6 +641,7 @@ fn verification_window_corroboration_rejects_each_unsafe_branch() { master_id: None, cancelled: Some(false), optional: Some(false), + effective_date: None, entries: vec![], }; let inside = voucher("guid-1", 3, "20260901"); @@ -769,6 +774,7 @@ fn unrelated_window_duplicates_do_not_block_a_verified_batch() { master_id: None, cancelled: Some(false), optional: Some(false), + effective_date: None, entries: vec![ ReadEntry { ledger: "Expense".to_string(), @@ -794,6 +800,7 @@ fn unrelated_window_duplicates_do_not_block_a_verified_batch() { master_id: None, cancelled: Some(false), optional: Some(false), + effective_date: None, entries: vec![ReadEntry { ledger: "Unrelated".to_string(), amount: "1.00".to_string(), @@ -850,6 +857,7 @@ fn fingerprint_only_verification_requires_a_post_mark_voucher() { master_id: None, cancelled: Some(false), optional: Some(false), + effective_date: None, entries: vec![ ReadEntry { ledger: "Expense".to_string(), @@ -873,6 +881,10 @@ fn fingerprint_only_verification_requires_a_post_mark_voucher() { let after = verify_observed_batch(&line, &[observed(11)]).expect("verification result"); assert_eq!(after["vouchers"][0]["status"], "matching_content_observed"); assert_eq!(after["vouchers"][0]["attribution"], "not_established"); + assert_eq!( + after["vouchers"][0]["not_observed"], + json!(["effective_date"]) + ); assert_eq!(after["counts"]["posted_verified"], 0); assert_eq!(verification_status(&after, 1), "verification_incomplete"); } @@ -913,6 +925,7 @@ fn fingerprint_fallback_consumes_an_observed_voucher_once_per_batch() { master_id: None, cancelled: Some(false), optional: Some(false), + effective_date: None, entries: vec![ ReadEntry { ledger: "Expense".to_string(), @@ -974,6 +987,7 @@ fn tagged_matches_are_reserved_and_consumed_independently_of_batch_order() { master_id: None, cancelled: Some(false), optional: Some(false), + effective_date: None, entries: vec![ ReadEntry { ledger: "Expense".to_string(), @@ -1042,6 +1056,7 @@ fn narration_tag_verification_requires_a_post_mark_voucher() { master_id: None, cancelled: Some(false), optional: Some(false), + effective_date: None, entries: vec![ ReadEntry { ledger: "Expense".to_string(), @@ -1106,6 +1121,7 @@ fn verification_compares_amounts_numerically_and_preserves_real_divergence() { master_id: None, cancelled: Some(false), optional: Some(false), + effective_date: None, entries: vec![ ReadEntry { ledger: "Expense".to_string(), @@ -1163,6 +1179,7 @@ fn verified_import_vouchers_require_observed_effective_accounting_flags() { master_id: None, cancelled: Some(false), optional: Some(false), + effective_date: None, entries: vec![ ReadEntry { ledger: "Expense".to_string(), @@ -1188,6 +1205,10 @@ fn verified_import_vouchers_require_observed_effective_accounting_flags() { let result = verify_observed_batch(&line, &[ineffective]).expect("ineffective voucher result"); assert_eq!(result["vouchers"][0]["status"], "posted_not_effective"); + assert_eq!( + result["vouchers"][0]["not_observed"], + json!(["effective_date"]) + ); assert_eq!(result["counts"]["posted_not_effective"], 1); } let mut missing = observed; diff --git a/src-tauri/src/agent_voucher_parse.rs b/src-tauri/src/agent_voucher_parse.rs index 9e3e2cc1..a15d7959 100644 --- a/src-tauri/src/agent_voucher_parse.rs +++ b/src-tauri/src/agent_voucher_parse.rs @@ -88,10 +88,28 @@ pub(super) fn parse_agent_changed_rows( parse_agent_rows_with_accounting_state(xml, true, company_guid) } +/// Changed rows plus `effective_date`, which only import verification reads. +/// The public voucher tools do not fetch it and their rows do not carry it. +pub(super) fn parse_import_verification_rows( + xml: &str, + company_guid: &str, +) -> Result, String> { + parse_voucher_rows(xml, true, true, company_guid) +} + pub(super) fn parse_agent_rows_with_accounting_state( xml: &str, require_change_identity: bool, company_guid: &str, +) -> Result, String> { + parse_voucher_rows(xml, require_change_identity, false, company_guid) +} + +fn parse_voucher_rows( + xml: &str, + require_change_identity: bool, + include_effective_date: bool, + company_guid: &str, ) -> Result, String> { // Tally's collection XML varies by release; use a deliberately conservative // extractor and never infer a missing field. Malformed rows fail before @@ -344,6 +362,20 @@ pub(super) fn parse_agent_rows_with_accounting_state( if require_change_identity { parsed["remote_id"] = json!(row.get("REMOTEID")); } + if include_effective_date { + // An empty element is treated like an absent one: not observed. + // A present value must be a date, as DATE must: a malformed + // one refuses the read rather than passing as unobserved. + if let Some(effective) = row + .get("EFFECTIVEDATE") + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + { + bridge_tally_core::TallyDate::parse(effective.to_string()) + .map_err(|_| "voucher_effective_date_invalid".to_string())?; + parsed["effective_date"] = json!(effective); + } + } parsed["cancelled"] = Value::Bool(required_tally_bool(row.get("ISCANCELLED"))?); parsed["optional"] = diff --git a/src-tauri/src/agent_voucher_parse_tests.rs b/src-tauri/src/agent_voucher_parse_tests.rs index 4af5afa1..0c7559bc 100644 --- a/src-tauri/src/agent_voucher_parse_tests.rs +++ b/src-tauri/src/agent_voucher_parse_tests.rs @@ -898,3 +898,56 @@ fn captured_voucher_guids_must_bind_every_row_to_the_selected_company() { ); } } + +/// The captured three-voucher response with the first voucher's EFFECTIVEDATE +/// element replaced by `element`. +fn with_effective_date(element: &str) -> String { + captured_native_vouchers().replacen( + "20260801", + element, + 1, + ) +} + +#[test] +fn effective_date_is_read_for_import_verification_only() { + // The capture returns EFFECTIVEDATE on its first two vouchers and not on the third. + let captured = captured_native_vouchers(); + let rows = parse_import_verification_rows(&captured, CAPTURED_VOUCHER_COMPANY_GUID).unwrap(); + assert_eq!(rows.len(), 3); + assert_eq!(rows[0]["effective_date"], "20260801"); + assert_eq!(rows[1]["effective_date"], "20260801"); + // not returned for a voucher is not invented for it + assert!(rows[2].get("effective_date").is_none()); + // the public voucher tools do not surface it + for public in [ + parse_agent_changed_rows(&captured, CAPTURED_VOUCHER_COMPANY_GUID).unwrap(), + parse_agent_rows(&captured, CAPTURED_VOUCHER_COMPANY_GUID).unwrap(), + ] { + assert!(public.iter().all(|row| row.get("effective_date").is_none())); + } + + // an empty element is not observed, like an absent one + let empty = with_effective_date(""); + let rows = parse_import_verification_rows(&empty, CAPTURED_VOUCHER_COMPANY_GUID).unwrap(); + assert!(rows[0].get("effective_date").is_none()); + + // surrounding whitespace is not part of the date + let padded = with_effective_date(" 20260802\n"); + let rows = parse_import_verification_rows(&padded, CAPTURED_VOUCHER_COMPANY_GUID).unwrap(); + assert_eq!(rows[0]["effective_date"], "20260802"); + + let invalid = with_effective_date("20261345"); + assert_eq!( + parse_import_verification_rows(&invalid, CAPTURED_VOUCHER_COMPANY_GUID), + Err("voucher_effective_date_invalid".to_string()) + ); + // two effective dates on one voucher cannot be compared with one written value + let repeated = with_effective_date( + "2026080120260802", + ); + assert_eq!( + parse_import_verification_rows(&repeated, CAPTURED_VOUCHER_COMPANY_GUID), + Err("agent_read_protocol_invalid".to_string()) + ); +} diff --git a/src-tauri/src/agent_voucher_scalars.rs b/src-tauri/src/agent_voucher_scalars.rs index a9861bdb..e5db1afb 100644 --- a/src-tauri/src/agent_voucher_scalars.rs +++ b/src-tauri/src/agent_voucher_scalars.rs @@ -5,6 +5,7 @@ pub(in crate::agent) fn is_voucher_scalar(field: &str) -> bool { matches!( field, "DATE" + | "EFFECTIVEDATE" | "VOUCHERTYPENAME" | "VOUCHERNUMBER" | "PARTYLEDGERNAME"