"""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)