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"""{html.escape(letter.title)} + +

{html.escape(letter.title)}

Client: {html.escape(client.client_name)} | Version: {letter.version_no} | Status: {html.escape(letter.status)}

{body}

+""" + + +def digitally_accept_engagement_letter_service(db, *, row, letter_id: int, current_user, declaration_text: str | None, request=None): + 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.") + if letter.status not in {"sent_to_client", "manual_uploaded", "rejected"}: + raise HTTPException(status_code=400, detail="Only partner-approved engagement letters sent to client can be accepted.") + ip, agent = _request_ip_and_agent(request) + now = datetime.now(timezone.utc) + declaration = _clean_text(declaration_text) or "I have read and accept the engagement letter for and on behalf of the client." + letter.status = "digitally_accepted" + letter.accepted_mode = "digital_otp" + letter.accepted_by_user_id = current_user.id + letter.accepted_at_utc = now + letter.accepted_ip = ip + letter.accepted_user_agent = agent + letter.acceptance_declaration_text = declaration + letter.otp_verified = True + db.add(letter) + updated = repository.update_client(db, row, {"engagement_letter_received": True}) + repository.write_audit_log(db, client_id=row.id, tenant_id=row.tenant_id, branch_id=row.branch_id, actor_user_id=current_user.id, action="engagement_letter_digitally_accepted", summary="Engagement letter accepted digitally with OTP/session evidence.", payload_json={"letter_id": letter.id, "ip": ip}) + db.commit() + return updated, letter + + +def save_manual_signed_engagement_letter_service(db, *, row, letter_id: int, upload_file, current_user, request=None): + 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.") + if letter.status not in {"sent_to_client", "manual_uploaded", "rejected"}: + raise HTTPException(status_code=400, detail="Signed copy can be uploaded only after partner approval and sending to client.") + ACCEPTANCE_STORAGE_ROOT.mkdir(parents=True, exist_ok=True) + folder = ACCEPTANCE_STORAGE_ROOT / str(row.tenant_id) / str(row.id) + folder.mkdir(parents=True, exist_ok=True) + filename = _slug(getattr(upload_file, "filename", None) or "signed_engagement_letter.pdf") + target = folder / f"letter_{letter.id}_signed_{filename}" + content = upload_file.file.read() + if not content: + raise HTTPException(status_code=400, detail="Uploaded signed engagement letter is empty.") + target.write_bytes(content) + letter.status = "manual_uploaded" + letter.accepted_mode = "manual_signed_upload" + letter.accepted_by_user_id = current_user.id + letter.accepted_at_utc = datetime.now(timezone.utc) + letter.manual_signed_file_path = str(target) + letter.manual_signed_file_hash_sha256 = hashlib.sha256(content).hexdigest() + 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=current_user.id, action="engagement_letter_signed_uploaded", summary="Client uploaded manually signed engagement letter.", payload_json={"letter_id": letter.id, "filename": filename}) + if row.partner_id: + _safe_create_alert(db, user_id=row.partner_id, title="Signed engagement letter uploaded", message=f"Please verify signed engagement letter for {row.client_name}.", client=row, actor_user_id=current_user.id) + db.commit() + return letter + + +def verify_manual_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.") + if letter.status != "manual_uploaded": + raise HTTPException(status_code=400, detail="Only manually uploaded engagement letters can be verified.") + now = datetime.now(timezone.utc) + letter.status = "manual_verified" + letter.manual_verified_by_user_id = actor_user_id + letter.manual_verified_at_utc = now + db.add(letter) + updated = repository.update_client(db, row, {"engagement_letter_received": 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="engagement_letter_manual_verified", summary="Manual signed engagement letter verified.", payload_json={"letter_id": letter.id}) + db.commit() + return updated, letter + + +def _refresh_acceptance_derived_statuses(db, *, client_id: int, actor_user_id: int | None = None): + client = repository.get_client_by_id(db, client_id) + if not client: + return None + declarations = list_client_acceptance_declarations(db, client_id=client_id) + independence = [d for d in declarations if d.declaration_type == "independence"] + conflict = [d for d in declarations if d.declaration_type == "conflict"] + independence_ok = bool(independence) and all(d.status in DECLARATION_CLEAR_STATUSES for d in independence) + conflict_ok = bool(conflict) and all(d.status in DECLARATION_CLEAR_STATUSES for d in conflict) + any_issue = any(d.status == "declared_issue" for d in declarations) + kyc = get_client_kyc_verification(db, client_id=client_id) + letter = get_current_engagement_letter(db, client_id=client_id) + payload = { + "independence_check_completed": independence_ok, + "conflict_check_completed": conflict_ok, + "kyc_completed": bool(kyc and kyc.status == "verified"), + "engagement_letter_received": bool(letter and letter.status in ENGAGEMENT_LETTER_ACCEPTED_STATUSES), + } + if any_issue: + payload["acceptance_status"] = "pending_review" + updated = repository.update_client(db, client, payload) + return updated + + +def build_client_acceptance_workflow_payload(db, *, client_id: int) -> dict: + client = repository.get_client_by_id(db, client_id) + declarations = list_client_acceptance_declarations(db, client_id=client_id) + kyc = get_client_kyc_verification(db, client_id=client_id) + letter = get_current_engagement_letter(db, client_id=client_id) + permanent_summary, permanent_count = _permanent_docs_summary(db, client_id=client_id) + return { + "acceptance_declarations": declarations, + "kyc_verification": kyc, + "engagement_letter": letter, + "permanent_document_summary": permanent_summary, + "permanent_document_count": permanent_count, + "workflow_ready": bool(client and client.independence_check_completed and client.conflict_check_completed and client.kyc_completed and (not client.engagement_letter_required or client.engagement_letter_received)), + } diff --git a/app/modules/clients/templates/clients/detail.html b/app/modules/clients/templates/clients/detail.html index 5835c6c..07a187b 100644 --- a/app/modules/clients/templates/clients/detail.html +++ b/app/modules/clients/templates/clients/detail.html @@ -151,6 +151,115 @@ {% endif %} {% endif %} +
+
+
+

