Files
finance/backend/app/ledger/service.py
T

577 lines
19 KiB
Python

import uuid
from datetime import date
from decimal import Decimal, InvalidOperation
from typing import Any
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.audit import service as audit
from app.categorization import service as categorization
from app.errors import bad_request, conflict, not_found
from app.identity.models import User
from app.ledger.models import ACCOUNT_KINDS, Account, Operation, Posting
MAX_PAGE_SIZE = 200
UNSET = object()
def to_kopecks(value: int | str | Decimal) -> int:
"""Convert a decimal ruble amount to integer kopecks. Floats are rejected upstream."""
if isinstance(value, int):
return value
try:
dec = Decimal(str(value)).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError) as exc:
raise bad_request("MONEY_INVALID_AMOUNT", f"Invalid amount: {value!r}") from exc
return int(dec.scaleb(2))
def get_account(db: Session, *, space_id: uuid.UUID, account_id: uuid.UUID) -> Account:
account = db.query(Account).filter_by(id=account_id, space_id=space_id).one_or_none()
if account is None:
raise not_found("LEDGER_ACCOUNT_NOT_FOUND", "Account not found")
return account
def get_operation(db: Session, *, space_id: uuid.UUID, operation_id: uuid.UUID) -> Operation:
operation = db.query(Operation).filter_by(id=operation_id, space_id=space_id).one_or_none()
if operation is None:
raise not_found("LEDGER_OPERATION_NOT_FOUND", "Operation not found")
return operation
def _require_active_account(db: Session, *, space_id: uuid.UUID, account_id: uuid.UUID) -> Account:
account = get_account(db, space_id=space_id, account_id=account_id)
if account.status != "active":
raise conflict("LEDGER_ACCOUNT_ARCHIVED", "Account is archived")
return account
def _reject_system_account(account: Account) -> None:
"""System accounts (savings) are journal-internal and never statement-facing."""
if account.system_key is not None:
raise bad_request(
"LEDGER_SYSTEM_ACCOUNT_NOT_EXTERNAL",
"System accounts cannot host external income or expense operations",
)
def _validate_category(db: Session, *, space_id: uuid.UUID, category_id: uuid.UUID | None) -> None:
if category_id is None:
return
from app.ledger.models import Category
category = db.query(Category).filter_by(id=category_id, space_id=space_id).one_or_none()
if category is None:
raise not_found("BUDGET_CATEGORY_NOT_FOUND", "Category not found")
if category.archived:
raise conflict("BUDGET_CATEGORY_ARCHIVED", "Category is archived")
if category.system_key is not None:
raise conflict(
"BUDGET_CATEGORY_SYSTEM",
"System categories cannot be assigned to regular operations",
)
def account_balance(db: Session, account: Account) -> int:
total = (
db.query(func.coalesce(func.sum(Posting.amount_kopecks), 0))
.join(Operation, Operation.id == Posting.operation_id)
.filter(
Posting.account_id == account.id,
Operation.status == "confirmed",
)
.scalar()
)
return account.opening_amount_kopecks + int(total or 0)
def create_account(
db: Session,
*,
space_id: uuid.UUID,
user: User,
name: str,
kind: str,
opened_on: date,
opening_amount_kopecks: int = 0,
bank_code: str | None = None,
masked_identifier: str | None = None,
) -> Account:
if kind not in ACCOUNT_KINDS:
raise bad_request("LEDGER_ACCOUNT_KIND_INVALID", "Unknown account kind")
account = Account(
space_id=space_id,
name=name,
kind=kind,
opened_on=opened_on,
opening_amount_kopecks=int(opening_amount_kopecks),
bank_code=bank_code,
masked_identifier=masked_identifier,
)
db.add(account)
db.flush()
audit.record(
db,
event_type="account.created",
space_id=space_id,
user_id=user.id,
detail={"account_id": str(account.id), "kind": kind},
)
return account
def _existing_by_request_key(
db: Session, *, space_id: uuid.UUID, request_key: str | None
) -> Operation | None:
if request_key is None:
return None
return db.query(Operation).filter_by(space_id=space_id, request_key=request_key).one_or_none()
def _apply_suggestion(
db: Session,
*,
space_id: uuid.UUID,
operation: Operation,
description: str | None,
) -> None:
"""Precedence: explicit choice (already set) > confirmed payee rule."""
if operation.category_id is not None or not description:
return
payee = categorization.find_payee_by_description(db, space_id=space_id, description=description)
if payee is None:
return
operation.payee_id = payee.id
rule = categorization.rule_category_for_payee(db, space_id=space_id, payee_id=payee.id)
if rule is not None:
operation.category_id = rule.category_id
def create_expense(
db: Session,
*,
space_id: uuid.UUID,
user: User,
account_id: uuid.UUID,
amount_kopecks: int,
occurred_on: date,
category_id: uuid.UUID | None = None,
payee_id: uuid.UUID | None = None,
description: str | None = None,
posted_on: date | None = None,
request_key: str | None = None,
) -> Operation:
if amount_kopecks <= 0:
raise bad_request("MONEY_AMOUNT_MUST_BE_POSITIVE", "Amount must be positive")
existing = _existing_by_request_key(db, space_id=space_id, request_key=request_key)
if existing is not None:
return existing
account = _require_active_account(db, space_id=space_id, account_id=account_id)
_reject_system_account(account)
_validate_category(db, space_id=space_id, category_id=category_id)
ensure_period_open_for_date(db, space_id=space_id, day=occurred_on)
operation = Operation(
space_id=space_id,
type="expense",
amount_kopecks=amount_kopecks,
account_id=account.id,
category_id=category_id,
payee_id=payee_id,
occurred_on=occurred_on,
posted_on=posted_on,
description=description,
request_key=request_key,
created_by_user_id=user.id,
)
db.add(operation)
db.flush()
_apply_suggestion(db, space_id=space_id, operation=operation, description=description)
db.add(
Posting(
space_id=space_id,
operation_id=operation.id,
account_id=account.id,
amount_kopecks=-amount_kopecks,
)
)
audit.record(
db,
event_type="operation.created",
space_id=space_id,
user_id=user.id,
detail={"operation_id": str(operation.id), "type": "expense"},
)
return operation
def create_income(
db: Session,
*,
space_id: uuid.UUID,
user: User,
account_id: uuid.UUID,
amount_kopecks: int,
occurred_on: date,
income_source_id: uuid.UUID | None = None,
description: str | None = None,
posted_on: date | None = None,
request_key: str | None = None,
) -> Operation:
if amount_kopecks <= 0:
raise bad_request("MONEY_AMOUNT_MUST_BE_POSITIVE", "Amount must be positive")
existing = _existing_by_request_key(db, space_id=space_id, request_key=request_key)
if existing is not None:
return existing
account = _require_active_account(db, space_id=space_id, account_id=account_id)
_reject_system_account(account)
if income_source_id is not None:
categorization.get_income_source(db, space_id=space_id, income_source_id=income_source_id)
ensure_period_open_for_date(db, space_id=space_id, day=occurred_on)
operation = Operation(
space_id=space_id,
type="income",
amount_kopecks=amount_kopecks,
account_id=account.id,
income_source_id=income_source_id,
occurred_on=occurred_on,
posted_on=posted_on,
description=description,
request_key=request_key,
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=amount_kopecks,
)
)
audit.record(
db,
event_type="operation.created",
space_id=space_id,
user_id=user.id,
detail={"operation_id": str(operation.id), "type": "income"},
)
return operation
def create_transfer(
db: Session,
*,
space_id: uuid.UUID,
user: User,
source_account_id: uuid.UUID,
target_account_id: uuid.UUID,
amount_kopecks: int,
occurred_on: date,
description: str | None = None,
posted_on: date | None = None,
request_key: str | None = None,
) -> Operation:
if amount_kopecks <= 0:
raise bad_request("MONEY_AMOUNT_MUST_BE_POSITIVE", "Amount must be positive")
if source_account_id == target_account_id:
raise bad_request("LEDGER_TRANSFER_SAME_ACCOUNT", "Transfer requires two distinct accounts")
existing = _existing_by_request_key(db, space_id=space_id, request_key=request_key)
if existing is not None:
return existing
source = _require_active_account(db, space_id=space_id, account_id=source_account_id)
target = _require_active_account(db, space_id=space_id, account_id=target_account_id)
ensure_period_open_for_date(db, space_id=space_id, day=occurred_on)
from app.ledger.savings import transfer_category_for_accounts
category = transfer_category_for_accounts(db, space_id=space_id, source=source, target=target)
operation = Operation(
space_id=space_id,
type="transfer",
amount_kopecks=amount_kopecks,
account_id=source.id,
counter_account_id=target.id,
category_id=category.id if category is not None else None,
occurred_on=occurred_on,
posted_on=posted_on,
description=description,
request_key=request_key,
created_by_user_id=user.id,
)
db.add(operation)
db.flush()
db.add(
Posting(
space_id=space_id,
operation_id=operation.id,
account_id=source.id,
amount_kopecks=-amount_kopecks,
)
)
db.add(
Posting(
space_id=space_id,
operation_id=operation.id,
account_id=target.id,
amount_kopecks=amount_kopecks,
)
)
audit.record(
db,
event_type="operation.created",
space_id=space_id,
user_id=user.id,
detail={"operation_id": str(operation.id), "type": "transfer"},
)
return operation
def refunded_kopecks(db: Session, *, parent_operation_id: uuid.UUID) -> int:
total = (
db.query(func.coalesce(func.sum(Operation.amount_kopecks), 0))
.filter(
Operation.parent_operation_id == parent_operation_id,
Operation.type == "refund",
Operation.status == "confirmed",
)
.scalar()
)
return int(total or 0)
def create_refund(
db: Session,
*,
space_id: uuid.UUID,
user: User,
parent_operation_id: uuid.UUID,
amount_kopecks: int,
occurred_on: date,
description: str | None = None,
request_key: str | None = None,
) -> Operation:
if amount_kopecks <= 0:
raise bad_request("MONEY_AMOUNT_MUST_BE_POSITIVE", "Amount must be positive")
existing = _existing_by_request_key(db, space_id=space_id, request_key=request_key)
if existing is not None:
return existing
parent = (
db.query(Operation)
.filter_by(id=parent_operation_id, space_id=space_id)
.with_for_update()
.one_or_none()
)
if parent is None:
raise not_found("LEDGER_OPERATION_NOT_FOUND", "Operation not found")
if parent.type != "expense" or parent.status != "confirmed":
raise conflict("LEDGER_REFUND_PARENT_INVALID", "Refund parent must be a confirmed expense")
ensure_period_open_for_date(db, space_id=space_id, day=occurred_on)
remaining = parent.amount_kopecks - refunded_kopecks(db, parent_operation_id=parent.id)
if amount_kopecks > remaining:
raise conflict(
"LEDGER_REFUND_EXCEEDS_PARENT",
"Refund exceeds the unrefunded remainder of the original expense",
details={"remaining_kopecks": remaining},
)
operation = Operation(
space_id=space_id,
type="refund",
amount_kopecks=amount_kopecks,
account_id=parent.account_id,
parent_operation_id=parent.id,
category_id=parent.category_id,
occurred_on=occurred_on,
description=description or parent.description,
request_key=request_key,
created_by_user_id=user.id,
)
db.add(operation)
db.flush()
db.add(
Posting(
space_id=space_id,
operation_id=operation.id,
account_id=parent.account_id,
amount_kopecks=amount_kopecks,
)
)
audit.record(
db,
event_type="operation.created",
space_id=space_id,
user_id=user.id,
detail={"operation_id": str(operation.id), "type": "refund"},
)
return operation
def ensure_period_open_for_date(db: Session, *, space_id: uuid.UUID, day: date) -> None:
from app.budgeting.models import BudgetPeriod
period = (
db.query(BudgetPeriod)
.filter(
BudgetPeriod.space_id == space_id,
BudgetPeriod.year == day.year,
BudgetPeriod.month == day.month,
)
.with_for_update()
.one_or_none()
)
if period is not None and period.status == "closed":
raise conflict(
"BUDGET_PERIOD_CLOSED",
"Period is closed; reopen it before changing its operations",
details={"period_id": str(period.id), "year": period.year, "month": period.month},
)
def void_operation(
db: Session, *, space_id: uuid.UUID, user: User, operation_id: uuid.UUID, reason: str
) -> Operation:
operation = get_operation(db, space_id=space_id, operation_id=operation_id)
if operation.status == "voided":
return operation
ensure_period_open_for_date(db, space_id=space_id, day=operation.occurred_on)
operation.status = "voided"
operation.void_reason = reason
audit.record(
db,
event_type="operation.voided",
space_id=space_id,
user_id=user.id,
detail={"operation_id": str(operation.id)},
)
return operation
def update_operation(
db: Session,
*,
space_id: uuid.UUID,
user: User,
operation_id: uuid.UUID,
category_id: Any = UNSET,
payee_id: Any = UNSET,
description: Any = UNSET,
) -> Operation:
operation = get_operation(db, space_id=space_id, operation_id=operation_id)
if operation.status == "voided":
raise conflict("LEDGER_OPERATION_VOIDED", "Voided operations cannot be edited")
ensure_period_open_for_date(db, space_id=space_id, day=operation.occurred_on)
if category_id is not UNSET:
if category_id is not None:
_validate_category(db, space_id=space_id, category_id=category_id)
operation.category_id = category_id
if payee_id is not UNSET:
operation.payee_id = payee_id
if description is not UNSET:
operation.description = description
audit.record(
db,
event_type="operation.updated",
space_id=space_id,
user_id=user.id,
detail={"operation_id": str(operation.id)},
)
return operation
def archive_account(
db: Session, *, space_id: uuid.UUID, user: User, account_id: uuid.UUID
) -> Account:
account = get_account(db, space_id=space_id, account_id=account_id)
if account.system_key is not None:
raise conflict("LEDGER_ACCOUNT_SYSTEM", "System accounts cannot be archived")
if account.status == "archived":
return account
if account_balance(db, account) != 0:
raise conflict(
"LEDGER_ACCOUNT_BALANCE_NOT_ZERO",
"Only accounts with zero balance can be archived",
)
account.status = "archived"
audit.record(
db,
event_type="account.archived",
space_id=space_id,
user_id=user.id,
detail={"account_id": str(account.id)},
)
return account
def delete_account(db: Session, *, space_id: uuid.UUID, user: User, account_id: uuid.UUID) -> None:
account = get_account(db, space_id=space_id, account_id=account_id)
if account.system_key is not None:
raise conflict("LEDGER_ACCOUNT_SYSTEM", "System accounts cannot be deleted")
used = (
db.query(Operation.id)
.filter((Operation.account_id == account.id) | (Operation.counter_account_id == account.id))
.first()
)
if used is not None:
raise conflict(
"LEDGER_ACCOUNT_IN_USE",
"Only never-used accounts can be deleted; archive instead",
)
db.delete(account)
audit.record(
db,
event_type="account.deleted",
space_id=space_id,
user_id=user.id,
detail={"account_id": str(account.id)},
)
def list_operations(
db: Session,
*,
space_id: uuid.UUID,
account_id: uuid.UUID | None = None,
operation_type: str | None = None,
category_id: uuid.UUID | None = None,
status: str | None = None,
date_from: date | None = None,
date_to: date | None = None,
search: str | None = None,
uncategorized: bool = False,
limit: int = 50,
offset: int = 0,
) -> tuple[list[Operation], int]:
query = db.query(Operation).filter(Operation.space_id == space_id)
if account_id is not None:
query = query.filter(
(Operation.account_id == account_id) | (Operation.counter_account_id == account_id)
)
if operation_type is not None:
query = query.filter(Operation.type == operation_type)
if category_id is not None:
query = query.filter(Operation.category_id == category_id)
if status is not None:
query = query.filter(Operation.status == status)
if date_from is not None:
query = query.filter(Operation.occurred_on >= date_from)
if date_to is not None:
query = query.filter(Operation.occurred_on <= date_to)
if search:
escaped = search.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
query = query.filter(Operation.description.ilike(f"%{escaped}%", escape="\\"))
if uncategorized:
query = query.filter(Operation.type == "expense", Operation.category_id.is_(None))
total = query.count()
items = (
query.order_by(Operation.occurred_on.desc(), Operation.created_at.desc())
.offset(offset)
.limit(min(limit, MAX_PAGE_SIZE))
.all()
)
return items, total
def list_accounts(db: Session, *, space_id: uuid.UUID) -> list[Account]:
return db.query(Account).filter_by(space_id=space_id).order_by(Account.created_at).all()