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.
This commit is contained in:
k1nq
2026-09-14 09:26:33 +05:00
parent d8b938adfb
commit d582f15681
164 changed files with 25553 additions and 1 deletions
View File
+181
View File
@@ -0,0 +1,181 @@
import uuid
from datetime import date
from sqlalchemy import (
BigInteger,
Boolean,
CheckConstraint,
Date,
ForeignKey,
ForeignKeyConstraint,
Integer,
String,
UniqueConstraint,
Uuid,
)
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, PkMixin, TimestampMixin
ACCOUNT_KINDS = ("cash", "debit", "credit")
ACCOUNT_STATUSES = ("active", "archived")
OPERATION_TYPES = ("expense", "income", "transfer", "refund")
OPERATION_STATUSES = ("confirmed", "voided")
class Account(Base, PkMixin, TimestampMixin):
"""Financial account. Balance is always derived: opening + confirmed postings."""
__tablename__ = "accounts"
__table_args__ = (
CheckConstraint("kind IN ('cash', 'debit', 'credit')", name="kind_valid"),
CheckConstraint("status IN ('active', 'archived')", name="status_valid"),
CheckConstraint("currency = 'RUB'", name="currency_rub_only"),
UniqueConstraint("space_id", "id", name="uq_accounts_space_id"),
)
space_id: Mapped[uuid.UUID] = mapped_column(
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(200), nullable=False)
kind: Mapped[str] = mapped_column(String(16), nullable=False)
currency: Mapped[str] = mapped_column(String(3), nullable=False, default="RUB")
opening_amount_kopecks: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
opened_on: Mapped[date] = mapped_column(Date, nullable=False)
bank_code: Mapped[str | None] = mapped_column(String(32), nullable=True)
masked_identifier: Mapped[str | None] = mapped_column(String(32), nullable=True)
fingerprint: Mapped[str | None] = mapped_column(String(128), nullable=True)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
__mapper_args__ = {"version_id_col": version}
class Operation(Base, PkMixin, TimestampMixin):
"""User financial fact. amount_kopecks is always positive; direction lives in postings."""
__tablename__ = "operations"
__table_args__ = (
CheckConstraint("type IN ('expense', 'income', 'transfer', 'refund')", name="type_valid"),
CheckConstraint("status IN ('confirmed', 'voided')", name="status_valid"),
CheckConstraint("amount_kopecks > 0", name="amount_positive"),
CheckConstraint(
"NOT (type = 'transfer' AND counter_account_id IS NULL)",
name="transfer_requires_counter",
),
ForeignKeyConstraint(
["space_id", "account_id"],
["accounts.space_id", "accounts.id"],
name="fk_operations_account_tenant",
),
ForeignKeyConstraint(
["space_id", "counter_account_id"],
["accounts.space_id", "accounts.id"],
name="fk_operations_counter_account_tenant",
),
ForeignKeyConstraint(
["space_id", "category_id"],
["categories.space_id", "categories.id"],
name="fk_operations_category_tenant",
),
ForeignKeyConstraint(
["space_id", "income_source_id"],
["income_sources.space_id", "income_sources.id"],
name="fk_operations_income_source_tenant",
),
ForeignKeyConstraint(
["space_id", "payee_id"],
["payees.space_id", "payees.id"],
name="fk_operations_payee_tenant",
),
ForeignKeyConstraint(
["space_id", "parent_operation_id"],
["operations.space_id", "operations.id"],
name="fk_operations_parent_tenant",
),
UniqueConstraint("space_id", "request_key", name="uq_operations_space_request_key"),
UniqueConstraint("space_id", "id", name="uq_operations_space_id"),
)
space_id: Mapped[uuid.UUID] = mapped_column(
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
)
type: Mapped[str] = mapped_column(String(16), nullable=False)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="confirmed")
amount_kopecks: Mapped[int] = mapped_column(BigInteger, nullable=False)
account_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False, index=True)
counter_account_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True)
category_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True, index=True)
income_source_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True)
payee_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True)
parent_operation_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True)
occurred_on: Mapped[date] = mapped_column(Date, nullable=False, index=True)
posted_on: Mapped[date | None] = mapped_column(Date, nullable=True)
description: Mapped[str | None] = mapped_column(String(500), nullable=True)
void_reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
request_key: Mapped[str | None] = mapped_column(String(120), nullable=True)
created_by_user_id: Mapped[uuid.UUID | None] = mapped_column(
Uuid, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
__mapper_args__ = {"version_id_col": version}
class Posting(Base, PkMixin, TimestampMixin):
"""Signed balance change of one account within one operation.
Sign convention (uniform for asset and credit accounts, from the account's
perspective): income and refunds post positive; expenses post negative;
transfers post negative on the source and positive on the destination.
Credit accounts normally carry a non-positive balance; debt = -balance.
"""
__tablename__ = "postings"
__table_args__ = (
CheckConstraint("amount_kopecks <> 0", name="amount_nonzero"),
ForeignKeyConstraint(
["space_id", "operation_id"],
["operations.space_id", "operations.id"],
ondelete="CASCADE",
name="fk_postings_operation_tenant",
),
ForeignKeyConstraint(
["space_id", "account_id"],
["accounts.space_id", "accounts.id"],
name="fk_postings_account_tenant",
),
UniqueConstraint("space_id", "id", name="uq_postings_space_id"),
)
space_id: Mapped[uuid.UUID] = mapped_column(
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
)
operation_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False, index=True)
account_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False, index=True)
amount_kopecks: Mapped[int] = mapped_column(BigInteger, nullable=False)
class Category(Base, PkMixin, TimestampMixin):
"""Budget category. Kept in ledger module to avoid circular imports."""
__tablename__ = "categories"
__table_args__ = (
CheckConstraint("kind IN ('monthly', 'savings_envelope')", name="kind_valid"),
ForeignKeyConstraint(
["space_id", "linked_account_id"],
["accounts.space_id", "accounts.id"],
name="fk_categories_linked_account_tenant",
),
UniqueConstraint("space_id", "id", name="uq_categories_space_id"),
)
space_id: Mapped[uuid.UUID] = mapped_column(
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
)
kind: Mapped[str] = mapped_column(String(16), nullable=False)
system_key: Mapped[str | None] = mapped_column(String(64), nullable=True)
name_override: Mapped[str | None] = mapped_column(String(200), nullable=True)
custom_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
linked_account_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True)
archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
+278
View File
@@ -0,0 +1,278 @@
import uuid
from datetime import date
from typing import Annotated, Any, Literal
from fastapi import APIRouter, Header, Query
from pydantic import BaseModel, ConfigDict, Field
from app.categorization import service as categorization
from app.categorization.models import IncomeSource, Payee
from app.dependencies import Context, Csrf, Db
from app.ledger import service
from app.ledger.models import Category
router = APIRouter(tags=["ledger"])
class AccountBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
name: str = Field(min_length=1, max_length=200)
kind: Literal["cash", "debit", "credit"] = Field(alias="type")
opened_on: date = Field(alias="openedOn")
opening_amount_kopecks: int = Field(default=0, alias="openingBalanceKopecks")
bank_code: str | None = Field(default=None, alias="bankCode", max_length=32)
masked_identifier: str | None = Field(default=None, alias="maskedNumber", max_length=32)
class OperationBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
operation_type: Literal["expense", "income", "transfer", "refund"] = Field(alias="kind")
amount_kopecks: int = Field(alias="amountKopecks", gt=0)
account_id: uuid.UUID | None = Field(default=None, alias="accountId")
source_account_id: uuid.UUID | None = Field(default=None, alias="fromAccountId")
counter_account_id: uuid.UUID | None = Field(default=None, alias="toAccountId")
parent_operation_id: uuid.UUID | None = Field(default=None, alias="parentOperationId")
category_id: uuid.UUID | None = Field(default=None, alias="categoryId")
income_source_name: str | None = Field(default=None, alias="incomeSourceName")
payee_name: str | None = Field(default=None, alias="payeeName")
occurred_on: date = Field(alias="occurredOn")
posted_on: date | None = Field(default=None, alias="postedOn")
description: str | None = Field(default=None, max_length=500)
class VoidBody(BaseModel):
reason: str = Field(min_length=1, max_length=500)
class OperationPatchBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
category_id: uuid.UUID | None = Field(default=None, alias="categoryId")
def account_json(db: Db, account: Any) -> dict:
return {
"id": account.id,
"name": account.name,
"type": account.kind,
"currency": account.currency,
"openingBalanceKopecks": account.opening_amount_kopecks,
"openedOn": account.opened_on,
"bankCode": account.bank_code,
"maskedNumber": account.masked_identifier,
"status": account.status,
"balanceKopecks": service.account_balance(db, account),
"version": account.version,
}
def operation_json(db: Db, operation: Any) -> dict:
category = db.get(Category, operation.category_id) if operation.category_id else None
payee = db.get(Payee, operation.payee_id) if operation.payee_id else None
income_source = (
db.get(IncomeSource, operation.income_source_id) if operation.income_source_id else None
)
return {
"id": operation.id,
"kind": operation.type,
"status": operation.status,
"amountKopecks": operation.amount_kopecks,
"accountId": operation.account_id,
"counterpartyAccountId": operation.counter_account_id,
"categoryId": operation.category_id,
"categoryName": categorization.resolve_category_name(category) if category else None,
"categoryKey": category.system_key if category else None,
"payeeName": payee.name if payee else None,
"incomeSourceName": income_source.name if income_source else None,
"parentOperationId": operation.parent_operation_id,
"occurredOn": operation.occurred_on,
"postedOn": operation.posted_on,
"description": operation.description,
"voidReason": operation.void_reason,
"version": operation.version,
"createdAt": operation.created_at,
}
@router.get("/accounts")
def accounts(db: Db, context: Context) -> list[dict]:
return [account_json(db, item) for item in service.list_accounts(db, space_id=context.space.id)]
@router.post("/accounts", status_code=201)
def create_account(body: AccountBody, db: Db, context: Context, _csrf: Csrf) -> dict:
account = service.create_account(
db,
space_id=context.space.id,
user=context.user,
**body.model_dump(by_alias=False),
)
return account_json(db, account)
@router.post("/accounts/{account_id}/archive")
def archive_account(account_id: uuid.UUID, db: Db, context: Context, _csrf: Csrf) -> dict:
account = service.archive_account(
db, space_id=context.space.id, user=context.user, account_id=account_id
)
return account_json(db, account)
@router.patch("/accounts/{account_id}")
def patch_account(account_id: uuid.UUID, db: Db, context: Context, _csrf: Csrf) -> dict:
account = service.archive_account(
db, space_id=context.space.id, user=context.user, account_id=account_id
)
return account_json(db, account)
@router.delete("/accounts/{account_id}", status_code=204)
def delete_account(account_id: uuid.UUID, db: Db, context: Context, _csrf: Csrf) -> None:
service.delete_account(db, space_id=context.space.id, user=context.user, account_id=account_id)
@router.get("/operations")
def operations(
db: Db,
context: Context,
account_id: Annotated[uuid.UUID | None, Query(alias="accountId")] = None,
operation_type: Annotated[str | None, Query(alias="kind")] = None,
category_id: Annotated[uuid.UUID | None, Query(alias="categoryId")] = None,
status: str | None = None,
date_from: Annotated[date | None, Query(alias="occurredFrom")] = None,
date_to: Annotated[date | None, Query(alias="occurredTo")] = None,
search: str | None = None,
uncategorized: bool = False,
limit: Annotated[int, Query(ge=1, le=200)] = 50,
cursor: str | None = None,
) -> dict:
offset = int(cursor) if cursor and cursor.isdigit() else 0
items, total = service.list_operations(
db,
space_id=context.space.id,
account_id=account_id,
operation_type=operation_type,
category_id=category_id,
status=status,
date_from=date_from,
date_to=date_to,
search=search,
uncategorized=uncategorized,
limit=limit,
offset=offset,
)
next_offset = offset + len(items)
return {
"items": [operation_json(db, item) for item in items],
"nextCursor": str(next_offset) if next_offset < total else None,
}
@router.post("/operations", status_code=201)
def create_operation(
body: OperationBody,
db: Db,
context: Context,
_csrf: Csrf,
idempotency_key: Annotated[str | None, Header(alias="Idempotency-Key")] = None,
) -> dict:
if body.operation_type == "expense" and body.account_id:
payee_id = None
if body.payee_name:
payee_id = categorization.get_or_create_payee(
db, space_id=context.space.id, name=body.payee_name, alias=body.payee_name
).id
operation = service.create_expense(
db,
space_id=context.space.id,
user=context.user,
account_id=body.account_id,
amount_kopecks=body.amount_kopecks,
occurred_on=body.occurred_on,
category_id=body.category_id,
payee_id=payee_id,
description=body.description,
posted_on=body.posted_on,
request_key=idempotency_key,
)
elif body.operation_type == "income" and body.account_id:
income_source_id = None
if body.income_source_name:
income_source_id = categorization.ensure_income_source(
db, space_id=context.space.id, name=body.income_source_name
).id
operation = service.create_income(
db,
space_id=context.space.id,
user=context.user,
account_id=body.account_id,
amount_kopecks=body.amount_kopecks,
occurred_on=body.occurred_on,
income_source_id=income_source_id,
description=body.description,
posted_on=body.posted_on,
request_key=idempotency_key,
)
elif body.operation_type == "transfer" and body.source_account_id and body.counter_account_id:
operation = service.create_transfer(
db,
space_id=context.space.id,
user=context.user,
source_account_id=body.source_account_id,
target_account_id=body.counter_account_id,
amount_kopecks=body.amount_kopecks,
occurred_on=body.occurred_on,
description=body.description,
posted_on=body.posted_on,
request_key=idempotency_key,
)
elif body.operation_type == "refund" and body.parent_operation_id:
operation = service.create_refund(
db,
space_id=context.space.id,
user=context.user,
parent_operation_id=body.parent_operation_id,
amount_kopecks=body.amount_kopecks,
occurred_on=body.occurred_on,
description=body.description,
request_key=idempotency_key,
)
else:
from app.errors import bad_request
raise bad_request("LEDGER_OPERATION_FIELDS_INVALID", "Required account fields are missing")
return operation_json(db, operation)
@router.post("/operations/{operation_id}/void")
def void_operation(
operation_id: uuid.UUID, body: VoidBody, db: Db, context: Context, _csrf: Csrf
) -> dict:
operation = service.void_operation(
db,
space_id=context.space.id,
user=context.user,
operation_id=operation_id,
reason=body.reason,
)
return operation_json(db, operation)
@router.patch("/operations/{operation_id}")
def patch_operation(
operation_id: uuid.UUID,
body: OperationPatchBody,
db: Db,
context: Context,
_csrf: Csrf,
) -> dict:
operation = service.update_operation(
db,
space_id=context.space.id,
user=context.user,
operation_id=operation_id,
category_id=body.category_id,
)
return operation_json(db, operation)
+552
View File
@@ -0,0 +1,552 @@
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()