Digital Acceptance Workflow

+

Declarations, KYC from permanent documents and engagement letter acceptance are captured as audit evidence.

+
+ {{ 'Ready for Approval' if workflow_ready else 'Pending Evidence' }} +
+ +
+
+
+
Independence / Conflict Declarations
+ {% if can_manage_acceptance %} +
+ + +
+ {% endif %} +
+
+ {% if acceptance_declarations %} + {% for d in acceptance_declarations %} +
+
{{ d.declaration_type.replace('_',' ').title() }} · User #{{ d.assigned_user_id }}
+
{{ d.status.replace('_',' ').title() }}
+ {% if d.assigned_user_id == current_user.id and d.status == 'pending' %} +
+ + + + + +
+ {% endif %} +
+ {% endfor %} + {% else %} +
No declaration requests have been created yet.
+ {% endif %} +
+
+ +
+
+
KYC from Permanent Documents
+ {% if can_manage_acceptance %} +
+ + +
+ {% endif %} +
+
Permanent documents found: {{ permanent_document_count }}
+
{{ permanent_document_summary or 'No permanent document summary available. Click Sync after client uploads permanent documents.' }}
+ {% if kyc_verification %} +
KYC Status: {{ kyc_verification.status.replace('_',' ').title() }}
+ {% endif %} + {% if can_manage_acceptance or can_approve_acceptance %} +
+
+ + + +
+
+ + + +
+
+ {% endif %} +
+ +
+
Engagement Letter
+ {% if engagement_letter %} +
Version {{ engagement_letter.version_no }} · {{ engagement_letter.status.replace('_',' ').title() }}
+ View / Print PDF + {% if can_approve_acceptance and engagement_letter.status == 'draft' %} +
+ + +
+ {% endif %} + {% if (can_manage_acceptance or can_approve_acceptance) and engagement_letter.status == 'manual_uploaded' %} +
+ + +
+ {% endif %} + {% else %} +
No engagement letter drafted yet.
+ {% endif %} + {% if can_manage_acceptance %} +
+ + + + +
+ {% endif %} +
+
+
+
diff --git a/app/modules/clients/templates/clients/portal_engagement_letter.html b/app/modules/clients/templates/clients/portal_engagement_letter.html new file mode 100644 index 0000000..5610e26 --- /dev/null +++ b/app/modules/clients/templates/clients/portal_engagement_letter.html @@ -0,0 +1,62 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Engagement Letter

+

Accept digitally with OTP/session evidence or upload a manually signed copy.

+
+ Back to Dashboard +
+ + {% if not engagement_letter %} +
+ Engagement letter is not yet sent by the firm. +
+ {% else %} +
+
+
+

{{ engagement_letter.title }}

+

Version {{ engagement_letter.version_no }} · {{ engagement_letter.status.replace('_',' ').title() }}

