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 categorize_batch( db: Session, *, space_id: uuid.UUID, user: User, items: list[tuple[uuid.UUID, uuid.UUID | None]], ) -> dict[str, list[str]]: """Assign categories to uncategorized operations in one transaction. Items without an explicit category fall back to the system «Остальное» category, so nothing stays uncategorized after a review-queue save. Operations that already carry a category (user work done elsewhere) or that were voided in the meantime are skipped, never overwritten. """ from app.categorization.service import ensure_others_category fallback = ensure_others_category(db, space_id=space_id) updated: list[str] = [] skipped: list[str] = [] for operation_id, category_id in items: operation = get_operation(db, space_id=space_id, operation_id=operation_id) if operation.status == "voided" or operation.category_id is not None: skipped.append(str(operation.id)) continue ensure_period_open_for_date(db, space_id=space_id, day=operation.occurred_on) target = category_id if category_id is not None else fallback.id _validate_category(db, space_id=space_id, category_id=target) operation.category_id = target updated.append(str(operation.id)) audit.record( db, event_type="operations.batch_categorized", space_id=space_id, user_id=user.id, detail={"updated": updated, "skipped": skipped}, ) return {"updated": updated, "skipped": skipped} 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()