From a71a26e21668a486d004d8d471c17e90c09d2f02 Mon Sep 17 00:00:00 2001 From: A R R R Associates Date: Thu, 3 Sep 2026 21:32:43 +0530 Subject: [PATCH] Add cash payment compliance and accounting tools --- app/modules/accounting/cash_payment_models.py | 26 ++ app/modules/accounting/cash_payment_ui.py | 353 ++++++++++++++++++ .../accounting/cash_payment_review.html | 109 ++++++ .../templates/accounting/tally.html | 1 + app/modules/documents/agent_package.py | 2 +- .../erp_local_agent/__init__.py | 2 +- .../erp_local_agent/commands.py | 163 ++++++++ app/modules/services/task_tools.py | 6 + app/ui/app.py | 2 + 9 files changed, 662 insertions(+), 2 deletions(-) create mode 100644 app/modules/accounting/cash_payment_models.py create mode 100644 app/modules/accounting/cash_payment_ui.py create mode 100644 app/modules/accounting/templates/accounting/cash_payment_review.html diff --git a/app/modules/accounting/cash_payment_models.py b/app/modules/accounting/cash_payment_models.py new file mode 100644 index 0000000..6c5a409 --- /dev/null +++ b/app/modules/accounting/cash_payment_models.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from datetime import date, datetime + +from sqlalchemy import Date, DateTime, Float, ForeignKey, Integer, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.db.common import CommonBase + + +class CashPaymentRuleSetting(CommonBase): + __tablename__ = "accounting_cash_payment_rule_settings" + __table_args__ = ( + UniqueConstraint("tenant_id", name="uq_cash_payment_rule_settings_tenant"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), index=True) + cash_limit: Mapped[float] = mapped_column(Float, default=10000.0) + effective_from: Mapped[date] = mapped_column(Date, default=date(2017, 4, 1)) + split_window_days: Mapped[int] = mapped_column(Integer, default=3) + near_limit_percent: Mapped[float] = mapped_column(Float, default=80.0) + max_allocation_days: Mapped[int] = mapped_column(Integer, default=5) + updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at_utc: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at_utc: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) diff --git a/app/modules/accounting/cash_payment_ui.py b/app/modules/accounting/cash_payment_ui.py new file mode 100644 index 0000000..b23a755 --- /dev/null +++ b/app/modules/accounting/cash_payment_ui.py @@ -0,0 +1,353 @@ +from __future__ import annotations + +from datetime import date, datetime +from decimal import Decimal, InvalidOperation +from urllib.parse import quote + +from fastapi import APIRouter, Request +from fastapi.responses import RedirectResponse +from sqlalchemy import select + +from app.core.db.common import CommonSessionLocal +from app.core.security.csrf import get_or_create_csrf_token, validate_csrf +from app.core.security.session_auth import get_current_user +from app.core.templating import templates +from app.modules.accounting.agent_bridge import request_agent_command +from app.modules.accounting.cash_payment_models import CashPaymentRuleSetting +from app.modules.accounting.ui import ( + _accounting_storage_payload, + _denied, + _find_visible_client, + _financial_year_for_date, + _node_online, + _require_partner, + _visible_clients, +) +from app.modules.core.audit.service import write_audit_log +from app.modules.core.rbac.deps import get_user_permissions, get_user_roles +from app.modules.documents.services import get_active_storage_node_for_branch + + +router = APIRouter(prefix="/tools/accounting/cash-payments", tags=["accounting-cash-payment-ui"]) + + +def _tenant_id(request: Request, user) -> int: + return int(request.session.get("active_tenant_id") or getattr(user, "tenant_id", 0) or 0) + + +def _setting(db, tenant_id: int) -> CashPaymentRuleSetting: + row = db.execute( + select(CashPaymentRuleSetting).where(CashPaymentRuleSetting.tenant_id == int(tenant_id)) + ).scalar_one_or_none() + if row: + return row + row = CashPaymentRuleSetting( + tenant_id=int(tenant_id), + cash_limit=10000.0, + effective_from=date(2017, 4, 1), + split_window_days=3, + near_limit_percent=80.0, + max_allocation_days=5, + ) + db.add(row) + db.commit() + db.refresh(row) + return row + + +def _parse_money(value: str, label: str) -> float: + try: + amount = Decimal(str(value or "").replace(",", "").strip()) + except (InvalidOperation, ValueError): + raise ValueError(f"{label} must be a valid amount.") + if amount <= 0: + raise ValueError(f"{label} must be greater than zero.") + return float(amount.quantize(Decimal("0.01"))) + + +def _allocation_plan(source_text: str, expected_total: float, cash_limit: float, max_days: int) -> dict: + entries = [] + errors = [] + for line_no, raw in enumerate((source_text or "").splitlines(), start=1): + text = raw.strip() + if not text: + continue + parts = [part.strip() for part in text.split(",")] + if len(parts) != 2: + errors.append(f"Line {line_no}: use YYYY-MM-DD, amount.") + continue + try: + paid_on = date.fromisoformat(parts[0]) + amount = _parse_money(parts[1], f"Line {line_no} amount") + except Exception as exc: + errors.append(str(exc)) + continue + entries.append({"date": paid_on.isoformat(), "amount": amount}) + + supplied_total = round(sum(row["amount"] for row in entries), 2) + by_date: dict[str, float] = {} + for row in entries: + by_date[row["date"]] = round(by_date.get(row["date"], 0.0) + row["amount"], 2) + + above_limit_dates = [ + {"date": key, "amount": value} + for key, value in sorted(by_date.items()) + if value > float(cash_limit) + 0.009 + ] + dates = sorted(date.fromisoformat(key) for key in by_date) + span_days = ((dates[-1] - dates[0]).days + 1) if dates else 0 + + if abs(supplied_total - round(float(expected_total), 2)) > 0.009: + errors.append( + f"Source-date total is {supplied_total:.2f}, but the accounting amount is {float(expected_total):.2f}." + ) + if above_limit_dates: + errors.append("One or more actual payment dates exceed the configured daily review limit.") + if span_days > int(max_days): + errors.append( + f"Actual source dates span {span_days} days, exceeding the configured {int(max_days)}-day allocation window." + ) + + return { + "entries": entries, + "by_date": [{"date": key, "amount": value} for key, value in sorted(by_date.items())], + "expected_total": round(float(expected_total), 2), + "supplied_total": supplied_total, + "cash_limit": round(float(cash_limit), 2), + "span_days": span_days, + "max_allocation_days": int(max_days), + "above_limit_dates": above_limit_dates, + "errors": errors, + "status": "ready" if entries and not errors else "manual_review", + "source_dates_only": True, + "note": "The planner never creates or changes payment dates. Proposed entries use only the actual source dates entered by the user.", + } + + +def _render(request: Request, db, user, **context): + base = { + "request": request, + "current_user": user, + "current_user_roles": get_user_roles(db, user.id), + "current_user_permissions": get_user_permissions(db, user.id), + "csrf_token": get_or_create_csrf_token(request), + } + base.update(context) + return templates.TemplateResponse( + "modules/accounting/templates/accounting/cash_payment_review.html", + base, + ) + + +@router.get("") +def cash_payment_review( + request: Request, + client_id: int | None = None, + tally_guid: str = "", + date_from: str = "", + date_to: str = "", + run: int = 0, + saved: int = 0, + error: str = "", +): + db = CommonSessionLocal() + try: + user, response = _require_partner(request, db, "accounting.tally.view") + if response: + return response + clients, scope = _visible_clients(db, request, user) + selected_client = next((row for row in clients if client_id and int(row.id) == int(client_id)), None) + tenant_id = _tenant_id(request, user) + setting = _setting(db, tenant_id) + node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id) + online = _node_online(node) + + today = date.today() + fy = _financial_year_for_date(today) + 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 + 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 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 run and tally_guid: + result = request_agent_command( + node.node_code, + "accounting_cash_payment_compliance", + { + **_accounting_storage_payload(selected_client, _financial_year_for_date(date.fromisoformat(start_text))), + "tally_guid": str(tally_guid), + "date_from": start_text, + "date_to": end_text, + "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 review failed.") + except Exception as exc: + command_error = str(exc) + + return _render( + request, + db, + user, + title="Cash Payment Compliance Review", + clients=clients, + selected_client=selected_client, + storage_node=node, + agent_online=online, + live_result=live_result, + selected_tally_guid=str(tally_guid or ""), + date_from=start_text, + date_to=end_text, + setting=setting, + review=review, + saved=bool(saved), + command_error=command_error, + allocation_plan=None, + allocation_input={"total_amount": "", "party_name": "", "expense_ledger": "", "source_payments": ""}, + ) + finally: + db.close() + + +@router.post("/settings") +async def save_cash_payment_settings(request: Request): + form = await request.form() + validate_csrf(request, str(form.get("csrf_token") or "")) + db = CommonSessionLocal() + try: + user, response = _require_partner(request, db, "accounting.tally.view") + if response: + return response + tenant_id = _tenant_id(request, user) + setting = _setting(db, tenant_id) + old = { + "cash_limit": setting.cash_limit, + "effective_from": setting.effective_from.isoformat() if setting.effective_from else None, + "split_window_days": setting.split_window_days, + "near_limit_percent": setting.near_limit_percent, + "max_allocation_days": setting.max_allocation_days, + } + try: + cash_limit = _parse_money(str(form.get("cash_limit") or ""), "Cash payment limit") + effective_from = date.fromisoformat(str(form.get("effective_from") or "")) + split_window_days = int(form.get("split_window_days") or 3) + near_limit_percent = float(form.get("near_limit_percent") or 80) + max_allocation_days = int(form.get("max_allocation_days") or 5) + 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.") + if not 1 <= max_allocation_days <= 366: + raise ValueError("Maximum source-date allocation window must be between 1 and 366 days.") + except Exception as exc: + return RedirectResponse(url="/tools/accounting/cash-payments?error=" + quote(str(exc)), status_code=303) + setting.cash_limit = cash_limit + setting.effective_from = effective_from + setting.split_window_days = split_window_days + setting.near_limit_percent = near_limit_percent + setting.max_allocation_days = max_allocation_days + setting.updated_by_user_id = int(user.id) + setting.updated_at_utc = datetime.utcnow() + db.commit() + try: + write_audit_log( + db, + actor=user, + request=request, + action="accounting.cash_payment_rule.updated", + entity_type="CashPaymentRuleSetting", + entity_id=int(setting.id), + entity_name="Cash Payment Compliance Settings", + target_tenant_id=int(tenant_id), + details={ + "before": old, + "after": { + "cash_limit": setting.cash_limit, + "effective_from": setting.effective_from.isoformat(), + "split_window_days": setting.split_window_days, + "near_limit_percent": setting.near_limit_percent, + "max_allocation_days": setting.max_allocation_days, + }, + }, + ) + except Exception: + db.rollback() + client_id = str(form.get("client_id") or "").strip() + suffix = f"&client_id={client_id}" if client_id else "" + return RedirectResponse(url=f"/tools/accounting/cash-payments?saved=1{suffix}", status_code=303) + finally: + db.close() + + +@router.post("/allocation-plan") +async def cash_payment_allocation_plan(request: Request): + form = await request.form() + validate_csrf(request, str(form.get("csrf_token") or "")) + db = CommonSessionLocal() + try: + user, response = _require_partner(request, db, "accounting.tally.view") + if response: + return response + clients, scope = _visible_clients(db, request, user) + client_id = int(form.get("client_id") or 0) + selected_client = next((row for row in clients if int(row.id) == client_id), None) + if not selected_client: + return _denied() + setting = _setting(db, _tenant_id(request, user)) + total_amount_text = str(form.get("total_amount") or "") + party_name = str(form.get("party_name") or "").strip() + expense_ledger = str(form.get("expense_ledger") or "").strip() + source_payments = str(form.get("source_payments") or "") + try: + total_amount = _parse_money(total_amount_text, "Total cash payment") + plan = _allocation_plan(source_payments, total_amount, float(setting.cash_limit), int(setting.max_allocation_days)) + except Exception as exc: + plan = {"entries": [], "errors": [str(exc)], "status": "manual_review", "source_dates_only": True} + node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id) + return _render( + request, + db, + user, + title="Cash Payment Compliance Review", + clients=clients, + selected_client=selected_client, + storage_node=node, + agent_online=_node_online(node), + live_result=None, + selected_tally_guid="", + date_from="", + date_to="", + setting=setting, + review=None, + saved=False, + command_error="", + allocation_plan=plan, + allocation_input={ + "total_amount": total_amount_text, + "party_name": party_name, + "expense_ledger": expense_ledger, + "source_payments": source_payments, + }, + ) + finally: + db.close() diff --git a/app/modules/accounting/templates/accounting/cash_payment_review.html b/app/modules/accounting/templates/accounting/cash_payment_review.html new file mode 100644 index 0000000..e7d22f2 --- /dev/null +++ b/app/modules/accounting/templates/accounting/cash_payment_review.html @@ -0,0 +1,109 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Tools · Accounting · Tally

+

Cash Payment Compliance Review

+

Read-only Tally review for cash payments above the configured limit, same-day party aggregation and possible split-payment patterns. The accounting planner below uses only actual source payment dates supplied by the user; it never invents or changes dates.

+
+ Back to Tally +
+ + {% if saved %}
Cash payment rule settings saved.
{% endif %} + {% if command_error %}
{{ command_error }}
{% endif %} + +
+
+

Tax Payment Compliance Review

+
+
+ + +
+
+ + +
+
+
+ +
+
+
+ +
+

Rule Settings

+
+ +
+
+
+
+
+ +
+
+
+ + {% if review %} +
+
+ {% 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 }}
+ {% endfor %} +
+ +
+

