946 lines
35 KiB
Python
946 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
|
||
|
||
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, object] = {
|
||
"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) # type: ignore[arg-type]
|
||
|
||
|
||
def make_draft(**overrides: object) -> StatementDraft:
|
||
values: dict[str, object] = {
|
||
"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) # type: ignore[arg-type]
|
||
|
||
|
||
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 = [
|
||
"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[object]]]]:
|
||
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 = [
|
||
[
|
||
[
|
||
[
|
||
"Дата операции",
|
||
"Документ",
|
||
"Назначение платежа",
|
||
"Сумма операции",
|
||
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)
|