Add client acceptance controls for AQMM and peer review workflow
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
"""phase_2_client_acceptance_controls
|
||||
|
||||
Revision ID: 20260622_phase_2_client_acceptance_controls
|
||||
Revises: 20260621_phase_1_udin_final_documents
|
||||
Create Date: 2026-06-22
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
revision = "20260622_phase_2_client_acceptance_controls"
|
||||
down_revision = "20260621_phase_1_udin_final_documents"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
CLIENT_TABLE = "clients"
|
||||
|
||||
|
||||
def _columns(table_name: str) -> set[str]:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
if table_name not in inspector.get_table_names():
|
||||
return set()
|
||||
return {col["name"] for col in inspector.get_columns(table_name)}
|
||||
|
||||
|
||||
def _add_column_if_missing(existing: set[str], column: sa.Column) -> None:
|
||||
if column.name in existing:
|
||||
return
|
||||
op.add_column(CLIENT_TABLE, column)
|
||||
existing.add(column.name)
|
||||
|
||||
|
||||
def _create_index_if_missing(index_name: str, columns: list[str]) -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
existing_indexes = {idx["name"] for idx in inspector.get_indexes(CLIENT_TABLE)}
|
||||
if index_name not in existing_indexes:
|
||||
op.create_index(index_name, CLIENT_TABLE, columns)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
existing = _columns(CLIENT_TABLE)
|
||||
|
||||
_add_column_if_missing(existing, sa.Column("acceptance_status", sa.String(length=30), nullable=False, server_default="pending_review"))
|
||||
_add_column_if_missing(existing, sa.Column("acceptance_required", sa.Boolean(), nullable=False, server_default=sa.true()))
|
||||
_add_column_if_missing(existing, sa.Column("independence_check_completed", sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||
_add_column_if_missing(existing, sa.Column("conflict_check_completed", sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||
_add_column_if_missing(existing, sa.Column("kyc_completed", sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||
_add_column_if_missing(existing, sa.Column("engagement_letter_required", sa.Boolean(), nullable=False, server_default=sa.true()))
|
||||
_add_column_if_missing(existing, sa.Column("engagement_letter_received", sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||
_add_column_if_missing(existing, sa.Column("acceptance_approved_by_user_id", sa.Integer(), nullable=True))
|
||||
_add_column_if_missing(existing, sa.Column("acceptance_approved_at_utc", sa.DateTime(timezone=True), nullable=True))
|
||||
_add_column_if_missing(existing, sa.Column("acceptance_review_notes", sa.Text(), nullable=True))
|
||||
_add_column_if_missing(existing, sa.Column("acceptance_rejection_reason", sa.Text(), nullable=True))
|
||||
|
||||
bind = op.get_bind()
|
||||
dialect = bind.dialect.name
|
||||
if dialect == "postgresql":
|
||||
op.execute(text("UPDATE clients SET acceptance_status = 'pending_review' WHERE acceptance_status IS NULL"))
|
||||
op.execute(text("UPDATE clients SET acceptance_required = TRUE WHERE acceptance_required IS NULL"))
|
||||
op.execute(text("UPDATE clients SET independence_check_completed = FALSE WHERE independence_check_completed IS NULL"))
|
||||
op.execute(text("UPDATE clients SET conflict_check_completed = FALSE WHERE conflict_check_completed IS NULL"))
|
||||
op.execute(text("UPDATE clients SET kyc_completed = FALSE WHERE kyc_completed IS NULL"))
|
||||
op.execute(text("UPDATE clients SET engagement_letter_required = TRUE WHERE engagement_letter_required IS NULL"))
|
||||
op.execute(text("UPDATE clients SET engagement_letter_received = FALSE WHERE engagement_letter_received IS NULL"))
|
||||
else:
|
||||
op.execute(text("UPDATE clients SET acceptance_status = 'pending_review' WHERE acceptance_status IS NULL"))
|
||||
op.execute(text("UPDATE clients SET acceptance_required = 1 WHERE acceptance_required IS NULL"))
|
||||
op.execute(text("UPDATE clients SET independence_check_completed = 0 WHERE independence_check_completed IS NULL"))
|
||||
op.execute(text("UPDATE clients SET conflict_check_completed = 0 WHERE conflict_check_completed IS NULL"))
|
||||
op.execute(text("UPDATE clients SET kyc_completed = 0 WHERE kyc_completed IS NULL"))
|
||||
op.execute(text("UPDATE clients SET engagement_letter_required = 1 WHERE engagement_letter_required IS NULL"))
|
||||
op.execute(text("UPDATE clients SET engagement_letter_received = 0 WHERE engagement_letter_received IS NULL"))
|
||||
|
||||
_create_index_if_missing("ix_clients_acceptance_status", ["acceptance_status"])
|
||||
_create_index_if_missing("ix_clients_acceptance_approved_by_user_id", ["acceptance_approved_by_user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
if CLIENT_TABLE not in inspector.get_table_names():
|
||||
return
|
||||
|
||||
existing_indexes = {idx["name"] for idx in inspector.get_indexes(CLIENT_TABLE)}
|
||||
if "ix_clients_acceptance_approved_by_user_id" in existing_indexes:
|
||||
op.drop_index("ix_clients_acceptance_approved_by_user_id", table_name=CLIENT_TABLE)
|
||||
if "ix_clients_acceptance_status" in existing_indexes:
|
||||
op.drop_index("ix_clients_acceptance_status", table_name=CLIENT_TABLE)
|
||||
|
||||
existing = _columns(CLIENT_TABLE)
|
||||
for col in [
|
||||
"acceptance_rejection_reason",
|
||||
"acceptance_review_notes",
|
||||
"acceptance_approved_at_utc",
|
||||
"acceptance_approved_by_user_id",
|
||||
"engagement_letter_received",
|
||||
"engagement_letter_required",
|
||||
"kyc_completed",
|
||||
"conflict_check_completed",
|
||||
"independence_check_completed",
|
||||
"acceptance_required",
|
||||
"acceptance_status",
|
||||
]:
|
||||
if col in existing:
|
||||
op.drop_column(CLIENT_TABLE, col)
|
||||
@@ -105,6 +105,8 @@ ROLE_PERMISSION_MAP = {
|
||||
"clients.cross_tenant",
|
||||
"clients.export",
|
||||
"clients.audit_log.view",
|
||||
"clients.acceptance.manage",
|
||||
"clients.acceptance.approve",
|
||||
"employees.dashboard.view",
|
||||
"employees.view",
|
||||
"employees.create",
|
||||
@@ -227,6 +229,8 @@ ROLE_PERMISSION_MAP = {
|
||||
"clients.cross_branch",
|
||||
"clients.export",
|
||||
"clients.audit_log.view",
|
||||
"clients.acceptance.manage",
|
||||
"clients.acceptance.approve",
|
||||
"documents.view",
|
||||
"documents.upload",
|
||||
"documents.download",
|
||||
@@ -329,6 +333,8 @@ ROLE_PERMISSION_MAP = {
|
||||
"clients.restore",
|
||||
"clients.export",
|
||||
"clients.audit_log.view",
|
||||
"clients.acceptance.manage",
|
||||
"clients.acceptance.approve",
|
||||
"documents.view",
|
||||
"documents.upload",
|
||||
"documents.download",
|
||||
@@ -428,6 +434,8 @@ ROLE_PERMISSION_MAP = {
|
||||
"clients.activate",
|
||||
"clients.export",
|
||||
"clients.audit_log.view",
|
||||
"clients.acceptance.manage",
|
||||
"clients.acceptance.approve",
|
||||
"documents.view",
|
||||
"documents.upload",
|
||||
"documents.download",
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -38,6 +38,8 @@ PERMISSIONS = {
|
||||
"clients.cross_tenant": "Manage Clients Across Tenants",
|
||||
"clients.export": "Export Clients",
|
||||
"clients.audit_log.view": "View Client Audit Logs",
|
||||
"clients.acceptance.manage": "Manage Client Acceptance Controls",
|
||||
"clients.acceptance.approve": "Approve or Reject Client Acceptance",
|
||||
"clients.view.own_only": "View Only Own Clients",
|
||||
|
||||
"employees.dashboard.view": "View HR Dashboard and Reports",
|
||||
|
||||
Reference in New Issue
Block a user