Browse Source

fix: parse extended Ourocard country layouts

master
Codex 2 months ago
parent
commit
9448d0a1cd
  1. 2
      docs/imports.md
  2. 6
      ingestion/parsers.py
  3. 12
      tests/test_import_parsers.py

2
docs/imports.md

@ -138,7 +138,7 @@ both occurrence and posting dates. OFX and PDF are unsupported.
| Profile | Accepted layout | | 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. | | `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. | | `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`, CSV header aliases are case/accent insensitive: date accepts `Data`,

6
ingestion/parsers.py

@ -85,7 +85,9 @@ def _txt_items(text, profile, skipped):
partial = profile == 'OUROCARD_PARTIAL_V1' partial = profile == 'OUROCARD_PARTIAL_V1'
date_size, body_size = (5, 43) if partial else (10, 37) 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}' 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*$') payment = re.compile(rf'^({date_re})\s*(PGTO DEBITO CONTA.*?)200211\s+(\S+)\s+(\S+)\s*$')
section, section_count = '1', 0 section, section_count = '1', 0
for number, line in enumerate(text.splitlines(), 1): for number, line in enumerate(text.splitlines(), 1):
@ -100,7 +102,7 @@ def _txt_items(text, profile, skipped):
skipped['balance'] += 1 skipped['balance'] += 1
elif match := payment.fullmatch(line): elif match := payment.fullmatch(line):
yield ParsedItem(locator, section, match[1], match[2].strip(), match[3], match[4], True) 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]): if _balance(match[2]):
skipped['balance'] += 1 skipped['balance'] += 1
else: else:

12
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}' 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): def card(text, partial=False, context=None):
return normalize(parse_document(text.encode(), 'OUROCARD_PARTIAL_V1' if partial else 'OUROCARD_FULL_V1', context)) 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' 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(): 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 '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 assert 'INVALID_ROW' in card('15.02.2026 MALFORMED')[0].errors

Loading…
Cancel
Save