diff --git a/app/modules/accounting/cash_payment_ui.py b/app/modules/accounting/cash_payment_ui.py index 5a6db0c..80d15ae 100644 --- a/app/modules/accounting/cash_payment_ui.py +++ b/app/modules/accounting/cash_payment_ui.py @@ -153,6 +153,12 @@ def cash_payment_review( saved: int = 0, error: str = "", ): + """Cash Payment Compliance from the local Accounting Mirror only. + + TallyPrime is not contacted by this review route. The Local Agent is used + only to read the client's local .act mirror database through the existing + secure tunnel. Users refresh that mirror separately from /tools/tally. + """ db = CommonSessionLocal() try: user, response = _require_partner(request, db, "accounting.tally.view") @@ -170,58 +176,68 @@ def cash_payment_review( fy_start_year = int(fy.split("-", 1)[0]) start_text = date_from or date(fy_start_year, 4, 1).isoformat() end_text = date_to or today.isoformat() - live_result = None + review = None - cache_job = None + mirror_status = None command_error = error or "" - if selected_client and node and online: - try: - status_response = request_agent_command( - node.node_code, - "phase4_status", - _accounting_storage_payload(selected_client, _financial_year_for_date(date.fromisoformat(start_text))), - timeout_seconds=20, + if selected_client: + if not node or not online: + command_error = command_error or ( + "ERP Local Agent is offline. Cash Payment Review reads the client's local " + "SQLite Accounting Mirror, so the storage agent must be online." ) - 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.") - - if job_id: - status = request_agent_command( + else: + storage_payload = _accounting_storage_payload( + selected_client, + _financial_year_for_date(date.fromisoformat(start_text)), + ) + try: + status_response = request_agent_command( node.node_code, - "accounting_cash_payment_cache_status", - { - **_accounting_storage_payload(selected_client, _financial_year_for_date(date.fromisoformat(start_text))), - "job_id": str(job_id), - }, + "accounting_mirror_status", + storage_payload, timeout_seconds=20, ) - if status.get("ok"): - cache_job = (status.get("result") or {}).get("job") + if status_response.get("ok"): + mirror_status = status_response.get("result") or {} else: - command_error = str(status.get("error") or "Could not read cash-payment cache status.") + command_error = str( + status_response.get("error") or "Could not read the local Accounting Mirror status." + ) + except Exception as exc: + command_error = str(exc) - if analyze and job_id and cache_job and cache_job.get("status") == "completed": - result = request_agent_command( - node.node_code, - "accounting_cash_payment_cache_analyze", - { - **_accounting_storage_payload(selected_client, _financial_year_for_date(date.fromisoformat(start_text))), - "job_id": str(job_id), - "cash_limit": float(setting.cash_limit), - "split_window_days": int(setting.split_window_days), - "near_limit_percent": float(setting.near_limit_percent), - }, - timeout_seconds=120, - ) - if result.get("ok"): - review = (result.get("result") or {}).get("cash_payment_review") - else: - command_error = str(result.get("error") or "Cash payment SQLite analysis failed.") - except Exception as exc: - command_error = str(exc) + if analyze and not command_error: + try: + result = request_agent_command( + node.node_code, + "accounting_cash_payment_mirror_analyze", + { + **storage_payload, + "date_from": start_text, + "date_to": end_text, + "cash_ledger_name": str(cash_ledger_name or "").strip(), + "cash_limit": float(setting.cash_limit), + "split_window_days": int(setting.split_window_days), + "near_limit_percent": float(setting.near_limit_percent), + "requested_by_user_id": int(user.id), + }, + timeout_seconds=120, + ) + if result.get("ok"): + body = result.get("result") or {} + review = body.get("cash_payment_review") + mirror_status = { + **(mirror_status or {}), + "mirror": body.get("mirror") or (mirror_status or {}).get("mirror") or {}, + } + else: + command_error = str( + result.get("error") or "Cash payment SQLite mirror analysis failed." + ) + except Exception as exc: + command_error = str(exc) return _render( request, @@ -232,14 +248,15 @@ def cash_payment_review( selected_client=selected_client, storage_node=node, agent_online=online, - live_result=live_result, + live_result=None, + mirror_status=mirror_status, selected_tally_guid=str(tally_guid or ""), date_from=start_text, date_to=end_text, setting=setting, review=review, - cache_job=cache_job, - active_job_id=str(job_id or ""), + cache_job=None, + active_job_id="", selected_cash_ledger_name=str(cash_ledger_name or ""), saved=bool(saved), command_error=command_error, diff --git a/app/modules/accounting/templates/accounting/cash_payment_review.html b/app/modules/accounting/templates/accounting/cash_payment_review.html index b5a49dc..b4bc038 100644 --- a/app/modules/accounting/templates/accounting/cash_payment_review.html +++ b/app/modules/accounting/templates/accounting/cash_payment_review.html @@ -3,15 +3,15 @@
-

Tools · Accounting · Tally

+

Tools · Accounting · SQLite Mirror

{{ 'Cash Payment Entry Allocation' if page_mode == 'allocation' else 'Cash Payment Compliance Review' }}

{% if page_mode == 'allocation' %}

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.

{% endif %}
- Back to Tally + Back to Tally Mirror
{% if saved %}
Cash payment rule settings saved.
{% endif %} @@ -20,9 +20,20 @@ {% if page_mode != 'allocation' %}
-

Cash Payment Compliance Review

-
- +
+
+

Review from Accounting Mirror

+

No Tally connection, company selection, extraction queue or day-by-day cache is used here.

+
+ {% set mirror = (mirror_status.mirror if mirror_status and mirror_status.mirror else {}) %} + {% if mirror.ready %} + SQLite Mirror Ready + {% else %} + Mirror Required + {% endif %} +
+ +
-
- - -
-
- - - - - - -
- - -
- -

Load Cash Ledgers reads the common master from the client .act SQLite database only. Refresh Master Data explicitly refreshes that common master from TallyPrime.

- -
-
1. Local Master
.act SQLite
-
2. Cash Filter
Cash-in-Hand
-
3. Transfer to VPS
Tunnel result
-
4. Ledger Ready
Dropdown
-
-
Master source: local .act SQLite. Normal loading never queries TallyPrime.
-
-

Select the Tally company and Cash ledger from the local .act master snapshot. Only the selected ledger’s relevant transactions are then extracted from Tally into SQLite; compliance analysis runs from SQLite. Existing threshold rules, allocation workflow and dual VPS/Local Agent progress are preserved.

+
+ +
+ + {% if selected_client %} +
+
+
Mirror Company
+
{{ 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 %}
@@ -106,25 +89,13 @@
- {% if cache_job %} -
-
-
-

Local Tally SQLite Cache

-

Status: {{ cache_job.status }}{% if cache_job.stage %} · {{ cache_job.stage }}{% endif %}

-
-
{{ cache_job.completed_days }}/{{ cache_job.total_days }} day(s)
{{ cache_job.vouchers_cached }} Cash-ledger voucher(s)
{% if cache_job.cash_ledgers %}
Ledger: {{ cache_job.cash_ledgers|join(', ') }}
{% endif %}
-
-
- {% if cache_job.error %}

{{ cache_job.error }}

{% 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)] %}
{{ label }}
{{ value }}
@@ -147,8 +118,6 @@
{% endif %} - - {% endif %} {% if page_mode == 'allocation' %} @@ -177,394 +146,5 @@ {% endif %} {% endif %} - - -
{% 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