Prepare and validate cash payment entry allocation using only actual source payment dates. The tool checks totals, the configured daily limit and the permitted source-date window; it never invents or changes dates.
{% else %}
-
Read-only Tally review for cash payments above the configured limit, same-day party aggregation and possible split-payment patterns.
+
Analyse cash-payment compliance directly from the client's local SQLite Accounting Mirror. TallyPrime is not contacted during this review.
{{ mirror.company.company_name if mirror.ready and mirror.company else 'Not mirrored' }}
+
+
+
Mirror Period
+
{% if mirror.ready and mirror.voucher_period %}{{ mirror.voucher_period.from_date or '—' }} → {{ mirror.voucher_period.to_date or '—' }}{% else %}—{% endif %}
+
+
+
Vouchers Available
+
{{ mirror.counts.voucher if mirror.ready and mirror.counts else 0 }}
+
+
+
Voucher Lines
+
{{ mirror.counts.voucher_ledger_entry if mirror.ready and mirror.counts else 0 }}
+
+
+ {% if mirror.ready %}
+
Source: {{ mirror.path or mirror_status.mirror_db_path or '' }}
+ {% else %}
+
Mirror this client first from Tally Accounting → Mirror Tally to SQLite. After that, Cash Payment Review works even if TallyPrime is closed.
{% endif %}
-
- {% endif %}
-
{% if review %}
- {% if review.sqlite_cache %}
-
Analysis completed from the Local Agent SQLite cache. TallyPrime was used only for the slow background extraction and was released before this analysis ran. {{ review.tally_request_count or 0 }} Tally request(s), {{ review.tally_retry_count or 0 }} retry/retries, and approximately {{ review.tally_pause_ms or 0 }} ms cooling between successful day reads.
- {% endif %}
+
+ Analysis completed entirely from the local SQLite Accounting Mirror. TallyPrime was not contacted.
+ {% if review.cash_ledgers %} Cash ledger(s): {{ review.cash_ledgers|join(', ') }}.{% endif %}
+ {% if review.mirror_period %} Mirror period: {{ review.mirror_period.from_date or '—' }} → {{ review.mirror_period.to_date or '—' }}.{% endif %}
+
{% for label, value in [('Vouchers reviewed', review.summary.vouchers_reviewed), ('Cash payments', review.summary.cash_payment_vouchers), ('Single-voucher exceptions', review.summary.single_voucher_exceptions), ('Same-day exceptions', review.summary.same_day_exceptions), ('Possible split patterns', review.summary.possible_split_patterns)] %}
Submitting a background extraction job to the ERP Local Agent…
-
-
0%
-
-
-
-
-
-
Days
0 / 0
-
Cash vouchers
0
-
Tally requests
0
-
Current date
—
-
-
-
1. Connect
-
2. Confirm ledger
-
3. Extract
-
4. Analyse
-
5. Complete
-
-
The confirmed Cash ledger comes from the common local .act master snapshot. The Local Agent then reads Tally in safe one-day batches and stores only vouchers containing that ledger in SQLite. Analysis runs from SQLite. You can close this ERP page; the background job continues and remains visible on the Local Agent dashboard.
-
-
-
-
-
-
-
-
-
{% endblock %}
diff --git a/app/modules/documents/agent_package.py b/app/modules/documents/agent_package.py
index 47902d7..d567fdb 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.24.4"
+ERP_LOCAL_AGENT_VERSION = "1.24.6"
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)
@@ -65,7 +65,7 @@ def _build_zip(*, env_text: str | None, include_env: bool, include_admin_readme:
text = (
f"ERP Local Agent {ERP_LOCAL_AGENT_VERSION}\n"
"Existing storage, WebSocket tunnel, dashboard, Tally mapping and client .act functionality are preserved.\n"
- "Existing master/transaction sync, accounting tools and controlled write-back are preserved.\nAccounting Mirror uses the proven v3.0.1 ODBC runtime for ledgers, vouchers, stock movement, HSN and GST rates.\nTally TDL is exposed through a stable local path and localhost URL so later agent updates can repair/replace the TDL without changing the Tally link.\nFull Accounting Export runs asynchronously from the ERP, publishes live progress in both ERP and the Local Agent dashboard, and refreshes the client SQLite accounting datasets after the ODBC mirror is built.\n"
+ "Existing master/transaction sync, accounting tools and controlled write-back are preserved.\nAccounting Mirror uses the proven v3.0.1 ODBC runtime for ledgers, vouchers, stock movement, HSN and GST rates.\nTally TDL is exposed through a stable local path and localhost URL so later agent updates can repair/replace the TDL without changing the Tally link.\nFull Accounting Export runs asynchronously from the ERP, publishes live progress in both ERP and the Local Agent dashboard, and refreshes the client SQLite accounting datasets after the ODBC mirror is built.\nCash Payment Compliance reads only the local SQLite Accounting Mirror after mirroring; it no longer starts a Tally extraction job or requires TallyPrime during review.\n"
"Dashboard: http://127.0.0.1:8788\nDesktop dashboard is single-instance: repeated shortcut clicks focus the existing window without starting another Local Agent.\nUpdate dashboard shows check, download, install, restart and completion progress.\n"
)
_write_zip_bytes(dst, "README_ERP_LOCAL_AGENT.txt", text.encode("utf-8"))
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 4ad293b..d55c3c4 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.24.4"
+__version__ = "1.24.6"
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 0cf06f3..220793d 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
@@ -144,6 +144,8 @@ class AgentCommandProcessor:
result = self._cash_payment_cache_cancel(payload)
elif action == "accounting_cash_payment_cache_analyze":
result = self._cash_payment_cache_analyze(payload)
+ elif action == "accounting_cash_payment_mirror_analyze":
+ result = self._cash_payment_mirror_analyze(payload)
elif action == "accounting_cash_payment_compliance":
result = self._cash_payment_compliance(payload)
elif action == "accounting_tds_compliance":
@@ -208,6 +210,7 @@ class AgentCommandProcessor:
"purchase_voucher_write_capability": True,
"it_depreciation_capability": True,
"cash_payment_compliance_capability": True,
+ "cash_payment_mirror_only_analysis_capability": True,
"cash_payment_sqlite_cache_capability": True,
"cash_payment_background_sync_capability": True,
"cash_payment_ledger_confirmation_capability": True,
@@ -2118,6 +2121,308 @@ class AgentCommandProcessor:
"agent": self._agent_info(),
}
+ def _cash_payment_mirror_analyze(self, payload: dict[str, Any]) -> dict[str, Any]:
+ """Analyse cash payments entirely from the client's local ODBC Accounting Mirror.
+
+ This path never calls TallyPrime. Tally is required only when the user
+ explicitly runs "Mirror Tally to SQLite" from the Tally Accounting page.
+ """
+ from collections import defaultdict
+ from datetime import date as _date
+
+ client_id = int(payload.get("client_id") or 0)
+ if client_id <= 0:
+ raise ValueError("client_id is required.")
+ if not self.store.exists(client_id):
+ raise ValueError("Accounting local storage is not initialized for this client.")
+
+ date_from = str(payload.get("date_from") or "").strip()
+ date_to = str(payload.get("date_to") or "").strip()
+ try:
+ start_day = _date.fromisoformat(date_from)
+ end_day = _date.fromisoformat(date_to)
+ except Exception as exc:
+ raise ValueError("Select valid From Date and To Date.") from exc
+ if start_day > end_day:
+ raise ValueError("From Date cannot be after To Date.")
+
+ cash_limit = round(float(payload.get("cash_limit") or 10000.0), 2)
+ split_window_days = max(1, int(payload.get("split_window_days") or 3))
+ near_limit_percent = max(1.0, min(float(payload.get("near_limit_percent") or 80.0), 100.0))
+ requested_cash_ledger = str(payload.get("cash_ledger_name") or "").strip()
+
+ mirror_status = self.tally.mirror.status(client_id)
+ mirror = mirror_status.get("mirror") or {}
+ if not mirror.get("ready"):
+ raise ValueError(
+ "Accounting Mirror is not ready for this client. "
+ "Open Tally Accounting and run 'Mirror Tally to SQLite' first."
+ )
+ company = mirror.get("company") or {}
+ company_name = str(company.get("company_name") or "").strip()
+ company_guid = str(company.get("company_guid") or "").strip()
+ if not company_name and not company_guid:
+ raise ValueError("Accounting Mirror does not contain a valid Tally company identity.")
+
+ counts = mirror.get("counts") or {}
+ if int(counts.get("voucher") or 0) <= 0:
+ raise ValueError(
+ "The Accounting Mirror contains no vouchers. "
+ "Run 'Mirror Tally to SQLite' for the required Tally period first."
+ )
+
+ db = self.tally.mirror.open_db(client_id, company_name, company_guid)
+ try:
+ ledger_rows = db.execute(
+ "SELECT ledger_name, parent_group FROM ledger_master ORDER BY ledger_name"
+ ).fetchall()
+ cash_ledger_names: list[str] = []
+ for row in ledger_rows:
+ name = str(row["ledger_name"] or "").strip()
+ parent = re.sub(r"[\s_-]+", " ", str(row["parent_group"] or "").strip()).casefold()
+ if not name:
+ continue
+ if parent in {"cash in hand", "cash"} or name.casefold() == "cash":
+ cash_ledger_names.append(name)
+
+ if requested_cash_ledger:
+ matched = next(
+ (x for x in cash_ledger_names if x.casefold() == requested_cash_ledger.casefold()),
+ None,
+ )
+ if not matched:
+ all_names = {str(row["ledger_name"] or "").strip().casefold() for row in ledger_rows}
+ if requested_cash_ledger.casefold() not in all_names:
+ raise ValueError(
+ f"Cash ledger '{requested_cash_ledger}' is not present in the Accounting Mirror."
+ )
+ matched = requested_cash_ledger
+ cash_ledger_names = [matched]
+
+ # Preserve legacy behaviour if a very old Tally company has no
+ # Cash-in-Hand hierarchy metadata but contains the standard Cash ledger.
+ if not cash_ledger_names:
+ cash_ledger_names = [
+ str(row["ledger_name"] or "").strip()
+ for row in ledger_rows
+ if str(row["ledger_name"] or "").strip().casefold() == "cash"
+ ]
+ if not cash_ledger_names:
+ raise ValueError(
+ "No Cash / Cash-in-Hand ledger was found in the Accounting Mirror. "
+ "Refresh the Tally mirror and verify the ledger hierarchy."
+ )
+
+ placeholders = ",".join("?" for _ in cash_ledger_names)
+ params = [date_from, date_to] + [x.casefold() for x in cash_ledger_names]
+
+ total_vouchers = int(db.execute(
+ """SELECT COUNT(*) FROM voucher
+ WHERE voucher_date>=? AND voucher_date<=?
+ AND COALESCE(is_cancelled,0)=0 AND COALESCE(is_optional,0)=0""",
+ (date_from, date_to),
+ ).fetchone()[0])
+
+ sql = f"""
+ SELECT
+ v.voucher_guid, v.voucher_date, v.voucher_type, v.voucher_number,
+ v.party_ledger, v.narration, v.reference,
+ e.ledger_name, e.dr_cr, e.raw_amount, e.amount, e.raw_is_deemed_positive
+ FROM voucher v
+ JOIN voucher_ledger_entry e
+ ON e.company_instance_id=v.company_instance_id
+ AND e.voucher_guid=v.voucher_guid
+ WHERE v.voucher_date>=? AND v.voucher_date<=?
+ AND COALESCE(v.is_cancelled,0)=0
+ AND COALESCE(v.is_optional,0)=0
+ AND EXISTS (
+ SELECT 1
+ FROM voucher_ledger_entry ce
+ WHERE ce.company_instance_id=v.company_instance_id
+ AND ce.voucher_guid=v.voucher_guid
+ AND LOWER(ce.ledger_name) IN ({placeholders})
+ )
+ ORDER BY v.voucher_date, v.voucher_type, v.voucher_number, e.line_no
+ """
+ rows = db.execute(sql, params).fetchall()
+ finally:
+ db.close()
+
+ cash_keys = {x.casefold() for x in cash_ledger_names}
+ grouped: dict[str, dict[str, Any]] = {}
+ for row in rows:
+ guid = str(row["voucher_guid"] or "")
+ item = grouped.setdefault(guid, {
+ "date": str(row["voucher_date"] or ""),
+ "voucher_type": str(row["voucher_type"] or ""),
+ "voucher_number": str(row["voucher_number"] or ""),
+ "party": str(row["party_ledger"] or "").strip(),
+ "reference": str(row["reference"] or ""),
+ "narration": str(row["narration"] or ""),
+ "entries": [],
+ })
+ item["entries"].append({
+ "ledger_name": str(row["ledger_name"] or "").strip(),
+ "dr_cr": str(row["dr_cr"] or "").strip().upper(),
+ "raw_amount": float(row["raw_amount"] or 0),
+ "amount": float(row["amount"] or 0),
+ "is_deemed_positive": str(row["raw_is_deemed_positive"] or "").strip(),
+ })
+
+ payments: list[dict[str, Any]] = []
+ for voucher in grouped.values():
+ cash_credit: list[float] = []
+ for entry in voucher["entries"]:
+ if entry["ledger_name"].casefold() not in cash_keys:
+ continue
+ raw_amount = float(entry["raw_amount"] or 0)
+ deemed = str(entry["is_deemed_positive"] or "").strip().lower()
+ # Keep the sign interpretation used by the existing Cash Payment
+ # Compliance engine, while also respecting the mirror's DR/CR field.
+ if entry["dr_cr"] == "CR" or raw_amount > 0 or deemed == "no":
+ cash_credit.append(abs(raw_amount if raw_amount else float(entry["amount"] or 0)))
+ cash_amount = round(sum(cash_credit), 2)
+ if cash_amount <= 0:
+ continue
+
+ non_cash = [e for e in voucher["entries"] if e["ledger_name"].casefold() not in cash_keys]
+ party = str(voucher["party"] or "").strip()
+ if not party or party.casefold() in cash_keys:
+ candidates = sorted(
+ non_cash,
+ key=lambda e: abs(float(e["raw_amount"] or e["amount"] or 0)),
+ reverse=True,
+ )
+ party = candidates[0]["ledger_name"] if candidates else "Unidentified counter-ledger"
+
+ payments.append({
+ "date": voucher["date"],
+ "party": party or "Unidentified counter-ledger",
+ "amount": cash_amount,
+ "voucher_number": voucher["voucher_number"],
+ "voucher_type": voucher["voucher_type"],
+ "reference": voucher["reference"],
+ "narration": voucher["narration"],
+ })
+
+ by_party_date = defaultdict(list)
+ for item in payments:
+ by_party_date[(item["party"].casefold(), item["date"])].append(item)
+
+ exceptions: list[dict[str, Any]] = []
+ seen = set()
+ single_count = 0
+ same_day_count = 0
+
+ for item in payments:
+ if item["amount"] > cash_limit + 0.009:
+ single_count += 1
+ key = (item["party"].casefold(), item["date"], "single", item["voucher_number"])
+ if key not in seen:
+ seen.add(key)
+ exceptions.append({
+ "date": item["date"],
+ "party": item["party"],
+ "amount": item["amount"],
+ "reason": "Single cash-payment voucher exceeds configured limit",
+ "voucher_numbers": [item["voucher_number"] or "-"],
+ })
+
+ for (party_key, paid_on), party_rows in by_party_date.items():
+ total = round(sum(x["amount"] for x in party_rows), 2)
+ if total > cash_limit + 0.009 and len(party_rows) > 1:
+ same_day_count += 1
+ key = (party_key, paid_on, "aggregate")
+ if key not in seen:
+ seen.add(key)
+ exceptions.append({
+ "date": paid_on,
+ "party": party_rows[0]["party"],
+ "amount": total,
+ "reason": "Same-day aggregate cash payments to the same party exceed configured limit",
+ "voucher_numbers": [x["voucher_number"] or "-" for x in party_rows],
+ })
+
+ daily_by_party = defaultdict(lambda: defaultdict(float))
+ for item in payments:
+ daily_by_party[item["party"].casefold()][item["date"]] += item["amount"]
+
+ near_floor = cash_limit * near_limit_percent / 100.0
+ split_patterns: list[dict[str, Any]] = []
+ for party_key, day_map in daily_by_party.items():
+ day_rows = sorted(
+ (_date.fromisoformat(day), round(amount, 2))
+ for day, amount in day_map.items()
+ if day
+ )
+ for start_idx in range(len(day_rows)):
+ window = []
+ for idx in range(start_idx, len(day_rows)):
+ d, amount = day_rows[idx]
+ if (d - day_rows[start_idx][0]).days >= split_window_days:
+ break
+ window.append((d, amount))
+ if len(window) < 2 or any(amount > cash_limit + 0.009 for _, amount in window):
+ continue
+ total = round(sum(amount for _, amount in window), 2)
+ if total <= cash_limit + 0.009:
+ continue
+ if sum(1 for _, amount in window if amount >= near_floor) < 2:
+ continue
+ party_name = next(
+ (x["party"] for x in payments if x["party"].casefold() == party_key),
+ party_key,
+ )
+ signature = (party_key, window[0][0].isoformat(), window[-1][0].isoformat())
+ if any(
+ (r["party"].casefold(), r["date_from"], r["date_to"]) == signature
+ for r in split_patterns
+ ):
+ continue
+ split_patterns.append({
+ "party": party_name,
+ "date_from": window[0][0].isoformat(),
+ "date_to": window[-1][0].isoformat(),
+ "total_amount": total,
+ "days": [{"date": d.isoformat(), "amount": a} for d, a in window],
+ "review_only": True,
+ })
+
+ exceptions.sort(key=lambda x: (x["date"], x["party"].casefold()))
+ split_patterns.sort(key=lambda x: (x["date_from"], x["party"].casefold()))
+
+ return {
+ "cash_payment_review": {
+ "company_name": company_name,
+ "company_guid": company_guid,
+ "date_from": date_from,
+ "date_to": date_to,
+ "cash_limit": cash_limit,
+ "cash_ledgers": cash_ledger_names,
+ "summary": {
+ "vouchers_reviewed": total_vouchers,
+ "cash_payment_vouchers": len(payments),
+ "single_voucher_exceptions": single_count,
+ "same_day_exceptions": same_day_count,
+ "possible_split_patterns": len(split_patterns),
+ },
+ "exceptions": exceptions,
+ "possible_split_payments": split_patterns,
+ "cash_payments": payments,
+ "sqlite_mirror": True,
+ "sqlite_cache": False,
+ "mirror_db_path": str(mirror_status.get("mirror_db_path") or mirror.get("path") or ""),
+ "mirror_period": mirror.get("voucher_period") or {},
+ "tally_contacted": False,
+ "tally_request_count": 0,
+ "tally_retry_count": 0,
+ "tally_pause_ms": 0,
+ "read_only": True,
+ },
+ "mirror": mirror,
+ "agent": self._agent_info(),
+ }
+
def _cash_payment_cache_analyze(self, payload: dict[str, Any]) -> dict[str, Any]:
from collections import defaultdict
from datetime import date as _date