diff --git a/alembic/versions/20260622_phase_2_client_acceptance_controls.py b/alembic/versions/20260622_phase_2_client_acceptance_controls.py new file mode 100644 index 0000000..bc52357 --- /dev/null +++ b/alembic/versions/20260622_phase_2_client_acceptance_controls.py @@ -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) diff --git a/app/core/startup.py b/app/core/startup.py index 7363c01..1286ad8 100644 --- a/app/core/startup.py +++ b/app/core/startup.py @@ -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", diff --git a/app/modules/clients/constants.py b/app/modules/clients/constants.py index 219d4d3..d141aee 100644 --- a/app/modules/clients/constants.py +++ b/app/modules/clients/constants.py @@ -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", ] diff --git a/app/modules/clients/models.py b/app/modules/clients/models.py index 2086dc3..4503e87 100644 --- a/app/modules/clients/models.py +++ b/app/modules/clients/models.py @@ -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) diff --git a/app/modules/clients/schemas.py b/app/modules/clients/schemas.py index 22a84eb..86c8834 100644 --- a/app/modules/clients/schemas.py +++ b/app/modules/clients/schemas.py @@ -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 diff --git a/app/modules/clients/service.py b/app/modules/clients/service.py index 0e52772..c3d705b 100644 --- a/app/modules/clients/service.py +++ b/app/modules/clients/service.py @@ -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 diff --git a/app/modules/clients/templates/clients/detail.html b/app/modules/clients/templates/clients/detail.html index 280d999..5835c6c 100644 --- a/app/modules/clients/templates/clients/detail.html +++ b/app/modules/clients/templates/clients/detail.html @@ -53,6 +53,12 @@ + {% if form_errors %} +
+ {% for err in form_errors %}
{{ err }}
{% endfor %} +
+ {% endif %} +

Profile

@@ -75,6 +81,78 @@
+
+ {% set acc_status = row.acceptance_status or 'pending_review' %} +
+
+

Client Acceptance Controls

+

Embedded AQMM / peer-review evidence for acceptance, independence, conflict, KYC and engagement letter controls.

+
+ {{ acc_status.replace('_',' ').title() }} +
+ +
+ {% 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) + ] %} +
+
{{ label }}
+
{{ 'Yes' if ok else 'No' }}
+
+ {% endfor %} +
+
Approved By
+
{{ row.acceptance_approved_by_user_id or '-' }}
+
+
+
Approved At
+
{{ row.acceptance_approved_at_utc or '-' }}
+
+
+ + {% if row.acceptance_review_notes %} +
+
Review Notes
+
{{ row.acceptance_review_notes }}
+
+ {% endif %} + {% if row.acceptance_rejection_reason %} +
+
Rejection / Remediation Notes
+
{{ row.acceptance_rejection_reason }}
+
+ {% endif %} + + {% if can_approve_acceptance or can_manage_acceptance %} +
+ {% if can_approve_acceptance %} +
+ + + +
+
+ + + +
+ {% endif %} + {% if can_manage_acceptance %} +
+ + + +
+ {% endif %} +
+ {% endif %} +
+

Association

diff --git a/app/modules/clients/templates/clients/partials/form.html b/app/modules/clients/templates/clients/partials/form.html index 9424079..2db9e65 100644 --- a/app/modules/clients/templates/clients/partials/form.html +++ b/app/modules/clients/templates/clients/partials/form.html @@ -147,6 +147,71 @@
+
+
+
+

Client Acceptance / Continuance Controls

+

Used for AQMM and peer review evidence. High/Critical risk clients cannot be activated until acceptance is approved.

+
+ {% set acc_status = form_data.acceptance_status or (row.acceptance_status if is_edit else 'pending_review') %} + {{ acc_status.replace('_',' ').title() }} +
+ +
+
+ + +

Formal approval can also be done from the client detail page.

+
+ +
+ +
+ + + + + + + + + + + +
+ + +
+ +
+ + +
+
+
+
@@ -161,7 +226,7 @@
diff --git a/app/modules/clients/ui.py b/app/modules/clients/ui.py index fc37273..49719cb 100644 --- a/app/modules/clients/ui.py +++ b/app/modules/clients/ui.py @@ -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() diff --git a/app/modules/core/rbac/permissions_registry.py b/app/modules/core/rbac/permissions_registry.py index 27c7b8b..ebc0ef9 100644 --- a/app/modules/core/rbac/permissions_registry.py +++ b/app/modules/core/rbac/permissions_registry.py @@ -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",