Files
2026-08-05 10:52:36 +05:30

2072 lines
87 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 sqlalchemy import func, 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.security.otp import start_otp, verify_otp
from app.core.templating import templates
from app.modules.clients import repository
from app.modules.clients.access import build_scope, can_view_client_row, enforce_partner_scope, is_partner_role
from app.modules.clients.constants import CLIENT_ACCEPTANCE_STATUS, 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,
approve_client_acceptance_service,
archive_client_service,
create_client_service,
deactivate_client_service,
export_clients_csv,
get_client_or_404,
list_client_audit_logs,
list_clients_payload,
reject_client_acceptance_service,
restore_client_service,
mark_client_acceptance_pending_service,
request_client_acceptance_declarations_service,
submit_client_acceptance_declaration_service,
sync_client_kyc_from_permanent_documents_service,
verify_client_kyc_service,
reject_client_kyc_service,
draft_client_engagement_letter_service,
approve_and_send_engagement_letter_service,
render_engagement_letter_html,
digitally_accept_engagement_letter_service,
save_manual_signed_engagement_letter_service,
verify_manual_engagement_letter_service,
build_client_acceptance_workflow_payload,
get_current_engagement_letter,
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.client_groups.service import get_group, list_groups
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.email_integration.services import send_auth_otp_email
from app.modules.documents.models import PermanentClientDocument
from app.modules.consultants.service import list_consultants, get_primary_client_consultant_link, get_client_consultant_summary
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,
"client_acceptance_statuses": CLIENT_ACCEPTANCE_STATUS,
}
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():
from app.core.http_responses import ui_access_denied
return ui_access_denied()
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 _apply_role_scope(scope, user, role_names):
scope = _elevate_scope_for_system_admin(scope, role_names)
return enforce_partner_scope(scope, user=user, role_names=role_names)
def _viewer_partner_id(user, role_names):
return int(user.id) if is_partner_role(role_names) else None
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,
"client_group_id": int(form.get("client_group_id")) if form.get("client_group_id") not in (None, "", "None") else None,
"group_relationship": form.get("group_relationship"),
"is_group_head": _form_bool(form.get("is_group_head")),
"default_performing_partner_user_id": int(form.get("default_performing_partner_user_id")) if form.get("default_performing_partner_user_id") not in (None, "", "None") else None,
"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,
"referred_by_consultant_id": int(form.get("referred_by_consultant_id")) if form.get("referred_by_consultant_id") not in (None, "", "None") else None,
"primary_consultant_id": int(form.get("primary_consultant_id")) if form.get("primary_consultant_id") not in (None, "", "None") else None,
"referral_date": form.get("referral_date") or None,
"referral_reference": form.get("referral_reference"),
"referral_status": form.get("referral_status") or "active",
"communication_routing_mode": form.get("communication_routing_mode") or "client_and_consultant",
"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,
"acceptance_status": form.get("acceptance_status") or "pending_review",
"acceptance_required": _form_bool(form.get("acceptance_required")),
"independence_check_completed": _form_bool(form.get("independence_check_completed")),
"conflict_check_completed": _form_bool(form.get("conflict_check_completed")),
"kyc_completed": _form_bool(form.get("kyc_completed")),
"engagement_letter_required": _form_bool(form.get("engagement_letter_required")),
"engagement_letter_received": _form_bool(form.get("engagement_letter_received")),
"acceptance_review_notes": form.get("acceptance_review_notes"),
"acceptance_rejection_reason": form.get("acceptance_rejection_reason"),
"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),
"performing_partners": repository.list_all_partners(db),
"review_partners": repository.list_all_partners(db),
"active_tenant_id": tenant_id,
"active_branch_id": branch_id,
"consultants": list_consultants(db, tenant_id=tenant_id, include_inactive=False) if tenant_id else [],
"client_groups": [item["group"] for item in list_groups(db, tenant_id=tenant_id)] if tenant_id else [],
}
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,
),
"consultants": list_consultants(db, tenant_id=tenant_id, branch_id=branch_id, include_inactive=False),
"client_groups": [item["group"] for item in list_groups(db, tenant_id=tenant_id)],
"performing_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 = "",
client_group_id: int | None = None,
partner_id: int | None = None,
include_archived: bool = False,
page: int = 1,
per_page: int = 25,
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)
partner_client_view = is_partner_role(role_names)
firm_admin_client_view = "firm admin" in role_names and not partner_client_view
compact_client_view = partner_client_view or firm_admin_client_view
scope = build_scope(request, user, has)
scope = _apply_role_scope(scope, user, role_names)
filters = ClientListFilters.from_params(
q=q,
status=status,
client_type=client_type,
partner_id=partner_id,
client_group_id=client_group_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=None if is_partner_role(role_names) else filters.partner_id,
viewer_partner_id=_viewer_partner_id(user, role_names),
q="" if compact_client_view else filters.q,
status="" if compact_client_view else filters.status,
client_type="" if compact_client_view else filters.client_type,
client_group_id=None if compact_client_view else filters.client_group_id,
include_archived=filters.include_archived,
page=1 if compact_client_view else filters.page,
per_page=5000 if compact_client_view else 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"),
partner_client_view=partner_client_view,
firm_admin_client_view=firm_admin_client_view,
compact_client_view=compact_client_view,
q=filters.q,
status=filters.status,
client_type=filters.client_type,
partner_id=filters.partner_id,
client_group_id=filters.client_group_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 = _apply_role_scope(scope, user, 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=None if is_partner_role(role_names) else filters.partner_id,
viewer_partner_id=_viewer_partner_id(user, role_names),
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 = _apply_role_scope(scope, user, 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.get_partner(db, user.id)] if is_partner_role(role_names) else 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 = _apply_role_scope(scope, user, 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 = _apply_role_scope(scope, user, 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 = _apply_role_scope(scope, user, 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 = _apply_role_scope(scope, user, role_names)
defaults = {
"status": "active",
"client_type": "Other",
"country": "India",
"engagement_mode": "hybrid" if form_mode == "firm_admin" else "internal_managed",
"partner_id": scope.locked_partner_id or getattr(user, "id", None),
"branch_id": scope.branch_id,
"tenant_id": scope.tenant_id,
"acceptance_status": "pending_review",
"acceptance_required": True,
"engagement_letter_required": True,
"referral_status": "active",
"communication_routing_mode": "client_and_consultant",
}
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 = _apply_role_scope(scope, user, 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"/documents/permanent/clients/{row.id}?onboarding=1", 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 = _apply_role_scope(scope, user, role_names)
row = repository.get_client_detail_payload(db, client_id, viewer_partner_id=_viewer_partner_id(user, role_names))
if not row or not can_view_client_row(scope, row, user_id=user.id):
return _redirect_denied()
review_only = bool(is_partner_role(role_names) and int(row.get("effective_partner_id") or 0) != int(user.id))
audit_logs = list_client_audit_logs(db, row=type("Tmp", (), {"id": row["id"]})(), limit=10) if has("clients.audit_log.view") and not review_only else []
permanent_document_count = db.execute(
select(func.count(PermanentClientDocument.id)).where(
PermanentClientDocument.client_id == client_id,
PermanentClientDocument.is_deleted.is_(False),
)
).scalar_one()
return _render(
request,
"modules/clients/templates/clients/detail.html",
db,
user,
title=f"Client • {row['client_name']}",
row=row,
audit_logs=audit_logs,
permanent_document_count=permanent_document_count,
scope=scope,
can_edit=has("clients.edit") and not review_only,
can_deactivate=has("clients.deactivate") and not review_only,
can_activate=has("clients.activate") and not review_only,
can_archive=has("clients.archive") and not review_only,
can_restore=has("clients.restore") and not review_only,
consultant_summary=get_client_consultant_summary(db, tenant_id=int(row["tenant_id"]), client_id=client_id),
client_group=get_group(db, tenant_id=int(row["tenant_id"]), group_id=int(row["client_group_id"])) if row.get("client_group_id") else None,
can_manage_acceptance=has("clients.acceptance.manage"),
can_approve_acceptance=has("clients.acceptance.approve"),
)
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 = _apply_role_scope(scope, user, 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,
viewer_partner_id=_viewer_partner_id(user, role_names),
)
return _render(
request,
"modules/clients/templates/clients/edit.html",
db,
user,
title=f"Edit Client • {row.client_name}",
row=row,
form_data={**row.__dict__, "primary_consultant_id": getattr(get_primary_client_consultant_link(db, tenant_id=row.tenant_id, client_id=row.id), "consultant_id", None)},
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 = _apply_role_scope(scope, user, 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,
viewer_partner_id=_viewer_partner_id(user, 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=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}/acceptance/approve")
async def client_acceptance_approve(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.acceptance.approve"):
return _redirect_denied()
role_names = _role_names(db, user)
scope = build_scope(request, user, has)
scope = _apply_role_scope(scope, user, 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,
viewer_partner_id=_viewer_partner_id(user, role_names),
)
try:
row = approve_client_acceptance_service(db, row=row, actor_user_id=user.id, review_notes=form.get("acceptance_review_notes"))
return RedirectResponse(url=f"/clients/{row.id}", status_code=303)
except Exception as exc:
return _render(
request,
"modules/clients/templates/clients/detail.html",
db,
user,
title=f"Client • {row.client_name}",
row=repository.get_client_detail_payload(db, client_id),
audit_logs=list_client_audit_logs(db, row=type("Tmp", (), {"id": client_id})(), limit=10) if has("clients.audit_log.view") else [],
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"),
can_manage_acceptance=has("clients.acceptance.manage"),
can_approve_acceptance=has("clients.acceptance.approve"),
form_errors=_field_errors(exc),
)
finally:
db.close()
@router.post("/{client_id}/acceptance/reject")
async def client_acceptance_reject(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.acceptance.approve"):
return _redirect_denied()
role_names = _role_names(db, user)
scope = build_scope(request, user, has)
scope = _apply_role_scope(scope, user, 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,
viewer_partner_id=_viewer_partner_id(user, role_names),
)
row = reject_client_acceptance_service(db, row=row, actor_user_id=user.id, rejection_reason=form.get("acceptance_rejection_reason"))
return RedirectResponse(url=f"/clients/{row.id}", status_code=303)
finally:
db.close()
@router.post("/{client_id}/acceptance/pending")
async def client_acceptance_pending(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.acceptance.manage"):
return _redirect_denied()
role_names = _role_names(db, user)
scope = build_scope(request, user, has)
scope = _apply_role_scope(scope, user, 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,
viewer_partner_id=_viewer_partner_id(user, role_names),
)
row = mark_client_acceptance_pending_service(db, row=row, actor_user_id=user.id, review_notes=form.get("acceptance_review_notes"))
return RedirectResponse(url=f"/clients/{row.id}", status_code=303)
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 = _apply_role_scope(scope, user, 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,
viewer_partner_id=_viewer_partner_id(user, role_names),
)
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 = _apply_role_scope(scope, user, 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,
viewer_partner_id=_viewer_partner_id(user, role_names),
)
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 = _apply_role_scope(scope, user, 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,
viewer_partner_id=_viewer_partner_id(user, role_names),
)
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 = _apply_role_scope(scope, user, 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,
viewer_partner_id=_viewer_partner_id(user, role_names),
)
restore_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}/acceptance/request-declarations")
async def client_acceptance_request_declarations(request: Request, client_id: int, csrf_token: str = Form(...)):
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return RedirectResponse(url="/login", status_code=303)
validate_csrf(request, csrf_token)
has = _has_perm_factory(db, user)
if not has("clients.acceptance.manage"):
return _redirect_denied()
scope = _elevate_scope_for_system_admin(build_scope(request, user, has), _role_names(db, user))
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, viewer_partner_id=_viewer_partner_id(user, role_names))
request_client_acceptance_declarations_service(db, row=row, actor_user_id=user.id)
return RedirectResponse(url=f"/clients/{client_id}", status_code=303)
finally:
db.close()
@router.post("/{client_id}/acceptance/declarations/{declaration_id}/submit")
async def client_acceptance_submit_declaration(
request: Request,
client_id: int,
declaration_id: int,
csrf_token: str = Form(...),
declaration_result: str = Form("clear"),
response_notes: str = Form(""),
issue_details: str = Form(""),
):
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return RedirectResponse(url="/login", status_code=303)
validate_csrf(request, csrf_token)
submit_client_acceptance_declaration_service(
db,
declaration_id=declaration_id,
current_user=user,
clear=(declaration_result == "clear"),
notes=response_notes,
issue_details=issue_details,
request=request,
)
return RedirectResponse(url=f"/clients/{client_id}", status_code=303)
finally:
db.close()
@router.post("/{client_id}/acceptance/kyc/sync")
async def client_acceptance_kyc_sync(request: Request, client_id: int, csrf_token: str = Form(...)):
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return RedirectResponse(url="/login", status_code=303)
validate_csrf(request, csrf_token)
has = _has_perm_factory(db, user)
if not has("clients.acceptance.manage"):
return _redirect_denied()
scope = _elevate_scope_for_system_admin(build_scope(request, user, has), _role_names(db, user))
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, viewer_partner_id=_viewer_partner_id(user, role_names))
sync_client_kyc_from_permanent_documents_service(db, row=row, actor_user_id=user.id)
return RedirectResponse(url=f"/clients/{client_id}", status_code=303)
finally:
db.close()
@router.post("/{client_id}/acceptance/kyc/verify")
async def client_acceptance_kyc_verify(request: Request, client_id: int, csrf_token: str = Form(...), verification_notes: str = Form("")):
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return RedirectResponse(url="/login", status_code=303)
validate_csrf(request, csrf_token)
has = _has_perm_factory(db, user)
if not has("clients.acceptance.approve") and not has("clients.acceptance.manage"):
return _redirect_denied()
scope = _elevate_scope_for_system_admin(build_scope(request, user, has), _role_names(db, user))
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, viewer_partner_id=_viewer_partner_id(user, role_names))
verify_client_kyc_service(db, row=row, actor_user_id=user.id, notes=verification_notes)
return RedirectResponse(url=f"/clients/{client_id}", status_code=303)
finally:
db.close()
@router.post("/{client_id}/acceptance/kyc/reject")
async def client_acceptance_kyc_reject(request: Request, client_id: int, csrf_token: str = Form(...), verification_notes: str = Form("")):
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return RedirectResponse(url="/login", status_code=303)
validate_csrf(request, csrf_token)
has = _has_perm_factory(db, user)
if not has("clients.acceptance.manage"):
return _redirect_denied()
scope = _elevate_scope_for_system_admin(build_scope(request, user, has), _role_names(db, user))
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, viewer_partner_id=_viewer_partner_id(user, role_names))
reject_client_kyc_service(db, row=row, actor_user_id=user.id, notes=verification_notes)
return RedirectResponse(url=f"/clients/{client_id}", status_code=303)
finally:
db.close()
@router.post("/{client_id}/acceptance/engagement-letter/draft")
async def client_engagement_letter_draft(request: Request, client_id: int, csrf_token: str = Form(...), title: str = Form(""), body_text: str = Form("")):
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return RedirectResponse(url="/login", status_code=303)
validate_csrf(request, csrf_token)
has = _has_perm_factory(db, user)
if not has("clients.acceptance.manage"):
return _redirect_denied()
scope = _elevate_scope_for_system_admin(build_scope(request, user, has), _role_names(db, user))
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, viewer_partner_id=_viewer_partner_id(user, role_names))
draft_client_engagement_letter_service(db, row=row, actor_user_id=user.id, title=title, body_text=body_text)
return RedirectResponse(url=f"/clients/{client_id}", status_code=303)
finally:
db.close()
@router.post("/{client_id}/acceptance/engagement-letter/{letter_id}/approve-send")
async def client_engagement_letter_approve_send(request: Request, client_id: int, letter_id: int, csrf_token: str = Form(...)):
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return RedirectResponse(url="/login", status_code=303)
validate_csrf(request, csrf_token)
has = _has_perm_factory(db, user)
if not has("clients.acceptance.approve"):
return _redirect_denied()
scope = _elevate_scope_for_system_admin(build_scope(request, user, has), _role_names(db, user))
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, viewer_partner_id=_viewer_partner_id(user, role_names))
approve_and_send_engagement_letter_service(db, row=row, letter_id=letter_id, actor_user_id=user.id)
return RedirectResponse(url=f"/clients/{client_id}", status_code=303)
finally:
db.close()
@router.get("/{client_id}/acceptance/engagement-letter/{letter_id}/download")
def client_engagement_letter_download(request: Request, client_id: int, letter_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)
scope = _elevate_scope_for_system_admin(build_scope(request, user, has), _role_names(db, user))
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=True, allow_all_clients=scope.allow_all_clients, viewer_partner_id=_viewer_partner_id(user, role_names))
letter = get_current_engagement_letter(db, client_id=client_id)
if not letter or letter.id != letter_id:
return _redirect_denied()
return HTMLResponse(render_engagement_letter_html(letter, row))
finally:
db.close()
@router.post("/{client_id}/acceptance/engagement-letter/{letter_id}/verify-manual")
async def client_engagement_letter_verify_manual(request: Request, client_id: int, letter_id: int, csrf_token: str = Form(...)):
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return RedirectResponse(url="/login", status_code=303)
validate_csrf(request, csrf_token)
has = _has_perm_factory(db, user)
if not has("clients.acceptance.approve") and not has("clients.acceptance.manage"):
return _redirect_denied()
scope = _elevate_scope_for_system_admin(build_scope(request, user, has), _role_names(db, user))
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, viewer_partner_id=_viewer_partner_id(user, role_names))
verify_manual_engagement_letter_service(db, row=row, letter_id=letter_id, actor_user_id=user.id)
return RedirectResponse(url=f"/clients/{client_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
CLIENT_PORTAL_TABS = {
"overview": "modules/clients/templates/clients/portal_partials/overview.html",
"action-centre": "modules/clients/templates/clients/portal_partials/action_centre.html",
"services-documents": "modules/clients/templates/clients/portal_partials/services_documents.html",
"billing-messages": "modules/clients/templates/clients/portal_partials/billing_messages.html",
"reports": "modules/clients/templates/clients/portal_partials/reports.html",
"pending": "modules/clients/templates/clients/portal_partials/pending.html",
"services": "modules/clients/templates/clients/portal_partials/services.html",
"documents": "modules/clients/templates/clients/portal_partials/documents.html",
"billing": "modules/clients/templates/clients/portal_partials/billing.html",
"messages": "modules/clients/templates/clients/portal_partials/messages.html",
}
def _client_dashboard_payload(db, client_row, financial_year: str | None = None) -> dict:
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)
return {
"client_visible_comments": list_client_visible_comments(db, client_row, limit=20, financial_year=financial_year),
"recent_documents": list_client_engagement_documents(db, client_row, financial_year=financial_year)[:12],
"permanent_documents": list_client_permanent_documents(db, client_row),
"auditor_card": build_client_auditor_card(db, client_row),
**summary,
**billing_summary,
}
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("/dashboard/tab/{tab_name}")
def client_portal_dashboard_tab(request: Request, tab_name: str):
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
active_tab = tab_name if tab_name in CLIENT_PORTAL_TABS else "overview"
if not client_row:
return templates.TemplateResponse(
CLIENT_PORTAL_TABS["overview"],
_portal_context(request, db, current_user, client_row=None, active_tab=active_tab),
status_code=200,
)
financial_year = _active_financial_year(request)
payload = _client_dashboard_payload(db, client_row, financial_year=financial_year)
return templates.TemplateResponse(
CLIENT_PORTAL_TABS[active_tab],
_portal_context(
request,
db,
current_user,
client_row=client_row,
active_tab=active_tab,
active_financial_year=financial_year,
**payload,
),
)
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()
@portal_router.get("/engagement-letter")
def client_portal_engagement_letter(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
letter = get_current_engagement_letter(db, client_id=client_row["id"] if isinstance(client_row, dict) else client_row.id)
return templates.TemplateResponse(
"modules/clients/templates/clients/portal_engagement_letter.html",
_portal_context(request, db, current_user, client_row=client_row, engagement_letter=letter),
)
finally:
db.close()
@portal_router.post("/engagement-letter/{letter_id}/send-otp")
async def client_portal_engagement_letter_send_otp(request: Request, letter_id: int, csrf_token: str = Form(...)):
db = CommonSessionLocal()
try:
current_user = get_current_user(request, db=db)
if not current_user:
return RedirectResponse(url="/login", status_code=303)
validate_csrf(request, csrf_token)
client_row, redirect = _portal_client_or_redirect(request, db, current_user)
if redirect:
return redirect
letter = get_current_engagement_letter(db, client_id=client_row["id"] if isinstance(client_row, dict) else client_row.id)
if not letter or letter.id != letter_id or letter.status not in ["sent_to_client", "manual_uploaded", "rejected"]:
return RedirectResponse(url="/client/engagement-letter?error=letter_not_available", status_code=303)
code = start_otp(request)
try:
send_auth_otp_email(db, user=current_user, otp_code=code, purpose="engagement_letter_acceptance")
db.commit()
except Exception as exc:
print(f"[ENGAGEMENT LETTER OTP ERROR] user={current_user.email} error={exc}")
request.session["client_engagement_letter_otp_letter_id"] = letter_id
return RedirectResponse(url="/client/engagement-letter?otp=sent", status_code=303)
finally:
db.close()
@portal_router.post("/engagement-letter/{letter_id}/accept-digital")
async def client_portal_engagement_letter_accept_digital(request: Request, letter_id: int, csrf_token: str = Form(...), otp: str = Form(...), declaration_text: str = Form("")):
db = CommonSessionLocal()
try:
current_user = get_current_user(request, db=db)
if not current_user:
return RedirectResponse(url="/login", status_code=303)
validate_csrf(request, csrf_token)
client_row, redirect = _portal_client_or_redirect(request, db, current_user)
if redirect:
return redirect
if request.session.get("client_engagement_letter_otp_letter_id") != letter_id or not verify_otp(request, otp):
return RedirectResponse(url="/client/engagement-letter?error=invalid_otp", status_code=303)
row_obj = repository.get_client_by_id(db, client_row["id"] if isinstance(client_row, dict) else client_row.id)
digitally_accept_engagement_letter_service(db, row=row_obj, letter_id=letter_id, current_user=current_user, declaration_text=declaration_text, request=request)
request.session.pop("client_engagement_letter_otp_letter_id", None)
return RedirectResponse(url="/client/engagement-letter", status_code=303)
finally:
db.close()
@portal_router.post("/engagement-letter/{letter_id}/upload-signed")
async def client_portal_engagement_letter_upload_signed(request: Request, letter_id: int, csrf_token: str = Form(...), signed_file: UploadFile = File(...)):
db = CommonSessionLocal()
try:
current_user = get_current_user(request, db=db)
if not current_user:
return RedirectResponse(url="/login", status_code=303)
validate_csrf(request, csrf_token)
client_row, redirect = _portal_client_or_redirect(request, db, current_user)
if redirect:
return redirect
row_obj = repository.get_client_by_id(db, client_row["id"] if isinstance(client_row, dict) else client_row.id)
save_manual_signed_engagement_letter_service(db, row=row_obj, letter_id=letter_id, upload_file=signed_file, current_user=current_user, request=request)
return RedirectResponse(url="/client/engagement-letter", status_code=303)
finally:
db.close()