Update file metadata in manifest.json and change MinIO image source in docker-compose.yml
This commit is contained in:
@@ -0,0 +1,495 @@
|
||||
"""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
|
||||
)
|
||||
Reference in New Issue
Block a user