Files
finance/backend/tests/test_import_savings.py
T

387 lines
13 KiB
Python

"""Import contract for the «Копилка для сдачи» savings_transfer decision."""
import uuid
from datetime import date
from types import SimpleNamespace
import pytest
from sqlalchemy.orm import Session
from app.errors import AppError
from app.identity.models import User
from app.imports import router as imports_router
from app.imports import service
from app.imports.adapters.alfa_xlsx import detect_savings_transfer
from app.imports.models import (
DedupCandidate,
ParseRun,
SourceRecord,
SourceRecordLink,
StatementDocument,
)
from app.ledger.models import Operation
from app.ledger.savings import get_savings_account
from app.ledger.service import create_account
from app.spaces.service import create_space
SAVINGS_PHRASE = (
'Перечисление средств в рамках услуги "Копилка для сдачи" '
"со счета 00000000000000000001 на счет 00000000000000000306"
)
def seed(db: Session):
user = User(email=f"{uuid.uuid4().hex}@example.com", password_hash="unused")
db.add(user)
db.flush()
space = create_space(db, user=user, name="ImportSavings")
account = create_account(
db,
space_id=space.id,
user=user,
name="Alfa Debit",
kind="debit",
opened_on=date(2026, 1, 1),
)
return user, space, account
def make_document(db: Session, space, account, *, user: User) -> tuple[StatementDocument, ParseRun]:
document = StatementDocument(
space_id=space.id,
account_id=account.id,
original_filename=f"{uuid.uuid4().hex}.xlsx",
size_bytes=100,
content_sha256=uuid.uuid4().hex + uuid.uuid4().hex,
object_key=uuid.uuid4().hex,
status="ready",
reconciliation={"full": True, "matched": True},
uploaded_by_user_id=user.id,
)
db.add(document)
db.flush()
run = ParseRun(
space_id=space.id,
document_id=document.id,
adapter_id="alfa_debit_xlsx",
status="succeeded",
)
db.add(run)
db.flush()
document.active_parse_run_id = run.id
return document, run
def add_record(
db: Session,
document: StatementDocument,
run: ParseRun,
*,
bank_status: str = "posted",
direction: str = "out",
external_id: str | None = None,
description: str | None = None,
occurred_on: date = date(2026, 5, 10),
) -> SourceRecord:
source = SourceRecord(
space_id=document.space_id,
document_id=document.id,
parse_run_id=run.id,
external_id=external_id,
occurred_on=occurred_on,
amount_kopecks=12_345,
direction=direction,
bank_status=bank_status,
description=description,
row_ref=f"sheet0:row{uuid.uuid4().int % 1000}",
record_fp=uuid.uuid4().hex,
review_decision="savings_transfer"
if detect_savings_transfer(
description,
direction=direction,
bank_status=bank_status,
statement_account="00000000000000000001",
)
else "create",
)
db.add(source)
db.flush()
return source
def test_parsed_savings_hint_becomes_initial_review_decision(db: Session) -> None:
user, space, account = seed(db)
document, run = make_document(db, space, account, user=user)
source = add_record(db, document, run, description=SAVINGS_PHRASE)
assert source.review_decision == "savings_transfer"
def test_confirm_creates_savings_transfer_with_system_category(db: Session) -> None:
user, space, account = seed(db)
document, run = make_document(db, space, account, user=user)
source = add_record(
db,
document,
run,
external_id="ALFA-1",
description=SAVINGS_PHRASE,
occurred_on=date(2026, 5, 10),
)
assert source.review_decision == "savings_transfer"
service.confirm_document(db, space_id=space.id, document_id=document.id, user=user)
db.flush()
savings_account = get_savings_account(db, space_id=space.id)
operations = db.query(Operation).filter_by(space_id=space.id).all()
assert len(operations) == 1
transfer = operations[0]
assert transfer.type == "transfer"
assert transfer.account_id == account.id
assert transfer.counter_account_id == savings_account.id
from app.ledger.models import Category
category = db.get(Category, transfer.category_id)
assert category is not None
assert category.system_key == "savings"
assert transfer.request_key == f"import-savings:{source.id}"
assert transfer.description == SAVINGS_PHRASE
link = db.query(SourceRecordLink).filter_by(source_record_id=source.id).one()
assert link.operation_id == transfer.id
assert link.kind == "created"
assert document.status == "confirmed"
def test_revert_removes_created_savings_transfer(db: Session) -> None:
user, space, account = seed(db)
document, run = make_document(db, space, account, user=user)
add_record(db, document, run, description=SAVINGS_PHRASE)
service.confirm_document(db, space_id=space.id, document_id=document.id, user=user)
assert db.query(Operation).filter_by(space_id=space.id).count() == 1
service.revert_document(db, space_id=space.id, document_id=document.id, user=user)
db.flush()
assert db.query(Operation).filter_by(space_id=space.id).count() == 0
assert db.query(SourceRecordLink).filter_by(space_id=space.id).count() == 0
assert document.status == "ready"
source = (
db.query(SourceRecord).filter_by(parse_run_id=document.active_parse_run_id).one()
)
# Repeated parse gives the same decision: the hint survives revert.
assert source.review_decision == "savings_transfer"
def test_user_can_switch_savings_decision_to_create(db: Session) -> None:
user, space, account = seed(db)
document, run = make_document(db, space, account, user=user)
source = add_record(db, document, run, description=SAVINGS_PHRASE)
service.decide_source_record(
db,
space_id=space.id,
document_id=document.id,
source_record_id=source.id,
decision="create",
duplicate_operation_id=None,
user=user,
)
assert source.review_decision == "create"
service.confirm_document(db, space_id=space.id, document_id=document.id, user=user)
operations = db.query(Operation).filter_by(space_id=space.id).all()
assert len(operations) == 1
assert operations[0].type == "expense"
def test_user_can_switch_savings_decision_to_discard(db: Session) -> None:
user, space, account = seed(db)
document, run = make_document(db, space, account, user=user)
source = add_record(db, document, run, description=SAVINGS_PHRASE)
service.decide_source_record(
db,
space_id=space.id,
document_id=document.id,
source_record_id=source.id,
decision="discard",
duplicate_operation_id=None,
user=user,
)
assert source.review_decision == "discard"
service.confirm_document(db, space_id=space.id, document_id=document.id, user=user)
assert db.query(Operation).filter_by(space_id=space.id).count() == 0
def test_savings_decision_rejects_incoming_rows(db: Session) -> None:
user, space, account = seed(db)
document, run = make_document(db, space, account, user=user)
source = add_record(
db,
document,
run,
direction="in",
description="Incoming transfer",
)
with pytest.raises(AppError) as exc:
service.decide_source_record(
db,
space_id=space.id,
document_id=document.id,
source_record_id=source.id,
decision="savings_transfer",
duplicate_operation_id=None,
user=user,
)
assert exc.value.code == "IMPORT_DECISION_INVALID"
def test_statement_mapping_rejects_system_account(db: Session) -> None:
user, space, account = seed(db)
document, _run = make_document(db, space, account, user=user)
savings_account = get_savings_account(db, space_id=space.id)
with pytest.raises(AppError) as exc:
service.set_review_confirmation(
db,
space_id=space.id,
document_id=document.id,
account_id=savings_account.id,
confirmed_without_full_reconciliation=True,
reason="invalid mapping",
)
assert exc.value.code == "IMPORT_SYSTEM_ACCOUNT_INVALID"
def test_existing_dedup_wins_over_savings_creation(db: Session) -> None:
user, space, account = seed(db)
first, first_run = make_document(db, space, account, user=user)
external_id = "ALFA-DUP-1"
first_source = SourceRecord(
space_id=space.id,
document_id=first.id,
parse_run_id=first_run.id,
external_id=external_id,
occurred_on=date(2026, 5, 3),
amount_kopecks=12_345,
direction="out",
description="Groceries",
row_ref="sheet0:row3",
record_fp="fp-existing",
)
db.add(first_source)
db.flush()
service.confirm_document(db, space_id=space.id, document_id=first.id, user=user)
created = db.query(Operation).filter_by(space_id=space.id).one()
second, second_run = make_document(db, space, account, user=user)
duplicate = add_record(
db,
second,
second_run,
external_id=external_id,
description=SAVINGS_PHRASE,
occurred_on=date(2026, 5, 10),
)
assert service._build_candidates(db, document=second, run=second_run)
candidates = db.query(DedupCandidate).filter_by(source_record_id=duplicate.id).all()
assert len(candidates) == 1
assert candidates[0].candidate_kind == "duplicate"
assert candidates[0].existing_operation_id == created.id
service.decide_source_record(
db,
space_id=space.id,
document_id=second.id,
source_record_id=duplicate.id,
decision="duplicate",
duplicate_operation_id=created.id,
user=user,
)
service.confirm_document(db, space_id=space.id, document_id=second.id, user=user)
db.flush()
operations = db.query(Operation).filter_by(space_id=space.id).all()
assert len(operations) == 1
assert operations[0].id == created.id
def test_savings_transfer_on_hold_row_never_matches(db: Session) -> None:
assert (
detect_savings_transfer(
SAVINGS_PHRASE,
direction="out",
bank_status="hold",
statement_account="00000000000000000001",
)
is False
)
def test_unresolved_hold_counts_and_month_filter(db: Session) -> None:
user, space, account = seed(db)
document, run = make_document(db, space, account, user=user)
add_record(
db,
document,
run,
bank_status="hold",
description="Coffee shop",
occurred_on=date(2026, 5, 10),
)
add_record(
db,
document,
run,
bank_status="hold",
description="Train ticket",
occurred_on=date(2026, 6, 1),
)
resolved = add_record(
db,
document,
run,
bank_status="hold",
description="Resolved one",
occurred_on=date(2026, 5, 12),
)
db.add(
service.HoldResolution(
space_id=space.id,
hold_source_record_id=resolved.id,
resolution="confirmed_as_posted",
reason="verified",
)
)
db.flush()
unresolved = service.unresolved_holds(db, space_id=space.id, document=document)
assert len(unresolved) == 2
assert service.unresolved_hold_counts(
db, space_id=space.id, document_ids=[document.id]
) == {document.id: 2}
may = service.parse_month_filter("2026-05")
assert may == (2026, 5)
may_holds = [h for h in unresolved if h.occurred_on and h.occurred_on.month == 5]
assert len(may_holds) == 1
assert service.unresolved_hold_counts(
db, space_id=space.id, document_ids=[document.id], month=may
) == {document.id: 1}
context = SimpleNamespace(space=space)
july_items = imports_router.imports(db, context, month="2026-07")
assert july_items[0]["id"] == document.id
assert july_items[0]["unresolvedHoldCount"] == 0
document.status = "confirmed"
assert imports_router.imports(db, context, month="2026-07") == []
may_items = imports_router.imports(db, context, month="2026-05")
assert may_items[0]["id"] == document.id
assert may_items[0]["unresolvedHoldCount"] == 1
assert service.parse_month_filter(None) is None
with pytest.raises(AppError) as exc:
service.parse_month_filter("2026-13")
assert exc.value.code == "IMPORT_MONTH_INVALID"
def test_confirm_savings_transfer_rejects_savings_statement_account(db: Session) -> None:
user, space, account = seed(db)
savings_account = get_savings_account(db, space_id=space.id)
document, run = make_document(db, space, savings_account, user=user)
document.account_id = savings_account.id
add_record(db, document, run, description=SAVINGS_PHRASE)
with pytest.raises(AppError) as exc:
service.confirm_document(db, space_id=space.id, document_id=document.id, user=user)
assert exc.value.code == "IMPORT_SAVINGS_SOURCE_INVALID"