diff --git a/alembic/versions/20260805_scope_aware_client_services.py b/alembic/versions/20260805_scope_aware_client_services.py
new file mode 100644
index 0000000..6060e94
--- /dev/null
+++ b/alembic/versions/20260805_scope_aware_client_services.py
@@ -0,0 +1,139 @@
+"""Scope-aware client services using the existing registrations module."""
+
+from alembic import op
+import sqlalchemy as sa
+
+revision = "20260805_scope_aware_services"
+down_revision = "20260805_client_service_plan_master"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.create_table(
+ "client_business_units",
+ sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
+ sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("business_code", sa.String(50), nullable=False),
+ sa.Column("business_name", sa.String(200), nullable=False),
+ sa.Column("trade_name", sa.String(200), nullable=True),
+ sa.Column("nature_of_business", sa.String(200), nullable=True),
+ sa.Column("is_primary", sa.Boolean(), nullable=False, server_default=sa.false()),
+ sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
+ sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
+ sa.Column("updated_by_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
+ sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
+ sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
+ sa.UniqueConstraint("tenant_id", "client_id", "business_code", name="uq_cbu_tenant_client_code"),
+ )
+ op.create_index("ix_client_business_units_tenant_id", "client_business_units", ["tenant_id"])
+ op.create_index("ix_client_business_units_client_id", "client_business_units", ["client_id"])
+
+ op.create_table(
+ "client_branches",
+ sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
+ sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("business_unit_id", sa.Integer(), sa.ForeignKey("client_business_units.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("branch_code", sa.String(50), nullable=False),
+ sa.Column("branch_name", sa.String(200), nullable=False),
+ sa.Column("branch_type", sa.String(40), nullable=False, server_default="branch"),
+ sa.Column("address_line_1", sa.String(255), nullable=True),
+ sa.Column("address_line_2", sa.String(255), nullable=True),
+ sa.Column("city", sa.String(100), nullable=True),
+ sa.Column("state", sa.String(100), nullable=True),
+ sa.Column("pincode", sa.String(20), nullable=True),
+ sa.Column("is_primary", sa.Boolean(), nullable=False, server_default=sa.false()),
+ sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
+ sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
+ sa.Column("updated_by_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
+ sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
+ sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
+ sa.UniqueConstraint("tenant_id", "business_unit_id", "branch_code", name="uq_cbranch_tenant_business_code"),
+ )
+ op.create_index("ix_client_branches_tenant_id", "client_branches", ["tenant_id"])
+ op.create_index("ix_client_branches_client_id", "client_branches", ["client_id"])
+ op.create_index("ix_client_branches_business_unit_id", "client_branches", ["business_unit_id"])
+
+ # Reuse and extend the production client_registrations table introduced in Phase 4.
+ with op.batch_alter_table("client_registrations") as batch:
+ batch.add_column(sa.Column("business_unit_id", sa.Integer(), nullable=True))
+ batch.add_column(sa.Column("client_branch_id", sa.Integer(), nullable=True))
+ batch.add_column(sa.Column("legal_name", sa.String(200), nullable=True))
+ batch.add_column(sa.Column("trade_name", sa.String(200), nullable=True))
+ batch.add_column(sa.Column("state", sa.String(100), nullable=True))
+ batch.create_foreign_key(
+ "fk_client_registrations_business_unit",
+ "client_business_units", ["business_unit_id"], ["id"], ondelete="SET NULL"
+ )
+ batch.create_foreign_key(
+ "fk_client_registrations_client_branch",
+ "client_branches", ["client_branch_id"], ["id"], ondelete="SET NULL"
+ )
+ batch.create_index("ix_client_registrations_business_unit_id", ["business_unit_id"])
+ batch.create_index("ix_client_registrations_client_branch_id", ["client_branch_id"])
+
+ with op.batch_alter_table("service_catalogues") as batch:
+ batch.add_column(sa.Column("service_scope_type", sa.String(30), nullable=False, server_default="client"))
+ batch.add_column(sa.Column("required_registration_type", sa.String(40), nullable=True))
+ batch.create_index("ix_service_catalogues_service_scope_type", ["service_scope_type"])
+ batch.create_index("ix_service_catalogues_required_registration_type", ["required_registration_type"])
+
+ for table in ("client_service_plans", "client_service_subscriptions"):
+ with op.batch_alter_table(table) as batch:
+ batch.add_column(sa.Column("scope_type", sa.String(30), nullable=False, server_default="client"))
+ batch.add_column(sa.Column("scope_key", sa.String(80), nullable=False, server_default=""))
+ batch.add_column(sa.Column("business_unit_id", sa.Integer(), nullable=True))
+ batch.add_column(sa.Column("client_branch_id", sa.Integer(), nullable=True))
+ batch.add_column(sa.Column("registration_id", sa.Integer(), nullable=True))
+ batch.create_foreign_key(f"fk_{table}_business_unit", "client_business_units", ["business_unit_id"], ["id"], ondelete="SET NULL")
+ batch.create_foreign_key(f"fk_{table}_client_branch", "client_branches", ["client_branch_id"], ["id"], ondelete="SET NULL")
+ batch.create_foreign_key(f"fk_{table}_registration", "client_registrations", ["registration_id"], ["id"], ondelete="SET NULL")
+ batch.create_index(f"ix_{table}_scope_type", ["scope_type"])
+ batch.create_index(f"ix_{table}_scope_key", ["scope_key"])
+ batch.create_index(f"ix_{table}_business_unit_id", ["business_unit_id"])
+ batch.create_index(f"ix_{table}_client_branch_id", ["client_branch_id"])
+ batch.create_index(f"ix_{table}_registration_id", ["registration_id"])
+
+ op.execute("UPDATE client_service_plans SET scope_key = 'CLIENT:' || client_id WHERE scope_key = ''")
+ op.execute("UPDATE client_service_subscriptions SET scope_key = 'CLIENT:' || client_id WHERE scope_key = ''")
+
+ with op.batch_alter_table("client_service_plans") as batch:
+ batch.drop_constraint("uq_csp_tenant_client_service", type_="unique")
+ batch.create_unique_constraint("uq_csp_tenant_service_scope", ["tenant_id", "service_catalogue_id", "scope_key"])
+
+ with op.batch_alter_table("client_service_subscriptions") as batch:
+ batch.drop_constraint("uq_css_tenant_client_service_fy_period", type_="unique")
+ batch.create_unique_constraint(
+ "uq_css_tenant_service_scope_fy_period",
+ ["tenant_id", "service_catalogue_id", "scope_key", "financial_year", "period_label"],
+ )
+
+
+def downgrade():
+ with op.batch_alter_table("client_service_subscriptions") as batch:
+ batch.drop_constraint("uq_css_tenant_service_scope_fy_period", type_="unique")
+ batch.create_unique_constraint(
+ "uq_css_tenant_client_service_fy_period",
+ ["tenant_id", "client_id", "service_catalogue_id", "financial_year", "period_label"],
+ )
+ for name in ("registration_id", "client_branch_id", "business_unit_id", "scope_key", "scope_type"):
+ batch.drop_column(name)
+
+ with op.batch_alter_table("client_service_plans") as batch:
+ batch.drop_constraint("uq_csp_tenant_service_scope", type_="unique")
+ batch.create_unique_constraint("uq_csp_tenant_client_service", ["tenant_id", "client_id", "service_catalogue_id"])
+ for name in ("registration_id", "client_branch_id", "business_unit_id", "scope_key", "scope_type"):
+ batch.drop_column(name)
+
+ with op.batch_alter_table("service_catalogues") as batch:
+ batch.drop_column("required_registration_type")
+ batch.drop_column("service_scope_type")
+
+ with op.batch_alter_table("client_registrations") as batch:
+ for name in ("state", "trade_name", "legal_name", "client_branch_id", "business_unit_id"):
+ batch.drop_column(name)
+
+ op.drop_table("client_branches")
+ op.drop_table("client_business_units")
diff --git a/app/modules/clients/models.py b/app/modules/clients/models.py
index 411112d..bcc88d1 100644
--- a/app/modules/clients/models.py
+++ b/app/modules/clients/models.py
@@ -203,3 +203,50 @@ class ClientEngagementLetter(CommonBase):
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 ClientBusinessUnit(CommonBase):
+ __tablename__ = "client_business_units"
+ __table_args__ = (
+ UniqueConstraint("tenant_id", "client_id", "business_code", name="uq_cbu_tenant_client_code"),
+ )
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+ tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
+ client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True)
+ business_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
+ business_name: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
+ trade_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
+ nature_of_business: Mapped[str | None] = mapped_column(String(200), nullable=True)
+ is_primary: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
+ is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
+ created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
+ updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), 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 ClientBranch(CommonBase):
+ __tablename__ = "client_branches"
+ __table_args__ = (
+ UniqueConstraint("tenant_id", "business_unit_id", "branch_code", name="uq_cbranch_tenant_business_code"),
+ )
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+ tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
+ client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True)
+ business_unit_id: Mapped[int] = mapped_column(ForeignKey("client_business_units.id", ondelete="CASCADE"), nullable=False, index=True)
+ branch_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
+ branch_name: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
+ branch_type: Mapped[str] = mapped_column(String(40), nullable=False, default="branch", index=True)
+ address_line_1: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ address_line_2: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ city: Mapped[str | None] = mapped_column(String(100), nullable=True)
+ state: Mapped[str | None] = mapped_column(String(100), nullable=True)
+ pincode: Mapped[str | None] = mapped_column(String(20), nullable=True)
+ is_primary: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
+ is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
+ created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
+ updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
+ created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
+ updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
diff --git a/app/modules/clients/scope_ui.py b/app/modules/clients/scope_ui.py
new file mode 100644
index 0000000..5becb4c
--- /dev/null
+++ b/app/modules/clients/scope_ui.py
@@ -0,0 +1,202 @@
+from __future__ import annotations
+
+from fastapi import APIRouter, Form, Request
+from fastapi.responses import RedirectResponse
+from sqlalchemy import select
+
+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.templating import templates
+from app.modules.clients.models import Client, ClientBusinessUnit, ClientBranch
+from app.modules.registrations.models import ClientRegistration, RegistrationType
+from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
+from app.modules.core.rbac.permission_guard import require_permission
+
+router = APIRouter(prefix="/clients", tags=["client-business-structure"])
+
+
+def _tenant_id(request, user):
+ return int(request.session.get("active_tenant_id") or request.session.get("tenant_id") or user.tenant_id)
+
+
+def _load_client(db, tenant_id: int, client_id: int):
+ return db.execute(select(Client).where(Client.id == client_id, Client.tenant_id == tenant_id)).scalar_one_or_none()
+
+
+def _context(request, db, user, **extra):
+ data = {
+ "request": request, "current_user": user,
+ "current_user_roles": get_user_roles(db, user.id),
+ "current_user_permissions": get_user_permissions(db, user.id),
+ "csrf_token": get_or_create_csrf_token(request),
+ }
+ data.update(extra)
+ return data
+
+
+@router.get("/{client_id}/business-structure")
+def business_structure_page(request: Request, client_id: int):
+ db = CommonSessionLocal()
+ try:
+ user = get_current_user(request, db=db)
+ if not user:
+ return RedirectResponse("/login", 303)
+ require_permission(db, user, "clients.view")
+ client = _load_client(db, _tenant_id(request, user), client_id)
+ if not client:
+ return RedirectResponse("/clients", 303)
+ businesses = db.execute(select(ClientBusinessUnit).where(
+ ClientBusinessUnit.client_id == client.id
+ ).order_by(ClientBusinessUnit.is_active.desc(), ClientBusinessUnit.is_primary.desc(), ClientBusinessUnit.business_name)).scalars().all()
+ branches = db.execute(select(ClientBranch).where(
+ ClientBranch.client_id == client.id
+ ).order_by(ClientBranch.is_active.desc(), ClientBranch.is_primary.desc(), ClientBranch.branch_name)).scalars().all()
+ registration_rows = db.execute(
+ select(ClientRegistration, RegistrationType).join(
+ RegistrationType, RegistrationType.id == ClientRegistration.registration_type_id
+ ).where(ClientRegistration.client_id == client.id).order_by(
+ ClientRegistration.status, RegistrationType.code, ClientRegistration.registration_number
+ )
+ ).all()
+ registrations = [row[0] for row in registration_rows]
+ registration_type_codes = {row[0].id: row[1].code for row in registration_rows}
+ registration_types = db.execute(
+ select(RegistrationType).where(RegistrationType.is_active.is_(True)).order_by(
+ RegistrationType.sort_order, RegistrationType.name
+ )
+ ).scalars().all()
+ return templates.TemplateResponse(
+ "modules/clients/templates/clients/business_structure.html",
+ _context(request, db, user, title="Client Business Structure", client=client,
+ businesses=businesses, branches=branches, registrations=registrations,
+ registration_type_codes=registration_type_codes, registration_types=registration_types,
+ can_edit="clients.edit" in set(get_user_permissions(db, user.id))),
+ )
+ finally:
+ db.close()
+
+
+@router.post("/{client_id}/business-units")
+def add_business_unit(request: Request, client_id: int, business_code: str = Form(...),
+ business_name: str = Form(...), trade_name: str = Form(""),
+ nature_of_business: str = Form(""), is_primary: str | None = Form(None),
+ csrf_token: str = Form(...)):
+ validate_csrf(request, csrf_token)
+ db = CommonSessionLocal()
+ try:
+ user = get_current_user(request, db=db); require_permission(db, user, "clients.edit")
+ tenant_id = _tenant_id(request, user); client = _load_client(db, tenant_id, client_id)
+ if not client: return RedirectResponse("/clients", 303)
+ if is_primary:
+ db.query(ClientBusinessUnit).filter_by(client_id=client.id).update({"is_primary": False})
+ db.add(ClientBusinessUnit(
+ tenant_id=tenant_id, client_id=client.id, business_code=business_code.strip().upper(),
+ business_name=business_name.strip(), trade_name=trade_name.strip() or None,
+ nature_of_business=nature_of_business.strip() or None, is_primary=bool(is_primary),
+ is_active=True, created_by_user_id=user.id, updated_by_user_id=user.id,
+ ))
+ db.commit()
+ return RedirectResponse(f"/clients/{client_id}/business-structure", 303)
+ finally:
+ db.close()
+
+
+@router.post("/{client_id}/branches")
+def add_client_branch(request: Request, client_id: int, business_unit_id: int = Form(...),
+ branch_code: str = Form(...), branch_name: str = Form(...),
+ branch_type: str = Form("branch"), city: str = Form(""),
+ state: str = Form(""), pincode: str = Form(""),
+ is_primary: str | None = Form(None), csrf_token: str = Form(...)):
+ validate_csrf(request, csrf_token)
+ db = CommonSessionLocal()
+ try:
+ user = get_current_user(request, db=db); require_permission(db, user, "clients.edit")
+ tenant_id = _tenant_id(request, user); client = _load_client(db, tenant_id, client_id)
+ business = db.get(ClientBusinessUnit, business_unit_id)
+ if not client or not business or business.client_id != client.id or business.tenant_id != tenant_id:
+ return RedirectResponse("/clients", 303)
+ if is_primary:
+ db.query(ClientBranch).filter_by(business_unit_id=business.id).update({"is_primary": False})
+ db.add(ClientBranch(
+ tenant_id=tenant_id, client_id=client.id, business_unit_id=business.id,
+ branch_code=branch_code.strip().upper(), branch_name=branch_name.strip(),
+ branch_type=branch_type.strip() or "branch", city=city.strip() or None,
+ state=state.strip() or None, pincode=pincode.strip() or None,
+ is_primary=bool(is_primary), is_active=True,
+ created_by_user_id=user.id, updated_by_user_id=user.id,
+ ))
+ db.commit()
+ return RedirectResponse(f"/clients/{client_id}/business-structure", 303)
+ finally:
+ db.close()
+
+
+@router.post("/{client_id}/registrations")
+def add_registration(request: Request, client_id: int, business_unit_id: str = Form(""),
+ client_branch_id: str = Form(""), registration_type_id: int = Form(...),
+ registration_number: str = Form(...), legal_name: str = Form(""),
+ trade_name: str = Form(""), state: str = Form(""),
+ csrf_token: str = Form(...)):
+ validate_csrf(request, csrf_token)
+ db = CommonSessionLocal()
+ try:
+ user = get_current_user(request, db=db); require_permission(db, user, "clients.edit")
+ tenant_id = _tenant_id(request, user); client = _load_client(db, tenant_id, client_id)
+ if not client: return RedirectResponse("/clients", 303)
+ bu_id = int(business_unit_id) if business_unit_id else None
+ br_id = int(client_branch_id) if client_branch_id else None
+ if bu_id:
+ business = db.get(ClientBusinessUnit, bu_id)
+ if not business or business.client_id != client.id: return RedirectResponse(f"/clients/{client_id}/business-structure", 303)
+ if br_id:
+ branch = db.get(ClientBranch, br_id)
+ if not branch or branch.client_id != client.id: return RedirectResponse(f"/clients/{client_id}/business-structure", 303)
+ bu_id = branch.business_unit_id
+ registration_type = db.get(RegistrationType, registration_type_id)
+ if not registration_type or not registration_type.is_active:
+ return RedirectResponse(f"/clients/{client_id}/business-structure", 303)
+ db.add(ClientRegistration(
+ tenant_id=tenant_id,
+ branch_id=getattr(client, "branch_id", None),
+ client_id=client.id,
+ business_unit_id=bu_id,
+ client_branch_id=br_id,
+ registration_type_id=registration_type.id,
+ registration_number=registration_number.strip().upper(),
+ legal_name=legal_name.strip() or client.client_name,
+ trade_name=trade_name.strip() or None,
+ state=state.strip() or None,
+ jurisdiction=state.strip() or None,
+ status="active",
+ primary_registration=False,
+ responsible_party="firm",
+ auto_create_task=True,
+ created_by_user_id=user.id,
+ ))
+ db.commit()
+ return RedirectResponse(f"/clients/{client_id}/business-structure", 303)
+ finally:
+ db.close()
+
+
+@router.post("/{client_id}/business-structure/{entity}/{entity_id}/toggle")
+def toggle_scope_record(request: Request, client_id: int, entity: str, entity_id: int, csrf_token: str = Form(...)):
+ validate_csrf(request, csrf_token)
+ db = CommonSessionLocal()
+ try:
+ user = get_current_user(request, db=db); require_permission(db, user, "clients.edit")
+ tenant_id = _tenant_id(request, user); client = _load_client(db, tenant_id, client_id)
+ model = {"business": ClientBusinessUnit, "branch": ClientBranch, "registration": ClientRegistration}.get(entity)
+ row = db.get(model, entity_id) if model else None
+ if not client or not row or row.client_id != client.id or row.tenant_id != tenant_id:
+ return RedirectResponse("/clients", 303)
+ if entity == "registration":
+ row.status = "inactive" if row.status in {"active", "valid", "registered"} else "active"
+ else:
+ row.is_active = not bool(row.is_active)
+ row.updated_by_user_id = user.id
+ db.commit()
+ return RedirectResponse(f"/clients/{client_id}/business-structure", 303)
+ finally:
+ db.close()
diff --git a/app/modules/clients/templates/clients/business_structure.html b/app/modules/clients/templates/clients/business_structure.html
new file mode 100644
index 0000000..37b4c64
--- /dev/null
+++ b/app/modules/clients/templates/clients/business_structure.html
@@ -0,0 +1,77 @@
+{% extends "ui/templates/base/layout.html" %}
+{% block content %}
+
+
+
{{ client.client_name }} — Business Structure
+
Maintain Business Units, Client Branches and statutory registrations under one PAN/legal client.
+
Back to Client
+
+
+ {% if can_edit %}
+
+ {% endif %}
+
+
+ Business Units
+ Code Business Unit Trade Name Nature Status
+ {% for row in businesses %}{{ row.business_code }} {{ row.business_name }}{% if row.is_primary %} Primary {% endif %} {{ row.trade_name or '-' }} {{ row.nature_of_business or '-' }} {{ 'Active' if row.is_active else 'Inactive' }} {% if can_edit %}{% endif %} {% else %}No Business Units added. {% endfor %}
+
+
+
+ Client Branches
+ Code Client Branch Type Location Status
+ {% for row in branches %}{{ row.branch_code }} {{ row.branch_name }}{% if row.is_primary %} Primary {% endif %} {{ row.branch_type|replace('_',' ')|title }} {{ row.city or '' }}{% if row.city and row.state %}, {% endif %}{{ row.state or '-' }} {{ 'Active' if row.is_active else 'Inactive' }} {% if can_edit %}{% endif %} {% else %}No Client Branches added. {% endfor %}
+
+
+
+ Registrations
+ Type Number Trade / Unit Name State Status
+ {% for row in registrations %}{{ registration_type_codes.get(row.id, "-") }} {{ row.registration_number }} {{ row.trade_name or row.legal_name or '-' }} {{ row.state or '-' }} {{ row.status|replace('_',' ')|title }} {% if can_edit %}{% endif %} {% else %}No registrations added. {% endfor %}
+
+
+{% endblock %}
diff --git a/app/modules/clients/templates/clients/detail.html b/app/modules/clients/templates/clients/detail.html
index 91de7f6..4ecaea5 100644
--- a/app/modules/clients/templates/clients/detail.html
+++ b/app/modules/clients/templates/clients/detail.html
@@ -9,6 +9,7 @@
{% if can_edit %}
+
Business Structure
Portal Identity
diff --git a/app/modules/registrations/models.py b/app/modules/registrations/models.py
index 49b42bf..3783324 100644
--- a/app/modules/registrations/models.py
+++ b/app/modules/registrations/models.py
@@ -29,6 +29,8 @@ class ClientRelatedPerson(CommonBase):
id: Mapped[int]=mapped_column(Integer, primary_key=True)
tenant_id: Mapped[int]=mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
client_id: Mapped[int]=mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True)
+ business_unit_id: Mapped[int|None]=mapped_column(ForeignKey("client_business_units.id", ondelete="SET NULL"), index=True)
+ client_branch_id: Mapped[int|None]=mapped_column(ForeignKey("client_branches.id", ondelete="SET NULL"), index=True)
person_type: Mapped[str]=mapped_column(String(60), nullable=False, index=True)
full_name: Mapped[str]=mapped_column(String(200), nullable=False)
designation: Mapped[str|None]=mapped_column(String(120))
@@ -56,6 +58,9 @@ class ClientRegistration(CommonBase):
registration_type_id: Mapped[int]=mapped_column(ForeignKey("registration_types.id"), nullable=False, index=True)
related_person_id: Mapped[int|None]=mapped_column(ForeignKey("client_related_persons.id", ondelete="SET NULL"), index=True)
registration_number: Mapped[str]=mapped_column(String(120), nullable=False, index=True)
+ legal_name: Mapped[str|None]=mapped_column(String(200))
+ trade_name: Mapped[str|None]=mapped_column(String(200))
+ state: Mapped[str|None]=mapped_column(String(100))
jurisdiction: Mapped[str|None]=mapped_column(String(160))
state_code: Mapped[str|None]=mapped_column(String(10))
issue_date: Mapped[date|None]=mapped_column(Date)
diff --git a/app/modules/services/models.py b/app/modules/services/models.py
index eef4f46..553147e 100644
--- a/app/modules/services/models.py
+++ b/app/modules/services/models.py
@@ -37,6 +37,8 @@ class ServiceCatalogue(CommonBase):
category_id: Mapped[int | None] = mapped_column(ForeignKey("service_categories.id"), nullable=True, index=True)
recurrence_type: Mapped[str | None] = mapped_column(String(30), nullable=True, index=True)
engagement_type: Mapped[str] = mapped_column(String(20), nullable=False, default="non_assurance", index=True)
+ service_scope_type: Mapped[str] = mapped_column(String(30), nullable=False, default="client", server_default="client", index=True)
+ required_registration_type: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=100)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
@@ -377,8 +379,8 @@ class ClientServicePlan(CommonBase):
__tablename__ = "client_service_plans"
__table_args__ = (
UniqueConstraint(
- "tenant_id", "client_id", "service_catalogue_id",
- name="uq_csp_tenant_client_service",
+ "tenant_id", "service_catalogue_id", "scope_key",
+ name="uq_csp_tenant_service_scope",
),
)
@@ -386,6 +388,11 @@ class ClientServicePlan(CommonBase):
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)
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True)
+ scope_type: Mapped[str] = mapped_column(String(30), nullable=False, default="client", server_default="client", index=True)
+ scope_key: Mapped[str] = mapped_column(String(80), nullable=False, default="", server_default="", index=True)
+ business_unit_id: Mapped[int | None] = mapped_column(ForeignKey("client_business_units.id", ondelete="SET NULL"), nullable=True, index=True)
+ client_branch_id: Mapped[int | None] = mapped_column(ForeignKey("client_branches.id", ondelete="SET NULL"), nullable=True, index=True)
+ registration_id: Mapped[int | None] = mapped_column(ForeignKey("client_registrations.id", ondelete="SET NULL"), nullable=True, index=True)
service_catalogue_id: Mapped[int] = mapped_column(ForeignKey("service_catalogues.id", ondelete="CASCADE"), nullable=False, index=True)
firm_service_selection_id: Mapped[int | None] = mapped_column(ForeignKey("firm_service_selections.id", ondelete="SET NULL"), nullable=True, index=True)
@@ -426,11 +433,11 @@ class ClientServiceSubscription(CommonBase):
__table_args__ = (
UniqueConstraint(
"tenant_id",
- "client_id",
"service_catalogue_id",
+ "scope_key",
"financial_year",
"period_label",
- name="uq_css_tenant_client_service_fy_period",
+ name="uq_css_tenant_service_scope_fy_period",
),
)
@@ -440,6 +447,11 @@ class ClientServiceSubscription(CommonBase):
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True)
service_plan_id: Mapped[int | None] = mapped_column(ForeignKey("client_service_plans.id", ondelete="SET NULL"), nullable=True, index=True)
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True)
+ scope_type: Mapped[str] = mapped_column(String(30), nullable=False, default="client", server_default="client", index=True)
+ scope_key: Mapped[str] = mapped_column(String(80), nullable=False, default="", server_default="", index=True)
+ business_unit_id: Mapped[int | None] = mapped_column(ForeignKey("client_business_units.id", ondelete="SET NULL"), nullable=True, index=True)
+ client_branch_id: Mapped[int | None] = mapped_column(ForeignKey("client_branches.id", ondelete="SET NULL"), nullable=True, index=True)
+ registration_id: Mapped[int | None] = mapped_column(ForeignKey("client_registrations.id", ondelete="SET NULL"), nullable=True, index=True)
service_catalogue_id: Mapped[int] = mapped_column(ForeignKey("service_catalogues.id", ondelete="CASCADE"), nullable=False, index=True)
firm_service_selection_id: Mapped[int | None] = mapped_column(ForeignKey("firm_service_selections.id", ondelete="SET NULL"), nullable=True, index=True)
diff --git a/app/modules/services/scope_targets.py b/app/modules/services/scope_targets.py
new file mode 100644
index 0000000..abfaf1b
--- /dev/null
+++ b/app/modules/services/scope_targets.py
@@ -0,0 +1,354 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from app.modules.clients.models import Client, ClientBusinessUnit, ClientBranch
+from app.modules.registrations.models import ClientRegistration, RegistrationType
+from app.modules.services.models import ClientServicePlan, ServiceCatalogue
+
+VALID_SCOPE_TYPES = {"client", "business_unit", "client_branch", "registration"}
+VALID_REGISTRATION_TYPES = {"GST", "TAN", "PF", "ESI", "PT", "IEC", "FSSAI", "UDYAM", "OTHER"}
+
+
+@dataclass
+class ScopeTarget:
+ token: str
+ scope_type: str
+ scope_key: str
+ client_id: int
+ business_unit_id: int | None
+ client_branch_id: int | None
+ registration_id: int | None
+ client_code: str
+ client_name: str
+ pan: str
+ business_unit: str
+ client_branch: str
+ registration_type: str
+ registration_number: str
+ trade_name: str
+ state: str
+ entity_type: str
+ partner_id: int | None
+ existing_plan_status: str = ""
+
+
+def normalize_scope_type(value: str | None) -> str:
+ value = (value or "client").strip().lower().replace("-", "_").replace(" ", "_")
+ return value if value in VALID_SCOPE_TYPES else "client"
+
+
+def normalize_registration_type(value: str | None) -> str | None:
+ value = (value or "").strip().upper()
+ return value if value in VALID_REGISTRATION_TYPES else None
+
+
+def scope_key(scope_type: str, target_id: int) -> str:
+ prefixes = {
+ "client": "CLIENT",
+ "business_unit": "BUSINESS",
+ "client_branch": "BRANCH",
+ "registration": "REGISTRATION",
+ }
+ return f"{prefixes[scope_type]}:{int(target_id)}"
+
+
+def _plan_status_map(db: Session, tenant_id: int, service_catalogue_id: int) -> dict[str, str]:
+ rows = db.execute(
+ select(ClientServicePlan.scope_key, ClientServicePlan.status).where(
+ ClientServicePlan.tenant_id == tenant_id,
+ ClientServicePlan.service_catalogue_id == service_catalogue_id,
+ )
+ ).all()
+ return {key: status for key, status in rows if key}
+
+
+def list_scope_targets(
+ db: Session,
+ *,
+ tenant_id: int,
+ clients: list[Client],
+ catalogue: ServiceCatalogue,
+) -> list[ScopeTarget]:
+ client_map = {int(c.id): c for c in clients}
+ client_ids = list(client_map)
+ if not client_ids:
+ return []
+
+ selected_scope = normalize_scope_type(catalogue.service_scope_type)
+ required_registration = normalize_registration_type(catalogue.required_registration_type)
+ plan_status = _plan_status_map(db, tenant_id, catalogue.id)
+ result: list[ScopeTarget] = []
+
+ if selected_scope == "client":
+ for client in clients:
+ key = scope_key("client", client.id)
+ result.append(ScopeTarget(
+ token=f"client:{client.id}", scope_type="client", scope_key=key,
+ client_id=client.id, business_unit_id=None, client_branch_id=None, registration_id=None,
+ client_code=client.client_code or "", client_name=client.client_name or "",
+ pan=client.pan or "", business_unit="", client_branch="", registration_type="",
+ registration_number="", trade_name=client.trade_name or "", state=client.state or "",
+ entity_type=client.client_type or "", partner_id=client.partner_id,
+ existing_plan_status=plan_status.get(key, ""),
+ ))
+ return result
+
+ businesses = db.execute(
+ select(ClientBusinessUnit).where(
+ ClientBusinessUnit.tenant_id == tenant_id,
+ ClientBusinessUnit.client_id.in_(client_ids),
+ ClientBusinessUnit.is_active.is_(True),
+ ).order_by(ClientBusinessUnit.client_id, ClientBusinessUnit.is_primary.desc(), ClientBusinessUnit.business_name)
+ ).scalars().all()
+
+ if selected_scope == "business_unit":
+ actual_clients = set()
+ for business in businesses:
+ client = client_map[business.client_id]
+ actual_clients.add(client.id)
+ key = scope_key("business_unit", business.id)
+ result.append(ScopeTarget(
+ token=f"business:{business.id}", scope_type="business_unit", scope_key=key,
+ client_id=client.id, business_unit_id=business.id, client_branch_id=None, registration_id=None,
+ client_code=client.client_code or "", client_name=client.client_name or "", pan=client.pan or "",
+ business_unit=business.business_name, client_branch="", registration_type="", registration_number="",
+ trade_name=business.trade_name or client.trade_name or "", state=client.state or "",
+ entity_type=client.client_type or "", partner_id=client.partner_id,
+ existing_plan_status=plan_status.get(key, ""),
+ ))
+ for client in clients:
+ if client.id not in actual_clients:
+ result.append(ScopeTarget(
+ token=f"business:auto:{client.id}", scope_type="business_unit", scope_key=f"AUTO_BUSINESS:{client.id}",
+ client_id=client.id, business_unit_id=None, client_branch_id=None, registration_id=None,
+ client_code=client.client_code or "", client_name=client.client_name or "", pan=client.pan or "",
+ business_unit=client.trade_name or client.client_name, client_branch="", registration_type="",
+ registration_number="", trade_name=client.trade_name or "", state=client.state or "",
+ entity_type=client.client_type or "", partner_id=client.partner_id, existing_plan_status="",
+ ))
+ return result
+
+ business_map = {b.id: b for b in businesses}
+ branches = db.execute(
+ select(ClientBranch).where(
+ ClientBranch.tenant_id == tenant_id,
+ ClientBranch.client_id.in_(client_ids),
+ ClientBranch.is_active.is_(True),
+ ).order_by(ClientBranch.client_id, ClientBranch.is_primary.desc(), ClientBranch.branch_name)
+ ).scalars().all()
+
+ if selected_scope == "client_branch":
+ for branch in branches:
+ client = client_map[branch.client_id]
+ business = business_map.get(branch.business_unit_id)
+ key = scope_key("client_branch", branch.id)
+ result.append(ScopeTarget(
+ token=f"branch:{branch.id}", scope_type="client_branch", scope_key=key,
+ client_id=client.id, business_unit_id=branch.business_unit_id, client_branch_id=branch.id, registration_id=None,
+ client_code=client.client_code or "", client_name=client.client_name or "", pan=client.pan or "",
+ business_unit=business.business_name if business else "", client_branch=branch.branch_name,
+ registration_type="", registration_number="", trade_name=(business.trade_name if business else "") or client.trade_name or "",
+ state=branch.state or client.state or "", entity_type=client.client_type or "", partner_id=client.partner_id,
+ existing_plan_status=plan_status.get(key, ""),
+ ))
+ return result
+
+ branch_map = {b.id: b for b in branches}
+ registration_rows = db.execute(
+ select(ClientRegistration, RegistrationType).join(
+ RegistrationType, RegistrationType.id == ClientRegistration.registration_type_id
+ ).where(
+ ClientRegistration.tenant_id == tenant_id,
+ ClientRegistration.client_id.in_(client_ids),
+ ClientRegistration.status.in_(("active", "valid", "registered")),
+ RegistrationType.is_active.is_(True),
+ ).order_by(ClientRegistration.client_id, RegistrationType.code, ClientRegistration.registration_number)
+ ).all()
+
+ actual_legacy = set()
+ for registration, registration_type in registration_rows:
+ registration_code = (registration_type.code or "").strip().upper()
+ if required_registration and registration_code != required_registration:
+ continue
+ client = client_map[registration.client_id]
+ business = business_map.get(registration.business_unit_id)
+ branch = branch_map.get(registration.client_branch_id)
+ key = scope_key("registration", registration.id)
+ actual_legacy.add((client.id, registration_code))
+ result.append(ScopeTarget(
+ token=f"registration:{registration.id}", scope_type="registration", scope_key=key,
+ client_id=client.id, business_unit_id=registration.business_unit_id,
+ client_branch_id=registration.client_branch_id, registration_id=registration.id,
+ client_code=client.client_code or "", client_name=client.client_name or "", pan=client.pan or "",
+ business_unit=business.business_name if business else "", client_branch=branch.branch_name if branch else "",
+ registration_type=registration_code, registration_number=registration.registration_number,
+ trade_name=registration.trade_name or (business.trade_name if business else "") or client.trade_name or "",
+ state=registration.state or (branch.state if branch else "") or client.state or "",
+ entity_type=client.client_type or "", partner_id=client.partner_id,
+ existing_plan_status=plan_status.get(key, ""),
+ ))
+
+ # Existing legacy GSTIN/TAN values remain usable immediately after migration.
+ for client in clients:
+ legacy_pairs = []
+ if required_registration in (None, "GST") and client.gstin:
+ legacy_pairs.append(("GST", client.gstin))
+ if required_registration in (None, "TAN") and client.tan:
+ legacy_pairs.append(("TAN", client.tan))
+ for reg_type, number in legacy_pairs:
+ if (client.id, reg_type) in actual_legacy:
+ continue
+ result.append(ScopeTarget(
+ token=f"registration:legacy:{reg_type}:{client.id}", scope_type="registration",
+ scope_key=f"LEGACY_{reg_type}:{client.id}", client_id=client.id,
+ business_unit_id=None, client_branch_id=None, registration_id=None,
+ client_code=client.client_code or "", client_name=client.client_name or "", pan=client.pan or "",
+ business_unit=client.trade_name or client.client_name, client_branch="Primary Branch",
+ registration_type=reg_type, registration_number=number, trade_name=client.trade_name or "",
+ state=client.state or "", entity_type=client.client_type or "", partner_id=client.partner_id,
+ existing_plan_status="",
+ ))
+ return result
+
+
+def _next_code(prefix: str, value: int) -> str:
+ return f"{prefix}{int(value):05d}"
+
+
+def resolve_scope_target(
+ db: Session,
+ *,
+ tenant_id: int,
+ clients_by_id: dict[int, Client],
+ token: str,
+ actor_user_id: int,
+):
+ parts = (token or "").split(":")
+ if not parts:
+ raise ValueError("Invalid subscription scope.")
+
+ kind = parts[0]
+ if kind == "client" and len(parts) == 2:
+ client = clients_by_id.get(int(parts[1]))
+ if not client:
+ raise ValueError("Client is outside the permitted scope.")
+ return client, "client", scope_key("client", client.id), None, None, None
+
+ if kind == "business":
+ if len(parts) == 2:
+ business = db.get(ClientBusinessUnit, int(parts[1]))
+ if not business or business.tenant_id != tenant_id or business.client_id not in clients_by_id:
+ raise ValueError("Business Unit is outside the permitted scope.")
+ elif len(parts) == 3 and parts[1] == "auto":
+ client = clients_by_id.get(int(parts[2]))
+ if not client:
+ raise ValueError("Client is outside the permitted scope.")
+ business = ClientBusinessUnit(
+ tenant_id=tenant_id, client_id=client.id,
+ business_code=_next_code("BU", client.id),
+ business_name=client.trade_name or client.client_name,
+ trade_name=client.trade_name, is_primary=True, is_active=True,
+ created_by_user_id=actor_user_id, updated_by_user_id=actor_user_id,
+ )
+ db.add(business); db.flush()
+ else:
+ raise ValueError("Invalid Business Unit.")
+ client = clients_by_id[business.client_id]
+ return client, "business_unit", scope_key("business_unit", business.id), business.id, None, None
+
+ if kind == "branch" and len(parts) == 2:
+ branch = db.get(ClientBranch, int(parts[1]))
+ if not branch or branch.tenant_id != tenant_id or branch.client_id not in clients_by_id:
+ raise ValueError("Client Branch is outside the permitted scope.")
+ client = clients_by_id[branch.client_id]
+ return client, "client_branch", scope_key("client_branch", branch.id), branch.business_unit_id, branch.id, None
+
+ if kind == "registration":
+ if len(parts) == 2:
+ reg = db.get(ClientRegistration, int(parts[1]))
+ if not reg or reg.tenant_id != tenant_id or reg.client_id not in clients_by_id:
+ raise ValueError("Registration is outside the permitted scope.")
+ elif len(parts) == 4 and parts[1] == "legacy":
+ reg_type = parts[2].upper()
+ client = clients_by_id.get(int(parts[3]))
+ if not client:
+ raise ValueError("Client is outside the permitted scope.")
+ number = client.gstin if reg_type == "GST" else client.tan if reg_type == "TAN" else None
+ if not number:
+ raise ValueError("Legacy registration is no longer available.")
+ business = db.execute(
+ select(ClientBusinessUnit).where(
+ ClientBusinessUnit.tenant_id == tenant_id,
+ ClientBusinessUnit.client_id == client.id,
+ ClientBusinessUnit.is_active.is_(True),
+ ).order_by(ClientBusinessUnit.is_primary.desc(), ClientBusinessUnit.id)
+ ).scalars().first()
+ if not business:
+ business = ClientBusinessUnit(
+ tenant_id=tenant_id, client_id=client.id,
+ business_code=_next_code("BU", client.id),
+ business_name=client.trade_name or client.client_name,
+ trade_name=client.trade_name, is_primary=True, is_active=True,
+ created_by_user_id=actor_user_id, updated_by_user_id=actor_user_id,
+ )
+ db.add(business); db.flush()
+ branch = db.execute(
+ select(ClientBranch).where(
+ ClientBranch.tenant_id == tenant_id,
+ ClientBranch.business_unit_id == business.id,
+ ClientBranch.is_active.is_(True),
+ ).order_by(ClientBranch.is_primary.desc(), ClientBranch.id)
+ ).scalars().first()
+ if not branch:
+ branch = ClientBranch(
+ tenant_id=tenant_id, client_id=client.id, business_unit_id=business.id,
+ branch_code=_next_code("BR", client.id), branch_name="Primary Branch",
+ branch_type="head_office", state=client.state, is_primary=True, is_active=True,
+ created_by_user_id=actor_user_id, updated_by_user_id=actor_user_id,
+ )
+ db.add(branch); db.flush()
+ registration_type = db.execute(
+ select(RegistrationType).where(RegistrationType.code == reg_type)
+ ).scalar_one_or_none()
+ if not registration_type:
+ registration_type = RegistrationType(
+ code=reg_type,
+ name=reg_type,
+ category="registration",
+ identifier_label=f"{reg_type} Number",
+ supports_expiry=False,
+ supports_related_person=False,
+ is_system=True,
+ is_active=True,
+ sort_order=0,
+ )
+ db.add(registration_type)
+ db.flush()
+ reg = ClientRegistration(
+ tenant_id=tenant_id,
+ branch_id=getattr(client, "branch_id", None),
+ client_id=client.id,
+ business_unit_id=business.id,
+ client_branch_id=branch.id,
+ registration_type_id=registration_type.id,
+ registration_number=number.strip().upper(),
+ legal_name=client.client_name,
+ trade_name=client.trade_name,
+ state=client.state,
+ jurisdiction=client.state,
+ status="active",
+ primary_registration=True,
+ responsible_party="firm",
+ auto_create_task=True,
+ created_by_user_id=actor_user_id,
+ )
+ db.add(reg); db.flush()
+ else:
+ raise ValueError("Invalid registration.")
+ client = clients_by_id[reg.client_id]
+ return client, "registration", scope_key("registration", reg.id), reg.business_unit_id, reg.client_branch_id, reg.id
+
+ raise ValueError("Invalid subscription scope.")
diff --git a/app/modules/services/subscriptions_ui.py b/app/modules/services/subscriptions_ui.py
index 8366824..30fbd9d 100644
--- a/app/modules/services/subscriptions_ui.py
+++ b/app/modules/services/subscriptions_ui.py
@@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import date, datetime, timezone
from fastapi import APIRouter, Form, Request
-from fastapi.responses import RedirectResponse
+from fastapi.responses import JSONResponse, RedirectResponse
from sqlalchemy import func, or_, select
from sqlalchemy.orm import selectinload
@@ -36,6 +36,7 @@ from app.modules.services.client_services import (
)
from app.modules.clients.models import Client
from app.modules.services.due_dates import apply_due_date_rule_to_subscription
+from app.modules.services.scope_targets import list_scope_targets, resolve_scope_target
from app.modules.services.models import (
ClientServicePlan,
ClientServiceSubscription,
@@ -240,27 +241,13 @@ def subscription_bulk_page(
tenant_id = _tenant_id(request, user)
branch_id = _branch_id(request, user)
- clients = list_clients_for_assignment(
- db,
- tenant_id=tenant_id,
- branch_id=branch_id,
- partner_id=_partner_scope_id(db, user),
- )
enabled_services = list_enabled_services_for_assignment(db, tenant_id=tenant_id)
- partners = list_assignable_users(
- db, tenant_id=tenant_id, branch_id=branch_id, role_names=("Partner",)
- )
- managers = list_assignable_users(
- db, tenant_id=tenant_id, branch_id=branch_id, role_names=("Branch Manager",)
- )
- staff_users = list_assignable_users(
- db, tenant_id=tenant_id, branch_id=branch_id, role_names=("Staff",)
- )
- review_partners = list_review_partners(
- db, tenant_id=tenant_id, branch_id=branch_id
- )
+ partners = list_assignable_users(db, tenant_id=tenant_id, branch_id=branch_id, role_names=("Partner",))
+ managers = list_assignable_users(db, tenant_id=tenant_id, branch_id=branch_id, role_names=("Branch Manager",))
+ staff_users = list_assignable_users(db, tenant_id=tenant_id, branch_id=branch_id, role_names=("Staff",))
+ review_partners = list_review_partners(db, tenant_id=tenant_id, branch_id=branch_id)
error_messages = {
- "clients": "Select at least one permitted client.",
+ "targets": "Select at least one permitted Client, Business Unit, Client Branch or Registration.",
"service": "Select a valid enabled firm service.",
"partner": "Select a valid Engagement Partner.",
"performing_partner": "Select a valid Performing Partner.",
@@ -276,20 +263,9 @@ def subscription_bulk_page(
return templates.TemplateResponse(
"modules/services/templates/services/subscriptions/bulk.html",
_ctx(
- request,
- db,
- user,
- title="Bulk Client Subscriptions",
- clients=clients,
- enabled_services=enabled_services,
- partners=partners,
- managers=managers,
- staff_users=staff_users,
- review_partners=review_partners,
- client_partner_names={
- row.id: (row.full_name or row.email)
- for row in partners
- },
+ request, db, user, title="Bulk Client Subscriptions",
+ enabled_services=enabled_services, partners=partners, managers=managers,
+ staff_users=staff_users, review_partners=review_partners,
financial_year=_active_financial_year(request),
error_message=error_messages.get(error, ""),
subscriptions_created=subscriptions_created,
@@ -302,10 +278,59 @@ def subscription_bulk_page(
db.close()
+@router.get("/bulk/targets")
+def subscription_bulk_targets(request: Request, service_catalogue_id: int):
+ db = CommonSessionLocal()
+ try:
+ user = get_current_user(request, db=db)
+ if not user:
+ return JSONResponse({"error": "Authentication required."}, status_code=401)
+ try:
+ require_permission(db, user, "clients.edit")
+ except Exception:
+ return JSONResponse({"error": "Access denied."}, status_code=403)
+
+ tenant_id = _tenant_id(request, user)
+ branch_id = _branch_id(request, user)
+ firm_selection = get_enabled_firm_service(db, tenant_id=tenant_id, service_catalogue_id=service_catalogue_id)
+ if not firm_selection:
+ return JSONResponse({"error": "Enabled firm service not found."}, status_code=404)
+
+ clients = list_clients_for_assignment(
+ db, tenant_id=tenant_id, branch_id=branch_id, partner_id=_partner_scope_id(db, user)
+ )
+ targets = list_scope_targets(db, tenant_id=tenant_id, clients=clients, catalogue=firm_selection.catalogue)
+ scope_type = getattr(firm_selection.catalogue, "service_scope_type", "client") or "client"
+ registration_type = getattr(firm_selection.catalogue, "required_registration_type", None)
+ return {
+ "scope_type": scope_type,
+ "registration_type": registration_type,
+ "targets": [
+ {
+ "token": row.token,
+ "client_code": row.client_code,
+ "client_name": row.client_name,
+ "pan": row.pan,
+ "business_unit": row.business_unit,
+ "client_branch": row.client_branch,
+ "registration_type": row.registration_type,
+ "registration_number": row.registration_number,
+ "trade_name": row.trade_name,
+ "state": row.state,
+ "entity_type": row.entity_type,
+ "subscription_status": row.existing_plan_status or "not_subscribed",
+ }
+ for row in targets
+ ],
+ }
+ finally:
+ db.close()
+
+
@router.post("/bulk")
def subscription_bulk_submit(
request: Request,
- client_ids: list[int] = Form([]),
+ scope_targets: list[str] = Form([]),
service_catalogue_id: int = Form(...),
default_partner_user_id: int = Form(...),
default_performing_partner_user_id: str = Form(""),
@@ -323,10 +348,7 @@ def subscription_bulk_submit(
):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
- subscriptions_created = 0
- subscriptions_reused = 0
- engagements_created = 0
- engagements_skipped = 0
+ subscriptions_created = subscriptions_reused = engagements_created = engagements_skipped = 0
try:
user = get_current_user(request, db=db)
if not user:
@@ -338,70 +360,41 @@ def subscription_bulk_submit(
tenant_id = _tenant_id(request, user)
branch_id = _branch_id(request, user)
- permitted_clients = list_clients_for_assignment(
- db,
- tenant_id=tenant_id,
- branch_id=branch_id,
- partner_id=_partner_scope_id(db, user),
- )
- permitted_client_map = {row.id: row for row in permitted_clients}
- selected_client_ids = list(dict.fromkeys(int(value) for value in client_ids))
- if not selected_client_ids or any(value not in permitted_client_map for value in selected_client_ids):
- return RedirectResponse("/services/subscriptions/bulk?error=clients", 303)
-
- firm_selection = get_enabled_firm_service(
- db,
- tenant_id=tenant_id,
- service_catalogue_id=service_catalogue_id,
- )
+ firm_selection = get_enabled_firm_service(db, tenant_id=tenant_id, service_catalogue_id=service_catalogue_id)
if not firm_selection:
return RedirectResponse("/services/subscriptions/bulk?error=service", 303)
- plan_branch_id = (
- branch_id
- or getattr(firm_selection, "default_branch_id", None)
- or getattr(user, "branch_id", None)
- )
- partners = list_assignable_users(
- db, tenant_id=tenant_id, branch_id=plan_branch_id, role_names=("Partner",)
- )
- managers = list_assignable_users(
- db, tenant_id=tenant_id, branch_id=plan_branch_id, role_names=("Branch Manager",)
- )
- staff_users = list_assignable_users(
- db, tenant_id=tenant_id, branch_id=plan_branch_id, role_names=("Staff",)
- )
- review_partners = list_review_partners(
- db, tenant_id=tenant_id, branch_id=plan_branch_id
+ permitted_clients = list_clients_for_assignment(
+ db, tenant_id=tenant_id, branch_id=branch_id, partner_id=_partner_scope_id(db, user)
)
+ clients_by_id = {int(row.id): row for row in permitted_clients}
+ selected_tokens = list(dict.fromkeys(value for value in scope_targets if value))
+ if not selected_tokens:
+ return RedirectResponse("/services/subscriptions/bulk?error=targets", 303)
- partner_ids = {row.id for row in partners}
- manager_ids = {row.id for row in managers}
- staff_ids = {row.id for row in staff_users}
- review_partner_ids = {row.id for row in review_partners}
+ plan_branch_id = branch_id or getattr(firm_selection, "default_branch_id", None) or getattr(user, "branch_id", None)
+ partners = list_assignable_users(db, tenant_id=tenant_id, branch_id=plan_branch_id, role_names=("Partner",))
+ managers = list_assignable_users(db, tenant_id=tenant_id, branch_id=plan_branch_id, role_names=("Branch Manager",))
+ staff_users = list_assignable_users(db, tenant_id=tenant_id, branch_id=plan_branch_id, role_names=("Staff",))
+ review_partners = list_review_partners(db, tenant_id=tenant_id, branch_id=plan_branch_id)
- if default_partner_user_id not in partner_ids:
+ if default_partner_user_id not in {row.id for row in partners}:
return RedirectResponse("/services/subscriptions/bulk?error=partner", 303)
performing_partner_id = _optional_int(default_performing_partner_user_id) or default_partner_user_id
- if performing_partner_id not in partner_ids:
+ if performing_partner_id not in {row.id for row in partners}:
return RedirectResponse("/services/subscriptions/bulk?error=performing_partner", 303)
manager_id = _optional_int(default_manager_user_id)
- if manager_id is not None and manager_id not in manager_ids:
+ if manager_id is not None and manager_id not in {row.id for row in managers}:
return RedirectResponse("/services/subscriptions/bulk?error=manager", 303)
staff_id = _optional_int(default_staff_user_id)
- if staff_id is not None and staff_id not in staff_ids:
+ if staff_id is not None and staff_id not in {row.id for row in staff_users}:
return RedirectResponse("/services/subscriptions/bulk?error=staff", 303)
review_partner_id = _optional_int(default_review_partner_user_id)
- if review_partner_id is not None and review_partner_id not in review_partner_ids:
+ if review_partner_id is not None and review_partner_id not in {row.id for row in review_partners}:
return RedirectResponse("/services/subscriptions/bulk?error=review_partner", 303)
- engagement_type = (
- getattr(firm_selection.catalogue, "engagement_type", None)
- or "non_assurance"
- )
- review_required = review_partner_required_for_engagement(
- db, tenant_id=tenant_id, engagement_type=engagement_type
- )
+ engagement_type = getattr(firm_selection.catalogue, "engagement_type", None) or "non_assurance"
+ review_required = review_partner_required_for_engagement(db, tenant_id=tenant_id, engagement_type=engagement_type)
if review_required and review_partner_id is None:
return RedirectResponse("/services/subscriptions/bulk?error=review_partner_required", 303)
if review_required and review_partner_id in {default_partner_user_id, performing_partner_id}:
@@ -409,39 +402,26 @@ def subscription_bulk_submit(
if generation_mode not in {"subscription_only", "current_period", "all_periods"}:
return RedirectResponse("/services/subscriptions/bulk?error=generation", 303)
-
- selected_financial_year = normalize_financial_year(
- financial_year or _active_financial_year(request)
- )
+ selected_financial_year = normalize_financial_year(financial_year or _active_financial_year(request))
if generation_mode != "subscription_only":
- locked_response = redirect_if_financial_year_locked(
- db,
- tenant_id=tenant_id,
- year_code=selected_financial_year,
+ locked = redirect_if_financial_year_locked(
+ db, tenant_id=tenant_id, year_code=selected_financial_year,
redirect_url="/services/subscriptions/bulk?error=financial_year",
)
- if locked_response:
- return locked_response
+ if locked:
+ return locked
recurrence_type = getattr(firm_selection.catalogue, "recurrence_type", None)
if generation_mode == "subscription_only":
- requested_periods: list[str] = []
+ requested_periods = []
elif recurrence_requires_period(recurrence_type):
if generation_mode == "all_periods":
- requested_periods = [
- code for code, _label in period_choices_for_service(
- selected_financial_year, recurrence_type
- )
- ]
+ requested_periods = [code for code, _ in period_choices_for_service(selected_financial_year, recurrence_type)]
else:
try:
- requested_periods = [
- normalize_period_label(
- period_label,
- financial_year=selected_financial_year,
- recurrence_type=recurrence_type,
- )
- ]
+ requested_periods = [normalize_period_label(
+ period_label, financial_year=selected_financial_year, recurrence_type=recurrence_type
+ )]
except ValueError:
return RedirectResponse("/services/subscriptions/bulk?error=period", 303)
else:
@@ -452,54 +432,51 @@ def subscription_bulk_submit(
if plan_effective_from and plan_effective_to and plan_effective_to < plan_effective_from:
return RedirectResponse("/services/subscriptions/bulk?error=period", 303)
- for client_id in selected_client_ids:
- client = permitted_client_map[client_id]
- existing_plan = db.execute(
- select(ClientServicePlan).where(
- ClientServicePlan.tenant_id == tenant_id,
- ClientServicePlan.client_id == client.id,
- ClientServicePlan.service_catalogue_id == service_catalogue_id,
+ for token in selected_tokens:
+ try:
+ client, scope_type, scope_key, business_unit_id, client_branch_id, registration_id = resolve_scope_target(
+ db, tenant_id=tenant_id, clients_by_id=clients_by_id, token=token, actor_user_id=user.id
)
- ).scalar_one_or_none()
+ except ValueError:
+ db.rollback()
+ return RedirectResponse("/services/subscriptions/bulk?error=targets", 303)
- plan = get_or_create_client_service_plan(
- db,
- tenant_id=tenant_id,
- client=client,
- catalogue=firm_selection.catalogue,
- firm_selection=firm_selection,
- branch_id=plan_branch_id or getattr(client, "branch_id", None),
- partner_user_id=default_partner_user_id,
- performing_partner_user_id=performing_partner_id,
- manager_user_id=manager_id,
- staff_user_id=staff_id,
- review_partner_user_id=review_partner_id,
- actor_user_id=user.id,
- remarks=remarks.strip() or None,
- )
- plan.effective_from = plan_effective_from
- plan.effective_to = plan_effective_to
- plan.auto_generate_periods = auto_generate_periods is not None
- plan.status = "active"
- plan.is_active = True
- plan.updated_by_user_id = user.id
- if remarks.strip():
- plan.remarks = remarks.strip()
+ plan = db.execute(select(ClientServicePlan).where(
+ ClientServicePlan.tenant_id == tenant_id,
+ ClientServicePlan.service_catalogue_id == service_catalogue_id,
+ ClientServicePlan.scope_key == scope_key,
+ )).scalar_one_or_none()
- if existing_plan is None:
- subscriptions_created += 1
- else:
+ if plan:
subscriptions_reused += 1
+ else:
+ plan = ClientServicePlan(
+ tenant_id=tenant_id, branch_id=plan_branch_id or client.branch_id,
+ client_id=client.id, scope_type=scope_type, scope_key=scope_key,
+ business_unit_id=business_unit_id, client_branch_id=client_branch_id,
+ registration_id=registration_id, service_catalogue_id=service_catalogue_id,
+ firm_service_selection_id=firm_selection.id,
+ default_partner_user_id=default_partner_user_id,
+ default_performing_partner_user_id=performing_partner_id,
+ default_manager_user_id=manager_id, default_staff_user_id=staff_id,
+ default_review_partner_user_id=review_partner_id,
+ recurrence_type=(recurrence_type or "one_time"),
+ effective_from=plan_effective_from, effective_to=plan_effective_to,
+ auto_generate_periods=auto_generate_periods is not None,
+ status="active", is_active=True, remarks=remarks.strip() or None,
+ created_by_user_id=user.id, updated_by_user_id=user.id,
+ )
+ db.add(plan); db.flush()
+ subscriptions_created += 1
for requested_period in requested_periods:
- existing = get_existing_subscription(
- db,
- tenant_id=tenant_id,
- client_id=client.id,
- service_catalogue_id=service_catalogue_id,
- financial_year=selected_financial_year,
- period_label=requested_period,
- )
+ existing = db.execute(select(ClientServiceSubscription).where(
+ ClientServiceSubscription.tenant_id == tenant_id,
+ ClientServiceSubscription.service_catalogue_id == service_catalogue_id,
+ ClientServiceSubscription.scope_key == scope_key,
+ ClientServiceSubscription.financial_year == selected_financial_year,
+ ClientServiceSubscription.period_label == requested_period,
+ )).scalar_one_or_none()
if existing:
if existing.service_plan_id is None:
existing.service_plan_id = plan.id
@@ -507,57 +484,37 @@ def subscription_bulk_submit(
continue
engagement = ClientServiceSubscription(
- tenant_id=tenant_id,
- branch_id=plan.branch_id or getattr(client, "branch_id", None),
- service_plan_id=plan.id,
- client_id=client.id,
- service_catalogue_id=service_catalogue_id,
+ tenant_id=tenant_id, branch_id=plan.branch_id, service_plan_id=plan.id,
+ client_id=client.id, scope_type=scope_type, scope_key=scope_key,
+ business_unit_id=business_unit_id, client_branch_id=client_branch_id,
+ registration_id=registration_id, service_catalogue_id=service_catalogue_id,
firm_service_selection_id=firm_selection.id,
assigned_partner_user_id=default_partner_user_id,
performing_partner_user_id=performing_partner_id,
- assigned_manager_user_id=manager_id,
- assigned_staff_user_id=staff_id,
+ assigned_manager_user_id=manager_id, assigned_staff_user_id=staff_id,
review_partner_user_id=review_partner_id if review_required else None,
- financial_year=selected_financial_year,
- period_label=requested_period,
+ financial_year=selected_financial_year, period_label=requested_period,
assessment_year=assessment_year_from_financial_year(selected_financial_year),
- engagement_type=engagement_type,
- start_date=plan_effective_from,
- end_date=plan_effective_to,
- status="active",
- remarks=remarks.strip() or None,
- is_active=True,
- created_by_user_id=user.id,
- updated_by_user_id=user.id,
+ engagement_type=engagement_type, start_date=plan_effective_from,
+ end_date=plan_effective_to, status="active", remarks=remarks.strip() or None,
+ is_active=True, created_by_user_id=user.id, updated_by_user_id=user.id,
)
- db.add(engagement)
- db.flush()
+ db.add(engagement); db.flush()
attach_engagement_to_plan(
- db,
- engagement=engagement,
- client=client,
- catalogue=firm_selection.catalogue,
- firm_selection=firm_selection,
- actor_user_id=user.id,
+ db, engagement=engagement, client=client, catalogue=firm_selection.catalogue,
+ firm_selection=firm_selection, actor_user_id=user.id,
)
apply_due_date_rule_to_subscription(db, engagement, force=True)
- ensure_engagement_quality_workflow(
- db,
- subscription=engagement,
- actor_user_id=user.id,
- create_declarations=False,
- )
+ ensure_engagement_quality_workflow(db, subscription=engagement, actor_user_id=user.id, create_declarations=False)
enforce_quality_gate_on_subscription(engagement)
engagements_created += 1
db.commit()
return RedirectResponse(
"/services/subscriptions/bulk"
- f"?subscriptions_created={subscriptions_created}"
- f"&subscriptions_reused={subscriptions_reused}"
- f"&engagements_created={engagements_created}"
- f"&engagements_skipped={engagements_skipped}",
- status_code=303,
+ f"?subscriptions_created={subscriptions_created}&subscriptions_reused={subscriptions_reused}"
+ f"&engagements_created={engagements_created}&engagements_skipped={engagements_skipped}",
+ 303,
)
except Exception:
db.rollback()
diff --git a/app/modules/services/templates/services/catalogue_form.html b/app/modules/services/templates/services/catalogue_form.html
index a699d6d..1015ea9 100644
--- a/app/modules/services/templates/services/catalogue_form.html
+++ b/app/modules/services/templates/services/catalogue_form.html
@@ -9,6 +9,22 @@
Category Select category {% for cat in categories %}{{ cat.name }} {% endfor %}
Recurrence Type Select recurrence {% for value, label in recurrence_choices %}{{ label }} {% endfor %}
Engagement Type {% for value, label in engagement_type_choices %}{{ label }} {% endfor %} This value will be copied to client engagements when the service is assigned.
+ Service Scope
+
+ {% for value, label in [('client','Client / PAN level'),('business_unit','Business Unit level'),('client_branch','Client Branch level'),('registration','Registration level')] %}
+ {{ label }}
+ {% endfor %}
+
+
+ Required Registration Type
+
+ Not applicable
+ {% for value in ['GST','TAN','PF','ESI','PT','IEC','FSSAI','UDYAM','OTHER'] %}
+ {{ value }}
+ {% endfor %}
+
+
Used only for Registration-level services.
+
Sort Order
Active Client Requestable Consultant Requestable
Description
@@ -27,4 +43,12 @@
+
{% endblock %}
diff --git a/app/modules/services/templates/services/subscriptions/bulk.html b/app/modules/services/templates/services/subscriptions/bulk.html
index cd6ec06..82bcc8a 100644
--- a/app/modules/services/templates/services/subscriptions/bulk.html
+++ b/app/modules/services/templates/services/subscriptions/bulk.html
@@ -2,493 +2,161 @@
{% block content %}
{% set _uiux_partner_role_text = (current_user_roles or [])|join('|')|lower %}
- {% if 'partner' in _uiux_partner_role_text %}
- {% include "ui/templates/components/partner_navigation_v2.html" %}
- {% endif %}
+ {% if 'partner' in _uiux_partner_role_text %}{% include "ui/templates/components/partner_navigation_v2.html" %}{% endif %}
-
-
Bulk Subscription Setup
-
- Choose one enabled firm service, create the subscription for multiple clients, and optionally generate period-wise engagements.
-
-
-
- Back
-
+
Bulk Subscription Setup
+
The selected service determines whether you select Clients, Business Units, Client Branches or registrations.
+
Back
- {% if error_message %}
-
- {{ error_message }}
-
- {% endif %}
-
+ {% if error_message %}
{{ error_message }}
{% endif %}
{% if subscriptions_created or subscriptions_reused or engagements_created or engagements_skipped %}
-
-
Subscriptions Created
-
{{ subscriptions_created }}
-
-
-
Existing Reused
-
{{ subscriptions_reused }}
-
-
-
Engagements Created
-
{{ engagements_created }}
-
-
-
Duplicates Skipped
-
{{ engagements_skipped }}
-
-
- {% endif %}
+ {% for label,value in [('Subscriptions Created',subscriptions_created),('Existing Reused',subscriptions_reused),('Engagements Created',engagements_created),('Duplicates Skipped',engagements_skipped)] %}
+
+ {% endfor %}
+
{% endif %}