diff --git a/app/core/startup.py b/app/core/startup.py
index e9aa61c..200d3f4 100644
--- a/app/core/startup.py
+++ b/app/core/startup.py
@@ -339,6 +339,7 @@ ROLE_PERMISSION_MAP = {
"accounting.tally.sync_masters",
"accounting.tally.sync_transactions",
"accounting.tally.depreciation_it",
+ "accounting.tally.writeback",
"accounting.act.initialize",
"clients.view.own_only",
"employees.dashboard.view",
@@ -975,3 +976,4 @@ def on_startup(app: FastAPI) -> None:
+
diff --git a/app/modules/accounting/act_store.py b/app/modules/accounting/act_store.py
index 8033957..f3c6984 100644
--- a/app/modules/accounting/act_store.py
+++ b/app/modules/accounting/act_store.py
@@ -8,7 +8,7 @@ import os
import sqlite3
from typing import Iterator, Sequence
-ACT_SCHEMA_VERSION = 5
+ACT_SCHEMA_VERSION = 6
class AccountingActStoreError(RuntimeError):
@@ -222,6 +222,19 @@ class AccountingActStore:
CREATE INDEX IF NOT EXISTS ix_it_dep_runs_company_fy ON it_depreciation_runs(tally_guid, fy_start, fy_end, id);
CREATE INDEX IF NOT EXISTS ix_it_dep_lines_run ON it_depreciation_run_lines(run_id, line_no);
+ CREATE TABLE IF NOT EXISTS tally_writeback_attempts (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, depreciation_run_id INTEGER NOT NULL,
+ tally_guid TEXT NOT NULL, company_name TEXT NOT NULL, voucher_type TEXT NOT NULL DEFAULT 'Journal',
+ voucher_date TEXT NOT NULL, reference TEXT NOT NULL DEFAULT '', debit_ledger TEXT NOT NULL,
+ credit_ledger TEXT NOT NULL, amount REAL NOT NULL DEFAULT 0, requested_by_user_id INTEGER,
+ started_at_utc TEXT NOT NULL, completed_at_utc TEXT, status TEXT NOT NULL,
+ tally_created INTEGER NOT NULL DEFAULT 0, tally_altered INTEGER NOT NULL DEFAULT 0,
+ tally_errors INTEGER NOT NULL DEFAULT 0, tally_last_voucher_id TEXT NOT NULL DEFAULT '',
+ request_xml TEXT NOT NULL DEFAULT '', response_xml TEXT NOT NULL DEFAULT '', error_message TEXT,
+ FOREIGN KEY(depreciation_run_id) REFERENCES it_depreciation_runs(id) ON DELETE CASCADE
+ );
+ CREATE INDEX IF NOT EXISTS ix_tally_writeback_run ON tally_writeback_attempts(depreciation_run_id, id);
+
CREATE INDEX IF NOT EXISTS ix_tally_groups_company ON tally_groups(tally_guid, name);
CREATE INDEX IF NOT EXISTS ix_tally_ledgers_company ON tally_ledgers(tally_guid, name);
CREATE INDEX IF NOT EXISTS ix_tally_voucher_types_company ON tally_voucher_types(tally_guid, name);
@@ -245,6 +258,15 @@ class AccountingActStore:
"""
)
self._ensure_mapping_columns(db)
+ dep_columns = {row["name"] for row in db.execute("PRAGMA table_info(it_depreciation_runs)").fetchall()}
+ for name, ddl in {
+ "approved_by_user_id": "INTEGER", "approved_at_utc": "TEXT", "approval_note": "TEXT NOT NULL DEFAULT ''",
+ "posted_by_user_id": "INTEGER", "posted_at_utc": "TEXT", "posted_voucher_type": "TEXT NOT NULL DEFAULT ''",
+ "posted_voucher_number": "TEXT NOT NULL DEFAULT ''", "tally_posting_status": "TEXT NOT NULL DEFAULT ''",
+ "tally_posting_error": "TEXT NOT NULL DEFAULT ''",
+ }.items():
+ if name not in dep_columns:
+ db.execute(f"ALTER TABLE it_depreciation_runs ADD COLUMN {name} {ddl}")
now = _utc_now_iso()
metadata = {
"schema_version": str(ACT_SCHEMA_VERSION),
diff --git a/app/modules/accounting/templates/accounting/depreciation_it.html b/app/modules/accounting/templates/accounting/depreciation_it.html
index 9d6096e..af56b2a 100644
--- a/app/modules/accounting/templates/accounting/depreciation_it.html
+++ b/app/modules/accounting/templates/accounting/depreciation_it.html
@@ -6,20 +6,22 @@
-
Tools · Tally · Phase 5
+
Tools · Tally · Phase 6
Depreciation (Income Tax)
-
Review synchronized Fixed Assets, assign rates ledger-wise and generate a draft depreciation calculation. No entry is posted to Tally.
+
Calculate Income-tax depreciation, approve the reviewed draft and—only after explicit confirmation—post the approved Journal to the mapped company currently open in TallyPrime.
Proposed only: Debit {{ depreciation_run.depreciation_expense_ledger or 'Depreciation' }} and credit {{ depreciation_run.depreciation_reserve_ledger or 'Depreciation Reserve' }} for ₹ {{ '%.2f'|format(depreciation_run.total_depreciation) }}. Tally write-back remains disabled.
+
Journal: Debit {{ depreciation_run.depreciation_expense_ledger or 'Depreciation' }} and credit {{ depreciation_run.depreciation_reserve_ledger or 'Depreciation Reserve' }} for ₹ {{ '%.2f'|format(depreciation_run.total_depreciation) }}. Voucher date will be {{ depreciation_run.fy_end }} and reference ARRR-ITDEP-{{ depreciation_run.id }}.
+
+
Draft Status
{{ depreciation_run.status|upper }}
+
Approved
{{ depreciation_run.approved_at_utc or 'Not yet approved' }}
+
Tally Posting
{{ depreciation_run.tally_posting_status|upper if depreciation_run.tally_posting_status else 'NOT POSTED' }}
Posted successfully. Posted at {{ depreciation_run.posted_at_utc }}{% if depreciation_run.posted_voucher_number %}; Tally voucher/reference ID {{ depreciation_run.posted_voucher_number }}{% endif %}. This draft is locked against duplicate posting.
{% for row in depreciation_run.writeback_attempts %}
#{{ row.id }}
{{ row.started_at_utc }}
{{ row.status|upper }}
Created {{ row.tally_created }} · Errors {{ row.tally_errors }}{% if row.tally_last_voucher_id %} · ID {{ row.tally_last_voucher_id }}{% endif %}
{{ row.error_message or '' }}
{% endfor %}
+ {% endif %}
{% endif %}
-
Phase 5 is review/calculation only. Phase 6 will add controlled, approved Tally write-back.
+
Phase 6 enables only controlled depreciation Journal write-back. All master and transaction synchronization remain read-only; no other Tally create/alter/delete operation is enabled.
{% endblock %}
diff --git a/app/modules/accounting/ui.py b/app/modules/accounting/ui.py
index 1eedf57..b41bb7b 100644
--- a/app/modules/accounting/ui.py
+++ b/app/modules/accounting/ui.py
@@ -478,6 +478,8 @@ def depreciation_it_tool(
fy_start: str = "",
fy_end: str = "",
calculated: int = 0,
+ approved: int = 0,
+ posted: int = 0,
run_id: int | None = None,
error: str = "",
):
@@ -494,7 +496,7 @@ def depreciation_it_tool(
live_result = None; preview = None; depreciation_run = None; command_error = error or ""
if selected_client and node and online:
try:
- status_response = request_agent_command(node.node_code,"phase5_status",{"client_id":int(selected_client.id)},timeout_seconds=20)
+ status_response = request_agent_command(node.node_code,"phase6_status",{"client_id":int(selected_client.id)},timeout_seconds=20)
if status_response.get("ok"): live_result=status_response.get("result") or {}
else: command_error=str(status_response.get("error") or "Local Agent status failed.")
chosen_guid=str(tally_guid or "").strip()
@@ -507,7 +509,7 @@ def depreciation_it_tool(
if run_response.get("ok"): depreciation_run=(run_response.get("result") or {}).get("depreciation")
except Exception as exc: command_error=str(exc)
base={"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)}
- base.update({"title":"Income-tax Depreciation","clients":clients,"selected_client":selected_client,"storage_node":node,"agent_online":online,"live_result":live_result,"preview":preview,"selected_tally_guid":str(tally_guid or ""),"fy_start":start_text,"fy_end":end_text,"calculated":bool(calculated),"depreciation_run":depreciation_run,"command_error":command_error})
+ base.update({"title":"Income-tax Depreciation","clients":clients,"selected_client":selected_client,"storage_node":node,"agent_online":online,"live_result":live_result,"preview":preview,"selected_tally_guid":str(tally_guid or ""),"fy_start":start_text,"fy_end":end_text,"calculated":bool(calculated),"approved":bool(approved),"posted":bool(posted),"depreciation_run":depreciation_run,"command_error":command_error})
return templates.TemplateResponse("modules/accounting/templates/accounting/depreciation_it.html",base)
finally: db.close()
@@ -543,3 +545,75 @@ async def calculate_it_depreciation(request: Request):
return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client.id}&tally_guid={quote(tally_guid)}&fy_start={start.isoformat()}&fy_end={end.isoformat()}&calculated=1&run_id={rid}",status_code=303)
finally: db.close()
+
+
+@router.post("/depreciation/approve")
+async def approve_it_depreciation(request: Request):
+ form = await request.form()
+ validate_csrf(request, str(form.get("csrf_token") or ""))
+ db = CommonSessionLocal()
+ try:
+ user, response = _require_partner(request, db, "accounting.tally.writeback")
+ if response: return response
+ try:
+ client_id = int(form.get("client_id")); run_id = int(form.get("run_id"))
+ except Exception:
+ return RedirectResponse(url="/tools/tally/depreciation?error=" + quote("Invalid depreciation draft."), status_code=303)
+ client, _clients, scope = _find_visible_client(db, request, user, client_id)
+ if not client: return _denied()
+ if str(form.get("confirm_approval") or "") != "yes":
+ return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client_id}&run_id={run_id}&error={quote('Confirm that you reviewed the depreciation draft before approval.')}", status_code=303)
+ node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id)
+ if not node or not _node_online(node):
+ return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client_id}&run_id={run_id}&error={quote('ERP Local Agent is offline for the active branch.')}", status_code=303)
+ try:
+ result = request_agent_command(
+ node.node_code, "accounting_approve_it_depreciation",
+ {"client_id": client_id, "run_id": run_id, "approved_by_user_id": int(user.id), "approval_note": str(form.get("approval_note") or "")},
+ timeout_seconds=30,
+ )
+ if not result.get("ok"): raise RuntimeError(str(result.get("error") or "Approval failed."))
+ dep = (result.get("result") or {}).get("depreciation") or {}
+ guid = quote(str(dep.get("tally_guid") or ""))
+ return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client_id}&tally_guid={guid}&fy_start={dep.get('fy_start','')}&fy_end={dep.get('fy_end','')}&run_id={run_id}&approved=1", status_code=303)
+ except Exception as exc:
+ return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client_id}&run_id={run_id}&error={quote(str(exc))}", status_code=303)
+ finally:
+ db.close()
+
+
+@router.post("/depreciation/post-to-tally")
+async def post_it_depreciation_to_tally(request: Request):
+ form = await request.form()
+ validate_csrf(request, str(form.get("csrf_token") or ""))
+ db = CommonSessionLocal()
+ try:
+ user, response = _require_partner(request, db, "accounting.tally.writeback")
+ if response: return response
+ try:
+ client_id = int(form.get("client_id")); run_id = int(form.get("run_id"))
+ except Exception:
+ return RedirectResponse(url="/tools/tally/depreciation?error=" + quote("Invalid depreciation draft."), status_code=303)
+ client, _clients, scope = _find_visible_client(db, request, user, client_id)
+ if not client: return _denied()
+ if str(form.get("post_confirmation") or "").strip().upper() != "POST":
+ return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client_id}&run_id={run_id}&error={quote('Type POST exactly to confirm Tally write-back.')}", status_code=303)
+ if str(form.get("confirm_posting") or "") != "yes":
+ return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client_id}&run_id={run_id}&error={quote('Confirm that you want to create the Journal voucher in TallyPrime.')}", status_code=303)
+ node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id)
+ if not node or not _node_online(node):
+ return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client_id}&run_id={run_id}&error={quote('ERP Local Agent is offline for the active branch.')}", status_code=303)
+ try:
+ result = request_agent_command(
+ node.node_code, "accounting_post_it_depreciation",
+ {"client_id": client_id, "run_id": run_id, "posted_by_user_id": int(user.id)},
+ timeout_seconds=120,
+ )
+ if not result.get("ok"): raise RuntimeError(str(result.get("error") or "Tally write-back failed."))
+ dep = (result.get("result") or {}).get("depreciation") or {}
+ guid = quote(str(dep.get("tally_guid") or ""))
+ return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client_id}&tally_guid={guid}&fy_start={dep.get('fy_start','')}&fy_end={dep.get('fy_end','')}&run_id={run_id}&posted=1", status_code=303)
+ except Exception as exc:
+ return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client_id}&run_id={run_id}&error={quote(str(exc))}", status_code=303)
+ finally:
+ db.close()
diff --git a/app/modules/core/rbac/permissions_registry.py b/app/modules/core/rbac/permissions_registry.py
index eba9f00..58136f5 100644
--- a/app/modules/core/rbac/permissions_registry.py
+++ b/app/modules/core/rbac/permissions_registry.py
@@ -153,6 +153,7 @@ PERMISSIONS = {
"accounting.tally.sync_masters": "Synchronize Tally Accounting Masters",
"accounting.tally.sync_transactions": "Synchronize Tally Accounting Transactions",
"accounting.tally.depreciation_it": "Calculate Income-tax Depreciation",
+ "accounting.tally.writeback": "Approve and Post Accounting Entries to Tally",
"accounting.act.initialize": "Initialize Client Accounting ACT Storage",
"notice_cases.view": "View Notice and Case Management",
@@ -185,3 +186,4 @@ def expand_permission_codes(code: str) -> list[str]:
+
diff --git a/app/modules/documents/agent_package.py b/app/modules/documents/agent_package.py
index 69fce46..f0ddb37 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.6.0"
+ERP_LOCAL_AGENT_VERSION = "1.7.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/README_ERP_LOCAL_AGENT.txt b/app/modules/documents/local_agent_runtime/README_ERP_LOCAL_AGENT.txt
index ccfd193..6dae74c 100644
--- a/app/modules/documents/local_agent_runtime/README_ERP_LOCAL_AGENT.txt
+++ b/app/modules/documents/local_agent_runtime/README_ERP_LOCAL_AGENT.txt
@@ -1,4 +1,4 @@
-ERP Local Agent 1.4.0
+ERP Local Agent 1.7.0
Existing storage, WebSocket tunnel, Tally and client .act functionality are preserved.
@@ -24,3 +24,5 @@ Phase 3: read-only Tally accounting master sync (Groups, Ledgers, Voucher Types,
Version 1.6.0: Phase 4 read-only Tally transaction synchronization (vouchers, ledger/inventory entries, bill/cost-centre/bank allocations) with raw voucher XML preservation.
Phase 5 adds draft-only Income-tax depreciation calculations from synchronized .act data. No Tally write-back.
+
+Phase 6: approved Income-tax depreciation Journal write-back is available only after explicit Partner approval and POST confirmation. All other Tally operations remain read-only.
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 7935cfd..632e9d7 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.6.0"
+__version__ = "1.7.0"
AGENT_NAME = "ERP Local Agent"
diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_store.py b/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_store.py
index 5b0e787..f55d8fd 100644
--- a/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_store.py
+++ b/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_store.py
@@ -7,7 +7,7 @@ import sqlite3
from typing import Sequence
-SCHEMA_VERSION = "5"
+SCHEMA_VERSION = "6"
MASTER_TABLES = {
"groups": "tally_groups",
@@ -32,8 +32,8 @@ class LocalAccountingStore:
Phase 3 preserves Phase 1/2 metadata and mappings and adds company-scoped
read-only Tally master snapshots. Phase 4 adds date-scoped vouchers and their
ledger, inventory, bill, cost-centre and bank allocations. Phase 5 adds
- reviewable Income-tax depreciation rate rules and draft calculations only.
- Existing .act files are upgraded in place and no Tally write-back occurs.
+ reviewable Income-tax depreciation calculations. Phase 6 adds controlled Partner approval and audited Tally write-back for approved depreciation journals.
+ Existing .act files are upgraded in place.
"""
def __init__(self, storage_root: Path):
@@ -121,6 +121,24 @@ class LocalAccountingStore:
CREATE INDEX IF NOT EXISTS ix_{table}_master_guid ON {table}(tally_guid, master_guid);
"""
+ @staticmethod
+ def _ensure_phase6_columns(db: sqlite3.Connection) -> None:
+ columns = {row["name"] for row in db.execute("PRAGMA table_info(it_depreciation_runs)").fetchall()}
+ additions = {
+ "approved_by_user_id": "INTEGER",
+ "approved_at_utc": "TEXT",
+ "approval_note": "TEXT NOT NULL DEFAULT ''",
+ "posted_by_user_id": "INTEGER",
+ "posted_at_utc": "TEXT",
+ "posted_voucher_type": "TEXT NOT NULL DEFAULT ''",
+ "posted_voucher_number": "TEXT NOT NULL DEFAULT ''",
+ "tally_posting_status": "TEXT NOT NULL DEFAULT ''",
+ "tally_posting_error": "TEXT NOT NULL DEFAULT ''",
+ }
+ for name, ddl in additions.items():
+ if name not in columns:
+ db.execute(f"ALTER TABLE it_depreciation_runs ADD COLUMN {name} {ddl}")
+
def initialize(self, client_id: int, client_name: str = "", tenant_id: int | None = None, created_by_user_id: int | None = None) -> Path:
path = self.db_path(client_id)
with self.connect(client_id) as db:
@@ -274,8 +292,36 @@ class LocalAccountingStore:
FOREIGN KEY(run_id) REFERENCES it_depreciation_runs(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS ix_it_dep_lines_run ON it_depreciation_run_lines(run_id, line_no);
+
+ CREATE TABLE IF NOT EXISTS tally_writeback_attempts (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ depreciation_run_id INTEGER NOT NULL,
+ tally_guid TEXT NOT NULL,
+ company_name TEXT NOT NULL,
+ voucher_type TEXT NOT NULL DEFAULT 'Journal',
+ voucher_date TEXT NOT NULL,
+ reference TEXT NOT NULL DEFAULT '',
+ debit_ledger TEXT NOT NULL,
+ credit_ledger TEXT NOT NULL,
+ amount REAL NOT NULL DEFAULT 0,
+ requested_by_user_id INTEGER,
+ started_at_utc TEXT NOT NULL,
+ completed_at_utc TEXT,
+ status TEXT NOT NULL,
+ tally_created INTEGER NOT NULL DEFAULT 0,
+ tally_altered INTEGER NOT NULL DEFAULT 0,
+ tally_errors INTEGER NOT NULL DEFAULT 0,
+ tally_last_voucher_id TEXT NOT NULL DEFAULT '',
+ request_xml TEXT NOT NULL DEFAULT '',
+ response_xml TEXT NOT NULL DEFAULT '',
+ error_message TEXT,
+ FOREIGN KEY(depreciation_run_id) REFERENCES it_depreciation_runs(id) ON DELETE CASCADE
+ );
+ CREATE INDEX IF NOT EXISTS ix_tally_writeback_run ON tally_writeback_attempts(depreciation_run_id, id);
+ CREATE INDEX IF NOT EXISTS ix_tally_writeback_company ON tally_writeback_attempts(tally_guid, started_at_utc);
"""
)
+ self._ensure_phase6_columns(db)
now = _utc_now_iso()
meta = {
@@ -697,9 +743,121 @@ class LocalAccountingStore:
run=db.execute("SELECT * FROM it_depreciation_runs WHERE id=?",(int(run_id),)).fetchone()
if not run: raise ValueError("Depreciation draft was not found.")
lines=db.execute("SELECT * FROM it_depreciation_run_lines WHERE run_id=? ORDER BY line_no",(int(run_id),)).fetchall()
- result=dict(run); result["lines"]=[dict(x) for x in lines]; result["no_tally_writeback"]=True
+ result=dict(run); result["lines"]=[dict(x) for x in lines]
+ result["writeback_attempts"] = self.get_writeback_attempts(client_id, run_id) if "tally_writeback_attempts" else []
+ result["no_tally_writeback"] = False
return result
+ def approve_it_depreciation(self, client_id: int, run_id: int, approved_by_user_id: int, approval_note: str = "") -> dict:
+ self.initialize(client_id)
+ now = _utc_now_iso()
+ with self.connect(client_id) as db:
+ run = db.execute("SELECT * FROM it_depreciation_runs WHERE id=?", (int(run_id),)).fetchone()
+ if not run:
+ raise ValueError("Depreciation draft was not found.")
+ status = str(run["status"] or "").strip().lower()
+ if status == "posted":
+ raise ValueError("This depreciation draft has already been posted to Tally.")
+ if status not in {"draft", "approved"}:
+ raise ValueError(f"Depreciation draft cannot be approved from status '{status}'.")
+ if float(run["total_depreciation"] or 0) <= 0:
+ raise ValueError("Depreciation amount must be greater than zero before approval.")
+ if not str(run["depreciation_expense_ledger"] or "").strip() or not str(run["depreciation_reserve_ledger"] or "").strip():
+ raise ValueError("Select both the depreciation expense ledger and the depreciation reserve/credit ledger before approval.")
+ db.execute(
+ """UPDATE it_depreciation_runs
+ SET status='approved', approved_by_user_id=?, approved_at_utc=?, approval_note=?,
+ tally_posting_status=CASE WHEN tally_posting_status='failed' THEN '' ELSE tally_posting_status END,
+ tally_posting_error=''
+ WHERE id=?""",
+ (int(approved_by_user_id), now, str(approval_note or "").strip(), int(run_id)),
+ )
+ return self.get_it_depreciation_run(client_id, run_id)
+
+ def begin_writeback_attempt(self, client_id: int, run_id: int, requested_by_user_id: int, *, voucher_date: str, reference: str, request_xml: str) -> dict:
+ self.initialize(client_id)
+ now = _utc_now_iso()
+ with self.connect(client_id) as db:
+ run = db.execute("SELECT * FROM it_depreciation_runs WHERE id=?", (int(run_id),)).fetchone()
+ if not run:
+ raise ValueError("Depreciation draft was not found.")
+ if str(run["status"] or "").strip().lower() != "approved":
+ raise ValueError("The depreciation draft must be approved before posting.")
+ successful = db.execute(
+ "SELECT id FROM tally_writeback_attempts WHERE depreciation_run_id=? AND status='posted' LIMIT 1",
+ (int(run_id),),
+ ).fetchone()
+ if successful:
+ raise ValueError("This depreciation draft has already been posted to Tally.")
+ cur = db.execute(
+ """INSERT INTO tally_writeback_attempts(
+ depreciation_run_id,tally_guid,company_name,voucher_type,voucher_date,reference,
+ debit_ledger,credit_ledger,amount,requested_by_user_id,started_at_utc,status,request_xml
+ ) VALUES(?,?,?,?,?,?,?,?,?,?,?,'posting',?)""",
+ (
+ int(run_id), str(run["tally_guid"] or ""), str(run["company_name"] or ""), "Journal",
+ str(voucher_date), str(reference or ""), str(run["depreciation_expense_ledger"] or ""),
+ str(run["depreciation_reserve_ledger"] or ""), float(run["total_depreciation"] or 0),
+ int(requested_by_user_id), now, str(request_xml or ""),
+ ),
+ )
+ attempt_id = int(cur.lastrowid)
+ db.execute(
+ "UPDATE it_depreciation_runs SET tally_posting_status='posting', tally_posting_error='' WHERE id=?",
+ (int(run_id),),
+ )
+ return {"attempt_id": attempt_id, "run": self.get_it_depreciation_run(client_id, run_id)}
+
+ def finish_writeback_attempt(self, client_id: int, attempt_id: int, *, posted: bool, result: dict | None = None, response_xml: str = "", error_message: str = "", posted_by_user_id: int | None = None) -> dict:
+ self.initialize(client_id)
+ now = _utc_now_iso()
+ result = result or {}
+ with self.connect(client_id) as db:
+ attempt = db.execute("SELECT * FROM tally_writeback_attempts WHERE id=?", (int(attempt_id),)).fetchone()
+ if not attempt:
+ raise ValueError("Tally write-back attempt was not found.")
+ run_id = int(attempt["depreciation_run_id"])
+ status = "posted" if posted else "failed"
+ db.execute(
+ """UPDATE tally_writeback_attempts
+ SET completed_at_utc=?, status=?, tally_created=?, tally_altered=?, tally_errors=?,
+ tally_last_voucher_id=?, response_xml=?, error_message=?
+ WHERE id=?""",
+ (
+ now, status, int(result.get("created") or 0), int(result.get("altered") or 0),
+ int(result.get("errors") or 0), str(result.get("last_voucher_id") or ""),
+ str(response_xml or result.get("raw_response") or ""), str(error_message or ""), int(attempt_id),
+ ),
+ )
+ if posted:
+ db.execute(
+ """UPDATE it_depreciation_runs
+ SET status='posted', posted_by_user_id=?, posted_at_utc=?, posted_voucher_type='Journal',
+ posted_voucher_number=?, tally_posting_status='posted', tally_posting_error=''
+ WHERE id=?""",
+ (int(posted_by_user_id) if posted_by_user_id is not None else None, now, str(result.get("last_voucher_id") or ""), run_id),
+ )
+ else:
+ db.execute(
+ """UPDATE it_depreciation_runs
+ SET status='approved', tally_posting_status='failed', tally_posting_error=?
+ WHERE id=?""",
+ (str(error_message or "Tally rejected the journal voucher."), run_id),
+ )
+ return self.get_it_depreciation_run(client_id, run_id)
+
+ def get_writeback_attempts(self, client_id: int, run_id: int) -> list[dict]:
+ self.initialize(client_id)
+ with self.connect(client_id) as db:
+ rows = db.execute(
+ """SELECT id,depreciation_run_id,tally_guid,company_name,voucher_type,voucher_date,reference,
+ debit_ledger,credit_ledger,amount,requested_by_user_id,started_at_utc,completed_at_utc,status,
+ tally_created,tally_altered,tally_errors,tally_last_voucher_id,error_message
+ FROM tally_writeback_attempts WHERE depreciation_run_id=? ORDER BY id DESC""",
+ (int(run_id),),
+ ).fetchall()
+ return [dict(row) for row in rows]
+
def snapshot(self, client_id: int) -> dict:
path = self.db_path(client_id)
if not path.is_file():
@@ -719,7 +877,7 @@ class LocalAccountingStore:
latest = db.execute("SELECT checked_at_utc, connected, tally_url, company_count, error_message FROM tally_connection_history ORDER BY id DESC LIMIT 1").fetchone()
latest_sync = db.execute("SELECT id, sync_type, tally_guid, company_name, mapping_id, requested_by_user_id, status, started_at_utc, completed_at_utc, rows_processed, error_message, details_json FROM tally_sync_runs WHERE sync_type='masters' ORDER BY id DESC LIMIT 1").fetchone()
latest_transaction_sync = db.execute("SELECT id, sync_type, tally_guid, company_name, mapping_id, requested_by_user_id, status, started_at_utc, completed_at_utc, rows_processed, error_message, details_json FROM tally_sync_runs WHERE sync_type='transactions' ORDER BY id DESC LIMIT 1").fetchone()
- latest_depreciation = db.execute("SELECT id, tally_guid, company_name, fy_start, fy_end, total_depreciation, depreciation_expense_ledger, depreciation_reserve_ledger, status, created_at_utc FROM it_depreciation_runs ORDER BY id DESC LIMIT 1").fetchone()
+ latest_depreciation = db.execute("SELECT id, tally_guid, company_name, fy_start, fy_end, total_depreciation, depreciation_expense_ledger, depreciation_reserve_ledger, status, created_at_utc, approved_by_user_id, approved_at_utc, posted_by_user_id, posted_at_utc, tally_posting_status, tally_posting_error FROM it_depreciation_runs ORDER BY id DESC LIMIT 1").fetchone()
loaded_guids = {str(row["guid"] or "") for row in companies if int(row["is_currently_loaded"] or 0)}
mapped = []
for row in mappings:
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 f7be368..e1f0781 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
@@ -41,6 +41,10 @@ class AgentCommandProcessor:
result = self._calculate_it_depreciation(payload)
elif action == "accounting_get_it_depreciation_run":
result = self._get_it_depreciation_run(payload)
+ elif action == "accounting_approve_it_depreciation":
+ result = self._approve_it_depreciation(payload)
+ elif action == "accounting_post_it_depreciation":
+ result = self._post_it_depreciation(payload)
else:
raise ValueError(f"Unsupported local-agent command: {action}")
ok = True
@@ -56,6 +60,7 @@ class AgentCommandProcessor:
"tally_mapping_capability": True, "tally_master_sync_capability": True,
"tally_transaction_sync_capability": True,
"it_depreciation_capability": True,
+ "tally_writeback_capability": True,
}
def _status(self, payload: dict[str, Any]) -> dict[str, Any]:
@@ -207,3 +212,71 @@ class AgentCommandProcessor:
client_id = int(payload.get("client_id")); run_id = int(payload.get("run_id"))
return {"depreciation": self.store.get_it_depreciation_run(client_id, run_id), "agent": self._agent_info()}
+ def _approve_it_depreciation(self, payload: dict[str, Any]) -> dict[str, Any]:
+ client_id = int(payload.get("client_id"))
+ run_id = int(payload.get("run_id"))
+ user_id = int(payload.get("approved_by_user_id"))
+ result = self.store.approve_it_depreciation(
+ client_id, run_id, user_id, str(payload.get("approval_note") or "").strip()
+ )
+ self.logger.info("IT depreciation approved client_id=%s run_id=%s approved_by=%s", client_id, run_id, user_id)
+ return {"approved": True, "depreciation": result, "agent": self._agent_info()}
+
+ def _post_it_depreciation(self, payload: dict[str, Any]) -> dict[str, Any]:
+ client_id = int(payload.get("client_id"))
+ run_id = int(payload.get("run_id"))
+ posted_by = int(payload.get("posted_by_user_id"))
+ run = self.store.get_it_depreciation_run(client_id, run_id)
+ if str(run.get("status") or "").strip().lower() != "approved":
+ raise ValueError("The depreciation draft must be approved before Tally write-back.")
+ tally_guid = str(run.get("tally_guid") or "").strip()
+ company_name = str(run.get("company_name") or "").strip()
+
+ tally_status = self.tally.status()
+ if not tally_status.get("connected"):
+ raise ValueError(str(tally_status.get("error") or "TallyPrime is not connected."))
+ loaded = next(
+ (row for row in (tally_status.get("companies") or [])
+ if str(row.get("guid") or "").strip() == tally_guid),
+ None,
+ )
+ if not loaded:
+ raise ValueError("The mapped Tally company is not currently open. Open the mapped company in TallyPrime and retry.")
+ if str(loaded.get("name") or "").strip() != company_name:
+ raise ValueError("The currently open Tally company GUID matches the mapping but its company name differs. Refresh mapping/status before posting.")
+
+ voucher_date = str(run.get("fy_end") or "").strip()
+ reference = f"ARRR-ITDEP-{run_id}"
+ narration = f"Income-tax depreciation for FY {run.get('fy_start')} to {run.get('fy_end')} · ERP draft #{run_id}"
+ debit = str(run.get("depreciation_expense_ledger") or "").strip()
+ credit = str(run.get("depreciation_reserve_ledger") or "").strip()
+ amount = float(run.get("total_depreciation") or 0)
+ request_xml = self.tally.build_journal_import_xml(
+ company_name, voucher_date=voucher_date, debit_ledger=debit,
+ credit_ledger=credit, amount=amount, narration=narration, reference=reference,
+ )
+ attempt = self.store.begin_writeback_attempt(
+ client_id, run_id, posted_by, voucher_date=voucher_date,
+ reference=reference, request_xml=request_xml,
+ )
+ attempt_id = int(attempt["attempt_id"])
+ try:
+ result = self.tally.post_journal_voucher(
+ company_name, voucher_date=voucher_date, debit_ledger=debit,
+ credit_ledger=credit, amount=amount, narration=narration, reference=reference,
+ )
+ updated = self.store.finish_writeback_attempt(
+ client_id, attempt_id, posted=True, result=result,
+ response_xml=str(result.get("raw_response") or ""), posted_by_user_id=posted_by,
+ )
+ self.logger.warning(
+ "CONTROLLED TALLY WRITEBACK posted client_id=%s company=%s run_id=%s amount=%.2f by_user=%s tally_voucher=%s",
+ client_id, company_name, run_id, amount, posted_by, result.get("last_voucher_id"),
+ )
+ return {"posted": True, "depreciation": updated, "tally_result": {k:v for k,v in result.items() if k not in {"raw_response","request_xml"}}, "agent": self._agent_info()}
+ except Exception as exc:
+ self.store.finish_writeback_attempt(
+ client_id, attempt_id, posted=False, result={}, error_message=str(exc), posted_by_user_id=posted_by,
+ )
+ raise
+
diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/tally.py b/app/modules/documents/local_agent_runtime/erp_local_agent/tally.py
index dfd8846..1e4df9d 100644
--- a/app/modules/documents/local_agent_runtime/erp_local_agent/tally.py
+++ b/app/modules/documents/local_agent_runtime/erp_local_agent/tally.py
@@ -99,9 +99,7 @@ class TallyCompany:
class TallyLiveConnector:
"""Read-only TallyPrime XML/HTTP connector used by the ERP Local Agent.
- Phase 3 adds master discovery for the currently loaded Tally companies. Phase 4
- adds date-scoped read-only voucher/transaction export. Every export is scoped
- with SVCURRENTCOMPANY and never creates, alters or deletes Tally data.
+ Phase 3 adds master discovery; Phase 4 adds transaction export. Phase 6 adds one narrowly-scoped controlled write-back operation for an approved depreciation Journal. All read operations remain read-only.
"""
MASTER_SPECS = {
@@ -286,6 +284,84 @@ class TallyLiveConnector:
vouchers = self.export_vouchers(company_name, start, end)
return {"date_from": start, "date_to": end, "vouchers": vouchers}
+ def build_journal_import_xml(self, company_name: str, *, voucher_date: str, debit_ledger: str, credit_ledger: str, amount: float, narration: str, reference: str = "") -> str:
+ company = str(company_name or "").strip()
+ debit = str(debit_ledger or "").strip()
+ credit = str(credit_ledger or "").strip()
+ if not company:
+ raise ValueError("Tally company name is required for write-back.")
+ if not debit or not credit:
+ raise ValueError("Both debit and credit ledgers are required for write-back.")
+ if debit.casefold() == credit.casefold():
+ raise ValueError("Debit and credit ledgers cannot be the same.")
+ value = round(float(amount or 0), 2)
+ if value <= 0:
+ raise ValueError("Journal amount must be greater than zero.")
+ dt = _tally_date(voucher_date)
+ ref_xml = f"{self._xml_escape(reference)}" if str(reference or "").strip() else ""
+ return f"""
+ 1ImportDataVouchers
+
+ {self._xml_escape(company)}
+
+
+
+ {dt}
+ Journal
+ Accounting Voucher View
+ {ref_xml}
+ {self._xml_escape(narration)}
+
+ {self._xml_escape(debit)}
+ Yes
+ -{value:.2f}
+
+
+ {self._xml_escape(credit)}
+ No
+ {value:.2f}
+
+
+
+"""
+
+ def post_journal_voucher(self, company_name: str, *, voucher_date: str, debit_ledger: str, credit_ledger: str, amount: float, narration: str, reference: str = "") -> dict:
+ request_xml = self.build_journal_import_xml(
+ company_name, voucher_date=voucher_date, debit_ledger=debit_ledger,
+ credit_ledger=credit_ledger, amount=amount, narration=narration, reference=reference,
+ )
+ response_xml = self._post_xml(request_xml)
+ cleaned = _clean_xml_response(response_xml)
+ try:
+ root = ET.fromstring(cleaned.encode("utf-8"))
+ except Exception as exc:
+ raise ValueError(f"Tally returned invalid XML after Journal import: {exc}") from exc
+
+ def first_int(tag_name: str) -> int:
+ text = _first_text(root, [tag_name])
+ try:
+ return int(float(str(text or "0")))
+ except Exception:
+ return 0
+
+ created = first_int("CREATED")
+ altered = first_int("ALTERED")
+ errors = first_int("ERRORS")
+ last_voucher_id = _first_text(root, ["LASTVCHID", "LASTMID", "LASTVOUCHERID"])
+ line_error = _first_text(root, ["LINEERROR"])
+ if errors > 0 or line_error:
+ raise ValueError(line_error or f"Tally reported {errors} error(s) while creating the Journal voucher.")
+ if created < 1 and altered < 1:
+ raise ValueError("Tally did not confirm creation of the Journal voucher.")
+ return {
+ "created": created,
+ "altered": altered,
+ "errors": errors,
+ "last_voucher_id": str(last_voucher_id or ""),
+ "raw_response": response_xml,
+ "request_xml": request_xml,
+ }
+
@staticmethod
def _parse_vouchers(xml_text: str) -> list[dict]:
cleaned = _clean_xml_response(xml_text)