143 lines
4.6 KiB
Python
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()
|