You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

117 lines
4.7 KiB

"""Convert parser evidence into immutable, serializable review candidates."""
import re
from dataclasses import dataclass, asdict
from datetime import date, datetime
from decimal import Decimal
from .parsers import ParseResult
@dataclass(frozen=True)
class Candidate:
locator: str
section: str
posting_date: str | None
occurrence_date: str | None
description: str
amount: str | None
kind: str
installment_number: int | None
installment_total: int | None
errors: tuple[str, ...]
def to_dict(self):
result = asdict(self)
result['errors'] = list(self.errors)
return result
def _date(text, profile, context):
if profile != 'OUROCARD_PARTIAL_V1':
pattern = r'\d{2}/\d{2}/\d{4}' if profile == 'BANK_CSV_V1' else r'\d{2}\.\d{2}\.\d{4}'
if not re.fullmatch(pattern, text):
raise ValueError('INVALID_DATE')
try:
return datetime.strptime(text, '%d/%m/%Y' if profile == 'BANK_CSV_V1' else '%d.%m.%Y').date().isoformat()
except ValueError:
raise ValueError('INVALID_DATE') from None
if not context.get('start_date') or not context.get('end_date'):
raise ValueError('MISSING_DATE_CONTEXT')
try:
start, end = date.fromisoformat(context['start_date']), date.fromisoformat(context['end_date'])
if not 0 <= (end - start).days <= 365:
raise ValueError
except (ValueError, TypeError):
raise ValueError('INVALID_DATE_CONTEXT') from None
if not re.fullmatch(r'\d{2}/\d{2}', text):
raise ValueError('INVALID_DATE')
day, month = map(int, text.split('/'))
possible = []
for year in range(start.year, end.year + 1):
try:
candidate = date(year, month, day)
except ValueError:
continue
if start <= candidate <= end:
possible.append(candidate)
if len(possible) != 1:
raise ValueError('AMBIGUOUS_DATE' if possible else 'INVALID_DATE')
return possible[0].isoformat()
def _cents(text, localized=False):
pattern = r'[+-]?(?:\d+|\d{1,3}(?:\.\d{3})+),\d{2}' if localized else r'[+-]?\d+(?:\.\d{1,2})?'
if len(text) > 40 or not re.fullmatch(pattern, text):
raise ValueError('INVALID_AMOUNT')
clean = text.replace('.', '').replace(',', '.') if localized else text
# Integer minor units keep all arithmetic independent of Decimal context.
negative = clean.startswith('-')
clean = clean.lstrip('+-')
whole, _, fraction = clean.partition('.')
return (-1 if negative else 1) * (int(whole) * 100 + int(fraction.ljust(2, '0')))
def _format_cents(value):
return ('-' if value < 0 else '') + f'{abs(value) // 100}.{abs(value) % 100:02d}'
def normalize(result: ParseResult) -> tuple[Candidate, ...]:
candidates = []
for item in result.items:
errors = list(item.errors)
posting_date = amount = installment_number = installment_total = None
kind = 'EXPENSE'
if not item.errors:
try:
posting_date = _date(item.date_text, result.profile, result.context)
except ValueError as exc:
errors.append(str(exc))
try:
if result.profile == 'BANK_CSV_V1':
cents = _cents(item.debit)
kind = 'INCOME' if cents > 0 else 'EXPENSE'
else:
debit, credit = _cents(item.debit, True), _cents(item.credit, True)
if item.payment:
if debit and credit:
errors.append('AMBIGUOUS_AMOUNT')
# copy_abs is exact even when caller Decimal context traps rounding.
cents = int(Decimal(debit).copy_abs())
kind = 'TRANSFER'
else:
if debit and credit:
errors.append('AMBIGUOUS_AMOUNT')
cents = credit - debit
kind = 'REFUND' if cents > 0 else 'EXPENSE'
amount = _format_cents(cents)
except ValueError as exc:
errors.append(str(exc))
if result.profile != 'BANK_CSV_V1' and re.search(r'\bPARC\b', item.description):
match = re.search(r'\bPARC\s+(\d{1,4})/(\d{1,4})\b', item.description)
if match and 1 <= int(match[1]) <= int(match[2]):
installment_number, installment_total = map(int, match.groups())
else:
errors.append('INVALID_INSTALLMENT')
candidates.append(Candidate(item.locator, item.section, posting_date, posting_date, item.description, amount, kind, installment_number, installment_total, tuple(errors)))
return tuple(candidates)