diff --git a/docs/imports.md b/docs/imports.md index c20e115..2c3d9d3 100644 --- a/docs/imports.md +++ b/docs/imports.md @@ -138,7 +138,7 @@ both occurrence and posting dates. OFX and PDF are unsupported. | Profile | Accepted layout | | --- | --- | | `BANK_CSV_V1` | Bank account CSV, comma-delimited with CSV quoting, at least six header columns; date at column 0 as `DD/MM/YYYY`, description at 2, signed decimal-point amount at 5. Each record must match the header width. | -| `OUROCARD_FULL_V1` | Card TXT, `DD.MM.YYYY`, 37-character description/location field, two-character country field, debit and credit in Brazilian decimal notation. A separate `PGTO DEBITO CONTA` layout is recognized. | +| `OUROCARD_FULL_V1` | Card TXT, `DD.MM.YYYY`, with either a 37-character description/location field plus a two-character country field, or a 33-character description/location field plus a seven-character country field; debit and credit use Brazilian decimal notation. A separate `PGTO DEBITO CONTA` layout is recognized. | | `OUROCARD_PARTIAL_V1` | Card TXT, `DD/MM`, 43-character description/location field, country, debit and credit; requires an explicit date interval. | CSV header aliases are case/accent insensitive: date accepts `Data`, diff --git a/ingestion/parsers.py b/ingestion/parsers.py index da9df14..ed831e8 100644 --- a/ingestion/parsers.py +++ b/ingestion/parsers.py @@ -85,7 +85,9 @@ def _txt_items(text, profile, skipped): partial = profile == 'OUROCARD_PARTIAL_V1' date_size, body_size = (5, 43) if partial else (10, 37) date_re = r'\d{2}/\d{2}' if partial else r'\d{2}\.\d{2}\.\d{4}' - regular = re.compile(rf'^({date_re})(.{{{body_size}}})([A-Z ]{{2}})\s+(\S+)\s+(\S+)\s*$') + regular = [re.compile(rf'^({date_re})(.{{{body_size}}})([A-Z ]{{2}})\s+(\S+)\s+(\S+)\s*$')] + if profile == 'OUROCARD_FULL_V1': + regular.append(re.compile(rf'^({date_re})(.{{33}})([A-Za-z ]{{7}})\s+(\S+)\s+(\S+)\s*$')) payment = re.compile(rf'^({date_re})\s*(PGTO DEBITO CONTA.*?)200211\s+(\S+)\s+(\S+)\s*$') section, section_count = '1', 0 for number, line in enumerate(text.splitlines(), 1): @@ -100,7 +102,7 @@ def _txt_items(text, profile, skipped): skipped['balance'] += 1 elif match := payment.fullmatch(line): yield ParsedItem(locator, section, match[1], match[2].strip(), match[3], match[4], True) - elif match := regular.fullmatch(line): + elif match := next((candidate for pattern in regular if (candidate := pattern.fullmatch(line))), None): if _balance(match[2]): skipped['balance'] += 1 else: diff --git a/tests/test_import_parsers.py b/tests/test_import_parsers.py index d86d090..3fc3ae7 100644 --- a/tests/test_import_parsers.py +++ b/tests/test_import_parsers.py @@ -20,6 +20,10 @@ def card_line(day='15.02.2026', body='LOJA INVENTADA', debit='12,34', credit='0, return day + body.ljust(43 if partial else 37) + 'BR' + f' {debit} {credit}' +def alternate_full_card_line(day='15.02.2026', body='LOJA EXTERIOR', country='ESTADOS', debit='12,34', credit='0,00'): + return day + body.ljust(33) + country.ljust(7) + f' {debit} {credit}' + + def card(text, partial=False, context=None): return normalize(parse_document(text.encode(), 'OUROCARD_PARTIAL_V1' if partial else 'OUROCARD_FULL_V1', context)) @@ -60,6 +64,14 @@ def test_full_card_sections_installments_credit_and_payment(): assert rows[3].description == 'unknown text' +@pytest.mark.parametrize('country', ['ESTADOS', 'Brasil']) +def test_full_card_accepts_extended_country_layout(country): + row = card(alternate_full_card_line(country=country))[0] + assert row.errors == () + assert row.description == 'LOJA EXTERIOR' + assert row.amount == '-12.34' + + def test_card_ambiguity_bad_shapes_and_installments_block(): assert 'AMBIGUOUS_AMOUNT' in card(card_line(debit='2,00', credit='1,00'))[0].errors assert 'INVALID_ROW' in card('15.02.2026 MALFORMED')[0].errors