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 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.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.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 _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, "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), "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": "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, } 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"/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 = _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 [] 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"), 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"), ) 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}/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 = _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, ) 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 = _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, ) 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 = _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, ) 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 = _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() @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) 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) 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) 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) 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) 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) 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) 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) 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'' for k, v in payload.items()]) html = ( '
Please wait. Do not refresh this page.
' f'' "" ) 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"""Please wait. Do not refresh this page.
""" 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"{message}
") 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"{message}
") 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()