Files
arrr-erp/app/modules/clients/ui.py
T
2026-06-20 15:01:44 +05:30

1537 lines
59 KiB
Python

from __future__ import annotations
from fastapi import APIRouter, File, Form, Request, UploadFile
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response
from pydantic import ValidationError
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.clients import repository
from app.modules.clients.access import build_scope, can_view_client_row
from app.modules.clients.constants import CLIENT_CATEGORY_OPTIONS, CLIENT_STATUS, CLIENT_TYPES, RISK_CATEGORIES
from app.modules.clients.filters import ClientListFilters
from app.modules.clients.import_service import (
build_client_import_template_bytes,
build_preview,
commit_import,
deserialize_preview_rows,
serialize_preview_rows,
)
from app.modules.clients.schemas import ClientCreate, ClientUpdate
from app.modules.clients.service import (
activate_client_service,
archive_client_service,
create_client_service,
deactivate_client_service,
export_clients_csv,
get_client_or_404,
list_client_audit_logs,
list_clients_payload,
restore_client_service,
update_client_service,
update_client_self_profile_service,
)
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
from app.modules.core.rbac.permission_guard import require_permission
from app.modules.services.execution import list_client_visible_task_comments
from app.modules.clients.auditor_service import build_client_auditor_card
from app.modules.clients.portal_service import (
build_client_portal_summary,
create_client_reply,
get_client_engagement,
get_client_task,
list_client_engagement_documents,
list_client_engagements,
list_client_permanent_documents,
list_client_tasks_for_engagement,
list_client_visible_comments,
)
from app.modules.billing.client_portal_service import (
build_client_billing_summary,
build_client_payment_context,
get_client_portal_invoice,
get_client_portal_payment,
list_client_portal_invoices,
)
from app.modules.billing.services import build_invoice_print_context, create_cashfree_transaction, create_payumoney_transaction, process_cashfree_return, process_cashfree_webhook, process_payumoney_response
from app.modules.documents.services import (
get_permanent_version,
get_version,
permanent_version_absolute_path,
version_absolute_path,
)
router = APIRouter(prefix="/clients", tags=["clients-ui"])
def _base_ctx(request: Request, user, db, **ctx):
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),
"client_types": CLIENT_TYPES,
"client_statuses": CLIENT_STATUS,
"client_categories": CLIENT_CATEGORY_OPTIONS,
"risk_categories": RISK_CATEGORIES,
}
base.update(ctx)
return base
def _render(request, template, db, user, **ctx):
return templates.TemplateResponse(template, _base_ctx(request, user, db, **ctx))
def _redirect_denied():
return RedirectResponse(url="/system-settings", status_code=303)
def _has_perm_factory(db, user):
def _has(code):
try:
require_permission(db, user, code)
return True
except Exception:
return False
return _has
def _role_names(db, user) -> set[str]:
return {str(r).lower() for r in get_user_roles(db, user.id)}
def _resolve_form_mode(role_names: set[str]) -> str:
if "system admin" in role_names:
return "system_admin"
if "firm admin" in role_names:
return "firm_admin"
if "partner" in role_names:
return "partner"
if "consultant" in role_names:
return "consultant"
return "self_service"
def _elevate_scope_for_system_admin(scope, role_names: set[str]):
if "system admin" in role_names:
scope.allow_all_clients = True
scope.allow_cross_tenant = True
scope.allow_cross_branch = True
scope.own_only = False
scope.locked_partner_id = None
return scope
def _form_bool(value):
return value in ("1", "true", "True", "on", "yes")
def _build_form_payload(request: Request, user, scope, *, include_client_code: bool = True):
form = request._form
branch_raw = form.get("branch_id")
branch_id = int(branch_raw) if branch_raw not in (None, "", "None") else int(scope.branch_id or getattr(user, "branch_id", None) or 0)
partner_raw = form.get("partner_id")
if partner_raw not in (None, "", "None"):
partner_id = int(partner_raw)
elif scope.locked_partner_id:
partner_id = scope.locked_partner_id
else:
partner_id = None
tenant_id = int(form.get("tenant_id") or scope.tenant_id)
payload = {
"tenant_id": tenant_id,
"branch_id": branch_id,
"partner_id": partner_id,
"default_review_partner_user_id": int(form.get("default_review_partner_user_id")) if form.get("default_review_partner_user_id") not in (None, "", "None") else None,
"engagement_mode": form.get("engagement_mode") or "internal_managed",
"client_name": form.get("client_name", ""),
"trade_name": form.get("trade_name"),
"client_type": form.get("client_type") or "Other",
"pan": form.get("pan"),
"gstin": form.get("gstin"),
"tan": form.get("tan"),
"cin_llpin": form.get("cin_llpin"),
"msme_no": form.get("msme_no"),
"iec_code": form.get("iec_code"),
"contact_person_name": form.get("contact_person_name"),
"contact_person_designation": form.get("contact_person_designation"),
"mobile": form.get("mobile"),
"alternate_mobile": form.get("alternate_mobile"),
"email": form.get("email"),
"alternate_email": form.get("alternate_email"),
"address_line_1": form.get("address_line_1"),
"address_line_2": form.get("address_line_2"),
"city": form.get("city"),
"state": form.get("state"),
"pincode": form.get("pincode"),
"country": form.get("country") or "India",
"status": form.get("status") or "active",
"client_category": form.get("client_category"),
"risk_category": form.get("risk_category"),
"onboarding_date": form.get("onboarding_date") or None,
"closing_date": form.get("closing_date") or None,
"notes": form.get("notes"),
"gst_applicable": _form_bool(form.get("gst_applicable")),
"income_tax_applicable": _form_bool(form.get("income_tax_applicable")),
"tds_applicable": _form_bool(form.get("tds_applicable")),
"roc_applicable": _form_bool(form.get("roc_applicable")),
"audit_applicable": _form_bool(form.get("audit_applicable")),
"pf_applicable": _form_bool(form.get("pf_applicable")),
"esi_applicable": _form_bool(form.get("esi_applicable")),
"professional_tax_applicable": _form_bool(form.get("professional_tax_applicable")),
"payroll_applicable": _form_bool(form.get("payroll_applicable")),
"msme_applicable": _form_bool(form.get("msme_applicable")),
"import_export_applicable": _form_bool(form.get("import_export_applicable")),
}
if include_client_code:
payload["client_code"] = form.get("client_code", "")
return payload
def _field_errors(exc):
if isinstance(exc, ValidationError):
out = []
for err in exc.errors():
loc = ".".join(str(x) for x in err.get("loc", []))
out.append(f"{loc}: {err.get('msg', 'Invalid value')}")
return out
detail = getattr(exc, "detail", None)
return [str(detail or exc)]
def _form_options(db, scope, form_mode: str):
tenant_id = scope.tenant_id
branch_id = scope.branch_id
if form_mode == "system_admin":
return {
"tenants": repository.list_tenants(db),
"branches": repository.list_all_branches(db),
"partners": repository.list_all_partners(db),
"review_partners": repository.list_all_partners(db),
"active_tenant_id": tenant_id,
"active_branch_id": branch_id,
}
return {
"tenants": repository.list_tenants(db),
"branches": repository.list_branches_for_tenant(db, tenant_id),
"partners": repository.list_partners_for_scope(
db,
tenant_id=tenant_id,
branch_id=None if scope.allow_cross_branch else branch_id,
),
"review_partners": repository.list_partners_for_scope(
db,
tenant_id=tenant_id,
branch_id=None if scope.allow_cross_branch else branch_id,
),
"active_tenant_id": tenant_id,
"active_branch_id": branch_id,
}
@router.get("")
def clients_list(
request: Request,
q: str = "",
status: str = "",
client_type: str = "",
partner_id: int | None = None,
include_archived: bool = False,
page: int = 1,
per_page: int = 10,
sort_by: str = "client_name",
sort_order: str = "asc",
):
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return RedirectResponse(url="/login", status_code=303)
has = _has_perm_factory(db, user)
if not has("clients.view"):
return _redirect_denied()
role_names = _role_names(db, user)
scope = build_scope(request, user, has)
scope = _elevate_scope_for_system_admin(scope, role_names)
filters = ClientListFilters.from_params(
q=q,
status=status,
client_type=client_type,
partner_id=partner_id,
include_archived=include_archived,
page=page,
per_page=per_page,
sort_by=sort_by,
sort_order=sort_order,
)
if scope.own_only:
filters.partner_id = scope.locked_partner_id
payload = list_clients_payload(
db,
tenant_id=scope.tenant_id,
branch_id=scope.branch_id,
allow_cross_branch=scope.allow_cross_branch,
allow_all_clients=scope.allow_all_clients,
partner_id=filters.partner_id,
q=filters.q,
status=filters.status,
client_type=filters.client_type,
include_archived=filters.include_archived,
page=filters.page,
per_page=filters.per_page,
sort_by=filters.sort_by,
sort_order=filters.sort_order,
)
payload["rows"] = [row for row in payload["rows"] if can_view_client_row(scope, row, user_id=user.id)]
return _render(
request,
"modules/clients/templates/clients/list.html",
db,
user,
title="Clients",
can_create=has("clients.create"),
can_export=has("clients.export"),
can_import=has("clients.import"),
q=filters.q,
status=filters.status,
client_type=filters.client_type,
partner_id=filters.partner_id,
include_archived=filters.include_archived,
page=filters.page,
per_page=filters.per_page,
sort_by=filters.sort_by,
sort_order=filters.sort_order,
scope=scope,
form_options=_form_options(db, scope, "system_admin" if "system admin" in role_names else "list"),
**payload,
)
finally:
db.close()
@router.get("/export")
def clients_export(
request: Request,
q: str = "",
status: str = "",
client_type: str = "",
partner_id: int | None = None,
include_archived: bool = False,
sort_by: str = "client_name",
sort_order: str = "asc",
):
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return RedirectResponse(url="/login", status_code=303)
has = _has_perm_factory(db, user)
if not has("clients.export"):
return _redirect_denied()
role_names = _role_names(db, user)
scope = build_scope(request, user, has)
scope = _elevate_scope_for_system_admin(scope, role_names)
filters = ClientListFilters.from_params(
q=q,
status=status,
client_type=client_type,
partner_id=partner_id,
include_archived=include_archived,
page=1,
per_page=10000,
sort_by=sort_by,
sort_order=sort_order,
)
if scope.own_only:
filters.partner_id = scope.locked_partner_id
payload = list_clients_payload(
db,
tenant_id=scope.tenant_id,
branch_id=scope.branch_id,
allow_cross_branch=scope.allow_cross_branch,
allow_all_clients=scope.allow_all_clients,
partner_id=filters.partner_id,
q=filters.q,
status=filters.status,
client_type=filters.client_type,
include_archived=filters.include_archived,
page=filters.page,
per_page=filters.per_page,
sort_by=filters.sort_by,
sort_order=filters.sort_order,
)
payload["rows"] = [row for row in payload["rows"] if can_view_client_row(scope, row, user_id=user.id)]
return Response(
content=export_clients_csv(payload),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=clients_export.csv"},
)
finally:
db.close()
@router.get("/import")
def client_import_page(request: Request):
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return RedirectResponse(url="/login", status_code=303)
has = _has_perm_factory(db, user)
if not has("clients.import"):
return _redirect_denied()
role_names = _role_names(db, user)
scope = build_scope(request, user, has)
scope = _elevate_scope_for_system_admin(scope, role_names)
return _render(
request,
"modules/clients/templates/clients/import.html",
db,
user,
title="Import Clients",
scope=scope,
role_names=sorted(role_names),
current_tenant=repository.get_tenant(db, scope.tenant_id),
partners=repository.list_partners_for_scope(db, tenant_id=scope.tenant_id, branch_id=None if scope.allow_cross_branch else scope.branch_id),
import_errors=[],
)
finally:
db.close()
@router.get("/import/template")
def client_import_template(request: Request):
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return RedirectResponse(url="/login", status_code=303)
has = _has_perm_factory(db, user)
if not has("clients.import"):
return _redirect_denied()
role_names = _role_names(db, user)
scope = build_scope(request, user, has)
scope = _elevate_scope_for_system_admin(scope, role_names)
payload = build_client_import_template_bytes(current_user=user, tenant_id=scope.tenant_id, partner_id=user.id if 'partner' in role_names else None)
return Response(
content=payload,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": "attachment; filename=client_import_template.xlsx"},
)
finally:
db.close()
@router.post("/import/preview")
async def client_import_preview(request: Request, excel_file: UploadFile = File(...)):
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return RedirectResponse(url="/login", status_code=303)
has = _has_perm_factory(db, user)
if not has("clients.import"):
return _redirect_denied()
role_names = _role_names(db, user)
scope = build_scope(request, user, has)
scope = _elevate_scope_for_system_admin(scope, role_names)
form = await request.form()
validate_csrf(request, form.get("csrf_token"))
content = await excel_file.read()
preview = build_preview(db, current_user=user, scope=scope, role_names=role_names, upload_bytes=content)
return _render(
request,
"modules/clients/templates/clients/import_preview.html",
db,
user,
title="Import Clients Preview",
scope=scope,
preview=preview,
preview_payload=serialize_preview_rows(preview.valid_rows),
)
finally:
db.close()
@router.post("/import/commit")
async def client_import_commit(request: Request):
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return RedirectResponse(url="/login", status_code=303)
has = _has_perm_factory(db, user)
if not has("clients.import"):
return _redirect_denied()
role_names = _role_names(db, user)
scope = build_scope(request, user, has)
scope = _elevate_scope_for_system_admin(scope, role_names)
form = await request.form()
validate_csrf(request, form.get("csrf_token"))
preview_rows = deserialize_preview_rows(form.get("preview_payload") or "[]")
result = commit_import(db, current_user=user, scope=scope, current_user_roles=get_user_roles(db, user.id), preview_rows=preview_rows)
return _render(
request,
"modules/clients/templates/clients/import_preview.html",
db,
user,
title="Import Clients Result",
scope=scope,
preview=None,
preview_payload="[]",
import_result=result,
)
finally:
db.close()
@router.get("/new")
def client_new_page(request: Request):
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return RedirectResponse(url="/login", status_code=303)
has = _has_perm_factory(db, user)
if not has("clients.create"):
return _redirect_denied()
role_names = _role_names(db, user)
form_mode = _resolve_form_mode(role_names)
scope = build_scope(request, user, has)
scope = _elevate_scope_for_system_admin(scope, role_names)
defaults = {
"status": "active",
"client_type": "Other",
"country": "India",
"engagement_mode": "internal_managed",
"partner_id": scope.locked_partner_id or getattr(user, "id", None),
"branch_id": scope.branch_id,
"tenant_id": scope.tenant_id,
}
return _render(
request,
"modules/clients/templates/clients/add.html",
db,
user,
title="Add Client",
form_data=defaults,
form_errors=[],
scope=scope,
form_options=_form_options(db, scope, form_mode),
form_mode=form_mode,
)
finally:
db.close()
@router.post("")
async def client_create(request: Request):
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return RedirectResponse(url="/login", status_code=303)
has = _has_perm_factory(db, user)
if not has("clients.create"):
return _redirect_denied()
role_names = _role_names(db, user)
form_mode = _resolve_form_mode(role_names)
scope = build_scope(request, user, has)
scope = _elevate_scope_for_system_admin(scope, role_names)
form = await request.form()
request._form = form
validate_csrf(request, form.get("csrf_token"))
raw_payload = _build_form_payload(request, user, scope, include_client_code=True)
roles = get_user_roles(db, user.id)
try:
data = ClientCreate(**raw_payload)
row = create_client_service(
db,
data=data,
actor_user_id=user.id,
scope=scope,
current_user_roles=roles,
portal_password=(form.get("portal_password") or "").strip() or None,
portal_password_confirm=(form.get("portal_password_confirm") or "").strip() or None,
)
return RedirectResponse(url=f"/clients/{row.id}", status_code=303)
except Exception as exc:
return _render(
request,
"modules/clients/templates/clients/add.html",
db,
user,
title="Add Client",
form_data=raw_payload,
form_errors=_field_errors(exc),
scope=scope,
form_options=_form_options(db, scope, form_mode),
form_mode=form_mode,
)
finally:
db.close()
@router.get("/{client_id}")
def client_detail(request: Request, client_id: int):
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return RedirectResponse(url="/login", status_code=303)
has = _has_perm_factory(db, user)
if not has("clients.view"):
return _redirect_denied()
role_names = _role_names(db, user)
scope = build_scope(request, user, has)
scope = _elevate_scope_for_system_admin(scope, role_names)
row = repository.get_client_detail_payload(db, client_id)
if not row or not can_view_client_row(scope, row, user_id=user.id):
return _redirect_denied()
audit_logs = list_client_audit_logs(db, row=type("Tmp", (), {"id": row["id"]})(), limit=10) if has("clients.audit_log.view") else []
return _render(
request,
"modules/clients/templates/clients/detail.html",
db,
user,
title=f"Client • {row['client_name']}",
row=row,
audit_logs=audit_logs,
scope=scope,
can_edit=has("clients.edit"),
can_deactivate=has("clients.deactivate"),
can_activate=has("clients.activate"),
can_archive=has("clients.archive"),
can_restore=has("clients.restore"),
)
finally:
db.close()
@router.get("/{client_id}/edit")
def client_edit_page(request: Request, client_id: int):
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return RedirectResponse(url="/login", status_code=303)
has = _has_perm_factory(db, user)
if not has("clients.edit"):
return _redirect_denied()
role_names = _role_names(db, user)
form_mode = _resolve_form_mode(role_names)
scope = build_scope(request, user, has)
scope = _elevate_scope_for_system_admin(scope, role_names)
row = get_client_or_404(
db,
client_id=client_id,
tenant_id=scope.tenant_id,
branch_id=scope.branch_id,
allow_cross_branch=scope.allow_cross_branch,
allow_all_clients=scope.allow_all_clients,
)
return _render(
request,
"modules/clients/templates/clients/edit.html",
db,
user,
title=f"Edit Client • {row.client_name}",
row=row,
form_data=row,
form_errors=[],
scope=scope,
form_options=_form_options(db, scope, form_mode),
form_mode=form_mode,
)
finally:
db.close()
@router.post("/{client_id}/edit")
async def client_update(request: Request, client_id: int):
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return RedirectResponse(url="/login", status_code=303)
has = _has_perm_factory(db, user)
if not has("clients.edit"):
return _redirect_denied()
role_names = _role_names(db, user)
form_mode = _resolve_form_mode(role_names)
scope = build_scope(request, user, has)
scope = _elevate_scope_for_system_admin(scope, role_names)
row = get_client_or_404(
db,
client_id=client_id,
tenant_id=scope.tenant_id,
branch_id=scope.branch_id,
allow_cross_branch=scope.allow_cross_branch,
allow_all_clients=scope.allow_all_clients,
)
form = await request.form()
request._form = form
validate_csrf(request, form.get("csrf_token"))
raw_payload = _build_form_payload(request, user, scope, include_client_code=False)
roles = get_user_roles(db, user.id)
try:
data = ClientUpdate(**raw_payload)
row = update_client_service(
db,
row=row,
data=data,
actor_user_id=user.id,
scope=scope,
current_user_roles=roles,
portal_password=(form.get("portal_password") or "").strip() or None,
portal_password_confirm=(form.get("portal_password_confirm") or "").strip() or None,
)
return RedirectResponse(url=f"/clients/{row.id}", status_code=303)
except Exception as exc:
return _render(
request,
"modules/clients/templates/clients/edit.html",
db,
user,
title=f"Edit Client • {row.client_name}",
row=row,
form_data=raw_payload,
form_errors=_field_errors(exc),
scope=scope,
form_options=_form_options(db, scope, form_mode),
form_mode=form_mode,
)
finally:
db.close()
@router.post("/{client_id}/deactivate")
async def client_deactivate(request: Request, client_id: int):
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return RedirectResponse(url="/login", status_code=303)
has = _has_perm_factory(db, user)
if not has("clients.deactivate"):
return _redirect_denied()
role_names = _role_names(db, user)
scope = build_scope(request, user, has)
scope = _elevate_scope_for_system_admin(scope, role_names)
form = await request.form()
validate_csrf(request, form.get("csrf_token"))
row = get_client_or_404(
db,
client_id=client_id,
tenant_id=scope.tenant_id,
branch_id=scope.branch_id,
allow_cross_branch=scope.allow_cross_branch,
allow_all_clients=scope.allow_all_clients,
)
deactivate_client_service(db, row=row, actor_user_id=user.id)
return RedirectResponse(url="/clients", status_code=303)
finally:
db.close()
@router.post("/{client_id}/activate")
async def client_activate(request: Request, client_id: int):
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return RedirectResponse(url="/login", status_code=303)
has = _has_perm_factory(db, user)
if not has("clients.activate"):
return _redirect_denied()
role_names = _role_names(db, user)
scope = build_scope(request, user, has)
scope = _elevate_scope_for_system_admin(scope, role_names)
form = await request.form()
validate_csrf(request, form.get("csrf_token"))
row = get_client_or_404(
db,
client_id=client_id,
tenant_id=scope.tenant_id,
branch_id=scope.branch_id,
allow_cross_branch=scope.allow_cross_branch,
allow_all_clients=scope.allow_all_clients,
)
activate_client_service(db, row=row, actor_user_id=user.id)
return RedirectResponse(url=f"/clients/{row.id}", status_code=303)
finally:
db.close()
@router.post("/{client_id}/archive")
async def client_archive(request: Request, client_id: int):
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return RedirectResponse(url="/login", status_code=303)
has = _has_perm_factory(db, user)
if not has("clients.archive"):
return _redirect_denied()
role_names = _role_names(db, user)
scope = build_scope(request, user, has)
scope = _elevate_scope_for_system_admin(scope, role_names)
form = await request.form()
validate_csrf(request, form.get("csrf_token"))
row = get_client_or_404(
db,
client_id=client_id,
tenant_id=scope.tenant_id,
branch_id=scope.branch_id,
allow_cross_branch=scope.allow_cross_branch,
allow_all_clients=scope.allow_all_clients,
)
archive_client_service(db, row=row, actor_user_id=user.id)
return RedirectResponse(url="/clients?include_archived=true", status_code=303)
finally:
db.close()
@router.post("/{client_id}/restore")
async def client_restore(request: Request, client_id: int):
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return RedirectResponse(url="/login", status_code=303)
has = _has_perm_factory(db, user)
if not has("clients.restore"):
return _redirect_denied()
role_names = _role_names(db, user)
scope = build_scope(request, user, has)
scope = _elevate_scope_for_system_admin(scope, role_names)
form = await request.form()
validate_csrf(request, form.get("csrf_token"))
row = get_client_or_404(
db,
client_id=client_id,
tenant_id=scope.tenant_id,
branch_id=scope.branch_id,
allow_cross_branch=scope.allow_cross_branch,
allow_all_clients=scope.allow_all_clients,
)
restore_client_service(db, row=row, actor_user_id=user.id)
return RedirectResponse(url=f"/clients/{row.id}", status_code=303)
finally:
db.close()
portal_router = APIRouter(prefix="/client", tags=["client-portal"])
def _portal_client_or_redirect(request: Request, db, current_user):
role_names = _role_names(db, current_user)
if "client" not in role_names:
return None, RedirectResponse(url="/system-settings", status_code=303)
client_row = repository.get_portal_client_for_user(db, user=current_user)
if not client_row:
return None, templates.TemplateResponse(
"modules/clients/templates/clients/portal_dashboard.html",
_portal_context(request, db, current_user, client_row=None),
status_code=200,
)
return client_row, None
def _active_financial_year(request: Request) -> str | None:
value = request.session.get("active_financial_year") or getattr(request.state, "year_code", None)
value = (value or "").strip()
return value or None
def _portal_context(request: Request, db, current_user, **extra):
ctx = {
"request": request,
"current_user": current_user,
"current_user_roles": get_user_roles(db, current_user.id),
"current_user_permissions": get_user_permissions(db, current_user.id),
"csrf_token": get_or_create_csrf_token(request),
"title": "Client Dashboard",
}
ctx.update(extra)
return ctx
@portal_router.get("/dashboard")
def client_portal_dashboard(request: Request):
db = CommonSessionLocal()
try:
current_user = get_current_user(request, db=db)
if not current_user:
return RedirectResponse(url="/login", status_code=303)
client_row, redirect = _portal_client_or_redirect(request, db, current_user)
if redirect:
return redirect
financial_year = _active_financial_year(request)
summary = build_client_portal_summary(db, client_row, financial_year=financial_year)
billing_summary = build_client_billing_summary(db, client_row, financial_year=financial_year)
client_visible_comments = list_client_visible_comments(db, client_row, limit=10, financial_year=financial_year)
recent_documents = list_client_engagement_documents(db, client_row, financial_year=financial_year)[:8]
auditor_card = build_client_auditor_card(db, client_row)
return templates.TemplateResponse(
"modules/clients/templates/clients/portal_dashboard.html",
_portal_context(
request,
db,
current_user,
client_row=client_row,
client_visible_comments=client_visible_comments,
recent_documents=recent_documents,
auditor_card=auditor_card,
**summary,
**billing_summary,
),
)
finally:
db.close()
@portal_router.get("/billing")
def client_portal_billing(request: Request, q: str = "", include_paid: str = "yes"):
db = CommonSessionLocal()
try:
current_user = get_current_user(request, db=db)
if not current_user:
return RedirectResponse(url="/login", status_code=303)
client_row, redirect = _portal_client_or_redirect(request, db, current_user)
if redirect:
return redirect
financial_year = _active_financial_year(request)
rows = list_client_portal_invoices(db, client_row, q=q, include_paid=(include_paid != "no"), financial_year=financial_year)
summary = build_client_billing_summary(db, client_row, financial_year=financial_year)
return templates.TemplateResponse(
"modules/billing/templates/billing/client_portal/list.html",
_portal_context(
request,
db,
current_user,
title="My Bills",
client_row=client_row,
rows=rows,
q=q,
include_paid=include_paid,
active_financial_year=financial_year,
**summary,
),
)
finally:
db.close()
@portal_router.get("/billing/receipts/{payment_id}")
def client_portal_receipt_print(request: Request, payment_id: int):
db = CommonSessionLocal()
try:
current_user = get_current_user(request, db=db)
if not current_user:
return RedirectResponse(url="/login", status_code=303)
client_row, redirect = _portal_client_or_redirect(request, db, current_user)
if redirect:
return redirect
payment = get_client_portal_payment(db, client_row, payment_id, financial_year=_active_financial_year(request))
if not payment:
return RedirectResponse(url="/client/billing", status_code=303)
invoice_ctx = build_invoice_print_context(db, payment.invoice)
return templates.TemplateResponse(
"modules/billing/templates/billing/payments/receipt_print.html",
_portal_context(
request,
db,
current_user,
title=f"Receipt {payment.receipt_no}",
client_row=client_row,
payment=payment,
invoice=payment.invoice,
invoice_ctx=invoice_ctx,
),
)
finally:
db.close()
@portal_router.get("/billing/{invoice_id}")
def client_portal_invoice_detail(request: Request, invoice_id: int):
db = CommonSessionLocal()
try:
current_user = get_current_user(request, db=db)
if not current_user:
return RedirectResponse(url="/login", status_code=303)
client_row, redirect = _portal_client_or_redirect(request, db, current_user)
if redirect:
return redirect
invoice = get_client_portal_invoice(db, client_row, invoice_id, financial_year=_active_financial_year(request))
if not invoice:
return RedirectResponse(url="/client/billing", status_code=303)
invoice_ctx = build_invoice_print_context(db, invoice)
return templates.TemplateResponse(
"modules/billing/templates/billing/client_portal/detail.html",
_portal_context(request, db, current_user, title=f"Invoice {invoice.invoice_no}", client_row=client_row, invoice=invoice, invoice_ctx=invoice_ctx),
)
finally:
db.close()
@portal_router.get("/billing/{invoice_id}/print")
def client_portal_invoice_print(request: Request, invoice_id: int):
db = CommonSessionLocal()
try:
current_user = get_current_user(request, db=db)
if not current_user:
return RedirectResponse(url="/login", status_code=303)
client_row, redirect = _portal_client_or_redirect(request, db, current_user)
if redirect:
return redirect
invoice = get_client_portal_invoice(db, client_row, invoice_id, financial_year=_active_financial_year(request))
if not invoice:
return RedirectResponse(url="/client/billing", status_code=303)
invoice_ctx = build_invoice_print_context(db, invoice)
return templates.TemplateResponse(
"modules/billing/templates/billing/invoice_print.html",
_portal_context(request, db, current_user, title=f"Print Invoice {invoice.invoice_no}", client_row=client_row, invoice=invoice, invoice_ctx=invoice_ctx),
)
finally:
db.close()
@portal_router.get("/billing/{invoice_id}/pay-now")
def client_portal_pay_now(request: Request, invoice_id: int):
db = CommonSessionLocal()
try:
current_user = get_current_user(request, db=db)
if not current_user:
return RedirectResponse(url="/login", status_code=303)
client_row, redirect = _portal_client_or_redirect(request, db, current_user)
if redirect:
return redirect
invoice = get_client_portal_invoice(db, client_row, invoice_id, financial_year=_active_financial_year(request))
if not invoice:
return RedirectResponse(url="/client/billing", status_code=303)
if invoice.status == "PAID" or invoice.balance_amount <= 0:
return RedirectResponse(url=f"/client/billing/{invoice.id}", status_code=303)
pay_ctx = build_client_payment_context(db, invoice)
return templates.TemplateResponse(
"modules/billing/templates/billing/client_portal/pay_now.html",
_portal_context(request, db, current_user, title=f"Pay Invoice {invoice.invoice_no}", client_row=client_row, invoice=invoice, **pay_ctx),
)
finally:
db.close()
@portal_router.post("/billing/{invoice_id}/payumoney/start")
def client_portal_payumoney_start(request: Request, invoice_id: int, csrf_token: str = Form(...)):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
current_user = get_current_user(request, db=db)
if not current_user:
return RedirectResponse(url="/login", status_code=303)
client_row, redirect = _portal_client_or_redirect(request, db, current_user)
if redirect:
return redirect
invoice = get_client_portal_invoice(db, client_row, invoice_id, financial_year=_active_financial_year(request))
if not invoice:
return RedirectResponse(url="/client/billing", status_code=303)
if invoice.status == "PAID" or invoice.balance_amount <= 0:
return RedirectResponse(url=f"/client/billing/{invoice.id}", status_code=303)
invoice_ctx = build_invoice_print_context(db, invoice)
settings = invoice_ctx.get("settings")
base_url = str(request.base_url).rstrip("/")
checkout = create_payumoney_transaction(db, invoice=invoice, settings=settings, base_url=base_url, client_ip=request.client.host if request.client else None)
db.commit()
payload = checkout["payload"]
inputs = "\n".join([f'<input type="hidden" name="{k}" value="{str(v).replace(chr(34), "&quot;")}" />' for k, v in payload.items()])
html = (
'<!doctype html><html><head><meta charset="utf-8"><title>Redirecting to PayUMoney</title></head>'
'<body style="font-family:Arial,sans-serif;padding:40px;text-align:center">'
'<h2>Redirecting to secure payment gateway...</h2><p>Please wait. Do not refresh this page.</p>'
f'<form id="payuForm" method="post" action="{checkout["checkout_url"]}">{inputs}'
'<noscript><button type="submit">Continue to PayUMoney</button></noscript></form>'
"<script>document.getElementById('payuForm').submit();</script></body></html>"
)
return HTMLResponse(html)
except Exception:
db.rollback()
raise
finally:
db.close()
@portal_router.post("/billing/{invoice_id}/cashfree/start")
def client_portal_cashfree_start(request: Request, invoice_id: int, csrf_token: str = Form(...)):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
current_user = get_current_user(request, db=db)
if not current_user:
return RedirectResponse(url="/login", status_code=303)
client_row, redirect = _portal_client_or_redirect(request, db, current_user)
if redirect:
return redirect
invoice = get_client_portal_invoice(db, client_row, invoice_id, financial_year=_active_financial_year(request))
if not invoice:
return RedirectResponse(url="/client/billing", status_code=303)
if invoice.status == "PAID" or invoice.balance_amount <= 0:
return RedirectResponse(url=f"/client/billing/{invoice.id}", status_code=303)
invoice_ctx = build_invoice_print_context(db, invoice)
settings = invoice_ctx.get("settings")
base_url = str(request.base_url).rstrip("/")
checkout = create_cashfree_transaction(db, invoice=invoice, settings=settings, base_url=base_url, client_ip=request.client.host if request.client else None)
db.commit()
payment_session_id = checkout["payment_session_id"]
mode = "production" if str(getattr(settings, "cashfree_mode", "TEST")).upper() == "LIVE" else "sandbox"
html = f"""
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Redirecting to Cashfree</title>
<script src="https://sdk.cashfree.com/js/v3/cashfree.js"></script>
</head>
<body style="font-family:Arial,sans-serif;padding:40px;text-align:center">
<h2>Redirecting to Cashfree checkout...</h2>
<p>Please wait. Do not refresh this page.</p>
<button id="manualBtn" style="display:none;padding:10px 16px;border-radius:8px;border:1px solid #ddd;background:#0f172a;color:#fff">Continue to Cashfree</button>
<script>
const cashfree = Cashfree({{ mode: "{mode}" }});
const checkoutOptions = {{ paymentSessionId: "{payment_session_id}", redirectTarget: "_self" }};
function openCheckout() {{ cashfree.checkout(checkoutOptions); }}
document.getElementById('manualBtn').onclick = openCheckout;
setTimeout(function(){{ document.getElementById('manualBtn').style.display='inline-block'; }}, 1200);
openCheckout();
</script>
</body>
</html>
"""
return HTMLResponse(html)
except Exception:
db.rollback()
raise
finally:
db.close()
@portal_router.get("/billing/cashfree/return")
def client_portal_cashfree_return(request: Request, order_id: str = ""):
db = CommonSessionLocal()
try:
transaction = process_cashfree_return(db, order_id=order_id) if order_id else None
db.commit()
current_user = get_current_user(request, db=db)
result = "success" if transaction and transaction.status == "SUCCESS" else "failure"
if result == "success":
heading = "Payment successful"
message = "Your Cashfree payment has been confirmed and receipt has been recorded."
else:
heading = "Payment not completed"
message = "The Cashfree payment is not yet confirmed. Please contact the audit firm if your bank account has been debited."
if current_user:
return templates.TemplateResponse(
"modules/billing/templates/billing/client_portal/payment_status.html",
_portal_context(request, db, current_user, title=heading, transaction=transaction, result=result, heading=heading, message=message),
)
invoice_url = f"/client/billing/{transaction.invoice_id}" if transaction else "/client/billing"
return HTMLResponse(f"<h2>{heading}</h2><p>{message}</p><p><a href='{invoice_url}'>Continue</a></p>")
except Exception:
db.rollback()
raise
finally:
db.close()
@portal_router.post("/billing/cashfree/webhook")
async def client_portal_cashfree_webhook(request: Request):
raw_body = await request.body()
db = CommonSessionLocal()
try:
transaction = process_cashfree_webhook(db, raw_body=raw_body, headers=dict(request.headers))
db.commit()
return JSONResponse({"ok": bool(transaction), "status": getattr(transaction, "status", None)})
except Exception as exc:
db.rollback()
return JSONResponse({"ok": False, "error": str(exc)}, status_code=400)
finally:
db.close()
async def _payumoney_callback(request: Request, expected: str):
db = CommonSessionLocal()
try:
data = dict(await request.form()) if request.method == "POST" else dict(request.query_params)
transaction = process_payumoney_response(db, response_data=data)
db.commit()
current_user = get_current_user(request, db=db)
result = "success" if transaction and transaction.status == "SUCCESS" else "failure"
if result == "success":
heading = "Payment successful"
message = "Your online payment has been confirmed and receipt has been recorded."
elif transaction and transaction.status == "HASH_FAILED":
heading = "Payment verification failed"
message = "The payment response could not be verified. Please contact the audit firm before retrying."
else:
heading = "Payment not completed"
message = "The payment was cancelled, failed, or could not be confirmed by the gateway."
if current_user:
return templates.TemplateResponse(
"modules/billing/templates/billing/client_portal/payment_status.html",
_portal_context(request, db, current_user, title=heading, transaction=transaction, result=result, heading=heading, message=message),
)
invoice_url = f"/client/billing/{transaction.invoice_id}" if transaction else "/client/billing"
return HTMLResponse(f"<h2>{heading}</h2><p>{message}</p><p><a href='{invoice_url}'>Continue</a></p>")
except Exception:
db.rollback()
raise
finally:
db.close()
@portal_router.api_route("/billing/payumoney/success", methods=["GET", "POST"])
async def client_portal_payumoney_success(request: Request):
return await _payumoney_callback(request, "success")
@portal_router.api_route("/billing/payumoney/failure", methods=["GET", "POST"])
async def client_portal_payumoney_failure(request: Request):
return await _payumoney_callback(request, "failure")
@portal_router.get("/profile")
def client_portal_profile_page(request: Request):
db = CommonSessionLocal()
try:
current_user = get_current_user(request, db=db)
if not current_user:
return RedirectResponse(url="/login", status_code=303)
client_row, redirect = _portal_client_or_redirect(request, db, current_user)
if redirect:
return redirect
form_data = {
"client_name": client_row.get("client_name") or "",
"trade_name": client_row.get("trade_name") or "",
"contact_person_name": client_row.get("contact_person_name") or "",
"contact_person_designation": client_row.get("contact_person_designation") or "",
"mobile": client_row.get("mobile") or "",
"alternate_mobile": client_row.get("alternate_mobile") or "",
"email": client_row.get("email") or "",
"alternate_email": client_row.get("alternate_email") or "",
"address_line_1": client_row.get("address_line_1") or "",
"address_line_2": client_row.get("address_line_2") or "",
"city": client_row.get("city") or "",
"state": client_row.get("state") or "",
"pincode": client_row.get("pincode") or "",
"country": client_row.get("country") or "India",
"notes": client_row.get("notes") or "",
}
return templates.TemplateResponse(
"modules/clients/templates/clients/profile.html",
_portal_context(request, db, current_user, client_row=client_row, form_data=form_data, form_errors=[]),
)
finally:
db.close()
@portal_router.post("/profile")
async def client_portal_profile_submit(request: Request):
db = CommonSessionLocal()
try:
current_user = get_current_user(request, db=db)
if not current_user:
return RedirectResponse(url="/login", status_code=303)
client_row, redirect = _portal_client_or_redirect(request, db, current_user)
if redirect:
return redirect
form = await request.form()
validate_csrf(request, form.get("csrf_token"))
raw_payload = {
"client_name": form.get("client_name", ""),
"trade_name": form.get("trade_name"),
"contact_person_name": form.get("contact_person_name"),
"contact_person_designation": form.get("contact_person_designation"),
"mobile": form.get("mobile"),
"alternate_mobile": form.get("alternate_mobile"),
"email": form.get("email"),
"alternate_email": form.get("alternate_email"),
"address_line_1": form.get("address_line_1"),
"address_line_2": form.get("address_line_2"),
"city": form.get("city"),
"state": form.get("state"),
"pincode": form.get("pincode"),
"country": form.get("country") or "India",
"notes": form.get("notes"),
}
try:
data = ClientUpdate(**raw_payload)
row = repository.get_client_by_id(db, int(client_row["id"]))
update_client_self_profile_service(db, row=row, data=data, current_user=current_user)
return RedirectResponse(url="/client/dashboard", status_code=303)
except Exception as exc:
refreshed = repository.get_portal_client_for_user(db, user=current_user) or client_row
return templates.TemplateResponse(
"modules/clients/templates/clients/profile.html",
_portal_context(request, db, current_user, client_row=refreshed, form_data=raw_payload, form_errors=_field_errors(exc)),
status_code=400,
)
finally:
db.close()
@portal_router.get("/compliance")
def client_portal_compliance(request: Request):
db = CommonSessionLocal()
try:
current_user = get_current_user(request, db=db)
if not current_user:
return RedirectResponse(url="/login", status_code=303)
client_row, redirect = _portal_client_or_redirect(request, db, current_user)
if redirect:
return redirect
financial_year = _active_financial_year(request)
summary = build_client_portal_summary(db, client_row, financial_year=financial_year)
return templates.TemplateResponse(
"modules/clients/templates/clients/compliance.html",
_portal_context(request, db, current_user, client_row=client_row, **summary),
)
finally:
db.close()
@portal_router.get("/engagements/{engagement_id}")
def client_portal_engagement_detail(request: Request, engagement_id: int):
db = CommonSessionLocal()
try:
current_user = get_current_user(request, db=db)
if not current_user:
return RedirectResponse(url="/login", status_code=303)
client_row, redirect = _portal_client_or_redirect(request, db, current_user)
if redirect:
return redirect
financial_year = _active_financial_year(request)
engagement = get_client_engagement(db, client_row, engagement_id, financial_year=financial_year)
if not engagement:
return RedirectResponse(url="/client/compliance", status_code=303)
tasks = list_client_tasks_for_engagement(db, client_row, engagement_id)
documents = list_client_engagement_documents(db, client_row, engagement_id=engagement_id, financial_year=financial_year)
comments = [c for c in list_client_visible_comments(db, client_row, limit=200, financial_year=financial_year) if c.subscription_id == engagement_id]
return templates.TemplateResponse(
"modules/clients/templates/clients/engagement_detail.html",
_portal_context(
request,
db,
current_user,
client_row=client_row,
engagement=engagement,
tasks=tasks,
documents=documents,
comments=comments,
),
)
finally:
db.close()
@portal_router.post("/tasks/{task_id}/reply")
async def client_portal_task_reply(request: Request, task_id: int):
db = CommonSessionLocal()
try:
current_user = get_current_user(request, db=db)
if not current_user:
return RedirectResponse(url="/login", status_code=303)
client_row, redirect = _portal_client_or_redirect(request, db, current_user)
if redirect:
return redirect
form = await request.form()
validate_csrf(request, form.get("csrf_token"))
task = get_client_task(db, client_row, task_id, financial_year=_active_financial_year(request))
if not task:
return RedirectResponse(url="/client/compliance?error=task_not_found", status_code=303)
try:
create_client_reply(db, client_row=client_row, task=task, message=form.get("message", ""), user=current_user)
db.commit()
return RedirectResponse(url=f"/client/engagements/{task.subscription_id}?reply=sent", status_code=303)
except Exception:
db.rollback()
return RedirectResponse(url=f"/client/engagements/{task.subscription_id}?error=reply_failed", status_code=303)
finally:
db.close()
@portal_router.get("/documents")
def client_portal_documents(request: Request):
db = CommonSessionLocal()
try:
current_user = get_current_user(request, db=db)
if not current_user:
return RedirectResponse(url="/login", status_code=303)
client_row, redirect = _portal_client_or_redirect(request, db, current_user)
if redirect:
return redirect
engagement_documents = list_client_engagement_documents(db, client_row, financial_year=_active_financial_year(request))
permanent_documents = list_client_permanent_documents(db, client_row)
return templates.TemplateResponse(
"modules/clients/templates/clients/documents.html",
_portal_context(
request,
db,
current_user,
client_row=client_row,
engagement_documents=engagement_documents,
permanent_documents=permanent_documents,
),
)
finally:
db.close()
@portal_router.get("/messages")
def client_portal_messages(request: Request):
db = CommonSessionLocal()
try:
current_user = get_current_user(request, db=db)
if not current_user:
return RedirectResponse(url="/login", status_code=303)
client_row, redirect = _portal_client_or_redirect(request, db, current_user)
if redirect:
return redirect
financial_year = _active_financial_year(request)
comments = list_client_visible_comments(db, client_row, limit=200, financial_year=financial_year)
engagements = list_client_engagements(db, client_row, limit=200, financial_year=financial_year)
return templates.TemplateResponse(
"modules/clients/templates/clients/messages.html",
_portal_context(request, db, current_user, client_row=client_row, comments=comments, engagements=engagements),
)
finally:
db.close()
@portal_router.get("/documents/engagement-versions/{version_id}/download")
def client_portal_download_engagement_document(request: Request, version_id: int):
db = CommonSessionLocal()
try:
current_user = get_current_user(request, db=db)
if not current_user:
return RedirectResponse(url="/login", status_code=303)
client_row, redirect = _portal_client_or_redirect(request, db, current_user)
if redirect:
return redirect
version = get_version(db, version_id)
document = version.document if version else None
active_fy = _active_financial_year(request)
if not version or not document or int(document.client_id) != int(client_row.get("id")) or int(document.tenant_id) != int(client_row.get("tenant_id")):
return RedirectResponse(url="/client/documents?error=not_allowed", status_code=303)
if active_fy and getattr(document, "financial_year", None) != active_fy:
return RedirectResponse(url="/client/documents?error=not_allowed", status_code=303)
path = version_absolute_path(version)
if not path.exists():
return RedirectResponse(url="/client/documents?error=file_not_available", status_code=303)
return FileResponse(path, media_type=version.content_type or "application/octet-stream", filename=version.original_filename)
finally:
db.close()
@portal_router.get("/documents/permanent-versions/{version_id}/download")
def client_portal_download_permanent_document(request: Request, version_id: int):
db = CommonSessionLocal()
try:
current_user = get_current_user(request, db=db)
if not current_user:
return RedirectResponse(url="/login", status_code=303)
client_row, redirect = _portal_client_or_redirect(request, db, current_user)
if redirect:
return redirect
version = get_permanent_version(db, version_id)
document = version.document if version else None
active_fy = _active_financial_year(request)
if not version or not document or int(document.client_id) != int(client_row.get("id")) or int(document.tenant_id) != int(client_row.get("tenant_id")):
return RedirectResponse(url="/client/documents?error=not_allowed", status_code=303)
if active_fy and getattr(document, "financial_year", None) != active_fy:
return RedirectResponse(url="/client/documents?error=not_allowed", status_code=303)
path = permanent_version_absolute_path(version)
if not path.exists():
return RedirectResponse(url="/client/documents?error=file_not_available", status_code=303)
return FileResponse(path, media_type=version.content_type or "application/octet-stream", filename=version.original_filename)
finally:
db.close()