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
+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