Files
finance/backend/tests/test_savings.py
T

496 lines
16 KiB
Python

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