diff --git a/alembic/versions/20260623_phase_2_1_digital_client_acceptance_workflow.py b/alembic/versions/20260623_phase_2_1_digital_client_acceptance_workflow.py
new file mode 100644
index 0000000..9348ee7
--- /dev/null
+++ b/alembic/versions/20260623_phase_2_1_digital_client_acceptance_workflow.py
@@ -0,0 +1,112 @@
+"""Phase 2.1 digital client acceptance workflow
+
+Revision ID: 20260623_phase_2_1_digital_client_acceptance_workflow
+Revises: 20260622_phase_2_client_acceptance_controls
+Create Date: 2026-06-23
+"""
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+
+revision = "20260623_phase_2_1_digital_client_acceptance_workflow"
+down_revision = "20260622_phase_2_client_acceptance_controls"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "client_acceptance_declarations",
+ sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
+ sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id", ondelete="SET NULL"), nullable=True),
+ sa.Column("declaration_type", sa.String(length=30), nullable=False),
+ sa.Column("assigned_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("assigned_role_snapshot", sa.String(length=120), nullable=True),
+ sa.Column("declaration_text", sa.Text(), nullable=False),
+ sa.Column("status", sa.String(length=30), nullable=False, server_default="pending"),
+ sa.Column("response_notes", sa.Text(), nullable=True),
+ sa.Column("issue_details", sa.Text(), nullable=True),
+ sa.Column("responded_at_utc", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("response_ip", sa.String(length=80), nullable=True),
+ sa.Column("response_user_agent", sa.String(length=500), nullable=True),
+ sa.Column("requested_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
+ sa.Column("requested_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
+ sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
+ sa.UniqueConstraint("client_id", "declaration_type", "assigned_user_id", name="uq_client_acceptance_declaration_user"),
+ )
+ op.create_index("ix_client_acceptance_declarations_client_id", "client_acceptance_declarations", ["client_id"])
+ op.create_index("ix_client_acceptance_declarations_tenant_id", "client_acceptance_declarations", ["tenant_id"])
+ op.create_index("ix_client_acceptance_declarations_branch_id", "client_acceptance_declarations", ["branch_id"])
+ op.create_index("ix_client_acceptance_declarations_assigned_user_id", "client_acceptance_declarations", ["assigned_user_id"])
+ op.create_index("ix_client_acceptance_declarations_status", "client_acceptance_declarations", ["status"])
+ op.create_index("ix_client_acceptance_declarations_declaration_type", "client_acceptance_declarations", ["declaration_type"])
+
+ op.create_table(
+ "client_kyc_verifications",
+ sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
+ sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id", ondelete="SET NULL"), nullable=True),
+ sa.Column("status", sa.String(length=30), nullable=False, server_default="pending_documents"),
+ sa.Column("required_document_summary", sa.Text(), nullable=True),
+ sa.Column("available_document_summary", sa.Text(), nullable=True),
+ sa.Column("verification_notes", sa.Text(), nullable=True),
+ sa.Column("verified_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
+ sa.Column("verified_at_utc", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("rejected_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
+ sa.Column("rejected_at_utc", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
+ sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
+ sa.UniqueConstraint("client_id", name="uq_client_kyc_verification_client"),
+ )
+ op.create_index("ix_client_kyc_verifications_client_id", "client_kyc_verifications", ["client_id"])
+ op.create_index("ix_client_kyc_verifications_tenant_id", "client_kyc_verifications", ["tenant_id"])
+ op.create_index("ix_client_kyc_verifications_branch_id", "client_kyc_verifications", ["branch_id"])
+ op.create_index("ix_client_kyc_verifications_status", "client_kyc_verifications", ["status"])
+
+ op.create_table(
+ "client_engagement_letters",
+ sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
+ sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id", ondelete="SET NULL"), nullable=True),
+ sa.Column("letter_code", sa.String(length=80), nullable=False, server_default="STANDARD"),
+ sa.Column("title", sa.String(length=255), nullable=False),
+ sa.Column("body_text", sa.Text(), nullable=False),
+ sa.Column("version_no", sa.Integer(), nullable=False, server_default="1"),
+ sa.Column("status", sa.String(length=40), nullable=False, server_default="draft"),
+ sa.Column("sent_to_client_at_utc", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("approved_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
+ sa.Column("approved_at_utc", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("accepted_mode", sa.String(length=40), nullable=True),
+ sa.Column("accepted_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
+ sa.Column("accepted_at_utc", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("accepted_ip", sa.String(length=80), nullable=True),
+ sa.Column("accepted_user_agent", sa.String(length=500), nullable=True),
+ sa.Column("acceptance_declaration_text", sa.Text(), nullable=True),
+ sa.Column("otp_verified", sa.Boolean(), nullable=False, server_default=sa.false()),
+ sa.Column("pdf_hash_sha256", sa.String(length=64), nullable=True),
+ sa.Column("manual_signed_file_path", sa.String(length=1000), nullable=True),
+ sa.Column("manual_signed_file_hash_sha256", sa.String(length=64), nullable=True),
+ sa.Column("manual_verified_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
+ sa.Column("manual_verified_at_utc", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("rejection_reason", sa.Text(), nullable=True),
+ sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
+ sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
+ sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
+ sa.UniqueConstraint("client_id", "letter_code", "version_no", name="uq_client_engagement_letter_code_version"),
+ )
+ op.create_index("ix_client_engagement_letters_client_id", "client_engagement_letters", ["client_id"])
+ op.create_index("ix_client_engagement_letters_tenant_id", "client_engagement_letters", ["tenant_id"])
+ op.create_index("ix_client_engagement_letters_branch_id", "client_engagement_letters", ["branch_id"])
+ op.create_index("ix_client_engagement_letters_status", "client_engagement_letters", ["status"])
+ op.create_index("ix_client_engagement_letters_accepted_mode", "client_engagement_letters", ["accepted_mode"])
+
+
+def downgrade() -> None:
+ op.drop_table("client_engagement_letters")
+ op.drop_table("client_kyc_verifications")
+ op.drop_table("client_acceptance_declarations")
diff --git a/app/modules/clients/models.py b/app/modules/clients/models.py
index 4503e87..cb4e7d0 100644
--- a/app/modules/clients/models.py
+++ b/app/modules/clients/models.py
@@ -97,3 +97,100 @@ class ClientAuditLog(CommonBase):
summary: Mapped[str] = mapped_column(String(255), nullable=False)
payload_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
+
+class ClientAcceptanceDeclaration(CommonBase):
+ """Digital independence/conflict declaration requested from partner, manager or staff.
+
+ Each row is an auditable declaration. The client level checkbox is derived
+ only when all applicable declaration rows are answered without issue.
+ """
+
+ __tablename__ = "client_acceptance_declarations"
+ __table_args__ = (
+ UniqueConstraint("client_id", "declaration_type", "assigned_user_id", name="uq_client_acceptance_declaration_user"),
+ )
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+ client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True)
+ tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
+ branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True)
+ declaration_type: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
+ assigned_user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
+ assigned_role_snapshot: Mapped[str | None] = mapped_column(String(120), nullable=True)
+ declaration_text: Mapped[str] = mapped_column(Text, nullable=False)
+ status: Mapped[str] = mapped_column(String(30), nullable=False, default="pending", index=True)
+ response_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
+ issue_details: Mapped[str | None] = mapped_column(Text, nullable=True)
+ responded_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ response_ip: Mapped[str | None] = mapped_column(String(80), nullable=True)
+ response_user_agent: Mapped[str | None] = mapped_column(String(500), nullable=True)
+ requested_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
+ requested_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
+ updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
+
+
+class ClientKycVerification(CommonBase):
+ """KYC verification layer on top of existing permanent client documents.
+
+ This does not duplicate client uploads. It stores whether the existing
+ permanent documents are enough and who verified them.
+ """
+
+ __tablename__ = "client_kyc_verifications"
+ __table_args__ = (
+ UniqueConstraint("client_id", name="uq_client_kyc_verification_client"),
+ )
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+ client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True)
+ tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
+ branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True)
+ status: Mapped[str] = mapped_column(String(30), nullable=False, default="pending_documents", index=True)
+ required_document_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
+ available_document_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
+ verification_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
+ verified_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
+ verified_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ rejected_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
+ rejected_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
+ updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
+
+
+class ClientEngagementLetter(CommonBase):
+ """Client acceptance engagement letter with digital OTP or manual signed upload."""
+
+ __tablename__ = "client_engagement_letters"
+ __table_args__ = (
+ UniqueConstraint("client_id", "letter_code", "version_no", name="uq_client_engagement_letter_code_version"),
+ )
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+ client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True)
+ tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
+ branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True)
+ letter_code: Mapped[str] = mapped_column(String(80), nullable=False, default="STANDARD", index=True)
+ title: Mapped[str] = mapped_column(String(255), nullable=False)
+ body_text: Mapped[str] = mapped_column(Text, nullable=False)
+ version_no: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
+ status: Mapped[str] = mapped_column(String(40), nullable=False, default="draft", index=True)
+ sent_to_client_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ approved_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
+ approved_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ accepted_mode: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True)
+ accepted_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
+ accepted_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ accepted_ip: Mapped[str | None] = mapped_column(String(80), nullable=True)
+ accepted_user_agent: Mapped[str | None] = mapped_column(String(500), nullable=True)
+ acceptance_declaration_text: Mapped[str | None] = mapped_column(Text, nullable=True)
+ otp_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
+ pdf_hash_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
+ manual_signed_file_path: Mapped[str | None] = mapped_column(String(1000), nullable=True)
+ manual_signed_file_hash_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
+ manual_verified_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
+ manual_verified_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ rejection_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
+ created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
+ created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
+ updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
+
diff --git a/app/modules/clients/service.py b/app/modules/clients/service.py
index c3d705b..aaa405e 100644
--- a/app/modules/clients/service.py
+++ b/app/modules/clients/service.py
@@ -1,17 +1,23 @@
from __future__ import annotations
import csv
+import hashlib
import io
+import os
+import re
+from pathlib import Path
from datetime import datetime, timezone
from fastapi import HTTPException
from app.modules.clients import repository
+from sqlalchemy import select, func, or_
from app.core.security.passwords import hash_password
from app.modules.clients.association_admin_service import (
ensure_active_association,
update_association_fields,
)
+from app.modules.clients.models import ClientAcceptanceDeclaration, ClientKycVerification, ClientEngagementLetter
from app.modules.clients.constants import (
CLIENT_CATEGORY_OPTIONS,
CLIENT_STATUS,
@@ -19,6 +25,11 @@ from app.modules.clients.constants import (
RISK_CATEGORIES,
CLIENT_ACCEPTANCE_APPROVAL_REQUIRED_RISKS,
)
+from app.modules.alerts.service import create_alert
+from app.modules.core.iam.models import User
+from app.modules.core.rbac.models import Role, UserRole
+from app.modules.documents.models import PermanentClientDocument
+
def _payload_from_schema(data):
@@ -588,3 +599,428 @@ def mark_client_acceptance_pending_service(db, *, row, actor_user_id: int, revie
payload_json={"review_notes": review_notes},
)
return updated
+
+# ---------------------------------------------------------------------------
+# Phase 2.1 - digital client acceptance workflow
+# ---------------------------------------------------------------------------
+
+DECLARATION_TYPES = {"independence", "conflict"}
+DECLARATION_CLEAR_STATUSES = {"declared_clear"}
+ENGAGEMENT_LETTER_ACCEPTED_STATUSES = {"digitally_accepted", "manual_verified"}
+KYC_REQUIRED_KEYWORDS = ["PAN", "GST", "Incorporation", "Partnership Deed", "Address Proof", "Authorisation"]
+ACCEPTANCE_STORAGE_ROOT = Path(os.getenv("CLIENT_ACCEPTANCE_STORAGE_ROOT", "documents/client_acceptance")).resolve()
+
+
+def _clean_text(value: str | None, max_len: int | None = None) -> str | None:
+ value = (value or "").strip()
+ if not value:
+ return None
+ return value[:max_len] if max_len else value
+
+
+def _slug(value: str | None) -> str:
+ value = re.sub(r"[^A-Za-z0-9._-]+", "_", (value or "file").strip())
+ return value.strip("._-")[:80] or "file"
+
+
+def _request_ip_and_agent(request):
+ ip = None
+ try:
+ ip = request.headers.get("x-forwarded-for", "").split(",")[0].strip() or request.client.host
+ except Exception:
+ ip = None
+ try:
+ agent = request.headers.get("user-agent")
+ except Exception:
+ agent = None
+ return ip, agent
+
+
+def _declaration_text(client, declaration_type: str) -> str:
+ if declaration_type == "conflict":
+ return (
+ f"I confirm that I have reviewed possible conflict of interest for {client.client_name}. "
+ "I do not have any financial, business, family, employment, litigation or other relationship "
+ "that creates a conflict, except as specifically disclosed in my remarks."
+ )
+ return (
+ f"I confirm that I am independent for the proposed/continuing engagement of {client.client_name}. "
+ "I will immediately report any independence threat or safeguard requirement to the engagement partner."
+ )
+
+
+def _role_name_for_user(db, user_id: int) -> str:
+ names = db.execute(
+ select(Role.name)
+ .join(UserRole, UserRole.role_id == Role.id)
+ .where(UserRole.user_id == user_id)
+ .order_by(Role.name.asc())
+ ).scalars().all()
+ return ",".join(names) if names else ""
+
+
+def _applicable_acceptance_user_ids(db, client) -> list[int]:
+ ids = []
+ for uid in [getattr(client, "partner_id", None), getattr(client, "default_review_partner_user_id", None), getattr(client, "created_by_user_id", None)]:
+ if uid and uid not in ids:
+ ids.append(int(uid))
+ if not ids and getattr(client, "updated_by_user_id", None):
+ ids.append(int(client.updated_by_user_id))
+ return ids
+
+
+def _safe_create_alert(db, *, user_id: int, title: str, message: str, client, actor_user_id: int | None = None, target_url: str | None = None):
+ try:
+ create_alert(
+ db,
+ user_id=user_id,
+ tenant_id=client.tenant_id,
+ branch_id=client.branch_id,
+ role_context="client_acceptance",
+ alert_type="client",
+ priority="high",
+ title=title,
+ message=message,
+ target_url=target_url or f"/clients/{client.id}",
+ created_by_user_id=actor_user_id,
+ commit=False,
+ )
+ except Exception:
+ pass
+
+
+def list_client_acceptance_declarations(db, *, client_id: int):
+ return db.execute(
+ select(ClientAcceptanceDeclaration)
+ .where(ClientAcceptanceDeclaration.client_id == client_id)
+ .order_by(ClientAcceptanceDeclaration.declaration_type.asc(), ClientAcceptanceDeclaration.assigned_user_id.asc())
+ ).scalars().all()
+
+
+def list_my_pending_acceptance_declarations(db, *, user_id: int, limit: int = 25):
+ return db.execute(
+ select(ClientAcceptanceDeclaration)
+ .where(ClientAcceptanceDeclaration.assigned_user_id == user_id, ClientAcceptanceDeclaration.status == "pending")
+ .order_by(ClientAcceptanceDeclaration.requested_at_utc.desc())
+ .limit(limit)
+ ).scalars().all()
+
+
+def request_client_acceptance_declarations_service(db, *, row, actor_user_id: int):
+ assigned_ids = _applicable_acceptance_user_ids(db, row)
+ if not assigned_ids:
+ raise HTTPException(status_code=400, detail="No applicable partner/review user is mapped to this client.")
+ created = 0
+ for uid in assigned_ids:
+ for dtype in sorted(DECLARATION_TYPES):
+ existing = db.execute(
+ select(ClientAcceptanceDeclaration).where(
+ ClientAcceptanceDeclaration.client_id == row.id,
+ ClientAcceptanceDeclaration.declaration_type == dtype,
+ ClientAcceptanceDeclaration.assigned_user_id == uid,
+ )
+ ).scalar_one_or_none()
+ if not existing:
+ db.add(ClientAcceptanceDeclaration(
+ client_id=row.id,
+ tenant_id=row.tenant_id,
+ branch_id=row.branch_id,
+ declaration_type=dtype,
+ assigned_user_id=uid,
+ assigned_role_snapshot=_role_name_for_user(db, uid),
+ declaration_text=_declaration_text(row, dtype),
+ requested_by_user_id=actor_user_id,
+ ))
+ created += 1
+ _safe_create_alert(
+ db,
+ user_id=uid,
+ title=f"{dtype.title()} declaration required",
+ message=f"Please submit your {dtype} declaration for {row.client_name}.",
+ client=row,
+ actor_user_id=actor_user_id,
+ )
+ repository.write_audit_log(
+ db,
+ client_id=row.id,
+ tenant_id=row.tenant_id,
+ branch_id=row.branch_id,
+ actor_user_id=actor_user_id,
+ action="acceptance_declarations_requested",
+ summary="Digital independence/conflict declarations requested.",
+ payload_json={"created": created, "assigned_user_ids": assigned_ids},
+ )
+ db.commit()
+ return list_client_acceptance_declarations(db, client_id=row.id)
+
+
+def submit_client_acceptance_declaration_service(db, *, declaration_id: int, current_user, clear: bool, notes: str | None, issue_details: str | None, request=None):
+ decl = db.get(ClientAcceptanceDeclaration, declaration_id)
+ if not decl:
+ raise HTTPException(status_code=404, detail="Declaration not found.")
+ if decl.assigned_user_id != current_user.id:
+ raise HTTPException(status_code=403, detail="This declaration is assigned to another user.")
+ ip, agent = _request_ip_and_agent(request)
+ decl.status = "declared_clear" if clear else "declared_issue"
+ decl.response_notes = _clean_text(notes)
+ decl.issue_details = _clean_text(issue_details)
+ decl.responded_at_utc = datetime.now(timezone.utc)
+ decl.response_ip = ip
+ decl.response_user_agent = agent
+ db.add(decl)
+ db.flush()
+ _refresh_acceptance_derived_statuses(db, client_id=decl.client_id, actor_user_id=current_user.id)
+ db.commit()
+ return decl
+
+
+def get_client_kyc_verification(db, *, client_id: int) -> ClientKycVerification | None:
+ return db.execute(select(ClientKycVerification).where(ClientKycVerification.client_id == client_id)).scalar_one_or_none()
+
+
+def _permanent_docs_summary(db, *, client_id: int) -> tuple[str, int]:
+ docs = db.execute(
+ select(PermanentClientDocument)
+ .where(PermanentClientDocument.client_id == client_id, PermanentClientDocument.is_deleted.is_(False))
+ .order_by(PermanentClientDocument.category.asc(), PermanentClientDocument.title.asc())
+ ).scalars().all()
+ parts = []
+ for d in docs:
+ parts.append(f"{d.category}: {d.title} ({d.current_version_no} version(s))")
+ return "\n".join(parts), len(docs)
+
+
+def sync_client_kyc_from_permanent_documents_service(db, *, row, actor_user_id: int):
+ summary, count = _permanent_docs_summary(db, client_id=row.id)
+ kyc = get_client_kyc_verification(db, client_id=row.id)
+ status = "pending_verification" if count else "pending_documents"
+ required_summary = "\n".join(f"- {x}" for x in KYC_REQUIRED_KEYWORDS)
+ if not kyc:
+ kyc = ClientKycVerification(client_id=row.id, tenant_id=row.tenant_id, branch_id=row.branch_id)
+ if kyc.status != "verified":
+ kyc.status = status
+ kyc.required_document_summary = required_summary
+ kyc.available_document_summary = summary or "No permanent client documents uploaded yet."
+ db.add(kyc)
+ repository.write_audit_log(
+ db,
+ client_id=row.id,
+ tenant_id=row.tenant_id,
+ branch_id=row.branch_id,
+ actor_user_id=actor_user_id,
+ action="kyc_synced_from_permanent_documents",
+ summary="KYC status refreshed from permanent client documents.",
+ payload_json={"permanent_document_count": count, "status": kyc.status},
+ )
+ db.commit()
+ return kyc
+
+
+def verify_client_kyc_service(db, *, row, actor_user_id: int, notes: str | None = None):
+ kyc = sync_client_kyc_from_permanent_documents_service(db, row=row, actor_user_id=actor_user_id)
+ if not kyc.available_document_summary or kyc.available_document_summary.startswith("No permanent"):
+ raise HTTPException(status_code=400, detail="KYC cannot be verified until permanent client documents are uploaded.")
+ now = datetime.now(timezone.utc)
+ kyc.status = "verified"
+ kyc.verified_by_user_id = actor_user_id
+ kyc.verified_at_utc = now
+ kyc.rejected_by_user_id = None
+ kyc.rejected_at_utc = None
+ kyc.verification_notes = _clean_text(notes)
+ db.add(kyc)
+ updated = repository.update_client(db, row, {"kyc_completed": True})
+ repository.write_audit_log(db, client_id=row.id, tenant_id=row.tenant_id, branch_id=row.branch_id, actor_user_id=actor_user_id, action="kyc_verified", summary="KYC verified using permanent client documents.", payload_json={"notes": notes})
+ db.commit()
+ return updated, kyc
+
+
+def reject_client_kyc_service(db, *, row, actor_user_id: int, notes: str | None = None):
+ kyc = get_client_kyc_verification(db, client_id=row.id) or ClientKycVerification(client_id=row.id, tenant_id=row.tenant_id, branch_id=row.branch_id)
+ now = datetime.now(timezone.utc)
+ kyc.status = "rejected"
+ kyc.rejected_by_user_id = actor_user_id
+ kyc.rejected_at_utc = now
+ kyc.verification_notes = _clean_text(notes)
+ db.add(kyc)
+ updated = repository.update_client(db, row, {"kyc_completed": False})
+ _safe_create_alert(db, user_id=row.portal_user_id, title="KYC documents require resubmission", message=notes or "Please update the permanent documents requested by the firm.", client=row, actor_user_id=actor_user_id, target_url="/client/documents") if getattr(row, "portal_user_id", None) else None
+ repository.write_audit_log(db, client_id=row.id, tenant_id=row.tenant_id, branch_id=row.branch_id, actor_user_id=actor_user_id, action="kyc_rejected", summary="KYC rejected / resubmission requested.", payload_json={"notes": notes})
+ db.commit()
+ return updated, kyc
+
+
+def get_current_engagement_letter(db, *, client_id: int) -> ClientEngagementLetter | None:
+ return db.execute(
+ select(ClientEngagementLetter)
+ .where(ClientEngagementLetter.client_id == client_id)
+ .order_by(ClientEngagementLetter.version_no.desc(), ClientEngagementLetter.id.desc())
+ ).scalars().first()
+
+
+def _default_engagement_letter_body(row) -> str:
+ return f"""Dear {row.client_name},
+
+We are pleased to confirm our understanding of the terms and objectives of our professional engagement. The services will be performed subject to applicable laws, professional standards, ICAI requirements, management responsibilities, timely submission of records and payment of agreed fees.
+
+Management is responsible for the completeness and accuracy of records, explanations and representations provided to the firm. Our responsibility is limited to the scope agreed for the relevant service/financial year. This engagement letter, once accepted digitally or by signed upload, will form part of the client acceptance and continuance evidence.
+
+Regards,
+A R R R & Associates"""
+
+
+def draft_client_engagement_letter_service(db, *, row, actor_user_id: int, title: str | None = None, body_text: str | None = None):
+ latest = get_current_engagement_letter(db, client_id=row.id)
+ version = (latest.version_no + 1) if latest else 1
+ letter = ClientEngagementLetter(
+ client_id=row.id,
+ tenant_id=row.tenant_id,
+ branch_id=row.branch_id,
+ letter_code=f"ENG-{row.id}",
+ title=_clean_text(title, 255) or f"Engagement Letter - {row.client_name}",
+ body_text=_clean_text(body_text) or _default_engagement_letter_body(row),
+ version_no=version,
+ status="draft",
+ created_by_user_id=actor_user_id,
+ )
+ db.add(letter)
+ repository.write_audit_log(db, client_id=row.id, tenant_id=row.tenant_id, branch_id=row.branch_id, actor_user_id=actor_user_id, action="engagement_letter_drafted", summary="Engagement letter draft created.", payload_json={"version_no": version})
+ db.commit()
+ db.refresh(letter)
+ return letter
+
+
+def approve_and_send_engagement_letter_service(db, *, row, letter_id: int, actor_user_id: int):
+ letter = db.get(ClientEngagementLetter, letter_id)
+ if not letter or letter.client_id != row.id:
+ raise HTTPException(status_code=404, detail="Engagement letter not found.")
+ now = datetime.now(timezone.utc)
+ letter.status = "sent_to_client"
+ letter.approved_by_user_id = actor_user_id
+ letter.approved_at_utc = now
+ letter.sent_to_client_at_utc = now
+ letter.pdf_hash_sha256 = hashlib.sha256(letter.body_text.encode("utf-8")).hexdigest()
+ db.add(letter)
+ if getattr(row, "portal_user_id", None):
+ _safe_create_alert(db, user_id=row.portal_user_id, title="Engagement letter ready for acceptance", message=f"Please accept the engagement letter for {row.client_name} digitally using OTP or upload the signed copy.", client=row, actor_user_id=actor_user_id, target_url="/client/engagement-letter")
+ repository.write_audit_log(db, client_id=row.id, tenant_id=row.tenant_id, branch_id=row.branch_id, actor_user_id=actor_user_id, action="engagement_letter_sent", summary="Engagement letter approved and sent to client.", payload_json={"letter_id": letter.id, "version_no": letter.version_no})
+ db.commit()
+ return letter
+
+
+def render_engagement_letter_html(letter: ClientEngagementLetter, client) -> str:
+ import html
+ body = html.escape(letter.body_text).replace("\n", "
")
+ return f"""
{body}
Declarations, KYC from permanent documents and engagement letter acceptance are captured as audit evidence.
+Accept digitally with OTP/session evidence or upload a manually signed copy.
+Version {{ engagement_letter.version_no }} · {{ engagement_letter.status.replace('_',' ').title() }}
+Use this option when you accept the engagement letter electronically. The ERP stores OTP verification, user, time, IP/device and declaration evidence.
+ + +