"""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"