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.
390 lines
13 KiB
Python
390 lines
13 KiB
Python
"""Import contract for the «Копилка для сдачи» savings_transfer decision."""
|
|
|
|
import uuid
|
|
from datetime import date
|
|
from types import SimpleNamespace
|
|
from typing import cast
|
|
|
|
import pytest
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.dependencies import RequestContext
|
|
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)
|
|
assert savings_account is not None
|
|
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)
|
|
assert savings_account is not None
|
|
|
|
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 = cast(RequestContext, 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)
|
|
assert savings_account is not None
|
|
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"
|