Files
finance/backend/app/ledger/savings.py
T
k1nq 970fe9dacf
Gitea Actions CI / detect (push) Successful in 3s
Gitea Actions CI / Compose & nginx validation (push) Failing after 14s
Gitea Actions CI / Frontend (lint / type / test / build) (push) Successful in 45s
Gitea Actions CI / Backend (lint / type / test / build) (push) Successful in 1m31s
Refactor code for improved readability and consistency across multiple files
2026-09-23 15:22:26 +05:00

327 lines
11 KiB
Python

"""System savings account/envelope, balance adjustments and reversible conversions.
The savings "convert" identity is defined exclusively by ``system_key``:
user-created accounts or categories that merely share the name «Накопления»
are never captured. Every command is tenant-scoped, period-guarded and
audited; balances stay derived (postings only, never stored mutations).
"""
import uuid
from datetime import date
from sqlalchemy.orm import Session
from app.audit import service as audit
from app.errors import bad_request, conflict, not_found
from app.identity.models import User
from app.ledger.models import Account, Category, Operation, Posting
SAVINGS_SYSTEM_KEY = "savings"
SAVINGS_ACCOUNT_NAME = "Накопления"
def get_savings_account(db: Session, *, space_id: uuid.UUID) -> Account | None:
return (
db.query(Account).filter_by(space_id=space_id, system_key=SAVINGS_SYSTEM_KEY).one_or_none()
)
def _savings_category(db: Session, *, space_id: uuid.UUID) -> Category | None:
return (
db.query(Category).filter_by(space_id=space_id, system_key=SAVINGS_SYSTEM_KEY).one_or_none()
)
def ensure_savings_resources(db: Session, *, space_id: uuid.UUID) -> tuple[Account, Category]:
"""Idempotently return the single system savings account and its envelope.
Any duplicate or inconsistent system pair is an explicit conflict, never a
random pick: identity is keyed by ``system_key`` only.
"""
accounts = db.query(Account).filter_by(space_id=space_id, system_key=SAVINGS_SYSTEM_KEY).all()
if len(accounts) > 1:
raise conflict(
"LEDGER_SAVINGS_CONFLICT",
"Multiple system savings accounts exist in this space",
)
account: Account | None = accounts[0] if accounts else None
created = False
if account is None:
account = Account(
space_id=space_id,
name=SAVINGS_ACCOUNT_NAME,
kind="savings",
system_key=SAVINGS_SYSTEM_KEY,
opened_on=date.today(),
opening_amount_kopecks=0,
)
db.add(account)
db.flush()
created = True
categories = (
db.query(Category).filter_by(space_id=space_id, system_key=SAVINGS_SYSTEM_KEY).all()
)
if len(categories) > 1:
raise conflict(
"LEDGER_SAVINGS_CONFLICT",
"Multiple system savings categories exist in this space",
)
category: Category | None = categories[0] if categories else None
if category is not None and category.linked_account_id != account.id:
raise conflict(
"LEDGER_SAVINGS_CONFLICT",
"System savings category is linked to a different account",
)
if category is None:
category = Category(
space_id=space_id,
kind="savings_envelope",
system_key=SAVINGS_SYSTEM_KEY,
linked_account_id=account.id,
)
db.add(category)
db.flush()
created = True
if created:
audit.record(
db,
event_type="savings.resources_ensured",
space_id=space_id,
detail={
"account_id": str(account.id),
"category_id": str(category.id),
},
)
return account, category
def transfer_category_for_accounts(
db: Session, *, space_id: uuid.UUID, source: Account, target: Account
) -> Category | None:
"""System category for a manual transfer touching the savings account."""
if SAVINGS_SYSTEM_KEY not in {source.system_key, target.system_key}:
return None
_, category = ensure_savings_resources(db, space_id=space_id)
return category
def adjust_balance(
db: Session,
*,
space_id: uuid.UUID,
user: User,
account_id: uuid.UUID,
target_balance_kopecks: int,
occurred_on: date,
reason: str,
) -> tuple[Operation, int, int]:
"""Reconcile a derived balance with reality via one audited adjustment.
The operation carries one signed posting for the delta; ``amount_kopecks``
stays positive and the sign lives in the posting. Adjustments never touch
income/expense totals, category spend, cash flow or the budget pool.
"""
if not reason.strip():
raise bad_request("LEDGER_ADJUST_REASON_REQUIRED", "An adjustment reason is required")
account = (
db.query(Account)
.filter_by(id=account_id, space_id=space_id)
.with_for_update()
.one_or_none()
)
if account is None:
raise not_found("LEDGER_ACCOUNT_NOT_FOUND", "Account not found")
if account.status != "active":
raise conflict("LEDGER_ACCOUNT_ARCHIVED", "Account is archived")
if account.kind == "credit":
raise conflict(
"LEDGER_ADJUST_CREDIT_UNSUPPORTED",
"Credit account balances cannot be adjusted with a non-negative target",
)
if target_balance_kopecks < 0:
raise bad_request("LEDGER_ADJUST_TARGET_NEGATIVE", "Target balance cannot be negative")
from app.ledger.service import account_balance, ensure_period_open_for_date
ensure_period_open_for_date(db, space_id=space_id, day=occurred_on)
old_balance = account_balance(db, account)
delta = target_balance_kopecks - old_balance
if delta == 0:
raise conflict(
"LEDGER_ADJUST_NOOP",
"Balance already equals the target; nothing to adjust",
details={"balance_kopecks": old_balance},
)
operation = Operation(
space_id=space_id,
type="adjustment",
amount_kopecks=abs(delta),
account_id=account.id,
occurred_on=occurred_on,
description=reason.strip(),
created_by_user_id=user.id,
)
db.add(operation)
db.flush()
db.add(
Posting(
space_id=space_id,
operation_id=operation.id,
account_id=account.id,
amount_kopecks=delta,
)
)
audit.record(
db,
event_type="account.balance_adjusted",
space_id=space_id,
user_id=user.id,
detail={
"account_id": str(account.id),
"operation_id": str(operation.id),
"old_balance_kopecks": old_balance,
"new_balance_kopecks": target_balance_kopecks,
"delta_kopecks": delta,
"reason": reason.strip(),
},
)
return operation, old_balance, target_balance_kopecks
def convert_expense_to_savings(
db: Session, *, space_id: uuid.UUID, user: User, operation_id: uuid.UUID
) -> Operation:
"""Turn a confirmed expense into a transfer into the system savings account.
Keeps the original negative posting plus description, payee, request key
and import provenance; adds one positive posting on the savings account and
remembers the previous category for exact reversion.
"""
operation = (
db.query(Operation)
.filter_by(id=operation_id, space_id=space_id)
.with_for_update()
.one_or_none()
)
if operation is None:
raise not_found("LEDGER_OPERATION_NOT_FOUND", "Operation not found")
if operation.status != "confirmed":
raise conflict(
"LEDGER_SAVINGS_CONVERT_INVALID",
"Only confirmed operations can be converted",
)
if operation.type != "expense":
raise conflict(
"LEDGER_SAVINGS_CONVERT_INVALID",
"Only expenses can be converted into savings transfers",
)
if operation.converted_from_expense:
raise conflict(
"LEDGER_SAVINGS_CONVERT_INVALID",
"Operation is already a savings transfer",
)
source_account = (
db.query(Account).filter_by(id=operation.account_id, space_id=space_id).one_or_none()
)
if source_account is None:
raise not_found("LEDGER_ACCOUNT_NOT_FOUND", "Account not found")
if source_account.system_key is not None:
raise conflict(
"LEDGER_SAVINGS_CONVERT_INVALID",
"Operations on system accounts cannot be converted",
)
confirmed_refund = (
db.query(Operation.id)
.filter_by(
space_id=space_id,
parent_operation_id=operation.id,
type="refund",
status="confirmed",
)
.first()
)
if confirmed_refund is not None:
raise conflict(
"LEDGER_SAVINGS_CONVERT_HAS_REFUNDS",
"Expenses with confirmed refunds cannot be converted into savings transfers",
)
from app.ledger.service import ensure_period_open_for_date
ensure_period_open_for_date(db, space_id=space_id, day=operation.occurred_on)
savings_account, savings_category = ensure_savings_resources(db, space_id=space_id)
previous_category_id = operation.category_id
operation.previous_category_id = previous_category_id
operation.converted_from_expense = True
operation.type = "transfer"
operation.counter_account_id = savings_account.id
operation.category_id = savings_category.id
db.add(
Posting(
space_id=space_id,
operation_id=operation.id,
account_id=savings_account.id,
amount_kopecks=operation.amount_kopecks,
)
)
audit.record(
db,
event_type="operation.savings_transfer",
space_id=space_id,
user_id=user.id,
detail={
"operation_id": str(operation.id),
"savings_account_id": str(savings_account.id),
"amount_kopecks": operation.amount_kopecks,
"previous_category_id": (str(previous_category_id) if previous_category_id else None),
},
)
return operation
def revert_savings_conversion(
db: Session, *, space_id: uuid.UUID, user: User, operation_id: uuid.UUID
) -> Operation:
"""Undo a marked conversion: restore the expense type, category and postings."""
operation = (
db.query(Operation)
.filter_by(id=operation_id, space_id=space_id)
.with_for_update()
.one_or_none()
)
if operation is None:
raise not_found("LEDGER_OPERATION_NOT_FOUND", "Operation not found")
if operation.status != "confirmed":
raise conflict(
"LEDGER_SAVINGS_REVERT_INVALID",
"Only confirmed operations can be reverted",
)
if operation.type != "transfer" or not operation.converted_from_expense:
raise conflict(
"LEDGER_SAVINGS_REVERT_INVALID",
"Only converted expenses can be reverted from savings",
)
from app.ledger.service import ensure_period_open_for_date
ensure_period_open_for_date(db, space_id=space_id, day=operation.occurred_on)
counter_account_id = operation.counter_account_id
if counter_account_id is not None:
savings_posting = (
db.query(Posting)
.filter_by(operation_id=operation.id, account_id=counter_account_id)
.one_or_none()
)
if savings_posting is not None:
db.delete(savings_posting)
operation.type = "expense"
operation.counter_account_id = None
operation.category_id = operation.previous_category_id
operation.converted_from_expense = False
operation.previous_category_id = None
audit.record(
db,
event_type="operation.savings_transfer_reverted",
space_id=space_id,
user_id=user.id,
detail={"operation_id": str(operation.id)},
)
return operation