Add client acceptance controls for AQMM and peer review workflow

This commit is contained in:
A R R R Associates
2026-07-06 18:37:34 +05:30
parent 561de117e4
commit aa32579084
10 changed files with 604 additions and 5 deletions
+3
View File
@@ -27,6 +27,9 @@ CLIENT_TYPES = [
CLIENT_STATUS = ["active", "inactive", "archived"]
CLIENT_ACCEPTANCE_STATUS = ["pending_review", "approved", "rejected"]
CLIENT_ACCEPTANCE_APPROVAL_REQUIRED_RISKS = {"high", "critical"}
CLIENT_CATEGORY_OPTIONS = [
"Audit", "Tax", "GST", "Compliance", "Payroll", "Advisory", "Litigation", "Internal", "Other",
]
+14
View File
@@ -49,6 +49,20 @@ class Client(CommonBase):
risk_category: Mapped[str | None] = mapped_column(String(50), nullable=True)
onboarding_date: Mapped[date | None] = mapped_column(Date, nullable=True)
closing_date: Mapped[date | None] = mapped_column(Date, nullable=True)
# Client acceptance / continuance controls for AQMM and peer review evidence.
acceptance_status: Mapped[str] = mapped_column(String(30), nullable=False, default="pending_review", index=True)
acceptance_required: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
independence_check_completed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
conflict_check_completed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
kyc_completed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
engagement_letter_required: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
engagement_letter_received: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
acceptance_approved_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
acceptance_approved_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
acceptance_review_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
acceptance_rejection_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
gst_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
income_tax_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+58 -2
View File
@@ -12,6 +12,7 @@ from app.modules.clients.constants import (
CLIENT_TYPES,
ENGAGEMENT_MODES,
RISK_CATEGORIES,
CLIENT_ACCEPTANCE_STATUS,
)
from app.modules.clients.utils import GSTIN_RE, MOBILE_RE, PAN_RE, PIN_RE, TAN_RE, normalize_text, normalize_upper
@@ -49,6 +50,15 @@ class ClientBase(BaseModel):
risk_category: Optional[str] = None
onboarding_date: Optional[date] = None
closing_date: Optional[date] = None
acceptance_status: str = "pending_review"
acceptance_required: bool = True
independence_check_completed: bool = False
conflict_check_completed: bool = False
kyc_completed: bool = False
engagement_letter_required: bool = True
engagement_letter_received: bool = False
acceptance_review_notes: Optional[str] = None
acceptance_rejection_reason: Optional[str] = None
notes: Optional[str] = None
gst_applicable: bool = False
income_tax_applicable: bool = False
@@ -72,7 +82,7 @@ class ClientBase(BaseModel):
@field_validator(
"trade_name", "cin_llpin", "msme_no", "iec_code", "contact_person_name", "contact_person_designation",
"address_line_1", "address_line_2", "city", "state", "country", "client_category", "risk_category", "notes",
"address_line_1", "address_line_2", "city", "state", "country", "client_category", "risk_category", "acceptance_review_notes", "acceptance_rejection_reason", "notes",
mode="before",
)
@classmethod
@@ -97,6 +107,19 @@ class ClientBase(BaseModel):
raise ValueError("Invalid engagement mode.")
return value
@field_validator("acceptance_status", mode="before")
@classmethod
def clean_acceptance_status(cls, value):
value = normalize_text(value) or "pending_review"
return value.lower()
@field_validator("acceptance_status")
@classmethod
def validate_acceptance_status(cls, value):
if value not in CLIENT_ACCEPTANCE_STATUS:
raise ValueError("Invalid client acceptance status.")
return value
@field_validator("mobile", "alternate_mobile", mode="before")
@classmethod
def clean_mobile(cls, value):
@@ -193,6 +216,15 @@ class ClientUpdate(BaseModel):
risk_category: Optional[str] = None
onboarding_date: Optional[date] = None
closing_date: Optional[date] = None
acceptance_status: Optional[str] = None
acceptance_required: Optional[bool] = None
independence_check_completed: Optional[bool] = None
conflict_check_completed: Optional[bool] = None
kyc_completed: Optional[bool] = None
engagement_letter_required: Optional[bool] = None
engagement_letter_received: Optional[bool] = None
acceptance_review_notes: Optional[str] = None
acceptance_rejection_reason: Optional[str] = None
notes: Optional[str] = None
gst_applicable: Optional[bool] = None
income_tax_applicable: Optional[bool] = None
@@ -209,7 +241,7 @@ class ClientUpdate(BaseModel):
@field_validator(
"client_name", "trade_name", "cin_llpin", "msme_no", "iec_code", "contact_person_name", "contact_person_designation",
"address_line_1", "address_line_2", "city", "state", "country", "client_category", "risk_category", "notes",
"address_line_1", "address_line_2", "city", "state", "country", "client_category", "risk_category", "acceptance_review_notes", "acceptance_rejection_reason", "notes",
mode="before",
)
@classmethod
@@ -236,6 +268,21 @@ class ClientUpdate(BaseModel):
raise ValueError("Invalid engagement mode.")
return value
@field_validator("acceptance_status", mode="before")
@classmethod
def clean_acceptance_status(cls, value):
if value is None:
return None
value = normalize_text(value) or None
return value.lower() if value else None
@field_validator("acceptance_status")
@classmethod
def validate_acceptance_status(cls, value):
if value is not None and value not in CLIENT_ACCEPTANCE_STATUS:
raise ValueError("Invalid client acceptance status.")
return value
@field_validator("mobile", "alternate_mobile", mode="before")
@classmethod
def clean_mobile(cls, value):
@@ -323,6 +370,15 @@ class ClientOut(BaseModel):
risk_category: Optional[str] = None
onboarding_date: Optional[date] = None
closing_date: Optional[date] = None
acceptance_status: str = "pending_review"
acceptance_required: bool = True
independence_check_completed: bool = False
conflict_check_completed: bool = False
kyc_completed: bool = False
engagement_letter_required: bool = True
engagement_letter_received: bool = False
acceptance_review_notes: Optional[str] = None
acceptance_rejection_reason: Optional[str] = None
notes: Optional[str] = None
gst_applicable: bool
income_tax_applicable: bool
+138
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import csv
import io
from datetime import datetime, timezone
from fastapi import HTTPException
@@ -16,6 +17,7 @@ from app.modules.clients.constants import (
CLIENT_STATUS,
CLIENT_TYPES,
RISK_CATEGORIES,
CLIENT_ACCEPTANCE_APPROVAL_REQUIRED_RISKS,
)
@@ -25,6 +27,53 @@ def _payload_from_schema(data):
def _is_high_risk(risk_category: str | None) -> bool:
return (risk_category or "").strip().lower() in CLIENT_ACCEPTANCE_APPROVAL_REQUIRED_RISKS
def _client_acceptance_ready(payload: dict) -> bool:
if not payload.get("acceptance_required", True):
return True
if not payload.get("independence_check_completed"):
return False
if not payload.get("conflict_check_completed"):
return False
if not payload.get("kyc_completed"):
return False
if payload.get("engagement_letter_required", True) and not payload.get("engagement_letter_received"):
return False
return True
def _enforce_client_acceptance_controls(payload: dict, *, existing_row=None):
risk = payload.get("risk_category")
if risk is None and existing_row is not None:
risk = getattr(existing_row, "risk_category", None)
status = payload.get("status")
if status is None and existing_row is not None:
status = getattr(existing_row, "status", None)
acceptance_status = payload.get("acceptance_status")
if acceptance_status is None and existing_row is not None:
acceptance_status = getattr(existing_row, "acceptance_status", "pending_review")
acceptance_status = acceptance_status or "pending_review"
if _is_high_risk(risk) and status == "active" and acceptance_status != "approved":
raise HTTPException(
status_code=400,
detail="High/Critical risk clients cannot be active until client acceptance is approved by an authorised partner or firm admin.",
)
if acceptance_status == "approved" and not _client_acceptance_ready(payload):
raise HTTPException(
status_code=400,
detail="Client acceptance cannot be approved until independence, conflict, KYC and required engagement letter controls are completed.",
)
return payload
def _ensure_portal_passwords(email: str | None, portal_password: str | None, portal_password_confirm: str | None, *, required: bool):
email_clean = (email or '').strip().lower()
pw = (portal_password or '').strip()
@@ -206,6 +255,7 @@ def create_client_service(db, *, data, actor_user_id: int, scope, current_user_r
raise HTTPException(status_code=400, detail="GSTIN already exists for another client in this tenant.")
payload = _payload_from_schema(data)
_enforce_client_acceptance_controls(payload)
row = repository.create_client(db, payload)
row = _sync_client_portal_user(db, row=row, portal_password=portal_password, portal_password_confirm=portal_password_confirm)
_write_association_from_client(db, row, actor_user_id=actor_user_id, current_user_roles=current_user_roles)
@@ -234,6 +284,7 @@ def update_client_service(db, *, row, data, actor_user_id: int, scope, current_u
)
payload = _payload_from_schema(data)
_enforce_client_acceptance_controls(payload, existing_row=row)
if payload.get("pan"):
existing_pan = repository.get_client_by_pan(db, tenant_id=payload["tenant_id"], pan=payload["pan"])
@@ -305,6 +356,18 @@ def deactivate_client_service(db, *, row, actor_user_id: int):
def activate_client_service(db, *, row, actor_user_id: int):
payload = {
"status": "active",
"risk_category": getattr(row, "risk_category", None),
"acceptance_status": getattr(row, "acceptance_status", "pending_review"),
"acceptance_required": getattr(row, "acceptance_required", True),
"independence_check_completed": getattr(row, "independence_check_completed", False),
"conflict_check_completed": getattr(row, "conflict_check_completed", False),
"kyc_completed": getattr(row, "kyc_completed", False),
"engagement_letter_required": getattr(row, "engagement_letter_required", True),
"engagement_letter_received": getattr(row, "engagement_letter_received", False),
}
_enforce_client_acceptance_controls(payload, existing_row=row)
row = repository.update_client(db, row, {"status": "active"})
repository.write_audit_log(
db,
@@ -450,3 +513,78 @@ def reset_client_portal_password_service(db, *, current_user, new_password: str)
db.commit()
db.refresh(current_user)
return current_user
def approve_client_acceptance_service(db, *, row, actor_user_id: int, review_notes: str | None = None):
payload = {
"acceptance_status": "approved",
"acceptance_required": getattr(row, "acceptance_required", True),
"independence_check_completed": getattr(row, "independence_check_completed", False),
"conflict_check_completed": getattr(row, "conflict_check_completed", False),
"kyc_completed": getattr(row, "kyc_completed", False),
"engagement_letter_required": getattr(row, "engagement_letter_required", True),
"engagement_letter_received": getattr(row, "engagement_letter_received", False),
"risk_category": getattr(row, "risk_category", None),
"status": getattr(row, "status", None),
}
_enforce_client_acceptance_controls(payload, existing_row=row)
now = datetime.now(timezone.utc)
updated = repository.update_client(db, row, {
"acceptance_status": "approved",
"acceptance_approved_by_user_id": actor_user_id,
"acceptance_approved_at_utc": now,
"acceptance_review_notes": review_notes or getattr(row, "acceptance_review_notes", None),
"acceptance_rejection_reason": None,
})
repository.write_audit_log(
db,
client_id=updated.id,
tenant_id=updated.tenant_id,
branch_id=updated.branch_id,
actor_user_id=actor_user_id,
action="acceptance_approved",
summary="Client acceptance approved.",
payload_json={"review_notes": review_notes},
)
return updated
def reject_client_acceptance_service(db, *, row, actor_user_id: int, rejection_reason: str | None = None):
updated = repository.update_client(db, row, {
"acceptance_status": "rejected",
"acceptance_approved_by_user_id": None,
"acceptance_approved_at_utc": None,
"acceptance_rejection_reason": rejection_reason,
})
repository.write_audit_log(
db,
client_id=updated.id,
tenant_id=updated.tenant_id,
branch_id=updated.branch_id,
actor_user_id=actor_user_id,
action="acceptance_rejected",
summary="Client acceptance rejected.",
payload_json={"rejection_reason": rejection_reason},
)
return updated
def mark_client_acceptance_pending_service(db, *, row, actor_user_id: int, review_notes: str | None = None):
updated = repository.update_client(db, row, {
"acceptance_status": "pending_review",
"acceptance_approved_by_user_id": None,
"acceptance_approved_at_utc": None,
"acceptance_review_notes": review_notes or getattr(row, "acceptance_review_notes", None),
"acceptance_rejection_reason": None,
})
repository.write_audit_log(
db,
client_id=updated.id,
tenant_id=updated.tenant_id,
branch_id=updated.branch_id,
actor_user_id=actor_user_id,
action="acceptance_pending_review",
summary="Client acceptance moved to pending review.",
payload_json={"review_notes": review_notes},
)
return updated
@@ -53,6 +53,12 @@
</div>
</div>
{% if form_errors %}
<div class="rounded-2xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-800">
{% for err in form_errors %}<div>{{ err }}</div>{% endfor %}
</div>
{% endif %}
<div class="grid gap-6 xl:grid-cols-3">
<section class="rounded-2xl bg-white p-6 shadow-soft xl:col-span-2">
<h3 class="text-base font-semibold text-slate-900">Profile</h3>
@@ -75,6 +81,78 @@
</div>
</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>
<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>
</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>
</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 %}
</section>
<section class="rounded-2xl bg-white p-6 shadow-soft">
<h3 class="text-base font-semibold text-slate-900">Association</h3>
<div class="mt-4 space-y-2 text-sm text-slate-700">
@@ -147,6 +147,71 @@
<input name="country" value="{{ form_data.country or (row.country if is_edit else 'India') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
</div>
<div class="md:col-span-2 rounded-2xl border border-amber-200 bg-amber-50 p-4">
<div class="flex items-start justify-between gap-3">
<div>
<h4 class="text-sm font-semibold text-amber-950">Client Acceptance / Continuance Controls</h4>
<p class="mt-1 text-xs text-amber-800">Used for AQMM and peer review evidence. High/Critical risk clients cannot be activated until acceptance is approved.</p>
</div>
{% set acc_status = form_data.acceptance_status or (row.acceptance_status if is_edit else 'pending_review') %}
<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>
</div>
<div class="mt-4 grid gap-4 md:grid-cols-2">
<div>
<label class="block text-sm font-medium text-slate-700">Acceptance Status</label>
<select name="acceptance_status" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
{% for opt in client_acceptance_statuses %}
<option value="{{ opt }}" {% if (form_data.acceptance_status or (row.acceptance_status if is_edit else 'pending_review')) == opt %}selected{% endif %}>{{ opt.replace('_',' ').title() }}</option>
{% endfor %}
</select>
<p class="mt-1 text-xs text-slate-500">Formal approval can also be done from the client detail page.</p>
</div>
<div class="flex items-end">
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="acceptance_required" value="1" {% if form_data.acceptance_required is not defined or form_data.acceptance_required or (row.acceptance_required if is_edit else true) %}checked{% endif %}>
Client acceptance / continuance review required
</label>
</div>
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="independence_check_completed" value="1" {% if form_data.independence_check_completed or (row.independence_check_completed if is_edit else false) %}checked{% endif %}>
Independence check completed
</label>
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="conflict_check_completed" value="1" {% if form_data.conflict_check_completed or (row.conflict_check_completed if is_edit else false) %}checked{% endif %}>
Conflict check completed
</label>
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="kyc_completed" value="1" {% if form_data.kyc_completed or (row.kyc_completed if is_edit else false) %}checked{% endif %}>
KYC completed / verified
</label>
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="engagement_letter_required" value="1" {% if form_data.engagement_letter_required is not defined or form_data.engagement_letter_required or (row.engagement_letter_required if is_edit else true) %}checked{% endif %}>
Engagement letter required
</label>
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="engagement_letter_received" value="1" {% if form_data.engagement_letter_received or (row.engagement_letter_received if is_edit else false) %}checked{% endif %}>
Engagement letter received / accepted
</label>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-slate-700">Acceptance Review Notes</label>
<textarea name="acceptance_review_notes" rows="3" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">{{ form_data.acceptance_review_notes or (row.acceptance_review_notes if is_edit else '') }}</textarea>
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-slate-700">Rejection Reason / Remediation Notes</label>
<textarea name="acceptance_rejection_reason" rows="2" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">{{ form_data.acceptance_rejection_reason or (row.acceptance_rejection_reason if is_edit else '') }}</textarea>
</div>
</div>
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-slate-700">Notes</label>
<textarea name="notes" rows="4" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">{{ form_data.notes or (row.notes if is_edit else '') }}</textarea>
@@ -161,7 +226,7 @@
<label class="block text-sm font-medium text-slate-700">Engagement Mode</label>
<select name="engagement_mode" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
{% for opt in ['internal_managed','self_tracked','hybrid'] %}
<option value="{{ opt }}" {% if (form_data.engagement_mode or (row.engagement_mode if is_edit else 'internal_managed')) == opt %}selected{% endif %}>{{ opt }}</option>
<option value="{{ opt }}" {% if (form_data.engagement_mode or (row.engagement_mode if is_edit else ('hybrid' if form_mode == 'firm_admin' else 'internal_managed'))) == opt %}selected{% endif %}>{{ opt }}</option>
{% endfor %}
</select>
</div>
+127 -2
View File
@@ -10,7 +10,7 @@ from app.core.security.session_auth import get_current_user
from app.core.templating import templates
from app.modules.clients import repository
from app.modules.clients.access import build_scope, can_view_client_row
from app.modules.clients.constants import CLIENT_CATEGORY_OPTIONS, CLIENT_STATUS, CLIENT_TYPES, RISK_CATEGORIES
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,
@@ -22,6 +22,7 @@ from app.modules.clients.import_service import (
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,
@@ -29,7 +30,9 @@ from app.modules.clients.service import (
get_client_or_404,
list_client_audit_logs,
list_clients_payload,
reject_client_acceptance_service,
restore_client_service,
mark_client_acceptance_pending_service,
update_client_service,
update_client_self_profile_service,
)
@@ -77,6 +80,7 @@ def _base_ctx(request: Request, user, db, **ctx):
"client_statuses": CLIENT_STATUS,
"client_categories": CLIENT_CATEGORY_OPTIONS,
"risk_categories": RISK_CATEGORIES,
"client_acceptance_statuses": CLIENT_ACCEPTANCE_STATUS,
}
base.update(ctx)
return base
@@ -179,6 +183,15 @@ def _build_form_payload(request: Request, user, scope, *, include_client_code: b
"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")),
@@ -535,10 +548,13 @@ def client_new_page(request: Request):
"status": "active",
"client_type": "Other",
"country": "India",
"engagement_mode": "internal_managed",
"engagement_mode": "hybrid" if form_mode == "firm_admin" else "internal_managed",
"partner_id": scope.locked_partner_id or getattr(user, "id", None),
"branch_id": scope.branch_id,
"tenant_id": scope.tenant_id,
"acceptance_status": "pending_review",
"acceptance_required": True,
"engagement_letter_required": True,
}
return _render(
@@ -645,6 +661,8 @@ def client_detail(request: Request, client_id: int):
can_activate=has("clients.activate"),
can_archive=has("clients.archive"),
can_restore=has("clients.restore"),
can_manage_acceptance=has("clients.acceptance.manage"),
can_approve_acceptance=has("clients.acceptance.approve"),
)
finally:
db.close()
@@ -757,6 +775,113 @@ async def client_update(request: Request, client_id: int):
db.close()
@router.post("/{client_id}/acceptance/approve")
async def client_acceptance_approve(request: Request, client_id: int):
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return RedirectResponse(url="/login", status_code=303)
has = _has_perm_factory(db, user)
if not has("clients.acceptance.approve"):
return _redirect_denied()
role_names = _role_names(db, user)
scope = build_scope(request, user, has)
scope = _elevate_scope_for_system_admin(scope, role_names)
form = await request.form()
validate_csrf(request, form.get("csrf_token"))
row = get_client_or_404(
db,
client_id=client_id,
tenant_id=scope.tenant_id,
branch_id=scope.branch_id,
allow_cross_branch=scope.allow_cross_branch,
allow_all_clients=scope.allow_all_clients,
)
try:
row = approve_client_acceptance_service(db, row=row, actor_user_id=user.id, review_notes=form.get("acceptance_review_notes"))
return RedirectResponse(url=f"/clients/{row.id}", status_code=303)
except Exception as exc:
return _render(
request,
"modules/clients/templates/clients/detail.html",
db,
user,
title=f"Client • {row.client_name}",
row=repository.get_client_detail_payload(db, client_id),
audit_logs=list_client_audit_logs(db, row=type("Tmp", (), {"id": client_id})(), limit=10) if has("clients.audit_log.view") else [],
scope=scope,
can_edit=has("clients.edit"),
can_deactivate=has("clients.deactivate"),
can_activate=has("clients.activate"),
can_archive=has("clients.archive"),
can_restore=has("clients.restore"),
can_manage_acceptance=has("clients.acceptance.manage"),
can_approve_acceptance=has("clients.acceptance.approve"),
form_errors=_field_errors(exc),
)
finally:
db.close()
@router.post("/{client_id}/acceptance/reject")
async def client_acceptance_reject(request: Request, client_id: int):
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return RedirectResponse(url="/login", status_code=303)
has = _has_perm_factory(db, user)
if not has("clients.acceptance.approve"):
return _redirect_denied()
role_names = _role_names(db, user)
scope = build_scope(request, user, has)
scope = _elevate_scope_for_system_admin(scope, role_names)
form = await request.form()
validate_csrf(request, form.get("csrf_token"))
row = get_client_or_404(
db,
client_id=client_id,
tenant_id=scope.tenant_id,
branch_id=scope.branch_id,
allow_cross_branch=scope.allow_cross_branch,
allow_all_clients=scope.allow_all_clients,
)
row = reject_client_acceptance_service(db, row=row, actor_user_id=user.id, rejection_reason=form.get("acceptance_rejection_reason"))
return RedirectResponse(url=f"/clients/{row.id}", status_code=303)
finally:
db.close()
@router.post("/{client_id}/acceptance/pending")
async def client_acceptance_pending(request: Request, client_id: int):
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return RedirectResponse(url="/login", status_code=303)
has = _has_perm_factory(db, user)
if not has("clients.acceptance.manage"):
return _redirect_denied()
role_names = _role_names(db, user)
scope = build_scope(request, user, has)
scope = _elevate_scope_for_system_admin(scope, role_names)
form = await request.form()
validate_csrf(request, form.get("csrf_token"))
row = get_client_or_404(
db,
client_id=client_id,
tenant_id=scope.tenant_id,
branch_id=scope.branch_id,
allow_cross_branch=scope.allow_cross_branch,
allow_all_clients=scope.allow_all_clients,
)
row = mark_client_acceptance_pending_service(db, row=row, actor_user_id=user.id, review_notes=form.get("acceptance_review_notes"))
return RedirectResponse(url=f"/clients/{row.id}", status_code=303)
finally:
db.close()
@router.post("/{client_id}/deactivate")
async def client_deactivate(request: Request, client_id: int):
db = CommonSessionLocal()