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>
237 lines
8.7 KiB
Python
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
|
|
)
|