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>
This commit is contained in:
Mortezakoohjani 2026-07-26 17:55:37 +03:30
parent 7953e47f8b
commit d92e1df332
22 changed files with 1509 additions and 67 deletions

View File

@ -171,12 +171,115 @@ async def confirm_document(
doc_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
user: CurrentUser = Depends(get_current_user),
):
"""Confirm ops document; when posting-policy auto_post is on, also post to GL."""
from decimal import Decimal
from app.services.posting_policy_service import PostingPolicyService
from app.services.purchase_inventory_service import PurchaseInventoryAccountingService
from app.services.sales_accounting_service import SalesAccountingService
from app.models.types import SalesDocumentType
from shared.exceptions import AppError
entity = await BizDocRepo(db).get(tenant_id, doc_id)
if entity is None:
raise NotFoundError("سند یافت نشد", error_code="document_not_found")
entity.status = "confirmed"
policy_svc = PostingPolicyService(db)
policy = await policy_svc.get_policy(tenant_id)
should_auto = policy_svc.is_auto_post_enabled(policy, entity.module, entity.doc_type)
voucher_info: dict | None = None
if should_auto:
amount = Decimal(str(entity.amount or 0))
if amount <= 0:
raise AppError(
"برای ثبت خودکار حسابداری مبلغ سند باید بزرگ‌تر از صفر باشد",
status_code=422,
error_code="auto_post_amount_required",
)
try:
if entity.module == "sales" and entity.doc_type in ("invoice", "return"):
doc_type = (
SalesDocumentType.RETURN
if entity.doc_type == "return"
else SalesDocumentType.INVOICE
)
voucher = await SalesAccountingService(db).post_sales_invoice(
tenant_id,
document_type=doc_type,
source_document_id=str(entity.id),
total_amount=amount,
actor_user_id=user.user_id,
)
voucher_info = {
"voucher_id": str(voucher.id),
"voucher_number": voucher.voucher_number,
}
elif entity.module == "purchase" and entity.doc_type in (
"goods_receipt",
"goods-receipts",
):
voucher = await PurchaseInventoryAccountingService(db).post_goods_receipt(
tenant_id,
source_document_id=str(entity.id),
amount=amount,
actor_user_id=user.user_id,
)
voucher_info = {
"voucher_id": str(voucher.id),
"voucher_number": voucher.voucher_number,
}
elif entity.module == "purchase" and entity.doc_type == "invoice":
voucher = await PurchaseInventoryAccountingService(db).post_purchase_invoice(
tenant_id,
source_document_id=str(entity.id),
amount=amount,
actor_user_id=user.user_id,
)
voucher_info = {
"voucher_id": str(voucher.id),
"voucher_number": voucher.voucher_number,
}
elif entity.module == "purchase" and entity.doc_type == "return":
voucher = await PurchaseInventoryAccountingService(db).post_purchase_return(
tenant_id,
source_document_id=str(entity.id),
amount=amount,
actor_user_id=user.user_id,
)
voucher_info = {
"voucher_id": str(voucher.id),
"voucher_number": voucher.voucher_number,
}
else:
# Policy on but no engine mapping — leave as confirmed only
voucher_info = None
except AppError:
raise
except Exception as exc: # noqa: BLE001
raise AppError(
f"ثبت خودکار حسابداری ناموفق بود: {exc}",
status_code=422,
error_code="auto_post_failed",
) from exc
entity.status = "posted" if voucher_info else "confirmed"
if voucher_info and entity.meta_json:
import json
try:
meta = json.loads(entity.meta_json)
except Exception:
meta = {}
meta["accounting_voucher"] = voucher_info
entity.meta_json = json.dumps(meta, ensure_ascii=False)
elif voucher_info:
import json
entity.meta_json = json.dumps({"accounting_voucher": voucher_info}, ensure_ascii=False)
await db.commit()
await db.refresh(entity)
return entity

View File

