import hashlib import re from dataclasses import dataclass from datetime import date from decimal import Decimal, InvalidOperation from typing import Protocol from app.errors import AppError DIRECTION_IN = "in" DIRECTION_OUT = "out" BANK_STATUS_POSTED = "posted" BANK_STATUS_HOLD = "hold" BANK_STATUSES = (BANK_STATUS_POSTED, BANK_STATUS_HOLD) # Legacy shared version kept only for the untouched, unregistered credit adapters. PARSER_VERSION = "1" # Per-contract versions: bumped for the rewritten evidenced contracts so old # parse runs can never be confused with the new canonical output. ALFA_DEBIT_XLSX_VERSION = "3" OZON_DEBIT_PDF_VERSION = "2" ROCKETBANK_DEBIT_PDF_VERSION = "2" @dataclass(frozen=True) class SourceRow: external_id: str | None occurred_on: date | None posted_on: date | None amount_kopecks: int direction: str description: str | None mcc: int | None bank_category: str | None location: str | None row_ref: str warnings: tuple[str, ...] = () bank_status: str = BANK_STATUS_POSTED savings_transfer: bool = False def __post_init__(self) -> None: if self.amount_kopecks < 0: raise UnsupportedStatementLayout( "amount_kopecks must be positive; direction carries the sign" ) if self.bank_status not in BANK_STATUSES: raise UnsupportedStatementLayout("bank_status must be posted or hold") @dataclass(frozen=True) class StatementDraft: adapter_id: str parser_version: str bank_code: str product: str account_fingerprint: str | None currency: str period_start: date | None period_end: date | None opening_balance_kopecks: int | None closing_balance_kopecks: int | None rows: tuple[SourceRow, ...] declared_inflow_kopecks: int | None = None declared_outflow_kopecks: int | None = None declared_hold_kopecks: int | None = None class UnsupportedStatementLayout(AppError): """Stable diagnostic for unknown bank layouts; never guesses.""" def __init__(self, detail: str) -> None: super().__init__( detail, code="IMPORT_UNSUPPORTED_LAYOUT", status_code=422, ) class StatementAdapter(Protocol): adapter_id: str def detect(self, raw: bytes) -> bool: ... def parse(self, raw: bytes) -> StatementDraft: ... def amount_to_kopecks(value: object) -> int: if isinstance(value, int): return value if isinstance(value, float): raise UnsupportedStatementLayout( "float amounts are not accepted; cell must be text/decimal" ) if isinstance(value, Decimal): return int(value.quantize(Decimal("0.01")).scaleb(2)) if isinstance(value, str): cleaned = value.replace(" ", "").replace("\u00a0", "").replace(",", ".") try: return int(Decimal(cleaned).quantize(Decimal("0.01")).scaleb(2)) except (InvalidOperation, ValueError) as exc: raise UnsupportedStatementLayout(f"unparseable amount: {value!r}") from exc raise UnsupportedStatementLayout(f"unsupported amount cell type: {type(value).__name__}") def _mask_digits(text: str) -> str: return re.sub(r"\d", "#", text) CURRENCY_SUFFIX_RE = re.compile(r"(?:₽|RUR|rub|руб\.?)\s*$", re.IGNORECASE) PLAIN_AMOUNT_RE = re.compile(r"^[+-]?\s?\d[\d\s]*(?:[.,]\d{1,2})?$") def money_to_kopecks(text: object) -> int: """Parse statement money strings like '-1 234,56 RUR', '+ 500.00 ₽', '12 345'. Accepts space/NBSP thousand separators and comma or dot decimal marks. Fails closed with a digit-masked diagnostic; never leaks the raw value. """ if not isinstance(text, str): raise UnsupportedStatementLayout(f"unsupported money cell type: {type(text).__name__}") cleaned = text.replace("\u00a0", " ").strip() cleaned = CURRENCY_SUFFIX_RE.sub("", cleaned).strip() if not PLAIN_AMOUNT_RE.fullmatch(cleaned): raise UnsupportedStatementLayout(f"unparseable money amount: {_mask_digits(text)}") return amount_to_kopecks(cleaned) def account_fingerprint(bank_code: str, masked_identifier: str) -> str: digest = hashlib.sha256(f"{bank_code}|{masked_identifier}".encode()).hexdigest() return digest[:64] def normalize_description(description: str | None) -> str: if not description: return "" return " ".join(description.lower().split()) def record_fingerprint( occurred_on: date | None, amount_kopecks: int, description: str | None ) -> str: occurred = occurred_on.isoformat() if occurred_on else "" payload = f"{occurred}|{amount_kopecks}|{normalize_description(description)[:120]}" return hashlib.sha256(payload.encode("utf-8")).hexdigest()