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