from __future__ import annotations import csv import io from datetime import datetime, timezone from fastapi import HTTPException from app.modules.clients import repository 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.constants import ( CLIENT_CATEGORY_OPTIONS, CLIENT_STATUS, CLIENT_TYPES, RISK_CATEGORIES, CLIENT_ACCEPTANCE_APPROVAL_REQUIRED_RISKS, ) def _payload_from_schema(data): return data.model_dump(exclude_none=True) if hasattr(data, "model_dump") else data.dict(exclude_none=True) 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) _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) _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