+
+ View / Download / Print +
+ +
{{ engagement_letter.body_text }}
+ + {% if engagement_letter.status in ['sent_to_client', 'manual_uploaded', 'rejected'] %} +
+
+

Accept Digitally with OTP

+

Use this option when you accept the engagement letter electronically. The ERP stores OTP verification, user, time, IP/device and declaration evidence.

+
+ + +
+
+ + + + +
+
+ +
+ +

Upload Manually Signed Copy

+

Download/print the engagement letter, sign it manually and upload the signed PDF/image. The firm will verify it before marking it accepted.

+ + +
+
+ {% else %} +
+ Current status: {{ engagement_letter.status.replace('_',' ').title() }}. + {% if engagement_letter.accepted_at_utc %}Accepted at: {{ engagement_letter.accepted_at_utc }}{% endif %} +
+ {% endif %} +
+ {% endif %} +
+{% endblock %} diff --git a/app/modules/clients/ui.py b/app/modules/clients/ui.py index 49719cb..7d90d74 100644 --- a/app/modules/clients/ui.py +++ b/app/modules/clients/ui.py @@ -7,6 +7,7 @@ from pydantic import ValidationError from app.core.db.common import CommonSessionLocal from app.core.security.csrf import get_or_create_csrf_token, validate_csrf from app.core.security.session_auth import get_current_user +from app.core.security.otp import start_otp, verify_otp from app.core.templating import templates from app.modules.clients import repository from app.modules.clients.access import build_scope, can_view_client_row @@ -33,6 +34,19 @@ from app.modules.clients.service import ( reject_client_acceptance_service, restore_client_service, mark_client_acceptance_pending_service, + request_client_acceptance_declarations_service, + submit_client_acceptance_declaration_service, + sync_client_kyc_from_permanent_documents_service, + verify_client_kyc_service, + reject_client_kyc_service, + draft_client_engagement_letter_service, + approve_and_send_engagement_letter_service, + render_engagement_letter_html, + digitally_accept_engagement_letter_service, + save_manual_signed_engagement_letter_service, + verify_manual_engagement_letter_service, + build_client_acceptance_workflow_payload, + get_current_engagement_letter, update_client_service, update_client_self_profile_service, ) @@ -59,6 +73,7 @@ from app.modules.billing.client_portal_service import ( list_client_portal_invoices, ) from app.modules.billing.services import build_invoice_print_context, create_cashfree_transaction, create_payumoney_transaction, process_cashfree_return, process_cashfree_webhook, process_payumoney_response +from app.modules.email_integration.services import send_auth_otp_email from app.modules.documents.services import ( get_permanent_version, get_version, @@ -647,6 +662,7 @@ def client_detail(request: Request, client_id: int): return _redirect_denied() audit_logs = list_client_audit_logs(db, row=type("Tmp", (), {"id": row["id"]})(), limit=10) if has("clients.audit_log.view") else [] + workflow = build_client_acceptance_workflow_payload(db, client_id=client_id) return _render( request, "modules/clients/templates/clients/detail.html", @@ -655,6 +671,12 @@ def client_detail(request: Request, client_id: int): title=f"Client • {row['client_name']}", row=row, audit_logs=audit_logs, + acceptance_declarations=workflow.get("acceptance_declarations", []), + kyc_verification=workflow.get("kyc_verification"), + engagement_letter=workflow.get("engagement_letter"), + permanent_document_summary=workflow.get("permanent_document_summary"), + permanent_document_count=workflow.get("permanent_document_count", 0), + workflow_ready=workflow.get("workflow_ready", False), scope=scope, can_edit=has("clients.edit"), can_deactivate=has("clients.deactivate"), @@ -1014,6 +1036,187 @@ async def client_restore(request: Request, client_id: int): db.close() +@router.post("/{client_id}/acceptance/request-declarations") +async def client_acceptance_request_declarations(request: Request, client_id: int, csrf_token: str = Form(...)): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + validate_csrf(request, csrf_token) + has = _has_perm_factory(db, user) + if not has("clients.acceptance.manage"): + return _redirect_denied() + scope = _elevate_scope_for_system_admin(build_scope(request, user, has), _role_names(db, user)) + 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) + request_client_acceptance_declarations_service(db, row=row, actor_user_id=user.id) + return RedirectResponse(url=f"/clients/{client_id}", status_code=303) + finally: + db.close() + + +@router.post("/{client_id}/acceptance/declarations/{declaration_id}/submit") +async def client_acceptance_submit_declaration( + request: Request, + client_id: int, + declaration_id: int, + csrf_token: str = Form(...), + declaration_result: str = Form("clear"), + response_notes: str = Form(""), + issue_details: str = Form(""), +): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + validate_csrf(request, csrf_token) + submit_client_acceptance_declaration_service( + db, + declaration_id=declaration_id, + current_user=user, + clear=(declaration_result == "clear"), + notes=response_notes, + issue_details=issue_details, + request=request, + ) + return RedirectResponse(url=f"/clients/{client_id}", status_code=303) + finally: + db.close() + + +@router.post("/{client_id}/acceptance/kyc/sync") +async def client_acceptance_kyc_sync(request: Request, client_id: int, csrf_token: str = Form(...)): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + validate_csrf(request, csrf_token) + has = _has_perm_factory(db, user) + if not has("clients.acceptance.manage"): + return _redirect_denied() + scope = _elevate_scope_for_system_admin(build_scope(request, user, has), _role_names(db, user)) + 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) + sync_client_kyc_from_permanent_documents_service(db, row=row, actor_user_id=user.id) + return RedirectResponse(url=f"/clients/{client_id}", status_code=303) + finally: + db.close() + + +@router.post("/{client_id}/acceptance/kyc/verify") +async def client_acceptance_kyc_verify(request: Request, client_id: int, csrf_token: str = Form(...), verification_notes: str = Form("")): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + validate_csrf(request, csrf_token) + has = _has_perm_factory(db, user) + if not has("clients.acceptance.approve") and not has("clients.acceptance.manage"): + return _redirect_denied() + scope = _elevate_scope_for_system_admin(build_scope(request, user, has), _role_names(db, user)) + 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) + verify_client_kyc_service(db, row=row, actor_user_id=user.id, notes=verification_notes) + return RedirectResponse(url=f"/clients/{client_id}", status_code=303) + finally: + db.close() + + +@router.post("/{client_id}/acceptance/kyc/reject") +async def client_acceptance_kyc_reject(request: Request, client_id: int, csrf_token: str = Form(...), verification_notes: str = Form("")): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + validate_csrf(request, csrf_token) + has = _has_perm_factory(db, user) + if not has("clients.acceptance.manage"): + return _redirect_denied() + scope = _elevate_scope_for_system_admin(build_scope(request, user, has), _role_names(db, user)) + 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) + reject_client_kyc_service(db, row=row, actor_user_id=user.id, notes=verification_notes) + return RedirectResponse(url=f"/clients/{client_id}", status_code=303) + finally: + db.close() + + +@router.post("/{client_id}/acceptance/engagement-letter/draft") +async def client_engagement_letter_draft(request: Request, client_id: int, csrf_token: str = Form(...), title: str = Form(""), body_text: str = Form("")): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + validate_csrf(request, csrf_token) + has = _has_perm_factory(db, user) + if not has("clients.acceptance.manage"): + return _redirect_denied() + scope = _elevate_scope_for_system_admin(build_scope(request, user, has), _role_names(db, user)) + 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) + draft_client_engagement_letter_service(db, row=row, actor_user_id=user.id, title=title, body_text=body_text) + return RedirectResponse(url=f"/clients/{client_id}", status_code=303) + finally: + db.close() + + +@router.post("/{client_id}/acceptance/engagement-letter/{letter_id}/approve-send") +async def client_engagement_letter_approve_send(request: Request, client_id: int, letter_id: int, csrf_token: str = Form(...)): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + validate_csrf(request, csrf_token) + has = _has_perm_factory(db, user) + if not has("clients.acceptance.approve"): + return _redirect_denied() + scope = _elevate_scope_for_system_admin(build_scope(request, user, has), _role_names(db, user)) + 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) + approve_and_send_engagement_letter_service(db, row=row, letter_id=letter_id, actor_user_id=user.id) + return RedirectResponse(url=f"/clients/{client_id}", status_code=303) + finally: + db.close() + + +@router.get("/{client_id}/acceptance/engagement-letter/{letter_id}/download") +def client_engagement_letter_download(request: Request, client_id: int, letter_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) + scope = _elevate_scope_for_system_admin(build_scope(request, user, has), _role_names(db, user)) + row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=True, allow_all_clients=scope.allow_all_clients) + letter = get_current_engagement_letter(db, client_id=client_id) + if not letter or letter.id != letter_id: + return _redirect_denied() + return HTMLResponse(render_engagement_letter_html(letter, row)) + finally: + db.close() + + +@router.post("/{client_id}/acceptance/engagement-letter/{letter_id}/verify-manual") +async def client_engagement_letter_verify_manual(request: Request, client_id: int, letter_id: int, csrf_token: str = Form(...)): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + validate_csrf(request, csrf_token) + has = _has_perm_factory(db, user) + if not has("clients.acceptance.approve") and not has("clients.acceptance.manage"): + return _redirect_denied() + scope = _elevate_scope_for_system_admin(build_scope(request, user, has), _role_names(db, user)) + 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) + verify_manual_engagement_letter_service(db, row=row, letter_id=letter_id, actor_user_id=user.id) + return RedirectResponse(url=f"/clients/{client_id}", status_code=303) + finally: + db.close() + + portal_router = APIRouter(prefix="/client", tags=["client-portal"]) @@ -1723,3 +1926,87 @@ def client_portal_download_permanent_document(request: Request, version_id: int) return FileResponse(path, media_type=version.content_type or "application/octet-stream", filename=version.original_filename) finally: db.close() + + +@portal_router.get("/engagement-letter") +def client_portal_engagement_letter(request: Request): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return RedirectResponse(url="/login", status_code=303) + client_row, redirect = _portal_client_or_redirect(request, db, current_user) + if redirect: + return redirect + letter = get_current_engagement_letter(db, client_id=client_row["id"] if isinstance(client_row, dict) else client_row.id) + return templates.TemplateResponse( + "modules/clients/templates/clients/portal_engagement_letter.html", + _portal_context(request, db, current_user, client_row=client_row, engagement_letter=letter), + ) + finally: + db.close() + + +@portal_router.post("/engagement-letter/{letter_id}/send-otp") +async def client_portal_engagement_letter_send_otp(request: Request, letter_id: int, csrf_token: str = Form(...)): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return RedirectResponse(url="/login", status_code=303) + validate_csrf(request, csrf_token) + client_row, redirect = _portal_client_or_redirect(request, db, current_user) + if redirect: + return redirect + letter = get_current_engagement_letter(db, client_id=client_row["id"] if isinstance(client_row, dict) else client_row.id) + if not letter or letter.id != letter_id or letter.status not in ["sent_to_client", "manual_uploaded", "rejected"]: + return RedirectResponse(url="/client/engagement-letter?error=letter_not_available", status_code=303) + code = start_otp(request) + try: + send_auth_otp_email(db, user=current_user, otp_code=code, purpose="engagement_letter_acceptance") + db.commit() + except Exception as exc: + print(f"[ENGAGEMENT LETTER OTP ERROR] user={current_user.email} error={exc}") + request.session["client_engagement_letter_otp_letter_id"] = letter_id + return RedirectResponse(url="/client/engagement-letter?otp=sent", status_code=303) + finally: + db.close() + + +@portal_router.post("/engagement-letter/{letter_id}/accept-digital") +async def client_portal_engagement_letter_accept_digital(request: Request, letter_id: int, csrf_token: str = Form(...), otp: str = Form(...), declaration_text: str = Form("")): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return RedirectResponse(url="/login", status_code=303) + validate_csrf(request, csrf_token) + client_row, redirect = _portal_client_or_redirect(request, db, current_user) + if redirect: + return redirect + if request.session.get("client_engagement_letter_otp_letter_id") != letter_id or not verify_otp(request, otp): + return RedirectResponse(url="/client/engagement-letter?error=invalid_otp", status_code=303) + row_obj = repository.get_client_by_id(db, client_row["id"] if isinstance(client_row, dict) else client_row.id) + digitally_accept_engagement_letter_service(db, row=row_obj, letter_id=letter_id, current_user=current_user, declaration_text=declaration_text, request=request) + request.session.pop("client_engagement_letter_otp_letter_id", None) + return RedirectResponse(url="/client/engagement-letter", status_code=303) + finally: + db.close() + + +@portal_router.post("/engagement-letter/{letter_id}/upload-signed") +async def client_portal_engagement_letter_upload_signed(request: Request, letter_id: int, csrf_token: str = Form(...), signed_file: UploadFile = File(...)): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return RedirectResponse(url="/login", status_code=303) + validate_csrf(request, csrf_token) + client_row, redirect = _portal_client_or_redirect(request, db, current_user) + if redirect: + return redirect + row_obj = repository.get_client_by_id(db, client_row["id"] if isinstance(client_row, dict) else client_row.id) + save_manual_signed_engagement_letter_service(db, row=row_obj, letter_id=letter_id, upload_file=signed_file, current_user=current_user, request=request) + return RedirectResponse(url="/client/engagement-letter", status_code=303) + finally: + db.close()