d582f15681
- Implemented tests for import polling functionality, ensuring correct behavior for active and terminal import statuses. - Created tests for the login page, validating user interactions and error handling. - Added unit tests for money utility functions, covering parsing, formatting, and summation of monetary values. - Developed tests for query keys, ensuring proper scoping and invalidation roots. chore: setup testing environment and configuration - Added setup file for testing with Vitest and Testing Library. - Configured TypeScript and Vite for the frontend project. - Created Docker and Nginx configurations for local development and production environments. - Established a PostgreSQL initialization script for setting up application roles. docs: add README and environment configuration for infra - Included README for the infra directory, detailing local development setup and service descriptions. - Created an example environment configuration file for local development with placeholders for sensitive information.
97 lines
2.8 KiB
Python
97 lines
2.8 KiB
Python
import hashlib
|
|
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"
|
|
PARSER_VERSION = "1"
|
|
|
|
|
|
@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, ...] = ()
|
|
|
|
|
|
@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, ...]
|
|
|
|
|
|
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 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()
|