@ -27,6 +27,45 @@ from shared.security import CurrentUser
router = APIRouter()
def _assert_lines_balanced(lines: list) -> None:
from decimal import Decimal
if len(lines) < 2:
raise AppError(
"حداقل دو ردیف برای سند لازم است",
status_code=422,
error_code="voucher_lines_min",
)
total_debit = Decimal("0")
total_credit = Decimal("0")
for i, line in enumerate(lines, start=1):
data = line.model_dump() if hasattr(line, "model_dump") else dict(line)
debit = Decimal(str(data.get("debit") or 0))
credit = Decimal(str(data.get("credit") or 0))
if debit > 0 and credit > 0:
raise AppError(
f"ردیف {i}: نمی‌توان همزمان بدهکار و بستانکار داشت",
status_code=422,
error_code="voucher_line_both_sides",
)
if debit == 0 and credit == 0:
raise AppError(
f"ردیف {i}: یکی از بدهکار یا بستانکار باید مقدار داشته باشد",
status_code=422,
error_code="voucher_line_empty",
)
total_debit += debit
total_credit += credit
if total_debit != total_credit:
diff = abs(total_debit - total_credit)
raise AppError(
f"سند نامتوازن است — جمع بدهکار {total_debit} و بستانکار {total_credit} "
f"(اختلاف {diff}). ثبت مجاز نیست.",
status_code=422,
error_code="voucher_unbalanced",
)
@router.get("/vouchers", response_model=list[VoucherRead])
async def list_vouchers(
tenant_id: UUID = Depends(require_tenant),
@ -58,6 +97,7 @@ async def create_voucher(
):
from app.services.document_number_service import DocumentNumberService
_assert_lines_balanced(body.lines)
repo = VoucherRepository(db)
number = await DocumentNumberService(db).allocate_if_blank(
tenant_id, "voucher", body.voucher_number
@ -120,6 +160,8 @@ async def update_draft_voucher(
)
data = body.model_dump(exclude_unset=True)
lines_data = data.pop("lines", None)
if lines_data is not None:
_assert_lines_balanced(lines_data)
for key, value in data.items():
setattr(voucher, key, value)
if lines_data is not None:

View File

@ -98,7 +98,70 @@ async def post_goods_receipt(
profile_id=body.profile_id,
)
await db.commit()
return {"voucher_id": str(voucher.id), "status": voucher.status.value}
return {"voucher_id": str(voucher.id), "voucher_number": voucher.voucher_number, "status": voucher.status.value}
@router.post("/preview/purchase-invoice")
async def preview_purchase_invoice(
body: GoodsReceiptRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
svc = PurchaseInventoryAccountingService(db)
preview = await svc.preview_purchase_invoice(tenant_id, body.amount, body.profile_id)
await db.commit()
return {
"is_balanced": preview.is_balanced,
"total_debit": str(preview.total_debit),
"total_credit": str(preview.total_credit),
}
@router.post("/post/purchase-invoice")
async def post_purchase_invoice(
body: GoodsReceiptRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
svc = PurchaseInventoryAccountingService(db)
voucher = await svc.post_purchase_invoice(
tenant_id,
source_document_id=body.source_document_id,
amount=body.amount,
actor_user_id=user.user_id,
profile_id=body.profile_id,
)
await db.commit()
return {
"voucher_id": str(voucher.id),
"voucher_number": voucher.voucher_number,
"status": voucher.status.value,
}
@router.post("/post/purchase-return")
async def post_purchase_return(
body: GoodsReceiptRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
svc = PurchaseInventoryAccountingService(db)
voucher = await svc.post_purchase_return(
tenant_id,
source_document_id=body.source_document_id,
amount=body.amount,
actor_user_id=user.user_id,
profile_id=body.profile_id,
)
await db.commit()
return {
"voucher_id": str(voucher.id),
"voucher_number": voucher.voucher_number,
"status": voucher.status.value,
}
@router.post("/valuation")

View File

@ -108,3 +108,40 @@ async def allocate_number(
number = await svc.allocate(tenant_id, body.document_type)
await db.commit()
return {"document_type": body.document_type, "number": number}
class PostingPolicyUpdate(BaseModel):
auto_post: dict[str, bool] | None = None
require_balanced: bool | None = None
require_source_reference: bool | None = None
lock_after_post: bool | None = None
sequential_numbers: bool | None = None
idempotent_source: bool | None = None
posting_mode: str | None = None
@router.get("/posting-policy")
async def get_posting_policy(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
from app.services.posting_policy_service import PostingPolicyService
return await PostingPolicyService(db).get_policy(tenant_id)
@router.put("/posting-policy")
async def update_posting_policy(
body: PostingPolicyUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
from app.services.posting_policy_service import PostingPolicyService
policy = await PostingPolicyService(db).update_policy(
tenant_id, body.model_dump(exclude_none=True)
)
await db.commit()
return policy

View File

@ -260,6 +260,7 @@ class ReceiptCreate(BaseModel):
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
@ -270,6 +271,7 @@ class PaymentCreate(BaseModel):
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
@ -365,6 +367,7 @@ async def list_receipts(
"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
]
@ -375,18 +378,72 @@ async def create_receipt(
body: ReceiptCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
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}
return {
"id": str(entity.id),
"receipt_number": entity.receipt_number,
"voucher_id": voucher_id,
"voucher_number": voucher_number,
}
@router.get("/payments")
@ -404,6 +461,7 @@ async def list_payments(
"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
]
@ -414,18 +472,113 @@ async def create_payment(
body: PaymentCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
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}
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")

View File

@ -0,0 +1,149 @@
"""Tenant posting policy for automatic GL documents (intl. controls)."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.foundation import AccountingSettings
# Defaults follow conservative international practice:
# balanced vouchers, immutable posted docs, sequential numbers, source refs,
# idempotent re-post prevention. Auto-post modules default OFF until enabled.
DEFAULT_POLICY: dict[str, str] = {
"auto_post.sales.invoice": "false",
"auto_post.sales.return": "false",
"auto_post.purchase.goods_receipt": "false",
"auto_post.purchase.invoice": "false",
"auto_post.purchase.return": "false",
"auto_post.treasury.receipt": "true",
"auto_post.treasury.payment": "true",
"auto_post.payroll": "false",
"auto_post.assets.depreciation": "false",
"policy.require_balanced": "true",
"policy.require_source_reference": "true",
"policy.lock_after_post": "true",
"policy.sequential_numbers": "true",
"policy.idempotent_source": "true",
"policy.posting_mode": "direct_post", # direct_post | draft_then_post
}
BOOL_TRUE = {"1", "true", "yes", "on"}
def _as_bool(value: str | None, default: bool = False) -> bool:
if value is None:
return default
return value.strip().lower() in BOOL_TRUE
class PostingPolicyService:
PREFIX = "posting."
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def get_policy(self, tenant_id: UUID) -> dict:
stmt = select(AccountingSettings).where(
AccountingSettings.tenant_id == tenant_id,
AccountingSettings.key.like(f"{self.PREFIX}%"),
)
rows = list((await self.session.execute(stmt)).scalars().all())
stored = {r.key[len(self.PREFIX) :]: (r.value or "") for r in rows}
merged = {**DEFAULT_POLICY, **stored}
auto_post = {
"sales_invoice": _as_bool(merged.get("auto_post.sales.invoice")),
"sales_return": _as_bool(merged.get("auto_post.sales.return")),
"purchase_goods_receipt": _as_bool(merged.get("auto_post.purchase.goods_receipt")),
"purchase_invoice": _as_bool(merged.get("auto_post.purchase.invoice")),
"purchase_return": _as_bool(merged.get("auto_post.purchase.return")),
"treasury_receipt": _as_bool(merged.get("auto_post.treasury.receipt"), True),
"treasury_payment": _as_bool(merged.get("auto_post.treasury.payment"), True),
"payroll": _as_bool(merged.get("auto_post.payroll")),
"assets_depreciation": _as_bool(merged.get("auto_post.assets.depreciation")),
}
return {
"auto_post": auto_post,
"require_balanced": _as_bool(merged.get("policy.require_balanced"), True),
"require_source_reference": _as_bool(
merged.get("policy.require_source_reference"), True
),
"lock_after_post": _as_bool(merged.get("policy.lock_after_post"), True),
"sequential_numbers": _as_bool(merged.get("policy.sequential_numbers"), True),
"idempotent_source": _as_bool(merged.get("policy.idempotent_source"), True),
"posting_mode": merged.get("policy.posting_mode") or "direct_post",
"raw": merged,
}
async def update_policy(self, tenant_id: UUID, body: dict) -> dict:
updates: dict[str, str] = {}
auto = body.get("auto_post") or {}
mapping = {
"sales_invoice": "auto_post.sales.invoice",
"sales_return": "auto_post.sales.return",
"purchase_goods_receipt": "auto_post.purchase.goods_receipt",
"purchase_invoice": "auto_post.purchase.invoice",
"purchase_return": "auto_post.purchase.return",
"treasury_receipt": "auto_post.treasury.receipt",
"treasury_payment": "auto_post.treasury.payment",
"payroll": "auto_post.payroll",
"assets_depreciation": "auto_post.assets.depreciation",
}
for fe_key, store_key in mapping.items():
if fe_key in auto:
updates[store_key] = "true" if bool(auto[fe_key]) else "false"
for key in (
"require_balanced",
"require_source_reference",
"lock_after_post",
"sequential_numbers",
"idempotent_source",
):
if key in body:
updates[f"policy.{key}"] = "true" if bool(body[key]) else "false"
if "posting_mode" in body and body["posting_mode"] in ("direct_post", "draft_then_post"):
updates["policy.posting_mode"] = str(body["posting_mode"])
for short_key, value in updates.items():
full_key = f"{self.PREFIX}{short_key}"
stmt = select(AccountingSettings).where(
AccountingSettings.tenant_id == tenant_id,
AccountingSettings.key == full_key,
)
row = (await self.session.execute(stmt)).scalar_one_or_none()
if row is None:
row = AccountingSettings(
tenant_id=tenant_id,
key=full_key,
value=value,
value_type="boolean" if short_key.startswith("auto_post") or short_key.startswith("policy.") and short_key != "policy.posting_mode" else "string",
)
self.session.add(row)
else:
row.value = value
await self.session.flush()
return await self.get_policy(tenant_id)
def is_auto_post_enabled(self, policy: dict, module: str, doc_type: str) -> bool:
auto = policy.get("auto_post") or {}
key = f"{module}.{doc_type}".replace("-", "_")
aliases = {
"sales.invoice": "sales_invoice",
"sales.return": "sales_return",
"purchase.goods_receipt": "purchase_goods_receipt",
"purchase.invoice": "purchase_invoice",
"purchase.return": "purchase_return",
"treasury.receipt": "treasury_receipt",
"treasury.payment": "treasury_payment",
"payroll.payroll": "payroll",
"assets.depreciation": "assets_depreciation",
}
mapped = aliases.get(key) or aliases.get(f"{module}.{doc_type}")
if mapped:
return bool(auto.get(mapped))
# snake variants
snake = key.replace(".", "_")
return bool(auto.get(snake))

View File

@ -114,6 +114,16 @@ class PurchaseInventoryAccountingService:
) -> Voucher:
from datetime import date
from app.services.source_posting_guard import SourcePostingGuard
guard = SourcePostingGuard(self.session)
await guard.assert_can_post(
tenant_id,
source_module="purchase",
source_document_type=PurchaseDocumentType.GOODS_RECEIPT.value,
source_document_id=source_document_id,
)
profile = await self._resolve_purchase_profile(tenant_id, profile_id)
period = await self.period_repo.get_current(tenant_id)
if period is None:
@ -142,9 +152,217 @@ class PurchaseInventoryAccountingService:
account_id=profile.grni_account_id, debit=Decimal("0"), credit=amount,
))
await self.session.flush()
return await self.posting_engine.post_voucher(
if await guard.should_post_immediately(tenant_id):
voucher = await self.posting_engine.post_voucher(
tenant_id, voucher.id, actor_user_id=actor_user_id, source_module="purchase"
)
await guard.mark_posted(
tenant_id,
voucher_id=voucher.id,
source_module="purchase",
source_document_type=PurchaseDocumentType.GOODS_RECEIPT.value,
source_document_id=source_document_id,
)
return voucher
async def preview_purchase_invoice(
self, tenant_id: UUID, amount: Decimal, profile_id: UUID | None = None
) -> AccountingPreview:
profile = await self._resolve_purchase_profile(tenant_id, profile_id)
lines = []
# Clear GRNI / expense and credit AP liability
debit_account = profile.grni_account_id or profile.expense_account_id or profile.inventory_account_id
credit_account = profile.liability_account_id
if debit_account:
lines.append({"account_id": str(debit_account), "debit": str(amount), "credit": "0"})
if credit_account:
lines.append({"account_id": str(credit_account), "debit": "0", "credit": str(amount)})
total_debit = sum(Decimal(l["debit"]) for l in lines)
total_credit = sum(Decimal(l["credit"]) for l in lines)
preview = AccountingPreview(
tenant_id=tenant_id,
source_module="purchase",
source_document_type=PurchaseDocumentType.SUPPLIER_INVOICE.value,
source_document_id="preview",
preview_data=json.dumps(lines),
is_balanced=total_debit == total_credit and len(lines) >= 2,
total_debit=total_debit,
total_credit=total_credit,
)
self.session.add(preview)
await self.session.flush()
return preview
async def post_purchase_invoice(
self,
tenant_id: UUID,
*,
source_document_id: str,
amount: Decimal,
actor_user_id: str,
profile_id: UUID | None = None,
) -> Voucher:
from datetime import date
from app.services.document_number_service import DocumentNumberService
from app.services.source_posting_guard import SourcePostingGuard
from shared.exceptions import AppError
guard = SourcePostingGuard(self.session)
await guard.assert_can_post(
tenant_id,
source_module="purchase",
source_document_type=PurchaseDocumentType.SUPPLIER_INVOICE.value,
source_document_id=source_document_id,
)
profile = await self._resolve_purchase_profile(tenant_id, profile_id)
period = await self.period_repo.get_current(tenant_id)
if period is None:
raise NotFoundError("دوره مالی جاری یافت نشد", error_code="period_not_found")
debit_account = profile.grni_account_id or profile.expense_account_id or profile.inventory_account_id
credit_account = profile.liability_account_id
if not debit_account or not credit_account:
raise AppError(
"پروفایل خرید ناقص است: حساب بدهکار (GRNI/هزینه) و بستانکار (بدهی) لازم است",
status_code=422,
error_code="purchase_profile_incomplete",
)
number = await DocumentNumberService(self.session).allocate(tenant_id, "voucher")
voucher = Voucher(
tenant_id=tenant_id,
fiscal_period_id=period.id,
voucher_number=number,
voucher_date=date.today(),
status=VoucherStatus.DRAFT,
description=f"Purchase invoice {source_document_id}",
source_module="purchase",
reference_number=source_document_id,
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,
debit=amount,
credit=Decimal("0"),
)
)
self.session.add(
VoucherLine(
tenant_id=tenant_id,
voucher_id=voucher.id,
line_number=2,
account_id=credit_account,
debit=Decimal("0"),
credit=amount,
)
)
await self.session.flush()
if await guard.should_post_immediately(tenant_id):
voucher = await self.posting_engine.post_voucher(
tenant_id, voucher.id, actor_user_id=actor_user_id, source_module="purchase"
)
await guard.mark_posted(
tenant_id,
voucher_id=voucher.id,
source_module="purchase",
source_document_type=PurchaseDocumentType.SUPPLIER_INVOICE.value,
source_document_id=source_document_id,
)
return voucher
async def post_purchase_return(
self,
tenant_id: UUID,
*,
source_document_id: str,
amount: Decimal,
actor_user_id: str,
profile_id: UUID | None = None,
) -> Voucher:
"""Reverse of supplier invoice: Dr AP liability / Cr inventory or GRNI."""
from datetime import date
from app.services.document_number_service import DocumentNumberService
from app.services.source_posting_guard import SourcePostingGuard
from shared.exceptions import AppError
doc_type_value = PurchaseDocumentType.PURCHASE_RETURN.value
guard = SourcePostingGuard(self.session)
await guard.assert_can_post(
tenant_id,
source_module="purchase",
source_document_type=doc_type_value,
source_document_id=source_document_id,
)
profile = await self._resolve_purchase_profile(tenant_id, profile_id)
period = await self.period_repo.get_current(tenant_id)
if period is None:
raise NotFoundError("دوره مالی جاری یافت نشد", error_code="period_not_found")
debit_account = profile.liability_account_id
credit_account = profile.inventory_account_id or profile.grni_account_id or profile.expense_account_id
if not debit_account or not credit_account:
raise AppError(
"پروفایل خرید ناقص است: حساب بدهی و موجودی/GRNI برای مرجوعی لازم است",
status_code=422,
error_code="purchase_profile_incomplete",
)
number = await DocumentNumberService(self.session).allocate(tenant_id, "voucher")
voucher = Voucher(
tenant_id=tenant_id,
fiscal_period_id=period.id,
voucher_number=number,
voucher_date=date.today(),
status=VoucherStatus.DRAFT,
description=f"Purchase return {source_document_id}",
source_module="purchase",
reference_number=source_document_id,
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,
debit=amount,
credit=Decimal("0"),
)
)
self.session.add(
VoucherLine(
tenant_id=tenant_id,
voucher_id=voucher.id,
line_number=2,
account_id=credit_account,
debit=Decimal("0"),
credit=amount,
)
)
await self.session.flush()
if await guard.should_post_immediately(tenant_id):
voucher = await self.posting_engine.post_voucher(
tenant_id, voucher.id, actor_user_id=actor_user_id, source_module="purchase"
)
await guard.mark_posted(
tenant_id,
voucher_id=voucher.id,
source_module="purchase",
source_document_type=doc_type_value,
source_document_id=source_document_id,
)
return voucher
async def record_valuation(
self,

View File

@ -90,6 +90,16 @@ class SalesAccountingService:
) -> Voucher:
from datetime import date
from app.services.source_posting_guard import SourcePostingGuard
guard = SourcePostingGuard(self.session)
await guard.assert_can_post(
tenant_id,
source_module="sales",
source_document_type=document_type.value,
source_document_id=source_document_id,
)
profile = await self._resolve_profile(tenant_id, document_type, profile_id)
period = await self.period_repo.get_current(tenant_id)
if period is None:
@ -138,9 +148,18 @@ class SalesAccountingService:
))
await self.session.flush()
return await self.posting_engine.post_voucher(
if await guard.should_post_immediately(tenant_id):
voucher = await self.posting_engine.post_voucher(
tenant_id, voucher.id, actor_user_id=actor_user_id, source_module="sales"
)
await guard.mark_posted(
tenant_id,
voucher_id=voucher.id,
source_module="sales",
source_document_type=document_type.value,
source_document_id=source_document_id,
)
return voucher
async def _resolve_profile(
self, tenant_id: UUID, document_type: SalesDocumentType, profile_id: UUID | None

View File

@ -0,0 +1,70 @@
"""Idempotent source-document posting guard (intl. no double-post)."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.posting import PostingReference
from app.repositories.posting import PostingReferenceRepository
from app.services.posting_policy_service import PostingPolicyService
from shared.exceptions import AppError
class SourcePostingGuard:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.ref_repo = PostingReferenceRepository(session)
self.policy_svc = PostingPolicyService(session)
async def assert_can_post(
self,
tenant_id: UUID,
*,
source_module: str,
source_document_type: str,
source_document_id: str,
) -> None:
policy = await self.policy_svc.get_policy(tenant_id)
if not policy.get("idempotent_source", True):
return
existing = await self.ref_repo.get_by_source(
tenant_id, source_module, source_document_type, source_document_id
)
if existing is not None and existing.is_posted:
raise AppError(
"این سند مبدأ قبلاً در دفتر کل ثبت شده است (جلوگیری از ثبت تکراری).",
status_code=409,
error_code="source_already_posted",
)
async def mark_posted(
self,
tenant_id: UUID,
*,
voucher_id: UUID,
source_module: str,
source_document_type: str,
source_document_id: str,
) -> PostingReference:
existing = await self.ref_repo.get_by_source(
tenant_id, source_module, source_document_type, source_document_id
)
if existing is None:
ref = PostingReference(
tenant_id=tenant_id,
voucher_id=voucher_id,
source_module=source_module,
source_document_type=source_document_type,
source_document_id=source_document_id,
is_posted=True,
)
return await self.ref_repo.add(ref)
existing.voucher_id = voucher_id
existing.is_posted = True
await self.session.flush()
return existing
async def should_post_immediately(self, tenant_id: UUID) -> bool:
policy = await self.policy_svc.get_policy(tenant_id)
return (policy.get("posting_mode") or "direct_post") != "draft_then_post"

View File

@ -132,6 +132,63 @@ class TreasuryService:
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,
@ -144,6 +201,9 @@ class TreasuryService:
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,
@ -153,6 +213,7 @@ class TreasuryService:
status=VoucherStatus.DRAFT,
description=description,
source_module=source_module,
reference_number=reference_number,
created_by=actor_user_id,
)
await self.voucher_repo.add(voucher)
@ -168,6 +229,8 @@ class TreasuryService:
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
)

View File

@ -11,7 +11,7 @@ export default function Page() {
docType="goods_receipt"
partyLabel="تأمین‌کننده"
kind="receipt"
enableAccountingPost="purchase"
enableAccountingPost="purchase_gr"
/>
);
}

View File

@ -11,7 +11,7 @@ export default function Page() {
docType="invoice"
partyLabel="تأمین‌کننده"
kind="invoice"
enableAccountingPost="purchase"
enableAccountingPost="purchase_invoice"
/>
);
}

View File

@ -11,6 +11,7 @@ export default function Page() {
docType="return"
partyLabel="تأمین‌کننده"
kind="invoice"
enableAccountingPost="purchase_return"
/>
);
}

View File

@ -11,6 +11,7 @@ export default function Page() {
docType="return"
partyLabel="مشتری"
kind="invoice"
enableAccountingPost="sales"
/>
);
}

View File

@ -0,0 +1,185 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { accountingApi } from "@/lib/accounting-api";
import { useTenantId } from "@/hooks/useTenantId";
import {
PageHeader,
LoadingState,
ErrorState,
Card,
CardContent,
CardHeader,
CardTitle,
Button,
Checkbox,
Select,
Badge,
} from "@/components/ds";
const AUTO_KEYS: { key: string; label: string; hint: string }[] = [
{ key: "sales_invoice", label: "فاکتور فروش", hint: "با تأیید فاکتور فروش، سند GL خودکار صادر شود" },
{ key: "sales_return", label: "مرجوعی فروش", hint: "ثبت خودکار برگشت فروش در دفتر" },
{ key: "purchase_goods_receipt", label: "رسید کالا", hint: "Dr موجودی / Cr GRNI هنگام تأیید رسید" },
{ key: "purchase_invoice", label: "فاکتور خرید", hint: "ثبت بدهی تأمین‌کننده هنگام تأیید فاکتور" },
{ key: "purchase_return", label: "مرجوعی خرید", hint: "ثبت خودکار مرجوعی خرید" },
{ key: "treasury_receipt", label: "دریافت خزانه", hint: "دریافت نقدی/بانکی همیشه به Posting Engine" },
{ key: "treasury_payment", label: "پرداخت خزانه", hint: "پرداخت نقدی/بانکی همیشه به Posting Engine" },
{ key: "payroll", label: "حقوق و دستمزد", hint: "پس از محاسبه/تأیید حقوق" },
{ key: "assets_depreciation", label: "استهلاک دارایی", hint: "ثبت خودکار استهلاک دوره‌ای" },
];
const POLICY_KEYS: { key: string; label: string; hint: string }[] = [
{
key: "require_balanced",
label: "اجبار تراز بدهکار/بستانکار",
hint: "سند نامتوازن هرگز ذخیره یا قطعی نشود (استاندارد دوبل‌انتری)",
},
{
key: "require_source_reference",
label: "الزام مرجع سند مبدأ",
hint: "اسناد اتومات باید source_module و reference داشته باشند",
},
{
key: "lock_after_post",
label: "قفل پس از ثبت قطعی",
hint: "ویرایش مستقیم ممنوع؛ فقط برگشت از طریق Posting Engine",
},
{
key: "sequential_numbers",
label: "شماره‌گذاری متوالی",
hint: "استفاده از Document Number Sequence به‌جای شناسه تصادفی",
},
{
key: "idempotent_source",
label: "جلوگیری از ثبت تکراری",
hint: "یک سند مبدأ بیش از یک بار به GL نرود",
},
];
export default function AutoPostingSettingsPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const policyQ = useQuery({
queryKey: ["accounting", tenantId, "posting-policy"],
queryFn: () => accountingApi.setup.getPostingPolicy(tenantId!),
enabled: !!tenantId,
});
const saveM = useMutation({
mutationFn: (body: Parameters<typeof accountingApi.setup.updatePostingPolicy>[1]) =>
accountingApi.setup.updatePostingPolicy(tenantId!, body),
onSuccess: async () => {
toast.success("سیاست اسناد اتومات ذخیره شد");
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "posting-policy"] });
},
onError: (e: Error) => toast.error(e.message),
});
if (!tenantId || policyQ.isLoading) return <LoadingState />;
if (policyQ.error) {
return <ErrorState message={policyQ.error.message} onRetry={() => policyQ.refetch()} />;
}
const policy = policyQ.data!;
const toggleAuto = (key: string, value: boolean) => {
saveM.mutate({
auto_post: { ...policy.auto_post, [key]: value },
});
};
const togglePolicy = (key: string, value: boolean) => {
saveM.mutate({ [key]: value } as Parameters<typeof accountingApi.setup.updatePostingPolicy>[1]);
};
return (
<div>
<PageHeader
title="اسناد اتوماتیک حسابداری"
description="کنترل ثبت خودکار در دفتر کل مطابق استانداردهای بین‌المللی دوبل‌انتری: تراز، قفل پس از ثبت، شماره متوالی، مرجع مبدأ و جلوگیری از ثبت تکراری."
/>
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
حالت ثبت
<Badge tone="primary">{policy.posting_mode === "direct_post" ? "ثبت مستقیم" : "پیش‌نویس سپس قطعی"}</Badge>
</CardTitle>
</CardHeader>
<CardContent className="max-w-md space-y-2">
<Select
value={policy.posting_mode}
disabled={saveM.isPending}
onChange={(e) => saveM.mutate({ posting_mode: e.target.value })}
>
<option value="direct_post">ثبت مستقیم در دفتر (پس از تأیید سند عملیاتی)</option>
<option value="draft_then_post">ایجاد پیشنویس و قطعیسازی جداگانه</option>
</Select>
<p className="text-xs text-[var(--muted)]">
در حالت مستقیم، با تأیید سند عملیاتی (اگر ماژول روشن باشد) سند GL بلافاصله از طریق Posting Engine صادر میشود.
</p>
</CardContent>
</Card>
<div className="mb-6 grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>قواعد الزامی (Compliance)</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{POLICY_KEYS.map((item) => (
<div key={item.key} className="flex items-start gap-3 rounded-xl border border-[var(--border)] p-3">
<Checkbox
checked={Boolean((policy as Record<string, unknown>)[item.key])}
disabled={saveM.isPending}
onChange={(e) => togglePolicy(item.key, e.target.checked)}
aria-label={item.label}
/>
<span>
<span className="block text-sm font-medium text-secondary">{item.label}</span>
<span className="mt-0.5 block text-xs text-[var(--muted)]">{item.hint}</span>
</span>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>ثبت خودکار به تفکیک ماژول</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{AUTO_KEYS.map((item) => (
<div key={item.key} className="flex items-start gap-3 rounded-xl border border-[var(--border)] p-3">
<Checkbox
checked={Boolean(policy.auto_post?.[item.key])}
disabled={saveM.isPending}
onChange={(e) => toggleAuto(item.key, e.target.checked)}
aria-label={item.label}
/>
<span>
<span className="block text-sm font-medium text-secondary">{item.label}</span>
<span className="mt-0.5 block text-xs text-[var(--muted)]">{item.hint}</span>
</span>
</div>
))}
</CardContent>
</Card>
</div>
<Card>
<CardContent className="flex flex-wrap items-center justify-between gap-3 pt-6">
<p className="text-sm text-[var(--muted)]">
قلب حسابداری فقط از مسیر Posting Engine میگذرد (ADR-010). تنظیمات بالا فقط زمان و شرط صدور را کنترل میکنند.
</p>
<Button type="button" variant="outline" disabled={saveM.isPending} onClick={() => policyQ.refetch()}>
بازخوانی
</Button>
</CardContent>
</Card>
</div>
);
}

