Add cash payment compliance and accounting tools

This commit is contained in:
A R R R Associates
2026-09-03 21:32:43 +05:30
parent 657a56712b
commit a71a26e216
9 changed files with 662 additions and 2 deletions
@@ -1,2 +1,2 @@
__version__ = "1.22.3"
__version__ = "1.22.4"
AGENT_NAME = "ERP Local Agent"
@@ -56,6 +56,8 @@ class AgentCommandProcessor:
result = self._bank_posting_preflight(payload)
elif action == "accounting_bank_reconciliation_extract":
result = self._bank_reconciliation_extract(payload)
elif action == "accounting_cash_payment_compliance":
result = self._cash_payment_compliance(payload)
elif action == "accounting_post_bank_voucher":
result = self._post_bank_voucher(payload)
elif action == "accounting_native_voucher_preflight":
@@ -97,6 +99,7 @@ class AgentCommandProcessor:
"purchase_posting_preflight_capability": True,
"purchase_voucher_write_capability": True,
"it_depreciation_capability": True,
"cash_payment_compliance_capability": True,
"tally_writeback_capability": True,
}
@@ -636,6 +639,166 @@ class AgentCommandProcessor:
"agent": self._agent_info(),
}
def _cash_payment_compliance(self, payload: dict[str, Any]) -> dict[str, Any]:
from collections import defaultdict
from datetime import date as _date
company, company_name = self._resolve_open_company(payload)
date_from = str(payload.get("date_from") or "").strip()
date_to = str(payload.get("date_to") or "").strip()
cash_limit = round(float(payload.get("cash_limit") or 10000.0), 2)
split_window_days = int(payload.get("split_window_days") or 3)
near_limit_percent = float(payload.get("near_limit_percent") or 80.0)
if cash_limit <= 0:
raise ValueError("Cash payment limit must be greater than zero.")
if not 1 <= split_window_days <= 31:
raise ValueError("Split-payment review window must be between 1 and 31 days.")
if not 1 <= near_limit_percent <= 100:
raise ValueError("Near-limit percentage must be between 1 and 100.")
ledger_masters = self.tally.export_master_collection(company_name, "ledgers")
cash_ledgers = set()
for ledger in ledger_masters:
name = str(ledger.get("name") or "").strip()
parent = str(ledger.get("parent") or "").strip().casefold().replace("-", " ")
reserved = str(ledger.get("reserved_name") or "").strip().casefold()
if name and (parent in {"cash in hand", "cash-in-hand"} or reserved == "cash" or name.casefold() == "cash"):
cash_ledgers.add(name.casefold())
if not cash_ledgers:
cash_ledgers.add("cash")
vouchers = self.tally.export_vouchers(company_name, date_from, date_to)
payments = []
for voucher in vouchers:
if str(voucher.get("is_cancelled") or "").strip().lower() in {"yes", "true", "1"}:
continue
if str(voucher.get("is_optional") or "").strip().lower() in {"yes", "true", "1"}:
continue
entries = list(voucher.get("ledger_entries") or [])
cash_credit = []
for entry in entries:
lname = str(entry.get("ledger_name") or "").strip()
if lname.casefold() not in cash_ledgers:
continue
amount = float(entry.get("amount") or 0)
deemed = str(entry.get("is_deemed_positive") or "").strip().lower()
if amount > 0 or deemed == "no":
cash_credit.append(abs(amount))
cash_amount = round(sum(cash_credit), 2)
if cash_amount <= 0:
continue
non_cash = [entry for entry in entries if str(entry.get("ledger_name") or "").strip().casefold() not in cash_ledgers]
party = str(voucher.get("party_ledger_name") or "").strip()
if not party or party.casefold() in cash_ledgers:
candidates = sorted(non_cash, key=lambda row: abs(float(row.get("amount") or 0)), reverse=True)
party = str(candidates[0].get("ledger_name") or "").strip() if candidates else "Unidentified counter-ledger"
payments.append({
"date": str(voucher.get("date") or voucher.get("effective_date") or ""),
"party": party or "Unidentified counter-ledger",
"amount": cash_amount,
"voucher_number": str(voucher.get("voucher_number") or ""),
"voucher_type": str(voucher.get("voucher_type_name") or ""),
"reference": str(voucher.get("reference") or ""),
"narration": str(voucher.get("narration") or ""),
})
by_party_date = defaultdict(list)
for row in payments:
by_party_date[(row["party"].casefold(), row["date"])].append(row)
exceptions = []
seen = set()
single_count = 0
same_day_count = 0
for row in payments:
if row["amount"] > cash_limit + 0.009:
single_count += 1
key = (row["party"].casefold(), row["date"], "single", row["voucher_number"])
if key not in seen:
seen.add(key)
exceptions.append({
"date": row["date"], "party": row["party"], "amount": row["amount"],
"reason": "Single cash-payment voucher exceeds configured limit",
"voucher_numbers": [row["voucher_number"] or "-"],
})
for (_party_key, paid_on), rows in by_party_date.items():
total = round(sum(row["amount"] for row in rows), 2)
if total > cash_limit + 0.009 and len(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": rows[0]["party"], "amount": total,
"reason": "Same-day aggregate cash payments to the same party exceed configured limit",
"voucher_numbers": [row["voucher_number"] or "-" for row in rows],
})
daily_by_party = defaultdict(lambda: defaultdict(float))
for row in payments:
daily_by_party[row["party"].casefold()][row["date"]] += row["amount"]
near_floor = cash_limit * near_limit_percent / 100.0
split_patterns = []
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)):
day_value, amount = day_rows[idx]
if (day_value - day_rows[start_idx][0]).days >= split_window_days:
break
window.append((day_value, amount))
if len(window) < 2:
continue
if 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
near_days = sum(1 for _, amount in window if amount >= near_floor)
if near_days < 2:
continue
party_name = next((row["party"] for row in payments if row["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 row: (row["date"], row["party"].casefold()))
split_patterns.sort(key=lambda row: (row["date_from"], row["party"].casefold()))
return {
"cash_payment_review": {
"company_name": company_name,
"company_guid": str(company.get("guid") or ""),
"date_from": date_from,
"date_to": date_to,
"cash_limit": cash_limit,
"cash_ledgers": sorted(cash_ledgers),
"summary": {
"vouchers_reviewed": len(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,
"read_only": True,
"review_note": "Possible split-payment patterns are review indicators only and do not determine tax treatment.",
},
"agent": self._agent_info(),
}
def _bank_posting_preflight(self, payload: dict[str, Any]) -> dict[str, Any]:
company, company_name = self._resolve_open_company(payload)
ledgers = self.tally.bank_posting_ledgers(company_name)