333 lines
12 KiB
Python
333 lines
12 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")
|
|
|
|
|
|
class AdjustBalanceBody(BaseModel):
|
|
model_config = ConfigDict(populate_by_name=True)
|
|
|
|
target_balance_kopecks: int = Field(ge=0, alias="targetBalanceKopecks")
|
|
occurred_on: date = Field(alias="occurredOn")
|
|
reason: str = Field(min_length=1, max_length=500)
|
|
|
|
|
|
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,
|
|
"systemKey": account.system_key,
|
|
"nameKey": f"account.{account.system_key}" if account.system_key else None,
|
|
"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,
|
|
"convertedFromExpense": operation.converted_from_expense,
|
|
"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)
|
|
|
|
|
|
@router.post("/accounts/{account_id}/adjust-balance")
|
|
def adjust_balance(
|
|
account_id: uuid.UUID, body: AdjustBalanceBody, db: Db, context: Context, _csrf: Csrf
|
|
) -> dict:
|
|
from app.ledger import savings
|
|
|
|
operation, old_balance, new_balance = savings.adjust_balance(
|
|
db,
|
|
space_id=context.space.id,
|
|
user=context.user,
|
|
account_id=account_id,
|
|
target_balance_kopecks=body.target_balance_kopecks,
|
|
occurred_on=body.occurred_on,
|
|
reason=body.reason,
|
|
)
|
|
payload = operation_json(db, operation)
|
|
payload["oldBalanceKopecks"] = old_balance
|
|
payload["newBalanceKopecks"] = new_balance
|
|
return payload
|
|
|
|
|
|
@router.post("/operations/{operation_id}/savings-transfer")
|
|
def savings_transfer(operation_id: uuid.UUID, db: Db, context: Context, _csrf: Csrf) -> dict:
|
|
from app.ledger import savings
|
|
|
|
operation = savings.convert_expense_to_savings(
|
|
db, space_id=context.space.id, user=context.user, operation_id=operation_id
|
|
)
|
|
return operation_json(db, operation)
|
|
|
|
|
|
@router.post("/operations/{operation_id}/savings-transfer/revert")
|
|
def savings_transfer_revert(
|
|
operation_id: uuid.UUID, db: Db, context: Context, _csrf: Csrf
|
|
) -> dict:
|
|
from app.ledger import savings
|
|
|
|
operation = savings.revert_savings_conversion(
|
|
db, space_id=context.space.id, user=context.user, operation_id=operation_id
|
|
)
|
|
return operation_json(db, operation)
|