TorbatYar/backend/services/accounting/app/api/v1/treasury.py
Mortezakoohjani 7d66932da8 Complete Accounting sidebar modules with ops CRUD, BFF proxy, and treasury APIs.
Wire nested nav pages for phases 5.1-5.11 to real list/create endpoints, fix COA import connectivity via same-origin proxy, and add operational migration.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 16:10:06 +03:30

460 lines
13 KiB
Python

"""Treasury API — Phase 5.4."""
from __future__ import annotations
from datetime import date
from decimal import Decimal
from uuid import UUID
from fastapi import APIRouter, Depends, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, require_tenant
from app.core.security import get_current_user
from app.models.treasury import Bank, BankAccount, CashBox
from app.repositories.base import TenantBaseRepository
from app.services.treasury_service import TreasuryService
from shared.security import CurrentUser
router = APIRouter()
class CashBoxCreate(BaseModel):
code: str
name: str
account_id: UUID | None = None
opening_balance: Decimal = Decimal("0")
class CashBoxRead(BaseModel):
id: UUID
tenant_id: UUID
code: str
name: str
current_balance: Decimal
is_active: bool
model_config = {"from_attributes": True}
class CashReceiptRequest(BaseModel):
cash_box_id: UUID
amount: Decimal
debit_account_id: UUID
credit_account_id: UUID
description: str | None = None
transaction_date: date | None = None
class BankCreate(BaseModel):
code: str
name: str
class BankAccountCreate(BaseModel):
bank_id: UUID
account_number: str
iban: str | None = None
class CashBoxRepo(TenantBaseRepository[CashBox]):
model = CashBox
class BankRepo(TenantBaseRepository[Bank]):
model = Bank
class BankAccountRepo(TenantBaseRepository[BankAccount]):
model = BankAccount
@router.post("/cash-boxes", response_model=CashBoxRead, status_code=status.HTTP_201_CREATED)
async def create_cash_box(
body: CashBoxCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = CashBoxRepo(db)
entity = CashBox(
tenant_id=tenant_id,
code=body.code,
name=body.name,
account_id=body.account_id,
opening_balance=body.opening_balance,
current_balance=body.opening_balance,
)
await repo.add(entity)
await db.commit()
await db.refresh(entity)
return entity
@router.get("/cash-boxes", response_model=list[CashBoxRead])
async def list_cash_boxes(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = CashBoxRepo(db)
return await repo.list_by_tenant(tenant_id, limit=100)
@router.post("/cash-receipts")
async def record_cash_receipt(
body: CashReceiptRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
svc = TreasuryService(db)
tx = await svc.record_cash_receipt(
tenant_id, body.cash_box_id, body.amount,
debit_account_id=body.debit_account_id,
credit_account_id=body.credit_account_id,
actor_user_id=user.user_id,
description=body.description,
transaction_date=body.transaction_date,
)
await db.commit()
return {"id": str(tx.id), "voucher_id": str(tx.voucher_id)}
@router.post("/banks", status_code=status.HTTP_201_CREATED)
async def create_bank(
body: BankCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = BankRepo(db)
entity = Bank(tenant_id=tenant_id, code=body.code, name=body.name)
await repo.add(entity)
await db.commit()
return {"id": str(entity.id)}
@router.post("/bank-accounts", status_code=status.HTTP_201_CREATED)
async def create_bank_account(
body: BankAccountCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = BankAccountRepo(db)
entity = BankAccount(
tenant_id=tenant_id,
bank_id=body.bank_id,
account_number=body.account_number,
iban=body.iban,
)
await repo.add(entity)
await db.commit()
return {"id": str(entity.id)}
@router.get("/banks")
async def list_banks(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
banks = await BankRepo(db).list_by_tenant(tenant_id, limit=100)
return [{"id": str(b.id), "code": b.code, "name": b.name, "is_active": b.is_active} for b in banks]
@router.get("/bank-accounts")
async def list_bank_accounts(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
accounts = await BankAccountRepo(db).list_by_tenant(tenant_id, limit=100)
return [
{
"id": str(a.id),
"bank_id": str(a.bank_id),
"account_number": a.account_number,
"iban": a.iban,
"is_active": a.is_active,
}
for a in accounts
]
@router.patch("/cash-boxes/{cash_box_id}")
async def update_cash_box(
cash_box_id: UUID,
body: dict,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
from shared.exceptions import NotFoundError
repo = CashBoxRepo(db)
entity = await repo.get(tenant_id, cash_box_id)
if entity is None:
raise NotFoundError("صندوق یافت نشد", error_code="cash_box_not_found")
if "name" in body and body["name"] is not None:
entity.name = body["name"]
if "is_active" in body and body["is_active"] is not None:
entity.is_active = bool(body["is_active"])
await db.commit()
await db.refresh(entity)
return CashBoxRead.model_validate(entity)
# ── Cheques / transfers / receipts / payments / reconciliation ───────────────
from app.models.treasury import Cheque, PaymentVoucher, ReceiptVoucher, Reconciliation, Transfer
from app.models.types import ChequeStatus, ReconciliationStatus
class ChequeRepo(TenantBaseRepository[Cheque]):
model = Cheque
class TransferRepo(TenantBaseRepository[Transfer]):
model = Transfer
class ReceiptRepo(TenantBaseRepository[ReceiptVoucher]):
model = ReceiptVoucher
class PaymentRepo(TenantBaseRepository[PaymentVoucher]):
model = PaymentVoucher
class ReconciliationRepo(TenantBaseRepository[Reconciliation]):
model = Reconciliation
class ChequeCreate(BaseModel):
cheque_number: str
amount: Decimal
issue_date: date
due_date: date | None = None
is_incoming: bool = True
payee: str | None = None
bank_account_id: UUID | None = None
status: ChequeStatus = ChequeStatus.RECEIVED
class TransferCreate(BaseModel):
transfer_date: date
amount: Decimal
from_type: str
from_id: UUID
to_type: str
to_id: UUID
description: str | None = None
class ReceiptCreate(BaseModel):
receipt_number: str
receipt_date: date
amount: Decimal
payment_method: str = "cash"
cash_box_id: UUID | None = None
bank_account_id: UUID | None = None
description: str | None = None
class PaymentCreate(BaseModel):
payment_number: str
payment_date: date
amount: Decimal
payment_method: str = "cash"
cash_box_id: UUID | None = None
bank_account_id: UUID | None = None
description: str | None = None
class ReconciliationCreate(BaseModel):
bank_account_id: UUID
statement_date: date
statement_balance: Decimal
book_balance: Decimal
@router.get("/cheques")
async def list_cheques(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
rows = await ChequeRepo(db).list_by_tenant(tenant_id, limit=200)
return [
{
"id": str(c.id),
"cheque_number": c.cheque_number,
"amount": str(c.amount),
"issue_date": c.issue_date.isoformat(),
"due_date": c.due_date.isoformat() if c.due_date else None,
"status": c.status.value,
"is_incoming": c.is_incoming,
"payee": c.payee,
}
for c in rows
]
@router.post("/cheques", status_code=status.HTTP_201_CREATED)
async def create_cheque(
body: ChequeCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
entity = Cheque(tenant_id=tenant_id, **body.model_dump())
await ChequeRepo(db).add(entity)
await db.commit()
return {"id": str(entity.id), "cheque_number": entity.cheque_number}
@router.get("/transfers")
async def list_transfers(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
rows = await TransferRepo(db).list_by_tenant(tenant_id, limit=200)
return [
{
"id": str(t.id),
"transfer_date": t.transfer_date.isoformat(),
"amount": str(t.amount),
"from_type": t.from_type,
"from_id": str(t.from_id),
"to_type": t.to_type,
"to_id": str(t.to_id),
"description": t.description,
}
for t in rows
]
@router.post("/transfers", status_code=status.HTTP_201_CREATED)
async def create_transfer(
body: TransferCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
entity = Transfer(tenant_id=tenant_id, **body.model_dump())
await TransferRepo(db).add(entity)
await db.commit()
return {"id": str(entity.id)}
@router.get("/receipts")
async def list_receipts(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
rows = await ReceiptRepo(db).list_by_tenant(tenant_id, limit=200)
return [
{
"id": str(r.id),
"receipt_number": r.receipt_number,
"receipt_date": r.receipt_date.isoformat(),
"amount": str(r.amount),
"payment_method": r.payment_method,
"description": r.description,
}
for r in rows
]
@router.post("/receipts", status_code=status.HTTP_201_CREATED)
async def create_receipt(
body: ReceiptCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
entity = ReceiptVoucher(tenant_id=tenant_id, **body.model_dump())
await ReceiptRepo(db).add(entity)
await db.commit()
return {"id": str(entity.id)}
@router.get("/payments")
async def list_payments(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
rows = await PaymentRepo(db).list_by_tenant(tenant_id, limit=200)
return [
{
"id": str(p.id),
"payment_number": p.payment_number,
"payment_date": p.payment_date.isoformat(),
"amount": str(p.amount),
"payment_method": p.payment_method,
"description": p.description,
}
for p in rows
]
@router.post("/payments", status_code=status.HTTP_201_CREATED)
async def create_payment(
body: PaymentCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
entity = PaymentVoucher(tenant_id=tenant_id, **body.model_dump())
await PaymentRepo(db).add(entity)
await db.commit()
return {"id": str(entity.id)}
@router.get("/reconciliations")
async def list_reconciliations(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
rows = await ReconciliationRepo(db).list_by_tenant(tenant_id, limit=100)
return [
{
"id": str(r.id),
"bank_account_id": str(r.bank_account_id),
"statement_date": r.statement_date.isoformat(),
"statement_balance": str(r.statement_balance),
"book_balance": str(r.book_balance),
"difference": str(r.difference),
"status": r.status.value,
}
for r in rows
]
@router.post("/reconciliations", status_code=status.HTTP_201_CREATED)
async def create_reconciliation(
body: ReconciliationCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
diff = body.statement_balance - body.book_balance
entity = Reconciliation(
tenant_id=tenant_id,
bank_account_id=body.bank_account_id,
statement_date=body.statement_date,
statement_balance=body.statement_balance,
book_balance=body.book_balance,
difference=diff,
status=ReconciliationStatus.PENDING,
)
await ReconciliationRepo(db).add(entity)
await db.commit()
return {"id": str(entity.id), "difference": str(diff)}