Add digital client acceptance workflow
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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", "<br>")
|
||||
return f"""<!doctype html><html><head><meta charset='utf-8'><title>{html.escape(letter.title)}</title>
|
||||
<style>body{{font-family:Arial,sans-serif;max-width:850px;margin:40px auto;line-height:1.55;color:#111827}}.meta{{color:#64748b;font-size:13px}}.box{{border:1px solid #cbd5e1;border-radius:12px;padding:24px}}</style></head><body>
|
||||
<div class='box'><h1>{html.escape(letter.title)}</h1><p class='meta'>Client: {html.escape(client.client_name)} | Version: {letter.version_no} | Status: {html.escape(letter.status)}</p><p>{body}</p></div>
|
||||
<script>window.print && setTimeout(function(){{window.print()}}, 300);</script></body></html>"""
|
||||
|
||||
|
||||
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)),
|
||||
}
|
||||
|
||||
@@ -151,6 +151,115 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="mt-6 rounded-2xl border border-indigo-100 bg-indigo-50/50 p-4">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold uppercase tracking-wide text-indigo-900">Digital Acceptance Workflow</h4>
|
||||
<p class="mt-1 text-sm text-indigo-800">Declarations, KYC from permanent documents and engagement letter acceptance are captured as audit evidence.</p>
|
||||
</div>
|
||||
<span class="rounded-full px-3 py-1 text-xs font-semibold {% if workflow_ready %}bg-emerald-100 text-emerald-700{% else %}bg-amber-100 text-amber-800{% endif %}">{{ 'Ready for Approval' if workflow_ready else 'Pending Evidence' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-4 lg:grid-cols-3">
|
||||
<div class="rounded-xl border border-white bg-white p-4 shadow-sm">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h5 class="text-sm font-semibold text-slate-900">Independence / Conflict Declarations</h5>
|
||||
{% if can_manage_acceptance %}
|
||||
<form method="post" action="/clients/{{ row.id }}/acceptance/request-declarations">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-indigo-700">Request</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mt-3 space-y-2">
|
||||
{% if acceptance_declarations %}
|
||||
{% for d in acceptance_declarations %}
|
||||
<div class="rounded-lg border border-slate-200 p-3 text-xs">
|
||||
<div class="font-semibold text-slate-800">{{ d.declaration_type.replace('_',' ').title() }} · User #{{ d.assigned_user_id }}</div>
|
||||
<div class="mt-1 {% if d.status == 'declared_clear' %}text-emerald-700{% elif d.status == 'declared_issue' %}text-rose-700{% else %}text-amber-700{% endif %}">{{ d.status.replace('_',' ').title() }}</div>
|
||||
{% if d.assigned_user_id == current_user.id and d.status == 'pending' %}
|
||||
<form method="post" action="/clients/{{ row.id }}/acceptance/declarations/{{ d.id }}/submit" class="mt-2 space-y-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<select name="declaration_result" class="w-full rounded-lg border border-slate-300 px-2 py-1 text-xs">
|
||||
<option value="clear">Declare Clear / No Issue</option>
|
||||
<option value="issue">Declare Issue / Conflict</option>
|
||||
</select>
|
||||
<textarea name="response_notes" rows="2" placeholder="Notes" class="w-full rounded-lg border border-slate-300 px-2 py-1 text-xs"></textarea>
|
||||
<textarea name="issue_details" rows="2" placeholder="Issue details if any" class="w-full rounded-lg border border-slate-300 px-2 py-1 text-xs"></textarea>
|
||||
<button class="rounded-lg bg-slate-900 px-3 py-1.5 text-xs font-semibold text-white">Submit Declaration</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="text-xs text-slate-500">No declaration requests have been created yet.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-white bg-white p-4 shadow-sm">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h5 class="text-sm font-semibold text-slate-900">KYC from Permanent Documents</h5>
|
||||
{% if can_manage_acceptance %}
|
||||
<form method="post" action="/clients/{{ row.id }}/acceptance/kyc/sync">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">Sync</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mt-3 text-xs text-slate-600">Permanent documents found: <span class="font-semibold">{{ permanent_document_count }}</span></div>
|
||||
<div class="mt-2 rounded-lg bg-slate-50 p-3 text-xs text-slate-600 whitespace-pre-wrap max-h-40 overflow-auto">{{ permanent_document_summary or 'No permanent document summary available. Click Sync after client uploads permanent documents.' }}</div>
|
||||
{% if kyc_verification %}
|
||||
<div class="mt-3 text-xs font-semibold {% if kyc_verification.status == 'verified' %}text-emerald-700{% elif kyc_verification.status == 'rejected' %}text-rose-700{% else %}text-amber-700{% endif %}">KYC Status: {{ kyc_verification.status.replace('_',' ').title() }}</div>
|
||||
{% endif %}
|
||||
{% if can_manage_acceptance or can_approve_acceptance %}
|
||||
<div class="mt-3 grid gap-2">
|
||||
<form method="post" action="/clients/{{ row.id }}/acceptance/kyc/verify" class="space-y-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<textarea name="verification_notes" rows="2" placeholder="KYC verification notes" class="w-full rounded-lg border border-slate-300 px-2 py-1 text-xs"></textarea>
|
||||
<button class="w-full rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700">Verify KYC</button>
|
||||
</form>
|
||||
<form method="post" action="/clients/{{ row.id }}/acceptance/kyc/reject" class="space-y-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<textarea name="verification_notes" rows="2" placeholder="Resubmission remarks" class="w-full rounded-lg border border-slate-300 px-2 py-1 text-xs"></textarea>
|
||||
<button class="w-full rounded-lg border border-rose-300 px-3 py-1.5 text-xs font-semibold text-rose-700 hover:bg-rose-50">Reject / Request Resubmission</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-white bg-white p-4 shadow-sm">
|
||||
<h5 class="text-sm font-semibold text-slate-900">Engagement Letter</h5>
|
||||
{% if engagement_letter %}
|
||||
<div class="mt-2 text-xs text-slate-600">Version {{ engagement_letter.version_no }} · <span class="font-semibold">{{ engagement_letter.status.replace('_',' ').title() }}</span></div>
|
||||
<a href="/clients/{{ row.id }}/acceptance/engagement-letter/{{ engagement_letter.id }}/download" target="_blank" class="mt-3 inline-flex rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">View / Print PDF</a>
|
||||
{% if can_approve_acceptance and engagement_letter.status == 'draft' %}
|
||||
<form method="post" action="/clients/{{ row.id }}/acceptance/engagement-letter/{{ engagement_letter.id }}/approve-send" class="mt-3">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="w-full rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-indigo-700">Partner Approve & Send</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if (can_manage_acceptance or can_approve_acceptance) and engagement_letter.status == 'manual_uploaded' %}
|
||||
<form method="post" action="/clients/{{ row.id }}/acceptance/engagement-letter/{{ engagement_letter.id }}/verify-manual" class="mt-3">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="w-full rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700">Verify Manual Signed Copy</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="mt-2 text-xs text-slate-500">No engagement letter drafted yet.</div>
|
||||
{% endif %}
|
||||
{% if can_manage_acceptance %}
|
||||
<form method="post" action="/clients/{{ row.id }}/acceptance/engagement-letter/draft" class="mt-4 space-y-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input name="title" placeholder="Letter title" class="w-full rounded-lg border border-slate-300 px-2 py-1 text-xs" value="Engagement Letter - {{ row.client_name }}">
|
||||
<textarea name="body_text" rows="4" placeholder="Leave blank to use standard engagement letter draft" class="w-full rounded-lg border border-slate-300 px-2 py-1 text-xs"></textarea>
|
||||
<button class="w-full rounded-lg border border-indigo-300 px-3 py-1.5 text-xs font-semibold text-indigo-700 hover:bg-indigo-50">Create New Draft</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl bg-white p-6 shadow-soft">
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="mx-auto max-w-5xl space-y-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-slate-900">Engagement Letter</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Accept digitally with OTP/session evidence or upload a manually signed copy.</p>
|
||||
</div>
|
||||
<a href="/client/dashboard" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back to Dashboard</a>
|
||||
</div>
|
||||
|
||||
{% if not engagement_letter %}
|
||||
<section class="rounded-2xl border border-amber-200 bg-amber-50 p-6 text-sm text-amber-800">
|
||||
Engagement letter is not yet sent by the firm.
|
||||
</section>
|
||||
{% else %}
|
||||
<section class="rounded-2xl bg-white p-6 shadow-soft">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-slate-900">{{ engagement_letter.title }}</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">Version {{ engagement_letter.version_no }} · {{ engagement_letter.status.replace('_',' ').title() }}</p>
|
||||
</div>
|
||||
<a href="/clients/{{ client_row.id if client_row.id is defined else client_row['id'] }}/acceptance/engagement-letter/{{ engagement_letter.id }}/download" target="_blank" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">View / Download / Print</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-700 whitespace-pre-wrap max-h-96 overflow-auto">{{ engagement_letter.body_text }}</div>
|
||||
|
||||
{% if engagement_letter.status in ['sent_to_client', 'manual_uploaded', 'rejected'] %}
|
||||
<div class="mt-6 grid gap-4 lg:grid-cols-2">
|
||||
<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-4 space-y-3">
|
||||
<h4 class="text-sm font-semibold text-emerald-900">Accept Digitally with OTP</h4>
|
||||
<p class="text-xs text-emerald-800">Use this option when you accept the engagement letter electronically. The ERP stores OTP verification, user, time, IP/device and declaration evidence.</p>
|
||||
<form method="post" action="/client/engagement-letter/{{ engagement_letter.id }}/send-otp">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="w-full rounded-xl border border-emerald-300 bg-white px-4 py-2 text-sm font-semibold text-emerald-700 hover:bg-emerald-100">Send OTP</button>
|
||||
</form>
|
||||
<form method="post" action="/client/engagement-letter/{{ engagement_letter.id }}/accept-digital" class="space-y-3">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input name="otp" inputmode="numeric" required placeholder="Enter OTP" class="w-full rounded-xl border border-emerald-200 px-3 py-2 text-sm">
|
||||
<textarea name="declaration_text" rows="3" class="w-full rounded-xl border border-emerald-200 px-3 py-2 text-sm">I have read and accept this engagement letter for and on behalf of the client.</textarea>
|
||||
<button class="w-full rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700">Verify OTP & Accept Digitally</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<form method="post" enctype="multipart/form-data" action="/client/engagement-letter/{{ engagement_letter.id }}/upload-signed" class="rounded-xl border border-indigo-200 bg-indigo-50 p-4 space-y-3">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<h4 class="text-sm font-semibold text-indigo-900">Upload Manually Signed Copy</h4>
|
||||
<p class="text-xs text-indigo-800">Download/print the engagement letter, sign it manually and upload the signed PDF/image. The firm will verify it before marking it accepted.</p>
|
||||
<input type="file" name="signed_file" accept=".pdf,.jpg,.jpeg,.png" required class="w-full rounded-xl border border-indigo-200 bg-white px-3 py-2 text-sm">
|
||||
<button class="w-full rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700">Upload Signed Copy</button>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="mt-6 rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-700">
|
||||
Current status: {{ engagement_letter.status.replace('_',' ').title() }}.
|
||||
{% if engagement_letter.accepted_at_utc %}Accepted at: {{ engagement_letter.accepted_at_utc }}{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user