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:
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user