Add cash payment compliance and accounting tools
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user