d582f15681
- 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.
553 lines
18 KiB
Python
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()
|