Align client onboarding documents and engagement AQMM workflow

This commit is contained in:
A R R R Associates
2026-07-11 22:20:26 +05:30
parent a9dda197ad
commit c36f419923
4 changed files with 223 additions and 180 deletions
+15 -170
View File
@@ -82,184 +82,29 @@
</section>
<section class="rounded-2xl bg-white p-6 shadow-soft xl:col-span-2">
{% set acc_status = row.acceptance_status or 'pending_review' %}
<div class="flex items-start justify-between gap-3">
<div class="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<h3 class="text-base font-semibold text-slate-900">Client Acceptance Controls</h3>
<p class="mt-1 text-sm text-slate-500">Embedded AQMM / peer-review evidence for acceptance, independence, conflict, KYC and engagement letter controls.</p>
<h3 class="text-base font-semibold text-slate-900">Client Onboarding & Permanent Documents</h3>
<p class="mt-1 text-sm text-slate-500">Client-master onboarding is limited to legal/KYC records and permanent documents. Engagement-specific AQMM formalities are managed from each engagement according to its existing Assurance / Non-Assurance classification.</p>
</div>
<span class="rounded-full px-3 py-1 text-xs font-semibold {% if acc_status == 'approved' %}bg-emerald-100 text-emerald-700{% elif acc_status == 'rejected' %}bg-rose-100 text-rose-700{% else %}bg-amber-100 text-amber-800{% endif %}">{{ acc_status.replace('_',' ').title() }}</span>
<span class="shrink-0 rounded-full px-3 py-1 text-xs font-semibold {% if permanent_document_count %}bg-emerald-100 text-emerald-700{% else %}bg-amber-100 text-amber-800{% endif %}">
{{ permanent_document_count }} permanent document{{ '' if permanent_document_count == 1 else 's' }}
</span>
</div>
<div class="mt-4 grid gap-3 md:grid-cols-2">
{% for label, ok in [
('Acceptance Required', row.acceptance_required),
('Independence Check', row.independence_check_completed),
('Conflict Check', row.conflict_check_completed),
('KYC Completed', row.kyc_completed),
('Engagement Letter Required', row.engagement_letter_required),
('Engagement Letter Received', row.engagement_letter_received)
] %}
<div class="rounded-xl border border-slate-200 px-4 py-3">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">{{ label }}</div>
<div class="mt-1 text-sm font-medium {% if ok %}text-emerald-700{% else %}text-amber-700{% endif %}">{{ 'Yes' if ok else 'No' }}</div>
</div>
{% endfor %}
<div class="rounded-xl border border-slate-200 px-4 py-3">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Approved By</div>
<div class="mt-1 text-sm text-slate-800">{{ row.acceptance_approved_by_user_id or '-' }}</div>
</div>
<div class="rounded-xl border border-slate-200 px-4 py-3">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Approved At</div>
<div class="mt-1 text-sm text-slate-800">{{ row.acceptance_approved_at_utc or '-' }}</div>
</div>
</div>
{% if row.acceptance_review_notes %}
<div class="mt-4 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700">
<div class="font-semibold text-slate-900">Review Notes</div>
<div class="mt-1 whitespace-pre-wrap">{{ row.acceptance_review_notes }}</div>
</div>
{% endif %}
{% if row.acceptance_rejection_reason %}
<div class="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-800">
<div class="font-semibold text-rose-900">Rejection / Remediation Notes</div>
<div class="mt-1 whitespace-pre-wrap">{{ row.acceptance_rejection_reason }}</div>
</div>
{% endif %}
{% if can_approve_acceptance or can_manage_acceptance %}
<div class="mt-5 grid gap-3 md:grid-cols-3">
{% if can_approve_acceptance %}
<form method="post" action="/clients/{{ row.id }}/acceptance/approve" class="space-y-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<textarea name="acceptance_review_notes" rows="2" placeholder="Approval notes" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ row.acceptance_review_notes or '' }}</textarea>
<button class="w-full rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700">Approve Acceptance</button>
</form>
<form method="post" action="/clients/{{ row.id }}/acceptance/reject" class="space-y-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<textarea name="acceptance_rejection_reason" rows="2" placeholder="Rejection/remediation reason" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ row.acceptance_rejection_reason or '' }}</textarea>
<button class="w-full rounded-xl bg-rose-600 px-4 py-2 text-sm font-semibold text-white hover:bg-rose-700">Reject</button>
</form>
{% endif %}
{% if can_manage_acceptance %}
<form method="post" action="/clients/{{ row.id }}/acceptance/pending" class="space-y-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<textarea name="acceptance_review_notes" rows="2" placeholder="Pending review notes" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ row.acceptance_review_notes or '' }}</textarea>
<button class="w-full rounded-xl border border-amber-300 px-4 py-2 text-sm font-semibold text-amber-800 hover:bg-amber-50">Move to Pending</button>
</form>
{% endif %}
</div>
{% endif %}
<div class="mt-6 rounded-2xl border border-indigo-100 bg-indigo-50/50 p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h4 class="text-sm font-semibold uppercase tracking-wide text-indigo-900">Digital Acceptance Workflow</h4>
<p class="mt-1 text-sm text-indigo-800">Declarations, KYC from permanent documents and engagement letter acceptance are captured as audit evidence.</p>
</div>
<span class="rounded-full px-3 py-1 text-xs font-semibold {% if workflow_ready %}bg-emerald-100 text-emerald-700{% else %}bg-amber-100 text-amber-800{% endif %}">{{ 'Ready for Approval' if workflow_ready else 'Pending Evidence' }}</span>
<div class="mt-5 grid gap-4 md:grid-cols-2">
<div class="rounded-2xl border border-slate-200 bg-slate-50 p-4">
<h4 class="text-sm font-semibold text-slate-900">Permanent KYC Documents</h4>
<p class="mt-1 text-sm text-slate-600">Upload PAN, registration/constitution records, authorised-person KYC, bank proof and other standing documents. The checklist adapts to the clients legal constitution.</p>
<a href="/documents/permanent/clients/{{ row.id }}?onboarding=1" class="mt-4 inline-flex rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Open Onboarding Documents</a>
</div>
<div class="mt-4 grid gap-4 lg:grid-cols-3">
<div class="rounded-xl border border-white bg-white p-4 shadow-sm">
<div class="flex items-center justify-between gap-2">
<h5 class="text-sm font-semibold text-slate-900">Independence / Conflict Declarations</h5>
{% if can_manage_acceptance %}
<form method="post" action="/clients/{{ row.id }}/acceptance/request-declarations">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-indigo-700">Request</button>
</form>
{% endif %}
</div>
<div class="mt-3 space-y-2">
{% if acceptance_declarations %}
{% for d in acceptance_declarations %}
<div class="rounded-lg border border-slate-200 p-3 text-xs">
<div class="font-semibold text-slate-800">{{ d.declaration_type.replace('_',' ').title() }} · User #{{ d.assigned_user_id }}</div>
<div class="mt-1 {% if d.status == 'declared_clear' %}text-emerald-700{% elif d.status == 'declared_issue' %}text-rose-700{% else %}text-amber-700{% endif %}">{{ d.status.replace('_',' ').title() }}</div>
{% if d.assigned_user_id == current_user.id and d.status == 'pending' %}
<form method="post" action="/clients/{{ row.id }}/acceptance/declarations/{{ d.id }}/submit" class="mt-2 space-y-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<select name="declaration_result" class="w-full rounded-lg border border-slate-300 px-2 py-1 text-xs">
<option value="clear">Declare Clear / No Issue</option>
<option value="issue">Declare Issue / Conflict</option>
</select>
<textarea name="response_notes" rows="2" placeholder="Notes" class="w-full rounded-lg border border-slate-300 px-2 py-1 text-xs"></textarea>
<textarea name="issue_details" rows="2" placeholder="Issue details if any" class="w-full rounded-lg border border-slate-300 px-2 py-1 text-xs"></textarea>
<button class="rounded-lg bg-slate-900 px-3 py-1.5 text-xs font-semibold text-white">Submit Declaration</button>
</form>
{% endif %}
</div>
{% endfor %}
{% else %}
<div class="text-xs text-slate-500">No declaration requests have been created yet.</div>
{% endif %}
</div>
</div>
<div class="rounded-xl border border-white bg-white p-4 shadow-sm">
<div class="flex items-center justify-between gap-2">
<h5 class="text-sm font-semibold text-slate-900">KYC from Permanent Documents</h5>
{% if can_manage_acceptance %}
<form method="post" action="/clients/{{ row.id }}/acceptance/kyc/sync">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">Sync</button>
</form>
{% endif %}
</div>
<div class="mt-3 text-xs text-slate-600">Permanent documents found: <span class="font-semibold">{{ permanent_document_count }}</span></div>
<div class="mt-2 rounded-lg bg-slate-50 p-3 text-xs text-slate-600 whitespace-pre-wrap max-h-40 overflow-auto">{{ permanent_document_summary or 'No permanent document summary available. Click Sync after client uploads permanent documents.' }}</div>
{% if kyc_verification %}
<div class="mt-3 text-xs font-semibold {% if kyc_verification.status == 'verified' %}text-emerald-700{% elif kyc_verification.status == 'rejected' %}text-rose-700{% else %}text-amber-700{% endif %}">KYC Status: {{ kyc_verification.status.replace('_',' ').title() }}</div>
{% endif %}
{% if can_manage_acceptance or can_approve_acceptance %}
<div class="mt-3 grid gap-2">
<form method="post" action="/clients/{{ row.id }}/acceptance/kyc/verify" class="space-y-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<textarea name="verification_notes" rows="2" placeholder="KYC verification notes" class="w-full rounded-lg border border-slate-300 px-2 py-1 text-xs"></textarea>
<button class="w-full rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700">Verify KYC</button>
</form>
<form method="post" action="/clients/{{ row.id }}/acceptance/kyc/reject" class="space-y-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<textarea name="verification_notes" rows="2" placeholder="Resubmission remarks" class="w-full rounded-lg border border-slate-300 px-2 py-1 text-xs"></textarea>
<button class="w-full rounded-lg border border-rose-300 px-3 py-1.5 text-xs font-semibold text-rose-700 hover:bg-rose-50">Reject / Request Resubmission</button>
</form>
</div>
{% endif %}
</div>
<div class="rounded-xl border border-white bg-white p-4 shadow-sm">
<h5 class="text-sm font-semibold text-slate-900">Engagement Letter</h5>
{% if engagement_letter %}
<div class="mt-2 text-xs text-slate-600">Version {{ engagement_letter.version_no }} · <span class="font-semibold">{{ engagement_letter.status.replace('_',' ').title() }}</span></div>
<a href="/clients/{{ row.id }}/acceptance/engagement-letter/{{ engagement_letter.id }}/download" target="_blank" class="mt-3 inline-flex rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">View / Print PDF</a>
{% if can_approve_acceptance and engagement_letter.status == 'draft' %}
<form method="post" action="/clients/{{ row.id }}/acceptance/engagement-letter/{{ engagement_letter.id }}/approve-send" class="mt-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="w-full rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-indigo-700">Partner Approve & Send</button>
</form>
{% endif %}
{% if (can_manage_acceptance or can_approve_acceptance) and engagement_letter.status == 'manual_uploaded' %}
<form method="post" action="/clients/{{ row.id }}/acceptance/engagement-letter/{{ engagement_letter.id }}/verify-manual" class="mt-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="w-full rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700">Verify Manual Signed Copy</button>
</form>
{% endif %}
{% else %}
<div class="mt-2 text-xs text-slate-500">No engagement letter drafted yet.</div>
{% endif %}
{% if can_manage_acceptance %}
<form method="post" action="/clients/{{ row.id }}/acceptance/engagement-letter/draft" class="mt-4 space-y-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input name="title" placeholder="Letter title" class="w-full rounded-lg border border-slate-300 px-2 py-1 text-xs" value="Engagement Letter - {{ row.client_name }}">
<textarea name="body_text" rows="4" placeholder="Leave blank to use standard engagement letter draft" class="w-full rounded-lg border border-slate-300 px-2 py-1 text-xs"></textarea>
<button class="w-full rounded-lg border border-indigo-300 px-3 py-1.5 text-xs font-semibold text-indigo-700 hover:bg-indigo-50">Create New Draft</button>
</form>
{% endif %}
</div>
<div class="rounded-2xl border border-indigo-200 bg-indigo-50 p-4">
<h4 class="text-sm font-semibold text-indigo-950">AQMM at Engagement Level</h4>
<p class="mt-1 text-sm text-indigo-800">Create or open an engagement to complete the applicable acceptance, independence, conflict, engagement-letter, evidence, review and closure requirements. The existing engagement classification remains the single source of truth.</p>
<a href="/services/engagements/new?client_id={{ row.id }}" class="mt-4 inline-flex rounded-xl border border-indigo-300 bg-white px-4 py-2 text-sm font-semibold text-indigo-700 hover:bg-indigo-100">Assign Service / Create Engagement</a>
</div>
</div>
</section>
<section class="rounded-2xl bg-white p-6 shadow-soft">
+10 -8
View File
@@ -3,6 +3,7 @@ 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
@@ -74,6 +75,7 @@ from app.modules.billing.client_portal_service import (
)
from app.modules.billing.services import build_invoice_print_context, create_cashfree_transaction, create_payumoney_transaction, process_cashfree_return, process_cashfree_webhook, process_payumoney_response
from app.modules.email_integration.services import send_auth_otp_email
from app.modules.documents.models import PermanentClientDocument
from app.modules.documents.services import (
get_permanent_version,
get_version,
@@ -623,7 +625,7 @@ async def client_create(request: Request):
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)
return RedirectResponse(url=f"/documents/permanent/clients/{row.id}?onboarding=1", status_code=303)
except Exception as exc:
return _render(
request,
@@ -662,7 +664,12 @@ 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)
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",
@@ -671,12 +678,7 @@ 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),
permanent_document_count=permanent_document_count,
scope=scope,
can_edit=has("clients.edit"),
can_deactivate=has("clients.deactivate"),