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,
CLIENT_TYPES,
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
from app.modules.consultants.service import sync_primary_client_consultant_link
def _payload_from_schema(data):
payload = data.model_dump(exclude_none=True) if hasattr(data, "model_dump") else data.dict(exclude_none=True)
# primary_consultant_id belongs to ClientConsultantLink, not the clients table.
payload.pop("primary_consultant_id", None)
return payload
def _is_high_risk(risk_category: str | None) -> bool:
return (risk_category or "").strip().lower() in CLIENT_ACCEPTANCE_APPROVAL_REQUIRED_RISKS
def _client_acceptance_ready(payload: dict) -> bool:
if not payload.get("acceptance_required", True):
return True
if not payload.get("independence_check_completed"):
return False
if not payload.get("conflict_check_completed"):
return False
if not payload.get("kyc_completed"):
return False
if payload.get("engagement_letter_required", True) and not payload.get("engagement_letter_received"):
return False
return True
def _enforce_client_acceptance_controls(payload: dict, *, existing_row=None):
risk = payload.get("risk_category")
if risk is None and existing_row is not None:
risk = getattr(existing_row, "risk_category", None)
status = payload.get("status")
if status is None and existing_row is not None:
status = getattr(existing_row, "status", None)
acceptance_status = payload.get("acceptance_status")
if acceptance_status is None and existing_row is not None:
acceptance_status = getattr(existing_row, "acceptance_status", "pending_review")
acceptance_status = acceptance_status or "pending_review"
if _is_high_risk(risk) and status == "active" and acceptance_status != "approved":
raise HTTPException(
status_code=400,
detail="High/Critical risk clients cannot be active until client acceptance is approved by an authorised partner or firm admin.",
)
if acceptance_status == "approved" and not _client_acceptance_ready(payload):
raise HTTPException(
status_code=400,
detail="Client acceptance cannot be approved until independence, conflict, KYC and required engagement letter controls are completed.",
)
return payload
def _ensure_portal_passwords(email: str | None, portal_password: str | None, portal_password_confirm: str | None, *, required: bool):
email_clean = (email or '').strip().lower()
pw = (portal_password or '').strip()
pw2 = (portal_password_confirm or '').strip()
if not required and not pw and not pw2:
return email_clean, None
if not email_clean:
raise HTTPException(status_code=400, detail="Email is required to create the client frontend login.")
if len(pw) < 8:
raise HTTPException(status_code=400, detail="Portal password must be at least 8 characters.")
if pw != pw2:
raise HTTPException(status_code=400, detail="Portal password and confirm password do not match.")
return email_clean, pw
def _sync_client_portal_user(db, *, row, portal_password: str | None = None, portal_password_confirm: str | None = None):
email_clean, pw = _ensure_portal_passwords(
getattr(row, 'email', None),
portal_password,
portal_password_confirm,
required=bool(portal_password or portal_password_confirm or not getattr(row, 'portal_user_id', None)),
)
if not pw:
return row
existing_user = repository.get_user_by_email(db, email=email_clean, exclude_user_id=getattr(row, 'portal_user_id', None))
if existing_user:
raise HTTPException(status_code=400, detail="That email is already used by another login.")
if row.portal_user_id:
user = db.get(repository.User, int(row.portal_user_id))
if not user:
row = repository.update_client(db, row, {'portal_user_id': None})
else:
user.email = email_clean
user.full_name = (row.client_name or '').strip()
user.password_hash = hash_password(pw)
user.tenant_id = row.tenant_id
user.branch_id = row.branch_id
user.is_active = True
user.allow_login = True
user.is_locked = False
user.must_change_password = False
db.add(user)
db.commit()
db.refresh(user)
return row
if not row.portal_user_id:
user = repository.create_portal_user(
db,
email=email_clean,
full_name=row.client_name,
tenant_id=row.tenant_id,
branch_id=row.branch_id,
password=pw,
)
role = repository.get_role_by_name(db, 'Client')
if not role:
raise HTTPException(status_code=500, detail='Client role is not available.')
repository.ensure_user_role(db, user_id=user.id, role_id=role.id)
row = repository.update_client(db, row, {'portal_user_id': user.id})
return row
def _validate_scope_for_create(data, scope, actor_user_id):
if scope.own_only:
data.partner_id = scope.locked_partner_id or actor_user_id
if not scope.allow_cross_tenant:
data.tenant_id = scope.tenant_id
if not scope.allow_cross_branch and scope.branch_id:
data.branch_id = scope.branch_id
return data
def _validate_scope_for_edit(data, scope, actor_user_id, *, existing_row, current_user_roles):
role_names = {str(r).lower() for r in current_user_roles}
if data.tenant_id is None:
data.tenant_id = existing_row.tenant_id
if data.branch_id is None:
data.branch_id = existing_row.branch_id
if data.partner_id is None:
data.partner_id = existing_row.partner_id
if "partner" in role_names and data.partner_id and data.partner_id != actor_user_id:
raise HTTPException(status_code=400, detail="Partner users cannot assign clients to another partner.")
if "consultant" in role_names and data.partner_id and data.partner_id != existing_row.partner_id:
raise HTTPException(status_code=400, detail="Consultants cannot assign or change partner mapping.")
if "firm admin" in role_names:
if existing_row.tenant_id != scope.tenant_id:
raise HTTPException(status_code=403, detail="Firm Admin can only manage clients within own firm.")
data.tenant_id = scope.tenant_id
if not scope.allow_cross_branch and data.branch_id != scope.branch_id:
raise HTTPException(status_code=403, detail="Branch change is not allowed in current scope.")
return data
if "system admin" in role_names:
return data
if scope.own_only:
data.partner_id = scope.locked_partner_id or actor_user_id
if existing_row.partner_id != actor_user_id:
raise HTTPException(status_code=403, detail="You can only edit your own associated clients.")
return data
def _write_association_from_client(db, client_row, *, actor_user_id, current_user_roles):
roles = {str(r).lower() for r in current_user_roles}
ensure_active_association(db, client_row.id)
if "system admin" in roles:
return update_association_fields(
db,
client_row.id,
association_type="firm" if client_row.tenant_id else "self_service_unassigned",
firm_tenant_id=client_row.tenant_id,
partner_user_id=client_row.partner_id,
created_source="system_admin",
)
if "firm admin" in roles:
return update_association_fields(
db,
client_row.id,
association_type="firm",
firm_tenant_id=client_row.tenant_id,
partner_user_id=client_row.partner_id,
created_source="firm_admin",
)
if "partner" in roles:
return update_association_fields(
db,
client_row.id,
association_type="firm",
firm_tenant_id=client_row.tenant_id,
partner_user_id=actor_user_id,
created_source="partner",
)
if "consultant" in roles:
return update_association_fields(
db,
client_row.id,
association_type="consultant",
consultant_id=actor_user_id,
created_source="consultant",
)
return update_association_fields(
db,
client_row.id,
association_type="self_service_unassigned",
created_source="self_service",
)
def create_client_service(db, *, data, actor_user_id: int, scope, current_user_roles=None, portal_password: str | None = None, portal_password_confirm: str | None = None):
current_user_roles = current_user_roles or []
data = _validate_scope_for_create(data, scope, actor_user_id)
existing = repository.get_client_by_code(db, tenant_id=data.tenant_id, client_code=data.client_code)
if existing:
raise HTTPException(status_code=400, detail="Client code already exists.")
if data.pan:
existing_pan = repository.get_client_by_pan(db, tenant_id=data.tenant_id, pan=data.pan)
if existing_pan:
raise HTTPException(status_code=400, detail="PAN already exists for another client in this tenant.")
if data.gstin:
existing_gstin = repository.get_client_by_gstin(db, tenant_id=data.tenant_id, gstin=data.gstin)
if existing_gstin:
raise HTTPException(status_code=400, detail="GSTIN already exists for another client in this tenant.")
payload = _payload_from_schema(data)
_enforce_client_acceptance_controls(payload)
row = repository.create_client(db, payload)
row = _sync_client_portal_user(db, row=row, portal_password=portal_password, portal_password_confirm=portal_password_confirm)
sync_primary_client_consultant_link(db, client=row, consultant_id=getattr(data, "primary_consultant_id", None), actor_user_id=actor_user_id)
_write_association_from_client(db, row, actor_user_id=actor_user_id, current_user_roles=current_user_roles)
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="created",
summary="Client created with association sync.",
payload_json={"client_id": row.id, "partner_id": row.partner_id},
)
return row
def update_client_service(db, *, row, data, actor_user_id: int, scope, current_user_roles=None, portal_password: str | None = None, portal_password_confirm: str | None = None):
current_user_roles = current_user_roles or []
data = _validate_scope_for_edit(
data,
scope,
actor_user_id,
existing_row=row,
current_user_roles=current_user_roles,
)
payload = _payload_from_schema(data)
_enforce_client_acceptance_controls(payload, existing_row=row)
if payload.get("pan"):
existing_pan = repository.get_client_by_pan(db, tenant_id=payload["tenant_id"], pan=payload["pan"])
if existing_pan and existing_pan.id != row.id:
raise HTTPException(status_code=400, detail="PAN already exists for another client in this tenant.")
if payload.get("gstin"):
existing_gstin = repository.get_client_by_gstin(db, tenant_id=payload["tenant_id"], gstin=payload["gstin"])
if existing_gstin and existing_gstin.id != row.id:
raise HTTPException(status_code=400, detail="GSTIN already exists for another client in this tenant.")
updated = repository.update_client(db, row, payload)
updated = _sync_client_portal_user(db, row=updated, portal_password=portal_password, portal_password_confirm=portal_password_confirm)
sync_primary_client_consultant_link(db, client=updated, consultant_id=getattr(data, "primary_consultant_id", None), actor_user_id=actor_user_id)
_write_association_from_client(db, updated, actor_user_id=actor_user_id, current_user_roles=current_user_roles)
repository.write_audit_log(
db,
client_id=updated.id,
tenant_id=updated.tenant_id,
branch_id=updated.branch_id,
actor_user_id=actor_user_id,
action="updated",
summary="Client updated with association sync.",
payload_json={"client_id": updated.id, "partner_id": updated.partner_id},
)
return updated
def get_client_or_404(
db,
*,
client_id: int,
tenant_id: int,
branch_id: int | None,
allow_cross_branch: bool,
allow_all_clients: bool = False,
):
row = repository.get_client_by_id(db, client_id)
if not row:
raise HTTPException(status_code=404, detail="Client not found.")
if not allow_all_clients and row.tenant_id != tenant_id:
raise HTTPException(status_code=404, detail="Client not found in current tenant.")
if not allow_all_clients and not allow_cross_branch and branch_id and row.branch_id != branch_id:
raise HTTPException(status_code=404, detail="Client not found in current branch.")
return row
def list_clients_payload(db, **kwargs):
return repository.list_clients(db, **kwargs)
def list_client_audit_logs(db, *, row, limit: int = 50):
return repository.list_audit_logs(db, client_id=row.id, limit=limit)
def deactivate_client_service(db, *, row, actor_user_id: int):
row = repository.update_client(db, row, {"status": "inactive"})
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="deactivated",
summary="Client deactivated.",
payload_json=None,
)
return row
def activate_client_service(db, *, row, actor_user_id: int):
payload = {
"status": "active",
"risk_category": getattr(row, "risk_category", None),
"acceptance_status": getattr(row, "acceptance_status", "pending_review"),
"acceptance_required": getattr(row, "acceptance_required", True),
"independence_check_completed": getattr(row, "independence_check_completed", False),
"conflict_check_completed": getattr(row, "conflict_check_completed", False),
"kyc_completed": getattr(row, "kyc_completed", False),
"engagement_letter_required": getattr(row, "engagement_letter_required", True),
"engagement_letter_received": getattr(row, "engagement_letter_received", False),
}
_enforce_client_acceptance_controls(payload, existing_row=row)
row = repository.update_client(db, row, {"status": "active"})
repository.write_audit_log(
db,
client_id=row.id,
tenant_id=row.tenant_id,
branch_id=row.branch_id,
actor_user_id=actor_user_id,
action="activated",
summary="Client activated.",
payload_json=None,
)
return row
def archive_client_service(db, *, row, actor_user_id: int):
row = repository.update_client(db, row, {"status": "archived", "is_archived": 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="archived",
summary="Client archived.",
payload_json=None,
)
return row
def restore_client_service(db, *, row, actor_user_id: int):
row = repository.update_client(db, row, {"status": "active", "is_archived": False})
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="restored",
summary="Client restored from archive.",
payload_json=None,
)
return row
def export_clients_csv(payload: dict) -> str:
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(
[
"client_code",
"client_name",
"client_type",
"status",
"pan",
"gstin",
"partner",
"association_type",
"association_source",
]
)
for row in payload.get("rows", []):
writer.writerow(
[
row.get("client_code"),
row.get("client_name"),
row.get("client_type"),
row.get("status"),
row.get("pan"),
row.get("gstin"),
row.get("partner_name") or row.get("effective_partner_id"),
row.get("association_type"),
row.get("assoc_created_source"),
]
)
return output.getvalue()
def get_filter_options():
return {
"client_types": CLIENT_TYPES,
"client_statuses": CLIENT_STATUS,
"client_categories": CLIENT_CATEGORY_OPTIONS,
"risk_categories": RISK_CATEGORIES,
}
SELF_SERVICE_EDITABLE_FIELDS = {
"client_name",
"trade_name",
"contact_person_name",
"contact_person_designation",
"mobile",
"alternate_mobile",
"email",
"alternate_email",
"address_line_1",
"address_line_2",
"city",
"state",
"pincode",
"country",
"notes",
}
def update_client_self_profile_service(db, *, row, data, current_user):
payload = _payload_from_schema(data)
payload = {key: value for key, value in payload.items() if key in SELF_SERVICE_EDITABLE_FIELDS}
new_email = (payload.get("email") or "").strip().lower()
if new_email:
existing_user = repository.get_user_by_email(db, email=new_email, exclude_user_id=int(current_user.id))
if existing_user:
raise HTTPException(status_code=400, detail="That email is already used by another login.")
updated = repository.update_client(db, row, payload)
if new_email and new_email != (getattr(current_user, "email", "") or "").strip().lower():
current_user.email = new_email
db.add(current_user)
db.commit()
db.refresh(current_user)
repository.write_audit_log(
db,
client_id=updated.id,
tenant_id=updated.tenant_id,
branch_id=updated.branch_id,
actor_user_id=current_user.id,
action="client_self_profile_updated",
summary="Client updated own contact profile.",
payload_json={"fields": sorted(payload.keys())},
)
return updated
def reset_client_portal_password_service(db, *, current_user, new_password: str):
if len((new_password or "").strip()) < 8:
raise HTTPException(status_code=400, detail="New password must be at least 8 characters.")
current_user.password_hash = hash_password(new_password.strip())
current_user.must_change_password = False
db.add(current_user)
db.commit()
db.refresh(current_user)
return current_user
def approve_client_acceptance_service(db, *, row, actor_user_id: int, review_notes: str | None = None):
payload = {
"acceptance_status": "approved",
"acceptance_required": getattr(row, "acceptance_required", True),
"independence_check_completed": getattr(row, "independence_check_completed", False),
"conflict_check_completed": getattr(row, "conflict_check_completed", False),
"kyc_completed": getattr(row, "kyc_completed", False),
"engagement_letter_required": getattr(row, "engagement_letter_required", True),
"engagement_letter_received": getattr(row, "engagement_letter_received", False),
"risk_category": getattr(row, "risk_category", None),
"status": getattr(row, "status", None),
}
_enforce_client_acceptance_controls(payload, existing_row=row)
now = datetime.now(timezone.utc)
updated = repository.update_client(db, row, {
"acceptance_status": "approved",
"acceptance_approved_by_user_id": actor_user_id,
"acceptance_approved_at_utc": now,
"acceptance_review_notes": review_notes or getattr(row, "acceptance_review_notes", None),
"acceptance_rejection_reason": None,
})
repository.write_audit_log(
db,
client_id=updated.id,
tenant_id=updated.tenant_id,
branch_id=updated.branch_id,
actor_user_id=actor_user_id,
action="acceptance_approved",
summary="Client acceptance approved.",
payload_json={"review_notes": review_notes},
)
return updated
def reject_client_acceptance_service(db, *, row, actor_user_id: int, rejection_reason: str | None = None):
updated = repository.update_client(db, row, {
"acceptance_status": "rejected",
"acceptance_approved_by_user_id": None,
"acceptance_approved_at_utc": None,
"acceptance_rejection_reason": rejection_reason,
})
repository.write_audit_log(
db,
client_id=updated.id,
tenant_id=updated.tenant_id,
branch_id=updated.branch_id,
actor_user_id=actor_user_id,
action="acceptance_rejected",
summary="Client acceptance rejected.",
payload_json={"rejection_reason": rejection_reason},
)
return updated
def mark_client_acceptance_pending_service(db, *, row, actor_user_id: int, review_notes: str | None = None):
updated = repository.update_client(db, row, {
"acceptance_status": "pending_review",
"acceptance_approved_by_user_id": None,
"acceptance_approved_at_utc": None,
"acceptance_review_notes": review_notes or getattr(row, "acceptance_review_notes", None),
"acceptance_rejection_reason": None,
})
repository.write_audit_log(
db,
client_id=updated.id,
tenant_id=updated.tenant_id,
branch_id=updated.branch_id,
actor_user_id=actor_user_id,
action="acceptance_pending_review",
summary="Client acceptance moved to pending review.",
payload_json={"review_notes": review_notes},
)
return updated
# ---------------------------------------------------------------------------
# Phase 2.1 - digital client acceptance workflow
# ---------------------------------------------------------------------------
DECLARATION_TYPES = {"independence", "conflict"}
DECLARATION_CLEAR_STATUSES = {"declared_clear"}
ENGAGEMENT_LETTER_ACCEPTED_STATUSES = {"digitally_accepted", "manual_verified"}
KYC_REQUIRED_KEYWORDS = ["PAN", "GST", "Incorporation", "Partnership Deed", "Address Proof", "Authorisation"]
ACCEPTANCE_STORAGE_ROOT = Path(os.getenv("CLIENT_ACCEPTANCE_STORAGE_ROOT", "documents/client_acceptance")).resolve()
def _clean_text(value: str | None, max_len: int | None = None) -> str | None:
value = (value or "").strip()
if not value:
return None
return value[:max_len] if max_len else value
def _slug(value: str | None) -> str:
value = re.sub(r"[^A-Za-z0-9._-]+", "_", (value or "file").strip())
return value.strip("._-")[:80] or "file"
def _request_ip_and_agent(request):
ip = None
try:
ip = request.headers.get("x-forwarded-for", "").split(",")[0].strip() or request.client.host
except Exception:
ip = None
try:
agent = request.headers.get("user-agent")
except Exception:
agent = None
return ip, agent
def _declaration_text(client, declaration_type: str) -> str:
if declaration_type == "conflict":
return (
f"I confirm that I have reviewed possible conflict of interest for {client.client_name}. "
"I do not have any financial, business, family, employment, litigation or other relationship "
"that creates a conflict, except as specifically disclosed in my remarks."
)
return (
f"I confirm that I am independent for the proposed/continuing engagement of {client.client_name}. "
"I will immediately report any independence threat or safeguard requirement to the engagement partner."
)
def _role_name_for_user(db, user_id: int) -> str:
names = db.execute(
select(Role.name)
.join(UserRole, UserRole.role_id == Role.id)
.where(UserRole.user_id == user_id)
.order_by(Role.name.asc())
).scalars().all()
return ",".join(names) if names else ""
def _applicable_acceptance_user_ids(db, client) -> list[int]:
ids = []
for uid in [getattr(client, "partner_id", None), getattr(client, "default_review_partner_user_id", None), getattr(client, "created_by_user_id", None)]:
if uid and uid not in ids:
ids.append(int(uid))
if not ids and getattr(client, "updated_by_user_id", None):
ids.append(int(client.updated_by_user_id))
return ids
def _safe_create_alert(db, *, user_id: int, title: str, message: str, client, actor_user_id: int | None = None, target_url: str | None = None):
try:
create_alert(
db,
user_id=user_id,
tenant_id=client.tenant_id,
branch_id=client.branch_id,
role_context="client_acceptance",
alert_type="client",
priority="high",
title=title,
message=message,
target_url=target_url or f"/clients/{client.id}",
created_by_user_id=actor_user_id,
commit=False,
)
except Exception:
pass
def list_client_acceptance_declarations(db, *, client_id: int):
return db.execute(
select(ClientAcceptanceDeclaration)
.where(ClientAcceptanceDeclaration.client_id == client_id)
.order_by(ClientAcceptanceDeclaration.declaration_type.asc(), ClientAcceptanceDeclaration.assigned_user_id.asc())
).scalars().all()
def list_my_pending_acceptance_declarations(db, *, user_id: int, limit: int = 25):
return db.execute(
select(ClientAcceptanceDeclaration)
.where(ClientAcceptanceDeclaration.assigned_user_id == user_id, ClientAcceptanceDeclaration.status == "pending")
.order_by(ClientAcceptanceDeclaration.requested_at_utc.desc())
.limit(limit)
).scalars().all()
def request_client_acceptance_declarations_service(db, *, row, actor_user_id: int):
assigned_ids = _applicable_acceptance_user_ids(db, row)
if not assigned_ids:
raise HTTPException(status_code=400, detail="No applicable partner/review user is mapped to this client.")
created = 0
for uid in assigned_ids:
for dtype in sorted(DECLARATION_TYPES):
existing = db.execute(
select(ClientAcceptanceDeclaration).where(
ClientAcceptanceDeclaration.client_id == row.id,
ClientAcceptanceDeclaration.declaration_type == dtype,
ClientAcceptanceDeclaration.assigned_user_id == uid,
)
).scalar_one_or_none()
if not existing:
db.add(ClientAcceptanceDeclaration(
client_id=row.id,
tenant_id=row.tenant_id,
branch_id=row.branch_id,
declaration_type=dtype,
assigned_user_id=uid,
assigned_role_snapshot=_role_name_for_user(db, uid),
declaration_text=_declaration_text(row, dtype),
requested_by_user_id=actor_user_id,
))
created += 1
_safe_create_alert(
db,
user_id=uid,
title=f"{dtype.title()} declaration required",
message=f"Please submit your {dtype} declaration for {row.client_name}.",
client=row,
actor_user_id=actor_user_id,
)
repository.write_audit_log(
db,
client_id=row.id,
tenant_id=row.tenant_id,
branch_id=row.branch_id,
actor_user_id=actor_user_id,
action="acceptance_declarations_requested",
summary="Digital independence/conflict declarations requested.",
payload_json={"created": created, "assigned_user_ids": assigned_ids},
)
db.commit()
return list_client_acceptance_declarations(db, client_id=row.id)
def submit_client_acceptance_declaration_service(db, *, declaration_id: int, current_user, clear: bool, notes: str | None, issue_details: str | None, request=None):
decl = db.get(ClientAcceptanceDeclaration, declaration_id)
if not decl:
raise HTTPException(status_code=404, detail="Declaration not found.")
if decl.assigned_user_id != current_user.id:
raise HTTPException(status_code=403, detail="This declaration is assigned to another user.")
ip, agent = _request_ip_and_agent(request)
decl.status = "declared_clear" if clear else "declared_issue"
decl.response_notes = _clean_text(notes)
decl.issue_details = _clean_text(issue_details)
decl.responded_at_utc = datetime.now(timezone.utc)
decl.response_ip = ip
decl.response_user_agent = agent
db.add(decl)
db.flush()
_refresh_acceptance_derived_statuses(db, client_id=decl.client_id, actor_user_id=current_user.id)
db.commit()
return decl
def get_client_kyc_verification(db, *, client_id: int) -> ClientKycVerification | None:
return db.execute(select(ClientKycVerification).where(ClientKycVerification.client_id == client_id)).scalar_one_or_none()
def _permanent_docs_summary(db, *, client_id: int) -> tuple[str, int]:
docs = db.execute(
select(PermanentClientDocument)
.where(PermanentClientDocument.client_id == client_id, PermanentClientDocument.is_deleted.is_(False))
.order_by(PermanentClientDocument.category.asc(), PermanentClientDocument.title.asc())
).scalars().all()
parts = []
for d in docs:
parts.append(f"{d.category}: {d.title} ({d.current_version_no} version(s))")
return "\n".join(parts), len(docs)
def sync_client_kyc_from_permanent_documents_service(db, *, row, actor_user_id: int):
summary, count = _permanent_docs_summary(db, client_id=row.id)
kyc = get_client_kyc_verification(db, client_id=row.id)
status = "pending_verification" if count else "pending_documents"
required_summary = "\n".join(f"- {x}" for x in KYC_REQUIRED_KEYWORDS)
if not kyc:
kyc = ClientKycVerification(client_id=row.id, tenant_id=row.tenant_id, branch_id=row.branch_id)
if kyc.status != "verified":
kyc.status = status
kyc.required_document_summary = required_summary
kyc.available_document_summary = summary or "No permanent client documents uploaded yet."
db.add(kyc)
repository.write_audit_log(
db,
client_id=row.id,
tenant_id=row.tenant_id,
branch_id=row.branch_id,
actor_user_id=actor_user_id,
action="kyc_synced_from_permanent_documents",
summary="KYC status refreshed from permanent client documents.",
payload_json={"permanent_document_count": count, "status": kyc.status},
)
db.commit()
return kyc
def verify_client_kyc_service(db, *, row, actor_user_id: int, notes: str | None = None):
kyc = sync_client_kyc_from_permanent_documents_service(db, row=row, actor_user_id=actor_user_id)
if not kyc.available_document_summary or kyc.available_document_summary.startswith("No permanent"):
raise HTTPException(status_code=400, detail="KYC cannot be verified until permanent client documents are uploaded.")
now = datetime.now(timezone.utc)
kyc.status = "verified"
kyc.verified_by_user_id = actor_user_id
kyc.verified_at_utc = now
kyc.rejected_by_user_id = None
kyc.rejected_at_utc = None
kyc.verification_notes = _clean_text(notes)
db.add(kyc)
updated = repository.update_client(db, row, {"kyc_completed": True})
repository.write_audit_log(db, client_id=row.id, tenant_id=row.tenant_id, branch_id=row.branch_id, actor_user_id=actor_user_id, action="kyc_verified", summary="KYC verified using permanent client documents.", payload_json={"notes": notes})
db.commit()
return updated, kyc
def reject_client_kyc_service(db, *, row, actor_user_id: int, notes: str | None = None):
kyc = get_client_kyc_verification(db, client_id=row.id) or ClientKycVerification(client_id=row.id, tenant_id=row.tenant_id, branch_id=row.branch_id)
now = datetime.now(timezone.utc)
kyc.status = "rejected"
kyc.rejected_by_user_id = actor_user_id
kyc.rejected_at_utc = now
kyc.verification_notes = _clean_text(notes)
db.add(kyc)
updated = repository.update_client(db, row, {"kyc_completed": False})
_safe_create_alert(db, user_id=row.portal_user_id, title="KYC documents require resubmission", message=notes or "Please update the permanent documents requested by the firm.", client=row, actor_user_id=actor_user_id, target_url="/client/documents") if getattr(row, "portal_user_id", None) else None
repository.write_audit_log(db, client_id=row.id, tenant_id=row.tenant_id, branch_id=row.branch_id, actor_user_id=actor_user_id, action="kyc_rejected", summary="KYC rejected / resubmission requested.", payload_json={"notes": notes})
db.commit()
return updated, kyc
def get_current_engagement_letter(db, *, client_id: int) -> ClientEngagementLetter | None:
return db.execute(
select(ClientEngagementLetter)
.where(ClientEngagementLetter.client_id == client_id)
.order_by(ClientEngagementLetter.version_no.desc(), ClientEngagementLetter.id.desc())
).scalars().first()
def _default_engagement_letter_body(row) -> str:
return f"""Dear {row.client_name},
We are pleased to confirm our understanding of the terms and objectives of our professional engagement. The services will be performed subject to applicable laws, professional standards, ICAI requirements, management responsibilities, timely submission of records and payment of agreed fees.
Management is responsible for the completeness and accuracy of records, explanations and representations provided to the firm. Our responsibility is limited to the scope agreed for the relevant service/financial year. This engagement letter, once accepted digitally or by signed upload, will form part of the client acceptance and continuance evidence.
Regards,
A R R R & Associates"""
def draft_client_engagement_letter_service(db, *, row, actor_user_id: int, title: str | None = None, body_text: str | None = None):
latest = get_current_engagement_letter(db, client_id=row.id)
version = (latest.version_no + 1) if latest else 1
letter = ClientEngagementLetter(
client_id=row.id,
tenant_id=row.tenant_id,
branch_id=row.branch_id,
letter_code=f"ENG-{row.id}",
title=_clean_text(title, 255) or f"Engagement Letter - {row.client_name}",
body_text=_clean_text(body_text) or _default_engagement_letter_body(row),
version_no=version,
status="draft",
created_by_user_id=actor_user_id,
)
db.add(letter)
repository.write_audit_log(db, client_id=row.id, tenant_id=row.tenant_id, branch_id=row.branch_id, actor_user_id=actor_user_id, action="engagement_letter_drafted", summary="Engagement letter draft created.", payload_json={"version_no": version})
db.commit()
db.refresh(letter)
return letter
def approve_and_send_engagement_letter_service(db, *, row, letter_id: int, actor_user_id: int):
letter = db.get(ClientEngagementLetter, letter_id)
if not letter or letter.client_id != row.id:
raise HTTPException(status_code=404, detail="Engagement letter not found.")
now = datetime.now(timezone.utc)
letter.status = "sent_to_client"
letter.approved_by_user_id = actor_user_id
letter.approved_at_utc = now
letter.sent_to_client_at_utc = now
letter.pdf_hash_sha256 = hashlib.sha256(letter.body_text.encode("utf-8")).hexdigest()
db.add(letter)
if getattr(row, "portal_user_id", None):
_safe_create_alert(db, user_id=row.portal_user_id, title="Engagement letter ready for acceptance", message=f"Please accept the engagement letter for {row.client_name} digitally using OTP or upload the signed copy.", client=row, actor_user_id=actor_user_id, target_url="/client/engagement-letter")
repository.write_audit_log(db, client_id=row.id, tenant_id=row.tenant_id, branch_id=row.branch_id, actor_user_id=actor_user_id, action="engagement_letter_sent", summary="Engagement letter approved and sent to client.", payload_json={"letter_id": letter.id, "version_no": letter.version_no})
db.commit()
return letter
def render_engagement_letter_html(letter: ClientEngagementLetter, client) -> str:
import html
body = html.escape(letter.body_text).replace("\n", "
")
return f"""
{body}