Files
finance/backend/app/ledger/router.py
T
k1nq d582f15681 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.
2026-09-14 09:26:33 +05:00

279 lines
10 KiB
Python

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)