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