023612ce0a
- Updated timestamps and AST hashes for multiple files in the manifest.json. - Added new test file backend/tests/test_expected_income.py. - Added documentation files: перево́д_между_своими.md and 0002-fixes.md. - Introduced pytest.ini to configure pytest settings, including test paths and warning filters.
958 lines
35 KiB
Python
958 lines
35 KiB
Python
"""Sanitized synthetic contract tests for the evidenced statement adapters.
|
||
|
||
These tests never touch real statements: every input below is synthetic and
|
||
tracked. Real-file validation lives in the separately collected private
|
||
suite (tests/test_private_fixtures.py, marker `private_fixtures`).
|
||
"""
|
||
|
||
import io
|
||
from datetime import date
|
||
from typing import Any
|
||
|
||
import pytest
|
||
from openpyxl import Workbook
|
||
|
||
from app.errors import AppError
|
||
from app.imports.adapters import ADAPTERS, detect_adapter, magic_kind
|
||
from app.imports.adapters import alfa_xlsx as alfa_module
|
||
from app.imports.adapters.alfa_xlsx import (
|
||
AlfaCreditXlsxAdapter,
|
||
AlfaDebitXlsxAdapter,
|
||
detect_savings_transfer,
|
||
)
|
||
from app.imports.adapters.base import (
|
||
ALFA_DEBIT_XLSX_VERSION,
|
||
BANK_STATUS_HOLD,
|
||
OZON_DEBIT_PDF_VERSION,
|
||
PARSER_VERSION,
|
||
ROCKETBANK_DEBIT_PDF_VERSION,
|
||
SourceRow,
|
||
StatementDraft,
|
||
UnsupportedStatementLayout,
|
||
account_fingerprint,
|
||
money_to_kopecks,
|
||
)
|
||
from app.imports.adapters.ozon_pdf import OzonCreditPdfAdapter, OzonDebitPdfAdapter
|
||
from app.imports.adapters.rocketbank_pdf import RocketbankDebitPdfAdapter
|
||
from app.imports.service import _reconciliation_summary
|
||
|
||
NBSP = "\u00a0"
|
||
|
||
|
||
def make_source_row(**overrides: object) -> SourceRow:
|
||
values: dict[str, Any] = {
|
||
"external_id": None,
|
||
"occurred_on": date(2026, 1, 1),
|
||
"posted_on": date(2026, 1, 2),
|
||
"amount_kopecks": 100,
|
||
"direction": "out",
|
||
"description": None,
|
||
"mcc": None,
|
||
"bank_category": None,
|
||
"location": None,
|
||
"row_ref": "row1",
|
||
}
|
||
values.update(overrides)
|
||
return SourceRow(**values)
|
||
|
||
|
||
def make_draft(**overrides: object) -> StatementDraft:
|
||
values: dict[str, Any] = {
|
||
"adapter_id": "alfa_debit_xlsx",
|
||
"parser_version": ALFA_DEBIT_XLSX_VERSION,
|
||
"bank_code": "alfa",
|
||
"product": "debit",
|
||
"account_fingerprint": None,
|
||
"currency": "RUB",
|
||
"period_start": None,
|
||
"period_end": None,
|
||
"opening_balance_kopecks": None,
|
||
"closing_balance_kopecks": None,
|
||
"rows": (),
|
||
}
|
||
values.update(overrides)
|
||
return StatementDraft(**values)
|
||
|
||
|
||
class TestBaseContract:
|
||
def test_source_row_defaults_to_posted(self) -> None:
|
||
assert make_source_row().bank_status == "posted"
|
||
|
||
def test_negative_amount_is_rejected(self) -> None:
|
||
with pytest.raises(UnsupportedStatementLayout):
|
||
make_source_row(amount_kopecks=-1)
|
||
|
||
def test_unknown_bank_status_is_rejected(self) -> None:
|
||
with pytest.raises(UnsupportedStatementLayout):
|
||
make_source_row(bank_status="pending")
|
||
|
||
def test_draft_declared_totals_default_to_none(self) -> None:
|
||
draft = make_draft()
|
||
assert draft.declared_inflow_kopecks is None
|
||
assert draft.declared_outflow_kopecks is None
|
||
assert draft.declared_hold_kopecks is None
|
||
|
||
def test_per_contract_parser_versions(self) -> None:
|
||
assert PARSER_VERSION == "1"
|
||
assert ALFA_DEBIT_XLSX_VERSION == "3"
|
||
assert OZON_DEBIT_PDF_VERSION == "2"
|
||
assert ROCKETBANK_DEBIT_PDF_VERSION == "2"
|
||
assert AlfaDebitXlsxAdapter.parser_version == ALFA_DEBIT_XLSX_VERSION
|
||
assert OzonDebitPdfAdapter.parser_version == OZON_DEBIT_PDF_VERSION
|
||
assert RocketbankDebitPdfAdapter.parser_version == ROCKETBANK_DEBIT_PDF_VERSION
|
||
|
||
def test_registry_lists_only_verified_contracts(self) -> None:
|
||
assert [adapter.adapter_id for adapter in ADAPTERS] == [
|
||
"alfa_debit_xlsx",
|
||
"ozon_debit_pdf",
|
||
"rocketbank_debit_pdf",
|
||
]
|
||
|
||
def test_credit_adapters_stay_present_but_unregistered(self) -> None:
|
||
assert AlfaCreditXlsxAdapter.adapter_id == "alfa_credit_xlsx"
|
||
assert OzonCreditPdfAdapter.adapter_id == "ozon_credit_pdf"
|
||
assert all(
|
||
adapter.adapter_id not in {"alfa_credit_xlsx", "ozon_credit_pdf"}
|
||
for adapter in ADAPTERS
|
||
)
|
||
|
||
def test_detect_adapter_fails_closed_on_unknown_xlsx(self) -> None:
|
||
buffer = io.BytesIO()
|
||
Workbook().save(buffer)
|
||
raw = buffer.getvalue()
|
||
with pytest.raises(UnsupportedStatementLayout) as exc:
|
||
detect_adapter(raw, "xlsx")
|
||
assert exc.value.code == "IMPORT_UNSUPPORTED_LAYOUT"
|
||
|
||
def test_detect_adapter_fails_closed_on_unknown_pdf(self) -> None:
|
||
with pytest.raises(UnsupportedStatementLayout):
|
||
detect_adapter(minimal_pdf(), "pdf")
|
||
|
||
def test_magic_kind(self) -> None:
|
||
assert magic_kind(b"PK\x03\x04") == "xlsx"
|
||
assert magic_kind(b"%PDF-1.4") == "pdf"
|
||
assert magic_kind(b"not a statement") is None
|
||
|
||
|
||
class TestMoneyParsing:
|
||
def test_plain_and_separated_amounts(self) -> None:
|
||
assert money_to_kopecks("123") == 12300
|
||
assert money_to_kopecks(f"1{NBSP}234,56") == 123456
|
||
assert money_to_kopecks("1 234.56") == 123456
|
||
assert money_to_kopecks("-1 234,56 RUR") == -123456
|
||
assert money_to_kopecks("+ 500.00 \u20bd") == 50000
|
||
|
||
def test_malformed_amount_fails_closed_without_leaking_digits(self) -> None:
|
||
with pytest.raises(UnsupportedStatementLayout) as exc:
|
||
money_to_kopecks("12a3,45")
|
||
assert "#" in exc.value.message
|
||
assert "12" not in exc.value.message
|
||
|
||
|
||
def alfa_workbook_bytes(rows: list[list[object]]) -> bytes:
|
||
buffer = io.BytesIO()
|
||
workbook = Workbook()
|
||
sheet = workbook.worksheets[0]
|
||
for row in rows:
|
||
sheet.append(row)
|
||
workbook.save(buffer)
|
||
return buffer.getvalue()
|
||
|
||
|
||
def synthetic_alfa_rows() -> list[list[object]]:
|
||
return [
|
||
[None, None, None],
|
||
["Выписка по счету"],
|
||
["За период с 01.09.2026 по 04.09.2026"],
|
||
["Номер счета", None, "00000000000000000001"],
|
||
["Дата открытия счета", None, "01.01.2025"],
|
||
["Валюта счета", None, "RUR"],
|
||
["Тип счета", None, "Текущий счёт"],
|
||
["Дата формирования выписки", None, "10.09.2026"],
|
||
[
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
"Поступления",
|
||
None,
|
||
"1 000,00 RUR",
|
||
],
|
||
[None, None, None, None, None, None, None, None, None, None, "Расходы", None, "293,45 RUR"],
|
||
[
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
"Неподтвержденные операции",
|
||
None,
|
||
"100,00 RUR",
|
||
],
|
||
[
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
"Текущий баланс",
|
||
None,
|
||
"800,00 RUR",
|
||
],
|
||
["Операции по счету"],
|
||
[
|
||
"Дата операции",
|
||
"Дата проводки",
|
||
None,
|
||
"Код",
|
||
"Категория",
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
"Описание",
|
||
f"Сумма в{NBSP}валюте{NBSP}счета",
|
||
None,
|
||
"Статус",
|
||
],
|
||
[
|
||
"01.09.2026",
|
||
"01.09.2026",
|
||
None,
|
||
"000000000000001",
|
||
"Покупки",
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
"Магазин 1 MCC: 5411",
|
||
"-123,45",
|
||
"Выполнен",
|
||
],
|
||
[
|
||
"02.09.2026",
|
||
"02.09.2026",
|
||
None,
|
||
"000000000000002",
|
||
"Переводы",
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
"Перевод 2 MCC5912",
|
||
"1 000,00",
|
||
"Выполнен",
|
||
],
|
||
[
|
||
"03.09.2026",
|
||
"HOLD",
|
||
None,
|
||
"000000000000003",
|
||
"Покупки",
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
"Покупка 3",
|
||
"-100,00",
|
||
],
|
||
[
|
||
"04.09.2026",
|
||
"04.09.2026",
|
||
None,
|
||
"000000000000004",
|
||
"Покупки",
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
"Покупка 4 MCC 5941",
|
||
"-50,00",
|
||
"Выполнен",
|
||
],
|
||
[
|
||
"05.09.2026",
|
||
"05.09.2026",
|
||
None,
|
||
"000000000000005",
|
||
"Покупки",
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
"Покупка 5",
|
||
"-20,00",
|
||
"Выполнен",
|
||
],
|
||
[None],
|
||
[
|
||
"(подпись сотрудника АО «БАНК»)",
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
"Уполномоченное лицо",
|
||
],
|
||
["Страница 1 из 1"],
|
||
]
|
||
|
||
|
||
class TestAlfaDebitContract:
|
||
def setup_method(self) -> None:
|
||
self.adapter = AlfaDebitXlsxAdapter()
|
||
self.raw = alfa_workbook_bytes(synthetic_alfa_rows())
|
||
|
||
def test_detect(self) -> None:
|
||
assert self.adapter.detect(self.raw) is True
|
||
|
||
def test_detect_rejects_workbook_without_contract_header(self) -> None:
|
||
raw = alfa_workbook_bytes([["Выписка по счету"], ["Что-то другое", "123"]])
|
||
assert self.adapter.detect(raw) is False
|
||
|
||
def test_parse_contract_fields(self) -> None:
|
||
draft = self.adapter.parse(self.raw)
|
||
assert draft.adapter_id == "alfa_debit_xlsx"
|
||
assert draft.parser_version == ALFA_DEBIT_XLSX_VERSION
|
||
assert draft.bank_code == "alfa"
|
||
assert draft.product == "debit"
|
||
assert draft.currency == "RUB"
|
||
assert draft.account_fingerprint == account_fingerprint("alfa", "00000000000000000001")
|
||
assert draft.declared_inflow_kopecks == 100000
|
||
# canonical posted-only: raw 293,45 minus declared hold 100,00
|
||
assert draft.declared_outflow_kopecks == 19345
|
||
assert draft.declared_hold_kopecks == 10000
|
||
assert draft.closing_balance_kopecks == 80000
|
||
assert draft.opening_balance_kopecks is None
|
||
# exact header period fields, not min/max of transaction dates
|
||
assert draft.period_start == date(2026, 9, 1)
|
||
assert draft.period_end == date(2026, 9, 4)
|
||
|
||
def test_missing_period_field_fails_closed(self) -> None:
|
||
rows = [list(row) for row in synthetic_alfa_rows()]
|
||
rows[2] = ["Что-то другое"]
|
||
with pytest.raises(UnsupportedStatementLayout) as exc:
|
||
self.adapter.parse(alfa_workbook_bytes(rows))
|
||
assert "period" in exc.value.message
|
||
|
||
def test_inconsistent_expense_control_fails_closed(self) -> None:
|
||
rows = [list(row) for row in synthetic_alfa_rows()]
|
||
rows[9] = [
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
"Расходы",
|
||
None,
|
||
"50,00 RUR",
|
||
]
|
||
with pytest.raises(UnsupportedStatementLayout) as exc:
|
||
self.adapter.parse(alfa_workbook_bytes(rows))
|
||
assert "expense" in exc.value.message
|
||
|
||
def test_parse_rows_with_hold(self) -> None:
|
||
draft = self.adapter.parse(self.raw)
|
||
assert len(draft.rows) == 5
|
||
first, second, hold, fourth, last = draft.rows
|
||
assert (first.amount_kopecks, first.direction, first.bank_status) == (
|
||
12345,
|
||
"out",
|
||
"posted",
|
||
)
|
||
assert first.posted_on == date(2026, 9, 1)
|
||
assert (second.amount_kopecks, second.direction) == (100000, "in")
|
||
assert hold.bank_status == BANK_STATUS_HOLD
|
||
assert hold.posted_on is None
|
||
assert hold.amount_kopecks == 10000
|
||
assert hold.direction == "out"
|
||
assert last.warnings == ()
|
||
assert first.row_ref == "sheet0:row15"
|
||
assert first.bank_category == "Покупки"
|
||
|
||
def test_external_ids_from_code_column(self) -> None:
|
||
draft = self.adapter.parse(self.raw)
|
||
assert [row.external_id for row in draft.rows] == [
|
||
"000000000000001",
|
||
"000000000000002",
|
||
"000000000000003",
|
||
"000000000000004",
|
||
"000000000000005",
|
||
]
|
||
|
||
def test_mcc_from_description(self) -> None:
|
||
draft = self.adapter.parse(self.raw)
|
||
first, second, hold, fourth, last = draft.rows
|
||
assert first.mcc == 5411
|
||
assert second.mcc == 5912
|
||
assert hold.mcc is None
|
||
assert fourth.mcc == 5941
|
||
assert last.mcc is None
|
||
assert all(row.location is None for row in draft.rows)
|
||
|
||
def test_footer_is_excluded(self) -> None:
|
||
draft = self.adapter.parse(self.raw)
|
||
assert all(row.occurred_on is not None for row in draft.rows)
|
||
assert draft.rows[-1].occurred_on == date(2026, 9, 5)
|
||
|
||
def test_parse_is_deterministic(self) -> None:
|
||
assert self.adapter.parse(self.raw) == self.adapter.parse(self.raw)
|
||
|
||
def test_service_reconciliation_contract(self) -> None:
|
||
draft = self.adapter.parse(self.raw)
|
||
summary = _reconciliation_summary(draft)
|
||
assert summary["matched"] is True
|
||
assert summary["posted_totals_matched"] is True
|
||
assert summary["hold_totals_matched"] is True
|
||
|
||
def test_service_reconciliation_flags_mismatch(self) -> None:
|
||
draft = make_draft(
|
||
declared_inflow_kopecks=1000,
|
||
declared_outflow_kopecks=1,
|
||
declared_hold_kopecks=0,
|
||
rows=(make_source_row(amount_kopecks=1000, direction="in"),),
|
||
)
|
||
summary = _reconciliation_summary(draft)
|
||
assert summary["posted_totals_matched"] is False
|
||
assert summary["matched"] is False
|
||
|
||
def test_malformed_layout_fails_closed(self) -> None:
|
||
raw = alfa_workbook_bytes([["Дата", "Сумма"], ["01.09.2026", "-10,00"]])
|
||
assert self.adapter.detect(raw) is False
|
||
with pytest.raises(UnsupportedStatementLayout) as exc:
|
||
self.adapter.parse(raw)
|
||
assert exc.value.code == "IMPORT_UNSUPPORTED_LAYOUT"
|
||
|
||
def test_unexpected_mid_table_row_fails_closed(self) -> None:
|
||
rows = [list(row) for row in synthetic_alfa_rows()]
|
||
rows.insert(16, ["unexpected layout drift"])
|
||
with pytest.raises(UnsupportedStatementLayout) as exc:
|
||
self.adapter.parse(alfa_workbook_bytes(rows))
|
||
assert exc.value.code == "IMPORT_UNSUPPORTED_LAYOUT"
|
||
|
||
def test_unsupported_currency_fails_closed(self) -> None:
|
||
rows = [list(row) for row in synthetic_alfa_rows()]
|
||
rows[5][2] = "USD"
|
||
with pytest.raises(UnsupportedStatementLayout):
|
||
self.adapter.parse(alfa_workbook_bytes(rows))
|
||
|
||
def test_row_quota_is_enforced(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
monkeypatch.setattr(alfa_module, "get_settings_row_cap", lambda: 3)
|
||
with pytest.raises(UnsupportedStatementLayout) as exc:
|
||
self.adapter.parse(self.raw)
|
||
assert "quota" in exc.value.message
|
||
|
||
|
||
class TestAlfaSavingsRule:
|
||
"""«Копилка для сдачи» contract: exact normalized phrase, statement-side
|
||
source account, outgoing posted rows only. Every input is synthetic."""
|
||
|
||
STATEMENT_ACCOUNT = "00000000000000000001"
|
||
SERVICE_ACCOUNT = "00000000000000000306"
|
||
PHRASE = (
|
||
'Перечисление средств в рамках услуги "Копилка для сдачи" '
|
||
"со счета {source} на счет {target}"
|
||
)
|
||
|
||
def rule(
|
||
self,
|
||
description: str | None,
|
||
*,
|
||
direction: str = "out",
|
||
bank_status: str = "posted",
|
||
statement_account: str = STATEMENT_ACCOUNT,
|
||
) -> bool:
|
||
return detect_savings_transfer(
|
||
description,
|
||
direction=direction,
|
||
bank_status=bank_status,
|
||
statement_account=statement_account,
|
||
)
|
||
|
||
def test_exact_phrase_matches(self) -> None:
|
||
assert self.rule(
|
||
self.PHRASE.format(source=self.STATEMENT_ACCOUNT, target=self.SERVICE_ACCOUNT)
|
||
)
|
||
|
||
def test_spaces_case_and_quotes_are_normalized(self) -> None:
|
||
noisy = (
|
||
" Перечисление\u00a0средств в рамках услуги «КОПИЛКА ДЛЯ СДАЧИ» "
|
||
f"со счёта {self.STATEMENT_ACCOUNT} на счет {self.SERVICE_ACCOUNT} "
|
||
)
|
||
assert self.rule(noisy)
|
||
|
||
def test_mismatched_source_account_fails_closed(self) -> None:
|
||
assert not self.rule(
|
||
self.PHRASE.format(source="00000000000000000099", target=self.SERVICE_ACCOUNT)
|
||
)
|
||
|
||
def test_incoming_row_never_matches(self) -> None:
|
||
assert not self.rule(
|
||
self.PHRASE.format(source=self.STATEMENT_ACCOUNT, target=self.SERVICE_ACCOUNT),
|
||
direction="in",
|
||
)
|
||
|
||
def test_hold_row_never_matches(self) -> None:
|
||
assert not self.rule(
|
||
self.PHRASE.format(source=self.STATEMENT_ACCOUNT, target=self.SERVICE_ACCOUNT),
|
||
bank_status="hold",
|
||
)
|
||
|
||
def test_similar_description_without_full_pattern_fails_closed(self) -> None:
|
||
assert not self.rule("Копилка для сдачи: пополнение")
|
||
assert not self.rule(
|
||
'Перечисление в рамках услуги "Копилка для сдачи" со счета 123 на счет 306'
|
||
)
|
||
|
||
def test_masked_statement_account_fails_closed(self) -> None:
|
||
assert not self.rule(
|
||
self.PHRASE.format(source=self.STATEMENT_ACCOUNT, target=self.SERVICE_ACCOUNT),
|
||
statement_account="0000********0001",
|
||
)
|
||
|
||
def savings_statement_rows(self) -> list[list[object]]:
|
||
rows = [list(row) for row in synthetic_alfa_rows()]
|
||
rows[9] = [
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
"Расходы",
|
||
None,
|
||
"416,90 RUR",
|
||
]
|
||
savings_row: list[object] = [
|
||
"03.09.2026",
|
||
"03.09.2026",
|
||
None,
|
||
"000000000000009",
|
||
"Переводы",
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
self.PHRASE.format(source=self.STATEMENT_ACCOUNT, target=self.SERVICE_ACCOUNT),
|
||
"-123,45",
|
||
"Выполнен",
|
||
]
|
||
rows.insert(17, savings_row)
|
||
return rows
|
||
|
||
def test_parse_marks_savings_rows_and_bumps_version(self) -> None:
|
||
adapter = AlfaDebitXlsxAdapter()
|
||
raw = alfa_workbook_bytes(self.savings_statement_rows())
|
||
draft = adapter.parse(raw)
|
||
assert draft.parser_version == ALFA_DEBIT_XLSX_VERSION
|
||
by_code = {row.external_id: row for row in draft.rows}
|
||
assert len(draft.rows) == 6
|
||
marked = by_code["000000000000009"]
|
||
assert marked.savings_transfer is True
|
||
assert marked.direction == "out"
|
||
assert marked.bank_status == "posted"
|
||
assert marked.amount_kopecks == 12345
|
||
assert all(
|
||
row.savings_transfer is False
|
||
for code, row in by_code.items()
|
||
if code != "000000000000009"
|
||
)
|
||
|
||
def test_parse_leaves_plain_expenses_unmarked(self) -> None:
|
||
adapter = AlfaDebitXlsxAdapter()
|
||
draft = adapter.parse(alfa_workbook_bytes(synthetic_alfa_rows()))
|
||
assert all(not row.savings_transfer for row in draft.rows)
|
||
|
||
|
||
def ozon_extracted() -> tuple[list[str], list[list[list[list[str | None]]]]]:
|
||
pages_text = [
|
||
"\n".join(
|
||
[
|
||
"ООО «ОЗОН Банк», ИНН 1234567890, город Москва",
|
||
"Справка о движении средств",
|
||
"Номер лицевого счёта: № 00000000000000000001, открыт 01.01.2025",
|
||
"Период выписки: 01.02.2026 – 28.02.2026",
|
||
"Входящий остаток: 1000.50 \u20bd",
|
||
]
|
||
),
|
||
"",
|
||
"\n".join(
|
||
[
|
||
"Итого зачислений за период: 3000.00 \u20bd",
|
||
"Итого списаний за период: 2500.75 \u20bd",
|
||
"Исходящий остаток: 1499.75 \u20bd",
|
||
]
|
||
),
|
||
]
|
||
pages_tables: list[list[list[list[str | None]]]] = [
|
||
[
|
||
[
|
||
[
|
||
"Дата операции",
|
||
"Документ",
|
||
"Назначение платежа",
|
||
"Сумма операции",
|
||
None,
|
||
],
|
||
[None, None, None, "Российские рубли", "Валюта"],
|
||
[
|
||
"01.02.2026 10:00:00",
|
||
"1234567890\n1",
|
||
"Оплата товаров\nпо карте 1234",
|
||
"- 500.00 \u20bd",
|
||
"- 500.00 \u20bd",
|
||
],
|
||
]
|
||
],
|
||
[
|
||
[
|
||
[
|
||
"02.02.2026 11:30:00",
|
||
"1234567891",
|
||
"Перевод\nчерез СБП",
|
||
"+ 2 000,00 \u20bd",
|
||
"+ 2 000,00 \u20bd",
|
||
]
|
||
]
|
||
],
|
||
[],
|
||
]
|
||
return pages_text, pages_tables
|
||
|
||
|
||
class TestOzonDebitContract:
|
||
def setup_method(self) -> None:
|
||
self.adapter = OzonDebitPdfAdapter()
|
||
self.pages_text, self.pages_tables = ozon_extracted()
|
||
|
||
def test_core_contract_fields(self) -> None:
|
||
draft = self.adapter._draft_from_extracted(self.pages_text, self.pages_tables)
|
||
assert draft.adapter_id == "ozon_debit_pdf"
|
||
assert draft.bank_code == "ozon"
|
||
assert draft.currency == "RUB"
|
||
assert draft.account_fingerprint == account_fingerprint("ozon", "00000000000000000001")
|
||
assert draft.period_start == date(2026, 2, 1)
|
||
assert draft.period_end == date(2026, 2, 28)
|
||
assert draft.opening_balance_kopecks == 100050
|
||
assert draft.closing_balance_kopecks == 149975
|
||
assert draft.declared_inflow_kopecks == 300000
|
||
assert draft.declared_outflow_kopecks == 250075
|
||
assert draft.declared_hold_kopecks is None
|
||
|
||
def test_multiline_cells_stay_one_transaction(self) -> None:
|
||
draft = self.adapter._draft_from_extracted(self.pages_text, self.pages_tables)
|
||
assert len(draft.rows) == 2
|
||
first, second = draft.rows
|
||
assert first.external_id == "12345678901"
|
||
assert first.description == "Оплата товаров по карте 1234"
|
||
assert (first.amount_kopecks, first.direction) == (50000, "out")
|
||
assert first.occurred_on == date(2026, 2, 1)
|
||
assert (second.amount_kopecks, second.direction) == (200000, "in")
|
||
assert second.external_id == "1234567891"
|
||
assert first.warnings == ()
|
||
|
||
def test_missing_header_marker_fails_closed(self) -> None:
|
||
pages_text, pages_tables = ozon_extracted()
|
||
pages_text[0] = "Какой-то другой документ"
|
||
with pytest.raises(UnsupportedStatementLayout):
|
||
self.adapter._draft_from_extracted(pages_text, pages_tables)
|
||
|
||
def test_missing_totals_fail_closed(self) -> None:
|
||
pages_text, pages_tables = ozon_extracted()
|
||
pages_text[2] = ""
|
||
with pytest.raises(UnsupportedStatementLayout):
|
||
self.adapter._draft_from_extracted(pages_text, pages_tables)
|
||
|
||
def test_no_rows_fails_closed(self) -> None:
|
||
pages_text, _ = ozon_extracted()
|
||
with pytest.raises(UnsupportedStatementLayout):
|
||
self.adapter._draft_from_extracted(pages_text, [[]])
|
||
|
||
def test_unexpected_row_shape_fails_closed(self) -> None:
|
||
pages_text, pages_tables = ozon_extracted()
|
||
pages_tables[0][0].append([None, None, "текст без суммы"])
|
||
with pytest.raises(UnsupportedStatementLayout):
|
||
self.adapter._draft_from_extracted(pages_text, pages_tables)
|
||
|
||
def test_detect_and_parse_fail_closed_on_blank_pdf(self) -> None:
|
||
raw = minimal_pdf()
|
||
assert self.adapter.detect(raw) is False
|
||
with pytest.raises(UnsupportedStatementLayout) as exc:
|
||
self.adapter.parse(raw)
|
||
assert "text layer" in exc.value.message
|
||
|
||
def test_oversized_pdf_is_rejected_by_quota(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
import app.imports.readers as readers_module
|
||
|
||
monkeypatch.setattr(readers_module, "get_settings", lambda: _QuotaSettings())
|
||
with pytest.raises(AppError) as exc:
|
||
self.adapter.parse(minimal_pdf(page_count=2))
|
||
assert exc.value.code == "IMPORT_QUOTA_EXCEEDED"
|
||
|
||
|
||
def same_page_rocket_lines() -> list[tuple[int, str]]:
|
||
return [
|
||
(1, "ВЫПИСКА ПО СЧЕТУ"),
|
||
(1, "С 01.07.2026 ПО 10.09.2026"),
|
||
(1, "ДЕБЕТОВАЯ КАРТА РОКЕТ 00000000000000000001 RUR"),
|
||
(1, "ВХОДЯЩИЙ ОСТАТОК ПОСТУПЛЕНИЯ"),
|
||
(1, "0,00 RUR 0,00 RUR"),
|
||
(1, "ИСХОДЯЩИЙ ОСТАТОК РАСХОДЫ"),
|
||
(1, "0,00 RUR 0,00 RUR"),
|
||
(1, "01 ИЮЛЯ 2026 БАЛАНС НА НАЧАЛО ДНЯ: 0,00 RUR"),
|
||
(1, "ВРЕМЯ КОД ОПЕРАЦИИ ОПИСАНИЕ СУММА"),
|
||
(1, "10:00 00000000001 -10,00 RUR"),
|
||
(1, "11:00 00000000002 -20,00 RUR"),
|
||
(1, "12:00 00000000003 -30,00 RUR"),
|
||
(1, "ВСЕГО ПОСТУПЛЕНИЙ: 0,00 RUR"),
|
||
(1, "ВСЕГО РАСХОДОВ: 60,00 RUR"),
|
||
]
|
||
|
||
|
||
def synthetic_rocket_lines() -> list[tuple[int, str]]:
|
||
return [
|
||
(1, "ВЫПИСКА ПО СЧЕТУ"),
|
||
(1, "ДАТА ВЫДАЧИ ВЫПИСКИ: 11.09.2026"),
|
||
(1, "С 01.07.2026 ПО 10.09.2026"),
|
||
(1, "ИВАНОВ ИВАН ИВАНОВИЧ"),
|
||
(1, "ДЕБЕТОВАЯ КАРТА РОКЕТ 00000000000000000001 RUR"),
|
||
(1, "ВХОДЯЩИЙ ОСТАТОК ПОСТУПЛЕНИЯ ДАТА ПОСЛЕДНЕГО ДВИЖЕНИЯ"),
|
||
(1, "0,00 RUR 1 000,00 RUR 01.07.2026"),
|
||
(1, "ИСХОДЯЩИЙ ОСТАТОК РАСХОДЫ"),
|
||
(1, "500,00 RUR 500,00 RUR"),
|
||
(1, "01 ИЮЛЯ 2026 БАЛАНС НА НАЧАЛО ДНЯ: 0,00 RUR"),
|
||
(1, "ВРЕМЯ КОД ОПЕРАЦИИ ОПИСАНИЕ СУММА"),
|
||
(1, "ЗАЧИСЛЕНИЕ ПЕРЕВОДА ДЕНЕЖНЫХ СРЕДСТВ/СБП/"),
|
||
(1, "12:00 00000000001 +1 000,00 RUR"),
|
||
(1, "СЧЕТ КОРРЕСПОНДЕНТА БАНК ВТБ (ПАО)."),
|
||
(1, "ВСЕГО ПОСТУПЛЕНИЙ: 1 000,00 RUR"),
|
||
(1, "ВСЕГО РАСХОДОВ: 0,00 RUR"),
|
||
(1, "СТРАНИЦА 1/2"),
|
||
(2, "ВЫПИСКА ПО СЧЕТУ"),
|
||
(2, "ДАТА ВЫДАЧИ ВЫПИСКИ: 11.09.2026"),
|
||
(2, "С 01.07.2026 ПО 10.09.2026"),
|
||
(2, "02 ИЮЛЯ 2026 БАЛАНС НА НАЧАЛО ДНЯ: 1 000,00 RUR"),
|
||
(2, "ВРЕМЯ КОД ОПЕРАЦИИ ОПИСАНИЕ СУММА"),
|
||
(2, "ПЛАТЕЖ АВТОРИЗАЦИЯ №123,01.07.2026 10:00,0000********0000,"),
|
||
(2, "13:30 00000000002 555RUR,EKATERINBURG,MCC 5411,SHOP\\RU\\CITY\\SHOP\\ -250,50 RUR"),
|
||
(2, "0000\\ ЗДАНИЕ."),
|
||
(2, "ВСЕГО ПОСТУПЛЕНИЙ: 0,00 RUR"),
|
||
(2, "ВСЕГО РАСХОДОВ: 250,50 RUR"),
|
||
(2, "СТРАНИЦА 2/2"),
|
||
]
|
||
|
||
|
||
class TestRocketbankDebitContract:
|
||
def setup_method(self) -> None:
|
||
self.adapter = RocketbankDebitPdfAdapter()
|
||
|
||
def test_day_and_page_state_machine(self) -> None:
|
||
draft = self.adapter._draft_from_lines(synthetic_rocket_lines())
|
||
assert len(draft.rows) == 2
|
||
first, second = draft.rows
|
||
assert first.occurred_on == date(2026, 7, 1)
|
||
assert second.occurred_on == date(2026, 7, 2)
|
||
|
||
def test_row_refs_are_unique_within_a_page(self) -> None:
|
||
draft = self.adapter._draft_from_lines(synthetic_rocket_lines())
|
||
first, second = draft.rows
|
||
assert first.row_ref == "page1:record1"
|
||
assert second.row_ref == "page2:record1"
|
||
same_page = self.adapter._draft_from_lines(same_page_rocket_lines())
|
||
refs = [row.row_ref for row in same_page.rows]
|
||
assert refs == ["page1:record1", "page1:record2", "page1:record3"]
|
||
assert len(set(refs)) == len(refs)
|
||
|
||
def test_statement_controls(self) -> None:
|
||
draft = self.adapter._draft_from_lines(synthetic_rocket_lines())
|
||
assert draft.adapter_id == "rocketbank_debit_pdf"
|
||
assert draft.bank_code == "rocketbank"
|
||
assert draft.currency == "RUB"
|
||
assert draft.account_fingerprint == account_fingerprint(
|
||
"rocketbank", "00000000000000000001"
|
||
)
|
||
assert draft.period_start == date(2026, 7, 1)
|
||
assert draft.period_end == date(2026, 9, 10)
|
||
assert draft.opening_balance_kopecks == 0
|
||
assert draft.closing_balance_kopecks == 50000
|
||
assert draft.declared_inflow_kopecks == 100000
|
||
assert draft.declared_outflow_kopecks == 50000
|
||
assert draft.declared_hold_kopecks is None
|
||
|
||
def test_record_folding_and_enrichment(self) -> None:
|
||
draft = self.adapter._draft_from_lines(synthetic_rocket_lines())
|
||
first, second = draft.rows
|
||
assert first.external_id == "00000000001"
|
||
assert (first.amount_kopecks, first.direction) == (100000, "in")
|
||
assert "ЗАЧИСЛЕНИЕ ПЕРЕВОДА" in (first.description or "")
|
||
assert "СЧЕТ КОРРЕСПОНДЕНТА" in (first.description or "")
|
||
assert first.mcc is None
|
||
assert second.external_id == "00000000002"
|
||
assert (second.amount_kopecks, second.direction) == (25050, "out")
|
||
assert second.mcc == 5411
|
||
assert second.location == "EKATERINBURG"
|
||
assert (second.description or "").startswith("ПЛАТЕЖ АВТОРИЗАЦИЯ")
|
||
assert "555RUR" in (second.description or "")
|
||
assert "ЗДАНИЕ." in (second.description or "")
|
||
assert all(row.warnings == () for row in draft.rows)
|
||
|
||
def test_repeated_headers_and_daily_totals_are_ignored(self) -> None:
|
||
draft = self.adapter._draft_from_lines(synthetic_rocket_lines())
|
||
joined = " ".join(row.description or "" for row in draft.rows)
|
||
assert "ВСЕГО" not in joined
|
||
assert "ВЫПИСКА ПО СЧЕТУ" not in joined
|
||
assert "СТРАНИЦА" not in joined
|
||
|
||
def test_parse_is_deterministic(self) -> None:
|
||
assert self.adapter._draft_from_lines(
|
||
synthetic_rocket_lines()
|
||
) == self.adapter._draft_from_lines(synthetic_rocket_lines())
|
||
|
||
def test_record_before_day_section_fails_closed(self) -> None:
|
||
lines = [
|
||
(1, "ВЫПИСКА ПО СЧЕТУ"),
|
||
(1, "ДЕБЕТОВАЯ КАРТА РОКЕТ 00000000000000000001 RUR"),
|
||
(1, "12:00 00000000001 +1 000,00 RUR"),
|
||
]
|
||
with pytest.raises(UnsupportedStatementLayout):
|
||
self.adapter._draft_from_lines(lines)
|
||
|
||
def test_missing_header_block_fails_closed(self) -> None:
|
||
lines = [
|
||
(1, "ВЫПИСКА ПО СЧЕТУ"),
|
||
(1, "ДЕБЕТОВАЯ КАРТА РОКЕТ 00000000000000000001 RUR"),
|
||
(1, "01 ИЮЛЯ 2026 БАЛАНС НА НАЧАЛО ДНЯ: 0,00 RUR"),
|
||
(1, "12:00 00000000001 +1 000,00 RUR"),
|
||
]
|
||
with pytest.raises(UnsupportedStatementLayout):
|
||
self.adapter._draft_from_lines(lines)
|
||
|
||
def test_unrecognized_content_fails_closed(self) -> None:
|
||
lines = synthetic_rocket_lines()[:11] + [(1, "НЕИЗВЕСТНАЯ СТРОКА БЕЗ СТРУКТУРЫ")]
|
||
with pytest.raises(UnsupportedStatementLayout) as exc:
|
||
self.adapter._draft_from_lines(lines)
|
||
assert exc.value.code == "IMPORT_UNSUPPORTED_LAYOUT"
|
||
|
||
def test_fold_line_cap_fails_closed(self) -> None:
|
||
junk = [(1, f"НЕИЗВЕСТНАЯ СТРОКА {index}") for index in range(7)]
|
||
lines = synthetic_rocket_lines()[:11] + junk
|
||
with pytest.raises(UnsupportedStatementLayout):
|
||
self.adapter._draft_from_lines(lines)
|
||
|
||
def test_detect_and_parse_fail_closed_on_blank_pdf(self) -> None:
|
||
raw = minimal_pdf()
|
||
assert self.adapter.detect(raw) is False
|
||
with pytest.raises(UnsupportedStatementLayout) as exc:
|
||
self.adapter.parse(raw)
|
||
assert "text layer" in exc.value.message
|
||
|
||
def test_oversized_pdf_is_rejected_by_quota(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
import app.imports.readers as readers_module
|
||
|
||
monkeypatch.setattr(readers_module, "get_settings", lambda: _QuotaSettings())
|
||
with pytest.raises(AppError) as exc:
|
||
self.adapter.parse(minimal_pdf(page_count=2))
|
||
assert exc.value.code == "IMPORT_QUOTA_EXCEEDED"
|
||
|
||
|
||
class _QuotaSettings:
|
||
max_pdf_pages = 1
|
||
|
||
|
||
def minimal_pdf(page_count: int = 1, page_text: str = "") -> bytes:
|
||
"""Build a small valid single-font PDF with empty or ASCII-only pages."""
|
||
objects: list[tuple[int, bytes]] = []
|
||
page_ids: list[int] = []
|
||
next_id = 3
|
||
for _ in range(page_count):
|
||
page_id = next_id
|
||
content_id = next_id + 1
|
||
next_id += 2
|
||
page_ids.append(page_id)
|
||
content = (
|
||
f"BT /F1 12 Tf 72 720 Td ({page_text}) Tj ET".encode("latin-1", "replace")
|
||
if page_text
|
||
else b""
|
||
)
|
||
objects.append(
|
||
(
|
||
content_id,
|
||
b"<< /Length %d >>\nstream\n%s\nendstream" % (len(content), content),
|
||
)
|
||
)
|
||
objects.append(
|
||
(
|
||
page_id,
|
||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents %d 0 R >>"
|
||
% content_id,
|
||
)
|
||
)
|
||
font_id = next_id
|
||
objects.append((font_id, b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"))
|
||
objects.insert(
|
||
0,
|
||
(
|
||
2,
|
||
b"<< /Type /Pages /Kids ["
|
||
+ b" ".join(b"%d 0 R" % page_id for page_id in page_ids)
|
||
+ b"] /Count %d >>" % page_count,
|
||
),
|
||
)
|
||
objects.insert(0, (1, b"<< /Type /Catalog /Pages 2 0 R >>"))
|
||
out = bytearray(b"%PDF-1.4\n")
|
||
offsets: dict[int, int] = {}
|
||
for number, body in objects:
|
||
offsets[number] = len(out)
|
||
out += b"%d 0 obj\n" % number + body + b"\nendobj\n"
|
||
xref_position = len(out)
|
||
size = max(offsets) + 1
|
||
out += b"xref\n0 %d\n" % size
|
||
out += b"0000000000 65535 f \n"
|
||
for number in range(1, size):
|
||
out += b"%010d 00000 n \n" % offsets[number]
|
||
out += b"trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n" % (
|
||
size,
|
||
xref_position,
|
||
)
|
||
return bytes(out)
|