Above-limit / same-day aggregate exceptions

+ + {% for row in review.exceptions %}{% else %}{% endfor %} +
DateParty / ledgerAmountReasonVoucher(s)
{{ row.date }}{{ row.party }}₹{{ '%.2f'|format(row.amount) }}{{ row.reason }}{{ row.voucher_numbers|join(', ') }}
No above-limit exception found for the selected period.
+
+ +
+

Possible split-payment patterns — review only

+

These rows are indicators for auditor review; they do not by themselves determine tax treatment.

+ + {% for row in review.possible_split_payments %}{% else %}{% endfor %} +
Party / ledgerPeriodTotalDaily amounts
{{ row.party }}{{ row.date_from }} → {{ row.date_to }}₹{{ '%.2f'|format(row.total_amount) }}{% for item in row.days %}{{ item.date }}: ₹{{ '%.2f'|format(item.amount) }}{% if not loop.last %}
{% endif %}{% endfor %}
No near-limit multi-day pattern found.
+
+
+ {% endif %} + +
+

Cash Payment Entry Allocation — Actual Source Dates

+

Use this when the source records establish that one accounting amount was actually paid on multiple dates. Enter the real dates and amounts. The tool validates totals, daily limits and the configured date window; it does not fabricate dates or automatically rewrite Tally vouchers.

