Add digital client acceptance workflow
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user