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