2059 lines
86 KiB
Python
2059 lines
86 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_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),
|
|
"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)],
|
|
"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)
|
|
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=filters.q,
|
|
status=filters.status,
|
|
client_type=filters.client_type,
|
|
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,
|
|
)
|
|
|
|
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,
|
|
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), """)}" />' 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()
|