496 lines
16 KiB
Python
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
|
|
)
|