Files
finance/backend/app/ledger/service.py
T
k1nq d582f15681 feat: add tests for import polling, login page, money utilities, and query keys
- Implemented tests for import polling functionality, ensuring correct behavior for active and terminal import statuses.
- Created tests for the login page, validating user interactions and error handling.
- Added unit tests for money utility functions, covering parsing, formatting, and summation of monetary values.
- Developed tests for query keys, ensuring proper scoping and invalidation roots.

chore: setup testing environment and configuration

- Added setup file for testing with Vitest and Testing Library.
- Configured TypeScript and Vite for the frontend project.
- Created Docker and Nginx configurations for local development and production environments.
- Established a PostgreSQL initialization script for setting up application roles.

docs: add README and environment configuration for infra

- Included README for the infra directory, detailing local development setup and service descriptions.
- Created an example environment configuration file for local development with placeholders for sensitive information.
2026-09-14 09:26:33 +05:00

553 lines
18 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 _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")
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)
_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)
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)
operation = Operation(
space_id=space_id,
type="transfer",
amount_kopecks=amount_kopecks,
account_id=source.id,
counter_account_id=target.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=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.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)
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()