TorbatYar/backend/services/accounting/app/services/treasury_service.py
Mortezakoohjani d92e1df332 Add automatic GL posting policy with inline voucher balance validation.
Enable tenant-controlled auto-post for sales/purchase/treasury via Posting Engine, block unbalanced voucher lines on FE and BE, and expose international posting controls in settings.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 17:55:37 +03:30

237 lines
8.7 KiB
Python

"""Treasury service — integrates with Posting Engine only."""
from __future__ import annotations
from datetime import date
from decimal import Decimal
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.posting import Voucher, VoucherLine
from app.models.treasury import CashBox, CashTransaction, BankAccount, BankTransaction
from app.models.types import TreasuryTransactionType, VoucherStatus
from app.repositories.base import TenantBaseRepository
from app.repositories.foundation import FiscalPeriodRepository
from app.repositories.posting import VoucherRepository
from app.services.posting_engine import PostingEngine
from shared.exceptions import AppError, NotFoundError
class TreasuryError(AppError):
status_code = 422
error_code = "treasury_error"
class CashBoxRepository(TenantBaseRepository[CashBox]):
model = CashBox
class CashTransactionRepository(TenantBaseRepository[CashTransaction]):
model = CashTransaction
class BankAccountRepository(TenantBaseRepository[BankAccount]):
model = BankAccount
class TreasuryService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.cash_box_repo = CashBoxRepository(session)
self.cash_tx_repo = CashTransactionRepository(session)
self.bank_account_repo = BankAccountRepository(session)
self.voucher_repo = VoucherRepository(session)
self.period_repo = FiscalPeriodRepository(session)
self.posting_engine = PostingEngine(session)
async def record_cash_receipt(
self,
tenant_id: UUID,
cash_box_id: UUID,
amount: Decimal,
*,
debit_account_id: UUID,
credit_account_id: UUID,
actor_user_id: str,
description: str | None = None,
transaction_date: date | None = None,
) -> CashTransaction:
cash_box = await self.cash_box_repo.get(tenant_id, cash_box_id)
if cash_box is None:
raise NotFoundError("صندوق یافت نشد", error_code="cash_box_not_found")
period = await self.period_repo.get_current(tenant_id)
if period is None:
raise NotFoundError("دوره مالی جاری یافت نشد", error_code="period_not_found")
tx_date = transaction_date or date.today()
ref = f"CASH-R-{tx_date.isoformat()}-{cash_box.code}"
voucher = await self._create_treasury_voucher(
tenant_id, period.id, ref, tx_date,
debit_account_id, credit_account_id, amount,
description or "دریافت نقدی", actor_user_id, "treasury",
)
tx = CashTransaction(
tenant_id=tenant_id,
cash_box_id=cash_box_id,
transaction_type=TreasuryTransactionType.CASH_RECEIPT,
transaction_date=tx_date,
amount=amount,
description=description,
voucher_id=voucher.id,
reference_number=ref,
)
await self.cash_tx_repo.add(tx)
cash_box.current_balance += amount
return tx
async def record_cash_payment(
self,
tenant_id: UUID,
cash_box_id: UUID,
amount: Decimal,
*,
debit_account_id: UUID,
credit_account_id: UUID,
actor_user_id: str,
description: str | None = None,
transaction_date: date | None = None,
) -> CashTransaction:
cash_box = await self.cash_box_repo.get(tenant_id, cash_box_id)
if cash_box is None:
raise NotFoundError("صندوق یافت نشد", error_code="cash_box_not_found")
if cash_box.current_balance < amount:
raise TreasuryError("موجودی صندوق کافی نیست")
period = await self.period_repo.get_current(tenant_id)
if period is None:
raise NotFoundError("دوره مالی جاری یافت نشد", error_code="period_not_found")
tx_date = transaction_date or date.today()
ref = f"CASH-P-{tx_date.isoformat()}-{cash_box.code}"
voucher = await self._create_treasury_voucher(
tenant_id, period.id, ref, tx_date,
debit_account_id, credit_account_id, amount,
description or "پرداخت نقدی", actor_user_id, "treasury",
)
tx = CashTransaction(
tenant_id=tenant_id,
cash_box_id=cash_box_id,
transaction_type=TreasuryTransactionType.CASH_PAYMENT,
transaction_date=tx_date,
amount=amount,
description=description,
voucher_id=voucher.id,
reference_number=ref,
)
await self.cash_tx_repo.add(tx)
cash_box.current_balance -= amount
return tx
async def post_receipt_payment_voucher(
self,
tenant_id: UUID,
*,
amount: Decimal,
treasury_account_id: UUID,
counter_account_id: UUID,
is_receipt: bool,
actor_user_id: str,
description: str,
transaction_date: date | None = None,
reference: str | None = None,
cash_box_id: UUID | None = None,
) -> Voucher:
"""Post GL for treasury receipt (Dr treasury / Cr counter) or payment (Dr counter / Cr treasury)."""
from app.services.document_number_service import DocumentNumberService
from app.services.source_posting_guard import SourcePostingGuard
period = await self.period_repo.get_current(tenant_id)
if period is None:
raise NotFoundError("دوره مالی جاری یافت نشد", error_code="period_not_found")
tx_date = transaction_date or date.today()
number = await DocumentNumberService(self.session).allocate(tenant_id, "voucher")
if is_receipt:
debit_id, credit_id = treasury_account_id, counter_account_id
else:
debit_id, credit_id = counter_account_id, treasury_account_id
if cash_box_id is not None:
cash_box = await self.cash_box_repo.get(tenant_id, cash_box_id)
if cash_box is not None and cash_box.current_balance < amount:
raise TreasuryError("موجودی صندوق کافی نیست")
voucher = await self._create_treasury_voucher(
tenant_id,
period.id,
number,
tx_date,
debit_id,
credit_id,
amount,
description,
actor_user_id,
"treasury",
post_immediately=await SourcePostingGuard(self.session).should_post_immediately(tenant_id),
reference_number=reference,
)
if cash_box_id is not None:
cash_box = await self.cash_box_repo.get(tenant_id, cash_box_id)
if cash_box is not None:
if is_receipt:
cash_box.current_balance += amount
else:
cash_box.current_balance -= amount
return voucher
async def _create_treasury_voucher(
self,
tenant_id: UUID,
period_id: UUID,
voucher_number: str,
voucher_date: date,
debit_account_id: UUID,
credit_account_id: UUID,
amount: Decimal,
description: str,
actor_user_id: str,
source_module: str,
*,
post_immediately: bool = True,
reference_number: str | None = None,
) -> Voucher:
voucher = Voucher(
tenant_id=tenant_id,
fiscal_period_id=period_id,
voucher_number=voucher_number,
voucher_date=voucher_date,
status=VoucherStatus.DRAFT,
description=description,
source_module=source_module,
reference_number=reference_number,
created_by=actor_user_id,
)
await self.voucher_repo.add(voucher)
self.session.add(VoucherLine(
tenant_id=tenant_id, voucher_id=voucher.id, line_number=1,
account_id=debit_account_id, debit=amount, credit=Decimal("0"),
description=description,
))
self.session.add(VoucherLine(
tenant_id=tenant_id, voucher_id=voucher.id, line_number=2,
account_id=credit_account_id, debit=Decimal("0"), credit=amount,
description=description,
))
await self.session.flush()
if not post_immediately:
return voucher
return await self.posting_engine.post_voucher(
tenant_id, voucher.id, actor_user_id=actor_user_id, source_module=source_module
)