fix(parties): read every separator a TIN can be written with (P7)

A taxpayer identification number can be written 123,45,6789 or
(123) 45-6789 as easily as 123-45-6789, and the scanner only knew the
second: a comma-grouped number slipped through every party field, every
refusal that quotes its input and the access log's masking, which is how
a full TIN could reach a result the books promise never shows one
(rules.md B-26, I-7).

The scanner now reads a comma or a parenthesis between a TIN's 3-2-4 or
2-7 groups, and any run of spaces counts once however long it is, so
12    3456789 is an EIN. Commas and parentheses join only those groups:
an amount's thousands separators (1,234,567.89 is 1-3-3-2) still read as
an amount, and a nine-digit D-U-N-S number stays its own setting's kind
(company.duns). The old run reading is unchanged for everything it
found, which the property asserts; replay still reads a journaled line
as P2 did (B-28).

The truth table and the ASCII-equality property pinned the old separator
set, so they were updated with it: the property is now
has_full_tin_never_forgets_a_tin (what was found before is still found).
No journal, entry or figure changes: this is a text scanner only.
This commit is contained in:
swaits committed 2026-10-26 20:28:41 -06:00
1 parent 40101f197e
commit c6527280e7
3 files changed
+224 -71

No files matched your search

+57
View File
@@ -1141,3 +1141,60 @@ proptest! {
}
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(2000))]
/// rules.md B-26 (P7): a TIN is found and masked however its groups
/// are separated — a hyphen, a dot, a comma, a parenthesis, a slash,
/// an underscore, or any run of spaces — and what merely looks like
/// one is not: an amount's thousands separators (1,234,567.89 is
/// 1-3-3-2, no TIN's shape), a longer number holding a TIN's digits,
/// and the company's own nine-digit D-U-N-S number, which is its own
/// setting's kind (company.duns), never a party field. The ACH payment
/// methods' lines get the same exemption in P4b S4's Kind; until then
/// the filing scan's narrow shapes keep a bank export's routing
/// numbers out of the rule (rules.md B-26).
#[test]
fn tin_shapes_with_any_separator_are_found(
sep in prop::sample::select(vec![
"-", " ", " ", " ", ".", ",", "/", "_", " - ", ", ", ") ", "(", ")", ",\u{a0}",
]),
which in 0usize..2,
) {
let shapes: [&[&str]; 2] = [&["187", "05", "1907"], &["12", "3456789"]];
let tin = shapes[which].join(sep);
prop_assert!(ledger_core::parties::has_full_tin(&tin), "{:?}", tin);
let masked = ledger_core::parties::redact_full_tins(&tin);
prop_assert_eq!(
masked.chars().filter(|c| *c == '•').count(),
9,
"{:?}: {:?}",
tin,
masked
);
prop_assert!(
!ledger_core::parties::has_full_tin(&masked),
"{:?}: {:?}",
tin,
masked
);
// No false positive on an amount.
prop_assert!(!ledger_core::parties::has_full_tin("1,234,567.89"));
let amount = ledger_core::parties::redact_full_tins("1,234,567.89");
prop_assert_eq!(amount.as_ref(), "1,234,567.89");
// Nor on the company's D-U-N-S number: the key's own kind takes a
// nine-digit number, bare or printed 2-3-4.
let duns = ledger_core::settings::KEYS
.iter()
.find(|k| k.name == "company.duns")
.expect("the books know company.duns");
prop_assert!(
ledger_core::settings::check(duns, &serde_json::json!("149976972")).is_ok()
);
prop_assert!(
ledger_core::settings::check(duns, &serde_json::json!("14-997-6972")).is_ok()
);
}
}