Files
finance/backend/app/imports/adapters/base.py
T

143 lines
4.6 KiB
Python

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