diff --git a/app/modules/accounting/cash_payment_ui.py b/app/modules/accounting/cash_payment_ui.py
index 59210af..18791c3 100644
--- a/app/modules/accounting/cash_payment_ui.py
+++ b/app/modules/accounting/cash_payment_ui.py
@@ -252,7 +252,7 @@ def cash_payment_review(
@router.post("/ledgers")
async def cash_payment_ledgers(request: Request):
- """Load Cash-in-Hand ledgers directly; keep full-ledger sync only as manual fallback."""
+ """Load Cash ledgers using the same live Tally master snapshot path as Opening Balance Review."""
form = await request.form()
validate_csrf(request, str(form.get("csrf_token") or ""))
db = CommonSessionLocal()
@@ -287,23 +287,19 @@ async def cash_payment_ledgers(request: Request):
"tally_guid": tally_guid,
"company_name": company_name,
}
- if ledger_scope == "all":
- # Explicit manual fallback preserves the existing full-master async flow.
- payload["force_refresh"] = force_refresh
- result = request_agent_command(
- node.node_code,
- "accounting_cash_payment_ledgers_start",
- payload,
- timeout_seconds=20,
- )
- else:
- # Normal path: small filtered Tally collection, no full ledger cache.
- result = request_agent_command(
- node.node_code,
- "accounting_cash_payment_cash_ledgers",
- payload,
- timeout_seconds=30,
- )
+ # Use the same proven live Tally master-snapshot mechanism used by
+ # Opening Balance Review. The Local Agent resolves the selected open
+ # company, reads the standard Tally masters and returns either only
+ # Cash-in-Hand ledgers or all ledgers for explicit manual selection.
+ # No separate cash-ledger cache/job is required for this selector.
+ payload["ledger_scope"] = ledger_scope
+ payload["force_refresh"] = force_refresh
+ result = request_agent_command(
+ node.node_code,
+ "accounting_cash_payment_cash_ledgers",
+ payload,
+ timeout_seconds=180,
+ )
except Exception as exc:
return JSONResponse({"ok": False, "error": str(exc)}, status_code=502)
if not result.get("ok"):
diff --git a/app/modules/accounting/templates/accounting/cash_payment_review.html b/app/modules/accounting/templates/accounting/cash_payment_review.html
index 54ffa31..17aa98b 100644
--- a/app/modules/accounting/templates/accounting/cash_payment_review.html
+++ b/app/modules/accounting/templates/accounting/cash_payment_review.html
@@ -69,7 +69,7 @@
-
By default only ledgers classified under Cash-in-Hand are shown. Choose “All Tally ledgers” only when you need to manually select a ledger that Tally has grouped differently.
+
Uses the same live Tally master-reading mechanism as Opening Balance Review. By default only ledgers under Cash-in-Hand are shown; choose “All Tally ledgers” only for manual selection when required.
Preparing Cash ledger list…0%
@@ -311,8 +311,8 @@
if(forceRefresh) fd.set('force_refresh','1');
ledgerButton.disabled = true; if(ledgerRefreshButton) ledgerRefreshButton.disabled=true;
ledgerHelp.textContent = selectedScope === 'all'
- ? (forceRefresh ? 'Starting background full-ledger refresh from Tally…' : 'Loading the full ledger list for manual selection…')
- : 'Reading Cash-in-Hand ledgers directly from the selected Tally company…';
+ ? 'Reading all Tally ledgers using the proven master snapshot…'
+ : 'Reading Tally masters and selecting Cash-in-Hand ledgers…';
try {
const response = await fetch('/tools/accounting/cash-payments/ledgers', {method:'POST', body:fd, headers:{'Accept':'application/json'}});
const data = await response.json();
diff --git a/app/modules/documents/agent_package.py b/app/modules/documents/agent_package.py
index f357ed4..78fd769 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.22.18"
+ERP_LOCAL_AGENT_VERSION = "1.22.19"
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 7a903a5..94580b7 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.22.18"
+__version__ = "1.22.19"
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 e3d2630..7904cf5 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
@@ -177,6 +177,7 @@ class AgentCommandProcessor:
"cash_payment_async_ledger_master_capability": True,
"cash_payment_ledger_cache_capability": True,
"cash_payment_open_company_selection_capability": True,
+ "cash_payment_opening_balance_master_path_capability": True,
"opening_balance_balance_sheet_only_capability": True,
"tally_writeback_capability": True,
}
@@ -719,57 +720,115 @@ class AgentCommandProcessor:
def _cash_payment_cash_ledgers(self, payload: dict[str, Any]) -> dict[str, Any]:
- """Discover Cash-in-Hand ledgers directly from the selected open company.
+ """Return Cash-in-Hand ledgers using the proven Opening Balance master path.
- The normal Cash Payment workflow must not depend on a full Tally ledger-master
- export. This command uses a narrow Tally collection and returns only the
- Cash-in-Hand hierarchy. The existing full-ledger async cache remains available
- for the explicit manual-selection fallback.
+ Cash Payment no longer uses a separate Tally collection or a separate
+ ledger-master job merely to populate the selector. It deliberately reuses
+ the same open-company resolution and ``fetch_accounting_masters`` call used
+ by Opening Balance Review, then filters those already-returned masters.
+ The legacy async full-ledger cache functions remain untouched for backward
+ compatibility and for any older callers.
"""
- client_id = int(payload.get("client_id"))
- tally_guid = str(payload.get("tally_guid") or "").strip()
- if not tally_guid:
- raise ValueError("Open Tally company GUID is required.")
expected_company_name = str(payload.get("company_name") or "").strip()
- progress_id = "CASHDISC-" + uuid.uuid4().hex[:12].upper()
+ expected_guid = str(payload.get("tally_guid") or "").strip()
+ ledger_scope = str(payload.get("ledger_scope") or "cash").strip().lower()
+ if ledger_scope not in {"cash", "all"}:
+ ledger_scope = "cash"
+
+ progress_id = "CASHMASTER-" + uuid.uuid4().hex[:12].upper()
_cash_progress_snapshot({
- "job_id": progress_id, "job_kind": "cash_ledger_discovery",
- "status": "running", "stage": "Reading Cash-in-Hand ledgers from Tally",
- "percent": 25, "company_name": expected_company_name, "ledger_count": 0,
+ "job_id": progress_id,
+ "job_kind": "cash_ledger_discovery",
+ "status": "running",
+ "stage": "Reading Tally masters using Opening Balance mechanism",
+ "percent": 20,
+ "company_name": expected_company_name,
+ "ledger_count": 0,
})
try:
- company, company_name = self._resolve_open_company(payload)
- rows = self.tally.cash_in_hand_ledgers(company_name) or []
- ledgers = self._cash_ledger_rows(rows)
- # Every row returned by this filtered Tally collection belongs to the
- # Cash-in-Hand hierarchy; mark it as a candidate even when it sits below
- # a user-created child group rather than directly under Cash-in-Hand.
- for row in ledgers:
- row["is_cash_candidate"] = True
+ # This is intentionally the same company-resolution helper used by
+ # _opening_balance_snapshot(). Company name is the human selection;
+ # GUID is verified when supplied so another open company cannot leak in.
+ company = self._opening_balance_company(expected_company_name)
+ if expected_guid and company.guid and str(company.guid).strip() != expected_guid:
+ raise ValueError(
+ "Selected Tally company GUID does not match the currently loaded company. "
+ "Reload Tally companies and select the company again."
+ )
+
+ masters = self.tally.fetch_accounting_masters(company.name)
+ group_rows = list(masters.get("groups") or [])
+ ledger_rows = list(masters.get("ledgers") or [])
+
+ # Build the complete Cash-in-Hand descendant-group hierarchy from the
+ # same Group masters already returned by Tally. This supports both
+ # ledgers directly under Cash-in-Hand and user-created child groups.
+ cash_group_keys = {"cash-in-hand", "cash in hand"}
+ changed = True
+ while changed:
+ changed = False
+ for group in group_rows:
+ name = str(group.get("name") or "").strip()
+ parent = str(group.get("parent") or "").strip()
+ if not name:
+ continue
+ parent_key = parent.casefold().replace("-", " ").strip()
+ name_key = name.casefold().replace("-", " ").strip()
+ normalized_cash = {x.replace("-", " ") for x in cash_group_keys}
+ if parent_key in normalized_cash and name_key not in normalized_cash:
+ cash_group_keys.add(name_key)
+ changed = True
+
+ all_ledgers = self._cash_ledger_rows(ledger_rows)
+ cash_ledgers: list[dict[str, Any]] = []
+ normalized_groups = {x.casefold().replace("-", " ").strip() for x in cash_group_keys}
+ for row in all_ledgers:
+ parent_key = str(row.get("parent") or "").casefold().replace("-", " ").strip()
+ reserved = str(row.get("reserved_name") or "").strip().casefold()
+ name_key = str(row.get("name") or "").strip().casefold()
+ is_cash = parent_key in normalized_groups or reserved == "cash" or name_key == "cash"
+ row["is_cash_candidate"] = bool(is_cash)
+ if is_cash:
+ cash_ledgers.append(row)
+
+ all_ledgers.sort(key=lambda x: (not bool(x.get("is_cash_candidate")), str(x.get("name") or "").casefold()))
+ cash_ledgers.sort(key=lambda x: str(x.get("name") or "").casefold())
+ returned = all_ledgers if ledger_scope == "all" else cash_ledgers
+
_cash_progress_snapshot({
- "job_id": progress_id, "job_kind": "cash_ledger_discovery",
+ "job_id": progress_id,
+ "job_kind": "cash_ledger_discovery",
"status": "completed",
- "stage": f"{len(ledgers)} Cash-in-Hand ledger(s) ready",
- "percent": 100, "company_name": company_name, "ledger_count": len(ledgers),
+ "stage": f"{len(returned)} ledger(s) ready from Tally master snapshot",
+ "percent": 100,
+ "company_name": company.name,
+ "ledger_count": len(returned),
})
return {
"status": "completed",
- "company": {"guid": str(company.get("guid") or tally_guid), "name": company_name},
- "company_name": company_name,
- "ledgers": ledgers,
- "cash_candidates": ledgers,
- "ledger_count": len(ledgers),
- "cash_only": True,
+ "company": company.as_dict(),
+ "company_name": company.name,
+ "ledgers": returned,
+ "cash_candidates": cash_ledgers,
+ "ledger_count": len(returned),
+ "total_ledgers_examined": len(all_ledgers),
+ "cash_ledger_count": len(cash_ledgers),
+ "cash_only": ledger_scope != "all",
"from_cache": False,
+ "source": "opening_balance_master_snapshot",
"read_only": True,
"agent": self._agent_info(),
}
except Exception as exc:
_cash_progress_snapshot({
- "job_id": progress_id, "job_kind": "cash_ledger_discovery",
- "status": "failed", "stage": "Cash-in-Hand ledger discovery failed",
- "percent": 100, "company_name": expected_company_name,
- "ledger_count": 0, "error": str(exc),
+ "job_id": progress_id,
+ "job_kind": "cash_ledger_discovery",
+ "status": "failed",
+ "stage": "Tally master snapshot failed",
+ "percent": 100,
+ "company_name": expected_company_name,
+ "ledger_count": 0,
+ "error": str(exc),
})
raise