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
@@ -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)
+353
View File
@@ -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()
@@ -0,0 +1,109 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-brand-600">Tools · Accounting · Tally</p>
<h1 class="mt-1 text-2xl font-semibold text-slate-900">Cash Payment Compliance Review</h1>
<p class="mt-1 max-w-4xl text-sm text-slate-600">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.</p>
</div>
<a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700">Back to Tally</a>
</div>
{% if saved %}<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">Cash payment rule settings saved.</div>{% endif %}
{% if command_error %}<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">{{ command_error }}</div>{% endif %}
<section class="grid gap-4 lg:grid-cols-3">
<div class="rounded-2xl bg-white p-5 shadow-soft lg:col-span-2">
<h2 class="text-lg font-semibold text-slate-900">Tax Payment Compliance Review</h2>
<form method="get" action="/tools/accounting/cash-payments" class="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div>
<label class="text-xs font-semibold uppercase text-slate-500">Client</label>
<select name="client_id" class="mt-1 w-full rounded-xl border-slate-300 text-sm" required onchange="this.form.submit()">
<option value="">Select client</option>
{% for client in clients %}<option value="{{ client.id }}" {% if selected_client and selected_client.id == client.id %}selected{% endif %}>{{ client.client_name }}</option>{% endfor %}
</select>
</div>
<div>
<label class="text-xs font-semibold uppercase text-slate-500">Tally Company</label>
<select name="tally_guid" class="mt-1 w-full rounded-xl border-slate-300 text-sm" {% if selected_client %}required{% endif %}>
<option value="">Select open/mapped company</option>
{% if live_result and live_result.accounting %}
{% for mapping in live_result.accounting.mappings or [] %}
<option value="{{ mapping.tally_guid }}" {% if selected_tally_guid == mapping.tally_guid %}selected{% endif %}>{{ mapping.company_name or mapping.tally_company_name or mapping.tally_guid }}</option>
{% endfor %}
{% endif %}
</select>
</div>
<div><label class="text-xs font-semibold uppercase text-slate-500">From</label><input type="date" name="date_from" value="{{ date_from }}" class="mt-1 w-full rounded-xl border-slate-300 text-sm" required></div>
<div><label class="text-xs font-semibold uppercase text-slate-500">To</label><input type="date" name="date_to" value="{{ date_to }}" class="mt-1 w-full rounded-xl border-slate-300 text-sm" required></div>
<input type="hidden" name="run" value="1">
<div class="md:col-span-2 xl:col-span-4"><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white" {% if not selected_client %}disabled{% endif %}>Run Tally Review</button></div>
</form>
</div>
<div class="rounded-2xl bg-white p-5 shadow-soft">
<h2 class="text-lg font-semibold text-slate-900">Rule Settings</h2>
<form method="post" action="/tools/accounting/cash-payments/settings" class="mt-4 space-y-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id if selected_client else '' }}">
<div><label class="text-xs font-semibold uppercase text-slate-500">Cash payment limit</label><input type="number" min="1" step="0.01" name="cash_limit" value="{{ '%.2f'|format(setting.cash_limit) }}" class="mt-1 w-full rounded-xl border-slate-300 text-sm" required></div>
<div><label class="text-xs font-semibold uppercase text-slate-500">Effective from</label><input type="date" name="effective_from" value="{{ setting.effective_from.isoformat() }}" class="mt-1 w-full rounded-xl border-slate-300 text-sm" required></div>
<div><label class="text-xs font-semibold uppercase text-slate-500">Split review window (days)</label><input type="number" min="1" max="31" name="split_window_days" value="{{ setting.split_window_days }}" class="mt-1 w-full rounded-xl border-slate-300 text-sm" required></div>
<div><label class="text-xs font-semibold uppercase text-slate-500">Near-limit threshold (%)</label><input type="number" min="1" max="100" step="0.1" name="near_limit_percent" value="{{ setting.near_limit_percent }}" class="mt-1 w-full rounded-xl border-slate-300 text-sm" required></div>
<div><label class="text-xs font-semibold uppercase text-slate-500">Max source-date allocation window</label><input type="number" min="1" max="366" name="max_allocation_days" value="{{ setting.max_allocation_days }}" class="mt-1 w-full rounded-xl border-slate-300 text-sm" required></div>
<button class="w-full rounded-xl border border-brand-300 bg-brand-50 px-4 py-2 text-sm font-semibold text-brand-700">Save Settings</button>
</form>
</div>
</section>
{% if review %}
<section class="space-y-4">
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
{% 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)] %}
<div class="rounded-2xl bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">{{ label }}</div><div class="mt-2 text-2xl font-semibold text-slate-900">{{ value }}</div></div>
{% endfor %}
</div>
<div class="rounded-2xl bg-white p-5 shadow-soft overflow-x-auto">
<h3 class="font-semibold text-slate-900">Above-limit / same-day aggregate exceptions</h3>
<table class="mt-3 min-w-full text-sm"><thead class="text-left text-xs uppercase text-slate-500"><tr><th class="p-2">Date</th><th class="p-2">Party / ledger</th><th class="p-2">Amount</th><th class="p-2">Reason</th><th class="p-2">Voucher(s)</th></tr></thead><tbody>
{% for row in review.exceptions %}<tr class="border-t border-slate-100"><td class="p-2">{{ row.date }}</td><td class="p-2 font-medium">{{ row.party }}</td><td class="p-2">₹{{ '%.2f'|format(row.amount) }}</td><td class="p-2">{{ row.reason }}</td><td class="p-2">{{ row.voucher_numbers|join(', ') }}</td></tr>{% else %}<tr><td colspan="5" class="p-4 text-slate-500">No above-limit exception found for the selected period.</td></tr>{% endfor %}
</tbody></table>
</div>
<div class="rounded-2xl bg-white p-5 shadow-soft overflow-x-auto">
<h3 class="font-semibold text-slate-900">Possible split-payment patterns — review only</h3>
<p class="mt-1 text-xs text-slate-500">These rows are indicators for auditor review; they do not by themselves determine tax treatment.</p>
<table class="mt-3 min-w-full text-sm"><thead class="text-left text-xs uppercase text-slate-500"><tr><th class="p-2">Party / ledger</th><th class="p-2">Period</th><th class="p-2">Total</th><th class="p-2">Daily amounts</th></tr></thead><tbody>
{% for row in review.possible_split_payments %}<tr class="border-t border-slate-100"><td class="p-2 font-medium">{{ row.party }}</td><td class="p-2">{{ row.date_from }} → {{ row.date_to }}</td><td class="p-2">₹{{ '%.2f'|format(row.total_amount) }}</td><td class="p-2">{% for item in row.days %}{{ item.date }}: ₹{{ '%.2f'|format(item.amount) }}{% if not loop.last %}<br>{% endif %}{% endfor %}</td></tr>{% else %}<tr><td colspan="4" class="p-4 text-slate-500">No near-limit multi-day pattern found.</td></tr>{% endfor %}
</tbody></table>
</div>
</section>
{% endif %}
<section class="rounded-2xl bg-white p-5 shadow-soft">
<h2 class="text-lg font-semibold text-slate-900">Cash Payment Entry Allocation — Actual Source Dates</h2>
<p class="mt-1 text-sm text-slate-600">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.</p>
<form method="post" action="/tools/accounting/cash-payments/allocation-plan" class="mt-4 grid gap-4 lg:grid-cols-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div><label class="text-xs font-semibold uppercase text-slate-500">Client</label><select name="client_id" class="mt-1 w-full rounded-xl border-slate-300 text-sm" required>{% for client in clients %}<option value="{{ client.id }}" {% if selected_client and selected_client.id == client.id %}selected{% endif %}>{{ client.client_name }}</option>{% endfor %}</select></div>
<div><label class="text-xs font-semibold uppercase text-slate-500">Total accounting amount</label><input type="number" min="0.01" step="0.01" name="total_amount" value="{{ allocation_input.total_amount }}" class="mt-1 w-full rounded-xl border-slate-300 text-sm" required></div>
<div><label class="text-xs font-semibold uppercase text-slate-500">Party</label><input name="party_name" value="{{ allocation_input.party_name }}" class="mt-1 w-full rounded-xl border-slate-300 text-sm"></div>
<div><label class="text-xs font-semibold uppercase text-slate-500">Expense / purchase ledger</label><input name="expense_ledger" value="{{ allocation_input.expense_ledger }}" class="mt-1 w-full rounded-xl border-slate-300 text-sm"></div>
<div class="lg:col-span-2"><label class="text-xs font-semibold uppercase text-slate-500">Actual source payments — one per line: YYYY-MM-DD, amount</label><textarea name="source_payments" rows="7" class="mt-1 w-full rounded-xl border-slate-300 font-mono text-sm" placeholder="2026-09-01, 8500.00
2026-09-02, 9000.00">{{ allocation_input.source_payments }}</textarea></div>
<div class="lg:col-span-2"><button class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white">Validate Source-Date Allocation</button></div>
</form>
{% if allocation_plan %}
<div class="mt-5 rounded-xl border {% if allocation_plan.status == 'ready' %}border-emerald-200 bg-emerald-50{% else %}border-amber-200 bg-amber-50{% endif %} p-4">
<div class="font-semibold {% if allocation_plan.status == 'ready' %}text-emerald-800{% else %}text-amber-900{% endif %}">{{ 'Ready for accounting review' if allocation_plan.status == 'ready' else 'Manual review required' }}</div>
{% if allocation_plan.errors %}<ul class="mt-2 list-disc pl-5 text-sm text-amber-900">{% for item in allocation_plan.errors %}<li>{{ item }}</li>{% endfor %}</ul>{% endif %}
<div class="mt-3 text-sm text-slate-700">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.</div>
{% if allocation_plan.entries %}<table class="mt-3 min-w-full text-sm"><thead><tr><th class="p-2 text-left">Actual Date</th><th class="p-2 text-left">Proposed Entry Amount</th></tr></thead><tbody>{% for row in allocation_plan.entries %}<tr class="border-t border-slate-200"><td class="p-2">{{ row.date }}</td><td class="p-2">₹{{ '%.2f'|format(row.amount) }}</td></tr>{% endfor %}</tbody></table>{% endif %}
<p class="mt-3 text-xs text-slate-600">{{ allocation_plan.note or 'No accounting dates are generated automatically.' }}</p>
</div>
{% endif %}
</section>
</div>
{% endblock %}
@@ -46,6 +46,7 @@
<a href="/tools/accounting/bank-posting{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-semibold">Bank Expenses / Payments</a>
<a href="/tools/accounting/bank-reconciliation{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-emerald-300 bg-white px-3 py-2 text-sm font-semibold text-emerald-800">Bank Reconciliation</a>
<a href="/tools/tally/depreciation{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-semibold">Depreciation (IT)</a>
<a href="/tools/accounting/cash-payments{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-orange-300 bg-white px-3 py-2 text-sm font-semibold text-orange-800">Cash Payment Compliance</a>
<a href="/tools/accounting/ai{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-indigo-300 bg-indigo-50 px-3 py-2 text-sm font-semibold text-indigo-800">Accounting AI</a>
<a href="/tools/accounting/internal-model" class="rounded-xl border border-emerald-300 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-800">Internal Model</a>
</div>
+1 -1
View File
@@ -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)
@@ -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)
+6
View File
@@ -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",
),
)
+2
View File
@@ -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)