diff --git a/alembic/versions/20260823_native_voucher_engine_phase17.py b/alembic/versions/20260823_native_voucher_engine_phase17.py
new file mode 100644
index 0000000..e40d7f3
--- /dev/null
+++ b/alembic/versions/20260823_native_voucher_engine_phase17.py
@@ -0,0 +1,79 @@
+"""Phase 17 Common Native Tally Voucher Engine.
+
+Revision ID: 20260823_native_voucher_engine_p17
+Revises: 20260823_chart_of_accounts_p16
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+revision = "20260823_native_voucher_engine_p17"
+down_revision = "20260823_chart_of_accounts_p16"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.create_table(
+ "accounting_voucher_drafts",
+ sa.Column("id", sa.Integer(), primary_key=True),
+ sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("tally_guid", sa.String(120), nullable=False),
+ sa.Column("company_name", sa.String(255), nullable=False, server_default=""),
+ sa.Column("voucher_type", sa.String(30), nullable=False),
+ sa.Column("business_nature", sa.String(60), nullable=False, server_default=""),
+ sa.Column("voucher_date", sa.String(10), nullable=False),
+ sa.Column("reference", sa.String(180), nullable=False, server_default=""),
+ sa.Column("narration", sa.Text(), nullable=False, server_default=""),
+ sa.Column("source_type", sa.String(60), nullable=False, server_default="manual"),
+ sa.Column("source_record_type", sa.String(80), nullable=False, server_default=""),
+ sa.Column("source_record_id", sa.String(120), nullable=False, server_default=""),
+ sa.Column("source_evidence_json", sa.Text(), nullable=False, server_default="{}"),
+ sa.Column("audit_area", sa.String(120), nullable=False, server_default=""),
+ sa.Column("audit_flags_json", sa.Text(), nullable=False, server_default="[]"),
+ sa.Column("total_debit", sa.Float(), nullable=False, server_default="0"),
+ sa.Column("total_credit", sa.Float(), nullable=False, server_default="0"),
+ sa.Column("status", sa.String(30), nullable=False, server_default="draft"),
+ sa.Column("review_note", sa.Text(), nullable=False, server_default=""),
+ sa.Column("reviewed_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
+ sa.Column("reviewed_at_utc", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("approved_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
+ sa.Column("approved_at_utc", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("workstation_agent_id", sa.Integer(), sa.ForeignKey("erp_workstation_agents.id", ondelete="SET NULL"), nullable=True),
+ sa.Column("preflight_job_id", sa.Integer(), sa.ForeignKey("erp_agent_jobs.id", ondelete="SET NULL"), nullable=True),
+ sa.Column("posting_job_id", sa.Integer(), sa.ForeignKey("erp_agent_jobs.id", ondelete="SET NULL"), nullable=True),
+ sa.Column("preflight_result_json", sa.Text(), nullable=False, server_default="{}"),
+ sa.Column("posting_result_json", sa.Text(), nullable=False, server_default="{}"),
+ sa.Column("tally_voucher_id", sa.String(120), nullable=False, server_default=""),
+ sa.Column("tally_voucher_number", sa.String(160), nullable=False, server_default=""),
+ sa.Column("last_error", sa.Text(), nullable=False, server_default=""),
+ sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
+ sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
+ sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
+ )
+ for name in (
+ "tenant_id", "client_id", "tally_guid", "voucher_type", "business_nature",
+ "voucher_date", "reference", "source_type", "audit_area", "status",
+ "workstation_agent_id", "preflight_job_id", "posting_job_id", "created_at_utc",
+ ):
+ op.create_index(f"ix_accounting_voucher_drafts_{name}", "accounting_voucher_drafts", [name])
+
+ op.create_table(
+ "accounting_voucher_lines",
+ sa.Column("id", sa.Integer(), primary_key=True),
+ sa.Column("draft_id", sa.Integer(), sa.ForeignKey("accounting_voucher_drafts.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("line_no", sa.Integer(), nullable=False, server_default="1"),
+ sa.Column("ledger_name", sa.String(255), nullable=False),
+ sa.Column("ledger_role_code", sa.String(50), nullable=False, server_default=""),
+ sa.Column("dr_cr", sa.String(2), nullable=False),
+ sa.Column("amount", sa.Float(), nullable=False, server_default="0"),
+ sa.Column("line_note", sa.Text(), nullable=False, server_default=""),
+ )
+ for name in ("draft_id", "ledger_name", "ledger_role_code"):
+ op.create_index(f"ix_accounting_voucher_lines_{name}", "accounting_voucher_lines", [name])
+
+
+def downgrade():
+ op.drop_table("accounting_voucher_lines")
+ op.drop_table("accounting_voucher_drafts")
diff --git a/app/modules/accounting/templates/accounting/tally.html b/app/modules/accounting/templates/accounting/tally.html
index 0bd8a04..906331f 100644
--- a/app/modules/accounting/templates/accounting/tally.html
+++ b/app/modules/accounting/templates/accounting/tally.html
@@ -20,6 +20,7 @@
diff --git a/app/modules/accounting/templates/accounting/voucher_engine.html b/app/modules/accounting/templates/accounting/voucher_engine.html
new file mode 100644
index 0000000..512e5e9
--- /dev/null
+++ b/app/modules/accounting/templates/accounting/voucher_engine.html
@@ -0,0 +1,339 @@
+{% extends "ui/templates/base/layout.html" %}
+{% block content %}
+
+
+
+
Accounting Engine · Phase 17
+
Common Native Tally Voucher Engine
+
+ Prepare one balanced accounting transaction and post it with the correct native Tally voucher type: Purchase, Sales, Receipt, Payment, Contra or Journal. Existing specialized accounting tools remain unchanged and can progressively feed this common engine.
+
+
+
+
+
+ {% if message %}
{{ message }}
{% endif %}
+ {% if error %}
{{ error }}
{% endif %}
+
+
+
+ {% if selected_client and selected_tally_guid %}
+
+ Native Tally voucher selection
+
+
Purchase Supplier bills, purchases, direct/indirect expenses and asset bills.
+
Sales Customer invoices, sales and billed direct/indirect income.
+
Receipt Money received into Bank/Cash from customers, income, loans or capital.
+
Payment Money paid from Bank/Cash to suppliers, expenses, assets or loans.
+
Contra Bank-to-bank, bank-to-cash and cash-to-bank transfers.
+
Journal Accruals, provisions, depreciation, reclassification and adjustments.
+
+
+
+
+
+
Create Voucher Draft
+
Every ledger must already exist in the Phase 16 Chart of Accounts. Debit and Credit must balance before the draft is accepted.
+
+
+
+
+ {% elif selected_client %}
+
+ Refresh the client's Chart of Accounts and select a mapped Tally company before creating vouchers.
+
+ {% endif %}
+
+ {% if selected_client %}
+
+
+
+
+
Voucher Review & Posting Queue
+
{{ total }} draft(s). Controlled flow: Draft → Reviewed → Approved → Tally Preflight → Post.
+
+
+
+
+ All statuses
+ {% for s in ['draft','reviewed','approved','preflight_queued','preflight_claimed','preflight_ready','preflight_failed','posting_queued','posting_claimed','posted','posting_failed','void'] %}
+ {{ s|replace('_',' ')|title }}
+ {% endfor %}
+
+ Filter
+
+
+
+
+
+ {% for draft in drafts %}
+
+
+
+
+ {{ draft.voucher_type }}
+ {{ draft.status|replace('_',' ')|title }}
+ Draft #{{ draft.id }}
+
+
{{ draft.company_name }} · {{ draft.voucher_date }}{% if draft.reference %} · Ref {{ draft.reference }}{% endif %}
+
Nature: {{ draft.business_nature|replace('_',' ')|title }} · Source: {{ draft.source_type }} · Audit use: {{ draft.audit_area or 'General Ledger' }}
+ {% if draft.narration %}
{{ draft.narration }}
{% endif %}
+
+
+
₹{{ '%.2f'|format(draft.total_debit) }}
+ {% if draft.tally_voucher_number %}
Tally voucher {{ draft.tally_voucher_number }}
{% endif %}
+
+
+
+
+
+ Ledger Role Dr/Cr Amount
+
+ {% for line in draft.lines %}
+
+ {{ line.ledger_name }}
+ {{ role_labels.get(line.ledger_role_code, line.ledger_role_code) }}
+ {{ line.dr_cr }}
+ ₹{{ '%.2f'|format(line.amount) }}
+
+ {% endfor %}
+
+
+
+
+ {% if draft.last_error %}{{ draft.last_error }}
{% endif %}
+ {% if draft.status == 'preflight_ready' %}Tally preflight completed. Ledger existence, balancing and duplicate checks completed.
{% endif %}
+
+
+ {% if draft.status == 'draft' %}
+
+
+
+ Mark Reviewed
+
+ {% endif %}
+
+ {% if draft.status == 'reviewed' %}
+
+
+ Approve
+
+ {% endif %}
+
+ {% if draft.status in ['approved','preflight_failed'] %}
+
+
+
+ Select workstation
+ {% for ws in workstations %}{{ ws.machine_name }} · Agent {{ ws.agent_version or '-' }} {% endfor %}
+
+ Run Tally Preflight
+
+ {% endif %}
+
+ {% if draft.status == 'preflight_ready' %}
+
+
+ Post {{ draft.voucher_type }} to Tally
+
+ {% endif %}
+
+ {% if draft.status not in ['posted','void','preflight_queued','preflight_claimed','posting_queued','posting_claimed'] %}
+
+
+ Void Draft
+
+ {% endif %}
+
+
+ {% else %}
+
No voucher drafts for this client yet.
+ {% endfor %}
+
+
+ {% if pages > 1 %}
+
+
Page {{ page }} of {{ pages }}
+
+ {% if page > 1 %}
Previous {% endif %}
+ {% if page < pages %}
Next {% endif %}
+
+
+ {% endif %}
+
+ {% endif %}
+
+
+{% if selected_client and selected_tally_guid %}
+
+{% endif %}
+{% endblock %}
diff --git a/app/modules/accounting/voucher_engine_models.py b/app/modules/accounting/voucher_engine_models.py
new file mode 100644
index 0000000..a61ad46
--- /dev/null
+++ b/app/modules/accounting/voucher_engine_models.py
@@ -0,0 +1,81 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+
+from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from app.core.db.common import CommonBase
+
+
+def _utcnow():
+ return datetime.now(timezone.utc)
+
+
+class AccountingVoucherDraft(CommonBase):
+ __tablename__ = "accounting_voucher_drafts"
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+ tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
+ client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True)
+
+ tally_guid: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
+ company_name: Mapped[str] = mapped_column(String(255), nullable=False, default="")
+ voucher_type: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
+ business_nature: Mapped[str] = mapped_column(String(60), nullable=False, default="", index=True)
+ voucher_date: Mapped[str] = mapped_column(String(10), nullable=False, index=True)
+ reference: Mapped[str] = mapped_column(String(180), nullable=False, default="", index=True)
+ narration: Mapped[str] = mapped_column(Text, nullable=False, default="")
+
+ source_type: Mapped[str] = mapped_column(String(60), nullable=False, default="manual", index=True)
+ source_record_type: Mapped[str] = mapped_column(String(80), nullable=False, default="")
+ source_record_id: Mapped[str] = mapped_column(String(120), nullable=False, default="")
+ source_evidence_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
+
+ audit_area: Mapped[str] = mapped_column(String(120), nullable=False, default="", index=True)
+ audit_flags_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
+
+ total_debit: Mapped[float] = mapped_column(Float, nullable=False, default=0)
+ total_credit: Mapped[float] = mapped_column(Float, nullable=False, default=0)
+
+ status: Mapped[str] = mapped_column(String(30), nullable=False, default="draft", index=True)
+ review_note: Mapped[str] = mapped_column(Text, nullable=False, default="")
+ reviewed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
+ reviewed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ approved_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
+ approved_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+
+ workstation_agent_id: Mapped[int | None] = mapped_column(ForeignKey("erp_workstation_agents.id", ondelete="SET NULL"), nullable=True, index=True)
+ preflight_job_id: Mapped[int | None] = mapped_column(ForeignKey("erp_agent_jobs.id", ondelete="SET NULL"), nullable=True, index=True)
+ posting_job_id: Mapped[int | None] = mapped_column(ForeignKey("erp_agent_jobs.id", ondelete="SET NULL"), nullable=True, index=True)
+ preflight_result_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
+ posting_result_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
+ tally_voucher_id: Mapped[str] = mapped_column(String(120), nullable=False, default="")
+ tally_voucher_number: Mapped[str] = mapped_column(String(160), nullable=False, default="")
+ last_error: Mapped[str] = mapped_column(Text, nullable=False, default="")
+
+ created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
+ created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=_utcnow, index=True)
+ updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=_utcnow)
+
+ lines: Mapped[list["AccountingVoucherLine"]] = relationship(
+ "AccountingVoucherLine",
+ back_populates="draft",
+ cascade="all, delete-orphan",
+ order_by="AccountingVoucherLine.line_no",
+ )
+
+
+class AccountingVoucherLine(CommonBase):
+ __tablename__ = "accounting_voucher_lines"
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+ draft_id: Mapped[int] = mapped_column(ForeignKey("accounting_voucher_drafts.id", ondelete="CASCADE"), nullable=False, index=True)
+ line_no: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
+ ledger_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
+ ledger_role_code: Mapped[str] = mapped_column(String(50), nullable=False, default="", index=True)
+ dr_cr: Mapped[str] = mapped_column(String(2), nullable=False)
+ amount: Mapped[float] = mapped_column(Float, nullable=False, default=0)
+ line_note: Mapped[str] = mapped_column(Text, nullable=False, default="")
+
+ draft: Mapped[AccountingVoucherDraft] = relationship("AccountingVoucherDraft", back_populates="lines")
diff --git a/app/modules/accounting/voucher_engine_service.py b/app/modules/accounting/voucher_engine_service.py
new file mode 100644
index 0000000..0748109
--- /dev/null
+++ b/app/modules/accounting/voucher_engine_service.py
@@ -0,0 +1,572 @@
+from __future__ import annotations
+
+import hashlib
+import json
+from datetime import datetime, timezone
+
+from sqlalchemy import func, select
+from sqlalchemy.orm import selectinload
+
+from app.modules.accounting.chart_models import AccountingChartLedger
+from app.modules.accounting.chart_service import effective_role
+from app.modules.accounting.voucher_engine_models import AccountingVoucherDraft, AccountingVoucherLine
+from app.modules.documents.agent_jobs import enqueue_agent_job
+from app.modules.documents.models import ERPAgentJob, ERPWorkstationAgent
+
+
+VOUCHER_TYPES = ("Purchase", "Sales", "Receipt", "Payment", "Contra", "Journal")
+BUSINESS_NATURES = (
+ ("PURCHASE", "Purchase"),
+ ("DIRECT_EXPENSE", "Direct Expense"),
+ ("INDIRECT_EXPENSE", "Indirect Expense"),
+ ("SALES", "Sales"),
+ ("DIRECT_INCOME", "Direct Income"),
+ ("INDIRECT_INCOME", "Indirect / Other Income"),
+ ("RECEIPT", "Receipt"),
+ ("PAYMENT", "Payment"),
+ ("CONTRA", "Contra"),
+ ("ADJUSTMENT", "Adjustment / Journal"),
+)
+
+PREFLIGHT_ACTION = "accounting_native_voucher_preflight"
+POST_ACTION = "accounting_post_native_voucher"
+
+
+def _utcnow():
+ return datetime.now(timezone.utc)
+
+
+def _s(value):
+ return str(value or "").strip()
+
+
+def _loads(value, default=None):
+ try:
+ return json.loads(value or "")
+ except Exception:
+ return default if default is not None else {}
+
+
+def _dumps(value):
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"), default=str)
+
+
+def suggested_voucher_type(business_nature: str) -> str:
+ nature = _s(business_nature).upper()
+ return {
+ "PURCHASE": "Purchase",
+ "DIRECT_EXPENSE": "Purchase",
+ "INDIRECT_EXPENSE": "Purchase",
+ "SALES": "Sales",
+ "DIRECT_INCOME": "Sales",
+ "INDIRECT_INCOME": "Sales",
+ "RECEIPT": "Receipt",
+ "PAYMENT": "Payment",
+ "CONTRA": "Contra",
+ "ADJUSTMENT": "Journal",
+ }.get(nature, "Journal")
+
+
+def chart_ledgers(db, *, tenant_id: int, client_id: int, tally_guid: str):
+ return list(
+ db.execute(
+ select(AccountingChartLedger)
+ .where(
+ AccountingChartLedger.tenant_id == int(tenant_id),
+ AccountingChartLedger.client_id == int(client_id),
+ AccountingChartLedger.tally_guid == _s(tally_guid),
+ )
+ .order_by(AccountingChartLedger.name)
+ ).scalars().all()
+ )
+
+
+def _ledger_map(db, *, tenant_id: int, client_id: int, tally_guid: str):
+ return {
+ row.name.casefold(): row
+ for row in chart_ledgers(
+ db,
+ tenant_id=tenant_id,
+ client_id=client_id,
+ tally_guid=tally_guid,
+ )
+ }
+
+
+def _normalize_lines(
+ db,
+ *,
+ tenant_id: int,
+ client_id: int,
+ tally_guid: str,
+ ledger_names,
+ dr_cr_values,
+ amounts,
+ notes,
+):
+ ledger_map = _ledger_map(
+ db,
+ tenant_id=tenant_id,
+ client_id=client_id,
+ tally_guid=tally_guid,
+ )
+ if not ledger_map:
+ raise ValueError(
+ "No Chart of Accounts is available for this mapped Tally company. "
+ "Refresh Chart of Accounts first."
+ )
+
+ ledger_names = list(ledger_names or [])
+ dr_cr_values = list(dr_cr_values or [])
+ amounts = list(amounts or [])
+ notes = list(notes or [])
+
+ result = []
+ for index, ledger_name in enumerate(ledger_names):
+ name = _s(ledger_name)
+ if not name:
+ continue
+
+ side = _s(dr_cr_values[index] if index < len(dr_cr_values) else "").upper()
+ if side not in {"DR", "CR"}:
+ raise ValueError(f"Line {index + 1}: debit/credit side is invalid.")
+
+ try:
+ amount = round(abs(float(amounts[index] if index < len(amounts) else 0)), 2)
+ except Exception:
+ amount = 0
+ if amount <= 0:
+ raise ValueError(f"Line {index + 1}: amount must be greater than zero.")
+
+ ledger = ledger_map.get(name.casefold())
+ if not ledger:
+ raise ValueError(
+ f"Line {index + 1}: ledger '{name}' is not in the synchronized Chart of Accounts."
+ )
+
+ result.append(
+ {
+ "line_no": len(result) + 1,
+ "ledger_name": ledger.name,
+ "ledger_role_code": effective_role(ledger),
+ "dr_cr": side,
+ "amount": amount,
+ "line_note": _s(notes[index] if index < len(notes) else ""),
+ }
+ )
+
+ if len(result) < 2:
+ raise ValueError("At least two voucher lines are required.")
+
+ total_dr = round(sum(row["amount"] for row in result if row["dr_cr"] == "DR"), 2)
+ total_cr = round(sum(row["amount"] for row in result if row["dr_cr"] == "CR"), 2)
+
+ if total_dr <= 0 or total_cr <= 0:
+ raise ValueError("The voucher must contain at least one Debit and one Credit line.")
+ if abs(total_dr - total_cr) > 0.01:
+ raise ValueError(
+ f"Voucher is not balanced. Debit ₹{total_dr:.2f} != Credit ₹{total_cr:.2f}."
+ )
+
+ return result, total_dr, total_cr
+
+
+def _validate_voucher_semantics(voucher_type: str, rows: list[dict]):
+ vtype = _s(voucher_type).title()
+ if vtype not in VOUCHER_TYPES:
+ raise ValueError("Voucher type must be Purchase, Sales, Receipt, Payment, Contra or Journal.")
+
+ debit_roles = {row["ledger_role_code"] for row in rows if row["dr_cr"] == "DR"}
+ credit_roles = {row["ledger_role_code"] for row in rows if row["dr_cr"] == "CR"}
+ all_roles = debit_roles | credit_roles
+ bank_cash = {"BANK", "CASH"}
+
+ if vtype == "Receipt" and not (debit_roles & bank_cash):
+ raise ValueError("Receipt voucher requires Bank/Cash on the Debit side.")
+
+ if vtype == "Payment" and not (credit_roles & bank_cash):
+ raise ValueError("Payment voucher requires Bank/Cash on the Credit side.")
+
+ if vtype == "Contra":
+ if not all_roles or not all_roles.issubset(bank_cash):
+ raise ValueError("Contra voucher may contain only Bank/Cash ledgers.")
+ if not (debit_roles & bank_cash) or not (credit_roles & bank_cash):
+ raise ValueError("Contra voucher requires Bank/Cash on both Debit and Credit sides.")
+
+ if vtype == "Purchase":
+ allowed = {
+ "PURCHASE",
+ "DIRECT_EXPENSE",
+ "INDIRECT_EXPENSE",
+ "FIXED_ASSET",
+ "CURRENT_ASSET",
+ "INVENTORY",
+ "GST_INPUT",
+ }
+ if not (debit_roles & allowed):
+ raise ValueError(
+ "Purchase voucher requires a Purchase/Expense/Asset/Input-tax ledger on the Debit side."
+ )
+
+ if vtype == "Sales":
+ allowed = {"SALES", "DIRECT_INCOME", "OTHER_INCOME", "GST_OUTPUT", "FREIGHT_CHARGE"}
+ if not (credit_roles & allowed):
+ raise ValueError(
+ "Sales voucher requires a Sales/Income/Output-tax/charge ledger on the Credit side."
+ )
+
+
+def _audit_projection(voucher_type: str, rows: list[dict]):
+ roles = {row["ledger_role_code"] for row in rows}
+ areas = []
+ flags = []
+
+ if roles & {"SALES", "DIRECT_INCOME", "OTHER_INCOME"}:
+ areas.append("Revenue & Other Income")
+ if roles & {"PURCHASE", "DIRECT_EXPENSE", "INDIRECT_EXPENSE", "SUPPLIER"}:
+ areas.append("Purchases & Expenses")
+ if roles & {"BANK", "CASH"}:
+ areas.append("Cash & Bank")
+ if roles & {"FIXED_ASSET", "DEPRECIATION"}:
+ areas.append("Property, Plant & Equipment")
+ if roles & {"GST_INPUT", "GST_OUTPUT", "TAX_OTHER"}:
+ areas.append("GST & Taxes")
+ flags.append("tax-ledger-present")
+ if roles & {"CUSTOMER"}:
+ areas.append("Trade Receivables")
+ if roles & {"LOAN", "CAPITAL", "RESERVE"}:
+ areas.append("Equity & Borrowings")
+ if voucher_type == "Journal":
+ flags.append("journal-adjustment")
+
+ if not areas:
+ areas.append("General Ledger")
+
+ return " · ".join(dict.fromkeys(areas)), flags
+
+
+def create_draft(
+ db,
+ *,
+ tenant_id: int,
+ client_id: int,
+ tally_guid: str,
+ company_name: str,
+ voucher_type: str,
+ business_nature: str,
+ voucher_date: str,
+ reference: str,
+ narration: str,
+ source_type: str,
+ ledger_names,
+ dr_cr_values,
+ amounts,
+ notes,
+ user_id: int,
+):
+ vtype = _s(voucher_type).title()
+ rows, total_dr, total_cr = _normalize_lines(
+ db,
+ tenant_id=tenant_id,
+ client_id=client_id,
+ tally_guid=tally_guid,
+ ledger_names=ledger_names,
+ dr_cr_values=dr_cr_values,
+ amounts=amounts,
+ notes=notes,
+ )
+ _validate_voucher_semantics(vtype, rows)
+ audit_area, audit_flags = _audit_projection(vtype, rows)
+
+ draft = AccountingVoucherDraft(
+ tenant_id=int(tenant_id),
+ client_id=int(client_id),
+ tally_guid=_s(tally_guid),
+ company_name=_s(company_name),
+ voucher_type=vtype,
+ business_nature=_s(business_nature).upper(),
+ voucher_date=_s(voucher_date),
+ reference=_s(reference),
+ narration=_s(narration),
+ source_type=_s(source_type) or "manual",
+ audit_area=audit_area,
+ audit_flags_json=_dumps(audit_flags),
+ total_debit=total_dr,
+ total_credit=total_cr,
+ status="draft",
+ created_by_user_id=int(user_id),
+ updated_at_utc=_utcnow(),
+ )
+
+ for row in rows:
+ draft.lines.append(AccountingVoucherLine(**row))
+
+ db.add(draft)
+ db.commit()
+ db.refresh(draft)
+ return draft
+
+
+def get_draft(db, *, tenant_id: int, client_id: int, draft_id: int):
+ return db.execute(
+ select(AccountingVoucherDraft)
+ .options(selectinload(AccountingVoucherDraft.lines))
+ .where(
+ AccountingVoucherDraft.id == int(draft_id),
+ AccountingVoucherDraft.tenant_id == int(tenant_id),
+ AccountingVoucherDraft.client_id == int(client_id),
+ )
+ ).scalar_one_or_none()
+
+
+def list_drafts(db, *, tenant_id: int, client_id: int, status: str = "", page: int = 1, per_page: int = 20):
+ where = [
+ AccountingVoucherDraft.tenant_id == int(tenant_id),
+ AccountingVoucherDraft.client_id == int(client_id),
+ ]
+ if _s(status):
+ where.append(AccountingVoucherDraft.status == _s(status))
+
+ total = int(db.scalar(select(func.count(AccountingVoucherDraft.id)).where(*where)) or 0)
+ per_page = max(10, min(100, int(per_page or 20)))
+ pages = max(1, (total + per_page - 1) // per_page)
+ page = max(1, min(int(page or 1), pages))
+
+ rows = list(
+ db.execute(
+ select(AccountingVoucherDraft)
+ .options(selectinload(AccountingVoucherDraft.lines))
+ .where(*where)
+ .order_by(AccountingVoucherDraft.id.desc())
+ .offset((page - 1) * per_page)
+ .limit(per_page)
+ ).scalars().all()
+ )
+ return rows, total, page, pages
+
+
+def review_draft(db, *, draft: AccountingVoucherDraft, note: str, user_id: int):
+ if draft.status != "draft":
+ raise ValueError("Only Draft vouchers can be marked Reviewed.")
+ draft.status = "reviewed"
+ draft.review_note = _s(note)
+ draft.reviewed_by_user_id = int(user_id)
+ draft.reviewed_at_utc = _utcnow()
+ draft.updated_at_utc = _utcnow()
+ db.add(draft)
+ db.commit()
+ return draft
+
+
+def approve_draft(db, *, draft: AccountingVoucherDraft, user_id: int):
+ if draft.status != "reviewed":
+ raise ValueError("Voucher must be Reviewed before Approval.")
+ draft.status = "approved"
+ draft.approved_by_user_id = int(user_id)
+ draft.approved_at_utc = _utcnow()
+ draft.updated_at_utc = _utcnow()
+ db.add(draft)
+ db.commit()
+ return draft
+
+
+def void_draft(db, *, draft: AccountingVoucherDraft):
+ if draft.status == "posted":
+ raise ValueError("A posted voucher cannot be voided from the ERP draft queue.")
+ if draft.status in {"preflight_queued", "preflight_claimed", "posting_queued", "posting_claimed"}:
+ raise ValueError("An active Local Agent job exists for this voucher.")
+ draft.status = "void"
+ draft.updated_at_utc = _utcnow()
+ db.add(draft)
+ db.commit()
+ return draft
+
+
+def visible_workstations(db, *, tenant_id: int, branch_id: int | None = None):
+ stmt = select(ERPWorkstationAgent).where(
+ ERPWorkstationAgent.tenant_id == int(tenant_id),
+ ERPWorkstationAgent.is_active.is_(True),
+ ERPWorkstationAgent.tally_connected.is_(True),
+ )
+ if branch_id is not None:
+ stmt = stmt.where(ERPWorkstationAgent.branch_id == int(branch_id))
+
+ return list(
+ db.execute(
+ stmt.order_by(
+ ERPWorkstationAgent.last_seen_at_utc.desc(),
+ ERPWorkstationAgent.id.desc(),
+ )
+ ).scalars().all()
+ )
+
+
+def _idempotency(draft: AccountingVoucherDraft):
+ raw = "|".join(
+ [
+ str(draft.tenant_id),
+ str(draft.client_id),
+ str(draft.id),
+ draft.tally_guid,
+ draft.voucher_type,
+ draft.voucher_date,
+ draft.reference,
+ f"{draft.total_debit:.2f}",
+ ]
+ )
+ return hashlib.sha256(raw.encode("utf-8", "ignore")).hexdigest()[:32]
+
+
+def _payload(draft: AccountingVoucherDraft):
+ return {
+ "client_id": int(draft.client_id),
+ "tenant_id": int(draft.tenant_id),
+ "tally_guid": draft.tally_guid,
+ "company_name": draft.company_name,
+ "voucher_type": draft.voucher_type,
+ "voucher_date": draft.voucher_date,
+ "reference": draft.reference,
+ "narration": draft.narration,
+ "business_nature": draft.business_nature,
+ "erp_voucher_draft_id": int(draft.id),
+ "total_amount": float(draft.total_debit),
+ "lines": [
+ {
+ "ledger_name": line.ledger_name,
+ "ledger_role_code": line.ledger_role_code,
+ "dr_cr": line.dr_cr,
+ "amount": float(line.amount),
+ "line_note": line.line_note,
+ }
+ for line in draft.lines
+ ],
+ }
+
+
+def queue_preflight(db, *, draft: AccountingVoucherDraft, workstation_id: int, user_id: int):
+ if draft.status != "approved":
+ raise ValueError("Voucher must be Approved before Local Agent preflight.")
+
+ workstation = db.get(ERPWorkstationAgent, int(workstation_id))
+ if (
+ not workstation
+ or not workstation.is_active
+ or int(workstation.tenant_id) != int(draft.tenant_id)
+ or not workstation.tally_connected
+ ):
+ raise ValueError("Selected workstation is unavailable or Tally is not connected.")
+
+ job = enqueue_agent_job(
+ db,
+ workstation_agent_id=workstation.id,
+ action=PREFLIGHT_ACTION,
+ payload=_payload(draft),
+ idempotency_key=f"native-voucher:{_idempotency(draft)}:preflight:{workstation.id}",
+ priority=9,
+ max_attempts=2,
+ created_by_user_id=user_id,
+ )
+
+ draft.workstation_agent_id = workstation.id
+ draft.preflight_job_id = job.id
+ draft.preflight_result_json = "{}"
+ draft.last_error = ""
+ draft.status = "preflight_queued"
+ draft.updated_at_utc = _utcnow()
+ db.add(draft)
+ db.commit()
+ return draft
+
+
+def queue_post(db, *, draft: AccountingVoucherDraft, user_id: int):
+ sync_draft(db, draft)
+ if draft.status != "preflight_ready":
+ raise ValueError("Successful workstation/Tally preflight is required before posting.")
+
+ preflight = _loads(draft.preflight_result_json, {})
+ if preflight.get("duplicate_candidates"):
+ raise ValueError("Possible duplicate voucher exists in Tally. Posting is blocked.")
+
+ workstation = db.get(ERPWorkstationAgent, int(draft.workstation_agent_id or 0))
+ if not workstation or not workstation.is_active or not workstation.tally_connected:
+ raise ValueError("The preflight workstation is no longer available or connected to Tally.")
+
+ job = enqueue_agent_job(
+ db,
+ workstation_agent_id=workstation.id,
+ action=POST_ACTION,
+ payload=_payload(draft),
+ idempotency_key=f"native-voucher:{_idempotency(draft)}:post",
+ priority=10,
+ max_attempts=1,
+ created_by_user_id=user_id,
+ )
+
+ draft.posting_job_id = job.id
+ draft.status = "posting_queued"
+ draft.last_error = ""
+ draft.updated_at_utc = _utcnow()
+ db.add(draft)
+ db.commit()
+ return draft
+
+
+def sync_draft(db, draft: AccountingVoucherDraft):
+ changed = False
+
+ if draft.preflight_job_id and draft.status.startswith("preflight"):
+ job = db.get(ERPAgentJob, int(draft.preflight_job_id))
+ if job:
+ if job.status == "claimed" and draft.status != "preflight_claimed":
+ draft.status = "preflight_claimed"
+ changed = True
+ elif job.status == "succeeded":
+ result = _loads(job.result_json, {})
+ draft.preflight_result_json = _dumps(result)
+ draft.company_name = _s(result.get("company_name")) or draft.company_name
+ draft.status = "preflight_ready"
+ draft.last_error = ""
+ changed = True
+ elif job.status in {"failed", "cancelled"}:
+ draft.status = "preflight_failed"
+ draft.last_error = _s(job.last_error) or f"Preflight job {job.status}."
+ changed = True
+
+ if draft.posting_job_id and draft.status.startswith("posting"):
+ job = db.get(ERPAgentJob, int(draft.posting_job_id))
+ if job:
+ if job.status == "claimed" and draft.status != "posting_claimed":
+ draft.status = "posting_claimed"
+ changed = True
+ elif job.status == "succeeded":
+ result = _loads(job.result_json, {})
+ tally = result.get("tally_result") or result
+ draft.posting_result_json = _dumps(result)
+ draft.tally_voucher_id = _s(
+ tally.get("last_voucher_id") or tally.get("voucher_id")
+ )[:120]
+ draft.tally_voucher_number = _s(
+ tally.get("voucher_number")
+ or tally.get("last_voucher_id")
+ or tally.get("voucher_id")
+ )[:160]
+ draft.status = "posted"
+ draft.last_error = ""
+ changed = True
+ elif job.status in {"failed", "cancelled"}:
+ draft.status = "posting_failed"
+ draft.last_error = _s(job.last_error) or f"Posting job {job.status}."
+ changed = True
+
+ if changed:
+ draft.updated_at_utc = _utcnow()
+ db.add(draft)
+ db.commit()
+
+ return draft
+
+
+def sync_drafts(db, drafts):
+ for draft in drafts:
+ if draft.status.startswith("preflight") or draft.status.startswith("posting"):
+ sync_draft(db, draft)
diff --git a/app/modules/accounting/voucher_engine_ui.py b/app/modules/accounting/voucher_engine_ui.py
new file mode 100644
index 0000000..3757598
--- /dev/null
+++ b/app/modules/accounting/voucher_engine_ui.py
@@ -0,0 +1,355 @@
+from __future__ import annotations
+
+from urllib.parse import urlencode
+
+from fastapi import APIRouter, Form, Request
+from fastapi.responses import RedirectResponse
+from sqlalchemy import select
+
+from app.core.db.common import CommonSessionLocal
+from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
+from app.core.templating import templates
+from app.modules.accounting.chart_models import AccountingChartLedger
+from app.modules.accounting.chart_service import ROLE_LABELS
+from app.modules.accounting.ui import _find_visible_client, _require_partner, _visible_clients
+from app.modules.accounting.voucher_engine_service import (
+ BUSINESS_NATURES,
+ VOUCHER_TYPES,
+ approve_draft,
+ chart_ledgers,
+ create_draft,
+ get_draft,
+ list_drafts,
+ queue_post,
+ queue_preflight,
+ review_draft,
+ sync_drafts,
+ visible_workstations,
+ void_draft,
+)
+from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
+
+router = APIRouter(prefix="/tools/accounting/voucher-engine", tags=["accounting-native-voucher-engine-ui"])
+
+
+def _go(client_id=0, message="", error="", **extra):
+ params = {}
+ if client_id:
+ params["client_id"] = client_id
+ if message:
+ params["message"] = message[:350]
+ if error:
+ params["error"] = error[:350]
+ for key, value in extra.items():
+ if value not in ("", None):
+ params[key] = value
+ return RedirectResponse(
+ "/tools/accounting/voucher-engine" + ("?" + urlencode(params) if params else ""),
+ status_code=303,
+ )
+
+
+def _company_options(db, tenant_id, client_id):
+ rows = list(
+ db.execute(
+ select(AccountingChartLedger.tally_guid, AccountingChartLedger.company_name)
+ .where(
+ AccountingChartLedger.tenant_id == int(tenant_id),
+ AccountingChartLedger.client_id == int(client_id),
+ )
+ .distinct()
+ .order_by(AccountingChartLedger.company_name)
+ ).all()
+ )
+ return [{"guid": guid, "name": name or guid} for guid, name in rows if guid]
+
+
+def _company_name(options, tally_guid):
+ return next((row["name"] for row in options if row["guid"] == tally_guid), "")
+
+
+@router.get("")
+def page(
+ request: Request,
+ client_id: int | None = None,
+ tally_guid: str = "",
+ status: str = "",
+ page: int = 1,
+ per_page: int = 20,
+ message: str = "",
+ error: str = "",
+):
+ db = CommonSessionLocal()
+ try:
+ user, denied = _require_partner(request, db, "accounting.learning.view")
+ if denied:
+ return denied
+
+ clients, scope = _visible_clients(db, request, user)
+ selected = next((c for c in clients if client_id and int(c.id) == int(client_id)), None)
+
+ company_options = []
+ ledgers = []
+ drafts = []
+ total = 0
+ pages = 1
+ workstations = []
+
+ if selected:
+ company_options = _company_options(db, scope.tenant_id, selected.id)
+ if not tally_guid and len(company_options) == 1:
+ tally_guid = company_options[0]["guid"]
+ if tally_guid and tally_guid not in {row["guid"] for row in company_options}:
+ tally_guid = ""
+
+ if tally_guid:
+ ledgers = chart_ledgers(
+ db,
+ tenant_id=scope.tenant_id,
+ client_id=selected.id,
+ tally_guid=tally_guid,
+ )
+
+ drafts, total, page, pages = list_drafts(
+ db,
+ tenant_id=scope.tenant_id,
+ client_id=selected.id,
+ status=status,
+ page=page,
+ per_page=per_page,
+ )
+ sync_drafts(db, drafts)
+ drafts, total, page, pages = list_drafts(
+ db,
+ tenant_id=scope.tenant_id,
+ client_id=selected.id,
+ status=status,
+ page=page,
+ per_page=per_page,
+ )
+ workstations = visible_workstations(
+ db,
+ tenant_id=scope.tenant_id,
+ branch_id=scope.branch_id,
+ )
+
+ return templates.TemplateResponse(
+ "modules/accounting/templates/accounting/voucher_engine.html",
+ {
+ "request": request,
+ "current_user": user,
+ "current_user_roles": get_user_roles(db, user.id),
+ "current_user_permissions": get_user_permissions(db, user.id),
+ "csrf_token": get_or_create_csrf_token(request),
+ "title": "Accounting Voucher Engine",
+ "clients": clients,
+ "selected_client": selected,
+ "selected_tally_guid": tally_guid,
+ "company_options": company_options,
+ "ledgers": ledgers,
+ "role_labels": ROLE_LABELS,
+ "voucher_types": VOUCHER_TYPES,
+ "business_natures": BUSINESS_NATURES,
+ "drafts": drafts,
+ "total": total,
+ "page": page,
+ "pages": pages,
+ "per_page": per_page,
+ "status_filter": status,
+ "workstations": workstations,
+ "message": message,
+ "error": error,
+ },
+ )
+ finally:
+ db.close()
+
+
+@router.post("/draft")
+def create(
+ request: Request,
+ client_id: int = Form(...),
+ tally_guid: str = Form(...),
+ voucher_type: str = Form(...),
+ business_nature: str = Form(...),
+ voucher_date: str = Form(...),
+ reference: str = Form(""),
+ narration: str = Form(""),
+ source_type: str = Form("manual"),
+ ledger_name: list[str] = Form(...),
+ dr_cr: list[str] = Form(...),
+ amount: list[float] = Form(...),
+ line_note: list[str] = Form([]),
+ csrf_token: str = Form(...),
+):
+ validate_csrf(request, csrf_token)
+ db = CommonSessionLocal()
+ try:
+ user, denied = _require_partner(request, db, "accounting.learning.manage")
+ if denied:
+ return denied
+ client, _clients, scope = _find_visible_client(db, request, user, client_id)
+ if not client:
+ return _go(error="Client is not visible.")
+
+ options = _company_options(db, scope.tenant_id, client.id)
+ company_name = _company_name(options, tally_guid)
+ if not company_name:
+ raise ValueError("Chart of Accounts for the selected Tally company is not available.")
+
+ draft = create_draft(
+ db,
+ tenant_id=scope.tenant_id,
+ client_id=client.id,
+ tally_guid=tally_guid,
+ company_name=company_name,
+ voucher_type=voucher_type,
+ business_nature=business_nature,
+ voucher_date=voucher_date,
+ reference=reference,
+ narration=narration,
+ source_type=source_type,
+ ledger_names=ledger_name,
+ dr_cr_values=dr_cr,
+ amounts=amount,
+ notes=line_note,
+ user_id=user.id,
+ )
+ return _go(client.id, tally_guid=tally_guid, message=f"{draft.voucher_type} voucher draft #{draft.id} created.")
+ except Exception as exc:
+ db.rollback()
+ return _go(client_id, tally_guid=tally_guid, error=str(exc))
+ finally:
+ db.close()
+
+
+def _draft_for_action(db, request, user, client_id, draft_id):
+ client, _clients, scope = _find_visible_client(db, request, user, client_id)
+ if not client:
+ raise ValueError("Client is not visible.")
+ draft = get_draft(
+ db,
+ tenant_id=scope.tenant_id,
+ client_id=client.id,
+ draft_id=draft_id,
+ )
+ if not draft:
+ raise ValueError("Voucher draft was not found.")
+ return client, scope, draft
+
+
+@router.post("/draft/{draft_id}/review")
+def review(
+ request: Request,
+ draft_id: int,
+ client_id: int = Form(...),
+ note: str = Form(""),
+ csrf_token: str = Form(...),
+):
+ validate_csrf(request, csrf_token)
+ db = CommonSessionLocal()
+ try:
+ user, denied = _require_partner(request, db, "accounting.learning.manage")
+ if denied:
+ return denied
+ client, scope, draft = _draft_for_action(db, request, user, client_id, draft_id)
+ review_draft(db, draft=draft, note=note, user_id=user.id)
+ return _go(client.id, message=f"Voucher draft #{draft.id} reviewed.")
+ except Exception as exc:
+ db.rollback()
+ return _go(client_id, error=str(exc))
+ finally:
+ db.close()
+
+
+@router.post("/draft/{draft_id}/approve")
+def approve(
+ request: Request,
+ draft_id: int,
+ client_id: int = Form(...),
+ csrf_token: str = Form(...),
+):
+ validate_csrf(request, csrf_token)
+ db = CommonSessionLocal()
+ try:
+ user, denied = _require_partner(request, db, "accounting.learning.manage")
+ if denied:
+ return denied
+ client, scope, draft = _draft_for_action(db, request, user, client_id, draft_id)
+ approve_draft(db, draft=draft, user_id=user.id)
+ return _go(client.id, message=f"Voucher draft #{draft.id} approved for Tally preflight.")
+ except Exception as exc:
+ db.rollback()
+ return _go(client_id, error=str(exc))
+ finally:
+ db.close()
+
+
+@router.post("/draft/{draft_id}/preflight")
+def preflight(
+ request: Request,
+ draft_id: int,
+ client_id: int = Form(...),
+ workstation_id: int = Form(...),
+ csrf_token: str = Form(...),
+):
+ validate_csrf(request, csrf_token)
+ db = CommonSessionLocal()
+ try:
+ user, denied = _require_partner(request, db, "accounting.learning.manage")
+ if denied:
+ return denied
+ client, scope, draft = _draft_for_action(db, request, user, client_id, draft_id)
+ queue_preflight(db, draft=draft, workstation_id=workstation_id, user_id=user.id)
+ return _go(client.id, message=f"Voucher draft #{draft.id} Tally preflight queued.")
+ except Exception as exc:
+ db.rollback()
+ return _go(client_id, error=str(exc))
+ finally:
+ db.close()
+
+
+@router.post("/draft/{draft_id}/post")
+def post(
+ request: Request,
+ draft_id: int,
+ client_id: int = Form(...),
+ csrf_token: str = Form(...),
+):
+ validate_csrf(request, csrf_token)
+ db = CommonSessionLocal()
+ try:
+ user, denied = _require_partner(request, db, "accounting.learning.manage")
+ if denied:
+ return denied
+ client, scope, draft = _draft_for_action(db, request, user, client_id, draft_id)
+ queue_post(db, draft=draft, user_id=user.id)
+ return _go(client.id, message=f"Voucher draft #{draft.id} controlled Tally posting queued.")
+ except Exception as exc:
+ db.rollback()
+ return _go(client_id, error=str(exc))
+ finally:
+ db.close()
+
+
+@router.post("/draft/{draft_id}/void")
+def void(
+ request: Request,
+ draft_id: int,
+ client_id: int = Form(...),
+ csrf_token: str = Form(...),
+):
+ validate_csrf(request, csrf_token)
+ db = CommonSessionLocal()
+ try:
+ user, denied = _require_partner(request, db, "accounting.learning.manage")
+ if denied:
+ return denied
+ client, scope, draft = _draft_for_action(db, request, user, client_id, draft_id)
+ void_draft(db, draft=draft)
+ return _go(client.id, message=f"Voucher draft #{draft.id} voided.")
+ except Exception as exc:
+ db.rollback()
+ return _go(client_id, error=str(exc))
+ finally:
+ db.close()
diff --git a/app/modules/documents/agent_package.py b/app/modules/documents/agent_package.py
index 26f6601..a8ffeb7 100644
--- a/app/modules/documents/agent_package.py
+++ b/app/modules/documents/agent_package.py
@@ -4,7 +4,7 @@ import io
from pathlib import Path
import zipfile
-ERP_LOCAL_AGENT_VERSION = "1.16.0"
+ERP_LOCAL_AGENT_VERSION = "1.17.0"
ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py
index ca33d77..be2362e 100644
--- a/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py
+++ b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py
@@ -1,2 +1,2 @@
-__version__ = "1.16.0"
+__version__ = "1.17.0"
AGENT_NAME = "ERP Local Agent"
diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py b/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py
index 08c5fbb..6340275 100644
--- a/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py
+++ b/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py
@@ -7,6 +7,7 @@ from typing import Any
from . import __version__
from .accounting_store import LocalAccountingStore
from .tally import TallyLiveConnector
+from .native_voucher_engine import NativeVoucherEngine
class AgentCommandProcessor:
@@ -49,6 +50,10 @@ class AgentCommandProcessor:
result = self._bank_posting_preflight(payload)
elif action == "accounting_post_bank_voucher":
result = self._post_bank_voucher(payload)
+ elif action == "accounting_native_voucher_preflight":
+ result = self._native_voucher_preflight(payload)
+ elif action == "accounting_post_native_voucher":
+ result = self._post_native_voucher(payload)
elif action == "accounting_depreciation_preview":
result = self._depreciation_preview(payload)
elif action == "accounting_calculate_it_depreciation":
@@ -78,7 +83,7 @@ class AgentCommandProcessor:
return {
"name": "ERP Local Agent", "version": __version__,
"tally_capability": True, "accounting_act_capability": True,
- "tally_mapping_capability": True, "tally_master_sync_capability": True, "chart_of_accounts_capability": True,
+ "tally_mapping_capability": True, "tally_master_sync_capability": True, "chart_of_accounts_capability": True, "native_voucher_engine_capability": True,
"tally_transaction_sync_capability": True,
"historical_learning_read_capability": True,
"purchase_posting_preflight_capability": True,
@@ -555,6 +560,73 @@ class AgentCommandProcessor:
"agent": self._agent_info(),
}
+
+ def _native_voucher_preflight(self, payload: dict[str, Any]) -> dict[str, Any]:
+ company, company_name = self._resolve_open_company(payload)
+ engine = NativeVoucherEngine(self.tally)
+ result = engine.preflight(
+ company_name,
+ voucher_type=str(payload.get("voucher_type") or ""),
+ voucher_date=str(payload.get("voucher_date") or ""),
+ reference=str(payload.get("reference") or ""),
+ total_amount=float(payload.get("total_amount") or 0),
+ lines=list(payload.get("lines") or []),
+ )
+ return {
+ **result,
+ "company_name": company_name,
+ "company_guid": str(company.get("guid") or ""),
+ "agent": self._agent_info(),
+ }
+
+ def _post_native_voucher(self, payload: dict[str, Any]) -> dict[str, Any]:
+ company, company_name = self._resolve_open_company(payload)
+ engine = NativeVoucherEngine(self.tally)
+
+ preflight = engine.preflight(
+ company_name,
+ voucher_type=str(payload.get("voucher_type") or ""),
+ voucher_date=str(payload.get("voucher_date") or ""),
+ reference=str(payload.get("reference") or ""),
+ total_amount=float(payload.get("total_amount") or 0),
+ lines=list(payload.get("lines") or []),
+ )
+ if preflight.get("duplicate_candidates"):
+ raise ValueError("Possible duplicate native Tally voucher exists. Posting blocked.")
+
+ result = engine.post(
+ company_name,
+ voucher_type=str(payload.get("voucher_type") or ""),
+ voucher_date=str(payload.get("voucher_date") or ""),
+ reference=str(payload.get("reference") or ""),
+ narration=str(payload.get("narration") or ""),
+ lines=list(payload.get("lines") or []),
+ )
+ safe_result = {
+ key: value
+ for key, value in result.items()
+ if key not in {"raw_response", "request_xml"}
+ }
+ self.logger.warning(
+ "CONTROLLED NATIVE TALLY WRITEBACK posted client_id=%s company=%s "
+ "erp_voucher_draft_id=%s voucher_type=%s amount=%.2f tally_voucher=%s",
+ payload.get("client_id"),
+ company_name,
+ payload.get("erp_voucher_draft_id"),
+ str(payload.get("voucher_type") or ""),
+ float(payload.get("total_amount") or 0),
+ safe_result.get("last_voucher_id"),
+ )
+ return {
+ "posted": True,
+ "company_name": company_name,
+ "company_guid": str(company.get("guid") or ""),
+ "voucher_type": str(payload.get("voucher_type") or "").title(),
+ "reference": str(payload.get("reference") or ""),
+ "tally_result": safe_result,
+ "agent": self._agent_info(),
+ }
+
def _depreciation_preview(self, payload: dict[str, Any]) -> dict[str, Any]:
client_id = int(payload.get("client_id"))
tally_guid = str(payload.get("tally_guid") or "").strip()
diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/native_voucher_engine.py b/app/modules/documents/local_agent_runtime/erp_local_agent/native_voucher_engine.py
new file mode 100644
index 0000000..12db42a
--- /dev/null
+++ b/app/modules/documents/local_agent_runtime/erp_local_agent/native_voucher_engine.py
@@ -0,0 +1,305 @@
+from __future__ import annotations
+
+import re
+import xml.etree.ElementTree as ET
+
+
+VALID_VOUCHER_TYPES = {"Purchase", "Sales", "Receipt", "Payment", "Contra", "Journal"}
+
+
+def _tag(element):
+ return element.tag.split("}")[-1].upper()
+
+
+def _first_text(root, names):
+ wanted = {str(name).upper() for name in names}
+ for element in root.iter():
+ if _tag(element) in wanted:
+ text = (element.text or "").strip()
+ if text:
+ return text
+ return ""
+
+
+def _clean_xml_response(text):
+ value = str(text or "")
+ start = value.find("<")
+ return value[start:] if start >= 0 else value
+
+
+def _tally_date(value):
+ digits = re.sub(r"[^0-9]", "", str(value or ""))
+ if len(digits) == 8 and str(value).startswith(tuple(str(y) for y in range(1900, 2200))):
+ return digits
+ parts = re.split(r"[-/]", str(value or "").strip())
+ if len(parts) == 3:
+ if len(parts[0]) == 4:
+ return f"{parts[0]}{parts[1].zfill(2)}{parts[2].zfill(2)}"
+ return f"{parts[2]}{parts[1].zfill(2)}{parts[0].zfill(2)}"
+ raise ValueError("Voucher date must be a valid YYYY-MM-DD date.")
+
+
+class NativeVoucherEngine:
+ """Native Tally accounting-voucher writer layered over the existing connector.
+
+ It intentionally reuses the current Tally connector's XML transport,
+ ledger discovery and voucher export functions so no existing posting
+ behaviour is replaced.
+ """
+
+ def __init__(self, tally_connector):
+ self.tally = tally_connector
+
+ def _normalize_lines(self, company_name: str, lines: list[dict]):
+ ledger_rows = self.tally.purchase_posting_ledgers(company_name)
+ available = {
+ str(row.get("name") or "").strip().casefold(): row
+ for row in ledger_rows
+ if str(row.get("name") or "").strip()
+ }
+
+ normalized = []
+ total_dr = 0.0
+ total_cr = 0.0
+
+ for index, line in enumerate(lines or []):
+ ledger_name = str(line.get("ledger_name") or "").strip()
+ side = str(line.get("dr_cr") or "").strip().upper()
+ amount = round(abs(float(line.get("amount") or 0)), 2)
+
+ if not ledger_name:
+ raise ValueError(f"Voucher line {index + 1}: ledger is required.")
+ if ledger_name.casefold() not in available:
+ raise ValueError(
+ f"Voucher line {index + 1}: ledger '{ledger_name}' does not exist in the open Tally company."
+ )
+ if side not in {"DR", "CR"}:
+ raise ValueError(f"Voucher line {index + 1}: DR/CR is invalid.")
+ if amount <= 0:
+ raise ValueError(f"Voucher line {index + 1}: amount must be greater than zero.")
+
+ actual_name = str(
+ available[ledger_name.casefold()].get("name") or ledger_name
+ ).strip()
+
+ if side == "DR":
+ total_dr += amount
+ else:
+ total_cr += amount
+
+ normalized.append(
+ {
+ "ledger_name": actual_name,
+ "dr_cr": side,
+ "amount": amount,
+ }
+ )
+
+ total_dr = round(total_dr, 2)
+ total_cr = round(total_cr, 2)
+
+ if len(normalized) < 2:
+ raise ValueError("At least two voucher lines are required.")
+ if total_dr <= 0 or total_cr <= 0:
+ raise ValueError("Voucher requires at least one Debit and one Credit.")
+ if abs(total_dr - total_cr) > 0.01:
+ raise ValueError(
+ f"Native voucher is not balanced. Debit={total_dr:.2f}, Credit={total_cr:.2f}."
+ )
+
+ return normalized, total_dr, total_cr
+
+ def find_duplicates(
+ self,
+ company_name: str,
+ *,
+ voucher_type: str,
+ voucher_date: str,
+ reference: str,
+ amount: float,
+ ):
+ target_ref = re.sub(r"[^A-Z0-9]", "", str(reference or "").upper())
+ expected = round(abs(float(amount or 0)), 2)
+ matches = []
+
+ for voucher in self.tally.export_vouchers(company_name, voucher_date, voucher_date):
+ if str(voucher.get("voucher_type_name") or "").strip().casefold() != str(voucher_type or "").strip().casefold():
+ continue
+
+ ref = re.sub(r"[^A-Z0-9]", "", str(voucher.get("reference") or "").upper())
+ if target_ref and ref and ref != target_ref:
+ continue
+
+ values = [
+ abs(float(entry.get("amount") or 0))
+ for entry in (voucher.get("ledger_entries") or [])
+ ]
+ if expected and values and all(
+ abs(value - expected) > max(1.0, expected * 0.002)
+ for value in values
+ ):
+ continue
+
+ matches.append(
+ {
+ "guid": voucher.get("guid") or "",
+ "voucher_number": voucher.get("voucher_number") or "",
+ "reference": voucher.get("reference") or "",
+ "date": voucher.get("date") or "",
+ }
+ )
+
+ return matches[:20]
+
+ def preflight(
+ self,
+ company_name: str,
+ *,
+ voucher_type: str,
+ voucher_date: str,
+ reference: str,
+ total_amount: float,
+ lines: list[dict],
+ ):
+ vtype = str(voucher_type or "").strip().title()
+ if vtype not in VALID_VOUCHER_TYPES:
+ raise ValueError("Unsupported native Tally voucher type.")
+
+ normalized, total_dr, total_cr = self._normalize_lines(company_name, lines)
+ duplicates = self.find_duplicates(
+ company_name,
+ voucher_type=vtype,
+ voucher_date=voucher_date,
+ reference=reference,
+ amount=float(total_amount or total_dr),
+ )
+
+ return {
+ "voucher_type": vtype,
+ "line_count": len(normalized),
+ "total_debit": total_dr,
+ "total_credit": total_cr,
+ "duplicate_candidates": duplicates,
+ "validated_ledgers": [row["ledger_name"] for row in normalized],
+ }
+
+ def build_xml(
+ self,
+ company_name: str,
+ *,
+ voucher_type: str,
+ voucher_date: str,
+ reference: str,
+ narration: str,
+ lines: list[dict],
+ ):
+ vtype = str(voucher_type or "").strip().title()
+ if vtype not in VALID_VOUCHER_TYPES:
+ raise ValueError("Unsupported native Tally voucher type.")
+
+ company = str(company_name or "").strip()
+ if not company:
+ raise ValueError("Tally company name is required.")
+
+ normalized, total_dr, total_cr = self._normalize_lines(company, lines)
+
+ entries = []
+ for row in normalized:
+ if row["dr_cr"] == "DR":
+ deemed_positive = "Yes"
+ signed_amount = f"-{row['amount']:.2f}"
+ else:
+ deemed_positive = "No"
+ signed_amount = f"{row['amount']:.2f}"
+
+ entries.append(
+ ""
+ f"{self.tally._xml_escape(row['ledger_name'])} "
+ f"{deemed_positive} "
+ f"{signed_amount} "
+ " "
+ )
+
+ ref_xml = (
+ f"{self.tally._xml_escape(reference)} "
+ if str(reference or "").strip()
+ else ""
+ )
+
+ return f"""
+
+ 1
+ Import
+ Data
+ Vouchers
+
+
+
+
+ {self.tally._xml_escape(company)}
+
+
+
+
+
+ {_tally_date(voucher_date)}
+ {vtype}
+ Accounting Voucher View
+ {ref_xml}
+ {self.tally._xml_escape(narration)}
+ {''.join(entries)}
+
+
+
+
+ """
+
+ def post(
+ self,
+ company_name: str,
+ *,
+ voucher_type: str,
+ voucher_date: str,
+ reference: str,
+ narration: str,
+ lines: list[dict],
+ ):
+ request_xml = self.build_xml(
+ company_name,
+ voucher_type=voucher_type,
+ voucher_date=voucher_date,
+ reference=reference,
+ narration=narration,
+ lines=lines,
+ )
+ response_xml = self.tally._post_xml(request_xml)
+ root = ET.fromstring(_clean_xml_response(response_xml).encode("utf-8"))
+
+ def _int(tag):
+ try:
+ return int(float(_first_text(root, [tag]) or 0))
+ except Exception:
+ return 0
+
+ errors = _int("ERRORS")
+ line_error = _first_text(root, ["LINEERROR"])
+ created = _int("CREATED")
+ altered = _int("ALTERED")
+
+ if errors or line_error:
+ raise ValueError(
+ line_error or f"Tally reported {errors} native voucher error(s)."
+ )
+ if created < 1 and altered < 1:
+ raise ValueError("Tally did not confirm creation of the native voucher.")
+
+ return {
+ "created": created,
+ "altered": altered,
+ "errors": errors,
+ "last_voucher_id": str(
+ _first_text(root, ["LASTVCHID", "LASTMID", "LASTVOUCHERID"]) or ""
+ ),
+ "raw_response": response_xml,
+ "request_xml": request_xml,
+ }
diff --git a/app/ui/app.py b/app/ui/app.py
index 5a4d1cd..47e9d0e 100644
--- a/app/ui/app.py
+++ b/app/ui/app.py
@@ -51,6 +51,7 @@ from app.modules.accounting.sales_ui import router as accounting_sales_income_ui
from app.modules.accounting.sales_learning_ui import router as accounting_sales_learning_ui_router
from app.modules.accounting.sales_menu_ui import router as accounting_sales_menu_ui_router
from app.modules.accounting.chart_ui import router as accounting_chart_ui_router
+from app.modules.accounting.voucher_engine_ui import router as accounting_voucher_engine_ui_router
from app.modules.registrations.ui import router as registrations_ui_router
from app.modules.credential_vault.ui import router as credential_vault_ui_router
from app.modules.client_identity.ui import router as client_identity_ui_router
@@ -88,6 +89,7 @@ def mount_ui(app: FastAPI) -> None:
app.include_router(accounting_sales_learning_ui_router)
app.include_router(accounting_sales_menu_ui_router)
app.include_router(accounting_chart_ui_router)
+ app.include_router(accounting_voucher_engine_ui_router)
app.include_router(work_tracker_ui_router)
app.include_router(billing_ui_router)
app.include_router(platform_billing_ui_router)