Files
2026-09-20 16:36:14 +05:30

856 lines
36 KiB
Python

from __future__ import annotations
from datetime import date, datetime
from decimal import Decimal, InvalidOperation
from urllib.parse import quote
import uuid
import re
from fastapi import APIRouter, Request
from fastapi.responses import RedirectResponse, JSONResponse
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,
_analysis_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 _parse_review_date(value: str, fallback: date) -> date:
text = str(value or "").strip()
if not text:
return fallback
try:
return date.fromisoformat(text)
except Exception:
return fallback
def _review_financial_year(date_from: str, date_to: str, fallback_today: date) -> tuple[str, date, date]:
"""Resolve Cash Payment review FY from the requested review period.
The review must follow the period selected by the user, not the ERP workspace FY.
"""
parsed_from = _parse_review_date(date_from, fallback_today)
parsed_to = _parse_review_date(date_to, parsed_from)
if parsed_to < parsed_from:
parsed_to = parsed_from
fy = _financial_year_for_date(parsed_from)
return fy, parsed_from, parsed_to
def _financial_year_bounds(financial_year: str) -> tuple[date, date]:
text = str(financial_year or "").strip()
match = re.fullmatch(r"(\d{4})-(\d{2})", text)
if not match:
raise ValueError("Invalid financial year.")
start_year = int(match.group(1))
expected_suffix = str((start_year + 1) % 100).zfill(2)
if match.group(2) != expected_suffix:
raise ValueError("Invalid financial year.")
return date(start_year, 4, 1), date(start_year + 1, 3, 31)
def _financial_year_options(selected_fy: str, today: date) -> list[str]:
current = _financial_year_for_date(today)
current_start = int(current.split("-", 1)[0])
selected_start = int(str(selected_fy).split("-", 1)[0]) if selected_fy else current_start
newest = max(current_start, selected_start)
oldest = min(current_start - 7, selected_start)
return [
f"{year}-{str((year + 1) % 100).zfill(2)}"
for year in range(newest, oldest - 1, -1)
]
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 = "",
financial_year: str = "",
job_id: str = "",
cash_ledger_name: str = "",
analyze: int = 0,
saved: int = 0,
error: str = "",
):
"""Cash Payment Compliance from the local Accounting Mirror only.
TallyPrime is not contacted by this review route. The Local Agent is used
only to read the client's local .act mirror database through the existing
secure tunnel. Users refresh that mirror separately from /tools/tally.
"""
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()
# Financial Year is now an explicit review selector. This avoids any
# ambiguity between the ERP workspace FY and the local mirror FY.
default_fy = _financial_year_for_date(today)
requested_fy = str(financial_year or "").strip()
try:
review_fy = requested_fy if requested_fy else (
_financial_year_for_date(_parse_review_date(date_from, today))
if date_from else default_fy
)
fy_start, fy_end = _financial_year_bounds(review_fy)
except Exception:
review_fy = default_fy
fy_start, fy_end = _financial_year_bounds(review_fy)
parsed_from = _parse_review_date(date_from, fy_start) if date_from else fy_start
parsed_to = _parse_review_date(date_to, fy_end) if date_to else fy_end
# Keep manual sub-period review possible, but never allow a reversed
# range such as 01/04/2025 -> 31/03/2025.
if parsed_from < fy_start or parsed_from > fy_end:
parsed_from = fy_start
if parsed_to < fy_start or parsed_to > fy_end:
parsed_to = fy_end
if parsed_to < parsed_from:
parsed_from, parsed_to = fy_start, fy_end
start_text = parsed_from.isoformat()
end_text = parsed_to.isoformat()
financial_year_options = _financial_year_options(review_fy, today)
review = None
mirror_status = None
command_error = error or ""
if selected_client:
if not node or not online:
command_error = command_error or (
"ERP Local Agent is offline. Cash Payment Review reads the client's local "
"SQLite Accounting Mirror, so the storage agent must be online."
)
else:
storage_payload = _analysis_accounting_storage_payload(
selected_client,
review_fy,
db=db,
tenant_id=scope.tenant_id,
)
try:
status_response = request_agent_command(
node.node_code,
"accounting_mirror_status",
storage_payload,
timeout_seconds=20,
)
if status_response.get("ok"):
mirror_status = status_response.get("result") or {}
else:
command_error = str(
status_response.get("error") or "Could not read the local Accounting Mirror status."
)
except Exception as exc:
command_error = str(exc)
if analyze and not command_error:
try:
result = request_agent_command(
node.node_code,
"accounting_cash_payment_mirror_analyze",
{
**storage_payload,
"date_from": start_text,
"date_to": end_text,
"cash_ledger_name": str(cash_ledger_name or "").strip(),
"cash_limit": float(setting.cash_limit),
"split_window_days": int(setting.split_window_days),
"near_limit_percent": float(setting.near_limit_percent),
"requested_by_user_id": int(user.id),
},
timeout_seconds=120,
)
if result.get("ok"):
body = result.get("result") or {}
review = body.get("cash_payment_review")
mirror_status = {
**(mirror_status or {}),
"mirror": body.get("mirror") or (mirror_status or {}).get("mirror") or {},
}
else:
command_error = str(
result.get("error") or "Cash payment SQLite mirror analysis 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=None,
mirror_status=mirror_status,
selected_tally_guid=str(tally_guid or ""),
date_from=start_text,
date_to=end_text,
setting=setting,
review=review,
cache_job=None,
active_job_id="",
selected_cash_ledger_name=str(cash_ledger_name or ""),
saved=bool(saved),
command_error=command_error,
allocation_plan=None,
allocation_input={"total_amount": "", "party_name": "", "expense_ledger": "", "source_payments": ""},
page_mode="compliance",
review_financial_year=review_fy,
financial_year_options=financial_year_options,
)
finally:
db.close()
@router.post("/ledgers")
async def cash_payment_ledgers(request: Request):
"""Queue Cash-ledger preparation and return a durable VPS-known job id.
The Local Agent reads the common client .act master in the background. Normal
loads are SQLite-only. Refresh Master Data is the only path that is allowed to
contact TallyPrime for a master refresh.
"""
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 JSONResponse({"ok": False, "error": "Access denied."}, status_code=403)
clients, scope = _visible_clients(db, request, user)
try:
client_id = int(form.get("client_id") or 0)
except Exception:
client_id = 0
selected_client = next((row for row in clients if int(row.id) == client_id), None)
if not selected_client:
return JSONResponse({"ok": False, "error": "Select a valid client."}, status_code=400)
tally_guid = str(form.get("tally_guid") or "").strip()
company_name = str(form.get("company_name") or "").strip()
date_from = str(form.get("date_from") or date.today().isoformat()).strip()
ledger_scope = str(form.get("ledger_scope") or "cash").strip().lower()
force_refresh = str(form.get("force_refresh") or "").strip().lower() in {"1", "true", "yes", "on"}
if not tally_guid:
return JSONResponse({"ok": False, "error": "Select an open Tally company."}, status_code=400)
node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id)
if not node or not _node_online(node):
return JSONResponse({"ok": False, "error": "ERP Local Agent is offline."}, status_code=409)
# The VPS generates the job id before sending the command. Even when a
# WebSocket ACK is lost, the browser can poll this known id and retrieve the
# ledger payload once the Local Agent has completed the SQLite read.
job_id = "CASHMASTER-" + uuid.uuid4().hex[:16].upper()
try:
fy = _financial_year_for_date(date.fromisoformat(date_from))
payload = {
**_analysis_accounting_storage_payload(selected_client, fy, db=db, tenant_id=scope.tenant_id),
"tally_guid": tally_guid,
"company_name": company_name,
"ledger_scope": ledger_scope,
"force_refresh": force_refresh,
"job_id": job_id,
"requested_by_user_id": int(user.id),
}
result = request_agent_command(
node.node_code,
"accounting_cash_payment_cash_ledgers",
payload,
timeout_seconds=12,
)
if result.get("ok"):
body = result.get("result") or {}
job = body.get("job") or {
"job_id": job_id,
"status": "queued",
"stage": "Local Agent accepted Cash ledger job",
"percent": 5,
}
return JSONResponse({"ok": True, "accepted": True, "job": job, **body})
return JSONResponse({
"ok": False,
"error": str(result.get("error") or "Local Agent rejected the Cash ledger request."),
}, status_code=409)
except Exception:
# A transport timeout does not mean the Local Agent failed. The job id
# is durable and known to the VPS; return it immediately and let the
# browser poll. This is specifically designed for intermittent tunnel
# keepalive/ACK loss.
return JSONResponse({
"ok": True,
"accepted": True,
"transport_pending": True,
"job": {
"job_id": job_id,
"status": "queued",
"stage": "Command sent; waiting for Local Agent acknowledgment",
"percent": 5,
"company_name": company_name,
},
})
finally:
db.close()
@router.get("/ledgers/progress")
def cash_payment_ledgers_progress(
request: Request,
client_id: int,
job_id: str,
date_from: str,
tally_guid: str = "",
company_name: str = "",
ledger_scope: str = "cash",
force_refresh: int = 0,
):
db = CommonSessionLocal()
try:
user, response = _require_partner(request, db, "accounting.tally.view")
if response:
return JSONResponse({"ok": False, "error": "Access denied."}, status_code=403)
clients, scope = _visible_clients(db, request, user)
selected_client = next((row for row in clients if int(row.id) == int(client_id)), None)
if not selected_client:
return JSONResponse({"ok": False, "error": "Client not found."}, status_code=404)
node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id)
if not node or not _node_online(node):
return JSONResponse({
"ok": True,
"transport_pending": True,
"job": {
"job_id": job_id,
"status": "running",
"stage": "Waiting for ERP Local Agent tunnel",
"percent": 85,
"company_name": company_name,
},
})
fy = _financial_year_for_date(date.fromisoformat(date_from))
payload = {
**_analysis_accounting_storage_payload(selected_client, fy, db=db, tenant_id=scope.tenant_id),
"job_id": str(job_id),
"tally_guid": str(tally_guid or ""),
"company_name": str(company_name or ""),
"ledger_scope": str(ledger_scope or "cash"),
"force_refresh": bool(force_refresh),
"requested_by_user_id": int(user.id),
}
try:
result = request_agent_command(
node.node_code,
"accounting_cash_payment_ledgers_status",
payload,
timeout_seconds=10,
)
except Exception:
return JSONResponse({
"ok": True,
"transport_pending": True,
"job": {
"job_id": job_id,
"status": "running",
"stage": "Local work complete or running; waiting to transfer result to VPS",
"percent": 90,
"company_name": company_name,
},
})
if result.get("ok"):
return JSONResponse({"ok": True, **(result.get("result") or {})})
error = str(result.get("error") or "")
# If the initial start command itself was lost before reaching the Local
# Agent, resend the same deterministic job id. The Local Agent treats this
# idempotently, so this cannot create duplicate work.
if "not found" in error.casefold():
try:
restarted = request_agent_command(
node.node_code,
"accounting_cash_payment_cash_ledgers",
payload,
timeout_seconds=10,
)
if restarted.get("ok"):
return JSONResponse({"ok": True, **(restarted.get("result") or {})})
except Exception:
pass
return JSONResponse({
"ok": True,
"transport_pending": True,
"job": {
"job_id": job_id,
"status": "queued",
"stage": "Retrying Cash ledger job delivery to Local Agent",
"percent": 10,
"company_name": company_name,
},
})
return JSONResponse({"ok": False, "error": error or "Could not read Cash ledger progress."}, status_code=409)
finally:
db.close()
@router.post("/cache/start")
async def start_cash_payment_cache(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 JSONResponse({"ok": False, "error": "Access denied."}, status_code=403)
clients, scope = _visible_clients(db, request, user)
try:
client_id = int(form.get("client_id") or 0)
except Exception:
client_id = 0
selected_client = next((row for row in clients if int(row.id) == client_id), None)
if not selected_client:
return JSONResponse({"ok": False, "error": "Select a valid client."}, status_code=400)
tally_guid = str(form.get("tally_guid") or "").strip()
company_name = str(form.get("company_name") or "").strip()
cash_ledger_name = str(form.get("cash_ledger_name") or "").strip()
date_from = str(form.get("date_from") or "").strip()
date_to = str(form.get("date_to") or "").strip()
try:
parsed_from = date.fromisoformat(date_from)
parsed_to = date.fromisoformat(date_to)
if parsed_to < parsed_from:
raise ValueError("To date cannot be before From date.")
except Exception as exc:
return JSONResponse({"ok": False, "error": str(exc)}, status_code=400)
if not tally_guid:
return JSONResponse({"ok": False, "error": "Select an open Tally company."}, status_code=400)
if not cash_ledger_name:
return JSONResponse({"ok": False, "error": "Load the Tally ledgers and confirm the Cash ledger."}, status_code=400)
node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id)
if not node or not _node_online(node):
return JSONResponse({"ok": False, "error": "ERP Local Agent is offline."}, status_code=409)
setting = _setting(db, _tenant_id(request, user))
try:
result = request_agent_command(
node.node_code,
"accounting_cash_payment_cache_start",
{
**_analysis_accounting_storage_payload(selected_client, _financial_year_for_date(parsed_from), db=db, tenant_id=scope.tenant_id),
"tally_guid": tally_guid,
"company_name": company_name,
"date_from": date_from,
"date_to": date_to,
"cash_ledger_name": cash_ledger_name,
"cash_ledger_names": [cash_ledger_name],
"cash_limit": float(setting.cash_limit),
"split_window_days": int(setting.split_window_days),
"near_limit_percent": float(setting.near_limit_percent),
"tally_pause_seconds": 3.0,
"requested_by_user_id": int(user.id),
},
timeout_seconds=30,
)
except Exception as exc:
return JSONResponse({"ok": False, "error": str(exc)}, status_code=502)
if not result.get("ok"):
return JSONResponse({"ok": False, "error": str(result.get("error") or "Could not start Tally extraction.")}, status_code=409)
return JSONResponse({"ok": True, **(result.get("result") or {})})
finally:
db.close()
@router.post("/cache/cancel")
async def cancel_cash_payment_cache(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 JSONResponse({"ok": False, "error": "Access denied."}, status_code=403)
clients, scope = _visible_clients(db, request, user)
try:
client_id = int(form.get("client_id") or 0)
except Exception:
client_id = 0
selected_client = next((row for row in clients if int(row.id) == client_id), None)
if not selected_client:
return JSONResponse({"ok": False, "error": "Select a valid client."}, status_code=400)
job_id = str(form.get("job_id") or "").strip()
date_from = str(form.get("date_from") or "").strip()
if not job_id:
return JSONResponse({"ok": False, "error": "Extraction job id is required."}, status_code=400)
try:
parsed_from = date.fromisoformat(date_from)
except Exception:
return JSONResponse({"ok": False, "error": "Valid extraction From date is required."}, status_code=400)
node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id)
if not node or not _node_online(node):
return JSONResponse({"ok": False, "error": "ERP Local Agent is offline."}, status_code=409)
result = request_agent_command(
node.node_code,
"accounting_cash_payment_cache_cancel",
{
**_analysis_accounting_storage_payload(selected_client, _financial_year_for_date(parsed_from), db=db, tenant_id=scope.tenant_id),
"job_id": job_id,
},
timeout_seconds=15,
)
if not result.get("ok"):
return JSONResponse({"ok": False, "error": str(result.get("error") or "Could not cancel extraction.")}, status_code=409)
return JSONResponse({"ok": True, **(result.get("result") or {})})
except Exception as exc:
return JSONResponse({"ok": False, "error": str(exc)}, status_code=502)
finally:
db.close()
@router.get("/cache/progress")
def cash_payment_cache_progress(request: Request, client_id: int, job_id: str, date_from: str):
db = CommonSessionLocal()
try:
user, response = _require_partner(request, db, "accounting.tally.view")
if response:
return JSONResponse({"ok": False, "error": "Access denied."}, status_code=403)
clients, scope = _visible_clients(db, request, user)
selected_client = next((row for row in clients if int(row.id) == int(client_id)), None)
if not selected_client:
return JSONResponse({"ok": False, "error": "Client not found."}, status_code=404)
node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id)
if not node or not _node_online(node):
return JSONResponse({"ok": False, "error": "ERP Local Agent is offline."}, status_code=409)
try:
fy = _financial_year_for_date(date.fromisoformat(date_from))
result = request_agent_command(
node.node_code,
"accounting_cash_payment_cache_status",
{**_analysis_accounting_storage_payload(selected_client, fy, db=db, tenant_id=scope.tenant_id), "job_id": str(job_id)},
timeout_seconds=20,
)
except Exception as exc:
return JSONResponse({"ok": False, "error": str(exc)}, status_code=502)
if not result.get("ok"):
return JSONResponse({"ok": False, "error": str(result.get("error") or "Could not read extraction progress.")}, status_code=409)
return JSONResponse({"ok": True, **(result.get("result") or {})})
finally:
db.close()
@router.get("/allocation")
def cash_payment_allocation(
request: Request,
client_id: int | None = None,
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)
setting = _setting(db, _tenant_id(request, user))
node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id)
return _render(
request,
db,
user,
title="Cash Payment Entry Allocation",
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,
cache_job=None,
active_job_id="",
saved=False,
command_error=error or "",
allocation_plan=None,
allocation_input={"total_amount": "", "party_name": "", "expense_ledger": "", "source_payments": ""},
page_mode="allocation",
)
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()
financial_year = str(form.get("financial_year") or "").strip()
parts = []
if client_id:
parts.append("client_id=" + quote(client_id))
if financial_year:
parts.append("financial_year=" + quote(financial_year))
suffix = ("&" + "&".join(parts)) if parts 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,
cache_job=None,
active_job_id="",
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,
},
page_mode="allocation",
)
finally:
db.close()