diff --git a/src/invoice2data/extract/template_builder.py b/src/invoice2data/extract/template_builder.py index 45b304945..eee83aec6 100644 --- a/src/invoice2data/extract/template_builder.py +++ b/src/invoice2data/extract/template_builder.py @@ -19,12 +19,22 @@ #: Capture pattern per candidate kind, used to build a field's value group. +#: These are TIGHT patterns applied at *extraction* time -- when the suggested +#: template is later run against a fresh PDF the label alone isn't enough to +#: anchor a value, so an overly loose pattern (``[A-Z0-9]+`` for BIC/VAT) +#: would grab any adjacent alphanumeric blob. The shapes here mirror the +#: strict validators in ``validators.py`` so a suggestion produces a regex +#: that will only re-match a properly shaped identifier. _VALUE_PATTERNS = { "date": r"\d[\d/.\-]+\d", "amount": r"[\d.,]+", - "iban": r"[A-Z0-9 ]+", - "vat": r"[A-Z0-9]+", - "bic": r"[A-Z0-9]+", + # Contiguous or space-grouped IBAN body -- capture then strip in-app. + "iban": r"[A-Z]{2}\d{2}(?:[ \-]?[A-Z0-9]){11,30}", + # VAT: 2-letter country + 8-14 alphanumerics. Country slot MUST be alpha. + "vat": r"[A-Z]{2}[A-Z0-9]{8,14}", + # BIC / SWIFT (ISO 9362): 8 or 11 chars, first 6 letters, then 2 alnum, + # optionally 3 more (branch). + "bic": r"[A-Z]{6}[A-Z0-9]{2}(?:[A-Z0-9]{3})?", } diff --git a/tests/test_template_builder.py b/tests/test_template_builder.py index 7da784702..5982bb662 100644 --- a/tests/test_template_builder.py +++ b/tests/test_template_builder.py @@ -37,6 +37,31 @@ def test_field_regex_anchors_on_label() -> None: assert match.group(1) == "121.00" +def test_bic_value_pattern_only_captures_bic_shape() -> None: + """A suggested BIC regex must not slurp arbitrary alphanumeric blobs. + + Regression: the value pattern used to be ``[A-Z0-9]+`` which, when the + suggestion was later applied to a fresh PDF, gobbled adjacent uppercase + tokens (order codes, postal codes, single letters). Now the ISO-9362 + shape is enforced at extraction time. + """ + text = "ACME Corp\nInvoice #ORDER12345\nBank: DEUTDEFFXXX\n" + template = suggested_template(text) + bic_pattern = template["fields"].get("bic") + assert bic_pattern, "expected a bic suggestion" + matches = re.findall(bic_pattern, text) + assert matches == ["DEUTDEFFXXX"], ( + f"tight pattern should only capture the actual BIC; got {matches!r}" + ) + # Explicitly: an 8-char order code alone should NOT match the bic pattern + # even without any surrounding label anchor. + from invoice2data.extract.template_builder import _VALUE_PATTERNS + + assert re.fullmatch(_VALUE_PATTERNS["bic"], "ORDER123") is None + assert re.fullmatch(_VALUE_PATTERNS["bic"], "DEUTDEFF") is not None + assert re.fullmatch(_VALUE_PATTERNS["bic"], "DEUTDEFFXXX") is not None + + def test_to_yaml_roundtrips() -> None: template = { "issuer": "ACME",