Files
arrr-erp/app/modules/clients/service.py
T

591 lines
21 KiB
Python

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