+
+ +
+
+
+
+
+
+
+ + {% if allocation_plan %} +
+
{{ 'Ready for accounting review' if allocation_plan.status == 'ready' else 'Manual review required' }}
+ {% if allocation_plan.errors %}
    {% for item in allocation_plan.errors %}
  • {{ item }}
  • {% endfor %}
{% endif %} +
Expected total: ₹{{ '%.2f'|format(allocation_plan.expected_total or 0) }} · Source total: ₹{{ '%.2f'|format(allocation_plan.supplied_total or 0) }} · Date span: {{ allocation_plan.span_days or 0 }} day(s) / {{ allocation_plan.max_allocation_days or setting.max_allocation_days }} allowed.
+ {% if allocation_plan.entries %}{% for row in allocation_plan.entries %}{% endfor %}
Actual DateProposed Entry Amount
{{ row.date }}₹{{ '%.2f'|format(row.amount) }}
{% endif %} +

{{ allocation_plan.note or 'No accounting dates are generated automatically.' }}

+
+ {% endif %} +
+
+{% endblock %} diff --git a/app/modules/accounting/templates/accounting/tally.html b/app/modules/accounting/templates/accounting/tally.html index dea0f5a..2dae43c 100644 --- a/app/modules/accounting/templates/accounting/tally.html +++ b/app/modules/accounting/templates/accounting/tally.html @@ -46,6 +46,7 @@ Bank Expenses / Payments Bank Reconciliation Depreciation (IT) + Cash Payment Compliance Accounting AI Internal Model diff --git a/app/modules/documents/agent_package.py b/app/modules/documents/agent_package.py index df3fa4e..5199fb6 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.3" +ERP_LOCAL_AGENT_VERSION = "1.22.4" 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 c5b1063..eea0808 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.3" +__version__ = "1.22.4" 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 f2b7c8e..302e12e 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 @@ -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) diff --git a/app/modules/services/task_tools.py b/app/modules/services/task_tools.py index 13e2622..8e61be4 100644 --- a/app/modules/services/task_tools.py +++ b/app/modules/services/task_tools.py @@ -31,7 +31,13 @@ _TASK_TOOLS: tuple[TaskToolDefinition, ...] = ( name="Depreciation (IT)", description="Launch the existing Income-tax depreciation workspace connected to Tally.", launch_url="/tools/tally/depreciation", + ), TaskToolDefinition( + code="ACCOUNTING_CASH_PAYMENT_COMPLIANCE", + name="Cash Payment Tax Compliance Review", + description="Review Tally cash payments against the configurable limit, same-day aggregation and possible split-payment patterns.", + launch_url="/tools/accounting/cash-payments", ), + ) diff --git a/app/ui/app.py b/app/ui/app.py index 55ded2d..a4c809e 100644 --- a/app/ui/app.py +++ b/app/ui/app.py @@ -59,6 +59,7 @@ from app.modules.accounting.stock_ui import router as accounting_stock_ui_router from app.modules.accounting.opening_balance_ui import router as accounting_opening_balance_ui_router from app.modules.accounting.automatic_voucher_ui import router as accounting_automatic_voucher_ui_router from app.modules.accounting.master_integrity_ui import router as accounting_master_integrity_ui_router +from app.modules.accounting.cash_payment_ui import router as accounting_cash_payment_ui_router from app.modules.registrations.ui import router as registrations_ui_router from app.modules.credential_vault.ui import router as credential_vault_ui_router from app.modules.client_identity.ui import router as client_identity_ui_router @@ -102,6 +103,7 @@ def mount_ui(app: FastAPI) -> None: app.include_router(accounting_opening_balance_ui_router) app.include_router(accounting_automatic_voucher_ui_router) app.include_router(accounting_master_integrity_ui_router) + app.include_router(accounting_cash_payment_ui_router) app.include_router(work_tracker_ui_router) app.include_router(billing_ui_router) app.include_router(platform_billing_ui_router)