View File

@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { z } from "zod";
@ -11,6 +11,7 @@ import { Plus, Trash2 } from "lucide-react";
import { accountingApi } from "@/lib/accounting-api";
import { useTenantId } from "@/hooks/useTenantId";
import { formatMoney, toRialInteger } from "@/lib/utils";
import { lineBothSidesError, sumVoucherLines, unbalancedMessage } from "@/lib/voucher-balance";
import {
PageHeader,
Button,
@ -116,8 +117,14 @@ export default function VoucherDetailPage() {
};
const updateM = useMutation({
mutationFn: (data: EditForm) =>
accountingApi.vouchers.update(tenantId!, id, {
mutationFn: (data: EditForm) => {
const bal = sumVoucherLines(data.lines);
if (!bal.isBalanced) throw new Error(unbalancedMessage(bal.debit, bal.credit));
for (let i = 0; i < data.lines.length; i++) {
const err = lineBothSidesError(data.lines[i].debit, data.lines[i].credit);
if (err) throw new Error(`ردیف ${i + 1}: ${err}`);
}
return accountingApi.vouchers.update(tenantId!, id, {
voucher_date: data.voucher_date,
description: data.description || undefined,
lines: data.lines.map((l) => ({
@ -128,7 +135,8 @@ export default function VoucherDetailPage() {
cost_center_id: l.cost_center_id || null,
project_id: l.project_id || null,
})),
}),
});
},
onSuccess: async () => {
toast.success("سند به‌روز شد");
setEditing(false);
@ -183,6 +191,9 @@ export default function VoucherDetailPage() {
onError: (e: Error) => toast.error(e.message),
});
const watchedEditLines = editForm.watch("lines");
const balance = useMemo(() => sumVoucherLines(watchedEditLines), [watchedEditLines]);
if (!tenantId || voucherQ.isLoading) return <LoadingState />;
if (voucherQ.error) return <ErrorState message={voucherQ.error.message} onRetry={() => voucherQ.refetch()} />;
@ -191,6 +202,7 @@ export default function VoucherDetailPage() {
const postable = (accountsQ.data ?? []).filter((a) => a.is_postable);
const activeCostCenters = (costCentersQ.data ?? []).filter((c) => c.is_active);
const activeProjects = (projectsQ.data ?? []).filter((p) => p.is_active);
const isDraft = v.status === "draft";
return (
@ -319,10 +331,17 @@ export default function VoucherDetailPage() {
ردیف
</Button>
</div>
{fields.map((field, index) => (
{fields.map((field, index) => {
const lineErr = lineBothSidesError(
watchedEditLines?.[index]?.debit ?? "0",
watchedEditLines?.[index]?.credit ?? "0"
);
return (
<div
key={field.id}
className="grid gap-2 rounded-xl border border-[var(--border)] p-3 sm:grid-cols-12"
className={`grid gap-2 rounded-xl border p-3 sm:grid-cols-12 ${
lineErr ? "border-red-300 bg-red-50/40" : "border-[var(--border)]"
}`}
>
<div className="sm:col-span-3">
<Label>حساب</Label>
@ -377,19 +396,40 @@ export default function VoucherDetailPage() {
<Trash2 className="h-4 w-4" />
</Button>
</div>
{lineErr ? (
<p className="sm:col-span-12 text-xs text-red-700">ردیف {index + 1}: {lineErr}</p>
) : null}
</div>
))}
);
})}
<FieldError>
{editForm.formState.errors.lines?.message || editForm.formState.errors.lines?.root?.message}
</FieldError>
</CardContent>
</Card>
<div
className={`rounded-xl border p-3 text-sm ${
balance.isBalanced
? "border-emerald-200 bg-emerald-50 text-emerald-800"
: "border-red-200 bg-red-50 text-red-800"
}`}
>
<div className="flex flex-wrap gap-4">
<span>جمع بدهکار: {formatMoney(String(balance.debit))}</span>
<span>جمع بستانکار: {formatMoney(String(balance.credit))}</span>
<span>اختلاف: {formatMoney(String(Math.abs(balance.difference)))}</span>
<span className="font-semibold">{balance.isBalanced ? "متوازن ✓" : "نامتوازن — ذخیره مجاز نیست"}</span>
</div>
{!balance.isBalanced ? (
<p className="mt-2 text-xs">{unbalancedMessage(balance.debit, balance.credit)}</p>
) : null}
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setEditing(false)}>
انصراف
</Button>
<Button type="submit" disabled={updateM.isPending}>
<Button type="submit" disabled={updateM.isPending || !balance.isBalanced || (watchedEditLines ?? []).some((l) => !!lineBothSidesError(l.debit, l.credit))}>
ذخیره تغییرات
</Button>
</div>

View File

@ -1,6 +1,6 @@
"use client";
import { useEffect } from "react";
import { useEffect, useMemo } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
@ -11,6 +11,8 @@ import { Plus, Trash2 } from "lucide-react";
import { accountingApi } from "@/lib/accounting-api";
import { useTenantId } from "@/hooks/useTenantId";
import { todayIso } from "@/lib/jalali";
import { formatMoney } from "@/lib/utils";
import { lineBothSidesError, sumVoucherLines, unbalancedMessage } from "@/lib/voucher-balance";
import {
PageHeader,
Button,
@ -96,8 +98,16 @@ export default function NewVoucherPage() {
}, [nextNumberQ.data, form]);
const createM = useMutation({
mutationFn: (data: FormValues) =>
accountingApi.vouchers.create(tenantId!, {
mutationFn: (data: FormValues) => {
const bal = sumVoucherLines(data.lines);
if (!bal.isBalanced) {
throw new Error(unbalancedMessage(bal.debit, bal.credit));
}
for (let i = 0; i < data.lines.length; i++) {
const err = lineBothSidesError(data.lines[i].debit, data.lines[i].credit);
if (err) throw new Error(`ردیف ${i + 1}: ${err}`);
}
return accountingApi.vouchers.create(tenantId!, {
fiscal_period_id: data.fiscal_period_id,
voucher_number: data.voucher_number?.trim() || undefined,
voucher_date: data.voucher_date,
@ -111,7 +121,8 @@ export default function NewVoucherPage() {
cost_center_id: l.cost_center_id || null,
project_id: l.project_id || null,
})),
}),
});
},
onSuccess: (v) => {
toast.success("سند ایجاد شد");
router.push(`/accounting/vouchers/${v.id}`);
@ -119,6 +130,9 @@ export default function NewVoucherPage() {
onError: (e: Error) => toast.error(e.message),
});
const watchedLines = form.watch("lines");
const balance = useMemo(() => sumVoucherLines(watchedLines), [watchedLines]);
if (!tenantId || periodsQ.isLoading || accountsQ.isLoading) return <LoadingState />;
if (periodsQ.error || accountsQ.error) {
return (
@ -219,10 +233,17 @@ export default function NewVoucherPage() {
ردیف
</Button>
</div>
{fields.map((field, index) => (
{fields.map((field, index) => {
const lineErr = lineBothSidesError(
watchedLines?.[index]?.debit ?? "0",
watchedLines?.[index]?.credit ?? "0"
);
return (
<div
key={field.id}
className="grid gap-2 rounded-xl border border-[var(--border)] p-3 sm:grid-cols-12"
className={`grid gap-2 rounded-xl border p-3 sm:grid-cols-12 ${
lineErr ? "border-red-300 bg-red-50/40" : "border-[var(--border)]"
}`}
>
<div className="sm:col-span-3">
<Label>حساب</Label>
@ -281,9 +302,30 @@ export default function NewVoucherPage() {
<Trash2 className="h-4 w-4" />
</Button>
</div>
{lineErr ? (
<p className="sm:col-span-12 text-xs text-red-700">ردیف {index + 1}: {lineErr}</p>
) : null}
</div>
))}
);
})}
<FieldError>{form.formState.errors.lines?.message || form.formState.errors.lines?.root?.message}</FieldError>
<div
className={`rounded-xl border p-3 text-sm ${
balance.isBalanced
? "border-emerald-200 bg-emerald-50 text-emerald-800"
: "border-red-200 bg-red-50 text-red-800"
}`}
>
<div className="flex flex-wrap gap-4">
<span>جمع بدهکار: {formatMoney(String(balance.debit))}</span>
<span>جمع بستانکار: {formatMoney(String(balance.credit))}</span>
<span>اختلاف: {formatMoney(String(Math.abs(balance.difference)))}</span>
<span className="font-semibold">{balance.isBalanced ? "متوازن ✓" : "نامتوازن — ثبت مجاز نیست"}</span>
</div>
{!balance.isBalanced ? (
<p className="mt-2 text-xs">{unbalancedMessage(balance.debit, balance.credit)}</p>
) : null}
</div>
</CardContent>
</Card>
@ -291,7 +333,7 @@ export default function NewVoucherPage() {
<Button type="button" variant="outline" onClick={() => router.push("/accounting/vouchers")}>
انصراف
</Button>
<Button type="submit" disabled={createM.isPending}>
<Button type="submit" disabled={createM.isPending || !balance.isBalanced || (watchedLines ?? []).some((l) => !!lineBothSidesError(l.debit, l.credit))}>
ذخیره پیشنویس
</Button>
</div>

View File

@ -713,6 +713,7 @@ export function ReceiptsPaymentsPage() {
payment_method: "cash",
cash_box_id: "",
bank_account_id: "",
counter_account_id: "",
description: "",
},
});
@ -737,6 +738,21 @@ export function ReceiptsPaymentsPage() {
queryFn: () => accountingApi.treasury.listBankAccounts(tenantId!),
enabled: !!tenantId,
});
const accountsQ = useQuery({
queryKey: ["accounting", tenantId, "accounts"],
queryFn: () => accountingApi.accounts.list(tenantId!),
enabled: !!tenantId,
});
const policyQ = useQuery({
queryKey: ["accounting", tenantId, "posting-policy"],
queryFn: () => accountingApi.setup.getPostingPolicy(tenantId!),
enabled: !!tenantId,
});
const autoPost =
mode === "receipt"
? Boolean(policyQ.data?.auto_post?.treasury_receipt ?? true)
: Boolean(policyQ.data?.auto_post?.treasury_payment ?? true);
const createM = useMutation({
mutationFn: async (d: {
@ -746,8 +762,13 @@ export function ReceiptsPaymentsPage() {
payment_method: string;
cash_box_id?: string;
bank_account_id?: string;
counter_account_id?: string;
description?: string;
}) => {
if (autoPost && !d.counter_account_id) {
throw new Error("حساب مقابل برای ثبت خودکار سند حسابداری الزامی است");
}
const counter = d.counter_account_id || undefined;
if (mode === "receipt") {
return accountingApi.treasury.createReceipt(tenantId!, {
receipt_number: d.number,
@ -756,6 +777,7 @@ export function ReceiptsPaymentsPage() {
payment_method: d.payment_method,
cash_box_id: d.payment_method === "cash" ? d.cash_box_id || undefined : undefined,
bank_account_id: d.payment_method === "bank" ? d.bank_account_id || undefined : undefined,
counter_account_id: counter,
description: d.description,
});
}
@ -766,20 +788,34 @@ export function ReceiptsPaymentsPage() {
payment_method: d.payment_method,
cash_box_id: d.payment_method === "cash" ? d.cash_box_id || undefined : undefined,
bank_account_id: d.payment_method === "bank" ? d.bank_account_id || undefined : undefined,
counter_account_id: counter,
description: d.description,
});
},
onSuccess: async () => {
toast.success("ثبت شد");
onSuccess: async (res) => {
const vn =
res && typeof res === "object" && "voucher_number" in res
? String((res as { voucher_number?: string | null }).voucher_number || "")
: "";
toast.success(vn ? `ثبت شد — سند حسابداری ${vn}` : "ثبت شد");
setOpen(false);
form.reset();
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "receipts"] });
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "payments"] });
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "vouchers"] });
},
onError: (e: Error) => toast.error(e.message),
});
if (!tenantId || receiptsQ.isLoading || paymentsQ.isLoading || cashBoxesQ.isLoading || bankAccountsQ.isLoading) return <LoadingState />;
if (
!tenantId ||
receiptsQ.isLoading ||
paymentsQ.isLoading ||
cashBoxesQ.isLoading ||
bankAccountsQ.isLoading
) {
return <LoadingState />;
}
if (receiptsQ.error || paymentsQ.error || cashBoxesQ.error || bankAccountsQ.error) {
return (
<ErrorState
@ -792,6 +828,7 @@ export function ReceiptsPaymentsPage() {
);
}
const postable = (accountsQ.data ?? []).filter((a) => a.is_postable);
const rows =
mode === "receipt"
? (receiptsQ.data ?? []).map((r) => ({
@ -801,6 +838,7 @@ export function ReceiptsPaymentsPage() {
amount: r.amount,
payment_method: r.payment_method,
description: r.description,
voucher_id: r.voucher_id,
}))
: (paymentsQ.data ?? []).map((p) => ({
id: p.id,
@ -809,13 +847,18 @@ export function ReceiptsPaymentsPage() {
amount: p.amount,
payment_method: p.payment_method,
description: p.description,
voucher_id: p.voucher_id,
}));
return (
<div>
<PageHeader
title="دریافت و پرداخت"
description="ثبت دریافت/پرداخت خزانه."
description={
autoPost
? "ثبت دریافت/پرداخت با صدور خودکار سند حسابداری (قابل تنظیم در اسناد اتوماتیک)."
: "ثبت دریافت/پرداخت خزانه — ثبت خودکار دفتر کل فعلاً خاموش است."
}
actions={
<Button type="button" onClick={() => setOpen(true)}>
<Plus className="h-4 w-4" />
@ -846,6 +889,11 @@ export function ReceiptsPaymentsPage() {
header: "مبلغ",
render: (r) => formatMoney(String(r.amount)),
},
{
key: "voucher_id",
header: "سند GL",
render: (r) => (r.voucher_id ? <Badge tone="success">ثبتشده</Badge> : "—"),
},
{ key: "description", header: "توضیح" },
]}
rows={rows as unknown as Record<string, unknown>[]}
@ -856,10 +904,7 @@ export function ReceiptsPaymentsPage() {
<form className="grid gap-3 sm:grid-cols-2" onSubmit={form.handleSubmit((d) => createM.mutate(d))}>
<div className="sm:col-span-2">
<FormField label="نوع عملیات">
<Select
value={mode}
onChange={(e) => setMode(e.target.value as typeof mode)}
>
<Select value={mode} onChange={(e) => setMode(e.target.value as typeof mode)}>
<option value="receipt">دریافت</option>
<option value="payment">پرداخت</option>
</Select>
@ -894,7 +939,9 @@ export function ReceiptsPaymentsPage() {
<Select {...form.register("cash_box_id")} disabled={form.watch("payment_method") !== "cash"}>
<option value="">انتخاب صندوق</option>
{(cashBoxesQ.data ?? []).map((cashBox) => (
<option key={cashBox.id} value={cashBox.id}>{cashBox.name}</option>
<option key={cashBox.id} value={cashBox.id}>
{cashBox.name}
</option>
))}
</Select>
</FormField>
@ -902,10 +949,29 @@ export function ReceiptsPaymentsPage() {
<Select {...form.register("bank_account_id")} disabled={form.watch("payment_method") !== "bank"}>
<option value="">انتخاب حساب</option>
{(bankAccountsQ.data ?? []).map((account) => (
<option key={account.id} value={account.id}>{account.account_number}</option>
<option key={account.id} value={account.id}>
{account.account_number}
</option>
))}
</Select>
</FormField>
<div className="sm:col-span-2">
<FormField label={mode === "receipt" ? "حساب مقابل (بستانکار)" : "حساب مقابل (بدهکار)"}>
<Select {...form.register("counter_account_id", { required: autoPost })}>
<option value="">انتخاب حساب</option>
{postable.map((a) => (
<option key={a.id} value={a.id}>
{a.code} {a.name}
</option>
))}
</Select>
</FormField>
{autoPost ? (
<p className="mt-1 text-xs text-[var(--muted)]">
ثبت خودکار سند دفتر کل فعال است حساب صندوق/بانک از تنظیمات صندوق/حساب بانکی گرفته میشود.
</p>
) : null}
</div>
<div className="sm:col-span-2">
<FormField label="توضیح">
<Input {...form.register("description")} />

View File

@ -144,8 +144,8 @@ export function OperationalDocumentPage({
createLabel: string;
partyLabel?: string;
kind?: DocKind;
/** When true, draft rows get "ثبت حسابداری" using sales/purchase engines. */
enableAccountingPost?: "sales" | "purchase";
/** When set, draft rows get "ثبت حسابداری" using the matching engine. */
enableAccountingPost?: "sales" | "purchase_gr" | "purchase_invoice" | "purchase_return";
}) {
const { tenantId } = useTenantId();
const qc = useQueryClient();
@ -173,7 +173,16 @@ export function OperationalDocumentPage({
const purchaseProfilesQ = useQuery({
queryKey: ["accounting", tenantId, "purchase-profiles"],
queryFn: () => accountingApi.purchaseInventory.listProfiles(tenantId!),
enabled: !!tenantId && enableAccountingPost === "purchase",
enabled:
!!tenantId &&
(enableAccountingPost === "purchase_gr" ||
enableAccountingPost === "purchase_invoice" ||
enableAccountingPost === "purchase_return"),
});
const policyQ = useQuery({
queryKey: ["accounting", tenantId, "posting-policy"],
queryFn: () => accountingApi.setup.getPostingPolicy(tenantId!),
enabled: !!tenantId,
});
const defaults: FormValues = useMemo(
@ -259,8 +268,21 @@ export function OperationalDocumentPage({
const confirmM = useMutation({
mutationFn: (id: string) => accountingApi.ops.confirmDocument(tenantId!, id),
onSuccess: async () => {
toast.success("تأیید شد");
onSuccess: async (doc) => {
const meta =
doc && typeof doc === "object" && "meta_json" in doc && typeof doc.meta_json === "string"
? (() => {
try {
return JSON.parse(doc.meta_json as string) as {
accounting_voucher?: { voucher_number?: string };
};
} catch {
return null;
}
})()
: null;
const vn = meta?.accounting_voucher?.voucher_number;
toast.success(vn ? `تأیید شد و سند حسابداری ${vn} صادر شد` : "تأیید شد");
setConfirmId(null);
await invalidate();
},
@ -274,13 +296,20 @@ export function OperationalDocumentPage({
const profileId = postForm.getValues("profile_id") || undefined;
if (enableAccountingPost === "sales") {
return accountingApi.salesAccounting.preview(tenantId!, {
document_type: "invoice",
document_type: docType === "return" ? "return" : "invoice",
total_amount: amount,
discount_amount: postForm.getValues("discount_amount") || "0",
tax_amount: postForm.getValues("tax_amount") || "0",
profile_id: profileId,
});
}
if (enableAccountingPost === "purchase_invoice" || enableAccountingPost === "purchase_return") {
return accountingApi.purchaseInventory.previewPurchaseInvoice(tenantId!, {
source_document_id: String(postRow.id),
amount,
profile_id: profileId,
});
}
return accountingApi.purchaseInventory.previewGoodsReceipt(tenantId!, {
source_document_id: String(postRow.id),
amount,
@ -301,7 +330,7 @@ export function OperationalDocumentPage({
const profileId = postForm.getValues("profile_id") || undefined;
if (enableAccountingPost === "sales") {
return accountingApi.salesAccounting.post(tenantId!, {
document_type: "invoice",
document_type: docType === "return" ? "return" : "invoice",
source_document_id: String(postRow.id),
total_amount: amount,
discount_amount: postForm.getValues("discount_amount") || "0",
@ -309,6 +338,20 @@ export function OperationalDocumentPage({
profile_id: profileId,
});
}
if (enableAccountingPost === "purchase_return") {
return accountingApi.purchaseInventory.postPurchaseReturn(tenantId!, {
source_document_id: String(postRow.id),
amount,
profile_id: profileId,
});
}
if (enableAccountingPost === "purchase_invoice") {
return accountingApi.purchaseInventory.postPurchaseInvoice(tenantId!, {
source_document_id: String(postRow.id),
amount,
profile_id: profileId,
});
}
return accountingApi.purchaseInventory.postGoodsReceipt(tenantId!, {
source_document_id: String(postRow.id),
amount,
@ -716,7 +759,27 @@ export function OperationalDocumentPage({
onClose={() => setConfirmId(null)}
onConfirm={() => confirmId && confirmM.mutate(confirmId)}
title="تأیید سند؟"
description="پس از تأیید، وضعیت سند به confirmed تغییر می‌کند."
description={
(() => {
const auto = policyQ.data?.auto_post;
const key =
module === "sales" && docType === "invoice"
? "sales_invoice"
: module === "sales" && docType === "return"
? "sales_return"
: module === "purchase" && (docType === "goods_receipt" || docType === "goods-receipts")
? "purchase_goods_receipt"
: module === "purchase" && docType === "invoice"
? "purchase_invoice"
: module === "purchase" && docType === "return"
? "purchase_return"
: null;
if (key && auto?.[key]) {
return "با تأیید، سند عملیاتی تأیید و سند حسابداری خودکار از طریق Posting Engine صادر می‌شود.";
}
return "پس از تأیید، وضعیت سند به confirmed تغییر می‌کند. ثبت دفتر کل جداگانه از «ثبت حسابداری» انجام می‌شود.";
})()
}
confirmLabel="تأیید"
loading={confirmM.isPending}
/>

View File

@ -254,6 +254,41 @@ export const accountingApi = {
method: "POST",
body: JSON.stringify({ document_type: documentType }),
}),
getPostingPolicy: (tenantId: string) =>
request<{
auto_post: Record<string, boolean>;
require_balanced: boolean;
require_source_reference: boolean;
lock_after_post: boolean;
sequential_numbers: boolean;
idempotent_source: boolean;
posting_mode: string;
}>("/api/v1/setup/posting-policy", { tenantId }),
updatePostingPolicy: (
tenantId: string,
body: {
auto_post?: Record<string, boolean>;
require_balanced?: boolean;
require_source_reference?: boolean;
lock_after_post?: boolean;
sequential_numbers?: boolean;
idempotent_source?: boolean;
posting_mode?: string;
}
) =>
request<{
auto_post: Record<string, boolean>;
require_balanced: boolean;
require_source_reference: boolean;
lock_after_post: boolean;
sequential_numbers: boolean;
idempotent_source: boolean;
posting_mode: string;
}>("/api/v1/setup/posting-policy", {
tenantId,
method: "PUT",
body: JSON.stringify(body),
}),
},
charts: {
@ -818,6 +853,7 @@ export const accountingApi = {
amount: string;
payment_method: string;
description: string | null;
voucher_id?: string | null;
}[]
>("/api/v1/treasury/receipts", { tenantId }),
createReceipt: (
@ -829,14 +865,18 @@ export const accountingApi = {
payment_method?: string;
cash_box_id?: string;
bank_account_id?: string;
counter_account_id?: string;
description?: string;
}
) =>
request<{ id: string }>("/api/v1/treasury/receipts", {
request<{ id: string; receipt_number?: string; voucher_id?: string | null; voucher_number?: string | null }>(
"/api/v1/treasury/receipts",
{
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
}
),
listPayments: (tenantId: string) =>
request<
{
@ -846,6 +886,7 @@ export const accountingApi = {
amount: string;
payment_method: string;
description: string | null;
voucher_id?: string | null;
}[]
>("/api/v1/treasury/payments", { tenantId }),
createPayment: (
@ -857,14 +898,18 @@ export const accountingApi = {
payment_method?: string;
cash_box_id?: string;
bank_account_id?: string;
counter_account_id?: string;
description?: string;
}
) =>
request<{ id: string }>("/api/v1/treasury/payments", {
request<{ id: string; payment_number?: string; voucher_id?: string | null; voucher_number?: string | null }>(
"/api/v1/treasury/payments",
{
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
}
),
listReconciliations: (tenantId: string) =>
request<
{
@ -953,11 +998,14 @@ export const accountingApi = {
body: JSON.stringify(body),
}),
confirmDocument: (tenantId: string, id: string) =>
request<{ id: string; status: string }>(`/api/v1/ops/documents/${id}/confirm`, {
request<{ id: string; status: string; meta_json?: string | null }>(
`/api/v1/ops/documents/${id}/confirm`,
{
tenantId,
method: "POST",
body: JSON.stringify({}),
}),
}
),
listItems: (tenantId: string) =>
request<
{
@ -1628,11 +1676,46 @@ export const accountingApi = {
tenantId: string,
body: { source_document_id: string; amount: string; profile_id?: string }
) =>
request<{ voucher_id: string; status: string }>("/api/v1/purchase-inventory/post/goods-receipt", {
request<{ voucher_id: string; voucher_number?: string; status: string }>(
"/api/v1/purchase-inventory/post/goods-receipt",
{
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
}
),
previewPurchaseInvoice: (
tenantId: string,
body: { source_document_id: string; amount: string; profile_id?: string }
) =>
request<{ is_balanced: boolean; total_debit: string; total_credit?: string }>(
"/api/v1/purchase-inventory/preview/purchase-invoice",
{ tenantId, method: "POST", body: JSON.stringify(body) }
),
postPurchaseInvoice: (
tenantId: string,
body: { source_document_id: string; amount: string; profile_id?: string }
) =>
request<{ voucher_id: string; voucher_number?: string; status: string }>(
"/api/v1/purchase-inventory/post/purchase-invoice",
{
tenantId,
method: "POST",
body: JSON.stringify(body),
}
),
postPurchaseReturn: (
tenantId: string,
body: { source_document_id: string; amount: string; profile_id?: string }
) =>
request<{ voucher_id: string; voucher_number?: string; status: string }>(
"/api/v1/purchase-inventory/post/purchase-return",
{
tenantId,
method: "POST",
body: JSON.stringify(body),
}
),
valuation: (
tenantId: string,
body: {

View File

@ -258,6 +258,7 @@ export const ACCOUNTING_NAV: AccountingNavGroup[] = [
{ href: "/accounting/fiscal", label: "دوره‌های مالی" },
{ href: "/accounting/currencies", label: "ارزها و نرخ‌ها" },
{ href: "/accounting/settings", label: "تنظیمات حسابداری" },
{ href: "/accounting/settings/auto-posting", label: "اسناد اتوماتیک" },
{ href: "/accounting/settings/tax", label: "تنظیمات مالیاتی" },
{ href: "/accounting/settings/general", label: "تنظیمات عمومی" },
{ href: "/dashboard/settings", label: "کاربران و دسترسی" },

View File

@ -0,0 +1,43 @@
/** Live voucher line balance helpers — Rial integers, zero tolerance. */
import { toRialInteger } from "@/lib/utils";
export type BalanceLine = { debit?: string; credit?: string };
export function sumVoucherLines(lines: BalanceLine[] | undefined) {
let debit = 0;
let credit = 0;
(lines ?? []).forEach((line, index) => {
const d = Number(toRialInteger(line.debit) || "0");
const c = Number(toRialInteger(line.credit) || "0");
debit += Number.isFinite(d) ? d : 0;
credit += Number.isFinite(c) ? c : 0;
void index;
});
const difference = debit - credit;
return {
debit,
credit,
difference,
isBalanced: difference === 0 && (lines?.length ?? 0) >= 2,
};
}
export function unbalancedMessage(debit: number, credit: number): string {
const diff = Math.abs(debit - credit);
if (debit > credit) {
return `سند نامتوازن است: جمع بدهکار ${debit.toLocaleString("fa-IR")} و بستانکار ${credit.toLocaleString("fa-IR")} — بدهکار ${diff.toLocaleString("fa-IR")} ریال بیشتر است.`;
}
return `سند نامتوازن است: جمع بدهکار ${debit.toLocaleString("fa-IR")} و بستانکار ${credit.toLocaleString("fa-IR")} — بستانکار ${diff.toLocaleString("fa-IR")} ریال بیشتر است.`;
}
export function lineBothSidesError(debit: string, credit: string): string | null {
const d = Number(toRialInteger(debit) || "0");
const c = Number(toRialInteger(credit) || "0");
if (d > 0 && c > 0) {
return "در یک ردیف نمی‌توان همزمان بدهکار و بستانکار داشت";
}
if (d === 0 && c === 0) {
return "حداقل یکی از بدهکار یا بستانکار باید مقدار داشته باشد";
}
return null;
}