import uuid from contextlib import contextmanager from datetime import date import pytest from sqlalchemy.orm import Session from app.errors import AppError from app.identity.models import User from app.imports import service from app.imports.adapters.base import SourceRow from app.imports.models import ( DedupCandidate, ParseRun, SourceRecord, SourceRecordLink, StatementDocument, ) from app.ledger.models import Operation from app.ledger.service import create_account from app.spaces.service import create_space 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="Imports") account = create_account( db, space_id=space.id, user=user, name="Debit", kind="debit", opened_on=date(2026, 1, 1), ) return user, space, account def ready_document(db: Session): user, space, account = seed(db) document = StatementDocument( space_id=space.id, account_id=account.id, original_filename="synthetic.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 source = SourceRecord( space_id=space.id, document_id=document.id, parse_run_id=run.id, occurred_on=date(2026, 1, 5), amount_kopecks=12_345, direction="out", row_ref="sheet0:row2", record_fp=uuid.uuid4().hex, ) db.add(source) db.flush() return user, space, document def test_confirm_and_revert_are_idempotent(db: Session) -> None: user, space, document = ready_document(db) service.confirm_document(db, space_id=space.id, document_id=document.id, user=user) service.confirm_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() == 1 assert db.query(SourceRecordLink).filter_by(space_id=space.id).count() == 1 service.revert_document(db, space_id=space.id, document_id=document.id, user=user) 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 def test_confirm_rejects_arithmetic_mismatch(db: Session) -> None: user, space, document = ready_document(db) document.reconciliation = {"full": True, "matched": False} 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_RECONCILIATION_MISMATCH" def test_confirmed_import_must_be_reverted_before_reparse(db: Session) -> None: user, space, document = ready_document(db) service.confirm_document(db, space_id=space.id, document_id=document.id, user=user) with pytest.raises(AppError) as exc: service.prepare_reparse(db, space_id=space.id, document_id=document.id, user=user) assert exc.value.code == "IMPORT_REPARSE_CONFIRMED" def test_invalid_direction_is_critical() -> None: source = service._source_from_row( space_id=uuid.uuid4(), document_id=uuid.uuid4(), run_id=uuid.uuid4(), row=SourceRow( external_id=None, occurred_on=date(2026, 1, 1), posted_on=None, amount_kopecks=100, direction="sideways", description=None, mcc=None, bank_category=None, location=None, row_ref="row1", ), ) assert source.critical is True assert source.field_warnings == {"codes": ["direction_invalid"]} def test_unexpected_parser_failure_reaches_terminal_state(db: Session, monkeypatch) -> None: _user, space, account = seed(db) document = StatementDocument( space_id=space.id, account_id=account.id, original_filename="synthetic.pdf", size_bytes=10, content_sha256=uuid.uuid4().hex + uuid.uuid4().hex, object_key=uuid.uuid4().hex, dek_wrapped=b"key", dek_wrap_nonce=b"nonce", content_nonce=b"nonce", status="queued", ) db.add(document) db.flush() @contextmanager def existing_session(_space_id): yield db class BrokenStore: def get(self, _key): raise RuntimeError("storage unavailable") monkeypatch.setattr(service, "session_scope", existing_session) monkeypatch.setattr(service, "get_blob_store", lambda: BrokenStore()) service.process_statement_document(document.id, space.id) assert document.status == "failed" assert document.error_code == "IMPORT_PARSE_FAILED" def test_duplicate_candidate_can_reuse_unique_operation_without_client_id(db: Session) -> None: user, space, first_document = ready_document(db) service.confirm_document(db, space_id=space.id, document_id=first_document.id, user=user) first_source = ( db.query(SourceRecord).filter_by(parse_run_id=first_document.active_parse_run_id).one() ) second = StatementDocument( space_id=space.id, account_id=first_document.account_id, original_filename="second.xlsx", size_bytes=100, content_sha256=uuid.uuid4().hex + uuid.uuid4().hex, object_key=uuid.uuid4().hex, status="review_required", reconciliation={"full": True, "matched": True}, ) db.add(second) db.flush() run = ParseRun( space_id=space.id, document_id=second.id, adapter_id="alfa_debit_xlsx", status="succeeded", ) db.add(run) db.flush() second.active_parse_run_id = run.id duplicate_source = SourceRecord( space_id=space.id, document_id=second.id, parse_run_id=run.id, occurred_on=first_source.occurred_on, amount_kopecks=first_source.amount_kopecks, direction=first_source.direction, row_ref="sheet0:row9", record_fp=first_source.record_fp, ) db.add(duplicate_source) db.flush() assert service._build_candidates(db, document=second, run=run) candidate = db.query(DedupCandidate).filter_by(source_record_id=duplicate_source.id).one() assert candidate.candidate_kind == "duplicate" service.decide_source_record( db, space_id=space.id, document_id=second.id, source_record_id=duplicate_source.id, decision="duplicate", duplicate_operation_id=None, user=user, ) assert second.status == "ready" service.confirm_document(db, space_id=space.id, document_id=second.id, user=user) assert db.query(Operation).filter_by(space_id=space.id).count() == 1 def test_transfer_merge_revert_restores_counterpart_operation(db: Session) -> None: user, space, first_document = ready_document(db) service.confirm_document(db, space_id=space.id, document_id=first_document.id, user=user) first_source = ( db.query(SourceRecord).filter_by(parse_run_id=first_document.active_parse_run_id).one() ) target = create_account( db, space_id=space.id, user=user, name="Second debit", kind="debit", opened_on=date(2026, 1, 1), ) second = StatementDocument( space_id=space.id, account_id=target.id, original_filename="target.xlsx", size_bytes=100, content_sha256=uuid.uuid4().hex + uuid.uuid4().hex, object_key=uuid.uuid4().hex, status="review_required", reconciliation={"full": True, "matched": True}, ) db.add(second) db.flush() run = ParseRun( space_id=space.id, document_id=second.id, adapter_id="alfa_debit_xlsx", status="succeeded", ) db.add(run) db.flush() second.active_parse_run_id = run.id incoming = SourceRecord( space_id=space.id, document_id=second.id, parse_run_id=run.id, occurred_on=first_source.occurred_on, amount_kopecks=first_source.amount_kopecks, direction="in", row_ref="sheet0:row10", record_fp=uuid.uuid4().hex, ) db.add(incoming) db.flush() assert service._build_candidates(db, document=second, run=run) candidate = db.query(DedupCandidate).filter_by(source_record_id=incoming.id).one() assert candidate.candidate_kind == "transfer" service.decide_source_record( db, space_id=space.id, document_id=second.id, source_record_id=incoming.id, decision="transfer", duplicate_operation_id=None, user=user, ) service.confirm_document(db, space_id=space.id, document_id=second.id, user=user) assert db.query(Operation).filter_by(space_id=space.id, type="transfer").count() == 1 service.revert_document(db, space_id=space.id, document_id=second.id, user=user) assert db.query(Operation).filter_by(space_id=space.id, type="transfer").count() == 0 assert db.query(Operation).filter_by(space_id=space.id, type="expense").count() == 1