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
+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.
+| Date | Party / ledger | Amount | Reason | Voucher(s) |
|---|---|---|---|---|
| {{ row.date }} | {{ row.party }} | ₹{{ '%.2f'|format(row.amount) }} | {{ row.reason }} | {{ row.voucher_numbers|join(', ') }} |
| No above-limit exception found for the selected period. | ||||
These rows are indicators for auditor review; they do not by themselves determine tax treatment.
+| Party / ledger | Period | Total | Daily 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. | |||
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 %} +| Actual Date | Proposed Entry Amount |
|---|---|
| {{ row.date }} | ₹{{ '%.2f'|format(row.amount) }} |
{{ allocation_plan.note or 'No accounting dates are generated automatically.' }}
+