Add digital client acceptance workflow

This commit is contained in:
A R R R Associates
2026-07-07 09:49:20 +05:30
parent 671adbd1a2
commit 1b5fec270b
6 changed files with 1103 additions and 0 deletions
+287
View File
@@ -7,6 +7,7 @@ from pydantic import ValidationError
from app.core.db.common import CommonSessionLocal
from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
from app.core.security.session_auth import get_current_user
from app.core.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
@@ -33,6 +34,19 @@ from app.modules.clients.service import (
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,
)
@@ -59,6 +73,7 @@ from app.modules.billing.client_portal_service import (
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.services import (
get_permanent_version,
get_version,
@@ -647,6 +662,7 @@ def client_detail(request: Request, client_id: int):
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 []
workflow = build_client_acceptance_workflow_payload(db, client_id=client_id)
return _render(
request,
"modules/clients/templates/clients/detail.html",
@@ -655,6 +671,12 @@ def client_detail(request: Request, client_id: int):
title=f"Client • {row['client_name']}",
row=row,
audit_logs=audit_logs,
acceptance_declarations=workflow.get("acceptance_declarations", []),
kyc_verification=workflow.get("kyc_verification"),
engagement_letter=workflow.get("engagement_letter"),
permanent_document_summary=workflow.get("permanent_document_summary"),
permanent_document_count=workflow.get("permanent_document_count", 0),
workflow_ready=workflow.get("workflow_ready", False),
scope=scope,
can_edit=has("clients.edit"),
can_deactivate=has("clients.deactivate"),
@@ -1014,6 +1036,187 @@ async def client_restore(request: Request, client_id: int):
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"])
@@ -1723,3 +1926,87 @@ def client_portal_download_permanent_document(request: Request, version_id: int)
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()