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.
279 lines
10 KiB
Python
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)
|