"""Savings system resources, adjustments and convert/revert contracts.""" from datetime import date from uuid import uuid4 import pytest from sqlalchemy.orm import Session from app.audit.models import AuditEvent from app.budgeting.service import close_period, get_or_create_period from app.errors import AppError from app.identity.models import User from app.ledger.models import Account, Category, Operation, Posting from app.ledger.savings import ( adjust_balance, convert_expense_to_savings, ensure_savings_resources, get_savings_account, revert_savings_conversion, ) from app.ledger.service import ( account_balance, create_account, create_expense, create_income, create_refund, create_transfer, ) from app.spaces.service import create_space def seed(db: Session): user = User(email=f"{uuid4().hex}@example.com", password_hash="unused") db.add(user) db.flush() space = create_space(db, user=user, name="Savings") account = create_account( db, space_id=space.id, user=user, name="Debit", kind="debit", opened_on=date(2026, 3, 1), opening_amount_kopecks=500_000, ) return user, space, account def test_space_creation_builds_exactly_one_system_pair(db: Session) -> None: user, space, _ = seed(db) account, category = ensure_savings_resources(db, space_id=space.id) assert account.kind == "savings" assert account.system_key == "savings" assert category.kind == "savings_envelope" assert category.system_key == "savings" assert category.linked_account_id == account.id assert get_savings_account(db, space_id=space.id) is account assert db.query(Account).filter_by(space_id=space.id, kind="savings").count() == 1 # Idempotent: repeated ensure returns the same pair and adds no rows. again_account, again_category = ensure_savings_resources(db, space_id=space.id) assert (again_account.id, again_category.id) == (account.id, category.id) assert db.query(Account).filter_by(space_id=space.id).count() == 2 assert user is not None def test_same_named_user_account_is_not_captured(db: Session) -> None: user, space, _ = seed(db) impostor = create_account( db, space_id=space.id, user=user, name="Накопления", kind="cash", opened_on=date(2026, 3, 1), ) system_account, category = ensure_savings_resources(db, space_id=space.id) assert impostor.id != system_account.id assert impostor.system_key is None assert category.linked_account_id == system_account.id def test_adjust_balance_creates_single_signed_posting(db: Session) -> None: user, space, account = seed(db) operation, old_balance, new_balance = adjust_balance( db, space_id=space.id, user=user, account_id=account.id, target_balance_kopecks=475_000, occurred_on=date(2026, 3, 15), reason="Купюры пересчитаны вручную", ) assert (old_balance, new_balance) == (500_000, 475_000) assert operation.type == "adjustment" assert operation.amount_kopecks == 25_000 postings = db.query(Posting).filter_by(operation_id=operation.id).all() assert len(postings) == 1 assert postings[0].amount_kopecks == -25_000 assert account_balance(db, account) == 475_000 event = ( db.query(AuditEvent).filter_by(event_type="account.balance_adjusted").one() ) assert event.detail["old_balance_kopecks"] == 500_000 assert event.detail["new_balance_kopecks"] == 475_000 assert event.detail["delta_kopecks"] == -25_000 def test_adjust_balance_positive_delta_and_upward_adjustment(db: Session) -> None: user, space, account = seed(db) operation, _, _ = adjust_balance( db, space_id=space.id, user=user, account_id=account.id, target_balance_kopecks=510_000, occurred_on=date(2026, 3, 15), reason="Найдена купюра", ) assert operation.amount_kopecks == 10_000 postings = db.query(Posting).filter_by(operation_id=operation.id).all() assert postings[0].amount_kopecks == 10_000 assert account_balance(db, account) == 510_000 def test_adjust_balance_rejects_credit_accounts(db: Session) -> None: user, space, _ = seed(db) credit = create_account( db, space_id=space.id, user=user, name="Credit", kind="credit", opened_on=date(2026, 3, 1), opening_amount_kopecks=-500_000, ) with pytest.raises(AppError) as exc: adjust_balance( db, space_id=space.id, user=user, account_id=credit.id, target_balance_kopecks=0, occurred_on=date(2026, 3, 16), reason="Invalid sign", ) assert exc.value.code == "LEDGER_ADJUST_CREDIT_UNSUPPORTED" def test_adjust_balance_rejects_noop_negative_target_and_missing_reason( db: Session, ) -> None: user, space, account = seed(db) with pytest.raises(AppError) as exc: adjust_balance( db, space_id=space.id, user=user, account_id=account.id, target_balance_kopecks=500_000, occurred_on=date(2026, 3, 15), reason="same", ) assert exc.value.code == "LEDGER_ADJUST_NOOP" with pytest.raises(AppError) as exc: adjust_balance( db, space_id=space.id, user=user, account_id=account.id, target_balance_kopecks=-1, occurred_on=date(2026, 3, 15), reason="below zero", ) assert exc.value.code == "LEDGER_ADJUST_TARGET_NEGATIVE" with pytest.raises(AppError) as exc: adjust_balance( db, space_id=space.id, user=user, account_id=account.id, target_balance_kopecks=1, occurred_on=date(2026, 3, 15), reason=" ", ) assert exc.value.code == "LEDGER_ADJUST_REASON_REQUIRED" def test_adjust_balance_protects_closed_period(db: Session) -> None: user, space, account = seed(db) period = get_or_create_period(db, space_id=space.id, year=2026, month=3) close_period(db, space_id=space.id, user=user, period=period) with pytest.raises(AppError) as exc: adjust_balance( db, space_id=space.id, user=user, account_id=account.id, target_balance_kopecks=1, occurred_on=date(2026, 3, 20), reason="late fix", ) assert exc.value.code == "BUDGET_PERIOD_CLOSED" def test_convert_expense_to_savings_and_revert_roundtrip(db: Session) -> None: user, space, account = seed(db) savings_account, savings_category = ensure_savings_resources(db, space_id=space.id) category = Category(space_id=space.id, kind="monthly", custom_name="Food") db.add(category) db.flush() expense = create_expense( db, space_id=space.id, user=user, account_id=account.id, amount_kopecks=12_345, occurred_on=date(2026, 3, 5), category_id=category.id, description="Groceries", request_key="op-1", ) converted = convert_expense_to_savings( db, space_id=space.id, user=user, operation_id=expense.id ) assert converted.type == "transfer" assert converted.counter_account_id == savings_account.id assert converted.category_id == savings_category.id assert converted.converted_from_expense is True assert converted.previous_category_id == category.id assert converted.description == "Groceries" assert converted.request_key == "op-1" postings = db.query(Posting).filter_by(operation_id=converted.id).all() assert len(postings) == 2 assert sum(item.amount_kopecks for item in postings) == 0 assert account_balance(db, account) == 500_000 - 12_345 assert account_balance(db, savings_account) == 12_345 reverted = revert_savings_conversion( db, space_id=space.id, user=user, operation_id=expense.id ) assert reverted.type == "expense" assert reverted.counter_account_id is None assert reverted.category_id == category.id assert reverted.converted_from_expense is False assert reverted.previous_category_id is None postings_after = db.query(Posting).filter_by(operation_id=reverted.id).all() assert len(postings_after) == 1 assert postings_after[0].amount_kopecks == -12_345 assert account_balance(db, account) == 500_000 - 12_345 assert account_balance(db, savings_account) == 0 def test_convert_rejects_income_refund_transfer_and_double_conversion( db: Session, ) -> None: user, space, account = seed(db) income = create_income( db, space_id=space.id, user=user, account_id=account.id, amount_kopecks=1_000, occurred_on=date(2026, 3, 2), ) with pytest.raises(AppError) as exc: convert_expense_to_savings( db, space_id=space.id, user=user, operation_id=income.id ) assert exc.value.code == "LEDGER_SAVINGS_CONVERT_INVALID" refundable_expense = create_expense( db, space_id=space.id, user=user, account_id=account.id, amount_kopecks=2_000, occurred_on=date(2026, 3, 3), ) refund = create_refund( db, space_id=space.id, user=user, parent_operation_id=refundable_expense.id, amount_kopecks=500, occurred_on=date(2026, 3, 4), ) with pytest.raises(AppError) as exc: convert_expense_to_savings( db, space_id=space.id, user=user, operation_id=refund.id ) assert exc.value.code == "LEDGER_SAVINGS_CONVERT_INVALID" expense = create_expense( db, space_id=space.id, user=user, account_id=account.id, amount_kopecks=2_000, occurred_on=date(2026, 3, 5), ) convert_expense_to_savings(db, space_id=space.id, user=user, operation_id=expense.id) with pytest.raises(AppError) as exc: convert_expense_to_savings( db, space_id=space.id, user=user, operation_id=expense.id ) assert exc.value.code == "LEDGER_SAVINGS_CONVERT_INVALID" transfer = create_transfer( db, space_id=space.id, user=user, source_account_id=account.id, target_account_id=get_savings_account(db, space_id=space.id).id, amount_kopecks=1_000, occurred_on=date(2026, 3, 6), ) with pytest.raises(AppError) as exc: convert_expense_to_savings( db, space_id=space.id, user=user, operation_id=transfer.id ) assert exc.value.code == "LEDGER_SAVINGS_CONVERT_INVALID" def test_convert_rejects_expense_with_confirmed_refund(db: Session) -> None: user, space, account = seed(db) expense = create_expense( db, space_id=space.id, user=user, account_id=account.id, amount_kopecks=2_000, occurred_on=date(2026, 3, 3), ) create_refund( db, space_id=space.id, user=user, parent_operation_id=expense.id, amount_kopecks=500, occurred_on=date(2026, 3, 4), ) with pytest.raises(AppError) as exc: convert_expense_to_savings( db, space_id=space.id, user=user, operation_id=expense.id ) assert exc.value.code == "LEDGER_SAVINGS_CONVERT_HAS_REFUNDS" def test_convert_rejects_closed_period_and_cross_space_operation(db: Session) -> None: user, space, account = seed(db) other_space = create_space(db, user=user, name="Other") category = Category(space_id=space.id, kind="monthly", custom_name="Food") db.add(category) db.flush() expense = create_expense( db, space_id=space.id, user=user, account_id=account.id, amount_kopecks=3_000, occurred_on=date(2026, 3, 7), category_id=category.id, ) period = get_or_create_period(db, space_id=space.id, year=2026, month=3) close_period(db, space_id=space.id, user=user, period=period) with pytest.raises(AppError) as exc: convert_expense_to_savings( db, space_id=space.id, user=user, operation_id=expense.id ) assert exc.value.code == "BUDGET_PERIOD_CLOSED" with pytest.raises(AppError) as exc: convert_expense_to_savings( db, space_id=other_space.id, user=user, operation_id=expense.id ) assert exc.value.code == "LEDGER_OPERATION_NOT_FOUND" def test_revert_rejects_plain_transfers_and_unknown_operations(db: Session) -> None: user, space, account = seed(db) savings_account, _ = ensure_savings_resources(db, space_id=space.id) plain = create_transfer( db, space_id=space.id, user=user, source_account_id=account.id, target_account_id=savings_account.id, amount_kopecks=500, occurred_on=date(2026, 3, 8), ) with pytest.raises(AppError) as exc: revert_savings_conversion(db, space_id=space.id, user=user, operation_id=plain.id) assert exc.value.code == "LEDGER_SAVINGS_REVERT_INVALID" with pytest.raises(AppError) as exc: revert_savings_conversion(db, space_id=space.id, user=user, operation_id=uuid4()) assert exc.value.code == "LEDGER_OPERATION_NOT_FOUND" def test_system_account_rejects_external_operations(db: Session) -> None: user, space, _ = seed(db) savings_account, savings_category = ensure_savings_resources(db, space_id=space.id) with pytest.raises(AppError) as exc: create_expense( db, space_id=space.id, user=user, account_id=savings_account.id, amount_kopecks=100, occurred_on=date(2026, 3, 9), ) assert exc.value.code == "LEDGER_SYSTEM_ACCOUNT_NOT_EXTERNAL" with pytest.raises(AppError) as exc: create_income( db, space_id=space.id, user=user, account_id=savings_account.id, amount_kopecks=100, occurred_on=date(2026, 3, 9), ) assert exc.value.code == "LEDGER_SYSTEM_ACCOUNT_NOT_EXTERNAL" assert savings_category is not None def test_manual_transfer_with_savings_gets_system_category(db: Session) -> None: user, space, account = seed(db) cash = create_account( db, space_id=space.id, user=user, name="Cash", kind="cash", opened_on=date(2026, 3, 1), ) savings_account, savings_category = ensure_savings_resources(db, space_id=space.id) into = create_transfer( db, space_id=space.id, user=user, source_account_id=account.id, target_account_id=savings_account.id, amount_kopecks=700, occurred_on=date(2026, 3, 10), ) assert into.category_id == savings_category.id out_of = create_transfer( db, space_id=space.id, user=user, source_account_id=savings_account.id, target_account_id=cash.id, amount_kopecks=200, occurred_on=date(2026, 3, 11), ) assert out_of.category_id == savings_category.id regular = create_transfer( db, space_id=space.id, user=user, source_account_id=account.id, target_account_id=cash.id, amount_kopecks=300, occurred_on=date(2026, 3, 12), ) assert regular.category_id is None def test_system_account_cannot_be_archived_or_deleted(db: Session) -> None: user, space, _ = seed(db) savings_account, _ = ensure_savings_resources(db, space_id=space.id) from app.ledger.service import archive_account, delete_account with pytest.raises(AppError) as exc: archive_account(db, space_id=space.id, user=user, account_id=savings_account.id) assert exc.value.code == "LEDGER_ACCOUNT_SYSTEM" with pytest.raises(AppError) as exc: delete_account(db, space_id=space.id, user=user, account_id=savings_account.id) assert exc.value.code == "LEDGER_ACCOUNT_SYSTEM" def test_operations_on_other_spaces_are_isolated(db: Session) -> None: user, space, account = seed(db) other_space = create_space(db, user=user, name="Elsewhere") expense = create_expense( db, space_id=space.id, user=user, account_id=account.id, amount_kopecks=1_500, occurred_on=date(2026, 3, 14), ) convert_expense_to_savings(db, space_id=space.id, user=user, operation_id=expense.id) assert ( db.query(Operation).filter_by(space_id=other_space.id).count() == 0 )