"""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 | None = None receipt_date: date amount: Decimal payment_method: str = "cash" cash_box_id: UUID | None = None bank_account_id: UUID | None = None counter_account_id: UUID | None = None description: str | None = None class PaymentCreate(BaseModel): payment_number: str | None = None payment_date: date amount: Decimal payment_method: str = "cash" cash_box_id: UUID | None = None bank_account_id: UUID | None = None counter_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, "voucher_id": str(t.voucher_id) if t.voucher_id else None, } for t in rows ] async def _resolve_transfer_gl_account( db: AsyncSession, tenant_id: UUID, side_type: str, side_id: UUID, ) -> UUID: from shared.exceptions import AppError if side_type == "account": return side_id if side_type == "cash": box = await CashBoxRepo(db).get(tenant_id, side_id) if box is None or box.account_id is None: raise AppError( "صندوق باید به حساب دفتر کل متصل باشد", status_code=422, error_code="cash_box_account_missing", ) return box.account_id if side_type == "bank": bank = await BankAccountRepo(db).get(tenant_id, side_id) if bank is None or bank.account_id is None: raise AppError( "حساب بانکی باید به حساب دفتر کل متصل باشد", status_code=422, error_code="bank_account_gl_missing", ) return bank.account_id raise AppError( f"نوع مبدأ/مقصد نامعتبر است: {side_type} (account|cash|bank)", status_code=422, error_code="transfer_type_invalid", ) @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), ): """Transfer between cash/bank/any GL account — auto-posts Dr to / Cr from.""" from app.services.posting_policy_service import PostingPolicyService from app.services.source_posting_guard import SourcePostingGuard entity = Transfer(tenant_id=tenant_id, **body.model_dump()) await TransferRepo(db).add(entity) from_gl = await _resolve_transfer_gl_account(db, tenant_id, body.from_type, body.from_id) to_gl = await _resolve_transfer_gl_account(db, tenant_id, body.to_type, body.to_id) policy = await PostingPolicyService(db).get_policy(tenant_id) voucher_id = None voucher_number = None # Transfers always need a GL document when accounts resolve — honor policy flag if present. auto = bool((policy.get("auto_post") or {}).get("treasury_transfer", True)) if auto: guard = SourcePostingGuard(db) await guard.assert_can_post( tenant_id, source_module="treasury", source_document_type="transfer", source_document_id=str(entity.id), ) svc = TreasuryService(db) voucher = await svc.post_gl_transfer( tenant_id, amount=body.amount, from_account_id=from_gl, to_account_id=to_gl, actor_user_id=user.user_id, description=body.description or "انتقال بین حساب‌ها", transaction_date=body.transfer_date, reference=str(entity.id), ) entity.voucher_id = voucher.id voucher_id = str(voucher.id) voucher_number = voucher.voucher_number await guard.mark_posted( tenant_id, voucher_id=voucher.id, source_module="treasury", source_document_type="transfer", source_document_id=str(entity.id), ) # Update cash balances when sides are cash boxes if body.from_type == "cash": box = await CashBoxRepo(db).get(tenant_id, body.from_id) if box is not None: box.current_balance -= body.amount if body.to_type == "cash": box = await CashBoxRepo(db).get(tenant_id, body.to_id) if box is not None: box.current_balance += body.amount await db.commit() return { "id": str(entity.id), "voucher_id": voucher_id, "voucher_number": voucher_number, } @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, "voucher_id": str(r.voucher_id) if r.voucher_id else None, } 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), ): from app.services.document_number_service import DocumentNumberService from app.services.posting_policy_service import PostingPolicyService from app.services.source_posting_guard import SourcePostingGuard from shared.exceptions import AppError data = body.model_dump() counter_account_id = data.pop("counter_account_id", None) data["receipt_number"] = await DocumentNumberService(db).allocate_if_blank( tenant_id, "receipt", body.receipt_number ) entity = ReceiptVoucher(tenant_id=tenant_id, **data) await ReceiptRepo(db).add(entity) policy = await PostingPolicyService(db).get_policy(tenant_id) voucher_id = None voucher_number = None if policy.get("auto_post", {}).get("treasury_receipt", True): if counter_account_id is None: raise AppError( "برای ثبت خودکار سند خزانه، حساب مقابل (طرف بستانکار) الزامی است", status_code=422, error_code="counter_account_required", ) treasury_account_id = await _resolve_treasury_gl_account( db, tenant_id, body.payment_method, body.cash_box_id, body.bank_account_id ) guard = SourcePostingGuard(db) await guard.assert_can_post( tenant_id, source_module="treasury", source_document_type="receipt", source_document_id=str(entity.id), ) svc = TreasuryService(db) voucher = await svc.post_receipt_payment_voucher( tenant_id, amount=body.amount, treasury_account_id=treasury_account_id, counter_account_id=counter_account_id, is_receipt=True, actor_user_id=user.user_id, description=body.description or f"دریافت {entity.receipt_number}", transaction_date=body.receipt_date, reference=entity.receipt_number, cash_box_id=body.cash_box_id, ) entity.voucher_id = voucher.id voucher_id = str(voucher.id) voucher_number = voucher.voucher_number await guard.mark_posted( tenant_id, voucher_id=voucher.id, source_module="treasury", source_document_type="receipt", source_document_id=str(entity.id), ) await db.commit() return { "id": str(entity.id), "receipt_number": entity.receipt_number, "voucher_id": voucher_id, "voucher_number": voucher_number, } @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, "voucher_id": str(p.voucher_id) if p.voucher_id else None, } 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), ): from app.services.document_number_service import DocumentNumberService from app.services.posting_policy_service import PostingPolicyService from app.services.source_posting_guard import SourcePostingGuard from shared.exceptions import AppError data = body.model_dump() counter_account_id = data.pop("counter_account_id", None) data["payment_number"] = await DocumentNumberService(db).allocate_if_blank( tenant_id, "payment", body.payment_number ) entity = PaymentVoucher(tenant_id=tenant_id, **data) await PaymentRepo(db).add(entity) policy = await PostingPolicyService(db).get_policy(tenant_id) voucher_id = None voucher_number = None if policy.get("auto_post", {}).get("treasury_payment", True): if counter_account_id is None: raise AppError( "برای ثبت خودکار سند خزانه، حساب مقابل (طرف بدهکار) الزامی است", status_code=422, error_code="counter_account_required", ) treasury_account_id = await _resolve_treasury_gl_account( db, tenant_id, body.payment_method, body.cash_box_id, body.bank_account_id ) guard = SourcePostingGuard(db) await guard.assert_can_post( tenant_id, source_module="treasury", source_document_type="payment", source_document_id=str(entity.id), ) svc = TreasuryService(db) voucher = await svc.post_receipt_payment_voucher( tenant_id, amount=body.amount, treasury_account_id=treasury_account_id, counter_account_id=counter_account_id, is_receipt=False, actor_user_id=user.user_id, description=body.description or f"پرداخت {entity.payment_number}", transaction_date=body.payment_date, reference=entity.payment_number, cash_box_id=body.cash_box_id, ) entity.voucher_id = voucher.id voucher_id = str(voucher.id) voucher_number = voucher.voucher_number await guard.mark_posted( tenant_id, voucher_id=voucher.id, source_module="treasury", source_document_type="payment", source_document_id=str(entity.id), ) await db.commit() return { "id": str(entity.id), "payment_number": entity.payment_number, "voucher_id": voucher_id, "voucher_number": voucher_number, } async def _resolve_treasury_gl_account( db: AsyncSession, tenant_id: UUID, payment_method: str, cash_box_id: UUID | None, bank_account_id: UUID | None, ) -> UUID: from shared.exceptions import AppError if payment_method == "cash" or cash_box_id: if cash_box_id is None: raise AppError( "برای دریافت/پرداخت نقدی انتخاب صندوق الزامی است", status_code=422, error_code="cash_box_required", ) box = await CashBoxRepo(db).get(tenant_id, cash_box_id) if box is None or box.account_id is None: raise AppError( "صندوق باید به حساب دفتر کل متصل باشد", status_code=422, error_code="cash_box_account_missing", ) return box.account_id if bank_account_id is None: raise AppError( "برای حواله بانکی انتخاب حساب بانکی الزامی است", status_code=422, error_code="bank_account_required", ) bank = await BankAccountRepo(db).get(tenant_id, bank_account_id) if bank is None or bank.account_id is None: raise AppError( "حساب بانکی باید به حساب دفتر کل متصل باشد", status_code=422, error_code="bank_account_gl_missing", ) return bank.account_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)}