commit 5c75eb6bd9e7204eaabf2e2cc78a036e4034c1cc Author: A R R R Associates Date: Sat Jun 20 15:01:44 2026 +0530 Prepare ERP source for Gitea deployment diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..256a7f4 --- /dev/null +++ b/.env.example @@ -0,0 +1,50 @@ +APP_NAME="Audit Firm v2" +ENV="dev" +DEBUG=true + +# IMPORTANT: change in production +SECRET_KEY="change-me-to-a-long-random-string" + +# Cookie/security +COOKIE_SECURE=false +COOKIE_SAMESITE="lax" # lax|strict|none +COOKIE_SESSION_NAME="af2sid" + +# DB backend +DB_BACKEND="sqlite" # sqlite|postgres + +SQLITE_COMMON_PATH="./data/common.db" + +ERP_PUBLIC_BASE_URL=http://localhost:8000 +DEV_AUTH_OTP_PRINT=false + +# Postgres placeholders (later) +PG_HOST="127.0.0.1" +PG_PORT=5432 +PG_USER="postgres" +PG_PASSWORD="postgres" +PG_DB_COMMON="audit_common" + +# Defaults (context fallback) +DEFAULT_TENANT_CODE="default" +DEFAULT_BRANCH_CODE="main" +DEFAULT_YEAR_CODE="2025-26" +DEFAULT_TIMEZONE="Asia/Kolkata" + +# Security hardening: do not trust browser/client supplied context headers in public deployment. +# Keep false for production unless an internal proxy/test runner is explicitly trusted. +TRUST_CONTEXT_HEADERS=false +TRUST_CONTEXT_HEADER_HOSTS="127.0.0.1,localhost,::1" +# Optional shared secret for trusted internal callers. If set, caller must send +# X-AuditFirm-Context-Secret with this value before context headers are accepted. +CONTEXT_HEADER_SECRET="" + +# Bootstrap admin (seeded if users table is empty) +BOOTSTRAP_ADMIN_EMAIL="admin@auditfirm.local" +BOOTSTRAP_ADMIN_PASSWORD="ChangeMe@123" + +# JWT for API clients (mobile/apps/integrations) +JWT_ISSUER="audit_firm_v2" +JWT_AUDIENCE="audit_firm_clients" +JWT_ACCESS_MINUTES=15 +JWT_REFRESH_DAYS=30 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..028bf1d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,36 @@ +* text=auto + +# Keep source code and config as LF +*.py text eol=lf +*.html text eol=lf +*.css text eol=lf +*.js text eol=lf +*.json text eol=lf +*.md text eol=lf +*.txt text eol=lf +*.ini text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.toml text eol=lf +Dockerfile text eol=lf +.dockerignore text eol=lf +.gitignore text eol=lf +.env.example text eol=lf + +# Windows batch files should remain CRLF +*.bat text eol=crlf +*.cmd text eol=crlf + +# Binary files +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.pdf binary +*.docx binary +*.xlsx binary +*.zip binary +*.db binary +*.sqlite binary +*.sqlite3 binary \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e2f0bc4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.env +__pycache__/ +*.pyc +data/*.db +data/*.sqlite3 +.pytest_cache/ +venv/ diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..9fd4db0 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,36 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +sqlalchemy.url = sqlite:///./data/common.db + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = console +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s diff --git a/alembic/README.md b/alembic/README.md new file mode 100644 index 0000000..8cd0db1 --- /dev/null +++ b/alembic/README.md @@ -0,0 +1,10 @@ +Alembic integration for the Common DB baseline. + +Create migration: + alembic revision --autogenerate -m "message" + +Apply migrations: + alembic upgrade head + +Downgrade: + alembic downgrade -1 diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..51ad783 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool +from sqlalchemy.engine import URL + +from app.core.settings import get_settings +from app.core.db.common import CommonBase + +# ----------------------------------------------------------------------------- +# IMPORTANT FOR ALEMBIC AUTOGENERATE +# ----------------------------------------------------------------------------- +# Alembic only sees tables that are registered in CommonBase.metadata. +# A model is registered only after its module is imported. Keep every SQLAlchemy +# model module here so a fresh baseline migration can generate all tables. +# Do not remove these imports even if they look unused. +# ----------------------------------------------------------------------------- + +# Core / tenancy models +from app.modules.core.tenancy import models as tenancy_models # noqa: F401 +from app.modules.core.tenancy import settings_models as tenancy_settings_models # noqa: F401 + +# IAM / RBAC / token / password-flow models +from app.modules.core.iam import models as iam_models # noqa: F401 +from app.modules.core.iam import tokens_models as iam_tokens_models # noqa: F401 +from app.modules.core.iam import password_flows_models as iam_password_flows_models # noqa: F401 +from app.modules.core.rbac import models as rbac_models # noqa: F401 + +# Audit log models +from app.modules.core.audit import models as audit_models # noqa: F401 + +# Business module models +from app.modules.clients import models as client_models # noqa: F401 +from app.modules.clients import association_models as client_association_models # noqa: F401 +from app.modules.services import models as service_models # noqa: F401 +from app.modules.consultants import models as consultant_models # noqa: F401 +from app.modules.employees import models as employee_models # noqa: F401 +from app.modules.billing import models as billing_models # noqa: F401 +from app.modules.platform_billing import models as platform_billing_models # noqa: F401 +from app.modules.marketplace import models as marketplace_models # noqa: F401 +from app.modules.documents import models as documents_models # noqa: F401 +from app.modules.alerts import models as alerts_models # noqa: F401 +from app.modules.email_integration import models as email_integration_models # noqa: F401 +from app.modules.domain_management import models as domain_management_models # noqa: F401 +from app.modules.notice_cases import models as notice_cases_models # noqa: F401 + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = CommonBase.metadata + + +def get_url() -> str: + s = get_settings() + if (s.DB_BACKEND or "sqlite").lower() == "sqlite": + return f"sqlite:///{s.SQLITE_COMMON_PATH}" + return URL.create( + drivername="postgresql+psycopg", + username=s.PG_USER, + password=s.PG_PASSWORD, + host=s.PG_HOST, + port=s.PG_PORT, + database=s.PG_DB_COMMON, + ).render_as_string(hide_password=False) + + +def run_migrations_offline() -> None: + context.configure( + url=get_url(), + target_metadata=target_metadata, + literal_binds=True, + compare_type=True, + compare_server_default=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + configuration = config.get_section(config.config_ini_section) or {} + configuration["sqlalchemy.url"] = get_url() + connectable = engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + future=True, + ) + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + compare_server_default=True, + render_as_batch=("sqlite" in get_url()), + ) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..2fe7e83 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,23 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/20260414_release_a_clients.py b/alembic/versions/20260414_release_a_clients.py new file mode 100644 index 0000000..8a0a9dc --- /dev/null +++ b/alembic/versions/20260414_release_a_clients.py @@ -0,0 +1,50 @@ +"""release_a_clients_engagement_mode + +Revision ID: 20260414_release_a_clients +Revises: 7f1c9b2d4a10 +Create Date: 2026-04-14 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "20260414_release_a_clients" +down_revision = "7f1c9b2d4a10" +branch_labels = None +depends_on = None + + +def upgrade(): + # engagement_mode column already exists from the earlier partial migration, + # so do NOT add it again. + + # Make partner_id nullable in a SQLite-safe way + with op.batch_alter_table("clients") as batch_op: + batch_op.alter_column( + "partner_id", + existing_type=sa.Integer(), + nullable=True, + ) + + # Remove server default from engagement_mode if it exists + with op.batch_alter_table("clients") as batch_op: + batch_op.alter_column( + "engagement_mode", + existing_type=sa.String(length=32), + server_default=None, + ) + + +def downgrade(): + # Revert partner_id back to NOT NULL + with op.batch_alter_table("clients") as batch_op: + batch_op.alter_column( + "partner_id", + existing_type=sa.Integer(), + nullable=False, + ) + + # Drop engagement_mode + with op.batch_alter_table("clients") as batch_op: + batch_op.drop_column("engagement_mode") \ No newline at end of file diff --git a/alembic/versions/20260415_release_b1_b2.py b/alembic/versions/20260415_release_b1_b2.py new file mode 100644 index 0000000..ed8fe39 --- /dev/null +++ b/alembic/versions/20260415_release_b1_b2.py @@ -0,0 +1,21 @@ + +from alembic import op +import sqlalchemy as sa + +revision = "20260415_release_b1_b2" +down_revision = "20260414_release_a_clients" + +def upgrade(): + op.create_table( + "client_associations", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("client_id", sa.Integer(), nullable=False), + sa.Column("association_type", sa.String(50), nullable=False), + sa.Column("firm_tenant_id", sa.Integer(), nullable=True), + sa.Column("partner_user_id", sa.Integer(), nullable=True), + sa.Column("consultant_id", sa.Integer(), nullable=True), + sa.Column("created_source", sa.String(50), nullable=False), + ) + +def downgrade(): + op.drop_table("client_associations") diff --git a/alembic/versions/20260416_release_b6_cleanup.py b/alembic/versions/20260416_release_b6_cleanup.py new file mode 100644 index 0000000..b16cff3 --- /dev/null +++ b/alembic/versions/20260416_release_b6_cleanup.py @@ -0,0 +1,73 @@ +from alembic import op +import sqlalchemy as sa + +revision = "20260416_release_b6_cleanup" +down_revision = "20260415_release_b1_b2" +branch_labels = None +depends_on = None + + +def upgrade(): + conn = op.get_bind() + + rows = conn.execute( + sa.text( + """ + SELECT c.id, c.tenant_id, c.branch_id, c.partner_id + FROM clients c + LEFT JOIN client_associations a + ON a.client_id = c.id + WHERE a.id IS NULL + """ + ) + ).fetchall() + + for row in rows: + association_type = "firm" if row.tenant_id else "self_service_unassigned" + created_source = "system_admin" if row.tenant_id else "self_service" + + conn.execute( + sa.text( + """ + INSERT INTO client_associations + ( + client_id, + association_type, + firm_tenant_id, + consultant_id, + partner_user_id, + created_source + ) + VALUES + ( + :client_id, + :association_type, + :firm_tenant_id, + :consultant_id, + :partner_user_id, + :created_source + ) + """ + ), + { + "client_id": row.id, + "association_type": association_type, + "firm_tenant_id": row.tenant_id, + "consultant_id": None, + "partner_user_id": row.partner_id, + "created_source": created_source, + }, + ) + + +def downgrade(): + conn = op.get_bind() + + conn.execute( + sa.text( + """ + DELETE FROM client_associations + WHERE created_source IN ('system_admin', 'self_service') + """ + ) + ) \ No newline at end of file diff --git a/alembic/versions/20260419_client_portal_user_link.py b/alembic/versions/20260419_client_portal_user_link.py new file mode 100644 index 0000000..b07c838 --- /dev/null +++ b/alembic/versions/20260419_client_portal_user_link.py @@ -0,0 +1,21 @@ +from alembic import op +import sqlalchemy as sa + +revision = "20260419_client_portal_user_link" +down_revision = "20260416_release_b6_cleanup" +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table("clients") as batch_op: + batch_op.add_column(sa.Column("portal_user_id", sa.Integer(), nullable=True)) + batch_op.create_index("ix_clients_portal_user_id", ["portal_user_id"], unique=False) + batch_op.create_foreign_key("fk_clients_portal_user_id_users", "users", ["portal_user_id"], ["id"]) + + +def downgrade(): + with op.batch_alter_table("clients") as batch_op: + batch_op.drop_constraint("fk_clients_portal_user_id_users", type_="foreignkey") + batch_op.drop_index("ix_clients_portal_user_id") + batch_op.drop_column("portal_user_id") diff --git a/alembic/versions/20260420_services_catalogue_and_firm_templates.py b/alembic/versions/20260420_services_catalogue_and_firm_templates.py new file mode 100644 index 0000000..6fadacb --- /dev/null +++ b/alembic/versions/20260420_services_catalogue_and_firm_templates.py @@ -0,0 +1,164 @@ +"""split services into system catalogue and firm task templates + +Revision ID: 20260420_services_catalogue_and_firm_templates +Revises: 20260419_client_portal_user_link +Create Date: 2026-04-20 18:00:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "20260420_services_catalogue_and_firm_templates" +down_revision: Union[str, None] = "20260419_client_portal_user_link" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _inspector(): + return sa.inspect(op.get_bind()) + + +def _has_table(name: str) -> bool: + return name in _inspector().get_table_names() + + +def _has_index(table_name: str, index_name: str) -> bool: + try: + indexes = _inspector().get_indexes(table_name) + except Exception: + return False + return any(ix.get("name") == index_name for ix in indexes) + + +def _create_index_if_missing(table_name: str, index_name: str, columns: list[str], unique: bool = False) -> None: + if not _has_index(table_name, index_name): + op.create_index(index_name, table_name, columns, unique=unique) + + +def upgrade() -> None: + if not _has_table("service_catalogues"): + op.create_table( + "service_catalogues", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False), + sa.Column("service_code", sa.String(length=50), nullable=False), + sa.Column("service_name", sa.String(length=200), nullable=False), + sa.Column("category", sa.String(length=100), nullable=True), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("recurrence_type", sa.String(length=50), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("is_client_requestable", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("is_consultant_requestable", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("created_by_user_id", sa.Integer(), nullable=True), + sa.Column("updated_by_user_id", sa.Integer(), nullable=True), + sa.Column("created_at_utc", sa.DateTime(), nullable=False), + sa.Column("updated_at_utc", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]), + sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"]), + sa.UniqueConstraint("service_code", name="uq_service_catalogues_code"), + ) + _create_index_if_missing("service_catalogues", "ix_service_catalogues_service_code", ["service_code"]) + _create_index_if_missing("service_catalogues", "ix_service_catalogues_service_name", ["service_name"]) + _create_index_if_missing("service_catalogues", "ix_service_catalogues_category", ["category"]) + + if not _has_table("firm_service_task_templates"): + op.create_table( + "firm_service_task_templates", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("branch_id", sa.Integer(), nullable=True), + sa.Column("service_catalogue_id", sa.Integer(), nullable=False), + sa.Column("task_name", sa.String(length=200), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("sequence_no", sa.Integer(), nullable=False), + sa.Column("default_role_name", sa.String(length=100), nullable=True), + sa.Column("sla_days", sa.Integer(), nullable=True), + sa.Column("is_mandatory", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("requires_review", 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(), nullable=True), + sa.Column("updated_by_user_id", sa.Integer(), nullable=True), + sa.Column("created_at_utc", sa.DateTime(), nullable=False), + sa.Column("updated_at_utc", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"]), + sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]), + sa.ForeignKeyConstraint(["service_catalogue_id"], ["service_catalogues.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]), + sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"]), + sa.UniqueConstraint("tenant_id", "service_catalogue_id", "sequence_no", name="uq_firm_service_task_sequence"), + ) + _create_index_if_missing("firm_service_task_templates", "ix_firm_service_task_templates_tenant_id", ["tenant_id"]) + _create_index_if_missing("firm_service_task_templates", "ix_firm_service_task_templates_branch_id", ["branch_id"]) + _create_index_if_missing("firm_service_task_templates", "ix_firm_service_task_templates_service_catalogue_id", ["service_catalogue_id"]) + + bind = op.get_bind() + if _has_table("services"): + bind.execute(sa.text( + """ + INSERT INTO service_catalogues ( + service_code, service_name, category, description, recurrence_type, + is_active, is_client_requestable, is_consultant_requestable, + created_by_user_id, updated_by_user_id, created_at_utc, updated_at_utc + ) + SELECT + s.service_code, + MIN(s.service_name) AS service_name, + MIN(s.category) AS category, + MIN(s.description) AS description, + NULL AS recurrence_type, + MAX(CASE WHEN s.is_active THEN 1 ELSE 0 END) AS is_active, + MAX(CASE WHEN s.is_client_requestable THEN 1 ELSE 0 END) AS is_client_requestable, + MAX(CASE WHEN s.is_consultant_requestable THEN 1 ELSE 0 END) AS is_consultant_requestable, + MIN(s.created_by_user_id) AS created_by_user_id, + MIN(s.updated_by_user_id) AS updated_by_user_id, + MIN(s.created_at_utc) AS created_at_utc, + MAX(s.updated_at_utc) AS updated_at_utc + FROM services s + WHERE NOT EXISTS ( + SELECT 1 FROM service_catalogues sc WHERE sc.service_code = s.service_code + ) + GROUP BY s.service_code + """ + )) + + if _has_table("services") and _has_table("service_task_templates"): + bind.execute(sa.text( + """ + INSERT INTO firm_service_task_templates ( + tenant_id, branch_id, service_catalogue_id, + task_name, description, sequence_no, default_role_name, sla_days, + is_mandatory, requires_review, is_active, + created_by_user_id, updated_by_user_id, created_at_utc, updated_at_utc + ) + SELECT + s.tenant_id, + s.branch_id, + sc.id, + st.task_name, + st.description, + st.sequence_no, + st.default_role_name, + NULL AS sla_days, + st.is_mandatory, + st.requires_review, + st.is_active, + st.created_by_user_id, + st.updated_by_user_id, + st.created_at_utc, + st.updated_at_utc + FROM service_task_templates st + JOIN services s ON s.id = st.service_id + JOIN service_catalogues sc ON sc.service_code = s.service_code + WHERE NOT EXISTS ( + SELECT 1 FROM firm_service_task_templates ft + WHERE ft.tenant_id = s.tenant_id + AND ft.service_catalogue_id = sc.id + AND ft.sequence_no = st.sequence_no + ) + """ + )) + + +def downgrade() -> None: + pass diff --git a/alembic/versions/20260420_services_catalogue_firm_toggle.py b/alembic/versions/20260420_services_catalogue_firm_toggle.py new file mode 100644 index 0000000..35a9b9d --- /dev/null +++ b/alembic/versions/20260420_services_catalogue_firm_toggle.py @@ -0,0 +1,108 @@ +from alembic import op +import sqlalchemy as sa + +revision = "20260420_services_catalogue_firm_toggle" +down_revision = "20260420_services_catalogue_and_firm_templates" +branch_labels = None +depends_on = None + + +def _inspector(): + return sa.inspect(op.get_bind()) + + +def _has_table(name: str) -> bool: + return name in _inspector().get_table_names() + + +def _has_index(table_name: str, index_name: str) -> bool: + try: + indexes = _inspector().get_indexes(table_name) + except Exception: + return False + return any(ix.get("name") == index_name for ix in indexes) + + +def _create_index_if_missing(table_name: str, index_name: str, columns, unique: bool = False) -> None: + if not _has_index(table_name, index_name): + op.create_index(index_name, table_name, columns, unique=unique) + + +def upgrade(): + if not _has_table("firm_service_selections"): + op.create_table( + "firm_service_selections", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("service_catalogue_id", sa.Integer(), nullable=False), + sa.Column("is_enabled", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("default_branch_id", sa.Integer(), nullable=True), + sa.Column("activated_by_user_id", sa.Integer(), nullable=True), + sa.Column("updated_by_user_id", sa.Integer(), nullable=True), + sa.Column("activated_at_utc", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")), + sa.Column("updated_at_utc", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["service_catalogue_id"], ["service_catalogues.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["default_branch_id"], ["branches.id"]), + sa.ForeignKeyConstraint(["activated_by_user_id"], ["users.id"]), + sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"]), + sa.UniqueConstraint("tenant_id", "service_catalogue_id", name="uq_firm_service_selections_tenant_catalogue"), + ) + + _create_index_if_missing( + "firm_service_selections", + "ix_firm_service_selections_tenant_id", + ["tenant_id"], + ) + _create_index_if_missing( + "firm_service_selections", + "ix_firm_service_selections_service_catalogue_id", + ["service_catalogue_id"], + ) + _create_index_if_missing( + "firm_service_selections", + "ix_firm_service_selections_default_branch_id", + ["default_branch_id"], + ) + + conn = op.get_bind() + + if _has_table("services") and _has_table("service_catalogues"): + conn.execute(sa.text( + """ + INSERT INTO firm_service_selections ( + tenant_id, + service_catalogue_id, + is_enabled, + default_branch_id, + activated_by_user_id, + updated_by_user_id, + activated_at_utc, + updated_at_utc + ) + SELECT + s.tenant_id, + sc.id, + MAX(CASE WHEN s.is_active THEN 1 ELSE 0 END) AS is_enabled, + MIN(s.branch_id) AS default_branch_id, + MIN(s.created_by_user_id) AS activated_by_user_id, + MIN(s.updated_by_user_id) AS updated_by_user_id, + MIN(s.created_at_utc) AS activated_at_utc, + MAX(s.updated_at_utc) AS updated_at_utc + FROM services s + JOIN service_catalogues sc + ON sc.service_code = s.service_code + WHERE NOT EXISTS ( + SELECT 1 + FROM firm_service_selections fss + WHERE fss.tenant_id = s.tenant_id + AND fss.service_catalogue_id = sc.id + ) + GROUP BY s.tenant_id, sc.id + """ + )) + + +def downgrade(): + if _has_table("firm_service_selections"): + op.drop_table("firm_service_selections") \ No newline at end of file diff --git a/alembic/versions/20260422_services_phase_s1.py b/alembic/versions/20260422_services_phase_s1.py new file mode 100644 index 0000000..3784882 --- /dev/null +++ b/alembic/versions/20260422_services_phase_s1.py @@ -0,0 +1,226 @@ +"""services phase s1: categories, recurrence, sort order, applicability flags + +Revision ID: 20260422_services_phase_s1 +Revises: 20260420_services_catalogue_firm_toggle +Create Date: 2026-04-22 20:00:00.000000 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "20260422_services_phase_s1" +down_revision = "20260420_services_catalogue_firm_toggle" +branch_labels = None +depends_on = None + + +def _inspector(): + return sa.inspect(op.get_bind()) + + +def _has_table(name: str) -> bool: + return name in _inspector().get_table_names() + + +def _has_column(table_name: str, column_name: str) -> bool: + try: + cols = _inspector().get_columns(table_name) + except Exception: + return False + return any(col.get("name") == column_name for col in cols) + + +def _has_index(table_name: str, index_name: str) -> bool: + try: + indexes = _inspector().get_indexes(table_name) + except Exception: + return False + return any(ix.get("name") == index_name for ix in indexes) + + +def _create_index_if_missing(table_name: str, index_name: str, columns, unique: bool = False) -> None: + if not _has_index(table_name, index_name): + op.create_index(index_name, table_name, columns, unique=unique) + + +def _normalize_code(name: str) -> str: + text = (name or "").strip().upper() + out = [] + last_sep = False + for ch in text: + if ch.isalnum(): + out.append(ch) + last_sep = False + else: + if not last_sep: + out.append("_") + last_sep = True + code = "".join(out).strip("_") + return code or "UNCATEGORIZED" + + +def upgrade(): + conn = op.get_bind() + + if not _has_table("service_categories"): + op.create_table( + "service_categories", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False), + sa.Column("code", sa.String(length=50), nullable=True), + sa.Column("name", sa.String(length=100), nullable=True), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + ) + + if _has_table("service_categories"): + if not _has_column("service_categories", "code"): + op.add_column("service_categories", sa.Column("code", sa.String(length=50), nullable=True)) + if not _has_column("service_categories", "name"): + op.add_column("service_categories", sa.Column("name", sa.String(length=100), nullable=True)) + if not _has_column("service_categories", "sort_order"): + op.add_column("service_categories", sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0")) + if not _has_column("service_categories", "is_active"): + op.add_column("service_categories", sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true())) + + conn.execute(sa.text( + "UPDATE service_categories SET name = COALESCE(name, 'Category ' || id) WHERE name IS NULL" + )) + + rows = conn.execute(sa.text( + "SELECT id, name FROM service_categories WHERE (code IS NULL OR TRIM(code) = '')" + )).fetchall() + used_codes = set() + existing_code_rows = conn.execute(sa.text( + "SELECT code FROM service_categories WHERE code IS NOT NULL AND TRIM(code) <> ''" + )).fetchall() + for row in existing_code_rows: + used_codes.add(row[0]) + + for row in rows: + category_id = row[0] + name = row[1] or f"Category {category_id}" + base_code = _normalize_code(name) + code = base_code + n = 2 + while code in used_codes: + code = f"{base_code}_{n}" + n += 1 + used_codes.add(code) + conn.execute( + sa.text("UPDATE service_categories SET code = :code, name = COALESCE(name, :name) WHERE id = :id"), + {"code": code, "name": name, "id": category_id}, + ) + + conn.execute(sa.text("UPDATE service_categories SET sort_order = 0 WHERE sort_order IS NULL")) + conn.execute(sa.text("UPDATE service_categories SET is_active = TRUE WHERE is_active IS NULL")) + + _create_index_if_missing("service_categories", "ix_service_categories_code", ["code"]) + _create_index_if_missing("service_categories", "ix_service_categories_name", ["name"]) + _create_index_if_missing("service_categories", "ix_service_categories_sort_order", ["sort_order"]) + + if _has_table("service_catalogues"): + additions = [ + ("category_id", sa.Integer(), True, None), + ("recurrence_type", sa.String(length=50), True, None), + ("sort_order", sa.Integer(), False, "0"), + ("applicable_individual", sa.Boolean(), False, sa.false()), + ("applicable_proprietorship", sa.Boolean(), False, sa.false()), + ("applicable_partnership", sa.Boolean(), False, sa.false()), + ("applicable_llp", sa.Boolean(), False, sa.false()), + ("applicable_company", sa.Boolean(), False, sa.false()), + ("applicable_trust", sa.Boolean(), False, sa.false()), + ("applicable_society", sa.Boolean(), False, sa.false()), + ] + + for name, typ, nullable, default in additions: + if not _has_column("service_catalogues", name): + kwargs = {"nullable": nullable} + if default is not None: + kwargs["server_default"] = default + op.add_column("service_catalogues", sa.Column(name, typ, **kwargs)) + + conn.execute(sa.text("UPDATE service_catalogues SET sort_order = 0 WHERE sort_order IS NULL")) + for flag in [ + "applicable_individual", + "applicable_proprietorship", + "applicable_partnership", + "applicable_llp", + "applicable_company", + "applicable_trust", + "applicable_society", + ]: + if _has_column("service_catalogues", flag): + conn.execute(sa.text(f"UPDATE service_catalogues SET {flag} = FALSE WHERE {flag} IS NULL")) + + _create_index_if_missing("service_catalogues", "ix_service_catalogues_category_id", ["category_id"]) + _create_index_if_missing("service_catalogues", "ix_service_catalogues_recurrence_type", ["recurrence_type"]) + _create_index_if_missing("service_catalogues", "ix_service_catalogues_sort_order", ["sort_order"]) + + if _has_column("service_catalogues", "category"): + rows = conn.execute(sa.text( + """ + SELECT DISTINCT TRIM(category) AS category + FROM service_catalogues + WHERE category IS NOT NULL AND TRIM(category) <> '' + ORDER BY TRIM(category) + """ + )).fetchall() + + next_sort = conn.execute(sa.text("SELECT COALESCE(MAX(sort_order), 0) FROM service_categories")).scalar() or 0 + + for row in rows: + category_name = row[0] + existing = conn.execute( + sa.text("SELECT id FROM service_categories WHERE name = :name ORDER BY id LIMIT 1"), + {"name": category_name}, + ).fetchone() + if not existing: + base_code = _normalize_code(category_name) + code = base_code + n = 2 + while conn.execute( + sa.text("SELECT 1 FROM service_categories WHERE code = :code"), + {"code": code}, + ).fetchone(): + code = f"{base_code}_{n}" + n += 1 + next_sort += 1 + conn.execute( + sa.text( + """ + INSERT INTO service_categories (code, name, sort_order, is_active) + VALUES (:code, :name, :sort_order, TRUE) + """ + ), + {"code": code, "name": category_name, "sort_order": next_sort}, + ) + + if _has_column("service_catalogues", "category_id"): + rows = conn.execute(sa.text( + """ + SELECT id, category + FROM service_catalogues + WHERE category_id IS NULL + AND category IS NOT NULL + AND TRIM(category) <> '' + """ + )).fetchall() + + for row in rows: + catalogue_id = row[0] + category_name = row[1].strip() + match = conn.execute( + sa.text("SELECT id FROM service_categories WHERE name = :name ORDER BY id LIMIT 1"), + {"name": category_name}, + ).fetchone() + if match: + conn.execute( + sa.text("UPDATE service_catalogues SET category_id = :category_id WHERE id = :catalogue_id"), + {"category_id": match[0], "catalogue_id": catalogue_id}, + ) + + +def downgrade(): + pass diff --git a/alembic/versions/20260423_services_phase_s2_default_templates.py b/alembic/versions/20260423_services_phase_s2_default_templates.py new file mode 100644 index 0000000..fd55105 --- /dev/null +++ b/alembic/versions/20260423_services_phase_s2_default_templates.py @@ -0,0 +1,55 @@ +"""services phase s2: default task templates + +Revision ID: 20260423_services_phase_s2_default_templates +Revises: 20260422_services_phase_s1 +Create Date: 2026-04-23 19:30:00.000000 +""" + +from alembic import op +import sqlalchemy as sa + +revision = "20260423_services_phase_s2_default_templates" +down_revision = "20260422_services_phase_s1" +branch_labels = None +depends_on = None + + +def _inspector(): + return sa.inspect(op.get_bind()) + + +def _has_table(name: str) -> bool: + return name in _inspector().get_table_names() + + +def _has_index(table_name: str, index_name: str) -> bool: + try: + indexes = _inspector().get_indexes(table_name) + except Exception: + return False + return any(ix.get("name") == index_name for ix in indexes) + + +def upgrade(): + if not _has_table("service_default_task_templates"): + op.create_table( + "service_default_task_templates", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False), + sa.Column("service_catalogue_id", sa.Integer(), nullable=False), + sa.Column("task_name", sa.String(length=200), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("sequence_no", sa.Integer(), nullable=False, server_default="1"), + sa.Column("default_role_name", sa.String(length=100), nullable=True), + sa.Column("is_mandatory", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("requires_review", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.ForeignKeyConstraint(["service_catalogue_id"], ["service_catalogues.id"], ondelete="CASCADE"), + sa.UniqueConstraint("service_catalogue_id", "sequence_no", name="uq_service_default_task_templates_sequence"), + ) + if not _has_index("service_default_task_templates", "ix_service_default_task_templates_service_catalogue_id"): + op.create_index("ix_service_default_task_templates_service_catalogue_id", "service_default_task_templates", ["service_catalogue_id"], unique=False) + + +def downgrade(): + if _has_table("service_default_task_templates"): + op.drop_table("service_default_task_templates") diff --git a/alembic/versions/20260424_partner_service_restrictions.py b/alembic/versions/20260424_partner_service_restrictions.py new file mode 100644 index 0000000..2ea9eef --- /dev/null +++ b/alembic/versions/20260424_partner_service_restrictions.py @@ -0,0 +1,77 @@ +"""restrict partner service management permissions + +Revision ID: 20260424_partner_service_restrictions +Revises: 20260423_services_phase_s2_default_templates +Create Date: 2026-04-24 20:00:00.000000 +""" + +from alembic import op +import sqlalchemy as sa + +revision = "20260424_partner_service_restrictions" +down_revision = "20260423_services_phase_s2_default_templates" +branch_labels = None +depends_on = None + +PARTNER_REMOVE_PERMISSIONS = ( + "services.create", + "services.edit", + "services.deactivate", + "services.cross_branch", + "services.import", + "services.selection.manage", + "service_tasks.create", + "service_tasks.edit", + "service_tasks.import", + "service_tasks.deactivate", +) + + +def upgrade(): + conn = op.get_bind() + conn.execute( + sa.text( + """ + DELETE FROM role_permissions + WHERE role_id IN (SELECT id FROM roles WHERE name = :role_name) + AND permission_id IN (SELECT id FROM permissions WHERE code IN :codes) + """ + ).bindparams(sa.bindparam("codes", expanding=True)), + {"role_name": "Partner", "codes": PARTNER_REMOVE_PERMISSIONS}, + ) + + +def downgrade(): + conn = op.get_bind() + rows = conn.execute( + sa.text( + """ + SELECT r.id AS role_id, p.id AS permission_id + FROM roles r + JOIN permissions p ON p.code IN :codes + WHERE r.name = :role_name + """ + ).bindparams(sa.bindparam("codes", expanding=True)), + {"role_name": "Partner", "codes": PARTNER_REMOVE_PERMISSIONS}, + ).fetchall() + + for row in rows: + exists = conn.execute( + sa.text( + """ + SELECT 1 FROM role_permissions + WHERE role_id = :role_id AND permission_id = :permission_id + """ + ), + {"role_id": row.role_id, "permission_id": row.permission_id}, + ).fetchone() + if not exists: + conn.execute( + sa.text( + """ + INSERT INTO role_permissions (role_id, permission_id) + VALUES (:role_id, :permission_id) + """ + ), + {"role_id": row.role_id, "permission_id": row.permission_id}, + ) diff --git a/alembic/versions/20260425_client_service_subscriptions.py b/alembic/versions/20260425_client_service_subscriptions.py new file mode 100644 index 0000000..083656a --- /dev/null +++ b/alembic/versions/20260425_client_service_subscriptions.py @@ -0,0 +1,87 @@ +"""client service subscriptions and lightweight assignment + +Revision ID: 20260425_client_service_subscriptions +Revises: 20260424_partner_service_restrictions +Create Date: 2026-04-25 20:00:00.000000 +""" + +from alembic import op +import sqlalchemy as sa + +revision = "20260425_client_service_subscriptions" +down_revision = "20260424_partner_service_restrictions" +branch_labels = None +depends_on = None + + +def _inspector(): + return sa.inspect(op.get_bind()) + + +def _has_table(name: str) -> bool: + return name in _inspector().get_table_names() + + +def _has_index(table_name: str, index_name: str) -> bool: + try: + indexes = _inspector().get_indexes(table_name) + except Exception: + return False + return any(ix.get("name") == index_name for ix in indexes) + + +def _create_index_if_missing(table_name: str, index_name: str, columns, unique: bool = False) -> None: + if not _has_index(table_name, index_name): + op.create_index(index_name, table_name, columns, unique=unique) + + +def upgrade(): + if not _has_table("client_service_subscriptions"): + op.create_table( + "client_service_subscriptions", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("branch_id", sa.Integer(), nullable=True), + sa.Column("client_id", sa.Integer(), nullable=False), + sa.Column("service_catalogue_id", sa.Integer(), nullable=False), + sa.Column("firm_service_selection_id", sa.Integer(), nullable=True), + sa.Column("assigned_partner_user_id", sa.Integer(), nullable=True), + sa.Column("assigned_manager_user_id", sa.Integer(), nullable=True), + sa.Column("assigned_staff_user_id", sa.Integer(), nullable=True), + sa.Column("start_date", sa.Date(), nullable=True), + sa.Column("end_date", sa.Date(), nullable=True), + sa.Column("status", sa.String(length=30), nullable=False, server_default="active"), + sa.Column("remarks", sa.Text(), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("created_by_user_id", sa.Integer(), nullable=True), + sa.Column("updated_by_user_id", sa.Integer(), nullable=True), + sa.Column("created_at_utc", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")), + sa.Column("updated_at_utc", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]), + sa.ForeignKeyConstraint(["client_id"], ["clients.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["service_catalogue_id"], ["service_catalogues.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["firm_service_selection_id"], ["firm_service_selections.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["assigned_partner_user_id"], ["users.id"]), + sa.ForeignKeyConstraint(["assigned_manager_user_id"], ["users.id"]), + sa.ForeignKeyConstraint(["assigned_staff_user_id"], ["users.id"]), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]), + sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"]), + sa.UniqueConstraint("tenant_id", "client_id", "service_catalogue_id", name="uq_client_service_subscription_tenant_client_service"), + ) + + _create_index_if_missing("client_service_subscriptions", "ix_client_service_subscriptions_tenant_id", ["tenant_id"]) + _create_index_if_missing("client_service_subscriptions", "ix_client_service_subscriptions_branch_id", ["branch_id"]) + _create_index_if_missing("client_service_subscriptions", "ix_client_service_subscriptions_client_id", ["client_id"]) + _create_index_if_missing("client_service_subscriptions", "ix_client_service_subscriptions_service_catalogue_id", ["service_catalogue_id"]) + _create_index_if_missing("client_service_subscriptions", "ix_client_service_subscriptions_firm_service_selection_id", ["firm_service_selection_id"]) + _create_index_if_missing("client_service_subscriptions", "ix_client_service_subscriptions_assigned_partner_user_id", ["assigned_partner_user_id"]) + _create_index_if_missing("client_service_subscriptions", "ix_client_service_subscriptions_assigned_manager_user_id", ["assigned_manager_user_id"]) + _create_index_if_missing("client_service_subscriptions", "ix_client_service_subscriptions_assigned_staff_user_id", ["assigned_staff_user_id"]) + _create_index_if_missing("client_service_subscriptions", "ix_client_service_subscriptions_status", ["status"]) + _create_index_if_missing("client_service_subscriptions", "ix_client_service_subscriptions_is_active", ["is_active"]) + + +def downgrade(): + if _has_table("client_service_subscriptions"): + op.drop_table("client_service_subscriptions") diff --git a/alembic/versions/20260426_service_execution_tasks.py b/alembic/versions/20260426_service_execution_tasks.py new file mode 100644 index 0000000..9251318 --- /dev/null +++ b/alembic/versions/20260426_service_execution_tasks.py @@ -0,0 +1,88 @@ +"""S4.3/S4.4 service execution task instances + +Revision ID: 20260426_service_execution_tasks +Revises: 20260425_client_service_subscriptions +Create Date: 2026-04-26 20:00:00.000000 +""" + +from alembic import op +import sqlalchemy as sa + +revision = "20260426_service_execution_tasks" +down_revision = "20260425_client_service_subscriptions" +branch_labels = None +depends_on = None + + +def _inspector(): + return sa.inspect(op.get_bind()) + + +def _has_table(name: str) -> bool: + return name in _inspector().get_table_names() + + +def _has_index(table_name: str, index_name: str) -> bool: + try: + indexes = _inspector().get_indexes(table_name) + except Exception: + return False + return any(ix.get("name") == index_name for ix in indexes) + + +def _create_index_if_missing(table_name: str, index_name: str, columns, unique: bool = False): + if not _has_index(table_name, index_name): + op.create_index(index_name, table_name, columns, unique=unique) + + +def upgrade(): + if not _has_table("client_service_task_instances"): + op.create_table( + "client_service_task_instances", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("branch_id", sa.Integer(), nullable=True), + sa.Column("subscription_id", sa.Integer(), nullable=False), + sa.Column("client_id", sa.Integer(), nullable=False), + sa.Column("service_catalogue_id", sa.Integer(), nullable=False), + sa.Column("firm_task_template_id", sa.Integer(), nullable=True), + sa.Column("task_name", sa.String(length=200), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("sequence_no", sa.Integer(), nullable=False, server_default="1"), + sa.Column("default_role_name", sa.String(length=100), nullable=True), + sa.Column("assigned_to_user_id", sa.Integer(), nullable=True), + sa.Column("status", sa.String(length=30), nullable=False, server_default="pending"), + sa.Column("priority", sa.String(length=20), nullable=False, server_default="normal"), + sa.Column("remarks", sa.Text(), nullable=True), + sa.Column("started_at_utc", sa.DateTime(), nullable=True), + sa.Column("completed_at_utc", sa.DateTime(), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("created_by_user_id", sa.Integer(), nullable=True), + sa.Column("updated_by_user_id", sa.Integer(), nullable=True), + sa.Column("created_at_utc", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")), + sa.Column("updated_at_utc", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]), + sa.ForeignKeyConstraint(["subscription_id"], ["client_service_subscriptions.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["client_id"], ["clients.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["service_catalogue_id"], ["service_catalogues.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["firm_task_template_id"], ["firm_service_task_templates.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["assigned_to_user_id"], ["users.id"]), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]), + sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"]), + sa.UniqueConstraint("subscription_id", "firm_task_template_id", name="uq_client_service_task_subscription_template"), + ) + + _create_index_if_missing("client_service_task_instances", "ix_client_service_task_instances_tenant_id", ["tenant_id"]) + _create_index_if_missing("client_service_task_instances", "ix_client_service_task_instances_branch_id", ["branch_id"]) + _create_index_if_missing("client_service_task_instances", "ix_client_service_task_instances_subscription_id", ["subscription_id"]) + _create_index_if_missing("client_service_task_instances", "ix_client_service_task_instances_client_id", ["client_id"]) + _create_index_if_missing("client_service_task_instances", "ix_client_service_task_instances_service_catalogue_id", ["service_catalogue_id"]) + _create_index_if_missing("client_service_task_instances", "ix_client_service_task_instances_assigned_to_user_id", ["assigned_to_user_id"]) + _create_index_if_missing("client_service_task_instances", "ix_client_service_task_instances_status", ["status"]) + _create_index_if_missing("client_service_task_instances", "ix_client_service_task_instances_is_active", ["is_active"]) + + +def downgrade(): + if _has_table("client_service_task_instances"): + op.drop_table("client_service_task_instances") diff --git a/alembic/versions/20260429_engagement_year_locking.py b/alembic/versions/20260429_engagement_year_locking.py new file mode 100644 index 0000000..ac32b6f --- /dev/null +++ b/alembic/versions/20260429_engagement_year_locking.py @@ -0,0 +1,96 @@ +"""engagement year columns and locking + +Revision ID: 20260429_engagement_year_locking +Revises: 20260426_service_execution_tasks +Create Date: 2026-04-29 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260429_engagement_year_locking" +down_revision: Union[str, None] = "20260426_service_execution_tasks" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _has_column(table_name: str, column_name: str) -> bool: + bind = op.get_bind() + insp = sa.inspect(bind) + return any(c["name"] == column_name for c in insp.get_columns(table_name)) + + +def _has_index(table_name: str, index_name: str) -> bool: + bind = op.get_bind() + insp = sa.inspect(bind) + return any(ix.get("name") == index_name for ix in insp.get_indexes(table_name)) + + +def _create_index_if_missing(table_name: str, index_name: str, columns: list[str], unique: bool = False) -> None: + if not _has_index(table_name, index_name): + op.create_index(index_name, table_name, columns, unique=unique) + + +def upgrade() -> None: + if not _has_column("client_service_subscriptions", "financial_year"): + op.add_column("client_service_subscriptions", sa.Column("financial_year", sa.String(length=9), nullable=False, server_default="2025-26")) + if not _has_column("client_service_subscriptions", "assessment_year"): + op.add_column("client_service_subscriptions", sa.Column("assessment_year", sa.String(length=9), nullable=True)) + if not _has_column("client_service_subscriptions", "is_locked"): + op.add_column("client_service_subscriptions", sa.Column("is_locked", sa.Boolean(), nullable=False, server_default=sa.false())) + if not _has_column("client_service_subscriptions", "locked_at_utc"): + op.add_column("client_service_subscriptions", sa.Column("locked_at_utc", sa.DateTime(timezone=True), nullable=True)) + if not _has_column("client_service_subscriptions", "locked_by_user_id"): + op.add_column("client_service_subscriptions", sa.Column("locked_by_user_id", sa.Integer(), nullable=True)) + + _create_index_if_missing("client_service_subscriptions", "ix_client_service_subscriptions_financial_year", ["financial_year"]) + _create_index_if_missing("client_service_subscriptions", "ix_client_service_subscriptions_assessment_year", ["assessment_year"]) + _create_index_if_missing("client_service_subscriptions", "ix_client_service_subscriptions_is_locked", ["is_locked"]) + + if not _has_column("client_service_task_instances", "financial_year"): + op.add_column("client_service_task_instances", sa.Column("financial_year", sa.String(length=9), nullable=False, server_default="2025-26")) + if not _has_column("client_service_task_instances", "assessment_year"): + op.add_column("client_service_task_instances", sa.Column("assessment_year", sa.String(length=9), nullable=True)) + if not _has_column("client_service_task_instances", "is_locked"): + op.add_column("client_service_task_instances", sa.Column("is_locked", sa.Boolean(), nullable=False, server_default=sa.false())) + if not _has_column("client_service_task_instances", "locked_at_utc"): + op.add_column("client_service_task_instances", sa.Column("locked_at_utc", sa.DateTime(timezone=True), nullable=True)) + if not _has_column("client_service_task_instances", "locked_by_user_id"): + op.add_column("client_service_task_instances", sa.Column("locked_by_user_id", sa.Integer(), nullable=True)) + + _create_index_if_missing("client_service_task_instances", "ix_client_service_task_instances_financial_year", ["financial_year"]) + _create_index_if_missing("client_service_task_instances", "ix_client_service_task_instances_assessment_year", ["assessment_year"]) + _create_index_if_missing("client_service_task_instances", "ix_client_service_task_instances_is_locked", ["is_locked"]) + + # SQLite/PostgreSQL compatible replacement of uniqueness to include financial_year. + with op.batch_alter_table("client_service_subscriptions") as batch_op: + try: + batch_op.drop_constraint("uq_client_service_subscription_tenant_client_service", type_="unique") + except Exception: + pass + try: + batch_op.create_unique_constraint( + "uq_client_service_subscription_tenant_client_service_year", + ["tenant_id", "client_id", "service_catalogue_id", "financial_year"], + ) + except Exception: + pass + + with op.batch_alter_table("client_service_task_instances") as batch_op: + try: + batch_op.drop_constraint("uq_client_service_task_subscription_template", type_="unique") + except Exception: + pass + try: + batch_op.create_unique_constraint( + "uq_client_service_task_subscription_template_year", + ["subscription_id", "firm_task_template_id", "financial_year"], + ) + except Exception: + pass + + +def downgrade() -> None: + pass diff --git a/alembic/versions/20260501_services_engagement_type.py b/alembic/versions/20260501_services_engagement_type.py new file mode 100644 index 0000000..365d735 --- /dev/null +++ b/alembic/versions/20260501_services_engagement_type.py @@ -0,0 +1,76 @@ +"""add engagement type to services and engagements + +Revision ID: 20260501_services_engagement_type +Revises: 20260429_engagement_year_locking +Create Date: 2026-05-01 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260501_services_engagement_type" +down_revision: Union[str, None] = "20260429_engagement_year_locking" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _has_column(table_name: str, column_name: str) -> bool: + bind = op.get_bind() + insp = sa.inspect(bind) + return any(c["name"] == column_name for c in insp.get_columns(table_name)) + + +def _has_index(table_name: str, index_name: str) -> bool: + bind = op.get_bind() + insp = sa.inspect(bind) + return any(ix.get("name") == index_name for ix in insp.get_indexes(table_name)) + + +def _create_index_if_missing(table_name: str, index_name: str, columns: list[str]) -> None: + if not _has_index(table_name, index_name): + op.create_index(index_name, table_name, columns) + + +def upgrade() -> None: + if not _has_column("service_catalogues", "engagement_type"): + op.add_column( + "service_catalogues", + sa.Column("engagement_type", sa.String(length=20), nullable=False, server_default="non_audit"), + ) + + if not _has_column("client_service_subscriptions", "engagement_type"): + op.add_column( + "client_service_subscriptions", + sa.Column("engagement_type", sa.String(length=20), nullable=False, server_default="non_audit"), + ) + + # Backfill existing engagement records from their service catalogue classification. + bind = op.get_bind() + bind.execute(sa.text(""" + UPDATE client_service_subscriptions + SET engagement_type = COALESCE( + ( + SELECT service_catalogues.engagement_type + FROM service_catalogues + WHERE service_catalogues.id = client_service_subscriptions.service_catalogue_id + ), + 'non_audit' + ) + WHERE engagement_type IS NULL OR engagement_type = '' + """)) + + _create_index_if_missing("service_catalogues", "ix_service_catalogues_engagement_type", ["engagement_type"]) + _create_index_if_missing("client_service_subscriptions", "ix_client_service_subscriptions_engagement_type", ["engagement_type"]) + + +def downgrade() -> None: + if _has_index("client_service_subscriptions", "ix_client_service_subscriptions_engagement_type"): + op.drop_index("ix_client_service_subscriptions_engagement_type", table_name="client_service_subscriptions") + if _has_index("service_catalogues", "ix_service_catalogues_engagement_type"): + op.drop_index("ix_service_catalogues_engagement_type", table_name="service_catalogues") + if _has_column("client_service_subscriptions", "engagement_type"): + op.drop_column("client_service_subscriptions", "engagement_type") + if _has_column("service_catalogues", "engagement_type"): + op.drop_column("service_catalogues", "engagement_type") diff --git a/alembic/versions/20260502_tenant_firm_type_review_partner.py b/alembic/versions/20260502_tenant_firm_type_review_partner.py new file mode 100644 index 0000000..a349402 --- /dev/null +++ b/alembic/versions/20260502_tenant_firm_type_review_partner.py @@ -0,0 +1,65 @@ +"""tenant firm type and engagement review partner + +Revision ID: 20260502_tenant_firm_type_review_partner +Revises: 20260501_services_engagement_type +Create Date: 2026-05-02 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260502_tenant_firm_type_review_partner" +down_revision: Union[str, None] = "20260501_services_engagement_type" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _has_column(table_name: str, column_name: str) -> bool: + bind = op.get_bind() + insp = sa.inspect(bind) + return any(c["name"] == column_name for c in insp.get_columns(table_name)) + + +def _has_index(table_name: str, index_name: str) -> bool: + bind = op.get_bind() + insp = sa.inspect(bind) + return any(ix.get("name") == index_name for ix in insp.get_indexes(table_name)) + + +def _create_index_if_missing(table_name: str, index_name: str, columns: list[str]) -> None: + if not _has_index(table_name, index_name): + op.create_index(index_name, table_name, columns) + + +def upgrade() -> None: + if not _has_column("tenants", "firm_type"): + op.add_column( + "tenants", + sa.Column("firm_type", sa.String(length=30), nullable=False, server_default="proprietorship"), + ) + _create_index_if_missing("tenants", "ix_tenants_firm_type", ["firm_type"]) + + if not _has_column("client_service_subscriptions", "review_partner_user_id"): + op.add_column( + "client_service_subscriptions", + sa.Column("review_partner_user_id", sa.Integer(), nullable=True), + ) + _create_index_if_missing( + "client_service_subscriptions", + "ix_client_service_subscriptions_review_partner_user_id", + ["review_partner_user_id"], + ) + + +def downgrade() -> None: + if _has_index("client_service_subscriptions", "ix_client_service_subscriptions_review_partner_user_id"): + op.drop_index("ix_client_service_subscriptions_review_partner_user_id", table_name="client_service_subscriptions") + if _has_column("client_service_subscriptions", "review_partner_user_id"): + op.drop_column("client_service_subscriptions", "review_partner_user_id") + + if _has_index("tenants", "ix_tenants_firm_type"): + op.drop_index("ix_tenants_firm_type", table_name="tenants") + if _has_column("tenants", "firm_type"): + op.drop_column("tenants", "firm_type") diff --git a/alembic/versions/20260503_assurance_review_partner.py b/alembic/versions/20260503_assurance_review_partner.py new file mode 100644 index 0000000..4b339dd --- /dev/null +++ b/alembic/versions/20260503_assurance_review_partner.py @@ -0,0 +1,120 @@ +"""rename audit classification to assurance and add client-wise review partner + +Revision ID: 20260503_assurance_review_partner +Revises: 20260502_tenant_firm_type_review_partner +Create Date: 2026-05-03 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260503_assurance_review_partner" +down_revision: Union[str, None] = "20260502_tenant_firm_type_review_partner" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _has_column(table_name: str, column_name: str) -> bool: + bind = op.get_bind() + insp = sa.inspect(bind) + return any(c["name"] == column_name for c in insp.get_columns(table_name)) + + +def _has_index(table_name: str, index_name: str) -> bool: + bind = op.get_bind() + insp = sa.inspect(bind) + return any(ix.get("name") == index_name for ix in insp.get_indexes(table_name)) + + +def _create_index_if_missing(table_name: str, index_name: str, columns: list[str]) -> None: + if not _has_index(table_name, index_name): + op.create_index(index_name, table_name, columns) + + +def upgrade() -> None: + bind = op.get_bind() + + # Client master: optional default review partner used for partnership + assurance engagements. + if not _has_column("clients", "default_review_partner_user_id"): + op.add_column("clients", sa.Column("default_review_partner_user_id", sa.Integer(), nullable=True)) + _create_index_if_missing( + "clients", + "ix_clients_default_review_partner_user_id", + ["default_review_partner_user_id"], + ) + + # Safety only: normally this column is created by 20260502_tenant_firm_type_review_partner. + if not _has_column("client_service_subscriptions", "review_partner_user_id"): + op.add_column( + "client_service_subscriptions", + sa.Column("review_partner_user_id", sa.Integer(), nullable=True), + ) + _create_index_if_missing( + "client_service_subscriptions", + "ix_client_service_subscriptions_review_partner_user_id", + ["review_partner_user_id"], + ) + + # Convert old audit / non-audit values to final assurance / non-assurance values. + bind.execute(sa.text(""" + UPDATE service_catalogues + SET engagement_type = CASE + WHEN LOWER(COALESCE(engagement_type, '')) IN ('audit', 'aud', 'assurance') THEN 'assurance' + ELSE 'non_assurance' + END + """)) + + bind.execute(sa.text(""" + UPDATE client_service_subscriptions + SET engagement_type = CASE + WHEN LOWER(COALESCE(engagement_type, '')) IN ('audit', 'aud', 'assurance') THEN 'assurance' + ELSE 'non_assurance' + END + """)) + + # Backfill review partner snapshots only for partnership-firm assurance engagements. + # Uses tenants.firm_type created by 20260502, not a separate firm_type column. + bind.execute(sa.text(""" + UPDATE client_service_subscriptions + SET review_partner_user_id = ( + SELECT clients.default_review_partner_user_id + FROM clients + WHERE clients.id = client_service_subscriptions.client_id + ) + WHERE engagement_type = 'assurance' + AND review_partner_user_id IS NULL + AND EXISTS ( + SELECT 1 FROM tenants + WHERE tenants.id = client_service_subscriptions.tenant_id + AND COALESCE(tenants.firm_type, 'proprietorship') = 'partnership' + ) + """)) + + +def downgrade() -> None: + bind = op.get_bind() + + bind.execute(sa.text(""" + UPDATE service_catalogues + SET engagement_type = CASE + WHEN engagement_type = 'assurance' THEN 'audit' + ELSE 'non_audit' + END + """)) + + bind.execute(sa.text(""" + UPDATE client_service_subscriptions + SET engagement_type = CASE + WHEN engagement_type = 'assurance' THEN 'audit' + ELSE 'non_audit' + END + """)) + + # Do not drop client_service_subscriptions.review_partner_user_id here. + # That column belongs to the previous 20260502 migration. + if _has_index("clients", "ix_clients_default_review_partner_user_id"): + op.drop_index("ix_clients_default_review_partner_user_id", table_name="clients") + if _has_column("clients", "default_review_partner_user_id"): + op.drop_column("clients", "default_review_partner_user_id") diff --git a/alembic/versions/20260504_due_date_engine_foundation.py b/alembic/versions/20260504_due_date_engine_foundation.py new file mode 100644 index 0000000..b666df0 --- /dev/null +++ b/alembic/versions/20260504_due_date_engine_foundation.py @@ -0,0 +1,142 @@ +"""services phase 4A due date engine foundation + +Revision ID: 20260504_due_date_engine_foundation +Revises: 20260503_assurance_review_partner +Create Date: 2026-05-04 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260504_due_date_engine_foundation" +down_revision: Union[str, None] = "20260503_assurance_review_partner" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _insp(): + return sa.inspect(op.get_bind()) + + +def _has_table(table_name: str) -> bool: + return table_name in _insp().get_table_names() + + +def _has_column(table_name: str, column_name: str) -> bool: + if not _has_table(table_name): + return False + return any(c["name"] == column_name for c in _insp().get_columns(table_name)) + + +def _has_index(table_name: str, index_name: str) -> bool: + if not _has_table(table_name): + return False + return any(ix.get("name") == index_name for ix in _insp().get_indexes(table_name)) + + +def _create_index_if_missing(table_name: str, index_name: str, columns: list[str]) -> None: + if _has_table(table_name) and not _has_index(table_name, index_name): + op.create_index(index_name, table_name, columns) + + +def upgrade() -> None: + if not _has_table("service_due_date_rules"): + op.create_table( + "service_due_date_rules", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("service_catalogue_id", sa.Integer(), nullable=False), + sa.Column("rule_name", sa.String(length=150), nullable=False), + sa.Column("period_type", sa.String(length=30), nullable=False, server_default="yearly"), + sa.Column("due_year_basis", sa.String(length=40), nullable=False, server_default="assessment_year_start"), + sa.Column("due_day", sa.Integer(), nullable=True), + sa.Column("due_month", sa.Integer(), nullable=True), + sa.Column("due_month_offset", sa.Integer(), nullable=False, server_default="0"), + sa.Column("days_offset_after_event", sa.Integer(), nullable=True), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default="100"), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("remarks", sa.Text(), nullable=True), + sa.Column("created_by_user_id", sa.Integer(), nullable=True), + sa.Column("updated_by_user_id", sa.Integer(), 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.ForeignKeyConstraint(["service_catalogue_id"], ["service_catalogues.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]), + sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"]), + sa.UniqueConstraint("service_catalogue_id", "rule_name", name="uq_service_due_date_rules_catalogue_name"), + ) + _create_index_if_missing("service_due_date_rules", "ix_service_due_date_rules_service_catalogue_id", ["service_catalogue_id"]) + _create_index_if_missing("service_due_date_rules", "ix_service_due_date_rules_period_type", ["period_type"]) + _create_index_if_missing("service_due_date_rules", "ix_service_due_date_rules_is_active", ["is_active"]) + + if not _has_table("service_due_date_extensions"): + op.create_table( + "service_due_date_extensions", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("service_catalogue_id", sa.Integer(), nullable=False), + sa.Column("due_date_rule_id", sa.Integer(), nullable=True), + sa.Column("financial_year", sa.String(length=9), nullable=False), + sa.Column("assessment_year", sa.String(length=9), nullable=True), + sa.Column("period_label", sa.String(length=30), nullable=True), + sa.Column("previous_due_date", sa.Date(), nullable=True), + sa.Column("extended_due_date", sa.Date(), nullable=False), + sa.Column("extension_sequence", sa.Integer(), nullable=False, server_default="1"), + sa.Column("notification_reference", sa.String(length=200), nullable=True), + sa.Column("notification_date", sa.Date(), nullable=True), + sa.Column("remarks", sa.Text(), nullable=True), + sa.Column("created_by_user_id", sa.Integer(), nullable=True), + sa.Column("updated_by_user_id", sa.Integer(), 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.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["service_catalogue_id"], ["service_catalogues.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["due_date_rule_id"], ["service_due_date_rules.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]), + sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"]), + ) + for index_name, columns in [ + ("ix_service_due_date_extensions_tenant_id", ["tenant_id"]), + ("ix_service_due_date_extensions_service_catalogue_id", ["service_catalogue_id"]), + ("ix_service_due_date_extensions_due_date_rule_id", ["due_date_rule_id"]), + ("ix_service_due_date_extensions_financial_year", ["financial_year"]), + ("ix_service_due_date_extensions_assessment_year", ["assessment_year"]), + ("ix_service_due_date_extensions_period_label", ["period_label"]), + ("ix_service_due_date_extensions_extended_due_date", ["extended_due_date"]), + ]: + _create_index_if_missing("service_due_date_extensions", index_name, columns) + + subscription_columns = [ + ("due_date_rule_id", sa.Column("due_date_rule_id", sa.Integer(), nullable=True)), + ("original_due_date", sa.Column("original_due_date", sa.Date(), nullable=True)), + ("current_due_date", sa.Column("current_due_date", sa.Date(), nullable=True)), + ("due_date_source", sa.Column("due_date_source", sa.String(length=30), nullable=True)), + ] + for column_name, column in subscription_columns: + if not _has_column("client_service_subscriptions", column_name): + op.add_column("client_service_subscriptions", column) + + for index_name, columns in [ + ("ix_client_service_subscriptions_due_date_rule_id", ["due_date_rule_id"]), + ("ix_client_service_subscriptions_original_due_date", ["original_due_date"]), + ("ix_client_service_subscriptions_current_due_date", ["current_due_date"]), + ]: + _create_index_if_missing("client_service_subscriptions", index_name, columns) + + +def downgrade() -> None: + for index_name in [ + "ix_client_service_subscriptions_current_due_date", + "ix_client_service_subscriptions_original_due_date", + "ix_client_service_subscriptions_due_date_rule_id", + ]: + if _has_index("client_service_subscriptions", index_name): + op.drop_index(index_name, table_name="client_service_subscriptions") + for column_name in ["due_date_source", "current_due_date", "original_due_date", "due_date_rule_id"]: + if _has_column("client_service_subscriptions", column_name): + op.drop_column("client_service_subscriptions", column_name) + if _has_table("service_due_date_extensions"): + op.drop_table("service_due_date_extensions") + if _has_table("service_due_date_rules"): + op.drop_table("service_due_date_rules") diff --git a/alembic/versions/20260505_renewal_before_expiry_due_dates.py b/alembic/versions/20260505_renewal_before_expiry_due_dates.py new file mode 100644 index 0000000..8df9e31 --- /dev/null +++ b/alembic/versions/20260505_renewal_before_expiry_due_dates.py @@ -0,0 +1,56 @@ +"""services phase 4A renewal before expiry due dates + +Revision ID: 20260505_renewal_before_expiry_due_dates +Revises: 20260504_due_date_engine_foundation +Create Date: 2026-05-05 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260505_renewal_before_expiry_due_dates" +down_revision: Union[str, None] = "20260504_due_date_engine_foundation" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _insp(): + return sa.inspect(op.get_bind()) + + +def _has_table(table_name: str) -> bool: + return table_name in _insp().get_table_names() + + +def _has_column(table_name: str, column_name: str) -> bool: + if not _has_table(table_name): + return False + return any(c["name"] == column_name for c in _insp().get_columns(table_name)) + + +def _has_index(table_name: str, index_name: str) -> bool: + if not _has_table(table_name): + return False + return any(ix.get("name") == index_name for ix in _insp().get_indexes(table_name)) + + +def upgrade() -> None: + if _has_table("service_due_date_rules") and not _has_column("service_due_date_rules", "renewal_days_before_expiry"): + op.add_column("service_due_date_rules", sa.Column("renewal_days_before_expiry", sa.Integer(), nullable=True)) + + if _has_table("client_service_subscriptions") and not _has_column("client_service_subscriptions", "expiry_date"): + op.add_column("client_service_subscriptions", sa.Column("expiry_date", sa.Date(), nullable=True)) + + if _has_table("client_service_subscriptions") and not _has_index("client_service_subscriptions", "ix_client_service_subscriptions_expiry_date"): + op.create_index("ix_client_service_subscriptions_expiry_date", "client_service_subscriptions", ["expiry_date"]) + + +def downgrade() -> None: + if _has_table("client_service_subscriptions") and _has_index("client_service_subscriptions", "ix_client_service_subscriptions_expiry_date"): + op.drop_index("ix_client_service_subscriptions_expiry_date", table_name="client_service_subscriptions") + if _has_table("client_service_subscriptions") and _has_column("client_service_subscriptions", "expiry_date"): + op.drop_column("client_service_subscriptions", "expiry_date") + if _has_table("service_due_date_rules") and _has_column("service_due_date_rules", "renewal_days_before_expiry"): + op.drop_column("service_due_date_rules", "renewal_days_before_expiry") diff --git a/alembic/versions/20260506_work_tracker_phase_4b.py b/alembic/versions/20260506_work_tracker_phase_4b.py new file mode 100644 index 0000000..d5a649e --- /dev/null +++ b/alembic/versions/20260506_work_tracker_phase_4b.py @@ -0,0 +1,60 @@ +"""Work tracker Phase 4B internal target dates + +Revision ID: 20260506_work_tracker_phase_4b +Revises: 20260505_renewal_before_expiry_due_dates +Create Date: 2026-05-03 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "20260506_work_tracker_phase_4b" +down_revision = "20260505_renewal_before_expiry_due_dates" +branch_labels = None +depends_on = None + + +def _table_columns(table_name: str) -> set[str]: + bind = op.get_bind() + inspector = sa.inspect(bind) + try: + return {column["name"] for column in inspector.get_columns(table_name)} + except Exception: + return set() + + +def _index_names(table_name: str) -> set[str]: + bind = op.get_bind() + inspector = sa.inspect(bind) + try: + return {index["name"] for index in inspector.get_indexes(table_name) if index.get("name")} + except Exception: + return set() + + +def upgrade() -> None: + columns = _table_columns("client_service_task_instances") + if "internal_target_date" not in columns: + op.add_column("client_service_task_instances", sa.Column("internal_target_date", sa.Date(), nullable=True)) + + indexes = _index_names("client_service_task_instances") + if "ix_client_service_task_instances_internal_target_date" not in indexes: + op.create_index( + "ix_client_service_task_instances_internal_target_date", + "client_service_task_instances", + ["internal_target_date"], + unique=False, + ) + + +def downgrade() -> None: + indexes = _index_names("client_service_task_instances") + if "ix_client_service_task_instances_internal_target_date" in indexes: + op.drop_index("ix_client_service_task_instances_internal_target_date", table_name="client_service_task_instances") + + columns = _table_columns("client_service_task_instances") + if "internal_target_date" in columns: + op.drop_column("client_service_task_instances", "internal_target_date") diff --git a/alembic/versions/20260507_task_communication_phase_4c.py b/alembic/versions/20260507_task_communication_phase_4c.py new file mode 100644 index 0000000..672ba4b --- /dev/null +++ b/alembic/versions/20260507_task_communication_phase_4c.py @@ -0,0 +1,69 @@ +"""services phase 4C task communication timeline + +Revision ID: 20260507_task_communication_phase_4c +Revises: 20260506_work_tracker_phase_4b +Create Date: 2026-05-03 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "20260507_task_communication_phase_4c" +down_revision = "20260506_work_tracker_phase_4b" +branch_labels = None +depends_on = None + + +def _insp(): + return sa.inspect(op.get_bind()) + + +def _has_table(table_name: str) -> bool: + return table_name in _insp().get_table_names() + + +def _has_index(table_name: str, index_name: str) -> bool: + if not _has_table(table_name): + return False + return any(ix.get("name") == index_name for ix in _insp().get_indexes(table_name)) + + +def upgrade() -> None: + if not _has_table("service_task_comments"): + op.create_table( + "service_task_comments", + 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("branch_id", sa.Integer(), sa.ForeignKey("branches.id"), nullable=True), + sa.Column("subscription_id", sa.Integer(), sa.ForeignKey("client_service_subscriptions.id", ondelete="CASCADE"), nullable=False), + sa.Column("task_instance_id", sa.Integer(), sa.ForeignKey("client_service_task_instances.id", ondelete="CASCADE"), nullable=False), + sa.Column("comment_type", sa.String(length=40), nullable=False, server_default="internal_note"), + sa.Column("visibility", sa.String(length=30), nullable=False, server_default="internal"), + sa.Column("message", sa.Text(), nullable=False), + sa.Column("created_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.text("CURRENT_TIMESTAMP")), + sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")), + sa.Column("is_deleted", sa.Boolean(), nullable=False, server_default=sa.text("0")), + ) + + index_specs = [ + ("ix_service_task_comments_tenant_id", ["tenant_id"]), + ("ix_service_task_comments_branch_id", ["branch_id"]), + ("ix_service_task_comments_subscription_id", ["subscription_id"]), + ("ix_service_task_comments_task_instance_id", ["task_instance_id"]), + ("ix_service_task_comments_comment_type", ["comment_type"]), + ("ix_service_task_comments_visibility", ["visibility"]), + ("ix_service_task_comments_created_by_user_id", ["created_by_user_id"]), + ("ix_service_task_comments_is_deleted", ["is_deleted"]), + ] + for index_name, columns in index_specs: + if not _has_index("service_task_comments", index_name): + op.create_index(index_name, "service_task_comments", columns, unique=False) + + +def downgrade() -> None: + if _has_table("service_task_comments"): + op.drop_table("service_task_comments") diff --git a/alembic/versions/20260508_consultant_portal_phase_5a1.py b/alembic/versions/20260508_consultant_portal_phase_5a1.py new file mode 100644 index 0000000..48519bd --- /dev/null +++ b/alembic/versions/20260508_consultant_portal_phase_5a1.py @@ -0,0 +1,119 @@ +"""Phase 5A.1 consultant portal foundation + +Revision ID: 20260508_consultant_portal_phase_5a1 +Revises: 20260507_task_communication_phase_4c +Create Date: 2026-05-08 +""" + +from __future__ import annotations + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260508_consultant_portal_phase_5a1" +down_revision: Union[str, None] = "20260507_task_communication_phase_4c" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _has_table(table_name: str) -> bool: + bind = op.get_bind() + inspector = sa.inspect(bind) + return table_name in inspector.get_table_names() + + +def upgrade() -> None: + if not _has_table("consultant_profiles"): + op.create_table( + "consultant_profiles", + 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("branch_id", sa.Integer(), sa.ForeignKey("branches.id"), nullable=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("consultant_type", sa.String(length=50), nullable=False, server_default="external_consultant"), + sa.Column("firm_name", sa.String(length=200), nullable=True), + sa.Column("contact_person", sa.String(length=200), nullable=False), + sa.Column("email", sa.String(length=255), nullable=True), + sa.Column("mobile", sa.String(length=20), nullable=True), + sa.Column("specialisation", sa.String(length=200), nullable=True), + sa.Column("gstin", sa.String(length=20), nullable=True), + sa.Column("pan", sa.String(length=20), nullable=True), + sa.Column("address", sa.Text(), nullable=True), + sa.Column("status", sa.String(length=30), nullable=False, server_default="active"), + sa.Column("onboarding_status", sa.String(length=30), nullable=False, server_default="approved"), + sa.Column("is_platform_partner", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("is_franchise_partner", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("is_saas_customer", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("remarks", sa.Text(), nullable=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", "user_id", name="uq_consultant_profiles_tenant_user"), + sa.UniqueConstraint("tenant_id", "email", name="uq_consultant_profiles_tenant_email"), + ) + op.create_index("ix_consultant_profiles_tenant_id", "consultant_profiles", ["tenant_id"]) + op.create_index("ix_consultant_profiles_branch_id", "consultant_profiles", ["branch_id"]) + op.create_index("ix_consultant_profiles_user_id", "consultant_profiles", ["user_id"]) + op.create_index("ix_consultant_profiles_email", "consultant_profiles", ["email"]) + op.create_index("ix_consultant_profiles_status", "consultant_profiles", ["status"]) + op.create_index("ix_consultant_profiles_onboarding_status", "consultant_profiles", ["onboarding_status"]) + op.create_index("ix_consultant_profiles_is_active", "consultant_profiles", ["is_active"]) + op.create_index("ix_consultant_profiles_consultant_type", "consultant_profiles", ["consultant_type"]) + + if not _has_table("client_consultant_links"): + op.create_table( + "client_consultant_links", + 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("branch_id", sa.Integer(), sa.ForeignKey("branches.id"), nullable=True), + sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False), + sa.Column("consultant_id", sa.Integer(), sa.ForeignKey("consultant_profiles.id", ondelete="CASCADE"), nullable=False), + sa.Column("service_catalogue_id", sa.Integer(), sa.ForeignKey("service_catalogues.id", ondelete="SET NULL"), nullable=True), + sa.Column("relationship_type", sa.String(length=50), nullable=False, server_default="accounts_consultant"), + sa.Column("is_primary", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("can_view_client", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("can_view_services", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("can_view_due_dates", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("can_view_communications", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("remarks", sa.Text(), nullable=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", "consultant_id", name="uq_client_consultant_links_client_consultant"), + ) + op.create_index("ix_client_consultant_links_tenant_id", "client_consultant_links", ["tenant_id"]) + op.create_index("ix_client_consultant_links_branch_id", "client_consultant_links", ["branch_id"]) + op.create_index("ix_client_consultant_links_client_id", "client_consultant_links", ["client_id"]) + op.create_index("ix_client_consultant_links_consultant_id", "client_consultant_links", ["consultant_id"]) + op.create_index("ix_client_consultant_links_service_catalogue_id", "client_consultant_links", ["service_catalogue_id"]) + op.create_index("ix_client_consultant_links_relationship_type", "client_consultant_links", ["relationship_type"]) + op.create_index("ix_client_consultant_links_is_active", "client_consultant_links", ["is_active"]) + + +def downgrade() -> None: + if _has_table("client_consultant_links"): + op.drop_index("ix_client_consultant_links_is_active", table_name="client_consultant_links") + op.drop_index("ix_client_consultant_links_relationship_type", table_name="client_consultant_links") + op.drop_index("ix_client_consultant_links_service_catalogue_id", table_name="client_consultant_links") + op.drop_index("ix_client_consultant_links_consultant_id", table_name="client_consultant_links") + op.drop_index("ix_client_consultant_links_client_id", table_name="client_consultant_links") + op.drop_index("ix_client_consultant_links_branch_id", table_name="client_consultant_links") + op.drop_index("ix_client_consultant_links_tenant_id", table_name="client_consultant_links") + op.drop_table("client_consultant_links") + if _has_table("consultant_profiles"): + op.drop_index("ix_consultant_profiles_consultant_type", table_name="consultant_profiles") + op.drop_index("ix_consultant_profiles_is_active", table_name="consultant_profiles") + op.drop_index("ix_consultant_profiles_onboarding_status", table_name="consultant_profiles") + op.drop_index("ix_consultant_profiles_status", table_name="consultant_profiles") + op.drop_index("ix_consultant_profiles_email", table_name="consultant_profiles") + op.drop_index("ix_consultant_profiles_user_id", table_name="consultant_profiles") + op.drop_index("ix_consultant_profiles_branch_id", table_name="consultant_profiles") + op.drop_index("ix_consultant_profiles_tenant_id", table_name="consultant_profiles") + op.drop_table("consultant_profiles") diff --git a/alembic/versions/20260509_consultant_managed_clients_phase_5a2.py b/alembic/versions/20260509_consultant_managed_clients_phase_5a2.py new file mode 100644 index 0000000..e010ec9 --- /dev/null +++ b/alembic/versions/20260509_consultant_managed_clients_phase_5a2.py @@ -0,0 +1,102 @@ +"""Phase 5A.2 consultant-managed clients + +Revision ID: 20260509_consultant_managed_clients_phase_5a2 +Revises: 20260508_consultant_portal_phase_5a1 +Create Date: 2026-05-09 +""" + +from __future__ import annotations + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260509_consultant_managed_clients_phase_5a2" +down_revision: Union[str, None] = "20260508_consultant_portal_phase_5a1" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _has_table(table_name: str) -> bool: + bind = op.get_bind() + inspector = sa.inspect(bind) + return table_name in inspector.get_table_names() + + +def _has_index(table_name: str, index_name: str) -> bool: + bind = op.get_bind() + inspector = sa.inspect(bind) + return any(ix.get("name") == index_name for ix in inspector.get_indexes(table_name)) + + +def upgrade() -> None: + if not _has_table("consultant_managed_clients"): + op.create_table( + "consultant_managed_clients", + 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("branch_id", sa.Integer(), sa.ForeignKey("branches.id"), nullable=True), + sa.Column("consultant_id", sa.Integer(), sa.ForeignKey("consultant_profiles.id", ondelete="CASCADE"), nullable=False), + sa.Column("linked_firm_client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="SET NULL"), nullable=True), + sa.Column("client_code", sa.String(length=50), nullable=True), + sa.Column("client_name", sa.String(length=200), nullable=False), + sa.Column("trade_name", sa.String(length=200), nullable=True), + sa.Column("client_type", sa.String(length=100), nullable=False, server_default="Other"), + sa.Column("pan", sa.String(length=20), nullable=True), + sa.Column("gstin", sa.String(length=20), nullable=True), + sa.Column("tan", sa.String(length=20), nullable=True), + sa.Column("contact_person_name", sa.String(length=200), nullable=True), + sa.Column("mobile", sa.String(length=20), nullable=True), + sa.Column("email", sa.String(length=255), nullable=True), + sa.Column("address_line_1", sa.String(length=255), nullable=True), + sa.Column("address_line_2", sa.String(length=255), nullable=True), + sa.Column("city", sa.String(length=100), nullable=True), + sa.Column("state", sa.String(length=100), nullable=True), + sa.Column("pincode", sa.String(length=20), nullable=True), + sa.Column("country", sa.String(length=100), nullable=True, server_default="India"), + sa.Column("service_interest", sa.Text(), nullable=True), + sa.Column("relationship_stage", sa.String(length=30), nullable=False, server_default="managed"), + sa.Column("status", sa.String(length=30), nullable=False, server_default="active"), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("notes", sa.Text(), nullable=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", "consultant_id", "client_code", name="uq_consultant_managed_clients_code"), + ) + op.create_index("ix_consultant_managed_clients_tenant_id", "consultant_managed_clients", ["tenant_id"]) + op.create_index("ix_consultant_managed_clients_branch_id", "consultant_managed_clients", ["branch_id"]) + op.create_index("ix_consultant_managed_clients_consultant_id", "consultant_managed_clients", ["consultant_id"]) + op.create_index("ix_consultant_managed_clients_linked_firm_client_id", "consultant_managed_clients", ["linked_firm_client_id"]) + op.create_index("ix_consultant_managed_clients_client_code", "consultant_managed_clients", ["client_code"]) + op.create_index("ix_consultant_managed_clients_client_name", "consultant_managed_clients", ["client_name"]) + op.create_index("ix_consultant_managed_clients_pan", "consultant_managed_clients", ["pan"]) + op.create_index("ix_consultant_managed_clients_gstin", "consultant_managed_clients", ["gstin"]) + op.create_index("ix_consultant_managed_clients_email", "consultant_managed_clients", ["email"]) + op.create_index("ix_consultant_managed_clients_relationship_stage", "consultant_managed_clients", ["relationship_stage"]) + op.create_index("ix_consultant_managed_clients_status", "consultant_managed_clients", ["status"]) + op.create_index("ix_consultant_managed_clients_is_active", "consultant_managed_clients", ["is_active"]) + + +def downgrade() -> None: + if _has_table("consultant_managed_clients"): + for index_name in [ + "ix_consultant_managed_clients_is_active", + "ix_consultant_managed_clients_status", + "ix_consultant_managed_clients_relationship_stage", + "ix_consultant_managed_clients_email", + "ix_consultant_managed_clients_gstin", + "ix_consultant_managed_clients_pan", + "ix_consultant_managed_clients_client_name", + "ix_consultant_managed_clients_client_code", + "ix_consultant_managed_clients_linked_firm_client_id", + "ix_consultant_managed_clients_consultant_id", + "ix_consultant_managed_clients_branch_id", + "ix_consultant_managed_clients_tenant_id", + ]: + if _has_index("consultant_managed_clients", index_name): + op.drop_index(index_name, table_name="consultant_managed_clients") + op.drop_table("consultant_managed_clients") diff --git a/alembic/versions/20260510_consultant_workspace_phase_5a3.py b/alembic/versions/20260510_consultant_workspace_phase_5a3.py new file mode 100644 index 0000000..6d4ab8a --- /dev/null +++ b/alembic/versions/20260510_consultant_workspace_phase_5a3.py @@ -0,0 +1,79 @@ +"""consultant workspace foundation phase 5a3 + +Revision ID: 20260510_consultant_workspace_phase_5a3 +Revises: 20260509_consultant_managed_clients_phase_5a2 +Create Date: 2026-05-10 00:00:00 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "20260510_consultant_workspace_phase_5a3" +down_revision = "20260509_consultant_managed_clients_phase_5a2" +branch_labels = None +depends_on = None + + +def _table_exists(bind, table_name: str) -> bool: + inspector = sa.inspect(bind) + return table_name in inspector.get_table_names() + + +def upgrade() -> None: + bind = op.get_bind() + if not _table_exists(bind, "consultant_workspaces"): + op.create_table( + "consultant_workspaces", + 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("branch_id", sa.Integer(), sa.ForeignKey("branches.id"), nullable=True), + sa.Column("consultant_id", sa.Integer(), sa.ForeignKey("consultant_profiles.id", ondelete="CASCADE"), nullable=False), + sa.Column("workspace_code", sa.String(length=50), nullable=False), + sa.Column("workspace_name", sa.String(length=200), nullable=False), + sa.Column("workspace_type", sa.String(length=50), nullable=False, server_default="consultant_saas"), + sa.Column("plan_code", sa.String(length=50), nullable=False, server_default="starter"), + sa.Column("billing_cycle", sa.String(length=30), nullable=False, server_default="manual"), + sa.Column("subscription_status", sa.String(length=30), nullable=False, server_default="trial"), + sa.Column("subscription_start_date", sa.Date(), nullable=True), + sa.Column("subscription_end_date", sa.Date(), nullable=True), + sa.Column("max_managed_clients", sa.Integer(), nullable=False, server_default="25"), + sa.Column("max_user_accounts", sa.Integer(), nullable=False, server_default="1"), + sa.Column("allow_client_portal", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("allow_firm_referrals", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("allow_service_marketplace", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("remarks", sa.Text(), nullable=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", "consultant_id", name="uq_consultant_workspaces_tenant_consultant"), + sa.UniqueConstraint("tenant_id", "workspace_code", name="uq_consultant_workspaces_tenant_code"), + ) + op.create_index("ix_consultant_workspaces_tenant_id", "consultant_workspaces", ["tenant_id"]) + op.create_index("ix_consultant_workspaces_branch_id", "consultant_workspaces", ["branch_id"]) + op.create_index("ix_consultant_workspaces_consultant_id", "consultant_workspaces", ["consultant_id"]) + op.create_index("ix_consultant_workspaces_workspace_code", "consultant_workspaces", ["workspace_code"]) + op.create_index("ix_consultant_workspaces_workspace_type", "consultant_workspaces", ["workspace_type"]) + op.create_index("ix_consultant_workspaces_plan_code", "consultant_workspaces", ["plan_code"]) + op.create_index("ix_consultant_workspaces_subscription_status", "consultant_workspaces", ["subscription_status"]) + op.create_index("ix_consultant_workspaces_billing_cycle", "consultant_workspaces", ["billing_cycle"]) + op.create_index("ix_consultant_workspaces_is_active", "consultant_workspaces", ["is_active"]) + + +def downgrade() -> None: + bind = op.get_bind() + if _table_exists(bind, "consultant_workspaces"): + op.drop_index("ix_consultant_workspaces_is_active", table_name="consultant_workspaces") + op.drop_index("ix_consultant_workspaces_billing_cycle", table_name="consultant_workspaces") + op.drop_index("ix_consultant_workspaces_subscription_status", table_name="consultant_workspaces") + op.drop_index("ix_consultant_workspaces_plan_code", table_name="consultant_workspaces") + op.drop_index("ix_consultant_workspaces_workspace_type", table_name="consultant_workspaces") + op.drop_index("ix_consultant_workspaces_workspace_code", table_name="consultant_workspaces") + op.drop_index("ix_consultant_workspaces_consultant_id", table_name="consultant_workspaces") + op.drop_index("ix_consultant_workspaces_branch_id", table_name="consultant_workspaces") + op.drop_index("ix_consultant_workspaces_tenant_id", table_name="consultant_workspaces") + op.drop_table("consultant_workspaces") diff --git a/alembic/versions/20260511_consultant_completion_phase_5a5_5a6.py b/alembic/versions/20260511_consultant_completion_phase_5a5_5a6.py new file mode 100644 index 0000000..1cea8d4 --- /dev/null +++ b/alembic/versions/20260511_consultant_completion_phase_5a5_5a6.py @@ -0,0 +1,86 @@ +"""consultant communications and service requests phase 5a5 5a6 + +Revision ID: 20260511_consultant_completion_phase_5a5_5a6 +Revises: 20260510_consultant_workspace_phase_5a3 +Create Date: 2026-05-11 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "20260511_consultant_completion_phase_5a5_5a6" +down_revision = "20260510_consultant_workspace_phase_5a3" +branch_labels = None +depends_on = None + + +def _has_table(bind, table_name: str) -> bool: + return sa.inspect(bind).has_table(table_name) + + +def _has_column(bind, table_name: str, column_name: str) -> bool: + if not _has_table(bind, table_name): + return False + return column_name in {col["name"] for col in sa.inspect(bind).get_columns(table_name)} + + +def upgrade() -> None: + bind = op.get_bind() + + if not _has_table(bind, "consultant_service_requests"): + op.create_table( + "consultant_service_requests", + 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("branch_id", sa.Integer(), sa.ForeignKey("branches.id"), nullable=True), + sa.Column("consultant_id", sa.Integer(), sa.ForeignKey("consultant_profiles.id", ondelete="CASCADE"), nullable=False), + sa.Column("managed_client_id", sa.Integer(), sa.ForeignKey("consultant_managed_clients.id", ondelete="SET NULL"), nullable=True), + sa.Column("firm_client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="SET NULL"), nullable=True), + sa.Column("service_catalogue_id", sa.Integer(), sa.ForeignKey("service_catalogues.id", ondelete="SET NULL"), nullable=True), + sa.Column("request_no", sa.String(length=50), nullable=False), + sa.Column("request_type", sa.String(length=50), nullable=False, server_default="service_request"), + sa.Column("status", sa.String(length=40), nullable=False, server_default="submitted"), + sa.Column("priority", sa.String(length=30), nullable=False, server_default="normal"), + sa.Column("requested_service_name", sa.String(length=200), nullable=False), + sa.Column("requested_due_date", sa.Date(), nullable=True), + sa.Column("subject", sa.String(length=255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("consultant_notes", sa.Text(), nullable=True), + sa.Column("firm_response", sa.Text(), nullable=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("reviewed_by_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True), + sa.Column("reviewed_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")), + sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("1")), + ) + + for index_name, columns in { + "ix_consultant_service_requests_tenant_id": ["tenant_id"], + "ix_consultant_service_requests_branch_id": ["branch_id"], + "ix_consultant_service_requests_consultant_id": ["consultant_id"], + "ix_consultant_service_requests_managed_client_id": ["managed_client_id"], + "ix_consultant_service_requests_firm_client_id": ["firm_client_id"], + "ix_consultant_service_requests_service_catalogue_id": ["service_catalogue_id"], + "ix_consultant_service_requests_request_no": ["request_no"], + "ix_consultant_service_requests_status": ["status"], + "ix_consultant_service_requests_priority": ["priority"], + "ix_consultant_service_requests_is_active": ["is_active"], + }.items(): + try: + op.create_index(index_name, "consultant_service_requests", columns) + except Exception: + pass + + if _has_table(bind, "service_catalogues") and not _has_column(bind, "service_catalogues", "is_consultant_requestable"): + with op.batch_alter_table("service_catalogues") as batch_op: + batch_op.add_column(sa.Column("is_consultant_requestable", sa.Boolean(), nullable=False, server_default=sa.text("0"))) + + +def downgrade() -> None: + bind = op.get_bind() + if _has_table(bind, "consultant_service_requests"): + op.drop_table("consultant_service_requests") diff --git a/alembic/versions/20260512_consultant_conversion_limits_phase_5a7_5a8.py b/alembic/versions/20260512_consultant_conversion_limits_phase_5a7_5a8.py new file mode 100644 index 0000000..2c6edad --- /dev/null +++ b/alembic/versions/20260512_consultant_conversion_limits_phase_5a7_5a8.py @@ -0,0 +1,79 @@ +"""consultant conversion and workspace limits phase 5a7 5a8 + +Revision ID: 20260512_consultant_conversion_limits_phase_5a7_5a8 +Revises: 20260511_consultant_completion_phase_5a5_5a6 +Create Date: 2026-05-12 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "20260512_consultant_conversion_limits_phase_5a7_5a8" +down_revision = "20260511_consultant_completion_phase_5a5_5a6" +branch_labels = None +depends_on = None + + +def _has_column(table_name: str, column_name: str) -> bool: + bind = op.get_bind() + inspector = sa.inspect(bind) + return column_name in {col["name"] for col in inspector.get_columns(table_name)} + + +def _has_index(table_name: str, index_name: str) -> bool: + bind = op.get_bind() + inspector = sa.inspect(bind) + return index_name in {idx["name"] for idx in inspector.get_indexes(table_name)} + + +def upgrade() -> None: + table = "consultant_managed_clients" + if not _has_column(table, "conversion_status"): + op.add_column(table, sa.Column("conversion_status", sa.String(length=30), nullable=False, server_default="not_requested")) + if not _has_column(table, "conversion_requested_at_utc"): + op.add_column(table, sa.Column("conversion_requested_at_utc", sa.DateTime(timezone=True), nullable=True)) + if not _has_column(table, "conversion_requested_by_user_id"): + op.add_column(table, sa.Column("conversion_requested_by_user_id", sa.Integer(), nullable=True)) + if not _has_column(table, "conversion_reviewed_at_utc"): + op.add_column(table, sa.Column("conversion_reviewed_at_utc", sa.DateTime(timezone=True), nullable=True)) + if not _has_column(table, "conversion_reviewed_by_user_id"): + op.add_column(table, sa.Column("conversion_reviewed_by_user_id", sa.Integer(), nullable=True)) + if not _has_column(table, "conversion_notes"): + op.add_column(table, sa.Column("conversion_notes", sa.Text(), nullable=True)) + if not _has_column(table, "conversion_firm_notes"): + op.add_column(table, sa.Column("conversion_firm_notes", sa.Text(), nullable=True)) + + if not _has_index(table, "ix_consultant_managed_clients_conversion_status"): + op.create_index("ix_consultant_managed_clients_conversion_status", table, ["conversion_status"]) + if not _has_index(table, "ix_consultant_managed_clients_conversion_requested_by_user_id"): + op.create_index("ix_consultant_managed_clients_conversion_requested_by_user_id", table, ["conversion_requested_by_user_id"]) + if not _has_index(table, "ix_consultant_managed_clients_conversion_reviewed_by_user_id"): + op.create_index("ix_consultant_managed_clients_conversion_reviewed_by_user_id", table, ["conversion_reviewed_by_user_id"]) + + # SQLite supports ALTER TABLE ADD COLUMN but does not always enforce server default cleanup safely. + # Keeping the server default is intentional so existing local installs remain simple and stable. + + +def downgrade() -> None: + table = "consultant_managed_clients" + for index_name in [ + "ix_consultant_managed_clients_conversion_reviewed_by_user_id", + "ix_consultant_managed_clients_conversion_requested_by_user_id", + "ix_consultant_managed_clients_conversion_status", + ]: + if _has_index(table, index_name): + op.drop_index(index_name, table_name=table) + + for column_name in [ + "conversion_firm_notes", + "conversion_notes", + "conversion_reviewed_by_user_id", + "conversion_reviewed_at_utc", + "conversion_requested_by_user_id", + "conversion_requested_at_utc", + "conversion_status", + ]: + if _has_column(table, column_name): + op.drop_column(table, column_name) diff --git a/alembic/versions/20260513_phase_6a_employees_core.py b/alembic/versions/20260513_phase_6a_employees_core.py new file mode 100644 index 0000000..bd2c8cb --- /dev/null +++ b/alembic/versions/20260513_phase_6a_employees_core.py @@ -0,0 +1,69 @@ +"""Phase 6A employee core foundation + +Revision ID: 20260513_phase_6a_employees_core +Revises: 20260512_consultant_conversion_limits_phase_5a7_5a8 +Create Date: 2026-05-05 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "20260513_phase_6a_employees_core" +down_revision = "20260512_consultant_conversion_limits_phase_5a7_5a8" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "employees", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("branch_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.Column("employee_code", sa.String(length=50), nullable=False), + sa.Column("full_name", sa.String(length=255), nullable=False), + sa.Column("email", sa.String(length=255), nullable=True), + sa.Column("mobile", sa.String(length=20), nullable=True), + sa.Column("alternate_mobile", sa.String(length=20), nullable=True), + sa.Column("date_of_joining", sa.Date(), nullable=True), + sa.Column("date_of_leaving", sa.Date(), nullable=True), + sa.Column("employment_type", sa.String(length=50), nullable=False, server_default="full_time"), + sa.Column("status", sa.String(length=30), nullable=False, server_default="active"), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("department", sa.String(length=100), nullable=True), + sa.Column("designation", sa.String(length=100), nullable=True), + sa.Column("reporting_manager_user_id", sa.Integer(), nullable=True), + sa.Column("pan", sa.String(length=20), nullable=True), + sa.Column("uan", sa.String(length=30), nullable=True), + sa.Column("esi_no", sa.String(length=30), nullable=True), + sa.Column("pf_no", sa.String(length=30), nullable=True), + sa.Column("aadhaar_last4", sa.String(length=4), nullable=True), + sa.Column("bank_name", sa.String(length=100), nullable=True), + sa.Column("bank_account_no", sa.String(length=40), nullable=True), + sa.Column("bank_ifsc", sa.String(length=20), nullable=True), + sa.Column("address", sa.Text(), nullable=True), + sa.Column("emergency_contact_name", sa.String(length=200), nullable=True), + sa.Column("emergency_contact_mobile", sa.String(length=20), nullable=True), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("created_by_user_id", sa.Integer(), nullable=True), + sa.Column("updated_by_user_id", sa.Integer(), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["reporting_manager_user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("tenant_id", "employee_code", name="uq_employees_tenant_code"), + sa.UniqueConstraint("tenant_id", "user_id", name="uq_employees_tenant_user"), + ) + for col in ["tenant_id", "branch_id", "user_id", "employee_code", "full_name", "email", "status", "is_active", "department", "designation", "reporting_manager_user_id", "pan"]: + op.create_index(f"ix_employees_{col}", "employees", [col], unique=False) + + +def downgrade() -> None: + op.drop_table("employees") diff --git a/alembic/versions/20260514_phase_6b_employee_ess_registration.py b/alembic/versions/20260514_phase_6b_employee_ess_registration.py new file mode 100644 index 0000000..6c60c25 --- /dev/null +++ b/alembic/versions/20260514_phase_6b_employee_ess_registration.py @@ -0,0 +1,63 @@ +"""Phase 6B employee ESS and registration requests + +Revision ID: 20260514_phase_6b_employee_ess_registration +Revises: 20260513_phase_6a_employees_core +Create Date: 2026-05-10 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "20260514_phase_6b_employee_ess_registration" +down_revision = "20260513_phase_6a_employees_core" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "employee_registration_requests", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("branch_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("requested_employee_code", sa.String(length=50), nullable=True), + sa.Column("full_name", sa.String(length=255), nullable=False), + sa.Column("email", sa.String(length=255), nullable=True), + sa.Column("mobile", sa.String(length=20), nullable=True), + sa.Column("department", sa.String(length=100), nullable=True), + sa.Column("designation", sa.String(length=100), nullable=True), + sa.Column("date_of_joining", sa.Date(), nullable=True), + sa.Column("remarks", sa.Text(), nullable=True), + sa.Column("status", sa.String(length=30), nullable=False, server_default="pending"), + sa.Column("review_notes", sa.Text(), nullable=True), + sa.Column("reviewed_by_user_id", sa.Integer(), nullable=True), + sa.Column("reviewed_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_employee_id", sa.Integer(), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]), + sa.ForeignKeyConstraint(["created_employee_id"], ["employees.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["reviewed_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + for col in [ + "tenant_id", + "branch_id", + "user_id", + "requested_employee_code", + "full_name", + "email", + "department", + "designation", + "status", + "created_employee_id", + ]: + op.create_index(f"ix_employee_registration_requests_{col}", "employee_registration_requests", [col], unique=False) + + +def downgrade() -> None: + op.drop_table("employee_registration_requests") diff --git a/alembic/versions/20260515_phase_6c_employee_attendance.py b/alembic/versions/20260515_phase_6c_employee_attendance.py new file mode 100644 index 0000000..4a59ff9 --- /dev/null +++ b/alembic/versions/20260515_phase_6c_employee_attendance.py @@ -0,0 +1,65 @@ +"""Phase 6C employee attendance foundation + +Revision ID: 20260515_phase_6c_employee_attendance +Revises: 20260514_phase_6b_employee_ess_registration +Create Date: 2026-05-10 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "20260515_phase_6c_employee_attendance" +down_revision = "20260514_phase_6b_employee_ess_registration" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "employee_attendance", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("branch_id", sa.Integer(), nullable=False), + sa.Column("employee_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.Column("attendance_date", sa.Date(), nullable=False), + sa.Column("punch_in_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("punch_out_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("work_duration_minutes", sa.Integer(), nullable=True), + sa.Column("status", sa.String(length=30), nullable=False, server_default="present"), + sa.Column("approval_status", sa.String(length=30), nullable=False, server_default="approved"), + sa.Column("source", sa.String(length=30), nullable=False, server_default="self_punch"), + sa.Column("remarks", sa.Text(), nullable=True), + sa.Column("reviewed_by_user_id", sa.Integer(), nullable=True), + sa.Column("reviewed_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("review_notes", sa.Text(), nullable=True), + sa.Column("created_by_user_id", sa.Integer(), nullable=True), + sa.Column("updated_by_user_id", sa.Integer(), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["employee_id"], ["employees.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["reviewed_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("tenant_id", "employee_id", "attendance_date", name="uq_employee_attendance_employee_date"), + ) + for col in [ + "tenant_id", + "branch_id", + "employee_id", + "user_id", + "attendance_date", + "status", + "approval_status", + "source", + ]: + op.create_index(f"ix_employee_attendance_{col}", "employee_attendance", [col], unique=False) + + +def downgrade() -> None: + op.drop_table("employee_attendance") diff --git a/alembic/versions/20260516_phase_6d_employee_leave.py b/alembic/versions/20260516_phase_6d_employee_leave.py new file mode 100644 index 0000000..9b75d34 --- /dev/null +++ b/alembic/versions/20260516_phase_6d_employee_leave.py @@ -0,0 +1,110 @@ +"""Phase 6D employee leave management + +Revision ID: 20260516_phase_6d_employee_leave +Revises: 20260515_phase_6c_employee_attendance +Create Date: 2026-05-10 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "20260516_phase_6d_employee_leave" +down_revision = "20260515_phase_6c_employee_attendance" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "employee_leave_types", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("branch_id", sa.Integer(), nullable=False), + sa.Column("code", sa.String(length=30), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("annual_quota_days", sa.Integer(), nullable=False, server_default="0"), + sa.Column("carry_forward_allowed", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("allow_negative_balance", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("requires_approval", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("is_paid", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("created_by_user_id", sa.Integer(), nullable=True), + sa.Column("updated_by_user_id", sa.Integer(), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("tenant_id", "branch_id", "code", name="uq_employee_leave_types_tenant_branch_code"), + ) + for col in ["tenant_id", "branch_id", "code", "is_active"]: + op.create_index(f"ix_employee_leave_types_{col}", "employee_leave_types", [col], unique=False) + + op.create_table( + "employee_leave_balances", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("branch_id", sa.Integer(), nullable=False), + sa.Column("employee_id", sa.Integer(), nullable=False), + sa.Column("leave_type_id", sa.Integer(), nullable=False), + sa.Column("opening_days", sa.Integer(), nullable=False, server_default="0"), + sa.Column("credited_days", sa.Integer(), nullable=False, server_default="0"), + sa.Column("availed_days", sa.Integer(), nullable=False, server_default="0"), + sa.Column("adjusted_days", sa.Integer(), nullable=False, server_default="0"), + sa.Column("balance_days", sa.Integer(), nullable=False, server_default="0"), + sa.Column("updated_by_user_id", sa.Integer(), nullable=True), + sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]), + sa.ForeignKeyConstraint(["employee_id"], ["employees.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["leave_type_id"], ["employee_leave_types.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("tenant_id", "employee_id", "leave_type_id", name="uq_employee_leave_balances_employee_type"), + ) + for col in ["tenant_id", "branch_id", "employee_id", "leave_type_id"]: + op.create_index(f"ix_employee_leave_balances_{col}", "employee_leave_balances", [col], unique=False) + + op.create_table( + "employee_leave_requests", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("branch_id", sa.Integer(), nullable=False), + sa.Column("employee_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.Column("leave_type_id", sa.Integer(), nullable=False), + sa.Column("from_date", sa.Date(), nullable=False), + sa.Column("to_date", sa.Date(), nullable=False), + sa.Column("days", sa.Integer(), nullable=False, server_default="1"), + sa.Column("reason", sa.Text(), nullable=True), + sa.Column("status", sa.String(length=30), nullable=False, server_default="pending"), + sa.Column("request_source", sa.String(length=30), nullable=False, server_default="employee_portal"), + sa.Column("reviewed_by_user_id", sa.Integer(), nullable=True), + sa.Column("reviewed_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("review_notes", sa.Text(), nullable=True), + sa.Column("created_by_user_id", sa.Integer(), nullable=True), + sa.Column("updated_by_user_id", sa.Integer(), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["employee_id"], ["employees.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["leave_type_id"], ["employee_leave_types.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["reviewed_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + for col in ["tenant_id", "branch_id", "employee_id", "user_id", "leave_type_id", "from_date", "to_date", "status", "request_source"]: + op.create_index(f"ix_employee_leave_requests_{col}", "employee_leave_requests", [col], unique=False) + + +def downgrade() -> None: + op.drop_table("employee_leave_requests") + op.drop_table("employee_leave_balances") + op.drop_table("employee_leave_types") diff --git a/alembic/versions/20260517_phase_6e_employee_documents.py b/alembic/versions/20260517_phase_6e_employee_documents.py new file mode 100644 index 0000000..fd9e6b5 --- /dev/null +++ b/alembic/versions/20260517_phase_6e_employee_documents.py @@ -0,0 +1,108 @@ +"""Phase 6E employee document management + +Revision ID: 20260517_phase_6e_employee_documents +Revises: 20260516_phase_6d_employee_leave +Create Date: 2026-05-10 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "20260517_phase_6e_employee_documents" +down_revision = "20260516_phase_6d_employee_leave" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "employee_document_types", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("branch_id", sa.Integer(), nullable=False), + sa.Column("code", sa.String(length=50), nullable=False), + sa.Column("name", sa.String(length=150), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("is_mandatory", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("allow_employee_upload", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("requires_verification", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("created_by_user_id", sa.Integer(), nullable=True), + sa.Column("updated_by_user_id", sa.Integer(), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("tenant_id", "branch_id", "code", name="uq_employee_document_types_tenant_branch_code"), + ) + op.create_index("ix_employee_document_types_tenant_id", "employee_document_types", ["tenant_id"]) + op.create_index("ix_employee_document_types_branch_id", "employee_document_types", ["branch_id"]) + op.create_index("ix_employee_document_types_code", "employee_document_types", ["code"]) + op.create_index("ix_employee_document_types_is_active", "employee_document_types", ["is_active"]) + + op.create_table( + "employee_documents", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("branch_id", sa.Integer(), nullable=False), + sa.Column("employee_id", sa.Integer(), nullable=False), + sa.Column("document_type_id", sa.Integer(), nullable=True), + sa.Column("title", sa.String(length=200), nullable=False), + sa.Column("document_no", sa.String(length=100), nullable=True), + sa.Column("issue_date", sa.Date(), nullable=True), + sa.Column("expiry_date", sa.Date(), nullable=True), + sa.Column("original_filename", sa.String(length=255), nullable=False), + sa.Column("stored_filename", sa.String(length=255), nullable=False), + sa.Column("storage_path", sa.String(length=500), nullable=False), + sa.Column("content_type", sa.String(length=150), nullable=True), + sa.Column("file_size_bytes", sa.Integer(), nullable=True), + sa.Column("status", sa.String(length=30), nullable=False, server_default="uploaded"), + sa.Column("visibility", sa.String(length=30), nullable=False, server_default="employee_and_hr"), + sa.Column("remarks", sa.Text(), nullable=True), + sa.Column("verified_by_user_id", sa.Integer(), nullable=True), + sa.Column("verified_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("verification_notes", sa.Text(), nullable=True), + sa.Column("uploaded_by_user_id", sa.Integer(), nullable=True), + sa.Column("updated_by_user_id", sa.Integer(), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]), + sa.ForeignKeyConstraint(["employee_id"], ["employees.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["document_type_id"], ["employee_document_types.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["verified_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["uploaded_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_employee_documents_tenant_id", "employee_documents", ["tenant_id"]) + op.create_index("ix_employee_documents_branch_id", "employee_documents", ["branch_id"]) + op.create_index("ix_employee_documents_employee_id", "employee_documents", ["employee_id"]) + op.create_index("ix_employee_documents_document_type_id", "employee_documents", ["document_type_id"]) + op.create_index("ix_employee_documents_title", "employee_documents", ["title"]) + op.create_index("ix_employee_documents_document_no", "employee_documents", ["document_no"]) + op.create_index("ix_employee_documents_expiry_date", "employee_documents", ["expiry_date"]) + op.create_index("ix_employee_documents_status", "employee_documents", ["status"]) + op.create_index("ix_employee_documents_visibility", "employee_documents", ["visibility"]) + + +def downgrade() -> None: + op.drop_index("ix_employee_documents_visibility", table_name="employee_documents") + op.drop_index("ix_employee_documents_status", table_name="employee_documents") + op.drop_index("ix_employee_documents_expiry_date", table_name="employee_documents") + op.drop_index("ix_employee_documents_document_no", table_name="employee_documents") + op.drop_index("ix_employee_documents_title", table_name="employee_documents") + op.drop_index("ix_employee_documents_document_type_id", table_name="employee_documents") + op.drop_index("ix_employee_documents_employee_id", table_name="employee_documents") + op.drop_index("ix_employee_documents_branch_id", table_name="employee_documents") + op.drop_index("ix_employee_documents_tenant_id", table_name="employee_documents") + op.drop_table("employee_documents") + op.drop_index("ix_employee_document_types_is_active", table_name="employee_document_types") + op.drop_index("ix_employee_document_types_code", table_name="employee_document_types") + op.drop_index("ix_employee_document_types_branch_id", table_name="employee_document_types") + op.drop_index("ix_employee_document_types_tenant_id", table_name="employee_document_types") + op.drop_table("employee_document_types") diff --git a/alembic/versions/20260518_ds6_permanent_client_document_vault.py b/alembic/versions/20260518_ds6_permanent_client_document_vault.py new file mode 100644 index 0000000..86cfb2c --- /dev/null +++ b/alembic/versions/20260518_ds6_permanent_client_document_vault.py @@ -0,0 +1,126 @@ +"""DS6 permanent client document vault + +Revision ID: 20260518_ds6_permanent_client_document_vault +Revises: +Create Date: 2026-05-18 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "20260518_ds6_permanent_client_document_vault" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "permanent_client_documents", + 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("branch_id", sa.Integer(), sa.ForeignKey("branches.id", ondelete="SET NULL"), nullable=True), + sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False), + sa.Column("document_code", sa.String(80), nullable=False), + sa.Column("category", sa.String(120), nullable=False, server_default="Other Permanent Documents"), + sa.Column("title", sa.String(255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("current_version_no", sa.Integer(), nullable=False, server_default="0"), + sa.Column("status", sa.String(30), nullable=False, server_default="active"), + sa.Column("is_deleted", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("deleted_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("deleted_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("updated_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), 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", "document_code", name="uq_permanent_client_documents_code"), + ) + for col in ["tenant_id", "branch_id", "client_id", "document_code", "category", "status", "is_deleted"]: + op.create_index(f"ix_permanent_client_documents_{col}", "permanent_client_documents", [col]) + + op.create_table( + "permanent_client_document_versions", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("document_id", sa.Integer(), sa.ForeignKey("permanent_client_documents.id", ondelete="CASCADE"), nullable=False), + sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False), + sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id", ondelete="SET NULL"), nullable=True), + sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False), + sa.Column("version_no", sa.Integer(), nullable=False), + sa.Column("original_filename", sa.String(255), nullable=False), + sa.Column("stored_filename", sa.String(255), nullable=False), + sa.Column("content_type", sa.String(150), nullable=True), + sa.Column("file_size_bytes", sa.Integer(), nullable=False, server_default="0"), + sa.Column("file_hash_sha256", sa.String(64), nullable=False), + sa.Column("storage_backend", sa.String(40), nullable=False, server_default="LOCAL_PERMANENT"), + sa.Column("local_relative_path", sa.String(1000), nullable=False), + sa.Column("storage_status", sa.String(30), nullable=False, server_default="stored"), + sa.Column("remarks", sa.Text(), nullable=True), + sa.Column("uploaded_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("uploaded_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint("document_id", "version_no", name="uq_permanent_client_document_versions_no"), + ) + for col in ["document_id", "tenant_id", "branch_id", "client_id", "file_hash_sha256", "storage_backend", "storage_status"]: + op.create_index(f"ix_permanent_client_document_versions_{col}", "permanent_client_document_versions", [col]) + + op.create_table( + "permanent_document_storage_jobs", + 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("branch_id", sa.Integer(), sa.ForeignKey("branches.id", ondelete="SET NULL"), nullable=True), + sa.Column("storage_node_id", sa.Integer(), sa.ForeignKey("branch_storage_nodes.id", ondelete="CASCADE"), nullable=False), + sa.Column("document_id", sa.Integer(), sa.ForeignKey("permanent_client_documents.id", ondelete="CASCADE"), nullable=False), + sa.Column("version_id", sa.Integer(), sa.ForeignKey("permanent_client_document_versions.id", ondelete="CASCADE"), nullable=False), + sa.Column("job_type", sa.String(40), nullable=False, server_default="store_permanent_version"), + sa.Column("status", sa.String(30), nullable=False, server_default="pending"), + sa.Column("priority", sa.Integer(), nullable=False, server_default="5"), + sa.Column("staging_relative_path", sa.String(1000), nullable=False), + sa.Column("target_relative_path", sa.String(1000), nullable=False), + sa.Column("file_size_bytes", sa.Integer(), nullable=False, server_default="0"), + sa.Column("expected_hash_sha256", sa.String(64), nullable=False), + sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"), + sa.Column("last_error", sa.Text(), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("picked_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("completed_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("acknowledged_hash_sha256", sa.String(64), nullable=True), + sa.Column("local_final_path", sa.String(1000), nullable=True), + sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + ) + for col in ["tenant_id", "branch_id", "storage_node_id", "document_id", "version_id", "job_type", "status", "priority", "expected_hash_sha256"]: + op.create_index(f"ix_permanent_document_storage_jobs_{col}", "permanent_document_storage_jobs", [col]) + + op.create_table( + "permanent_document_download_requests", + 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("branch_id", sa.Integer(), sa.ForeignKey("branches.id", ondelete="SET NULL"), nullable=True), + sa.Column("storage_node_id", sa.Integer(), sa.ForeignKey("branch_storage_nodes.id", ondelete="CASCADE"), nullable=False), + sa.Column("document_id", sa.Integer(), sa.ForeignKey("permanent_client_documents.id", ondelete="CASCADE"), nullable=False), + sa.Column("version_id", sa.Integer(), sa.ForeignKey("permanent_client_document_versions.id", ondelete="CASCADE"), nullable=False), + sa.Column("request_status", sa.String(30), nullable=False, server_default="pending"), + sa.Column("local_relative_path", sa.String(1000), nullable=True), + sa.Column("expected_hash_sha256", sa.String(64), nullable=False), + sa.Column("cached_relative_path", sa.String(1000), nullable=True), + sa.Column("cached_hash_sha256", sa.String(64), nullable=True), + sa.Column("file_size_bytes", sa.Integer(), nullable=False, server_default="0"), + sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"), + sa.Column("last_error", sa.Text(), nullable=True), + sa.Column("requested_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("requested_ip", sa.String(80), nullable=True), + sa.Column("requested_user_agent", sa.String(500), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("fulfilled_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("failed_at_utc", sa.DateTime(timezone=True), nullable=True), + ) + for col in ["tenant_id", "branch_id", "storage_node_id", "document_id", "version_id", "request_status", "expected_hash_sha256", "requested_by_user_id"]: + op.create_index(f"ix_permanent_document_download_requests_{col}", "permanent_document_download_requests", [col]) + + +def downgrade() -> None: + op.drop_table("permanent_document_download_requests") + op.drop_table("permanent_document_storage_jobs") + op.drop_table("permanent_client_document_versions") + op.drop_table("permanent_client_documents") diff --git a/alembic/versions/20260518_phase_6f_employee_onboarding_offboarding.py b/alembic/versions/20260518_phase_6f_employee_onboarding_offboarding.py new file mode 100644 index 0000000..3de7ac7 --- /dev/null +++ b/alembic/versions/20260518_phase_6f_employee_onboarding_offboarding.py @@ -0,0 +1,121 @@ +"""phase 6f employee onboarding and offboarding + +Revision ID: 20260518_phase_6f_employee_onboarding_offboarding +Revises: 20260517_phase_6e_employee_documents +Create Date: 2026-05-18 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "20260518_phase_6f_employee_onboarding_offboarding" +down_revision = "20260517_phase_6e_employee_documents" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "employee_onboarding_checklist_items", + 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("branch_id", sa.Integer(), sa.ForeignKey("branches.id"), nullable=False), + sa.Column("code", sa.String(length=50), nullable=False), + sa.Column("title", sa.String(length=200), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("stage", sa.String(length=50), nullable=False, server_default="joining"), + sa.Column("default_due_days", sa.Integer(), nullable=False, server_default="0"), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"), + sa.Column("is_mandatory", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("updated_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint("tenant_id", "branch_id", "code", name="uq_employee_onboarding_items_tenant_branch_code"), + ) + op.create_index("ix_employee_onboarding_checklist_items_tenant_id", "employee_onboarding_checklist_items", ["tenant_id"]) + op.create_index("ix_employee_onboarding_checklist_items_branch_id", "employee_onboarding_checklist_items", ["branch_id"]) + op.create_index("ix_employee_onboarding_checklist_items_code", "employee_onboarding_checklist_items", ["code"]) + op.create_index("ix_employee_onboarding_checklist_items_is_active", "employee_onboarding_checklist_items", ["is_active"]) + + op.create_table( + "employee_onboarding_tasks", + 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("branch_id", sa.Integer(), sa.ForeignKey("branches.id"), nullable=False), + sa.Column("employee_id", sa.Integer(), sa.ForeignKey("employees.id", ondelete="CASCADE"), nullable=False), + sa.Column("checklist_item_id", sa.Integer(), sa.ForeignKey("employee_onboarding_checklist_items.id", ondelete="SET NULL"), nullable=True), + sa.Column("title", sa.String(length=200), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("stage", sa.String(length=50), nullable=False, server_default="joining"), + sa.Column("due_date", sa.Date(), nullable=True), + sa.Column("status", sa.String(length=30), nullable=False, server_default="pending"), + sa.Column("assigned_to_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("completed_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("completed_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("review_notes", sa.Text(), nullable=True), + sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("updated_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint("tenant_id", "employee_id", "checklist_item_id", name="uq_employee_onboarding_task_employee_item"), + ) + for col in ["tenant_id", "branch_id", "employee_id", "checklist_item_id", "due_date", "status", "assigned_to_user_id"]: + op.create_index(f"ix_employee_onboarding_tasks_{col}", "employee_onboarding_tasks", [col]) + + op.create_table( + "employee_offboarding_requests", + 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("branch_id", sa.Integer(), sa.ForeignKey("branches.id"), nullable=False), + sa.Column("employee_id", sa.Integer(), sa.ForeignKey("employees.id", ondelete="CASCADE"), nullable=False), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("request_type", sa.String(length=50), nullable=False, server_default="resignation"), + sa.Column("requested_relieving_date", sa.Date(), nullable=True), + sa.Column("approved_relieving_date", sa.Date(), nullable=True), + sa.Column("reason", sa.Text(), nullable=True), + sa.Column("handover_notes", sa.Text(), nullable=True), + sa.Column("status", sa.String(length=30), nullable=False, server_default="pending"), + sa.Column("requested_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("reviewed_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("reviewed_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("review_notes", sa.Text(), nullable=True), + sa.Column("completed_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("completed_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False), + ) + for col in ["tenant_id", "branch_id", "employee_id", "user_id", "request_type", "requested_relieving_date", "approved_relieving_date", "status"]: + op.create_index(f"ix_employee_offboarding_requests_{col}", "employee_offboarding_requests", [col]) + + op.create_table( + "employee_offboarding_tasks", + 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("branch_id", sa.Integer(), sa.ForeignKey("branches.id"), nullable=False), + sa.Column("request_id", sa.Integer(), sa.ForeignKey("employee_offboarding_requests.id", ondelete="CASCADE"), nullable=False), + sa.Column("employee_id", sa.Integer(), sa.ForeignKey("employees.id", ondelete="CASCADE"), nullable=False), + sa.Column("title", sa.String(length=200), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("due_date", sa.Date(), nullable=True), + sa.Column("status", sa.String(length=30), nullable=False, server_default="pending"), + sa.Column("assigned_to_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("completed_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("completed_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("review_notes", sa.Text(), nullable=True), + sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("updated_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False), + ) + for col in ["tenant_id", "branch_id", "request_id", "employee_id", "due_date", "status", "assigned_to_user_id"]: + op.create_index(f"ix_employee_offboarding_tasks_{col}", "employee_offboarding_tasks", [col]) + + +def downgrade() -> None: + op.drop_table("employee_offboarding_tasks") + op.drop_table("employee_offboarding_requests") + op.drop_table("employee_onboarding_tasks") + op.drop_table("employee_onboarding_checklist_items") diff --git a/alembic/versions/20260519_phase_6g_employee_payroll.py b/alembic/versions/20260519_phase_6g_employee_payroll.py new file mode 100644 index 0000000..c61a327 --- /dev/null +++ b/alembic/versions/20260519_phase_6g_employee_payroll.py @@ -0,0 +1,113 @@ +"""Phase 6G employee payroll foundation + +Revision ID: 20260519_phase_6g_employee_payroll +Revises: 20260518_phase_6f_employee_onboarding_offboarding +Create Date: 2026-05-19 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "20260519_phase_6g_employee_payroll" +down_revision = "20260518_phase_6f_employee_onboarding_offboarding" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "employee_salary_structures", + 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("branch_id", sa.Integer(), sa.ForeignKey("branches.id"), nullable=False), + sa.Column("employee_id", sa.Integer(), sa.ForeignKey("employees.id", ondelete="CASCADE"), nullable=False), + sa.Column("effective_from", sa.Date(), nullable=False), + sa.Column("effective_to", sa.Date(), nullable=True), + sa.Column("pay_cycle", sa.String(length=30), nullable=False, server_default="monthly"), + sa.Column("monthly_ctc_amount", sa.Integer(), nullable=False, server_default="0"), + sa.Column("basic_amount", sa.Integer(), nullable=False, server_default="0"), + sa.Column("hra_amount", sa.Integer(), nullable=False, server_default="0"), + sa.Column("allowance_amount", sa.Integer(), nullable=False, server_default="0"), + sa.Column("employee_pf_amount", sa.Integer(), nullable=False, server_default="0"), + sa.Column("employee_esi_amount", sa.Integer(), nullable=False, server_default="0"), + sa.Column("professional_tax_amount", sa.Integer(), nullable=False, server_default="0"), + sa.Column("tds_amount", sa.Integer(), nullable=False, server_default="0"), + sa.Column("other_deduction_amount", sa.Integer(), nullable=False, server_default="0"), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("remarks", sa.Text(), nullable=True), + sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("updated_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint("tenant_id", "employee_id", "effective_from", name="uq_employee_salary_structure_effective"), + ) + op.create_index("ix_employee_salary_structures_tenant_id", "employee_salary_structures", ["tenant_id"]) + op.create_index("ix_employee_salary_structures_branch_id", "employee_salary_structures", ["branch_id"]) + op.create_index("ix_employee_salary_structures_employee_id", "employee_salary_structures", ["employee_id"]) + + op.create_table( + "employee_payroll_runs", + 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("branch_id", sa.Integer(), sa.ForeignKey("branches.id"), nullable=False), + sa.Column("pay_year", sa.Integer(), nullable=False), + sa.Column("pay_month", sa.Integer(), nullable=False), + sa.Column("run_name", sa.String(length=150), nullable=False), + sa.Column("status", sa.String(length=30), nullable=False, server_default="draft"), + sa.Column("total_employees", sa.Integer(), nullable=False, server_default="0"), + sa.Column("gross_amount", sa.Integer(), nullable=False, server_default="0"), + sa.Column("deduction_amount", sa.Integer(), nullable=False, server_default="0"), + sa.Column("net_amount", sa.Integer(), nullable=False, server_default="0"), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("processed_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("approved_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("approved_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("paid_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("paid_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint("tenant_id", "branch_id", "pay_year", "pay_month", name="uq_employee_payroll_run_period"), + ) + op.create_index("ix_employee_payroll_runs_tenant_id", "employee_payroll_runs", ["tenant_id"]) + op.create_index("ix_employee_payroll_runs_branch_id", "employee_payroll_runs", ["branch_id"]) + op.create_index("ix_employee_payroll_runs_period", "employee_payroll_runs", ["pay_year", "pay_month"]) + + op.create_table( + "employee_payslips", + 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("branch_id", sa.Integer(), sa.ForeignKey("branches.id"), nullable=False), + sa.Column("payroll_run_id", sa.Integer(), sa.ForeignKey("employee_payroll_runs.id", ondelete="CASCADE"), nullable=False), + sa.Column("employee_id", sa.Integer(), sa.ForeignKey("employees.id", ondelete="CASCADE"), nullable=False), + sa.Column("salary_structure_id", sa.Integer(), sa.ForeignKey("employee_salary_structures.id", ondelete="SET NULL"), nullable=True), + sa.Column("pay_year", sa.Integer(), nullable=False), + sa.Column("pay_month", sa.Integer(), nullable=False), + sa.Column("basic_amount", sa.Integer(), nullable=False, server_default="0"), + sa.Column("hra_amount", sa.Integer(), nullable=False, server_default="0"), + sa.Column("allowance_amount", sa.Integer(), nullable=False, server_default="0"), + sa.Column("gross_amount", sa.Integer(), nullable=False, server_default="0"), + sa.Column("employee_pf_amount", sa.Integer(), nullable=False, server_default="0"), + sa.Column("employee_esi_amount", sa.Integer(), nullable=False, server_default="0"), + sa.Column("professional_tax_amount", sa.Integer(), nullable=False, server_default="0"), + sa.Column("tds_amount", sa.Integer(), nullable=False, server_default="0"), + sa.Column("other_deduction_amount", sa.Integer(), nullable=False, server_default="0"), + sa.Column("deduction_amount", sa.Integer(), nullable=False, server_default="0"), + sa.Column("net_amount", sa.Integer(), nullable=False, server_default="0"), + sa.Column("status", sa.String(length=30), nullable=False, server_default="generated"), + sa.Column("remarks", sa.Text(), nullable=True), + sa.Column("generated_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint("tenant_id", "payroll_run_id", "employee_id", name="uq_employee_payslip_run_employee"), + ) + op.create_index("ix_employee_payslips_tenant_id", "employee_payslips", ["tenant_id"]) + op.create_index("ix_employee_payslips_branch_id", "employee_payslips", ["branch_id"]) + op.create_index("ix_employee_payslips_payroll_run_id", "employee_payslips", ["payroll_run_id"]) + op.create_index("ix_employee_payslips_employee_id", "employee_payslips", ["employee_id"]) + + +def downgrade() -> None: + op.drop_table("employee_payslips") + op.drop_table("employee_payroll_runs") + op.drop_table("employee_salary_structures") diff --git a/alembic/versions/20260520_phase_6k_geo_attendance.py b/alembic/versions/20260520_phase_6k_geo_attendance.py new file mode 100644 index 0000000..15d9b25 --- /dev/null +++ b/alembic/versions/20260520_phase_6k_geo_attendance.py @@ -0,0 +1,63 @@ +"""Phase 6K geo-fenced attendance and OD approval + +Revision ID: 20260520_phase_6k_geo_attendance +Revises: 20260519_phase_6g_employee_payroll +Create Date: 2026-05-20 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "20260520_phase_6k_geo_attendance" +down_revision = "20260519_phase_6g_employee_payroll" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("branch_settings", sa.Column("attendance_geo_enabled", sa.Boolean(), nullable=False, server_default=sa.false())) + op.add_column("branch_settings", sa.Column("attendance_geo_radius_meters", sa.Integer(), nullable=False, server_default="100")) + op.add_column("branch_settings", sa.Column("attendance_ip_enabled", sa.Boolean(), nullable=False, server_default=sa.false())) + op.add_column("branch_settings", sa.Column("attendance_allowed_ip_csv", sa.Text(), nullable=True)) + + op.add_column("employee_attendance", sa.Column("punch_in_latitude", sa.Float(), nullable=True)) + op.add_column("employee_attendance", sa.Column("punch_in_longitude", sa.Float(), nullable=True)) + op.add_column("employee_attendance", sa.Column("punch_in_accuracy_meters", sa.Float(), nullable=True)) + op.add_column("employee_attendance", sa.Column("punch_in_distance_meters", sa.Float(), nullable=True)) + op.add_column("employee_attendance", sa.Column("punch_in_ip", sa.String(length=80), nullable=True)) + op.add_column("employee_attendance", sa.Column("punch_in_geo_status", sa.String(length=40), nullable=True)) + op.add_column("employee_attendance", sa.Column("punch_in_ip_status", sa.String(length=40), nullable=True)) + + op.add_column("employee_attendance", sa.Column("punch_out_latitude", sa.Float(), nullable=True)) + op.add_column("employee_attendance", sa.Column("punch_out_longitude", sa.Float(), nullable=True)) + op.add_column("employee_attendance", sa.Column("punch_out_accuracy_meters", sa.Float(), nullable=True)) + op.add_column("employee_attendance", sa.Column("punch_out_distance_meters", sa.Float(), nullable=True)) + op.add_column("employee_attendance", sa.Column("punch_out_ip", sa.String(length=80), nullable=True)) + op.add_column("employee_attendance", sa.Column("punch_out_geo_status", sa.String(length=40), nullable=True)) + op.add_column("employee_attendance", sa.Column("punch_out_ip_status", sa.String(length=40), nullable=True)) + + op.create_index("ix_employee_attendance_punch_in_geo_status", "employee_attendance", ["punch_in_geo_status"]) + op.create_index("ix_employee_attendance_punch_in_ip_status", "employee_attendance", ["punch_in_ip_status"]) + op.create_index("ix_employee_attendance_punch_out_geo_status", "employee_attendance", ["punch_out_geo_status"]) + op.create_index("ix_employee_attendance_punch_out_ip_status", "employee_attendance", ["punch_out_ip_status"]) + + +def downgrade() -> None: + op.drop_index("ix_employee_attendance_punch_out_ip_status", table_name="employee_attendance") + op.drop_index("ix_employee_attendance_punch_out_geo_status", table_name="employee_attendance") + op.drop_index("ix_employee_attendance_punch_in_ip_status", table_name="employee_attendance") + op.drop_index("ix_employee_attendance_punch_in_geo_status", table_name="employee_attendance") + + for col in [ + "punch_out_ip_status", "punch_out_geo_status", "punch_out_ip", "punch_out_distance_meters", + "punch_out_accuracy_meters", "punch_out_longitude", "punch_out_latitude", + "punch_in_ip_status", "punch_in_geo_status", "punch_in_ip", "punch_in_distance_meters", + "punch_in_accuracy_meters", "punch_in_longitude", "punch_in_latitude", + ]: + op.drop_column("employee_attendance", col) + + op.drop_column("branch_settings", "attendance_allowed_ip_csv") + op.drop_column("branch_settings", "attendance_ip_enabled") + op.drop_column("branch_settings", "attendance_geo_radius_meters") + op.drop_column("branch_settings", "attendance_geo_enabled") diff --git a/alembic/versions/20260521_phase_6l_branch_timezone_attendance_rules.py b/alembic/versions/20260521_phase_6l_branch_timezone_attendance_rules.py new file mode 100644 index 0000000..74e58a1 --- /dev/null +++ b/alembic/versions/20260521_phase_6l_branch_timezone_attendance_rules.py @@ -0,0 +1,50 @@ +"""Phase 6L branch timezone and attendance timing rules + +Revision ID: 20260521_phase_6l_branch_timezone_attendance_rules +Revises: 20260520_phase_6k_geo_attendance +Create Date: 2026-05-21 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "20260521_phase_6l_branch_timezone_attendance_rules" +down_revision = "20260520_phase_6k_geo_attendance" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("branch_settings", sa.Column("attendance_grace_minutes", sa.Integer(), nullable=False, server_default="10")) + op.add_column("branch_settings", sa.Column("attendance_half_day_after_time", sa.Time(), nullable=True)) + op.add_column("branch_settings", sa.Column("attendance_rule_enabled", sa.Boolean(), nullable=False, server_default=sa.true())) + + op.add_column("employee_attendance", sa.Column("punch_in_local_at", sa.DateTime(timezone=False), nullable=True)) + op.add_column("employee_attendance", sa.Column("punch_out_local_at", sa.DateTime(timezone=False), nullable=True)) + op.add_column("employee_attendance", sa.Column("branch_timezone", sa.String(length=64), nullable=False, server_default="Asia/Kolkata")) + op.add_column("employee_attendance", sa.Column("scheduled_start_local", sa.Time(), nullable=True)) + op.add_column("employee_attendance", sa.Column("scheduled_end_local", sa.Time(), nullable=True)) + op.add_column("employee_attendance", sa.Column("late_by_minutes", sa.Integer(), nullable=True)) + op.add_column("employee_attendance", sa.Column("attendance_rule_status", sa.String(length=40), nullable=True)) + op.add_column("employee_attendance", sa.Column("is_weekly_off", sa.Boolean(), nullable=False, server_default=sa.false())) + op.create_index("ix_employee_attendance_attendance_rule_status", "employee_attendance", ["attendance_rule_status"]) + + +def downgrade() -> None: + op.drop_index("ix_employee_attendance_attendance_rule_status", table_name="employee_attendance") + for col in [ + "is_weekly_off", + "attendance_rule_status", + "late_by_minutes", + "scheduled_end_local", + "scheduled_start_local", + "branch_timezone", + "punch_out_local_at", + "punch_in_local_at", + ]: + op.drop_column("employee_attendance", col) + + op.drop_column("branch_settings", "attendance_rule_enabled") + op.drop_column("branch_settings", "attendance_half_day_after_time") + op.drop_column("branch_settings", "attendance_grace_minutes") diff --git a/alembic/versions/20260522_phase_b1_b2_billing.py b/alembic/versions/20260522_phase_b1_b2_billing.py new file mode 100644 index 0000000..31c1966 --- /dev/null +++ b/alembic/versions/20260522_phase_b1_b2_billing.py @@ -0,0 +1,181 @@ +"""Phase B1 B2 firm billing and fee structure import + +Revision ID: 20260522_phase_b1_b2_billing +Revises: 20260521_phase_6l_branch_timezone_attendance_rules +Create Date: 2026-05-22 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "20260522_phase_b1_b2_billing" +down_revision = "20260521_phase_6l_branch_timezone_attendance_rules" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "billing_settings", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("branch_id", sa.Integer(), nullable=True), + sa.Column("invoice_prefix", sa.String(length=40), nullable=False, server_default="INV"), + sa.Column("next_invoice_no", sa.Integer(), nullable=False, server_default="1"), + sa.Column("padding", sa.Integer(), nullable=False, server_default="4"), + sa.Column("default_gst_rate", sa.Numeric(5, 2), nullable=False, server_default="18.00"), + sa.Column("default_tax_type", sa.String(length=20), nullable=False, server_default="CGST_SGST"), + sa.Column("legal_name", sa.String(length=200), nullable=True), + sa.Column("gstin", sa.String(length=20), nullable=True), + sa.Column("billing_address", sa.Text(), nullable=True), + sa.Column("bank_details", sa.Text(), nullable=True), + sa.Column("terms", sa.Text(), nullable=True), + sa.Column("footer_note", sa.Text(), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], ondelete="SET NULL"), + sa.UniqueConstraint("tenant_id", "branch_id", name="uq_billing_settings_tenant_branch"), + ) + op.create_index("ix_billing_settings_tenant_id", "billing_settings", ["tenant_id"]) + op.create_index("ix_billing_settings_branch_id", "billing_settings", ["branch_id"]) + + op.create_table( + "billing_fee_groups", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("branch_id", sa.Integer(), nullable=True), + sa.Column("client_id", sa.Integer(), nullable=False), + sa.Column("partner_id", sa.Integer(), nullable=True), + sa.Column("group_code", sa.String(length=80), nullable=False), + sa.Column("group_name", sa.String(length=200), nullable=False), + sa.Column("billing_mode", sa.String(length=20), nullable=False, server_default="PACKAGE"), + sa.Column("frequency", sa.String(length=20), nullable=False, server_default="Monthly"), + sa.Column("fee_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"), + sa.Column("gst_rate", sa.Numeric(5, 2), nullable=False, server_default="18.00"), + sa.Column("tax_type", sa.String(length=20), nullable=False, server_default="CGST_SGST"), + sa.Column("effective_from", sa.Date(), nullable=True), + sa.Column("effective_to", sa.Date(), nullable=True), + sa.Column("auto_generate", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("created_by_user_id", sa.Integer(), nullable=True), + sa.Column("updated_by_user_id", sa.Integer(), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["client_id"], ["clients.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["partner_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]), + sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"]), + sa.UniqueConstraint("tenant_id", "group_code", name="uq_billing_fee_groups_tenant_code"), + ) + op.create_index("ix_billing_fee_groups_tenant_id", "billing_fee_groups", ["tenant_id"]) + op.create_index("ix_billing_fee_groups_branch_id", "billing_fee_groups", ["branch_id"]) + op.create_index("ix_billing_fee_groups_client_id", "billing_fee_groups", ["client_id"]) + op.create_index("ix_billing_fee_groups_partner_id", "billing_fee_groups", ["partner_id"]) + op.create_index("ix_billing_fee_groups_group_code", "billing_fee_groups", ["group_code"]) + op.create_index("ix_billing_fee_groups_is_active", "billing_fee_groups", ["is_active"]) + + op.create_table( + "billing_invoices", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("branch_id", sa.Integer(), nullable=True), + sa.Column("client_id", sa.Integer(), nullable=False), + sa.Column("engagement_id", sa.Integer(), nullable=True), + sa.Column("invoice_no", sa.String(length=60), nullable=False), + sa.Column("invoice_date", sa.Date(), nullable=False), + sa.Column("due_date", sa.Date(), nullable=True), + sa.Column("billing_period_from", sa.Date(), nullable=True), + sa.Column("billing_period_to", sa.Date(), nullable=True), + sa.Column("tax_type", sa.String(length=20), nullable=False, server_default="CGST_SGST"), + sa.Column("subtotal", sa.Numeric(14, 2), nullable=False, server_default="0.00"), + sa.Column("discount_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"), + sa.Column("taxable_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"), + sa.Column("cgst_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"), + sa.Column("sgst_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"), + sa.Column("igst_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"), + sa.Column("round_off", sa.Numeric(14, 2), nullable=False, server_default="0.00"), + sa.Column("total_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"), + sa.Column("status", sa.String(length=20), nullable=False, server_default="DRAFT"), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("terms", sa.Text(), nullable=True), + sa.Column("created_by_user_id", sa.Integer(), nullable=True), + sa.Column("approved_by_user_id", sa.Integer(), nullable=True), + sa.Column("posted_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["client_id"], ["clients.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["engagement_id"], ["client_service_subscriptions.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]), + sa.ForeignKeyConstraint(["approved_by_user_id"], ["users.id"]), + sa.UniqueConstraint("tenant_id", "invoice_no", name="uq_billing_invoices_tenant_invoice_no"), + ) + op.create_index("ix_billing_invoices_tenant_id", "billing_invoices", ["tenant_id"]) + op.create_index("ix_billing_invoices_branch_id", "billing_invoices", ["branch_id"]) + op.create_index("ix_billing_invoices_client_id", "billing_invoices", ["client_id"]) + op.create_index("ix_billing_invoices_engagement_id", "billing_invoices", ["engagement_id"]) + op.create_index("ix_billing_invoices_invoice_no", "billing_invoices", ["invoice_no"]) + op.create_index("ix_billing_invoices_invoice_date", "billing_invoices", ["invoice_date"]) + op.create_index("ix_billing_invoices_due_date", "billing_invoices", ["due_date"]) + op.create_index("ix_billing_invoices_status", "billing_invoices", ["status"]) + + op.create_table( + "billing_fee_group_services", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("fee_group_id", sa.Integer(), nullable=False), + sa.Column("service_id", sa.Integer(), nullable=False), + sa.Column("line_description", sa.String(length=500), nullable=True), + sa.Column("allocation_type", sa.String(length=20), nullable=False, server_default="Included"), + sa.Column("line_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"), + sa.Column("percentage", sa.Numeric(5, 2), nullable=True), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default="1"), + sa.ForeignKeyConstraint(["fee_group_id"], ["billing_fee_groups.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["service_id"], ["service_catalogues.id"], ondelete="RESTRICT"), + sa.UniqueConstraint("fee_group_id", "service_id", name="uq_billing_fee_group_services_group_service"), + ) + op.create_index("ix_billing_fee_group_services_fee_group_id", "billing_fee_group_services", ["fee_group_id"]) + op.create_index("ix_billing_fee_group_services_service_id", "billing_fee_group_services", ["service_id"]) + + op.create_table( + "billing_invoice_lines", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("invoice_id", sa.Integer(), nullable=False), + sa.Column("service_id", sa.Integer(), nullable=True), + sa.Column("engagement_id", sa.Integer(), nullable=True), + sa.Column("fee_group_id", sa.Integer(), nullable=True), + sa.Column("description", sa.String(length=500), nullable=False), + sa.Column("billing_period_from", sa.Date(), nullable=True), + sa.Column("billing_period_to", sa.Date(), nullable=True), + sa.Column("quantity", sa.Numeric(12, 2), nullable=False, server_default="1.00"), + sa.Column("rate", sa.Numeric(14, 2), nullable=False, server_default="0.00"), + sa.Column("discount_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"), + sa.Column("taxable_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"), + sa.Column("gst_rate", sa.Numeric(5, 2), nullable=False, server_default="18.00"), + sa.Column("cgst_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"), + sa.Column("sgst_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"), + sa.Column("igst_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"), + sa.Column("line_total", sa.Numeric(14, 2), nullable=False, server_default="0.00"), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default="1"), + sa.ForeignKeyConstraint(["invoice_id"], ["billing_invoices.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["service_id"], ["service_catalogues.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["engagement_id"], ["client_service_subscriptions.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["fee_group_id"], ["billing_fee_groups.id"], ondelete="SET NULL"), + ) + op.create_index("ix_billing_invoice_lines_invoice_id", "billing_invoice_lines", ["invoice_id"]) + op.create_index("ix_billing_invoice_lines_service_id", "billing_invoice_lines", ["service_id"]) + op.create_index("ix_billing_invoice_lines_engagement_id", "billing_invoice_lines", ["engagement_id"]) + op.create_index("ix_billing_invoice_lines_fee_group_id", "billing_invoice_lines", ["fee_group_id"]) + + +def downgrade() -> None: + op.drop_table("billing_invoice_lines") + op.drop_table("billing_fee_group_services") + op.drop_table("billing_invoices") + op.drop_table("billing_fee_groups") + op.drop_table("billing_settings") diff --git a/alembic/versions/20260523_phase_b3_billing_generation.py b/alembic/versions/20260523_phase_b3_billing_generation.py new file mode 100644 index 0000000..17da109 --- /dev/null +++ b/alembic/versions/20260523_phase_b3_billing_generation.py @@ -0,0 +1,70 @@ +"""Phase B3 generate draft invoices from fee structures + +Revision ID: 20260523_phase_b3_billing_generation +Revises: 20260522_phase_b1_b2_billing +Create Date: 2026-05-23 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "20260523_phase_b3_billing_generation" +down_revision = "20260522_phase_b1_b2_billing" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "billing_invoice_generation_batches", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("branch_id", sa.Integer(), nullable=True), + sa.Column("billing_period_from", sa.Date(), nullable=False), + sa.Column("billing_period_to", sa.Date(), nullable=False), + sa.Column("frequency", sa.String(length=20), nullable=True), + sa.Column("status", sa.String(length=20), nullable=False, server_default="DRAFT_CREATED"), + sa.Column("selected_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("created_invoice_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("skipped_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("error_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("remarks", sa.Text(), nullable=True), + sa.Column("generated_by_user_id", sa.Integer(), nullable=True), + sa.Column("generated_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["generated_by_user_id"], ["users.id"]), + ) + op.create_index("ix_billing_invoice_generation_batches_tenant_id", "billing_invoice_generation_batches", ["tenant_id"]) + op.create_index("ix_billing_invoice_generation_batches_branch_id", "billing_invoice_generation_batches", ["branch_id"]) + op.create_index("ix_billing_invoice_generation_batches_billing_period_from", "billing_invoice_generation_batches", ["billing_period_from"]) + op.create_index("ix_billing_invoice_generation_batches_billing_period_to", "billing_invoice_generation_batches", ["billing_period_to"]) + op.create_index("ix_billing_invoice_generation_batches_frequency", "billing_invoice_generation_batches", ["frequency"]) + op.create_index("ix_billing_invoice_generation_batches_status", "billing_invoice_generation_batches", ["status"]) + + with op.batch_alter_table("billing_invoices") as batch_op: + batch_op.add_column(sa.Column("generation_batch_id", sa.Integer(), nullable=True)) + batch_op.create_foreign_key( + "fk_billing_invoices_generation_batch_id", + "billing_invoice_generation_batches", + ["generation_batch_id"], + ["id"], + ondelete="SET NULL", + ) + batch_op.create_index("ix_billing_invoices_generation_batch_id", ["generation_batch_id"]) + + +def downgrade() -> None: + with op.batch_alter_table("billing_invoices") as batch_op: + batch_op.drop_index("ix_billing_invoices_generation_batch_id") + batch_op.drop_constraint("fk_billing_invoices_generation_batch_id", type_="foreignkey") + batch_op.drop_column("generation_batch_id") + + op.drop_index("ix_billing_invoice_generation_batches_status", table_name="billing_invoice_generation_batches") + op.drop_index("ix_billing_invoice_generation_batches_frequency", table_name="billing_invoice_generation_batches") + op.drop_index("ix_billing_invoice_generation_batches_billing_period_to", table_name="billing_invoice_generation_batches") + op.drop_index("ix_billing_invoice_generation_batches_billing_period_from", table_name="billing_invoice_generation_batches") + op.drop_index("ix_billing_invoice_generation_batches_branch_id", table_name="billing_invoice_generation_batches") + op.drop_index("ix_billing_invoice_generation_batches_tenant_id", table_name="billing_invoice_generation_batches") + op.drop_table("billing_invoice_generation_batches") diff --git a/alembic/versions/20260524_phase_pb1_platform_billing.py b/alembic/versions/20260524_phase_pb1_platform_billing.py new file mode 100644 index 0000000..d9093e5 --- /dev/null +++ b/alembic/versions/20260524_phase_pb1_platform_billing.py @@ -0,0 +1,234 @@ +"""Phase PB1 platform SaaS billing foundation + +Revision ID: 20260524_phase_pb1_platform_billing +Revises: 20260523_phase_b3_billing_generation +Create Date: 2026-05-24 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "20260524_phase_pb1_platform_billing" +down_revision = "20260523_phase_b3_billing_generation" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "platform_plans", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("code", sa.String(length=80), nullable=False), + sa.Column("name", sa.String(length=200), nullable=False), + sa.Column("target_account_type", sa.String(length=30), nullable=False, server_default="AUDIT_FIRM"), + sa.Column("billing_cycle", sa.String(length=20), nullable=False, server_default="Monthly"), + sa.Column("base_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"), + sa.Column("gst_rate", sa.Numeric(5, 2), nullable=False, server_default="18.00"), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint("code", name="uq_platform_plans_code"), + ) + op.create_index("ix_platform_plans_code", "platform_plans", ["code"]) + op.create_index("ix_platform_plans_target_account_type", "platform_plans", ["target_account_type"]) + op.create_index("ix_platform_plans_billing_cycle", "platform_plans", ["billing_cycle"]) + op.create_index("ix_platform_plans_is_active", "platform_plans", ["is_active"]) + + op.create_table( + "platform_plan_features", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("plan_id", sa.Integer(), nullable=False), + sa.Column("feature_code", sa.String(length=100), nullable=False), + sa.Column("feature_name", sa.String(length=200), nullable=False), + sa.Column("limit_value", sa.String(length=100), nullable=True), + sa.Column("is_enabled", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default="1"), + sa.ForeignKeyConstraint(["plan_id"], ["platform_plans.id"], ondelete="CASCADE"), + ) + op.create_index("ix_platform_plan_features_plan_id", "platform_plan_features", ["plan_id"]) + op.create_index("ix_platform_plan_features_feature_code", "platform_plan_features", ["feature_code"]) + + op.create_table( + "platform_billing_accounts", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("account_type", sa.String(length=30), nullable=False), + sa.Column("account_code", sa.String(length=80), nullable=False), + sa.Column("display_name", sa.String(length=220), nullable=False), + sa.Column("tenant_id", sa.Integer(), nullable=True), + sa.Column("client_id", sa.Integer(), nullable=True), + sa.Column("consultant_id", sa.Integer(), nullable=True), + sa.Column("email", sa.String(length=255), nullable=True), + sa.Column("mobile", sa.String(length=20), nullable=True), + sa.Column("gstin", sa.String(length=20), nullable=True), + sa.Column("pan", sa.String(length=20), nullable=True), + sa.Column("billing_address", sa.Text(), nullable=True), + sa.Column("state", sa.String(length=100), nullable=True), + sa.Column("status", sa.String(length=20), nullable=False, server_default="ACTIVE"), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("created_by_user_id", sa.Integer(), nullable=True), + sa.Column("updated_by_user_id", sa.Integer(), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["client_id"], ["clients.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["consultant_id"], ["consultant_profiles.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]), + sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"]), + sa.UniqueConstraint("account_type", "account_code", name="uq_platform_billing_accounts_type_code"), + ) + op.create_index("ix_platform_billing_accounts_account_type", "platform_billing_accounts", ["account_type"]) + op.create_index("ix_platform_billing_accounts_account_code", "platform_billing_accounts", ["account_code"]) + op.create_index("ix_platform_billing_accounts_display_name", "platform_billing_accounts", ["display_name"]) + op.create_index("ix_platform_billing_accounts_tenant_id", "platform_billing_accounts", ["tenant_id"]) + op.create_index("ix_platform_billing_accounts_client_id", "platform_billing_accounts", ["client_id"]) + op.create_index("ix_platform_billing_accounts_consultant_id", "platform_billing_accounts", ["consultant_id"]) + op.create_index("ix_platform_billing_accounts_status", "platform_billing_accounts", ["status"]) + + op.create_table( + "platform_subscriptions", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("account_id", sa.Integer(), nullable=False), + sa.Column("plan_id", sa.Integer(), nullable=False), + sa.Column("subscription_code", sa.String(length=100), nullable=False), + sa.Column("start_date", sa.Date(), nullable=False), + sa.Column("end_date", sa.Date(), nullable=True), + sa.Column("billing_cycle", sa.String(length=20), nullable=False, server_default="Monthly"), + sa.Column("amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"), + sa.Column("gst_rate", sa.Numeric(5, 2), nullable=False, server_default="18.00"), + sa.Column("status", sa.String(length=20), nullable=False, server_default="ACTIVE"), + sa.Column("auto_generate_invoice", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("created_by_user_id", sa.Integer(), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["account_id"], ["platform_billing_accounts.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["plan_id"], ["platform_plans.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]), + ) + op.create_index("ix_platform_subscriptions_account_id", "platform_subscriptions", ["account_id"]) + op.create_index("ix_platform_subscriptions_plan_id", "platform_subscriptions", ["plan_id"]) + op.create_index("ix_platform_subscriptions_subscription_code", "platform_subscriptions", ["subscription_code"]) + op.create_index("ix_platform_subscriptions_billing_cycle", "platform_subscriptions", ["billing_cycle"]) + op.create_index("ix_platform_subscriptions_status", "platform_subscriptions", ["status"]) + + op.create_table( + "platform_invoices", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("account_id", sa.Integer(), nullable=False), + sa.Column("subscription_id", sa.Integer(), nullable=True), + sa.Column("invoice_no", sa.String(length=80), nullable=False), + sa.Column("invoice_date", sa.Date(), nullable=False), + sa.Column("due_date", sa.Date(), nullable=True), + sa.Column("billing_period_from", sa.Date(), nullable=True), + sa.Column("billing_period_to", sa.Date(), nullable=True), + sa.Column("tax_type", sa.String(length=20), nullable=False, server_default="CGST_SGST"), + sa.Column("subtotal", sa.Numeric(14, 2), nullable=False, server_default="0.00"), + sa.Column("discount_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"), + sa.Column("taxable_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"), + sa.Column("cgst_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"), + sa.Column("sgst_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"), + sa.Column("igst_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"), + sa.Column("total_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"), + sa.Column("status", sa.String(length=20), nullable=False, server_default="DRAFT"), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("created_by_user_id", sa.Integer(), nullable=True), + sa.Column("posted_by_user_id", sa.Integer(), nullable=True), + sa.Column("posted_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["account_id"], ["platform_billing_accounts.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["subscription_id"], ["platform_subscriptions.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]), + sa.ForeignKeyConstraint(["posted_by_user_id"], ["users.id"]), + sa.UniqueConstraint("invoice_no", name="uq_platform_invoices_invoice_no"), + ) + op.create_index("ix_platform_invoices_account_id", "platform_invoices", ["account_id"]) + op.create_index("ix_platform_invoices_subscription_id", "platform_invoices", ["subscription_id"]) + op.create_index("ix_platform_invoices_invoice_no", "platform_invoices", ["invoice_no"]) + op.create_index("ix_platform_invoices_invoice_date", "platform_invoices", ["invoice_date"]) + op.create_index("ix_platform_invoices_due_date", "platform_invoices", ["due_date"]) + op.create_index("ix_platform_invoices_status", "platform_invoices", ["status"]) + + op.create_table( + "platform_invoice_lines", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("invoice_id", sa.Integer(), nullable=False), + sa.Column("charge_type", sa.String(length=40), nullable=False, server_default="SUBSCRIPTION"), + sa.Column("description", sa.String(length=500), nullable=False), + sa.Column("reference_type", sa.String(length=60), nullable=True), + sa.Column("reference_id", sa.Integer(), nullable=True), + sa.Column("quantity", sa.Numeric(12, 2), nullable=False, server_default="1.00"), + sa.Column("rate", sa.Numeric(14, 2), nullable=False, server_default="0.00"), + sa.Column("discount_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"), + sa.Column("taxable_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"), + sa.Column("gst_rate", sa.Numeric(5, 2), nullable=False, server_default="18.00"), + sa.Column("cgst_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"), + sa.Column("sgst_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"), + sa.Column("igst_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"), + sa.Column("line_total", sa.Numeric(14, 2), nullable=False, server_default="0.00"), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default="1"), + sa.ForeignKeyConstraint(["invoice_id"], ["platform_invoices.id"], ondelete="CASCADE"), + ) + op.create_index("ix_platform_invoice_lines_invoice_id", "platform_invoice_lines", ["invoice_id"]) + op.create_index("ix_platform_invoice_lines_charge_type", "platform_invoice_lines", ["charge_type"]) + + op.create_table( + "platform_payments", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("invoice_id", sa.Integer(), nullable=False), + sa.Column("account_id", sa.Integer(), nullable=False), + sa.Column("payment_date", sa.Date(), nullable=False), + sa.Column("amount", sa.Numeric(14, 2), nullable=False), + sa.Column("mode", sa.String(length=30), nullable=False, server_default="Bank"), + sa.Column("reference_no", sa.String(length=100), nullable=True), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("created_by_user_id", sa.Integer(), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["invoice_id"], ["platform_invoices.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["account_id"], ["platform_billing_accounts.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]), + ) + op.create_index("ix_platform_payments_invoice_id", "platform_payments", ["invoice_id"]) + op.create_index("ix_platform_payments_account_id", "platform_payments", ["account_id"]) + op.create_index("ix_platform_payments_payment_date", "platform_payments", ["payment_date"]) + + +def downgrade() -> None: + op.drop_index("ix_platform_payments_payment_date", table_name="platform_payments") + op.drop_index("ix_platform_payments_account_id", table_name="platform_payments") + op.drop_index("ix_platform_payments_invoice_id", table_name="platform_payments") + op.drop_table("platform_payments") + op.drop_index("ix_platform_invoice_lines_charge_type", table_name="platform_invoice_lines") + op.drop_index("ix_platform_invoice_lines_invoice_id", table_name="platform_invoice_lines") + op.drop_table("platform_invoice_lines") + op.drop_index("ix_platform_invoices_status", table_name="platform_invoices") + op.drop_index("ix_platform_invoices_due_date", table_name="platform_invoices") + op.drop_index("ix_platform_invoices_invoice_date", table_name="platform_invoices") + op.drop_index("ix_platform_invoices_invoice_no", table_name="platform_invoices") + op.drop_index("ix_platform_invoices_subscription_id", table_name="platform_invoices") + op.drop_index("ix_platform_invoices_account_id", table_name="platform_invoices") + op.drop_table("platform_invoices") + op.drop_index("ix_platform_subscriptions_status", table_name="platform_subscriptions") + op.drop_index("ix_platform_subscriptions_billing_cycle", table_name="platform_subscriptions") + op.drop_index("ix_platform_subscriptions_subscription_code", table_name="platform_subscriptions") + op.drop_index("ix_platform_subscriptions_plan_id", table_name="platform_subscriptions") + op.drop_index("ix_platform_subscriptions_account_id", table_name="platform_subscriptions") + op.drop_table("platform_subscriptions") + op.drop_index("ix_platform_billing_accounts_status", table_name="platform_billing_accounts") + op.drop_index("ix_platform_billing_accounts_consultant_id", table_name="platform_billing_accounts") + op.drop_index("ix_platform_billing_accounts_client_id", table_name="platform_billing_accounts") + op.drop_index("ix_platform_billing_accounts_tenant_id", table_name="platform_billing_accounts") + op.drop_index("ix_platform_billing_accounts_display_name", table_name="platform_billing_accounts") + op.drop_index("ix_platform_billing_accounts_account_code", table_name="platform_billing_accounts") + op.drop_index("ix_platform_billing_accounts_account_type", table_name="platform_billing_accounts") + op.drop_table("platform_billing_accounts") + op.drop_index("ix_platform_plan_features_feature_code", table_name="platform_plan_features") + op.drop_index("ix_platform_plan_features_plan_id", table_name="platform_plan_features") + op.drop_table("platform_plan_features") + op.drop_index("ix_platform_plans_is_active", table_name="platform_plans") + op.drop_index("ix_platform_plans_billing_cycle", table_name="platform_plans") + op.drop_index("ix_platform_plans_target_account_type", table_name="platform_plans") + op.drop_index("ix_platform_plans_code", table_name="platform_plans") + op.drop_table("platform_plans") diff --git a/alembic/versions/20260525_phase_pb5_marketplace_leads.py b/alembic/versions/20260525_phase_pb5_marketplace_leads.py new file mode 100644 index 0000000..3830e96 --- /dev/null +++ b/alembic/versions/20260525_phase_pb5_marketplace_leads.py @@ -0,0 +1,84 @@ +"""Phase PB5 marketplace lead foundation + +Revision ID: 20260525_phase_pb5_marketplace_leads +Revises: 20260524_phase_pb1_platform_billing +Create Date: 2026-05-25 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "20260525_phase_pb5_marketplace_leads" +down_revision = "20260524_phase_pb1_platform_billing" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "marketplace_leads", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("lead_no", sa.String(length=50), nullable=False), + sa.Column("source", sa.String(length=50), nullable=False, server_default="manual"), + sa.Column("service_category", sa.String(length=100), nullable=True), + sa.Column("service_requested", sa.String(length=200), nullable=False), + sa.Column("lead_name", sa.String(length=200), nullable=False), + sa.Column("business_name", sa.String(length=200), nullable=True), + sa.Column("email", sa.String(length=255), nullable=True), + sa.Column("mobile", sa.String(length=20), nullable=True), + sa.Column("city", sa.String(length=100), nullable=True), + sa.Column("state", sa.String(length=100), nullable=True), + sa.Column("message", sa.Text(), nullable=True), + sa.Column("status", sa.String(length=30), nullable=False, server_default="NEW"), + sa.Column("priority", sa.String(length=30), nullable=False, server_default="NORMAL"), + sa.Column("estimated_value", sa.Numeric(12, 2), nullable=False, server_default="0.00"), + sa.Column("assigned_tenant_id", sa.Integer(), nullable=True), + sa.Column("assigned_branch_id", sa.Integer(), nullable=True), + sa.Column("assigned_partner_user_id", sa.Integer(), nullable=True), + sa.Column("assigned_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("assigned_by_user_id", sa.Integer(), nullable=True), + sa.Column("converted_client_id", sa.Integer(), nullable=True), + sa.Column("converted_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("converted_by_user_id", sa.Integer(), nullable=True), + sa.Column("created_by_user_id", sa.Integer(), nullable=True), + sa.Column("updated_by_user_id", sa.Integer(), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["assigned_tenant_id"], ["tenants.id"]), + sa.ForeignKeyConstraint(["assigned_branch_id"], ["branches.id"]), + sa.ForeignKeyConstraint(["assigned_partner_user_id"], ["users.id"]), + sa.ForeignKeyConstraint(["assigned_by_user_id"], ["users.id"]), + sa.ForeignKeyConstraint(["converted_client_id"], ["clients.id"]), + sa.ForeignKeyConstraint(["converted_by_user_id"], ["users.id"]), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]), + sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"]), + sa.UniqueConstraint("lead_no", name="uq_marketplace_leads_lead_no"), + ) + for col in ["lead_no", "source", "service_category", "service_requested", "lead_name", "business_name", "email", "mobile", "status", "priority", "assigned_tenant_id", "assigned_branch_id", "assigned_partner_user_id", "converted_client_id"]: + op.create_index(f"ix_marketplace_leads_{col}", "marketplace_leads", [col]) + + op.create_table( + "marketplace_lead_assignments", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("lead_id", sa.Integer(), nullable=False), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("branch_id", sa.Integer(), nullable=True), + sa.Column("partner_user_id", sa.Integer(), nullable=True), + sa.Column("status", sa.String(length=30), nullable=False, server_default="ASSIGNED"), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("assigned_by_user_id", sa.Integer(), nullable=True), + sa.Column("assigned_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["lead_id"], ["marketplace_leads.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"]), + sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]), + sa.ForeignKeyConstraint(["partner_user_id"], ["users.id"]), + sa.ForeignKeyConstraint(["assigned_by_user_id"], ["users.id"]), + ) + for col in ["lead_id", "tenant_id", "branch_id", "partner_user_id", "status"]: + op.create_index(f"ix_marketplace_lead_assignments_{col}", "marketplace_lead_assignments", [col]) + + +def downgrade() -> None: + op.drop_table("marketplace_lead_assignments") + op.drop_table("marketplace_leads") diff --git a/alembic/versions/20260526_phase_ds1_ds2_engagement_documents.py b/alembic/versions/20260526_phase_ds1_ds2_engagement_documents.py new file mode 100644 index 0000000..b502664 --- /dev/null +++ b/alembic/versions/20260526_phase_ds1_ds2_engagement_documents.py @@ -0,0 +1,119 @@ +"""phase ds1 ds2 engagement documents + +Revision ID: 20260526_phase_ds1_ds2_documents +Revises: 20260525_phase_pb5_marketplace_leads +Create Date: 2026-05-14 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "20260526_phase_ds1_ds2_documents" +down_revision = "20260525_phase_pb5_marketplace_leads" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "engagement_documents", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("branch_id", sa.Integer(), nullable=True), + sa.Column("client_id", sa.Integer(), nullable=False), + sa.Column("engagement_id", sa.Integer(), nullable=False), + sa.Column("financial_year", sa.String(length=9), nullable=False), + sa.Column("assessment_year", sa.String(length=9), nullable=True), + sa.Column("document_code", sa.String(length=80), nullable=False), + sa.Column("document_type", sa.String(length=80), nullable=False), + sa.Column("title", sa.String(length=255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("current_version_no", sa.Integer(), nullable=False, server_default="0"), + sa.Column("status", sa.String(length=30), nullable=False, server_default="active"), + sa.Column("is_deleted", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("deleted_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("deleted_by_user_id", sa.Integer(), nullable=True), + sa.Column("created_by_user_id", sa.Integer(), nullable=True), + sa.Column("updated_by_user_id", sa.Integer(), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["client_id"], ["clients.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["engagement_id"], ["client_service_subscriptions.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["deleted_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("tenant_id", "engagement_id", "document_code", name="uq_engagement_documents_code"), + ) + for col in ["tenant_id", "branch_id", "client_id", "engagement_id", "financial_year", "assessment_year", "document_code", "document_type", "status", "is_deleted"]: + op.create_index(f"ix_engagement_documents_{col}", "engagement_documents", [col]) + + op.create_table( + "engagement_document_versions", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("document_id", sa.Integer(), nullable=False), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("branch_id", sa.Integer(), nullable=True), + sa.Column("client_id", sa.Integer(), nullable=False), + sa.Column("engagement_id", sa.Integer(), nullable=False), + sa.Column("version_no", sa.Integer(), nullable=False), + sa.Column("original_filename", sa.String(length=255), nullable=False), + sa.Column("stored_filename", sa.String(length=255), nullable=False), + sa.Column("content_type", sa.String(length=150), nullable=True), + sa.Column("file_size_bytes", sa.Integer(), nullable=False, server_default="0"), + sa.Column("file_hash_sha256", sa.String(length=64), nullable=False), + sa.Column("storage_backend", sa.String(length=40), nullable=False, server_default="LOCAL_YEAR_WISE"), + sa.Column("local_relative_path", sa.String(length=1000), nullable=False), + sa.Column("storage_status", sa.String(length=30), nullable=False, server_default="stored"), + sa.Column("remarks", sa.Text(), nullable=True), + sa.Column("uploaded_by_user_id", sa.Integer(), nullable=True), + sa.Column("uploaded_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["document_id"], ["engagement_documents.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["client_id"], ["clients.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["engagement_id"], ["client_service_subscriptions.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["uploaded_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("document_id", "version_no", name="uq_engagement_document_versions_no"), + ) + for col in ["document_id", "tenant_id", "branch_id", "client_id", "engagement_id", "file_hash_sha256", "storage_backend", "storage_status"]: + op.create_index(f"ix_engagement_document_versions_{col}", "engagement_document_versions", [col]) + + op.create_table( + "document_access_logs", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("tenant_id", sa.Integer(), nullable=True), + sa.Column("branch_id", sa.Integer(), nullable=True), + sa.Column("client_id", sa.Integer(), nullable=True), + sa.Column("engagement_id", sa.Integer(), nullable=True), + sa.Column("document_id", sa.Integer(), nullable=True), + sa.Column("version_id", sa.Integer(), nullable=True), + sa.Column("action", sa.String(length=40), nullable=False), + sa.Column("result", sa.String(length=40), nullable=False, server_default="success"), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.Column("ip_address", sa.String(length=80), nullable=True), + sa.Column("user_agent", sa.String(length=500), nullable=True), + sa.Column("message", sa.Text(), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["client_id"], ["clients.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["engagement_id"], ["client_service_subscriptions.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["document_id"], ["engagement_documents.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["version_id"], ["engagement_document_versions.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + for col in ["tenant_id", "branch_id", "client_id", "engagement_id", "document_id", "version_id", "action", "result", "user_id"]: + op.create_index(f"ix_document_access_logs_{col}", "document_access_logs", [col]) + + +def downgrade() -> None: + op.drop_table("document_access_logs") + op.drop_table("engagement_document_versions") + op.drop_table("engagement_documents") diff --git a/alembic/versions/20260527_phase_ds3_ds4_storage_jobs.py b/alembic/versions/20260527_phase_ds3_ds4_storage_jobs.py new file mode 100644 index 0000000..cc8e4c7 --- /dev/null +++ b/alembic/versions/20260527_phase_ds3_ds4_storage_jobs.py @@ -0,0 +1,87 @@ +"""phase ds3 ds4 branch storage nodes and document jobs + +Revision ID: 20260527_phase_ds3_ds4_storage_jobs +Revises: 20260526_phase_ds1_ds2_documents +Create Date: 2026-05-14 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "20260527_phase_ds3_ds4_storage_jobs" +down_revision = "20260526_phase_ds1_ds2_documents" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "branch_storage_nodes", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("branch_id", sa.Integer(), nullable=True), + sa.Column("node_code", sa.String(length=80), nullable=False), + sa.Column("node_name", sa.String(length=200), nullable=False), + sa.Column("connector_url", sa.String(length=500), nullable=True), + sa.Column("secret_key_hash", sa.String(length=64), nullable=False), + sa.Column("storage_root_path", sa.String(length=1000), nullable=True), + sa.Column("storage_mode", sa.String(length=40), nullable=False, server_default="pull_jobs"), + sa.Column("status", sa.String(length=30), nullable=False, server_default="active"), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("last_seen_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("last_seen_ip", sa.String(length=80), nullable=True), + sa.Column("quota_limit_bytes", sa.Integer(), nullable=True), + sa.Column("used_storage_bytes", sa.Integer(), nullable=False, server_default="0"), + sa.Column("subscription_required", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("created_by_user_id", sa.Integer(), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("tenant_id", "branch_id", "node_code", name="uq_branch_storage_node_code"), + ) + for col in ["tenant_id", "branch_id", "node_code", "storage_mode", "status", "is_active"]: + op.create_index(f"ix_branch_storage_nodes_{col}", "branch_storage_nodes", [col]) + + op.create_table( + "document_storage_jobs", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("branch_id", sa.Integer(), nullable=True), + sa.Column("storage_node_id", sa.Integer(), nullable=False), + sa.Column("document_id", sa.Integer(), nullable=False), + sa.Column("version_id", sa.Integer(), nullable=False), + sa.Column("job_type", sa.String(length=40), nullable=False, server_default="store_version"), + sa.Column("status", sa.String(length=30), nullable=False, server_default="pending"), + sa.Column("priority", sa.Integer(), nullable=False, server_default="5"), + sa.Column("staging_relative_path", sa.String(length=1000), nullable=False), + sa.Column("target_relative_path", sa.String(length=1000), nullable=False), + sa.Column("file_size_bytes", sa.Integer(), nullable=False, server_default="0"), + sa.Column("expected_hash_sha256", sa.String(length=64), nullable=False), + sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"), + sa.Column("last_error", sa.Text(), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.Column("picked_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("completed_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("acknowledged_hash_sha256", sa.String(length=64), nullable=True), + sa.Column("local_final_path", sa.String(length=1000), nullable=True), + sa.Column("created_by_user_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["storage_node_id"], ["branch_storage_nodes.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["document_id"], ["engagement_documents.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["version_id"], ["engagement_document_versions.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + for col in ["tenant_id", "branch_id", "storage_node_id", "document_id", "version_id", "job_type", "status", "priority", "expected_hash_sha256"]: + op.create_index(f"ix_document_storage_jobs_{col}", "document_storage_jobs", [col]) + + +def downgrade() -> None: + op.drop_table("document_storage_jobs") + op.drop_table("branch_storage_nodes") diff --git a/alembic/versions/20260528_phase_ds5_secure_download_streaming.py b/alembic/versions/20260528_phase_ds5_secure_download_streaming.py new file mode 100644 index 0000000..47b427d --- /dev/null +++ b/alembic/versions/20260528_phase_ds5_secure_download_streaming.py @@ -0,0 +1,64 @@ +"""phase ds5 secure local download streaming requests + +Revision ID: 20260528_phase_ds5_secure_download_streaming +Revises: 20260527_phase_ds3_ds4_storage_jobs +Create Date: 2026-05-14 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "20260528_phase_ds5_secure_download_streaming" +down_revision = "20260527_phase_ds3_ds4_storage_jobs" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "document_download_requests", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("branch_id", sa.Integer(), nullable=True), + sa.Column("storage_node_id", sa.Integer(), nullable=False), + sa.Column("document_id", sa.Integer(), nullable=False), + sa.Column("version_id", sa.Integer(), nullable=False), + sa.Column("request_status", sa.String(length=30), nullable=False, server_default="pending"), + sa.Column("local_relative_path", sa.String(length=1000), nullable=True), + sa.Column("expected_hash_sha256", sa.String(length=64), nullable=False), + sa.Column("cached_relative_path", sa.String(length=1000), nullable=True), + sa.Column("cached_hash_sha256", sa.String(length=64), nullable=True), + sa.Column("file_size_bytes", sa.Integer(), nullable=False, server_default="0"), + sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"), + sa.Column("last_error", sa.Text(), nullable=True), + sa.Column("requested_by_user_id", sa.Integer(), nullable=True), + sa.Column("requested_ip", sa.String(length=80), nullable=True), + sa.Column("requested_user_agent", sa.String(length=500), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.Column("fulfilled_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("failed_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["storage_node_id"], ["branch_storage_nodes.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["document_id"], ["engagement_documents.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["version_id"], ["engagement_document_versions.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["requested_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + for col in [ + "tenant_id", + "branch_id", + "storage_node_id", + "document_id", + "version_id", + "request_status", + "expected_hash_sha256", + "requested_by_user_id", + ]: + op.create_index(f"ix_document_download_requests_{col}", "document_download_requests", [col]) + + +def downgrade() -> None: + op.drop_table("document_download_requests") diff --git a/alembic/versions/20260529_phase_7h_common_alerts.py b/alembic/versions/20260529_phase_7h_common_alerts.py new file mode 100644 index 0000000..62debb2 --- /dev/null +++ b/alembic/versions/20260529_phase_7h_common_alerts.py @@ -0,0 +1,125 @@ +"""Phase 7H common alerts foundation + +Revision ID: 20260529_phase_7h_common_alerts +Revises: af48d99d7321 +Create Date: 2026-05-20 + +This migration is intentionally idempotent for SQLite/dev environments. +If a previous failed Alembic run already created user_alerts before the +revision was recorded, re-running `alembic upgrade head` will safely continue. +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "20260529_phase_7h_common_alerts" +down_revision = "af48d99d7321" +branch_labels = None +depends_on = None + +TABLE_NAME = "user_alerts" + + +def _table_exists(inspector: sa.Inspector, table_name: str) -> bool: + return table_name in inspector.get_table_names() + + +def _existing_columns(inspector: sa.Inspector, table_name: str) -> set[str]: + if not _table_exists(inspector, table_name): + return set() + return {col["name"] for col in inspector.get_columns(table_name)} + + +def _existing_indexes(inspector: sa.Inspector, table_name: str) -> set[str]: + if not _table_exists(inspector, table_name): + return set() + return {idx["name"] for idx in inspector.get_indexes(table_name)} + + +def _add_column_if_missing(inspector: sa.Inspector, column_name: str, column: sa.Column) -> None: + existing = _existing_columns(inspector, TABLE_NAME) + if column_name not in existing: + op.add_column(TABLE_NAME, column) + + +def _create_index_if_missing(inspector: sa.Inspector, index_name: str, columns: list[str]) -> None: + existing = _existing_indexes(inspector, TABLE_NAME) + if index_name not in existing: + op.create_index(index_name, TABLE_NAME, columns) + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if not _table_exists(inspector, TABLE_NAME): + op.create_table( + TABLE_NAME, + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True), + sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id", ondelete="SET NULL"), nullable=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("role_context", sa.String(length=50), nullable=True), + sa.Column("alert_type", sa.String(length=80), nullable=False, server_default="general"), + sa.Column("priority", sa.String(length=20), nullable=False, server_default="normal"), + sa.Column("title", sa.String(length=255), nullable=False), + sa.Column("message", sa.Text(), nullable=True), + sa.Column("target_url", sa.String(length=500), nullable=True), + sa.Column("is_read", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("read_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + ) + inspector = sa.inspect(bind) + else: + # Safety path for a failed/partial migration run. SQLite cannot add + # foreign-key constraints after table creation, but it can add any + # missing columns so the model and UI continue to work. + _add_column_if_missing(inspector, "tenant_id", sa.Column("tenant_id", sa.Integer(), nullable=True)) + _add_column_if_missing(inspector, "branch_id", sa.Column("branch_id", sa.Integer(), nullable=True)) + _add_column_if_missing(inspector, "user_id", sa.Column("user_id", sa.Integer(), nullable=False, server_default="0")) + _add_column_if_missing(inspector, "role_context", sa.Column("role_context", sa.String(length=50), nullable=True)) + _add_column_if_missing(inspector, "alert_type", sa.Column("alert_type", sa.String(length=80), nullable=False, server_default="general")) + _add_column_if_missing(inspector, "priority", sa.Column("priority", sa.String(length=20), nullable=False, server_default="normal")) + _add_column_if_missing(inspector, "title", sa.Column("title", sa.String(length=255), nullable=False, server_default="Alert")) + _add_column_if_missing(inspector, "message", sa.Column("message", sa.Text(), nullable=True)) + _add_column_if_missing(inspector, "target_url", sa.Column("target_url", sa.String(length=500), nullable=True)) + _add_column_if_missing(inspector, "is_read", sa.Column("is_read", sa.Boolean(), nullable=False, server_default=sa.false())) + _add_column_if_missing(inspector, "read_at_utc", sa.Column("read_at_utc", sa.DateTime(timezone=True), nullable=True)) + _add_column_if_missing(inspector, "created_at_utc", sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now())) + _add_column_if_missing(inspector, "created_by_user_id", sa.Column("created_by_user_id", sa.Integer(), nullable=True)) + inspector = sa.inspect(bind) + + _create_index_if_missing(inspector, "ix_user_alerts_tenant_id", ["tenant_id"]) + _create_index_if_missing(inspector, "ix_user_alerts_branch_id", ["branch_id"]) + _create_index_if_missing(inspector, "ix_user_alerts_user_id", ["user_id"]) + _create_index_if_missing(inspector, "ix_user_alerts_role_context", ["role_context"]) + _create_index_if_missing(inspector, "ix_user_alerts_alert_type", ["alert_type"]) + _create_index_if_missing(inspector, "ix_user_alerts_priority", ["priority"]) + _create_index_if_missing(inspector, "ix_user_alerts_is_read", ["is_read"]) + _create_index_if_missing(inspector, "ix_user_alerts_created_at_utc", ["created_at_utc"]) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if not _table_exists(inspector, TABLE_NAME): + return + + existing_indexes = _existing_indexes(inspector, TABLE_NAME) + for index_name in [ + "ix_user_alerts_created_at_utc", + "ix_user_alerts_is_read", + "ix_user_alerts_priority", + "ix_user_alerts_alert_type", + "ix_user_alerts_role_context", + "ix_user_alerts_user_id", + "ix_user_alerts_branch_id", + "ix_user_alerts_tenant_id", + ]: + if index_name in existing_indexes: + op.drop_index(index_name, table_name=TABLE_NAME) + + op.drop_table(TABLE_NAME) diff --git a/alembic/versions/20260530_phase_7q2_firm_branding.py b/alembic/versions/20260530_phase_7q2_firm_branding.py new file mode 100644 index 0000000..6c5fbd1 --- /dev/null +++ b/alembic/versions/20260530_phase_7q2_firm_branding.py @@ -0,0 +1,55 @@ +"""Phase 7Q.2 firm branding settings + +Revision ID: 20260530_phase_7q2_firm_branding +Revises: 20260529_phase_7h_common_alerts +Create Date: 2026-05-21 + +Adds tenant-level branding fields and branch-level invoice/payment presentation fields. +The migration is idempotent for SQLite/dev environments. +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "20260530_phase_7q2_firm_branding" +down_revision = "20260529_phase_7h_common_alerts" +branch_labels = None +depends_on = None + + +def _columns(table_name: str) -> set[str]: + bind = op.get_bind() + inspector = sa.inspect(bind) + if table_name not in inspector.get_table_names(): + return set() + return {c["name"] for c in inspector.get_columns(table_name)} + + +def _add_if_missing(table: str, column: sa.Column) -> None: + if column.name not in _columns(table): + op.add_column(table, column) + + +def upgrade() -> None: + _add_if_missing("tenants", sa.Column("display_name", sa.String(length=200), nullable=True)) + _add_if_missing("tenants", sa.Column("logo_path", sa.String(length=500), nullable=True)) + _add_if_missing("tenants", sa.Column("favicon_path", sa.String(length=500), nullable=True)) + _add_if_missing("tenants", sa.Column("primary_color", sa.String(length=20), nullable=True)) + _add_if_missing("tenants", sa.Column("accent_color", sa.String(length=20), nullable=True)) + _add_if_missing("tenants", sa.Column("website_url", sa.String(length=255), nullable=True)) + _add_if_missing("tenants", sa.Column("contact_email", sa.String(length=255), nullable=True)) + _add_if_missing("tenants", sa.Column("contact_mobile", sa.String(length=50), nullable=True)) + + _add_if_missing("branch_settings", sa.Column("invoice_footer_text", sa.Text(), nullable=True)) + _add_if_missing("branch_settings", sa.Column("bank_name", sa.String(length=200), nullable=True)) + _add_if_missing("branch_settings", sa.Column("bank_account_name", sa.String(length=200), nullable=True)) + _add_if_missing("branch_settings", sa.Column("bank_account_number", sa.String(length=50), nullable=True)) + _add_if_missing("branch_settings", sa.Column("bank_ifsc", sa.String(length=20), nullable=True)) + _add_if_missing("branch_settings", sa.Column("upi_id", sa.String(length=100), nullable=True)) + + +def downgrade() -> None: + # SQLite cannot reliably drop columns on older setups without table rebuild. + # Keep downgrade no-op to avoid damaging live tenant/branch settings. + pass diff --git a/alembic/versions/20260531_phase_7q3_user_profile_personalisation.py b/alembic/versions/20260531_phase_7q3_user_profile_personalisation.py new file mode 100644 index 0000000..7b4d131 --- /dev/null +++ b/alembic/versions/20260531_phase_7q3_user_profile_personalisation.py @@ -0,0 +1,45 @@ +"""Phase 7Q.3 user profile photo and qualification fields + +Revision ID: 20260531_phase_7q3_user_profile +Revises: 20260530_phase_7q2_firm_branding +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "20260531_phase_7q3_user_profile" +down_revision = "20260530_phase_7q2_firm_branding" +branch_labels = None +depends_on = None + + +def _columns(table_name: str) -> set[str]: + bind = op.get_bind() + inspector = sa.inspect(bind) + return {col["name"] for col in inspector.get_columns(table_name)} + + +def upgrade() -> None: + existing = _columns("users") + with op.batch_alter_table("users") as batch_op: + if "profile_photo_path" not in existing: + batch_op.add_column(sa.Column("profile_photo_path", sa.String(length=500), nullable=True)) + if "qualification" not in existing: + batch_op.add_column(sa.Column("qualification", sa.String(length=200), nullable=True)) + if "designation" not in existing: + batch_op.add_column(sa.Column("designation", sa.String(length=200), nullable=True)) + if "mobile" not in existing: + batch_op.add_column(sa.Column("mobile", sa.String(length=30), nullable=True)) + if "bio" not in existing: + batch_op.add_column(sa.Column("bio", sa.Text(), nullable=True)) + if "signature_image_path" not in existing: + batch_op.add_column(sa.Column("signature_image_path", sa.String(length=500), nullable=True)) + + +def downgrade() -> None: + existing = _columns("users") + with op.batch_alter_table("users") as batch_op: + for column_name in ["signature_image_path", "bio", "mobile", "designation", "qualification", "profile_photo_path"]: + if column_name in existing: + batch_op.drop_column(column_name) diff --git a/alembic/versions/20260601_phase_7r1_firm_billing_settings.py b/alembic/versions/20260601_phase_7r1_firm_billing_settings.py new file mode 100644 index 0000000..9dc4a35 --- /dev/null +++ b/alembic/versions/20260601_phase_7r1_firm_billing_settings.py @@ -0,0 +1,56 @@ +"""Phase 7R.1 firm billing settings + +Revision ID: 20260601_phase_7r1_billing_settings +Revises: 20260531_phase_7q3_user_profile +Create Date: 2026-06-01 + +Adds professional billing settings fields used by GST invoice formatting, +payment tracking and future online payment phases. Idempotent for SQLite dev use. +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "20260601_phase_7r1_billing_settings" +down_revision = "20260531_phase_7q3_user_profile" +branch_labels = None +depends_on = None + + +def _columns(table_name: str) -> set[str]: + bind = op.get_bind() + inspector = sa.inspect(bind) + if table_name not in inspector.get_table_names(): + return set() + return {c["name"] for c in inspector.get_columns(table_name)} + + +def _add_if_missing(table: str, column: sa.Column) -> None: + if column.name not in _columns(table): + op.add_column(table, column) + + +def upgrade() -> None: + _add_if_missing("billing_settings", sa.Column("pan", sa.String(length=10), nullable=True)) + _add_if_missing("billing_settings", sa.Column("state_code", sa.String(length=2), nullable=True)) + _add_if_missing("billing_settings", sa.Column("contact_email", sa.String(length=255), nullable=True)) + _add_if_missing("billing_settings", sa.Column("contact_mobile", sa.String(length=50), nullable=True)) + _add_if_missing("billing_settings", sa.Column("website_url", sa.String(length=255), nullable=True)) + _add_if_missing("billing_settings", sa.Column("invoice_title", sa.String(length=80), nullable=True)) + _add_if_missing("billing_settings", sa.Column("invoice_number_format", sa.String(length=120), nullable=True)) + _add_if_missing("billing_settings", sa.Column("default_due_days", sa.Integer(), nullable=False, server_default="15")) + _add_if_missing("billing_settings", sa.Column("default_sac_code", sa.String(length=20), nullable=True)) + _add_if_missing("billing_settings", sa.Column("bank_name", sa.String(length=200), nullable=True)) + _add_if_missing("billing_settings", sa.Column("bank_account_name", sa.String(length=200), nullable=True)) + _add_if_missing("billing_settings", sa.Column("bank_account_number", sa.String(length=50), nullable=True)) + _add_if_missing("billing_settings", sa.Column("bank_ifsc", sa.String(length=20), nullable=True)) + _add_if_missing("billing_settings", sa.Column("upi_id", sa.String(length=100), nullable=True)) + _add_if_missing("billing_settings", sa.Column("authorised_signatory_name", sa.String(length=200), nullable=True)) + _add_if_missing("billing_settings", sa.Column("declaration", sa.Text(), nullable=True)) + + +def downgrade() -> None: + # No-op downgrade for SQLite/dev safety. Dropping columns in SQLite can require + # table rebuild and may damage live billing configuration. + pass diff --git a/alembic/versions/20260602_phase_7r2_gst_invoice_format.py b/alembic/versions/20260602_phase_7r2_gst_invoice_format.py new file mode 100644 index 0000000..e03f048 --- /dev/null +++ b/alembic/versions/20260602_phase_7r2_gst_invoice_format.py @@ -0,0 +1,55 @@ +"""Phase 7R.2 GST invoice format + +Revision ID: 20260602_phase_7r2_gst_invoice +Revises: 20260601_phase_7r1_billing_settings +Create Date: 2026-06-02 + +Adds GST invoice snapshot fields, place of supply, reverse charge, +SAC code per line and amount-in-words support. The migration is +idempotent for the existing SQLite development workflow. +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "20260602_phase_7r2_gst_invoice" +down_revision = "20260601_phase_7r1_billing_settings" +branch_labels = None +depends_on = None + + +def _columns(table_name: str) -> set[str]: + bind = op.get_bind() + inspector = sa.inspect(bind) + if table_name not in inspector.get_table_names(): + return set() + return {c["name"] for c in inspector.get_columns(table_name)} + + +def _add_if_missing(table: str, column: sa.Column) -> None: + if column.name not in _columns(table): + op.add_column(table, column) + + +def upgrade() -> None: + _add_if_missing("billing_invoices", sa.Column("invoice_title", sa.String(length=80), nullable=True)) + _add_if_missing("billing_invoices", sa.Column("place_of_supply", sa.String(length=120), nullable=True)) + _add_if_missing("billing_invoices", sa.Column("reverse_charge", sa.Boolean(), nullable=False, server_default=sa.false())) + _add_if_missing("billing_invoices", sa.Column("client_legal_name", sa.String(length=200), nullable=True)) + _add_if_missing("billing_invoices", sa.Column("client_trade_name", sa.String(length=200), nullable=True)) + _add_if_missing("billing_invoices", sa.Column("client_gstin", sa.String(length=20), nullable=True)) + _add_if_missing("billing_invoices", sa.Column("client_pan", sa.String(length=20), nullable=True)) + _add_if_missing("billing_invoices", sa.Column("client_billing_address", sa.Text(), nullable=True)) + _add_if_missing("billing_invoices", sa.Column("client_state", sa.String(length=100), nullable=True)) + _add_if_missing("billing_invoices", sa.Column("client_state_code", sa.String(length=2), nullable=True)) + _add_if_missing("billing_invoices", sa.Column("client_email", sa.String(length=255), nullable=True)) + _add_if_missing("billing_invoices", sa.Column("client_mobile", sa.String(length=50), nullable=True)) + _add_if_missing("billing_invoices", sa.Column("amount_in_words", sa.String(length=500), nullable=True)) + _add_if_missing("billing_invoice_lines", sa.Column("sac_code", sa.String(length=20), nullable=True)) + + +def downgrade() -> None: + # No-op downgrade for SQLite/dev safety. Dropping columns in SQLite can + # require table rebuild and may damage existing billing records. + pass diff --git a/alembic/versions/20260603_phase_7r3_payment_tracking_receipts.py b/alembic/versions/20260603_phase_7r3_payment_tracking_receipts.py new file mode 100644 index 0000000..0c656b4 --- /dev/null +++ b/alembic/versions/20260603_phase_7r3_payment_tracking_receipts.py @@ -0,0 +1,97 @@ +"""Phase 7R.3 payment tracking and receipts + +Revision ID: 20260603_phase_7r3_payments +Revises: 20260602_phase_7r2_gst_invoice +Create Date: 2026-06-03 + +Adds invoice collection totals and a payment/receipt ledger. The migration +is intentionally idempotent for SQLite development databases used in this +project. +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "20260603_phase_7r3_payments" +down_revision = "20260602_phase_7r2_gst_invoice" +branch_labels = None +depends_on = None + + +def _tables() -> set[str]: + return set(sa.inspect(op.get_bind()).get_table_names()) + + +def _columns(table_name: str) -> set[str]: + inspector = sa.inspect(op.get_bind()) + if table_name not in inspector.get_table_names(): + return set() + return {c["name"] for c in inspector.get_columns(table_name)} + + +def _add_if_missing(table: str, column: sa.Column) -> None: + if column.name not in _columns(table): + op.add_column(table, column) + + +def _index_exists(index_name: str) -> bool: + inspector = sa.inspect(op.get_bind()) + for table_name in inspector.get_table_names(): + for idx in inspector.get_indexes(table_name): + if idx.get("name") == index_name: + return True + return False + + +def upgrade() -> None: + _add_if_missing("billing_invoices", sa.Column("amount_received", sa.Numeric(14, 2), nullable=False, server_default="0.00")) + _add_if_missing("billing_invoices", sa.Column("tds_deducted", sa.Numeric(14, 2), nullable=False, server_default="0.00")) + _add_if_missing("billing_invoices", sa.Column("bank_charges", sa.Numeric(14, 2), nullable=False, server_default="0.00")) + _add_if_missing("billing_invoices", sa.Column("balance_amount", sa.Numeric(14, 2), nullable=False, server_default="0.00")) + + if "billing_payments" not in _tables(): + op.create_table( + "billing_payments", + 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("branch_id", sa.Integer(), sa.ForeignKey("branches.id", ondelete="SET NULL"), nullable=True), + sa.Column("invoice_id", sa.Integer(), sa.ForeignKey("billing_invoices.id", ondelete="CASCADE"), nullable=False), + sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="RESTRICT"), nullable=False), + sa.Column("receipt_no", sa.String(length=60), nullable=False), + sa.Column("receipt_date", sa.Date(), nullable=False), + sa.Column("payment_date", sa.Date(), nullable=False), + sa.Column("amount_received", sa.Numeric(14, 2), nullable=False, server_default="0.00"), + sa.Column("tds_deducted", sa.Numeric(14, 2), nullable=False, server_default="0.00"), + sa.Column("bank_charges", sa.Numeric(14, 2), nullable=False, server_default="0.00"), + sa.Column("mode", sa.String(length=30), nullable=False, server_default="BANK"), + sa.Column("reference_no", sa.String(length=120), nullable=True), + sa.Column("payment_gateway", sa.String(length=50), nullable=True), + sa.Column("gateway_transaction_id", sa.String(length=120), nullable=True), + sa.Column("remarks", sa.Text(), nullable=True), + sa.Column("status", sa.String(length=20), nullable=False, server_default="RECEIVED"), + sa.Column("created_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", "receipt_no", name="uq_billing_payments_tenant_receipt_no"), + ) + for name, cols in { + "ix_billing_payments_tenant_id": ["tenant_id"], + "ix_billing_payments_branch_id": ["branch_id"], + "ix_billing_payments_invoice_id": ["invoice_id"], + "ix_billing_payments_client_id": ["client_id"], + "ix_billing_payments_receipt_no": ["receipt_no"], + "ix_billing_payments_payment_date": ["payment_date"], + "ix_billing_payments_status": ["status"], + }.items(): + if not _index_exists(name): + op.create_index(name, "billing_payments", cols) + + bind = op.get_bind() + if "billing_invoices" in _tables(): + bind.execute(sa.text("UPDATE billing_invoices SET balance_amount = COALESCE(total_amount, 0) WHERE COALESCE(balance_amount, 0) = 0 AND COALESCE(amount_received, 0) = 0 AND COALESCE(tds_deducted, 0) = 0")) + + +def downgrade() -> None: + # No-op downgrade for SQLite/dev safety. + pass diff --git a/alembic/versions/20260606_phase_7r6_payumoney_gateway.py b/alembic/versions/20260606_phase_7r6_payumoney_gateway.py new file mode 100644 index 0000000..f82952f --- /dev/null +++ b/alembic/versions/20260606_phase_7r6_payumoney_gateway.py @@ -0,0 +1,103 @@ +"""Phase 7R.6 PayUMoney online payment gateway + +Revision ID: 20260606_phase_7r6_payumoney +Revises: 20260603_phase_7r3_payments +Create Date: 2026-06-06 + +Adds PayUMoney settings and a small online payment transaction ledger. +The migration is intentionally defensive for SQLite development databases. +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "20260606_phase_7r6_payumoney" +down_revision = "20260603_phase_7r3_payments" +branch_labels = None +depends_on = None + + +def _tables() -> set[str]: + return set(sa.inspect(op.get_bind()).get_table_names()) + + +def _columns(table_name: str) -> set[str]: + inspector = sa.inspect(op.get_bind()) + if table_name not in inspector.get_table_names(): + return set() + return {c["name"] for c in inspector.get_columns(table_name)} + + +def _add_if_missing(table: str, column: sa.Column) -> None: + if table in _tables() and column.name not in _columns(table): + op.add_column(table, column) + + +def _index_exists(index_name: str) -> bool: + inspector = sa.inspect(op.get_bind()) + for table_name in inspector.get_table_names(): + for idx in inspector.get_indexes(table_name): + if idx.get("name") == index_name: + return True + return False + + +def _create_index_if_missing(name: str, table: str, columns: list[str], unique: bool = False) -> None: + if table in _tables() and not _index_exists(name): + op.create_index(name, table, columns, unique=unique) + + +def upgrade() -> None: + _add_if_missing("billing_settings", sa.Column("payumoney_enabled", sa.Boolean(), nullable=False, server_default=sa.false())) + _add_if_missing("billing_settings", sa.Column("payumoney_mode", sa.String(length=20), nullable=False, server_default="TEST")) + _add_if_missing("billing_settings", sa.Column("payumoney_merchant_key", sa.String(length=120), nullable=True)) + _add_if_missing("billing_settings", sa.Column("payumoney_merchant_salt", sa.String(length=200), nullable=True)) + _add_if_missing("billing_settings", sa.Column("payumoney_merchant_id", sa.String(length=120), nullable=True)) + _add_if_missing("billing_settings", sa.Column("payumoney_product_info", sa.String(length=200), nullable=True)) + + if "billing_online_payment_transactions" not in _tables(): + op.create_table( + "billing_online_payment_transactions", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("branch_id", sa.Integer(), nullable=True), + sa.Column("invoice_id", sa.Integer(), nullable=False), + sa.Column("client_id", sa.Integer(), nullable=False), + sa.Column("provider", sa.String(length=40), nullable=False, server_default="PAYUMONEY"), + sa.Column("mode", sa.String(length=20), nullable=False, server_default="TEST"), + sa.Column("txnid", sa.String(length=80), nullable=False), + sa.Column("amount", sa.Numeric(14, 2), nullable=False, server_default="0.00"), + sa.Column("productinfo", sa.String(length=250), nullable=True), + sa.Column("firstname", sa.String(length=120), nullable=True), + sa.Column("email", sa.String(length=255), nullable=True), + sa.Column("phone", sa.String(length=50), nullable=True), + sa.Column("payu_payment_id", sa.String(length=120), nullable=True), + sa.Column("bank_ref_num", sa.String(length=120), nullable=True), + sa.Column("mihpayid", sa.String(length=120), nullable=True), + sa.Column("status", sa.String(length=30), nullable=False, server_default="INITIATED"), + sa.Column("gateway_status", sa.String(length=80), nullable=True), + sa.Column("response_hash", sa.String(length=200), nullable=True), + sa.Column("raw_response", sa.Text(), nullable=True), + sa.Column("receipt_payment_id", sa.Integer(), 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.Column("completed_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["invoice_id"], ["billing_invoices.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["client_id"], ["clients.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["receipt_payment_id"], ["billing_payments.id"], ondelete="SET NULL"), + sa.UniqueConstraint("tenant_id", "txnid", name="uq_billing_online_payment_tenant_txnid"), + ) + _create_index_if_missing("ix_billing_online_payment_transactions_tenant_id", "billing_online_payment_transactions", ["tenant_id"]) + _create_index_if_missing("ix_billing_online_payment_transactions_branch_id", "billing_online_payment_transactions", ["branch_id"]) + _create_index_if_missing("ix_billing_online_payment_transactions_invoice_id", "billing_online_payment_transactions", ["invoice_id"]) + _create_index_if_missing("ix_billing_online_payment_transactions_client_id", "billing_online_payment_transactions", ["client_id"]) + _create_index_if_missing("ix_billing_online_payment_transactions_txnid", "billing_online_payment_transactions", ["txnid"]) + _create_index_if_missing("ix_billing_online_payment_transactions_status", "billing_online_payment_transactions", ["status"]) + + +def downgrade() -> None: + if "billing_online_payment_transactions" in _tables(): + op.drop_table("billing_online_payment_transactions") diff --git a/alembic/versions/20260607_phase_7r6a_cashfree_gateway.py b/alembic/versions/20260607_phase_7r6a_cashfree_gateway.py new file mode 100644 index 0000000..6ab38ad --- /dev/null +++ b/alembic/versions/20260607_phase_7r6a_cashfree_gateway.py @@ -0,0 +1,73 @@ +"""Phase 7R.6A Cashfree payment gateway integration + +Revision ID: 20260607_phase_7r6a_cashfree +Revises: 20260606_phase_7r6_payumoney +Create Date: 2026-06-07 + +Adds Cashfree gateway settings and generic Cashfree fields to the existing online +payment transaction ledger. Defensive for SQLite development databases. +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "20260607_phase_7r6a_cashfree" +down_revision = "20260606_phase_7r6_payumoney" +branch_labels = None +depends_on = None + + +def _tables() -> set[str]: + return set(sa.inspect(op.get_bind()).get_table_names()) + + +def _columns(table_name: str) -> set[str]: + inspector = sa.inspect(op.get_bind()) + if table_name not in inspector.get_table_names(): + return set() + return {c["name"] for c in inspector.get_columns(table_name)} + + +def _add_if_missing(table: str, column: sa.Column) -> None: + if table in _tables() and column.name not in _columns(table): + op.add_column(table, column) + + +def _index_exists(index_name: str) -> bool: + inspector = sa.inspect(op.get_bind()) + for table_name in inspector.get_table_names(): + for idx in inspector.get_indexes(table_name): + if idx.get("name") == index_name: + return True + return False + + +def _create_index_if_missing(name: str, table: str, columns: list[str], unique: bool = False) -> None: + if table in _tables() and not _index_exists(name): + op.create_index(name, table, columns, unique=unique) + + +def upgrade() -> None: + _add_if_missing("billing_settings", sa.Column("cashfree_enabled", sa.Boolean(), nullable=False, server_default=sa.false())) + _add_if_missing("billing_settings", sa.Column("cashfree_mode", sa.String(length=20), nullable=False, server_default="TEST")) + _add_if_missing("billing_settings", sa.Column("cashfree_client_id", sa.String(length=180), nullable=True)) + _add_if_missing("billing_settings", sa.Column("cashfree_client_secret", sa.String(length=240), nullable=True)) + _add_if_missing("billing_settings", sa.Column("cashfree_api_version", sa.String(length=20), nullable=False, server_default="2023-08-01")) + _add_if_missing("billing_settings", sa.Column("cashfree_order_note", sa.String(length=250), nullable=True)) + + _add_if_missing("billing_online_payment_transactions", sa.Column("cashfree_order_id", sa.String(length=120), nullable=True)) + _add_if_missing("billing_online_payment_transactions", sa.Column("cashfree_cf_order_id", sa.String(length=120), nullable=True)) + _add_if_missing("billing_online_payment_transactions", sa.Column("cashfree_payment_session_id", sa.String(length=500), nullable=True)) + _add_if_missing("billing_online_payment_transactions", sa.Column("cashfree_payment_id", sa.String(length=120), nullable=True)) + _add_if_missing("billing_online_payment_transactions", sa.Column("webhook_event_id", sa.String(length=120), nullable=True)) + + _create_index_if_missing("ix_billing_online_payment_transactions_cashfree_order_id", "billing_online_payment_transactions", ["cashfree_order_id"]) + _create_index_if_missing("ix_billing_online_payment_transactions_cashfree_cf_order_id", "billing_online_payment_transactions", ["cashfree_cf_order_id"]) + _create_index_if_missing("ix_billing_online_payment_transactions_cashfree_payment_id", "billing_online_payment_transactions", ["cashfree_payment_id"]) + _create_index_if_missing("ix_billing_online_payment_transactions_webhook_event_id", "billing_online_payment_transactions", ["webhook_event_id"]) + + +def downgrade() -> None: + # SQLite cannot safely drop columns in older versions. Keep columns to avoid data loss. + pass diff --git a/alembic/versions/20260608_phase_7s1_email_notifications.py b/alembic/versions/20260608_phase_7s1_email_notifications.py new file mode 100644 index 0000000..7dd4f2e --- /dev/null +++ b/alembic/versions/20260608_phase_7s1_email_notifications.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "20260608_phase_7s1_email_notifications" +down_revision = "20260607_phase_7r6a_cashfree" +branch_labels = None +depends_on = None + + +def _has_table(bind, table_name: str) -> bool: + return sa.inspect(bind).has_table(table_name) + + +def upgrade() -> None: + bind = op.get_bind() + + if not _has_table(bind, "email_settings"): + op.create_table( + "email_settings", + 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("branch_id", sa.Integer(), sa.ForeignKey("branches.id", ondelete="SET NULL"), nullable=True), + sa.Column("smtp_host", sa.String(length=255), nullable=True), + sa.Column("smtp_port", sa.Integer(), nullable=True), + sa.Column("smtp_username", sa.String(length=255), nullable=True), + sa.Column("smtp_password", sa.String(length=500), nullable=True), + sa.Column("smtp_security", sa.String(length=20), nullable=False, server_default="SSL"), + sa.Column("smtp_timeout_seconds", sa.Integer(), nullable=False, server_default="20"), + sa.Column("from_email", sa.String(length=255), nullable=True), + sa.Column("from_name", sa.String(length=255), nullable=True), + sa.Column("reply_to_email", sa.String(length=255), nullable=True), + sa.Column("imap_host", sa.String(length=255), nullable=True), + sa.Column("imap_port", sa.Integer(), nullable=True), + sa.Column("imap_username", sa.String(length=255), nullable=True), + sa.Column("imap_password", sa.String(length=500), nullable=True), + sa.Column("imap_security", sa.String(length=20), nullable=False, server_default="SSL"), + sa.Column("send_auth_emails", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("send_alert_emails", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("send_billing_emails", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("send_task_emails", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("updated_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), 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", "branch_id", name="uq_email_settings_tenant_branch"), + ) + op.create_index("ix_email_settings_tenant_id", "email_settings", ["tenant_id"]) + op.create_index("ix_email_settings_branch_id", "email_settings", ["branch_id"]) + op.create_index("ix_email_settings_is_active", "email_settings", ["is_active"]) + + if not _has_table(bind, "email_templates"): + op.create_table( + "email_templates", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True), + sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id", ondelete="SET NULL"), nullable=True), + sa.Column("template_code", sa.String(length=80), nullable=False), + sa.Column("template_name", sa.String(length=160), nullable=False), + sa.Column("subject_template", sa.String(length=500), nullable=False), + sa.Column("body_template", sa.Text(), nullable=False), + sa.Column("is_html", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.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", "branch_id", "template_code", name="uq_email_templates_scope_code"), + ) + op.create_index("ix_email_templates_tenant_id", "email_templates", ["tenant_id"]) + op.create_index("ix_email_templates_branch_id", "email_templates", ["branch_id"]) + op.create_index("ix_email_templates_template_code", "email_templates", ["template_code"]) + op.create_index("ix_email_templates_is_active", "email_templates", ["is_active"]) + + if not _has_table(bind, "email_logs"): + op.create_table( + "email_logs", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True), + sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id", ondelete="SET NULL"), nullable=True), + sa.Column("recipient_email", sa.String(length=255), nullable=False), + sa.Column("subject", sa.String(length=500), nullable=False), + sa.Column("body", sa.Text(), nullable=True), + sa.Column("status", sa.String(length=30), nullable=False, server_default="PENDING"), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("sent_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("related_module", sa.String(length=80), nullable=True), + sa.Column("related_id", sa.Integer(), nullable=True), + sa.Column("template_code", sa.String(length=80), nullable=True), + sa.Column("provider_message_id", sa.String(length=255), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + ) + op.create_index("ix_email_logs_tenant_id", "email_logs", ["tenant_id"]) + op.create_index("ix_email_logs_branch_id", "email_logs", ["branch_id"]) + op.create_index("ix_email_logs_recipient_email", "email_logs", ["recipient_email"]) + op.create_index("ix_email_logs_status", "email_logs", ["status"]) + op.create_index("ix_email_logs_sent_at", "email_logs", ["sent_at"]) + op.create_index("ix_email_logs_related_module", "email_logs", ["related_module"]) + op.create_index("ix_email_logs_related_id", "email_logs", ["related_id"]) + op.create_index("ix_email_logs_template_code", "email_logs", ["template_code"]) + op.create_index("ix_email_logs_created_at_utc", "email_logs", ["created_at_utc"]) + + +def downgrade() -> None: + bind = op.get_bind() + if _has_table(bind, "email_logs"): + op.drop_table("email_logs") + if _has_table(bind, "email_templates"): + op.drop_table("email_templates") + if _has_table(bind, "email_settings"): + op.drop_table("email_settings") diff --git a/alembic/versions/20260609_phase_7s1c_email_preferences.py b/alembic/versions/20260609_phase_7s1c_email_preferences.py new file mode 100644 index 0000000..5d39f0c --- /dev/null +++ b/alembic/versions/20260609_phase_7s1c_email_preferences.py @@ -0,0 +1,50 @@ +"""Phase 7S.1C email notification preferences + +Revision ID: 20260609_phase_7s1c_email_preferences +Revises: 20260608_phase_7s1_email_notifications +Create Date: 2026-06-09 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "20260609_phase_7s1c_email_preferences" +down_revision = "20260608_phase_7s1_email_notifications" +branch_labels = None +depends_on = None + + +_NEW_COLUMNS: list[tuple[str, sa.Column]] = [ + ("send_invoice_emails", sa.Column("send_invoice_emails", sa.Boolean(), nullable=False, server_default=sa.true())), + ("send_payment_emails", sa.Column("send_payment_emails", sa.Boolean(), nullable=False, server_default=sa.true())), + ("send_client_emails", sa.Column("send_client_emails", sa.Boolean(), nullable=False, server_default=sa.true())), + ("send_document_emails", sa.Column("send_document_emails", sa.Boolean(), nullable=False, server_default=sa.true())), + ("send_consultant_emails", sa.Column("send_consultant_emails", sa.Boolean(), nullable=False, server_default=sa.true())), + ("send_partner_review_emails", sa.Column("send_partner_review_emails", sa.Boolean(), nullable=False, server_default=sa.true())), + ("send_leave_attendance_emails", sa.Column("send_leave_attendance_emails", sa.Boolean(), nullable=False, server_default=sa.true())), + ("send_online_payment_emails", sa.Column("send_online_payment_emails", sa.Boolean(), nullable=False, server_default=sa.true())), +] + + +def _existing_columns(table_name: str) -> set[str]: + bind = op.get_bind() + inspector = sa.inspect(bind) + return {col["name"] for col in inspector.get_columns(table_name)} + + +def upgrade() -> None: + existing = _existing_columns("email_settings") + with op.batch_alter_table("email_settings") as batch_op: + for name, column in _NEW_COLUMNS: + if name not in existing: + batch_op.add_column(column) + + +def downgrade() -> None: + existing = _existing_columns("email_settings") + with op.batch_alter_table("email_settings") as batch_op: + for name, _column in reversed(_NEW_COLUMNS): + if name in existing: + batch_op.drop_column(name) diff --git a/alembic/versions/20260610_phase_7s1e_email_queue_retry.py b/alembic/versions/20260610_phase_7s1e_email_queue_retry.py new file mode 100644 index 0000000..b8bcb4e --- /dev/null +++ b/alembic/versions/20260610_phase_7s1e_email_queue_retry.py @@ -0,0 +1,84 @@ +"""Phase 7S.1E email queue and retry metadata + +Revision ID: 20260610_phase_7s1e_email_queue +Revises: 20260609_phase_7s1c_email_preferences +Create Date: 2026-06-10 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "20260610_phase_7s1e_email_queue" +down_revision = "20260609_phase_7s1c_email_preferences" +branch_labels = None +depends_on = None + + +def _existing_columns(table_name: str) -> set[str]: + bind = op.get_bind() + inspector = sa.inspect(bind) + try: + return {col["name"] for col in inspector.get_columns(table_name)} + except Exception: + return set() + + +def _add_column_if_missing(table_name: str, column: sa.Column) -> None: + if column.name not in _existing_columns(table_name): + op.add_column(table_name, column) + + +def upgrade() -> None: + _add_column_if_missing("email_logs", sa.Column("attempt_count", sa.Integer(), nullable=False, server_default="0")) + _add_column_if_missing("email_logs", sa.Column("max_attempts", sa.Integer(), nullable=False, server_default="3")) + _add_column_if_missing("email_logs", sa.Column("queue_priority", sa.Integer(), nullable=False, server_default="100")) + _add_column_if_missing("email_logs", sa.Column("is_retryable", sa.Boolean(), nullable=False, server_default=sa.true())) + _add_column_if_missing("email_logs", sa.Column("queued_at", sa.DateTime(timezone=True), nullable=True)) + _add_column_if_missing("email_logs", sa.Column("next_retry_at", sa.DateTime(timezone=True), nullable=True)) + _add_column_if_missing("email_logs", sa.Column("last_attempt_at", sa.DateTime(timezone=True), nullable=True)) + _add_column_if_missing("email_logs", sa.Column("processing_started_at", sa.DateTime(timezone=True), nullable=True)) + + existing_indexes = {idx["name"] for idx in sa.inspect(op.get_bind()).get_indexes("email_logs")} + for name, cols in { + "ix_email_logs_queue_priority": ["queue_priority"], + "ix_email_logs_is_retryable": ["is_retryable"], + "ix_email_logs_queued_at": ["queued_at"], + "ix_email_logs_next_retry_at": ["next_retry_at"], + "ix_email_logs_last_attempt_at": ["last_attempt_at"], + "ix_email_logs_processing_started_at": ["processing_started_at"], + }.items(): + if name not in existing_indexes: + op.create_index(name, "email_logs", cols) + + +def downgrade() -> None: + # Safe downgrade for development only. SQLite may not support all drop-column + # operations depending on version, so production rollback should use backups. + for name in [ + "ix_email_logs_processing_started_at", + "ix_email_logs_last_attempt_at", + "ix_email_logs_next_retry_at", + "ix_email_logs_queued_at", + "ix_email_logs_is_retryable", + "ix_email_logs_queue_priority", + ]: + try: + op.drop_index(name, table_name="email_logs") + except Exception: + pass + for col in [ + "processing_started_at", + "last_attempt_at", + "next_retry_at", + "queued_at", + "is_retryable", + "queue_priority", + "max_attempts", + "attempt_count", + ]: + try: + op.drop_column("email_logs", col) + except Exception: + pass diff --git a/alembic/versions/20260611_phase_7s2_imap_incoming_email_reading.py b/alembic/versions/20260611_phase_7s2_imap_incoming_email_reading.py new file mode 100644 index 0000000..a9736c8 --- /dev/null +++ b/alembic/versions/20260611_phase_7s2_imap_incoming_email_reading.py @@ -0,0 +1,113 @@ +"""Phase 7S.2 IMAP incoming email reading + +Revision ID: 20260611_phase_7s2_imap_incoming +Revises: 20260610_phase_7s1e_email_queue +Create Date: 2026-06-11 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "20260611_phase_7s2_imap_incoming" +down_revision = "20260610_phase_7s1e_email_queue" +branch_labels = None +depends_on = None + + +def _has_table(table_name: str) -> bool: + return table_name in sa.inspect(op.get_bind()).get_table_names() + + +def _create_index_if_missing(table_name: str, index_name: str, columns: list[str]) -> None: + existing = {idx["name"] for idx in sa.inspect(op.get_bind()).get_indexes(table_name)} if _has_table(table_name) else set() + if index_name not in existing: + op.create_index(index_name, table_name, columns) + + +def upgrade() -> None: + if not _has_table("email_incoming_messages"): + op.create_table( + "email_incoming_messages", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True), + sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id", ondelete="SET NULL"), nullable=True), + sa.Column("mailbox_email", sa.String(length=255), nullable=False), + sa.Column("folder_name", sa.String(length=120), nullable=False, server_default="INBOX"), + sa.Column("provider_uid", sa.String(length=120), nullable=False), + sa.Column("provider_message_id", sa.String(length=500), nullable=True), + sa.Column("sender_email", sa.String(length=255), nullable=True), + sa.Column("sender_name", sa.String(length=255), nullable=True), + sa.Column("recipient_emails", sa.Text(), nullable=True), + sa.Column("cc_emails", sa.Text(), nullable=True), + sa.Column("subject", sa.String(length=500), nullable=True), + sa.Column("body_text", sa.Text(), nullable=True), + sa.Column("body_html", sa.Text(), nullable=True), + sa.Column("raw_headers", sa.Text(), nullable=True), + sa.Column("received_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("status", sa.String(length=30), nullable=False, server_default="NEW"), + sa.Column("matched_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("matched_client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="SET NULL"), nullable=True), + sa.Column("matched_consultant_id", sa.Integer(), sa.ForeignKey("consultant_profiles.id", ondelete="SET NULL"), nullable=True), + sa.Column("related_module", sa.String(length=80), nullable=True), + sa.Column("related_id", sa.Integer(), nullable=True), + sa.Column("has_attachments", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("attachment_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("fetched_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + 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", "branch_id", "mailbox_email", "folder_name", "provider_uid", name="uq_email_incoming_scope_mailbox_folder_uid"), + ) + for name, cols in { + "ix_email_incoming_messages_tenant_id": ["tenant_id"], + "ix_email_incoming_messages_branch_id": ["branch_id"], + "ix_email_incoming_messages_mailbox_email": ["mailbox_email"], + "ix_email_incoming_messages_folder_name": ["folder_name"], + "ix_email_incoming_messages_provider_uid": ["provider_uid"], + "ix_email_incoming_messages_provider_message_id": ["provider_message_id"], + "ix_email_incoming_messages_sender_email": ["sender_email"], + "ix_email_incoming_messages_subject": ["subject"], + "ix_email_incoming_messages_received_at_utc": ["received_at_utc"], + "ix_email_incoming_messages_status": ["status"], + "ix_email_incoming_messages_matched_user_id": ["matched_user_id"], + "ix_email_incoming_messages_matched_client_id": ["matched_client_id"], + "ix_email_incoming_messages_matched_consultant_id": ["matched_consultant_id"], + "ix_email_incoming_messages_related_module": ["related_module"], + "ix_email_incoming_messages_related_id": ["related_id"], + "ix_email_incoming_messages_has_attachments": ["has_attachments"], + "ix_email_incoming_messages_fetched_at_utc": ["fetched_at_utc"], + }.items(): + _create_index_if_missing("email_incoming_messages", name, cols) + + if not _has_table("email_incoming_attachments"): + op.create_table( + "email_incoming_attachments", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("incoming_message_id", sa.Integer(), sa.ForeignKey("email_incoming_messages.id", ondelete="CASCADE"), nullable=False), + sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True), + sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id", ondelete="SET NULL"), nullable=True), + sa.Column("filename", sa.String(length=255), nullable=True), + sa.Column("content_type", sa.String(length=120), nullable=True), + sa.Column("size_bytes", sa.Integer(), nullable=False, server_default="0"), + sa.Column("storage_path", sa.String(length=1000), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + ) + for name, cols in { + "ix_email_incoming_attachments_incoming_message_id": ["incoming_message_id"], + "ix_email_incoming_attachments_tenant_id": ["tenant_id"], + "ix_email_incoming_attachments_branch_id": ["branch_id"], + }.items(): + _create_index_if_missing("email_incoming_attachments", name, cols) + + +def downgrade() -> None: + try: + op.drop_table("email_incoming_attachments") + except Exception: + pass + try: + op.drop_table("email_incoming_messages") + except Exception: + pass diff --git a/alembic/versions/20260612_phase_7s3_email_to_engagement_mapping.py b/alembic/versions/20260612_phase_7s3_email_to_engagement_mapping.py new file mode 100644 index 0000000..9e9bdae --- /dev/null +++ b/alembic/versions/20260612_phase_7s3_email_to_engagement_mapping.py @@ -0,0 +1,103 @@ +"""Phase 7S.3 email-to-engagement mapping + +Revision ID: 20260612_phase_7s3_email_mapping +Revises: 20260611_phase_7s2_imap_incoming +Create Date: 2026-06-12 + +SQLite-safe revision: +- Adds reference id columns as plain Integer columns instead of adding + ForeignKey constraints through ALTER TABLE, because SQLite cannot ALTER + constraints without batch copy/move migration. +- SQLAlchemy model relationships can still use these id columns for lookups. +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "20260612_phase_7s3_email_mapping" +down_revision = "20260611_phase_7s2_imap_incoming" +branch_labels = None +depends_on = None + + +def _inspector(): + return sa.inspect(op.get_bind()) + + +def _has_table(table_name: str) -> bool: + return table_name in _inspector().get_table_names() + + +def _has_column(table_name: str, column_name: str) -> bool: + if not _has_table(table_name): + return False + return column_name in {col["name"] for col in _inspector().get_columns(table_name)} + + +def _create_index_if_missing(table_name: str, index_name: str, columns: list[str]) -> None: + if not _has_table(table_name): + return + existing = {idx["name"] for idx in _inspector().get_indexes(table_name)} + if index_name not in existing: + op.create_index(index_name, table_name, columns) + + +def _add_column_if_missing(table_name: str, column: sa.Column) -> None: + if _has_table(table_name) and not _has_column(table_name, column.name): + op.add_column(table_name, column) + + +def upgrade() -> None: + if not _has_table("email_incoming_messages"): + return + + _add_column_if_missing( + "email_incoming_messages", + sa.Column("tracking_code", sa.String(length=80), nullable=True), + ) + + # SQLite does not support ALTER TABLE ADD CONSTRAINT for ForeignKey. + # Keep these as nullable integer reference columns. Application code and + # SQLAlchemy models can still resolve the linked records safely. + _add_column_if_missing( + "email_incoming_messages", + sa.Column("matched_engagement_id", sa.Integer(), nullable=True), + ) + _add_column_if_missing( + "email_incoming_messages", + sa.Column("matched_task_id", sa.Integer(), nullable=True), + ) + _add_column_if_missing( + "email_incoming_messages", + sa.Column("matched_invoice_id", sa.Integer(), nullable=True), + ) + _add_column_if_missing( + "email_incoming_messages", + sa.Column("mapping_status", sa.String(length=30), nullable=False, server_default="UNMAPPED"), + ) + _add_column_if_missing( + "email_incoming_messages", + sa.Column("mapping_notes", sa.Text(), nullable=True), + ) + _add_column_if_missing( + "email_incoming_messages", + sa.Column("mapped_at_utc", sa.DateTime(timezone=True), nullable=True), + ) + + for name, cols in { + "ix_email_incoming_messages_tracking_code": ["tracking_code"], + "ix_email_incoming_messages_matched_engagement_id": ["matched_engagement_id"], + "ix_email_incoming_messages_matched_task_id": ["matched_task_id"], + "ix_email_incoming_messages_matched_invoice_id": ["matched_invoice_id"], + "ix_email_incoming_messages_mapping_status": ["mapping_status"], + "ix_email_incoming_messages_mapped_at_utc": ["mapped_at_utc"], + }.items(): + _create_index_if_missing("email_incoming_messages", name, cols) + + +def downgrade() -> None: + # Keep defensive because SQLite cannot reliably drop columns/constraints + # in all local development environments without a batch table rewrite. + pass diff --git a/alembic/versions/20260613_phase_7t1_domain_mapping_table.py b/alembic/versions/20260613_phase_7t1_domain_mapping_table.py new file mode 100644 index 0000000..384faf7 --- /dev/null +++ b/alembic/versions/20260613_phase_7t1_domain_mapping_table.py @@ -0,0 +1,72 @@ +"""Phase 7T.1 domain mapping table + +Revision ID: 20260613_phase_7t1_domain_mapping +Revises: 20260612_phase_7s3_email_mapping +Create Date: 2026-06-13 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "20260613_phase_7t1_domain_mapping" +down_revision = "20260612_phase_7s3_email_mapping" +branch_labels = None +depends_on = None + + +def _table_exists(table_name: str) -> bool: + bind = op.get_bind() + inspector = sa.inspect(bind) + return table_name in inspector.get_table_names() + + +def upgrade() -> None: + if _table_exists("domain_mappings"): + return + + op.create_table( + "domain_mappings", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("domain_name", sa.String(length=255), nullable=False), + sa.Column("domain_type", sa.String(length=60), nullable=False), + sa.Column("tenant_id", sa.Integer(), nullable=True), + sa.Column("branch_id", sa.Integer(), nullable=True), + sa.Column("consultant_id", sa.Integer(), nullable=True), + sa.Column("parent_tenant_id", sa.Integer(), nullable=True), + sa.Column("is_primary", sa.Boolean(), nullable=False, server_default=sa.text("0")), + sa.Column("is_verified", sa.Boolean(), nullable=False, server_default=sa.text("0")), + sa.Column("status", sa.String(length=30), nullable=False, server_default="draft"), + sa.Column("verification_token", sa.String(length=120), nullable=True), + sa.Column("dns_txt_name", sa.String(length=255), nullable=True), + sa.Column("dns_txt_value", sa.String(length=255), nullable=True), + sa.Column("ssl_mode", sa.String(length=30), nullable=False, server_default="manual"), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("created_by_user_id", sa.Integer(), nullable=True), + sa.Column("updated_by_user_id", sa.Integer(), 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.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["consultant_id"], ["consultant_profiles.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["parent_tenant_id"], ["tenants.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.UniqueConstraint("domain_name", name="uq_domain_mappings_domain_name"), + ) + op.create_index("ix_domain_mappings_domain_name", "domain_mappings", ["domain_name"]) + op.create_index("ix_domain_mappings_domain_type", "domain_mappings", ["domain_type"]) + op.create_index("ix_domain_mappings_status", "domain_mappings", ["status"]) + op.create_index("ix_domain_mappings_tenant_id", "domain_mappings", ["tenant_id"]) + op.create_index("ix_domain_mappings_branch_id", "domain_mappings", ["branch_id"]) + op.create_index("ix_domain_mappings_consultant_id", "domain_mappings", ["consultant_id"]) + op.create_index("ix_domain_mappings_parent_tenant_id", "domain_mappings", ["parent_tenant_id"]) + op.create_index("ix_domain_mappings_is_primary", "domain_mappings", ["is_primary"]) + op.create_index("ix_domain_mappings_is_verified", "domain_mappings", ["is_verified"]) + op.create_index("ix_domain_mappings_verification_token", "domain_mappings", ["verification_token"]) + + +def downgrade() -> None: + if not _table_exists("domain_mappings"): + return + op.drop_table("domain_mappings") diff --git a/alembic/versions/20260614_phase_7t9_ssl_automation.py b/alembic/versions/20260614_phase_7t9_ssl_automation.py new file mode 100644 index 0000000..fb2ba2b --- /dev/null +++ b/alembic/versions/20260614_phase_7t9_ssl_automation.py @@ -0,0 +1,59 @@ +"""Phase 7T.9 SSL automation status fields + +Revision ID: 20260614_phase_7t9_ssl_automation +Revises: 20260613_phase_7t1_domain_mapping +Create Date: 2026-06-14 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "20260614_phase_7t9_ssl_automation" +down_revision = "20260613_phase_7t1_domain_mapping" +branch_labels = None +depends_on = None + + +def _table_exists(table_name: str) -> bool: + bind = op.get_bind() + inspector = sa.inspect(bind) + return table_name in inspector.get_table_names() + + +def _column_exists(table_name: str, column_name: str) -> bool: + bind = op.get_bind() + inspector = sa.inspect(bind) + if table_name not in inspector.get_table_names(): + return False + return column_name in {c["name"] for c in inspector.get_columns(table_name)} + + +def _add_column_if_missing(table_name: str, column: sa.Column) -> None: + if not _column_exists(table_name, column.name): + op.add_column(table_name, column) + + +def upgrade() -> None: + if not _table_exists("domain_mappings"): + return + + _add_column_if_missing("domain_mappings", sa.Column("ssl_status", sa.String(length=30), nullable=False, server_default="not_checked")) + _add_column_if_missing("domain_mappings", sa.Column("ssl_provider", sa.String(length=60), nullable=True)) + _add_column_if_missing("domain_mappings", sa.Column("ssl_last_checked_at_utc", sa.DateTime(timezone=True), nullable=True)) + _add_column_if_missing("domain_mappings", sa.Column("ssl_not_after_utc", sa.DateTime(timezone=True), nullable=True)) + _add_column_if_missing("domain_mappings", sa.Column("ssl_issuer", sa.String(length=255), nullable=True)) + _add_column_if_missing("domain_mappings", sa.Column("ssl_subject", sa.String(length=255), nullable=True)) + _add_column_if_missing("domain_mappings", sa.Column("ssl_last_error", sa.Text(), nullable=True)) + + bind = op.get_bind() + inspector = sa.inspect(bind) + indexes = {idx["name"] for idx in inspector.get_indexes("domain_mappings")} + if "ix_domain_mappings_ssl_status" not in indexes: + op.create_index("ix_domain_mappings_ssl_status", "domain_mappings", ["ssl_status"], unique=False) + + +def downgrade() -> None: + # SQLite-safe downgrade is intentionally conservative. Existing projects can keep + # these nullable/status fields without affecting domain resolution. + pass diff --git a/alembic/versions/20260615_phase_8a_task_level_documents.py b/alembic/versions/20260615_phase_8a_task_level_documents.py new file mode 100644 index 0000000..eb8458a --- /dev/null +++ b/alembic/versions/20260615_phase_8a_task_level_documents.py @@ -0,0 +1,141 @@ +"""Phase 8A - task level documents and task template uploads + +Revision ID: 20260615_phase_8a_task_docs +Revises: 20260614_phase_7t9_ssl_automation +Create Date: 2026-06-02 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "20260615_phase_8a_task_docs" +down_revision = "20260614_phase_7t9_ssl_automation" +branch_labels = None +depends_on = None + + +def _inspect(bind): + return sa.inspect(bind) + + +def _has_table(bind, table_name: str) -> bool: + return _inspect(bind).has_table(table_name) + + +def _has_column(bind, table_name: str, column_name: str) -> bool: + if not _has_table(bind, table_name): + return False + return column_name in {col["name"] for col in _inspect(bind).get_columns(table_name)} + + +def _has_index(bind, table_name: str, index_name: str) -> bool: + if not _has_table(bind, table_name): + return False + return index_name in {ix["name"] for ix in _inspect(bind).get_indexes(table_name)} + + +def upgrade() -> None: + bind = op.get_bind() + + if not _has_table(bind, "firm_task_document_requirements"): + op.create_table( + "firm_task_document_requirements", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("service_catalogue_id", sa.Integer(), nullable=False), + sa.Column("firm_task_template_id", sa.Integer(), nullable=False), + sa.Column("document_name", sa.String(length=200), nullable=False), + sa.Column("document_type", sa.String(length=80), nullable=False, server_default="GENERAL"), + sa.Column("is_mandatory", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("allowed_file_types", sa.String(length=255), nullable=True), + sa.Column("instructions", sa.Text(), nullable=True), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default="100"), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("created_by_user_id", sa.Integer(), nullable=True), + sa.Column("updated_by_user_id", sa.Integer(), 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.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name="fk_firm_task_doc_req_tenant_id", ondelete="CASCADE"), + sa.ForeignKeyConstraint(["service_catalogue_id"], ["service_catalogues.id"], name="fk_firm_task_doc_req_service_catalogue_id", ondelete="CASCADE"), + sa.ForeignKeyConstraint(["firm_task_template_id"], ["firm_service_task_templates.id"], name="fk_firm_task_doc_req_firm_task_template_id", ondelete="CASCADE"), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], name="fk_firm_task_doc_req_created_by_user_id"), + sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"], name="fk_firm_task_doc_req_updated_by_user_id"), + sa.UniqueConstraint("tenant_id", "firm_task_template_id", "document_name", name="uq_firm_task_document_requirements_name"), + ) + if not _has_index(bind, "firm_task_document_requirements", "ix_firm_task_document_requirements_tenant_id"): + op.create_index("ix_firm_task_document_requirements_tenant_id", "firm_task_document_requirements", ["tenant_id"]) + if not _has_index(bind, "firm_task_document_requirements", "ix_firm_task_document_requirements_service_catalogue_id"): + op.create_index("ix_firm_task_document_requirements_service_catalogue_id", "firm_task_document_requirements", ["service_catalogue_id"]) + if not _has_index(bind, "firm_task_document_requirements", "ix_firm_task_document_requirements_firm_task_template_id"): + op.create_index("ix_firm_task_document_requirements_firm_task_template_id", "firm_task_document_requirements", ["firm_task_template_id"]) + if not _has_index(bind, "firm_task_document_requirements", "ix_firm_task_document_requirements_document_type"): + op.create_index("ix_firm_task_document_requirements_document_type", "firm_task_document_requirements", ["document_type"]) + + if not _has_table(bind, "firm_task_document_templates"): + op.create_table( + "firm_task_document_templates", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("service_catalogue_id", sa.Integer(), nullable=False), + sa.Column("firm_task_template_id", sa.Integer(), nullable=False), + sa.Column("template_name", sa.String(length=200), nullable=False), + sa.Column("template_category", sa.String(length=100), nullable=True), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("original_filename", sa.String(length=255), nullable=False), + sa.Column("stored_filename", sa.String(length=255), nullable=False), + sa.Column("content_type", sa.String(length=150), nullable=True), + sa.Column("file_size_bytes", sa.Integer(), nullable=False, server_default="0"), + sa.Column("local_relative_path", sa.String(length=1000), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("uploaded_by_user_id", sa.Integer(), nullable=True), + sa.Column("uploaded_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name="fk_firm_task_doc_tpl_tenant_id", ondelete="CASCADE"), + sa.ForeignKeyConstraint(["service_catalogue_id"], ["service_catalogues.id"], name="fk_firm_task_doc_tpl_service_catalogue_id", ondelete="CASCADE"), + sa.ForeignKeyConstraint(["firm_task_template_id"], ["firm_service_task_templates.id"], name="fk_firm_task_doc_tpl_firm_task_template_id", ondelete="CASCADE"), + sa.ForeignKeyConstraint(["uploaded_by_user_id"], ["users.id"], name="fk_firm_task_doc_tpl_uploaded_by_user_id"), + ) + if not _has_index(bind, "firm_task_document_templates", "ix_firm_task_document_templates_tenant_id"): + op.create_index("ix_firm_task_document_templates_tenant_id", "firm_task_document_templates", ["tenant_id"]) + if not _has_index(bind, "firm_task_document_templates", "ix_firm_task_document_templates_service_catalogue_id"): + op.create_index("ix_firm_task_document_templates_service_catalogue_id", "firm_task_document_templates", ["service_catalogue_id"]) + if not _has_index(bind, "firm_task_document_templates", "ix_firm_task_document_templates_firm_task_template_id"): + op.create_index("ix_firm_task_document_templates_firm_task_template_id", "firm_task_document_templates", ["firm_task_template_id"]) + if not _has_index(bind, "firm_task_document_templates", "ix_firm_task_document_templates_template_category"): + op.create_index("ix_firm_task_document_templates_template_category", "firm_task_document_templates", ["template_category"]) + if not _has_index(bind, "firm_task_document_templates", "ix_firm_task_document_templates_is_active"): + op.create_index("ix_firm_task_document_templates_is_active", "firm_task_document_templates", ["is_active"]) + + # SQLite-safe: add nullable columns only. Do not create batch foreign-key constraints here. + # SQLAlchemy relationships in the app still work through these *_id columns. + if _has_table(bind, "engagement_documents"): + with op.batch_alter_table("engagement_documents") as batch: + if not _has_column(bind, "engagement_documents", "task_instance_id"): + batch.add_column(sa.Column("task_instance_id", sa.Integer(), nullable=True)) + if not _has_column(bind, "engagement_documents", "document_requirement_id"): + batch.add_column(sa.Column("document_requirement_id", sa.Integer(), nullable=True)) + + if not _has_index(bind, "engagement_documents", "ix_engagement_documents_task_instance_id"): + op.create_index("ix_engagement_documents_task_instance_id", "engagement_documents", ["task_instance_id"]) + if not _has_index(bind, "engagement_documents", "ix_engagement_documents_document_requirement_id"): + op.create_index("ix_engagement_documents_document_requirement_id", "engagement_documents", ["document_requirement_id"]) + + +def downgrade() -> None: + bind = op.get_bind() + + if _has_table(bind, "engagement_documents"): + if _has_index(bind, "engagement_documents", "ix_engagement_documents_document_requirement_id"): + op.drop_index("ix_engagement_documents_document_requirement_id", table_name="engagement_documents") + if _has_index(bind, "engagement_documents", "ix_engagement_documents_task_instance_id"): + op.drop_index("ix_engagement_documents_task_instance_id", table_name="engagement_documents") + with op.batch_alter_table("engagement_documents") as batch: + if _has_column(bind, "engagement_documents", "document_requirement_id"): + batch.drop_column("document_requirement_id") + if _has_column(bind, "engagement_documents", "task_instance_id"): + batch.drop_column("task_instance_id") + + if _has_table(bind, "firm_task_document_templates"): + op.drop_table("firm_task_document_templates") + if _has_table(bind, "firm_task_document_requirements"): + op.drop_table("firm_task_document_requirements") diff --git a/alembic/versions/20260616_phase_8b_notice_case_management.py b/alembic/versions/20260616_phase_8b_notice_case_management.py new file mode 100644 index 0000000..51ca0bc --- /dev/null +++ b/alembic/versions/20260616_phase_8b_notice_case_management.py @@ -0,0 +1,174 @@ +"""Phase 8B - Notice and Case Management + +Revision ID: 20260616_phase_8b_notice_cases +Revises: 20260615_phase_8a_task_docs +Create Date: 2026-06-16 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "20260616_phase_8b_notice_cases" +down_revision = "20260615_phase_8a_task_docs" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "notice_cases", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("branch_id", sa.Integer(), nullable=True), + sa.Column("client_id", sa.Integer(), nullable=False), + sa.Column("engagement_id", sa.Integer(), nullable=True), + sa.Column("case_code", sa.String(80), nullable=False), + sa.Column("department", sa.String(40), nullable=False), + sa.Column("case_type", sa.String(50), nullable=False), + sa.Column("title", sa.String(255), nullable=False), + sa.Column("reference_no", sa.String(150), nullable=True), + sa.Column("din_ack_no", sa.String(150), nullable=True), + sa.Column("notice_date", sa.Date(), nullable=True), + sa.Column("due_date", sa.Date(), nullable=True), + sa.Column("financial_year", sa.String(9), nullable=True), + sa.Column("assessment_year", sa.String(9), nullable=True), + sa.Column("period_label", sa.String(60), nullable=True), + sa.Column("status", sa.String(40), nullable=False, server_default="open"), + sa.Column("priority", sa.String(20), nullable=False, server_default="normal"), + sa.Column("issue_summary", sa.Text(), nullable=True), + sa.Column("remarks", sa.Text(), nullable=True), + sa.Column("assigned_partner_user_id", sa.Integer(), nullable=True), + sa.Column("assigned_manager_user_id", sa.Integer(), nullable=True), + sa.Column("assigned_staff_user_id", sa.Integer(), nullable=True), + sa.Column("is_archived", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("archived_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("archived_by_user_id", sa.Integer(), nullable=True), + sa.Column("created_by_user_id", sa.Integer(), nullable=True), + sa.Column("updated_by_user_id", sa.Integer(), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name="fk_notice_cases_tenant", ondelete="CASCADE"), + sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], name="fk_notice_cases_branch", ondelete="SET NULL"), + sa.ForeignKeyConstraint(["client_id"], ["clients.id"], name="fk_notice_cases_client", ondelete="CASCADE"), + sa.ForeignKeyConstraint(["engagement_id"], ["client_service_subscriptions.id"], name="fk_notice_cases_engagement", ondelete="SET NULL"), + sa.ForeignKeyConstraint(["assigned_partner_user_id"], ["users.id"], name="fk_notice_cases_partner", ondelete="SET NULL"), + sa.ForeignKeyConstraint(["assigned_manager_user_id"], ["users.id"], name="fk_notice_cases_manager", ondelete="SET NULL"), + sa.ForeignKeyConstraint(["assigned_staff_user_id"], ["users.id"], name="fk_notice_cases_staff", ondelete="SET NULL"), + sa.ForeignKeyConstraint(["archived_by_user_id"], ["users.id"], name="fk_notice_cases_archived_by", ondelete="SET NULL"), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], name="fk_notice_cases_created_by", ondelete="SET NULL"), + sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"], name="fk_notice_cases_updated_by", ondelete="SET NULL"), + sa.UniqueConstraint("tenant_id", "case_code", name="uq_notice_cases_tenant_code"), + ) + for col in ["tenant_id","branch_id","client_id","engagement_id","case_code","department","case_type","reference_no","din_ack_no","notice_date","due_date","financial_year","assessment_year","period_label","status","priority","assigned_partner_user_id","assigned_manager_user_id","assigned_staff_user_id","is_archived"]: + op.create_index(f"ix_notice_cases_{col}", "notice_cases", [col]) + + op.create_table( + "notice_case_events", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("branch_id", sa.Integer(), nullable=True), + sa.Column("case_id", sa.Integer(), nullable=False), + sa.Column("event_type", sa.String(60), nullable=False), + sa.Column("event_date", sa.Date(), nullable=False), + sa.Column("description", sa.Text(), nullable=False), + sa.Column("next_due_date", sa.Date(), nullable=True), + sa.Column("created_by_user_id", sa.Integer(), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name="fk_notice_case_events_tenant", ondelete="CASCADE"), + sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], name="fk_notice_case_events_branch", ondelete="SET NULL"), + sa.ForeignKeyConstraint(["case_id"], ["notice_cases.id"], name="fk_notice_case_events_case", ondelete="CASCADE"), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], name="fk_notice_case_events_created_by", ondelete="SET NULL"), + ) + for col in ["tenant_id","branch_id","case_id","event_type","event_date","next_due_date","created_by_user_id"]: + op.create_index(f"ix_notice_case_events_{col}", "notice_case_events", [col]) + + op.create_table( + "notice_case_hearings", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("branch_id", sa.Integer(), nullable=True), + sa.Column("case_id", sa.Integer(), nullable=False), + sa.Column("hearing_date", sa.Date(), nullable=False), + sa.Column("hearing_time", sa.String(20), nullable=True), + sa.Column("venue_or_mode", sa.String(200), nullable=True), + sa.Column("officer_name", sa.String(150), nullable=True), + sa.Column("agenda", sa.Text(), nullable=True), + sa.Column("outcome", sa.Text(), nullable=True), + sa.Column("status", sa.String(30), nullable=False, server_default="scheduled"), + sa.Column("created_by_user_id", sa.Integer(), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name="fk_notice_case_hearings_tenant", ondelete="CASCADE"), + sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], name="fk_notice_case_hearings_branch", ondelete="SET NULL"), + sa.ForeignKeyConstraint(["case_id"], ["notice_cases.id"], name="fk_notice_case_hearings_case", ondelete="CASCADE"), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], name="fk_notice_case_hearings_created_by", ondelete="SET NULL"), + ) + for col in ["tenant_id","branch_id","case_id","hearing_date","status","created_by_user_id"]: + op.create_index(f"ix_notice_case_hearings_{col}", "notice_case_hearings", [col]) + + op.create_table( + "notice_case_orders", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("branch_id", sa.Integer(), nullable=True), + sa.Column("case_id", sa.Integer(), nullable=False), + sa.Column("order_type", sa.String(60), nullable=False), + sa.Column("order_no", sa.String(150), nullable=True), + sa.Column("order_date", sa.Date(), nullable=False), + sa.Column("demand_amount", sa.Integer(), nullable=False, server_default="0"), + sa.Column("tax_amount", sa.Integer(), nullable=False, server_default="0"), + sa.Column("interest_amount", sa.Integer(), nullable=False, server_default="0"), + sa.Column("penalty_amount", sa.Integer(), nullable=False, server_default="0"), + sa.Column("summary", sa.Text(), nullable=True), + sa.Column("appeal_due_date", sa.Date(), nullable=True), + sa.Column("appeal_filed", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("created_by_user_id", sa.Integer(), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name="fk_notice_case_orders_tenant", ondelete="CASCADE"), + sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], name="fk_notice_case_orders_branch", ondelete="SET NULL"), + sa.ForeignKeyConstraint(["case_id"], ["notice_cases.id"], name="fk_notice_case_orders_case", ondelete="CASCADE"), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], name="fk_notice_case_orders_created_by", ondelete="SET NULL"), + ) + for col in ["tenant_id","branch_id","case_id","order_type","order_no","order_date","appeal_due_date","appeal_filed","created_by_user_id"]: + op.create_index(f"ix_notice_case_orders_{col}", "notice_case_orders", [col]) + + op.create_table( + "notice_case_documents", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("branch_id", sa.Integer(), nullable=True), + sa.Column("case_id", sa.Integer(), nullable=False), + sa.Column("event_id", sa.Integer(), nullable=True), + sa.Column("document_type", sa.String(80), nullable=False, server_default="GENERAL"), + sa.Column("title", sa.String(255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("original_filename", sa.String(255), nullable=False), + sa.Column("stored_filename", sa.String(255), nullable=False), + sa.Column("content_type", sa.String(150), nullable=True), + sa.Column("file_size_bytes", sa.Integer(), nullable=False, server_default="0"), + sa.Column("local_relative_path", sa.String(1000), nullable=False), + sa.Column("version_no", sa.Integer(), nullable=False, server_default="1"), + sa.Column("status", sa.String(30), nullable=False, server_default="active"), + sa.Column("is_deleted", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("deleted_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("deleted_by_user_id", sa.Integer(), nullable=True), + sa.Column("uploaded_by_user_id", sa.Integer(), nullable=True), + sa.Column("uploaded_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name="fk_notice_case_documents_tenant", ondelete="CASCADE"), + sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], name="fk_notice_case_documents_branch", ondelete="SET NULL"), + sa.ForeignKeyConstraint(["case_id"], ["notice_cases.id"], name="fk_notice_case_documents_case", ondelete="CASCADE"), + sa.ForeignKeyConstraint(["event_id"], ["notice_case_events.id"], name="fk_notice_case_documents_event", ondelete="SET NULL"), + sa.ForeignKeyConstraint(["deleted_by_user_id"], ["users.id"], name="fk_notice_case_documents_deleted_by", ondelete="SET NULL"), + sa.ForeignKeyConstraint(["uploaded_by_user_id"], ["users.id"], name="fk_notice_case_documents_uploaded_by", ondelete="SET NULL"), + ) + for col in ["tenant_id","branch_id","case_id","event_id","document_type","status","is_deleted","uploaded_by_user_id"]: + op.create_index(f"ix_notice_case_documents_{col}", "notice_case_documents", [col]) + + +def downgrade() -> None: + op.drop_table("notice_case_documents") + op.drop_table("notice_case_orders") + op.drop_table("notice_case_hearings") + op.drop_table("notice_case_events") + op.drop_table("notice_cases") diff --git a/alembic/versions/20260617_phase_204a_financial_year_master.py b/alembic/versions/20260617_phase_204a_financial_year_master.py new file mode 100644 index 0000000..bf2f315 --- /dev/null +++ b/alembic/versions/20260617_phase_204a_financial_year_master.py @@ -0,0 +1,89 @@ +"""Phase v2.0.4-A - Financial Year Master and Active FY Context + +Revision ID: 20260617_phase_204a_financial_year +Revises: 20260616_phase_8b_notice_cases +Create Date: 2026-06-17 +""" +from __future__ import annotations + +from datetime import date, datetime, timezone + +from alembic import op +import sqlalchemy as sa + +revision = "20260617_phase_204a_financial_year" +down_revision = "20260616_phase_8b_notice_cases" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "financial_years", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("year_code", sa.String(9), nullable=False), + sa.Column("assessment_year", sa.String(9), nullable=False), + sa.Column("start_date", sa.Date(), nullable=False), + sa.Column("end_date", sa.Date(), nullable=False), + sa.Column("is_current", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("is_locked", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("locked_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("locked_by_user_id", sa.Integer(), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name="fk_financial_years_tenant", ondelete="CASCADE"), + sa.ForeignKeyConstraint(["locked_by_user_id"], ["users.id"], name="fk_financial_years_locked_by", ondelete="SET NULL"), + sa.UniqueConstraint("tenant_id", "year_code", name="uq_financial_year_tenant_code"), + ) + op.create_index("ix_financial_years_tenant_id", "financial_years", ["tenant_id"]) + op.create_index("ix_financial_years_year_code", "financial_years", ["year_code"]) + op.create_index("ix_financial_years_assessment_year", "financial_years", ["assessment_year"]) + op.create_index("ix_financial_years_is_current", "financial_years", ["is_current"]) + op.create_index("ix_financial_years_is_locked", "financial_years", ["is_locked"]) + op.create_index("ix_financial_years_locked_by_user_id", "financial_years", ["locked_by_user_id"]) + + # Seed the default FY for every existing tenant so the selector works immediately + # after upgrade. Further years can be created from System Settings. + conn = op.get_bind() + tenants = conn.execute(sa.text("SELECT id FROM tenants")).fetchall() + now = datetime.now(timezone.utc) + for row in tenants: + tenant_id = row[0] + exists = conn.execute( + sa.text("SELECT id FROM financial_years WHERE tenant_id = :tenant_id AND year_code = :year_code"), + {"tenant_id": tenant_id, "year_code": "2025-26"}, + ).first() + if exists: + continue + conn.execute( + sa.text( + """ + INSERT INTO financial_years + (tenant_id, year_code, assessment_year, start_date, end_date, is_current, is_locked, created_at_utc, updated_at_utc) + VALUES + (:tenant_id, :year_code, :assessment_year, :start_date, :end_date, :is_current, :is_locked, :created_at_utc, :updated_at_utc) + """ + ), + { + "tenant_id": tenant_id, + "year_code": "2025-26", + "assessment_year": "2026-27", + "start_date": date(2025, 4, 1), + "end_date": date(2026, 3, 31), + "is_current": True, + "is_locked": False, + "created_at_utc": now, + "updated_at_utc": now, + }, + ) + + +def downgrade() -> None: + op.drop_index("ix_financial_years_locked_by_user_id", table_name="financial_years") + op.drop_index("ix_financial_years_is_locked", table_name="financial_years") + op.drop_index("ix_financial_years_is_current", table_name="financial_years") + op.drop_index("ix_financial_years_assessment_year", table_name="financial_years") + op.drop_index("ix_financial_years_year_code", table_name="financial_years") + op.drop_index("ix_financial_years_tenant_id", table_name="financial_years") + op.drop_table("financial_years") diff --git a/alembic/versions/20260618_phase_204d_billing_fy_isolation.py b/alembic/versions/20260618_phase_204d_billing_fy_isolation.py new file mode 100644 index 0000000..a26e760 --- /dev/null +++ b/alembic/versions/20260618_phase_204d_billing_fy_isolation.py @@ -0,0 +1,107 @@ +"""Phase v2.0.4-D - Billing financial year isolation + +Revision ID: 20260618_phase_204d_billing_fy +Revises: 20260617_phase_204a_financial_year +Create Date: 2026-06-18 +""" +from __future__ import annotations + +from datetime import date, datetime + +from alembic import op +import sqlalchemy as sa + + +revision = "20260618_phase_204d_billing_fy" +down_revision = "20260617_phase_204a_financial_year" +branch_labels = None +depends_on = None + + +def _fy_label(value) -> str | None: + if value is None: + return None + if isinstance(value, datetime): + value = value.date() + if isinstance(value, str): + try: + value = date.fromisoformat(value[:10]) + except Exception: + return None + if not isinstance(value, date): + return None + start_year = value.year if value.month >= 4 else value.year - 1 + return f"{start_year}-{str(start_year + 1)[-2:]}" + + +def _has_table(conn, table_name: str) -> bool: + return sa.inspect(conn).has_table(table_name) + + +def _has_column(conn, table_name: str, column_name: str) -> bool: + if not _has_table(conn, table_name): + return False + return any(col["name"] == column_name for col in sa.inspect(conn).get_columns(table_name)) + + +def _create_index_if_missing(conn, index_name: str, table_name: str, columns: list[str]) -> None: + if not _has_table(conn, table_name): + return + existing = {idx["name"] for idx in sa.inspect(conn).get_indexes(table_name)} + if index_name not in existing: + op.create_index(index_name, table_name, columns) + + +def _drop_index_if_exists(conn, index_name: str, table_name: str) -> None: + if not _has_table(conn, table_name): + return + existing = {idx["name"] for idx in sa.inspect(conn).get_indexes(table_name)} + if index_name in existing: + op.drop_index(index_name, table_name=table_name) + + +def upgrade() -> None: + conn = op.get_bind() + + if _has_table(conn, "billing_invoice_generation_batches") and not _has_column(conn, "billing_invoice_generation_batches", "financial_year"): + op.add_column("billing_invoice_generation_batches", sa.Column("financial_year", sa.String(length=20), nullable=True)) + rows = conn.execute(sa.text("SELECT id, billing_period_from FROM billing_invoice_generation_batches")).mappings().all() + for row in rows: + fy = _fy_label(row["billing_period_from"]) + if fy: + conn.execute(sa.text("UPDATE billing_invoice_generation_batches SET financial_year = :fy WHERE id = :id"), {"fy": fy, "id": row["id"]}) + _create_index_if_missing(conn, "ix_billing_invoice_generation_batches_financial_year", "billing_invoice_generation_batches", ["financial_year"]) + + if _has_table(conn, "billing_invoices") and not _has_column(conn, "billing_invoices", "financial_year"): + op.add_column("billing_invoices", sa.Column("financial_year", sa.String(length=20), nullable=True)) + rows = conn.execute(sa.text("SELECT id, billing_period_from, invoice_date FROM billing_invoices")).mappings().all() + for row in rows: + fy = _fy_label(row["billing_period_from"]) or _fy_label(row["invoice_date"]) + if fy: + conn.execute(sa.text("UPDATE billing_invoices SET financial_year = :fy WHERE id = :id"), {"fy": fy, "id": row["id"]}) + _create_index_if_missing(conn, "ix_billing_invoices_financial_year", "billing_invoices", ["financial_year"]) + + if _has_table(conn, "billing_payments") and not _has_column(conn, "billing_payments", "financial_year"): + op.add_column("billing_payments", sa.Column("financial_year", sa.String(length=20), nullable=True)) + rows = conn.execute(sa.text("SELECT id, payment_date FROM billing_payments")).mappings().all() + for row in rows: + fy = _fy_label(row["payment_date"]) + if fy: + conn.execute(sa.text("UPDATE billing_payments SET financial_year = :fy WHERE id = :id"), {"fy": fy, "id": row["id"]}) + _create_index_if_missing(conn, "ix_billing_payments_financial_year", "billing_payments", ["financial_year"]) + + +def downgrade() -> None: + conn = op.get_bind() + + if _has_column(conn, "billing_payments", "financial_year"): + _drop_index_if_exists(conn, "ix_billing_payments_financial_year", "billing_payments") + op.drop_column("billing_payments", "financial_year") + + if _has_column(conn, "billing_invoices", "financial_year"): + _drop_index_if_exists(conn, "ix_billing_invoices_financial_year", "billing_invoices") + op.drop_column("billing_invoices", "financial_year") + + if _has_column(conn, "billing_invoice_generation_batches", "financial_year"): + _drop_index_if_exists(conn, "ix_billing_invoice_generation_batches_financial_year", "billing_invoice_generation_batches") + op.drop_column("billing_invoice_generation_batches", "financial_year") diff --git a/alembic/versions/20260619_phase_204e_year_lock_backup.py b/alembic/versions/20260619_phase_204e_year_lock_backup.py new file mode 100644 index 0000000..f0017e6 --- /dev/null +++ b/alembic/versions/20260619_phase_204e_year_lock_backup.py @@ -0,0 +1,51 @@ +"""Phase v2.0.4-E - Year Lock and Backup Export + +Revision ID: 20260619_phase_204e_year_lock_backup +Revises: 20260618_phase_204d_billing_fy +Create Date: 2026-06-19 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "20260619_phase_204e_year_lock_backup" +down_revision = "20260618_phase_204d_billing_fy" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "year_backup_exports", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("financial_year_id", sa.Integer(), nullable=False), + sa.Column("year_code", sa.String(length=9), nullable=False), + sa.Column("assessment_year", sa.String(length=9), nullable=True), + sa.Column("export_status", sa.String(length=30), nullable=False, server_default="completed"), + sa.Column("export_file_path", sa.String(length=1000), nullable=False), + sa.Column("file_size_bytes", sa.Integer(), nullable=False, server_default="0"), + sa.Column("manifest_json", sa.Text(), nullable=True), + sa.Column("generated_by_user_id", sa.Integer(), nullable=True), + sa.Column("generated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["financial_year_id"], ["financial_years.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["generated_by_user_id"], ["users.id"], ondelete="SET NULL"), + ) + op.create_index("ix_year_backup_exports_tenant_id", "year_backup_exports", ["tenant_id"]) + op.create_index("ix_year_backup_exports_financial_year_id", "year_backup_exports", ["financial_year_id"]) + op.create_index("ix_year_backup_exports_year_code", "year_backup_exports", ["year_code"]) + op.create_index("ix_year_backup_exports_assessment_year", "year_backup_exports", ["assessment_year"]) + op.create_index("ix_year_backup_exports_export_status", "year_backup_exports", ["export_status"]) + op.create_index("ix_year_backup_exports_generated_by_user_id", "year_backup_exports", ["generated_by_user_id"]) + + +def downgrade() -> None: + op.drop_index("ix_year_backup_exports_generated_by_user_id", table_name="year_backup_exports") + op.drop_index("ix_year_backup_exports_export_status", table_name="year_backup_exports") + op.drop_index("ix_year_backup_exports_assessment_year", table_name="year_backup_exports") + op.drop_index("ix_year_backup_exports_year_code", table_name="year_backup_exports") + op.drop_index("ix_year_backup_exports_financial_year_id", table_name="year_backup_exports") + op.drop_index("ix_year_backup_exports_tenant_id", table_name="year_backup_exports") + op.drop_table("year_backup_exports") diff --git a/alembic/versions/7f1c9b2d4a10_reconcile_common_tables_and_clients_safely.py b/alembic/versions/7f1c9b2d4a10_reconcile_common_tables_and_clients_safely.py new file mode 100644 index 0000000..0bc7eca --- /dev/null +++ b/alembic/versions/7f1c9b2d4a10_reconcile_common_tables_and_clients_safely.py @@ -0,0 +1,270 @@ + +"""reconcile common tables and clients safely + +Revision ID: 7f1c9b2d4a10 +Revises: 3d5ce4b11767 +Create Date: 2026-04-05 14:30:00.000000 + +NOTE: +- This is a manual repair/reconciliation migration. +- It is intentionally written to CREATE missing tables/indexes only. +- It does NOT drop existing tables. +- It is safe for the current state where revision 3d5ce4b11767 has already been applied. +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "7f1c9b2d4a10" +down_revision: Union[str, None] = "940fb75ddce6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _inspector(): + bind = op.get_bind() + return sa.inspect(bind) + + +def _has_table(table_name: str) -> bool: + return table_name in _inspector().get_table_names() + + +def _has_index(table_name: str, index_name: str) -> bool: + try: + indexes = _inspector().get_indexes(table_name) + except Exception: + return False + return any(ix.get("name") == index_name for ix in indexes) + + +def _create_index_if_missing(table_name: str, index_name: str, columns: list[str], unique: bool = False) -> None: + if not _has_index(table_name, index_name): + op.create_index(index_name, table_name, columns, unique=unique) + + +def upgrade() -> None: + if not _has_table("audit_logs"): + op.create_table( + "audit_logs", + sa.Column("id", sa.Integer(), primary_key=True, nullable=False), + sa.Column("created_at_utc", sa.DateTime(), nullable=False), + sa.Column("actor_user_id", sa.Integer(), nullable=True), + sa.Column("actor_email", sa.String(length=255), nullable=True), + sa.Column("actor_tenant_id", sa.Integer(), nullable=True), + sa.Column("actor_branch_id", sa.Integer(), nullable=True), + sa.Column("action", sa.String(length=120), nullable=False), + sa.Column("entity_type", sa.String(length=120), nullable=False), + sa.Column("entity_id", sa.String(length=120), nullable=True), + sa.Column("entity_name", sa.String(length=255), nullable=True), + sa.Column("status", sa.String(length=30), nullable=False), + sa.Column("target_tenant_id", sa.Integer(), nullable=True), + sa.Column("target_branch_id", sa.Integer(), nullable=True), + sa.Column("ip_address", sa.String(length=100), nullable=True), + sa.Column("user_agent", sa.String(length=500), nullable=True), + sa.Column("details_json", sa.Text(), nullable=False), + sa.ForeignKeyConstraint(["actor_user_id"], ["users.id"], ondelete="SET NULL"), + ) + + _create_index_if_missing("audit_logs", "ix_audit_logs_action", ["action"]) + _create_index_if_missing("audit_logs", "ix_audit_logs_actor_branch_id", ["actor_branch_id"]) + _create_index_if_missing("audit_logs", "ix_audit_logs_actor_tenant_id", ["actor_tenant_id"]) + _create_index_if_missing("audit_logs", "ix_audit_logs_actor_user_id", ["actor_user_id"]) + _create_index_if_missing("audit_logs", "ix_audit_logs_created_at_utc", ["created_at_utc"]) + _create_index_if_missing("audit_logs", "ix_audit_logs_entity_type", ["entity_type"]) + _create_index_if_missing("audit_logs", "ix_audit_logs_status", ["status"]) + _create_index_if_missing("audit_logs", "ix_audit_logs_target_branch_id", ["target_branch_id"]) + _create_index_if_missing("audit_logs", "ix_audit_logs_target_tenant_id", ["target_tenant_id"]) + + if not _has_table("services"): + op.create_table( + "services", + sa.Column("id", sa.Integer(), primary_key=True, nullable=False), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("branch_id", sa.Integer(), nullable=False), + sa.Column("service_code", sa.String(length=50), nullable=False), + sa.Column("service_name", sa.String(length=200), nullable=False), + sa.Column("category", sa.String(length=100), nullable=True), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False), + sa.Column("is_client_requestable", sa.Boolean(), nullable=False), + sa.Column("is_consultant_requestable", sa.Boolean(), nullable=False), + sa.Column("created_by_user_id", sa.Integer(), nullable=True), + sa.Column("updated_by_user_id", sa.Integer(), nullable=True), + sa.Column("created_at_utc", sa.DateTime(), nullable=False), + sa.Column("updated_at_utc", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"]), + sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]), + sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"]), + sa.UniqueConstraint("tenant_id", "branch_id", "service_code", name="uq_services_tenant_branch_code"), + ) + + _create_index_if_missing("services", "ix_services_tenant_id", ["tenant_id"]) + _create_index_if_missing("services", "ix_services_branch_id", ["branch_id"]) + _create_index_if_missing("services", "ix_services_service_code", ["service_code"]) + _create_index_if_missing("services", "ix_services_service_name", ["service_name"]) + + if not _has_table("service_task_templates"): + op.create_table( + "service_task_templates", + sa.Column("id", sa.Integer(), primary_key=True, nullable=False), + sa.Column("service_id", sa.Integer(), nullable=False), + sa.Column("task_name", sa.String(length=200), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("sequence_no", sa.Integer(), nullable=False), + sa.Column("default_role_name", sa.String(length=100), nullable=True), + sa.Column("is_mandatory", sa.Boolean(), nullable=False), + sa.Column("requires_review", sa.Boolean(), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False), + sa.Column("created_by_user_id", sa.Integer(), nullable=True), + sa.Column("updated_by_user_id", sa.Integer(), nullable=True), + sa.Column("created_at_utc", sa.DateTime(), nullable=False), + sa.Column("updated_at_utc", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["service_id"], ["services.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]), + sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"]), + sa.UniqueConstraint("service_id", "sequence_no", name="uq_service_task_templates_sequence"), + ) + + _create_index_if_missing("service_task_templates", "ix_service_task_templates_service_id", ["service_id"]) + + if not _has_table("invite_tokens"): + op.create_table( + "invite_tokens", + sa.Column("id", sa.Integer(), primary_key=True, nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("token_hash", sa.String(length=64), nullable=False), + sa.Column("created_at_utc", sa.DateTime(), nullable=False), + sa.Column("expires_at_utc", sa.DateTime(), nullable=False), + sa.Column("used_at_utc", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.UniqueConstraint("token_hash", name="uq_invite_token_hash"), + ) + + _create_index_if_missing("invite_tokens", "ix_invite_tokens_user_id", ["user_id"]) + _create_index_if_missing("invite_tokens", "ix_invite_tokens_token_hash", ["token_hash"]) + + if not _has_table("password_reset_tokens"): + op.create_table( + "password_reset_tokens", + sa.Column("id", sa.Integer(), primary_key=True, nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("token_hash", sa.String(length=64), nullable=False), + sa.Column("created_at_utc", sa.DateTime(), nullable=False), + sa.Column("expires_at_utc", sa.DateTime(), nullable=False), + sa.Column("used_at_utc", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.UniqueConstraint("token_hash", name="uq_password_reset_token_hash"), + ) + + _create_index_if_missing("password_reset_tokens", "ix_password_reset_tokens_user_id", ["user_id"]) + _create_index_if_missing("password_reset_tokens", "ix_password_reset_tokens_token_hash", ["token_hash"]) + + if not _has_table("clients"): + op.create_table( + "clients", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("branch_id", sa.Integer(), nullable=False), + sa.Column("partner_id", sa.Integer(), nullable=False), + sa.Column("client_code", sa.String(length=50), nullable=False), + sa.Column("client_name", sa.String(length=200), nullable=False), + sa.Column("trade_name", sa.String(length=200), nullable=True), + sa.Column("client_type", sa.String(length=100), nullable=False, server_default="Other"), + sa.Column("pan", sa.String(length=20), nullable=True), + sa.Column("gstin", sa.String(length=20), nullable=True), + sa.Column("tan", sa.String(length=20), nullable=True), + sa.Column("cin_llpin", sa.String(length=30), nullable=True), + sa.Column("msme_no", sa.String(length=50), nullable=True), + sa.Column("iec_code", sa.String(length=30), nullable=True), + sa.Column("contact_person_name", sa.String(length=200), nullable=True), + sa.Column("contact_person_designation", sa.String(length=200), nullable=True), + sa.Column("mobile", sa.String(length=20), nullable=True), + sa.Column("alternate_mobile", sa.String(length=20), nullable=True), + sa.Column("email", sa.String(length=255), nullable=True), + sa.Column("alternate_email", sa.String(length=255), nullable=True), + sa.Column("address_line_1", sa.String(length=255), nullable=True), + sa.Column("address_line_2", sa.String(length=255), nullable=True), + sa.Column("city", sa.String(length=100), nullable=True), + sa.Column("state", sa.String(length=100), nullable=True), + sa.Column("pincode", sa.String(length=20), nullable=True), + sa.Column("country", sa.String(length=100), nullable=True, server_default="India"), + sa.Column("status", sa.String(length=20), nullable=False, server_default="active"), + sa.Column("client_category", sa.String(length=100), nullable=True), + sa.Column("risk_category", sa.String(length=50), nullable=True), + sa.Column("onboarding_date", sa.Date(), nullable=True), + sa.Column("closing_date", sa.Date(), nullable=True), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("gst_applicable", sa.Boolean(), nullable=False, server_default=sa.text("0")), + sa.Column("income_tax_applicable", sa.Boolean(), nullable=False, server_default=sa.text("0")), + sa.Column("tds_applicable", sa.Boolean(), nullable=False, server_default=sa.text("0")), + sa.Column("roc_applicable", sa.Boolean(), nullable=False, server_default=sa.text("0")), + sa.Column("audit_applicable", sa.Boolean(), nullable=False, server_default=sa.text("0")), + sa.Column("pf_applicable", sa.Boolean(), nullable=False, server_default=sa.text("0")), + sa.Column("esi_applicable", sa.Boolean(), nullable=False, server_default=sa.text("0")), + sa.Column("professional_tax_applicable", sa.Boolean(), nullable=False, server_default=sa.text("0")), + sa.Column("payroll_applicable", sa.Boolean(), nullable=False, server_default=sa.text("0")), + sa.Column("msme_applicable", sa.Boolean(), nullable=False, server_default=sa.text("0")), + sa.Column("import_export_applicable", sa.Boolean(), nullable=False, server_default=sa.text("0")), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("1")), + sa.Column("is_archived", sa.Boolean(), nullable=False, server_default=sa.text("0")), + sa.Column("archived_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_by_user_id", sa.Integer(), nullable=True), + sa.Column("updated_by_user_id", sa.Integer(), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"]), + sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]), + sa.ForeignKeyConstraint(["partner_id"], ["users.id"]), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]), + sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"]), + sa.UniqueConstraint("tenant_id", "client_code", name="uq_clients_tenant_code"), + sa.UniqueConstraint("tenant_id", "pan", name="uq_clients_tenant_pan"), + sa.UniqueConstraint("tenant_id", "gstin", name="uq_clients_tenant_gstin"), + ) + + _create_index_if_missing("clients", "ix_clients_tenant_id", ["tenant_id"]) + _create_index_if_missing("clients", "ix_clients_branch_id", ["branch_id"]) + _create_index_if_missing("clients", "ix_clients_partner_id", ["partner_id"]) + _create_index_if_missing("clients", "ix_clients_client_code", ["client_code"]) + _create_index_if_missing("clients", "ix_clients_client_name", ["client_name"]) + _create_index_if_missing("clients", "ix_clients_pan", ["pan"]) + _create_index_if_missing("clients", "ix_clients_gstin", ["gstin"]) + _create_index_if_missing("clients", "ix_clients_status", ["status"]) + + if not _has_table("client_audit_logs"): + op.create_table( + "client_audit_logs", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False), + sa.Column("client_id", sa.Integer(), nullable=False), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("branch_id", sa.Integer(), nullable=False), + sa.Column("actor_user_id", sa.Integer(), nullable=True), + sa.Column("action", sa.String(length=50), nullable=False), + sa.Column("summary", sa.String(length=255), nullable=False), + sa.Column("payload_json", sa.JSON(), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["client_id"], ["clients.id"]), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"]), + sa.ForeignKeyConstraint(["branch_id"], ["branches.id"]), + sa.ForeignKeyConstraint(["actor_user_id"], ["users.id"]), + ) + + _create_index_if_missing("client_audit_logs", "ix_client_audit_logs_client_id", ["client_id"]) + _create_index_if_missing("client_audit_logs", "ix_client_audit_logs_tenant_id", ["tenant_id"]) + _create_index_if_missing("client_audit_logs", "ix_client_audit_logs_branch_id", ["branch_id"]) + _create_index_if_missing("client_audit_logs", "ix_client_audit_logs_action", ["action"]) + + +def downgrade() -> None: + # Intentionally conservative: + # only remove tables introduced by this repair migration that are not baseline-owned. + # We do NOT drop services/auth-flow/audit tables here to avoid accidental data loss. + if _has_table("client_audit_logs"): + op.drop_table("client_audit_logs") + if _has_table("clients"): + op.drop_table("clients") diff --git a/alembic/versions/940fb75ddce6_0001_baseline_common.py b/alembic/versions/940fb75ddce6_0001_baseline_common.py new file mode 100644 index 0000000..c124ff0 --- /dev/null +++ b/alembic/versions/940fb75ddce6_0001_baseline_common.py @@ -0,0 +1,251 @@ +"""0001 baseline common + +Revision ID: 940fb75ddce6 +Revises: +Create Date: 2026-03-15 19:52:18.618166 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = '940fb75ddce6' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('login_attempts', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('key', sa.String(length=255), nullable=False), + sa.Column('attempts', sa.Integer(), nullable=False), + sa.Column('locked_until_utc', sa.DateTime(), nullable=True), + sa.Column('updated_at_utc', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('key', name='uq_login_attempt_key') + ) + with op.batch_alter_table('login_attempts', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_login_attempts_key'), ['key'], unique=False) + + op.create_table('permissions', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('code', sa.String(length=150), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('code', name='uq_permission_code') + ) + with op.batch_alter_table('permissions', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_permissions_code'), ['code'], unique=False) + + op.create_table('roles', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name', name='uq_role_name') + ) + with op.batch_alter_table('roles', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_roles_name'), ['name'], unique=False) + + op.create_table('tenants', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('code', sa.String(length=50), nullable=False), + sa.Column('name', sa.String(length=200), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('default_timezone', sa.String(length=64), nullable=False), + sa.Column('default_session_duration_minutes', sa.Integer(), nullable=False), + sa.Column('default_otp_required_roles_csv', sa.String(length=200), nullable=False), + sa.Column('default_storage_mode', sa.String(length=20), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('tenants', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_tenants_code'), ['code'], unique=True) + + op.create_table('branches', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('code', sa.String(length=50), nullable=False), + sa.Column('name', sa.String(length=200), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('timezone', sa.String(length=64), nullable=False), + sa.Column('office_start_time', sa.Time(), nullable=True), + sa.Column('office_end_time', sa.Time(), nullable=True), + sa.Column('allow_login', sa.Boolean(), nullable=False), + sa.Column('allow_new_assignments', sa.Boolean(), nullable=False), + sa.Column('is_head_office', sa.Boolean(), nullable=False), + sa.Column('smtp_host', sa.String(length=255), nullable=True), + sa.Column('smtp_port', sa.Integer(), nullable=True), + sa.Column('smtp_username', sa.String(length=255), nullable=True), + sa.Column('smtp_password', sa.String(length=255), nullable=True), + sa.Column('smtp_use_tls', sa.Boolean(), nullable=False), + sa.Column('local_storage_path', sa.String(length=500), nullable=True), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('tenant_id', 'code', name='uq_branch_tenant_code') + ) + with op.batch_alter_table('branches', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_branches_code'), ['code'], unique=False) + batch_op.create_index(batch_op.f('ix_branches_tenant_id'), ['tenant_id'], unique=False) + + op.create_table('role_permissions', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('role_id', sa.Integer(), nullable=False), + sa.Column('permission_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['permission_id'], ['permissions.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('role_id', 'permission_id', name='uq_role_perm') + ) + with op.batch_alter_table('role_permissions', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_role_permissions_permission_id'), ['permission_id'], unique=False) + batch_op.create_index(batch_op.f('ix_role_permissions_role_id'), ['role_id'], unique=False) + + op.create_table('branch_settings', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('branch_id', sa.Integer(), nullable=False), + sa.Column('address_line1', sa.String(length=255), nullable=True), + sa.Column('address_line2', sa.String(length=255), nullable=True), + sa.Column('city', sa.String(length=100), nullable=True), + sa.Column('state', sa.String(length=100), nullable=True), + sa.Column('pin_code', sa.String(length=10), nullable=True), + sa.Column('gstin', sa.String(length=20), nullable=True), + sa.Column('pan', sa.String(length=10), nullable=True), + sa.Column('letterhead_logo_path', sa.String(length=500), nullable=True), + sa.Column('letterhead_signature_path', sa.String(length=500), nullable=True), + sa.Column('letterhead_stamp_path', sa.String(length=500), nullable=True), + sa.Column('geo_address', sa.String(length=500), nullable=True), + sa.Column('latitude', sa.Float(), nullable=True), + sa.Column('longitude', sa.Float(), nullable=True), + sa.Column('working_days_csv', sa.String(length=50), nullable=False), + sa.Column('holidays_json', sa.Text(), nullable=False), + sa.Column('timezone_locked', sa.Boolean(), nullable=False), + sa.Column('email_from_name', sa.String(length=200), nullable=True), + sa.Column('email_from_email', sa.String(length=255), nullable=True), + sa.Column('email_reply_to', sa.String(length=255), nullable=True), + sa.Column('default_cc_csv', sa.String(length=500), nullable=True), + sa.Column('default_bcc_csv', sa.String(length=500), nullable=True), + sa.Column('email_signature_html', sa.Text(), nullable=True), + sa.Column('storage_mode', sa.String(length=20), nullable=False), + sa.Column('folder_template', sa.String(length=500), nullable=False), + sa.Column('max_file_mb', sa.Integer(), nullable=False), + sa.Column('allowed_ext_csv', sa.String(length=500), nullable=False), + sa.Column('retention_years', sa.Integer(), nullable=False), + sa.Column('otp_required_roles_csv', sa.String(length=200), nullable=False), + sa.Column('session_duration_minutes', sa.Integer(), nullable=False), + sa.Column('lockout_attempts', sa.Integer(), nullable=False), + sa.Column('lockout_minutes', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['branch_id'], ['branches.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('branch_id', name='uq_branch_settings_branch') + ) + with op.batch_alter_table('branch_settings', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_branch_settings_branch_id'), ['branch_id'], unique=False) + + op.create_table('users', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('email', sa.String(length=255), nullable=False), + sa.Column('full_name', sa.String(length=255), nullable=False), + sa.Column('password_hash', sa.String(length=255), nullable=False), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('branch_id', sa.Integer(), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('allow_login', sa.Boolean(), nullable=False), + sa.Column('is_locked', sa.Boolean(), nullable=False), + sa.Column('locked_at_utc', sa.DateTime(), nullable=True), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('must_change_password', sa.Boolean(), nullable=False), + sa.Column('password_changed_at_utc', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['branch_id'], ['branches.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email', name='uq_user_email') + ) + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_users_branch_id'), ['branch_id'], unique=False) + batch_op.create_index(batch_op.f('ix_users_email'), ['email'], unique=False) + batch_op.create_index(batch_op.f('ix_users_tenant_id'), ['tenant_id'], unique=False) + + op.create_table('refresh_tokens', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('token_hash', sa.String(length=64), nullable=False), + sa.Column('created_at_utc', sa.DateTime(), nullable=False), + sa.Column('expires_at_utc', sa.DateTime(), nullable=False), + sa.Column('revoked', sa.Boolean(), nullable=False), + sa.Column('rotated_from_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('token_hash', name='uq_refresh_token_hash') + ) + with op.batch_alter_table('refresh_tokens', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_refresh_tokens_token_hash'), ['token_hash'], unique=False) + batch_op.create_index(batch_op.f('ix_refresh_tokens_user_id'), ['user_id'], unique=False) + + op.create_table('user_roles', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('role_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id', 'role_id', name='uq_user_role') + ) + with op.batch_alter_table('user_roles', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_user_roles_role_id'), ['role_id'], unique=False) + batch_op.create_index(batch_op.f('ix_user_roles_user_id'), ['user_id'], unique=False) + + # ### end Alembic commands ### + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user_roles', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_user_roles_user_id')) + batch_op.drop_index(batch_op.f('ix_user_roles_role_id')) + + op.drop_table('user_roles') + with op.batch_alter_table('refresh_tokens', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_refresh_tokens_user_id')) + batch_op.drop_index(batch_op.f('ix_refresh_tokens_token_hash')) + + op.drop_table('refresh_tokens') + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_users_tenant_id')) + batch_op.drop_index(batch_op.f('ix_users_email')) + batch_op.drop_index(batch_op.f('ix_users_branch_id')) + + op.drop_table('users') + with op.batch_alter_table('branch_settings', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_branch_settings_branch_id')) + + op.drop_table('branch_settings') + with op.batch_alter_table('role_permissions', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_role_permissions_role_id')) + batch_op.drop_index(batch_op.f('ix_role_permissions_permission_id')) + + op.drop_table('role_permissions') + with op.batch_alter_table('branches', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_branches_tenant_id')) + batch_op.drop_index(batch_op.f('ix_branches_code')) + + op.drop_table('branches') + with op.batch_alter_table('tenants', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_tenants_code')) + + op.drop_table('tenants') + with op.batch_alter_table('roles', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_roles_name')) + + op.drop_table('roles') + with op.batch_alter_table('permissions', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_permissions_code')) + + op.drop_table('permissions') + with op.batch_alter_table('login_attempts', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_login_attempts_key')) + + op.drop_table('login_attempts') + # ### end Alembic commands ### diff --git a/alembic/versions/af48d99d7321_merge_ds5_and_ds6_document_heads.py b/alembic/versions/af48d99d7321_merge_ds5_and_ds6_document_heads.py new file mode 100644 index 0000000..4153da2 --- /dev/null +++ b/alembic/versions/af48d99d7321_merge_ds5_and_ds6_document_heads.py @@ -0,0 +1,23 @@ +"""merge ds5 and ds6 document heads + +Revision ID: af48d99d7321 +Revises: 20260518_ds6_permanent_client_document_vault, 20260528_phase_ds5_secure_download_streaming +Create Date: 2026-05-18 20:41:43.386276 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'af48d99d7321' +down_revision: Union[str, None] = ('20260518_ds6_permanent_client_document_vault', '20260528_phase_ds5_secure_download_streaming') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +def upgrade() -> None: + pass + +def downgrade() -> None: + pass diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/api.py b/app/core/api.py new file mode 100644 index 0000000..9c5d2a7 --- /dev/null +++ b/app/core/api.py @@ -0,0 +1,16 @@ +from fastapi import APIRouter + +from app.modules.clients.api import router as clients_api +from app.modules.core.iam.api import router as users_api +from app.modules.core.iam.auth_api import router as auth_api +from app.modules.core.rbac.api import router as rbac_api +from app.modules.core.tenancy.api import router as tenancy_api +from app.modules.system.health.api import router as health_api + +api_router = APIRouter() +api_router.include_router(health_api) +api_router.include_router(tenancy_api) +api_router.include_router(rbac_api) +api_router.include_router(auth_api) +api_router.include_router(users_api) +api_router.include_router(clients_api) diff --git a/app/core/db/__init__.py b/app/core/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/db/common.py b/app/core/db/common.py new file mode 100644 index 0000000..917ca56 --- /dev/null +++ b/app/core/db/common.py @@ -0,0 +1,9 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, DeclarativeBase +from app.core.db.urls import get_common_db_url + +class CommonBase(DeclarativeBase): + pass + +CommonEngine = create_engine(get_common_db_url(), pool_pre_ping=True, future=True) +CommonSessionLocal = sessionmaker(bind=CommonEngine, autocommit=False, autoflush=False, future=True) diff --git a/app/core/db/deps.py b/app/core/db/deps.py new file mode 100644 index 0000000..e4940d9 --- /dev/null +++ b/app/core/db/deps.py @@ -0,0 +1,10 @@ +from typing import Generator +from sqlalchemy.orm import Session +from app.core.db.common import CommonSessionLocal + +def get_common_db() -> Generator[Session, None, None]: + db = CommonSessionLocal() + try: + yield db + finally: + db.close() diff --git a/app/core/db/urls.py b/app/core/db/urls.py new file mode 100644 index 0000000..f09884a --- /dev/null +++ b/app/core/db/urls.py @@ -0,0 +1,30 @@ +import os + +from sqlalchemy.engine import URL + +from app.core.settings import get_settings + + +def sqlite_url(path: str) -> str: + parent = os.path.dirname(path) + if parent: + os.makedirs(parent, exist_ok=True) + return f"sqlite+pysqlite:///{path}" + + +def postgres_url(user: str, password: str, host: str, port: int, db: str) -> str: + return URL.create( + drivername="postgresql+psycopg", + username=user, + password=password, + host=host, + port=port, + database=db, + ).render_as_string(hide_password=False) + + +def get_common_db_url() -> str: + s = get_settings() + if s.DB_BACKEND.lower() == "sqlite": + return sqlite_url(s.SQLITE_COMMON_PATH) + return postgres_url(s.PG_USER, s.PG_PASSWORD, s.PG_HOST, s.PG_PORT, s.PG_DB_COMMON) diff --git a/app/core/middleware/__init__.py b/app/core/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/middleware/context.py b/app/core/middleware/context.py new file mode 100644 index 0000000..71cfabd --- /dev/null +++ b/app/core/middleware/context.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +from ipaddress import ip_address, ip_network + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.types import ASGIApp + +from app.core.settings import get_settings + + +_CONTEXT_SECRET_HEADER = "X-AuditFirm-Context-Secret" +_TENANT_HEADER = "X-Tenant-Code" +_BRANCH_HEADER = "X-Branch-Code" +_YEAR_HEADER = "X-Year-Code" + + +def _csv_values(value: str | None) -> list[str]: + return [item.strip() for item in (value or "").split(",") if item.strip()] + + +def _safe_env(value: str | None) -> str: + return (value or "").strip().lower() + + +def _host_matches_trusted_entry(client_host: str, trusted_entry: str) -> bool: + """Return True when client_host matches a trusted host/IP/CIDR entry. + + Deliberately does not support '*' wildcard. For Docker/Coolify internal + networks, use an explicit CIDR such as 172.16.0.0/12. + """ + client_host = (client_host or "").strip().lower() + trusted_entry = (trusted_entry or "").strip().lower() + if not client_host or not trusted_entry: + return False + + if client_host == trusted_entry: + return True + + try: + client_ip = ip_address(client_host) + except ValueError: + return False + + try: + if "/" in trusted_entry: + return client_ip in ip_network(trusted_entry, strict=False) + return client_ip == ip_address(trusted_entry) + except ValueError: + return False + + +def _normalise_session_int(value): + if value in (None, "", 0, "0"): + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +class ContextResolveMiddleware(BaseHTTPMiddleware): + def __init__(self, app: ASGIApp) -> None: + super().__init__(app) + self.s = get_settings() + + def _context_headers_are_trusted(self, request: Request) -> bool: + """Permit context headers only from trusted internal callers. + + Public users must not be able to switch tenant/branch/FY by adding + X-Tenant-Code, X-Branch-Code or X-Year-Code headers. The production-safe + default is TRUST_CONTEXT_HEADERS=false. + """ + if not bool(getattr(self.s, "TRUST_CONTEXT_HEADERS", False)): + return False + + required_secret = (getattr(self.s, "CONTEXT_HEADER_SECRET", "") or "").strip() + if required_secret: + supplied_secret = (request.headers.get(_CONTEXT_SECRET_HEADER) or "").strip() + if supplied_secret != required_secret: + return False + elif _safe_env(getattr(self.s, "ENV", "")) in {"prod", "production"}: + return False + + client_host = request.client.host if request.client else "" + trusted_entries = _csv_values(getattr(self.s, "TRUST_CONTEXT_HEADER_HOSTS", "")) + return any(_host_matches_trusted_entry(client_host, item) for item in trusted_entries) + + async def dispatch(self, request: Request, call_next): + # Trusted production context priority: + # 1) Authenticated UI session selected tenant/branch/FY. + # 2) Domain resolver mapping for pre-login/domain-routed requests. + # 3) Trusted internal headers only when explicitly enabled with secret/host. + # 4) Application defaults. + session = request.scope.get("session") or {} + trust_headers = self._context_headers_are_trusted(request) + + session_tenant_id = _normalise_session_int(session.get("active_tenant_id") or session.get("tenant_id")) + session_branch_id = _normalise_session_int(session.get("active_branch_id") or session.get("branch_id")) + + session_tenant_code = (session.get("active_tenant_code") or session.get("tenant_code") or "").strip() or None + session_branch_code = (session.get("active_branch_code") or session.get("branch_code") or "").strip() or None + + domain_tenant_code = getattr(request.state, "domain_tenant_code", None) + domain_branch_code = getattr(request.state, "domain_branch_code", None) + + tenant_code = ( + session_tenant_code + or domain_tenant_code + or (request.headers.get(_TENANT_HEADER) if trust_headers else None) + or self.s.DEFAULT_TENANT_CODE + ) + + branch_code = ( + session_branch_code + or domain_branch_code + or (request.headers.get(_BRANCH_HEADER) if trust_headers else None) + or self.s.DEFAULT_BRANCH_CODE + ) + + year_code = ( + session.get("active_financial_year") + or (request.headers.get(_YEAR_HEADER) if trust_headers else None) + or self.s.DEFAULT_YEAR_CODE + ) + + request.state.active_tenant_id = session_tenant_id + request.state.active_branch_id = session_branch_id + request.state.tenant_code = tenant_code + request.state.branch_code = branch_code + request.state.year_code = year_code + request.state.context_headers_trusted = trust_headers + return await call_next(request) diff --git a/app/core/middleware/domain_resolver.py b/app/core/middleware/domain_resolver.py new file mode 100644 index 0000000..a620aed --- /dev/null +++ b/app/core/middleware/domain_resolver.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.types import ASGIApp + +from app.core.db.common import CommonSessionLocal +from app.modules.domain_management.services import normalize_request_host, resolve_domain_context + + +class DomainResolverMiddleware(BaseHTTPMiddleware): + """Resolve request host to platform / tenant / consultant context. + + Phase 7T.2 is intentionally read-only: + - It does not redirect users. + - It does not change database records. + - It does not override logged-in user permissions. + - It only exposes a trusted runtime context on request.state. + + Later phases use this context for branding, marketplace mode, tenant subdomains, + consultant domains, and custom domain verification. + """ + + def __init__(self, app: ASGIApp) -> None: + super().__init__(app) + + async def dispatch(self, request: Request, call_next): + host_header = request.headers.get("x-forwarded-host") or request.headers.get("host") + host = normalize_request_host(host_header) + + # Safe defaults; every template/route can read these without checking existence. + request.state.request_host = host + request.state.domain_resolved = False + request.state.domain_mapping_id = None + request.state.domain_name = host + request.state.domain_type = None + request.state.domain_tenant_id = None + request.state.domain_tenant_code = None + request.state.domain_branch_id = None + request.state.domain_branch_code = None + request.state.domain_consultant_id = None + request.state.domain_parent_tenant_id = None + request.state.domain_is_verified = False + request.state.domain_status = None + request.state.domain_context = { + "is_resolved": False, + "host": host, + "mapping_id": None, + "domain_name": host, + "domain_type": None, + "tenant_id": None, + "tenant_code": None, + "branch_id": None, + "branch_code": None, + "consultant_id": None, + "parent_tenant_id": None, + "is_verified": False, + "status": None, + } + + # Static files and empty/invalid host can proceed without DB lookup. + if host and not request.url.path.startswith("/static/"): + db = CommonSessionLocal() + try: + resolved = resolve_domain_context(db, host) + if resolved.is_resolved: + request.state.domain_resolved = True + request.state.domain_mapping_id = resolved.mapping_id + request.state.domain_name = resolved.domain_name + request.state.domain_type = resolved.domain_type + request.state.domain_tenant_id = resolved.tenant_id + request.state.domain_tenant_code = resolved.tenant_code + request.state.domain_branch_id = resolved.branch_id + request.state.domain_branch_code = resolved.branch_code + request.state.domain_consultant_id = resolved.consultant_id + request.state.domain_parent_tenant_id = resolved.parent_tenant_id + request.state.domain_is_verified = resolved.is_verified + request.state.domain_status = resolved.status + request.state.domain_context = { + "is_resolved": True, + "host": resolved.host, + "mapping_id": resolved.mapping_id, + "domain_name": resolved.domain_name, + "domain_type": resolved.domain_type, + "tenant_id": resolved.tenant_id, + "tenant_code": resolved.tenant_code, + "branch_id": resolved.branch_id, + "branch_code": resolved.branch_code, + "consultant_id": resolved.consultant_id, + "parent_tenant_id": resolved.parent_tenant_id, + "is_verified": resolved.is_verified, + "status": resolved.status, + } + except Exception: + # Domain resolution must never take the ERP down. If the domain table is + # missing during deployment or DB is temporarily unavailable, continue + # with the normal default context. + pass + finally: + db.close() + + response = await call_next(request) + if getattr(request.state, "domain_resolved", False): + response.headers["X-AuditFirm-Domain-Resolved"] = "1" + response.headers["X-AuditFirm-Domain-Type"] = str(getattr(request.state, "domain_type", "") or "") + return response diff --git a/app/core/middleware/security_headers.py b/app/core/middleware/security_headers.py new file mode 100644 index 0000000..23829ce --- /dev/null +++ b/app/core/middleware/security_headers.py @@ -0,0 +1,21 @@ +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request + +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + resp = await call_next(request) + resp.headers["X-Content-Type-Options"] = "nosniff" + resp.headers["X-Frame-Options"] = "DENY" + resp.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + resp.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()" + + # CSP: allow Tailwind CDN only + resp.headers["Content-Security-Policy"] = ( + "default-src 'self'; " + "style-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com; " + "script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com; " + "img-src 'self' data:; " + "connect-src 'self'; " + "frame-ancestors 'none';" + ) + return resp diff --git a/app/core/security/__init__.py b/app/core/security/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/security/csrf.py b/app/core/security/csrf.py new file mode 100644 index 0000000..8021816 --- /dev/null +++ b/app/core/security/csrf.py @@ -0,0 +1,16 @@ +import secrets +from fastapi import Request + +CSRF_KEY = "csrf_token" + +def get_or_create_csrf_token(request: Request) -> str: + token = request.session.get(CSRF_KEY) + if not token: + token = secrets.token_urlsafe(32) + request.session[CSRF_KEY] = token + return token + +def validate_csrf(request: Request, form_token: str | None) -> None: + token = request.session.get(CSRF_KEY) + if not token or not form_token or token != form_token: + raise PermissionError("CSRF validation failed") diff --git a/app/core/security/jwt_auth.py b/app/core/security/jwt_auth.py new file mode 100644 index 0000000..7f5ac27 --- /dev/null +++ b/app/core/security/jwt_auth.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from fastapi import Depends +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.orm import Session +from sqlalchemy import select + +from app.core.db.deps import get_common_db +from app.core.security.jwt_tokens import decode_token +from app.modules.core.iam.models import User + +bearer = HTTPBearer(auto_error=False) + +def get_current_user_jwt( + creds: HTTPAuthorizationCredentials | None = Depends(bearer), + db: Session = Depends(get_common_db), +) -> User | None: + if not creds or not creds.credentials: + return None + data = decode_token(creds.credentials) + if data.get("typ") != "access": + return None + + user_id = int(data.get("sub", 0) or 0) + if not user_id: + return None + user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none() + if not user or not user.is_active: + return None + return user + +def require_jwt_user(user: User | None = Depends(get_current_user_jwt)) -> User: + if not user: + raise PermissionError("Not authenticated (JWT)") + return user diff --git a/app/core/security/jwt_tokens.py b/app/core/security/jwt_tokens.py new file mode 100644 index 0000000..ae90df1 --- /dev/null +++ b/app/core/security/jwt_tokens.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Any + +import jwt +from jwt import PyJWTError + +from app.core.settings import get_settings + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + +def encode_access_token(payload: dict[str, Any], expires_minutes: int) -> str: + s = get_settings() + now = utcnow() + exp = now + timedelta(minutes=expires_minutes) + token_payload = { + **payload, + "iss": s.JWT_ISSUER, + "aud": s.JWT_AUDIENCE, + "iat": int(now.timestamp()), + "exp": int(exp.timestamp()), + "typ": "access", + } + return jwt.encode(token_payload, s.SECRET_KEY, algorithm="HS256") + +def decode_token(token: str) -> dict[str, Any]: + s = get_settings() + try: + data = jwt.decode( + token, + s.SECRET_KEY, + algorithms=["HS256"], + audience=s.JWT_AUDIENCE, + issuer=s.JWT_ISSUER, + options={"require": ["exp", "iat", "iss", "aud"]}, + ) + return data + except PyJWTError as e: + raise PermissionError("Invalid token") from e diff --git a/app/core/security/otp.py b/app/core/security/otp.py new file mode 100644 index 0000000..a9084ea --- /dev/null +++ b/app/core/security/otp.py @@ -0,0 +1,23 @@ +import secrets +from fastapi import Request + +OTP_CODE_KEY = "otp_code" +OTP_VERIFIED_KEY = "otp_verified" + +def start_otp(request: Request) -> str: + # 6-digit numeric code + code = str(secrets.randbelow(900000) + 100000) + request.session[OTP_CODE_KEY] = code + request.session[OTP_VERIFIED_KEY] = False + return code + +def verify_otp(request: Request, code: str) -> bool: + expected = request.session.get(OTP_CODE_KEY) + if expected and code and code.strip() == expected: + request.session[OTP_VERIFIED_KEY] = True + request.session.pop(OTP_CODE_KEY, None) + return True + return False + +def is_otp_verified(request: Request) -> bool: + return bool(request.session.get(OTP_VERIFIED_KEY)) diff --git a/app/core/security/passwords.py b/app/core/security/passwords.py new file mode 100644 index 0000000..48dceea --- /dev/null +++ b/app/core/security/passwords.py @@ -0,0 +1,8 @@ +from passlib.context import CryptContext +_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto") + +def hash_password(p: str) -> str: + return _pwd.hash(p) + +def verify_password(p: str, h: str) -> bool: + return _pwd.verify(p, h) diff --git a/app/core/security/session_auth.py b/app/core/security/session_auth.py new file mode 100644 index 0000000..0b943e7 --- /dev/null +++ b/app/core/security/session_auth.py @@ -0,0 +1,52 @@ +from __future__ import annotations +from datetime import datetime, timedelta, timezone +from fastapi import Request, Depends +from sqlalchemy.orm import Session +from sqlalchemy import select + +from app.core.db.deps import get_common_db +from app.modules.core.iam.models import User +from app.modules.core.tenancy.models import Branch +from app.modules.core.tenancy.settings_models import BranchSettings + +SESSION_USER_ID_KEY = "user_id" +SESSION_LOGIN_AT_KEY = "login_at" + +def _now_utc() -> datetime: + return datetime.now(timezone.utc) + +def get_current_user(request: Request, db: Session = Depends(get_common_db)) -> User | None: + user_id = request.session.get(SESSION_USER_ID_KEY) + if not user_id: + return None + + user = db.execute(select(User).where(User.id == int(user_id))).scalar_one_or_none() + if not user or not user.is_active or not getattr(user, "allow_login", True) or getattr(user, "is_locked", False) or getattr(user, "deleted_at", None) is not None: + return None + + # Enforce session duration from BranchSettings + login_at = request.session.get(SESSION_LOGIN_AT_KEY) + if login_at: + try: + login_at_dt = datetime.fromisoformat(login_at) + except Exception: + login_at_dt = None + else: + login_at_dt = None + + bs = db.execute(select(BranchSettings).where(BranchSettings.branch_id == user.branch_id)).scalar_one_or_none() + max_minutes = bs.session_duration_minutes if bs else 480 + + if login_at_dt: + if _now_utc() - login_at_dt > timedelta(minutes=max_minutes): + # expire session + request.session.pop(SESSION_USER_ID_KEY, None) + request.session.pop(SESSION_LOGIN_AT_KEY, None) + return None + + return user + +def require_login(user: User | None = Depends(get_current_user)) -> User: + if not user: + raise PermissionError("Not authenticated") + return user diff --git a/app/core/settings.py b/app/core/settings.py new file mode 100644 index 0000000..58a25f6 --- /dev/null +++ b/app/core/settings.py @@ -0,0 +1,76 @@ +from functools import lru_cache +from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic import Field + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore" + ) + + APP_NAME: str = "Audit_Firm_v2.0.3.6" + ENV: str = "dev" + DEBUG: bool = True + SECRET_KEY: str = "change-me-to-a-long-random-string" + + COOKIE_SECURE: bool = False + COOKIE_SAMESITE: str = "lax" + COOKIE_SESSION_NAME: str = "af2sid" + + DB_BACKEND: str = Field(default="sqlite", description="sqlite|postgres") + + SQLITE_COMMON_PATH: str = "./data/common.db" + + PG_HOST: str = "127.0.0.1" + PG_PORT: int = 5432 + PG_USER: str = "postgres" + PG_PASSWORD: str = "postgres" + PG_DB_COMMON: str = "audit_common" + + DEFAULT_TENANT_CODE: str = "default" + DEFAULT_BRANCH_CODE: str = "main" + DEFAULT_YEAR_CODE: str = "2025-26" + DEFAULT_TIMEZONE: str = "Asia/Kolkata" + + # Public base URL used for email links such as invite and password reset. + # In Coolify production set this to https://your-erp-domain. + ERP_PUBLIC_BASE_URL: str = "http://localhost:8000" + + # Print OTP to server logs only in local/dev troubleshooting. Keep false in UAT/production. + DEV_AUTH_OTP_PRINT: bool = False + + # Context headers are disabled by default for public deployments. + # When disabled, browser/client supplied X-Tenant-Code, X-Branch-Code, + # and X-Year-Code are ignored. Enable only for trusted internal runners + # or reverse proxies that also restrict/strip external request headers. + TRUST_CONTEXT_HEADERS: bool = False + TRUST_CONTEXT_HEADER_HOSTS: str = "127.0.0.1,localhost,::1" + CONTEXT_HEADER_SECRET: str = "" + + # JWT Configuration + JWT_ISSUER: str = "Audit_Firm_v2.0.3.6" + JWT_AUDIENCE: str = "audit_firm_clients" + JWT_ACCESS_MINUTES: int = 15 + JWT_REFRESH_DAYS: int = 30 + + # Bootstrap Admin + BOOTSTRAP_ADMIN_EMAIL: str = "admin@auditfirm.local" + BOOTSTRAP_ADMIN_PASSWORD: str = "ChangeMe@123" + + INVITE_TOKEN_HOURS: int = 72 + PASSWORD_RESET_HOURS: int = 2 + PASSWORD_MIN_LENGTH: int = 8 + + +@lru_cache +def get_settings() -> Settings: + s = Settings() + + # Normalize cookie values + s.COOKIE_SAMESITE = (s.COOKIE_SAMESITE or "lax").lower() + if s.COOKIE_SAMESITE not in {"lax", "strict", "none"}: + s.COOKIE_SAMESITE = "lax" + + return s \ No newline at end of file diff --git a/app/core/startup.py b/app/core/startup.py new file mode 100644 index 0000000..7b2e5cc --- /dev/null +++ b/app/core/startup.py @@ -0,0 +1,951 @@ +from __future__ import annotations + +from fastapi import FastAPI +from datetime import date, datetime, timezone + +from sqlalchemy import inspect, select, text + +from app.core.db.common import CommonBase, CommonEngine, CommonSessionLocal +from app.core.security.passwords import hash_password +from app.core.settings import get_settings +from app.modules.core.iam.models import User +from app.modules.core.iam.password_flows_models import InviteToken, PasswordResetToken +from app.modules.core.audit.models import AuditLog +from app.modules.core.rbac.models import Permission, Role, RolePermission, UserRole +from app.modules.core.rbac.permissions_registry import PERMISSIONS +from app.modules.core.tenancy.models import Branch, FinancialYear, Tenant +from app.modules.core.tenancy.settings_models import BranchSettings +from app.modules.employees.models import Employee, EmployeeAttendance, EmployeeRegistrationRequest, EmployeeLeaveType, EmployeeLeaveBalance, EmployeeLeaveRequest, EmployeeDocumentType, EmployeeDocument, EmployeeOnboardingChecklistItem, EmployeeOnboardingTask, EmployeeOffboardingRequest, EmployeeOffboardingTask, EmployeeSalaryStructure, EmployeePayrollRun, EmployeePayslip +from app.modules.consultants.models import ClientConsultantLink, ConsultantManagedClient, ConsultantProfile, ConsultantWorkspace, ConsultantServiceRequest +from app.modules.services.models import FirmServiceSelection, FirmServiceTaskTemplate, ServiceCatalogue, ServiceCategory, FirmTaskDocumentRequirement, FirmTaskDocumentTemplate +from app.modules.billing.models import BillingSettings, BillingInvoice, BillingInvoiceLine, BillingFeeGroup, BillingFeeGroupService +from app.modules.platform_billing.models import PlatformBillingAccount, PlatformInvoice, PlatformInvoiceLine, PlatformPayment, PlatformPlan, PlatformPlanFeature, PlatformSubscription +from app.modules.marketplace.models import MarketplaceLead, MarketplaceLeadAssignment +from app.modules.documents.models import EngagementDocument, EngagementDocumentVersion, DocumentAccessLog +from app.modules.alerts.models import UserAlert +from app.modules.notice_cases.models import NoticeCase, NoticeCaseEvent, NoticeCaseHearing, NoticeCaseOrder, NoticeCaseDocument +from app.modules.notifications.automation import start_notification_scheduler + +DEFAULT_ROLES = [ + "System Admin", + "Firm Admin", + "Partner", + "Branch Manager", + "Staff", + "Client", + "Consultant", +] + +LEGACY_ROLE_RENAMES = { + "SystemAdmin": "System Admin", + "Manager": "Branch Manager", +} + +DEFAULT_PERMISSIONS = list(PERMISSIONS.items()) + + +def _ensure_user_lifecycle_columns() -> None: + inspector = inspect(CommonEngine) + existing = {c["name"] for c in inspector.get_columns("users")} if "users" in inspector.get_table_names() else set() + dialect = CommonEngine.dialect.name + ddl_map = { + "allow_login": "BOOLEAN DEFAULT TRUE", + "is_locked": "BOOLEAN DEFAULT FALSE", + "locked_at_utc": "TIMESTAMP NULL", + "deleted_at": "TIMESTAMP NULL", + "must_change_password": "BOOLEAN DEFAULT FALSE", + "password_changed_at_utc": "TIMESTAMP NULL", + } + for col, ddl in ddl_map.items(): + if col in existing: + continue + with CommonEngine.begin() as conn: + conn.execute(text(f"ALTER TABLE users ADD COLUMN {col} {ddl}")) + if dialect == "postgres" and col in {"allow_login", "is_locked"}: + default_value = "TRUE" if col == "allow_login" else "FALSE" + conn.execute(text(f"UPDATE users SET {col} = {default_value} WHERE {col} IS NULL")) + + +ROLE_PERMISSION_MAP = { + "System Admin": [ + "system.settings.view", + "system.settings.edit", + "system.settings.manage", + "users.view", + "users.manage", + "users.invite", + "users.reset_password", + "rbac.view", + "rbac.manage", + "audit.view", + "alerts.view_self", + "alerts.manage", + "services.view", + "services.create", + "services.edit", + "services.selection.manage", + "services.deactivate", + "services.cross_branch", + "services.cross_tenant", + "services.catalogue.manage", + "service_tasks.view", + "service_tasks.create", + "service_tasks.edit", + "service_tasks.deactivate", + "clients.view", + "clients.create", + "clients.import", + "clients.edit", + "clients.deactivate", + "clients.activate", + "clients.archive", + "clients.restore", + "clients.assign_partner", + "clients.cross_branch", + "clients.cross_tenant", + "clients.export", + "clients.audit_log.view", + "employees.dashboard.view", + "employees.view", + "employees.create", + "employees.edit", + "employees.status", + "employees.cross_branch", + "employees.cross_tenant", + "consultants.view", + "consultants.manage", + "consultants.link_clients", + "consultants.cross_branch", + "consultants.managed_clients.manage", + "consultants.workspace.manage", + "consultants.service_requests.manage", + "consultants.conversions.manage", + + # System Admin has billing support/view access only. + # System Admin must not create, import, generate, approve, post, cancel, + # or record firm-level client bills. + "billing.view", + "billing.payment.view", + "billing.reports", + "billing.cross_branch", + "billing.cross_tenant", + "billing_fee_structure.view", + + # Platform/SaaS billing is System Admin revenue layer. + "platform_billing.view", + "platform_billing.create", + "platform_billing.edit", + "platform_billing.generate", + "platform_billing.post", + "platform_billing.cancel", + "platform_billing.payment.create", + "platform_billing.payment.view", + "platform_billing.reports", + "platform_plans.manage", + "platform_subscriptions.manage", + + # Marketplace / public lead management. + "marketplace_leads.view", + "marketplace_leads.create", + "marketplace_leads.assign", + "marketplace_leads.update", + "marketplace_leads.convert", + "marketplace_leads.reports", + "marketplace_leads.view_assigned", + + "alerts.view_self", + "employees.ess.view", "employees.ess.profile.edit", + "employees.work.view_self", + "employees.work.manage", + "employees.progress.view", + "employees.registration.request", + "employees.registration.approve", + "employees.attendance.punch", + "employees.attendance.view_self", + "employees.attendance.view_all", + "employees.attendance.approve", + "employees.leave.apply", + "employees.leave.view_self", + "employees.leave.view_all", + "employees.leave.approve", + "employees.leave_type.manage", + "employees.leave_balance.manage", + "employees.documents.view_self", + "employees.documents.upload_self", + "employees.documents.view_all", + "employees.documents.manage", + "employees.documents.verify", + "employees.documents.delete", + "employees.document_type.manage", + "employees.onboarding.view", + "employees.onboarding.manage", + "employees.onboarding.approve", + "employees.offboarding.view", + "employees.offboarding.manage", + "employees.offboarding.approve", + "employees.offboarding.request_self", + "employees.payroll.payout", + "employees.payroll.view_self", + "employees.payroll.view", + "employees.payroll.run", + "employees.payroll.structure.manage", + "employees.import", + "employees.import.employee", + "employees.import.leave_type", + "employees.import.leave_balance", + "employees.import.salary_structure", + ], + "Firm Admin": [ + "system.settings.view", + "system.settings.edit", + "users.view", + "users.manage", + "users.invite", + "users.reset_password", + "audit.view", + "alerts.view_self", + "alerts.manage", + "services.view", + "services.create", + "services.edit", + "services.selection.manage", + "services.deactivate", + "services.cross_branch", + "service_tasks.view", + "service_tasks.create", + "service_tasks.edit", + "service_tasks.deactivate", + "clients.view", + "clients.create", + "clients.import", + "clients.edit", + "clients.deactivate", + "clients.activate", + "clients.archive", + "clients.restore", + "clients.assign_partner", + "clients.cross_branch", + "clients.export", + "clients.audit_log.view", + "documents.view", + "documents.upload", + "documents.download", + "documents.delete", + "documents.audit.view", + "employees.dashboard.view", + "employees.view", + "employees.create", + "employees.edit", + "employees.status", + "employees.cross_branch", + "consultants.view", + "consultants.manage", + "consultants.link_clients", + "consultants.cross_branch", + "consultants.managed_clients.manage", + "consultants.workspace.manage", + "consultants.service_requests.manage", + "consultants.conversions.manage", + + "billing.view", + "billing.create", + "billing.edit", + "billing.approve", + "billing.post", + "billing.cancel", + "billing.payment.create", + "billing.payment.view", + "billing.reports", + "billing.cross_branch", + "billing_fee_structure.view", + "billing_fee_structure.import", + "billing_fee_structure.edit", + "billing_fee_structure.delete", + "billing_invoice.generate", + "billing_invoice.bulk_generate", + + # Audit Firm can work on leads assigned to its audit firm. + "marketplace_leads.view_assigned", + "marketplace_leads.update", + "marketplace_leads.convert", + + "employees.ess.view", "employees.ess.profile.edit", + "employees.work.view_self", + "employees.work.manage", + "employees.progress.view", + "employees.registration.request", + "employees.registration.approve", + "employees.attendance.punch", + "employees.attendance.view_self", + "employees.attendance.view_all", + "employees.attendance.approve", + "employees.leave.apply", + "employees.leave.view_self", + "employees.leave.view_all", + "employees.leave.approve", + "employees.leave_type.manage", + "employees.leave_balance.manage", + "employees.documents.view_self", + "employees.documents.upload_self", + "employees.documents.view_all", + "employees.documents.manage", + "employees.documents.verify", + "employees.documents.delete", + "employees.document_type.manage", + "employees.onboarding.view", + "employees.onboarding.manage", + "employees.onboarding.approve", + "employees.offboarding.view", + "employees.offboarding.manage", + "employees.offboarding.approve", + "employees.offboarding.request_self", + "employees.payroll.payout", + "employees.payroll.view_self", + "employees.payroll.view", + "employees.payroll.run", + "employees.payroll.structure.manage", + "employees.import", + "employees.import.employee", + "employees.import.leave_type", + "employees.import.leave_balance", + "employees.import.salary_structure", + ], + "Partner": [ + "users.view", + "system.settings.view", + "services.view", + "services.cross_branch", + "service_tasks.view", + "clients.view", + "clients.create", + "clients.import", + "clients.edit", + "clients.deactivate", + "clients.activate", + "clients.archive", + "clients.restore", + "clients.export", + "clients.audit_log.view", + "documents.view", + "documents.upload", + "documents.download", + "documents.delete", + "clients.view.own_only", + "employees.dashboard.view", + "employees.view", + "employees.create", + "employees.edit", + "employees.status", + "consultants.view", + "consultants.link_clients", + "consultants.cross_branch", + "consultants.managed_clients.manage", + "consultants.workspace.manage", + + # Partner has almost the same firm-billing privileges as Firm Admin, + # but is intentionally scoped to own clients through billing.view_own. + "billing.view", + "billing.create", + "billing.edit", + "billing.approve", + "billing.post", + "billing.cancel", + "billing.payment.create", + "billing.payment.view", + "billing.reports", + "billing.view_own", + "billing_fee_structure.view", + "billing_fee_structure.import", + "billing_fee_structure.edit", + "billing_fee_structure.delete", + "billing_invoice.generate", + "billing_invoice.bulk_generate", + + # Partner can handle assigned marketplace leads for own clients/work. + "marketplace_leads.view_assigned", + "marketplace_leads.update", + "marketplace_leads.convert", + + "employees.ess.view", "employees.ess.profile.edit", + "employees.work.view_self", + "employees.work.manage", + "employees.progress.view", + "employees.registration.request", + "employees.registration.approve", + "employees.attendance.punch", + "employees.attendance.view_self", + "employees.attendance.view_all", + "employees.attendance.approve", + "employees.leave.apply", + "employees.leave.view_self", + "employees.leave.view_all", + "employees.leave.approve", + "employees.leave_type.manage", + "employees.leave_balance.manage", + "employees.documents.view_self", + "employees.documents.upload_self", + "employees.documents.view_all", + "employees.documents.manage", + "employees.documents.verify", + "employees.documents.delete", + "employees.document_type.manage", + "employees.onboarding.view", + "employees.onboarding.manage", + "employees.onboarding.approve", + "employees.offboarding.view", + "employees.offboarding.manage", + "employees.offboarding.approve", + "employees.offboarding.request_self", + "employees.payroll.payout", + "employees.payroll.view_self", + "employees.payroll.view", + "employees.payroll.run", + "employees.payroll.structure.manage", + "employees.import", + "employees.import.employee", + "employees.import.leave_type", + "employees.import.leave_balance", + "employees.import.salary_structure", + ], + "Branch Manager": [ + "users.view", + "services.view", + "services.create", + "services.edit", + "service_tasks.view", + "service_tasks.create", + "service_tasks.edit", + "clients.view", + "clients.create", + "clients.edit", + "clients.deactivate", + "clients.activate", + "clients.export", + "clients.audit_log.view", + "documents.view", + "documents.upload", + "documents.download", + "employees.dashboard.view", + "employees.view", + "employees.create", + "employees.edit", + "employees.status", + "consultants.view", + + "billing.view", + "billing.create", + "billing_fee_structure.view", + "employees.ess.view", "employees.ess.profile.edit", + "employees.work.view_self", + "employees.work.manage", + "employees.progress.view", + "employees.registration.request", + "employees.registration.approve", + "employees.attendance.punch", + "employees.attendance.view_self", + "employees.attendance.view_all", + "employees.attendance.approve", + "employees.leave.apply", + "employees.leave.view_self", + "employees.leave.view_all", + "employees.leave.approve", + "employees.leave_type.manage", + "employees.leave_balance.manage", + "employees.documents.view_self", + "employees.documents.upload_self", + "employees.documents.view_all", + "employees.documents.manage", + "employees.documents.verify", + "employees.documents.delete", + "employees.document_type.manage", + "employees.onboarding.view", + "employees.onboarding.manage", + "employees.onboarding.approve", + "employees.offboarding.view", + "employees.offboarding.manage", + "employees.offboarding.approve", + "employees.offboarding.request_self", + "employees.payroll.view_self", + "employees.payroll.view", + "employees.payroll.run", + "employees.payroll.structure.manage", + "employees.import", + "employees.import.employee", + "employees.import.leave_type", + "employees.import.leave_balance", + "employees.import.salary_structure", + ], + "Staff": [ + "alerts.view_self", + "employees.ess.view", + "employees.ess.profile.edit", + "employees.work.view_self", + "employees.registration.request", + "employees.attendance.punch", + "employees.attendance.view_self", + "employees.leave.apply", + "employees.leave.view_self", + "employees.documents.view_self", + "employees.documents.upload_self", + "employees.offboarding.request_self", + "employees.payroll.view_self", + "documents.view", + "documents.upload", + "documents.download", + ], + "Client": [], + "Consultant": [ + "alerts.view_self", + "consultants.portal.view", + "consultants.managed_clients.manage", + "consultants.workspace.manage", + ], +} + + +# Keep existing databases aligned with the billing permission policy. +# The normal startup seed only adds missing permissions; it does not remove +# permissions that were granted in an earlier patch. This sync is limited to +# billing permissions for these default roles so existing non-billing features +# and custom modules are not touched. +BILLING_PERMISSION_CODES = { + "billing.view", + "billing.create", + "billing.edit", + "billing.approve", + "billing.post", + "billing.cancel", + "billing.payment.create", + "billing.payment.view", + "billing.reports", + "billing.cross_branch", + "billing.cross_tenant", + "billing.view_own", + "billing_fee_structure.view", + "billing_fee_structure.import", + "billing_fee_structure.edit", + "billing_fee_structure.delete", + "billing_invoice.generate", + "billing_invoice.bulk_generate", +} + +BILLING_ROLE_PERMISSION_SYNC = { + "System Admin": { + "billing.view", + "billing.payment.view", + "billing.reports", + "billing.cross_branch", + "billing.cross_tenant", + "billing_fee_structure.view", + }, + "Firm Admin": { + "billing.view", + "billing.create", + "billing.edit", + "billing.approve", + "billing.post", + "billing.cancel", + "billing.payment.create", + "billing.payment.view", + "billing.reports", + "billing.cross_branch", + "billing_fee_structure.view", + "billing_fee_structure.import", + "billing_fee_structure.edit", + "billing_fee_structure.delete", + "billing_invoice.generate", + "billing_invoice.bulk_generate", + }, + "Partner": { + "billing.view", + "billing.create", + "billing.edit", + "billing.approve", + "billing.post", + "billing.cancel", + "billing.payment.create", + "billing.payment.view", + "billing.reports", + "billing.view_own", + "billing_fee_structure.view", + "billing_fee_structure.import", + "billing_fee_structure.edit", + "billing_fee_structure.delete", + "billing_invoice.generate", + "billing_invoice.bulk_generate", + }, +} + + +NOTICE_CASE_ROLE_PERMISSIONS = { + "System Admin": [ + "notice_cases.view", "notice_cases.create", "notice_cases.edit", + "notice_cases.events.manage", "notice_cases.hearings.manage", "notice_cases.orders.manage", + "notice_cases.documents.upload", "notice_cases.documents.download", "notice_cases.documents.delete", + "notice_cases.cross_branch", "notice_cases.cross_tenant", + ], + "Firm Admin": [ + "notice_cases.view", "notice_cases.create", "notice_cases.edit", + "notice_cases.events.manage", "notice_cases.hearings.manage", "notice_cases.orders.manage", + "notice_cases.documents.upload", "notice_cases.documents.download", "notice_cases.documents.delete", + "notice_cases.cross_branch", + ], + "Partner": [ + "notice_cases.view", "notice_cases.create", "notice_cases.edit", + "notice_cases.events.manage", "notice_cases.hearings.manage", "notice_cases.orders.manage", + "notice_cases.documents.upload", "notice_cases.documents.download", + ], + "Branch Manager": [ + "notice_cases.view", "notice_cases.create", "notice_cases.edit", + "notice_cases.events.manage", "notice_cases.hearings.manage", "notice_cases.orders.manage", + "notice_cases.documents.upload", "notice_cases.documents.download", + "notice_cases.cross_branch", + ], + "Staff": [ + "notice_cases.view", "notice_cases.events.manage", + "notice_cases.documents.upload", "notice_cases.documents.download", + ], +} + +for _role_name, _codes in NOTICE_CASE_ROLE_PERMISSIONS.items(): + _target = ROLE_PERMISSION_MAP.setdefault(_role_name, []) + for _code in _codes: + if isinstance(_target, set): + _target.add(_code) + elif _code not in _target: + _target.append(_code) + + + + +def _fy_dates_from_code(year_code: str) -> tuple[date, date, str]: + parts = (year_code or "").split("-", 1) + try: + start_year = int(parts[0]) + except Exception: + start_year = 2025 + end_year = start_year + 1 + assessment_year = f"{end_year}-{str(end_year + 1)[-2:]}" + return date(start_year, 4, 1), date(end_year, 3, 31), assessment_year + + +def _ensure_financial_year(db, tenant_id: int, year_code: str) -> FinancialYear: + fy = db.execute( + select(FinancialYear).where( + FinancialYear.tenant_id == tenant_id, + FinancialYear.year_code == year_code, + ) + ).scalar_one_or_none() + if fy: + return fy + + start_date, end_date, assessment_year = _fy_dates_from_code(year_code) + current_exists = db.execute( + select(FinancialYear.id).where( + FinancialYear.tenant_id == tenant_id, + FinancialYear.is_current.is_(True), + ) + ).first() + now = datetime.now(timezone.utc) + fy = FinancialYear( + tenant_id=tenant_id, + year_code=year_code, + assessment_year=assessment_year, + start_date=start_date, + end_date=end_date, + is_current=current_exists is None, + is_locked=False, + created_at_utc=now, + updated_at_utc=now, + ) + db.add(fy) + db.commit() + db.refresh(fy) + return fy + + +def _ensure_financial_years_for_all_tenants(db, default_year_code: str) -> None: + tenant_ids = db.execute(select(Tenant.id)).scalars().all() + for tenant_id in tenant_ids: + _ensure_financial_year(db, int(tenant_id), default_year_code) + +def on_startup(app: FastAPI) -> None: + s = get_settings() + inspector = inspect(CommonEngine) + existing_tables = set(inspector.get_table_names()) + + if "audit_logs" not in existing_tables: + CommonBase.metadata.create_all(bind=CommonEngine, tables=[AuditLog.__table__]) + existing_tables = set(inspect(CommonEngine).get_table_names()) + + required_tables = { + "tenants", + "branches", + "branch_settings", + "users", + "roles", + "permissions", + "role_permissions", + "user_roles", + "audit_logs", + } + + missing_optional_tables = [] + if "invite_tokens" not in existing_tables: + missing_optional_tables.append(InviteToken.__table__) + if "password_reset_tokens" not in existing_tables: + missing_optional_tables.append(PasswordResetToken.__table__) + if "service_categories" not in existing_tables: + missing_optional_tables.append(ServiceCategory.__table__) + if "service_catalogues" not in existing_tables: + missing_optional_tables.append(ServiceCatalogue.__table__) + if "firm_service_selections" not in existing_tables: + missing_optional_tables.append(FirmServiceSelection.__table__) + if "firm_service_task_templates" not in existing_tables: + missing_optional_tables.append(FirmServiceTaskTemplate.__table__) + billing_tables = [ + ("billing_settings", BillingSettings.__table__), + ("billing_fee_groups", BillingFeeGroup.__table__), + ("billing_fee_group_services", BillingFeeGroupService.__table__), + ("billing_invoices", BillingInvoice.__table__), + ("billing_invoice_lines", BillingInvoiceLine.__table__), + ] + for table_name, table in billing_tables: + if table_name not in existing_tables: + missing_optional_tables.append(table) + + platform_billing_tables = [ + ("platform_plans", PlatformPlan.__table__), + ("platform_plan_features", PlatformPlanFeature.__table__), + ("platform_billing_accounts", PlatformBillingAccount.__table__), + ("platform_subscriptions", PlatformSubscription.__table__), + ("platform_invoices", PlatformInvoice.__table__), + ("platform_invoice_lines", PlatformInvoiceLine.__table__), + ("platform_payments", PlatformPayment.__table__), + ] + marketplace_tables = [ + ("marketplace_leads", MarketplaceLead.__table__), + ("marketplace_lead_assignments", MarketplaceLeadAssignment.__table__), + ] + for table_name, table in platform_billing_tables: + if table_name not in existing_tables: + missing_optional_tables.append(table) + for table_name, table in marketplace_tables: + if table_name not in existing_tables: + missing_optional_tables.append(table) + + documents_tables = [ + ("engagement_documents", EngagementDocument.__table__), + ("engagement_document_versions", EngagementDocumentVersion.__table__), + ("document_access_logs", DocumentAccessLog.__table__), + ] + for table_name, table in documents_tables: + if table_name not in existing_tables: + missing_optional_tables.append(table) + if "user_alerts" not in existing_tables: + missing_optional_tables.append(UserAlert.__table__) + if "financial_years" not in existing_tables: + missing_optional_tables.append(FinancialYear.__table__) + + notice_case_tables = [ + ("notice_cases", NoticeCase.__table__), + ("notice_case_events", NoticeCaseEvent.__table__), + ("notice_case_hearings", NoticeCaseHearing.__table__), + ("notice_case_orders", NoticeCaseOrder.__table__), + ("notice_case_documents", NoticeCaseDocument.__table__), + ] + for table_name, table in notice_case_tables: + if table_name not in existing_tables: + missing_optional_tables.append(table) + if "employees" not in existing_tables: + missing_optional_tables.append(Employee.__table__) + if "employee_registration_requests" not in existing_tables and "employees" in existing_tables: + missing_optional_tables.append(EmployeeRegistrationRequest.__table__) + if "employee_attendance" not in existing_tables and "employees" in existing_tables: + missing_optional_tables.append(EmployeeAttendance.__table__) + if "employee_onboarding_checklist_items" not in existing_tables and "employees" in existing_tables: + missing_optional_tables.append(EmployeeOnboardingChecklistItem.__table__) + if "employee_onboarding_tasks" not in existing_tables and "employees" in existing_tables: + missing_optional_tables.append(EmployeeOnboardingTask.__table__) + if "employee_offboarding_requests" not in existing_tables and "employees" in existing_tables: + missing_optional_tables.append(EmployeeOffboardingRequest.__table__) + if "employee_offboarding_tasks" not in existing_tables and "employees" in existing_tables: + missing_optional_tables.append(EmployeeOffboardingTask.__table__) + if "employee_salary_structures" not in existing_tables and "employees" in existing_tables: + missing_optional_tables.append(EmployeeSalaryStructure.__table__) + if "employee_payroll_runs" not in existing_tables and "employees" in existing_tables: + missing_optional_tables.append(EmployeePayrollRun.__table__) + if "employee_payslips" not in existing_tables and "employees" in existing_tables: + missing_optional_tables.append(EmployeePayslip.__table__) + if "consultant_workspaces" not in existing_tables and "consultant_profiles" in existing_tables: + missing_optional_tables.append(ConsultantWorkspace.__table__) + if "consultant_service_requests" not in existing_tables and "consultant_profiles" in existing_tables: + missing_optional_tables.append(ConsultantServiceRequest.__table__) + if missing_optional_tables: + CommonBase.metadata.create_all(bind=CommonEngine, tables=missing_optional_tables) + + if not required_tables.issubset(existing_tables): + raise RuntimeError("Database schema is not initialized. Run 'alembic upgrade head' first.") + + _ensure_user_lifecycle_columns() + + db = CommonSessionLocal() + try: + tenant = db.execute(select(Tenant).where(Tenant.code == s.DEFAULT_TENANT_CODE)).scalar_one_or_none() + if not tenant: + tenant = Tenant(code=s.DEFAULT_TENANT_CODE, name="Default Tenant", is_active=True) + db.add(tenant) + db.commit() + db.refresh(tenant) + + branch = db.execute( + select(Branch).where(Branch.tenant_id == tenant.id, Branch.code == s.DEFAULT_BRANCH_CODE) + ).scalar_one_or_none() + if not branch: + branch = Branch( + tenant_id=tenant.id, + code=s.DEFAULT_BRANCH_CODE, + name="Main Branch", + timezone=s.DEFAULT_TIMEZONE, + is_active=True, + allow_login=True, + ) + db.add(branch) + db.commit() + db.refresh(branch) + + bs = db.execute(select(BranchSettings).where(BranchSettings.branch_id == branch.id)).scalar_one_or_none() + if not bs: + bs = BranchSettings(branch_id=branch.id) + db.add(bs) + db.commit() + + _ensure_financial_years_for_all_tenants(db, s.DEFAULT_YEAR_CODE) + + for legacy_name, new_name in LEGACY_ROLE_RENAMES.items(): + legacy_role = db.execute(select(Role).where(Role.name == legacy_name)).scalar_one_or_none() + target_role = db.execute(select(Role).where(Role.name == new_name)).scalar_one_or_none() + if legacy_role and not target_role: + legacy_role.name = new_name + elif legacy_role and target_role: + for user_role in db.execute( + select(UserRole).where(UserRole.role_id == legacy_role.id) + ).scalars().all(): + exists = db.execute( + select(UserRole).where( + UserRole.user_id == user_role.user_id, + UserRole.role_id == target_role.id, + ) + ).scalar_one_or_none() + if not exists: + db.add(UserRole(user_id=user_role.user_id, role_id=target_role.id)) + db.flush() + db.delete(legacy_role) + db.commit() + + for role_name in DEFAULT_ROLES: + exists = db.execute(select(Role).where(Role.name == role_name)).scalar_one_or_none() + if not exists: + db.add(Role(name=role_name, is_active=True)) + db.commit() + + for code, name in DEFAULT_PERMISSIONS: + exists = db.execute(select(Permission).where(Permission.code == code)).scalar_one_or_none() + if not exists: + db.add(Permission(code=code, name=name, is_active=True)) + db.commit() + + roles = {r.name: r for r in db.execute(select(Role)).scalars().all()} + permissions = {p.code: p for p in db.execute(select(Permission)).scalars().all()} + + for role_name, permission_codes in ROLE_PERMISSION_MAP.items(): + role = roles.get(role_name) + if not role: + continue + + permission_codes = list(dict.fromkeys(permission_codes)) + + for code in permission_codes: + permission = permissions.get(code) + if not permission: + continue + + exists = db.execute( + select(RolePermission).where( + RolePermission.role_id == role.id, + RolePermission.permission_id == permission.id, + ) + ).scalar_one_or_none() + + if not exists: + db.add(RolePermission(role_id=role.id, permission_id=permission.id)) + db.commit() + + # Enforce the updated billing privilege matrix for existing databases. + # This removes stale billing permissions from System Admin and grants + # Partner own-client billing privileges without altering other modules. + billing_permissions = { + code: permissions[code] + for code in BILLING_PERMISSION_CODES + if code in permissions + } + for role_name, allowed_codes in BILLING_ROLE_PERMISSION_SYNC.items(): + role = roles.get(role_name) + if not role: + continue + + allowed_permission_ids = { + billing_permissions[code].id + for code in allowed_codes + if code in billing_permissions + } + billing_permission_ids = {permission.id for permission in billing_permissions.values()} + + existing_links = db.execute( + select(RolePermission).where( + RolePermission.role_id == role.id, + RolePermission.permission_id.in_(billing_permission_ids), + ) + ).scalars().all() if billing_permission_ids else [] + + existing_ids = {link.permission_id for link in existing_links} + for link in existing_links: + if link.permission_id not in allowed_permission_ids: + db.delete(link) + + for permission_id in allowed_permission_ids - existing_ids: + db.add(RolePermission(role_id=role.id, permission_id=permission_id)) + db.commit() + + any_user = db.execute(select(User.id)).first() + if not any_user: + admin = User( + email=s.BOOTSTRAP_ADMIN_EMAIL, + full_name="System Admin", + password_hash=hash_password(s.BOOTSTRAP_ADMIN_PASSWORD), + tenant_id=tenant.id, + branch_id=branch.id, + is_active=True, + allow_login=True, + is_locked=False, + deleted_at=None, + ) + db.add(admin) + db.commit() + db.refresh(admin) + + if roles.get("System Admin"): + exists = db.execute( + select(UserRole).where( + UserRole.user_id == admin.id, + UserRole.role_id == roles["System Admin"].id, + ) + ).scalar_one_or_none() + if not exists: + db.add(UserRole(user_id=admin.id, role_id=roles["System Admin"].id)) + db.commit() + finally: + db.close() + + # Phase 7O: start alert notification/escalation automation after schema and seed checks. + start_notification_scheduler() diff --git a/app/core/templating.py b/app/core/templating.py new file mode 100644 index 0000000..5fe1af2 --- /dev/null +++ b/app/core/templating.py @@ -0,0 +1,622 @@ +from urllib.parse import parse_qsl, urlencode + +from fastapi.templating import Jinja2Templates +from sqlalchemy import select + +templates = Jinja2Templates(directory="app") + +from app.core.db.common import CommonSessionLocal +from app.modules.core.iam.scope import build_scope, list_visible_branches, list_visible_tenants +from app.modules.core.rbac.ui_permissions import ( + can_change_branch_tenant, + can_export_clients, + can_import_service_tasks, + can_import_services, + can_manage_branches, + can_manage_clients, + can_manage_rbac, + can_manage_service_tasks, + can_manage_services, + can_manage_settings, + can_manage_tenants, + can_manage_users, + can_view_employee_dashboard, + can_view_employees, + can_manage_employees, + can_change_employee_status, + can_switch_employee_tenant, + can_switch_employee_branch, + can_view_employee_portal, + can_edit_own_employee_profile, + can_view_own_employee_work, + can_manage_employee_work, + can_view_employee_progress, + can_request_employee_registration, + can_approve_employee_registrations, + can_punch_employee_attendance, + can_view_own_employee_attendance, + can_view_all_employee_attendance, + can_approve_employee_attendance, + can_apply_employee_leave, + can_view_own_employee_leave, + can_view_all_employee_leave, + can_approve_employee_leave, + can_manage_employee_leave_types, + can_manage_employee_leave_balances, + can_view_own_employee_documents, + can_upload_own_employee_documents, + can_view_all_employee_documents, + can_manage_employee_documents, + can_verify_employee_documents, + can_manage_employee_document_types, + can_view_employee_onboarding, + can_manage_employee_onboarding, + can_approve_employee_onboarding, + can_view_employee_offboarding, + can_manage_employee_offboarding, + can_approve_employee_offboarding, + can_request_own_employee_offboarding, + can_import_employee_hr, + can_manage_employee_payroll_structures, + can_run_employee_payroll, + can_view_employee_payroll, + can_view_own_employee_payslips, + can_approve_employee_payroll, + can_view_consultants, + can_manage_consultants, + can_link_consultant_clients, + can_manage_consultant_service_requests, + can_manage_consultant_conversions, + can_view_consultant_portal, + can_manage_own_consultant_workspace, + can_switch_client_branch, + can_switch_client_tenant, + can_switch_service_branch, + can_switch_service_tenant, + can_view_audit, + can_view_branches, + can_view_clients, + can_view_billing, + can_create_billing, + can_generate_billing_invoices, + can_view_billing_fee_structure, + can_import_billing_fee_structure, + can_view_platform_billing, + can_manage_platform_billing, + can_generate_platform_billing, + can_manage_platform_plans, + can_manage_platform_subscriptions, + can_view_marketplace_leads, + can_create_marketplace_leads, + can_assign_marketplace_leads, + can_update_marketplace_leads, + can_convert_marketplace_leads, + can_view_documents, + can_upload_documents, + can_download_documents, + can_delete_documents, + can_view_rbac, + can_view_services, + can_view_settings, + can_view_tenants, + can_view_users, + can_view_own_alerts, + can_manage_alerts, + can_view_notice_cases, + can_manage_notice_cases, + can_upload_notice_case_documents, + can_download_notice_case_documents, + can_delete_notice_case_documents, +) + + +def build_page_url(base_url: str, page: int, query: str | None = None) -> str: + params = dict(parse_qsl((query or "").lstrip("?"), keep_blank_values=True)) + params["page"] = str(page) + qs = urlencode(params) + return f"{base_url}?{qs}" if qs else base_url + + +def get_active_tenant_id(request, current_user=None): + if not current_user: + return None + return request.session.get("active_tenant_id") or getattr(current_user, "tenant_id", None) + + +def get_active_branch_id(request, current_user=None): + if not current_user: + return None + # return None when "all branches" context is active + val = request.session.get("active_branch_id") + if val in (None, "", 0, "0"): + return None + return val + + +def get_active_tenant_code(request, current_user=None): + if not current_user: + return None + return request.session.get("active_tenant_code") or request.session.get("tenant_code") or getattr(request.state, "tenant_code", None) + + +def get_active_branch_code(request, current_user=None): + if not current_user: + return None + val = request.session.get("active_branch_code") or request.session.get("branch_code") + return val or getattr(request.state, "branch_code", None) + + +def get_active_financial_year(request, current_user=None): + if not current_user: + return None + return request.session.get("active_financial_year") or getattr(request.state, "year_code", None) + + +def get_active_assessment_year(request, current_user=None): + if not current_user: + return None + fy_code = get_active_financial_year(request, current_user) + if not fy_code: + return None + db = CommonSessionLocal() + try: + from app.modules.core.tenancy.models import FinancialYear + tenant_id = get_active_tenant_id(request, current_user) or getattr(current_user, "tenant_id", None) + fy = db.execute( + select(FinancialYear).where( + FinancialYear.tenant_id == tenant_id, + FinancialYear.year_code == fy_code, + ) + ).scalar_one_or_none() + return fy.assessment_year if fy else None + except Exception: + return None + finally: + db.close() + + +def get_unread_alert_count(request, current_user=None): + if not current_user: + return 0 + try: + from app.modules.alerts.service import count_unread_alerts + except Exception: + return 0 + db = CommonSessionLocal() + try: + return count_unread_alerts(db, current_user) + except Exception: + return 0 + finally: + db.close() + + + +def _safe_static_path(path: str | None) -> str | None: + path = (path or "").strip() + if not path: + return None + if path.startswith("/static/"): + return path + if path.startswith("app/ui/static/"): + return "/static/" + path.split("app/ui/static/", 1)[1] + return path + + +def get_domain_context(request) -> dict: + """Return safe domain context populated by Phase 7T.2 middleware.""" + try: + ctx = getattr(request.state, "domain_context", None) + return ctx if isinstance(ctx, dict) else {"is_resolved": False} + except Exception: + return {"is_resolved": False} + + +def _branding_default() -> dict: + return { + "firm_name": "Audit Firm ERP", + "branch_name": "", + "logo_url": None, + "favicon_url": None, + "primary_color": "#2563eb", + "accent_color": "#0f172a", + "contact_email": None, + "contact_mobile": None, + "website_url": None, + "domain_name": None, + "domain_type": None, + "domain_resolved": False, + "is_marketplace_domain": False, + "is_consultant_domain": False, + "consultant_name": None, + "consultant_firm_name": None, + } + + +def _tenant_branding_from_row(tenant, branch=None, default: dict | None = None) -> dict: + default = default or _branding_default() + if not tenant: + return default.copy() + return { + **default, + "firm_name": getattr(tenant, "display_name", None) or getattr(tenant, "name", None) or default["firm_name"], + "branch_name": getattr(branch, "name", None) if branch else "All Branches", + "logo_url": _safe_static_path(getattr(tenant, "logo_path", None)), + "favicon_url": _safe_static_path(getattr(tenant, "favicon_path", None)), + "primary_color": getattr(tenant, "primary_color", None) or default["primary_color"], + "accent_color": getattr(tenant, "accent_color", None) or default["accent_color"], + "contact_email": getattr(tenant, "contact_email", None), + "contact_mobile": getattr(tenant, "contact_mobile", None), + "website_url": getattr(tenant, "website_url", None), + } + + +def _domain_branding(request, default: dict | None = None) -> dict: + default = default or _branding_default() + ctx = get_domain_context(request) + if not ctx.get("is_resolved"): + return default.copy() + + db = CommonSessionLocal() + try: + from app.modules.core.tenancy.models import Branch, Tenant + from app.modules.consultants.models import ConsultantProfile + from app.modules.core.iam.models import User + domain_type = ctx.get("domain_type") + tenant_id = ctx.get("tenant_id") or ctx.get("parent_tenant_id") + branch_id = ctx.get("branch_id") + consultant_id = ctx.get("consultant_id") + + tenant = db.execute(select(Tenant).where(Tenant.id == tenant_id)).scalar_one_or_none() if tenant_id else None + branch = db.execute(select(Branch).where(Branch.id == branch_id)).scalar_one_or_none() if branch_id else None + branding = _tenant_branding_from_row(tenant, branch, default) + branding.update({ + "domain_name": ctx.get("domain_name") or ctx.get("host"), + "domain_type": domain_type, + "domain_resolved": True, + "is_marketplace_domain": domain_type == "marketplace", + "is_consultant_domain": str(domain_type or "").startswith("consultant_"), + }) + + if domain_type == "marketplace": + branding["firm_name"] = "FilingABC" + branding["branch_name"] = "Marketplace" + return branding + + if consultant_id: + consultant = db.execute(select(ConsultantProfile).where(ConsultantProfile.id == consultant_id)).scalar_one_or_none() + if consultant: + consultant_name = getattr(consultant, "contact_person", None) or getattr(consultant, "firm_name", None) or "Consultant" + consultant_firm_name = getattr(consultant, "firm_name", None) or consultant_name + branding["consultant_name"] = consultant_name + branding["consultant_firm_name"] = consultant_firm_name + branding["firm_name"] = consultant_firm_name + branding["branch_name"] = "Consultant Workspace" + branding["contact_email"] = getattr(consultant, "email", None) or branding.get("contact_email") + branding["contact_mobile"] = getattr(consultant, "mobile", None) or branding.get("contact_mobile") + + # If the consultant user has a profile photo, use it as the domain logo. + user_id = getattr(consultant, "user_id", None) + if user_id: + user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none() + logo = _safe_static_path(getattr(user, "profile_photo_path", None)) + if logo: + branding["logo_url"] = logo + return branding + except Exception: + return default.copy() + finally: + db.close() + + +def get_current_tenant_name(request, current_user=None): + if not current_user: + return _domain_branding(request).get("firm_name") or "Audit Firm" + db = CommonSessionLocal() + try: + from app.modules.core.tenancy.models import Tenant + tenant_id = get_active_tenant_id(request, current_user) + tenant = db.execute(select(Tenant).where(Tenant.id == tenant_id)).scalar_one_or_none() + if not tenant: + return _domain_branding(request).get("firm_name") or "Audit Firm" + return getattr(tenant, "display_name", None) or tenant.name or "Audit Firm" + except Exception: + return _domain_branding(request).get("firm_name") or "Audit Firm" + finally: + db.close() + + +def get_current_branch_name(request, current_user=None): + if not current_user: + return _domain_branding(request).get("branch_name") or "-" + db = CommonSessionLocal() + try: + from app.modules.core.tenancy.models import Branch + branch_id = get_active_branch_id(request, current_user) or getattr(current_user, "branch_id", None) + if not branch_id: + return "All Branches" + branch = db.execute(select(Branch).where(Branch.id == branch_id)).scalar_one_or_none() + return branch.name if branch else "-" + except Exception: + return "-" + finally: + db.close() + + +def get_current_firm_branding(request, current_user=None): + default = _branding_default() + + # Before login, domain branding is the only safe branding source. This supports + # arrr.associates, auditfirm.filingabc.com, filingabc.com and consultant domains. + if not current_user: + return _domain_branding(request, default) + + db = CommonSessionLocal() + try: + from app.modules.core.tenancy.models import Branch, Tenant + tenant_id = get_active_tenant_id(request, current_user) + branch_id = get_active_branch_id(request, current_user) or getattr(current_user, "branch_id", None) + tenant = db.execute(select(Tenant).where(Tenant.id == tenant_id)).scalar_one_or_none() + branch = db.execute(select(Branch).where(Branch.id == branch_id)).scalar_one_or_none() if branch_id else None + if not tenant: + return _domain_branding(request, default) + branding = _tenant_branding_from_row(tenant, branch, default) + ctx = get_domain_context(request) + if ctx.get("is_resolved"): + branding.update({ + "domain_name": ctx.get("domain_name") or ctx.get("host"), + "domain_type": ctx.get("domain_type"), + "domain_resolved": True, + "is_marketplace_domain": ctx.get("domain_type") == "marketplace", + "is_consultant_domain": str(ctx.get("domain_type") or "").startswith("consultant_"), + }) + return branding + except Exception: + return _domain_branding(request, default) + finally: + db.close() + + + +def get_user_profile_photo_url(current_user=None): + if not current_user: + return None + try: + from app.modules.core.iam.profile_service import profile_photo_url + return profile_photo_url(current_user) + except Exception: + return None + + +def get_user_initials(current_user=None): + try: + from app.modules.core.iam.profile_service import user_initials + return user_initials(current_user) + except Exception: + return "U" + + +def get_client_sidebar_auditor_card(request, current_user=None): + """Return the client-facing auditor card for the logged-in client user. + + This is used only by the sidebar. It reuses Phase 7Q.5 auditor_service and + does not create or alter any business workflow. + """ + if not current_user: + return None + db = CommonSessionLocal() + try: + from app.modules.clients.auditor_service import build_client_auditor_card + from app.modules.clients.models import Client + from app.modules.core.tenancy.models import Branch, Tenant + + tenant_id = get_active_tenant_id(request, current_user) or getattr(current_user, "tenant_id", None) + email = (getattr(current_user, "email", None) or "").strip().lower() + + stmt = ( + select(Client, Tenant.name.label("tenant_name"), Branch.name.label("branch_name")) + .join(Tenant, Tenant.id == Client.tenant_id, isouter=True) + .join(Branch, Branch.id == Client.branch_id, isouter=True) + .where(Client.is_active.is_(True), Client.is_archived.is_(False)) + ) + if tenant_id: + stmt = stmt.where(Client.tenant_id == int(tenant_id)) + if email: + stmt = stmt.where((Client.portal_user_id == current_user.id) | (Client.email == email) | (Client.alternate_email == email)) + else: + stmt = stmt.where(Client.portal_user_id == current_user.id) + + result = db.execute(stmt.order_by(Client.id.desc())).first() + if not result: + return None + + client, tenant_name, branch_name = result + client_row = { + "id": client.id, + "tenant_id": client.tenant_id, + "branch_id": client.branch_id, + "tenant_name": tenant_name, + "branch_name": branch_name, + "partner_id": client.partner_id, + "default_review_partner_user_id": client.default_review_partner_user_id, + } + return build_client_auditor_card(db, client_row) + except Exception: + return None + finally: + db.close() + +def get_context_tenants(request, current_user=None, permissions=None, role_names=None): + if not current_user: + return [] + + if not ( + can_switch_service_tenant(current_user, permissions, role_names) + or can_switch_client_tenant(current_user, permissions, role_names) + or can_switch_employee_tenant(current_user, permissions, role_names) + ): + return [] + + db = CommonSessionLocal() + try: + scope = build_scope(db, current_user) + return list_visible_tenants(db, scope) + finally: + db.close() + + +def get_context_branches(request, current_user=None, permissions=None, role_names=None): + if not current_user: + return [] + + if not ( + can_switch_service_branch(current_user, permissions, role_names) + or can_switch_client_branch(current_user, permissions, role_names) + or can_switch_employee_branch(current_user, permissions, role_names) + ): + return [] + + db = CommonSessionLocal() + try: + scope = build_scope(db, current_user) + tenant_id = int(get_active_tenant_id(request, current_user) or current_user.tenant_id) + return list_visible_branches(db, scope, tenant_id=tenant_id) + finally: + db.close() + +def get_context_financial_years(request, current_user=None, permissions=None, role_names=None): + if not current_user: + return [] + + db = CommonSessionLocal() + try: + from app.modules.core.tenancy.models import FinancialYear + tenant_id = int(get_active_tenant_id(request, current_user) or current_user.tenant_id) + return db.execute( + select(FinancialYear) + .where(FinancialYear.tenant_id == tenant_id) + .order_by(FinancialYear.start_date.desc(), FinancialYear.year_code.desc()) + ).scalars().all() + finally: + db.close() + + +templates.env.globals.update( + can_view_users=can_view_users, + can_view_own_alerts=can_view_own_alerts, + can_manage_alerts=can_manage_alerts, + can_view_notice_cases=can_view_notice_cases, + can_manage_notice_cases=can_manage_notice_cases, + can_upload_notice_case_documents=can_upload_notice_case_documents, + can_download_notice_case_documents=can_download_notice_case_documents, + can_delete_notice_case_documents=can_delete_notice_case_documents, + can_view_employee_dashboard=can_view_employee_dashboard, + can_view_employees=can_view_employees, + can_manage_employees=can_manage_employees, + can_change_employee_status=can_change_employee_status, + can_switch_employee_tenant=can_switch_employee_tenant, + can_switch_employee_branch=can_switch_employee_branch, + can_view_employee_portal=can_view_employee_portal, + can_edit_own_employee_profile=can_edit_own_employee_profile, + can_view_own_employee_work=can_view_own_employee_work, + can_manage_employee_work=can_manage_employee_work, + can_view_employee_progress=can_view_employee_progress, + can_request_employee_registration=can_request_employee_registration, + can_approve_employee_registrations=can_approve_employee_registrations, + can_punch_employee_attendance=can_punch_employee_attendance, + can_view_own_employee_attendance=can_view_own_employee_attendance, + can_view_all_employee_attendance=can_view_all_employee_attendance, + can_approve_employee_attendance=can_approve_employee_attendance, + can_apply_employee_leave=can_apply_employee_leave, + can_view_own_employee_leave=can_view_own_employee_leave, + can_view_all_employee_leave=can_view_all_employee_leave, + can_approve_employee_leave=can_approve_employee_leave, + can_manage_employee_leave_types=can_manage_employee_leave_types, + can_manage_employee_leave_balances=can_manage_employee_leave_balances, + can_view_own_employee_documents=can_view_own_employee_documents, + can_upload_own_employee_documents=can_upload_own_employee_documents, + can_view_all_employee_documents=can_view_all_employee_documents, + can_manage_employee_documents=can_manage_employee_documents, + can_verify_employee_documents=can_verify_employee_documents, + can_manage_employee_document_types=can_manage_employee_document_types, + can_view_employee_onboarding=can_view_employee_onboarding, + can_manage_employee_onboarding=can_manage_employee_onboarding, + can_approve_employee_onboarding=can_approve_employee_onboarding, + can_view_employee_offboarding=can_view_employee_offboarding, + can_manage_employee_offboarding=can_manage_employee_offboarding, + can_approve_employee_offboarding=can_approve_employee_offboarding, + can_request_own_employee_offboarding=can_request_own_employee_offboarding, + can_import_employee_hr=can_import_employee_hr, + can_manage_employee_payroll_structures=can_manage_employee_payroll_structures, + can_run_employee_payroll=can_run_employee_payroll, + can_view_employee_payroll=can_view_employee_payroll, + can_view_own_employee_payslips=can_view_own_employee_payslips, + can_approve_employee_payroll=can_approve_employee_payroll, + can_manage_users=can_manage_users, + can_view_consultants=can_view_consultants, + can_manage_consultants=can_manage_consultants, + can_link_consultant_clients=can_link_consultant_clients, + can_manage_consultant_service_requests=can_manage_consultant_service_requests, + can_manage_consultant_conversions=can_manage_consultant_conversions, + can_view_consultant_portal=can_view_consultant_portal, + can_manage_own_consultant_workspace=can_manage_own_consultant_workspace, + can_view_settings=can_view_settings, + can_manage_settings=can_manage_settings, + can_view_rbac=can_view_rbac, + can_manage_rbac=can_manage_rbac, + can_view_audit=can_view_audit, + can_view_tenants=can_view_tenants, + can_manage_tenants=can_manage_tenants, + can_view_branches=can_view_branches, + can_manage_branches=can_manage_branches, + can_change_branch_tenant=can_change_branch_tenant, + can_view_services=can_view_services, + can_manage_services=can_manage_services, + can_manage_service_tasks=can_manage_service_tasks, + can_import_services=can_import_services, + can_import_service_tasks=can_import_service_tasks, + can_switch_service_tenant=can_switch_service_tenant, + can_switch_service_branch=can_switch_service_branch, + can_view_clients=can_view_clients, + can_view_billing=can_view_billing, + can_create_billing=can_create_billing, + can_generate_billing_invoices=can_generate_billing_invoices, + can_view_billing_fee_structure=can_view_billing_fee_structure, + can_import_billing_fee_structure=can_import_billing_fee_structure, + can_view_platform_billing=can_view_platform_billing, + can_manage_platform_billing=can_manage_platform_billing, + can_generate_platform_billing=can_generate_platform_billing, + can_manage_platform_plans=can_manage_platform_plans, + can_manage_platform_subscriptions=can_manage_platform_subscriptions, + can_view_marketplace_leads=can_view_marketplace_leads, + can_create_marketplace_leads=can_create_marketplace_leads, + can_assign_marketplace_leads=can_assign_marketplace_leads, + can_update_marketplace_leads=can_update_marketplace_leads, + can_convert_marketplace_leads=can_convert_marketplace_leads, + can_view_documents=can_view_documents, + can_upload_documents=can_upload_documents, + can_download_documents=can_download_documents, + can_delete_documents=can_delete_documents, + can_manage_clients=can_manage_clients, + can_export_clients=can_export_clients, + can_switch_client_tenant=can_switch_client_tenant, + can_switch_client_branch=can_switch_client_branch, + get_unread_alert_count=get_unread_alert_count, + get_current_tenant_name=get_current_tenant_name, + get_current_branch_name=get_current_branch_name, + get_current_firm_branding=get_current_firm_branding, + get_domain_context=get_domain_context, + get_user_profile_photo_url=get_user_profile_photo_url, + get_user_initials=get_user_initials, + get_client_sidebar_auditor_card=get_client_sidebar_auditor_card, + get_context_tenants=get_context_tenants, + get_context_branches=get_context_branches, + get_context_financial_years=get_context_financial_years, + get_active_tenant_id=get_active_tenant_id, + get_active_tenant_code=get_active_tenant_code, + get_active_branch_id=get_active_branch_id, + get_active_branch_code=get_active_branch_code, + get_active_financial_year=get_active_financial_year, + get_active_assessment_year=get_active_assessment_year, + build_page_url=build_page_url, +) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..bd3a59a --- /dev/null +++ b/app/main.py @@ -0,0 +1,40 @@ +from fastapi import FastAPI +from starlette.middleware.sessions import SessionMiddleware + +from app.core.settings import get_settings +from app.core.middleware.context import ContextResolveMiddleware +from app.core.middleware.domain_resolver import DomainResolverMiddleware +from app.core.middleware.security_headers import SecurityHeadersMiddleware +from app.core.startup import on_startup +from app.core.api import api_router +from app.ui.app import mount_ui + + +def create_app() -> FastAPI: + s = get_settings() + app = FastAPI(title=s.APP_NAME, debug=s.DEBUG) + + app.add_middleware(SecurityHeadersMiddleware) + app.add_middleware(ContextResolveMiddleware) + # Phase 7T.2: added after context so it resolves the request host before + # context-aware middleware/routes need tenant/branch/domain state. + app.add_middleware(DomainResolverMiddleware) + # SessionMiddleware is added last so it is available to downstream + # middleware/routes in Starlette's middleware execution order. + app.add_middleware( + SessionMiddleware, + secret_key=s.SECRET_KEY, + session_cookie=s.COOKIE_SESSION_NAME, + same_site=s.COOKIE_SAMESITE, + https_only=s.COOKIE_SECURE, + ) + + app.add_event_handler("startup", lambda: on_startup(app)) + + app.include_router(api_router, prefix="/api") + mount_ui(app) + + return app + + +app = create_app() diff --git a/app/modules/__init__.py b/app/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/alerts/__init__.py b/app/modules/alerts/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/app/modules/alerts/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/app/modules/alerts/models.py b/app/modules/alerts/models.py new file mode 100644 index 0000000..5948e77 --- /dev/null +++ b/app/modules/alerts/models.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.db.common import CommonBase + + +class UserAlert(CommonBase): + """Common role-aware alert table for all dashboards and portals. + + Phase 7H foundation only stores and displays alerts. Later phases can call + app.modules.alerts.service.create_alert() from task, document, attendance, + client and consultant workflows without changing this schema. + """ + + __tablename__ = "user_alerts" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + tenant_id: Mapped[int | None] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True) + branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + + role_context: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True) + alert_type: Mapped[str] = mapped_column(String(80), nullable=False, default="general", index=True) + priority: Mapped[str] = mapped_column(String(20), nullable=False, default="normal", index=True) + title: Mapped[str] = mapped_column(String(255), nullable=False) + message: Mapped[str | None] = mapped_column(Text, nullable=True) + target_url: Mapped[str | None] = mapped_column(String(500), nullable=True) + + is_read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True) + read_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at_utc: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False, index=True + ) + created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + + user = relationship("User", foreign_keys=[user_id]) + created_by = relationship("User", foreign_keys=[created_by_user_id]) diff --git a/app/modules/alerts/service.py b/app/modules/alerts/service.py new file mode 100644 index 0000000..70354b7 --- /dev/null +++ b/app/modules/alerts/service.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Iterable + +from sqlalchemy import Select, func, select, update +from sqlalchemy.orm import Session + +from app.modules.alerts.models import UserAlert +from app.modules.core.iam.models import User +from app.modules.email_integration.event_service import send_alert_created_email + +ALERT_PRIORITIES = ("low", "normal", "high", "critical") +ALERT_TYPES = ( + "general", + "task_assigned", + "task_due", + "task_overdue", + "task_review", + "document_uploaded", + "clarification", + "attendance", + "leave", + "payroll", + "consultant", + "client", +) + + +def normalize_priority(priority: str | None) -> str: + value = (priority or "normal").strip().lower() + return value if value in ALERT_PRIORITIES else "normal" + + +def normalize_alert_type(alert_type: str | None) -> str: + value = (alert_type or "general").strip().lower() + return value or "general" + + +def create_alert( + db: Session, + *, + user_id: int, + title: str, + message: str | None = None, + tenant_id: int | None = None, + branch_id: int | None = None, + role_context: str | None = None, + alert_type: str = "general", + priority: str = "normal", + target_url: str | None = None, + created_by_user_id: int | None = None, + commit: bool = True, +) -> UserAlert: + alert = UserAlert( + tenant_id=tenant_id, + branch_id=branch_id, + user_id=user_id, + role_context=(role_context or None), + alert_type=normalize_alert_type(alert_type), + priority=normalize_priority(priority), + title=(title or "Alert").strip()[:255], + message=(message or None), + target_url=(target_url or None), + created_by_user_id=created_by_user_id, + ) + db.add(alert) + db.flush() + try: + send_alert_created_email(db, alert) + except Exception: + # Email notification must never block in-app alert creation. + pass + if commit: + db.commit() + db.refresh(alert) + return alert + + +def create_bulk_alerts( + db: Session, + *, + user_ids: Iterable[int], + title: str, + message: str | None = None, + tenant_id: int | None = None, + branch_id: int | None = None, + role_context: str | None = None, + alert_type: str = "general", + priority: str = "normal", + target_url: str | None = None, + created_by_user_id: int | None = None, +) -> list[UserAlert]: + rows: list[UserAlert] = [] + for user_id in sorted({int(uid) for uid in user_ids if uid}): + rows.append( + create_alert( + db, + user_id=user_id, + title=title, + message=message, + tenant_id=tenant_id, + branch_id=branch_id, + role_context=role_context, + alert_type=alert_type, + priority=priority, + target_url=target_url, + created_by_user_id=created_by_user_id, + commit=False, + ) + ) + db.commit() + for row in rows: + db.refresh(row) + return rows + + +def _user_alert_query(current_user: User) -> Select: + return select(UserAlert).where(UserAlert.user_id == current_user.id) + + +def list_my_alerts( + db: Session, + current_user: User, + *, + status: str = "all", + priority: str = "all", + limit: int = 100, +) -> list[UserAlert]: + q = _user_alert_query(current_user) + if status == "unread": + q = q.where(UserAlert.is_read.is_(False)) + elif status == "read": + q = q.where(UserAlert.is_read.is_(True)) + if priority in ALERT_PRIORITIES: + q = q.where(UserAlert.priority == priority) + q = q.order_by(UserAlert.is_read.asc(), UserAlert.created_at_utc.desc()).limit(max(1, min(limit, 500))) + return list(db.execute(q).scalars().all()) + + +def count_unread_alerts(db: Session, current_user: User | None) -> int: + if not current_user: + return 0 + value = db.execute( + select(func.count(UserAlert.id)).where(UserAlert.user_id == current_user.id, UserAlert.is_read.is_(False)) + ).scalar_one() + return int(value or 0) + + +def get_my_alert_or_404(db: Session, current_user: User, alert_id: int) -> UserAlert | None: + return db.execute( + select(UserAlert).where(UserAlert.id == alert_id, UserAlert.user_id == current_user.id) + ).scalar_one_or_none() + + +def mark_alert_read(db: Session, current_user: User, alert_id: int) -> bool: + alert = get_my_alert_or_404(db, current_user, alert_id) + if not alert: + return False + if not alert.is_read: + alert.is_read = True + alert.read_at_utc = datetime.now(timezone.utc) + db.commit() + return True + + +def mark_all_alerts_read(db: Session, current_user: User) -> int: + result = db.execute( + update(UserAlert) + .where(UserAlert.user_id == current_user.id, UserAlert.is_read.is_(False)) + .values(is_read=True, read_at_utc=datetime.now(timezone.utc)) + ) + db.commit() + return int(result.rowcount or 0) diff --git a/app/modules/alerts/templates/alerts/list.html b/app/modules/alerts/templates/alerts/list.html new file mode 100644 index 0000000..ec3728f --- /dev/null +++ b/app/modules/alerts/templates/alerts/list.html @@ -0,0 +1,73 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+ {% set _role_text = (current_user_roles or [])|join('|')|lower %} + {% if 'partner' in _role_text %} + {% include "modules/partners/templates/partners/_partner_tabs.html" %} + {% elif 'manager' in _role_text %} + {% include "modules/managers/templates/managers/_manager_tabs.html" %} + {% else %} + {% include "modules/employees/templates/employees/_my_workspace_tabs.html" %} + {% endif %} +
+
+

My Alerts

+

Role-wise alerts for tasks, documents, attendance, leave, payroll, client and consultant workflows.

+
+
+ + +
+
+ +
+
Unread
{{ unread_count }}
+
Showing
{{ alerts|length }}
+
Filter
{{ status.replace('_',' ').title() }} · {{ priority.title() }}
+
+ +
+
+ + + +
+
+ +
+ {% for alert in alerts %} +
+
+
+
+

{{ alert.title }}

+ {{ alert.priority }} + {% if not alert.is_read %}Unread{% endif %} +
+
{{ alert.alert_type.replace('_',' ').title() }}{% if alert.role_context %} · {{ alert.role_context }}{% endif %} · {{ alert.created_at_utc.strftime('%d-%m-%Y %H:%M') if alert.created_at_utc else '-' }}
+ {% if alert.message %}

{{ alert.message }}

{% endif %} +
+
+ {% if alert.target_url %}Open{% endif %} + {% if not alert.is_read %} +
+ + +
+ {% endif %} +
+
+
+ {% else %} +
No alerts found for the selected filter.
+ {% endfor %} +
+
+{% endblock %} diff --git a/app/modules/alerts/ui.py b/app/modules/alerts/ui.py new file mode 100644 index 0000000..489cf40 --- /dev/null +++ b/app/modules/alerts/ui.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +from fastapi import APIRouter, Form, Request +from fastapi.responses import JSONResponse, RedirectResponse + +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.alerts.service import ALERT_PRIORITIES, count_unread_alerts, list_my_alerts, mark_alert_read, mark_all_alerts_read +from app.modules.core.rbac.deps import get_user_permissions, get_user_roles + +router = APIRouter(prefix="/alerts", tags=["alerts-ui"]) + + +def _redirect_login(): + return RedirectResponse(url="/login", status_code=303) + + +def _base_ctx(request: Request, db, current_user, **ctx): + base = { + "request": request, + "current_user": current_user, + "current_user_roles": get_user_roles(db, current_user.id), + "current_user_permissions": get_user_permissions(db, current_user.id), + "csrf_token": get_or_create_csrf_token(request), + } + base.update(ctx) + return base + + +@router.get("/poll") +def poll_unread_alerts(request: Request, limit: int = 5): + """Lightweight polling endpoint used by the base layout toast popup. + + Returns a small list of unread alerts for the logged-in user. It does not + mark alerts as read; the normal /alerts page and existing read actions + continue to control read status. + """ + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db) + if not current_user: + return JSONResponse({"authenticated": False, "unread_count": 0, "alerts": []}, status_code=401) + + safe_limit = max(1, min(int(limit or 5), 10)) + rows = list_my_alerts(db, current_user, status="unread", priority="all", limit=safe_limit) + payload = [] + for row in rows: + created_at = getattr(row, "created_at_utc", None) + payload.append( + { + "id": row.id, + "title": row.title or "Alert", + "message": row.message or "", + "priority": row.priority or "normal", + "alert_type": row.alert_type or "general", + "target_url": row.target_url or "/alerts", + "created_at_utc": created_at.isoformat() if created_at else None, + } + ) + + return JSONResponse( + { + "authenticated": True, + "unread_count": count_unread_alerts(db, current_user), + "alerts": payload, + } + ) + finally: + db.close() + + +@router.get("") +def alerts_list(request: Request, status: str = "all", priority: str = "all"): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db) + if not current_user: + return _redirect_login() + status = status if status in {"all", "unread", "read"} else "all" + priority = priority if priority in ALERT_PRIORITIES else "all" + rows = list_my_alerts(db, current_user, status=status, priority=priority, limit=150) + return templates.TemplateResponse( + "modules/alerts/templates/alerts/list.html", + _base_ctx( + request, + db, + current_user, + title="My Alerts", + alerts=rows, + status=status, + priority=priority, + priorities=ALERT_PRIORITIES, + unread_count=count_unread_alerts(db, current_user), + ), + ) + finally: + db.close() + + +@router.post("/{alert_id}/read") +def mark_read(request: Request, alert_id: int, csrf_token: str = Form(...)): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db) + if not current_user: + return _redirect_login() + validate_csrf(request, csrf_token) + mark_alert_read(db, current_user, alert_id) + return RedirectResponse(url="/alerts", status_code=303) + finally: + db.close() + + +@router.post("/read-all") +def mark_all_read(request: Request, csrf_token: str = Form(...)): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db) + if not current_user: + return _redirect_login() + validate_csrf(request, csrf_token) + mark_all_alerts_read(db, current_user) + return RedirectResponse(url="/alerts", status_code=303) + finally: + db.close() diff --git a/app/modules/billing/__init__.py b/app/modules/billing/__init__.py new file mode 100644 index 0000000..e79a753 --- /dev/null +++ b/app/modules/billing/__init__.py @@ -0,0 +1 @@ +"""Billing module for firm-level invoices and fee structure imports.""" diff --git a/app/modules/billing/client_portal_service.py b/app/modules/billing/client_portal_service.py new file mode 100644 index 0000000..3ed1e9d --- /dev/null +++ b/app/modules/billing/client_portal_service.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +from decimal import Decimal +from typing import Any +from urllib.parse import quote + +from sqlalchemy import or_, select +from sqlalchemy.orm import Session, selectinload + +from app.modules.billing.models import BillingInvoice, BillingInvoiceLine, BillingPayment +from app.modules.billing.services import build_invoice_print_context, is_cashfree_ready, is_payumoney_ready, money + +CLIENT_VISIBLE_INVOICE_STATUSES = {"ISSUED", "PARTLY_PAID", "PAID", "OVERDUE"} + + +def _client_ids(client_row: Any) -> tuple[int, int]: + """Return (tenant_id, client_id) from dict/row/model style client payload.""" + if isinstance(client_row, dict): + return int(client_row.get("tenant_id") or 0), int(client_row.get("id") or 0) + return int(getattr(client_row, "tenant_id", 0) or 0), int(getattr(client_row, "id", 0) or 0) + + +def list_client_portal_invoices(db: Session, client_row: Any, *, q: str = "", include_paid: bool = True, financial_year: str | None = None) -> list[BillingInvoice]: + tenant_id, client_id = _client_ids(client_row) + stmt = ( + select(BillingInvoice) + .options( + selectinload(BillingInvoice.lines).selectinload(BillingInvoiceLine.service), + selectinload(BillingInvoice.payments), + ) + .where( + BillingInvoice.tenant_id == tenant_id, + BillingInvoice.client_id == client_id, + BillingInvoice.status.in_(CLIENT_VISIBLE_INVOICE_STATUSES), + ) + ) + if not include_paid: + stmt = stmt.where(BillingInvoice.status != "PAID") + if financial_year and financial_year.upper() != "ALL": + stmt = stmt.where(BillingInvoice.financial_year == financial_year) + if q.strip(): + term = f"%{q.strip()}%" + stmt = stmt.where(or_(BillingInvoice.invoice_no.ilike(term), BillingInvoice.invoice_title.ilike(term))) + return db.execute(stmt.order_by(BillingInvoice.invoice_date.desc(), BillingInvoice.id.desc())).scalars().unique().all() + + +def get_client_portal_invoice(db: Session, client_row: Any, invoice_id: int, *, financial_year: str | None = None) -> BillingInvoice | None: + tenant_id, client_id = _client_ids(client_row) + stmt = ( + select(BillingInvoice) + .options( + selectinload(BillingInvoice.client), + selectinload(BillingInvoice.lines).selectinload(BillingInvoiceLine.service), + selectinload(BillingInvoice.payments), + ) + .where( + BillingInvoice.id == invoice_id, + BillingInvoice.tenant_id == tenant_id, + BillingInvoice.client_id == client_id, + BillingInvoice.status.in_(CLIENT_VISIBLE_INVOICE_STATUSES), + ) + ) + if financial_year and financial_year.upper() != "ALL": + stmt = stmt.where(BillingInvoice.financial_year == financial_year) + return db.execute(stmt).scalars().unique().one_or_none() + + +def get_client_portal_payment(db: Session, client_row: Any, payment_id: int, *, financial_year: str | None = None) -> BillingPayment | None: + tenant_id, client_id = _client_ids(client_row) + stmt = ( + select(BillingPayment) + .options( + selectinload(BillingPayment.invoice).selectinload(BillingInvoice.lines), + selectinload(BillingPayment.client), + ) + .where( + BillingPayment.id == payment_id, + BillingPayment.tenant_id == tenant_id, + BillingPayment.client_id == client_id, + BillingPayment.status == "RECEIVED", + ) + ) + if financial_year and financial_year.upper() != "ALL": + stmt = stmt.where(BillingPayment.financial_year == financial_year) + return db.execute(stmt).scalars().unique().one_or_none() + + +def build_client_billing_summary(db: Session, client_row: Any, *, financial_year: str | None = None) -> dict[str, Any]: + invoices = list_client_portal_invoices(db, client_row, include_paid=True, financial_year=financial_year) + open_invoices = [row for row in invoices if row.status in {"ISSUED", "PARTLY_PAID", "OVERDUE"} and money(row.balance_amount) > Decimal("0.00")] + paid_invoices = [row for row in invoices if row.status == "PAID"] + outstanding = sum((money(row.balance_amount) for row in open_invoices), Decimal("0.00")) + latest_invoice = invoices[0] if invoices else None + latest_due_invoice = open_invoices[0] if open_invoices else None + return { + "billing_invoices": invoices, + "billing_open_invoices": open_invoices, + "billing_paid_invoices": paid_invoices, + "billing_outstanding_amount": money(outstanding), + "billing_latest_invoice": latest_invoice, + "billing_latest_due_invoice": latest_due_invoice, + "billing_open_count": len(open_invoices), + "billing_paid_count": len(paid_invoices), + "billing_total_count": len(invoices), + } + + +def build_client_payment_context(db: Session, invoice: BillingInvoice) -> dict[str, Any]: + invoice_ctx = build_invoice_print_context(db, invoice) + settings = invoice_ctx.get("settings") + amount_due = money(invoice.balance_amount) + firm_name = invoice_ctx.get("firm_name") or "Audit Firm" + upi_id = getattr(settings, "upi_id", None) if settings else None + upi_link = None + if upi_id and amount_due > Decimal("0.00"): + upi_link = ( + "upi://pay?" + f"pa={quote(str(upi_id))}" + f"&pn={quote(str(firm_name))}" + f"&am={quote(str(amount_due))}" + "&cu=INR" + f"&tn={quote('Invoice ' + str(invoice.invoice_no))}" + ) + return { + "invoice_ctx": invoice_ctx, + "amount_due": amount_due, + "upi_link": upi_link, + "upi_id": upi_id, + "bank_name": invoice_ctx.get("bank_name"), + "bank_account_name": invoice_ctx.get("bank_account_name"), + "bank_account_number": invoice_ctx.get("bank_account_number"), + "bank_ifsc": invoice_ctx.get("bank_ifsc"), + "payment_instructions": getattr(settings, "bank_details", None) if settings else None, + "payumoney_enabled": is_payumoney_ready(settings), + "payumoney_mode": getattr(settings, "payumoney_mode", "TEST") if settings else "TEST", + "cashfree_enabled": is_cashfree_ready(settings), + "cashfree_mode": getattr(settings, "cashfree_mode", "TEST") if settings else "TEST", + } diff --git a/app/modules/billing/models.py b/app/modules/billing/models.py new file mode 100644 index 0000000..3ec3365 --- /dev/null +++ b/app/modules/billing/models.py @@ -0,0 +1,320 @@ +from __future__ import annotations + +from datetime import date, datetime, timezone +from decimal import Decimal + +from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, Numeric, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.db.common import CommonBase + + +class BillingSettings(CommonBase): + __tablename__ = "billing_settings" + __table_args__ = ( + UniqueConstraint("tenant_id", "branch_id", name="uq_billing_settings_tenant_branch"), + ) + + 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) + branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True) + + invoice_prefix: Mapped[str] = mapped_column(String(40), nullable=False, default="INV") + next_invoice_no: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + padding: Mapped[int] = mapped_column(Integer, nullable=False, default=4) + default_gst_rate: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=Decimal("18.00")) + default_tax_type: Mapped[str] = mapped_column(String(20), nullable=False, default="CGST_SGST") + + legal_name: Mapped[str | None] = mapped_column(String(200), nullable=True) + gstin: Mapped[str | None] = mapped_column(String(20), nullable=True) + pan: Mapped[str | None] = mapped_column(String(10), nullable=True) + state_code: Mapped[str | None] = mapped_column(String(2), nullable=True) + billing_address: Mapped[str | None] = mapped_column(Text, nullable=True) + contact_email: Mapped[str | None] = mapped_column(String(255), nullable=True) + contact_mobile: Mapped[str | None] = mapped_column(String(50), nullable=True) + website_url: Mapped[str | None] = mapped_column(String(255), nullable=True) + + invoice_title: Mapped[str | None] = mapped_column(String(80), nullable=True) + invoice_number_format: Mapped[str | None] = mapped_column(String(120), nullable=True) + default_due_days: Mapped[int] = mapped_column(Integer, nullable=False, default=15) + default_sac_code: Mapped[str | None] = mapped_column(String(20), nullable=True) + + bank_name: Mapped[str | None] = mapped_column(String(200), nullable=True) + bank_account_name: Mapped[str | None] = mapped_column(String(200), nullable=True) + bank_account_number: Mapped[str | None] = mapped_column(String(50), nullable=True) + bank_ifsc: Mapped[str | None] = mapped_column(String(20), nullable=True) + upi_id: Mapped[str | None] = mapped_column(String(100), nullable=True) + bank_details: Mapped[str | None] = mapped_column(Text, nullable=True) + + payumoney_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + payumoney_mode: Mapped[str] = mapped_column(String(20), nullable=False, default="TEST") + payumoney_merchant_key: Mapped[str | None] = mapped_column(String(120), nullable=True) + payumoney_merchant_salt: Mapped[str | None] = mapped_column(String(200), nullable=True) + payumoney_merchant_id: Mapped[str | None] = mapped_column(String(120), nullable=True) + payumoney_product_info: Mapped[str | None] = mapped_column(String(200), nullable=True) + + cashfree_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + cashfree_mode: Mapped[str] = mapped_column(String(20), nullable=False, default="TEST") + cashfree_client_id: Mapped[str | None] = mapped_column(String(180), nullable=True) + cashfree_client_secret: Mapped[str | None] = mapped_column(String(240), nullable=True) + cashfree_api_version: Mapped[str] = mapped_column(String(20), nullable=False, default="2023-08-01") + cashfree_order_note: Mapped[str | None] = mapped_column(String(250), nullable=True) + + authorised_signatory_name: Mapped[str | None] = mapped_column(String(200), nullable=True) + declaration: Mapped[str | None] = mapped_column(Text, nullable=True) + terms: Mapped[str | None] = mapped_column(Text, nullable=True) + footer_note: Mapped[str | None] = mapped_column(Text, 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 BillingInvoiceGenerationBatch(CommonBase): + __tablename__ = "billing_invoice_generation_batches" + + 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) + branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True) + + billing_period_from: Mapped[date] = mapped_column(Date, nullable=False, index=True) + billing_period_to: Mapped[date] = mapped_column(Date, nullable=False, index=True) + financial_year: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True) + frequency: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True) + status: Mapped[str] = mapped_column(String(20), nullable=False, default="DRAFT_CREATED", index=True) + + selected_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + created_invoice_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + skipped_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + error_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + remarks: Mapped[str | None] = mapped_column(Text, nullable=True) + + generated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) + generated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) + + invoices = relationship("BillingInvoice", back_populates="generation_batch") + + +class BillingInvoice(CommonBase): + __tablename__ = "billing_invoices" + __table_args__ = ( + UniqueConstraint("tenant_id", "invoice_no", name="uq_billing_invoices_tenant_invoice_no"), + ) + + 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) + 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="RESTRICT"), nullable=False, index=True) + engagement_id: Mapped[int | None] = mapped_column(ForeignKey("client_service_subscriptions.id", ondelete="SET NULL"), nullable=True, index=True) + generation_batch_id: Mapped[int | None] = mapped_column(ForeignKey("billing_invoice_generation_batches.id", ondelete="SET NULL"), nullable=True, index=True) + + invoice_no: Mapped[str] = mapped_column(String(60), nullable=False, index=True) + invoice_date: Mapped[date] = mapped_column(Date, nullable=False, default=date.today, index=True) + due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True) + billing_period_from: Mapped[date | None] = mapped_column(Date, nullable=True) + billing_period_to: Mapped[date | None] = mapped_column(Date, nullable=True) + financial_year: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True) + + invoice_title: Mapped[str | None] = mapped_column(String(80), nullable=True) + place_of_supply: Mapped[str | None] = mapped_column(String(120), nullable=True) + reverse_charge: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + + client_legal_name: Mapped[str | None] = mapped_column(String(200), nullable=True) + client_trade_name: Mapped[str | None] = mapped_column(String(200), nullable=True) + client_gstin: Mapped[str | None] = mapped_column(String(20), nullable=True) + client_pan: Mapped[str | None] = mapped_column(String(20), nullable=True) + client_billing_address: Mapped[str | None] = mapped_column(Text, nullable=True) + client_state: Mapped[str | None] = mapped_column(String(100), nullable=True) + client_state_code: Mapped[str | None] = mapped_column(String(2), nullable=True) + client_email: Mapped[str | None] = mapped_column(String(255), nullable=True) + client_mobile: Mapped[str | None] = mapped_column(String(50), nullable=True) + + tax_type: Mapped[str] = mapped_column(String(20), nullable=False, default="CGST_SGST") + subtotal: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00")) + discount_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00")) + taxable_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00")) + cgst_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00")) + sgst_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00")) + igst_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00")) + round_off: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00")) + total_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00")) + amount_received: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00")) + tds_deducted: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00")) + bank_charges: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00")) + balance_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00")) + amount_in_words: Mapped[str | None] = mapped_column(String(500), nullable=True) + + status: Mapped[str] = mapped_column(String(20), nullable=False, default="DRAFT", index=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + terms: Mapped[str | None] = mapped_column(Text, nullable=True) + + created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) + approved_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) + posted_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) + updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False) + + client = relationship("Client") + engagement = relationship("ClientServiceSubscription", foreign_keys=[engagement_id]) + generation_batch = relationship("BillingInvoiceGenerationBatch", back_populates="invoices") + lines = relationship("BillingInvoiceLine", back_populates="invoice", cascade="all, delete-orphan", order_by="BillingInvoiceLine.sort_order.asc()") + payments = relationship("BillingPayment", back_populates="invoice", cascade="all, delete-orphan", order_by="BillingPayment.payment_date.desc(), BillingPayment.id.desc()") + online_transactions = relationship("BillingOnlinePaymentTransaction", back_populates="invoice", cascade="all, delete-orphan", order_by="BillingOnlinePaymentTransaction.created_at_utc.desc(), BillingOnlinePaymentTransaction.id.desc()") + + +class BillingPayment(CommonBase): + __tablename__ = "billing_payments" + __table_args__ = ( + UniqueConstraint("tenant_id", "receipt_no", name="uq_billing_payments_tenant_receipt_no"), + ) + + 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) + branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True) + invoice_id: Mapped[int] = mapped_column(ForeignKey("billing_invoices.id", ondelete="CASCADE"), nullable=False, index=True) + client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="RESTRICT"), nullable=False, index=True) + + receipt_no: Mapped[str] = mapped_column(String(60), nullable=False, index=True) + receipt_date: Mapped[date] = mapped_column(Date, nullable=False, default=date.today, index=True) + payment_date: Mapped[date] = mapped_column(Date, nullable=False, default=date.today, index=True) + financial_year: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True) + amount_received: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00")) + tds_deducted: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00")) + bank_charges: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00")) + mode: Mapped[str] = mapped_column(String(30), nullable=False, default="BANK") + reference_no: Mapped[str | None] = mapped_column(String(120), nullable=True) + payment_gateway: Mapped[str | None] = mapped_column(String(50), nullable=True) + gateway_transaction_id: Mapped[str | None] = mapped_column(String(120), nullable=True) + remarks: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[str] = mapped_column(String(20), nullable=False, default="RECEIVED", index=True) + + created_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) + + invoice = relationship("BillingInvoice", back_populates="payments") + client = relationship("Client") + + +class BillingOnlinePaymentTransaction(CommonBase): + __tablename__ = "billing_online_payment_transactions" + __table_args__ = ( + UniqueConstraint("tenant_id", "txnid", name="uq_billing_online_payment_tenant_txnid"), + ) + + 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) + branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True) + invoice_id: Mapped[int] = mapped_column(ForeignKey("billing_invoices.id", ondelete="CASCADE"), nullable=False, index=True) + client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="RESTRICT"), nullable=False, index=True) + + provider: Mapped[str] = mapped_column(String(40), nullable=False, default="PAYUMONEY", index=True) + mode: Mapped[str] = mapped_column(String(20), nullable=False, default="TEST") + txnid: Mapped[str] = mapped_column(String(80), nullable=False, index=True) + amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00")) + productinfo: Mapped[str | None] = mapped_column(String(250), nullable=True) + firstname: Mapped[str | None] = mapped_column(String(120), nullable=True) + email: Mapped[str | None] = mapped_column(String(255), nullable=True) + phone: Mapped[str | None] = mapped_column(String(50), nullable=True) + + payu_payment_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True) + cashfree_order_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True) + cashfree_cf_order_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True) + cashfree_payment_session_id: Mapped[str | None] = mapped_column(String(500), nullable=True) + cashfree_payment_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True) + webhook_event_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True) + bank_ref_num: Mapped[str | None] = mapped_column(String(120), nullable=True) + mihpayid: Mapped[str | None] = mapped_column(String(120), nullable=True) + status: Mapped[str] = mapped_column(String(30), nullable=False, default="INITIATED", index=True) + gateway_status: Mapped[str | None] = mapped_column(String(80), nullable=True) + response_hash: Mapped[str | None] = mapped_column(String(200), nullable=True) + raw_response: Mapped[str | None] = mapped_column(Text, nullable=True) + receipt_payment_id: Mapped[int | None] = mapped_column(ForeignKey("billing_payments.id", ondelete="SET NULL"), nullable=True, index=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) + completed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + invoice = relationship("BillingInvoice", back_populates="online_transactions") + client = relationship("Client") + receipt_payment = relationship("BillingPayment", foreign_keys=[receipt_payment_id]) + + +class BillingInvoiceLine(CommonBase): + __tablename__ = "billing_invoice_lines" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + invoice_id: Mapped[int] = mapped_column(ForeignKey("billing_invoices.id", ondelete="CASCADE"), nullable=False, index=True) + service_id: Mapped[int | None] = mapped_column(ForeignKey("service_catalogues.id", ondelete="SET NULL"), nullable=True, index=True) + engagement_id: Mapped[int | None] = mapped_column(ForeignKey("client_service_subscriptions.id", ondelete="SET NULL"), nullable=True, index=True) + fee_group_id: Mapped[int | None] = mapped_column(ForeignKey("billing_fee_groups.id", ondelete="SET NULL"), nullable=True, index=True) + + description: Mapped[str] = mapped_column(String(500), nullable=False) + sac_code: Mapped[str | None] = mapped_column(String(20), nullable=True) + billing_period_from: Mapped[date | None] = mapped_column(Date, nullable=True) + billing_period_to: Mapped[date | None] = mapped_column(Date, nullable=True) + quantity: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False, default=Decimal("1.00")) + rate: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00")) + discount_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00")) + taxable_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00")) + gst_rate: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=Decimal("18.00")) + cgst_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00")) + sgst_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00")) + igst_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00")) + line_total: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00")) + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + + invoice = relationship("BillingInvoice", back_populates="lines") + service = relationship("ServiceCatalogue") + + +class BillingFeeGroup(CommonBase): + __tablename__ = "billing_fee_groups" + __table_args__ = ( + UniqueConstraint("tenant_id", "group_code", name="uq_billing_fee_groups_tenant_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) + 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) + partner_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + + group_code: Mapped[str] = mapped_column(String(80), nullable=False, index=True) + group_name: Mapped[str] = mapped_column(String(200), nullable=False) + billing_mode: Mapped[str] = mapped_column(String(20), nullable=False, default="PACKAGE") + frequency: Mapped[str] = mapped_column(String(20), nullable=False, default="Monthly") + fee_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00")) + gst_rate: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=Decimal("18.00")) + tax_type: Mapped[str] = mapped_column(String(20), nullable=False, default="CGST_SGST") + effective_from: Mapped[date | None] = mapped_column(Date, nullable=True) + effective_to: Mapped[date | None] = mapped_column(Date, nullable=True) + auto_generate: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=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) + + client = relationship("Client") + services = relationship("BillingFeeGroupService", back_populates="fee_group", cascade="all, delete-orphan", order_by="BillingFeeGroupService.sort_order.asc()") + + +class BillingFeeGroupService(CommonBase): + __tablename__ = "billing_fee_group_services" + __table_args__ = ( + UniqueConstraint("fee_group_id", "service_id", name="uq_billing_fee_group_services_group_service"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + fee_group_id: Mapped[int] = mapped_column(ForeignKey("billing_fee_groups.id", ondelete="CASCADE"), nullable=False, index=True) + service_id: Mapped[int] = mapped_column(ForeignKey("service_catalogues.id", ondelete="RESTRICT"), nullable=False, index=True) + line_description: Mapped[str | None] = mapped_column(String(500), nullable=True) + allocation_type: Mapped[str] = mapped_column(String(20), nullable=False, default="Included") + line_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00")) + percentage: Mapped[Decimal | None] = mapped_column(Numeric(5, 2), nullable=True) + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + + fee_group = relationship("BillingFeeGroup", back_populates="services") + service = relationship("ServiceCatalogue") diff --git a/app/modules/billing/services.py b/app/modules/billing/services.py new file mode 100644 index 0000000..7c85952 --- /dev/null +++ b/app/modules/billing/services.py @@ -0,0 +1,1372 @@ +from __future__ import annotations + +from datetime import date, datetime, timezone +from decimal import Decimal, InvalidOperation, ROUND_HALF_UP +from io import BytesIO +from typing import Any + +from openpyxl import Workbook, load_workbook +from sqlalchemy import and_, or_, select +from sqlalchemy.orm import Session, selectinload + +from app.modules.billing.models import ( + BillingFeeGroup, + BillingFeeGroupService, + BillingInvoice, + BillingInvoiceLine, + BillingInvoiceGenerationBatch, + BillingPayment, + BillingSettings, + BillingOnlinePaymentTransaction, +) +from app.modules.clients.models import Client +from app.modules.email_integration.event_service import send_invoice_issued_email, send_payment_receipt_email +from app.modules.services.models import ClientServiceSubscription, ServiceCatalogue + +TAX_TYPES = ["CGST_SGST", "IGST", "NO_GST"] +BILLING_MODES = ["PACKAGE", "SERVICE_WISE"] +FREQUENCIES = ["Monthly", "Quarterly", "Half-Yearly", "Yearly", "One-time"] +INVOICE_STATUSES = ["DRAFT", "ISSUED", "PARTLY_PAID", "PAID", "OVERDUE", "CANCELLED", "WRITTEN_OFF"] +PAYMENT_MODES = ["CASH", "BANK", "UPI", "CHEQUE", "ONLINE", "ADJUSTMENT"] +PAYMENT_STATUSES = ["RECEIVED", "CANCELLED", "REFUNDED"] + + +def money(value: Any) -> Decimal: + try: + if value in (None, ""): + return Decimal("0.00") + return Decimal(str(value)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + except (InvalidOperation, ValueError): + return Decimal("0.00") + + +def parse_date(value: Any) -> date | None: + if value in (None, ""): + return None + if isinstance(value, datetime): + return value.date() + if isinstance(value, date): + return value + value = str(value).strip() + if not value: + return None + return date.fromisoformat(value) + + +def normalize_code(value: Any) -> str: + return str(value or "").strip().upper().replace(" ", "-") + + +def normalize_yes_no(value: Any) -> bool: + return str(value or "").strip().lower() in {"yes", "y", "true", "1", "active"} + + +def get_or_create_settings(db: Session, *, tenant_id: int, branch_id: int | None = None) -> BillingSettings: + row = db.execute( + select(BillingSettings).where(BillingSettings.tenant_id == tenant_id, BillingSettings.branch_id == branch_id) + ).scalar_one_or_none() + if row: + return row + row = BillingSettings(tenant_id=tenant_id, branch_id=branch_id) + db.add(row) + db.flush() + return row + + +def _financial_year_label(value: date | None = None) -> str: + value = value or date.today() + start_year = value.year if value.month >= 4 else value.year - 1 + return f"{start_year}-{str(start_year + 1)[-2:]}" + + +def billing_financial_year(*, billing_period_from: date | None = None, invoice_date: date | None = None, fallback: date | None = None) -> str: + return _financial_year_label(billing_period_from or invoice_date or fallback or date.today()) + + +def next_invoice_number(db: Session, *, tenant_id: int, branch_id: int | None = None, financial_year: str | None = None) -> str: + settings = get_or_create_settings(db, tenant_id=tenant_id, branch_id=branch_id) + serial = str(settings.next_invoice_no).zfill(settings.padding or 4) + fy = financial_year or _financial_year_label() + fmt = (getattr(settings, "invoice_number_format", None) or "{prefix}/{fy}/{number}").strip() + try: + number = fmt.format(prefix=settings.invoice_prefix or "INV", fy=fy, number=serial, branch_id=branch_id or "") + except Exception: + number = f"{settings.invoice_prefix or 'INV'}/{fy}/{serial}" + settings.next_invoice_no += 1 + settings.updated_at_utc = datetime.now(timezone.utc) + return number + + +def preview_invoice_number(settings: BillingSettings, *, branch_id: int | None = None, financial_year: str | None = None) -> str: + serial = str((settings.next_invoice_no or 1)).zfill(settings.padding or 4) + fy = financial_year or _financial_year_label() + fmt = (getattr(settings, "invoice_number_format", None) or "{prefix}/{fy}/{number}").strip() + try: + return fmt.format(prefix=settings.invoice_prefix or "INV", fy=fy, number=serial, branch_id=branch_id or "") + except Exception: + return f"{settings.invoice_prefix or 'INV'}/{fy}/{serial}" + + + +_ONES = ["", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten", "Eleven", "Twelve", "Thirteen", "Fourteen", "Fifteen", "Sixteen", "Seventeen", "Eighteen", "Nineteen"] +_TENS = ["", "", "Twenty", "Thirty", "Forty", "Fifty", "Sixty", "Seventy", "Eighty", "Ninety"] + + +def _words_below_1000(n: int) -> str: + parts: list[str] = [] + if n >= 100: + parts.append(_ONES[n // 100] + " Hundred") + n %= 100 + if n >= 20: + parts.append(_TENS[n // 10]) + n %= 10 + if n > 0: + parts.append(_ONES[n]) + return " ".join(parts) + + +def amount_to_indian_words(value: Any) -> str: + amount = money(value) + rupees = int(amount) + paise = int((amount - Decimal(rupees)) * 100) + if rupees == 0: + words = "Zero" + else: + parts: list[str] = [] + crore, rupees = divmod(rupees, 10000000) + lakh, rupees = divmod(rupees, 100000) + thousand, rupees = divmod(rupees, 1000) + if crore: + parts.append(_words_below_1000(crore) + " Crore") + if lakh: + parts.append(_words_below_1000(lakh) + " Lakh") + if thousand: + parts.append(_words_below_1000(thousand) + " Thousand") + if rupees: + parts.append(_words_below_1000(rupees)) + words = " ".join(parts) + result = f"Rupees {words} Only" + if paise: + result = f"Rupees {words} and Paise {_words_below_1000(paise)} Only" + return result + + +def _client_address_snapshot(client: Client) -> str | None: + parts = [ + getattr(client, "address_line_1", None), + getattr(client, "address_line_2", None), + getattr(client, "city", None), + getattr(client, "state", None), + getattr(client, "pincode", None), + getattr(client, "country", None), + ] + return ", ".join([str(p).strip() for p in parts if str(p or "").strip()]) or None + + +def _state_code_from_gstin(gstin: str | None) -> str | None: + value = str(gstin or "").strip() + if len(value) >= 2 and value[:2].isdigit(): + return value[:2] + return None + + +def get_effective_billing_settings(db: Session, *, tenant_id: int, branch_id: int | None = None) -> BillingSettings: + if branch_id: + row = db.execute(select(BillingSettings).where(BillingSettings.tenant_id == tenant_id, BillingSettings.branch_id == branch_id)).scalar_one_or_none() + if row: + return row + return get_or_create_settings(db, tenant_id=tenant_id, branch_id=None) + + +def build_invoice_print_context(db: Session, invoice: BillingInvoice) -> dict[str, Any]: + settings = get_effective_billing_settings(db, tenant_id=invoice.tenant_id, branch_id=invoice.branch_id) + return { + "invoice": invoice, + "settings": settings, + "invoice_title": invoice.invoice_title or settings.invoice_title or "Tax Invoice", + "firm_name": settings.legal_name or "Audit Firm", + "firm_address": settings.billing_address, + "firm_gstin": settings.gstin, + "firm_pan": settings.pan, + "firm_state_code": settings.state_code, + "firm_contact_email": settings.contact_email, + "firm_contact_mobile": settings.contact_mobile, + "firm_website": settings.website_url, + "bank_name": settings.bank_name, + "bank_account_name": settings.bank_account_name, + "bank_account_number": settings.bank_account_number, + "bank_ifsc": settings.bank_ifsc, + "upi_id": settings.upi_id, + "bank_details": settings.bank_details, + "declaration": invoice.notes or settings.declaration, + "terms": invoice.terms or settings.terms, + "footer_note": settings.footer_note, + "authorised_signatory_name": settings.authorised_signatory_name, + } + +def calculate_line(*, quantity: Decimal, rate: Decimal, discount: Decimal, gst_rate: Decimal, tax_type: str) -> dict[str, Decimal]: + taxable = (quantity * rate - discount).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + if taxable < 0: + taxable = Decimal("0.00") + cgst = sgst = igst = Decimal("0.00") + if tax_type == "IGST": + igst = (taxable * gst_rate / Decimal("100.00")).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + elif tax_type == "CGST_SGST": + half = (taxable * gst_rate / Decimal("200.00")).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + cgst = half + sgst = half + total = taxable + cgst + sgst + igst + return {"taxable": taxable, "cgst": cgst, "sgst": sgst, "igst": igst, "total": total} + + +def recalc_invoice(invoice: BillingInvoice) -> None: + subtotal = Decimal("0.00") + discount = Decimal("0.00") + taxable = Decimal("0.00") + cgst = Decimal("0.00") + sgst = Decimal("0.00") + igst = Decimal("0.00") + total = Decimal("0.00") + for line in invoice.lines: + subtotal += money(line.quantity) * money(line.rate) + discount += money(line.discount_amount) + taxable += money(line.taxable_amount) + cgst += money(line.cgst_amount) + sgst += money(line.sgst_amount) + igst += money(line.igst_amount) + total += money(line.line_total) + invoice.subtotal = money(subtotal) + invoice.discount_amount = money(discount) + invoice.taxable_amount = money(taxable) + invoice.cgst_amount = money(cgst) + invoice.sgst_amount = money(sgst) + invoice.igst_amount = money(igst) + invoice.total_amount = money(total + money(invoice.round_off)) + update_invoice_payment_totals(invoice) + + +def update_invoice_payment_totals(invoice: BillingInvoice) -> None: + paid = Decimal("0.00") + tds = Decimal("0.00") + charges = Decimal("0.00") + for payment in getattr(invoice, "payments", []) or []: + if getattr(payment, "status", "RECEIVED") != "CANCELLED": + paid += money(payment.amount_received) + tds += money(payment.tds_deducted) + charges += money(payment.bank_charges) + invoice.amount_received = money(paid) + invoice.tds_deducted = money(tds) + invoice.bank_charges = money(charges) + invoice.balance_amount = money(money(invoice.total_amount) - paid - tds) + if invoice.balance_amount < Decimal("0.00"): + invoice.balance_amount = Decimal("0.00") + if invoice.status not in {"DRAFT", "CANCELLED", "WRITTEN_OFF"}: + if invoice.balance_amount <= Decimal("0.00") and money(invoice.total_amount) > Decimal("0.00"): + invoice.status = "PAID" + elif paid > Decimal("0.00") or tds > Decimal("0.00"): + invoice.status = "PARTLY_PAID" + elif invoice.status == "PAID": + invoice.status = "ISSUED" + + +def next_receipt_number(db: Session, *, tenant_id: int, branch_id: int | None = None, financial_year: str | None = None) -> str: + fy = financial_year or _financial_year_label() + prefix = "RCT" + like_prefix = f"{prefix}/{fy}/%" + last = db.execute( + select(BillingPayment.receipt_no) + .where(BillingPayment.tenant_id == tenant_id, BillingPayment.receipt_no.ilike(like_prefix)) + .order_by(BillingPayment.id.desc()) + ).scalar_one_or_none() + next_no = 1 + if last: + try: + next_no = int(str(last).split("/")[-1]) + 1 + except Exception: + next_no = 1 + return f"{prefix}/{fy}/{str(next_no).zfill(4)}" + + +def record_invoice_payment( + db: Session, + *, + invoice: BillingInvoice, + payment_date: date, + amount_received: Decimal, + tds_deducted: Decimal = Decimal("0.00"), + bank_charges: Decimal = Decimal("0.00"), + mode: str = "BANK", + reference_no: str | None = None, + remarks: str | None = None, + created_by_user_id: int | None = None, + payment_gateway: str | None = None, + gateway_transaction_id: str | None = None, +) -> BillingPayment: + if invoice.status in {"DRAFT", "CANCELLED"}: + raise ValueError("Payment can be recorded only after invoice is issued.") + mode = mode if mode in PAYMENT_MODES else "BANK" + payment = BillingPayment( + tenant_id=invoice.tenant_id, + branch_id=invoice.branch_id, + invoice_id=invoice.id, + client_id=invoice.client_id, + receipt_no=next_receipt_number(db, tenant_id=invoice.tenant_id, branch_id=invoice.branch_id, financial_year=getattr(invoice, "financial_year", None)), + receipt_date=payment_date, + payment_date=payment_date, + financial_year=getattr(invoice, "financial_year", None) or billing_financial_year(invoice_date=payment_date), + amount_received=money(amount_received), + tds_deducted=money(tds_deducted), + bank_charges=money(bank_charges), + mode=mode, + reference_no=(reference_no or "").strip() or None, + payment_gateway=(payment_gateway or "").strip() or None, + gateway_transaction_id=(gateway_transaction_id or "").strip() or None, + remarks=(remarks or "").strip() or None, + created_by_user_id=created_by_user_id, + status="RECEIVED", + ) + db.add(payment) + db.flush() + if payment not in invoice.payments: + invoice.payments.append(payment) + update_invoice_payment_totals(invoice) + invoice.updated_at_utc = datetime.now(timezone.utc) + db.flush() + try: + send_payment_receipt_email(db, payment) + except Exception: + # Email failure should not block payment posting or receipt generation. + pass + return payment + + +def list_payments(db: Session, *, tenant_id: int, branch_id: int | None = None, partner_id: int | None = None, financial_year: str | None = None, q: str = ""): + stmt = select(BillingPayment).options(selectinload(BillingPayment.invoice), selectinload(BillingPayment.client)).where(BillingPayment.tenant_id == tenant_id) + if branch_id: + stmt = stmt.where(BillingPayment.branch_id == branch_id) + if partner_id: + stmt = stmt.join(Client, Client.id == BillingPayment.client_id).where(Client.partner_id == partner_id) + if financial_year and financial_year.upper() != "ALL": + stmt = stmt.where(BillingPayment.financial_year == financial_year) + if q.strip(): + term = f"%{q.strip()}%" + stmt = stmt.join(BillingInvoice, BillingInvoice.id == BillingPayment.invoice_id).join(Client, Client.id == BillingPayment.client_id).where(or_(BillingPayment.receipt_no.ilike(term), BillingInvoice.invoice_no.ilike(term), Client.client_name.ilike(term), Client.client_code.ilike(term))) + return db.execute(stmt.order_by(BillingPayment.payment_date.desc(), BillingPayment.id.desc())).scalars().unique().all() + + +def get_payment(db: Session, *, payment_id: int, tenant_id: int, partner_id: int | None = None, financial_year: str | None = None) -> BillingPayment | None: + stmt = select(BillingPayment).options(selectinload(BillingPayment.invoice).selectinload(BillingInvoice.lines), selectinload(BillingPayment.client)).where(BillingPayment.id == payment_id, BillingPayment.tenant_id == tenant_id) + if partner_id: + stmt = stmt.join(Client, Client.id == BillingPayment.client_id).where(Client.partner_id == partner_id) + if financial_year and financial_year.upper() != "ALL": + stmt = stmt.where(BillingPayment.financial_year == financial_year) + return db.execute(stmt).scalars().unique().one_or_none() + + +def list_clients_for_billing(db: Session, *, tenant_id: int, branch_id: int | None = None, partner_id: int | None = None, q: str = ""): + stmt = select(Client).where(Client.tenant_id == tenant_id, Client.is_archived.is_(False)) + if branch_id: + stmt = stmt.where(Client.branch_id == branch_id) + if partner_id: + stmt = stmt.where(Client.partner_id == partner_id) + if q.strip(): + term = f"%{q.strip()}%" + stmt = stmt.where(or_(Client.client_name.ilike(term), Client.client_code.ilike(term), Client.pan.ilike(term), Client.gstin.ilike(term))) + return db.execute(stmt.order_by(Client.client_name.asc())).scalars().all() + + +def list_services_for_billing(db: Session): + return db.execute( + select(ServiceCatalogue).where(ServiceCatalogue.is_active.is_(True)).order_by(ServiceCatalogue.service_name.asc()) + ).scalars().all() + + +def list_invoices(db: Session, *, tenant_id: int, branch_id: int | None = None, partner_id: int | None = None, financial_year: str | None = None, q: str = ""): + stmt = select(BillingInvoice).options(selectinload(BillingInvoice.client), selectinload(BillingInvoice.lines), selectinload(BillingInvoice.payments)).where(BillingInvoice.tenant_id == tenant_id) + if branch_id: + stmt = stmt.where(BillingInvoice.branch_id == branch_id) + if partner_id: + stmt = stmt.join(Client, Client.id == BillingInvoice.client_id).where(Client.partner_id == partner_id) + if financial_year and financial_year.upper() != "ALL": + stmt = stmt.where(BillingInvoice.financial_year == financial_year) + if q.strip(): + term = f"%{q.strip()}%" + stmt = stmt.join(Client, Client.id == BillingInvoice.client_id).where(or_(BillingInvoice.invoice_no.ilike(term), Client.client_name.ilike(term), Client.client_code.ilike(term))) + return db.execute(stmt.order_by(BillingInvoice.invoice_date.desc(), BillingInvoice.id.desc())).scalars().unique().all() + + +def build_billing_report_summary(db: Session, *, tenant_id: int, branch_id: int | None = None, partner_id: int | None = None, financial_year: str | None = None) -> dict[str, Any]: + invoices = list_invoices(db, tenant_id=tenant_id, branch_id=branch_id, partner_id=partner_id, financial_year=financial_year) + payments = list_payments(db, tenant_id=tenant_id, branch_id=branch_id, partner_id=partner_id, financial_year=financial_year) + total_billed = sum((money(row.total_amount) for row in invoices if row.status != "CANCELLED"), Decimal("0.00")) + total_received = sum((money(row.amount_received) for row in payments if row.status == "RECEIVED"), Decimal("0.00")) + total_tds = sum((money(row.tds_deducted) for row in payments if row.status == "RECEIVED"), Decimal("0.00")) + outstanding = sum((money(row.balance_amount) for row in invoices if row.status in {"ISSUED", "PARTLY_PAID", "OVERDUE"}), Decimal("0.00")) + draft_count = sum(1 for row in invoices if row.status == "DRAFT") + issued_count = sum(1 for row in invoices if row.status in {"ISSUED", "PARTLY_PAID", "OVERDUE"}) + paid_count = sum(1 for row in invoices if row.status == "PAID") + return { + "invoice_count": len(invoices), + "draft_count": draft_count, + "issued_count": issued_count, + "paid_count": paid_count, + "payment_count": len(payments), + "total_billed": money(total_billed), + "total_received": money(total_received), + "total_tds": money(total_tds), + "total_collected_with_tds": money(total_received + total_tds), + "outstanding": money(outstanding), + } + + +def get_invoice(db: Session, *, invoice_id: int, tenant_id: int, partner_id: int | None = None, financial_year: str | None = None) -> BillingInvoice | None: + stmt = select(BillingInvoice).options(selectinload(BillingInvoice.client), selectinload(BillingInvoice.lines).selectinload(BillingInvoiceLine.service), selectinload(BillingInvoice.payments)).where(BillingInvoice.id == invoice_id, BillingInvoice.tenant_id == tenant_id) + if partner_id: + stmt = stmt.join(Client, Client.id == BillingInvoice.client_id).where(Client.partner_id == partner_id) + if financial_year and financial_year.upper() != "ALL": + stmt = stmt.where(BillingInvoice.financial_year == financial_year) + return db.execute(stmt).scalars().unique().one_or_none() + + +def create_invoice( + db: Session, + *, + tenant_id: int, + branch_id: int | None, + client_id: int, + invoice_date: date, + due_date: date | None, + billing_period_from: date | None, + billing_period_to: date | None, + tax_type: str, + notes: str | None, + terms: str | None, + place_of_supply: str | None = None, + client_state_code: str | None = None, + reverse_charge: bool = False, + created_by_user_id: int, + raw_lines: list[dict[str, Any]], + generation_batch_id: int | None = None, + engagement_id: int | None = None, + financial_year: str | None = None, +) -> BillingInvoice: + settings = get_effective_billing_settings(db, tenant_id=tenant_id, branch_id=branch_id) + client = db.get(Client, client_id) + if client is None: + raise ValueError("Client not found.") + snapshot_state_code = (client_state_code or _state_code_from_gstin(getattr(client, "gstin", None)) or "").strip()[:2] or None + invoice_financial_year = financial_year or billing_financial_year(billing_period_from=billing_period_from, invoice_date=invoice_date) + invoice = BillingInvoice( + tenant_id=tenant_id, + branch_id=branch_id, + client_id=client_id, + engagement_id=engagement_id, + invoice_no=next_invoice_number(db, tenant_id=tenant_id, branch_id=branch_id, financial_year=invoice_financial_year), + invoice_date=invoice_date, + due_date=due_date, + billing_period_from=billing_period_from, + billing_period_to=billing_period_to, + financial_year=invoice_financial_year, + invoice_title=settings.invoice_title or "Tax Invoice", + place_of_supply=(place_of_supply or getattr(client, "state", None) or "").strip() or None, + reverse_charge=bool(reverse_charge), + client_legal_name=getattr(client, "client_name", None), + client_trade_name=getattr(client, "trade_name", None), + client_gstin=(getattr(client, "gstin", None) or "").strip().upper() or None, + client_pan=(getattr(client, "pan", None) or "").strip().upper() or None, + client_billing_address=_client_address_snapshot(client), + client_state=getattr(client, "state", None), + client_state_code=snapshot_state_code, + client_email=getattr(client, "email", None), + client_mobile=getattr(client, "mobile", None), + tax_type=tax_type if tax_type in TAX_TYPES else (settings.default_tax_type if settings.default_tax_type in TAX_TYPES else "CGST_SGST"), + notes=(notes or "").strip() or None, + terms=(terms or settings.terms or "").strip() or None, + created_by_user_id=created_by_user_id, + generation_batch_id=generation_batch_id, + status="DRAFT", + balance_amount=Decimal("0.00"), + ) + db.add(invoice) + db.flush() + + sort_order = 1 + for raw in raw_lines: + description = str(raw.get("description") or "").strip() + if not description: + continue + qty = money(raw.get("quantity") or 1) + if qty <= 0: + qty = Decimal("1.00") + rate = money(raw.get("rate")) + disc = money(raw.get("discount_amount")) + gst_rate = money(raw.get("gst_rate") or settings.default_gst_rate) + sac_code = str(raw.get("sac_code") or settings.default_sac_code or "").strip()[:20] or None + calc = calculate_line(quantity=qty, rate=rate, discount=disc, gst_rate=gst_rate, tax_type=invoice.tax_type) + service_id = raw.get("service_id") or None + fee_group_id = raw.get("fee_group_id") or None + raw_engagement_id = raw.get("engagement_id") or None + line = BillingInvoiceLine( + invoice_id=invoice.id, + service_id=int(service_id) if service_id else None, + fee_group_id=int(fee_group_id) if fee_group_id else None, + engagement_id=int(raw_engagement_id) if raw_engagement_id else None, + description=description, + sac_code=sac_code, + billing_period_from=billing_period_from, + billing_period_to=billing_period_to, + quantity=qty, + rate=rate, + discount_amount=disc, + taxable_amount=calc["taxable"], + gst_rate=gst_rate, + cgst_amount=calc["cgst"], + sgst_amount=calc["sgst"], + igst_amount=calc["igst"], + line_total=calc["total"], + sort_order=sort_order, + ) + db.add(line) + invoice.lines.append(line) + sort_order += 1 + + if sort_order == 1: + raise ValueError("At least one invoice line with description is required.") + recalc_invoice(invoice) + invoice.amount_in_words = amount_to_indian_words(invoice.total_amount) + db.flush() + return invoice + + +def issue_invoice(db: Session, invoice: BillingInvoice, *, user_id: int | None = None) -> BillingInvoice: + issued_now = False + if invoice.status == "DRAFT": + invoice.status = "ISSUED" + invoice.approved_by_user_id = user_id + invoice.posted_at_utc = datetime.now(timezone.utc) + update_invoice_payment_totals(invoice) + invoice.updated_at_utc = datetime.now(timezone.utc) + issued_now = True + if issued_now: + db.flush() + try: + send_invoice_issued_email(db, invoice) + except Exception: + # Email failure should be recorded in email logs and must not block invoice issue. + pass + return invoice + + +def list_fee_groups(db: Session, *, tenant_id: int, branch_id: int | None = None, partner_id: int | None = None, q: str = ""): + stmt = select(BillingFeeGroup).options(selectinload(BillingFeeGroup.client), selectinload(BillingFeeGroup.services).selectinload(BillingFeeGroupService.service)).where(BillingFeeGroup.tenant_id == tenant_id) + if branch_id: + stmt = stmt.where(BillingFeeGroup.branch_id == branch_id) + if partner_id: + stmt = stmt.where(BillingFeeGroup.partner_id == partner_id) + if q.strip(): + term = f"%{q.strip()}%" + stmt = stmt.join(Client, Client.id == BillingFeeGroup.client_id).where(or_(BillingFeeGroup.group_code.ilike(term), BillingFeeGroup.group_name.ilike(term), Client.client_name.ilike(term), Client.client_code.ilike(term))) + return db.execute(stmt.order_by(BillingFeeGroup.group_code.asc())).scalars().unique().all() + + + +def period_label(period_from: date, period_to: date) -> str: + if period_from.year == period_to.year and period_from.month == period_to.month: + return period_from.strftime("%B %Y") + return f"{period_from.isoformat()} to {period_to.isoformat()}" + + +def list_fee_groups_for_generation( + db: Session, + *, + tenant_id: int, + branch_id: int | None = None, + partner_id: int | None = None, + frequency: str | None = None, + auto_generate_only: bool = True, + q: str = "", +): + stmt = ( + select(BillingFeeGroup) + .options( + selectinload(BillingFeeGroup.client), + selectinload(BillingFeeGroup.services).selectinload(BillingFeeGroupService.service), + ) + .where(BillingFeeGroup.tenant_id == tenant_id, BillingFeeGroup.is_active.is_(True)) + ) + if branch_id: + stmt = stmt.where(BillingFeeGroup.branch_id == branch_id) + if partner_id: + stmt = stmt.where(BillingFeeGroup.partner_id == partner_id) + if frequency: + stmt = stmt.where(BillingFeeGroup.frequency == frequency) + if auto_generate_only: + stmt = stmt.where(BillingFeeGroup.auto_generate.is_(True)) + if q.strip(): + term = f"%{q.strip()}%" + stmt = stmt.join(Client, Client.id == BillingFeeGroup.client_id).where( + or_(BillingFeeGroup.group_code.ilike(term), BillingFeeGroup.group_name.ilike(term), Client.client_name.ilike(term), Client.client_code.ilike(term)) + ) + return db.execute(stmt.order_by(BillingFeeGroup.group_code.asc())).scalars().unique().all() + + +def fee_group_already_billed(db: Session, *, tenant_id: int, fee_group_id: int, period_from: date, period_to: date) -> BillingInvoice | None: + stmt = ( + select(BillingInvoice) + .join(BillingInvoiceLine, BillingInvoiceLine.invoice_id == BillingInvoice.id) + .where( + BillingInvoice.tenant_id == tenant_id, + BillingInvoice.status != "CANCELLED", + BillingInvoice.billing_period_from == period_from, + BillingInvoice.billing_period_to == period_to, + BillingInvoiceLine.fee_group_id == fee_group_id, + ) + .order_by(BillingInvoice.id.desc()) + ) + return db.execute(stmt).scalars().first() + + +def _financial_year_from_period(period_from: date) -> str: + return _financial_year_label(period_from) + + +def _billing_engagement_lookup( + db: Session, + *, + tenant_id: int, + branch_id: int | None, + client_ids: set[int], + service_ids: set[int], + financial_year: str, +) -> dict[tuple[int, int], ClientServiceSubscription]: + if not client_ids or not service_ids: + return {} + stmt = ( + select(ClientServiceSubscription) + .options(selectinload(ClientServiceSubscription.client), selectinload(ClientServiceSubscription.catalogue)) + .where( + ClientServiceSubscription.tenant_id == tenant_id, + ClientServiceSubscription.client_id.in_(client_ids), + ClientServiceSubscription.service_catalogue_id.in_(service_ids), + ClientServiceSubscription.financial_year == financial_year, + ClientServiceSubscription.is_active.is_(True), + ) + ) + if branch_id: + stmt = stmt.where(ClientServiceSubscription.branch_id == branch_id) + rows = db.execute(stmt.order_by(ClientServiceSubscription.id.desc())).scalars().unique().all() + lookup: dict[tuple[int, int], ClientServiceSubscription] = {} + for row in rows: + lookup.setdefault((row.client_id, row.service_catalogue_id), row) + return lookup + + +def _annotate_invoice_lines_with_engagements( + raw_lines: list[dict[str, Any]], + *, + client_id: int, + lookup: dict[tuple[int, int], ClientServiceSubscription], +) -> tuple[list[dict[str, Any]], int | None, list[ClientServiceSubscription]]: + engagement_ids: set[int] = set() + linked: list[ClientServiceSubscription] = [] + for line in raw_lines: + service_id = line.get("service_id") + if not service_id: + continue + subscription = lookup.get((client_id, int(service_id))) + if not subscription: + continue + line["engagement_id"] = subscription.id + engagement_ids.add(subscription.id) + linked.append(subscription) + invoice_engagement_id = next(iter(engagement_ids)) if len(engagement_ids) == 1 else None + return raw_lines, invoice_engagement_id, linked + + +def _fee_group_invoice_lines(fee_group: BillingFeeGroup, *, period_from: date, period_to: date) -> list[dict[str, Any]]: + label = period_label(period_from, period_to) + if fee_group.billing_mode == "SERVICE_WISE": + lines: list[dict[str, Any]] = [] + for item in sorted(fee_group.services, key=lambda x: x.sort_order or 100): + rate = money(item.line_amount) + if rate <= 0 and item.percentage is not None: + rate = (money(fee_group.fee_amount) * money(item.percentage) / Decimal("100.00")).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + description = item.line_description or (item.service.service_name if item.service else fee_group.group_name) + lines.append({ + "description": f"{description} - {label}"[:500], + "service_id": item.service_id, + "fee_group_id": fee_group.id, + "quantity": "1", + "rate": rate, + "discount_amount": "0", + "gst_rate": fee_group.gst_rate, + }) + if lines: + return lines + included = [] + for item in sorted(fee_group.services, key=lambda x: x.sort_order or 100): + if item.service: + included.append(item.line_description or item.service.service_name) + desc = f"{fee_group.group_name} - {label}" + if included: + desc = desc + "\nIncluded services: " + ", ".join(included) + return [{ + "description": desc[:500], + "service_id": None, + "fee_group_id": fee_group.id, + "quantity": "1", + "rate": fee_group.fee_amount, + "discount_amount": "0", + "gst_rate": fee_group.gst_rate, + }] + + +def generate_draft_invoices_from_fee_groups( + db: Session, + *, + tenant_id: int, + branch_id: int | None, + partner_id: int | None, + generated_by_user_id: int, + billing_period_from: date, + billing_period_to: date, + frequency: str | None, + fee_group_ids: list[int], + skip_duplicates: bool = True, +) -> dict[str, Any]: + if billing_period_to < billing_period_from: + raise ValueError("Billing Period To cannot be earlier than Billing Period From.") + if not fee_group_ids: + raise ValueError("Select at least one fee structure to generate invoices.") + + stmt = ( + select(BillingFeeGroup) + .options(selectinload(BillingFeeGroup.client), selectinload(BillingFeeGroup.services).selectinload(BillingFeeGroupService.service)) + .where(BillingFeeGroup.tenant_id == tenant_id, BillingFeeGroup.id.in_(fee_group_ids), BillingFeeGroup.is_active.is_(True)) + ) + if branch_id: + stmt = stmt.where(BillingFeeGroup.branch_id == branch_id) + if partner_id: + stmt = stmt.where(BillingFeeGroup.partner_id == partner_id) + if frequency: + stmt = stmt.where(BillingFeeGroup.frequency == frequency) + fee_groups = db.execute(stmt.order_by(BillingFeeGroup.group_code.asc())).scalars().unique().all() + + batch = BillingInvoiceGenerationBatch( + tenant_id=tenant_id, + branch_id=branch_id, + billing_period_from=billing_period_from, + billing_period_to=billing_period_to, + financial_year=billing_financial_year(billing_period_from=billing_period_from, invoice_date=billing_period_to), + frequency=frequency or None, + selected_count=len(fee_group_ids), + generated_by_user_id=generated_by_user_id, + status="DRAFT_CREATED", + ) + db.add(batch) + db.flush() + + created: list[BillingInvoice] = [] + skipped: list[str] = [] + errors: list[str] = [] + found_ids = {g.id for g in fee_groups} + financial_year = _financial_year_from_period(billing_period_from) + client_ids = {int(g.client_id) for g in fee_groups if g.client_id} + service_ids = {int(item.service_id) for g in fee_groups for item in (g.services or []) if item.service_id} + engagement_lookup = _billing_engagement_lookup( + db, + tenant_id=tenant_id, + branch_id=branch_id, + client_ids=client_ids, + service_ids=service_ids, + financial_year=financial_year, + ) + for missing_id in sorted(set(fee_group_ids) - found_ids): + skipped.append(f"Fee structure ID {missing_id} is not available in the active Audit Firm/Branch context.") + + for fee_group in fee_groups: + try: + existing = fee_group_already_billed( + db, + tenant_id=tenant_id, + fee_group_id=fee_group.id, + period_from=billing_period_from, + period_to=billing_period_to, + ) + if existing and skip_duplicates: + skipped.append(f"{fee_group.group_code}: already billed in invoice {existing.invoice_no}.") + continue + raw_lines = _fee_group_invoice_lines(fee_group, period_from=billing_period_from, period_to=billing_period_to) + raw_lines, invoice_engagement_id, linked_subscriptions = _annotate_invoice_lines_with_engagements( + raw_lines, + client_id=fee_group.client_id, + lookup=engagement_lookup, + ) + linked_note = "" + if linked_subscriptions: + linked_labels = [] + for sub in linked_subscriptions: + service_name = sub.catalogue.service_name if getattr(sub, "catalogue", None) else f"Service {sub.service_catalogue_id}" + linked_labels.append(f"{service_name} / {sub.financial_year}") + linked_note = " Linked service subscriptions: " + "; ".join(linked_labels[:5]) + "." + invoice = create_invoice( + db, + tenant_id=tenant_id, + branch_id=fee_group.branch_id or branch_id, + client_id=fee_group.client_id, + invoice_date=date.today(), + due_date=None, + billing_period_from=billing_period_from, + billing_period_to=billing_period_to, + tax_type=fee_group.tax_type if fee_group.tax_type in TAX_TYPES else "CGST_SGST", + notes=f"Draft generated from fee structure {fee_group.group_code}.{linked_note}", + terms=None, + created_by_user_id=generated_by_user_id, + raw_lines=raw_lines, + generation_batch_id=batch.id, + engagement_id=invoice_engagement_id, + financial_year=financial_year, + ) + created.append(invoice) + except Exception as exc: # keep batch generation resilient per client/package + errors.append(f"{fee_group.group_code}: {exc}") + + batch.created_invoice_count = len(created) + batch.skipped_count = len(skipped) + batch.error_count = len(errors) + if errors and created: + batch.status = "PARTIAL" + elif errors and not created: + batch.status = "FAILED" + batch.remarks = "\n".join(skipped + errors) or None + db.flush() + return {"batch": batch, "created": created, "skipped": skipped, "errors": errors} + +def build_fee_structure_template() -> bytes: + wb = Workbook() + ws = wb.active + ws.title = "Fee_Structure" + ws.append([ + "Client Code", "Billing Group Code", "Billing Group Name", "Billing Mode", "Frequency", "Fee Amount", + "GST Rate", "Tax Type", "Effective From", "Effective To", "Auto Generate", "Notes" + ]) + ws.append(["ABC001", "ABC-GST-MONTHLY", "Monthly GST Compliance", "PACKAGE", "Monthly", 2500, 18, "CGST_SGST", "2026-04-01", "", "Yes", "GSTR-1 and GSTR-3B package"]) + ws2 = wb.create_sheet("Fee_Services") + ws2.append(["Billing Group Code", "Service Code", "Line Description", "Allocation Type", "Line Amount", "Percentage", "Sort Order"]) + ws2.append(["ABC-GST-MONTHLY", "GSTR1", "GSTR-1 Filing", "Included", 0, "", 1]) + ws2.append(["ABC-GST-MONTHLY", "GSTR3B", "GSTR-3B Filing", "Included", 0, "", 2]) + bio = BytesIO() + wb.save(bio) + return bio.getvalue() + + +def import_fee_structure_excel(db: Session, *, tenant_id: int, branch_id: int | None, created_by_user_id: int, file_bytes: bytes) -> dict[str, Any]: + wb = load_workbook(BytesIO(file_bytes), data_only=True) + if "Fee_Structure" not in wb.sheetnames or "Fee_Services" not in wb.sheetnames: + raise ValueError("Excel must contain Fee_Structure and Fee_Services sheets.") + + clients = {c.client_code.strip().upper(): c for c in db.execute(select(Client).where(Client.tenant_id == tenant_id)).scalars().all()} + services = {s.service_code.strip().upper(): s for s in db.execute(select(ServiceCatalogue)).scalars().all()} + + ws = wb["Fee_Structure"] + header = [str(c.value or "").strip() for c in ws[1]] + rows = [] + errors: list[str] = [] + for idx, row in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2): + data = dict(zip(header, row)) + if not any(data.values()): + continue + client_code = normalize_code(data.get("Client Code")) + group_code = normalize_code(data.get("Billing Group Code")) + if not client_code or client_code not in clients: + errors.append(f"Fee_Structure row {idx}: Client Code not found: {client_code}") + continue + if not group_code: + errors.append(f"Fee_Structure row {idx}: Billing Group Code is required") + continue + mode = str(data.get("Billing Mode") or "PACKAGE").strip().upper().replace(" ", "_") + if mode not in BILLING_MODES: + errors.append(f"Fee_Structure row {idx}: Billing Mode must be PACKAGE or SERVICE_WISE") + continue + frequency = str(data.get("Frequency") or "Monthly").strip() or "Monthly" + rows.append({ + "client": clients[client_code], + "group_code": group_code, + "group_name": str(data.get("Billing Group Name") or group_code).strip(), + "billing_mode": mode, + "frequency": frequency, + "fee_amount": money(data.get("Fee Amount")), + "gst_rate": money(data.get("GST Rate") or 18), + "tax_type": str(data.get("Tax Type") or "CGST_SGST").strip().upper() if str(data.get("Tax Type") or "").strip().upper() in TAX_TYPES else "CGST_SGST", + "effective_from": parse_date(data.get("Effective From")), + "effective_to": parse_date(data.get("Effective To")), + "auto_generate": normalize_yes_no(data.get("Auto Generate")), + "notes": str(data.get("Notes") or "").strip() or None, + }) + + service_rows_by_group: dict[str, list[dict[str, Any]]] = {} + ws2 = wb["Fee_Services"] + header2 = [str(c.value or "").strip() for c in ws2[1]] + for idx, row in enumerate(ws2.iter_rows(min_row=2, values_only=True), start=2): + data = dict(zip(header2, row)) + if not any(data.values()): + continue + group_code = normalize_code(data.get("Billing Group Code")) + service_code = normalize_code(data.get("Service Code")) + if not group_code: + errors.append(f"Fee_Services row {idx}: Billing Group Code is required") + continue + if not service_code or service_code not in services: + errors.append(f"Fee_Services row {idx}: Service Code not found: {service_code}") + continue + service_rows_by_group.setdefault(group_code, []).append({ + "service": services[service_code], + "line_description": str(data.get("Line Description") or services[service_code].service_name).strip(), + "allocation_type": str(data.get("Allocation Type") or "Included").strip() or "Included", + "line_amount": money(data.get("Line Amount")), + "percentage": money(data.get("Percentage")) if data.get("Percentage") not in (None, "") else None, + "sort_order": int(data.get("Sort Order") or 100), + }) + + if errors: + return {"success": False, "created": 0, "updated": 0, "errors": errors} + + created = updated = 0 + for row in rows: + existing = db.execute(select(BillingFeeGroup).where(BillingFeeGroup.tenant_id == tenant_id, BillingFeeGroup.group_code == row["group_code"])).scalar_one_or_none() + if existing: + fee_group = existing + updated += 1 + else: + fee_group = BillingFeeGroup(tenant_id=tenant_id, group_code=row["group_code"], created_by_user_id=created_by_user_id) + db.add(fee_group) + created += 1 + fee_group.branch_id = branch_id or row["client"].branch_id + fee_group.client_id = row["client"].id + fee_group.partner_id = row["client"].partner_id + fee_group.group_name = row["group_name"] + fee_group.billing_mode = row["billing_mode"] + fee_group.frequency = row["frequency"] + fee_group.fee_amount = row["fee_amount"] + fee_group.gst_rate = row["gst_rate"] + fee_group.tax_type = row["tax_type"] + fee_group.effective_from = row["effective_from"] + fee_group.effective_to = row["effective_to"] + fee_group.auto_generate = row["auto_generate"] + fee_group.notes = row["notes"] + fee_group.updated_by_user_id = created_by_user_id + db.flush() + + for old in list(fee_group.services): + db.delete(old) + db.flush() + for service_row in service_rows_by_group.get(row["group_code"], []): + db.add(BillingFeeGroupService( + fee_group_id=fee_group.id, + service_id=service_row["service"].id, + line_description=service_row["line_description"], + allocation_type=service_row["allocation_type"], + line_amount=service_row["line_amount"], + percentage=service_row["percentage"], + sort_order=service_row["sort_order"], + )) + + db.commit() + return {"success": True, "created": created, "updated": updated, "errors": []} + +# --------------------------------------------------------------------------- +# Phase 7R.6 - PayUMoney / PayU redirect integration helpers +# --------------------------------------------------------------------------- +import base64 +import hashlib +import hmac +import json +from urllib.parse import urlencode +from urllib.request import Request as UrlRequest, urlopen +from urllib.error import HTTPError, URLError + +from app.modules.billing.models import BillingOnlinePaymentTransaction + +PAYUMONEY_PROVIDER = "PAYUMONEY" +PAYUMONEY_TEST_URL = "https://test.payu.in/_payment" +PAYUMONEY_PROD_URL = "https://secure.payu.in/_payment" +PAYUMONEY_MODES = ["TEST", "LIVE"] + + +def payumoney_checkout_url(settings: BillingSettings) -> str: + return PAYUMONEY_PROD_URL if str(getattr(settings, "payumoney_mode", "TEST")).upper() == "LIVE" else PAYUMONEY_TEST_URL + + +def is_payumoney_ready(settings: BillingSettings | None) -> bool: + return bool( + settings + and getattr(settings, "payumoney_enabled", False) + and (getattr(settings, "payumoney_merchant_key", None) or "").strip() + and (getattr(settings, "payumoney_merchant_salt", None) or "").strip() + ) + + +def generate_payumoney_hash(*, key: str, txnid: str, amount: str, productinfo: str, firstname: str, email: str, salt: str, udf1: str = "", udf2: str = "", udf3: str = "", udf4: str = "", udf5: str = "") -> str: + hash_string = f"{key}|{txnid}|{amount}|{productinfo}|{firstname}|{email}|{udf1}|{udf2}|{udf3}|{udf4}|{udf5}||||||{salt}" + return hashlib.sha512(hash_string.encode("utf-8")).hexdigest().lower() + + +def verify_payumoney_response_hash(*, response_data: dict[str, Any], salt: str, key: str) -> bool: + # PayU redirect response hash: salt|status||||||udf5|udf4|udf3|udf2|udf1|email|firstname|productinfo|amount|txnid|key + received_hash = str(response_data.get("hash") or "").strip().lower() + if not received_hash: + return False + status = str(response_data.get("status") or "") + txnid = str(response_data.get("txnid") or "") + amount = str(response_data.get("amount") or "") + productinfo = str(response_data.get("productinfo") or "") + firstname = str(response_data.get("firstname") or "") + email = str(response_data.get("email") or "") + udf1 = str(response_data.get("udf1") or "") + udf2 = str(response_data.get("udf2") or "") + udf3 = str(response_data.get("udf3") or "") + udf4 = str(response_data.get("udf4") or "") + udf5 = str(response_data.get("udf5") or "") + reverse = f"{salt}|{status}||||||{udf5}|{udf4}|{udf3}|{udf2}|{udf1}|{email}|{firstname}|{productinfo}|{amount}|{txnid}|{key}" + expected = hashlib.sha512(reverse.encode("utf-8")).hexdigest().lower() + return expected == received_hash + + +def create_payumoney_transaction(db: Session, *, invoice: BillingInvoice, settings: BillingSettings, base_url: str, client_ip: str | None = None) -> dict[str, Any]: + if not is_payumoney_ready(settings): + raise ValueError("PayUMoney is not enabled or merchant credentials are missing in Billing Settings.") + if invoice.status not in {"ISSUED", "PARTLY_PAID", "OVERDUE"} or money(invoice.balance_amount) <= Decimal("0.00"): + raise ValueError("Only issued invoices with balance can be paid online.") + + amount = f"{money(invoice.balance_amount):.2f}" + txnid = f"AF{invoice.tenant_id}I{invoice.id}T{int(datetime.now(timezone.utc).timestamp())}" + key = (settings.payumoney_merchant_key or "").strip() + salt = (settings.payumoney_merchant_salt or "").strip() + productinfo = (settings.payumoney_product_info or f"Invoice {invoice.invoice_no}").strip()[:250] + firstname = (invoice.client_legal_name or invoice.client_trade_name or getattr(invoice.client, "client_name", None) or "Client").strip()[:120] + email = (invoice.client_email or getattr(invoice.client, "email", None) or settings.contact_email or "no-reply@example.com").strip() + phone = (invoice.client_mobile or getattr(invoice.client, "mobile", None) or settings.contact_mobile or "9999999999").strip() + udf1, udf2, udf3, udf4, udf5 = str(invoice.id), str(invoice.client_id), str(invoice.tenant_id), str(invoice.branch_id or ""), "audit_firm_erp" + hash_value = generate_payumoney_hash(key=key, txnid=txnid, amount=amount, productinfo=productinfo, firstname=firstname, email=email, salt=salt, udf1=udf1, udf2=udf2, udf3=udf3, udf4=udf4, udf5=udf5) + + transaction = BillingOnlinePaymentTransaction( + tenant_id=invoice.tenant_id, + branch_id=invoice.branch_id, + invoice_id=invoice.id, + client_id=invoice.client_id, + provider=PAYUMONEY_PROVIDER, + mode=(settings.payumoney_mode or "TEST").upper(), + txnid=txnid, + amount=money(amount), + productinfo=productinfo, + firstname=firstname, + email=email, + phone=phone, + status="INITIATED", + gateway_status="created", + ) + db.add(transaction) + db.flush() + + surl = f"{base_url.rstrip('/')}/client/billing/payumoney/success" + furl = f"{base_url.rstrip('/')}/client/billing/payumoney/failure" + payload = { + "key": key, + "txnid": txnid, + "amount": amount, + "productinfo": productinfo, + "firstname": firstname, + "email": email, + "phone": phone, + "surl": surl, + "furl": furl, + "hash": hash_value, + "udf1": udf1, + "udf2": udf2, + "udf3": udf3, + "udf4": udf4, + "udf5": udf5, + } + if getattr(settings, "payumoney_merchant_id", None): + payload["merchant_id"] = settings.payumoney_merchant_id + return {"transaction": transaction, "payload": payload, "checkout_url": payumoney_checkout_url(settings)} + + +def get_online_transaction_by_txnid(db: Session, *, txnid: str) -> BillingOnlinePaymentTransaction | None: + return db.execute( + select(BillingOnlinePaymentTransaction) + .options(selectinload(BillingOnlinePaymentTransaction.invoice).selectinload(BillingInvoice.payments)) + .where(BillingOnlinePaymentTransaction.txnid == txnid) + ).scalars().unique().one_or_none() + + +def process_payumoney_response(db: Session, *, response_data: dict[str, Any]) -> BillingOnlinePaymentTransaction | None: + txnid = str(response_data.get("txnid") or "").strip() + if not txnid: + return None + transaction = get_online_transaction_by_txnid(db, txnid=txnid) + if not transaction: + return None + invoice = transaction.invoice + settings = get_effective_billing_settings(db, tenant_id=invoice.tenant_id, branch_id=invoice.branch_id) + hash_ok = verify_payumoney_response_hash(response_data=response_data, salt=(settings.payumoney_merchant_salt or ""), key=(settings.payumoney_merchant_key or "")) + gateway_status = str(response_data.get("status") or "").lower() + transaction.gateway_status = gateway_status or None + transaction.response_hash = str(response_data.get("hash") or "") or None + transaction.payu_payment_id = str(response_data.get("payuMoneyId") or response_data.get("payu_money_id") or "") or None + transaction.mihpayid = str(response_data.get("mihpayid") or "") or None + transaction.bank_ref_num = str(response_data.get("bank_ref_num") or response_data.get("bank_ref_no") or "") or None + transaction.raw_response = json.dumps({k: str(v) for k, v in response_data.items()}, ensure_ascii=False) + transaction.updated_at_utc = datetime.now(timezone.utc) + + if not hash_ok: + transaction.status = "HASH_FAILED" + return transaction + if gateway_status == "success": + transaction.status = "SUCCESS" + transaction.completed_at_utc = datetime.now(timezone.utc) + if not transaction.receipt_payment_id: + payment = record_invoice_payment( + db, + invoice=invoice, + payment_date=date.today(), + amount_received=money(response_data.get("amount") or transaction.amount), + tds_deducted=Decimal("0.00"), + bank_charges=Decimal("0.00"), + mode="ONLINE", + reference_no=transaction.bank_ref_num or transaction.mihpayid or transaction.txnid, + remarks=f"Online payment received through PayUMoney. Txn ID: {transaction.txnid}", + created_by_user_id=None, + payment_gateway=PAYUMONEY_PROVIDER, + gateway_transaction_id=transaction.mihpayid or transaction.payu_payment_id or transaction.txnid, + ) + transaction.receipt_payment_id = payment.id + else: + transaction.status = "FAILED" + return transaction + + +# --------------------------------------------------------------------------- +# Phase 7R.6A - Cashfree Payment Gateway Integration helpers +# --------------------------------------------------------------------------- +CASHFREE_PROVIDER = "CASHFREE" +CASHFREE_API_VERSION_DEFAULT = "2023-08-01" +CASHFREE_TEST_BASE_URL = "https://sandbox.cashfree.com/pg" +CASHFREE_PROD_BASE_URL = "https://api.cashfree.com/pg" +CASHFREE_MODES = ["TEST", "LIVE"] + + +def cashfree_base_url(settings: BillingSettings) -> str: + return CASHFREE_PROD_BASE_URL if str(getattr(settings, "cashfree_mode", "TEST")).upper() == "LIVE" else CASHFREE_TEST_BASE_URL + + +def is_cashfree_ready(settings: BillingSettings | None) -> bool: + return bool( + settings + and getattr(settings, "cashfree_enabled", False) + and (getattr(settings, "cashfree_client_id", None) or "").strip() + and (getattr(settings, "cashfree_client_secret", None) or "").strip() + ) + + +def _cashfree_headers(settings: BillingSettings) -> dict[str, str]: + return { + "Content-Type": "application/json", + "Accept": "application/json", + "x-api-version": (getattr(settings, "cashfree_api_version", None) or CASHFREE_API_VERSION_DEFAULT).strip() or CASHFREE_API_VERSION_DEFAULT, + "x-client-id": (settings.cashfree_client_id or "").strip(), + "x-client-secret": (settings.cashfree_client_secret or "").strip(), + } + + +def _cashfree_api_request(settings: BillingSettings, *, method: str, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: + url = cashfree_base_url(settings).rstrip("/") + path + body = json.dumps(payload or {}).encode("utf-8") if payload is not None else None + req = UrlRequest(url, data=body, headers=_cashfree_headers(settings), method=method.upper()) + try: + with urlopen(req, timeout=30) as response: + raw = response.read().decode("utf-8") + return json.loads(raw or "{}") + except HTTPError as exc: + raw = exc.read().decode("utf-8", errors="replace") + raise ValueError(f"Cashfree API error {exc.code}: {raw}") from exc + except URLError as exc: + raise ValueError(f"Cashfree API connection error: {exc.reason}") from exc + + +def create_cashfree_transaction(db: Session, *, invoice: BillingInvoice, settings: BillingSettings, base_url: str, client_ip: str | None = None) -> dict[str, Any]: + if not is_cashfree_ready(settings): + raise ValueError("Cashfree is not enabled or client credentials are missing in Billing Settings.") + if invoice.status not in {"ISSUED", "PARTLY_PAID", "OVERDUE"} or money(invoice.balance_amount) <= Decimal("0.00"): + raise ValueError("Only issued invoices with balance can be paid online.") + + amount = money(invoice.balance_amount) + order_id = f"AF{invoice.tenant_id}I{invoice.id}C{int(datetime.now(timezone.utc).timestamp())}" + customer_name = (invoice.client_legal_name or invoice.client_trade_name or getattr(invoice.client, "client_name", None) or "Client").strip()[:120] + customer_email = (invoice.client_email or getattr(invoice.client, "email", None) or settings.contact_email or "no-reply@example.com").strip() + customer_phone = (invoice.client_mobile or getattr(invoice.client, "mobile", None) or settings.contact_mobile or "9999999999").strip() + note = (settings.cashfree_order_note or f"Invoice {invoice.invoice_no}").strip()[:250] + + return_url = f"{base_url.rstrip('/')}/client/billing/cashfree/return?order_id={{order_id}}" + notify_url = f"{base_url.rstrip('/')}/client/billing/cashfree/webhook" + payload = { + "order_id": order_id, + "order_amount": float(amount), + "order_currency": "INR", + "customer_details": { + "customer_id": str(invoice.client_id), + "customer_name": customer_name, + "customer_email": customer_email, + "customer_phone": customer_phone, + }, + "order_meta": { + "return_url": return_url, + "notify_url": notify_url, + }, + "order_note": note, + "order_tags": { + "tenant_id": str(invoice.tenant_id), + "branch_id": str(invoice.branch_id or ""), + "invoice_id": str(invoice.id), + "invoice_no": str(invoice.invoice_no), + "source": "audit_firm_erp", + }, + } + response = _cashfree_api_request(settings, method="POST", path="/orders", payload=payload) + payment_session_id = str(response.get("payment_session_id") or "").strip() + if not payment_session_id: + raise ValueError(f"Cashfree order created without payment_session_id: {response}") + + transaction = BillingOnlinePaymentTransaction( + tenant_id=invoice.tenant_id, + branch_id=invoice.branch_id, + invoice_id=invoice.id, + client_id=invoice.client_id, + provider=CASHFREE_PROVIDER, + mode=(settings.cashfree_mode or "TEST").upper(), + txnid=order_id, + amount=amount, + productinfo=note, + firstname=customer_name, + email=customer_email, + phone=customer_phone, + status="INITIATED", + gateway_status=str(response.get("order_status") or "ACTIVE"), + cashfree_order_id=order_id, + cashfree_cf_order_id=str(response.get("cf_order_id") or "") or None, + cashfree_payment_session_id=payment_session_id, + raw_response=json.dumps(response, ensure_ascii=False, default=str), + ) + db.add(transaction) + db.flush() + return {"transaction": transaction, "payment_session_id": payment_session_id, "order_response": response} + + +def get_cashfree_transaction_by_order_id(db: Session, *, order_id: str) -> BillingOnlinePaymentTransaction | None: + return db.execute( + select(BillingOnlinePaymentTransaction) + .options(selectinload(BillingOnlinePaymentTransaction.invoice).selectinload(BillingInvoice.payments)) + .where( + BillingOnlinePaymentTransaction.provider == CASHFREE_PROVIDER, + BillingOnlinePaymentTransaction.txnid == order_id, + ) + ).scalars().unique().one_or_none() + + +def fetch_cashfree_order_status(settings: BillingSettings, *, order_id: str) -> dict[str, Any]: + return _cashfree_api_request(settings, method="GET", path=f"/orders/{order_id}") + + +def _mark_cashfree_success(db: Session, transaction: BillingOnlinePaymentTransaction, *, amount: Any, reference_no: str | None, raw_payload: dict[str, Any] | None = None) -> BillingOnlinePaymentTransaction: + invoice = transaction.invoice + transaction.status = "SUCCESS" + transaction.gateway_status = "PAID" + transaction.completed_at_utc = datetime.now(timezone.utc) + transaction.updated_at_utc = datetime.now(timezone.utc) + if raw_payload is not None: + transaction.raw_response = json.dumps(raw_payload, ensure_ascii=False, default=str) + if not transaction.receipt_payment_id: + payment = record_invoice_payment( + db, + invoice=invoice, + payment_date=date.today(), + amount_received=money(amount or transaction.amount), + tds_deducted=Decimal("0.00"), + bank_charges=Decimal("0.00"), + mode="ONLINE", + reference_no=reference_no or transaction.cashfree_payment_id or transaction.txnid, + remarks=f"Online payment received through Cashfree. Order ID: {transaction.txnid}", + created_by_user_id=None, + payment_gateway=CASHFREE_PROVIDER, + gateway_transaction_id=reference_no or transaction.cashfree_payment_id or transaction.txnid, + ) + transaction.receipt_payment_id = payment.id + return transaction + + +def process_cashfree_return(db: Session, *, order_id: str) -> BillingOnlinePaymentTransaction | None: + transaction = get_cashfree_transaction_by_order_id(db, order_id=order_id) + if not transaction: + return None + invoice = transaction.invoice + settings = get_effective_billing_settings(db, tenant_id=invoice.tenant_id, branch_id=invoice.branch_id) + order = fetch_cashfree_order_status(settings, order_id=order_id) + transaction.gateway_status = str(order.get("order_status") or "") or transaction.gateway_status + transaction.cashfree_cf_order_id = str(order.get("cf_order_id") or transaction.cashfree_cf_order_id or "") or None + transaction.raw_response = json.dumps(order, ensure_ascii=False, default=str) + transaction.updated_at_utc = datetime.now(timezone.utc) + if str(order.get("order_status") or "").upper() == "PAID": + return _mark_cashfree_success(db, transaction, amount=order.get("order_amount") or transaction.amount, reference_no=str(order.get("cf_order_id") or order_id), raw_payload=order) + if str(order.get("order_status") or "").upper() in {"EXPIRED", "TERMINATED", "CANCELLED"}: + transaction.status = "FAILED" + return transaction + + +def verify_cashfree_webhook_signature(*, raw_body: bytes, timestamp: str, signature: str, secret: str) -> bool: + if not raw_body or not timestamp or not signature or not secret: + return False + signed_payload = timestamp.encode("utf-8") + raw_body + digest = hmac.new(secret.encode("utf-8"), signed_payload, hashlib.sha256).digest() + expected = base64.b64encode(digest).decode("utf-8") + return hmac.compare_digest(expected, signature) + + +def process_cashfree_webhook(db: Session, *, raw_body: bytes, headers: dict[str, str]) -> BillingOnlinePaymentTransaction | None: + payload = json.loads(raw_body.decode("utf-8") or "{}") + data = payload.get("data") or payload + order = data.get("order") or data + payment = data.get("payment") or {} + order_id = str(order.get("order_id") or data.get("order_id") or "").strip() + if not order_id: + return None + transaction = get_cashfree_transaction_by_order_id(db, order_id=order_id) + if not transaction: + return None + invoice = transaction.invoice + settings = get_effective_billing_settings(db, tenant_id=invoice.tenant_id, branch_id=invoice.branch_id) + timestamp = headers.get("x-webhook-timestamp") or headers.get("X-Webhook-Timestamp") or "" + signature = headers.get("x-webhook-signature") or headers.get("X-Webhook-Signature") or "" + if not verify_cashfree_webhook_signature(raw_body=raw_body, timestamp=timestamp, signature=signature, secret=(settings.cashfree_client_secret or "")): + transaction.status = "HASH_FAILED" + transaction.gateway_status = "WEBHOOK_SIGNATURE_FAILED" + transaction.raw_response = raw_body.decode("utf-8", errors="replace") + transaction.updated_at_utc = datetime.now(timezone.utc) + return transaction + + event_id = str(payload.get("event_id") or payload.get("cf_event_id") or "") or None + if event_id and transaction.webhook_event_id == event_id and transaction.status == "SUCCESS": + return transaction + transaction.webhook_event_id = event_id + transaction.cashfree_payment_id = str(payment.get("cf_payment_id") or payment.get("payment_id") or "") or transaction.cashfree_payment_id + transaction.gateway_status = str(payment.get("payment_status") or order.get("order_status") or payload.get("type") or "") or None + transaction.raw_response = raw_body.decode("utf-8", errors="replace") + transaction.updated_at_utc = datetime.now(timezone.utc) + + status_text = (transaction.gateway_status or "").upper() + if "SUCCESS" in status_text or status_text == "PAID" or str(order.get("order_status") or "").upper() == "PAID": + amount = payment.get("payment_amount") or order.get("order_amount") or transaction.amount + reference = transaction.cashfree_payment_id or str(payment.get("bank_reference") or order.get("cf_order_id") or order_id) + return _mark_cashfree_success(db, transaction, amount=amount, reference_no=reference, raw_payload=payload) + if "FAILED" in status_text or "CANCELLED" in status_text or "EXPIRED" in status_text: + transaction.status = "FAILED" + return transaction diff --git a/app/modules/billing/templates/billing/client_portal/detail.html b/app/modules/billing/templates/billing/client_portal/detail.html new file mode 100644 index 0000000..32cdf7e --- /dev/null +++ b/app/modules/billing/templates/billing/client_portal/detail.html @@ -0,0 +1,63 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +{% include "modules/clients/templates/clients/_client_tabs.html" %} +
+
+
+

Invoice {{ invoice.invoice_no }}

+

Issued on {{ invoice.invoice_date.strftime('%d-%m-%Y') if invoice.invoice_date else '-' }}{% if invoice.due_date %} • Due {{ invoice.due_date.strftime('%d-%m-%Y') }}{% endif %}

+
+
+ Back to Bills + Print / Save PDF + {% if invoice.balance_amount and invoice.balance_amount > 0 %}Pay Now{% endif %} +
+
+ +
+
Invoice Total
₹ {{ '%.2f'|format(invoice.total_amount or 0) }}
+
Received
₹ {{ '%.2f'|format(invoice.amount_received or 0) }}
+
TDS
₹ {{ '%.2f'|format(invoice.tds_deducted or 0) }}
+
Balance
₹ {{ '%.2f'|format(invoice.balance_amount or 0) }}
+
+ +
+
+

Invoice Lines

{{ invoice.status.replace('_', ' ') }}
+
+ + + + {% for line in invoice.lines %} + + {% endfor %} + +
DescriptionSACTaxableGSTTotal
{{ line.description }}{{ line.sac_code or '-' }}₹ {{ '%.2f'|format(line.taxable_amount or 0) }}₹ {{ '%.2f'|format((line.cgst_amount or 0) + (line.sgst_amount or 0) + (line.igst_amount or 0)) }}₹ {{ '%.2f'|format(line.line_total or 0) }}
+
+
+ + +
+
+{% endblock %} diff --git a/app/modules/billing/templates/billing/client_portal/list.html b/app/modules/billing/templates/billing/client_portal/list.html new file mode 100644 index 0000000..4bf9009 --- /dev/null +++ b/app/modules/billing/templates/billing/client_portal/list.html @@ -0,0 +1,75 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +{% include "modules/clients/templates/clients/_client_tabs.html" %} +
+
+
+
+

Client Portal

+

My Bills & Payments

+

View invoices issued by your audit firm, download receipts and use Pay Now for pending bills.

+

Active FY: {{ active_financial_year or 'All Years' }}

+
+ {% if billing_latest_due_invoice %} + Pay Latest Due + {% endif %} +
+
+ +
+
Outstanding
₹ {{ '%.2f'|format(billing_outstanding_amount or 0) }}
{{ billing_open_count or 0 }} open bill(s)
+
Total Invoices
{{ billing_total_count or 0 }}
Issued by firm
+
Paid
{{ billing_paid_count or 0 }}
Completed payments
+
+ +
+
+
+

Invoices

+

Draft and cancelled invoices are not shown in the client portal.

+
+
+ + + +
+
+ +
+ + + + + + + + + + + + + + {% for row in rows %} + + + + + + + + + + {% else %} + + {% endfor %} + +
InvoiceDateFYDue DateTotalBalanceStatusAction
{{ row.invoice_no }}{{ row.invoice_date.strftime('%d-%m-%Y') if row.invoice_date else '-' }}{{ row.due_date.strftime('%d-%m-%Y') if row.due_date else '-' }}₹ {{ '%.2f'|format(row.total_amount or 0) }}₹ {{ '%.2f'|format(row.balance_amount or 0) }}{{ row.status.replace('_', ' ') }} + {% if row.balance_amount and row.balance_amount > 0 %}Pay Now{% else %}View{% endif %} +
No invoices found.
+
+
+
+{% endblock %} diff --git a/app/modules/billing/templates/billing/client_portal/pay_now.html b/app/modules/billing/templates/billing/client_portal/pay_now.html new file mode 100644 index 0000000..800945a --- /dev/null +++ b/app/modules/billing/templates/billing/client_portal/pay_now.html @@ -0,0 +1,94 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +{% include "modules/clients/templates/clients/_client_tabs.html" %} +
+
+

Pay Now

+

Invoice {{ invoice.invoice_no }}

+

Pay the outstanding amount using online gateway, UPI or bank transfer. Online gateway receipts are created automatically after successful verification.

+
+ +
+
+

Payment Options

+
+
Amount payable: ₹ {{ '%.2f'|format(amount_due or 0) }}
+
Invoice balance only is shown here. TDS or bank charges will be adjusted by the firm while recording receipt.
+
+ + + {% if payumoney_enabled %} +
+
+
+
Online Payment Gateway
+

Pay securely through PayUMoney / PayU. Receipt will be created automatically after successful confirmation.

+ {% if payumoney_mode != 'LIVE' %}

Currently running in TEST mode.

{% endif %} +
+
+ + +
+
+
+ {% endif %} + + + {% if cashfree_enabled %} +
+
+
+
Cashfree Payment Gateway
+

Pay securely through Cashfree checkout. Receipt will be created automatically after successful confirmation.

+ {% if cashfree_mode != 'LIVE' %}

Currently running in TEST / Sandbox mode.

{% endif %} +
+
+ + +
+
+
+ {% endif %} + + {% if upi_link %} +
+
UPI Payment
+
UPI ID: {{ upi_id }}
+ Open UPI App +

This opens a UPI app on supported devices. After payment, share the UTR/reference number with the firm if requested.

+
+ {% endif %} + +
+
Bank Transfer
+
+
Bank
{{ bank_name or '-' }}
+
Account Name
{{ bank_account_name or '-' }}
+
Account No.
{{ bank_account_number or '-' }}
+
IFSC
{{ bank_ifsc or '-' }}
+
+ {% if payment_instructions %}
{{ payment_instructions }}
{% endif %} +
+
+ + +
+
+{% endblock %} diff --git a/app/modules/billing/templates/billing/client_portal/payment_status.html b/app/modules/billing/templates/billing/client_portal/payment_status.html new file mode 100644 index 0000000..295f77b --- /dev/null +++ b/app/modules/billing/templates/billing/client_portal/payment_status.html @@ -0,0 +1,27 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +{% include "modules/clients/templates/clients/_client_tabs.html" %} +
+
+

PayUMoney Payment

+

{{ heading }}

+

{{ message }}

+ + {% if transaction %} +
+
Invoice
{{ transaction.invoice.invoice_no }}
+
Amount
₹ {{ '%.2f'|format(transaction.amount or 0) }}
+
Txn ID
{{ transaction.txnid }}
+
Gateway Status
{{ transaction.gateway_status or transaction.status }}
+ {% if transaction.bank_ref_num %}
Bank Ref.
{{ transaction.bank_ref_num }}
{% endif %} + {% if transaction.mihpayid %}
PayU ID
{{ transaction.mihpayid }}
{% endif %} +
+ {% endif %} + +
+ {% if transaction %}View Invoice{% endif %} + Back to My Bills +
+
+
+{% endblock %} diff --git a/app/modules/billing/templates/billing/create.html b/app/modules/billing/templates/billing/create.html new file mode 100644 index 0000000..0dc640b --- /dev/null +++ b/app/modules/billing/templates/billing/create.html @@ -0,0 +1,124 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Create GST Invoice

+

Prepare a professional tax invoice with SAC, GST breakup, place of supply and firm billing defaults.

+

Invoice will be tagged to active FY: {{ active_financial_year or 'Current FY' }}

+
+ Billing Settings +
+ +
+ + +
+
+
+

Invoice Header

+

Client, date, GST treatment and billing period.

+
+ {{ settings.invoice_title or 'Tax Invoice' }} +
+
+ + + + + + + + + +
+
+ +
+
+
+

Invoice Lines

+

SAC defaults to billing settings if left blank. Blank description rows are ignored.

+
+
+
+ + + + + + + + + + + + + + {% for i in range(1, 8) %} + + + + + + + + + + {% endfor %} + +
ServiceDescriptionSACQtyRateDiscountGST %
+ +
+
+
+ +
+ + +
+ +
+ Cancel + +
+
+
+{% endblock %} diff --git a/app/modules/billing/templates/billing/detail.html b/app/modules/billing/templates/billing/detail.html new file mode 100644 index 0000000..c471187 --- /dev/null +++ b/app/modules/billing/templates/billing/detail.html @@ -0,0 +1,120 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

{{ invoice_ctx.invoice_title }} {{ invoice.invoice_no }}

+

{{ invoice.client_legal_name or (invoice.client.client_name if invoice.client else '') }} • {{ invoice.invoice_date }}

+
+
+ {% if invoice.status == 'DRAFT' %} +
+ + +
+ {% endif %} + {% if can_record_payment and invoice.status not in ['DRAFT','CANCELLED','PAID'] %} + Record Payment + {% endif %} + Print / PDF + Back +
+
+ +
+
+
Status
{{ invoice.status }}
+
Due Date
{{ invoice.due_date or '-' }}
+
Place of Supply
{{ invoice.place_of_supply or '-' }}
+
Total
₹ {{ '%.2f'|format(invoice.total_amount or 0) }}
+
Amount Received
₹ {{ '%.2f'|format(invoice.amount_received or 0) }}
+
TDS Deducted
₹ {{ '%.2f'|format(invoice.tds_deducted or 0) }}
+
Balance
₹ {{ '%.2f'|format(invoice.balance_amount or 0) }}
+
+
+
+

Supplier

+
{{ invoice_ctx.firm_name }}
+
{{ invoice_ctx.firm_address or '-' }}
+
GSTIN: {{ invoice_ctx.firm_gstin or '-' }} • PAN: {{ invoice_ctx.firm_pan or '-' }}
+
+
+

Bill To

+
{{ invoice.client_legal_name or '-' }}
+
{{ invoice.client_billing_address or '-' }}
+
GSTIN: {{ invoice.client_gstin or '-' }} • PAN: {{ invoice.client_pan or '-' }}
+
+
+
+ +
+ + + + + + {% for line in invoice.lines %} + + + + + + + + + + {% endfor %} + + + + + + + + + + +
DescriptionSACQtyRateTaxableGSTTotal
{{ line.description }}
{{ line.service.service_name if line.service else '' }}
{{ line.sac_code or '-' }}{{ line.quantity }}₹ {{ '%.2f'|format(line.rate or 0) }}₹ {{ '%.2f'|format(line.taxable_amount or 0) }}{{ line.gst_rate }}%₹ {{ '%.2f'|format(line.line_total or 0) }}
Subtotal₹ {{ '%.2f'|format(invoice.subtotal or 0) }}
Discount₹ {{ '%.2f'|format(invoice.discount_amount or 0) }}
Taxable Value₹ {{ '%.2f'|format(invoice.taxable_amount or 0) }}
CGST₹ {{ '%.2f'|format(invoice.cgst_amount or 0) }}
SGST₹ {{ '%.2f'|format(invoice.sgst_amount or 0) }}
IGST₹ {{ '%.2f'|format(invoice.igst_amount or 0) }}
Grand Total₹ {{ '%.2f'|format(invoice.total_amount or 0) }}
+
+ + +
+
+
+

Payment History

+

Receipts, TDS deductions and outstanding balance for this invoice.

+
+ {% if can_record_payment and invoice.status not in ['DRAFT','CANCELLED','PAID'] %} + Record Payment + {% endif %} +
+
+ + + + + + {% for payment in invoice.payments %} + + + + + + + + + + {% else %} + + {% endfor %} + +
ReceiptDateModeReferenceReceivedTDS
{{ payment.receipt_no }}{{ payment.payment_date }}{{ payment.mode }}{{ payment.reference_no or '-' }}₹ {{ '%.2f'|format(payment.amount_received or 0) }}₹ {{ '%.2f'|format(payment.tds_deducted or 0) }}Receipt
No payments recorded yet.
+
+
+ +
+

Amount in Words

{{ invoice.amount_in_words or '-' }}

+

Bank / UPI Details

{% if invoice_ctx.bank_name %}{{ invoice_ctx.bank_name }}{% endif %}{% if invoice_ctx.bank_account_number %}\nA/c: {{ invoice_ctx.bank_account_number }}{% endif %}{% if invoice_ctx.bank_ifsc %}\nIFSC: {{ invoice_ctx.bank_ifsc }}{% endif %}{% if invoice_ctx.upi_id %}\nUPI: {{ invoice_ctx.upi_id }}{% endif %}

+
+
+{% endblock %} diff --git a/app/modules/billing/templates/billing/fee_structures/import.html b/app/modules/billing/templates/billing/fee_structures/import.html new file mode 100644 index 0000000..9f3db5b --- /dev/null +++ b/app/modules/billing/templates/billing/fee_structures/import.html @@ -0,0 +1,53 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Import Fee Structure

+

Upload Excel with Fee_Structure and Fee_Services sheets.

+
+ +
+ + {% if result %} +
+ {% if result.success %} +
Import completed
+
Created: {{ result.created }} | Updated: {{ result.updated }}
+ {% else %} +
Import failed
+
    + {% for error in result.errors %}
  • {{ error }}
  • {% endfor %} +
+ {% endif %} +
+ {% endif %} + +
+ + +
+
How to import using template
+
    +
  1. Click Download Excel Template.
  2. +
  3. Fill Fee_Structure for client-wise package/header details.
  4. +
  5. Fill Fee_Services for services included in each package.
  6. +
  7. Upload the completed file here. Imported fee structures can then be used in Generate Bills.
  8. +
+
Required sheets
+
Fee_Structure: client, billing group, mode, frequency, fee and tax details.
+
Fee_Services: services included in each billing group.
+
+
+ Back + +
+
+
+{% endblock %} diff --git a/app/modules/billing/templates/billing/fee_structures/list.html b/app/modules/billing/templates/billing/fee_structures/list.html new file mode 100644 index 0000000..1dce820 --- /dev/null +++ b/app/modules/billing/templates/billing/fee_structures/list.html @@ -0,0 +1,63 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Fee Structure

+

Client-wise billing packages with multiple services grouped for future invoice generation.

+
+
+ Generate Bills + Invoices + {% if can_import %} + Download Template + Import Using Template + {% endif %} +
+
+ +
+
+ + +
+
+ +
+ + + + + + + + + + + + + + {% for row in rows %} + + + + + + + + + + {% else %} + + {% endfor %} + +
Group CodeClientPackageModeFrequencyFeeServices
{{ row.group_code }}{{ row.client.client_name if row.client else row.client_id }}{{ row.group_name }}{{ row.billing_mode }}{{ row.frequency }}₹ {{ '%.2f'|format(row.fee_amount or 0) }} + {% for item in row.services %} +
{{ item.service.service_code if item.service else item.service_id }} - {{ item.line_description or (item.service.service_name if item.service else '') }}
+ {% else %} + No services mapped + {% endfor %} +
No fee structures found.
+
+
+{% endblock %} diff --git a/app/modules/billing/templates/billing/generate.html b/app/modules/billing/templates/billing/generate.html new file mode 100644 index 0000000..2641b50 --- /dev/null +++ b/app/modules/billing/templates/billing/generate.html @@ -0,0 +1,182 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Generate Draft Invoices

+

Create draft GST invoices from fee structures and automatically link matching client service subscriptions / engagements for the selected financial year. Existing invoices for the same fee group and period are skipped by default.

+
+ +
+ + {% if result %} +
+

Generation Result

+
+
Draft invoices created
{{ result.created|length }}
+
Skipped
{{ result.skipped|length }}
+
Errors
{{ result.errors|length }}
+
+ + {% if result.created %} +
+
Created Draft Invoices
+
+ + + + {% for invoice in result.created %} + + + + + + {% endfor %} + +
InvoiceClientAmount
{{ invoice.invoice_no }}{{ invoice.client.client_name if invoice.client else invoice.client_id }}₹ {{ '%.2f'|format(invoice.total_amount or 0) }}
+
+
+ {% endif %} + + {% if result.skipped %} +
+
Skipped rows
+
    + {% for item in result.skipped %}
  • {{ item }}
  • {% endfor %} +
+
+ {% endif %} + + {% if result.errors %} +
+
Errors
+
    + {% for item in result.errors %}
  • {{ item }}
  • {% endfor %} +
+
+ {% endif %} +
+ {% endif %} + +
+
Engagement-to-invoice refinement
+
This screen continues to use your existing fee-structure billing logic. During generation, the system checks the client, service and active financial year ({{ active_financial_year or 'current FY' }}) and links the invoice / invoice lines to the matching client service subscription wherever available. No duplicate module is created.
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+
+ +
+ + + + + + + +
+
+
Eligible Fee Structures
+
Select packages and create draft invoices for {{ billing_period_from }} to {{ billing_period_to }}.
+
+ +
+ +
+ + + + + + + + + + + + + + + {% for row in rows %} + {% set duplicate = duplicate_map.get(row.id) %} + + + + + + + + + + + {% else %} + + {% endfor %} + +
Group CodeClientPackageServices / Engagement SourceModeFeeStatus
{{ row.group_code }}{{ row.client.client_name if row.client else row.client_id }} +
{{ row.group_name }}
+
{{ row.frequency }} billing
+
+ {% if row.services %} +
+ {% for item in row.services[:4] %} + {{ item.service.service_name if item.service else item.service_id }} + {% endfor %} + {% if row.services|length > 4 %}+{{ row.services|length - 4 }}{% endif %} +
+
Matching active subscriptions are linked during generation.
+ {% else %} + Package line only + {% endif %} +
{{ row.billing_mode }}₹ {{ '%.2f'|format(row.fee_amount or 0) }} + {% if duplicate %} + Already billed: {{ duplicate.invoice_no }} + {% else %} + Ready + {% endif %} +
No eligible fee structures found for the selected filter.
+
+ +
+ +
+
+
+{% endblock %} diff --git a/app/modules/billing/templates/billing/invoice_print.html b/app/modules/billing/templates/billing/invoice_print.html new file mode 100644 index 0000000..1d41153 --- /dev/null +++ b/app/modules/billing/templates/billing/invoice_print.html @@ -0,0 +1,120 @@ + + + + + + {{ invoice_ctx.invoice_title }} {{ invoice.invoice_no }} + + + + +
+ + Back +
+
+
+
+
+
{{ invoice_ctx.firm_name }}
+
{{ invoice_ctx.firm_address or '' }}
+
GSTIN: {{ invoice_ctx.firm_gstin or '-' }} | PAN: {{ invoice_ctx.firm_pan or '-' }}
+
Email: {{ invoice_ctx.firm_contact_email or '-' }} | Mobile: {{ invoice_ctx.firm_contact_mobile or '-' }}
+
+
+
{{ invoice_ctx.invoice_title }}
+
Invoice No: {{ invoice.invoice_no }}
+
Invoice Date: {{ invoice.invoice_date }}
+
Due Date: {{ invoice.due_date or '-' }}
+
Status: {{ invoice.status }}
+
+
+
+ +
+
+
Bill To
+
{{ invoice.client_legal_name or '-' }}
+
{{ invoice.client_billing_address or '-' }}
+
GSTIN: {{ invoice.client_gstin or '-' }}
+
PAN: {{ invoice.client_pan or '-' }}
+
+
+
Tax Particulars
+
Place of Supply: {{ invoice.place_of_supply or '-' }}
+
Tax Type: {{ invoice.tax_type }}
+
Reverse Charge: {{ 'Yes' if invoice.reverse_charge else 'No' }}
+
Client State Code: {{ invoice.client_state_code or '-' }}
+
+
+ + + + + + + + + + + + + + + + {% for line in invoice.lines %} + + + + + + + + + + + {% endfor %} + + + + + + + + + + +
#DescriptionSACQtyRateTaxableGST %Total
{{ loop.index }}{{ line.description }}{{ line.sac_code or '-' }}{{ line.quantity }}{{ '%.2f'|format(line.rate or 0) }}{{ '%.2f'|format(line.taxable_amount or 0) }}{{ line.gst_rate }}{{ '%.2f'|format(line.line_total or 0) }}
Subtotal{{ '%.2f'|format(invoice.subtotal or 0) }}
Discount{{ '%.2f'|format(invoice.discount_amount or 0) }}
Taxable Value{{ '%.2f'|format(invoice.taxable_amount or 0) }}
CGST{{ '%.2f'|format(invoice.cgst_amount or 0) }}
SGST{{ '%.2f'|format(invoice.sgst_amount or 0) }}
IGST{{ '%.2f'|format(invoice.igst_amount or 0) }}
Grand Total₹ {{ '%.2f'|format(invoice.total_amount or 0) }}
+ +
+
+
Amount in Words
+
{{ invoice.amount_in_words or '-' }}
+
+
+
Payment Details
+
Bank: {{ invoice_ctx.bank_name or '-' }}
+
A/c: {{ invoice_ctx.bank_account_number or '-' }}
+
IFSC: {{ invoice_ctx.bank_ifsc or '-' }}
+
UPI: {{ invoice_ctx.upi_id or '-' }}
+
+
+ +
+ {% if invoice_ctx.terms %}
Terms: {{ invoice_ctx.terms }}
{% endif %} + {% if invoice_ctx.declaration %}
Declaration: {{ invoice_ctx.declaration }}
{% endif %} +
+ +
+
{{ invoice_ctx.footer_note or '' }}
+
+
For {{ invoice_ctx.firm_name }}
+
{{ invoice_ctx.authorised_signatory_name or 'Authorised Signatory' }}
+
+
+
+ + diff --git a/app/modules/billing/templates/billing/list.html b/app/modules/billing/templates/billing/list.html new file mode 100644 index 0000000..7e32686 --- /dev/null +++ b/app/modules/billing/templates/billing/list.html @@ -0,0 +1,81 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Billing Invoices

+

Create, issue and print GST-ready client invoices with SAC and tax breakup.

+

Showing billing records for active FY: {{ active_financial_year or 'All Years' }}

+
+
+ {% if can_generate %} + Generate Bills + {% endif %} + {% if can_view_fee_structure %} + Fee Structure + {% endif %} + {% if can_import_fee_structure %} + Download Fee Template + Import Fee Excel + {% endif %} + Payments + Billing Settings + {% if can_create %} + New Invoice + {% endif %} +
+
+ +
+
+ + +
+
+ + + {% if report_summary %} +
+
Billed
₹ {{ '%.2f'|format(report_summary.total_billed or 0) }}
{{ report_summary.invoice_count }} invoice(s)
+
Collected + TDS
₹ {{ '%.2f'|format(report_summary.total_collected_with_tds or 0) }}
{{ report_summary.payment_count }} receipt(s)
+
Outstanding
₹ {{ '%.2f'|format(report_summary.outstanding or 0) }}
Active issued bills
+
Status
Draft {{ report_summary.draft_count }} · Open {{ report_summary.issued_count }} · Paid {{ report_summary.paid_count }}
FY-filtered billing report
+
+ {% endif %} + +
+ + + + + + + + + + + + + + + + {% for row in rows %} + + + + + + + + + + + + {% else %} + + {% endfor %} + +
Invoice NoDateFYClientAmountReceived/TDSBalanceStatus
{{ row.invoice_no }}{{ row.invoice_date }}{{ row.financial_year or '-' }}{{ row.client.client_name if row.client else row.client_id }}₹ {{ '%.2f'|format(row.total_amount or 0) }}₹ {{ '%.2f'|format((row.amount_received or 0) + (row.tds_deducted or 0)) }}₹ {{ '%.2f'|format(row.balance_amount or row.total_amount or 0) }}{{ row.status }}
View{% if can_record_payment and row.status not in ['DRAFT','CANCELLED','PAID'] %}Payment{% endif %}Print
No invoices found.
+
+
+{% endblock %} diff --git a/app/modules/billing/templates/billing/payments/list.html b/app/modules/billing/templates/billing/payments/list.html new file mode 100644 index 0000000..7c5faf0 --- /dev/null +++ b/app/modules/billing/templates/billing/payments/list.html @@ -0,0 +1,22 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+

Payments & Receipts

Track invoice collections, TDS deductions and receipt printouts.

Showing receipts for active FY: {{ active_financial_year or 'All Years' }}

+ Invoices +
+
+
+ + + + {% for row in rows %} + + {% else %} + + {% endfor %} + +
ReceiptInvoiceClientDateFYModeReceivedTDS
{{ row.receipt_no }}{{ row.invoice.invoice_no if row.invoice else row.invoice_id }}{{ row.client.client_name if row.client else row.client_id }}{{ row.payment_date }}{{ row.financial_year or '-' }}{{ row.mode }}₹ {{ '%.2f'|format(row.amount_received or 0) }}₹ {{ '%.2f'|format(row.tds_deducted or 0) }}Receipt
No payments recorded.
+
+
+{% endblock %} diff --git a/app/modules/billing/templates/billing/payments/new.html b/app/modules/billing/templates/billing/payments/new.html new file mode 100644 index 0000000..38b87a1 --- /dev/null +++ b/app/modules/billing/templates/billing/payments/new.html @@ -0,0 +1,24 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+

Record Payment

+

Invoice {{ invoice.invoice_no }} • Balance ₹ {{ '%.2f'|format(invoice.balance_amount or invoice.total_amount or 0) }}

+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
Cancel
+
+
+{% endblock %} diff --git a/app/modules/billing/templates/billing/payments/receipt_print.html b/app/modules/billing/templates/billing/payments/receipt_print.html new file mode 100644 index 0000000..a5626e2 --- /dev/null +++ b/app/modules/billing/templates/billing/payments/receipt_print.html @@ -0,0 +1,19 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+
+

{{ invoice_ctx.firm_name }}

{{ invoice_ctx.firm_address or '' }}

GSTIN: {{ invoice_ctx.firm_gstin or '-' }} • PAN: {{ invoice_ctx.firm_pan or '-' }}

+
Receipt
{{ payment.receipt_no }}
{{ payment.receipt_date }}
+
+
+
Received From
{{ payment.client.client_name if payment.client else invoice.client_legal_name }}
Invoice: {{ invoice.invoice_no }}
+
Amount Received₹ {{ '%.2f'|format(payment.amount_received or 0) }}
TDS Deducted₹ {{ '%.2f'|format(payment.tds_deducted or 0) }}
Bank Charges₹ {{ '%.2f'|format(payment.bank_charges or 0) }}
+
+
Mode
{{ payment.mode }}
Payment Date
{{ payment.payment_date }}
Reference
{{ payment.reference_no or '-' }}
+ {% if payment.remarks %}
{{ payment.remarks }}
{% endif %} +
Authorised Signatory
+
+
+{% endblock %} diff --git a/app/modules/billing/templates/billing/settings.html b/app/modules/billing/templates/billing/settings.html new file mode 100644 index 0000000..3de0f76 --- /dev/null +++ b/app/modules/billing/templates/billing/settings.html @@ -0,0 +1,244 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Phase 7R.1

+

Firm Billing Settings

+

Configure firm GST, invoice numbering, payment details and invoice footer defaults.

+
+
+
{{ tenant_name }}
+
{% if branch_name %}Branch: {{ branch_name }}{% else %}Firm-wide default{% endif %}
+
Next invoice preview
+
{{ preview_invoice_no }}
+
+
+ +
+
+ + +
+
+
+

Scope

+

Keep branch-specific settings for branch-wise invoice series, or use firm-wide default if you are working across branches.

+
+
+
+ + +
+
+ +
+

Firm GST & Contact Details

+
+ + + + + + + + +
+
+ +
+

Invoice Numbering & Tax Defaults

+
+ + + + + + + + + +
+
+ +
+

Bank, UPI & Payment Details

+
+ + + + + + +
+
+ + +
+

PayUMoney / PayU Online Payment Gateway

+

Enable this only after entering valid PayU/PayUMoney merchant credentials. Test mode posts to PayU test checkout.

+
+ + + + + + +
+
+ Store separate test and live credentials carefully. Do not enable LIVE until callback testing is completed from an accessible public URL. +
+
+ + +
+

Cashfree Online Payment Gateway

+

Enable Cashfree only after adding valid Cashfree PG credentials. Sandbox mode uses Cashfree sandbox APIs.

+
+ + + + + + +
+
+ Cashfree checkout creates an order from the server and uses payment_session_id for hosted checkout. Webhook URL: /client/billing/cashfree/webhook +
+
+ +
+

Invoice Notes, Terms & Signatory

+
+ + + + +
+
+ +
+ Back to Invoices + {% if can_edit_settings %} + + {% else %} + View-only access + {% endif %} +
+
+ + +
+
+{% endblock %} diff --git a/app/modules/billing/ui.py b/app/modules/billing/ui.py new file mode 100644 index 0000000..059ff29 --- /dev/null +++ b/app/modules/billing/ui.py @@ -0,0 +1,880 @@ +from __future__ import annotations + +from datetime import date + +from decimal import Decimal + +from fastapi import APIRouter, File, Form, Request, UploadFile +from fastapi.responses import RedirectResponse, StreamingResponse +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.billing.models import BillingFeeGroup, BillingSettings +from app.modules.billing.services import ( + BILLING_MODES, + FREQUENCIES, + PAYMENT_MODES, + TAX_TYPES, + build_fee_structure_template, + build_invoice_print_context, + build_billing_report_summary, + billing_financial_year, + create_invoice, + fee_group_already_billed, + generate_draft_invoices_from_fee_groups, + get_invoice, + import_fee_structure_excel, + issue_invoice, + list_clients_for_billing, + list_fee_groups, + list_fee_groups_for_generation, + list_invoices, + list_payments, + list_services_for_billing, + parse_date, + preview_invoice_number, + record_invoice_payment, + get_payment, +) +from app.modules.core.rbac.deps import get_user_permissions, get_user_roles +from app.modules.core.rbac.permission_guard import require_permission +from app.modules.core.tenancy.models import Branch, Tenant +from app.modules.core.tenancy.year_control import redirect_if_financial_year_locked, is_row_financial_year_locked + +router = APIRouter(prefix="/billing", tags=["billing-ui"]) + + +def _base_ctx(request: Request, user, db, **ctx): + base = { + "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), + "tax_types": TAX_TYPES, + "billing_modes": BILLING_MODES, + "frequencies": FREQUENCIES, + "payment_modes": PAYMENT_MODES, + } + base.update(ctx) + return base + + +def _render(request: Request, template: str, db, user, **ctx): + return templates.TemplateResponse(template, _base_ctx(request, user, db, **ctx)) + + +def _redirect_denied(): + return RedirectResponse(url="/system-settings", status_code=303) + + +def _has_perm(db, user, code: str) -> bool: + try: + require_permission(db, user, code) + return True + except Exception: + return False + + +def _role_names(db, user) -> set[str]: + return {str(r or "").strip() for r in get_user_roles(db, user.id)} + + +def _can_manage_billing_settings(db, user) -> bool: + roles = _role_names(db, user) + return bool({"System Admin", "Firm Admin", "Partner"}.intersection(roles)) or _has_perm(db, user, "billing.edit") + + +def _get_or_create_billing_settings(db, *, tenant_id: int, branch_id: int | None) -> BillingSettings: + row = db.execute( + select(BillingSettings).where(BillingSettings.tenant_id == tenant_id, BillingSettings.branch_id == branch_id) + ).scalar_one_or_none() + if row: + return row + row = BillingSettings(tenant_id=tenant_id, branch_id=branch_id) + db.add(row) + db.flush() + return row + + +def _decimal_form(value: str | None, default: str = "0.00") -> Decimal: + try: + return Decimal(str(value or default)).quantize(Decimal("0.01")) + except Exception: + return Decimal(default).quantize(Decimal("0.01")) + + +def _int_form(value: str | int | None, default: int, minimum: int | None = None, maximum: int | None = None) -> int: + try: + parsed = int(value) + except Exception: + parsed = default + if minimum is not None: + parsed = max(minimum, parsed) + if maximum is not None: + parsed = min(maximum, parsed) + return parsed + + +def _billing_context_names(db, *, tenant_id: int, branch_id: int | None) -> tuple[str, str | None]: + tenant = db.get(Tenant, tenant_id) + branch = db.get(Branch, branch_id) if branch_id else None + return (getattr(tenant, "name", None) or f"Audit Firm {tenant_id}", getattr(branch, "name", None) if branch else None) + + +def _active_tenant_id(request: Request, user) -> int: + return int(request.session.get("active_tenant_id") or request.session.get("selected_tenant_id") or request.session.get("tenant_id") or user.tenant_id) + + +def _active_branch_id(request: Request, user, db) -> int | None: + value = request.session.get("active_branch_id") + if value in (None, "", 0, "0"): + if _has_perm(db, user, "billing.cross_branch"): + return None + return int(getattr(user, "branch_id", 0) or 0) or None + return int(value) + + +def _active_financial_year(request: Request) -> str | None: + value = request.session.get("active_financial_year") or getattr(request.state, "year_code", None) + value = (value or "").strip() + if not value or value.upper() == "ALL": + return None + return value + + +def _period_start_for_fy(financial_year: str | None) -> date: + try: + start_year = int(str(financial_year or "").split("-")[0]) + return date(start_year, 4, 1) + except Exception: + today = date.today() + return date(today.year if today.month >= 4 else today.year - 1, 4, 1) + + +def _period_end_for_fy(financial_year: str | None) -> date: + start = _period_start_for_fy(financial_year) + return date(start.year + 1, 3, 31) + + +def _locked_partner_id(db, user) -> int | None: + return int(user.id) if _has_perm(db, user, "billing.view_own") else None + + +def _require_billing_user(request: Request, db, permission_code: str): + user = get_current_user(request, db=db) + if not user: + return None, RedirectResponse(url="/login", status_code=303) + try: + require_permission(db, user, permission_code) + except Exception: + return user, _redirect_denied() + return user, None + + +@router.get("") +def invoice_list(request: Request, q: str = ""): + db = CommonSessionLocal() + try: + user, response = _require_billing_user(request, db, "billing.view") + if response: + return response + tenant_id = _active_tenant_id(request, user) + branch_id = _active_branch_id(request, user, db) + partner_id = _locked_partner_id(db, user) + financial_year = _active_financial_year(request) + rows = list_invoices(db, tenant_id=tenant_id, branch_id=branch_id, partner_id=partner_id, financial_year=financial_year, q=q) + report_summary = build_billing_report_summary(db, tenant_id=tenant_id, branch_id=branch_id, partner_id=partner_id, financial_year=financial_year) + return _render( + request, + "modules/billing/templates/billing/list.html", + db, + user, + title="Billing - Invoices", + q=q, + active_financial_year=financial_year, + rows=rows, + report_summary=report_summary, + can_create=_has_perm(db, user, "billing.create"), + can_import_fee_structure=_has_perm(db, user, "billing_fee_structure.import"), + can_generate=_has_perm(db, user, "billing_invoice.generate"), + can_view_fee_structure=_has_perm(db, user, "billing_fee_structure.view"), + can_record_payment=_has_perm(db, user, "billing_payment.record") or _has_perm(db, user, "billing.create"), + ) + finally: + db.close() + + +@router.get("/payments") +def payment_list(request: Request, q: str = ""): + db = CommonSessionLocal() + try: + user, response = _require_billing_user(request, db, "billing.view") + if response: + return response + financial_year = _active_financial_year(request) + rows = list_payments( + db, + tenant_id=_active_tenant_id(request, user), + branch_id=_active_branch_id(request, user, db), + partner_id=_locked_partner_id(db, user), + financial_year=financial_year, + q=q, + ) + return _render(request, "modules/billing/templates/billing/payments/list.html", db, user, title="Payments & Receipts", rows=rows, q=q, active_financial_year=financial_year) + finally: + db.close() + + +@router.get("/payments/{payment_id}/receipt") +def payment_receipt_print(request: Request, payment_id: int): + db = CommonSessionLocal() + try: + user, response = _require_billing_user(request, db, "billing.view") + if response: + return response + payment = get_payment(db, payment_id=payment_id, tenant_id=_active_tenant_id(request, user), partner_id=_locked_partner_id(db, user), financial_year=_active_financial_year(request)) + if not payment: + return _redirect_denied() + invoice_ctx = build_invoice_print_context(db, payment.invoice) + return _render(request, "modules/billing/templates/billing/payments/receipt_print.html", db, user, title=f"Receipt {payment.receipt_no}", payment=payment, invoice=payment.invoice, invoice_ctx=invoice_ctx) + finally: + db.close() + + +@router.get("/settings") +def billing_settings_page(request: Request, branch_scope: str = "active"): + db = CommonSessionLocal() + try: + user, response = _require_billing_user(request, db, "billing.view") + if response: + return response + tenant_id = _active_tenant_id(request, user) + active_branch_id = _active_branch_id(request, user, db) + branch_id = None if branch_scope == "firm" and _has_perm(db, user, "billing.cross_branch") else active_branch_id + settings = _get_or_create_billing_settings(db, tenant_id=tenant_id, branch_id=branch_id) + tenant_name, branch_name = _billing_context_names(db, tenant_id=tenant_id, branch_id=branch_id) + return _render( + request, + "modules/billing/templates/billing/settings.html", + db, + user, + title="Billing Settings", + settings=settings, + preview_invoice_no=preview_invoice_number(settings, branch_id=branch_id, financial_year=_active_financial_year(request)), + tenant_name=tenant_name, + branch_name=branch_name, + branch_scope="firm" if branch_id is None else "active", + can_edit_settings=_can_manage_billing_settings(db, user), + ) + finally: + db.close() + + +@router.post("/settings") +def billing_settings_submit( + request: Request, + branch_scope: str = Form("active"), + legal_name: str | None = Form(None), + gstin: str | None = Form(None), + pan: str | None = Form(None), + state_code: str | None = Form(None), + billing_address: str | None = Form(None), + contact_email: str | None = Form(None), + contact_mobile: str | None = Form(None), + website_url: str | None = Form(None), + invoice_title: str | None = Form(None), + invoice_prefix: str = Form("INV"), + invoice_number_format: str | None = Form("{prefix}/{fy}/{number}"), + next_invoice_no: int = Form(1), + padding: int = Form(4), + default_due_days: int = Form(15), + default_gst_rate: str = Form("18.00"), + default_tax_type: str = Form("CGST_SGST"), + default_sac_code: str | None = Form(None), + bank_name: str | None = Form(None), + bank_account_name: str | None = Form(None), + bank_account_number: str | None = Form(None), + bank_ifsc: str | None = Form(None), + upi_id: str | None = Form(None), + bank_details: str | None = Form(None), + terms: str | None = Form(None), + footer_note: str | None = Form(None), + declaration: str | None = Form(None), + authorised_signatory_name: str | None = Form(None), + payumoney_enabled: str | None = Form(None), + payumoney_mode: str = Form("TEST"), + payumoney_merchant_key: str | None = Form(None), + payumoney_merchant_salt: str | None = Form(None), + payumoney_merchant_id: str | None = Form(None), + payumoney_product_info: str | None = Form(None), + cashfree_enabled: str | None = Form(None), + cashfree_mode: str = Form("TEST"), + cashfree_client_id: str | None = Form(None), + cashfree_client_secret: str | None = Form(None), + cashfree_api_version: str | None = Form("2023-08-01"), + cashfree_order_note: str | None = Form(None), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, response = _require_billing_user(request, db, "billing.view") + if response: + return response + if not _can_manage_billing_settings(db, user): + return _redirect_denied() + tenant_id = _active_tenant_id(request, user) + active_branch_id = _active_branch_id(request, user, db) + branch_id = None if branch_scope == "firm" and _has_perm(db, user, "billing.cross_branch") else active_branch_id + settings = _get_or_create_billing_settings(db, tenant_id=tenant_id, branch_id=branch_id) + + settings.legal_name = (legal_name or "").strip() or None + settings.gstin = (gstin or "").strip().upper() or None + settings.pan = (pan or "").strip().upper() or None + settings.state_code = (state_code or "").strip()[:2] or None + settings.billing_address = (billing_address or "").strip() or None + settings.contact_email = (contact_email or "").strip() or None + settings.contact_mobile = (contact_mobile or "").strip() or None + settings.website_url = (website_url or "").strip() or None + + settings.invoice_title = (invoice_title or "").strip() or None + settings.invoice_prefix = (invoice_prefix or "INV").strip().upper()[:40] or "INV" + settings.invoice_number_format = (invoice_number_format or "{prefix}/{fy}/{number}").strip()[:120] or "{prefix}/{fy}/{number}" + settings.next_invoice_no = _int_form(next_invoice_no, 1, minimum=1) + settings.padding = _int_form(padding, 4, minimum=1, maximum=10) + settings.default_due_days = _int_form(default_due_days, 15, minimum=0, maximum=365) + settings.default_gst_rate = _decimal_form(default_gst_rate, "18.00") + settings.default_tax_type = default_tax_type if default_tax_type in TAX_TYPES else "CGST_SGST" + settings.default_sac_code = (default_sac_code or "").strip()[:20] or None + + settings.bank_name = (bank_name or "").strip() or None + settings.bank_account_name = (bank_account_name or "").strip() or None + settings.bank_account_number = (bank_account_number or "").strip() or None + settings.bank_ifsc = (bank_ifsc or "").strip().upper() or None + settings.upi_id = (upi_id or "").strip() or None + settings.bank_details = (bank_details or "").strip() or None + settings.terms = (terms or "").strip() or None + settings.footer_note = (footer_note or "").strip() or None + settings.declaration = (declaration or "").strip() or None + settings.authorised_signatory_name = (authorised_signatory_name or "").strip() or None + + settings.payumoney_enabled = bool(payumoney_enabled) + settings.payumoney_mode = (payumoney_mode or "TEST").strip().upper() if (payumoney_mode or "TEST").strip().upper() in {"TEST", "LIVE"} else "TEST" + settings.payumoney_merchant_key = (payumoney_merchant_key or "").strip() or None + settings.payumoney_merchant_salt = (payumoney_merchant_salt or "").strip() or None + settings.payumoney_merchant_id = (payumoney_merchant_id or "").strip() or None + settings.payumoney_product_info = (payumoney_product_info or "").strip() or None + db.commit() + suffix = "?branch_scope=firm" if branch_id is None else "" + return RedirectResponse(url=f"/billing/settings{suffix}", status_code=303) + except Exception: + db.rollback() + raise + finally: + db.close() + + +@router.get("/new") +def invoice_create_page(request: Request): + db = CommonSessionLocal() + try: + user, response = _require_billing_user(request, db, "billing.create") + if response: + return response + tenant_id = _active_tenant_id(request, user) + branch_id = _active_branch_id(request, user, db) + partner_id = _locked_partner_id(db, user) + clients = list_clients_for_billing(db, tenant_id=tenant_id, branch_id=branch_id, partner_id=partner_id) + services = list_services_for_billing(db) + settings = _get_or_create_billing_settings(db, tenant_id=tenant_id, branch_id=branch_id) + return _render( + request, + "modules/billing/templates/billing/create.html", + db, + user, + title="Create Invoice", + active_financial_year=financial_year, + default_billing_period_from=_period_start_for_fy(_active_financial_year(request)).isoformat(), + default_billing_period_to=_period_end_for_fy(_active_financial_year(request)).isoformat(), + clients=clients, + services=services, + settings=settings, + today=date.today().isoformat(), + ) + finally: + db.close() + + +@router.post("/new") +def invoice_create_submit( + request: Request, + client_id: int = Form(...), + invoice_date: str = Form(...), + due_date: str | None = Form(None), + billing_period_from: str | None = Form(None), + billing_period_to: str | None = Form(None), + tax_type: str = Form("CGST_SGST"), + place_of_supply: str | None = Form(None), + client_state_code: str | None = Form(None), + reverse_charge: str | None = Form(None), + notes: str | None = Form(None), + terms: str | None = Form(None), + line_description: list[str] = Form(default=[]), + line_service_id: list[str] = Form(default=[]), + line_quantity: list[str] = Form(default=[]), + line_rate: list[str] = Form(default=[]), + line_discount: list[str] = Form(default=[]), + line_gst_rate: list[str] = Form(default=[]), + line_sac_code: list[str] = Form(default=[]), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, response = _require_billing_user(request, db, "billing.create") + if response: + return response + tenant_id = _active_tenant_id(request, user) + branch_id = _active_branch_id(request, user, db) + partner_id = _locked_partner_id(db, user) + financial_year = _active_financial_year(request) + locked_response = redirect_if_financial_year_locked(db, tenant_id=tenant_id, year_code=financial_year, redirect_url=f"/billing?financial_year={financial_year or ''}") + if locked_response: + return locked_response + + allowed_clients = {c.id for c in list_clients_for_billing(db, tenant_id=tenant_id, branch_id=branch_id, partner_id=partner_id)} + if client_id not in allowed_clients: + return _redirect_denied() + + raw_lines = [] + max_len = max(len(line_description), len(line_service_id), len(line_quantity), len(line_rate), len(line_discount), len(line_gst_rate), len(line_sac_code), 0) + for idx in range(max_len): + raw_lines.append({ + "description": line_description[idx] if idx < len(line_description) else "", + "service_id": line_service_id[idx] if idx < len(line_service_id) else "", + "quantity": line_quantity[idx] if idx < len(line_quantity) else "1", + "rate": line_rate[idx] if idx < len(line_rate) else "0", + "discount_amount": line_discount[idx] if idx < len(line_discount) else "0", + "gst_rate": line_gst_rate[idx] if idx < len(line_gst_rate) else "18", + "sac_code": line_sac_code[idx] if idx < len(line_sac_code) else "", + }) + + invoice = create_invoice( + db, + tenant_id=tenant_id, + branch_id=branch_id, + client_id=client_id, + invoice_date=parse_date(invoice_date) or date.today(), + due_date=parse_date(due_date), + billing_period_from=parse_date(billing_period_from), + billing_period_to=parse_date(billing_period_to), + tax_type=tax_type, + notes=notes, + terms=terms, + place_of_supply=place_of_supply, + client_state_code=client_state_code, + reverse_charge=(reverse_charge == "yes"), + created_by_user_id=user.id, + raw_lines=raw_lines, + financial_year=_active_financial_year(request), + ) + db.commit() + return RedirectResponse(url=f"/billing/{invoice.id}", status_code=303) + except ValueError: + db.rollback() + return RedirectResponse(url="/billing/new", status_code=303) + finally: + db.close() + + +@router.get("/fee-structures/list") +def fee_structure_list(request: Request, q: str = ""): + db = CommonSessionLocal() + try: + user, response = _require_billing_user(request, db, "billing_fee_structure.view") + if response: + return response + rows = list_fee_groups( + db, + tenant_id=_active_tenant_id(request, user), + branch_id=_active_branch_id(request, user, db), + partner_id=_locked_partner_id(db, user), + q=q, + ) + return _render( + request, + "modules/billing/templates/billing/fee_structures/list.html", + db, + user, + title="Fee Structure", + q=q, + active_financial_year=financial_year, + rows=rows, + can_import=_has_perm(db, user, "billing_fee_structure.import"), + ) + finally: + db.close() + + +@router.get("/fee-structures/import") +def fee_structure_import_page(request: Request): + db = CommonSessionLocal() + try: + user, response = _require_billing_user(request, db, "billing_fee_structure.import") + if response: + return response + return _render(request, "modules/billing/templates/billing/fee_structures/import.html", db, user, title="Import Fee Structure", result=None) + finally: + db.close() + + +@router.get("/fee-structures/template") +def fee_structure_template_download(request: Request): + db = CommonSessionLocal() + try: + user, response = _require_billing_user(request, db, "billing_fee_structure.import") + if response: + return response + data = build_fee_structure_template() + return StreamingResponse( + iter([data]), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": "attachment; filename=billing_fee_structure_template.xlsx"}, + ) + finally: + db.close() + + +@router.post("/fee-structures/import") +async def fee_structure_import_submit(request: Request, import_file: UploadFile = File(...), csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, response = _require_billing_user(request, db, "billing_fee_structure.import") + if response: + return response + filename = (import_file.filename or "").lower() + if not filename.endswith((".xlsx", ".xlsm")): + result = {"success": False, "created": 0, "updated": 0, "errors": ["Please upload an .xlsx file."]} + else: + content = await import_file.read() + if len(content) > 5 * 1024 * 1024: + result = {"success": False, "created": 0, "updated": 0, "errors": ["File size must be 5 MB or less."]} + else: + result = import_fee_structure_excel( + db, + tenant_id=_active_tenant_id(request, user), + branch_id=_active_branch_id(request, user, db), + created_by_user_id=user.id, + file_bytes=content, + ) + return _render(request, "modules/billing/templates/billing/fee_structures/import.html", db, user, title="Import Fee Structure", result=result) + finally: + db.close() + + +@router.get("/generate") +def generate_invoices_page( + request: Request, + frequency: str = "Monthly", + billing_period_from: str | None = None, + billing_period_to: str | None = None, + auto_generate_only: str = "yes", + q: str = "", +): + db = CommonSessionLocal() + try: + user, response = _require_billing_user(request, db, "billing_invoice.generate") + if response: + return response + tenant_id = _active_tenant_id(request, user) + branch_id = _active_branch_id(request, user, db) + partner_id = _locked_partner_id(db, user) + financial_year = _active_financial_year(request) + locked_response = redirect_if_financial_year_locked(db, tenant_id=tenant_id, year_code=financial_year, redirect_url=f"/billing?financial_year={financial_year or ''}") + if locked_response: + return locked_response + period_from = parse_date(billing_period_from) or _period_start_for_fy(financial_year) + period_to = parse_date(billing_period_to) or _period_end_for_fy(financial_year) + rows = list_fee_groups_for_generation( + db, + tenant_id=tenant_id, + branch_id=branch_id, + partner_id=partner_id, + frequency=frequency or None, + auto_generate_only=(auto_generate_only != "no"), + q=q, + ) + duplicate_map = { + row.id: fee_group_already_billed(db, tenant_id=tenant_id, fee_group_id=row.id, period_from=period_from, period_to=period_to) + for row in rows + } + return _render( + request, + "modules/billing/templates/billing/generate.html", + db, + user, + title="Generate Draft Invoices", + rows=rows, + duplicate_map=duplicate_map, + frequencies=FREQUENCIES, + frequency=frequency, + billing_period_from=period_from.isoformat(), + billing_period_to=period_to.isoformat(), + auto_generate_only=auto_generate_only, + q=q, + active_financial_year=financial_year, + result=None, + ) + finally: + db.close() + + +@router.post("/generate") +def generate_invoices_submit( + request: Request, + frequency: str = Form("Monthly"), + billing_period_from: str = Form(...), + billing_period_to: str = Form(...), + auto_generate_only: str = Form("yes"), + q: str = Form(""), + fee_group_ids: list[int] = Form(default=[]), + skip_duplicates: str = Form("yes"), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, response = _require_billing_user(request, db, "billing_invoice.generate") + if response: + return response + tenant_id = _active_tenant_id(request, user) + branch_id = _active_branch_id(request, user, db) + partner_id = _locked_partner_id(db, user) + financial_year = _active_financial_year(request) + locked_response = redirect_if_financial_year_locked(db, tenant_id=tenant_id, year_code=financial_year, redirect_url=f"/billing/generate?year_locked=1") + if locked_response: + return locked_response + period_from = parse_date(billing_period_from) + period_to = parse_date(billing_period_to) + if financial_year and period_from and billing_financial_year(billing_period_from=period_from) != financial_year: + result = {"created": [], "skipped": [], "errors": [f"Billing period must fall within active FY {financial_year}."], "batch": None} + elif period_from is None or period_to is None: + result = {"created": [], "skipped": [], "errors": ["Billing period From and To are required."], "batch": None} + else: + result = generate_draft_invoices_from_fee_groups( + db, + tenant_id=tenant_id, + branch_id=branch_id, + partner_id=partner_id, + generated_by_user_id=user.id, + billing_period_from=period_from, + billing_period_to=period_to, + frequency=frequency or None, + fee_group_ids=fee_group_ids, + skip_duplicates=(skip_duplicates != "no"), + ) + db.commit() + rows = list_fee_groups_for_generation( + db, + tenant_id=tenant_id, + branch_id=branch_id, + partner_id=partner_id, + frequency=frequency or None, + auto_generate_only=(auto_generate_only != "no"), + q=q, + ) + duplicate_map = { + row.id: fee_group_already_billed(db, tenant_id=tenant_id, fee_group_id=row.id, period_from=period_from or date.today(), period_to=period_to or date.today()) + for row in rows + } + return _render( + request, + "modules/billing/templates/billing/generate.html", + db, + user, + title="Generate Draft Invoices", + rows=rows, + duplicate_map=duplicate_map, + frequencies=FREQUENCIES, + frequency=frequency, + billing_period_from=(period_from or date.today()).isoformat(), + billing_period_to=(period_to or date.today()).isoformat(), + auto_generate_only=auto_generate_only, + q=q, + active_financial_year=financial_year, + result=result, + ) + except ValueError as exc: + db.rollback() + rows = [] + result = {"created": [], "skipped": [], "errors": [str(exc)], "batch": None} + return _render( + request, + "modules/billing/templates/billing/generate.html", + db, + user, + title="Generate Draft Invoices", + rows=rows, + duplicate_map={}, + frequencies=FREQUENCIES, + frequency=frequency, + billing_period_from=billing_period_from, + billing_period_to=billing_period_to, + auto_generate_only=auto_generate_only, + q=q, + active_financial_year=_active_financial_year(request), + result=result, + ) + except Exception as exc: + db.rollback() + result = {"created": [], "skipped": [], "errors": [f"Generation failed: {exc}"], "batch": None} + return _render( + request, + "modules/billing/templates/billing/generate.html", + db, + user, + title="Generate Draft Invoices", + rows=[], + duplicate_map={}, + frequencies=FREQUENCIES, + frequency=frequency, + billing_period_from=billing_period_from, + billing_period_to=billing_period_to, + auto_generate_only=auto_generate_only, + q=q, + active_financial_year=_active_financial_year(request), + result=result, + ) + finally: + db.close() + + +@router.get("/{invoice_id}/payments/new") +def invoice_payment_page(request: Request, invoice_id: int): + db = CommonSessionLocal() + try: + user, response = _require_billing_user(request, db, "billing.view") + if response: + return response + invoice = get_invoice(db, invoice_id=invoice_id, tenant_id=_active_tenant_id(request, user), partner_id=_locked_partner_id(db, user), financial_year=_active_financial_year(request)) + if not invoice: + return _redirect_denied() + if invoice.status in {"DRAFT", "CANCELLED"}: + return RedirectResponse(url=f"/billing/{invoice.id}", status_code=303) + can_record = _has_perm(db, user, "billing_payment.record") or _has_perm(db, user, "billing.create") + if not can_record: + return _redirect_denied() + return _render(request, "modules/billing/templates/billing/payments/new.html", db, user, title=f"Record Payment - {invoice.invoice_no}", invoice=invoice, today=date.today().isoformat()) + finally: + db.close() + + +@router.post("/{invoice_id}/payments/new") +def invoice_payment_submit( + request: Request, + invoice_id: int, + payment_date: str = Form(...), + amount_received: str = Form("0.00"), + tds_deducted: str = Form("0.00"), + bank_charges: str = Form("0.00"), + mode: str = Form("BANK"), + reference_no: str | None = Form(None), + remarks: str | None = Form(None), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, response = _require_billing_user(request, db, "billing.view") + if response: + return response + can_record = _has_perm(db, user, "billing_payment.record") or _has_perm(db, user, "billing.create") + if not can_record: + return _redirect_denied() + invoice = get_invoice(db, invoice_id=invoice_id, tenant_id=_active_tenant_id(request, user), partner_id=_locked_partner_id(db, user), financial_year=_active_financial_year(request)) + if not invoice: + return _redirect_denied() + if is_row_financial_year_locked(db, invoice): + return RedirectResponse(url=f"/billing/{invoice.id}?year_locked=1", status_code=303) + payment = record_invoice_payment( + db, + invoice=invoice, + payment_date=parse_date(payment_date) or date.today(), + amount_received=_decimal_form(amount_received, "0.00"), + tds_deducted=_decimal_form(tds_deducted, "0.00"), + bank_charges=_decimal_form(bank_charges, "0.00"), + mode=mode, + reference_no=reference_no, + remarks=remarks, + created_by_user_id=user.id, + ) + db.commit() + return RedirectResponse(url=f"/billing/payments/{payment.id}/receipt", status_code=303) + except ValueError: + db.rollback() + return RedirectResponse(url=f"/billing/{invoice_id}", status_code=303) + except Exception: + db.rollback() + raise + finally: + db.close() + + +@router.get("/{invoice_id}/print") +def invoice_print(request: Request, invoice_id: int): + db = CommonSessionLocal() + try: + user, response = _require_billing_user(request, db, "billing.view") + if response: + return response + invoice = get_invoice(db, invoice_id=invoice_id, tenant_id=_active_tenant_id(request, user), partner_id=_locked_partner_id(db, user), financial_year=_active_financial_year(request)) + if not invoice: + return _redirect_denied() + invoice_ctx = build_invoice_print_context(db, invoice) + return _render(request, "modules/billing/templates/billing/invoice_print.html", db, user, title=f"Print Invoice {invoice.invoice_no}", invoice=invoice, invoice_ctx=invoice_ctx) + finally: + db.close() + + +@router.post("/{invoice_id}/issue") +def invoice_issue_submit(request: Request, invoice_id: int, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, response = _require_billing_user(request, db, "billing.create") + if response: + return response + invoice = get_invoice(db, invoice_id=invoice_id, tenant_id=_active_tenant_id(request, user), partner_id=_locked_partner_id(db, user), financial_year=_active_financial_year(request)) + if not invoice: + return _redirect_denied() + if is_row_financial_year_locked(db, invoice): + return RedirectResponse(url=f"/billing/{invoice.id}?year_locked=1", status_code=303) + issue_invoice(db, invoice, user_id=user.id) + db.commit() + return RedirectResponse(url=f"/billing/{invoice.id}", status_code=303) + except Exception: + db.rollback() + raise + finally: + db.close() + + +@router.get("/{invoice_id}") +def invoice_detail(request: Request, invoice_id: int): + db = CommonSessionLocal() + try: + user, response = _require_billing_user(request, db, "billing.view") + if response: + return response + invoice = get_invoice(db, invoice_id=invoice_id, tenant_id=_active_tenant_id(request, user), partner_id=_locked_partner_id(db, user), financial_year=_active_financial_year(request)) + if not invoice: + return _redirect_denied() + invoice_ctx = build_invoice_print_context(db, invoice) + return _render(request, "modules/billing/templates/billing/detail.html", db, user, title=f"Invoice {invoice.invoice_no}", invoice=invoice, invoice_ctx=invoice_ctx, can_record_payment=_has_perm(db, user, "billing_payment.record") or _has_perm(db, user, "billing.create")) + finally: + db.close() diff --git a/app/modules/clients/__init__.py b/app/modules/clients/__init__.py new file mode 100644 index 0000000..335a310 --- /dev/null +++ b/app/modules/clients/__init__.py @@ -0,0 +1,4 @@ +from .api import router as api_router +from .ui import router as ui_router + +__all__ = ["api_router", "ui_router"] diff --git a/app/modules/clients/access.py b/app/modules/clients/access.py new file mode 100644 index 0000000..dbbb23c --- /dev/null +++ b/app/modules/clients/access.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class ClientAccessScope: + tenant_id: int + branch_id: int | None + allow_cross_branch: bool + allow_cross_tenant: bool + allow_all_clients: bool + own_only: bool + locked_partner_id: int | None + can_assign_partner: bool + can_change_branch: bool + can_change_tenant: bool + + +def build_scope(request, user, permission_checker): + active_tenant_id = int(request.session.get("active_tenant_id") or user.tenant_id) + active_branch_value = request.session.get("active_branch_id") + active_branch_id = None if active_branch_value in (None, "", 0, "0") else int(active_branch_value) + + allow_cross_branch = permission_checker("clients.cross_branch") + allow_cross_tenant = permission_checker("clients.cross_tenant") + can_assign_partner = permission_checker("clients.assign_partner") + own_only = permission_checker("clients.view.own_only") + + allow_all_clients = bool((allow_cross_tenant and allow_cross_branch and not own_only) or permission_checker("clients.view.all")) + + locked_partner_id = user.id if own_only else None + can_change_branch = allow_cross_branch + can_change_tenant = allow_cross_tenant + + return ClientAccessScope( + tenant_id=active_tenant_id, + branch_id=active_branch_id or getattr(user, "branch_id", None), + allow_cross_branch=allow_cross_branch, + allow_cross_tenant=allow_cross_tenant, + allow_all_clients=allow_all_clients, + own_only=own_only, + locked_partner_id=locked_partner_id, + can_assign_partner=can_assign_partner, + can_change_branch=can_change_branch, + can_change_tenant=can_change_tenant, + ) + + +def effective_partner_id(row: dict): + return row.get("assoc_partner_user_id") or row.get("partner_id") + + +def effective_tenant_id(row: dict): + return row.get("assoc_firm_tenant_id") or row.get("tenant_id") + + +def effective_branch_id(row: dict): + return row.get("branch_id") + + +def can_view_client_row(scope: ClientAccessScope, row: dict, *, user_id: int) -> bool: + if scope.allow_all_clients: + return True + + if not scope.allow_cross_tenant and effective_tenant_id(row) != scope.tenant_id: + return False + + branch_id = effective_branch_id(row) + if not scope.allow_cross_branch and scope.branch_id and branch_id and branch_id != scope.branch_id: + return False + + if scope.own_only and scope.locked_partner_id and effective_partner_id(row) != scope.locked_partner_id: + return False + + return True diff --git a/app/modules/clients/api.py b/app/modules/clients/api.py new file mode 100644 index 0000000..1c80c8b --- /dev/null +++ b/app/modules/clients/api.py @@ -0,0 +1,178 @@ + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import Response +from sqlalchemy.orm import Session + +from app.core.db.deps import get_common_db +from app.core.security.session_auth import require_login +from app.modules.clients.access import ClientAccessScope +from app.modules.clients.schemas import ClientAuditLogOut, ClientFilterOptions, ClientListResponse, ClientOut, ClientUpdate, ClientCreate +from app.modules.clients.service import ( + activate_client_service, + archive_client_service, + create_client_service, + deactivate_client_service, + export_clients_csv, + get_client_or_404, + get_filter_options, + list_client_audit_logs, + list_clients_payload, + restore_client_service, + update_client_service, +) +from app.modules.core.rbac.permission_guard import require_permission + +router = APIRouter(prefix="/api/v1/clients", tags=["clients-api"]) + +def _api_scope_from_user(db, user): + def has(code: str): + try: + require_permission(db, user, code) + return True + except Exception: + return False + own_only = has("clients.view.own_only") or not has("clients.assign_partner") + return ClientAccessScope( + tenant_id=user.tenant_id, + branch_id=user.branch_id, + allow_cross_branch=has("clients.cross_branch"), + allow_cross_tenant=has("clients.cross_tenant"), + own_only=own_only, + locked_partner_id=user.id if own_only else None, + can_assign_partner=has("clients.assign_partner"), + can_change_branch=has("clients.cross_branch"), + can_change_tenant=has("clients.cross_tenant"), + ) + +@router.get("/filters", response_model=ClientFilterOptions) +def api_client_filters(): + return get_filter_options() + +@router.get("", response_model=ClientListResponse) +def api_list_clients( + q: str = Query("", max_length=100), + status: str = Query("", max_length=20), + client_type: str = Query("", max_length=100), + partner_id: int | None = Query(None), + include_archived: bool = Query(False), + page: int = Query(1, ge=1), + per_page: int = Query(10, ge=1, le=100), + sort_by: str = Query("client_name"), + sort_order: str = Query("asc"), + db: Session = Depends(get_common_db), + user=Depends(require_login), +): + require_permission(db, user, "clients.view") + scope = _api_scope_from_user(db, user) + if scope.own_only: + partner_id = scope.locked_partner_id + return list_clients_payload( + db, + tenant_id=scope.tenant_id, + branch_id=scope.branch_id, + allow_cross_branch=scope.allow_cross_branch, + partner_id=partner_id, + q=q, + status=status, + client_type=client_type, + include_archived=include_archived, + page=page, + per_page=per_page, + sort_by=sort_by, + sort_order=sort_order, + ) + +@router.get("/export") +def api_export_clients( + q: str = Query("", max_length=100), + status: str = Query("", max_length=20), + client_type: str = Query("", max_length=100), + partner_id: int | None = Query(None), + include_archived: bool = Query(False), + sort_by: str = Query("client_name"), + sort_order: str = Query("asc"), + db: Session = Depends(get_common_db), + user=Depends(require_login), +): + require_permission(db, user, "clients.export") + scope = _api_scope_from_user(db, user) + if scope.own_only: + partner_id = scope.locked_partner_id + payload = list_clients_payload( + db, + tenant_id=scope.tenant_id, + branch_id=scope.branch_id, + allow_cross_branch=scope.allow_cross_branch, + partner_id=partner_id, + q=q, + status=status, + client_type=client_type, + include_archived=include_archived, + page=1, + per_page=10000, + sort_by=sort_by, + sort_order=sort_order, + ) + csv_text = export_clients_csv(payload) + return Response(content=csv_text, media_type="text/csv", headers={"Content-Disposition": "attachment; filename=clients_export.csv"}) + +@router.get("/{client_id}", response_model=ClientOut) +def api_get_client(client_id: int, db: Session = Depends(get_common_db), user=Depends(require_login)): + require_permission(db, user, "clients.view") + scope = _api_scope_from_user(db, user) + row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch) + if scope.own_only and row.partner_id != scope.locked_partner_id: + raise HTTPException(status_code=404, detail="Client not found.") + return row + +@router.post("", response_model=ClientOut, status_code=201) +def api_create_client(data: ClientCreate, db: Session = Depends(get_common_db), user=Depends(require_login)): + require_permission(db, user, "clients.create") + scope = _api_scope_from_user(db, user) + return create_client_service(db, data=data, actor_user_id=user.id, scope=scope) + +@router.put("/{client_id}", response_model=ClientOut) +def api_update_client(client_id: int, data: ClientUpdate, db: Session = Depends(get_common_db), user=Depends(require_login)): + require_permission(db, user, "clients.edit") + scope = _api_scope_from_user(db, user) + row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch) + if scope.own_only and row.partner_id != scope.locked_partner_id: + raise HTTPException(status_code=404, detail="Client not found.") + return update_client_service(db, row=row, data=data, actor_user_id=user.id, scope=scope) + +@router.post("/{client_id}/deactivate", response_model=ClientOut) +def api_deactivate_client(client_id: int, db: Session = Depends(get_common_db), user=Depends(require_login)): + require_permission(db, user, "clients.deactivate") + scope = _api_scope_from_user(db, user) + row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch) + return deactivate_client_service(db, row=row, actor_user_id=user.id) + +@router.post("/{client_id}/activate", response_model=ClientOut) +def api_activate_client(client_id: int, db: Session = Depends(get_common_db), user=Depends(require_login)): + require_permission(db, user, "clients.activate") + scope = _api_scope_from_user(db, user) + row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch) + return activate_client_service(db, row=row, actor_user_id=user.id) + +@router.post("/{client_id}/archive", response_model=ClientOut) +def api_archive_client(client_id: int, db: Session = Depends(get_common_db), user=Depends(require_login)): + require_permission(db, user, "clients.archive") + scope = _api_scope_from_user(db, user) + row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch) + return archive_client_service(db, row=row, actor_user_id=user.id) + +@router.post("/{client_id}/restore", response_model=ClientOut) +def api_restore_client(client_id: int, db: Session = Depends(get_common_db), user=Depends(require_login)): + require_permission(db, user, "clients.restore") + scope = _api_scope_from_user(db, user) + row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch) + return restore_client_service(db, row=row, actor_user_id=user.id) + +@router.get("/{client_id}/audit-logs", response_model=list[ClientAuditLogOut]) +def api_client_audit_logs(client_id: int, limit: int = Query(50, ge=1, le=200), db: Session = Depends(get_common_db), user=Depends(require_login)): + require_permission(db, user, "clients.audit_log.view") + scope = _api_scope_from_user(db, user) + row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch) + return list_client_audit_logs(db, row=row, limit=limit) diff --git a/app/modules/clients/association_admin_service.py b/app/modules/clients/association_admin_service.py new file mode 100644 index 0000000..47b3b8b --- /dev/null +++ b/app/modules/clients/association_admin_service.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.modules.clients.association_models import ClientAssociation + + +def get_active_association(db: Session, client_id: int): + stmt = ( + select(ClientAssociation) + .where(ClientAssociation.client_id == client_id) + .limit(1) + ) + return db.execute(stmt).scalar_one_or_none() + + +def ensure_active_association(db: Session, client_id: int): + row = get_active_association(db, client_id) + if row: + return row + + row = ClientAssociation( + client_id=client_id, + association_type="firm", + created_source="system_admin", + ) + db.add(row) + db.commit() + db.refresh(row) + return row + + +def update_association_fields(db: Session, client_id: int, **fields): + row = ensure_active_association(db, client_id) + for key, value in fields.items(): + if hasattr(row, key): + setattr(row, key, value) + db.add(row) + db.commit() + db.refresh(row) + return row \ No newline at end of file diff --git a/app/modules/clients/association_models.py b/app/modules/clients/association_models.py new file mode 100644 index 0000000..1c212a2 --- /dev/null +++ b/app/modules/clients/association_models.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from sqlalchemy import Integer, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.db.common import CommonBase + + +class ClientAssociation(CommonBase): + __tablename__ = "client_associations" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + client_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True) + association_type: Mapped[str] = mapped_column(String(50), nullable=False, default="firm") + firm_tenant_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) + consultant_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) + partner_user_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) + created_source: Mapped[str] = mapped_column(String(50), nullable=False, default="system_admin") \ No newline at end of file diff --git a/app/modules/clients/association_service.py b/app/modules/clients/association_service.py new file mode 100644 index 0000000..5830b8d --- /dev/null +++ b/app/modules/clients/association_service.py @@ -0,0 +1,27 @@ + +def build_client_association(current_user, role_name, selected_partner_id=None): + role = (role_name or '').lower() + if role in ('system admin', 'firm admin'): + return { + 'association_type': 'firm', + 'firm_tenant_id': getattr(current_user, 'tenant_id', None), + 'partner_user_id': selected_partner_id, + 'created_source': 'firm_admin', + } + if role == 'partner': + return { + 'association_type': 'firm', + 'firm_tenant_id': getattr(current_user, 'tenant_id', None), + 'partner_user_id': current_user.id, + 'created_source': 'partner', + } + if role == 'consultant': + return { + 'association_type': 'consultant', + 'consultant_id': current_user.id, + 'created_source': 'consultant', + } + return { + 'association_type': 'self_service_unassigned', + 'created_source': 'self_service', + } diff --git a/app/modules/clients/auditor_service.py b/app/modules/clients/auditor_service.py new file mode 100644 index 0000000..b900864 --- /dev/null +++ b/app/modules/clients/auditor_service.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.modules.core.iam.models import User +from app.modules.core.iam.profile_service import profile_photo_url +from app.modules.core.tenancy.models import Branch, Tenant + + +def _initials(name: str | None, email: str | None = None) -> str: + source = (name or email or "Auditor").strip() + parts = [p for p in source.replace("@", " ").replace(".", " ").split() if p] + if not parts: + return "AU" + if len(parts) == 1: + return parts[0][:2].upper() + return (parts[0][:1] + parts[-1][:1]).upper() + + +def _contact_card_from_user( + *, + user: User | None, + tenant_name: str | None, + branch_name: str | None, + source_label: str, +) -> dict: + if not user: + return { + "available": False, + "name": "Firm team", + "designation": "Audit support team", + "qualification": None, + "email": None, + "mobile": None, + "photo_url": None, + "initials": "FT", + "firm_name": tenant_name, + "branch_name": branch_name, + "source_label": source_label, + } + + name = getattr(user, "full_name", None) or getattr(user, "email", None) or "Firm team" + designation = getattr(user, "designation", None) or source_label or "Auditor" + return { + "available": True, + "name": name, + "designation": designation, + "qualification": getattr(user, "qualification", None), + "email": getattr(user, "email", None), + "mobile": getattr(user, "mobile", None), + "photo_url": profile_photo_url(user), + "initials": _initials(name, getattr(user, "email", None)), + "firm_name": tenant_name, + "branch_name": branch_name, + "source_label": source_label, + } + + +def build_client_auditor_card(db: Session, client_row: dict | None) -> dict: + """Return a client-facing contact card for the assigned auditor/partner. + + Priority: + 1. Client assigned partner (`partner_id`). + 2. Default review partner, if no assigned partner exists. + 3. Firm team fallback using tenant/branch names. + + This reuses Phase 7Q.3 user profile fields and does not create new tables. + """ + if not client_row: + return _contact_card_from_user( + user=None, + tenant_name=None, + branch_name=None, + source_label="Firm team", + ) + + tenant_name = client_row.get("tenant_name") + branch_name = client_row.get("branch_name") + tenant_id = client_row.get("tenant_id") + branch_id = client_row.get("branch_id") + + partner_id = client_row.get("partner_id") or client_row.get("assoc_partner_user_id") + review_partner_id = client_row.get("default_review_partner_user_id") + + target_user_id = partner_id or review_partner_id + source_label = "Assigned Auditor" if partner_id else "Review Partner" + + if not target_user_id: + return _contact_card_from_user( + user=None, + tenant_name=tenant_name, + branch_name=branch_name, + source_label="Firm team", + ) + + stmt = ( + select(User, Tenant.name.label("tenant_name"), Branch.name.label("branch_name")) + .join(Tenant, Tenant.id == User.tenant_id, isouter=True) + .join(Branch, Branch.id == User.branch_id, isouter=True) + .where(User.id == int(target_user_id), User.deleted_at.is_(None)) + ) + if tenant_id: + stmt = stmt.where(User.tenant_id == int(tenant_id)) + result = db.execute(stmt).first() + if not result: + return _contact_card_from_user( + user=None, + tenant_name=tenant_name, + branch_name=branch_name, + source_label="Firm team", + ) + + user, resolved_tenant_name, resolved_branch_name = result + return _contact_card_from_user( + user=user, + tenant_name=resolved_tenant_name or tenant_name, + branch_name=resolved_branch_name or branch_name, + source_label=source_label, + ) diff --git a/app/modules/clients/constants.py b/app/modules/clients/constants.py new file mode 100644 index 0000000..219d4d3 --- /dev/null +++ b/app/modules/clients/constants.py @@ -0,0 +1,44 @@ +ENGAGEMENT_MODES = [ + "internal_managed", + "self_tracked", + "hybrid", +] + +ASSOCIATION_TYPES = [ + "firm", + "consultant", + "firm_consultant", + "self_service_unassigned", +] + +CLIENT_TYPES = [ + "Proprietorship", + "Partnership", + "LLP", + "Private Limited Company", + "Public Limited Company", + "Trust", + "Society", + "AOP", + "HUF", + "NRI", + "Other", +] + +CLIENT_STATUS = ["active", "inactive", "archived"] + +CLIENT_CATEGORY_OPTIONS = [ + "Audit", "Tax", "GST", "Compliance", "Payroll", "Advisory", "Litigation", "Internal", "Other", +] + +RISK_CATEGORIES = ["low", "medium", "high", "critical"] + +CLIENT_SORT_FIELDS = { + "client_code": "client_code", + "client_name": "client_name", + "client_type": "client_type", + "status": "status", + "created_at_utc": "created_at_utc", + "updated_at_utc": "updated_at_utc", + "onboarding_date": "onboarding_date", +} diff --git a/app/modules/clients/filters.py b/app/modules/clients/filters.py new file mode 100644 index 0000000..d1e9976 --- /dev/null +++ b/app/modules/clients/filters.py @@ -0,0 +1,36 @@ + +from dataclasses import dataclass + +@dataclass +class ClientListFilters: + q: str = "" + status: str = "" + client_type: str = "" + partner_id: int | None = None + include_archived: bool = False + page: int = 1 + per_page: int = 10 + sort_by: str = "client_name" + sort_order: str = "asc" + + @classmethod + def from_params(cls, **kwargs): + partner_id = kwargs.get("partner_id") + if partner_id in ("", None): + partner_id = None + elif not isinstance(partner_id, int): + partner_id = int(partner_id) + include_archived = kwargs.get("include_archived", False) + if isinstance(include_archived, str): + include_archived = include_archived.lower() in ("1", "true", "yes", "on") + return cls( + q=kwargs.get("q", "") or "", + status=kwargs.get("status", "") or "", + client_type=kwargs.get("client_type", "") or "", + partner_id=partner_id, + include_archived=include_archived, + page=max(int(kwargs.get("page", 1) or 1), 1), + per_page=min(max(int(kwargs.get("per_page", 10) or 10), 1), 100), + sort_by=kwargs.get("sort_by", "client_name") or "client_name", + sort_order=kwargs.get("sort_order", "asc") or "asc", + ) diff --git a/app/modules/clients/import_service.py b/app/modules/clients/import_service.py new file mode 100644 index 0000000..216b9d9 --- /dev/null +++ b/app/modules/clients/import_service.py @@ -0,0 +1,281 @@ +from __future__ import annotations + +import io +import json +from dataclasses import dataclass +from datetime import datetime +from typing import Any + +from openpyxl import Workbook, load_workbook +from sqlalchemy.orm import Session + +from app.modules.clients import repository +from app.modules.clients.schemas import ClientCreate +from app.modules.clients.service import create_client_service + +TEMPLATE_COLUMNS = [ + "uploader_user_id", + "firm_tenant_id", + "partner_user_id", + "branch_id", + "client_code", + "client_name", + "client_type", + "engagement_mode", + "email", + "portal_password", + "portal_password_confirm", + "mobile", + "pan", + "gstin", + "tan", + "cin_llpin", + "msme_no", + "iec_code", + "contact_person_name", + "contact_person_designation", + "alternate_mobile", + "alternate_email", + "address_line_1", + "address_line_2", + "city", + "state", + "pincode", + "country", + "client_category", + "risk_category", + "onboarding_date", + "closing_date", + "notes", + "status", + "gst_applicable", + "income_tax_applicable", + "tds_applicable", + "roc_applicable", + "audit_applicable", + "pf_applicable", + "esi_applicable", + "professional_tax_applicable", + "payroll_applicable", + "msme_applicable", + "import_export_applicable", +] + +BOOL_FIELDS = { + "gst_applicable", "income_tax_applicable", "tds_applicable", "roc_applicable", + "audit_applicable", "pf_applicable", "esi_applicable", "professional_tax_applicable", + "payroll_applicable", "msme_applicable", "import_export_applicable", +} + +@dataclass +class ImportPreview: + valid_rows: list[dict] + errors: list[dict] + total_rows: int + + +def _clean(value: Any) -> str | None: + if value is None: + return None + txt = str(value).strip() + return txt or None + + +def _to_bool(value: Any) -> bool: + txt = str(value or '').strip().lower() + return txt in {'1','true','yes','y','on'} + + +def build_client_import_template_bytes(*, current_user, tenant_id: int, partner_id: int | None) -> bytes: + wb = Workbook() + ws = wb.active + ws.title = 'clients_import' + ws.append(TEMPLATE_COLUMNS) + sample = [ + current_user.id, tenant_id, partner_id or current_user.id, getattr(current_user, 'branch_id', '') or '', + 'CLT-001', 'Sample Client', 'Other', 'internal_managed', 'client@example.com', 'ChangeMe@123', 'ChangeMe@123', + '9876543210', '', '', '', '', '', '', 'Client Contact', 'Proprietor', '', '', 'Address line 1', '', 'Chennai', 'Tamil Nadu', '600001', 'India', '', '', '', '', '', 'active', + 'yes', 'yes', 'no', 'no', 'no', 'no', 'no', 'no', 'no', 'no', 'no' + ] + ws.append(sample) + ref = wb.create_sheet('instructions') + ref.append(['Field', 'Notes']) + ref.append(['uploader_user_id', 'Must match the logged-in uploader user id exactly.']) + ref.append(['firm_tenant_id', 'Must match the active firm/tenant context of the upload.']) + ref.append(['partner_user_id', 'Must be an active Partner user mapped to the same firm.']) + ref.append(['branch_id', 'Optional. If blank, uploader branch or partner branch will be used.']) + ref.append(['email', 'Used as the client frontend login email.']) + ref.append(['portal_password', 'Minimum 8 characters.']) + ref.append(['portal_password_confirm', 'Must match portal_password.']) + bio = io.BytesIO() + wb.save(bio) + return bio.getvalue() + + +def _row_dict(ws, row_idx: int) -> dict[str, Any]: + headers = [str(c.value).strip() if c.value is not None else '' for c in ws[1]] + values = [c.value for c in ws[row_idx]] + return {headers[i]: values[i] if i < len(values) else None for i in range(len(headers)) if headers[i]} + + +def build_preview(db: Session, *, current_user, scope, role_names: set[str], upload_bytes: bytes) -> ImportPreview: + wb = load_workbook(io.BytesIO(upload_bytes), data_only=True) + ws = wb[wb.sheetnames[0]] + headers = [str(c.value).strip() if c.value is not None else '' for c in ws[1]] + missing = [c for c in TEMPLATE_COLUMNS if c not in headers] + if missing: + return ImportPreview(valid_rows=[], errors=[{'row_number': 1, 'messages': [f'Missing required columns: {", ".join(missing)}']}], total_rows=0) + + valid_rows = [] + errors = [] + active_tenant_id = int(scope.tenant_id) + active_branch_id = int(scope.branch_id or getattr(current_user, 'branch_id', 0) or 0) + + for row_idx in range(2, ws.max_row + 1): + raw = _row_dict(ws, row_idx) + if not any(v not in (None, '') for v in raw.values()): + continue + msgs: list[str] = [] + cleaned = {k: (_to_bool(v) if k in BOOL_FIELDS else _clean(v)) for k, v in raw.items()} + + try: + uploader_user_id = int(cleaned.get('uploader_user_id') or 0) + except Exception: + uploader_user_id = 0 + try: + firm_tenant_id = int(cleaned.get('firm_tenant_id') or 0) + except Exception: + firm_tenant_id = 0 + try: + partner_user_id = int(cleaned.get('partner_user_id') or 0) + except Exception: + partner_user_id = 0 + try: + branch_id = int(cleaned.get('branch_id') or 0) + except Exception: + branch_id = 0 + + if uploader_user_id != int(current_user.id): + msgs.append('uploader_user_id must match the currently logged-in user id.') + if firm_tenant_id != active_tenant_id: + msgs.append('firm_tenant_id must match the active firm/tenant context of the uploader.') + partner = repository.get_partner_for_tenant(db, partner_user_id=partner_user_id, tenant_id=firm_tenant_id) if partner_user_id else None + if not partner: + msgs.append('partner_user_id must belong to an active Partner user in the same firm.') + if 'partner' in role_names and partner_user_id != int(current_user.id): + msgs.append('Partner uploader can import only for their own partner_user_id.') + + if branch_id: + branch = repository.get_branch(db, branch_id) + if not branch or int(branch.tenant_id) != firm_tenant_id: + msgs.append('branch_id must belong to the same firm/tenant.') + else: + branch_id = int(getattr(partner, 'branch_id', None) or active_branch_id or getattr(current_user, 'branch_id', 0) or 0) + if not branch_id: + msgs.append('branch_id is required when uploader and partner have no branch mapped.') + + payload = { + 'tenant_id': firm_tenant_id, + 'branch_id': branch_id, + 'partner_id': partner_user_id or None, + 'engagement_mode': cleaned.get('engagement_mode') or 'internal_managed', + 'client_code': cleaned.get('client_code') or '', + 'client_name': cleaned.get('client_name') or '', + 'trade_name': None, + 'client_type': cleaned.get('client_type') or 'Other', + 'pan': cleaned.get('pan'), + 'gstin': cleaned.get('gstin'), + 'tan': cleaned.get('tan'), + 'cin_llpin': cleaned.get('cin_llpin'), + 'msme_no': cleaned.get('msme_no'), + 'iec_code': cleaned.get('iec_code'), + 'contact_person_name': cleaned.get('contact_person_name'), + 'contact_person_designation': cleaned.get('contact_person_designation'), + 'mobile': cleaned.get('mobile'), + 'alternate_mobile': cleaned.get('alternate_mobile'), + 'email': cleaned.get('email'), + 'alternate_email': cleaned.get('alternate_email'), + 'address_line_1': cleaned.get('address_line_1'), + 'address_line_2': cleaned.get('address_line_2'), + 'city': cleaned.get('city'), + 'state': cleaned.get('state'), + 'pincode': cleaned.get('pincode'), + 'country': cleaned.get('country') or 'India', + 'status': cleaned.get('status') or 'active', + 'client_category': cleaned.get('client_category'), + 'risk_category': cleaned.get('risk_category'), + 'onboarding_date': cleaned.get('onboarding_date'), + 'closing_date': cleaned.get('closing_date'), + 'notes': cleaned.get('notes'), + 'gst_applicable': cleaned.get('gst_applicable') or False, + 'income_tax_applicable': cleaned.get('income_tax_applicable') or False, + 'tds_applicable': cleaned.get('tds_applicable') or False, + 'roc_applicable': cleaned.get('roc_applicable') or False, + 'audit_applicable': cleaned.get('audit_applicable') or False, + 'pf_applicable': cleaned.get('pf_applicable') or False, + 'esi_applicable': cleaned.get('esi_applicable') or False, + 'professional_tax_applicable': cleaned.get('professional_tax_applicable') or False, + 'payroll_applicable': cleaned.get('payroll_applicable') or False, + 'msme_applicable': cleaned.get('msme_applicable') or False, + 'import_export_applicable': cleaned.get('import_export_applicable') or False, + } + + try: + ClientCreate(**payload) + except Exception as exc: + msgs.append(str(exc)) + + if not cleaned.get('portal_password'): + msgs.append('portal_password is required for imported clients.') + if cleaned.get('portal_password') != cleaned.get('portal_password_confirm'): + msgs.append('portal_password and portal_password_confirm must match.') + + # intra-file duplicate client codes + if any(v.get('client_code') == payload['client_code'] and v.get('tenant_id') == firm_tenant_id for v in valid_rows): + msgs.append('Duplicate client_code found within the same upload file.') + + if msgs: + errors.append({'row_number': row_idx, 'messages': msgs, 'row': cleaned}) + continue + + valid_rows.append({ + 'row_number': row_idx, + 'tenant_id': firm_tenant_id, + 'branch_id': branch_id, + 'partner_id': partner_user_id, + 'client_payload': payload, + 'portal_password': cleaned.get('portal_password'), + 'portal_password_confirm': cleaned.get('portal_password_confirm'), + }) + + return ImportPreview(valid_rows=valid_rows, errors=errors, total_rows=len(valid_rows) + len(errors)) + + +def serialize_preview_rows(valid_rows: list[dict]) -> str: + return json.dumps(valid_rows, default=str) + + +def deserialize_preview_rows(raw: str) -> list[dict]: + rows = json.loads(raw or '[]') + return rows if isinstance(rows, list) else [] + + +def commit_import(db: Session, *, current_user, scope, current_user_roles: list[str], preview_rows: list[dict]) -> dict: + created = [] + failures = [] + for item in preview_rows: + try: + data = ClientCreate(**item['client_payload']) + row = create_client_service( + db, + data=data, + actor_user_id=current_user.id, + scope=scope, + current_user_roles=current_user_roles, + portal_password=item.get('portal_password'), + portal_password_confirm=item.get('portal_password_confirm'), + ) + created.append({'id': row.id, 'client_code': row.client_code, 'client_name': row.client_name}) + except Exception as exc: + failures.append({'row_number': item.get('row_number'), 'message': str(getattr(exc, 'detail', exc))}) + return {'created': created, 'failures': failures} diff --git a/app/modules/clients/models.py b/app/modules/clients/models.py new file mode 100644 index 0000000..2086dc3 --- /dev/null +++ b/app/modules/clients/models.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from datetime import date, datetime, timezone + +from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, JSON, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.db.common import CommonBase + + +class Client(CommonBase): + __tablename__ = "clients" + __table_args__ = ( + UniqueConstraint("tenant_id", "client_code", name="uq_clients_tenant_code"), + UniqueConstraint("tenant_id", "pan", name="uq_clients_tenant_pan"), + UniqueConstraint("tenant_id", "gstin", name="uq_clients_tenant_gstin"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id"), nullable=False, index=True) + branch_id: Mapped[int] = mapped_column(ForeignKey("branches.id"), nullable=False, index=True) + partner_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True) + default_review_partner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True) + engagement_mode: Mapped[str] = mapped_column(String(30), nullable=False, default="internal_managed", index=True) + client_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + client_name: Mapped[str] = mapped_column(String(200), nullable=False, index=True) + trade_name: Mapped[str | None] = mapped_column(String(200), nullable=True) + client_type: Mapped[str] = mapped_column(String(100), nullable=False, default="Other") + pan: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True) + gstin: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True) + tan: Mapped[str | None] = mapped_column(String(20), nullable=True) + cin_llpin: Mapped[str | None] = mapped_column(String(30), nullable=True) + msme_no: Mapped[str | None] = mapped_column(String(50), nullable=True) + iec_code: Mapped[str | None] = mapped_column(String(30), nullable=True) + contact_person_name: Mapped[str | None] = mapped_column(String(200), nullable=True) + contact_person_designation: Mapped[str | None] = mapped_column(String(200), nullable=True) + mobile: Mapped[str | None] = mapped_column(String(20), nullable=True) + alternate_mobile: Mapped[str | None] = mapped_column(String(20), nullable=True) + email: Mapped[str | None] = mapped_column(String(255), nullable=True) + alternate_email: Mapped[str | None] = mapped_column(String(255), nullable=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) + country: Mapped[str | None] = mapped_column(String(100), nullable=True, default="India") + status: Mapped[str] = mapped_column(String(20), nullable=False, default="active", index=True) + client_category: Mapped[str | None] = mapped_column(String(100), nullable=True) + risk_category: Mapped[str | None] = mapped_column(String(50), nullable=True) + onboarding_date: Mapped[date | None] = mapped_column(Date, nullable=True) + closing_date: Mapped[date | None] = mapped_column(Date, nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + gst_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + income_tax_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + tds_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + roc_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + audit_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + pf_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + esi_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + professional_tax_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + payroll_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + msme_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + import_export_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + is_archived: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + archived_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=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) + portal_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=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 ClientAuditLog(CommonBase): + __tablename__ = "client_audit_logs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + client_id: Mapped[int] = mapped_column(ForeignKey("clients.id"), nullable=False, index=True) + tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id"), nullable=False, index=True) + branch_id: Mapped[int] = mapped_column(ForeignKey("branches.id"), nullable=False, index=True) + actor_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) + action: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + summary: Mapped[str] = mapped_column(String(255), nullable=False) + payload_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) diff --git a/app/modules/clients/permissions.py b/app/modules/clients/permissions.py new file mode 100644 index 0000000..3df4fa6 --- /dev/null +++ b/app/modules/clients/permissions.py @@ -0,0 +1,15 @@ +CLIENT_PERMISSION_CODES = { + "view": "clients.view", + "create": "clients.create", + "edit": "clients.edit", + "deactivate": "clients.deactivate", + "activate": "clients.activate", + "archive": "clients.archive", + "restore": "clients.restore", + "assign_partner": "clients.assign_partner", + "cross_branch": "clients.cross_branch", + "cross_tenant": "clients.cross_tenant", + "export": "clients.export", + "audit_log_view": "clients.audit_log.view", + "view_own_only": "clients.view.own_only", +} diff --git a/app/modules/clients/portal_service.py b/app/modules/clients/portal_service.py new file mode 100644 index 0000000..275926c --- /dev/null +++ b/app/modules/clients/portal_service.py @@ -0,0 +1,244 @@ +from __future__ import annotations + +from collections import Counter +from datetime import date +from typing import Any + +from sqlalchemy import select, func +from sqlalchemy.orm import Session, selectinload + +from app.modules.documents.models import EngagementDocument, PermanentClientDocument +from app.modules.services.models import ( + ClientServiceSubscription, + ClientServiceTaskInstance, + ServiceTaskComment, +) + +OPEN_TASK_STATUSES = {"pending", "in_progress", "blocked", "ready_for_review", "rework"} +CLOSED_TASK_STATUSES = {"completed", "approved", "closed", "not_applicable"} + + +def _client_id(client_row: dict[str, Any]) -> int: + return int(client_row.get("id") or 0) + + +def _tenant_id(client_row: dict[str, Any]) -> int: + return int(client_row.get("tenant_id") or 0) + + +def list_client_engagements(db: Session, client_row: dict[str, Any], *, limit: int = 200, financial_year: str | None = None) -> list[ClientServiceSubscription]: + """Return engagements/subscriptions visible to the logged-in client.""" + query = ( + select(ClientServiceSubscription) + .options( + selectinload(ClientServiceSubscription.catalogue), + selectinload(ClientServiceSubscription.assigned_partner), + selectinload(ClientServiceSubscription.assigned_manager), + selectinload(ClientServiceSubscription.assigned_staff), + ) + .where( + ClientServiceSubscription.tenant_id == _tenant_id(client_row), + ClientServiceSubscription.client_id == _client_id(client_row), + ClientServiceSubscription.is_active.is_(True), + ) + ) + if financial_year: + query = query.where(ClientServiceSubscription.financial_year == financial_year.strip()) + rows = db.execute( + query.order_by( + ClientServiceSubscription.current_due_date.asc().nulls_last(), + ClientServiceSubscription.updated_at_utc.desc(), + ) + .limit(max(1, min(int(limit or 200), 500))) + ).scalars().all() + return rows + + +def list_client_tasks_for_engagement(db: Session, client_row: dict[str, Any], engagement_id: int) -> list[ClientServiceTaskInstance]: + return db.execute( + select(ClientServiceTaskInstance) + .options( + selectinload(ClientServiceTaskInstance.catalogue), + selectinload(ClientServiceTaskInstance.assigned_to), + selectinload(ClientServiceTaskInstance.comments).selectinload(ServiceTaskComment.created_by), + ) + .where( + ClientServiceTaskInstance.tenant_id == _tenant_id(client_row), + ClientServiceTaskInstance.client_id == _client_id(client_row), + ClientServiceTaskInstance.subscription_id == int(engagement_id), + ClientServiceTaskInstance.is_active.is_(True), + ) + .order_by(ClientServiceTaskInstance.sequence_no.asc(), ClientServiceTaskInstance.id.asc()) + ).scalars().all() + + +def get_client_engagement(db: Session, client_row: dict[str, Any], engagement_id: int, *, financial_year: str | None = None) -> ClientServiceSubscription | None: + query = ( + select(ClientServiceSubscription) + .options( + selectinload(ClientServiceSubscription.catalogue), + selectinload(ClientServiceSubscription.assigned_partner), + selectinload(ClientServiceSubscription.assigned_manager), + selectinload(ClientServiceSubscription.assigned_staff), + ) + .where( + ClientServiceSubscription.id == int(engagement_id), + ClientServiceSubscription.tenant_id == _tenant_id(client_row), + ClientServiceSubscription.client_id == _client_id(client_row), + ClientServiceSubscription.is_active.is_(True), + ) + ) + if financial_year: + query = query.where(ClientServiceSubscription.financial_year == financial_year.strip()) + return db.execute(query).scalar_one_or_none() + + +def get_client_task(db: Session, client_row: dict[str, Any], task_id: int, *, financial_year: str | None = None) -> ClientServiceTaskInstance | None: + query = ( + select(ClientServiceTaskInstance) + .where( + ClientServiceTaskInstance.id == int(task_id), + ClientServiceTaskInstance.tenant_id == _tenant_id(client_row), + ClientServiceTaskInstance.client_id == _client_id(client_row), + ClientServiceTaskInstance.is_active.is_(True), + ) + ) + if financial_year: + query = query.where(ClientServiceTaskInstance.financial_year == financial_year.strip()) + return db.execute(query).scalar_one_or_none() + + +def list_client_visible_comments(db: Session, client_row: dict[str, Any], *, limit: int = 100, financial_year: str | None = None) -> list[ServiceTaskComment]: + query = ( + select(ServiceTaskComment) + .options( + selectinload(ServiceTaskComment.created_by), + selectinload(ServiceTaskComment.task).selectinload(ClientServiceTaskInstance.catalogue), + selectinload(ServiceTaskComment.subscription).selectinload(ClientServiceSubscription.catalogue), + ) + .join(ClientServiceTaskInstance, ClientServiceTaskInstance.id == ServiceTaskComment.task_instance_id) + .where( + ServiceTaskComment.tenant_id == _tenant_id(client_row), + ServiceTaskComment.visibility == "client", + ServiceTaskComment.is_deleted.is_(False), + ClientServiceTaskInstance.client_id == _client_id(client_row), + ClientServiceTaskInstance.tenant_id == _tenant_id(client_row), + ClientServiceTaskInstance.is_active.is_(True), + ) + ) + if financial_year: + query = query.where(ClientServiceTaskInstance.financial_year == financial_year.strip()) + return db.execute( + query.order_by(ServiceTaskComment.created_at_utc.desc(), ServiceTaskComment.id.desc()) + .limit(max(1, min(int(limit or 100), 300))) + ).scalars().all() + + +def create_client_reply(db: Session, *, client_row: dict[str, Any], task: ClientServiceTaskInstance, message: str, user) -> ServiceTaskComment: + clean_message = (message or "").strip() + if not clean_message: + raise ValueError("Reply message is required.") + if len(clean_message) > 4000: + raise ValueError("Reply message is too long. Please keep it within 4000 characters.") + comment = ServiceTaskComment( + tenant_id=task.tenant_id, + branch_id=task.branch_id, + subscription_id=task.subscription_id, + task_instance_id=task.id, + comment_type="client_clarification", + visibility="client", + message=clean_message, + created_by_user_id=getattr(user, "id", None), + ) + db.add(comment) + db.flush() + return comment + + +def list_client_engagement_documents(db: Session, client_row: dict[str, Any], *, engagement_id: int | None = None, financial_year: str | None = None) -> list[EngagementDocument]: + stmt = ( + select(EngagementDocument) + .options(selectinload(EngagementDocument.versions), selectinload(EngagementDocument.engagement).selectinload(ClientServiceSubscription.catalogue)) + .where( + EngagementDocument.tenant_id == _tenant_id(client_row), + EngagementDocument.client_id == _client_id(client_row), + EngagementDocument.is_deleted.is_(False), + ) + ) + if engagement_id is not None: + stmt = stmt.where(EngagementDocument.engagement_id == int(engagement_id)) + if financial_year: + stmt = stmt.where(EngagementDocument.financial_year == financial_year.strip()) + return db.execute(stmt.order_by(EngagementDocument.updated_at_utc.desc())).unique().scalars().all() + + +def list_client_permanent_documents(db: Session, client_row: dict[str, Any]) -> list[PermanentClientDocument]: + return db.execute( + select(PermanentClientDocument) + .options(selectinload(PermanentClientDocument.versions)) + .where( + PermanentClientDocument.tenant_id == _tenant_id(client_row), + PermanentClientDocument.client_id == _client_id(client_row), + PermanentClientDocument.is_deleted.is_(False), + ) + .order_by(PermanentClientDocument.category.asc(), PermanentClientDocument.updated_at_utc.desc()) + ).unique().scalars().all() + + +def build_client_portal_summary(db: Session, client_row: dict[str, Any], *, financial_year: str | None = None) -> dict[str, Any]: + engagements = list_client_engagements(db, client_row, limit=500, financial_year=financial_year) + engagement_ids = [row.id for row in engagements] + today = date.today() + + task_rows: list[ClientServiceTaskInstance] = [] + if engagement_ids: + task_rows = db.execute( + select(ClientServiceTaskInstance).where( + ClientServiceTaskInstance.tenant_id == _tenant_id(client_row), + ClientServiceTaskInstance.client_id == _client_id(client_row), + ClientServiceTaskInstance.subscription_id.in_(engagement_ids), + ClientServiceTaskInstance.is_active.is_(True), + ) + ).scalars().all() + + status_counter = Counter((task.status or "pending") for task in task_rows) + open_tasks = [task for task in task_rows if (task.status or "pending") in OPEN_TASK_STATUSES] + overdue_tasks = [ + task for task in open_tasks + if task.internal_target_date is not None and task.internal_target_date < today + ] + due_soon_engagements = [ + row for row in engagements + if row.current_due_date is not None and row.current_due_date >= today + ][:10] + + pending_from_client = 0 + with_firm = 0 + completed = 0 + clarification_required = 0 + for row in engagements: + tasks_for_eng = [t for t in task_rows if t.subscription_id == row.id] + statuses = {(t.status or "pending") for t in tasks_for_eng} + if statuses & {"blocked", "client_pending", "clarification_required"}: + clarification_required += 1 + elif tasks_for_eng and all((t.status or "pending") in CLOSED_TASK_STATUSES for t in tasks_for_eng): + completed += 1 + elif statuses & {"pending"}: + pending_from_client += 1 + else: + with_firm += 1 + + return { + "engagements": engagements, + "task_rows": task_rows, + "status_counter": status_counter, + "total_engagements": len(engagements), + "open_tasks": len(open_tasks), + "overdue_tasks": len(overdue_tasks), + "completed_tasks": status_counter.get("completed", 0) + status_counter.get("approved", 0) + status_counter.get("closed", 0), + "due_soon_engagements": due_soon_engagements, + "pending_from_client": pending_from_client, + "with_firm": with_firm, + "clarification_required": clarification_required, + "completed_engagements": completed, + } diff --git a/app/modules/clients/repository.py b/app/modules/clients/repository.py new file mode 100644 index 0000000..66b6ed4 --- /dev/null +++ b/app/modules/clients/repository.py @@ -0,0 +1,523 @@ +from __future__ import annotations + +from math import ceil + +from sqlalchemy import asc, case, desc, func, or_, select +from sqlalchemy.orm import Session + +from app.core.security.passwords import hash_password +from app.modules.clients.association_models import ClientAssociation +from app.modules.clients.constants import CLIENT_SORT_FIELDS +from app.modules.clients.models import Client, ClientAuditLog +from app.modules.core.iam.models import User +from app.modules.core.rbac.models import Role, UserRole +from app.modules.core.tenancy.models import Branch, Tenant + + +def _safe_sort(sort_by: str, sort_order: str): + attr_name = CLIENT_SORT_FIELDS.get(sort_by, "client_name") + column = getattr(Client, attr_name) + return desc(column) if sort_order == "desc" else asc(column) + + +def build_clients_query( + *, + tenant_id: int, + branch_id: int | None = None, + allow_cross_branch: bool = False, + allow_all_clients: bool = False, + partner_id: int | None = None, + q: str = "", + status: str = "", + client_type: str = "", + include_archived: bool = False, +): + assoc = ClientAssociation + + stmt = ( + select( + Client, + User.full_name.label("partner_name"), + Branch.name.label("branch_name"), + Tenant.name.label("tenant_name"), + assoc.association_type.label("association_type"), + assoc.firm_tenant_id.label("assoc_firm_tenant_id"), + assoc.consultant_id.label("assoc_consultant_id"), + assoc.partner_user_id.label("assoc_partner_user_id"), + assoc.created_source.label("assoc_created_source"), + ) + .outerjoin(assoc, assoc.client_id == Client.id) + .join(User, User.id == Client.partner_id, isouter=True) + .join(Branch, Branch.id == Client.branch_id, isouter=True) + .join(Tenant, Tenant.id == Client.tenant_id, isouter=True) + ) + + if not allow_all_clients: + stmt = stmt.where(Client.tenant_id == tenant_id) + + if not include_archived: + stmt = stmt.where(Client.is_archived.is_(False)) + + if branch_id and not allow_all_clients and not allow_cross_branch: + stmt = stmt.where(Client.branch_id == branch_id) + + if partner_id: + stmt = stmt.where( + (Client.partner_id == partner_id) | (assoc.partner_user_id == partner_id) + ) + + if status: + stmt = stmt.where(Client.status == status) + + if client_type: + stmt = stmt.where(Client.client_type == client_type) + + if q: + like = f"%{q.strip()}%" + stmt = stmt.where( + or_( + Client.client_code.ilike(like), + Client.client_name.ilike(like), + Client.trade_name.ilike(like), + Client.pan.ilike(like), + Client.gstin.ilike(like), + Client.mobile.ilike(like), + Client.email.ilike(like), + ) + ) + + return stmt + + +def list_clients( + db: Session, + *, + tenant_id: int, + branch_id: int | None = None, + allow_cross_branch: bool = False, + allow_all_clients: bool = False, + partner_id: int | None = None, + q: str = "", + status: str = "", + client_type: str = "", + include_archived: bool = False, + page: int = 1, + per_page: int = 10, + sort_by: str = "client_name", + sort_order: str = "asc", +) -> dict: + stmt = build_clients_query( + tenant_id=tenant_id, + branch_id=branch_id, + allow_cross_branch=allow_cross_branch, + allow_all_clients=allow_all_clients, + partner_id=partner_id, + q=q, + status=status, + client_type=client_type, + include_archived=include_archived, + ) + + total = db.execute(select(func.count()).select_from(stmt.subquery())).scalar_one() + result = db.execute( + stmt.order_by(_safe_sort(sort_by, sort_order)) + .offset((page - 1) * per_page) + .limit(per_page) + ).all() + + rows = [] + for ( + client, + partner_name, + branch_name, + tenant_name, + association_type, + assoc_firm_tenant_id, + assoc_consultant_id, + assoc_partner_user_id, + assoc_created_source, + ) in result: + row = {**client.__dict__} + row.pop("_sa_instance_state", None) + row.update( + { + "partner_name": partner_name, + "branch_name": branch_name, + "tenant_name": tenant_name, + "association_type": association_type, + "assoc_firm_tenant_id": assoc_firm_tenant_id, + "assoc_consultant_id": assoc_consultant_id, + "assoc_partner_user_id": assoc_partner_user_id, + "assoc_created_source": assoc_created_source, + "effective_partner_id": assoc_partner_user_id or row.get("partner_id"), + } + ) + rows.append(row) + + stats_stmt = select( + func.count(Client.id), + func.sum(case((Client.status == "active", 1), else_=0)), + func.sum(case((Client.status == "inactive", 1), else_=0)), + func.sum(case((Client.status == "archived", 1), else_=0)), + ) + + if not allow_all_clients: + stats_stmt = stats_stmt.where(Client.tenant_id == tenant_id) + if branch_id and not allow_cross_branch: + stats_stmt = stats_stmt.where(Client.branch_id == branch_id) + + if partner_id: + stats_stmt = stats_stmt.where(Client.partner_id == partner_id) + + total_all, active, inactive, archived = db.execute(stats_stmt).one() + + pages = ceil(total / per_page) if per_page else 1 + return { + "rows": rows, + "meta": { + "total": total, + "page": page, + "per_page": per_page, + "pages": max(pages, 1), + }, + "stats": { + "total": int(total_all or 0), + "active": int(active or 0), + "inactive": int(inactive or 0), + "archived": int(archived or 0), + }, + } + + +def get_client_detail_payload(db: Session, client_id: int): + assoc = ClientAssociation + + stmt = ( + select( + Client, + assoc.association_type.label("association_type"), + assoc.firm_tenant_id.label("assoc_firm_tenant_id"), + assoc.consultant_id.label("assoc_consultant_id"), + assoc.partner_user_id.label("assoc_partner_user_id"), + assoc.created_source.label("assoc_created_source"), + ) + .outerjoin(assoc, assoc.client_id == Client.id) + .where(Client.id == client_id) + ) + + result = db.execute(stmt).one_or_none() + if not result: + return None + + ( + client, + association_type, + assoc_firm_tenant_id, + assoc_consultant_id, + assoc_partner_user_id, + assoc_created_source, + ) = result + + row = {**client.__dict__} + row.pop("_sa_instance_state", None) + row.update( + { + "association_type": association_type, + "assoc_firm_tenant_id": assoc_firm_tenant_id, + "assoc_consultant_id": assoc_consultant_id, + "assoc_partner_user_id": assoc_partner_user_id, + "assoc_created_source": assoc_created_source, + "effective_partner_id": assoc_partner_user_id or row.get("partner_id"), + } + ) + return row + + +def get_client_by_id(db: Session, client_id: int): + return db.get(Client, client_id) + + +def get_client_by_code(db: Session, *, tenant_id: int, client_code: str): + return db.execute( + select(Client).where(Client.tenant_id == tenant_id, Client.client_code == client_code) + ).scalar_one_or_none() + + +def get_client_by_pan(db: Session, *, tenant_id: int, pan: str): + return db.execute( + select(Client).where(Client.tenant_id == tenant_id, Client.pan == pan) + ).scalar_one_or_none() + + +def get_client_by_gstin(db: Session, *, tenant_id: int, gstin: str): + return db.execute( + select(Client).where(Client.tenant_id == tenant_id, Client.gstin == gstin) + ).scalar_one_or_none() + + +def create_client(db: Session, payload: dict): + row = Client(**payload) + db.add(row) + db.commit() + db.refresh(row) + return row + + +def update_client(db: Session, row: Client, payload: dict): + for key, value in payload.items(): + setattr(row, key, value) + db.add(row) + db.commit() + db.refresh(row) + return row + + +def write_audit_log(db: Session, **kwargs): + row = ClientAuditLog(**kwargs) + db.add(row) + db.commit() + db.refresh(row) + return row + + +def list_audit_logs(db: Session, *, client_id: int, limit: int = 50): + stmt = ( + select(ClientAuditLog) + .where(ClientAuditLog.client_id == client_id) + .order_by(ClientAuditLog.created_at_utc.desc()) + .limit(limit) + ) + return db.execute(stmt).scalars().all() + + +def list_tenants(db: Session): + return db.execute( + select(Tenant).where(Tenant.is_active.is_(True)).order_by(Tenant.name.asc()) + ).scalars().all() + + +def list_branches_for_tenant(db: Session, tenant_id: int): + stmt = ( + select(Branch) + .where(Branch.tenant_id == tenant_id, Branch.is_active.is_(True)) + .order_by(Branch.name.asc()) + ) + return db.execute(stmt).scalars().all() + + +def list_partners_for_scope(db: Session, *, tenant_id: int, branch_id: int | None = None): + stmt = ( + select(User) + .join(UserRole, UserRole.user_id == User.id) + .join(Role, Role.id == UserRole.role_id) + .where( + Role.name == "Partner", + User.tenant_id == tenant_id, + User.is_active.is_(True), + User.deleted_at.is_(None), + ) + .order_by(User.full_name.asc(), User.email.asc()) + ) + if branch_id: + stmt = stmt.where(User.branch_id == branch_id) + return db.execute(stmt).scalars().all() + + +def get_branch(db: Session, branch_id: int): + return db.execute( + select(Branch).where(Branch.id == branch_id, Branch.is_active.is_(True)) + ).scalar_one_or_none() + + +def get_partner(db: Session, partner_id: int): + return db.execute( + select(User).where(User.id == partner_id, User.is_active.is_(True), User.deleted_at.is_(None)) + ).scalar_one_or_none() + +def list_all_branches(db: Session): + stmt = ( + select( + Branch, + Tenant.name.label("tenant_name"), + ) + .join(Tenant, Tenant.id == Branch.tenant_id) + .where(Branch.is_active.is_(True)) + .order_by(Tenant.name.asc(), Branch.name.asc()) + ) + + rows = [] + for branch, tenant_name in db.execute(stmt).all(): + branch.tenant_name = tenant_name + rows.append(branch) + return rows + +def list_all_partners(db: Session): + stmt = ( + select( + User, + Tenant.name.label("tenant_name"), + ) + .join(UserRole, UserRole.user_id == User.id) + .join(Role, Role.id == UserRole.role_id) + .join(Tenant, Tenant.id == User.tenant_id, isouter=True) + .where( + Role.name == "Partner", + User.is_active.is_(True), + User.deleted_at.is_(None), + ) + .order_by(Tenant.name.asc(), User.full_name.asc(), User.email.asc()) + ) + + rows = [] + for user, tenant_name in db.execute(stmt).all(): + user.tenant_name = tenant_name + rows.append(user) + return rows + +def list_all_branches(db: Session): + stmt = ( + select( + Branch, + Tenant.name.label("tenant_name"), + ) + .join(Tenant, Tenant.id == Branch.tenant_id) + .where(Branch.is_active.is_(True)) + .order_by(Tenant.name.asc(), Branch.name.asc()) + ) + + rows = [] + for branch, tenant_name in db.execute(stmt).all(): + branch.tenant_name = tenant_name + rows.append(branch) + return rows + + +def list_all_partners(db: Session): + stmt = ( + select( + User, + Tenant.name.label("tenant_name"), + ) + .join(UserRole, UserRole.user_id == User.id) + .join(Role, Role.id == UserRole.role_id) + .join(Tenant, Tenant.id == User.tenant_id, isouter=True) + .where( + Role.name == "Partner", + User.is_active.is_(True), + User.deleted_at.is_(None), + ) + .order_by(Tenant.name.asc(), User.full_name.asc(), User.email.asc()) + ) + + rows = [] + for user, tenant_name in db.execute(stmt).all(): + user.tenant_name = tenant_name + rows.append(user) + return rows + + +def get_portal_client_for_user(db: Session, *, user: User): + email = (getattr(user, "email", "") or "").strip().lower() + tenant_id = getattr(user, "tenant_id", None) + if not email or not tenant_id: + return None + + stmt = ( + select( + Client, + User.full_name.label("partner_name"), + Branch.name.label("branch_name"), + Tenant.name.label("tenant_name"), + ) + .join(User, User.id == Client.partner_id, isouter=True) + .join(Branch, Branch.id == Client.branch_id, isouter=True) + .join(Tenant, Tenant.id == Client.tenant_id, isouter=True) + .where( + Client.tenant_id == tenant_id, + Client.is_archived.is_(False), + or_(Client.email.ilike(email), Client.alternate_email.ilike(email)), + ) + .order_by( + case((Client.status == "active", 0), else_=1), + Client.client_name.asc(), + Client.id.asc(), + ) + ) + result = db.execute(stmt).first() + if not result: + return None + + client, partner_name, branch_name, tenant_name = result + row = {**client.__dict__} + row.pop("_sa_instance_state", None) + row.update( + { + "partner_name": partner_name, + "branch_name": branch_name, + "tenant_name": tenant_name, + } + ) + return row + + +def get_user_by_email(db: Session, *, email: str, exclude_user_id: int | None = None): + email_clean = (email or "").strip().lower() + if not email_clean: + return None + stmt = select(User).where(User.email.ilike(email_clean), User.deleted_at.is_(None)) + if exclude_user_id: + stmt = stmt.where(User.id != exclude_user_id) + return db.execute(stmt).scalar_one_or_none() + + +def get_tenant(db: Session, tenant_id: int): + return db.execute(select(Tenant).where(Tenant.id == tenant_id, Tenant.is_active.is_(True))).scalar_one_or_none() + + +def get_partner_for_tenant(db: Session, *, partner_user_id: int, tenant_id: int): + stmt = ( + select(User) + .join(UserRole, UserRole.user_id == User.id) + .join(Role, Role.id == UserRole.role_id) + .where( + User.id == partner_user_id, + User.tenant_id == tenant_id, + User.is_active.is_(True), + User.deleted_at.is_(None), + Role.name == "Partner", + ) + ) + return db.execute(stmt).scalar_one_or_none() + + +def get_role_by_name(db: Session, role_name: str): + return db.execute(select(Role).where(Role.name == role_name, Role.is_active.is_(True))).scalar_one_or_none() + + +def create_portal_user(db: Session, *, email: str, full_name: str, tenant_id: int, branch_id: int, password: str): + row = User( + email=(email or '').strip().lower(), + full_name=(full_name or '').strip(), + password_hash=hash_password(password), + tenant_id=tenant_id, + branch_id=branch_id, + is_active=True, + allow_login=True, + is_locked=False, + must_change_password=False, + ) + db.add(row) + db.commit() + db.refresh(row) + return row + + +def ensure_user_role(db: Session, *, user_id: int, role_id: int): + existing = db.execute(select(UserRole).where(UserRole.user_id == user_id, UserRole.role_id == role_id)).scalar_one_or_none() + if existing: + return existing + row = UserRole(user_id=user_id, role_id=role_id) + db.add(row) + db.commit() + db.refresh(row) + return row diff --git a/app/modules/clients/schemas.py b/app/modules/clients/schemas.py new file mode 100644 index 0000000..22a84eb --- /dev/null +++ b/app/modules/clients/schemas.py @@ -0,0 +1,386 @@ +from __future__ import annotations + +from datetime import date, datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict, EmailStr, field_validator, model_validator + +from app.modules.clients.constants import ( + CLIENT_CATEGORY_OPTIONS, + CLIENT_SORT_FIELDS, + CLIENT_STATUS, + CLIENT_TYPES, + ENGAGEMENT_MODES, + RISK_CATEGORIES, +) +from app.modules.clients.utils import GSTIN_RE, MOBILE_RE, PAN_RE, PIN_RE, TAN_RE, normalize_text, normalize_upper + + +class ClientBase(BaseModel): + tenant_id: int + branch_id: int + partner_id: Optional[int] = None + default_review_partner_user_id: Optional[int] = None + engagement_mode: str = "internal_managed" + client_code: str + client_name: str + trade_name: Optional[str] = None + client_type: str = "Other" + pan: Optional[str] = None + gstin: Optional[str] = None + tan: Optional[str] = None + cin_llpin: Optional[str] = None + msme_no: Optional[str] = None + iec_code: Optional[str] = None + contact_person_name: Optional[str] = None + contact_person_designation: Optional[str] = None + mobile: Optional[str] = None + alternate_mobile: Optional[str] = None + email: Optional[EmailStr] = None + alternate_email: Optional[EmailStr] = None + address_line_1: Optional[str] = None + address_line_2: Optional[str] = None + city: Optional[str] = None + state: Optional[str] = None + pincode: Optional[str] = None + country: Optional[str] = "India" + status: str = "active" + client_category: Optional[str] = None + risk_category: Optional[str] = None + onboarding_date: Optional[date] = None + closing_date: Optional[date] = None + notes: Optional[str] = None + gst_applicable: bool = False + income_tax_applicable: bool = False + tds_applicable: bool = False + roc_applicable: bool = False + audit_applicable: bool = False + pf_applicable: bool = False + esi_applicable: bool = False + professional_tax_applicable: bool = False + payroll_applicable: bool = False + msme_applicable: bool = False + import_export_applicable: bool = False + + @field_validator("client_code", "client_name", mode="before") + @classmethod + def required_text(cls, value): + value = normalize_text(value) + if not value: + raise ValueError("This field is required.") + return value + + @field_validator( + "trade_name", "cin_llpin", "msme_no", "iec_code", "contact_person_name", "contact_person_designation", + "address_line_1", "address_line_2", "city", "state", "country", "client_category", "risk_category", "notes", + mode="before", + ) + @classmethod + def clean_text(cls, value): + return normalize_text(value) + + @field_validator("pan", "gstin", "tan", mode="before") + @classmethod + def uppercase_codes(cls, value): + return normalize_upper(value) + + @field_validator("engagement_mode", mode="before") + @classmethod + def clean_engagement_mode(cls, value): + value = normalize_text(value) or "internal_managed" + return value.lower() + + @field_validator("engagement_mode") + @classmethod + def validate_engagement_mode(cls, value): + if value not in ENGAGEMENT_MODES: + raise ValueError("Invalid engagement mode.") + return value + + @field_validator("mobile", "alternate_mobile", mode="before") + @classmethod + def clean_mobile(cls, value): + value = normalize_text(value) + if value is None: + return None + value = value.replace(" ", "").replace("-", "") + if value.startswith("+91"): + value = value[3:] + return value + + @field_validator("pan") + @classmethod + def validate_pan(cls, value): + if value and not PAN_RE.match(value): + raise ValueError("Invalid PAN format.") + return value + + @field_validator("gstin") + @classmethod + def validate_gstin(cls, value): + if value and not GSTIN_RE.match(value): + raise ValueError("Invalid GSTIN format.") + return value + + @field_validator("tan") + @classmethod + def validate_tan(cls, value): + if value and not TAN_RE.match(value): + raise ValueError("Invalid TAN format.") + return value + + @field_validator("mobile", "alternate_mobile") + @classmethod + def validate_mobile(cls, value): + if value and not MOBILE_RE.match(value): + raise ValueError("Mobile number must be a valid 10-digit Indian mobile.") + return value + + @field_validator("pincode", mode="before") + @classmethod + def clean_pincode(cls, value): + return normalize_text(value) + + @field_validator("pincode") + @classmethod + def validate_pincode(cls, value): + if value and not PIN_RE.match(value): + raise ValueError("Pincode must be a valid 6-digit code.") + return value + + @model_validator(mode="after") + def validate_dates_and_assignment(self): + if self.onboarding_date and self.closing_date and self.closing_date < self.onboarding_date: + raise ValueError("Closing date cannot be earlier than onboarding date.") + if self.engagement_mode == "internal_managed" and not self.partner_id: + raise ValueError("Partner is required for internal managed clients.") + return self + + +class ClientCreate(ClientBase): + pass + + +class ClientUpdate(BaseModel): + tenant_id: Optional[int] = None + branch_id: Optional[int] = None + partner_id: Optional[int] = None + default_review_partner_user_id: Optional[int] = None + engagement_mode: Optional[str] = None + client_name: Optional[str] = None + trade_name: Optional[str] = None + client_type: Optional[str] = None + pan: Optional[str] = None + gstin: Optional[str] = None + tan: Optional[str] = None + cin_llpin: Optional[str] = None + msme_no: Optional[str] = None + iec_code: Optional[str] = None + contact_person_name: Optional[str] = None + contact_person_designation: Optional[str] = None + mobile: Optional[str] = None + alternate_mobile: Optional[str] = None + email: Optional[EmailStr] = None + alternate_email: Optional[EmailStr] = None + address_line_1: Optional[str] = None + address_line_2: Optional[str] = None + city: Optional[str] = None + state: Optional[str] = None + pincode: Optional[str] = None + country: Optional[str] = None + status: Optional[str] = None + client_category: Optional[str] = None + risk_category: Optional[str] = None + onboarding_date: Optional[date] = None + closing_date: Optional[date] = None + notes: Optional[str] = None + gst_applicable: Optional[bool] = None + income_tax_applicable: Optional[bool] = None + tds_applicable: Optional[bool] = None + roc_applicable: Optional[bool] = None + audit_applicable: Optional[bool] = None + pf_applicable: Optional[bool] = None + esi_applicable: Optional[bool] = None + professional_tax_applicable: Optional[bool] = None + payroll_applicable: Optional[bool] = None + msme_applicable: Optional[bool] = None + import_export_applicable: Optional[bool] = None + model_config = ConfigDict(extra="forbid") + + @field_validator( + "client_name", "trade_name", "cin_llpin", "msme_no", "iec_code", "contact_person_name", "contact_person_designation", + "address_line_1", "address_line_2", "city", "state", "country", "client_category", "risk_category", "notes", + mode="before", + ) + @classmethod + def clean_text(cls, value): + return normalize_text(value) + + @field_validator("pan", "gstin", "tan", mode="before") + @classmethod + def uppercase_codes(cls, value): + return normalize_upper(value) + + @field_validator("engagement_mode", mode="before") + @classmethod + def clean_engagement_mode(cls, value): + if value is None: + return None + value = normalize_text(value) or None + return value.lower() if value else None + + @field_validator("engagement_mode") + @classmethod + def validate_engagement_mode(cls, value): + if value is not None and value not in ENGAGEMENT_MODES: + raise ValueError("Invalid engagement mode.") + return value + + @field_validator("mobile", "alternate_mobile", mode="before") + @classmethod + def clean_mobile(cls, value): + value = normalize_text(value) + if value is None: + return None + value = value.replace(" ", "").replace("-", "") + if value.startswith("+91"): + value = value[3:] + return value + + @field_validator("pan") + @classmethod + def validate_pan(cls, value): + if value and not PAN_RE.match(value): + raise ValueError("Invalid PAN format.") + return value + + @field_validator("gstin") + @classmethod + def validate_gstin(cls, value): + if value and not GSTIN_RE.match(value): + raise ValueError("Invalid GSTIN format.") + return value + + @field_validator("tan") + @classmethod + def validate_tan(cls, value): + if value and not TAN_RE.match(value): + raise ValueError("Invalid TAN format.") + return value + + @field_validator("mobile", "alternate_mobile") + @classmethod + def validate_mobile(cls, value): + if value and not MOBILE_RE.match(value): + raise ValueError("Mobile number must be a valid 10-digit Indian mobile.") + return value + + @field_validator("pincode", mode="before") + @classmethod + def clean_pincode(cls, value): + return normalize_text(value) + + @field_validator("pincode") + @classmethod + def validate_pincode(cls, value): + if value and not PIN_RE.match(value): + raise ValueError("Pincode must be a valid 6-digit code.") + return value + + +class ClientOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int + tenant_id: int + branch_id: int + partner_id: Optional[int] = None + default_review_partner_user_id: Optional[int] = None + engagement_mode: str + client_code: str + client_name: str + trade_name: Optional[str] = None + client_type: str + pan: Optional[str] = None + gstin: Optional[str] = None + tan: Optional[str] = None + cin_llpin: Optional[str] = None + msme_no: Optional[str] = None + iec_code: Optional[str] = None + contact_person_name: Optional[str] = None + contact_person_designation: Optional[str] = None + mobile: Optional[str] = None + alternate_mobile: Optional[str] = None + email: Optional[str] = None + alternate_email: Optional[str] = None + address_line_1: Optional[str] = None + address_line_2: Optional[str] = None + city: Optional[str] = None + state: Optional[str] = None + pincode: Optional[str] = None + country: Optional[str] = None + status: str + client_category: Optional[str] = None + risk_category: Optional[str] = None + onboarding_date: Optional[date] = None + closing_date: Optional[date] = None + notes: Optional[str] = None + gst_applicable: bool + income_tax_applicable: bool + tds_applicable: bool + roc_applicable: bool + audit_applicable: bool + pf_applicable: bool + esi_applicable: bool + professional_tax_applicable: bool + payroll_applicable: bool + msme_applicable: bool + import_export_applicable: bool + is_active: bool + is_archived: bool + created_at_utc: datetime + updated_at_utc: datetime + + +class ClientListRow(ClientOut): + partner_name: Optional[str] = None + branch_name: Optional[str] = None + tenant_name: Optional[str] = None + + +class ClientAuditLogOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int + client_id: int + tenant_id: int + branch_id: int + actor_user_id: Optional[int] = None + action: str + summary: str + payload_json: Optional[dict] = None + created_at_utc: datetime + + +class PaginationMeta(BaseModel): + total: int + page: int + per_page: int + +class ClientFilterOptions(BaseModel): + client_types: list[str] + client_statuses: list[str] + client_categories: list[str] + risk_categories: list[str] + + +class ClientListStats(BaseModel): + total: int = 0 + active: int = 0 + inactive: int = 0 + archived: int = 0 + + +class ClientListResponse(BaseModel): + rows: list[ClientOut] + meta: PaginationMeta + stats: ClientListStats | None = None + filter_options: ClientFilterOptions | None = None \ No newline at end of file diff --git a/app/modules/clients/service.py b/app/modules/clients/service.py new file mode 100644 index 0000000..0e52772 --- /dev/null +++ b/app/modules/clients/service.py @@ -0,0 +1,452 @@ +from __future__ import annotations + +import csv +import io + +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, +) + + +def _payload_from_schema(data): + return data.model_dump(exclude_none=True) if hasattr(data, "model_dump") else data.dict(exclude_none=True) + + + + +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) + 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) + + 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): + 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 diff --git a/app/modules/clients/templates/clients/_client_tabs.html b/app/modules/clients/templates/clients/_client_tabs.html new file mode 100644 index 0000000..bd12389 --- /dev/null +++ b/app/modules/clients/templates/clients/_client_tabs.html @@ -0,0 +1,12 @@ +
+
+ {% set path = request.url.path %} + Overview + My Compliance + My Documents + My Messages + My Bills + My Profile + My Alert +
+
diff --git a/app/modules/clients/templates/clients/add.html b/app/modules/clients/templates/clients/add.html new file mode 100644 index 0000000..bfa317c --- /dev/null +++ b/app/modules/clients/templates/clients/add.html @@ -0,0 +1 @@ +{% extends "ui/templates/base/layout.html" %}{% block content %}

Add Client

Create a validated client master with ownership and branch-safe rules.

{% if form_errors %}
{% for err in form_errors %}
{{ err }}
{% endfor %}
{% endif %}
{% include "modules/clients/templates/clients/partials/form.html" %}
Cancel
{% endblock %} \ No newline at end of file diff --git a/app/modules/clients/templates/clients/compliance.html b/app/modules/clients/templates/clients/compliance.html new file mode 100644 index 0000000..2ffbcd8 --- /dev/null +++ b/app/modules/clients/templates/clients/compliance.html @@ -0,0 +1,23 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +{% include "modules/clients/templates/clients/_client_tabs.html" %} +
+

My Compliance

Service-wise status and pending actions visible to you.

+
+
Pending from Client
{{ pending_from_client or 0 }}
+
With Firm
{{ with_firm or 0 }}
+
Clarification Required
{{ clarification_required or 0 }}
+
Completed
{{ completed_engagements or 0 }}
+
+ +
+{% endblock %} diff --git a/app/modules/clients/templates/clients/detail.html b/app/modules/clients/templates/clients/detail.html new file mode 100644 index 0000000..280d999 --- /dev/null +++ b/app/modules/clients/templates/clients/detail.html @@ -0,0 +1,92 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

{{ row.client_name }}

+

{{ row.client_code }} • {{ row.client_type }} • {{ row.status|title }}

+
+ +
+ {% if can_edit %} + + Edit + + {% endif %} + + {% if can_activate and row.status != 'active' and row.status != 'archived' %} +
+ + +
+ {% endif %} + + {% if can_deactivate and row.status == 'active' %} +
+ + +
+ {% endif %} + + {% if can_archive and row.status != 'archived' %} +
+ + +
+ {% endif %} + + {% if can_restore and row.status == 'archived' %} +
+ + +
+ {% endif %} +
+
+ +
+
+

Profile

+
+ {% for label, value in [ + ('Trade Name', row.trade_name), + ('PAN', row.pan), + ('GSTIN', row.gstin), + ('TAN', row.tan), + ('Contact Person', row.contact_person_name), + ('Designation', row.contact_person_designation), + ('Mobile', row.mobile), + ('Email', row.email) + ] %} +
+
{{ label }}
+
{{ value or '-' }}
+
+ {% endfor %} +
+
+ +
+

Association

+
+
Type: {{ row.association_type or 'legacy_firm' }}
+
Source: {{ row.assoc_created_source or 'legacy' }}
+
Audit Firm: {{ row.assoc_firm_tenant_id or row.tenant_id or '-' }}
+
Branch: {{ row.branch_id or '-' }}
+
Partner: {{ row.assoc_partner_user_id or row.partner_id or '-' }}
+
Default Review Partner: {{ row.default_review_partner_user_id or '-' }}
+
Consultant: {{ row.assoc_consultant_id or '-' }}
+
+
+
+
+{% endblock %} diff --git a/app/modules/clients/templates/clients/documents.html b/app/modules/clients/templates/clients/documents.html new file mode 100644 index 0000000..27128fe --- /dev/null +++ b/app/modules/clients/templates/clients/documents.html @@ -0,0 +1,9 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +{% include "modules/clients/templates/clients/_client_tabs.html" %} +
+

My Documents

View documents shared by your audit firm.

+

Engagement Documents

{% for doc in engagement_documents %}{% set ver = doc.versions[0] if doc.versions else None %}{% else %}{% endfor %}
DocumentServiceTypeVersionAction
{{ doc.title }}{{ doc.engagement.catalogue.service_name if doc.engagement and doc.engagement.catalogue else '-' }}{{ doc.document_type }}v{{ doc.current_version_no }}{% if ver %}Download{% endif %}
No engagement documents shared yet.
+

Permanent Documents

{% for doc in permanent_documents %}{% set ver = doc.versions[0] if doc.versions else None %}{% else %}{% endfor %}
DocumentCategoryVersionAction
{{ doc.title }}{{ doc.category }}v{{ doc.current_version_no }}{% if ver %}Download{% endif %}
No permanent documents shared yet.
+
+{% endblock %} diff --git a/app/modules/clients/templates/clients/edit.html b/app/modules/clients/templates/clients/edit.html new file mode 100644 index 0000000..f16ed46 --- /dev/null +++ b/app/modules/clients/templates/clients/edit.html @@ -0,0 +1,26 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+

Edit Client

+

B5 role-aware edit flow.

+
+ + {% if form_errors %} +
+
    + {% for err in form_errors %}
  • {{ err }}
  • {% endfor %} +
+
+ {% endif %} + +
+ + {% include "modules/clients/templates/clients/partials/form.html" %} +
+ Cancel + +
+
+
+{% endblock %} diff --git a/app/modules/clients/templates/clients/engagement_detail.html b/app/modules/clients/templates/clients/engagement_detail.html new file mode 100644 index 0000000..63e3d03 --- /dev/null +++ b/app/modules/clients/templates/clients/engagement_detail.html @@ -0,0 +1,14 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +{% include "modules/clients/templates/clients/_client_tabs.html" %} +
+

{{ engagement.catalogue.service_name if engagement.catalogue else 'Engagement' }}

FY {{ engagement.financial_year }}{% if engagement.assessment_year %} • AY {{ engagement.assessment_year }}{% endif %} • Due {{ engagement.current_due_date.strftime('%d-%m-%Y') if engagement.current_due_date else '-' }}

Back to My Compliance
+
+
+

Task / Action Status

{% for task in tasks %}
{{ task.task_name }}
{{ task.description or '' }}
{{ task.status|replace('_',' ')|title }}
{% else %}
No task details available.
{% endfor %}
+

Communication Timeline

{% for note in comments %}
{{ note.task.task_name if note.task else 'Message' }}
{{ note.created_at_utc.strftime('%d-%m-%Y %I:%M %p') if note.created_at_utc else '' }}

{{ note.message }}

{% else %}
No visible communication yet.
{% endfor %}
+
+ +
+
+{% endblock %} diff --git a/app/modules/clients/templates/clients/import.html b/app/modules/clients/templates/clients/import.html new file mode 100644 index 0000000..528adc1 --- /dev/null +++ b/app/modules/clients/templates/clients/import.html @@ -0,0 +1,59 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Import Clients

+

Bulk upload clients for the active audit firm with partner validation.

+
+ Download Template +
+ +
+
+
+
Active Audit Firm
+
{{ current_tenant.name if current_tenant else scope.tenant_id }} (ID: {{ scope.tenant_id }})
+
+
+
Logged-in uploader
+
{{ current_user.full_name or current_user.email }} — User ID {{ current_user.id }}
+
+
+ +
+ Template includes uploader_user_id, firm_tenant_id, and partner_user_id. + Validation checks that uploader_user_id matches the logged-in user, firm_tenant_id matches the active audit firm, and partner_user_id belongs to an active Partner in that same audit firm. +
+ +
+
Partners available in this audit firm
+
+ {% for p in partners %} +
{{ p.id }} — {{ p.full_name or p.email }}
+ {% else %} +
No active partners found for this audit firm.
+ {% endfor %} +
+
+ + {% if import_errors %} +
+ {% for err in import_errors %}
{{ err }}
{% endfor %} +
+ {% endif %} + +
+ +
+ + +
+
+ Back + +
+
+
+
+{% endblock %} diff --git a/app/modules/clients/templates/clients/import_preview.html b/app/modules/clients/templates/clients/import_preview.html new file mode 100644 index 0000000..709803b --- /dev/null +++ b/app/modules/clients/templates/clients/import_preview.html @@ -0,0 +1,71 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+

Import Clients Preview

+

Review validation result before final import.

+
+ + {% if import_result is defined and import_result %} +
+
Created {{ import_result.created|length }} clients.
+ {% if import_result.created %} +
+ {% for row in import_result.created %}
{{ row.client_code }} — {{ row.client_name }} (ID {{ row.id }})
{% endfor %} +
+ {% endif %} + {% if import_result.failures %} +
+ {% for err in import_result.failures %}
Row {{ err.row_number }}: {{ err.message }}
{% endfor %} +
+ {% endif %} + +
+ {% else %} +
+
+

Valid rows

+
{{ preview.valid_rows|length }} of {{ preview.total_rows }} rows are ready to import.
+
+ + + + {% for item in preview.valid_rows %} + + {% else %} + + {% endfor %} + +
RowAudit Firm IDPartnerClient CodeClient Name
{{ item.row_number }}{{ item.tenant_id }}{{ item.partner_id }}{{ item.client_payload.client_code }}{{ item.client_payload.client_name }}
No valid rows found.
+
+
+ +
+

Validation errors

+
{{ preview.errors|length }} rows have issues.
+
+ {% for err in preview.errors %} +
+
Row {{ err.row_number }}
+
    {% for msg in err.messages %}
  • {{ msg }}
  • {% endfor %}
+
+ {% else %} +
No validation errors found.
+ {% endfor %} +
+
+
+ +
+ Back + {% if preview.valid_rows %} +
+ + + +
+ {% endif %} +
+ {% endif %} +
+{% endblock %} diff --git a/app/modules/clients/templates/clients/list.html b/app/modules/clients/templates/clients/list.html new file mode 100644 index 0000000..5abcd47 --- /dev/null +++ b/app/modules/clients/templates/clients/list.html @@ -0,0 +1,36 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Clients

+

Association-aware list view.

+
+ +
+ {% if can_export %} + + Export CSV + + {% endif %} + + {% if can_import %} + + Import Clients + + {% endif %} + + {% if can_create %} + + Add Client + + {% endif %} +
+
+ + {% include "modules/clients/templates/clients/partials/table.html" %} +
+{% endblock %} \ No newline at end of file diff --git a/app/modules/clients/templates/clients/messages.html b/app/modules/clients/templates/clients/messages.html new file mode 100644 index 0000000..4e4bdd2 --- /dev/null +++ b/app/modules/clients/templates/clients/messages.html @@ -0,0 +1,5 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +{% include "modules/clients/templates/clients/_client_tabs.html" %} +

My Messages

Client-visible communications and clarifications from your firm.

{% for note in comments %}
{{ note.comment_type|replace('_',' ')|title }}

{{ note.task.task_name if note.task else 'Message' }}

{{ note.subscription.catalogue.service_name if note.subscription and note.subscription.catalogue else '' }}
{{ note.created_at_utc.strftime('%d-%m-%Y %I:%M %p') if note.created_at_utc else '' }}

{{ note.message }}

From: {% if note.created_by %}{{ note.created_by.full_name or note.created_by.email }}{% else %}Firm team{% endif %}
{% if note.task %}Open related work{% endif %}
{% else %}
No messages found.
{% endfor %}
+{% endblock %} diff --git a/app/modules/clients/templates/clients/partials/audit_log_table.html b/app/modules/clients/templates/clients/partials/audit_log_table.html new file mode 100644 index 0000000..2621e2f --- /dev/null +++ b/app/modules/clients/templates/clients/partials/audit_log_table.html @@ -0,0 +1 @@ +
{% for log in audit_logs %}{% else %}{% endfor %}
WhenActionSummary
{{ log.created_at_utc }}{{ log.action }}{{ log.summary }}
No audit entries yet.
\ No newline at end of file diff --git a/app/modules/clients/templates/clients/partials/compliance_flags.html b/app/modules/clients/templates/clients/partials/compliance_flags.html new file mode 100644 index 0000000..398b5f0 --- /dev/null +++ b/app/modules/clients/templates/clients/partials/compliance_flags.html @@ -0,0 +1 @@ +
{% for label, value in [('GST', row.gst_applicable),('Income Tax', row.income_tax_applicable),('TDS', row.tds_applicable),('ROC', row.roc_applicable),('Audit', row.audit_applicable),('PF', row.pf_applicable),('ESI', row.esi_applicable),('Professional Tax', row.professional_tax_applicable),('Payroll', row.payroll_applicable),('MSME', row.msme_applicable),('Import / Export', row.import_export_applicable)] %}
{{ label }}
{% if value %}Applicable{% else %}Not Applicable{% endif %}
{% endfor %}
\ No newline at end of file diff --git a/app/modules/clients/templates/clients/partials/form.html b/app/modules/clients/templates/clients/partials/form.html new file mode 100644 index 0000000..9424079 --- /dev/null +++ b/app/modules/clients/templates/clients/partials/form.html @@ -0,0 +1,372 @@ +{% set is_edit = row is defined and row %} +
+
+

Basic Profile

+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ +
+

Assignment & Scope

+
+
+ + +
+ + {% if form_mode == 'firm_admin' %} +
+ +
+ {% for t in form_options.tenants %} + {{ t.name }} + {% endfor %} +
+ +
+ +
+ + +
+ +
+ + +
+ + {% elif form_mode == 'system_admin' %} +
+ + +
+ +
+ + +
+ +
+ + +
+ + {% elif form_mode == 'partner' %} +
+ Partner assignment is locked to your own user. + + + +
+ + {% elif form_mode == 'consultant' %} +
+ Consultant users cannot assign or reassign partner mappings. + + + +
+ + {% elif form_mode == 'self_service' %} +
+ This client is currently unassigned. Initial association to firm, branch, and partner must be done by System Admin. +
+ {% endif %} + + + +
+ + +

Used automatically for assurance engagements only when the audit firm is a partnership firm.

+
+ +
+ + +
+ +
+

Client Frontend Login

+
+ {% if is_edit and row.portal_user_id %} + Linked portal user already exists. Leave password blank to keep the current password, or enter a new password to reset it. + {% elif is_edit %} + This existing client does not yet have a linked login. Enter email and password below to create the client login now. + {% else %} + Creating a client will also create a frontend login using the client email and password below. + {% endif %} +
+ +
+
+ + +
+ +
+ + +
+ + {% if is_edit and row.portal_user_id %} +
+ Portal user id linked: {{ row.portal_user_id }} +
+ {% endif %} +
+
+ +
+

Compliance Applicability

+ +
+ + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
diff --git a/app/modules/clients/templates/clients/partials/table.html b/app/modules/clients/templates/clients/partials/table.html new file mode 100644 index 0000000..c803328 --- /dev/null +++ b/app/modules/clients/templates/clients/partials/table.html @@ -0,0 +1,2 @@ + +
{% for row in rows %}{% else %}{% endfor %}
CodeClientAssociationPartnerBranchStatus
{{ row.client_code }}
{{ row.client_name }}
{{ row.pan or row.gstin or '-' }}
{{ row.association_type or 'legacy_firm' }}
{{ row.assoc_created_source or 'legacy' }}
{{ row.partner_name or row.effective_partner_id or '-' }}{{ row.branch_name or row.assoc_firm_branch_id or row.branch_id or '-' }}{% if row.status == 'active' %}Active{% elif row.status == 'archived' %}Archived{% else %}Inactive{% endif %}Open
No clients found.
diff --git a/app/modules/clients/templates/clients/portal_dashboard.html b/app/modules/clients/templates/clients/portal_dashboard.html new file mode 100644 index 0000000..841dcb2 --- /dev/null +++ b/app/modules/clients/templates/clients/portal_dashboard.html @@ -0,0 +1,93 @@ +{% extends "ui/templates/base/layout.html" %} + +{% block content %} +{% include "modules/clients/templates/clients/_client_tabs.html" %} +
+
+
+
+

Client Portal

+

My Compliance & Firm Communication

+

Track your compliance status, pending actions, required documents, messages and firm updates.

+
+ {% if client_row %}Update My Profile{% endif %} +
+
+ + {% if not client_row %} +
+ We could not find a client master linked to your login email in the current audit firm. Please contact your firm admin to map this login to the correct client record. +
+ {% else %} +
+
Active Compliance
{{ total_engagements or 0 }}
Services / filings
+
Pending Action
{{ pending_from_client or 0 }}
Required from you
+
With Firm
{{ with_firm or 0 }}
Being handled
+
Documents
{{ recent_documents|length if recent_documents else 0 }}
Recent uploads
+
Outstanding Bills
₹ {{ '%.2f'|format(billing_outstanding_amount or 0) }}
{{ billing_open_count or 0 }} open bill(s)
+
+ +
+
+
+

Compliance Status

Simple client-facing status of your active services.

View All
+
+
Pending from You
{{ pending_from_client or 0 }}
+
With Firm
{{ with_firm or 0 }}
+
Clarification
{{ clarification_required or 0 }}
+
Completed
{{ completed_engagements or 0 }}
+
+ + +
+ +
+

Latest Messages

View all
+
+ {% for note in client_visible_comments[:5] %} +
{{ note.task.task_name if note.task else 'Message' }}

{{ note.message }}

{{ note.created_at_utc.strftime('%d-%m-%Y %I:%M %p') if note.created_at_utc else '' }}
+ {% else %} +
No messages yet.
+ {% endfor %} +
+
+
+ + +
+ {% endif %} +
+{% endblock %} diff --git a/app/modules/clients/templates/clients/profile.html b/app/modules/clients/templates/clients/profile.html new file mode 100644 index 0000000..83844d3 --- /dev/null +++ b/app/modules/clients/templates/clients/profile.html @@ -0,0 +1,109 @@ +{% extends "ui/templates/base/layout.html" %} + +{% block content %} +{% include "modules/clients/templates/clients/_client_tabs.html" %} +
+
+
+

Edit My Profile

+

You can update contact and communication details here. PAN, GSTIN and other compliance identity fields stay read-only.

+
+ +
+ + {% if form_errors %} +
+
    + {% for error in form_errors %}
  • {{ error }}
  • {% endfor %} +
+
+ {% endif %} + +
+ + +
+

Read-only compliance identity

+
+
PAN
{{ client_row.pan or '-' }}
+
GSTIN
{{ client_row.gstin or '-' }}
+
TAN
{{ client_row.tan or '-' }}
+
CIN / LLPIN
{{ client_row.cin_llpin or '-' }}
+
+
+ +
+

Editable profile details

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +

If you change this, your next login will use the new email.

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ +
+
+
+{% endblock %} diff --git a/app/modules/clients/ui.py b/app/modules/clients/ui.py new file mode 100644 index 0000000..c440fe7 --- /dev/null +++ b/app/modules/clients/ui.py @@ -0,0 +1,1536 @@ +from __future__ import annotations + +from fastapi import APIRouter, File, Form, Request, UploadFile +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response +from pydantic import ValidationError + +from app.core.db.common import CommonSessionLocal +from app.core.security.csrf import get_or_create_csrf_token, validate_csrf +from app.core.security.session_auth import get_current_user +from app.core.templating import templates +from app.modules.clients import repository +from app.modules.clients.access import build_scope, can_view_client_row +from app.modules.clients.constants import CLIENT_CATEGORY_OPTIONS, CLIENT_STATUS, CLIENT_TYPES, RISK_CATEGORIES +from app.modules.clients.filters import ClientListFilters +from app.modules.clients.import_service import ( + build_client_import_template_bytes, + build_preview, + commit_import, + deserialize_preview_rows, + serialize_preview_rows, +) +from app.modules.clients.schemas import ClientCreate, ClientUpdate +from app.modules.clients.service import ( + activate_client_service, + archive_client_service, + create_client_service, + deactivate_client_service, + export_clients_csv, + get_client_or_404, + list_client_audit_logs, + list_clients_payload, + restore_client_service, + update_client_service, + update_client_self_profile_service, +) +from app.modules.core.rbac.deps import get_user_permissions, get_user_roles +from app.modules.core.rbac.permission_guard import require_permission +from app.modules.services.execution import list_client_visible_task_comments +from app.modules.clients.auditor_service import build_client_auditor_card +from app.modules.clients.portal_service import ( + build_client_portal_summary, + create_client_reply, + get_client_engagement, + get_client_task, + list_client_engagement_documents, + list_client_engagements, + list_client_permanent_documents, + list_client_tasks_for_engagement, + list_client_visible_comments, +) +from app.modules.billing.client_portal_service import ( + build_client_billing_summary, + build_client_payment_context, + get_client_portal_invoice, + get_client_portal_payment, + list_client_portal_invoices, +) +from app.modules.billing.services import build_invoice_print_context, create_cashfree_transaction, create_payumoney_transaction, process_cashfree_return, process_cashfree_webhook, process_payumoney_response +from app.modules.documents.services import ( + get_permanent_version, + get_version, + permanent_version_absolute_path, + version_absolute_path, +) + +router = APIRouter(prefix="/clients", tags=["clients-ui"]) + + +def _base_ctx(request: Request, user, db, **ctx): + base = { + "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), + "client_types": CLIENT_TYPES, + "client_statuses": CLIENT_STATUS, + "client_categories": CLIENT_CATEGORY_OPTIONS, + "risk_categories": RISK_CATEGORIES, + } + base.update(ctx) + return base + + +def _render(request, template, db, user, **ctx): + return templates.TemplateResponse(template, _base_ctx(request, user, db, **ctx)) + + +def _redirect_denied(): + return RedirectResponse(url="/system-settings", status_code=303) + + +def _has_perm_factory(db, user): + def _has(code): + try: + require_permission(db, user, code) + return True + except Exception: + return False + return _has + + +def _role_names(db, user) -> set[str]: + return {str(r).lower() for r in get_user_roles(db, user.id)} + + +def _resolve_form_mode(role_names: set[str]) -> str: + if "system admin" in role_names: + return "system_admin" + if "firm admin" in role_names: + return "firm_admin" + if "partner" in role_names: + return "partner" + if "consultant" in role_names: + return "consultant" + return "self_service" + + +def _elevate_scope_for_system_admin(scope, role_names: set[str]): + if "system admin" in role_names: + scope.allow_all_clients = True + scope.allow_cross_tenant = True + scope.allow_cross_branch = True + scope.own_only = False + scope.locked_partner_id = None + return scope + + +def _form_bool(value): + return value in ("1", "true", "True", "on", "yes") + + +def _build_form_payload(request: Request, user, scope, *, include_client_code: bool = True): + form = request._form + + branch_raw = form.get("branch_id") + branch_id = int(branch_raw) if branch_raw not in (None, "", "None") else int(scope.branch_id or getattr(user, "branch_id", None) or 0) + + partner_raw = form.get("partner_id") + if partner_raw not in (None, "", "None"): + partner_id = int(partner_raw) + elif scope.locked_partner_id: + partner_id = scope.locked_partner_id + else: + partner_id = None + + tenant_id = int(form.get("tenant_id") or scope.tenant_id) + + payload = { + "tenant_id": tenant_id, + "branch_id": branch_id, + "partner_id": partner_id, + "default_review_partner_user_id": int(form.get("default_review_partner_user_id")) if form.get("default_review_partner_user_id") not in (None, "", "None") else None, + "engagement_mode": form.get("engagement_mode") or "internal_managed", + "client_name": form.get("client_name", ""), + "trade_name": form.get("trade_name"), + "client_type": form.get("client_type") or "Other", + "pan": form.get("pan"), + "gstin": form.get("gstin"), + "tan": form.get("tan"), + "cin_llpin": form.get("cin_llpin"), + "msme_no": form.get("msme_no"), + "iec_code": form.get("iec_code"), + "contact_person_name": form.get("contact_person_name"), + "contact_person_designation": form.get("contact_person_designation"), + "mobile": form.get("mobile"), + "alternate_mobile": form.get("alternate_mobile"), + "email": form.get("email"), + "alternate_email": form.get("alternate_email"), + "address_line_1": form.get("address_line_1"), + "address_line_2": form.get("address_line_2"), + "city": form.get("city"), + "state": form.get("state"), + "pincode": form.get("pincode"), + "country": form.get("country") or "India", + "status": form.get("status") or "active", + "client_category": form.get("client_category"), + "risk_category": form.get("risk_category"), + "onboarding_date": form.get("onboarding_date") or None, + "closing_date": form.get("closing_date") or None, + "notes": form.get("notes"), + "gst_applicable": _form_bool(form.get("gst_applicable")), + "income_tax_applicable": _form_bool(form.get("income_tax_applicable")), + "tds_applicable": _form_bool(form.get("tds_applicable")), + "roc_applicable": _form_bool(form.get("roc_applicable")), + "audit_applicable": _form_bool(form.get("audit_applicable")), + "pf_applicable": _form_bool(form.get("pf_applicable")), + "esi_applicable": _form_bool(form.get("esi_applicable")), + "professional_tax_applicable": _form_bool(form.get("professional_tax_applicable")), + "payroll_applicable": _form_bool(form.get("payroll_applicable")), + "msme_applicable": _form_bool(form.get("msme_applicable")), + "import_export_applicable": _form_bool(form.get("import_export_applicable")), + } + + if include_client_code: + payload["client_code"] = form.get("client_code", "") + + return payload + + +def _field_errors(exc): + if isinstance(exc, ValidationError): + out = [] + for err in exc.errors(): + loc = ".".join(str(x) for x in err.get("loc", [])) + out.append(f"{loc}: {err.get('msg', 'Invalid value')}") + return out + detail = getattr(exc, "detail", None) + return [str(detail or exc)] + + +def _form_options(db, scope, form_mode: str): + tenant_id = scope.tenant_id + branch_id = scope.branch_id + + if form_mode == "system_admin": + return { + "tenants": repository.list_tenants(db), + "branches": repository.list_all_branches(db), + "partners": repository.list_all_partners(db), + "review_partners": repository.list_all_partners(db), + "active_tenant_id": tenant_id, + "active_branch_id": branch_id, + } + + return { + "tenants": repository.list_tenants(db), + "branches": repository.list_branches_for_tenant(db, tenant_id), + "partners": repository.list_partners_for_scope( + db, + tenant_id=tenant_id, + branch_id=None if scope.allow_cross_branch else branch_id, + ), + "review_partners": repository.list_partners_for_scope( + db, + tenant_id=tenant_id, + branch_id=None if scope.allow_cross_branch else branch_id, + ), + "active_tenant_id": tenant_id, + "active_branch_id": branch_id, + } + + +@router.get("") +def clients_list( + request: Request, + q: str = "", + status: str = "", + client_type: str = "", + partner_id: int | None = None, + include_archived: bool = False, + page: int = 1, + per_page: int = 10, + sort_by: str = "client_name", + sort_order: str = "asc", +): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + + has = _has_perm_factory(db, user) + if not has("clients.view"): + return _redirect_denied() + + role_names = _role_names(db, user) + scope = build_scope(request, user, has) + scope = _elevate_scope_for_system_admin(scope, role_names) + + filters = ClientListFilters.from_params( + q=q, + status=status, + client_type=client_type, + partner_id=partner_id, + include_archived=include_archived, + page=page, + per_page=per_page, + sort_by=sort_by, + sort_order=sort_order, + ) + if scope.own_only: + filters.partner_id = scope.locked_partner_id + + payload = list_clients_payload( + db, + tenant_id=scope.tenant_id, + branch_id=scope.branch_id, + allow_cross_branch=scope.allow_cross_branch, + allow_all_clients=scope.allow_all_clients, + partner_id=filters.partner_id, + q=filters.q, + status=filters.status, + client_type=filters.client_type, + include_archived=filters.include_archived, + page=filters.page, + per_page=filters.per_page, + sort_by=filters.sort_by, + sort_order=filters.sort_order, + ) + + payload["rows"] = [row for row in payload["rows"] if can_view_client_row(scope, row, user_id=user.id)] + + return _render( + request, + "modules/clients/templates/clients/list.html", + db, + user, + title="Clients", + can_create=has("clients.create"), + can_export=has("clients.export"), + can_import=has("clients.import"), + q=filters.q, + status=filters.status, + client_type=filters.client_type, + partner_id=filters.partner_id, + include_archived=filters.include_archived, + page=filters.page, + per_page=filters.per_page, + sort_by=filters.sort_by, + sort_order=filters.sort_order, + scope=scope, + form_options=_form_options(db, scope, "system_admin" if "system admin" in role_names else "list"), + **payload, + ) + finally: + db.close() + + +@router.get("/export") +def clients_export( + request: Request, + q: str = "", + status: str = "", + client_type: str = "", + partner_id: int | None = None, + include_archived: bool = False, + sort_by: str = "client_name", + sort_order: str = "asc", +): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + + has = _has_perm_factory(db, user) + if not has("clients.export"): + return _redirect_denied() + + role_names = _role_names(db, user) + scope = build_scope(request, user, has) + scope = _elevate_scope_for_system_admin(scope, role_names) + + filters = ClientListFilters.from_params( + q=q, + status=status, + client_type=client_type, + partner_id=partner_id, + include_archived=include_archived, + page=1, + per_page=10000, + sort_by=sort_by, + sort_order=sort_order, + ) + if scope.own_only: + filters.partner_id = scope.locked_partner_id + + payload = list_clients_payload( + db, + tenant_id=scope.tenant_id, + branch_id=scope.branch_id, + allow_cross_branch=scope.allow_cross_branch, + allow_all_clients=scope.allow_all_clients, + partner_id=filters.partner_id, + q=filters.q, + status=filters.status, + client_type=filters.client_type, + include_archived=filters.include_archived, + page=filters.page, + per_page=filters.per_page, + sort_by=filters.sort_by, + sort_order=filters.sort_order, + ) + + payload["rows"] = [row for row in payload["rows"] if can_view_client_row(scope, row, user_id=user.id)] + + return Response( + content=export_clients_csv(payload), + media_type="text/csv", + headers={"Content-Disposition": "attachment; filename=clients_export.csv"}, + ) + finally: + db.close() + + +@router.get("/import") +def client_import_page(request: Request): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + + has = _has_perm_factory(db, user) + if not has("clients.import"): + return _redirect_denied() + + role_names = _role_names(db, user) + scope = build_scope(request, user, has) + scope = _elevate_scope_for_system_admin(scope, role_names) + + return _render( + request, + "modules/clients/templates/clients/import.html", + db, + user, + title="Import Clients", + scope=scope, + role_names=sorted(role_names), + current_tenant=repository.get_tenant(db, scope.tenant_id), + partners=repository.list_partners_for_scope(db, tenant_id=scope.tenant_id, branch_id=None if scope.allow_cross_branch else scope.branch_id), + import_errors=[], + ) + finally: + db.close() + + +@router.get("/import/template") +def client_import_template(request: Request): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + has = _has_perm_factory(db, user) + if not has("clients.import"): + return _redirect_denied() + role_names = _role_names(db, user) + scope = build_scope(request, user, has) + scope = _elevate_scope_for_system_admin(scope, role_names) + payload = build_client_import_template_bytes(current_user=user, tenant_id=scope.tenant_id, partner_id=user.id if 'partner' in role_names else None) + return Response( + content=payload, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": "attachment; filename=client_import_template.xlsx"}, + ) + finally: + db.close() + + +@router.post("/import/preview") +async def client_import_preview(request: Request, excel_file: UploadFile = File(...)): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + has = _has_perm_factory(db, user) + if not has("clients.import"): + return _redirect_denied() + role_names = _role_names(db, user) + scope = build_scope(request, user, has) + scope = _elevate_scope_for_system_admin(scope, role_names) + + form = await request.form() + validate_csrf(request, form.get("csrf_token")) + content = await excel_file.read() + preview = build_preview(db, current_user=user, scope=scope, role_names=role_names, upload_bytes=content) + return _render( + request, + "modules/clients/templates/clients/import_preview.html", + db, + user, + title="Import Clients Preview", + scope=scope, + preview=preview, + preview_payload=serialize_preview_rows(preview.valid_rows), + ) + finally: + db.close() + + +@router.post("/import/commit") +async def client_import_commit(request: Request): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + has = _has_perm_factory(db, user) + if not has("clients.import"): + return _redirect_denied() + role_names = _role_names(db, user) + scope = build_scope(request, user, has) + scope = _elevate_scope_for_system_admin(scope, role_names) + form = await request.form() + validate_csrf(request, form.get("csrf_token")) + preview_rows = deserialize_preview_rows(form.get("preview_payload") or "[]") + result = commit_import(db, current_user=user, scope=scope, current_user_roles=get_user_roles(db, user.id), preview_rows=preview_rows) + return _render( + request, + "modules/clients/templates/clients/import_preview.html", + db, + user, + title="Import Clients Result", + scope=scope, + preview=None, + preview_payload="[]", + import_result=result, + ) + finally: + db.close() + + +@router.get("/new") +def client_new_page(request: Request): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + + has = _has_perm_factory(db, user) + if not has("clients.create"): + return _redirect_denied() + + role_names = _role_names(db, user) + form_mode = _resolve_form_mode(role_names) + scope = build_scope(request, user, has) + scope = _elevate_scope_for_system_admin(scope, role_names) + + defaults = { + "status": "active", + "client_type": "Other", + "country": "India", + "engagement_mode": "internal_managed", + "partner_id": scope.locked_partner_id or getattr(user, "id", None), + "branch_id": scope.branch_id, + "tenant_id": scope.tenant_id, + } + + return _render( + request, + "modules/clients/templates/clients/add.html", + db, + user, + title="Add Client", + form_data=defaults, + form_errors=[], + scope=scope, + form_options=_form_options(db, scope, form_mode), + form_mode=form_mode, + ) + finally: + db.close() + + +@router.post("") +async def client_create(request: Request): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + + has = _has_perm_factory(db, user) + if not has("clients.create"): + return _redirect_denied() + + role_names = _role_names(db, user) + form_mode = _resolve_form_mode(role_names) + scope = build_scope(request, user, has) + scope = _elevate_scope_for_system_admin(scope, role_names) + + form = await request.form() + request._form = form + validate_csrf(request, form.get("csrf_token")) + + raw_payload = _build_form_payload(request, user, scope, include_client_code=True) + roles = get_user_roles(db, user.id) + + try: + data = ClientCreate(**raw_payload) + row = create_client_service( + db, + data=data, + actor_user_id=user.id, + scope=scope, + current_user_roles=roles, + portal_password=(form.get("portal_password") or "").strip() or None, + portal_password_confirm=(form.get("portal_password_confirm") or "").strip() or None, + ) + return RedirectResponse(url=f"/clients/{row.id}", status_code=303) + except Exception as exc: + return _render( + request, + "modules/clients/templates/clients/add.html", + db, + user, + title="Add Client", + form_data=raw_payload, + form_errors=_field_errors(exc), + scope=scope, + form_options=_form_options(db, scope, form_mode), + form_mode=form_mode, + ) + finally: + db.close() + + +@router.get("/{client_id}") +def client_detail(request: Request, client_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + + has = _has_perm_factory(db, user) + if not has("clients.view"): + return _redirect_denied() + + role_names = _role_names(db, user) + scope = build_scope(request, user, has) + scope = _elevate_scope_for_system_admin(scope, role_names) + + row = repository.get_client_detail_payload(db, client_id) + if not row or not can_view_client_row(scope, row, user_id=user.id): + return _redirect_denied() + + audit_logs = list_client_audit_logs(db, row=type("Tmp", (), {"id": row["id"]})(), limit=10) if has("clients.audit_log.view") else [] + return _render( + request, + "modules/clients/templates/clients/detail.html", + db, + user, + title=f"Client • {row['client_name']}", + row=row, + audit_logs=audit_logs, + scope=scope, + can_edit=has("clients.edit"), + can_deactivate=has("clients.deactivate"), + can_activate=has("clients.activate"), + can_archive=has("clients.archive"), + can_restore=has("clients.restore"), + ) + finally: + db.close() + + +@router.get("/{client_id}/edit") +def client_edit_page(request: Request, client_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + + has = _has_perm_factory(db, user) + if not has("clients.edit"): + return _redirect_denied() + + role_names = _role_names(db, user) + form_mode = _resolve_form_mode(role_names) + scope = build_scope(request, user, has) + scope = _elevate_scope_for_system_admin(scope, role_names) + + row = get_client_or_404( + db, + client_id=client_id, + tenant_id=scope.tenant_id, + branch_id=scope.branch_id, + allow_cross_branch=scope.allow_cross_branch, + allow_all_clients=scope.allow_all_clients, + ) + + return _render( + request, + "modules/clients/templates/clients/edit.html", + db, + user, + title=f"Edit Client • {row.client_name}", + row=row, + form_data=row, + form_errors=[], + scope=scope, + form_options=_form_options(db, scope, form_mode), + form_mode=form_mode, + ) + finally: + db.close() + + +@router.post("/{client_id}/edit") +async def client_update(request: Request, client_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + + has = _has_perm_factory(db, user) + if not has("clients.edit"): + return _redirect_denied() + + role_names = _role_names(db, user) + form_mode = _resolve_form_mode(role_names) + scope = build_scope(request, user, has) + scope = _elevate_scope_for_system_admin(scope, role_names) + + row = get_client_or_404( + db, + client_id=client_id, + tenant_id=scope.tenant_id, + branch_id=scope.branch_id, + allow_cross_branch=scope.allow_cross_branch, + allow_all_clients=scope.allow_all_clients, + ) + + form = await request.form() + request._form = form + validate_csrf(request, form.get("csrf_token")) + + raw_payload = _build_form_payload(request, user, scope, include_client_code=False) + roles = get_user_roles(db, user.id) + + try: + data = ClientUpdate(**raw_payload) + row = update_client_service( + db, + row=row, + data=data, + actor_user_id=user.id, + scope=scope, + current_user_roles=roles, + portal_password=(form.get("portal_password") or "").strip() or None, + portal_password_confirm=(form.get("portal_password_confirm") or "").strip() or None, + ) + return RedirectResponse(url=f"/clients/{row.id}", status_code=303) + except Exception as exc: + return _render( + request, + "modules/clients/templates/clients/edit.html", + db, + user, + title=f"Edit Client • {row.client_name}", + row=row, + form_data=raw_payload, + form_errors=_field_errors(exc), + scope=scope, + form_options=_form_options(db, scope, form_mode), + form_mode=form_mode, + ) + finally: + db.close() + + +@router.post("/{client_id}/deactivate") +async def client_deactivate(request: Request, client_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + + has = _has_perm_factory(db, user) + if not has("clients.deactivate"): + return _redirect_denied() + + role_names = _role_names(db, user) + scope = build_scope(request, user, has) + scope = _elevate_scope_for_system_admin(scope, role_names) + + form = await request.form() + validate_csrf(request, form.get("csrf_token")) + + row = get_client_or_404( + db, + client_id=client_id, + tenant_id=scope.tenant_id, + branch_id=scope.branch_id, + allow_cross_branch=scope.allow_cross_branch, + allow_all_clients=scope.allow_all_clients, + ) + deactivate_client_service(db, row=row, actor_user_id=user.id) + return RedirectResponse(url="/clients", status_code=303) + finally: + db.close() + + +@router.post("/{client_id}/activate") +async def client_activate(request: Request, client_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + + has = _has_perm_factory(db, user) + if not has("clients.activate"): + return _redirect_denied() + + role_names = _role_names(db, user) + scope = build_scope(request, user, has) + scope = _elevate_scope_for_system_admin(scope, role_names) + + form = await request.form() + validate_csrf(request, form.get("csrf_token")) + + row = get_client_or_404( + db, + client_id=client_id, + tenant_id=scope.tenant_id, + branch_id=scope.branch_id, + allow_cross_branch=scope.allow_cross_branch, + allow_all_clients=scope.allow_all_clients, + ) + activate_client_service(db, row=row, actor_user_id=user.id) + return RedirectResponse(url=f"/clients/{row.id}", status_code=303) + finally: + db.close() + + +@router.post("/{client_id}/archive") +async def client_archive(request: Request, client_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + + has = _has_perm_factory(db, user) + if not has("clients.archive"): + return _redirect_denied() + + role_names = _role_names(db, user) + scope = build_scope(request, user, has) + scope = _elevate_scope_for_system_admin(scope, role_names) + + form = await request.form() + validate_csrf(request, form.get("csrf_token")) + + row = get_client_or_404( + db, + client_id=client_id, + tenant_id=scope.tenant_id, + branch_id=scope.branch_id, + allow_cross_branch=scope.allow_cross_branch, + allow_all_clients=scope.allow_all_clients, + ) + archive_client_service(db, row=row, actor_user_id=user.id) + return RedirectResponse(url="/clients?include_archived=true", status_code=303) + finally: + db.close() + + +@router.post("/{client_id}/restore") +async def client_restore(request: Request, client_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + + has = _has_perm_factory(db, user) + if not has("clients.restore"): + return _redirect_denied() + + role_names = _role_names(db, user) + scope = build_scope(request, user, has) + scope = _elevate_scope_for_system_admin(scope, role_names) + + form = await request.form() + validate_csrf(request, form.get("csrf_token")) + + row = get_client_or_404( + db, + client_id=client_id, + tenant_id=scope.tenant_id, + branch_id=scope.branch_id, + allow_cross_branch=scope.allow_cross_branch, + allow_all_clients=scope.allow_all_clients, + ) + restore_client_service(db, row=row, actor_user_id=user.id) + return RedirectResponse(url=f"/clients/{row.id}", status_code=303) + finally: + db.close() + + +portal_router = APIRouter(prefix="/client", tags=["client-portal"]) + + + + +def _portal_client_or_redirect(request: Request, db, current_user): + role_names = _role_names(db, current_user) + if "client" not in role_names: + return None, RedirectResponse(url="/system-settings", status_code=303) + client_row = repository.get_portal_client_for_user(db, user=current_user) + if not client_row: + return None, templates.TemplateResponse( + "modules/clients/templates/clients/portal_dashboard.html", + _portal_context(request, db, current_user, client_row=None), + status_code=200, + ) + return client_row, None + + + +def _active_financial_year(request: Request) -> str | None: + value = request.session.get("active_financial_year") or getattr(request.state, "year_code", None) + value = (value or "").strip() + return value or None + +def _portal_context(request: Request, db, current_user, **extra): + ctx = { + "request": request, + "current_user": current_user, + "current_user_roles": get_user_roles(db, current_user.id), + "current_user_permissions": get_user_permissions(db, current_user.id), + "csrf_token": get_or_create_csrf_token(request), + "title": "Client Dashboard", + } + ctx.update(extra) + return ctx + + +@portal_router.get("/dashboard") +def client_portal_dashboard(request: Request): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return RedirectResponse(url="/login", status_code=303) + + client_row, redirect = _portal_client_or_redirect(request, db, current_user) + if redirect: + return redirect + + financial_year = _active_financial_year(request) + summary = build_client_portal_summary(db, client_row, financial_year=financial_year) + billing_summary = build_client_billing_summary(db, client_row, financial_year=financial_year) + client_visible_comments = list_client_visible_comments(db, client_row, limit=10, financial_year=financial_year) + recent_documents = list_client_engagement_documents(db, client_row, financial_year=financial_year)[:8] + auditor_card = build_client_auditor_card(db, client_row) + + return templates.TemplateResponse( + "modules/clients/templates/clients/portal_dashboard.html", + _portal_context( + request, + db, + current_user, + client_row=client_row, + client_visible_comments=client_visible_comments, + recent_documents=recent_documents, + auditor_card=auditor_card, + **summary, + **billing_summary, + ), + ) + finally: + db.close() + + +@portal_router.get("/billing") +def client_portal_billing(request: Request, q: str = "", include_paid: str = "yes"): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return RedirectResponse(url="/login", status_code=303) + client_row, redirect = _portal_client_or_redirect(request, db, current_user) + if redirect: + return redirect + financial_year = _active_financial_year(request) + rows = list_client_portal_invoices(db, client_row, q=q, include_paid=(include_paid != "no"), financial_year=financial_year) + summary = build_client_billing_summary(db, client_row, financial_year=financial_year) + return templates.TemplateResponse( + "modules/billing/templates/billing/client_portal/list.html", + _portal_context( + request, + db, + current_user, + title="My Bills", + client_row=client_row, + rows=rows, + q=q, + include_paid=include_paid, + active_financial_year=financial_year, + **summary, + ), + ) + finally: + db.close() + + +@portal_router.get("/billing/receipts/{payment_id}") +def client_portal_receipt_print(request: Request, payment_id: int): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return RedirectResponse(url="/login", status_code=303) + client_row, redirect = _portal_client_or_redirect(request, db, current_user) + if redirect: + return redirect + payment = get_client_portal_payment(db, client_row, payment_id, financial_year=_active_financial_year(request)) + if not payment: + return RedirectResponse(url="/client/billing", status_code=303) + invoice_ctx = build_invoice_print_context(db, payment.invoice) + return templates.TemplateResponse( + "modules/billing/templates/billing/payments/receipt_print.html", + _portal_context( + request, + db, + current_user, + title=f"Receipt {payment.receipt_no}", + client_row=client_row, + payment=payment, + invoice=payment.invoice, + invoice_ctx=invoice_ctx, + ), + ) + finally: + db.close() + + +@portal_router.get("/billing/{invoice_id}") +def client_portal_invoice_detail(request: Request, invoice_id: int): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return RedirectResponse(url="/login", status_code=303) + client_row, redirect = _portal_client_or_redirect(request, db, current_user) + if redirect: + return redirect + invoice = get_client_portal_invoice(db, client_row, invoice_id, financial_year=_active_financial_year(request)) + if not invoice: + return RedirectResponse(url="/client/billing", status_code=303) + invoice_ctx = build_invoice_print_context(db, invoice) + return templates.TemplateResponse( + "modules/billing/templates/billing/client_portal/detail.html", + _portal_context(request, db, current_user, title=f"Invoice {invoice.invoice_no}", client_row=client_row, invoice=invoice, invoice_ctx=invoice_ctx), + ) + finally: + db.close() + + +@portal_router.get("/billing/{invoice_id}/print") +def client_portal_invoice_print(request: Request, invoice_id: int): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return RedirectResponse(url="/login", status_code=303) + client_row, redirect = _portal_client_or_redirect(request, db, current_user) + if redirect: + return redirect + invoice = get_client_portal_invoice(db, client_row, invoice_id, financial_year=_active_financial_year(request)) + if not invoice: + return RedirectResponse(url="/client/billing", status_code=303) + invoice_ctx = build_invoice_print_context(db, invoice) + return templates.TemplateResponse( + "modules/billing/templates/billing/invoice_print.html", + _portal_context(request, db, current_user, title=f"Print Invoice {invoice.invoice_no}", client_row=client_row, invoice=invoice, invoice_ctx=invoice_ctx), + ) + finally: + db.close() + + +@portal_router.get("/billing/{invoice_id}/pay-now") +def client_portal_pay_now(request: Request, invoice_id: int): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return RedirectResponse(url="/login", status_code=303) + client_row, redirect = _portal_client_or_redirect(request, db, current_user) + if redirect: + return redirect + invoice = get_client_portal_invoice(db, client_row, invoice_id, financial_year=_active_financial_year(request)) + if not invoice: + return RedirectResponse(url="/client/billing", status_code=303) + if invoice.status == "PAID" or invoice.balance_amount <= 0: + return RedirectResponse(url=f"/client/billing/{invoice.id}", status_code=303) + pay_ctx = build_client_payment_context(db, invoice) + return templates.TemplateResponse( + "modules/billing/templates/billing/client_portal/pay_now.html", + _portal_context(request, db, current_user, title=f"Pay Invoice {invoice.invoice_no}", client_row=client_row, invoice=invoice, **pay_ctx), + ) + finally: + db.close() + + +@portal_router.post("/billing/{invoice_id}/payumoney/start") +def client_portal_payumoney_start(request: Request, invoice_id: int, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return RedirectResponse(url="/login", status_code=303) + client_row, redirect = _portal_client_or_redirect(request, db, current_user) + if redirect: + return redirect + invoice = get_client_portal_invoice(db, client_row, invoice_id, financial_year=_active_financial_year(request)) + if not invoice: + return RedirectResponse(url="/client/billing", status_code=303) + if invoice.status == "PAID" or invoice.balance_amount <= 0: + return RedirectResponse(url=f"/client/billing/{invoice.id}", status_code=303) + invoice_ctx = build_invoice_print_context(db, invoice) + settings = invoice_ctx.get("settings") + base_url = str(request.base_url).rstrip("/") + checkout = create_payumoney_transaction(db, invoice=invoice, settings=settings, base_url=base_url, client_ip=request.client.host if request.client else None) + db.commit() + payload = checkout["payload"] + inputs = "\n".join([f'' for k, v in payload.items()]) + html = ( + 'Redirecting to PayUMoney' + '' + '

Redirecting to secure payment gateway...

Please wait. Do not refresh this page.

' + f'
{inputs}' + '
' + "" + ) + return HTMLResponse(html) + except Exception: + db.rollback() + raise + finally: + db.close() + + +@portal_router.post("/billing/{invoice_id}/cashfree/start") +def client_portal_cashfree_start(request: Request, invoice_id: int, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return RedirectResponse(url="/login", status_code=303) + client_row, redirect = _portal_client_or_redirect(request, db, current_user) + if redirect: + return redirect + invoice = get_client_portal_invoice(db, client_row, invoice_id, financial_year=_active_financial_year(request)) + if not invoice: + return RedirectResponse(url="/client/billing", status_code=303) + if invoice.status == "PAID" or invoice.balance_amount <= 0: + return RedirectResponse(url=f"/client/billing/{invoice.id}", status_code=303) + invoice_ctx = build_invoice_print_context(db, invoice) + settings = invoice_ctx.get("settings") + base_url = str(request.base_url).rstrip("/") + checkout = create_cashfree_transaction(db, invoice=invoice, settings=settings, base_url=base_url, client_ip=request.client.host if request.client else None) + db.commit() + payment_session_id = checkout["payment_session_id"] + mode = "production" if str(getattr(settings, "cashfree_mode", "TEST")).upper() == "LIVE" else "sandbox" + html = f""" + + + + + Redirecting to Cashfree + + + +

Redirecting to Cashfree checkout...

+

Please wait. Do not refresh this page.

+ + + + +""" + return HTMLResponse(html) + except Exception: + db.rollback() + raise + finally: + db.close() + + +@portal_router.get("/billing/cashfree/return") +def client_portal_cashfree_return(request: Request, order_id: str = ""): + db = CommonSessionLocal() + try: + transaction = process_cashfree_return(db, order_id=order_id) if order_id else None + db.commit() + current_user = get_current_user(request, db=db) + result = "success" if transaction and transaction.status == "SUCCESS" else "failure" + if result == "success": + heading = "Payment successful" + message = "Your Cashfree payment has been confirmed and receipt has been recorded." + else: + heading = "Payment not completed" + message = "The Cashfree payment is not yet confirmed. Please contact the audit firm if your bank account has been debited." + if current_user: + return templates.TemplateResponse( + "modules/billing/templates/billing/client_portal/payment_status.html", + _portal_context(request, db, current_user, title=heading, transaction=transaction, result=result, heading=heading, message=message), + ) + invoice_url = f"/client/billing/{transaction.invoice_id}" if transaction else "/client/billing" + return HTMLResponse(f"

{heading}

{message}

Continue

") + except Exception: + db.rollback() + raise + finally: + db.close() + + +@portal_router.post("/billing/cashfree/webhook") +async def client_portal_cashfree_webhook(request: Request): + raw_body = await request.body() + db = CommonSessionLocal() + try: + transaction = process_cashfree_webhook(db, raw_body=raw_body, headers=dict(request.headers)) + db.commit() + return JSONResponse({"ok": bool(transaction), "status": getattr(transaction, "status", None)}) + except Exception as exc: + db.rollback() + return JSONResponse({"ok": False, "error": str(exc)}, status_code=400) + finally: + db.close() + + +async def _payumoney_callback(request: Request, expected: str): + db = CommonSessionLocal() + try: + data = dict(await request.form()) if request.method == "POST" else dict(request.query_params) + transaction = process_payumoney_response(db, response_data=data) + db.commit() + current_user = get_current_user(request, db=db) + result = "success" if transaction and transaction.status == "SUCCESS" else "failure" + if result == "success": + heading = "Payment successful" + message = "Your online payment has been confirmed and receipt has been recorded." + elif transaction and transaction.status == "HASH_FAILED": + heading = "Payment verification failed" + message = "The payment response could not be verified. Please contact the audit firm before retrying." + else: + heading = "Payment not completed" + message = "The payment was cancelled, failed, or could not be confirmed by the gateway." + if current_user: + return templates.TemplateResponse( + "modules/billing/templates/billing/client_portal/payment_status.html", + _portal_context(request, db, current_user, title=heading, transaction=transaction, result=result, heading=heading, message=message), + ) + invoice_url = f"/client/billing/{transaction.invoice_id}" if transaction else "/client/billing" + return HTMLResponse(f"

{heading}

{message}

Continue

") + except Exception: + db.rollback() + raise + finally: + db.close() + + +@portal_router.api_route("/billing/payumoney/success", methods=["GET", "POST"]) +async def client_portal_payumoney_success(request: Request): + return await _payumoney_callback(request, "success") + + +@portal_router.api_route("/billing/payumoney/failure", methods=["GET", "POST"]) +async def client_portal_payumoney_failure(request: Request): + return await _payumoney_callback(request, "failure") + + +@portal_router.get("/profile") +def client_portal_profile_page(request: Request): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return RedirectResponse(url="/login", status_code=303) + + client_row, redirect = _portal_client_or_redirect(request, db, current_user) + if redirect: + return redirect + + form_data = { + "client_name": client_row.get("client_name") or "", + "trade_name": client_row.get("trade_name") or "", + "contact_person_name": client_row.get("contact_person_name") or "", + "contact_person_designation": client_row.get("contact_person_designation") or "", + "mobile": client_row.get("mobile") or "", + "alternate_mobile": client_row.get("alternate_mobile") or "", + "email": client_row.get("email") or "", + "alternate_email": client_row.get("alternate_email") or "", + "address_line_1": client_row.get("address_line_1") or "", + "address_line_2": client_row.get("address_line_2") or "", + "city": client_row.get("city") or "", + "state": client_row.get("state") or "", + "pincode": client_row.get("pincode") or "", + "country": client_row.get("country") or "India", + "notes": client_row.get("notes") or "", + } + + return templates.TemplateResponse( + "modules/clients/templates/clients/profile.html", + _portal_context(request, db, current_user, client_row=client_row, form_data=form_data, form_errors=[]), + ) + finally: + db.close() + + +@portal_router.post("/profile") +async def client_portal_profile_submit(request: Request): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return RedirectResponse(url="/login", status_code=303) + + client_row, redirect = _portal_client_or_redirect(request, db, current_user) + if redirect: + return redirect + + form = await request.form() + validate_csrf(request, form.get("csrf_token")) + + raw_payload = { + "client_name": form.get("client_name", ""), + "trade_name": form.get("trade_name"), + "contact_person_name": form.get("contact_person_name"), + "contact_person_designation": form.get("contact_person_designation"), + "mobile": form.get("mobile"), + "alternate_mobile": form.get("alternate_mobile"), + "email": form.get("email"), + "alternate_email": form.get("alternate_email"), + "address_line_1": form.get("address_line_1"), + "address_line_2": form.get("address_line_2"), + "city": form.get("city"), + "state": form.get("state"), + "pincode": form.get("pincode"), + "country": form.get("country") or "India", + "notes": form.get("notes"), + } + + try: + data = ClientUpdate(**raw_payload) + row = repository.get_client_by_id(db, int(client_row["id"])) + update_client_self_profile_service(db, row=row, data=data, current_user=current_user) + return RedirectResponse(url="/client/dashboard", status_code=303) + except Exception as exc: + refreshed = repository.get_portal_client_for_user(db, user=current_user) or client_row + return templates.TemplateResponse( + "modules/clients/templates/clients/profile.html", + _portal_context(request, db, current_user, client_row=refreshed, form_data=raw_payload, form_errors=_field_errors(exc)), + status_code=400, + ) + finally: + db.close() + + +@portal_router.get("/compliance") +def client_portal_compliance(request: Request): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return RedirectResponse(url="/login", status_code=303) + client_row, redirect = _portal_client_or_redirect(request, db, current_user) + if redirect: + return redirect + financial_year = _active_financial_year(request) + summary = build_client_portal_summary(db, client_row, financial_year=financial_year) + return templates.TemplateResponse( + "modules/clients/templates/clients/compliance.html", + _portal_context(request, db, current_user, client_row=client_row, **summary), + ) + finally: + db.close() + + +@portal_router.get("/engagements/{engagement_id}") +def client_portal_engagement_detail(request: Request, engagement_id: int): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return RedirectResponse(url="/login", status_code=303) + client_row, redirect = _portal_client_or_redirect(request, db, current_user) + if redirect: + return redirect + financial_year = _active_financial_year(request) + engagement = get_client_engagement(db, client_row, engagement_id, financial_year=financial_year) + if not engagement: + return RedirectResponse(url="/client/compliance", status_code=303) + tasks = list_client_tasks_for_engagement(db, client_row, engagement_id) + documents = list_client_engagement_documents(db, client_row, engagement_id=engagement_id, financial_year=financial_year) + comments = [c for c in list_client_visible_comments(db, client_row, limit=200, financial_year=financial_year) if c.subscription_id == engagement_id] + return templates.TemplateResponse( + "modules/clients/templates/clients/engagement_detail.html", + _portal_context( + request, + db, + current_user, + client_row=client_row, + engagement=engagement, + tasks=tasks, + documents=documents, + comments=comments, + ), + ) + finally: + db.close() + + +@portal_router.post("/tasks/{task_id}/reply") +async def client_portal_task_reply(request: Request, task_id: int): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return RedirectResponse(url="/login", status_code=303) + client_row, redirect = _portal_client_or_redirect(request, db, current_user) + if redirect: + return redirect + form = await request.form() + validate_csrf(request, form.get("csrf_token")) + task = get_client_task(db, client_row, task_id, financial_year=_active_financial_year(request)) + if not task: + return RedirectResponse(url="/client/compliance?error=task_not_found", status_code=303) + try: + create_client_reply(db, client_row=client_row, task=task, message=form.get("message", ""), user=current_user) + db.commit() + return RedirectResponse(url=f"/client/engagements/{task.subscription_id}?reply=sent", status_code=303) + except Exception: + db.rollback() + return RedirectResponse(url=f"/client/engagements/{task.subscription_id}?error=reply_failed", status_code=303) + finally: + db.close() + + +@portal_router.get("/documents") +def client_portal_documents(request: Request): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return RedirectResponse(url="/login", status_code=303) + client_row, redirect = _portal_client_or_redirect(request, db, current_user) + if redirect: + return redirect + engagement_documents = list_client_engagement_documents(db, client_row, financial_year=_active_financial_year(request)) + permanent_documents = list_client_permanent_documents(db, client_row) + return templates.TemplateResponse( + "modules/clients/templates/clients/documents.html", + _portal_context( + request, + db, + current_user, + client_row=client_row, + engagement_documents=engagement_documents, + permanent_documents=permanent_documents, + ), + ) + finally: + db.close() + + +@portal_router.get("/messages") +def client_portal_messages(request: Request): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return RedirectResponse(url="/login", status_code=303) + client_row, redirect = _portal_client_or_redirect(request, db, current_user) + if redirect: + return redirect + financial_year = _active_financial_year(request) + comments = list_client_visible_comments(db, client_row, limit=200, financial_year=financial_year) + engagements = list_client_engagements(db, client_row, limit=200, financial_year=financial_year) + return templates.TemplateResponse( + "modules/clients/templates/clients/messages.html", + _portal_context(request, db, current_user, client_row=client_row, comments=comments, engagements=engagements), + ) + finally: + db.close() + + +@portal_router.get("/documents/engagement-versions/{version_id}/download") +def client_portal_download_engagement_document(request: Request, version_id: int): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return RedirectResponse(url="/login", status_code=303) + client_row, redirect = _portal_client_or_redirect(request, db, current_user) + if redirect: + return redirect + version = get_version(db, version_id) + document = version.document if version else None + active_fy = _active_financial_year(request) + if not version or not document or int(document.client_id) != int(client_row.get("id")) or int(document.tenant_id) != int(client_row.get("tenant_id")): + return RedirectResponse(url="/client/documents?error=not_allowed", status_code=303) + if active_fy and getattr(document, "financial_year", None) != active_fy: + return RedirectResponse(url="/client/documents?error=not_allowed", status_code=303) + path = version_absolute_path(version) + if not path.exists(): + return RedirectResponse(url="/client/documents?error=file_not_available", status_code=303) + return FileResponse(path, media_type=version.content_type or "application/octet-stream", filename=version.original_filename) + finally: + db.close() + + +@portal_router.get("/documents/permanent-versions/{version_id}/download") +def client_portal_download_permanent_document(request: Request, version_id: int): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return RedirectResponse(url="/login", status_code=303) + client_row, redirect = _portal_client_or_redirect(request, db, current_user) + if redirect: + return redirect + version = get_permanent_version(db, version_id) + document = version.document if version else None + active_fy = _active_financial_year(request) + if not version or not document or int(document.client_id) != int(client_row.get("id")) or int(document.tenant_id) != int(client_row.get("tenant_id")): + return RedirectResponse(url="/client/documents?error=not_allowed", status_code=303) + if active_fy and getattr(document, "financial_year", None) != active_fy: + return RedirectResponse(url="/client/documents?error=not_allowed", status_code=303) + path = permanent_version_absolute_path(version) + if not path.exists(): + return RedirectResponse(url="/client/documents?error=file_not_available", status_code=303) + return FileResponse(path, media_type=version.content_type or "application/octet-stream", filename=version.original_filename) + finally: + db.close() diff --git a/app/modules/clients/utils.py b/app/modules/clients/utils.py new file mode 100644 index 0000000..781d49c --- /dev/null +++ b/app/modules/clients/utils.py @@ -0,0 +1,28 @@ + +import csv +import io +import re + +PAN_RE = re.compile(r"^[A-Z]{5}[0-9]{4}[A-Z]$") +GSTIN_RE = re.compile(r"^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$") +TAN_RE = re.compile(r"^[A-Z]{4}[0-9]{5}[A-Z]$") +MOBILE_RE = re.compile(r"^[6-9][0-9]{9}$") +PIN_RE = re.compile(r"^[0-9]{6}$") + +def normalize_text(value): + if value is None: + return None + text = str(value).strip() + return text or None + +def normalize_upper(value): + value = normalize_text(value) + return value.upper() if value else None + +def build_csv(rows, headers): + output = io.StringIO() + writer = csv.writer(output) + writer.writerow(headers) + for row in rows: + writer.writerow(row) + return output.getvalue() diff --git a/app/modules/consultants/__init__.py b/app/modules/consultants/__init__.py new file mode 100644 index 0000000..a9067c5 --- /dev/null +++ b/app/modules/consultants/__init__.py @@ -0,0 +1 @@ +"""Consultant portal foundation module.""" diff --git a/app/modules/consultants/models.py b/app/modules/consultants/models.py new file mode 100644 index 0000000..2ba758a --- /dev/null +++ b/app/modules/consultants/models.py @@ -0,0 +1,311 @@ +from __future__ import annotations + +from datetime import date, datetime, timezone + +from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.db.common import CommonBase + + +class ConsultantProfile(CommonBase): + """Portal-enabled consultant / ecosystem partner profile. + + The login/security account remains in users. This table stores consultant + business/profile details and links the consultant user to firm clients. + """ + + __tablename__ = "consultant_profiles" + __table_args__ = ( + UniqueConstraint("tenant_id", "user_id", name="uq_consultant_profiles_tenant_user"), + UniqueConstraint("tenant_id", "email", name="uq_consultant_profiles_tenant_email"), + ) + + 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) + branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True) + user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + + consultant_type: Mapped[str] = mapped_column(String(50), nullable=False, default="external_consultant", index=True) + firm_name: Mapped[str | None] = mapped_column(String(200), nullable=True) + contact_person: Mapped[str] = mapped_column(String(200), nullable=False) + email: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True) + mobile: Mapped[str | None] = mapped_column(String(20), nullable=True) + specialisation: Mapped[str | None] = mapped_column(String(200), nullable=True) + gstin: Mapped[str | None] = mapped_column(String(20), nullable=True) + pan: Mapped[str | None] = mapped_column(String(20), nullable=True) + address: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[str] = mapped_column(String(30), nullable=False, default="active", index=True) + onboarding_status: Mapped[str] = mapped_column(String(30), nullable=False, default="approved", index=True) + + is_platform_partner: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + is_franchise_partner: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + is_saas_customer: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True) + remarks: Mapped[str | None] = mapped_column(Text, nullable=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, + ) + + user = relationship("User", foreign_keys=[user_id]) + links = relationship( + "ClientConsultantLink", + back_populates="consultant", + cascade="all, delete-orphan", + passive_deletes=True, + ) + workspace = relationship( + "ConsultantWorkspace", + back_populates="consultant", + uselist=False, + cascade="all, delete-orphan", + passive_deletes=True, + ) + managed_clients = relationship( + "ConsultantManagedClient", + back_populates="consultant", + cascade="all, delete-orphan", + passive_deletes=True, + ) + service_requests = relationship( + "ConsultantServiceRequest", + back_populates="consultant", + cascade="all, delete-orphan", + passive_deletes=True, + ) + + +class ConsultantWorkspace(CommonBase): + """SaaS/franchise workspace settings for a consultant portal account. + + This is a foundation table only. It does not change firm-owned clients or + consultant-managed clients. It records whether the consultant operates as a + SaaS customer, franchise partner, platform partner, or a normal external + consultant, along with soft limits used by later subscription/billing phases. + """ + + __tablename__ = "consultant_workspaces" + __table_args__ = ( + UniqueConstraint("tenant_id", "consultant_id", name="uq_consultant_workspaces_tenant_consultant"), + UniqueConstraint("tenant_id", "workspace_code", name="uq_consultant_workspaces_tenant_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) + branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True) + consultant_id: Mapped[int] = mapped_column( + ForeignKey("consultant_profiles.id", ondelete="CASCADE"), nullable=False, index=True + ) + + workspace_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + workspace_name: Mapped[str] = mapped_column(String(200), nullable=False) + workspace_type: Mapped[str] = mapped_column(String(50), nullable=False, default="consultant_saas", index=True) + plan_code: Mapped[str] = mapped_column(String(50), nullable=False, default="starter", index=True) + billing_cycle: Mapped[str] = mapped_column(String(30), nullable=False, default="manual", index=True) + subscription_status: Mapped[str] = mapped_column(String(30), nullable=False, default="trial", index=True) + subscription_start_date: Mapped[date | None] = mapped_column(Date, nullable=True) + subscription_end_date: Mapped[date | None] = mapped_column(Date, nullable=True) + + max_managed_clients: Mapped[int] = mapped_column(Integer, nullable=False, default=25) + max_user_accounts: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + allow_client_portal: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + allow_firm_referrals: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + allow_service_marketplace: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True) + remarks: Mapped[str | None] = mapped_column(Text, nullable=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, + ) + + consultant = relationship("ConsultantProfile", back_populates="workspace") + + +class ClientConsultantLink(CommonBase): + """Explicit link between a firm client and a consultant portal profile.""" + + __tablename__ = "client_consultant_links" + __table_args__ = ( + UniqueConstraint("tenant_id", "client_id", "consultant_id", name="uq_client_consultant_links_client_consultant"), + ) + + 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) + branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True) + client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True) + consultant_id: Mapped[int] = mapped_column( + ForeignKey("consultant_profiles.id", ondelete="CASCADE"), nullable=False, index=True + ) + service_catalogue_id: Mapped[int | None] = mapped_column(ForeignKey("service_catalogues.id", ondelete="SET NULL"), nullable=True, index=True) + + relationship_type: Mapped[str] = mapped_column(String(50), nullable=False, default="accounts_consultant", index=True) + is_primary: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + can_view_client: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + can_view_services: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + can_view_due_dates: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + can_view_communications: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True) + remarks: Mapped[str | None] = mapped_column(Text, nullable=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, + ) + + consultant = relationship("ConsultantProfile", back_populates="links") + client = relationship("Client") + service_catalogue = relationship("ServiceCatalogue") + + +class ConsultantManagedClient(CommonBase): + """Client/contact managed by a consultant inside the consultant portal. + + This table is intentionally separate from the firm `clients` master. It lets + consultants maintain their own client book without affecting audit-firm + client records. A later phase can convert/link a managed client to the firm + client master through an approval workflow. + """ + + __tablename__ = "consultant_managed_clients" + __table_args__ = ( + UniqueConstraint("tenant_id", "consultant_id", "client_code", name="uq_consultant_managed_clients_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) + branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True) + consultant_id: Mapped[int] = mapped_column( + ForeignKey("consultant_profiles.id", ondelete="CASCADE"), nullable=False, index=True + ) + linked_firm_client_id: Mapped[int | None] = mapped_column(ForeignKey("clients.id", ondelete="SET NULL"), nullable=True, index=True) + + client_code: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True) + client_name: Mapped[str] = mapped_column(String(200), nullable=False, index=True) + trade_name: Mapped[str | None] = mapped_column(String(200), nullable=True) + client_type: Mapped[str] = mapped_column(String(100), nullable=False, default="Other") + pan: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True) + gstin: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True) + tan: Mapped[str | None] = mapped_column(String(20), nullable=True) + + contact_person_name: Mapped[str | None] = mapped_column(String(200), nullable=True) + mobile: Mapped[str | None] = mapped_column(String(20), nullable=True) + email: Mapped[str | None] = mapped_column(String(255), nullable=True, 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) + country: Mapped[str | None] = mapped_column(String(100), nullable=True, default="India") + + service_interest: Mapped[str | None] = mapped_column(Text, nullable=True) + relationship_stage: Mapped[str] = mapped_column(String(30), nullable=False, default="managed", index=True) + status: Mapped[str] = mapped_column(String(30), nullable=False, default="active", index=True) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + conversion_status: Mapped[str] = mapped_column(String(30), nullable=False, default="not_requested", index=True) + conversion_requested_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + conversion_requested_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True) + conversion_reviewed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + conversion_reviewed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True) + conversion_notes: Mapped[str | None] = mapped_column(Text, nullable=True) + conversion_firm_notes: Mapped[str | None] = mapped_column(Text, nullable=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, + ) + + consultant = relationship("ConsultantProfile", back_populates="managed_clients") + linked_firm_client = relationship("Client") + + +class ConsultantServiceRequest(CommonBase): + """Service request raised by a consultant to the audit firm. + + A request can relate either to a consultant-managed client or to an already + linked firm client. The request itself does not create engagements; firm + users review it first and decide whether to accept, reject, or keep it under + review. + """ + + __tablename__ = "consultant_service_requests" + + 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) + branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True) + consultant_id: Mapped[int] = mapped_column( + ForeignKey("consultant_profiles.id", ondelete="CASCADE"), nullable=False, index=True + ) + managed_client_id: Mapped[int | None] = mapped_column( + ForeignKey("consultant_managed_clients.id", ondelete="SET NULL"), nullable=True, index=True + ) + firm_client_id: Mapped[int | None] = mapped_column(ForeignKey("clients.id", ondelete="SET NULL"), nullable=True, index=True) + service_catalogue_id: Mapped[int | None] = mapped_column( + ForeignKey("service_catalogues.id", ondelete="SET NULL"), nullable=True, index=True + ) + + request_no: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + request_type: Mapped[str] = mapped_column(String(50), nullable=False, default="service_request", index=True) + status: Mapped[str] = mapped_column(String(40), nullable=False, default="submitted", index=True) + priority: Mapped[str] = mapped_column(String(30), nullable=False, default="normal", index=True) + requested_service_name: Mapped[str] = mapped_column(String(200), nullable=False) + requested_due_date: Mapped[date | None] = mapped_column(Date, nullable=True) + subject: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + consultant_notes: Mapped[str | None] = mapped_column(Text, nullable=True) + firm_response: Mapped[str | None] = mapped_column(Text, nullable=True) + + created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True) + updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True) + reviewed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True) + reviewed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at_utc: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False + ) + updated_at_utc: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + nullable=False, + ) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True) + + consultant = relationship("ConsultantProfile", back_populates="service_requests") + managed_client = relationship("ConsultantManagedClient") + firm_client = relationship("Client") + service_catalogue = relationship("ServiceCatalogue") + created_by = relationship("User", foreign_keys=[created_by_user_id]) + reviewed_by = relationship("User", foreign_keys=[reviewed_by_user_id]) diff --git a/app/modules/consultants/portal_service.py b/app/modules/consultants/portal_service.py new file mode 100644 index 0000000..77e3cf9 --- /dev/null +++ b/app/modules/consultants/portal_service.py @@ -0,0 +1,245 @@ +from __future__ import annotations + +from collections import OrderedDict +from datetime import date, timedelta +from typing import Any + +from sqlalchemy import or_, select +from sqlalchemy.orm import Session, selectinload + +from app.modules.clients.models import Client +from app.modules.consultants.models import ClientConsultantLink, ConsultantProfile, ConsultantServiceRequest +from app.modules.documents.models import EngagementDocument, PermanentClientDocument +from app.modules.services.models import ( + ClientServiceSubscription, + ClientServiceTaskInstance, + ServiceCatalogue, + ServiceTaskComment, +) + +CONSULTANT_BOARD_COLUMNS = OrderedDict( + [ + ("assigned", "Assigned"), + ("awaiting_documents", "Awaiting Documents"), + ("in_progress", "In Progress"), + ("submitted", "Submitted"), + ("accepted", "Accepted"), + ("closed", "Closed"), + ] +) + + +def _allowed_client_ids(db: Session, *, consultant: ConsultantProfile, require_communications: bool = False) -> list[int]: + query = select(ClientConsultantLink.client_id).where( + ClientConsultantLink.tenant_id == consultant.tenant_id, + ClientConsultantLink.consultant_id == consultant.id, + ClientConsultantLink.is_active.is_(True), + ) + if require_communications: + query = query.where(ClientConsultantLink.can_view_communications.is_(True)) + return [int(x) for x in db.execute(query).scalars().all()] + + +def _board_key_for_status(status: str | None) -> str: + value = (status or "pending").strip().lower() + if value in {"blocked", "awaiting_documents", "document_pending", "clarification_required"}: + return "awaiting_documents" + if value in {"in_progress", "under_process", "processing", "started"}: + return "in_progress" + if value in {"pending_review", "ready_for_review", "submitted", "completed"}: + return "submitted" + if value in {"approved", "accepted"}: + return "accepted" + if value in {"closed", "locked", "cancelled", "inactive"}: + return "closed" + return "assigned" + + +def _matches_search(*values: Any, q: str = "") -> bool: + term = (q or "").strip().lower() + if not term: + return True + return any(term in str(v or "").lower() for v in values) + + +def get_consultant_work_board(db: Session, *, consultant: ConsultantProfile, q: str = "", status: str = "") -> dict: + """Build consultant work board from consultant-visible firm task communications. + + The board intentionally uses existing task/comment visibility rules only. A consultant sees a task here only when + the firm has linked the consultant to the client and has created a consultant-visible communication for that task. + """ + client_ids = _allowed_client_ids(db, consultant=consultant, require_communications=True) + columns = {key: {"label": label, "items": []} for key, label in CONSULTANT_BOARD_COLUMNS.items()} + latest_by_task: dict[int, dict] = {} + + if client_ids: + rows = db.execute( + select(ServiceTaskComment, ClientServiceTaskInstance, ClientServiceSubscription, Client, ServiceCatalogue) + .join(ClientServiceTaskInstance, ClientServiceTaskInstance.id == ServiceTaskComment.task_instance_id) + .join(ClientServiceSubscription, ClientServiceSubscription.id == ServiceTaskComment.subscription_id) + .join(Client, Client.id == ClientServiceTaskInstance.client_id) + .join(ServiceCatalogue, ServiceCatalogue.id == ClientServiceTaskInstance.service_catalogue_id) + .where( + ServiceTaskComment.tenant_id == consultant.tenant_id, + ServiceTaskComment.visibility == "consultant", + ServiceTaskComment.is_deleted.is_(False), + ClientServiceTaskInstance.client_id.in_(client_ids), + ClientServiceTaskInstance.tenant_id == consultant.tenant_id, + ClientServiceTaskInstance.is_active.is_(True), + ) + .order_by(ServiceTaskComment.created_at_utc.desc(), ServiceTaskComment.id.desc()) + .limit(300) + ).all() + consultant_user_id = int(getattr(consultant, "user_id", 0) or 0) + for comment, task, subscription, client, catalogue in rows: + if int(task.id) in latest_by_task: + continue + if not _matches_search(client.client_name, getattr(client, "client_code", ""), catalogue.service_name, task.task_name, comment.message, q=q): + continue + key = _board_key_for_status(task.status) + if status and key != status: + continue + latest_by_task[int(task.id)] = { + "comment": comment, + "task": task, + "subscription": subscription, + "client": client, + "catalogue": catalogue, + "board_key": key, + "last_message_from_consultant": int(getattr(comment, "created_by_user_id", 0) or 0) == consultant_user_id, + "is_overdue": bool(getattr(task, "internal_target_date", None) and task.internal_target_date < date.today()), + } + + for item in latest_by_task.values(): + columns[item["board_key"]]["items"].append(item) + + service_requests = db.execute( + select(ConsultantServiceRequest) + .options( + selectinload(ConsultantServiceRequest.managed_client), + selectinload(ConsultantServiceRequest.firm_client), + selectinload(ConsultantServiceRequest.service_catalogue), + ) + .where( + ConsultantServiceRequest.tenant_id == consultant.tenant_id, + ConsultantServiceRequest.consultant_id == consultant.id, + ConsultantServiceRequest.is_active.is_(True), + ) + .order_by(ConsultantServiceRequest.created_at_utc.desc(), ConsultantServiceRequest.id.desc()) + .limit(50) + ).scalars().all() + + return { + "columns": columns, + "column_options": list(CONSULTANT_BOARD_COLUMNS.items()), + "total_tasks": len(latest_by_task), + "service_requests": service_requests, + } + + +def get_consultant_assignment_detail(db: Session, *, consultant: ConsultantProfile, task_id: int) -> dict | None: + client_ids = _allowed_client_ids(db, consultant=consultant, require_communications=True) + if not client_ids: + return None + row = db.execute( + select(ClientServiceTaskInstance, ClientServiceSubscription, Client, ServiceCatalogue) + .join(ClientServiceSubscription, ClientServiceSubscription.id == ClientServiceTaskInstance.subscription_id) + .join(Client, Client.id == ClientServiceTaskInstance.client_id) + .join(ServiceCatalogue, ServiceCatalogue.id == ClientServiceTaskInstance.service_catalogue_id) + .where( + ClientServiceTaskInstance.id == task_id, + ClientServiceTaskInstance.tenant_id == consultant.tenant_id, + ClientServiceTaskInstance.client_id.in_(client_ids), + ClientServiceTaskInstance.is_active.is_(True), + ) + ).first() + if not row: + return None + task, subscription, client, catalogue = row + timeline = db.execute( + select(ServiceTaskComment) + .options(selectinload(ServiceTaskComment.created_by)) + .where( + ServiceTaskComment.tenant_id == consultant.tenant_id, + ServiceTaskComment.task_instance_id == task.id, + ServiceTaskComment.visibility == "consultant", + ServiceTaskComment.is_deleted.is_(False), + ) + .order_by(ServiceTaskComment.created_at_utc.asc(), ServiceTaskComment.id.asc()) + ).scalars().all() + engagement_documents = db.execute( + select(EngagementDocument) + .options(selectinload(EngagementDocument.versions)) + .where( + EngagementDocument.tenant_id == consultant.tenant_id, + EngagementDocument.client_id == client.id, + EngagementDocument.engagement_id == subscription.id, + EngagementDocument.is_deleted.is_(False), + ) + .order_by(EngagementDocument.document_type.asc(), EngagementDocument.title.asc()) + .limit(50) + ).scalars().all() + permanent_documents = db.execute( + select(PermanentClientDocument) + .options(selectinload(PermanentClientDocument.versions)) + .where( + PermanentClientDocument.tenant_id == consultant.tenant_id, + PermanentClientDocument.client_id == client.id, + PermanentClientDocument.is_deleted.is_(False), + ) + .order_by(PermanentClientDocument.category.asc(), PermanentClientDocument.title.asc()) + .limit(50) + ).scalars().all() + return { + "task": task, + "subscription": subscription, + "client": client, + "catalogue": catalogue, + "timeline": timeline, + "engagement_documents": engagement_documents, + "permanent_documents": permanent_documents, + } + + +def get_consultant_document_centre(db: Session, *, consultant: ConsultantProfile, q: str = "") -> dict: + client_ids = _allowed_client_ids(db, consultant=consultant, require_communications=False) + if not client_ids: + return {"engagement_documents": [], "permanent_documents": [], "total": 0} + + engagement_query = ( + select(EngagementDocument) + .options(selectinload(EngagementDocument.client), selectinload(EngagementDocument.engagement), selectinload(EngagementDocument.versions)) + .where( + EngagementDocument.tenant_id == consultant.tenant_id, + EngagementDocument.client_id.in_(client_ids), + EngagementDocument.is_deleted.is_(False), + ) + ) + permanent_query = ( + select(PermanentClientDocument) + .options(selectinload(PermanentClientDocument.client), selectinload(PermanentClientDocument.versions)) + .where( + PermanentClientDocument.tenant_id == consultant.tenant_id, + PermanentClientDocument.client_id.in_(client_ids), + PermanentClientDocument.is_deleted.is_(False), + ) + ) + if (q or "").strip(): + term = f"%{q.strip()}%" + engagement_query = engagement_query.where( + or_(EngagementDocument.title.ilike(term), EngagementDocument.document_type.ilike(term), EngagementDocument.document_code.ilike(term)) + ) + permanent_query = permanent_query.where( + or_(PermanentClientDocument.title.ilike(term), PermanentClientDocument.category.ilike(term), PermanentClientDocument.document_code.ilike(term)) + ) + engagement_documents = db.execute( + engagement_query.order_by(EngagementDocument.created_at_utc.desc(), EngagementDocument.id.desc()).limit(200) + ).scalars().all() + permanent_documents = db.execute( + permanent_query.order_by(PermanentClientDocument.created_at_utc.desc(), PermanentClientDocument.id.desc()).limit(200) + ).scalars().all() + return { + "engagement_documents": engagement_documents, + "permanent_documents": permanent_documents, + "total": len(engagement_documents) + len(permanent_documents), + } diff --git a/app/modules/consultants/service.py b/app/modules/consultants/service.py new file mode 100644 index 0000000..b237003 --- /dev/null +++ b/app/modules/consultants/service.py @@ -0,0 +1,1390 @@ +from __future__ import annotations + +from datetime import date, datetime, timezone, timedelta +import secrets + +from sqlalchemy import and_, func, or_, select +from sqlalchemy.orm import Session, selectinload + +from app.modules.clients.models import Client, ClientAuditLog +from app.modules.consultants.models import ClientConsultantLink, ConsultantManagedClient, ConsultantProfile, ConsultantWorkspace, ConsultantServiceRequest +from app.core.security.passwords import hash_password +from app.modules.core.iam.invite_service import issue_invite_token +from app.modules.core.iam.models import User +from app.modules.core.tenancy.models import Branch +from app.modules.core.rbac.models import Role, UserRole +from app.modules.services.models import ( + ClientServiceSubscription, + ClientServiceTaskInstance, + ServiceCatalogue, + ServiceTaskComment, +) + +CONSULTANT_TYPES = [ + ("external_consultant", "External Consultant"), + ("gst_consultant", "GST Consultant"), + ("tax_consultant", "Tax Consultant"), + ("roc_consultant", "ROC Consultant"), + ("payroll_consultant", "Payroll Consultant"), + ("franchise_partner", "Franchise Partner"), + ("saas_customer", "SaaS Customer"), +] + +CONSULTANT_RELATIONSHIP_TYPES = [ + ("accounts_consultant", "Accounts Consultant"), + ("gst_consultant", "GST Consultant"), + ("tax_consultant", "Tax Consultant"), + ("roc_consultant", "ROC Consultant"), + ("payroll_consultant", "Payroll Consultant"), + ("audit_coordination", "Audit Coordination"), + ("other", "Other"), +] + +CONSULTANT_MANAGED_CLIENT_STATUSES = [ + ("active", "Active"), + ("prospect", "Prospect"), + ("on_hold", "On Hold"), + ("closed", "Closed"), +] + +CONSULTANT_MANAGED_CLIENT_STAGES = [ + ("managed", "Managed Client"), + ("lead", "Lead / Prospect"), + ("referred_to_firm", "Referred to Audit Firm"), + ("linked_to_firm", "Linked to Firm Client"), +] + +CONSULTANT_CLIENT_CONVERSION_STATUSES = [ + ("not_requested", "Not Requested"), + ("requested", "Requested"), + ("under_review", "Under Review"), + ("approved", "Approved / Converted"), + ("rejected", "Rejected"), +] + +CONSULTANT_WORKSPACE_TYPES = [ + ("consultant_saas", "Consultant SaaS Workspace"), + ("franchise_partner", "Franchise / Referral Partner"), + ("platform_partner", "Platform Ecosystem Partner"), +] + +CONSULTANT_WORKSPACE_PLANS = [ + ("starter", "Starter"), + ("professional", "Professional"), + ("business", "Business"), + ("franchise", "Franchise"), +] + +CONSULTANT_SUBSCRIPTION_STATUSES = [ + ("trial", "Trial"), + ("active", "Active"), + ("suspended", "Suspended"), + ("cancelled", "Cancelled"), +] + +CONSULTANT_BILLING_CYCLES = [ + ("manual", "Manual / Not Billed"), + ("monthly", "Monthly"), + ("quarterly", "Quarterly"), + ("yearly", "Yearly"), +] + +CONSULTANT_ONBOARDING_STATUSES = [ + ("draft", "Draft"), + ("invited", "Invited"), + ("active", "Active"), + ("approved", "Approved"), + ("suspended", "Suspended"), + ("inactive", "Inactive"), +] + + +def normalise_text(value: str | None) -> str | None: + text = (value or "").strip() + return text or None + + + +def _first_branch_id_for_tenant(db: Session, tenant_id: int) -> int | None: + return db.execute( + select(Branch.id).where(Branch.tenant_id == tenant_id, Branch.is_active.is_(True)).order_by(Branch.id.asc()) + ).scalar_one_or_none() + + +def _consultant_role(db: Session) -> Role | None: + return db.execute(select(Role).where(Role.name == "Consultant", Role.is_active.is_(True))).scalar_one_or_none() + + +def _user_has_role(db: Session, *, user_id: int, role_id: int) -> bool: + return db.execute( + select(UserRole.id).where(UserRole.user_id == user_id, UserRole.role_id == role_id) + ).scalar_one_or_none() is not None + + +def ensure_consultant_login_user( + db: Session, + *, + tenant_id: int, + branch_id: int | None, + email: str, + full_name: str, + password: str | None, + invite_user: bool, +) -> tuple[User, str | None]: + """Create or update a portal login for a consultant and assign Consultant role. + + Returns (user, invite_url). Existing users are not overwritten except that the + Consultant role is added if missing and basic login flags are enabled. + """ + login_email = (email or "").strip().lower() + if not login_email: + raise ValueError("Login email is required to create consultant login.") + + resolved_branch_id = branch_id or _first_branch_id_for_tenant(db, tenant_id) + if not resolved_branch_id: + raise ValueError("A branch is required to create consultant login user.") + + user = db.execute(select(User).where(User.email == login_email)).scalar_one_or_none() + if user and int(user.tenant_id) != int(tenant_id): + raise ValueError("A user with this login email already exists in another tenant.") + + if user is None: + temp_password = (password or "").strip() or secrets.token_urlsafe(12) + user = User( + email=login_email, + full_name=(full_name or login_email).strip(), + password_hash=hash_password(temp_password), + tenant_id=tenant_id, + branch_id=resolved_branch_id, + is_active=True, + allow_login=True, + is_locked=False, + deleted_at=None, + must_change_password=True, + password_changed_at_utc=None, + ) + db.add(user) + db.flush() + else: + user.full_name = (full_name or user.full_name or login_email).strip() + user.branch_id = resolved_branch_id + user.is_active = True + user.allow_login = True + user.is_locked = False + if password and password.strip(): + user.password_hash = hash_password(password.strip()) + user.must_change_password = True + + role = _consultant_role(db) + if not role: + raise ValueError("Consultant role is not available. Run startup/permission seeding first.") + if not _user_has_role(db, user_id=int(user.id), role_id=int(role.id)): + db.add(UserRole(user_id=int(user.id), role_id=int(role.id))) + + invite_url = None + if invite_user: + db.flush() + token = issue_invite_token(db, user) + invite_url = f"/invite/accept?token={token}" + return user, invite_url + + +def list_consultant_role_users(db: Session, *, tenant_id: int, branch_id: int | None = None) -> list[User]: + query = ( + select(User) + .join(UserRole, UserRole.user_id == User.id) + .join(Role, Role.id == UserRole.role_id) + .where(User.tenant_id == tenant_id, User.is_active.is_(True), Role.name == "Consultant") + ) + if branch_id: + query = query.where(or_(User.branch_id == branch_id, User.branch_id.is_(None))) + return db.execute(query.order_by(User.full_name.asc(), User.email.asc())).scalars().all() + + +def list_consultants( + db: Session, + *, + tenant_id: int, + branch_id: int | None = None, + q: str = "", + include_inactive: bool = False, +) -> list[ConsultantProfile]: + query = ( + select(ConsultantProfile) + .options(selectinload(ConsultantProfile.user)) + .where(ConsultantProfile.tenant_id == tenant_id) + ) + if branch_id: + query = query.where(or_(ConsultantProfile.branch_id == branch_id, ConsultantProfile.branch_id.is_(None))) + if not include_inactive: + query = query.where(ConsultantProfile.is_active.is_(True)) + if q.strip(): + term = f"%{q.strip()}%" + query = query.where( + or_( + ConsultantProfile.contact_person.ilike(term), + ConsultantProfile.firm_name.ilike(term), + ConsultantProfile.email.ilike(term), + ConsultantProfile.mobile.ilike(term), + ConsultantProfile.specialisation.ilike(term), + ) + ) + return db.execute(query.order_by(ConsultantProfile.contact_person.asc())).scalars().all() + + +def get_consultant(db: Session, *, tenant_id: int, consultant_id: int, branch_id: int | None = None) -> ConsultantProfile | None: + query = ( + select(ConsultantProfile) + .options(selectinload(ConsultantProfile.user), selectinload(ConsultantProfile.links)) + .where(ConsultantProfile.id == consultant_id, ConsultantProfile.tenant_id == tenant_id) + ) + if branch_id: + query = query.where(or_(ConsultantProfile.branch_id == branch_id, ConsultantProfile.branch_id.is_(None))) + return db.execute(query).scalar_one_or_none() + + +def get_consultant_by_user(db: Session, *, tenant_id: int, user_id: int) -> ConsultantProfile | None: + return db.execute( + select(ConsultantProfile) + .options(selectinload(ConsultantProfile.user)) + .where( + ConsultantProfile.tenant_id == tenant_id, + ConsultantProfile.user_id == user_id, + ConsultantProfile.is_active.is_(True), + ) + ).scalar_one_or_none() + + +def create_or_update_consultant(db: Session, *, payload: dict, user_id: int, consultant: ConsultantProfile | None = None) -> ConsultantProfile: + if consultant is None: + consultant = ConsultantProfile( + tenant_id=payload["tenant_id"], + branch_id=payload.get("branch_id"), + user_id=payload.get("user_id"), + contact_person=payload["contact_person"], + created_by_user_id=user_id, + ) + db.add(consultant) + + consultant.branch_id = payload.get("branch_id") + consultant.user_id = payload.get("user_id") + consultant.consultant_type = payload.get("consultant_type") or "external_consultant" + consultant.firm_name = normalise_text(payload.get("firm_name")) + consultant.contact_person = payload["contact_person"].strip() + consultant.email = normalise_text(payload.get("email")) + consultant.mobile = normalise_text(payload.get("mobile")) + consultant.specialisation = normalise_text(payload.get("specialisation")) + consultant.gstin = normalise_text(payload.get("gstin")) + consultant.pan = normalise_text(payload.get("pan")) + consultant.address = normalise_text(payload.get("address")) + consultant.status = payload.get("status") or "active" + consultant.onboarding_status = payload.get("onboarding_status") or "approved" + consultant.is_platform_partner = bool(payload.get("is_platform_partner")) + consultant.is_franchise_partner = bool(payload.get("is_franchise_partner")) + consultant.is_saas_customer = bool(payload.get("is_saas_customer")) + consultant.is_active = bool(payload.get("is_active", True)) + consultant.remarks = normalise_text(payload.get("remarks")) + consultant.updated_by_user_id = user_id + return consultant + + + +def update_consultant_own_profile(db: Session, *, consultant: ConsultantProfile, payload: dict, user_id: int) -> ConsultantProfile: + consultant.firm_name = normalise_text(payload.get("firm_name")) + consultant.contact_person = (payload.get("contact_person") or consultant.contact_person or "").strip() + consultant.email = normalise_text(payload.get("email")) + consultant.mobile = normalise_text(payload.get("mobile")) + consultant.specialisation = normalise_text(payload.get("specialisation")) + consultant.gstin = normalise_text(payload.get("gstin")) + consultant.pan = normalise_text(payload.get("pan")) + consultant.address = normalise_text(payload.get("address")) + consultant.updated_by_user_id = user_id + return consultant + + +def list_clients_available_for_link( + db: Session, + *, + tenant_id: int, + branch_id: int | None = None, + partner_user_id: int | None = None, +) -> list[Client]: + query = select(Client).where(Client.tenant_id == tenant_id, Client.is_archived.is_(False), Client.is_active.is_(True)) + if branch_id: + query = query.where(Client.branch_id == branch_id) + if partner_user_id: + query = query.where(Client.partner_id == partner_user_id) + return db.execute(query.order_by(Client.client_name.asc())).scalars().all() + + +def list_client_links_for_consultant(db: Session, *, consultant_id: int) -> list[ClientConsultantLink]: + return db.execute( + select(ClientConsultantLink) + .options(selectinload(ClientConsultantLink.client), selectinload(ClientConsultantLink.service_catalogue)) + .where(ClientConsultantLink.consultant_id == consultant_id) + .order_by(ClientConsultantLink.is_active.desc(), ClientConsultantLink.id.desc()) + ).scalars().all() + + +def link_client_to_consultant( + db: Session, + *, + tenant_id: int, + consultant_id: int, + client: Client, + relationship_type: str, + is_primary: bool, + can_view_client: bool, + can_view_services: bool, + can_view_due_dates: bool, + can_view_communications: bool, + remarks: str | None, + user_id: int, +) -> ClientConsultantLink: + link = db.execute( + select(ClientConsultantLink).where( + ClientConsultantLink.tenant_id == tenant_id, + ClientConsultantLink.client_id == client.id, + ClientConsultantLink.consultant_id == consultant_id, + ) + ).scalar_one_or_none() + if link is None: + link = ClientConsultantLink( + tenant_id=tenant_id, + branch_id=client.branch_id, + client_id=client.id, + consultant_id=consultant_id, + created_by_user_id=user_id, + ) + db.add(link) + link.relationship_type = relationship_type or "accounts_consultant" + link.is_primary = bool(is_primary) + link.can_view_client = bool(can_view_client) + link.can_view_services = bool(can_view_services) + link.can_view_due_dates = bool(can_view_due_dates) + link.can_view_communications = bool(can_view_communications) + link.is_active = True + link.remarks = normalise_text(remarks) + link.updated_by_user_id = user_id + return link + + +def set_link_active(db: Session, *, tenant_id: int, link_id: int, active: bool, user_id: int) -> ClientConsultantLink | None: + link = db.execute( + select(ClientConsultantLink).where(ClientConsultantLink.id == link_id, ClientConsultantLink.tenant_id == tenant_id) + ).scalar_one_or_none() + if link: + link.is_active = active + link.updated_by_user_id = user_id + return link + + +def linked_client_ids_for_consultant(db: Session, *, consultant_id: int, require_communications: bool = False) -> list[int]: + query = select(ClientConsultantLink.client_id).where( + ClientConsultantLink.consultant_id == consultant_id, + ClientConsultantLink.is_active.is_(True), + ) + if require_communications: + query = query.where(ClientConsultantLink.can_view_communications.is_(True)) + return [int(x) for x in db.execute(query).scalars().all()] + + +def consultant_dashboard_payload(db: Session, *, consultant: ConsultantProfile) -> dict: + """Build the consultant portal dashboard payload. + + This function is intentionally read-only. It does not create or update any + consultant records. It only aggregates already available data from consultant + links, task communications, service requests, conversion requests and the + consultant workspace. + """ + links = list_client_links_for_consultant(db, consultant_id=consultant.id) + active_links = [link for link in links if link.is_active] + communication_client_ids = [int(link.client_id) for link in active_links if link.can_view_communications] + due_date_client_ids = [int(link.client_id) for link in active_links if link.can_view_due_dates] + + comments: list[tuple[ServiceTaskComment, ClientServiceTaskInstance, ClientServiceSubscription, Client, ServiceCatalogue]] = [] + if communication_client_ids: + comments = db.execute( + select(ServiceTaskComment, ClientServiceTaskInstance, ClientServiceSubscription, Client, ServiceCatalogue) + .join(ClientServiceTaskInstance, ClientServiceTaskInstance.id == ServiceTaskComment.task_instance_id) + .join(ClientServiceSubscription, ClientServiceSubscription.id == ServiceTaskComment.subscription_id) + .join(Client, Client.id == ClientServiceTaskInstance.client_id) + .join(ServiceCatalogue, ServiceCatalogue.id == ClientServiceTaskInstance.service_catalogue_id) + .where( + ServiceTaskComment.tenant_id == consultant.tenant_id, + ServiceTaskComment.visibility == "consultant", + ServiceTaskComment.is_deleted.is_(False), + ClientServiceTaskInstance.client_id.in_(communication_client_ids), + ClientServiceTaskInstance.tenant_id == consultant.tenant_id, + ClientServiceTaskInstance.is_active.is_(True), + ) + .order_by(ServiceTaskComment.created_at_utc.desc(), ServiceTaskComment.id.desc()) + .limit(50) + ).all() + + today = date.today() + next_30_days = today + timedelta(days=30) + upcoming_due = 0 + overdue = 0 + due_items: list[tuple[ClientServiceSubscription, Client, ServiceCatalogue]] = [] + overdue_items: list[tuple[ClientServiceSubscription, Client, ServiceCatalogue]] = [] + + if due_date_client_ids: + due_query = ( + select(ClientServiceSubscription, Client, ServiceCatalogue) + .join(Client, Client.id == ClientServiceSubscription.client_id) + .join(ServiceCatalogue, ServiceCatalogue.id == ClientServiceSubscription.service_catalogue_id) + .where( + ClientServiceSubscription.tenant_id == consultant.tenant_id, + ClientServiceSubscription.client_id.in_(due_date_client_ids), + ClientServiceSubscription.is_active.is_(True), + ClientServiceSubscription.current_due_date.is_not(None), + ) + ) + due_items = db.execute( + due_query.where( + ClientServiceSubscription.current_due_date >= today, + ClientServiceSubscription.current_due_date <= next_30_days, + ) + .order_by(ClientServiceSubscription.current_due_date.asc(), Client.client_name.asc()) + .limit(8) + ).all() + overdue_items = db.execute( + due_query.where( + ClientServiceSubscription.current_due_date < today, + ClientServiceSubscription.status != "completed", + ) + .order_by(ClientServiceSubscription.current_due_date.asc(), Client.client_name.asc()) + .limit(8) + ).all() + upcoming_due = len(due_items) + overdue = len(overdue_items) + + managed_clients = list_consultant_managed_clients( + db, + tenant_id=consultant.tenant_id, + consultant_id=consultant.id, + include_inactive=False, + ) + managed_stats = consultant_managed_clients_stats(db, consultant=consultant) + workspace_summary = consultant_workspace_summary(db, consultant=consultant) + service_requests = list_consultant_service_requests(db, consultant=consultant) + open_service_requests = [r for r in service_requests if r.status in {"submitted", "under_review", "accepted"}] + pending_service_requests = [r for r in service_requests if r.status in {"submitted", "under_review"}] + + conversion_requests = [ + r + for r in managed_clients + if getattr(r, "conversion_status", "not_requested") in {"requested", "under_review", "approved", "rejected"} + ] + pending_conversions = [r for r in conversion_requests if r.conversion_status in {"requested", "under_review"}] + converted_clients = [r for r in managed_clients if getattr(r, "linked_firm_client_id", None)] + + consultant_user_id = int(getattr(consultant, "user_id", 0) or 0) + firm_messages = [row for row in comments if int(getattr(row[0], "created_by_user_id", 0) or 0) != consultant_user_id] + consultant_replies = [row for row in comments if int(getattr(row[0], "created_by_user_id", 0) or 0) == consultant_user_id] + + workspace = workspace_summary.get("workspace") + workspace_alerts: list[str] = [] + remaining = workspace_summary.get("managed_clients_remaining") + limit = workspace_summary.get("managed_clients_limit") + usage_percent = int(workspace_summary.get("usage_percent") or 0) + if workspace is None: + workspace_alerts.append("Workspace setup is pending.") + elif not getattr(workspace, "is_active", True): + workspace_alerts.append("Workspace is inactive.") + elif getattr(workspace, "subscription_status", "") in {"suspended", "cancelled"}: + workspace_alerts.append(f"Subscription status is {workspace.subscription_status.replace('_', ' ').title()}.") + if limit and remaining is not None and remaining <= 0: + workspace_alerts.append("Managed client limit has been reached.") + elif limit and usage_percent >= 80: + workspace_alerts.append("Managed client usage is above 80% of the workspace limit.") + + return { + "links": links, + "active_links": active_links, + "comments": comments[:8], + "recent_firm_messages": firm_messages[:6], + "recent_consultant_replies": consultant_replies[:6], + "managed_clients": managed_clients[:8], + "workspace_summary": workspace_summary, + "workspace": workspace, + "workspace_alerts": workspace_alerts, + "service_requests": service_requests[:6], + "open_service_requests": open_service_requests[:6], + "pending_service_requests": pending_service_requests[:6], + "conversion_requests": conversion_requests[:6], + "pending_conversions": pending_conversions[:6], + "due_items": due_items, + "overdue_items": overdue_items, + "stats": { + "linked_clients": len(active_links), + "communication_enabled_clients": len(communication_client_ids), + "due_date_enabled_clients": len(due_date_client_ids), + "pending_clarifications": len(firm_messages), + "consultant_replies": len(consultant_replies), + "upcoming_due": int(upcoming_due or 0), + "overdue": int(overdue or 0), + "open_service_requests": len(open_service_requests), + "pending_service_requests": len(pending_service_requests), + "pending_conversions": len(pending_conversions), + "converted_clients": len(converted_clients), + **managed_stats, + }, + } + + +def _next_workspace_code(db: Session, *, tenant_id: int) -> str: + count = db.execute( + select(func.count(ConsultantWorkspace.id)).where(ConsultantWorkspace.tenant_id == tenant_id) + ).scalar_one() + return f"CW-{int(count or 0) + 1:04d}" + + +def get_workspace_by_consultant( + db: Session, + *, + tenant_id: int, + consultant_id: int, +) -> ConsultantWorkspace | None: + return db.execute( + select(ConsultantWorkspace).where( + ConsultantWorkspace.tenant_id == tenant_id, + ConsultantWorkspace.consultant_id == consultant_id, + ) + ).scalar_one_or_none() + + +def ensure_consultant_workspace( + db: Session, + *, + consultant: ConsultantProfile, + user_id: int | None = None, +) -> ConsultantWorkspace: + workspace = get_workspace_by_consultant(db, tenant_id=consultant.tenant_id, consultant_id=consultant.id) + if workspace: + return workspace + + workspace = ConsultantWorkspace( + tenant_id=consultant.tenant_id, + branch_id=consultant.branch_id, + consultant_id=consultant.id, + workspace_code=_next_workspace_code(db, tenant_id=consultant.tenant_id), + workspace_name=consultant.firm_name or consultant.contact_person, + workspace_type="franchise_partner" if consultant.is_franchise_partner else "consultant_saas", + plan_code="franchise" if consultant.is_franchise_partner else "starter", + subscription_status="active" if consultant.is_saas_customer or consultant.is_franchise_partner else "trial", + billing_cycle="manual", + max_managed_clients=100 if consultant.is_franchise_partner else 25, + max_user_accounts=5 if consultant.is_franchise_partner else 1, + allow_client_portal=False, + allow_firm_referrals=True, + allow_service_marketplace=bool(consultant.is_franchise_partner or consultant.is_platform_partner), + is_active=True, + created_by_user_id=user_id, + updated_by_user_id=user_id, + ) + db.add(workspace) + return workspace + + +def parse_optional_date(value: str | None) -> date | None: + if not value: + return None + text = value.strip() + if not text: + return None + for fmt in ("%Y-%m-%d", "%d-%m-%Y", "%d/%m/%Y"): + try: + return datetime.strptime(text, fmt).date() + except ValueError: + continue + return None + + +def update_consultant_workspace( + db: Session, + *, + workspace: ConsultantWorkspace, + payload: dict, + user_id: int, + admin_mode: bool = False, +) -> ConsultantWorkspace: + """Update consultant workspace. + + Normal consultant portal users may update only safe self-service fields + (workspace name and remarks). Commercial controls such as plan, billing, + subscription status, client/user limits and feature switches remain firm/admin + controlled unless admin_mode=True is explicitly used by a future internal route. + """ + workspace.workspace_name = normalise_text(payload.get("workspace_name")) or workspace.workspace_name + workspace.remarks = normalise_text(payload.get("remarks")) + + if admin_mode: + workspace.workspace_type = payload.get("workspace_type") or workspace.workspace_type + workspace.plan_code = payload.get("plan_code") or workspace.plan_code + workspace.billing_cycle = payload.get("billing_cycle") or workspace.billing_cycle + workspace.subscription_status = payload.get("subscription_status") or workspace.subscription_status + workspace.subscription_start_date = payload.get("subscription_start_date") + workspace.subscription_end_date = payload.get("subscription_end_date") + workspace.max_managed_clients = int(payload.get("max_managed_clients") or workspace.max_managed_clients or 25) + workspace.max_user_accounts = int(payload.get("max_user_accounts") or workspace.max_user_accounts or 1) + workspace.allow_client_portal = bool(payload.get("allow_client_portal")) + workspace.allow_firm_referrals = bool(payload.get("allow_firm_referrals")) + workspace.allow_service_marketplace = bool(payload.get("allow_service_marketplace")) + workspace.is_active = bool(payload.get("is_active", True)) + + workspace.updated_by_user_id = user_id + return workspace + +def consultant_workspace_summary(db: Session, *, consultant: ConsultantProfile) -> dict: + workspace = get_workspace_by_consultant(db, tenant_id=consultant.tenant_id, consultant_id=consultant.id) + managed_total = db.execute( + select(func.count(ConsultantManagedClient.id)).where( + ConsultantManagedClient.tenant_id == consultant.tenant_id, + ConsultantManagedClient.consultant_id == consultant.id, + ConsultantManagedClient.is_active.is_(True), + ) + ).scalar_one() + if not workspace: + return { + "workspace": None, + "managed_clients_used": int(managed_total or 0), + "managed_clients_limit": None, + "managed_clients_remaining": None, + "usage_percent": 0, + } + limit = max(int(workspace.max_managed_clients or 0), 0) + used = int(managed_total or 0) + remaining = max(limit - used, 0) if limit else None + usage_percent = int(round((used / limit) * 100)) if limit else 0 + return { + "workspace": workspace, + "managed_clients_used": used, + "managed_clients_limit": limit, + "managed_clients_remaining": remaining, + "usage_percent": usage_percent, + } + + +def consultant_can_add_managed_client(db: Session, *, consultant: ConsultantProfile) -> tuple[bool, dict]: + """Return whether the consultant can add one more managed client under workspace limits.""" + summary = consultant_workspace_summary(db, consultant=consultant) + limit = summary.get("managed_clients_limit") + used = int(summary.get("managed_clients_used") or 0) + if limit is None or int(limit or 0) <= 0: + return True, summary + return used < int(limit), summary + + +def _next_consultant_client_code(db: Session, *, tenant_id: int, consultant_id: int) -> str: + count = db.execute( + select(func.count(ConsultantManagedClient.id)).where( + ConsultantManagedClient.tenant_id == tenant_id, + ConsultantManagedClient.consultant_id == consultant_id, + ) + ).scalar_one() + return f"CMC-{consultant_id}-{int(count or 0) + 1:04d}" + + +def list_consultant_managed_clients( + db: Session, + *, + tenant_id: int, + consultant_id: int, + q: str = "", + status: str = "", + include_inactive: bool = False, +) -> list[ConsultantManagedClient]: + query = select(ConsultantManagedClient).where( + ConsultantManagedClient.tenant_id == tenant_id, + ConsultantManagedClient.consultant_id == consultant_id, + ) + if not include_inactive: + query = query.where(ConsultantManagedClient.is_active.is_(True)) + if status.strip(): + query = query.where(ConsultantManagedClient.status == status.strip()) + if q.strip(): + term = f"%{q.strip()}%" + query = query.where( + or_( + ConsultantManagedClient.client_name.ilike(term), + ConsultantManagedClient.trade_name.ilike(term), + ConsultantManagedClient.client_code.ilike(term), + ConsultantManagedClient.pan.ilike(term), + ConsultantManagedClient.gstin.ilike(term), + ConsultantManagedClient.email.ilike(term), + ConsultantManagedClient.mobile.ilike(term), + ) + ) + return db.execute(query.order_by(ConsultantManagedClient.client_name.asc())).scalars().all() + + +def get_consultant_managed_client( + db: Session, + *, + tenant_id: int, + consultant_id: int, + managed_client_id: int, +) -> ConsultantManagedClient | None: + return db.execute( + select(ConsultantManagedClient).where( + ConsultantManagedClient.id == managed_client_id, + ConsultantManagedClient.tenant_id == tenant_id, + ConsultantManagedClient.consultant_id == consultant_id, + ) + ).scalar_one_or_none() + + +def create_or_update_managed_client( + db: Session, + *, + consultant: ConsultantProfile, + payload: dict, + user_id: int, + managed_client: ConsultantManagedClient | None = None, +) -> ConsultantManagedClient: + if managed_client is None: + can_add, summary = consultant_can_add_managed_client(db, consultant=consultant) + if not can_add: + raise ValueError( + "Managed client limit reached for this consultant workspace " + f"({summary.get('managed_clients_used')}/{summary.get('managed_clients_limit')})." + ) + managed_client = ConsultantManagedClient( + tenant_id=consultant.tenant_id, + branch_id=consultant.branch_id, + consultant_id=consultant.id, + created_by_user_id=user_id, + ) + db.add(managed_client) + + managed_client.client_code = normalise_text(payload.get("client_code")) or managed_client.client_code or _next_consultant_client_code( + db, tenant_id=consultant.tenant_id, consultant_id=consultant.id + ) + managed_client.client_name = (payload.get("client_name") or "").strip() + managed_client.trade_name = normalise_text(payload.get("trade_name")) + managed_client.client_type = payload.get("client_type") or "Other" + managed_client.pan = normalise_text(payload.get("pan")) + managed_client.gstin = normalise_text(payload.get("gstin")) + managed_client.tan = normalise_text(payload.get("tan")) + managed_client.contact_person_name = normalise_text(payload.get("contact_person_name")) + managed_client.mobile = normalise_text(payload.get("mobile")) + managed_client.email = normalise_text(payload.get("email")) + managed_client.address_line_1 = normalise_text(payload.get("address_line_1")) + managed_client.address_line_2 = normalise_text(payload.get("address_line_2")) + managed_client.city = normalise_text(payload.get("city")) + managed_client.state = normalise_text(payload.get("state")) + managed_client.pincode = normalise_text(payload.get("pincode")) + managed_client.country = normalise_text(payload.get("country")) or "India" + managed_client.service_interest = normalise_text(payload.get("service_interest")) + managed_client.relationship_stage = payload.get("relationship_stage") or "managed" + managed_client.status = payload.get("status") or "active" + managed_client.is_active = bool(payload.get("is_active", True)) + managed_client.notes = normalise_text(payload.get("notes")) + managed_client.updated_by_user_id = user_id + return managed_client + + +def consultant_managed_clients_stats(db: Session, *, consultant: ConsultantProfile) -> dict: + rows = list_consultant_managed_clients( + db, + tenant_id=consultant.tenant_id, + consultant_id=consultant.id, + include_inactive=True, + ) + return { + "managed_clients": len([r for r in rows if r.is_active]), + "prospects": len([r for r in rows if r.relationship_stage == "lead" and r.is_active]), + "referred_to_firm": len([r for r in rows if r.relationship_stage == "referred_to_firm" and r.is_active]), + } + + +CONSULTANT_SERVICE_REQUEST_STATUSES = [ + ("submitted", "Submitted"), + ("under_review", "Under Review"), + ("accepted", "Accepted"), + ("rejected", "Rejected"), + ("converted", "Converted to Engagement"), + ("closed", "Closed"), +] + +CONSULTANT_SERVICE_REQUEST_PRIORITIES = [ + ("low", "Low"), + ("normal", "Normal"), + ("high", "High"), + ("urgent", "Urgent"), +] + + +def _allowed_consultant_client_ids(db: Session, *, consultant: ConsultantProfile, require_communications: bool = True) -> list[int]: + query = select(ClientConsultantLink.client_id).where( + ClientConsultantLink.tenant_id == consultant.tenant_id, + ClientConsultantLink.consultant_id == consultant.id, + ClientConsultantLink.is_active.is_(True), + ) + if require_communications: + query = query.where(ClientConsultantLink.can_view_communications.is_(True)) + return [int(x) for x in db.execute(query).scalars().all()] + + +def list_consultant_visible_communications( + db: Session, + *, + consultant: ConsultantProfile, + q: str = "", + limit: int = 100, +) -> list[tuple[ServiceTaskComment, ClientServiceTaskInstance, ClientServiceSubscription, Client, ServiceCatalogue]]: + client_ids = _allowed_consultant_client_ids(db, consultant=consultant, require_communications=True) + if not client_ids: + return [] + safe_limit = max(1, min(int(limit or 100), 200)) + query = ( + select(ServiceTaskComment, ClientServiceTaskInstance, ClientServiceSubscription, Client, ServiceCatalogue) + .join(ClientServiceTaskInstance, ClientServiceTaskInstance.id == ServiceTaskComment.task_instance_id) + .join(ClientServiceSubscription, ClientServiceSubscription.id == ServiceTaskComment.subscription_id) + .join(Client, Client.id == ClientServiceTaskInstance.client_id) + .join(ServiceCatalogue, ServiceCatalogue.id == ClientServiceTaskInstance.service_catalogue_id) + .where( + ServiceTaskComment.tenant_id == consultant.tenant_id, + ServiceTaskComment.visibility == "consultant", + ServiceTaskComment.is_deleted.is_(False), + ClientServiceTaskInstance.client_id.in_(client_ids), + ClientServiceTaskInstance.tenant_id == consultant.tenant_id, + ClientServiceTaskInstance.is_active.is_(True), + ) + ) + if q.strip(): + term = f"%{q.strip()}%" + query = query.where( + or_( + Client.client_name.ilike(term), + Client.client_code.ilike(term), + ServiceCatalogue.service_name.ilike(term), + ServiceCatalogue.service_code.ilike(term), + ClientServiceTaskInstance.task_name.ilike(term), + ServiceTaskComment.message.ilike(term), + ) + ) + return db.execute(query.order_by(ServiceTaskComment.created_at_utc.desc(), ServiceTaskComment.id.desc()).limit(safe_limit)).all() + + +def get_consultant_visible_communication( + db: Session, + *, + consultant: ConsultantProfile, + comment_id: int, +) -> tuple[ServiceTaskComment, ClientServiceTaskInstance, ClientServiceSubscription, Client, ServiceCatalogue] | None: + client_ids = _allowed_consultant_client_ids(db, consultant=consultant, require_communications=True) + if not client_ids: + return None + return db.execute( + select(ServiceTaskComment, ClientServiceTaskInstance, ClientServiceSubscription, Client, ServiceCatalogue) + .join(ClientServiceTaskInstance, ClientServiceTaskInstance.id == ServiceTaskComment.task_instance_id) + .join(ClientServiceSubscription, ClientServiceSubscription.id == ServiceTaskComment.subscription_id) + .join(Client, Client.id == ClientServiceTaskInstance.client_id) + .join(ServiceCatalogue, ServiceCatalogue.id == ClientServiceTaskInstance.service_catalogue_id) + .where( + ServiceTaskComment.id == comment_id, + ServiceTaskComment.tenant_id == consultant.tenant_id, + ServiceTaskComment.visibility == "consultant", + ServiceTaskComment.is_deleted.is_(False), + ClientServiceTaskInstance.client_id.in_(client_ids), + ClientServiceTaskInstance.tenant_id == consultant.tenant_id, + ClientServiceTaskInstance.is_active.is_(True), + ) + ).first() + + +def list_consultant_task_timeline( + db: Session, + *, + consultant: ConsultantProfile, + task_id: int, +) -> list[ServiceTaskComment]: + client_ids = _allowed_consultant_client_ids(db, consultant=consultant, require_communications=True) + if not client_ids: + return [] + task = db.execute( + select(ClientServiceTaskInstance).where( + ClientServiceTaskInstance.id == task_id, + ClientServiceTaskInstance.tenant_id == consultant.tenant_id, + ClientServiceTaskInstance.client_id.in_(client_ids), + ClientServiceTaskInstance.is_active.is_(True), + ) + ).scalar_one_or_none() + if not task: + return [] + return db.execute( + select(ServiceTaskComment) + .options(selectinload(ServiceTaskComment.created_by)) + .where( + ServiceTaskComment.tenant_id == consultant.tenant_id, + ServiceTaskComment.task_instance_id == task_id, + ServiceTaskComment.visibility == "consultant", + ServiceTaskComment.is_deleted.is_(False), + ) + .order_by(ServiceTaskComment.created_at_utc.asc(), ServiceTaskComment.id.asc()) + ).scalars().all() + + +def add_consultant_task_reply( + db: Session, + *, + consultant: ConsultantProfile, + task: ClientServiceTaskInstance, + message: str, + user_id: int, +) -> ServiceTaskComment | None: + client_ids = _allowed_consultant_client_ids(db, consultant=consultant, require_communications=True) + if int(task.client_id) not in client_ids: + return None + if getattr(task, "is_locked", False) or getattr(getattr(task, "subscription", None), "is_locked", False): + return None + clean_message = (message or "").strip() + if not clean_message: + return None + row = ServiceTaskComment( + tenant_id=task.tenant_id, + branch_id=task.branch_id, + subscription_id=task.subscription_id, + task_instance_id=task.id, + comment_type="consultant_clarification", + visibility="consultant", + message=clean_message, + created_by_user_id=user_id, + ) + db.add(row) + task.updated_by_user_id = user_id + return row + + +def _next_service_request_no(db: Session, *, tenant_id: int) -> str: + count = db.execute( + select(func.count(ConsultantServiceRequest.id)).where(ConsultantServiceRequest.tenant_id == tenant_id) + ).scalar_one() + return f"CSR-{int(count or 0) + 1:05d}" + + +def list_consultant_requestable_services(db: Session) -> list[ServiceCatalogue]: + flagged = db.execute( + select(ServiceCatalogue) + .where(ServiceCatalogue.is_active.is_(True), ServiceCatalogue.is_consultant_requestable.is_(True)) + .order_by(ServiceCatalogue.service_name.asc()) + ).scalars().all() + if flagged: + return flagged + return db.execute( + select(ServiceCatalogue) + .where(ServiceCatalogue.is_active.is_(True)) + .order_by(ServiceCatalogue.service_name.asc()) + ).scalars().all() + + +def _valid_managed_client_for_consultant(db: Session, *, consultant: ConsultantProfile, managed_client_id: int | None) -> ConsultantManagedClient | None: + if not managed_client_id: + return None + return get_consultant_managed_client( + db, + tenant_id=consultant.tenant_id, + consultant_id=consultant.id, + managed_client_id=int(managed_client_id), + ) + + +def _valid_firm_client_for_consultant(db: Session, *, consultant: ConsultantProfile, firm_client_id: int | None) -> Client | None: + if not firm_client_id: + return None + allowed_client_ids = _allowed_consultant_client_ids(db, consultant=consultant, require_communications=False) + if int(firm_client_id) not in allowed_client_ids: + return None + return db.execute( + select(Client).where( + Client.id == int(firm_client_id), + Client.tenant_id == consultant.tenant_id, + Client.is_archived.is_(False), + ) + ).scalar_one_or_none() + + +def create_consultant_service_request( + db: Session, + *, + consultant: ConsultantProfile, + payload: dict, + user_id: int, +) -> ConsultantServiceRequest: + service_id = payload.get("service_catalogue_id") + service = None + if service_id: + service = db.execute( + select(ServiceCatalogue).where(ServiceCatalogue.id == int(service_id), ServiceCatalogue.is_active.is_(True)) + ).scalar_one_or_none() + managed_client = _valid_managed_client_for_consultant( + db, consultant=consultant, managed_client_id=payload.get("managed_client_id") + ) + firm_client = _valid_firm_client_for_consultant( + db, consultant=consultant, firm_client_id=payload.get("firm_client_id") + ) + if not managed_client and not firm_client: + raise ValueError("Select either one managed client or one linked firm client for the service request.") + requested_service_name = normalise_text(payload.get("requested_service_name")) or (service.service_name if service else None) + if not requested_service_name: + raise ValueError("Service name is required.") + subject = normalise_text(payload.get("subject")) or requested_service_name + row = ConsultantServiceRequest( + tenant_id=consultant.tenant_id, + branch_id=consultant.branch_id, + consultant_id=consultant.id, + managed_client_id=managed_client.id if managed_client else None, + firm_client_id=firm_client.id if firm_client else None, + service_catalogue_id=service.id if service else None, + request_no=_next_service_request_no(db, tenant_id=consultant.tenant_id), + request_type=payload.get("request_type") or "service_request", + status="submitted", + priority=payload.get("priority") or "normal", + requested_service_name=requested_service_name, + requested_due_date=payload.get("requested_due_date"), + subject=subject, + description=normalise_text(payload.get("description")), + consultant_notes=normalise_text(payload.get("consultant_notes")), + created_by_user_id=user_id, + updated_by_user_id=user_id, + is_active=True, + ) + db.add(row) + return row + + +def list_consultant_service_requests( + db: Session, + *, + consultant: ConsultantProfile, + status: str = "", +) -> list[ConsultantServiceRequest]: + query = ( + select(ConsultantServiceRequest) + .options( + selectinload(ConsultantServiceRequest.managed_client), + selectinload(ConsultantServiceRequest.firm_client), + selectinload(ConsultantServiceRequest.service_catalogue), + selectinload(ConsultantServiceRequest.reviewed_by), + ) + .where( + ConsultantServiceRequest.tenant_id == consultant.tenant_id, + ConsultantServiceRequest.consultant_id == consultant.id, + ConsultantServiceRequest.is_active.is_(True), + ) + ) + if status.strip(): + query = query.where(ConsultantServiceRequest.status == status.strip()) + return db.execute(query.order_by(ConsultantServiceRequest.created_at_utc.desc(), ConsultantServiceRequest.id.desc())).scalars().all() + + +def get_consultant_service_request( + db: Session, + *, + tenant_id: int, + request_id: int, + consultant_id: int | None = None, +) -> ConsultantServiceRequest | None: + query = ( + select(ConsultantServiceRequest) + .options( + selectinload(ConsultantServiceRequest.consultant), + selectinload(ConsultantServiceRequest.managed_client), + selectinload(ConsultantServiceRequest.firm_client), + selectinload(ConsultantServiceRequest.service_catalogue), + selectinload(ConsultantServiceRequest.created_by), + selectinload(ConsultantServiceRequest.reviewed_by), + ) + .where(ConsultantServiceRequest.id == request_id, ConsultantServiceRequest.tenant_id == tenant_id) + ) + if consultant_id: + query = query.where(ConsultantServiceRequest.consultant_id == consultant_id) + return db.execute(query).scalar_one_or_none() + + +def list_all_consultant_service_requests( + db: Session, + *, + tenant_id: int, + branch_id: int | None = None, + status: str = "", +) -> list[ConsultantServiceRequest]: + query = ( + select(ConsultantServiceRequest) + .options( + selectinload(ConsultantServiceRequest.consultant), + selectinload(ConsultantServiceRequest.managed_client), + selectinload(ConsultantServiceRequest.firm_client), + selectinload(ConsultantServiceRequest.service_catalogue), + ) + .where(ConsultantServiceRequest.tenant_id == tenant_id, ConsultantServiceRequest.is_active.is_(True)) + ) + if branch_id: + query = query.where(ConsultantServiceRequest.branch_id == branch_id) + if status.strip(): + query = query.where(ConsultantServiceRequest.status == status.strip()) + return db.execute(query.order_by(ConsultantServiceRequest.created_at_utc.desc(), ConsultantServiceRequest.id.desc())).scalars().all() + + +def update_consultant_service_request_status( + db: Session, + *, + request: ConsultantServiceRequest, + status: str, + firm_response: str | None, + user_id: int, +) -> ConsultantServiceRequest: + allowed = {code for code, _label in CONSULTANT_SERVICE_REQUEST_STATUSES} + clean_status = (status or "under_review").strip() + if clean_status not in allowed: + clean_status = "under_review" + request.status = clean_status + request.firm_response = normalise_text(firm_response) + request.reviewed_by_user_id = user_id + request.reviewed_at_utc = datetime.now(timezone.utc) + request.updated_by_user_id = user_id + return request + + +# --------------------------------------------------------------------------- +# Phase 5A.7 / 5A.8 helpers +# Consultant-managed client conversion + workspace limit enforcement +# --------------------------------------------------------------------------- + +def request_managed_client_conversion( + db: Session, + *, + consultant: ConsultantProfile, + managed_client: ConsultantManagedClient, + notes: str | None, + user_id: int, +) -> ConsultantManagedClient: + if managed_client.tenant_id != consultant.tenant_id or managed_client.consultant_id != consultant.id: + raise ValueError("Managed client is not available for this consultant.") + if managed_client.linked_firm_client_id: + raise ValueError("This managed client is already linked to a firm client.") + if managed_client.conversion_status in {"requested", "under_review"}: + raise ValueError("Conversion request is already pending review.") + managed_client.conversion_status = "requested" + managed_client.relationship_stage = "conversion_requested" + managed_client.conversion_requested_at_utc = datetime.now(timezone.utc) + managed_client.conversion_requested_by_user_id = user_id + managed_client.conversion_notes = normalise_text(notes) + managed_client.updated_by_user_id = user_id + return managed_client + + +def list_consultant_conversion_requests( + db: Session, + *, + tenant_id: int, + branch_id: int | None = None, + status: str = "", +) -> list[ConsultantManagedClient]: + query = ( + select(ConsultantManagedClient) + .options( + selectinload(ConsultantManagedClient.consultant), + selectinload(ConsultantManagedClient.linked_firm_client), + ) + .where( + ConsultantManagedClient.tenant_id == tenant_id, + ConsultantManagedClient.conversion_status != "not_requested", + ) + ) + if branch_id: + query = query.where(ConsultantManagedClient.branch_id == branch_id) + if status.strip(): + query = query.where(ConsultantManagedClient.conversion_status == status.strip()) + return db.execute( + query.order_by( + ConsultantManagedClient.conversion_requested_at_utc.desc().nullslast(), + ConsultantManagedClient.id.desc(), + ) + ).scalars().all() + + +def get_client_conversion_request( + db: Session, + *, + tenant_id: int, + managed_client_id: int, + branch_id: int | None = None, +) -> ConsultantManagedClient | None: + query = ( + select(ConsultantManagedClient) + .options( + selectinload(ConsultantManagedClient.consultant), + selectinload(ConsultantManagedClient.linked_firm_client), + ) + .where( + ConsultantManagedClient.id == managed_client_id, + ConsultantManagedClient.tenant_id == tenant_id, + ConsultantManagedClient.conversion_status != "not_requested", + ) + ) + if branch_id: + query = query.where(ConsultantManagedClient.branch_id == branch_id) + return db.execute(query).scalar_one_or_none() + + +def _next_converted_client_code(db: Session, *, tenant_id: int, source_code: str | None) -> str: + base = (normalise_text(source_code) or "CONSULTANT").replace(" ", "-").upper()[:35] + candidate = f"FC-{base}" + existing = db.execute(select(Client.id).where(Client.tenant_id == tenant_id, Client.client_code == candidate)).first() + if not existing: + return candidate + count = db.execute(select(func.count(Client.id)).where(Client.tenant_id == tenant_id)).scalar_one() + return f"FC-{base}-{int(count or 0) + 1:04d}"[:50] + + +def approve_managed_client_conversion( + db: Session, + *, + managed_client: ConsultantManagedClient, + user_id: int, + partner_user_id: int | None = None, + client_code: str | None = None, + firm_notes: str | None = None, +) -> Client: + if managed_client.linked_firm_client_id: + existing_client = db.get(Client, int(managed_client.linked_firm_client_id)) + if existing_client: + return existing_client + if managed_client.conversion_status not in {"requested", "under_review", "rejected"}: + raise ValueError("Only requested/under-review conversion records can be approved.") + if not managed_client.branch_id: + raise ValueError("Managed client has no branch. Set branch before approving conversion.") + + pan = normalise_text(managed_client.pan) + gstin = normalise_text(managed_client.gstin) + if pan: + duplicate_pan = db.execute( + select(Client).where(Client.tenant_id == managed_client.tenant_id, Client.pan == pan, Client.is_archived.is_(False)) + ).scalar_one_or_none() + if duplicate_pan: + raise ValueError(f"A firm client with PAN {pan} already exists: {duplicate_pan.client_name}.") + if gstin: + duplicate_gstin = db.execute( + select(Client).where(Client.tenant_id == managed_client.tenant_id, Client.gstin == gstin, Client.is_archived.is_(False)) + ).scalar_one_or_none() + if duplicate_gstin: + raise ValueError(f"A firm client with GSTIN {gstin} already exists: {duplicate_gstin.client_name}.") + + clean_code = normalise_text(client_code) or _next_converted_client_code( + db, tenant_id=managed_client.tenant_id, source_code=managed_client.client_code + ) + duplicate_code = db.execute( + select(Client).where(Client.tenant_id == managed_client.tenant_id, Client.client_code == clean_code) + ).scalar_one_or_none() + if duplicate_code: + raise ValueError(f"Client code {clean_code} already exists.") + + client = Client( + tenant_id=managed_client.tenant_id, + branch_id=int(managed_client.branch_id), + partner_id=partner_user_id, + engagement_mode="hybrid", + client_code=clean_code, + client_name=managed_client.client_name, + trade_name=managed_client.trade_name, + client_type=managed_client.client_type or "Other", + pan=pan, + gstin=gstin, + tan=normalise_text(managed_client.tan), + contact_person_name=managed_client.contact_person_name, + mobile=managed_client.mobile, + email=managed_client.email, + address_line_1=managed_client.address_line_1, + address_line_2=managed_client.address_line_2, + city=managed_client.city, + state=managed_client.state, + pincode=managed_client.pincode, + country=managed_client.country or "India", + status="active", + client_category="Consultant Referral", + notes=(managed_client.notes or "") + ("\n\nConverted from consultant-managed client."), + is_active=True, + is_archived=False, + created_by_user_id=user_id, + updated_by_user_id=user_id, + ) + db.add(client) + db.flush() + + managed_client.linked_firm_client_id = client.id + managed_client.conversion_status = "approved" + managed_client.relationship_stage = "converted_to_firm_client" + managed_client.conversion_reviewed_at_utc = datetime.now(timezone.utc) + managed_client.conversion_reviewed_by_user_id = user_id + managed_client.conversion_firm_notes = normalise_text(firm_notes) + managed_client.updated_by_user_id = user_id + + link_client_to_consultant( + db, + tenant_id=managed_client.tenant_id, + consultant_id=managed_client.consultant_id, + client=client, + relationship_type="audit_coordination", + is_primary=True, + can_view_client=True, + can_view_services=True, + can_view_due_dates=True, + can_view_communications=True, + remarks="Auto-linked after consultant-managed client conversion.", + user_id=user_id, + ) + db.add( + ClientAuditLog( + client_id=client.id, + tenant_id=client.tenant_id, + branch_id=client.branch_id, + actor_user_id=user_id, + action="converted_from_consultant_client", + summary="Client created from consultant-managed client conversion request.", + payload_json={ + "consultant_id": managed_client.consultant_id, + "managed_client_id": managed_client.id, + "partner_id": partner_user_id, + }, + ) + ) + return client + + +def mark_conversion_under_review( + db: Session, + *, + managed_client: ConsultantManagedClient, + user_id: int, + firm_notes: str | None = None, +) -> ConsultantManagedClient: + if managed_client.conversion_status != "requested": + raise ValueError("Only requested conversions can be marked under review.") + managed_client.conversion_status = "under_review" + managed_client.conversion_reviewed_by_user_id = user_id + managed_client.conversion_reviewed_at_utc = datetime.now(timezone.utc) + managed_client.conversion_firm_notes = normalise_text(firm_notes) + managed_client.updated_by_user_id = user_id + return managed_client + + +def reject_managed_client_conversion( + db: Session, + *, + managed_client: ConsultantManagedClient, + user_id: int, + firm_notes: str | None, +) -> ConsultantManagedClient: + if managed_client.conversion_status not in {"requested", "under_review"}: + raise ValueError("Only pending conversion requests can be rejected.") + managed_client.conversion_status = "rejected" + managed_client.relationship_stage = "managed" + managed_client.conversion_reviewed_by_user_id = user_id + managed_client.conversion_reviewed_at_utc = datetime.now(timezone.utc) + managed_client.conversion_firm_notes = normalise_text(firm_notes) + managed_client.updated_by_user_id = user_id + return managed_client diff --git a/app/modules/consultants/templates/consultants/_consultant_tabs.html b/app/modules/consultants/templates/consultants/_consultant_tabs.html new file mode 100644 index 0000000..b3abbaa --- /dev/null +++ b/app/modules/consultants/templates/consultants/_consultant_tabs.html @@ -0,0 +1,14 @@ +{% set path = request.url.path %} +
+ +
diff --git a/app/modules/consultants/templates/consultants/assignment_detail.html b/app/modules/consultants/templates/consultants/assignment_detail.html new file mode 100644 index 0000000..d1d4cc5 --- /dev/null +++ b/app/modules/consultants/templates/consultants/assignment_detail.html @@ -0,0 +1,78 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %} +
+
+
+

{{ client.client_name }} — {{ catalogue.service_name }}

+

{{ task.task_name }}{% if task.internal_target_date %} • Target {{ task.internal_target_date.strftime('%d-%m-%Y') }}{% endif %}

+
+ Back to My Work Board +
+ +
+
+
+
+
Task Status
{{ task.status.replace('_',' ').title() }}
+
Priority
{{ task.priority.replace('_',' ').title() }}
+
Financial Year
{{ task.financial_year }}
+
Engagement Status
{{ subscription.status.replace('_',' ').title() }}
+
+ {% if task.description %}
{{ task.description }}
{% endif %} +
+ +
+

Consultant Communication Timeline

+
+ {% for note in timeline %} +
+
+
{{ note.comment_type.replace('_',' ').title() }}
+
{{ note.created_at_utc.strftime('%d-%m-%Y %H:%M') if note.created_at_utc else '' }}
+
+
{{ note.message }}
+
+ {% else %} +
No consultant-visible timeline yet.
+ {% endfor %} +
+
+ +
+ +

Send Reply / Submit Update

+ {% if errors %}
{{ errors|join(' ') }}
{% endif %} + +
+
+
+ + +
+
+{% endblock %} diff --git a/app/modules/consultants/templates/consultants/communication_detail.html b/app/modules/consultants/templates/consultants/communication_detail.html new file mode 100644 index 0000000..d0e1f84 --- /dev/null +++ b/app/modules/consultants/templates/consultants/communication_detail.html @@ -0,0 +1,59 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %} +
+
+
+

Communication Detail

+

{{ client.client_name }} • {{ catalogue.service_name }} • {{ task.task_name }}

+
+ Back +
+ + {% if errors %} +
+ {% for error in errors %}
{{ error }}
{% endfor %} +
+ {% endif %} + +
+
+
Client
{{ client.client_name }}
+
Service
{{ catalogue.service_name }}
+
Task Status
{{ task.status.replace('_',' ').title() }}
+
+
+ +
+

Consultant-visible Timeline

+
+ {% for item in timeline %} +
+
+
+ {{ item.comment_type.replace('_',' ').title() }} + {{ item.visibility.replace('_',' ').title() }} +
+
{{ item.created_at_utc.strftime('%d-%m-%Y %H:%M') if item.created_at_utc else '-' }}
+
+
{{ item.created_by.full_name or item.created_by.email if item.created_by else 'System' }}
+

{{ item.message }}

+
+ {% else %} +
No consultant-visible timeline found.
+ {% endfor %} +
+
+ + {% if not task.is_locked and not (task.subscription and task.subscription.is_locked) %} +
+ + + +
+
+ {% else %} +
This task/engagement is locked. Replies are disabled.
+ {% endif %} +
+{% endblock %} diff --git a/app/modules/consultants/templates/consultants/communications_list.html b/app/modules/consultants/templates/consultants/communications_list.html new file mode 100644 index 0000000..902867f --- /dev/null +++ b/app/modules/consultants/templates/consultants/communications_list.html @@ -0,0 +1,41 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %} +
+
+
+

Consultant Communications

+

Only task messages marked with Visibility = Consultant are listed here.

+
+ Back to Dashboard +
+ +
+
+ + +
+
+ +
+ + + + + + {% for comment, task, subscription, client, catalogue in rows %} + + + + + + + + {% else %} + + {% endfor %} + +
Client / ServiceTaskTypeDateAction
{{ client.client_name }}
{{ catalogue.service_name }}
{{ task.task_name }}{{ comment.comment_type.replace('_',' ').title() }}{{ comment.created_at_utc.strftime('%d-%m-%Y %H:%M') if comment.created_at_utc else '-' }}Open / Reply
No consultant-visible communication found.
+
+
+{% endblock %} diff --git a/app/modules/consultants/templates/consultants/consultant_invite_link.html b/app/modules/consultants/templates/consultants/consultant_invite_link.html new file mode 100644 index 0000000..8a5fe41 --- /dev/null +++ b/app/modules/consultants/templates/consultants/consultant_invite_link.html @@ -0,0 +1,12 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+

Consultant Invite Link Generated

+

Share this link with {{ consultant.contact_person }} to set password and activate consultant portal login.

+
{{ invite_url }}
+ +
+{% endblock %} diff --git a/app/modules/consultants/templates/consultants/conversion_request_detail.html b/app/modules/consultants/templates/consultants/conversion_request_detail.html new file mode 100644 index 0000000..cae72d6 --- /dev/null +++ b/app/modules/consultants/templates/consultants/conversion_request_detail.html @@ -0,0 +1,70 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Conversion Request: {{ managed_client.client_name }}

+

Consultant: {{ consultant.firm_name or consultant.contact_person if consultant else '-' }}

+
+ Back +
+ + {% if errors %} +
+ {% for error in errors %}
{{ error }}
{% endfor %} +
+ {% endif %} + +
+

Managed Client Details

+
+
Client Code
{{ managed_client.client_code or '-' }}
+
Client Type
{{ managed_client.client_type }}
+
Conversion Status
{{ managed_client.conversion_status.replace('_',' ').title() }}
+
PAN
{{ managed_client.pan or '-' }}
+
GSTIN
{{ managed_client.gstin or '-' }}
+
Mobile / Email
{{ managed_client.mobile or managed_client.email or '-' }}
+
+
+
Consultant notes
{{ managed_client.conversion_notes or '-' }}
+
Firm notes
{{ managed_client.conversion_firm_notes or '-' }}
+
+
+ + {% if managed_client.linked_firm_client %} +
+ Already converted and linked to firm client: {{ managed_client.linked_firm_client.client_code }} — {{ managed_client.linked_firm_client.client_name }} +
+ {% else %} +
+ +

Firm Review

+
+
+ + +
+
+ + +

Used only when approving.

+
+
+ + +

Optional for now. You can assign partner later from client master.

+
+
+
+ + +
+ +
+ {% endif %} +
+{% endblock %} diff --git a/app/modules/consultants/templates/consultants/detail.html b/app/modules/consultants/templates/consultants/detail.html new file mode 100644 index 0000000..d813cbe --- /dev/null +++ b/app/modules/consultants/templates/consultants/detail.html @@ -0,0 +1,111 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

{{ consultant.contact_person }}

+

{{ consultant.firm_name or 'Individual consultant' }}{% if consultant.specialisation %} • {{ consultant.specialisation }}{% endif %}

+
+
+ Back + {% if can_manage %}Edit{% endif %} +
+
+ +
+
+
+
Email
{{ consultant.email or '-' }}
+
Mobile
{{ consultant.mobile or '-' }}
+
Type
{{ consultant.consultant_type.replace('_',' ').title() }}
+
PAN
{{ consultant.pan or '-' }}
+
GSTIN
{{ consultant.gstin or '-' }}
+
Login user
{{ consultant.user.email if consultant.user else '-' }}
+
+ {% if consultant.address or consultant.remarks %} +
+
Address
{{ consultant.address or '-' }}
+
Remarks
{{ consultant.remarks or '-' }}
+
+ {% endif %} +
+
+
Status
+
{{ consultant.status }}
+
+
Platform partner: {{ 'Yes' if consultant.is_platform_partner else 'No' }}
+
Franchise partner: {{ 'Yes' if consultant.is_franchise_partner else 'No' }}
+
SaaS customer: {{ 'Yes' if consultant.is_saas_customer else 'No' }}
+
+
+
+ + {% if can_link %} +
+ +

Link client to consultant

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + + + +
+
+
+ {% endif %} + +
+
+

Linked Clients

+
+ + + + + + {% for link in links %} + + + + + + + + {% else %} + + {% endfor %} + +
ClientRelationshipAccessStatusAction
{{ link.client.client_name if link.client else '-' }}
{{ link.client.client_code if link.client else '' }}
{{ link.relationship_type.replace('_',' ').title() }}{% if link.is_primary %}Primary{% endif %} + Client {{ '✓' if link.can_view_client else '×' }} • Services {{ '✓' if link.can_view_services else '×' }} • Due {{ '✓' if link.can_view_due_dates else '×' }} • Comm {{ '✓' if link.can_view_communications else '×' }} + {{ 'Active' if link.is_active else 'Inactive' }} + {% if can_link %} +
+ + + +
+ {% endif %} +
No linked clients.
+
+
+{% endblock %} diff --git a/app/modules/consultants/templates/consultants/documents.html b/app/modules/consultants/templates/consultants/documents.html new file mode 100644 index 0000000..c58d8fb --- /dev/null +++ b/app/modules/consultants/templates/consultants/documents.html @@ -0,0 +1,57 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %} +
+
+
+

Shared Documents

+

Documents visible through your active client links. Internal firm-only documents are not listed.

+
+
+ +
+
+ + +
+
+ +
+
+

Engagement Documents

+
+ {% for doc in docs.engagement_documents %} +
+
+
+
{{ doc.title }}
+
{{ doc.client.client_name if doc.client else 'Client' }} • {{ doc.document_type }} • {{ doc.financial_year }}
+
+ v{{ doc.current_version_no }} +
+ {% if doc.versions %}
Latest file: {{ doc.versions[0].original_filename }}
{% endif %} +
+ {% else %}
No engagement documents found.
{% endfor %} +
+
+ +
+

Permanent Documents

+
+ {% for doc in docs.permanent_documents %} +
+
+
+
{{ doc.title }}
+
{{ doc.client.client_name if doc.client else 'Client' }} • {{ doc.category }}
+
+ v{{ doc.current_version_no }} +
+ {% if doc.versions %}
Latest file: {{ doc.versions[0].original_filename }}
{% endif %} +
+ {% else %}
No permanent documents found.
{% endfor %} +
+
+
+
+{% endblock %} diff --git a/app/modules/consultants/templates/consultants/form.html b/app/modules/consultants/templates/consultants/form.html new file mode 100644 index 0000000..8afc3d6 --- /dev/null +++ b/app/modules/consultants/templates/consultants/form.html @@ -0,0 +1,136 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +{% set is_dict = consultant is mapping %} +{% set is_edit = consultant and not is_dict and consultant.id %} +
+
+
+

{{ title }}

+

Create or update a consultant portal profile and optionally create the consultant login user from this page.

+
+ Back +
+ + {% if errors %} +
+
    + {% for error in errors %}
  • {{ error }}
  • {% endfor %} +
+
+ {% endif %} + +
+ + +
+

Consultant Login

+

Link an existing Consultant-role user or create a new login for this consultant.

+
+
+ + {% set current_user_id = consultant.user_id if consultant and not is_dict else consultant.get('user_id') if consultant else None %} + +

Leave blank if you want to create a new login below.

+
+
+ + +
+
+ + +
+
+ + +

Used only when invite link is not selected. Consultant must change password after login.

+
+
+
+ +
+
+ + {% set current_type = consultant.consultant_type if consultant and not is_dict else consultant.get('consultant_type') if consultant else 'external_consultant' %} + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + {% set st = consultant.status if consultant and not is_dict else consultant.get('status') if consultant else 'active' %} + +
+
+ + {% set os = consultant.onboarding_status if consultant and not is_dict else consultant.get('onboarding_status') if consultant else 'active' %} + +
+
+ +
+ + + + +
+ +
+ + +
+ +
+ Cancel + +
+
+
+{% endblock %} diff --git a/app/modules/consultants/templates/consultants/internal_conversion_requests_list.html b/app/modules/consultants/templates/consultants/internal_conversion_requests_list.html new file mode 100644 index 0000000..33e3e85 --- /dev/null +++ b/app/modules/consultants/templates/consultants/internal_conversion_requests_list.html @@ -0,0 +1,59 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Consultant Client Conversion Requests

+

Review consultant-managed clients requested for conversion into firm client master.

+
+ +
+ +
+
+ + +
+
+ +
+ + + + + + + + + + + + + {% for row in rows %} + + + + + + + + + {% else %} + + {% endfor %} + +
Managed ClientConsultantPAN / GSTINStatusRequestedAction
+
{{ row.client_name }}
+
{{ row.client_code or '-' }}{% if row.linked_firm_client %} • Linked: {{ row.linked_firm_client.client_code }}{% endif %}
+
{{ row.consultant.firm_name or row.consultant.contact_person if row.consultant else '-' }}
{{ row.pan or '-' }}
{{ row.gstin or '-' }}
{{ row.conversion_status.replace('_',' ').title() }}{{ row.conversion_requested_at_utc.strftime('%d-%m-%Y') if row.conversion_requested_at_utc else '-' }}Review
No conversion requests found.
+
+
+{% endblock %} diff --git a/app/modules/consultants/templates/consultants/internal_service_requests_list.html b/app/modules/consultants/templates/consultants/internal_service_requests_list.html new file mode 100644 index 0000000..d4e084f --- /dev/null +++ b/app/modules/consultants/templates/consultants/internal_service_requests_list.html @@ -0,0 +1,8 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+

Consultant Service Requests

Review requests raised by consultants.

Consultants
+
+
{% for row in rows %}{% else %}{% endfor %}
RequestConsultantClientServiceStatusAction
{{ row.request_no }}
{{ row.subject }}
{{ row.consultant.contact_person if row.consultant else '-' }}{{ row.managed_client.client_name if row.managed_client else (row.firm_client.client_name if row.firm_client else '-') }}{{ row.requested_service_name }}{{ row.status.replace('_',' ').title() }}Open
No consultant service requests found.
+
+{% endblock %} diff --git a/app/modules/consultants/templates/consultants/list.html b/app/modules/consultants/templates/consultants/list.html new file mode 100644 index 0000000..c1785a7 --- /dev/null +++ b/app/modules/consultants/templates/consultants/list.html @@ -0,0 +1,71 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Consultants

+

Portal-enabled consultants, franchise partners, and external ecosystem collaborators.

+
+
+ {% if can_manage_consultant_service_requests(current_user, current_user_permissions, current_user_roles) %} + Service Requests + {% endif %} + {% if can_manage_consultant_conversions(current_user, current_user_permissions, current_user_roles) %} + Conversions + {% endif %} + {% if can_manage %} + Add Consultant + {% endif %} +
+
+ +
+
+ + + +
+
+ +
+ + + + + + + + + + + + + {% for row in rows %} + + + + + + + + + {% else %} + + {% endfor %} + +
ConsultantContactTypeSpecialisationStatusAction
+
{{ row.contact_person }}
+
{{ row.firm_name or 'Individual consultant' }}
+
+
{{ row.email or '-' }}
+
{{ row.mobile or '-' }}
+
{{ row.consultant_type.replace('_', ' ').title() }}{{ row.specialisation or '-' }} + {{ row.status }} + + Open +
No consultants found.
+
+
+{% endblock %} diff --git a/app/modules/consultants/templates/consultants/managed_client_detail.html b/app/modules/consultants/templates/consultants/managed_client_detail.html new file mode 100644 index 0000000..71e1041 --- /dev/null +++ b/app/modules/consultants/templates/consultants/managed_client_detail.html @@ -0,0 +1,64 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %} +
+
+
+

{{ managed_client.client_name }}

+

{{ managed_client.client_code or 'Managed client' }}{% if managed_client.trade_name %} • {{ managed_client.trade_name }}{% endif %}

+
+
+ Back + Edit +
+
+ +
+
+
Client Type
{{ managed_client.client_type }}
+
PAN
{{ managed_client.pan or '-' }}
+
GSTIN
{{ managed_client.gstin or '-' }}
+
Contact Person
{{ managed_client.contact_person_name or '-' }}
+
Mobile
{{ managed_client.mobile or '-' }}
+
Email
{{ managed_client.email or '-' }}
+
Stage
{{ managed_client.relationship_stage.replace('_',' ').title() }}
+
Status
{{ managed_client.status.replace('_',' ').title() }}
+
Active
{{ 'Yes' if managed_client.is_active else 'No' }}
+
+
+ + +
+

Firm Client Conversion

+
+
Conversion Status
{{ managed_client.conversion_status.replace('_',' ').title() if managed_client.conversion_status else 'Not Requested' }}
+
Requested On
{{ managed_client.conversion_requested_at_utc.strftime('%d-%m-%Y %H:%M') if managed_client.conversion_requested_at_utc else '-' }}
+
Linked Firm Client
{% if managed_client.linked_firm_client %}{{ managed_client.linked_firm_client.client_name }}{% else %}-{% endif %}
+
+ {% if managed_client.conversion_notes %}
Consultant notes:
{{ managed_client.conversion_notes }}
{% endif %} + {% if managed_client.conversion_firm_notes %}
Firm response:
{{ managed_client.conversion_firm_notes }}
{% endif %} + {% if can_request_conversion %} +
+ + + + +
+ {% endif %} +
+ +
+
+

Address

+

{{ [managed_client.address_line_1, managed_client.address_line_2, managed_client.city, managed_client.state, managed_client.pincode, managed_client.country] | select | join('\n') or '-' }}

+
+
+

Service Interest / Notes

+
+
Service Interest
{{ managed_client.service_interest or '-' }}
+
Notes
{{ managed_client.notes or '-' }}
+
+
+
+
+{% endblock %} diff --git a/app/modules/consultants/templates/consultants/managed_client_form.html b/app/modules/consultants/templates/consultants/managed_client_form.html new file mode 100644 index 0000000..ac713e2 --- /dev/null +++ b/app/modules/consultants/templates/consultants/managed_client_form.html @@ -0,0 +1,127 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %} +{% set is_dict = managed_client is mapping %} +
+
+

{{ title }}

+

Maintain your own client record in the consultant portal. This does not change the audit firm's client master.

+
+ + {% if errors %} +
+
    {% for err in errors %}
  • {{ err }}
  • {% endfor %}
+
+ {% endif %} + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + {% set ct = managed_client.client_type if managed_client and not is_dict else managed_client.get('client_type','Other') if managed_client else 'Other' %} + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + {% set stage = managed_client.relationship_stage if managed_client and not is_dict else managed_client.get('relationship_stage','managed') if managed_client else 'managed' %} + +
+
+ + {% set st = managed_client.status if managed_client and not is_dict else managed_client.get('status','active') if managed_client else 'active' %} + +
+ +
+ +
+ + +
+
+ + +
+ +
+ Cancel + +
+
+
+{% endblock %} diff --git a/app/modules/consultants/templates/consultants/managed_clients_list.html b/app/modules/consultants/templates/consultants/managed_clients_list.html new file mode 100644 index 0000000..366a8a6 --- /dev/null +++ b/app/modules/consultants/templates/consultants/managed_clients_list.html @@ -0,0 +1,88 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %} +
+
+
+

My Managed Clients

+

Clients maintained by you in the consultant portal. These records do not modify the audit firm client master.

+
+
+ Dashboard + {% if can_add_managed_client %} + Add Managed Client + {% else %} + Limit Reached + {% endif %} +
+
+ + {% if workspace_summary %} +
+ Managed client usage: {{ workspace_summary.managed_clients_used }} / {{ workspace_summary.managed_clients_limit or 'No limit' }} + {% if workspace_summary.managed_clients_remaining is not none %} • Remaining: {{ workspace_summary.managed_clients_remaining }}{% endif %} +
+ {% endif %} + +
+
+ + + + +
+
+ +
+ + + + + + + + + + + + + + {% for row in rows %} + + + + + + + + + + {% else %} + + {% endfor %} + +
ClientContactPAN / GSTINStageStatusConversionAction
+
{{ row.client_name }}
+
{{ row.client_code or '-' }}{% if row.trade_name %} • {{ row.trade_name }}{% endif %}
+
+
{{ row.contact_person_name or '-' }}
+
{{ row.mobile or row.email or '-' }}
+
+
{{ row.pan or '-' }}
+
{{ row.gstin or '-' }}
+
{{ row.relationship_stage.replace('_', ' ').title() }} + {{ row.status.replace('_',' ').title() }} + + {{ row.conversion_status.replace('_',' ').title() if row.conversion_status else 'Not Requested' }} + {% if row.linked_firm_client_id %}
Linked to firm client
{% endif %} +
+ Open +
No managed clients found.
+
+
+{% endblock %} diff --git a/app/modules/consultants/templates/consultants/portal_dashboard.html b/app/modules/consultants/templates/consultants/portal_dashboard.html new file mode 100644 index 0000000..2c01742 --- /dev/null +++ b/app/modules/consultants/templates/consultants/portal_dashboard.html @@ -0,0 +1,128 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %} +
+ {% if not payload.workspace %} +
+

Consultant Portal

+

Set up your consultant workspace

+

Create your workspace to manage clients, communicate with firms and refer work.

+
+
+ Your consultant workspace is not configured yet. Open profile/workspace settings and complete setup. + +
+ {% else %} +
+
+
+

Consultant Portal

+

Managed clients, firm referrals and consultant assignments

+

Track bookkeeping clients, clarifications, due dates, service requests and conversion requests with audit firms.

+
+ +
+
+ +
+
Clarifications
{{ payload.stats.pending_clarifications or 0 }}
Firm messages
+
Service Requests
{{ payload.stats.open_service_requests or 0 }}
Open requests
+
Managed Clients
{{ payload.stats.managed_clients or 0 }}
Bookkeeping / support
+
Prospects
{{ payload.stats.prospects or 0 }}
Lead pipeline
+
Due Soon
{{ payload.stats.upcoming_due or 0 }}
Next 30 days
+
Overdue
{{ payload.stats.overdue or 0 }}
Linked firm work
+
+ +
+ + +
+
+
+

Pending Consultant Clarifications

Firm messages visible to consultant. Internal and client-only notes are hidden.

+ View all +
+ +
+ +
+
+

Pending Conversions

View
+
{% for client in payload.pending_conversions %}
{{ client.client_name }}
{{ client.client_code or '-' }} • {{ client.conversion_status.replace('_',' ').title() }}
{% else %}
No pending firm-client conversion requests.
{% endfor %}
+
+
+

Upcoming Due Dates

30 days
+
{% for subscription, client, catalogue in payload.due_items %}
{{ client.client_name }}
{{ catalogue.service_name }} • Due {{ subscription.current_due_date.strftime('%d-%m-%Y') if subscription.current_due_date else '-' }}
{% else %}
No upcoming due dates.
{% endfor %}
+
+
+

Overdue Work

Attention
+
{% for subscription, client, catalogue in payload.overdue_items %}
{{ client.client_name }}
{{ catalogue.service_name }} • Due {{ subscription.current_due_date.strftime('%d-%m-%Y') if subscription.current_due_date else '-' }}
{% else %}
No overdue linked firm engagements.
{% endfor %}
+
+
+ +
+ +
+

Linked Firm Clients

Only explicitly linked clients are visible here.

+
{% for link in payload.active_links %}
{{ link.client.client_name if link.client else '-' }}
{{ link.client.client_code if link.client else '' }} • {{ link.relationship_type.replace('_',' ').title() }}
Due dates {{ 'enabled' if link.can_view_due_dates else 'hidden' }} • Communications {{ 'enabled' if link.can_view_communications else 'hidden' }}
{% else %}
No firm clients are linked yet.
{% endfor %}
+
+
+
+
+ {% endif %} +
+{% endblock %} diff --git a/app/modules/consultants/templates/consultants/profile_form.html b/app/modules/consultants/templates/consultants/profile_form.html new file mode 100644 index 0000000..6c7617e --- /dev/null +++ b/app/modules/consultants/templates/consultants/profile_form.html @@ -0,0 +1,92 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %} +{% set is_dict = consultant is mapping %} +
+
+
+

My Consultant Profile

+

Update your visible consultant contact and business details.

+
+ Back +
+ + {% if errors %} +
+
    {% for error in errors %}
  • {{ error }}
  • {% endfor %}
+
+ {% endif %} + +
+ +
+
+ {% set profile_photo_url = get_user_profile_photo_url(current_user) %} + {% if profile_photo_url %} + Profile photo + {% else %} +
{{ get_user_initials(current_user) }}
+ {% endif %} +
+
Public profile
+

Photo, qualification and bio will be used in consultant workspace and future lead pages.

+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ Cancel + +
+
+
+{% endblock %} diff --git a/app/modules/consultants/templates/consultants/service_request_detail.html b/app/modules/consultants/templates/consultants/service_request_detail.html new file mode 100644 index 0000000..f4c0b37 --- /dev/null +++ b/app/modules/consultants/templates/consultants/service_request_detail.html @@ -0,0 +1,30 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %} +
+

{{ request_row.request_no }}

{{ request_row.subject }}

Back
+
+
+
Status
{{ request_row.status.replace('_',' ').title() }}
+
Priority
{{ request_row.priority.replace('_',' ').title() }}
+
Requested Date
{{ request_row.requested_due_date or '-' }}
+
Consultant
{{ request_row.consultant.contact_person if request_row.consultant else '-' }}
+
Client
{{ request_row.managed_client.client_name if request_row.managed_client else (request_row.firm_client.client_name if request_row.firm_client else '-') }}
+
Service
{{ request_row.requested_service_name }}
+
+
+
+

Description

{{ request_row.description or '-' }}

+

Consultant Notes

{{ request_row.consultant_notes or '-' }}

+
+

Firm Response

{{ request_row.firm_response or 'No response yet.' }}

{% if request_row.reviewed_by %}

Reviewed by {{ request_row.reviewed_by.full_name or request_row.reviewed_by.email }} on {{ request_row.reviewed_at_utc.strftime('%d-%m-%Y %H:%M') if request_row.reviewed_at_utc else '-' }}

{% endif %}
+ {% if internal_view %} +
+ +

Update Firm Decision

+
+
+
+ {% endif %} +
+{% endblock %} diff --git a/app/modules/consultants/templates/consultants/service_request_form.html b/app/modules/consultants/templates/consultants/service_request_form.html new file mode 100644 index 0000000..c189258 --- /dev/null +++ b/app/modules/consultants/templates/consultants/service_request_form.html @@ -0,0 +1,23 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %} +
+

New Service Request

Raise a request to the audit firm for review and acceptance.

Back
+ {% if errors %}
{% for error in errors %}
{{ error }}
{% endfor %}
{% endif %} +
+ +
+

Use this for clients maintained in your consultant portal.

+

Select either managed client or linked firm client, not both.

+
+
+
+
+
+
+
+
+
+
+
+{% endblock %} diff --git a/app/modules/consultants/templates/consultants/service_requests_list.html b/app/modules/consultants/templates/consultants/service_requests_list.html new file mode 100644 index 0000000..f623452 --- /dev/null +++ b/app/modules/consultants/templates/consultants/service_requests_list.html @@ -0,0 +1,20 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %} +
+
+

My Service Requests

Request audit firm services for your managed or linked clients.

+ +
+
+
+ + +
+
+
+ + {% for row in rows %}{% else %}{% endfor %}
RequestClientServiceStatusAction
{{ row.request_no }}
{{ row.subject }}
{{ row.managed_client.client_name if row.managed_client else (row.firm_client.client_name if row.firm_client else '-') }}{{ row.requested_service_name }}{{ row.status.replace('_',' ').title() }}Open
No service requests yet.
+
+
+{% endblock %} diff --git a/app/modules/consultants/templates/consultants/work_board.html b/app/modules/consultants/templates/consultants/work_board.html new file mode 100644 index 0000000..31d68a4 --- /dev/null +++ b/app/modules/consultants/templates/consultants/work_board.html @@ -0,0 +1,85 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %} +
+
+
+

My Consultant Work Board

+

Consultant-visible work shared by the firm. Internal firm tasks and private notes are not shown here.

+
+ Raise Service Request +
+ +
+
+ + + +
+
+ +
+
+ {% for key, column in board.columns.items() %} +
+
+

{{ column["label"] }}

+ {{ column["items"]|length }} +
+ +
+ {% endfor %} +
+
+ +
+
+
+

My Service Requests

+

Requests raised by you to the firm.

+
+ View all +
+ +
+
+{% endblock %} diff --git a/app/modules/consultants/templates/consultants/workspace_detail.html b/app/modules/consultants/templates/consultants/workspace_detail.html new file mode 100644 index 0000000..99a5825 --- /dev/null +++ b/app/modules/consultants/templates/consultants/workspace_detail.html @@ -0,0 +1,73 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %} +
+
+
+

Consultant Workspace

+

SaaS / franchise workspace foundation for your consultant portal.

+
+ +
+ +
+
+
Workspace Code
+
{{ workspace.workspace_code }}
+
+
+
Plan
+
{{ workspace.plan_code.replace('_',' ').title() }}
+
+
+
Status
+
{{ workspace.subscription_status.replace('_',' ').title() }}
+
+
+
Billing
+
{{ workspace.billing_cycle.replace('_',' ').title() }}
+
+
+ +
+
+

Workspace Details

+
+
+
+
Workspace Name
+
{{ workspace.workspace_name }}
+
+
+
Workspace Type
+
{{ workspace.workspace_type.replace('_',' ').title() }}
+
+
+
Subscription Period
+
{{ workspace.subscription_start_date or '-' }} to {{ workspace.subscription_end_date or '-' }}
+
+
+
Limits
+
{{ workspace.max_managed_clients }} managed clients • {{ workspace.max_user_accounts }} user account(s)
+
+
+
Enabled Features
+
+ Client Portal {{ 'On' if workspace.allow_client_portal else 'Off' }} + Firm Referrals {{ 'On' if workspace.allow_firm_referrals else 'Off' }} + Marketplace {{ 'On' if workspace.allow_service_marketplace else 'Off' }} +
+
+ {% if workspace.remarks %} +
+
Remarks
+
{{ workspace.remarks }}
+
+ {% endif %} +
+
+
+{% endblock %} diff --git a/app/modules/consultants/templates/consultants/workspace_form.html b/app/modules/consultants/templates/consultants/workspace_form.html new file mode 100644 index 0000000..6bc40cf --- /dev/null +++ b/app/modules/consultants/templates/consultants/workspace_form.html @@ -0,0 +1,70 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %} +
+
+
+

Edit Consultant Workspace

+

Update your workspace display details. Plan, billing, subscription and limits are controlled by the firm/admin.

+
+ Back to Workspace +
+ + {% if errors %} +
+
    + {% for error in errors %}
  • {{ error }}
  • {% endfor %} +
+
+ {% endif %} + +
+
+
Plan
+
{{ workspace.plan_code.replace('_',' ').title() if workspace and workspace.plan_code else 'Starter' }}
+
+
+
Subscription
+
{{ workspace.subscription_status.replace('_',' ').title() if workspace and workspace.subscription_status else 'Trial' }}
+
+
+
Billing
+
{{ workspace.billing_cycle.replace('_',' ').title() if workspace and workspace.billing_cycle else 'Manual' }}
+
+
+
Client Limit
+
{{ workspace.max_managed_clients if workspace else 25 }}
+
+
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+
Admin-controlled fields
+

Workspace type, plan, subscription status, billing cycle, client/user limits, feature switches and active status cannot be changed from the consultant portal.

+
+ +
+ + +
+ +
+ + Cancel +
+
+
+{% endblock %} diff --git a/app/modules/consultants/ui.py b/app/modules/consultants/ui.py new file mode 100644 index 0000000..32d6a98 --- /dev/null +++ b/app/modules/consultants/ui.py @@ -0,0 +1,1747 @@ +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.constants import CLIENT_TYPES +from app.modules.clients.models import Client +from app.modules.consultants.service import ( + CONSULTANT_MANAGED_CLIENT_STAGES, + CONSULTANT_MANAGED_CLIENT_STATUSES, + CONSULTANT_RELATIONSHIP_TYPES, + CONSULTANT_TYPES, + CONSULTANT_BILLING_CYCLES, + CONSULTANT_ONBOARDING_STATUSES, + CONSULTANT_CLIENT_CONVERSION_STATUSES, + CONSULTANT_SERVICE_REQUEST_PRIORITIES, + CONSULTANT_SERVICE_REQUEST_STATUSES, + CONSULTANT_SUBSCRIPTION_STATUSES, + CONSULTANT_WORKSPACE_PLANS, + CONSULTANT_WORKSPACE_TYPES, + add_consultant_task_reply, + approve_managed_client_conversion, + consultant_can_add_managed_client, + consultant_dashboard_payload, + create_consultant_service_request, + create_or_update_consultant, + create_or_update_managed_client, + ensure_consultant_login_user, + ensure_consultant_workspace, + get_consultant, + get_consultant_by_user, + get_consultant_managed_client, + get_client_conversion_request, + get_consultant_service_request, + get_consultant_visible_communication, + get_workspace_by_consultant, + link_client_to_consultant, + list_all_consultant_service_requests, + list_client_links_for_consultant, + list_consultant_conversion_requests, + list_clients_available_for_link, + list_consultant_requestable_services, + list_consultant_role_users, + list_consultant_managed_clients, + list_consultant_service_requests, + list_consultant_task_timeline, + list_consultant_visible_communications, + list_consultants, + mark_conversion_under_review, + parse_optional_date, + reject_managed_client_conversion, + request_managed_client_conversion, + set_link_active, + update_consultant_own_profile, + update_consultant_service_request_status, + update_consultant_workspace, +) +from app.modules.consultants.portal_service import ( + get_consultant_assignment_detail, + get_consultant_document_centre, + get_consultant_work_board, +) +from app.modules.core.rbac.deps import get_user_permissions, get_user_roles +from app.modules.core.rbac.permission_guard import require_permission +from app.modules.core.iam.profile_service import save_user_profile_photo, update_user_public_profile + +router = APIRouter(prefix="/consultants", tags=["consultants-ui"]) +portal_router = APIRouter(prefix="/consultant", tags=["consultant-portal-ui"]) + + +def _base_ctx(request: Request, user, db, **ctx): + base = { + "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), + "consultant_types": CONSULTANT_TYPES, + "relationship_types": CONSULTANT_RELATIONSHIP_TYPES, + "managed_client_statuses": CONSULTANT_MANAGED_CLIENT_STATUSES, + "managed_client_stages": CONSULTANT_MANAGED_CLIENT_STAGES, + "client_types": CLIENT_TYPES, + "workspace_types": CONSULTANT_WORKSPACE_TYPES, + "workspace_plans": CONSULTANT_WORKSPACE_PLANS, + "subscription_statuses": CONSULTANT_SUBSCRIPTION_STATUSES, + "billing_cycles": CONSULTANT_BILLING_CYCLES, + "onboarding_statuses": CONSULTANT_ONBOARDING_STATUSES, + "service_request_statuses": CONSULTANT_SERVICE_REQUEST_STATUSES, + "service_request_priorities": CONSULTANT_SERVICE_REQUEST_PRIORITIES, + "conversion_statuses": CONSULTANT_CLIENT_CONVERSION_STATUSES, + } + base.update(ctx) + return base + + +def _render(request: Request, template_name: str, db, user, **ctx): + return templates.TemplateResponse(template_name, _base_ctx(request, user, db, **ctx)) + + +def _redirect_denied(): + return RedirectResponse(url="/system-settings", status_code=303) + + +def _has_perm(db, user, code: str) -> bool: + try: + require_permission(db, user, code) + return True + except Exception: + return False + + +def _role_names(db, user) -> set[str]: + return set(get_user_roles(db, user.id)) + + +def _is_system_admin(db, user) -> bool: + return "System Admin" in _role_names(db, user) + + +def _is_firm_admin(db, user) -> bool: + return "Firm Admin" in _role_names(db, user) + + +def _is_partner(db, user) -> bool: + return "Partner" in _role_names(db, user) + + +def _is_consultant(db, user) -> bool: + return "Consultant" in _role_names(db, user) + + +def _active_tenant_id(request: Request, user) -> int: + return int( + request.session.get("active_tenant_id") + or request.session.get("selected_tenant_id") + or request.session.get("tenant_id") + or user.tenant_id + ) + + +def _active_branch_id(request: Request, user, db) -> int | None: + value = request.session.get("active_branch_id") + if value in (None, "", 0, "0"): + if _has_perm(db, user, "consultants.cross_branch") or _has_perm(db, user, "clients.cross_branch"): + return None + return int(getattr(user, "branch_id", 0) or 0) or None + return int(value) + + +def _form_bool(value) -> bool: + return value in ("1", "true", "True", "on", "yes") + + +def _can_manage_profiles(db, user) -> bool: + return _has_perm(db, user, "consultants.manage") + + +def _can_link_clients(db, user) -> bool: + return _has_perm(db, user, "consultants.link_clients") + + +def _can_manage_conversions(db, user) -> bool: + return _has_perm(db, user, "consultants.conversions.manage") + + +def _partner_filter_user_id(db, user) -> int | None: + return int(user.id) if _is_partner(db, user) and not _is_firm_admin(db, user) else None + + +def _build_payload(request: Request, *, tenant_id: int): + form = request._form + user_id_raw = form.get("user_id") + branch_raw = form.get("branch_id") + return { + "tenant_id": tenant_id, + "branch_id": int(branch_raw) if branch_raw not in (None, "", "None") else None, + "user_id": int(user_id_raw) if user_id_raw not in (None, "", "None") else None, + "consultant_type": form.get("consultant_type") or "external_consultant", + "firm_name": form.get("firm_name"), + "contact_person": (form.get("contact_person") or "").strip(), + "email": form.get("email"), + "mobile": form.get("mobile"), + "specialisation": form.get("specialisation"), + "gstin": form.get("gstin"), + "pan": form.get("pan"), + "address": form.get("address"), + "status": form.get("status") or "active", + "onboarding_status": form.get("onboarding_status") or "active", + "is_platform_partner": _form_bool(form.get("is_platform_partner")), + "is_franchise_partner": _form_bool(form.get("is_franchise_partner")), + "is_saas_customer": _form_bool(form.get("is_saas_customer")), + "is_active": _form_bool(form.get("is_active")), + "remarks": form.get("remarks"), + "create_login_user": _form_bool(form.get("create_login_user")), + "invite_login_user": _form_bool(form.get("invite_login_user")), + "login_email": form.get("login_email"), + "temporary_password": form.get("temporary_password"), + } + + +def _validate_payload(payload: dict) -> list[str]: + errors: list[str] = [] + if not payload.get("contact_person"): + errors.append("Contact person/name is required.") + if not payload.get("email") and not payload.get("mobile"): + errors.append("Enter at least email or mobile for consultant communication.") + if payload.get("create_login_user") and payload.get("user_id"): + errors.append("Choose either an existing linked user or create a new login user, not both.") + if payload.get("create_login_user") and not (payload.get("login_email") or payload.get("email")): + errors.append("Login email is required for consultant login creation.") + if payload.get("create_login_user") and not payload.get("invite_login_user") and not (payload.get("temporary_password") or "").strip(): + errors.append("Enter a temporary password or choose invite link for consultant login.") + if payload.get("temporary_password") and len((payload.get("temporary_password") or "").strip()) < 8: + errors.append("Temporary password must be at least 8 characters.") + return errors + + +def _build_own_profile_payload(request: Request) -> dict: + form = request._form + return { + "firm_name": form.get("firm_name"), + "contact_person": (form.get("contact_person") or "").strip(), + "email": form.get("email"), + "mobile": form.get("mobile"), + "specialisation": form.get("specialisation"), + "gstin": form.get("gstin"), + "pan": form.get("pan"), + "address": form.get("address"), + } + + +def _validate_own_profile_payload(payload: dict) -> list[str]: + errors: list[str] = [] + if not payload.get("contact_person"): + errors.append("Contact person/name is required.") + if not payload.get("email") and not payload.get("mobile"): + errors.append("Enter at least email or mobile.") + return errors + + +def _maybe_create_consultant_login(db, *, payload: dict, actor_user, branch_id: int | None) -> tuple[int | None, str | None]: + if not payload.get("create_login_user"): + return payload.get("user_id"), None + login_user, invite_url = ensure_consultant_login_user( + db, + tenant_id=int(payload["tenant_id"]), + branch_id=payload.get("branch_id") or branch_id or getattr(actor_user, "branch_id", None), + email=(payload.get("login_email") or payload.get("email") or "").strip(), + full_name=payload.get("contact_person") or payload.get("firm_name") or "Consultant", + password=payload.get("temporary_password"), + invite_user=bool(payload.get("invite_login_user")), + ) + return int(login_user.id), invite_url + + +def _get_own_consultant_or_dashboard(request: Request, db, user): + tenant_id = int(getattr(user, "tenant_id", 0) or 0) + consultant = get_consultant_by_user(db, tenant_id=tenant_id, user_id=user.id) + if consultant: + return consultant + return None + + +def _build_managed_client_payload(request: Request) -> dict: + form = request._form + return { + "client_code": form.get("client_code"), + "client_name": (form.get("client_name") or "").strip(), + "trade_name": form.get("trade_name"), + "client_type": form.get("client_type") or "Other", + "pan": form.get("pan"), + "gstin": form.get("gstin"), + "tan": form.get("tan"), + "contact_person_name": form.get("contact_person_name"), + "mobile": form.get("mobile"), + "email": form.get("email"), + "address_line_1": form.get("address_line_1"), + "address_line_2": form.get("address_line_2"), + "city": form.get("city"), + "state": form.get("state"), + "pincode": form.get("pincode"), + "country": form.get("country") or "India", + "service_interest": form.get("service_interest"), + "relationship_stage": form.get("relationship_stage") or "managed", + "status": form.get("status") or "active", + "is_active": _form_bool(form.get("is_active")), + "notes": form.get("notes"), + } + + +def _validate_managed_client_payload(payload: dict) -> list[str]: + errors: list[str] = [] + if not payload.get("client_name"): + errors.append("Client name is required.") + if not payload.get("mobile") and not payload.get("email"): + errors.append("Enter at least mobile or email for the managed client.") + return errors + + + +def _build_workspace_payload(request: Request) -> dict: + form = request._form + return { + "workspace_name": (form.get("workspace_name") or "").strip(), + "workspace_type": form.get("workspace_type") or "consultant_saas", + "plan_code": form.get("plan_code") or "starter", + "billing_cycle": form.get("billing_cycle") or "manual", + "subscription_status": form.get("subscription_status") or "trial", + "subscription_start_date": parse_optional_date(form.get("subscription_start_date")), + "subscription_end_date": parse_optional_date(form.get("subscription_end_date")), + "max_managed_clients": form.get("max_managed_clients") or 25, + "max_user_accounts": form.get("max_user_accounts") or 1, + "allow_client_portal": _form_bool(form.get("allow_client_portal")), + "allow_firm_referrals": _form_bool(form.get("allow_firm_referrals")), + "allow_service_marketplace": _form_bool(form.get("allow_service_marketplace")), + "is_active": _form_bool(form.get("is_active")), + "remarks": form.get("remarks"), + } + + +def _validate_workspace_payload(payload: dict) -> list[str]: + errors: list[str] = [] + if not payload.get("workspace_name"): + errors.append("Workspace name is required.") + try: + if int(payload.get("max_managed_clients") or 0) < 0: + errors.append("Managed client limit cannot be negative.") + except (TypeError, ValueError): + errors.append("Managed client limit must be a number.") + try: + if int(payload.get("max_user_accounts") or 0) < 1: + errors.append("User account limit must be at least 1.") + except (TypeError, ValueError): + errors.append("User account limit must be a number.") + start = payload.get("subscription_start_date") + end = payload.get("subscription_end_date") + if start and end and end < start: + errors.append("Subscription end date cannot be earlier than start date.") + return errors + + +def _build_workspace_self_payload(request: Request) -> dict: + form = request._form + return { + "workspace_name": (form.get("workspace_name") or "").strip(), + "remarks": form.get("remarks"), + } + + +def _validate_workspace_self_payload(payload: dict) -> list[str]: + errors: list[str] = [] + if not payload.get("workspace_name"): + errors.append("Workspace name is required.") + return errors + + +@router.get("") +def consultants_list(request: Request, q: str = "", include_inactive: bool = False): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not _has_perm(db, user, "consultants.view"): + return _redirect_denied() + tenant_id = _active_tenant_id(request, user) + branch_id = _active_branch_id(request, user, db) + rows = list_consultants(db, tenant_id=tenant_id, branch_id=branch_id, q=q, include_inactive=include_inactive) + return _render( + request, + "modules/consultants/templates/consultants/list.html", + db, + user, + title="Consultants", + rows=rows, + q=q, + include_inactive=include_inactive, + can_manage=_can_manage_profiles(db, user), + can_link=_can_link_clients(db, user), + ) + finally: + db.close() + + +@router.get("/new") +def consultant_new_page(request: Request): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not _can_manage_profiles(db, user): + return _redirect_denied() + tenant_id = _active_tenant_id(request, user) + branch_id = _active_branch_id(request, user, db) + users = list_consultant_role_users(db, tenant_id=tenant_id, branch_id=branch_id) + return _render( + request, + "modules/consultants/templates/consultants/form.html", + db, + user, + title="Add Consultant", + consultant=None, + consultant_users=users, + errors=[], + ) + finally: + db.close() + + +@router.post("/new") +async def consultant_new_submit(request: Request, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + await request.form() + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not _can_manage_profiles(db, user): + return _redirect_denied() + tenant_id = _active_tenant_id(request, user) + branch_id = _active_branch_id(request, user, db) + payload = _build_payload(request, tenant_id=tenant_id) + if branch_id and payload.get("branch_id") not in (None, branch_id): + return _redirect_denied() + errors = _validate_payload(payload) + if errors: + users = list_consultant_role_users(db, tenant_id=tenant_id, branch_id=branch_id) + return _render( + request, + "modules/consultants/templates/consultants/form.html", + db, + user, + title="Add Consultant", + consultant=payload, + consultant_users=users, + errors=errors, + ) + try: + linked_user_id, invite_url = _maybe_create_consultant_login(db, payload=payload, actor_user=user, branch_id=branch_id) + payload["user_id"] = linked_user_id + if invite_url: + payload["onboarding_status"] = "invited" + elif linked_user_id: + payload["onboarding_status"] = payload.get("onboarding_status") or "active" + consultant = create_or_update_consultant(db, payload=payload, user_id=user.id) + db.commit() + db.refresh(consultant) + except Exception as exc: + db.rollback() + users = list_consultant_role_users(db, tenant_id=tenant_id, branch_id=branch_id) + return _render( + request, + "modules/consultants/templates/consultants/form.html", + db, + user, + title="Add Consultant", + consultant=payload, + consultant_users=users, + errors=[str(exc)], + ) + if invite_url: + return _render( + request, + "modules/consultants/templates/consultants/consultant_invite_link.html", + db, + user, + title="Consultant Invite Link", + consultant=consultant, + invite_url=invite_url, + invited_user=consultant.user, + ) + return RedirectResponse(url=f"/consultants/{consultant.id}", status_code=303) + finally: + db.close() + + +@router.get("/service-requests") +def internal_consultant_service_requests_list(request: Request, status: str = ""): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not (_has_perm(db, user, "consultants.view") and _has_perm(db, user, "consultants.service_requests.manage")): + return _redirect_denied() + tenant_id = _active_tenant_id(request, user) + branch_id = _active_branch_id(request, user, db) + rows = list_all_consultant_service_requests(db, tenant_id=tenant_id, branch_id=branch_id, status=status) + return _render( + request, + "modules/consultants/templates/consultants/internal_service_requests_list.html", + db, + user, + title="Consultant Service Requests", + rows=rows, + status=status, + ) + finally: + db.close() + + +@router.get("/service-requests/{request_id}") +def internal_consultant_service_request_detail(request: Request, request_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not (_has_perm(db, user, "consultants.view") and _has_perm(db, user, "consultants.service_requests.manage")): + return _redirect_denied() + tenant_id = _active_tenant_id(request, user) + row = get_consultant_service_request(db, tenant_id=tenant_id, request_id=request_id) + if not row: + return RedirectResponse(url="/consultants/service-requests", status_code=303) + return _render( + request, + "modules/consultants/templates/consultants/service_request_detail.html", + db, + user, + title="Consultant Service Request", + consultant=row.consultant, + request_row=row, + internal_view=True, + ) + finally: + db.close() + + +@router.post("/service-requests/{request_id}/status") +def internal_consultant_service_request_status( + request: Request, + request_id: int, + status: str = Form(...), + firm_response: str = Form(""), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not (_has_perm(db, user, "consultants.view") and _has_perm(db, user, "consultants.service_requests.manage")): + return _redirect_denied() + tenant_id = _active_tenant_id(request, user) + row = get_consultant_service_request(db, tenant_id=tenant_id, request_id=request_id) + if not row: + return RedirectResponse(url="/consultants/service-requests", status_code=303) + update_consultant_service_request_status(db, request=row, status=status, firm_response=firm_response, user_id=user.id) + db.commit() + return RedirectResponse(url=f"/consultants/service-requests/{request_id}", status_code=303) + finally: + db.close() + + + + +@router.get("/conversion-requests") +def internal_consultant_conversion_requests_list(request: Request, status: str = ""): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not (_has_perm(db, user, "consultants.view") and _can_manage_conversions(db, user)): + return _redirect_denied() + tenant_id = _active_tenant_id(request, user) + branch_id = _active_branch_id(request, user, db) + rows = list_consultant_conversion_requests(db, tenant_id=tenant_id, branch_id=branch_id, status=status) + return _render( + request, + "modules/consultants/templates/consultants/internal_conversion_requests_list.html", + db, + user, + title="Consultant Client Conversion Requests", + rows=rows, + status=status, + ) + finally: + db.close() + + +@router.get("/conversion-requests/{managed_client_id}") +def internal_consultant_conversion_request_detail(request: Request, managed_client_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not (_has_perm(db, user, "consultants.view") and _can_manage_conversions(db, user)): + return _redirect_denied() + tenant_id = _active_tenant_id(request, user) + branch_id = _active_branch_id(request, user, db) + row = get_client_conversion_request(db, tenant_id=tenant_id, managed_client_id=managed_client_id, branch_id=branch_id) + if not row: + return RedirectResponse(url="/consultants/conversion-requests", status_code=303) + return _render( + request, + "modules/consultants/templates/consultants/conversion_request_detail.html", + db, + user, + title="Conversion Request", + managed_client=row, + consultant=row.consultant, + errors=[], + ) + finally: + db.close() + + +@router.post("/conversion-requests/{managed_client_id}/review") +def internal_consultant_conversion_review( + request: Request, + managed_client_id: int, + action: str = Form(...), + client_code: str = Form(""), + partner_user_id: str = Form(""), + firm_notes: str = Form(""), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not (_has_perm(db, user, "consultants.view") and _can_manage_conversions(db, user)): + return _redirect_denied() + tenant_id = _active_tenant_id(request, user) + branch_id = _active_branch_id(request, user, db) + row = get_client_conversion_request(db, tenant_id=tenant_id, managed_client_id=managed_client_id, branch_id=branch_id) + if not row: + return RedirectResponse(url="/consultants/conversion-requests", status_code=303) + try: + if action == "under_review": + mark_conversion_under_review(db, managed_client=row, user_id=user.id, firm_notes=firm_notes) + elif action == "reject": + reject_managed_client_conversion(db, managed_client=row, user_id=user.id, firm_notes=firm_notes) + elif action == "approve": + partner_id = int(partner_user_id) if str(partner_user_id).strip() else None + firm_client = approve_managed_client_conversion( + db, + managed_client=row, + user_id=user.id, + partner_user_id=partner_id, + client_code=client_code, + firm_notes=firm_notes, + ) + db.commit() + return RedirectResponse(url=f"/clients/{firm_client.id}", status_code=303) + else: + raise ValueError("Invalid review action.") + db.commit() + return RedirectResponse(url=f"/consultants/conversion-requests/{managed_client_id}", status_code=303) + except Exception as exc: + db.rollback() + return _render( + request, + "modules/consultants/templates/consultants/conversion_request_detail.html", + db, + user, + title="Conversion Request", + managed_client=row, + consultant=row.consultant, + errors=[str(exc)], + ) + finally: + db.close() + + +@router.get("/{consultant_id}") +def consultant_detail(request: Request, consultant_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not _has_perm(db, user, "consultants.view"): + return _redirect_denied() + tenant_id = _active_tenant_id(request, user) + branch_id = _active_branch_id(request, user, db) + consultant = get_consultant(db, tenant_id=tenant_id, consultant_id=consultant_id, branch_id=branch_id) + if not consultant: + return RedirectResponse(url="/consultants", status_code=303) + links = list_client_links_for_consultant(db, consultant_id=consultant.id) + clients = list_clients_available_for_link( + db, + tenant_id=tenant_id, + branch_id=branch_id, + partner_user_id=_partner_filter_user_id(db, user), + ) + return _render( + request, + "modules/consultants/templates/consultants/detail.html", + db, + user, + title="Consultant Detail", + consultant=consultant, + links=links, + clients=clients, + can_manage=_can_manage_profiles(db, user), + can_link=_can_link_clients(db, user), + ) + finally: + db.close() + + +@router.get("/{consultant_id}/edit") +def consultant_edit_page(request: Request, consultant_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not _can_manage_profiles(db, user): + return _redirect_denied() + tenant_id = _active_tenant_id(request, user) + branch_id = _active_branch_id(request, user, db) + consultant = get_consultant(db, tenant_id=tenant_id, consultant_id=consultant_id, branch_id=branch_id) + if not consultant: + return RedirectResponse(url="/consultants", status_code=303) + users = list_consultant_role_users(db, tenant_id=tenant_id, branch_id=branch_id) + return _render( + request, + "modules/consultants/templates/consultants/form.html", + db, + user, + title="Edit Consultant", + consultant=consultant, + consultant_users=users, + errors=[], + ) + finally: + db.close() + + +@router.post("/{consultant_id}/edit") +async def consultant_edit_submit(request: Request, consultant_id: int, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + await request.form() + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not _can_manage_profiles(db, user): + return _redirect_denied() + tenant_id = _active_tenant_id(request, user) + branch_id = _active_branch_id(request, user, db) + consultant = get_consultant(db, tenant_id=tenant_id, consultant_id=consultant_id, branch_id=branch_id) + if not consultant: + return RedirectResponse(url="/consultants", status_code=303) + payload = _build_payload(request, tenant_id=tenant_id) + if branch_id and payload.get("branch_id") not in (None, branch_id): + return _redirect_denied() + errors = _validate_payload(payload) + if errors: + users = list_consultant_role_users(db, tenant_id=tenant_id, branch_id=branch_id) + return _render( + request, + "modules/consultants/templates/consultants/form.html", + db, + user, + title="Edit Consultant", + consultant=consultant, + consultant_users=users, + errors=errors, + ) + invite_url = None + try: + linked_user_id, invite_url = _maybe_create_consultant_login(db, payload=payload, actor_user=user, branch_id=branch_id) + payload["user_id"] = linked_user_id + if invite_url: + payload["onboarding_status"] = "invited" + create_or_update_consultant(db, payload=payload, user_id=user.id, consultant=consultant) + db.commit() + except Exception as exc: + db.rollback() + users = list_consultant_role_users(db, tenant_id=tenant_id, branch_id=branch_id) + return _render( + request, + "modules/consultants/templates/consultants/form.html", + db, + user, + title="Edit Consultant", + consultant=consultant, + consultant_users=users, + errors=[str(exc)], + ) + if invite_url: + return _render( + request, + "modules/consultants/templates/consultants/consultant_invite_link.html", + db, + user, + title="Consultant Invite Link", + consultant=consultant, + invite_url=invite_url, + invited_user=consultant.user, + ) + return RedirectResponse(url=f"/consultants/{consultant.id}", status_code=303) + finally: + db.close() + + +@router.post("/{consultant_id}/links") +def consultant_link_submit( + request: Request, + consultant_id: int, + client_id: int = Form(...), + relationship_type: str = Form("accounts_consultant"), + is_primary: str | None = Form(None), + can_view_client: str | None = Form(None), + can_view_services: str | None = Form(None), + can_view_due_dates: str | None = Form(None), + can_view_communications: str | None = Form(None), + remarks: str = Form(""), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not _can_link_clients(db, user): + return _redirect_denied() + tenant_id = _active_tenant_id(request, user) + branch_id = _active_branch_id(request, user, db) + consultant = get_consultant(db, tenant_id=tenant_id, consultant_id=consultant_id, branch_id=branch_id) + if not consultant: + return RedirectResponse(url="/consultants", status_code=303) + client_query = select(Client).where(Client.id == client_id, Client.tenant_id == tenant_id, Client.is_archived.is_(False)) + if branch_id: + client_query = client_query.where(Client.branch_id == branch_id) + partner_id = _partner_filter_user_id(db, user) + if partner_id: + client_query = client_query.where(Client.partner_id == partner_id) + client = db.execute(client_query).scalar_one_or_none() + if not client: + return _redirect_denied() + link_client_to_consultant( + db, + tenant_id=tenant_id, + consultant_id=consultant.id, + client=client, + relationship_type=relationship_type, + is_primary=_form_bool(is_primary), + can_view_client=can_view_client is not None, + can_view_services=can_view_services is not None, + can_view_due_dates=can_view_due_dates is not None, + can_view_communications=can_view_communications is not None, + remarks=remarks, + user_id=user.id, + ) + db.commit() + return RedirectResponse(url=f"/consultants/{consultant.id}", status_code=303) + finally: + db.close() + + +@router.post("/links/{link_id}/toggle") +def consultant_link_toggle(request: Request, link_id: int, active: str = Form("0"), csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not _can_link_clients(db, user): + return _redirect_denied() + tenant_id = _active_tenant_id(request, user) + link = set_link_active(db, tenant_id=tenant_id, link_id=link_id, active=_form_bool(active), user_id=user.id) + if not link: + return RedirectResponse(url="/consultants", status_code=303) + db.commit() + return RedirectResponse(url=f"/consultants/{link.consultant_id}", status_code=303) + finally: + db.close() + + + +@portal_router.get("/managed-clients") +def consultant_managed_clients_list(request: Request, q: str = "", status: str = "", include_inactive: bool = False): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not (_has_perm(db, user, "consultants.portal.view") or _is_consultant(db, user)): + return _redirect_denied() + consultant = _get_own_consultant_or_dashboard(request, db, user) + if not consultant: + return RedirectResponse(url="/consultant/dashboard", status_code=303) + rows = list_consultant_managed_clients( + db, + tenant_id=consultant.tenant_id, + consultant_id=consultant.id, + q=q, + status=status, + include_inactive=include_inactive, + ) + can_add_managed_client, workspace_summary = consultant_can_add_managed_client(db, consultant=consultant) + return _render( + request, + "modules/consultants/templates/consultants/managed_clients_list.html", + db, + user, + title="My Managed Clients", + consultant=consultant, + rows=rows, + q=q, + status=status, + include_inactive=include_inactive, + can_add_managed_client=can_add_managed_client, + workspace_summary=workspace_summary, + ) + finally: + db.close() + + +@portal_router.get("/managed-clients/new") +def consultant_managed_client_new_page(request: Request): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not (_has_perm(db, user, "consultants.portal.view") or _is_consultant(db, user)): + return _redirect_denied() + consultant = _get_own_consultant_or_dashboard(request, db, user) + if not consultant: + return RedirectResponse(url="/consultant/dashboard", status_code=303) + can_add_managed_client, workspace_summary = consultant_can_add_managed_client(db, consultant=consultant) + return _render( + request, + "modules/consultants/templates/consultants/managed_client_form.html", + db, + user, + title="Add Managed Client", + consultant=consultant, + managed_client=None, + errors=[] if can_add_managed_client else [ + f"Managed client limit reached ({workspace_summary.get('managed_clients_used')}/{workspace_summary.get('managed_clients_limit')})." + ], + can_add_managed_client=can_add_managed_client, + workspace_summary=workspace_summary, + ) + finally: + db.close() + + +@portal_router.post("/managed-clients/new") +async def consultant_managed_client_new_submit(request: Request, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + await request.form() + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not (_has_perm(db, user, "consultants.portal.view") or _is_consultant(db, user)): + return _redirect_denied() + consultant = _get_own_consultant_or_dashboard(request, db, user) + if not consultant: + return RedirectResponse(url="/consultant/dashboard", status_code=303) + payload = _build_managed_client_payload(request) + errors = _validate_managed_client_payload(payload) + can_add_managed_client, workspace_summary = consultant_can_add_managed_client(db, consultant=consultant) + if not can_add_managed_client: + errors.append( + f"Managed client limit reached ({workspace_summary.get('managed_clients_used')}/{workspace_summary.get('managed_clients_limit')})." + ) + if errors: + return _render( + request, + "modules/consultants/templates/consultants/managed_client_form.html", + db, + user, + title="Add Managed Client", + consultant=consultant, + managed_client=payload, + errors=errors, + can_add_managed_client=can_add_managed_client, + workspace_summary=workspace_summary, + ) + try: + row = create_or_update_managed_client(db, consultant=consultant, payload=payload, user_id=user.id) + db.commit() + db.refresh(row) + except Exception as exc: + db.rollback() + return _render( + request, + "modules/consultants/templates/consultants/managed_client_form.html", + db, + user, + title="Add Managed Client", + consultant=consultant, + managed_client=payload, + errors=[str(exc)], + can_add_managed_client=can_add_managed_client, + workspace_summary=workspace_summary, + ) + return RedirectResponse(url=f"/consultant/managed-clients/{row.id}", status_code=303) + finally: + db.close() + + +@portal_router.get("/managed-clients/{managed_client_id}") +def consultant_managed_client_detail(request: Request, managed_client_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not (_has_perm(db, user, "consultants.portal.view") or _is_consultant(db, user)): + return _redirect_denied() + consultant = _get_own_consultant_or_dashboard(request, db, user) + if not consultant: + return RedirectResponse(url="/consultant/dashboard", status_code=303) + row = get_consultant_managed_client( + db, + tenant_id=consultant.tenant_id, + consultant_id=consultant.id, + managed_client_id=managed_client_id, + ) + if not row: + return RedirectResponse(url="/consultant/managed-clients", status_code=303) + return _render( + request, + "modules/consultants/templates/consultants/managed_client_detail.html", + db, + user, + title="Managed Client Detail", + consultant=consultant, + managed_client=row, + can_request_conversion=(not row.linked_firm_client_id and row.conversion_status not in {"requested", "under_review", "approved"}), + ) + finally: + db.close() + + + + +@portal_router.post("/managed-clients/{managed_client_id}/request-conversion") +def consultant_managed_client_request_conversion( + request: Request, + managed_client_id: int, + conversion_notes: str = Form(""), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not (_has_perm(db, user, "consultants.portal.view") or _is_consultant(db, user)): + return _redirect_denied() + consultant = _get_own_consultant_or_dashboard(request, db, user) + if not consultant: + return RedirectResponse(url="/consultant/dashboard", status_code=303) + row = get_consultant_managed_client( + db, + tenant_id=consultant.tenant_id, + consultant_id=consultant.id, + managed_client_id=managed_client_id, + ) + if not row: + return RedirectResponse(url="/consultant/managed-clients", status_code=303) + try: + request_managed_client_conversion( + db, + consultant=consultant, + managed_client=row, + notes=conversion_notes, + user_id=user.id, + ) + db.commit() + except Exception: + db.rollback() + return RedirectResponse(url=f"/consultant/managed-clients/{managed_client_id}", status_code=303) + finally: + db.close() + + +@portal_router.get("/managed-clients/{managed_client_id}/edit") +def consultant_managed_client_edit_page(request: Request, managed_client_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not (_has_perm(db, user, "consultants.portal.view") or _is_consultant(db, user)): + return _redirect_denied() + consultant = _get_own_consultant_or_dashboard(request, db, user) + if not consultant: + return RedirectResponse(url="/consultant/dashboard", status_code=303) + row = get_consultant_managed_client( + db, + tenant_id=consultant.tenant_id, + consultant_id=consultant.id, + managed_client_id=managed_client_id, + ) + if not row: + return RedirectResponse(url="/consultant/managed-clients", status_code=303) + return _render( + request, + "modules/consultants/templates/consultants/managed_client_form.html", + db, + user, + title="Edit Managed Client", + consultant=consultant, + managed_client=row, + errors=[], + ) + finally: + db.close() + + +@portal_router.post("/managed-clients/{managed_client_id}/edit") +async def consultant_managed_client_edit_submit(request: Request, managed_client_id: int, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + await request.form() + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not (_has_perm(db, user, "consultants.portal.view") or _is_consultant(db, user)): + return _redirect_denied() + consultant = _get_own_consultant_or_dashboard(request, db, user) + if not consultant: + return RedirectResponse(url="/consultant/dashboard", status_code=303) + row = get_consultant_managed_client( + db, + tenant_id=consultant.tenant_id, + consultant_id=consultant.id, + managed_client_id=managed_client_id, + ) + if not row: + return RedirectResponse(url="/consultant/managed-clients", status_code=303) + payload = _build_managed_client_payload(request) + errors = _validate_managed_client_payload(payload) + if errors: + return _render( + request, + "modules/consultants/templates/consultants/managed_client_form.html", + db, + user, + title="Edit Managed Client", + consultant=consultant, + managed_client=row, + errors=errors, + ) + create_or_update_managed_client(db, consultant=consultant, payload=payload, user_id=user.id, managed_client=row) + db.commit() + return RedirectResponse(url=f"/consultant/managed-clients/{row.id}", status_code=303) + finally: + db.close() + + +@portal_router.get("/workspace") +def consultant_workspace_page(request: Request): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not (_has_perm(db, user, "consultants.portal.view") or _is_consultant(db, user)): + return _redirect_denied() + consultant = _get_own_consultant_or_dashboard(request, db, user) + if not consultant: + return RedirectResponse(url="/consultant/dashboard", status_code=303) + workspace = ensure_consultant_workspace(db, consultant=consultant, user_id=user.id) + db.commit() + db.refresh(workspace) + return _render( + request, + "modules/consultants/templates/consultants/workspace_detail.html", + db, + user, + title="Consultant Workspace", + consultant=consultant, + workspace=workspace, + ) + finally: + db.close() + + +@portal_router.get("/workspace/edit") +def consultant_workspace_edit_page(request: Request): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not (_has_perm(db, user, "consultants.workspace.manage") or _is_consultant(db, user)): + return _redirect_denied() + consultant = _get_own_consultant_or_dashboard(request, db, user) + if not consultant: + return RedirectResponse(url="/consultant/dashboard", status_code=303) + workspace = ensure_consultant_workspace(db, consultant=consultant, user_id=user.id) + db.commit() + db.refresh(workspace) + return _render( + request, + "modules/consultants/templates/consultants/workspace_form.html", + db, + user, + title="Edit Consultant Workspace", + consultant=consultant, + workspace=workspace, + errors=[], + ) + finally: + db.close() + + +@portal_router.post("/workspace/edit") +async def consultant_workspace_edit_submit(request: Request, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + await request.form() + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not (_has_perm(db, user, "consultants.workspace.manage") or _is_consultant(db, user)): + return _redirect_denied() + consultant = _get_own_consultant_or_dashboard(request, db, user) + if not consultant: + return RedirectResponse(url="/consultant/dashboard", status_code=303) + workspace = ensure_consultant_workspace(db, consultant=consultant, user_id=user.id) + payload = _build_workspace_self_payload(request) + errors = _validate_workspace_self_payload(payload) + if errors: + return _render( + request, + "modules/consultants/templates/consultants/workspace_form.html", + db, + user, + title="Edit Consultant Workspace", + consultant=consultant, + workspace={**workspace.__dict__, **payload}, + errors=errors, + ) + update_consultant_workspace(db, workspace=workspace, payload=payload, user_id=user.id, admin_mode=False) + db.commit() + return RedirectResponse(url="/consultant/workspace", status_code=303) + finally: + db.close() + + +@portal_router.get("/profile") +def consultant_profile_page(request: Request): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not (_has_perm(db, user, "consultants.portal.view") or _is_consultant(db, user)): + return _redirect_denied() + consultant = _get_own_consultant_or_dashboard(request, db, user) + if not consultant: + return RedirectResponse(url="/consultant/dashboard", status_code=303) + return _render( + request, + "modules/consultants/templates/consultants/profile_form.html", + db, + user, + title="My Consultant Profile", + consultant=consultant, + errors=[], + ) + finally: + db.close() + + +@portal_router.post("/profile") +async def consultant_profile_submit(request: Request, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + await request.form() + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not (_has_perm(db, user, "consultants.portal.view") or _is_consultant(db, user)): + return _redirect_denied() + consultant = _get_own_consultant_or_dashboard(request, db, user) + if not consultant: + return RedirectResponse(url="/consultant/dashboard", status_code=303) + payload = _build_own_profile_payload(request) + errors = _validate_own_profile_payload(payload) + if errors: + return _render( + request, + "modules/consultants/templates/consultants/profile_form.html", + db, + user, + title="My Consultant Profile", + consultant={**consultant.__dict__, **payload}, + errors=errors, + ) + form = request._form + profile_photo_path = await save_user_profile_photo(user, form.get("profile_photo")) + update_user_public_profile( + db, + user, + qualification=form.get("qualification"), + designation=form.get("public_designation") or "Consultant", + mobile=payload.get("mobile"), + bio=form.get("bio"), + profile_photo_path=profile_photo_path, + ) + update_consultant_own_profile(db, consultant=consultant, payload=payload, user_id=user.id) + db.commit() + return RedirectResponse(url="/consultant/dashboard", status_code=303) + finally: + db.close() + + +@portal_router.get("/dashboard") +def consultant_dashboard(request: Request): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not (_has_perm(db, user, "consultants.portal.view") or _is_consultant(db, user)): + return _redirect_denied() + tenant_id = int(getattr(user, "tenant_id", 0) or 0) + consultant = get_consultant_by_user(db, tenant_id=tenant_id, user_id=user.id) + if not consultant: + return _render( + request, + "modules/consultants/templates/consultants/portal_dashboard.html", + db, + user, + title="Consultant Portal", + consultant=None, + payload={"links": [], "active_links": [], "comments": [], "managed_clients": [], "workspace": None, "workspace_summary": {"workspace": None, "managed_clients_used": 0, "managed_clients_limit": None, "managed_clients_remaining": None, "usage_percent": 0}, "stats": {"linked_clients": 0, "managed_clients": 0, "prospects": 0, "referred_to_firm": 0, "pending_clarifications": 0, "upcoming_due": 0, "overdue": 0}}, + ) + payload = consultant_dashboard_payload(db, consultant=consultant) + return _render( + request, + "modules/consultants/templates/consultants/portal_dashboard.html", + db, + user, + title="Consultant Portal", + consultant=consultant, + payload=payload, + ) + finally: + db.close() + + +@portal_router.get("/communications") +def consultant_communications_list(request: Request, q: str = ""): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not (_has_perm(db, user, "consultants.portal.view") or _is_consultant(db, user)): + return _redirect_denied() + consultant = _get_own_consultant_or_dashboard(request, db, user) + if not consultant: + return RedirectResponse(url="/consultant/dashboard", status_code=303) + rows = list_consultant_visible_communications(db, consultant=consultant, q=q) + return _render( + request, + "modules/consultants/templates/consultants/communications_list.html", + db, + user, + title="Consultant Communications", + consultant=consultant, + rows=rows, + q=q, + ) + finally: + db.close() + + +@portal_router.get("/communications/{comment_id}") +def consultant_communication_detail(request: Request, comment_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not (_has_perm(db, user, "consultants.portal.view") or _is_consultant(db, user)): + return _redirect_denied() + consultant = _get_own_consultant_or_dashboard(request, db, user) + if not consultant: + return RedirectResponse(url="/consultant/dashboard", status_code=303) + row = get_consultant_visible_communication(db, consultant=consultant, comment_id=comment_id) + if not row: + return RedirectResponse(url="/consultant/communications", status_code=303) + comment, task, subscription, client, catalogue = row + timeline = list_consultant_task_timeline(db, consultant=consultant, task_id=task.id) + return _render( + request, + "modules/consultants/templates/consultants/communication_detail.html", + db, + user, + title="Communication Detail", + consultant=consultant, + comment=comment, + task=task, + subscription=subscription, + client=client, + catalogue=catalogue, + timeline=timeline, + errors=[], + ) + finally: + db.close() + + +@portal_router.post("/communications/{comment_id}/reply") +async def consultant_communication_reply(request: Request, comment_id: int, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + form = await request.form() + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not (_has_perm(db, user, "consultants.portal.view") or _is_consultant(db, user)): + return _redirect_denied() + consultant = _get_own_consultant_or_dashboard(request, db, user) + if not consultant: + return RedirectResponse(url="/consultant/dashboard", status_code=303) + row = get_consultant_visible_communication(db, consultant=consultant, comment_id=comment_id) + if not row: + return RedirectResponse(url="/consultant/communications", status_code=303) + comment, task, subscription, client, catalogue = row + message = (form.get("message") or "").strip() + errors = [] if message else ["Reply message is required."] + if not errors: + reply = add_consultant_task_reply(db, consultant=consultant, task=task, message=message, user_id=user.id) + if not reply: + errors.append("Reply could not be saved. The task may be locked or access is restricted.") + if errors: + timeline = list_consultant_task_timeline(db, consultant=consultant, task_id=task.id) + return _render( + request, + "modules/consultants/templates/consultants/communication_detail.html", + db, + user, + title="Communication Detail", + consultant=consultant, + comment=comment, + task=task, + subscription=subscription, + client=client, + catalogue=catalogue, + timeline=timeline, + errors=errors, + ) + db.commit() + return RedirectResponse(url=f"/consultant/communications/{comment_id}", status_code=303) + finally: + db.close() + + +@portal_router.get("/service-requests") +def consultant_service_requests_list(request: Request, status: str = ""): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not (_has_perm(db, user, "consultants.portal.view") or _is_consultant(db, user)): + return _redirect_denied() + consultant = _get_own_consultant_or_dashboard(request, db, user) + if not consultant: + return RedirectResponse(url="/consultant/dashboard", status_code=303) + rows = list_consultant_service_requests(db, consultant=consultant, status=status) + return _render( + request, + "modules/consultants/templates/consultants/service_requests_list.html", + db, + user, + title="Service Requests", + consultant=consultant, + rows=rows, + status=status, + ) + finally: + db.close() + + +@portal_router.get("/service-requests/new") +def consultant_service_request_new_page(request: Request): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not (_has_perm(db, user, "consultants.portal.view") or _is_consultant(db, user)): + return _redirect_denied() + consultant = _get_own_consultant_or_dashboard(request, db, user) + if not consultant: + return RedirectResponse(url="/consultant/dashboard", status_code=303) + managed_clients = list_consultant_managed_clients(db, tenant_id=consultant.tenant_id, consultant_id=consultant.id) + firm_links = list_client_links_for_consultant(db, consultant_id=consultant.id) + services = list_consultant_requestable_services(db) + return _render( + request, + "modules/consultants/templates/consultants/service_request_form.html", + db, + user, + title="New Service Request", + consultant=consultant, + request_row=None, + managed_clients=managed_clients, + firm_links=[link for link in firm_links if link.is_active and link.can_view_client], + services=services, + errors=[], + ) + finally: + db.close() + + +@portal_router.post("/service-requests/new") +async def consultant_service_request_new_submit(request: Request, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + await request.form() + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not (_has_perm(db, user, "consultants.portal.view") or _is_consultant(db, user)): + return _redirect_denied() + consultant = _get_own_consultant_or_dashboard(request, db, user) + if not consultant: + return RedirectResponse(url="/consultant/dashboard", status_code=303) + payload = _build_service_request_payload(request) + errors = _validate_service_request_payload(payload) + if errors: + managed_clients = list_consultant_managed_clients(db, tenant_id=consultant.tenant_id, consultant_id=consultant.id) + firm_links = list_client_links_for_consultant(db, consultant_id=consultant.id) + services = list_consultant_requestable_services(db) + return _render( + request, + "modules/consultants/templates/consultants/service_request_form.html", + db, + user, + title="New Service Request", + consultant=consultant, + request_row=payload, + managed_clients=managed_clients, + firm_links=[link for link in firm_links if link.is_active and link.can_view_client], + services=services, + errors=errors, + ) + try: + row = create_consultant_service_request(db, consultant=consultant, payload=payload, user_id=user.id) + db.commit() + db.refresh(row) + except Exception as exc: + db.rollback() + managed_clients = list_consultant_managed_clients(db, tenant_id=consultant.tenant_id, consultant_id=consultant.id) + firm_links = list_client_links_for_consultant(db, consultant_id=consultant.id) + services = list_consultant_requestable_services(db) + return _render( + request, + "modules/consultants/templates/consultants/service_request_form.html", + db, + user, + title="New Service Request", + consultant=consultant, + request_row=payload, + managed_clients=managed_clients, + firm_links=[link for link in firm_links if link.is_active and link.can_view_client], + services=services, + errors=[str(exc)], + ) + return RedirectResponse(url=f"/consultant/service-requests/{row.id}", status_code=303) + finally: + db.close() + + +@portal_router.get("/service-requests/{request_id}") +def consultant_service_request_detail(request: Request, request_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not (_has_perm(db, user, "consultants.portal.view") or _is_consultant(db, user)): + return _redirect_denied() + consultant = _get_own_consultant_or_dashboard(request, db, user) + if not consultant: + return RedirectResponse(url="/consultant/dashboard", status_code=303) + row = get_consultant_service_request(db, tenant_id=consultant.tenant_id, request_id=request_id, consultant_id=consultant.id) + if not row: + return RedirectResponse(url="/consultant/service-requests", status_code=303) + return _render( + request, + "modules/consultants/templates/consultants/service_request_detail.html", + db, + user, + title="Service Request Detail", + consultant=consultant, + request_row=row, + internal_view=False, + ) + finally: + db.close() + + +@portal_router.get("/work") +def consultant_work_board(request: Request, q: str = "", status: str = ""): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not (_has_perm(db, user, "consultants.portal.view") or _is_consultant(db, user)): + return _redirect_denied() + consultant = _get_own_consultant_or_dashboard(request, db, user) + if not consultant: + return RedirectResponse(url="/consultant/dashboard", status_code=303) + board = get_consultant_work_board(db, consultant=consultant, q=q, status=status) + return _render( + request, + "modules/consultants/templates/consultants/work_board.html", + db, + user, + title="My Consultant Work", + consultant=consultant, + board=board, + q=q, + status=status, + ) + finally: + db.close() + + +@portal_router.get("/assignments/{task_id}") +def consultant_assignment_detail(request: Request, task_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not (_has_perm(db, user, "consultants.portal.view") or _is_consultant(db, user)): + return _redirect_denied() + consultant = _get_own_consultant_or_dashboard(request, db, user) + if not consultant: + return RedirectResponse(url="/consultant/dashboard", status_code=303) + detail = get_consultant_assignment_detail(db, consultant=consultant, task_id=task_id) + if not detail: + return RedirectResponse(url="/consultant/work", status_code=303) + return _render( + request, + "modules/consultants/templates/consultants/assignment_detail.html", + db, + user, + title="Consultant Work Detail", + consultant=consultant, + **detail, + errors=[], + ) + finally: + db.close() + + +@portal_router.post("/assignments/{task_id}/reply") +async def consultant_assignment_reply(request: Request, task_id: int, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + form = await request.form() + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not (_has_perm(db, user, "consultants.portal.view") or _is_consultant(db, user)): + return _redirect_denied() + consultant = _get_own_consultant_or_dashboard(request, db, user) + if not consultant: + return RedirectResponse(url="/consultant/dashboard", status_code=303) + detail = get_consultant_assignment_detail(db, consultant=consultant, task_id=task_id) + if not detail: + return RedirectResponse(url="/consultant/work", status_code=303) + message = (form.get("message") or "").strip() + errors = [] if message else ["Reply/submission note is required."] + if not errors: + reply = add_consultant_task_reply(db, consultant=consultant, task=detail["task"], message=message, user_id=user.id) + if not reply: + errors.append("Reply could not be saved. The task may be locked or access is restricted.") + if errors: + refreshed = get_consultant_assignment_detail(db, consultant=consultant, task_id=task_id) or detail + return _render( + request, + "modules/consultants/templates/consultants/assignment_detail.html", + db, + user, + title="Consultant Work Detail", + consultant=consultant, + **refreshed, + errors=errors, + ) + db.commit() + return RedirectResponse(url=f"/consultant/assignments/{task_id}", status_code=303) + finally: + db.close() + + +@portal_router.get("/documents") +def consultant_documents_page(request: Request, q: str = ""): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not (_has_perm(db, user, "consultants.portal.view") or _is_consultant(db, user)): + return _redirect_denied() + consultant = _get_own_consultant_or_dashboard(request, db, user) + if not consultant: + return RedirectResponse(url="/consultant/dashboard", status_code=303) + docs = get_consultant_document_centre(db, consultant=consultant, q=q) + return _render( + request, + "modules/consultants/templates/consultants/documents.html", + db, + user, + title="Consultant Documents", + consultant=consultant, + docs=docs, + q=q, + ) + finally: + db.close() diff --git a/app/modules/core/__init__.py b/app/modules/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/core/audit/models.py b/app/modules/core/audit/models.py new file mode 100644 index 0000000..390b5b4 --- /dev/null +++ b/app/modules/core/audit/models.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.db.common import CommonBase + + +class AuditLog(CommonBase): + __tablename__ = "audit_logs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + created_at_utc: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) + + actor_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True, nullable=True) + actor_email: Mapped[str | None] = mapped_column(String(255), nullable=True) + actor_tenant_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) + actor_branch_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) + + action: Mapped[str] = mapped_column(String(120), index=True) + entity_type: Mapped[str] = mapped_column(String(120), index=True) + entity_id: Mapped[str | None] = mapped_column(String(120), nullable=True) + entity_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + status: Mapped[str] = mapped_column(String(30), default="success", index=True) + + target_tenant_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) + target_branch_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) + + ip_address: Mapped[str | None] = mapped_column(String(100), nullable=True) + user_agent: Mapped[str | None] = mapped_column(String(500), nullable=True) + details_json: Mapped[str] = mapped_column(Text, default="{}") diff --git a/app/modules/core/audit/service.py b/app/modules/core/audit/service.py new file mode 100644 index 0000000..94d7672 --- /dev/null +++ b/app/modules/core/audit/service.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import json +from datetime import datetime +from typing import Any + +from fastapi import Request +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.modules.core.audit.models import AuditLog +from app.modules.core.iam.models import User +from app.modules.core.iam.scope import UserScope + + +def _safe_json(value: Any) -> str: + return json.dumps(value or {}, ensure_ascii=False, default=_json_default, sort_keys=True) + + +def _json_default(value: Any): + if isinstance(value, datetime): + return value.isoformat() + if hasattr(value, "isoformat"): + try: + return value.isoformat() + except Exception: + pass + return str(value) + + +def _request_meta(request: Request | None) -> tuple[str | None, str | None]: + if not request: + return None, None + ip = request.client.host if request.client else None + user_agent = request.headers.get("user-agent") + return ip, user_agent + + +def write_audit_log( + db: Session, + *, + action: str, + entity_type: str, + actor: User | None = None, + request: Request | None = None, + entity_id: str | int | None = None, + entity_name: str | None = None, + status: str = "success", + target_tenant_id: int | None = None, + target_branch_id: int | None = None, + details: dict[str, Any] | None = None, + actor_email: str | None = None, +) -> AuditLog: + ip_address, user_agent = _request_meta(request) + log = AuditLog( + actor_user_id=actor.id if actor else None, + actor_email=(actor.email if actor else actor_email), + actor_tenant_id=(actor.tenant_id if actor else None), + actor_branch_id=(actor.branch_id if actor else None), + action=action, + entity_type=entity_type, + entity_id=str(entity_id) if entity_id is not None else None, + entity_name=entity_name, + status=status, + target_tenant_id=target_tenant_id, + target_branch_id=target_branch_id, + ip_address=ip_address, + user_agent=user_agent, + details_json=_safe_json(details), + ) + db.add(log) + db.commit() + db.refresh(log) + return log + + +def model_snapshot(obj: Any, fields: list[str]) -> dict[str, Any]: + return {field: getattr(obj, field, None) for field in fields} + + +def pair_before_after(before: dict[str, Any], after: dict[str, Any]) -> dict[str, Any]: + return {"before": before, "after": after} + + +def list_audit_logs(db: Session, scope: UserScope, limit: int = 200) -> list[AuditLog]: + q = select(AuditLog) + if not scope.is_system_admin: + q = q.where(AuditLog.target_tenant_id == scope.actor.tenant_id) + if scope.branch_scoped: + q = q.where(AuditLog.target_branch_id == scope.actor.branch_id) + return db.execute(q.order_by(AuditLog.created_at_utc.desc(), AuditLog.id.desc()).limit(limit)).scalars().all() + + +def parse_details(log: AuditLog) -> dict[str, Any]: + try: + return json.loads(log.details_json or "{}") + except Exception: + return {"raw": log.details_json} + + + +def search_audit_logs(db: Session, scope: UserScope, q: str | None = None) -> list[AuditLog]: + rows = list_audit_logs(db, scope, limit=1000) + query = (q or "").strip().lower() + if not query: + return rows + result = [] + for row in rows: + hay = " ".join([str(row.action or ""), str(row.entity_type or ""), str(row.entity_name or ""), str(row.actor_email or ""), str(row.details_json or "")]).lower() + if query in hay: + result.append(row) + return result diff --git a/app/modules/core/audit/templates/logs.html b/app/modules/core/audit/templates/logs.html new file mode 100644 index 0000000..a322c3f --- /dev/null +++ b/app/modules/core/audit/templates/logs.html @@ -0,0 +1,14 @@ +{% extends "ui/templates/base/layout.html" %} +{% import "ui/templates/components/macros.html" as ui %} +{% block content %} +
+ {{ ui.page_shell('Audit Logs', 'Latest security and admin changes captured from IAM, RBAC, login, audit firm and branch operations.') }} +
+ {{ ui.search_bar('/system-settings/audit-logs', filters.q, filters.per_page) }} + {% if logs %} +
{% for row in logs %}{% endfor %}
WhenActionEntityActorTarget ScopeDetailsStatus
{{ row.created_at_utc }}
{{ row.action }}
IP {{ row.ip_address or '-' }}
{{ row.entity_type }}
{{ row.entity_name or row.entity_id or '-' }}
{{ row.actor_email or 'System' }}
Audit Firm {{ row.actor_tenant_id or '-' }} • Branch {{ row.actor_branch_id or '-' }}
Audit Firm {{ row.target_tenant_id or '-' }} • Branch {{ row.target_branch_id or '-' }}
{{ row.pretty_details }}
{% if row.status == 'success' %}{{ ui.badge(row.status, 'emerald') }}{% elif row.status == 'denied' %}{{ ui.badge(row.status, 'amber') }}{% else %}{{ ui.badge(row.status, 'rose') }}{% endif %}
+ {{ ui.pagination(logs_page, '/system-settings/audit-logs', request.url.query) }} + {% else %}
{{ ui.empty_state('No audit entries found for the current filter.') }}
{% endif %} +
+
+{% endblock %} diff --git a/app/modules/core/audit/ui.py b/app/modules/core/audit/ui.py new file mode 100644 index 0000000..764f154 --- /dev/null +++ b/app/modules/core/audit/ui.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import json + +from fastapi import APIRouter, Request +from fastapi.responses import RedirectResponse + +from app.core.templating import templates +from app.core.db.common import CommonSessionLocal +from app.core.security.csrf import get_or_create_csrf_token +from app.core.security.session_auth import get_current_user +from app.modules.core.audit.service import parse_details, search_audit_logs +from app.modules.core.iam.scope import build_scope +from app.modules.core.iam.services import paginate_list +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="/system-settings/audit-logs", tags=["audit-ui"]) + + +def _is_system_admin(db, user) -> bool: + return "System Admin" in get_user_roles(db, user.id) + + +def _is_firm_admin(db, user) -> bool: + return "Firm Admin" in get_user_roles(db, user.id) + + +@router.get("") +def logs(request: Request, q: str = "", page: int = 1, per_page: int = 20): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + + try: + require_permission(db, user, "audit.view") + except Exception: + return RedirectResponse(url="/system-settings", status_code=303) + + if not _is_system_admin(db, user) and not _is_firm_admin(db, user): + return RedirectResponse(url="/system-settings", status_code=303) + + scope = build_scope(db, user) + rows = search_audit_logs(db, scope, q=q) + + # Enforce final matrix explicitly: + # - System Admin: all logs + # - Firm Admin: own tenant logs only + # - others: none + if _is_firm_admin(db, user) and not _is_system_admin(db, user): + rows = [r for r in rows if getattr(r, "target_tenant_id", None) == user.tenant_id] + + paged = paginate_list(rows, page=page, per_page=per_page) + decorated = [] + for row in paged.items: + pretty = json.dumps(parse_details(row), indent=2, ensure_ascii=False, default=str) + setattr(row, "pretty_details", pretty) + decorated.append(row) + + return templates.TemplateResponse( + "modules/core/audit/templates/logs.html", + { + "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), + "title": "Audit Logs", + "logs": decorated, + "logs_page": paged, + "filters": {"q": (q or "").strip(), "per_page": paged.per_page}, + }, + ) + finally: + db.close() \ No newline at end of file diff --git a/app/modules/core/iam/__init__.py b/app/modules/core/iam/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/core/iam/api.py b/app/modules/core/iam/api.py new file mode 100644 index 0000000..9241ac0 --- /dev/null +++ b/app/modules/core/iam/api.py @@ -0,0 +1,363 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, EmailStr +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.db.deps import get_common_db +from app.core.security.passwords import hash_password +from app.core.security.session_auth import require_login +from app.modules.core.audit.service import model_snapshot, pair_before_after, write_audit_log +from app.modules.core.iam.lifecycle import activate_user, deactivate_user, disable_login, enable_login, ensure_manageable_lifecycle, lock_user, restore_user, soft_delete_user, unlock_user, LifecycleError +from app.modules.core.iam.models import User +from app.modules.core.iam.scope import ( + build_scope, + ensure_assignable_roles, + ensure_manageable_existing_user, + ensure_users_manage_scope, + ensure_users_view_scope, + list_scoped_users, + resolve_target_tenant_branch, + scope_to_http, +) +from app.modules.core.rbac.deps import require_permission +from app.modules.core.rbac.models import UserRole + +router = APIRouter(prefix="/users", tags=["users"]) + + +class UserCreateRequest(BaseModel): + email: EmailStr + full_name: str + password: str + tenant_id: int | None = None + branch_id: int | None = None + role_ids: list[int] = [] + is_active: bool = True + allow_login: bool = True + + +class UserUpdateRequest(BaseModel): + full_name: str + tenant_id: int | None = None + branch_id: int | None = None + role_ids: list[int] = [] + is_active: bool = True + allow_login: bool = True + password: str | None = None + + +@router.get("", dependencies=[Depends(require_permission("users.view"))]) +def list_users(current_user: User = Depends(require_login), db: Session = Depends(get_common_db)): + scope = build_scope(db, current_user) + try: + ensure_users_view_scope(scope) + except Exception as exc: + raise scope_to_http(exc) + + users = list_scoped_users(db, scope) + return [ + { + "id": u.id, + "email": u.email, + "full_name": u.full_name, + "tenant_id": u.tenant_id, + "branch_id": u.branch_id, + "is_active": u.is_active, + "allow_login": getattr(u, "allow_login", True), + "is_locked": getattr(u, "is_locked", False), + "deleted_at": (u.deleted_at.isoformat() if getattr(u, "deleted_at", None) else None), + } + for u in users + ] + + +@router.post("", dependencies=[Depends(require_permission("users.manage"))]) +def create_user( + payload: UserCreateRequest, + current_user: User = Depends(require_login), + db: Session = Depends(get_common_db), +): + scope = build_scope(db, current_user) + try: + ensure_users_manage_scope(scope) + tenant_id, branch_id = resolve_target_tenant_branch(db, scope, payload.tenant_id, payload.branch_id) + roles = ensure_assignable_roles(db, scope, payload.role_ids) + except Exception as exc: + raise scope_to_http(exc) + + email = payload.email.lower().strip() + exists = db.execute(select(User).where(User.email == email)).scalar_one_or_none() + if exists: + raise HTTPException(status_code=400, detail="Email already exists") + + user = User( + email=email, + full_name=payload.full_name.strip(), + password_hash=hash_password(payload.password), + tenant_id=tenant_id, + branch_id=branch_id, + is_active=payload.is_active, + allow_login=payload.allow_login, + is_locked=False, + deleted_at=None, + ) + db.add(user) + db.commit() + db.refresh(user) + + for role in roles: + db.add(UserRole(user_id=user.id, role_id=role.id)) + db.commit() + write_audit_log( + db, + action="user.create.api", + entity_type="user", + actor=current_user, + entity_id=user.id, + entity_name=user.email, + target_tenant_id=user.tenant_id, + target_branch_id=user.branch_id, + details={"after": model_snapshot(user, ["email", "full_name", "tenant_id", "branch_id", "is_active", "allow_login", "is_locked", "deleted_at"]), "role_ids": [role.id for role in roles]}, + ) + return {"status": "ok", "id": user.id} + + +@router.get("/{user_id}", dependencies=[Depends(require_permission("users.view"))]) +def get_user(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)): + scope = build_scope(db, current_user) + try: + ensure_users_view_scope(scope) + except Exception as exc: + raise scope_to_http(exc) + + user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + try: + ensure_manageable_existing_user(db, scope, user) + except Exception as exc: + raise scope_to_http(exc) + + role_ids = db.execute(select(UserRole.role_id).where(UserRole.user_id == user.id)).scalars().all() + return { + "id": user.id, + "email": user.email, + "full_name": user.full_name, + "tenant_id": user.tenant_id, + "branch_id": user.branch_id, + "is_active": user.is_active, + "allow_login": getattr(user, "allow_login", True), + "is_locked": getattr(user, "is_locked", False), + "deleted_at": (user.deleted_at.isoformat() if getattr(user, "deleted_at", None) else None), + "role_ids": list(role_ids), + } + + +@router.put("/{user_id}", dependencies=[Depends(require_permission("users.manage"))]) +def update_user( + user_id: int, + payload: UserUpdateRequest, + current_user: User = Depends(require_login), + db: Session = Depends(get_common_db), +): + scope = build_scope(db, current_user) + user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + try: + ensure_users_manage_scope(scope) + ensure_manageable_existing_user(db, scope, user) + tenant_id, branch_id = resolve_target_tenant_branch(db, scope, payload.tenant_id, payload.branch_id) + roles = ensure_assignable_roles(db, scope, payload.role_ids) + except Exception as exc: + raise scope_to_http(exc) + + before_snapshot = model_snapshot(user, ["email", "full_name", "tenant_id", "branch_id", "is_active", "allow_login", "is_locked", "deleted_at"]) + old_role_ids = list(db.execute(select(UserRole.role_id).where(UserRole.user_id == user.id)).scalars().all()) + + user.full_name = payload.full_name.strip() + user.tenant_id = tenant_id + user.branch_id = branch_id + user.is_active = payload.is_active + user.allow_login = payload.allow_login + if payload.password: + user.password_hash = hash_password(payload.password) + + db.execute(UserRole.__table__.delete().where(UserRole.user_id == user.id)) + for role in roles: + db.add(UserRole(user_id=user.id, role_id=role.id)) + db.commit() + write_audit_log( + db, + action="user.update.api", + entity_type="user", + actor=current_user, + entity_id=user.id, + entity_name=user.email, + target_tenant_id=user.tenant_id, + target_branch_id=user.branch_id, + details={**pair_before_after(before_snapshot, model_snapshot(user, ["email", "full_name", "tenant_id", "branch_id", "is_active", "allow_login", "is_locked", "deleted_at"])), "old_role_ids": old_role_ids, "new_role_ids": [role.id for role in roles]}, + ) + return {"status": "ok"} + + + +def _lifecycle_response(user: User) -> dict: + return { + "status": "ok", + "user_id": user.id, + "is_active": user.is_active, + "allow_login": getattr(user, "allow_login", True), + "is_locked": getattr(user, "is_locked", False), + "deleted_at": (user.deleted_at.isoformat() if getattr(user, "deleted_at", None) else None), + } + + +def _apply_lifecycle_action(db: Session, current_user: User, user: User, action: str): + before = model_snapshot(user, ["email", "full_name", "tenant_id", "branch_id", "is_active", "allow_login", "is_locked", "deleted_at"]) + if action == "activate": + activate_user(user) + audit_action = "user.activate.api" + elif action == "deactivate": + deactivate_user(user) + audit_action = "user.deactivate.api" + elif action == "enable-login": + enable_login(user) + audit_action = "user.enable_login.api" + elif action == "disable-login": + disable_login(user) + audit_action = "user.disable_login.api" + elif action == "lock": + lock_user(user) + audit_action = "user.lock.api" + elif action == "unlock": + unlock_user(user) + audit_action = "user.unlock.api" + elif action == "delete": + soft_delete_user(user) + audit_action = "user.soft_delete.api" + elif action == "restore": + restore_user(user) + audit_action = "user.restore.api" + else: + raise HTTPException(status_code=400, detail="Unknown action") + db.commit() + write_audit_log( + db, + action=audit_action, + entity_type="user", + actor=current_user, + entity_id=user.id, + entity_name=user.email, + target_tenant_id=user.tenant_id, + target_branch_id=user.branch_id, + details=pair_before_after(before, model_snapshot(user, ["email", "full_name", "tenant_id", "branch_id", "is_active", "allow_login", "is_locked", "deleted_at"])), + ) + return _lifecycle_response(user) + + +@router.post("/{user_id}/activate", dependencies=[Depends(require_permission("users.manage"))]) +def activate_user_api(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)): + scope = build_scope(db, current_user) + user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + try: + ensure_manageable_lifecycle(scope, db, current_user, user, "activate") + except (Exception, LifecycleError) as exc: + raise scope_to_http(exc) + return _apply_lifecycle_action(db, current_user, user, "activate") + + +@router.post("/{user_id}/deactivate", dependencies=[Depends(require_permission("users.manage"))]) +def deactivate_user_api(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)): + scope = build_scope(db, current_user) + user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + try: + ensure_manageable_lifecycle(scope, db, current_user, user, "deactivate") + except (Exception, LifecycleError) as exc: + raise scope_to_http(exc) + return _apply_lifecycle_action(db, current_user, user, "deactivate") + + +@router.post("/{user_id}/enable-login", dependencies=[Depends(require_permission("users.manage"))]) +def enable_login_api(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)): + scope = build_scope(db, current_user) + user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + try: + ensure_manageable_lifecycle(scope, db, current_user, user, "enable login for") + except (Exception, LifecycleError) as exc: + raise scope_to_http(exc) + return _apply_lifecycle_action(db, current_user, user, "enable-login") + + +@router.post("/{user_id}/disable-login", dependencies=[Depends(require_permission("users.manage"))]) +def disable_login_api(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)): + scope = build_scope(db, current_user) + user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + try: + ensure_manageable_lifecycle(scope, db, current_user, user, "disable login for") + except (Exception, LifecycleError) as exc: + raise scope_to_http(exc) + return _apply_lifecycle_action(db, current_user, user, "disable-login") + + +@router.post("/{user_id}/lock", dependencies=[Depends(require_permission("users.manage"))]) +def lock_user_api(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)): + scope = build_scope(db, current_user) + user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + try: + ensure_manageable_lifecycle(scope, db, current_user, user, "lock") + except (Exception, LifecycleError) as exc: + raise scope_to_http(exc) + return _apply_lifecycle_action(db, current_user, user, "lock") + + +@router.post("/{user_id}/unlock", dependencies=[Depends(require_permission("users.manage"))]) +def unlock_user_api(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)): + scope = build_scope(db, current_user) + user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + try: + ensure_manageable_lifecycle(scope, db, current_user, user, "unlock") + except (Exception, LifecycleError) as exc: + raise scope_to_http(exc) + return _apply_lifecycle_action(db, current_user, user, "unlock") + + +@router.post("/{user_id}/delete", dependencies=[Depends(require_permission("users.manage"))]) +def delete_user_api(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)): + scope = build_scope(db, current_user) + user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + try: + ensure_manageable_lifecycle(scope, db, current_user, user, "delete") + except (Exception, LifecycleError) as exc: + raise scope_to_http(exc) + return _apply_lifecycle_action(db, current_user, user, "delete") + + +@router.post("/{user_id}/restore", dependencies=[Depends(require_permission("users.manage"))]) +def restore_user_api(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)): + scope = build_scope(db, current_user) + user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + try: + ensure_manageable_lifecycle(scope, db, current_user, user, "restore") + except (Exception, LifecycleError) as exc: + raise scope_to_http(exc) + return _apply_lifecycle_action(db, current_user, user, "restore") diff --git a/app/modules/core/iam/auth_api.py b/app/modules/core/iam/auth_api.py new file mode 100644 index 0000000..6231eca --- /dev/null +++ b/app/modules/core/iam/auth_api.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +from datetime import timedelta, timezone +import hashlib +import secrets + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, EmailStr +from sqlalchemy.orm import Session +from sqlalchemy import select + +from app.core.db.deps import get_common_db +from app.core.settings import get_settings +from app.core.security.passwords import verify_password +from app.core.security.jwt_tokens import encode_access_token, decode_token, utcnow +from app.modules.core.audit.service import write_audit_log +from app.modules.core.iam.invite_service import accept_invite, issue_password_reset_token, reset_password_with_token +from app.modules.email_integration.services import send_password_reset_link_email, send_password_changed_email +from app.modules.core.iam.models import User +from app.modules.core.iam.tokens_models import RefreshToken +from app.modules.core.rbac.models import Role, UserRole + +router = APIRouter(prefix="/auth", tags=["auth"]) + +class TokenRequest(BaseModel): + email: EmailStr + password: str + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + expires_in: int + refresh_token: str + +class ForgotPasswordRequest(BaseModel): + email: EmailStr + +class ResetPasswordRequest(BaseModel): + token: str + new_password: str + +class AcceptInviteRequest(BaseModel): + token: str + password: str + +def _hash_refresh(rt: str) -> str: + return hashlib.sha256(rt.encode("utf-8")).hexdigest() + +def _roles(db: Session, user_id: int) -> list[str]: + q = select(Role.name).join(UserRole, UserRole.role_id == Role.id).where(UserRole.user_id == user_id) + return [r for (r,) in db.execute(q).all()] + +def _issue_tokens(db: Session, user: User) -> TokenResponse: + s = get_settings() + roles = _roles(db, user.id) + payload = { + "sub": str(user.id), + "email": user.email, + "tenant_id": user.tenant_id, + "branch_id": user.branch_id, + "roles": roles, + } + access = encode_access_token(payload, expires_minutes=s.JWT_ACCESS_MINUTES) + + refresh_plain = secrets.token_urlsafe(48) + now = utcnow() + exp = now + timedelta(days=s.JWT_REFRESH_DAYS) + + rt = RefreshToken( + user_id=user.id, + token_hash=_hash_refresh(refresh_plain), + created_at_utc=now, + expires_at_utc=exp, + revoked=False, + rotated_from_id=None, + ) + db.add(rt) + db.commit() + + return TokenResponse(access_token=access, expires_in=s.JWT_ACCESS_MINUTES * 60, refresh_token=refresh_plain) + +@router.post("/token", response_model=TokenResponse) +def token(req: TokenRequest, db: Session = Depends(get_common_db)): + user = db.execute(select(User).where(User.email == req.email.lower().strip())).scalar_one_or_none() + if (not user or not user.is_active or not getattr(user, "allow_login", True) or getattr(user, "is_locked", False) or getattr(user, "deleted_at", None) is not None or not verify_password(req.password, user.password_hash)): + write_audit_log(db, action="auth.token.failed", entity_type="api_session", actor=user, actor_email=req.email.lower().strip(), status="error", target_tenant_id=(user.tenant_id if user else None), target_branch_id=(user.branch_id if user else None), details={"reason": "invalid credentials"}) + raise HTTPException(status_code=401, detail="Invalid credentials") + if getattr(user, "must_change_password", False): + raise HTTPException(status_code=403, detail="Password setup/change required before API login") + token_response = _issue_tokens(db, user) + write_audit_log(db, action="auth.token.success", entity_type="api_session", actor=user, entity_name=user.email, target_tenant_id=user.tenant_id, target_branch_id=user.branch_id) + return token_response + +@router.post('/forgot-password') +def forgot_password(req: ForgotPasswordRequest, db: Session = Depends(get_common_db)): + user = db.execute(select(User).where(User.email == req.email.lower().strip())).scalar_one_or_none() + if user and user.is_active and getattr(user, "deleted_at", None) is None: + reset_token = issue_password_reset_token(db, user) + try: + send_password_reset_link_email(db, user=user, reset_token=reset_token) + except Exception as exc: + write_audit_log(db, action="auth.password_reset.email_failed", entity_type="user", actor=user, entity_id=user.id, entity_name=user.email, status="error", target_tenant_id=user.tenant_id, target_branch_id=user.branch_id, details={"error": str(exc)}) + write_audit_log(db, action="auth.password_reset.requested", entity_type="user", actor=user, entity_id=user.id, entity_name=user.email, target_tenant_id=user.tenant_id, target_branch_id=user.branch_id) + db.commit() + return {"status": "ok"} + +@router.post('/reset-password') +def reset_password(req: ResetPasswordRequest, db: Session = Depends(get_common_db)): + try: + user = reset_password_with_token(db, req.token, req.new_password) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + if not user: + raise HTTPException(status_code=400, detail="Invalid or expired reset token") + try: + send_password_changed_email(db, user=user) + except Exception as exc: + write_audit_log(db, action="auth.password_changed.email_failed", entity_type="user", actor=user, entity_id=user.id, entity_name=user.email, status="error", target_tenant_id=user.tenant_id, target_branch_id=user.branch_id, details={"error": str(exc)}) + write_audit_log(db, action="auth.password_reset.completed", entity_type="user", actor=user, entity_id=user.id, entity_name=user.email, target_tenant_id=user.tenant_id, target_branch_id=user.branch_id) + db.commit() + return {"status": "ok"} + +@router.post('/invite/accept') +def invite_accept(req: AcceptInviteRequest, db: Session = Depends(get_common_db)): + try: + user = accept_invite(db, req.token, req.password) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + if not user: + raise HTTPException(status_code=400, detail="Invalid or expired invite token") + write_audit_log(db, action="auth.invite.accepted", entity_type="user", actor=user, entity_id=user.id, entity_name=user.email, target_tenant_id=user.tenant_id, target_branch_id=user.branch_id) + return {"status": "ok"} + +class RefreshRequest(BaseModel): + refresh_token: str + +@router.post("/refresh", response_model=TokenResponse) +def refresh(req: RefreshRequest, db: Session = Depends(get_common_db)): + h = _hash_refresh(req.refresh_token) + rt = db.execute(select(RefreshToken).where(RefreshToken.token_hash == h)).scalar_one_or_none() + if not rt or rt.revoked: + raise HTTPException(status_code=401, detail="Invalid refresh token") + now = utcnow() + if rt.expires_at_utc.replace(tzinfo=timezone.utc) < now: + raise HTTPException(status_code=401, detail="Refresh token expired") + + user = db.execute(select(User).where(User.id == rt.user_id)).scalar_one_or_none() + if not user or not user.is_active or not getattr(user, "allow_login", True) or getattr(user, "is_locked", False) or getattr(user, "deleted_at", None) is not None: + raise HTTPException(status_code=401, detail="User inactive") + + rt.revoked = True + db.commit() + token_response = _issue_tokens(db, user) + write_audit_log(db, action="auth.token.refresh", entity_type="api_session", actor=user, entity_name=user.email, target_tenant_id=user.tenant_id, target_branch_id=user.branch_id) + return token_response + +class LogoutRequest(BaseModel): + refresh_token: str + +@router.post("/logout") +def logout(req: LogoutRequest, db: Session = Depends(get_common_db)): + h = _hash_refresh(req.refresh_token) + rt = db.execute(select(RefreshToken).where(RefreshToken.token_hash == h)).scalar_one_or_none() + if rt: + rt.revoked = True + db.commit() + user = db.execute(select(User).where(User.id == rt.user_id)).scalar_one_or_none() + write_audit_log(db, action="auth.token.logout", entity_type="api_session", actor=user, entity_name=(user.email if user else None), target_tenant_id=(user.tenant_id if user else None), target_branch_id=(user.branch_id if user else None)) + return {"status": "ok"} + +@router.get("/me") +def me(token: str): + return {"token": decode_token(token)} diff --git a/app/modules/core/iam/invite_service.py b/app/modules/core/iam/invite_service.py new file mode 100644 index 0000000..018fd3b --- /dev/null +++ b/app/modules/core/iam/invite_service.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from datetime import timedelta, timezone +import hashlib +import re +import secrets +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.security.jwt_tokens import utcnow +from app.core.security.passwords import hash_password +from app.core.settings import get_settings +from app.modules.core.iam.models import User +from app.modules.core.iam.password_flows_models import InviteToken, PasswordResetToken + + +def _hash_token(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def validate_password_policy(password: str) -> str | None: + s = get_settings() + if len(password or "") < s.PASSWORD_MIN_LENGTH: + return f"Password must be at least {s.PASSWORD_MIN_LENGTH} characters long." + if not re.search(r"[A-Za-z]", password or ""): + return "Password must include at least one letter." + if not re.search(r"\d", password or ""): + return "Password must include at least one number." + return None + + +def issue_invite_token(db: Session, user: User) -> str: + plain = secrets.token_urlsafe(32) + now = utcnow() + token = InviteToken( + user_id=user.id, + token_hash=_hash_token(plain), + created_at_utc=now, + expires_at_utc=now + timedelta(hours=get_settings().INVITE_TOKEN_HOURS), + used_at_utc=None, + ) + db.add(token) + user.must_change_password = True + db.commit() + return plain + + +def issue_password_reset_token(db: Session, user: User) -> str: + plain = secrets.token_urlsafe(32) + now = utcnow() + token = PasswordResetToken( + user_id=user.id, + token_hash=_hash_token(plain), + created_at_utc=now, + expires_at_utc=now + timedelta(hours=get_settings().PASSWORD_RESET_HOURS), + used_at_utc=None, + ) + db.add(token) + db.commit() + return plain + + +def _validate_unused(record) -> bool: + if not record or record.used_at_utc is not None: + return False + now = utcnow() + exp = record.expires_at_utc + if getattr(exp, "tzinfo", None) is None: + exp = exp.replace(tzinfo=timezone.utc) + return exp >= now + + +def accept_invite(db: Session, token: str, password: str) -> User | None: + err = validate_password_policy(password) + if err: + raise ValueError(err) + record = db.execute(select(InviteToken).where(InviteToken.token_hash == _hash_token(token))).scalar_one_or_none() + if not _validate_unused(record): + return None + user = db.execute(select(User).where(User.id == record.user_id)).scalar_one_or_none() + if not user: + return None + user.password_hash = hash_password(password) + user.must_change_password = False + user.password_changed_at_utc = utcnow().replace(tzinfo=None) + user.allow_login = True + user.is_active = True + record.used_at_utc = utcnow().replace(tzinfo=None) + db.commit() + return user + + +def reset_password_with_token(db: Session, token: str, password: str) -> User | None: + err = validate_password_policy(password) + if err: + raise ValueError(err) + record = db.execute(select(PasswordResetToken).where(PasswordResetToken.token_hash == _hash_token(token))).scalar_one_or_none() + if not _validate_unused(record): + return None + user = db.execute(select(User).where(User.id == record.user_id)).scalar_one_or_none() + if not user: + return None + user.password_hash = hash_password(password) + user.must_change_password = False + user.password_changed_at_utc = utcnow().replace(tzinfo=None) + record.used_at_utc = utcnow().replace(tzinfo=None) + db.commit() + return user + + +def force_change_password(db: Session, user: User, new_password: str) -> None: + err = validate_password_policy(new_password) + if err: + raise ValueError(err) + user.password_hash = hash_password(new_password) + user.must_change_password = False + user.password_changed_at_utc = utcnow().replace(tzinfo=None) + db.commit() diff --git a/app/modules/core/iam/lifecycle.py b/app/modules/core/iam/lifecycle.py new file mode 100644 index 0000000..1408194 --- /dev/null +++ b/app/modules/core/iam/lifecycle.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from sqlalchemy.orm import Session + +from app.modules.core.iam.models import User +from app.modules.core.iam.scope import UserScope, ensure_manageable_existing_user + + +class LifecycleError(Exception): + pass + + +def utcnow_naive() -> datetime: + return datetime.now(timezone.utc).replace(tzinfo=None) + + +def ensure_not_self(actor: User, target: User, action_label: str) -> None: + if actor.id == target.id: + raise LifecycleError(f"You cannot {action_label} your own account.") + + +def ensure_not_bootstrap_admin(target: User) -> None: + if (target.email or '').strip().lower() == 'admin@auditfirm.local': + raise LifecycleError('Bootstrap system admin cannot be modified by this action.') + + +def ensure_manageable_lifecycle(scope: UserScope, db: Session, actor: User, target: User, action_label: str) -> None: + ensure_manageable_existing_user(db, scope, target) + ensure_not_self(actor, target, action_label) + + +def activate_user(user: User) -> None: + user.is_active = True + if user.deleted_at is not None: + user.deleted_at = None + + +def deactivate_user(user: User) -> None: + user.is_active = False + + +def enable_login(user: User) -> None: + user.allow_login = True + + +def disable_login(user: User) -> None: + user.allow_login = False + + +def lock_user(user: User) -> None: + user.is_locked = True + user.locked_at_utc = utcnow_naive() + + +def unlock_user(user: User) -> None: + user.is_locked = False + user.locked_at_utc = None + + +def soft_delete_user(user: User) -> None: + user.deleted_at = utcnow_naive() + user.is_active = False + user.allow_login = False + user.is_locked = True + if user.locked_at_utc is None: + user.locked_at_utc = utcnow_naive() + + +def restore_user(user: User) -> None: + user.deleted_at = None + user.is_active = True + user.allow_login = True + user.is_locked = False + user.locked_at_utc = None diff --git a/app/modules/core/iam/models.py b/app/modules/core/iam/models.py new file mode 100644 index 0000000..a8f4d3c --- /dev/null +++ b/app/modules/core/iam/models.py @@ -0,0 +1,45 @@ +from __future__ import annotations +from datetime import datetime +from sqlalchemy import String, Boolean, Integer, ForeignKey, UniqueConstraint, DateTime, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.core.db.common import CommonBase + +class User(CommonBase): + __tablename__ = "users" + __table_args__ = (UniqueConstraint("email", name="uq_user_email"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + email: Mapped[str] = mapped_column(String(255), index=True) + full_name: Mapped[str] = mapped_column(String(255), default="") + password_hash: Mapped[str] = mapped_column(String(255)) + + tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id"), index=True) + branch_id: Mapped[int] = mapped_column(ForeignKey("branches.id"), index=True) + + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + allow_login: Mapped[bool] = mapped_column(Boolean, default=True) + is_locked: Mapped[bool] = mapped_column(Boolean, default=False) + locked_at_utc: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + must_change_password: Mapped[bool] = mapped_column(Boolean, default=False) + password_changed_at_utc: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + # Phase 7Q.3 - common user profile/personalisation fields. + # Employee/consultant/client master records remain the source for official data; + # these fields are used for display, dashboards, client-facing contact cards and branding. + profile_photo_path: Mapped[str | None] = mapped_column(String(500), nullable=True) + qualification: Mapped[str | None] = mapped_column(String(200), nullable=True) + designation: Mapped[str | None] = mapped_column(String(200), nullable=True) + mobile: Mapped[str | None] = mapped_column(String(30), nullable=True) + bio: Mapped[str | None] = mapped_column(Text, nullable=True) + signature_image_path: Mapped[str | None] = mapped_column(String(500), nullable=True) + +class LoginAttempt(CommonBase): + __tablename__ = "login_attempts" + __table_args__ = (UniqueConstraint("key", name="uq_login_attempt_key"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + key: Mapped[str] = mapped_column(String(255), index=True) # email|ip + attempts: Mapped[int] = mapped_column(Integer, default=0) + locked_until_utc: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + updated_at_utc: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) diff --git a/app/modules/core/iam/password_flows_models.py b/app/modules/core/iam/password_flows_models.py new file mode 100644 index 0000000..d2032bd --- /dev/null +++ b/app/modules/core/iam/password_flows_models.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from datetime import datetime +from sqlalchemy import Integer, String, ForeignKey, DateTime, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from app.core.db.common import CommonBase + +class InviteToken(CommonBase): + __tablename__ = "invite_tokens" + __table_args__ = (UniqueConstraint("token_hash", name="uq_invite_token_hash"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) + token_hash: Mapped[str] = mapped_column(String(64), index=True) + created_at_utc: Mapped[datetime] = mapped_column(DateTime, nullable=False) + expires_at_utc: Mapped[datetime] = mapped_column(DateTime, nullable=False) + used_at_utc: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + +class PasswordResetToken(CommonBase): + __tablename__ = "password_reset_tokens" + __table_args__ = (UniqueConstraint("token_hash", name="uq_password_reset_token_hash"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) + token_hash: Mapped[str] = mapped_column(String(64), index=True) + created_at_utc: Mapped[datetime] = mapped_column(DateTime, nullable=False) + expires_at_utc: Mapped[datetime] = mapped_column(DateTime, nullable=False) + used_at_utc: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) diff --git a/app/modules/core/iam/profile_service.py b/app/modules/core/iam/profile_service.py new file mode 100644 index 0000000..f9810b4 --- /dev/null +++ b/app/modules/core/iam/profile_service.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import imghdr +import re +from pathlib import Path +from uuid import uuid4 + +from fastapi import HTTPException, UploadFile +from sqlalchemy.orm import Session + +from app.modules.core.iam.models import User + +STATIC_ROOT = Path("app/ui/static") +PROFILE_UPLOAD_DIR = STATIC_ROOT / "uploads" / "user_profiles" +MAX_IMAGE_BYTES = 2 * 1024 * 1024 +ALLOWED_IMAGE_TYPES = {"jpeg": ".jpg", "png": ".png", "gif": ".gif", "webp": ".webp"} + + +def _blank_to_none(value: object) -> str | None: + value = (str(value).strip() if value is not None else "") + return value or None + + +def _safe_static_path(path: str | None) -> str | None: + path = (path or "").strip() + if not path: + return None + if path.startswith("/static/"): + return path + if path.startswith("app/ui/static/"): + return "/static/" + path.split("app/ui/static/", 1)[1].replace("\\", "/") + return path + + +def profile_photo_url(user: User | None) -> str | None: + if not user: + return None + return _safe_static_path(getattr(user, "profile_photo_path", None)) + + +def user_initials(user: User | None) -> str: + if not user: + return "U" + name = (getattr(user, "full_name", None) or getattr(user, "email", "") or "User").strip() + if "@" in name and not getattr(user, "full_name", None): + name = name.split("@", 1)[0] + parts = [p for p in re.split(r"\s+", name) if p] + if not parts: + return "U" + if len(parts) == 1: + return parts[0][:2].upper() + return (parts[0][0] + parts[-1][0]).upper() + + +async def save_user_profile_photo(user: User, upload: UploadFile | None) -> str | None: + """Persist a profile photo and return a static path, or current path if nothing uploaded.""" + if not upload or not getattr(upload, "filename", None): + return getattr(user, "profile_photo_path", None) + + raw = await upload.read() + if not raw: + return getattr(user, "profile_photo_path", None) + if len(raw) > MAX_IMAGE_BYTES: + raise HTTPException(status_code=400, detail="Profile photo must be 2 MB or smaller.") + + detected = imghdr.what(None, raw) + suffix = ALLOWED_IMAGE_TYPES.get(detected or "") + if not suffix: + raise HTTPException(status_code=400, detail="Upload a valid JPG, PNG, GIF or WebP profile photo.") + + PROFILE_UPLOAD_DIR.mkdir(parents=True, exist_ok=True) + filename = f"user_{user.id}_{uuid4().hex}{suffix}" + target = PROFILE_UPLOAD_DIR / filename + target.write_bytes(raw) + return f"app/ui/static/uploads/user_profiles/{filename}" + + +def update_user_public_profile( + db: Session, + user: User, + *, + qualification: object = None, + designation: object = None, + mobile: object = None, + bio: object = None, + profile_photo_path: str | None = None, +) -> User: + user.qualification = _blank_to_none(qualification) + user.designation = _blank_to_none(designation) + user.mobile = _blank_to_none(mobile) + user.bio = _blank_to_none(bio) + if profile_photo_path is not None: + user.profile_photo_path = profile_photo_path + db.add(user) + return user diff --git a/app/modules/core/iam/scope.py b/app/modules/core/iam/scope.py new file mode 100644 index 0000000..07428a2 --- /dev/null +++ b/app/modules/core/iam/scope.py @@ -0,0 +1,228 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from fastapi import HTTPException +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.modules.core.iam.models import User +from app.modules.core.rbac.models import Role, UserRole +from app.modules.core.tenancy.models import Branch, Tenant + +ROLE_SYSTEM_ADMIN = "System Admin" +ROLE_FIRM_ADMIN = "Firm Admin" +ROLE_PARTNER = "Partner" +ROLE_BRANCH_MANAGER = "Branch Manager" +ROLE_STAFF = "Staff" +ROLE_CLIENT = "Client" +ROLE_CONSULTANT = "Consultant" + +# Matrix: only System Admin and Firm Admin manage users. +MANAGEABLE_ROLES_BY_ACTOR = { + ROLE_SYSTEM_ADMIN: { + ROLE_SYSTEM_ADMIN, + ROLE_FIRM_ADMIN, + ROLE_PARTNER, + ROLE_BRANCH_MANAGER, + ROLE_STAFF, + ROLE_CLIENT, + ROLE_CONSULTANT, + }, + ROLE_FIRM_ADMIN: { + ROLE_PARTNER, + ROLE_BRANCH_MANAGER, + ROLE_STAFF, + ROLE_CLIENT, + ROLE_CONSULTANT, + }, +} + + +@dataclass +class UserScope: + actor: User + role_names: list[str] + is_system_admin: bool + is_firm_admin: bool + is_partner: bool + is_branch_manager: bool + + @property + def tenant_scoped(self) -> bool: + return self.is_firm_admin or self.is_partner or self.is_branch_manager + + @property + def branch_scoped(self) -> bool: + return self.is_branch_manager + + +class ScopeError(Exception): + pass + + +def get_role_names(db: Session, user_id: int) -> list[str]: + q = ( + select(Role.name) + .join(UserRole, UserRole.role_id == Role.id) + .where(UserRole.user_id == user_id, Role.is_active.is_(True)) + .order_by(Role.name) + ) + return [name for (name,) in db.execute(q).all()] + + +def build_scope(db: Session, actor: User) -> UserScope: + role_names = get_role_names(db, actor.id) + return UserScope( + actor=actor, + role_names=role_names, + is_system_admin=ROLE_SYSTEM_ADMIN in role_names, + is_firm_admin=ROLE_FIRM_ADMIN in role_names, + is_partner=ROLE_PARTNER in role_names, + is_branch_manager=ROLE_BRANCH_MANAGER in role_names, + ) + + +def ensure_users_view_scope(scope: UserScope) -> None: + if scope.is_system_admin or scope.is_firm_admin or scope.is_partner or scope.is_branch_manager: + return + raise ScopeError("You are not allowed to view users.") + + +def ensure_users_manage_scope(scope: UserScope) -> None: + if scope.is_system_admin or scope.is_firm_admin: + return + raise ScopeError("Only System Admin and Firm Admin can manage users.") + + +def list_visible_tenants(db: Session, scope: UserScope) -> list[Tenant]: + if scope.is_system_admin: + return db.execute( + select(Tenant).where(Tenant.is_active.is_(True)).order_by(Tenant.name) + ).scalars().all() + tenant = db.execute(select(Tenant).where(Tenant.id == scope.actor.tenant_id)).scalar_one_or_none() + return [tenant] if tenant else [] + + +def list_visible_branches(db: Session, scope: UserScope, tenant_id: int | None = None) -> list[Branch]: + effective_tenant_id = tenant_id or scope.actor.tenant_id + q = select(Branch).where(Branch.is_active.is_(True), Branch.tenant_id == effective_tenant_id) + if scope.branch_scoped: + q = q.where(Branch.id == scope.actor.branch_id) + return db.execute(q.order_by(Branch.name)).scalars().all() + + +def list_scoped_users(db: Session, scope: UserScope) -> list[User]: + q = select(User) + if not scope.is_system_admin: + q = q.where(User.tenant_id == scope.actor.tenant_id) + if scope.branch_scoped: + q = q.where(User.branch_id == scope.actor.branch_id) + return db.execute(q.order_by(User.id)).scalars().all() + + +def get_manageable_roles(db: Session, scope: UserScope) -> list[Role]: + if scope.is_system_admin: + return db.execute( + select(Role).where(Role.is_active.is_(True)).order_by(Role.name) + ).scalars().all() + + allowed_names: set[str] = set() + for role_name in scope.role_names: + allowed_names.update(MANAGEABLE_ROLES_BY_ACTOR.get(role_name, set())) + if not allowed_names: + return [] + return db.execute( + select(Role).where(Role.is_active.is_(True), Role.name.in_(sorted(allowed_names))).order_by(Role.name) + ).scalars().all() + + +def get_user_role_names(db: Session, user_id: int) -> list[str]: + return get_role_names(db, user_id) + + +def get_user_role_ids(db: Session, user_id: int) -> list[int]: + return db.execute(select(UserRole.role_id).where(UserRole.user_id == user_id)).scalars().all() + + +def can_manage_role_names(scope: UserScope, role_names: list[str]) -> bool: + if scope.is_system_admin: + return True + allowed: set[str] = set() + for actor_role in scope.role_names: + allowed.update(MANAGEABLE_ROLES_BY_ACTOR.get(actor_role, set())) + return set(role_names).issubset(allowed) + + +def validate_branch_matches_tenant(db: Session, tenant_id: int, branch_id: int) -> Branch: + branch = db.execute( + select(Branch).where( + Branch.id == branch_id, + Branch.tenant_id == tenant_id, + Branch.is_active.is_(True), + ) + ).scalar_one_or_none() + if not branch: + raise ScopeError("Selected branch does not belong to the selected tenant.") + return branch + + +def resolve_target_tenant_branch( + db: Session, + scope: UserScope, + tenant_id: int | None, + branch_id: int | None, +) -> tuple[int, int]: + if scope.is_system_admin: + if tenant_id is None or branch_id is None: + raise ScopeError("Tenant and branch are required.") + validate_branch_matches_tenant(db, tenant_id, branch_id) + return tenant_id, branch_id + + effective_tenant_id = scope.actor.tenant_id + effective_branch_id = branch_id + + if tenant_id is not None and tenant_id != scope.actor.tenant_id: + raise ScopeError("Cross-tenant user creation is not allowed.") + + if effective_branch_id is None: + raise ScopeError("Branch is required.") + + validate_branch_matches_tenant(db, effective_tenant_id, effective_branch_id) + return effective_tenant_id, effective_branch_id + + +def ensure_manageable_existing_user(db: Session, scope: UserScope, target_user: User) -> None: + if scope.is_system_admin: + return + if not scope.is_firm_admin: + raise ScopeError("Only System Admin and Firm Admin can manage users.") + if target_user.tenant_id != scope.actor.tenant_id: + raise ScopeError("You cannot manage users of another tenant.") + + target_roles = get_user_role_names(db, target_user.id) + if target_roles and not can_manage_role_names(scope, target_roles): + raise ScopeError("You cannot manage the selected user's role level.") + + +def ensure_assignable_roles(db: Session, scope: UserScope, role_ids: list[int]) -> list[Role]: + if not role_ids: + return [] + roles = db.execute( + select(Role).where(Role.id.in_(role_ids), Role.is_active.is_(True)).order_by(Role.name) + ).scalars().all() + if len(roles) != len(set(role_ids)): + raise ScopeError("One or more selected roles are invalid.") + if not can_manage_role_names(scope, [r.name for r in roles]): + raise ScopeError("You cannot assign one or more selected roles.") + return roles + + +def assert_can_manage_role_object(scope: UserScope, role: Role) -> None: + if scope.is_system_admin: + return + raise ScopeError("Only System Admin can manage RBAC roles.") + + +def scope_to_http(exc: ScopeError) -> HTTPException: + return HTTPException(status_code=403, detail=str(exc)) \ No newline at end of file diff --git a/app/modules/core/iam/scope_guard.py b/app/modules/core/iam/scope_guard.py new file mode 100644 index 0000000..e3d64e8 --- /dev/null +++ b/app/modules/core/iam/scope_guard.py @@ -0,0 +1,19 @@ + +"""Scope guard utilities for tenant/branch enforcement (v2.0.3.1)""" + +from typing import Optional + +class ScopeError(Exception): + pass + +def ensure_same_tenant(actor_tenant_id: int, target_tenant_id: int): + if actor_tenant_id != target_tenant_id: + raise ScopeError("Cross-tenant operation is not allowed") + +def ensure_same_branch(actor_branch_id: int, target_branch_id: int): + if actor_branch_id != target_branch_id: + raise ScopeError("Cross-branch operation is not allowed") + +def validate_branch_belongs_to_tenant(branch_tenant_id: int, tenant_id: int): + if branch_tenant_id != tenant_id: + raise ScopeError("Branch does not belong to selected tenant") diff --git a/app/modules/core/iam/services.py b/app/modules/core/iam/services.py new file mode 100644 index 0000000..1218983 --- /dev/null +++ b/app/modules/core/iam/services.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +from dataclasses import dataclass +from math import ceil + +from sqlalchemy import or_, select +from sqlalchemy.orm import Session + +from app.modules.core.iam.models import User +from app.modules.core.iam.scope import ( + UserScope, + get_manageable_roles, + get_user_role_names, + list_visible_branches, + list_visible_tenants, +) +from app.modules.core.tenancy.models import Branch, Tenant + + +@dataclass +class PageResult: + items: list + page: int + per_page: int + total: int + pages: int + + @property + def has_prev(self) -> bool: + return self.page > 1 + + @property + def has_next(self) -> bool: + return self.page < self.pages + + +def paginate_list(items: list, page: int = 1, per_page: int = 10) -> PageResult: + total = len(items) + per_page = max(1, min(per_page, 100)) + pages = max(1, ceil(total / per_page)) if total else 1 + page = max(1, min(page, pages)) + start = (page - 1) * per_page + end = start + per_page + return PageResult(items=items[start:end], page=page, per_page=per_page, total=total, pages=pages) + + +def search_scoped_users(db: Session, scope: UserScope, q: str | None = None) -> list[User]: + stmt = select(User) + if not scope.is_system_admin: + stmt = stmt.where(User.tenant_id == scope.actor.tenant_id) + if scope.branch_scoped: + stmt = stmt.where(User.branch_id == scope.actor.branch_id) + + query = (q or "").strip() + if query: + like = f"%{query}%" + stmt = stmt.where(or_(User.email.ilike(like), User.full_name.ilike(like))) + + return db.execute(stmt.order_by(User.full_name, User.email, User.id)).scalars().all() + + +def build_user_listing_payload( + db: Session, + scope: UserScope, + q: str | None = None, + page: int = 1, + per_page: int = 10, +) -> dict: + users = search_scoped_users(db, scope, q=q) + paged = paginate_list(users, page=page, per_page=per_page) + user_ids = [u.id for u in paged.items] + roles = {user_id: get_user_role_names(db, user_id) for user_id in user_ids} + tenants = {t.id: t for t in list_visible_tenants(db, scope)} + + if scope.is_system_admin: + branch_rows = db.execute(select(Branch).order_by(Branch.name)).scalars().all() + else: + branch_rows = list_visible_branches(db, scope, scope.actor.tenant_id) + + branches = {b.id: b for b in branch_rows} + + return { + "users_page": paged, + "users": paged.items, + "user_roles": roles, + "tenants": tenants, + "branches": branches, + "filters": { + "q": (q or "").strip(), + "per_page": paged.per_page, + }, + } + + +def build_user_form_payload( + db: Session, + scope: UserScope, + actor: User, + user_obj: User | None = None, + assigned_role_ids: list[int] | None = None, +) -> dict: + tenants = list_visible_tenants(db, scope) + + if user_obj: + selected_tenant_id = user_obj.tenant_id + selected_branch_id = user_obj.branch_id + else: + if scope.is_system_admin: + selected_tenant_id = actor.tenant_id or (tenants[0].id if tenants else None) + else: + selected_tenant_id = actor.tenant_id + selected_branch_id = actor.branch_id if scope.branch_scoped else None + + branches = list_visible_branches(db, scope, selected_tenant_id) + + selected_tenant = next((t for t in tenants if t.id == selected_tenant_id), None) + selected_branch = next((b for b in branches if b.id == selected_branch_id), None) + + manageable_roles = get_manageable_roles(db, scope) + + return { + "user_obj": user_obj, + "assigned_role_ids": assigned_role_ids or [], + "roles": manageable_roles, + "tenants": tenants, + "branches": branches, + "scope": scope, + "selected_tenant_id": selected_tenant_id, + "selected_branch_id": selected_branch_id, + "selected_tenant_name": selected_tenant.name if selected_tenant else "", + "selected_branch_name": selected_branch.name if selected_branch else "", + "can_change_tenant": scope.is_system_admin, + } \ No newline at end of file diff --git a/app/modules/core/iam/templates/change_password.html b/app/modules/core/iam/templates/change_password.html new file mode 100644 index 0000000..20f477e --- /dev/null +++ b/app/modules/core/iam/templates/change_password.html @@ -0,0 +1,37 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+

Change Password

+

Enter your current password, then confirm the password change using the OTP sent to your registered email.

+ + {% if flash %} +
+ {{ flash }} +
+ {% endif %} + +
+ + + + + + + + +
+ + Cancel +
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/modules/core/iam/templates/change_password_otp.html b/app/modules/core/iam/templates/change_password_otp.html new file mode 100644 index 0000000..1d3ddaf --- /dev/null +++ b/app/modules/core/iam/templates/change_password_otp.html @@ -0,0 +1,27 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+

Confirm Password Change

+

Enter the OTP sent to your registered email to complete the password change.

+ + {% if flash %} +
+ {{ flash }} +
+ {% endif %} + +
+ + + + +
+ + Back +
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/modules/core/iam/templates/forgot_password.html b/app/modules/core/iam/templates/forgot_password.html new file mode 100644 index 0000000..fb6b83a --- /dev/null +++ b/app/modules/core/iam/templates/forgot_password.html @@ -0,0 +1,36 @@ +{% extends "ui/templates/base/layout.html" %} + +{% block content %} +
+

Forgot Password

+

+ Enter your login email to receive password reset instructions by email. +

+ +
+ + +
+ + +
+ + +
+ + +
+{% endblock %} \ No newline at end of file diff --git a/app/modules/core/iam/templates/invite_link.html b/app/modules/core/iam/templates/invite_link.html new file mode 100644 index 0000000..b69c61b --- /dev/null +++ b/app/modules/core/iam/templates/invite_link.html @@ -0,0 +1 @@ +{% extends "ui/templates/base/layout.html" %}{% block content %}

Invite Link Generated

The invite email has been attempted through the configured firm SMTP. You may also copy and share this link manually with {{ invited_user.full_name or invited_user.email }} if required.

{{ invite_url }}
{% endblock %} \ No newline at end of file diff --git a/app/modules/core/iam/templates/otp.html b/app/modules/core/iam/templates/otp.html new file mode 100644 index 0000000..77c4d25 --- /dev/null +++ b/app/modules/core/iam/templates/otp.html @@ -0,0 +1,18 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+

OTP Verification

+

Enter the OTP sent to your registered email.

+ +
+ + + + + +
+
+{% endblock %} diff --git a/app/modules/core/iam/templates/reset_password.html b/app/modules/core/iam/templates/reset_password.html new file mode 100644 index 0000000..0d69a66 --- /dev/null +++ b/app/modules/core/iam/templates/reset_password.html @@ -0,0 +1,58 @@ +{% extends "ui/templates/base/layout.html" %} + +{% block content %} +
+

Reset Password

+

+ Enter the OTP sent to your registered email and set your new password. +

+ +
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ + +
+{% endblock %} \ No newline at end of file diff --git a/app/modules/core/iam/templates/reset_password_token.html b/app/modules/core/iam/templates/reset_password_token.html new file mode 100644 index 0000000..81323c7 --- /dev/null +++ b/app/modules/core/iam/templates/reset_password_token.html @@ -0,0 +1,53 @@ +{% extends "ui/templates/base/layout.html" %} + +{% block content %} +
+

Reset Password

+

+ Enter your new password to complete the password reset request. +

+ + {% if flash %} +
{{ flash }}
+ {% endif %} + +
+ + + +
+ + +

Use at least 8 characters with letters and numbers.

+
+ +
+ + +
+ + +
+ + +
+{% endblock %} diff --git a/app/modules/core/iam/tokens_models.py b/app/modules/core/iam/tokens_models.py new file mode 100644 index 0000000..437700f --- /dev/null +++ b/app/modules/core/iam/tokens_models.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from datetime import datetime +from sqlalchemy import Integer, String, ForeignKey, DateTime, Boolean, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from app.core.db.common import CommonBase + +class RefreshToken(CommonBase): + __tablename__ = "refresh_tokens" + __table_args__ = (UniqueConstraint("token_hash", name="uq_refresh_token_hash"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) + + token_hash: Mapped[str] = mapped_column(String(64), index=True) # sha256 hex + created_at_utc: Mapped[datetime] = mapped_column(DateTime, nullable=False) + expires_at_utc: Mapped[datetime] = mapped_column(DateTime, nullable=False) + revoked: Mapped[bool] = mapped_column(Boolean, default=False) + rotated_from_id: Mapped[int | None] = mapped_column(Integer, nullable=True) diff --git a/app/modules/core/iam/ui.py b/app/modules/core/iam/ui.py new file mode 100644 index 0000000..01ca8f2 --- /dev/null +++ b/app/modules/core/iam/ui.py @@ -0,0 +1,1013 @@ +from __future__ import annotations + +import secrets +from datetime import date, datetime, timezone + +from fastapi import APIRouter, Form, Request +from fastapi.responses import JSONResponse, 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.passwords import hash_password +from app.core.security.session_auth import get_current_user +from app.core.templating import templates +from app.modules.core.audit.service import model_snapshot, pair_before_after, write_audit_log +from app.modules.core.iam.invite_service import issue_invite_token +from app.modules.email_integration.services import send_user_invite_email +from app.core.settings import get_settings +from app.modules.core.iam.lifecycle import ( + LifecycleError, + activate_user, + deactivate_user, + disable_login, + enable_login, + ensure_manageable_lifecycle, + lock_user, + restore_user, + soft_delete_user, + unlock_user, +) +from app.modules.core.iam.models import User +from app.modules.core.iam.scope import ( + build_scope, + ensure_assignable_roles, + ensure_manageable_existing_user, + ensure_users_manage_scope, + ensure_users_view_scope, + get_user_role_ids, + get_user_role_names, + list_scoped_users, + list_visible_branches, + list_visible_tenants, + resolve_target_tenant_branch, +) +from app.modules.core.iam.services import build_user_form_payload, build_user_listing_payload +from app.modules.core.rbac.deps import get_user_permissions, get_user_roles +from app.modules.core.rbac.models import UserRole +from app.modules.core.rbac.permission_guard import require_permission +from app.modules.core.tenancy.models import Branch + +router = APIRouter(prefix="/system-settings/users", tags=["users-ui"]) + + +def _public_invite_url(invite_token: str) -> str: + base = (get_settings().ERP_PUBLIC_BASE_URL or "").strip().rstrip("/") or "http://localhost:8000" + return f"{base}/invite/accept?token={invite_token}" + + +def _redirect_login(): + return RedirectResponse(url="/login", status_code=303) + + +def _redirect_denied(): + return RedirectResponse(url="/system-settings", status_code=303) + + +def _flash_redirect(url: str) -> RedirectResponse: + return RedirectResponse(url=url, status_code=303) + + +def _common_context(request: Request, db, current_user: User, extra: dict | None = None): + ctx = { + "request": request, + "current_user": current_user, + "current_user_roles": get_user_roles(db, current_user.id), + "current_user_permissions": get_user_permissions(db, current_user.id), + "csrf_token": get_or_create_csrf_token(request), + } + if extra: + ctx.update(extra) + return ctx + + +def _user_admin_roles(db, current_user: User) -> set[str]: + return set(get_user_roles(db, current_user.id)) + + +def _ensure_user_admin(db, current_user: User): + roles = _user_admin_roles(db, current_user) + if "System Admin" in roles or "Firm Admin" in roles: + return + raise PermissionError("Only System Admin and Firm Admin can create or manage users.") + + +INTERNAL_EMPLOYEE_ROLE_NAMES = {"Firm Admin", "Partner", "Manager", "Branch Manager", "Staff"} +EMPLOYEE_EXCLUDED_ROLE_NAMES = {"System Admin", "Client", "Consultant"} + + +def _role_name_set(roles) -> set[str]: + return {getattr(role, "name", "") for role in roles if getattr(role, "name", "")} + + +def _requires_employee_profile(role_names: set[str]) -> bool: + """Internal firm users must have an employee profile. + + Platform-only users, clients and consultants are intentionally excluded. + If an account has at least one internal employee role, we create/link the + employee profile, unless it is also a System Admin account. + """ + if "System Admin" in role_names: + return False + return bool(role_names.intersection(INTERNAL_EMPLOYEE_ROLE_NAMES)) + + +def _parse_optional_date(value: str | None) -> date | None: + value = (value or "").strip() + if not value: + return None + return date.fromisoformat(value) + + +def _next_employee_code(db, tenant_id: int) -> str: + from app.modules.employees.models import Employee + + prefix = "EMP" + rows = db.execute( + select(Employee.employee_code) + .where(Employee.tenant_id == tenant_id, Employee.employee_code.ilike(f"{prefix}%")) + .order_by(Employee.employee_code.desc()) + ).all() + max_no = 0 + for (code,) in rows: + suffix = "".join(ch for ch in str(code or "") if ch.isdigit()) + if suffix: + max_no = max(max_no, int(suffix)) + return f"{prefix}{max_no + 1:05d}" + + +def _ensure_employee_profile_for_internal_user( + db, + *, + actor: User, + user_obj: User, + role_names: set[str], + employee_code: str | None = None, + mobile: str | None = None, + department: str | None = None, + designation: str | None = None, + date_of_joining: str | None = None, + employment_type: str | None = None, +) -> dict: + """Create/link an Employee profile for internal firm users. + + This function is intentionally additive. It does not create employee + profiles for System Admin, Client or Consultant-only users, and it does not + overwrite an existing linked employee profile. + """ + if not _requires_employee_profile(role_names): + return {"required": False, "created": False, "linked_existing": False, "employee_id": None} + + if not user_obj.tenant_id or not user_obj.branch_id: + raise ValueError("Tenant and Branch are mandatory for internal employee users.") + + from app.modules.employees.models import Employee + + existing_linked = db.execute( + select(Employee).where(Employee.tenant_id == user_obj.tenant_id, Employee.user_id == user_obj.id) + ).scalar_one_or_none() + if existing_linked: + return {"required": True, "created": False, "linked_existing": True, "employee_id": existing_linked.id} + + email = (user_obj.email or "").strip().lower() + existing_unlinked = None + if email: + existing_unlinked = db.execute( + select(Employee).where( + Employee.tenant_id == user_obj.tenant_id, + Employee.user_id.is_(None), + Employee.email == email, + ) + ).scalar_one_or_none() + + if existing_unlinked: + existing_unlinked.user_id = user_obj.id + existing_unlinked.branch_id = user_obj.branch_id + existing_unlinked.full_name = user_obj.full_name or existing_unlinked.full_name + existing_unlinked.updated_by_user_id = actor.id + existing_unlinked.updated_at_utc = datetime.now(timezone.utc) + return {"required": True, "created": False, "linked_existing": True, "employee_id": existing_unlinked.id} + + code = (employee_code or "").strip() or _next_employee_code(db, int(user_obj.tenant_id)) + code_exists = db.execute( + select(Employee).where(Employee.tenant_id == user_obj.tenant_id, Employee.employee_code == code) + ).scalar_one_or_none() + if code_exists: + raise ValueError(f"Employee code '{code}' already exists in this tenant.") + + emp_type = (employment_type or "full_time").strip() or "full_time" + if emp_type not in {"full_time", "part_time", "article_assistant", "intern", "consultant", "contract"}: + emp_type = "full_time" + + employee = Employee( + tenant_id=int(user_obj.tenant_id), + branch_id=int(user_obj.branch_id), + user_id=user_obj.id, + employee_code=code, + full_name=(user_obj.full_name or email or code).strip(), + email=email or None, + mobile=(mobile or "").strip() or None, + date_of_joining=_parse_optional_date(date_of_joining), + employment_type=emp_type, + status="active", + is_active=True, + department=(department or "").strip() or None, + designation=(designation or "").strip() or None, + created_by_user_id=actor.id, + updated_by_user_id=actor.id, + ) + db.add(employee) + db.flush() + return {"required": True, "created": True, "linked_existing": False, "employee_id": employee.id} + + +def _form_context( + db, + request: Request, + current_user: User, + scope, + *, + user_obj=None, + assigned_role_ids=None, + flash=None, + title="Create User", + form_mode="create", +): + payload = build_user_form_payload(db, scope, current_user, user_obj=user_obj, assigned_role_ids=assigned_role_ids) + payload.update({"title": title, "flash": flash, "form_mode": form_mode}) + return _common_context(request, db, current_user, payload) + + +@router.get("") +def users_list(request: Request, q: str = "", page: int = 1, per_page: int = 10): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + + try: + require_permission(db, current_user, "users.view") + except Exception: + return _redirect_denied() + + scope = build_scope(db, current_user) + try: + ensure_users_view_scope(scope) + except Exception: + return _flash_redirect("/system-settings") + + payload = build_user_listing_payload(db, scope, q=q, page=page, per_page=per_page) + payload.update({"title": "Users", "scope": scope}) + + return templates.TemplateResponse( + "modules/core/iam/templates/users_list.html", + _common_context(request, db, current_user, payload), + ) + finally: + db.close() + + +@router.get("/branch-options") +def branch_options(request: Request, tenant_id: int): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return JSONResponse([], status_code=401) + + try: + require_permission(db, current_user, "users.manage") + _ensure_user_admin(db, current_user) + except Exception: + return JSONResponse([], status_code=403) + + scope = build_scope(db, current_user) + + try: + ensure_users_manage_scope(scope) + except Exception: + return JSONResponse([], status_code=403) + + if scope.is_system_admin: + branch_rows = db.execute( + select(Branch) + .where(Branch.tenant_id == tenant_id) + .order_by(Branch.name) + ).scalars().all() + else: + if tenant_id != current_user.tenant_id: + return JSONResponse([], status_code=403) + branch_rows = list_visible_branches(db, scope, tenant_id) + + return JSONResponse( + [ + {"id": b.id, "name": b.name, "code": b.code} + for b in branch_rows + ] + ) + finally: + db.close() + + +@router.get("/new") +def user_create(request: Request): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + + try: + require_permission(db, current_user, "users.manage") + _ensure_user_admin(db, current_user) + except Exception: + return _redirect_denied() + + scope = build_scope(db, current_user) + try: + ensure_users_manage_scope(scope) + except Exception: + return _flash_redirect("/system-settings/users") + + return templates.TemplateResponse( + "modules/core/iam/templates/user_form.html", + _form_context(db, request, current_user, scope, title="Create User", form_mode="create"), + ) + finally: + db.close() + + +@router.post("/new") +def user_create_submit( + request: Request, + email: str = Form(...), + full_name: str = Form(...), + password: str = Form(...), + tenant_id: int | None = Form(None), + branch_id: int | None = Form(None), + role_ids: list[int] = Form([]), + is_active: str | None = Form(None), + allow_login: str | None = Form(None), + invite_user: str | None = Form(None), + employee_code: str = Form(""), + employee_mobile: str = Form(""), + employee_department: str = Form(""), + employee_designation: str = Form(""), + employee_date_of_joining: str = Form(""), + employee_employment_type: str = Form("full_time"), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + + try: + require_permission(db, current_user, "users.manage") + _ensure_user_admin(db, current_user) + except Exception: + return _redirect_denied() + + scope = build_scope(db, current_user) + + try: + ensure_users_manage_scope(scope) + resolved_tenant_id, resolved_branch_id = resolve_target_tenant_branch(db, scope, tenant_id, branch_id) + roles = ensure_assignable_roles(db, scope, role_ids) + except Exception as exc: + return templates.TemplateResponse( + "modules/core/iam/templates/user_form.html", + _form_context( + db, + request, + current_user, + scope, + assigned_role_ids=role_ids, + flash=str(exc), + title="Create User", + form_mode="create", + ), + status_code=403, + ) + + email = email.lower().strip() + if db.execute(select(User).where(User.email == email)).scalar_one_or_none(): + return templates.TemplateResponse( + "modules/core/iam/templates/user_form.html", + _form_context( + db, + request, + current_user, + scope, + assigned_role_ids=role_ids, + flash="Email already exists.", + title="Create User", + form_mode="create", + ), + status_code=400, + ) + + temp_password = password or secrets.token_urlsafe(12) + + user = User( + email=email, + full_name=full_name.strip(), + password_hash=hash_password(temp_password), + tenant_id=resolved_tenant_id, + branch_id=resolved_branch_id, + is_active=is_active is not None, + allow_login=allow_login is not None, + is_locked=False, + deleted_at=None, + must_change_password=invite_user is not None, + password_changed_at_utc=None, + ) + db.add(user) + db.flush() + + for role in roles: + db.add(UserRole(user_id=user.id, role_id=role.id)) + + role_names = _role_name_set(roles) + try: + employee_link_result = _ensure_employee_profile_for_internal_user( + db, + actor=current_user, + user_obj=user, + role_names=role_names, + employee_code=employee_code, + mobile=employee_mobile, + department=employee_department, + designation=employee_designation, + date_of_joining=employee_date_of_joining, + employment_type=employee_employment_type, + ) + except Exception as exc: + db.rollback() + return templates.TemplateResponse( + "modules/core/iam/templates/user_form.html", + _form_context( + db, + request, + current_user, + scope, + assigned_role_ids=role_ids, + flash=f"User was not created: {exc}", + title="Create User", + form_mode="create", + ), + status_code=400, + ) + + db.commit() + db.refresh(user) + + invite_url = None + if invite_user is not None: + invite_token = issue_invite_token(db, user) + invite_url = _public_invite_url(invite_token) + + write_audit_log( + db, + action="user.create", + entity_type="user", + actor=current_user, + request=request, + entity_id=user.id, + entity_name=user.email, + target_tenant_id=user.tenant_id, + target_branch_id=user.branch_id, + details={ + "after": model_snapshot( + user, + ["email", "full_name", "tenant_id", "branch_id", "is_active", "allow_login", "is_locked", "deleted_at"], + ), + "role_ids": [role.id for role in roles], + "employee_profile": employee_link_result, + }, + ) + + if invite_url: + try: + send_user_invite_email(db, user=user, invite_token=invite_token) + db.commit() + except Exception as exc: + print(f"[EMAIL INVITE ERROR] user={user.email} error={exc}") + return templates.TemplateResponse( + "modules/core/iam/templates/invite_link.html", + _common_context( + request, + db, + current_user, + {"title": "Invite Link", "invite_url": invite_url, "invited_user": user}, + ), + ) + + return RedirectResponse(url="/system-settings/users", status_code=303) + finally: + db.close() + + +@router.post("/{user_id}/invite") +def user_invite(request: Request, user_id: int, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + + try: + require_permission(db, current_user, "users.manage") + require_permission(db, current_user, "users.invite") + _ensure_user_admin(db, current_user) + except Exception: + return _redirect_denied() + + scope = build_scope(db, current_user) + user_obj = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none() + if not user_obj: + return RedirectResponse(url="/system-settings/users", status_code=303) + + ensure_manageable_existing_user(db, scope, user_obj) + + invite_token = issue_invite_token(db, user_obj) + invite_url = _public_invite_url(invite_token) + try: + send_user_invite_email(db, user=user_obj, invite_token=invite_token) + db.commit() + except Exception as exc: + print(f"[EMAIL INVITE ERROR] user={user_obj.email} error={exc}") + + write_audit_log( + db, + action="user.invited", + entity_type="user", + actor=current_user, + request=request, + entity_id=user_obj.id, + entity_name=user_obj.email, + target_tenant_id=user_obj.tenant_id, + target_branch_id=user_obj.branch_id, + details={"invite_url": invite_url}, + ) + + return templates.TemplateResponse( + "modules/core/iam/templates/invite_link.html", + _common_context( + request, + db, + current_user, + {"title": "Invite Link", "invite_url": invite_url, "invited_user": user_obj}, + ), + ) + finally: + db.close() + + +@router.get("/{user_id}/edit") +def user_edit(request: Request, user_id: int): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + + try: + require_permission(db, current_user, "users.manage") + _ensure_user_admin(db, current_user) + except Exception: + return _redirect_denied() + + scope = build_scope(db, current_user) + user_obj = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none() + if not user_obj: + return RedirectResponse(url="/system-settings/users", status_code=303) + + try: + ensure_manageable_existing_user(db, scope, user_obj) + ensure_users_manage_scope(scope) + except Exception: + return RedirectResponse(url="/system-settings/users", status_code=303) + + assigned_role_ids = list(get_user_role_ids(db, user_id)) + + return templates.TemplateResponse( + "modules/core/iam/templates/user_form.html", + _form_context( + db, + request, + current_user, + scope, + title=f"Edit User - {user_obj.email}", + form_mode="edit", + user_obj=user_obj, + assigned_role_ids=assigned_role_ids, + ), + ) + finally: + db.close() + + +@router.post("/{user_id}/edit") +def user_edit_submit( + request: Request, + user_id: int, + full_name: str = Form(...), + password: str = Form(""), + tenant_id: int | None = Form(None), + branch_id: int | None = Form(None), + role_ids: list[int] = Form([]), + is_active: str | None = Form(None), + allow_login: str | None = Form(None), + invite_user: str | None = Form(None), + employee_code: str = Form(""), + employee_mobile: str = Form(""), + employee_department: str = Form(""), + employee_designation: str = Form(""), + employee_date_of_joining: str = Form(""), + employee_employment_type: str = Form("full_time"), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + + try: + require_permission(db, current_user, "users.manage") + _ensure_user_admin(db, current_user) + except Exception: + return _redirect_denied() + + scope = build_scope(db, current_user) + user_obj = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none() + if not user_obj: + return RedirectResponse(url="/system-settings/users", status_code=303) + + try: + ensure_users_manage_scope(scope) + ensure_manageable_existing_user(db, scope, user_obj) + resolved_tenant_id, resolved_branch_id = resolve_target_tenant_branch(db, scope, tenant_id, branch_id) + roles = ensure_assignable_roles(db, scope, role_ids) + except Exception as exc: + return templates.TemplateResponse( + "modules/core/iam/templates/user_form.html", + _form_context( + db, + request, + current_user, + scope, + title=f"Edit User - {user_obj.email}", + form_mode="edit", + user_obj=user_obj, + assigned_role_ids=role_ids, + flash=str(exc), + ), + status_code=403, + ) + + before_snapshot = model_snapshot( + user_obj, + ["email", "full_name", "tenant_id", "branch_id", "is_active", "allow_login", "is_locked", "deleted_at"], + ) + old_role_ids = list(get_user_role_ids(db, user_obj.id)) + + user_obj.full_name = full_name.strip() + user_obj.tenant_id = resolved_tenant_id + user_obj.branch_id = resolved_branch_id + user_obj.is_active = is_active is not None + user_obj.allow_login = allow_login is not None + + if password.strip(): + user_obj.password_hash = hash_password(password.strip()) + + db.execute(UserRole.__table__.delete().where(UserRole.user_id == user_obj.id)) + for role in roles: + db.add(UserRole(user_id=user_obj.id, role_id=role.id)) + + role_names = _role_name_set(roles) + try: + employee_link_result = _ensure_employee_profile_for_internal_user( + db, + actor=current_user, + user_obj=user_obj, + role_names=role_names, + employee_code=employee_code, + mobile=employee_mobile, + department=employee_department, + designation=employee_designation, + date_of_joining=employee_date_of_joining, + employment_type=employee_employment_type, + ) + except Exception as exc: + db.rollback() + return templates.TemplateResponse( + "modules/core/iam/templates/user_form.html", + _form_context( + db, + request, + current_user, + scope, + title=f"Edit User - {user_obj.email}", + form_mode="edit", + user_obj=user_obj, + assigned_role_ids=role_ids, + flash=f"User was not updated: {exc}", + ), + status_code=400, + ) + + db.commit() + + if invite_user is not None: + invite_token = issue_invite_token(db, user_obj) + invite_url = _public_invite_url(invite_token) + try: + send_user_invite_email(db, user=user_obj, invite_token=invite_token) + db.commit() + except Exception as exc: + print(f"[EMAIL INVITE ERROR] user={user_obj.email} error={exc}") + write_audit_log( + db, + action="user.invited", + entity_type="user", + actor=current_user, + request=request, + entity_id=user_obj.id, + entity_name=user_obj.email, + target_tenant_id=user_obj.tenant_id, + target_branch_id=user_obj.branch_id, + details={"invite_url": invite_url}, + ) + + write_audit_log( + db, + action="user.update", + entity_type="user", + actor=current_user, + request=request, + entity_id=user_obj.id, + entity_name=user_obj.email, + target_tenant_id=user_obj.tenant_id, + target_branch_id=user_obj.branch_id, + details={ + **pair_before_after( + before_snapshot, + model_snapshot( + user_obj, + ["email", "full_name", "tenant_id", "branch_id", "is_active", "allow_login", "is_locked", "deleted_at"], + ), + ), + "old_role_ids": old_role_ids, + "new_role_ids": [role.id for role in roles], + "employee_profile": employee_link_result, + }, + ) + + return RedirectResponse(url="/system-settings/users", status_code=303) + finally: + db.close() + + +def _lifecycle_template_response(request: Request, db, current_user: User, scope, flash: str, status_code: int = 400): + users = list_scoped_users(db, scope) + roles = {user_id: get_user_role_names(db, user_id) for user_id in [u.id for u in users]} + tenants = {t.id: t for t in list_visible_tenants(db, scope)} + + visible_branch_tenant_id = current_user.tenant_id if not scope.is_system_admin else None + branch_rows = ( + db.execute(select(Branch).order_by(Branch.name)).scalars().all() + if scope.is_system_admin + else list_visible_branches(db, scope, visible_branch_tenant_id) + ) + branches = {b.id: b for b in branch_rows} + + return templates.TemplateResponse( + "modules/core/iam/templates/users_list.html", + _common_context( + request, + db, + current_user, + { + "title": "Users", + "users": users, + "user_roles": roles, + "tenants": tenants, + "branches": branches, + "scope": scope, + "flash": flash, + }, + ), + status_code=status_code, + ) + + +def _run_lifecycle_action(db, request: Request, current_user: User, user_id: int, action: str): + scope = build_scope(db, current_user) + user_obj = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none() + if not user_obj: + return RedirectResponse(url="/system-settings/users", status_code=303) + + try: + _ensure_user_admin(db, current_user) + ensure_users_manage_scope(scope) + ensure_manageable_lifecycle(scope, db, current_user, user_obj, action.replace("-", " ")) + except (Exception, LifecycleError) as exc: + return _lifecycle_template_response(request, db, current_user, scope, str(exc), 403) + + before_snapshot = model_snapshot( + user_obj, + ["email", "full_name", "tenant_id", "branch_id", "is_active", "allow_login", "is_locked", "deleted_at"], + ) + + if action == "activate": + activate_user(user_obj) + audit_action = "user.activate" + elif action == "deactivate": + deactivate_user(user_obj) + audit_action = "user.deactivate" + elif action == "enable-login": + enable_login(user_obj) + audit_action = "user.enable_login" + elif action == "disable-login": + disable_login(user_obj) + audit_action = "user.disable_login" + elif action == "lock": + lock_user(user_obj) + audit_action = "user.lock" + elif action == "unlock": + unlock_user(user_obj) + audit_action = "user.unlock" + elif action == "delete": + soft_delete_user(user_obj) + audit_action = "user.soft_delete" + elif action == "restore": + restore_user(user_obj) + audit_action = "user.restore" + else: + return _lifecycle_template_response(request, db, current_user, scope, "Unknown action", 400) + + db.commit() + + write_audit_log( + db, + action=audit_action, + entity_type="user", + actor=current_user, + request=request, + entity_id=user_obj.id, + entity_name=user_obj.email, + target_tenant_id=user_obj.tenant_id, + target_branch_id=user_obj.branch_id, + details=pair_before_after( + before_snapshot, + model_snapshot( + user_obj, + ["email", "full_name", "tenant_id", "branch_id", "is_active", "allow_login", "is_locked", "deleted_at"], + ), + ), + ) + + return RedirectResponse(url="/system-settings/users", status_code=303) + + +@router.post("/{user_id}/activate") +def user_activate_submit(request: Request, user_id: int, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "users.manage") + except Exception: + return _redirect_denied() + return _run_lifecycle_action(db, request, current_user, user_id, "activate") + finally: + db.close() + + +@router.post("/{user_id}/deactivate") +def user_deactivate_submit(request: Request, user_id: int, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "users.manage") + except Exception: + return _redirect_denied() + return _run_lifecycle_action(db, request, current_user, user_id, "deactivate") + finally: + db.close() + + +@router.post("/{user_id}/enable-login") +def user_enable_login_submit(request: Request, user_id: int, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "users.manage") + except Exception: + return _redirect_denied() + return _run_lifecycle_action(db, request, current_user, user_id, "enable-login") + finally: + db.close() + + +@router.post("/{user_id}/disable-login") +def user_disable_login_submit(request: Request, user_id: int, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "users.manage") + except Exception: + return _redirect_denied() + return _run_lifecycle_action(db, request, current_user, user_id, "disable-login") + finally: + db.close() + + +@router.post("/{user_id}/lock") +def user_lock_submit(request: Request, user_id: int, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "users.manage") + except Exception: + return _redirect_denied() + return _run_lifecycle_action(db, request, current_user, user_id, "lock") + finally: + db.close() + + +@router.post("/{user_id}/unlock") +def user_unlock_submit(request: Request, user_id: int, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "users.manage") + except Exception: + return _redirect_denied() + return _run_lifecycle_action(db, request, current_user, user_id, "unlock") + finally: + db.close() + + +@router.post("/{user_id}/delete") +def user_delete_submit(request: Request, user_id: int, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "users.manage") + except Exception: + return _redirect_denied() + return _run_lifecycle_action(db, request, current_user, user_id, "delete") + finally: + db.close() + + +@router.post("/{user_id}/restore") +def user_restore_submit(request: Request, user_id: int, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "users.manage") + except Exception: + return _redirect_denied() + return _run_lifecycle_action(db, request, current_user, user_id, "restore") + finally: + db.close() \ No newline at end of file diff --git a/app/modules/core/rbac/__init__.py b/app/modules/core/rbac/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/core/rbac/api.py b/app/modules/core/rbac/api.py new file mode 100644 index 0000000..f126fde --- /dev/null +++ b/app/modules/core/rbac/api.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.db.deps import get_common_db +from app.core.security.session_auth import require_login +from app.modules.core.audit.service import write_audit_log +from app.modules.core.iam.models import User +from app.modules.core.iam.scope import build_scope, scope_to_http, assert_can_manage_role_object +from app.modules.core.rbac.models import Permission, Role, RolePermission + +router = APIRouter(prefix="/rbac", tags=["rbac"]) + + +class RoleCreateRequest(BaseModel): + name: str + is_active: bool = True + + +class PermissionCreateRequest(BaseModel): + code: str + name: str + is_active: bool = True + + +class RolePermissionUpdateRequest(BaseModel): + permission_ids: list[int] = [] + + +def _require_system_admin_scope(db: Session, current_user: User): + scope = build_scope(db, current_user) + if not scope.is_system_admin: + raise HTTPException(status_code=403, detail="Only System Admin can access RBAC.") + return scope + + +@router.get("/roles") +def list_roles( + current_user: User = Depends(require_login), + db: Session = Depends(get_common_db), +): + _require_system_admin_scope(db, current_user) + roles = db.execute(select(Role).order_by(Role.name)).scalars().all() + return [{"id": r.id, "name": r.name, "is_active": r.is_active} for r in roles] + + +@router.post("/roles") +def create_role( + payload: RoleCreateRequest, + current_user: User = Depends(require_login), + db: Session = Depends(get_common_db), +): + _require_system_admin_scope(db, current_user) + + name = payload.name.strip() + if not name: + raise HTTPException(status_code=400, detail="Role name is required") + + exists = db.execute(select(Role).where(Role.name == name)).scalar_one_or_none() + if exists: + raise HTTPException(status_code=400, detail="Role already exists") + + role = Role(name=name, is_active=payload.is_active) + db.add(role) + db.commit() + db.refresh(role) + + write_audit_log( + db, + action="role.create.api", + entity_type="role", + actor=current_user, + entity_id=role.id, + entity_name=role.name, + target_tenant_id=current_user.tenant_id, + target_branch_id=current_user.branch_id, + details={"name": role.name, "is_active": role.is_active}, + ) + return {"status": "ok", "id": role.id} + + +@router.get("/permissions") +def list_permissions( + current_user: User = Depends(require_login), + db: Session = Depends(get_common_db), +): + _require_system_admin_scope(db, current_user) + permissions = db.execute(select(Permission).order_by(Permission.code)).scalars().all() + return [{"id": p.id, "code": p.code, "name": p.name, "is_active": p.is_active} for p in permissions] + + +@router.post("/permissions") +def create_permission( + payload: PermissionCreateRequest, + current_user: User = Depends(require_login), + db: Session = Depends(get_common_db), +): + _require_system_admin_scope(db, current_user) + + code = payload.code.strip() + if not code: + raise HTTPException(status_code=400, detail="Permission code is required") + + exists = db.execute(select(Permission).where(Permission.code == code)).scalar_one_or_none() + if exists: + raise HTTPException(status_code=400, detail="Permission already exists") + + permission = Permission(code=code, name=payload.name.strip(), is_active=payload.is_active) + db.add(permission) + db.commit() + db.refresh(permission) + + write_audit_log( + db, + action="permission.create.api", + entity_type="permission", + actor=current_user, + entity_id=permission.id, + entity_name=permission.code, + target_tenant_id=current_user.tenant_id, + target_branch_id=current_user.branch_id, + details={"code": permission.code, "name": permission.name, "is_active": permission.is_active}, + ) + return {"status": "ok", "id": permission.id} + + +@router.get("/roles/{role_id}") +def role_detail( + role_id: int, + current_user: User = Depends(require_login), + db: Session = Depends(get_common_db), +): + scope = _require_system_admin_scope(db, current_user) + + role = db.execute(select(Role).where(Role.id == role_id)).scalar_one_or_none() + if not role: + raise HTTPException(status_code=404, detail="Role not found") + + try: + assert_can_manage_role_object(scope, role) + except Exception as exc: + raise scope_to_http(exc) + + permission_ids = db.execute( + select(RolePermission.permission_id).where(RolePermission.role_id == role.id) + ).scalars().all() + + return { + "id": role.id, + "name": role.name, + "is_active": role.is_active, + "permission_ids": list(permission_ids), + } + + +@router.put("/roles/{role_id}/permissions") +def update_role_permissions( + role_id: int, + payload: RolePermissionUpdateRequest, + current_user: User = Depends(require_login), + db: Session = Depends(get_common_db), +): + _require_system_admin_scope(db, current_user) + + role = db.execute(select(Role).where(Role.id == role_id)).scalar_one_or_none() + if not role: + raise HTTPException(status_code=404, detail="Role not found") + + old_permission_ids = db.execute( + select(RolePermission.permission_id).where(RolePermission.role_id == role.id) + ).scalars().all() + + db.execute(RolePermission.__table__.delete().where(RolePermission.role_id == role.id)) + for permission_id in payload.permission_ids: + db.add(RolePermission(role_id=role.id, permission_id=permission_id)) + db.commit() + + write_audit_log( + db, + action="role.permissions.update.api", + entity_type="role", + actor=current_user, + entity_id=role.id, + entity_name=role.name, + target_tenant_id=current_user.tenant_id, + target_branch_id=current_user.branch_id, + details={ + "old_permission_ids": list(old_permission_ids), + "new_permission_ids": list(payload.permission_ids), + }, + ) + return {"status": "ok"} \ No newline at end of file diff --git a/app/modules/core/rbac/deps.py b/app/modules/core/rbac/deps.py new file mode 100644 index 0000000..912b594 --- /dev/null +++ b/app/modules/core/rbac/deps.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from fastapi import Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.db.deps import get_common_db +from app.core.security.session_auth import require_login +from app.modules.core.rbac.models import Permission, Role, RolePermission, UserRole + + +def get_user_roles(db: Session, user_id: int) -> list[str]: + q = ( + select(Role.name) + .join(UserRole, UserRole.role_id == Role.id) + .where(UserRole.user_id == user_id) + .order_by(Role.name) + ) + return [name for (name,) in db.execute(q).all()] + + +def get_user_permissions(db: Session, user_id: int) -> list[str]: + q = ( + select(Permission.code) + .join(RolePermission, RolePermission.permission_id == Permission.id) + .join(Role, Role.id == RolePermission.role_id) + .join(UserRole, UserRole.role_id == Role.id) + .where(UserRole.user_id == user_id, Permission.is_active.is_(True), Role.is_active.is_(True)) + .distinct() + .order_by(Permission.code) + ) + return [code for (code,) in db.execute(q).all()] + + +def user_has_role(db: Session, user_id: int, role_name: str) -> bool: + q = ( + select(UserRole.id) + .join(Role, Role.id == UserRole.role_id) + .where(UserRole.user_id == user_id, Role.name == role_name) + ) + return db.execute(q).first() is not None + + +def user_has_permission(db: Session, user_id: int, permission_code: str) -> bool: + if user_has_role(db, user_id, "System Admin"): + return True + q = ( + select(Permission.id) + .join(RolePermission, RolePermission.permission_id == Permission.id) + .join(Role, Role.id == RolePermission.role_id) + .join(UserRole, UserRole.role_id == Role.id) + .where(UserRole.user_id == user_id, Permission.code == permission_code) + ) + return db.execute(q).first() is not None + + +def require_role(role_name: str): + def _dep(user=Depends(require_login), db: Session = Depends(get_common_db)): + if user_has_role(db, user.id, role_name): + return True + raise HTTPException(status_code=403, detail=f"Missing role: {role_name}") + + return _dep + + +def require_permission(permission_code: str): + def _dep(user=Depends(require_login), db: Session = Depends(get_common_db)): + if user_has_permission(db, user.id, permission_code): + return True + raise HTTPException(status_code=403, detail=f"Missing permission: {permission_code}") + + return _dep diff --git a/app/modules/core/rbac/models.py b/app/modules/core/rbac/models.py new file mode 100644 index 0000000..a456144 --- /dev/null +++ b/app/modules/core/rbac/models.py @@ -0,0 +1,33 @@ +from __future__ import annotations +from sqlalchemy import String, Boolean, Integer, ForeignKey, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from app.core.db.common import CommonBase + +class Role(CommonBase): + __tablename__ = "roles" + __table_args__ = (UniqueConstraint("name", name="uq_role_name"),) + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(100), index=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + +class Permission(CommonBase): + __tablename__ = "permissions" + __table_args__ = (UniqueConstraint("code", name="uq_permission_code"),) + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + code: Mapped[str] = mapped_column(String(150), index=True) + name: Mapped[str] = mapped_column(String(255), default="") + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + +class RolePermission(CommonBase): + __tablename__ = "role_permissions" + __table_args__ = (UniqueConstraint("role_id","permission_id", name="uq_role_perm"),) + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + role_id: Mapped[int] = mapped_column(ForeignKey("roles.id", ondelete="CASCADE"), index=True) + permission_id: Mapped[int] = mapped_column(ForeignKey("permissions.id", ondelete="CASCADE"), index=True) + +class UserRole(CommonBase): + __tablename__ = "user_roles" + __table_args__ = (UniqueConstraint("user_id","role_id", name="uq_user_role"),) + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) + role_id: Mapped[int] = mapped_column(ForeignKey("roles.id", ondelete="CASCADE"), index=True) diff --git a/app/modules/core/rbac/permission_guard.py b/app/modules/core/rbac/permission_guard.py new file mode 100644 index 0000000..825249a --- /dev/null +++ b/app/modules/core/rbac/permission_guard.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from app.modules.core.iam.models import User +from app.modules.core.rbac.deps import get_user_permissions, get_user_roles +from app.modules.core.rbac.permissions_registry import expand_permission_codes + + +def has_permission(db: Session, user: User | None, permission_code: str) -> bool: + if not user: + return False + roles = set(get_user_roles(db, user.id)) + if "System Admin" in roles: + return True + granted = set(get_user_permissions(db, user.id)) + for code in expand_permission_codes(permission_code): + if code in granted: + return True + return False + + +def require_permission(db: Session, user: User | None, permission_code: str) -> None: + if not has_permission(db, user, permission_code): + raise HTTPException(status_code=403, detail=f"Missing permission: {permission_code}") diff --git a/app/modules/core/rbac/permissions_registry.py b/app/modules/core/rbac/permissions_registry.py new file mode 100644 index 0000000..dec728a --- /dev/null +++ b/app/modules/core/rbac/permissions_registry.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +PERMISSIONS = { + "system.settings.view": "View System Settings", + "system.settings.edit": "Edit System Settings", + "system.settings.manage": "Manage System Settings", + "users.view": "View Users", + "users.manage": "Create and Edit Users", + "rbac.view": "View Roles and Permissions", + "rbac.manage": "Manage Roles and Permissions", + "audit.view": "View Audit Logs", + "alerts.view_self": "View Own Alerts", + "alerts.manage": "Create and Manage Alerts", + "users.invite": "Invite Users", + "users.reset_password": "Reset User Passwords", + "services.view": "View Services", + "services.create": "Create Services", + "services.edit": "Edit Services", + "services.deactivate": "Deactivate Services", + "services.cross_branch": "Manage Services Across Branches", + "services.cross_tenant": "Manage Services Across Tenants", + "services.catalogue.manage": "Manage System Service Catalogue", + "services.selection.manage": "Enable or Disable Firm Services", + "service_tasks.view": "View Service Task Templates", + "service_tasks.create": "Create Service Task Templates", + "service_tasks.edit": "Edit Service Task Templates", + "service_tasks.deactivate": "Deactivate Service Task Templates", + "clients.view": "View Clients", + "clients.create": "Create Clients", + "clients.import": "Import Clients from Excel", + "clients.edit": "Edit Clients", + "clients.deactivate": "Deactivate Clients", + "clients.activate": "Activate Clients", + "clients.archive": "Archive Clients", + "clients.restore": "Restore Clients", + "clients.assign_partner": "Assign Partner to Clients", + "clients.cross_branch": "Manage Clients Across Branches", + "clients.cross_tenant": "Manage Clients Across Tenants", + "clients.export": "Export Clients", + "clients.audit_log.view": "View Client Audit Logs", + "clients.view.own_only": "View Only Own Clients", + + "employees.dashboard.view": "View HR Dashboard and Reports", + "employees.view": "View Employees", + "employees.create": "Create Employees", + "employees.edit": "Edit Employees", + "employees.status": "Change Employee Status", + "employees.cross_branch": "Manage Employees Across Branches", + "employees.cross_tenant": "Manage Employees Across Tenants", + "employees.ess.view": "View Employee Self Service Portal", + "employees.ess.profile.edit": "Edit Own Employee Profile", + "employees.work.view_self": "View Own Engagement Work Dashboard", + "employees.work.manage": "Manage Employee Engagement Work Allocation", + "employees.progress.view": "View Engagement Progress Dashboard", + "employees.registration.request": "Request Employee Profile Linkage", + "employees.registration.approve": "Approve Employee Registration Requests", + "employees.attendance.punch": "Punch Employee Attendance", + "employees.attendance.view_self": "View Own Attendance", + "employees.attendance.view_all": "View Employee Attendance", + "employees.attendance.approve": "Approve or Manage Employee Attendance", + "employees.leave.apply": "Apply Employee Leave", + "employees.leave.view_self": "View Own Leave", + "employees.leave.view_all": "View Employee Leave Requests", + "employees.leave.approve": "Approve Employee Leave Requests", + "employees.leave_type.manage": "Manage Employee Leave Types", + "employees.leave_balance.manage": "Manage Employee Leave Balances", + "employees.document_type.manage": "Manage Employee Document Types", + "employees.documents.delete": "Archive Employee Documents", + "employees.documents.verify": "Verify Employee Documents", + "employees.documents.manage": "Upload and Manage Employee Documents", + "employees.documents.view_all": "View Employee Documents", + "employees.documents.upload_self": "Upload Own Employee Documents", + "employees.documents.view_self": "View Own Employee Documents", + "employees.onboarding.view": "View Employee Onboarding", + "employees.onboarding.manage": "Manage Employee Onboarding Checklist and Tasks", + "employees.onboarding.approve": "Complete or Approve Employee Onboarding Tasks", + "employees.offboarding.view": "View Employee Offboarding", + "employees.offboarding.manage": "Manage Employee Offboarding", + "employees.offboarding.approve": "Approve and Complete Employee Offboarding", + "employees.offboarding.request_self": "Request Own Employee Offboarding", + "employees.payroll.structure.manage": "Manage Employee Salary Structures", + "employees.payroll.run": "Run Employee Payroll", + "employees.payroll.view": "View Employee Payroll and Payslips", + "employees.payroll.view_self": "View Own Payslips", + "employees.payroll.payout": "Approve or Mark Payroll Paid", + "employees.import": "Import Employee HR Data from Excel", + "employees.import.employee": "Import Employees from Excel", + "employees.import.leave_type": "Import Employee Leave Types from Excel", + "employees.import.leave_balance": "Import Employee Leave Balances from Excel", + "employees.import.salary_structure": "Import Employee Salary Structures from Excel", + "consultants.view": "View Consultants", + "consultants.manage": "Create and Edit Consultants", + "consultants.link_clients": "Link Consultants to Clients", + "consultants.cross_branch": "Manage Consultants Across Branches", + "consultants.portal.view": "View Consultant Portal", + "consultants.managed_clients.manage": "Manage Own Consultant Portal Clients", + "consultants.workspace.manage": "Manage Own Consultant SaaS Workspace", + "consultants.service_requests.manage": "Review Consultant Service Requests", + "consultants.conversions.manage": "Approve Consultant Managed Client Conversions", + + "billing.view": "View Billing Invoices", + "billing.create": "Create Billing Invoices", + "billing.edit": "Edit Billing Invoices", + "billing.approve": "Approve Billing Invoices", + "billing.post": "Post Billing Invoices", + "billing.cancel": "Cancel Billing Invoices", + "billing.payment.create": "Record Billing Payments", + "billing.payment.view": "View Billing Payments", + "billing.reports": "View Billing Reports", + "billing.cross_branch": "Manage Billing Across Branches", + "billing.cross_tenant": "Manage Billing Across Audit Firms", + "billing.view_own": "View Own Client Billing Only", + "billing_fee_structure.view": "View Billing Fee Structure", + "billing_fee_structure.import": "Import Billing Fee Structure", + "billing_fee_structure.edit": "Edit Billing Fee Structure", + "billing_fee_structure.delete": "Delete Billing Fee Structure", + "billing_invoice.generate": "Generate Billing Invoices", + "billing_invoice.bulk_generate": "Bulk Generate Billing Invoices", + + "platform_billing.view": "View Platform Billing", + "platform_billing.create": "Create Platform Billing Accounts and Invoices", + "platform_billing.edit": "Edit Platform Billing Records", + "platform_billing.generate": "Generate Platform Billing Invoices", + "platform_billing.post": "Post Platform Invoices", + "platform_billing.cancel": "Cancel Platform Invoices", + "platform_billing.payment.create": "Record Platform Billing Payments", + "platform_billing.payment.view": "View Platform Billing Payments", + "platform_billing.reports": "View Platform Billing Reports", + "platform_plans.manage": "Manage Platform Billing Plans", + "platform_subscriptions.manage": "Manage Platform Subscriptions", + + # Marketplace / public lead permissions + "marketplace_leads.view": "View Marketplace Leads", + "marketplace_leads.create": "Create Marketplace Leads", + "marketplace_leads.assign": "Assign Marketplace Leads to Audit Firms", + "marketplace_leads.update": "Update Marketplace Lead Status", + "marketplace_leads.convert": "Convert Marketplace Leads to Clients", + "marketplace_leads.reports": "View Marketplace Lead Reports", + "marketplace_leads.view_assigned": "View Assigned Marketplace Leads", + + "documents.view": "View Engagement Documents", + "documents.upload": "Upload Engagement Documents", + "documents.download": "Download Engagement Documents", + "documents.delete": "Archive Engagement Documents", + "documents.audit.view": "View Document Access Logs", + + "notice_cases.view": "View Notice and Case Management", + "notice_cases.create": "Create Notices and Cases", + "notice_cases.edit": "Edit Notices and Cases", + "notice_cases.events.manage": "Manage Notice/Case Timeline Events", + "notice_cases.hearings.manage": "Manage Notice/Case Hearings", + "notice_cases.orders.manage": "Manage Notice/Case Orders", + "notice_cases.documents.upload": "Upload Notice/Case Documents", + "notice_cases.documents.download": "Download Notice/Case Documents", + "notice_cases.documents.delete": "Archive Notice/Case Documents", + "notice_cases.cross_branch": "Manage Notices/Cases Across Branches", + "notice_cases.cross_tenant": "Manage Notices/Cases Across Tenants", + "notice_cases.view.own_only": "View Only Own Assigned Notices/Cases", +} + +PERMISSION_ALIASES = { + "system.settings.manage": ["system.settings.edit"], + "system.settings.edit": ["system.settings.manage"], +} + + +def expand_permission_codes(code: str) -> list[str]: + codes = [code] + for alias in PERMISSION_ALIASES.get(code, []): + if alias not in codes: + codes.append(alias) + return codes diff --git a/app/modules/core/rbac/services.py b/app/modules/core/rbac/services.py new file mode 100644 index 0000000..b682f94 --- /dev/null +++ b/app/modules/core/rbac/services.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from sqlalchemy import or_, select +from sqlalchemy.orm import Session + +from app.modules.core.iam.scope import UserScope, get_manageable_roles +from app.modules.core.rbac.models import Permission, Role, RolePermission +from app.modules.core.iam.services import paginate_list + + +def search_roles(db: Session, scope: UserScope, q: str | None = None) -> list[Role]: + roles = db.execute(select(Role).order_by(Role.name)).scalars().all() if scope.is_system_admin else get_manageable_roles(db, scope) + query = (q or '').strip().lower() + if not query: + return roles + return [r for r in roles if query in (r.name or '').lower()] + + +def permissions_by_role(db: Session, roles: list[Role]) -> dict[int, list[str]]: + return { + role.id: [code for (code,) in db.execute( + select(Permission.code) + .join(RolePermission, RolePermission.permission_id == Permission.id) + .where(RolePermission.role_id == role.id) + .order_by(Permission.code) + ).all()] + for role in roles + } + + +def build_roles_payload(db: Session, scope: UserScope, q: str | None = None, page: int = 1, per_page: int = 10) -> dict: + roles = search_roles(db, scope, q=q) + paged = paginate_list(roles, page=page, per_page=per_page) + return { + 'roles': paged.items, + 'roles_page': paged, + 'permissions_by_role': permissions_by_role(db, paged.items), + 'filters': {'q': (q or '').strip(), 'per_page': paged.per_page}, + } + + +def search_permissions(db: Session, q: str | None = None) -> list[Permission]: + permissions = db.execute(select(Permission).order_by(Permission.code)).scalars().all() + query = (q or '').strip().lower() + if not query: + return permissions + return [p for p in permissions if query in (p.code or '').lower() or query in (p.name or '').lower()] + + +def build_permissions_payload(db: Session, q: str | None = None, page: int = 1, per_page: int = 15) -> dict: + permissions = search_permissions(db, q=q) + paged = paginate_list(permissions, page=page, per_page=per_page) + return {'permissions': paged.items, 'permissions_page': paged, 'filters': {'q': (q or '').strip(), 'per_page': paged.per_page}} diff --git a/app/modules/core/rbac/templates/permissions_list.html b/app/modules/core/rbac/templates/permissions_list.html new file mode 100644 index 0000000..6d88a75 --- /dev/null +++ b/app/modules/core/rbac/templates/permissions_list.html @@ -0,0 +1,21 @@ +{% extends "ui/templates/base/layout.html" %} +{% import "ui/templates/components/macros.html" as ui %} +{% block content %} +
+
+

Permissions

+

Searchable permission registry used by API guards and UI gate checks.

+
+ {{ ui.search_bar('/system-settings/rbac/permissions', filters.q, filters.per_page) }} + {% if permissions %} + + + {% for permission in permissions %}{% endfor %} +
CodeNameStatus
{{ permission.code }}{{ permission.name }}{{ ui.badge('Active','emerald') if permission.is_active else ui.badge('Inactive','rose') }}
+ {{ ui.pagination(permissions_page, '/system-settings/rbac/permissions', request.url.query) }} + {% else %}
{{ ui.empty_state('No permissions matched the current filter.') }}
{% endif %} +
+
+

Create Permission

{% if "rbac.manage" in current_user_permissions and "System Admin" in current_user_roles %}
{% else %}
{{ ui.alert('Only System Admin can create permissions.', 'amber') }}
{% endif %}
+
+{% endblock %} diff --git a/app/modules/core/rbac/templates/role_permissions_form.html b/app/modules/core/rbac/templates/role_permissions_form.html new file mode 100644 index 0000000..b5b1d7e --- /dev/null +++ b/app/modules/core/rbac/templates/role_permissions_form.html @@ -0,0 +1,31 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Configure Role Permissions

+

Role: {{ role.name }}

+
+ Back to roles +
+ +
+ +
+ {% for permission in permissions %} + + {% endfor %} +
+ +
+ {% if "rbac.manage" in current_user_permissions and "System Admin" in current_user_roles %}{% else %}View-only access{% endif %} +
+
+
+{% endblock %} diff --git a/app/modules/core/rbac/templates/roles_list.html b/app/modules/core/rbac/templates/roles_list.html new file mode 100644 index 0000000..115e97e --- /dev/null +++ b/app/modules/core/rbac/templates/roles_list.html @@ -0,0 +1,23 @@ +{% extends "ui/templates/base/layout.html" %} +{% import "ui/templates/components/macros.html" as ui %} +{% block content %} +
+
+
+

Roles

Core RBAC matrix with search and pagination for safer maintenance.

+ View permissions +
+
+ {{ ui.search_bar('/system-settings/rbac/roles', filters.q, filters.per_page) }} + {% if roles %} + + + {% for role in roles %}{% endfor %} +
RolePermissionsAction
{{ role.name }}
{{ 'Active' if role.is_active else 'Inactive' }}
{% for code in permissions_by_role.get(role.id, []) %}{{ ui.badge(code) }}{% else %}No permissions{% endfor %}
{{ "Configure" if "rbac.manage" in current_user_permissions else "View" }}
+ {{ ui.pagination(roles_page, '/system-settings/rbac/roles', request.url.query) }} + {% else %}
{{ ui.empty_state('No roles matched the current filter.') }}
{% endif %} +
+
+

Create Role

{% if "rbac.manage" in current_user_permissions and "System Admin" in current_user_roles %}
{% else %}
{{ ui.alert('Only System Admin can create roles.', 'amber') }}
{% endif %}
+
+{% endblock %} diff --git a/app/modules/core/rbac/ui.py b/app/modules/core/rbac/ui.py new file mode 100644 index 0000000..17b8ea3 --- /dev/null +++ b/app/modules/core/rbac/ui.py @@ -0,0 +1,267 @@ +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.core.audit.service import write_audit_log +from app.modules.core.iam.scope import build_scope, assert_can_manage_role_object +from app.modules.core.rbac.deps import get_user_permissions, get_user_roles +from app.modules.core.rbac.models import Permission, Role, RolePermission +from app.modules.core.rbac.services import build_permissions_payload, build_roles_payload + +router = APIRouter(prefix="/system-settings/rbac", tags=["rbac-ui"]) + + +def _redirect_login(): + return RedirectResponse(url="/login", status_code=303) + + +def _redirect_denied(): + return RedirectResponse(url="/system-settings", status_code=303) + + +def _is_system_admin(db, current_user) -> bool: + return "System Admin" in get_user_roles(db, current_user.id) + + +def _base_ctx(request: Request, current_user, db, **ctx): + base = { + "request": request, + "current_user": current_user, + "current_user_roles": get_user_roles(db, current_user.id), + "current_user_permissions": get_user_permissions(db, current_user.id), + "csrf_token": get_or_create_csrf_token(request), + } + base.update(ctx) + return base + + +@router.get("") +def rbac_dashboard(request: Request): + return RedirectResponse(url="/system-settings/rbac/roles", status_code=303) + + +@router.get("/roles") +def roles_list(request: Request, q: str = "", page: int = 1, per_page: int = 10): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + + if not _is_system_admin(db, current_user): + return _redirect_denied() + + scope = build_scope(db, current_user) + payload = build_roles_payload(db, scope, q=q, page=page, per_page=per_page) + + return templates.TemplateResponse( + "modules/core/rbac/templates/roles_list.html", + _base_ctx(request, current_user, db, title="RBAC Roles", **payload), + ) + finally: + db.close() + + +@router.post("/roles/new") +def role_create_submit( + request: Request, + name: str = Form(...), + is_active: str | None = Form(None), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + + if not _is_system_admin(db, current_user): + return _redirect_denied() + + name = name.strip() + if name and not db.execute(select(Role).where(Role.name == name)).scalar_one_or_none(): + role = Role(name=name, is_active=is_active is not None) + db.add(role) + db.commit() + db.refresh(role) + + write_audit_log( + db, + action="role.create", + entity_type="role", + actor=current_user, + request=request, + entity_id=role.id, + entity_name=role.name, + target_tenant_id=current_user.tenant_id, + target_branch_id=current_user.branch_id, + details={"name": role.name, "is_active": role.is_active}, + ) + + return RedirectResponse(url="/system-settings/rbac/roles", status_code=303) + finally: + db.close() + + +@router.get("/roles/{role_id}") +def role_permissions_edit(request: Request, role_id: int): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + + if not _is_system_admin(db, current_user): + return _redirect_denied() + + role = db.execute(select(Role).where(Role.id == role_id)).scalar_one_or_none() + if not role: + return RedirectResponse(url="/system-settings/rbac/roles", status_code=303) + + scope = build_scope(db, current_user) + try: + assert_can_manage_role_object(scope, role) + except Exception: + return RedirectResponse(url="/system-settings/rbac/roles", status_code=303) + + permissions = db.execute(select(Permission).order_by(Permission.code)).scalars().all() + assigned_permission_ids = db.execute( + select(RolePermission.permission_id).where(RolePermission.role_id == role_id) + ).scalars().all() + + return templates.TemplateResponse( + "modules/core/rbac/templates/role_permissions_form.html", + _base_ctx( + request, + current_user, + db, + title=f"Role Permissions - {role.name}", + role=role, + permissions=permissions, + assigned_permission_ids=list(assigned_permission_ids), + ), + ) + finally: + db.close() + + +@router.post("/roles/{role_id}") +def role_permissions_submit( + request: Request, + role_id: int, + permission_ids: list[int] = Form([]), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + + if not _is_system_admin(db, current_user): + return _redirect_denied() + + role = db.execute(select(Role).where(Role.id == role_id)).scalar_one_or_none() + if not role: + return RedirectResponse(url="/system-settings/rbac/roles", status_code=303) + + old_permission_ids = db.execute( + select(RolePermission.permission_id).where(RolePermission.role_id == role.id) + ).scalars().all() + + db.execute(RolePermission.__table__.delete().where(RolePermission.role_id == role.id)) + for permission_id in permission_ids: + db.add(RolePermission(role_id=role.id, permission_id=permission_id)) + db.commit() + + write_audit_log( + db, + action="role.permissions.update", + entity_type="role", + actor=current_user, + request=request, + entity_id=role.id, + entity_name=role.name, + target_tenant_id=current_user.tenant_id, + target_branch_id=current_user.branch_id, + details={ + "old_permission_ids": list(old_permission_ids), + "new_permission_ids": list(permission_ids), + }, + ) + + return RedirectResponse(url="/system-settings/rbac/roles", status_code=303) + finally: + db.close() + + +@router.get("/permissions") +def permissions_list(request: Request, q: str = "", page: int = 1, per_page: int = 15): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + + if not _is_system_admin(db, current_user): + return _redirect_denied() + + payload = build_permissions_payload(db, q=q, page=page, per_page=per_page) + + return templates.TemplateResponse( + "modules/core/rbac/templates/permissions_list.html", + _base_ctx(request, current_user, db, title="RBAC Permissions", **payload), + ) + finally: + db.close() + + +@router.post("/permissions/new") +def permission_create_submit( + request: Request, + code: str = Form(...), + name: str = Form(...), + is_active: str | None = Form(None), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + + if not _is_system_admin(db, current_user): + return _redirect_denied() + + code = code.strip() + if code and not db.execute(select(Permission).where(Permission.code == code)).scalar_one_or_none(): + permission = Permission(code=code, name=name.strip(), is_active=is_active is not None) + db.add(permission) + db.commit() + db.refresh(permission) + + write_audit_log( + db, + action="permission.create", + entity_type="permission", + actor=current_user, + request=request, + entity_id=permission.id, + entity_name=permission.code, + target_tenant_id=current_user.tenant_id, + target_branch_id=current_user.branch_id, + details={"code": permission.code, "name": permission.name, "is_active": permission.is_active}, + ) + + return RedirectResponse(url="/system-settings/rbac/permissions", status_code=303) + finally: + db.close() \ No newline at end of file diff --git a/app/modules/core/rbac/ui_permissions.py b/app/modules/core/rbac/ui_permissions.py new file mode 100644 index 0000000..088b6fa --- /dev/null +++ b/app/modules/core/rbac/ui_permissions.py @@ -0,0 +1,493 @@ +from __future__ import annotations + +from app.modules.core.rbac.permissions_registry import expand_permission_codes + + +def _has(permissions: list[str] | None, code: str) -> bool: + granted = set(permissions or []) + for candidate in expand_permission_codes(code): + if candidate in granted: + return True + return False + + +def _roles(role_names: list[str] | None) -> set[str]: + return set(role_names or []) + + +def is_system_admin(role_names: list[str] | None = None) -> bool: + return "System Admin" in _roles(role_names) + + +def is_firm_admin(role_names: list[str] | None = None) -> bool: + return "Firm Admin" in _roles(role_names) + + +def can_view_own_alerts(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "alerts.view_self") or bool(user) + + +def can_manage_alerts(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "alerts.manage") and (is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names)) + + +def can_view_users(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "users.view") + + +def can_manage_users(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "users.manage") and ( + is_system_admin(role_names) or is_firm_admin(role_names) + ) + + +def can_view_settings(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "system.settings.view") + + +def can_edit_settings(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "system.settings.edit") + + +def can_manage_settings(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return is_system_admin(role_names) + + +def can_view_tenants(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return is_system_admin(role_names) + + +def can_manage_tenants(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return is_system_admin(role_names) + + +def can_view_branches(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return is_system_admin(role_names) or is_firm_admin(role_names) + + +def can_manage_branches(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return is_system_admin(role_names) or is_firm_admin(role_names) + + +def can_change_branch_tenant(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return is_system_admin(role_names) + + +def can_view_rbac(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return is_system_admin(role_names) + + +def can_manage_rbac(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return is_system_admin(role_names) + + +def can_view_audit(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "audit.view") and ( + is_system_admin(role_names) or is_firm_admin(role_names) + ) + + +def can_view_services(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "services.view") + + +def can_manage_services(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "services.create") or _has(permissions, "services.edit") + + +def can_manage_service_tasks(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "service_tasks.create") or _has(permissions, "service_tasks.edit") + + +def can_import_services(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "services.import") + + +def can_import_service_tasks(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "service_tasks.import") + + +def can_switch_service_tenant(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return is_system_admin(role_names) and _has(permissions, "services.cross_tenant") + + +def can_switch_service_branch(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "services.cross_branch") or can_switch_service_tenant( + user, permissions, role_names + ) + + +def can_view_clients(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "clients.view") + + +def can_manage_clients(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "clients.create") or _has(permissions, "clients.edit") + + +def can_export_clients(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "clients.export") + + +def can_switch_client_tenant(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return is_system_admin(role_names) and _has(permissions, "clients.cross_tenant") + + +def can_switch_client_branch(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "clients.cross_branch") or can_switch_client_tenant( + user, permissions, role_names + ) + +def can_view_consultants(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "consultants.view") + + +def can_manage_consultants(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "consultants.manage") and ( + is_system_admin(role_names) or is_firm_admin(role_names) + ) + + +def can_link_consultant_clients(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "consultants.link_clients") + + +def can_manage_consultant_service_requests(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "consultants.service_requests.manage") and ( + is_system_admin(role_names) or is_firm_admin(role_names) + ) + + +def can_manage_consultant_conversions(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "consultants.conversions.manage") and ( + is_system_admin(role_names) or is_firm_admin(role_names) + ) + + +def can_view_consultant_portal(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "consultants.portal.view") or "Consultant" in _roles(role_names) + + +def can_manage_own_consultant_workspace(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return can_view_consultant_portal(user, permissions, role_names) and _has(permissions, "consultants.workspace.manage") + + + + + + +def can_view_employee_dashboard(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "employees.dashboard.view") and ( + is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names) + ) + +def can_view_employees(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "employees.view") + + +def can_manage_employees(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "employees.create") or _has(permissions, "employees.edit") + + +def can_change_employee_status(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "employees.status") + + +def can_switch_employee_tenant(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return is_system_admin(role_names) and _has(permissions, "employees.cross_tenant") + + +def can_switch_employee_branch(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "employees.cross_branch") or can_switch_employee_tenant(user, permissions, role_names) + + + +def can_view_employee_portal(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "employees.ess.view") or bool({"System Admin", "Firm Admin", "Partner", "Branch Manager", "Staff"}.intersection(_roles(role_names))) + + +def can_edit_own_employee_profile(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return can_view_employee_portal(user, permissions, role_names) and _has(permissions, "employees.ess.profile.edit") + + +def can_view_own_employee_work(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "employees.work.view_self") or can_view_employee_portal(user, permissions, role_names) + + +def can_request_employee_registration(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "employees.registration.request") or can_view_employee_portal(user, permissions, role_names) + + +def can_approve_employee_registrations(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "employees.registration.approve") and ( + is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names) + ) + + + +def can_punch_employee_attendance(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "employees.attendance.punch") or can_view_employee_portal(user, permissions, role_names) + + +def can_view_own_employee_attendance(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "employees.attendance.view_self") or can_view_employee_portal(user, permissions, role_names) + + +def can_view_all_employee_attendance(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "employees.attendance.view_all") and ( + is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names) + ) + + +def can_approve_employee_attendance(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "employees.attendance.approve") and ( + is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names) + ) + + + +def can_apply_employee_leave(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "employees.leave.apply") or can_view_employee_portal(user, permissions, role_names) + + +def can_view_own_employee_leave(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "employees.leave.view_self") or can_view_employee_portal(user, permissions, role_names) + + +def can_view_all_employee_leave(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "employees.leave.view_all") and ( + is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names) + ) + + +def can_approve_employee_leave(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "employees.leave.approve") and ( + is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names) + ) + + +def can_manage_employee_leave_types(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "employees.leave_type.manage") and ( + is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names) + ) + + +def can_manage_employee_leave_balances(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "employees.leave_balance.manage") and ( + is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names) + ) + + + +def can_view_own_employee_documents(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "employees.documents.view_self") or can_view_employee_portal(user, permissions, role_names) + + +def can_upload_own_employee_documents(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "employees.documents.upload_self") or can_view_employee_portal(user, permissions, role_names) + + +def can_view_all_employee_documents(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "employees.documents.view_all") and ( + is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names) + ) + + +def can_manage_employee_documents(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "employees.documents.manage") and ( + is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names) + ) + + +def can_verify_employee_documents(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "employees.documents.verify") and ( + is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names) + ) + + +def can_manage_employee_document_types(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "employees.document_type.manage") and ( + is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names) + ) + + + +def can_view_employee_onboarding(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "employees.onboarding.view") and ( + is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names) + ) + + +def can_manage_employee_onboarding(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "employees.onboarding.manage") and ( + is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names) + ) + + +def can_approve_employee_onboarding(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "employees.onboarding.approve") and ( + is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names) + ) + + +def can_view_employee_offboarding(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "employees.offboarding.view") and ( + is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names) + ) + + +def can_manage_employee_offboarding(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "employees.offboarding.manage") and ( + is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names) + ) + + +def can_approve_employee_offboarding(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "employees.offboarding.approve") and ( + is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names) + ) + + +def can_request_own_employee_offboarding(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "employees.offboarding.request_self") or can_view_employee_portal(user, permissions, role_names) + + + + +def can_import_employee_hr(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "employees.import") and ( + is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names) + ) + +def can_manage_employee_payroll_structures(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "employees.payroll.structure.manage") and ( + is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names) + ) + + +def can_run_employee_payroll(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "employees.payroll.run") and ( + is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names) + ) + + +def can_view_employee_payroll(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "employees.payroll.view") and ( + is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names) + ) + + +def can_view_own_employee_payslips(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "employees.payroll.view_self") or can_view_employee_portal(user, permissions, role_names) + + +def can_approve_employee_payroll(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "employees.payroll.payout") and ( + is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) + ) + + + +def can_view_employee_progress(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "employees.progress.view") and ( + is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names) + ) + + +def can_manage_employee_work(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "employees.work.manage") and ( + is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names) + ) + + +def can_view_billing(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "billing.view") + + +def can_create_billing(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "billing.create") + + +def can_import_billing_fee_structure(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "billing_fee_structure.import") + + +def can_view_billing_fee_structure(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "billing_fee_structure.view") + + +def can_generate_billing_invoices(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "billing_invoice.generate") or _has(permissions, "billing_invoice.bulk_generate") + + +def can_view_platform_billing(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return is_system_admin(role_names) and _has(permissions, "platform_billing.view") + + +def can_manage_platform_billing(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return is_system_admin(role_names) and (_has(permissions, "platform_billing.create") or _has(permissions, "platform_billing.edit")) + + +def can_generate_platform_billing(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return is_system_admin(role_names) and _has(permissions, "platform_billing.generate") + + +def can_manage_platform_plans(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return is_system_admin(role_names) and _has(permissions, "platform_plans.manage") + + +def can_manage_platform_subscriptions(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return is_system_admin(role_names) and _has(permissions, "platform_subscriptions.manage") + + +def can_view_marketplace_leads(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "marketplace_leads.view") or _has(permissions, "marketplace_leads.view_assigned") + + +def can_create_marketplace_leads(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return is_system_admin(role_names) and _has(permissions, "marketplace_leads.create") + + +def can_assign_marketplace_leads(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return is_system_admin(role_names) and _has(permissions, "marketplace_leads.assign") + + +def can_update_marketplace_leads(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "marketplace_leads.update") + + +def can_convert_marketplace_leads(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "marketplace_leads.convert") + + + +def can_view_documents(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "documents.view") + + +def can_upload_documents(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "documents.upload") + + +def can_download_documents(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "documents.download") + + +def can_delete_documents(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "documents.delete") and (is_firm_admin(role_names) or "Partner" in _roles(role_names)) + + +def can_view_notice_cases(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "notice_cases.view") + +def can_manage_notice_cases(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "notice_cases.create") or _has(permissions, "notice_cases.edit") + +def can_upload_notice_case_documents(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "notice_cases.documents.upload") + +def can_download_notice_case_documents(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "notice_cases.documents.download") + +def can_delete_notice_case_documents(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool: + return _has(permissions, "notice_cases.documents.delete") diff --git a/app/modules/core/tenancy/__init__.py b/app/modules/core/tenancy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/core/tenancy/api.py b/app/modules/core/tenancy/api.py new file mode 100644 index 0000000..1b01e21 --- /dev/null +++ b/app/modules/core/tenancy/api.py @@ -0,0 +1,15 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session +from sqlalchemy import select +from app.core.db.deps import get_common_db +from app.modules.core.tenancy.models import Tenant, Branch + +router = APIRouter(prefix="/tenancy", tags=["tenancy"]) + +@router.get("/tenants") +def list_tenants(db: Session = Depends(get_common_db)): + return db.execute(select(Tenant).order_by(Tenant.id)).scalars().all() + +@router.get("/branches") +def list_branches(db: Session = Depends(get_common_db)): + return db.execute(select(Branch).order_by(Branch.id)).scalars().all() diff --git a/app/modules/core/tenancy/models.py b/app/modules/core/tenancy/models.py new file mode 100644 index 0000000..3abfc78 --- /dev/null +++ b/app/modules/core/tenancy/models.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from datetime import date, datetime, time + +from sqlalchemy import String, Boolean, Integer, Time, Date, DateTime, ForeignKey, UniqueConstraint, Text +from sqlalchemy.orm import Mapped, mapped_column +from app.core.db.common import CommonBase + + +class Tenant(CommonBase): + __tablename__ = "tenants" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + code: Mapped[str] = mapped_column(String(50), unique=True, index=True) + name: Mapped[str] = mapped_column(String(200)) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + firm_type: Mapped[str] = mapped_column(String(30), default="proprietorship") # partnership|proprietorship|individual + + # Tenant-level defaults + default_timezone: Mapped[str] = mapped_column(String(64), default="Asia/Kolkata") + default_session_duration_minutes: Mapped[int] = mapped_column(Integer, default=480) + default_otp_required_roles_csv: Mapped[str] = mapped_column( + String(200), + default="Partner,System Admin", + ) + default_storage_mode: Mapped[str] = mapped_column( + String(20), + default="local_only", + ) # local_only|cloud_only|hybrid + + # Phase 7Q.2 - firm branding defaults + display_name: Mapped[str | None] = mapped_column(String(200), nullable=True) + logo_path: Mapped[str | None] = mapped_column(String(500), nullable=True) + favicon_path: Mapped[str | None] = mapped_column(String(500), nullable=True) + primary_color: Mapped[str | None] = mapped_column(String(20), nullable=True) + accent_color: Mapped[str | None] = mapped_column(String(20), nullable=True) + website_url: Mapped[str | None] = mapped_column(String(255), nullable=True) + contact_email: Mapped[str | None] = mapped_column(String(255), nullable=True) + contact_mobile: Mapped[str | None] = mapped_column(String(50), nullable=True) + + +class Branch(CommonBase): + __tablename__ = "branches" + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_branch_tenant_code"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + tenant_id: Mapped[int] = mapped_column( + ForeignKey("tenants.id", ondelete="CASCADE"), + index=True, + ) + code: Mapped[str] = mapped_column(String(50), index=True) + name: Mapped[str] = mapped_column(String(200)) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + + timezone: Mapped[str] = mapped_column(String(64), default="Asia/Kolkata") + office_start_time: Mapped[time | None] = mapped_column(Time, nullable=True) + office_end_time: Mapped[time | None] = mapped_column(Time, nullable=True) + + # Branch control flags + allow_login: Mapped[bool] = mapped_column(Boolean, default=True) + allow_new_assignments: Mapped[bool] = mapped_column(Boolean, default=True) + is_head_office: Mapped[bool] = mapped_column(Boolean, default=False) + + # SMTP (credentials here; policy in BranchSettings) + smtp_host: Mapped[str | None] = mapped_column(String(255), nullable=True) + smtp_port: Mapped[int | None] = mapped_column(Integer, nullable=True) + smtp_username: Mapped[str | None] = mapped_column(String(255), nullable=True) + smtp_password: Mapped[str | None] = mapped_column(String(255), nullable=True) + smtp_use_tls: Mapped[bool] = mapped_column(Boolean, default=True) + + # Local storage root for branch PC + local_storage_path: Mapped[str | None] = mapped_column(String(500), nullable=True) + +class FinancialYear(CommonBase): + __tablename__ = "financial_years" + __table_args__ = ( + UniqueConstraint("tenant_id", "year_code", name="uq_financial_year_tenant_code"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + tenant_id: Mapped[int] = mapped_column( + ForeignKey("tenants.id", ondelete="CASCADE"), + index=True, + ) + year_code: Mapped[str] = mapped_column(String(9), index=True) # e.g. 2025-26 + assessment_year: Mapped[str] = mapped_column(String(9), index=True) # e.g. 2026-27 + start_date: Mapped[date] = mapped_column(Date) + end_date: Mapped[date] = mapped_column(Date) + is_current: Mapped[bool] = mapped_column(Boolean, default=False, index=True) + is_locked: Mapped[bool] = mapped_column(Boolean, default=False, index=True) + locked_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + locked_by_user_id: Mapped[int | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow) + updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow) + + +class YearBackupExport(CommonBase): + __tablename__ = "year_backup_exports" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + tenant_id: Mapped[int] = mapped_column( + ForeignKey("tenants.id", ondelete="CASCADE"), + index=True, + ) + financial_year_id: Mapped[int] = mapped_column( + ForeignKey("financial_years.id", ondelete="CASCADE"), + index=True, + ) + year_code: Mapped[str] = mapped_column(String(9), index=True) + assessment_year: Mapped[str | None] = mapped_column(String(9), nullable=True, index=True) + export_status: Mapped[str] = mapped_column(String(30), default="completed", index=True) + export_file_path: Mapped[str] = mapped_column(String(1000)) + file_size_bytes: Mapped[int] = mapped_column(Integer, default=0) + manifest_json: Mapped[str | None] = mapped_column(Text, nullable=True) + generated_by_user_id: Mapped[int | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + generated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow) diff --git a/app/modules/core/tenancy/services.py b/app/modules/core/tenancy/services.py new file mode 100644 index 0000000..ed87174 --- /dev/null +++ b/app/modules/core/tenancy/services.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.modules.core.iam.scope import ( + UserScope, + list_visible_branches, + list_visible_tenants, +) +from app.modules.core.tenancy.models import Branch +from app.modules.core.iam.services import paginate_list + + +def build_tenants_payload( + db: Session, + scope: UserScope, + q: str | None = None, + page: int = 1, + per_page: int = 10, +) -> dict: + tenants = list_visible_tenants(db, scope) + query = (q or "").strip().lower() + + if query: + tenants = [ + t + for t in tenants + if query in (t.name or "").lower() + or query in (t.code or "").lower() + ] + + paged = paginate_list(tenants, page=page, per_page=per_page) + + return { + "tenants": paged.items, + "tenants_page": paged, + "filters": { + "q": (q or "").strip(), + "per_page": paged.per_page, + }, + } + + +def build_branches_payload( + db: Session, + scope: UserScope, + q: str | None = None, + tenant_id: int | None = None, + page: int = 1, + per_page: int = 10, +) -> dict: + if scope.is_system_admin: + effective_tenant_id = tenant_id + + if effective_tenant_id is None: + branches = db.execute( + select(Branch).order_by(Branch.name) + ).scalars().all() + else: + branches = db.execute( + select(Branch) + .where(Branch.tenant_id == effective_tenant_id) + .order_by(Branch.name) + ).scalars().all() + else: + effective_tenant_id = scope.actor.tenant_id + branches = list_visible_branches(db, scope, effective_tenant_id) + + query = (q or "").strip().lower() + if query: + branches = [ + b + for b in branches + if query in (b.name or "").lower() + or query in (b.code or "").lower() + or query in (b.timezone or "").lower() + ] + + paged = paginate_list(branches, page=page, per_page=per_page) + tenants = {t.id: t for t in list_visible_tenants(db, scope)} + + return { + "branches": paged.items, + "branches_page": paged, + "tenants": tenants, + "filters": { + "q": (q or "").strip(), + "per_page": paged.per_page, + "tenant_id": effective_tenant_id, + }, + } \ No newline at end of file diff --git a/app/modules/core/tenancy/settings_models.py b/app/modules/core/tenancy/settings_models.py new file mode 100644 index 0000000..c899322 --- /dev/null +++ b/app/modules/core/tenancy/settings_models.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from sqlalchemy import String, Boolean, Integer, ForeignKey, UniqueConstraint, Text, Float, Time +from sqlalchemy.orm import Mapped, mapped_column +from app.core.db.common import CommonBase + + +class BranchSettings(CommonBase): + __tablename__ = "branch_settings" + __table_args__ = ( + UniqueConstraint("branch_id", name="uq_branch_settings_branch"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + branch_id: Mapped[int] = mapped_column( + ForeignKey("branches.id", ondelete="CASCADE"), + index=True, + ) + + # Identity + address_line1: Mapped[str | None] = mapped_column(String(255), nullable=True) + address_line2: 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) + pin_code: Mapped[str | None] = mapped_column(String(10), nullable=True) + gstin: Mapped[str | None] = mapped_column(String(20), nullable=True) + pan: Mapped[str | None] = mapped_column(String(10), nullable=True) + + letterhead_logo_path: Mapped[str | None] = mapped_column(String(500), nullable=True) + letterhead_signature_path: Mapped[str | None] = mapped_column(String(500), nullable=True) + letterhead_stamp_path: Mapped[str | None] = mapped_column(String(500), nullable=True) + + # Geolocation and attendance controls + geo_address: Mapped[str | None] = mapped_column(String(500), nullable=True) + latitude: Mapped[float | None] = mapped_column(Float, nullable=True) + longitude: Mapped[float | None] = mapped_column(Float, nullable=True) + attendance_geo_enabled: Mapped[bool] = mapped_column(Boolean, default=False) + attendance_geo_radius_meters: Mapped[int] = mapped_column(Integer, default=100) + attendance_ip_enabled: Mapped[bool] = mapped_column(Boolean, default=False) + attendance_allowed_ip_csv: Mapped[str | None] = mapped_column(Text, nullable=True) + + # Working calendar and attendance timing rules + working_days_csv: Mapped[str] = mapped_column(String(50), default="MON,TUE,WED,THU,FRI,SAT") + holidays_json: Mapped[str] = mapped_column(Text, default="[]") + timezone_locked: Mapped[bool] = mapped_column(Boolean, default=False) + attendance_grace_minutes: Mapped[int] = mapped_column(Integer, default=10) + attendance_half_day_after_time = mapped_column(Time, nullable=True) + attendance_rule_enabled: Mapped[bool] = mapped_column(Boolean, default=True) + + # Email policy + email_from_name: Mapped[str | None] = mapped_column(String(200), nullable=True) + email_from_email: Mapped[str | None] = mapped_column(String(255), nullable=True) + email_reply_to: Mapped[str | None] = mapped_column(String(255), nullable=True) + default_cc_csv: Mapped[str | None] = mapped_column(String(500), nullable=True) + default_bcc_csv: Mapped[str | None] = mapped_column(String(500), nullable=True) + email_signature_html: Mapped[str | None] = mapped_column(Text, nullable=True) + + # Phase 7Q.2 - branding / billing presentation defaults + invoice_footer_text: Mapped[str | None] = mapped_column(Text, nullable=True) + bank_name: Mapped[str | None] = mapped_column(String(200), nullable=True) + bank_account_name: Mapped[str | None] = mapped_column(String(200), nullable=True) + bank_account_number: Mapped[str | None] = mapped_column(String(50), nullable=True) + bank_ifsc: Mapped[str | None] = mapped_column(String(20), nullable=True) + upi_id: Mapped[str | None] = mapped_column(String(100), nullable=True) + + # Storage policy + storage_mode: Mapped[str] = mapped_column(String(20), default="local_only") + folder_template: Mapped[str] = mapped_column( + String(500), + default="{root}/Clients/{client_code}/{fy}/{service}/", + ) + max_file_mb: Mapped[int] = mapped_column(Integer, default=25) + allowed_ext_csv: Mapped[str] = mapped_column( + String(500), + default="pdf,jpg,jpeg,png,xlsx,xls,docx,zip", + ) + retention_years: Mapped[int] = mapped_column(Integer, default=8) + + # Security policy + otp_required_roles_csv: Mapped[str] = mapped_column( + String(200), + default="Partner,System Admin", + ) + session_duration_minutes: Mapped[int] = mapped_column(Integer, default=480) + lockout_attempts: Mapped[int] = mapped_column(Integer, default=5) + lockout_minutes: Mapped[int] = mapped_column(Integer, default=15) \ No newline at end of file diff --git a/app/modules/core/tenancy/year_control.py b/app/modules/core/tenancy/year_control.py new file mode 100644 index 0000000..69ec0e6 --- /dev/null +++ b/app/modules/core/tenancy/year_control.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import Request +from fastapi.responses import RedirectResponse +from sqlalchemy import select + +from app.modules.core.tenancy.models import FinancialYear + + +def active_financial_year_from_request(request: Request) -> str | None: + session = request.scope.get("session") or {} + value = session.get("active_financial_year") or getattr(request.state, "year_code", None) + value = (value or "").strip() + if not value or value.upper() == "ALL": + return None + return value + + +def get_financial_year(db: Any, *, tenant_id: int, year_code: str | None) -> FinancialYear | None: + code = (year_code or "").strip() + if not tenant_id or not code: + return None + return db.execute( + select(FinancialYear).where( + FinancialYear.tenant_id == int(tenant_id), + FinancialYear.year_code == code, + ) + ).scalar_one_or_none() + + +def is_financial_year_locked(db: Any, *, tenant_id: int, year_code: str | None) -> bool: + fy = get_financial_year(db, tenant_id=tenant_id, year_code=year_code) + return bool(fy and fy.is_locked) + + +def redirect_if_financial_year_locked( + db: Any, + *, + tenant_id: int, + year_code: str | None, + redirect_url: str, +) -> RedirectResponse | None: + if is_financial_year_locked(db, tenant_id=tenant_id, year_code=year_code): + separator = "&" if "?" in redirect_url else "?" + return RedirectResponse(url=f"{redirect_url}{separator}year_locked=1", status_code=303) + return None + + +def is_row_financial_year_locked(db: Any, row: Any) -> bool: + tenant_id = getattr(row, "tenant_id", None) + year_code = getattr(row, "financial_year", None) + if not tenant_id or not year_code: + return False + return is_financial_year_locked(db, tenant_id=int(tenant_id), year_code=str(year_code)) diff --git a/app/modules/documents/__init__.py b/app/modules/documents/__init__.py new file mode 100644 index 0000000..d6f5d58 --- /dev/null +++ b/app/modules/documents/__init__.py @@ -0,0 +1 @@ +"""Engagement document streaming module.""" diff --git a/app/modules/documents/agent_package.py b/app/modules/documents/agent_package.py new file mode 100644 index 0000000..d708812 --- /dev/null +++ b/app/modules/documents/agent_package.py @@ -0,0 +1,527 @@ +from __future__ import annotations + +import base64 +import io +import zipfile + +# Embedded LSA4 tunnel-enabled Task Scheduler local storage agent package. +# Uses Windows Task Scheduler instead of pywin32 Windows Service. +# This is used by the ERP Branch Storage Dashboard to generate branch-specific +# pre-configured agent ZIP files with .env already filled. +LSA4_ZIP_B64 = """ +UEsDBBQAAAAIAKqIuFw8Po1A0QAAAD4BAAAMABwALmVudi5leGFtcGxlVVQJAAPQLxNq0C8TanV4CwABBAAAAAAEAAAAAG2MwWoCMRBA7/mX1GwLtSzkMLuZ +1oWY2Mms7UEI0gbdg1Vi/H9XrLQHGZjDvHkPaREbCBh7snpbyqGeTKrH6YMap6pflFLCeYOxHZduCFw7k4E9wRtKpaorDNgSsj6sjyXJ/U+SZdgleUxfORW5 +TTmJXyWS96xNvVrB6Xsor0PehbLP600SC29t7BwjLcFeit6ZoJ+UMP7DWQ8mEr73GPju0wyBuEG4Q5+VuJnczdH3/B/N4XMMM3U4dgT3zqGN6KCxaHTJp3S7 +0cVx2P7ZlRJnUEsDBBQAAAAIAKqIuFxSLOFFWAIAADoEAAAKABwAUkVBRE1FLnR4dFVUCQAD0C8TatAvE2p1eAsAAQQAAAAABAAAAAB9U8tu2zAQvOsrFsjN +gJjmcQrQg5OobQDHMSQVQYsAFkWuLcI0VxXJPP6+Sykx0CCpDgZlDWdnZ2ePYB61CfDNDHuoAg1yizDfoguQQy39DirVoY4WBygYaMhlWd0ZD71UuwQeovMQ +OgRLSlrwrxxy5IjeuC3cG6fpyb/nM84HlBpoA/3Lk3FnpwdkhcOjUSiy7OiIX0LsgdyBW9G+jwGH7ERA8RwGqcKo4PfNCgKBhI15Rg0bsprL+Kg6kB6a4uJh +bDb1+trq2Ol6XpZl/uUkr+q7cv69aER2KqA02y7kyhq1gyZJldauA3ew9m8diFaGBqTToDoijzCbldGlWlLvjTOepXGd2UxkZwJqVpjug5N7BHaw+VBNI+Am +TK7KcDCEyw/JBeauflV1cSuycwFXHSZ1lrb+YTRc8HFSlPwoyhVcDtJx/2+jvZa+a0kOyZ0B7paLm2WRyEP0k9m30kUeI1u8Zxqf5XyVS4OjpwtoRhkfuTDi +qD/AqP8UlWpNVHz4BFXinh6RUdH9x3oGXmMbt6zWebJjFvkO/65HN9Y6fZ2g2ZIOKdPYo9Po1Esaw4B/ohk4L8axa/wHTjlnPxbV/Bzq6BxauCWNWf7u+XcV +iGl5ag4ohpYiT+Ee24rUDjmfEwvH81iTinuW549fA52Pao8niBjHpgk9m8m+46hsopNKofeJJI23nUa7uhK8vtw9p835TUp8MNby7jGFjIx0wSgZmOdHXa8q +4N57MlxfpNWC+udyWSzWxXJ+uSiuv26k5SRzCT5YaLm19MJwXkkORk/W8k6L7C9QSwMEFAAAAAgAqoi4XJHTLtOTAwAAOQcAACMAHABSRUFETUVfTFNBMV9M +T0NBTF9TVE9SQUdFX0FHRU5ULnR4dFVUCQADzy8Tas8vE2p1eAsAAQQAAAAABAAAAACNVe1uGzcQ/M+n2AfI3fkjTgsjDqDKahLAsQSd0vSHAYG6W+mYnMgL +P2wrQd+9Q95Jllu0iQQQFLk75C5nRqNQK0+/K7ulG1PJlkpvrNwwjTasPWV0U45O6cMfMyFmwXbGscj6j1g0ypHzUteyNZppZaWumkxprLUt1yQTRGW05so7 +8oZ8A+B04mQ+o+vyPLsuL4CRjsz6eNZ1Z5T2LhfvPXWhbR11WFR6Q7WpwjYGDSn02awcra3ZJuiqNaGO0C9SAGNLtRiVTtv9BalNdQ4IYm3amu0LktUXbR5w +7Q0Synejs4tX1EjXFBGCnPqGfMTEKtIBKJvWocWuo1hEbR50a2RNlr8Gdqh3taPQxSXcXAyr6Ep/fH+xI8Rc9A3tsBQrqw32tfHA61pZMRlLW1Or9Q5H71L/ +Oms+o7MJC+kl+9D1ryNOcxqbboeyAdmXuO//0IV9Byuz7YJnWzi292xzcYZUy9IzSZrtfGM03SvrAy7NGjOj4wtcCiLq+u1sS/fYojyOcT1N7srKqs67O1l5 +dQ88cZ7T+54cqUnKckRyPZTqaGAOZfbZfu4fvXg5FJQDOedHue3wKqgo/k5vwWBVQkJnlr+Nysny4/zmqvG+uyyK07Nf8hN8Ty9/PTk5ia3cmWCf+EKIjbm3 +0+vJcozh6nUiVWLpwDlH2ZuDOm5Nze7NIaWcjOeTxdVryCDzassEJrOV8bkdV5Z9Ci0X0/no7WQ5n04XV9eXd0kJUXoDqrjIaR40GV3xP/orY+hyeLNlEkq+ +leB1lsVo8arPhNa80sEE1+5+EkGIWORBdBQc13uJH33EbFouqNgL0BXPRFs0LK1fgTXi7WRB/x0X9VoMav6Z0O8Yl6r+q9jL6wf3eJ4EKf3gkD1stlft4XL/ +f86/874Ps3Rwr3tIUq7Z74Z2iow+Ooj6zyyyJxtjSMwdfpeJKIROQqwwvyyR7eBhRrfwEx2FfMyjFCe18jAoB9m0EmJjuIhvBmOMz7viNaDowSIONpoQY+a4 +F//B8Ia4Iy+MxcfIT0hFXGs2ycjzIs6KnkaYwn5u+RF2Dctkd8wfgT+QM3pQ0DXYCYfxvSmBbAYO80lp9BIXgPsouFyxd4FoRUg971NlXZPpvDIaNuQD/lHa +oo2FOg+v2qKoFP3yKfrdh9G4cGqjpQ+Wi5oT/Eql103BF0/BLqxcciscQGtOKVQ1XH1x6Ym+BuMlRILuVENL/gZQSwMECgAAAAAAq4i4XAAAAAAAAAAAAAAA +ABQAHABhdWRpdF9zdG9yYWdlX2FnZW50L1VUCQAD0S8TatEvE2p1eAsAAQQAAAAABAAAAABQSwMECgAAAAAAqoi4XGtoY4klAAAAJQAAAB8AHABhdWRpdF9z +dG9yYWdlX2FnZW50L19faW5pdF9fLnB5VVQJAAPPLxNq0S8TanV4CwABBAAAAAAEAAAAAF9fdmVyc2lvbl9fID0gIjAuMy4wLXRhc2stc2NoZWR1bGVyIgpQ +SwMEFAAAAAgAqoi4XGFbMtTgAgAA8AsAAB0AHABhdWRpdF9zdG9yYWdlX2FnZW50L2NsaWVudC5weVVUCQADzy8TatEvE2p1eAsAAQQAAAAABAAAAADlVktv +nDAQvvMrLE4gsRCpt5W2UhX10EsVtb1FkeWFYdcJ2NQ2TVaU/96xebNRu22lRGo57MK8PPN9MwO5kiWhNK9NrYBSwstKKkOYENIww6XQnpdbm4qZY8H3g8EN +PnYKc6q4OAzyd+IUkQ8GFNsX4PVCBV9r0GYIFadS5HxyOYAw107keV5aMK3J+0831wVH+dYjeGWQY5JccENpoKHII9LF2M69w87YXtZmOGbX2y6VGrTG8lA7 +ZBd/7kRB+KxlfASWgdJxXWXMQNAFHaShNyVaq6JP0oK2JdqokGze2v8pQwWIuCC538xyjUFVdM802BhtY91bf4qMZymzB2bG8KdCsmxLMp6aWwwfWfzv3GFL +0fxcXSGrgIUvqqukNsFoNRbvivGTTKZ1iUDrRBup2AE2zOKejBn5YbRwvtdS7Pr8lhrDS5C12c3L7imgvY5qQEWmJ8fwLP9YMY445VJRjZ1a6yBcYzua2mSC +kPB8EmF8gwUQKBCKxu9C+Fviywe/nRCvQGTY3bSvmt7LvXbgO4wLrs3tEugLkD7AHwBtD076bNZYvwCi2PHMjcoC0VGNyHLNBbqJFAJrHDlwZhM548Xq11xZ +mUPGt5X6Ebm9m41UJh+F7aQ5D/0M4B3lOAIcyfzuRu2vGMh/QUHTndcmQ0prMjADYOXui6rhtft+wo+lD0I+FpAhdBdA+HKb5WK4sYJ/fMWMPT68j15lzwxZ +bIYs/o+lM1S7Xjx15ShZc0NzXkA/PYNoNUHWgnbvf/utFBF4Mor91kA9cnOc4sQSmcBM935ImCZHJrIClqVaW42INb69w1YLJm/BSoh6r4j4rKoKnrovvESm +BsymW1x+2C5C9hw0D3ByHzLBN1bUEBIkjKAsIu4ZC+/qi7mBUneD0Gs0wU9J8lEKaFe8XLoulr37k5Vx3rzNxE6bdGSuG3kEbud+z5UWgp1rrzPVMAQlewou +GQTy5upqdfwLr50fUEsDBBQAAAAIAKqIuFxm9F9AyQQAAOYNAAAdABwAYXVkaXRfc3RvcmFnZV9hZ2VudC9jb25maWcucHlVVAkAA9AvE2rRLxNqdXgLAAEE +AAAAAAQAAAAAvVZRc+I2EH7nV7h+sqeJk5vr3AMztEfA12OGmNSY681kMhqBRaKekVxJDkkp/70r2dgCm+StPGB79e1qd7/VateCbxyE1oUqBEHIoZucC+Vg +xrjCinIme71KxmVvrdEpVniVYSmJPMBrUYkoRJbRZZBjIckBAjLCVjwlJSTH6gkwh9U7+Kysc0XY80GecZyiUtTr9T7X+3iA/YewQSIK4veMyBk+EqZGnK3p +Y7/nwI+IHC2xJAj27jtSCSNl4APSjpyIJFkJohqhVFzgR4IE5yA1DmoxuIKZQjQ1SOdfJ+KMOAPzMIClwGz19AYg51mGKFNEPONMb8tZKvsOSAD18dpgUr5l +JnZB/i6IVO/hnwgWaknweeCnEniwp+iG8EKdQW3wC+ysBCXNRmX0BWMkQ5CDZUYgwiXnGSxqGux1oa0ysmrZ/3DdM7jPueA5Eeq1jJasdQQpEdKTJFv7zuWv +TkpX6h4yeKHT+FAyWkYApcqcXS3QP/f7ZQQkXo7gz4XEg5GgJvqiEzo3fB+ByxI4gS8kEZemtgDrDouUqi9UbOZleZiFq+vgl+DabRT356Ks8gMF2QQK4TXR +6XqFPBmf7PoNBMBo7rlXrl+D6drgA6mAfbml6slzn5TKZf8KYP2jOLYSVbbdrTQA52ejfQ/n8kjtodYj2bkd3tug037bvCTnbOhHvQQlK15BWPcQb+fW7Lbp +dlyLzQ6C9/5pMa3dXbX1/irlq2IDpMqrqgNcYsNxSd1vO+PM3oV2pAlFj0QhfQo8hjdlS7nQTOMiU+XxMBzrlzJUgbcQCZcBKEJXM2qlP5BrvUhl2TG40MY8 +EPlByb3vDCC5busoVNv1LNGpZpDxLRFggcLJcT+4kCMFZ1Y/X4k0D/3Hmbu3A4ND2xkXyE1Y8PxfolLitbWufdNmylb/siK5cr7hrCChELALllpoaWEKhRUX +THc+A/HW7oRBp6SptkUeiXDWoLjTvu/7zg6M/yT2ru+YewmMVZkxfXll7hkPYkVrmpGOXm8S1LqTIB21Tu2bdcvVFqu4jo6IjfN7rTvuiAE3jO/QzXAeokU8 +1dy6fquH1EfmWDOajUM0gr9KreLp9KrsUJqHozhMOtTsyxSdFos7T2bx8PcQxbNZl3J95R5rJWE0jBI0GYOKJR4uxpMEfZnEt+USWGtKDfhtX9THZm/iYTT6 +Wuu2VMsLkkpJ2SNo3j8ceIWJ6XjkqHmr0AHOc8LSE2p8W78ZTs4qN+y0NasZ5m3diqQj7VN+zps44qq2UaHePG7ubZU0PYNQQVKnPESFMIMmXK5wY+ikB39x +yrzKpF8Vuu0hpF3PY96p135AXnLM0kLqXhcIInn2TDpKMNj8SKnwYEDVjd5MkRdwwikMRvxHNVTa7dQ6xl5zfVlcD+yPZg6o6Rx0DCMWY4PO8cP2eGB/NJD6 +aAzqt2axLvBB/dYsdk6hg7rru3ez6RRNoiSMvw2numZm0XgO7Hy89hsj746plsHx7M9oOhuOURz+sQjnybvGz8+0ltWv4TBObsJhp7lPtrkzk69l6+BYMrkN +Z4vknCFrOLaUb4ffIbIknoQmEAt/PDMPmonBTRZRFE4R9LGbaaj7jam8lmZrmra2rUzE2tUoHNlOfzg47ff+A1BLAwQUAAAACACqiLhciumkADYCAABCCQAA +GQAcAGF1ZGl0X3N0b3JhZ2VfYWdlbnQvZGIucHlVVAkAA88vE2rRLxNqdXgLAAEEAAAAAAQAAAAA7VVLb5wwEL7zK0Z7AolyaNUeVkorkrrVqnR3xZIqOVle +MI0bsDe20ab59TXmXTZK1UOiSrUQ0ni+mfnmBbkUJWCcV7qSFGNg5UFIDYRzoYlmgivHae/UXcE0fePktcmB6JuC7Tv81oiNIiOaalbSTtPJPtTvB8Gp4zhp +QZSCSKSk+Hi+dMCcjOaGB+NMY+wqWuQ+ZHtch1la714Ds9DmPjgQSbkOytuMSbcR1FkiKxOL3jOlsbi1otdb1n6D1hzOOkdTteXgek7PKhWc01RbUiMW9bXx +0VYlGKO6EN4EHEhxxDlJtZA/R4axOPYwSU0buEUP8RtCNjq8eg9rU8KBxZGZTGzMjoAHRFkPA6hnQO9pWmnqTjT1WSwWs7uLGIUJgiQ8jxCsPsF6kwC6Wu2S +HRykSKlSNMPKZEO+U/xD7BXM/dbHqDDLIEFXCWzj1dcwvoYv6No/CS7qmWj6Yw3qoOvLKDqNVjfk9dt3f4LMWUGxYg8UVusEfUbxE/ghQ6Kn/mdw78lqes/R +iUwceSFIhiW9q6jSj7WjVf9vyejYlqSiLKebL2kq5GTG2y9TM9JLUFr6o/q0F00JWqHPcgmM6+fd4NV6h+IENjHEaBuFF6iu9OaR7Z1Zu02W4wS73EZp+ZO+ +zAv/LYwu0Q7cDz4Mz8n+zLvumgq2LLzTNExJ3Z6KwXR/m4CLo9v9cIJKp17AlMiFLImpr+f/9Sz8vmXtQAxL9e8PxexDMu/LkO5LT8fA5KUm5BdQSwMEFAAA +AAgAqoi4XDIlztSHAQAAPwMAAB0AHABhdWRpdF9zdG9yYWdlX2FnZW50L2xvZ2dlci5weVVUCQADzy8TatEvE2p1eAsAAQQAAAAABAAAAACVUttKxDAQfc9X +hMJCCmtdBEGE+qCwKCyrqO8httNu2DYpydQL+PFOs8naBV8spO3MnDNnLmmc7bmUzYijAym57gfrkCtjLCrU1njGoq+zbatNy5qJEo1ip0zdgfOJ+Bxopl3r +Du4PsQNhULjr9FvCPZHJGKuh4R5wHOSUEJx4Ux5krd11QPBvvrUGeBk+OT+7OQpvAv6acXqctUiYxOXWBXZRfdQiDwhi+RAqD+Bznk2u7CRY9Ht6i0E5MOjL +VzfCksOn9ijtPpg5SwTSplypmBbwUI/I1FhrlB6tUy1IOgazfMYqqN0NvEMnEvlhu348QaSZFlUHyoko2ljXK8QT3XXyiWwhlK9Q95B7mtpCdJOGUUd79tuD +91RY7rOYuqI12w5miV/QgerjAuMMI2pq4Ff3WNVJB6quEzeyUhN0LWTsj+T+uC3iuCraURhfQZ5syXv1efuF4MsLuVqtprOkjVf7cbizo8HyknZlKltTujIb +sTm7inOfa/6z+Dk1duDotjoTsewHUEsDBBQAAAAIAKqIuFzrTm6iUQIAAH0FAAAbABwAYXVkaXRfc3RvcmFnZV9hZ2VudC9tYWluLnB5VVQJAAPQLxNq0S8T +anV4CwABBAAAAAAEAAAAAH1U22rcMBB991cMhoIXsm77WnAhbVMoLSUkeRdaa+yIypLRJY3/vqOL7c2tfvHunDNnLjryYM0EjA3BB4uMgZxmYz1wrY3nXhrt +qmqN2XHm1mE1xJyZ+3slT2vCNf1diW6hpERqeyVR+5V0dXP9NQVW1OhBjiuqDBcshwouNvlfpufq25cSV2Yc0a6YQx9mlmOF4Bbdr/CtN5aPeDnudX3QGtVr +hLuEbBNHHWmqqhI4wClIJVhagW0OcPy8baS9tGOYKP06gZ8qoCcToXuL1Qh0vZVzXHJXXwYhPXyXdsqzrl1BaguOcMfdH7jt71EERbI3sVFbH85KtVwIxkuN +pj4eUT/UF3CPau7qeD7gDbQUhEEqJISG4kH57rfR+F8ho/vI533u1VFryLwNuMlTO0AqkBbfL71CspAAfJS+9GjplKwuFcpGJy513qTU/tnWnm47YdSSI6Q0 +mV6xTVfg4oruiSGaOHjb/xXNIbO8XXKl+BQDdufea6JkXNNho5EPu9WCZ4LwHmrBPa/jDx7PiRxb72nF/N3u+yaX2CkpixjnHiyki5JP53S6KMPtiXJI62jj +0ezzbJKtDZpFrNlTUFFS1i43gKHmJ4XimUB2fZRoXt6NJukfUoGBfPAQj+e8iHuzn42+4cUUH1IAH3ucPfzE5WS4FT+0R2vD7He5vIRW6sE0db4Y5MV5RgGn +BYLbLsRb2lfpRSamIWPshTSuhFV/4DIuCN458jqhL/Q/kpdpr4xpPsUPaNdBzVh0NmN11rdcOoTbxXmcruhGNNn3h+ofUEsDBBQAAAAIAKqIuFxT7nvCSQMA +AOQHAAAeABwAYXVkaXRfc3RvcmFnZV9hZ2VudC9zdG9yYWdlLnB5VVQJAAPPLxNq0S8TanV4CwABBAAAAAAEAAAAAI1VTY/bNhC961dMeaKyNncTtAFiwAFS +YFv0kDRIcupmS9AWZROWSIGk1rtBfnxnSMkr2QEaHWzNJ/neG1K1dy1IWfex91pKMG3nfARlrYsqGmdDUQy+vQr7xmxG04XxzeuipjadipQw9viIZg7Ep87Y +3ej/3Vjln/76uyjk53d/3MrPt3++v/3wBdbYSGxd25lGc8/u/n23/Ectv90s3wgJy/srVhZFUekagrImmm9aBr1rtY38QTW9XkGIfgG1apqN2h6SiT2Zibpl +JSzfkmNVAD4pH2O5EJwHxkqBYdPxUnjdNWqrObtmC2CSTTxfvw6uWZsZDBH6DceURQ7PM9P/sBADMTbyGtm3cNrNiOGEt9bS6wb1eNCSWOZeHdNLRvkdPjir +n7HLGim0qs2cJOwkRgbPGPuU11OpM4ydk34L6Lx+QFJJMHJA9OpB+6AacH0MptLY03m1w0LnosB2qa2p4bSp5KCnUz4GxH13f3LViI/cYGzaFMcdnuBcUH3N +ylKkLs9Nh9XGJpxRokg/gpXzPHq2jtD0ehZIPYXqOm0rfjFQFC3L4my1sz0MqiUQL1J8pmYGd975QiLcduW2PQXFxljEO6judXAN6t3bSntJXPOB+GSs0gIL +mM1Fdp4JHpXf6UjjPq2H63kpUZ8W5AMKylnDtOQsA1lxQVAtndrW2TSZd0lPzC4XNHw8r17el/DLGk6xZya9MkHDpx41avWt985znM+6DzSA0cHR4wG+9lpV +P5y/4QTlVWhSiMf2UBnPsxHWX3yPLOtHE6J0h2TOhMq141nbq1e/vU7q8DNGT9dHZXY6EDnDjShy0UDL0eChSaw4HC7O/AZvHxUw2VaNXs0Ownbf2wMNMWL0 +vFHtplKrIVMQZv7y5tWv8ALoDwndsPMBz3sRfVepqHnqNwM3xPf6Mb/xcbwSrxIxadXK6DLibK5OV/QCKqxBg74EEypi3zX6ztiYNL7/GVoifk8aDN5kq+3w +fdJcEG0y9HVtHvnUn11whaebzhibkIxNBo6PA8c4Ic/0HPcICUjuOWWZ9DSMCPaS5vOLBr+Euebyatlg8WHmxR2IRO1Ui/8Va3wySVdrwLthGiek4804IWc+ +xlS7+JHg/wFQSwMEFAAAAAgAqoi4XHs9ObNfBQAA0w4AACcAHABhdWRpdF9zdG9yYWdlX2FnZW50L3N0b3JhZ2VfaWRlbnRpdHkucHlVVAkAA88vE2rRLxNq +dXgLAAEEAAAAAAQAAAAAjVZdb9s2FH3Xr7hVX+TNlbE9DS40LNtSwMDWFl32MASBQEuUzVomBZJK4mX577uXlKgPO0H7kNrk5bnnfp3rSqsj5HnV2lbzPAdx +bJS2wKRUllmhpImi7uyrUTKqyL5klhU1M4ab8MCUorDL4cpbNszua7HtrT7jV39hT42Qu/78Sp6iaPP79cebzc0/+YfNH9cfr/68hgzilLWlsLmxSrMdz6Uq +eUpE4iiKfgnOEsT8l8vsRrd8Ebkj+Ms/2ZRcWmFP6wjwH9dNvmWG562u12CsdqeEmhf4ZziyXDJpc1G6I/gPPirJ3c1WM1nsL930JLVSdkDCEwfkaeT3XBtM +6xqEtBjgDxhHySvAyPSR1cLw5J7VLTLBnCzg3c+E47lrjjWS9N2bgNIQx4sUD0STLFLtP8SreHEGmquGisnqC+hdCN6JR85e9DJmEu5dBrzLECdVPplmhKrv +nNKHWUyDHazgrBM68G0r6jKkMimUrMTOIV4sdgc+u0vc3bwZslEBPG46vl4sw6vQLBeehLuRfeik7FI5dhzHDHPtAZYQB/N46fK6GEGF1vs2qGB+AWqc8cyE +V+n4PNXcqPqeJ/3Dvq+4NKQWve2sIEv4bgmsrtVDLqSwwtFcw1apGhvLjei0CeI4/k1zZvkKO0rgUGNTSR5aCSpR4zcJds/B8JoXlpd9AOCIRg7nZi8MNFpZ +tDDO2mcg2FaqLrkGJ0CsKJwDJHqCLScx0rw1iLw9AXN4pagqrtEGrr98dnVfeUAMT5awE/d84oZIG8u2NZ+xI7CNhVKhPeoqmIL5aB72Co2PrNgLyd87N8aK +ukYqRyakR68EVhdYa/dKYzocWoVTxyX+LYg4OWaFRT6OpWNHZ74eWKrSEYEGg/dc0z7xflDoMoNXWyAYpsdDKXTSMMqMcZK7BP4ojM3VoVNgsiUBQNCpIBCA +v+aPjS9k9sJY+5qKymWMHqfOiUkW69DF3e1ZswUDR5rhEXxpEf3Ir7VWOpncu3zGnUrMu87AURhDSWYWnojGcwqb4OlSl7lsVgLlOI0njhbhmwvnAavJc8sf +bUIbLS3bY2MSv0iTPj2LJXY+Ucp+xI9corggmSxubfXup3hA7LSOgH3irD4NaXCZoyAyt8XTWrHSJI4FDl7pSZyB94UqeIMj4P5DpcFVT2cD+IX8vpLOVpJH +GpJVobRuG1wMPq/xwk8mgvsQij0vDgY5PwVf8ViU43XooolYDyoXB0Ee24bDkeEguyPDcDgyHER1ZBgOveGzp499XGMtzRqwVewtquwdBnN7Fyb4wE/LAJL7 +dYo65+NOsTuOk27vq5j3S7o/SFH6EwQb2uEtfHJS17DigGXALmYnNyp7hiIR4loF4ilc0RDBtmbyEIBHeM4nCpJCsYS22WlWklZyfLTECbYgOf608aNIoVs0 +wOCVPnVP0/HUIlmK9Gmy7ka5fXYaRoSnQU8nm9wI2fIxMu2yWaJe/p0Eb7LuwaQGLz848+8rnLKm4bLEvn/CyJ7XgXX2NOXyRj8PFafLsV+8jAfZG9rntUmb +8IndBpxtOzxhNc3cCdtQHrBoWEM2W279m9GSS2GqXvHfndwFMZnpXtdrlD3cGtyuij2TO/9mqo005eh17uF7iN/jL/6vSsgkRD/0dJeat/ArOqpoS/bS7Juo +441Kw+vSuAbSvEIqe+i2WL8GSfc6hSGGtIM+sNr4Pmob+gVCZ50Q+2gX06kNwzrX6/OxxVp2mGFO6WFCP8mW1GiOqgekjp/cTRuuw7lFDJIS92bakSEgWsTR +BffxeL37CQgyNvm5Hs2dTl/ewQsPo9e5dKcD/Cu7sPP8DStwvP7+B1BLAwQUAAAACACqiLhcnDkQJcEGAAB6GgAAGwAcAGF1ZGl0X3N0b3JhZ2VfYWdlbnQv +c3luYy5weVVUCQAD0C8TatEvE2p1eAsAAQQAAAAABAAAAADVWN2P1DYQf9+/wl0JKUEhXCn0YdWtRO9DVIIDAX0CFHkTZ9clG29th2M53f/eGTtO4sR7t0B5 +6D3cXcbj+fjNePxLSim2JMvKRjeSZRnh252QmtC6FppqLmo1m7UytWk0r9yT5ls2K3F3QTXDJ7fXPSdG54uoW70d1ZuKr5zaK3i0C3q/4/XayZ/W+5mVp3nF +Wa3dwvnrV6dG4FZFXfJ+2xpWTo2oXS86V89FTquzP1q50kLSdReuZEpUn1jW1AWTmRRCJ0TRkmWSVYAArGDgINvQR09+zUpeMVSoueZfWKbYeguOE3IluYZH +LRndZloYPd9hxgvQ5HrvPLNaIejj5dlslldUKfLGLpjMFjMCPwUroVgcfGdZpFhVJsSisBjmD0KD06KHLCHFauFwSEgl1msmY2sUf9CWA3TZ2hwt2losW9v+ +IkC9BA++0DqBBfvPaJEqnW0YlXrFKJo9SU98DQeHQRIUDqAV2WDjkO+U16WI5i2OBGtLuiJ8YpKXnBULck/Nk4DPeNZhLps6K4VksMfAHpMHv5NL6OzFrW4h +O2zsp03BNbngcmsr4Apra0bAMKlFwZZ9HDanFKVZDr/67K42iMZb2Qxcd+4xTFHnLIq9NTyGqaoY20Vb+jl64jvZiaqCltJMfqIV9DOICxWPkjdWb8s829L9 +ivUVjUYF2UmRM6W68v0tVuqQTiGu6krQAk7gPw1TGhX79h87sscAQMzZgqyEqKBXLmilWCDUWlzBqsEDfw0C4CUsamsGpl9hVB8Ee/U3D75OPsHQL5BkMGHr +TqTl3l/XMG+rhDSKFZCNZNjyduKmBVcfs0YBbNHQs4MS29ov+I7uET+wcO3J8WeuYK43ar4g85eXz/+8PJ8nIZ3eNGjCVDvsObDd5JKt9pqhH5vZVAtT7ZRM +3lMdBKLTwYeADsVTlMHRVHBdYV4n6eP0JJSW1cTKZ43OQdVdVXDSriJ3W6WwFqdcCWiGLXayb+lmeu7sSOxbIWoLEE9VJ3MPPAe0hnPkWaet0IuBIVuvlvfS +R+W87ZWHJPr55NFjcv8++SXu3bLPOdvBzWn+ADqEKpQFRkfrkTnNoduSwsxxcxI0BqcxeKwPzIlJy6MydvkAwh2rCxiZh8bE9+RzKpqqsIec6XxDWlfE0QF0 +NcxxaLI9vHePtAxv/Kwtf4SSO8DyN9hZZiOpuNLvCp7rd3D8EqRFHz4EUG0Hl9lz28CZ9tWFAMoDCYeQiFQMOFSstjn0aOBlBSLC64DLSYU7x1kgdzTtw3xU +eW8p8Zs+gbZp8V+4VwmTUkh7wYIkXTMdzXkxj4lNxwrgnwyF4yYPBt+VCqaIV6RAjaxhaPW7fQ/r2m7jamTOVJdyxcjrpsapdY7ZRaWX/5YrZVg1HNxrENzM +475/heRrXsOMRp5T0y0bxjZZHIXqi0sT+rWN9SZd8XreufEYdCbp1dBNhVTIJ9kjR6O1sFkcIBO2Hk08J9Oce4MFkAxYMi23DLwQHLz8Ej+WeHbLcTtrWc3o +pGF3akFcawKIyTCeYdZqB69jbDQxO7I0OllgaHBqIeVMwfsK2OZrsA5GQi8skXOSGsi8ODpjh9jFgFmcvnzx6vn52/Oz0S08b1+iNlRt8Po1sYxUTKx3KmAy +SAm6xHydUHNZHuMXLJVsV1HgtvP376EA84fzeBwxwAo8Bfjdt1GGG78l2qrR/CPshwG1ZoHCJWRCINrXLIg3F9IvNmbVFjw5MkUHbI/fMe9POFZysd1VTPeT +FTebv6ayS6+PJ20XuAynZP9Y+uA2ZAc4ROg1wu39EUTC+esC+yY2MYnaZwid9BgoQ+SiW/tahtFv/E6aMcap5xp9cj7haOVIOg5EcQTzGKPjvP23FORslJzj +Ie3jiIu0Up8TeML24XZeMknNK/URBKX3Yu6/rwlq2CYDM8eTlgliHnNphR57CdEKL76D1GKURZheDDiRp3+AF3k6Y240rkx23WN0G1k6mtVMyYxNvv1o9j1c +ZnT+e7vQ79yMU2K+XHlLXFkiEYcqfwErl0KbieDK/9riAUfEhAx9Y7/D4NAwdqs9tEHvweuE7oIxXxm7IJCIRDH8MWs9zXPUZ/ApN+q3DW+HHcshJENDxm0w +pDDhBmjXhgD6FvH7El7UnjSGaXLFJKD607IN1UmOof7PnkJUeHaAf8CltGL4vZI0O+y+tPO/vPZ83hCa64ZWy2vr0AN3Osr/2jkCa2qFo3k8zHHAYft4882Q +gTDQWtIAkfw/scQwwbPATyazY9khWBILx0HON53yJlBn6geRv+mF1lSwcXSnhVjgMMsAE/wXUEsDBBQAAAAIAKqIuFwwclIdhwMAAPUKAAAdABwAYXVkaXRf +c3RvcmFnZV9hZ2VudC90dW5uZWwucHlVVAkAA9AvE2rRLxNqdXgLAAEEAAAAAAQAAAAAlVZNz5s4EL7zKyyklcwqS/fzgpSVeuihqtRLe6sqy4Ehr7vETm3T +vOlq//vO2EAwkLTlkID9zMwzn3ZrzYkJ0fa+tyAEU6ezsZ5JrY2XXhntsmxcc1ddK5O1JNJID16dYBQYv3eMfr8aDRHnr2eljyPqpb5O6i5wcKb+BzxaCNCS +9I/Id95YeYSXR9A+y7K6k84li+97raGrMoZPAy36oLTyQnAHXbtjkjBVIlFEMD2EKQOE7SM03aqNbtVx3Bs+U0hnjkewEyR+ZgETAhVI2V6L1lj4AjbwKtgv +f7O3GJxqS1mpdGt4/s5L6yloPrjITqYBhlqYxpf9Ty7fzUmWtCpq/CkmnZcn1QF7b/uZHXq8vaYLge1FKh81ohq0WHthdA28SKDwXMPZszdwPRhpm9fag7X9 +2a8VWqkcbMm+Cn9YUxghWluLzoMBI5rnMdesUW4gCM0LtG5sxUI4EJqSjT4NBVu6DuDMT/KZ/5WGLgZYWBj9dvTWuKJYJjKNzJ1URvhF+adZcZeDJM9WjqYs +etvtEgw1DhY1xvmL7PZ//LqxS71mer/arDvjYNr9bbGLkRBOfQXc+P1P9jOjvxuioOxM/KuNuE6bpQPd8Fg6n5zRwsOz5//m2PSQVyy3IJsr5icPPRL4iN7X +uBVltLnw4r+iWIcmNpU4yesBxBNgQxxAeo5dUMOeCnuR7xB56hErL0zpe/yD9+AcqsfOjSTO0mKsiD5H4WKFVy02nmfKKe28pPQPGnZYkPV8qiQZMBp7uIe1 +eYdpw/ig/UFPeQTPY8w2rd8k9iyfGiDftrseJ0PzTIJDzyTGpxmSF2sKD91Z8qNE3KH2yRzc0mtaywuGifvwcVPIwucenHdiKdmYi+6MbMSIiGoSTLp1x0I6 +K7wR/onqls/q8GxNjXqFi6eJINaCziwsniuR2AXntkP3A/pXPi2MTMuPLH2rOzdF6ZnaNnYrFrvvsVLu40MYHIBGoQ40DzF4gJ/oz2RuLj0QfDQ+tsWWQ2V8 +vruOw/HyHT0WcFOTOZzVeCmYn01JQUat4/ESDhZyYjpPnLc3kxbwTqanm1VJyPFyVWIcilI5gzPvhINxrvGW63gVGsqnCuPqA5rY0T3s49rgcPUi+SUJWiub +/nR2fKpGBzg6JbaE2/N8R2O+Sl2bTdbIBOdrRSaDaeTw2PTqtjLnQhRcOrCXt4xNYTqxs/8BUEsDBAoAAAAAAKuIuFwAAAAAAAAAAAAAAAAgABwAYXVkaXRf +c3RvcmFnZV9hZ2VudC9fX3B5Y2FjaGVfXy9VVAkAA9EvE2rRLxNqdXgLAAEEAAAAAAQAAAAAUEsDBBQAAAAIAKuIuFwFuHqZmgAAAL4AAAA4ABwAYXVkaXRf +c3RvcmFnZV9hZ2VudC9fX3B5Y2FjaGVfXy9fX2luaXRfXy5jcHl0aG9uLTMxMy5weWNVVAkAA9EvE2rRLxNqdXgLAAEEAAAAAAQAAAAA+8zLy8UABOf1hbNU +gfRjBiTACKU/cwCJqQzBDEUM6YyaTFUiBnrGega6JYnF2brFyRmpKaU5qUV+moy3uOPjy1KLijPz8+LjVzJ8Bum9ZaKfm1ein5JYkqifU5xoEp+YngrkJ5am +ZJbEF5fkFwH5ULH4+Mw8oGC8XkHlLQ6b3HyQsXZFbFCHFIPc+YGZkZHxBp9KI3cRC5ALAFBLAwQUAAAACACriLhc2lwJpvgIAAA0FQAANgAcAGF1ZGl0X3N0 +b3JhZ2VfYWdlbnQvX19weWNhY2hlX18vY2xpZW50LmNweXRob24tMzEzLnB5Y1VUCQAD0S8TatEvE2p1eAsAAQQAAAAABAAAAADtWE1sG8cVnv3l8kciJVIk +basxKcmR6T+6/ktdOzEcyY0lwrTLDZ0mirNYkSt5bXJJzy4TW3DQngoZDeA4RWEHDRC3l1o9JUWB5Oiil95KVg5IbF30UASob0ItwKhPnbfLXVMm5R/ARS8l +pMc382bezLz3vfdmeL+vz4fI58/p8LkVP0L/QB0f1v6i7r9J6C+QiEQqg6YpTCWApzP0NIMZi2cy7DSHuWke81abzXiwR+REPiNMe7E3geJoBImejUgU9tO2 +VuybZ1Pee8CmKNMva1rFkA21oumkyZ6SjbMp2mSOapdMYcpQsDxbUrL3qPboo/OKZkxUtDl1vtC5Y9rZcdHa8bsIUyLCtEhhRgyI9EZ0jsWs2CcyFseJ/SJr +cbwYFDmL84ghkbc4QRyAHRPOS6SCxfnEQdFrcX7RhwPz/lTY9B7LnZooqWRD98CUBbpjP4z9T93/tbWfH4VKrmjGHZdjUddnBO1vc3lUYpDL5/jusTmhR5+/ +uy9P9Rq5n7K/E2geLVHZFG/yBcuwpoCVCzVFN3TTIyq6TlxjenSHOavIRQXrJl+rFmVDIb5idaU0h/uJKh2OlkiYe9JlzUgTsZwu6fI+SQavpeVaUTUk3ahg +0m73FSz77aoSZ0uSqhG5ZA66dt3l9MGh9L2E/AS1osMf+e/GNjdj43+JjS/HUovsR4GWELgauBJoCsMNYbgpjDaE0Zs/+E32V9lvhL330ePOoR3nHLCcQ4zb +bR2Uo7v7+okp+9FeegzhPtJM0dapzYCCq9KsrCtSDZdIZwQ62SpBctsgeMjqAbHZ33E60g7ByYbtkw1Gr2+/tr05uK0xuK0+sP3W5K0D9eCeurAHxx8/Au8c +4Z9POgLT3UdGct29IuWgIU89O9ZE+iCj0Xm6F+YcFCcQkQe65Vvb3zIxABkRXF/DGMkyIrubGkMpLrswki5WCrUyMZ+ebkNppw0lAkxszCqysUQgeU4HrBpq +WanUDJPXSYap6SZdOb8k4AFkuaeiGxgMb/sw1sa81J4j6QqJhqJuhrCsEs/OVbDU1uIhAoOsiBNkYoqx3e2pypdKFbkIwaNXSTJTdLB+wnG+192eGX6EALcz +TMbokLsIDMKx64evHW6GxxrhsVYgdHXqylQ9+v3lwKHWQLwViV/PXMs0I680Iq+seFBkywpiI75FYcWHvANNIdkQkneE0dbItubIHvK3ePHqh1c+/NmP68Lx +r+O399W/d5xwNpyoDlsDJCw49VMvGE7PAyZao0iqejKYqFzf+nKNnnGBlKdnQq68HTou6GiQjwHwepxJZNLuDAI6NruwZV3QnavM6umqohVVbX6JwkkE0ILO +Ja+NM2Ze6YAZfhnIOJAESH2qrmoEVlpBMdmSqhtLNpzwNksRZNC1MIq013LTKKxlvvQIUb3ksLx+Aa0B12gjPOqC69XlwGtrkcWhyNgKYixkCWuQFYzZiFpk +7/qHPi5+Ntn0Jxv+JFG8mGkFw1cvXrl4g79JLwdH68Jod+LyoDbSfoteDNIgI4v0XsZBnMg8B+bYZ0tgVup5+NKTUbDgJfIPNEgCb5KqSHIOVuSyBYkUb4EB +fxdIDzA4SYQniiS1aHn/Mb87qjsd2+n3XnJQrcMVbh2/xw988f4XZ79W69Hjy4EpMqQru4z1zi7B2GLQdm2no1zXrqD/jmvz9HO4liGuZfLMU1zLPLU2MU+p +TazIWbWJz+KDpGeBTcuF83gzYfEIkFEgY4SkWDu0DwFJoXaQ63AHTLh+jpHJWuWDklIkLux0dfKRq9cZshu0ldG6JaTt7WI9OrEcmPx/Lfkf1ZI962YRJ4R3 +Otdvp7DgXWQ2ht1gcDLehx5PDxuctO+mAfcKP9pdG7oGAXCfWCCmlwOZF1kgfDdfXw6O14VxGz6d7yB4StlZxIYPnesEl2NWFxQ00lgRUniP63qeFZl9zG5K +40gU94CW4+owOs2f9ujMbvKACaPjUYRqRFt1Pu+B0nWC7CjPz3hcrR5nbYk+QbbvJ1DSBI3XPPlnf5GhXtATWSvjcR0Zj8sLHTBEuVD3rNxgD028AzrRc5DV +vCK8zV0gJtBb3txQ96yO0PDmot3yNXnRm9uwvgaSFwXRC3lRpxMEq5pH4+dQEu1wdRTJCeeIbDNZLYGyU68ym9EclfJlTRrPmuycWlIW4nK1WlIL1o8D6UrB +UIyddlFd2P4cQbTgSdeq0KXuoKDUmhwo1zHs1arOj/J0qs9kKyRKTFaTy4rJqYZS1k2GLGrX7y2oXb9NpixffPxGB0ez3wQ+u5z7nDeFWjS9sKgEb0KTUy4a +WDb5s7JWLCn4DUvfeeWSyb0vl2qKvTGrNkAsJNyPHerD9mG6gliCBcyXH0X7k8ZNgvYWsgK+L3r1vSvv3Zi805e8ebo1tOmG+rn2qXZrrn7gVP07P6xHcovH +WgcOfXX+y/O/L98Jv/278pLvFnO79vOTH098Fr6R/+XGT05+WV6cbITfbsU2Xb987XIztrUR29oKRa57r3nrw2/8yXf7cl18p75xZjn0biu6qTW04W58043J +zzOfZprDRxrDR26Hl+PHVrwonlpBXLx/8fgDD+ofWRl89jL07/sDKPIO9YAj877pSz7UwZ2fbJkYZ77yTiSFP7AhoJGjIdLzxyQPdNw3sd/TfsJb7s06DQhi +Uq03AZ8Bm/NYMWpYsxpEAMX7YX9RLRgzBBo7Eke1S2dwHkafRtavAsAvROA+P7N21BkihPr/0KtqRuJyggjI3dC6EbyFnGsBKOlQJ+ATrjwLBLIlPtljZB/8 +iALIlSTTJ0nlSrFWAj4gSRdqcqktCUoEAlg3SqqmaBVJwlEH1Xg7EPhRBB8GcgTI60BEsMKQZL2A1YIkGwZWZ2uGokvSErJyuAVO7HMI/Os7CfkpWqUZbt+q +j+L2rvIebopaHfRyY6tRDxdfHXTaWx9E/YS1NcHclB9OMFcjZid7xlDs4IltnC2ps5YBTN64VIXaCHkdw73ALpCuA+29nAHi7s8UDttGeQ3LpAlJVofgX2Eo +ivobGvkWxf+OEn9FwW/R5gf8MYqaov6FrC9Lx38AUEsDBBQAAAAIAKuIuFz3aglpvAsAAGsWAAA2ABwAYXVkaXRfc3RvcmFnZV9hZ2VudC9fX3B5Y2FjaGVf +Xy9jb25maWcuY3B5dGhvbi0zMTMucHljVVQJAAPRLxNq0S8TanV4CwABBAAAAAAEAAAAAMUYW1Ab13VfeqMHkpB4GLwCGyxiwA9sx9QhESBjDIhUKzlOcbKz +SAuWI3bJ3ZVtSNsh04/ipjMmbjumX2WazJR8lU+3/bF/2nyuZt2RZkf2eCaezviPDJlJJ/3pvStpkYNcO19dm6tzzp577r3nfXfX6bRj8FEGfNf+5cSwr7Ca +x1T+wXfX4fg7jMEYfBK7iAOcRjAxSQAC/pKT5EUKUDqNmjRdNAOzDpsmLRetwKrD5knbRTuw09gVqhNjLIz1LN6MQcjWijH2U0R5nVOVZYGDcTGOVuwaBRoY +N9OgQ07GwzQyTgRfw4FrgQh7nyHmMK45OEEQZU7OiIIUg7gtzclcKstJEkJyIMsLKTHNQ4R6m5OvohlZkUuzaVHmhesJiJvngbgCuWoPT1YP/1Q//BUM4AwG +D4wD8jA8/BWKIXvgGx2iDMikQ2YIWXTICkyMDWL2CmbWMYeONQAL44SYq4JZdcytYx5g07HGCmbXMa+O+aCCEObXsSaopADEgjqGQ0Whd80Iu+JiDjAtSGWG +at2Q1s60PkfzMG2gccEa7tAckQVekEdFYT6z8Az5hUZKMtAaeLDEznESz0JlajYBKpNFGtUcOijxKcDLwIz4GyRZBNwCzwJRlGPf2eF0+qd0TBR4zQa1zQky +m0lrtjnACamrEHzWoa+SEWStaUnMZlkI8eA6l0VSRSEtaaG0eEPQ7QX4D3O8JO9jeXYOyWi9ynNAnuO5/QxasDpVzizyYk42ZiIja45F7iaLTpDhpYRGzYli +VnPJOUHgsyzc8lyWT2vNFRygiQKfMkSk8Bqfocp+g++e1n0micWx/Q+kEvupDM4Q/eQhLEzGVtoiuXRGPp8Bi0xZm7pZBo71D/YfC5Mrjst9Maj3vlE4rDgr +CKMbYcWelHjQp/N/QQCXbvJylFASn52X0G5p7cTAIpSHwmQgK3GDLKfL59CqbNWAZVpK94X+pWXNAvWb5oGk+WqcpL9CbIVipRAcVrEdM3aA/sPw74eL7aFC +e1++va/YeuZrC+Wx72CUzb6LDvuc0kxVpfXjL1ZanKynslMVOQKexOOmOhxEleNIhcJ5IZVM4rOW/TwMtF8fkkbM9L5AHlVXnuk5eVQdeUQSF4hZW5UHntG+ +XzqkNtRZ09xv7FAgk4QL5s8k6cJOImexxjR8YMV6VZaXpKGBgRXzDUn/tSAKAkw3EA6QaVbogbSYyi1C40kDFSv3la1cdu43wxYASwCmmQEM28ySBqMXhpR0 +IyNf1UiYRQFSSY1TUaATsVMoNWiWG5KeIzQTDDWwLKFYoOEDuhCPvRI/KIEEah1oj45kSZ9iyIeeuH0FN5130wV3T97ds0Wq7qNrVNHhvv3mrTc3hgqO7ryj +uxjo2PR/ntrq/UxUuk8roTP5wJlb40Vn4232FrvxbsHZk3f2FAPtm9TnzFbLZ+9vsgp9Kh84dWv8SaB1bbzkadmi/uz6wnVfLoy/k4f/Wy+rnnfXTEVvy3qL +0jp079S9HsU9olhHQDfc1RdYmICa4eUcEFZc6UxKnoVaOkrD4b0wAU4grSDzhb2alWUFbpFnWc3OsotiOpdFcAPLfpjjspU3bpadz0A9ZzMCL4hlQk0dY1ng +RRJ9aAigIYiGFjSgYAMH0ICSJziI9GtdAuISD+RlcAjRBhCtiYXBDMWlWE6G9pzLyTySexwxoBPplgH26oD+pE04/BJ74m1Sqaai2/uLWLHRr1L+ojegUoHi +AXp1Ym1FpegaKNSlUC0bJ1Wqq9g3oFCHNyWVGih2H1Gojk1SpY4Uuw4rVNsGo1KHi8Hm1cl1n0o1F9sOrsbWx1TqYPFQt0Id2EipVPcTk3OXMJnsuyRmdu0g +aMcFoV3CZhrFdWIZLG8ebTdVm0hJrJJH/o6hPDJrvKiXbjthtBlRRSSJuXYMmzUiPmlEXNyyf261cDL40GWYAaD0JH7oB8+O2178jiF+BEe9GGjYMBKs4cc1 +fFkjREEjl3lJo2SQ48MmSJA08wKPGhnd+zRTOXRNWfEGD8KkRiF/0yxpfp7LZWWNBNwNCWnKiEwbC6ezqOiBCxBHniNNYigKS/aG2+FfhdfHPhlYHS1a7B9/ +9NvURtdvrt4VPhXUQPdWpxo4WnQH1lwlh2tN+uT128O3hlVHW8ERyjtCqqNL6Tmfd5xXqPO6wZ6zFdKLbquH2P/DVh1YTS425h/CQtiskZctGNeMMu6ss0ph +iCTmQtWDOAez8EkjzydJHvbBAhkh5/F57A3yIDaPh6kYQDZc8U8IsBPJpGnUlCzwgJ4XAb1CDNEw2UYgAxjBKokDjKKhCdnEfonL5vgoACJsvuI5AbUtOgZT +bhRxnUfDOGIl+Zup7+Vaq25RuB6YhihqRaSFH2bQHYI0TeElt3f95Cc319z/LjmadjEckVytSvvx7UPbgW1wr+svQaUtorpGviQV6+R/dkjE8Z2EEtVf7RET +9sBkj/SQD9y+SCf5oNME4efqvrfqAzepct03airUxqzBuWd7Gpux79GrlqWxWcNn4tR+D+jUW6vqHaOejzDkXh/xUllUjaz/EcGw2r9MkukVJZEvlWR+RUnU +SyVZXsph3VvrVdZ8A69aNU0gW8E4Mb10DdurnaeObPMAJliSe/RG6D+WuGO/jL3ekYYZ4BX4qRp+8hX4TTX81Cvwm2v4LUYkoEy3l3/sjCPu3j83aanORd1m +Febx2UaDw9h93Ld/vlGRml78TrAmrfFgnX03wH/OswSMwhZjNTxJJMmkNWlKmmfbDE4X465atYbqqUttZLx1qL66VD/TZFDbDWqAaajDG2Saq1Sm5awTVtjW +mNYQjb/NjkSYKJuMT+lZGyADaLbYzFiUHYWD5tBBJjoajya0BiYxE4+MR9n4zExCsyWisUgswU6Mac5IcmwiwZ6fiE8j1DYSj8RGL0BwhZ7OSFJGWKDRRTQD ++DRdvljlgN7uDcGKcJROwAbTssQB1KBrVv5mBl5YxQ+0prdnpqbYiVgiGr8UmUKbmImNMcCPthgam3knNjUTGWPj0R8no0xiH5/WeiEaiSdGopH970AzkhGs +Tk1MTEdnkgljpmM6chnKTcQnogxAOtRciWQsFp1i4YlHpqJjWnMFj6MZseioMRcgFw879evE3oUBeNDwQ/rasBughF1TKcNYpVJqZm5piRfSADUrGnVNzAjl +TxB2/uYSJ6Rz8CKsWQAvidnr8FKy+EE6A8rNLqqKeq8TtkE1C9dhI57lv7dVzVP7IYOFXdPevjXLYtma+nkklKHo6lOuwOXvS2UTA1R730KMf8NREX5scZes +vvUL/7S2P7X6fu186mu6e/bO2Y33t+Kq71jBdybvO3OPVH3nVqdKHu9d2x3bxmubQPX0FjzH8p5jqufE6kTJ678bvhPemN46oXr7C97BvHdQ9Z5enSwFW+4u +31ne7Nj2qsHBQnAoHxxSg+cUym/I+hz8afmPy9sH73eqPVHVc77gmcp7plRPrOC5pMQvQeH1Vy14hrbTqxNPXJ7V8ceOYNHqvu265Vr/+UNr92OHv4p+9NDa +9dgRqKI/e2g9/NjR9hyzxYW6iLY37lP/sD2wfdmjvhXPt8VVF6NYmae+oNJ8RPWFC77BvG9Q9Z0u+IbzvmHV99bqVJHyFKhAngpsyFtpBUEnn8KlmkJFb1vR +f7ACeNtKHd1Kz/B9Qu0YLfWeUE5eUi7/RO2dLXW9phy98GWn2jVdoo8o4ZH7Iyo9XvIf2HhvK6L6+0tBWgkd3x5Ug6+XQmGld+z+nBqa+LrN1WDewVwms97F +hs3lBkxv2PQuDKWc8u2vve77JuM9gsJ4DPZwaYQ2Gi/suqejy+J8Dl4v4fUQkFWv1xzG10xeAqheag3wspzNzPXDVAF9XL8dwLQhX4W0cgSYy182y6Fjr3p7 +2e+RN37vCqhZz5UvqcNgEaKoUkgX4QAbOhx/hHV+hdkeYSEIPMKaH2EdX5sxwrF+KI8HvyVC+Ci+g6ER3s+I5h2d8M0FnMLH8W9cFnwK/9bfi5dV919QSwME +FAAAAAgAq4i4XOVBTYQKBgAAZg8AADIAHABhdWRpdF9zdG9yYWdlX2FnZW50L19fcHljYWNoZV9fL2RiLmNweXRob24tMzEzLnB5Y1VUCQAD0S8TatEvE2p1 +eAsAAQQAAAAABAAAAADtV01sE0cUnv31ZhPnhzQQJVGzDVRkwU1UCJVQWiobHJrYGOS1UWgitht7nWxY74bZtQIoEj1VpKJNEaoEJ7hyQ4JDD1yKqFClShAF +KdEK1AMnbohEQuqpM7v+S9YWP6p6adf2zOx7b968+fy9N7vrwSAP0PVosH020gDAc1B10V5HrH+F2p+BBCQiBsYISAh4TMZISKKeilFjNKRdGR1jxhjIjLGQ +FUAn6AMS0wUk9gDpeYKBaVIMvMBDkXAaFcMwbcXWTMNKoHv6hGLPiKTDZRVbtbW86nC4vWAaaqY6LLIUVsQNaxJAQgIoFAJSUoNEdoFZFI5EdQHISLxEu/es +1IhDQaOA1CSx7oiTArBhmhODTiBuZhT9SOQFhzxmyKq1GPSj8FpZd600kQT+K0n5ZRKBPuRBUgDjrXrZYxokA37bA8VeANNApBIphEFgToGqYVsOp57TLFs2 +z4i0w3pCh8mfyWrQCWSn5DkEmcPImqHZaBZtqXoOtiBXFl5SEJyBwbxhDyJAlUHdUoZkZRo5GFQKWc2WLduE6L4oy04NzJ13ONn1JctOaxGTgZKkAbvdjZpv +wRrXstS82LzK9SxzPTdTt488xqODazs+vNy0xjUt8Yv8D03reEsZomqfVAnMORfMiQqA1ZAXrz4MFl0DrKJHg6iaz/rt0oROV8a7EOESIuMErLO6Zqv7nUDG +NBCvbBcth0qa804jNOflnJJBsJy/RcI2rKCxWRFMuA11sBOLW0rgFL00Y2w+drF52rJ96eL3F6/ZNxauL6y0hC7RT7v7bgxfH75EP+E611raLzXCjq3IsCVk +7rnIoJ1Xa0s7L/Yk2jsiYk3SldARQB0LcpMFU38VN5/Rp5S7mJwfgRDoL+qzyE8O0bsXabDuC6oX5AhEXyWGNLyw5TqcjIZTUSEVjsSjwuiIkDieEqLjo1JK +EuagmVEtS82WGTlrTllCv88HvpBK1rJCKjqeEk4kR4+Fk6eEWPRUqKaxjv8lN0e8CXjRRDoer21tzSj7Dnz2NpY5TVdlS7ugCqOJVPRoNPkG+8oOFXuzf5+5 +6JMoqffGM2vOG7qpZGWoni2oll0P1KL6PwasSHnJHFDPqZmCrTpsxsznNRt2g3IB9XK+FZsFywUR18N2bDPiZvxasG1pcnHy8ulr9lpT61J8Mf6S5No+2KD4 +YPPL7QBpa4hii7HLx14PguaepdM/nv7LakSuruwPB6lfg3y4O5CpzszGUnXoJ96qOjBppnZ1mCh7TZcrxfjOifKRlKZL0gmuJEvyfk99YKKprG+uEUvRS7Kt +fpxDTKWyoHjb61u+ZyUiExfu+f7z0YQUTaaE40khGT0RDx+OYqIdr1OCfLP7veoTquJ/qMjuUIW7oU209PPuZDiejkpC/5chofL1m93iXXrCXoAPKMuGDqUZ +NmTcW8Och6w7KtgZp0GzzJwJ84oNBSQUWe/wYr1wHb4SrsN64ToN5XBhD7K1sDNvXZfyzjaoZky4CQ+nq5QBft0n2IcM6qUDjbjPg20dP5282n1t9u7O29qd +gQf9D0P3Q3+ce7Tw+8JKZHI1kluO5FYiM49btY1gAOVJayVP2kt5so55dyUsRijqPsVH2v5PFPBPJMpv754ovrPFny2VY+XfyRi4E1TIz1dWh3uQAO7FTQg3 +NQm/o0jqrftyerewfqvBQextCryR+l9f7b25527mF/GO8WDmoX5ffyJNrErysiSvjHyzOjK7PDK7MqI/bs3XZz8cxBsk3GfWhEi6PcRPuejZn4WqXYCGQyfQ ++5LIQxHrduFmT3m0tzxyccAPrHAIN5hBaM6n7z4HvzEYSl5Fbwy8LOfNbEHH4yZZPltQ9KKmRZZzGrRsXTNUw5RliEnsVTd8tEJcPeA+/C90yKiqoFfCDOKB +DbUpdCxbsnwLuI/M3mHMlRr8s/BZ/R3YICmm4xVPMQjrTqZrY4hnul53osabhy1FDgeYKyCQUEgQpz3cgZcMYFLq2pQLpFte3crqrTOMm/LaDve5t8ND8BC6 +JUoBvKQIgngG+p6D5meg80+w+zU7THS9AqhxJ/8NUEsDBBQAAAAIAKuIuFwtXS5klAMAAA4GAAA2ABwAYXVkaXRfc3RvcmFnZV9hZ2VudC9fX3B5Y2FjaGVf +Xy9sb2dnZXIuY3B5dGhvbi0zMTMucHljVVQJAAPRLxNq0S8TanV4CwABBAAAAAAEAAAAAJ1Uz28bRRSe3Z1dr72xceuGJlQNzi/IghojlEhUokUClLZJcCov +lkA9LBPv2HGy3o1mx+BaIG0lpIpbgaLmyBFu/BncGieRbI0UqVJz6Q0pB66dWf+IkX1iLO++N+/73nx6896eJ5MJwNfzXGb3EwWAl2BoKd2XdH6bP38DFrCk +DbAuESkrbHlDJjJ/KxvKOiQw2oMb6rpGNG5rVsxS3wK7cFcisYps6q9EKlNiBvI8nyJa9b0gz/1MIfK8ylrVxXeR57iY8G14H9GdkjSkJtZVJJ1rslBTBLek +pV7ImQQgCx4MoAUZjKw5sNqzPKkoWdINg1tyUS6oo1hL5j/lpsxzaoOc+ricFlztafSUolIwRjFDGSZGo312FnB2ajReuDSGA/qcodyXx6pTB+rgEPbKOOyg +OmpRLVwdRRThf7ROj0GoF4gH1wa7sqWJWvN+0K24lbgJPa2o/c8TtGHEAjCNPIOuXwm+NGUW20cEezRgOm5UA2r7eyyD6k6V2gH1Capgm/892lxZXEJBiVZr +2Ayy32cXl1z8LXY9NPCHzBoOAs4yg2Y8Ii/zw6rhjzPgleiaplqn5RsfmQrTa6jx6UOKA2Zso9Jeff8zv+5RrsQr+Q7vbTNFBIEppe8cptb2nCphMZ6rwmMs +XsF0k9uYMD3gppDD4L382hbTd7oDETC15GJEWHzNJzVEKccmLUowqvVGhk1w6kUwgRynFyGQn2zGmL6NAmyLkyHxfS5OFC7yNbd7erw84MdKfDx9F7OJMh9L +uy9DzGC2u9iHuZpHcw6iKOcGaKVb3dyYiue66Zf3H0Yi6/t2d4PM82xiqoLn/BGCjpFpG7NP6bPGL41fm+HnnTcyT8o//RDe7cCJx/lH+YOVPxbCfAvmzpJX +2smZo+TM4dsrx8nVcK0DE4+3Hm09vfNs8+fNEzjb89twugWnT+C1s/TVdnr2KD17OPfVcfrr8N5ZarKdmj9KzR+nFsM7HWi04VQLTh1w8Dwnt+FkC04+qZzA +mbPMO783/nq/9e6tv+OH9jeHe6SVCcLNDky34fUWvH5QPYFLFxSXJ3hhXAq/OBddakp5ExIxCk1DfM94T+V9DzON8DIQr5nqtcBy9/pNnSVsu1znMWzbRHx+ +iZh/lu7j+vcQ3anoeLrjVrej3oqq+ScgC8KeE2H945rv1F18m7zHXTE4wZv88Y8iSdIpmHsJUqfgg1Mw9a+WkS5HvNdQSwMEFAAAAAgAq4i4XG4hMUfhBQAA +vwkAADQAHABhdWRpdF9zdG9yYWdlX2FnZW50L19fcHljYWNoZV9fL21haW4uY3B5dGhvbi0zMTMucHljVVQJAAPRLxNq0S8TanV4CwABBAAAAAAEAAAAAJVV +zW8TRxSf/fB67fXXJjZxgiGLFaKYkIRIBDU00AYCam0aIW+CUoy62njXxtRZW7NrSixVinoK7SFqadWUVqLH9B/ooaeq5Q+w60gJi3NCPXCLFA6ovXRm1jZp +E5C6smfevHkz782b3/vNnt/vBeirjXXf+dQFwDOw72Odjtr7GbXfABnIVAokKUhJWKZTNKRRz6SYJAvZls4FXTIru1Jc0g3dSMfKXIpPeqCHyO6UNylAgch8 +ypf0Qz+RPalAMgiDRPamQkkRiq39umCXHJCFPnCHhd1yUPYRKXwrIvvPLwCg+gG4dSQOboXjYKIV9gTl9DqVpxOh51hMULagGkbJUq1CyTBn0Zi9rlq3n1PO +pOdK+vrlYkE3LGxZLKmaki0ZuUIeDd3XSlm1OHMJiT5TtyplpVjK53WIx7JVgmpen847K7v2j+cqhqEXs/sTyqE/gxMKSUIznYk0DQ58cZRumZ6kDDAP0q6D +8zKDkgVk1yQjvc6Ck90yL3sciwGQ8M5Wz01XtIIlXS3AJYkcTGrFLJGgpRFpTjU/luTsbV2rFHUopfEx8GEFTTezsFDGGay6RkZ0425VxEmUrJI0ikZSrlDU +E7TN3taLZdut6Tm1UrSq3MhIycjqttdEjnTFghW9ehTtKpUMXTKXjayUXc4WdUk1NEm/V7DQDpyaxV5gAN8OY/MqzJdVaOp2YBrmK0sozut4CG2fqmmK2tKh +GDliBk18rZJ9ZmzJsMY01VLHiqZ6VlHxCcdUnADFdE7d0i2pBWO0vGz7FiuFoqY4u8AetEsI/c0Z1KyApj+y5R/4wz9QGy/U/XdWrm6z3i22p872rLtrg5O/ +nq3hwXuvtPxGrDb1IdHe3BbElQ/28K0cjojPaIIIqj3RBjO5febg3XbmqUzn5jNcB0/8YXj6b4EY9HGQEdrz81Tad4ifti2TCf5fPzI1gnaXadx29mEzYscj +09G6Mt1t7cLEwqWJFvUYHIoqfNDHUKtX0V7zXLrnkLhbvQTyzDyTjr5hj1F0or7OiWKHnSjT34mZa8ecPvF6r20b4p17kyW2OAEy8bbeDdQw1s7T6ZMHV8ns +q5370crM4L51KHLDjdYlDlnnmndP0K9Wyshy2p3nnD5H5cAFph/kqIR71mZxwVR5Uhij2iLEy6oRhx1Q1ZTLuiYtLksVVCHVkKPOqajytfPSSRNiMCYipHRs +L6kjXJ8m9CAFxLdqM9lPNOgmEiINiBEEMaAgfohslnAFDyuGQqSARWhU0Q11Efmw3SqmjELJZpAJxOC1BWycQ9xyFzGCmNKXF0sq1N43LFTClbJlswUjV0IU +fy+rE/KyPXpbTPAwQrziGG3OIXabc7jfprVFJJNnwXaRfKCQ72VNHLLU+uARsh4TCBxDIj64+RI4fNH7xUeIJQRxS4jVhVhDOL4yg5Q/DDwaejj0/amG/+TK +1V1adIlNMbY+92jhu4WGOLjKNf3hr+e+Xfhq4cHNHwfrkaGN8/XIRMN/bpVuin3r5xpiHNmEYus3NtKN0Furrm0huDa5NrntE9dm789u+mK7XhASdz0gEN7y +x9Gv6eteS32e2oye2phoRMe3ohfq0QuN6Dubvnd3OWzKAH/Xlu9Y3Xds09e/HQy9bAqxPUC7xG0+sCbcF2rh4U3+9JNgqCmE2/otPlrno7Xe4Q15kx9vBkN/ +77rQzF/mKXT4X6bHL0+B305fRO3vwyHcTh2ducg87vXgNt41M8k8nnQh2eYVBSdPUTDvQ/S+QqPa0yb80X/zfYKG4wQ4BcT2PbZXUXIVtEBXFIgpEnbhSXcZ +vUnFwmILbeayCU93MIaDI+CDw7gh4MP1QvBpsxhbDg45B3cEYRDXL4E0uWIcsqEuIa+2V142LX3pCnq2fgIQk4yDB35qqYSfz4vwbTTEJWteQ80uQ1HUDog/ +A8EdEH0KvH+CYzugfwf07ABpBxzfAYNPQeAFx1JdLwIRStw9A1jfarXB9D7xhr+ce3Cj4Y3V2Bi6L7aPePsHUEsDBBQAAAAIAKuIuFxYn0FqmggAAJ4PAAA3 +ABwAYXVkaXRfc3RvcmFnZV9hZ2VudC9fX3B5Y2FjaGVfXy9zdG9yYWdlLmNweXRob24tMzEzLnB5Y1VUCQAD0S8TatEvE2p1eAsAAQQAAAAABAAAAACNV91P +G9kVv/PhsfGMwRhIMAlhYEl2XcBOA0TNx1IIkCZxYFtPLKXr3VgDHsDBHrt3xiFBlUqeEqqsoB/Rou6qStVKpX2K1D7ksdt/oLYGxe6UVJW2qtS3REk31T71 +3Bl/sXbTTsLcj3PuuWfu+d3fOX7p8bgRPH8Kddz8uxOhz1Hdw9gN9fJn8H6IJCRRYXSFwpRI+nSYxrTVMpixWhaz0DJhxxUOc5YOG3ZecWGXiD5gIz7U8Awg +yTFO2X3slnhJkLgedJO9SWFe8khO0seC1Cq5rJ5HapNarF6r5JXcVq9tmQ60/5MYCFAmL6tqRpf1ZEbV5mHMflvWV6B1XUiqMr5z+b319tiNqZH35ZH1kyNn +gnFx5MOhRbrOIdb+auplyfriKHqXeqcsSoAefBMVoRq/Y7zcRphGmURLzDj9Bjlbk6soxlbmI84mukwU1XSjqJkvtRNVm/gfpQZRgJs3kUmFTCpuUh+sM2Iw +HmBNh6bjZNZ0YiWbkhcV0xOXpi7OxqXZb83Nzl8zGS23EKBNxy05lVNM15KcSi3Ii6sa8UYUzdFQWtVDCVmXQylNHovLywqM5Vwiqcc1PYNhXJ4rj4LZO6ZX +k9WknlxX4pqynAYhJhgR4E/7Lrw2UMnj24ptLxSFvoLQZwj9RSFQEAK7b+1qhjBaFM4VhHNPZv7wliFc3JjdF/xFobcg9O4sPho3hMDGbElo3wpvhncchnAM +Rry3yB/dvpRnj74k53Eg7hwqx91N2XGvnJq8glAIqXSsetKxagwhFuXZiKNJHCiJrsSqqsc16nWgS8chVkyUkZhzZEdYM8eDdTrS0qgd46u7MxWrlVZEc28j +xJMo01X/XbCm6nuUPo0G68Z11iiJ/aq3gBTH+tWIouewKsqiJi8pIlZScL1uKWIWbtawmMXKLYhbUl22JkQdy7cUrMkpMZPTtWRCEcvhFnEmowcx4RoMLqEA +gwm+TSq4TgeD60Iis5gjCAguJNUAh8ktAMjpGBM0mI6sjHXN5ORsVlETFkoAsS4sr8XJtmZ7BY7xpWRKUeW0gg+Tday1jhgT4cHtZM5HviNe+Q5rPT5qxQFg +9z1EYPcPZ1vJ67vv2O/s/rRj5/onvUV/sOAP7uqPJcN/xug8W+yc3OucvD9TauvY/oHRduLP3m4CtqubV/f8gd1Bwx96Kpx84URdU9QXLiS073cc3unaPp8X ++vb5tj2vmO8/9Xje8E4b/EyencFk6+Zw7LTgeJ0agUC9iXhUujkZVOVMrGo/wjbqNSObWBV+NajVzdGVuRDdRFpdcXYNQMjXQ02iKjKFApALTbxpa5wDGgUi +PUMTcAMw2fn1oYiylNMI9PSMuIaTuhLCipxoirxrQF1OAAMgTDNdyu2kpsczqwEXoTstk7qlmHRGI3ABMLkXM+l0RrWAcQh2NoVIDiCeVmYxzmCTs82YjvRq +IokBhkKF38hGpucAskxOl/GyopssEX4VieW94zk1oWBrOR4iGqiKxFJrT6H1a0XPaMEzanjGNy6W+MNFvq/A9xl8/8bMvrPl3trdtaLTX3D6Px3d0T8586vF +3RO/XM33nDKco7/Xnoz97o7hnNxv7ckfuWS0Xs67LpdY9725u3NF9nCBPfyI3dXzpDdW4ts35mwk1uOoikQfzN5gHqJYLUr1iC0/AzXEUdH/QouV6NMozcSq +uIsyY0CYkNJdtXQI1NhpYZtqRoU1BALxHbKJT7L+VdaTdBfhG1dWfBxE/Wi4SvUJsLYE+OqDdf975btMH1qiAIcmjRcW63UYy3M4shPQmaAeomvN8zRVx7P0 +PJQwXlLCsATD0HIrsppIKRrR2bDx4jqfktMLCXnC7NVW5FPjpy22C55PZRbllDYRrIiHQVlrhde/N9CjE78e+sXQ7urewFkruNYrwJnOFVlbSSUXTM42ZbIZ +oFaThVsEEM9lIZMrZsuKcjuRXFY0PcDiAeICZw9Nx+JKTl3FXyc7lUE9aTvJ17mGz5PNiM5N25t9T+fWjR/eMDy9gOQWz5Z/07899qBv5+J+l3+v5+yT6add +k/chb/u25jbndo4/FcQXDDo0Rb1mkNv/nEOCr8gfKfBHDL43z/a+dsLsg76G+S81ErndwFSA+SzgvsA5m3Przy1u/b/xTEsUJGmqGaZhtklil+gRVEumKhtl +m6FYYmr3QXUcI0VdE9xBcq7Y4aJcPWLfOxV1RDxNfOJqtwNYtgmjHtBgYu2N8yMekjnmwqjxXrGRjiYWqZrF66ciXY0aldMco9909+ZPlm+XYx6TLdcdQZLI +TXptAYdgHODxGDSYWDP5taS+EtdyS0vJ24Bmq8WnifgkETuszIC/QfoM1AZWQYHPECtOUNfhuqVNPgGghp8I5IcDPmst0+F3RMpk9HTWZCCjYFKaaYStRPux +0d5pWY/bZuJ6xob9JRBZV+O3iJD4AdQLHihF3V1Fd3/B3f9oqDgwVhgYe7xWGJgw3N/cmC61CFvdm93bgw+O7YyWWn2lzp6Pwz8KP+KNzuH7s3/t6C55u0pC +29blzcvbyp5wtHpX9gQRSpCfSjtdP3kfiuX7sy88qK2DXJzu517kcN0L3w1v83vsUVIH+wYKvgHDN5jnB/Ps4OsOUPro2JcagdFnzinPhQnmjxPuacFJ+Ai+ +Lw0VGQGQlQ6xt9IzOWzVhtYAVPwkVG44C/H74nxGVXBPdc0V8mJttbcrffxOtVeT20RTN1s2Pk365O7hmQPy9TY9l00psaSqD0PKxx8GvKY7Hl/KgWdKPI4J +G9to6Scu01ghhYBOuM+uMjn9ThaKCMu26YTcnyUhJEdh1ZlWbWilZYvNrNj+xqbRCiunM4lcSpnA34Ehwb82Ca/nDEVRz9DA56j1L6gF/j9D3c/Qkb95fR+7 +f+zOHwkZ3pMbni84lup91eqmZqlXPRzle+VzUMf+5fVQx60d/gNQSwMEFAAAAAgAq4i4XClngrReCwAAahUAAEAAHABhdWRpdF9zdG9yYWdlX2FnZW50L19f +cHljYWNoZV9fL3N0b3JhZ2VfaWRlbnRpdHkuY3B5dGhvbi0zMTMucHljVVQJAAPRLxNq0S8TanV4CwABBAAAAAAEAAAAALUYS2wbx3X2w+9SpESJtCRL8lqS +5dCWyKSy49iS3Bq2EkuOhYQrBmnlZLsil9La1JKdXdqREgQKeojcHCwUKKIERawCQeHcfCpy6MHoyc2lJLYJ2YWSGsjJhwI0ZDRFeumbXZKiRSYtipYEZmfe +e/P+8/bN7ra1eRH8/hTrvGr4EfoaNfwc9oPa/RjGXyEBCdQlNEthiidz+hKNaXgyl5hZFrOzDuyw4Owl56wLu6y545J71oM9MHdi7xXHIBJcgvs01Y1g5ulF +gvckbcs4WRWJOaFD4HrRVRb7hKDgs2ZtQqfQZs38Qpfgt2YB4BSIUkJIaCfrqzRuX6Ij4YeESYQyOUlVs7qkK1lVm4vQplPSUkpSNz0pSZeSGUnTgIh9SdKX +4cmcU1fXuqNSPqXooqZnsbQki2o2JUevall1HiicaZxdk9Vko3eYmndesbxzBWFKQOARCjNHwDtXWIE5ChhrxlozB8yc9ZmrjnVbMw92CF5YcdbKh51LbRG/ +GRBsfWZSsqor+upDDrAmo+nY9Mk4Jy5Kmizmccb0EIXFJAzfegHLv8XPZVXZ9OiyKqm6qKRMzyKW1OQymfpqVuJsVn9IWSwVVTfDALOIbWHidRlr4MJP0FzE +ZbpFUZVWZFE0vaK4kk3lM2TuE8Wf5aVMFRMQxbSCNT2jqLKatQENoRBFHCLCQiL4GUBJUdJ1rCzmdZngwoDbJU41J2Mrqh4jwYplNOmEaOkVezJCNqy2qukc +za1iktLWQJylHYPhXfQgGDLYUDkQ/PlcOdBjsD3V0QYPDhXY3q3zX7BDuL+2OUk1RJu1I07tpqxoL9RxCTRFPVWdpyCbIdepk1VsnEFNv1qmxx3NOIGu7RxG +EWbORCYVizDYRzzigKAqOdOJrecnlOm4LmXyskY28HiAkHghafGKlFE0GY8AgLhaOwLDOtrh/Bv5W29u0QZ3sMQdLnKHDW6oxB0rcsfujBtcrMDGLMNb23zl +f2Sz2mon2PoJPYd5gERoy1Y8SIZhon2DdZ171onZHEknKYMjgOkhhEO2mb7ApuN9bito+PpKvsGib9DwDa9Pl7n2W1ObFwpsd7ORdM3ILsvIBFqoo8Y4KxDU +HFSAjpkL03PzM/M/Fp+feXF67tzl6QiFg/tUbKsfnBxUFjwKMALX/JZyZe7An3uPF7njBfZ4sx6+mh7f7nP2Qt2hCRRnWziXakHn/B46T50zt7dDgOJ1kvmP +KOkWlG0Nsv3NsuMdLfTZp5fAnHaAt9k53A4rTHZEHJgwxoTjHtRye8Rjn3KS5w1EJB9M15Ksk7piJ1PQAmFZy2auy6SWJ7NqWllqjJt/Ma9kUvUSgp8BIMlB +jRx2cnh6d0KHtp0lfrzIjxuhEzvBvq1XSv1jxf4xIxjdCR75eOlO+u61eyeMkYtGcKYZEOreCpd6Ros9o6WeE8WeE0bPs0bo1CPO6XNWkNPhtNIBdGuXMpns +DVFRFV2xMr2eIuTpRtUUmXTYqRpv9ur3nz86QbesOxT86dM0jxZc9UDWa5HK7CVCoh4ulU0wcQ41/Wqy6qf8AuzaO/OBxmQRmATjh45hnKlxlSngGmjmutBe +ty/YjB1EC6G6hmw9oRyC8zRtdRxgWYIZhsPYwCfcig9I72mG211L3XJHgo33NVMBdKAllG8JHWwhh4uyqiuGVHfCFT/SjK/5thNd/C1CeShduQ8TjvjRFvw9 +dW25hEfwTcBMgigmuMbqexlkLETqe7im2txWr+rHv1ub+FgLS/z10rDH3/v/4H/mRtWW90CCOx5rpk14IMcCCW6Shmd7wkue447afh5dvkMaBcgQd007qf+J +LO0QgvFnWvB113gQBfcyWOhUfQvjdaq6JLWtVZb+mzgfq8b5o0Tb98dZ6CIxtjwBuZmATnmCqq7hTfrq3xIeq6+kVN/l0aq9LTkKoRpHyNFmtOVvqeO7sIk2 +IVSTk/DV/Qlv5//uXAO/plN8GC301ihcSIL3tupsiJaH1JTxevVKOGVa8KrOc840lUZTzCGUpiJhaRHQ57Es6XIM+igFOk2Zh36Zr70D+LSSgZXK68syr8kZ +OanLKb7abPKkbY56vfPLisbncFYHrGZR2i12nS6dzaRkzMPtYYWXkkmLOZT4VX5RVtQlHst5DbgurvKSN6Wk0zIGPD8df4knzXzMZjbKS2qKX1Kuy0+IIMpC +D72YkfdpNaPzqSzQQtPNa0nJtuDGchYIV6TkMjTmE5YITVcyGVBhRVJUm3NagZaKl/L6chaDC7zpLOZlFcYkUZYIlJI66GFpZ2lFYPbrNI/BEKIAnwODbR2j +3nm4erlyEjFLM93yG4qmi9lra8PVS81+b2v8iqJpRJik82vHo/xM/UXYyruWOOu6EX1I2jjycldUwnLNkdfTY88BwA23tmwKWK6NfafQvAqJkCKujCWzGOdz ++hl+LsK26j3O0rWZDePJcBiGNe4Mb9kHoqbWuFFY5KycmVr7jZUm+1ICINC+gthVHu5K18B5epaX+CezoLanIRuifKLqjJq0/V7JSclr5AnBg6ZH1mPJZUld +svc86TmSlSAmyq/RE/zzmJxWu7fqtR6Y1FLTsXItpWCrl7X6ItNpCdZMXzwPblyRpzHOYtN7A1JGFnX5Dd1kybXZdKTyKzkNsxaTTFZKaaaHGGzTeKbfSMpW +F9/KzaYDmK1oJgOdXMOVYMiSL+VyspoCKVlFNVlyrY8E8NME/wOCZ4lxJks6cJJydhSqyQfuMhn5jSQ0gcty8hpoRLI3AyxA1jV51fTXNojW7Yqs7W3Vtct2 +Zsp05XOkZqQwKd8aqWz8kz+7szwgqxocDXH/LRULgCU+1RSatJiVduQLlriBIjdQ4kaK3IjBPbV+ocy63519Z3ZT306tzxbZ4ztc91aXwQ2sX9gJ9G+ljcCR +9YsPuMCtiZsTt6ZuTn0VOLQTHCgHn72r300XJpcK7cu7DN3pryC6zV9xIk/brcDNwK/Z294PvR8/e+cpY2j803PF7rP3D2wEiu6Xy4HODa7CMo4r1E7ngffP +/PLMVuq2+oF6d2lLLQycNTp/uOGueFHP4VL3WLF7rBzqL4WOFkNHmyePPA6/c/35ig/1DazPlA8duf3WB2999HbRfXCDKfccvt33Qd920ug5tkG08gUKwVGD +G/3d63/xdZF76tulwNB20OCG7fspoErceJEbv3ve4J77/fl7/tKP5gvxeWMyUZpcKE4uGJOvlSZTxclUQV42JpXPOaXsC5V8fUVf3zazld++dNdfjJ27d/D+ +68UXXy/0i1/4fvoogPjo34PI3bHj763QVNsi9cAfvKXcVLY8hn/wkYNuG9pl2IC3gliPt+JHga71F3b8wc3n3lNhEu7betUIj5TCo8Xw6Bfh6E0v2MUFSRw2 +Nbhhb619zkXuyJ+e+Tw6XT7Ib56/+WI53PuLFx650IFYxY3cgXfffOfNzbOGa3B7uTQyURyZMFwT5cFjpcFTxcFTG65Nf9F9qBwMb7i+cvnLbt9e3LZP3X2m +2H3ynouE7GKZC6xf+sfjyxTyhXYRRSLn7y30z94/cX+kcPBlwx8vvLpQcC/8s8IQ5LdaN6TcHw4OzUyhP055Zw8yn7kCs2Hms7AD5lDxSDJjl3XKsKznsWod +vkYEnidDwIZaVcJZh5IZXOGtudemsE/kYjabaSDpIB+Q0nmdHAwRk/sKniJ0XP3jnGzXDUzuKuQFoi9nlEVrs+nUV3NwHm1ljtdE2fdA0mruVSrrjFnflOxv +OtaBdE/aX67O4p8g+1alvQYD+IeivkSDXyPuryjyJer+EoUfdEULqOORE9Hc5nCROvAN7aSG4bpGDe8yiO6ukOXjIEU9/dhNUz2PvRQ1+tjtopyVLnSOmqa+ +QVNUe4UMlvh/AVBLAwQUAAAACACriLhcevoyGcwSAADpKgAANAAcAGF1ZGl0X3N0b3JhZ2VfYWdlbnQvX19weWNhY2hlX18vc3luYy5jcHl0aG9uLTMxMy5w +eWNVVAkAA9EvE2rRLxNqdXgLAAEEAAAAAAQAAAAA7VpbbBtXeh7ehxdJlEhZlExKFGVdKMWyYlnxxpYsy5Jli7JlmTQdObRDU+SQpk2R9JmhL0oWKPZhK29T +pMFuG6dYIN5ugchosFGABaJgA0TZBqiLPnRmKe8IEzkxsOhDXgolNprA+9JzznCGt5E3LtqHFqWEM/+c888/Z85//c7Mo5oaEwF/7B7b5cUWgvgDUfLTigfV +o2XY/jURIAKqacKnAio3otXTaqDGRw3QwKNmWuvTAq1PB3R4XDut9xmAAdO6adJnBEZI6wOGaZPPDMyYJqctvhpQg2njdK2vDtRh2jRt9dWDel8DaPDZgM1n +B3ZfI2jEY+bpHb4m0OQmHISHCFhaiEDNkFqcKXAk1N7arxDpVQnmSDqdYSJMMpOmZ7xqgYxFGIpJLlACidrFTJqCXNrZCHMJHjVj6ZtfqcQrjUf9s+OpJJVm +kJixBCTGM+l4MgFPDScy0Uhq4ohXJzQAis6krlHhXDpGgTDIZBihgY7EqTCgUvC+cCQLhQtm+lJk79AL4XgyRQlWOpJOMslFKkxTiQUoWbBfB0kGnjKAiiyE +mQzmg3dqotJ0DqCBDIgkqHAyBrmTzM1oqY40ko5ewzo6TwBVgIB6UUGd1AbULcRlqJNAXUCDKR2ktJjSB6yB+oAO0ZdVwAD79bifDDQEDJgyBmwBElMmOGrE +lDlgD5gwZQk0BsyYqglYQG2ixrtDsATEmeIF+8oKJxTVVZiTBk31V3iqc9aUPDTnTqlkeiCllunDKY1EB1RBIqWVzkJmiQqqhgrXwnG93Ev4Zbr489dV9wXU +kNdS3S8ZlZtIEF7NTELqX/QUHtONFO6WtOK+RoFkPEnFDrg7aS8p6KPYYuARG5Kgjs0L+lQmkaCAUJuK0Ez4EhUBzDwVYUA9FCvUSJJEO9Em0/EMNDItTaXi +YAfkAE2ocaCmGTY0Wls3+gkDexbSzB5o3pE9KTqyLxxBCtgTycWSjGw9Yh99Mx3tz94UyHA4Cc0wHBYaS5XWL3WjGdGTsPkzYmOH83Wz2DTYXzeIVGv7knad +3Lnp2rdyNu8aRictG6TlDfMtM086ONLBNo+sMvzoaW709H3S/wgtXFRVsrp6yRi2sDFAFVRrgPBrqvuQGfi1CrwK6i6qsBXdwajAQUgcIZMsScEYPERINh1s +MEozsG4/A8lE3cRJuKrQnBYHAwxUfzKdcI8hPbknk2DBjWOLW7IvrBJ3PAPc6UyMGumkv0Iq9xqx+oETNcguBCMaDkdhI5Aglw5n0lFoPzjS6egURWUFzULk +htCYzaRSUL8MBa5FUjD8QAuN0XdVwAWF0GiCbtCK5JmREHhbCpq04Cizj5KRFnQV0ho0kXLNz7CzZ/jZEDcb4mcpbpZaJ+MbtQ0bFusbJ2+dfP3UJiR8P/H9 +jL79/O3YO1fevsK3jnKto/mmw+uWsa91RJ0NtBMV5oKWEZtLjCiYS+lolTKDiqZTNq572jj0eNWMVyNYwwuRm/NU0VUFexZkohRNy351OTNPC81SbyxzPZ3K +RGIwA1zNUTRDg97yxQWdaIUbqxYV6cyNWLukFeVJF0e61sk2TLdzZPs62YHpLo7suk/2iKukLnkAo7RKi2q0SiF5QMm5PPLzptVBVU+BjnWjaOxXV/PvNuGo ++j1d70ADQUTgYyeg64VImdNUzenZTqpSTC5oPQefMrsvrQuoim757FKCmqA2qAuoQ7Lj+huU5heyy+M7tpfmb1YYKxwDmn5DWg9n2KIwQ1d1X1BfDBiluRFK +aFWQ0F7dF0BPptttLs9j7UTII3EYiAi0tbRhG5mdCjL1QUNRWhss7NKGMUOicIyr4sSIpo2Iq7yGGUF/aubE1MzRRd1A/77+Aa9B0NOwFMvRgkVyHFwumRlY +oaXC8zcZihZMOZqKSXQcUFSBrsG5KwxjDg1rOaFWPEXxLZxjoovO45J3umnkTm58aWJ+pLN/bzwJ53p40V5kiUdgdhWTtA0gZ8OBTAykLbKfV4VJOP9LOSaZ +EkyxJH0lnKPhJABSr6CBhRvQYiqduQ50mILzEoxJOgNj5QJM8ChzC8ZiwpcDOCw1b0SpLKpRBSMlkV4Sx2RBBy+PUmAU03ilBC1aI0GLHlEwZCM3UbARNPBK +GjmZu/ATQ00/us5Vnt8r4hnSMv3vBAo5m7UNbyR/kvyLK0uaL+rst218214O/jfvXTnzYeiDED8U4oZC+ebQhqVhS11nHN10d/Hu53/nfn5l14d9H/Txe33c +Xl/ePc1ZXEtHb9sftng23R13Jt6d/sU0v2uY2zWcd49stO3aaPWg//aeTU/vu8/93XPv3Xj/tbuv5T3DvGeS80zmPce/riObapeObTUQNfVvTN+a5i3tnKX9 +Tve6pXfD07luaUOZBPc7OYuTdR1cPbN2jhs5dd8y+91mneMRoTKOyizoUtbzg9UO3nKIsxz645YGjj6hkQI+1Y1rJ0zEZybTxAHNZ3bnxJDmsyEdpMsyD9In +jqm/JVBMbX2GUkWOr6hkMVePFyvXgm/K8avgm+ptqliFQgaWReoK31SPqROFY4lvqmcWe8YzuVQMFhXQGygmesmdpdIxVIkUXNONchr2EL1oufYCQ1nSA+NE +oRIFE4ipXSkzhuMgsxAu2KlXI5q1Fl/uQ6anEY1WtNc9WEyZvSrJPIAuDGCb3VJrjbYNW9NbB988yNsGOdtg3ja0NLFlIow1PNnPkf3LzH1y33ePjYS1+RGh +hszlhnHiXnTd4t+0NPxxSwdHnzxCof5HLf3Er0xjrZpV51iz5tNmHSTL8iwp2cQJFa5GitkTZ7xnKWZDch1StIaiHoMqG3G8EVkCtjtDtQSo9WJpCZVllmxJ +zrnQlk5BCZptZqVgl7DAVftrFPo10r1Gik9cj+epzK8tZtohjTRLbJuaMc3JNFGgSqxTN7PYNpmBeBpan5JZ9tDeIv6C54VwjkhYH7spADIAEoI6GRP0sBOC +Zq+hJNxqUlRasIcVzEo0Z2zJmgTFeLXYVsEp3IHGsb0iDy0G2Bk02P8nDbbMCdBt6FsEst4v6qA5NlQUzwc+Hl/T/mZqnZz83Na0NL6l1tTkVBtWO2/1clbv +csfvrf3f6Ai747vHesLugsFOHH7L/KaZt3Zy1k62y3dv/F+P/fMx9szcv5zkT6TZCxf5CzHuQoylFvIX0myW5q3M76wMioXw4iePEKT8df2YSf2pyXSkQ/ep +fUx7pFX321YdPFG2+481ot0rVeEB1bZWoswvWzCMd+poLcT2RcyvCapqiUGZg1JtI0OrcM/Wp9xTp8CvR/ywxlHXEgHDoAZ6jPK15DM+n1F+Pm3R2+fGJS9P +60KyX0MfJYmqn1LVHNTJ1+uV9y8U9zpMc11FTwwqQla/TeFuRR0ZQo1yr0GpHoa1a2FmOXjM3gqYg0b4R4bk+jgor75/p8IcLcUNvZBTnpVCjQorc7c87qke +l++iUMvKlXltvz5t2mYluqv75npLojPh76nm8PdV95U8u7yS/5X1mLs9pC25+/fVet1cT9BYvBKiW+sMQBkUnIHNoqM0oC4kaRoF3SSskYX6DEgmkmlYBqM9 +qXQE7Z3KFErj4Sfa/vlkWrCl0M5Fxb5nTdnponuiAI4rYjoK3EwGBnzBOH7q5OyJo2eOTgiq84JqD6w/9hFoQ6KwgXopQl8SjGgCpSSdXKTARcRXg6RCCAHr +dwQM+kufK5pZyKYoppgr0GX4iGVDyrsDvISkWPy5NAIXR1EyASivAbTQ4h4cspuS/RexPJJRf2k+QQYlaEDkOjiEKAOgsqkIrOaRGnBJj6ECOIyaMcTRFIle +gQgCprMEVSYJxWe02RzNgLJbeM1ijpqTNAnOI876smUPoxlE8CrGKJqBysRggwQUnc2kaQqgjU1BH0sm4CiYgic0KgncxZ+Y684SVcWZUho9iQT8UIX3MOrt +bzW92XTb/lMXXz94p+Pd7l90L/fkPYNL+g1z/a1Dmw2tbPuLqx2rjWzbaL7hMGs5/NDZ9s6xt4+xu/bnnT/gnfHV8U+OfXRszZcfPgXP2OA59uWL7LkIG6HY +QJwlmzck/hfzzgO88+xq9JPER4m1TP7QWTi66ehl+4bzjtmlms0dPcv293fe3cl7xzjv2NpIfsfpJfPD8tR7cPXsOnm6rJxcmczbDi6Rm86BFceHzg+cq6/m +ndMc2bxk3NITTZ6Nne0bzW0bzZ7Njq47P8x3DPEdI1zHyGpsbTzfcXzT1fnO+b89/17P+8/dfS7v2s+7xjjXWN41/nWNwWZaMmxZYZkqTqCPI/uWJ9fJYVQN +GG4ZeNLDkZ6/H7xz7ZcHf61ZOZkfOMIP+LgB37197Okz+YEg+9KFdTJZUTtM3XuBfSV2n6SqN9D+D8KY3dvBGMkd3dImXCmWaZawTPVWXQWg6dp2U08R1Rjl +UQVoM4AFKlaKVdKRQ9LInSV843C+Y37bzDtGOMdI3jG6NFXAN4McObgydZ88tD2+Oc0Gzq5bXioCHHCaqNg0/H8w8z8IZnZVgZlK40SIpm+iolOCNYXTUmiD +87ZgKoxU4BsQRIPN4e1sqwhycKqTQM51QkxQIo8C0LmGGAa/n/mWOUcWyfolIaIdRzXaGf3MtnbuH1vXydnPm9xLxxDa8akx2unnrP3L139v3Q/RjqO9iHbw +cCnaCbDB83zwIhe8CNNCPhj/liDOqibU7OWr/OUcdzm3RRDXVePqRwRxBXbDs0n1lBoy1fvU3+AW4yCf+gl4mahwDWSi2DV+rP1fgHeedk+5wtwW1eifEUEZ +tkVQJEZQRoigSvHOvv9GvKNXclApTUmziTQiLKJUD1dyxpAO6uUnMwX1FTrQK2EiSYrfXj0GUVKTPHN5ZdPkNmtpfkZtWmSJEFnJzztchimMT8MU0tyD5NNG +D8C4EKkvs8+aoBHqtjZIwhXSlqwQ4XdWy/G3Kcy9rgyBkvDPoIiElN5pWAP1khUEGvq1GLcp4D4lvDfXFTSVId8uheu81X3fG7n9ifnO/XkZcvu+q2Wb6w6S +ZcjNXkBui7BZdFUljlL4hkERrv0xMAAXCIQOKgN2GISRqE6/eAqzDkJV7iQtVlY4f2F0l7oJRVJIUBxd0BM4PgaBE7rjQgSVX/MUejPszmWR9H43dSNLRaG4 +kUXSHYkyuUhqZLE/mJUAIL4JerddmRBRskPABeU69KkLQK98YaaS74wRC362xf3ViTOXgpIrcmc11GvE+Q+gt0NKCE/QUzeSMJcJhiQtfo5RPwnbmQyD8zlG +hoIWvdESDHAFMQKtRRdjqKdLZa7DdF3MyuhDF7H6dIqrU5U18U1EoNdUAHqVLBLae03SPshIShXxnUnE4FgSXiKAemskPYiQWUfdYEBEGeK9SlQVqdtWEmgC +9Jcizmt0vhV6M3THnm/s4hvHl7vf77vbt5LK944vmTbMjZzZhZHeodXrq0m27Xi+YYq1TD10drwz/fY02zOSdx7ina+sdf9T36d99zL5I68gXGdrQRiMbdub +tw3ytmsr1z989YNX1w7k98/AM/YMxcazLHWVvZpj/dcgPCsAvWMQ6DV2L2vfN9818z2HuZ7Da7vzjbNwGnXNfJ2bq3PzdR6uzrPcy/cOc73DfO8hrhfi0A62 +a/qe794wu2su33COtZx7aG9969Sbp/L2Tt6+m7PvXjJu1jtvv5yv74HY1dGGyvE7J/OOvbxjZiXx4ZUPrqz15V+YWar5wuz4eeCOK+/aw7uGONcQBH2rL3wy +/NHwbw7lXVN4Gabu7b7Xxs7BYiXBXrzEtiXzDZdZy+WHlUjuwDo5//Cp8PJro67etKTbssgocoAjB1Za7jWuk6dkHNnNkd3vaZZ9/1D3sW31Qv7Faf7FAPdi +gD0zx74MT19hL8bXSVBRlvnZM6+wV67eJwFGkgXsJPoH/gAJZQ3xxSmy8hnIgMdMkqEDo8yKTM2rFvSAYnIgLWhn8Hd3k9CjjyJ7085nMinwOuL8S9HR0Zb7 +E3sK+l8olowyIViSPuceS9+8cKGMDe16PKkt5yhjQFUteAM1pb3Yff6qvLcOfQ+F9rTCYcEUDi9kYrkUoi3h8FUYtwojdWHoW4BmUsk0lc6EwwAFbIB2+/BH +Hfh1K36Hhd8L4A0TjPtw9Sw6V2M4jIJGMhqOMAxIzucYig6H7xJ4lUUntEkN2j/C77p+TDxWG3QB1eMGre6I6rFFo/P+h8mmG33ca9ANPW4gdTnVY7tDF1c9 +3meAvbDDp/7WvlNHq0SxSJi3GT1YPMegDwrDACVCMIwa/AYcb0nhF9cGFHlTyXmgx1GQuZmFsRoYytVaVLWsYBQGcdgQAykOqTggovJL3AqzVn7HiD97E5/3 +b1Ajr4FADosqOAR+Dk9RhqX3wwbW6SrVA8LzB6Lmc8L8JdH9gHA8IHb8G+F6QLQ9IJq+JLL3iewDYuhb/VG1ilZ9Q+ADFvyfUEsDBBQAAAAIAKuIuFwdGf3d +CwsAAC4VAAA2ABwAYXVkaXRfc3RvcmFnZV9hZ2VudC9fX3B5Y2FjaGVfXy90dW5uZWwuY3B5dGhvbi0zMTMucHljVVQJAAPRLxNq0S8TanV4CwABBAAAAAAE +AAAAAJ0YbWxb1fXe9+HP59iJncTYTeKkSVOHNIGWgpoudGmpOseNhfIaWgjweLFfUnfOc7jvuSUBTROTNjoxtd3EAhISRSCRiklM2hDdr9EgQfvPxmh234Im +hPaj//o1hbFp2rnv2W6KHWCz5fPOO/ecc88599x7zvUtj8eF4JMf9p/4B2Bfog0fznrgW08CfAWJSMRxNI4JjlCciTOEgScbZ8dZwo5zhDPpXJwftxFbhcdO +7CIv2uKOcSdxRlAQ9SDRHkKiYzdjaSeuOSbqvEbRKDbcsqpmdVlPZ1UtEWUMR0rWFT09rxgOCpeyqgJc7Ji6eA1bEoKoZ4k8p4zNKaqe3Gg+UzX/UdP8JxHB +IgKTMWFFl8iE0AkwWXSLrInxgHEmZhMFkTcxu+gRbSbmEJuo2YA5RQeY7Ix6jZaNMx/JqaqSuSbAVEnmG0aw1IgjphHHfJna0BSe3MhZpaIM3sDBNeSoyc2h +CzhxgTV4mRph2JJZdTY9Z9gy2bk5hUAAOU3JzBIvsGpUKBIxdg7Pq/owxFUezmjyA5IpOSznUmld0iyPKjTd9GloYdFwSFJahXHJCNc7PVQd9NBZOgD8FJVb +Qy+7ym3h5cy5zEvcy+4a+iv3LWp3Em9wyAa/n9EYvQ7UrSgCcZpCk/WOo0m2niZi4G0QpUlbPa2acxHUCV+QczbgqTxjkL/HYb6T+BU2wQsgM+2r6W6pl+tB +0601m9hNbGrb3KbdeLN5J44i5ECJ/d1o2lWVsiOZoX4oaFrYQNuGkIo3iZ23niYyU/hOTLpgZhWP4UTOes7iWTTKdqFZDLMffIidxVEusRQRdZnoaXUuYqVH +ZD6bUiKzWRJRARnt05Y6rLSIpNIapKOqJHUlNawQkiUjkT7tGg+TRQXSDA+DS6sgSeNqOKm4lARgeKSKnJRVk4rRHFcWZ7IyScVUHdTkFnTDefC5pLJATwnD +qdRQu6wtqsl01uC1jKIsGOy8/JwRtMyUiFJVqlEspUUZQpfRYEFBZW8QPyW4SU6VwCzlpEKMzgb5vmGcatDeBnCNJr1DOOM+7S45ggVHMH/P3otH/jz9p+nS +6GRhdPIzh1huarnO8J7DuNwWKrVtL7RtL7YNlHwDed/AbRfyh5Z3/HrH2+z5sfP6O8+/+XypP17ojxcjh4stEyVhIi9M3PAir/8L38BXa/7eWwh72sq+1jX/ +FoqCTl9g2X3OXfL1FXx9+W0HP9RKvnjBF//XdZYOf/2FMPFP7TAY+uLOAwJ+37/fgy458H43d8nJUdzzEJBXOXzAxa3yNoq7MdBXPYEDdna1e8sBhl0d5YH+ +EcMD5SM7B/hdu9iNKrv4P1x1F0/fyb4GJ10P+v47V8TwZURW5PbwGMXw3XsEch432sug391AFw90Tz29uvNF2xBT3Y2b70rQ0dTA9uYG89lFxx4MEtiHWr6h +R2VAj7+B5TULVPbOuTLFTtdOkOq+3V55T8H7xP2UZ/KeBjY4a/q4KU50jRxDSN5hRijUwIuOBhrcm2gWarGqnSQTOdDMiR46yyFMT6pNZJuqsqN4ox8RNIxU +fhMZbwOZEJXZhN/3LXPYpjtrPkfqZXs2W+WeetoUX/W/QeX4/2bpbTCLbfNZ/qc90Dzdv8HySoQ20GxV2nfslZYh7rv3yiE88RMzI/w0I1IszYqJX2yafdEG +9gY2WV1/ffaB7vdpxTzEJJoA4kTZhB9AFfsEfr+FX9xFu1P41qIJnexxrmqzA1Gr51A3GmwwUssimHmWoXUzYnJbtdKsk62Ja3TJr9F6nUbIh6Kc4VmAogmd +EtSwk3LGEMxX2tRmc7rhSWaymlJ7dUDlkrT0kmLwRJFTi7SR0xcXFKPJ7MxMPimnJ49AA8xDKUoqZAjRIloruEuBShWuUWjxJTTc0BNCmTS4E9kZzWhOZU+p +maycggL5bE7RdM1w1DDBmk2DhjynRbnKHFQOiqiiGp4qp/lKhukwb1b6pfbK9Bp4q5BIrfxHWw3XKWVGyyZ/rMAM9op5Vg/gqtTqHMnQxlVNGS7phJZVJV15 +Tjc4Sc2eMltZwyfNy4szinRcgV5kRpF1wy0tyAQiSNkNV1pLq2A17R64VDqpG+ycopstB6FJRLpMR/SspB+n8TW6F0g2qWharQE2fZwl2XlQu0jDY2yrstQF +7G4+NgOR2AP6o3arv3DW3DVYIp8y7POgBeaARdYgAWBVyS6T704w7TSPrY/ZkpAwZYg0ar03dkr01NZ4xmpG1vwdJX/fp/6+suA7Ez8dLwk9BaGnHNpSvidM +YWtk3W0LuG4iANcF1NRebgkvD50bOh94p+vNrov9+anHS1NPF6aeLk49U7hfLnbPFFuSJSGZF5JU4/jp8ZLQXxD6V1J5+tx1tWPbOmr1HMVnk+VQZyk0UAgN +rOwqhobP2tfau8/3/+6BUvt9hfb7rga2lMM9b+x9be/5k8Xw0FlH2R9+9fmif6AcCC7Hz8VLgd5CoDfft/fiUdo9XQ4XR6c+CzxGxYDx5Gfg0AbxUviB3/ec +faQc6X/H/aY7P7ivGPlhKSJ/eOiT+KV4/tEnio9Mw2teks/G1kLdpdC9n4buXXnkvfEL46XBY4XBY/knni6GpFJAygekOoanCoNP5aXjxVC6FEjnA+lyaOsb +Q68NrQTe67rQVY4MrfVtXwm89fhadHDl8Xc7ywND70UvRN+994aTH2m7hfiBdgDh4DriW4PXKbgpoNYO6sWpv/i31dztKwSgYdv3Yfsn4Uvhy0eLY1OlwGM3 +R+naNCFPCt/kaFRvtKHWZ/DVQPoGQ9WFUWf/uh941lE9uE7Bv7WLkA4v3h8bxX/AMWz7IwvYB7tjW9lLgzjG2S7t6AJ8VeBiPm7Vh2N2x2ozS/HAgSEY+JjB +MZfjY3YnxXfjmOD4+MExAV4uw0CT4zLrprifsl7BGFRf4TDF7Rh0XHGZuGDCJhP6TNjsppxbcKyLu9Jho/hWW2zEfiWKY4PClQGB4g/agX5XZ8miyh16FqHv +0VVO13rKhnfAiuZvu/v1IkJrXpQnVJXB0nOHNzE4cw1nWsvCmTsv61FsXQCoyspO3UfZ2hvtVFDyMGWmhQG2p7f9zAu/fOH1H71x+LXDRW+05N1V8O4qenfn +HbsJbfLu+heBr0ZgwIwA9MIoTntHptGNeMrslPcwvVB1EoSqgfKBBw08AtXCpSlwUMpgn0aLinlc8qnc/IIWZa3zyl45ycgxai1rHUWWc9R+o6ORc7VTeowK +BU0X/+rwl73+M0unl17tXznx0lLeuyfv2FPvHFd1LnGXc3BB38S96hL2QoG+s9hw/WWta+wcql5co0wlAKzpjcFTxzTSYZGou2ScgnpXqR8NL38b6sxBKjVQ +9fU6wzqDZX/78si5kVeTv9mXF3q+WvMG4XrmDF71t8M9zBn8WqO39Z/72tGyq5d9C/eyhDZ+CbiL0rpGHNZi2Yii54hqcAn6fxdDJDP3NJ1ADX4C8KUmWtSm +gTAYGVMXnzIZiEyFOcshiltUugWibvoPjipD0yBBSZXgyp7LUFyQpGdzcqYy4pWk2TTR9ExaVdSsJJEAVUC7ULMAmblt5oAVnVbJ7AzSSUnWdZKeyemKJkkX +kLnCVhRdVUDd1rZS39FthuW33nY5+QS+3dbFH8W392PMP3jbxvAj6w6eD1ryLstusHY2B8EA+whdILN0m9vS2pE2KKDQSJlukhEKdlYDac0+R0HNIsPxA8v5 +h8kJeKWJpHUDgNXB+HPU8yVq+hvq/xy1fYma/446120P4+BNBMBU8V9QSwMEFAAAAAgAqoi4XApngiOWAAAA2QAAABAAHABjaGVja19jb25maWcuYmF0VVQJ +AAPPLxNqzy8TanV4CwABBAAAAAAEAAAAAF2MQQrCMBBF93OK2Qi6aBS8gHdwW5A4mbTBNhmTaWk3nl1DQdC//P+9f2HqEybvgRweHe5eTk4QPMakyEsoimbm +OLdXykG0tLJqn6LhhXEP+Ims2JyxGbFiGwwHIDsMf6YlDbNVNnersN1UTYJgiEWr0DSTdNk6ri38LBkzP6eQeeSoxejy/aCe6XGjFH3ojKwgdioMb1BLAwQU +AAAACACqiLhcjA5piscBAAArBAAADwAcAGNoZWNrX2NvbmZpZy5weVVUCQADzy8Tas8vE2p1eAsAAQQAAAAABAAAAAB9Ul1r2zAUffevEIKCDatW9ljwQ+p6 +XWHYxfGehWJdJwJFMpIcKGP/fbIdpbG67MEy9+Pcj3NPb/QRDcwdpNghcRy0cejNm0k/BdjIhaPWacP2QP2nHOm06sU+5ErNOF1c/4FI4X8BUjZvxey4DZB6 +vwcTABbcONDFdxsTLMG9Jdx7QIOyowEah5NkxyxQLgzK541TSnshgdKMGLBaniDNyMDMNOh5nnw1ShoKZMmZk/yajtS6jxT0FWEC6oSz7FyMCNXrFBdz7miY +E1rNcODIjl0H1vajlO84Anj26NNmW9Jfzc/8zuIvaGlHwAx0bjcaGWGq+rmkhX9WAKU5+Fk5RNnbtm42LyVt6rpdAQKFRmsXYdqy2lQtfX1eARwoppynHGmD +8H28ylOzqYofMWhnmOoO16BwMjrp1JN846LpUiDeZslCF1lMR0YnMKIXwB/R3HrVwZ9z0Wv+IdVLbS+NQSsLPrhkkQMw43bAXPobW8fcaPEjwkVdfX99oW25 +bX19fE2dD0/S+BepPnMWM/XzWS+IqdID+UYe8J/PMkCXzsiBdSiMdt4pmFkyGOE3iKTGFJ/Wm1hX0M2uuUqnj4MEF6mQ+DP8BVBLAwQUAAAACACqiLhc+jEC +YSIBAADoAQAADwAcAGluc3RhbGxfZGV2LnBzMVVUCQADzy8Tas8vE2p1eAsAAQQAAAAABAAAAAB9kF9LwzAUxd/7Ka7Fhw1MQHzzbdaiwrAlqfgyKLG92wJt +EpPbsSF+d9NuqOCfQMLl5nfuPRyJxJa2UaStgfNSysZrR8JaSvQaZmezCgOxUtEWUr5Ds1sdibByB9paw3GP6XwObwnE4w7AroD1MJIw8cl7wld/KUfUaQfa +BFJdB4wNbuNVi2P3p067SfRJe/D4OmiPPRoKnPa/mY4Tvgxm1h3YA2F//IjjVO86TE/cxDx7TcjubSBIM4+KsIUJhrJDFRCw1QS5KOubhczrJ7G8gMfiNq+z ++JxKmWcir0CZFmRViMVdXouiqOAF19Yj+MEYbTY8jel8X7eUi0sISIODxo7G4m4OYjDX8H+Iaoie6kDWqw3W8RrivdImJmpNg2nyAVBLAwQUAAAACACqiLhc +Q213CvoBAACcAwAAGgAcAGluc3RhbGxfdGFza19zY2hlZHVsZXIuYmF0VVQJAAPPLxNqzy8TanV4CwABBAAAAAAEAAAAAH1TTW+bQBC98ytGSI7ag6Fpb5Vc +FdnYiVo7FdBDpSjWZhnsVZZduh+ufclv7yw4qZtY4YBWw7x5b98bviLfatBNE1l0UnMmI15DWsPose4+RKIBpR3gXlgHCaodvIuAnh6VF8VN8XkoN0IiCAut +sFaoTQILVGiYw7TWf5TUrAa3ReBaNWLjDdbANqgcdIw/0Akao1sa+CPpx3fMWxyI9sJBeg+X0fsXYnZEe1tyIzpnb7uD22qV4B5PBU4NMkdqYCeM80wCQYTR +qiXmJDlSHWD8CcYthHnDVKIiH+QLCsad2NGFknvmooEvwDrRgVDWBcB47LuNYTWGavTfFwMGf3thMHDbxO1dMBziKiu/rVfZMp9kvhZuLkxbOm3Ikiz4Ew9d +2SJfVevZdTHpY4lPsNPlbMJbiozDEFwcj57bR3EMdxd3F2C8WveGB/UE51vH7IOFNFcEqlYQj56VjGL4oryEj+F90jpDiQ7PdKfz84De/rOAqniqkPxQKKdw +syqrrKggLX5C+aus8iUdv8PV9eIqL6k8D/mjMdpI3KGEy9OkK2KEkm+x9hLNk+0UPoXUMNrNOoHCK9pB2tF+V5mFrG6Fol2iNdXmrcX7d6Uw49V9ol5Cnx+E +AOGYIPQRgj3Koj8gqDxqC38AeU9n4+hsPedobeOlPCTRIOMvUEsDBBQAAAAIAKqIuFxOD3XONQAAADcAAAAQABwAcmVxdWlyZW1lbnRzLnR4dFVUCQAD0C8T +as8vE2p1eAsAAQQAAAAABAAAAAArSi0sTS0uKba1NdIzBiKugsqSjPw83ZT8ktS8MltbQz0DPUOu8tSk4vzk7FSQOkMjPQMuAFBLAwQUAAAACACqiLhcfDvP +u/wAAACUAQAADQAcAHJ1bl9hZ2VudC5iYXRVVAkAA88vE2rPLxNqdXgLAAEEAAAAAAQAAAAAXZAxT8QwDIX3/Iq3IMHQFMTGhIQQI6isJ51yiduzSJOQuKW3 +8NtJr8txnixb733PfiZ7jIh9rwqJj9Z4ZR1ah5tfl+4V9whRQAsXgaYw41ah1ln12nXv3dM27tkTuGDkUjgMGm8UKBuh1sWf4KNxkCPBxtDzMGVyMAMFQTL2 +q3bocxyr4Yfe7BcWtAc8qLurCHOF7T5t5iRll05yjEHTQpexXjIZqRkwc5bJeFQJ5xjGytN6A6QTmkc0I1a/zbWi6vX+CmGs8FzP0AcjauOtssQJHIqsgqaZ +0pCNo3Wq/m0yMn1PnGllFy3LpYeZHMu+SMz1AfvzO/RoOKg/UEsDBAoAAAAAAKqIuFytoohsLwAAAC8AAAATABwAcnVuX2FnZW50X2RlYnVnLmJhdFVUCQAD +zy8Tas8vE2p1eAsAAQQAAAAABAAAAABAZWNobyBvZmYKY2QgL2QgJX5kcDAKY2FsbCBydW5fYWdlbnQuYmF0CnBhdXNlClBLAwQUAAAACACqiLhcc9tLXl4A +AABmAAAADAAcAHJ1bl9vbmNlLmJhdFVUCQADzy8Tas8vE2p1eAsAAQQAAAAABAAAAAANyTEOgCAMAMC9r+jiKPoE/+BKYmqpSiKUSCVx8e063HST8KGo2wYc +cAjYvaGMwHSe6Jrk5me+YrHqiS02MnErGZTHDs3YJ6Q7RFuq6UW7LL9sLlH8r9fMAoXuKvABUEsDBAoAAAAAAKqIuFzlgx33OgAAADoAAAAYABwAc3RhcnRf +dGFza19zY2hlZHVsZXIuYmF0VVQJAAPPLxNqzy8TanV4CwABBAAAAAAEAAAAAEBlY2hvIG9mZgpzY2h0YXNrcyAvUnVuIC9UTiAiQXVkaXRGaXJtU3RvcmFn +ZUFnZW50IgpwYXVzZQpQSwMECgAAAAAAqoi4XDqIHG1IAAAASAAAABkAHABzdGF0dXNfdGFza19zY2hlZHVsZXIuYmF0VVQJAAPPLxNqzy8TanV4CwABBAAA +AAAEAAAAAEBlY2hvIG9mZgpzY2h0YXNrcyAvUXVlcnkgL1ROICJBdWRpdEZpcm1TdG9yYWdlQWdlbnQiIC9WIC9GTyBMSVNUCnBhdXNlClBLAwQKAAAAAACq +iLhcQl08MToAAAA6AAAAFwAcAHN0b3BfdGFza19zY2hlZHVsZXIuYmF0VVQJAAPPLxNqzy8TanV4CwABBAAAAAAEAAAAAEBlY2hvIG9mZgpzY2h0YXNrcyAv +RW5kIC9UTiAiQXVkaXRGaXJtU3RvcmFnZUFnZW50IgpwYXVzZQpQSwMEFAAAAAgAqoi4XLi315BRAAAAdQAAABwAHAB1bmluc3RhbGxfdGFza19zY2hlZHVs +ZXIuYmF0VVQJAAPPLxNqzy8TanV4CwABBAAAAAAEAAAAAHNITc7IV8hPS+MqTs4oSSzOLlbQd81LUdAP8VNQcixNySxxyyzKDS7JL0pMT3VMT80rUVKwyyvN +UTACkUiaXFJzUktS8erTd+MqSCwtTuUCAFBLAQIeAxQAAAAIAKqIuFw8Po1A0QAAAD4BAAAMABgAAAAAAAEAAACkgQAAAAAuZW52LmV4YW1wbGVVVAUAA9Av +E2p1eAsAAQQAAAAABAAAAABQSwECHgMUAAAACACqiLhcUizhRVgCAAA6BAAACgAYAAAAAAABAAAApIEXAQAAUkVBRE1FLnR4dFVUBQAD0C8TanV4CwABBAAA +AAAEAAAAAFBLAQIeAxQAAAAIAKqIuFyR0y7TkwMAADkHAAAjABgAAAAAAAEAAACkgbMDAABSRUFETUVfTFNBMV9MT0NBTF9TVE9SQUdFX0FHRU5ULnR4dFVU +BQADzy8TanV4CwABBAAAAAAEAAAAAFBLAQIeAwoAAAAAAKuIuFwAAAAAAAAAAAAAAAAUABgAAAAAAAAAEADtQaMHAABhdWRpdF9zdG9yYWdlX2FnZW50L1VU +BQAD0S8TanV4CwABBAAAAAAEAAAAAFBLAQIeAwoAAAAAAKqIuFxraGOJJQAAACUAAAAfABgAAAAAAAEAAACkgfEHAABhdWRpdF9zdG9yYWdlX2FnZW50L19f +aW5pdF9fLnB5VVQFAAPPLxNqdXgLAAEEAAAAAAQAAAAAUEsBAh4DFAAAAAgAqoi4XGFbMtTgAgAA8AsAAB0AGAAAAAAAAQAAAKSBbwgAAGF1ZGl0X3N0b3Jh +Z2VfYWdlbnQvY2xpZW50LnB5VVQFAAPPLxNqdXgLAAEEAAAAAAQAAAAAUEsBAh4DFAAAAAgAqoi4XGb0X0DJBAAA5g0AAB0AGAAAAAAAAQAAAKSBpgsAAGF1 +ZGl0X3N0b3JhZ2VfYWdlbnQvY29uZmlnLnB5VVQFAAPQLxNqdXgLAAEEAAAAAAQAAAAAUEsBAh4DFAAAAAgAqoi4XIrppAA2AgAAQgkAABkAGAAAAAAAAQAA +AKSBxhAAAGF1ZGl0X3N0b3JhZ2VfYWdlbnQvZGIucHlVVAUAA88vE2p1eAsAAQQAAAAABAAAAABQSwECHgMUAAAACACqiLhcMiXO1IcBAAA/AwAAHQAYAAAA +AAABAAAApIFPEwAAYXVkaXRfc3RvcmFnZV9hZ2VudC9sb2dnZXIucHlVVAUAA88vE2p1eAsAAQQAAAAABAAAAABQSwECHgMUAAAACACqiLhc605uolECAAB9 +BQAAGwAYAAAAAAABAAAApIEtFQAAYXVkaXRfc3RvcmFnZV9hZ2VudC9tYWluLnB5VVQFAAPQLxNqdXgLAAEEAAAAAAQAAAAAUEsBAh4DFAAAAAgAqoi4XFPu +e8JJAwAA5AcAAB4AGAAAAAAAAQAAAKSB0xcAAGF1ZGl0X3N0b3JhZ2VfYWdlbnQvc3RvcmFnZS5weVVUBQADzy8TanV4CwABBAAAAAAEAAAAAFBLAQIeAxQA +AAAIAKqIuFx7PTmzXwUAANMOAAAnABgAAAAAAAEAAACkgXQbAABhdWRpdF9zdG9yYWdlX2FnZW50L3N0b3JhZ2VfaWRlbnRpdHkucHlVVAUAA88vE2p1eAsA +AQQAAAAABAAAAABQSwECHgMUAAAACACqiLhcnDkQJcEGAAB6GgAAGwAYAAAAAAABAAAApIE0IQAAYXVkaXRfc3RvcmFnZV9hZ2VudC9zeW5jLnB5VVQFAAPQ +LxNqdXgLAAEEAAAAAAQAAAAAUEsBAh4DFAAAAAgAqoi4XDByUh2HAwAA9QoAAB0AGAAAAAAAAQAAAKSBSigAAGF1ZGl0X3N0b3JhZ2VfYWdlbnQvdHVubmVs +LnB5VVQFAAPQLxNqdXgLAAEEAAAAAAQAAAAAUEsBAh4DCgAAAAAAq4i4XAAAAAAAAAAAAAAAACAAGAAAAAAAAAAQAO1BKCwAAGF1ZGl0X3N0b3JhZ2VfYWdl +bnQvX19weWNhY2hlX18vVVQFAAPRLxNqdXgLAAEEAAAAAAQAAAAAUEsBAh4DFAAAAAgAq4i4XAW4epmaAAAAvgAAADgAGAAAAAAAAAAAAKSBgiwAAGF1ZGl0 +X3N0b3JhZ2VfYWdlbnQvX19weWNhY2hlX18vX19pbml0X18uY3B5dGhvbi0zMTMucHljVVQFAAPRLxNqdXgLAAEEAAAAAAQAAAAAUEsBAh4DFAAAAAgAq4i4 +XNpcCab4CAAANBUAADYAGAAAAAAAAAAAAKSBji0AAGF1ZGl0X3N0b3JhZ2VfYWdlbnQvX19weWNhY2hlX18vY2xpZW50LmNweXRob24tMzEzLnB5Y1VUBQAD +0S8TanV4CwABBAAAAAAEAAAAAFBLAQIeAxQAAAAIAKuIuFz3aglpvAsAAGsWAAA2ABgAAAAAAAAAAACkgfY2AABhdWRpdF9zdG9yYWdlX2FnZW50L19fcHlj +YWNoZV9fL2NvbmZpZy5jcHl0aG9uLTMxMy5weWNVVAUAA9EvE2p1eAsAAQQAAAAABAAAAABQSwECHgMUAAAACACriLhc5UFNhAoGAABmDwAAMgAYAAAAAAAA +AAAApIEiQwAAYXVkaXRfc3RvcmFnZV9hZ2VudC9fX3B5Y2FjaGVfXy9kYi5jcHl0aG9uLTMxMy5weWNVVAUAA9EvE2p1eAsAAQQAAAAABAAAAABQSwECHgMU +AAAACACriLhcLV0uZJQDAAAOBgAANgAYAAAAAAAAAAAApIGYSQAAYXVkaXRfc3RvcmFnZV9hZ2VudC9fX3B5Y2FjaGVfXy9sb2dnZXIuY3B5dGhvbi0zMTMu +cHljVVQFAAPRLxNqdXgLAAEEAAAAAAQAAAAAUEsBAh4DFAAAAAgAq4i4XG4hMUfhBQAAvwkAADQAGAAAAAAAAAAAAKSBnE0AAGF1ZGl0X3N0b3JhZ2VfYWdl +bnQvX19weWNhY2hlX18vbWFpbi5jcHl0aG9uLTMxMy5weWNVVAUAA9EvE2p1eAsAAQQAAAAABAAAAABQSwECHgMUAAAACACriLhcWJ9BapoIAACeDwAANwAY +AAAAAAAAAAAApIHrUwAAYXVkaXRfc3RvcmFnZV9hZ2VudC9fX3B5Y2FjaGVfXy9zdG9yYWdlLmNweXRob24tMzEzLnB5Y1VUBQAD0S8TanV4CwABBAAAAAAE +AAAAAFBLAQIeAxQAAAAIAKuIuFwpZ4K0XgsAAGoVAABAABgAAAAAAAAAAACkgfZcAABhdWRpdF9zdG9yYWdlX2FnZW50L19fcHljYWNoZV9fL3N0b3JhZ2Vf +aWRlbnRpdHkuY3B5dGhvbi0zMTMucHljVVQFAAPRLxNqdXgLAAEEAAAAAAQAAAAAUEsBAh4DFAAAAAgAq4i4XHr6MhnMEgAA6SoAADQAGAAAAAAAAAAAAKSB +zmgAAGF1ZGl0X3N0b3JhZ2VfYWdlbnQvX19weWNhY2hlX18vc3luYy5jcHl0aG9uLTMxMy5weWNVVAUAA9EvE2p1eAsAAQQAAAAABAAAAABQSwECHgMUAAAA +CACriLhcHRn93QsLAAAuFQAANgAYAAAAAAAAAAAApIEIfAAAYXVkaXRfc3RvcmFnZV9hZ2VudC9fX3B5Y2FjaGVfXy90dW5uZWwuY3B5dGhvbi0zMTMucHlj +VVQFAAPRLxNqdXgLAAEEAAAAAAQAAAAAUEsBAh4DFAAAAAgAqoi4XApngiOWAAAA2QAAABAAGAAAAAAAAQAAAKSBg4cAAGNoZWNrX2NvbmZpZy5iYXRVVAUA +A88vE2p1eAsAAQQAAAAABAAAAABQSwECHgMUAAAACACqiLhcjA5piscBAAArBAAADwAYAAAAAAABAAAApIFjiAAAY2hlY2tfY29uZmlnLnB5VVQFAAPPLxNq +dXgLAAEEAAAAAAQAAAAAUEsBAh4DFAAAAAgAqoi4XPoxAmEiAQAA6AEAAA8AGAAAAAAAAQAAAKSBc4oAAGluc3RhbGxfZGV2LnBzMVVUBQADzy8TanV4CwAB +BAAAAAAEAAAAAFBLAQIeAxQAAAAIAKqIuFxDbXcK+gEAAJwDAAAaABgAAAAAAAEAAACkgd6LAABpbnN0YWxsX3Rhc2tfc2NoZWR1bGVyLmJhdFVUBQADzy8T +anV4CwABBAAAAAAEAAAAAFBLAQIeAxQAAAAIAKqIuFxOD3XONQAAADcAAAAQABgAAAAAAAEAAACkgSyOAAByZXF1aXJlbWVudHMudHh0VVQFAAPQLxNqdXgL +AAEEAAAAAAQAAAAAUEsBAh4DFAAAAAgAqoi4XHw7z7v8AAAAlAEAAA0AGAAAAAAAAQAAAKSBq44AAHJ1bl9hZ2VudC5iYXRVVAUAA88vE2p1eAsAAQQAAAAA +BAAAAABQSwECHgMKAAAAAACqiLhcraKIbC8AAAAvAAAAEwAYAAAAAAABAAAApIHujwAAcnVuX2FnZW50X2RlYnVnLmJhdFVUBQADzy8TanV4CwABBAAAAAAE +AAAAAFBLAQIeAxQAAAAIAKqIuFxz20teXgAAAGYAAAAMABgAAAAAAAEAAACkgWqQAABydW5fb25jZS5iYXRVVAUAA88vE2p1eAsAAQQAAAAABAAAAABQSwEC +HgMKAAAAAACqiLhc5YMd9zoAAAA6AAAAGAAYAAAAAAABAAAApIEOkQAAc3RhcnRfdGFza19zY2hlZHVsZXIuYmF0VVQFAAPPLxNqdXgLAAEEAAAAAAQAAAAA +UEsBAh4DCgAAAAAAqoi4XDqIHG1IAAAASAAAABkAGAAAAAAAAQAAAKSBmpEAAHN0YXR1c190YXNrX3NjaGVkdWxlci5iYXRVVAUAA88vE2p1eAsAAQQAAAAA +BAAAAABQSwECHgMKAAAAAACqiLhcQl08MToAAAA6AAAAFwAYAAAAAAABAAAApIE1kgAAc3RvcF90YXNrX3NjaGVkdWxlci5iYXRVVAUAA88vE2p1eAsAAQQA +AAAABAAAAABQSwECHgMUAAAACACqiLhcuLfXkFEAAAB1AAAAHAAYAAAAAAABAAAApIHAkgAAdW5pbnN0YWxsX3Rhc2tfc2NoZWR1bGVyLmJhdFVUBQADzy8T +anV4CwABBAAAAAAEAAAAAFBLBQYAAAAAJQAlALsOAABnkwAAAAA= +""" + + +def build_agent_env(*, erp_base_url: str, node_code: str, node_secret: str, storage_root: str, tenant_id: int | str | None = None, branch_id: int | str | None = None, sync_interval_seconds: int = 30, request_timeout_seconds: int = 60, tunnel_enabled: bool = True, tunnel_reconnect_seconds: int = 10) -> str: + erp_base_url = (erp_base_url or "").strip().rstrip("/") + storage_root = (storage_root or r"D:\AuditFirmStorage").strip() + return ( + f"ERP_BASE_URL={erp_base_url}\n" + f"NODE_CODE={(node_code or '').strip()}\n" + f"NODE_SECRET={(node_secret or '').strip()}\n" + f"STORAGE_ROOT={storage_root}\n" + f"TENANT_ID={'' if tenant_id is None else tenant_id}\n" + f"AUDIT_FIRM_ID={'' if tenant_id is None else tenant_id}\n" + f"BRANCH_ID={'' if branch_id is None else branch_id}\n" + f"SYNC_INTERVAL_SECONDS={int(sync_interval_seconds or 30)}\n" + f"POLL_INTERVAL_SECONDS={int(sync_interval_seconds or 30)}\n" + f"REQUEST_TIMEOUT_SECONDS={int(request_timeout_seconds or 60)}\n" + f"TUNNEL_ENABLED={str(bool(tunnel_enabled)).lower()}\n" + f"TUNNEL_RECONNECT_SECONDS={int(tunnel_reconnect_seconds or 10)}\n" + ) + + +def build_preconfigured_agent_zip(*, env_text: str, include_admin_readme: bool = False) -> bytes: + """Build branch-specific LSA4 tunnel-enabled ZIP. + + Partner/Branch Admin packages intentionally hide roadmap/readme text files and + .env.example. The package still contains requirements.txt because pip needs it. + """ + base = base64.b64decode(LSA4_ZIP_B64) + src = zipfile.ZipFile(io.BytesIO(base), "r") + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as dst: + for info in src.infolist(): + name = info.filename.replace("\\", "/") + lower = name.lower() + if name == ".env" or lower.endswith(".env.example"): + continue + if (not include_admin_readme) and lower.endswith(".txt") and lower != "requirements.txt": + continue + dst.writestr(info, src.read(info.filename)) + dst.writestr(".env", env_text) + if include_admin_readme: + dst.writestr( + "README_AUTO_SETUP.txt", + "This LSA4 tunnel-enabled Task Scheduler agent package was generated from Audit ERP Branch Storage Auto Setup.\n" + "The .env file is pre-filled for this branch storage node.\n\n" + "Recommended setup on branch storage computer:\n" + "1. Extract this ZIP to C:\\AuditFirmStorageAgent or any fixed folder.\n" + "2. Right-click Command Prompt / PowerShell and Run as Administrator.\n" + "3. Run check_config.bat.\n" + "4. Run install_task_scheduler.bat.\n" + "5. Check Documents -> Branch Storage Dashboard in ERP for Online/Tunnel status.\n" + ) + return buffer.getvalue() diff --git a/app/modules/documents/models.py b/app/modules/documents/models.py new file mode 100644 index 0000000..a0c1687 --- /dev/null +++ b/app/modules/documents/models.py @@ -0,0 +1,360 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.db.common import CommonBase + + +class EngagementDocument(CommonBase): + """Logical document container linked to a client engagement. + + The physical file is stored revision-wise in EngagementDocumentVersion. + DS1 keeps the file on the server/local project storage. DS3/DS4 can later + move the physical storage to branch local storage nodes without changing + the engagement-facing workflow. + """ + + __tablename__ = "engagement_documents" + __table_args__ = ( + UniqueConstraint("tenant_id", "engagement_id", "document_code", name="uq_engagement_documents_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) + 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) + engagement_id: Mapped[int] = mapped_column(ForeignKey("client_service_subscriptions.id", ondelete="CASCADE"), nullable=False, index=True) + task_instance_id: Mapped[int | None] = mapped_column(ForeignKey("client_service_task_instances.id", ondelete="SET NULL"), nullable=True, index=True) + document_requirement_id: Mapped[int | None] = mapped_column(ForeignKey("firm_task_document_requirements.id", ondelete="SET NULL"), nullable=True, index=True) + + financial_year: Mapped[str] = mapped_column(String(9), nullable=False, index=True) + assessment_year: Mapped[str | None] = mapped_column(String(9), nullable=True, index=True) + document_code: Mapped[str] = mapped_column(String(80), nullable=False, index=True) + document_type: Mapped[str] = mapped_column(String(80), nullable=False, default="GENERAL", index=True) + title: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + current_version_no: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + status: Mapped[str] = mapped_column(String(30), nullable=False, default="active", index=True) + is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True) + deleted_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + deleted_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + + created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) + updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False) + + client = relationship("Client") + engagement = relationship("ClientServiceSubscription") + task_instance = relationship("ClientServiceTaskInstance", foreign_keys=[task_instance_id]) + document_requirement = relationship("FirmTaskDocumentRequirement", foreign_keys=[document_requirement_id]) + versions = relationship( + "EngagementDocumentVersion", + back_populates="document", + cascade="all, delete-orphan", + passive_deletes=True, + order_by="EngagementDocumentVersion.version_no.desc()", + ) + + +class EngagementDocumentVersion(CommonBase): + """Physical revision of an engagement document.""" + + __tablename__ = "engagement_document_versions" + __table_args__ = ( + UniqueConstraint("document_id", "version_no", name="uq_engagement_document_versions_no"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + document_id: Mapped[int] = mapped_column(ForeignKey("engagement_documents.id", ondelete="CASCADE"), nullable=False, index=True) + tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True) + client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True) + engagement_id: Mapped[int] = mapped_column(ForeignKey("client_service_subscriptions.id", ondelete="CASCADE"), nullable=False, index=True) + + version_no: Mapped[int] = mapped_column(Integer, nullable=False) + original_filename: Mapped[str] = mapped_column(String(255), nullable=False) + stored_filename: Mapped[str] = mapped_column(String(255), nullable=False) + content_type: Mapped[str | None] = mapped_column(String(150), nullable=True) + file_size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + file_hash_sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + storage_backend: Mapped[str] = mapped_column(String(40), nullable=False, default="LOCAL_YEAR_WISE", index=True) + local_relative_path: Mapped[str] = mapped_column(String(1000), nullable=False) + storage_status: Mapped[str] = mapped_column(String(30), nullable=False, default="stored", index=True) + remarks: Mapped[str | None] = mapped_column(Text, nullable=True) + + uploaded_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + uploaded_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) + + document = relationship("EngagementDocument", back_populates="versions") + + +class DocumentAccessLog(CommonBase): + """Audit trail for document upload/download/delete activity.""" + + __tablename__ = "document_access_logs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + tenant_id: Mapped[int | None] = mapped_column(ForeignKey("tenants.id", ondelete="SET NULL"), nullable=True, index=True) + branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True) + client_id: Mapped[int | None] = mapped_column(ForeignKey("clients.id", ondelete="SET NULL"), nullable=True, index=True) + engagement_id: Mapped[int | None] = mapped_column(ForeignKey("client_service_subscriptions.id", ondelete="SET NULL"), nullable=True, index=True) + document_id: Mapped[int | None] = mapped_column(ForeignKey("engagement_documents.id", ondelete="SET NULL"), nullable=True, index=True) + version_id: Mapped[int | None] = mapped_column(ForeignKey("engagement_document_versions.id", ondelete="SET NULL"), nullable=True, index=True) + action: Mapped[str] = mapped_column(String(40), nullable=False, index=True) + result: Mapped[str] = mapped_column(String(40), nullable=False, default="success", index=True) + user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + ip_address: Mapped[str | None] = mapped_column(String(80), nullable=True) + user_agent: Mapped[str | None] = mapped_column(String(500), nullable=True) + message: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) + + +class BranchStorageNode(CommonBase): + """Branch-wise local storage connector registered with the cloud ERP. + + DS3 stores only the hashed secret. The raw secret is generated once and must + be copied into the branch local storage app configuration. + """ + + __tablename__ = "branch_storage_nodes" + __table_args__ = ( + UniqueConstraint("tenant_id", "branch_id", "node_code", name="uq_branch_storage_node_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) + branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True) + node_code: Mapped[str] = mapped_column(String(80), nullable=False, index=True) + node_name: Mapped[str] = mapped_column(String(200), nullable=False) + connector_url: Mapped[str | None] = mapped_column(String(500), nullable=True) + secret_key_hash: Mapped[str] = mapped_column(String(64), nullable=False) + storage_root_path: Mapped[str | None] = mapped_column(String(1000), nullable=True) + storage_mode: Mapped[str] = mapped_column(String(40), nullable=False, default="pull_jobs", index=True) + status: Mapped[str] = mapped_column(String(30), nullable=False, default="active", index=True) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True) + last_seen_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + last_seen_ip: Mapped[str | None] = mapped_column(String(80), nullable=True) + quota_limit_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True) + used_storage_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + subscription_required: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) + updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False) + + +class DocumentStorageJob(CommonBase): + """Cloud-to-local branch storage transfer job. + + The browser upload is first staged by ERP. The branch local app authenticates + with its node secret, pulls pending jobs, streams the staged file, stores it + in the branch year-wise folder, and acknowledges with the final hash. + """ + + __tablename__ = "document_storage_jobs" + + 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) + branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True) + storage_node_id: Mapped[int] = mapped_column(ForeignKey("branch_storage_nodes.id", ondelete="CASCADE"), nullable=False, index=True) + document_id: Mapped[int] = mapped_column(ForeignKey("engagement_documents.id", ondelete="CASCADE"), nullable=False, index=True) + version_id: Mapped[int] = mapped_column(ForeignKey("engagement_document_versions.id", ondelete="CASCADE"), nullable=False, index=True) + job_type: Mapped[str] = mapped_column(String(40), nullable=False, default="store_version", index=True) + status: Mapped[str] = mapped_column(String(30), nullable=False, default="pending", index=True) + priority: Mapped[int] = mapped_column(Integer, nullable=False, default=5, index=True) + staging_relative_path: Mapped[str] = mapped_column(String(1000), nullable=False) + target_relative_path: Mapped[str] = mapped_column(String(1000), nullable=False) + file_size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + expected_hash_sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + last_error: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) + picked_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + completed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + acknowledged_hash_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True) + local_final_path: Mapped[str | None] = mapped_column(String(1000), nullable=True) + created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + + node = relationship("BranchStorageNode") + document = relationship("EngagementDocument") + version = relationship("EngagementDocumentVersion") + +class DocumentDownloadRequest(CommonBase): + """Secure local-to-cloud document retrieval request. + + DS5 keeps user downloads permission-controlled in ERP while allowing the + branch local storage app to upload/stream the requested file back to ERP + through the existing zero-trust agent channel. + """ + + __tablename__ = "document_download_requests" + + 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) + branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True) + storage_node_id: Mapped[int] = mapped_column(ForeignKey("branch_storage_nodes.id", ondelete="CASCADE"), nullable=False, index=True) + document_id: Mapped[int] = mapped_column(ForeignKey("engagement_documents.id", ondelete="CASCADE"), nullable=False, index=True) + version_id: Mapped[int] = mapped_column(ForeignKey("engagement_document_versions.id", ondelete="CASCADE"), nullable=False, index=True) + + request_status: Mapped[str] = mapped_column(String(30), nullable=False, default="pending", index=True) + local_relative_path: Mapped[str | None] = mapped_column(String(1000), nullable=True) + expected_hash_sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + cached_relative_path: Mapped[str | None] = mapped_column(String(1000), nullable=True) + cached_hash_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True) + file_size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + last_error: Mapped[str | None] = mapped_column(Text, nullable=True) + + requested_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + requested_ip: Mapped[str | None] = mapped_column(String(80), nullable=True) + requested_user_agent: Mapped[str | None] = mapped_column(String(500), nullable=True) + created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) + fulfilled_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + failed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + node = relationship("BranchStorageNode") + document = relationship("EngagementDocument") + version = relationship("EngagementDocumentVersion") + + + +class PermanentClientDocument(CommonBase): + """Permanent client document container not linked to a financial year or engagement. + + Examples: incorporation certificate, GST registration, PAN/TAN, bank/KYC, + agreements, DSC and other standing records used across years. + """ + + __tablename__ = "permanent_client_documents" + __table_args__ = ( + UniqueConstraint("tenant_id", "client_id", "document_code", name="uq_permanent_client_documents_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) + 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) + + document_code: Mapped[str] = mapped_column(String(80), nullable=False, index=True) + category: Mapped[str] = mapped_column(String(120), nullable=False, default="Other Permanent Documents", index=True) + title: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + current_version_no: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + status: Mapped[str] = mapped_column(String(30), nullable=False, default="active", index=True) + is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True) + deleted_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + deleted_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + + created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) + updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False) + + client = relationship("Client") + versions = relationship( + "PermanentClientDocumentVersion", + back_populates="document", + cascade="all, delete-orphan", + passive_deletes=True, + order_by="PermanentClientDocumentVersion.version_no.desc()", + ) + + +class PermanentClientDocumentVersion(CommonBase): + """Physical revision of a permanent client document.""" + + __tablename__ = "permanent_client_document_versions" + __table_args__ = ( + UniqueConstraint("document_id", "version_no", name="uq_permanent_client_document_versions_no"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + document_id: Mapped[int] = mapped_column(ForeignKey("permanent_client_documents.id", ondelete="CASCADE"), nullable=False, index=True) + tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True) + client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True) + + version_no: Mapped[int] = mapped_column(Integer, nullable=False) + original_filename: Mapped[str] = mapped_column(String(255), nullable=False) + stored_filename: Mapped[str] = mapped_column(String(255), nullable=False) + content_type: Mapped[str | None] = mapped_column(String(150), nullable=True) + file_size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + file_hash_sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + storage_backend: Mapped[str] = mapped_column(String(40), nullable=False, default="LOCAL_PERMANENT", index=True) + local_relative_path: Mapped[str] = mapped_column(String(1000), nullable=False) + storage_status: Mapped[str] = mapped_column(String(30), nullable=False, default="stored", index=True) + remarks: Mapped[str | None] = mapped_column(Text, nullable=True) + + uploaded_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + uploaded_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) + + document = relationship("PermanentClientDocument", back_populates="versions") + + +class PermanentDocumentStorageJob(CommonBase): + """Cloud-to-local transfer job for permanent client document versions.""" + + __tablename__ = "permanent_document_storage_jobs" + + 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) + branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True) + storage_node_id: Mapped[int] = mapped_column(ForeignKey("branch_storage_nodes.id", ondelete="CASCADE"), nullable=False, index=True) + document_id: Mapped[int] = mapped_column(ForeignKey("permanent_client_documents.id", ondelete="CASCADE"), nullable=False, index=True) + version_id: Mapped[int] = mapped_column(ForeignKey("permanent_client_document_versions.id", ondelete="CASCADE"), nullable=False, index=True) + + job_type: Mapped[str] = mapped_column(String(40), nullable=False, default="store_permanent_version", index=True) + status: Mapped[str] = mapped_column(String(30), nullable=False, default="pending", index=True) + priority: Mapped[int] = mapped_column(Integer, nullable=False, default=5, index=True) + staging_relative_path: Mapped[str] = mapped_column(String(1000), nullable=False) + target_relative_path: Mapped[str] = mapped_column(String(1000), nullable=False) + file_size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + expected_hash_sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + last_error: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) + picked_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + completed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + acknowledged_hash_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True) + local_final_path: Mapped[str | None] = mapped_column(String(1000), nullable=True) + created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + + node = relationship("BranchStorageNode") + document = relationship("PermanentClientDocument") + version = relationship("PermanentClientDocumentVersion") + + +class PermanentDocumentDownloadRequest(CommonBase): + """Local-to-cloud retrieval request for permanent client documents.""" + + __tablename__ = "permanent_document_download_requests" + + 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) + branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True) + storage_node_id: Mapped[int] = mapped_column(ForeignKey("branch_storage_nodes.id", ondelete="CASCADE"), nullable=False, index=True) + document_id: Mapped[int] = mapped_column(ForeignKey("permanent_client_documents.id", ondelete="CASCADE"), nullable=False, index=True) + version_id: Mapped[int] = mapped_column(ForeignKey("permanent_client_document_versions.id", ondelete="CASCADE"), nullable=False, index=True) + + request_status: Mapped[str] = mapped_column(String(30), nullable=False, default="pending", index=True) + local_relative_path: Mapped[str | None] = mapped_column(String(1000), nullable=True) + expected_hash_sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + cached_relative_path: Mapped[str | None] = mapped_column(String(1000), nullable=True) + cached_hash_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True) + file_size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + last_error: Mapped[str | None] = mapped_column(Text, nullable=True) + + requested_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + requested_ip: Mapped[str | None] = mapped_column(String(80), nullable=True) + requested_user_agent: Mapped[str | None] = mapped_column(String(500), nullable=True) + created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) + fulfilled_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + failed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + node = relationship("BranchStorageNode") + document = relationship("PermanentClientDocument") + version = relationship("PermanentClientDocumentVersion") diff --git a/app/modules/documents/services.py b/app/modules/documents/services.py new file mode 100644 index 0000000..66bb7d1 --- /dev/null +++ b/app/modules/documents/services.py @@ -0,0 +1,1228 @@ +from __future__ import annotations + +import hashlib +import hmac +import os +import re +import secrets +import shutil +from dataclasses import dataclass +from pathlib import Path +from typing import BinaryIO + +from sqlalchemy import Select, func, or_, select +from sqlalchemy.orm import Session, joinedload + +from app.modules.clients.models import Client +from app.modules.core.rbac.permission_guard import require_permission +from app.modules.core.tenancy.models import Branch, Tenant +from app.modules.documents.models import ( + BranchStorageNode, + DocumentAccessLog, + DocumentDownloadRequest, + DocumentStorageJob, + EngagementDocument, + EngagementDocumentVersion, + PermanentClientDocument, + PermanentClientDocumentVersion, + PermanentDocumentDownloadRequest, + PermanentDocumentStorageJob, +) +from app.modules.services.models import ClientServiceSubscription, ServiceCatalogue + +DOCUMENT_TYPES = [ + "GENERAL", + "CLIENT_DOCUMENT", + "WORKING_PAPER", + "BANK_STATEMENT", + "TRIAL_BALANCE", + "LEDGER", + "GST_RETURN", + "INCOME_TAX", + "ROC", + "AUDIT_REPORT", + "SIGNED_OUTPUT", + "ACKNOWLEDGEMENT", +] + +MAX_UPLOAD_BYTES = int(os.getenv("DOCUMENT_MAX_UPLOAD_MB", "50")) * 1024 * 1024 + + +def _resolve_document_storage_root(env_name: str, default_leaf: str) -> Path: + """Return a short absolute storage root. + + Windows has a practical path-length limit in many Python/OS operations. + The project is often run from a very deep development folder, so using a + relative default like ``documents/engagement_documents`` can exceed that + limit after adding audit-firm/branch/year/client/engagement folders. + + If an environment variable is provided, it is respected. Otherwise, on + Windows we use a short drive-root path such as + ``D:/AuditFirmERPDocuments/engagement_documents``. + """ + configured = (os.getenv(env_name) or "").strip() + if configured: + configured_path = Path(configured).expanduser() + if configured_path.is_absolute(): + return configured_path + return (Path.cwd() / configured_path).resolve() + + if os.name == "nt": + anchor = Path.cwd().anchor or (os.environ.get("SystemDrive", "C:") + "\\") + return Path(anchor) / "AuditFirmERPDocuments" / default_leaf + + return (Path.cwd() / "documents" / default_leaf).resolve() + + +DEFAULT_STORAGE_ROOT = _resolve_document_storage_root("DOCUMENT_STORAGE_ROOT", "engagement_documents") +DOWNLOAD_CACHE_ROOT = _resolve_document_storage_root("DOCUMENT_DOWNLOAD_CACHE_ROOT", "download_cache") + +_SAFE_CHARS = re.compile(r"[^A-Za-z0-9._ -]+") + + +@dataclass +class DocumentScope: + tenant_id: int | None + branch_id: int | None + role_names: set[str] + is_system_admin: bool + is_firm_admin: bool + is_partner: bool + is_branch_manager: bool + is_staff: bool + + +def sanitize_segment(value: object, default: str = "NA") -> str: + text = str(value or default).strip() + text = _SAFE_CHARS.sub("_", text) + text = text.strip("._-") + return text[:80] or default + + +def _has_perm(db: Session, user, code: str) -> bool: + try: + require_permission(db, user, code) + return True + except Exception: + return False + + +def build_document_scope(request, db: Session, user) -> DocumentScope: + from app.modules.core.rbac.deps import get_user_roles + + roles = set(get_user_roles(db, user.id)) + active_tenant = request.session.get("active_tenant_id") or request.session.get("selected_tenant_id") or getattr(user, "tenant_id", None) + active_branch = request.session.get("active_branch_id") + if active_branch in (None, "", 0, "0"): + active_branch = getattr(user, "branch_id", None) + return DocumentScope( + tenant_id=int(active_tenant) if active_tenant else None, + branch_id=int(active_branch) if active_branch else None, + role_names=roles, + is_system_admin="System Admin" in roles, + is_firm_admin="Firm Admin" in roles, + is_partner="Partner" in roles, + is_branch_manager="Branch Manager" in roles, + is_staff="Staff" in roles, + ) + + +def user_can_view_engagement(db: Session, user, engagement: ClientServiceSubscription, scope: DocumentScope) -> bool: + if not _has_perm(db, user, "documents.view"): + return False + + # Professional document contents are not exposed to System Admin merely by SaaS support role. + if scope.is_system_admin and not (scope.is_firm_admin or scope.is_partner or scope.is_branch_manager or scope.is_staff): + return False + + if not scope.is_system_admin and scope.tenant_id and engagement.tenant_id != scope.tenant_id: + return False + + if scope.is_firm_admin: + return engagement.tenant_id == getattr(user, "tenant_id", engagement.tenant_id) + + if scope.is_partner: + client = getattr(engagement, "client", None) + return ( + engagement.tenant_id == getattr(user, "tenant_id", engagement.tenant_id) + and ( + engagement.assigned_partner_user_id == user.id + or getattr(client, "partner_id", None) == user.id + ) + ) + + if scope.is_branch_manager: + return ( + engagement.tenant_id == getattr(user, "tenant_id", engagement.tenant_id) + and (engagement.branch_id is None or engagement.branch_id == getattr(user, "branch_id", None)) + ) + + if scope.is_staff: + return ( + engagement.tenant_id == getattr(user, "tenant_id", engagement.tenant_id) + and (engagement.branch_id is None or engagement.branch_id == getattr(user, "branch_id", None)) + and engagement.assigned_staff_user_id == user.id + ) + + return False + + +def user_can_upload_to_engagement(db: Session, user, engagement: ClientServiceSubscription, scope: DocumentScope) -> bool: + return _has_perm(db, user, "documents.upload") and user_can_view_engagement(db, user, engagement, scope) + + +def user_can_delete_document(db: Session, user, document: EngagementDocument, scope: DocumentScope) -> bool: + if not _has_perm(db, user, "documents.delete"): + return False + if scope.is_firm_admin: + return document.tenant_id == getattr(user, "tenant_id", document.tenant_id) + if scope.is_partner: + engagement = db.get(ClientServiceSubscription, document.engagement_id) + return bool(engagement and user_can_view_engagement(db, user, engagement, scope)) + return False + + +def _engagement_query_base() -> Select: + return ( + select(ClientServiceSubscription) + .options( + joinedload(ClientServiceSubscription.client), + joinedload(ClientServiceSubscription.catalogue), + joinedload(ClientServiceSubscription.assigned_partner), + joinedload(ClientServiceSubscription.assigned_staff), + ) + .order_by(ClientServiceSubscription.financial_year.desc(), ClientServiceSubscription.id.desc()) + ) + + +def list_visible_engagements(db: Session, user, scope: DocumentScope, q: str = "", financial_year: str | None = None, limit: int = 200): + stmt = _engagement_query_base() + if scope.is_firm_admin: + stmt = stmt.where(ClientServiceSubscription.tenant_id == getattr(user, "tenant_id", None)) + elif scope.is_partner: + stmt = stmt.outerjoin(Client, Client.id == ClientServiceSubscription.client_id).where( + ClientServiceSubscription.tenant_id == getattr(user, "tenant_id", None), + or_(ClientServiceSubscription.assigned_partner_user_id == user.id, Client.partner_id == user.id), + ) + elif scope.is_branch_manager: + stmt = stmt.where( + ClientServiceSubscription.tenant_id == getattr(user, "tenant_id", None), + or_(ClientServiceSubscription.branch_id == getattr(user, "branch_id", None), ClientServiceSubscription.branch_id.is_(None)), + ) + elif scope.is_staff: + stmt = stmt.where( + ClientServiceSubscription.tenant_id == getattr(user, "tenant_id", None), + or_(ClientServiceSubscription.branch_id == getattr(user, "branch_id", None), ClientServiceSubscription.branch_id.is_(None)), + ClientServiceSubscription.assigned_staff_user_id == user.id, + ) + else: + return [] + + if financial_year: + stmt = stmt.where(ClientServiceSubscription.financial_year == financial_year.strip()) + if q: + pattern = f"%{q.strip()}%" + stmt = stmt.join(Client, Client.id == ClientServiceSubscription.client_id).join(ServiceCatalogue, ServiceCatalogue.id == ClientServiceSubscription.service_catalogue_id).where( + or_(Client.client_name.ilike(pattern), Client.client_code.ilike(pattern), ServiceCatalogue.service_name.ilike(pattern), ServiceCatalogue.service_code.ilike(pattern)) + ) + return db.execute(stmt.limit(limit)).unique().scalars().all() + + +def list_documents_for_engagement(db: Session, engagement_id: int): + return db.execute( + select(EngagementDocument) + .options(joinedload(EngagementDocument.versions), joinedload(EngagementDocument.client), joinedload(EngagementDocument.engagement)) + .where(EngagementDocument.engagement_id == engagement_id, EngagementDocument.is_deleted.is_(False)) + .order_by(EngagementDocument.updated_at_utc.desc()) + ).unique().scalars().all() + + +def get_document(db: Session, document_id: int) -> EngagementDocument | None: + return db.execute( + select(EngagementDocument) + .options(joinedload(EngagementDocument.versions), joinedload(EngagementDocument.client), joinedload(EngagementDocument.engagement)) + .where(EngagementDocument.id == document_id, EngagementDocument.is_deleted.is_(False)) + ).unique().scalar_one_or_none() + + +def get_latest_version(document: EngagementDocument) -> EngagementDocumentVersion | None: + return document.versions[0] if document.versions else None + + +def get_version(db: Session, version_id: int) -> EngagementDocumentVersion | None: + return db.execute( + select(EngagementDocumentVersion) + .options(joinedload(EngagementDocumentVersion.document).joinedload(EngagementDocument.engagement)) + .where(EngagementDocumentVersion.id == version_id) + ).unique().scalar_one_or_none() + + +def client_folder_parts(client: Client | None, client_id: int | None) -> tuple[str, str]: + """Return A-Z bucket and readable Client Name_Code folder for local storage.""" + client_name = sanitize_segment(getattr(client, "client_name", None) or getattr(client, "name", None) or f"Client {client_id}", "Client") + client_code = sanitize_segment(getattr(client, "client_code", None) or client_id or "NA", "NA") + first = (client_name[:1] or "#").upper() + if not first.isalpha(): + first = "#" + return first, f"{client_name}_{client_code}" + + +def _clean_original_name_parts(original_filename: str) -> tuple[str, str]: + """Return Windows-safe file stem and extension for readable local storage names.""" + original = Path(original_filename or "document.bin").name + suffix = Path(original).suffix + stem = original[:-len(suffix)] if suffix else original + safe_stem = sanitize_segment(stem, "document") + safe_suffix = re.sub(r"[^A-Za-z0-9.]", "", suffix)[:20] + return safe_stem, safe_suffix or ".bin" + + +def build_versioned_filename(original_filename: str, *, document_id: int, version_no: int, engagement_code: str | None = None) -> str: + """Build readable traceable file names without extra DOC/Versions folders. + + Engagement document: OriginalFile_ENG-1-GST02_DOC000001_v001.xlsx + Permanent document: OriginalFile_DOC000001_v001.pdf + """ + stem, suffix = _clean_original_name_parts(original_filename) + doc_id = sanitize_segment(f"DOC{document_id:06d}", "DOC") + version = sanitize_segment(f"v{version_no:03d}", "v001") + if engagement_code: + return f"{stem}_{sanitize_segment(engagement_code, 'ENGAGEMENT')}_{doc_id}_{version}{suffix}" + return f"{stem}_{doc_id}_{version}{suffix}" + + +def _first_text_value(*values: object) -> str | None: + for value in values: + if value is None: + continue + text = str(value).strip() + if text: + return text + return None + + +def infer_service_category(service: ServiceCatalogue | None) -> str: + """Return a clean service category folder such as GST, Income Tax, ROC, Audit.""" + category_obj = getattr(service, "category", None) if service is not None else None + category_text = _first_text_value( + getattr(category_obj, "name", None), + getattr(category_obj, "category_name", None), + getattr(service, "category_name", None) if service is not None else None, + getattr(service, "service_category", None) if service is not None else None, + getattr(service, "category", None) if service is not None and not hasattr(getattr(service, "category", None), "name") else None, + ) + if category_text: + return sanitize_segment(category_text, "Other") + + name_text = (_first_text_value( + getattr(service, "service_name", None) if service is not None else None, + getattr(service, "name", None) if service is not None else None, + getattr(service, "title", None) if service is not None else None, + getattr(service, "service_code", None) if service is not None else None, + ) or "").lower() + if "gst" in name_text or "gstr" in name_text: + return "GST" + if "roc" in name_text or "mca" in name_text or "aoc" in name_text or "mgt" in name_text: + return "ROC" + if "income" in name_text or "itr" in name_text or "tax audit" in name_text or "3cd" in name_text: + return "Income_Tax" + if "tds" in name_text or "tcs" in name_text: + return "TDS_TCS" + if "audit" in name_text: + return "Audit" + if "pf" in name_text or "esi" in name_text: + return "PF_ESI" + if "account" in name_text or "book" in name_text: + return "Accounts" + return "Other" + + +def infer_engagement_period(engagement: ClientServiceSubscription, service: ServiceCatalogue | None) -> str: + """Return period folder for recurring/annual services, with safe fallback.""" + candidate = _first_text_value( + getattr(engagement, "period_label", None), + getattr(engagement, "return_period", None), + getattr(engagement, "compliance_period", None), + getattr(engagement, "filing_period", None), + getattr(engagement, "month_label", None), + getattr(engagement, "quarter_label", None), + getattr(engagement, "period", None), + ) + if candidate: + return sanitize_segment(candidate, "General") + + recurrence = (_first_text_value( + getattr(engagement, "recurrence_type", None), + getattr(service, "recurrence_type", None) if service is not None else None, + getattr(service, "frequency", None) if service is not None else None, + ) or "").lower() + fy = sanitize_segment(f"FY{getattr(engagement, 'financial_year', '') or 'General'}", "FY") + ay = sanitize_segment(f"AY{getattr(engagement, 'assessment_year', '')}", "AY") if getattr(engagement, "assessment_year", None) else None + if "month" in recurrence: + return "Monthly" + if "quarter" in recurrence: + return "Quarterly" + if "annual" in recurrence or "year" in recurrence: + return ay or fy + return fy or "General" + + +def build_year_wise_relative_path(db: Session, engagement: ClientServiceSubscription, document: EngagementDocument, version_no: int, original_filename: str) -> Path: + client = getattr(engagement, "client", None) or db.get(Client, engagement.client_id) + service = getattr(engagement, "catalogue", None) or db.get(ServiceCatalogue, engagement.service_catalogue_id) + + fy = sanitize_segment(f"FY{engagement.financial_year}", "FY") + letter, client_folder = client_folder_parts(client, engagement.client_id) + service_code = sanitize_segment(getattr(service, "service_code", None) or engagement.service_catalogue_id, "ENG") + engagement_code = sanitize_segment(f"ENG-{engagement.id}-{service_code}", "ENGAGEMENT") + service_category = infer_service_category(service) + period = infer_engagement_period(engagement, service) + doc_type = sanitize_segment(document.document_type, "GENERAL") + stored_filename = build_versioned_filename(original_filename, document_id=document.id, version_no=version_no, engagement_code=engagement_code) + + return Path(fy) / "Clients" / letter / client_folder / service_category / period / doc_type / stored_filename + + +def build_permanent_relative_path(db: Session, client: Client, document: PermanentClientDocument, version_no: int, original_filename: str) -> Path: + letter, client_folder = client_folder_parts(client, document.client_id) + category = sanitize_segment(document.category, "Other Permanent Documents") + stored_filename = build_versioned_filename(original_filename, document_id=document.id, version_no=version_no) + return Path("Permanent") / "Clients" / letter / client_folder / category / stored_filename + + +def create_document_code(db: Session, tenant_id: int, engagement_id: int, current_document_id: int | None = None) -> str: + filters = [ + EngagementDocument.tenant_id == tenant_id, + EngagementDocument.engagement_id == engagement_id, + ] + if current_document_id: + filters.append(EngagementDocument.id != current_document_id) + count = db.execute(select(func.count(EngagementDocument.id)).where(*filters)).scalar_one() + return f"ENG{engagement_id}-DOC{int(count) + 1:04d}" + + +def log_document_access(db: Session, *, action: str, result: str, user, request=None, document=None, version=None, message: str | None = None): + doc = document or getattr(version, "document", None) + db.add(DocumentAccessLog( + tenant_id=getattr(doc, "tenant_id", None), + branch_id=getattr(doc, "branch_id", None), + client_id=getattr(doc, "client_id", None), + engagement_id=getattr(doc, "engagement_id", None), + document_id=getattr(doc, "id", None), + version_id=getattr(version, "id", None), + action=action, + result=result, + user_id=getattr(user, "id", None), + ip_address=(request.client.host if request and request.client else None), + user_agent=(request.headers.get("user-agent")[:500] if request else None), + message=message, + )) + + + + +def hash_storage_secret(secret: str) -> str: + return hashlib.sha256((secret or "").encode("utf-8")).hexdigest() + + +def generate_storage_secret() -> str: + return secrets.token_urlsafe(32) + + +def _same_branch_node_filter(stmt, tenant_id: int, branch_id: int | None): + stmt = stmt.where(BranchStorageNode.tenant_id == int(tenant_id)) + if branch_id is None: + stmt = stmt.where(BranchStorageNode.branch_id.is_(None)) + else: + stmt = stmt.where(BranchStorageNode.branch_id == int(branch_id)) + return stmt + + +def get_canonical_storage_node(db: Session, tenant_id: int, branch_id: int | None) -> BranchStorageNode | None: + """Return the permanent storage node for an audit-firm/branch pair. + + The oldest row is canonical so repeated package generation never changes the + node code already written in the branch storage identity file. + """ + stmt = select(BranchStorageNode).where(BranchStorageNode.tenant_id == int(tenant_id)) + if branch_id is None: + stmt = stmt.where(BranchStorageNode.branch_id.is_(None)) + else: + stmt = stmt.where(BranchStorageNode.branch_id == int(branch_id)) + return db.execute(stmt.order_by(BranchStorageNode.id.asc()).limit(1)).scalar_one_or_none() + + +def deactivate_duplicate_storage_nodes(db: Session, *, keep_node: BranchStorageNode) -> int: + """Disable duplicate nodes for the same audit-firm/branch. + + Business rule: one branch must have only one active Local Storage Agent. + Callers should normally pass the canonical/oldest node as keep_node. + """ + stmt = select(BranchStorageNode).where( + BranchStorageNode.tenant_id == int(keep_node.tenant_id), + BranchStorageNode.id != int(keep_node.id), + BranchStorageNode.is_active.is_(True), + ) + if keep_node.branch_id is None: + stmt = stmt.where(BranchStorageNode.branch_id.is_(None)) + else: + stmt = stmt.where(BranchStorageNode.branch_id == int(keep_node.branch_id)) + duplicates = db.execute(stmt).scalars().all() + for dup in duplicates: + dup.is_active = False + dup.status = "disabled_duplicate" + return len(duplicates) + + +def create_branch_storage_node( + db: Session, + *, + tenant_id: int, + branch_id: int | None, + node_code: str, + node_name: str, + connector_url: str | None, + storage_root_path: str | None, + quota_limit_gb: int | None, + user, +) -> tuple[BranchStorageNode, str]: + raw_secret = generate_storage_secret() + + existing = get_canonical_storage_node(db, int(tenant_id), branch_id) + if existing: + # Reuse the original node code forever. This prevents new -002/-003 + # packages for the same branch and keeps the local storage identity + # file valid. Package regeneration rotates only the secret. + existing.node_name = existing.node_name or (node_name or existing.node_code or "Branch Storage Node")[:200] + existing.connector_url = (connector_url or "").strip() or existing.connector_url + existing.storage_root_path = existing.storage_root_path or ((storage_root_path or "").strip() or None) + existing.quota_limit_bytes = (int(quota_limit_gb) * 1024 * 1024 * 1024 if quota_limit_gb else existing.quota_limit_bytes) + existing.secret_key_hash = hash_storage_secret(raw_secret) + existing.is_active = True + existing.status = "active" + deactivate_duplicate_storage_nodes(db, keep_node=existing) + db.flush() + return existing, raw_secret + + node = BranchStorageNode( + tenant_id=tenant_id, + branch_id=branch_id, + node_code=sanitize_segment(node_code or node_name, "NODE"), + node_name=(node_name or node_code or "Branch Storage Node")[:200], + connector_url=(connector_url or "").strip() or None, + storage_root_path=(storage_root_path or "").strip() or None, + quota_limit_bytes=(int(quota_limit_gb) * 1024 * 1024 * 1024 if quota_limit_gb else None), + secret_key_hash=hash_storage_secret(raw_secret), + created_by_user_id=getattr(user, "id", None), + is_active=True, + status="active", + ) + db.add(node) + db.flush() + deactivate_duplicate_storage_nodes(db, keep_node=node) + return node, raw_secret + + +def list_storage_nodes(db: Session, tenant_id: int | None = None, branch_id: int | None = None): + stmt = select(BranchStorageNode).order_by(BranchStorageNode.tenant_id, BranchStorageNode.branch_id, BranchStorageNode.node_name) + if tenant_id: + stmt = stmt.where(BranchStorageNode.tenant_id == tenant_id) + if branch_id: + stmt = stmt.where(BranchStorageNode.branch_id == branch_id) + return db.execute(stmt).scalars().all() + + +def get_active_storage_node_for_branch(db: Session, tenant_id: int, branch_id: int | None) -> BranchStorageNode | None: + stmt = ( + select(BranchStorageNode) + .where( + BranchStorageNode.tenant_id == tenant_id, + BranchStorageNode.is_active.is_(True), + BranchStorageNode.status == "active", + ) + .order_by(BranchStorageNode.branch_id.desc(), BranchStorageNode.id.asc()) + ) + if branch_id: + stmt = stmt.where(or_(BranchStorageNode.branch_id == branch_id, BranchStorageNode.branch_id.is_(None))) + else: + stmt = stmt.where(BranchStorageNode.branch_id.is_(None)) + return db.execute(stmt.limit(1)).scalar_one_or_none() + + +def create_storage_job_for_version(db: Session, *, version: EngagementDocumentVersion, user) -> DocumentStorageJob | None: + node = get_active_storage_node_for_branch(db, version.tenant_id, version.branch_id) + if not node: + return None + job = DocumentStorageJob( + tenant_id=version.tenant_id, + branch_id=version.branch_id, + storage_node_id=node.id, + document_id=version.document_id, + version_id=version.id, + staging_relative_path=version.local_relative_path, + target_relative_path=version.local_relative_path, + file_size_bytes=version.file_size_bytes, + expected_hash_sha256=version.file_hash_sha256, + created_by_user_id=getattr(user, "id", None), + ) + version.storage_backend = "BRANCH_STORAGE_NODE" + version.storage_status = "pending_local_sync" + db.add(job) + return job + + +def authenticate_storage_node(db: Session, node_code: str | None, secret: str | None, request=None) -> BranchStorageNode | None: + if not node_code or not secret: + return None + node = db.execute( + select(BranchStorageNode).where(BranchStorageNode.node_code == node_code, BranchStorageNode.is_active.is_(True)) + ).scalar_one_or_none() + if not node or not hmac.compare_digest(node.secret_key_hash, hash_storage_secret(secret)): + return None + node.last_seen_at_utc = __import__("datetime").datetime.now(__import__("datetime").timezone.utc) + node.last_seen_ip = request.client.host if request and request.client else None + return node + + +def list_pending_storage_jobs(db: Session, node: BranchStorageNode, limit: int = 20): + return db.execute( + select(DocumentStorageJob) + .where(DocumentStorageJob.storage_node_id == node.id, DocumentStorageJob.status.in_(["pending", "retry"])) + .order_by(DocumentStorageJob.priority.asc(), DocumentStorageJob.created_at_utc.asc()) + .limit(limit) + ).scalars().all() + + +def get_storage_job_for_node(db: Session, node: BranchStorageNode, job_id: int) -> DocumentStorageJob | None: + return db.execute( + select(DocumentStorageJob).where(DocumentStorageJob.id == job_id, DocumentStorageJob.storage_node_id == node.id) + ).scalar_one_or_none() + + +def acknowledge_storage_job(db: Session, *, node: BranchStorageNode, job: DocumentStorageJob, acknowledged_hash: str, local_final_path: str | None, success: bool, error: str | None = None) -> bool: + from datetime import datetime, timezone + job.attempts = int(job.attempts or 0) + 1 + if success and acknowledged_hash and acknowledged_hash.lower() == job.expected_hash_sha256.lower(): + job.status = "completed" + job.completed_at_utc = datetime.now(timezone.utc) + job.acknowledged_hash_sha256 = acknowledged_hash.lower() + job.local_final_path = local_final_path + version = db.get(EngagementDocumentVersion, job.version_id) + if version: + version.storage_status = "stored_on_branch_node" + node.used_storage_bytes = int(node.used_storage_bytes or 0) + int(job.file_size_bytes or 0) + return True + job.status = "failed" if job.attempts >= 3 else "retry" + job.last_error = error or "Hash mismatch or local storage acknowledgement failed." + return False + + +def list_storage_jobs(db: Session, tenant_id: int | None = None, branch_id: int | None = None, status: str | None = None, limit: int = 200): + stmt = select(DocumentStorageJob).order_by(DocumentStorageJob.created_at_utc.desc()) + if tenant_id: + stmt = stmt.where(DocumentStorageJob.tenant_id == tenant_id) + if branch_id: + stmt = stmt.where(DocumentStorageJob.branch_id == branch_id) + if status: + stmt = stmt.where(DocumentStorageJob.status == status) + return db.execute(stmt.limit(limit)).scalars().all() + + + +def _now_utc(): + from datetime import datetime, timezone + return datetime.now(timezone.utc) + + +def get_completed_storage_job_for_version(db: Session, version_id: int) -> DocumentStorageJob | None: + """Return the latest completed local-storage job for a document version.""" + return db.execute( + select(DocumentStorageJob) + .where(DocumentStorageJob.version_id == version_id, DocumentStorageJob.status == "completed") + .order_by(DocumentStorageJob.completed_at_utc.desc(), DocumentStorageJob.id.desc()) + .limit(1) + ).scalar_one_or_none() + + +def create_download_request_for_version(db: Session, *, version: EngagementDocumentVersion, user, request=None) -> DocumentDownloadRequest | None: + """Queue a secure local-to-cloud download request for the branch storage agent. + + If the staged ERP file is already available, callers should stream that file + directly and avoid creating a request. This request is used when the version + is stored on the branch node and the ERP copy is unavailable. + """ + completed_job = get_completed_storage_job_for_version(db, version.id) + if not completed_job or not completed_job.storage_node_id: + return None + + # Reuse a recent pending/ready request for the same user/version to avoid duplicate queues. + existing = db.execute( + select(DocumentDownloadRequest) + .where( + DocumentDownloadRequest.version_id == version.id, + DocumentDownloadRequest.requested_by_user_id == getattr(user, "id", None), + DocumentDownloadRequest.request_status.in_(["pending", "picked", "ready"]), + ) + .order_by(DocumentDownloadRequest.created_at_utc.desc()) + .limit(1) + ).scalar_one_or_none() + if existing: + return existing + + download_request = DocumentDownloadRequest( + tenant_id=version.tenant_id, + branch_id=version.branch_id, + storage_node_id=completed_job.storage_node_id, + document_id=version.document_id, + version_id=version.id, + local_relative_path=completed_job.local_final_path or completed_job.target_relative_path, + expected_hash_sha256=version.file_hash_sha256, + file_size_bytes=version.file_size_bytes, + requested_by_user_id=getattr(user, "id", None), + requested_ip=(request.client.host if request and request.client else None), + requested_user_agent=(request.headers.get("user-agent")[:500] if request else None), + ) + db.add(download_request) + return download_request + + +def list_pending_download_requests(db: Session, node: BranchStorageNode, limit: int = 20): + return db.execute( + select(DocumentDownloadRequest) + .where( + DocumentDownloadRequest.storage_node_id == node.id, + DocumentDownloadRequest.request_status.in_(["pending", "retry"]), + ) + .order_by(DocumentDownloadRequest.created_at_utc.asc()) + .limit(limit) + ).scalars().all() + + +def get_download_request_for_node(db: Session, node: BranchStorageNode, request_id: int) -> DocumentDownloadRequest | None: + return db.execute( + select(DocumentDownloadRequest).where( + DocumentDownloadRequest.id == request_id, + DocumentDownloadRequest.storage_node_id == node.id, + ) + ).scalar_one_or_none() + + +def get_download_request(db: Session, request_id: int) -> DocumentDownloadRequest | None: + return db.execute( + select(DocumentDownloadRequest) + .options(joinedload(DocumentDownloadRequest.document), joinedload(DocumentDownloadRequest.version)) + .where(DocumentDownloadRequest.id == request_id) + ).unique().scalar_one_or_none() + + +def list_download_requests(db: Session, tenant_id: int | None = None, branch_id: int | None = None, status: str | None = None, limit: int = 200): + stmt = ( + select(DocumentDownloadRequest) + .options(joinedload(DocumentDownloadRequest.document), joinedload(DocumentDownloadRequest.version)) + .order_by(DocumentDownloadRequest.created_at_utc.desc()) + ) + if tenant_id: + stmt = stmt.where(DocumentDownloadRequest.tenant_id == tenant_id) + if branch_id: + stmt = stmt.where(DocumentDownloadRequest.branch_id == branch_id) + if status: + stmt = stmt.where(DocumentDownloadRequest.request_status == status) + return db.execute(stmt.limit(limit)).scalars().all() + + +def list_recent_download_requests_for_engagement(db: Session, engagement_id: int, user_id: int | None = None, limit: int = 10): + stmt = ( + select(DocumentDownloadRequest) + .options(joinedload(DocumentDownloadRequest.document), joinedload(DocumentDownloadRequest.version)) + .join(EngagementDocumentVersion, EngagementDocumentVersion.id == DocumentDownloadRequest.version_id) + .where(EngagementDocumentVersion.engagement_id == engagement_id) + .order_by(DocumentDownloadRequest.created_at_utc.desc()) + .limit(limit) + ) + if user_id: + stmt = stmt.where(DocumentDownloadRequest.requested_by_user_id == user_id) + return db.execute(stmt).scalars().all() + + +def download_request_cache_path(download_request: DocumentDownloadRequest) -> Path | None: + if not download_request.cached_relative_path: + return None + return DOWNLOAD_CACHE_ROOT / Path(download_request.cached_relative_path) + + +def fulfill_download_request_from_upload(db: Session, *, node: BranchStorageNode, download_request: DocumentDownloadRequest, upload_file) -> bool: + """Store the file uploaded by local agent into ERP download cache and verify hash.""" + if download_request.storage_node_id != node.id: + return False + version = db.get(EngagementDocumentVersion, download_request.version_id) + if not version: + download_request.request_status = "failed" + download_request.failed_at_utc = _now_utc() + download_request.last_error = "Document version not found." + return False + + safe_name = sanitize_segment(version.original_filename or f"version_{version.id}.bin", "document.bin") + cache_rel = Path(f"request_{download_request.id}") / f"v{version.version_no:03d}_{safe_name}" + cache_abs = DOWNLOAD_CACHE_ROOT / cache_rel + cache_abs.parent.mkdir(parents=True, exist_ok=True) + + hasher = hashlib.sha256() + total = 0 + with cache_abs.open("wb") as out: + while True: + chunk = upload_file.file.read(1024 * 1024) + if not chunk: + break + total += len(chunk) + if total > MAX_UPLOAD_BYTES: + out.close() + cache_abs.unlink(missing_ok=True) + download_request.request_status = "failed" + download_request.failed_at_utc = _now_utc() + download_request.last_error = f"Uploaded stream exceeds maximum limit of {MAX_UPLOAD_BYTES // (1024 * 1024)} MB." + return False + hasher.update(chunk) + out.write(chunk) + + actual_hash = hasher.hexdigest() + download_request.attempts = int(download_request.attempts or 0) + 1 + if actual_hash.lower() != (download_request.expected_hash_sha256 or "").lower(): + cache_abs.unlink(missing_ok=True) + download_request.request_status = "failed" if download_request.attempts >= 3 else "retry" + download_request.last_error = "Uploaded file hash does not match the original document version." + if download_request.request_status == "failed": + download_request.failed_at_utc = _now_utc() + return False + + download_request.request_status = "ready" + download_request.cached_relative_path = str(cache_rel).replace("\\", "/") + download_request.cached_hash_sha256 = actual_hash.lower() + download_request.file_size_bytes = total + download_request.fulfilled_at_utc = _now_utc() + download_request.last_error = None + return True + +def save_uploaded_revision( + db: Session, + *, + engagement: ClientServiceSubscription, + upload_file, + title: str, + document_type: str, + description: str | None, + remarks: str | None, + user, + existing_document_id: int | None = None, +) -> EngagementDocument: + original_filename = Path(upload_file.filename or "document.bin").name + document_type = document_type if document_type in DOCUMENT_TYPES else "GENERAL" + + if existing_document_id: + document = db.get(EngagementDocument, existing_document_id) + if not document or document.is_deleted or document.engagement_id != engagement.id: + raise ValueError("Invalid document selected for new revision.") + if title: + document.title = title.strip()[:255] + if description is not None: + document.description = description.strip() or None + document.document_type = document_type + else: + document = EngagementDocument( + tenant_id=engagement.tenant_id, + branch_id=engagement.branch_id, + client_id=engagement.client_id, + engagement_id=engagement.id, + financial_year=engagement.financial_year, + assessment_year=engagement.assessment_year, + document_code="PENDING", + document_type=document_type, + title=(title.strip()[:255] if title else original_filename[:255]), + description=description.strip() if description else None, + created_by_user_id=user.id, + updated_by_user_id=user.id, + ) + db.add(document) + db.flush() + document.document_code = create_document_code(db, engagement.tenant_id, engagement.id, document.id) + + next_version_no = int(document.current_version_no or 0) + 1 + rel_path = build_year_wise_relative_path(db, engagement, document, next_version_no, original_filename) + abs_path = DEFAULT_STORAGE_ROOT / rel_path + try: + abs_path.parent.mkdir(parents=True, exist_ok=True) + except OSError as exc: + raise RuntimeError( + "Unable to create ERP document staging folder. " + f"storage_root='{DEFAULT_STORAGE_ROOT}', relative_path='{rel_path}'. " + "On Windows, set DOCUMENT_STORAGE_ROOT to a short absolute path like " + "D:\\AuditFirmERPDocuments\\engagement_documents." + ) from exc + + hasher = hashlib.sha256() + total = 0 + with abs_path.open("wb") as out: + while True: + chunk = upload_file.file.read(1024 * 1024) + if not chunk: + break + total += len(chunk) + if total > MAX_UPLOAD_BYTES: + out.close() + abs_path.unlink(missing_ok=True) + raise ValueError(f"File exceeds maximum upload limit of {MAX_UPLOAD_BYTES // (1024 * 1024)} MB.") + hasher.update(chunk) + out.write(chunk) + + version = EngagementDocumentVersion( + document_id=document.id, + tenant_id=document.tenant_id, + branch_id=document.branch_id, + client_id=document.client_id, + engagement_id=document.engagement_id, + version_no=next_version_no, + original_filename=original_filename, + stored_filename=abs_path.name, + content_type=getattr(upload_file, "content_type", None), + file_size_bytes=total, + file_hash_sha256=hasher.hexdigest(), + local_relative_path=str(rel_path).replace("\\", "/"), + remarks=remarks.strip() if remarks else None, + uploaded_by_user_id=user.id, + ) + document.current_version_no = next_version_no + document.updated_by_user_id = user.id + db.add(version) + db.flush() + create_storage_job_for_version(db, version=version, user=user) + return document + + + +PERMANENT_DOCUMENT_CATEGORIES = [ + "Company Registration", + "GST Registration", + "Income Tax", + "Bank", + "KYC", + "Agreements", + "Licenses", + "DSC", + "ROC Master Data", + "Other Permanent Documents", +] + + +def user_can_view_client_documents(db: Session, user, client: Client, scope: DocumentScope) -> bool: + if not _has_perm(db, user, "documents.view"): + return False + if scope.is_system_admin and not (scope.is_firm_admin or scope.is_partner or scope.is_branch_manager or scope.is_staff): + return False + if scope.is_firm_admin: + return client.tenant_id == getattr(user, "tenant_id", client.tenant_id) + if scope.is_partner: + return client.tenant_id == getattr(user, "tenant_id", client.tenant_id) and getattr(client, "partner_id", None) == user.id + if scope.is_branch_manager or scope.is_staff: + return client.tenant_id == getattr(user, "tenant_id", client.tenant_id) and (getattr(client, "branch_id", None) in (None, getattr(user, "branch_id", None))) + return False + + +def user_can_upload_client_documents(db: Session, user, client: Client, scope: DocumentScope) -> bool: + return _has_perm(db, user, "documents.upload") and user_can_view_client_documents(db, user, client, scope) + + +def list_visible_clients_for_permanent_documents(db: Session, user, scope: DocumentScope, q: str = "", limit: int = 200): + stmt = select(Client).order_by(Client.client_name.asc()).limit(limit) + if scope.is_firm_admin: + stmt = stmt.where(Client.tenant_id == getattr(user, "tenant_id", None)) + elif scope.is_partner: + stmt = stmt.where(Client.tenant_id == getattr(user, "tenant_id", None), Client.partner_id == user.id) + elif scope.is_branch_manager or scope.is_staff: + stmt = stmt.where(Client.tenant_id == getattr(user, "tenant_id", None), or_(Client.branch_id == getattr(user, "branch_id", None), Client.branch_id.is_(None))) + else: + return [] + if q: + pattern = f"%{q.strip()}%" + stmt = stmt.where(or_(Client.client_name.ilike(pattern), Client.client_code.ilike(pattern))) + return db.execute(stmt).scalars().all() + + +def list_permanent_documents_for_client(db: Session, client_id: int): + return db.execute( + select(PermanentClientDocument) + .options(joinedload(PermanentClientDocument.versions), joinedload(PermanentClientDocument.client)) + .where(PermanentClientDocument.client_id == client_id, PermanentClientDocument.is_deleted.is_(False)) + .order_by(PermanentClientDocument.category.asc(), PermanentClientDocument.updated_at_utc.desc()) + ).unique().scalars().all() + + +def get_permanent_document(db: Session, document_id: int) -> PermanentClientDocument | None: + return db.execute( + select(PermanentClientDocument) + .options(joinedload(PermanentClientDocument.versions), joinedload(PermanentClientDocument.client)) + .where(PermanentClientDocument.id == document_id, PermanentClientDocument.is_deleted.is_(False)) + ).unique().scalar_one_or_none() + + +def get_permanent_version(db: Session, version_id: int) -> PermanentClientDocumentVersion | None: + return db.execute( + select(PermanentClientDocumentVersion) + .options(joinedload(PermanentClientDocumentVersion.document).joinedload(PermanentClientDocument.client)) + .where(PermanentClientDocumentVersion.id == version_id) + ).unique().scalar_one_or_none() + + +def get_latest_permanent_version(document: PermanentClientDocument) -> PermanentClientDocumentVersion | None: + return document.versions[0] if document.versions else None + + +def create_permanent_document_code(db: Session, tenant_id: int, client_id: int, current_document_id: int | None = None) -> str: + filters = [PermanentClientDocument.tenant_id == tenant_id, PermanentClientDocument.client_id == client_id] + if current_document_id: + filters.append(PermanentClientDocument.id != current_document_id) + count = db.execute(select(func.count(PermanentClientDocument.id)).where(*filters)).scalar_one() + return f"PERM{client_id}-DOC{int(count) + 1:04d}" + + +def get_active_storage_node_for_permanent_client(db: Session, tenant_id: int, branch_id: int | None) -> BranchStorageNode | None: + stmt = select(BranchStorageNode).where( + BranchStorageNode.tenant_id == tenant_id, + BranchStorageNode.is_active.is_(True), + BranchStorageNode.status == "active", + ) + if branch_id is None: + stmt = stmt.where(BranchStorageNode.branch_id.is_(None)) + else: + stmt = stmt.where(or_(BranchStorageNode.branch_id == branch_id, BranchStorageNode.branch_id.is_(None))) + return db.execute(stmt.order_by(BranchStorageNode.branch_id.desc(), BranchStorageNode.id.asc()).limit(1)).scalar_one_or_none() + + +def create_permanent_storage_job_for_version(db: Session, *, version: PermanentClientDocumentVersion, user) -> PermanentDocumentStorageJob | None: + node = get_active_storage_node_for_permanent_client(db, version.tenant_id, version.branch_id) + if not node: + return None + job = PermanentDocumentStorageJob( + tenant_id=version.tenant_id, + branch_id=version.branch_id, + storage_node_id=node.id, + document_id=version.document_id, + version_id=version.id, + staging_relative_path=version.local_relative_path, + target_relative_path=version.local_relative_path, + file_size_bytes=version.file_size_bytes, + expected_hash_sha256=version.file_hash_sha256, + created_by_user_id=getattr(user, "id", None), + ) + db.add(job) + return job + + +def list_pending_permanent_storage_jobs(db: Session, node: BranchStorageNode, limit: int = 20): + return db.execute( + select(PermanentDocumentStorageJob) + .where(PermanentDocumentStorageJob.storage_node_id == node.id, PermanentDocumentStorageJob.status.in_(["pending", "retry"])) + .order_by(PermanentDocumentStorageJob.priority.asc(), PermanentDocumentStorageJob.created_at_utc.asc()) + .limit(limit) + ).scalars().all() + + +def get_permanent_storage_job_for_node(db: Session, node: BranchStorageNode, job_id: int) -> PermanentDocumentStorageJob | None: + return db.execute(select(PermanentDocumentStorageJob).where(PermanentDocumentStorageJob.id == job_id, PermanentDocumentStorageJob.storage_node_id == node.id)).scalar_one_or_none() + + +def acknowledge_permanent_storage_job(db: Session, *, node: BranchStorageNode, job: PermanentDocumentStorageJob, acknowledged_hash: str, local_final_path: str | None, success: bool, error: str | None = None) -> bool: + if job.storage_node_id != node.id: + return False + job.attempts = int(job.attempts or 0) + 1 + if not success: + job.status = "failed" if job.attempts >= 3 else "retry" + job.last_error = error or "Local storage agent reported failure." + return False + if acknowledged_hash.lower() != (job.expected_hash_sha256 or "").lower(): + job.status = "failed" if job.attempts >= 3 else "retry" + job.last_error = "Hash mismatch after local storage write." + return False + job.status = "completed" + job.completed_at_utc = _now_utc() + job.acknowledged_hash_sha256 = acknowledged_hash.lower() + job.local_final_path = local_final_path or job.target_relative_path + job.last_error = None + version = db.get(PermanentClientDocumentVersion, job.version_id) + if version: + version.storage_status = "synced_local" + return True + + +def get_completed_permanent_storage_job_for_version(db: Session, version_id: int) -> PermanentDocumentStorageJob | None: + return db.execute( + select(PermanentDocumentStorageJob) + .where(PermanentDocumentStorageJob.version_id == version_id, PermanentDocumentStorageJob.status == "completed") + .order_by(PermanentDocumentStorageJob.completed_at_utc.desc(), PermanentDocumentStorageJob.id.desc()) + .limit(1) + ).scalar_one_or_none() + + +def create_permanent_download_request_for_version(db: Session, *, version: PermanentClientDocumentVersion, user, request=None) -> PermanentDocumentDownloadRequest | None: + completed_job = get_completed_permanent_storage_job_for_version(db, version.id) + if not completed_job: + return None + existing = db.execute( + select(PermanentDocumentDownloadRequest) + .where( + PermanentDocumentDownloadRequest.version_id == version.id, + PermanentDocumentDownloadRequest.requested_by_user_id == getattr(user, "id", None), + PermanentDocumentDownloadRequest.request_status.in_(["pending", "picked", "ready"]), + ) + .order_by(PermanentDocumentDownloadRequest.created_at_utc.desc()) + .limit(1) + ).scalar_one_or_none() + if existing: + return existing + item = PermanentDocumentDownloadRequest( + tenant_id=version.tenant_id, + branch_id=version.branch_id, + storage_node_id=completed_job.storage_node_id, + document_id=version.document_id, + version_id=version.id, + local_relative_path=completed_job.local_final_path or completed_job.target_relative_path, + expected_hash_sha256=version.file_hash_sha256, + file_size_bytes=version.file_size_bytes, + requested_by_user_id=getattr(user, "id", None), + requested_ip=(request.client.host if request and request.client else None), + requested_user_agent=(request.headers.get("user-agent")[:500] if request else None), + ) + db.add(item) + return item + + +def list_pending_permanent_download_requests(db: Session, node: BranchStorageNode, limit: int = 20): + return db.execute( + select(PermanentDocumentDownloadRequest) + .where(PermanentDocumentDownloadRequest.storage_node_id == node.id, PermanentDocumentDownloadRequest.request_status.in_(["pending", "retry"])) + .order_by(PermanentDocumentDownloadRequest.created_at_utc.asc()) + .limit(limit) + ).scalars().all() + + +def get_permanent_download_request_for_node(db: Session, node: BranchStorageNode, request_id: int) -> PermanentDocumentDownloadRequest | None: + return db.execute(select(PermanentDocumentDownloadRequest).where(PermanentDocumentDownloadRequest.id == request_id, PermanentDocumentDownloadRequest.storage_node_id == node.id)).scalar_one_or_none() + + +def permanent_download_request_cache_path(download_request: PermanentDocumentDownloadRequest) -> Path | None: + if not download_request.cached_relative_path: + return None + return DOWNLOAD_CACHE_ROOT / Path(download_request.cached_relative_path) + + +def fulfill_permanent_download_request_from_upload(db: Session, *, node: BranchStorageNode, download_request: PermanentDocumentDownloadRequest, upload_file) -> bool: + if download_request.storage_node_id != node.id: + return False + version = db.get(PermanentClientDocumentVersion, download_request.version_id) + if not version: + download_request.request_status = "failed" + download_request.failed_at_utc = _now_utc() + download_request.last_error = "Permanent document version not found." + return False + safe_name = sanitize_segment(version.original_filename or f"version_{version.id}.bin", "document.bin") + cache_rel = Path(f"permanent_request_{download_request.id}") / f"v{version.version_no:03d}_{safe_name}" + cache_abs = DOWNLOAD_CACHE_ROOT / cache_rel + cache_abs.parent.mkdir(parents=True, exist_ok=True) + hasher = hashlib.sha256() + total = 0 + with cache_abs.open("wb") as out: + while True: + chunk = upload_file.file.read(1024 * 1024) + if not chunk: + break + total += len(chunk) + if total > MAX_UPLOAD_BYTES: + out.close(); cache_abs.unlink(missing_ok=True) + download_request.request_status = "failed"; download_request.failed_at_utc = _now_utc(); download_request.last_error = "Uploaded stream exceeds maximum limit." + return False + hasher.update(chunk); out.write(chunk) + actual_hash = hasher.hexdigest() + download_request.attempts = int(download_request.attempts or 0) + 1 + if actual_hash.lower() != (download_request.expected_hash_sha256 or "").lower(): + cache_abs.unlink(missing_ok=True) + download_request.request_status = "failed" if download_request.attempts >= 3 else "retry" + download_request.last_error = "Uploaded file hash does not match the permanent document version." + if download_request.request_status == "failed": + download_request.failed_at_utc = _now_utc() + return False + download_request.request_status = "ready" + download_request.cached_relative_path = str(cache_rel).replace("\\", "/") + download_request.cached_hash_sha256 = actual_hash.lower() + download_request.file_size_bytes = total + download_request.fulfilled_at_utc = _now_utc() + download_request.last_error = None + return True + + +def save_uploaded_permanent_revision(db: Session, *, client: Client, upload_file, title: str, category: str, description: str | None, remarks: str | None, user, existing_document_id: int | None = None) -> PermanentClientDocument: + original_filename = Path(upload_file.filename or "document.bin").name + category = category if category in PERMANENT_DOCUMENT_CATEGORIES else "Other Permanent Documents" + if existing_document_id: + document = db.get(PermanentClientDocument, existing_document_id) + if not document or document.is_deleted or document.client_id != client.id: + raise ValueError("Invalid permanent document selected for new revision.") + if title: + document.title = title.strip()[:255] + if description is not None: + document.description = description.strip() or None + document.category = category + else: + document = PermanentClientDocument( + tenant_id=client.tenant_id, + branch_id=getattr(client, "branch_id", None), + client_id=client.id, + document_code="PENDING", + category=category, + title=(title.strip()[:255] if title else original_filename[:255]), + description=description.strip() if description else None, + created_by_user_id=getattr(user, "id", None), + updated_by_user_id=getattr(user, "id", None), + ) + db.add(document); db.flush() + document.document_code = create_permanent_document_code(db, client.tenant_id, client.id, document.id) + next_version_no = int(document.current_version_no or 0) + 1 + rel_path = build_permanent_relative_path(db, client, document, next_version_no, original_filename) + abs_path = DEFAULT_STORAGE_ROOT / rel_path + abs_path.parent.mkdir(parents=True, exist_ok=True) + hasher = hashlib.sha256(); total = 0 + with abs_path.open("wb") as out: + while True: + chunk = upload_file.file.read(1024 * 1024) + if not chunk: + break + total += len(chunk) + if total > MAX_UPLOAD_BYTES: + out.close(); abs_path.unlink(missing_ok=True) + raise ValueError(f"File exceeds maximum upload limit of {MAX_UPLOAD_BYTES // (1024 * 1024)} MB.") + hasher.update(chunk); out.write(chunk) + version = PermanentClientDocumentVersion( + document_id=document.id, + tenant_id=document.tenant_id, + branch_id=document.branch_id, + client_id=document.client_id, + version_no=next_version_no, + original_filename=original_filename, + stored_filename=abs_path.name, + content_type=getattr(upload_file, "content_type", None), + file_size_bytes=total, + file_hash_sha256=hasher.hexdigest(), + local_relative_path=str(rel_path).replace("\\", "/"), + remarks=remarks.strip() if remarks else None, + uploaded_by_user_id=getattr(user, "id", None), + ) + document.current_version_no = next_version_no + document.updated_by_user_id = getattr(user, "id", None) + db.add(version); db.flush() + create_permanent_storage_job_for_version(db, version=version, user=user) + return document + + +def permanent_version_absolute_path(version: PermanentClientDocumentVersion) -> Path: + return DEFAULT_STORAGE_ROOT / Path(version.local_relative_path) + + +def version_absolute_path(version: EngagementDocumentVersion) -> Path: + return DEFAULT_STORAGE_ROOT / Path(version.local_relative_path) diff --git a/app/modules/documents/templates/documents/download_requests.html b/app/modules/documents/templates/documents/download_requests.html new file mode 100644 index 0000000..90998bc --- /dev/null +++ b/app/modules/documents/templates/documents/download_requests.html @@ -0,0 +1,42 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Document Download Requests

+

Secure local-to-cloud retrieval queue for files stored on branch storage nodes.

+
+ +
+ +
+
+ + Clear +
+ +
+ + + + {% for item in requests %} + + + + + + + + + + {% else %} + + {% endfor %} + +
RequestDocumentVersionStatusLocal PathCreatedAction
#{{ item.id }}{{ item.document.title if item.document else item.document_id }}{{ item.version.version_no if item.version else item.version_id }}{{ item.request_status.replace('_',' ').title() }}{% if item.last_error %}
{{ item.last_error }}
{% endif %}
{{ item.local_relative_path or '-' }}{{ item.created_at_utc }}{% if item.request_status == 'ready' %}Download{% else %}Not ready{% endif %}
No download requests found.
+
+
+{% endblock %} diff --git a/app/modules/documents/templates/documents/engagement_documents.html b/app/modules/documents/templates/documents/engagement_documents.html new file mode 100644 index 0000000..6a8b1b5 --- /dev/null +++ b/app/modules/documents/templates/documents/engagement_documents.html @@ -0,0 +1,137 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Engagement Documents

+

{{ engagement.client.client_name if engagement.client else '-' }} · {{ engagement.catalogue.service_name if engagement.catalogue else '-' }} · FY {{ engagement.financial_year }}

+
+ +
+ + {% if request.query_params.get('uploaded') %}
Document uploaded successfully.
{% endif %} + {% if request.query_params.get('deleted') %}
Document archived successfully.
{% endif %} + {% if request.query_params.get('download_queued') %}
The file is stored in branch local storage. A secure retrieval request #{{ request.query_params.get('download_queued') }} has been queued. Please refresh after the local storage app fulfils it.
{% endif %} + {% if request.query_params.get('error') %}
Action failed. Please check file size, permission and storage path.
{% endif %} + + {% if can_upload %} +
+

Upload Document / New Revision

+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ {% endif %} + +
+ + + + + + + + + + + + + {% for doc in documents %} + {% set latest = doc.versions[0] if doc.versions else None %} + + + + + + + + + {% else %} + + {% endfor %} + +
DocumentTypeLatest VersionStorageRevision HistoryAction
{{ doc.title }}
{{ doc.document_code }}{% if doc.description %} · {{ doc.description }}{% endif %}
{{ doc.document_type.replace('_',' ').title() }}{% if latest %}
v{{ latest.version_no }} · {{ latest.original_filename }}
{{ (latest.file_size_bytes / 1024)|round(1) }} KB · {{ latest.uploaded_at_utc }}
{% else %}-{% endif %}
+ {% if latest %} +
{{ latest.storage_status.replace('_',' ').title() }}
+
{{ latest.local_relative_path }}
+ {% else %}-{% endif %} +
+ {% for v in doc.versions %} +
v{{ v.version_no }} · {{ v.original_filename }}{% if v.remarks %} · {{ v.remarks }}{% endif %}
+ {% endfor %} +
+ {% if latest %}Download Latest{% endif %} + {% if can_upload %} +
+ + +
+ {% endif %} +
No documents uploaded for this engagement.
+
+ + {% if download_requests %} +
+
+
+

Recent Local Storage Retrieval Requests

+

Used when the ERP staged file is unavailable and the file must be pulled back from branch local storage.

+
+ View All +
+
+ + + + {% for req in download_requests %} + + + + + + + + {% endfor %} + +
RequestVersionStatusCreatedAction
#{{ req.id }}v{{ req.version.version_no if req.version else req.version_id }}{{ req.request_status.replace('_',' ').title() }}{{ req.created_at_utc }}{% if req.request_status == 'ready' %}Download{% else %}Waiting for local app{% endif %}
+
+
+ {% endif %} + +
+{% endblock %} diff --git a/app/modules/documents/templates/documents/index.html b/app/modules/documents/templates/documents/index.html new file mode 100644 index 0000000..22c8f73 --- /dev/null +++ b/app/modules/documents/templates/documents/index.html @@ -0,0 +1,55 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Engagement Documents

+

Upload, stream, download and version documents engagement-wise with year-wise storage.

+
+
+ +
+
+ + +
+
+ + +
+ +
+ + +
+ + + + + + + + + + + + {% for row in engagements %} + + + + + + + + {% else %} + + {% endfor %} + +
ClientEngagementFY / AYAssignedAction
{{ row.client.client_name if row.client else '-' }}
{{ row.client.client_code if row.client else '' }}
{{ row.catalogue.service_name if row.catalogue else '-' }}
{{ row.catalogue.service_code if row.catalogue else '' }}
FY: {{ row.financial_year or '-' }}
AY: {{ row.assessment_year or '-' }}
Partner: {{ row.assigned_partner.full_name if row.assigned_partner and row.assigned_partner.full_name else (row.assigned_partner.email if row.assigned_partner else '-') }}
Staff: {{ row.assigned_staff.full_name if row.assigned_staff and row.assigned_staff.full_name else (row.assigned_staff.email if row.assigned_staff else '-') }}
Open Documents
No engagements available for document access.
+
+
+{% endblock %} diff --git a/app/modules/documents/templates/documents/permanent_client_documents.html b/app/modules/documents/templates/documents/permanent_client_documents.html new file mode 100644 index 0000000..1a035c2 --- /dev/null +++ b/app/modules/documents/templates/documents/permanent_client_documents.html @@ -0,0 +1,97 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Permanent Client Documents

+

{{ client.client_name }} · {{ client.client_code or '-' }}

+
+ +
+ + {% if request.query_params.get('uploaded') %}
Permanent document uploaded successfully.
{% endif %} + {% if request.query_params.get('deleted') %}
Permanent document archived successfully.
{% endif %} + {% if request.query_params.get('download_queued') %}
The file is stored in branch local storage. Retrieval request #{{ request.query_params.get('download_queued') }} has been queued.
{% endif %} + {% if request.query_params.get('error') %}
Action failed. Please check file size, permission and storage path.
{% endif %} + + {% if can_upload %} +
+

Upload Permanent Document / New Revision

+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ {% endif %} + +
+ + + + + + + + + + + + + {% for doc in documents %} + {% set latest = doc.versions[0] if doc.versions else None %} + + + + + + + + + {% else %} + + {% endfor %} + +
DocumentCategoryLatest VersionStorage PathVersionsAction
{{ doc.title }}
{{ doc.document_code }}{% if doc.description %} · {{ doc.description }}{% endif %}
{{ doc.category }}{% if latest %}
v{{ latest.version_no }} · {{ latest.original_filename }}
{{ (latest.file_size_bytes / 1024)|round(1) }} KB · {{ latest.uploaded_at_utc }}
{% else %}-{% endif %}
{% if latest %}
{{ latest.storage_status.replace('_',' ').title() }}
{{ latest.local_relative_path }}
{% else %}-{% endif %}
{% for v in doc.versions %}
v{{ v.version_no }} · {{ v.original_filename }}{% if v.remarks %} · {{ v.remarks }}{% endif %}
{% endfor %}
+ {% if latest %}Download Latest{% endif %} + {% if can_upload %} +
+ + +
+ {% endif %} +
No permanent documents uploaded for this client.
+
+
+{% endblock %} diff --git a/app/modules/documents/templates/documents/permanent_index.html b/app/modules/documents/templates/documents/permanent_index.html new file mode 100644 index 0000000..7091c33 --- /dev/null +++ b/app/modules/documents/templates/documents/permanent_index.html @@ -0,0 +1,45 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Permanent Client Documents

+

Store standing records like registration documents, GST, PAN/TAN, KYC, bank and agreements outside financial-year folders.

+
+ Engagement Documents +
+ +
+
+ + +
+ +
+ +
+ + + + + + + + + + + {% for client in clients %} + + + + + + + {% else %} + + {% endfor %} + +
ClientCodeBranchAction
{{ client.client_name }}{{ client.client_code or '-' }}{{ client.branch_id or '-' }}Open Permanent Vault
No clients available for permanent document access.
+
+
+{% endblock %} diff --git a/app/modules/documents/templates/documents/storage_jobs.html b/app/modules/documents/templates/documents/storage_jobs.html new file mode 100644 index 0000000..16e7aee --- /dev/null +++ b/app/modules/documents/templates/documents/storage_jobs.html @@ -0,0 +1,35 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Document Storage Jobs

+

Cloud-to-local streaming queue for branch storage nodes.

+
+ +
+
+
+ + Clear +
+
+ + + + {% for job in jobs %} + + + + + + + + {% else %} + + {% endfor %} + +
JobVersionStatusTarget PathCreated
#{{ job.id }}{{ job.version_id }}{{ job.status }}{{ job.target_relative_path }}{{ job.created_at_utc }}
No storage jobs found.
+
+
+{% endblock %} diff --git a/app/modules/documents/templates/documents/storage_nodes.html b/app/modules/documents/templates/documents/storage_nodes.html new file mode 100644 index 0000000..22a5e8b --- /dev/null +++ b/app/modules/documents/templates/documents/storage_nodes.html @@ -0,0 +1,199 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Branch Local Storage

+

Partner/branch controlled storage setup for engagement document streaming.

+

Scope: {{ storage_scope_title or '-' }}

+
+ +
+ + {% if request.query_params.get('error') == 'branch_not_linked' %} +
+ Your user is not linked to a branch. Please update the Partner/Branch Manager user profile with branch_id before enabling local storage. +
+ {% endif %} + +
+
+
Storage Nodes
+
{{ nodes|length }}
+
+
+
Online / Active
+
{{ nodes|selectattr('is_active')|list|length }}
+
+
+
Recent Storage Jobs
+
{{ recent_jobs|length if recent_jobs is defined else 0 }}
+
+
+
Recent Download Requests
+
{{ recent_download_requests|length if recent_download_requests is defined else 0 }}
+
+
+ + {% if request.query_params.get('created') == '1' %} +
+ Storage node created. Use the Download Agent Package button in the node row to generate the branch package. Secret and standalone .env are not displayed. +
+ {% endif %} + + {% if request.query_params.get('error') == 'env_download_disabled' %} +
+ Standalone .env download is disabled for security. Download the pre-configured agent ZIP instead. +
+ {% endif %} + +
+
+ {% if can_manage_branch_storage %} +
+ +
+

One-click branch setup

+

Recommended. ERP will create or reuse the branch node and immediately download the pre-configured LSA2 Windows Service ZIP. Secret is not shown on screen. If a branch already has a storage root, the existing root will be reused and will not be silently changed.

+
+
+ + {% if forced_branch_id %} + +
+ {% set fb = branch_map.get(forced_branch_id) if branch_map else None %} + {{ fb.name if fb else ('Branch ID ' ~ forced_branch_id) }}{% if fb and fb.code %} ({{ fb.code }}){% endif %} +
+ {% else %} + + {% endif %} +
+

For a new branch setup only. Existing branch nodes keep their already configured root.

+
+ +
+ +
+ +
+

Manual node creation

+

Use only when you want a custom node code/name.

+
+
+
+
+ + {% if forced_branch_id %} + +
+ {% set fb = branch_map.get(forced_branch_id) if branch_map else None %} + {{ fb.name if fb else ('Branch ID ' ~ forced_branch_id) }}{% if fb and fb.code %} ({{ fb.code }}){% endif %} +
+ {% else %} + + {% endif %} +
+
+
+
+ +
+ {% else %} +
+ Storage setup package generation is available to Firm Admin, Partner and Branch Manager. This screen is monitoring-only for your current role. +
+ {% endif %} +
+ +
+
+
+
+

Configured storage nodes

+

Partners/Branch Managers see only their managed branch. One branch should have one active node and one fixed storage root. Existing node secrets are not shown again.

+
+
+ + + + {% for node in nodes %} + {% set b = branch_map.get(node.branch_id) if branch_map and node.branch_id else None %} + + + + + + + + + {% else %} + + {% endfor %} + +
NodeBranchStatusLast SeenRootAction
{{ node.node_name }}
{{ node.node_code }}
{{ b.name if b else (node.branch_id or 'Firm-level') }}{{ node.status }}{{ node.last_seen_at_utc or '-' }}{{ node.storage_root_path or '-' }} + {% if can_manage_branch_storage %} +
+ {% if node.status == 'disabled_duplicate' %} + Duplicate disabled + {% else %} +
+ + + +
+
+ + +
+ {% endif %} +
+ {% else %}-{% endif %} +
No storage nodes registered yet.
+
+ +
+
+

Recent Storage Jobs

+ + + + {% for job in recent_jobs or [] %} + + {% else %} + + {% endfor %} + +
JobStatusCreated
#{{ job.id }}{{ job.status }}{{ job.created_at_utc or '-' }}
No recent storage jobs.
+
+
+

Recent Download Requests

+ + + + {% for dr in recent_download_requests or [] %} + + {% else %} + + {% endfor %} + +
RequestStatusCreated
#{{ dr.id }}{{ dr.request_status }}{{ dr.created_at_utc or '-' }}
No recent download requests.
+
+
+
+
+
+{% endblock %} diff --git a/app/modules/documents/templates/documents/task_documents.html b/app/modules/documents/templates/documents/task_documents.html new file mode 100644 index 0000000..19dac0c --- /dev/null +++ b/app/modules/documents/templates/documents/task_documents.html @@ -0,0 +1,108 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Task Documents

+

+ {{ engagement.client.client_name if engagement.client else '-' }} · + {{ engagement.catalogue.service_name if engagement.catalogue else '-' }} · + {{ task.task_name }} · FY {{ engagement.financial_year }} +

+
+ +
+ + {% if request.query_params.get('uploaded') %}
Task document uploaded successfully.
{% endif %} + {% if request.query_params.get('error') %}
Upload failed. Please verify file, requirement and permission.
{% endif %} + +
+
Mandatory Pending
{{ requirement_status|selectattr('is_pending_mandatory')|list|length }}
+
Requirements
{{ requirements|length }}
+
Uploaded Documents
{{ documents|length }}
+
+ + {% if can_upload %} +
+

Upload Task Document

+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ {% endif %} + +
+

Requirement Status

+ + + + {% for item in requirement_status %} + {% set req = item.requirement %} + + + + + + + {% else %}{% endfor %} + +
RequirementTypeStatusDocuments
{{ req.document_name }}
{% if req.instructions %}
{{ req.instructions }}
{% endif %}
{{ req.document_type.replace('_',' ').title() }}{% if req.allowed_file_types %}
{{ req.allowed_file_types }}
{% endif %}
{% if item.is_uploaded %}Uploaded{% elif req.is_mandatory %}Pending Mandatory{% else %}Optional Pending{% endif %}{% for doc in item.documents %}
{{ doc.title }} · v{{ doc.current_version_no }}
{% else %}-{% endfor %}
No document requirements configured for this task template.
+
+ +
+

All Task Documents

+ + + + {% for doc in documents %} + {% set latest = doc.versions[0] if doc.versions else None %} + + {% else %}{% endfor %} + +
DocumentLatest VersionAction
{{ doc.title }}
{{ doc.document_type.replace('_',' ').title() }}{% if doc.document_requirement %} · {{ doc.document_requirement.document_name }}{% endif %}
{% if latest %}v{{ latest.version_no }} · {{ latest.original_filename }}{% else %}-{% endif %}{% if latest %}Download{% endif %}
No task documents uploaded yet.
+
+
+{% endblock %} diff --git a/app/modules/documents/ui.py b/app/modules/documents/ui.py new file mode 100644 index 0000000..fd54e8c --- /dev/null +++ b/app/modules/documents/ui.py @@ -0,0 +1,1466 @@ +from __future__ import annotations + +from datetime import datetime, timezone +import asyncio +import logging +from pathlib import Path +from urllib.parse import quote +import re + +from fastapi import APIRouter, File, Form, Header, Request, UploadFile, WebSocket, WebSocketDisconnect +from fastapi.responses import JSONResponse, RedirectResponse, StreamingResponse, Response +from sqlalchemy import select +from sqlalchemy.orm import joinedload + +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.core.rbac.deps import get_user_permissions, get_user_roles +from app.modules.core.rbac.permission_guard import require_permission +from app.modules.documents.services import ( + DOCUMENT_TYPES, + build_document_scope, + get_document, + get_download_request, + get_download_request_for_node, + get_latest_version, + get_version, + acknowledge_storage_job, + authenticate_storage_node, + create_branch_storage_node, + generate_storage_secret, + hash_storage_secret, + get_storage_job_for_node, + list_documents_for_engagement, + list_download_requests, + list_recent_download_requests_for_engagement, + list_visible_engagements, + list_pending_download_requests, + list_pending_storage_jobs, + list_storage_jobs, + list_storage_nodes, + log_document_access, + create_download_request_for_version, + download_request_cache_path, + fulfill_download_request_from_upload, + save_uploaded_revision, + user_can_delete_document, + user_can_upload_to_engagement, + user_can_view_engagement, + version_absolute_path, + PERMANENT_DOCUMENT_CATEGORIES, + acknowledge_permanent_storage_job, + create_permanent_download_request_for_version, + fulfill_permanent_download_request_from_upload, + get_latest_permanent_version, + get_permanent_document, + get_permanent_download_request_for_node, + get_permanent_storage_job_for_node, + get_permanent_version, + list_pending_permanent_download_requests, + list_pending_permanent_storage_jobs, + list_permanent_documents_for_client, + list_visible_clients_for_permanent_documents, + permanent_download_request_cache_path, + permanent_version_absolute_path, + save_uploaded_permanent_revision, + user_can_upload_client_documents, + user_can_view_client_documents, +) +from app.modules.services.models import ClientServiceSubscription +from app.modules.core.tenancy.models import Branch, Tenant +from app.modules.core.tenancy.year_control import is_row_financial_year_locked +from app.modules.documents.agent_package import build_agent_env, build_preconfigured_agent_zip +from app.modules.documents.models import BranchStorageNode + +from app.modules.services.task_documents import ( + get_task_with_subscription, + get_task_document_requirement, + list_documents_for_task, + list_task_document_requirements, + requirement_upload_status, + save_uploaded_task_document, +) + +router = APIRouter(prefix="/documents", tags=["documents-ui"]) +logger = logging.getLogger("audit_storage_agent.documents_ui") + + +def _base_ctx(request: Request, user, db, **ctx): + base = { + "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), + "document_types": DOCUMENT_TYPES, + "permanent_document_categories": PERMANENT_DOCUMENT_CATEGORIES, + } + base.update(ctx) + return base + + +def _render(request: Request, template: str, db, user, **ctx): + return templates.TemplateResponse(template, _base_ctx(request, user, db, **ctx)) + + +def _redirect_denied(): + return RedirectResponse(url="/system-settings", status_code=303) + + +def _require_user(request: Request, db, permission_code: str): + user = get_current_user(request, db=db) + if not user: + return None, RedirectResponse(url="/login", status_code=303) + try: + require_permission(db, user, permission_code) + except Exception: + return user, _redirect_denied() + return user, None + + + + +def _active_financial_year(request: Request) -> str | None: + value = request.session.get("active_financial_year") or getattr(request.state, "year_code", None) + value = (value or "").strip() + return value or None + +def _load_engagement(db, engagement_id: int): + return db.execute( + select(ClientServiceSubscription) + .options( + joinedload(ClientServiceSubscription.client), + joinedload(ClientServiceSubscription.catalogue), + joinedload(ClientServiceSubscription.assigned_partner), + joinedload(ClientServiceSubscription.assigned_staff), + ) + .where(ClientServiceSubscription.id == engagement_id) + ).unique().scalar_one_or_none() + + +@router.get("") +def documents_home(request: Request, q: str = "", financial_year: str = ""): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "documents.view") + if response: + return response + scope = build_document_scope(request, db, user) + selected_financial_year = (financial_year or _active_financial_year(request) or "").strip() + engagements = list_visible_engagements(db, user, scope, q=q, financial_year=selected_financial_year or None, limit=50) + return _render(request, "modules/documents/templates/documents/index.html", db, user, title="Engagement Documents", q=q, financial_year=selected_financial_year, engagements=engagements) + finally: + db.close() + + +@router.get("/engagements/{engagement_id}") +def engagement_documents(request: Request, engagement_id: int): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "documents.view") + if response: + return response + scope = build_document_scope(request, db, user) + engagement = _load_engagement(db, engagement_id) + active_fy = _active_financial_year(request) + if active_fy and engagement and engagement.financial_year != active_fy: + return RedirectResponse(url=f"/documents?financial_year={active_fy}", status_code=303) + if not engagement or not user_can_view_engagement(db, user, engagement, scope): + return _redirect_denied() + documents = list_documents_for_engagement(db, engagement_id) + download_requests = list_recent_download_requests_for_engagement(db, engagement_id, user_id=user.id, limit=10) + return _render(request, "modules/documents/templates/documents/engagement_documents.html", db, user, title="Engagement Documents", engagement=engagement, documents=documents, download_requests=download_requests, can_upload=user_can_upload_to_engagement(db, user, engagement, scope)) + finally: + db.close() + + +@router.post("/engagements/{engagement_id}/upload") +def upload_engagement_document( + request: Request, + engagement_id: int, + title: str = Form(""), + document_type: str = Form("GENERAL"), + description: str | None = Form(None), + remarks: str | None = Form(None), + existing_document_id: str | None = Form(None), + file: UploadFile = File(...), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "documents.upload") + if response: + return response + scope = build_document_scope(request, db, user) + engagement = _load_engagement(db, engagement_id) + active_fy = _active_financial_year(request) + if active_fy and engagement and engagement.financial_year != active_fy: + return RedirectResponse(url=f"/documents?financial_year={active_fy}", status_code=303) + if not engagement or not user_can_upload_to_engagement(db, user, engagement, scope): + return _redirect_denied() + if is_row_financial_year_locked(db, engagement): + return RedirectResponse(url=f"/documents/engagements/{engagement.id}?year_locked=1", status_code=303) + if not file or not file.filename: + return RedirectResponse(url=f"/documents/engagements/{engagement_id}?error=missing_file", status_code=303) + try: + doc = save_uploaded_revision( + db, + engagement=engagement, + upload_file=file, + title=title, + document_type=document_type, + description=description, + remarks=remarks, + user=user, + existing_document_id=int(existing_document_id) if existing_document_id else None, + ) + log_document_access(db, action="upload", result="success", user=user, request=request, document=doc) + db.commit() + except Exception as exc: + db.rollback() + logger.exception("Document upload failed for engagement_id=%s user_id=%s filename=%s", engagement_id, getattr(user, "id", None), getattr(file, "filename", None)) + try: + log_document_access(db, action="upload", result="failed", user=user, request=request, message=str(exc)[:1000]) + db.commit() + except Exception: + db.rollback() + logger.exception("Unable to write document upload failure audit log for engagement_id=%s", engagement_id) + return RedirectResponse(url=f"/documents/engagements/{engagement_id}?error=upload_failed", status_code=303) + return RedirectResponse(url=f"/documents/engagements/{engagement_id}?uploaded=1", status_code=303) + finally: + db.close() + + + +@router.get("/tasks/{task_id}") +def task_documents(request: Request, task_id: int): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "documents.view") + if response: + return response + scope = build_document_scope(request, db, user) + task = get_task_with_subscription(db, task_id) + engagement = task.subscription if task else None + active_fy = _active_financial_year(request) + if active_fy and engagement and engagement.financial_year != active_fy: + return RedirectResponse(url=f"/documents?financial_year={active_fy}", status_code=303) + if not task or not engagement or not user_can_view_engagement(db, user, engagement, scope): + return _redirect_denied() + requirements = [] + if task.firm_task_template_id: + requirements = list_task_document_requirements( + db, + tenant_id=task.tenant_id, + firm_task_template_id=task.firm_task_template_id, + ) + documents = list_documents_for_task(db, task.id) + requirement_status = requirement_upload_status(requirements, documents) + return _render( + request, + "modules/documents/templates/documents/task_documents.html", + db, + user, + title="Task Documents", + task=task, + engagement=engagement, + requirements=requirements, + requirement_status=requirement_status, + documents=documents, + can_upload=user_can_upload_to_engagement(db, user, engagement, scope), + ) + finally: + db.close() + + +@router.post("/tasks/{task_id}/upload") +def upload_task_document( + request: Request, + task_id: int, + requirement_id: str | None = Form(None), + title: str = Form(""), + document_type: str = Form("GENERAL"), + description: str | None = Form(None), + remarks: str | None = Form(None), + existing_document_id: str | None = Form(None), + file: UploadFile = File(...), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "documents.upload") + if response: + return response + scope = build_document_scope(request, db, user) + task = get_task_with_subscription(db, task_id) + engagement = task.subscription if task else None + active_fy = _active_financial_year(request) + if active_fy and engagement and engagement.financial_year != active_fy: + return RedirectResponse(url=f"/documents?financial_year={active_fy}", status_code=303) + if not task or not engagement or not user_can_upload_to_engagement(db, user, engagement, scope): + return _redirect_denied() + if is_row_financial_year_locked(db, engagement): + return RedirectResponse(url=f"/documents/engagements/{engagement.id}?year_locked=1", status_code=303) + if not file or not file.filename: + return RedirectResponse(url=f"/documents/tasks/{task_id}?error=missing_file", status_code=303) + requirement = None + if requirement_id and str(requirement_id).strip(): + requirement = get_task_document_requirement(db, requirement_id=int(requirement_id), tenant_id=task.tenant_id) + if not requirement or requirement.firm_task_template_id != task.firm_task_template_id: + return RedirectResponse(url=f"/documents/tasks/{task_id}?error=invalid_requirement", status_code=303) + try: + doc = save_uploaded_task_document( + db, + task=task, + requirement=requirement, + upload_file=file, + title=title, + document_type=document_type, + description=description, + remarks=remarks, + user=user, + existing_document_id=int(existing_document_id) if existing_document_id else None, + ) + log_document_access(db, action="task_upload", result="success", user=user, request=request, document=doc) + db.commit() + except Exception as exc: + db.rollback() + logger.exception("Task document upload failed for task_id=%s user_id=%s filename=%s", task_id, getattr(user, "id", None), getattr(file, "filename", None)) + try: + log_document_access(db, action="task_upload", result="failed", user=user, request=request, message=str(exc)[:1000]) + db.commit() + except Exception: + db.rollback() + return RedirectResponse(url=f"/documents/tasks/{task_id}?error=upload_failed", status_code=303) + return RedirectResponse(url=f"/documents/tasks/{task_id}?uploaded=1", status_code=303) + finally: + db.close() + +def _stream_file(path: Path, download_name: str, content_type: str | None = None): + def file_iterator(): + with path.open("rb") as fh: + while True: + chunk = fh.read(1024 * 1024) + if not chunk: + break + yield chunk + + quoted = quote(download_name) + headers = {"Content-Disposition": f"attachment; filename*=UTF-8''{quoted}"} + return StreamingResponse(file_iterator(), media_type=content_type or "application/octet-stream", headers=headers) + + +def _download_or_queue_from_local_node(request: Request, db, user, document, version, action: str): + """Stream immediately if ERP/staged copy exists, otherwise queue DS5 local retrieval.""" + path = version_absolute_path(version) + if path.exists(): + log_document_access(db, action=action, result="success", user=user, request=request, document=document, version=version, message="source=erp_stage") + db.commit() + return _stream_file(path, version.original_filename, version.content_type) + + download_request = create_download_request_for_version(db, version=version, user=user, request=request) + if download_request: + log_document_access(db, action=action, result="queued_local_retrieval", user=user, request=request, document=document, version=version, message=f"download_request_id={download_request.id}") + db.commit() + return RedirectResponse(url=f"/documents/engagements/{document.engagement_id}?download_queued={download_request.id}", status_code=303) + + log_document_access(db, action=action, result="missing_file", user=user, request=request, document=document, version=version, message="No ERP copy and no completed branch storage job found.") + db.commit() + return RedirectResponse(url=f"/documents/engagements/{document.engagement_id}?error=file_missing", status_code=303) + + +@router.get("/{document_id}/download") +def download_latest_document(request: Request, document_id: int): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "documents.download") + if response: + return response + scope = build_document_scope(request, db, user) + document = get_document(db, document_id) + active_fy = _active_financial_year(request) + if active_fy and document and getattr(document, "financial_year", None) != active_fy: + return RedirectResponse(url=f"/documents?financial_year={active_fy}&error=wrong_year", status_code=303) + if not document or not document.engagement or not user_can_view_engagement(db, user, document.engagement, scope): + return _redirect_denied() + version = get_latest_version(document) + if not version: + return RedirectResponse(url=f"/documents/engagements/{document.engagement_id}?error=no_version", status_code=303) + return _download_or_queue_from_local_node(request, db, user, document, version, action="download") + finally: + db.close() + + +@router.get("/versions/{version_id}/download") +def download_document_version(request: Request, version_id: int): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "documents.download") + if response: + return response + scope = build_document_scope(request, db, user) + version = get_version(db, version_id) + document = version.document if version else None + active_fy = _active_financial_year(request) + if active_fy and document and getattr(document, "financial_year", None) != active_fy: + return RedirectResponse(url=f"/documents?financial_year={active_fy}&error=wrong_year", status_code=303) + if not version or not document or not document.engagement or not user_can_view_engagement(db, user, document.engagement, scope): + return _redirect_denied() + return _download_or_queue_from_local_node(request, db, user, document, version, action="download_version") + finally: + db.close() + + +@router.get("/download-requests") +def download_requests(request: Request, status: str = ""): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "documents.download") + if response: + return response + scope = build_document_scope(request, db, user) + requests = list_download_requests(db, tenant_id=_storage_tenant_filter(user, scope), branch_id=_storage_branch_filter(user, scope), status=status or None) + return _render(request, "modules/documents/templates/documents/download_requests.html", db, user, title="Document Download Requests", requests=requests, status=status) + finally: + db.close() + + +@router.get("/download-requests/{request_id}/download") +def download_ready_request_file(request: Request, request_id: int): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "documents.download") + if response: + return response + scope = build_document_scope(request, db, user) + download_request = get_download_request(db, request_id) + if not download_request or download_request.request_status != "ready": + return RedirectResponse(url="/documents/download-requests?error=not_ready", status_code=303) + document = download_request.document + version = download_request.version + active_fy = _active_financial_year(request) + if active_fy and document and getattr(document, "financial_year", None) != active_fy: + return RedirectResponse(url=f"/documents?financial_year={active_fy}&error=wrong_year", status_code=303) + if not document or not version or not document.engagement or not user_can_view_engagement(db, user, document.engagement, scope): + return _redirect_denied() + path = download_request_cache_path(download_request) + if not path or not path.exists(): + log_document_access(db, action="download_cached_request", result="missing_cache", user=user, request=request, document=document, version=version, message=f"download_request_id={download_request.id}") + db.commit() + return RedirectResponse(url=f"/documents/engagements/{document.engagement_id}?error=file_missing", status_code=303) + log_document_access(db, action="download_cached_request", result="success", user=user, request=request, document=document, version=version, message=f"download_request_id={download_request.id}") + db.commit() + return _stream_file(path, version.original_filename, version.content_type) + finally: + db.close() + + + + + +@router.get("/permanent") +def permanent_documents_home(request: Request, q: str = ""): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "documents.view") + if response: + return response + scope = build_document_scope(request, db, user) + clients = list_visible_clients_for_permanent_documents(db, user, scope, q=q, limit=200) + return _render(request, "modules/documents/templates/documents/permanent_index.html", db, user, title="Permanent Client Documents", clients=clients, q=q) + finally: + db.close() + + +@router.get("/permanent/clients/{client_id}") +def permanent_client_documents(request: Request, client_id: int): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "documents.view") + if response: + return response + scope = build_document_scope(request, db, user) + from app.modules.clients.models import Client + client = db.get(Client, client_id) + if not client or not user_can_view_client_documents(db, user, client, scope): + return _redirect_denied() + documents = list_permanent_documents_for_client(db, client_id) + return _render(request, "modules/documents/templates/documents/permanent_client_documents.html", db, user, title="Permanent Client Documents", client=client, documents=documents, can_upload=user_can_upload_client_documents(db, user, client, scope)) + finally: + db.close() + + +@router.post("/permanent/clients/{client_id}/upload") +def upload_permanent_client_document( + request: Request, + client_id: int, + title: str = Form(""), + category: str = Form("Other Permanent Documents"), + description: str | None = Form(None), + remarks: str | None = Form(None), + existing_document_id: str | None = Form(None), + file: UploadFile = File(...), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "documents.upload") + if response: + return response + scope = build_document_scope(request, db, user) + from app.modules.clients.models import Client + client = db.get(Client, client_id) + if not client or not user_can_upload_client_documents(db, user, client, scope): + return _redirect_denied() + if not file or not file.filename: + return RedirectResponse(url=f"/documents/permanent/clients/{client_id}?error=missing_file", status_code=303) + try: + doc = save_uploaded_permanent_revision( + db, + client=client, + upload_file=file, + title=title, + category=category, + description=description, + remarks=remarks, + user=user, + existing_document_id=int(existing_document_id) if existing_document_id else None, + ) + log_document_access(db, action="permanent_upload", result="success", user=user, request=request, message=f"permanent_document_id={doc.id}") + db.commit() + except Exception as exc: + db.rollback() + logger.exception("Permanent document upload failed for client_id=%s user_id=%s filename=%s", client_id, getattr(user, "id", None), getattr(file, "filename", None)) + try: + log_document_access(db, action="permanent_upload", result="failed", user=user, request=request, message=str(exc)[:1000]) + db.commit() + except Exception: + db.rollback() + return RedirectResponse(url=f"/documents/permanent/clients/{client_id}?error=upload_failed", status_code=303) + return RedirectResponse(url=f"/documents/permanent/clients/{client_id}?uploaded=1", status_code=303) + finally: + db.close() + + +def _download_or_queue_permanent_from_local_node(request: Request, db, user, document, version): + path = permanent_version_absolute_path(version) + if path.exists(): + log_document_access(db, action="permanent_download", result="success", user=user, request=request, message=f"permanent_version_id={version.id};source=erp_stage") + db.commit() + return _stream_file(path, version.original_filename, version.content_type) + download_request = create_permanent_download_request_for_version(db, version=version, user=user, request=request) + if download_request: + if getattr(download_request, "request_status", None) == "ready": + cached_path = permanent_download_request_cache_path(download_request) + if cached_path and cached_path.exists(): + log_document_access(db, action="permanent_download_cached_request", result="success", user=user, request=request, message=f"permanent_download_request_id={download_request.id}") + db.commit() + return _stream_file(cached_path, version.original_filename, version.content_type) + log_document_access(db, action="permanent_download", result="queued_local_retrieval", user=user, request=request, message=f"permanent_download_request_id={download_request.id}") + db.commit() + return RedirectResponse(url=f"/documents/permanent/clients/{document.client_id}?download_queued={download_request.id}", status_code=303) + log_document_access(db, action="permanent_download", result="missing_file", user=user, request=request, message=f"permanent_version_id={version.id}") + db.commit() + return RedirectResponse(url=f"/documents/permanent/clients/{document.client_id}?error=file_missing", status_code=303) + + +@router.get("/permanent/{document_id}/download") +def download_latest_permanent_document(request: Request, document_id: int): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "documents.download") + if response: + return response + scope = build_document_scope(request, db, user) + document = get_permanent_document(db, document_id) + if not document or not document.client or not user_can_view_client_documents(db, user, document.client, scope): + return _redirect_denied() + version = get_latest_permanent_version(document) + if not version: + return RedirectResponse(url=f"/documents/permanent/clients/{document.client_id}?error=no_version", status_code=303) + return _download_or_queue_permanent_from_local_node(request, db, user, document, version) + finally: + db.close() + + +@router.get("/permanent/versions/{version_id}/download") +def download_permanent_version(request: Request, version_id: int): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "documents.download") + if response: + return response + scope = build_document_scope(request, db, user) + version = get_permanent_version(db, version_id) + document = version.document if version else None + if not version or not document or not document.client or not user_can_view_client_documents(db, user, document.client, scope): + return _redirect_denied() + return _download_or_queue_permanent_from_local_node(request, db, user, document, version) + finally: + db.close() + + +@router.post("/permanent/{document_id}/delete") +def delete_permanent_document(request: Request, document_id: int, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "documents.delete") + if response: + return response + scope = build_document_scope(request, db, user) + document = get_permanent_document(db, document_id) + if not document or not document.client or not user_can_upload_client_documents(db, user, document.client, scope): + return _redirect_denied() + document.is_deleted = True + document.status = "deleted" + document.deleted_at_utc = datetime.now(timezone.utc) + document.deleted_by_user_id = user.id + document.updated_by_user_id = user.id + log_document_access(db, action="permanent_delete", result="success", user=user, request=request, message=f"permanent_document_id={document.id}") + db.commit() + return RedirectResponse(url=f"/documents/permanent/clients/{document.client_id}?deleted=1", status_code=303) + finally: + db.close() + +def _visible_branches(db, user, scope): + stmt = select(Branch).where(Branch.is_active.is_(True)).order_by(Branch.tenant_id, Branch.name) + forced_branch_id = _storage_branch_filter(user, scope) + if forced_branch_id: + stmt = stmt.where(Branch.id == int(forced_branch_id)) + elif not scope.is_system_admin: + tenant_id = getattr(user, "tenant_id", None) or scope.tenant_id + if tenant_id: + stmt = stmt.where(Branch.tenant_id == int(tenant_id)) + return db.execute(stmt).scalars().all() + + +def _make_node_code(db, tenant_id: int, branch_id: int | None) -> str: + tenant = db.get(Tenant, tenant_id) + branch = db.get(Branch, branch_id) if branch_id else None + tenant_code = re.sub(r"[^A-Za-z0-9]+", "", (getattr(tenant, "code", None) or f"AF{tenant_id}"))[:12].upper() or f"AF{tenant_id}" + branch_code = re.sub(r"[^A-Za-z0-9]+", "", (getattr(branch, "code", None) or "FIRM"))[:12].upper() or "FIRM" + base = f"{tenant_code}-{branch_code}-STORAGE" + existing = { + row[0] + for row in db.execute(select(BranchStorageNode.node_code).where(BranchStorageNode.node_code.like(f"{base}%"))).all() + } + if base not in existing: + return base + for idx in range(2, 1000): + candidate = f"{base}-{idx:03d}" + if candidate not in existing: + return candidate + return f"{base}-{__import__('secrets').token_hex(3).upper()}" + + +def _default_node_name(db, tenant_id: int, branch_id: int | None) -> str: + branch = db.get(Branch, branch_id) if branch_id else None + if branch: + return f"{branch.name} Local Storage" + tenant = db.get(Tenant, tenant_id) + return f"{getattr(tenant, 'name', 'Audit Firm')} Local Storage" + + +def _agent_download_filename(node_code: str, suffix: str) -> str: + safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", node_code or "storage_node") + return f"AuditFirmStorageAgent_{safe}{suffix}" + + +def _find_existing_storage_node(db, tenant_id: int, branch_id: int | None) -> BranchStorageNode | None: + """Return the canonical storage node for an audit-firm/branch pair. + + Important business rule: + One Audit Firm + One Branch = One Storage Node Code forever. + + Earlier builds sometimes generated ARRR-01-STORAGE-002 / -003 for the + same branch. That breaks the local .audit_storage_node.json identity file + and can cause files to be stored through different agents. Therefore this + function deliberately reuses the oldest row for the same tenant_id + + branch_id, even if a newer duplicate is active. Package regeneration must + rotate only the secret, not the node code. + """ + stmt = select(BranchStorageNode).where(BranchStorageNode.tenant_id == int(tenant_id)) + if branch_id is None: + stmt = stmt.where(BranchStorageNode.branch_id.is_(None)) + else: + stmt = stmt.where(BranchStorageNode.branch_id == int(branch_id)) + stmt = stmt.order_by(BranchStorageNode.id.asc()) + return db.execute(stmt.limit(1)).scalar_one_or_none() + + +def _deactivate_duplicate_storage_nodes(db, node: BranchStorageNode) -> int: + """Keep exactly one active storage node for one audit-firm/branch. + + Business rule: one branch can have only one permissible local storage agent. + Any older duplicate nodes for the same tenant_id + branch_id are disabled so + an old downloaded package/service cannot continue connecting to ERP. + """ + stmt = select(BranchStorageNode).where( + BranchStorageNode.tenant_id == int(node.tenant_id), + BranchStorageNode.id != int(node.id), + BranchStorageNode.is_active.is_(True), + ) + if node.branch_id is None: + stmt = stmt.where(BranchStorageNode.branch_id.is_(None)) + else: + stmt = stmt.where(BranchStorageNode.branch_id == int(node.branch_id)) + duplicates = db.execute(stmt).scalars().all() + for dup in duplicates: + dup.is_active = False + dup.status = "disabled_duplicate" + return len(duplicates) + + +def _cleanup_visible_duplicate_storage_nodes(db, *, tenant_id: int | None, branch_id: int | None) -> None: + """Keep the original node code as canonical and disable duplicates. + + For every audit-firm/branch pair, the oldest node row is treated as the + permanent/canonical node. Newer duplicates are disabled, including rows that + were previously generated with suffixes like -002. This matches the local + storage identity file rule and prevents multiple branch agents for one + branch. + """ + stmt = select(BranchStorageNode) + if tenant_id: + stmt = stmt.where(BranchStorageNode.tenant_id == int(tenant_id)) + if branch_id: + stmt = stmt.where(BranchStorageNode.branch_id == int(branch_id)) + nodes = db.execute(stmt.order_by(BranchStorageNode.tenant_id, BranchStorageNode.branch_id, BranchStorageNode.id.asc())).scalars().all() + canonical_by_key: dict[tuple[int, int | None], BranchStorageNode] = {} + for node in nodes: + key = (int(node.tenant_id), int(node.branch_id) if node.branch_id is not None else None) + canonical = canonical_by_key.get(key) + if canonical is None: + canonical_by_key[key] = node + continue + if node.is_active or node.status == "active": + node.is_active = False + node.status = "disabled_duplicate" + + + +def _normalise_storage_root(value: str | None) -> str: + cleaned = (value or "").strip().strip('"').strip("'") + return cleaned or r"D:\AuditFirmStorage" + + +def _effective_storage_root(node: BranchStorageNode, requested_root: str | None = None, *, allow_change: bool = False) -> str: + """Return the one permissible storage root for a branch node. + + Existing configured root always wins. This prevents repeated package + generation or accidental reinstall from silently creating a second local + storage folder for the same branch. A future dedicated root-change flow can + pass allow_change=True after warning/confirmation. + """ + requested = _normalise_storage_root(requested_root) + existing = (node.storage_root_path or "").strip() + if existing and not allow_change: + return existing + return requested or existing or r"D:\AuditFirmStorage" + +def _update_node_secret_and_package(db, node: BranchStorageNode, *, storage_root_path: str | None, request: Request, include_admin_readme: bool = False, allow_storage_root_change: bool = False) -> Response: + raw_secret = generate_storage_secret() + node.secret_key_hash = hash_storage_secret(raw_secret) + effective_root = _effective_storage_root(node, storage_root_path, allow_change=allow_storage_root_change) + node.storage_root_path = effective_root + node.is_active = True + node.status = "active" + _deactivate_duplicate_storage_nodes(db, node) + db.flush() + env_text = build_agent_env( + erp_base_url=str(request.base_url).rstrip("/"), + node_code=node.node_code, + node_secret=raw_secret, + storage_root=effective_root, + tenant_id=node.tenant_id, + branch_id=node.branch_id, + ) + package = build_preconfigured_agent_zip(env_text=env_text, include_admin_readme=include_admin_readme) + filename = _agent_download_filename(node.node_code, ".zip") + return Response( + package, + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + + +def _is_partner_branch_storage_scope(scope) -> bool: + """Partner/Branch Manager storage is limited to their own active/user branch.""" + return (scope.is_partner or scope.is_branch_manager) and not scope.is_firm_admin and not scope.is_system_admin + + +def _storage_branch_filter(user, scope) -> int | None: + """Return branch id that should restrict storage-node screens for branch-managed roles.""" + if _is_partner_branch_storage_scope(scope): + return scope.branch_id or getattr(user, "branch_id", None) + return None + + +def _storage_tenant_filter(user, scope) -> int | None: + if scope.is_system_admin: + return None + return getattr(user, "tenant_id", None) or scope.tenant_id + + +def _can_manage_branch_storage(scope) -> bool: + # System Admin is monitoring/support-only for branch secrets/packages. + return bool(scope.is_firm_admin or scope.is_partner or scope.is_branch_manager) + + +def _branch_name_map(db, branches=None): + if branches is not None: + return {b.id: b for b in branches} + return {b.id: b for b in db.execute(select(Branch)).scalars().all()} + + +def _storage_scope_title(user, scope) -> str: + if scope.is_system_admin: + return "All audit firms — monitoring only" + if scope.is_firm_admin: + return "All branches of your audit firm" + if _is_partner_branch_storage_scope(scope): + branch_id = _storage_branch_filter(user, scope) + return f"Your managed branch only{f' (Branch ID {branch_id})' if branch_id else ''}" + return "Your permitted branch storage scope" + + +def _node_allowed_for_storage_scope(node: BranchStorageNode | None, user, scope) -> bool: + if not node: + return False + tenant_filter = _storage_tenant_filter(user, scope) + if tenant_filter and node.tenant_id != int(tenant_filter): + return False + branch_filter = _storage_branch_filter(user, scope) + if branch_filter and node.branch_id != int(branch_filter): + return False + return True + + +def _selected_or_forced_branch_id(branch_id: str | None, user, scope) -> int | None: + forced_branch_id = _storage_branch_filter(user, scope) + if forced_branch_id: + return int(forced_branch_id) + return int(branch_id) if branch_id and str(branch_id).isdigit() else None + +@router.get("/storage-nodes") +def storage_nodes(request: Request): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "documents.view") + if response: + return response + scope = build_document_scope(request, db, user) + tenant_filter = _storage_tenant_filter(user, scope) + branch_filter = _storage_branch_filter(user, scope) + _cleanup_visible_duplicate_storage_nodes(db, tenant_id=tenant_filter, branch_id=branch_filter) + db.commit() + nodes = list_storage_nodes(db, tenant_id=tenant_filter, branch_id=branch_filter) + branches = _visible_branches(db, user, scope) + jobs = list_storage_jobs(db, tenant_id=tenant_filter, branch_id=branch_filter, limit=10) + requests = list_download_requests(db, tenant_id=tenant_filter, branch_id=branch_filter, limit=10) + return _render( + request, + "modules/documents/templates/documents/storage_nodes.html", + db, + user, + title="Branch Storage Nodes", + nodes=nodes, + branches=branches, + branch_map=_branch_name_map(db, branches), + recent_jobs=jobs, + recent_download_requests=requests, + generated_secret=None, + storage_scope_title=_storage_scope_title(user, scope), + can_manage_branch_storage=_can_manage_branch_storage(scope), + forced_branch_id=branch_filter, + ) + finally: + db.close() + + +@router.get("/branch-storage-dashboard") +def partner_branch_storage_dashboard(request: Request): + """Partner/Branch dashboard card for local storage setup and status. + + It intentionally reuses the same storage node data model and keeps the full + Storage Nodes screen intact. Partner/Branch Manager users are automatically + scoped to their own branch. + """ + return storage_nodes(request) + + +@router.post("/storage-nodes") +def create_storage_node( + request: Request, + node_code: str = Form(...), + node_name: str = Form(...), + branch_id: str | None = Form(None), + connector_url: str | None = Form(None), + storage_root_path: str | None = Form(None), + quota_limit_gb: str | None = Form(None), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "documents.upload") + if response: + return response + scope = build_document_scope(request, db, user) + if not _can_manage_branch_storage(scope): + return _redirect_denied() + tenant_id = getattr(user, "tenant_id", None) or scope.tenant_id + branch_id_int = _selected_or_forced_branch_id(branch_id, user, scope) + if not tenant_id: + return _redirect_denied() + if branch_id_int: + branch = db.get(Branch, branch_id_int) + if not branch or (not scope.is_system_admin and branch.tenant_id != int(tenant_id)): + return _redirect_denied() + tenant_id = branch.tenant_id + qgb = int(quota_limit_gb) if quota_limit_gb and quota_limit_gb.isdigit() else None + existing = _find_existing_storage_node(db, int(tenant_id), branch_id_int) + if existing: + # Do not create ARRR-01-STORAGE-002 style duplicates. Reuse the + # canonical branch node and update safe metadata only. Node code is + # intentionally kept unchanged forever for the branch. + existing.node_name = existing.node_name or node_name or _default_node_name(db, int(tenant_id), branch_id_int) + existing.connector_url = (connector_url or "").strip() or existing.connector_url + existing.storage_root_path = existing.storage_root_path or _normalise_storage_root(storage_root_path) + existing.quota_limit_bytes = int(qgb) * 1024 * 1024 * 1024 if qgb else existing.quota_limit_bytes + existing.is_active = True + existing.status = "active" + node = existing + else: + node, _secret = create_branch_storage_node( + db, + tenant_id=int(tenant_id), + branch_id=branch_id_int, + node_code=node_code, + node_name=node_name, + connector_url=connector_url, + storage_root_path=storage_root_path, + quota_limit_gb=qgb, + user=user, + ) + _deactivate_duplicate_storage_nodes(db, node) + db.commit() + return RedirectResponse(url="/documents/storage-nodes?created=1", status_code=303) + except Exception: + db.rollback() + return RedirectResponse(url="/documents/storage-nodes?error=create_failed", status_code=303) + finally: + db.close() + + + + +@router.post("/storage-nodes/auto-setup") +def auto_setup_storage_node( + request: Request, + branch_id: str | None = Form(None), + storage_root_path: str | None = Form(r"D:\AuditFirmStorage"), + quota_limit_gb: str | None = Form(None), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "documents.upload") + if response: + return response + scope = build_document_scope(request, db, user) + if not _can_manage_branch_storage(scope): + return _redirect_denied() + tenant_id = getattr(user, "tenant_id", None) or scope.tenant_id + branch_id_int = _selected_or_forced_branch_id(branch_id, user, scope) + if _is_partner_branch_storage_scope(scope) and not branch_id_int: + return RedirectResponse(url="/documents/storage-nodes?error=branch_not_linked", status_code=303) + if not tenant_id: + return _redirect_denied() + if branch_id_int: + branch = db.get(Branch, branch_id_int) + if not branch or (not scope.is_system_admin and branch.tenant_id != int(tenant_id)): + return _redirect_denied() + tenant_id = branch.tenant_id + qgb = int(quota_limit_gb) if quota_limit_gb and str(quota_limit_gb).isdigit() else None + existing = _find_existing_storage_node(db, int(tenant_id), branch_id_int) + if existing: + existing.node_name = existing.node_name or _default_node_name(db, int(tenant_id), branch_id_int) + existing.storage_root_path = existing.storage_root_path or _normalise_storage_root(storage_root_path) + existing.quota_limit_bytes = int(qgb) * 1024 * 1024 * 1024 if qgb else existing.quota_limit_bytes + node = existing + else: + node_code = _make_node_code(db, int(tenant_id), branch_id_int) + node_name = _default_node_name(db, int(tenant_id), branch_id_int) + node, _unused_secret = create_branch_storage_node( + db, + tenant_id=int(tenant_id), + branch_id=branch_id_int, + node_code=node_code, + node_name=node_name, + connector_url=None, + storage_root_path=_normalise_storage_root(storage_root_path), + quota_limit_gb=qgb, + user=user, + ) + response = _update_node_secret_and_package( + db, + node, + storage_root_path=_effective_storage_root(node, storage_root_path), + request=request, + include_admin_readme=bool(scope.is_system_admin), + ) + db.commit() + return response + except Exception: + db.rollback() + return RedirectResponse(url="/documents/storage-nodes?error=auto_setup_failed", status_code=303) + finally: + db.close() + + +@router.post("/storage-nodes/download-env") +def download_storage_node_env(request: Request, csrf_token: str = Form(...)): + """Do not expose standalone .env download to normal UI. + + Branch packages are generated server-side and include the .env internally. + This keeps NODE_SECRET out of HTML source and avoids accidental sharing. + """ + validate_csrf(request, csrf_token) + return RedirectResponse(url="/documents/storage-nodes?error=env_download_disabled", status_code=303) + + +@router.post("/storage-nodes/download-agent-package") +def download_preconfigured_storage_agent( + request: Request, + node_code: str = Form(...), + storage_root_path: str = Form(r"D:\AuditFirmStorage"), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "documents.upload") + if response: + return response + scope = build_document_scope(request, db, user) + if not _can_manage_branch_storage(scope): + return _redirect_denied() + node = db.execute(select(BranchStorageNode).where(BranchStorageNode.node_code == node_code)).scalar_one_or_none() + if not _node_allowed_for_storage_scope(node, user, scope): + return _redirect_denied() + canonical = _find_existing_storage_node(db, int(node.tenant_id), int(node.branch_id) if node.branch_id is not None else None) + if canonical and canonical.id != node.id: + node = canonical + response = _update_node_secret_and_package( + db, + node, + storage_root_path=_effective_storage_root(node, storage_root_path), + request=request, + include_admin_readme=bool(scope.is_system_admin), + ) + db.commit() + return response + finally: + db.close() + + +@router.post("/storage-nodes/{node_id}/download-agent-package") +def download_storage_node_agent_by_id( + request: Request, + node_id: int, + storage_root_path: str = Form(r"D:\AuditFirmStorage"), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "documents.upload") + if response: + return response + scope = build_document_scope(request, db, user) + if not _can_manage_branch_storage(scope): + return _redirect_denied() + node = db.get(BranchStorageNode, node_id) + if not _node_allowed_for_storage_scope(node, user, scope): + return _redirect_denied() + canonical = _find_existing_storage_node(db, int(node.tenant_id), int(node.branch_id) if node.branch_id is not None else None) + if canonical and canonical.id != node.id: + node = canonical + response = _update_node_secret_and_package( + db, + node, + storage_root_path=_effective_storage_root(node, storage_root_path), + request=request, + include_admin_readme=bool(scope.is_system_admin), + ) + db.commit() + return response + finally: + db.close() + +@router.post("/storage-nodes/{node_id}/toggle") +def toggle_storage_node(request: Request, node_id: int, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "documents.upload") + if response: + return response + scope = build_document_scope(request, db, user) + if not _can_manage_branch_storage(scope): + return _redirect_denied() + from app.modules.documents.models import BranchStorageNode + node = db.get(BranchStorageNode, node_id) + if not _node_allowed_for_storage_scope(node, user, scope): + return _redirect_denied() + canonical = _find_existing_storage_node(db, int(node.tenant_id), int(node.branch_id) if node.branch_id is not None else None) + if canonical and canonical.id != node.id: + # Duplicate rows must never be re-enabled. Enable/disable only the + # canonical branch node to preserve one agent per branch. + node.is_active = False + node.status = "disabled_duplicate" + node = canonical + node.is_active = not bool(node.is_active) + node.status = "active" if node.is_active else "disabled" + if node.is_active: + _deactivate_duplicate_storage_nodes(db, node) + db.commit() + return RedirectResponse(url="/documents/storage-nodes", status_code=303) + finally: + db.close() + + +@router.get("/storage-jobs") +def storage_jobs(request: Request, status: str = ""): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "documents.view") + if response: + return response + scope = build_document_scope(request, db, user) + jobs = list_storage_jobs(db, tenant_id=_storage_tenant_filter(user, scope), branch_id=_storage_branch_filter(user, scope), status=status or None) + return _render(request, "modules/documents/templates/documents/storage_jobs.html", db, user, title="Document Storage Jobs", jobs=jobs, status=status) + finally: + db.close() + + +def _agent_auth(db, request: Request, x_node_code: str | None, x_node_secret: str | None): + node = authenticate_storage_node(db, x_node_code, x_node_secret, request=request) + if not node: + return None, JSONResponse({"ok": False, "error": "invalid_storage_node_credentials"}, status_code=401) + return node, None + + +@router.post("/storage-agent/heartbeat") +async def storage_agent_heartbeat(request: Request, x_node_code: str | None = Header(None), x_node_secret: str | None = Header(None)): + db = CommonSessionLocal() + try: + node, error = _agent_auth(db, request, x_node_code, x_node_secret) + if error: + return error + db.commit() + return {"ok": True, "node_code": node.node_code, "status": node.status} + finally: + db.close() + + + +def _normal_storage_jobs_payload(jobs): + return [ + { + "job_id": job.id, + "job_kind": "engagement", + "version_id": job.version_id, + "document_id": job.document_id, + "target_relative_path": job.target_relative_path, + "local_relative_path": job.target_relative_path, + "file_size_bytes": job.file_size_bytes, + "expected_hash_sha256": job.expected_hash_sha256, + "download_url": f"/documents/storage-agent/jobs/{job.id}/download", + } + for job in jobs + ] + + +def _permanent_storage_jobs_payload(jobs): + return [ + { + "job_id": f"P{job.id}", + "job_kind": "permanent", + "version_id": job.version_id, + "document_id": job.document_id, + "target_relative_path": job.target_relative_path, + "local_relative_path": job.target_relative_path, + "file_size_bytes": job.file_size_bytes, + "expected_hash_sha256": job.expected_hash_sha256, + "download_url": f"/documents/storage-agent/jobs/P{job.id}/download", + } + for job in jobs + ] + + +def _normal_download_requests_payload(items): + return [ + { + "request_id": item.id, + "request_kind": "engagement", + "version_id": item.version_id, + "document_id": item.document_id, + "local_relative_path": item.local_relative_path, + "expected_hash_sha256": item.expected_hash_sha256, + "file_hash": item.expected_hash_sha256, + "file_size_bytes": item.file_size_bytes, + "upload_url": f"/documents/storage-agent/download-requests/{item.id}/upload", + } + for item in items + ] + + +def _permanent_download_requests_payload(items): + return [ + { + "request_id": f"P{item.id}", + "request_kind": "permanent", + "version_id": item.version_id, + "document_id": item.document_id, + "local_relative_path": item.local_relative_path, + "expected_hash_sha256": item.expected_hash_sha256, + "file_hash": item.expected_hash_sha256, + "file_size_bytes": item.file_size_bytes, + "upload_url": f"/documents/storage-agent/download-requests/P{item.id}/upload", + } + for item in items + ] + + +def _storage_agent_sync_payload(db, node): + jobs = _normal_storage_jobs_payload(list_pending_storage_jobs(db, node)) + jobs += _permanent_storage_jobs_payload(list_pending_permanent_storage_jobs(db, node)) + requests_ = _normal_download_requests_payload(list_pending_download_requests(db, node)) + requests_ += _permanent_download_requests_payload(list_pending_permanent_download_requests(db, node)) + return {"ok": True, "jobs": jobs, "download_requests": requests_, "requests": requests_} + + +@router.get("/storage-agent/jobs/pending") +def storage_agent_pending_jobs(request: Request, x_node_code: str | None = Header(None), x_node_secret: str | None = Header(None)): + db = CommonSessionLocal() + try: + node, error = _agent_auth(db, request, x_node_code, x_node_secret) + if error: + return error + payload = _storage_agent_sync_payload(db, node) + db.commit() + return {"ok": True, "jobs": payload["jobs"]} + finally: + db.close() + + +@router.get("/storage-agent/jobs/{job_id}/download") +def storage_agent_download_job(request: Request, job_id: str, x_node_code: str | None = Header(None), x_node_secret: str | None = Header(None)): + db = CommonSessionLocal() + try: + node, error = _agent_auth(db, request, x_node_code, x_node_secret) + if error: + return error + raw_job_id = str(job_id) + is_permanent = raw_job_id.upper().startswith("P") + actual_job_id = int(raw_job_id[1:] if is_permanent else raw_job_id) + if is_permanent: + job = get_permanent_storage_job_for_node(db, node, actual_job_id) + path = permanent_version_absolute_path(job.version) if job and job.version else None + else: + job = get_storage_job_for_node(db, node, actual_job_id) + path = version_absolute_path(job.version) if job and job.version else None + if not job or job.status not in {"pending", "retry"}: + return JSONResponse({"ok": False, "error": "job_not_available"}, status_code=404) + if not path or not path.exists(): + job.status = "failed" + job.last_error = "Staged file missing on ERP server." + db.commit() + return JSONResponse({"ok": False, "error": "staged_file_missing"}, status_code=404) + job.status = "picked" + job.picked_at_utc = datetime.now(timezone.utc) + db.commit() + return _stream_file(path, Path(job.target_relative_path).name, job.version.content_type if job.version else None) + finally: + db.close() + + +@router.post("/storage-agent/jobs/{job_id}/ack") +async def storage_agent_ack_job(request: Request, job_id: str, x_node_code: str | None = Header(None), x_node_secret: str | None = Header(None)): + db = CommonSessionLocal() + try: + node, error = _agent_auth(db, request, x_node_code, x_node_secret) + if error: + return error + payload = await request.json() + raw_job_id = str(job_id) + is_permanent = raw_job_id.upper().startswith("P") + actual_job_id = int(raw_job_id[1:] if is_permanent else raw_job_id) + acknowledged_hash = (payload.get("sha256") or payload.get("sha256_hash") or payload.get("file_hash") or "").strip() + local_final_path = (payload.get("local_final_path") or payload.get("local_relative_path") or "").strip() or None + if is_permanent: + job = get_permanent_storage_job_for_node(db, node, actual_job_id) + if not job: + return JSONResponse({"ok": False, "error": "job_not_found"}, status_code=404) + ok = acknowledge_permanent_storage_job(db, node=node, job=job, acknowledged_hash=acknowledged_hash, local_final_path=local_final_path, success=bool(payload.get("success", True)), error=payload.get("error")) + else: + job = get_storage_job_for_node(db, node, actual_job_id) + if not job: + return JSONResponse({"ok": False, "error": "job_not_found"}, status_code=404) + ok = acknowledge_storage_job(db, node=node, job=job, acknowledged_hash=acknowledged_hash, local_final_path=local_final_path, success=bool(payload.get("success", True)), error=payload.get("error")) + db.commit() + return {"ok": ok, "job_status": job.status} + finally: + db.close() + + +@router.get("/storage-agent/download-requests/pending") +def storage_agent_pending_download_requests(request: Request, x_node_code: str | None = Header(None), x_node_secret: str | None = Header(None)): + db = CommonSessionLocal() + try: + node, error = _agent_auth(db, request, x_node_code, x_node_secret) + if error: + return error + payload = _storage_agent_sync_payload(db, node) + db.commit() + return {"ok": True, "download_requests": payload["download_requests"], "requests": payload["requests"]} + finally: + db.close() + + +@router.websocket("/storage-agent/tunnel") +async def storage_agent_tunnel(websocket: WebSocket): + """Outbound local-agent tunnel. + + The branch PC opens this WebSocket connection to ERP. ERP never opens an + inbound connection to the branch PC. File movement still uses the existing + authenticated HTTP endpoints; this tunnel is the always-on control channel + that pushes pending job/request notifications to the agent. + """ + node_code = websocket.query_params.get("node_code") or websocket.headers.get("x-node-code") + node_secret = websocket.query_params.get("node_secret") or websocket.headers.get("x-node-secret") + await websocket.accept() + + db = CommonSessionLocal() + try: + node = authenticate_storage_node(db, node_code, node_secret, request=None) + if not node: + await websocket.send_json({"ok": False, "type": "error", "error": "invalid_storage_node_credentials"}) + await websocket.close(code=1008) + return + try: + node.storage_mode = "tunnel" + except Exception: + pass + db.commit() + await websocket.send_json({"ok": True, "type": "connected", "node_code": node.node_code, "storage_mode": getattr(node, "storage_mode", "tunnel")}) + finally: + db.close() + + last_push = 0.0 + while True: + try: + try: + message = await asyncio.wait_for(websocket.receive_json(), timeout=5.0) + except asyncio.TimeoutError: + message = None + + db = CommonSessionLocal() + try: + node = authenticate_storage_node(db, node_code, node_secret, request=None) + if not node: + await websocket.send_json({"ok": False, "type": "error", "error": "node_deactivated_or_invalid"}) + await websocket.close(code=1008) + return + if message and message.get("type") in {"heartbeat", "agent_status"}: + try: + node.storage_mode = "tunnel" + except Exception: + pass + now = datetime.now(timezone.utc).timestamp() + force = bool(message and message.get("type") in {"ready", "sync_now", "agent_status"}) + if force or now - last_push >= 5: + payload = _storage_agent_sync_payload(db, node) + payload.update({"type": "sync", "node_code": node.node_code}) + await websocket.send_json(payload) + last_push = now + db.commit() + finally: + db.close() + except WebSocketDisconnect: + break + except Exception as exc: + logger.exception("Storage agent tunnel failed for node=%s: %s", node_code, exc) + try: + await websocket.send_json({"ok": False, "type": "error", "error": "tunnel_server_error"}) + except Exception: + pass + await asyncio.sleep(5) + + +@router.post("/storage-agent/download-requests/{request_id}/upload") +def storage_agent_upload_download_request( + request: Request, + request_id: str, + file: UploadFile = File(...), + x_node_code: str | None = Header(None), + x_node_secret: str | None = Header(None), +): + db = CommonSessionLocal() + try: + node, error = _agent_auth(db, request, x_node_code, x_node_secret) + if error: + return error + raw_request_id = str(request_id) + is_permanent = raw_request_id.upper().startswith("P") + actual_request_id = int(raw_request_id[1:] if is_permanent else raw_request_id) + if is_permanent: + download_request = get_permanent_download_request_for_node(db, node, actual_request_id) + if not download_request or download_request.request_status not in {"pending", "picked", "retry"}: + return JSONResponse({"ok": False, "error": "download_request_not_available"}, status_code=404) + download_request.request_status = "picked" + ok = fulfill_permanent_download_request_from_upload(db, node=node, download_request=download_request, upload_file=file) + else: + download_request = get_download_request_for_node(db, node, actual_request_id) + if not download_request or download_request.request_status not in {"pending", "picked", "retry"}: + return JSONResponse({"ok": False, "error": "download_request_not_available"}, status_code=404) + download_request.request_status = "picked" + ok = fulfill_download_request_from_upload(db, node=node, download_request=download_request, upload_file=file) + db.commit() + return {"ok": ok, "request_status": download_request.request_status} + finally: + db.close() + + +@router.post("/{document_id}/delete") +def delete_document(request: Request, document_id: int, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "documents.delete") + if response: + return response + scope = build_document_scope(request, db, user) + document = get_document(db, document_id) + if not document or not user_can_delete_document(db, user, document, scope): + return _redirect_denied() + document.is_deleted = True + document.status = "deleted" + document.deleted_at_utc = datetime.now(timezone.utc) + document.deleted_by_user_id = user.id + document.updated_by_user_id = user.id + log_document_access(db, action="delete", result="success", user=user, request=request, document=document) + db.commit() + return RedirectResponse(url=f"/documents/engagements/{document.engagement_id}?deleted=1", status_code=303) + finally: + db.close() diff --git a/app/modules/domain_management/__init__.py b/app/modules/domain_management/__init__.py new file mode 100644 index 0000000..be00c8e --- /dev/null +++ b/app/modules/domain_management/__init__.py @@ -0,0 +1 @@ +"""Domain mapping module for multi-domain SaaS routing.""" diff --git a/app/modules/domain_management/models.py b/app/modules/domain_management/models.py new file mode 100644 index 0000000..b83f231 --- /dev/null +++ b/app/modules/domain_management/models.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.db.common import CommonBase + + +class DomainMapping(CommonBase): + """Maps an incoming domain/host to marketplace, tenant, branch, or consultant context. + + Phase 7T.1 only stores and manages the mappings. Runtime domain resolution is added + in Phase 7T.2, so this table is intentionally safe and independent. + """ + + __tablename__ = "domain_mappings" + __table_args__ = ( + UniqueConstraint("domain_name", name="uq_domain_mappings_domain_name"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + domain_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + domain_type: Mapped[str] = mapped_column(String(60), nullable=False, index=True) + + tenant_id: Mapped[int | None] = mapped_column(ForeignKey("tenants.id", ondelete="SET NULL"), nullable=True, index=True) + branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True) + consultant_id: Mapped[int | None] = mapped_column(ForeignKey("consultant_profiles.id", ondelete="SET NULL"), nullable=True, index=True) + parent_tenant_id: Mapped[int | None] = mapped_column(ForeignKey("tenants.id", ondelete="SET NULL"), nullable=True, index=True) + + is_primary: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True) + is_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True) + status: Mapped[str] = mapped_column(String(30), nullable=False, default="draft", index=True) + + verification_token: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True) + dns_txt_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + dns_txt_value: Mapped[str | None] = mapped_column(String(255), nullable=True) + ssl_mode: Mapped[str] = mapped_column(String(30), nullable=False, default="manual") + ssl_status: Mapped[str] = mapped_column(String(30), nullable=False, default="not_checked", index=True) + ssl_provider: Mapped[str | None] = mapped_column(String(60), nullable=True) + ssl_last_checked_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + ssl_not_after_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + ssl_issuer: Mapped[str | None] = mapped_column(String(255), nullable=True) + ssl_subject: Mapped[str | None] = mapped_column(String(255), nullable=True) + ssl_last_error: Mapped[str | None] = mapped_column(Text, nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) + updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False) + + tenant = relationship("Tenant", foreign_keys=[tenant_id]) + branch = relationship("Branch", foreign_keys=[branch_id]) + consultant = relationship("ConsultantProfile", foreign_keys=[consultant_id]) + parent_tenant = relationship("Tenant", foreign_keys=[parent_tenant_id]) diff --git a/app/modules/domain_management/services.py b/app/modules/domain_management/services.py new file mode 100644 index 0000000..9a89f62 --- /dev/null +++ b/app/modules/domain_management/services.py @@ -0,0 +1,880 @@ +from __future__ import annotations + +import re +import secrets +import socket +import ssl +import subprocess +from dataclasses import dataclass +from datetime import datetime, timezone +from email.utils import parsedate_to_datetime + +from sqlalchemy import Select, func, select +from sqlalchemy.orm import Session + +from app.modules.consultants.models import ConsultantProfile +from app.modules.core.tenancy.models import Branch, Tenant +from app.modules.domain_management.models import DomainMapping + +DOMAIN_TYPES: tuple[tuple[str, str], ...] = ( + ("marketplace", "Marketplace / SaaS platform"), + ("audit_firm_domain", "Audit firm custom domain"), + ("audit_firm_subdomain", "Audit firm platform subdomain"), + ("consultant_custom_domain", "Consultant custom domain"), + ("consultant_firm_domain", "Consultant under firm domain"), + ("consultant_marketplace_subdomain", "Consultant platform subdomain"), +) + +PLATFORM_SUBDOMAIN_BASE_DOMAINS: tuple[str, ...] = ("filingabc.com", "filingabc.local") +RESERVED_SUBDOMAIN_LABELS: set[str] = {"www", "mail", "smtp", "imap", "pop", "api", "admin", "app", "portal", "client", "clients", "staff", "system", "marketplace", "support", "billing"} + +DOMAIN_STATUSES: tuple[tuple[str, str], ...] = ( + ("draft", "Draft"), + ("pending_verification", "Pending verification"), + ("active", "Active"), + ("suspended", "Suspended"), + ("inactive", "Inactive"), +) + +SSL_MODES: tuple[tuple[str, str], ...] = ( + ("manual", "Manual / deployment managed"), + ("coolify", "Coolify proxy"), + ("caddy", "Caddy"), + ("traefik", "Traefik"), + ("cloudflare", "Cloudflare"), +) + +SSL_STATUSES: tuple[tuple[str, str], ...] = ( + ("not_checked", "Not checked"), + ("pending_dns", "Pending DNS/proxy"), + ("pending_ssl", "Pending SSL"), + ("active", "Active"), + ("failed", "Failed"), + ("manual", "Manual / external"), +) + +_HOST_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$", re.IGNORECASE) + + +@dataclass(frozen=True) +class DomainSslCheckResult: + ok: bool + status: str + message: str + subject: str | None = None + issuer: str | None = None + not_after_utc: datetime | None = None + + +@dataclass(frozen=True) +class DomainSslProxySnippet: + provider: str + title: str + body: str + + +@dataclass(frozen=True) +class DomainMappingPayload: + domain_name: str + domain_type: str + tenant_id: int | None = None + branch_id: int | None = None + consultant_id: int | None = None + parent_tenant_id: int | None = None + is_primary: bool = False + is_verified: bool = False + status: str = "draft" + ssl_mode: str = "manual" + notes: str | None = None + + +def normalize_domain(domain_name: str) -> str: + value = (domain_name or "").strip().lower() + value = value.removeprefix("http://").removeprefix("https://") + value = value.split("/", 1)[0] + value = value.split(":", 1)[0] + return value.rstrip(".") + + +def validate_domain_payload(db: Session, payload: DomainMappingPayload, mapping_id: int | None = None) -> list[str]: + errors: list[str] = [] + domain_name = normalize_domain(payload.domain_name) + + if not domain_name: + errors.append("Domain name is required.") + elif not _HOST_RE.match(domain_name) and domain_name not in {"localhost"}: + errors.append("Enter a valid domain name, for example filingabc.com or arrr.filingabc.com.") + + if payload.domain_type not in {code for code, _label in DOMAIN_TYPES}: + errors.append("Invalid domain type selected.") + + if payload.status not in {code for code, _label in DOMAIN_STATUSES}: + errors.append("Invalid status selected.") + + if payload.ssl_mode not in {code for code, _label in SSL_MODES}: + errors.append("Invalid SSL mode selected.") + + existing_stmt = select(DomainMapping).where(DomainMapping.domain_name == domain_name) + if mapping_id: + existing_stmt = existing_stmt.where(DomainMapping.id != mapping_id) + if db.execute(existing_stmt).scalar_one_or_none(): + errors.append("This domain is already mapped.") + + if payload.tenant_id and not db.get(Tenant, payload.tenant_id): + errors.append("Selected audit firm was not found.") + if payload.parent_tenant_id and not db.get(Tenant, payload.parent_tenant_id): + errors.append("Selected parent audit firm was not found.") + if payload.branch_id and not db.get(Branch, payload.branch_id): + errors.append("Selected branch was not found.") + if payload.consultant_id and not db.get(ConsultantProfile, payload.consultant_id): + errors.append("Selected consultant was not found.") + + if payload.domain_type in {"audit_firm_domain", "audit_firm_subdomain"} and not payload.tenant_id: + errors.append("Audit firm domains must be linked to an audit firm.") + if payload.domain_type in {"consultant_custom_domain", "consultant_firm_domain", "consultant_marketplace_subdomain"} and not payload.consultant_id: + errors.append("Consultant domains must be linked to a consultant profile.") + if payload.domain_type == "consultant_firm_domain" and not payload.parent_tenant_id: + errors.append("Consultant firm-domain mappings must have a parent audit firm.") + + if payload.domain_type == "audit_firm_subdomain" and domain_name: + parts = domain_name.split(".") + if len(parts) < 3: + errors.append("Audit firm subdomain must be like auditfirm.filingabc.com.") + subdomain_label = parts[0] if parts else "" + if subdomain_label in RESERVED_SUBDOMAIN_LABELS: + errors.append(f"'{subdomain_label}' is a reserved subdomain label. Please use another audit firm code.") + if subdomain_label and not re.match(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$", subdomain_label): + errors.append("Subdomain label may contain only lowercase letters, numbers and hyphen, and cannot start/end with hyphen.") + + return errors + + +def make_verification_token() -> str: + return "af-domain-" + secrets.token_urlsafe(24).replace("-", "").replace("_", "")[:36] + + +def dns_txt_name_for(domain_name: str) -> str: + return f"_audit-firm-verify.{normalize_domain(domain_name)}" + + +def list_domain_mappings(db: Session, *, q: str = "", status: str = "", domain_type: str = "") -> list[DomainMapping]: + stmt: Select[tuple[DomainMapping]] = select(DomainMapping).order_by(DomainMapping.created_at_utc.desc(), DomainMapping.id.desc()) + q = (q or "").strip().lower() + if q: + like = f"%{q}%" + stmt = stmt.where(func.lower(DomainMapping.domain_name).like(like)) + if status: + stmt = stmt.where(DomainMapping.status == status) + if domain_type: + stmt = stmt.where(DomainMapping.domain_type == domain_type) + return list(db.execute(stmt).scalars().all()) + + +def create_domain_mapping(db: Session, payload: DomainMappingPayload, *, user_id: int | None) -> DomainMapping: + domain_name = normalize_domain(payload.domain_name) + token = make_verification_token() + mapping = DomainMapping( + domain_name=domain_name, + domain_type=payload.domain_type, + tenant_id=payload.tenant_id, + branch_id=payload.branch_id, + consultant_id=payload.consultant_id, + parent_tenant_id=payload.parent_tenant_id, + is_primary=payload.is_primary, + is_verified=payload.is_verified, + status="active" if payload.is_verified and payload.status == "active" else payload.status, + verification_token=token, + dns_txt_name=dns_txt_name_for(domain_name), + dns_txt_value=token, + ssl_mode=payload.ssl_mode or "manual", + notes=(payload.notes or "").strip() or None, + created_by_user_id=user_id, + updated_by_user_id=user_id, + ) + db.add(mapping) + db.commit() + db.refresh(mapping) + return mapping + + +def update_domain_mapping(db: Session, mapping: DomainMapping, payload: DomainMappingPayload, *, user_id: int | None) -> DomainMapping: + old_domain = mapping.domain_name + mapping.domain_name = normalize_domain(payload.domain_name) + mapping.domain_type = payload.domain_type + mapping.tenant_id = payload.tenant_id + mapping.branch_id = payload.branch_id + mapping.consultant_id = payload.consultant_id + mapping.parent_tenant_id = payload.parent_tenant_id + mapping.is_primary = payload.is_primary + mapping.is_verified = payload.is_verified + mapping.status = payload.status + mapping.ssl_mode = payload.ssl_mode or "manual" + mapping.notes = (payload.notes or "").strip() or None + mapping.updated_by_user_id = user_id + mapping.updated_at_utc = datetime.now(timezone.utc) + if old_domain != mapping.domain_name or not mapping.verification_token: + mapping.verification_token = make_verification_token() + mapping.dns_txt_name = dns_txt_name_for(mapping.domain_name) + mapping.dns_txt_value = mapping.verification_token + mapping.is_verified = False + if mapping.status == "active": + mapping.status = "pending_verification" + db.commit() + db.refresh(mapping) + return mapping + + +def regenerate_verification_token(db: Session, mapping: DomainMapping, *, user_id: int | None) -> DomainMapping: + mapping.verification_token = make_verification_token() + mapping.dns_txt_name = dns_txt_name_for(mapping.domain_name) + mapping.dns_txt_value = mapping.verification_token + mapping.is_verified = False + if mapping.status == "active": + mapping.status = "pending_verification" + mapping.updated_by_user_id = user_id + mapping.updated_at_utc = datetime.now(timezone.utc) + db.commit() + db.refresh(mapping) + return mapping + + +def mark_verified(db: Session, mapping: DomainMapping, *, user_id: int | None) -> DomainMapping: + mapping.is_verified = True + mapping.status = "active" + mapping.updated_by_user_id = user_id + mapping.updated_at_utc = datetime.now(timezone.utc) + db.commit() + db.refresh(mapping) + return mapping + + + +def slugify_subdomain_label(value: str) -> str: + """Create a safe subdomain label from a tenant code/name.""" + raw = (value or "").strip().lower() + raw = re.sub(r"[^a-z0-9-]+", "-", raw) + raw = re.sub(r"-+", "-", raw).strip("-") + if not raw: + raw = "firm" + if raw in RESERVED_SUBDOMAIN_LABELS: + raw = f"{raw}-firm" + return raw[:63].strip("-") or "firm" + + +def tenant_subdomain_label(tenant: Tenant) -> str: + code = getattr(tenant, "code", None) or getattr(tenant, "name", None) or f"firm-{tenant.id}" + return slugify_subdomain_label(str(code)) + + +def build_tenant_subdomain(tenant: Tenant, base_domain: str = "filingabc.com") -> str: + base = normalize_domain(base_domain or "filingabc.com") + return f"{tenant_subdomain_label(tenant)}.{base}" + + +def list_tenant_subdomain_candidates(db: Session, *, base_domain: str = "filingabc.com") -> list[dict]: + """Return all tenants with their suggested platform subdomain and existing mapping, if any.""" + base = normalize_domain(base_domain or "filingabc.com") + tenants = db.execute(select(Tenant).order_by(Tenant.name.asc())).scalars().all() + out: list[dict] = [] + for tenant in tenants: + domain_name = build_tenant_subdomain(tenant, base) + mapping = db.execute(select(DomainMapping).where(DomainMapping.domain_name == domain_name)).scalar_one_or_none() + out.append({"tenant": tenant, "domain_name": domain_name, "mapping": mapping}) + return out + + +def create_or_get_tenant_subdomain_mapping( + db: Session, + *, + tenant_id: int, + base_domain: str = "filingabc.com", + branch_id: int | None = None, + mark_verified_active: bool = True, + user_id: int | None = None, +) -> tuple[DomainMapping, bool]: + """Create auditfirm.filingabc.com mapping for a tenant without duplicating existing rows. + + Returns (mapping, created). The platform owner normally verifies wildcard DNS/SSL once, + so mark_verified_active=True is safe for the owned *.filingabc.com style domain. + """ + tenant = db.get(Tenant, int(tenant_id)) + if not tenant: + raise ValueError("Selected audit firm was not found.") + domain_name = build_tenant_subdomain(tenant, base_domain) + existing = db.execute(select(DomainMapping).where(DomainMapping.domain_name == domain_name)).scalar_one_or_none() + if existing: + return existing, False + + payload = DomainMappingPayload( + domain_name=domain_name, + domain_type="audit_firm_subdomain", + tenant_id=tenant.id, + branch_id=branch_id, + is_primary=False, + is_verified=bool(mark_verified_active), + status="active" if mark_verified_active else "pending_verification", + ssl_mode="coolify", + notes="Auto-created platform tenant subdomain. Ensure wildcard DNS/SSL for *.filingabc.com is configured in deployment.", + ) + errors = validate_domain_payload(db, payload) + if errors: + raise ValueError(" ".join(errors)) + mapping = create_domain_mapping(db, payload, user_id=user_id) + return mapping, True + + + +def create_or_get_firm_custom_domain_mapping( + db: Session, + *, + tenant_id: int, + domain_name: str = "arrr.associates", + branch_id: int | None = None, + mark_verified_active: bool = False, + user_id: int | None = None, +) -> tuple[DomainMapping, bool]: + """Create an audit-firm custom domain mapping such as arrr.associates. + + This is intentionally separate from tenant subdomains. Custom domains normally + need DNS verification before activation, so mark_verified_active defaults to + False. For a domain owned by the platform owner and already configured in DNS, + an admin may tick mark_verified_active to make it active immediately. + + Returns (mapping, created). + """ + tenant = db.get(Tenant, int(tenant_id)) + if not tenant: + raise ValueError("Selected audit firm was not found.") + + normalized_domain = normalize_domain(domain_name or "arrr.associates") + existing = db.execute(select(DomainMapping).where(DomainMapping.domain_name == normalized_domain)).scalar_one_or_none() + if existing: + return existing, False + + payload = DomainMappingPayload( + domain_name=normalized_domain, + domain_type="audit_firm_domain", + tenant_id=tenant.id, + branch_id=branch_id, + is_primary=True, + is_verified=bool(mark_verified_active), + status="active" if mark_verified_active else "pending_verification", + ssl_mode="coolify", + notes=( + "Custom audit firm domain created from Phase 7T.6. " + "Ensure DNS points to the ERP server and SSL/proxy configuration is completed before live use." + ), + ) + errors = validate_domain_payload(db, payload) + if errors: + raise ValueError(" ".join(errors)) + mapping = create_domain_mapping(db, payload, user_id=user_id) + return mapping, True + + +def consultant_subdomain_label(consultant: ConsultantProfile) -> str: + """Create a safe domain label for consultant profile domains.""" + raw = (getattr(consultant, "firm_name", None) or getattr(consultant, "contact_person", None) or f"consultant-{consultant.id}") + return slugify_subdomain_label(str(raw)) + + +def build_consultant_platform_subdomain(consultant: ConsultantProfile, base_domain: str = "filingabc.com") -> str: + base = normalize_domain(base_domain or "filingabc.com") + return f"{consultant_subdomain_label(consultant)}.{base}" + + +def build_consultant_firm_domain(consultant: ConsultantProfile, firm_base_domain: str = "arrr.accountant") -> str: + base = normalize_domain(firm_base_domain or "arrr.accountant") + return f"{consultant_subdomain_label(consultant)}.{base}" + + +def list_consultant_domain_candidates( + db: Session, + *, + base_domain: str = "filingabc.com", + firm_base_domain: str = "arrr.accountant", +) -> list[dict]: + """Return consultants with suggested marketplace and firm-linked profile domains.""" + consultants = db.execute(select(ConsultantProfile).order_by(ConsultantProfile.contact_person.asc())).scalars().all() + out: list[dict] = [] + for consultant in consultants: + platform_domain = build_consultant_platform_subdomain(consultant, base_domain) + firm_domain = build_consultant_firm_domain(consultant, firm_base_domain) + platform_mapping = db.execute(select(DomainMapping).where(DomainMapping.domain_name == platform_domain)).scalar_one_or_none() + firm_mapping = db.execute(select(DomainMapping).where(DomainMapping.domain_name == firm_domain)).scalar_one_or_none() + out.append({ + "consultant": consultant, + "platform_domain": platform_domain, + "platform_mapping": platform_mapping, + "firm_domain": firm_domain, + "firm_mapping": firm_mapping, + }) + return out + + +def create_or_get_consultant_domain_mapping( + db: Session, + *, + consultant_id: int, + domain_type: str = "consultant_marketplace_subdomain", + domain_name: str | None = None, + base_domain: str = "filingabc.com", + firm_base_domain: str = "arrr.accountant", + parent_tenant_id: int | None = None, + mark_verified_active: bool = False, + user_id: int | None = None, +) -> tuple[DomainMapping, bool]: + """Create consultant profile domain mapping without duplicating existing rows. + + Supported examples: + consultant_marketplace_subdomain -> consultant.filingabc.com + consultant_firm_domain -> consultant.arrr.accountant + consultant_custom_domain -> abc.accountants + """ + consultant = db.get(ConsultantProfile, int(consultant_id)) + if not consultant: + raise ValueError("Selected consultant profile was not found.") + + if domain_type == "consultant_marketplace_subdomain": + normalized_domain = normalize_domain(domain_name or build_consultant_platform_subdomain(consultant, base_domain)) + resolved_parent_tenant_id = parent_tenant_id + ssl_mode = "coolify" + default_notes = "Auto-created consultant marketplace profile subdomain. Ensure wildcard DNS/SSL for the platform base domain is configured." + default_verified = True + elif domain_type == "consultant_firm_domain": + normalized_domain = normalize_domain(domain_name or build_consultant_firm_domain(consultant, firm_base_domain)) + resolved_parent_tenant_id = parent_tenant_id or consultant.tenant_id + ssl_mode = "coolify" + default_notes = "Consultant domain under an audit firm brand. Ensure DNS/SSL for the firm consultant domain is configured." + default_verified = False + elif domain_type == "consultant_custom_domain": + if not domain_name: + raise ValueError("Custom consultant domain is required.") + normalized_domain = normalize_domain(domain_name) + resolved_parent_tenant_id = parent_tenant_id + ssl_mode = "coolify" + default_notes = "Consultant custom domain. Verify DNS ownership before activation." + default_verified = False + else: + raise ValueError("Invalid consultant domain type selected.") + + existing = db.execute(select(DomainMapping).where(DomainMapping.domain_name == normalized_domain)).scalar_one_or_none() + if existing: + return existing, False + + verified = bool(mark_verified_active or default_verified) + payload = DomainMappingPayload( + domain_name=normalized_domain, + domain_type=domain_type, + tenant_id=consultant.tenant_id, + branch_id=consultant.branch_id, + consultant_id=consultant.id, + parent_tenant_id=resolved_parent_tenant_id, + is_primary=False, + is_verified=verified, + status="active" if verified else "pending_verification", + ssl_mode=ssl_mode, + notes=default_notes, + ) + errors = validate_domain_payload(db, payload) + if errors: + raise ValueError(" ".join(errors)) + mapping = create_domain_mapping(db, payload, user_id=user_id) + return mapping, True + +def reference_data(db: Session) -> dict: + return { + "tenants": db.execute(select(Tenant).order_by(Tenant.name.asc())).scalars().all(), + "branches": db.execute(select(Branch).order_by(Branch.name.asc())).scalars().all(), + "consultants": db.execute(select(ConsultantProfile).order_by(ConsultantProfile.contact_person.asc())).scalars().all(), + "domain_types": DOMAIN_TYPES, + "statuses": DOMAIN_STATUSES, + "ssl_modes": SSL_MODES, + "ssl_statuses": SSL_STATUSES, + } + + +@dataclass(frozen=True) +class DomainResolution: + """Resolved runtime domain context used by Phase 7T.2 middleware.""" + + is_resolved: bool + host: str + mapping_id: int | None = None + domain_name: str | None = None + domain_type: str | None = None + tenant_id: int | None = None + tenant_code: str | None = None + branch_id: int | None = None + branch_code: str | None = None + consultant_id: int | None = None + parent_tenant_id: int | None = None + is_verified: bool = False + status: str | None = None + + +def normalize_request_host(host_header: str | None) -> str: + """Normalise the HTTP Host / X-Forwarded-Host value for lookup. + + Handles values such as: + localhost:8000 + arrr.associates + arrr.associates:443 + arrr.associates, proxy-host + """ + value = (host_header or "").strip().lower() + if not value: + return "" + value = value.split(",", 1)[0].strip() + value = value.removeprefix("http://").removeprefix("https://") + value = value.split("/", 1)[0] + if value.startswith("[") and "]" in value: + # IPv6 literal; keep without brackets/port for safety. + value = value.split("]", 1)[0].lstrip("[") + else: + value = value.split(":", 1)[0] + return value.rstrip(".") + + +def resolve_domain_context(db: Session, host: str) -> DomainResolution: + """Resolve an incoming host to an active domain mapping. + + Phase 7T.2 intentionally resolves only exact domain mappings. Wildcard/platform + subdomain inference comes later in Phase 7T.5, and custom-domain DNS checks come + in Phase 7T.8. + """ + normalized = normalize_domain(host) + if not normalized: + return DomainResolution(is_resolved=False, host="") + + stmt = ( + select(DomainMapping, Tenant.code, Branch.code) + .outerjoin(Tenant, Tenant.id == DomainMapping.tenant_id) + .outerjoin(Branch, Branch.id == DomainMapping.branch_id) + .where(DomainMapping.domain_name == normalized) + .where(DomainMapping.status == "active") + .where(DomainMapping.is_verified.is_(True)) + .limit(1) + ) + row = db.execute(stmt).first() + if not row: + return DomainResolution(is_resolved=False, host=normalized) + + mapping, tenant_code, branch_code = row + return DomainResolution( + is_resolved=True, + host=normalized, + mapping_id=int(mapping.id), + domain_name=mapping.domain_name, + domain_type=mapping.domain_type, + tenant_id=mapping.tenant_id, + tenant_code=tenant_code, + branch_id=mapping.branch_id, + branch_code=branch_code, + consultant_id=mapping.consultant_id, + parent_tenant_id=mapping.parent_tenant_id, + is_verified=bool(mapping.is_verified), + status=mapping.status, + ) + + + +@dataclass(frozen=True) +class DomainDnsVerificationResult: + ok: bool + message: str + txt_name: str + expected_value: str + found_values: tuple[str, ...] = () + + +def _normalise_txt_value(value: str) -> str: + value = (value or "").strip() + # TXT records may be returned with quotes and split chunks. + if value.startswith('"') and value.endswith('"') and len(value) >= 2: + value = value[1:-1] + value = value.replace('" "', '').replace('"', '').strip() + return value + + +def _resolve_txt_with_dnspython(txt_name: str) -> tuple[list[str], str | None]: + try: + import dns.resolver # type: ignore + except Exception: + return [], "dnspython is not installed" + try: + answers = dns.resolver.resolve(txt_name, "TXT") + values: list[str] = [] + for answer in answers: + try: + chunks = [part.decode("utf-8", errors="ignore") if isinstance(part, bytes) else str(part) for part in answer.strings] + values.append("".join(chunks)) + except Exception: + values.append(str(answer).strip()) + return values, None + except Exception as exc: + return [], str(exc) + + +def _resolve_txt_with_nslookup(txt_name: str) -> tuple[list[str], str | None]: + try: + completed = subprocess.run( + ["nslookup", "-type=TXT", txt_name], + capture_output=True, + text=True, + timeout=12, + check=False, + ) + except FileNotFoundError: + return [], "nslookup command is not available on this machine" + except Exception as exc: + return [], str(exc) + + output = "\n".join([completed.stdout or "", completed.stderr or ""]) + if completed.returncode != 0 and not output.strip(): + return [], "DNS lookup failed" + + values: list[str] = [] + for line in output.splitlines(): + line = line.strip() + if not line: + continue + # Windows/Linux nslookup generally prints TXT values inside quotes. + quoted = re.findall(r'"([^"]+)"', line) + if quoted: + values.append("".join(quoted)) + continue + if "text =" in line.lower(): + values.append(line.split("=", 1)[1].strip()) + if not values and output.strip(): + # Keep a short diagnostic without storing full command noise. + return [], output.strip().splitlines()[-1][:240] + return values, None + + +def lookup_dns_txt_values(txt_name: str) -> tuple[list[str], str | None]: + """Return TXT records for a DNS name using dnspython when available, else nslookup. + + No new dependency is required. On Windows, nslookup is normally available by default. + """ + txt_name = normalize_domain(txt_name) + if not txt_name: + return [], "TXT name is empty" + + values, error = _resolve_txt_with_dnspython(txt_name) + if values: + return [_normalise_txt_value(v) for v in values], None + + ns_values, ns_error = _resolve_txt_with_nslookup(txt_name) + if ns_values: + return [_normalise_txt_value(v) for v in ns_values], None + + return [], ns_error or error or "No TXT record found" + + +def verify_domain_dns_txt(db: Session, mapping: DomainMapping, *, user_id: int | None) -> DomainDnsVerificationResult: + """Verify a custom/platform domain using its DNS TXT token. + + If the expected TXT value is found, the domain is marked verified and active. If not, + the mapping remains unverified and is moved to pending_verification unless suspended. + """ + txt_name = mapping.dns_txt_name or dns_txt_name_for(mapping.domain_name) + expected = mapping.dns_txt_value or mapping.verification_token or "" + if not expected: + mapping.verification_token = make_verification_token() + mapping.dns_txt_name = txt_name + mapping.dns_txt_value = mapping.verification_token + expected = mapping.dns_txt_value + db.commit() + db.refresh(mapping) + + found_values, error = lookup_dns_txt_values(txt_name) + found_normalised = tuple(_normalise_txt_value(v) for v in found_values if _normalise_txt_value(v)) + expected_normalised = _normalise_txt_value(expected) + matched = any(v == expected_normalised or expected_normalised in v for v in found_normalised) + + now = datetime.now(timezone.utc) + if matched: + mapping.is_verified = True + mapping.status = "active" + mapping.updated_by_user_id = user_id + mapping.updated_at_utc = now + db.commit() + db.refresh(mapping) + return DomainDnsVerificationResult( + ok=True, + message="DNS TXT verification successful. Domain is now active.", + txt_name=txt_name, + expected_value=expected_normalised, + found_values=found_normalised, + ) + + if mapping.status not in {"suspended", "inactive"}: + mapping.status = "pending_verification" + mapping.is_verified = False + mapping.updated_by_user_id = user_id + mapping.updated_at_utc = now + db.commit() + db.refresh(mapping) + msg = "DNS TXT record not found or value does not match." + if error: + msg = f"{msg} DNS response: {error}" + return DomainDnsVerificationResult( + ok=False, + message=msg, + txt_name=txt_name, + expected_value=expected_normalised, + found_values=found_normalised, + ) + + +def list_domains_requiring_verification(db: Session) -> list[DomainMapping]: + stmt = ( + select(DomainMapping) + .where(DomainMapping.is_verified.is_(False)) + .where(DomainMapping.status.in_(["draft", "pending_verification"])) + .order_by(DomainMapping.updated_at_utc.desc(), DomainMapping.id.desc()) + ) + return list(db.execute(stmt).scalars().all()) + + +def verify_all_pending_domains(db: Session, *, user_id: int | None, limit: int = 25) -> list[tuple[DomainMapping, DomainDnsVerificationResult]]: + results: list[tuple[DomainMapping, DomainDnsVerificationResult]] = [] + for mapping in list_domains_requiring_verification(db)[: max(1, min(limit, 100))]: + result = verify_domain_dns_txt(db, mapping, user_id=user_id) + results.append((mapping, result)) + return results + + + +def _name_tuple_to_text(name_tuple) -> str | None: + try: + parts: list[str] = [] + for section in name_tuple or []: + for key, value in section: + if value: + parts.append(f"{key}={value}") + return ", ".join(parts) if parts else None + except Exception: + return None + + +def _parse_ssl_not_after(value: str | None) -> datetime | None: + if not value: + return None + try: + dt = parsedate_to_datetime(value) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + except Exception: + return None + + +def check_domain_ssl_certificate(db: Session, mapping: DomainMapping, *, user_id: int | None = None, timeout: float = 5.0) -> DomainSslCheckResult: + """Check whether the domain currently serves a valid TLS certificate on 443. + + This does not issue certificates. Certificate issuance is handled by Coolify/Caddy/ + Traefik/Cloudflare/Certbot. This function records readiness/status inside ERP. + """ + domain = normalize_domain(mapping.domain_name) + now = datetime.now(timezone.utc) + if not domain or domain == "localhost": + mapping.ssl_status = "manual" + mapping.ssl_last_checked_at_utc = now + mapping.ssl_last_error = "Localhost does not require public SSL certificate checking." + db.commit() + return DomainSslCheckResult(False, "manual", mapping.ssl_last_error) + + if not mapping.is_verified or mapping.status != "active": + mapping.ssl_status = "pending_dns" + mapping.ssl_last_checked_at_utc = now + mapping.ssl_last_error = "Domain must be verified and active before SSL check." + db.commit() + return DomainSslCheckResult(False, "pending_dns", mapping.ssl_last_error) + + try: + context = ssl.create_default_context() + with socket.create_connection((domain, 443), timeout=timeout) as sock: + with context.wrap_socket(sock, server_hostname=domain) as ssock: + cert = ssock.getpeercert() + subject = _name_tuple_to_text(cert.get("subject")) + issuer = _name_tuple_to_text(cert.get("issuer")) + not_after = _parse_ssl_not_after(cert.get("notAfter")) + mapping.ssl_status = "active" + mapping.ssl_last_checked_at_utc = now + mapping.ssl_not_after_utc = not_after + mapping.ssl_subject = subject + mapping.ssl_issuer = issuer + mapping.ssl_last_error = None + db.commit() + db.refresh(mapping) + return DomainSslCheckResult(True, "active", "SSL certificate is active and trusted.", subject, issuer, not_after) + except Exception as exc: + mapping.ssl_status = "failed" + mapping.ssl_last_checked_at_utc = now + mapping.ssl_last_error = str(exc)[:1000] + db.commit() + db.refresh(mapping) + return DomainSslCheckResult(False, "failed", f"SSL check failed: {mapping.ssl_last_error}") + + +def mark_ssl_managed(db: Session, mapping: DomainMapping, *, provider: str, user_id: int | None = None) -> DomainMapping: + provider = (provider or mapping.ssl_mode or "manual").strip().lower() + valid = {code for code, _label in SSL_MODES} + if provider not in valid: + provider = "manual" + mapping.ssl_provider = provider + mapping.ssl_mode = provider + mapping.ssl_status = "manual" if provider in {"manual", "cloudflare"} else "pending_ssl" + mapping.updated_by_user_id = user_id + mapping.updated_at_utc = datetime.now(timezone.utc) + db.commit() + db.refresh(mapping) + return mapping + + +def list_ssl_domains(db: Session) -> list[DomainMapping]: + stmt = ( + select(DomainMapping) + .where(DomainMapping.status.in_(["active", "pending_verification", "draft"])) + .order_by(DomainMapping.domain_type.asc(), DomainMapping.domain_name.asc()) + ) + return list(db.execute(stmt).scalars().all()) + + +def build_ssl_proxy_snippets(mapping: DomainMapping, *, app_upstream: str = "http://127.0.0.1:8000") -> list[DomainSslProxySnippet]: + domain = normalize_domain(mapping.domain_name) + if not domain or domain == "localhost": + return [] + caddy = f"""{domain} {{ + encode gzip + reverse_proxy {app_upstream} +}}""" + nginx = f"""server {{ + listen 80; + server_name {domain}; + + location / {{ + proxy_pass {app_upstream}; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + }} +}} + +# After DNS points to this server: +# certbot --nginx -d {domain}""" + coolify = f"""Coolify setup checklist for {domain} +1. Open the Audit ERP application in Coolify. +2. Add domain: https://{domain} +3. Ensure DNS A/CNAME points to the Coolify server. +4. Enable Force HTTPS after certificate is issued. +5. Keep Host header forwarding enabled. +6. In ERP, verify DNS TXT and then run SSL check.""" + return [ + DomainSslProxySnippet("coolify", "Coolify domain setup", coolify), + DomainSslProxySnippet("caddy", "Caddy automatic HTTPS snippet", caddy), + DomainSslProxySnippet("nginx", "Nginx + Certbot snippet", nginx), + ] diff --git a/app/modules/domain_management/templates/domain_management/consultant_domains.html b/app/modules/domain_management/templates/domain_management/consultant_domains.html new file mode 100644 index 0000000..3c42d3e --- /dev/null +++ b/app/modules/domain_management/templates/domain_management/consultant_domains.html @@ -0,0 +1,167 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Consultant Profile Domains

+

Create domains for consultant public profile, login and lead capture pages. Examples: abc.accountants, consultant.filingabc.com, or yourname.arrr.accountant.

+
+ +
+ + {% if errors %} +
+
    + {% for err in errors %}
  • {{ err }}
  • {% endfor %} +
+
+ {% endif %} + {% if message %} +
{{ message }}
+ {% endif %} + +
+
+

Create Consultant Domain

+

Use platform subdomains for quick setup. Use custom domains only after the consultant points DNS to your ERP server.

+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +

Used for consultant.filingabc.com.

+
+
+ + +

Used for yourname.arrr.accountant.

+
+
+ +
+ + +

Leave blank to auto-generate based on the selected consultant and base domain. Enter a value for custom domains.

+
+ +
+
+ + +
+
+
Activation rule
+

Platform subdomains can be active immediately if wildcard DNS/SSL is managed by you. Custom domains should normally remain pending until DNS verification is done.

+
+
+ + + +
+ +
+
+
+ +
+
+

Recommended DNS

+
+
Platform: *.filingabc.com → ERP server IP/proxy
+
Firm brand: *.arrr.accountant → ERP server IP/proxy
+
Custom: abc.accountants → ERP server IP/proxy
+
SSL: Coolify / proxy / Cloudflare will be configured later in 7T.9.
+
+
+ +
+

Suggested domains

+

The suggestions below are generated from consultant firm name or contact person.

+
+
+
+ +
+
+

Consultant Domain Suggestions

+
+ {% if candidates %} + + + + + + + + + + + {% for item in candidates %} + + + + + + + {% endfor %} + +
ConsultantPlatform DomainFirm DomainStatus
+
{{ item.consultant.firm_name or item.consultant.contact_person }}
+
{{ item.consultant.email or 'No email configured' }}
+
+
{{ item.platform_domain }}
+ {% if item.platform_mapping %}Open mapping{% endif %} +
+
{{ item.firm_domain }}
+ {% if item.firm_mapping %}Open mapping{% endif %} +
+ {% if item.platform_mapping or item.firm_mapping %} + Mapped + {% else %} + Not mapped + {% endif %} +
+ {% else %} +
No consultants found.
+ {% endif %} +
+
+{% endblock %} diff --git a/app/modules/domain_management/templates/domain_management/detail.html b/app/modules/domain_management/templates/domain_management/detail.html new file mode 100644 index 0000000..d46d457 --- /dev/null +++ b/app/modules/domain_management/templates/domain_management/detail.html @@ -0,0 +1,71 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

{{ mapping.domain_name }}

+

Domain mapping details and DNS verification token.

+
+ +
+ +
+
+

Mapping

+
+
Type
{{ mapping.domain_type.replace('_', ' ')|title }}
+
Status
{{ mapping.status.replace('_', ' ')|title }}
+
Audit Firm
{{ mapping.tenant.display_name or mapping.tenant.name if mapping.tenant else '-' }}
+
Branch
{{ mapping.branch.name if mapping.branch else '-' }}
+
Consultant
{{ mapping.consultant.contact_person if mapping.consultant else '-' }}
+
Parent Firm
{{ mapping.parent_tenant.display_name or mapping.parent_tenant.name if mapping.parent_tenant else '-' }}
+
Primary
{{ 'Yes' if mapping.is_primary else 'No' }}
+
SSL Mode
{{ mapping.ssl_mode }}
+
SSL Status
{{ (mapping.ssl_status or "not_checked").replace("_", " ")|title }}
+
+ {% if mapping.notes %}
{{ mapping.notes }}
{% endif %} +
+ +
+

DNS Verification

+ {% if dns_result is defined and dns_result %} +
+
{{ 'Verification successful' if dns_result.ok else 'Verification failed' }}
+
{{ dns_result.message }}
+ {% if dns_result.found_values %} +
Found TXT: {{ dns_result.found_values|join(', ') }}
+ {% endif %} +
+ {% endif %} + + {% if mapping.is_verified %} +
This domain is marked as verified.
+ {% else %} +
Add the TXT record below at your DNS provider. Click Verify DNS after adding the TXT record at your DNS provider.
+ {% endif %} +
+
TXT Name
{{ mapping.dns_txt_name or '-' }}
+
TXT Value
{{ mapping.dns_txt_value or '-' }}
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+{% endblock %} diff --git a/app/modules/domain_management/templates/domain_management/firm_domain.html b/app/modules/domain_management/templates/domain_management/firm_domain.html new file mode 100644 index 0000000..96f395c --- /dev/null +++ b/app/modules/domain_management/templates/domain_management/firm_domain.html @@ -0,0 +1,98 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Audit Firm Custom Domain

+

Create a verified mapping for your own audit firm domain, for example arrr.associates.

+
+ +
+ + {% if errors %} +
+
    + {% for err in errors %}
  • {{ err }}
  • {% endfor %} +
+
+ {% endif %} + {% if message %} +
{{ message }}
+ {% endif %} + +
+
+

Create / Link Firm Domain

+

Use this for your own branded firm domain. For other tenant firms, use auditfirm.filingabc.com subdomains first.

+ +
+ +
+ + +

Enter the domain only. Do not include http://, https:// or path.

+
+ +
+
+ + +
+
+ + +
+
+ + + +
+ +
+
+
+ +
+
+

Recommended DNS

+
+
A record: arrr.associates → your server IP
+
CNAME: www.arrr.associates → arrr.associates
+
SSL: configure in Coolify / proxy / Cloudflare
+
+
+ +
+

Verification

+

If not marked active now, open the created domain record and use the DNS TXT token shown there.

+ {% if existing %} +
+
Existing mapping found
+
{{ existing.domain_name }} — {{ existing.status.replace('_', ' ')|title }}
+ Open mapping +
+ {% endif %} +
+
+
+
+{% endblock %} diff --git a/app/modules/domain_management/templates/domain_management/form.html b/app/modules/domain_management/templates/domain_management/form.html new file mode 100644 index 0000000..404aecc --- /dev/null +++ b/app/modules/domain_management/templates/domain_management/form.html @@ -0,0 +1,104 @@ +{% extends "ui/templates/base/layout.html" %} +{% set f = form if form else {} %} +{% block content %} +
+
+

{{ title }}

+

Create or update a domain mapping. Runtime resolver will use this table in Phase 7T.2.

+
+ + {% if errors %} +
+
Please fix the following:
+
    {% for e in errors %}
  • {{ e }}
  • {% endfor %}
+
+ {% endif %} + +
+ +
+ + + + + + + + + + + + + + + + +
+ {% set primary_checked = f.is_primary if f.is_primary is defined else (mapping.is_primary if mapping else False) %} + {% set verified_checked = f.is_verified if f.is_verified is defined else (mapping.is_verified if mapping else False) %} + + +
+ + +
+ +
+ + Cancel +
+
+
+{% endblock %} diff --git a/app/modules/domain_management/templates/domain_management/list.html b/app/modules/domain_management/templates/domain_management/list.html new file mode 100644 index 0000000..6cd9618 --- /dev/null +++ b/app/modules/domain_management/templates/domain_management/list.html @@ -0,0 +1,82 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Domain Mapping

+

Map marketplace, audit firm, branch and consultant domains. Tenant subdomains like auditfirm.filingabc.com, firm domains like arrr.associates, and consultant profile domains like abc.accountants or yourname.arrr.accountant can be created from here.

+
+ +
+ +
+
+ + + + +
+
+ +
+ {% if mappings %} + + + + + + + + + + + + + {% for m in mappings %} + + + + + + + + + {% endfor %} + +
DomainTypeMapped ToStatusVerificationAction
+
{{ m.domain_name }}
+
SSL: {{ m.ssl_mode }} / {{ (m.ssl_status or "not_checked").replace("_", " ")|title }}
+
{{ m.domain_type.replace('_', ' ')|title }} + {% if m.tenant %}
Firm: {{ m.tenant.display_name or m.tenant.name }}
{% endif %} + {% if m.branch %}
Branch: {{ m.branch.name }}
{% endif %} + {% if m.consultant %}
Consultant: {{ m.consultant.contact_person }}
{% endif %} + {% if not m.tenant and not m.branch and not m.consultant %}Marketplace / platform{% endif %} +
+ {{ m.status.replace('_', ' ')|title }} + {% if m.is_primary %}Primary{% endif %} + + {% if m.is_verified %} + Verified + {% else %} + Pending + {% endif %} +
+ {% else %} +
No domain mappings found.
+ {% endif %} +
+
+{% endblock %} diff --git a/app/modules/domain_management/templates/domain_management/ssl.html b/app/modules/domain_management/templates/domain_management/ssl.html new file mode 100644 index 0000000..37cb528 --- /dev/null +++ b/app/modules/domain_management/templates/domain_management/ssl.html @@ -0,0 +1,82 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

SSL Automation

+

Track SSL readiness for marketplace, audit firm and consultant domains. Certificate issuance is handled by your deployment proxy such as Coolify, Caddy, Traefik, Cloudflare or Nginx + Certbot.

+
+
+
+ + +
+ DNS Verification + Domains +
+
+ + {% if ssl_results is defined and ssl_results %} +
+

Latest SSL check

+
+ {% for mapping, result in ssl_results %} +
+ {{ mapping.domain_name }}: {{ result.message }} +
+ {% endfor %} +
+
+ {% endif %} + +
+
+
Verified active domains
+
{{ domains|selectattr('is_verified')|selectattr('status', 'equalto', 'active')|list|length }}
+
+
+
SSL active
+
{{ domains|selectattr('ssl_status', 'equalto', 'active')|list|length }}
+
+
+
Needs attention
+
{{ domains|rejectattr('ssl_status', 'equalto', 'active')|list|length }}
+
+
+ +
+ + + + + + + + + + + + + + {% for m in domains %} + + + + + + + + + + {% endfor %} + +
DomainTypeDNSSSL ModeSSL StatusExpiryAction
{{ m.domain_name }}{{ m.domain_type.replace('_', ' ')|title }} + {% if m.is_verified and m.status == 'active' %}Verified{% else %}Pending{% endif %} + {{ m.ssl_mode or 'manual' }} + {% set ssl_status = m.ssl_status or 'not_checked' %} + {{ ssl_status.replace('_', ' ')|title }} + {% if m.ssl_last_error %}
{{ m.ssl_last_error }}
{% endif %} +
{{ m.ssl_not_after_utc.strftime('%d-%m-%Y') if m.ssl_not_after_utc else '-' }}Open SSL
+
+
+{% endblock %} diff --git a/app/modules/domain_management/templates/domain_management/ssl_detail.html b/app/modules/domain_management/templates/domain_management/ssl_detail.html new file mode 100644 index 0000000..a4d85d0 --- /dev/null +++ b/app/modules/domain_management/templates/domain_management/ssl_detail.html @@ -0,0 +1,75 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

SSL Setup: {{ mapping.domain_name }}

+

Use this page to track DNS, proxy and certificate readiness.

+
+ +
+ + {% if ssl_result is defined and ssl_result %} +
+
{{ ssl_result.status.replace('_', ' ')|title }}
+
{{ ssl_result.message }}
+
+ {% endif %} + +
+
+

Readiness checklist

+
+
{{ 'Done' if mapping.is_verified and mapping.status == 'active' else 'Pending' }}
DNS TXT verification
The domain should be verified and active in ERP.
+
Manual
A/CNAME record
Point {{ mapping.domain_name }} to your Coolify/proxy server before certificate issue.
+
Proxy
Add domain in deployment proxy
Coolify/Caddy/Traefik/Nginx must route this domain to the FastAPI app.
+
{{ (mapping.ssl_status or 'not_checked').replace('_', ' ')|title }}
Certificate check
ERP checks public HTTPS on port 443 and records certificate expiry.
+
+ +
+
+ + +
+
+ + + +
+
+
+ +
+

Certificate status

+
+
SSL Status
{{ (mapping.ssl_status or 'not_checked').replace('_', ' ')|title }}
+
SSL Mode
{{ mapping.ssl_mode or 'manual' }}
+
Last Checked
{{ mapping.ssl_last_checked_at_utc.strftime('%d-%m-%Y %H:%M') if mapping.ssl_last_checked_at_utc else '-' }}
+
Valid Until
{{ mapping.ssl_not_after_utc.strftime('%d-%m-%Y') if mapping.ssl_not_after_utc else '-' }}
+
Issuer
{{ mapping.ssl_issuer or '-' }}
+
Subject
{{ mapping.ssl_subject or '-' }}
+
+ {% if mapping.ssl_last_error %}
{{ mapping.ssl_last_error }}
{% endif %} +
+
+ +
+

Proxy snippets / setup notes

+ {% for snippet in snippets %} +
+

{{ snippet.title }}

+
{{ snippet.body }}
+
+ {% endfor %} +
+
+{% endblock %} diff --git a/app/modules/domain_management/templates/domain_management/tenant_subdomains.html b/app/modules/domain_management/templates/domain_management/tenant_subdomains.html new file mode 100644 index 0000000..bbdeeaa --- /dev/null +++ b/app/modules/domain_management/templates/domain_management/tenant_subdomains.html @@ -0,0 +1,90 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Tenant Subdomains

+

Create audit firm platform subdomains like auditfirm.filingabc.com. This uses your existing domain_mappings table.

+
+ Back to Domains +
+ + {% if errors %} +
+
Please fix the following:
+
    {% for e in errors %}
  • {{ e }}
  • {% endfor %}
+
+ {% endif %} + {% if message %}
{{ message }}
{% endif %} + +
+
+ +
+
+

For local testing you can use filingabc.local. For production use filingabc.com and configure wildcard DNS/SSL for *.filingabc.com.

+
+ +
+

Audit firm subdomain candidates

+
+ + + + + + + + + + + {% for item in candidates %} + {% set tenant = item.tenant %} + {% set mapping = item.mapping %} + + + + + + + {% else %} + + {% endfor %} + +
Audit FirmSuggested SubdomainCurrent StatusAction
+
{{ tenant.display_name or tenant.name }}
+
Code: {{ tenant.code }} · ID: {{ tenant.id }}
+
+ {{ item.domain_name }} + + {% if mapping %} +
Created
+
{{ mapping.status|title }}{% if mapping.is_verified %} · Verified{% else %} · Pending verification{% endif %}
+ {% else %} + Not created + {% endif %} +
+ {% if mapping %} + Open Mapping + {% else %} +
+ + + + + +
+ {% endif %} +
No audit firms found.
+
+
+ +
+
Deployment note
+

This phase creates and resolves tenant subdomain mappings in the ERP. DNS/SSL is still deployment-level: point *.{{ base_domain }} to your server and configure wildcard SSL in Coolify/Caddy/Traefik/Nginx as applicable.

+
+
+{% endblock %} diff --git a/app/modules/domain_management/templates/domain_management/verification.html b/app/modules/domain_management/templates/domain_management/verification.html new file mode 100644 index 0000000..3d72715 --- /dev/null +++ b/app/modules/domain_management/templates/domain_management/verification.html @@ -0,0 +1,78 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Custom Domain Verification

+

Verify DNS TXT records for custom firm, consultant and marketplace domains before activating runtime routing.

+
+
+ Back to Domains +
+ + + +
+
+
+ + {% if results %} +
+

Verification Result

+
+ {% for mapping, result in results %} +
+
+
{{ mapping.domain_name }}
+ {{ 'Verified' if result.ok else 'Pending' }} +
+
{{ result.message }}
+ {% if result.found_values %}
Found: {{ result.found_values|join(', ') }}
{% endif %} +
+ {% endfor %} +
+
+ {% endif %} + +
+
+

Domains Awaiting DNS Verification

+

Add the TXT record at your DNS provider, wait for DNS propagation, then verify.

+
+ {% if pending %} +
+ {% for m in pending %} +
+
+
{{ m.domain_name }}
+
{{ m.domain_type.replace('_', ' ')|title }}
+
+
+
TXT Name
+ {{ m.dns_txt_name or '-' }} +
TXT Value
+ {{ m.dns_txt_value or '-' }} +
+
+
+ + +
+ Open Details +
+
+ {% endfor %} +
+ {% else %} +
No pending domain verification records.
+ {% endif %} +
+ +
+

DNS record format

+

For a domain such as arrr.associates, add a TXT record at:

+ _audit-firm-verify.arrr.associates +

The TXT value must exactly match the verification token shown for that domain.

+
+
+{% endblock %} diff --git a/app/modules/domain_management/ui.py b/app/modules/domain_management/ui.py new file mode 100644 index 0000000..6fdc5ba --- /dev/null +++ b/app/modules/domain_management/ui.py @@ -0,0 +1,733 @@ +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.core.rbac.deps import get_user_permissions, get_user_roles +from app.modules.domain_management.models import DomainMapping +from app.modules.domain_management.services import ( + DomainMappingPayload, + create_domain_mapping, + create_or_get_tenant_subdomain_mapping, + create_or_get_firm_custom_domain_mapping, + create_or_get_consultant_domain_mapping, + list_consultant_domain_candidates, + list_domain_mappings, + list_domains_requiring_verification, + verify_all_pending_domains, + verify_domain_dns_txt, + build_ssl_proxy_snippets, + check_domain_ssl_certificate, + list_ssl_domains, + mark_ssl_managed, + list_tenant_subdomain_candidates, + mark_verified, + reference_data, + regenerate_verification_token, + update_domain_mapping, + validate_domain_payload, +) + +router = APIRouter(prefix="/domains", tags=["domain-management-ui"]) + + +def _is_domain_admin(db, user) -> bool: + roles = set(get_user_roles(db, user.id)) + return bool(roles.intersection({"System Admin", "Firm Admin"})) + + +def _base_ctx(request: Request, db, user, **ctx): + base = { + "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), + } + base.update(ctx) + return base + + +def _require_user(request: Request, db): + user = get_current_user(request, db=db) + if not user: + return None, RedirectResponse(url="/login", status_code=303) + if not _is_domain_admin(db, user): + return user, RedirectResponse(url="/system-settings", status_code=303) + return user, None + + +def _to_int(value: str | int | None) -> int | None: + try: + n = int(value or 0) + return n if n > 0 else None + except Exception: + return None + + +def _payload_from_form( + *, + domain_name: str, + domain_type: str, + tenant_id: str | int | None, + branch_id: str | int | None, + consultant_id: str | int | None, + parent_tenant_id: str | int | None, + is_primary: str | None, + is_verified: str | None, + status: str, + ssl_mode: str, + notes: str, +) -> DomainMappingPayload: + return DomainMappingPayload( + domain_name=domain_name, + domain_type=domain_type, + tenant_id=_to_int(tenant_id), + branch_id=_to_int(branch_id), + consultant_id=_to_int(consultant_id), + parent_tenant_id=_to_int(parent_tenant_id), + is_primary=bool(is_primary), + is_verified=bool(is_verified), + status=status or "draft", + ssl_mode=ssl_mode or "manual", + notes=notes, + ) + + +@router.get("") +def domain_list(request: Request, q: str = "", status: str = "", domain_type: str = ""): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db) + if response: + return response + mappings = list_domain_mappings(db, q=q, status=status, domain_type=domain_type) + refs = reference_data(db) + return templates.TemplateResponse( + "modules/domain_management/templates/domain_management/list.html", + _base_ctx( + request, + db, + user, + title="Domain Mapping", + mappings=mappings, + filters={"q": q, "status": status, "domain_type": domain_type}, + **refs, + ), + ) + finally: + db.close() + + +@router.get("/new") +def domain_create_page(request: Request): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db) + if response: + return response + return templates.TemplateResponse( + "modules/domain_management/templates/domain_management/form.html", + _base_ctx( + request, + db, + user, + title="Add Domain Mapping", + mapping=None, + errors=[], + form={}, + **reference_data(db), + ), + ) + finally: + db.close() + + +@router.post("/new") +def domain_create_submit( + request: Request, + csrf_token: str = Form(...), + domain_name: str = Form(...), + domain_type: str = Form(...), + tenant_id: str = Form(""), + branch_id: str = Form(""), + consultant_id: str = Form(""), + parent_tenant_id: str = Form(""), + is_primary: str | None = Form(None), + is_verified: str | None = Form(None), + status: str = Form("draft"), + ssl_mode: str = Form("manual"), + notes: str = Form(""), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, response = _require_user(request, db) + if response: + return response + payload = _payload_from_form( + domain_name=domain_name, + domain_type=domain_type, + tenant_id=tenant_id, + branch_id=branch_id, + consultant_id=consultant_id, + parent_tenant_id=parent_tenant_id, + is_primary=is_primary, + is_verified=is_verified, + status=status, + ssl_mode=ssl_mode, + notes=notes, + ) + errors = validate_domain_payload(db, payload) + if errors: + return templates.TemplateResponse( + "modules/domain_management/templates/domain_management/form.html", + _base_ctx(request, db, user, title="Add Domain Mapping", mapping=None, errors=errors, form=payload.__dict__, **reference_data(db)), + status_code=400, + ) + mapping = create_domain_mapping(db, payload, user_id=user.id) + return RedirectResponse(url=f"/domains/{mapping.id}", status_code=303) + finally: + db.close() + + + +@router.get("/tenant-subdomains") +def tenant_subdomain_page(request: Request, base_domain: str = "filingabc.com"): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db) + if response: + return response + refs = reference_data(db) + candidates = list_tenant_subdomain_candidates(db, base_domain=base_domain) + return templates.TemplateResponse( + "modules/domain_management/templates/domain_management/tenant_subdomains.html", + _base_ctx( + request, + db, + user, + title="Tenant Subdomains", + base_domain=base_domain, + candidates=candidates, + message="", + errors=[], + **refs, + ), + ) + finally: + db.close() + + +@router.post("/tenant-subdomains/create") +def tenant_subdomain_create( + request: Request, + csrf_token: str = Form(...), + tenant_id: str = Form(...), + base_domain: str = Form("filingabc.com"), + branch_id: str = Form(""), + mark_verified_active: str | None = Form(None), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, response = _require_user(request, db) + if response: + return response + errors: list[str] = [] + message = "" + try: + mapping, created = create_or_get_tenant_subdomain_mapping( + db, + tenant_id=int(tenant_id), + base_domain=base_domain, + branch_id=_to_int(branch_id), + mark_verified_active=bool(mark_verified_active), + user_id=user.id, + ) + if created: + return RedirectResponse(url=f"/domains/{mapping.id}", status_code=303) + message = f"Domain mapping already exists: {mapping.domain_name}" + except Exception as exc: + errors.append(str(exc)) + refs = reference_data(db) + return templates.TemplateResponse( + "modules/domain_management/templates/domain_management/tenant_subdomains.html", + _base_ctx( + request, + db, + user, + title="Tenant Subdomains", + base_domain=base_domain, + candidates=list_tenant_subdomain_candidates(db, base_domain=base_domain), + message=message, + errors=errors, + **refs, + ), + status_code=400 if errors else 200, + ) + finally: + db.close() + + +@router.get("/firm-domain") +def firm_custom_domain_page(request: Request, domain_name: str = "arrr.associates"): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db) + if response: + return response + refs = reference_data(db) + existing = None + normalized = (domain_name or "arrr.associates").strip().lower().split("/", 1)[0].split(":", 1)[0].rstrip(".") + if normalized: + existing = db.execute(select(DomainMapping).where(DomainMapping.domain_name == normalized)).scalar_one_or_none() + return templates.TemplateResponse( + "modules/domain_management/templates/domain_management/firm_domain.html", + _base_ctx( + request, + db, + user, + title="Audit Firm Custom Domain", + domain_name=normalized or "arrr.associates", + existing=existing, + message="", + errors=[], + **refs, + ), + ) + finally: + db.close() + + +@router.post("/firm-domain/create") +def firm_custom_domain_create( + request: Request, + csrf_token: str = Form(...), + tenant_id: str = Form(...), + domain_name: str = Form("arrr.associates"), + branch_id: str = Form(""), + mark_verified_active: str | None = Form(None), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, response = _require_user(request, db) + if response: + return response + errors: list[str] = [] + message = "" + try: + mapping, created = create_or_get_firm_custom_domain_mapping( + db, + tenant_id=int(tenant_id), + domain_name=domain_name, + branch_id=_to_int(branch_id), + mark_verified_active=bool(mark_verified_active), + user_id=user.id, + ) + if created: + return RedirectResponse(url=f"/domains/{mapping.id}", status_code=303) + message = f"Domain mapping already exists: {mapping.domain_name}" + except Exception as exc: + errors.append(str(exc)) + refs = reference_data(db) + normalized = (domain_name or "arrr.associates").strip().lower().split("/", 1)[0].split(":", 1)[0].rstrip(".") + existing = None + if normalized: + existing = db.execute(select(DomainMapping).where(DomainMapping.domain_name == normalized)).scalar_one_or_none() + return templates.TemplateResponse( + "modules/domain_management/templates/domain_management/firm_domain.html", + _base_ctx( + request, + db, + user, + title="Audit Firm Custom Domain", + domain_name=normalized or "arrr.associates", + existing=existing, + message=message, + errors=errors, + **refs, + ), + status_code=400 if errors else 200, + ) + finally: + db.close() + + +@router.get("/consultant-domains") +def consultant_domains_page(request: Request, base_domain: str = "filingabc.com", firm_base_domain: str = "arrr.accountant"): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db) + if response: + return response + refs = reference_data(db) + candidates = list_consultant_domain_candidates(db, base_domain=base_domain, firm_base_domain=firm_base_domain) + return templates.TemplateResponse( + "modules/domain_management/templates/domain_management/consultant_domains.html", + _base_ctx( + request, + db, + user, + title="Consultant Profile Domains", + base_domain=base_domain, + firm_base_domain=firm_base_domain, + candidates=candidates, + message="", + errors=[], + **refs, + ), + ) + finally: + db.close() + + +@router.post("/consultant-domains/create") +def consultant_domain_create( + request: Request, + csrf_token: str = Form(...), + consultant_id: str = Form(...), + domain_type: str = Form("consultant_marketplace_subdomain"), + domain_name: str = Form(""), + base_domain: str = Form("filingabc.com"), + firm_base_domain: str = Form("arrr.accountant"), + parent_tenant_id: str = Form(""), + mark_verified_active: str | None = Form(None), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, response = _require_user(request, db) + if response: + return response + errors: list[str] = [] + message = "" + try: + mapping, created = create_or_get_consultant_domain_mapping( + db, + consultant_id=int(consultant_id), + domain_type=domain_type, + domain_name=domain_name or None, + base_domain=base_domain, + firm_base_domain=firm_base_domain, + parent_tenant_id=_to_int(parent_tenant_id), + mark_verified_active=bool(mark_verified_active), + user_id=user.id, + ) + if created: + return RedirectResponse(url=f"/domains/{mapping.id}", status_code=303) + message = f"Domain mapping already exists: {mapping.domain_name}" + except Exception as exc: + errors.append(str(exc)) + refs = reference_data(db) + return templates.TemplateResponse( + "modules/domain_management/templates/domain_management/consultant_domains.html", + _base_ctx( + request, + db, + user, + title="Consultant Profile Domains", + base_domain=base_domain, + firm_base_domain=firm_base_domain, + candidates=list_consultant_domain_candidates(db, base_domain=base_domain, firm_base_domain=firm_base_domain), + message=message, + errors=errors, + **refs, + ), + status_code=400 if errors else 200, + ) + finally: + db.close() + + +@router.get("/verification") +def domain_verification_page(request: Request): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db) + if response: + return response + pending = list_domains_requiring_verification(db) + return templates.TemplateResponse( + "modules/domain_management/templates/domain_management/verification.html", + _base_ctx( + request, + db, + user, + title="Custom Domain Verification", + pending=pending, + results=[], + ), + ) + finally: + db.close() + + +@router.post("/verification/run") +def domain_verification_run(request: Request, csrf_token: str = Form(...), limit: str = Form("25")): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, response = _require_user(request, db) + if response: + return response + try: + n = int(limit or 25) + except Exception: + n = 25 + results = verify_all_pending_domains(db, user_id=user.id, limit=n) + return templates.TemplateResponse( + "modules/domain_management/templates/domain_management/verification.html", + _base_ctx( + request, + db, + user, + title="Custom Domain Verification", + pending=list_domains_requiring_verification(db), + results=results, + ), + ) + finally: + db.close() + + +@router.get("/ssl") +def ssl_dashboard(request: Request): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db) + if response: + return response + domains = list_ssl_domains(db) + return templates.TemplateResponse( + "modules/domain_management/templates/domain_management/ssl.html", + _base_ctx(request, db, user, title="SSL Automation", domains=domains), + ) + finally: + db.close() + + +@router.post("/ssl/check-all") +def ssl_check_all(request: Request, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, response = _require_user(request, db) + if response: + return response + results = [] + for mapping in list_ssl_domains(db)[:25]: + if mapping.status == "active" and mapping.is_verified: + result = check_domain_ssl_certificate(db, mapping, user_id=user.id) + results.append((mapping, result)) + domains = list_ssl_domains(db) + return templates.TemplateResponse( + "modules/domain_management/templates/domain_management/ssl.html", + _base_ctx(request, db, user, title="SSL Automation", domains=domains, ssl_results=results), + ) + finally: + db.close() + + +@router.get("/{mapping_id}/ssl") +def ssl_detail(request: Request, mapping_id: int): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db) + if response: + return response + mapping = db.execute(select(DomainMapping).where(DomainMapping.id == mapping_id)).scalar_one_or_none() + if not mapping: + return RedirectResponse(url="/domains/ssl", status_code=303) + snippets = build_ssl_proxy_snippets(mapping) + return templates.TemplateResponse( + "modules/domain_management/templates/domain_management/ssl_detail.html", + _base_ctx(request, db, user, title=f"SSL: {mapping.domain_name}", mapping=mapping, snippets=snippets), + ) + finally: + db.close() + + +@router.post("/{mapping_id}/ssl/check") +def ssl_check_one(request: Request, mapping_id: int, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, response = _require_user(request, db) + if response: + return response + mapping = db.execute(select(DomainMapping).where(DomainMapping.id == mapping_id)).scalar_one_or_none() + if not mapping: + return RedirectResponse(url="/domains/ssl", status_code=303) + result = check_domain_ssl_certificate(db, mapping, user_id=user.id) + snippets = build_ssl_proxy_snippets(mapping) + return templates.TemplateResponse( + "modules/domain_management/templates/domain_management/ssl_detail.html", + _base_ctx(request, db, user, title=f"SSL: {mapping.domain_name}", mapping=mapping, snippets=snippets, ssl_result=result), + status_code=200 if result.ok else 400, + ) + finally: + db.close() + + +@router.post("/{mapping_id}/ssl/mark-managed") +def ssl_mark_managed(request: Request, mapping_id: int, csrf_token: str = Form(...), provider: str = Form("manual")): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, response = _require_user(request, db) + if response: + return response + mapping = db.execute(select(DomainMapping).where(DomainMapping.id == mapping_id)).scalar_one_or_none() + if mapping: + mark_ssl_managed(db, mapping, provider=provider, user_id=user.id) + return RedirectResponse(url=f"/domains/{mapping_id}/ssl", status_code=303) + finally: + db.close() + + +@router.get("/{mapping_id}") +def domain_detail(request: Request, mapping_id: int): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db) + if response: + return response + mapping = db.execute(select(DomainMapping).where(DomainMapping.id == mapping_id)).scalar_one_or_none() + if not mapping: + return RedirectResponse(url="/domains", status_code=303) + return templates.TemplateResponse( + "modules/domain_management/templates/domain_management/detail.html", + _base_ctx(request, db, user, title=mapping.domain_name, mapping=mapping), + ) + finally: + db.close() + + +@router.get("/{mapping_id}/edit") +def domain_edit_page(request: Request, mapping_id: int): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db) + if response: + return response + mapping = db.execute(select(DomainMapping).where(DomainMapping.id == mapping_id)).scalar_one_or_none() + if not mapping: + return RedirectResponse(url="/domains", status_code=303) + return templates.TemplateResponse( + "modules/domain_management/templates/domain_management/form.html", + _base_ctx(request, db, user, title="Edit Domain Mapping", mapping=mapping, errors=[], form={}, **reference_data(db)), + ) + finally: + db.close() + + +@router.post("/{mapping_id}/edit") +def domain_edit_submit( + request: Request, + mapping_id: int, + csrf_token: str = Form(...), + domain_name: str = Form(...), + domain_type: str = Form(...), + tenant_id: str = Form(""), + branch_id: str = Form(""), + consultant_id: str = Form(""), + parent_tenant_id: str = Form(""), + is_primary: str | None = Form(None), + is_verified: str | None = Form(None), + status: str = Form("draft"), + ssl_mode: str = Form("manual"), + notes: str = Form(""), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, response = _require_user(request, db) + if response: + return response + mapping = db.execute(select(DomainMapping).where(DomainMapping.id == mapping_id)).scalar_one_or_none() + if not mapping: + return RedirectResponse(url="/domains", status_code=303) + payload = _payload_from_form( + domain_name=domain_name, + domain_type=domain_type, + tenant_id=tenant_id, + branch_id=branch_id, + consultant_id=consultant_id, + parent_tenant_id=parent_tenant_id, + is_primary=is_primary, + is_verified=is_verified, + status=status, + ssl_mode=ssl_mode, + notes=notes, + ) + errors = validate_domain_payload(db, payload, mapping_id=mapping.id) + if errors: + return templates.TemplateResponse( + "modules/domain_management/templates/domain_management/form.html", + _base_ctx(request, db, user, title="Edit Domain Mapping", mapping=mapping, errors=errors, form=payload.__dict__, **reference_data(db)), + status_code=400, + ) + update_domain_mapping(db, mapping, payload, user_id=user.id) + return RedirectResponse(url=f"/domains/{mapping.id}", status_code=303) + finally: + db.close() + + +@router.post("/{mapping_id}/verify-dns") +def domain_verify_dns(request: Request, mapping_id: int, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, response = _require_user(request, db) + if response: + return response + mapping = db.execute(select(DomainMapping).where(DomainMapping.id == mapping_id)).scalar_one_or_none() + if not mapping: + return RedirectResponse(url="/domains", status_code=303) + result = verify_domain_dns_txt(db, mapping, user_id=user.id) + return templates.TemplateResponse( + "modules/domain_management/templates/domain_management/detail.html", + _base_ctx(request, db, user, title=mapping.domain_name, mapping=mapping, dns_result=result), + status_code=200 if result.ok else 400, + ) + finally: + db.close() + + +@router.post("/{mapping_id}/regenerate-token") +def domain_regenerate_token(request: Request, mapping_id: int, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, response = _require_user(request, db) + if response: + return response + mapping = db.execute(select(DomainMapping).where(DomainMapping.id == mapping_id)).scalar_one_or_none() + if mapping: + regenerate_verification_token(db, mapping, user_id=user.id) + return RedirectResponse(url=f"/domains/{mapping_id}", status_code=303) + finally: + db.close() + + +@router.post("/{mapping_id}/mark-verified") +def domain_mark_verified(request: Request, mapping_id: int, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, response = _require_user(request, db) + if response: + return response + mapping = db.execute(select(DomainMapping).where(DomainMapping.id == mapping_id)).scalar_one_or_none() + if mapping: + mark_verified(db, mapping, user_id=user.id) + return RedirectResponse(url=f"/domains/{mapping_id}", status_code=303) + finally: + db.close() diff --git a/app/modules/email_integration/__init__.py b/app/modules/email_integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/email_integration/attachment_service.py b/app/modules/email_integration/attachment_service.py new file mode 100644 index 0000000..5cfee85 --- /dev/null +++ b/app/modules/email_integration/attachment_service.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import html +import re +from dataclasses import dataclass +from datetime import date, datetime +from decimal import Decimal +from typing import Any + + +@dataclass(slots=True) +class EmailAttachment: + """In-memory attachment used by the SMTP email service. + + This avoids writing temporary invoice/receipt files to disk and keeps Phase + 7S.1D independent of any PDF engine. The attachment is currently generated + as an HTML snapshot, which users can open/print/save as PDF from the mail + client. A later PDF-rendering phase can reuse the same hook. + """ + + filename: str + content: bytes + content_type: str = "application/octet-stream" + + +def _safe_text(value: Any) -> str: + if value is None: + return "" + if isinstance(value, (date, datetime)): + return value.isoformat() + return str(value) + + +def _safe_filename(value: str, fallback: str) -> str: + cleaned = re.sub(r"[^A-Za-z0-9_.-]+", "_", (value or "").strip()).strip("_") + return cleaned or fallback + + +def _money(value: Any) -> str: + try: + amount = Decimal(str(value or "0")) + return f"₹ {amount:,.2f}" + except Exception: + return _safe_text(value) + + +def _client_name(client: Any) -> str: + if not client: + return "Client" + return ( + getattr(client, "client_name", None) + or getattr(client, "trade_name", None) + or getattr(client, "name", None) + or "Client" + ) + + +def _invoice_number(invoice: Any) -> str: + return _safe_text( + getattr(invoice, "invoice_no", None) + or getattr(invoice, "invoice_number", None) + or getattr(invoice, "number", None) + or getattr(invoice, "id", "invoice") + ) + + +def _invoice_items(invoice: Any) -> list[Any]: + for attr in ("items", "line_items", "invoice_items"): + rows = getattr(invoice, attr, None) + if rows: + try: + return list(rows) + except Exception: + return [] + return [] + + +def build_invoice_html(invoice: Any, *, firm_name: str = "") -> str: + client = getattr(invoice, "client", None) + invoice_no = _invoice_number(invoice) + rows = [] + for index, item in enumerate(_invoice_items(invoice), start=1): + desc = getattr(item, "description", None) or getattr(item, "item_description", None) or getattr(item, "service_name", None) or "Professional Fees" + sac = getattr(item, "sac_code", None) or getattr(item, "hsn_sac", None) or "" + taxable = getattr(item, "taxable_value", None) or getattr(item, "amount", None) or getattr(item, "line_total", None) + gst_rate = getattr(item, "gst_rate", None) or getattr(item, "tax_rate", None) or "" + total = getattr(item, "total_amount", None) or getattr(item, "gross_amount", None) or taxable + rows.append( + f"{index}{html.escape(_safe_text(desc))}{html.escape(_safe_text(sac))}" + f"{html.escape(_money(taxable))}" + f"{html.escape(_safe_text(gst_rate))}" + f"{html.escape(_money(total))}" + ) + if not rows: + rows.append("1Professional Fees") + + return f""" +Invoice {html.escape(invoice_no)} + +
+

Tax Invoice

+

{html.escape(firm_name or _safe_text(getattr(invoice, 'firm_name', '') or 'Audit Firm'))}

+

Invoice No: {html.escape(invoice_no)}
Invoice Date: {html.escape(_safe_text(getattr(invoice, 'invoice_date', '')))}
Due Date: {html.escape(_safe_text(getattr(invoice, 'due_date', '')))}

+

Bill To

{html.escape(_safe_text(_client_name(client)))}

+{''.join(rows)}
#DescriptionSACTaxableGST %Total
+ + + + + + + +
Taxable Value{html.escape(_money(getattr(invoice, 'taxable_value', None) or getattr(invoice, 'subtotal', None)))}
CGST{html.escape(_money(getattr(invoice, 'cgst_amount', None) or getattr(invoice, 'cgst', None)))}
SGST{html.escape(_money(getattr(invoice, 'sgst_amount', None) or getattr(invoice, 'sgst', None)))}
IGST{html.escape(_money(getattr(invoice, 'igst_amount', None) or getattr(invoice, 'igst', None)))}
Total{html.escape(_money(getattr(invoice, 'total_amount', None)))}
Outstanding{html.escape(_money(getattr(invoice, 'balance_amount', None)))}
+

This is an ERP-generated invoice attachment. For payment, please use the client portal payment link provided in the email.

+
""" + + +def build_receipt_html(payment: Any, *, firm_name: str = "") -> str: + invoice = getattr(payment, "invoice", None) + client = getattr(payment, "client", None) or getattr(invoice, "client", None) + receipt_no = _safe_text(getattr(payment, "receipt_no", None) or getattr(payment, "receipt_number", None) or getattr(payment, "id", "receipt")) + return f""" +Receipt {html.escape(receipt_no)} + +
+

Payment Receipt

+

{html.escape(firm_name or 'Audit Firm')}

+ + + + + + + + + + +
Receipt No{html.escape(receipt_no)}
Receipt Date{html.escape(_safe_text(getattr(payment, 'payment_date', None) or getattr(payment, 'receipt_date', None)))}
Client{html.escape(_safe_text(_client_name(client)))}
Invoice No{html.escape(_invoice_number(invoice) if invoice else '')}
Amount Received{html.escape(_money(getattr(payment, 'amount_received', None) or getattr(payment, 'amount', None)))}
TDS Deducted{html.escape(_money(getattr(payment, 'tds_amount', None) or getattr(payment, 'tds_deducted', None)))}
Bank Charges{html.escape(_money(getattr(payment, 'bank_charges', None)))}
Payment Mode{html.escape(_safe_text(getattr(payment, 'mode', None) or getattr(payment, 'payment_mode', None)))}
Reference{html.escape(_safe_text(getattr(payment, 'reference_no', None) or getattr(payment, 'reference_number', None) or getattr(payment, 'utr_no', None)))}
+

This is an ERP-generated receipt attachment.

+
""" + + +def invoice_attachment(invoice: Any, *, firm_name: str = "") -> EmailAttachment: + invoice_no = _safe_filename(_invoice_number(invoice), "invoice") + return EmailAttachment( + filename=f"Invoice_{invoice_no}.html", + content=build_invoice_html(invoice, firm_name=firm_name).encode("utf-8"), + content_type="text/html", + ) + + +def receipt_attachment(payment: Any, *, firm_name: str = "") -> EmailAttachment: + receipt_no = _safe_filename(_safe_text(getattr(payment, "receipt_no", None) or getattr(payment, "receipt_number", None) or getattr(payment, "id", "receipt")), "receipt") + return EmailAttachment( + filename=f"Receipt_{receipt_no}.html", + content=build_receipt_html(payment, firm_name=firm_name).encode("utf-8"), + content_type="text/html", + ) diff --git a/app/modules/email_integration/event_service.py b/app/modules/email_integration/event_service.py new file mode 100644 index 0000000..df21824 --- /dev/null +++ b/app/modules/email_integration/event_service.py @@ -0,0 +1,231 @@ +from __future__ import annotations + +import logging +from decimal import Decimal +from typing import Any + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.modules.core.iam.models import User +from app.modules.email_integration.attachment_service import invoice_attachment, receipt_attachment +from app.modules.email_integration.services import _firm_name, get_email_setting, is_template_allowed_by_preferences, send_template_email + +logger = logging.getLogger("audit_firm.email_events") + +_TASK_ALERT_TYPES = {"task_assigned", "task_due", "task_overdue", "task_review"} +_BILLING_TEMPLATE_CODES = {"INVOICE_GENERATED", "PAYMENT_REMINDER", "PAYMENT_RECEIVED_RECEIPT", "ONLINE_PAYMENT_SUCCESS", "ONLINE_PAYMENT_FAILED"} + + +def _money(value: Any) -> str: + try: + amount = Decimal(str(value or "0")) + return f"₹ {amount:,.2f}" + except Exception: + return str(value or "") + + +def _user_display_name(user: User | None) -> str: + if not user: + return "User" + return (getattr(user, "full_name", None) or getattr(user, "email", None) or "User").strip() + + +def _load_user(db: Session, user_id: int | None) -> User | None: + if not user_id: + return None + try: + return db.execute(select(User).where(User.id == int(user_id))).scalar_one_or_none() + except Exception: + return None + + +def _can_send_alert_email(db: Session, tenant_id: int | None, branch_id: int | None, alert_type: str | None) -> bool: + setting = get_email_setting(db, tenant_id, branch_id) + if not setting or not setting.is_active: + return False + if not getattr(setting, "send_alert_emails", False): + return False + template_code = _alert_template_code(alert_type) + if not template_code: + return False + allowed, _reason = is_template_allowed_by_preferences(setting, template_code) + return bool(allowed) + + +def _can_send_billing_email(db: Session, tenant_id: int | None, branch_id: int | None, template_code: str | None = None) -> bool: + setting = get_email_setting(db, tenant_id, branch_id) + if not setting or not setting.is_active or not getattr(setting, "send_billing_emails", False): + return False + if template_code: + allowed, _reason = is_template_allowed_by_preferences(setting, template_code) + return bool(allowed) + return True + + +def _client_email(client: Any) -> str | None: + if not client: + return None + for field in ("email", "alternate_email"): + value = (getattr(client, field, None) or "").strip() + if value: + return value + return None + + +def _client_name(client: Any) -> str: + return (getattr(client, "client_name", None) or getattr(client, "trade_name", None) or "Client").strip() + + +def _invoice_pay_link(invoice: Any) -> str: + invoice_id = getattr(invoice, "id", None) + return f"/client/billing/{invoice_id}/pay-now" if invoice_id else "/client/billing" + + +def _alert_template_code(alert_type: str | None) -> str | None: + value = (alert_type or "general").strip().lower() + if value == "task_assigned": + return "TASK_ASSIGNED" + if value == "task_due": + return "TASK_DUE_TODAY" + if value == "task_overdue": + return "TASK_OVERDUE" + if value == "task_review": + return "PARTNER_REVIEW_REQUIRED" + if value == "document_uploaded": + return "CLIENT_DOCUMENT_RECEIVED" + if value == "clarification": + return "CLIENT_CLARIFICATION_REQUEST" + if value == "attendance": + return "ATTENDANCE_PUNCH_MISSING" + if value == "leave": + return "LEAVE_REQUEST_SUBMITTED" + if value == "consultant": + return "CONSULTANT_ASSIGNMENT" + return None + + +def send_alert_created_email(db: Session, alert: Any) -> None: + """Best-effort email notification for any newly created in-app alert. + + This is intentionally non-blocking from business-flow perspective. SMTP + failure is captured in email_logs by send_template_email and should not + prevent alert creation, task updates, billing, attendance, etc. + """ + tenant_id = getattr(alert, "tenant_id", None) + branch_id = getattr(alert, "branch_id", None) + alert_type = getattr(alert, "alert_type", None) + if not _can_send_alert_email(db, tenant_id, branch_id, alert_type): + return + + template_code = _alert_template_code(alert_type) + if not template_code: + return + + user = _load_user(db, getattr(alert, "user_id", None)) + recipient = (getattr(user, "email", None) or "").strip() if user else "" + if not recipient: + return + + title = getattr(alert, "title", None) or "Alert" + message = getattr(alert, "message", None) or "" + target_url = getattr(alert, "target_url", None) or "/alerts" + + context = { + "recipient_name": _user_display_name(user), + "user_name": _user_display_name(user), + "partner_name": _user_display_name(user), + "consultant_name": _user_display_name(user), + "task_title": title, + "work_title": title, + "assignment_title": title, + "service_name": "", + "client_name": "", + "engagement_code": "", + "due_date": "", + "clarification_text": message, + "review_note": message, + "action_url": target_url, + "login_url": "/login", + } + try: + send_template_email( + db, + tenant_id=tenant_id, + branch_id=branch_id, + recipient_email=recipient, + template_code=template_code, + context=context, + related_module="alert", + related_id=getattr(alert, "id", None), + ) + except Exception: + logger.exception("Email alert notification failed for alert_id=%s", getattr(alert, "id", None)) + + +def send_invoice_issued_email(db: Session, invoice: Any) -> None: + tenant_id = getattr(invoice, "tenant_id", None) + branch_id = getattr(invoice, "branch_id", None) + if not _can_send_billing_email(db, tenant_id, branch_id, "INVOICE_GENERATED"): + return + client = getattr(invoice, "client", None) + recipient = _client_email(client) + if not recipient: + return + try: + send_template_email( + db, + tenant_id=tenant_id, + branch_id=branch_id, + recipient_email=recipient, + template_code="INVOICE_GENERATED", + context={ + "client_name": _client_name(client), + "invoice_number": getattr(invoice, "invoice_no", None) or getattr(invoice, "invoice_number", None) or str(getattr(invoice, "id", "")), + "invoice_amount": _money(getattr(invoice, "total_amount", None)), + "outstanding_amount": _money(getattr(invoice, "balance_amount", None)), + "due_date": getattr(getattr(invoice, "due_date", None), "isoformat", lambda: str(getattr(invoice, "due_date", "")))(), + "payment_link": _invoice_pay_link(invoice), + "action_url": _invoice_pay_link(invoice), + }, + related_module="billing_invoice", + related_id=getattr(invoice, "id", None), + attachments=[invoice_attachment(invoice, firm_name=_firm_name(db, tenant_id))], + ) + except Exception: + logger.exception("Invoice email failed for invoice_id=%s", getattr(invoice, "id", None)) + + +def send_payment_receipt_email(db: Session, payment: Any) -> None: + invoice = getattr(payment, "invoice", None) + tenant_id = getattr(payment, "tenant_id", None) or getattr(invoice, "tenant_id", None) + branch_id = getattr(payment, "branch_id", None) or getattr(invoice, "branch_id", None) + if not _can_send_billing_email(db, tenant_id, branch_id, "PAYMENT_RECEIVED_RECEIPT"): + return + client = getattr(payment, "client", None) or getattr(invoice, "client", None) + recipient = _client_email(client) + if not recipient: + return + try: + send_template_email( + db, + tenant_id=tenant_id, + branch_id=branch_id, + recipient_email=recipient, + template_code="PAYMENT_RECEIVED_RECEIPT", + context={ + "client_name": _client_name(client), + "invoice_number": getattr(invoice, "invoice_no", None) or str(getattr(invoice, "id", "")), + "receipt_number": getattr(payment, "receipt_no", None) or str(getattr(payment, "id", "")), + "payment_amount": _money(getattr(payment, "amount_received", None)), + "payment_date": getattr(getattr(payment, "payment_date", None), "isoformat", lambda: str(getattr(payment, "payment_date", "")))(), + "payment_mode": getattr(payment, "mode", None) or "", + "payment_link": _invoice_pay_link(invoice), + "action_url": _invoice_pay_link(invoice), + }, + related_module="billing_payment", + related_id=getattr(payment, "id", None), + attachments=[receipt_attachment(payment, firm_name=_firm_name(db, tenant_id))], + ) + except Exception: + logger.exception("Payment receipt email failed for payment_id=%s", getattr(payment, "id", None)) diff --git a/app/modules/email_integration/imap_service.py b/app/modules/email_integration/imap_service.py new file mode 100644 index 0000000..f5ded5e --- /dev/null +++ b/app/modules/email_integration/imap_service.py @@ -0,0 +1,327 @@ +from __future__ import annotations + +import email +import imaplib +import re +import ssl +from dataclasses import dataclass +from datetime import datetime, timezone +from email.header import decode_header, make_header +from email.message import Message +from email.utils import getaddresses, parsedate_to_datetime +from pathlib import Path +from typing import Iterable +from uuid import uuid4 + +from sqlalchemy import or_, select +from sqlalchemy.orm import Session + +from app.modules.clients.models import Client +from app.modules.consultants.models import ConsultantProfile +from app.modules.core.iam.models import User +from app.modules.email_integration.models import EmailIncomingAttachment, EmailIncomingMessage, EmailSetting +from app.modules.email_integration.mapping_service import apply_email_mapping + +UPLOAD_ROOT = Path("app/ui/static/uploads/incoming_emails") +MAX_ATTACHMENT_BYTES = 15 * 1024 * 1024 + + +@dataclass +class ImapFetchResult: + fetched: int = 0 + imported: int = 0 + skipped_existing: int = 0 + failed: int = 0 + error: str | None = None + + +def _decode_mime(value: str | None) -> str: + if not value: + return "" + try: + return str(make_header(decode_header(value))) + except Exception: + return value + + +def _normalise_email(value: str | None) -> str: + return (value or "").strip().lower() + + +def _addresses(header_value: str | None) -> list[tuple[str, str]]: + decoded = _decode_mime(header_value) + return [(name, addr.lower()) for name, addr in getaddresses([decoded]) if addr] + + +def _joined_addresses(header_value: str | None) -> str: + return ", ".join(addr for _name, addr in _addresses(header_value)) + + +def _received_at(msg: Message) -> datetime | None: + raw_date = msg.get("Date") + if not raw_date: + return None + try: + parsed = parsedate_to_datetime(raw_date) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + except Exception: + return None + + +def _extract_bodies(msg: Message) -> tuple[str | None, str | None]: + text_parts: list[str] = [] + html_parts: list[str] = [] + + def decode_part(part: Message) -> str: + payload = part.get_payload(decode=True) + if payload is None: + raw = part.get_payload() + return raw if isinstance(raw, str) else "" + charset = part.get_content_charset() or "utf-8" + try: + return payload.decode(charset, errors="replace") + except Exception: + return payload.decode("utf-8", errors="replace") + + if msg.is_multipart(): + for part in msg.walk(): + if part.get_content_maintype() == "multipart": + continue + if part.get_filename(): + continue + ctype = part.get_content_type().lower() + content = decode_part(part).strip() + if not content: + continue + if ctype == "text/plain": + text_parts.append(content) + elif ctype == "text/html": + html_parts.append(content) + else: + ctype = msg.get_content_type().lower() + content = decode_part(msg).strip() + if ctype == "text/html": + html_parts.append(content) + else: + text_parts.append(content) + + text = "\n\n".join(text_parts).strip() or None + html = "\n\n".join(html_parts).strip() or None + return text, html + + +def _safe_filename(name: str | None) -> str: + decoded = _decode_mime(name or "attachment") or "attachment" + cleaned = re.sub(r"[^A-Za-z0-9._ -]", "_", decoded).strip(" .") + return cleaned[:180] or "attachment" + + +def _save_attachments(msg: Message, tenant_id: int | None, branch_id: int | None, incoming_id: int) -> list[EmailIncomingAttachment]: + saved: list[EmailIncomingAttachment] = [] + base = UPLOAD_ROOT / str(tenant_id or "system") / str(branch_id or "all") / str(incoming_id) + base.mkdir(parents=True, exist_ok=True) + + for part in msg.walk() if msg.is_multipart() else []: + if part.get_content_maintype() == "multipart": + continue + filename = part.get_filename() + disposition = (part.get("Content-Disposition") or "").lower() + if not filename and "attachment" not in disposition: + continue + payload = part.get_payload(decode=True) or b"" + if not payload: + continue + if len(payload) > MAX_ATTACHMENT_BYTES: + continue + safe = _safe_filename(filename) + target_name = f"{uuid4().hex}_{safe}" + target = base / target_name + target.write_bytes(payload) + saved.append( + EmailIncomingAttachment( + incoming_message_id=incoming_id, + tenant_id=tenant_id, + branch_id=branch_id, + filename=safe, + content_type=part.get_content_type(), + size_bytes=len(payload), + storage_path=str(target).replace("\\", "/"), + ) + ) + return saved + + +def _match_sender(db: Session, tenant_id: int | None, branch_id: int | None, sender_email: str | None) -> tuple[int | None, int | None, int | None, str]: + email_value = _normalise_email(sender_email) + if not email_value: + return None, None, None, "NEW" + + user_id = None + client_id = None + consultant_id = None + + user = db.execute(select(User).where(User.email == email_value).limit(1)).scalar_one_or_none() + if user: + user_id = int(user.id) + + if tenant_id: + client_filters = [Client.tenant_id == tenant_id, or_(Client.email == email_value, Client.alternate_email == email_value)] + if branch_id: + client_filters.append(Client.branch_id == branch_id) + client = db.execute(select(Client).where(*client_filters).limit(1)).scalar_one_or_none() + if client: + client_id = int(client.id) + + consultant_filters = [ConsultantProfile.tenant_id == tenant_id, ConsultantProfile.email == email_value] + if branch_id: + consultant_filters.append(or_(ConsultantProfile.branch_id == branch_id, ConsultantProfile.branch_id.is_(None))) + consultant = db.execute(select(ConsultantProfile).where(*consultant_filters).limit(1)).scalar_one_or_none() + if consultant: + consultant_id = int(consultant.id) + + status = "MATCHED" if any([user_id, client_id, consultant_id]) else "NEW" + return user_id, client_id, consultant_id, status + + +def _connect(setting: EmailSetting): + host = (setting.imap_host or "").strip() + port = int(setting.imap_port or 993) + security = (setting.imap_security or "SSL").upper() + if not host or not setting.imap_username or not setting.imap_password: + raise RuntimeError("IMAP settings are incomplete. Please configure IMAP host, username and password.") + + if security == "SSL": + conn = imaplib.IMAP4_SSL(host, port, ssl_context=ssl.create_default_context()) + else: + conn = imaplib.IMAP4(host, port) + if security == "STARTTLS": + conn.starttls(ssl_context=ssl.create_default_context()) + conn.login(setting.imap_username, setting.imap_password) + return conn + + +def fetch_incoming_emails( + db: Session, + *, + setting: EmailSetting, + tenant_id: int | None, + branch_id: int | None, + folder: str = "INBOX", + unread_only: bool = True, + limit: int = 25, + mark_seen: bool = False, +) -> ImapFetchResult: + result = ImapFetchResult() + mailbox_email = _normalise_email(setting.imap_username or setting.from_email or "mailbox") + conn = None + try: + conn = _connect(setting) + typ, _ = conn.select(folder, readonly=not mark_seen) + if typ != "OK": + raise RuntimeError(f"Unable to open IMAP folder: {folder}") + criteria = "UNSEEN" if unread_only else "ALL" + typ, data = conn.search(None, criteria) + if typ != "OK": + raise RuntimeError("IMAP search failed.") + ids = (data[0] or b"").split() + ids = ids[-max(1, min(int(limit or 25), 100)):] + result.fetched = len(ids) + + for msg_seq in ids: + try: + typ, uid_data = conn.fetch(msg_seq, "(UID)") + uid_text = uid_data[0].decode(errors="ignore") if uid_data and uid_data[0] else msg_seq.decode() + uid_match = re.search(r"UID (\d+)", uid_text) + provider_uid = uid_match.group(1) if uid_match else msg_seq.decode() + + exists = db.execute( + select(EmailIncomingMessage).where( + EmailIncomingMessage.tenant_id == tenant_id, + EmailIncomingMessage.branch_id == branch_id, + EmailIncomingMessage.mailbox_email == mailbox_email, + EmailIncomingMessage.folder_name == folder, + EmailIncomingMessage.provider_uid == provider_uid, + ) + ).scalar_one_or_none() + if exists: + result.skipped_existing += 1 + continue + + typ, msg_data = conn.fetch(msg_seq, "(RFC822)") + if typ != "OK" or not msg_data: + result.failed += 1 + continue + raw = None + for item in msg_data: + if isinstance(item, tuple): + raw = item[1] + break + if not raw: + result.failed += 1 + continue + msg = email.message_from_bytes(raw) + from_rows = _addresses(msg.get("From")) + sender_name, sender_email = from_rows[0] if from_rows else ("", "") + body_text, body_html = _extract_bodies(msg) + user_id, client_id, consultant_id, status = _match_sender(db, tenant_id, branch_id, sender_email) + + incoming = EmailIncomingMessage( + tenant_id=tenant_id, + branch_id=branch_id, + mailbox_email=mailbox_email, + folder_name=folder, + provider_uid=provider_uid, + provider_message_id=_decode_mime(msg.get("Message-ID")) or None, + sender_email=sender_email or None, + sender_name=sender_name or None, + recipient_emails=_joined_addresses(msg.get("To")) or None, + cc_emails=_joined_addresses(msg.get("Cc")) or None, + subject=_decode_mime(msg.get("Subject"))[:500] or None, + body_text=body_text, + body_html=body_html, + raw_headers="\n".join(f"{k}: {v}" for k, v in msg.items()), + received_at_utc=_received_at(msg), + status=status, + matched_user_id=user_id, + matched_client_id=client_id, + matched_consultant_id=consultant_id, + ) + db.add(incoming) + db.flush() + attachments = _save_attachments(msg, tenant_id, branch_id, int(incoming.id)) + for attachment in attachments: + db.add(attachment) + incoming.has_attachments = bool(attachments) + incoming.attachment_count = len(attachments) + # Phase 7S.3: immediately try tracking-code mapping after fetch. + # Failure to map should never fail the IMAP import. + try: + apply_email_mapping(db, incoming) + except Exception as map_exc: + incoming.mapping_status = "ERROR" + incoming.mapping_notes = str(map_exc)[:1000] + result.imported += 1 + except Exception: + result.failed += 1 + if mark_seen: + for msg_seq in ids: + try: + conn.store(msg_seq, "+FLAGS", "\\Seen") + except Exception: + pass + return result + except Exception as exc: + result.error = str(exc) + return result + finally: + if conn is not None: + try: + conn.close() + except Exception: + pass + try: + conn.logout() + except Exception: + pass diff --git a/app/modules/email_integration/mapping_service.py b/app/modules/email_integration/mapping_service.py new file mode 100644 index 0000000..8d39961 --- /dev/null +++ b/app/modules/email_integration/mapping_service.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Iterable + +from sqlalchemy import or_, select +from sqlalchemy.orm import Session + +from app.modules.billing.models import BillingInvoice +from app.modules.email_integration.models import EmailIncomingMessage +from app.modules.services.models import ClientServiceSubscription, ClientServiceTaskInstance, ServiceTaskComment + +TRACKING_PATTERN = re.compile(r"\[(?:AF-)?(?PENG|TASK|INV|INVOICE|CLIENT|NOTICE)-(?P[A-Za-z0-9._/-]+)\]", re.IGNORECASE) + + +@dataclass +class EmailMappingResult: + matched: bool = False + status: str = "NO_MATCH" + tracking_code: str | None = None + related_module: str | None = None + related_id: int | None = None + engagement_id: int | None = None + task_id: int | None = None + invoice_id: int | None = None + client_id: int | None = None + notes: str | None = None + + +def _scope_filter(model, tenant_id: int | None, branch_id: int | None) -> list: + filters = [] + if tenant_id is not None and hasattr(model, "tenant_id"): + filters.append(model.tenant_id == tenant_id) + if branch_id is not None and hasattr(model, "branch_id"): + filters.append(or_(model.branch_id == branch_id, model.branch_id.is_(None))) + return filters + + +def extract_tracking_codes(subject: str | None, body_text: str | None = None, body_html: str | None = None) -> list[tuple[str, str, str]]: + """Return tracking codes as (raw_code, kind, value).""" + combined = "\n".join([subject or "", body_text or "", body_html or ""]) + seen: set[str] = set() + rows: list[tuple[str, str, str]] = [] + for match in TRACKING_PATTERN.finditer(combined): + raw = match.group(0).upper() + if raw in seen: + continue + seen.add(raw) + kind = match.group("kind").upper() + if kind == "INVOICE": + kind = "INV" + value = match.group("value").strip() + rows.append((raw, kind, value)) + return rows + + +def _int_or_none(value: str | int | None) -> int | None: + if value is None: + return None + try: + return int(str(value).strip()) + except Exception: + return None + + +def _find_engagement(db: Session, tenant_id: int | None, branch_id: int | None, value: str) -> ClientServiceSubscription | None: + ident = _int_or_none(value) + if ident is None: + return None + filters = [ClientServiceSubscription.id == ident, *_scope_filter(ClientServiceSubscription, tenant_id, branch_id)] + return db.execute(select(ClientServiceSubscription).where(*filters).limit(1)).scalar_one_or_none() + + +def _find_task(db: Session, tenant_id: int | None, branch_id: int | None, value: str) -> ClientServiceTaskInstance | None: + ident = _int_or_none(value) + if ident is None: + return None + filters = [ClientServiceTaskInstance.id == ident, *_scope_filter(ClientServiceTaskInstance, tenant_id, branch_id)] + return db.execute(select(ClientServiceTaskInstance).where(*filters).limit(1)).scalar_one_or_none() + + +def _find_invoice(db: Session, tenant_id: int | None, branch_id: int | None, value: str) -> BillingInvoice | None: + ident = _int_or_none(value) + filters = [*_scope_filter(BillingInvoice, tenant_id, branch_id)] + if ident is not None: + invoice = db.execute(select(BillingInvoice).where(BillingInvoice.id == ident, *filters).limit(1)).scalar_one_or_none() + if invoice: + return invoice + value_clean = value.strip() + if value_clean: + return db.execute(select(BillingInvoice).where(BillingInvoice.invoice_no == value_clean, *filters).limit(1)).scalar_one_or_none() + return None + + +def _create_task_timeline_comment(db: Session, message: EmailIncomingMessage, task: ClientServiceTaskInstance) -> None: + existing = db.execute( + select(ServiceTaskComment).where( + ServiceTaskComment.task_instance_id == task.id, + ServiceTaskComment.message.like(f"%Incoming Email ID: {message.id}%"), + ).limit(1) + ).scalar_one_or_none() + if existing: + return + body = (message.body_text or "").strip() + if len(body) > 1200: + body = body[:1200].rstrip() + "..." + sender = message.sender_email or "Unknown sender" + subject = message.subject or "No subject" + comment_text = ( + f"Incoming email mapped from {sender}\n" + f"Subject: {subject}\n\n" + f"{body}\n\n" + f"Incoming Email ID: {message.id}" + ).strip() + db.add( + ServiceTaskComment( + tenant_id=task.tenant_id, + branch_id=task.branch_id, + subscription_id=task.subscription_id, + task_instance_id=task.id, + comment_type="client_communication" if message.matched_client_id else "email_reply", + visibility="internal", + message=comment_text, + created_by_user_id=message.matched_user_id, + ) + ) + + +def apply_email_mapping( + db: Session, + message: EmailIncomingMessage, + *, + engagement_id: int | None = None, + task_id: int | None = None, + invoice_id: int | None = None, + manual: bool = False, +) -> EmailMappingResult: + """Map one incoming email to engagement/task/invoice by manual selection or tracking code.""" + tenant_id = message.tenant_id + branch_id = message.branch_id + result = EmailMappingResult(status="NO_MATCH") + + # Manual mapping takes precedence. + if task_id: + task = _find_task(db, tenant_id, branch_id, str(task_id)) + if task: + result = EmailMappingResult(True, "MANUAL_MAPPED" if manual else "AUTO_MAPPED", None, "task", int(task.id), int(task.subscription_id), int(task.id), None, int(task.client_id), "Mapped to task.") + _apply_result(db, message, result) + _create_task_timeline_comment(db, message, task) + return result + if engagement_id: + eng = _find_engagement(db, tenant_id, branch_id, str(engagement_id)) + if eng: + result = EmailMappingResult(True, "MANUAL_MAPPED" if manual else "AUTO_MAPPED", None, "engagement", int(eng.id), int(eng.id), None, None, int(eng.client_id), "Mapped to engagement.") + _apply_result(db, message, result) + return result + if invoice_id: + inv = _find_invoice(db, tenant_id, branch_id, str(invoice_id)) + if inv: + result = EmailMappingResult(True, "MANUAL_MAPPED" if manual else "AUTO_MAPPED", None, "invoice", int(inv.id), int(inv.engagement_id) if inv.engagement_id else None, None, int(inv.id), int(inv.client_id), "Mapped to invoice.") + _apply_result(db, message, result) + return result + + # Auto mapping by tracking code in subject/body. + for raw, kind, value in extract_tracking_codes(message.subject, message.body_text, message.body_html): + if kind == "TASK": + task = _find_task(db, tenant_id, branch_id, value) + if task: + result = EmailMappingResult(True, "AUTO_MAPPED", raw, "task", int(task.id), int(task.subscription_id), int(task.id), None, int(task.client_id), "Auto-mapped using task tracking code.") + _apply_result(db, message, result) + _create_task_timeline_comment(db, message, task) + return result + if kind == "ENG": + eng = _find_engagement(db, tenant_id, branch_id, value) + if eng: + result = EmailMappingResult(True, "AUTO_MAPPED", raw, "engagement", int(eng.id), int(eng.id), None, None, int(eng.client_id), "Auto-mapped using engagement tracking code.") + _apply_result(db, message, result) + return result + if kind == "INV": + inv = _find_invoice(db, tenant_id, branch_id, value) + if inv: + result = EmailMappingResult(True, "AUTO_MAPPED", raw, "invoice", int(inv.id), int(inv.engagement_id) if inv.engagement_id else None, None, int(inv.id), int(inv.client_id), "Auto-mapped using invoice tracking code.") + _apply_result(db, message, result) + return result + + message.mapping_status = "NO_MATCH" + message.mapping_notes = "No tracking code/manual match found." + message.mapped_at_utc = datetime.now(timezone.utc) + if message.status == "NEW": + message.status = "NEW" + return result + + +def _apply_result(db: Session, message: EmailIncomingMessage, result: EmailMappingResult) -> None: + message.tracking_code = result.tracking_code + message.related_module = result.related_module + message.related_id = result.related_id + message.matched_engagement_id = result.engagement_id + message.matched_task_id = result.task_id + message.matched_invoice_id = result.invoice_id + if result.client_id and not message.matched_client_id: + message.matched_client_id = result.client_id + message.mapping_status = result.status + message.mapping_notes = result.notes + message.mapped_at_utc = datetime.now(timezone.utc) + message.status = "MATCHED" if result.matched else message.status + + +def auto_map_unmapped_emails(db: Session, *, tenant_id: int | None, branch_id: int | None, limit: int = 100) -> dict[str, int]: + q = select(EmailIncomingMessage).where(EmailIncomingMessage.tenant_id == tenant_id) + if branch_id: + q = q.where(EmailIncomingMessage.branch_id == branch_id) + q = q.where(EmailIncomingMessage.mapping_status.in_(["UNMAPPED", "NO_MATCH"])) + rows = db.execute(q.order_by(EmailIncomingMessage.received_at_utc.desc(), EmailIncomingMessage.id.desc()).limit(max(1, min(limit, 500)))).scalars().all() + mapped = 0 + no_match = 0 + for row in rows: + res = apply_email_mapping(db, row) + if res.matched: + mapped += 1 + else: + no_match += 1 + return {"processed": len(rows), "mapped": mapped, "no_match": no_match} diff --git a/app/modules/email_integration/models.py b/app/modules/email_integration/models.py new file mode 100644 index 0000000..a92082c --- /dev/null +++ b/app/modules/email_integration/models.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.db.common import CommonBase + + +class EmailSetting(CommonBase): + """Firm/branch email configuration for outgoing SMTP and later IMAP use. + + Phase 7S.1 uses SMTP for outgoing notifications. IMAP fields are included + now so Hostinger/Dovecot mailbox credentials can be stored once and reused + in Phase 7S.2 without changing the settings screen again. + """ + + __tablename__ = "email_settings" + __table_args__ = ( + UniqueConstraint("tenant_id", "branch_id", name="uq_email_settings_tenant_branch"), + ) + + 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) + branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True) + + smtp_host: Mapped[str | None] = mapped_column(String(255), nullable=True) + smtp_port: Mapped[int | None] = mapped_column(Integer, nullable=True) + smtp_username: Mapped[str | None] = mapped_column(String(255), nullable=True) + smtp_password: Mapped[str | None] = mapped_column(String(500), nullable=True) + smtp_security: Mapped[str] = mapped_column(String(20), nullable=False, default="SSL") # SSL|STARTTLS|NONE + smtp_timeout_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=20) + + from_email: Mapped[str | None] = mapped_column(String(255), nullable=True) + from_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + reply_to_email: Mapped[str | None] = mapped_column(String(255), nullable=True) + + imap_host: Mapped[str | None] = mapped_column(String(255), nullable=True) + imap_port: Mapped[int | None] = mapped_column(Integer, nullable=True) + imap_username: Mapped[str | None] = mapped_column(String(255), nullable=True) + imap_password: Mapped[str | None] = mapped_column(String(500), nullable=True) + imap_security: Mapped[str] = mapped_column(String(20), nullable=False, default="SSL") # SSL|STARTTLS|NONE + + send_auth_emails: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + send_alert_emails: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + send_billing_emails: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + send_task_emails: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + + # Phase 7S.1C - granular notification preferences. These are firm/branch + # level switches used by business-event email hooks. Authentication emails + # remain separately controlled by send_auth_emails and may still be forced + # for security-critical OTP flows. + send_invoice_emails: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + send_payment_emails: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + send_client_emails: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + send_document_emails: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + send_consultant_emails: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + send_partner_review_emails: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + send_leave_attendance_emails: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + send_online_payment_emails: Mapped[bool] = mapped_column(Boolean, nullable=False, default=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", ondelete="SET NULL"), nullable=True) + updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) + updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False) + + +class EmailTemplate(CommonBase): + __tablename__ = "email_templates" + __table_args__ = ( + UniqueConstraint("tenant_id", "branch_id", "template_code", name="uq_email_templates_scope_code"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + tenant_id: Mapped[int | None] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True) + branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True) + + template_code: Mapped[str] = mapped_column(String(80), nullable=False, index=True) + template_name: Mapped[str] = mapped_column(String(160), nullable=False) + subject_template: Mapped[str] = mapped_column(String(500), nullable=False) + body_template: Mapped[str] = mapped_column(Text, nullable=False) + is_html: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=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 EmailLog(CommonBase): + __tablename__ = "email_logs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + tenant_id: Mapped[int | None] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True) + branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True) + + recipient_email: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + subject: Mapped[str] = mapped_column(String(500), nullable=False) + body: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[str] = mapped_column(String(30), nullable=False, default="PENDING", index=True) # SENT|FAILED|SKIPPED + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + + related_module: Mapped[str | None] = mapped_column(String(80), nullable=True, index=True) + related_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) + template_code: Mapped[str | None] = mapped_column(String(80), nullable=True, index=True) + provider_message_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + + # Phase 7S.1E - queue/retry metadata. Existing callers may still attempt + # immediate sending, but failed/pending emails can now be retried safely. + attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=3) + queue_priority: Mapped[int] = mapped_column(Integer, nullable=False, default=100, index=True) + is_retryable: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True) + queued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + next_retry_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + last_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + processing_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + + created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False, index=True) + + +class EmailIncomingMessage(CommonBase): + """Incoming IMAP message fetched from the configured firm mailbox. + + Phase 7S.2 stores metadata/body locally so replies can later be mapped to + clients, engagements, tasks, notices and invoices. Attachments are recorded + separately and are saved to a safe local email upload folder for now. + """ + + __tablename__ = "email_incoming_messages" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "branch_id", + "mailbox_email", + "folder_name", + "provider_uid", + name="uq_email_incoming_scope_mailbox_folder_uid", + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + tenant_id: Mapped[int | None] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True) + branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True) + + mailbox_email: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + folder_name: Mapped[str] = mapped_column(String(120), nullable=False, default="INBOX", index=True) + provider_uid: Mapped[str] = mapped_column(String(120), nullable=False, index=True) + provider_message_id: Mapped[str | None] = mapped_column(String(500), nullable=True, index=True) + + sender_email: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True) + sender_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + recipient_emails: Mapped[str | None] = mapped_column(Text, nullable=True) + cc_emails: Mapped[str | None] = mapped_column(Text, nullable=True) + subject: Mapped[str | None] = mapped_column(String(500), nullable=True, index=True) + body_text: Mapped[str | None] = mapped_column(Text, nullable=True) + body_html: Mapped[str | None] = mapped_column(Text, nullable=True) + raw_headers: Mapped[str | None] = mapped_column(Text, nullable=True) + received_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + + status: Mapped[str] = mapped_column(String(30), nullable=False, default="NEW", index=True) # NEW|MATCHED|PROCESSED|ERROR + matched_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + matched_client_id: Mapped[int | None] = mapped_column(ForeignKey("clients.id", ondelete="SET NULL"), nullable=True, index=True) + matched_consultant_id: Mapped[int | None] = mapped_column(ForeignKey("consultant_profiles.id", ondelete="SET NULL"), nullable=True, index=True) + related_module: Mapped[str | None] = mapped_column(String(80), nullable=True, index=True) + related_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) + + # Phase 7S.3 - email-to-work mapping. Tracking codes in the subject/body + # such as [AF-ENG-123], [AF-TASK-123] and [AF-INV-123] are resolved to + # the relevant engagement/task/invoice while still preserving the generic + # related_module/related_id fields for older screens and future modules. + tracking_code: Mapped[str | None] = mapped_column(String(80), nullable=True, index=True) + matched_engagement_id: Mapped[int | None] = mapped_column(ForeignKey("client_service_subscriptions.id", ondelete="SET NULL"), nullable=True, index=True) + matched_task_id: Mapped[int | None] = mapped_column(ForeignKey("client_service_task_instances.id", ondelete="SET NULL"), nullable=True, index=True) + matched_invoice_id: Mapped[int | None] = mapped_column(ForeignKey("billing_invoices.id", ondelete="SET NULL"), nullable=True, index=True) + mapping_status: Mapped[str] = mapped_column(String(30), nullable=False, default="UNMAPPED", index=True) # UNMAPPED|AUTO_MAPPED|MANUAL_MAPPED|NO_MATCH|ERROR + mapping_notes: Mapped[str | None] = mapped_column(Text, nullable=True) + mapped_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + + has_attachments: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True) + attachment_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + fetched_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False, index=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 EmailIncomingAttachment(CommonBase): + __tablename__ = "email_incoming_attachments" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + incoming_message_id: Mapped[int] = mapped_column(ForeignKey("email_incoming_messages.id", ondelete="CASCADE"), nullable=False, index=True) + tenant_id: Mapped[int | None] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True) + branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True) + + filename: Mapped[str | None] = mapped_column(String(255), nullable=True) + content_type: Mapped[str | None] = mapped_column(String(120), nullable=True) + size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + storage_path: Mapped[str | None] = mapped_column(String(1000), nullable=True) + created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) diff --git a/app/modules/email_integration/services.py b/app/modules/email_integration/services.py new file mode 100644 index 0000000..58537a9 --- /dev/null +++ b/app/modules/email_integration/services.py @@ -0,0 +1,767 @@ +from __future__ import annotations + +import mimetypes +import re +import smtplib +from pathlib import Path +from datetime import datetime, timezone, timedelta +from email.message import EmailMessage +from typing import Any + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.modules.email_integration.models import EmailLog, EmailSetting, EmailTemplate +from app.modules.email_integration.attachment_service import EmailAttachment +from app.modules.core.iam.models import User +from app.core.settings import get_settings + + +DEFAULT_TEMPLATES: dict[str, dict[str, str]] = { + "AUTH_LOGIN_OTP": { + "name": "Login OTP", + "subject": "Your OTP for {{ firm_name }} ERP login", + "body": "Dear {{ user_name }},\n\nYour OTP for logging into {{ firm_name }} ERP is:\n\n{{ otp_code }}\n\nThis OTP is valid for {{ expiry_minutes }} minutes. Do not share it with anyone.\n\nIf you did not request this login, please contact your firm administrator immediately.\n\nRegards,\n{{ firm_name }}", + }, + "AUTH_PASSWORD_RESET": { + "name": "Password Reset OTP", + "subject": "Password reset OTP for {{ firm_name }} ERP", + "body": "Dear {{ user_name }},\n\nWe received a request to reset your password for {{ firm_name }} ERP.\n\nYour password reset OTP is:\n\n{{ otp_code }}\n\nThis OTP is valid for {{ expiry_minutes }} minutes. If you did not request this reset, please contact your firm administrator immediately.\n\nRegards,\n{{ firm_name }}", + }, + "AUTH_PASSWORD_CHANGE": { + "name": "Password Change OTP", + "subject": "Confirm password change for {{ firm_name }} ERP", + "body": "Dear {{ user_name }},\n\nYour OTP to confirm password change for {{ firm_name }} ERP is:\n\n{{ otp_code }}\n\nThis OTP is valid for {{ expiry_minutes }} minutes. If you did not request this change, please contact your firm administrator immediately.\n\nRegards,\n{{ firm_name }}", + }, + "AUTH_PASSWORD_RESET_OTP": { + "name": "Password Reset OTP Alias", + "subject": "Password reset OTP for {{ firm_name }} ERP", + "body": "Dear {{ user_name }},\n\nYour password reset OTP for {{ firm_name }} ERP is:\n\n{{ otp_code }}\n\nPlease use this OTP to continue the password reset process.\n\nRegards,\n{{ firm_name }}", + }, + "AUTH_PASSWORD_CHANGE_OTP": { + "name": "Password Change OTP Alias", + "subject": "Confirm password change for {{ firm_name }} ERP", + "body": "Dear {{ user_name }},\n\nYour OTP to confirm password change for {{ firm_name }} ERP is:\n\n{{ otp_code }}\n\nRegards,\n{{ firm_name }}", + }, + "AUTH_PASSWORD_RESET_LINK": { + "name": "Password Reset Link", + "subject": "Password reset request for {{ firm_name }} ERP", + "body": "Dear {{ user_name }},\n\nWe received a request to reset your password for {{ firm_name }} ERP.\n\nClick the link below to reset your password:\n\n{{ reset_link }}\n\nThis link will expire in {{ expiry_hours }} hours. If you did not request this reset, please ignore this email or contact your firm administrator.\n\nRegards,\n{{ firm_name }}", + }, + "AUTH_USER_INVITE": { + "name": "User Invite", + "subject": "You are invited to {{ firm_name }} ERP", + "body": "Dear {{ user_name }},\n\nYou have been invited to access {{ firm_name }} ERP.\n\nPlease click the link below to set your password and activate your account:\n\n{{ invite_link }}\n\nThis invite link will expire in {{ expiry_hours }} hours. If you were not expecting this invite, please contact the firm administrator.\n\nRegards,\n{{ firm_name }}", + }, + "AUTH_PASSWORD_CHANGED": { + "name": "Password Changed", + "subject": "Your {{ firm_name }} ERP password was changed", + "body": "Dear {{ user_name }},\n\nYour password for {{ firm_name }} ERP was changed successfully on {{ changed_at }}.\n\nIf this change was not done by you, please contact your firm administrator immediately.\n\nRegards,\n{{ firm_name }}", + }, + + "TASK_ASSIGNED": { + "name": "Task Assigned", + "subject": "Task assigned: {{ task_title }}", + "body": "Dear {{ user_name }},\n\nA task has been assigned to you.\n\nClient: {{ client_name }}\nService: {{ service_name }}\nEngagement: {{ engagement_code }}\nTask: {{ task_title }}\nDue Date: {{ due_date }}\n\nPlease login to the ERP and update the task status.\n\n{{ action_url }}\n\nRegards,\n{{ firm_name }}", + }, + "TASK_DUE_TODAY": { + "name": "Task Due Today", + "subject": "Task due today: {{ task_title }}", + "body": "Dear {{ user_name }},\n\nThe following task is due today.\n\nClient: {{ client_name }}\nService: {{ service_name }}\nTask: {{ task_title }}\nDue Date: {{ due_date }}\n\nPlease complete or update the status in the ERP.\n\n{{ action_url }}\n\nRegards,\n{{ firm_name }}", + }, + "TASK_OVERDUE": { + "name": "Task Overdue", + "subject": "Overdue task: {{ task_title }}", + "body": "Dear {{ user_name }},\n\nThe following task is overdue.\n\nClient: {{ client_name }}\nService: {{ service_name }}\nEngagement: {{ engagement_code }}\nTask: {{ task_title }}\nDue Date: {{ due_date }}\n\nPlease update the status immediately.\n\n{{ action_url }}\n\nRegards,\n{{ firm_name }}", + }, + "TASK_STATUS_UPDATED": { + "name": "Task Status Updated", + "subject": "Task status updated: {{ task_title }}", + "body": "Dear {{ recipient_name }},\n\nThe task status has been updated.\n\nClient: {{ client_name }}\nTask: {{ task_title }}\nOld Status: {{ old_status }}\nNew Status: {{ new_status }}\nUpdated By: {{ updated_by }}\n\n{{ action_url }}\n\nRegards,\n{{ firm_name }}", + }, + + "CLIENT_DOCUMENT_REQUEST": { + "name": "Client Document Request", + "subject": "Documents required - {{ service_name }} - {{ firm_name }}", + "body": "Dear {{ client_name }},\n\nWe request you to provide the following documents for {{ service_name }}.\n\n{{ document_list }}\n\nReference: {{ reference_code }}\nDue Date: {{ due_date }}\n\nYou may upload the documents through the client portal.\n\n{{ action_url }}\n\nRegards,\n{{ firm_name }}", + }, + "CLIENT_CLARIFICATION_REQUEST": { + "name": "Client Clarification Request", + "subject": "Clarification required - {{ service_name }} - {{ firm_name }}", + "body": "Dear {{ client_name }},\n\nWe require your clarification for the following matter.\n\nService: {{ service_name }}\nReference: {{ reference_code }}\nClarification Required: {{ clarification_text }}\n\nPlease reply through the client portal or contact your auditor.\n\n{{ action_url }}\n\nRegards,\n{{ firm_name }}", + }, + "CLIENT_DOCUMENT_RECEIVED": { + "name": "Client Document Received", + "subject": "Document received - {{ client_name }}", + "body": "Dear {{ recipient_name }},\n\nA document has been received from the client.\n\nClient: {{ client_name }}\nDocument: {{ document_name }}\nService: {{ service_name }}\nUploaded By: {{ uploaded_by }}\n\nPlease review it in the ERP.\n\n{{ action_url }}\n\nRegards,\n{{ firm_name }}", + }, + + "PARTNER_REVIEW_REQUIRED": { + "name": "Partner Review Required", + "subject": "Review required: {{ work_title }}", + "body": "Dear {{ partner_name }},\n\nThe following work is pending for your review.\n\nClient: {{ client_name }}\nService: {{ service_name }}\nEngagement: {{ engagement_code }}\nWork: {{ work_title }}\nDue Date: {{ due_date }}\n\nPlease review and approve or send for rework.\n\n{{ action_url }}\n\nRegards,\n{{ firm_name }}", + }, + "PARTNER_REWORK_ASSIGNED": { + "name": "Partner Rework Assigned", + "subject": "Rework assigned: {{ work_title }}", + "body": "Dear {{ user_name }},\n\nThe partner has requested rework on the following item.\n\nClient: {{ client_name }}\nService: {{ service_name }}\nWork: {{ work_title }}\nReview Note: {{ review_note }}\n\nPlease update the work and resubmit for review.\n\n{{ action_url }}\n\nRegards,\n{{ firm_name }}", + }, + + "CONSULTANT_ASSIGNMENT": { + "name": "Consultant Assignment", + "subject": "Assignment from {{ firm_name }}: {{ assignment_title }}", + "body": "Dear {{ consultant_name }},\n\nYou have been assigned the following work.\n\nClient: {{ client_name }}\nService: {{ service_name }}\nAssignment: {{ assignment_title }}\nDue Date: {{ due_date }}\n\nPlease login to the consultant portal to view details and submit updates.\n\n{{ action_url }}\n\nRegards,\n{{ firm_name }}", + }, + "CONSULTANT_CLARIFICATION_REQUEST": { + "name": "Consultant Clarification Request", + "subject": "Clarification required: {{ assignment_title }}", + "body": "Dear {{ consultant_name }},\n\nWe require clarification on the following consultant assignment.\n\nClient: {{ client_name }}\nAssignment: {{ assignment_title }}\nClarification Required: {{ clarification_text }}\n\nPlease reply through the consultant portal.\n\n{{ action_url }}\n\nRegards,\n{{ firm_name }}", + }, + "CONSULTANT_SUBMISSION_RECEIVED": { + "name": "Consultant Submission Received", + "subject": "Consultant submission received - {{ client_name }}", + "body": "Dear {{ recipient_name }},\n\nA consultant submission has been received.\n\nConsultant: {{ consultant_name }}\nClient: {{ client_name }}\nAssignment: {{ assignment_title }}\nSubmitted On: {{ submitted_at }}\n\nPlease review it in the ERP.\n\n{{ action_url }}\n\nRegards,\n{{ firm_name }}", + }, + + "INVOICE_GENERATED": { + "name": "Invoice Generated", + "subject": "Invoice {{ invoice_number }} from {{ firm_name }}", + "body": "Dear {{ client_name }},\n\nInvoice {{ invoice_number }} has been generated by {{ firm_name }}.\n\nInvoice Amount: {{ invoice_amount }}\nDue Date: {{ due_date }}\n\nYou may view or pay the invoice using the link below.\n\n{{ payment_link }}\n\nRegards,\n{{ firm_name }}", + }, + "PAYMENT_REMINDER": { + "name": "Payment Reminder", + "subject": "Payment reminder for invoice {{ invoice_number }}", + "body": "Dear {{ client_name }},\n\nThis is a gentle reminder that payment is pending against the following invoice.\n\nInvoice Number: {{ invoice_number }}\nInvoice Amount: {{ invoice_amount }}\nOutstanding Amount: {{ outstanding_amount }}\nDue Date: {{ due_date }}\n\nPayment Link: {{ payment_link }}\n\nIf payment has already been made, please share the payment details with us.\n\nRegards,\n{{ firm_name }}", + }, + "PAYMENT_RECEIVED_RECEIPT": { + "name": "Payment Received Receipt", + "subject": "Payment received for invoice {{ invoice_number }}", + "body": "Dear {{ client_name }},\n\nWe acknowledge receipt of your payment.\n\nInvoice Number: {{ invoice_number }}\nReceipt Number: {{ receipt_number }}\nAmount Received: {{ payment_amount }}\nPayment Date: {{ payment_date }}\nMode: {{ payment_mode }}\n\nThank you.\n\nRegards,\n{{ firm_name }}", + }, + "ONLINE_PAYMENT_SUCCESS": { + "name": "Online Payment Success", + "subject": "Online payment successful - {{ invoice_number }}", + "body": "Dear {{ client_name }},\n\nYour online payment has been successfully received.\n\nInvoice Number: {{ invoice_number }}\nAmount Paid: {{ payment_amount }}\nGateway: {{ gateway_name }}\nTransaction Reference: {{ transaction_reference }}\n\nReceipt Number: {{ receipt_number }}\n\nRegards,\n{{ firm_name }}", + }, + "ONLINE_PAYMENT_FAILED": { + "name": "Online Payment Failed", + "subject": "Online payment failed - {{ invoice_number }}", + "body": "Dear {{ client_name }},\n\nYour online payment attempt could not be completed.\n\nInvoice Number: {{ invoice_number }}\nAmount: {{ invoice_amount }}\nGateway: {{ gateway_name }}\nReason: {{ failure_reason }}\n\nPlease try again using the payment link below or contact us for assistance.\n\n{{ payment_link }}\n\nRegards,\n{{ firm_name }}", + }, + + "LEAVE_REQUEST_SUBMITTED": { + "name": "Leave Request Submitted", + "subject": "Leave request submitted by {{ employee_name }}", + "body": "Dear {{ manager_name }},\n\nA leave request has been submitted.\n\nEmployee: {{ employee_name }}\nLeave Type: {{ leave_type }}\nFrom: {{ from_date }}\nTo: {{ to_date }}\nReason: {{ reason }}\n\nPlease review it in the ERP.\n\n{{ action_url }}\n\nRegards,\n{{ firm_name }}", + }, + "LEAVE_APPROVED": { + "name": "Leave Approved", + "subject": "Leave approved - {{ firm_name }}", + "body": "Dear {{ employee_name }},\n\nYour leave request has been approved.\n\nLeave Type: {{ leave_type }}\nFrom: {{ from_date }}\nTo: {{ to_date }}\nApproved By: {{ approved_by }}\n\nRegards,\n{{ firm_name }}", + }, + "LEAVE_REJECTED": { + "name": "Leave Rejected", + "subject": "Leave request update - {{ firm_name }}", + "body": "Dear {{ employee_name }},\n\nYour leave request has been reviewed and rejected.\n\nLeave Type: {{ leave_type }}\nFrom: {{ from_date }}\nTo: {{ to_date }}\nReason/Remarks: {{ remarks }}\n\nRegards,\n{{ firm_name }}", + }, + "ATTENDANCE_PUNCH_MISSING": { + "name": "Attendance Punch Missing", + "subject": "Attendance punch missing - {{ attendance_date }}", + "body": "Dear {{ employee_name }},\n\nYour attendance record appears incomplete.\n\nDate: {{ attendance_date }}\nMissing Punch: {{ missing_punch }}\n\nPlease regularise or contact your manager.\n\n{{ action_url }}\n\nRegards,\n{{ firm_name }}", + }, + + "CLIENT_PORTAL_WELCOME": { + "name": "Client Portal Welcome", + "subject": "Welcome to {{ firm_name }} Client Portal", + "body": "Dear {{ client_name }},\n\nWelcome to the {{ firm_name }} client portal.\n\nYou can use the portal to view compliance status, upload documents, reply to clarifications, view invoices and download receipts.\n\nLogin URL: {{ login_url }}\n\nRegards,\n{{ firm_name }}", + }, + "CONSULTANT_LEAD_FORWARDED": { + "name": "Consultant Lead Forwarded", + "subject": "New lead referred by {{ consultant_name }}", + "body": "Dear {{ recipient_name }},\n\nA consultant has forwarded a new lead to the firm.\n\nConsultant: {{ consultant_name }}\nClient/Lead: {{ client_name }}\nService Required: {{ service_name }}\nContact: {{ client_contact }}\n\nPlease review and convert the lead if suitable.\n\n{{ action_url }}\n\nRegards,\n{{ firm_name }}", + }, +} +_TOKEN_RE = re.compile(r"{{\s*([a-zA-Z0-9_\.]+)\s*}}") + + +def _clean(value: Any) -> str: + return "" if value is None else str(value) + + +def render_template_text(template_text: str, context: dict[str, Any]) -> str: + def repl(match: re.Match[str]) -> str: + key = match.group(1) + return _clean(context.get(key, "")) + return _TOKEN_RE.sub(repl, template_text or "") + + +def seed_default_email_templates(db: Session, tenant_id: int | None = None, branch_id: int | None = None) -> None: + """Seed default templates for one tenant/branch scope without duplicate inserts. + + This function may be called more than once in the same request, for example + when Email Settings creates the default settings row and the page also + refreshes the template list. SQLAlchemy pending objects are not always + visible to the later SELECT in a way that prevents duplicate INSERTs before + commit, so we explicitly check both database rows and pending session rows. + """ + q = select(EmailTemplate.template_code).where(EmailTemplate.tenant_id == tenant_id) + q = q.where(EmailTemplate.branch_id.is_(None)) if branch_id is None else q.where(EmailTemplate.branch_id == branch_id) + existing_codes = set(db.execute(q).scalars().all()) + + for obj in list(db.new): + if not isinstance(obj, EmailTemplate): + continue + if obj.tenant_id == tenant_id and obj.branch_id == branch_id and obj.template_code: + existing_codes.add(obj.template_code) + + for code, payload in DEFAULT_TEMPLATES.items(): + if code in existing_codes: + continue + db.add( + EmailTemplate( + tenant_id=tenant_id, + branch_id=branch_id, + template_code=code, + template_name=payload["name"], + subject_template=payload["subject"], + body_template=payload["body"], + is_html=False, + is_active=True, + ) + ) + existing_codes.add(code) + + +def get_email_setting(db: Session, tenant_id: int | None, branch_id: int | None) -> EmailSetting | None: + if tenant_id is None: + return None + if branch_id is not None: + row = db.execute( + select(EmailSetting).where(EmailSetting.tenant_id == tenant_id, EmailSetting.branch_id == branch_id) + ).scalar_one_or_none() + if row: + return row + return db.execute( + select(EmailSetting).where(EmailSetting.tenant_id == tenant_id, EmailSetting.branch_id.is_(None)) + ).scalar_one_or_none() + + +def get_or_create_email_setting(db: Session, tenant_id: int, branch_id: int | None, actor_user_id: int | None = None) -> EmailSetting: + row = db.execute( + select(EmailSetting).where(EmailSetting.tenant_id == tenant_id, EmailSetting.branch_id == branch_id) + ).scalar_one_or_none() + if row: + return row + row = EmailSetting( + tenant_id=tenant_id, + branch_id=branch_id, + smtp_host="smtp.hostinger.com", + smtp_port=465, + smtp_security="SSL", + imap_host="imap.hostinger.com", + imap_port=993, + imap_security="SSL", + created_by_user_id=actor_user_id, + updated_by_user_id=actor_user_id, + ) + db.add(row) + db.flush() + seed_default_email_templates(db, tenant_id=tenant_id, branch_id=branch_id) + return row + + +def _get_template(db: Session, tenant_id: int | None, branch_id: int | None, template_code: str) -> EmailTemplate | None: + scopes = [] + if tenant_id is not None and branch_id is not None: + scopes.append((tenant_id, branch_id)) + if tenant_id is not None: + scopes.append((tenant_id, None)) + scopes.append((None, None)) + for t_id, b_id in scopes: + q = select(EmailTemplate).where( + EmailTemplate.tenant_id == t_id, + EmailTemplate.template_code == template_code, + EmailTemplate.is_active.is_(True), + ) + q = q.where(EmailTemplate.branch_id == b_id) if b_id is not None else q.where(EmailTemplate.branch_id.is_(None)) + row = db.execute(q).scalar_one_or_none() + if row: + return row + defaults = DEFAULT_TEMPLATES.get(template_code) + if not defaults: + return None + return EmailTemplate( + tenant_id=tenant_id, + branch_id=branch_id, + template_code=template_code, + template_name=defaults["name"], + subject_template=defaults["subject"], + body_template=defaults["body"], + is_html=False, + is_active=True, + ) + + +def _firm_name(db: Session, tenant_id: int | None) -> str: + try: + from app.modules.core.tenancy.models import Tenant + tenant = db.execute(select(Tenant).where(Tenant.id == tenant_id)).scalar_one_or_none() if tenant_id else None + return getattr(tenant, "display_name", None) or getattr(tenant, "name", None) or "Audit Firm" + except Exception: + return "Audit Firm" + + +def _normalise_attachment(raw: EmailAttachment | str | Path) -> EmailAttachment: + if isinstance(raw, EmailAttachment): + return raw + path = Path(raw) + if not path.exists() or not path.is_file(): + raise FileNotFoundError(f"Email attachment not found: {path}") + content_type = mimetypes.guess_type(str(path))[0] or "application/octet-stream" + return EmailAttachment(filename=path.name, content=path.read_bytes(), content_type=content_type) + + +def _send_smtp( + setting: EmailSetting, + recipient_email: str, + subject: str, + body: str, + is_html: bool = False, + attachments: list[EmailAttachment | str | Path] | None = None, +) -> None: + host = (setting.smtp_host or "").strip() + port = int(setting.smtp_port or 0) + username = (setting.smtp_username or "").strip() + password = setting.smtp_password or "" + from_email = (setting.from_email or username or "").strip() + from_name = (setting.from_name or "Audit Firm ERP").strip() + reply_to = (setting.reply_to_email or from_email).strip() + security = (setting.smtp_security or "SSL").upper() + + if not host or not port or not from_email: + raise ValueError("SMTP host, port and from email are required.") + + msg = EmailMessage() + msg["Subject"] = subject + msg["From"] = f"{from_name} <{from_email}>" + msg["To"] = recipient_email + if reply_to: + msg["Reply-To"] = reply_to + if is_html: + msg.set_content("This email requires an HTML compatible email client.") + msg.add_alternative(body, subtype="html") + else: + msg.set_content(body) + + total_attachment_bytes = 0 + for raw_attachment in attachments or []: + attachment = _normalise_attachment(raw_attachment) + content = attachment.content or b"" + total_attachment_bytes += len(content) + if total_attachment_bytes > 10 * 1024 * 1024: + raise ValueError("Total email attachment size exceeds 10 MB safe limit.") + maintype, subtype = (attachment.content_type or "application/octet-stream").split("/", 1) + msg.add_attachment( + content, + maintype=maintype, + subtype=subtype, + filename=attachment.filename or "attachment", + ) + + timeout = int(setting.smtp_timeout_seconds or 20) + if security == "SSL": + with smtplib.SMTP_SSL(host, port, timeout=timeout) as smtp: + if username: + smtp.login(username, password) + smtp.send_message(msg) + else: + with smtplib.SMTP(host, port, timeout=timeout) as smtp: + if security == "STARTTLS": + smtp.starttls() + if username: + smtp.login(username, password) + smtp.send_message(msg) + + +def is_template_allowed_by_preferences(setting: EmailSetting, template_code: str) -> tuple[bool, str | None]: + """Return whether a template may be sent under firm/branch preferences. + + Phase 7S.1C keeps the switches firm-level so the same SMTP account can be + used while selectively enabling/disabling modules. This helper is defensive + with getattr() so older databases/files do not break during staged upgrades. + """ + code = (template_code or "").upper().strip() + + if code.startswith("AUTH_") and not getattr(setting, "send_auth_emails", False): + return False, "Authentication emails disabled in email preferences." + + if code.startswith("TASK_") and not getattr(setting, "send_task_emails", False): + return False, "Task/work emails disabled in email preferences." + + if code in {"INVOICE_GENERATED", "PAYMENT_REMINDER"}: + if not getattr(setting, "send_billing_emails", False): + return False, "Billing emails disabled in email preferences." + if not getattr(setting, "send_invoice_emails", True): + return False, "Invoice/reminder emails disabled in email preferences." + + if code in {"PAYMENT_RECEIVED_RECEIPT", "ONLINE_PAYMENT_SUCCESS", "ONLINE_PAYMENT_FAILED"}: + if not getattr(setting, "send_billing_emails", False): + return False, "Billing emails disabled in email preferences." + if not getattr(setting, "send_payment_emails", True): + return False, "Payment/receipt emails disabled in email preferences." + if code.startswith("ONLINE_PAYMENT") and not getattr(setting, "send_online_payment_emails", True): + return False, "Online payment emails disabled in email preferences." + + if code.startswith("CLIENT_") and not getattr(setting, "send_client_emails", True): + return False, "Client emails disabled in email preferences." + + if "DOCUMENT" in code and not getattr(setting, "send_document_emails", True): + return False, "Document-request/receipt emails disabled in email preferences." + + if code.startswith("CONSULTANT_") and not getattr(setting, "send_consultant_emails", True): + return False, "Consultant emails disabled in email preferences." + + if code.startswith("PARTNER_") and not getattr(setting, "send_partner_review_emails", True): + return False, "Partner review emails disabled in email preferences." + + if code.startswith("LEAVE_") or code.startswith("ATTENDANCE_"): + if not getattr(setting, "send_leave_attendance_emails", True): + return False, "Leave/attendance emails disabled in email preferences." + + return True, None + + + + +def _retry_delay(attempt_count: int) -> timedelta: + """Small exponential backoff for SMTP failures. + + Attempt 1 -> 5 minutes, 2 -> 15 minutes, 3+ -> 60 minutes. + This keeps local development friendly while preventing repeated immediate + SMTP retries when credentials/server are wrong. + """ + if attempt_count <= 1: + return timedelta(minutes=5) + if attempt_count == 2: + return timedelta(minutes=15) + return timedelta(minutes=60) + + +def _attachments_for_log(db: Session, log: EmailLog) -> list[EmailAttachment | str | Path]: + """Regenerate known billing attachments during retry. + + We intentionally do not persist raw attachment bytes in the database. + For invoice/receipt emails, attachments are safely regenerated from the + related billing records. For all other templates, retries are sent without + attachments. + """ + try: + if not log.related_id: + return [] + if log.template_code == "INVOICE_GENERATED" and log.related_module == "billing_invoice": + from app.modules.billing.models import BillingInvoice + from app.modules.email_integration.attachment_service import invoice_attachment + invoice = db.execute(select(BillingInvoice).where(BillingInvoice.id == int(log.related_id))).scalar_one_or_none() + return [invoice_attachment(invoice, firm_name=_firm_name(db, log.tenant_id))] if invoice else [] + if log.template_code == "PAYMENT_RECEIVED_RECEIPT" and log.related_module == "billing_payment": + from app.modules.billing.models import BillingPayment + from app.modules.email_integration.attachment_service import receipt_attachment + payment = db.execute(select(BillingPayment).where(BillingPayment.id == int(log.related_id))).scalar_one_or_none() + return [receipt_attachment(payment, firm_name=_firm_name(db, log.tenant_id))] if payment else [] + except Exception: + return [] + return [] + + +def send_email_log_now( + db: Session, + log: EmailLog, + *, + attachments: list[EmailAttachment | str | Path] | None = None, + send_immediately: bool = True, + max_attempts: int = 3, + queue_priority: int = 100, +) -> EmailLog: + """Attempt to send one queued/pending email log and update retry metadata.""" + setting = get_email_setting(db, log.tenant_id, log.branch_id) + now = datetime.now(timezone.utc) + log.processing_started_at = now + log.last_attempt_at = now + log.attempt_count = int(getattr(log, "attempt_count", 0) or 0) + 1 + + if not setting or not setting.is_active: + log.status = "SKIPPED" + log.error_message = "Email settings not configured or inactive." + log.is_retryable = False + log.processing_started_at = None + db.flush() + return log + + try: + send_attachments = attachments if attachments is not None else _attachments_for_log(db, log) + _send_smtp( + setting, + log.recipient_email, + log.subject, + log.body or "", + is_html=False, + attachments=send_attachments, + ) + log.status = "SENT" + log.sent_at = datetime.now(timezone.utc) + log.error_message = None + log.next_retry_at = None + log.processing_started_at = None + log.is_retryable = False + except Exception as exc: + log.status = "FAILED" + log.error_message = str(exc) + log.processing_started_at = None + max_attempts = int(getattr(log, "max_attempts", 3) or 3) + if log.attempt_count < max_attempts and bool(getattr(log, "is_retryable", True)): + log.next_retry_at = datetime.now(timezone.utc) + _retry_delay(log.attempt_count) + log.is_retryable = True + else: + log.next_retry_at = None + log.is_retryable = False + db.flush() + return log + + +def process_pending_email_queue( + db: Session, + *, + tenant_id: int | None = None, + branch_id: int | None = None, + limit: int = 25, +) -> dict[str, int]: + """Send retryable pending/failed emails due for retry. + + This can be called manually from /email/queue/process and later from a + background scheduler/worker. It is intentionally conservative and does not + affect SKIPPED or permanently failed rows. + """ + now = datetime.now(timezone.utc) + q = select(EmailLog).where( + EmailLog.status.in_(["PENDING", "FAILED"]), + EmailLog.is_retryable.is_(True), + EmailLog.attempt_count < EmailLog.max_attempts, + ).where( + (EmailLog.next_retry_at.is_(None)) | (EmailLog.next_retry_at <= now) + ) + if tenant_id is not None: + q = q.where(EmailLog.tenant_id == tenant_id) + if branch_id is not None: + q = q.where(EmailLog.branch_id == branch_id) + rows = db.execute( + q.order_by(EmailLog.queue_priority.asc(), EmailLog.created_at_utc.asc()).limit(max(1, min(int(limit or 25), 100))) + ).scalars().all() + + result = {"processed": 0, "sent": 0, "failed": 0, "skipped": 0} + for row in rows: + send_email_log_now(db, row) + result["processed"] += 1 + if row.status == "SENT": + result["sent"] += 1 + elif row.status == "SKIPPED": + result["skipped"] += 1 + else: + result["failed"] += 1 + db.flush() + return result + +def send_template_email( + db: Session, + *, + tenant_id: int | None, + branch_id: int | None, + recipient_email: str, + template_code: str, + context: dict[str, Any] | None = None, + related_module: str | None = None, + related_id: int | None = None, + force_send: bool = False, + attachments: list[EmailAttachment | str | Path] | None = None, + send_immediately: bool = True, + max_attempts: int = 3, + queue_priority: int = 100, +) -> EmailLog: + context = dict(context or {}) + context.setdefault("firm_name", _firm_name(db, tenant_id)) + context.setdefault("support_email", "") + + template = _get_template(db, tenant_id, branch_id, template_code) + if not template: + subject = template_code + body = "" + is_html = False + else: + subject = render_template_text(template.subject_template, context) + body = render_template_text(template.body_template, context) + is_html = bool(template.is_html) + + log = EmailLog( + tenant_id=tenant_id, + branch_id=branch_id, + recipient_email=recipient_email, + subject=subject[:500], + body=body, + status="PENDING", + related_module=related_module, + related_id=related_id, + template_code=template_code, + queued_at=datetime.now(timezone.utc), + max_attempts=max(1, int(max_attempts or 3)), + queue_priority=int(queue_priority or 100), + is_retryable=True, + ) + db.add(log) + db.flush() + + setting = get_email_setting(db, tenant_id, branch_id) + if not setting or not setting.is_active: + log.status = "SKIPPED" + log.error_message = "Email settings not configured or inactive." + db.flush() + return log + + if not force_send: + allowed, reason = is_template_allowed_by_preferences(setting, template_code) + if not allowed: + log.status = "SKIPPED" + log.error_message = reason or "Email disabled in email preferences." + db.flush() + return log + + if not send_immediately: + db.flush() + return log + + # Immediate first attempt keeps OTP/test email behaviour familiar, while + # Phase 7S.1E retry metadata ensures SMTP failures can be retried later + # from the email queue page without blocking the business transaction. + send_email_log_now(db, log, attachments=attachments) + db.flush() + return log + + + +def _public_base_url() -> str: + base = (get_settings().ERP_PUBLIC_BASE_URL or "").strip().rstrip("/") + return base or "http://localhost:8000" + + +def _support_email_from_setting(db: Session, tenant_id: int | None, branch_id: int | None) -> str: + setting = get_email_setting(db, tenant_id, branch_id) + return (getattr(setting, "reply_to_email", None) or getattr(setting, "from_email", None) or "").strip() if setting else "" + + +def _user_display_name(user: User) -> str: + return getattr(user, "full_name", None) or str(getattr(user, "email", "User")) + + +def send_auth_otp_email(db: Session, *, user: User, otp_code: str, purpose: str) -> EmailLog | None: + code_map = { + "login": "AUTH_LOGIN_OTP", + "password_reset": "AUTH_PASSWORD_RESET", + "password_change": "AUTH_PASSWORD_CHANGE", + } + template_code = code_map.get(purpose, "AUTH_LOGIN_OTP") + if not getattr(user, "email", None): + return None + tenant_id = getattr(user, "tenant_id", None) + branch_id = getattr(user, "branch_id", None) + return send_template_email( + db, + tenant_id=tenant_id, + branch_id=branch_id, + recipient_email=str(user.email), + template_code=template_code, + context={ + "user_name": _user_display_name(user), + "user_email": str(user.email), + "otp_code": otp_code, + "expiry_minutes": "10", + "support_email": _support_email_from_setting(db, tenant_id, branch_id), + }, + related_module="auth", + related_id=int(user.id), + force_send=True, + queue_priority=10, + ) + + +def send_password_reset_link_email(db: Session, *, user: User, reset_token: str) -> EmailLog | None: + if not getattr(user, "email", None): + return None + tenant_id = getattr(user, "tenant_id", None) + branch_id = getattr(user, "branch_id", None) + reset_link = f"{_public_base_url()}/password-reset/accept?token={reset_token}" + return send_template_email( + db, + tenant_id=tenant_id, + branch_id=branch_id, + recipient_email=str(user.email), + template_code="AUTH_PASSWORD_RESET_LINK", + context={ + "user_name": _user_display_name(user), + "user_email": str(user.email), + "reset_link": reset_link, + "expiry_hours": str(get_settings().PASSWORD_RESET_HOURS), + "support_email": _support_email_from_setting(db, tenant_id, branch_id), + }, + related_module="auth_password_reset", + related_id=int(user.id), + force_send=True, + queue_priority=10, + ) + + +def send_user_invite_email(db: Session, *, user: User, invite_token: str) -> EmailLog | None: + if not getattr(user, "email", None): + return None + tenant_id = getattr(user, "tenant_id", None) + branch_id = getattr(user, "branch_id", None) + invite_link = f"{_public_base_url()}/invite/accept?token={invite_token}" + return send_template_email( + db, + tenant_id=tenant_id, + branch_id=branch_id, + recipient_email=str(user.email), + template_code="AUTH_USER_INVITE", + context={ + "user_name": _user_display_name(user), + "user_email": str(user.email), + "invite_link": invite_link, + "expiry_hours": str(get_settings().INVITE_TOKEN_HOURS), + "support_email": _support_email_from_setting(db, tenant_id, branch_id), + }, + related_module="auth_invite", + related_id=int(user.id), + force_send=True, + queue_priority=10, + ) + + +def send_password_changed_email(db: Session, *, user: User) -> EmailLog | None: + if not getattr(user, "email", None): + return None + tenant_id = getattr(user, "tenant_id", None) + branch_id = getattr(user, "branch_id", None) + return send_template_email( + db, + tenant_id=tenant_id, + branch_id=branch_id, + recipient_email=str(user.email), + template_code="AUTH_PASSWORD_CHANGED", + context={ + "user_name": _user_display_name(user), + "user_email": str(user.email), + "changed_at": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"), + "support_email": _support_email_from_setting(db, tenant_id, branch_id), + }, + related_module="auth_password_changed", + related_id=int(user.id), + force_send=True, + queue_priority=20, + ) diff --git a/app/modules/email_integration/templates/email_integration/audit_dashboard.html b/app/modules/email_integration/templates/email_integration/audit_dashboard.html new file mode 100644 index 0000000..47df745 --- /dev/null +++ b/app/modules/email_integration/templates/email_integration/audit_dashboard.html @@ -0,0 +1,176 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+
+

Email Audit Dashboard

+

Monitor SMTP delivery, retry queue, incoming IMAP messages, template activity and email-to-work mapping health.

+
+ +
+
+ +
+
+
SMTP Status
+
+
{{ 'Ready' if smtp_configured else 'Pending' }}
+ {% if smtp_configured %} + Configured + {% else %} + Needs setup + {% endif %} +
+

{{ setting.smtp_host if setting and setting.smtp_host else 'SMTP host not configured' }}

+
+ +
+
Sent in 24 Hours
+
{{ summary.sent_24h }}
+

Last 7 days sent: {{ summary.sent_7d }}

+
+ +
+
Failed in 24 Hours
+
{{ summary.failed_24h }}
+

Last 7 days failed: {{ summary.failed_7d }}

+
+ +
+
Queue Pending
+
{{ summary.pending_now }}
+

Retryable: {{ summary.queue_retryable }} | Exhausted: {{ summary.queue_exhausted }}

+
+
+ +
+
+
IMAP Status
+
+
{{ 'Ready' if imap_configured else 'Pending' }}
+ {% if imap_configured %} + Configured + {% else %} + Needs setup + {% endif %} +
+

{{ setting.imap_host if setting and setting.imap_host else 'IMAP host not configured' }}

+
+ +
+
Incoming Emails
+
{{ summary.incoming_7d }}
+

Fetched in last 7 days

+
+ +
+
Unmapped Incoming
+
{{ summary.incoming_unmapped }}
+

Mapped: {{ summary.incoming_mapped }}

+
+ +
+
Templates
+
{{ summary.active_templates }}
+

Inactive: {{ summary.inactive_templates }}

+
+
+ +
+
+
+

7-Day Delivery Summary

+ Generated: {{ generated_at }} +
+
+ + + + {% for row in status_summary %} + + {% else %} + + {% endfor %} + +
StatusCount
{{ row.status }}{{ row.count }}
No email activity in the last 7 days.
+
+
+ +
+

Template Activity

+
+ + + + {% for row in template_summary_rows %} + + + + + + + + {% else %} + + {% endfor %} + +
TemplateSentFailedPendingTotal
{{ row.template_code }}{{ row.SENT }}{{ row.FAILED }}{{ row.PENDING }}{{ row.total }}
No template-wise activity found.
+
+
+
+ +
+
+

Recent Failed Emails

View logs
+
+ {% for log in recent_failed %} +
+
{{ log.recipient_email }}
+
{{ log.subject }}
+
{{ log.error_message or 'No error message stored.' }}
+
+ {% else %} +
No recent failed emails.
+ {% endfor %} +
+
+ +
+

Queue Attention

Process queue
+
+ {% for log in queue_due %} +
+
{{ log.status }}{{ log.attempt_count }}/{{ log.max_attempts }}
+
{{ log.recipient_email }}
+
{{ log.subject }}
+
+ {% else %} +
No pending retry items.
+ {% endfor %} +
+
+ +
+

Incoming Mapping Attention

Open inbox
+
+ {% for mail in incoming_attention %} + +
{{ mail.sender_email or '-' }}{{ mail.mapping_status }}
+
{{ mail.subject or '(No subject)' }}
+
Attachments: {{ mail.attachment_count }}
+
+ {% else %} +
No unmapped incoming emails needing attention.
+ {% endfor %} +
+
+
+
+{% endblock %} diff --git a/app/modules/email_integration/templates/email_integration/inbox.html b/app/modules/email_integration/templates/email_integration/inbox.html new file mode 100644 index 0000000..838917c --- /dev/null +++ b/app/modules/email_integration/templates/email_integration/inbox.html @@ -0,0 +1,107 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+
+

Incoming Emails

+

Fetch unread replies from the configured IMAP mailbox and map tracking codes to engagements, tasks or invoices.

+
+
+ Settings + Logs +
+
+ {% if flash %} +
{{ flash }}
+ {% endif %} +
+ +
+
+ +

Fetch from IMAP

+
+ + + + +
+ +
+ +
+ +

Map Existing Emails

+

Scans unmapped emails for tracking codes such as [AF-ENG-123], [AF-TASK-123] and [AF-INV-INV-001].

+ + +
+
+ +
+
+

Fetched Emails

+
+
+ + + + + + + + + + + + + + {% for row in incoming_rows %} + + + + + + + + + + {% else %} + + {% endfor %} + +
ReceivedFromSubjectSender MatchWork MappingAttachmentsAction
{{ row.received_at_utc or row.fetched_at_utc }}
{{ row.sender_name or '-' }}
{{ row.sender_email or '-' }}
{{ row.subject or '(No subject)' }} + {% if row.matched_client_id %}Client{% endif %} + {% if row.matched_consultant_id %}Consultant{% endif %} + {% if row.matched_user_id and not row.matched_client_id and not row.matched_consultant_id %}User{% endif %} + {% if not row.matched_client_id and not row.matched_consultant_id and not row.matched_user_id %}Unmatched{% endif %} + + {% if row.mapping_status in ['AUTO_MAPPED','MANUAL_MAPPED'] %} +
{{ row.mapping_status }}
+
{{ row.related_module or '-' }} #{{ row.related_id or '-' }}
+ {% elif row.mapping_status == 'ERROR' %} + Error + {% else %} + {{ row.mapping_status or 'UNMAPPED' }} + {% endif %} +
{{ row.attachment_count }}Open
No incoming emails fetched yet.
+
+
+
+{% endblock %} diff --git a/app/modules/email_integration/templates/email_integration/inbox_detail.html b/app/modules/email_integration/templates/email_integration/inbox_detail.html new file mode 100644 index 0000000..78fe9fb --- /dev/null +++ b/app/modules/email_integration/templates/email_integration/inbox_detail.html @@ -0,0 +1,110 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+
+ ← Back to inbox +

{{ row.subject or '(No subject)' }}

+

From {{ row.sender_name or row.sender_email or '-' }} <{{ row.sender_email or '-' }}>

+
+
+ {{ row.status }} + {{ row.mapping_status or 'UNMAPPED' }} +
+
+
+
Received: {{ row.received_at_utc or '-' }}
+
Mailbox: {{ row.mailbox_email }}
+
To: {{ row.recipient_emails or '-' }}
+
CC: {{ row.cc_emails or '-' }}
+
+
+ +
+
+

Message

+ {% if row.body_text %} +
{{ row.body_text }}
+ {% elif row.body_html %} +
HTML-only message stored. Full HTML body is available in database.
+ {% else %} +
No readable body found.
+ {% endif %} +
+ +
+
+

Matched Sender

+
+
Client ID: {{ row.matched_client_id or '-' }}
+
Consultant ID: {{ row.matched_consultant_id or '-' }}
+
User ID: {{ row.matched_user_id or '-' }}
+
+
+ +
+

Work Mapping

+
+
Tracking Code: {{ row.tracking_code or '-' }}
+
Related Module: {{ row.related_module or '-' }}
+
Related ID: {{ row.related_id or '-' }}
+
Engagement ID: {{ row.matched_engagement_id or '-' }}
+
Task ID: {{ row.matched_task_id or '-' }}
+
Invoice ID: {{ row.matched_invoice_id or '-' }}
+ {% if row.mapping_notes %}
{{ row.mapping_notes }}
{% endif %} +
+
+ +
+ +

Manual Mapping

+

Select only one. Task mapping also adds the email to the task communication timeline.

+ + + + +
+ +
+

Attachments

+
+ {% for item in attachments %} +
+
{{ item.filename or 'attachment' }}
+
{{ item.content_type or '-' }} • {{ item.size_bytes }} bytes
+
{{ item.storage_path or '' }}
+
+ {% else %} +

No attachments.

+ {% endfor %} +
+
+
+
+
+{% endblock %} diff --git a/app/modules/email_integration/templates/email_integration/logs.html b/app/modules/email_integration/templates/email_integration/logs.html new file mode 100644 index 0000000..e4b7f6c --- /dev/null +++ b/app/modules/email_integration/templates/email_integration/logs.html @@ -0,0 +1,7 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+

Email Logs

Last 100 outgoing email attempts.

Settings
+
{% for log in logs %}{% else %}{% endfor %}
CreatedSentToTemplateSubjectStatusError
{{ log.created_at_utc }}{{ log.sent_at or '-' }}{{ log.recipient_email }}{{ log.template_code or '-' }}{{ log.subject }}{{ log.status }}{{ log.error_message or '' }}
No email logs found.
+
+{% endblock %} diff --git a/app/modules/email_integration/templates/email_integration/queue.html b/app/modules/email_integration/templates/email_integration/queue.html new file mode 100644 index 0000000..6e15083 --- /dev/null +++ b/app/modules/email_integration/templates/email_integration/queue.html @@ -0,0 +1,60 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+
+

Email Queue & Retry

+

Pending and failed emails that can be retried without disturbing the original business transaction.

+
+
+ Settings + Logs +
+ + + +
+
+
+ {% if flash %} +
{{ flash }}
+ {% endif %} +
+ +
+
+ + + + + + + + + + + + + + + {% for log in queue_rows %} + + + + + + + + + + + {% else %} + + {% endfor %} + +
QueuedNext RetryAttemptsToTemplateSubjectStatusError
{{ log.queued_at or log.created_at_utc }}{{ log.next_retry_at or 'Ready' }}{{ log.attempt_count or 0 }} / {{ log.max_attempts or 3 }}{{ log.recipient_email }}{{ log.template_code or '-' }}{{ log.subject }}{{ log.status }}{{ log.error_message or '' }}
No pending or failed retryable emails.
+
+
+
+{% endblock %} diff --git a/app/modules/email_integration/templates/email_integration/settings.html b/app/modules/email_integration/templates/email_integration/settings.html new file mode 100644 index 0000000..156ca4f --- /dev/null +++ b/app/modules/email_integration/templates/email_integration/settings.html @@ -0,0 +1,125 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+
+

SMTP & IMAP Settings

+

Use Hostinger, Dovecot, Zoho, Gmail Workspace or any SMTP/IMAP provider. Password values are retained if left blank.

+
+ +
+
+ +
+ +
+

Outgoing SMTP

+
+ + + + + + + + +
+
+ +
+

Incoming IMAP

+

Use Hostinger IMAP, Dovecot IMAP or any standard IMAP mailbox. Incoming replies can be fetched from Email → Inbox.

+
+ + + + + +
+
+ +
+
+

Notification Preferences

+

Enable only the email categories your firm wants. In-app alerts and popup alerts will continue separately.

+
+ +
+ + + + + + + + + + + + + +
+
+
+
+ +
+

Send Test Email

+
+ + + +
+
+ +
+

Recent Email Logs

+
{% for log in recent_logs %}{% else %}{% endfor %}
TimeToSubjectStatusError
{{ log.created_at_utc }}{{ log.recipient_email }}{{ log.subject }}{{ log.status }}{{ log.error_message or '' }}
No emails logged yet.
+
+
+{% endblock %} diff --git a/app/modules/email_integration/templates/email_integration/templates.html b/app/modules/email_integration/templates/email_integration/templates.html new file mode 100644 index 0000000..85635b0 --- /dev/null +++ b/app/modules/email_integration/templates/email_integration/templates.html @@ -0,0 +1,19 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+

Email Templates

Use tokens like {{ '{{ firm_name }}' }}, {{ '{{ user_name }}' }}, {{ '{{ otp_code }}' }}, {{ '{{ reset_link }}' }}, {{ '{{ invite_link }}' }}, {{ '{{ expiry_minutes }}' }}, {{ '{{ expiry_hours }}' }} and {{ '{{ client_name }}' }}.

Settings
+
+ {% for tpl in templates_rows %} +
+ +

{{ tpl.template_name }}

{{ tpl.template_code }}
+ + +
+
+ {% else %} +
No templates found. Open Email Settings once to seed defaults.
+ {% endfor %} +
+{% endblock %} diff --git a/app/modules/email_integration/ui.py b/app/modules/email_integration/ui.py new file mode 100644 index 0000000..47fa559 --- /dev/null +++ b/app/modules/email_integration/ui.py @@ -0,0 +1,609 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +from fastapi import APIRouter, Form, Request +from fastapi.responses import RedirectResponse +from sqlalchemy import desc, func, or_, 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.core.rbac.deps import get_user_permissions, get_user_roles +from app.modules.email_integration.models import EmailIncomingAttachment, EmailIncomingMessage, EmailLog, EmailSetting, EmailTemplate +from app.modules.email_integration.imap_service import fetch_incoming_emails +from app.modules.email_integration.mapping_service import apply_email_mapping, auto_map_unmapped_emails +from app.modules.billing.models import BillingInvoice +from app.modules.services.models import ClientServiceSubscription, ClientServiceTaskInstance +from app.modules.email_integration.services import ( + DEFAULT_TEMPLATES, + get_or_create_email_setting, + seed_default_email_templates, + send_template_email, + process_pending_email_queue, +) + +router = APIRouter(prefix="/email", tags=["email-integration-ui"]) + + +def _redirect_login(): + return RedirectResponse(url="/login", status_code=303) + + +def _user_can_manage_email(db, user) -> bool: + roles = set(get_user_roles(db, int(user.id))) + perms = set(get_user_permissions(db, int(user.id))) + return "System Admin" in roles or "Firm Admin" in roles or "system.settings.manage" in perms or "system.settings.edit" in perms + + +def _scope_from_request(request: Request, user): + tenant_id = request.session.get("active_tenant_id") or getattr(user, "tenant_id", None) + branch_id = request.session.get("active_branch_id") or getattr(user, "branch_id", None) + if branch_id in ("", "0", 0): + branch_id = None + return (int(tenant_id) if tenant_id else None, int(branch_id) if branch_id else None) + + +def _ctx(request: Request, db, user, **extra): + ctx = { + "request": request, + "current_user": user, + "current_user_roles": get_user_roles(db, int(user.id)), + "current_user_permissions": get_user_permissions(db, int(user.id)), + "csrf_token": get_or_create_csrf_token(request), + } + ctx.update(extra) + return ctx + + + + +@router.get("/audit") +def email_audit_dashboard_page(request: Request): + """Operational dashboard for email health, queue, IMAP and mapping status. + + Phase 7S.4 intentionally uses the existing email_settings, email_logs, + email_templates and incoming email tables. No schema change is required. + """ + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return _redirect_login() + if not _user_can_manage_email(db, user): + return RedirectResponse(url="/employee/dashboard", status_code=303) + tenant_id, branch_id = _scope_from_request(request, user) + now = datetime.now(timezone.utc) + since_24h = now - timedelta(hours=24) + since_7d = now - timedelta(days=7) + + def _log_filters(extra=None): + filters = [EmailLog.tenant_id == tenant_id] + if branch_id: + filters.append(EmailLog.branch_id == branch_id) + if extra: + filters.extend(extra) + return filters + + def _incoming_filters(extra=None): + filters = [EmailIncomingMessage.tenant_id == tenant_id] + if branch_id: + filters.append(EmailIncomingMessage.branch_id == branch_id) + if extra: + filters.extend(extra) + return filters + + def _count(model_id, filters): + return int(db.execute(select(func.count(model_id)).where(*filters)).scalar() or 0) + + setting = db.execute( + select(EmailSetting).where(EmailSetting.tenant_id == tenant_id, EmailSetting.branch_id == branch_id) + ).scalar_one_or_none() + if not setting and branch_id: + setting = db.execute( + select(EmailSetting).where(EmailSetting.tenant_id == tenant_id, EmailSetting.branch_id.is_(None)) + ).scalar_one_or_none() + + summary = { + "sent_24h": _count(EmailLog.id, _log_filters([EmailLog.status == "SENT", EmailLog.created_at_utc >= since_24h])), + "failed_24h": _count(EmailLog.id, _log_filters([EmailLog.status == "FAILED", EmailLog.created_at_utc >= since_24h])), + "pending_now": _count(EmailLog.id, _log_filters([EmailLog.status == "PENDING"])), + "sent_7d": _count(EmailLog.id, _log_filters([EmailLog.status == "SENT", EmailLog.created_at_utc >= since_7d])), + "failed_7d": _count(EmailLog.id, _log_filters([EmailLog.status == "FAILED", EmailLog.created_at_utc >= since_7d])), + "skipped_7d": _count(EmailLog.id, _log_filters([EmailLog.status == "SKIPPED", EmailLog.created_at_utc >= since_7d])), + "queue_retryable": _count(EmailLog.id, _log_filters([EmailLog.status.in_(["PENDING", "FAILED"]), EmailLog.is_retryable.is_(True)])), + "queue_exhausted": _count(EmailLog.id, _log_filters([EmailLog.status == "FAILED", EmailLog.attempt_count >= EmailLog.max_attempts])), + "incoming_7d": _count(EmailIncomingMessage.id, _incoming_filters([EmailIncomingMessage.fetched_at_utc >= since_7d])), + "incoming_unmapped": _count(EmailIncomingMessage.id, _incoming_filters([EmailIncomingMessage.mapping_status.in_(["UNMAPPED", "NO_MATCH", "ERROR"])])), + "incoming_mapped": _count(EmailIncomingMessage.id, _incoming_filters([EmailIncomingMessage.mapping_status.in_(["AUTO_MAPPED", "MANUAL_MAPPED"])])), + "incoming_with_attachments": _count(EmailIncomingMessage.id, _incoming_filters([EmailIncomingMessage.has_attachments.is_(True)])), + "active_templates": _count(EmailTemplate.id, [EmailTemplate.tenant_id == tenant_id, EmailTemplate.branch_id == branch_id, EmailTemplate.is_active.is_(True)]), + "inactive_templates": _count(EmailTemplate.id, [EmailTemplate.tenant_id == tenant_id, EmailTemplate.branch_id == branch_id, EmailTemplate.is_active.is_(False)]), + } + + status_rows = db.execute( + select(EmailLog.status, func.count(EmailLog.id)) + .where(*_log_filters([EmailLog.created_at_utc >= since_7d])) + .group_by(EmailLog.status) + .order_by(EmailLog.status) + ).all() + status_summary = [{"status": row[0] or "UNKNOWN", "count": int(row[1] or 0)} for row in status_rows] + + template_rows = db.execute( + select(EmailLog.template_code, EmailLog.status, func.count(EmailLog.id)) + .where(*_log_filters([EmailLog.created_at_utc >= since_7d])) + .group_by(EmailLog.template_code, EmailLog.status) + .order_by(desc(func.count(EmailLog.id))) + .limit(30) + ).all() + template_summary = {} + for code, status, count in template_rows: + key = code or "MANUAL / NO TEMPLATE" + template_summary.setdefault(key, {"template_code": key, "SENT": 0, "FAILED": 0, "PENDING": 0, "SKIPPED": 0, "total": 0}) + normalized_status = status or "UNKNOWN" + template_summary[key][normalized_status] = int(count or 0) + template_summary[key]["total"] += int(count or 0) + template_summary_rows = sorted(template_summary.values(), key=lambda row: row["total"], reverse=True)[:12] + + recent_failed = db.execute( + select(EmailLog) + .where(*_log_filters([EmailLog.status == "FAILED"])) + .order_by(desc(EmailLog.created_at_utc)) + .limit(10) + ).scalars().all() + queue_due = db.execute( + select(EmailLog) + .where(*_log_filters([EmailLog.status.in_(["PENDING", "FAILED"]), EmailLog.is_retryable.is_(True)])) + .order_by(EmailLog.queue_priority.asc(), EmailLog.next_retry_at.asc().nullsfirst(), EmailLog.created_at_utc.asc()) + .limit(10) + ).scalars().all() + incoming_attention = db.execute( + select(EmailIncomingMessage) + .where(*_incoming_filters([EmailIncomingMessage.mapping_status.in_(["UNMAPPED", "NO_MATCH", "ERROR"])])) + .order_by(desc(EmailIncomingMessage.fetched_at_utc), desc(EmailIncomingMessage.id)) + .limit(10) + ).scalars().all() + + smtp_configured = bool(setting and setting.is_active and setting.smtp_host and setting.smtp_port and setting.from_email) + imap_configured = bool(setting and setting.imap_host and setting.imap_port and setting.imap_username) + + return templates.TemplateResponse( + "modules/email_integration/templates/email_integration/audit_dashboard.html", + _ctx( + request, db, user, + title="Email Audit Dashboard", + setting=setting, + summary=summary, + status_summary=status_summary, + template_summary_rows=template_summary_rows, + recent_failed=recent_failed, + queue_due=queue_due, + incoming_attention=incoming_attention, + smtp_configured=smtp_configured, + imap_configured=imap_configured, + generated_at=now, + ), + ) + finally: + db.close() + + +@router.get("/settings") +def email_settings_page(request: Request, flash: str | None = None): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return _redirect_login() + if not _user_can_manage_email(db, user): + return RedirectResponse(url="/employee/dashboard", status_code=303) + tenant_id, branch_id = _scope_from_request(request, user) + if not tenant_id: + return RedirectResponse(url="/system-settings", status_code=303) + setting = get_or_create_email_setting(db, tenant_id, branch_id, actor_user_id=int(user.id)) + # get_or_create_email_setting already seeds default templates for new settings. + # seed_default_email_templates is idempotent, but avoiding a second same-request + # call keeps the first load of /email/settings clean on SQLite. + db.commit() + recent_logs = db.execute( + select(EmailLog) + .where(EmailLog.tenant_id == tenant_id) + .order_by(desc(EmailLog.created_at_utc)) + .limit(10) + ).scalars().all() + return templates.TemplateResponse( + "modules/email_integration/templates/email_integration/settings.html", + _ctx(request, db, user, title="Email Settings", setting=setting, recent_logs=recent_logs, flash=flash), + ) + finally: + db.close() + + +@router.post("/settings") +def save_email_settings( + request: Request, + csrf_token: str = Form(...), + smtp_host: str = Form(""), + smtp_port: int = Form(465), + smtp_username: str = Form(""), + smtp_password: str = Form(""), + smtp_security: str = Form("SSL"), + from_email: str = Form(""), + from_name: str = Form(""), + reply_to_email: str = Form(""), + imap_host: str = Form(""), + imap_port: int = Form(993), + imap_username: str = Form(""), + imap_password: str = Form(""), + imap_security: str = Form("SSL"), + send_auth_emails: str | None = Form(None), + send_alert_emails: str | None = Form(None), + send_billing_emails: str | None = Form(None), + send_task_emails: str | None = Form(None), + send_invoice_emails: str | None = Form(None), + send_payment_emails: str | None = Form(None), + send_client_emails: str | None = Form(None), + send_document_emails: str | None = Form(None), + send_consultant_emails: str | None = Form(None), + send_partner_review_emails: str | None = Form(None), + send_leave_attendance_emails: str | None = Form(None), + send_online_payment_emails: str | None = Form(None), + is_active: str | None = Form(None), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return _redirect_login() + if not _user_can_manage_email(db, user): + return RedirectResponse(url="/employee/dashboard", status_code=303) + tenant_id, branch_id = _scope_from_request(request, user) + setting = get_or_create_email_setting(db, int(tenant_id), branch_id, actor_user_id=int(user.id)) + setting.smtp_host = smtp_host.strip() or None + setting.smtp_port = int(smtp_port or 465) + setting.smtp_username = smtp_username.strip() or None + if smtp_password.strip(): + setting.smtp_password = smtp_password.strip() + setting.smtp_security = (smtp_security or "SSL").upper() + setting.from_email = from_email.strip() or None + setting.from_name = from_name.strip() or None + setting.reply_to_email = reply_to_email.strip() or None + setting.imap_host = imap_host.strip() or None + setting.imap_port = int(imap_port or 993) + setting.imap_username = imap_username.strip() or None + if imap_password.strip(): + setting.imap_password = imap_password.strip() + setting.imap_security = (imap_security or "SSL").upper() + setting.send_auth_emails = bool(send_auth_emails) + setting.send_alert_emails = bool(send_alert_emails) + setting.send_billing_emails = bool(send_billing_emails) + setting.send_task_emails = bool(send_task_emails) + setting.send_invoice_emails = bool(send_invoice_emails) + setting.send_payment_emails = bool(send_payment_emails) + setting.send_client_emails = bool(send_client_emails) + setting.send_document_emails = bool(send_document_emails) + setting.send_consultant_emails = bool(send_consultant_emails) + setting.send_partner_review_emails = bool(send_partner_review_emails) + setting.send_leave_attendance_emails = bool(send_leave_attendance_emails) + setting.send_online_payment_emails = bool(send_online_payment_emails) + setting.is_active = bool(is_active) + setting.updated_by_user_id = int(user.id) + db.commit() + return RedirectResponse(url="/email/settings?flash=Email settings saved.", status_code=303) + finally: + db.close() + + +@router.post("/settings/test") +def test_email_settings(request: Request, csrf_token: str = Form(...), test_email: str = Form("")): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return _redirect_login() + if not _user_can_manage_email(db, user): + return RedirectResponse(url="/employee/dashboard", status_code=303) + tenant_id, branch_id = _scope_from_request(request, user) + recipient = (test_email or getattr(user, "email", "")).strip() + send_template_email( + db, + tenant_id=tenant_id, + branch_id=branch_id, + recipient_email=recipient, + template_code="AUTH_LOGIN_OTP", + context={"user_name": getattr(user, "full_name", None) or recipient, "otp_code": "123456"}, + related_module="email_settings_test", + related_id=int(user.id), + force_send=True, + ) + db.commit() + return RedirectResponse(url="/email/settings?flash=Test email attempted. Please check Email Logs for SENT/FAILED status.", status_code=303) + finally: + db.close() + + +@router.get("/logs") +def email_logs_page(request: Request): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return _redirect_login() + if not _user_can_manage_email(db, user): + return RedirectResponse(url="/employee/dashboard", status_code=303) + tenant_id, branch_id = _scope_from_request(request, user) + q = select(EmailLog).where(EmailLog.tenant_id == tenant_id) + if branch_id: + q = q.where(EmailLog.branch_id == branch_id) + logs = db.execute(q.order_by(desc(EmailLog.created_at_utc)).limit(100)).scalars().all() + return templates.TemplateResponse( + "modules/email_integration/templates/email_integration/logs.html", + _ctx(request, db, user, title="Email Logs", logs=logs), + ) + finally: + db.close() + + + +@router.get("/queue") +def email_queue_page(request: Request, flash: str | None = None): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return _redirect_login() + if not _user_can_manage_email(db, user): + return RedirectResponse(url="/employee/dashboard", status_code=303) + tenant_id, branch_id = _scope_from_request(request, user) + q = select(EmailLog).where(EmailLog.tenant_id == tenant_id, EmailLog.status.in_(["PENDING", "FAILED"])) + if branch_id: + q = q.where(EmailLog.branch_id == branch_id) + queue_rows = db.execute(q.order_by(EmailLog.queue_priority.asc(), EmailLog.created_at_utc.asc()).limit(100)).scalars().all() + return templates.TemplateResponse( + "modules/email_integration/templates/email_integration/queue.html", + _ctx(request, db, user, title="Email Queue", queue_rows=queue_rows, flash=flash), + ) + finally: + db.close() + + +@router.post("/queue/process") +def process_email_queue_page(request: Request, csrf_token: str = Form(...), limit: int = Form(25)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return _redirect_login() + if not _user_can_manage_email(db, user): + return RedirectResponse(url="/employee/dashboard", status_code=303) + tenant_id, branch_id = _scope_from_request(request, user) + result = process_pending_email_queue(db, tenant_id=tenant_id, branch_id=branch_id, limit=limit) + db.commit() + flash = f"Email queue processed: {result.get('processed', 0)} processed, {result.get('sent', 0)} sent, {result.get('failed', 0)} failed, {result.get('skipped', 0)} skipped." + return RedirectResponse(url=f"/email/queue?flash={flash}", status_code=303) + finally: + db.close() + + +@router.get("/inbox") +def email_inbox_page(request: Request, flash: str | None = None): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return _redirect_login() + if not _user_can_manage_email(db, user): + return RedirectResponse(url="/employee/dashboard", status_code=303) + tenant_id, branch_id = _scope_from_request(request, user) + q = select(EmailIncomingMessage).where(EmailIncomingMessage.tenant_id == tenant_id) + if branch_id: + q = q.where(EmailIncomingMessage.branch_id == branch_id) + incoming_rows = db.execute(q.order_by(desc(EmailIncomingMessage.received_at_utc), desc(EmailIncomingMessage.id)).limit(100)).scalars().all() + return templates.TemplateResponse( + "modules/email_integration/templates/email_integration/inbox.html", + _ctx(request, db, user, title="Incoming Emails", incoming_rows=incoming_rows, flash=flash), + ) + finally: + db.close() + + +@router.post("/inbox/fetch") +def fetch_email_inbox_page( + request: Request, + csrf_token: str = Form(...), + folder: str = Form("INBOX"), + limit: int = Form(25), + unread_only: str | None = Form("1"), + mark_seen: str | None = Form(None), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return _redirect_login() + if not _user_can_manage_email(db, user): + return RedirectResponse(url="/employee/dashboard", status_code=303) + tenant_id, branch_id = _scope_from_request(request, user) + setting = get_or_create_email_setting(db, int(tenant_id), branch_id, actor_user_id=int(user.id)) + result = fetch_incoming_emails( + db, + setting=setting, + tenant_id=tenant_id, + branch_id=branch_id, + folder=(folder or "INBOX").strip() or "INBOX", + unread_only=bool(unread_only), + limit=max(1, min(int(limit or 25), 100)), + mark_seen=bool(mark_seen), + ) + db.commit() + if result.error: + flash = f"IMAP fetch failed: {result.error}" + else: + flash = f"IMAP fetch completed: {result.imported} imported, {result.skipped_existing} skipped, {result.failed} failed." + return RedirectResponse(url=f"/email/inbox?flash={flash}", status_code=303) + finally: + db.close() + + +@router.get("/inbox/{message_id}") +def email_inbox_detail_page(request: Request, message_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return _redirect_login() + if not _user_can_manage_email(db, user): + return RedirectResponse(url="/employee/dashboard", status_code=303) + tenant_id, branch_id = _scope_from_request(request, user) + filters = [EmailIncomingMessage.id == message_id, EmailIncomingMessage.tenant_id == tenant_id] + if branch_id: + filters.append(EmailIncomingMessage.branch_id == branch_id) + row = db.execute(select(EmailIncomingMessage).where(*filters)).scalar_one_or_none() + if not row: + return RedirectResponse(url="/email/inbox", status_code=303) + attachments = db.execute( + select(EmailIncomingAttachment).where(EmailIncomingAttachment.incoming_message_id == row.id).order_by(EmailIncomingAttachment.id) + ).scalars().all() + scope_filters = [ClientServiceSubscription.tenant_id == tenant_id] + task_filters = [ClientServiceTaskInstance.tenant_id == tenant_id] + invoice_filters = [BillingInvoice.tenant_id == tenant_id] + if branch_id: + scope_filters.append(or_(ClientServiceSubscription.branch_id == branch_id, ClientServiceSubscription.branch_id.is_(None))) + task_filters.append(or_(ClientServiceTaskInstance.branch_id == branch_id, ClientServiceTaskInstance.branch_id.is_(None))) + invoice_filters.append(or_(BillingInvoice.branch_id == branch_id, BillingInvoice.branch_id.is_(None))) + if row.matched_client_id: + scope_filters.append(ClientServiceSubscription.client_id == row.matched_client_id) + task_filters.append(ClientServiceTaskInstance.client_id == row.matched_client_id) + invoice_filters.append(BillingInvoice.client_id == row.matched_client_id) + engagements = db.execute(select(ClientServiceSubscription).where(*scope_filters).order_by(desc(ClientServiceSubscription.updated_at_utc)).limit(50)).scalars().all() + tasks = db.execute(select(ClientServiceTaskInstance).where(*task_filters).order_by(desc(ClientServiceTaskInstance.updated_at_utc)).limit(50)).scalars().all() + invoices = db.execute(select(BillingInvoice).where(*invoice_filters).order_by(desc(BillingInvoice.invoice_date), desc(BillingInvoice.id)).limit(50)).scalars().all() + return templates.TemplateResponse( + "modules/email_integration/templates/email_integration/inbox_detail.html", + _ctx( + request, db, user, title="Incoming Email", row=row, attachments=attachments, + engagements=engagements, tasks=tasks, invoices=invoices, + ), + ) + finally: + db.close() + + +@router.post("/inbox/map-all") +def email_inbox_map_all(request: Request, csrf_token: str = Form(...), limit: int = Form(100)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return _redirect_login() + if not _user_can_manage_email(db, user): + return RedirectResponse(url="/employee/dashboard", status_code=303) + tenant_id, branch_id = _scope_from_request(request, user) + result = auto_map_unmapped_emails(db, tenant_id=tenant_id, branch_id=branch_id, limit=limit) + db.commit() + flash = f"Email mapping completed: {result.get('processed', 0)} processed, {result.get('mapped', 0)} mapped, {result.get('no_match', 0)} no match." + return RedirectResponse(url=f"/email/inbox?flash={flash}", status_code=303) + finally: + db.close() + + +@router.post("/inbox/{message_id}/map") +def email_inbox_manual_map_page( + request: Request, + message_id: int, + csrf_token: str = Form(...), + engagement_id: int | None = Form(None), + task_id: int | None = Form(None), + invoice_id: int | None = Form(None), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return _redirect_login() + if not _user_can_manage_email(db, user): + return RedirectResponse(url="/employee/dashboard", status_code=303) + tenant_id, branch_id = _scope_from_request(request, user) + filters = [EmailIncomingMessage.id == message_id, EmailIncomingMessage.tenant_id == tenant_id] + if branch_id: + filters.append(EmailIncomingMessage.branch_id == branch_id) + row = db.execute(select(EmailIncomingMessage).where(*filters)).scalar_one_or_none() + if row: + apply_email_mapping( + db, row, + engagement_id=engagement_id or None, + task_id=task_id or None, + invoice_id=invoice_id or None, + manual=True, + ) + db.commit() + return RedirectResponse(url=f"/email/inbox/{message_id}", status_code=303) + finally: + db.close() + + +@router.get("/templates") +def email_templates_page(request: Request): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return _redirect_login() + if not _user_can_manage_email(db, user): + return RedirectResponse(url="/employee/dashboard", status_code=303) + tenant_id, branch_id = _scope_from_request(request, user) + seed_default_email_templates(db, tenant_id=tenant_id, branch_id=branch_id) + db.commit() + templates_rows = db.execute( + select(EmailTemplate) + .where(EmailTemplate.tenant_id == tenant_id, EmailTemplate.branch_id == branch_id) + .order_by(EmailTemplate.template_name) + ).scalars().all() + return templates.TemplateResponse( + "modules/email_integration/templates/email_integration/templates.html", + _ctx(request, db, user, title="Email Templates", templates_rows=templates_rows, default_templates=DEFAULT_TEMPLATES), + ) + finally: + db.close() + + +@router.post("/templates/{template_id}") +def update_email_template( + request: Request, + template_id: int, + csrf_token: str = Form(...), + subject_template: str = Form(...), + body_template: str = Form(...), + is_active: str | None = Form(None), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return _redirect_login() + if not _user_can_manage_email(db, user): + return RedirectResponse(url="/employee/dashboard", status_code=303) + tenant_id, branch_id = _scope_from_request(request, user) + row = db.execute( + select(EmailTemplate).where( + EmailTemplate.id == template_id, + EmailTemplate.tenant_id == tenant_id, + EmailTemplate.branch_id == branch_id, + ) + ).scalar_one_or_none() + if row: + row.subject_template = subject_template + row.body_template = body_template + row.is_active = bool(is_active) + db.commit() + return RedirectResponse(url="/email/templates", status_code=303) + finally: + db.close() diff --git a/app/modules/employees/__init__.py b/app/modules/employees/__init__.py new file mode 100644 index 0000000..9c52959 --- /dev/null +++ b/app/modules/employees/__init__.py @@ -0,0 +1 @@ +"""Employee core module for Audit Firm v2.""" diff --git a/app/modules/employees/import_service.py b/app/modules/employees/import_service.py new file mode 100644 index 0000000..39b06aa --- /dev/null +++ b/app/modules/employees/import_service.py @@ -0,0 +1,543 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, datetime +from io import BytesIO +from typing import Any + +from fastapi import HTTPException +from openpyxl import Workbook, load_workbook +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.security.passwords import hash_password +from app.modules.core.iam.models import User +from app.modules.core.iam.scope import validate_branch_matches_tenant +from app.modules.core.rbac.models import Role, UserRole +from app.modules.employees.models import ( + Employee, + EmployeeLeaveBalance, + EmployeeLeaveType, + EmployeeSalaryStructure, +) +from app.modules.employees.service import EMPLOYEE_ROLE_NAMES, EMPLOYEE_STATUS, EMPLOYMENT_TYPES, EmployeeScope + + +IMPORT_TYPES = { + "employees": "Employees / Staff", + "leave_types": "Leave Types", + "leave_balances": "Leave Balances", + "salary_structures": "Salary Structures", +} + +TEMPLATE_HEADERS: dict[str, list[str]] = { + "employees": [ + "employee_code", "full_name", "email", "mobile", "alternate_mobile", "department", "designation", + "employment_type", "date_of_joining", "status", "pan", "uan", "esi_no", "pf_no", "aadhaar_last4", + "bank_name", "bank_account_no", "bank_ifsc", "address", "emergency_contact_name", + "emergency_contact_mobile", "branch_id", "reporting_manager_email", "create_user", "login_email", + "temporary_password", "employee_role", "notes", + ], + "leave_types": [ + "code", "name", "description", "annual_quota_days", "carry_forward_allowed", "allow_negative_balance", + "requires_approval", "is_paid", "is_active", "branch_id", + ], + "leave_balances": [ + "employee_code", "leave_code", "opening_days", "credited_days", "availed_days", "adjusted_days", "balance_days", "branch_id", + ], + "salary_structures": [ + "employee_code", "effective_from", "effective_to", "pay_cycle", "monthly_ctc_amount", "basic_amount", + "hra_amount", "allowance_amount", "employee_pf_amount", "employee_esi_amount", "professional_tax_amount", + "tds_amount", "other_deduction_amount", "is_active", "remarks", "branch_id", + ], +} + +SAMPLE_ROWS: dict[str, list[Any]] = { + "employees": [ + "EMP001", "Sample Staff", "staff@example.com", "9999999999", "", "Audit", "Associate", + "full_time", "2026-04-01", "active", "ABCDE1234F", "", "", "", "1234", "Bank", "1234567890", "IFSC0000001", + "Office address", "Emergency Contact", "9999999998", "", "", "no", "", "", "Staff", "Sample only - delete before import", + ], + "leave_types": ["CL", "Casual Leave", "Casual leave", 12, "yes", "no", "yes", "yes", "yes", ""], + "leave_balances": ["EMP001", "CL", 0, 12, 0, 0, 12, ""], + "salary_structures": ["EMP001", "2026-04-01", "", "monthly", 30000, 15000, 6000, 9000, 0, 0, 0, 0, 0, "yes", "Initial structure", ""], +} + + +@dataclass +class ImportRowResult: + row_no: int + status: str + action: str + data: dict[str, Any] + messages: list[str] + + def as_dict(self) -> dict[str, Any]: + return {"row_no": self.row_no, "status": self.status, "action": self.action, "data": self.data, "messages": self.messages} + + +def supported_import_types() -> dict[str, str]: + return IMPORT_TYPES.copy() + + +def normalize_import_type(import_type: str) -> str: + import_type = (import_type or "").strip().lower() + if import_type not in IMPORT_TYPES: + raise HTTPException(status_code=400, detail="Unsupported HR import type.") + return import_type + + +def _safe_excel_sheet_title(title: str) -> str: + """Return a valid Excel worksheet title for openpyxl.""" + invalid_chars = {"\\", "/", "?", "*", "[", "]", ":"} + safe_title = "".join("-" if ch in invalid_chars else ch for ch in (title or "Sheet")) + safe_title = safe_title.strip() or "Sheet" + return safe_title[:31] + + +def build_template_workbook(import_type: str) -> bytes: + import_type = normalize_import_type(import_type) + wb = Workbook() + ws = wb.active + ws.title = _safe_excel_sheet_title(IMPORT_TYPES[import_type]) + headers = TEMPLATE_HEADERS[import_type] + ws.append(headers) + ws.append(SAMPLE_ROWS[import_type]) + for col_no, header in enumerate(headers, start=1): + ws.cell(row=1, column=col_no).font = ws.cell(row=1, column=col_no).font.copy(bold=True) + ws.column_dimensions[ws.cell(row=1, column=col_no).column_letter].width = max(14, min(28, len(header) + 4)) + bio = BytesIO() + wb.save(bio) + return bio.getvalue() + + +def parse_workbook_rows(content: bytes) -> list[dict[str, Any]]: + try: + wb = load_workbook(BytesIO(content), data_only=True) + except Exception as exc: + raise HTTPException(status_code=400, detail=f"Unable to read Excel file: {exc}") + ws = wb.active + raw_headers = [str(cell.value or "").strip().lower() for cell in ws[1]] + headers = [h for h in raw_headers] + if not any(headers): + raise HTTPException(status_code=400, detail="Excel file has no header row.") + rows: list[dict[str, Any]] = [] + for row_no, row in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2): + if not any(cell not in (None, "") for cell in row): + continue + item = {headers[idx]: _cell_value(row[idx] if idx < len(row) else None) for idx in range(len(headers)) if headers[idx]} + item["_row_no"] = row_no + rows.append(item) + return rows + + +def preview_import(db: Session, scope: EmployeeScope, import_type: str, content: bytes) -> dict[str, Any]: + import_type = normalize_import_type(import_type) + rows = parse_workbook_rows(content) + results: list[ImportRowResult] = [] + for row in rows: + if import_type == "employees": + results.append(_preview_employee(db, scope, row)) + elif import_type == "leave_types": + results.append(_preview_leave_type(db, scope, row)) + elif import_type == "leave_balances": + results.append(_preview_leave_balance(db, scope, row)) + elif import_type == "salary_structures": + results.append(_preview_salary_structure(db, scope, row)) + valid = sum(1 for r in results if r.status == "valid") + warning = sum(1 for r in results if r.status == "warning") + error = sum(1 for r in results if r.status == "error") + return { + "import_type": import_type, + "import_label": IMPORT_TYPES[import_type], + "rows": [r.as_dict() for r in results], + "summary": {"total": len(results), "valid": valid, "warning": warning, "error": error}, + } + + +def commit_import(db: Session, actor: User, scope: EmployeeScope, preview: dict[str, Any]) -> dict[str, Any]: + import_type = normalize_import_type(preview.get("import_type")) + created = updated = skipped = failed = 0 + errors: list[str] = [] + for row in preview.get("rows", []): + if row.get("status") == "error": + skipped += 1 + continue + try: + action = row.get("action") or "create" + data = row.get("data") or {} + if import_type == "employees": + action = _commit_employee(db, actor, scope, data) + elif import_type == "leave_types": + action = _commit_leave_type(db, actor, scope, data) + elif import_type == "leave_balances": + action = _commit_leave_balance(db, actor, scope, data) + elif import_type == "salary_structures": + action = _commit_salary_structure(db, actor, scope, data) + if action == "updated": + updated += 1 + else: + created += 1 + except Exception as exc: + db.rollback() + failed += 1 + errors.append(f"Row {row.get('row_no')}: {getattr(exc, 'detail', str(exc))}") + return {"created": created, "updated": updated, "skipped": skipped, "failed": failed, "errors": errors} + + +def _cell_value(value: Any) -> Any: + if isinstance(value, datetime): + return value.date().isoformat() + if isinstance(value, date): + return value.isoformat() + if isinstance(value, str): + return value.strip() + return value + + +def _blank(value: Any) -> Any: + if value is None: + return None + if isinstance(value, str) and not value.strip(): + return None + return value.strip() if isinstance(value, str) else value + + +def _str(value: Any, default: str = "") -> str: + value = _blank(value) + return str(value).strip() if value is not None else default + + +def _lower(value: Any, default: str = "") -> str: + return _str(value, default).lower() + + +def _upper(value: Any, default: str = "") -> str: + return _str(value, default).upper() + + +def _bool(value: Any, default: bool = False) -> bool: + value = _blank(value) + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + return str(value).strip().lower() in {"1", "true", "yes", "y", "on", "active"} + + +def _int(value: Any, default: int = 0) -> int: + value = _blank(value) + if value is None: + return default + try: + return int(round(float(value))) + except Exception: + raise ValueError(f"Invalid integer/amount value: {value}") + + +def _date(value: Any) -> date | None: + value = _blank(value) + if not value: + return None + if isinstance(value, date): + return value + text = str(value).strip() + for fmt in ("%Y-%m-%d", "%d-%m-%Y", "%d/%m/%Y", "%m/%d/%Y"): + try: + return datetime.strptime(text, fmt).date() + except ValueError: + pass + return date.fromisoformat(text) + + +def _branch_id(scope: EmployeeScope, row: dict[str, Any]) -> int: + raw = _blank(row.get("branch_id")) + if raw is not None: + branch_id = int(raw) + elif scope.branch_id is not None: + branch_id = int(scope.branch_id) + else: + raise ValueError("Branch is required. Select active branch or provide branch_id in Excel.") + validate_branch_matches_tenant(_CURRENT_DB.get(), scope.tenant_id, branch_id) + return branch_id + + +class _DbHolder: + def __init__(self): + self.db = None + def set(self, db): + self.db = db + def get(self): + return self.db + +_CURRENT_DB = _DbHolder() + + +def _employee_by_code(db: Session, tenant_id: int, code: str) -> Employee | None: + return db.execute(select(Employee).where(Employee.tenant_id == tenant_id, Employee.employee_code == code)).scalar_one_or_none() + + +def _leave_type_by_code(db: Session, tenant_id: int, branch_id: int, code: str) -> EmployeeLeaveType | None: + return db.execute(select(EmployeeLeaveType).where(EmployeeLeaveType.tenant_id == tenant_id, EmployeeLeaveType.branch_id == branch_id, EmployeeLeaveType.code == code)).scalar_one_or_none() + + +def _preview_employee(db: Session, scope: EmployeeScope, row: dict[str, Any]) -> ImportRowResult: + _CURRENT_DB.set(db) + messages: list[str] = [] + data: dict[str, Any] = {"tenant_id": scope.tenant_id} + try: + data["branch_id"] = _branch_id(scope, row) + data["employee_code"] = _upper(row.get("employee_code")) + data["full_name"] = _str(row.get("full_name")) + if not data["employee_code"] or not data["full_name"]: + raise ValueError("employee_code and full_name are required.") + data["email"] = _str(row.get("email")) or None + data["mobile"] = _str(row.get("mobile")) or None + data["alternate_mobile"] = _str(row.get("alternate_mobile")) or None + data["department"] = _str(row.get("department")) or None + data["designation"] = _str(row.get("designation")) or None + data["employment_type"] = _lower(row.get("employment_type"), "full_time") + if data["employment_type"] not in EMPLOYMENT_TYPES: + raise ValueError(f"Invalid employment_type: {data['employment_type']}.") + data["date_of_joining"] = _date(row.get("date_of_joining")).isoformat() if _date(row.get("date_of_joining")) else None + data["status"] = _lower(row.get("status"), "active") + if data["status"] not in EMPLOYEE_STATUS: + raise ValueError(f"Invalid status: {data['status']}.") + for key in ("pan", "uan", "esi_no", "pf_no", "aadhaar_last4", "bank_name", "bank_account_no", "bank_ifsc", "address", "emergency_contact_name", "emergency_contact_mobile", "notes"): + data[key] = _str(row.get(key)) or None + data["create_user"] = _bool(row.get("create_user"), False) + data["login_email"] = (_str(row.get("login_email")) or data["email"] or "").lower() or None + data["temporary_password"] = _str(row.get("temporary_password")) or None + data["employee_role"] = _str(row.get("employee_role"), "Staff") or "Staff" + mgr_email = _str(row.get("reporting_manager_email")) + if mgr_email: + mgr = db.execute(select(User).where(User.email == mgr_email.lower(), User.tenant_id == scope.tenant_id)).scalar_one_or_none() + if not mgr: + messages.append("Reporting manager email was not found; manager will be blank.") + else: + data["reporting_manager_user_id"] = mgr.id + existing = _employee_by_code(db, scope.tenant_id, data["employee_code"]) + if existing and existing.branch_id != data["branch_id"]: + raise ValueError("Employee code exists in another branch of this tenant.") + if data["create_user"]: + if not data["login_email"]: + raise ValueError("login_email/email is required when create_user is yes.") + if not existing and (not data["temporary_password"] or len(data["temporary_password"]) < 8): + raise ValueError("temporary_password must be at least 8 characters when creating user.") + if data["employee_role"] not in EMPLOYEE_ROLE_NAMES: + messages.append("Invalid employee_role; Staff will be used.") + data["employee_role"] = "Staff" + return ImportRowResult(int(row.get("_row_no", 0)), "warning" if messages else "valid", "update" if existing else "create", data, messages) + except Exception as exc: + return ImportRowResult(int(row.get("_row_no", 0)), "error", "skip", data, [str(exc)]) + + +def _preview_leave_type(db: Session, scope: EmployeeScope, row: dict[str, Any]) -> ImportRowResult: + _CURRENT_DB.set(db) + data: dict[str, Any] = {"tenant_id": scope.tenant_id} + try: + data["branch_id"] = _branch_id(scope, row) + data["code"] = _upper(row.get("code")) + data["name"] = _str(row.get("name")) + if not data["code"] or not data["name"]: + raise ValueError("code and name are required.") + data["description"] = _str(row.get("description")) or None + data["annual_quota_days"] = _int(row.get("annual_quota_days"), 0) + data["carry_forward_allowed"] = _bool(row.get("carry_forward_allowed"), False) + data["allow_negative_balance"] = _bool(row.get("allow_negative_balance"), False) + data["requires_approval"] = _bool(row.get("requires_approval"), True) + data["is_paid"] = _bool(row.get("is_paid"), True) + data["is_active"] = _bool(row.get("is_active"), True) + existing = _leave_type_by_code(db, scope.tenant_id, data["branch_id"], data["code"]) + return ImportRowResult(int(row.get("_row_no", 0)), "valid", "update" if existing else "create", data, []) + except Exception as exc: + return ImportRowResult(int(row.get("_row_no", 0)), "error", "skip", data, [str(exc)]) + + +def _preview_leave_balance(db: Session, scope: EmployeeScope, row: dict[str, Any]) -> ImportRowResult: + _CURRENT_DB.set(db) + data: dict[str, Any] = {"tenant_id": scope.tenant_id} + try: + data["branch_id"] = _branch_id(scope, row) + data["employee_code"] = _upper(row.get("employee_code")) + data["leave_code"] = _upper(row.get("leave_code")) + emp = _employee_by_code(db, scope.tenant_id, data["employee_code"]) + if not emp or emp.branch_id != data["branch_id"]: + raise ValueError("Employee not found in selected/provided branch.") + lt = _leave_type_by_code(db, scope.tenant_id, data["branch_id"], data["leave_code"]) + if not lt: + raise ValueError("Leave type not found for selected/provided branch.") + data["employee_id"] = emp.id + data["leave_type_id"] = lt.id + for key in ("opening_days", "credited_days", "availed_days", "adjusted_days"): + data[key] = _int(row.get(key), 0) + data["balance_days"] = _int(row.get("balance_days"), data["opening_days"] + data["credited_days"] + data["adjusted_days"] - data["availed_days"]) + existing = db.execute(select(EmployeeLeaveBalance).where(EmployeeLeaveBalance.tenant_id == scope.tenant_id, EmployeeLeaveBalance.employee_id == emp.id, EmployeeLeaveBalance.leave_type_id == lt.id)).scalar_one_or_none() + return ImportRowResult(int(row.get("_row_no", 0)), "valid", "update" if existing else "create", data, []) + except Exception as exc: + return ImportRowResult(int(row.get("_row_no", 0)), "error", "skip", data, [str(exc)]) + + +def _preview_salary_structure(db: Session, scope: EmployeeScope, row: dict[str, Any]) -> ImportRowResult: + _CURRENT_DB.set(db) + data: dict[str, Any] = {"tenant_id": scope.tenant_id} + try: + data["branch_id"] = _branch_id(scope, row) + data["employee_code"] = _upper(row.get("employee_code")) + emp = _employee_by_code(db, scope.tenant_id, data["employee_code"]) + if not emp or emp.branch_id != data["branch_id"]: + raise ValueError("Employee not found in selected/provided branch.") + data["employee_id"] = emp.id + eff = _date(row.get("effective_from")) + if not eff: + raise ValueError("effective_from is required.") + data["effective_from"] = eff.isoformat() + eff_to = _date(row.get("effective_to")) + data["effective_to"] = eff_to.isoformat() if eff_to else None + if eff_to and eff_to < eff: + raise ValueError("effective_to cannot be before effective_from.") + data["pay_cycle"] = _lower(row.get("pay_cycle"), "monthly") + for key in ("monthly_ctc_amount", "basic_amount", "hra_amount", "allowance_amount", "employee_pf_amount", "employee_esi_amount", "professional_tax_amount", "tds_amount", "other_deduction_amount"): + data[key] = _int(row.get(key), 0) + data["is_active"] = _bool(row.get("is_active"), True) + data["remarks"] = _str(row.get("remarks")) or None + existing = db.execute(select(EmployeeSalaryStructure).where(EmployeeSalaryStructure.tenant_id == scope.tenant_id, EmployeeSalaryStructure.employee_id == emp.id, EmployeeSalaryStructure.effective_from == eff)).scalar_one_or_none() + return ImportRowResult(int(row.get("_row_no", 0)), "valid", "update" if existing else "create", data, []) + except Exception as exc: + return ImportRowResult(int(row.get("_row_no", 0)), "error", "skip", data, [str(exc)]) + + +def _assign_role_if_needed(db: Session, user_id: int, role_name: str) -> None: + role = db.execute(select(Role).where(Role.name == role_name, Role.is_active.is_(True))).scalar_one_or_none() + if not role: + return + exists = db.execute(select(UserRole).where(UserRole.user_id == user_id, UserRole.role_id == role.id)).scalar_one_or_none() + if not exists: + db.add(UserRole(user_id=user_id, role_id=role.id)) + + +def _get_or_create_user(db: Session, data: dict[str, Any], actor: User) -> int | None: + if not data.get("create_user"): + return None + email = (data.get("login_email") or data.get("email") or "").lower().strip() + if not email: + return None + existing = db.execute(select(User).where(User.email == email)).scalar_one_or_none() + role_name = data.get("employee_role") or "Staff" + if role_name not in EMPLOYEE_ROLE_NAMES: + role_name = "Staff" + if existing: + _assign_role_if_needed(db, existing.id, role_name) + return existing.id + password = data.get("temporary_password") or "" + if len(password) < 8: + raise ValueError("temporary_password must be at least 8 characters.") + user = User( + email=email, + full_name=data.get("full_name") or email, + password_hash=hash_password(password), + tenant_id=int(data["tenant_id"]), + branch_id=int(data["branch_id"]), + is_active=True, + allow_login=True, + is_locked=False, + deleted_at=None, + must_change_password=True, + password_changed_at_utc=None, + ) + db.add(user) + db.flush() + _assign_role_if_needed(db, user.id, role_name) + return user.id + + +def _commit_employee(db: Session, actor: User, scope: EmployeeScope, data: dict[str, Any]) -> str: + emp = _employee_by_code(db, scope.tenant_id, data["employee_code"]) + user_id = _get_or_create_user(db, data, actor) + payload = dict(data) + payload["date_of_joining"] = _date(payload.get("date_of_joining")) + payload["is_active"] = payload.get("status") == "active" + payload.pop("create_user", None) + payload.pop("login_email", None) + payload.pop("temporary_password", None) + payload.pop("employee_role", None) + payload.pop("employee_code", None) + payload.pop("full_name", None) + payload.pop("tenant_id", None) + payload.pop("branch_id", None) + if emp: + emp.full_name = data["full_name"] + emp.email = data.get("email") + if user_id and not emp.user_id: + emp.user_id = user_id + for key, value in payload.items(): + if hasattr(emp, key): + setattr(emp, key, value) + emp.updated_by_user_id = actor.id + emp.updated_at_utc = datetime.utcnow() + db.commit() + return "updated" + emp = Employee( + tenant_id=int(data["tenant_id"]), branch_id=int(data["branch_id"]), user_id=user_id, + employee_code=data["employee_code"], full_name=data["full_name"], created_by_user_id=actor.id, updated_by_user_id=actor.id, + **{k: v for k, v in payload.items() if hasattr(Employee, k)} + ) + db.add(emp) + db.commit() + return "created" + + +def _commit_leave_type(db: Session, actor: User, scope: EmployeeScope, data: dict[str, Any]) -> str: + row = _leave_type_by_code(db, scope.tenant_id, int(data["branch_id"]), data["code"]) + fields = ["name", "description", "annual_quota_days", "carry_forward_allowed", "allow_negative_balance", "requires_approval", "is_paid", "is_active"] + if row: + for field in fields: + setattr(row, field, data.get(field)) + row.updated_by_user_id = actor.id + row.updated_at_utc = datetime.utcnow() + db.commit() + return "updated" + row = EmployeeLeaveType(tenant_id=scope.tenant_id, branch_id=int(data["branch_id"]), code=data["code"], created_by_user_id=actor.id, updated_by_user_id=actor.id, **{k: data.get(k) for k in fields}) + db.add(row) + db.commit() + return "created" + + +def _commit_leave_balance(db: Session, actor: User, scope: EmployeeScope, data: dict[str, Any]) -> str: + row = db.execute(select(EmployeeLeaveBalance).where(EmployeeLeaveBalance.tenant_id == scope.tenant_id, EmployeeLeaveBalance.employee_id == data["employee_id"], EmployeeLeaveBalance.leave_type_id == data["leave_type_id"])).scalar_one_or_none() + fields = ["opening_days", "credited_days", "availed_days", "adjusted_days", "balance_days"] + if row: + for field in fields: + setattr(row, field, int(data.get(field) or 0)) + row.updated_by_user_id = actor.id + row.updated_at_utc = datetime.utcnow() + db.commit() + return "updated" + row = EmployeeLeaveBalance(tenant_id=scope.tenant_id, branch_id=int(data["branch_id"]), employee_id=int(data["employee_id"]), leave_type_id=int(data["leave_type_id"]), updated_by_user_id=actor.id, **{k: int(data.get(k) or 0) for k in fields}) + db.add(row) + db.commit() + return "created" + + +def _commit_salary_structure(db: Session, actor: User, scope: EmployeeScope, data: dict[str, Any]) -> str: + effective_from = _date(data.get("effective_from")) + row = db.execute(select(EmployeeSalaryStructure).where(EmployeeSalaryStructure.tenant_id == scope.tenant_id, EmployeeSalaryStructure.employee_id == data["employee_id"], EmployeeSalaryStructure.effective_from == effective_from)).scalar_one_or_none() + payload = dict(data) + payload["effective_from"] = effective_from + payload["effective_to"] = _date(payload.get("effective_to")) + for remove in ("employee_code", "tenant_id"): + payload.pop(remove, None) + fields = ["effective_from", "effective_to", "pay_cycle", "monthly_ctc_amount", "basic_amount", "hra_amount", "allowance_amount", "employee_pf_amount", "employee_esi_amount", "professional_tax_amount", "tds_amount", "other_deduction_amount", "is_active", "remarks"] + if row: + for field in fields: + setattr(row, field, payload.get(field)) + row.updated_by_user_id = actor.id + row.updated_at_utc = datetime.utcnow() + db.commit() + return "updated" + row = EmployeeSalaryStructure(tenant_id=scope.tenant_id, branch_id=int(data["branch_id"]), employee_id=int(data["employee_id"]), created_by_user_id=actor.id, updated_by_user_id=actor.id, **{k: payload.get(k) for k in fields}) + db.add(row) + db.commit() + return "created" diff --git a/app/modules/employees/models.py b/app/modules/employees/models.py new file mode 100644 index 0000000..23b2f89 --- /dev/null +++ b/app/modules/employees/models.py @@ -0,0 +1,660 @@ +from __future__ import annotations + +from datetime import date, datetime, time, timezone + +from sqlalchemy import Boolean, Date, DateTime, Float, ForeignKey, Integer, String, Text, Time, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.db.common import CommonBase + + +class Employee(CommonBase): + """Tenant and branch aware employee master. + + This is the v2 Employee Core foundation migrated from the older HRMS module. + It intentionally keeps attendance, leave, payroll, documents and ESS out of + this table so those features can be added safely in later phases. + """ + + __tablename__ = "employees" + __table_args__ = ( + UniqueConstraint("tenant_id", "employee_code", name="uq_employees_tenant_code"), + UniqueConstraint("tenant_id", "user_id", name="uq_employees_tenant_user"), + ) + + 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) + branch_id: Mapped[int] = mapped_column(ForeignKey("branches.id"), nullable=False, index=True) + user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + + employee_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + full_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + email: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True) + mobile: Mapped[str | None] = mapped_column(String(20), nullable=True) + alternate_mobile: Mapped[str | None] = mapped_column(String(20), nullable=True) + + date_of_joining: Mapped[date | None] = mapped_column(Date, nullable=True) + date_of_leaving: Mapped[date | None] = mapped_column(Date, nullable=True) + employment_type: Mapped[str] = mapped_column(String(50), nullable=False, default="full_time", index=True) + status: Mapped[str] = mapped_column(String(30), nullable=False, default="active", index=True) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True) + + department: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True) + designation: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True) + reporting_manager_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + + pan: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True) + uan: Mapped[str | None] = mapped_column(String(30), nullable=True) + esi_no: Mapped[str | None] = mapped_column(String(30), nullable=True) + pf_no: Mapped[str | None] = mapped_column(String(30), nullable=True) + aadhaar_last4: Mapped[str | None] = mapped_column(String(4), nullable=True) + + bank_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + bank_account_no: Mapped[str | None] = mapped_column(String(40), nullable=True) + bank_ifsc: Mapped[str | None] = mapped_column(String(20), nullable=True) + + address: Mapped[str | None] = mapped_column(Text, nullable=True) + emergency_contact_name: Mapped[str | None] = mapped_column(String(200), nullable=True) + emergency_contact_mobile: Mapped[str | None] = mapped_column(String(20), nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at_utc: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False + ) + updated_at_utc: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + nullable=False, + ) + + user = relationship("User", foreign_keys=[user_id]) + reporting_manager = relationship("User", foreign_keys=[reporting_manager_user_id]) + + + +class EmployeeRegistrationRequest(CommonBase): + """Employee self-registration / linkage request for ESS onboarding. + + This table is deliberately separate from employees so a logged-in user can + request an employee profile without immediately creating an employee master. + Firm Admin / Partner / Branch Manager can review and approve it. + """ + + __tablename__ = "employee_registration_requests" + + 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) + branch_id: Mapped[int] = mapped_column(ForeignKey("branches.id"), nullable=False, index=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + + requested_employee_code: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True) + full_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + email: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True) + mobile: Mapped[str | None] = mapped_column(String(20), nullable=True) + department: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True) + designation: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True) + date_of_joining: Mapped[date | None] = mapped_column(Date, nullable=True) + remarks: Mapped[str | None] = mapped_column(Text, nullable=True) + + status: Mapped[str] = mapped_column(String(30), nullable=False, default="pending", index=True) + review_notes: Mapped[str | None] = mapped_column(Text, nullable=True) + reviewed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + reviewed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_employee_id: Mapped[int | None] = mapped_column(ForeignKey("employees.id", ondelete="SET NULL"), nullable=True, index=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, + ) + + user = relationship("User", foreign_keys=[user_id]) + reviewed_by = relationship("User", foreign_keys=[reviewed_by_user_id]) + created_employee = relationship("Employee", foreign_keys=[created_employee_id]) + + + +class EmployeeAttendance(CommonBase): + """Daily attendance records for employee self-service and admin review. + + One row is maintained per employee per attendance date. Phase 6C keeps the + model deliberately simple and tenant/branch-safe. Geo/IP validation can be + added later without changing the employee master. + """ + + __tablename__ = "employee_attendance" + __table_args__ = ( + UniqueConstraint("tenant_id", "employee_id", "attendance_date", name="uq_employee_attendance_employee_date"), + ) + + 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) + branch_id: Mapped[int] = mapped_column(ForeignKey("branches.id"), nullable=False, index=True) + employee_id: Mapped[int] = mapped_column(ForeignKey("employees.id", ondelete="CASCADE"), nullable=False, index=True) + user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + + attendance_date: Mapped[date] = mapped_column(Date, nullable=False, index=True) + punch_in_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + punch_out_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + punch_in_local_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=False), nullable=True) + punch_out_local_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=False), nullable=True) + branch_timezone: Mapped[str] = mapped_column(String(64), nullable=False, default="Asia/Kolkata") + scheduled_start_local: Mapped[time | None] = mapped_column(Time, nullable=True) + scheduled_end_local: Mapped[time | None] = mapped_column(Time, nullable=True) + late_by_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True) + attendance_rule_status: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) + is_weekly_off: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + work_duration_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True) + + status: Mapped[str] = mapped_column(String(30), nullable=False, default="present", index=True) + approval_status: Mapped[str] = mapped_column(String(30), nullable=False, default="approved", index=True) + source: Mapped[str] = mapped_column(String(30), nullable=False, default="self_punch", index=True) + remarks: Mapped[str | None] = mapped_column(Text, nullable=True) + + punch_in_latitude: Mapped[float | None] = mapped_column(Float, nullable=True) + punch_in_longitude: Mapped[float | None] = mapped_column(Float, nullable=True) + punch_in_accuracy_meters: Mapped[float | None] = mapped_column(Float, nullable=True) + punch_in_distance_meters: Mapped[float | None] = mapped_column(Float, nullable=True) + punch_in_ip: Mapped[str | None] = mapped_column(String(80), nullable=True) + punch_in_geo_status: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) + punch_in_ip_status: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) + + punch_out_latitude: Mapped[float | None] = mapped_column(Float, nullable=True) + punch_out_longitude: Mapped[float | None] = mapped_column(Float, nullable=True) + punch_out_accuracy_meters: Mapped[float | None] = mapped_column(Float, nullable=True) + punch_out_distance_meters: Mapped[float | None] = mapped_column(Float, nullable=True) + punch_out_ip: Mapped[str | None] = mapped_column(String(80), nullable=True) + punch_out_geo_status: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) + punch_out_ip_status: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) + + reviewed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + reviewed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + review_notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at_utc: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False + ) + updated_at_utc: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + nullable=False, + ) + + employee = relationship("Employee", foreign_keys=[employee_id]) + user = relationship("User", foreign_keys=[user_id]) + reviewed_by = relationship("User", foreign_keys=[reviewed_by_user_id]) + + + +class EmployeeLeaveType(CommonBase): + """Tenant/branch aware leave type master for Phase 6D.""" + + __tablename__ = "employee_leave_types" + __table_args__ = ( + UniqueConstraint("tenant_id", "branch_id", "code", name="uq_employee_leave_types_tenant_branch_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) + branch_id: Mapped[int] = mapped_column(ForeignKey("branches.id"), nullable=False, index=True) + + code: Mapped[str] = mapped_column(String(30), nullable=False, index=True) + name: Mapped[str] = mapped_column(String(100), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + annual_quota_days: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + carry_forward_allowed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + allow_negative_balance: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + requires_approval: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + is_paid: Mapped[bool] = mapped_column(Boolean, nullable=False, default=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", ondelete="SET NULL"), nullable=True) + updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at_utc: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False + ) + updated_at_utc: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + nullable=False, + ) + + +class EmployeeLeaveBalance(CommonBase): + """Leave balance per employee and leave type.""" + + __tablename__ = "employee_leave_balances" + __table_args__ = ( + UniqueConstraint("tenant_id", "employee_id", "leave_type_id", name="uq_employee_leave_balances_employee_type"), + ) + + 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) + branch_id: Mapped[int] = mapped_column(ForeignKey("branches.id"), nullable=False, index=True) + employee_id: Mapped[int] = mapped_column(ForeignKey("employees.id", ondelete="CASCADE"), nullable=False, index=True) + leave_type_id: Mapped[int] = mapped_column(ForeignKey("employee_leave_types.id", ondelete="CASCADE"), nullable=False, index=True) + + opening_days: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + credited_days: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + availed_days: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + adjusted_days: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + balance_days: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + updated_at_utc: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + nullable=False, + ) + + employee = relationship("Employee", foreign_keys=[employee_id]) + leave_type = relationship("EmployeeLeaveType", foreign_keys=[leave_type_id]) + + +class EmployeeLeaveRequest(CommonBase): + """Employee leave request, review and approval workflow.""" + + __tablename__ = "employee_leave_requests" + + 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) + branch_id: Mapped[int] = mapped_column(ForeignKey("branches.id"), nullable=False, index=True) + employee_id: Mapped[int] = mapped_column(ForeignKey("employees.id", ondelete="CASCADE"), nullable=False, index=True) + user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + leave_type_id: Mapped[int] = mapped_column(ForeignKey("employee_leave_types.id", ondelete="RESTRICT"), nullable=False, index=True) + + from_date: Mapped[date] = mapped_column(Date, nullable=False, index=True) + to_date: Mapped[date] = mapped_column(Date, nullable=False, index=True) + days: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + reason: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[str] = mapped_column(String(30), nullable=False, default="pending", index=True) + request_source: Mapped[str] = mapped_column(String(30), nullable=False, default="employee_portal", index=True) + + reviewed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + reviewed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + review_notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at_utc: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False + ) + updated_at_utc: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + nullable=False, + ) + + employee = relationship("Employee", foreign_keys=[employee_id]) + user = relationship("User", foreign_keys=[user_id]) + leave_type = relationship("EmployeeLeaveType", foreign_keys=[leave_type_id]) + reviewed_by = relationship("User", foreign_keys=[reviewed_by_user_id]) + + + +class EmployeeDocumentType(CommonBase): + """Tenant/branch aware employee document type master for Phase 6E.""" + + __tablename__ = "employee_document_types" + __table_args__ = ( + UniqueConstraint("tenant_id", "branch_id", "code", name="uq_employee_document_types_tenant_branch_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) + branch_id: Mapped[int] = mapped_column(ForeignKey("branches.id"), nullable=False, index=True) + + code: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + name: Mapped[str] = mapped_column(String(150), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + is_mandatory: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + allow_employee_upload: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + requires_verification: Mapped[bool] = mapped_column(Boolean, nullable=False, default=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", ondelete="SET NULL"), nullable=True) + updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at_utc: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False + ) + updated_at_utc: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + nullable=False, + ) + + +class EmployeeDocument(CommonBase): + """Employee document metadata. + + Files are stored on disk under data/uploads/employee_documents. The DB keeps + only controlled metadata and the relative file path so a later storage + backend such as Nextcloud/local office storage can be introduced safely. + """ + + __tablename__ = "employee_documents" + + 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) + branch_id: Mapped[int] = mapped_column(ForeignKey("branches.id"), nullable=False, index=True) + employee_id: Mapped[int] = mapped_column(ForeignKey("employees.id", ondelete="CASCADE"), nullable=False, index=True) + document_type_id: Mapped[int | None] = mapped_column(ForeignKey("employee_document_types.id", ondelete="SET NULL"), nullable=True, index=True) + + title: Mapped[str] = mapped_column(String(200), nullable=False, index=True) + document_no: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True) + issue_date: Mapped[date | None] = mapped_column(Date, nullable=True) + expiry_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True) + + original_filename: Mapped[str] = mapped_column(String(255), nullable=False) + stored_filename: Mapped[str] = mapped_column(String(255), nullable=False) + storage_path: Mapped[str] = mapped_column(String(500), nullable=False) + content_type: Mapped[str | None] = mapped_column(String(150), nullable=True) + file_size_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True) + + status: Mapped[str] = mapped_column(String(30), nullable=False, default="uploaded", index=True) + visibility: Mapped[str] = mapped_column(String(30), nullable=False, default="employee_and_hr", index=True) + remarks: Mapped[str | None] = mapped_column(Text, nullable=True) + + verified_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + verified_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + verification_notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + uploaded_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at_utc: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False + ) + updated_at_utc: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + nullable=False, + ) + + employee = relationship("Employee", foreign_keys=[employee_id]) + document_type = relationship("EmployeeDocumentType", foreign_keys=[document_type_id]) + verified_by = relationship("User", foreign_keys=[verified_by_user_id]) + uploaded_by = relationship("User", foreign_keys=[uploaded_by_user_id]) + + + +class EmployeeOnboardingChecklistItem(CommonBase): + """Reusable tenant/branch onboarding checklist master.""" + + __tablename__ = "employee_onboarding_checklist_items" + __table_args__ = ( + UniqueConstraint("tenant_id", "branch_id", "code", name="uq_employee_onboarding_items_tenant_branch_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) + branch_id: Mapped[int] = mapped_column(ForeignKey("branches.id"), nullable=False, index=True) + + code: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + title: Mapped[str] = mapped_column(String(200), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + stage: Mapped[str] = mapped_column(String(50), nullable=False, default="joining", index=True) + default_due_days: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + is_mandatory: Mapped[bool] = mapped_column(Boolean, nullable=False, default=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", ondelete="SET NULL"), nullable=True) + updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at_utc: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False + ) + updated_at_utc: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + nullable=False, + ) + + +class EmployeeOnboardingTask(CommonBase): + """Employee-specific onboarding checklist task.""" + + __tablename__ = "employee_onboarding_tasks" + __table_args__ = ( + UniqueConstraint("tenant_id", "employee_id", "checklist_item_id", name="uq_employee_onboarding_task_employee_item"), + ) + + 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) + branch_id: Mapped[int] = mapped_column(ForeignKey("branches.id"), nullable=False, index=True) + employee_id: Mapped[int] = mapped_column(ForeignKey("employees.id", ondelete="CASCADE"), nullable=False, index=True) + checklist_item_id: Mapped[int | None] = mapped_column(ForeignKey("employee_onboarding_checklist_items.id", ondelete="SET NULL"), nullable=True, index=True) + + title: Mapped[str] = mapped_column(String(200), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + stage: Mapped[str] = mapped_column(String(50), nullable=False, default="joining", index=True) + due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True) + status: Mapped[str] = mapped_column(String(30), nullable=False, default="pending", index=True) + assigned_to_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + + completed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + completed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + review_notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at_utc: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False + ) + updated_at_utc: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + nullable=False, + ) + + employee = relationship("Employee", foreign_keys=[employee_id]) + checklist_item = relationship("EmployeeOnboardingChecklistItem", foreign_keys=[checklist_item_id]) + assigned_to = relationship("User", foreign_keys=[assigned_to_user_id]) + completed_by = relationship("User", foreign_keys=[completed_by_user_id]) + + +class EmployeeOffboardingRequest(CommonBase): + """Employee resignation/relieving/offboarding workflow request.""" + + __tablename__ = "employee_offboarding_requests" + + 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) + branch_id: Mapped[int] = mapped_column(ForeignKey("branches.id"), nullable=False, index=True) + employee_id: Mapped[int] = mapped_column(ForeignKey("employees.id", ondelete="CASCADE"), nullable=False, index=True) + user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + + request_type: Mapped[str] = mapped_column(String(50), nullable=False, default="resignation", index=True) + requested_relieving_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True) + approved_relieving_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True) + reason: Mapped[str | None] = mapped_column(Text, nullable=True) + handover_notes: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[str] = mapped_column(String(30), nullable=False, default="pending", index=True) + + requested_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + reviewed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + reviewed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + review_notes: Mapped[str | None] = mapped_column(Text, nullable=True) + completed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + completed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + created_at_utc: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False + ) + updated_at_utc: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + nullable=False, + ) + + employee = relationship("Employee", foreign_keys=[employee_id]) + requested_by = relationship("User", foreign_keys=[requested_by_user_id]) + reviewed_by = relationship("User", foreign_keys=[reviewed_by_user_id]) + completed_by = relationship("User", foreign_keys=[completed_by_user_id]) + + +class EmployeeOffboardingTask(CommonBase): + """Offboarding checklist task linked to an offboarding request.""" + + __tablename__ = "employee_offboarding_tasks" + + 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) + branch_id: Mapped[int] = mapped_column(ForeignKey("branches.id"), nullable=False, index=True) + request_id: Mapped[int] = mapped_column(ForeignKey("employee_offboarding_requests.id", ondelete="CASCADE"), nullable=False, index=True) + employee_id: Mapped[int] = mapped_column(ForeignKey("employees.id", ondelete="CASCADE"), nullable=False, index=True) + + title: Mapped[str] = mapped_column(String(200), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True) + status: Mapped[str] = mapped_column(String(30), nullable=False, default="pending", index=True) + assigned_to_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + completed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + completed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + review_notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at_utc: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False + ) + updated_at_utc: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + nullable=False, + ) + + request = relationship("EmployeeOffboardingRequest", foreign_keys=[request_id]) + employee = relationship("Employee", foreign_keys=[employee_id]) + assigned_to = relationship("User", foreign_keys=[assigned_to_user_id]) + completed_by = relationship("User", foreign_keys=[completed_by_user_id]) + + + +class EmployeeSalaryStructure(CommonBase): + """Employee salary structure header for Phase 6G payroll foundation.""" + + __tablename__ = "employee_salary_structures" + __table_args__ = ( + UniqueConstraint("tenant_id", "employee_id", "effective_from", name="uq_employee_salary_structure_effective"), + ) + + 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) + branch_id: Mapped[int] = mapped_column(ForeignKey("branches.id"), nullable=False, index=True) + employee_id: Mapped[int] = mapped_column(ForeignKey("employees.id", ondelete="CASCADE"), nullable=False, index=True) + + effective_from: Mapped[date] = mapped_column(Date, nullable=False, index=True) + effective_to: Mapped[date | None] = mapped_column(Date, nullable=True, index=True) + pay_cycle: Mapped[str] = mapped_column(String(30), nullable=False, default="monthly", index=True) + monthly_ctc_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + basic_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + hra_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + allowance_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + employee_pf_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + employee_esi_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + professional_tax_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + tds_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + other_deduction_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True) + remarks: Mapped[str | None] = mapped_column(Text, nullable=True) + + created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) + updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False) + + employee = relationship("Employee", foreign_keys=[employee_id]) + + +class EmployeePayrollRun(CommonBase): + """Monthly payroll run header.""" + + __tablename__ = "employee_payroll_runs" + __table_args__ = ( + UniqueConstraint("tenant_id", "branch_id", "pay_year", "pay_month", name="uq_employee_payroll_run_period"), + ) + + 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) + branch_id: Mapped[int] = mapped_column(ForeignKey("branches.id"), nullable=False, index=True) + pay_year: Mapped[int] = mapped_column(Integer, nullable=False, index=True) + pay_month: Mapped[int] = mapped_column(Integer, nullable=False, index=True) + run_name: Mapped[str] = mapped_column(String(150), nullable=False) + status: Mapped[str] = mapped_column(String(30), nullable=False, default="draft", index=True) + total_employees: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + gross_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + deduction_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + net_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + processed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + approved_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + approved_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + paid_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + paid_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) + updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False) + + processed_by = relationship("User", foreign_keys=[processed_by_user_id]) + approved_by = relationship("User", foreign_keys=[approved_by_user_id]) + paid_by = relationship("User", foreign_keys=[paid_by_user_id]) + + +class EmployeePayslip(CommonBase): + """Employee payslip generated from an approved salary structure.""" + + __tablename__ = "employee_payslips" + __table_args__ = ( + UniqueConstraint("tenant_id", "payroll_run_id", "employee_id", name="uq_employee_payslip_run_employee"), + ) + + 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) + branch_id: Mapped[int] = mapped_column(ForeignKey("branches.id"), nullable=False, index=True) + payroll_run_id: Mapped[int] = mapped_column(ForeignKey("employee_payroll_runs.id", ondelete="CASCADE"), nullable=False, index=True) + employee_id: Mapped[int] = mapped_column(ForeignKey("employees.id", ondelete="CASCADE"), nullable=False, index=True) + salary_structure_id: Mapped[int | None] = mapped_column(ForeignKey("employee_salary_structures.id", ondelete="SET NULL"), nullable=True, index=True) + + pay_year: Mapped[int] = mapped_column(Integer, nullable=False, index=True) + pay_month: Mapped[int] = mapped_column(Integer, nullable=False, index=True) + basic_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + hra_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + allowance_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + gross_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + employee_pf_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + employee_esi_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + professional_tax_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + tds_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + other_deduction_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + deduction_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + net_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + status: Mapped[str] = mapped_column(String(30), nullable=False, default="generated", index=True) + remarks: Mapped[str | None] = mapped_column(Text, nullable=True) + + generated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) + updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False) + + payroll_run = relationship("EmployeePayrollRun", foreign_keys=[payroll_run_id]) + employee = relationship("Employee", foreign_keys=[employee_id]) + salary_structure = relationship("EmployeeSalaryStructure", foreign_keys=[salary_structure_id]) + generated_by = relationship("User", foreign_keys=[generated_by_user_id]) diff --git a/app/modules/employees/service.py b/app/modules/employees/service.py new file mode 100644 index 0000000..8d30e6d --- /dev/null +++ b/app/modules/employees/service.py @@ -0,0 +1,3455 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, datetime, timezone, timedelta +from math import asin, cos, radians, sin, sqrt +from typing import Any +import ipaddress +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from fastapi import HTTPException +from sqlalchemy import func, or_, select +from sqlalchemy.orm import Session, selectinload + +from app.core.security.passwords import hash_password +from app.modules.core.iam.models import User +from app.modules.core.iam.scope import build_scope, list_visible_branches, list_visible_tenants, validate_branch_matches_tenant +from app.modules.core.rbac.deps import get_user_roles +from app.modules.core.rbac.models import Role, UserRole +from app.modules.core.tenancy.models import Branch, Tenant +from app.modules.core.tenancy.settings_models import BranchSettings +from app.modules.employees.models import Employee, EmployeeAttendance, EmployeeRegistrationRequest, EmployeeLeaveType, EmployeeLeaveBalance, EmployeeLeaveRequest, EmployeeDocumentType, EmployeeDocument, EmployeeOnboardingChecklistItem, EmployeeOnboardingTask, EmployeeOffboardingRequest, EmployeeOffboardingTask, EmployeeSalaryStructure, EmployeePayrollRun, EmployeePayslip +from app.modules.clients.models import Client +from app.modules.documents.models import EngagementDocument +from app.modules.services.models import ClientServiceTaskInstance, ClientServiceSubscription, ServiceCatalogue, ServiceTaskComment +from app.modules.services.execution import CLOSED_TASK_STATUSES, TASK_PRIORITIES, TASK_STATUSES + +EMPLOYEE_STATUS = ["active", "inactive", "relieved"] +EMPLOYMENT_TYPES = ["full_time", "part_time", "article_assistant", "intern", "consultant", "contract"] +EMPLOYEE_ROLE_NAMES = ["Firm Admin", "Partner", "Branch Manager", "Staff"] + +DOCUMENT_STATUS = ["uploaded", "verified", "rejected", "archived"] +DOCUMENT_VISIBILITY = ["employee_and_hr", "hr_only"] +ONBOARDING_TASK_STATUS = ["pending", "completed", "skipped"] +OFFBOARDING_REQUEST_STATUS = ["pending", "approved", "rejected", "completed", "cancelled"] +OFFBOARDING_TASK_STATUS = ["pending", "completed", "waived"] +PAYROLL_RUN_STATUS = ["draft", "generated", "approved", "paid", "cancelled"] +PAYSLIP_STATUS = ["generated", "approved", "paid", "cancelled"] + +TASK_COMMUNICATION_TYPES = [ + ("internal_note", "Internal Note"), + ("client_clarification", "Client Clarification"), + ("consultant_communication", "Consultant Communication"), + ("partner_review_note", "Partner Review Note"), +] + +TASK_COMMUNICATION_VISIBILITIES = [ + ("internal", "Internal Team"), + ("client_visible", "Client Visible Later"), + ("consultant_visible", "Consultant Visible Later"), + ("partner_review", "Partner / Review"), +] + +TASK_COMMUNICATION_TYPE_CODES = {code for code, _ in TASK_COMMUNICATION_TYPES} +TASK_COMMUNICATION_VISIBILITY_CODES = {code for code, _ in TASK_COMMUNICATION_VISIBILITIES} + + +@dataclass +class EmployeeScope: + tenant_id: int + branch_id: int | None + is_system_admin: bool + is_firm_admin: bool + is_partner: bool + is_branch_manager: bool + is_staff: bool + allow_cross_tenant: bool + allow_cross_branch: bool + own_user_id: int + + +def _role_set(db: Session, user: User) -> set[str]: + return set(get_user_roles(db, user.id)) + + +def build_employee_scope(db: Session, user: User, *, tenant_id: int | None = None, branch_id: int | None = None) -> EmployeeScope: + roles = _role_set(db, user) + is_system_admin = "System Admin" in roles + is_firm_admin = "Firm Admin" in roles + is_partner = "Partner" in roles + is_branch_manager = "Branch Manager" in roles + is_staff = "Staff" in roles + + effective_tenant_id = int(tenant_id or user.tenant_id) + effective_branch_id = branch_id + + if not is_system_admin: + effective_tenant_id = int(user.tenant_id) + + # System Admin and Firm Admin may use all-branch context. Others stay locked to own branch. + if not (is_system_admin or is_firm_admin): + effective_branch_id = int(user.branch_id) + elif effective_branch_id in (0, "0", "", None): + effective_branch_id = None + else: + effective_branch_id = int(effective_branch_id) + + if effective_branch_id is not None: + validate_branch_matches_tenant(db, effective_tenant_id, effective_branch_id) + + return EmployeeScope( + tenant_id=effective_tenant_id, + branch_id=effective_branch_id, + is_system_admin=is_system_admin, + is_firm_admin=is_firm_admin, + is_partner=is_partner, + is_branch_manager=is_branch_manager, + is_staff=is_staff, + allow_cross_tenant=is_system_admin, + allow_cross_branch=is_system_admin or is_firm_admin, + own_user_id=user.id, + ) + + +def list_employees( + db: Session, + scope: EmployeeScope, + *, + q: str = "", + include_inactive: bool = False, + link_status: str = "all", +) -> list[Employee]: + stmt = select(Employee).where(Employee.tenant_id == scope.tenant_id) + if scope.branch_id is not None: + stmt = stmt.where(Employee.branch_id == scope.branch_id) + if not include_inactive: + stmt = stmt.where(Employee.is_active.is_(True)) + + link_status = (link_status or "all").lower() + if link_status == "linked": + stmt = stmt.where(Employee.user_id.is_not(None)) + elif link_status == "unlinked": + stmt = stmt.where(Employee.user_id.is_(None)) + + if q: + like = f"%{q.strip()}%" + stmt = stmt.where( + or_( + Employee.employee_code.ilike(like), + Employee.full_name.ilike(like), + Employee.email.ilike(like), + Employee.mobile.ilike(like), + Employee.department.ilike(like), + Employee.designation.ilike(like), + Employee.pan.ilike(like), + ) + ) + return db.execute(stmt.order_by(Employee.full_name, Employee.employee_code)).scalars().all() + + +def get_employee_or_404(db: Session, employee_id: int, scope: EmployeeScope) -> Employee: + stmt = select(Employee).where(Employee.id == employee_id, Employee.tenant_id == scope.tenant_id) + if scope.branch_id is not None: + stmt = stmt.where(Employee.branch_id == scope.branch_id) + emp = db.execute(stmt).scalar_one_or_none() + if not emp: + raise HTTPException(status_code=404, detail="Employee not found or not accessible.") + return emp + + +def _blank_to_none(value: Any) -> Any: + if value is None: + return None + if isinstance(value, str) and not value.strip(): + return None + if isinstance(value, str): + return value.strip() + return value + + +def parse_date(value: Any) -> date | None: + value = _blank_to_none(value) + if not value: + return None + if isinstance(value, date): + return value + return date.fromisoformat(str(value)) + + +def _clean_payload(data: dict[str, Any]) -> dict[str, Any]: + cleaned = {k: _blank_to_none(v) for k, v in data.items()} + for key in ("date_of_joining", "date_of_leaving"): + cleaned[key] = parse_date(cleaned.get(key)) + status = (cleaned.get("status") or "active").lower() + if status not in EMPLOYEE_STATUS: + raise HTTPException(status_code=400, detail="Invalid employee status.") + cleaned["status"] = status + emp_type = (cleaned.get("employment_type") or "full_time").lower() + if emp_type not in EMPLOYMENT_TYPES: + raise HTTPException(status_code=400, detail="Invalid employment type.") + cleaned["employment_type"] = emp_type + cleaned["is_active"] = bool(cleaned.get("is_active", True)) and status == "active" + return cleaned + + +def _ensure_unique(db: Session, *, tenant_id: int, employee_code: str, user_id: int | None, exclude_id: int | None = None) -> None: + stmt = select(Employee).where(Employee.tenant_id == tenant_id, Employee.employee_code == employee_code) + if exclude_id: + stmt = stmt.where(Employee.id != exclude_id) + if db.execute(stmt).scalar_one_or_none(): + raise HTTPException(status_code=409, detail="Employee code already exists in this tenant.") + if user_id: + stmt = select(Employee).where(Employee.tenant_id == tenant_id, Employee.user_id == user_id) + if exclude_id: + stmt = stmt.where(Employee.id != exclude_id) + if db.execute(stmt).scalar_one_or_none(): + raise HTTPException(status_code=409, detail="Selected user is already linked to another employee.") + + +def _get_role(db: Session, role_name: str) -> Role | None: + return db.execute(select(Role).where(Role.name == role_name, Role.is_active.is_(True))).scalar_one_or_none() + + +def _assign_role_if_needed(db: Session, user_id: int, role_name: str) -> None: + role = _get_role(db, role_name) + if not role: + return + exists = db.execute(select(UserRole).where(UserRole.user_id == user_id, UserRole.role_id == role.id)).scalar_one_or_none() + if not exists: + db.add(UserRole(user_id=user_id, role_id=role.id)) + + +def create_login_user_for_employee( + db: Session, + *, + tenant_id: int, + branch_id: int, + email: str, + full_name: str, + password: str, + role_name: str = "Staff", +) -> User: + if not email: + raise HTTPException(status_code=400, detail="Login email is required to create an employee user.") + if not password or len(password) < 8: + raise HTTPException(status_code=400, detail="Temporary password must be at least 8 characters.") + existing = db.execute(select(User).where(User.email == email)).scalar_one_or_none() + if existing: + raise HTTPException(status_code=409, detail="A user with this login email already exists. Select the existing user instead.") + if role_name not in EMPLOYEE_ROLE_NAMES: + role_name = "Staff" + user = User( + email=email.strip().lower(), + full_name=full_name.strip(), + password_hash=hash_password(password), + tenant_id=tenant_id, + branch_id=branch_id, + is_active=True, + allow_login=True, + is_locked=False, + deleted_at=None, + must_change_password=True, + password_changed_at_utc=None, + ) + db.add(user) + db.flush() + _assign_role_if_needed(db, user.id, role_name) + return user + + +def create_employee(db: Session, actor: User, scope: EmployeeScope, data: dict[str, Any]) -> Employee: + cleaned = _clean_payload(data) + tenant_id = int(cleaned.get("tenant_id") or scope.tenant_id) + branch_id = int(cleaned.get("branch_id") or actor.branch_id) + + if not scope.allow_cross_tenant and tenant_id != actor.tenant_id: + raise HTTPException(status_code=403, detail="You cannot create employee in another tenant.") + if not scope.allow_cross_branch and branch_id != actor.branch_id: + raise HTTPException(status_code=403, detail="You cannot create employee in another branch.") + validate_branch_matches_tenant(db, tenant_id, branch_id) + + employee_code = (cleaned.get("employee_code") or "").strip() + full_name = (cleaned.get("full_name") or "").strip() + if not employee_code or not full_name: + raise HTTPException(status_code=400, detail="Employee code and full name are required.") + + user_id = cleaned.get("user_id") + if user_id: + linked_user = db.get(User, int(user_id)) + if not linked_user: + raise HTTPException(status_code=404, detail="Selected user was not found.") + if linked_user.tenant_id != tenant_id or linked_user.branch_id != branch_id: + raise HTTPException(status_code=400, detail="Selected user must belong to the employee tenant and branch.") + user_id = linked_user.id + elif cleaned.get("create_login_user"): + login_user = create_login_user_for_employee( + db, + tenant_id=tenant_id, + branch_id=branch_id, + email=cleaned.get("login_email") or cleaned.get("email"), + full_name=full_name, + password=cleaned.get("temporary_password") or "", + role_name=cleaned.get("employee_role") or "Staff", + ) + user_id = login_user.id + + _ensure_unique(db, tenant_id=tenant_id, employee_code=employee_code, user_id=user_id) + + emp = Employee( + tenant_id=tenant_id, + branch_id=branch_id, + user_id=user_id, + employee_code=employee_code, + full_name=full_name, + email=cleaned.get("email") or cleaned.get("login_email"), + mobile=cleaned.get("mobile"), + alternate_mobile=cleaned.get("alternate_mobile"), + date_of_joining=cleaned.get("date_of_joining"), + date_of_leaving=cleaned.get("date_of_leaving"), + employment_type=cleaned.get("employment_type") or "full_time", + status=cleaned.get("status") or "active", + is_active=cleaned.get("is_active", True), + department=cleaned.get("department"), + designation=cleaned.get("designation"), + reporting_manager_user_id=cleaned.get("reporting_manager_user_id"), + pan=cleaned.get("pan"), + uan=cleaned.get("uan"), + esi_no=cleaned.get("esi_no"), + pf_no=cleaned.get("pf_no"), + aadhaar_last4=cleaned.get("aadhaar_last4"), + bank_name=cleaned.get("bank_name"), + bank_account_no=cleaned.get("bank_account_no"), + bank_ifsc=cleaned.get("bank_ifsc"), + address=cleaned.get("address"), + emergency_contact_name=cleaned.get("emergency_contact_name"), + emergency_contact_mobile=cleaned.get("emergency_contact_mobile"), + notes=cleaned.get("notes"), + created_by_user_id=actor.id, + updated_by_user_id=actor.id, + ) + db.add(emp) + db.commit() + db.refresh(emp) + return emp + + +def update_employee(db: Session, actor: User, emp: Employee, data: dict[str, Any]) -> Employee: + cleaned = _clean_payload(data) + employee_code = (cleaned.get("employee_code") or emp.employee_code).strip() + full_name = (cleaned.get("full_name") or emp.full_name).strip() + if not employee_code or not full_name: + raise HTTPException(status_code=400, detail="Employee code and full name are required.") + + user_id = cleaned.get("user_id") + if user_id: + linked_user = db.get(User, int(user_id)) + if not linked_user: + raise HTTPException(status_code=404, detail="Selected user was not found.") + if linked_user.tenant_id != emp.tenant_id or linked_user.branch_id != emp.branch_id: + raise HTTPException(status_code=400, detail="Selected user must belong to the employee tenant and branch.") + user_id = linked_user.id + else: + user_id = None + + _ensure_unique(db, tenant_id=emp.tenant_id, employee_code=employee_code, user_id=user_id, exclude_id=emp.id) + + update_fields = [ + "employee_code", "full_name", "email", "mobile", "alternate_mobile", "date_of_joining", "date_of_leaving", + "employment_type", "status", "is_active", "department", "designation", "reporting_manager_user_id", + "pan", "uan", "esi_no", "pf_no", "aadhaar_last4", "bank_name", "bank_account_no", "bank_ifsc", + "address", "emergency_contact_name", "emergency_contact_mobile", "notes", + ] + cleaned["employee_code"] = employee_code + cleaned["full_name"] = full_name + cleaned["user_id"] = user_id + for field in update_fields + ["user_id"]: + setattr(emp, field, cleaned.get(field)) + emp.updated_by_user_id = actor.id + emp.updated_at_utc = datetime.now(timezone.utc) + db.commit() + db.refresh(emp) + return emp + + +def change_employee_status(db: Session, actor: User, emp: Employee, status: str, date_of_leaving: date | None = None) -> Employee: + status = (status or "").lower() + if status not in EMPLOYEE_STATUS: + raise HTTPException(status_code=400, detail="Invalid employee status.") + emp.status = status + emp.is_active = status == "active" + if status == "relieved" and date_of_leaving: + emp.date_of_leaving = date_of_leaving + emp.updated_by_user_id = actor.id + emp.updated_at_utc = datetime.now(timezone.utc) + db.commit() + db.refresh(emp) + return emp + + +def visible_tenants(db: Session, user: User) -> list[Tenant]: + return list_visible_tenants(db, build_scope(db, user)) + + +def visible_branches(db: Session, user: User, tenant_id: int | None = None) -> list[Branch]: + return list_visible_branches(db, build_scope(db, user), tenant_id or user.tenant_id) + + +def list_linkable_users(db: Session, scope: EmployeeScope, *, include_user_id: int | None = None) -> list[User]: + """Return active login users that can be linked to an employee. + + Already-linked users are excluded to avoid accidental duplicate linkage. + When editing an employee, include_user_id keeps that employee's current user + visible in the dropdown. + """ + linked_user_ids = set( + db.execute( + select(Employee.user_id).where( + Employee.tenant_id == scope.tenant_id, + Employee.user_id.is_not(None), + ) + ).scalars().all() + ) + if include_user_id: + linked_user_ids.discard(int(include_user_id)) + + stmt = select(User).where( + User.tenant_id == scope.tenant_id, + User.deleted_at.is_(None), + User.is_active.is_(True), + ) + if scope.branch_id is not None: + stmt = stmt.where(User.branch_id == scope.branch_id) + if linked_user_ids: + stmt = stmt.where(User.id.not_in(linked_user_ids)) + return db.execute(stmt.order_by(User.full_name, User.email)).scalars().all() + + +def get_employee_user_link_summary(db: Session, scope: EmployeeScope) -> dict[str, int]: + base = select(Employee).where(Employee.tenant_id == scope.tenant_id) + if scope.branch_id is not None: + base = base.where(Employee.branch_id == scope.branch_id) + rows = db.execute(base).scalars().all() + linked = sum(1 for emp in rows if emp.user_id) + unlinked = len(rows) - linked + return {"total": len(rows), "linked": linked, "unlinked": unlinked} + + +def link_employee_to_user(db: Session, actor: User, emp: Employee, user_id: int | None) -> Employee: + """Link or unlink an employee master with an IAM login user. + + This is intentionally a narrow helper for Phase 7A.1 UX cleanup. It does + not create users or change roles; it only updates Employee.user_id after + validating tenant/branch and duplicate linkage. + """ + resolved_user_id = None + if user_id: + linked_user = db.get(User, int(user_id)) + if not linked_user or linked_user.deleted_at is not None: + raise HTTPException(status_code=404, detail="Selected user was not found or is inactive.") + if int(linked_user.tenant_id) != int(emp.tenant_id) or int(linked_user.branch_id) != int(emp.branch_id): + raise HTTPException(status_code=400, detail="Selected user must belong to the employee tenant and branch.") + resolved_user_id = linked_user.id + + _ensure_unique(db, tenant_id=emp.tenant_id, employee_code=emp.employee_code, user_id=resolved_user_id, exclude_id=emp.id) + emp.user_id = resolved_user_id + emp.updated_by_user_id = actor.id + emp.updated_at_utc = datetime.now(timezone.utc) + db.commit() + db.refresh(emp) + return emp + + +def list_reporting_managers(db: Session, scope: EmployeeScope) -> list[User]: + stmt = select(User).where(User.tenant_id == scope.tenant_id, User.deleted_at.is_(None), User.is_active.is_(True)) + if scope.branch_id is not None: + stmt = stmt.where(User.branch_id == scope.branch_id) + return db.execute(stmt.order_by(User.full_name, User.email)).scalars().all() + + +def _count_for_scope(db: Session, model, scope: EmployeeScope, *conditions) -> int: + stmt = select(func.count(model.id)).where(model.tenant_id == scope.tenant_id) + if scope.branch_id is not None and hasattr(model, "branch_id"): + stmt = stmt.where(model.branch_id == scope.branch_id) + for condition in conditions: + stmt = stmt.where(condition) + return int(db.execute(stmt).scalar() or 0) + + +def get_employee_dashboard_stats(db: Session, scope: EmployeeScope) -> dict[str, Any]: + """Return HR dashboard and report counters for the active tenant/branch context. + + Phase 6H intentionally adds reporting only. It does not change any existing + employee, attendance, leave, document, onboarding, offboarding or payroll + workflows. + """ + today = date.today() + month_start = today.replace(day=1) + + total_employees = _count_for_scope(db, Employee, scope) + active_employees = _count_for_scope(db, Employee, scope, Employee.status == "active", Employee.is_active.is_(True)) + inactive_employees = _count_for_scope(db, Employee, scope, Employee.status == "inactive") + relieved_employees = _count_for_scope(db, Employee, scope, Employee.status == "relieved") + + pending_registrations = _count_for_scope(db, EmployeeRegistrationRequest, scope, EmployeeRegistrationRequest.status == "pending") + + attendance_today_total = _count_for_scope(db, EmployeeAttendance, scope, EmployeeAttendance.attendance_date == today) + attendance_today_present = _count_for_scope(db, EmployeeAttendance, scope, EmployeeAttendance.attendance_date == today, EmployeeAttendance.status == "present") + attendance_today_pending = _count_for_scope(db, EmployeeAttendance, scope, EmployeeAttendance.attendance_date == today, EmployeeAttendance.approval_status == "pending") + attendance_month_total = _count_for_scope(db, EmployeeAttendance, scope, EmployeeAttendance.attendance_date >= month_start) + + pending_leave_requests = _count_for_scope(db, EmployeeLeaveRequest, scope, EmployeeLeaveRequest.status == "pending") + approved_leave_requests = _count_for_scope(db, EmployeeLeaveRequest, scope, EmployeeLeaveRequest.status == "approved") + rejected_leave_requests = _count_for_scope(db, EmployeeLeaveRequest, scope, EmployeeLeaveRequest.status == "rejected") + + uploaded_documents = _count_for_scope(db, EmployeeDocument, scope, EmployeeDocument.status == "uploaded") + verified_documents = _count_for_scope(db, EmployeeDocument, scope, EmployeeDocument.status == "verified") + rejected_documents = _count_for_scope(db, EmployeeDocument, scope, EmployeeDocument.status == "rejected") + + onboarding_pending = _count_for_scope(db, EmployeeOnboardingTask, scope, EmployeeOnboardingTask.status == "pending") + onboarding_completed = _count_for_scope(db, EmployeeOnboardingTask, scope, EmployeeOnboardingTask.status == "completed") + + offboarding_pending = _count_for_scope(db, EmployeeOffboardingRequest, scope, EmployeeOffboardingRequest.status == "pending") + offboarding_approved = _count_for_scope(db, EmployeeOffboardingRequest, scope, EmployeeOffboardingRequest.status == "approved") + + salary_structures_active = _count_for_scope(db, EmployeeSalaryStructure, scope, EmployeeSalaryStructure.is_active.is_(True)) + payroll_runs_draft = _count_for_scope(db, EmployeePayrollRun, scope, EmployeePayrollRun.status == "draft") + payroll_runs_generated = _count_for_scope(db, EmployeePayrollRun, scope, EmployeePayrollRun.status == "generated") + payroll_runs_approved = _count_for_scope(db, EmployeePayrollRun, scope, EmployeePayrollRun.status == "approved") + payroll_runs_paid = _count_for_scope(db, EmployeePayrollRun, scope, EmployeePayrollRun.status == "paid") + payslips_generated = _count_for_scope(db, EmployeePayslip, scope, EmployeePayslip.status == "generated") + payslips_paid = _count_for_scope(db, EmployeePayslip, scope, EmployeePayslip.status == "paid") + + recent_employees_stmt = select(Employee).where(Employee.tenant_id == scope.tenant_id) + if scope.branch_id is not None: + recent_employees_stmt = recent_employees_stmt.where(Employee.branch_id == scope.branch_id) + recent_employees = db.execute( + recent_employees_stmt.order_by(Employee.created_at_utc.desc()).limit(8) + ).scalars().all() + + recent_leave_stmt = select(EmployeeLeaveRequest).options(selectinload(EmployeeLeaveRequest.employee), selectinload(EmployeeLeaveRequest.leave_type)).where(EmployeeLeaveRequest.tenant_id == scope.tenant_id) + if scope.branch_id is not None: + recent_leave_stmt = recent_leave_stmt.where(EmployeeLeaveRequest.branch_id == scope.branch_id) + recent_leave_requests = db.execute( + recent_leave_stmt.order_by(EmployeeLeaveRequest.created_at_utc.desc()).limit(8) + ).scalars().all() + + recent_offboarding_stmt = select(EmployeeOffboardingRequest).options(selectinload(EmployeeOffboardingRequest.employee)).where(EmployeeOffboardingRequest.tenant_id == scope.tenant_id) + if scope.branch_id is not None: + recent_offboarding_stmt = recent_offboarding_stmt.where(EmployeeOffboardingRequest.branch_id == scope.branch_id) + recent_offboarding_requests = db.execute( + recent_offboarding_stmt.order_by(EmployeeOffboardingRequest.created_at_utc.desc()).limit(8) + ).scalars().all() + + return { + "as_on": today, + "month_start": month_start, + "employees": { + "total": total_employees, + "active": active_employees, + "inactive": inactive_employees, + "relieved": relieved_employees, + "pending_registrations": pending_registrations, + }, + "attendance": { + "today_total": attendance_today_total, + "today_present": attendance_today_present, + "today_pending": attendance_today_pending, + "month_total": attendance_month_total, + }, + "leave": { + "pending": pending_leave_requests, + "approved": approved_leave_requests, + "rejected": rejected_leave_requests, + }, + "documents": { + "uploaded": uploaded_documents, + "verified": verified_documents, + "rejected": rejected_documents, + }, + "onboarding": { + "pending": onboarding_pending, + "completed": onboarding_completed, + }, + "offboarding": { + "pending": offboarding_pending, + "approved": offboarding_approved, + }, + "payroll": { + "salary_structures_active": salary_structures_active, + "runs_draft": payroll_runs_draft, + "runs_generated": payroll_runs_generated, + "runs_approved": payroll_runs_approved, + "runs_paid": payroll_runs_paid, + "payslips_generated": payslips_generated, + "payslips_paid": payslips_paid, + }, + "recent_employees": recent_employees, + "recent_leave_requests": recent_leave_requests, + "recent_offboarding_requests": recent_offboarding_requests, + } + + +REGISTRATION_STATUS = ["pending", "approved", "rejected"] + + +def get_employee_for_user(db: Session, user: User) -> Employee | None: + return db.execute( + select(Employee).where( + Employee.tenant_id == user.tenant_id, + Employee.user_id == user.id, + ) + ).scalar_one_or_none() + + +def update_own_employee_profile(db: Session, actor: User, emp: Employee, data: dict[str, Any]) -> Employee: + """Allow employees to update only safe self-service fields.""" + if emp.user_id != actor.id: + raise HTTPException(status_code=403, detail="You can update only your own employee profile.") + allowed_fields = [ + "mobile", + "alternate_mobile", + "address", + "emergency_contact_name", + "emergency_contact_mobile", + "bank_name", + "bank_account_no", + "bank_ifsc", + ] + cleaned = {k: _blank_to_none(data.get(k)) for k in allowed_fields} + for field, value in cleaned.items(): + setattr(emp, field, value) + emp.updated_by_user_id = actor.id + emp.updated_at_utc = datetime.now(timezone.utc) + db.commit() + db.refresh(emp) + return emp + + +def _next_employee_code(db: Session, tenant_id: int) -> str: + prefix = "EMP" + latest = db.execute( + select(Employee.employee_code) + .where(Employee.tenant_id == tenant_id, Employee.employee_code.ilike(f"{prefix}%")) + .order_by(Employee.id.desc()) + .limit(1) + ).scalar_one_or_none() + if not latest: + return "EMP0001" + digits = "".join(ch for ch in str(latest) if ch.isdigit()) + next_no = (int(digits) + 1) if digits else 1 + return f"EMP{next_no:04d}" + + +def get_pending_registration_for_user(db: Session, user: User): + from app.modules.employees.models import Employee, EmployeeAttendance, EmployeeRegistrationRequest, EmployeeLeaveType, EmployeeLeaveBalance, EmployeeRegistrationRequest + + return db.execute( + select(EmployeeRegistrationRequest).where( + EmployeeRegistrationRequest.tenant_id == user.tenant_id, + EmployeeRegistrationRequest.user_id == user.id, + EmployeeRegistrationRequest.status == "pending", + ) + ).scalar_one_or_none() + + +def create_employee_registration_request(db: Session, actor: User, data: dict[str, Any]): + from app.modules.employees.models import Employee, EmployeeAttendance, EmployeeRegistrationRequest, EmployeeLeaveType, EmployeeLeaveBalance, EmployeeRegistrationRequest + + if get_employee_for_user(db, actor): + raise HTTPException(status_code=400, detail="Your user is already linked to an employee profile.") + if get_pending_registration_for_user(db, actor): + raise HTTPException(status_code=409, detail="A pending employee registration request already exists for your user.") + full_name = (_blank_to_none(data.get("full_name")) or actor.full_name or actor.email).strip() + req = EmployeeRegistrationRequest( + tenant_id=actor.tenant_id, + branch_id=actor.branch_id, + user_id=actor.id, + requested_employee_code=_blank_to_none(data.get("requested_employee_code")), + full_name=full_name, + email=_blank_to_none(data.get("email")) or actor.email, + mobile=_blank_to_none(data.get("mobile")), + department=_blank_to_none(data.get("department")), + designation=_blank_to_none(data.get("designation")), + date_of_joining=parse_date(data.get("date_of_joining")), + remarks=_blank_to_none(data.get("remarks")), + status="pending", + ) + db.add(req) + db.commit() + db.refresh(req) + return req + + +def list_employee_registration_requests(db: Session, scope: EmployeeScope, *, status: str | None = None): + from app.modules.employees.models import Employee, EmployeeAttendance, EmployeeRegistrationRequest, EmployeeLeaveType, EmployeeLeaveBalance, EmployeeRegistrationRequest + + stmt = select(EmployeeRegistrationRequest).where(EmployeeRegistrationRequest.tenant_id == scope.tenant_id) + if scope.branch_id is not None: + stmt = stmt.where(EmployeeRegistrationRequest.branch_id == scope.branch_id) + if status: + stmt = stmt.where(EmployeeRegistrationRequest.status == status) + return db.execute(stmt.order_by(EmployeeRegistrationRequest.created_at_utc.desc())).scalars().all() + + +def get_employee_registration_request_or_404(db: Session, request_id: int, scope: EmployeeScope): + from app.modules.employees.models import Employee, EmployeeAttendance, EmployeeRegistrationRequest, EmployeeLeaveType, EmployeeLeaveBalance, EmployeeRegistrationRequest + + stmt = select(EmployeeRegistrationRequest).where( + EmployeeRegistrationRequest.id == request_id, + EmployeeRegistrationRequest.tenant_id == scope.tenant_id, + ) + if scope.branch_id is not None: + stmt = stmt.where(EmployeeRegistrationRequest.branch_id == scope.branch_id) + row = db.execute(stmt).scalar_one_or_none() + if not row: + raise HTTPException(status_code=404, detail="Employee registration request not found or not accessible.") + return row + + +def approve_employee_registration_request(db: Session, actor: User, req, *, employee_code: str | None = None, notes: str | None = None) -> Employee: + if req.status != "pending": + raise HTTPException(status_code=400, detail="Only pending registration requests can be approved.") + existing_emp = db.execute( + select(Employee).where(Employee.tenant_id == req.tenant_id, Employee.user_id == req.user_id) + ).scalar_one_or_none() + if existing_emp: + req.status = "approved" + req.review_notes = notes + req.reviewed_by_user_id = actor.id + req.reviewed_at_utc = datetime.now(timezone.utc) + req.created_employee_id = existing_emp.id + db.commit() + db.refresh(existing_emp) + return existing_emp + code = (employee_code or req.requested_employee_code or _next_employee_code(db, req.tenant_id)).strip() + _ensure_unique(db, tenant_id=req.tenant_id, employee_code=code, user_id=req.user_id) + emp = Employee( + tenant_id=req.tenant_id, + branch_id=req.branch_id, + user_id=req.user_id, + employee_code=code, + full_name=req.full_name, + email=req.email, + mobile=req.mobile, + date_of_joining=req.date_of_joining, + employment_type="full_time", + status="active", + is_active=True, + department=req.department, + designation=req.designation, + notes=req.remarks, + created_by_user_id=actor.id, + updated_by_user_id=actor.id, + ) + db.add(emp) + db.flush() + req.status = "approved" + req.review_notes = notes + req.reviewed_by_user_id = actor.id + req.reviewed_at_utc = datetime.now(timezone.utc) + req.created_employee_id = emp.id + req.updated_at_utc = datetime.now(timezone.utc) + db.commit() + db.refresh(emp) + return emp + + +def reject_employee_registration_request(db: Session, actor: User, req, *, notes: str | None = None): + if req.status != "pending": + raise HTTPException(status_code=400, detail="Only pending registration requests can be rejected.") + req.status = "rejected" + req.review_notes = _blank_to_none(notes) + req.reviewed_by_user_id = actor.id + req.reviewed_at_utc = datetime.now(timezone.utc) + req.updated_at_utc = datetime.now(timezone.utc) + db.commit() + db.refresh(req) + return req + + + +ATTENDANCE_STATUS = ["present", "late", "absent", "half_day", "on_duty", "work_from_home"] +ATTENDANCE_APPROVAL_STATUS = ["pending", "approved", "rejected"] +ATTENDANCE_GEO_STATUS = ["not_configured", "inside_geofence", "outside_geofence", "location_missing", "invalid_location"] +ATTENDANCE_IP_STATUS = ["not_configured", "allowed_ip", "outside_allowed_ip", "ip_missing", "invalid_ip_rule"] +ATTENDANCE_RULE_STATUS = ["not_configured", "within_time", "within_grace", "late", "half_day", "weekly_off"] +WEEKDAY_CODES = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"] + + +def _safe_timezone(tz_name: str | None) -> ZoneInfo: + name = (tz_name or "Asia/Kolkata").strip() or "Asia/Kolkata" + try: + return ZoneInfo(name) + except ZoneInfoNotFoundError: + return ZoneInfo("Asia/Kolkata") + + +def _utc_now_naive() -> datetime: + """Return UTC now as a naive datetime for SQLite-safe storage. + + The column names ending with `_utc` are still UTC values. Keeping them + naive avoids SQLite/SQLAlchemy timezone stripping inconsistencies. Branch + local display times are stored separately in *_local_at fields. + """ + return datetime.now(timezone.utc).replace(tzinfo=None) + + +def _as_utc_aware(value: datetime | None) -> datetime | None: + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + +def _branch_local_naive_from_utc(value: datetime | None, tz_name: str | None) -> datetime | None: + utc_value = _as_utc_aware(value) + if utc_value is None: + return None + return utc_value.astimezone(_safe_timezone(tz_name)).replace(tzinfo=None) + + +def _refresh_attendance_local_evidence(row: "EmployeeAttendance") -> "EmployeeAttendance": + """Keep displayed punch time tied to the branch timezone snapshot. + + This also fixes old rows where local fields were missing or were saved + incorrectly after timezone-related changes. It updates the in-memory row; + callers may commit only when they intentionally edit attendance. + """ + tz_name = row.branch_timezone or "Asia/Kolkata" + if row.punch_in_utc: + row.punch_in_local_at = _branch_local_naive_from_utc(row.punch_in_utc, tz_name) + if row.punch_out_utc: + row.punch_out_local_at = _branch_local_naive_from_utc(row.punch_out_utc, tz_name) + return row + + +def _branch_settings(db: Session, branch_id: int) -> tuple[Branch | None, BranchSettings | None]: + branch = db.execute(select(Branch).where(Branch.id == int(branch_id))).scalar_one_or_none() + settings = db.execute(select(BranchSettings).where(BranchSettings.branch_id == int(branch_id))).scalar_one_or_none() + return branch, settings + + +def _branch_now(db: Session, branch_id: int) -> tuple[datetime, datetime, date, str, Branch | None, BranchSettings | None]: + branch, settings = _branch_settings(db, branch_id) + tz_name = getattr(branch, "timezone", None) or "Asia/Kolkata" + now_utc = _utc_now_naive() + local_dt = _branch_local_naive_from_utc(now_utc, tz_name) or now_utc + return now_utc, local_dt, local_dt.date(), tz_name, branch, settings + + +def _working_day_codes(settings: BranchSettings | None) -> set[str]: + raw = (getattr(settings, "working_days_csv", None) or "MON,TUE,WED,THU,FRI,SAT").strip() + return {x.strip().upper() for x in raw.split(',') if x.strip()} + + +def _evaluate_attendance_timing(branch: Branch | None, settings: BranchSettings | None, local_dt: datetime) -> dict[str, Any]: + rule_enabled = bool(getattr(settings, "attendance_rule_enabled", True)) if settings else True + start_time = getattr(branch, "office_start_time", None) if branch else None + end_time = getattr(branch, "office_end_time", None) if branch else None + grace_minutes = int(getattr(settings, "attendance_grace_minutes", 10) or 0) if settings else 10 + half_day_after = getattr(settings, "attendance_half_day_after_time", None) if settings else None + + weekday_code = WEEKDAY_CODES[local_dt.weekday()] + is_weekly_off = weekday_code not in _working_day_codes(settings) + + result = { + "status": "present", + "rule_status": "within_time", + "late_by_minutes": None, + "scheduled_start_local": start_time, + "scheduled_end_local": end_time, + "is_weekly_off": is_weekly_off, + } + + if not rule_enabled: + result["rule_status"] = "not_configured" + return result + if is_weekly_off: + result["rule_status"] = "weekly_off" + return result + if not start_time: + result["rule_status"] = "not_configured" + return result + + local_time = local_dt.time().replace(tzinfo=None) + start_dt = datetime.combine(local_dt.date(), start_time) + local_naive = local_dt.replace(tzinfo=None) + late_by = max(int((local_naive - start_dt).total_seconds() // 60), 0) + result["late_by_minutes"] = late_by if late_by else None + + if half_day_after and local_time >= half_day_after: + result["status"] = "half_day" + result["rule_status"] = "half_day" + elif late_by > grace_minutes: + result["status"] = "late" + result["rule_status"] = "late" + elif late_by > 0: + result["status"] = "present" + result["rule_status"] = "within_grace" + else: + result["status"] = "present" + result["rule_status"] = "within_time" + return result + + +def _attendance_duration_minutes(row: EmployeeAttendance) -> int | None: + if not row.punch_in_utc or not row.punch_out_utc: + return None + delta = row.punch_out_utc - row.punch_in_utc + minutes = int(delta.total_seconds() // 60) + return max(minutes, 0) + + +def _to_float(value: float | str | None) -> float | None: + if value is None or value == "": + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _haversine_distance_meters(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + radius_m = 6371000.0 + lat1_r, lon1_r, lat2_r, lon2_r = map(radians, [lat1, lon1, lat2, lon2]) + dlat = lat2_r - lat1_r + dlon = lon2_r - lon1_r + a = sin(dlat / 2) ** 2 + cos(lat1_r) * cos(lat2_r) * sin(dlon / 2) ** 2 + c = 2 * asin(sqrt(a)) + return radius_m * c + + +def _ip_matches_allowed(ip_value: str | None, allowed_csv: str | None) -> tuple[bool, str]: + if not allowed_csv or not allowed_csv.strip(): + return False, "not_configured" + if not ip_value or not ip_value.strip(): + return False, "ip_missing" + try: + client_ip = ipaddress.ip_address(ip_value.strip()) + except ValueError: + return False, "ip_missing" + + invalid_rule_found = False + for raw_rule in allowed_csv.split(','): + rule = raw_rule.strip() + if not rule: + continue + try: + if '/' in rule: + if client_ip in ipaddress.ip_network(rule, strict=False): + return True, "allowed_ip" + elif client_ip == ipaddress.ip_address(rule): + return True, "allowed_ip" + except ValueError: + invalid_rule_found = True + return False, "invalid_ip_rule" if invalid_rule_found else "outside_allowed_ip" + + +def _evaluate_attendance_controls( + db: Session, + *, + branch_id: int, + latitude: float | str | None = None, + longitude: float | str | None = None, + client_ip: str | None = None, +) -> dict[str, Any]: + settings = db.execute(select(BranchSettings).where(BranchSettings.branch_id == branch_id)).scalar_one_or_none() + lat = _to_float(latitude) + lon = _to_float(longitude) + + geo_enabled = bool(getattr(settings, 'attendance_geo_enabled', False)) if settings else False + ip_enabled = bool(getattr(settings, 'attendance_ip_enabled', False)) if settings else False + branch_lat = _to_float(getattr(settings, 'latitude', None)) if settings else None + branch_lon = _to_float(getattr(settings, 'longitude', None)) if settings else None + radius_m = int(getattr(settings, 'attendance_geo_radius_meters', 100) or 100) if settings else 100 + + distance_m = None + geo_ok = False + if not geo_enabled: + geo_status = "not_configured" + elif branch_lat is None or branch_lon is None: + geo_status = "not_configured" + elif lat is None or lon is None: + geo_status = "location_missing" + else: + distance_m = round(_haversine_distance_meters(branch_lat, branch_lon, lat, lon), 2) + geo_ok = distance_m <= radius_m + geo_status = "inside_geofence" if geo_ok else "outside_geofence" + + if ip_enabled: + ip_ok, ip_status = _ip_matches_allowed(client_ip, getattr(settings, 'attendance_allowed_ip_csv', None) if settings else None) + else: + ip_ok, ip_status = False, "not_configured" + + if not geo_enabled and not ip_enabled: + approval_status = "approved" + status = "present" + source = "self_punch" + elif geo_ok or ip_ok: + approval_status = "approved" + status = "present" + source = "geo_ip_punch" if geo_ok and ip_ok else ("geo_punch" if geo_ok else "ip_punch") + else: + approval_status = "pending" + status = "on_duty" + source = "od_request" + + return { + "approval_status": approval_status, + "status": status, + "source": source, + "geo_status": geo_status, + "ip_status": ip_status, + "distance_meters": distance_m, + } + + +def get_today_attendance_for_user(db: Session, actor: User) -> EmployeeAttendance | None: + emp = get_employee_for_user(db, actor) + if not emp: + return None + _now_utc, _local_dt, local_date, _tz_name, _branch, _settings = _branch_now(db, emp.branch_id) + row = db.execute( + select(EmployeeAttendance).where( + EmployeeAttendance.tenant_id == emp.tenant_id, + EmployeeAttendance.employee_id == emp.id, + EmployeeAttendance.attendance_date == local_date, + ) + ).scalar_one_or_none() + return _refresh_attendance_local_evidence(row) if row else None + + +def list_own_attendance(db: Session, actor: User, *, limit: int = 60) -> list[EmployeeAttendance]: + emp = get_employee_for_user(db, actor) + if not emp: + return [] + rows = db.execute( + select(EmployeeAttendance) + .where(EmployeeAttendance.tenant_id == emp.tenant_id, EmployeeAttendance.employee_id == emp.id) + .order_by(EmployeeAttendance.attendance_date.desc(), EmployeeAttendance.id.desc()) + .limit(limit) + ).scalars().all() + return [_refresh_attendance_local_evidence(row) for row in rows] + + +def punch_in_attendance( + db: Session, + actor: User, + *, + remarks: str | None = None, + latitude: float | str | None = None, + longitude: float | str | None = None, + accuracy_meters: float | str | None = None, + client_ip: str | None = None, +) -> EmployeeAttendance: + emp = get_employee_for_user(db, actor) + if not emp: + raise HTTPException(status_code=400, detail="Your user is not linked to an employee profile. Please request employee registration first.") + if not emp.is_active or emp.status != "active": + raise HTTPException(status_code=400, detail="Attendance punch is allowed only for active employees.") + now, local_dt, today, branch_tz, branch, branch_settings = _branch_now(db, emp.branch_id) + existing = db.execute( + select(EmployeeAttendance).where( + EmployeeAttendance.tenant_id == emp.tenant_id, + EmployeeAttendance.employee_id == emp.id, + EmployeeAttendance.attendance_date == today, + ) + ).scalar_one_or_none() + if existing and existing.punch_in_utc: + raise HTTPException(status_code=409, detail="You have already punched in today.") + evaluation = _evaluate_attendance_controls( + db, + branch_id=emp.branch_id, + latitude=latitude, + longitude=longitude, + client_ip=client_ip, + ) + timing = _evaluate_attendance_timing(branch, branch_settings, local_dt) + final_status = evaluation["status"] if evaluation["approval_status"] == "pending" else timing["status"] + punch_remarks = _blank_to_none(remarks) + if evaluation["approval_status"] == "pending" and not punch_remarks: + punch_remarks = "Outside branch geofence / office IP. Approval required for OD, client visit or remote duty." + + if not existing: + existing = EmployeeAttendance( + tenant_id=emp.tenant_id, + branch_id=emp.branch_id, + employee_id=emp.id, + user_id=actor.id, + attendance_date=today, + punch_in_utc=now, + punch_in_local_at=local_dt, + branch_timezone=branch_tz, + scheduled_start_local=timing["scheduled_start_local"], + scheduled_end_local=timing["scheduled_end_local"], + late_by_minutes=timing["late_by_minutes"], + attendance_rule_status=timing["rule_status"], + is_weekly_off=timing["is_weekly_off"], + status=final_status, + approval_status=evaluation["approval_status"], + source=evaluation["source"], + remarks=punch_remarks, + punch_in_latitude=_to_float(latitude), + punch_in_longitude=_to_float(longitude), + punch_in_accuracy_meters=_to_float(accuracy_meters), + punch_in_distance_meters=evaluation["distance_meters"], + punch_in_ip=client_ip, + punch_in_geo_status=evaluation["geo_status"], + punch_in_ip_status=evaluation["ip_status"], + created_by_user_id=actor.id, + updated_by_user_id=actor.id, + ) + db.add(existing) + else: + existing.punch_in_utc = now + existing.punch_in_local_at = local_dt + existing.branch_timezone = branch_tz + existing.scheduled_start_local = timing["scheduled_start_local"] + existing.scheduled_end_local = timing["scheduled_end_local"] + existing.late_by_minutes = timing["late_by_minutes"] + existing.attendance_rule_status = timing["rule_status"] + existing.is_weekly_off = timing["is_weekly_off"] + existing.status = final_status + existing.approval_status = evaluation["approval_status"] + existing.source = evaluation["source"] + existing.remarks = punch_remarks + existing.punch_in_latitude = _to_float(latitude) + existing.punch_in_longitude = _to_float(longitude) + existing.punch_in_accuracy_meters = _to_float(accuracy_meters) + existing.punch_in_distance_meters = evaluation["distance_meters"] + existing.punch_in_ip = client_ip + existing.punch_in_geo_status = evaluation["geo_status"] + existing.punch_in_ip_status = evaluation["ip_status"] + existing.updated_by_user_id = actor.id + existing.updated_at_utc = now + db.commit() + db.refresh(existing) + return existing + + +def punch_out_attendance( + db: Session, + actor: User, + *, + remarks: str | None = None, + latitude: float | str | None = None, + longitude: float | str | None = None, + accuracy_meters: float | str | None = None, + client_ip: str | None = None, +) -> EmployeeAttendance: + emp = get_employee_for_user(db, actor) + if not emp: + raise HTTPException(status_code=400, detail="Your user is not linked to an employee profile. Please request employee registration first.") + now, local_dt, today, branch_tz, branch, branch_settings = _branch_now(db, emp.branch_id) + row = db.execute( + select(EmployeeAttendance).where( + EmployeeAttendance.tenant_id == emp.tenant_id, + EmployeeAttendance.employee_id == emp.id, + EmployeeAttendance.attendance_date == today, + ) + ).scalar_one_or_none() + if not row or not row.punch_in_utc: + raise HTTPException(status_code=400, detail="No punch-in found for today.") + if row.punch_out_utc: + raise HTTPException(status_code=409, detail="You have already punched out today.") + evaluation = _evaluate_attendance_controls( + db, + branch_id=emp.branch_id, + latitude=latitude, + longitude=longitude, + client_ip=client_ip, + ) + row.punch_out_utc = now + row.punch_out_local_at = local_dt + row.branch_timezone = row.branch_timezone or branch_tz + row.work_duration_minutes = _attendance_duration_minutes(row) + row.punch_out_latitude = _to_float(latitude) + row.punch_out_longitude = _to_float(longitude) + row.punch_out_accuracy_meters = _to_float(accuracy_meters) + row.punch_out_distance_meters = evaluation["distance_meters"] + row.punch_out_ip = client_ip + row.punch_out_geo_status = evaluation["geo_status"] + row.punch_out_ip_status = evaluation["ip_status"] + if row.approval_status == "approved" and evaluation["approval_status"] == "pending": + row.approval_status = "pending" + row.status = "on_duty" + row.source = "od_request" + if _blank_to_none(remarks): + row.remarks = _blank_to_none(remarks) + elif row.approval_status == "pending" and not _blank_to_none(row.remarks): + row.remarks = "Punch-out outside branch geofence / office IP. Approval required." + row.updated_by_user_id = actor.id + row.updated_at_utc = now + db.commit() + db.refresh(row) + return row + + +def list_attendance_records( + db: Session, + scope: EmployeeScope, + *, + employee_id: int | None = None, + from_date: date | None = None, + to_date: date | None = None, + status: str | None = None, + approval_status: str | None = None, +) -> list[EmployeeAttendance]: + stmt = select(EmployeeAttendance).options(selectinload(EmployeeAttendance.employee)).where(EmployeeAttendance.tenant_id == scope.tenant_id) + if scope.branch_id is not None: + stmt = stmt.where(EmployeeAttendance.branch_id == scope.branch_id) + if employee_id: + stmt = stmt.where(EmployeeAttendance.employee_id == int(employee_id)) + if from_date: + stmt = stmt.where(EmployeeAttendance.attendance_date >= from_date) + if to_date: + stmt = stmt.where(EmployeeAttendance.attendance_date <= to_date) + if status: + stmt = stmt.where(EmployeeAttendance.status == status) + if approval_status: + stmt = stmt.where(EmployeeAttendance.approval_status == approval_status) + rows = db.execute(stmt.order_by(EmployeeAttendance.attendance_date.desc(), EmployeeAttendance.id.desc())).scalars().all() + return [_refresh_attendance_local_evidence(row) for row in rows] + + +def get_attendance_or_404(db: Session, attendance_id: int, scope: EmployeeScope) -> EmployeeAttendance: + stmt = select(EmployeeAttendance).where(EmployeeAttendance.id == attendance_id, EmployeeAttendance.tenant_id == scope.tenant_id) + if scope.branch_id is not None: + stmt = stmt.where(EmployeeAttendance.branch_id == scope.branch_id) + row = db.execute(stmt).scalar_one_or_none() + if not row: + raise HTTPException(status_code=404, detail="Attendance record not found or not accessible.") + return row + + +def review_attendance_record( + db: Session, + actor: User, + row: EmployeeAttendance, + *, + approval_status: str, + review_notes: str | None = None, +) -> EmployeeAttendance: + approval_status = (approval_status or "").lower() + if approval_status not in ATTENDANCE_APPROVAL_STATUS: + raise HTTPException(status_code=400, detail="Invalid attendance approval status.") + now = _utc_now_naive() + row.approval_status = approval_status + row.review_notes = _blank_to_none(review_notes) + row.reviewed_by_user_id = actor.id + row.reviewed_at_utc = now + row.updated_by_user_id = actor.id + row.updated_at_utc = now + db.commit() + db.refresh(row) + return row + + +def create_or_update_manual_attendance( + db: Session, + actor: User, + scope: EmployeeScope, + *, + employee_id: int, + attendance_date: date, + status: str, + remarks: str | None = None, +) -> EmployeeAttendance: + emp = get_employee_or_404(db, int(employee_id), scope) + status = (status or "present").lower() + if status not in ATTENDANCE_STATUS: + raise HTTPException(status_code=400, detail="Invalid attendance status.") + today_now = _utc_now_naive() + branch, branch_settings = _branch_settings(db, emp.branch_id) + branch_tz = getattr(branch, "timezone", None) or "Asia/Kolkata" + row = db.execute( + select(EmployeeAttendance).where( + EmployeeAttendance.tenant_id == emp.tenant_id, + EmployeeAttendance.employee_id == emp.id, + EmployeeAttendance.attendance_date == attendance_date, + ) + ).scalar_one_or_none() + if not row: + row = EmployeeAttendance( + tenant_id=emp.tenant_id, + branch_id=emp.branch_id, + employee_id=emp.id, + user_id=emp.user_id, + attendance_date=attendance_date, + branch_timezone=branch_tz, + scheduled_start_local=getattr(branch, "office_start_time", None) if branch else None, + scheduled_end_local=getattr(branch, "office_end_time", None) if branch else None, + attendance_rule_status="manual", + status=status, + approval_status="approved", + source="manual", + remarks=_blank_to_none(remarks), + created_by_user_id=actor.id, + updated_by_user_id=actor.id, + ) + db.add(row) + else: + row.status = status + row.approval_status = "approved" + row.source = "manual" + row.remarks = _blank_to_none(remarks) + row.updated_by_user_id = actor.id + row.updated_at_utc = today_now + db.commit() + db.refresh(row) + return row + + + +LEAVE_REQUEST_STATUS = ["pending", "approved", "rejected", "cancelled"] +LEAVE_TYPE_DEFAULTS = [ + ("CL", "Casual Leave", 12, True), + ("SL", "Sick Leave", 12, True), + ("EL", "Earned Leave", 0, True), + ("LOP", "Loss of Pay", 0, False), +] + + +def _days_between(from_date: date, to_date: date) -> int: + days = (to_date - from_date).days + 1 + if days <= 0: + raise HTTPException(status_code=400, detail="Leave to-date must be on or after from-date.") + return days + + +def list_leave_types(db: Session, scope: EmployeeScope, *, include_inactive: bool = False) -> list[EmployeeLeaveType]: + stmt = select(EmployeeLeaveType).where(EmployeeLeaveType.tenant_id == scope.tenant_id) + if scope.branch_id is not None: + stmt = stmt.where(EmployeeLeaveType.branch_id == scope.branch_id) + if not include_inactive: + stmt = stmt.where(EmployeeLeaveType.is_active.is_(True)) + return db.execute(stmt.order_by(EmployeeLeaveType.code)).scalars().all() + + +def get_leave_type_or_404(db: Session, leave_type_id: int, scope: EmployeeScope) -> EmployeeLeaveType: + stmt = select(EmployeeLeaveType).where(EmployeeLeaveType.id == leave_type_id, EmployeeLeaveType.tenant_id == scope.tenant_id) + if scope.branch_id is not None: + stmt = stmt.where(EmployeeLeaveType.branch_id == scope.branch_id) + row = db.execute(stmt).scalar_one_or_none() + if not row: + raise HTTPException(status_code=404, detail="Leave type not found or not accessible.") + return row + + +def ensure_default_leave_types(db: Session, actor: User, scope: EmployeeScope) -> int: + if scope.branch_id is None: + raise HTTPException(status_code=400, detail="Select a branch before creating default leave types.") + created = 0 + for code, name, quota, paid in LEAVE_TYPE_DEFAULTS: + exists = db.execute( + select(EmployeeLeaveType).where( + EmployeeLeaveType.tenant_id == scope.tenant_id, + EmployeeLeaveType.branch_id == scope.branch_id, + EmployeeLeaveType.code == code, + ) + ).scalar_one_or_none() + if exists: + continue + db.add(EmployeeLeaveType( + tenant_id=scope.tenant_id, + branch_id=scope.branch_id, + code=code, + name=name, + annual_quota_days=quota, + is_paid=paid, + allow_negative_balance=(code == "LOP"), + created_by_user_id=actor.id, + updated_by_user_id=actor.id, + )) + created += 1 + db.commit() + return created + + +def create_leave_type(db: Session, actor: User, scope: EmployeeScope, data: dict[str, Any]) -> EmployeeLeaveType: + if scope.branch_id is None: + raise HTTPException(status_code=400, detail="Select a branch before creating a leave type.") + code = (_blank_to_none(data.get("code")) or "").upper() + name = _blank_to_none(data.get("name")) or "" + if not code or not name: + raise HTTPException(status_code=400, detail="Leave code and name are required.") + exists = db.execute(select(EmployeeLeaveType).where( + EmployeeLeaveType.tenant_id == scope.tenant_id, + EmployeeLeaveType.branch_id == scope.branch_id, + EmployeeLeaveType.code == code, + )).scalar_one_or_none() + if exists: + raise HTTPException(status_code=409, detail="Leave type code already exists for this branch.") + row = EmployeeLeaveType( + tenant_id=scope.tenant_id, + branch_id=scope.branch_id, + code=code, + name=name, + description=_blank_to_none(data.get("description")), + annual_quota_days=int(data.get("annual_quota_days") or 0), + carry_forward_allowed=bool(data.get("carry_forward_allowed")), + allow_negative_balance=bool(data.get("allow_negative_balance")), + requires_approval=bool(data.get("requires_approval", True)), + is_paid=bool(data.get("is_paid", True)), + is_active=bool(data.get("is_active", True)), + created_by_user_id=actor.id, + updated_by_user_id=actor.id, + ) + db.add(row) + db.commit() + db.refresh(row) + return row + + +def update_leave_type(db: Session, actor: User, row: EmployeeLeaveType, data: dict[str, Any]) -> EmployeeLeaveType: + row.name = _blank_to_none(data.get("name")) or row.name + row.description = _blank_to_none(data.get("description")) + row.annual_quota_days = int(data.get("annual_quota_days") or 0) + row.carry_forward_allowed = bool(data.get("carry_forward_allowed")) + row.allow_negative_balance = bool(data.get("allow_negative_balance")) + row.requires_approval = bool(data.get("requires_approval", True)) + row.is_paid = bool(data.get("is_paid", True)) + row.is_active = bool(data.get("is_active")) + row.updated_by_user_id = actor.id + row.updated_at_utc = datetime.now(timezone.utc) + db.commit() + db.refresh(row) + return row + + +def _get_or_create_leave_balance(db: Session, emp: Employee, leave_type: EmployeeLeaveType, actor_id: int | None = None) -> EmployeeLeaveBalance: + bal = db.execute(select(EmployeeLeaveBalance).where( + EmployeeLeaveBalance.tenant_id == emp.tenant_id, + EmployeeLeaveBalance.employee_id == emp.id, + EmployeeLeaveBalance.leave_type_id == leave_type.id, + )).scalar_one_or_none() + if bal: + return bal + initial = int(leave_type.annual_quota_days or 0) + bal = EmployeeLeaveBalance( + tenant_id=emp.tenant_id, + branch_id=emp.branch_id, + employee_id=emp.id, + leave_type_id=leave_type.id, + opening_days=0, + credited_days=initial, + availed_days=0, + adjusted_days=0, + balance_days=initial, + updated_by_user_id=actor_id, + ) + db.add(bal) + db.flush() + return bal + + +def list_leave_balances(db: Session, scope: EmployeeScope, *, employee_id: int | None = None) -> list[EmployeeLeaveBalance]: + stmt = select(EmployeeLeaveBalance).options(selectinload(EmployeeLeaveBalance.employee), selectinload(EmployeeLeaveBalance.leave_type)).where(EmployeeLeaveBalance.tenant_id == scope.tenant_id) + if scope.branch_id is not None: + stmt = stmt.where(EmployeeLeaveBalance.branch_id == scope.branch_id) + if employee_id: + stmt = stmt.where(EmployeeLeaveBalance.employee_id == employee_id) + return db.execute(stmt.order_by(EmployeeLeaveBalance.employee_id, EmployeeLeaveBalance.leave_type_id)).scalars().all() + + +def list_own_leave_balances(db: Session, actor: User) -> list[EmployeeLeaveBalance]: + emp = get_employee_for_user(db, actor) + if not emp: + return [] + return db.execute(select(EmployeeLeaveBalance).options(selectinload(EmployeeLeaveBalance.leave_type)).where(EmployeeLeaveBalance.employee_id == emp.id).order_by(EmployeeLeaveBalance.leave_type_id)).scalars().all() + + +def adjust_leave_balance(db: Session, actor: User, scope: EmployeeScope, *, employee_id: int, leave_type_id: int, adjusted_days: int, notes: str | None = None) -> EmployeeLeaveBalance: + emp = get_employee_or_404(db, employee_id, scope) + leave_type = get_leave_type_or_404(db, leave_type_id, scope) + if leave_type.branch_id != emp.branch_id: + raise HTTPException(status_code=400, detail="Leave type and employee branch do not match.") + bal = _get_or_create_leave_balance(db, emp, leave_type, actor.id) + bal.adjusted_days = int(adjusted_days or 0) + bal.balance_days = int(bal.opening_days or 0) + int(bal.credited_days or 0) + int(bal.adjusted_days or 0) - int(bal.availed_days or 0) + bal.updated_by_user_id = actor.id + bal.updated_at_utc = datetime.now(timezone.utc) + db.commit() + db.refresh(bal) + return bal + + +def list_leave_requests(db: Session, scope: EmployeeScope, *, employee_id: int | None = None, status: str | None = None) -> list[EmployeeLeaveRequest]: + stmt = select(EmployeeLeaveRequest).options(selectinload(EmployeeLeaveRequest.employee), selectinload(EmployeeLeaveRequest.leave_type)).where(EmployeeLeaveRequest.tenant_id == scope.tenant_id) + if scope.branch_id is not None: + stmt = stmt.where(EmployeeLeaveRequest.branch_id == scope.branch_id) + if employee_id: + stmt = stmt.where(EmployeeLeaveRequest.employee_id == employee_id) + if status: + stmt = stmt.where(EmployeeLeaveRequest.status == status) + return db.execute(stmt.order_by(EmployeeLeaveRequest.created_at_utc.desc())).scalars().all() + + +def list_own_leave_requests(db: Session, actor: User) -> list[EmployeeLeaveRequest]: + emp = get_employee_for_user(db, actor) + if not emp: + return [] + return db.execute(select(EmployeeLeaveRequest).options(selectinload(EmployeeLeaveRequest.leave_type)).where(EmployeeLeaveRequest.employee_id == emp.id).order_by(EmployeeLeaveRequest.created_at_utc.desc())).scalars().all() + + +def get_leave_request_or_404(db: Session, request_id: int, scope: EmployeeScope) -> EmployeeLeaveRequest: + stmt = select(EmployeeLeaveRequest).options(selectinload(EmployeeLeaveRequest.employee), selectinload(EmployeeLeaveRequest.leave_type)).where(EmployeeLeaveRequest.id == request_id, EmployeeLeaveRequest.tenant_id == scope.tenant_id) + if scope.branch_id is not None: + stmt = stmt.where(EmployeeLeaveRequest.branch_id == scope.branch_id) + row = db.execute(stmt).scalar_one_or_none() + if not row: + raise HTTPException(status_code=404, detail="Leave request not found or not accessible.") + return row + + +def apply_employee_leave(db: Session, actor: User, *, leave_type_id: int, from_date: date, to_date: date, reason: str | None = None) -> EmployeeLeaveRequest: + emp = get_employee_for_user(db, actor) + if not emp: + raise HTTPException(status_code=400, detail="Your user is not linked to an employee profile.") + days = _days_between(from_date, to_date) + leave_type = db.get(EmployeeLeaveType, int(leave_type_id)) + if not leave_type or leave_type.tenant_id != emp.tenant_id or leave_type.branch_id != emp.branch_id or not leave_type.is_active: + raise HTTPException(status_code=404, detail="Leave type not available for your branch.") + row = EmployeeLeaveRequest( + tenant_id=emp.tenant_id, + branch_id=emp.branch_id, + employee_id=emp.id, + user_id=actor.id, + leave_type_id=leave_type.id, + from_date=from_date, + to_date=to_date, + days=days, + reason=_blank_to_none(reason), + status="pending" if leave_type.requires_approval else "approved", + request_source="employee_portal", + created_by_user_id=actor.id, + updated_by_user_id=actor.id, + ) + db.add(row) + if not leave_type.requires_approval: + bal = _get_or_create_leave_balance(db, emp, leave_type, actor.id) + if not leave_type.allow_negative_balance and bal.balance_days < days: + raise HTTPException(status_code=400, detail="Insufficient leave balance.") + bal.availed_days += days + bal.balance_days -= days + db.commit() + db.refresh(row) + return row + + +def review_leave_request(db: Session, actor: User, row: EmployeeLeaveRequest, *, status: str, review_notes: str | None = None) -> EmployeeLeaveRequest: + status = (status or "").lower() + if status not in ("approved", "rejected"): + raise HTTPException(status_code=400, detail="Invalid leave review status.") + if row.status not in ("pending",): + raise HTTPException(status_code=400, detail="Only pending leave requests can be reviewed.") + leave_type = row.leave_type or db.get(EmployeeLeaveType, row.leave_type_id) + emp = row.employee or db.get(Employee, row.employee_id) + if status == "approved": + bal = _get_or_create_leave_balance(db, emp, leave_type, actor.id) + if not leave_type.allow_negative_balance and bal.balance_days < row.days: + raise HTTPException(status_code=400, detail="Insufficient leave balance for approval.") + bal.availed_days += row.days + bal.balance_days -= row.days + bal.updated_by_user_id = actor.id + bal.updated_at_utc = datetime.now(timezone.utc) + row.status = status + row.review_notes = _blank_to_none(review_notes) + row.reviewed_by_user_id = actor.id + row.reviewed_at_utc = datetime.now(timezone.utc) + row.updated_by_user_id = actor.id + row.updated_at_utc = datetime.now(timezone.utc) + db.commit() + db.refresh(row) + return row + + +def cancel_own_leave_request(db: Session, actor: User, request_id: int) -> EmployeeLeaveRequest: + emp = get_employee_for_user(db, actor) + if not emp: + raise HTTPException(status_code=400, detail="Your user is not linked to an employee profile.") + row = db.execute(select(EmployeeLeaveRequest).where(EmployeeLeaveRequest.id == request_id, EmployeeLeaveRequest.employee_id == emp.id)).scalar_one_or_none() + if not row: + raise HTTPException(status_code=404, detail="Leave request not found.") + if row.status != "pending": + raise HTTPException(status_code=400, detail="Only pending leave requests can be cancelled.") + row.status = "cancelled" + row.updated_by_user_id = actor.id + row.updated_at_utc = datetime.now(timezone.utc) + db.commit() + db.refresh(row) + return row + + + +DOCUMENT_TYPE_DEFAULTS = [ + ("AADHAAR", "Aadhaar / ID Proof", True), + ("PAN", "PAN Card", True), + ("PHOTO", "Photo", False), + ("ADDRESS", "Address Proof", False), + ("EDU", "Education Certificate", False), + ("EXP", "Experience Certificate", False), + ("BANK", "Bank Proof / Cancelled Cheque", False), + ("OTHER", "Other Document", False), +] + + +def list_document_types(db: Session, scope: EmployeeScope, *, include_inactive: bool = False) -> list[EmployeeDocumentType]: + stmt = select(EmployeeDocumentType).where(EmployeeDocumentType.tenant_id == scope.tenant_id) + if scope.branch_id is not None: + stmt = stmt.where(EmployeeDocumentType.branch_id == scope.branch_id) + if not include_inactive: + stmt = stmt.where(EmployeeDocumentType.is_active.is_(True)) + return db.execute(stmt.order_by(EmployeeDocumentType.code)).scalars().all() + + +def get_document_type_or_404(db: Session, document_type_id: int, scope: EmployeeScope) -> EmployeeDocumentType: + stmt = select(EmployeeDocumentType).where(EmployeeDocumentType.id == document_type_id, EmployeeDocumentType.tenant_id == scope.tenant_id) + if scope.branch_id is not None: + stmt = stmt.where(EmployeeDocumentType.branch_id == scope.branch_id) + row = db.execute(stmt).scalar_one_or_none() + if not row: + raise HTTPException(status_code=404, detail="Document type not found or not accessible.") + return row + + +def ensure_default_document_types(db: Session, actor: User, scope: EmployeeScope) -> int: + if scope.branch_id is None: + raise HTTPException(status_code=400, detail="Select a branch before creating default document types.") + created = 0 + for code, name, mandatory in DOCUMENT_TYPE_DEFAULTS: + exists = db.execute( + select(EmployeeDocumentType).where( + EmployeeDocumentType.tenant_id == scope.tenant_id, + EmployeeDocumentType.branch_id == scope.branch_id, + EmployeeDocumentType.code == code, + ) + ).scalar_one_or_none() + if exists: + continue + db.add(EmployeeDocumentType( + tenant_id=scope.tenant_id, + branch_id=scope.branch_id, + code=code, + name=name, + is_mandatory=mandatory, + allow_employee_upload=True, + requires_verification=True, + is_active=True, + created_by_user_id=actor.id, + updated_by_user_id=actor.id, + )) + created += 1 + db.commit() + return created + + +def create_document_type(db: Session, actor: User, scope: EmployeeScope, data: dict[str, Any]) -> EmployeeDocumentType: + if scope.branch_id is None: + raise HTTPException(status_code=400, detail="Select a branch before creating a document type.") + code = (_blank_to_none(data.get("code")) or "").upper() + name = _blank_to_none(data.get("name")) or "" + if not code or not name: + raise HTTPException(status_code=400, detail="Document type code and name are required.") + exists = db.execute(select(EmployeeDocumentType).where( + EmployeeDocumentType.tenant_id == scope.tenant_id, + EmployeeDocumentType.branch_id == scope.branch_id, + EmployeeDocumentType.code == code, + )).scalar_one_or_none() + if exists: + raise HTTPException(status_code=409, detail="Document type code already exists for this branch.") + row = EmployeeDocumentType( + tenant_id=scope.tenant_id, + branch_id=scope.branch_id, + code=code, + name=name, + description=_blank_to_none(data.get("description")), + is_mandatory=bool(data.get("is_mandatory")), + allow_employee_upload=bool(data.get("allow_employee_upload", True)), + requires_verification=bool(data.get("requires_verification", True)), + is_active=bool(data.get("is_active", True)), + created_by_user_id=actor.id, + updated_by_user_id=actor.id, + ) + db.add(row) + db.commit() + db.refresh(row) + return row + + +def update_document_type(db: Session, actor: User, row: EmployeeDocumentType, data: dict[str, Any]) -> EmployeeDocumentType: + row.name = _blank_to_none(data.get("name")) or row.name + row.description = _blank_to_none(data.get("description")) + row.is_mandatory = bool(data.get("is_mandatory")) + row.allow_employee_upload = bool(data.get("allow_employee_upload", True)) + row.requires_verification = bool(data.get("requires_verification", True)) + row.is_active = bool(data.get("is_active")) + row.updated_by_user_id = actor.id + row.updated_at_utc = datetime.now(timezone.utc) + db.commit() + db.refresh(row) + return row + + +def list_employee_documents(db: Session, scope: EmployeeScope, *, employee_id: int | None = None, status: str | None = None) -> list[EmployeeDocument]: + stmt = select(EmployeeDocument).options(selectinload(EmployeeDocument.employee), selectinload(EmployeeDocument.document_type)).where(EmployeeDocument.tenant_id == scope.tenant_id) + if scope.branch_id is not None: + stmt = stmt.where(EmployeeDocument.branch_id == scope.branch_id) + if employee_id: + stmt = stmt.where(EmployeeDocument.employee_id == employee_id) + if status: + stmt = stmt.where(EmployeeDocument.status == status) + return db.execute(stmt.order_by(EmployeeDocument.created_at_utc.desc())).scalars().all() + + +def list_own_employee_documents(db: Session, actor: User) -> list[EmployeeDocument]: + emp = get_employee_for_user(db, actor) + if not emp: + return [] + return db.execute( + select(EmployeeDocument) + .options(selectinload(EmployeeDocument.document_type)) + .where( + EmployeeDocument.employee_id == emp.id, + EmployeeDocument.visibility == "employee_and_hr", + EmployeeDocument.status != "archived", + ) + .order_by(EmployeeDocument.created_at_utc.desc()) + ).scalars().all() + + +def get_employee_document_or_404(db: Session, document_id: int, scope: EmployeeScope) -> EmployeeDocument: + stmt = select(EmployeeDocument).options(selectinload(EmployeeDocument.employee), selectinload(EmployeeDocument.document_type)).where(EmployeeDocument.id == document_id, EmployeeDocument.tenant_id == scope.tenant_id) + if scope.branch_id is not None: + stmt = stmt.where(EmployeeDocument.branch_id == scope.branch_id) + row = db.execute(stmt).scalar_one_or_none() + if not row: + raise HTTPException(status_code=404, detail="Employee document not found or not accessible.") + return row + + +def get_own_employee_document_or_404(db: Session, actor: User, document_id: int) -> EmployeeDocument: + emp = get_employee_for_user(db, actor) + if not emp: + raise HTTPException(status_code=400, detail="Your user is not linked to an employee profile.") + row = db.execute(select(EmployeeDocument).where(EmployeeDocument.id == document_id, EmployeeDocument.employee_id == emp.id)).scalar_one_or_none() + if not row or row.visibility != "employee_and_hr" or row.status == "archived": + raise HTTPException(status_code=404, detail="Employee document not found.") + return row + + +def create_employee_document_record( + db: Session, + actor: User, + employee: Employee, + *, + document_type_id: int | None, + title: str, + document_no: str | None, + issue_date: date | None, + expiry_date: date | None, + original_filename: str, + stored_filename: str, + storage_path: str, + content_type: str | None, + file_size_bytes: int | None, + remarks: str | None = None, + visibility: str = "employee_and_hr", + uploaded_status: str = "uploaded", +) -> EmployeeDocument: + title = (_blank_to_none(title) or original_filename or "Employee Document").strip() + if visibility not in DOCUMENT_VISIBILITY: + visibility = "employee_and_hr" + document_type = None + if document_type_id: + document_type = db.get(EmployeeDocumentType, int(document_type_id)) + if not document_type or document_type.tenant_id != employee.tenant_id or document_type.branch_id != employee.branch_id: + raise HTTPException(status_code=404, detail="Document type is not available for this employee branch.") + row = EmployeeDocument( + tenant_id=employee.tenant_id, + branch_id=employee.branch_id, + employee_id=employee.id, + document_type_id=document_type.id if document_type else None, + title=title, + document_no=_blank_to_none(document_no), + issue_date=issue_date, + expiry_date=expiry_date, + original_filename=original_filename, + stored_filename=stored_filename, + storage_path=storage_path, + content_type=content_type, + file_size_bytes=file_size_bytes, + status=uploaded_status if uploaded_status in DOCUMENT_STATUS else "uploaded", + visibility=visibility, + remarks=_blank_to_none(remarks), + uploaded_by_user_id=actor.id, + updated_by_user_id=actor.id, + ) + db.add(row) + db.commit() + db.refresh(row) + return row + + +def review_employee_document(db: Session, actor: User, row: EmployeeDocument, *, status: str, verification_notes: str | None = None) -> EmployeeDocument: + status = (status or "").lower() + if status not in ("verified", "rejected"): + raise HTTPException(status_code=400, detail="Invalid document review status.") + if row.status == "archived": + raise HTTPException(status_code=400, detail="Archived documents cannot be reviewed.") + row.status = status + row.verification_notes = _blank_to_none(verification_notes) + row.verified_by_user_id = actor.id + row.verified_at_utc = datetime.now(timezone.utc) + row.updated_by_user_id = actor.id + row.updated_at_utc = datetime.now(timezone.utc) + db.commit() + db.refresh(row) + return row + + +def archive_employee_document(db: Session, actor: User, row: EmployeeDocument, *, notes: str | None = None) -> EmployeeDocument: + row.status = "archived" + row.verification_notes = _blank_to_none(notes) or row.verification_notes + row.updated_by_user_id = actor.id + row.updated_at_utc = datetime.now(timezone.utc) + db.commit() + db.refresh(row) + return row + + + +ONBOARDING_DEFAULTS = [ + ("WELCOME", "Welcome and joining confirmation", "joining", 0, 10), + ("ID_DOCS", "Collect and verify identity documents", "joining", 1, 20), + ("BANK", "Collect bank account details", "joining", 1, 30), + ("SYSTEM_ACCESS", "Create system access and assign role", "first_week", 1, 40), + ("POLICIES", "Share office policies and confidentiality instructions", "first_week", 2, 50), +] + +OFFBOARDING_DEFAULT_TASKS = [ + ("Collect handover notes", "Collect pending work, client list and handover notes."), + ("Recover office assets", "Recover laptop, tokens, books, keys and other assets."), + ("Disable system access", "Disable/limit application, email and storage access after relieving."), + ("Final settlement checklist", "Verify attendance, leave and final settlement points."), + ("Relieving documentation", "Prepare relieving/experience documentation where applicable."), +] + + +def _task_due(base_date: date | None, days: int) -> date | None: + if not base_date: + return None + return base_date + timedelta(days=int(days or 0)) + + +def list_onboarding_checklist_items(db: Session, scope: EmployeeScope, *, include_inactive: bool = True) -> list[EmployeeOnboardingChecklistItem]: + stmt = select(EmployeeOnboardingChecklistItem).where(EmployeeOnboardingChecklistItem.tenant_id == scope.tenant_id) + if scope.branch_id is not None: + stmt = stmt.where(EmployeeOnboardingChecklistItem.branch_id == scope.branch_id) + if not include_inactive: + stmt = stmt.where(EmployeeOnboardingChecklistItem.is_active.is_(True)) + return db.execute(stmt.order_by(EmployeeOnboardingChecklistItem.sort_order, EmployeeOnboardingChecklistItem.title)).scalars().all() + + +def ensure_default_onboarding_checklist(db: Session, user: User, scope: EmployeeScope) -> int: + if scope.branch_id is None: + raise HTTPException(status_code=400, detail="Please select a specific branch before creating onboarding defaults.") + created = 0 + for code, title, stage, due_days, sort_order in ONBOARDING_DEFAULTS: + exists = db.execute(select(EmployeeOnboardingChecklistItem).where( + EmployeeOnboardingChecklistItem.tenant_id == scope.tenant_id, + EmployeeOnboardingChecklistItem.branch_id == scope.branch_id, + EmployeeOnboardingChecklistItem.code == code, + )).scalar_one_or_none() + if exists: + continue + db.add(EmployeeOnboardingChecklistItem( + tenant_id=scope.tenant_id, + branch_id=scope.branch_id, + code=code, + title=title, + stage=stage, + default_due_days=due_days, + sort_order=sort_order, + is_mandatory=True, + is_active=True, + created_by_user_id=user.id, + updated_by_user_id=user.id, + )) + created += 1 + db.commit() + return created + + +def create_onboarding_checklist_item(db: Session, user: User, scope: EmployeeScope, data: dict[str, Any]) -> EmployeeOnboardingChecklistItem: + if scope.branch_id is None: + raise HTTPException(status_code=400, detail="Please select a specific branch before creating checklist item.") + code = str(_blank_to_none(data.get("code")) or "").upper() + title = str(_blank_to_none(data.get("title")) or "") + if not code or not title: + raise HTTPException(status_code=400, detail="Code and title are required.") + exists = db.execute(select(EmployeeOnboardingChecklistItem).where( + EmployeeOnboardingChecklistItem.tenant_id == scope.tenant_id, + EmployeeOnboardingChecklistItem.branch_id == scope.branch_id, + EmployeeOnboardingChecklistItem.code == code, + )).scalar_one_or_none() + if exists: + raise HTTPException(status_code=409, detail="Checklist code already exists for this branch.") + item = EmployeeOnboardingChecklistItem( + tenant_id=scope.tenant_id, + branch_id=scope.branch_id, + code=code, + title=title, + description=_blank_to_none(data.get("description")), + stage=_blank_to_none(data.get("stage")) or "joining", + default_due_days=int(data.get("default_due_days") or 0), + sort_order=int(data.get("sort_order") or 0), + is_mandatory=bool(data.get("is_mandatory", True)), + is_active=bool(data.get("is_active", True)), + created_by_user_id=user.id, + updated_by_user_id=user.id, + ) + db.add(item) + db.commit() + db.refresh(item) + return item + + +def get_onboarding_checklist_item_or_404(db: Session, item_id: int, scope: EmployeeScope) -> EmployeeOnboardingChecklistItem: + stmt = select(EmployeeOnboardingChecklistItem).where(EmployeeOnboardingChecklistItem.id == item_id, EmployeeOnboardingChecklistItem.tenant_id == scope.tenant_id) + if scope.branch_id is not None: + stmt = stmt.where(EmployeeOnboardingChecklistItem.branch_id == scope.branch_id) + item = db.execute(stmt).scalar_one_or_none() + if not item: + raise HTTPException(status_code=404, detail="Onboarding checklist item not found or not accessible.") + return item + + +def update_onboarding_checklist_item(db: Session, user: User, item: EmployeeOnboardingChecklistItem, data: dict[str, Any]) -> EmployeeOnboardingChecklistItem: + item.title = str(_blank_to_none(data.get("title")) or item.title) + item.description = _blank_to_none(data.get("description")) + item.stage = _blank_to_none(data.get("stage")) or "joining" + item.default_due_days = int(data.get("default_due_days") or 0) + item.sort_order = int(data.get("sort_order") or 0) + item.is_mandatory = bool(data.get("is_mandatory", True)) + item.is_active = bool(data.get("is_active", True)) + item.updated_by_user_id = user.id + db.commit() + db.refresh(item) + return item + + +def generate_onboarding_tasks_for_employee(db: Session, user: User, employee: Employee, scope: EmployeeScope) -> int: + items = db.execute(select(EmployeeOnboardingChecklistItem).where( + EmployeeOnboardingChecklistItem.tenant_id == employee.tenant_id, + EmployeeOnboardingChecklistItem.branch_id == employee.branch_id, + EmployeeOnboardingChecklistItem.is_active.is_(True), + ).order_by(EmployeeOnboardingChecklistItem.sort_order)).scalars().all() + if not items: + branch_scope = EmployeeScope(employee.tenant_id, employee.branch_id, scope.is_system_admin, scope.is_firm_admin, scope.is_partner, scope.is_branch_manager, scope.is_staff, scope.allow_cross_tenant, scope.allow_cross_branch, scope.own_user_id) + ensure_default_onboarding_checklist(db, user, branch_scope) + items = db.execute(select(EmployeeOnboardingChecklistItem).where( + EmployeeOnboardingChecklistItem.tenant_id == employee.tenant_id, + EmployeeOnboardingChecklistItem.branch_id == employee.branch_id, + EmployeeOnboardingChecklistItem.is_active.is_(True), + ).order_by(EmployeeOnboardingChecklistItem.sort_order)).scalars().all() + created = 0 + base_date = employee.date_of_joining or date.today() + for item in items: + exists = db.execute(select(EmployeeOnboardingTask).where( + EmployeeOnboardingTask.tenant_id == employee.tenant_id, + EmployeeOnboardingTask.employee_id == employee.id, + EmployeeOnboardingTask.checklist_item_id == item.id, + )).scalar_one_or_none() + if exists: + continue + db.add(EmployeeOnboardingTask( + tenant_id=employee.tenant_id, + branch_id=employee.branch_id, + employee_id=employee.id, + checklist_item_id=item.id, + title=item.title, + description=item.description, + stage=item.stage, + due_date=_task_due(base_date, item.default_due_days), + status="pending", + assigned_to_user_id=employee.reporting_manager_user_id, + created_by_user_id=user.id, + updated_by_user_id=user.id, + )) + created += 1 + db.commit() + return created + + +def list_onboarding_tasks(db: Session, scope: EmployeeScope, *, employee_id: int | None = None) -> list[EmployeeOnboardingTask]: + stmt = select(EmployeeOnboardingTask).options(selectinload(EmployeeOnboardingTask.employee), selectinload(EmployeeOnboardingTask.assigned_to)).where(EmployeeOnboardingTask.tenant_id == scope.tenant_id) + if scope.branch_id is not None: + stmt = stmt.where(EmployeeOnboardingTask.branch_id == scope.branch_id) + if employee_id: + stmt = stmt.where(EmployeeOnboardingTask.employee_id == employee_id) + return db.execute(stmt.order_by(EmployeeOnboardingTask.status, EmployeeOnboardingTask.due_date)).scalars().all() + + +def get_onboarding_task_or_404(db: Session, task_id: int, scope: EmployeeScope) -> EmployeeOnboardingTask: + stmt = select(EmployeeOnboardingTask).where(EmployeeOnboardingTask.id == task_id, EmployeeOnboardingTask.tenant_id == scope.tenant_id) + if scope.branch_id is not None: + stmt = stmt.where(EmployeeOnboardingTask.branch_id == scope.branch_id) + task = db.execute(stmt).scalar_one_or_none() + if not task: + raise HTTPException(status_code=404, detail="Onboarding task not found or not accessible.") + return task + + +def update_onboarding_task_status(db: Session, user: User, task: EmployeeOnboardingTask, status: str, notes: str | None = None) -> EmployeeOnboardingTask: + status = (status or "pending").lower() + if status not in ONBOARDING_TASK_STATUS: + raise HTTPException(status_code=400, detail="Invalid onboarding task status.") + task.status = status + task.review_notes = _blank_to_none(notes) + task.updated_by_user_id = user.id + if status in ("completed", "skipped"): + task.completed_by_user_id = user.id + task.completed_at_utc = datetime.now(timezone.utc) + else: + task.completed_by_user_id = None + task.completed_at_utc = None + db.commit() + db.refresh(task) + return task + + +def list_offboarding_requests(db: Session, scope: EmployeeScope, *, status: str | None = None) -> list[EmployeeOffboardingRequest]: + stmt = select(EmployeeOffboardingRequest).options(selectinload(EmployeeOffboardingRequest.employee)).where(EmployeeOffboardingRequest.tenant_id == scope.tenant_id) + if scope.branch_id is not None: + stmt = stmt.where(EmployeeOffboardingRequest.branch_id == scope.branch_id) + if status: + stmt = stmt.where(EmployeeOffboardingRequest.status == status) + return db.execute(stmt.order_by(EmployeeOffboardingRequest.created_at_utc.desc())).scalars().all() + + +def list_own_offboarding_requests(db: Session, user: User) -> list[EmployeeOffboardingRequest]: + employee = get_employee_for_user(db, user) + if not employee: + return [] + stmt = select(EmployeeOffboardingRequest).where( + EmployeeOffboardingRequest.tenant_id == employee.tenant_id, + EmployeeOffboardingRequest.employee_id == employee.id, + ).order_by(EmployeeOffboardingRequest.created_at_utc.desc()) + return db.execute(stmt).scalars().all() + + +def create_offboarding_request(db: Session, user: User, employee: Employee, data: dict[str, Any], *, source: str = "admin") -> EmployeeOffboardingRequest: + pending = db.execute(select(EmployeeOffboardingRequest).where( + EmployeeOffboardingRequest.employee_id == employee.id, + EmployeeOffboardingRequest.status.in_(["pending", "approved"]), + )).scalar_one_or_none() + if pending: + raise HTTPException(status_code=409, detail="This employee already has an active offboarding request.") + req = EmployeeOffboardingRequest( + tenant_id=employee.tenant_id, + branch_id=employee.branch_id, + employee_id=employee.id, + user_id=employee.user_id, + request_type=_blank_to_none(data.get("request_type")) or "resignation", + requested_relieving_date=parse_date(data.get("requested_relieving_date")), + reason=_blank_to_none(data.get("reason")), + handover_notes=_blank_to_none(data.get("handover_notes")), + status="pending", + requested_by_user_id=user.id, + ) + db.add(req) + db.commit() + db.refresh(req) + return req + + +def get_offboarding_request_or_404(db: Session, request_id: int, scope: EmployeeScope) -> EmployeeOffboardingRequest: + stmt = select(EmployeeOffboardingRequest).options(selectinload(EmployeeOffboardingRequest.employee)).where(EmployeeOffboardingRequest.id == request_id, EmployeeOffboardingRequest.tenant_id == scope.tenant_id) + if scope.branch_id is not None: + stmt = stmt.where(EmployeeOffboardingRequest.branch_id == scope.branch_id) + req = db.execute(stmt).scalar_one_or_none() + if not req: + raise HTTPException(status_code=404, detail="Offboarding request not found or not accessible.") + return req + + +def _ensure_offboarding_tasks(db: Session, user: User, req: EmployeeOffboardingRequest) -> int: + existing = db.execute(select(EmployeeOffboardingTask).where(EmployeeOffboardingTask.request_id == req.id)).scalars().all() + if existing: + return 0 + base = req.approved_relieving_date or req.requested_relieving_date or date.today() + created = 0 + for title, desc in OFFBOARDING_DEFAULT_TASKS: + db.add(EmployeeOffboardingTask( + tenant_id=req.tenant_id, + branch_id=req.branch_id, + request_id=req.id, + employee_id=req.employee_id, + title=title, + description=desc, + due_date=base, + status="pending", + created_by_user_id=user.id, + updated_by_user_id=user.id, + )) + created += 1 + db.commit() + return created + + +def review_offboarding_request(db: Session, user: User, req: EmployeeOffboardingRequest, *, status: str, approved_relieving_date: date | None = None, review_notes: str | None = None) -> EmployeeOffboardingRequest: + status = (status or "").lower() + if status not in ("approved", "rejected"): + raise HTTPException(status_code=400, detail="Offboarding review status must be approved or rejected.") + if req.status not in ("pending", "approved"): + raise HTTPException(status_code=400, detail="Only pending/approved requests can be reviewed.") + req.status = status + req.approved_relieving_date = approved_relieving_date or req.requested_relieving_date + req.review_notes = _blank_to_none(review_notes) + req.reviewed_by_user_id = user.id + req.reviewed_at_utc = datetime.now(timezone.utc) + db.commit() + db.refresh(req) + if status == "approved": + _ensure_offboarding_tasks(db, user, req) + db.refresh(req) + return req + + +def list_offboarding_tasks(db: Session, req: EmployeeOffboardingRequest) -> list[EmployeeOffboardingTask]: + return db.execute(select(EmployeeOffboardingTask).where(EmployeeOffboardingTask.request_id == req.id).order_by(EmployeeOffboardingTask.status, EmployeeOffboardingTask.due_date, EmployeeOffboardingTask.id)).scalars().all() + + +def get_offboarding_task_or_404(db: Session, task_id: int, scope: EmployeeScope) -> EmployeeOffboardingTask: + stmt = select(EmployeeOffboardingTask).where(EmployeeOffboardingTask.id == task_id, EmployeeOffboardingTask.tenant_id == scope.tenant_id) + if scope.branch_id is not None: + stmt = stmt.where(EmployeeOffboardingTask.branch_id == scope.branch_id) + task = db.execute(stmt).scalar_one_or_none() + if not task: + raise HTTPException(status_code=404, detail="Offboarding task not found or not accessible.") + return task + + +def update_offboarding_task_status(db: Session, user: User, task: EmployeeOffboardingTask, status: str, notes: str | None = None) -> EmployeeOffboardingTask: + status = (status or "pending").lower() + if status not in OFFBOARDING_TASK_STATUS: + raise HTTPException(status_code=400, detail="Invalid offboarding task status.") + task.status = status + task.review_notes = _blank_to_none(notes) + task.updated_by_user_id = user.id + if status in ("completed", "waived"): + task.completed_by_user_id = user.id + task.completed_at_utc = datetime.now(timezone.utc) + else: + task.completed_by_user_id = None + task.completed_at_utc = None + db.commit() + db.refresh(task) + return task + + +def complete_offboarding_request(db: Session, user: User, req: EmployeeOffboardingRequest) -> EmployeeOffboardingRequest: + if req.status != "approved": + raise HTTPException(status_code=400, detail="Only approved offboarding requests can be completed.") + tasks = list_offboarding_tasks(db, req) + pending = [t for t in tasks if t.status == "pending"] + if pending: + raise HTTPException(status_code=400, detail="Complete or waive all offboarding tasks before final completion.") + employee = db.get(Employee, req.employee_id) + if employee: + employee.status = "relieved" + employee.is_active = False + employee.date_of_leaving = req.approved_relieving_date or req.requested_relieving_date or date.today() + employee.updated_by_user_id = user.id + req.status = "completed" + req.completed_by_user_id = user.id + req.completed_at_utc = datetime.now(timezone.utc) + db.commit() + db.refresh(req) + return req + + + +# ------------------------- +# Phase 6G payroll foundation +# ------------------------- + +def _amount_int(value: Any) -> int: + value = _blank_to_none(value) + if value is None: + return 0 + try: + return int(round(float(value))) + except Exception: + raise HTTPException(status_code=400, detail=f"Invalid amount: {value}") + + +def list_salary_structures(db: Session, scope: EmployeeScope, *, employee_id: int | None = None, include_inactive: bool = True) -> list[EmployeeSalaryStructure]: + stmt = select(EmployeeSalaryStructure).options(selectinload(EmployeeSalaryStructure.employee)).where(EmployeeSalaryStructure.tenant_id == scope.tenant_id) + if scope.branch_id is not None: + stmt = stmt.where(EmployeeSalaryStructure.branch_id == scope.branch_id) + if employee_id: + stmt = stmt.where(EmployeeSalaryStructure.employee_id == employee_id) + if not include_inactive: + stmt = stmt.where(EmployeeSalaryStructure.is_active.is_(True)) + return db.execute(stmt.order_by(EmployeeSalaryStructure.effective_from.desc(), EmployeeSalaryStructure.id.desc())).scalars().all() + + +def get_salary_structure_or_404(db: Session, structure_id: int, scope: EmployeeScope) -> EmployeeSalaryStructure: + stmt = select(EmployeeSalaryStructure).options(selectinload(EmployeeSalaryStructure.employee)).where(EmployeeSalaryStructure.id == structure_id, EmployeeSalaryStructure.tenant_id == scope.tenant_id) + if scope.branch_id is not None: + stmt = stmt.where(EmployeeSalaryStructure.branch_id == scope.branch_id) + row = db.execute(stmt).scalar_one_or_none() + if not row: + raise HTTPException(status_code=404, detail="Salary structure not found or not accessible.") + return row + + +def _salary_payload(data: dict[str, Any]) -> dict[str, Any]: + return { + "effective_from": parse_date(data.get("effective_from")) or date.today(), + "effective_to": parse_date(data.get("effective_to")), + "pay_cycle": _blank_to_none(data.get("pay_cycle")) or "monthly", + "monthly_ctc_amount": _amount_int(data.get("monthly_ctc_amount")), + "basic_amount": _amount_int(data.get("basic_amount")), + "hra_amount": _amount_int(data.get("hra_amount")), + "allowance_amount": _amount_int(data.get("allowance_amount")), + "employee_pf_amount": _amount_int(data.get("employee_pf_amount")), + "employee_esi_amount": _amount_int(data.get("employee_esi_amount")), + "professional_tax_amount": _amount_int(data.get("professional_tax_amount")), + "tds_amount": _amount_int(data.get("tds_amount")), + "other_deduction_amount": _amount_int(data.get("other_deduction_amount")), + "is_active": bool(data.get("is_active", True)), + "remarks": _blank_to_none(data.get("remarks")), + } + + +def create_salary_structure(db: Session, actor: User, scope: EmployeeScope, data: dict[str, Any]) -> EmployeeSalaryStructure: + employee_id = int(data.get("employee_id") or 0) + emp = get_employee_or_404(db, employee_id, scope) + payload = _salary_payload(data) + if payload["effective_to"] and payload["effective_to"] < payload["effective_from"]: + raise HTTPException(status_code=400, detail="Effective to date cannot be before effective from date.") + exists = db.execute(select(EmployeeSalaryStructure).where( + EmployeeSalaryStructure.tenant_id == emp.tenant_id, + EmployeeSalaryStructure.employee_id == emp.id, + EmployeeSalaryStructure.effective_from == payload["effective_from"], + )).scalar_one_or_none() + if exists: + raise HTTPException(status_code=409, detail="Salary structure already exists for this employee and effective date.") + row = EmployeeSalaryStructure(tenant_id=emp.tenant_id, branch_id=emp.branch_id, employee_id=emp.id, created_by_user_id=actor.id, updated_by_user_id=actor.id, **payload) + db.add(row) + db.commit() + db.refresh(row) + return row + + +def update_salary_structure(db: Session, actor: User, row: EmployeeSalaryStructure, data: dict[str, Any]) -> EmployeeSalaryStructure: + payload = _salary_payload(data) + if payload["effective_to"] and payload["effective_to"] < payload["effective_from"]: + raise HTTPException(status_code=400, detail="Effective to date cannot be before effective from date.") + for key, value in payload.items(): + setattr(row, key, value) + row.updated_by_user_id = actor.id + row.updated_at_utc = datetime.now(timezone.utc) + db.commit() + db.refresh(row) + return row + + +def _active_salary_structure_for_employee(db: Session, emp: Employee, period_end: date) -> EmployeeSalaryStructure | None: + stmt = select(EmployeeSalaryStructure).where( + EmployeeSalaryStructure.employee_id == emp.id, + EmployeeSalaryStructure.is_active.is_(True), + EmployeeSalaryStructure.effective_from <= period_end, + or_(EmployeeSalaryStructure.effective_to.is_(None), EmployeeSalaryStructure.effective_to >= period_end), + ).order_by(EmployeeSalaryStructure.effective_from.desc(), EmployeeSalaryStructure.id.desc()) + return db.execute(stmt).scalar_one_or_none() + + +def list_payroll_runs(db: Session, scope: EmployeeScope) -> list[EmployeePayrollRun]: + stmt = select(EmployeePayrollRun).where(EmployeePayrollRun.tenant_id == scope.tenant_id) + if scope.branch_id is not None: + stmt = stmt.where(EmployeePayrollRun.branch_id == scope.branch_id) + return db.execute(stmt.order_by(EmployeePayrollRun.pay_year.desc(), EmployeePayrollRun.pay_month.desc(), EmployeePayrollRun.id.desc())).scalars().all() + + +def get_payroll_run_or_404(db: Session, run_id: int, scope: EmployeeScope) -> EmployeePayrollRun: + stmt = select(EmployeePayrollRun).where(EmployeePayrollRun.id == run_id, EmployeePayrollRun.tenant_id == scope.tenant_id) + if scope.branch_id is not None: + stmt = stmt.where(EmployeePayrollRun.branch_id == scope.branch_id) + row = db.execute(stmt).scalar_one_or_none() + if not row: + raise HTTPException(status_code=404, detail="Payroll run not found or not accessible.") + return row + + +def create_payroll_run(db: Session, actor: User, scope: EmployeeScope, *, pay_year: int, pay_month: int, notes: str | None = None) -> EmployeePayrollRun: + if scope.branch_id is None: + raise HTTPException(status_code=400, detail="Select a branch before creating payroll run.") + pay_year = int(pay_year) + pay_month = int(pay_month) + if pay_month < 1 or pay_month > 12: + raise HTTPException(status_code=400, detail="Pay month must be between 1 and 12.") + exists = db.execute(select(EmployeePayrollRun).where(EmployeePayrollRun.tenant_id == scope.tenant_id, EmployeePayrollRun.branch_id == scope.branch_id, EmployeePayrollRun.pay_year == pay_year, EmployeePayrollRun.pay_month == pay_month)).scalar_one_or_none() + if exists: + raise HTTPException(status_code=409, detail="Payroll run already exists for this branch and period.") + row = EmployeePayrollRun( + tenant_id=scope.tenant_id, + branch_id=scope.branch_id, + pay_year=pay_year, + pay_month=pay_month, + run_name=f"Payroll {pay_month:02d}/{pay_year}", + status="draft", + notes=_blank_to_none(notes), + processed_by_user_id=actor.id, + ) + db.add(row) + db.commit() + db.refresh(row) + return row + + +def list_payslips(db: Session, scope: EmployeeScope, *, payroll_run_id: int | None = None, employee_id: int | None = None) -> list[EmployeePayslip]: + stmt = select(EmployeePayslip).options(selectinload(EmployeePayslip.employee), selectinload(EmployeePayslip.payroll_run)).where(EmployeePayslip.tenant_id == scope.tenant_id) + if scope.branch_id is not None: + stmt = stmt.where(EmployeePayslip.branch_id == scope.branch_id) + if payroll_run_id: + stmt = stmt.where(EmployeePayslip.payroll_run_id == payroll_run_id) + if employee_id: + stmt = stmt.where(EmployeePayslip.employee_id == employee_id) + return db.execute(stmt.order_by(EmployeePayslip.pay_year.desc(), EmployeePayslip.pay_month.desc(), EmployeePayslip.employee_id)).scalars().all() + + +def list_own_payslips(db: Session, actor: User) -> list[EmployeePayslip]: + emp = get_employee_for_user(db, actor) + if not emp: + return [] + return db.execute(select(EmployeePayslip).options(selectinload(EmployeePayslip.payroll_run)).where(EmployeePayslip.employee_id == emp.id).order_by(EmployeePayslip.pay_year.desc(), EmployeePayslip.pay_month.desc())).scalars().all() + + +def generate_payslips_for_run(db: Session, actor: User, scope: EmployeeScope, run: EmployeePayrollRun) -> int: + if run.status not in ("draft", "generated"): + raise HTTPException(status_code=400, detail="Only draft/generated payroll runs can be regenerated.") + period_end = date(run.pay_year, run.pay_month, 28) + employees = db.execute(select(Employee).where(Employee.tenant_id == run.tenant_id, Employee.branch_id == run.branch_id, Employee.is_active.is_(True)).order_by(Employee.full_name)).scalars().all() + created = 0 + gross_total = deduction_total = net_total = 0 + for emp in employees: + structure = _active_salary_structure_for_employee(db, emp, period_end) + if not structure: + continue + gross = int(structure.basic_amount or 0) + int(structure.hra_amount or 0) + int(structure.allowance_amount or 0) + deductions = int(structure.employee_pf_amount or 0) + int(structure.employee_esi_amount or 0) + int(structure.professional_tax_amount or 0) + int(structure.tds_amount or 0) + int(structure.other_deduction_amount or 0) + net = gross - deductions + existing = db.execute(select(EmployeePayslip).where(EmployeePayslip.payroll_run_id == run.id, EmployeePayslip.employee_id == emp.id)).scalar_one_or_none() + if not existing: + existing = EmployeePayslip(tenant_id=run.tenant_id, branch_id=run.branch_id, payroll_run_id=run.id, employee_id=emp.id, generated_by_user_id=actor.id) + db.add(existing) + created += 1 + existing.salary_structure_id = structure.id + existing.pay_year = run.pay_year + existing.pay_month = run.pay_month + existing.basic_amount = int(structure.basic_amount or 0) + existing.hra_amount = int(structure.hra_amount or 0) + existing.allowance_amount = int(structure.allowance_amount or 0) + existing.gross_amount = gross + existing.employee_pf_amount = int(structure.employee_pf_amount or 0) + existing.employee_esi_amount = int(structure.employee_esi_amount or 0) + existing.professional_tax_amount = int(structure.professional_tax_amount or 0) + existing.tds_amount = int(structure.tds_amount or 0) + existing.other_deduction_amount = int(structure.other_deduction_amount or 0) + existing.deduction_amount = deductions + existing.net_amount = net + existing.status = "generated" + gross_total += gross + deduction_total += deductions + net_total += net + run.total_employees = len(list_payslips(db, scope, payroll_run_id=run.id)) + run.gross_amount = gross_total + run.deduction_amount = deduction_total + run.net_amount = net_total + run.status = "generated" + run.processed_by_user_id = actor.id + run.updated_at_utc = datetime.now(timezone.utc) + db.commit() + db.refresh(run) + return created + + +def approve_payroll_run(db: Session, actor: User, run: EmployeePayrollRun) -> EmployeePayrollRun: + if run.status != "generated": + raise HTTPException(status_code=400, detail="Only generated payroll runs can be approved.") + run.status = "approved" + run.approved_by_user_id = actor.id + run.approved_at_utc = datetime.now(timezone.utc) + for slip in db.execute(select(EmployeePayslip).where(EmployeePayslip.payroll_run_id == run.id)).scalars().all(): + slip.status = "approved" + db.commit() + db.refresh(run) + return run + + +def mark_payroll_run_paid(db: Session, actor: User, run: EmployeePayrollRun) -> EmployeePayrollRun: + if run.status != "approved": + raise HTTPException(status_code=400, detail="Only approved payroll runs can be marked paid.") + run.status = "paid" + run.paid_by_user_id = actor.id + run.paid_at_utc = datetime.now(timezone.utc) + for slip in db.execute(select(EmployeePayslip).where(EmployeePayslip.payroll_run_id == run.id)).scalars().all(): + slip.status = "paid" + db.commit() + db.refresh(run) + return run + + +# ------------------------- +# Phase 7A Employee Work Dashboard +# ------------------------- + +PRIORITY_BUCKETS = [ + ("urgent", "Urgent"), + ("high", "High"), + ("normal", "Normal"), + ("low", "Low"), + ("none", "No Priority"), +] + + +def _task_priority_key(task: ClientServiceTaskInstance) -> str: + value = (getattr(task, "priority", None) or "normal").strip().lower() + return value if value in {code for code, _ in PRIORITY_BUCKETS} else "normal" + + +def _task_date_bucket(task: ClientServiceTaskInstance, *, today: date) -> str: + target = getattr(task, "internal_target_date", None) + status = (getattr(task, "status", "") or "").lower() + if status in CLOSED_TASK_STATUSES: + return "Completed / Closed" + if target and target < today: + return "Overdue" + if target and target == today: + return "Due Today" + if target: + return "Upcoming" + return "No Target Date" + + +def _subscription_label(subscription: ClientServiceSubscription | None, task: ClientServiceTaskInstance) -> str: + catalogue = getattr(task, "catalogue", None) + service_name = getattr(catalogue, "service_name", None) or getattr(catalogue, "service_code", None) + if not service_name and subscription: + cat = getattr(subscription, "catalogue", None) + service_name = getattr(cat, "service_name", None) or getattr(cat, "service_code", None) + fy = getattr(subscription, "financial_year", None) or getattr(task, "financial_year", None) or "-" + due = getattr(subscription, "current_due_date", None) if subscription else None + label = f"{service_name or 'Service Engagement'} · FY {fy}" + if due: + label = f"{label} · Due {due}" + return label + + +def _task_status_label(task: ClientServiceTaskInstance) -> str: + return dict(TASK_STATUSES).get(getattr(task, "status", ""), getattr(task, "status", "")) + + +def _task_priority_label(task: ClientServiceTaskInstance) -> str: + return dict(TASK_PRIORITIES).get(getattr(task, "priority", ""), getattr(task, "priority", "")) + + +def list_employee_work_dashboard( + db: Session, + scope: EmployeeScope, + *, + q: str = "", + status: str = "open", + financial_year: str | None = None, +) -> dict[str, Any]: + """Return assigned service/engagement tasks grouped for the employee workspace. + + Phase 7A is intentionally read-only. It does not modify existing engagement/task + creation flows. It groups existing ClientServiceTaskInstance records assigned to + the logged-in user by priority -> client -> engagement/service subscription. + """ + today = date.today() + status_filter = (status or "open").strip().lower() + + stmt = ( + select(ClientServiceTaskInstance) + .options( + selectinload(ClientServiceTaskInstance.client), + selectinload(ClientServiceTaskInstance.catalogue), + selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.catalogue), + selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_partner), + selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_manager), + selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_staff), + selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.review_partner), + selectinload(ClientServiceTaskInstance.assigned_to), + selectinload(ClientServiceTaskInstance.comments).selectinload(ServiceTaskComment.created_by), + ) + .where( + ClientServiceTaskInstance.tenant_id == scope.tenant_id, + ClientServiceTaskInstance.assigned_to_user_id == scope.own_user_id, + ClientServiceTaskInstance.is_active.is_(True), + ) + ) + if scope.branch_id is not None: + stmt = stmt.where(ClientServiceTaskInstance.branch_id == scope.branch_id) + if financial_year: + stmt = stmt.where(ClientServiceTaskInstance.financial_year == financial_year.strip()) + + if status_filter == "open": + stmt = stmt.where(ClientServiceTaskInstance.status.notin_(list(CLOSED_TASK_STATUSES))) + elif status_filter == "closed": + stmt = stmt.where(ClientServiceTaskInstance.status.in_(list(CLOSED_TASK_STATUSES))) + elif status_filter in {code for code, _ in TASK_STATUSES}: + stmt = stmt.where(ClientServiceTaskInstance.status == status_filter) + + if q.strip(): + like = f"%{q.strip()}%" + stmt = stmt.where( + or_( + ClientServiceTaskInstance.task_name.ilike(like), + ClientServiceTaskInstance.description.ilike(like), + ClientServiceTaskInstance.client.has(or_( + Client.client_name.ilike(like), + Client.client_code.ilike(like), + )), + ClientServiceTaskInstance.catalogue.has(or_( + ServiceCatalogue.service_name.ilike(like), + ServiceCatalogue.service_code.ilike(like), + )), + ) + ) + + tasks = db.execute( + stmt.order_by( + ClientServiceTaskInstance.priority.desc(), + ClientServiceTaskInstance.internal_target_date.is_(None), + ClientServiceTaskInstance.internal_target_date.asc(), + ClientServiceTaskInstance.sequence_no.asc(), + ClientServiceTaskInstance.id.desc(), + ) + ).scalars().all() + + summary = { + "total": len(tasks), + "open": 0, + "overdue": 0, + "due_today": 0, + "upcoming": 0, + "completed": 0, + } + priority_map: dict[str, dict[str, Any]] = { + code: {"code": code, "label": label, "task_count": 0, "clients": []} + for code, label in PRIORITY_BUCKETS + } + client_lookup: dict[tuple[str, int], dict[str, Any]] = {} + engagement_lookup: dict[tuple[str, int, int], dict[str, Any]] = {} + + for task in tasks: + is_closed = (task.status or "") in CLOSED_TASK_STATUSES + if is_closed: + summary["completed"] += 1 + else: + summary["open"] += 1 + target = task.internal_target_date + if target and target < today and not is_closed: + summary["overdue"] += 1 + elif target and target == today and not is_closed: + summary["due_today"] += 1 + elif target and target > today and not is_closed: + summary["upcoming"] += 1 + + priority_key = _task_priority_key(task) + priority_bucket = priority_map.setdefault(priority_key, {"code": priority_key, "label": priority_key.title(), "task_count": 0, "clients": []}) + priority_bucket["task_count"] += 1 + + client = getattr(task, "client", None) + client_id = getattr(client, "id", 0) or 0 + client_key = (priority_key, client_id) + if client_key not in client_lookup: + client_group = { + "client": client, + "client_name": getattr(client, "client_name", None) or "Unlinked Client", + "client_code": getattr(client, "client_code", None) or "", + "task_count": 0, + "engagements": [], + } + client_lookup[client_key] = client_group + priority_bucket["clients"].append(client_group) + client_group = client_lookup[client_key] + client_group["task_count"] += 1 + + subscription = getattr(task, "subscription", None) + engagement_id = getattr(subscription, "id", None) or getattr(task, "subscription_id", 0) or 0 + engagement_key = (priority_key, client_id, engagement_id) + if engagement_key not in engagement_lookup: + engagement_group = { + "subscription": subscription, + "label": _subscription_label(subscription, task), + "status": getattr(subscription, "status", None) or "-", + "due_date": getattr(subscription, "current_due_date", None) if subscription else None, + "task_count": 0, + "open_count": 0, + "completed_count": 0, + "tasks": [], + } + engagement_lookup[engagement_key] = engagement_group + client_group["engagements"].append(engagement_group) + engagement_group = engagement_lookup[engagement_key] + engagement_group["task_count"] += 1 + if is_closed: + engagement_group["completed_count"] += 1 + else: + engagement_group["open_count"] += 1 + + task.comment_count = len([c for c in getattr(task, "comments", []) if not getattr(c, "is_deleted", False)]) + task.latest_comment = next((c for c in getattr(task, "comments", []) if not getattr(c, "is_deleted", False)), None) + task.status_label = _task_status_label(task) + task.priority_label = _task_priority_label(task) + task.date_bucket = _task_date_bucket(task, today=today) + task.is_overdue = bool(target and target < today and not is_closed) + task.is_due_today = bool(target and target == today and not is_closed) + engagement_group["tasks"].append(task) + + groups = [priority_map[code] for code, _label in PRIORITY_BUCKETS if priority_map.get(code, {}).get("task_count")] + return {"summary": summary, "groups": groups, "q": q, "status": status_filter, "today": today} + + + +def _employee_work_task_query(db: Session, scope: EmployeeScope, *, assigned_only: bool = True, financial_year: str | None = None): + """Base query for employee self-work views. + + Kept separate for Phase 7I so the staff kanban and engagement board reuse the + same tenant/branch/assignee scoping and do not bypass existing security rules. + """ + stmt = ( + select(ClientServiceTaskInstance) + .options( + selectinload(ClientServiceTaskInstance.client), + selectinload(ClientServiceTaskInstance.catalogue), + selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.catalogue), + selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_partner), + selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_manager), + selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_staff), + selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.review_partner), + selectinload(ClientServiceTaskInstance.assigned_to), + selectinload(ClientServiceTaskInstance.comments).selectinload(ServiceTaskComment.created_by), + ) + .where( + ClientServiceTaskInstance.tenant_id == scope.tenant_id, + ClientServiceTaskInstance.is_active.is_(True), + ) + ) + if assigned_only: + stmt = stmt.where(ClientServiceTaskInstance.assigned_to_user_id == scope.own_user_id) + if scope.branch_id is not None: + stmt = stmt.where(ClientServiceTaskInstance.branch_id == scope.branch_id) + if financial_year: + stmt = stmt.where(ClientServiceTaskInstance.financial_year == financial_year.strip()) + return stmt + + +def _phase7i_task_card_enrich(task: ClientServiceTaskInstance, *, today: date) -> None: + is_closed = (task.status or "") in CLOSED_TASK_STATUSES + target = getattr(task, "internal_target_date", None) + task.comment_count = len([c for c in getattr(task, "comments", []) if not getattr(c, "is_deleted", False)]) + task.latest_comment = next((c for c in getattr(task, "comments", []) if not getattr(c, "is_deleted", False)), None) + task.status_label = _task_status_label(task) + task.priority_label = _task_priority_label(task) + task.date_bucket = _task_date_bucket(task, today=today) + task.is_overdue = bool(target and target < today and not is_closed) + task.is_due_today = bool(target and target == today and not is_closed) + task.engagement_label = _subscription_label(getattr(task, "subscription", None), task) + + +def list_employee_work_kanban( + db: Session, + scope: EmployeeScope, + *, + q: str = "", + status: str = "open", + financial_year: str | None = None, +) -> dict[str, Any]: + """Return staff self-work as engagement cards in kanban columns. + + Phase 7I does not introduce a new task table. It reuses existing + client_service_task_instances and groups assigned tasks by engagement/service + subscription so staff can open one engagement board and work through tasks. + """ + today = date.today() + status_filter = (status or "open").strip().lower() + stmt = _employee_work_task_query(db, scope, assigned_only=True, financial_year=financial_year) + + if status_filter == "open": + stmt = stmt.where(ClientServiceTaskInstance.status.notin_(list(CLOSED_TASK_STATUSES))) + elif status_filter == "closed": + stmt = stmt.where(ClientServiceTaskInstance.status.in_(list(CLOSED_TASK_STATUSES))) + elif status_filter in {code for code, _ in TASK_STATUSES}: + stmt = stmt.where(ClientServiceTaskInstance.status == status_filter) + + if q.strip(): + like = f"%{q.strip()}%" + stmt = stmt.where( + or_( + ClientServiceTaskInstance.task_name.ilike(like), + ClientServiceTaskInstance.description.ilike(like), + ClientServiceTaskInstance.client.has(or_(Client.client_name.ilike(like), Client.client_code.ilike(like))), + ClientServiceTaskInstance.catalogue.has(or_(ServiceCatalogue.service_name.ilike(like), ServiceCatalogue.service_code.ilike(like))), + ) + ) + + tasks = db.execute( + stmt.order_by( + ClientServiceTaskInstance.internal_target_date.is_(None), + ClientServiceTaskInstance.internal_target_date.asc(), + ClientServiceTaskInstance.priority.desc(), + ClientServiceTaskInstance.sequence_no.asc(), + ClientServiceTaskInstance.id.desc(), + ) + ).scalars().all() + + summary = {"total": len(tasks), "open": 0, "pending": 0, "in_progress": 0, "blocked": 0, "completed": 0, "overdue": 0, "due_today": 0} + columns = [ + {"code": "pending", "label": "Pending", "cards": []}, + {"code": "in_progress", "label": "In Progress", "cards": []}, + {"code": "blocked", "label": "Blocked", "cards": []}, + {"code": "completed", "label": "Completed", "cards": []}, + ] + column_lookup = {c["code"]: c for c in columns} + card_lookup: dict[tuple[str, int], dict[str, Any]] = {} + + for task in tasks: + _phase7i_task_card_enrich(task, today=today) + status_code = (task.status or "pending").strip().lower() + is_closed = status_code in CLOSED_TASK_STATUSES + summary["completed" if is_closed else "open"] += 1 + if status_code in summary: + summary[status_code] += 1 + if task.is_overdue: + summary["overdue"] += 1 + if task.is_due_today: + summary["due_today"] += 1 + + column_code = "completed" if is_closed else status_code + if column_code not in column_lookup: + column_code = "pending" + subscription = getattr(task, "subscription", None) + engagement_id = getattr(subscription, "id", None) or getattr(task, "subscription_id", 0) or 0 + card_key = (column_code, engagement_id) + if card_key not in card_lookup: + client = getattr(task, "client", None) + card = { + "engagement_id": engagement_id, + "subscription": subscription, + "label": _subscription_label(subscription, task), + "client_name": getattr(client, "client_name", None) or "Unlinked Client", + "client_code": getattr(client, "client_code", None) or "", + "service_name": getattr(getattr(subscription, "catalogue", None), "service_name", None) or getattr(getattr(task, "catalogue", None), "service_name", None) or "Service Engagement", + "financial_year": getattr(subscription, "financial_year", None) or getattr(task, "financial_year", None) or "-", + "due_date": getattr(subscription, "current_due_date", None) if subscription else getattr(task, "internal_target_date", None), + "status": getattr(subscription, "status", None) or "active", + "task_count": 0, + "open_count": 0, + "completed_count": 0, + "blocked_count": 0, + "overdue_count": 0, + "due_today_count": 0, + "latest_comment": None, + "tasks": [], + } + card_lookup[card_key] = card + column_lookup[column_code]["cards"].append(card) + card = card_lookup[card_key] + card["task_count"] += 1 + card["tasks"].append(task) + if is_closed: + card["completed_count"] += 1 + else: + card["open_count"] += 1 + if status_code == "blocked": + card["blocked_count"] += 1 + if task.is_overdue: + card["overdue_count"] += 1 + if task.is_due_today: + card["due_today_count"] += 1 + if task.latest_comment and not card.get("latest_comment"): + card["latest_comment"] = task.latest_comment + + return {"summary": summary, "columns": columns, "q": q, "status": status_filter, "today": today} + + +def list_employee_engagement_documents(db: Session, scope: EmployeeScope, engagement_id: int, *, financial_year: str | None = None) -> list[EngagementDocument]: + stmt = ( + select(EngagementDocument) + .options(selectinload(EngagementDocument.versions)) + .where( + EngagementDocument.tenant_id == scope.tenant_id, + EngagementDocument.engagement_id == engagement_id, + EngagementDocument.is_deleted.is_(False), + ) + .order_by(EngagementDocument.document_type.asc(), EngagementDocument.updated_at_utc.desc(), EngagementDocument.id.desc()) + ) + if scope.branch_id is not None: + stmt = stmt.where(or_(EngagementDocument.branch_id == scope.branch_id, EngagementDocument.branch_id.is_(None))) + if financial_year: + stmt = stmt.where(EngagementDocument.financial_year == financial_year.strip()) + return db.execute(stmt).scalars().unique().all() + + +def get_employee_engagement_work_board(db: Session, scope: EmployeeScope, engagement_id: int, *, financial_year: str | None = None) -> dict[str, Any]: + today = date.today() + stmt = _employee_work_task_query(db, scope, assigned_only=True, financial_year=financial_year).where(ClientServiceTaskInstance.subscription_id == engagement_id) + tasks = db.execute( + stmt.order_by( + ClientServiceTaskInstance.sequence_no.asc(), + ClientServiceTaskInstance.internal_target_date.is_(None), + ClientServiceTaskInstance.internal_target_date.asc(), + ClientServiceTaskInstance.id.asc(), + ) + ).scalars().all() + if not tasks: + raise HTTPException(status_code=404, detail="Engagement work not found or not assigned to you") + + subscription = getattr(tasks[0], "subscription", None) + client = getattr(tasks[0], "client", None) + summary = {"total": len(tasks), "open": 0, "pending": 0, "in_progress": 0, "blocked": 0, "completed": 0, "overdue": 0, "due_today": 0} + columns = [ + {"code": "pending", "label": "Pending", "tasks": []}, + {"code": "in_progress", "label": "In Progress", "tasks": []}, + {"code": "blocked", "label": "Blocked", "tasks": []}, + {"code": "completed", "label": "Completed", "tasks": []}, + ] + column_lookup = {c["code"]: c for c in columns} + + for task in tasks: + _phase7i_task_card_enrich(task, today=today) + status_code = (task.status or "pending").strip().lower() + is_closed = status_code in CLOSED_TASK_STATUSES + summary["completed" if is_closed else "open"] += 1 + if status_code in summary: + summary[status_code] += 1 + if task.is_overdue: + summary["overdue"] += 1 + if task.is_due_today: + summary["due_today"] += 1 + column_code = "completed" if is_closed else status_code + if column_code not in column_lookup: + column_code = "pending" + column_lookup[column_code]["tasks"].append(task) + + return { + "engagement_id": engagement_id, + "subscription": subscription, + "client": client, + "label": _subscription_label(subscription, tasks[0]), + "summary": summary, + "columns": columns, + "documents": list_employee_engagement_documents(db, scope, engagement_id, financial_year=financial_year), + "today": today, + } + + + +def list_employee_work_assignable_users(db: Session, scope: EmployeeScope) -> list[User]: + """Users that can be assigned engagement/service tasks in the active employee scope.""" + stmt = ( + select(User) + .where(User.tenant_id == scope.tenant_id) + .order_by(User.full_name.asc(), User.email.asc(), User.id.asc()) + ) + if scope.branch_id is not None: + stmt = stmt.where(or_(User.branch_id == scope.branch_id, User.branch_id.is_(None))) + return db.execute(stmt).scalars().all() + + +def list_visible_work_assignment_dashboard( + db: Session, + scope: EmployeeScope, + *, + q: str = "", + status: str = "open", + assigned_to_user_id: int | None = None, + client_id: int | None = None, + financial_year: str | None = None, +) -> dict[str, Any]: + """Manager/admin work allocation view grouped by priority -> client -> engagement. + + This uses the existing client_service_task_instances table and therefore does + not disturb the engagement/task generation flow. It is intentionally an + assignment/review layer over existing service execution tasks. + """ + today = date.today() + status_filter = (status or "open").strip().lower() + + stmt = ( + select(ClientServiceTaskInstance) + .options( + selectinload(ClientServiceTaskInstance.client), + selectinload(ClientServiceTaskInstance.catalogue), + selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.catalogue), + selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_partner), + selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_manager), + selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_staff), + selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.review_partner), + selectinload(ClientServiceTaskInstance.assigned_to), + selectinload(ClientServiceTaskInstance.comments).selectinload(ServiceTaskComment.created_by), + ) + .where( + ClientServiceTaskInstance.tenant_id == scope.tenant_id, + ClientServiceTaskInstance.is_active.is_(True), + ) + ) + if scope.branch_id is not None: + stmt = stmt.where(ClientServiceTaskInstance.branch_id == scope.branch_id) + if financial_year: + stmt = stmt.where(ClientServiceTaskInstance.financial_year == financial_year.strip()) + if assigned_to_user_id: + stmt = stmt.where(ClientServiceTaskInstance.assigned_to_user_id == int(assigned_to_user_id)) + if client_id: + stmt = stmt.where(ClientServiceTaskInstance.client_id == int(client_id)) + + if status_filter == "open": + stmt = stmt.where(ClientServiceTaskInstance.status.notin_(list(CLOSED_TASK_STATUSES))) + elif status_filter == "unassigned": + stmt = stmt.where(ClientServiceTaskInstance.assigned_to_user_id.is_(None)) + elif status_filter == "closed": + stmt = stmt.where(ClientServiceTaskInstance.status.in_(list(CLOSED_TASK_STATUSES))) + elif status_filter in {code for code, _ in TASK_STATUSES}: + stmt = stmt.where(ClientServiceTaskInstance.status == status_filter) + + if q.strip(): + like = f"%{q.strip()}%" + stmt = stmt.where( + or_( + ClientServiceTaskInstance.task_name.ilike(like), + ClientServiceTaskInstance.description.ilike(like), + ClientServiceTaskInstance.client.has(or_(Client.client_name.ilike(like), Client.client_code.ilike(like))), + ClientServiceTaskInstance.catalogue.has(or_(ServiceCatalogue.service_name.ilike(like), ServiceCatalogue.service_code.ilike(like))), + ) + ) + + tasks = db.execute( + stmt.order_by( + ClientServiceTaskInstance.priority.desc(), + ClientServiceTaskInstance.internal_target_date.is_(None), + ClientServiceTaskInstance.internal_target_date.asc(), + ClientServiceTaskInstance.sequence_no.asc(), + ClientServiceTaskInstance.id.desc(), + ) + ).scalars().all() + + summary = { + "total": len(tasks), + "open": 0, + "unassigned": 0, + "overdue": 0, + "due_today": 0, + "completed": 0, + } + priority_map: dict[str, dict[str, Any]] = {code: {"code": code, "label": label, "task_count": 0, "clients": []} for code, label in PRIORITY_BUCKETS} + client_lookup: dict[tuple[str, int], dict[str, Any]] = {} + engagement_lookup: dict[tuple[str, int, int], dict[str, Any]] = {} + + for task in tasks: + is_closed = (task.status or "") in CLOSED_TASK_STATUSES + if is_closed: + summary["completed"] += 1 + else: + summary["open"] += 1 + if not getattr(task, "assigned_to_user_id", None): + summary["unassigned"] += 1 + target = task.internal_target_date + if target and target < today and not is_closed: + summary["overdue"] += 1 + elif target and target == today and not is_closed: + summary["due_today"] += 1 + + priority_key = _task_priority_key(task) + priority_bucket = priority_map.setdefault(priority_key, {"code": priority_key, "label": priority_key.title(), "task_count": 0, "clients": []}) + priority_bucket["task_count"] += 1 + + client = getattr(task, "client", None) + client_id_value = getattr(client, "id", 0) or getattr(task, "client_id", 0) or 0 + client_key = (priority_key, client_id_value) + if client_key not in client_lookup: + client_group = { + "client": client, + "client_name": getattr(client, "client_name", None) or "Unlinked Client", + "client_code": getattr(client, "client_code", None) or "", + "task_count": 0, + "engagements": [], + } + client_lookup[client_key] = client_group + priority_bucket["clients"].append(client_group) + client_group = client_lookup[client_key] + client_group["task_count"] += 1 + + subscription = getattr(task, "subscription", None) + engagement_id = getattr(subscription, "id", None) or getattr(task, "subscription_id", 0) or 0 + engagement_key = (priority_key, client_id_value, engagement_id) + if engagement_key not in engagement_lookup: + engagement_group = { + "subscription": subscription, + "label": _subscription_label(subscription, task), + "status": getattr(subscription, "status", None) or "-", + "due_date": getattr(subscription, "current_due_date", None) if subscription else None, + "task_count": 0, + "open_count": 0, + "completed_count": 0, + "tasks": [], + } + engagement_lookup[engagement_key] = engagement_group + client_group["engagements"].append(engagement_group) + engagement_group = engagement_lookup[engagement_key] + engagement_group["task_count"] += 1 + if is_closed: + engagement_group["completed_count"] += 1 + else: + engagement_group["open_count"] += 1 + + task.comment_count = len([c for c in getattr(task, "comments", []) if not getattr(c, "is_deleted", False)]) + task.latest_comment = next((c for c in getattr(task, "comments", []) if not getattr(c, "is_deleted", False)), None) + task.status_label = _task_status_label(task) + task.priority_label = _task_priority_label(task) + task.date_bucket = _task_date_bucket(task, today=today) + task.is_overdue = bool(target and target < today and not is_closed) + task.is_due_today = bool(target and target == today and not is_closed) + engagement_group["tasks"].append(task) + + groups = [priority_map[code] for code, _label in PRIORITY_BUCKETS if priority_map.get(code, {}).get("task_count")] + return {"summary": summary, "groups": groups, "q": q, "status": status_filter, "today": today} + + + +def update_service_task_assignment( + db: Session, + scope: EmployeeScope, + task_id: int, + *, + assigned_to_user_id: int | None, + status: str | None, + priority: str | None, + internal_target_date: date | None, + remarks: str | None, + actor_user_id: int, + financial_year: str | None = None, +) -> ClientServiceTaskInstance: + stmt = select(ClientServiceTaskInstance).where( + ClientServiceTaskInstance.id == task_id, + ClientServiceTaskInstance.tenant_id == scope.tenant_id, + ) + if scope.branch_id is not None: + stmt = stmt.where(ClientServiceTaskInstance.branch_id == scope.branch_id) + if financial_year: + stmt = stmt.where(ClientServiceTaskInstance.financial_year == financial_year.strip()) + task = db.execute(stmt).scalar_one_or_none() + if not task: + raise HTTPException(status_code=404, detail="Task not found") + if getattr(task, "is_locked", False): + raise HTTPException(status_code=400, detail="Locked task cannot be changed") + + if assigned_to_user_id: + assignee = db.get(User, int(assigned_to_user_id)) + if not assignee or assignee.tenant_id != scope.tenant_id: + raise HTTPException(status_code=400, detail="Invalid assignee") + if scope.branch_id is not None and getattr(assignee, "branch_id", None) not in (None, scope.branch_id): + raise HTTPException(status_code=400, detail="Assignee is outside active branch") + task.assigned_to_user_id = int(assigned_to_user_id) + else: + task.assigned_to_user_id = None + + allowed_statuses = {code for code, _ in TASK_STATUSES} + if status and status in allowed_statuses: + task.status = status + if status == "completed" and not getattr(task, "completed_at_utc", None): + task.completed_at_utc = datetime.now(timezone.utc) + elif status != "completed": + task.completed_at_utc = None + allowed_priorities = {code for code, _ in TASK_PRIORITIES} + if priority and priority in allowed_priorities: + task.priority = priority + task.internal_target_date = internal_target_date + if remarks is not None: + task.remarks = remarks.strip() or None + task.updated_by_user_id = actor_user_id + db.add(task) + db.commit() + db.refresh(task) + return task + + +def update_own_service_task_status( + db: Session, + scope: EmployeeScope, + task_id: int, + *, + status: str, + remarks: str | None, + actor_user_id: int, + financial_year: str | None = None, +) -> ClientServiceTaskInstance: + stmt = select(ClientServiceTaskInstance).where( + ClientServiceTaskInstance.id == task_id, + ClientServiceTaskInstance.tenant_id == scope.tenant_id, + ClientServiceTaskInstance.assigned_to_user_id == scope.own_user_id, + ) + if scope.branch_id is not None: + stmt = stmt.where(ClientServiceTaskInstance.branch_id == scope.branch_id) + if financial_year: + stmt = stmt.where(ClientServiceTaskInstance.financial_year == financial_year.strip()) + task = db.execute(stmt).scalar_one_or_none() + if not task: + raise HTTPException(status_code=404, detail="Task not found or not assigned to you") + if getattr(task, "is_locked", False): + raise HTTPException(status_code=400, detail="Locked task cannot be changed") + allowed = {"pending", "in_progress", "blocked", "completed"} + if status not in allowed: + raise HTTPException(status_code=400, detail="Invalid status") + task.status = status + if status == "completed": + task.completed_at_utc = datetime.now(timezone.utc) + else: + task.completed_at_utc = None + if status == "in_progress" and not getattr(task, "started_at_utc", None): + task.started_at_utc = datetime.now(timezone.utc) + if remarks is not None and remarks.strip(): + task.remarks = remarks.strip() + task.updated_by_user_id = actor_user_id + db.add(task) + db.commit() + db.refresh(task) + return task + + +# ------------------------- +# Phase 7D Task Communication Timeline +# ------------------------- + +def _apply_task_scope(stmt, scope: EmployeeScope): + stmt = stmt.where(ClientServiceTaskInstance.tenant_id == scope.tenant_id) + if scope.branch_id is not None: + stmt = stmt.where(ClientServiceTaskInstance.branch_id == scope.branch_id) + return stmt + + +def get_work_task_with_communications( + db: Session, + scope: EmployeeScope, + task_id: int, + *, + assigned_only: bool = False, + financial_year: str | None = None, +) -> ClientServiceTaskInstance: + stmt = ( + select(ClientServiceTaskInstance) + .options( + selectinload(ClientServiceTaskInstance.client), + selectinload(ClientServiceTaskInstance.catalogue), + selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.catalogue), + selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_partner), + selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_manager), + selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_staff), + selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.review_partner), + selectinload(ClientServiceTaskInstance.assigned_to), + selectinload(ClientServiceTaskInstance.comments).selectinload(ServiceTaskComment.created_by), + ) + .where( + ClientServiceTaskInstance.id == task_id, + ClientServiceTaskInstance.is_active.is_(True), + ) + ) + stmt = _apply_task_scope(stmt, scope) + if financial_year: + stmt = stmt.where(ClientServiceTaskInstance.financial_year == financial_year.strip()) + if assigned_only: + stmt = stmt.where(ClientServiceTaskInstance.assigned_to_user_id == scope.own_user_id) + + task = db.execute(stmt).scalar_one_or_none() + if not task: + raise HTTPException(status_code=404, detail="Task not found") + + task.status_label = _task_status_label(task) + task.priority_label = _task_priority_label(task) + task.date_bucket = _task_date_bucket(task, today=date.today()) + task.engagement_label = _subscription_label(getattr(task, "subscription", None), task) + task.communication_items = [c for c in getattr(task, "comments", []) if not getattr(c, "is_deleted", False)] + return task + + +def add_work_task_communication( + db: Session, + scope: EmployeeScope, + task_id: int, + *, + comment_type: str, + visibility: str, + message: str, + actor_user_id: int, + assigned_only: bool = False, + financial_year: str | None = None, +) -> ServiceTaskComment: + task = get_work_task_with_communications(db, scope, task_id, assigned_only=assigned_only, financial_year=financial_year) + comment_type = (comment_type or "internal_note").strip() + visibility = (visibility or "internal").strip() + message = (message or "").strip() + + if comment_type not in TASK_COMMUNICATION_TYPE_CODES: + raise HTTPException(status_code=400, detail="Invalid communication type") + if visibility not in TASK_COMMUNICATION_VISIBILITY_CODES: + raise HTTPException(status_code=400, detail="Invalid communication visibility") + if not message: + raise HTTPException(status_code=400, detail="Message is required") + + comment = ServiceTaskComment( + tenant_id=task.tenant_id, + branch_id=task.branch_id, + subscription_id=task.subscription_id, + task_instance_id=task.id, + comment_type=comment_type, + visibility=visibility, + message=message, + created_by_user_id=actor_user_id, + is_deleted=False, + ) + db.add(comment) + task.updated_by_user_id = actor_user_id + db.add(task) + db.commit() + db.refresh(comment) + return comment + + +# ------------------------- +# Phase 7C Engagement Progress Dashboard +# ------------------------- + +def _safe_progress(completed: int, total: int) -> int: + if total <= 0: + return 0 + return int(round((completed / total) * 100)) + + +def _progress_status_bucket(open_count: int, overdue_count: int, completed_count: int, total_count: int) -> str: + if total_count <= 0: + return "no_tasks" + if completed_count >= total_count: + return "completed" + if overdue_count > 0: + return "overdue" + if open_count > 0: + return "in_progress" + return "pending" + + +def list_engagement_progress_dashboard( + db: Session, + scope: EmployeeScope, + *, + q: str = "", + status: str = "open", + assigned_to_user_id: int | None = None, + client_id: int | None = None, + financial_year: str | None = None, +) -> dict[str, Any]: + """Return engagement progress grouped by client -> engagement/service subscription. + + This is a read-only reporting layer over existing client_service_task_instances. + It does not change task generation, assignment, or service execution logic. + """ + today = date.today() + status_filter = (status or "open").strip().lower() + + stmt = ( + select(ClientServiceTaskInstance) + .options( + selectinload(ClientServiceTaskInstance.client), + selectinload(ClientServiceTaskInstance.catalogue), + selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.catalogue), + selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_partner), + selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_manager), + selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_staff), + selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.review_partner), + selectinload(ClientServiceTaskInstance.assigned_to), + selectinload(ClientServiceTaskInstance.comments).selectinload(ServiceTaskComment.created_by), + ) + .where( + ClientServiceTaskInstance.tenant_id == scope.tenant_id, + ClientServiceTaskInstance.is_active.is_(True), + ) + ) + if scope.branch_id is not None: + stmt = stmt.where(ClientServiceTaskInstance.branch_id == scope.branch_id) + if financial_year: + stmt = stmt.where(ClientServiceTaskInstance.financial_year == financial_year.strip()) + if assigned_to_user_id: + stmt = stmt.where(ClientServiceTaskInstance.assigned_to_user_id == int(assigned_to_user_id)) + if client_id: + stmt = stmt.where(ClientServiceTaskInstance.client_id == int(client_id)) + + if status_filter == "open": + stmt = stmt.where(ClientServiceTaskInstance.status.notin_(list(CLOSED_TASK_STATUSES))) + elif status_filter == "closed": + stmt = stmt.where(ClientServiceTaskInstance.status.in_(list(CLOSED_TASK_STATUSES))) + elif status_filter == "overdue": + stmt = stmt.where( + ClientServiceTaskInstance.internal_target_date.is_not(None), + ClientServiceTaskInstance.internal_target_date < today, + ClientServiceTaskInstance.status.notin_(list(CLOSED_TASK_STATUSES)), + ) + elif status_filter == "due_today": + stmt = stmt.where( + ClientServiceTaskInstance.internal_target_date == today, + ClientServiceTaskInstance.status.notin_(list(CLOSED_TASK_STATUSES)), + ) + elif status_filter == "all": + pass + elif status_filter in {code for code, _ in TASK_STATUSES}: + stmt = stmt.where(ClientServiceTaskInstance.status == status_filter) + + if q.strip(): + like = f"%{q.strip()}%" + stmt = stmt.where( + or_( + ClientServiceTaskInstance.task_name.ilike(like), + ClientServiceTaskInstance.description.ilike(like), + ClientServiceTaskInstance.client.has(or_(Client.client_name.ilike(like), Client.client_code.ilike(like))), + ClientServiceTaskInstance.catalogue.has(or_(ServiceCatalogue.service_name.ilike(like), ServiceCatalogue.service_code.ilike(like))), + ) + ) + + tasks = db.execute( + stmt.order_by( + ClientServiceTaskInstance.client_id.asc(), + ClientServiceTaskInstance.subscription_id.asc(), + ClientServiceTaskInstance.internal_target_date.is_(None), + ClientServiceTaskInstance.internal_target_date.asc(), + ClientServiceTaskInstance.sequence_no.asc(), + ClientServiceTaskInstance.id.asc(), + ) + ).scalars().all() + + summary = { + "clients": 0, + "engagements": 0, + "tasks": len(tasks), + "open": 0, + "completed": 0, + "overdue": 0, + "due_today": 0, + "unassigned": 0, + "average_progress": 0, + } + + clients: dict[int, dict[str, Any]] = {} + engagement_lookup: dict[tuple[int, int], dict[str, Any]] = {} + + for task in tasks: + is_closed = (task.status or "") in CLOSED_TASK_STATUSES + target = task.internal_target_date + if is_closed: + summary["completed"] += 1 + else: + summary["open"] += 1 + if target and target < today and not is_closed: + summary["overdue"] += 1 + if target and target == today and not is_closed: + summary["due_today"] += 1 + if not getattr(task, "assigned_to_user_id", None): + summary["unassigned"] += 1 + + client = getattr(task, "client", None) + client_id_value = getattr(client, "id", None) or getattr(task, "client_id", None) or 0 + if client_id_value not in clients: + clients[client_id_value] = { + "client": client, + "client_name": getattr(client, "client_name", None) or "Unlinked Client", + "client_code": getattr(client, "client_code", None) or "", + "task_count": 0, + "open_count": 0, + "completed_count": 0, + "overdue_count": 0, + "due_today_count": 0, + "progress_percent": 0, + "engagements": [], + } + client_group = clients[client_id_value] + client_group["task_count"] += 1 + if is_closed: + client_group["completed_count"] += 1 + else: + client_group["open_count"] += 1 + if target and target < today and not is_closed: + client_group["overdue_count"] += 1 + if target and target == today and not is_closed: + client_group["due_today_count"] += 1 + + subscription = getattr(task, "subscription", None) + engagement_id = getattr(subscription, "id", None) or getattr(task, "subscription_id", None) or 0 + key = (client_id_value, engagement_id) + if key not in engagement_lookup: + engagement_group = { + "subscription": subscription, + "label": _subscription_label(subscription, task), + "status": getattr(subscription, "status", None) or "-", + "due_date": getattr(subscription, "current_due_date", None) if subscription else None, + "task_count": 0, + "open_count": 0, + "completed_count": 0, + "overdue_count": 0, + "due_today_count": 0, + "unassigned_count": 0, + "progress_percent": 0, + "progress_status": "pending", + "tasks": [], + } + engagement_lookup[key] = engagement_group + client_group["engagements"].append(engagement_group) + engagement_group = engagement_lookup[key] + engagement_group["task_count"] += 1 + if is_closed: + engagement_group["completed_count"] += 1 + else: + engagement_group["open_count"] += 1 + if target and target < today and not is_closed: + engagement_group["overdue_count"] += 1 + if target and target == today and not is_closed: + engagement_group["due_today_count"] += 1 + if not getattr(task, "assigned_to_user_id", None): + engagement_group["unassigned_count"] += 1 + + task.comment_count = len([c for c in getattr(task, "comments", []) if not getattr(c, "is_deleted", False)]) + task.latest_comment = next((c for c in getattr(task, "comments", []) if not getattr(c, "is_deleted", False)), None) + task.status_label = _task_status_label(task) + task.priority_label = _task_priority_label(task) + task.date_bucket = _task_date_bucket(task, today=today) + task.is_overdue = bool(target and target < today and not is_closed) + task.is_due_today = bool(target and target == today and not is_closed) + engagement_group["tasks"].append(task) + + total_progress = 0 + for client_group in clients.values(): + client_group["progress_percent"] = _safe_progress(client_group["completed_count"], client_group["task_count"]) + for engagement_group in client_group["engagements"]: + engagement_group["progress_percent"] = _safe_progress(engagement_group["completed_count"], engagement_group["task_count"]) + engagement_group["progress_status"] = _progress_status_bucket( + engagement_group["open_count"], + engagement_group["overdue_count"], + engagement_group["completed_count"], + engagement_group["task_count"], + ) + total_progress += engagement_group["progress_percent"] + + client_list = sorted( + clients.values(), + key=lambda c: (-c["overdue_count"], -c["due_today_count"], c["client_name"].lower()), + ) + engagement_count = len(engagement_lookup) + summary["clients"] = len(client_list) + summary["engagements"] = engagement_count + summary["average_progress"] = _safe_progress(total_progress, engagement_count * 100) if engagement_count else 0 + + return {"summary": summary, "clients": client_list, "q": q, "status": status_filter, "today": today} diff --git a/app/modules/employees/templates/employees/_my_workspace_tabs.html b/app/modules/employees/templates/employees/_my_workspace_tabs.html new file mode 100644 index 0000000..bbdc683 --- /dev/null +++ b/app/modules/employees/templates/employees/_my_workspace_tabs.html @@ -0,0 +1,38 @@ +{% set current_path = request.url.path %} +{% set _tab_base = "inline-flex shrink-0 items-center rounded-xl px-3 py-2 text-sm font-semibold whitespace-nowrap transition" %} +{% set _tab_active = "bg-brand-600 text-white shadow-soft" %} +{% set _tab_idle = "border border-slate-300 bg-white text-slate-700 hover:bg-slate-50" %} +
+
My Workspace
+
+
+ {% if can_view_employee_portal(current_user, current_user_permissions, current_user_roles) %} + Overview + {% endif %} + {% if can_view_own_employee_work(current_user, current_user_permissions, current_user_roles) %} + My Work Board + {% endif %} + {% if can_view_own_employee_attendance(current_user, current_user_permissions, current_user_roles) %} + My Attendance + {% endif %} + {% if can_view_employee_portal(current_user, current_user_permissions, current_user_roles) %} + My Profile + {% endif %} + {% if can_view_own_employee_leave(current_user, current_user_permissions, current_user_roles) %} + My Leave + {% endif %} + {% if can_view_own_employee_documents(current_user, current_user_permissions, current_user_roles) %} + My Documents + {% endif %} + {% if can_view_own_employee_payslips(current_user, current_user_permissions, current_user_roles) %} + My Payslips + {% endif %} + {% if can_request_own_employee_offboarding(current_user, current_user_permissions, current_user_roles) %} + My Offboarding + {% endif %} + + My Alert{% if unread_alert_count is defined and unread_alert_count > 0 %}{{ unread_alert_count }}{% endif %} + +
+
+
diff --git a/app/modules/employees/templates/employees/attendance_list.html b/app/modules/employees/templates/employees/attendance_list.html new file mode 100644 index 0000000..5b3493f --- /dev/null +++ b/app/modules/employees/templates/employees/attendance_list.html @@ -0,0 +1,114 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +{% include "modules/managers/templates/managers/_manager_tabs.html" %} +
+
+
+

Employee Attendance

+

Review employee attendance, filter records, and manually mark attendance where required.

+
+ Employees +
+ +
+
+ + + + + + +
+
+ + {% if can_approve_employee_attendance(current_user, current_user_permissions, current_user_roles) %} +
+ +

Manual Attendance Marking

+
+ + + + + +
+
+ {% endif %} + +
+ + + + + + + + + + + + + + + + {% for row in rows %} + + + + + + + + + + + + {% else %} + + {% endfor %} + +
DateEmployeePunchStatusApprovalTimingGeo/IP EvidenceRemarksAction
{{ row.attendance_date }}
{{ row.employee.full_name if row.employee else ('Employee #' ~ row.employee_id) }}
{{ row.employee.employee_code if row.employee else '' }}
In: {{ row.punch_in_local_at.strftime('%H:%M') if row.punch_in_local_at else (row.punch_in_utc.strftime('%H:%M UTC') if row.punch_in_utc else '-') }}
Out: {{ row.punch_out_local_at.strftime('%H:%M') if row.punch_out_local_at else (row.punch_out_utc.strftime('%H:%M UTC') if row.punch_out_utc else '-') }}
{{ row.status.replace('_',' ').title() }}{{ row.approval_status.title() }} +
TZ: {{ row.branch_timezone or 'Asia/Kolkata' }}
+
Rule: {{ (row.attendance_rule_status or '-').replace('_',' ').title() }}
+
Late: {{ (row.late_by_minutes ~ ' min') if row.late_by_minutes else '-' }}
+
Weekly Off: {{ 'Yes' if row.is_weekly_off else 'No' }}
+
+
Source: {{ (row.source or '-').replace('_',' ').title() }}
+
Geo: {{ (row.punch_in_geo_status or '-').replace('_',' ').title() }}
+
Distance: {{ (row.punch_in_distance_meters ~ ' m') if row.punch_in_distance_meters is not none else '-' }}
+
IP: {{ (row.punch_in_ip_status or '-').replace('_',' ').title() }}
+
{{ row.remarks or row.review_notes or '-' }} + {% if can_approve_employee_attendance(current_user, current_user_permissions, current_user_roles) %} +
+ + + + +
+
+ + + + +
+ {% else %}-{% endif %} +
No attendance records found.
+
+
+{% endblock %} diff --git a/app/modules/employees/templates/employees/detail.html b/app/modules/employees/templates/employees/detail.html new file mode 100644 index 0000000..7d3215e --- /dev/null +++ b/app/modules/employees/templates/employees/detail.html @@ -0,0 +1,108 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+ + +
+
+ + + + + +
+
+
+
+
+

{{ employee.full_name }}

+

Employee Code: {{ employee.employee_code }} • Audit Firm {{ employee.tenant_id }} • Branch {{ employee.branch_id }}

+
+
+ Back + {% if can_manage_employees(current_user, current_user_permissions, current_user_roles) %}Edit{% endif %} +
+
+ + {% if link_error %} +
Unable to update login linkage. Please select an active user from the same audit firm and branch who is not already linked to another employee.
+ {% endif %} + +
+
+
+

Login user linkage

+ {% if employee.user_id %} +

This employee is linked to {% if employee.user %}{{ employee.user.full_name or employee.user.email }}{% else %}User #{{ employee.user_id }}{% endif %}. Employee self-service pages will work for this login.

+ {% else %} +

This employee is not linked to a login user yet. Attendance, My Workspace, profile, leave, documents and payslip self-service need this link.

+ {% endif %} +
+ {% if can_manage_employees(current_user, current_user_permissions, current_user_roles) %} +
+ + + +
+ {% endif %} +
+
+ +
+
+
+

Basic Details

+
+
Email
{{ employee.email or '-' }}
+
Mobile
{{ employee.mobile or '-' }}
+
Department
{{ employee.department or '-' }}
+
Designation
{{ employee.designation or '-' }}
+
Employment Type
{{ employee.employment_type.replace('_',' ').title() }}
+
Date of Joining
{{ employee.date_of_joining or '-' }}
+
Date of Leaving
{{ employee.date_of_leaving or '-' }}
+
Linked User
{% if employee.user %}{{ employee.user.full_name or employee.user.email }} ({{ employee.user.email }}){% else %}Not linked{% endif %}
+
+
+ +
+

Statutory & Bank Details

+
+
PAN
{{ employee.pan or '-' }}
+
UAN
{{ employee.uan or '-' }}
+
ESI No
{{ employee.esi_no or '-' }}
+
PF No
{{ employee.pf_no or '-' }}
+
Bank Name
{{ employee.bank_name or '-' }}
+
Bank Account
{{ employee.bank_account_no or '-' }}
+
IFSC
{{ employee.bank_ifsc or '-' }}
+
+
+
+ +
+
+

Status

+
{{ employee.status.replace('_',' ').title() }}
+ {% if can_change_employee_status(current_user, current_user_permissions, current_user_roles) %} +
+ +
+
+ +
+ {% endif %} +
+ +
+

Emergency / Notes

+
Emergency Contact
{{ employee.emergency_contact_name or '-' }} {{ employee.emergency_contact_mobile or '' }}
Address
{{ employee.address or '-' }}
Notes
{{ employee.notes or '-' }}
+
+
+
+
+{% endblock %} diff --git a/app/modules/employees/templates/employees/document_types.html b/app/modules/employees/templates/employees/document_types.html new file mode 100644 index 0000000..ed3ee4f --- /dev/null +++ b/app/modules/employees/templates/employees/document_types.html @@ -0,0 +1,61 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+ {% if errors %}
{{ errors|join(', ') }}
{% endif %} +
+
+

Employee Document Types

+

Maintain branch-wise document categories like PAN, Aadhaar, certificates and bank proof.

+
+
+ + +
+
+ +
+

Add Document Type

+
+ +
+
+
+
+ + + + +
+
+
+
+ +
+ + + + {% for row in rows %} + + + + + + {% else %}{% endfor %} + +
CodeNameRulesEdit
{{ row.code }}{{ row.name }}
{{ row.description or '' }}
{% if row.is_mandatory %}Mandatory{% else %}Optional{% endif %} • {% if row.allow_employee_upload %}Employee upload{% else %}HR upload only{% endif %} • {% if row.requires_verification %}Verification{% else %}No verification{% endif %} • {% if row.is_active %}Active{% else %}Inactive{% endif %} +
Edit +
+ + + + + + + + +
+
+
No document types found.
+
+
+{% endblock %} diff --git a/app/modules/employees/templates/employees/documents.html b/app/modules/employees/templates/employees/documents.html new file mode 100644 index 0000000..bf7625f --- /dev/null +++ b/app/modules/employees/templates/employees/documents.html @@ -0,0 +1,44 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+ {% if errors %}
{{ errors|join(', ') }}
{% endif %} +
+

Employee Documents

Upload, review and archive employee documents with audit firm/branch scope.

+ Document Types +
+ +
+

Upload Document

+
+ +
+
+
+
+
+
+
+
+
+
+
+
+ +
+ + {% for row in rows %} + + + + + + {% else %}{% endfor %} +
EmployeeDocumentStatusDatesReview
{{ row.employee.employee_code if row.employee else '' }}
{{ row.employee.full_name if row.employee else '' }}
{{ row.title }}
{{ row.document_type.code if row.document_type else 'GENERAL' }} • {{ row.original_filename }} • {{ row.file_size_bytes or 0 }} bytes
{{ row.storage_path }}
{{ row.status }}
{{ row.visibility }}
Issue: {{ row.issue_date or '-' }}
Expiry: {{ row.expiry_date or '-' }}
+ {% if row.status != 'archived' %} +
+
+ {% endif %} +
No documents found.
+
+
+{% endblock %} diff --git a/app/modules/employees/templates/employees/form.html b/app/modules/employees/templates/employees/form.html new file mode 100644 index 0000000..7d0b4ee --- /dev/null +++ b/app/modules/employees/templates/employees/form.html @@ -0,0 +1,107 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +{% set is_dict = employee is mapping %} +{% set is_edit = mode == 'edit' %} +{% macro val(name, default='') -%} + {%- if employee -%} + {%- if is_dict -%}{{ employee.get(name, default) or '' }}{%- else -%}{{ employee|attr(name) or '' }}{%- endif -%} + {%- else -%}{{ default }}{%- endif -%} +{%- endmacro %} +
+
+
+

{{ title }}

+

Create or update the employee master. Attendance, leave, payroll and ESS will be added in later phases.

+
+ Back +
+ + {% if errors %} +
    {% for error in errors %}
  • {{ error }}
  • {% endfor %}
+ {% endif %} + +
+ + + {% if not is_edit %} +
+

Employee Login Link

+

Either link an existing unlinked login user or create a login user automatically for this employee.

+
+
+ + +
+
+ +

When selected, Login Email and Temporary Password below are required.

+
+
+

Minimum 8 characters. User must change password after login.

+
+ + +
+
+
+ {% else %} +
Login user linkage can be changed below. The dropdown shows unlinked users from the same audit firm/branch, plus the currently linked user if any. New user creation is available only while creating an employee.
+
+ + {% set uid = employee.user_id if employee and not is_dict else employee.get('user_id') if employee else None %} + +

Selecting “No user linked” will keep this employee as HR master only. Employee self-service will not work until linked.

+
+ {% endif %} + + {% if not is_edit and scope.allow_cross_tenant %} +
+
+
+
+ {% else %} + + + {% endif %} + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+ + + +
Cancel
+
+
+{% endblock %} diff --git a/app/modules/employees/templates/employees/hr_dashboard.html b/app/modules/employees/templates/employees/hr_dashboard.html new file mode 100644 index 0000000..9b740f3 --- /dev/null +++ b/app/modules/employees/templates/employees/hr_dashboard.html @@ -0,0 +1,97 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Employees / HRMS

+

HR Dashboard & Reports

+

Live summary for the active audit firm{% if scope.branch_id %} and branch{% else %} across accessible branches{% endif %}.

+
+ +
+ +
+

Total Employees

{{ stats.employees.total }}

Active: {{ stats.employees.active }}

+

Today Attendance

{{ stats.attendance.today_present }}

Records: {{ stats.attendance.today_total }}, Pending: {{ stats.attendance.today_pending }}

+

Pending Leave

{{ stats.leave.pending }}

Approved: {{ stats.leave.approved }}

+

Docs to Verify

{{ stats.documents.uploaded }}

Verified: {{ stats.documents.verified }}

+

Payroll Pending

{{ stats.payroll.runs_draft + stats.payroll.runs_generated + stats.payroll.runs_approved }}

Paid runs: {{ stats.payroll.runs_paid }}

+
+ +
+
+

Employee Status

+
+
Active{{ stats.employees.active }}
+
Inactive{{ stats.employees.inactive }}
+
Relieved{{ stats.employees.relieved }}
+
Pending registrations{{ stats.employees.pending_registrations }}
+
+
+ +
+

Workflow Pending

+
+
Attendance approvals{{ stats.attendance.today_pending }}
+
Leave approvals{{ stats.leave.pending }}
+
Onboarding tasks{{ stats.onboarding.pending }}
+
Offboarding requests{{ stats.offboarding.pending }}
+
+
+ +
+

Payroll Snapshot

+
+
Active salary structures{{ stats.payroll.salary_structures_active }}
+
Draft runs{{ stats.payroll.runs_draft }}
+
Generated payslips{{ stats.payroll.payslips_generated }}
+
Paid payslips{{ stats.payroll.payslips_paid }}
+
+
+
+ +
+
+

Recent Employees

View all
+
+ + + {% for emp in stats.recent_employees %} + + {% else %} + + {% endfor %} + +
{{ emp.full_name }}
{{ emp.employee_code }} · {{ emp.designation or '-' }}
{{ emp.status }}
No employees found.
+
+
+ +
+

Recent Leave

View all
+
+ {% for item in stats.recent_leave_requests %} +
{{ item.employee.full_name if item.employee else 'Employee' }}{{ item.status }}
{{ item.leave_type.code if item.leave_type else '-' }} · {{ item.from_date }} to {{ item.to_date }}
+ {% else %} +

No leave requests found.

+ {% endfor %} +
+
+ +
+

Recent Offboarding

View all
+
+ {% for item in stats.recent_offboarding_requests %} +
{{ item.employee.full_name if item.employee else 'Employee' }}{{ item.status }}
Requested relieving date: {{ item.requested_relieving_date or '-' }}
+ {% else %} +

No offboarding requests found.

+ {% endfor %} +
+
+
+
+{% endblock %} diff --git a/app/modules/employees/templates/employees/import_preview.html b/app/modules/employees/templates/employees/import_preview.html new file mode 100644 index 0000000..8d94bd2 --- /dev/null +++ b/app/modules/employees/templates/employees/import_preview.html @@ -0,0 +1,48 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Import Preview - {{ preview.import_label }}

+

Review validation results. Rows with error will be skipped during commit.

+
+ Back to Imports +
+ + {% if errors %}
{{ errors|join(', ') }}
{% endif %} + +
+
Total Rows
{{ preview.summary.total }}
+
Valid
{{ preview.summary.valid }}
+
Warnings
{{ preview.summary.warning }}
+
Errors
{{ preview.summary.error }}
+
+ +
+ + + + + + {% for row in preview.rows %} + + + + + + + + {% else %}{% endfor %} + +
Excel RowStatusActionKey DataMessages
{{ row.row_no }}{{ row.status|title }}{{ row.action|title }} + {% if preview.import_type == 'employees' %}{{ row.data.employee_code }} - {{ row.data.full_name }}{% elif preview.import_type == 'leave_types' %}{{ row.data.code }} - {{ row.data.name }}{% elif preview.import_type == 'leave_balances' %}{{ row.data.employee_code }} / {{ row.data.leave_code }}{% else %}{{ row.data.employee_code }} from {{ row.data.effective_from }}{% endif %} + {{ row.messages|join('; ') if row.messages else '-' }}
No rows found.
+
+ +
+ + Cancel + +
+
+{% endblock %} diff --git a/app/modules/employees/templates/employees/imports.html b/app/modules/employees/templates/employees/imports.html new file mode 100644 index 0000000..671a704 --- /dev/null +++ b/app/modules/employees/templates/employees/imports.html @@ -0,0 +1,40 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

HR Excel Imports

+

Import employees, leave types, opening leave balances and salary structures with validation preview before commit.

+
+ Employees +
+ + {% if errors %}
{{ errors|join(', ') }}
{% endif %} + {% if message %}
{{ message }}
{% endif %} + +
+ {% for key, label in import_types.items() %} +
+
+
+

{{ label }}

+

Download template, fill data, upload and preview before import.

+
+ Template +
+
+ + + + +
+
+ {% endfor %} +
+ +
+
Important
+

For System Admin/Firm Admin using all-branch context, provide branch_id in Excel or switch to a specific active branch before importing. Existing rows are updated based on employee code, leave code, or employee/effective date as applicable.

+
+
+{% endblock %} diff --git a/app/modules/employees/templates/employees/leave_balances.html b/app/modules/employees/templates/employees/leave_balances.html new file mode 100644 index 0000000..72c3f2a --- /dev/null +++ b/app/modules/employees/templates/employees/leave_balances.html @@ -0,0 +1,7 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +{% include "modules/managers/templates/managers/_manager_tabs.html" %} + +

Leave Balances

View and adjust employee leave balances branch-wise.

Leave Requests

Adjust Balance

{% for row in rows %}{% else %}{% endfor %}
EmployeeLeave TypeOpeningCreditAvailedAdjustBalance
{{ row.employee.full_name if row.employee else row.employee_id }}{{ row.leave_type.code if row.leave_type else row.leave_type_id }}{{ row.opening_days }}{{ row.credited_days }}{{ row.availed_days }}{{ row.adjusted_days }}{{ row.balance_days }}
No leave balances found. Balances are created when leave is approved or adjusted.
+ +{% endblock %} diff --git a/app/modules/employees/templates/employees/leave_requests.html b/app/modules/employees/templates/employees/leave_requests.html new file mode 100644 index 0000000..1501d83 --- /dev/null +++ b/app/modules/employees/templates/employees/leave_requests.html @@ -0,0 +1,7 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +{% include "modules/managers/templates/managers/_manager_tabs.html" %} + +

Employee Leave Requests

Review and approve/reject employee leave applications.

Leave Balances
{% for row in rows %}{% else %}{% endfor %}
EmployeeLeavePeriodDaysStatusReason / ReviewAction
{{ row.employee.full_name if row.employee else ('Employee #' ~ row.employee_id) }}
{{ row.employee.employee_code if row.employee else '' }}
{{ row.leave_type.name if row.leave_type else row.leave_type_id }}{{ row.from_date }} to {{ row.to_date }}{{ row.days }}{{ row.status.title() }}
{{ row.reason or '-' }}
{% if row.review_notes %}
Review: {{ row.review_notes }}
{% endif %}
{% if row.status == 'pending' and can_approve_employee_leave(current_user, current_user_permissions, current_user_roles) %}
{% else %}-{% endif %}
No leave requests found.
+ +{% endblock %} diff --git a/app/modules/employees/templates/employees/leave_types.html b/app/modules/employees/templates/employees/leave_types.html new file mode 100644 index 0000000..a9fd0d0 --- /dev/null +++ b/app/modules/employees/templates/employees/leave_types.html @@ -0,0 +1,10 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} + +
+

Leave Types

Configure branch-wise leave masters such as CL, SL, EL and LOP.

+

Add Leave Type

+
{% for row in rows %}{% else %}{% endfor %}
CodeNameQuotaRulesStatusEdit
{{ row.code }}{{ row.name }}{{ row.annual_quota_days }}{{ 'Approval' if row.requires_approval else 'Auto approve' }} · {{ 'Paid' if row.is_paid else 'Unpaid' }}{% if row.allow_negative_balance %} · Negative allowed{% endif %}{{ 'Active' if row.is_active else 'Inactive' }}
Edit
No leave types found.
+
+ +{% endblock %} diff --git a/app/modules/employees/templates/employees/list.html b/app/modules/employees/templates/employees/list.html new file mode 100644 index 0000000..62e74d7 --- /dev/null +++ b/app/modules/employees/templates/employees/list.html @@ -0,0 +1,85 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Employees

+

Employee master, user linkage, department, designation, reporting manager and status.

+
+ {% if can_import_employee_hr(current_user, current_user_permissions, current_user_roles) %} + HR Imports + {% endif %} + {% if can_manage_employees(current_user, current_user_permissions, current_user_roles) %} + Add Employee + {% endif %} +
+ +
+
+ + + + +
+
+ +
+
Visible Employees
{{ rows|length }}
+
Linked Users
{{ link_summary.linked if link_summary else 0 }}
+
Unlinked Employees
{{ link_summary.unlinked if link_summary else 0 }}
+
Active Scope Branch
{{ scope.branch_id if scope.branch_id else 'All' }}
+
Access
{{ 'Cross Audit Firm' if scope.allow_cross_tenant else 'Audit Firm Scoped' }} / {{ 'Cross Branch' if scope.allow_cross_branch else 'Branch Scoped' }}
+
+ + {% if link_summary and link_summary.unlinked %} +
+
{{ link_summary.unlinked }} employee(s) are not linked to login users.
+

Employee self-service pages such as My Workspace, attendance, leave, documents and payslips work fully only after the employee master is linked to an IAM user.

+ Show unlinked employees +
+ {% endif %} + +
+ + + + + + + + + + + + + {% for row in rows %} + + + + + + + + + {% else %} + + {% endfor %} + +
EmployeeDepartmentContactJoiningStatusAction
+
{{ row.full_name }}
+
{{ row.employee_code }}{% if row.user_id %} • User #{{ row.user_id }}{% endif %}
+ {% if row.user_id %} + Login linked + {% else %} + Login not linked + {% endif %} +
{{ row.department or '-' }}
{{ row.designation or '-' }}
{{ row.email or '-' }}
{{ row.mobile or '-' }}
{{ row.date_of_joining or '-' }}{{ row.status.replace('_',' ').title() }}Open
No employees found.
+
+
+{% endblock %} diff --git a/app/modules/employees/templates/employees/offboarding_requests.html b/app/modules/employees/templates/employees/offboarding_requests.html new file mode 100644 index 0000000..72757c5 --- /dev/null +++ b/app/modules/employees/templates/employees/offboarding_requests.html @@ -0,0 +1,10 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+

Employee Offboarding

Manage resignation, handover and relieving checklist.

+

Initiate offboarding

+
+ {% for r in rows %}{% else %}{% endfor %} +
EmployeeRequested DateStatusReview / Completion
{{ r.employee.full_name if r.employee else r.employee_id }}
{{ r.request_type }}{% if r.reason %} · {{ r.reason }}{% endif %}
{{ r.requested_relieving_date or '-' }}{{ r.status }}{% if r.status in ['pending','approved'] %}
{% endif %}{% if r.status == 'approved' %}
{% endif %}
No offboarding requests.
+
+{% endblock %} diff --git a/app/modules/employees/templates/employees/onboarding_checklist.html b/app/modules/employees/templates/employees/onboarding_checklist.html new file mode 100644 index 0000000..3047311 --- /dev/null +++ b/app/modules/employees/templates/employees/onboarding_checklist.html @@ -0,0 +1,26 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+

Onboarding Checklist

Branch-wise reusable joining checklist items.

+
+
+

Add checklist item

+
+ + + + + + + + + + +
+
+
+ {% for r in rows %}{% else %}{% endfor %} +
CodeTitleStageDue DaysStatusUpdate
{{ r.code }}
No checklist items yet.
+
+{% endblock %} diff --git a/app/modules/employees/templates/employees/onboarding_tasks.html b/app/modules/employees/templates/employees/onboarding_tasks.html new file mode 100644 index 0000000..fd744f5 --- /dev/null +++ b/app/modules/employees/templates/employees/onboarding_tasks.html @@ -0,0 +1,10 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+

Employee Onboarding

Generate and track joining checklist tasks.

Checklist Master
+
+
+ {% for r in rows %}{% else %}{% endfor %} +
EmployeeTaskDueStatusAction
{{ r.employee.full_name if r.employee else r.employee_id }}
{{ r.title }}
{{ r.stage }}{% if r.description %} · {{ r.description }}{% endif %}
{{ r.due_date or '-' }}{{ r.status }}
No onboarding tasks. Open an employee detail page and click Generate Onboarding.
+
+{% endblock %} diff --git a/app/modules/employees/templates/employees/payroll_runs.html b/app/modules/employees/templates/employees/payroll_runs.html new file mode 100644 index 0000000..530bfaa --- /dev/null +++ b/app/modules/employees/templates/employees/payroll_runs.html @@ -0,0 +1,13 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+

Payroll Runs

Create monthly payroll run, generate payslips, approve and mark paid.

Payslips
+ {% if scope.branch_id is none %}
Please select a branch context before creating payroll run.
{% endif %} +
+ +
+
+ {% for row in rows %}{% else %}{% endfor %} +
PeriodStatusEmployeesGrossDeductionNetActions
{{ '%02d' % row.pay_month }}/{{ row.pay_year }}{{ row.status.title() }}{{ row.total_employees }}{{ row.gross_amount }}{{ row.deduction_amount }}{{ row.net_amount }}
{% if row.status == 'generated' %}
{% endif %}{% if row.status == 'approved' %}
{% endif %}View
No payroll runs yet.
+
+{% endblock %} diff --git a/app/modules/employees/templates/employees/payroll_structures.html b/app/modules/employees/templates/employees/payroll_structures.html new file mode 100644 index 0000000..70f0319 --- /dev/null +++ b/app/modules/employees/templates/employees/payroll_structures.html @@ -0,0 +1,36 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+

Salary Structures

Maintain branch-wise employee salary structures for payroll generation.

+ Employees +
+ {% if errors %}
{{ errors|join(', ') }}
{% endif %} +
+

Add Salary Structure

+
+ + + + + + + + + + + + + + + + +
+
+
+ + {% for row in rows %}{% set gross = row.basic_amount + row.hra_amount + row.allowance_amount %}{% set ded = row.employee_pf_amount + row.employee_esi_amount + row.professional_tax_amount + row.tds_amount + row.other_deduction_amount %}{% else %}{% endfor %} +
EmployeeEffectiveGrossDeductionsNetStatus
{{ row.employee.employee_code if row.employee else row.employee_id }} - {{ row.employee.full_name if row.employee else '' }}{{ row.effective_from }}{% if row.effective_to %} to {{ row.effective_to }}{% endif %}{{ gross }}{{ ded }}{{ gross - ded }}{{ 'Active' if row.is_active else 'Inactive' }}
No salary structures available.
+
+
+{% endblock %} diff --git a/app/modules/employees/templates/employees/payslips.html b/app/modules/employees/templates/employees/payslips.html new file mode 100644 index 0000000..b7607f4 --- /dev/null +++ b/app/modules/employees/templates/employees/payslips.html @@ -0,0 +1,8 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+

Employee Payslips

Review generated payroll slips for employees.

Payroll Runs
+
+
{% for row in rows %}{% else %}{% endfor %}
PeriodEmployeeGrossDeductionNetStatus
{{ '%02d' % row.pay_month }}/{{ row.pay_year }}{{ row.employee.employee_code if row.employee else row.employee_id }} - {{ row.employee.full_name if row.employee else '' }}{{ row.gross_amount }}{{ row.deduction_amount }}{{ row.net_amount }}{{ row.status.title() }}
No payslips available.
+
+{% endblock %} diff --git a/app/modules/employees/templates/employees/portal_dashboard.html b/app/modules/employees/templates/employees/portal_dashboard.html new file mode 100644 index 0000000..e8dc342 --- /dev/null +++ b/app/modules/employees/templates/employees/portal_dashboard.html @@ -0,0 +1,148 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+ {% include "modules/employees/templates/employees/_my_workspace_tabs.html" %} + +
+
+
+
+

My Workspace

+

Today’s work, attendance and personal profile

+

Use this dashboard for your own assignments, attendance, leave, documents, payslips and alerts.

+
+
+ {% if employee %} + Open My Work Board + Punch / Attendance + {% else %} + Request Employee Linkage + {% endif %} +
+
+
+
+ + {% if employee %} +
+ +
Open Work
+
{{ work_payload.summary.open if work_payload else 0 }}
+
Assigned to me
+
+ +
In Progress
+
{{ work_payload.summary.in_progress if work_payload else 0 }}
+
Currently active
+
+ +
Blocked
+
{{ work_payload.summary.blocked if work_payload else 0 }}
+
Need clarification
+
+ +
Overdue
+
{{ work_payload.summary.overdue if work_payload else 0 }}
+
Immediate action
+
+ +
Attendance
+
{% if today_attendance %}Marked{% else %}Not Marked{% endif %}
+
Today
+
+ +
Payslips
+
{{ payslips|length }}
+
Available slips
+
+
+ +
+
+
+
+
+

My Assignment Board Snapshot

+

Your work is grouped by practical status so you can start from urgent items first.

+
+ Open My Work Board +
+
+ {% if work_payload %} + {% for column in work_payload.columns %} + +
{{ column.label }}
+
{{ column.cards|length }}
+
assignment card(s)
+
+ {% endfor %} + {% else %} +
No work board data available yet.
+ {% endif %} +
+
+ +
+
+
+

Leave & Availability

+

Balances and recent leave requests for your profile.

+
+ Open Leave +
+
+ {% for bal in leave_balances %} +
+
{{ bal.leave_type.name if bal.leave_type else bal.leave_type_id }}
+
{{ bal.balance_days }}
+
days balance
+
+ {% else %} +
No leave balance available yet.
+ {% endfor %} +
+
+
+ + +
+ {% elif pending_request %} +

Employee profile request is pending

Your request #{{ pending_request.id }} is waiting for approval.

+ {% else %} +

Your login is not linked to an employee master

Submit an employee link request or ask Firm Admin/Partner/Branch Manager to link your user account from Employee Master.

Request Employee Link
+ {% endif %} +
+{% endblock %} diff --git a/app/modules/employees/templates/employees/progress_dashboard.html b/app/modules/employees/templates/employees/progress_dashboard.html new file mode 100644 index 0000000..9d4b70f --- /dev/null +++ b/app/modules/employees/templates/employees/progress_dashboard.html @@ -0,0 +1,105 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +{% include "modules/managers/templates/managers/_manager_tabs.html" %} +
+
+
+

Engagement Progress

+

Client-wise and engagement-wise progress based on existing service task instances.

+
+
+ +
+
Clients
{{ progress_payload.summary.clients }}
+
Engagements
{{ progress_payload.summary.engagements }}
+
Tasks
{{ progress_payload.summary.tasks }}
+
Avg Progress
{{ progress_payload.summary.average_progress }}%
+
Open
{{ progress_payload.summary.open }}
+
Overdue
{{ progress_payload.summary.overdue }}
+
Due Today
{{ progress_payload.summary.due_today }}
+
Completed
{{ progress_payload.summary.completed }}
+
+ +
+
+ + + + +
+
+ +
+ {% for client_group in progress_payload.clients %} +
+ +
+
{{ client_group.client_name }}
+
{{ client_group.client_code or 'No client code' }} · {{ client_group.engagements|length }} engagement{{ '' if client_group.engagements|length == 1 else 's' }} · {{ client_group.task_count }} task{{ '' if client_group.task_count == 1 else 's' }}
+
+
+
Client progress{{ client_group.progress_percent }}%
+
+
Open {{ client_group.open_count }} · Completed {{ client_group.completed_count }} · Overdue {{ client_group.overdue_count }}
+
+ +
+ +
+ {% for engagement_group in client_group.engagements %} +
0 or loop.first %}open{% endif %}> + +
+
{{ engagement_group.label }}
+
Status: {{ engagement_group.status.replace('_',' ').title() }} · Due: {{ engagement_group.due_date or '-' }}
+
+
+
{{ engagement_group.progress_status.replace('_',' ').title() }}{{ engagement_group.progress_percent }}%
+
+
Open {{ engagement_group.open_count }} · Completed {{ engagement_group.completed_count }} · Unassigned {{ engagement_group.unassigned_count }}
+
+ +
+
+ + + + + + {% for task in engagement_group.tasks %} + + + + + + + + {% endfor %} + +
TaskAssigneeStatusPriorityTarget
{{ task.task_name }}
{% if task.description %}
{{ task.description }}
{% endif %}
{{ task.assigned_to.full_name if task.assigned_to else 'Unassigned' }}{{ task.status_label }}{{ task.priority_label }}{{ task.internal_target_date or '-' }}
{{ task.date_bucket }}
+
+
+ {% endfor %} +
+
+ {% else %} +

No progress data found

No engagement task matches the selected filter.

+ {% endfor %} +
+
+{% endblock %} diff --git a/app/modules/employees/templates/employees/registration_form.html b/app/modules/employees/templates/employees/registration_form.html new file mode 100644 index 0000000..7fbebf1 --- /dev/null +++ b/app/modules/employees/templates/employees/registration_form.html @@ -0,0 +1,35 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Request Employee Profile

+

Use this when your login user is not yet linked to an employee master.

+
+ Back +
+ + {% if employee %} +
Your user is already linked to employee {{ employee.employee_code }}.
+ {% elif pending_request %} +
Your request #{{ pending_request.id }} is already pending approval.
+ {% else %} + {% if errors %}
    {% for error in errors %}
  • {{ error }}
  • {% endfor %}
{% endif %} + {% set f = form if form is defined else {} %} +
+ +
+

Optional. Admin may approve with another code.

+
+
+
+
+
+
+
+
+
+
+ {% endif %} +
+{% endblock %} diff --git a/app/modules/employees/templates/employees/registration_request_detail.html b/app/modules/employees/templates/employees/registration_request_detail.html new file mode 100644 index 0000000..5a5f532 --- /dev/null +++ b/app/modules/employees/templates/employees/registration_request_detail.html @@ -0,0 +1,46 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Employee Registration Request #{{ item.id }}

+

Review and approve/reject employee self-service linkage request.

+
+ Back +
+ +
+

Request Details

+
+
Full Name
{{ item.full_name }}
+
Requested Code
{{ item.requested_employee_code or 'Auto generate' }}
+
Email
{{ item.email or '-' }}
+
Mobile
{{ item.mobile or '-' }}
+
Department
{{ item.department or '-' }}
+
Designation
{{ item.designation or '-' }}
+
Status
{{ item.status.title() }}
+
User ID
{{ item.user_id }}
+
Remarks
{{ item.remarks or '-' }}
+ {% if item.review_notes %}
Review Notes
{{ item.review_notes }}
{% endif %} +
+
+ + {% if item.status == 'pending' %} +
+
+ +

Approve Request

+
+
+ +
+
+ +

Reject Request

+
+ +
+
+ {% endif %} +
+{% endblock %} diff --git a/app/modules/employees/templates/employees/registration_requests.html b/app/modules/employees/templates/employees/registration_requests.html new file mode 100644 index 0000000..e51bd80 --- /dev/null +++ b/app/modules/employees/templates/employees/registration_requests.html @@ -0,0 +1,36 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Employee Registration Requests

+

Approve self-service employee profile requests and create linked employee masters.

+
+ Employees +
+ +
+
+
+ +
+
+ +
+ + + + {% for row in rows %} + + + + + + + + {% else %}{% endfor %} + +
RequestUserRole DetailsStatusAction
#{{ row.id }} {{ row.full_name }}
Requested Code: {{ row.requested_employee_code or 'Auto' }} • {{ row.created_at_utc }}
{{ row.email or '-' }}
User #{{ row.user_id }}
{{ row.department or '-' }}
{{ row.designation or '-' }}
{{ row.status.title() }}Open
No registration requests found.
+
+
+{% endblock %} diff --git a/app/modules/employees/templates/employees/self_attendance.html b/app/modules/employees/templates/employees/self_attendance.html new file mode 100644 index 0000000..6eec7d4 --- /dev/null +++ b/app/modules/employees/templates/employees/self_attendance.html @@ -0,0 +1,248 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+ {% include "modules/employees/templates/employees/_my_workspace_tabs.html" %} +
+
+

My Attendance

+

Punch in/out and view your recent attendance records. Times are documented in your branch timezone.

+
+ +
+ + {% for error in errors or [] %} +
{{ error }}
+ {% endfor %} + + {% if not employee %} +
+ Your login is not linked to an employee profile yet. Please request employee linkage first. + +
+ {% else %} +
+
+
Today
+
{{ today_attendance.attendance_date if today_attendance else 'Not marked' }}
+
+
+
Punch In
+
{{ today_attendance.punch_in_local_at.strftime('%H:%M') if today_attendance and today_attendance.punch_in_local_at else (today_attendance.punch_in_utc.strftime('%H:%M UTC') if today_attendance and today_attendance.punch_in_utc else '-') }}
+
+
+
Punch Out
+
{{ today_attendance.punch_out_local_at.strftime('%H:%M') if today_attendance and today_attendance.punch_out_local_at else (today_attendance.punch_out_utc.strftime('%H:%M UTC') if today_attendance and today_attendance.punch_out_utc else '-') }}
+
+
+ +
+

Today's Action

+

Location will be captured when your browser allows it. If you are outside the branch geofence, the attendance is saved as pending approval for OD/client visit review.

+
Location not captured yet.
+
+
+ + + + + + + +
+
+ + + + + + + +
+
+
+ +
+ + + + + + + + + + + + + + + {% for row in rows %} + + + + + + + + + + + {% else %} + + {% endfor %} + +
DatePunch InPunch OutDurationStatusApprovalTiming RuleGeo/IP
{{ row.attendance_date }}{{ row.punch_in_local_at.strftime('%H:%M') if row.punch_in_local_at else (row.punch_in_utc.strftime('%H:%M UTC') if row.punch_in_utc else '-') }}{{ row.punch_out_local_at.strftime('%H:%M') if row.punch_out_local_at else (row.punch_out_utc.strftime('%H:%M UTC') if row.punch_out_utc else '-') }}{{ (row.work_duration_minutes ~ ' min') if row.work_duration_minutes else '-' }}{{ row.status.replace('_',' ').title() }}{{ row.approval_status.replace('_',' ').title() }} +
TZ: {{ row.branch_timezone or 'Asia/Kolkata' }}
+
Rule: {{ (row.attendance_rule_status or '-').replace('_',' ').title() }}
+
Late: {{ (row.late_by_minutes ~ ' min') if row.late_by_minutes else '-' }}
+
+
Geo: {{ (row.punch_in_geo_status or '-').replace('_',' ').title() }}
+
Distance: {{ (row.punch_in_distance_meters ~ ' m') if row.punch_in_distance_meters is not none else '-' }}
+
IP: {{ (row.punch_in_ip_status or '-').replace('_',' ').title() }}
+
No attendance records found.
+
+ {% endif %} +
+ + + + +{% endblock %} diff --git a/app/modules/employees/templates/employees/self_documents.html b/app/modules/employees/templates/employees/self_documents.html new file mode 100644 index 0000000..e384452 --- /dev/null +++ b/app/modules/employees/templates/employees/self_documents.html @@ -0,0 +1,32 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+ {% include "modules/employees/templates/employees/_my_workspace_tabs.html" %} + {% if errors %}
{{ errors|join(', ') }}
{% endif %} +

My Documents

Upload and track your own employee documents.

+ {% if not employee %} +
Your user is not linked to an employee profile. Request employee profile linkage.
+ {% else %} +
+

Upload Document

+
+ +
+
+
+
+
+
+
+
+
+
+ +
+ + {% for row in rows %}{% else %}{% endfor %} +
DocumentStatusDatesRemarks
{{ row.title }}
{{ row.document_type.code if row.document_type else 'GENERAL' }} • {{ row.original_filename }}
{{ row.status }}Issue: {{ row.issue_date or '-' }}
Expiry: {{ row.expiry_date or '-' }}
{{ row.remarks or '' }}
{{ row.verification_notes or '' }}
No documents uploaded.
+
+ {% endif %} +
+{% endblock %} diff --git a/app/modules/employees/templates/employees/self_leave.html b/app/modules/employees/templates/employees/self_leave.html new file mode 100644 index 0000000..2721aca --- /dev/null +++ b/app/modules/employees/templates/employees/self_leave.html @@ -0,0 +1,7 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} + +
+ {% include "modules/employees/templates/employees/_my_workspace_tabs.html" %}

My Leave

Apply for leave and track approval status.

{% if not employee %}
Your login user is not linked to an employee profile. Please request employee linkage first.
{% else %}
{% for bal in balances %}
{{ bal.leave_type.name if bal.leave_type else bal.leave_type_id }}
{{ bal.balance_days }}
Availed {{ bal.availed_days }} days
{% else %}
No leave balance available yet.
{% endfor %}

Apply Leave

{% for row in rows %}{% else %}{% endfor %}
LeavePeriodDaysStatusReasonAction
{{ row.leave_type.name if row.leave_type else row.leave_type_id }}{{ row.from_date }} to {{ row.to_date }}{{ row.days }}{{ row.status.title() }}{{ row.reason or row.review_notes or '-' }}{% if row.status == 'pending' %}
{% else %}-{% endif %}
No leave requests found.
{% endif %}
+ +{% endblock %} diff --git a/app/modules/employees/templates/employees/self_offboarding.html b/app/modules/employees/templates/employees/self_offboarding.html new file mode 100644 index 0000000..d015a5b --- /dev/null +++ b/app/modules/employees/templates/employees/self_offboarding.html @@ -0,0 +1,11 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+ {% include "modules/employees/templates/employees/_my_workspace_tabs.html" %} +

My Offboarding

Submit resignation/offboarding request and track status.

+ {% if not employee %}
Employee profile is not linked. Please request employee profile first.
{% else %} +
+ {% endif %} +
{% for r in rows %}{% else %}{% endfor %}
TypeRequested DateApproved DateStatusNotes
{{ r.request_type }}{{ r.requested_relieving_date or '-' }}{{ r.approved_relieving_date or '-' }}{{ r.status }}{{ r.review_notes or r.handover_notes or '-' }}
No requests yet.
+
+{% endblock %} diff --git a/app/modules/employees/templates/employees/self_payslips.html b/app/modules/employees/templates/employees/self_payslips.html new file mode 100644 index 0000000..e9bfd4f --- /dev/null +++ b/app/modules/employees/templates/employees/self_payslips.html @@ -0,0 +1,10 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+ {% include "modules/employees/templates/employees/_my_workspace_tabs.html" %} +

My Payslips

View your generated monthly payslips.

+ {% if not employee %}
Your user is not linked to an employee profile.
{% else %} +
{% for row in rows %}{% else %}{% endfor %}
PeriodGrossDeductionsNet PayStatus
{{ '%02d' % row.pay_month }}/{{ row.pay_year }}{{ row.gross_amount }}{{ row.deduction_amount }}{{ row.net_amount }}{{ row.status.title() }}
No payslips generated yet.
+ {% endif %} +
+{% endblock %} diff --git a/app/modules/employees/templates/employees/self_profile.html b/app/modules/employees/templates/employees/self_profile.html new file mode 100644 index 0000000..70e4a45 --- /dev/null +++ b/app/modules/employees/templates/employees/self_profile.html @@ -0,0 +1,93 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +{% set profile_photo_url = get_user_profile_photo_url(current_user) %} +{% set profile_initials = get_user_initials(current_user) %} +
+ {% include "modules/employees/templates/employees/_my_workspace_tabs.html" %} + +
+
+

My Profile

+

Maintain your profile photo and public contact details. Official HR fields remain controlled by admin.

+
+
+ + {% if saved %}
Profile updated successfully.
{% endif %} + {% if errors %}
    {% for error in errors %}
  • {{ error }}
  • {% endfor %}
{% endif %} + +
+
+
+ {% if profile_photo_url %} + Profile photo + {% else %} +
{{ profile_initials }}
+ {% endif %} +

{{ current_user.full_name or employee.full_name }}

+

{{ current_user.qualification or '' }}{% if current_user.qualification and (current_user.designation or employee.designation) %} • {% endif %}{{ current_user.designation or employee.designation or '' }}

+

{{ current_user.email }}

+
+ {% if current_user.bio %} +
{{ current_user.bio }}
+ {% endif %} +
+ +
+
+

Official Details

+
+
Employee Code
{{ employee.employee_code }}
+
Name
{{ employee.full_name }}
+
Department
{{ employee.department or '-' }}
+
Official Designation
{{ employee.designation or '-' }}
+
+
+ +
+ +
+

Public Profile

+

These details will be reused in dashboards and future client-facing auditor cards.

+
+
+
+ + +

JPG, PNG, GIF or WebP. Maximum 2 MB.

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+

Personal / HR Self-Service Details

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %} diff --git a/app/modules/employees/templates/employees/self_work.html b/app/modules/employees/templates/employees/self_work.html new file mode 100644 index 0000000..54c505a --- /dev/null +++ b/app/modules/employees/templates/employees/self_work.html @@ -0,0 +1,87 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+ {% include "modules/employees/templates/employees/_my_workspace_tabs.html" %} +
+
+

My Work

+

Board view of your assigned engagements. Open a card to work on tasks and refer to engagement documents.

+
+
+ +
+
+ + {% if not employee %} +
+

Your login is not linked to an employee master

+

You may still see tasks assigned directly to your user ID, but attendance, profile, leave, documents and payslip self-service require an employee master link.

+ +
+ {% endif %} + +
+
Total
{{ work_payload.summary.total }}
+
Open
{{ work_payload.summary.open }}
+
In Progress
{{ work_payload.summary.in_progress }}
+
Blocked
{{ work_payload.summary.blocked }}
+
Overdue
{{ work_payload.summary.overdue }}
+
Completed
{{ work_payload.summary.completed }}
+
+ +
+
+ + + +
+
+ +
+ {% for column in work_payload.columns %} +
+
+

{{ column.label }}

+ {{ column.cards|length }} +
+
+ {% for card in column.cards %} +
+
+
+
{{ card.client_code or 'Client' }}
+

{{ card.client_name }}

+
+ {% if card.overdue_count %}Overdue {{ card.overdue_count }}{% elif card.due_today_count %}Due today{% endif %} +
+
+
{{ card.service_name }}
+
FY {{ card.financial_year }} · Due {{ card.due_date or '-' }}
+
+
+
{{ card.open_count }}
Open
+
{{ card.blocked_count }}
Blocked
+
{{ card.completed_count }}
Done
+
+ {% if card.latest_comment %}

Latest: {{ card.latest_comment.message }}

{% endif %} + Open Work Details +
+ {% else %} +
No cards in this column.
+ {% endfor %} +
+
+ {% endfor %} +
+
+{% endblock %} diff --git a/app/modules/employees/templates/employees/work_dashboard.html b/app/modules/employees/templates/employees/work_dashboard.html new file mode 100644 index 0000000..f87df99 --- /dev/null +++ b/app/modules/employees/templates/employees/work_dashboard.html @@ -0,0 +1,105 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +{% include "modules/managers/templates/managers/_manager_tabs.html" %} +
+
+
+

Employee Work Allocation

+

Assign and review engagement/service tasks grouped by priority, client and engagement.

+
+ Back to HR Dashboard +
+ +
+
Total
{{ work_payload.summary.total }}
+
Open
{{ work_payload.summary.open }}
+
Unassigned
{{ work_payload.summary.unassigned }}
+
Overdue
{{ work_payload.summary.overdue }}
+
Completed
{{ work_payload.summary.completed }}
+
+ +
+
+ + + + +
+
+ +
+ {% for priority_group in work_payload.groups %} +
+ +
Priority
{{ priority_group.label }}
+
{{ priority_group.task_count }} task{{ '' if priority_group.task_count == 1 else 's' }}
+
+
+ {% for client_group in priority_group.clients %} +
+ +
{{ client_group.client_name }}
{{ client_group.client_code or 'No client code' }}
+
{{ client_group.task_count }} task{{ '' if client_group.task_count == 1 else 's' }}
+
+
+ {% for engagement_group in client_group.engagements %} +
+ +
{{ engagement_group.label }}
Open: {{ engagement_group.open_count }} · Completed: {{ engagement_group.completed_count }} · Status: {{ engagement_group.status.replace('_',' ').title() }}
+
{{ engagement_group.task_count }} task{{ '' if engagement_group.task_count == 1 else 's' }}
+
+
+ + + + + + {% for task in engagement_group.tasks %} + + + + + + {% endfor %} + +
TaskCurrentAssign / Review
{{ task.task_name }}
{% if task.description %}
{{ task.description }}
{% endif %}
Target: {{ task.internal_target_date or '-' }} · {{ task.date_bucket }}
{{ task.status_label }}
Assigned: {{ task.assigned_to.full_name if task.assigned_to else 'Unassigned' }}
Priority: {{ task.priority_label }}
+ + {% if task.latest_comment %}
{{ task.latest_comment.message }}
{% endif %} +
+
+ + + + + + + +
+
+
+
+ {% endfor %} +
+
+ {% endfor %} +
+
+ {% else %} +

No work items found

No engagement task matches the selected filter.

+ {% endfor %} +
+
+{% endblock %} diff --git a/app/modules/employees/templates/employees/work_engagement_board.html b/app/modules/employees/templates/employees/work_engagement_board.html new file mode 100644 index 0000000..80cc5a9 --- /dev/null +++ b/app/modules/employees/templates/employees/work_engagement_board.html @@ -0,0 +1,117 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+ {% include "modules/employees/templates/employees/_my_workspace_tabs.html" %} +
+
+

{{ board.label }}

+

+ {{ board.client.client_name if board.client else 'Unlinked Client' }} + {% if board.client and board.client.client_code %} · {{ board.client.client_code }}{% endif %} +

+
+ +
+ +
+
Total Tasks
{{ board.summary.total }}
+
Open
{{ board.summary.open }}
+
In Progress
{{ board.summary.in_progress }}
+
Blocked
{{ board.summary.blocked }}
+
Overdue
{{ board.summary.overdue }}
+
Completed
{{ board.summary.completed }}
+
+ +
+
+ {% for column in board.columns %} +
+
+

{{ column.label }}

+ {{ column.tasks|length }} +
+
+ {% for task in column.tasks %} +
+
+
+

{{ task.task_name }}

+ {% if task.description %}

{{ task.description }}

{% endif %} +
+ #{{ task.sequence_no }} +
+
+ {{ task.priority_label }} + {{ task.date_bucket }} + Target {{ task.internal_target_date or '-' }} +
+ {% if task.latest_comment %}

Latest: {{ task.latest_comment.message }}

{% endif %} +
+ + + + + +
+
+ {% else %} +
No tasks.
+ {% endfor %} +
+
+ {% endfor %} +
+ + +
+
+{% endblock %} diff --git a/app/modules/employees/templates/employees/work_task_communication.html b/app/modules/employees/templates/employees/work_task_communication.html new file mode 100644 index 0000000..84d96ba --- /dev/null +++ b/app/modules/employees/templates/employees/work_task_communication.html @@ -0,0 +1,96 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +{% if is_employee_self %} + {% include "modules/employees/templates/employees/_my_workspace_tabs.html" %} +{% else %} + {% include "modules/managers/templates/managers/_manager_tabs.html" %} +{% endif %} +
+
+
+

Task Communication

+

Communication timeline for engagement task notes, clarifications and review remarks.

+
+ Back +
+ +
+
+
+
Task
+
{{ task.task_name }}
+ {% if task.description %}

{{ task.description }}

{% endif %} +
+
+
Client
+
{{ task.client.client_name if task.client else 'Unlinked Client' }}
+
{{ task.client.client_code if task.client else '' }}
+
+
+
Engagement / Service
+
{{ task.engagement_label }}
+
Target: {{ task.internal_target_date or '-' }}
+
+
+
+ {{ task.status_label }} + {{ task.priority_label }} + {{ task.date_bucket }} + Assigned: {{ task.assigned_to.full_name if task.assigned_to else 'Unassigned' }} +
+
+ +
+

Add communication

+
+ +
+ + +
+ +
+ +
+
+
+ +
+
+

Communication Timeline

+

Newest communication appears first.

+
+
+ {% for item in task.communication_items %} +
+
+
+ {{ item.comment_type.replace('_',' ').title() }} + {{ item.visibility.replace('_',' ').title() }} +
+
{{ item.created_at_utc }}
+
+

{{ item.message }}

+
By {{ item.created_by.full_name if item.created_by else 'System/User' }}
+
+ {% else %} +
No communication has been added for this task yet.
+ {% endfor %} +
+
+
+{% endblock %} diff --git a/app/modules/employees/ui.py b/app/modules/employees/ui.py new file mode 100644 index 0000000..7e12d82 --- /dev/null +++ b/app/modules/employees/ui.py @@ -0,0 +1,2641 @@ +from __future__ import annotations + +from pathlib import Path +from io import BytesIO +from uuid import uuid4 + +from fastapi import APIRouter, File, Form, Request, UploadFile +from fastapi.responses import RedirectResponse, StreamingResponse + +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.core.rbac.deps import get_user_permissions, get_user_roles +from app.modules.core.rbac.permission_guard import require_permission +from app.modules.core.iam.profile_service import save_user_profile_photo, update_user_public_profile +from app.modules.employees.import_service import ( + build_template_workbook, + commit_import, + preview_import, + supported_import_types, +) +from app.modules.employees.service import ( + EMPLOYEE_ROLE_NAMES, + EMPLOYEE_STATUS, + EMPLOYMENT_TYPES, + build_employee_scope, + change_employee_status, + create_employee, + get_employee_or_404, + get_employee_user_link_summary, + link_employee_to_user, + list_employees, + list_linkable_users, + list_reporting_managers, + parse_date, + update_employee, + visible_branches, + visible_tenants, + REGISTRATION_STATUS, + approve_employee_registration_request, + create_employee_registration_request, + get_employee_for_user, + get_employee_dashboard_stats, + list_employee_work_dashboard, + list_employee_work_kanban, + get_employee_engagement_work_board, + list_employee_work_assignable_users, + list_visible_work_assignment_dashboard, + list_engagement_progress_dashboard, + update_service_task_assignment, + update_own_service_task_status, + TASK_COMMUNICATION_TYPES, + TASK_COMMUNICATION_VISIBILITIES, + add_work_task_communication, + get_work_task_with_communications, + get_employee_registration_request_or_404, + get_pending_registration_for_user, + list_employee_registration_requests, + reject_employee_registration_request, + update_own_employee_profile, + ATTENDANCE_APPROVAL_STATUS, + ATTENDANCE_STATUS, + create_or_update_manual_attendance, + get_attendance_or_404, + get_today_attendance_for_user, + list_attendance_records, + list_own_attendance, + punch_in_attendance, + punch_out_attendance, + review_attendance_record, + LEAVE_REQUEST_STATUS, + adjust_leave_balance, + apply_employee_leave, + cancel_own_leave_request, + create_leave_type, + ensure_default_leave_types, + get_leave_request_or_404, + get_leave_type_or_404, + list_leave_balances, + list_leave_requests, + list_leave_types, + list_own_leave_balances, + list_own_leave_requests, + review_leave_request, + update_leave_type, + DOCUMENT_STATUS, + DOCUMENT_VISIBILITY, + archive_employee_document, + create_document_type, + create_employee_document_record, + ensure_default_document_types, + get_document_type_or_404, + get_employee_document_or_404, + list_document_types, + list_employee_documents, + list_own_employee_documents, + review_employee_document, + update_document_type, + ONBOARDING_TASK_STATUS, + OFFBOARDING_REQUEST_STATUS, + OFFBOARDING_TASK_STATUS, + create_offboarding_request, + create_onboarding_checklist_item, + complete_offboarding_request, + ensure_default_onboarding_checklist, + generate_onboarding_tasks_for_employee, + get_offboarding_request_or_404, + get_offboarding_task_or_404, + get_onboarding_checklist_item_or_404, + get_onboarding_task_or_404, + list_offboarding_requests, + list_offboarding_tasks, + list_onboarding_checklist_items, + list_onboarding_tasks, + list_own_offboarding_requests, + review_offboarding_request, + update_offboarding_task_status, + update_onboarding_checklist_item, + update_onboarding_task_status, + PAYROLL_RUN_STATUS, + PAYSLIP_STATUS, + approve_payroll_run, + create_payroll_run, + create_salary_structure, + generate_payslips_for_run, + get_payroll_run_or_404, + get_salary_structure_or_404, + list_own_payslips, + list_payroll_runs, + list_payslips, + list_salary_structures, + mark_payroll_run_paid, + update_salary_structure, +) + +router = APIRouter(prefix="/employees", tags=["employees-ui"]) +portal_router = APIRouter(prefix="/employee", tags=["employee-portal-ui"]) + + +def _redirect_login(): + return RedirectResponse(url="/login", status_code=303) + + +def _redirect_denied(): + return RedirectResponse(url="/system-settings", status_code=303) + + +def _base_ctx(request: Request, db, current_user, **ctx): + base = { + "request": request, + "current_user": current_user, + "current_user_roles": get_user_roles(db, current_user.id), + "current_user_permissions": get_user_permissions(db, current_user.id), + "csrf_token": get_or_create_csrf_token(request), + "employee_statuses": EMPLOYEE_STATUS, + "employment_types": EMPLOYMENT_TYPES, + "employee_role_names": EMPLOYEE_ROLE_NAMES, + "attendance_statuses": ATTENDANCE_STATUS, + "attendance_approval_statuses": ATTENDANCE_APPROVAL_STATUS, + "leave_request_statuses": LEAVE_REQUEST_STATUS, + "document_statuses": DOCUMENT_STATUS, + "document_visibilities": DOCUMENT_VISIBILITY, + "onboarding_task_statuses": ONBOARDING_TASK_STATUS, + "offboarding_request_statuses": OFFBOARDING_REQUEST_STATUS, + "offboarding_task_statuses": OFFBOARDING_TASK_STATUS, + "payroll_run_statuses": PAYROLL_RUN_STATUS, + "payslip_statuses": PAYSLIP_STATUS, + } + base.update(ctx) + return base + + +def _render(request: Request, template: str, db, current_user, **ctx): + return templates.TemplateResponse(template, _base_ctx(request, db, current_user, **ctx)) + + +def _client_ip(request: Request) -> str | None: + forwarded_for = request.headers.get("x-forwarded-for") + if forwarded_for: + return forwarded_for.split(",")[0].strip() or None + real_ip = request.headers.get("x-real-ip") + if real_ip: + return real_ip.strip() or None + return request.client.host if request.client else None + + + + +def _active_financial_year(request: Request) -> str | None: + value = request.session.get("active_financial_year") or getattr(request.state, "year_code", None) + value = (value or "").strip() + return value or None + +def _form_bool(value) -> bool: + return value in ("1", "true", "True", "on", "yes") + + +def _form_payload(form, *, include_context: bool = False): + payload = { + "user_id": int(form.get("user_id")) if form.get("user_id") not in (None, "", "None") else None, + "employee_code": form.get("employee_code", ""), + "full_name": form.get("full_name", ""), + "email": form.get("email"), + "mobile": form.get("mobile"), + "alternate_mobile": form.get("alternate_mobile"), + "date_of_joining": form.get("date_of_joining"), + "date_of_leaving": form.get("date_of_leaving"), + "employment_type": form.get("employment_type") or "full_time", + "status": form.get("status") or "active", + "is_active": _form_bool(form.get("is_active")), + "department": form.get("department"), + "designation": form.get("designation"), + "reporting_manager_user_id": int(form.get("reporting_manager_user_id")) if form.get("reporting_manager_user_id") not in (None, "", "None") else None, + "pan": form.get("pan"), + "uan": form.get("uan"), + "esi_no": form.get("esi_no"), + "pf_no": form.get("pf_no"), + "aadhaar_last4": form.get("aadhaar_last4"), + "bank_name": form.get("bank_name"), + "bank_account_no": form.get("bank_account_no"), + "bank_ifsc": form.get("bank_ifsc"), + "address": form.get("address"), + "emergency_contact_name": form.get("emergency_contact_name"), + "emergency_contact_mobile": form.get("emergency_contact_mobile"), + "notes": form.get("notes"), + "create_login_user": _form_bool(form.get("create_login_user")), + "login_email": form.get("login_email"), + "temporary_password": form.get("temporary_password"), + "employee_role": form.get("employee_role") or "Staff", + } + if include_context: + payload["tenant_id"] = int(form.get("tenant_id")) if form.get("tenant_id") not in (None, "", "None") else None + payload["branch_id"] = int(form.get("branch_id")) if form.get("branch_id") not in (None, "", "None") else None + return payload + + +def _form_options(db, current_user, scope, *, include_user_id: int | None = None): + return { + "tenants": visible_tenants(db, current_user), + "branches": visible_branches(db, current_user, scope.tenant_id), + "users": list_linkable_users(db, scope, include_user_id=include_user_id), + "managers": list_reporting_managers(db, scope), + "scope": scope, + } + + +UPLOAD_ROOT = Path("data/uploads/employee_documents") +ALLOWED_DOCUMENT_EXTENSIONS = {".pdf", ".jpg", ".jpeg", ".png", ".webp", ".doc", ".docx", ".xls", ".xlsx"} + + +def _document_type_payload(form): + return { + "code": form.get("code"), + "name": form.get("name"), + "description": form.get("description"), + "is_mandatory": _form_bool(form.get("is_mandatory")), + "allow_employee_upload": _form_bool(form.get("allow_employee_upload")), + "requires_verification": _form_bool(form.get("requires_verification")), + "is_active": _form_bool(form.get("is_active")), + } + + +def _safe_upload_filename(filename: str) -> str: + base = Path(filename or "document").name.replace(" ", "_") + keep = [] + for ch in base: + keep.append(ch if ch.isalnum() or ch in {".", "_", "-"} else "_") + safe = "".join(keep).strip("._") or "document" + return safe[:180] + + + + +def _onboarding_item_payload(form): + return { + "code": form.get("code"), + "title": form.get("title"), + "description": form.get("description"), + "stage": form.get("stage") or "joining", + "default_due_days": int(form.get("default_due_days") or 0), + "sort_order": int(form.get("sort_order") or 0), + "is_mandatory": _form_bool(form.get("is_mandatory")), + "is_active": _form_bool(form.get("is_active")), + } + + +def _offboarding_payload(form): + return { + "request_type": form.get("request_type") or "resignation", + "requested_relieving_date": form.get("requested_relieving_date"), + "reason": form.get("reason"), + "handover_notes": form.get("handover_notes"), + } + + +async def _save_employee_upload(upload: UploadFile, *, tenant_id: int, employee_id: int) -> tuple[str, str, str, int | None, str | None]: + original = _safe_upload_filename(upload.filename or "document") + suffix = Path(original).suffix.lower() + if suffix not in ALLOWED_DOCUMENT_EXTENSIONS: + raise ValueError("Unsupported file type. Allowed: PDF, images, Word and Excel files.") + data = await upload.read() + if not data: + raise ValueError("Please choose a non-empty file to upload.") + max_size = 10 * 1024 * 1024 + if len(data) > max_size: + raise ValueError("File size should not exceed 10 MB.") + folder = UPLOAD_ROOT / str(tenant_id) / str(employee_id) + folder.mkdir(parents=True, exist_ok=True) + stored = f"{uuid4().hex}{suffix}" + path = folder / stored + path.write_bytes(data) + return original, stored, str(path.as_posix()), len(data), upload.content_type + + + + +@router.get("/dashboard") +def employee_hr_dashboard(request: Request): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.dashboard.view") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + stats = get_employee_dashboard_stats(db, scope) + return _render( + request, + "modules/employees/templates/employees/hr_dashboard.html", + db, + current_user, + title="HR Dashboard", + stats=stats, + scope=scope, + ) + finally: + db.close() + + +@router.get("") +def employees_list(request: Request, q: str = "", include_inactive: str | None = None, link_status: str = "all"): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.view") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + normalized_link_status = link_status if link_status in ("all", "linked", "unlinked") else "all" + rows = list_employees( + db, + scope, + q=q, + include_inactive=bool(include_inactive), + link_status=normalized_link_status, + ) + link_summary = get_employee_user_link_summary(db, scope) + return _render( + request, + "modules/employees/templates/employees/list.html", + db, + current_user, + title="Employees", + rows=rows, + q=q, + include_inactive=bool(include_inactive), + link_status=normalized_link_status, + link_summary=link_summary, + scope=scope, + ) + finally: + db.close() + + +@router.get("/new") +def employee_new(request: Request): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.create") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + return _render( + request, + "modules/employees/templates/employees/form.html", + db, + current_user, + title="Add Employee", + employee=None, + errors=[], + mode="create", + **_form_options(db, current_user, scope), + ) + finally: + db.close() + + +@router.post("/new") +async def employee_create_submit(request: Request): + form = await request.form() + validate_csrf(request, form.get("csrf_token")) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.create") + except Exception: + return _redirect_denied() + scope = build_employee_scope(db, current_user, tenant_id=form.get("tenant_id"), branch_id=form.get("branch_id")) + payload = _form_payload(form, include_context=True) + try: + emp = create_employee(db, current_user, scope, payload) + return RedirectResponse(url=f"/employees/{emp.id}", status_code=303) + except Exception as exc: + return _render( + request, + "modules/employees/templates/employees/form.html", + db, + current_user, + title="Add Employee", + employee=payload, + errors=[getattr(exc, "detail", str(exc))], + mode="create", + **_form_options(db, current_user, scope), + ) + finally: + db.close() + + +@router.get("/attendance") +def employees_attendance_list( + request: Request, + employee_id: int | None = None, + from_date: str | None = None, + to_date: str | None = None, + status: str | None = None, + approval_status: str | None = None, +): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.attendance.view_all") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + rows = list_attendance_records( + db, + scope, + employee_id=employee_id, + from_date=parse_date(from_date), + to_date=parse_date(to_date), + status=status or None, + approval_status=approval_status or None, + ) + employees = list_employees(db, scope, include_inactive=True) + return _render( + request, + "modules/employees/templates/employees/attendance_list.html", + db, + current_user, + title="Employee Attendance", + rows=rows, + employees=employees, + selected_employee_id=employee_id, + from_date=from_date or "", + to_date=to_date or "", + selected_status=status or "", + selected_approval_status=approval_status or "", + scope=scope, + ) + finally: + db.close() + + +@router.post("/attendance/manual") +def employees_attendance_manual_submit( + request: Request, + employee_id: int = Form(...), + attendance_date: str = Form(...), + status: str = Form(...), + remarks: str | None = Form(None), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.attendance.approve") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + create_or_update_manual_attendance( + db, + current_user, + scope, + employee_id=employee_id, + attendance_date=parse_date(attendance_date), + status=status, + remarks=remarks, + ) + return RedirectResponse(url="/employees/attendance", status_code=303) + finally: + db.close() + + +@router.post("/attendance/{attendance_id}/review") +def employees_attendance_review_submit( + request: Request, + attendance_id: int, + approval_status: str = Form(...), + review_notes: str | None = Form(None), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.attendance.approve") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + row = get_attendance_or_404(db, attendance_id, scope) + review_attendance_record(db, current_user, row, approval_status=approval_status, review_notes=review_notes) + return RedirectResponse(url="/employees/attendance", status_code=303) + finally: + db.close() + + +@router.get("/leave-types") +def employee_leave_types(request: Request, include_inactive: str | None = None): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.leave_type.manage") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") or current_user.branch_id + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + rows = list_leave_types(db, scope, include_inactive=bool(include_inactive)) + return _render(request, "modules/employees/templates/employees/leave_types.html", db, current_user, title="Employee Leave Types", rows=rows, scope=scope, errors=[], include_inactive=bool(include_inactive)) + finally: + db.close() + + +@router.post("/leave-types/defaults") +def employee_leave_type_defaults(request: Request, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.leave_type.manage") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") or current_user.branch_id + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + ensure_default_leave_types(db, current_user, scope) + return RedirectResponse(url="/employees/leave-types", status_code=303) + finally: + db.close() + + +@router.post("/leave-types/new") +def employee_leave_type_create(request: Request, code: str = Form(...), name: str = Form(...), description: str | None = Form(None), annual_quota_days: int = Form(0), carry_forward_allowed: str | None = Form(None), allow_negative_balance: str | None = Form(None), requires_approval: str | None = Form(None), is_paid: str | None = Form(None), is_active: str | None = Form(None), csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.leave_type.manage") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") or current_user.branch_id + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + create_leave_type(db, current_user, scope, {"code": code, "name": name, "description": description, "annual_quota_days": annual_quota_days, "carry_forward_allowed": _form_bool(carry_forward_allowed), "allow_negative_balance": _form_bool(allow_negative_balance), "requires_approval": _form_bool(requires_approval), "is_paid": _form_bool(is_paid), "is_active": _form_bool(is_active)}) + return RedirectResponse(url="/employees/leave-types", status_code=303) + finally: + db.close() + + +@router.post("/leave-types/{leave_type_id}/edit") +def employee_leave_type_edit(request: Request, leave_type_id: int, name: str = Form(...), description: str | None = Form(None), annual_quota_days: int = Form(0), carry_forward_allowed: str | None = Form(None), allow_negative_balance: str | None = Form(None), requires_approval: str | None = Form(None), is_paid: str | None = Form(None), is_active: str | None = Form(None), csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.leave_type.manage") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") or current_user.branch_id + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + row = get_leave_type_or_404(db, leave_type_id, scope) + update_leave_type(db, current_user, row, {"name": name, "description": description, "annual_quota_days": annual_quota_days, "carry_forward_allowed": _form_bool(carry_forward_allowed), "allow_negative_balance": _form_bool(allow_negative_balance), "requires_approval": _form_bool(requires_approval), "is_paid": _form_bool(is_paid), "is_active": _form_bool(is_active)}) + return RedirectResponse(url="/employees/leave-types", status_code=303) + finally: + db.close() + + +@router.get("/leave-balances") +def employee_leave_balances(request: Request, employee_id: int | None = None): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.leave_balance.manage") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + rows = list_leave_balances(db, scope, employee_id=employee_id) + employees = list_employees(db, scope, include_inactive=True) + leave_types = list_leave_types(db, scope, include_inactive=False) + return _render(request, "modules/employees/templates/employees/leave_balances.html", db, current_user, title="Employee Leave Balances", rows=rows, employees=employees, leave_types=leave_types, selected_employee_id=employee_id, scope=scope) + finally: + db.close() + + +@router.post("/leave-balances/adjust") +def employee_leave_balance_adjust(request: Request, employee_id: int = Form(...), leave_type_id: int = Form(...), adjusted_days: int = Form(0), notes: str | None = Form(None), csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.leave_balance.manage") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + adjust_leave_balance(db, current_user, scope, employee_id=employee_id, leave_type_id=leave_type_id, adjusted_days=adjusted_days, notes=notes) + return RedirectResponse(url=f"/employees/leave-balances?employee_id={employee_id}", status_code=303) + finally: + db.close() + + +@router.get("/leave") +def employee_leave_requests(request: Request, employee_id: int | None = None, status: str = "pending"): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.leave.view_all") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + rows = list_leave_requests(db, scope, employee_id=employee_id, status=status if status != "all" else None) + employees = list_employees(db, scope, include_inactive=True) + return _render(request, "modules/employees/templates/employees/leave_requests.html", db, current_user, title="Employee Leave Requests", rows=rows, employees=employees, status=status, selected_employee_id=employee_id, statuses=LEAVE_REQUEST_STATUS, scope=scope) + finally: + db.close() + + +@router.post("/leave/{request_id}/review") +def employee_leave_review_submit(request: Request, request_id: int, status: str = Form(...), review_notes: str | None = Form(None), csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.leave.approve") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + row = get_leave_request_or_404(db, request_id, scope) + review_leave_request(db, current_user, row, status=status, review_notes=review_notes) + return RedirectResponse(url="/employees/leave", status_code=303) + finally: + db.close() + + +@router.get("/registration-requests") +def employee_registration_requests(request: Request, status: str = "pending"): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.registration.approve") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + rows = list_employee_registration_requests(db, scope, status=status if status != "all" else None) + return _render( + request, + "modules/employees/templates/employees/registration_requests.html", + db, + current_user, + title="Employee Registration Requests", + rows=rows, + status=status, + statuses=REGISTRATION_STATUS, + scope=scope, + ) + finally: + db.close() + + +@router.get("/registration-requests/{request_id}") +def employee_registration_request_detail(request: Request, request_id: int): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.registration.approve") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + req = get_employee_registration_request_or_404(db, request_id, scope) + return _render( + request, + "modules/employees/templates/employees/registration_request_detail.html", + db, + current_user, + title=f"Employee Registration #{req.id}", + item=req, + error=None, + ) + finally: + db.close() + + +@router.post("/registration-requests/{request_id}/approve") +def employee_registration_request_approve( + request: Request, + request_id: int, + employee_code: str | None = Form(None), + review_notes: str | None = Form(None), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.registration.approve") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + req = get_employee_registration_request_or_404(db, request_id, scope) + emp = approve_employee_registration_request(db, current_user, req, employee_code=employee_code, notes=review_notes) + return RedirectResponse(url=f"/employees/{emp.id}", status_code=303) + finally: + db.close() + + +@router.post("/registration-requests/{request_id}/reject") +def employee_registration_request_reject( + request: Request, + request_id: int, + review_notes: str | None = Form(None), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.registration.approve") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + req = get_employee_registration_request_or_404(db, request_id, scope) + reject_employee_registration_request(db, current_user, req, notes=review_notes) + return RedirectResponse(url="/employees/registration-requests", status_code=303) + finally: + db.close() + + + + + +@router.get("/onboarding-checklist") +def employee_onboarding_checklist(request: Request): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.onboarding.manage") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") or current_user.branch_id + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + return _render(request, "modules/employees/templates/employees/onboarding_checklist.html", db, current_user, title="Onboarding Checklist", rows=list_onboarding_checklist_items(db, scope), errors=[], **_form_options(db, current_user, scope)) + finally: + db.close() + + +@router.post("/onboarding-checklist/defaults") +def employee_onboarding_checklist_defaults(request: Request, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.onboarding.manage") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") or current_user.branch_id + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + ensure_default_onboarding_checklist(db, current_user, scope) + return RedirectResponse(url="/employees/onboarding-checklist", status_code=303) + finally: + db.close() + + +@router.post("/onboarding-checklist") +async def employee_onboarding_checklist_create(request: Request): + form = await request.form() + validate_csrf(request, form.get("csrf_token")) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.onboarding.manage") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") or current_user.branch_id + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + create_onboarding_checklist_item(db, current_user, scope, _onboarding_item_payload(form)) + return RedirectResponse(url="/employees/onboarding-checklist", status_code=303) + finally: + db.close() + + +@router.post("/onboarding-checklist/{item_id}/edit") +async def employee_onboarding_checklist_edit(request: Request, item_id: int): + form = await request.form() + validate_csrf(request, form.get("csrf_token")) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.onboarding.manage") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") or current_user.branch_id + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + item = get_onboarding_checklist_item_or_404(db, item_id, scope) + update_onboarding_checklist_item(db, current_user, item, _onboarding_item_payload(form)) + return RedirectResponse(url="/employees/onboarding-checklist", status_code=303) + finally: + db.close() + + +@router.get("/onboarding") +def employee_onboarding_tasks(request: Request, employee_id: int | None = None): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.onboarding.view") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + return _render(request, "modules/employees/templates/employees/onboarding_tasks.html", db, current_user, title="Employee Onboarding", rows=list_onboarding_tasks(db, scope, employee_id=employee_id), employees=list_employees(db, scope, include_inactive=True), selected_employee_id=employee_id, errors=[]) + finally: + db.close() + + +@router.post("/{employee_id}/onboarding/generate") +def employee_generate_onboarding(request: Request, employee_id: int, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.onboarding.manage") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + employee = get_employee_or_404(db, employee_id, scope) + generate_onboarding_tasks_for_employee(db, current_user, employee, scope) + return RedirectResponse(url=f"/employees/onboarding?employee_id={employee_id}", status_code=303) + finally: + db.close() + + +@router.post("/onboarding/{task_id}/status") +def employee_onboarding_task_status(request: Request, task_id: int, status: str = Form(...), review_notes: str | None = Form(None), csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.onboarding.approve") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + task = get_onboarding_task_or_404(db, task_id, scope) + update_onboarding_task_status(db, current_user, task, status, review_notes) + return RedirectResponse(url=f"/employees/onboarding?employee_id={task.employee_id}", status_code=303) + finally: + db.close() + + +@router.get("/offboarding") +def employee_offboarding_requests(request: Request, status: str | None = None): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.offboarding.view") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + rows = list_offboarding_requests(db, scope, status=status if status else None) + return _render(request, "modules/employees/templates/employees/offboarding_requests.html", db, current_user, title="Employee Offboarding", rows=rows, employees=list_employees(db, scope, include_inactive=True), selected_status=status, errors=[]) + finally: + db.close() + + +@router.post("/{employee_id}/offboarding/initiate") +async def employee_offboarding_initiate(request: Request, employee_id: int): + form = await request.form() + validate_csrf(request, form.get("csrf_token")) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.offboarding.manage") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + employee = get_employee_or_404(db, employee_id, scope) + create_offboarding_request(db, current_user, employee, _offboarding_payload(form), source="admin") + return RedirectResponse(url="/employees/offboarding", status_code=303) + finally: + db.close() + + +@router.post("/offboarding/{request_id}/review") +def employee_offboarding_review(request: Request, request_id: int, status: str = Form(...), approved_relieving_date: str | None = Form(None), review_notes: str | None = Form(None), csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.offboarding.approve") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + req = get_offboarding_request_or_404(db, request_id, scope) + review_offboarding_request(db, current_user, req, status=status, approved_relieving_date=parse_date(approved_relieving_date), review_notes=review_notes) + return RedirectResponse(url="/employees/offboarding", status_code=303) + finally: + db.close() + + +@router.post("/offboarding/tasks/{task_id}/status") +def employee_offboarding_task_status(request: Request, task_id: int, status: str = Form(...), review_notes: str | None = Form(None), csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.offboarding.manage") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + task = get_offboarding_task_or_404(db, task_id, scope) + update_offboarding_task_status(db, current_user, task, status, review_notes) + return RedirectResponse(url="/employees/offboarding", status_code=303) + finally: + db.close() + + +@router.post("/offboarding/{request_id}/complete") +def employee_offboarding_complete(request: Request, request_id: int, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.offboarding.approve") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + req = get_offboarding_request_or_404(db, request_id, scope) + complete_offboarding_request(db, current_user, req) + return RedirectResponse(url="/employees/offboarding", status_code=303) + finally: + db.close() + + +@router.get("/document-types") +def employee_document_types(request: Request, include_inactive: str | None = None): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.document_type.manage") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + rows = list_document_types(db, scope, include_inactive=bool(include_inactive)) + return _render(request, "modules/employees/templates/employees/document_types.html", db, current_user, title="Employee Document Types", rows=rows, scope=scope, include_inactive=bool(include_inactive), editing=None, errors=[]) + finally: + db.close() + + +@router.post("/document-types/defaults") +def employee_document_types_defaults(request: Request, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.document_type.manage") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + ensure_default_document_types(db, current_user, scope) + return RedirectResponse(url="/employees/document-types", status_code=303) + finally: + db.close() + + +@router.post("/document-types") +async def employee_document_type_create(request: Request): + form = await request.form() + validate_csrf(request, form.get("csrf_token")) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.document_type.manage") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + try: + create_document_type(db, current_user, scope, _document_type_payload(form)) + return RedirectResponse(url="/employees/document-types", status_code=303) + except Exception as exc: + return _render(request, "modules/employees/templates/employees/document_types.html", db, current_user, title="Employee Document Types", rows=list_document_types(db, scope, include_inactive=True), scope=scope, include_inactive=True, editing=None, errors=[getattr(exc, "detail", str(exc))]) + finally: + db.close() + + +@router.post("/document-types/{type_id}/edit") +async def employee_document_type_update(request: Request, type_id: int): + form = await request.form() + validate_csrf(request, form.get("csrf_token")) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.document_type.manage") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + row = get_document_type_or_404(db, type_id, scope) + update_document_type(db, current_user, row, _document_type_payload(form)) + return RedirectResponse(url="/employees/document-types", status_code=303) + finally: + db.close() + + +@router.get("/documents") +def employee_documents(request: Request, employee_id: int | None = None, status: str | None = None): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.documents.view_all") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + return _render(request, "modules/employees/templates/employees/documents.html", db, current_user, title="Employee Documents", rows=list_employee_documents(db, scope, employee_id=employee_id, status=status), employees=list_employees(db, scope, include_inactive=True), document_types=list_document_types(db, scope), scope=scope, selected_employee_id=employee_id, selected_status=status, errors=[]) + finally: + db.close() + + +@router.post("/{employee_id}/documents/upload") +async def employee_document_upload(request: Request, employee_id: int, document_file: UploadFile = File(...)): + form = await request.form() + validate_csrf(request, form.get("csrf_token")) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.documents.manage") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + employee = get_employee_or_404(db, employee_id, scope) + try: + original, stored, path, size, content_type = await _save_employee_upload(document_file, tenant_id=employee.tenant_id, employee_id=employee.id) + create_employee_document_record( + db, + current_user, + employee, + document_type_id=int(form.get("document_type_id")) if form.get("document_type_id") not in (None, "", "None") else None, + title=form.get("title") or original, + document_no=form.get("document_no"), + issue_date=parse_date(form.get("issue_date")), + expiry_date=parse_date(form.get("expiry_date")), + original_filename=original, + stored_filename=stored, + storage_path=path, + content_type=content_type, + file_size_bytes=size, + remarks=form.get("remarks"), + visibility=form.get("visibility") or "employee_and_hr", + ) + return RedirectResponse(url=f"/employees/documents?employee_id={employee.id}", status_code=303) + except Exception as exc: + return _render(request, "modules/employees/templates/employees/documents.html", db, current_user, title="Employee Documents", rows=list_employee_documents(db, scope, employee_id=employee.id), employees=list_employees(db, scope, include_inactive=True), document_types=list_document_types(db, scope), scope=scope, selected_employee_id=employee.id, selected_status=None, errors=[getattr(exc, "detail", str(exc))]) + finally: + db.close() + + +@router.post("/documents/{document_id}/review") +def employee_document_review(request: Request, document_id: int, status: str = Form(...), verification_notes: str | None = Form(None), csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.documents.verify") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + row = get_employee_document_or_404(db, document_id, scope) + review_employee_document(db, current_user, row, status=status, verification_notes=verification_notes) + return RedirectResponse(url="/employees/documents", status_code=303) + finally: + db.close() + + +@router.post("/documents/{document_id}/archive") +def employee_document_archive(request: Request, document_id: int, notes: str | None = Form(None), csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.documents.delete") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + row = get_employee_document_or_404(db, document_id, scope) + archive_employee_document(db, current_user, row, notes=notes) + return RedirectResponse(url="/employees/documents", status_code=303) + finally: + db.close() + + +@portal_router.get("/documents") +def employee_self_documents(request: Request): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.documents.view_self") + except Exception: + return _redirect_denied() + employee = get_employee_for_user(db, current_user) + document_types = [] + if employee: + scope = build_employee_scope(db, current_user, tenant_id=employee.tenant_id, branch_id=employee.branch_id) + document_types = [dt for dt in list_document_types(db, scope) if dt.allow_employee_upload] + return _render(request, "modules/employees/templates/employees/self_documents.html", db, current_user, title="My Documents", employee=employee, rows=list_own_employee_documents(db, current_user), document_types=document_types, errors=[]) + finally: + db.close() + + +@portal_router.post("/documents/upload") +async def employee_self_document_upload(request: Request, document_file: UploadFile = File(...)): + form = await request.form() + validate_csrf(request, form.get("csrf_token")) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.documents.upload_self") + except Exception: + return _redirect_denied() + employee = get_employee_for_user(db, current_user) + if not employee: + return RedirectResponse(url="/employee/register", status_code=303) + try: + original, stored, path, size, content_type = await _save_employee_upload(document_file, tenant_id=employee.tenant_id, employee_id=employee.id) + create_employee_document_record( + db, + current_user, + employee, + document_type_id=int(form.get("document_type_id")) if form.get("document_type_id") not in (None, "", "None") else None, + title=form.get("title") or original, + document_no=form.get("document_no"), + issue_date=parse_date(form.get("issue_date")), + expiry_date=parse_date(form.get("expiry_date")), + original_filename=original, + stored_filename=stored, + storage_path=path, + content_type=content_type, + file_size_bytes=size, + remarks=form.get("remarks"), + visibility="employee_and_hr", + ) + return RedirectResponse(url="/employee/documents", status_code=303) + except Exception as exc: + scope = build_employee_scope(db, current_user, tenant_id=employee.tenant_id, branch_id=employee.branch_id) + return _render(request, "modules/employees/templates/employees/self_documents.html", db, current_user, title="My Documents", employee=employee, rows=list_own_employee_documents(db, current_user), document_types=[dt for dt in list_document_types(db, scope) if dt.allow_employee_upload], errors=[getattr(exc, "detail", str(exc))]) + finally: + db.close() + + + +@router.get("/payroll/structures") +def employee_salary_structures(request: Request, employee_id: int | None = None): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.payroll.structure.manage") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + return _render( + request, + "modules/employees/templates/employees/payroll_structures.html", + db, + current_user, + title="Employee Salary Structures", + rows=list_salary_structures(db, scope, employee_id=employee_id), + employees=list_employees(db, scope, include_inactive=True), + selected_employee_id=employee_id, + scope=scope, + errors=[], + ) + finally: + db.close() + + +@router.post("/payroll/structures") +async def employee_salary_structure_submit(request: Request): + form = await request.form() + validate_csrf(request, form.get("csrf_token")) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.payroll.structure.manage") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + payload = dict(form) + payload["is_active"] = _form_bool(form.get("is_active")) + try: + create_salary_structure(db, current_user, scope, payload) + return RedirectResponse(url="/employees/payroll/structures", status_code=303) + except Exception as exc: + return _render(request, "modules/employees/templates/employees/payroll_structures.html", db, current_user, title="Employee Salary Structures", rows=list_salary_structures(db, scope), employees=list_employees(db, scope, include_inactive=True), selected_employee_id=None, scope=scope, errors=[getattr(exc, "detail", str(exc))], form=payload) + finally: + db.close() + + +@router.post("/payroll/structures/{structure_id}/update") +async def employee_salary_structure_update(request: Request, structure_id: int): + form = await request.form() + validate_csrf(request, form.get("csrf_token")) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.payroll.structure.manage") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + row = get_salary_structure_or_404(db, structure_id, scope) + payload = dict(form) + payload["is_active"] = _form_bool(form.get("is_active")) + update_salary_structure(db, current_user, row, payload) + return RedirectResponse(url="/employees/payroll/structures", status_code=303) + finally: + db.close() + + +@router.get("/payroll/runs") +def employee_payroll_runs(request: Request): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.payroll.run") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + return _render(request, "modules/employees/templates/employees/payroll_runs.html", db, current_user, title="Payroll Runs", rows=list_payroll_runs(db, scope), scope=scope, errors=[]) + finally: + db.close() + + +@router.post("/payroll/runs") +def employee_payroll_run_create(request: Request, pay_year: int = Form(...), pay_month: int = Form(...), notes: str | None = Form(None), csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.payroll.run") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + create_payroll_run(db, current_user, scope, pay_year=pay_year, pay_month=pay_month, notes=notes) + return RedirectResponse(url="/employees/payroll/runs", status_code=303) + finally: + db.close() + + +@router.post("/payroll/runs/{run_id}/generate") +def employee_payroll_run_generate(request: Request, run_id: int, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.payroll.run") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + run = get_payroll_run_or_404(db, run_id, scope) + generate_payslips_for_run(db, current_user, scope, run) + return RedirectResponse(url=f"/employees/payroll/payslips?payroll_run_id={run_id}", status_code=303) + finally: + db.close() + + +@router.post("/payroll/runs/{run_id}/approve") +def employee_payroll_run_approve(request: Request, run_id: int, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.payroll.payout") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + run = get_payroll_run_or_404(db, run_id, scope) + approve_payroll_run(db, current_user, run) + return RedirectResponse(url="/employees/payroll/runs", status_code=303) + finally: + db.close() + + +@router.post("/payroll/runs/{run_id}/paid") +def employee_payroll_run_paid(request: Request, run_id: int, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.payroll.payout") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + run = get_payroll_run_or_404(db, run_id, scope) + mark_payroll_run_paid(db, current_user, run) + return RedirectResponse(url="/employees/payroll/runs", status_code=303) + finally: + db.close() + + +@router.get("/payroll/payslips") +def employee_payslips(request: Request, payroll_run_id: int | None = None, employee_id: int | None = None): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.payroll.view") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + return _render(request, "modules/employees/templates/employees/payslips.html", db, current_user, title="Employee Payslips", rows=list_payslips(db, scope, payroll_run_id=payroll_run_id, employee_id=employee_id), employees=list_employees(db, scope, include_inactive=True), runs=list_payroll_runs(db, scope), selected_run_id=payroll_run_id, selected_employee_id=employee_id, scope=scope, errors=[]) + finally: + db.close() + + + + + + + +@router.get("/progress") +def employee_engagement_progress_dashboard( + request: Request, + q: str = "", + status: str = "open", + assigned_to_user_id: int = 0, +): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.progress.view") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + assignee_id = int(assigned_to_user_id or 0) or None + financial_year = _active_financial_year(request) + progress_payload = list_engagement_progress_dashboard(db, scope, q=q, status=status, assigned_to_user_id=assignee_id, financial_year=financial_year) + assignable_users = list_employee_work_assignable_users(db, scope) + return _render( + request, + "modules/employees/templates/employees/progress_dashboard.html", + db, + current_user, + progress_payload=progress_payload, + assignable_users=assignable_users, + q=q, + status=status, + selected_assigned_to_user_id=assignee_id, + page_title="Engagement Progress", + financial_year=financial_year, + ) + finally: + db.close() + + +@router.get("/work") +def employee_work_assignment_dashboard( + request: Request, + q: str = "", + status: str = "open", + assigned_to_user_id: int = 0, +): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.work.manage") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + assignee_id = int(assigned_to_user_id or 0) or None + financial_year = _active_financial_year(request) + work_payload = list_visible_work_assignment_dashboard(db, scope, q=q, status=status, assigned_to_user_id=assignee_id, financial_year=financial_year) + assignable_users = list_employee_work_assignable_users(db, scope) + return _render( + request, + "modules/employees/templates/employees/work_dashboard.html", + db, + current_user, + title="Employee Work Allocation", + work_payload=work_payload, + assignable_users=assignable_users, + q=q, + status=status, + selected_assigned_to_user_id=assignee_id, + scope=scope, + errors=[], + financial_year=financial_year, + ) + finally: + db.close() + + +@router.post("/work/tasks/{task_id}/assign") +def employee_work_task_assign( + request: Request, + task_id: int, + assigned_to_user_id: str = Form(""), + status: str = Form("pending"), + priority: str = Form("normal"), + internal_target_date: str = Form(""), + remarks: str = Form(""), + return_url: str = Form(""), + csrf_token: str = Form(...), +): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.work.manage") + except Exception: + return _redirect_denied() + validate_csrf(request, csrf_token) + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + update_service_task_assignment( + db, + scope, + task_id, + assigned_to_user_id=int(assigned_to_user_id) if assigned_to_user_id else None, + status=status, + priority=priority, + internal_target_date=parse_date(internal_target_date), + remarks=remarks, + actor_user_id=current_user.id, + financial_year=_active_financial_year(request), + ) + safe_return_url = return_url if return_url.startswith(("/employees/work", "/manager/work", "/manager/dashboard")) else "/employees/work" + return RedirectResponse(url=safe_return_url, status_code=303) + finally: + db.close() + + +@portal_router.post("/work/tasks/{task_id}/status") +def employee_my_work_task_status( + request: Request, + task_id: int, + status: str = Form(...), + remarks: str = Form(""), + return_url: str = Form(""), + csrf_token: str = Form(...), +): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.work.view_self") + except Exception: + return _redirect_denied() + validate_csrf(request, csrf_token) + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + update_own_service_task_status( + db, + scope, + task_id, + status=status, + remarks=remarks, + actor_user_id=current_user.id, + financial_year=_active_financial_year(request), + ) + safe_return_url = return_url if return_url.startswith("/employee/work") else "/employee/work" + return RedirectResponse(url=safe_return_url, status_code=303) + finally: + db.close() + + +@portal_router.get("/work") +def employee_my_work(request: Request, q: str = "", status: str = "open"): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.work.view_self") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + employee = get_employee_for_user(db, current_user) + financial_year = _active_financial_year(request) + work_payload = list_employee_work_kanban(db, scope, q=q, status=status, financial_year=financial_year) + return _render( + request, + "modules/employees/templates/employees/self_work.html", + db, + current_user, + title="My Work", + employee=employee, + work_payload=work_payload, + q=q, + status=status, + errors=[], + financial_year=financial_year, + ) + finally: + db.close() + + +@portal_router.get("/work/engagements/{engagement_id}") +def employee_my_work_engagement_board(request: Request, engagement_id: int): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.work.view_self") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + employee = get_employee_for_user(db, current_user) + financial_year = _active_financial_year(request) + board = get_employee_engagement_work_board(db, scope, engagement_id, financial_year=financial_year) + return _render( + request, + "modules/employees/templates/employees/work_engagement_board.html", + db, + current_user, + title="Engagement Work Board", + employee=employee, + board=board, + errors=[], + financial_year=financial_year, + ) + finally: + db.close() + + +@router.get("/work/tasks/{task_id}/communication") +def employee_work_task_communication( + request: Request, + task_id: int, +): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.work.manage") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + financial_year = _active_financial_year(request) + task = get_work_task_with_communications(db, scope, task_id, financial_year=financial_year) + return _render( + request, + "modules/employees/templates/employees/work_task_communication.html", + db, + current_user, + title="Task Communication", + task=task, + communication_types=TASK_COMMUNICATION_TYPES, + communication_visibilities=TASK_COMMUNICATION_VISIBILITIES, + post_url=f"/employees/work/tasks/{task_id}/communication", + back_url="/employees/work", + is_employee_self=False, + errors=[], + ) + finally: + db.close() + + +@router.post("/work/tasks/{task_id}/communication") +def employee_work_task_add_communication( + request: Request, + task_id: int, + comment_type: str = Form("internal_note"), + visibility: str = Form("internal"), + message: str = Form(...), + csrf_token: str = Form(...), +): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.work.manage") + except Exception: + return _redirect_denied() + validate_csrf(request, csrf_token) + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + add_work_task_communication( + db, + scope, + task_id, + comment_type=comment_type, + visibility=visibility, + message=message, + actor_user_id=current_user.id, + financial_year=_active_financial_year(request), + ) + return RedirectResponse(url=f"/employees/work/tasks/{task_id}/communication", status_code=303) + finally: + db.close() + + +@portal_router.get("/work/tasks/{task_id}/communication") +def employee_my_work_task_communication( + request: Request, + task_id: int, +): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.work.view_self") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + financial_year = _active_financial_year(request) + task = get_work_task_with_communications(db, scope, task_id, assigned_only=True, financial_year=financial_year) + return _render( + request, + "modules/employees/templates/employees/work_task_communication.html", + db, + current_user, + title="My Task Communication", + task=task, + communication_types=TASK_COMMUNICATION_TYPES, + communication_visibilities=TASK_COMMUNICATION_VISIBILITIES, + post_url=f"/employee/work/tasks/{task_id}/communication", + back_url="/employee/work", + is_employee_self=True, + errors=[], + financial_year=financial_year, + ) + finally: + db.close() + + +@portal_router.post("/work/tasks/{task_id}/communication") +def employee_my_work_task_add_communication( + request: Request, + task_id: int, + comment_type: str = Form("internal_note"), + visibility: str = Form("internal"), + message: str = Form(...), + csrf_token: str = Form(...), +): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.work.view_self") + except Exception: + return _redirect_denied() + validate_csrf(request, csrf_token) + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + add_work_task_communication( + db, + scope, + task_id, + comment_type=comment_type, + visibility=visibility, + message=message, + actor_user_id=current_user.id, + assigned_only=True, + financial_year=_active_financial_year(request), + ) + return RedirectResponse(url=f"/employee/work/tasks/{task_id}/communication", status_code=303) + finally: + db.close() + + +@portal_router.get("/payslips") +def employee_self_payslips(request: Request): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.payroll.view_self") + except Exception: + return _redirect_denied() + employee = get_employee_for_user(db, current_user) + return _render(request, "modules/employees/templates/employees/self_payslips.html", db, current_user, title="My Payslips", employee=employee, rows=list_own_payslips(db, current_user), errors=[]) + finally: + db.close() + + +# ------------------------- +# Phase 6J HR Excel imports +# ------------------------- + +@router.get("/imports") +def employee_imports_page(request: Request, message: str | None = None): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.import") + except Exception: + return _redirect_denied() + return _render( + request, + "modules/employees/templates/employees/imports.html", + db, + current_user, + title="HR Excel Imports", + import_types=supported_import_types(), + message=message, + errors=[], + ) + finally: + db.close() + + +@router.get("/imports/template/{import_type}") +def employee_import_template(request: Request, import_type: str): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.import") + except Exception: + return _redirect_denied() + content = build_template_workbook(import_type) + filename = f"hr_{import_type}_template.xlsx" + return StreamingResponse( + BytesIO(content), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + finally: + db.close() + + +@router.post("/imports/preview") +async def employee_import_preview(request: Request, import_type: str = Form(...), import_file: UploadFile = File(...)): + form = await request.form() + validate_csrf(request, form.get("csrf_token")) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.import") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + try: + content = await import_file.read() + preview = preview_import(db, scope, import_type, content) + request.session["employee_import_preview"] = preview + return _render( + request, + "modules/employees/templates/employees/import_preview.html", + db, + current_user, + title="HR Import Preview", + preview=preview, + errors=[], + ) + except Exception as exc: + return _render( + request, + "modules/employees/templates/employees/imports.html", + db, + current_user, + title="HR Excel Imports", + import_types=supported_import_types(), + message=None, + errors=[getattr(exc, "detail", str(exc))], + ) + finally: + db.close() + + +@router.post("/imports/commit") +def employee_import_commit(request: Request, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.import") + except Exception: + return _redirect_denied() + preview = request.session.get("employee_import_preview") + if not preview: + return _render(request, "modules/employees/templates/employees/imports.html", db, current_user, title="HR Excel Imports", import_types=supported_import_types(), message=None, errors=["No pending import preview found. Please upload and preview again."]) + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + result = commit_import(db, current_user, scope, preview) + request.session.pop("employee_import_preview", None) + message = f"Import completed. Created: {result['created']}, Updated: {result['updated']}, Skipped: {result['skipped']}, Failed: {result['failed']}." + errors = result.get("errors") or [] + return _render(request, "modules/employees/templates/employees/imports.html", db, current_user, title="HR Excel Imports", import_types=supported_import_types(), message=message, errors=errors) + finally: + db.close() + +@router.get("/{employee_id}") +def employee_detail(request: Request, employee_id: int, link_error: str | None = None): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.view") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + try: + employee = get_employee_or_404(db, employee_id, scope) + except Exception: + return _redirect_denied() + return _render( + request, + "modules/employees/templates/employees/detail.html", + db, + current_user, + title=f"Employee - {employee.full_name}", + employee=employee, + linkable_users=list_linkable_users(db, scope, include_user_id=employee.user_id), + link_error=bool(link_error), + ) + finally: + db.close() + + +@router.get("/{employee_id}/edit") +def employee_edit(request: Request, employee_id: int): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.edit") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + try: + employee = get_employee_or_404(db, employee_id, scope) + except Exception: + return _redirect_denied() + form_scope = build_employee_scope(db, current_user, tenant_id=employee.tenant_id, branch_id=employee.branch_id) + return _render( + request, + "modules/employees/templates/employees/form.html", + db, + current_user, + title="Edit Employee", + employee=employee, + errors=[], + mode="edit", + **_form_options(db, current_user, form_scope, include_user_id=employee.user_id), + ) + finally: + db.close() + + +@router.post("/{employee_id}/edit") +async def employee_edit_submit(request: Request, employee_id: int): + form = await request.form() + validate_csrf(request, form.get("csrf_token")) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.edit") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + employee = get_employee_or_404(db, employee_id, scope) + form_scope = build_employee_scope(db, current_user, tenant_id=employee.tenant_id, branch_id=employee.branch_id) + payload = _form_payload(form) + try: + employee = update_employee(db, current_user, employee, payload) + return RedirectResponse(url=f"/employees/{employee.id}", status_code=303) + except Exception as exc: + return _render( + request, + "modules/employees/templates/employees/form.html", + db, + current_user, + title="Edit Employee", + employee=payload | {"id": employee.id, "tenant_id": employee.tenant_id, "branch_id": employee.branch_id}, + errors=[getattr(exc, "detail", str(exc))], + mode="edit", + **_form_options(db, current_user, form_scope, include_user_id=employee.user_id), + ) + finally: + db.close() + + +@router.post("/{employee_id}/link-user") +def employee_link_user_submit(request: Request, employee_id: int, user_id: str | None = Form(None), csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.edit") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + employee = get_employee_or_404(db, employee_id, scope) + try: + resolved_user_id = int(user_id) if user_id not in (None, "", "None") else None + link_employee_to_user(db, current_user, employee, resolved_user_id) + return RedirectResponse(url=f"/employees/{employee.id}", status_code=303) + except Exception: + return RedirectResponse(url=f"/employees/{employee.id}?link_error=1", status_code=303) + finally: + db.close() + + +@router.post("/{employee_id}/status") +def employee_status_submit( + request: Request, + employee_id: int, + status: str = Form(...), + date_of_leaving: str | None = Form(None), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.status") + except Exception: + return _redirect_denied() + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + employee = get_employee_or_404(db, employee_id, scope) + change_employee_status(db, current_user, employee, status, parse_date(date_of_leaving)) + return RedirectResponse(url=f"/employees/{employee_id}", status_code=303) + finally: + db.close() + + +@portal_router.get("/dashboard") +def employee_portal_dashboard(request: Request): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.ess.view") + except Exception: + return _redirect_denied() + employee = get_employee_for_user(db, current_user) + pending_request = None if employee else get_pending_registration_for_user(db, current_user) + work_payload = None + if employee: + scope = build_employee_scope(db, current_user, tenant_id=request.session.get("active_tenant_id") or current_user.tenant_id, branch_id=request.session.get("active_branch_id")) + work_payload = list_employee_work_kanban(db, scope, q="", status="open", financial_year=_active_financial_year(request)) + return _render( + request, + "modules/employees/templates/employees/portal_dashboard.html", + db, + current_user, + title="Employee Portal", + employee=employee, + pending_request=pending_request, + today_attendance=get_today_attendance_for_user(db, current_user), + leave_balances=list_own_leave_balances(db, current_user), + leave_requests=list_own_leave_requests(db, current_user)[:5], + offboarding_requests=list_own_offboarding_requests(db, current_user)[:5], + payslips=list_own_payslips(db, current_user)[:5], + work_payload=work_payload, + ) + finally: + db.close() + + +@portal_router.get("/attendance") +def employee_self_attendance(request: Request): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.attendance.view_self") + except Exception: + return _redirect_denied() + employee = get_employee_for_user(db, current_user) + rows = list_own_attendance(db, current_user) + return _render( + request, + "modules/employees/templates/employees/self_attendance.html", + db, + current_user, + title="My Attendance", + employee=employee, + rows=rows, + today_attendance=get_today_attendance_for_user(db, current_user), + errors=[], + ) + finally: + db.close() + + +@portal_router.post("/attendance/punch-in") +def employee_punch_in_submit( + request: Request, + remarks: str | None = Form(None), + latitude: str | None = Form(None), + longitude: str | None = Form(None), + accuracy_meters: str | None = Form(None), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.attendance.punch") + except Exception: + return _redirect_denied() + punch_in_attendance( + db, + current_user, + remarks=remarks, + latitude=latitude, + longitude=longitude, + accuracy_meters=accuracy_meters, + client_ip=_client_ip(request), + ) + return RedirectResponse(url="/employee/attendance", status_code=303) + finally: + db.close() + + +@portal_router.post("/attendance/punch-out") +def employee_punch_out_submit( + request: Request, + remarks: str | None = Form(None), + latitude: str | None = Form(None), + longitude: str | None = Form(None), + accuracy_meters: str | None = Form(None), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.attendance.punch") + except Exception: + return _redirect_denied() + punch_out_attendance( + db, + current_user, + remarks=remarks, + latitude=latitude, + longitude=longitude, + accuracy_meters=accuracy_meters, + client_ip=_client_ip(request), + ) + return RedirectResponse(url="/employee/attendance", status_code=303) + finally: + db.close() + + +@portal_router.get("/leave") +def employee_self_leave(request: Request): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.leave.view_self") + except Exception: + return _redirect_denied() + employee = get_employee_for_user(db, current_user) + leave_types = [] + if employee: + scope = build_employee_scope(db, current_user, tenant_id=employee.tenant_id, branch_id=employee.branch_id) + leave_types = list_leave_types(db, scope, include_inactive=False) + return _render(request, "modules/employees/templates/employees/self_leave.html", db, current_user, title="My Leave", employee=employee, rows=list_own_leave_requests(db, current_user), balances=list_own_leave_balances(db, current_user), leave_types=leave_types, errors=[]) + finally: + db.close() + + +@portal_router.post("/leave/apply") +def employee_self_leave_apply(request: Request, leave_type_id: int = Form(...), from_date: str = Form(...), to_date: str = Form(...), reason: str | None = Form(None), csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.leave.apply") + except Exception: + return _redirect_denied() + apply_employee_leave(db, current_user, leave_type_id=leave_type_id, from_date=parse_date(from_date), to_date=parse_date(to_date), reason=reason) + return RedirectResponse(url="/employee/leave", status_code=303) + finally: + db.close() + + +@portal_router.post("/leave/{request_id}/cancel") +def employee_self_leave_cancel(request: Request, request_id: int, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.leave.apply") + except Exception: + return _redirect_denied() + cancel_own_leave_request(db, current_user, request_id) + return RedirectResponse(url="/employee/leave", status_code=303) + finally: + db.close() + + + + +@portal_router.get("/offboarding") +def employee_self_offboarding(request: Request): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.offboarding.request_self") + except Exception: + return _redirect_denied() + employee = get_employee_for_user(db, current_user) + return _render(request, "modules/employees/templates/employees/self_offboarding.html", db, current_user, title="My Offboarding", employee=employee, rows=list_own_offboarding_requests(db, current_user), errors=[]) + finally: + db.close() + + +@portal_router.post("/offboarding/request") +async def employee_self_offboarding_request(request: Request): + form = await request.form() + validate_csrf(request, form.get("csrf_token")) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.offboarding.request_self") + except Exception: + return _redirect_denied() + employee = get_employee_for_user(db, current_user) + if not employee: + return RedirectResponse(url="/employee/register", status_code=303) + create_offboarding_request(db, current_user, employee, _offboarding_payload(form), source="employee") + return RedirectResponse(url="/employee/offboarding", status_code=303) + finally: + db.close() + + + + +@portal_router.get("/profile") +def employee_self_profile(request: Request): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.ess.view") + except Exception: + return _redirect_denied() + employee = get_employee_for_user(db, current_user) + if not employee: + return RedirectResponse(url="/employee/register", status_code=303) + return _render( + request, + "modules/employees/templates/employees/self_profile.html", + db, + current_user, + title="My Employee Profile", + employee=employee, + errors=[], + saved=False, + ) + finally: + db.close() + + +@portal_router.post("/profile") +async def employee_self_profile_submit(request: Request): + form = await request.form() + validate_csrf(request, form.get("csrf_token")) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.ess.profile.edit") + except Exception: + return _redirect_denied() + employee = get_employee_for_user(db, current_user) + if not employee: + return RedirectResponse(url="/employee/register", status_code=303) + payload = { + "mobile": form.get("mobile"), + "alternate_mobile": form.get("alternate_mobile"), + "address": form.get("address"), + "emergency_contact_name": form.get("emergency_contact_name"), + "emergency_contact_mobile": form.get("emergency_contact_mobile"), + "bank_name": form.get("bank_name"), + "bank_account_no": form.get("bank_account_no"), + "bank_ifsc": form.get("bank_ifsc"), + } + try: + profile_photo_path = await save_user_profile_photo(current_user, form.get("profile_photo")) + update_user_public_profile( + db, + current_user, + qualification=form.get("qualification"), + designation=form.get("public_designation") or employee.designation, + mobile=form.get("mobile"), + bio=form.get("bio"), + profile_photo_path=profile_photo_path, + ) + employee = update_own_employee_profile(db, current_user, employee, payload) + db.commit() + db.refresh(current_user) + return _render( + request, + "modules/employees/templates/employees/self_profile.html", + db, + current_user, + title="My Employee Profile", + employee=employee, + errors=[], + saved=True, + ) + except Exception as exc: + return _render( + request, + "modules/employees/templates/employees/self_profile.html", + db, + current_user, + title="My Employee Profile", + employee=employee, + errors=[getattr(exc, "detail", str(exc))], + saved=False, + ) + finally: + db.close() + + +@portal_router.get("/register") +def employee_registration_form(request: Request): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.registration.request") + except Exception: + return _redirect_denied() + employee = get_employee_for_user(db, current_user) + pending_request = get_pending_registration_for_user(db, current_user) + return _render( + request, + "modules/employees/templates/employees/registration_form.html", + db, + current_user, + title="Request Employee Profile", + employee=employee, + pending_request=pending_request, + errors=[], + ) + finally: + db.close() + + +@portal_router.post("/register") +async def employee_registration_submit(request: Request): + form = await request.form() + validate_csrf(request, form.get("csrf_token")) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.registration.request") + except Exception: + return _redirect_denied() + payload = { + "requested_employee_code": form.get("requested_employee_code"), + "full_name": form.get("full_name"), + "email": form.get("email"), + "mobile": form.get("mobile"), + "department": form.get("department"), + "designation": form.get("designation"), + "date_of_joining": form.get("date_of_joining"), + "remarks": form.get("remarks"), + } + try: + create_employee_registration_request(db, current_user, payload) + return RedirectResponse(url="/employee/dashboard", status_code=303) + except Exception as exc: + return _render( + request, + "modules/employees/templates/employees/registration_form.html", + db, + current_user, + title="Request Employee Profile", + employee=get_employee_for_user(db, current_user), + pending_request=get_pending_registration_for_user(db, current_user), + errors=[getattr(exc, "detail", str(exc))], + form=payload, + ) + finally: + db.close() diff --git a/app/modules/managers/__init__.py b/app/modules/managers/__init__.py new file mode 100644 index 0000000..5ac1c95 --- /dev/null +++ b/app/modules/managers/__init__.py @@ -0,0 +1 @@ +"""Manager workspace UI module.""" diff --git a/app/modules/managers/templates/managers/_manager_tabs.html b/app/modules/managers/templates/managers/_manager_tabs.html new file mode 100644 index 0000000..21fc760 --- /dev/null +++ b/app/modules/managers/templates/managers/_manager_tabs.html @@ -0,0 +1,12 @@ +{% set manager_path = request.url.path %} + diff --git a/app/modules/managers/templates/managers/dashboard.html b/app/modules/managers/templates/managers/dashboard.html new file mode 100644 index 0000000..1dec7d3 --- /dev/null +++ b/app/modules/managers/templates/managers/dashboard.html @@ -0,0 +1,92 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +{% include "modules/managers/templates/managers/_manager_tabs.html" %} +
+
+
+
+

Team Workspace

+

Allocation, follow-up and team execution control

+

Focus on unassigned work, blocked assignments, due dates, team attendance and review-ready tasks.

+ {% if current_user_roles and ('Partner' in current_user_roles or 'Firm Admin' in current_user_roles) %} +

Multi-role view enabled: partner/admin responsibilities may also apply.

+ {% endif %} +
+
+ Open Team Work Board + {% if current_user_roles and 'Partner' in current_user_roles %}Partner Dashboard{% endif %} +
+
+
+ +
+
Total Tasks
{{ payload.summary.total }}
All visible work
+
Unassigned
{{ payload.summary.unassigned }}
Allocate first
+
In Progress
{{ payload.summary.in_progress }}
Being worked
+
Blocked
{{ payload.summary.blocked }}
Needs help
+
Overdue
{{ payload.summary.overdue }}
Escalate
+
Completed
{{ payload.summary.completed }}
Closed tasks
+
+ +
+
+
+
+

Attention Required

+

Unassigned, blocked and overdue work should be handled first.

+
+ View All +
+
+ {% set shown = namespace(count=0) %} + {% for column in payload.columns %} + {% if column.code in ['unassigned', 'blocked'] and column.tasks %} +
+
{{ column.label }}
{{ column.tasks|length }} item(s)
+
+ {% for task in column.tasks[:6] %} + {% set shown.count = shown.count + 1 %} +
+
+
+
{{ task.task_name }}
+
{{ task.client_display }} · {{ task.engagement_label }}
+
Target: {{ task.internal_target_date or '-' }} · {{ task.date_bucket }}
+
+ Timeline +
+
+ {% endfor %} +
+
+ {% endif %} + {% endfor %} + {% if shown.count == 0 %}
No team assignments require manager attention right now.
{% endif %} +
+
+ + +
+
+{% endblock %} diff --git a/app/modules/managers/templates/managers/work_board.html b/app/modules/managers/templates/managers/work_board.html new file mode 100644 index 0000000..4b5d6b5 --- /dev/null +++ b/app/modules/managers/templates/managers/work_board.html @@ -0,0 +1,90 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +{% include "modules/managers/templates/managers/_manager_tabs.html" %} +
+
+
+

Team Work Board

+

Assign, monitor and follow up on engagement/service tasks by operational status.

+
+ Open Detailed Allocation +
+ +
+
+ + + +
+
+ +
+ {% for column in payload.columns %} +
+
+
+

{{ column.label }}

+

{{ column.hint }}

+
+ {{ column.count }} +
+
+ {% for task in column.tasks %} +
+
+
+
{{ task.task_name }}
+
{{ task.client_display }}
+
{{ task.engagement_label }}
+
+ {% if task.is_overdue %}Overdue{% elif task.is_due_today %}Today{% endif %} +
+ +
+
Status: {{ task.status_label }}
+
Priority: {{ task.priority_label }}
+
Assigned: {{ task.assignee_display }}
+
Target: {{ task.internal_target_date or '-' }} · {{ task.date_bucket }}
+
+ +
+ + + +
+ + +
+ + + +
+ + +
+ {% else %} +
No tasks
+ {% endfor %} +
+
+ {% endfor %} +
+
+{% endblock %} diff --git a/app/modules/managers/ui.py b/app/modules/managers/ui.py new file mode 100644 index 0000000..0ad7e26 --- /dev/null +++ b/app/modules/managers/ui.py @@ -0,0 +1,287 @@ +from __future__ import annotations + +from datetime import date +from typing import Any + +from fastapi import APIRouter, Request +from fastapi.responses import RedirectResponse +from sqlalchemy import or_, select +from sqlalchemy.orm import Session, selectinload + +from app.core.db.common import CommonSessionLocal +from app.core.security.csrf import get_or_create_csrf_token +from app.core.security.session_auth import get_current_user +from app.core.templating import templates +from app.modules.core.rbac.deps import get_user_permissions, get_user_roles +from app.modules.core.rbac.permission_guard import require_permission +from app.modules.employees.service import ( + build_employee_scope, + list_employee_work_assignable_users, +) +from app.modules.services.execution import CLOSED_TASK_STATUSES, TASK_PRIORITIES, TASK_STATUSES +from app.modules.services.models import ClientServiceSubscription, ClientServiceTaskInstance, ServiceCatalogue, ServiceTaskComment +from app.modules.clients.models import Client + +router = APIRouter(prefix="/manager", tags=["manager-workspace-ui"]) + + +def _redirect_login(): + return RedirectResponse(url="/login", status_code=303) + + +def _redirect_denied(): + return RedirectResponse(url="/employee/dashboard", status_code=303) + + +def _base_ctx(request: Request, db: Session, current_user, **ctx): + base = { + "request": request, + "current_user": current_user, + "current_user_roles": get_user_roles(db, current_user.id), + "current_user_permissions": get_user_permissions(db, current_user.id), + "csrf_token": get_or_create_csrf_token(request), + "task_statuses": TASK_STATUSES, + "task_priorities": TASK_PRIORITIES, + } + base.update(ctx) + return base + + +def _render(request: Request, template_name: str, db: Session, current_user, **ctx): + return templates.TemplateResponse(template_name, _base_ctx(request, db, current_user, **ctx)) + + +def _task_status_label(task: ClientServiceTaskInstance) -> str: + return dict(TASK_STATUSES).get(getattr(task, "status", ""), (getattr(task, "status", "") or "-").replace("_", " ").title()) + + +def _task_priority_label(task: ClientServiceTaskInstance) -> str: + return dict(TASK_PRIORITIES).get(getattr(task, "priority", ""), (getattr(task, "priority", "") or "normal").replace("_", " ").title()) + + + + +def _active_financial_year(request: Request) -> str | None: + value = request.session.get("active_financial_year") or getattr(request.state, "year_code", None) + value = (value or "").strip() + return value or None + +def _subscription_label(subscription: ClientServiceSubscription | None, task: ClientServiceTaskInstance) -> str: + catalogue = getattr(subscription, "catalogue", None) if subscription else getattr(task, "catalogue", None) + service_name = getattr(catalogue, "service_name", None) or getattr(catalogue, "name", None) or getattr(task, "task_name", "Engagement") + fy = getattr(subscription, "financial_year", None) or getattr(task, "financial_year", None) + return f"{service_name} · FY {fy}" if fy else str(service_name) + + +def _date_bucket(task: ClientServiceTaskInstance, today: date) -> str: + target = getattr(task, "internal_target_date", None) + if not target: + return "No target date" + if target < today and (task.status or "") not in CLOSED_TASK_STATUSES: + return "Overdue" + if target == today and (task.status or "") not in CLOSED_TASK_STATUSES: + return "Due today" + if target > today and (task.status or "") not in CLOSED_TASK_STATUSES: + return "Upcoming" + return "Closed" + + +def _manager_task_query(db: Session, scope, *, q: str = "", assigned_to_user_id: int | None = None, financial_year: str | None = None): + stmt = ( + select(ClientServiceTaskInstance) + .options( + selectinload(ClientServiceTaskInstance.client), + selectinload(ClientServiceTaskInstance.catalogue), + selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.catalogue), + selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_partner), + selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_manager), + selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_staff), + selectinload(ClientServiceTaskInstance.assigned_to), + selectinload(ClientServiceTaskInstance.comments).selectinload(ServiceTaskComment.created_by), + ) + .where( + ClientServiceTaskInstance.tenant_id == scope.tenant_id, + ClientServiceTaskInstance.is_active.is_(True), + ) + ) + if scope.branch_id is not None: + stmt = stmt.where(ClientServiceTaskInstance.branch_id == scope.branch_id) + if financial_year: + stmt = stmt.where(ClientServiceTaskInstance.financial_year == financial_year.strip()) + if assigned_to_user_id: + stmt = stmt.where(ClientServiceTaskInstance.assigned_to_user_id == int(assigned_to_user_id)) + if q.strip(): + like = f"%{q.strip()}%" + stmt = stmt.where( + or_( + ClientServiceTaskInstance.task_name.ilike(like), + ClientServiceTaskInstance.description.ilike(like), + ClientServiceTaskInstance.client.has(or_(Client.client_name.ilike(like), Client.client_code.ilike(like))), + ClientServiceTaskInstance.catalogue.has(or_(ServiceCatalogue.service_name.ilike(like), ServiceCatalogue.service_code.ilike(like))), + ) + ) + return db.execute( + stmt.order_by( + ClientServiceTaskInstance.internal_target_date.is_(None), + ClientServiceTaskInstance.internal_target_date.asc(), + ClientServiceTaskInstance.priority.desc(), + ClientServiceTaskInstance.sequence_no.asc(), + ClientServiceTaskInstance.id.desc(), + ) + ).scalars().all() + + +def build_manager_workspace_payload(db: Session, scope, *, q: str = "", assigned_to_user_id: int | None = None, financial_year: str | None = None) -> dict[str, Any]: + today = date.today() + tasks = _manager_task_query(db, scope, q=q, assigned_to_user_id=assigned_to_user_id, financial_year=financial_year) + + columns = [ + {"code": "unassigned", "label": "Unassigned", "hint": "Needs manager allocation", "tasks": []}, + {"code": "assigned", "label": "Assigned", "hint": "Assigned but not started", "tasks": []}, + {"code": "in_progress", "label": "In Progress", "hint": "Currently being worked on", "tasks": []}, + {"code": "blocked", "label": "Blocked", "hint": "Needs intervention", "tasks": []}, + {"code": "ready_review", "label": "Completed / Review", "hint": "Completed by staff, review if required", "tasks": []}, + ] + lookup = {c["code"]: c for c in columns} + summary = { + "total": len(tasks), + "unassigned": 0, + "open": 0, + "in_progress": 0, + "blocked": 0, + "completed": 0, + "overdue": 0, + "due_today": 0, + "clients": set(), + "assignees": set(), + } + + for task in tasks: + status = (task.status or "pending").strip().lower() + is_closed = status in CLOSED_TASK_STATUSES + target = getattr(task, "internal_target_date", None) + client = getattr(task, "client", None) + assignee = getattr(task, "assigned_to", None) + subscription = getattr(task, "subscription", None) + + if client and getattr(client, "id", None): + summary["clients"].add(client.id) + if assignee and getattr(assignee, "id", None): + summary["assignees"].add(assignee.id) + if not is_closed: + summary["open"] += 1 + if not getattr(task, "assigned_to_user_id", None) and not is_closed: + summary["unassigned"] += 1 + if status == "in_progress": + summary["in_progress"] += 1 + if status == "blocked": + summary["blocked"] += 1 + if status == "completed": + summary["completed"] += 1 + if target and target < today and not is_closed: + summary["overdue"] += 1 + if target and target == today and not is_closed: + summary["due_today"] += 1 + + task.status_label = _task_status_label(task) + task.priority_label = _task_priority_label(task) + task.date_bucket = _date_bucket(task, today) + task.is_overdue = bool(target and target < today and not is_closed) + task.is_due_today = bool(target and target == today and not is_closed) + task.engagement_label = _subscription_label(subscription, task) + task.client_display = getattr(client, "client_name", None) or "Unlinked Client" + task.client_code_display = getattr(client, "client_code", None) or "" + task.assignee_display = getattr(assignee, "full_name", None) or getattr(assignee, "email", None) or "Unassigned" + task.comment_count = len([c for c in getattr(task, "comments", []) if not getattr(c, "is_deleted", False)]) + + if not getattr(task, "assigned_to_user_id", None) and not is_closed: + bucket = "unassigned" + elif status == "pending": + bucket = "assigned" + elif status == "in_progress": + bucket = "in_progress" + elif status == "blocked": + bucket = "blocked" + else: + bucket = "ready_review" + lookup[bucket]["tasks"].append(task) + + summary["clients"] = len(summary["clients"]) + summary["assignees"] = len(summary["assignees"]) + for column in columns: + column["count"] = len(column["tasks"]) + return {"summary": summary, "columns": columns, "q": q, "selected_assigned_to_user_id": assigned_to_user_id, "today": today, "financial_year": financial_year} + + +@router.get("/dashboard") +def manager_dashboard(request: Request, q: str = "", assigned_to_user_id: int = 0): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.work.manage") + except Exception: + return _redirect_denied() + scope = build_employee_scope( + db, + current_user, + tenant_id=request.session.get("active_tenant_id") or current_user.tenant_id, + branch_id=request.session.get("active_branch_id"), + ) + assignee_id = int(assigned_to_user_id or 0) or None + financial_year = _active_financial_year(request) + payload = build_manager_workspace_payload(db, scope, q=q, assigned_to_user_id=assignee_id, financial_year=financial_year) + return _render( + request, + "modules/managers/templates/managers/dashboard.html", + db, + current_user, + title="Manager Workspace", + payload=payload, + assignable_users=list_employee_work_assignable_users(db, scope), + q=q, + selected_assigned_to_user_id=assignee_id, + errors=[], + financial_year=financial_year, + ) + finally: + db.close() + + +@router.get("/work") +def manager_work_board(request: Request, q: str = "", assigned_to_user_id: int = 0): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.work.manage") + except Exception: + return _redirect_denied() + scope = build_employee_scope( + db, + current_user, + tenant_id=request.session.get("active_tenant_id") or current_user.tenant_id, + branch_id=request.session.get("active_branch_id"), + ) + assignee_id = int(assigned_to_user_id or 0) or None + financial_year = _active_financial_year(request) + payload = build_manager_workspace_payload(db, scope, q=q, assigned_to_user_id=assignee_id, financial_year=financial_year) + return _render( + request, + "modules/managers/templates/managers/work_board.html", + db, + current_user, + title="Team Work Board", + payload=payload, + assignable_users=list_employee_work_assignable_users(db, scope), + q=q, + selected_assigned_to_user_id=assignee_id, + errors=[], + financial_year=financial_year, + ) + finally: + db.close() diff --git a/app/modules/marketplace/__init__.py b/app/modules/marketplace/__init__.py new file mode 100644 index 0000000..9a93239 --- /dev/null +++ b/app/modules/marketplace/__init__.py @@ -0,0 +1 @@ +"""Marketplace / public lead foundation module.""" diff --git a/app/modules/marketplace/models.py b/app/modules/marketplace/models.py new file mode 100644 index 0000000..1436411 --- /dev/null +++ b/app/modules/marketplace/models.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from decimal import Decimal + +from sqlalchemy import DateTime, ForeignKey, Integer, Numeric, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.db.common import CommonBase + + +class MarketplaceLead(CommonBase): + """Public/service marketplace lead captured for later assignment and conversion.""" + + __tablename__ = "marketplace_leads" + __table_args__ = (UniqueConstraint("lead_no", name="uq_marketplace_leads_lead_no"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + lead_no: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + source: Mapped[str] = mapped_column(String(50), nullable=False, default="manual", index=True) + service_category: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True) + service_requested: Mapped[str] = mapped_column(String(200), nullable=False, index=True) + + lead_name: Mapped[str] = mapped_column(String(200), nullable=False, index=True) + business_name: Mapped[str | None] = mapped_column(String(200), nullable=True, index=True) + email: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True) + mobile: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True) + city: Mapped[str | None] = mapped_column(String(100), nullable=True) + state: Mapped[str | None] = mapped_column(String(100), nullable=True) + message: Mapped[str | None] = mapped_column(Text, nullable=True) + + status: Mapped[str] = mapped_column(String(30), nullable=False, default="NEW", index=True) + priority: Mapped[str] = mapped_column(String(30), nullable=False, default="NORMAL", index=True) + estimated_value: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False, default=Decimal("0.00")) + + assigned_tenant_id: Mapped[int | None] = mapped_column(ForeignKey("tenants.id"), nullable=True, index=True) + assigned_branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True) + assigned_partner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True) + assigned_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + assigned_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) + + converted_client_id: Mapped[int | None] = mapped_column(ForeignKey("clients.id"), nullable=True, index=True) + converted_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + converted_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=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 MarketplaceLeadAssignment(CommonBase): + """Assignment history for marketplace leads.""" + + __tablename__ = "marketplace_lead_assignments" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + lead_id: Mapped[int] = mapped_column(ForeignKey("marketplace_leads.id", ondelete="CASCADE"), nullable=False, index=True) + tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id"), nullable=False, index=True) + branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True) + partner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True) + status: Mapped[str] = mapped_column(String(30), nullable=False, default="ASSIGNED", index=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + assigned_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) + assigned_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) diff --git a/app/modules/marketplace/services.py b/app/modules/marketplace/services.py new file mode 100644 index 0000000..f6acb1a --- /dev/null +++ b/app/modules/marketplace/services.py @@ -0,0 +1,228 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from decimal import Decimal, ROUND_HALF_UP + +from sqlalchemy import func, or_, select + +from app.modules.clients.models import Client +from app.modules.consultants.models import ConsultantProfile +from app.modules.core.iam.models import User +from app.modules.core.tenancy.models import Branch, Tenant +from app.modules.marketplace.models import MarketplaceLead, MarketplaceLeadAssignment + +LEAD_STATUSES = ["NEW", "CONTACTED", "QUALIFIED", "ASSIGNED", "ACCEPTED", "REJECTED", "CONVERTED", "LOST"] +LEAD_PRIORITIES = ["LOW", "NORMAL", "HIGH", "URGENT"] +LEAD_SOURCES = ["manual", "public_website", "consultant", "referral", "campaign", "other"] +SERVICE_CATEGORIES = ["GST", "Income Tax", "ROC", "Audit", "Accounting", "Payroll", "Registration", "Advisory", "Other"] + + +def money(value) -> Decimal: + if value in (None, ""): + return Decimal("0.00") + return Decimal(str(value)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + + +def _role_set(role_names: list[str] | None) -> set[str]: + return set(role_names or []) + + +def is_system_admin(role_names: list[str] | None) -> bool: + return "System Admin" in _role_set(role_names) + + +def is_firm_admin(role_names: list[str] | None) -> bool: + return "Firm Admin" in _role_set(role_names) + + +def is_partner(role_names: list[str] | None) -> bool: + return "Partner" in _role_set(role_names) + + +def generate_lead_no(db) -> str: + year = datetime.now().year + count = db.execute(select(func.count(MarketplaceLead.id))).scalar_one() or 0 + return f"ML-{year}-{count + 1:05d}" + + +def list_reference_audit_firms(db) -> list[Tenant]: + return list(db.execute(select(Tenant).order_by(Tenant.name.asc())).scalars().all()) + + +def list_reference_branches(db, tenant_id: int | None = None) -> list[Branch]: + stmt = select(Branch).order_by(Branch.name.asc()) + if tenant_id: + stmt = stmt.where(Branch.tenant_id == tenant_id) + return list(db.execute(stmt).scalars().all()) + + +def list_reference_partners(db, tenant_id: int | None = None) -> list[User]: + stmt = select(User).order_by(User.full_name.asc(), User.email.asc()) + if tenant_id: + stmt = stmt.where(User.tenant_id == tenant_id) + return list(db.execute(stmt).scalars().all()) + + +def list_marketplace_leads(db, *, q: str = "", status: str = "", user=None, role_names: list[str] | None = None) -> list[MarketplaceLead]: + stmt = select(MarketplaceLead).order_by(MarketplaceLead.created_at_utc.desc(), MarketplaceLead.id.desc()) + roles = _role_set(role_names) + if not is_system_admin(role_names): + tenant_id = getattr(user, "tenant_id", None) + if tenant_id: + stmt = stmt.where(MarketplaceLead.assigned_tenant_id == tenant_id) + if is_partner(role_names): + stmt = stmt.where(MarketplaceLead.assigned_partner_user_id == getattr(user, "id", None)) + if status: + stmt = stmt.where(MarketplaceLead.status == status) + if q: + like = f"%{q}%" + stmt = stmt.where(or_(MarketplaceLead.lead_no.ilike(like), MarketplaceLead.lead_name.ilike(like), MarketplaceLead.business_name.ilike(like), MarketplaceLead.mobile.ilike(like), MarketplaceLead.email.ilike(like), MarketplaceLead.service_requested.ilike(like))) + return list(db.execute(stmt).scalars().all()) + + +def get_marketplace_lead(db, lead_id: int) -> MarketplaceLead | None: + return db.get(MarketplaceLead, int(lead_id)) + + +def create_marketplace_lead(db, *, lead_name: str, business_name: str | None, email: str | None, mobile: str | None, city: str | None, state: str | None, service_category: str | None, service_requested: str, message: str | None, source: str = "manual", priority: str = "NORMAL", estimated_value=0, created_by_user_id: int | None = None) -> MarketplaceLead: + lead = MarketplaceLead( + lead_no=generate_lead_no(db), + source=source or "manual", + service_category=service_category or None, + service_requested=(service_requested or "General enquiry").strip(), + lead_name=lead_name.strip(), + business_name=(business_name or None), + email=(email or None), + mobile=(mobile or None), + city=(city or None), + state=(state or None), + message=(message or None), + priority=priority if priority in LEAD_PRIORITIES else "NORMAL", + estimated_value=money(estimated_value), + created_by_user_id=created_by_user_id, + ) + db.add(lead) + db.commit() + db.refresh(lead) + return lead + + +def assign_marketplace_lead(db, *, lead: MarketplaceLead, tenant_id: int, branch_id: int | None, partner_user_id: int | None, notes: str | None, assigned_by_user_id: int | None) -> MarketplaceLead: + now = datetime.now(timezone.utc) + lead.assigned_tenant_id = tenant_id + lead.assigned_branch_id = branch_id or None + lead.assigned_partner_user_id = partner_user_id or None + lead.assigned_by_user_id = assigned_by_user_id + lead.assigned_at_utc = now + lead.status = "ASSIGNED" + lead.updated_by_user_id = assigned_by_user_id + db.add(MarketplaceLeadAssignment(lead_id=lead.id, tenant_id=tenant_id, branch_id=branch_id or None, partner_user_id=partner_user_id or None, notes=notes or None, assigned_by_user_id=assigned_by_user_id, assigned_at_utc=now)) + db.commit() + db.refresh(lead) + return lead + + +def update_lead_status(db, *, lead: MarketplaceLead, status: str, user_id: int | None) -> MarketplaceLead: + if status not in LEAD_STATUSES: + raise ValueError("Invalid lead status") + lead.status = status + lead.updated_by_user_id = user_id + db.commit() + db.refresh(lead) + return lead + + +def convert_lead_to_client(db, *, lead: MarketplaceLead, tenant_id: int, branch_id: int, partner_user_id: int | None, client_code: str | None, user_id: int | None) -> Client: + if lead.converted_client_id: + existing = db.get(Client, lead.converted_client_id) + if existing: + return existing + code = (client_code or f"LEAD-{lead.id:05d}").strip().upper() + name = (lead.business_name or lead.lead_name).strip() + client = Client( + tenant_id=tenant_id, + branch_id=branch_id, + partner_id=partner_user_id or None, + engagement_mode="internal_managed", + client_code=code, + client_name=name, + trade_name=lead.business_name or None, + client_type="Other", + contact_person_name=lead.lead_name, + mobile=lead.mobile, + email=lead.email, + city=lead.city, + state=lead.state, + status="active", + is_active=True, + is_archived=False, + notes=f"Converted from marketplace lead {lead.lead_no}.\n\n{lead.message or ''}".strip(), + created_by_user_id=user_id, + updated_by_user_id=user_id, + ) + db.add(client) + db.flush() + lead.converted_client_id = client.id + lead.converted_by_user_id = user_id + lead.converted_at_utc = datetime.now(timezone.utc) + lead.status = "CONVERTED" + lead.updated_by_user_id = user_id + db.commit() + db.refresh(client) + return client + + +def get_marketplace_overview_counts(db) -> dict[str, int]: + """Return lightweight public marketplace counts. + + This is intentionally read-only and tolerant of partially configured data so the + public marketplace page never breaks the ERP login/runtime flow. + """ + try: + firm_count = db.execute(select(func.count(Tenant.id)).where(Tenant.is_active.is_(True))).scalar_one() or 0 + except Exception: + firm_count = 0 + try: + consultant_count = db.execute(select(func.count(ConsultantProfile.id)).where(ConsultantProfile.is_active.is_(True))).scalar_one() or 0 + except Exception: + consultant_count = 0 + try: + lead_count = db.execute(select(func.count(MarketplaceLead.id))).scalar_one() or 0 + except Exception: + lead_count = 0 + return {"audit_firms": int(firm_count), "consultants": int(consultant_count), "service_requests": int(lead_count)} + + +def list_public_marketplace_audit_firms(db, limit: int = 12) -> list[Tenant]: + """List active audit firms for public marketplace display. + + More advanced publication controls can be added later. For Phase 7T.4 this uses + only active tenants and does not expose private firm records beyond basic name/type. + """ + try: + stmt = select(Tenant).where(Tenant.is_active.is_(True)).order_by(Tenant.name.asc()).limit(int(limit or 12)) + return list(db.execute(stmt).scalars().all()) + except Exception: + return [] + + +def list_public_marketplace_consultants(db, limit: int = 12) -> list[ConsultantProfile]: + """List active consultants for public marketplace display.""" + try: + stmt = ( + select(ConsultantProfile) + .where(ConsultantProfile.is_active.is_(True)) + .order_by(ConsultantProfile.contact_person.asc(), ConsultantProfile.firm_name.asc()) + .limit(int(limit or 12)) + ) + return list(db.execute(stmt).scalars().all()) + except Exception: + return [] + + +def is_marketplace_domain_request(request) -> bool: + """True when Phase 7T.2 resolved the host as a marketplace domain.""" + try: + return getattr(request.state, "domain_type", None) == "marketplace" + except Exception: + return False diff --git a/app/modules/marketplace/templates/marketplace/dashboard.html b/app/modules/marketplace/templates/marketplace/dashboard.html new file mode 100644 index 0000000..447549a --- /dev/null +++ b/app/modules/marketplace/templates/marketplace/dashboard.html @@ -0,0 +1,35 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Marketplace Leads

+

Public/service leads that can be assigned to Audit Firms and converted into clients.

+
+
+ View Leads + {% if can_create %}New Lead{% endif %} + Public Lead Form +
+
+ +
+

Recent Leads

+
+ + + + {% for row in leads %} + + {% else %}{% endfor %} + +
LeadServiceStatusContact
{{ row.lead_no }}
{{ row.lead_name }}
{{ row.service_requested }}{{ row.status }}{{ row.mobile or row.email or '-' }}Open
No leads found.
+
+
+
+{% endblock %} diff --git a/app/modules/marketplace/templates/marketplace/lead_detail.html b/app/modules/marketplace/templates/marketplace/lead_detail.html new file mode 100644 index 0000000..7e1edc3 --- /dev/null +++ b/app/modules/marketplace/templates/marketplace/lead_detail.html @@ -0,0 +1,61 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+

{{ lead.lead_no }} - {{ lead.lead_name }}

{{ lead.service_requested }} • {{ lead.status }}

+ Back +
+
+
+

Lead Details

+
+
Business
{{ lead.business_name or '-' }}
+
Contact
{{ lead.mobile or '-' }} / {{ lead.email or '-' }}
+
Location
{{ lead.city or '-' }}, {{ lead.state or '-' }}
+
Category
{{ lead.service_category or '-' }}
+
Priority
{{ lead.priority }}
+
Estimated Value
{{ lead.estimated_value }}
+
Assigned Audit Firm ID
{{ lead.assigned_tenant_id or '-' }}
+
Assigned Partner User ID
{{ lead.assigned_partner_user_id or '-' }}
+
Message
{{ lead.message or '-' }}
+
+
+
+ {% if can_update %} +
+ +

Update Status

+ + +
+ {% endif %} + {% if can_assign %} +
+ +

Assign Lead

+ + + + + +
+ {% endif %} + {% if can_convert and not lead.converted_client_id and lead.assigned_tenant_id %} +
+ +

Convert to Client

+ + + + + +
+ {% elif can_convert and not lead.converted_client_id and not lead.assigned_tenant_id %} +
Assign this lead to an Audit Firm before converting it to a client.
+ {% elif lead.converted_client_id %} +
Converted to Client ID {{ lead.converted_client_id }}
+ {% endif %} +
+
+
+{% endblock %} diff --git a/app/modules/marketplace/templates/marketplace/lead_form.html b/app/modules/marketplace/templates/marketplace/lead_form.html new file mode 100644 index 0000000..7f130ff --- /dev/null +++ b/app/modules/marketplace/templates/marketplace/lead_form.html @@ -0,0 +1,22 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+

New Marketplace Lead

+
+ + + + + + + + + + + + + +
Cancel
+
+
+{% endblock %} diff --git a/app/modules/marketplace/templates/marketplace/leads_list.html b/app/modules/marketplace/templates/marketplace/leads_list.html new file mode 100644 index 0000000..2cb3695 --- /dev/null +++ b/app/modules/marketplace/templates/marketplace/leads_list.html @@ -0,0 +1,24 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+

Marketplace Leads

Manage public leads and lead assignments.

+ {% if can_create %}New Lead{% endif %} +
+
+ + + +
+
+ + + + {% for row in rows %} + + {% else %}{% endfor %} + +
Lead NoLeadServiceStatusPriorityAssigned Audit Firm ID
{{ row.lead_no }}{{ row.lead_name }}
{{ row.business_name or '' }}
{{ row.service_requested }}{{ row.status }}{{ row.priority }}{{ row.assigned_tenant_id or '-' }}Open
No leads found.
+
+
+{% endblock %} diff --git a/app/modules/marketplace/templates/marketplace/public_audit_firms.html b/app/modules/marketplace/templates/marketplace/public_audit_firms.html new file mode 100644 index 0000000..0dfe943 --- /dev/null +++ b/app/modules/marketplace/templates/marketplace/public_audit_firms.html @@ -0,0 +1,20 @@ +{% extends "modules/marketplace/templates/marketplace/public_base.html" %} +{% block public_content %} +
+
+

Audit Firms

Active audit firms available in the marketplace.

+ Request Service +
+
+ {% for firm in public_audit_firms %} +
+
{{ firm.name }}
+
{{ firm.firm_type|replace('_',' ')|title if firm.firm_type else 'Audit Firm' }}
+
Service enquiry can be routed by the marketplace team.
+
+ {% else %} +
No public audit firm listing is available yet.
+ {% endfor %} +
+
+{% endblock %} diff --git a/app/modules/marketplace/templates/marketplace/public_base.html b/app/modules/marketplace/templates/marketplace/public_base.html new file mode 100644 index 0000000..fd65f61 --- /dev/null +++ b/app/modules/marketplace/templates/marketplace/public_base.html @@ -0,0 +1,68 @@ + + + + + + {% set brand = get_current_firm_branding(request, none) %} + {{ title or 'Marketplace' }} | {{ brand.firm_name or 'FilingABC' }} + {% if brand.favicon_url %}{% endif %} + + + + + +
+ +
+ +
+ {% block public_content %}{% endblock %} +
+ +
+
+
+
{{ brand.firm_name or 'FilingABC' }}
+

A marketplace layer for audit firms, consultants and clients.

+
+ +
+
Contact
+
{% if brand.contact_email %}
{{ brand.contact_email }}
{% endif %}{% if brand.contact_mobile %}
{{ brand.contact_mobile }}
{% endif %}
{{ brand.domain_name or request.url.hostname }}
+
+
+
+ + diff --git a/app/modules/marketplace/templates/marketplace/public_consultants.html b/app/modules/marketplace/templates/marketplace/public_consultants.html new file mode 100644 index 0000000..35ca318 --- /dev/null +++ b/app/modules/marketplace/templates/marketplace/public_consultants.html @@ -0,0 +1,20 @@ +{% extends "modules/marketplace/templates/marketplace/public_base.html" %} +{% block public_content %} +
+
+

Consultants

Bookkeeping, filing, advisory and referral partners.

+ Request Service +
+
+ {% for consultant in public_consultants %} +
+
{{ consultant.firm_name or consultant.contact_person }}
+
{{ consultant.specialisation or consultant.consultant_type|replace('_',' ')|title }}
+ {% if consultant.mobile or consultant.email %}
{% if consultant.mobile %}
{{ consultant.mobile }}
{% endif %}{% if consultant.email %}
{{ consultant.email }}
{% endif %}
{% endif %} +
+ {% else %} +
No public consultant listing is available yet.
+ {% endfor %} +
+
+{% endblock %} diff --git a/app/modules/marketplace/templates/marketplace/public_lead_form.html b/app/modules/marketplace/templates/marketplace/public_lead_form.html new file mode 100644 index 0000000..a93ffc3 --- /dev/null +++ b/app/modules/marketplace/templates/marketplace/public_lead_form.html @@ -0,0 +1,20 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+

Request Professional Service

+

Submit your requirement. The platform team will review and assign it to a suitable Audit Firm.

+
+ + + + + + + + + + +
+
+
+{% endblock %} diff --git a/app/modules/marketplace/templates/marketplace/public_marketplace_home.html b/app/modules/marketplace/templates/marketplace/public_marketplace_home.html new file mode 100644 index 0000000..9663ea9 --- /dev/null +++ b/app/modules/marketplace/templates/marketplace/public_marketplace_home.html @@ -0,0 +1,61 @@ +{% extends "modules/marketplace/templates/marketplace/public_base.html" %} +{% block public_content %} +
+
+
+
FilingABC Marketplace
+

Find audit, GST, income tax, ROC and accounting support from trusted professionals.

+

Clients can request services, consultants can generate leads, and the platform team can route work to the right audit firm or partner.

+ +
+
+
+
{{ marketplace_counts.audit_firms }}
Audit firms
+
{{ marketplace_counts.consultants }}
Consultants
+
{{ marketplace_counts.service_requests }}
Service requests
+
+
+
+
+ +
+
+ {% for item in [ + ('GST & Tax Compliance', 'GSTR filing, notices, reconciliation, tax return support and advisory.'), + ('Audit & Assurance', 'Statutory audit, internal audit, tax audit and engagement tracking.'), + ('ROC & Business Services', 'Company filings, registrations, payroll, accounting and compliance calendars.') + ] %} +
+

{{ item[0] }}

+

{{ item[1] }}

+
+ {% endfor %} +
+
+ +
+
+

Featured Audit Firms

View all
+
+ {% for firm in public_audit_firms %} +
{{ firm.name }}
{{ firm.firm_type|replace('_',' ')|title if firm.firm_type else 'Audit Firm' }}
+ {% else %} +
Audit firm listings will appear here after activation.
+ {% endfor %} +
+
+
+

Consultant Network

View all
+
+ {% for consultant in public_consultants %} +
{{ consultant.firm_name or consultant.contact_person }}
{{ consultant.specialisation or consultant.consultant_type|replace('_',' ')|title }}
+ {% else %} +
Consultant listings will appear here after activation.
+ {% endfor %} +
+
+
+{% endblock %} diff --git a/app/modules/marketplace/templates/marketplace/public_marketplace_lead_form.html b/app/modules/marketplace/templates/marketplace/public_marketplace_lead_form.html new file mode 100644 index 0000000..27d8beb --- /dev/null +++ b/app/modules/marketplace/templates/marketplace/public_marketplace_lead_form.html @@ -0,0 +1,22 @@ +{% extends "modules/marketplace/templates/marketplace/public_base.html" %} +{% block public_content %} +
+
+

Request Professional Service

+

Submit your requirement. The marketplace team will review and route it to the right audit firm or consultant.

+
+ + + + + + + + + + +
+
+
+
+{% endblock %} diff --git a/app/modules/marketplace/templates/marketplace/public_marketplace_thank_you.html b/app/modules/marketplace/templates/marketplace/public_marketplace_thank_you.html new file mode 100644 index 0000000..7a44094 --- /dev/null +++ b/app/modules/marketplace/templates/marketplace/public_marketplace_thank_you.html @@ -0,0 +1,12 @@ +{% extends "modules/marketplace/templates/marketplace/public_base.html" %} +{% block public_content %} +
+
+
+

Thank you

+

Your service request has been received by the marketplace team.

+

Reference: {{ lead.lead_no }}

+ +
+
+{% endblock %} diff --git a/app/modules/marketplace/templates/marketplace/public_thank_you.html b/app/modules/marketplace/templates/marketplace/public_thank_you.html new file mode 100644 index 0000000..6ffd24f --- /dev/null +++ b/app/modules/marketplace/templates/marketplace/public_thank_you.html @@ -0,0 +1,8 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+

Thank you

+

Your service request has been received.

+

Reference: {{ lead.lead_no }}

+
+{% endblock %} diff --git a/app/modules/marketplace/ui.py b/app/modules/marketplace/ui.py new file mode 100644 index 0000000..c4c6614 --- /dev/null +++ b/app/modules/marketplace/ui.py @@ -0,0 +1,343 @@ +from __future__ import annotations + +from fastapi import APIRouter, Form, Request +from fastapi.responses import RedirectResponse + +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.core.rbac.deps import get_user_permissions, get_user_roles +from app.modules.core.rbac.permission_guard import require_permission +from app.modules.marketplace.services import ( + LEAD_PRIORITIES, + LEAD_SOURCES, + LEAD_STATUSES, + SERVICE_CATEGORIES, + assign_marketplace_lead, + convert_lead_to_client, + create_marketplace_lead, + get_marketplace_lead, + get_marketplace_overview_counts, + is_marketplace_domain_request, + list_marketplace_leads, + list_public_marketplace_audit_firms, + list_public_marketplace_consultants, + list_reference_audit_firms, + list_reference_branches, + list_reference_partners, + update_lead_status, +) + +router = APIRouter(prefix="/marketplace", tags=["marketplace-ui"]) +public_router = APIRouter(tags=["marketplace-public-ui"]) + + + + +def _public_marketplace_ctx(request: Request, db, **ctx): + base = { + "request": request, + "current_user": None, + "current_user_roles": [], + "current_user_permissions": [], + "csrf_token": get_or_create_csrf_token(request), + "service_categories": SERVICE_CATEGORIES, + "lead_priorities": LEAD_PRIORITIES, + "lead_sources": LEAD_SOURCES, + "marketplace_counts": get_marketplace_overview_counts(db), + "public_audit_firms": list_public_marketplace_audit_firms(db, limit=8), + "public_consultants": list_public_marketplace_consultants(db, limit=8), + } + base.update(ctx) + return base + + +def _render_public_marketplace(request: Request, template: str, db, **ctx): + return templates.TemplateResponse(template, _public_marketplace_ctx(request, db, **ctx)) + + +@public_router.get("/") +def marketplace_domain_home(request: Request): + """Marketplace domain landing page. + + Only marketplace-mapped domains use this as the public home page. Normal localhost, + tenant domains and consultant domains continue to the normal login flow. + """ + if not is_marketplace_domain_request(request): + return RedirectResponse(url="/login", status_code=303) + db = CommonSessionLocal() + try: + return _render_public_marketplace( + request, + "modules/marketplace/templates/marketplace/public_marketplace_home.html", + db, + title="FilingABC Marketplace", + ) + finally: + db.close() + + +@public_router.get("/audit-firms") +def marketplace_public_audit_firms(request: Request): + if not is_marketplace_domain_request(request): + return RedirectResponse(url="/login", status_code=303) + db = CommonSessionLocal() + try: + return _render_public_marketplace( + request, + "modules/marketplace/templates/marketplace/public_audit_firms.html", + db, + title="Audit Firms", + public_audit_firms=list_public_marketplace_audit_firms(db, limit=50), + ) + finally: + db.close() + + +@public_router.get("/consultants") +def marketplace_public_consultants(request: Request): + if not is_marketplace_domain_request(request): + return RedirectResponse(url="/login", status_code=303) + db = CommonSessionLocal() + try: + return _render_public_marketplace( + request, + "modules/marketplace/templates/marketplace/public_consultants.html", + db, + title="Consultants", + public_consultants=list_public_marketplace_consultants(db, limit=50), + ) + finally: + db.close() + + +@public_router.get("/request-service") +def marketplace_public_request_service(request: Request): + if not is_marketplace_domain_request(request): + return RedirectResponse(url="/marketplace/public-lead", status_code=303) + db = CommonSessionLocal() + try: + return _render_public_marketplace( + request, + "modules/marketplace/templates/marketplace/public_marketplace_lead_form.html", + db, + title="Request a Service", + ) + finally: + db.close() + + +@public_router.post("/request-service") +def marketplace_public_request_service_submit(request: Request, csrf_token: str = Form(...), lead_name: str = Form(...), business_name: str = Form(""), email: str = Form(""), mobile: str = Form(""), city: str = Form(""), state: str = Form(""), service_category: str = Form(""), service_requested: str = Form(...), message: str = Form("")): + if not is_marketplace_domain_request(request): + return RedirectResponse(url="/marketplace/public-lead", status_code=303) + db = CommonSessionLocal() + try: + validate_csrf(request, csrf_token) + lead = create_marketplace_lead( + db, + lead_name=lead_name, + business_name=business_name, + email=email, + mobile=mobile, + city=city, + state=state, + service_category=service_category, + service_requested=service_requested, + message=message, + source="public_website", + priority="NORMAL", + ) + return _render_public_marketplace( + request, + "modules/marketplace/templates/marketplace/public_marketplace_thank_you.html", + db, + title="Thank You", + lead=lead, + ) + finally: + db.close() + + +def _redirect_denied(): + return RedirectResponse(url="/system-settings", status_code=303) + + +def _has_perm(db, user, code: str) -> bool: + try: + require_permission(db, user, code) + return True + except Exception: + return False + + +def _require_user(request: Request, db, permission_code: str): + user = get_current_user(request, db=db) + if not user: + return None, RedirectResponse(url="/login", status_code=303) + try: + require_permission(db, user, permission_code) + except Exception: + # Assigned lead users are allowed if they have assigned-lead permission. + if permission_code == "marketplace_leads.view" and _has_perm(db, user, "marketplace_leads.view_assigned"): + return user, None + return user, _redirect_denied() + return user, None + + +def _base_ctx(request: Request, db, user=None, **ctx): + base = { + "request": request, + "current_user": user, + "current_user_roles": get_user_roles(db, user.id) if user else [], + "current_user_permissions": get_user_permissions(db, user.id) if user else [], + "csrf_token": get_or_create_csrf_token(request), + "lead_statuses": LEAD_STATUSES, + "lead_priorities": LEAD_PRIORITIES, + "lead_sources": LEAD_SOURCES, + "service_categories": SERVICE_CATEGORIES, + } + base.update(ctx) + return base + + +def _render(request: Request, template: str, db, user=None, **ctx): + return templates.TemplateResponse(template, _base_ctx(request, db, user, **ctx)) + + +@router.get("") +def dashboard(request: Request): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "marketplace_leads.view") + if response: + return response + roles = get_user_roles(db, user.id) + leads = list_marketplace_leads(db, user=user, role_names=roles) + return _render(request, "modules/marketplace/templates/marketplace/dashboard.html", db, user, title="Marketplace Leads", leads=leads[:8], total=len(leads), can_create=_has_perm(db, user, "marketplace_leads.create"), can_assign=_has_perm(db, user, "marketplace_leads.assign")) + finally: + db.close() + + +@router.get("/leads") +def leads_list(request: Request, q: str = "", status: str = ""): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "marketplace_leads.view") + if response: + return response + roles = get_user_roles(db, user.id) + return _render(request, "modules/marketplace/templates/marketplace/leads_list.html", db, user, title="Marketplace Leads", rows=list_marketplace_leads(db, q=q, status=status, user=user, role_names=roles), q=q, selected_status=status, can_create=_has_perm(db, user, "marketplace_leads.create"), can_assign=_has_perm(db, user, "marketplace_leads.assign")) + finally: + db.close() + + +@router.get("/leads/new") +def lead_new(request: Request): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "marketplace_leads.create") + if response: + return response + return _render(request, "modules/marketplace/templates/marketplace/lead_form.html", db, user, title="New Marketplace Lead", public_mode=False) + finally: + db.close() + + +@router.post("/leads/new") +def lead_create(request: Request, csrf_token: str = Form(...), lead_name: str = Form(...), business_name: str = Form(""), email: str = Form(""), mobile: str = Form(""), city: str = Form(""), state: str = Form(""), service_category: str = Form(""), service_requested: str = Form(...), message: str = Form(""), source: str = Form("manual"), priority: str = Form("NORMAL"), estimated_value: str = Form("0")): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "marketplace_leads.create") + if response: + return response + validate_csrf(request, csrf_token) + lead = create_marketplace_lead(db, lead_name=lead_name, business_name=business_name, email=email, mobile=mobile, city=city, state=state, service_category=service_category, service_requested=service_requested, message=message, source=source, priority=priority, estimated_value=estimated_value, created_by_user_id=user.id) + return RedirectResponse(url=f"/marketplace/leads/{lead.id}", status_code=303) + finally: + db.close() + + +@router.get("/public-lead") +def public_lead_form(request: Request): + db = CommonSessionLocal() + try: + return _render(request, "modules/marketplace/templates/marketplace/public_lead_form.html", db, None, title="Request a Service") + finally: + db.close() + + +@router.post("/public-lead") +def public_lead_submit(request: Request, csrf_token: str = Form(...), lead_name: str = Form(...), business_name: str = Form(""), email: str = Form(""), mobile: str = Form(""), city: str = Form(""), state: str = Form(""), service_category: str = Form(""), service_requested: str = Form(...), message: str = Form("")): + db = CommonSessionLocal() + try: + validate_csrf(request, csrf_token) + lead = create_marketplace_lead(db, lead_name=lead_name, business_name=business_name, email=email, mobile=mobile, city=city, state=state, service_category=service_category, service_requested=service_requested, message=message, source="public_website", priority="NORMAL") + return _render(request, "modules/marketplace/templates/marketplace/public_thank_you.html", db, None, title="Thank You", lead=lead) + finally: + db.close() + + +@router.get("/leads/{lead_id}") +def lead_detail(request: Request, lead_id: int): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "marketplace_leads.view") + if response: + return response + lead = get_marketplace_lead(db, lead_id) + if not lead: + return RedirectResponse(url="/marketplace/leads", status_code=303) + return _render(request, "modules/marketplace/templates/marketplace/lead_detail.html", db, user, title=f"Lead {lead.lead_no}", lead=lead, audit_firms=list_reference_audit_firms(db), branches=list_reference_branches(db, lead.assigned_tenant_id), partners=list_reference_partners(db, lead.assigned_tenant_id), can_assign=_has_perm(db, user, "marketplace_leads.assign"), can_update=_has_perm(db, user, "marketplace_leads.update"), can_convert=_has_perm(db, user, "marketplace_leads.convert")) + finally: + db.close() + + +@router.post("/leads/{lead_id}/assign") +def lead_assign(request: Request, lead_id: int, csrf_token: str = Form(...), tenant_id: int = Form(...), branch_id: int = Form(0), partner_user_id: int = Form(0), notes: str = Form("")): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "marketplace_leads.assign") + if response: + return response + validate_csrf(request, csrf_token) + lead = get_marketplace_lead(db, lead_id) + if lead: + assign_marketplace_lead(db, lead=lead, tenant_id=tenant_id, branch_id=branch_id or None, partner_user_id=partner_user_id or None, notes=notes, assigned_by_user_id=user.id) + return RedirectResponse(url=f"/marketplace/leads/{lead_id}", status_code=303) + finally: + db.close() + + +@router.post("/leads/{lead_id}/status") +def lead_status_update(request: Request, lead_id: int, csrf_token: str = Form(...), status: str = Form(...)): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "marketplace_leads.update") + if response: + return response + validate_csrf(request, csrf_token) + lead = get_marketplace_lead(db, lead_id) + if lead: + update_lead_status(db, lead=lead, status=status, user_id=user.id) + return RedirectResponse(url=f"/marketplace/leads/{lead_id}", status_code=303) + finally: + db.close() + + +@router.post("/leads/{lead_id}/convert-client") +def lead_convert_client(request: Request, lead_id: int, csrf_token: str = Form(...), tenant_id: int = Form(...), branch_id: int = Form(...), partner_user_id: int = Form(0), client_code: str = Form("")): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "marketplace_leads.convert") + if response: + return response + validate_csrf(request, csrf_token) + lead = get_marketplace_lead(db, lead_id) + if lead: + client = convert_lead_to_client(db, lead=lead, tenant_id=tenant_id, branch_id=branch_id, partner_user_id=partner_user_id or None, client_code=client_code, user_id=user.id) + return RedirectResponse(url=f"/clients/{client.id}", status_code=303) + return RedirectResponse(url="/marketplace/leads", status_code=303) + finally: + db.close() diff --git a/app/modules/notice_cases/__init__.py b/app/modules/notice_cases/__init__.py new file mode 100644 index 0000000..0924acf --- /dev/null +++ b/app/modules/notice_cases/__init__.py @@ -0,0 +1 @@ +"""Notice and case management module.""" diff --git a/app/modules/notice_cases/models.py b/app/modules/notice_cases/models.py new file mode 100644 index 0000000..53ce057 --- /dev/null +++ b/app/modules/notice_cases/models.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +from datetime import date, datetime, timezone + +from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.db.common import CommonBase + + +class NoticeCase(CommonBase): + """Department notice / case / appeal master. + + A case is intentionally kept separate from engagements because GST/Income Tax/ROC + proceedings can continue across years, have independent due dates, and may later + be linked to one or more engagements/tasks. + """ + + __tablename__ = "notice_cases" + __table_args__ = ( + UniqueConstraint("tenant_id", "case_code", name="uq_notice_cases_tenant_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) + 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) + engagement_id: Mapped[int | None] = mapped_column(ForeignKey("client_service_subscriptions.id", ondelete="SET NULL"), nullable=True, index=True) + + case_code: Mapped[str] = mapped_column(String(80), nullable=False, index=True) + department: Mapped[str] = mapped_column(String(40), nullable=False, index=True) + case_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + title: Mapped[str] = mapped_column(String(255), nullable=False) + reference_no: Mapped[str | None] = mapped_column(String(150), nullable=True, index=True) + din_ack_no: Mapped[str | None] = mapped_column(String(150), nullable=True, index=True) + + notice_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True) + due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True) + financial_year: Mapped[str | None] = mapped_column(String(9), nullable=True, index=True) + assessment_year: Mapped[str | None] = mapped_column(String(9), nullable=True, index=True) + period_label: Mapped[str | None] = mapped_column(String(60), nullable=True, index=True) + + status: Mapped[str] = mapped_column(String(40), nullable=False, default="open", index=True) + priority: Mapped[str] = mapped_column(String(20), nullable=False, default="normal", index=True) + issue_summary: Mapped[str | None] = mapped_column(Text, nullable=True) + remarks: Mapped[str | None] = mapped_column(Text, nullable=True) + + assigned_partner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + assigned_manager_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + assigned_staff_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + + is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True) + archived_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + archived_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + + created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) + updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False) + + client = relationship("Client") + engagement = relationship("ClientServiceSubscription") + assigned_partner = relationship("User", foreign_keys=[assigned_partner_user_id]) + assigned_manager = relationship("User", foreign_keys=[assigned_manager_user_id]) + assigned_staff = relationship("User", foreign_keys=[assigned_staff_user_id]) + events = relationship("NoticeCaseEvent", back_populates="case", cascade="all, delete-orphan", passive_deletes=True, order_by="NoticeCaseEvent.event_date.desc(), NoticeCaseEvent.id.desc()") + hearings = relationship("NoticeCaseHearing", back_populates="case", cascade="all, delete-orphan", passive_deletes=True, order_by="NoticeCaseHearing.hearing_date.asc(), NoticeCaseHearing.id.asc()") + orders = relationship("NoticeCaseOrder", back_populates="case", cascade="all, delete-orphan", passive_deletes=True, order_by="NoticeCaseOrder.order_date.desc(), NoticeCaseOrder.id.desc()") + documents = relationship("NoticeCaseDocument", back_populates="case", cascade="all, delete-orphan", passive_deletes=True, order_by="NoticeCaseDocument.uploaded_at_utc.desc()") + + +class NoticeCaseEvent(CommonBase): + __tablename__ = "notice_case_events" + + 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) + branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True) + case_id: Mapped[int] = mapped_column(ForeignKey("notice_cases.id", ondelete="CASCADE"), nullable=False, index=True) + + event_type: Mapped[str] = mapped_column(String(60), nullable=False, index=True) + event_date: Mapped[date] = mapped_column(Date, nullable=False, index=True) + description: Mapped[str] = mapped_column(Text, nullable=False) + next_due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True) + created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) + + case = relationship("NoticeCase", back_populates="events") + created_by = relationship("User") + + +class NoticeCaseHearing(CommonBase): + __tablename__ = "notice_case_hearings" + + 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) + branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True) + case_id: Mapped[int] = mapped_column(ForeignKey("notice_cases.id", ondelete="CASCADE"), nullable=False, index=True) + + hearing_date: Mapped[date] = mapped_column(Date, nullable=False, index=True) + hearing_time: Mapped[str | None] = mapped_column(String(20), nullable=True) + venue_or_mode: Mapped[str | None] = mapped_column(String(200), nullable=True) + officer_name: Mapped[str | None] = mapped_column(String(150), nullable=True) + agenda: Mapped[str | None] = mapped_column(Text, nullable=True) + outcome: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[str] = mapped_column(String(30), nullable=False, default="scheduled", index=True) + + created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=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) + + case = relationship("NoticeCase", back_populates="hearings") + created_by = relationship("User") + + +class NoticeCaseOrder(CommonBase): + __tablename__ = "notice_case_orders" + + 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) + branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True) + case_id: Mapped[int] = mapped_column(ForeignKey("notice_cases.id", ondelete="CASCADE"), nullable=False, index=True) + + order_type: Mapped[str] = mapped_column(String(60), nullable=False, index=True) + order_no: Mapped[str | None] = mapped_column(String(150), nullable=True, index=True) + order_date: Mapped[date] = mapped_column(Date, nullable=False, index=True) + demand_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + tax_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + interest_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + penalty_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + summary: Mapped[str | None] = mapped_column(Text, nullable=True) + appeal_due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True) + appeal_filed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True) + + created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) + + case = relationship("NoticeCase", back_populates="orders") + created_by = relationship("User") + + +class NoticeCaseDocument(CommonBase): + __tablename__ = "notice_case_documents" + + 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) + branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True) + case_id: Mapped[int] = mapped_column(ForeignKey("notice_cases.id", ondelete="CASCADE"), nullable=False, index=True) + event_id: Mapped[int | None] = mapped_column(ForeignKey("notice_case_events.id", ondelete="SET NULL"), nullable=True, index=True) + + document_type: Mapped[str] = mapped_column(String(80), nullable=False, default="GENERAL", index=True) + title: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + original_filename: Mapped[str] = mapped_column(String(255), nullable=False) + stored_filename: Mapped[str] = mapped_column(String(255), nullable=False) + content_type: Mapped[str | None] = mapped_column(String(150), nullable=True) + file_size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + local_relative_path: Mapped[str] = mapped_column(String(1000), nullable=False) + version_no: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + status: Mapped[str] = mapped_column(String(30), nullable=False, default="active", index=True) + is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True) + deleted_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + deleted_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + + uploaded_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + uploaded_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) + + case = relationship("NoticeCase", back_populates="documents") + event = relationship("NoticeCaseEvent") + uploaded_by = relationship("User", foreign_keys=[uploaded_by_user_id]) diff --git a/app/modules/notice_cases/service.py b/app/modules/notice_cases/service.py new file mode 100644 index 0000000..1518c0e --- /dev/null +++ b/app/modules/notice_cases/service.py @@ -0,0 +1,423 @@ +from __future__ import annotations + +from datetime import date, datetime, timezone +from pathlib import Path +from uuid import uuid4 + +from fastapi import UploadFile +from sqlalchemy import and_, func, or_, select +from sqlalchemy.orm import Session, joinedload + +from app.core.settings import get_settings +from app.modules.clients.models import Client +from app.modules.core.rbac.deps import get_user_permissions, get_user_roles +from app.modules.notice_cases.models import ( + NoticeCase, + NoticeCaseDocument, + NoticeCaseEvent, + NoticeCaseHearing, + NoticeCaseOrder, +) +from app.modules.services.models import ClientServiceSubscription + +DEPARTMENTS = ["GST", "Income Tax", "ROC", "PF", "ESI", "Labour", "MSME", "Other"] +CASE_TYPES = ["Notice", "Assessment", "Appeal", "Rectification", "Refund", "Registration", "Investigation", "Other"] +CASE_STATUSES = ["open", "reply_pending", "reply_filed", "hearing_scheduled", "order_received", "appeal_pending", "appeal_filed", "closed", "archived"] +CASE_PRIORITIES = ["low", "normal", "high", "urgent"] +EVENT_TYPES = ["Notice Received", "Reply Filed", "Hearing", "Order Received", "Appeal Filed", "Rectification Filed", "Payment Made", "Internal Note", "Client Clarification", "Other"] +HEARING_STATUSES = ["scheduled", "attended", "adjourned", "missed", "cancelled"] +ORDER_TYPES = ["Assessment Order", "Appeal Order", "Rectification Order", "Refund Order", "Penalty Order", "Other"] +CASE_DOCUMENT_TYPES = ["NOTICE", "REPLY", "APPEAL", "ORDER", "CHALLAN", "WORKING", "CLIENT_DOCUMENT", "ACKNOWLEDGEMENT", "OTHER"] + + +def _has(permissions: list[str], code: str) -> bool: + return code in set(permissions or []) + + +def can_view_notice_cases(db: Session, user) -> bool: + perms = get_user_permissions(db, user.id) + return _has(perms, "notice_cases.view") + + +def can_manage_notice_cases(db: Session, user) -> bool: + perms = get_user_permissions(db, user.id) + return _has(perms, "notice_cases.create") or _has(perms, "notice_cases.edit") + + +def can_upload_notice_case_documents(db: Session, user) -> bool: + perms = get_user_permissions(db, user.id) + return _has(perms, "notice_cases.documents.upload") + + +def can_delete_notice_case_documents(db: Session, user) -> bool: + perms = get_user_permissions(db, user.id) + return _has(perms, "notice_cases.documents.delete") + + +def user_can_access_case( + db: Session, + user, + case: NoticeCase, + *, + active_tenant_id: int | None, + active_branch_id: int | None, + active_financial_year: str | None = None, + active_assessment_year: str | None = None, +) -> bool: + roles = set(get_user_roles(db, user.id)) + perms = set(get_user_permissions(db, user.id)) + if "System Admin" in roles and "notice_cases.cross_tenant" in perms: + return active_tenant_id in (None, case.tenant_id) or True + if case.tenant_id != int(active_tenant_id or getattr(user, "tenant_id", 0) or 0): + return False + if active_branch_id and case.branch_id and case.branch_id != int(active_branch_id): + return False + if "notice_cases.view" not in perms: + return False + fy = (active_financial_year or "").strip() + ay = (active_assessment_year or "").strip() + if fy and (case.financial_year or "").strip() and (case.financial_year or "").strip() != fy: + return False + if ay and not fy and (case.assessment_year or "").strip() and (case.assessment_year or "").strip() != ay: + return False + if "notice_cases.view.own_only" in perms and not ({case.assigned_partner_user_id, case.assigned_manager_user_id, case.assigned_staff_user_id} & {user.id}): + return False + return True + + +def parse_date(value: str | None) -> date | None: + value = (value or "").strip() + if not value: + return None + return date.fromisoformat(value) + + +def normalize_choice(value: str | None, choices: list[str], default: str) -> str: + value = (value or "").strip() + return value if value in choices else default + + +def make_case_code(db: Session, tenant_id: int, case_id: int, department: str) -> str: + prefix = "CASE" + dept = (department or "GEN").upper().replace(" ", "")[:4] + return f"{prefix}-{tenant_id}-{dept}-{case_id:06d}" + + +def list_clients_for_case(db: Session, *, tenant_id: int, branch_id: int | None) -> list[Client]: + stmt = select(Client).where(Client.tenant_id == tenant_id, Client.is_archived.is_(False)) + if branch_id: + stmt = stmt.where(Client.branch_id == branch_id) + return list(db.execute(stmt.order_by(Client.client_name.asc())).scalars()) + + +def list_engagements_for_client( + db: Session, + *, + tenant_id: int, + client_id: int, + financial_year: str | None = None, +) -> list[ClientServiceSubscription]: + stmt = select(ClientServiceSubscription).where( + ClientServiceSubscription.tenant_id == tenant_id, + ClientServiceSubscription.client_id == client_id, + ) + if financial_year: + stmt = stmt.where(ClientServiceSubscription.financial_year == financial_year.strip()) + return list( + db.execute( + stmt.order_by(ClientServiceSubscription.financial_year.desc(), ClientServiceSubscription.id.desc()) + ).scalars() + ) + + +def list_assignable_users(db: Session, *, tenant_id: int, branch_id: int | None): + from app.modules.core.iam.models import User + + stmt = select(User).where(User.tenant_id == tenant_id) + if branch_id: + stmt = stmt.where(or_(User.branch_id == branch_id, User.branch_id.is_(None))) + order_cols = [User.full_name.asc()] + if hasattr(User, "login_id"): + order_cols.append(User.login_id.asc()) + elif hasattr(User, "email"): + order_cols.append(User.email.asc()) + return list(db.execute(stmt.order_by(*order_cols)).scalars()) + + +def list_cases( + db: Session, + *, + tenant_id: int, + branch_id: int | None, + q: str = "", + department: str = "", + status: str = "", + include_archived: bool = False, + financial_year: str | None = None, + assessment_year: str | None = None, +) -> list[NoticeCase]: + stmt = ( + select(NoticeCase) + .options(joinedload(NoticeCase.client), joinedload(NoticeCase.assigned_partner), joinedload(NoticeCase.assigned_manager), joinedload(NoticeCase.assigned_staff)) + .where(NoticeCase.tenant_id == tenant_id) + ) + if branch_id: + stmt = stmt.where(NoticeCase.branch_id == branch_id) + if not include_archived: + stmt = stmt.where(NoticeCase.is_archived.is_(False)) + if department: + stmt = stmt.where(NoticeCase.department == department) + if status: + stmt = stmt.where(NoticeCase.status == status) + fy = (financial_year or "").strip() + ay = (assessment_year or "").strip() + if fy: + stmt = stmt.where(NoticeCase.financial_year == fy) + elif ay: + stmt = stmt.where(NoticeCase.assessment_year == ay) + q = (q or "").strip() + if q: + like = f"%{q}%" + stmt = stmt.join(Client, Client.id == NoticeCase.client_id).where( + or_( + NoticeCase.case_code.ilike(like), + NoticeCase.title.ilike(like), + NoticeCase.reference_no.ilike(like), + NoticeCase.din_ack_no.ilike(like), + Client.client_name.ilike(like), + Client.client_code.ilike(like), + Client.pan.ilike(like), + Client.gstin.ilike(like), + ) + ) + return list(db.execute(stmt.order_by(NoticeCase.due_date.asc().nullslast(), NoticeCase.updated_at_utc.desc())).unique().scalars()) + + +def case_dashboard_summary(rows: list[NoticeCase]) -> dict[str, int]: + today = date.today() + open_statuses = {"open", "reply_pending", "hearing_scheduled", "appeal_pending"} + summary = { + "total": len(rows), + "open": 0, + "reply_pending": 0, + "hearing_scheduled": 0, + "overdue": 0, + "closed": 0, + } + for row in rows: + status = (row.status or "").strip().lower() + if status in open_statuses: + summary["open"] += 1 + if status == "reply_pending": + summary["reply_pending"] += 1 + if status == "hearing_scheduled": + summary["hearing_scheduled"] += 1 + if status in {"closed", "archived"}: + summary["closed"] += 1 + if row.due_date and row.due_date < today and status not in {"closed", "archived"}: + summary["overdue"] += 1 + return summary + + +def get_case(db: Session, case_id: int) -> NoticeCase | None: + return db.execute( + select(NoticeCase) + .options( + joinedload(NoticeCase.client), + joinedload(NoticeCase.engagement), + joinedload(NoticeCase.assigned_partner), + joinedload(NoticeCase.assigned_manager), + joinedload(NoticeCase.assigned_staff), + joinedload(NoticeCase.events), + joinedload(NoticeCase.hearings), + joinedload(NoticeCase.orders), + joinedload(NoticeCase.documents).joinedload(NoticeCaseDocument.uploaded_by), + ) + .where(NoticeCase.id == case_id) + ).unique().scalar_one_or_none() + + +def create_case(db: Session, *, tenant_id: int, branch_id: int | None, user, data: dict) -> NoticeCase: + client = db.get(Client, int(data["client_id"])) + if not client or client.tenant_id != tenant_id: + raise ValueError("Invalid client selected.") + if branch_id and client.branch_id != branch_id: + raise ValueError("Selected client does not belong to the active branch.") + engagement_id = int(data["engagement_id"]) if data.get("engagement_id") else None + active_fy = (data.get("active_financial_year") or "").strip()[:9] or None + active_ay = (data.get("active_assessment_year") or "").strip()[:9] or None + selected_fy = (data.get("financial_year") or "").strip()[:9] or active_fy + selected_ay = (data.get("assessment_year") or "").strip()[:9] or active_ay + if engagement_id: + engagement = db.get(ClientServiceSubscription, engagement_id) + if not engagement or engagement.tenant_id != tenant_id or engagement.client_id != client.id: + raise ValueError("Invalid engagement selected.") + engagement_fy = (engagement.financial_year or "").strip() + engagement_ay = (engagement.assessment_year or "").strip() + if selected_fy and engagement_fy and selected_fy != engagement_fy: + raise ValueError("Selected engagement belongs to a different financial year.") + selected_fy = selected_fy or engagement_fy or None + selected_ay = selected_ay or engagement_ay or None + row = NoticeCase( + tenant_id=tenant_id, + branch_id=client.branch_id, + client_id=client.id, + engagement_id=engagement_id, + case_code="PENDING", + department=normalize_choice(data.get("department"), DEPARTMENTS, "GST"), + case_type=normalize_choice(data.get("case_type"), CASE_TYPES, "Notice"), + title=(data.get("title") or "").strip()[:255], + reference_no=(data.get("reference_no") or "").strip()[:150] or None, + din_ack_no=(data.get("din_ack_no") or "").strip()[:150] or None, + notice_date=parse_date(data.get("notice_date")), + due_date=parse_date(data.get("due_date")), + financial_year=selected_fy, + assessment_year=selected_ay, + period_label=(data.get("period_label") or "").strip()[:60] or None, + status=normalize_choice(data.get("status"), CASE_STATUSES, "open"), + priority=normalize_choice(data.get("priority"), CASE_PRIORITIES, "normal"), + issue_summary=(data.get("issue_summary") or "").strip() or None, + remarks=(data.get("remarks") or "").strip() or None, + assigned_partner_user_id=int(data["assigned_partner_user_id"]) if data.get("assigned_partner_user_id") else None, + assigned_manager_user_id=int(data["assigned_manager_user_id"]) if data.get("assigned_manager_user_id") else None, + assigned_staff_user_id=int(data["assigned_staff_user_id"]) if data.get("assigned_staff_user_id") else None, + created_by_user_id=user.id, + updated_by_user_id=user.id, + ) + if not row.title: + raise ValueError("Case title is required.") + db.add(row) + db.flush() + row.case_code = make_case_code(db, tenant_id, row.id, row.department) + return row + + +def update_case(db: Session, *, case: NoticeCase, user, data: dict) -> NoticeCase: + case.department = normalize_choice(data.get("department"), DEPARTMENTS, case.department) + case.case_type = normalize_choice(data.get("case_type"), CASE_TYPES, case.case_type) + case.title = (data.get("title") or case.title).strip()[:255] + case.reference_no = (data.get("reference_no") or "").strip()[:150] or None + case.din_ack_no = (data.get("din_ack_no") or "").strip()[:150] or None + case.notice_date = parse_date(data.get("notice_date")) + case.due_date = parse_date(data.get("due_date")) + active_fy = (data.get("active_financial_year") or "").strip()[:9] or None + active_ay = (data.get("active_assessment_year") or "").strip()[:9] or None + case.financial_year = (data.get("financial_year") or "").strip()[:9] or active_fy + case.assessment_year = (data.get("assessment_year") or "").strip()[:9] or active_ay + case.period_label = (data.get("period_label") or "").strip()[:60] or None + case.status = normalize_choice(data.get("status"), CASE_STATUSES, case.status) + case.priority = normalize_choice(data.get("priority"), CASE_PRIORITIES, case.priority) + case.issue_summary = (data.get("issue_summary") or "").strip() or None + case.remarks = (data.get("remarks") or "").strip() or None + case.assigned_partner_user_id = int(data["assigned_partner_user_id"]) if data.get("assigned_partner_user_id") else None + case.assigned_manager_user_id = int(data["assigned_manager_user_id"]) if data.get("assigned_manager_user_id") else None + case.assigned_staff_user_id = int(data["assigned_staff_user_id"]) if data.get("assigned_staff_user_id") else None + case.updated_by_user_id = user.id + return case + + +def add_event(db: Session, *, case: NoticeCase, user, data: dict) -> NoticeCaseEvent: + row = NoticeCaseEvent( + tenant_id=case.tenant_id, + branch_id=case.branch_id, + case_id=case.id, + event_type=normalize_choice(data.get("event_type"), EVENT_TYPES, "Internal Note"), + event_date=parse_date(data.get("event_date")) or date.today(), + description=(data.get("description") or "").strip(), + next_due_date=parse_date(data.get("next_due_date")), + created_by_user_id=user.id, + ) + if not row.description: + raise ValueError("Event description is required.") + if row.next_due_date: + case.due_date = row.next_due_date + case.updated_by_user_id = user.id + db.add(row) + return row + + +def add_hearing(db: Session, *, case: NoticeCase, user, data: dict) -> NoticeCaseHearing: + row = NoticeCaseHearing( + tenant_id=case.tenant_id, + branch_id=case.branch_id, + case_id=case.id, + hearing_date=parse_date(data.get("hearing_date")) or date.today(), + hearing_time=(data.get("hearing_time") or "").strip()[:20] or None, + venue_or_mode=(data.get("venue_or_mode") or "").strip()[:200] or None, + officer_name=(data.get("officer_name") or "").strip()[:150] or None, + agenda=(data.get("agenda") or "").strip() or None, + outcome=(data.get("outcome") or "").strip() or None, + status=normalize_choice(data.get("status"), HEARING_STATUSES, "scheduled"), + created_by_user_id=user.id, + ) + case.status = "hearing_scheduled" if row.status == "scheduled" else case.status + case.due_date = row.hearing_date + case.updated_by_user_id = user.id + db.add(row) + return row + + +def add_order(db: Session, *, case: NoticeCase, user, data: dict) -> NoticeCaseOrder: + row = NoticeCaseOrder( + tenant_id=case.tenant_id, + branch_id=case.branch_id, + case_id=case.id, + order_type=normalize_choice(data.get("order_type"), ORDER_TYPES, "Other"), + order_no=(data.get("order_no") or "").strip()[:150] or None, + order_date=parse_date(data.get("order_date")) or date.today(), + demand_amount=int(data.get("demand_amount") or 0), + tax_amount=int(data.get("tax_amount") or 0), + interest_amount=int(data.get("interest_amount") or 0), + penalty_amount=int(data.get("penalty_amount") or 0), + summary=(data.get("summary") or "").strip() or None, + appeal_due_date=parse_date(data.get("appeal_due_date")), + appeal_filed=bool(data.get("appeal_filed")), + created_by_user_id=user.id, + ) + case.status = "appeal_filed" if row.appeal_filed else "order_received" + case.due_date = row.appeal_due_date + case.updated_by_user_id = user.id + db.add(row) + return row + + +def _storage_root() -> Path: + settings = get_settings() + base = getattr(settings, "LOCAL_STORAGE_ROOT", None) or getattr(settings, "DOCUMENT_STORAGE_ROOT", None) or "data/storage" + return Path(base) + + +def case_document_absolute_path(document: NoticeCaseDocument) -> Path: + return _storage_root() / document.local_relative_path + + +def save_case_document(db: Session, *, case: NoticeCase, upload_file: UploadFile, user, title: str, document_type: str, description: str | None, event_id: int | None = None) -> NoticeCaseDocument: + original = Path(upload_file.filename or "case_document.bin").name + ext = Path(original).suffix.lower() + stored = f"case_{case.id}_{uuid4().hex}{ext}" + fy_folder = f"FY{case.financial_year}" if case.financial_year else "FY_UNASSIGNED" + rel = Path("notice_cases") / fy_folder / str(case.tenant_id) / str(case.client_id) / case.case_code / stored + absolute = _storage_root() / rel + absolute.parent.mkdir(parents=True, exist_ok=True) + data = upload_file.file.read() + absolute.write_bytes(data) + latest_version = db.execute(select(func.max(NoticeCaseDocument.version_no)).where(NoticeCaseDocument.case_id == case.id, NoticeCaseDocument.title == (title or original))).scalar_one() or 0 + row = NoticeCaseDocument( + tenant_id=case.tenant_id, + branch_id=case.branch_id, + case_id=case.id, + event_id=event_id, + document_type=normalize_choice(document_type, CASE_DOCUMENT_TYPES, "OTHER"), + title=(title or original).strip()[:255], + description=(description or "").strip() or None, + original_filename=original, + stored_filename=stored, + content_type=upload_file.content_type, + file_size_bytes=len(data), + local_relative_path=str(rel).replace("\\", "/"), + version_no=int(latest_version) + 1, + uploaded_by_user_id=user.id, + ) + db.add(row) + case.updated_by_user_id = user.id + return row diff --git a/app/modules/notice_cases/templates/notice_cases/detail.html b/app/modules/notice_cases/templates/notice_cases/detail.html new file mode 100644 index 0000000..0400e98 --- /dev/null +++ b/app/modules/notice_cases/templates/notice_cases/detail.html @@ -0,0 +1,32 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+

{{ case.case_code }} - {{ case.title }}

{{ case.client.client_name if case.client else '' }} · {{ case.department }} · {{ case.case_type }}

+
Back{% if can_manage %}Edit{% endif %}
+
+
+
Status
{{ case.status|replace('_',' ')|title }}
+
Due Date
{{ case.due_date or '-' }}
+
Reference
{{ case.reference_no or '-' }}
+
DIN/Ack
{{ case.din_ack_no or '-' }}
+
+ +
+

Issue Summary

{{ case.issue_summary or 'No issue summary captured.' }}

+

Assignments

Partner
{{ case.assigned_partner.full_name if case.assigned_partner else '-' }}
Manager
{{ case.assigned_manager.full_name if case.assigned_manager else '-' }}
Staff
{{ case.assigned_staff.full_name if case.assigned_staff else '-' }}
FY/AY
{{ case.financial_year or '-' }} / {{ case.assessment_year or '-' }}
+
+ +
+

Add Timeline Event

+

Timeline

{% for e in case.events %}
{{ e.event_type }} · {{ e.event_date }}
{{ e.description }}
{% if e.next_due_date %}
Next due: {{ e.next_due_date }}
{% endif %}
{% else %}

No timeline events.

{% endfor %}
+
+ +
+

Hearings

{% for h in case.hearings %}
{{ h.hearing_date }} {{ h.hearing_time or '' }} · {{ h.status|title }}
{{ h.venue_or_mode or '' }} {{ h.officer_name or '' }}
{% endfor %}
+

Orders

{% for o in case.orders %}
{{ o.order_type }} · {{ o.order_date }} · Demand: {{ o.demand_amount }}
{{ o.summary or '' }}
{% endfor %}
+
+ +

Case Documents

{% if can_upload %}
{% endif %}
{% for d in case.documents if not d.is_deleted %}{% else %}{% endfor %}
DocumentTypeUploadedAction
{{ d.title }}
{{ d.original_filename }}
{{ d.document_type }}{{ d.uploaded_at_utc.date() if d.uploaded_at_utc else '' }}Download{% if can_delete_documents %}
{% endif %}
No documents uploaded.
+
+{% endblock %} diff --git a/app/modules/notice_cases/templates/notice_cases/form.html b/app/modules/notice_cases/templates/notice_cases/form.html new file mode 100644 index 0000000..205b0f4 --- /dev/null +++ b/app/modules/notice_cases/templates/notice_cases/form.html @@ -0,0 +1,44 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+

{{ title }}

+

Capture notice/case details, due dates and responsibility.

+

Active FY: {{ active_financial_year or '-' }}{% if active_assessment_year %} · AY {{ active_assessment_year }}{% endif %}

+
+ {% if form_error %}
{{ form_error }}
{% endif %} +
+ +
+ + + +
+
+ + + +
+ +
+ + + + +
+
+ + + +
+
+ {% for field,label in [('assigned_partner_user_id','Partner'),('assigned_manager_user_id','Manager'),('assigned_staff_user_id','Staff')] %} + + {% endfor %} +
+ + +
Cancel
+
+
+{% endblock %} diff --git a/app/modules/notice_cases/templates/notice_cases/list.html b/app/modules/notice_cases/templates/notice_cases/list.html new file mode 100644 index 0000000..2b3cc1f --- /dev/null +++ b/app/modules/notice_cases/templates/notice_cases/list.html @@ -0,0 +1,49 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Notices & Cases

+

Track GST, Income Tax, ROC, PF/ESI notices, appeals, hearings, orders and case documents.

+

+ Showing: {% if financial_year == 'all' %}All Financial Years{% else %}FY {{ financial_year }}{% endif %}{% if assessment_year %} · AY {{ assessment_year }}{% endif %} +

+
+ {% if can_manage %}New Case{% endif %} +
+ +
+
Total
{{ summary.total }}
+
Open
{{ summary.open }}
+
Reply Pending
{{ summary.reply_pending }}
+
Hearings
{{ summary.hearing_scheduled }}
+
Overdue
{{ summary.overdue }}
+
+ +
+ + + + + + {% if include_archived %}{% endif %} + +
+ +
+ + + + {% for row in rows %} + + {% else %} + + {% endfor %} + +
CaseClientDepartmentFY / AYDue DateStatusAction
{{ row.case_code }}
{{ row.title }}
{{ row.reference_no or row.din_ack_no or '' }}
{{ row.client.client_name if row.client else '-' }}{{ row.department }}
{{ row.case_type }}
{{ row.financial_year or '-' }}
{{ row.assessment_year or '-' }}
{{ row.due_date or '-' }}{{ row.status|replace('_',' ')|title }}Open
No notice/case records found for the selected year/filter.
+
+
+{% endblock %} diff --git a/app/modules/notice_cases/ui.py b/app/modules/notice_cases/ui.py new file mode 100644 index 0000000..7db5bc1 --- /dev/null +++ b/app/modules/notice_cases/ui.py @@ -0,0 +1,469 @@ +from __future__ import annotations + +from urllib.parse import quote + +from fastapi import APIRouter, File, Form, Request, UploadFile +from fastapi.responses import RedirectResponse, StreamingResponse +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.core.rbac.deps import get_user_permissions, get_user_roles +from app.modules.core.rbac.permission_guard import require_permission +from app.modules.notice_cases.service import ( + CASE_DOCUMENT_TYPES, + CASE_PRIORITIES, + CASE_STATUSES, + CASE_TYPES, + DEPARTMENTS, + case_dashboard_summary, + EVENT_TYPES, + HEARING_STATUSES, + ORDER_TYPES, + add_event, + add_hearing, + add_order, + can_delete_notice_case_documents, + can_manage_notice_cases, + can_upload_notice_case_documents, + case_document_absolute_path, + create_case, + get_case, + list_assignable_users, + list_cases, + list_clients_for_case, + list_engagements_for_client, + save_case_document, + update_case, + user_can_access_case, +) +from app.modules.notice_cases.models import NoticeCaseDocument +from app.modules.core.tenancy.models import FinancialYear +from app.modules.core.tenancy.year_control import redirect_if_financial_year_locked, is_row_financial_year_locked + +router = APIRouter(prefix="/notice-cases", tags=["notice-cases-ui"]) + + +def _base_ctx(request: Request, user, db, **ctx): + base = { + "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), + "departments": DEPARTMENTS, + "case_types": CASE_TYPES, + "case_statuses": CASE_STATUSES, + "case_priorities": CASE_PRIORITIES, + "event_types": EVENT_TYPES, + "hearing_statuses": HEARING_STATUSES, + "order_types": ORDER_TYPES, + "case_document_types": CASE_DOCUMENT_TYPES, + } + base.update(ctx) + return base + + +def _render(request: Request, template_name: str, db, user, **ctx): + return templates.TemplateResponse(template_name, _base_ctx(request, user, db, **ctx)) + + +def _redirect_denied(): + return RedirectResponse(url="/system-settings", status_code=303) + + +def _require_user(request: Request, db, permission: str): + user = get_current_user(request, db=db) + if not user: + return None, RedirectResponse(url="/login", status_code=303) + try: + require_permission(db, user, permission) + except Exception: + return user, _redirect_denied() + return user, None + + +def _active_tenant_id(request: Request, user) -> int: + return int(request.session.get("active_tenant_id") or request.session.get("selected_tenant_id") or request.session.get("tenant_id") or user.tenant_id) + + +def _active_branch_id(request: Request, user, db) -> int | None: + value = request.session.get("active_branch_id") + perms = set(get_user_permissions(db, user.id)) + if value in (None, "", 0, "0"): + if "notice_cases.cross_branch" in perms or "notice_cases.cross_tenant" in perms: + return None + return int(getattr(user, "branch_id", 0) or 0) or None + return int(value) + + +def _active_financial_year(request: Request) -> str | None: + value = request.session.get("active_financial_year") or getattr(request.state, "year_code", None) + value = (value or "").strip() + return value or None + + +def _active_assessment_year(db, tenant_id: int, financial_year: str | None) -> str | None: + fy_code = (financial_year or "").strip() + if not fy_code: + return None + row = db.execute( + select(FinancialYear).where( + FinancialYear.tenant_id == tenant_id, + FinancialYear.year_code == fy_code, + ) + ).scalar_one_or_none() + return row.assessment_year if row else None + + +def _financial_year_options(db, tenant_id: int): + return list( + db.execute( + select(FinancialYear) + .where(FinancialYear.tenant_id == tenant_id) + .order_by(FinancialYear.start_date.desc(), FinancialYear.year_code.desc()) + ).scalars() + ) + + +def _selected_financial_year(request: Request, query_financial_year: str | None) -> str | None: + value = (query_financial_year or "").strip() + if value.lower() == "all": + return None + return value or _active_financial_year(request) + + +def _case_access_allowed(request: Request, db, user, case) -> bool: + return user_can_access_case( + db, + user, + case, + active_tenant_id=_active_tenant_id(request, user), + active_branch_id=_active_branch_id(request, user, db), + active_financial_year=_active_financial_year(request), + active_assessment_year=_active_assessment_year(db, _active_tenant_id(request, user), _active_financial_year(request)), + ) + + +def _form_data(**kwargs): + return {k: v for k, v in kwargs.items()} + + +@router.get("") +def cases_list( + request: Request, + q: str = "", + department: str = "", + status: str = "", + financial_year: str = "", + assessment_year: str = "", + include_archived: bool = False, +): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "notice_cases.view") + if response: + return response + tenant_id = _active_tenant_id(request, user) + branch_id = _active_branch_id(request, user, db) + selected_fy = _selected_financial_year(request, financial_year) + selected_ay = (assessment_year or "").strip() or None + if selected_fy: + selected_ay = None + rows = list_cases( + db, + tenant_id=tenant_id, + branch_id=branch_id, + q=q, + department=department, + status=status, + include_archived=include_archived, + financial_year=selected_fy, + assessment_year=selected_ay, + ) + return _render( + request, + "modules/notice_cases/templates/notice_cases/list.html", + db, + user, + title="Notices & Cases", + rows=rows, + summary=case_dashboard_summary(rows), + q=q, + department=department, + status=status, + financial_year=selected_fy or "all", + assessment_year=selected_ay or "", + active_financial_year=_active_financial_year(request), + financial_year_options=_financial_year_options(db, tenant_id), + include_archived=include_archived, + can_manage=can_manage_notice_cases(db, user), + ) + finally: + db.close() + + +@router.get("/new") +def case_create_page(request: Request, client_id: int | None = None): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "notice_cases.create") + if response: + return response + tenant_id = _active_tenant_id(request, user) + branch_id = _active_branch_id(request, user, db) + active_fy = _active_financial_year(request) + active_ay = _active_assessment_year(db, tenant_id, active_fy) + clients = list_clients_for_case(db, tenant_id=tenant_id, branch_id=branch_id) + engagements = list_engagements_for_client(db, tenant_id=tenant_id, client_id=client_id, financial_year=active_fy) if client_id else [] + users = list_assignable_users(db, tenant_id=tenant_id, branch_id=branch_id) + return _render(request, "modules/notice_cases/templates/notice_cases/form.html", db, user, title="Create Notice / Case", mode="create", case=None, clients=clients, engagements=engagements, assignable_users=users, selected_client_id=client_id, form_error="", active_financial_year=active_fy, active_assessment_year=active_ay) + finally: + db.close() + + +@router.post("/new") +def case_create_submit( + request: Request, + client_id: int = Form(...), + engagement_id: str = Form(""), + department: str = Form("GST"), + case_type: str = Form("Notice"), + title: str = Form(""), + reference_no: str = Form(""), + din_ack_no: str = Form(""), + notice_date: str = Form(""), + due_date: str = Form(""), + financial_year: str = Form(""), + assessment_year: str = Form(""), + period_label: str = Form(""), + status: str = Form("open"), + priority: str = Form("normal"), + assigned_partner_user_id: str = Form(""), + assigned_manager_user_id: str = Form(""), + assigned_staff_user_id: str = Form(""), + issue_summary: str = Form(""), + remarks: str = Form(""), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "notice_cases.create") + if response: + return response + tenant_id = _active_tenant_id(request, user) + branch_id = _active_branch_id(request, user, db) + active_fy = _active_financial_year(request) + active_ay = _active_assessment_year(db, tenant_id, active_fy) + locked_response = redirect_if_financial_year_locked(db, tenant_id=tenant_id, year_code=active_fy, redirect_url=f"/notice-cases?financial_year={active_fy or ''}") + if locked_response: + return locked_response + data = locals().copy(); data.pop("request", None); data.pop("db", None); data.pop("user", None); data.pop("response", None); data.pop("csrf_token", None); data.pop("tenant_id", None); data.pop("branch_id", None) + data["active_financial_year"] = active_fy + data["active_assessment_year"] = active_ay + try: + row = create_case(db, tenant_id=tenant_id, branch_id=branch_id, user=user, data=data) + db.commit() + return RedirectResponse(url=f"/notice-cases/{row.id}?created=1", status_code=303) + except Exception as exc: + db.rollback() + clients = list_clients_for_case(db, tenant_id=tenant_id, branch_id=branch_id) + engagements = list_engagements_for_client(db, tenant_id=tenant_id, client_id=client_id, financial_year=active_fy) if client_id else [] + users = list_assignable_users(db, tenant_id=tenant_id, branch_id=branch_id) + return _render(request, "modules/notice_cases/templates/notice_cases/form.html", db, user, title="Create Notice / Case", mode="create", case=data, clients=clients, engagements=engagements, assignable_users=users, selected_client_id=client_id, form_error=str(exc), active_financial_year=active_fy, active_assessment_year=active_ay) + finally: + db.close() + + +@router.get("/{case_id}") +def case_detail(request: Request, case_id: int): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "notice_cases.view") + if response: + return response + case = get_case(db, case_id) + if not case or not _case_access_allowed(request, db, user, case): + return _redirect_denied() + return _render(request, "modules/notice_cases/templates/notice_cases/detail.html", db, user, title=case.case_code, case=case, can_manage=can_manage_notice_cases(db, user), can_upload=can_upload_notice_case_documents(db, user), can_delete_documents=can_delete_notice_case_documents(db, user)) + finally: + db.close() + + +@router.get("/{case_id}/edit") +def case_edit_page(request: Request, case_id: int): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "notice_cases.edit") + if response: + return response + case = get_case(db, case_id) + if not case or not _case_access_allowed(request, db, user, case): + return _redirect_denied() + clients = [case.client] + active_fy = _active_financial_year(request) + active_ay = _active_assessment_year(db, case.tenant_id, active_fy) + engagements = list_engagements_for_client(db, tenant_id=case.tenant_id, client_id=case.client_id, financial_year=active_fy) + users = list_assignable_users(db, tenant_id=case.tenant_id, branch_id=case.branch_id) + return _render(request, "modules/notice_cases/templates/notice_cases/form.html", db, user, title="Edit Notice / Case", mode="edit", case=case, clients=clients, engagements=engagements, assignable_users=users, selected_client_id=case.client_id, form_error="", active_financial_year=active_fy, active_assessment_year=active_ay) + finally: + db.close() + + +@router.post("/{case_id}/edit") +def case_edit_submit(request: Request, case_id: int, department: str = Form("GST"), case_type: str = Form("Notice"), title: str = Form(""), reference_no: str = Form(""), din_ack_no: str = Form(""), notice_date: str = Form(""), due_date: str = Form(""), financial_year: str = Form(""), assessment_year: str = Form(""), period_label: str = Form(""), status: str = Form("open"), priority: str = Form("normal"), assigned_partner_user_id: str = Form(""), assigned_manager_user_id: str = Form(""), assigned_staff_user_id: str = Form(""), issue_summary: str = Form(""), remarks: str = Form(""), csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "notice_cases.edit") + if response: + return response + case = get_case(db, case_id) + if not case or not _case_access_allowed(request, db, user, case): + return _redirect_denied() + if is_row_financial_year_locked(db, case): + return RedirectResponse(url=f"/notice-cases/{case.id}?year_locked=1", status_code=303) + active_fy = _active_financial_year(request) + active_ay = _active_assessment_year(db, case.tenant_id, active_fy) + data = locals().copy(); [data.pop(k, None) for k in ["request","db","user","response","case","csrf_token","case_id"]] + data["active_financial_year"] = active_fy + data["active_assessment_year"] = active_ay + try: + update_case(db, case=case, user=user, data=data) + db.commit() + return RedirectResponse(url=f"/notice-cases/{case.id}?updated=1", status_code=303) + except Exception as exc: + db.rollback() + clients = [case.client] + engagements = list_engagements_for_client(db, tenant_id=case.tenant_id, client_id=case.client_id, financial_year=active_fy) + users = list_assignable_users(db, tenant_id=case.tenant_id, branch_id=case.branch_id) + return _render(request, "modules/notice_cases/templates/notice_cases/form.html", db, user, title="Edit Notice / Case", mode="edit", case=case, clients=clients, engagements=engagements, assignable_users=users, selected_client_id=case.client_id, form_error=str(exc), active_financial_year=active_fy, active_assessment_year=active_ay) + finally: + db.close() + + +@router.post("/{case_id}/events") +def case_add_event(request: Request, case_id: int, event_type: str = Form("Internal Note"), event_date: str = Form(""), description: str = Form(""), next_due_date: str = Form(""), csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "notice_cases.events.manage") + if response: + return response + case = get_case(db, case_id) + if not case or not _case_access_allowed(request, db, user, case): + return _redirect_denied() + if is_row_financial_year_locked(db, case): + return RedirectResponse(url=f"/notice-cases/{case.id}?year_locked=1", status_code=303) + add_event(db, case=case, user=user, data=locals()) + db.commit() + return RedirectResponse(url=f"/notice-cases/{case_id}?event_added=1", status_code=303) + finally: + db.close() + + +@router.post("/{case_id}/hearings") +def case_add_hearing(request: Request, case_id: int, hearing_date: str = Form(""), hearing_time: str = Form(""), venue_or_mode: str = Form(""), officer_name: str = Form(""), agenda: str = Form(""), outcome: str = Form(""), status: str = Form("scheduled"), csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "notice_cases.hearings.manage") + if response: + return response + case = get_case(db, case_id) + if not case or not _case_access_allowed(request, db, user, case): + return _redirect_denied() + if is_row_financial_year_locked(db, case): + return RedirectResponse(url=f"/notice-cases/{case.id}?year_locked=1", status_code=303) + add_hearing(db, case=case, user=user, data=locals()) + db.commit() + return RedirectResponse(url=f"/notice-cases/{case_id}?hearing_added=1", status_code=303) + finally: + db.close() + + +@router.post("/{case_id}/orders") +def case_add_order(request: Request, case_id: int, order_type: str = Form("Other"), order_no: str = Form(""), order_date: str = Form(""), demand_amount: str = Form("0"), tax_amount: str = Form("0"), interest_amount: str = Form("0"), penalty_amount: str = Form("0"), summary: str = Form(""), appeal_due_date: str = Form(""), appeal_filed: str | None = Form(None), csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "notice_cases.orders.manage") + if response: + return response + case = get_case(db, case_id) + if not case or not _case_access_allowed(request, db, user, case): + return _redirect_denied() + if is_row_financial_year_locked(db, case): + return RedirectResponse(url=f"/notice-cases/{case.id}?year_locked=1", status_code=303) + add_order(db, case=case, user=user, data=locals()) + db.commit() + return RedirectResponse(url=f"/notice-cases/{case_id}?order_added=1", status_code=303) + finally: + db.close() + + +@router.post("/{case_id}/documents/upload") +def case_document_upload(request: Request, case_id: int, title: str = Form(""), document_type: str = Form("OTHER"), description: str = Form(""), event_id: str = Form(""), file: UploadFile = File(...), csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "notice_cases.documents.upload") + if response: + return response + case = get_case(db, case_id) + if not case or not _case_access_allowed(request, db, user, case): + return _redirect_denied() + if is_row_financial_year_locked(db, case): + return RedirectResponse(url=f"/notice-cases/{case.id}?year_locked=1", status_code=303) + if not file or not file.filename: + return RedirectResponse(url=f"/notice-cases/{case_id}?error=missing_file", status_code=303) + save_case_document(db, case=case, upload_file=file, user=user, title=title, document_type=document_type, description=description, event_id=int(event_id) if event_id else None) + db.commit() + return RedirectResponse(url=f"/notice-cases/{case_id}?uploaded=1", status_code=303) + finally: + db.close() + + +@router.get("/documents/{document_id}/download") +def case_document_download(request: Request, document_id: int): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "notice_cases.documents.download") + if response: + return response + doc = db.execute(select(NoticeCaseDocument).where(NoticeCaseDocument.id == document_id, NoticeCaseDocument.is_deleted.is_(False))).scalar_one_or_none() + case = get_case(db, doc.case_id) if doc else None + if not doc or not case or not _case_access_allowed(request, db, user, case): + return _redirect_denied() + path = case_document_absolute_path(doc) + if not path.exists(): + return RedirectResponse(url=f"/notice-cases/{doc.case_id}?error=file_missing", status_code=303) + quoted = quote(doc.original_filename) + headers = {"Content-Disposition": f"attachment; filename*=UTF-8''{quoted}"} + return StreamingResponse(path.open("rb"), media_type=doc.content_type or "application/octet-stream", headers=headers) + finally: + db.close() + + +@router.post("/documents/{document_id}/delete") +def case_document_delete(request: Request, document_id: int, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "notice_cases.documents.delete") + if response: + return response + doc = db.execute(select(NoticeCaseDocument).where(NoticeCaseDocument.id == document_id, NoticeCaseDocument.is_deleted.is_(False))).scalar_one_or_none() + case = get_case(db, doc.case_id) if doc else None + if not doc or not case or not _case_access_allowed(request, db, user, case): + return _redirect_denied() + doc.is_deleted = True + doc.deleted_at_utc = __import__('datetime').datetime.now(__import__('datetime').timezone.utc) + doc.deleted_by_user_id = user.id + db.commit() + return RedirectResponse(url=f"/notice-cases/{doc.case_id}?deleted=1", status_code=303) + finally: + db.close() diff --git a/app/modules/notifications/__init__.py b/app/modules/notifications/__init__.py new file mode 100644 index 0000000..87983df --- /dev/null +++ b/app/modules/notifications/__init__.py @@ -0,0 +1 @@ +"""Notification and escalation automation package for Phase 7O.""" diff --git a/app/modules/notifications/automation.py b/app/modules/notifications/automation.py new file mode 100644 index 0000000..438e54f --- /dev/null +++ b/app/modules/notifications/automation.py @@ -0,0 +1,416 @@ +from __future__ import annotations + +import logging +import os +import threading +import time +from dataclasses import dataclass +from datetime import date, datetime, time as dt_time, timedelta, timezone + +from sqlalchemy import and_, func, or_, select +from sqlalchemy.orm import Session + +from app.core.db.common import CommonEngine, CommonSessionLocal +from app.modules.alerts.models import UserAlert +from app.modules.alerts.service import create_alert +from app.modules.clients.models import Client +from app.modules.employees.models import Employee, EmployeeAttendance +from app.modules.services.models import ClientServiceSubscription, ClientServiceTaskInstance + +logger = logging.getLogger("audit_firm.notifications") + +_COMPLETED_STATUSES = { + "completed", + "complete", + "done", + "approved", + "closed", + "filed", + "cancelled", + "inactive", +} + +_STARTED = False +_START_LOCK = threading.Lock() + + +@dataclass(slots=True) +class AutomationResult: + task_due_today: int = 0 + task_overdue_staff: int = 0 + task_overdue_manager: int = 0 + task_overdue_partner: int = 0 + engagement_due: int = 0 + engagement_overdue: int = 0 + attendance_missing_punchout: int = 0 + + @property + def total_alerts(self) -> int: + return ( + self.task_due_today + + self.task_overdue_staff + + self.task_overdue_manager + + self.task_overdue_partner + + self.engagement_due + + self.engagement_overdue + + self.attendance_missing_punchout + ) + + +def _table_exists(table_name: str) -> bool: + try: + return CommonEngine.dialect.has_table(CommonEngine.connect(), table_name) + except Exception: + return False + + +def _today_bounds_utc(now_utc: datetime) -> tuple[datetime, datetime]: + start = datetime.combine(now_utc.date(), dt_time.min, tzinfo=timezone.utc) + end = start + timedelta(days=1) + return start, end + + +def _normalize_status(value: str | None) -> str: + return (value or "").strip().lower().replace(" ", "_") + + +def _is_open_status(value: str | None) -> bool: + return _normalize_status(value) not in _COMPLETED_STATUSES + + +def _alert_exists_today( + db: Session, + *, + user_id: int, + alert_type: str, + title: str, + target_url: str | None, + today_start_utc: datetime, +) -> bool: + q = select(UserAlert.id).where( + UserAlert.user_id == user_id, + UserAlert.alert_type == alert_type, + UserAlert.title == title[:255], + UserAlert.created_at_utc >= today_start_utc, + ) + if target_url: + q = q.where(UserAlert.target_url == target_url) + else: + q = q.where(UserAlert.target_url.is_(None)) + return db.execute(q.limit(1)).scalar_one_or_none() is not None + + +def _create_alert_once( + db: Session, + *, + user_id: int | None, + title: str, + message: str | None, + tenant_id: int | None, + branch_id: int | None, + role_context: str | None, + alert_type: str, + priority: str, + target_url: str | None, + today_start_utc: datetime, +) -> bool: + if not user_id: + return False + if _alert_exists_today( + db, + user_id=int(user_id), + alert_type=alert_type, + title=title, + target_url=target_url, + today_start_utc=today_start_utc, + ): + return False + create_alert( + db, + user_id=int(user_id), + title=title, + message=message, + tenant_id=tenant_id, + branch_id=branch_id, + role_context=role_context, + alert_type=alert_type, + priority=priority, + target_url=target_url, + commit=False, + ) + return True + + +def _service_label(subscription: ClientServiceSubscription | None, client: Client | None = None) -> str: + if not subscription: + return "work item" + service_name = getattr(getattr(subscription, "catalogue", None), "service_name", None) + client_name = getattr(client or getattr(subscription, "client", None), "client_name", None) + period = getattr(subscription, "financial_year", None) or getattr(subscription, "assessment_year", None) + parts = [p for p in [service_name, client_name, period] if p] + return " - ".join(parts) if parts else f"Engagement #{subscription.id}" + + +def run_notification_escalation_once(*, db: Session | None = None, now: datetime | None = None) -> AutomationResult: + """Create daily alerts for due work, overdue work and basic escalations. + + This function is intentionally idempotent for the current UTC day. It checks + the existing user_alerts table before inserting, so repeated scheduler runs + do not flood users with duplicate alerts. + """ + + owns_session = db is None + session = db or CommonSessionLocal() + now_utc = now.astimezone(timezone.utc) if now else datetime.now(timezone.utc) + today = now_utc.date() + today_start_utc, _ = _today_bounds_utc(now_utc) + result = AutomationResult() + + try: + if not _table_exists("user_alerts"): + logger.warning("Phase 7O skipped: user_alerts table is not available. Apply Phase 7H first.") + return result + + # 1) Staff task due today and overdue alerts. + task_rows = session.execute( + select(ClientServiceTaskInstance, ClientServiceSubscription, Client) + .join(ClientServiceSubscription, ClientServiceTaskInstance.subscription_id == ClientServiceSubscription.id) + .join(Client, ClientServiceTaskInstance.client_id == Client.id) + .where( + ClientServiceTaskInstance.is_active.is_(True), + ClientServiceTaskInstance.assigned_to_user_id.is_not(None), + ClientServiceTaskInstance.internal_target_date.is_not(None), + ClientServiceTaskInstance.internal_target_date <= today, + ) + .order_by(ClientServiceTaskInstance.internal_target_date.asc(), ClientServiceTaskInstance.id.asc()) + ).all() + + for task, subscription, client in task_rows: + if not _is_open_status(task.status): + continue + target_url = f"/work/engagements/{subscription.id}" + label = _service_label(subscription, client) + task_due = task.internal_target_date + if task_due == today: + title = f"Task due today: {task.task_name}" + if _create_alert_once( + session, + user_id=task.assigned_to_user_id, + title=title, + message=f"{label} has a task due today. Please open the work details and update the task status.", + tenant_id=task.tenant_id, + branch_id=task.branch_id, + role_context="staff", + alert_type="task_due", + priority="high" if task.priority in {"high", "critical"} else "normal", + target_url=target_url, + today_start_utc=today_start_utc, + ): + result.task_due_today += 1 + continue + + overdue_days = max(1, (today - task_due).days) + title = f"Task overdue: {task.task_name}" + if _create_alert_once( + session, + user_id=task.assigned_to_user_id, + title=title, + message=f"{label} is overdue by {overdue_days} day(s). Please complete or update the task status.", + tenant_id=task.tenant_id, + branch_id=task.branch_id, + role_context="staff", + alert_type="task_overdue", + priority="critical" if overdue_days >= 3 else "high", + target_url=target_url, + today_start_utc=today_start_utc, + ): + result.task_overdue_staff += 1 + + if subscription.assigned_manager_user_id and overdue_days >= 1: + if _create_alert_once( + session, + user_id=subscription.assigned_manager_user_id, + title=f"Team task overdue: {task.task_name}", + message=f"{label} has an overdue task assigned to staff. Overdue by {overdue_days} day(s).", + tenant_id=task.tenant_id, + branch_id=task.branch_id, + role_context="manager", + alert_type="task_overdue", + priority="high", + target_url=target_url, + today_start_utc=today_start_utc, + ): + result.task_overdue_manager += 1 + + partner_user_id = subscription.assigned_partner_user_id or subscription.review_partner_user_id + if partner_user_id and overdue_days >= 3: + if _create_alert_once( + session, + user_id=partner_user_id, + title=f"Escalation: task overdue for {overdue_days} days", + message=f"{label} has a task pending beyond escalation threshold: {task.task_name}.", + tenant_id=task.tenant_id, + branch_id=task.branch_id, + role_context="partner", + alert_type="task_overdue", + priority="critical", + target_url=target_url, + today_start_utc=today_start_utc, + ): + result.task_overdue_partner += 1 + + # 2) Engagement/service due and overdue alerts for responsible users and client portal user. + sub_rows = session.execute( + select(ClientServiceSubscription, Client) + .join(Client, ClientServiceSubscription.client_id == Client.id) + .where( + ClientServiceSubscription.is_active.is_(True), + ClientServiceSubscription.current_due_date.is_not(None), + ClientServiceSubscription.current_due_date <= today + timedelta(days=2), + ) + .order_by(ClientServiceSubscription.current_due_date.asc(), ClientServiceSubscription.id.asc()) + ).all() + + for subscription, client in sub_rows: + if not _is_open_status(subscription.status): + continue + due_date = subscription.current_due_date + target_url = f"/work/engagements/{subscription.id}" + label = _service_label(subscription, client) + recipient_map = [ + (subscription.assigned_staff_user_id, "staff"), + (subscription.assigned_manager_user_id, "manager"), + (subscription.assigned_partner_user_id or subscription.review_partner_user_id, "partner"), + (getattr(client, "portal_user_id", None), "client"), + ] + seen: set[int] = set() + if due_date < today: + overdue_days = (today - due_date).days + title = f"Engagement overdue: {label}" + message = f"Due date was {due_date.isoformat()} and is overdue by {overdue_days} day(s)." + alert_type = "task_overdue" + priority = "critical" if overdue_days >= 3 else "high" + else: + days_left = (due_date - today).days + title = f"Due date approaching: {label}" + message = "Due today." if days_left == 0 else f"Due in {days_left} day(s), on {due_date.isoformat()}." + alert_type = "task_due" + priority = "high" if days_left == 0 else "normal" + for user_id, role_context in recipient_map: + if not user_id or int(user_id) in seen: + continue + seen.add(int(user_id)) + if _create_alert_once( + session, + user_id=user_id, + title=title, + message=message, + tenant_id=subscription.tenant_id, + branch_id=subscription.branch_id, + role_context=role_context, + alert_type=alert_type, + priority=priority, + target_url=target_url, + today_start_utc=today_start_utc, + ): + if due_date < today: + result.engagement_overdue += 1 + else: + result.engagement_due += 1 + + # 3) Missing punch-out reminders from yesterday. + yesterday = today - timedelta(days=1) + attendance_rows = session.execute( + select(EmployeeAttendance, Employee) + .join(Employee, EmployeeAttendance.employee_id == Employee.id) + .where( + EmployeeAttendance.attendance_date == yesterday, + EmployeeAttendance.punch_in_utc.is_not(None), + EmployeeAttendance.punch_out_utc.is_(None), + Employee.user_id.is_not(None), + ) + ).all() + for attendance, employee in attendance_rows: + title = "Attendance punch-out missing" + target_url = "/employee/attendance" + if _create_alert_once( + session, + user_id=employee.user_id, + title=title, + message=f"Punch-out is missing for {yesterday.isoformat()}. Please regularise or contact your manager.", + tenant_id=attendance.tenant_id, + branch_id=attendance.branch_id, + role_context="staff", + alert_type="attendance", + priority="normal", + target_url=target_url, + today_start_utc=today_start_utc, + ): + result.attendance_missing_punchout += 1 + if employee.reporting_manager_user_id: + if _create_alert_once( + session, + user_id=employee.reporting_manager_user_id, + title=f"Team attendance punch-out missing: {employee.full_name}", + message=f"{employee.full_name} has no punch-out for {yesterday.isoformat()}.", + tenant_id=attendance.tenant_id, + branch_id=attendance.branch_id, + role_context="manager", + alert_type="attendance", + priority="normal", + target_url="/employees/attendance", + today_start_utc=today_start_utc, + ): + result.attendance_missing_punchout += 1 + + session.commit() + logger.info("Phase 7O notification automation completed: %s alert(s) created", result.total_alerts) + return result + except Exception: + session.rollback() + logger.exception("Phase 7O notification automation failed") + return result + finally: + if owns_session: + session.close() + + +def _scheduler_loop(interval_seconds: int, initial_delay_seconds: int) -> None: + if initial_delay_seconds > 0: + time.sleep(initial_delay_seconds) + while True: + run_notification_escalation_once() + time.sleep(interval_seconds) + + +def start_notification_scheduler() -> None: + """Start the lightweight Phase 7O background scheduler once per process. + + Environment variables: + AF_ALERT_AUTOMATION_ENABLED=false disables the scheduler + AF_ALERT_AUTOMATION_INTERVAL_SECONDS=3600 controls repeat interval + AF_ALERT_AUTOMATION_INITIAL_DELAY_SECONDS=20 controls first run delay + """ + + global _STARTED + enabled = (os.getenv("AF_ALERT_AUTOMATION_ENABLED", "true") or "true").strip().lower() + if enabled in {"0", "false", "no", "off"}: + logger.info("Phase 7O notification automation disabled by environment setting") + return + + with _START_LOCK: + if _STARTED: + return + interval = int(os.getenv("AF_ALERT_AUTOMATION_INTERVAL_SECONDS", "3600") or "3600") + initial_delay = int(os.getenv("AF_ALERT_AUTOMATION_INITIAL_DELAY_SECONDS", "20") or "20") + interval = max(300, interval) + initial_delay = max(0, initial_delay) + thread = threading.Thread( + target=_scheduler_loop, + args=(interval, initial_delay), + name="phase7o-notification-escalation", + daemon=True, + ) + thread.start() + _STARTED = True + logger.info("Phase 7O notification automation started with interval=%s seconds", interval) diff --git a/app/modules/partners/__init__.py b/app/modules/partners/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/partners/templates/partners/_partner_tabs.html b/app/modules/partners/templates/partners/_partner_tabs.html new file mode 100644 index 0000000..5d9a71c --- /dev/null +++ b/app/modules/partners/templates/partners/_partner_tabs.html @@ -0,0 +1,12 @@ +{% set path = request.url.path %} + diff --git a/app/modules/partners/templates/partners/clients.html b/app/modules/partners/templates/partners/clients.html new file mode 100644 index 0000000..05cbc7a --- /dev/null +++ b/app/modules/partners/templates/partners/clients.html @@ -0,0 +1,32 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +{% include "modules/partners/templates/partners/_partner_tabs.html" %} + + +{% endblock %} diff --git a/app/modules/partners/templates/partners/dashboard.html b/app/modules/partners/templates/partners/dashboard.html new file mode 100644 index 0000000..f7df59f --- /dev/null +++ b/app/modules/partners/templates/partners/dashboard.html @@ -0,0 +1,88 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +{% include "modules/partners/templates/partners/_partner_tabs.html" %} + +
+
+
+
+

Partner Workspace

+

Review, control and escalation dashboard

+

Track assigned client engagements, pending reviews, blocked work, overdue items and documents requiring attention.

+ {% if current_user_roles and ('Manager' in current_user_roles or 'Branch Manager' in current_user_roles or 'Firm Admin' in current_user_roles) %} +

Role-aware view: this partner login also carries team/administration responsibility.

+ {% endif %} +
+
+ Open Review Board + {% if current_user_roles and ('Manager' in current_user_roles or 'Branch Manager' in current_user_roles) %}Team Workspace{% endif %} +
+
+
+ +
+
Tasks
{{ payload.summary.total }}
Visible portfolio
+
Pending Review
{{ payload.summary.pending_review }}
Needs partner action
+
Blocked
{{ payload.summary.blocked }}
Escalation
+
Overdue
{{ payload.summary.overdue }}
High risk
+
Clients
{{ payload.summary.clients }}
Assigned clients
+
Engagements
{{ payload.summary.engagements }}
Active portfolio
+
+ +
+
+
+

Attention Required

Items that need partner review, decision or intervention.

+ Open Review Board +
+
+ {% set shown = namespace(count=0) %} + {% for column in payload.columns %} + {% if column.code in ['pending_review', 'clarification_required', 'blocked'] %} + {% for task in column.tasks[:5] %} + {% set shown.count = shown.count + 1 %} + +
+
{{ task.task_name }}
{{ task.client_display }} · {{ task.engagement_label }}
+ {{ column.label }} +
+
Assigned to {{ task.assignee_display }}{% if task.internal_target_date %} · Target {{ task.internal_target_date }}{% endif %}
+
+ {% endfor %} + {% endif %} + {% endfor %} + {% if shown.count == 0 %}
No pending partner attention items found.
{% endif %} +
+
+ + +
+
+{% endblock %} diff --git a/app/modules/partners/templates/partners/engagement_detail.html b/app/modules/partners/templates/partners/engagement_detail.html new file mode 100644 index 0000000..9a928a2 --- /dev/null +++ b/app/modules/partners/templates/partners/engagement_detail.html @@ -0,0 +1,56 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +{% include "modules/partners/templates/partners/_partner_tabs.html" %} + +
+
+
+
+

Engagement Review

+

{{ engagement.client.client_name if engagement.client else 'Client' }}

+

{{ engagement.display_label }}{% if engagement.current_due_date %} · Due {{ engagement.current_due_date }}{% endif %}

+
+ +
+
+ +
+
+ {% for task in tasks %} +
+
+

{{ loop.index }}. {{ task.task_name }}

{{ task.description or 'No description.' }}

{{ task.status_label }}{{ task.priority_label }}Assigned: {{ task.assignee_display }}{% if task.is_overdue %}Overdue{% endif %}
+ Communication +
+
+ + + + +
+ {% if task.comments %} +
+ {% for comment in task.comments[:3] %} +
{{ comment.comment_type|replace('_',' ')|title }} · {{ comment.created_at_utc }}
{{ comment.message }}
+ {% endfor %} +
+ {% endif %} +
+ {% else %} +
No tasks found for this engagement.
+ {% endfor %} +
+ + +
+
+{% endblock %} diff --git a/app/modules/partners/templates/partners/review_board.html b/app/modules/partners/templates/partners/review_board.html new file mode 100644 index 0000000..3729f8c --- /dev/null +++ b/app/modules/partners/templates/partners/review_board.html @@ -0,0 +1,31 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +{% include "modules/partners/templates/partners/_partner_tabs.html" %} + +
+
+

Partner Review Board

Review completed work, blocked tasks, rework and in-progress engagement tasks.

+
+
+ +
+ {% for column in payload.columns %} +
+

{{ column.label }}

{{ column.hint }}

{{ column.count }}
+
+ {% for task in column.tasks %} +
+
{{ task.task_name }}
{{ task.client_display }}
{% if task.is_overdue %}Overdue{% endif %}
+
{{ task.engagement_label }}
+
{{ task.status_label }}{{ task.priority_label }}{{ task.assignee_display }}
+ Open Review Details +
+ {% else %} +
No items.
+ {% endfor %} +
+
+ {% endfor %} +
+
+{% endblock %} diff --git a/app/modules/partners/ui.py b/app/modules/partners/ui.py new file mode 100644 index 0000000..944e64c --- /dev/null +++ b/app/modules/partners/ui.py @@ -0,0 +1,408 @@ +from __future__ import annotations + +from collections import defaultdict +from datetime import date, datetime, timezone +from typing import Any + +from fastapi import APIRouter, Form, Request +from fastapi.responses import RedirectResponse +from sqlalchemy import or_, select +from sqlalchemy.orm import Session, selectinload + +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.core.rbac.deps import get_user_permissions, get_user_roles +from app.modules.documents.models import EngagementDocument +from app.modules.clients.models import Client +from app.modules.services.execution import CLOSED_TASK_STATUSES, TASK_PRIORITIES, TASK_STATUSES +from app.modules.services.models import ( + ClientServiceSubscription, + ClientServiceTaskInstance, + ServiceCatalogue, + ServiceTaskComment, +) + +router = APIRouter(prefix="/partner", tags=["partner-workspace-ui"]) + +REVIEW_ACTIONS = { + "approve": ("completed", "partner_review", "partner_review"), + "send_rework": ("pending", "partner_review_note", "partner_review"), + "clarification": ("blocked", "client_clarification", "internal"), +} + + +def _redirect_login(): + return RedirectResponse(url="/login", status_code=303) + + +def _redirect_denied(): + return RedirectResponse(url="/employee/dashboard", status_code=303) + + +def _is_partner_user(db: Session, current_user) -> bool: + roles = set(get_user_roles(db, current_user.id)) + return bool(roles.intersection({"Partner", "Firm Admin", "System Admin"})) + + +def _base_ctx(request: Request, db: Session, current_user, **ctx): + base = { + "request": request, + "current_user": current_user, + "current_user_roles": get_user_roles(db, current_user.id), + "current_user_permissions": get_user_permissions(db, current_user.id), + "csrf_token": get_or_create_csrf_token(request), + "task_statuses": TASK_STATUSES, + "task_priorities": TASK_PRIORITIES, + } + base.update(ctx) + return base + + +def _render(request: Request, template_name: str, db: Session, current_user, **ctx): + return templates.TemplateResponse(template_name, _base_ctx(request, db, current_user, **ctx)) + + +def _active_tenant_branch(request: Request, current_user, roles: set[str]) -> tuple[int, int | None]: + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + if "System Admin" not in roles: + tenant_id = current_user.tenant_id + if not roles.intersection({"System Admin", "Firm Admin"}): + branch_id = current_user.branch_id + if branch_id in (0, "0", "", None): + branch_id = None + return int(tenant_id), int(branch_id) if branch_id is not None else None + + + + +def _active_financial_year(request: Request) -> str | None: + value = request.session.get("active_financial_year") or getattr(request.state, "year_code", None) + value = (value or "").strip() + return value or None + +def _subscription_scope_filter(stmt, tenant_id: int, branch_id: int | None, current_user, roles: set[str], financial_year: str | None = None): + stmt = stmt.where(ClientServiceSubscription.tenant_id == tenant_id, ClientServiceSubscription.is_active.is_(True)) + if branch_id is not None: + stmt = stmt.where(ClientServiceSubscription.branch_id == branch_id) + if financial_year: + stmt = stmt.where(ClientServiceSubscription.financial_year == financial_year.strip()) + if not roles.intersection({"System Admin", "Firm Admin"}): + stmt = stmt.where( + or_( + ClientServiceSubscription.assigned_partner_user_id == current_user.id, + ClientServiceSubscription.review_partner_user_id == current_user.id, + ) + ) + return stmt + + +def _task_scope_filter(stmt, tenant_id: int, branch_id: int | None, current_user, roles: set[str], financial_year: str | None = None): + stmt = stmt.where(ClientServiceTaskInstance.tenant_id == tenant_id, ClientServiceTaskInstance.is_active.is_(True)) + if branch_id is not None: + stmt = stmt.where(ClientServiceTaskInstance.branch_id == branch_id) + if financial_year: + stmt = stmt.where(ClientServiceTaskInstance.financial_year == financial_year.strip()) + if not roles.intersection({"System Admin", "Firm Admin"}): + stmt = stmt.where( + ClientServiceTaskInstance.subscription.has( + or_( + ClientServiceSubscription.assigned_partner_user_id == current_user.id, + ClientServiceSubscription.review_partner_user_id == current_user.id, + ) + ) + ) + return stmt + + +def _task_status_label(task: ClientServiceTaskInstance) -> str: + return dict(TASK_STATUSES).get(getattr(task, "status", ""), (getattr(task, "status", "") or "-").replace("_", " ").title()) + + +def _task_priority_label(task: ClientServiceTaskInstance) -> str: + return dict(TASK_PRIORITIES).get(getattr(task, "priority", ""), (getattr(task, "priority", "") or "normal").replace("_", " ").title()) + + +def _engagement_label(subscription: ClientServiceSubscription | None, task: ClientServiceTaskInstance | None = None) -> str: + catalogue = getattr(subscription, "catalogue", None) if subscription else getattr(task, "catalogue", None) + service_name = getattr(catalogue, "service_name", None) or getattr(catalogue, "name", None) or "Engagement" + fy = getattr(subscription, "financial_year", None) or getattr(task, "financial_year", None) + return f"{service_name} · FY {fy}" if fy else str(service_name) + + +def _decorate_task(task: ClientServiceTaskInstance, today: date) -> ClientServiceTaskInstance: + client = getattr(task, "client", None) + assignee = getattr(task, "assigned_to", None) + subscription = getattr(task, "subscription", None) + target = getattr(task, "internal_target_date", None) + status = (task.status or "pending").strip().lower() + is_closed = status in CLOSED_TASK_STATUSES + task.status_label = _task_status_label(task) + task.priority_label = _task_priority_label(task) + task.client_display = getattr(client, "client_name", None) or "Unlinked Client" + task.client_code_display = getattr(client, "client_code", None) or "" + task.assignee_display = getattr(assignee, "full_name", None) or getattr(assignee, "email", None) or "Unassigned" + task.engagement_label = _engagement_label(subscription, task) + task.is_overdue = bool(target and target < today and not is_closed) + task.is_due_today = bool(target and target == today and not is_closed) + task.comment_count = len([c for c in getattr(task, "comments", []) if not getattr(c, "is_deleted", False)]) + return task + + +def _task_bucket(task: ClientServiceTaskInstance) -> str: + status = (task.status or "pending").strip().lower() + if status == "blocked": + return "clarification_required" + if status == "completed": + return "pending_review" + if status in CLOSED_TASK_STATUSES: + return "completed" + if status in {"pending", "rework", "rework_required"}: + return "rework_sent" + return "approved" + + +def build_partner_payload(db: Session, request: Request, current_user, *, q: str = "") -> dict[str, Any]: + roles = set(get_user_roles(db, current_user.id)) + tenant_id, branch_id = _active_tenant_branch(request, current_user, roles) + financial_year = _active_financial_year(request) + today = date.today() + + task_stmt = ( + select(ClientServiceTaskInstance) + .options( + selectinload(ClientServiceTaskInstance.client), + selectinload(ClientServiceTaskInstance.catalogue), + selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.catalogue), + selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_manager), + selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_staff), + selectinload(ClientServiceTaskInstance.assigned_to), + selectinload(ClientServiceTaskInstance.comments).selectinload(ServiceTaskComment.created_by), + ) + ) + task_stmt = _task_scope_filter(task_stmt, tenant_id, branch_id, current_user, roles, financial_year=financial_year) + if q.strip(): + like = f"%{q.strip()}%" + task_stmt = task_stmt.where( + or_( + ClientServiceTaskInstance.task_name.ilike(like), + ClientServiceTaskInstance.description.ilike(like), + ClientServiceTaskInstance.client.has(or_(Client.client_name.ilike(like), Client.client_code.ilike(like))), + ClientServiceTaskInstance.catalogue.has(or_(ServiceCatalogue.service_name.ilike(like), ServiceCatalogue.service_code.ilike(like))), + ) + ) + tasks = db.execute( + task_stmt.order_by( + ClientServiceTaskInstance.internal_target_date.is_(None), + ClientServiceTaskInstance.internal_target_date.asc(), + ClientServiceTaskInstance.priority.desc(), + ClientServiceTaskInstance.id.desc(), + ) + ).scalars().all() + + columns = [ + {"code": "pending_review", "label": "Pending Review", "hint": "Completed tasks waiting for partner review", "tasks": []}, + {"code": "clarification_required", "label": "Clarification Required", "hint": "Blocked tasks needing partner attention", "tasks": []}, + {"code": "rework_sent", "label": "Rework Sent", "hint": "Pending/reopened after review notes", "tasks": []}, + {"code": "approved", "label": "In Progress", "hint": "Work currently moving with the team", "tasks": []}, + {"code": "completed", "label": "Completed", "hint": "Closed tasks", "tasks": []}, + ] + lookup = {c["code"]: c for c in columns} + summary = {"total": len(tasks), "pending_review": 0, "blocked": 0, "overdue": 0, "due_today": 0, "completed": 0, "clients": set(), "engagements": set()} + + for task in tasks: + _decorate_task(task, today) + status = (task.status or "pending").lower() + if getattr(task, "client_id", None): + summary["clients"].add(task.client_id) + if getattr(task, "subscription_id", None): + summary["engagements"].add(task.subscription_id) + if status == "completed": + summary["pending_review"] += 1 + if status == "blocked": + summary["blocked"] += 1 + if getattr(task, "is_overdue", False): + summary["overdue"] += 1 + if getattr(task, "is_due_today", False): + summary["due_today"] += 1 + if status in CLOSED_TASK_STATUSES: + summary["completed"] += 1 + lookup[_task_bucket(task)]["tasks"].append(task) + + summary["clients"] = len(summary["clients"]) + summary["engagements"] = len(summary["engagements"]) + for col in columns: + col["count"] = len(col["tasks"]) + + engagement_stmt = ( + select(ClientServiceSubscription) + .options( + selectinload(ClientServiceSubscription.client), + selectinload(ClientServiceSubscription.catalogue), + selectinload(ClientServiceSubscription.assigned_manager), + selectinload(ClientServiceSubscription.assigned_staff), + ) + ) + engagement_stmt = _subscription_scope_filter(engagement_stmt, tenant_id, branch_id, current_user, roles, financial_year=financial_year) + engagements = db.execute( + engagement_stmt.order_by(ClientServiceSubscription.current_due_date.is_(None), ClientServiceSubscription.current_due_date.asc(), ClientServiceSubscription.id.desc()).limit(25) + ).scalars().all() + + task_counts_by_subscription: dict[int, dict[str, int]] = defaultdict(lambda: {"total": 0, "completed": 0, "open": 0}) + for task in tasks: + bucket = task_counts_by_subscription[int(task.subscription_id)] + bucket["total"] += 1 + if (task.status or "").lower() in CLOSED_TASK_STATUSES: + bucket["completed"] += 1 + else: + bucket["open"] += 1 + + for engagement in engagements: + engagement.display_label = _engagement_label(engagement) + engagement.client_display = getattr(getattr(engagement, "client", None), "client_name", None) or "Unlinked Client" + engagement.task_counts = task_counts_by_subscription.get(int(engagement.id), {"total": 0, "completed": 0, "open": 0}) + due = getattr(engagement, "current_due_date", None) + engagement.is_overdue = bool(due and due < today and (engagement.status or "").lower() not in {"completed", "closed", "locked"}) + + return {"summary": summary, "columns": columns, "engagements": engagements, "q": q, "today": today, "financial_year": financial_year} + + +def _get_partner_task_or_redirect(db: Session, request: Request, current_user, task_id: int) -> ClientServiceTaskInstance | None: + roles = set(get_user_roles(db, current_user.id)) + tenant_id, branch_id = _active_tenant_branch(request, current_user, roles) + financial_year = _active_financial_year(request) + stmt = select(ClientServiceTaskInstance).options(selectinload(ClientServiceTaskInstance.subscription)).where(ClientServiceTaskInstance.id == int(task_id)) + stmt = _task_scope_filter(stmt, tenant_id, branch_id, current_user, roles, financial_year=financial_year) + return db.execute(stmt).scalar_one_or_none() + + +@router.get("/dashboard") +def partner_dashboard(request: Request, q: str = ""): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + if not _is_partner_user(db, current_user): + return _redirect_denied() + payload = build_partner_payload(db, request, current_user, q=q) + return _render(request, "modules/partners/templates/partners/dashboard.html", db, current_user, title="Partner Workspace", payload=payload, q=q, errors=[]) + finally: + db.close() + + +@router.get("/reviews") +def partner_review_board(request: Request, q: str = ""): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + if not _is_partner_user(db, current_user): + return _redirect_denied() + payload = build_partner_payload(db, request, current_user, q=q) + return _render(request, "modules/partners/templates/partners/review_board.html", db, current_user, title="Partner Review Board", payload=payload, q=q, errors=[]) + finally: + db.close() + + +@router.get("/clients") +def partner_clients(request: Request, q: str = ""): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + if not _is_partner_user(db, current_user): + return _redirect_denied() + payload = build_partner_payload(db, request, current_user, q=q) + return _render(request, "modules/partners/templates/partners/clients.html", db, current_user, title="My Client Portfolio", payload=payload, q=q, errors=[]) + finally: + db.close() + + +@router.get("/engagements/{engagement_id}") +def partner_engagement_detail(request: Request, engagement_id: int): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + if not _is_partner_user(db, current_user): + return _redirect_denied() + roles = set(get_user_roles(db, current_user.id)) + tenant_id, branch_id = _active_tenant_branch(request, current_user, roles) + financial_year = _active_financial_year(request) + engagement_stmt = select(ClientServiceSubscription).options( + selectinload(ClientServiceSubscription.client), + selectinload(ClientServiceSubscription.catalogue), + selectinload(ClientServiceSubscription.assigned_manager), + selectinload(ClientServiceSubscription.assigned_staff), + ).where(ClientServiceSubscription.id == int(engagement_id)) + engagement_stmt = _subscription_scope_filter(engagement_stmt, tenant_id, branch_id, current_user, roles, financial_year=financial_year) + engagement = db.execute(engagement_stmt).scalar_one_or_none() + if not engagement: + return _redirect_denied() + + task_stmt = select(ClientServiceTaskInstance).options( + selectinload(ClientServiceTaskInstance.client), + selectinload(ClientServiceTaskInstance.catalogue), + selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.catalogue), + selectinload(ClientServiceTaskInstance.assigned_to), + selectinload(ClientServiceTaskInstance.comments).selectinload(ServiceTaskComment.created_by), + ).where(ClientServiceTaskInstance.subscription_id == engagement.id, ClientServiceTaskInstance.is_active.is_(True)) + tasks = db.execute(task_stmt.order_by(ClientServiceTaskInstance.sequence_no.asc(), ClientServiceTaskInstance.id.asc())).scalars().all() + today = date.today() + for task in tasks: + _decorate_task(task, today) + documents = db.execute( + select(EngagementDocument) + .where(EngagementDocument.engagement_id == engagement.id, EngagementDocument.is_deleted.is_(False)) + .order_by(EngagementDocument.updated_at_utc.desc(), EngagementDocument.id.desc()) + ).scalars().all() + engagement.display_label = _engagement_label(engagement) + return _render(request, "modules/partners/templates/partners/engagement_detail.html", db, current_user, title="Partner Engagement Review", engagement=engagement, tasks=tasks, documents=documents, errors=[], financial_year=financial_year) + finally: + db.close() + + +@router.post("/tasks/{task_id}/review") +def partner_review_task(request: Request, task_id: int, action: str = Form(...), message: str = Form(""), csrf_token: str = Form(...)): + db = CommonSessionLocal() + try: + validate_csrf(request, csrf_token) + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + if not _is_partner_user(db, current_user): + return _redirect_denied() + task = _get_partner_task_or_redirect(db, request, current_user, task_id) + if not task: + return _redirect_denied() + new_status, comment_type, visibility = REVIEW_ACTIONS.get(action, REVIEW_ACTIONS["approve"]) + task.status = new_status + task.updated_by_user_id = current_user.id + task.updated_at_utc = datetime.now(timezone.utc) + note = (message or "").strip() + if not note: + note = { + "approve": "Approved by partner.", + "send_rework": "Sent back for rework by partner.", + "clarification": "Clarification requested by partner.", + }.get(action, "Partner review updated.") + db.add(ServiceTaskComment( + tenant_id=task.tenant_id, + branch_id=task.branch_id, + subscription_id=task.subscription_id, + task_instance_id=task.id, + comment_type=comment_type, + visibility=visibility, + message=note, + created_by_user_id=current_user.id, + )) + db.commit() + return RedirectResponse(url=f"/partner/engagements/{task.subscription_id}", status_code=303) + finally: + db.close() diff --git a/app/modules/platform_billing/__init__.py b/app/modules/platform_billing/__init__.py new file mode 100644 index 0000000..1010309 --- /dev/null +++ b/app/modules/platform_billing/__init__.py @@ -0,0 +1 @@ +"""Platform/SaaS billing module.""" diff --git a/app/modules/platform_billing/models.py b/app/modules/platform_billing/models.py new file mode 100644 index 0000000..407d4c3 --- /dev/null +++ b/app/modules/platform_billing/models.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +from datetime import date, datetime, timezone +from decimal import Decimal + +from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, Numeric, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.db.common import CommonBase + + +class PlatformPlan(CommonBase): + __tablename__ = "platform_plans" + __table_args__ = (UniqueConstraint("code", name="uq_platform_plans_code"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + code: Mapped[str] = mapped_column(String(80), nullable=False, index=True) + name: Mapped[str] = mapped_column(String(200), nullable=False) + target_account_type: Mapped[str] = mapped_column(String(30), nullable=False, default="AUDIT_FIRM", index=True) + billing_cycle: Mapped[str] = mapped_column(String(20), nullable=False, default="Monthly", index=True) + base_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00")) + gst_rate: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=Decimal("18.00")) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=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) + + features = relationship("PlatformPlanFeature", back_populates="plan", cascade="all, delete-orphan", order_by="PlatformPlanFeature.sort_order.asc()") + subscriptions = relationship("PlatformSubscription", back_populates="plan") + + +class PlatformPlanFeature(CommonBase): + __tablename__ = "platform_plan_features" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + plan_id: Mapped[int] = mapped_column(ForeignKey("platform_plans.id", ondelete="CASCADE"), nullable=False, index=True) + feature_code: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + feature_name: Mapped[str] = mapped_column(String(200), nullable=False) + limit_value: Mapped[str | None] = mapped_column(String(100), nullable=True) + is_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + + plan = relationship("PlatformPlan", back_populates="features") + + +class PlatformBillingAccount(CommonBase): + __tablename__ = "platform_billing_accounts" + __table_args__ = (UniqueConstraint("account_type", "account_code", name="uq_platform_billing_accounts_type_code"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + account_type: Mapped[str] = mapped_column(String(30), nullable=False, index=True) # AUDIT_FIRM|CLIENT|CONSULTANT|MARKETPLACE_CUSTOMER + account_code: Mapped[str] = mapped_column(String(80), nullable=False, index=True) + display_name: Mapped[str] = mapped_column(String(220), nullable=False, index=True) + + tenant_id: Mapped[int | None] = mapped_column(ForeignKey("tenants.id", ondelete="SET NULL"), nullable=True, index=True) + client_id: Mapped[int | None] = mapped_column(ForeignKey("clients.id", ondelete="SET NULL"), nullable=True, index=True) + consultant_id: Mapped[int | None] = mapped_column(ForeignKey("consultant_profiles.id", ondelete="SET NULL"), nullable=True, index=True) + + email: Mapped[str | None] = mapped_column(String(255), nullable=True) + mobile: Mapped[str | None] = mapped_column(String(20), nullable=True) + gstin: Mapped[str | None] = mapped_column(String(20), nullable=True) + pan: Mapped[str | None] = mapped_column(String(20), nullable=True) + billing_address: Mapped[str | None] = mapped_column(Text, nullable=True) + state: Mapped[str | None] = mapped_column(String(100), nullable=True) + status: Mapped[str] = mapped_column(String(20), nullable=False, default="ACTIVE", index=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=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) + + subscriptions = relationship("PlatformSubscription", back_populates="account") + invoices = relationship("PlatformInvoice", back_populates="account") + + +class PlatformSubscription(CommonBase): + __tablename__ = "platform_subscriptions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + account_id: Mapped[int] = mapped_column(ForeignKey("platform_billing_accounts.id", ondelete="CASCADE"), nullable=False, index=True) + plan_id: Mapped[int] = mapped_column(ForeignKey("platform_plans.id", ondelete="RESTRICT"), nullable=False, index=True) + subscription_code: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + start_date: Mapped[date] = mapped_column(Date, nullable=False, default=date.today) + end_date: Mapped[date | None] = mapped_column(Date, nullable=True) + billing_cycle: Mapped[str] = mapped_column(String(20), nullable=False, default="Monthly", index=True) + amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00")) + gst_rate: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=Decimal("18.00")) + status: Mapped[str] = mapped_column(String(20), nullable=False, default="ACTIVE", index=True) + auto_generate_invoice: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + created_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) + + account = relationship("PlatformBillingAccount", back_populates="subscriptions") + plan = relationship("PlatformPlan", back_populates="subscriptions") + + +class PlatformInvoice(CommonBase): + __tablename__ = "platform_invoices" + __table_args__ = (UniqueConstraint("invoice_no", name="uq_platform_invoices_invoice_no"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + account_id: Mapped[int] = mapped_column(ForeignKey("platform_billing_accounts.id", ondelete="RESTRICT"), nullable=False, index=True) + subscription_id: Mapped[int | None] = mapped_column(ForeignKey("platform_subscriptions.id", ondelete="SET NULL"), nullable=True, index=True) + invoice_no: Mapped[str] = mapped_column(String(80), nullable=False, index=True) + invoice_date: Mapped[date] = mapped_column(Date, nullable=False, default=date.today, index=True) + due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True) + billing_period_from: Mapped[date | None] = mapped_column(Date, nullable=True) + billing_period_to: Mapped[date | None] = mapped_column(Date, nullable=True) + tax_type: Mapped[str] = mapped_column(String(20), nullable=False, default="CGST_SGST") + subtotal: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00")) + discount_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00")) + taxable_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00")) + cgst_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00")) + sgst_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00")) + igst_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00")) + total_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00")) + status: Mapped[str] = mapped_column(String(20), nullable=False, default="DRAFT", index=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) + posted_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) + posted_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) + updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False) + + account = relationship("PlatformBillingAccount", back_populates="invoices") + subscription = relationship("PlatformSubscription") + lines = relationship("PlatformInvoiceLine", back_populates="invoice", cascade="all, delete-orphan", order_by="PlatformInvoiceLine.sort_order.asc()") + + +class PlatformInvoiceLine(CommonBase): + __tablename__ = "platform_invoice_lines" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + invoice_id: Mapped[int] = mapped_column(ForeignKey("platform_invoices.id", ondelete="CASCADE"), nullable=False, index=True) + charge_type: Mapped[str] = mapped_column(String(40), nullable=False, default="SUBSCRIPTION", index=True) + description: Mapped[str] = mapped_column(String(500), nullable=False) + reference_type: Mapped[str | None] = mapped_column(String(60), nullable=True) + reference_id: Mapped[int | None] = mapped_column(Integer, nullable=True) + quantity: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False, default=Decimal("1.00")) + rate: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00")) + discount_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00")) + taxable_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00")) + gst_rate: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=Decimal("18.00")) + cgst_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00")) + sgst_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00")) + igst_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00")) + line_total: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00")) + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + + invoice = relationship("PlatformInvoice", back_populates="lines") + + +class PlatformPayment(CommonBase): + __tablename__ = "platform_payments" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + invoice_id: Mapped[int] = mapped_column(ForeignKey("platform_invoices.id", ondelete="CASCADE"), nullable=False, index=True) + account_id: Mapped[int] = mapped_column(ForeignKey("platform_billing_accounts.id", ondelete="RESTRICT"), nullable=False, index=True) + payment_date: Mapped[date] = mapped_column(Date, nullable=False, default=date.today, index=True) + amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False) + mode: Mapped[str] = mapped_column(String(30), nullable=False, default="Bank") + reference_no: Mapped[str | None] = mapped_column(String(100), nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + created_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) diff --git a/app/modules/platform_billing/services.py b/app/modules/platform_billing/services.py new file mode 100644 index 0000000..0dc1631 --- /dev/null +++ b/app/modules/platform_billing/services.py @@ -0,0 +1,1066 @@ +from __future__ import annotations + +from datetime import date, datetime, timezone +from decimal import Decimal, ROUND_HALF_UP +from typing import Iterable + +from sqlalchemy import and_, func, or_, select +from sqlalchemy.orm import joinedload + +from app.modules.clients.models import Client +from app.modules.consultants.models import ConsultantManagedClient, ConsultantProfile +from app.modules.employees.models import Employee +from app.modules.core.tenancy.models import Branch, Tenant +from app.modules.platform_billing.models import ( + PlatformBillingAccount, + PlatformInvoice, + PlatformInvoiceLine, + PlatformPayment, + PlatformPlan, + PlatformPlanFeature, + PlatformSubscription, +) + +ACCOUNT_TYPES = ["AUDIT_FIRM", "CLIENT", "CONSULTANT", "MARKETPLACE_CUSTOMER"] +BILLING_CYCLES = ["Monthly", "Quarterly", "Yearly", "One-time"] +TAX_TYPES = ["CGST_SGST", "IGST", "NO_GST"] +CHARGE_TYPES = [ + "SUBSCRIPTION", + "CLIENT_USAGE", + "CONSULTANT_USAGE", + "EMPLOYEE_USAGE", + "BRANCH_USAGE", + "MODULE_CHARGE", + "COMPLIANCE_DASHBOARD", + "CONSULTANT_PORTAL", + "MANAGED_CLIENT_USAGE", + "USER_ACCOUNT_USAGE", + "SERVICE_REQUEST_USAGE", + "GSTIN_USAGE", + "PAN_USAGE", + "COMPLIANCE_MODULE_USAGE", + "LEAD_FEE", + "LEAD_COMMISSION", + "AI_CREDITS", + "STORAGE", + "OTHER", +] + + +def money(value) -> Decimal: + if value in (None, ""): + return Decimal("0.00") + return Decimal(str(value)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + + +def parse_date(value: str | None) -> date | None: + if not value: + return None + return datetime.strptime(value, "%Y-%m-%d").date() + + +def _tax_split(taxable: Decimal, gst_rate: Decimal, tax_type: str) -> tuple[Decimal, Decimal, Decimal]: + taxable = money(taxable) + gst_rate = money(gst_rate) + if tax_type == "NO_GST" or gst_rate <= 0: + return money(0), money(0), money(0) + tax = money(taxable * gst_rate / Decimal("100")) + if tax_type == "IGST": + return money(0), money(0), tax + half = money(tax / Decimal("2")) + return half, money(tax - half), money(0) + + +def list_platform_plans(db, q: str = "") -> list[PlatformPlan]: + stmt = select(PlatformPlan).order_by(PlatformPlan.target_account_type.asc(), PlatformPlan.name.asc()) + if q: + like = f"%{q}%" + stmt = stmt.where(or_(PlatformPlan.code.ilike(like), PlatformPlan.name.ilike(like))) + return list(db.execute(stmt).scalars().all()) + + +def create_platform_plan(db, *, code: str, name: str, target_account_type: str, billing_cycle: str, base_amount, gst_rate, description: str | None, feature_text: str | None) -> PlatformPlan: + plan = PlatformPlan( + code=code.strip(), + name=name.strip(), + target_account_type=target_account_type, + billing_cycle=billing_cycle, + base_amount=money(base_amount), + gst_rate=money(gst_rate), + description=description or None, + is_active=True, + ) + db.add(plan) + db.flush() + for idx, raw in enumerate((feature_text or "").splitlines(), start=1): + raw = raw.strip() + if not raw: + continue + code_part = raw.upper().replace(" ", "_")[:80] + db.add(PlatformPlanFeature(plan_id=plan.id, feature_code=code_part, feature_name=raw, sort_order=idx)) + db.commit() + db.refresh(plan) + return plan + + +def list_platform_accounts(db, q: str = "", account_type: str = "") -> list[PlatformBillingAccount]: + stmt = select(PlatformBillingAccount).order_by(PlatformBillingAccount.account_type.asc(), PlatformBillingAccount.display_name.asc()) + if account_type: + stmt = stmt.where(PlatformBillingAccount.account_type == account_type) + if q: + like = f"%{q}%" + stmt = stmt.where(or_(PlatformBillingAccount.account_code.ilike(like), PlatformBillingAccount.display_name.ilike(like), PlatformBillingAccount.email.ilike(like))) + return list(db.execute(stmt).scalars().all()) + + +def list_reference_audit_firms(db) -> list[Tenant]: + return list(db.execute(select(Tenant).order_by(Tenant.name.asc())).scalars().all()) + + +def list_reference_clients(db) -> list[Client]: + return list(db.execute(select(Client).order_by(Client.client_name.asc())).scalars().all()) + + +def list_reference_consultants(db) -> list[ConsultantProfile]: + return list(db.execute(select(ConsultantProfile).order_by(ConsultantProfile.contact_person.asc())).scalars().all()) + + +def create_platform_account(db, *, account_type: str, account_code: str, display_name: str, tenant_id: int | None, client_id: int | None, consultant_id: int | None, email: str | None, mobile: str | None, gstin: str | None, pan: str | None, billing_address: str | None, state: str | None, notes: str | None, user_id: int | None) -> PlatformBillingAccount: + account = PlatformBillingAccount( + account_type=account_type, + account_code=account_code.strip(), + display_name=display_name.strip(), + tenant_id=tenant_id, + client_id=client_id, + consultant_id=consultant_id, + email=email or None, + mobile=mobile or None, + gstin=gstin or None, + pan=pan or None, + billing_address=billing_address or None, + state=state or None, + notes=notes or None, + created_by_user_id=user_id, + updated_by_user_id=user_id, + status="ACTIVE", + ) + db.add(account) + db.commit() + db.refresh(account) + return account + + +def list_platform_subscriptions(db, q: str = "") -> list[PlatformSubscription]: + stmt = select(PlatformSubscription).options(joinedload(PlatformSubscription.account), joinedload(PlatformSubscription.plan)).order_by(PlatformSubscription.id.desc()) + if q: + like = f"%{q}%" + stmt = stmt.join(PlatformBillingAccount).where(or_(PlatformSubscription.subscription_code.ilike(like), PlatformBillingAccount.display_name.ilike(like))) + return list(db.execute(stmt).scalars().all()) + + +def create_platform_subscription(db, *, account_id: int, plan_id: int, subscription_code: str, start_date: date, end_date: date | None, billing_cycle: str, amount, gst_rate, auto_generate_invoice: bool, notes: str | None, user_id: int | None) -> PlatformSubscription: + sub = PlatformSubscription( + account_id=account_id, + plan_id=plan_id, + subscription_code=subscription_code.strip(), + start_date=start_date, + end_date=end_date, + billing_cycle=billing_cycle, + amount=money(amount), + gst_rate=money(gst_rate), + auto_generate_invoice=auto_generate_invoice, + notes=notes or None, + status="ACTIVE", + created_by_user_id=user_id, + ) + db.add(sub) + db.commit() + db.refresh(sub) + return sub + + + +# ----------------------------------------------------------------------------- +# PB2 - Audit Firm Subscription Billing helpers +# ----------------------------------------------------------------------------- + +def list_audit_firm_accounts(db, q: str = "") -> list[PlatformBillingAccount]: + stmt = ( + select(PlatformBillingAccount) + .where(PlatformBillingAccount.account_type == "AUDIT_FIRM") + .order_by(PlatformBillingAccount.display_name.asc()) + ) + if q: + like = f"%{q}%" + stmt = stmt.where(or_(PlatformBillingAccount.account_code.ilike(like), PlatformBillingAccount.display_name.ilike(like), PlatformBillingAccount.email.ilike(like))) + return list(db.execute(stmt).scalars().all()) + + +def sync_audit_firm_billing_accounts(db, *, user_id: int | None = None) -> dict: + """Create/update platform billing accounts for every active Audit Firm. + + This only touches platform_billing_accounts with account_type=AUDIT_FIRM. + It does not alter tenants, firm billing invoices, or any firm-owned records. + """ + tenants = list(db.execute(select(Tenant).order_by(Tenant.name.asc())).scalars().all()) + created = 0 + updated = 0 + for tenant in tenants: + existing = db.execute( + select(PlatformBillingAccount).where( + or_( + and_(PlatformBillingAccount.account_type == "AUDIT_FIRM", PlatformBillingAccount.tenant_id == tenant.id), + and_(PlatformBillingAccount.account_type == "AUDIT_FIRM", PlatformBillingAccount.account_code == tenant.code), + ) + ) + ).scalars().first() + if existing: + changed = False + if existing.tenant_id != tenant.id: + existing.tenant_id = tenant.id + changed = True + if existing.account_code != tenant.code: + existing.account_code = tenant.code + changed = True + if existing.display_name != tenant.name: + existing.display_name = tenant.name + changed = True + if existing.status != "ACTIVE" and getattr(tenant, "is_active", True): + existing.status = "ACTIVE" + changed = True + if changed: + existing.updated_by_user_id = user_id + updated += 1 + continue + db.add( + PlatformBillingAccount( + account_type="AUDIT_FIRM", + account_code=tenant.code, + display_name=tenant.name, + tenant_id=tenant.id, + status="ACTIVE" if getattr(tenant, "is_active", True) else "INACTIVE", + created_by_user_id=user_id, + updated_by_user_id=user_id, + ) + ) + created += 1 + db.commit() + return {"created": created, "updated": updated, "total": len(tenants)} + + +def list_audit_firm_subscriptions(db, q: str = "") -> list[PlatformSubscription]: + stmt = ( + select(PlatformSubscription) + .join(PlatformBillingAccount, PlatformSubscription.account_id == PlatformBillingAccount.id) + .options(joinedload(PlatformSubscription.account), joinedload(PlatformSubscription.plan)) + .where(PlatformBillingAccount.account_type == "AUDIT_FIRM") + .order_by(PlatformBillingAccount.display_name.asc(), PlatformSubscription.id.desc()) + ) + if q: + like = f"%{q}%" + stmt = stmt.where(or_(PlatformSubscription.subscription_code.ilike(like), PlatformBillingAccount.display_name.ilike(like))) + return list(db.execute(stmt).scalars().all()) + + +def _count_scalar(db, stmt) -> int: + value = db.execute(stmt).scalar() + return int(value or 0) + + +def get_audit_firm_usage_counts(db, tenant_id: int | None) -> dict[str, int]: + if not tenant_id: + return {"clients": 0, "branches": 0, "employees": 0, "consultants": 0} + return { + "clients": _count_scalar(db, select(func.count()).select_from(Client).where(Client.tenant_id == tenant_id, Client.is_archived.is_(False), Client.is_active.is_(True))), + "branches": _count_scalar(db, select(func.count()).select_from(Branch).where(Branch.tenant_id == tenant_id, Branch.is_active.is_(True))), + "employees": _count_scalar(db, select(func.count()).select_from(Employee).where(Employee.tenant_id == tenant_id, Employee.is_active.is_(True))), + "consultants": _count_scalar(db, select(func.count()).select_from(ConsultantProfile).where(ConsultantProfile.tenant_id == tenant_id, ConsultantProfile.is_active.is_(True))), + } + + +def list_audit_firm_subscription_rows(db, q: str = "") -> list[dict]: + rows = [] + for sub in list_audit_firm_subscriptions(db, q=q): + rows.append({"subscription": sub, "usage": get_audit_firm_usage_counts(db, sub.account.tenant_id if sub.account else None)}) + return rows + + +def _platform_invoice_exists_for_subscription_period(db, *, subscription_id: int, period_from: date, period_to: date) -> PlatformInvoice | None: + return db.execute( + select(PlatformInvoice).where( + PlatformInvoice.subscription_id == subscription_id, + PlatformInvoice.billing_period_from == period_from, + PlatformInvoice.billing_period_to == period_to, + PlatformInvoice.status != "CANCELLED", + ) + ).scalars().first() + + +def _next_audit_firm_platform_invoice_no(db, *, subscription: PlatformSubscription, invoice_date: date) -> str: + base = f"PB/AF/{invoice_date.strftime('%Y%m')}/{subscription.id:05d}" + candidate = base + suffix = 1 + while db.execute(select(PlatformInvoice.id).where(PlatformInvoice.invoice_no == candidate)).scalar() is not None: + suffix += 1 + candidate = f"{base}-{suffix}" + return candidate + + +def generate_audit_firm_subscription_invoices( + db, + *, + subscription_ids: list[int], + period_from: date, + period_to: date, + invoice_date: date, + due_date: date | None, + tax_type: str, + client_rate, + employee_rate, + consultant_rate, + branch_rate, + include_zero_usage_lines: bool = False, + user_id: int | None = None, +) -> dict: + """Generate draft platform invoices for Audit Firm subscriptions. + + Duplicate prevention is based on subscription + billing period. Generated + invoices are kept as DRAFT for review/posting by System Admin. + """ + clean_ids = [int(x) for x in subscription_ids if str(x).strip()] + if not clean_ids: + return {"created": 0, "skipped": 0, "errors": ["No subscriptions selected."], "invoices": []} + + stmt = ( + select(PlatformSubscription) + .join(PlatformBillingAccount, PlatformSubscription.account_id == PlatformBillingAccount.id) + .options(joinedload(PlatformSubscription.account), joinedload(PlatformSubscription.plan)) + .where(PlatformSubscription.id.in_(clean_ids), PlatformBillingAccount.account_type == "AUDIT_FIRM") + ) + subscriptions = list(db.execute(stmt).scalars().all()) + created = 0 + skipped = 0 + errors: list[str] = [] + invoices: list[PlatformInvoice] = [] + rate_map = { + "CLIENT_USAGE": money(client_rate), + "EMPLOYEE_USAGE": money(employee_rate), + "CONSULTANT_USAGE": money(consultant_rate), + "BRANCH_USAGE": money(branch_rate), + } + + for sub in subscriptions: + account = sub.account + if not account or account.account_type != "AUDIT_FIRM" or not account.tenant_id: + skipped += 1 + errors.append(f"Skipped subscription {sub.subscription_code}: not linked to an Audit Firm account.") + continue + if sub.status != "ACTIVE" or not sub.auto_generate_invoice: + skipped += 1 + errors.append(f"Skipped {account.display_name}: subscription is not active/auto-generate enabled.") + continue + existing = _platform_invoice_exists_for_subscription_period(db, subscription_id=sub.id, period_from=period_from, period_to=period_to) + if existing: + skipped += 1 + errors.append(f"Skipped {account.display_name}: invoice already exists for this period ({existing.invoice_no}).") + continue + + usage = get_audit_firm_usage_counts(db, account.tenant_id) + line_items: list[dict] = [] + if money(sub.amount) > 0: + line_items.append({ + "charge_type": "SUBSCRIPTION", + "description": f"{sub.plan.name if sub.plan else 'Audit Firm Subscription'} - {period_from.strftime('%d-%m-%Y')} to {period_to.strftime('%d-%m-%Y')}", + "quantity": "1", + "rate": sub.amount, + "discount_amount": "0", + "gst_rate": sub.gst_rate, + "reference_type": "PLATFORM_SUBSCRIPTION", + "reference_id": sub.id, + }) + + usage_specs = [ + ("CLIENT_USAGE", "Client usage", usage["clients"]), + ("EMPLOYEE_USAGE", "Employee usage", usage["employees"]), + ("CONSULTANT_USAGE", "Consultant usage", usage["consultants"]), + ("BRANCH_USAGE", "Branch usage", usage["branches"]), + ] + for charge_type, label, count in usage_specs: + rate = rate_map[charge_type] + if rate > 0 and (count > 0 or include_zero_usage_lines): + line_items.append({ + "charge_type": charge_type, + "description": f"{label} for {account.display_name} ({period_from.strftime('%b %Y')})", + "quantity": str(count), + "rate": rate, + "discount_amount": "0", + "gst_rate": sub.gst_rate, + "reference_type": "AUDIT_FIRM", + "reference_id": account.tenant_id, + }) + + if not line_items: + skipped += 1 + errors.append(f"Skipped {account.display_name}: no billable line items.") + continue + + invoice = create_platform_invoice( + db, + account_id=account.id, + subscription_id=sub.id, + invoice_no=_next_audit_firm_platform_invoice_no(db, subscription=sub, invoice_date=invoice_date), + invoice_date=invoice_date, + due_date=due_date, + billing_period_from=period_from, + billing_period_to=period_to, + tax_type=tax_type, + line_items=line_items, + notes="Generated from Audit Firm subscription billing (PB2).", + user_id=user_id, + ) + created += 1 + invoices.append(invoice) + + return {"created": created, "skipped": skipped, "errors": errors, "invoices": invoices} + + + +# ----------------------------------------------------------------------------- +# PB3 - Client Compliance Dashboard Billing helpers +# ----------------------------------------------------------------------------- + +CLIENT_COMPLIANCE_FLAGS = [ + ("gst_applicable", "GST"), + ("income_tax_applicable", "Income Tax"), + ("tds_applicable", "TDS"), + ("roc_applicable", "ROC"), + ("audit_applicable", "Audit"), + ("pf_applicable", "PF"), + ("esi_applicable", "ESI"), + ("professional_tax_applicable", "Professional Tax"), + ("payroll_applicable", "Payroll"), + ("msme_applicable", "MSME"), + ("import_export_applicable", "Import/Export"), +] + + +def _client_account_code(client: Client) -> str: + return f"CL-{client.id:06d}" + + +def list_client_dashboard_accounts(db, q: str = "") -> list[PlatformBillingAccount]: + stmt = ( + select(PlatformBillingAccount) + .where(PlatformBillingAccount.account_type == "CLIENT") + .order_by(PlatformBillingAccount.display_name.asc()) + ) + if q: + like = f"%{q}%" + stmt = stmt.where(or_(PlatformBillingAccount.account_code.ilike(like), PlatformBillingAccount.display_name.ilike(like), PlatformBillingAccount.email.ilike(like))) + return list(db.execute(stmt).scalars().all()) + + +def sync_client_dashboard_billing_accounts(db, *, user_id: int | None = None) -> dict: + """Create/update platform billing accounts for active clients. + + This is for Platform/System Admin billing of client compliance dashboard + access. It only touches platform_billing_accounts with account_type=CLIENT. + It does not alter client master records or firm-level billing invoices. + """ + clients = list( + db.execute( + select(Client) + .where(Client.is_archived.is_(False), Client.is_active.is_(True)) + .order_by(Client.client_name.asc()) + ).scalars().all() + ) + created = 0 + updated = 0 + for client in clients: + code = _client_account_code(client) + display_name = client.client_name + address_parts = [client.address_line_1, client.address_line_2, client.city, client.state, client.pincode] + billing_address = ", ".join([part for part in address_parts if part]) or None + existing = db.execute( + select(PlatformBillingAccount).where( + or_( + and_(PlatformBillingAccount.account_type == "CLIENT", PlatformBillingAccount.client_id == client.id), + and_(PlatformBillingAccount.account_type == "CLIENT", PlatformBillingAccount.account_code == code), + ) + ) + ).scalars().first() + if existing: + changed = False + updates = { + "account_code": code, + "display_name": display_name, + "tenant_id": client.tenant_id, + "client_id": client.id, + "email": client.email, + "mobile": client.mobile, + "gstin": client.gstin, + "pan": client.pan, + "billing_address": billing_address, + "state": client.state, + "status": "ACTIVE", + } + for field, value in updates.items(): + if getattr(existing, field) != value: + setattr(existing, field, value) + changed = True + if changed: + existing.updated_by_user_id = user_id + updated += 1 + continue + db.add( + PlatformBillingAccount( + account_type="CLIENT", + account_code=code, + display_name=display_name, + tenant_id=client.tenant_id, + client_id=client.id, + email=client.email, + mobile=client.mobile, + gstin=client.gstin, + pan=client.pan, + billing_address=billing_address, + state=client.state, + status="ACTIVE", + created_by_user_id=user_id, + updated_by_user_id=user_id, + ) + ) + created += 1 + db.commit() + return {"created": created, "updated": updated, "total": len(clients)} + + +def list_client_dashboard_plans(db) -> list[PlatformPlan]: + return list( + db.execute( + select(PlatformPlan) + .where(PlatformPlan.target_account_type == "CLIENT", PlatformPlan.is_active.is_(True)) + .order_by(PlatformPlan.name.asc()) + ).scalars().all() + ) + + +def list_client_dashboard_subscriptions(db, q: str = "") -> list[PlatformSubscription]: + stmt = ( + select(PlatformSubscription) + .join(PlatformBillingAccount, PlatformSubscription.account_id == PlatformBillingAccount.id) + .options(joinedload(PlatformSubscription.account), joinedload(PlatformSubscription.plan)) + .where(PlatformBillingAccount.account_type == "CLIENT") + .order_by(PlatformBillingAccount.display_name.asc(), PlatformSubscription.id.desc()) + ) + if q: + like = f"%{q}%" + stmt = stmt.where(or_(PlatformSubscription.subscription_code.ilike(like), PlatformBillingAccount.display_name.ilike(like), PlatformBillingAccount.account_code.ilike(like))) + return list(db.execute(stmt).scalars().all()) + + +def get_client_dashboard_usage(db, client_id: int | None) -> dict: + if not client_id: + return {"pan_units": 0, "gstin_units": 0, "module_count": 0, "modules": []} + client = db.get(Client, client_id) + if not client: + return {"pan_units": 0, "gstin_units": 0, "module_count": 0, "modules": []} + modules = [label for attr, label in CLIENT_COMPLIANCE_FLAGS if bool(getattr(client, attr, False))] + return { + "pan_units": 1 if client.pan else 0, + "gstin_units": 1 if client.gstin else 0, + "module_count": len(modules), + "modules": modules, + } + + +def list_client_dashboard_subscription_rows(db, q: str = "") -> list[dict]: + rows = [] + for sub in list_client_dashboard_subscriptions(db, q=q): + rows.append({"subscription": sub, "usage": get_client_dashboard_usage(db, sub.account.client_id if sub.account else None)}) + return rows + + +def _next_client_dashboard_invoice_no(db, *, subscription: PlatformSubscription, invoice_date: date) -> str: + base = f"PB/CL/{invoice_date.strftime('%Y%m')}/{subscription.id:05d}" + candidate = base + suffix = 1 + while db.execute(select(PlatformInvoice.id).where(PlatformInvoice.invoice_no == candidate)).scalar() is not None: + suffix += 1 + candidate = f"{base}-{suffix}" + return candidate + + +def generate_client_dashboard_subscription_invoices( + db, + *, + subscription_ids: list[int], + period_from: date, + period_to: date, + invoice_date: date, + due_date: date | None, + tax_type: str, + pan_rate, + gstin_rate, + module_rate, + include_zero_usage_lines: bool = False, + user_id: int | None = None, +) -> dict: + """Generate draft platform invoices for client compliance dashboard subscriptions.""" + clean_ids = [int(x) for x in subscription_ids if str(x).strip()] + if not clean_ids: + return {"created": 0, "skipped": 0, "errors": ["No client dashboard subscriptions selected."], "invoices": []} + + stmt = ( + select(PlatformSubscription) + .join(PlatformBillingAccount, PlatformSubscription.account_id == PlatformBillingAccount.id) + .options(joinedload(PlatformSubscription.account), joinedload(PlatformSubscription.plan)) + .where(PlatformSubscription.id.in_(clean_ids), PlatformBillingAccount.account_type == "CLIENT") + ) + subscriptions = list(db.execute(stmt).scalars().all()) + created = 0 + skipped = 0 + errors: list[str] = [] + invoices: list[PlatformInvoice] = [] + pan_rate = money(pan_rate) + gstin_rate = money(gstin_rate) + module_rate = money(module_rate) + + for sub in subscriptions: + account = sub.account + if not account or account.account_type != "CLIENT" or not account.client_id: + skipped += 1 + errors.append(f"Skipped subscription {sub.subscription_code}: not linked to a client account.") + continue + if sub.status != "ACTIVE" or not sub.auto_generate_invoice: + skipped += 1 + errors.append(f"Skipped {account.display_name}: subscription is not active/auto-generate enabled.") + continue + existing = _platform_invoice_exists_for_subscription_period(db, subscription_id=sub.id, period_from=period_from, period_to=period_to) + if existing: + skipped += 1 + errors.append(f"Skipped {account.display_name}: invoice already exists for this period ({existing.invoice_no}).") + continue + + usage = get_client_dashboard_usage(db, account.client_id) + line_items: list[dict] = [] + if money(sub.amount) > 0: + line_items.append({ + "charge_type": "COMPLIANCE_DASHBOARD", + "description": f"{sub.plan.name if sub.plan else 'Client Compliance Dashboard'} - {period_from.strftime('%d-%m-%Y')} to {period_to.strftime('%d-%m-%Y')}", + "quantity": "1", + "rate": sub.amount, + "discount_amount": "0", + "gst_rate": sub.gst_rate, + "reference_type": "CLIENT_DASHBOARD_SUBSCRIPTION", + "reference_id": sub.id, + }) + + usage_specs = [ + ("PAN_USAGE", "PAN dashboard access", usage["pan_units"], pan_rate), + ("GSTIN_USAGE", "GSTIN dashboard access", usage["gstin_units"], gstin_rate), + ("COMPLIANCE_MODULE_USAGE", "Compliance modules", usage["module_count"], module_rate), + ] + for charge_type, label, quantity, rate in usage_specs: + if rate > 0 and (quantity > 0 or include_zero_usage_lines): + extra = "" + if charge_type == "COMPLIANCE_MODULE_USAGE" and usage["modules"]: + extra = f" ({', '.join(usage['modules'])})" + line_items.append({ + "charge_type": charge_type, + "description": f"{label}{extra} for {account.display_name} ({period_from.strftime('%b %Y')})", + "quantity": str(quantity), + "rate": rate, + "discount_amount": "0", + "gst_rate": sub.gst_rate, + "reference_type": "CLIENT", + "reference_id": account.client_id, + }) + + if not line_items: + skipped += 1 + errors.append(f"Skipped {account.display_name}: no billable line items.") + continue + + invoice = create_platform_invoice( + db, + account_id=account.id, + subscription_id=sub.id, + invoice_no=_next_client_dashboard_invoice_no(db, subscription=sub, invoice_date=invoice_date), + invoice_date=invoice_date, + due_date=due_date, + billing_period_from=period_from, + billing_period_to=period_to, + tax_type=tax_type, + line_items=line_items, + notes="Generated from Client Compliance Dashboard subscription billing (PB3).", + user_id=user_id, + ) + created += 1 + invoices.append(invoice) + + return {"created": created, "skipped": skipped, "errors": errors, "invoices": invoices} + + +# ----------------------------------------------------------------------------- +# PB4 - Consultant SaaS / Tool Access Billing helpers +# ----------------------------------------------------------------------------- + +def _consultant_account_code(consultant: ConsultantProfile) -> str: + return f"CON-{consultant.id:06d}" + + +def _consultant_display_name(consultant: ConsultantProfile) -> str: + if consultant.firm_name and consultant.contact_person: + return f"{consultant.firm_name} - {consultant.contact_person}" + return consultant.firm_name or consultant.contact_person or f"Consultant {consultant.id}" + + +def list_consultant_billing_accounts(db, q: str = "") -> list[PlatformBillingAccount]: + stmt = ( + select(PlatformBillingAccount) + .where(PlatformBillingAccount.account_type == "CONSULTANT") + .order_by(PlatformBillingAccount.display_name.asc()) + ) + if q: + like = f"%{q}%" + stmt = stmt.where(or_(PlatformBillingAccount.account_code.ilike(like), PlatformBillingAccount.display_name.ilike(like), PlatformBillingAccount.email.ilike(like))) + return list(db.execute(stmt).scalars().all()) + + +def sync_consultant_billing_accounts(db, *, user_id: int | None = None) -> dict: + """Create/update platform billing accounts for active consultants. + + This is for Platform/System Admin billing of consultant SaaS/tool access. + It only touches platform_billing_accounts with account_type=CONSULTANT. + It does not alter consultant profiles, consultant workspace data, firm billing, + or firm-owned client invoices. + """ + consultants = list( + db.execute( + select(ConsultantProfile) + .where(ConsultantProfile.is_active.is_(True)) + .order_by(ConsultantProfile.contact_person.asc()) + ).scalars().all() + ) + created = 0 + updated = 0 + for consultant in consultants: + code = _consultant_account_code(consultant) + display_name = _consultant_display_name(consultant) + existing = db.execute( + select(PlatformBillingAccount).where( + or_( + and_(PlatformBillingAccount.account_type == "CONSULTANT", PlatformBillingAccount.consultant_id == consultant.id), + and_(PlatformBillingAccount.account_type == "CONSULTANT", PlatformBillingAccount.account_code == code), + ) + ) + ).scalars().first() + updates = { + "account_code": code, + "display_name": display_name, + "tenant_id": consultant.tenant_id, + "consultant_id": consultant.id, + "email": consultant.email, + "mobile": consultant.mobile, + "gstin": consultant.gstin, + "pan": consultant.pan, + "billing_address": consultant.address, + "status": "ACTIVE", + } + if existing: + changed = False + for field, value in updates.items(): + if getattr(existing, field) != value: + setattr(existing, field, value) + changed = True + if changed: + existing.updated_by_user_id = user_id + updated += 1 + continue + db.add( + PlatformBillingAccount( + account_type="CONSULTANT", + created_by_user_id=user_id, + updated_by_user_id=user_id, + **updates, + ) + ) + created += 1 + db.commit() + return {"created": created, "updated": updated, "total": len(consultants)} + + +def list_consultant_billing_plans(db) -> list[PlatformPlan]: + return list( + db.execute( + select(PlatformPlan) + .where(PlatformPlan.target_account_type == "CONSULTANT", PlatformPlan.is_active.is_(True)) + .order_by(PlatformPlan.name.asc()) + ).scalars().all() + ) + + +def list_consultant_billing_subscriptions(db, q: str = "") -> list[PlatformSubscription]: + stmt = ( + select(PlatformSubscription) + .join(PlatformBillingAccount, PlatformSubscription.account_id == PlatformBillingAccount.id) + .options(joinedload(PlatformSubscription.account), joinedload(PlatformSubscription.plan)) + .where(PlatformBillingAccount.account_type == "CONSULTANT") + .order_by(PlatformBillingAccount.display_name.asc(), PlatformSubscription.id.desc()) + ) + if q: + like = f"%{q}%" + stmt = stmt.where(or_(PlatformSubscription.subscription_code.ilike(like), PlatformBillingAccount.display_name.ilike(like), PlatformBillingAccount.account_code.ilike(like))) + return list(db.execute(stmt).scalars().all()) + + +def get_consultant_tool_usage(db, consultant_id: int | None) -> dict: + if not consultant_id: + return {"managed_client_count": 0, "user_account_count": 1, "workspace_type": "-", "workspace_plan": "-"} + consultant = db.get(ConsultantProfile, consultant_id) + managed_client_count = db.execute( + select(func.count(ConsultantManagedClient.id)).where( + ConsultantManagedClient.consultant_id == consultant_id, + ConsultantManagedClient.is_active.is_(True), + ) + ).scalar() or 0 + workspace = getattr(consultant, "workspace", None) if consultant else None + workspace_type = "-" + if consultant: + workspace_type = getattr(workspace, "workspace_type", None) or getattr(consultant, "consultant_type", "-") or "-" + return { + "managed_client_count": int(managed_client_count), + "user_account_count": 1 if consultant and consultant.user_id else 0, + "workspace_type": workspace_type, + "workspace_plan": getattr(workspace, "plan_code", None) or "-", + } + + +def list_consultant_billing_subscription_rows(db, q: str = "") -> list[dict]: + rows = [] + for sub in list_consultant_billing_subscriptions(db, q=q): + rows.append({"subscription": sub, "usage": get_consultant_tool_usage(db, sub.account.consultant_id if sub.account else None)}) + return rows + + +def _next_consultant_invoice_no(db, *, subscription: PlatformSubscription, invoice_date: date) -> str: + base = f"PB/CON/{invoice_date.strftime('%Y%m')}/{subscription.id:05d}" + candidate = base + suffix = 1 + while db.execute(select(PlatformInvoice.id).where(PlatformInvoice.invoice_no == candidate)).scalar() is not None: + suffix += 1 + candidate = f"{base}-{suffix}" + return candidate + + +def generate_consultant_subscription_invoices( + db, + *, + subscription_ids: list[int], + period_from: date, + period_to: date, + invoice_date: date, + due_date: date | None, + tax_type: str, + managed_client_rate, + user_account_rate, + include_zero_usage_lines: bool = False, + user_id: int | None = None, +) -> dict: + """Generate draft platform invoices for consultant SaaS/tool subscriptions.""" + clean_ids = [int(x) for x in subscription_ids if str(x).strip()] + if not clean_ids: + return {"created": 0, "skipped": 0, "errors": ["No consultant subscriptions selected."], "invoices": []} + + stmt = ( + select(PlatformSubscription) + .join(PlatformBillingAccount, PlatformSubscription.account_id == PlatformBillingAccount.id) + .options(joinedload(PlatformSubscription.account), joinedload(PlatformSubscription.plan)) + .where(PlatformSubscription.id.in_(clean_ids), PlatformBillingAccount.account_type == "CONSULTANT") + ) + subscriptions = list(db.execute(stmt).scalars().all()) + created = 0 + skipped = 0 + errors: list[str] = [] + invoices: list[PlatformInvoice] = [] + managed_client_rate = money(managed_client_rate) + user_account_rate = money(user_account_rate) + + for sub in subscriptions: + account = sub.account + if not account or account.account_type != "CONSULTANT" or not account.consultant_id: + skipped += 1 + errors.append(f"Skipped subscription {sub.subscription_code}: not linked to a consultant account.") + continue + if sub.status != "ACTIVE" or not sub.auto_generate_invoice: + skipped += 1 + errors.append(f"Skipped {account.display_name}: subscription is not active/auto-generate enabled.") + continue + existing = _platform_invoice_exists_for_subscription_period(db, subscription_id=sub.id, period_from=period_from, period_to=period_to) + if existing: + skipped += 1 + errors.append(f"Skipped {account.display_name}: invoice already exists for this period ({existing.invoice_no}).") + continue + + usage = get_consultant_tool_usage(db, account.consultant_id) + line_items: list[dict] = [] + if money(sub.amount) > 0: + line_items.append({ + "charge_type": "CONSULTANT_PORTAL", + "description": f"{sub.plan.name if sub.plan else 'Consultant Tool Access'} - {period_from.strftime('%d-%m-%Y')} to {period_to.strftime('%d-%m-%Y')}", + "quantity": "1", + "rate": sub.amount, + "discount_amount": "0", + "gst_rate": sub.gst_rate, + "reference_type": "CONSULTANT_SUBSCRIPTION", + "reference_id": sub.id, + }) + + usage_specs = [ + ("MANAGED_CLIENT_USAGE", "Managed consultant clients", usage["managed_client_count"], managed_client_rate), + ("USER_ACCOUNT_USAGE", "Consultant portal user accounts", usage["user_account_count"], user_account_rate), + ] + for charge_type, label, quantity, rate in usage_specs: + if rate > 0 and (quantity > 0 or include_zero_usage_lines): + line_items.append({ + "charge_type": charge_type, + "description": f"{label} for {account.display_name} ({period_from.strftime('%b %Y')})", + "quantity": str(quantity), + "rate": rate, + "discount_amount": "0", + "gst_rate": sub.gst_rate, + "reference_type": "CONSULTANT", + "reference_id": account.consultant_id, + }) + + if not line_items: + skipped += 1 + errors.append(f"Skipped {account.display_name}: no billable line items.") + continue + + invoice = create_platform_invoice( + db, + account_id=account.id, + subscription_id=sub.id, + invoice_no=_next_consultant_invoice_no(db, subscription=sub, invoice_date=invoice_date), + invoice_date=invoice_date, + due_date=due_date, + billing_period_from=period_from, + billing_period_to=period_to, + tax_type=tax_type, + line_items=line_items, + notes="Generated from Consultant SaaS/tool subscription billing (PB4).", + user_id=user_id, + ) + created += 1 + invoices.append(invoice) + + return {"created": created, "skipped": skipped, "errors": errors, "invoices": invoices} + + +def list_platform_invoices(db, q: str = "") -> list[PlatformInvoice]: + stmt = select(PlatformInvoice).options(joinedload(PlatformInvoice.account)).order_by(PlatformInvoice.invoice_date.desc(), PlatformInvoice.id.desc()) + if q: + like = f"%{q}%" + stmt = stmt.join(PlatformBillingAccount).where(or_(PlatformInvoice.invoice_no.ilike(like), PlatformBillingAccount.display_name.ilike(like))) + return list(db.execute(stmt).scalars().all()) + + +def get_platform_invoice(db, invoice_id: int) -> PlatformInvoice | None: + return db.execute( + select(PlatformInvoice).options(joinedload(PlatformInvoice.account), joinedload(PlatformInvoice.lines)).where(PlatformInvoice.id == invoice_id) + ).unique().scalar_one_or_none() + + +def create_platform_invoice(db, *, account_id: int, subscription_id: int | None, invoice_no: str, invoice_date: date, due_date: date | None, billing_period_from: date | None, billing_period_to: date | None, tax_type: str, line_items: Iterable[dict], notes: str | None, user_id: int | None) -> PlatformInvoice: + invoice = PlatformInvoice( + account_id=account_id, + subscription_id=subscription_id, + invoice_no=invoice_no.strip(), + invoice_date=invoice_date, + due_date=due_date, + billing_period_from=billing_period_from, + billing_period_to=billing_period_to, + tax_type=tax_type, + notes=notes or None, + created_by_user_id=user_id, + status="DRAFT", + ) + db.add(invoice) + db.flush() + subtotal = money(0) + taxable_total = money(0) + cgst_total = money(0) + sgst_total = money(0) + igst_total = money(0) + for idx, item in enumerate(line_items, start=1): + description = (item.get("description") or "").strip() + if not description: + continue + quantity = money(item.get("quantity", 1)) + rate = money(item.get("rate", 0)) + discount = money(item.get("discount_amount", 0)) + gst_rate = money(item.get("gst_rate", 18)) + taxable = money((quantity * rate) - discount) + if taxable < 0: + taxable = money(0) + cgst, sgst, igst = _tax_split(taxable, gst_rate, tax_type) + line_total = money(taxable + cgst + sgst + igst) + subtotal += money(quantity * rate) + taxable_total += taxable + cgst_total += cgst + sgst_total += sgst + igst_total += igst + db.add(PlatformInvoiceLine( + invoice_id=invoice.id, + charge_type=item.get("charge_type") or "SUBSCRIPTION", + description=description, + reference_type=item.get("reference_type") or None, + reference_id=item.get("reference_id") or None, + quantity=quantity, + rate=rate, + discount_amount=discount, + taxable_amount=taxable, + gst_rate=gst_rate, + cgst_amount=cgst, + sgst_amount=sgst, + igst_amount=igst, + line_total=line_total, + sort_order=idx, + )) + invoice.subtotal = money(subtotal) + invoice.discount_amount = money(subtotal - taxable_total) + invoice.taxable_amount = money(taxable_total) + invoice.cgst_amount = money(cgst_total) + invoice.sgst_amount = money(sgst_total) + invoice.igst_amount = money(igst_total) + invoice.total_amount = money(taxable_total + cgst_total + sgst_total + igst_total) + db.commit() + db.refresh(invoice) + return invoice + + +def post_platform_invoice(db, invoice: PlatformInvoice, user_id: int | None) -> PlatformInvoice: + invoice.status = "POSTED" + invoice.posted_by_user_id = user_id + invoice.posted_at_utc = datetime.now(timezone.utc) + db.commit() + db.refresh(invoice) + return invoice + + +def record_platform_payment(db, *, invoice: PlatformInvoice, amount, mode: str, reference_no: str | None, notes: str | None, user_id: int | None) -> PlatformPayment: + payment = PlatformPayment( + invoice_id=invoice.id, + account_id=invoice.account_id, + amount=money(amount), + mode=mode, + reference_no=reference_no or None, + notes=notes or None, + created_by_user_id=user_id, + ) + db.add(payment) + db.commit() + db.refresh(payment) + return payment diff --git a/app/modules/platform_billing/templates/platform_billing/accounts/create.html b/app/modules/platform_billing/templates/platform_billing/accounts/create.html new file mode 100644 index 0000000..a1360be --- /dev/null +++ b/app/modules/platform_billing/templates/platform_billing/accounts/create.html @@ -0,0 +1,5 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +

New Platform Billing Account

+
+{% endblock %} diff --git a/app/modules/platform_billing/templates/platform_billing/accounts/list.html b/app/modules/platform_billing/templates/platform_billing/accounts/list.html new file mode 100644 index 0000000..f56502f --- /dev/null +++ b/app/modules/platform_billing/templates/platform_billing/accounts/list.html @@ -0,0 +1,6 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +

Platform Billing Accounts

{% if can_create %}New Account{% endif %}
+
+
{% for row in rows %}{% else %}{% endfor %}
CodeNameTypeEmailStatus
{{ row.account_code }}{{ row.display_name }}{{ row.account_type }}{{ row.email or '-' }}{{ row.status }}
No billing accounts found.
+{% endblock %} diff --git a/app/modules/platform_billing/templates/platform_billing/audit_firms/generate.html b/app/modules/platform_billing/templates/platform_billing/audit_firms/generate.html new file mode 100644 index 0000000..440b46c --- /dev/null +++ b/app/modules/platform_billing/templates/platform_billing/audit_firms/generate.html @@ -0,0 +1,86 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Generate Audit Firm Platform Bills

+

Generate draft SaaS invoices for Audit Firm subscriptions. Duplicate invoices for the same subscription and period are skipped.

+
+ Back to Audit Firm Subscriptions +
+ + {% if generated or skipped %} +
+
Generated: {{ generated }} · Skipped: {{ skipped }}
+ {% if errors %} +
    + {% for error in errors %}
  • {{ error }}
  • {% endfor %} +
+ {% endif %} + {% if invoices %} +
+ {% for invoice in invoices %}{{ invoice.invoice_no }}{% endfor %} +
+ {% endif %} +
+ {% endif %} + +
+ +
+ + + + + + +
+ +
+

Optional usage rates

+

Base subscription amount comes from the subscription. Add usage rates only when you want to charge extra by count.

+
+ + + + +
+
+ +
+
Select Audit Firm subscriptions
+
+ + + + + + + + + + + + + {% for row in rows %} + {% set sub = row.subscription %} + + + + + + + + + {% else %} + + {% endfor %} + +
SelectAudit FirmSubscriptionPlanBaseUsage Count
{{ sub.account.display_name if sub.account else '-' }}{{ sub.subscription_code }}{{ sub.plan.name if sub.plan else '-' }}{{ '%.2f'|format(sub.amount or 0) }}Clients: {{ row.usage.clients }} · Employees: {{ row.usage.employees }} · Consultants: {{ row.usage.consultants }} · Branches: {{ row.usage.branches }}
No Audit Firm subscriptions available.
+
+
+ + +
+
+{% endblock %} diff --git a/app/modules/platform_billing/templates/platform_billing/audit_firms/list.html b/app/modules/platform_billing/templates/platform_billing/audit_firms/list.html new file mode 100644 index 0000000..16abdfe --- /dev/null +++ b/app/modules/platform_billing/templates/platform_billing/audit_firms/list.html @@ -0,0 +1,83 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Audit Firm Subscription Billing

+

PB2: manage SaaS subscriptions and generate platform bills for Audit Firms.

+ {% if synced %}

Sync completed: {{ synced }}

{% endif %} +
+
+ {% if can_manage %} +
+ + +
+ New Subscription + {% endif %} + {% if can_generate %} + Generate Audit Firm Bills + {% endif %} +
+
+ +
+ + +
+ +
+
+

Active Audit Firm subscriptions

+
+
+ + + + + + + + + + + + + + {% for row in rows %} + {% set sub = row.subscription %} + + + + + + + + + + {% else %} + + {% endfor %} + +
Audit FirmSubscriptionPlanCycleBase AmountUsage CountStatus
{{ sub.account.display_name if sub.account else '-' }}{{ sub.subscription_code }}{{ sub.plan.name if sub.plan else '-' }}{{ sub.billing_cycle }}{{ '%.2f'|format(sub.amount or 0) }} + Clients: {{ row.usage.clients }} · Employees: {{ row.usage.employees }} · Consultants: {{ row.usage.consultants }} · Branches: {{ row.usage.branches }} + {{ sub.status }}
No Audit Firm subscriptions found. Sync Audit Firm accounts, create a plan, then create subscriptions.
+
+
+ +
+

Audit Firm billing accounts

+

These are platform billing accounts linked to your Audit Firm master. They are separate from firm-level client billing.

+
+ {% for account in audit_firm_accounts %} +
+
{{ account.display_name }}
+
Code: {{ account.account_code }} · Status: {{ account.status }}
+
+ {% else %} +
No Audit Firm billing accounts synced yet.
+ {% endfor %} +
+
+
+{% endblock %} diff --git a/app/modules/platform_billing/templates/platform_billing/clients/generate.html b/app/modules/platform_billing/templates/platform_billing/clients/generate.html new file mode 100644 index 0000000..abdb290 --- /dev/null +++ b/app/modules/platform_billing/templates/platform_billing/clients/generate.html @@ -0,0 +1,98 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Generate Client Dashboard Platform Bills

+

Generate draft platform invoices for client compliance dashboard subscriptions. Existing invoice for the same subscription and period will be skipped.

+
+ Back to Client Dashboard Billing +
+ + {% if generated or skipped or errors %} +
+
Generated: {{ generated }} · Skipped: {{ skipped }}
+ {% if errors %} +
    + {% for error in errors %}
  • {{ error }}
  • {% endfor %} +
+ {% endif %} + {% if invoices %} +
+ {% for invoice in invoices %}{{ invoice.invoice_no }}{% endfor %} +
+ {% endif %} +
+ {% endif %} + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
Select client dashboard subscriptions
+
+ {% for row in rows %} + {% set sub = row.subscription %} + + {% else %} +
No client dashboard subscriptions available.
+ {% endfor %} +
+
+ +
+ +
+
+
+{% endblock %} diff --git a/app/modules/platform_billing/templates/platform_billing/clients/list.html b/app/modules/platform_billing/templates/platform_billing/clients/list.html new file mode 100644 index 0000000..5b56f86 --- /dev/null +++ b/app/modules/platform_billing/templates/platform_billing/clients/list.html @@ -0,0 +1,145 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Client Compliance Dashboard Billing

+

PB3: manage platform subscriptions and generate bills for client compliance dashboard access.

+ {% if synced %}

Sync completed: {{ synced }}

{% endif %} + {% if created %}

Client dashboard subscription created.

{% endif %} +
+
+ {% if can_manage %} +
+ + +
+ {% endif %} + {% if can_generate %} + Generate Client Dashboard Bills + {% endif %} +
+
+ + {% if can_manage %} +
+

Create Client Dashboard Subscription

+

Use CLIENT-target platform plans. If no plan appears, create a plan with Target Account Type = CLIENT.

+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ +
+
+
+ {% endif %} + +
+ + +
+ +
+
+

Active client dashboard subscriptions

+
+
+ + + + + + + + + + + + + + {% for row in rows %} + {% set sub = row.subscription %} + + + + + + + + + + {% else %} + + {% endfor %} + +
ClientSubscriptionPlanCycleBase AmountDashboard UnitsStatus
{{ sub.account.display_name if sub.account else '-' }}{{ sub.subscription_code }}{{ sub.plan.name if sub.plan else '-' }}{{ sub.billing_cycle }}{{ '%.2f'|format(sub.amount or 0) }} + PAN: {{ row.usage.pan_units }} · GSTIN: {{ row.usage.gstin_units }} · Modules: {{ row.usage.module_count }} + {% if row.usage.modules %}
{{ row.usage.modules|join(', ') }}
{% endif %} +
{{ sub.status }}
No client dashboard subscriptions found. Sync client accounts, create a CLIENT plan, then create subscriptions.
+
+
+ +
+

Client billing accounts

+

These accounts are for platform billing of compliance dashboard access. They are separate from the Audit Firm's client invoices.

+
+ {% for account in client_accounts[:12] %} +
+
{{ account.display_name }}
+
Code: {{ account.account_code }} · Status: {{ account.status }}
+
PAN: {{ account.pan or '-' }} · GSTIN: {{ account.gstin or '-' }}
+
+ {% else %} +
No client billing accounts synced yet.
+ {% endfor %} +
+
+
+{% endblock %} diff --git a/app/modules/platform_billing/templates/platform_billing/consultants/generate.html b/app/modules/platform_billing/templates/platform_billing/consultants/generate.html new file mode 100644 index 0000000..597bea2 --- /dev/null +++ b/app/modules/platform_billing/templates/platform_billing/consultants/generate.html @@ -0,0 +1,94 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Generate Consultant Platform Bills

+

Generate draft platform invoices for consultant SaaS/tool access subscriptions. Existing invoice for the same subscription and period will be skipped.

+
+ Back to Consultant Billing +
+ + {% if generated or skipped or errors %} +
+
Generated: {{ generated }} · Skipped: {{ skipped }}
+ {% if errors %} +
    + {% for error in errors %}
  • {{ error }}
  • {% endfor %} +
+ {% endif %} + {% if invoices %} +
+ {% for invoice in invoices %}{{ invoice.invoice_no }}{% endfor %} +
+ {% endif %} +
+ {% endif %} + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+ + +
+
+ + +
+
+ +
+
Select consultant subscriptions
+
+ {% for row in rows %} + {% set sub = row.subscription %} + + {% else %} +
No consultant subscriptions available.
+ {% endfor %} +
+
+ +
+ +
+
+
+{% endblock %} diff --git a/app/modules/platform_billing/templates/platform_billing/consultants/list.html b/app/modules/platform_billing/templates/platform_billing/consultants/list.html new file mode 100644 index 0000000..62d3b24 --- /dev/null +++ b/app/modules/platform_billing/templates/platform_billing/consultants/list.html @@ -0,0 +1,145 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Consultant SaaS/Tool Billing

+

PB4: manage platform subscriptions and generate bills for consultant SaaS/tool access.

+ {% if synced %}

Sync completed: {{ synced }}

{% endif %} + {% if created %}

Consultant subscription created.

{% endif %} +
+
+ {% if can_manage %} +
+ + +
+ {% endif %} + {% if can_generate %} + Generate Consultant Bills + {% endif %} +
+
+ + {% if can_manage %} +
+

Create Consultant Subscription

+

Use CONSULTANT-target platform plans. If no plan appears, create a plan with Target Account Type = CONSULTANT.

+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ +
+
+
+ {% endif %} + +
+ + +
+ +
+
+

Active consultant subscriptions

+
+
+ + + + + + + + + + + + + + {% for row in rows %} + {% set sub = row.subscription %} + + + + + + + + + + {% else %} + + {% endfor %} + +
ConsultantSubscriptionPlanCycleBase AmountTool UsageStatus
{{ sub.account.display_name if sub.account else '-' }}{{ sub.subscription_code }}{{ sub.plan.name if sub.plan else '-' }}{{ sub.billing_cycle }}{{ '%.2f'|format(sub.amount or 0) }} + Managed Clients: {{ row.usage.managed_client_count }} · Portal Users: {{ row.usage.user_account_count }} +
Workspace: {{ row.usage.workspace_type }} · Plan: {{ row.usage.workspace_plan }}
+
{{ sub.status }}
No consultant subscriptions found. Sync consultant accounts, create a CONSULTANT plan, then create subscriptions.
+
+
+ +
+

Consultant billing accounts

+

These accounts are for platform billing of consultant SaaS/tool access. They are separate from firm-level billing and consultant master records.

+
+ {% for account in consultant_accounts[:12] %} +
+
{{ account.display_name }}
+
Code: {{ account.account_code }} · Status: {{ account.status }}
+
Email: {{ account.email or '-' }} · Mobile: {{ account.mobile or '-' }}
+
+ {% else %} +
No consultant billing accounts synced yet.
+ {% endfor %} +
+
+
+{% endblock %} diff --git a/app/modules/platform_billing/templates/platform_billing/dashboard.html b/app/modules/platform_billing/templates/platform_billing/dashboard.html new file mode 100644 index 0000000..70d737f --- /dev/null +++ b/app/modules/platform_billing/templates/platform_billing/dashboard.html @@ -0,0 +1,48 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Platform Billing

+

SaaS billing for Audit Firms, compliance-dashboard clients, consultants and future marketplace customers.

+
+
+ {% if can_manage_plans %}New Plan{% endif %} + {% if can_generate_platform_billing_flag %}Generate Audit Firm Bills{% endif %} + {% if can_generate_platform_billing_flag %}Generate Client Dashboard Bills{% endif %} + {% if can_generate_platform_billing_flag %}Generate Consultant Bills{% endif %} + {% if can_create_invoice %}New Platform Invoice{% endif %} +
+
+ + +
+{% endblock %} diff --git a/app/modules/platform_billing/templates/platform_billing/invoices/create.html b/app/modules/platform_billing/templates/platform_billing/invoices/create.html new file mode 100644 index 0000000..0a26e19 --- /dev/null +++ b/app/modules/platform_billing/templates/platform_billing/invoices/create.html @@ -0,0 +1,2 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %}

New Platform Invoice

Invoice Line

{% endblock %} diff --git a/app/modules/platform_billing/templates/platform_billing/invoices/detail.html b/app/modules/platform_billing/templates/platform_billing/invoices/detail.html new file mode 100644 index 0000000..7b93cec --- /dev/null +++ b/app/modules/platform_billing/templates/platform_billing/invoices/detail.html @@ -0,0 +1,2 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %}

Platform Invoice {{ invoice.invoice_no }}

{{ invoice.account.display_name if invoice.account else invoice.account_id }} • {{ invoice.status }}

{% if can_post and invoice.status == 'DRAFT' %}
{% endif %}
Invoice Date
{{ invoice.invoice_date }}
Due Date
{{ invoice.due_date or '-' }}
Tax Type
{{ invoice.tax_type }}
Total
₹ {{ '%.2f'|format(invoice.total_amount or 0) }}
{% for line in invoice.lines %}{% endfor %}
DescriptionChargeTaxableTaxTotal
{{ line.description }}{{ line.charge_type }}₹ {{ '%.2f'|format(line.taxable_amount or 0) }}₹ {{ '%.2f'|format((line.cgst_amount or 0) + (line.sgst_amount or 0) + (line.igst_amount or 0)) }}₹ {{ '%.2f'|format(line.line_total or 0) }}
{% if can_record_payment %}
{% endif %}
{% endblock %} diff --git a/app/modules/platform_billing/templates/platform_billing/invoices/list.html b/app/modules/platform_billing/templates/platform_billing/invoices/list.html new file mode 100644 index 0000000..cf84ec5 --- /dev/null +++ b/app/modules/platform_billing/templates/platform_billing/invoices/list.html @@ -0,0 +1,2 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %}

Platform Invoices

{% if can_create %}New Platform Invoice{% endif %}
{% for row in rows %}{% else %}{% endfor %}
Invoice NoDateAccountTotalStatus
{{ row.invoice_no }}{{ row.invoice_date }}{{ row.account.display_name if row.account else row.account_id }}₹ {{ '%.2f'|format(row.total_amount or 0) }}{{ row.status }}View
No platform invoices found.
{% endblock %} diff --git a/app/modules/platform_billing/templates/platform_billing/plans/create.html b/app/modules/platform_billing/templates/platform_billing/plans/create.html new file mode 100644 index 0000000..a5bfc20 --- /dev/null +++ b/app/modules/platform_billing/templates/platform_billing/plans/create.html @@ -0,0 +1,5 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +

New Platform Plan

+
+{% endblock %} diff --git a/app/modules/platform_billing/templates/platform_billing/plans/list.html b/app/modules/platform_billing/templates/platform_billing/plans/list.html new file mode 100644 index 0000000..aeb38b3 --- /dev/null +++ b/app/modules/platform_billing/templates/platform_billing/plans/list.html @@ -0,0 +1,8 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+

Platform Plans

{% if can_manage %}New Plan{% endif %}
+
+
{% for row in rows %}{% else %}{% endfor %}
CodeNameForCycleBaseStatus
{{ row.code }}{{ row.name }}{{ row.target_account_type }}{{ row.billing_cycle }}₹ {{ '%.2f'|format(row.base_amount or 0) }}{{ 'Active' if row.is_active else 'Inactive' }}
No plans found.
+
+{% endblock %} diff --git a/app/modules/platform_billing/templates/platform_billing/subscriptions/create.html b/app/modules/platform_billing/templates/platform_billing/subscriptions/create.html new file mode 100644 index 0000000..5313a43 --- /dev/null +++ b/app/modules/platform_billing/templates/platform_billing/subscriptions/create.html @@ -0,0 +1,2 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %}

New Platform Subscription

{% endblock %} diff --git a/app/modules/platform_billing/templates/platform_billing/subscriptions/list.html b/app/modules/platform_billing/templates/platform_billing/subscriptions/list.html new file mode 100644 index 0000000..375c68e --- /dev/null +++ b/app/modules/platform_billing/templates/platform_billing/subscriptions/list.html @@ -0,0 +1,2 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %}

Platform Subscriptions

{% if can_manage %}New Subscription{% endif %}
{% for row in rows %}{% else %}{% endfor %}
CodeAccountPlanCycleAmountStatus
{{ row.subscription_code }}{{ row.account.display_name if row.account else row.account_id }}{{ row.plan.name if row.plan else row.plan_id }}{{ row.billing_cycle }}₹ {{ '%.2f'|format(row.amount or 0) }}{{ row.status }}
No subscriptions found.
{% endblock %} diff --git a/app/modules/platform_billing/ui.py b/app/modules/platform_billing/ui.py new file mode 100644 index 0000000..2532709 --- /dev/null +++ b/app/modules/platform_billing/ui.py @@ -0,0 +1,684 @@ +from __future__ import annotations + +from fastapi import APIRouter, Form, Request +from fastapi.responses import RedirectResponse + +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.core.rbac.deps import get_user_permissions, get_user_roles +from app.modules.core.rbac.permission_guard import require_permission +from app.modules.platform_billing.services import ( + ACCOUNT_TYPES, + BILLING_CYCLES, + CHARGE_TYPES, + TAX_TYPES, + create_platform_account, + create_platform_invoice, + create_platform_plan, + create_platform_subscription, + generate_audit_firm_subscription_invoices, + generate_client_dashboard_subscription_invoices, + generate_consultant_subscription_invoices, + get_platform_invoice, + list_audit_firm_accounts, + list_audit_firm_subscription_rows, + list_client_dashboard_accounts, + list_client_dashboard_plans, + list_client_dashboard_subscription_rows, + list_consultant_billing_accounts, + list_consultant_billing_plans, + list_consultant_billing_subscription_rows, + list_platform_accounts, + list_platform_invoices, + list_platform_plans, + list_platform_subscriptions, + list_reference_audit_firms, + list_reference_clients, + list_reference_consultants, + parse_date, + post_platform_invoice, + record_platform_payment, + sync_audit_firm_billing_accounts, + sync_client_dashboard_billing_accounts, + sync_consultant_billing_accounts, +) + +router = APIRouter(prefix="/platform-billing", tags=["platform-billing-ui"]) + + +def _redirect_denied(): + return RedirectResponse(url="/system-settings", status_code=303) + + +def _has_perm(db, user, code: str) -> bool: + try: + require_permission(db, user, code) + return True + except Exception: + return False + + +def _require_user(request: Request, db, permission_code: str): + user = get_current_user(request, db=db) + if not user: + return None, RedirectResponse(url="/login", status_code=303) + try: + require_permission(db, user, permission_code) + except Exception: + return user, _redirect_denied() + return user, None + + +def _base_ctx(request: Request, db, user, **ctx): + base = { + "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), + "account_types": ACCOUNT_TYPES, + "billing_cycles": BILLING_CYCLES, + "tax_types": TAX_TYPES, + "charge_types": CHARGE_TYPES, + } + base.update(ctx) + return base + + +def _render(request: Request, template: str, db, user, **ctx): + return templates.TemplateResponse(template, _base_ctx(request, db, user, **ctx)) + + +@router.get("") +def dashboard(request: Request): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "platform_billing.view") + if response: + return response + return _render( + request, + "modules/platform_billing/templates/platform_billing/dashboard.html", + db, + user, + title="Platform Billing", + plans=list_platform_plans(db)[:5], + accounts=list_platform_accounts(db)[:5], + audit_firm_accounts=list_audit_firm_accounts(db)[:5], + client_dashboard_accounts=list_client_dashboard_accounts(db)[:5], + consultant_billing_accounts=list_consultant_billing_accounts(db)[:5], + subscriptions=list_platform_subscriptions(db)[:5], + invoices=list_platform_invoices(db)[:5], + can_manage_plans=_has_perm(db, user, "platform_plans.manage"), + can_manage_subscriptions=_has_perm(db, user, "platform_subscriptions.manage"), + can_generate_platform_billing_flag=_has_perm(db, user, "platform_billing.generate"), + can_create_invoice=_has_perm(db, user, "platform_billing.create"), + ) + finally: + db.close() + + +@router.get("/plans") +def plans_list(request: Request, q: str = ""): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "platform_billing.view") + if response: + return response + return _render(request, "modules/platform_billing/templates/platform_billing/plans/list.html", db, user, title="Platform Plans", rows=list_platform_plans(db, q=q), q=q, can_manage=_has_perm(db, user, "platform_plans.manage")) + finally: + db.close() + + +@router.get("/plans/new") +def plan_new(request: Request): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "platform_plans.manage") + if response: + return response + return _render(request, "modules/platform_billing/templates/platform_billing/plans/create.html", db, user, title="New Platform Plan") + finally: + db.close() + + +@router.post("/plans/new") +def plan_create(request: Request, csrf_token: str = Form(...), code: str = Form(...), name: str = Form(...), target_account_type: str = Form(...), billing_cycle: str = Form(...), base_amount: str = Form("0"), gst_rate: str = Form("18"), description: str = Form(""), feature_text: str = Form("")): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "platform_plans.manage") + if response: + return response + validate_csrf(request, csrf_token) + create_platform_plan(db, code=code, name=name, target_account_type=target_account_type, billing_cycle=billing_cycle, base_amount=base_amount, gst_rate=gst_rate, description=description, feature_text=feature_text) + return RedirectResponse(url="/platform-billing/plans", status_code=303) + finally: + db.close() + + +@router.get("/accounts") +def accounts_list(request: Request, q: str = "", account_type: str = ""): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "platform_billing.view") + if response: + return response + return _render(request, "modules/platform_billing/templates/platform_billing/accounts/list.html", db, user, title="Platform Billing Accounts", rows=list_platform_accounts(db, q=q, account_type=account_type), q=q, selected_account_type=account_type, can_create=_has_perm(db, user, "platform_billing.create")) + finally: + db.close() + + +@router.get("/accounts/new") +def account_new(request: Request): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "platform_billing.create") + if response: + return response + return _render(request, "modules/platform_billing/templates/platform_billing/accounts/create.html", db, user, title="New Platform Billing Account", audit_firms=list_reference_audit_firms(db), clients=list_reference_clients(db), consultants=list_reference_consultants(db)) + finally: + db.close() + + +@router.post("/accounts/new") +def account_create(request: Request, csrf_token: str = Form(...), account_type: str = Form(...), account_code: str = Form(...), display_name: str = Form(...), tenant_id: str = Form(""), client_id: str = Form(""), consultant_id: str = Form(""), email: str = Form(""), mobile: str = Form(""), gstin: str = Form(""), pan: str = Form(""), billing_address: str = Form(""), state: str = Form(""), notes: str = Form("")): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "platform_billing.create") + if response: + return response + validate_csrf(request, csrf_token) + create_platform_account(db, account_type=account_type, account_code=account_code, display_name=display_name, tenant_id=int(tenant_id) if tenant_id else None, client_id=int(client_id) if client_id else None, consultant_id=int(consultant_id) if consultant_id else None, email=email, mobile=mobile, gstin=gstin, pan=pan, billing_address=billing_address, state=state, notes=notes, user_id=user.id) + return RedirectResponse(url="/platform-billing/accounts", status_code=303) + finally: + db.close() + + +@router.get("/audit-firm-subscriptions") +def audit_firm_subscriptions(request: Request, q: str = "", synced: str = ""): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "platform_billing.view") + if response: + return response + return _render( + request, + "modules/platform_billing/templates/platform_billing/audit_firms/list.html", + db, + user, + title="Audit Firm Subscription Billing", + rows=list_audit_firm_subscription_rows(db, q=q), + audit_firm_accounts=list_audit_firm_accounts(db, q=q), + q=q, + synced=synced, + can_manage=_has_perm(db, user, "platform_subscriptions.manage"), + can_generate=_has_perm(db, user, "platform_billing.generate"), + ) + finally: + db.close() + + +@router.post("/audit-firm-subscriptions/sync-accounts") +def audit_firm_accounts_sync(request: Request, csrf_token: str = Form(...)): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "platform_subscriptions.manage") + if response: + return response + validate_csrf(request, csrf_token) + result = sync_audit_firm_billing_accounts(db, user_id=user.id) + return RedirectResponse(url=f"/platform-billing/audit-firm-subscriptions?synced=created-{result['created']}-updated-{result['updated']}", status_code=303) + finally: + db.close() + + +@router.get("/audit-firm-subscriptions/generate") +def audit_firm_generate_form(request: Request, generated: int = 0, skipped: int = 0): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "platform_billing.generate") + if response: + return response + return _render( + request, + "modules/platform_billing/templates/platform_billing/audit_firms/generate.html", + db, + user, + title="Generate Audit Firm Platform Bills", + rows=list_audit_firm_subscription_rows(db), + generated=generated, + skipped=skipped, + errors=[], + ) + finally: + db.close() + + +@router.post("/audit-firm-subscriptions/generate") +def audit_firm_generate_submit(request: Request, csrf_token: str = Form(...), subscription_ids: list[int] = Form(default=[]), period_from: str = Form(...), period_to: str = Form(...), invoice_date: str = Form(...), due_date: str = Form(""), tax_type: str = Form("CGST_SGST"), client_rate: str = Form("0"), employee_rate: str = Form("0"), consultant_rate: str = Form("0"), branch_rate: str = Form("0"), include_zero_usage_lines: str = Form("off")): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "platform_billing.generate") + if response: + return response + validate_csrf(request, csrf_token) + result = generate_audit_firm_subscription_invoices( + db, + subscription_ids=subscription_ids, + period_from=parse_date(period_from), + period_to=parse_date(period_to), + invoice_date=parse_date(invoice_date), + due_date=parse_date(due_date), + tax_type=tax_type, + client_rate=client_rate, + employee_rate=employee_rate, + consultant_rate=consultant_rate, + branch_rate=branch_rate, + include_zero_usage_lines=include_zero_usage_lines == "on", + user_id=user.id, + ) + return _render( + request, + "modules/platform_billing/templates/platform_billing/audit_firms/generate.html", + db, + user, + title="Generate Audit Firm Platform Bills", + rows=list_audit_firm_subscription_rows(db), + generated=result["created"], + skipped=result["skipped"], + errors=result["errors"], + invoices=result["invoices"], + ) + finally: + db.close() + + +@router.get("/client-dashboard-subscriptions") +def client_dashboard_subscriptions(request: Request, q: str = "", synced: str = "", created: str = ""): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "platform_billing.view") + if response: + return response + return _render( + request, + "modules/platform_billing/templates/platform_billing/clients/list.html", + db, + user, + title="Client Compliance Dashboard Billing", + rows=list_client_dashboard_subscription_rows(db, q=q), + client_accounts=list_client_dashboard_accounts(db, q=q), + client_plans=list_client_dashboard_plans(db), + q=q, + synced=synced, + created=created, + can_manage=_has_perm(db, user, "platform_subscriptions.manage"), + can_generate=_has_perm(db, user, "platform_billing.generate"), + ) + finally: + db.close() + + +@router.post("/client-dashboard-subscriptions/sync-accounts") +def client_dashboard_accounts_sync(request: Request, csrf_token: str = Form(...)): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "platform_subscriptions.manage") + if response: + return response + validate_csrf(request, csrf_token) + result = sync_client_dashboard_billing_accounts(db, user_id=user.id) + return RedirectResponse(url=f"/platform-billing/client-dashboard-subscriptions?synced=created-{result['created']}-updated-{result['updated']}", status_code=303) + finally: + db.close() + + +@router.post("/client-dashboard-subscriptions/new") +def client_dashboard_subscription_create(request: Request, csrf_token: str = Form(...), account_id: int = Form(...), plan_id: int = Form(...), subscription_code: str = Form(...), start_date: str = Form(...), end_date: str = Form(""), billing_cycle: str = Form("Monthly"), amount: str = Form("0"), gst_rate: str = Form("18"), auto_generate_invoice: str = Form("on"), notes: str = Form("")): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "platform_subscriptions.manage") + if response: + return response + validate_csrf(request, csrf_token) + create_platform_subscription( + db, + account_id=account_id, + plan_id=plan_id, + subscription_code=subscription_code, + start_date=parse_date(start_date), + end_date=parse_date(end_date), + billing_cycle=billing_cycle, + amount=amount, + gst_rate=gst_rate, + auto_generate_invoice=auto_generate_invoice == "on", + notes=notes, + user_id=user.id, + ) + return RedirectResponse(url="/platform-billing/client-dashboard-subscriptions?created=1", status_code=303) + finally: + db.close() + + +@router.get("/client-dashboard-subscriptions/generate") +def client_dashboard_generate_form(request: Request, generated: int = 0, skipped: int = 0): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "platform_billing.generate") + if response: + return response + return _render( + request, + "modules/platform_billing/templates/platform_billing/clients/generate.html", + db, + user, + title="Generate Client Dashboard Platform Bills", + rows=list_client_dashboard_subscription_rows(db), + generated=generated, + skipped=skipped, + errors=[], + ) + finally: + db.close() + + +@router.post("/client-dashboard-subscriptions/generate") +def client_dashboard_generate_submit(request: Request, csrf_token: str = Form(...), subscription_ids: list[int] = Form(default=[]), period_from: str = Form(...), period_to: str = Form(...), invoice_date: str = Form(...), due_date: str = Form(""), tax_type: str = Form("CGST_SGST"), pan_rate: str = Form("0"), gstin_rate: str = Form("0"), module_rate: str = Form("0"), include_zero_usage_lines: str = Form("off")): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "platform_billing.generate") + if response: + return response + validate_csrf(request, csrf_token) + result = generate_client_dashboard_subscription_invoices( + db, + subscription_ids=subscription_ids, + period_from=parse_date(period_from), + period_to=parse_date(period_to), + invoice_date=parse_date(invoice_date), + due_date=parse_date(due_date), + tax_type=tax_type, + pan_rate=pan_rate, + gstin_rate=gstin_rate, + module_rate=module_rate, + include_zero_usage_lines=include_zero_usage_lines == "on", + user_id=user.id, + ) + return _render( + request, + "modules/platform_billing/templates/platform_billing/clients/generate.html", + db, + user, + title="Generate Client Dashboard Platform Bills", + rows=list_client_dashboard_subscription_rows(db), + generated=result["created"], + skipped=result["skipped"], + errors=result["errors"], + invoices=result["invoices"], + ) + finally: + db.close() + + +@router.get("/consultant-subscriptions") +def consultant_subscriptions(request: Request, q: str = "", synced: str = "", created: str = ""): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "platform_billing.view") + if response: + return response + return _render( + request, + "modules/platform_billing/templates/platform_billing/consultants/list.html", + db, + user, + title="Consultant SaaS/Tool Billing", + rows=list_consultant_billing_subscription_rows(db, q=q), + consultant_accounts=list_consultant_billing_accounts(db, q=q), + consultant_plans=list_consultant_billing_plans(db), + q=q, + synced=synced, + created=created, + can_manage=_has_perm(db, user, "platform_subscriptions.manage"), + can_generate=_has_perm(db, user, "platform_billing.generate"), + ) + finally: + db.close() + + +@router.post("/consultant-subscriptions/sync-accounts") +def consultant_accounts_sync(request: Request, csrf_token: str = Form(...)): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "platform_subscriptions.manage") + if response: + return response + validate_csrf(request, csrf_token) + result = sync_consultant_billing_accounts(db, user_id=user.id) + return RedirectResponse(url=f"/platform-billing/consultant-subscriptions?synced=created-{result['created']}-updated-{result['updated']}", status_code=303) + finally: + db.close() + + +@router.post("/consultant-subscriptions/new") +def consultant_subscription_create(request: Request, csrf_token: str = Form(...), account_id: int = Form(...), plan_id: int = Form(...), subscription_code: str = Form(...), start_date: str = Form(...), end_date: str = Form(""), billing_cycle: str = Form("Monthly"), amount: str = Form("0"), gst_rate: str = Form("18"), auto_generate_invoice: str = Form("on"), notes: str = Form("")): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "platform_subscriptions.manage") + if response: + return response + validate_csrf(request, csrf_token) + create_platform_subscription( + db, + account_id=account_id, + plan_id=plan_id, + subscription_code=subscription_code, + start_date=parse_date(start_date), + end_date=parse_date(end_date), + billing_cycle=billing_cycle, + amount=amount, + gst_rate=gst_rate, + auto_generate_invoice=auto_generate_invoice == "on", + notes=notes, + user_id=user.id, + ) + return RedirectResponse(url="/platform-billing/consultant-subscriptions?created=1", status_code=303) + finally: + db.close() + + +@router.get("/consultant-subscriptions/generate") +def consultant_generate_form(request: Request, generated: int = 0, skipped: int = 0): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "platform_billing.generate") + if response: + return response + return _render( + request, + "modules/platform_billing/templates/platform_billing/consultants/generate.html", + db, + user, + title="Generate Consultant Platform Bills", + rows=list_consultant_billing_subscription_rows(db), + generated=generated, + skipped=skipped, + errors=[], + ) + finally: + db.close() + + +@router.post("/consultant-subscriptions/generate") +def consultant_generate_submit(request: Request, csrf_token: str = Form(...), subscription_ids: list[int] = Form(default=[]), period_from: str = Form(...), period_to: str = Form(...), invoice_date: str = Form(...), due_date: str = Form(""), tax_type: str = Form("CGST_SGST"), managed_client_rate: str = Form("0"), user_account_rate: str = Form("0"), include_zero_usage_lines: str = Form("off")): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "platform_billing.generate") + if response: + return response + validate_csrf(request, csrf_token) + result = generate_consultant_subscription_invoices( + db, + subscription_ids=subscription_ids, + period_from=parse_date(period_from), + period_to=parse_date(period_to), + invoice_date=parse_date(invoice_date), + due_date=parse_date(due_date), + tax_type=tax_type, + managed_client_rate=managed_client_rate, + user_account_rate=user_account_rate, + include_zero_usage_lines=include_zero_usage_lines == "on", + user_id=user.id, + ) + return _render( + request, + "modules/platform_billing/templates/platform_billing/consultants/generate.html", + db, + user, + title="Generate Consultant Platform Bills", + rows=list_consultant_billing_subscription_rows(db), + generated=result["created"], + skipped=result["skipped"], + errors=result["errors"], + invoices=result["invoices"], + ) + finally: + db.close() + + +@router.get("/subscriptions") +def subscriptions_list(request: Request, q: str = ""): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "platform_billing.view") + if response: + return response + return _render(request, "modules/platform_billing/templates/platform_billing/subscriptions/list.html", db, user, title="Platform Subscriptions", rows=list_platform_subscriptions(db, q=q), q=q, can_manage=_has_perm(db, user, "platform_subscriptions.manage")) + finally: + db.close() + + +@router.get("/subscriptions/new") +def subscription_new(request: Request): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "platform_subscriptions.manage") + if response: + return response + return _render(request, "modules/platform_billing/templates/platform_billing/subscriptions/create.html", db, user, title="New Platform Subscription", accounts=list_platform_accounts(db), plans=list_platform_plans(db)) + finally: + db.close() + + +@router.post("/subscriptions/new") +def subscription_create(request: Request, csrf_token: str = Form(...), account_id: int = Form(...), plan_id: int = Form(...), subscription_code: str = Form(...), start_date: str = Form(...), end_date: str = Form(""), billing_cycle: str = Form(...), amount: str = Form("0"), gst_rate: str = Form("18"), auto_generate_invoice: str = Form("off"), notes: str = Form("")): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "platform_subscriptions.manage") + if response: + return response + validate_csrf(request, csrf_token) + create_platform_subscription(db, account_id=account_id, plan_id=plan_id, subscription_code=subscription_code, start_date=parse_date(start_date), end_date=parse_date(end_date), billing_cycle=billing_cycle, amount=amount, gst_rate=gst_rate, auto_generate_invoice=auto_generate_invoice == "on", notes=notes, user_id=user.id) + return RedirectResponse(url="/platform-billing/subscriptions", status_code=303) + finally: + db.close() + + +@router.get("/invoices") +def invoices_list(request: Request, q: str = ""): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "platform_billing.view") + if response: + return response + return _render(request, "modules/platform_billing/templates/platform_billing/invoices/list.html", db, user, title="Platform Invoices", rows=list_platform_invoices(db, q=q), q=q, can_create=_has_perm(db, user, "platform_billing.create")) + finally: + db.close() + + +@router.get("/invoices/new") +def invoice_new(request: Request): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "platform_billing.create") + if response: + return response + return _render(request, "modules/platform_billing/templates/platform_billing/invoices/create.html", db, user, title="New Platform Invoice", accounts=list_platform_accounts(db), subscriptions=list_platform_subscriptions(db)) + finally: + db.close() + + +@router.post("/invoices/new") +def invoice_create(request: Request, csrf_token: str = Form(...), account_id: int = Form(...), subscription_id: str = Form(""), invoice_no: str = Form(...), invoice_date: str = Form(...), due_date: str = Form(""), billing_period_from: str = Form(""), billing_period_to: str = Form(""), tax_type: str = Form(...), charge_type: str = Form("SUBSCRIPTION"), description: str = Form(...), quantity: str = Form("1"), rate: str = Form("0"), discount_amount: str = Form("0"), gst_rate: str = Form("18"), notes: str = Form("")): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "platform_billing.create") + if response: + return response + validate_csrf(request, csrf_token) + invoice = create_platform_invoice( + db, + account_id=account_id, + subscription_id=int(subscription_id) if subscription_id else None, + invoice_no=invoice_no, + invoice_date=parse_date(invoice_date), + due_date=parse_date(due_date), + billing_period_from=parse_date(billing_period_from), + billing_period_to=parse_date(billing_period_to), + tax_type=tax_type, + line_items=[{"charge_type": charge_type, "description": description, "quantity": quantity, "rate": rate, "discount_amount": discount_amount, "gst_rate": gst_rate}], + notes=notes, + user_id=user.id, + ) + return RedirectResponse(url=f"/platform-billing/invoices/{invoice.id}", status_code=303) + finally: + db.close() + + +@router.get("/invoices/{invoice_id}") +def invoice_detail(request: Request, invoice_id: int): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "platform_billing.view") + if response: + return response + invoice = get_platform_invoice(db, invoice_id) + if not invoice: + return RedirectResponse(url="/platform-billing/invoices", status_code=303) + return _render(request, "modules/platform_billing/templates/platform_billing/invoices/detail.html", db, user, title=f"Platform Invoice {invoice.invoice_no}", invoice=invoice, can_post=_has_perm(db, user, "platform_billing.post"), can_record_payment=_has_perm(db, user, "platform_billing.payment.create")) + finally: + db.close() + + +@router.post("/invoices/{invoice_id}/post") +def invoice_post(request: Request, invoice_id: int, csrf_token: str = Form(...)): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "platform_billing.post") + if response: + return response + validate_csrf(request, csrf_token) + invoice = get_platform_invoice(db, invoice_id) + if invoice and invoice.status == "DRAFT": + post_platform_invoice(db, invoice, user.id) + return RedirectResponse(url=f"/platform-billing/invoices/{invoice_id}", status_code=303) + finally: + db.close() + + +@router.post("/invoices/{invoice_id}/payments") +def payment_create(request: Request, invoice_id: int, csrf_token: str = Form(...), amount: str = Form(...), mode: str = Form("Bank"), reference_no: str = Form(""), notes: str = Form("")): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "platform_billing.payment.create") + if response: + return response + validate_csrf(request, csrf_token) + invoice = get_platform_invoice(db, invoice_id) + if invoice: + record_platform_payment(db, invoice=invoice, amount=amount, mode=mode, reference_no=reference_no, notes=notes, user_id=user.id) + return RedirectResponse(url=f"/platform-billing/invoices/{invoice_id}", status_code=303) + finally: + db.close() diff --git a/app/modules/services/__init__.py b/app/modules/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/services/bulk_imports.py b/app/modules/services/bulk_imports.py new file mode 100644 index 0000000..2dbe595 --- /dev/null +++ b/app/modules/services/bulk_imports.py @@ -0,0 +1,1076 @@ +from __future__ import annotations + +from datetime import date, datetime +from io import BytesIO +from typing import Any + +from openpyxl import Workbook, load_workbook +from openpyxl.styles import Font +from sqlalchemy import or_, select +from sqlalchemy.orm import Session + +from app.modules.services.models import ClientServiceSubscription +from app.modules.clients.models import Client +from app.modules.core.iam.models import User +from app.modules.core.rbac.models import Role, UserRole +from app.modules.services.models import ( + FirmServiceSelection, + FirmServiceTaskTemplate, + ServiceCatalogue, + ServiceCategory, + ServiceDefaultTaskTemplate, + ServiceDueDateExtension, + ServiceDueDateRule, +) +from app.modules.services.services import normalize_code, normalize_engagement_type +from app.modules.services.client_services import assessment_year_from_financial_year, normalize_financial_year, review_partner_required_for_engagement +from app.modules.services.due_dates import apply_due_date_rule_to_subscription, create_due_date_extension + +TRUE_VALUES = {"1", "true", "yes", "y", "on"} +FALSE_VALUES = {"0", "false", "no", "n", "off"} + +CLIENT_ASSIGNMENT_COLUMNS = [ + "client_code", + "service_code", + "financial_year", + "engagement_type", + "assigned_partner_email", + "assigned_manager_email", + "assigned_staff_email", + "start_date", + "end_date", + "expiry_date", + "status", + "is_active", + "remarks", +] + +SERVICE_MASTER_COLUMNS = [ + "category_code", + "category_name", + "service_code", + "service_name", + "recurrence_type", + "engagement_type", + "sort_order", + "applicable_individual", + "applicable_proprietorship", + "applicable_partnership", + "applicable_llp", + "applicable_company", + "applicable_trust", + "applicable_society", + "is_active", + "is_client_requestable", + "is_consultant_requestable", + "description", +] + +DEFAULT_TASK_COLUMNS = [ + "service_code", + "sequence_no", + "task_name", + "default_role_name", + "is_mandatory", + "requires_review", + "is_active", + "description", +] + +FIRM_TASK_COLUMNS = [ + "service_code", + "sequence_no", + "task_name", + "default_role_name", + "is_mandatory", + "requires_review", + "is_active", + "description", +] + +DUE_DATE_RULE_COLUMNS = [ + "service_code", + "rule_name", + "period_type", + "due_year_basis", + "due_day", + "due_month", + "due_month_offset", + "days_offset_after_event", + "renewal_days_before_expiry", + "sort_order", + "is_active", + "remarks", +] + +DUE_DATE_EXTENSION_COLUMNS = [ + "service_code", + "rule_name", + "financial_year", + "assessment_year", + "period_label", + "extended_due_date", + "notification_reference", + "notification_date", + "remarks", +] + + +def _clean(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + +def _upper(value: Any) -> str: + return _clean(value).upper() + + +def _bool(value: Any, default: bool = False) -> bool: + if value is None or value == "": + return default + if isinstance(value, bool): + return value + txt = str(value).strip().lower() + if txt in TRUE_VALUES: + return True + if txt in FALSE_VALUES: + return False + return default + + +def _int(value: Any, default: int | None = None) -> int | None: + if value is None or value == "": + return default + try: + return int(value) + except Exception: + return default + + +def _date(value: Any) -> date | None: + if value in (None, ""): + return None + if isinstance(value, datetime): + return value.date() + if isinstance(value, date): + return value + txt = str(value).strip() + if not txt: + return None + for fmt in ("%Y-%m-%d", "%d-%m-%Y", "%d/%m/%Y"): + try: + return datetime.strptime(txt, fmt).date() + except Exception: + pass + return date.fromisoformat(txt) + + +def _headers(ws) -> dict[str, int]: + row = next(ws.iter_rows(min_row=1, max_row=1, values_only=True), []) + return {str(v).strip().lower(): idx for idx, v in enumerate(row) if v is not None and str(v).strip()} + + +def _cell(row: tuple[Any, ...], headers: dict[str, int], key: str) -> Any: + idx = headers.get(key.lower()) + if idx is None or idx >= len(row): + return None + return row[idx] + + +def _style_template(wb: Workbook) -> bytes: + for ws in wb.worksheets: + ws.freeze_panes = "A2" + for cell in ws[1]: + cell.font = Font(bold=True) + for col in ws.columns: + col_letter = col[0].column_letter + max_len = max(len(str(c.value or "")) for c in col) + ws.column_dimensions[col_letter].width = min(max(max_len + 2, 14), 42) + out = BytesIO() + wb.save(out) + return out.getvalue() + + +def build_template(template_type: str) -> bytes: + wb = Workbook() + ws = wb.active + + if template_type == "client_service_assignments": + ws.title = "client_service_assignments" + ws.append(CLIENT_ASSIGNMENT_COLUMNS) + ws.append([ + "CLT-001", + "GST-MONTHLY", + "2025-26", + "", + "partner@example.com", + "manager@example.com", + "staff@example.com", + "2026-04-01", + "", + "", + "active", + "TRUE", + "Monthly GST compliance assignment", + ]) + notes = wb.create_sheet("instructions") + notes.append(["Column", "Instruction"]) + notes.append(["client_code", "Client must belong to the active firm."]) + notes.append(["service_code", "Service must be enabled for the active firm."]) + notes.append(["financial_year", "Use format like 2025-26. If blank, current FY is used."]) + notes.append(["engagement_type", "Optional. Leave blank to copy from service catalogue. Allowed: assurance, non_assurance. Existing unlocked engagements can be updated."]) + notes.append(["assigned_*_email", "Optional, but if provided must be an active user in the same firm."]) + notes.append(["expiry_date", "Optional. Required for renewal-before-expiry services such as DSC renewal. Due date = expiry_date - renewal_days_before_expiry configured in due_date_rules."]) + notes.append(["Partner users", "If logged in as Partner/own-only role, assigned_partner_email must be your email and client must be your client."]) + + elif template_type == "service_master": + ws.title = "service_master" + ws.append(SERVICE_MASTER_COLUMNS) + ws.append([ + "GST", + "GST", + "GST-MONTHLY", + "GST Monthly Return Filing", + "monthly", + "non_assurance", + 10, + "TRUE", + "TRUE", + "TRUE", + "TRUE", + "TRUE", + "FALSE", + "FALSE", + "TRUE", + "TRUE", + "FALSE", + "Monthly GST compliance service", + ]) + ws.append([ + "ROC", + "ROC / MCA Compliance", + "ROC-AOC4", + "AOC-4 Filing", + "annual", + "non_assurance", + 20, + "FALSE", + "FALSE", + "FALSE", + "TRUE", + "TRUE", + "FALSE", + "FALSE", + "TRUE", + "TRUE", + "FALSE", + "Annual ROC filing service. Category will be auto-created if missing.", + ]) + ws.append([ + "DIGITAL", + "Digital Signature / Certificates", + "DSC-RENEWAL", + "DSC Renewal", + "renewal", + "non_assurance", + 30, + "TRUE", + "TRUE", + "TRUE", + "TRUE", + "TRUE", + "TRUE", + "FALSE", + "TRUE", + "TRUE", + "TRUE", + "Before-expiry renewal service. Engagement expiry_date drives due date.", + ]) + ws_rules = wb.create_sheet("due_date_rules") + ws_rules.append(DUE_DATE_RULE_COLUMNS) + ws_rules.append([ + "GST-MONTHLY", + "Monthly 20th of next month", + "monthly", + "calendar_year", + 20, + "", + 1, + "", + "", + 10, + "TRUE", + "GSTR-3B style monthly due date rule", + ]) + ws_rules.append([ + "ROC-AOC4", + "30 days from AGM", + "event_based", + "calendar_year", + "", + "", + 0, + 30, + "", + 20, + "TRUE", + "Event-based rule foundation; actual event date handling can be added later.", + ]) + ws_rules.append([ + "DSC-RENEWAL", + "Renew 30 days before expiry", + "renewal_based", + "calendar_year", + "", + "", + 0, + "", + 30, + 30, + "TRUE", + "For DSC/FSSAI/licence renewal: engagement expiry_date minus 30 days.", + ]) + + ws_ext = wb.create_sheet("due_date_extensions") + ws_ext.append(DUE_DATE_EXTENSION_COLUMNS) + ws_ext.append([ + "GST-MONTHLY", + "Monthly 20th of next month", + "2025-26", + "2026-27", + "Apr", + "2025-05-25", + "GST notification / advisory reference", + "2025-05-18", + "Example extension row. Delete if not required.", + ]) + + notes = wb.create_sheet("instructions") + notes.append(["Column", "Instruction"]) + notes.append(["category_code", "Optional but recommended. If category_code/category_name does not exist, System Admin import auto-creates the category."]) + notes.append(["category_name", "Optional if category_code is given. If blank and category is new, category_code is used as name."]) + notes.append(["service_code", "Required. Unique service catalogue code. Used by due_date_rules and due_date_extensions sheets."]) + notes.append(["service_name", "Required. Display name of service."]) + notes.append(["recurrence_type", "Optional values like monthly, quarterly, annual, one_time."]) + notes.append(["engagement_type", "Allowed: assurance, non_assurance. Old values audit/non_audit are also normalized."]) + notes.append(["due_date_rules", "Optional sheet. One service can have multiple rows/rules. Existing rules are matched by service_code + rule_name."]) + notes.append(["due_date_extensions", "Optional sheet. Imports extension history for the active tenant/firm and updates matching unlocked engagements."]) + notes.append(["Multiple extensions", "Upload a later extended_due_date as another row. The system stores sequence/history and keeps original due date intact."]) + notes.append(["Duplicate protection", "If same service/rule/FY/AY/period/extended_due_date/notification_reference already exists, upload updates/skips instead of creating another duplicate."]) + notes.append(["TRUE/FALSE columns", "Use TRUE/FALSE, Yes/No, 1/0."]) + + elif template_type == "due_date_extensions": + ws.title = "due_date_extensions" + ws.append(DUE_DATE_EXTENSION_COLUMNS) + ws.append([ + "GST-MONTHLY", + "Monthly 20th of next month", + "2025-26", + "2026-27", + "Apr", + "2025-05-25", + "GST notification / advisory reference", + "2025-05-18", + "Example extension row. Keep one row per extension notification.", + ]) + notes = wb.create_sheet("instructions") + notes.append(["Column", "Instruction"]) + notes.append(["service_code", "Required. Existing service catalogue code."]) + notes.append(["rule_name", "Optional but recommended. If blank, the active due date rule of the service is used."]) + notes.append(["financial_year", "Required. Format 2025-26."]) + notes.append(["assessment_year", "Optional. If blank, derived from financial_year."]) + notes.append(["period_label", "Optional. Use Apr/May/Q1/Q2 etc. for monthly/quarterly rules."]) + notes.append(["extended_due_date", "Required. Use YYYY-MM-DD."]) + notes.append(["notification_reference", "Recommended. Circular/order/advisory reference."]) + notes.append(["notification_date", "Optional. Use YYYY-MM-DD."]) + notes.append(["Multiple extensions", "Every new extended_due_date is stored as a fresh sequence. Existing duplicate rows are not duplicated."]) + + elif template_type == "system_default_tasks": + ws.title = "system_default_tasks" + ws.append(DEFAULT_TASK_COLUMNS) + ws.append(["GST-MONTHLY", 1, "Collect data", "Staff", "TRUE", "FALSE", "TRUE", "Collect sales/purchase data"]) + ws.append(["GST-MONTHLY", 2, "Review and file", "Manager", "TRUE", "TRUE", "TRUE", "Review and file return"]) + + elif template_type == "firm_task_templates": + ws.title = "firm_task_templates" + ws.append(FIRM_TASK_COLUMNS) + ws.append(["GST-MONTHLY", 1, "Collect data", "Staff", "TRUE", "FALSE", "TRUE", "Firm-specific data collection task"]) + ws.append(["GST-MONTHLY", 2, "Partner review", "Partner", "TRUE", "TRUE", "TRUE", "Firm-specific review task"]) + + else: + raise ValueError("Unknown template type") + + return _style_template(wb) + + +def _load_sheet(file_bytes: bytes, expected_sheet: str): + wb = load_workbook(BytesIO(file_bytes), data_only=True) + if expected_sheet in wb.sheetnames: + ws = wb[expected_sheet] + else: + ws = wb[wb.sheetnames[0]] + return ws, _headers(ws) + + +def _validate_headers(headers: dict[str, int], required: list[str]) -> list[str]: + return [h for h in required if h not in headers] + + +def get_user_by_email(db: Session, *, tenant_id: int, email: str) -> User | None: + if not email: + return None + return db.execute( + select(User).where( + User.tenant_id == tenant_id, + User.email == email.strip().lower(), + User.is_active.is_(True), + ) + ).scalar_one_or_none() + + +def user_has_role(db: Session, *, user_id: int, role_names: set[str]) -> bool: + return db.execute( + select(User.id) + .join(UserRole, UserRole.user_id == User.id) + .join(Role, Role.id == UserRole.role_id) + .where(User.id == user_id, Role.name.in_(role_names)) + ).first() is not None + + +def find_client(db: Session, *, tenant_id: int, client_code: str) -> Client | None: + return db.execute( + select(Client).where( + Client.tenant_id == tenant_id, + Client.client_code == client_code, + Client.is_active.is_(True), + ) + ).scalar_one_or_none() + + +def get_or_create_service_category( + db: Session, + *, + category_code: str | None = None, + category_name: str | None = None, +) -> ServiceCategory | None: + """Return existing service category or auto-create it during System Admin service import. + + Matching is forgiving: category_code first, then category_name. If neither + exists, a new active category is created. This allows one-step service + master upload without a separate category import. + """ + clean_name = _clean(category_name) + clean_code = normalize_code(category_code) if category_code else "" + + if not clean_code and clean_name: + clean_code = normalize_code(clean_name) + if not clean_name and clean_code: + clean_name = clean_code + if not clean_code and not clean_name: + return None + + category = None + if clean_code: + category = db.execute( + select(ServiceCategory).where(ServiceCategory.code == clean_code) + ).scalar_one_or_none() + + if not category and clean_name: + category = db.execute( + select(ServiceCategory).where(ServiceCategory.name == clean_name) + ).scalar_one_or_none() + + if category: + if clean_name and category.name != clean_name: + category.name = clean_name + if hasattr(category, "is_active"): + category.is_active = True + return category + + category = ServiceCategory( + code=clean_code, + name=clean_name, + sort_order=100, + is_active=True, + ) + db.add(category) + db.flush() + return category + +def find_service(db: Session, *, service_code: str) -> ServiceCatalogue | None: + return db.execute( + select(ServiceCatalogue).where(ServiceCatalogue.service_code == service_code) + ).scalar_one_or_none() + + +def find_enabled_service(db: Session, *, tenant_id: int, service_code: str): + return db.execute( + select(FirmServiceSelection, ServiceCatalogue) + .join(ServiceCatalogue, ServiceCatalogue.id == FirmServiceSelection.service_catalogue_id) + .where( + FirmServiceSelection.tenant_id == tenant_id, + FirmServiceSelection.is_enabled.is_(True), + ServiceCatalogue.service_code == service_code, + ) + ).first() + + + +def find_due_date_rule( + db: Session, + *, + service_catalogue_id: int, + rule_name: str | None, +) -> ServiceDueDateRule | None: + query = select(ServiceDueDateRule).where(ServiceDueDateRule.service_catalogue_id == service_catalogue_id) + if rule_name: + query = query.where(ServiceDueDateRule.rule_name == rule_name.strip()) + else: + query = query.where(ServiceDueDateRule.is_active.is_(True)).order_by(ServiceDueDateRule.sort_order.asc(), ServiceDueDateRule.id.asc()) + return db.execute(query).scalars().first() + return db.execute(query).scalar_one_or_none() + + +def _workbook_sheet(file_bytes: bytes, sheet_name: str): + wb = load_workbook(BytesIO(file_bytes), data_only=True) + if sheet_name not in wb.sheetnames: + return None, {} + ws = wb[sheet_name] + return ws, _headers(ws) + + +def _extension_duplicate( + db: Session, + *, + tenant_id: int, + catalogue_id: int, + rule_id: int | None, + financial_year: str, + assessment_year: str | None, + period_label: str | None, + extended_due_date: date, + notification_reference: str | None, +) -> ServiceDueDateExtension | None: + query = select(ServiceDueDateExtension).where( + ServiceDueDateExtension.tenant_id == tenant_id, + ServiceDueDateExtension.service_catalogue_id == catalogue_id, + ServiceDueDateExtension.financial_year == financial_year, + ServiceDueDateExtension.extended_due_date == extended_due_date, + ) + if rule_id: + query = query.where(ServiceDueDateExtension.due_date_rule_id == rule_id) + else: + query = query.where(ServiceDueDateExtension.due_date_rule_id.is_(None)) + if assessment_year: + query = query.where(ServiceDueDateExtension.assessment_year == assessment_year) + else: + query = query.where((ServiceDueDateExtension.assessment_year.is_(None)) | (ServiceDueDateExtension.assessment_year == "")) + if period_label: + query = query.where(ServiceDueDateExtension.period_label == period_label) + else: + query = query.where((ServiceDueDateExtension.period_label.is_(None)) | (ServiceDueDateExtension.period_label == "")) + if notification_reference: + query = query.where(ServiceDueDateExtension.notification_reference == notification_reference) + return db.execute(query.order_by(ServiceDueDateExtension.id.desc())).scalars().first() + + +def _import_due_date_rules_from_sheet( + db: Session, + *, + current_user, + ws, + headers: dict[str, int], + update_existing: bool, +) -> tuple[int, int, int, list[dict]]: + missing = _validate_headers(headers, ["service_code", "rule_name"]) + if missing: + return 0, 0, 0, [{"row": 1, "message": f"Due date rules sheet missing columns: {', '.join(missing)}"}] + + created = updated = skipped = 0 + errors: list[dict] = [] + for row_no, row in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2): + if not any(v not in (None, "") for v in row): + continue + try: + service_code = normalize_code(_cell(row, headers, "service_code")) + rule_name = _clean(_cell(row, headers, "rule_name")) + if not service_code or not rule_name: + raise ValueError("service_code and rule_name are required.") + catalogue = find_service(db, service_code=service_code) + if not catalogue: + raise ValueError("Service code not found in service catalogue.") + + rule = find_due_date_rule(db, service_catalogue_id=catalogue.id, rule_name=rule_name) + if rule and not update_existing: + skipped += 1 + continue + if rule: + updated += 1 + else: + rule = ServiceDueDateRule(service_catalogue_id=catalogue.id, rule_name=rule_name, created_by_user_id=current_user.id) + db.add(rule) + created += 1 + + rule.rule_name = rule_name + rule.period_type = (_clean(_cell(row, headers, "period_type")) or "yearly").lower() + rule.due_year_basis = _clean(_cell(row, headers, "due_year_basis")) or "assessment_year_start" + rule.due_day = _int(_cell(row, headers, "due_day"), None) + rule.due_month = _int(_cell(row, headers, "due_month"), None) + rule.due_month_offset = _int(_cell(row, headers, "due_month_offset"), 0) or 0 + rule.days_offset_after_event = _int(_cell(row, headers, "days_offset_after_event"), None) + rule.renewal_days_before_expiry = _int(_cell(row, headers, "renewal_days_before_expiry"), None) + rule.sort_order = _int(_cell(row, headers, "sort_order"), 100) or 100 + rule.is_active = _bool(_cell(row, headers, "is_active"), True) + rule.remarks = _clean(_cell(row, headers, "remarks")) or None + rule.updated_by_user_id = current_user.id + except Exception as exc: + errors.append({"row": row_no, "message": str(exc)}) + return created, updated, skipped, errors + + +def _import_due_date_extensions_from_sheet( + db: Session, + *, + current_user, + tenant_id: int, + ws, + headers: dict[str, int], + update_existing: bool, +) -> tuple[int, int, int, list[dict]]: + missing = _validate_headers(headers, ["service_code", "financial_year", "extended_due_date"]) + if missing: + return 0, 0, 0, [{"row": 1, "message": f"Due date extensions sheet missing columns: {', '.join(missing)}"}] + + created = updated = skipped = 0 + errors: list[dict] = [] + for row_no, row in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2): + if not any(v not in (None, "") for v in row): + continue + try: + service_code = normalize_code(_cell(row, headers, "service_code")) + financial_year = normalize_financial_year(_cell(row, headers, "financial_year")) + assessment_year = _clean(_cell(row, headers, "assessment_year")) or assessment_year_from_financial_year(financial_year) + period_label = _clean(_cell(row, headers, "period_label")) or None + extended_due_date = _date(_cell(row, headers, "extended_due_date")) + notification_reference = _clean(_cell(row, headers, "notification_reference")) or None + notification_date = _date(_cell(row, headers, "notification_date")) + remarks = _clean(_cell(row, headers, "remarks")) or None + if not service_code: + raise ValueError("service_code is required.") + if not financial_year: + raise ValueError("financial_year is required.") + if not extended_due_date: + raise ValueError("extended_due_date is required and must be a valid date.") + catalogue = find_service(db, service_code=service_code) + if not catalogue: + raise ValueError("Service code not found in service catalogue.") + rule_name = _clean(_cell(row, headers, "rule_name")) or None + rule = find_due_date_rule(db, service_catalogue_id=catalogue.id, rule_name=rule_name) + if rule_name and not rule: + raise ValueError("rule_name not found for this service.") + + duplicate = _extension_duplicate( + db, + tenant_id=tenant_id, + catalogue_id=catalogue.id, + rule_id=rule.id if rule else None, + financial_year=financial_year, + assessment_year=assessment_year, + period_label=period_label, + extended_due_date=extended_due_date, + notification_reference=notification_reference, + ) + if duplicate: + if not update_existing: + skipped += 1 + continue + duplicate.notification_date = notification_date + duplicate.remarks = remarks + duplicate.updated_by_user_id = current_user.id + updated += 1 + continue + + create_due_date_extension( + db, + tenant_id=tenant_id, + catalogue_id=catalogue.id, + due_date_rule_id=rule.id if rule else None, + financial_year=financial_year, + assessment_year=assessment_year, + period_label=period_label, + extended_due_date=extended_due_date, + notification_reference=notification_reference, + notification_date=notification_date, + remarks=remarks, + user_id=current_user.id, + ) + created += 1 + except Exception as exc: + errors.append({"row": row_no, "message": str(exc)}) + return created, updated, skipped, errors + +def import_client_service_assignments( + db: Session, + *, + current_user, + tenant_id: int, + locked_partner_id: int | None, + file_bytes: bytes, + update_existing: bool = True, +) -> dict: + ws, headers = _load_sheet(file_bytes, "client_service_assignments") + missing = _validate_headers(headers, ["client_code", "service_code"]) + if missing: + return {"created": 0, "updated": 0, "skipped": 0, "errors": [{"row": 1, "message": f"Missing columns: {', '.join(missing)}"}]} + + created = updated = skipped = 0 + errors: list[dict] = [] + + for row_no, row in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2): + if not any(v not in (None, "") for v in row): + continue + try: + client_code = _clean(_cell(row, headers, "client_code")) + service_code = normalize_code(_cell(row, headers, "service_code")) + financial_year = normalize_financial_year(_cell(row, headers, "financial_year")) + client = find_client(db, tenant_id=tenant_id, client_code=client_code) + enabled = find_enabled_service(db, tenant_id=tenant_id, service_code=service_code) + if not client: + raise ValueError("Client not found in active firm.") + if locked_partner_id and int(client.partner_id or 0) != int(locked_partner_id): + raise ValueError("Partner can assign services only to own clients.") + if not enabled: + raise ValueError("Service is not enabled for this firm.") + firm_selection, catalogue = enabled + + partner_email = _clean(_cell(row, headers, "assigned_partner_email")) + manager_email = _clean(_cell(row, headers, "assigned_manager_email")) + staff_email = _clean(_cell(row, headers, "assigned_staff_email")) + partner = get_user_by_email(db, tenant_id=tenant_id, email=partner_email) if partner_email else None + manager = get_user_by_email(db, tenant_id=tenant_id, email=manager_email) if manager_email else None + staff = get_user_by_email(db, tenant_id=tenant_id, email=staff_email) if staff_email else None + + if locked_partner_id: + if partner_email and (not partner or int(partner.id) != int(locked_partner_id)): + raise ValueError("Partner upload must assign the partner field to the logged-in partner.") + partner = db.get(User, locked_partner_id) + if partner_email and not partner: + raise ValueError("Assigned partner email not found in active firm.") + if manager_email and not manager: + raise ValueError("Assigned manager email not found in active firm.") + if staff_email and not staff: + raise ValueError("Assigned staff email not found in active firm.") + + status = _clean(_cell(row, headers, "status")) or "active" + is_active = _bool(_cell(row, headers, "is_active"), status == "active") + existing = db.execute( + select(ClientServiceSubscription).where( + ClientServiceSubscription.tenant_id == tenant_id, + ClientServiceSubscription.client_id == client.id, + ClientServiceSubscription.service_catalogue_id == catalogue.id, + ClientServiceSubscription.financial_year == financial_year, + ) + ).scalar_one_or_none() + + if existing and not update_existing: + skipped += 1 + continue + if existing: + sub = existing + updated += 1 + else: + sub = ClientServiceSubscription( + tenant_id=tenant_id, + client_id=client.id, + service_catalogue_id=catalogue.id, + financial_year=financial_year, + assessment_year=assessment_year_from_financial_year(financial_year), + created_by_user_id=current_user.id, + ) + db.add(sub) + created += 1 + + if getattr(sub, "is_locked", False): + raise ValueError("Existing engagement for this client/service/year is locked and cannot be updated.") + sub.financial_year = financial_year + sub.assessment_year = assessment_year_from_financial_year(financial_year) + imported_engagement_type = _clean(_cell(row, headers, "engagement_type")) + sub.engagement_type = normalize_engagement_type(imported_engagement_type) if imported_engagement_type else (getattr(catalogue, "engagement_type", "non_assurance") or "non_assurance") + sub.branch_id = client.branch_id + sub.firm_service_selection_id = firm_selection.id + sub.assigned_partner_user_id = partner.id if partner else (client.partner_id or None) + sub.assigned_manager_user_id = manager.id if manager else None + sub.assigned_staff_user_id = staff.id if staff else None + sub.review_partner_user_id = client.default_review_partner_user_id if review_partner_required_for_engagement(db, tenant_id=tenant_id, engagement_type=sub.engagement_type) else None + sub.start_date = _date(_cell(row, headers, "start_date")) + sub.end_date = _date(_cell(row, headers, "end_date")) + sub.expiry_date = _date(_cell(row, headers, "expiry_date")) + sub.status = status + sub.is_active = is_active + sub.remarks = _clean(_cell(row, headers, "remarks")) or None + sub.updated_by_user_id = current_user.id + apply_due_date_rule_to_subscription(db, sub) + except Exception as exc: + errors.append({"row": row_no, "message": str(exc)}) + + if not errors: + db.commit() + else: + db.rollback() + return {"created": created if not errors else 0, "updated": updated if not errors else 0, "skipped": skipped, "errors": errors} + + +def import_service_master( + db: Session, + *, + current_user, + file_bytes: bytes, + update_existing: bool = True, + tenant_id: int | None = None, +) -> dict: + wb = load_workbook(BytesIO(file_bytes), data_only=True) + if "service_master" in wb.sheetnames: + ws = wb["service_master"] + else: + ws = wb[wb.sheetnames[0]] + headers = _headers(ws) + missing = _validate_headers(headers, ["service_code", "service_name"]) + if missing: + return {"created": 0, "updated": 0, "skipped": 0, "errors": [{"row": 1, "message": f"Missing columns: {', '.join(missing)}"}]} + + created = updated = skipped = 0 + errors: list[dict] = [] + for row_no, row in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2): + if not any(v not in (None, "") for v in row): + continue + try: + service_code = normalize_code(_cell(row, headers, "service_code")) + service_name = _clean(_cell(row, headers, "service_name")) + if not service_code or not service_name: + raise ValueError("service_code and service_name are required.") + + category_id = None + category_name = _clean(_cell(row, headers, "category_name")) + category_code = _clean(_cell(row, headers, "category_code")) + category = get_or_create_service_category( + db, + category_code=category_code, + category_name=category_name, + ) + if category: + category_id = category.id + category_name = category.name + + catalogue = db.execute(select(ServiceCatalogue).where(ServiceCatalogue.service_code == service_code)).scalar_one_or_none() + if catalogue and not update_existing: + skipped += 1 + continue + if catalogue: + updated += 1 + else: + catalogue = ServiceCatalogue(service_code=service_code, service_name=service_name, created_by_user_id=current_user.id) + db.add(catalogue) + created += 1 + + catalogue.service_name = service_name + if hasattr(catalogue, "category_id"): + catalogue.category_id = category_id + catalogue.category = category_name or None + if hasattr(catalogue, "recurrence_type"): + catalogue.recurrence_type = _clean(_cell(row, headers, "recurrence_type")) or None + if hasattr(catalogue, "engagement_type"): + catalogue.engagement_type = normalize_engagement_type(_cell(row, headers, "engagement_type")) + if hasattr(catalogue, "sort_order"): + catalogue.sort_order = _int(_cell(row, headers, "sort_order"), 100) or 100 + catalogue.description = _clean(_cell(row, headers, "description")) or None + for flag in [ + "applicable_individual", "applicable_proprietorship", "applicable_partnership", "applicable_llp", + "applicable_company", "applicable_trust", "applicable_society", + "is_active", "is_client_requestable", "is_consultant_requestable", + ]: + if hasattr(catalogue, flag) and flag in headers: + setattr(catalogue, flag, _bool(_cell(row, headers, flag), getattr(catalogue, flag, False))) + catalogue.updated_by_user_id = current_user.id + except Exception as exc: + errors.append({"row": row_no, "message": str(exc)}) + + rule_created = rule_updated = rule_skipped = 0 + ext_created = ext_updated = ext_skipped = 0 + if not errors and "due_date_rules" in wb.sheetnames: + r_ws = wb["due_date_rules"] + c, u, sk, rule_errors = _import_due_date_rules_from_sheet( + db, + current_user=current_user, + ws=r_ws, + headers=_headers(r_ws), + update_existing=update_existing, + ) + rule_created, rule_updated, rule_skipped = c, u, sk + errors.extend(rule_errors) + + if not errors and "due_date_extensions" in wb.sheetnames: + if not tenant_id: + errors.append({"row": 1, "message": "due_date_extensions sheet requires an active tenant/firm context."}) + else: + e_ws = wb["due_date_extensions"] + c, u, sk, ext_errors = _import_due_date_extensions_from_sheet( + db, + current_user=current_user, + tenant_id=tenant_id, + ws=e_ws, + headers=_headers(e_ws), + update_existing=update_existing, + ) + ext_created, ext_updated, ext_skipped = c, u, sk + errors.extend(ext_errors) + + if not errors: + db.commit() + else: + db.rollback() + return { + "created": (created + rule_created + ext_created) if not errors else 0, + "updated": (updated + rule_updated + ext_updated) if not errors else 0, + "skipped": skipped + rule_skipped + ext_skipped, + "errors": errors, + "breakdown": { + "services_created": created, + "services_updated": updated, + "due_rules_created": rule_created, + "due_rules_updated": rule_updated, + "due_extensions_created": ext_created, + "due_extensions_updated": ext_updated, + }, + } + + +def import_due_date_extensions( + db: Session, + *, + current_user, + tenant_id: int, + file_bytes: bytes, + update_existing: bool = True, +) -> dict: + ws, headers = _load_sheet(file_bytes, "due_date_extensions") + created, updated, skipped, errors = _import_due_date_extensions_from_sheet( + db, + current_user=current_user, + tenant_id=tenant_id, + ws=ws, + headers=headers, + update_existing=update_existing, + ) + if not errors: + db.commit() + else: + db.rollback() + return {"created": created if not errors else 0, "updated": updated if not errors else 0, "skipped": skipped, "errors": errors} + + +def import_system_default_tasks(db: Session, *, current_user, file_bytes: bytes, update_existing: bool = True) -> dict: + ws, headers = _load_sheet(file_bytes, "system_default_tasks") + missing = _validate_headers(headers, ["service_code", "sequence_no", "task_name"]) + if missing: + return {"created": 0, "updated": 0, "skipped": 0, "errors": [{"row": 1, "message": f"Missing columns: {', '.join(missing)}"}]} + created = updated = skipped = 0 + errors: list[dict] = [] + for row_no, row in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2): + if not any(v not in (None, "") for v in row): + continue + try: + service_code = normalize_code(_cell(row, headers, "service_code")) + sequence_no = _int(_cell(row, headers, "sequence_no"), None) + task_name = _clean(_cell(row, headers, "task_name")) + catalogue = find_service(db, service_code=service_code) + if not catalogue: + raise ValueError("Service code not found in service catalogue.") + if not sequence_no or not task_name: + raise ValueError("sequence_no and task_name are required.") + task = db.execute( + select(ServiceDefaultTaskTemplate).where( + ServiceDefaultTaskTemplate.service_catalogue_id == catalogue.id, + ServiceDefaultTaskTemplate.sequence_no == sequence_no, + ) + ).scalar_one_or_none() + if task and not update_existing: + skipped += 1 + continue + if task: + updated += 1 + else: + task = ServiceDefaultTaskTemplate(service_catalogue_id=catalogue.id, sequence_no=sequence_no, task_name=task_name) + db.add(task) + created += 1 + task.task_name = task_name + task.description = _clean(_cell(row, headers, "description")) or None + task.default_role_name = _clean(_cell(row, headers, "default_role_name")) or None + task.is_mandatory = _bool(_cell(row, headers, "is_mandatory"), True) + task.requires_review = _bool(_cell(row, headers, "requires_review"), False) + task.is_active = _bool(_cell(row, headers, "is_active"), True) + except Exception as exc: + errors.append({"row": row_no, "message": str(exc)}) + if not errors: + db.commit() + else: + db.rollback() + return {"created": created if not errors else 0, "updated": updated if not errors else 0, "skipped": skipped, "errors": errors} + + +def import_firm_task_templates(db: Session, *, current_user, tenant_id: int, file_bytes: bytes, update_existing: bool = True) -> dict: + ws, headers = _load_sheet(file_bytes, "firm_task_templates") + missing = _validate_headers(headers, ["service_code", "sequence_no", "task_name"]) + if missing: + return {"created": 0, "updated": 0, "skipped": 0, "errors": [{"row": 1, "message": f"Missing columns: {', '.join(missing)}"}]} + created = updated = skipped = 0 + errors: list[dict] = [] + for row_no, row in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2): + if not any(v not in (None, "") for v in row): + continue + try: + service_code = normalize_code(_cell(row, headers, "service_code")) + sequence_no = _int(_cell(row, headers, "sequence_no"), None) + task_name = _clean(_cell(row, headers, "task_name")) + enabled = find_enabled_service(db, tenant_id=tenant_id, service_code=service_code) + if not enabled: + raise ValueError("Service is not enabled for this firm.") + firm_selection, catalogue = enabled + if not sequence_no or not task_name: + raise ValueError("sequence_no and task_name are required.") + task = db.execute( + select(FirmServiceTaskTemplate).where( + FirmServiceTaskTemplate.tenant_id == tenant_id, + FirmServiceTaskTemplate.service_catalogue_id == catalogue.id, + FirmServiceTaskTemplate.sequence_no == sequence_no, + ) + ).scalar_one_or_none() + if task and not update_existing: + skipped += 1 + continue + if task: + updated += 1 + else: + task = FirmServiceTaskTemplate( + tenant_id=tenant_id, + service_catalogue_id=catalogue.id, + sequence_no=sequence_no, + task_name=task_name, + created_by_user_id=current_user.id, + ) + db.add(task) + created += 1 + task.task_name = task_name + task.description = _clean(_cell(row, headers, "description")) or None + task.default_role_name = _clean(_cell(row, headers, "default_role_name")) or None + task.is_mandatory = _bool(_cell(row, headers, "is_mandatory"), True) + task.requires_review = _bool(_cell(row, headers, "requires_review"), False) + task.is_active = _bool(_cell(row, headers, "is_active"), True) + task.updated_by_user_id = current_user.id + except Exception as exc: + errors.append({"row": row_no, "message": str(exc)}) + if not errors: + db.commit() + else: + db.rollback() + return {"created": created if not errors else 0, "updated": updated if not errors else 0, "skipped": skipped, "errors": errors} diff --git a/app/modules/services/client_services.py b/app/modules/services/client_services.py new file mode 100644 index 0000000..3179cb3 --- /dev/null +++ b/app/modules/services/client_services.py @@ -0,0 +1,206 @@ +from __future__ import annotations + +from datetime import date + +from sqlalchemy import or_, select +from sqlalchemy.orm import Session, selectinload + +from app.modules.clients.models import Client +from app.modules.core.iam.models import User +from app.modules.core.rbac.models import Role, UserRole +from app.modules.core.tenancy.models import Tenant +from app.modules.services.models import ClientServiceSubscription, FirmServiceSelection, ServiceCatalogue + +SUBSCRIPTION_STATUSES = [ + ("draft", "Draft"), + ("active", "Active"), + ("on_hold", "On Hold"), + ("completed", "Completed"), + ("cancelled", "Cancelled"), + ("inactive", "Inactive"), +] + +ASSIGNMENT_ROLE_NAMES = ("Partner", "Branch Manager", "Staff") + + +def current_financial_year(today: date | None = None) -> str: + today = today or date.today() + if today.month >= 4: + start = today.year + else: + start = today.year - 1 + return f"{start}-{str(start + 1)[-2:]}" + + +def assessment_year_from_financial_year(financial_year: str | None) -> str | None: + if not financial_year or "-" not in financial_year: + return None + start = int(str(financial_year).split("-")[0]) + return f"{start + 1}-{str(start + 2)[-2:]}" + + +def normalize_financial_year(value: str | None) -> str: + value = (value or "").strip() + return value or current_financial_year() + + +def parse_date(value: str | None) -> date | None: + if not value: + return None + value = value.strip() + if not value: + return None + return date.fromisoformat(value) + + +def list_subscription_payload( + db: Session, + *, + tenant_id: int, + branch_id: int | None = None, + financial_year: str | None = None, + q: str = "", + include_inactive: bool = True, +): + fy = normalize_financial_year(financial_year) + query = ( + select(ClientServiceSubscription) + .options( + selectinload(ClientServiceSubscription.client), + selectinload(ClientServiceSubscription.catalogue), + selectinload(ClientServiceSubscription.assigned_partner), + selectinload(ClientServiceSubscription.assigned_manager), + selectinload(ClientServiceSubscription.assigned_staff), + selectinload(ClientServiceSubscription.review_partner), + selectinload(ClientServiceSubscription.due_date_rule), + ) + .where( + ClientServiceSubscription.tenant_id == tenant_id, + ClientServiceSubscription.financial_year == fy, + ) + ) + + if branch_id: + query = query.where(ClientServiceSubscription.branch_id == branch_id) + + if not include_inactive: + query = query.where(ClientServiceSubscription.is_active.is_(True)) + + if q.strip(): + term = f"%{q.strip()}%" + query = ( + query.join(Client, Client.id == ClientServiceSubscription.client_id) + .join(ServiceCatalogue, ServiceCatalogue.id == ClientServiceSubscription.service_catalogue_id) + .where( + or_( + Client.client_name.ilike(term), + Client.client_code.ilike(term), + ServiceCatalogue.service_code.ilike(term), + ServiceCatalogue.service_name.ilike(term), + ) + ) + ) + + return db.execute( + query.order_by( + ClientServiceSubscription.is_locked.asc(), + ClientServiceSubscription.is_active.desc(), + ClientServiceSubscription.id.desc(), + ) + ).scalars().all() + + +def get_subscription(db: Session, *, subscription_id: int, tenant_id: int) -> ClientServiceSubscription | None: + return db.execute( + select(ClientServiceSubscription) + .options( + selectinload(ClientServiceSubscription.client), + selectinload(ClientServiceSubscription.catalogue), + selectinload(ClientServiceSubscription.assigned_partner), + selectinload(ClientServiceSubscription.assigned_manager), + selectinload(ClientServiceSubscription.assigned_staff), + selectinload(ClientServiceSubscription.review_partner), + selectinload(ClientServiceSubscription.due_date_rule), + ) + .where( + ClientServiceSubscription.id == subscription_id, + ClientServiceSubscription.tenant_id == tenant_id, + ) + ).scalar_one_or_none() + + +def get_existing_subscription( + db: Session, + *, + tenant_id: int, + client_id: int, + service_catalogue_id: int, + financial_year: str | None = None, +) -> ClientServiceSubscription | None: + return db.execute( + select(ClientServiceSubscription).where( + ClientServiceSubscription.tenant_id == tenant_id, + ClientServiceSubscription.client_id == client_id, + ClientServiceSubscription.service_catalogue_id == service_catalogue_id, + ClientServiceSubscription.financial_year == normalize_financial_year(financial_year), + ) + ).scalar_one_or_none() + + +def list_clients_for_assignment(db: Session, *, tenant_id: int, branch_id: int | None = None, partner_id: int | None = None): + query = select(Client).where(Client.tenant_id == tenant_id) + if branch_id: + query = query.where(Client.branch_id == branch_id) + if partner_id: + query = query.where(Client.partner_id == partner_id) + return db.execute(query.order_by(Client.client_name.asc())).scalars().all() + + +def list_enabled_services_for_assignment(db: Session, *, tenant_id: int): + return db.execute( + select(FirmServiceSelection) + .join(ServiceCatalogue, ServiceCatalogue.id == FirmServiceSelection.service_catalogue_id) + .options(selectinload(FirmServiceSelection.catalogue)) + .where( + FirmServiceSelection.tenant_id == tenant_id, + FirmServiceSelection.is_enabled.is_(True), + ServiceCatalogue.is_active.is_(True), + ) + .order_by(ServiceCatalogue.sort_order.asc(), ServiceCatalogue.service_name.asc()) + ).scalars().all() + + +def get_enabled_firm_service(db: Session, *, tenant_id: int, service_catalogue_id: int) -> FirmServiceSelection | None: + return db.execute( + select(FirmServiceSelection).where( + FirmServiceSelection.tenant_id == tenant_id, + FirmServiceSelection.service_catalogue_id == service_catalogue_id, + FirmServiceSelection.is_enabled.is_(True), + ) + ).scalar_one_or_none() + + +def list_assignable_users(db: Session, *, tenant_id: int, branch_id: int | None = None, role_names: tuple[str, ...] = ASSIGNMENT_ROLE_NAMES): + query = ( + select(User) + .join(UserRole, UserRole.user_id == User.id) + .join(Role, Role.id == UserRole.role_id) + .where(User.tenant_id == tenant_id, User.is_active.is_(True), Role.name.in_(role_names)) + ) + if branch_id: + query = query.where(or_(User.branch_id == branch_id, User.branch_id.is_(None))) + return db.execute(query.order_by(User.full_name.asc(), User.email.asc()).distinct()).scalars().all() + + +def tenant_requires_review_partner(db: Session, *, tenant_id: int) -> bool: + tenant = db.get(Tenant, tenant_id) + firm_type = (getattr(tenant, "firm_type", None) or "partnership").strip().lower() if tenant else "partnership" + return firm_type == "partnership" + + +def review_partner_required_for_engagement(db: Session, *, tenant_id: int, engagement_type: str | None) -> bool: + return tenant_requires_review_partner(db, tenant_id=tenant_id) and (engagement_type or "").strip().lower() == "assurance" + + +def list_review_partners(db: Session, *, tenant_id: int, branch_id: int | None = None): + return list_assignable_users(db, tenant_id=tenant_id, branch_id=branch_id, role_names=("Partner",)) diff --git a/app/modules/services/client_services_ui_old.py b/app/modules/services/client_services_ui_old.py new file mode 100644 index 0000000..5cf1613 --- /dev/null +++ b/app/modules/services/client_services_ui_old.py @@ -0,0 +1,341 @@ +from __future__ import annotations + +from fastapi import APIRouter, Form, Request +from fastapi.responses import RedirectResponse + +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.services.models import ClientServiceSubscription +from app.modules.services.client_services import ( + SUBSCRIPTION_STATUSES, + get_enabled_firm_service, + get_existing_subscription, + get_subscription, + list_assignable_users, + list_clients_for_assignment, + list_enabled_services_for_assignment, + list_subscription_payload, + parse_date, +) +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="/services/client-services", tags=["services-client-services-ui"]) + + +def _base_ctx(request: Request, user, db, **ctx): + base = { + "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), + "subscription_statuses": SUBSCRIPTION_STATUSES, + } + base.update(ctx) + return base + + +def _render(request: Request, template: str, db, user, **ctx): + return templates.TemplateResponse(template, _base_ctx(request, user, db, **ctx)) + + +def _redirect_denied(): + return RedirectResponse(url="/system-settings", status_code=303) + + +def _has_perm(db, user, code: str) -> bool: + try: + require_permission(db, user, code) + return True + except Exception: + return False + + +def _active_tenant_id(request: Request, user) -> int: + return int( + request.session.get("active_tenant_id") + or request.session.get("selected_tenant_id") + or request.session.get("tenant_id") + or user.tenant_id + ) + + +def _active_branch_id(request: Request, user, db) -> int | None: + value = request.session.get("active_branch_id") + if value in (None, "", 0, "0"): + if _has_perm(db, user, "clients.cross_branch"): + return None + return int(getattr(user, "branch_id", 0) or 0) or None + return int(value) + + +def _locked_partner_id(db, user) -> int | None: + return int(user.id) if _has_perm(db, user, "clients.view.own_only") else None + + +def _can_manage_client_services(db, user) -> bool: + return _has_perm(db, user, "clients.edit") + + +@router.get("") +def subscription_list(request: Request, q: str = "", include_inactive: bool = True): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + try: + require_permission(db, user, "clients.view") + except Exception: + return _redirect_denied() + + tenant_id = _active_tenant_id(request, user) + branch_id = _active_branch_id(request, user, db) + rows = list_subscription_payload( + db, + tenant_id=tenant_id, + branch_id=branch_id, + q=q, + include_inactive=include_inactive, + ) + return _render( + request, + "modules/services/templates/services/client_services/list.html", + db, + user, + title="Client Service Subscriptions", + rows=rows, + q=q, + include_inactive=include_inactive, + can_manage=_can_manage_client_services(db, user), + ) + finally: + db.close() + + +@router.get("/new") +def subscription_create_page(request: Request, client_id: int | None = None): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + try: + require_permission(db, user, "clients.edit") + except Exception: + return _redirect_denied() + + tenant_id = _active_tenant_id(request, user) + branch_id = _active_branch_id(request, user, db) + clients = list_clients_for_assignment( + db, + tenant_id=tenant_id, + branch_id=branch_id, + partner_id=_locked_partner_id(db, user), + ) + enabled_services = list_enabled_services_for_assignment(db, tenant_id=tenant_id) + assignable_users = list_assignable_users(db, tenant_id=tenant_id, branch_id=branch_id) + + return _render( + request, + "modules/services/templates/services/client_services/form.html", + db, + user, + title="Assign Service to Client", + mode="create", + subscription=None, + clients=clients, + enabled_services=enabled_services, + assignable_users=assignable_users, + selected_client_id=client_id, + ) + finally: + db.close() + + +@router.post("/new") +def subscription_create_submit( + request: Request, + client_id: int = Form(...), + service_catalogue_id: int = Form(...), + assigned_partner_user_id: str = Form(""), + assigned_manager_user_id: str = Form(""), + assigned_staff_user_id: str = Form(""), + start_date: str = Form(""), + end_date: str = Form(""), + status: str = Form("active"), + remarks: str = Form(""), + is_active: str | None = Form(None), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + try: + require_permission(db, user, "clients.edit") + except Exception: + return _redirect_denied() + + tenant_id = _active_tenant_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 RedirectResponse(url="/services/client-services/new", status_code=303) + + existing = get_existing_subscription( + db, + tenant_id=tenant_id, + client_id=client_id, + service_catalogue_id=service_catalogue_id, + ) + if existing: + row = existing + else: + row = ClientServiceSubscription( + tenant_id=tenant_id, + client_id=client_id, + service_catalogue_id=service_catalogue_id, + created_by_user_id=user.id, + ) + db.add(row) + + row.branch_id = firm_selection.default_branch_id or getattr(user, "branch_id", None) + row.firm_service_selection_id = firm_selection.id + row.assigned_partner_user_id = int(assigned_partner_user_id) if assigned_partner_user_id.strip() else None + row.assigned_manager_user_id = int(assigned_manager_user_id) if assigned_manager_user_id.strip() else None + row.assigned_staff_user_id = int(assigned_staff_user_id) if assigned_staff_user_id.strip() else None + row.start_date = parse_date(start_date) + row.end_date = parse_date(end_date) + row.status = status or "active" + row.remarks = remarks.strip() or None + row.is_active = is_active is not None + row.updated_by_user_id = user.id + + db.commit() + db.refresh(row) + return RedirectResponse(url=f"/services/client-services/{row.id}", status_code=303) + finally: + db.close() + + +@router.get("/{subscription_id}") +def subscription_detail(request: Request, subscription_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + try: + require_permission(db, user, "clients.view") + except Exception: + return _redirect_denied() + + tenant_id = _active_tenant_id(request, user) + row = get_subscription(db, subscription_id=subscription_id, tenant_id=tenant_id) + if not row: + return RedirectResponse(url="/services/client-services", status_code=303) + + return _render( + request, + "modules/services/templates/services/client_services/detail.html", + db, + user, + title="Client Service Subscription", + row=row, + can_manage=_can_manage_client_services(db, user), + ) + finally: + db.close() + + +@router.get("/{subscription_id}/edit") +def subscription_edit_page(request: Request, subscription_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + try: + require_permission(db, user, "clients.edit") + except Exception: + return _redirect_denied() + + tenant_id = _active_tenant_id(request, user) + branch_id = _active_branch_id(request, user, db) + row = get_subscription(db, subscription_id=subscription_id, tenant_id=tenant_id) + if not row: + return RedirectResponse(url="/services/client-services", status_code=303) + + clients = list_clients_for_assignment( + db, + tenant_id=tenant_id, + branch_id=branch_id, + partner_id=_locked_partner_id(db, user), + ) + enabled_services = list_enabled_services_for_assignment(db, tenant_id=tenant_id) + assignable_users = list_assignable_users(db, tenant_id=tenant_id, branch_id=branch_id) + return _render( + request, + "modules/services/templates/services/client_services/form.html", + db, + user, + title="Edit Client Service Subscription", + mode="edit", + subscription=row, + clients=clients, + enabled_services=enabled_services, + assignable_users=assignable_users, + selected_client_id=row.client_id, + ) + finally: + db.close() + + +@router.post("/{subscription_id}/edit") +def subscription_edit_submit( + request: Request, + subscription_id: int, + assigned_partner_user_id: str = Form(""), + assigned_manager_user_id: str = Form(""), + assigned_staff_user_id: str = Form(""), + start_date: str = Form(""), + end_date: str = Form(""), + status: str = Form("active"), + remarks: str = Form(""), + is_active: str | None = Form(None), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + try: + require_permission(db, user, "clients.edit") + except Exception: + return _redirect_denied() + + tenant_id = _active_tenant_id(request, user) + row = get_subscription(db, subscription_id=subscription_id, tenant_id=tenant_id) + if not row: + return RedirectResponse(url="/services/client-services", status_code=303) + + row.assigned_partner_user_id = int(assigned_partner_user_id) if assigned_partner_user_id.strip() else None + row.assigned_manager_user_id = int(assigned_manager_user_id) if assigned_manager_user_id.strip() else None + row.assigned_staff_user_id = int(assigned_staff_user_id) if assigned_staff_user_id.strip() else None + row.start_date = parse_date(start_date) + row.end_date = parse_date(end_date) + row.status = status or "active" + row.remarks = remarks.strip() or None + row.is_active = is_active is not None + row.updated_by_user_id = user.id + db.commit() + return RedirectResponse(url=f"/services/client-services/{row.id}", status_code=303) + finally: + db.close() diff --git a/app/modules/services/due_dates.py b/app/modules/services/due_dates.py new file mode 100644 index 0000000..894c699 --- /dev/null +++ b/app/modules/services/due_dates.py @@ -0,0 +1,337 @@ +from __future__ import annotations + +import re +from datetime import date, datetime, timezone +from typing import Iterable + +from sqlalchemy import func, select +from sqlalchemy.orm import Session, selectinload + +from app.modules.services.models import ( + ClientServiceSubscription, + ServiceCatalogue, + ServiceDueDateExtension, + ServiceDueDateRule, +) + +DUE_PERIOD_TYPES = [ + ("yearly", "Yearly / Annual"), + ("monthly", "Monthly"), + ("quarterly", "Quarterly"), + ("one_time", "One Time"), + ("event_based", "Event Based"), + ("renewal_based", "Renewal Before Expiry"), + ("custom", "Custom / Manual"), +] + +DUE_YEAR_BASIS_CHOICES = [ + ("assessment_year_start", "Assessment year start year"), + ("financial_year_start", "Financial year start year"), + ("financial_year_end", "Financial year end year"), + ("calendar_year", "Calendar year from period"), +] + +DUE_DATE_SOURCE_RULE = "rule" +DUE_DATE_SOURCE_EXTENSION = "extension" +DUE_DATE_SOURCE_MANUAL = "manual" + + +def parse_optional_date(value: str | None) -> date | None: + value = (value or "").strip() + if not value: + return None + return date.fromisoformat(value) + + +def _parse_year_pair(value: str | None) -> tuple[int, int] | None: + value = (value or "").strip() + match = re.match(r"^(\d{4})\s*-\s*(\d{2}|\d{4})$", value) + if not match: + return None + start = int(match.group(1)) + end_raw = match.group(2) + end = int(end_raw) if len(end_raw) == 4 else int(str(start)[:2] + end_raw) + return start, end + + +def _month_add(year: int, month: int, offset: int) -> tuple[int, int]: + index = (year * 12 + (month - 1)) + int(offset or 0) + return index // 12, index % 12 + 1 + + +def _safe_date(year: int, month: int, day: int) -> date | None: + try: + return date(int(year), int(month), int(day)) + except Exception: + return None + + +def _period_month_from_label(financial_year: str | None, period_label: str | None) -> tuple[int, int] | None: + label = (period_label or "").strip().lower() + fy = _parse_year_pair(financial_year) + + iso_match = re.match(r"^(\d{4})[-/](\d{1,2})$", label) + if iso_match: + return int(iso_match.group(1)), int(iso_match.group(2)) + + month_names = { + "apr": 4, "april": 4, "may": 5, "jun": 6, "june": 6, "jul": 7, "july": 7, + "aug": 8, "august": 8, "sep": 9, "sept": 9, "september": 9, "oct": 10, "october": 10, + "nov": 11, "november": 11, "dec": 12, "december": 12, "jan": 1, "january": 1, + "feb": 2, "february": 2, "mar": 3, "march": 3, + } + if label in month_names and fy: + month = month_names[label] + year = fy[0] if month >= 4 else fy[1] + return year, month + return None + + +def _quarter_end_from_label(financial_year: str | None, period_label: str | None) -> tuple[int, int] | None: + label = (period_label or "").strip().lower().replace(" ", "") + fy = _parse_year_pair(financial_year) + if not fy: + return None + mapping = { + "q1": (fy[0], 6), "quarter1": (fy[0], 6), "apr-jun": (fy[0], 6), + "q2": (fy[0], 9), "quarter2": (fy[0], 9), "jul-sep": (fy[0], 9), + "q3": (fy[0], 12), "quarter3": (fy[0], 12), "oct-dec": (fy[0], 12), + "q4": (fy[1], 3), "quarter4": (fy[1], 3), "jan-mar": (fy[1], 3), + } + return mapping.get(label) + + +def calculate_due_date( + rule: ServiceDueDateRule | None, + *, + financial_year: str | None, + assessment_year: str | None = None, + period_label: str | None = None, + expiry_date: date | None = None, +) -> date | None: + """Calculate a statutory due date from a catalogue due-date rule. + + The function is intentionally conservative. If the rule needs a period label + and the engagement does not yet carry one, it returns None instead of + guessing. This preserves existing engagement creation behaviour. + """ + if not rule or not getattr(rule, "is_active", True): + return None + day = getattr(rule, "due_day", None) + + period_type = (getattr(rule, "period_type", None) or "yearly").strip().lower() + due_month = getattr(rule, "due_month", None) + month_offset = int(getattr(rule, "due_month_offset", None) or 0) + + if period_type in {"renewal_based", "before_expiry", "expiry_based"}: + if not expiry_date: + return None + days_before = int(getattr(rule, "renewal_days_before_expiry", None) or 0) + from datetime import timedelta + return expiry_date - timedelta(days=days_before) + + if not day: + return None + + if period_type in {"yearly", "one_time"}: + if not due_month: + return None + basis = (getattr(rule, "due_year_basis", None) or "assessment_year_start").strip().lower() + fy = _parse_year_pair(financial_year) + ay = _parse_year_pair(assessment_year) + if basis == "financial_year_start" and fy: + year = fy[0] + elif basis == "financial_year_end" and fy: + year = fy[1] + elif ay: + year = ay[0] + elif fy: + year = fy[1] + else: + return None + return _safe_date(year, int(due_month), int(day)) + + if period_type == "monthly": + period = _period_month_from_label(financial_year, period_label) + if not period: + return None + year, month = _month_add(period[0], period[1], month_offset) + return _safe_date(year, month, int(day)) + + if period_type == "quarterly": + period = _quarter_end_from_label(financial_year, period_label) + if not period: + return None + year, month = _month_add(period[0], period[1], month_offset) + return _safe_date(year, month, int(day)) + + return None + + +def get_active_due_rule_for_catalogue(db: Session, *, catalogue_id: int) -> ServiceDueDateRule | None: + return db.execute( + select(ServiceDueDateRule) + .where( + ServiceDueDateRule.service_catalogue_id == catalogue_id, + ServiceDueDateRule.is_active.is_(True), + ) + .order_by(ServiceDueDateRule.sort_order.asc(), ServiceDueDateRule.id.asc()) + ).scalars().first() + + +def list_due_rules(db: Session, *, catalogue_id: int, include_inactive: bool = True) -> list[ServiceDueDateRule]: + query = select(ServiceDueDateRule).where(ServiceDueDateRule.service_catalogue_id == catalogue_id) + if not include_inactive: + query = query.where(ServiceDueDateRule.is_active.is_(True)) + return db.execute(query.order_by(ServiceDueDateRule.sort_order.asc(), ServiceDueDateRule.id.asc())).scalars().all() + + +def get_due_rule(db: Session, *, rule_id: int, catalogue_id: int | None = None) -> ServiceDueDateRule | None: + query = select(ServiceDueDateRule).where(ServiceDueDateRule.id == rule_id) + if catalogue_id: + query = query.where(ServiceDueDateRule.service_catalogue_id == catalogue_id) + return db.execute(query).scalar_one_or_none() + + +def list_due_extensions(db: Session, *, catalogue_id: int, tenant_id: int | None = None, limit: int = 20) -> list[ServiceDueDateExtension]: + query = ( + select(ServiceDueDateExtension) + .options(selectinload(ServiceDueDateExtension.due_rule)) + .where(ServiceDueDateExtension.service_catalogue_id == catalogue_id) + ) + if tenant_id: + query = query.where(ServiceDueDateExtension.tenant_id == tenant_id) + return db.execute( + query.order_by(ServiceDueDateExtension.id.desc()).limit(limit) + ).scalars().all() + + +def apply_due_date_rule_to_subscription( + db: Session, + subscription: ClientServiceSubscription, + *, + force: bool = False, +) -> date | None: + if getattr(subscription, "is_locked", False): + return getattr(subscription, "current_due_date", None) + rule = get_active_due_rule_for_catalogue(db, catalogue_id=subscription.service_catalogue_id) + rule_type = (getattr(rule, "period_type", None) or "").strip().lower() if rule else "" + # For normal statutory rules, preserve an already calculated/extended due date. + # For renewal-based rules, recalculate when expiry_date changes, unless the due date was manually overridden/extended. + if getattr(subscription, "current_due_date", None) and not force: + if rule_type not in {"renewal_based", "before_expiry", "expiry_based"}: + return subscription.current_due_date + if getattr(subscription, "due_date_source", None) in {DUE_DATE_SOURCE_EXTENSION, DUE_DATE_SOURCE_MANUAL}: + return subscription.current_due_date + calculated = calculate_due_date( + rule, + financial_year=subscription.financial_year, + assessment_year=subscription.assessment_year, + period_label=getattr(subscription, "period_label", None), + expiry_date=getattr(subscription, "expiry_date", None), + ) + if calculated: + subscription.due_date_rule_id = rule.id if rule else None + subscription.original_due_date = calculated + subscription.current_due_date = calculated + subscription.due_date_source = DUE_DATE_SOURCE_RULE + return calculated + + +def _matching_extension_query( + *, + tenant_id: int, + catalogue_id: int, + rule_id: int | None, + financial_year: str, + assessment_year: str | None, + period_label: str | None, +): + query = select(ServiceDueDateExtension).where( + ServiceDueDateExtension.tenant_id == tenant_id, + ServiceDueDateExtension.service_catalogue_id == catalogue_id, + ServiceDueDateExtension.financial_year == financial_year, + ) + if rule_id: + query = query.where(ServiceDueDateExtension.due_date_rule_id == rule_id) + if assessment_year: + query = query.where(ServiceDueDateExtension.assessment_year == assessment_year) + if period_label: + query = query.where(ServiceDueDateExtension.period_label == period_label) + else: + query = query.where((ServiceDueDateExtension.period_label.is_(None)) | (ServiceDueDateExtension.period_label == "")) + return query + + +def create_due_date_extension( + db: Session, + *, + tenant_id: int, + catalogue_id: int, + due_date_rule_id: int | None, + financial_year: str, + assessment_year: str | None, + period_label: str | None, + extended_due_date: date, + notification_reference: str | None, + notification_date: date | None, + remarks: str | None, + user_id: int, +) -> tuple[ServiceDueDateExtension, int, int]: + rule = get_due_rule(db, rule_id=due_date_rule_id, catalogue_id=catalogue_id) if due_date_rule_id else get_active_due_rule_for_catalogue(db, catalogue_id=catalogue_id) + latest = db.execute( + _matching_extension_query( + tenant_id=tenant_id, + catalogue_id=catalogue_id, + rule_id=rule.id if rule else None, + financial_year=financial_year, + assessment_year=assessment_year, + period_label=period_label, + ).order_by(ServiceDueDateExtension.extension_sequence.desc(), ServiceDueDateExtension.id.desc()) + ).scalars().first() + + base_due = calculate_due_date(rule, financial_year=financial_year, assessment_year=assessment_year, period_label=period_label) + previous_due = latest.extended_due_date if latest else base_due + sequence = int((latest.extension_sequence if latest else 0) or 0) + 1 + + extension = ServiceDueDateExtension( + tenant_id=tenant_id, + service_catalogue_id=catalogue_id, + due_date_rule_id=rule.id if rule else None, + financial_year=financial_year, + assessment_year=assessment_year, + period_label=(period_label or "").strip() or None, + previous_due_date=previous_due, + extended_due_date=extended_due_date, + extension_sequence=sequence, + notification_reference=(notification_reference or "").strip() or None, + notification_date=notification_date, + remarks=(remarks or "").strip() or None, + created_by_user_id=user_id, + updated_by_user_id=user_id, + ) + db.add(extension) + db.flush() + + updated = skipped_locked = 0 + sub_query = select(ClientServiceSubscription).where( + ClientServiceSubscription.tenant_id == tenant_id, + ClientServiceSubscription.service_catalogue_id == catalogue_id, + ClientServiceSubscription.financial_year == financial_year, + ) + if assessment_year: + sub_query = sub_query.where(ClientServiceSubscription.assessment_year == assessment_year) + subscriptions = db.execute(sub_query).scalars().all() + for sub in subscriptions: + if getattr(sub, "is_locked", False): + skipped_locked += 1 + continue + if rule: + sub.due_date_rule_id = rule.id + if not getattr(sub, "original_due_date", None): + sub.original_due_date = previous_due or base_due + sub.current_due_date = extended_due_date + sub.due_date_source = DUE_DATE_SOURCE_EXTENSION + sub.updated_by_user_id = user_id + updated += 1 + return extension, updated, skipped_locked diff --git a/app/modules/services/engagements_ui.py b/app/modules/services/engagements_ui.py new file mode 100644 index 0000000..5dd6c53 --- /dev/null +++ b/app/modules/services/engagements_ui.py @@ -0,0 +1,532 @@ +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.services.models import ClientServiceSubscription, ClientServiceTaskInstance +from app.modules.services.due_dates import apply_due_date_rule_to_subscription +from app.modules.clients.models import Client +from app.modules.services.client_services import ( + SUBSCRIPTION_STATUSES, + assessment_year_from_financial_year, + current_financial_year, + get_enabled_firm_service, + get_existing_subscription, + get_subscription, + normalize_financial_year, + list_assignable_users, + list_clients_for_assignment, + list_enabled_services_for_assignment, + list_subscription_payload, + list_review_partners, + parse_date, + review_partner_required_for_engagement, +) +from app.modules.core.rbac.deps import get_user_permissions, get_user_roles +from app.modules.core.rbac.permission_guard import require_permission +from app.modules.core.tenancy.year_control import redirect_if_financial_year_locked, is_row_financial_year_locked + +router = APIRouter(prefix="/services/engagements", tags=["services-engagements-ui"]) + + +def _base_ctx(request: Request, user, db, **ctx): + base = { + "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), + "subscription_statuses": SUBSCRIPTION_STATUSES, + } + base.update(ctx) + return base + + +def _render(request: Request, template: str, db, user, **ctx): + return templates.TemplateResponse(template, _base_ctx(request, user, db, **ctx)) + + +def _redirect_denied(): + return RedirectResponse(url="/system-settings", status_code=303) + + +def _has_perm(db, user, code: str) -> bool: + try: + require_permission(db, user, code) + return True + except Exception: + return False + + +def _active_tenant_id(request: Request, user) -> int: + return int( + request.session.get("active_tenant_id") + or request.session.get("selected_tenant_id") + or request.session.get("tenant_id") + or user.tenant_id + ) + + +def _active_branch_id(request: Request, user, db) -> int | None: + value = request.session.get("active_branch_id") + if value in (None, "", 0, "0"): + if _has_perm(db, user, "clients.cross_branch"): + return None + return int(getattr(user, "branch_id", 0) or 0) or None + return int(value) + + +def _active_financial_year(request: Request) -> str: + return normalize_financial_year( + request.session.get("active_financial_year") + or getattr(request.state, "year_code", None) + ) + + +def _locked_partner_id(db, user) -> int | None: + return int(user.id) if _has_perm(db, user, "clients.view.own_only") else None + + +def _can_manage_client_services(db, user) -> bool: + return _has_perm(db, user, "clients.edit") + + +def _can_lock_engagements(db, user) -> bool: + roles = set(get_user_roles(db, user.id)) + return bool(roles.intersection({"Firm Admin", "Partner"})) + + +def _user_can_lock_subscription(db, user, row: ClientServiceSubscription) -> bool: + roles = set(get_user_roles(db, user.id)) + if "Firm Admin" in roles: + return True + if "Partner" in roles: + client = getattr(row, "client", None) + return ( + getattr(row, "assigned_partner_user_id", None) == user.id + or getattr(client, "partner_id", None) == user.id + ) + return False + + +def _lock_subscription_row(row: ClientServiceSubscription, user) -> bool: + if getattr(row, "is_locked", False): + return False + from datetime import datetime, timezone + + row.is_locked = True + row.status = "completed" if row.status == "active" else row.status + row.locked_at_utc = datetime.now(timezone.utc) + row.locked_by_user_id = user.id + row.updated_by_user_id = user.id + return True + + +@router.get("") +def subscription_list(request: Request, q: str = "", financial_year: str = "", include_inactive: bool = True, locked: int = 0, skipped: int = 0): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + try: + require_permission(db, user, "clients.view") + except Exception: + return _redirect_denied() + + tenant_id = _active_tenant_id(request, user) + branch_id = _active_branch_id(request, user, db) + selected_financial_year = normalize_financial_year(financial_year or _active_financial_year(request)) + rows = list_subscription_payload( + db, + tenant_id=tenant_id, + branch_id=branch_id, + financial_year=selected_financial_year, + q=q, + include_inactive=include_inactive, + ) + return _render( + request, + "modules/services/templates/services/engagements/list.html", + db, + user, + title="Engagement Subscriptions", + rows=rows, + q=q, + financial_year=selected_financial_year, + include_inactive=include_inactive, + locked_count=locked, + skipped_count=skipped, + can_manage=_can_manage_client_services(db, user), + can_lock_engagements=_can_lock_engagements(db, user), + ) + finally: + db.close() + + +@router.get("/new") +def subscription_create_page(request: Request, client_id: int | None = None): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + try: + require_permission(db, user, "clients.edit") + except Exception: + return _redirect_denied() + + tenant_id = _active_tenant_id(request, user) + branch_id = _active_branch_id(request, user, db) + clients = list_clients_for_assignment( + db, + tenant_id=tenant_id, + branch_id=branch_id, + partner_id=_locked_partner_id(db, user), + ) + enabled_services = list_enabled_services_for_assignment(db, tenant_id=tenant_id) + assignable_users = list_assignable_users(db, tenant_id=tenant_id, branch_id=branch_id) + review_partners = list_review_partners(db, tenant_id=tenant_id, branch_id=branch_id) + + return _render( + request, + "modules/services/templates/services/engagements/form.html", + db, + user, + title="Assign Service to Client", + mode="create", + subscription=None, + clients=clients, + enabled_services=enabled_services, + assignable_users=assignable_users, + review_partners=review_partners, + selected_client_id=client_id, + financial_year=_active_financial_year(request), + ) + finally: + db.close() + + +@router.post("/new") +def subscription_create_submit( + request: Request, + client_id: int = Form(...), + service_catalogue_id: int = Form(...), + assigned_partner_user_id: str = Form(""), + assigned_manager_user_id: str = Form(""), + assigned_staff_user_id: str = Form(""), + review_partner_user_id: str = Form(""), + financial_year: str = Form(""), + start_date: str = Form(""), + end_date: str = Form(""), + expiry_date: str = Form(""), + status: str = Form("active"), + remarks: str = Form(""), + is_active: str | None = Form(None), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + try: + require_permission(db, user, "clients.edit") + except Exception: + return _redirect_denied() + + tenant_id = _active_tenant_id(request, user) + selected_financial_year = normalize_financial_year(financial_year or _active_financial_year(request)) + locked_response = redirect_if_financial_year_locked(db, tenant_id=tenant_id, year_code=selected_financial_year, redirect_url=f"/services/engagements?financial_year={selected_financial_year}") + if locked_response: + return locked_response + firm_selection = get_enabled_firm_service(db, tenant_id=tenant_id, service_catalogue_id=service_catalogue_id) + if not firm_selection: + return RedirectResponse(url="/services/engagements/new", status_code=303) + + existing = get_existing_subscription( + db, + tenant_id=tenant_id, + client_id=client_id, + service_catalogue_id=service_catalogue_id, + financial_year=selected_financial_year, + ) + client = db.get(Client, client_id) + if not client or client.tenant_id != tenant_id: + return RedirectResponse(url="/services/engagements/new", status_code=303) + + if existing: + row = existing + else: + row = ClientServiceSubscription( + tenant_id=tenant_id, + client_id=client_id, + service_catalogue_id=service_catalogue_id, + financial_year=selected_financial_year, + assessment_year=assessment_year_from_financial_year(selected_financial_year), + engagement_type=getattr(firm_selection.catalogue, "engagement_type", "non_assurance") or "non_assurance", + created_by_user_id=user.id, + ) + db.add(row) + + if getattr(row, "is_locked", False) or is_row_financial_year_locked(db, row): + return RedirectResponse(url=f"/services/engagements/{row.id}?year_locked=1", status_code=303) + + row.financial_year = selected_financial_year + row.assessment_year = assessment_year_from_financial_year(selected_financial_year) + if not getattr(row, "engagement_type", None): + row.engagement_type = getattr(firm_selection.catalogue, "engagement_type", "non_assurance") or "non_assurance" + row.branch_id = firm_selection.default_branch_id or getattr(user, "branch_id", None) + row.firm_service_selection_id = firm_selection.id + row.assigned_partner_user_id = int(assigned_partner_user_id) if assigned_partner_user_id.strip() else None + row.assigned_manager_user_id = int(assigned_manager_user_id) if assigned_manager_user_id.strip() else None + row.assigned_staff_user_id = int(assigned_staff_user_id) if assigned_staff_user_id.strip() else None + if review_partner_required_for_engagement(db, tenant_id=tenant_id, engagement_type=row.engagement_type): + row.review_partner_user_id = int(review_partner_user_id) if review_partner_user_id.strip() else getattr(client, "default_review_partner_user_id", None) + else: + row.review_partner_user_id = None + row.start_date = parse_date(start_date) + row.end_date = parse_date(end_date) + row.expiry_date = parse_date(expiry_date) + row.status = status or "active" + row.remarks = remarks.strip() or None + row.is_active = is_active is not None + row.updated_by_user_id = user.id + apply_due_date_rule_to_subscription(db, row) + + db.commit() + db.refresh(row) + return RedirectResponse(url=f"/services/engagements/{row.id}", status_code=303) + finally: + db.close() + + +@router.post("/bulk-lock") +def subscription_bulk_lock( + request: Request, + subscription_ids: list[int] = Form([]), + financial_year: str = Form(""), + q: str = Form(""), + include_inactive: str | None = Form(None), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + locked_count = 0 + skipped_count = 0 + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not _can_lock_engagements(db, user): + return _redirect_denied() + + tenant_id = _active_tenant_id(request, user) + selected_ids = [int(value) for value in subscription_ids if value] + for subscription_id in selected_ids: + row = get_subscription(db, subscription_id=subscription_id, tenant_id=tenant_id) + if not row or not _user_can_lock_subscription(db, user, row): + skipped_count += 1 + continue + if _lock_subscription_row(row, user): + locked_count += 1 + else: + skipped_count += 1 + + if locked_count: + db.commit() + + params = [] + fy = normalize_financial_year(financial_year or _active_financial_year(request)) + if fy: + params.append(f"financial_year={fy}") + if q.strip(): + from urllib.parse import quote_plus + params.append(f"q={quote_plus(q.strip())}") + if include_inactive: + params.append("include_inactive=true") + params.append(f"locked={locked_count}") + params.append(f"skipped={skipped_count}") + suffix = "?" + "&".join(params) if params else "" + return RedirectResponse(url=f"/services/engagements{suffix}", status_code=303) + finally: + db.close() + + +@router.get("/{subscription_id}") +def subscription_detail(request: Request, subscription_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + try: + require_permission(db, user, "clients.view") + except Exception: + return _redirect_denied() + + tenant_id = _active_tenant_id(request, user) + row = get_subscription(db, subscription_id=subscription_id, tenant_id=tenant_id) + if not row: + return RedirectResponse(url="/services/engagements", status_code=303) + active_fy = _active_financial_year(request) + if row.financial_year != active_fy: + return RedirectResponse(url=f"/services/engagements?financial_year={active_fy}", status_code=303) + + tasks = db.execute( + select(ClientServiceTaskInstance) + .where(ClientServiceTaskInstance.subscription_id == row.id) + .order_by(ClientServiceTaskInstance.sequence_no.asc(), ClientServiceTaskInstance.id.asc()) + ).scalars().all() + + return _render( + request, + "modules/services/templates/services/engagements/detail.html", + db, + user, + title="Engagement Subscription", + row=row, + tasks=tasks, + can_manage=_can_manage_client_services(db, user), + ) + finally: + db.close() + + +@router.get("/{subscription_id}/edit") +def subscription_edit_page(request: Request, subscription_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + try: + require_permission(db, user, "clients.edit") + except Exception: + return _redirect_denied() + + tenant_id = _active_tenant_id(request, user) + branch_id = _active_branch_id(request, user, db) + row = get_subscription(db, subscription_id=subscription_id, tenant_id=tenant_id) + if not row: + return RedirectResponse(url="/services/engagements", status_code=303) + active_fy = _active_financial_year(request) + if row.financial_year != active_fy: + return RedirectResponse(url=f"/services/engagements?financial_year={active_fy}", status_code=303) + if getattr(row, "is_locked", False) or is_row_financial_year_locked(db, row): + return RedirectResponse(url=f"/services/engagements/{row.id}?year_locked=1", status_code=303) + + clients = list_clients_for_assignment( + db, + tenant_id=tenant_id, + branch_id=branch_id, + partner_id=_locked_partner_id(db, user), + ) + enabled_services = list_enabled_services_for_assignment(db, tenant_id=tenant_id) + assignable_users = list_assignable_users(db, tenant_id=tenant_id, branch_id=branch_id) + review_partners = list_review_partners(db, tenant_id=tenant_id, branch_id=branch_id) + return _render( + request, + "modules/services/templates/services/engagements/form.html", + db, + user, + title="Edit Engagement Subscription", + mode="edit", + subscription=row, + clients=clients, + enabled_services=enabled_services, + assignable_users=assignable_users, + review_partners=review_partners, + selected_client_id=row.client_id, + financial_year=row.financial_year, + ) + finally: + db.close() + + +@router.post("/{subscription_id}/edit") +def subscription_edit_submit( + request: Request, + subscription_id: int, + assigned_partner_user_id: str = Form(""), + assigned_manager_user_id: str = Form(""), + assigned_staff_user_id: str = Form(""), + review_partner_user_id: str = Form(""), + start_date: str = Form(""), + end_date: str = Form(""), + expiry_date: str = Form(""), + status: str = Form("active"), + remarks: str = Form(""), + is_active: str | None = Form(None), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + try: + require_permission(db, user, "clients.edit") + except Exception: + return _redirect_denied() + + tenant_id = _active_tenant_id(request, user) + row = get_subscription(db, subscription_id=subscription_id, tenant_id=tenant_id) + if not row: + return RedirectResponse(url="/services/engagements", status_code=303) + active_fy = _active_financial_year(request) + if row.financial_year != active_fy: + return RedirectResponse(url=f"/services/engagements?financial_year={active_fy}", status_code=303) + if getattr(row, "is_locked", False) or is_row_financial_year_locked(db, row): + return RedirectResponse(url=f"/services/engagements/{row.id}?year_locked=1", status_code=303) + + row.assigned_partner_user_id = int(assigned_partner_user_id) if assigned_partner_user_id.strip() else None + row.assigned_manager_user_id = int(assigned_manager_user_id) if assigned_manager_user_id.strip() else None + row.assigned_staff_user_id = int(assigned_staff_user_id) if assigned_staff_user_id.strip() else None + if review_partner_required_for_engagement(db, tenant_id=tenant_id, engagement_type=row.engagement_type): + row.review_partner_user_id = int(review_partner_user_id) if review_partner_user_id.strip() else getattr(row.client, "default_review_partner_user_id", None) + else: + row.review_partner_user_id = None + row.start_date = parse_date(start_date) + row.end_date = parse_date(end_date) + row.expiry_date = parse_date(expiry_date) + row.status = status or "active" + row.remarks = remarks.strip() or None + row.is_active = is_active is not None + row.updated_by_user_id = user.id + apply_due_date_rule_to_subscription(db, row) + db.commit() + return RedirectResponse(url=f"/services/engagements/{row.id}", status_code=303) + finally: + db.close() + + +@router.post("/{subscription_id}/lock") +def subscription_lock(request: Request, subscription_id: int, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not _can_lock_engagements(db, user): + return _redirect_denied() + tenant_id = _active_tenant_id(request, user) + row = get_subscription(db, subscription_id=subscription_id, tenant_id=tenant_id) + if row and row.financial_year != _active_financial_year(request): + return RedirectResponse(url=f"/services/engagements?financial_year={_active_financial_year(request)}", status_code=303) + if row and is_row_financial_year_locked(db, row): + return RedirectResponse(url=f"/services/engagements/{row.id}?year_locked=1", status_code=303) + if row and _user_can_lock_subscription(db, user, row): + if _lock_subscription_row(row, user): + db.commit() + return RedirectResponse(url=f"/services/engagements/{subscription_id}", status_code=303) + finally: + db.close() diff --git a/app/modules/services/execution.py b/app/modules/services/execution.py new file mode 100644 index 0000000..e0d51d1 --- /dev/null +++ b/app/modules/services/execution.py @@ -0,0 +1,609 @@ +from __future__ import annotations + +from datetime import date, datetime, timezone + +from sqlalchemy import func, or_, select +from sqlalchemy.orm import Session, selectinload + +from app.modules.clients.models import Client +from app.modules.core.iam.models import User +from app.modules.services.models import ( + ClientServiceSubscription, + ClientServiceTaskInstance, + ServiceTaskComment, + FirmServiceTaskTemplate, + ServiceCatalogue, +) + +TASK_STATUSES = [ + ("pending", "Pending"), + ("in_progress", "In Progress"), + ("completed", "Completed"), + ("blocked", "Blocked"), + ("not_applicable", "Not Applicable"), + ("cancelled", "Cancelled"), +] + +OPEN_TASK_STATUSES = {"pending", "in_progress", "blocked"} +CLOSED_TASK_STATUSES = {"completed", "not_applicable", "cancelled"} + + +TASK_COMMENT_TYPES = [ + ("internal_note", "Internal Note"), + ("client_clarification", "Client Clarification"), + ("consultant_clarification", "Consultant Clarification"), + ("partner_review_note", "Partner Review Note"), +] + +TASK_COMMENT_VISIBILITIES = [ + ("internal", "Internal"), + ("client", "Client"), + ("consultant", "Consultant"), +] + +TASK_PRIORITIES = [ + ("low", "Low"), + ("normal", "Normal"), + ("high", "High"), + ("urgent", "Urgent"), +] + + +def _normalise_status(status: str | None) -> str: + allowed = {code for code, _label in TASK_STATUSES} + value = (status or "pending").strip().lower() + return value if value in allowed else "pending" + + +def _normalise_priority(priority: str | None) -> str: + allowed = {code for code, _label in TASK_PRIORITIES} + value = (priority or "normal").strip().lower() + return value if value in allowed else "normal" + + +def _normalise_comment_type(comment_type: str | None) -> str: + allowed = {code for code, _label in TASK_COMMENT_TYPES} + value = (comment_type or "internal_note").strip().lower() + return value if value in allowed else "internal_note" + + +def _normalise_visibility(visibility: str | None) -> str: + allowed = {code for code, _label in TASK_COMMENT_VISIBILITIES} + value = (visibility or "internal").strip().lower() + return value if value in allowed else "internal" + + +def parse_date_value(value: str | None) -> date | None: + text = (value or "").strip() + if not text: + return None + return date.fromisoformat(text) + + +def _default_assignee_for_template(subscription: ClientServiceSubscription, template: FirmServiceTaskTemplate) -> int | None: + role = (template.default_role_name or "").strip().lower() + if "partner" in role: + return subscription.assigned_partner_user_id + if "manager" in role: + return subscription.assigned_manager_user_id + if "staff" in role or "employee" in role: + return subscription.assigned_staff_user_id + return subscription.assigned_staff_user_id or subscription.assigned_manager_user_id or subscription.assigned_partner_user_id + + +def get_subscription_for_execution(db: Session, *, tenant_id: int, subscription_id: int) -> ClientServiceSubscription | None: + return db.execute( + select(ClientServiceSubscription) + .options( + selectinload(ClientServiceSubscription.client), + selectinload(ClientServiceSubscription.catalogue), + selectinload(ClientServiceSubscription.assigned_partner), + selectinload(ClientServiceSubscription.assigned_manager), + selectinload(ClientServiceSubscription.assigned_staff), + ) + .where( + ClientServiceSubscription.id == subscription_id, + ClientServiceSubscription.tenant_id == tenant_id, + ) + ).scalar_one_or_none() + + +def list_subscription_execution_payload(db: Session, *, tenant_id: int, branch_id: int | None = None, financial_year: str | None = None, q: str = ""): + query = ( + select(ClientServiceSubscription) + .options( + selectinload(ClientServiceSubscription.client), + selectinload(ClientServiceSubscription.catalogue), + selectinload(ClientServiceSubscription.assigned_partner), + selectinload(ClientServiceSubscription.assigned_manager), + selectinload(ClientServiceSubscription.assigned_staff), + ) + .where( + ClientServiceSubscription.tenant_id == tenant_id, + ClientServiceSubscription.is_active.is_(True), + ClientServiceSubscription.status == "active", + ClientServiceSubscription.is_locked.is_(False), + ) + ) + if financial_year: + query = query.where(ClientServiceSubscription.financial_year == financial_year.strip()) + if branch_id: + query = query.where(ClientServiceSubscription.branch_id == branch_id) + if q.strip(): + term = f"%{q.strip()}%" + query = ( + query.join(Client, Client.id == ClientServiceSubscription.client_id) + .join(ServiceCatalogue, ServiceCatalogue.id == ClientServiceSubscription.service_catalogue_id) + .where( + or_( + Client.client_name.ilike(term), + Client.client_code.ilike(term), + ServiceCatalogue.service_code.ilike(term), + ServiceCatalogue.service_name.ilike(term), + ) + ) + ) + + rows = db.execute(query.order_by(ClientServiceSubscription.id.desc())).scalars().all() + payload = [] + for sub in rows: + total = db.execute( + select(func.count(ClientServiceTaskInstance.id)).where( + ClientServiceTaskInstance.subscription_id == sub.id, + ClientServiceTaskInstance.is_active.is_(True), + ) + ).scalar_one() + completed = db.execute( + select(func.count(ClientServiceTaskInstance.id)).where( + ClientServiceTaskInstance.subscription_id == sub.id, + ClientServiceTaskInstance.is_active.is_(True), + ClientServiceTaskInstance.status == "completed", + ) + ).scalar_one() + open_tasks = db.execute( + select(func.count(ClientServiceTaskInstance.id)).where( + ClientServiceTaskInstance.subscription_id == sub.id, + ClientServiceTaskInstance.is_active.is_(True), + ClientServiceTaskInstance.status.in_(list(OPEN_TASK_STATUSES)), + ) + ).scalar_one() + payload.append({"subscription": sub, "total_tasks": total, "completed_tasks": completed, "open_tasks": open_tasks}) + return payload + + +def _default_internal_target_date(subscription: ClientServiceSubscription, template: FirmServiceTaskTemplate) -> date | None: + # Phase 4B keeps task target dates internal. Existing task templates do not yet have + # an offset field, so new generated tasks start blank and can be assigned through the tracker. + return None + + +def generate_tasks_for_subscription(db: Session, *, subscription: ClientServiceSubscription, user_id: int) -> int: + templates = db.execute( + select(FirmServiceTaskTemplate) + .where( + FirmServiceTaskTemplate.tenant_id == subscription.tenant_id, + FirmServiceTaskTemplate.service_catalogue_id == subscription.service_catalogue_id, + FirmServiceTaskTemplate.is_active.is_(True), + ) + .order_by(FirmServiceTaskTemplate.sequence_no.asc(), FirmServiceTaskTemplate.id.asc()) + ).scalars().all() + + created = 0 + for template in templates: + existing = db.execute( + select(ClientServiceTaskInstance.id).where( + ClientServiceTaskInstance.subscription_id == subscription.id, + ClientServiceTaskInstance.firm_task_template_id == template.id, + ClientServiceTaskInstance.financial_year == subscription.financial_year, + ) + ).first() + if existing: + continue + + db.add( + ClientServiceTaskInstance( + tenant_id=subscription.tenant_id, + branch_id=subscription.branch_id, + subscription_id=subscription.id, + client_id=subscription.client_id, + service_catalogue_id=subscription.service_catalogue_id, + firm_task_template_id=template.id, + financial_year=subscription.financial_year, + assessment_year=subscription.assessment_year, + task_name=template.task_name, + description=template.description, + sequence_no=template.sequence_no, + default_role_name=template.default_role_name, + assigned_to_user_id=_default_assignee_for_template(subscription, template), + internal_target_date=_default_internal_target_date(subscription, template), + status="pending", + priority="normal", + is_active=True, + created_by_user_id=user_id, + updated_by_user_id=user_id, + ) + ) + created += 1 + return created + + +def _decorate_task_for_tracker(task: ClientServiceTaskInstance, *, today: date) -> ClientServiceTaskInstance: + target_date = getattr(task, "internal_target_date", None) + subscription = getattr(task, "subscription", None) + engagement_due_date = getattr(subscription, "current_due_date", None) if subscription else None + task.is_task_overdue = bool(target_date and target_date < today and task.status not in CLOSED_TASK_STATUSES) + task.is_due_today = bool(target_date and target_date == today and task.status not in CLOSED_TASK_STATUSES) + task.is_engagement_due_overdue = bool( + engagement_due_date and engagement_due_date < today and task.status not in CLOSED_TASK_STATUSES + ) + task.tracker_status_label = dict(TASK_STATUSES).get(task.status, task.status) + task.priority_label = dict(TASK_PRIORITIES).get(task.priority, task.priority) + return task + + + + +def _apply_partner_visibility_filter(query, partner_user_id: int | None): + if not partner_user_id: + return query + return query.where( + or_( + ClientServiceTaskInstance.subscription.has( + ClientServiceSubscription.assigned_partner_user_id == partner_user_id + ), + ClientServiceTaskInstance.client.has(Client.partner_id == partner_user_id), + ) + ) + +def list_tasks_payload( + db: Session, + *, + tenant_id: int, + branch_id: int | None = None, + assigned_to_user_id: int | None = None, + partner_user_id: int | None = None, + status: str = "", + q: str = "", + include_inactive: bool = False, + financial_year: str | None = None, +): + today = date.today() + special_filter = (status or "").strip().lower() + query = ( + select(ClientServiceTaskInstance) + .options( + selectinload(ClientServiceTaskInstance.client), + selectinload(ClientServiceTaskInstance.catalogue), + selectinload(ClientServiceTaskInstance.assigned_to), + selectinload(ClientServiceTaskInstance.subscription), + ) + .where(ClientServiceTaskInstance.tenant_id == tenant_id) + ) + if financial_year: + query = query.where(ClientServiceTaskInstance.financial_year == financial_year.strip()) + if branch_id: + query = query.where(ClientServiceTaskInstance.branch_id == branch_id) + if assigned_to_user_id: + query = query.where(ClientServiceTaskInstance.assigned_to_user_id == assigned_to_user_id) + query = _apply_partner_visibility_filter(query, partner_user_id) + if special_filter and special_filter not in {"overdue", "due_today", "unassigned"}: + query = query.where(ClientServiceTaskInstance.status == special_filter) + if special_filter == "overdue": + query = query.where( + ClientServiceTaskInstance.internal_target_date.is_not(None), + ClientServiceTaskInstance.internal_target_date < today, + ClientServiceTaskInstance.status.notin_(list(CLOSED_TASK_STATUSES)), + ) + elif special_filter == "due_today": + query = query.where( + ClientServiceTaskInstance.internal_target_date == today, + ClientServiceTaskInstance.status.notin_(list(CLOSED_TASK_STATUSES)), + ) + elif special_filter == "unassigned": + query = query.where(ClientServiceTaskInstance.assigned_to_user_id.is_(None)) + if not include_inactive: + query = query.where(ClientServiceTaskInstance.is_active.is_(True)) + if q.strip(): + term = f"%{q.strip()}%" + query = ( + query.join(Client, Client.id == ClientServiceTaskInstance.client_id) + .join(ServiceCatalogue, ServiceCatalogue.id == ClientServiceTaskInstance.service_catalogue_id) + .where( + or_( + ClientServiceTaskInstance.task_name.ilike(term), + Client.client_name.ilike(term), + Client.client_code.ilike(term), + ServiceCatalogue.service_code.ilike(term), + ServiceCatalogue.service_name.ilike(term), + ) + ) + ) + rows = db.execute( + query.order_by( + ClientServiceTaskInstance.internal_target_date.is_(None), + ClientServiceTaskInstance.internal_target_date.asc(), + ClientServiceTaskInstance.status.asc(), + ClientServiceTaskInstance.sequence_no.asc(), + ClientServiceTaskInstance.id.desc(), + ) + ).scalars().all() + return [_decorate_task_for_tracker(task, today=today) for task in rows] + + +def get_task( + db: Session, + *, + tenant_id: int, + task_id: int, + branch_id: int | None = None, + assigned_to_user_id: int | None = None, + partner_user_id: int | None = None, + financial_year: str | None = None, +) -> ClientServiceTaskInstance | None: + query = ( + select(ClientServiceTaskInstance) + .options( + selectinload(ClientServiceTaskInstance.client), + selectinload(ClientServiceTaskInstance.catalogue), + selectinload(ClientServiceTaskInstance.assigned_to), + selectinload(ClientServiceTaskInstance.subscription), + ) + .where( + ClientServiceTaskInstance.id == task_id, + ClientServiceTaskInstance.tenant_id == tenant_id, + ) + ) + if branch_id: + query = query.where(ClientServiceTaskInstance.branch_id == branch_id) + if financial_year: + query = query.where(ClientServiceTaskInstance.financial_year == financial_year.strip()) + if assigned_to_user_id: + query = query.where(ClientServiceTaskInstance.assigned_to_user_id == assigned_to_user_id) + query = _apply_partner_visibility_filter(query, partner_user_id) + task = db.execute(query).scalar_one_or_none() + return _decorate_task_for_tracker(task, today=date.today()) if task else None + + +def list_assignees_for_execution(db: Session, *, tenant_id: int, branch_id: int | None = None): + query = select(User).where(User.tenant_id == tenant_id, User.is_active.is_(True)) + if branch_id: + query = query.where((User.branch_id == branch_id) | (User.branch_id.is_(None))) + return db.execute(query.order_by(User.full_name.asc(), User.email.asc())).scalars().all() + + +def apply_task_update( + task: ClientServiceTaskInstance, + *, + status: str, + priority: str, + assigned_to_user_id: int | None, + internal_target_date: date | None, + remarks: str, + is_active: bool, + user_id: int, +): + if getattr(task, "is_locked", False) or getattr(getattr(task, "subscription", None), "is_locked", False): + return + previous_status = task.status + task.status = _normalise_status(status) + task.priority = _normalise_priority(priority) + task.assigned_to_user_id = assigned_to_user_id + task.internal_target_date = internal_target_date + task.remarks = remarks.strip() or None + task.is_active = is_active + task.updated_by_user_id = user_id + + now = datetime.now(timezone.utc) + if previous_status != "in_progress" and task.status == "in_progress" and not task.started_at_utc: + task.started_at_utc = now + if task.status == "completed" and not task.completed_at_utc: + task.completed_at_utc = now + if task.status != "completed": + task.completed_at_utc = None + + +def apply_bulk_task_update( + tasks: list[ClientServiceTaskInstance], + *, + status: str | None, + assigned_to_user_id: int | None, + update_assignee: bool, + internal_target_date: date | None, + update_internal_target_date: bool, + user_id: int, +) -> tuple[int, int]: + updated = 0 + skipped = 0 + for task in tasks: + if getattr(task, "is_locked", False) or getattr(getattr(task, "subscription", None), "is_locked", False): + skipped += 1 + continue + previous_status = task.status + if status: + task.status = _normalise_status(status) + if update_assignee: + task.assigned_to_user_id = assigned_to_user_id + if update_internal_target_date: + task.internal_target_date = internal_target_date + task.updated_by_user_id = user_id + now = datetime.now(timezone.utc) + if previous_status != "in_progress" and task.status == "in_progress" and not task.started_at_utc: + task.started_at_utc = now + if task.status == "completed" and not task.completed_at_utc: + task.completed_at_utc = now + if task.status != "completed": + task.completed_at_utc = None + updated += 1 + return updated, skipped + + +def get_tasks_for_bulk_update( + db: Session, + *, + tenant_id: int, + task_ids: list[int], + branch_id: int | None = None, + assigned_to_user_id: int | None = None, + partner_user_id: int | None = None, + financial_year: str | None = None, +) -> list[ClientServiceTaskInstance]: + if not task_ids: + return [] + query = ( + select(ClientServiceTaskInstance) + .options(selectinload(ClientServiceTaskInstance.subscription)) + .where( + ClientServiceTaskInstance.tenant_id == tenant_id, + ClientServiceTaskInstance.id.in_(task_ids), + ClientServiceTaskInstance.is_active.is_(True), + ) + ) + if branch_id: + query = query.where(ClientServiceTaskInstance.branch_id == branch_id) + if financial_year: + query = query.where(ClientServiceTaskInstance.financial_year == financial_year.strip()) + if assigned_to_user_id: + query = query.where(ClientServiceTaskInstance.assigned_to_user_id == assigned_to_user_id) + query = _apply_partner_visibility_filter(query, partner_user_id) + return db.execute(query).scalars().all() + + + +def list_task_comments(db: Session, *, tenant_id: int, task_id: int) -> list[ServiceTaskComment]: + return db.execute( + select(ServiceTaskComment) + .options(selectinload(ServiceTaskComment.created_by)) + .where( + ServiceTaskComment.tenant_id == tenant_id, + ServiceTaskComment.task_instance_id == task_id, + ServiceTaskComment.is_deleted.is_(False), + ) + .order_by(ServiceTaskComment.created_at_utc.desc(), ServiceTaskComment.id.desc()) + ).scalars().all() + + +def add_task_comment( + db: Session, + *, + task: ClientServiceTaskInstance, + comment_type: str, + visibility: str, + message: str, + user_id: int, +) -> ServiceTaskComment | None: + if getattr(task, "is_locked", False) or getattr(getattr(task, "subscription", None), "is_locked", False): + return None + clean_message = (message or "").strip() + if not clean_message: + return None + row = ServiceTaskComment( + tenant_id=task.tenant_id, + branch_id=task.branch_id, + subscription_id=task.subscription_id, + task_instance_id=task.id, + comment_type=_normalise_comment_type(comment_type), + visibility=_normalise_visibility(visibility), + message=clean_message, + created_by_user_id=user_id, + ) + db.add(row) + task.updated_by_user_id = user_id + return row + + + +def list_client_visible_task_comments( + db: Session, + *, + tenant_id: int, + client_id: int, + limit: int = 20, +) -> list[ServiceTaskComment]: + """Return client-visible task communication for one client dashboard. + + This is intentionally read-only and scoped by tenant + client. Internal and + consultant-only notes are never returned to the client portal. + """ + safe_limit = max(1, min(int(limit or 20), 100)) + rows = db.execute( + select(ServiceTaskComment) + .options( + selectinload(ServiceTaskComment.created_by), + selectinload(ServiceTaskComment.task).selectinload(ClientServiceTaskInstance.catalogue), + selectinload(ServiceTaskComment.task).selectinload(ClientServiceTaskInstance.subscription), + ) + .join(ClientServiceTaskInstance, ClientServiceTaskInstance.id == ServiceTaskComment.task_instance_id) + .where( + ServiceTaskComment.tenant_id == tenant_id, + ServiceTaskComment.visibility == "client", + ServiceTaskComment.is_deleted.is_(False), + ClientServiceTaskInstance.client_id == client_id, + ClientServiceTaskInstance.tenant_id == tenant_id, + ClientServiceTaskInstance.is_active.is_(True), + ) + .order_by(ServiceTaskComment.created_at_utc.desc(), ServiceTaskComment.id.desc()) + .limit(safe_limit) + ).scalars().all() + + type_labels = dict(TASK_COMMENT_TYPES) + visibility_labels = dict(TASK_COMMENT_VISIBILITIES) + for row in rows: + row.comment_type_label = type_labels.get(row.comment_type, row.comment_type) + row.visibility_label = visibility_labels.get(row.visibility, row.visibility) + return rows + + +def dashboard_stats( + db: Session, + *, + tenant_id: int, + branch_id: int | None = None, + assigned_to_user_id: int | None = None, + partner_user_id: int | None = None, + financial_year: str | None = None, +): + today = date.today() + base = select(ClientServiceTaskInstance).where( + ClientServiceTaskInstance.tenant_id == tenant_id, + ClientServiceTaskInstance.is_active.is_(True), + ) + if branch_id: + base = base.where(ClientServiceTaskInstance.branch_id == branch_id) + if financial_year: + base = base.where(ClientServiceTaskInstance.financial_year == financial_year.strip()) + if assigned_to_user_id: + base = base.where(ClientServiceTaskInstance.assigned_to_user_id == assigned_to_user_id) + base = _apply_partner_visibility_filter(base, partner_user_id) + + subq = base.subquery() + total = db.execute(select(func.count()).select_from(subq)).scalar_one() + pending = db.execute(select(func.count()).select_from(subq).where(subq.c.status == "pending")).scalar_one() + progress = db.execute(select(func.count()).select_from(subq).where(subq.c.status == "in_progress")).scalar_one() + blocked = db.execute(select(func.count()).select_from(subq).where(subq.c.status == "blocked")).scalar_one() + completed = db.execute(select(func.count()).select_from(subq).where(subq.c.status == "completed")).scalar_one() + not_applicable = db.execute(select(func.count()).select_from(subq).where(subq.c.status == "not_applicable")).scalar_one() + unassigned = db.execute(select(func.count()).select_from(subq).where(subq.c.assigned_to_user_id.is_(None))).scalar_one() + overdue = db.execute( + select(func.count()).select_from(subq).where( + subq.c.internal_target_date.is_not(None), + subq.c.internal_target_date < today, + subq.c.status.notin_(list(CLOSED_TASK_STATUSES)), + ) + ).scalar_one() + due_today = db.execute( + select(func.count()).select_from(subq).where( + subq.c.internal_target_date == today, + subq.c.status.notin_(list(CLOSED_TASK_STATUSES)), + ) + ).scalar_one() + return { + "total": total, + "pending": pending, + "in_progress": progress, + "blocked": blocked, + "completed": completed, + "not_applicable": not_applicable, + "unassigned": unassigned, + "overdue": overdue, + "due_today": due_today, + } diff --git a/app/modules/services/execution_ui_old.py b/app/modules/services/execution_ui_old.py new file mode 100644 index 0000000..db47983 --- /dev/null +++ b/app/modules/services/execution_ui_old.py @@ -0,0 +1,259 @@ +from __future__ import annotations + +from fastapi import APIRouter, Form, Request +from fastapi.responses import RedirectResponse + +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.core.rbac.deps import get_user_permissions, get_user_roles +from app.modules.core.rbac.permission_guard import require_permission +from app.modules.services.execution import ( + TASK_PRIORITIES, + TASK_STATUSES, + apply_task_update, + dashboard_stats, + generate_tasks_for_subscription, + get_subscription_for_execution, + get_task, + list_assignees_for_execution, + list_subscription_execution_payload, + list_tasks_payload, +) + +router = APIRouter(prefix="/services/execution", tags=["services-execution-ui"]) + + +def _base_ctx(request: Request, user, db, **ctx): + base = { + "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), + "task_statuses": TASK_STATUSES, + "task_priorities": TASK_PRIORITIES, + } + base.update(ctx) + return base + + +def _render(request: Request, template: str, db, user, **ctx): + return templates.TemplateResponse(template, _base_ctx(request, user, db, **ctx)) + + +def _redirect_denied(): + return RedirectResponse(url="/system-settings", status_code=303) + + +def _has_perm(db, user, code: str) -> bool: + try: + require_permission(db, user, code) + return True + except Exception: + return False + + +def _active_tenant_id(request: Request, user) -> int: + return int( + request.session.get("active_tenant_id") + or request.session.get("selected_tenant_id") + or request.session.get("tenant_id") + or user.tenant_id + ) + + +def _active_branch_id(request: Request, user, db) -> int | None: + value = request.session.get("active_branch_id") + if value in (None, "", 0, "0"): + if _has_perm(db, user, "clients.cross_branch"): + return None + return int(getattr(user, "branch_id", 0) or 0) or None + return int(value) + + +def _assigned_user_filter(db, user) -> int | None: + # Partner/Staff style users with own-only permission see only their assigned tasks. + return int(user.id) if _has_perm(db, user, "clients.view.own_only") else None + + +def _can_manage_execution(db, user) -> bool: + return _has_perm(db, user, "service_tasks.edit") or _has_perm(db, user, "service_tasks.create") + + +@router.get("") +def execution_dashboard(request: Request, q: str = "", status: str = ""): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + try: + require_permission(db, user, "service_tasks.view") + except Exception: + return _redirect_denied() + + tenant_id = _active_tenant_id(request, user) + branch_id = _active_branch_id(request, user, db) + assigned_to_user_id = _assigned_user_filter(db, user) + stats = dashboard_stats(db, tenant_id=tenant_id, branch_id=branch_id, assigned_to_user_id=assigned_to_user_id) + tasks = list_tasks_payload( + db, + tenant_id=tenant_id, + branch_id=branch_id, + assigned_to_user_id=assigned_to_user_id, + status=status, + q=q, + ) + return _render( + request, + "modules/services/templates/services/execution/dashboard.html", + db, + user, + title="Service Execution Dashboard", + stats=stats, + tasks=tasks, + q=q, + status=status, + can_manage=_can_manage_execution(db, user), + ) + finally: + db.close() + + +@router.get("/subscriptions") +def subscription_execution_list(request: Request, q: str = ""): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + try: + require_permission(db, user, "clients.view") + except Exception: + return _redirect_denied() + + tenant_id = _active_tenant_id(request, user) + branch_id = _active_branch_id(request, user, db) + rows = list_subscription_execution_payload(db, tenant_id=tenant_id, branch_id=branch_id, q=q) + return _render( + request, + "modules/services/templates/services/execution/subscriptions.html", + db, + user, + title="Generate Service Tasks", + rows=rows, + q=q, + can_generate=_has_perm(db, user, "service_tasks.create"), + ) + finally: + db.close() + + +@router.post("/subscriptions/{subscription_id}/generate") +def generate_subscription_tasks(request: Request, subscription_id: int, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + try: + require_permission(db, user, "service_tasks.create") + except Exception: + return _redirect_denied() + + tenant_id = _active_tenant_id(request, user) + subscription = get_subscription_for_execution(db, tenant_id=tenant_id, subscription_id=subscription_id) + if not subscription or not subscription.is_active or subscription.status != "active": + return RedirectResponse(url="/services/execution/subscriptions", status_code=303) + + generate_tasks_for_subscription(db, subscription=subscription, user_id=user.id) + db.commit() + return RedirectResponse(url="/services/execution", status_code=303) + finally: + db.close() + + +@router.get("/tasks/{task_id}/edit") +def task_edit_page(request: Request, task_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + try: + require_permission(db, user, "service_tasks.view") + except Exception: + return _redirect_denied() + + tenant_id = _active_tenant_id(request, user) + task = get_task(db, tenant_id=tenant_id, task_id=task_id) + if not task: + return RedirectResponse(url="/services/execution", status_code=303) + + own_only_user_id = _assigned_user_filter(db, user) + if own_only_user_id and task.assigned_to_user_id != own_only_user_id: + return _redirect_denied() + + branch_id = _active_branch_id(request, user, db) + assignees = list_assignees_for_execution(db, tenant_id=tenant_id, branch_id=branch_id) + return _render( + request, + "modules/services/templates/services/execution/task_form.html", + db, + user, + title="Update Service Task", + task=task, + assignees=assignees, + can_edit=_has_perm(db, user, "service_tasks.edit"), + ) + finally: + db.close() + + +@router.post("/tasks/{task_id}/edit") +def task_edit_submit( + request: Request, + task_id: int, + status: str = Form("pending"), + priority: str = Form("normal"), + assigned_to_user_id: str = Form(""), + remarks: str = Form(""), + is_active: str | None = Form(None), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + try: + require_permission(db, user, "service_tasks.edit") + except Exception: + return _redirect_denied() + + tenant_id = _active_tenant_id(request, user) + task = get_task(db, tenant_id=tenant_id, task_id=task_id) + if not task: + return RedirectResponse(url="/services/execution", status_code=303) + + own_only_user_id = _assigned_user_filter(db, user) + if own_only_user_id and task.assigned_to_user_id != own_only_user_id: + return _redirect_denied() + + apply_task_update( + task, + status=status, + priority=priority, + assigned_to_user_id=int(assigned_to_user_id) if assigned_to_user_id.strip() else None, + remarks=remarks, + is_active=is_active is not None, + user_id=user.id, + ) + db.commit() + return RedirectResponse(url="/services/execution", status_code=303) + finally: + db.close() diff --git a/app/modules/services/import_service.py b/app/modules/services/import_service.py new file mode 100644 index 0000000..39ba57b --- /dev/null +++ b/app/modules/services/import_service.py @@ -0,0 +1,305 @@ +from __future__ import annotations + +from io import BytesIO +from typing import Any + +from openpyxl import Workbook, load_workbook +from openpyxl.styles import Font +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.modules.services.models import FirmServiceTaskTemplate, ServiceCatalogue + + +def build_import_template_workbook() -> bytes: + wb = Workbook() + + ws_services = wb.active + ws_services.title = "service_catalogue" + service_headers = [ + "service_code", + "service_name", + "category", + "description", + "recurrence_type", + "is_active", + "is_client_requestable", + "is_consultant_requestable", + ] + ws_services.append(service_headers) + ws_services.append([ + "GST-MONTHLY", + "GST Monthly Return Filing", + "GST", + "Monthly GST compliance service", + "MONTHLY", + "TRUE", + "TRUE", + "FALSE", + ]) + + ws_tasks = wb.create_sheet("firm_task_templates") + task_headers = [ + "service_code", + "sequence_no", + "task_name", + "description", + "default_role_name", + "sla_days", + "is_mandatory", + "requires_review", + "is_active", + ] + ws_tasks.append(task_headers) + ws_tasks.append([ + "GST-MONTHLY", + 1, + "Collect Purchase and Sales Data", + "Collect source data from client", + "Staff", + 3, + "TRUE", + "FALSE", + "TRUE", + ]) + ws_tasks.append([ + "GST-MONTHLY", + 2, + "Review and File Return", + "Manager review and final filing", + "Partner", + 2, + "TRUE", + "TRUE", + "TRUE", + ]) + + for ws in [ws_services, ws_tasks]: + for cell in ws[1]: + cell.font = Font(bold=True) + for col in ws.columns: + max_len = 0 + col_letter = col[0].column_letter + for cell in col: + val = "" if cell.value is None else str(cell.value) + max_len = max(max_len, len(val)) + ws.column_dimensions[col_letter].width = min(max(max_len + 2, 14), 40) + + out = BytesIO() + wb.save(out) + return out.getvalue() + + +def _norm_text(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + +def _norm_upper(value: Any) -> str: + return _norm_text(value).upper() + + +def _norm_bool(value: Any, default: bool = False) -> bool: + if value is None or value == "": + return default + if isinstance(value, bool): + return value + return str(value).strip().lower() in {"1", "true", "yes", "y", "on"} + + +def _norm_int(value: Any, default: int | None = None) -> int | None: + if value is None or value == "": + return default + try: + return int(value) + except Exception: + return default + + +def _sheet_headers(ws) -> dict[str, int]: + headers = {} + first_row = next(ws.iter_rows(min_row=1, max_row=1, values_only=True), []) + for idx, val in enumerate(first_row): + key = _norm_text(val).lower() + if key: + headers[key] = idx + return headers + + +def _cell(row: tuple, headers: dict[str, int], key: str) -> Any: + idx = headers.get(key.lower()) + if idx is None or idx >= len(row): + return None + return row[idx] + + +def parse_import_workbook(file_bytes: bytes) -> dict: + wb = load_workbook(BytesIO(file_bytes), data_only=True) + errors: list[str] = [] + catalogue_rows: list[dict] = [] + task_rows: list[dict] = [] + + if "service_catalogue" not in wb.sheetnames: + return {"ok": False, "errors": ["Workbook must contain a sheet named 'service_catalogue'."], "catalogue_rows": [], "task_rows": []} + + ws_services = wb["service_catalogue"] + headers = _sheet_headers(ws_services) + for h in ["service_code", "service_name"]: + if h not in headers: + errors.append(f"Service catalogue sheet missing required column: {h}") + + for row_no, row in enumerate(ws_services.iter_rows(min_row=2, values_only=True), start=2): + service_code = _norm_upper(_cell(row, headers, "service_code")) + service_name = _norm_text(_cell(row, headers, "service_name")) + if not service_code and not service_name: + continue + if not service_code: + errors.append(f"Service catalogue row {row_no}: service_code is required.") + continue + if not service_name: + errors.append(f"Service catalogue row {row_no}: service_name is required.") + continue + catalogue_rows.append({ + "service_code": service_code, + "service_name": service_name, + "category": _norm_text(_cell(row, headers, "category")) or None, + "description": _norm_text(_cell(row, headers, "description")) or None, + "recurrence_type": _norm_text(_cell(row, headers, "recurrence_type")) or None, + "is_active": _norm_bool(_cell(row, headers, "is_active"), True), + "is_client_requestable": _norm_bool(_cell(row, headers, "is_client_requestable"), False), + "is_consultant_requestable": _norm_bool(_cell(row, headers, "is_consultant_requestable"), False), + }) + + if "firm_task_templates" in wb.sheetnames: + ws_tasks = wb["firm_task_templates"] + task_headers = _sheet_headers(ws_tasks) + for h in ["service_code", "sequence_no", "task_name"]: + if h not in task_headers: + errors.append(f"Firm task templates sheet missing required column: {h}") + for row_no, row in enumerate(ws_tasks.iter_rows(min_row=2, values_only=True), start=2): + service_code = _norm_upper(_cell(row, task_headers, "service_code")) + task_name = _norm_text(_cell(row, task_headers, "task_name")) + sequence_no = _norm_int(_cell(row, task_headers, "sequence_no")) + if not service_code and not task_name: + continue + if not service_code: + errors.append(f"Firm task templates row {row_no}: service_code is required.") + continue + if not task_name: + errors.append(f"Firm task templates row {row_no}: task_name is required.") + continue + if sequence_no is None: + errors.append(f"Firm task templates row {row_no}: sequence_no must be numeric.") + continue + task_rows.append({ + "service_code": service_code, + "sequence_no": sequence_no, + "task_name": task_name, + "description": _norm_text(_cell(row, task_headers, "description")) or None, + "default_role_name": _norm_text(_cell(row, task_headers, "default_role_name")) or None, + "sla_days": _norm_int(_cell(row, task_headers, "sla_days")), + "is_mandatory": _norm_bool(_cell(row, task_headers, "is_mandatory"), True), + "requires_review": _norm_bool(_cell(row, task_headers, "requires_review"), False), + "is_active": _norm_bool(_cell(row, task_headers, "is_active"), True), + }) + + service_codes = {row["service_code"] for row in catalogue_rows} + for row in task_rows: + if row["service_code"] not in service_codes: + errors.append(f"Task row for service_code '{row['service_code']}' does not match any service in service_catalogue sheet.") + + return {"ok": len(errors) == 0, "errors": errors, "catalogue_rows": catalogue_rows, "task_rows": task_rows} + + +def apply_import_payload( + db: Session, + *, + tenant_id: int, + branch_id: int | None, + actor_user_id: int, + catalogue_rows: list[dict], + task_rows: list[dict], +) -> dict: + created_catalogue = 0 + updated_catalogue = 0 + created_tasks = 0 + updated_tasks = 0 + catalogue_map: dict[str, ServiceCatalogue] = {} + + for row in catalogue_rows: + service = db.execute(select(ServiceCatalogue).where(ServiceCatalogue.service_code == row["service_code"])).scalar_one_or_none() + if service is None: + service = ServiceCatalogue( + service_code=row["service_code"], + service_name=row["service_name"], + category=row["category"], + description=row["description"], + recurrence_type=row["recurrence_type"], + is_active=row["is_active"], + is_client_requestable=row["is_client_requestable"], + is_consultant_requestable=row["is_consultant_requestable"], + created_by_user_id=actor_user_id, + updated_by_user_id=actor_user_id, + ) + db.add(service) + db.flush() + created_catalogue += 1 + else: + service.service_name = row["service_name"] + service.category = row["category"] + service.description = row["description"] + service.recurrence_type = row["recurrence_type"] + service.is_active = row["is_active"] + service.is_client_requestable = row["is_client_requestable"] + service.is_consultant_requestable = row["is_consultant_requestable"] + service.updated_by_user_id = actor_user_id + updated_catalogue += 1 + catalogue_map[row["service_code"]] = service + + for row in task_rows: + service = catalogue_map[row["service_code"]] + task = db.execute( + select(FirmServiceTaskTemplate).where( + FirmServiceTaskTemplate.tenant_id == tenant_id, + FirmServiceTaskTemplate.service_catalogue_id == service.id, + FirmServiceTaskTemplate.sequence_no == row["sequence_no"], + ) + ).scalar_one_or_none() + if task is None: + task = FirmServiceTaskTemplate( + tenant_id=tenant_id, + branch_id=branch_id, + service_catalogue_id=service.id, + sequence_no=row["sequence_no"], + task_name=row["task_name"], + description=row["description"], + default_role_name=row["default_role_name"], + sla_days=row["sla_days"], + is_mandatory=row["is_mandatory"], + requires_review=row["requires_review"], + is_active=row["is_active"], + created_by_user_id=actor_user_id, + updated_by_user_id=actor_user_id, + ) + db.add(task) + created_tasks += 1 + else: + task.task_name = row["task_name"] + task.description = row["description"] + task.default_role_name = row["default_role_name"] + task.sla_days = row["sla_days"] + task.is_mandatory = row["is_mandatory"] + task.requires_review = row["requires_review"] + task.is_active = row["is_active"] + task.branch_id = branch_id + task.updated_by_user_id = actor_user_id + updated_tasks += 1 + + db.commit() + return { + "created_catalogue": created_catalogue, + "updated_catalogue": updated_catalogue, + "created_tasks": created_tasks, + "updated_tasks": updated_tasks, + } diff --git a/app/modules/services/models.py b/app/modules/services/models.py new file mode 100644 index 0000000..0c7dc7b --- /dev/null +++ b/app/modules/services/models.py @@ -0,0 +1,500 @@ +from __future__ import annotations + +from datetime import date, datetime, timezone + +from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.db.common import CommonBase + + +class ServiceCategory(CommonBase): + __tablename__ = "service_categories" + __table_args__ = ( + UniqueConstraint("code", name="uq_service_categories_code"), + UniqueConstraint("name", name="uq_service_categories_name"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + code: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + name: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=100) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + + catalogue_items = relationship("ServiceCatalogue", back_populates="service_category") + + +class ServiceCatalogue(CommonBase): + __tablename__ = "service_catalogues" + __table_args__ = ( + UniqueConstraint("service_code", name="uq_service_catalogues_code"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + service_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + service_name: Mapped[str] = mapped_column(String(200), nullable=False, index=True) + category: Mapped[str | None] = mapped_column(String(100), nullable=True) + 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) + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=100) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + + applicable_individual: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + applicable_proprietorship: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + applicable_partnership: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + applicable_llp: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + applicable_company: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + applicable_trust: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + applicable_society: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + is_client_requestable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + is_consultant_requestable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + 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, + ) + + service_category = relationship("ServiceCategory", back_populates="catalogue_items") + firm_services = relationship( + "FirmServiceSelection", + back_populates="catalogue", + cascade="all, delete-orphan", + passive_deletes=True, + ) + task_templates = relationship( + "FirmServiceTaskTemplate", + back_populates="catalogue", + cascade="all, delete-orphan", + passive_deletes=True, + ) + default_task_templates = relationship( + "ServiceDefaultTaskTemplate", + back_populates="catalogue", + cascade="all, delete-orphan", + passive_deletes=True, + order_by="ServiceDefaultTaskTemplate.sequence_no.asc()", + ) + + due_date_rules = relationship( + "ServiceDueDateRule", + back_populates="catalogue", + cascade="all, delete-orphan", + passive_deletes=True, + order_by="ServiceDueDateRule.sort_order.asc()", + ) + + +class ServiceDefaultTaskTemplate(CommonBase): + __tablename__ = "service_default_task_templates" + __table_args__ = ( + UniqueConstraint("service_catalogue_id", "sequence_no", name="uq_service_default_task_templates_sequence"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + service_catalogue_id: Mapped[int] = mapped_column( + ForeignKey("service_catalogues.id", ondelete="CASCADE"), nullable=False, index=True + ) + task_name: Mapped[str] = mapped_column(String(200), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + sequence_no: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + default_role_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + is_mandatory: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + requires_review: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + + catalogue = relationship("ServiceCatalogue", back_populates="default_task_templates") + + +class FirmServiceSelection(CommonBase): + __tablename__ = "firm_service_selections" + __table_args__ = ( + UniqueConstraint("tenant_id", "service_catalogue_id", name="uq_firm_service_selections_tenant_catalogue"), + ) + + 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) + service_catalogue_id: Mapped[int] = mapped_column( + ForeignKey("service_catalogues.id", ondelete="CASCADE"), nullable=False, index=True + ) + is_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + default_branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True) + activated_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) + activated_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, + ) + + catalogue = relationship("ServiceCatalogue", back_populates="firm_services") + + +class FirmServiceTaskTemplate(CommonBase): + __tablename__ = "firm_service_task_templates" + __table_args__ = ( + UniqueConstraint( + "tenant_id", "service_catalogue_id", "sequence_no", + name="uq_firm_service_task_templates_sequence", + ), + ) + + 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) + service_catalogue_id: Mapped[int] = mapped_column( + ForeignKey("service_catalogues.id", ondelete="CASCADE"), nullable=False, index=True + ) + + task_name: Mapped[str] = mapped_column(String(200), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + sequence_no: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + + default_role_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + is_mandatory: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + requires_review: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + + 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, + ) + + catalogue = relationship("ServiceCatalogue", back_populates="task_templates") + document_requirements = relationship( + "FirmTaskDocumentRequirement", + back_populates="task_template", + cascade="all, delete-orphan", + passive_deletes=True, + order_by="FirmTaskDocumentRequirement.sort_order.asc()", + ) + document_templates = relationship( + "FirmTaskDocumentTemplate", + back_populates="task_template", + cascade="all, delete-orphan", + passive_deletes=True, + order_by="FirmTaskDocumentTemplate.uploaded_at_utc.desc()", + ) + + +class FirmTaskDocumentRequirement(CommonBase): + """Document required at service task-template level. + + These rows define what must/should be collected when engagement task + instances are generated from a firm task template. Actual uploaded files are + linked to ClientServiceTaskInstance through EngagementDocument.task_instance_id. + """ + + __tablename__ = "firm_task_document_requirements" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "firm_task_template_id", + "document_name", + name="uq_firm_task_document_requirements_name", + ), + ) + + 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) + service_catalogue_id: Mapped[int] = mapped_column(ForeignKey("service_catalogues.id", ondelete="CASCADE"), nullable=False, index=True) + firm_task_template_id: Mapped[int] = mapped_column(ForeignKey("firm_service_task_templates.id", ondelete="CASCADE"), nullable=False, index=True) + + document_name: Mapped[str] = mapped_column(String(200), nullable=False) + document_type: Mapped[str] = mapped_column(String(80), nullable=False, default="GENERAL", index=True) + is_mandatory: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + allowed_file_types: Mapped[str | None] = mapped_column(String(255), nullable=True) + instructions: Mapped[str | None] = mapped_column(Text, nullable=True) + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=100) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + + 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) + + task_template = relationship("FirmServiceTaskTemplate", back_populates="document_requirements") + catalogue = relationship("ServiceCatalogue") + + +class FirmTaskDocumentTemplate(CommonBase): + """Reusable uploaded template file attached to a firm task template. + + Example: GST registration NOC format, partnership deed format, agreement + draft, company incorporation checklist, board resolution format etc. + """ + + __tablename__ = "firm_task_document_templates" + + 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) + service_catalogue_id: Mapped[int] = mapped_column(ForeignKey("service_catalogues.id", ondelete="CASCADE"), nullable=False, index=True) + firm_task_template_id: Mapped[int] = mapped_column(ForeignKey("firm_service_task_templates.id", ondelete="CASCADE"), nullable=False, index=True) + + template_name: Mapped[str] = mapped_column(String(200), nullable=False) + template_category: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + original_filename: Mapped[str] = mapped_column(String(255), nullable=False) + stored_filename: Mapped[str] = mapped_column(String(255), nullable=False) + content_type: Mapped[str | None] = mapped_column(String(150), nullable=True) + file_size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + local_relative_path: Mapped[str] = mapped_column(String(1000), nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True) + + uploaded_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) + uploaded_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) + + task_template = relationship("FirmServiceTaskTemplate", back_populates="document_templates") + catalogue = relationship("ServiceCatalogue") + + +class ServiceDueDateRule(CommonBase): + """Statutory/compliance due-date rule attached to a service catalogue item.""" + + __tablename__ = "service_due_date_rules" + __table_args__ = ( + UniqueConstraint("service_catalogue_id", "rule_name", name="uq_service_due_date_rules_catalogue_name"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + service_catalogue_id: Mapped[int] = mapped_column( + ForeignKey("service_catalogues.id", ondelete="CASCADE"), nullable=False, index=True + ) + rule_name: Mapped[str] = mapped_column(String(150), nullable=False) + period_type: Mapped[str] = mapped_column(String(30), nullable=False, default="yearly", index=True) + due_year_basis: Mapped[str] = mapped_column(String(40), nullable=False, default="assessment_year_start") + due_day: Mapped[int | None] = mapped_column(Integer, nullable=True) + due_month: Mapped[int | None] = mapped_column(Integer, nullable=True) + due_month_offset: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + days_offset_after_event: Mapped[int | None] = mapped_column(Integer, nullable=True) + renewal_days_before_expiry: Mapped[int | None] = mapped_column(Integer, nullable=True) + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=100) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True) + remarks: Mapped[str | None] = mapped_column(Text, nullable=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, + ) + + catalogue = relationship("ServiceCatalogue", back_populates="due_date_rules") + + +class ServiceDueDateExtension(CommonBase): + """History of statutory due-date extensions for a service/rule/FY/period.""" + + __tablename__ = "service_due_date_extensions" + + 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) + service_catalogue_id: Mapped[int] = mapped_column( + ForeignKey("service_catalogues.id", ondelete="CASCADE"), nullable=False, index=True + ) + due_date_rule_id: Mapped[int | None] = mapped_column( + ForeignKey("service_due_date_rules.id", ondelete="SET NULL"), nullable=True, index=True + ) + financial_year: Mapped[str] = mapped_column(String(9), nullable=False, index=True) + assessment_year: Mapped[str | None] = mapped_column(String(9), nullable=True, index=True) + period_label: Mapped[str | None] = mapped_column(String(30), nullable=True, index=True) + previous_due_date: Mapped[date | None] = mapped_column(Date, nullable=True) + extended_due_date: Mapped[date] = mapped_column(Date, nullable=False, index=True) + extension_sequence: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + notification_reference: Mapped[str | None] = mapped_column(String(200), nullable=True) + notification_date: Mapped[date | None] = mapped_column(Date, nullable=True) + remarks: Mapped[str | None] = mapped_column(Text, nullable=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, + ) + + catalogue = relationship("ServiceCatalogue") + due_rule = relationship("ServiceDueDateRule") + + +class ClientServiceSubscription(CommonBase): + """Firm-level subscription of an enabled service to a specific client.""" + + __tablename__ = "client_service_subscriptions" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "client_id", + "service_catalogue_id", + "financial_year", + name="uq_client_service_subscription_tenant_client_service_year", + ), + ) + + 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) + branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True) + client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, 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) + + assigned_partner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True) + assigned_manager_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True) + assigned_staff_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True) + review_partner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True) + + financial_year: Mapped[str] = mapped_column(String(9), nullable=False, default="2025-26", index=True) + assessment_year: Mapped[str | None] = mapped_column(String(9), nullable=True, index=True) + engagement_type: Mapped[str] = mapped_column(String(20), nullable=False, default="non_assurance", index=True) + due_date_rule_id: Mapped[int | None] = mapped_column(ForeignKey("service_due_date_rules.id", ondelete="SET NULL"), nullable=True, index=True) + original_due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True) + current_due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True) + due_date_source: Mapped[str | None] = mapped_column(String(30), nullable=True) + expiry_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True) + start_date: Mapped[date | None] = mapped_column(Date, nullable=True) + end_date: Mapped[date | None] = mapped_column(Date, nullable=True) + status: Mapped[str] = mapped_column(String(30), nullable=False, default="active", index=True) + remarks: Mapped[str | None] = mapped_column(Text, nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True) + is_locked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True) + locked_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + locked_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=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) + + client = relationship("Client") + catalogue = relationship("ServiceCatalogue") + due_date_rule = relationship("ServiceDueDateRule", foreign_keys=[due_date_rule_id]) + firm_selection = relationship("FirmServiceSelection") + assigned_partner = relationship("User", foreign_keys=[assigned_partner_user_id]) + assigned_manager = relationship("User", foreign_keys=[assigned_manager_user_id]) + assigned_staff = relationship("User", foreign_keys=[assigned_staff_user_id]) + review_partner = relationship("User", foreign_keys=[review_partner_user_id]) + locked_by = relationship("User", foreign_keys=[locked_by_user_id]) + + +class ClientServiceTaskInstance(CommonBase): + """Execution task generated from a firm service task template for a client-service subscription.""" + + __tablename__ = "client_service_task_instances" + __table_args__ = ( + UniqueConstraint( + "subscription_id", + "firm_task_template_id", + "financial_year", + name="uq_client_service_task_subscription_template_year", + ), + ) + + 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) + branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True) + subscription_id: Mapped[int] = mapped_column(ForeignKey("client_service_subscriptions.id", ondelete="CASCADE"), nullable=False, index=True) + client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True) + service_catalogue_id: Mapped[int] = mapped_column(ForeignKey("service_catalogues.id", ondelete="CASCADE"), nullable=False, index=True) + firm_task_template_id: Mapped[int | None] = mapped_column(ForeignKey("firm_service_task_templates.id", ondelete="SET NULL"), nullable=True, index=True) + + financial_year: Mapped[str] = mapped_column(String(9), nullable=False, default="2025-26", index=True) + assessment_year: Mapped[str | None] = mapped_column(String(9), nullable=True, index=True) + task_name: Mapped[str] = mapped_column(String(200), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + sequence_no: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + default_role_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + + assigned_to_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True) + internal_target_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True) + status: Mapped[str] = mapped_column(String(30), nullable=False, default="pending", index=True) + priority: Mapped[str] = mapped_column(String(20), nullable=False, default="normal") + remarks: Mapped[str | None] = mapped_column(Text, nullable=True) + + started_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + completed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True) + is_locked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True) + locked_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + locked_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=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) + + subscription = relationship("ClientServiceSubscription") + client = relationship("Client") + catalogue = relationship("ServiceCatalogue") + template = relationship("FirmServiceTaskTemplate") + assigned_to = relationship("User", foreign_keys=[assigned_to_user_id]) + locked_by = relationship("User", foreign_keys=[locked_by_user_id]) + documents = relationship( + "EngagementDocument", + primaryjoin="ClientServiceTaskInstance.id == foreign(EngagementDocument.task_instance_id)", + viewonly=True, + order_by="EngagementDocument.updated_at_utc.desc()", + ) + comments = relationship( + "ServiceTaskComment", + back_populates="task", + cascade="all, delete-orphan", + passive_deletes=True, + order_by="ServiceTaskComment.created_at_utc.desc()", + ) + + +class ServiceTaskComment(CommonBase): + """Communication timeline entry linked to a service task instance.""" + + __tablename__ = "service_task_comments" + + 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) + branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True) + subscription_id: Mapped[int] = mapped_column(ForeignKey("client_service_subscriptions.id", ondelete="CASCADE"), nullable=False, index=True) + task_instance_id: Mapped[int] = mapped_column(ForeignKey("client_service_task_instances.id", ondelete="CASCADE"), nullable=False, index=True) + + comment_type: Mapped[str] = mapped_column(String(40), nullable=False, default="internal_note", index=True) + visibility: Mapped[str] = mapped_column(String(30), nullable=False, default="internal", index=True) + message: Mapped[str] = mapped_column(Text, nullable=False) + + created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=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) + is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True) + + task = relationship("ClientServiceTaskInstance", back_populates="comments") + subscription = relationship("ClientServiceSubscription") + created_by = relationship("User", foreign_keys=[created_by_user_id]) diff --git a/app/modules/services/services.py b/app/modules/services/services.py new file mode 100644 index 0000000..16437d1 --- /dev/null +++ b/app/modules/services/services.py @@ -0,0 +1,248 @@ +from __future__ import annotations + +import re +from sqlalchemy import func, or_, select +from sqlalchemy.orm import Session, selectinload + +from app.modules.core.tenancy.models import Branch +from app.modules.services.models import ( + FirmServiceSelection, + FirmServiceTaskTemplate, + ServiceCatalogue, + ServiceCategory, + ServiceDefaultTaskTemplate, + ServiceDueDateRule, +) + +RECURRENCE_CHOICES = [ + ("one_time", "One Time"), + ("monthly", "Monthly"), + ("quarterly", "Quarterly"), + ("yearly", "Yearly"), + ("event_based", "Event Based"), + ("custom", "Custom"), +] + +ENGAGEMENT_TYPE_CHOICES = [ + ("assurance", "Assurance"), + ("non_assurance", "Non-Assurance"), +] + +VALID_ENGAGEMENT_TYPES = {value for value, _label in ENGAGEMENT_TYPE_CHOICES} + + +def normalize_engagement_type(value: str | None) -> str: + value = (value or "").strip().lower().replace("-", "_").replace(" ", "_") + if value in {"assurance", "audit", "aud", "certification", "certificate", "attestation"}: + return "assurance" + if value in {"non_assurance", "nonassurance", "non_audit", "nonaudit", "non", "compliance", "consulting", "consultancy"}: + return "non_assurance" + return "non_assurance" + + +def engagement_type_label(value: str | None) -> str: + normalized = normalize_engagement_type(value) + return "Assurance" if normalized == "assurance" else "Non-Assurance" + + +def normalize_code(value: str) -> str: + value = (value or "").strip().upper() + value = re.sub(r"[^A-Z0-9]+", "-", value) + value = re.sub(r"-+", "-", value).strip("-") + return value + + +def list_categories(db: Session, *, q: str = ""): + query = select(ServiceCategory) + if q.strip(): + term = f"%{q.strip()}%" + query = query.where(or_(ServiceCategory.code.ilike(term), ServiceCategory.name.ilike(term))) + return db.execute(query.order_by(ServiceCategory.sort_order.asc(), ServiceCategory.name.asc())).scalars().all() + + +def get_category(db: Session, category_id: int) -> ServiceCategory | None: + return db.execute(select(ServiceCategory).where(ServiceCategory.id == category_id)).scalar_one_or_none() + + +def list_catalogue_payload(db: Session, *, q: str = "", category_id: int | None = None, recurrence_type: str = "", engagement_type: str = "", page: int = 1, per_page: int = 20): + query = select(ServiceCatalogue).options( + selectinload(ServiceCatalogue.service_category), + selectinload(ServiceCatalogue.default_task_templates), + selectinload(ServiceCatalogue.due_date_rules), + ) + if q.strip(): + term = f"%{q.strip()}%" + query = query.outerjoin(ServiceCategory, ServiceCategory.id == ServiceCatalogue.category_id).where( + or_( + ServiceCatalogue.service_code.ilike(term), + ServiceCatalogue.service_name.ilike(term), + ServiceCatalogue.category.ilike(term), + ServiceCategory.name.ilike(term), + ) + ) + if category_id: + query = query.where(ServiceCatalogue.category_id == category_id) + if recurrence_type.strip(): + query = query.where(ServiceCatalogue.recurrence_type == recurrence_type.strip()) + if engagement_type.strip(): + query = query.where(ServiceCatalogue.engagement_type == normalize_engagement_type(engagement_type)) + + total = db.execute(select(func.count()).select_from(query.subquery())).scalar_one() + rows = db.execute( + query.order_by(ServiceCatalogue.sort_order.asc(), ServiceCatalogue.service_name.asc()) + .offset((page - 1) * per_page) + .limit(per_page) + ).scalars().all() + + return { + "rows": rows, + "q": q, + "category_id": category_id, + "recurrence_type": recurrence_type, + "engagement_type": engagement_type, + "page": page, + "per_page": per_page, + "total": total, + "pages": max(1, (total + per_page - 1) // per_page), + } + + +def list_firm_services_payload(db: Session, *, tenant_id: int, q: str = ""): + query = ( + select(FirmServiceSelection, ServiceCatalogue, Branch) + .join(ServiceCatalogue, ServiceCatalogue.id == FirmServiceSelection.service_catalogue_id) + .outerjoin(Branch, Branch.id == FirmServiceSelection.default_branch_id) + .where( + FirmServiceSelection.tenant_id == tenant_id, + FirmServiceSelection.is_enabled.is_(True), + ) + ) + if q.strip(): + term = f"%{q.strip()}%" + query = query.outerjoin(ServiceCategory, ServiceCategory.id == ServiceCatalogue.category_id).where( + or_( + ServiceCatalogue.service_code.ilike(term), + ServiceCatalogue.service_name.ilike(term), + ServiceCatalogue.category.ilike(term), + ServiceCategory.name.ilike(term), + ) + ) + rows = db.execute(query.order_by(ServiceCatalogue.sort_order.asc(), ServiceCatalogue.service_name.asc())).all() + return [ + {"selection": selection, "catalogue": catalogue, "branch": branch} + for selection, catalogue, branch in rows + ] + + +def list_disabled_catalogues(db: Session, *, tenant_id: int, q: str = ""): + enabled_subq = ( + select(FirmServiceSelection.service_catalogue_id) + .where( + FirmServiceSelection.tenant_id == tenant_id, + FirmServiceSelection.is_enabled.is_(True), + ) + ) + + query = select(ServiceCatalogue).where(~ServiceCatalogue.id.in_(enabled_subq)).options( + selectinload(ServiceCatalogue.service_category), + selectinload(ServiceCatalogue.default_task_templates), + selectinload(ServiceCatalogue.due_date_rules), + ) + if q.strip(): + term = f"%{q.strip()}%" + query = query.outerjoin(ServiceCategory, ServiceCategory.id == ServiceCatalogue.category_id).where( + or_( + ServiceCatalogue.service_code.ilike(term), + ServiceCatalogue.service_name.ilike(term), + ServiceCatalogue.category.ilike(term), + ServiceCategory.name.ilike(term), + ) + ) + return db.execute(query.order_by(ServiceCatalogue.sort_order.asc(), ServiceCatalogue.service_name.asc())).scalars().all() + + +def get_catalogue(db: Session, catalogue_id: int) -> ServiceCatalogue | None: + return db.execute( + select(ServiceCatalogue) + .options( + selectinload(ServiceCatalogue.service_category), + selectinload(ServiceCatalogue.default_task_templates), + ) + .where(ServiceCatalogue.id == catalogue_id) + ).scalar_one_or_none() + + +def get_firm_selection(db: Session, *, tenant_id: int, catalogue_id: int) -> FirmServiceSelection | None: + return db.execute( + select(FirmServiceSelection).where( + FirmServiceSelection.tenant_id == tenant_id, + FirmServiceSelection.service_catalogue_id == catalogue_id, + ) + ).scalar_one_or_none() + + +def get_firm_task_templates(db: Session, *, tenant_id: int, catalogue_id: int): + return db.execute( + select(FirmServiceTaskTemplate) + .where( + FirmServiceTaskTemplate.tenant_id == tenant_id, + FirmServiceTaskTemplate.service_catalogue_id == catalogue_id, + ) + .order_by(FirmServiceTaskTemplate.sequence_no.asc(), FirmServiceTaskTemplate.id.asc()) + ).scalars().all() + + +def get_default_task_templates(db: Session, *, catalogue_id: int): + return db.execute( + select(ServiceDefaultTaskTemplate) + .where(ServiceDefaultTaskTemplate.service_catalogue_id == catalogue_id) + .order_by(ServiceDefaultTaskTemplate.sequence_no.asc(), ServiceDefaultTaskTemplate.id.asc()) + ).scalars().all() + + +def next_task_sequence(db: Session, *, tenant_id: int, catalogue_id: int) -> int: + max_seq = db.execute( + select(func.max(FirmServiceTaskTemplate.sequence_no)).where( + FirmServiceTaskTemplate.tenant_id == tenant_id, + FirmServiceTaskTemplate.service_catalogue_id == catalogue_id, + ) + ).scalar_one() + return int(max_seq or 0) + 1 + + +def next_default_task_sequence(db: Session, *, catalogue_id: int) -> int: + max_seq = db.execute( + select(func.max(ServiceDefaultTaskTemplate.sequence_no)).where( + ServiceDefaultTaskTemplate.service_catalogue_id == catalogue_id, + ) + ).scalar_one() + return int(max_seq or 0) + 1 + +def get_firm_task_template( + db: Session, + *, + tenant_id: int, + catalogue_id: int, + task_id: int, +) -> FirmServiceTaskTemplate | None: + return db.execute( + select(FirmServiceTaskTemplate).where( + FirmServiceTaskTemplate.id == task_id, + FirmServiceTaskTemplate.tenant_id == tenant_id, + FirmServiceTaskTemplate.service_catalogue_id == catalogue_id, + ) + ).scalar_one_or_none() + + +def get_default_task_template( + db: Session, + *, + catalogue_id: int, + task_id: int, +) -> ServiceDefaultTaskTemplate | None: + return db.execute( + select(ServiceDefaultTaskTemplate).where( + ServiceDefaultTaskTemplate.id == task_id, + ServiceDefaultTaskTemplate.service_catalogue_id == catalogue_id, + ) + ).scalar_one_or_none() diff --git a/app/modules/services/task_documents.py b/app/modules/services/task_documents.py new file mode 100644 index 0000000..648d7d1 --- /dev/null +++ b/app/modules/services/task_documents.py @@ -0,0 +1,243 @@ +from __future__ import annotations + +import os +import shutil +from pathlib import Path +from uuid import uuid4 + +from sqlalchemy import select +from sqlalchemy.orm import Session, joinedload + +from app.modules.documents.models import EngagementDocument +from app.modules.documents.services import DEFAULT_STORAGE_ROOT, sanitize_segment, save_uploaded_revision +from app.modules.services.models import ( + ClientServiceSubscription, + ClientServiceTaskInstance, + FirmServiceTaskTemplate, + FirmTaskDocumentRequirement, + FirmTaskDocumentTemplate, +) + +TEMPLATE_UPLOAD_ROOT = Path(os.getenv("DOCUMENT_TEMPLATE_STORAGE_ROOT", str(DEFAULT_STORAGE_ROOT.parent / "document_templates"))).resolve() + + +def list_task_document_requirements(db: Session, *, tenant_id: int, firm_task_template_id: int) -> list[FirmTaskDocumentRequirement]: + return db.execute( + select(FirmTaskDocumentRequirement) + .where( + FirmTaskDocumentRequirement.tenant_id == int(tenant_id), + FirmTaskDocumentRequirement.firm_task_template_id == int(firm_task_template_id), + ) + .order_by(FirmTaskDocumentRequirement.sort_order.asc(), FirmTaskDocumentRequirement.id.asc()) + ).scalars().all() + + +def get_task_document_requirement(db: Session, *, requirement_id: int, tenant_id: int | None = None) -> FirmTaskDocumentRequirement | None: + stmt = select(FirmTaskDocumentRequirement).where(FirmTaskDocumentRequirement.id == int(requirement_id)) + if tenant_id is not None: + stmt = stmt.where(FirmTaskDocumentRequirement.tenant_id == int(tenant_id)) + return db.execute(stmt).scalar_one_or_none() + + +def create_task_document_requirement( + db: Session, + *, + task_template: FirmServiceTaskTemplate, + document_name: str, + document_type: str, + is_mandatory: bool, + allowed_file_types: str | None, + instructions: str | None, + sort_order: int, + user, +) -> FirmTaskDocumentRequirement: + row = FirmTaskDocumentRequirement( + tenant_id=task_template.tenant_id, + service_catalogue_id=task_template.service_catalogue_id, + firm_task_template_id=task_template.id, + document_name=document_name.strip()[:200], + document_type=(document_type or "GENERAL").strip().upper()[:80] or "GENERAL", + is_mandatory=bool(is_mandatory), + allowed_file_types=(allowed_file_types or "").strip()[:255] or None, + instructions=(instructions or "").strip() or None, + sort_order=int(sort_order or 100), + is_active=True, + created_by_user_id=getattr(user, "id", None), + updated_by_user_id=getattr(user, "id", None), + ) + db.add(row) + db.flush() + return row + + +def update_task_document_requirement( + db: Session, + *, + requirement: FirmTaskDocumentRequirement, + document_name: str, + document_type: str, + is_mandatory: bool, + allowed_file_types: str | None, + instructions: str | None, + sort_order: int, + is_active: bool, + user, +) -> FirmTaskDocumentRequirement: + requirement.document_name = document_name.strip()[:200] + requirement.document_type = (document_type or "GENERAL").strip().upper()[:80] or "GENERAL" + requirement.is_mandatory = bool(is_mandatory) + requirement.allowed_file_types = (allowed_file_types or "").strip()[:255] or None + requirement.instructions = (instructions or "").strip() or None + requirement.sort_order = int(sort_order or 100) + requirement.is_active = bool(is_active) + requirement.updated_by_user_id = getattr(user, "id", None) + db.flush() + return requirement + + +def list_task_document_templates(db: Session, *, tenant_id: int, firm_task_template_id: int) -> list[FirmTaskDocumentTemplate]: + return db.execute( + select(FirmTaskDocumentTemplate) + .where( + FirmTaskDocumentTemplate.tenant_id == int(tenant_id), + FirmTaskDocumentTemplate.firm_task_template_id == int(firm_task_template_id), + FirmTaskDocumentTemplate.is_active.is_(True), + ) + .order_by(FirmTaskDocumentTemplate.uploaded_at_utc.desc(), FirmTaskDocumentTemplate.id.desc()) + ).scalars().all() + + +def _template_relative_path(task_template: FirmServiceTaskTemplate, original_filename: str, template_id: int) -> Path: + suffix = Path(original_filename or "template.bin").suffix or ".bin" + safe_name = sanitize_segment(Path(original_filename or "template.bin").stem, "template")[:80] + return ( + Path(f"tenant_{task_template.tenant_id}") + / f"service_{task_template.service_catalogue_id}" + / f"task_{task_template.id}" + / f"TPL{template_id:06d}_{safe_name}_{uuid4().hex[:8]}{suffix}" + ) + + +def save_task_document_template( + db: Session, + *, + task_template: FirmServiceTaskTemplate, + template_name: str, + template_category: str | None, + description: str | None, + upload_file, + user, +) -> FirmTaskDocumentTemplate: + original_filename = Path(upload_file.filename or "template.bin").name + row = FirmTaskDocumentTemplate( + tenant_id=task_template.tenant_id, + service_catalogue_id=task_template.service_catalogue_id, + firm_task_template_id=task_template.id, + template_name=(template_name or original_filename).strip()[:200], + template_category=(template_category or "").strip()[:100] or None, + description=(description or "").strip() or None, + original_filename=original_filename, + stored_filename="PENDING", + content_type=getattr(upload_file, "content_type", None), + file_size_bytes=0, + local_relative_path="PENDING", + uploaded_by_user_id=getattr(user, "id", None), + ) + db.add(row) + db.flush() + + rel_path = _template_relative_path(task_template, original_filename, row.id) + abs_path = TEMPLATE_UPLOAD_ROOT / rel_path + abs_path.parent.mkdir(parents=True, exist_ok=True) + total = 0 + with abs_path.open("wb") as out: + while True: + chunk = upload_file.file.read(1024 * 1024) + if not chunk: + break + total += len(chunk) + out.write(chunk) + row.stored_filename = abs_path.name + row.file_size_bytes = total + row.local_relative_path = str(rel_path).replace("\\", "/") + db.flush() + return row + + +def template_absolute_path(template: FirmTaskDocumentTemplate) -> Path: + return TEMPLATE_UPLOAD_ROOT / (template.local_relative_path or "") + + +def get_task_with_subscription(db: Session, task_id: int) -> ClientServiceTaskInstance | None: + return db.execute( + select(ClientServiceTaskInstance) + .options( + joinedload(ClientServiceTaskInstance.subscription).joinedload(ClientServiceSubscription.client), + joinedload(ClientServiceTaskInstance.subscription).joinedload(ClientServiceSubscription.catalogue), + joinedload(ClientServiceTaskInstance.template), + ) + .where(ClientServiceTaskInstance.id == int(task_id)) + ).unique().scalar_one_or_none() + + +def list_documents_for_task(db: Session, task_id: int) -> list[EngagementDocument]: + return db.execute( + select(EngagementDocument) + .options(joinedload(EngagementDocument.versions), joinedload(EngagementDocument.document_requirement)) + .where(EngagementDocument.task_instance_id == int(task_id), EngagementDocument.is_deleted.is_(False)) + .order_by(EngagementDocument.updated_at_utc.desc(), EngagementDocument.id.desc()) + ).unique().scalars().all() + + +def requirement_upload_status(requirements: list[FirmTaskDocumentRequirement], documents: list[EngagementDocument]) -> list[dict]: + by_req: dict[int, list[EngagementDocument]] = {} + for doc in documents: + if doc.document_requirement_id: + by_req.setdefault(int(doc.document_requirement_id), []).append(doc) + payload = [] + for req in requirements: + docs = by_req.get(int(req.id), []) + payload.append({ + "requirement": req, + "documents": docs, + "is_uploaded": bool(docs), + "is_pending_mandatory": bool(req.is_mandatory and not docs), + }) + return payload + + +def save_uploaded_task_document( + db: Session, + *, + task: ClientServiceTaskInstance, + requirement: FirmTaskDocumentRequirement | None, + upload_file, + title: str, + document_type: str, + description: str | None, + remarks: str | None, + user, + existing_document_id: int | None = None, +) -> EngagementDocument: + engagement = task.subscription or db.get(ClientServiceSubscription, task.subscription_id) + if engagement is None: + raise ValueError("Task is not linked to a valid engagement.") + if requirement: + title = title or requirement.document_name + document_type = requirement.document_type or document_type + description = description or requirement.instructions + doc = save_uploaded_revision( + db, + engagement=engagement, + upload_file=upload_file, + title=title, + document_type=document_type, + description=description, + remarks=remarks, + user=user, + existing_document_id=existing_document_id, + ) + doc.task_instance_id = task.id + doc.document_requirement_id = requirement.id if requirement else None + db.flush() + return doc diff --git a/app/modules/services/templates/services/bulk_imports/index.html b/app/modules/services/templates/services/bulk_imports/index.html new file mode 100644 index 0000000..c08d9e1 --- /dev/null +++ b/app/modules/services/templates/services/bulk_imports/index.html @@ -0,0 +1,81 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+

Bulk Imports

+

Download Excel templates, fill data, and upload to configure services faster.

+
+ +
+ {% if can_client_assignment %} +
+

Bulk assign services to clients

+

For Firm Admin / Partner. Services must already be enabled for the active audit firm. Use expiry_date for renewal-before-expiry services like DSC renewal.

+ Download Template +
+ + + +
+
+
+ {% endif %} + + {% if can_due_date_extensions %} +
+

Import due date extensions

+

Imports government deadline extensions for the active audit firm and updates matching unlocked engagements.

+ Download Template +
+ + + +

Multiple extensions are stored as separate history rows. Duplicate rows are detected by service, rule, FY/AY, period, extended date and notification reference.

+
+
+
+ {% endif %} + + {% if can_firm_tasks %} +
+

Import firm task templates

+

For Firm Admin. Service must be enabled for active audit firm.

+ Download Template +
+ + + +
+
+
+ {% endif %} + + {% if can_system_import %} +
+

Import system service master

+

System Admin only. Creates/updates service categories, catalogue services, and optional due date rules/extensions in the workbook.

+ Download Template +
+ + + +

Template contains optional sheets: service_master, due_date_rules, due_date_extensions. Due date rules support renewal_based using renewal_days_before_expiry.

+
+
+
+ +
+

Import system default tasks

+

System Admin only. Creates/updates default task templates by service code and sequence no.

+ Download Template +
+ + + +
+
+
+ {% endif %} +
+
+{% endblock %} diff --git a/app/modules/services/templates/services/bulk_imports/result.html b/app/modules/services/templates/services/bulk_imports/result.html new file mode 100644 index 0000000..6d33c5e --- /dev/null +++ b/app/modules/services/templates/services/bulk_imports/result.html @@ -0,0 +1,37 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

{{ title }}

+

Import summary and row-level validation errors.

+
+ Back +
+ +
+
Created
{{ result.created }}
+
Updated
{{ result.updated }}
+
Skipped
{{ result.skipped }}
+
Errors
{{ result.errors|length }}
+
+ + {% if result.errors %} +
+

Errors found. No rows were committed.

+
+ + + + {% for err in result.errors %} + + {% endfor %} + +
RowMessage
{{ err.row }}{{ err.message }}
+
+
+ {% else %} +
Import completed successfully.
+ {% endif %} +
+{% endblock %} diff --git a/app/modules/services/templates/services/catalogue_detail.html b/app/modules/services/templates/services/catalogue_detail.html new file mode 100644 index 0000000..bacfaee --- /dev/null +++ b/app/modules/services/templates/services/catalogue_detail.html @@ -0,0 +1,156 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

{{ catalogue.service_name }}

+

{{ catalogue.service_code }} · {{ catalogue.service_category.name if catalogue.service_category else (catalogue.category or 'Uncategorised') }}

+
+
+ {% if can_edit %}Edit{% endif %} + Back to Services +
+
+ +
+
+

Catalogue Details

+
+
Recurrence
{{ catalogue.recurrence_type or '-' }}
+
Engagement Type
{{ 'Assurance' if catalogue.engagement_type == 'assurance' else 'Non-Assurance' }}
+
Sort order
{{ catalogue.sort_order }}
+
Description
{{ catalogue.description or 'No description added.' }}
+
Applicability
+ {% set labels = [] %} + {% if catalogue.applicable_individual %}{% set _ = labels.append('Individual') %}{% endif %} + {% if catalogue.applicable_proprietorship %}{% set _ = labels.append('Proprietorship') %}{% endif %} + {% if catalogue.applicable_partnership %}{% set _ = labels.append('Partnership') %}{% endif %} + {% if catalogue.applicable_llp %}{% set _ = labels.append('LLP') %}{% endif %} + {% if catalogue.applicable_company %}{% set _ = labels.append('Company') %}{% endif %} + {% if catalogue.applicable_trust %}{% set _ = labels.append('Trust') %}{% endif %} + {% if catalogue.applicable_society %}{% set _ = labels.append('Society') %}{% endif %} + {{ labels|join(', ') if labels else '-' }} +
+
+
+
+

Firm Service Selection

+
+ {% if current_selection and current_selection.is_enabled %} + Selected for Firm +

Firm task templates configured: {{ current_templates|length }}

+ {% if current_selection.default_branch_id %}

Default Branch ID: {{ current_selection.default_branch_id }}

{% endif %} + {% else %} + Not Selected +

Select this service for the active firm to customise firm task templates.

+ {% endif %} +
+ + {% if can_manage_firm_services %} +
+ +
+ + {% if branches %} + + {% else %} + +

No active branch found for this firm.

+ {% endif %} +
+ +
+ {% endif %} + + {% if current_selection and current_selection.is_enabled %} + + {% endif %} +
+
+ +
+
+
+

Due Date Rules

+

Rules define statutory due dates copied into engagements. Extensions are stored separately as history.

+
+ {% if can_edit %}Add Rule{% endif %} +
+
+ + + + {% for rule in due_rules %} + + + + + + + + {% else %} + + {% endfor %} + +
RuleTypeDue LogicStatus
{{ rule.rule_name }}{{ rule.period_type|replace('_',' ')|title }} + {% if rule.period_type in ['yearly', 'one_time'] %} + {{ '%02d'|format(rule.due_day or 0) }}-{{ '%02d'|format(rule.due_month or 0) }} based on {{ rule.due_year_basis|replace('_',' ') }} + {% elif rule.period_type in ['monthly', 'quarterly'] %} + Day {{ rule.due_day or '-' }} with month offset {{ rule.due_month_offset }} + {% elif rule.period_type == 'renewal_based' %} + Expiry date minus {{ rule.renewal_days_before_expiry or 0 }} day(s) + {% else %} + Manual / event based + {% endif %} + {{ 'Active' if rule.is_active else 'Inactive' }}{% if can_edit %}Edit{% endif %}
No due date rules configured yet.
+
+
+ +
+
+
+

Due Date Extensions

+

Each extension is preserved. New extensions update current due dates for matching unlocked engagements.

+
+ {% if can_edit %}Add Extension{% endif %} +
+
+ + + + {% for ext in due_extensions %} + + + + + + + + {% else %} + + {% endfor %} + +
FY / AY / PeriodSequencePreviousExtended ToReference
FY {{ ext.financial_year }}{% if ext.assessment_year %} / AY {{ ext.assessment_year }}{% endif %}{% if ext.period_label %} / {{ ext.period_label }}{% endif %}{{ ext.extension_sequence }}{{ ext.previous_due_date or '-' }}{{ ext.extended_due_date }}
{{ ext.notification_reference or '-' }}
{% if ext.notification_date %}
{{ ext.notification_date }}
{% endif %}
No due date extensions recorded yet.
+
+
+ +
+

System Default Tasks

{% if can_edit %}Manage{% endif %}
+
+ {% for task in default_templates %} +
{{ task.sequence_no }}. {{ task.task_name }}
Role: {{ task.default_role_name or '-' }} · Mandatory: {{ 'Yes' if task.is_mandatory else 'No' }} · Review: {{ 'Yes' if task.requires_review else 'No' }}
{% if task.description %}

{{ task.description }}

{% endif %}
+ {% else %}
No system default tasks configured yet.
{% endfor %} +
+
+
+{% endblock %} diff --git a/app/modules/services/templates/services/catalogue_form.html b/app/modules/services/templates/services/catalogue_form.html new file mode 100644 index 0000000..a699d6d --- /dev/null +++ b/app/modules/services/templates/services/catalogue_form.html @@ -0,0 +1,30 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+

{% if mode == 'edit' %}Edit Service Catalogue{% else %}Create Service Catalogue{% endif %}

+
+ +
+
+
+
+

This value will be copied to client engagements when the service is assigned.

+
+
+
+
+

Applicability

+
+ + + + + + + +
+
+
Cancel
+
+
+{% endblock %} diff --git a/app/modules/services/templates/services/catalogue_list.html b/app/modules/services/templates/services/catalogue_list.html new file mode 100644 index 0000000..987925e --- /dev/null +++ b/app/modules/services/templates/services/catalogue_list.html @@ -0,0 +1,110 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Service Catalogue

+

Standard service master. Firm Admin can select the services applicable for the active firm.

+
+ +
+ + {% if can_manage_firm_services %} +
+ Select the services your firm provides. Once selected, you can open Firm Task Templates and customise tasks for your firm. +
+ {% endif %} + +
+
+ + + + +
+
+
+ +
+ + + + + + + + + + + + + {% for row in rows %} + {% set selection = firm_selection_by_catalogue.get(row.id) if firm_selection_by_catalogue else None %} + + + + + + + + + {% else %} + + {% endfor %} + +
CodeNameCategoryRecurrenceFirm SelectionAction
{{ row.service_code }} +
{{ row.service_name }}
+
{{ 'Assurance' if row.engagement_type == 'assurance' else 'Non-Assurance' }}
+
{{ row.service_category.name if row.service_category else (row.category or '-') }}{{ row.recurrence_type|replace('_',' ')|title if row.recurrence_type else '-' }} + {% if selection and selection.is_enabled %} +
Selected for Firm
+ {% if selection.default_branch_id %}
Default Branch ID: {{ selection.default_branch_id }}
{% endif %} + {% else %} +
Not Selected
+ {% endif %} + + {% if can_manage_firm_services %} +
+ + {% if branches %} + + {% else %} + + {% endif %} + +
+ {% endif %} +
+ Open + {% if selection and selection.is_enabled %} + Firm Tasks + {% endif %} + {% if can_create %}Edit{% endif %} +
No catalogue services found.
+
+
+{% endblock %} diff --git a/app/modules/services/templates/services/category_form.html b/app/modules/services/templates/services/category_form.html new file mode 100644 index 0000000..fd61412 --- /dev/null +++ b/app/modules/services/templates/services/category_form.html @@ -0,0 +1,28 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+

{% if mode == 'edit' %}Edit Service Category{% else %}Create Service Category{% endif %}

+
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ Cancel + +
+
+
+{% endblock %} diff --git a/app/modules/services/templates/services/category_list.html b/app/modules/services/templates/services/category_list.html new file mode 100644 index 0000000..63889d0 --- /dev/null +++ b/app/modules/services/templates/services/category_list.html @@ -0,0 +1,34 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Service Categories

+

System-level grouping for service catalogue entries.

+
+ {% if can_create %}Add Category{% endif %} +
+
+
+ + +
+
+
+ + + + {% for row in rows %} + + + + + + + + {% else %}{% endfor %} + +
CodeNameOrderStatus
{{ row.code }}{{ row.name }}{{ row.sort_order }}{% if row.is_active %}Active{% else %}Inactive{% endif %}Edit
No categories found.
+
+
+{% endblock %} diff --git a/app/modules/services/templates/services/default_task_form.html b/app/modules/services/templates/services/default_task_form.html new file mode 100644 index 0000000..efc0f6c --- /dev/null +++ b/app/modules/services/templates/services/default_task_form.html @@ -0,0 +1,60 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Edit System Default Task

+

{{ service.service_code }} · {{ service.service_name }}

+
+ Back +
+ +
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + +
+ +
+ + +
+ +
+ To deactivate this system default task, untick Active and save. Existing firm-copied tasks will not be automatically changed. +
+ +
+ Cancel + +
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/modules/services/templates/services/default_templates_detail.html b/app/modules/services/templates/services/default_templates_detail.html new file mode 100644 index 0000000..757796f --- /dev/null +++ b/app/modules/services/templates/services/default_templates_detail.html @@ -0,0 +1,53 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

{{ service.service_name }}

+

System default task templates for {{ service.service_code }}

+
+ +
+
+
+ {% for task in default_tasks %} + {% if task.is_active or is_system_admin %} +
+
+
+
{{ task.sequence_no }}. {{ task.task_name }}
+
+ Role: {{ task.default_role_name or '-' }} · + Mandatory: {{ 'Yes' if task.is_mandatory else 'No' }} · + Review: {{ 'Yes' if task.requires_review else 'No' }} +
+
+
+ + {{ 'Active' if task.is_active else 'Inactive' }} + + Edit +
+
+ {% if task.description %} +

{{ task.description }}

+ {% endif %} +
+ {% endif %} + {% else %}
No default tasks configured yet.
{% endfor %} +
+
+
+

Add Default Task Template

+
+ +
+
+
+
+
+
+
+
+
+{% endblock %} diff --git a/app/modules/services/templates/services/default_templates_list.html b/app/modules/services/templates/services/default_templates_list.html new file mode 100644 index 0000000..09b8dc7 --- /dev/null +++ b/app/modules/services/templates/services/default_templates_list.html @@ -0,0 +1,18 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Default Task Templates

+

System-level default task templates by service catalogue.

+
+ Back to Catalogue +
+
+ + + {% for row in rows %}{% else %}{% endfor %} +
CodeServiceDefault Tasks
{{ row.service_code }}{{ row.service_name }}{{ row.default_task_templates|length }}Manage
No catalogue services found.
+
+
+{% endblock %} diff --git a/app/modules/services/templates/services/detail.html b/app/modules/services/templates/services/detail.html new file mode 100644 index 0000000..0797ba2 --- /dev/null +++ b/app/modules/services/templates/services/detail.html @@ -0,0 +1,20 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+

Engagement

{{ row.client.client_name if row.client else '-' }} · {{ row.catalogue.service_name if row.catalogue else '-' }} · FY {{ row.financial_year }}

+
+ {% if can_view_documents(current_user, current_user_permissions, current_user_roles) %}Documents{% endif %} + Back + {% if can_manage and not row.is_locked %}Edit{% endif %} +
+
+ {% if row.is_locked %}
This engagement is locked as historical record. It cannot be edited.
{% endif %} +
+

Client & Service

Client
{{ row.client.client_name if row.client else '-' }}
Service
{{ row.catalogue.service_name if row.catalogue else '-' }}
Financial Year
{{ row.financial_year or '-' }}
Assessment Year
{{ row.assessment_year or '-' }}
Engagement Type
{{ 'Assurance' if row.engagement_type == 'assurance' else 'Non-Assurance' }}
Original Due Date
{{ row.original_due_date or '-' }}
Expiry Date
{{ row.expiry_date or '-' }}
Current Due Date
{{ row.current_due_date or '-' }}{% if row.due_date_source %}{{ row.due_date_source|replace('_',' ')|title }}{% endif %}
Status
{{ 'Locked' if row.is_locked else row.status|replace('_',' ')|title }}{% if not row.is_active %} / Inactive{% endif %}
Period
{{ row.start_date or '-' }} to {{ row.end_date or '-' }}
+

Assignment

Partner
{{ row.assigned_partner.full_name if row.assigned_partner and row.assigned_partner.full_name else (row.assigned_partner.email if row.assigned_partner else '-') }}
Manager
{{ row.assigned_manager.full_name if row.assigned_manager and row.assigned_manager.full_name else (row.assigned_manager.email if row.assigned_manager else '-') }}
Staff
{{ row.assigned_staff.full_name if row.assigned_staff and row.assigned_staff.full_name else (row.assigned_staff.email if row.assigned_staff else '-') }}
Review Partner
{{ row.review_partner.full_name if row.review_partner and row.review_partner.full_name else (row.review_partner.email if row.review_partner else '-') }}
+
+ {% if can_manage and not row.is_locked %}

Year-end Lock

Lock this engagement when the year is complete. After locking, it becomes read-only history.

{% endif %} + {% if row.remarks %}

Remarks

{{ row.remarks }}

{% endif %} +
+{% endblock %} diff --git a/app/modules/services/templates/services/due_dates/extension_form.html b/app/modules/services/templates/services/due_dates/extension_form.html new file mode 100644 index 0000000..f0aa279 --- /dev/null +++ b/app/modules/services/templates/services/due_dates/extension_form.html @@ -0,0 +1,59 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Add Due Date Extension

+

{{ catalogue.service_name }} · {{ catalogue.service_code }}

+
+ Back +
+ +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +

Leave blank for annual services.

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ This extension will be stored as a new sequence. Matching unlocked engagements for the active audit firm will be updated to the new current due date. Locked engagements will be skipped. +
+
+ Cancel + +
+
+
+{% endblock %} diff --git a/app/modules/services/templates/services/due_dates/rule_form.html b/app/modules/services/templates/services/due_dates/rule_form.html new file mode 100644 index 0000000..af0636f --- /dev/null +++ b/app/modules/services/templates/services/due_dates/rule_form.html @@ -0,0 +1,73 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

{% if mode == 'edit' %}Edit Due Date Rule{% else %}Add Due Date Rule{% endif %}

+

{{ catalogue.service_name }} · {{ catalogue.service_code }}

+
+ Back +
+ +
+ +
+ + +
+
+ + +
+
+ + +

For Tax Audit AY 2026-27 due 30 Sep 2026, use assessment year start year.

+
+
+
+ + +
+
+ + +
+
+
+ + +

For monthly GST 20th of next month, use period type Monthly, due day 20, offset 1.

+
+
+ + +

For event-based rules like 30 days from AGM. Event-date calculation can be handled later.

+
+
+ + +

For renewal-before-expiry services like DSC/FSSAI. Due date = engagement expiry date minus these days.

+
+
+ + +
+
+ +
+
+ + +
+
+ Cancel + +
+
+
+{% endblock %} diff --git a/app/modules/services/templates/services/engagements/detail.html b/app/modules/services/templates/services/engagements/detail.html new file mode 100644 index 0000000..9093894 --- /dev/null +++ b/app/modules/services/templates/services/engagements/detail.html @@ -0,0 +1,41 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+

Engagement

{{ row.client.client_name if row.client else '-' }} · {{ row.catalogue.service_name if row.catalogue else '-' }} · FY {{ row.financial_year }}

+
+ Back + {% if can_manage and not row.is_locked %}Edit{% endif %} +
+
+ {% if row.is_locked %}
This engagement is locked as historical record. It cannot be edited.
{% endif %} +
+

Client & Service

Client
{{ row.client.client_name if row.client else '-' }}
Service
{{ row.catalogue.service_name if row.catalogue else '-' }}
Financial Year
{{ row.financial_year or '-' }}
Assessment Year
{{ row.assessment_year or '-' }}
Engagement Type
{{ 'Assurance' if row.engagement_type == 'assurance' else 'Non-Assurance' }}
Original Due Date
{{ row.original_due_date or '-' }}
Expiry Date
{{ row.expiry_date or '-' }}
Current Due Date
{{ row.current_due_date or '-' }}{% if row.due_date_source %}{{ row.due_date_source|replace('_',' ')|title }}{% endif %}
Status
{{ 'Locked' if row.is_locked else row.status|replace('_',' ')|title }}{% if not row.is_active %} / Inactive{% endif %}
Period
{{ row.start_date or '-' }} to {{ row.end_date or '-' }}
+

Assignment

Partner
{{ row.assigned_partner.full_name if row.assigned_partner and row.assigned_partner.full_name else (row.assigned_partner.email if row.assigned_partner else '-') }}
Manager
{{ row.assigned_manager.full_name if row.assigned_manager and row.assigned_manager.full_name else (row.assigned_manager.email if row.assigned_manager else '-') }}
Staff
{{ row.assigned_staff.full_name if row.assigned_staff and row.assigned_staff.full_name else (row.assigned_staff.email if row.assigned_staff else '-') }}
Review Partner
{{ row.review_partner.full_name if row.review_partner and row.review_partner.full_name else (row.review_partner.email if row.review_partner else '-') }}
+
+ {% if can_manage and not row.is_locked %}

Year-end Lock

Lock this engagement when the year is complete. After locking, it becomes read-only history.

{% endif %} + +
+
+

Engagement Tasks & Task Documents

+

Open each task to upload documents against its configured document requirements.

+
+ + + + {% for task in tasks or [] %} + + + + + + + {% else %} + + {% endfor %} + +
SeqTaskStatusDocuments
{{ task.sequence_no }}
{{ task.task_name }}
{% if task.description %}
{{ task.description }}
{% endif %}
{{ task.status.replace('_',' ').title() }}Open Task Documents
No execution tasks generated yet. Generate work tracker tasks first.
+
+ {% if row.remarks %}

Remarks

{{ row.remarks }}

{% endif %} +
+{% endblock %} diff --git a/app/modules/services/templates/services/engagements/form.html b/app/modules/services/templates/services/engagements/form.html new file mode 100644 index 0000000..49bea6c --- /dev/null +++ b/app/modules/services/templates/services/engagements/form.html @@ -0,0 +1,66 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

{% if mode == 'edit' %}Edit Engagement{% else %}Assign Service to Client{% endif %}

+

Engagements are maintained financial-year wise. Review partner is used only for assurance engagements of partnership audit firms.

+
+ Back +
+ +
+ + +
+ + {% if mode == 'edit' %} +
{{ subscription.financial_year }}
+ {% else %} + + {% endif %} +
+ +
+ + {% if mode == 'edit' %} +
{{ subscription.client.client_name if subscription and subscription.client else '-' }}
+ {% else %} + + {% endif %} +
+ +
+ + {% if mode == 'edit' %} +
+ {{ subscription.catalogue.service_name if subscription and subscription.catalogue else '-' }} · {{ 'Assurance' if subscription and subscription.engagement_type == 'assurance' else 'Non-Assurance' }} +
+ {% else %} + + {% endif %} +
+ +
+
+
+

Saved only for assurance engagements of partnership audit firms.

+ +
+
+
+

Use for renewal-before-expiry services like DSC/FSSAI. Current due date is calculated from the service due-date rule.

+
+
+
Cancel
+
+
+{% endblock %} diff --git a/app/modules/services/templates/services/engagements/list.html b/app/modules/services/templates/services/engagements/list.html new file mode 100644 index 0000000..e575c15 --- /dev/null +++ b/app/modules/services/templates/services/engagements/list.html @@ -0,0 +1,88 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Engagements

+

Year-wise client service engagements with locking for completed years.

+
+ {% if can_manage %} + + {% endif %} +
+ + {% if locked_count or skipped_count %} +
+ {% if locked_count %}{{ locked_count }} engagement{{ 's' if locked_count != 1 else '' }} locked.{% endif %} + {% if skipped_count %}{{ skipped_count }} skipped because already locked or not permitted.{% endif %} +
+ {% endif %} + +
+
+ + +
+
+ + +
+ + +
+ +
+ + + + {% if include_inactive %}{% endif %} + {% if can_lock_engagements %} +
+

Select completed engagements and lock them in bulk. Locked engagements become read-only history.

+ +
+ {% endif %} + +
+ + + + {% if can_lock_engagements %}{% endif %} + + + + + + + + + + + + {% for row in rows %} + + {% if can_lock_engagements %}{% endif %} + + + + + + + + + + {% else %} + + {% endfor %} + +
ClientServiceFY / AYDue DateTypeAssigned UsersStatusAction
{% if not row.is_locked %}{% endif %}
{{ row.client.client_name if row.client else '-' }}
{{ row.client.client_code if row.client else '' }}
{{ row.catalogue.service_name if row.catalogue else '-' }}
{{ row.catalogue.service_code if row.catalogue else '' }}
FY: {{ row.financial_year or '-' }}
AY: {{ row.assessment_year or '-' }}
Current: {{ row.current_due_date or '-' }}
Original: {{ row.original_due_date or '-' }}
{% if row.expiry_date %}
Expiry: {{ row.expiry_date }}
{% endif %}{% if row.due_date_source %}
{{ row.due_date_source|replace('_',' ')|title }}
{% endif %}
{{ 'Assurance' if row.engagement_type == 'assurance' else 'Non-Assurance' }}
Partner: {{ row.assigned_partner.full_name if row.assigned_partner and row.assigned_partner.full_name else (row.assigned_partner.email if row.assigned_partner else '-') }}
Manager: {{ row.assigned_manager.full_name if row.assigned_manager and row.assigned_manager.full_name else (row.assigned_manager.email if row.assigned_manager else '-') }}
Staff: {{ row.assigned_staff.full_name if row.assigned_staff and row.assigned_staff.full_name else (row.assigned_staff.email if row.assigned_staff else '-') }}
Review: {{ row.review_partner.full_name if row.review_partner and row.review_partner.full_name else (row.review_partner.email if row.review_partner else '-') }}
{{ 'Locked' if row.is_locked else row.status|replace('_',' ')|title }}{% if not row.is_active %} / Inactive{% endif %}View{% if can_manage and not row.is_locked %}Edit{% endif %}
No engagements found for this financial year.
+
+
+
+{% endblock %} diff --git a/app/modules/services/templates/services/firm_task_form.html b/app/modules/services/templates/services/firm_task_form.html new file mode 100644 index 0000000..ecbe1af --- /dev/null +++ b/app/modules/services/templates/services/firm_task_form.html @@ -0,0 +1,60 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Edit Firm Task Template

+

{{ service.service_code }} · {{ service.service_name }}

+
+ Back +
+ +
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + +
+ +
+ + +
+ +
+ To deactivate this task, untick Active and save. No hard delete is used. +
+ +
+ Cancel + +
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/modules/services/templates/services/import.html b/app/modules/services/templates/services/import.html new file mode 100644 index 0000000..c5dff34 --- /dev/null +++ b/app/modules/services/templates/services/import.html @@ -0,0 +1,4 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +

Import Service Catalogue + Firm Task Templates

This import updates the system-level service catalogue and creates or updates task templates for your current firm.

Cancel
+{% endblock %} diff --git a/app/modules/services/templates/services/import_preview.html b/app/modules/services/templates/services/import_preview.html new file mode 100644 index 0000000..785c728 --- /dev/null +++ b/app/modules/services/templates/services/import_preview.html @@ -0,0 +1,4 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +

Import Preview

Audit Firm ID {{ tenant_id }} • Branch {{ branch_id }}

{% if preview.errors %}
Please fix these issues before importing:
    {% for err in preview.errors %}
  • {{ err }}
  • {% endfor %}
{% else %}
Catalogue Rows
{{ preview.catalogue_rows|length }}
Task Template Rows
{{ preview.task_rows|length }}
Catalogue Preview
{% for row in preview.catalogue_rows[:15] %}{% endfor %}
CodeNameCategory
{{ row.service_code }}{{ row.service_name }}{{ row.category or '-' }}
{% if preview.task_rows %}
Firm Task Template Preview
{% for row in preview.task_rows[:20] %}{% endfor %}
ServiceSeqTask
{{ row.service_code }}{{ row.sequence_no }}{{ row.task_name }}
{% endif %}
Back
{% endif %}
+{% endblock %} diff --git a/app/modules/services/templates/services/list.html b/app/modules/services/templates/services/list.html new file mode 100644 index 0000000..21e63dd --- /dev/null +++ b/app/modules/services/templates/services/list.html @@ -0,0 +1,88 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Engagements

+

Year-wise client service engagements with locking for completed years.

+
+ {% if can_manage %} + + {% endif %} +
+ + {% if locked_count or skipped_count %} +
+ {% if locked_count %}{{ locked_count }} engagement{{ 's' if locked_count != 1 else '' }} locked.{% endif %} + {% if skipped_count %}{{ skipped_count }} skipped because already locked or not permitted.{% endif %} +
+ {% endif %} + +
+
+ + +
+
+ + +
+ + +
+ +
+ + + + {% if include_inactive %}{% endif %} + {% if can_lock_engagements %} +
+

Select completed engagements and lock them in bulk. Locked engagements become read-only history.

+ +
+ {% endif %} + +
+ + + + {% if can_lock_engagements %}{% endif %} + + + + + + + + + + + + {% for row in rows %} + + {% if can_lock_engagements %}{% endif %} + + + + + + + + + + {% else %} + + {% endfor %} + +
ClientServiceFY / AYDue DateTypeAssigned UsersStatusAction
{% if not row.is_locked %}{% endif %}
{{ row.client.client_name if row.client else '-' }}
{{ row.client.client_code if row.client else '' }}
{{ row.catalogue.service_name if row.catalogue else '-' }}
{{ row.catalogue.service_code if row.catalogue else '' }}
FY: {{ row.financial_year or '-' }}
AY: {{ row.assessment_year or '-' }}
Current: {{ row.current_due_date or '-' }}
Original: {{ row.original_due_date or '-' }}
{% if row.expiry_date %}
Expiry: {{ row.expiry_date }}
{% endif %}{% if row.due_date_source %}
{{ row.due_date_source|replace('_',' ')|title }}
{% endif %}
{{ 'Assurance' if row.engagement_type == 'assurance' else 'Non-Assurance' }}
Partner: {{ row.assigned_partner.full_name if row.assigned_partner and row.assigned_partner.full_name else (row.assigned_partner.email if row.assigned_partner else '-') }}
Manager: {{ row.assigned_manager.full_name if row.assigned_manager and row.assigned_manager.full_name else (row.assigned_manager.email if row.assigned_manager else '-') }}
Staff: {{ row.assigned_staff.full_name if row.assigned_staff and row.assigned_staff.full_name else (row.assigned_staff.email if row.assigned_staff else '-') }}
Review: {{ row.review_partner.full_name if row.review_partner and row.review_partner.full_name else (row.review_partner.email if row.review_partner else '-') }}
{{ 'Locked' if row.is_locked else row.status|replace('_',' ')|title }}{% if not row.is_active %} / Inactive{% endif %}View{% if can_view_documents(current_user, current_user_permissions, current_user_roles) %}Docs{% endif %}{% if can_manage and not row.is_locked %}Edit{% endif %}
No engagements found for this financial year.
+
+
+
+{% endblock %} diff --git a/app/modules/services/templates/services/task_template_detail.html b/app/modules/services/templates/services/task_template_detail.html new file mode 100644 index 0000000..3701016 --- /dev/null +++ b/app/modules/services/templates/services/task_template_detail.html @@ -0,0 +1,181 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Firm Task Templates

+

+ {{ service.service_code }} · {{ service.service_name }} + {% if selection and selection.default_branch_id %} · Default Branch ID {{ selection.default_branch_id }}{% endif %} +

+
+ +
+ + {% if request.query_params.get('requirement_added') %}
Document requirement added successfully.
{% endif %} + {% if request.query_params.get('template_uploaded') %}
Template file uploaded successfully.
{% endif %} + {% if request.query_params.get('error') %}
Action failed. Please check the selected task, file and permissions.
{% endif %} + +
+
+
Firm Status
+
{{ 'Enabled' if selection and selection.is_enabled else 'Not Enabled' }}
+

Firm-level service selection controls whether these task templates are used.

+
+
+
Firm Tasks
+
{{ task_templates|length }}
+

Tasks customised for the active firm.

+
+
+
System Defaults
+
{{ default_tasks|length }}
+

Defaults may be copied and customised for the firm.

+
+
+ + {% if can_manage_tasks %} +
+
+
+

Add Firm Task

+

Create a task template for this firm and service.

+
+ {% if default_tasks|length > 0 %} +
+ + +
+ {% endif %} +
+ +
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + +
+
+
+ + +
+
+ +
+
+
+ {% endif %} + +
+
+

Firm Task List

+

These tasks will be used when work is generated for this firm.

+
+ + + + + + + + + + + + + + {% for task in task_templates %} + + + + + + + + + + {% else %} + + {% endfor %} + +
SeqTaskRoleFlagsDocument RequirementsTemplate Uploads
{{ task.sequence_no }} +
{{ task.task_name }}
+ {% if task.description %}
{{ task.description }}
{% endif %} +
{{ task.default_role_name or '-' }} +
+ {% if task.is_mandatory %}Mandatory{% endif %} + {% if task.requires_review %}Review{% endif %} + {{ 'Active' if task.is_active else 'Inactive' }} +
+
+ {% set reqs = task_requirement_map.get(task.id, []) if task_requirement_map else [] %} +
+ {% for req in reqs %} +
+
{{ req.document_name }}
+
{{ req.document_type.replace('_',' ').title() }} · {{ 'Mandatory' if req.is_mandatory else 'Optional' }} · {{ 'Active' if req.is_active else 'Inactive' }}
+ {% if can_manage_tasks %} +
+ + +
+ {% endif %} +
+ {% else %}No requirements{% endfor %} +
+ {% if can_manage_tasks %} +
+ + +
+ + +
+ + + + +
+ {% endif %} +
+ {% set files = task_template_file_map.get(task.id, []) if task_template_file_map else [] %} +
+ {% for tpl in files %} +
{{ tpl.template_name }}
{{ tpl.template_category or 'Template' }} · {{ tpl.original_filename }}
+ {% else %}No template files{% endfor %} +
+ {% if can_manage_tasks %} +
+ + + + + + +
+ {% endif %} +
+ {% if can_manage_tasks %}Edit{% endif %} +
No firm task templates yet. Copy defaults or add tasks manually.
+
+
+{% endblock %} diff --git a/app/modules/services/templates/services/task_templates_list.html b/app/modules/services/templates/services/task_templates_list.html new file mode 100644 index 0000000..a5d2a13 --- /dev/null +++ b/app/modules/services/templates/services/task_templates_list.html @@ -0,0 +1,31 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Firm Service Task Templates

+

Each firm can maintain its own execution template for enabled services.

+
+ Back to Services +
+
+ + + + + + + + {% for row in rows %} + + + + + + + {% else %}{% endfor %} + +
CodeServiceTask Count
{{ row.catalogue.service_code }}{{ row.catalogue.service_name }}{{ row.task_count }}Open
Enable a service for the firm first.
+
+
+{% endblock %} diff --git a/app/modules/services/templates/services/work_tracker/dashboard.html b/app/modules/services/templates/services/work_tracker/dashboard.html new file mode 100644 index 0000000..b2672d1 --- /dev/null +++ b/app/modules/services/templates/services/work_tracker/dashboard.html @@ -0,0 +1,119 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Work Tracker Dashboard

+

Track engagement tasks, assigned staff, internal target dates and execution status.

+
+ Generate Tasks +
+ + + +
+
+ + +
+
+ + +
+ + {% if q or status %}Clear{% endif %} +
+ +
+ + {% if can_bulk_manage %} +
+
+
+ + +
+ {% if can_assign_staff %} +
+ + +
+ {% endif %} +
+ + + +
+ +
+

Select tasks below, then apply status, assignee or internal target date. Locked engagements/tasks are skipped.

+
+ {% endif %} + +
+ + + + {% if can_bulk_manage %}{% endif %} + + + + + + + + + + + + {% for task in tasks %} + + {% if can_bulk_manage %}{% endif %} + + + + + + + + + + {% else %} + + {% endfor %} + +
TaskClientServiceAssigneeInternal TargetEngagement DueStatus
{{ task.sequence_no }}. {{ task.task_name }}{{ task.client.client_name if task.client else '-' }}{{ task.catalogue.service_name if task.catalogue else '-' }}{{ task.assigned_to.full_name if task.assigned_to and task.assigned_to.full_name else (task.assigned_to.email if task.assigned_to else '-') }} + {% if task.internal_target_date %} + {{ task.internal_target_date }} + {% if task.is_task_overdue %}Overdue{% endif %} + {% if task.is_due_today %}Today{% endif %} + {% else %}-{% endif %} + {% if task.subscription and task.subscription.current_due_date %}{{ task.subscription.current_due_date }}{% else %}-{% endif %}{{ task.tracker_status_label }}Open
No execution tasks found. Generate tasks from subscriptions first.
+
+
+
+{% endblock %} diff --git a/app/modules/services/templates/services/work_tracker/subscriptions.html b/app/modules/services/templates/services/work_tracker/subscriptions.html new file mode 100644 index 0000000..26cc7a2 --- /dev/null +++ b/app/modules/services/templates/services/work_tracker/subscriptions.html @@ -0,0 +1,41 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Generate Service Tasks

+

Generate execution tasks from firm task templates for active engagement subscriptions.

+
+ Dashboard +
+ +
+ +
+ +
+ + + + {% for row in rows %} + + + + + + + {% else %} + + {% endfor %} + +
ClientServiceTasks
{{ row.subscription.client.client_name if row.subscription.client else '-' }}{{ row.subscription.catalogue.service_name if row.subscription.catalogue else '-' }}{{ row.completed_tasks }}/{{ row.total_tasks }} completed · {{ row.open_tasks }} open + {% if can_generate %} +
+ + +
+ {% endif %} +
No active subscriptions found.
+
+
+{% endblock %} diff --git a/app/modules/services/templates/services/work_tracker/task_form.html b/app/modules/services/templates/services/work_tracker/task_form.html new file mode 100644 index 0000000..ad783d3 --- /dev/null +++ b/app/modules/services/templates/services/work_tracker/task_form.html @@ -0,0 +1,137 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Update Service Task

+

{{ task.client.client_name if task.client else '-' }} · {{ task.catalogue.service_name if task.catalogue else '-' }}

+
+ Back +
+ +
+
{{ task.sequence_no }}. {{ task.task_name }}
+ {% if task.description %}

{{ task.description }}

{% endif %} +
+
Engagement Due Date: {{ task.subscription.current_due_date if task.subscription and task.subscription.current_due_date else '-' }}
+
Internal Target Date: {{ task.internal_target_date or '-' }}
+
+
+ + {% if task.is_task_overdue %} +
This task is overdue based on the internal target date.
+ {% elif task.is_due_today %} +
This task is due today based on the internal target date.
+ {% endif %} + +
+ +
+ + +
+
+ + +
+
+ + +

Internal office target date. Statutory due date remains at engagement level.

+
+
+ + + {% if not can_reassign and task.assigned_to %}

Assignee changes are restricted for this role.

{% endif %} + {% if can_edit and not can_manage_fields %}

You can update status and work note only for your own assigned task.

{% endif %} +
+
+ + +
+
+ +
+ Cancel + {% if can_edit %}{% endif %} +
+
+
+
+ +
+
+
+

Task Communication Timeline

+

Record internal notes, client clarifications, consultant clarifications, and partner review notes for this task.

+
+ {% if task.subscription and task.subscription.is_locked %} + Locked + {% endif %} +
+ + {% if can_add_comment and comment_type_options %} +
+ +
+
+ + +
+
+ + +

Client/consultant visibility is stored now and will be used when portals are enabled.

+
+
+
+ + +
+
+ +
+
+ {% else %} +
Communication entry is not available for this task or role.
+ {% endif %} + +
+ {% if comments %} + {% for comment in comments %} +
+
+
+ {% set type_label = comment.comment_type.replace('_', ' ').title() %} + {% set visibility_label = comment.visibility.replace('_', ' ').title() %} + {{ type_label }} + {{ visibility_label }} +
+
{{ comment.created_at_utc }}
+
+
{{ comment.created_by.full_name or comment.created_by.email if comment.created_by else 'System' }}
+

{{ comment.message }}

+
+ {% endfor %} + {% else %} +
No task communication recorded yet.
+ {% endif %} +
+
+ +{% endblock %} diff --git a/app/modules/services/ui.py b/app/modules/services/ui.py new file mode 100644 index 0000000..a355a90 --- /dev/null +++ b/app/modules/services/ui.py @@ -0,0 +1,1452 @@ +from __future__ import annotations + +from fastapi import APIRouter, File, Form, Request, UploadFile +from fastapi.responses import FileResponse, RedirectResponse, StreamingResponse +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.core.audit.service import model_snapshot, pair_before_after, write_audit_log +from app.modules.core.rbac.deps import get_user_permissions, get_user_roles +from app.modules.core.rbac.permission_guard import require_permission +from app.modules.core.tenancy.models import Branch +from app.modules.services.models import FirmServiceSelection, FirmServiceTaskTemplate, ServiceCatalogue, ServiceCategory, ServiceDefaultTaskTemplate, ServiceDueDateRule, FirmTaskDocumentRequirement, FirmTaskDocumentTemplate +from app.modules.services.bulk_imports import ( + build_template as build_bulk_import_template, + import_client_service_assignments, + import_firm_task_templates, + import_service_master, + import_system_default_tasks, + import_due_date_extensions, +) +from app.modules.services.due_dates import ( + DUE_PERIOD_TYPES, + DUE_YEAR_BASIS_CHOICES, + create_due_date_extension, + get_due_rule, + list_due_extensions, + list_due_rules, + parse_optional_date, +) +from app.modules.services.task_documents import ( + create_task_document_requirement, + get_task_document_requirement, + list_task_document_requirements, + list_task_document_templates, + save_task_document_template, + template_absolute_path, + update_task_document_requirement, +) +from app.modules.services.services import ( + RECURRENCE_CHOICES, + ENGAGEMENT_TYPE_CHOICES, + normalize_engagement_type, + get_catalogue, + get_category, + get_firm_selection, + get_firm_task_templates, + list_catalogue_payload, + list_categories, + list_disabled_catalogues, + list_firm_services_payload, + next_task_sequence, + next_default_task_sequence, + normalize_code, + get_default_task_templates, + get_firm_task_template, + get_default_task_template, +) + +router = APIRouter(prefix="/services", tags=["services-ui"]) + + +def _base_ctx(request: Request, user, db, **ctx): + base = { + "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), + "recurrence_choices": RECURRENCE_CHOICES, + "engagement_type_choices": ENGAGEMENT_TYPE_CHOICES, + "due_period_types": DUE_PERIOD_TYPES, + "due_year_basis_choices": DUE_YEAR_BASIS_CHOICES, + } + base.update(ctx) + return base + + +def _render(request: Request, template: str, db, user, **ctx): + return templates.TemplateResponse(template, _base_ctx(request, user, db, **ctx)) + + +def _redirect_denied(): + return RedirectResponse(url="/system-settings", status_code=303) + + +def _has_perm(db, user, code: str) -> bool: + try: + require_permission(db, user, code) + return True + except Exception: + return False + + +def _role_names(db, user) -> set[str]: + try: + return set(get_user_roles(db, user.id)) + except Exception: + return set() + + +def _has_role(db, user, *names: str) -> bool: + roles = _role_names(db, user) + return any(name in roles for name in names) + + +def _can_view_services(db, user) -> bool: + return _has_perm(db, user, "services.view") or _has_role(db, user, "System Admin", "Firm Admin", "Partner", "Branch Manager") + + +def _is_system_admin(db, user) -> bool: + return _has_perm(db, user, "services.cross_tenant") + + +def _can_manage_firm_services(db, user) -> bool: + return _has_perm(db, user, "services.edit") or _has_role(db, user, "Firm Admin", "Partner") + + +def _can_manage_firm_tasks(db, user) -> bool: + return _has_perm(db, user, "service_tasks.create") or _has_perm(db, user, "service_tasks.edit") or _has_role(db, user, "Firm Admin", "Partner") + + +def _active_tenant_id(request: Request, user) -> int: + value = ( + request.session.get("active_tenant_id") + or request.session.get("selected_tenant_id") + or request.session.get("tenant_id") + or user.tenant_id + ) + return int(value) + + +@router.get("") +def services_home(request: Request, q: str = ""): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + try: + require_permission(db, user, "services.view") + except Exception: + return _redirect_denied() + + tenant_id = _active_tenant_id(request, user) + + enabled_rows = list_firm_services_payload(db, tenant_id=tenant_id, q=q) + disabled_rows = list_disabled_catalogues(db, tenant_id=tenant_id, q=q) + branches = db.execute( + select(Branch).where(Branch.tenant_id == tenant_id, Branch.is_active.is_(True)).order_by(Branch.name.asc()) + ).scalars().all() + return _render( + request, + "modules/services/templates/services/list.html", + db, + user, + title="Services", + q=q, + enabled_rows=enabled_rows, + disabled_rows=disabled_rows, + branches=branches, + can_manage_catalogue=_is_system_admin(db, user), + can_manage_firm_services=_can_manage_firm_services(db, user), + can_manage_firm_tasks=_can_manage_firm_tasks(db, user), + ) + finally: + db.close() + + +@router.get('/categories') +def category_list(request: Request, q: str = ''): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + if not _can_view_services(db, user): + return _redirect_denied() + rows = list_categories(db, q=q) + return _render(request, 'modules/services/templates/services/category_list.html', db, user, title='Service Categories', q=q, rows=rows, can_create=(_has_perm(db, user, 'services.create') and _is_system_admin(db, user))) + except Exception: + return _redirect_denied() + finally: + db.close() + + +@router.get('/categories/new') +def category_create_page(request: Request): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + require_permission(db, user, 'services.create') + if not _is_system_admin(db, user): + return _redirect_denied() + return _render(request, 'modules/services/templates/services/category_form.html', db, user, title='Create Service Category', mode='create', category=None) + except Exception: + return _redirect_denied() + finally: + db.close() + + +@router.post('/categories/new') +def category_create_submit( + request: Request, + code: str = Form(...), + name: str = Form(...), + sort_order: int = Form(100), + is_active: str | None = Form(None), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + + require_permission(db, user, 'services.create') + + if not _is_system_admin(db, user): + return _redirect_denied() + + row = ServiceCategory( + code=normalize_code(code), + name=name.strip(), + sort_order=sort_order, + is_active=is_active is not None, + ) + + db.add(row) + db.commit() + db.refresh(row) + + return RedirectResponse(url='/services/categories', status_code=303) + finally: + db.close() + +@router.get('/categories/{category_id}/edit') +def category_edit_page(request: Request, category_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + require_permission(db, user, 'services.edit') + if not _is_system_admin(db, user): + return _redirect_denied() + row = get_category(db, category_id) + if not row: + return RedirectResponse(url='/services/categories', status_code=303) + return _render(request, 'modules/services/templates/services/category_form.html', db, user, title='Edit Service Category', mode='edit', category=row) + except Exception: + return _redirect_denied() + finally: + db.close() + + +@router.post('/categories/{category_id}/edit') +def category_edit_submit(request: Request, category_id: int, name: str = Form(...), sort_order: int = Form(100), is_active: str | None = Form(None), csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + require_permission(db, user, 'services.edit') + if not _is_system_admin(db, user): + return _redirect_denied() + row = get_category(db, category_id) + if not row: + return RedirectResponse(url='/services/categories', status_code=303) + row.name = name.strip() + row.sort_order = sort_order + row.is_active = is_active is not None + db.commit() + return RedirectResponse(url='/services/categories', status_code=303) + finally: + db.close() + + +@router.get('/catalogue') +def catalogue_list(request: Request, q: str = '', category_id: int | None = None, recurrence_type: str = '', engagement_type: str = '', page: int = 1, per_page: int = 20): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + if not _can_view_services(db, user): + return _redirect_denied() + payload = list_catalogue_payload(db, q=q, category_id=category_id, recurrence_type=recurrence_type, engagement_type=engagement_type, page=page, per_page=per_page) + tenant_id = _active_tenant_id(request, user) + can_create_catalogue = _is_system_admin(db, user) and _has_perm(db, user, 'services.create') + can_manage_firm_services = _can_manage_firm_services(db, user) + + catalogue_ids = [row.id for row in payload.get('rows', [])] + firm_selection_by_catalogue = {} + if catalogue_ids: + selections = db.execute( + select(FirmServiceSelection).where( + FirmServiceSelection.tenant_id == tenant_id, + FirmServiceSelection.service_catalogue_id.in_(catalogue_ids), + ) + ).scalars().all() + firm_selection_by_catalogue = {selection.service_catalogue_id: selection for selection in selections} + + branches = db.execute( + select(Branch) + .where(Branch.tenant_id == tenant_id, Branch.is_active.is_(True)) + .order_by(Branch.name.asc()) + ).scalars().all() + + return _render( + request, + 'modules/services/templates/services/catalogue_list.html', + db, + user, + title='Service Catalogue', + can_create=can_create_catalogue, + can_manage_firm_services=can_manage_firm_services, + categories=list_categories(db), + branches=branches, + tenant_id=tenant_id, + firm_selection_by_catalogue=firm_selection_by_catalogue, + **payload, + ) + finally: + db.close() + + +@router.get('/catalogue/new') +def catalogue_create_page(request: Request): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + if not _is_system_admin(db, user): + return _redirect_denied() + require_permission(db, user, 'services.create') + return _render(request, 'modules/services/templates/services/catalogue_form.html', db, user, title='Create Service Catalogue', mode='create', catalogue=None, categories=list_categories(db)) + except Exception: + return _redirect_denied() + finally: + db.close() + + +@router.post('/catalogue/new') +def catalogue_create_submit(request: Request, service_code: str = Form(...), service_name: str = Form(...), category_id: str = Form(''), recurrence_type: str = Form(''), engagement_type: str = Form('non_assurance'), sort_order: int = Form(100), description: str = Form(''), applicable_individual: str | None = Form(None), applicable_proprietorship: str | None = Form(None), applicable_partnership: str | None = Form(None), applicable_llp: str | None = Form(None), applicable_company: str | None = Form(None), applicable_trust: str | None = Form(None), applicable_society: str | None = Form(None), is_active: str | None = Form(None), is_client_requestable: str | None = Form(None), is_consultant_requestable: str | None = Form(None), csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + if not _is_system_admin(db, user): + return _redirect_denied() + require_permission(db, user, 'services.create') + selected_category = get_category(db, int(category_id)) if str(category_id).strip() else None + row = ServiceCatalogue( + service_code=normalize_code(service_code), + service_name=service_name.strip(), + category_id=selected_category.id if selected_category else None, + category=selected_category.name if selected_category else None, + recurrence_type=recurrence_type.strip() or None, + engagement_type=normalize_engagement_type(engagement_type), + sort_order=sort_order, + description=description.strip() or None, + applicable_individual=applicable_individual is not None, + applicable_proprietorship=applicable_proprietorship is not None, + applicable_partnership=applicable_partnership is not None, + applicable_llp=applicable_llp is not None, + applicable_company=applicable_company is not None, + applicable_trust=applicable_trust is not None, + applicable_society=applicable_society is not None, + is_active=is_active is not None, + is_client_requestable=is_client_requestable is not None, + is_consultant_requestable=is_consultant_requestable is not None, + created_by_user_id=user.id, + updated_by_user_id=user.id, + ) + db.add(row); db.commit(); db.refresh(row) + return RedirectResponse(url=f'/services/catalogue/{row.id}', status_code=303) + finally: + db.close() + + +@router.get('/catalogue/{catalogue_id}') +def catalogue_detail(request: Request, catalogue_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + if not _can_view_services(db, user): + return _redirect_denied() + row = get_catalogue(db, catalogue_id) + if not row: + return RedirectResponse(url='/services/catalogue', status_code=303) + tenant_id = _active_tenant_id(request, user) + can_edit = _is_system_admin(db, user) and _has_perm(db, user, 'services.edit') + branches = db.execute( + select(Branch) + .where(Branch.tenant_id == tenant_id, Branch.is_active.is_(True)) + .order_by(Branch.name.asc()) + ).scalars().all() + return _render( + request, + 'modules/services/templates/services/catalogue_detail.html', + db, + user, + title=f'Service Catalogue - {row.service_name}', + catalogue=row, + can_edit=can_edit, + can_manage_firm_services=_can_manage_firm_services(db, user), + branches=branches, + current_selection=get_firm_selection(db, tenant_id=tenant_id, catalogue_id=row.id), + current_templates=get_firm_task_templates(db, tenant_id=tenant_id, catalogue_id=row.id), + default_templates=get_default_task_templates(db, catalogue_id=row.id), + due_rules=list_due_rules(db, catalogue_id=row.id), + due_extensions=list_due_extensions(db, catalogue_id=row.id, tenant_id=tenant_id), + tenant_id=tenant_id, + ) + finally: + db.close() + + +@router.get('/catalogue/{catalogue_id}/due-rules/new') +def due_rule_create_page(request: Request, catalogue_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + if not _is_system_admin(db, user): + return _redirect_denied() + require_permission(db, user, 'services.edit') + catalogue = get_catalogue(db, catalogue_id) + if not catalogue: + return RedirectResponse(url='/services/catalogue', status_code=303) + return _render( + request, + 'modules/services/templates/services/due_dates/rule_form.html', + db, + user, + title='Add Due Date Rule', + mode='create', + catalogue=catalogue, + rule=None, + ) + finally: + db.close() + + +@router.post('/catalogue/{catalogue_id}/due-rules/new') +def due_rule_create_submit( + request: Request, + catalogue_id: int, + rule_name: str = Form(...), + period_type: str = Form('yearly'), + due_year_basis: str = Form('assessment_year_start'), + due_day: str = Form(''), + due_month: str = Form(''), + due_month_offset: int = Form(0), + days_offset_after_event: str = Form(''), + renewal_days_before_expiry: str = Form(''), + sort_order: int = Form(100), + is_active: str | None = Form(None), + remarks: str = Form(''), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + if not _is_system_admin(db, user): + return _redirect_denied() + require_permission(db, user, 'services.edit') + catalogue = get_catalogue(db, catalogue_id) + if not catalogue: + return RedirectResponse(url='/services/catalogue', status_code=303) + row = ServiceDueDateRule( + service_catalogue_id=catalogue_id, + rule_name=rule_name.strip(), + period_type=(period_type or 'yearly').strip(), + due_year_basis=(due_year_basis or 'assessment_year_start').strip(), + due_day=int(due_day) if str(due_day).strip() else None, + due_month=int(due_month) if str(due_month).strip() else None, + due_month_offset=int(due_month_offset or 0), + days_offset_after_event=int(days_offset_after_event) if str(days_offset_after_event).strip() else None, + renewal_days_before_expiry=int(renewal_days_before_expiry) if str(renewal_days_before_expiry).strip() else None, + sort_order=sort_order, + is_active=is_active is not None, + remarks=remarks.strip() or None, + created_by_user_id=user.id, + updated_by_user_id=user.id, + ) + db.add(row) + db.commit() + return RedirectResponse(url=f'/services/catalogue/{catalogue_id}', status_code=303) + finally: + db.close() + + +@router.get('/catalogue/{catalogue_id}/due-rules/{rule_id}/edit') +def due_rule_edit_page(request: Request, catalogue_id: int, rule_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + if not _is_system_admin(db, user): + return _redirect_denied() + require_permission(db, user, 'services.edit') + catalogue = get_catalogue(db, catalogue_id) + rule = get_due_rule(db, rule_id=rule_id, catalogue_id=catalogue_id) + if not catalogue or not rule: + return RedirectResponse(url=f'/services/catalogue/{catalogue_id}', status_code=303) + return _render( + request, + 'modules/services/templates/services/due_dates/rule_form.html', + db, + user, + title='Edit Due Date Rule', + mode='edit', + catalogue=catalogue, + rule=rule, + ) + finally: + db.close() + + +@router.post('/catalogue/{catalogue_id}/due-rules/{rule_id}/edit') +def due_rule_edit_submit( + request: Request, + catalogue_id: int, + rule_id: int, + rule_name: str = Form(...), + period_type: str = Form('yearly'), + due_year_basis: str = Form('assessment_year_start'), + due_day: str = Form(''), + due_month: str = Form(''), + due_month_offset: int = Form(0), + days_offset_after_event: str = Form(''), + renewal_days_before_expiry: str = Form(''), + sort_order: int = Form(100), + is_active: str | None = Form(None), + remarks: str = Form(''), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + if not _is_system_admin(db, user): + return _redirect_denied() + require_permission(db, user, 'services.edit') + rule = get_due_rule(db, rule_id=rule_id, catalogue_id=catalogue_id) + if not rule: + return RedirectResponse(url=f'/services/catalogue/{catalogue_id}', status_code=303) + rule.rule_name = rule_name.strip() + rule.period_type = (period_type or 'yearly').strip() + rule.due_year_basis = (due_year_basis or 'assessment_year_start').strip() + rule.due_day = int(due_day) if str(due_day).strip() else None + rule.due_month = int(due_month) if str(due_month).strip() else None + rule.due_month_offset = int(due_month_offset or 0) + rule.days_offset_after_event = int(days_offset_after_event) if str(days_offset_after_event).strip() else None + rule.renewal_days_before_expiry = int(renewal_days_before_expiry) if str(renewal_days_before_expiry).strip() else None + rule.sort_order = sort_order + rule.is_active = is_active is not None + rule.remarks = remarks.strip() or None + rule.updated_by_user_id = user.id + db.commit() + return RedirectResponse(url=f'/services/catalogue/{catalogue_id}', status_code=303) + finally: + db.close() + + +@router.get('/catalogue/{catalogue_id}/due-extensions/new') +def due_extension_create_page(request: Request, catalogue_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + if not _is_system_admin(db, user): + return _redirect_denied() + require_permission(db, user, 'services.edit') + catalogue = get_catalogue(db, catalogue_id) + if not catalogue: + return RedirectResponse(url='/services/catalogue', status_code=303) + return _render( + request, + 'modules/services/templates/services/due_dates/extension_form.html', + db, + user, + title='Add Due Date Extension', + catalogue=catalogue, + rules=list_due_rules(db, catalogue_id=catalogue_id, include_inactive=False), + ) + finally: + db.close() + + +@router.post('/catalogue/{catalogue_id}/due-extensions/new') +def due_extension_create_submit( + request: Request, + catalogue_id: int, + due_date_rule_id: str = Form(''), + financial_year: str = Form(...), + assessment_year: str = Form(''), + period_label: str = Form(''), + extended_due_date: str = Form(...), + notification_reference: str = Form(''), + notification_date: str = Form(''), + remarks: str = Form(''), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + if not _is_system_admin(db, user): + return _redirect_denied() + require_permission(db, user, 'services.edit') + catalogue = get_catalogue(db, catalogue_id) + if not catalogue: + return RedirectResponse(url='/services/catalogue', status_code=303) + tenant_id = _active_tenant_id(request, user) + create_due_date_extension( + db, + tenant_id=tenant_id, + catalogue_id=catalogue_id, + due_date_rule_id=int(due_date_rule_id) if str(due_date_rule_id).strip() else None, + financial_year=financial_year.strip(), + assessment_year=assessment_year.strip() or None, + period_label=period_label.strip() or None, + extended_due_date=parse_optional_date(extended_due_date), + notification_reference=notification_reference, + notification_date=parse_optional_date(notification_date), + remarks=remarks, + user_id=user.id, + ) + db.commit() + return RedirectResponse(url=f'/services/catalogue/{catalogue_id}', status_code=303) + finally: + db.close() + + +@router.get('/catalogue/{catalogue_id}/edit') +def catalogue_edit_page(request: Request, catalogue_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + if not _is_system_admin(db, user): + return _redirect_denied() + require_permission(db, user, 'services.edit') + row = get_catalogue(db, catalogue_id) + if not row: + return RedirectResponse(url='/services/catalogue', status_code=303) + return _render(request, 'modules/services/templates/services/catalogue_form.html', db, user, title='Edit Service Catalogue', mode='edit', catalogue=row, categories=list_categories(db)) + except Exception: + return _redirect_denied() + finally: + db.close() + + +@router.post('/catalogue/{catalogue_id}/edit') +def catalogue_edit_submit(request: Request, catalogue_id: int, service_name: str = Form(...), category_id: str = Form(''), recurrence_type: str = Form(''), engagement_type: str = Form('non_assurance'), sort_order: int = Form(100), description: str = Form(''), applicable_individual: str | None = Form(None), applicable_proprietorship: str | None = Form(None), applicable_partnership: str | None = Form(None), applicable_llp: str | None = Form(None), applicable_company: str | None = Form(None), applicable_trust: str | None = Form(None), applicable_society: str | None = Form(None), is_active: str | None = Form(None), is_client_requestable: str | None = Form(None), is_consultant_requestable: str | None = Form(None), csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + if not _is_system_admin(db, user): + return _redirect_denied() + require_permission(db, user, 'services.edit') + row = get_catalogue(db, catalogue_id) + if not row: + return RedirectResponse(url='/services/catalogue', status_code=303) + selected_category = get_category(db, int(category_id)) if str(category_id).strip() else None + row.service_name = service_name.strip() + row.category_id = selected_category.id if selected_category else None + row.category = selected_category.name if selected_category else None + row.recurrence_type = recurrence_type.strip() or None + row.engagement_type = normalize_engagement_type(engagement_type) + row.sort_order = sort_order + row.applicable_individual = applicable_individual is not None + row.applicable_proprietorship = applicable_proprietorship is not None + row.applicable_partnership = applicable_partnership is not None + row.applicable_llp = applicable_llp is not None + row.applicable_company = applicable_company is not None + row.applicable_trust = applicable_trust is not None + row.applicable_society = applicable_society is not None + row.is_active = is_active is not None + row.is_client_requestable = is_client_requestable is not None + row.is_consultant_requestable = is_consultant_requestable is not None + row.updated_by_user_id = user.id + db.commit() + return RedirectResponse(url=f'/services/catalogue/{row.id}', status_code=303) + finally: + db.close() + + +@router.post('/catalogue/{catalogue_id}/toggle') +def toggle_firm_service(request: Request, catalogue_id: int, default_branch_id: str = Form(''), csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + if not _can_manage_firm_services(db, user): + return _redirect_denied() + tenant_id = _active_tenant_id(request, user) + row = get_catalogue(db, catalogue_id) + if not row: + return RedirectResponse(url='/services', status_code=303) + selection = get_firm_selection(db, tenant_id=tenant_id, catalogue_id=catalogue_id) + if not selection: + selection = FirmServiceSelection( + tenant_id=tenant_id, + service_catalogue_id=catalogue_id, + is_enabled=True, + default_branch_id=int(default_branch_id) if str(default_branch_id).strip() else None, + activated_by_user_id=user.id, + updated_by_user_id=user.id, + ) + db.add(selection) + else: + selection.is_enabled = not bool(selection.is_enabled) + if str(default_branch_id).strip(): + selection.default_branch_id = int(default_branch_id) + selection.updated_by_user_id = user.id + db.commit() + return RedirectResponse(url='/services', status_code=303) + finally: + db.close() + + +@router.get('/templates') +def task_templates_list(request: Request): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + if not (_has_perm(db, user, 'service_tasks.view') or _can_manage_firm_tasks(db, user)): + return _redirect_denied() + tenant_id = _active_tenant_id(request, user) + enabled_rows = list_firm_services_payload(db, tenant_id=tenant_id) + for row in enabled_rows: + row['task_count'] = len(get_firm_task_templates(db, tenant_id=tenant_id, catalogue_id=row['catalogue'].id)) + return _render(request, 'modules/services/templates/services/task_templates_list.html', db, user, title='Firm Service Task Templates', rows=enabled_rows, can_manage_tasks=_can_manage_firm_tasks(db, user)) + finally: + db.close() + + +@router.get('/templates/{catalogue_id}') +def task_templates_detail(request: Request, catalogue_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + if not (_has_perm(db, user, 'service_tasks.view') or _can_manage_firm_tasks(db, user)): + return _redirect_denied() + catalogue = get_catalogue(db, catalogue_id) + if not catalogue: + return RedirectResponse(url='/services/templates', status_code=303) + tenant_id = _active_tenant_id(request, user) + selection = get_firm_selection(db, tenant_id=tenant_id, catalogue_id=catalogue_id) + if not selection or not selection.is_enabled: + return RedirectResponse(url='/services', status_code=303) + tasks = get_firm_task_templates(db, tenant_id=tenant_id, catalogue_id=catalogue_id) + task_requirement_map = { + task.id: list_task_document_requirements(db, tenant_id=tenant_id, firm_task_template_id=task.id) + for task in tasks + } + task_template_file_map = { + task.id: list_task_document_templates(db, tenant_id=tenant_id, firm_task_template_id=task.id) + for task in tasks + } + return _render( + request, + 'modules/services/templates/services/task_template_detail.html', + db, + user, + title=f'Firm Task Templates - {catalogue.service_name}', + service=catalogue, + selection=selection, + task_templates=tasks, + task_requirement_map=task_requirement_map, + task_template_file_map=task_template_file_map, + can_manage_tasks=_can_manage_firm_tasks(db, user), + default_tasks=get_default_task_templates(db, catalogue_id=catalogue_id), + is_system_admin=_is_system_admin(db, user), + ) + finally: + db.close() + + +@router.post('/templates/{catalogue_id}/tasks/new') +def task_template_create_submit(request: Request, catalogue_id: int, task_name: str = Form(...), description: str = Form(''), default_role_name: str = Form(''), sequence_no: int | None = Form(None), is_mandatory: str | None = Form(None), requires_review: str | None = Form(None), is_active: str | None = Form(None), csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + if not _can_manage_firm_tasks(db, user): + return _redirect_denied() + catalogue = get_catalogue(db, catalogue_id) + if not catalogue: + return RedirectResponse(url='/services/templates', status_code=303) + tenant_id = _active_tenant_id(request, user) + selection = get_firm_selection(db, tenant_id=tenant_id, catalogue_id=catalogue_id) + if not selection or not selection.is_enabled: + return RedirectResponse(url='/services', status_code=303) + row = FirmServiceTaskTemplate( + tenant_id=tenant_id, + service_catalogue_id=catalogue_id, + task_name=task_name.strip(), + description=description.strip() or None, + default_role_name=default_role_name.strip() or None, + sequence_no=sequence_no or next_task_sequence(db, tenant_id=tenant_id, catalogue_id=catalogue_id), + is_mandatory=is_mandatory is not None, + requires_review=requires_review is not None, + is_active=is_active is not None, + created_by_user_id=user.id, + updated_by_user_id=user.id, + ) + db.add(row); db.commit() + return RedirectResponse(url=f'/services/templates/{catalogue_id}', status_code=303) + finally: + db.close() + + +@router.get('/defaults') +def default_templates_catalogue_list(request: Request): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + require_permission(db, user, 'service_tasks.view') + if not _is_system_admin(db, user): + return _redirect_denied() + payload = list_catalogue_payload(db, page=1, per_page=500) + rows = payload['rows'] + return _render(request, 'modules/services/templates/services/default_templates_list.html', db, user, title='Default Task Templates', rows=rows, can_manage_defaults=_has_perm(db, user, 'service_tasks.create')) + except Exception: + return _redirect_denied() + finally: + db.close() + + +@router.get('/catalogue/{catalogue_id}/defaults') +def default_templates_detail(request: Request, catalogue_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + require_permission(db, user, 'service_tasks.view') + if not _is_system_admin(db, user): + return _redirect_denied() + catalogue = get_catalogue(db, catalogue_id) + if not catalogue: + return RedirectResponse(url='/services/defaults', status_code=303) + defaults = get_default_task_templates(db, catalogue_id=catalogue_id) + return _render(request, 'modules/services/templates/services/default_templates_detail.html', db, user, title=f'Default Tasks - {catalogue.service_name}', service=catalogue, default_tasks=defaults) + finally: + db.close() + + +@router.post('/catalogue/{catalogue_id}/defaults/new') +def default_template_create_submit(request: Request, catalogue_id: int, task_name: str = Form(...), description: str = Form(''), default_role_name: str = Form(''), sequence_no: int | None = Form(None), is_mandatory: str | None = Form(None), requires_review: str | None = Form(None), is_active: str | None = Form(None), csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + require_permission(db, user, 'service_tasks.create') + if not _is_system_admin(db, user): + return _redirect_denied() + catalogue = get_catalogue(db, catalogue_id) + if not catalogue: + return RedirectResponse(url='/services/defaults', status_code=303) + row = ServiceDefaultTaskTemplate(service_catalogue_id=catalogue_id, task_name=task_name.strip(), description=description.strip() or None, default_role_name=default_role_name.strip() or None, sequence_no=sequence_no or next_default_task_sequence(db, catalogue_id=catalogue_id), is_mandatory=is_mandatory is not None, requires_review=requires_review is not None, is_active=is_active is not None) + db.add(row); db.commit() + return RedirectResponse(url=f'/services/catalogue/{catalogue_id}/defaults', status_code=303) + finally: + db.close() + + +@router.post('/catalogue/{catalogue_id}/defaults/copy-to-firm') +def copy_defaults_to_firm(request: Request, catalogue_id: int, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + if not _can_manage_firm_tasks(db, user): + return _redirect_denied() + tenant_id = _active_tenant_id(request, user) + selection = get_firm_selection(db, tenant_id=tenant_id, catalogue_id=catalogue_id) + if not selection or not selection.is_enabled: + return RedirectResponse(url='/services', status_code=303) + existing_sequences = {t.sequence_no for t in get_firm_task_templates(db, tenant_id=tenant_id, catalogue_id=catalogue_id)} + defaults = get_default_task_templates(db, catalogue_id=catalogue_id) + next_seq = next_task_sequence(db, tenant_id=tenant_id, catalogue_id=catalogue_id) + for d in defaults: + seq = d.sequence_no + if seq in existing_sequences: + seq = next_seq + next_seq += 1 + existing_sequences.add(seq) + db.add(FirmServiceTaskTemplate( + tenant_id=tenant_id, + service_catalogue_id=catalogue_id, + task_name=d.task_name, + description=d.description, + sequence_no=seq, + default_role_name=d.default_role_name, + is_mandatory=d.is_mandatory, + requires_review=d.requires_review, + is_active=d.is_active, + created_by_user_id=user.id, + updated_by_user_id=user.id, + )) + db.commit() + return RedirectResponse(url=f'/services/templates/{catalogue_id}', status_code=303) + finally: + db.close() + + +@router.post('/templates/{catalogue_id}/tasks/{task_id}/document-requirements/new') +def firm_task_document_requirement_create( + request: Request, + catalogue_id: int, + task_id: int, + document_name: str = Form(...), + document_type: str = Form('GENERAL'), + is_mandatory: str | None = Form(None), + allowed_file_types: str = Form(''), + instructions: str = Form(''), + sort_order: int = Form(100), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + if not _can_manage_firm_tasks(db, user): + return _redirect_denied() + tenant_id = _active_tenant_id(request, user) + task = get_firm_task_template(db, task_id=task_id, tenant_id=tenant_id, catalogue_id=catalogue_id) + if not task: + return RedirectResponse(url=f'/services/templates/{catalogue_id}?error=task_missing', status_code=303) + create_task_document_requirement( + db, + task_template=task, + document_name=document_name, + document_type=document_type, + is_mandatory=is_mandatory is not None, + allowed_file_types=allowed_file_types, + instructions=instructions, + sort_order=sort_order, + user=user, + ) + db.commit() + return RedirectResponse(url=f'/services/templates/{catalogue_id}?requirement_added=1', status_code=303) + finally: + db.close() + + +@router.post('/templates/{catalogue_id}/tasks/{task_id}/document-requirements/{requirement_id}/toggle') +def firm_task_document_requirement_toggle( + request: Request, + catalogue_id: int, + task_id: int, + requirement_id: int, + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + if not _can_manage_firm_tasks(db, user): + return _redirect_denied() + tenant_id = _active_tenant_id(request, user) + task = get_firm_task_template(db, task_id=task_id, tenant_id=tenant_id, catalogue_id=catalogue_id) + requirement = get_task_document_requirement(db, requirement_id=requirement_id, tenant_id=tenant_id) + if not task or not requirement or requirement.firm_task_template_id != task.id: + return RedirectResponse(url=f'/services/templates/{catalogue_id}?error=requirement_missing', status_code=303) + requirement.is_active = not bool(requirement.is_active) + requirement.updated_by_user_id = user.id + db.commit() + return RedirectResponse(url=f'/services/templates/{catalogue_id}?requirement_updated=1', status_code=303) + finally: + db.close() + + +@router.post('/templates/{catalogue_id}/tasks/{task_id}/document-templates/upload') +def firm_task_document_template_upload( + request: Request, + catalogue_id: int, + task_id: int, + template_name: str = Form(''), + template_category: str = Form(''), + description: str = Form(''), + file: UploadFile = File(...), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + if not _can_manage_firm_tasks(db, user): + return _redirect_denied() + tenant_id = _active_tenant_id(request, user) + task = get_firm_task_template(db, task_id=task_id, tenant_id=tenant_id, catalogue_id=catalogue_id) + if not task: + return RedirectResponse(url=f'/services/templates/{catalogue_id}?error=task_missing', status_code=303) + if not file or not file.filename: + return RedirectResponse(url=f'/services/templates/{catalogue_id}?error=missing_file', status_code=303) + save_task_document_template( + db, + task_template=task, + template_name=template_name or file.filename, + template_category=template_category, + description=description, + upload_file=file, + user=user, + ) + db.commit() + return RedirectResponse(url=f'/services/templates/{catalogue_id}?template_uploaded=1', status_code=303) + finally: + db.close() + + +@router.get('/document-templates/{template_id}/download') +def firm_task_document_template_download(request: Request, template_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + if not (_has_perm(db, user, 'service_tasks.view') or _can_manage_firm_tasks(db, user)): + return _redirect_denied() + tenant_id = _active_tenant_id(request, user) + template = db.get(FirmTaskDocumentTemplate, template_id) + if not template or template.tenant_id != tenant_id or not template.is_active: + return _redirect_denied() + path = template_absolute_path(template) + if not path.exists(): + return RedirectResponse(url=f'/services/templates/{template.service_catalogue_id}?error=template_file_missing', status_code=303) + return FileResponse(path, filename=template.original_filename, media_type=template.content_type or 'application/octet-stream') + finally: + db.close() + + +@router.get('/templates/{catalogue_id}/tasks/{task_id}/edit') +def firm_task_template_edit_page(request: Request, catalogue_id: int, task_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + + if not _can_manage_firm_tasks(db, user): + return _redirect_denied() + + tenant_id = _active_tenant_id(request, user) + + catalogue = get_catalogue(db, catalogue_id) + if not catalogue: + return RedirectResponse(url='/services/templates', status_code=303) + + selection = get_firm_selection(db, tenant_id=tenant_id, catalogue_id=catalogue_id) + if not selection or not selection.is_enabled: + return RedirectResponse(url='/services', status_code=303) + + task = get_firm_task_template( + db, + tenant_id=tenant_id, + catalogue_id=catalogue_id, + task_id=task_id, + ) + if not task: + return RedirectResponse(url=f'/services/templates/{catalogue_id}', status_code=303) + + return _render( + request, + 'modules/services/templates/services/firm_task_form.html', + db, + user, + title=f'Edit Firm Task - {catalogue.service_name}', + service=catalogue, + task=task, + ) + finally: + db.close() + + +@router.post('/templates/{catalogue_id}/tasks/{task_id}/edit') +def firm_task_template_edit_submit( + request: Request, + catalogue_id: int, + task_id: int, + task_name: str = Form(...), + description: str = Form(''), + default_role_name: str = Form(''), + sequence_no: int = Form(1), + is_mandatory: str | None = Form(None), + requires_review: str | None = Form(None), + is_active: str | None = Form(None), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + + if not _can_manage_firm_tasks(db, user): + return _redirect_denied() + + tenant_id = _active_tenant_id(request, user) + + task = get_firm_task_template( + db, + tenant_id=tenant_id, + catalogue_id=catalogue_id, + task_id=task_id, + ) + if not task: + return RedirectResponse(url=f'/services/templates/{catalogue_id}', status_code=303) + + task.task_name = task_name.strip() + task.description = description.strip() or None + task.default_role_name = default_role_name.strip() or None + task.sequence_no = sequence_no + task.is_mandatory = is_mandatory is not None + task.requires_review = requires_review is not None + task.is_active = is_active is not None + task.updated_by_user_id = user.id + + db.commit() + return RedirectResponse(url=f'/services/templates/{catalogue_id}', status_code=303) + finally: + db.close() + + +@router.get('/catalogue/{catalogue_id}/defaults/{task_id}/edit') +def default_task_template_edit_page(request: Request, catalogue_id: int, task_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + + try: + require_permission(db, user, 'service_tasks.edit') + except Exception: + return _redirect_denied() + + if not _is_system_admin(db, user): + return _redirect_denied() + + catalogue = get_catalogue(db, catalogue_id) + if not catalogue: + return RedirectResponse(url='/services/defaults', status_code=303) + + task = get_default_task_template( + db, + catalogue_id=catalogue_id, + task_id=task_id, + ) + if not task: + return RedirectResponse(url=f'/services/catalogue/{catalogue_id}/defaults', status_code=303) + + return _render( + request, + 'modules/services/templates/services/default_task_form.html', + db, + user, + title=f'Edit Default Task - {catalogue.service_name}', + service=catalogue, + task=task, + ) + finally: + db.close() + + +@router.post('/catalogue/{catalogue_id}/defaults/{task_id}/edit') +def default_task_template_edit_submit( + request: Request, + catalogue_id: int, + task_id: int, + task_name: str = Form(...), + description: str = Form(''), + default_role_name: str = Form(''), + sequence_no: int = Form(1), + is_mandatory: str | None = Form(None), + requires_review: str | None = Form(None), + is_active: str | None = Form(None), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url='/login', status_code=303) + + try: + require_permission(db, user, 'service_tasks.edit') + except Exception: + return _redirect_denied() + + if not _is_system_admin(db, user): + return _redirect_denied() + + task = get_default_task_template( + db, + catalogue_id=catalogue_id, + task_id=task_id, + ) + if not task: + return RedirectResponse(url=f'/services/catalogue/{catalogue_id}/defaults', status_code=303) + + task.task_name = task_name.strip() + task.description = description.strip() or None + task.default_role_name = default_role_name.strip() or None + task.sequence_no = sequence_no + task.is_mandatory = is_mandatory is not None + task.requires_review = requires_review is not None + task.is_active = is_active is not None + + db.commit() + return RedirectResponse(url=f'/services/catalogue/{catalogue_id}/defaults', status_code=303) + finally: + db.close() + +# ----------------------------------------------------------------------------- +# S4.5 Bulk import and bulk service assignment routes +# Kept inside existing Services module intentionally; no separate router required. +# ----------------------------------------------------------------------------- + +def _locked_partner_id(db, user) -> int | None: + return int(user.id) if _has_perm(db, user, "clients.view.own_only") else None + + +def _bulk_template_response(template_type: str, filename: str): + data = build_bulk_import_template(template_type) + return StreamingResponse( + iter([data]), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@router.get("/bulk-imports") +def services_bulk_import_home(request: Request): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + can_client_assignment = _has_perm(db, user, "clients.edit") + can_firm_tasks = _has_perm(db, user, "service_tasks.create") + can_system_import = _is_system_admin(db, user) + can_due_date_extensions = _can_manage_firm_services(db, user) or can_system_import + if not (can_client_assignment or can_firm_tasks or can_system_import or can_due_date_extensions): + return _redirect_denied() + return _render( + request, + "modules/services/templates/services/bulk_imports/index.html", + db, + user, + title="Services Bulk Import", + can_client_assignment=can_client_assignment, + can_firm_tasks=can_firm_tasks, + can_system_import=can_system_import, + can_due_date_extensions=can_due_date_extensions, + ) + finally: + db.close() + + +@router.get("/bulk-imports/templates/engagement-assignments.xlsx") +def download_client_service_assignment_template(request: Request): + return _bulk_template_response("client_service_assignments", "client_service_assignments_template.xlsx") + + +@router.get("/bulk-imports/templates/service-master.xlsx") +def download_service_master_template(request: Request): + return _bulk_template_response("service_master", "service_master_template.xlsx") + + +@router.get("/bulk-imports/templates/system-default-tasks.xlsx") +def download_system_default_tasks_template(request: Request): + return _bulk_template_response("system_default_tasks", "system_default_tasks_template.xlsx") + + +@router.get("/bulk-imports/templates/due-date-extensions.xlsx") +def download_due_date_extensions_template(request: Request): + return _bulk_template_response("due_date_extensions", "due_date_extensions_template.xlsx") + + +@router.get("/bulk-imports/templates/firm-task-templates.xlsx") +def download_firm_task_templates_template(request: Request): + return _bulk_template_response("firm_task_templates", "firm_task_templates_template.xlsx") + + +@router.post("/bulk-imports/engagement-assignments") +def upload_client_service_assignments( + request: Request, + file: UploadFile = File(...), + update_existing: str | None = Form(None), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + try: + require_permission(db, user, "clients.edit") + except Exception: + return _redirect_denied() + result = import_client_service_assignments( + db, + current_user=user, + tenant_id=_active_tenant_id(request, user), + locked_partner_id=_locked_partner_id(db, user), + file_bytes=file.file.read(), + update_existing=update_existing is not None, + ) + return _render(request, "modules/services/templates/services/bulk_imports/result.html", db, user, title="Engagement Assignment Import Result", result=result, back_url="/services/bulk-imports") + finally: + db.close() + + +@router.post("/bulk-imports/service-master") +def upload_service_master(request: Request, file: UploadFile = File(...), update_existing: str | None = Form(None), csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not _is_system_admin(db, user): + return _redirect_denied() + try: + require_permission(db, user, "services.create") + except Exception: + return _redirect_denied() + result = import_service_master( + db, + current_user=user, + tenant_id=_active_tenant_id(request, user), + file_bytes=file.file.read(), + update_existing=update_existing is not None, + ) + return _render(request, "modules/services/templates/services/bulk_imports/result.html", db, user, title="Service Master Import Result", result=result, back_url="/services/bulk-imports") + finally: + db.close() + + +@router.post("/bulk-imports/system-default-tasks") +def upload_system_default_tasks(request: Request, file: UploadFile = File(...), update_existing: str | None = Form(None), csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not _is_system_admin(db, user): + return _redirect_denied() + try: + require_permission(db, user, "service_tasks.create") + except Exception: + return _redirect_denied() + result = import_system_default_tasks(db, current_user=user, file_bytes=file.file.read(), update_existing=update_existing is not None) + return _render(request, "modules/services/templates/services/bulk_imports/result.html", db, user, title="System Default Tasks Import Result", result=result, back_url="/services/bulk-imports") + finally: + db.close() + + +@router.post("/bulk-imports/due-date-extensions") +def upload_due_date_extensions(request: Request, file: UploadFile = File(...), update_existing: str | None = Form(None), csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not (_can_manage_firm_services(db, user) or _is_system_admin(db, user)): + return _redirect_denied() + result = import_due_date_extensions( + db, + current_user=user, + tenant_id=_active_tenant_id(request, user), + file_bytes=file.file.read(), + update_existing=update_existing is not None, + ) + return _render(request, "modules/services/templates/services/bulk_imports/result.html", db, user, title="Due Date Extension Import Result", result=result, back_url="/services/bulk-imports") + finally: + db.close() + + +@router.post("/bulk-imports/firm-task-templates") +def upload_firm_task_templates(request: Request, file: UploadFile = File(...), update_existing: str | None = Form(None), csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + try: + require_permission(db, user, "service_tasks.create") + except Exception: + return _redirect_denied() + result = import_firm_task_templates(db, current_user=user, tenant_id=_active_tenant_id(request, user), file_bytes=file.file.read(), update_existing=update_existing is not None) + return _render(request, "modules/services/templates/services/bulk_imports/result.html", db, user, title="Firm Task Templates Import Result", result=result, back_url="/services/bulk-imports") + finally: + db.close() diff --git a/app/modules/services/work_tracker_ui.py b/app/modules/services/work_tracker_ui.py new file mode 100644 index 0000000..92392c2 --- /dev/null +++ b/app/modules/services/work_tracker_ui.py @@ -0,0 +1,532 @@ +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.core.rbac.deps import get_user_permissions, get_user_roles +from app.modules.core.iam.models import User +from app.modules.core.rbac.permission_guard import require_permission +from app.modules.services.execution import ( + TASK_COMMENT_TYPES, + TASK_COMMENT_VISIBILITIES, + TASK_PRIORITIES, + TASK_STATUSES, + add_task_comment, + apply_bulk_task_update, + apply_task_update, + dashboard_stats, + generate_tasks_for_subscription, + get_subscription_for_execution, + get_task, + get_tasks_for_bulk_update, + list_assignees_for_execution, + list_task_comments, + list_subscription_execution_payload, + list_tasks_payload, + parse_date_value, +) + +router = APIRouter(prefix="/services/work-tracker", tags=["services-work-tracker-ui"]) + + +def _base_ctx(request: Request, user, db, **ctx): + base = { + "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), + "task_statuses": TASK_STATUSES, + "task_priorities": TASK_PRIORITIES, + "task_comment_types": TASK_COMMENT_TYPES, + "task_comment_visibilities": TASK_COMMENT_VISIBILITIES, + } + base.update(ctx) + return base + + +def _render(request: Request, template: str, db, user, **ctx): + return templates.TemplateResponse(template, _base_ctx(request, user, db, **ctx)) + + +def _redirect_denied(): + return RedirectResponse(url="/system-settings", status_code=303) + + +def _has_perm(db, user, code: str) -> bool: + try: + require_permission(db, user, code) + return True + except Exception: + return False + + + + +def _role_names(db, user) -> set[str]: + return set(get_user_roles(db, user.id)) + + +def _is_firm_admin(db, user) -> bool: + return "Firm Admin" in _role_names(db, user) + + +def _is_partner(db, user) -> bool: + return "Partner" in _role_names(db, user) + + +def _is_staff(db, user) -> bool: + return "Staff" in _role_names(db, user) + + +def _can_bulk_manage_tasks(db, user) -> bool: + # Operational bulk task management is intentionally limited to Firm Admin and Partner. + # Firm Admin keeps the normal edit-permission gate. + # Partner is allowed with service_tasks.view because partner visibility is enforced again + # on every submitted task id in get_tasks_for_bulk_update(..., partner_user_id=user.id). + if _is_firm_admin(db, user): + return _has_perm(db, user, "service_tasks.edit") + if _is_partner(db, user): + return _has_perm(db, user, "service_tasks.view") + return False + + +def _can_assign_staff(db, user) -> bool: + return _can_bulk_manage_tasks(db, user) + + +def _partner_visibility_user_id(db, user) -> int | None: + return int(user.id) if _is_partner(db, user) else None + + +def _staff_own_task_user_id(db, user) -> int | None: + return int(user.id) if _is_staff(db, user) else None + + +def _can_staff_update_own_task(db, user, task) -> bool: + return _is_staff(db, user) and int(task.assigned_to_user_id or 0) == int(user.id) and _has_perm(db, user, "service_tasks.view") + + +def _can_update_task_status(db, user, task) -> bool: + return _can_bulk_manage_tasks(db, user) or _can_staff_update_own_task(db, user, task) + + +def _allowed_comment_type_codes(db, user) -> set[str]: + if _can_bulk_manage_tasks(db, user): + return {"internal_note", "client_clarification", "consultant_clarification", "partner_review_note"} + if _is_staff(db, user): + # Staff may communicate through consultants for clients where consultant is the communication channel. + return {"internal_note", "client_clarification", "consultant_clarification"} + return set() + + +def _can_add_task_comment(db, user, task) -> bool: + if getattr(task, "is_locked", False) or getattr(getattr(task, "subscription", None), "is_locked", False): + return False + return _can_update_task_status(db, user, task) + + +def _comment_type_options_for_user(db, user): + allowed = _allowed_comment_type_codes(db, user) + return [(code, label) for code, label in TASK_COMMENT_TYPES if code in allowed] + + +def _resolve_allowed_assignee_id(db, *, tenant_id: int, branch_id: int | None, assigned_to_user_id: int | None) -> int | None: + if assigned_to_user_id is None: + return None + query = select(User.id).where(User.id == assigned_to_user_id, User.tenant_id == tenant_id, User.is_active.is_(True)) + if branch_id: + query = query.where((User.branch_id == branch_id) | (User.branch_id.is_(None))) + return assigned_to_user_id if db.execute(query).first() else None + +def _active_tenant_id(request: Request, user) -> int: + return int( + request.session.get("active_tenant_id") + or request.session.get("selected_tenant_id") + or request.session.get("tenant_id") + or user.tenant_id + ) + + +def _active_branch_id(request: Request, user, db) -> int | None: + value = request.session.get("active_branch_id") + if value in (None, "", 0, "0"): + if _has_perm(db, user, "clients.cross_branch"): + return None + return int(getattr(user, "branch_id", 0) or 0) or None + return int(value) + + +def _active_financial_year(request: Request) -> str | None: + value = request.session.get("active_financial_year") or getattr(request.state, "year_code", None) + value = (value or "").strip() + return value or None + + +def _assigned_user_filter(db, user) -> int | None: + # Staff users see/update only their own assigned tasks. Partner visibility is handled separately. + return _staff_own_task_user_id(db, user) + + +def _can_manage_execution(db, user) -> bool: + return _can_bulk_manage_tasks(db, user) + + +def _safe_int_list(values: list[str] | None) -> list[int]: + ids: list[int] = [] + for value in values or []: + try: + ids.append(int(value)) + except (TypeError, ValueError): + continue + return ids + + +@router.get("") +def execution_dashboard(request: Request, q: str = "", status: str = ""): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + try: + require_permission(db, user, "service_tasks.view") + except Exception: + return _redirect_denied() + + tenant_id = _active_tenant_id(request, user) + branch_id = _active_branch_id(request, user, db) + assigned_to_user_id = _assigned_user_filter(db, user) + partner_user_id = _partner_visibility_user_id(db, user) + financial_year = _active_financial_year(request) + stats = dashboard_stats( + db, + tenant_id=tenant_id, + branch_id=branch_id, + assigned_to_user_id=assigned_to_user_id, + partner_user_id=partner_user_id, + financial_year=financial_year, + ) + tasks = list_tasks_payload( + db, + tenant_id=tenant_id, + branch_id=branch_id, + assigned_to_user_id=assigned_to_user_id, + partner_user_id=partner_user_id, + status=status, + q=q, + financial_year=financial_year, + ) + assignees = list_assignees_for_execution(db, tenant_id=tenant_id, branch_id=branch_id) + return _render( + request, + "modules/services/templates/services/work_tracker/dashboard.html", + db, + user, + title="Work Tracker Dashboard", + stats=stats, + tasks=tasks, + q=q, + status=status, + assignees=assignees, + can_manage=_can_manage_execution(db, user), + can_bulk_manage=_can_bulk_manage_tasks(db, user), + can_assign_staff=_can_assign_staff(db, user), + financial_year=financial_year, + ) + finally: + db.close() + + +@router.post("/tasks/bulk-update") +def task_bulk_update_submit( + request: Request, + task_ids: list[str] = Form(default=[]), + bulk_status: str = Form(""), + bulk_assigned_to_user_id: str = Form("__no_change__"), + bulk_internal_target_date: str = Form(""), + update_internal_target_date: str | None = Form(None), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not _can_bulk_manage_tasks(db, user): + return _redirect_denied() + + ids = _safe_int_list(task_ids) + tenant_id = _active_tenant_id(request, user) + branch_id = _active_branch_id(request, user, db) + partner_user_id = _partner_visibility_user_id(db, user) + financial_year = _active_financial_year(request) + tasks = get_tasks_for_bulk_update( + db, + tenant_id=tenant_id, + task_ids=ids, + branch_id=branch_id, + assigned_to_user_id=None, + partner_user_id=partner_user_id, + financial_year=financial_year, + ) + update_assignee = bulk_assigned_to_user_id != "__no_change__" + assigned_to_user_id = None + if update_assignee and not _can_assign_staff(db, user): + return _redirect_denied() + if update_assignee and bulk_assigned_to_user_id.strip(): + try: + requested_assignee_id = int(bulk_assigned_to_user_id) + except ValueError: + return _redirect_denied() + assigned_to_user_id = _resolve_allowed_assignee_id( + db, tenant_id=tenant_id, branch_id=branch_id, assigned_to_user_id=requested_assignee_id + ) + if assigned_to_user_id != requested_assignee_id: + return _redirect_denied() + target_date = parse_date_value(bulk_internal_target_date) if update_internal_target_date is not None else None + apply_bulk_task_update( + tasks, + status=bulk_status.strip() or None, + assigned_to_user_id=assigned_to_user_id, + update_assignee=update_assignee, + internal_target_date=target_date, + update_internal_target_date=update_internal_target_date is not None, + user_id=user.id, + ) + db.commit() + return RedirectResponse(url="/services/work-tracker", status_code=303) + finally: + db.close() + + +@router.get("/subscriptions") +def subscription_execution_list(request: Request, q: str = ""): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + try: + require_permission(db, user, "clients.view") + except Exception: + return _redirect_denied() + + tenant_id = _active_tenant_id(request, user) + branch_id = _active_branch_id(request, user, db) + financial_year = _active_financial_year(request) + rows = list_subscription_execution_payload(db, tenant_id=tenant_id, branch_id=branch_id, financial_year=financial_year, q=q) + return _render( + request, + "modules/services/templates/services/work_tracker/subscriptions.html", + db, + user, + title="Generate Service Tasks", + rows=rows, + q=q, + can_generate=_has_perm(db, user, "service_tasks.create"), + financial_year=financial_year, + ) + finally: + db.close() + + +@router.post("/subscriptions/{subscription_id}/generate") +def generate_subscription_tasks(request: Request, subscription_id: int, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + try: + require_permission(db, user, "service_tasks.create") + except Exception: + return _redirect_denied() + + tenant_id = _active_tenant_id(request, user) + financial_year = _active_financial_year(request) + subscription = get_subscription_for_execution(db, tenant_id=tenant_id, subscription_id=subscription_id) + if subscription and financial_year and subscription.financial_year != financial_year: + return RedirectResponse(url="/services/work-tracker/subscriptions", status_code=303) + if not subscription or not subscription.is_active or subscription.status != "active": + return RedirectResponse(url="/services/work-tracker/subscriptions", status_code=303) + + generate_tasks_for_subscription(db, subscription=subscription, user_id=user.id) + db.commit() + return RedirectResponse(url="/services/work-tracker", status_code=303) + finally: + db.close() + + +@router.get("/tasks/{task_id}/edit") +def task_edit_page(request: Request, task_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + try: + require_permission(db, user, "service_tasks.view") + except Exception: + return _redirect_denied() + + tenant_id = _active_tenant_id(request, user) + branch_id = _active_branch_id(request, user, db) + task = get_task( + db, + tenant_id=tenant_id, + task_id=task_id, + branch_id=branch_id, + assigned_to_user_id=_assigned_user_filter(db, user), + partner_user_id=_partner_visibility_user_id(db, user), + financial_year=_active_financial_year(request), + ) + if not task: + return RedirectResponse(url="/services/work-tracker", status_code=303) + + assignees = list_assignees_for_execution(db, tenant_id=tenant_id, branch_id=branch_id) + comments = list_task_comments(db, tenant_id=tenant_id, task_id=task.id) + return _render( + request, + "modules/services/templates/services/work_tracker/task_form.html", + db, + user, + title="Update Service Task", + task=task, + assignees=assignees, + comments=comments, + comment_type_options=_comment_type_options_for_user(db, user), + can_add_comment=_can_add_task_comment(db, user, task), + can_edit=_can_update_task_status(db, user, task), + can_manage_fields=_can_bulk_manage_tasks(db, user), + can_reassign=_can_assign_staff(db, user), + ) + finally: + db.close() + + +@router.post("/tasks/{task_id}/edit") +def task_edit_submit( + request: Request, + task_id: int, + status: str = Form("pending"), + priority: str = Form("normal"), + assigned_to_user_id: str = Form(""), + internal_target_date: str = Form(""), + remarks: str = Form(""), + is_active: str | None = Form(None), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + tenant_id = _active_tenant_id(request, user) + branch_id = _active_branch_id(request, user, db) + task = get_task( + db, + tenant_id=tenant_id, + task_id=task_id, + branch_id=branch_id, + assigned_to_user_id=_assigned_user_filter(db, user), + partner_user_id=_partner_visibility_user_id(db, user), + financial_year=_active_financial_year(request), + ) + if not task: + return RedirectResponse(url="/services/work-tracker", status_code=303) + if not _can_update_task_status(db, user, task): + return _redirect_denied() + + can_manage_fields = _can_bulk_manage_tasks(db, user) + if can_manage_fields: + if assigned_to_user_id.strip(): + try: + requested_assignee_id = int(assigned_to_user_id) + except ValueError: + return _redirect_denied() + resolved_assignee = _resolve_allowed_assignee_id( + db, tenant_id=tenant_id, branch_id=branch_id, assigned_to_user_id=requested_assignee_id + ) + if resolved_assignee != requested_assignee_id: + return _redirect_denied() + else: + resolved_assignee = None + resolved_priority = priority + resolved_internal_target_date = parse_date_value(internal_target_date) + resolved_is_active = is_active is not None + else: + # Staff can update only status and work note for their own task. + resolved_assignee = task.assigned_to_user_id + resolved_priority = task.priority + resolved_internal_target_date = task.internal_target_date + resolved_is_active = task.is_active + + apply_task_update( + task, + status=status, + priority=resolved_priority, + assigned_to_user_id=resolved_assignee, + internal_target_date=resolved_internal_target_date, + remarks=remarks, + is_active=resolved_is_active, + user_id=user.id, + ) + db.commit() + return RedirectResponse(url="/services/work-tracker", status_code=303) + finally: + db.close() + + +@router.post("/tasks/{task_id}/comments") +def task_comment_submit( + request: Request, + task_id: int, + comment_type: str = Form("internal_note"), + visibility: str = Form("internal"), + message: str = Form(""), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + tenant_id = _active_tenant_id(request, user) + branch_id = _active_branch_id(request, user, db) + task = get_task( + db, + tenant_id=tenant_id, + task_id=task_id, + branch_id=branch_id, + assigned_to_user_id=_assigned_user_filter(db, user), + partner_user_id=_partner_visibility_user_id(db, user), + financial_year=_active_financial_year(request), + ) + if not task: + return RedirectResponse(url="/services/work-tracker", status_code=303) + if not _can_add_task_comment(db, user, task): + return _redirect_denied() + if comment_type not in _allowed_comment_type_codes(db, user): + return _redirect_denied() + add_task_comment( + db, + task=task, + comment_type=comment_type, + visibility=visibility, + message=message, + user_id=user.id, + ) + db.commit() + return RedirectResponse(url=f"/services/work-tracker/tasks/{task_id}/edit", status_code=303) + finally: + db.close() diff --git a/app/modules/system/__init__.py b/app/modules/system/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/system/health/__init__.py b/app/modules/system/health/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/system/health/api.py b/app/modules/system/health/api.py new file mode 100644 index 0000000..f056cb5 --- /dev/null +++ b/app/modules/system/health/api.py @@ -0,0 +1,19 @@ +from fastapi import APIRouter, Request +from app.core.settings import get_settings + +router = APIRouter(tags=["system"]) + +@router.get("/health") +def health(request: Request): + s = get_settings() + return { + "status": "ok", + "app": s.APP_NAME, + "env": s.ENV, + "db_backend": s.DB_BACKEND, + "context": { + "tenant_code": getattr(request.state, "tenant_code", None), + "branch_code": getattr(request.state, "branch_code", None), + "year_code": getattr(request.state, "year_code", None), + }, + } diff --git a/app/modules/system_settings/__init__.py b/app/modules/system_settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/system_settings/templates/branch_create.html b/app/modules/system_settings/templates/branch_create.html new file mode 100644 index 0000000..6d1ed73 --- /dev/null +++ b/app/modules/system_settings/templates/branch_create.html @@ -0,0 +1,201 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+

Create Branch

+

+ System Admin can create branches for any audit firm. Firm Admin can create branches only for their own audit firm. +

+
+ + {% if flash %} +
+ {{ flash }} +
+ {% endif %} + +
+ + +
+
+ + + +
+ +
+ + + +
+ +
+ + +
+ +
+
SMTP Credentials
+
+ + + + + +
+
+ +
+ +
+ +
+
Branch Identity (Compliance)
+
+ + + + + + + + + + + + +
+
+ +
+
Attendance Geo/IP Controls
+

Enable geo-fenced attendance for this branch. Default radius is 100 meters. IP validation is optional and useful only for office LAN/static public IP.

+
+ + + + + + + + + + +
+
+ +
+ + Cancel +
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/modules/system_settings/templates/branch_edit.html b/app/modules/system_settings/templates/branch_edit.html new file mode 100644 index 0000000..f0e445f --- /dev/null +++ b/app/modules/system_settings/templates/branch_edit.html @@ -0,0 +1,184 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+

Edit Branch

+

+ Branch remains mapped to its audit firm. Firm Admin can edit only branches of their own audit firm. +

+
+ + {% if flash %} +
+ {{ flash }} +
+ {% endif %} + +
+ + +
+
+ + + +
+ +
+ + + +
+ +
+ + +
+ +
+
SMTP Credentials
+
+ + + + + +
+
+ +
+ +
+ +
+
Branch Identity (Compliance)
+
+ + + + + + + + + + + + +
+
+ +
+
Attendance Geo/IP Controls
+

If geo/IP validation fails, employee punch is still saved but marked pending approval for OD/client visit review.

+
+ + + + + + + + + + +
+
+ +
+ + Cancel +
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/modules/system_settings/templates/branches_list.html b/app/modules/system_settings/templates/branches_list.html new file mode 100644 index 0000000..189dff4 --- /dev/null +++ b/app/modules/system_settings/templates/branches_list.html @@ -0,0 +1,63 @@ +{% extends "ui/templates/base/layout.html" %} +{% import "ui/templates/components/macros.html" as ui %} +{% block content %} +
+ {% set perms = current_user_permissions or [] %} + {% set roles = current_user_roles or [] %} + {% set add_branch_cta = 'Add Branch' if can_manage_branches(current_user, perms, roles) else '' %} + + {{ ui.page_shell('Branches', 'Branch directory with search, paging, and quick navigation to branch settings.', add_branch_cta) }} + +
+ {{ ui.search_bar('/system-settings/branches', filters.q, filters.per_page) }} + + {% if branches %} + + + + + + + + + + + + {% for b in branches %} + + + + + + + + {% endfor %} + +
BranchAudit FirmTimezoneFlagsActions
+
{{ b.name }}
+
{{ b.code }}
+
+ {{ tenants[b.tenant_id].name if b.tenant_id in tenants else '-' }} + {{ b.timezone }} +
+ {{ ui.badge('Active','emerald') if b.is_active else ui.badge('Inactive','rose') }} + {{ ui.badge('Login enabled','sky') if b.allow_login else ui.badge('Login disabled','amber') }} + {% if b.is_head_office %}{{ ui.badge('Head Office','brand') }}{% endif %} +
+
+ {% if can_manage_branches(current_user, perms, roles) %} + Edit + {% else %} + View only + {% endif %} +
+ + {{ ui.pagination(branches_page, '/system-settings/branches', request.url.query) }} + {% else %} +
+ {{ ui.empty_state('No branches found for the current scope.') }} +
+ {% endif %} +
+
+{% endblock %} \ No newline at end of file diff --git a/app/modules/system_settings/templates/branding.html b/app/modules/system_settings/templates/branding.html new file mode 100644 index 0000000..61e58cc --- /dev/null +++ b/app/modules/system_settings/templates/branding.html @@ -0,0 +1,167 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Firm Branding Settings

+

Configure firm logo, favicon, standard colours, contact details and invoice presentation defaults.

+
+ {% if saved %} +
Saved successfully
+ {% endif %} +
+ + {% if can_change_tenant %} +
+
+ + +
System Admin can preview/edit branding for any audit firm. Firm Admin is restricted to own firm.
+
+
+ {% endif %} + +
+ + + {% if branch %}{% endif %} + +
+
+
+
Brand Identity
+
+ + + + + + + + + +
+
+ +
+
Contact & Statutory Details
+
+ + + + + + + + + + +
+
+ +
+
Invoice / Payment Defaults
+
+ + + + + + +
+
+ +
+ + Back to Setup +
+
+ + +
+
+
+{% endblock %} diff --git a/app/modules/system_settings/templates/dashboard.html b/app/modules/system_settings/templates/dashboard.html new file mode 100644 index 0000000..0a29269 --- /dev/null +++ b/app/modules/system_settings/templates/dashboard.html @@ -0,0 +1,108 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} + {% set ui_perms = current_user_permissions or [] %} + {% set ui_roles = current_user_roles or [] %} + +

Workspace

+

+ Welcome{% if current_user and current_user.full_name %}, {{ current_user.full_name }}{% endif %}. + Use the options available for your access level. +

+ + {% set show_admin_cards = + can_view_tenants(current_user, ui_perms, ui_roles) + or can_view_branches(current_user, ui_perms, ui_roles) + or can_view_users(current_user, ui_perms, ui_roles) + or can_view_audit(current_user, ui_perms, ui_roles) + or can_view_rbac(current_user, ui_perms, ui_roles) + or ("Firm Admin" in ui_roles) + %} + + {% if show_admin_cards %} +
+ + {% if can_view_tenants(current_user, ui_perms, ui_roles) %} + +
Audit Firms
+
Create and manage audit firms.
+
+ {% endif %} + + {% if can_view_branches(current_user, ui_perms, ui_roles) %} + +
Branches
+
Branch settings, SMTP, storage, identity and policy controls.
+
+ {% endif %} + + {% if can_view_settings(current_user, ui_perms, ui_roles) %} + +
Financial Years
+
Create, switch, lock, backup and manage financial year context.
+
+ {% endif %} + + {% if "Firm Admin" in ui_roles or "System Admin" in ui_roles %} + +
Firm Branding
+
Logo, favicon, standard colours, contact details and invoice defaults.
+
+ {% endif %} + + {% if "Firm Admin" in ui_roles or "System Admin" in ui_roles %} + +
Firm Email / SMTP
+
Configure SMTP, IMAP, sender identity and authentication email delivery.
+
+ +
Email Templates
+
Edit OTP, password reset, invite and notification email templates.
+
+ {% endif %} + + {% if can_view_users(current_user, ui_perms, ui_roles) %} + +
Users
+
Manage user accounts, access and lifecycle actions.
+
+ {% endif %} + + {% if can_view_rbac(current_user, ui_perms, ui_roles) %} + +
RBAC
+
Configure roles and permissions.
+
+ {% endif %} + + {% if can_view_audit(current_user, ui_perms, ui_roles) %} + +
Audit Logs
+
Review recorded actions for your allowed scope.
+
+ {% endif %} +
+ {% else %} +
+
Your workspace is ready
+

+ Admin modules are not assigned to your current role. Profile, task, and other user-facing features can be added here next. +

+ +
+
+
Profile / Account
+
+ Use this area later for profile, password change, notifications, and personal settings. +
+
+ +
+
Upcoming Features
+
+ Staff dashboard, tasks, attendance, documents, and other user workspace modules can be plugged in here. +
+
+
+
+ {% endif %} +{% endblock %} \ No newline at end of file diff --git a/app/modules/system_settings/templates/financial_year_backup.html b/app/modules/system_settings/templates/financial_year_backup.html new file mode 100644 index 0000000..7d10cd8 --- /dev/null +++ b/app/modules/system_settings/templates/financial_year_backup.html @@ -0,0 +1,59 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+

Year Backup Export

+

Financial Year {{ fy.year_code }} / Assessment Year {{ fy.assessment_year }}

+
+ Back to Financial Years +
+ + {% if request.query_params.get('exported') %} +
Backup export generated successfully.
+ {% endif %} + {% if request.query_params.get('error') %} +
Backup file is not available on disk.
+ {% endif %} + +
+
+
+

Create export ZIP

+

+ This creates a year-wise archive containing CSV indexes for engagements, tasks, engagement documents, notice/case records, billing invoices, payments, and available stored document files. +

+
+ {% if can_manage_fy %} +
+ + +
+ {% endif %} +
+
+ +
+ + + + + + + + + + + {% for export in exports %} + + + + + + + {% else %} + + {% endfor %} + +
Generated AtStatusSizeAction
{{ export.generated_at_utc }}{{ export.export_status|title }}{{ (export.file_size_bytes / 1024)|round(1) }} KBDownload
No backup exports generated yet.
+
+{% endblock %} diff --git a/app/modules/system_settings/templates/financial_year_create.html b/app/modules/system_settings/templates/financial_year_create.html new file mode 100644 index 0000000..dd92d32 --- /dev/null +++ b/app/modules/system_settings/templates/financial_year_create.html @@ -0,0 +1,50 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +

Create Financial Year

+
+ +
+ {% if tenants|length > 1 %} + + {% else %} + + {% endif %} + +
+ + + + +
+ + + +
+ + Cancel +
+
+
+{% endblock %} diff --git a/app/modules/system_settings/templates/financial_year_edit.html b/app/modules/system_settings/templates/financial_year_edit.html new file mode 100644 index 0000000..e8e8543 --- /dev/null +++ b/app/modules/system_settings/templates/financial_year_edit.html @@ -0,0 +1,39 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +

Edit Financial Year

+

{{ tenant.name if tenant else '' }} • {{ fy.year_code }}

+ +
+ +
+ + +
+ + + + +
+ +
+ + Cancel +
+
+
+{% endblock %} diff --git a/app/modules/system_settings/templates/financial_years_list.html b/app/modules/system_settings/templates/financial_years_list.html new file mode 100644 index 0000000..aae8492 --- /dev/null +++ b/app/modules/system_settings/templates/financial_years_list.html @@ -0,0 +1,83 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+

Financial Years

+

Manage active financial year context, year locking and assessment year mapping.

+
+ {% if can_manage_fy %} + Add Financial Year + {% endif %} +
+ + {% if tenants|length > 1 %} +
+ +
+ {% endif %} + +
+ + + + + + + + + + + + + {% for fy in financial_years %} + + + + + + + + + {% else %} + + {% endfor %} + +
Financial YearAssessment YearPeriodCurrentLockedActions
{{ fy.year_code }}{{ fy.assessment_year }}{{ fy.start_date }} to {{ fy.end_date }} + {% if fy.is_current %}Current{% else %}No{% endif %} + + {% if fy.is_locked %}Locked{% else %}Open{% endif %} + +
+ Use + Backup + {% if can_manage_fy and not fy.is_locked %} + Edit + {% endif %} + {% if can_manage_fy and not fy.is_current %} +
+ + +
+ {% endif %} + {% if can_manage_fy and not fy.is_locked %} +
+ + +
+ {% elif can_manage_fy and fy.is_locked %} +
+ + +
+ {% endif %} +
+
No financial years created yet.
+
+{% endblock %} diff --git a/app/modules/system_settings/templates/tenant_create.html b/app/modules/system_settings/templates/tenant_create.html new file mode 100644 index 0000000..5d6ca35 --- /dev/null +++ b/app/modules/system_settings/templates/tenant_create.html @@ -0,0 +1,66 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +

Create Audit Firm

+ +
+ + +
+
+ + + + + +
+ +
+
Audit Firm Default Policies
+
+ + + + + + + +
+
+ +
+ + Cancel +
+
+
+{% endblock %} diff --git a/app/modules/system_settings/templates/tenant_edit.html b/app/modules/system_settings/templates/tenant_edit.html new file mode 100644 index 0000000..e4e72f4 --- /dev/null +++ b/app/modules/system_settings/templates/tenant_edit.html @@ -0,0 +1,70 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +

Edit Audit Firm

+ +
+ + +
+
+ + + + + +
+ +
+
Audit Firm Default Policies
+
+ + + + + + + + + +
+
+ +
+ + Cancel +
+
+
+{% endblock %} diff --git a/app/modules/system_settings/templates/tenants_list.html b/app/modules/system_settings/templates/tenants_list.html new file mode 100644 index 0000000..1217e14 --- /dev/null +++ b/app/modules/system_settings/templates/tenants_list.html @@ -0,0 +1,14 @@ +{% extends "ui/templates/base/layout.html" %} +{% import "ui/templates/components/macros.html" as ui %} +{% block content %} +
+ {{ ui.page_shell('Audit Firms', 'Audit Firm master with search, paging, and quick actions for branch setup.', 'Add Audit Firm') }} +
+ {{ ui.search_bar('/system-settings/tenants', filters.q, filters.per_page) }} + {% if tenants %} + {% for t in tenants %}{% endfor %}
IDCodeNameFirm TypeStatusActions
{{ t.id }}{{ t.code }}{{ t.name }}{{ (t.firm_type or 'proprietorship')|replace('_',' ')|title }}{{ ui.badge('Active','emerald') if t.is_active else ui.badge('Inactive','rose') }}
+ {{ ui.pagination(tenants_page, '/system-settings/tenants', request.url.query) }} + {% else %}
{{ ui.empty_state('No audit firms found.') }}
{% endif %} +
+
+{% endblock %} diff --git a/app/modules/system_settings/ui.py b/app/modules/system_settings/ui.py new file mode 100644 index 0000000..b39415a --- /dev/null +++ b/app/modules/system_settings/ui.py @@ -0,0 +1,1434 @@ +from __future__ import annotations + +from datetime import date, datetime, time, timezone +from pathlib import Path +from uuid import uuid4 +import re + +from fastapi import APIRouter, File, Form, Request, UploadFile +from fastapi.responses import FileResponse, 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.core.audit.service import model_snapshot, pair_before_after, write_audit_log +from app.modules.core.tenancy.models import Branch, FinancialYear, Tenant, YearBackupExport +from app.modules.core.tenancy.settings_models import BranchSettings +from app.modules.core.rbac.deps import get_user_permissions, get_user_roles +from app.modules.core.rbac.permission_guard import require_permission +from app.modules.core.iam.scope import build_scope, list_visible_branches, list_visible_tenants +from app.modules.core.tenancy.services import build_branches_payload, build_tenants_payload +from app.modules.system_settings.year_backup_service import build_year_backup_export + +router = APIRouter(prefix="/system-settings", tags=["system-settings-ui"]) + + +def _base_ctx(request: Request, user, db, **ctx): + base = { + "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), + } + base.update(ctx) + return base + + +def _redirect_denied(default_url: str = "/system-settings"): + return RedirectResponse(url=default_url, status_code=303) + + +def _render_with_user(request: Request, template: str, ctx: dict, status_code: int = 200): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + return templates.TemplateResponse(template, _base_ctx(request, user, db, **ctx), status_code=status_code) + finally: + db.close() + + +def _parse_time(s: str | None) -> time | None: + if not s: + return None + s = s.strip() + if not s: + return None + hh, mm = s.split(":") + return time(int(hh), int(mm)) + +def _parse_date(s: str | None) -> date | None: + s = (s or "").strip() + if not s: + return None + return date.fromisoformat(s) + + +def _assessment_year_from_fy(year_code: str) -> str: + try: + start_year = int((year_code or "").split("-", 1)[0]) + except Exception: + return "" + end_year = start_year + 1 + return f"{end_year}-{str(end_year + 1)[-2:]}" + + +def _default_dates_from_fy(year_code: str) -> tuple[date | None, date | None]: + try: + start_year = int((year_code or "").split("-", 1)[0]) + return date(start_year, 4, 1), date(start_year + 1, 3, 31) + except Exception: + return None, None + + +def _active_tenant_id_for_settings(request: Request, user) -> int: + return int(request.session.get("active_tenant_id") or getattr(user, "tenant_id", 0) or 0) + + +def _store_active_tenant_context(request: Request, tenant: Tenant | None) -> None: + if not tenant: + request.session.pop("active_tenant_id", None) + request.session.pop("active_tenant_code", None) + return + request.session["active_tenant_id"] = int(tenant.id) + request.session["active_tenant_code"] = tenant.code + + +def _store_active_branch_context(request: Request, branch: Branch | None) -> None: + if not branch: + request.session.pop("active_branch_id", None) + request.session.pop("active_branch_code", None) + return + request.session["active_branch_id"] = int(branch.id) + request.session["active_branch_code"] = branch.code + + +def _can_manage_financial_years(db, user) -> bool: + roles = set(get_user_roles(db, user.id)) + perms = set(get_user_permissions(db, user.id)) + return "System Admin" in roles or "Firm Admin" in roles or "system.settings.edit" in perms + + +def _can_view_financial_years(db, user) -> bool: + roles = set(get_user_roles(db, user.id)) + perms = set(get_user_permissions(db, user.id)) + return bool(roles.intersection({"System Admin", "Firm Admin", "Partner", "Branch Manager"})) or "system.settings.view" in perms + + +def _visible_financial_year_tenant_ids(db, user) -> set[int]: + roles = set(get_user_roles(db, user.id)) + if "System Admin" in roles: + scope = build_scope(db, user) + return {int(t.id) for t in list_visible_tenants(db, scope)} + return {int(user.tenant_id)} if getattr(user, "tenant_id", None) else set() + + +def _current_financial_year(db, tenant_id: int) -> FinancialYear | None: + return db.execute( + select(FinancialYear).where( + FinancialYear.tenant_id == tenant_id, + FinancialYear.is_current.is_(True), + ) + ).scalar_one_or_none() + + +def _financial_year_redirect_back(request: Request) -> RedirectResponse: + return RedirectResponse(url=request.headers.get("referer") or "/system-settings/financial-years", status_code=303) + + +def _is_system_admin(db, user) -> bool: + return "System Admin" in get_user_roles(db, user.id) + + +def _is_firm_admin(db, user) -> bool: + return "Firm Admin" in get_user_roles(db, user.id) + + +@router.get("") +def dashboard(request: Request): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + try: + require_permission(db, user, "system.settings.view") + except Exception: + return _redirect_denied() + finally: + db.close() + return _render_with_user( + request, + "modules/system_settings/templates/dashboard.html", + {"title": "System Settings"}, + ) + + +# ----------------------------- +# Tenant - System Admin only +# ----------------------------- +@router.get("/tenants") +def tenants_list(request: Request, q: str = "", page: int = 1, per_page: int = 10): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + + if not _is_system_admin(db, user): + return _redirect_denied() + + payload = build_tenants_payload(db, build_scope(db, user), q=q, page=page, per_page=per_page) + return templates.TemplateResponse( + "modules/system_settings/templates/tenants_list.html", + _base_ctx(request, user, db, title="Tenants", **payload), + ) + finally: + db.close() + + +@router.get("/tenants/new") +def tenant_create_page(request: Request): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not _is_system_admin(db, user): + return _redirect_denied() + finally: + db.close() + return _render_with_user( + request, + "modules/system_settings/templates/tenant_create.html", + {"title": "Create Tenant"}, + ) + + +@router.post("/tenants/new") +def tenant_create_submit( + request: Request, + code: str = Form(...), + name: str = Form(...), + firm_type: str = Form("proprietorship"), + default_timezone: str = Form("Asia/Kolkata"), + default_session_duration_minutes: int = Form(480), + default_otp_required_roles_csv: str = Form("Partner,System Admin"), + default_storage_mode: str = Form("local_only"), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not _is_system_admin(db, user): + return _redirect_denied() + + tenant = Tenant( + code=code.strip(), + name=name.strip(), + is_active=True, + firm_type=(firm_type or "partnership").strip(), + default_timezone=(default_timezone or "Asia/Kolkata").strip(), + default_session_duration_minutes=default_session_duration_minutes or 480, + default_otp_required_roles_csv=(default_otp_required_roles_csv or "Partner,System Admin").strip(), + default_storage_mode=(default_storage_mode or "local_only").strip(), + ) + db.add(tenant) + db.commit() + db.refresh(tenant) + + write_audit_log( + db, + action="tenant.create", + entity_type="tenant", + actor=user, + request=request, + entity_id=tenant.id, + entity_name=tenant.name, + target_tenant_id=tenant.id, + details={"after": model_snapshot(tenant, ["code", "name", "firm_type", "is_active"])}, + ) + return RedirectResponse(url="/system-settings/tenants", status_code=303) + finally: + db.close() + + +@router.get("/tenants/{tenant_id}/edit") +def tenant_edit_page(request: Request, tenant_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not _is_system_admin(db, user): + return _redirect_denied() + + tenant = db.execute(select(Tenant).where(Tenant.id == tenant_id)).scalar_one_or_none() + if not tenant: + return RedirectResponse(url="/system-settings/tenants", status_code=303) + + return templates.TemplateResponse( + "modules/system_settings/templates/tenant_edit.html", + _base_ctx(request, user, db, tenant=tenant, title="Edit Tenant"), + ) + finally: + db.close() + + +@router.post("/tenants/{tenant_id}/edit") +def tenant_edit_submit( + request: Request, + tenant_id: int, + name: str = Form(...), + firm_type: str = Form("proprietorship"), + default_timezone: str = Form("Asia/Kolkata"), + default_session_duration_minutes: int = Form(480), + default_otp_required_roles_csv: str = Form("Partner,System Admin"), + default_storage_mode: str = Form("local_only"), + is_active: str | None = Form(None), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not _is_system_admin(db, user): + return _redirect_denied() + + tenant = db.execute(select(Tenant).where(Tenant.id == tenant_id)).scalar_one_or_none() + if not tenant: + return RedirectResponse(url="/system-settings/tenants", status_code=303) + + before_snapshot = model_snapshot(tenant, ["code", "name", "firm_type", "default_timezone", "default_session_duration_minutes", "default_otp_required_roles_csv", "default_storage_mode", "is_active"]) + tenant.name = name.strip() + tenant.firm_type = (firm_type or "partnership").strip() + tenant.default_timezone = (default_timezone or "Asia/Kolkata").strip() + tenant.default_session_duration_minutes = default_session_duration_minutes or 480 + tenant.default_otp_required_roles_csv = (default_otp_required_roles_csv or "Partner,System Admin").strip() + tenant.default_storage_mode = (default_storage_mode or "local_only").strip() + tenant.is_active = is_active is not None + db.commit() + + write_audit_log( + db, + action="tenant.update", + entity_type="tenant", + actor=user, + request=request, + entity_id=tenant.id, + entity_name=tenant.name, + target_tenant_id=tenant.id, + details=pair_before_after(before_snapshot, model_snapshot(tenant, ["code", "name", "firm_type", "default_timezone", "default_session_duration_minutes", "default_otp_required_roles_csv", "default_storage_mode", "is_active"])), + ) + return RedirectResponse(url="/system-settings/tenants", status_code=303) + finally: + db.close() + + +# ----------------------------- +# Branch +# ----------------------------- +@router.get("/branches") +def branches_list(request: Request, q: str = "", page: int = 1, per_page: int = 10, tenant_id: int | None = None): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + + try: + require_permission(db, user, "system.settings.view") + except Exception: + return _redirect_denied() + + scope = build_scope(db, user) + payload = build_branches_payload(db, scope, q=q, page=page, per_page=per_page, tenant_id=tenant_id) + return templates.TemplateResponse( + "modules/system_settings/templates/branches_list.html", + _base_ctx(request, user, db, title="Branches", **payload), + ) + finally: + db.close() + + +@router.get("/branches/new") +def branch_create_page(request: Request, tenant_id: int | None = None): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + + roles = get_user_roles(db, user.id) + if "System Admin" not in roles and "Firm Admin" not in roles: + return _redirect_denied() + + if "System Admin" in roles: + tenants = db.execute(select(Tenant).where(Tenant.is_active.is_(True)).order_by(Tenant.name)).scalars().all() + selected_tenant_id = tenant_id + can_change_tenant = True + else: + tenants = db.execute(select(Tenant).where(Tenant.id == user.tenant_id)).scalars().all() + selected_tenant_id = user.tenant_id + can_change_tenant = False + + return templates.TemplateResponse( + "modules/system_settings/templates/branch_create.html", + _base_ctx( + request, + user, + db, + tenants=tenants, + selected_tenant_id=selected_tenant_id, + can_change_tenant=can_change_tenant, + title="Create Branch", + ), + ) + finally: + db.close() + + +@router.post("/branches/new") +def branch_create_submit( + request: Request, + tenant_id: int = Form(...), + code: str = Form(...), + name: str = Form(...), + timezone: str = Form("Asia/Kolkata"), + office_start_time: str = Form(""), + office_end_time: str = Form(""), + smtp_host: str = Form(""), + smtp_port: str = Form(""), + smtp_username: str = Form(""), + smtp_password: str = Form(""), + smtp_use_tls: str | None = Form(None), + local_storage_path: str = Form(""), + address_line1: str = Form(""), + address_line2: str = Form(""), + city: str = Form(""), + state: str = Form(""), + pin_code: str = Form(""), + gstin: str = Form(""), + pan: str = Form(""), + geo_address: str = Form(""), + latitude: str = Form(""), + longitude: str = Form(""), + attendance_geo_enabled: str | None = Form(None), + attendance_geo_radius_meters: str = Form("100"), + attendance_grace_minutes: str = Form("10"), + attendance_half_day_after_time: str = Form(""), + attendance_rule_enabled: str | None = Form(None), + attendance_ip_enabled: str | None = Form(None), + attendance_allowed_ip_csv: str = Form(""), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + + roles = get_user_roles(db, user.id) + if "System Admin" not in roles and "Firm Admin" not in roles: + return _redirect_denied() + + if "Firm Admin" in roles and tenant_id != user.tenant_id: + return _redirect_denied() + + tenant = db.execute(select(Tenant).where(Tenant.id == tenant_id)).scalar_one_or_none() + if not tenant: + return RedirectResponse(url="/system-settings/branches", status_code=303) + + existing = db.execute( + select(Branch).where(Branch.tenant_id == tenant_id, Branch.code == code.strip()) + ).scalar_one_or_none() + + if existing: + if "System Admin" in roles: + tenants = db.execute(select(Tenant).where(Tenant.is_active.is_(True)).order_by(Tenant.name)).scalars().all() + can_change_tenant = True + else: + tenants = db.execute(select(Tenant).where(Tenant.id == user.tenant_id)).scalars().all() + can_change_tenant = False + + return templates.TemplateResponse( + "modules/system_settings/templates/branch_create.html", + _base_ctx( + request, + user, + db, + tenants=tenants, + selected_tenant_id=tenant_id, + can_change_tenant=can_change_tenant, + title="Create Branch", + flash="Branch code already exists for this tenant.", + ), + status_code=400, + ) + + branch = Branch( + tenant_id=tenant_id, + code=code.strip(), + name=name.strip(), + timezone=(timezone or "Asia/Kolkata").strip(), + office_start_time=_parse_time(office_start_time), + office_end_time=_parse_time(office_end_time), + smtp_host=smtp_host or None, + smtp_port=int(smtp_port) if str(smtp_port).strip() else None, + smtp_username=smtp_username or None, + smtp_password=smtp_password or None, + smtp_use_tls=smtp_use_tls is not None, + local_storage_path=local_storage_path or None, + is_active=True, + ) + db.add(branch) + db.commit() + db.refresh(branch) + + settings = BranchSettings( + branch_id=branch.id, + address_line1=address_line1 or None, + address_line2=address_line2 or None, + city=city or None, + state=state or None, + pin_code=pin_code or None, + gstin=gstin or None, + pan=pan or None, + geo_address=geo_address or None, + latitude=float(latitude) if str(latitude).strip() else None, + longitude=float(longitude) if str(longitude).strip() else None, + attendance_geo_enabled=attendance_geo_enabled is not None, + attendance_geo_radius_meters=int(attendance_geo_radius_meters) if str(attendance_geo_radius_meters).strip() else 100, + attendance_grace_minutes=int(attendance_grace_minutes) if str(attendance_grace_minutes).strip() else 10, + attendance_half_day_after_time=_parse_time(attendance_half_day_after_time), + attendance_rule_enabled=attendance_rule_enabled is not None, + attendance_ip_enabled=attendance_ip_enabled is not None, + attendance_allowed_ip_csv=attendance_allowed_ip_csv or None, + ) + db.add(settings) + db.commit() + + write_audit_log( + db, + action="branch.create", + entity_type="branch", + actor=user, + request=request, + entity_id=branch.id, + entity_name=branch.name, + target_tenant_id=branch.tenant_id, + target_branch_id=branch.id, + details={ + "branch": model_snapshot(branch, ["tenant_id", "code", "name", "is_active", "timezone", "allow_login", "allow_new_assignments", "is_head_office"]), + "settings": model_snapshot(settings, ["city", "state", "pin_code", "gstin", "pan"]), + }, + ) + + return RedirectResponse(url="/system-settings/branches", status_code=303) + finally: + db.close() + + +@router.get("/branches/{branch_id}/edit") +def branch_edit_page(request: Request, branch_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + + roles = get_user_roles(db, user.id) + if "System Admin" not in roles and "Firm Admin" not in roles: + return _redirect_denied() + + branch = db.execute(select(Branch).where(Branch.id == branch_id)).scalar_one_or_none() + if not branch: + return RedirectResponse(url="/system-settings/branches", status_code=303) + + if "Firm Admin" in roles and branch.tenant_id != user.tenant_id: + return _redirect_denied() + + settings = db.execute(select(BranchSettings).where(BranchSettings.branch_id == branch.id)).scalar_one_or_none() + if not settings: + settings = BranchSettings(branch_id=branch.id) + db.add(settings) + db.commit() + + return templates.TemplateResponse( + "modules/system_settings/templates/branch_edit.html", + _base_ctx(request, user, db, branch=branch, settings=settings, title="Edit Branch"), + ) + finally: + db.close() + + +@router.post("/branches/{branch_id}/edit") +def branch_edit_submit( + request: Request, + branch_id: int, + csrf_token: str = Form(...), + name: str = Form(...), + timezone: str = Form("Asia/Kolkata"), + office_start_time: str = Form(""), + office_end_time: str = Form(""), + smtp_host: str = Form(""), + smtp_port: str = Form(""), + smtp_username: str = Form(""), + smtp_password: str = Form(""), + smtp_use_tls: str | None = Form(None), + local_storage_path: str = Form(""), + address_line1: str = Form(""), + address_line2: str = Form(""), + city: str = Form(""), + state: str = Form(""), + pin_code: str = Form(""), + gstin: str = Form(""), + pan: str = Form(""), + geo_address: str = Form(""), + latitude: str = Form(""), + longitude: str = Form(""), + attendance_geo_enabled: str | None = Form(None), + attendance_geo_radius_meters: str = Form("100"), + attendance_grace_minutes: str = Form("10"), + attendance_half_day_after_time: str = Form(""), + attendance_rule_enabled: str | None = Form(None), + attendance_ip_enabled: str | None = Form(None), + attendance_allowed_ip_csv: str = Form(""), + letterhead_logo_path: str = Form(""), + letterhead_signature_path: str = Form(""), + letterhead_stamp_path: str = Form(""), + working_days_csv: str = Form("MON,TUE,WED,THU,FRI,SAT"), + holidays_json: str = Form("[]"), + timezone_locked: str | None = Form(None), + email_from_name: str = Form(""), + email_from_email: str = Form(""), + email_reply_to: str = Form(""), + default_cc_csv: str = Form(""), + default_bcc_csv: str = Form(""), + email_signature_html: str = Form(""), + storage_mode: str = Form("local_only"), + folder_template: str = Form("{root}/Clients/{client_code}/{fy}/{service}/"), + max_file_mb: str = Form("25"), + allowed_ext_csv: str = Form("pdf,jpg,jpeg,png,xlsx,xls,docx,zip"), + retention_years: str = Form("8"), + otp_required_roles_csv: str = Form("Partner,System Admin"), + session_duration_minutes: str = Form("480"), + lockout_attempts: str = Form("5"), + lockout_minutes: str = Form("15"), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + + roles = get_user_roles(db, user.id) + if "System Admin" not in roles and "Firm Admin" not in roles: + return _redirect_denied() + + branch = db.execute(select(Branch).where(Branch.id == branch_id)).scalar_one_or_none() + if not branch: + return RedirectResponse(url="/system-settings/branches", status_code=303) + + if "Firm Admin" in roles and branch.tenant_id != user.tenant_id: + return _redirect_denied() + + bs = db.execute(select(BranchSettings).where(BranchSettings.branch_id == branch.id)).scalar_one_or_none() + + tz_locked_now = bool(bs.timezone_locked) if bs else False + before_branch = model_snapshot(branch, ["tenant_id", "code", "name", "is_active", "timezone", "allow_login", "allow_new_assignments", "is_head_office", "smtp_host", "smtp_port", "smtp_username", "smtp_use_tls", "local_storage_path"]) + before_settings = model_snapshot(bs, ["address_line1", "address_line2", "city", "state", "pin_code", "gstin", "pan", "geo_address", "latitude", "longitude", "attendance_geo_enabled", "attendance_geo_radius_meters", "attendance_grace_minutes", "attendance_half_day_after_time", "attendance_rule_enabled", "attendance_ip_enabled", "attendance_allowed_ip_csv", "working_days_csv", "holidays_json", "timezone_locked", "email_from_name", "email_from_email", "email_reply_to", "default_cc_csv", "default_bcc_csv", "storage_mode", "folder_template", "max_file_mb", "allowed_ext_csv", "retention_years", "otp_required_roles_csv", "session_duration_minutes", "lockout_attempts", "lockout_minutes"]) if bs else {} + + branch.name = name.strip() + if not tz_locked_now: + branch.timezone = (timezone or "Asia/Kolkata").strip() + branch.office_start_time = _parse_time(office_start_time) + branch.office_end_time = _parse_time(office_end_time) + branch.smtp_host = smtp_host or None + branch.smtp_port = int(smtp_port) if str(smtp_port).strip() else None + branch.smtp_username = smtp_username or None + branch.smtp_password = smtp_password or None + branch.smtp_use_tls = smtp_use_tls is not None + branch.local_storage_path = local_storage_path or None + + settings = bs + if not settings: + settings = BranchSettings(branch_id=branch.id) + db.add(settings) + + settings.address_line1 = address_line1 or None + settings.address_line2 = address_line2 or None + settings.city = city or None + settings.state = state or None + settings.pin_code = pin_code or None + settings.gstin = gstin or None + settings.pan = pan or None + settings.geo_address = geo_address or None + settings.latitude = float(latitude) if str(latitude).strip() else None + settings.longitude = float(longitude) if str(longitude).strip() else None + settings.attendance_geo_enabled = attendance_geo_enabled is not None + settings.attendance_geo_radius_meters = int(attendance_geo_radius_meters) if str(attendance_geo_radius_meters).strip() else 100 + settings.attendance_grace_minutes = int(attendance_grace_minutes) if str(attendance_grace_minutes).strip() else 10 + settings.attendance_half_day_after_time = _parse_time(attendance_half_day_after_time) + settings.attendance_rule_enabled = attendance_rule_enabled is not None + settings.attendance_ip_enabled = attendance_ip_enabled is not None + settings.attendance_allowed_ip_csv = attendance_allowed_ip_csv or None + settings.letterhead_logo_path = letterhead_logo_path or None + settings.letterhead_signature_path = letterhead_signature_path or None + settings.letterhead_stamp_path = letterhead_stamp_path or None + settings.working_days_csv = (working_days_csv or "MON,TUE,WED,THU,FRI,SAT").strip() + settings.holidays_json = (holidays_json or "[]").strip() + settings.timezone_locked = timezone_locked is not None + settings.email_from_name = email_from_name or None + settings.email_from_email = email_from_email or None + settings.email_reply_to = email_reply_to or None + settings.default_cc_csv = default_cc_csv or None + settings.default_bcc_csv = default_bcc_csv or None + settings.email_signature_html = email_signature_html or None + settings.storage_mode = (storage_mode or "local_only").strip() + settings.folder_template = (folder_template or "{root}/Clients/{client_code}/{fy}/{service}/").strip() + settings.max_file_mb = int(max_file_mb) if str(max_file_mb).strip() else 25 + settings.allowed_ext_csv = (allowed_ext_csv or "pdf,jpg,jpeg,png,xlsx,xls,docx,zip").strip() + settings.retention_years = int(retention_years) if str(retention_years).strip() else 8 + settings.otp_required_roles_csv = (otp_required_roles_csv or "Partner,System Admin").strip() + settings.session_duration_minutes = int(session_duration_minutes) if str(session_duration_minutes).strip() else 480 + settings.lockout_attempts = int(lockout_attempts) if str(lockout_attempts).strip() else 5 + settings.lockout_minutes = int(lockout_minutes) if str(lockout_minutes).strip() else 15 + + db.commit() + + write_audit_log( + db, + action="branch.update", + entity_type="branch", + actor=user, + request=request, + entity_id=branch.id, + entity_name=branch.name, + target_tenant_id=branch.tenant_id, + target_branch_id=branch.id, + details={ + "branch": pair_before_after(before_branch, model_snapshot(branch, ["tenant_id", "code", "name", "is_active", "timezone", "allow_login", "allow_new_assignments", "is_head_office", "smtp_host", "smtp_port", "smtp_username", "smtp_use_tls", "local_storage_path"])), + "settings": pair_before_after(before_settings, model_snapshot(settings, ["address_line1", "address_line2", "city", "state", "pin_code", "gstin", "pan", "geo_address", "latitude", "longitude", "attendance_geo_enabled", "attendance_geo_radius_meters", "attendance_grace_minutes", "attendance_half_day_after_time", "attendance_rule_enabled", "attendance_ip_enabled", "attendance_allowed_ip_csv", "working_days_csv", "holidays_json", "timezone_locked", "email_from_name", "email_from_email", "email_reply_to", "default_cc_csv", "default_bcc_csv", "storage_mode", "folder_template", "max_file_mb", "allowed_ext_csv", "retention_years", "otp_required_roles_csv", "session_duration_minutes", "lockout_attempts", "lockout_minutes"])), + }, + ) + return RedirectResponse(url="/system-settings/branches", status_code=303) + finally: + db.close() + + +# ----------------------------- +# Phase 7Q.2 - Firm Branding Settings +# ----------------------------- +BRANDING_UPLOAD_ROOT = Path("app/ui/static/uploads/branding") +_ALLOWED_BRANDING_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".ico", ".svg"} +_HEX_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$") + + +def _clean_optional(value: str | None) -> str | None: + value = (value or "").strip() + return value or None + + +def _clean_hex_color(value: str | None, fallback: str | None = None) -> str | None: + value = (value or "").strip() + if not value: + return fallback + return value if _HEX_COLOR_RE.match(value) else fallback + + +def _can_manage_branding(db, user) -> bool: + roles = set(get_user_roles(db, user.id)) + return "System Admin" in roles or "Firm Admin" in roles + + +def _resolve_branding_scope(db, user, tenant_id: int | None = None, branch_id: int | None = None): + roles = set(get_user_roles(db, user.id)) + if "System Admin" in roles: + effective_tenant_id = tenant_id or user.tenant_id + else: + effective_tenant_id = user.tenant_id + + tenant = db.execute(select(Tenant).where(Tenant.id == effective_tenant_id)).scalar_one_or_none() + if not tenant: + return None, None, None + + if "System Admin" not in roles and tenant.id != user.tenant_id: + return None, None, None + + if branch_id: + branch = db.execute(select(Branch).where(Branch.id == branch_id, Branch.tenant_id == tenant.id)).scalar_one_or_none() + else: + branch = None + + if not branch and user.branch_id: + branch = db.execute(select(Branch).where(Branch.id == user.branch_id, Branch.tenant_id == tenant.id)).scalar_one_or_none() + + if not branch: + branch = db.execute( + select(Branch).where(Branch.tenant_id == tenant.id, Branch.is_active.is_(True)).order_by(Branch.is_head_office.desc(), Branch.name) + ).scalar_one_or_none() + + settings = None + if branch: + settings = db.execute(select(BranchSettings).where(BranchSettings.branch_id == branch.id)).scalar_one_or_none() + if not settings: + settings = BranchSettings(branch_id=branch.id) + db.add(settings) + db.commit() + db.refresh(settings) + return tenant, branch, settings + + +async def _save_branding_upload(upload: UploadFile | None, *, tenant_id: int, kind: str) -> str | None: + if not upload or not upload.filename: + return None + original = Path(upload.filename).name + suffix = Path(original).suffix.lower() + if suffix not in _ALLOWED_BRANDING_SUFFIXES: + raise ValueError("Unsupported branding file type. Allowed: PNG, JPG, WEBP, ICO and SVG.") + data = await upload.read() + if not data: + return None + if len(data) > 2 * 1024 * 1024: + raise ValueError("Branding image size should not exceed 2 MB.") + folder = BRANDING_UPLOAD_ROOT / f"tenant_{tenant_id}" + folder.mkdir(parents=True, exist_ok=True) + filename = f"{kind}_{uuid4().hex}{suffix}" + path = folder / filename + path.write_bytes(data) + return "/static/uploads/branding/" + f"tenant_{tenant_id}/{filename}" + + +@router.get("/branding") +def branding_page(request: Request, tenant_id: int | None = None, branch_id: int | None = None, saved: int = 0): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not _can_manage_branding(db, user): + return _redirect_denied() + + roles = set(get_user_roles(db, user.id)) + tenant, branch, settings = _resolve_branding_scope(db, user, tenant_id=tenant_id, branch_id=branch_id) + if not tenant: + return _redirect_denied() + + if "System Admin" in roles: + tenants = db.execute(select(Tenant).where(Tenant.is_active.is_(True)).order_by(Tenant.name)).scalars().all() + else: + tenants = db.execute(select(Tenant).where(Tenant.id == user.tenant_id)).scalars().all() + branches = db.execute(select(Branch).where(Branch.tenant_id == tenant.id, Branch.is_active.is_(True)).order_by(Branch.name)).scalars().all() + + return templates.TemplateResponse( + "modules/system_settings/templates/branding.html", + _base_ctx( + request, + user, + db, + title="Firm Branding", + tenant=tenant, + branch=branch, + settings=settings, + tenants=tenants, + branches=branches, + can_change_tenant=("System Admin" in roles), + saved=bool(saved), + ), + ) + finally: + db.close() + + +@router.post("/branding") +async def branding_save( + request: Request, + tenant_id: int = Form(...), + branch_id: int | None = Form(None), + display_name: str = Form(""), + primary_color: str = Form("#2563eb"), + accent_color: str = Form("#0f172a"), + website_url: str = Form(""), + contact_email: str = Form(""), + contact_mobile: str = Form(""), + address_line1: str = Form(""), + address_line2: str = Form(""), + city: str = Form(""), + state: str = Form(""), + pin_code: str = Form(""), + gstin: str = Form(""), + pan: str = Form(""), + invoice_footer_text: str = Form(""), + bank_name: str = Form(""), + bank_account_name: str = Form(""), + bank_account_number: str = Form(""), + bank_ifsc: str = Form(""), + upi_id: str = Form(""), + logo_file: UploadFile | None = File(None), + favicon_file: UploadFile | None = File(None), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not _can_manage_branding(db, user): + return _redirect_denied() + + tenant, branch, settings = _resolve_branding_scope(db, user, tenant_id=tenant_id, branch_id=branch_id) + if not tenant: + return _redirect_denied() + + before_tenant = model_snapshot(tenant, ["name", "display_name", "logo_path", "favicon_path", "primary_color", "accent_color", "website_url", "contact_email", "contact_mobile"]) + before_settings = model_snapshot(settings, ["address_line1", "address_line2", "city", "state", "pin_code", "gstin", "pan", "invoice_footer_text", "bank_name", "bank_account_name", "bank_account_number", "bank_ifsc", "upi_id"]) if settings else {} + + tenant.display_name = _clean_optional(display_name) + tenant.primary_color = _clean_hex_color(primary_color, "#2563eb") + tenant.accent_color = _clean_hex_color(accent_color, "#0f172a") + tenant.website_url = _clean_optional(website_url) + tenant.contact_email = _clean_optional(contact_email) + tenant.contact_mobile = _clean_optional(contact_mobile) + + logo_path = await _save_branding_upload(logo_file, tenant_id=tenant.id, kind="logo") + favicon_path = await _save_branding_upload(favicon_file, tenant_id=tenant.id, kind="favicon") + if logo_path: + tenant.logo_path = logo_path + if favicon_path: + tenant.favicon_path = favicon_path + + if settings: + settings.address_line1 = _clean_optional(address_line1) + settings.address_line2 = _clean_optional(address_line2) + settings.city = _clean_optional(city) + settings.state = _clean_optional(state) + settings.pin_code = _clean_optional(pin_code) + settings.gstin = _clean_optional(gstin) + settings.pan = _clean_optional(pan) + settings.invoice_footer_text = _clean_optional(invoice_footer_text) + settings.bank_name = _clean_optional(bank_name) + settings.bank_account_name = _clean_optional(bank_account_name) + settings.bank_account_number = _clean_optional(bank_account_number) + settings.bank_ifsc = _clean_optional(bank_ifsc) + settings.upi_id = _clean_optional(upi_id) + + db.commit() + + write_audit_log( + db, + action="firm_branding.update", + entity_type="tenant", + actor=user, + request=request, + entity_id=tenant.id, + entity_name=tenant.display_name or tenant.name, + target_tenant_id=tenant.id, + target_branch_id=branch.id if branch else None, + details={ + "tenant": pair_before_after(before_tenant, model_snapshot(tenant, ["name", "display_name", "logo_path", "favicon_path", "primary_color", "accent_color", "website_url", "contact_email", "contact_mobile"])), + "settings": pair_before_after(before_settings, model_snapshot(settings, ["address_line1", "address_line2", "city", "state", "pin_code", "gstin", "pan", "invoice_footer_text", "bank_name", "bank_account_name", "bank_account_number", "bank_ifsc", "upi_id"]) if settings else {}), + }, + ) + suffix = f"?tenant_id={tenant.id}" + if branch: + suffix += f"&branch_id={branch.id}" + suffix += "&saved=1" + return RedirectResponse(url="/system-settings/branding" + suffix, status_code=303) + finally: + db.close() + +# ----------------------------- +# Phase v2.0.4-A - Financial Year Master +# ----------------------------- +@router.get("/financial-years") +def financial_years_list(request: Request, tenant_id: int | None = None): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not _can_view_financial_years(db, user): + return _redirect_denied() + + visible_tenant_ids = _visible_financial_year_tenant_ids(db, user) + active_tenant_id = tenant_id or _active_tenant_id_for_settings(request, user) + if active_tenant_id not in visible_tenant_ids: + active_tenant_id = int(user.tenant_id) + tenants = db.execute( + select(Tenant).where(Tenant.id.in_(visible_tenant_ids)).order_by(Tenant.name) + ).scalars().all() if visible_tenant_ids else [] + rows = db.execute( + select(FinancialYear) + .where(FinancialYear.tenant_id == active_tenant_id) + .order_by(FinancialYear.start_date.desc(), FinancialYear.year_code.desc()) + ).scalars().all() + return templates.TemplateResponse( + "modules/system_settings/templates/financial_years_list.html", + _base_ctx( + request, + user, + db, + title="Financial Years", + financial_years=rows, + tenants=tenants, + selected_tenant_id=active_tenant_id, + can_manage_fy=_can_manage_financial_years(db, user), + latest_backups={row.financial_year_id: row for row in db.execute(select(YearBackupExport).where(YearBackupExport.tenant_id == active_tenant_id).order_by(YearBackupExport.generated_at_utc.desc())).scalars().all()}, + ), + ) + finally: + db.close() + + +@router.get("/financial-years/new") +def financial_year_create_page(request: Request, tenant_id: int | None = None, year_code: str = ""): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not _can_manage_financial_years(db, user): + return _redirect_denied() + visible_tenant_ids = _visible_financial_year_tenant_ids(db, user) + active_tenant_id = tenant_id or _active_tenant_id_for_settings(request, user) + if active_tenant_id not in visible_tenant_ids: + active_tenant_id = int(user.tenant_id) + tenants = db.execute( + select(Tenant).where(Tenant.id.in_(visible_tenant_ids)).order_by(Tenant.name) + ).scalars().all() if visible_tenant_ids else [] + start_date, end_date = _default_dates_from_fy(year_code) + return templates.TemplateResponse( + "modules/system_settings/templates/financial_year_create.html", + _base_ctx( + request, + user, + db, + title="Create Financial Year", + tenants=tenants, + selected_tenant_id=active_tenant_id, + form_data={ + "year_code": year_code, + "assessment_year": _assessment_year_from_fy(year_code), + "start_date": start_date.isoformat() if start_date else "", + "end_date": end_date.isoformat() if end_date else "", + }, + ), + ) + finally: + db.close() + + +@router.post("/financial-years/new") +def financial_year_create_submit( + request: Request, + tenant_id: int = Form(...), + year_code: str = Form(...), + assessment_year: str = Form(""), + start_date: str = Form(...), + end_date: str = Form(...), + is_current: str | None = Form(None), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not _can_manage_financial_years(db, user): + return _redirect_denied() + if tenant_id not in _visible_financial_year_tenant_ids(db, user): + return _redirect_denied() + + year_code_clean = year_code.strip() + assessment_year_clean = (assessment_year or _assessment_year_from_fy(year_code_clean)).strip() + start_date_value = _parse_date(start_date) + end_date_value = _parse_date(end_date) + if not year_code_clean or not assessment_year_clean or not start_date_value or not end_date_value or start_date_value > end_date_value: + return RedirectResponse(url=f"/system-settings/financial-years/new?tenant_id={tenant_id}", status_code=303) + + exists = db.execute( + select(FinancialYear).where( + FinancialYear.tenant_id == tenant_id, + FinancialYear.year_code == year_code_clean, + ) + ).scalar_one_or_none() + if exists: + return RedirectResponse(url=f"/system-settings/financial-years?tenant_id={tenant_id}", status_code=303) + + if is_current is not None: + for existing_current in db.execute( + select(FinancialYear).where( + FinancialYear.tenant_id == tenant_id, + FinancialYear.is_current.is_(True), + ) + ).scalars().all(): + existing_current.is_current = False + + now = datetime.now(timezone.utc) + fy = FinancialYear( + tenant_id=tenant_id, + year_code=year_code_clean, + assessment_year=assessment_year_clean, + start_date=start_date_value, + end_date=end_date_value, + is_current=is_current is not None or _current_financial_year(db, tenant_id) is None, + is_locked=False, + created_at_utc=now, + updated_at_utc=now, + ) + db.add(fy) + db.commit() + db.refresh(fy) + write_audit_log( + db, + action="financial_year.create", + entity_type="financial_year", + actor=user, + request=request, + entity_id=fy.id, + entity_name=fy.year_code, + target_tenant_id=fy.tenant_id, + details={"after": model_snapshot(fy, ["tenant_id", "year_code", "assessment_year", "start_date", "end_date", "is_current", "is_locked"])}, + ) + return RedirectResponse(url=f"/system-settings/financial-years?tenant_id={tenant_id}", status_code=303) + finally: + db.close() + + +@router.get("/financial-years/{fy_id}/edit") +def financial_year_edit_page(request: Request, fy_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not _can_manage_financial_years(db, user): + return _redirect_denied() + fy = db.execute(select(FinancialYear).where(FinancialYear.id == fy_id)).scalar_one_or_none() + if not fy or fy.tenant_id not in _visible_financial_year_tenant_ids(db, user): + return _redirect_denied() + tenant = db.execute(select(Tenant).where(Tenant.id == fy.tenant_id)).scalar_one_or_none() + return templates.TemplateResponse( + "modules/system_settings/templates/financial_year_edit.html", + _base_ctx(request, user, db, title="Edit Financial Year", fy=fy, tenant=tenant), + ) + finally: + db.close() + + +@router.post("/financial-years/{fy_id}/edit") +def financial_year_edit_submit( + request: Request, + fy_id: int, + assessment_year: str = Form(...), + start_date: str = Form(...), + end_date: str = Form(...), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not _can_manage_financial_years(db, user): + return _redirect_denied() + fy = db.execute(select(FinancialYear).where(FinancialYear.id == fy_id)).scalar_one_or_none() + if not fy or fy.tenant_id not in _visible_financial_year_tenant_ids(db, user): + return _redirect_denied() + if fy.is_locked: + return RedirectResponse(url=f"/system-settings/financial-years?tenant_id={fy.tenant_id}", status_code=303) + + before = model_snapshot(fy, ["assessment_year", "start_date", "end_date", "is_current", "is_locked"]) + start_date_value = _parse_date(start_date) + end_date_value = _parse_date(end_date) + if not start_date_value or not end_date_value or start_date_value > end_date_value: + return RedirectResponse(url=f"/system-settings/financial-years/{fy.id}/edit", status_code=303) + fy.assessment_year = assessment_year.strip() + fy.start_date = start_date_value + fy.end_date = end_date_value + fy.updated_at_utc = datetime.now(timezone.utc) + db.commit() + write_audit_log( + db, + action="financial_year.update", + entity_type="financial_year", + actor=user, + request=request, + entity_id=fy.id, + entity_name=fy.year_code, + target_tenant_id=fy.tenant_id, + details=pair_before_after(before, model_snapshot(fy, ["assessment_year", "start_date", "end_date", "is_current", "is_locked"])), + ) + return RedirectResponse(url=f"/system-settings/financial-years?tenant_id={fy.tenant_id}", status_code=303) + finally: + db.close() + + +@router.post("/financial-years/{fy_id}/make-current") +def financial_year_make_current(request: Request, fy_id: int, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not _can_manage_financial_years(db, user): + return _redirect_denied() + fy = db.execute(select(FinancialYear).where(FinancialYear.id == fy_id)).scalar_one_or_none() + if not fy or fy.tenant_id not in _visible_financial_year_tenant_ids(db, user): + return _redirect_denied() + for existing_current in db.execute( + select(FinancialYear).where(FinancialYear.tenant_id == fy.tenant_id, FinancialYear.is_current.is_(True)) + ).scalars().all(): + existing_current.is_current = False + fy.is_current = True + fy.updated_at_utc = datetime.now(timezone.utc) + db.commit() + if int(request.session.get("active_tenant_id") or user.tenant_id) == fy.tenant_id: + request.session["active_financial_year"] = fy.year_code + return RedirectResponse(url=f"/system-settings/financial-years?tenant_id={fy.tenant_id}", status_code=303) + finally: + db.close() + + +@router.post("/financial-years/{fy_id}/lock") +def financial_year_lock(request: Request, fy_id: int, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not _can_manage_financial_years(db, user): + return _redirect_denied() + fy = db.execute(select(FinancialYear).where(FinancialYear.id == fy_id)).scalar_one_or_none() + if not fy or fy.tenant_id not in _visible_financial_year_tenant_ids(db, user): + return _redirect_denied() + fy.is_locked = True + fy.locked_at_utc = datetime.now(timezone.utc) + fy.locked_by_user_id = user.id + fy.updated_at_utc = datetime.now(timezone.utc) + db.commit() + return RedirectResponse(url=f"/system-settings/financial-years?tenant_id={fy.tenant_id}", status_code=303) + finally: + db.close() + + +@router.post("/financial-years/{fy_id}/unlock") +def financial_year_unlock(request: Request, fy_id: int, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + roles = set(get_user_roles(db, user.id)) + if "System Admin" not in roles and "Firm Admin" not in roles: + return _redirect_denied() + fy = db.execute(select(FinancialYear).where(FinancialYear.id == fy_id)).scalar_one_or_none() + if not fy or fy.tenant_id not in _visible_financial_year_tenant_ids(db, user): + return _redirect_denied() + fy.is_locked = False + fy.locked_at_utc = None + fy.locked_by_user_id = None + fy.updated_at_utc = datetime.now(timezone.utc) + db.commit() + return RedirectResponse(url=f"/system-settings/financial-years?tenant_id={fy.tenant_id}", status_code=303) + finally: + db.close() + + +@router.get("/financial-years/{fy_id}/backup") +def financial_year_backup_page(request: Request, fy_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not _can_view_financial_years(db, user): + return _redirect_denied() + fy = db.execute(select(FinancialYear).where(FinancialYear.id == fy_id)).scalar_one_or_none() + if not fy or fy.tenant_id not in _visible_financial_year_tenant_ids(db, user): + return _redirect_denied() + exports = db.execute( + select(YearBackupExport) + .where(YearBackupExport.financial_year_id == fy.id) + .order_by(YearBackupExport.generated_at_utc.desc(), YearBackupExport.id.desc()) + ).scalars().all() + return templates.TemplateResponse( + "modules/system_settings/templates/financial_year_backup.html", + _base_ctx( + request, + user, + db, + title=f"Backup Export - {fy.year_code}", + fy=fy, + exports=exports, + can_manage_fy=_can_manage_financial_years(db, user), + ), + ) + finally: + db.close() + + +@router.post("/financial-years/{fy_id}/backup/export") +def financial_year_backup_export_submit(request: Request, fy_id: int, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not _can_manage_financial_years(db, user): + return _redirect_denied() + fy = db.execute(select(FinancialYear).where(FinancialYear.id == fy_id)).scalar_one_or_none() + if not fy or fy.tenant_id not in _visible_financial_year_tenant_ids(db, user): + return _redirect_denied() + export = build_year_backup_export(db, financial_year=fy, user_id=user.id) + db.commit() + write_audit_log( + db, + action="financial_year.backup_export", + entity_type="financial_year", + actor=user, + request=request, + entity_id=fy.id, + entity_name=fy.year_code, + target_tenant_id=fy.tenant_id, + details={"backup_export_id": export.id, "file_size_bytes": export.file_size_bytes}, + ) + return RedirectResponse(url=f"/system-settings/financial-years/{fy.id}/backup?exported=1", status_code=303) + except Exception: + db.rollback() + raise + finally: + db.close() + + +@router.get("/financial-years/backups/{export_id}/download") +def financial_year_backup_download(request: Request, export_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not _can_view_financial_years(db, user): + return _redirect_denied() + export = db.execute(select(YearBackupExport).where(YearBackupExport.id == export_id)).scalar_one_or_none() + if not export or export.tenant_id not in _visible_financial_year_tenant_ids(db, user): + return _redirect_denied() + path = Path(export.export_file_path) + if not path.exists() or not path.is_file(): + return RedirectResponse(url=f"/system-settings/financial-years/{export.financial_year_id}/backup?error=file_missing", status_code=303) + return FileResponse(path, filename=path.name, media_type="application/zip") + finally: + db.close() + + +@router.get("/context/financial-year/{year_code}") +def switch_active_financial_year(request: Request, year_code: str): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + active_tenant_id = int(request.session.get("active_tenant_id") or user.tenant_id) + if active_tenant_id not in _visible_financial_year_tenant_ids(db, user): + return _redirect_denied() + fy = db.execute( + select(FinancialYear).where( + FinancialYear.tenant_id == active_tenant_id, + FinancialYear.year_code == year_code.strip(), + ) + ).scalar_one_or_none() + if not fy: + return _redirect_denied() + request.session["active_financial_year"] = fy.year_code + return _financial_year_redirect_back(request) + finally: + db.close() + +def _redirect_back(request: Request, default_url: str = "/services") -> RedirectResponse: + return RedirectResponse(url=request.headers.get("referer") or default_url, status_code=303) + + +@router.get("/context/tenant/{tenant_id}") +def switch_active_tenant(request: Request, tenant_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + + roles = get_user_roles(db, user.id) + perms = set(get_user_permissions(db, user.id)) + if "System Admin" not in roles or "services.cross_tenant" not in perms: + return _redirect_denied() + + scope = build_scope(db, user) + visible_ids = {t.id for t in list_visible_tenants(db, scope)} + if tenant_id not in visible_ids: + return _redirect_denied() + + tenant = db.get(Tenant, tenant_id) + if not tenant or not tenant.is_active: + return _redirect_denied() + + _store_active_tenant_context(request, tenant) + _store_active_branch_context(request, None) + current_fy = _current_financial_year(db, tenant_id) + if current_fy: + request.session["active_financial_year"] = current_fy.year_code + else: + request.session.pop("active_financial_year", None) + return _redirect_back(request) + finally: + db.close() + + +@router.get("/context/branch/{branch_id}") +def switch_active_branch(request: Request, branch_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + + perms = set(get_user_permissions(db, user.id)) + roles = get_user_roles(db, user.id) + if "services.cross_branch" not in perms and not ("System Admin" in roles and "services.cross_tenant" in perms): + return _redirect_denied() + + active_tenant_id = int(request.session.get("active_tenant_id") or user.tenant_id) + if branch_id == 0: + _store_active_branch_context(request, None) + return _redirect_back(request) + + scope = build_scope(db, user) + visible_ids = {b.id for b in list_visible_branches(db, scope, tenant_id=active_tenant_id)} + if branch_id not in visible_ids: + return _redirect_denied() + + branch = db.get(Branch, branch_id) + if not branch or int(branch.tenant_id) != int(active_tenant_id) or not branch.is_active: + return _redirect_denied() + + _store_active_branch_context(request, branch) + return _redirect_back(request) + finally: + db.close() diff --git a/app/modules/system_settings/year_backup_service.py b/app/modules/system_settings/year_backup_service.py new file mode 100644 index 0000000..530188c --- /dev/null +++ b/app/modules/system_settings/year_backup_service.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import csv +import json +import shutil +import zipfile +from datetime import datetime, timezone +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import Any, Iterable + +from sqlalchemy import select + +from app.modules.billing.models import BillingInvoice, BillingInvoiceLine, BillingPayment, BillingInvoiceGenerationBatch +from app.modules.clients.models import Client +from app.modules.core.tenancy.models import FinancialYear, YearBackupExport +from app.modules.documents.models import EngagementDocument, EngagementDocumentVersion +from app.modules.notice_cases.models import NoticeCase, NoticeCaseDocument, NoticeCaseEvent, NoticeCaseHearing, NoticeCaseOrder +from app.modules.services.models import ClientServiceSubscription, ClientServiceTaskInstance + +BACKUP_ROOT = Path("data/year_backups") + + +def _safe_name(value: str) -> str: + return "".join(ch if ch.isalnum() or ch in {"-", "_", "."} else "_" for ch in str(value or "")).strip("_") or "export" + + +def _serialise(value: Any) -> str: + if value is None: + return "" + if isinstance(value, (datetime,)): + return value.isoformat() + if hasattr(value, "isoformat"): + try: + return value.isoformat() + except Exception: + pass + return str(value) + + +def _columns(model: Any) -> list[str]: + return [column.name for column in model.__table__.columns] + + +def _write_csv(path: Path, model: Any, rows: Iterable[Any]) -> int: + cols = _columns(model) + count = 0 + with path.open("w", newline="", encoding="utf-8") as fh: + writer = csv.DictWriter(fh, fieldnames=cols) + writer.writeheader() + for row in rows: + writer.writerow({col: _serialise(getattr(row, col, None)) for col in cols}) + count += 1 + return count + + +def _relative_file_candidates(row: Any) -> list[Path]: + candidates: list[Path] = [] + rel = getattr(row, "local_relative_path", None) + if rel: + candidates.append(Path(str(rel))) + candidates.append(Path("documents") / str(rel)) + candidates.append(Path("data") / str(rel)) + return candidates + + +def _copy_known_files(staging_dir: Path, rows: Iterable[Any], subfolder: str) -> int: + copied = 0 + target_root = staging_dir / "files" / subfolder + target_root.mkdir(parents=True, exist_ok=True) + for row in rows: + source = None + for candidate in _relative_file_candidates(row): + if candidate.exists() and candidate.is_file(): + source = candidate + break + if not source: + continue + target_name = f"{getattr(row, 'id', 'file')}_{_safe_name(getattr(row, 'original_filename', None) or getattr(row, 'stored_filename', None) or source.name)}" + shutil.copy2(source, target_root / target_name) + copied += 1 + return copied + + +def _rows_by_fy(db, model: Any, *, tenant_id: int, financial_year: str): + return db.execute( + select(model).where(model.tenant_id == tenant_id, model.financial_year == financial_year) + ).scalars().all() + + +def build_year_backup_export(db, *, financial_year: FinancialYear, user_id: int | None) -> YearBackupExport: + BACKUP_ROOT.mkdir(parents=True, exist_ok=True) + now = datetime.now(timezone.utc) + export_code = f"FY_{_safe_name(financial_year.year_code)}_{now.strftime('%Y%m%d_%H%M%S')}" + staging_parent = BACKUP_ROOT / "_staging" + staging_parent.mkdir(parents=True, exist_ok=True) + + with TemporaryDirectory(prefix=export_code + "_", dir=str(staging_parent)) as tmp: + staging = Path(tmp) + tenant_id = int(financial_year.tenant_id) + fy = financial_year.year_code + + subscriptions = _rows_by_fy(db, ClientServiceSubscription, tenant_id=tenant_id, financial_year=fy) + tasks = _rows_by_fy(db, ClientServiceTaskInstance, tenant_id=tenant_id, financial_year=fy) + engagement_documents = _rows_by_fy(db, EngagementDocument, tenant_id=tenant_id, financial_year=fy) + engagement_document_ids = [row.id for row in engagement_documents] + engagement_versions = db.execute( + select(EngagementDocumentVersion).where(EngagementDocumentVersion.document_id.in_(engagement_document_ids)) + ).scalars().all() if engagement_document_ids else [] + + notice_cases = _rows_by_fy(db, NoticeCase, tenant_id=tenant_id, financial_year=fy) + notice_case_ids = [row.id for row in notice_cases] + notice_events = db.execute(select(NoticeCaseEvent).where(NoticeCaseEvent.case_id.in_(notice_case_ids))).scalars().all() if notice_case_ids else [] + notice_hearings = db.execute(select(NoticeCaseHearing).where(NoticeCaseHearing.case_id.in_(notice_case_ids))).scalars().all() if notice_case_ids else [] + notice_orders = db.execute(select(NoticeCaseOrder).where(NoticeCaseOrder.case_id.in_(notice_case_ids))).scalars().all() if notice_case_ids else [] + notice_documents = db.execute(select(NoticeCaseDocument).where(NoticeCaseDocument.case_id.in_(notice_case_ids))).scalars().all() if notice_case_ids else [] + + invoices = _rows_by_fy(db, BillingInvoice, tenant_id=tenant_id, financial_year=fy) + invoice_ids = [row.id for row in invoices] + invoice_lines = db.execute(select(BillingInvoiceLine).where(BillingInvoiceLine.invoice_id.in_(invoice_ids))).scalars().all() if invoice_ids else [] + payments = _rows_by_fy(db, BillingPayment, tenant_id=tenant_id, financial_year=fy) + batches = _rows_by_fy(db, BillingInvoiceGenerationBatch, tenant_id=tenant_id, financial_year=fy) + + client_ids = sorted({row.client_id for row in subscriptions if getattr(row, "client_id", None)} | {row.client_id for row in notice_cases if getattr(row, "client_id", None)} | {row.client_id for row in invoices if getattr(row, "client_id", None)}) + clients = db.execute(select(Client).where(Client.id.in_(client_ids))).scalars().all() if client_ids else [] + + manifest = { + "export_code": export_code, + "tenant_id": tenant_id, + "financial_year": fy, + "assessment_year": financial_year.assessment_year, + "generated_at_utc": now.isoformat(), + "generated_by_user_id": user_id, + "record_counts": {}, + "file_counts": {}, + } + + datasets = [ + ("financial_year.csv", FinancialYear, [financial_year]), + ("clients.csv", Client, clients), + ("engagements.csv", ClientServiceSubscription, subscriptions), + ("tasks.csv", ClientServiceTaskInstance, tasks), + ("engagement_documents.csv", EngagementDocument, engagement_documents), + ("engagement_document_versions.csv", EngagementDocumentVersion, engagement_versions), + ("notice_cases.csv", NoticeCase, notice_cases), + ("notice_case_events.csv", NoticeCaseEvent, notice_events), + ("notice_case_hearings.csv", NoticeCaseHearing, notice_hearings), + ("notice_case_orders.csv", NoticeCaseOrder, notice_orders), + ("notice_case_documents.csv", NoticeCaseDocument, notice_documents), + ("billing_batches.csv", BillingInvoiceGenerationBatch, batches), + ("billing_invoices.csv", BillingInvoice, invoices), + ("billing_invoice_lines.csv", BillingInvoiceLine, invoice_lines), + ("billing_payments.csv", BillingPayment, payments), + ] + for filename, model, rows in datasets: + manifest["record_counts"][filename] = _write_csv(staging / filename, model, rows) + + manifest["file_counts"]["engagement_document_versions"] = _copy_known_files(staging, engagement_versions, "engagement_documents") + manifest["file_counts"]["notice_case_documents"] = _copy_known_files(staging, notice_documents, "notice_cases") + + (staging / "manifest.json").write_text(json.dumps(manifest, indent=2, default=str), encoding="utf-8") + + zip_path = BACKUP_ROOT / f"{export_code}.zip" + with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for file_path in staging.rglob("*"): + if file_path.is_file(): + zf.write(file_path, file_path.relative_to(staging)) + + export = YearBackupExport( + tenant_id=financial_year.tenant_id, + financial_year_id=financial_year.id, + year_code=financial_year.year_code, + assessment_year=financial_year.assessment_year, + export_status="completed", + export_file_path=str(zip_path), + file_size_bytes=zip_path.stat().st_size if zip_path.exists() else 0, + manifest_json=json.dumps(manifest, default=str), + generated_by_user_id=user_id, + generated_at_utc=now, + ) + db.add(export) + db.flush() + return export diff --git a/app/modules/work_detail/__init__.py b/app/modules/work_detail/__init__.py new file mode 100644 index 0000000..f9e960c --- /dev/null +++ b/app/modules/work_detail/__init__.py @@ -0,0 +1 @@ +"""Unified engagement/work detail page for role-wise workspaces.""" diff --git a/app/modules/work_detail/service.py b/app/modules/work_detail/service.py new file mode 100644 index 0000000..33b813c --- /dev/null +++ b/app/modules/work_detail/service.py @@ -0,0 +1,305 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, datetime, timezone +from typing import Any + +from sqlalchemy import or_, select +from sqlalchemy.orm import Session, selectinload + +from app.modules.clients import repository as client_repository +from app.modules.clients.models import Client +from app.modules.consultants.models import ClientConsultantLink, ConsultantProfile +from app.modules.core.rbac.deps import get_user_roles +from app.modules.documents.models import EngagementDocument, PermanentClientDocument +from app.modules.services.execution import ( + CLOSED_TASK_STATUSES, + TASK_COMMENT_TYPES, + TASK_COMMENT_VISIBILITIES, + TASK_PRIORITIES, + TASK_STATUSES, + add_task_comment, +) +from app.modules.services.models import ( + ClientServiceSubscription, + ClientServiceTaskInstance, + ServiceTaskComment, +) + +MANAGEMENT_ROLES = {"System Admin", "Firm Admin"} +PARTNER_ROLES = {"Partner"} +MANAGER_ROLES = {"Manager", "Branch Manager"} +STAFF_ROLES = {"Staff", "Employee"} +CLIENT_ROLES = {"Client"} +CONSULTANT_ROLES = {"Consultant"} + + +@dataclass(frozen=True) +class WorkAccess: + role_context: str + can_update_tasks: bool + can_comment: bool + allowed_comment_types: list[tuple[str, str]] + allowed_visibilities: list[tuple[str, str]] + back_url: str + + +def _roles(db: Session, user) -> set[str]: + return set(get_user_roles(db, user.id)) + + +def _safe_int(value: Any) -> int | None: + try: + return int(value) if value not in (None, "", "None") else None + except Exception: + return None + + +def _current_client_row(db: Session, user): + try: + return client_repository.get_portal_client_for_user(db, user=user) + except Exception: + return None + + +def _current_consultant(db: Session, user) -> ConsultantProfile | None: + return db.execute( + select(ConsultantProfile).where( + ConsultantProfile.user_id == user.id, + ConsultantProfile.tenant_id == user.tenant_id, + ConsultantProfile.is_active.is_(True), + ) + ).scalar_one_or_none() + + +def _consultant_can_view_engagement(db: Session, *, consultant: ConsultantProfile, engagement: ClientServiceSubscription) -> bool: + linked = db.execute( + select(ClientConsultantLink.id).where( + ClientConsultantLink.tenant_id == consultant.tenant_id, + ClientConsultantLink.client_id == engagement.client_id, + ClientConsultantLink.consultant_id == consultant.id, + ClientConsultantLink.is_active.is_(True), + ClientConsultantLink.can_view_communications.is_(True), + ) + ).first() + if not linked: + return False + visible_comment = db.execute( + select(ServiceTaskComment.id) + .join(ClientServiceTaskInstance, ClientServiceTaskInstance.id == ServiceTaskComment.task_instance_id) + .where( + ServiceTaskComment.tenant_id == consultant.tenant_id, + ServiceTaskComment.subscription_id == engagement.id, + ServiceTaskComment.visibility == "consultant", + ServiceTaskComment.is_deleted.is_(False), + ClientServiceTaskInstance.is_active.is_(True), + ) + .limit(1) + ).first() + return bool(visible_comment) + + +def _is_assigned_staff(engagement: ClientServiceSubscription, tasks: list[ClientServiceTaskInstance], user) -> bool: + if _safe_int(getattr(engagement, "assigned_staff_user_id", None)) == user.id: + return True + return any(_safe_int(getattr(task, "assigned_to_user_id", None)) == user.id for task in tasks) + + +def _is_manager_for_engagement(engagement: ClientServiceSubscription, user) -> bool: + return _safe_int(getattr(engagement, "assigned_manager_user_id", None)) == user.id + + +def _is_partner_for_engagement(engagement: ClientServiceSubscription, user) -> bool: + return user.id in { + _safe_int(getattr(engagement, "assigned_partner_user_id", None)), + _safe_int(getattr(engagement, "review_partner_user_id", None)), + } + + +def _engagement_query(engagement_id: int): + return ( + select(ClientServiceSubscription) + .options( + selectinload(ClientServiceSubscription.client), + selectinload(ClientServiceSubscription.catalogue), + selectinload(ClientServiceSubscription.assigned_partner), + selectinload(ClientServiceSubscription.assigned_manager), + selectinload(ClientServiceSubscription.assigned_staff), + selectinload(ClientServiceSubscription.review_partner), + ) + .where(ClientServiceSubscription.id == int(engagement_id), ClientServiceSubscription.is_active.is_(True)) + ) + + +def _load_tasks(db: Session, engagement: ClientServiceSubscription) -> list[ClientServiceTaskInstance]: + return db.execute( + select(ClientServiceTaskInstance) + .options( + selectinload(ClientServiceTaskInstance.assigned_to), + selectinload(ClientServiceTaskInstance.catalogue), + selectinload(ClientServiceTaskInstance.comments).selectinload(ServiceTaskComment.created_by), + ) + .where( + ClientServiceTaskInstance.tenant_id == engagement.tenant_id, + ClientServiceTaskInstance.subscription_id == engagement.id, + ClientServiceTaskInstance.is_active.is_(True), + ) + .order_by(ClientServiceTaskInstance.sequence_no.asc(), ClientServiceTaskInstance.id.asc()) + ).scalars().all() + + +def _load_documents(db: Session, engagement: ClientServiceSubscription) -> tuple[list[EngagementDocument], list[PermanentClientDocument]]: + engagement_documents = db.execute( + select(EngagementDocument) + .options(selectinload(EngagementDocument.versions)) + .where( + EngagementDocument.tenant_id == engagement.tenant_id, + EngagementDocument.client_id == engagement.client_id, + EngagementDocument.engagement_id == engagement.id, + EngagementDocument.is_deleted.is_(False), + ) + .order_by(EngagementDocument.document_type.asc(), EngagementDocument.title.asc()) + ).unique().scalars().all() + permanent_documents = db.execute( + select(PermanentClientDocument) + .options(selectinload(PermanentClientDocument.versions)) + .where( + PermanentClientDocument.tenant_id == engagement.tenant_id, + PermanentClientDocument.client_id == engagement.client_id, + PermanentClientDocument.is_deleted.is_(False), + ) + .order_by(PermanentClientDocument.category.asc(), PermanentClientDocument.title.asc()) + .limit(50) + ).unique().scalars().all() + return engagement_documents, permanent_documents + + +def _load_timeline(db: Session, engagement: ClientServiceSubscription, access: WorkAccess) -> list[ServiceTaskComment]: + stmt = ( + select(ServiceTaskComment) + .options(selectinload(ServiceTaskComment.created_by), selectinload(ServiceTaskComment.task)) + .where( + ServiceTaskComment.tenant_id == engagement.tenant_id, + ServiceTaskComment.subscription_id == engagement.id, + ServiceTaskComment.is_deleted.is_(False), + ) + ) + if access.role_context == "client": + stmt = stmt.where(ServiceTaskComment.visibility == "client") + elif access.role_context == "consultant": + stmt = stmt.where(ServiceTaskComment.visibility == "consultant") + return db.execute(stmt.order_by(ServiceTaskComment.created_at_utc.asc(), ServiceTaskComment.id.asc())).scalars().all() + + +def _build_access(db: Session, *, user, engagement: ClientServiceSubscription, tasks: list[ClientServiceTaskInstance]) -> WorkAccess | None: + roles = _roles(db, user) + if roles.intersection(MANAGEMENT_ROLES): + return WorkAccess("admin", True, True, TASK_COMMENT_TYPES, TASK_COMMENT_VISIBILITIES, "/services/work-tracker") + + if roles.intersection(CLIENT_ROLES): + client_row = _current_client_row(db, user) + if client_row and int(client_row.get("id") or 0) == engagement.client_id and int(client_row.get("tenant_id") or 0) == engagement.tenant_id: + return WorkAccess("client", False, True, [("client_clarification", "Client Clarification")], [("client", "Client")], "/client/compliance") + return None + + if roles.intersection(CONSULTANT_ROLES): + consultant = _current_consultant(db, user) + if consultant and _consultant_can_view_engagement(db, consultant=consultant, engagement=engagement): + return WorkAccess("consultant", False, True, [("consultant_clarification", "Consultant Clarification")], [("consultant", "Consultant")], "/consultant/work") + return None + + if roles.intersection(PARTNER_ROLES) and _is_partner_for_engagement(engagement, user): + return WorkAccess("partner", True, True, TASK_COMMENT_TYPES, TASK_COMMENT_VISIBILITIES, "/partner/reviews") + + if roles.intersection(MANAGER_ROLES): + if _is_manager_for_engagement(engagement, user) or (engagement.tenant_id == user.tenant_id and (engagement.branch_id in (None, user.branch_id))): + return WorkAccess("manager", True, True, TASK_COMMENT_TYPES, TASK_COMMENT_VISIBILITIES, "/manager/work") + + if roles.intersection(STAFF_ROLES) or roles.intersection({"Employee"}): + if _is_assigned_staff(engagement, tasks, user): + return WorkAccess("staff", True, True, [("internal_note", "Internal Note"), ("client_clarification", "Client Clarification")], [("internal", "Internal"), ("client", "Client")], "/employee/work") + + return None + + +def load_unified_engagement_detail(db: Session, *, request, user, engagement_id: int) -> dict[str, Any] | None: + engagement = db.execute(_engagement_query(engagement_id)).scalar_one_or_none() + if not engagement: + return None + roles = _roles(db, user) + if "System Admin" not in roles and engagement.tenant_id != user.tenant_id: + return None + + tasks = _load_tasks(db, engagement) + access = _build_access(db, user=user, engagement=engagement, tasks=tasks) + if not access: + return None + + engagement_documents, permanent_documents = _load_documents(db, engagement) + timeline = _load_timeline(db, engagement, access) + today = date.today() + for task in tasks: + status = (task.status or "pending").lower() + task.status_label = dict(TASK_STATUSES).get(status, status.replace("_", " ").title()) + task.priority_label = dict(TASK_PRIORITIES).get(task.priority or "normal", (task.priority or "normal").replace("_", " ").title()) + task.is_closed_display = status in CLOSED_TASK_STATUSES + task.is_overdue_display = bool(task.internal_target_date and task.internal_target_date < today and status not in CLOSED_TASK_STATUSES) + task.comments_visible_count = len([c for c in getattr(task, "comments", []) if not getattr(c, "is_deleted", False)]) + + status_counts: dict[str, int] = {code: 0 for code, _ in TASK_STATUSES} + for task in tasks: + status_counts[(task.status or "pending").lower()] = status_counts.get((task.status or "pending").lower(), 0) + 1 + + return { + "engagement": engagement, + "tasks": tasks, + "engagement_documents": engagement_documents, + "permanent_documents": permanent_documents, + "timeline": timeline, + "access": access, + "role_context": access.role_context, + "task_statuses": TASK_STATUSES, + "task_priorities": TASK_PRIORITIES, + "status_counts": status_counts, + "open_task_count": sum(1 for task in tasks if (task.status or "pending").lower() not in CLOSED_TASK_STATUSES), + "completed_task_count": sum(1 for task in tasks if (task.status or "pending").lower() in CLOSED_TASK_STATUSES), + } + + +def get_task_for_action(db: Session, *, user, task_id: int) -> tuple[ClientServiceTaskInstance | None, WorkAccess | None]: + task = db.execute( + select(ClientServiceTaskInstance) + .options(selectinload(ClientServiceTaskInstance.subscription), selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.client)) + .where(ClientServiceTaskInstance.id == int(task_id), ClientServiceTaskInstance.is_active.is_(True)) + ).scalar_one_or_none() + if not task or not task.subscription: + return None, None + detail = load_unified_engagement_detail(db, request=None, user=user, engagement_id=task.subscription_id) + if not detail: + return None, None + return task, detail["access"] + + +def save_task_status(db: Session, *, task: ClientServiceTaskInstance, status: str, priority: str | None, user_id: int) -> None: + allowed_statuses = {code for code, _ in TASK_STATUSES} + allowed_priorities = {code for code, _ in TASK_PRIORITIES} + clean_status = (status or task.status or "pending").strip().lower() + clean_priority = (priority or task.priority or "normal").strip().lower() + if clean_status in allowed_statuses: + task.status = clean_status + if clean_priority in allowed_priorities: + task.priority = clean_priority + if task.status == "in_progress" and not task.started_at_utc: + task.started_at_utc = datetime.now(timezone.utc) + if task.status in CLOSED_TASK_STATUSES and not task.completed_at_utc: + task.completed_at_utc = datetime.now(timezone.utc) + task.updated_by_user_id = user_id + + +def save_task_comment(db: Session, *, task: ClientServiceTaskInstance, access: WorkAccess, comment_type: str, visibility: str, message: str, user_id: int) -> bool: + allowed_comment_types = {code for code, _ in access.allowed_comment_types} + allowed_visibilities = {code for code, _ in access.allowed_visibilities} + clean_type = comment_type if comment_type in allowed_comment_types else next(iter(allowed_comment_types), "internal_note") + clean_visibility = visibility if visibility in allowed_visibilities else next(iter(allowed_visibilities), "internal") + row = add_task_comment(db, task=task, comment_type=clean_type, visibility=clean_visibility, message=message, user_id=user_id) + return row is not None diff --git a/app/modules/work_detail/templates/work_detail/engagement_detail.html b/app/modules/work_detail/templates/work_detail/engagement_detail.html new file mode 100644 index 0000000..0d3b553 --- /dev/null +++ b/app/modules/work_detail/templates/work_detail/engagement_detail.html @@ -0,0 +1,154 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +{% if role_context == 'client' %} + {% include "modules/clients/templates/clients/_client_tabs.html" ignore missing %} +{% elif role_context == 'consultant' %} + {% include "modules/consultants/templates/consultants/_consultant_tabs.html" ignore missing %} +{% elif role_context == 'partner' %} + {% include "modules/partners/templates/partners/_partner_tabs.html" ignore missing %} +{% elif role_context == 'manager' %} + {% include "modules/managers/templates/managers/_manager_tabs.html" ignore missing %} +{% elif role_context == 'staff' %} + {% include "modules/employees/templates/employees/_my_workspace_tabs.html" ignore missing %} +{% endif %} + +{% set client = engagement.client %} +{% set catalogue = engagement.catalogue %} +
+
+
+
+
Unified Work Details
+

{{ catalogue.service_name if catalogue else 'Engagement' }}

+

+ {{ client.client_name if client else 'Client' }}{% if client and client.client_code %} • {{ client.client_code }}{% endif %} + {% if engagement.financial_year %} • FY {{ engagement.financial_year }}{% endif %} + {% if engagement.assessment_year %} • AY {{ engagement.assessment_year }}{% endif %} +

+
+
+ {{ engagement.status.replace('_',' ').title() }} + {% if engagement.current_due_date %}Due {{ engagement.current_due_date.strftime('%d-%m-%Y') }}{% endif %} + Back +
+
+ +
+
Open Tasks
{{ open_task_count }}
+
Completed
{{ completed_task_count }}
+
Engagement Docs
{{ engagement_documents|length }}
+
Timeline Notes
{{ timeline|length }}
+
+
+ +
+
+
+
+

Task Board

+

Role-sensitive task view. Clients and consultants see only permitted action/comment options.

+
+
+ {% for task in tasks %} +
+
+
+
{{ task.sequence_no }}. {{ task.task_name }}
+ {% if task.description %}
{{ task.description }}
{% endif %} +
+ {{ task.status_label }} + {{ task.priority_label }} + {% if task.internal_target_date %}Target {{ task.internal_target_date.strftime('%d-%m-%Y') }}{% endif %} + {% if task.assigned_to %}Assigned: {{ task.assigned_to.full_name or task.assigned_to.email }}{% endif %} +
+
+ {% if access.can_update_tasks %} +
+ + + + +
+ {% endif %} +
+ + {% if access.can_comment %} +
+ +
+ + + + +
+
+ {% endif %} +
+ {% else %} +
No tasks have been generated for this engagement yet.
+ {% endfor %} +
+
+ +
+

Communication Timeline

+
+ {% for note in timeline %} +
+
+ {{ note.comment_type.replace('_',' ').title() }} + {{ note.visibility.replace('_',' ').title() }} + {% if note.created_by %}{{ note.created_by.full_name or note.created_by.email }}{% endif %} + {% if note.created_at_utc %}{{ note.created_at_utc.strftime('%d-%m-%Y %H:%M') }}{% endif %} + {% if note.task %}{{ note.task.task_name }}{% endif %} +
+
{{ note.message }}
+
+ {% else %} +
No communication yet.
+ {% endfor %} +
+
+
+ + +
+
+{% endblock %} diff --git a/app/modules/work_detail/ui.py b/app/modules/work_detail/ui.py new file mode 100644 index 0000000..7a280cd --- /dev/null +++ b/app/modules/work_detail/ui.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +from fastapi import APIRouter, Form, Request +from fastapi.responses import RedirectResponse + +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.core.rbac.deps import get_user_permissions, get_user_roles +from app.modules.work_detail.service import get_task_for_action, load_unified_engagement_detail, save_task_comment, save_task_status + +router = APIRouter(prefix="/work", tags=["unified-work-detail-ui"]) + + +def _base_ctx(request: Request, db, user, **ctx): + base = { + "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), + } + base.update(ctx) + return base + + +def _fallback_for_user(db, user) -> str: + roles = set(get_user_roles(db, user.id)) + if "Client" in roles: + return "/client/compliance" + if "Consultant" in roles: + return "/consultant/work" + if "Partner" in roles: + return "/partner/reviews" + if roles.intersection({"Manager", "Branch Manager"}): + return "/manager/work" + return "/employee/work" + + +@router.get("/engagements/{engagement_id}") +def unified_engagement_detail(request: Request, engagement_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + detail = load_unified_engagement_detail(db, request=request, user=user, engagement_id=engagement_id) + if not detail: + return RedirectResponse(url=_fallback_for_user(db, user), status_code=303) + return templates.TemplateResponse( + "modules/work_detail/templates/work_detail/engagement_detail.html", + _base_ctx(request, db, user, title="Work Details", **detail), + ) + finally: + db.close() + + +@router.post("/tasks/{task_id}/status") +async def unified_task_status_update(request: Request, task_id: int, csrf_token: str = Form(...), status: str = Form(...), priority: str = Form("")): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + task, access = get_task_for_action(db, user=user, task_id=task_id) + if not task or not access: + return RedirectResponse(url=_fallback_for_user(db, user), status_code=303) + if not access.can_update_tasks: + return RedirectResponse(url=f"/work/engagements/{task.subscription_id}?error=not_allowed", status_code=303) + save_task_status(db, task=task, status=status, priority=priority, user_id=user.id) + db.commit() + return RedirectResponse(url=f"/work/engagements/{task.subscription_id}?updated=1", status_code=303) + except Exception: + db.rollback() + raise + finally: + db.close() + + +@router.post("/tasks/{task_id}/comment") +async def unified_task_comment_add( + request: Request, + task_id: int, + csrf_token: str = Form(...), + comment_type: str = Form("internal_note"), + visibility: str = Form("internal"), + message: str = Form(""), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + task, access = get_task_for_action(db, user=user, task_id=task_id) + if not task or not access: + return RedirectResponse(url=_fallback_for_user(db, user), status_code=303) + if not access.can_comment: + return RedirectResponse(url=f"/work/engagements/{task.subscription_id}?error=comment_not_allowed", status_code=303) + ok = save_task_comment(db, task=task, access=access, comment_type=comment_type, visibility=visibility, message=message, user_id=user.id) + if ok: + db.commit() + return RedirectResponse(url=f"/work/engagements/{task.subscription_id}?comment=sent", status_code=303) + db.rollback() + return RedirectResponse(url=f"/work/engagements/{task.subscription_id}?error=empty_comment", status_code=303) + except Exception: + db.rollback() + raise + finally: + db.close() diff --git a/app/ui/__init__.py b/app/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/ui/app.py b/app/ui/app.py new file mode 100644 index 0000000..cbe637f --- /dev/null +++ b/app/ui/app.py @@ -0,0 +1,55 @@ +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles + +from app.modules.clients.ui import router as clients_ui_router, portal_router as client_portal_router +from app.modules.consultants.ui import router as consultants_ui_router, portal_router as consultant_portal_router +from app.modules.employees.ui import router as employees_ui_router, portal_router as employee_portal_router +from app.modules.managers.ui import router as managers_ui_router +from app.modules.partners.ui import router as partners_ui_router +from app.modules.core.audit.ui import router as audit_ui_router +from app.modules.core.iam.ui import router as iam_ui_router +from app.modules.core.rbac.ui import router as rbac_ui_router +from app.modules.services.ui import router as services_ui_router +from app.modules.services.engagements_ui import router as engagements_ui_router +from app.modules.services.work_tracker_ui import router as work_tracker_ui_router +from app.modules.billing.ui import router as billing_ui_router +from app.modules.platform_billing.ui import router as platform_billing_ui_router +from app.modules.marketplace.ui import router as marketplace_ui_router, public_router as marketplace_public_router +from app.modules.documents.ui import router as documents_ui_router +from app.modules.alerts.ui import router as alerts_ui_router +from app.modules.work_detail.ui import router as work_detail_ui_router +from app.modules.system_settings.ui import router as system_settings_router +from app.modules.email_integration.ui import router as email_integration_router +from app.modules.domain_management.ui import router as domain_management_router +from app.modules.notice_cases.ui import router as notice_cases_router +from app.ui.routes.auth import router as auth_router + + +def mount_ui(app: FastAPI) -> None: + app.mount("/static", StaticFiles(directory="app/ui/static"), name="static") + app.include_router(marketplace_public_router) + app.include_router(auth_router) + app.include_router(system_settings_router) + app.include_router(email_integration_router) + app.include_router(domain_management_router) + app.include_router(iam_ui_router) + app.include_router(rbac_ui_router) + app.include_router(audit_ui_router) + app.include_router(services_ui_router) + app.include_router(work_tracker_ui_router) + app.include_router(billing_ui_router) + app.include_router(platform_billing_ui_router) + app.include_router(marketplace_ui_router) + app.include_router(documents_ui_router) + app.include_router(alerts_ui_router) + app.include_router(notice_cases_router) + app.include_router(work_detail_ui_router) + app.include_router(clients_ui_router) + app.include_router(employees_ui_router) + app.include_router(managers_ui_router) + app.include_router(partners_ui_router) + app.include_router(employee_portal_router) + app.include_router(consultants_ui_router) + app.include_router(engagements_ui_router) + app.include_router(client_portal_router) + app.include_router(consultant_portal_router) diff --git a/app/ui/routes/__init__.py b/app/ui/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/ui/routes/auth.py b/app/ui/routes/auth.py new file mode 100644 index 0000000..6187759 --- /dev/null +++ b/app/ui/routes/auth.py @@ -0,0 +1,837 @@ +from __future__ import annotations + +from datetime import datetime, timezone, timedelta + +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.otp import start_otp, verify_otp +from app.core.security.passwords import verify_password, hash_password +from app.core.security.session_auth import ( + SESSION_LOGIN_AT_KEY, + SESSION_USER_ID_KEY, + get_current_user, +) +from app.core.templating import templates +from app.core.settings import get_settings +from app.modules.core.iam.invite_service import accept_invite, reset_password_with_token +from app.modules.core.iam.models import LoginAttempt, User +from app.modules.core.rbac.models import Permission, Role, RolePermission, UserRole +from app.modules.core.tenancy.models import Branch, FinancialYear, Tenant +from app.modules.core.tenancy.settings_models import BranchSettings +from app.modules.email_integration.services import send_auth_otp_email, send_password_changed_email + +router = APIRouter() + + +def _dev_otp_print_enabled() -> bool: + settings = get_settings() + return bool(getattr(settings, "DEV_AUTH_OTP_PRINT", False)) and (settings.ENV or "").lower() in {"dev", "local", "development"} + + +def _log_dev_otp(label: str, email: str, code: str) -> None: + if _dev_otp_print_enabled(): + print(f"[DEV OTP] {label} user={email} code={code}") + + +def _client_ip(request: Request) -> str: + return request.client.host if request.client else "unknown" + + +def _attempt_key(email: str, ip: str) -> str: + return f"{email.lower().strip()}|{ip}" + + +def _get_branch_security_policy(db, user: User) -> BranchSettings | None: + if not getattr(user, "branch_id", None): + return None + return db.execute( + select(BranchSettings).where(BranchSettings.branch_id == user.branch_id) + ).scalar_one_or_none() + + +def _user_roles(db, user_id: int) -> list[str]: + q = ( + select(Role.name) + .join(UserRole, UserRole.role_id == Role.id) + .where(UserRole.user_id == user_id) + ) + return [r for (r,) in db.execute(q).all()] + + +def _user_permissions(db, user_id: int) -> set[str]: + q = ( + select(Permission.code) + .join(RolePermission, RolePermission.permission_id == Permission.id) + .join(UserRole, UserRole.role_id == RolePermission.role_id) + .where(UserRole.user_id == user_id, Permission.is_active.is_(True)) + ) + return set(db.execute(q).scalars().all()) + + +def _otp_required(bs: BranchSettings | None, roles: list[str]) -> bool: + if not bs: + return False + required = { + x.strip() for x in (bs.otp_required_roles_csv or "").split(",") if x.strip() + } + return any(r in required for r in roles) + +def _default_financial_year_code(db, tenant_id: int | None) -> str | None: + if not tenant_id: + return None + fy = db.execute( + select(FinancialYear).where( + FinancialYear.tenant_id == tenant_id, + FinancialYear.is_current.is_(True), + ) + ).scalar_one_or_none() + if fy: + return fy.year_code + fy = db.execute( + select(FinancialYear) + .where(FinancialYear.tenant_id == tenant_id) + .order_by(FinancialYear.start_date.desc(), FinancialYear.year_code.desc()) + ).scalars().first() + return fy.year_code if fy else None + + +def _tenant_code(db, tenant_id: int | None) -> str | None: + if not tenant_id: + return None + tenant = db.get(Tenant, int(tenant_id)) + return tenant.code if tenant else None + + +def _branch_code(db, branch_id: int | None) -> str | None: + if not branch_id: + return None + branch = db.get(Branch, int(branch_id)) + return branch.code if branch else None + + +def _post_login_redirect(must_change_password: bool, permissions: set[str], roles: list[str]) -> str: + if must_change_password: + return "/change-password-required" + + if "Client" in roles: + return "/client/dashboard" + + if "Consultant" in roles: + return "/consultant/dashboard" + + role_set = set(roles or []) + + # Phase 7K refinement: + # Dedicated Partner users should land directly on Partner Workspace. + # System/Firm Admin users are not forced here because they keep broader admin context. + if "Partner" in role_set and not role_set.intersection({"System Admin", "Firm Admin"}): + return "/partner/dashboard" + + # Phase 7J refinement: + # Dedicated manager users should land directly on Manager Workspace. + # Higher management roles are intentionally not redirected here because + # they may later get their own Firm Admin dashboards. + if role_set.intersection({"Manager", "Branch Manager"}) and not role_set.intersection({"System Admin", "Firm Admin", "Partner"}): + return "/manager/dashboard" + + # Phase 7I refinement: + # For internal firm users, make My Workspace the default landing page. + # This keeps Client/Consultant portal routing unchanged and only falls back + # to System Settings where the login has no employee/self-service access. + if ( + "employees.ess.view" in permissions + or "employees.work.view_self" in permissions + or "employees.attendance.view_self" in permissions + or "employees.leave.view_self" in permissions + or "employees.documents.view_self" in permissions + or "employees.payroll.view_self" in permissions + or {"System Admin", "Firm Admin", "Partner", "Staff"}.intersection(role_set) + ): + return "/employee/dashboard" + + if "system.settings.view" in permissions or "users.view" in permissions: + return "/system-settings" + + return "/employee/dashboard" + + +def _is_user_login_allowed(user: User) -> tuple[bool, str | None]: + if not user: + return False, "Invalid credentials" + + if not getattr(user, "is_active", True): + return False, "User account is inactive." + + if hasattr(user, "allow_login") and not bool(getattr(user, "allow_login", True)): + return False, "Login is disabled for this account." + + if hasattr(user, "is_locked") and bool(getattr(user, "is_locked", False)): + return False, "User account is locked." + + if hasattr(user, "deleted_at") and getattr(user, "deleted_at", None) is not None: + return False, "User account is deleted." + + return True, None + + +def _template_context( + request: Request, + db=None, + *, + title: str, + flash: str | None = None, + extra: dict | None = None, +) -> dict: + ctx = { + "request": request, + "csrf_token": get_or_create_csrf_token(request), + "flash": flash, + "title": title, + } + + if db is not None: + current_user = get_current_user(request, db=db) + if current_user: + ctx.update( + { + "current_user": current_user, + "current_user_roles": _user_roles(db, int(current_user.id)), + "current_user_permissions": list( + _user_permissions(db, int(current_user.id)) + ), + } + ) + + if extra: + ctx.update(extra) + + return ctx + + +def _render_login(request: Request, flash: str | None = None, status_code: int = 200): + return templates.TemplateResponse( + "modules/core/iam/templates/login.html", + _template_context(request, title="Login", flash=flash), + status_code=status_code, + ) + + +def _render_otp(request: Request, flash: str | None = None, status_code: int = 200): + db = CommonSessionLocal() + try: + return templates.TemplateResponse( + "modules/core/iam/templates/otp.html", + _template_context( + request, + db=db, + title="OTP Verification", + flash=flash, + ), + status_code=status_code, + ) + finally: + db.close() + + +def _render_change_password( + request: Request, flash: str | None = None, status_code: int = 200 +): + db = CommonSessionLocal() + try: + return templates.TemplateResponse( + "modules/core/iam/templates/change_password.html", + _template_context( + request, + db=db, + title="Change Password", + flash=flash, + ), + status_code=status_code, + ) + finally: + db.close() + + +def _render_change_password_otp( + request: Request, flash: str | None = None, status_code: int = 200 +): + db = CommonSessionLocal() + try: + return templates.TemplateResponse( + "modules/core/iam/templates/change_password_otp.html", + _template_context( + request, + db=db, + title="Confirm Password Change", + flash=flash, + ), + status_code=status_code, + ) + finally: + db.close() + + +def _render_forgot_password( + request: Request, flash: str | None = None, status_code: int = 200 +): + return templates.TemplateResponse( + "modules/core/iam/templates/forgot_password.html", + _template_context( + request, + title="Forgot Password", + flash=flash, + ), + status_code=status_code, + ) + + +def _render_reset_password( + request: Request, flash: str | None = None, status_code: int = 200 +): + return templates.TemplateResponse( + "modules/core/iam/templates/reset_password.html", + _template_context( + request, + title="Reset Password", + flash=flash, + ), + status_code=status_code, + ) + + +def _render_invite_accept( + request: Request, token: str, flash: str | None = None, status_code: int = 200 +): + return templates.TemplateResponse( + "modules/core/iam/templates/invite_accept.html", + _template_context( + request, + title="Accept Invite", + flash=flash, + extra={"token": token}, + ), + status_code=status_code, + ) + + +@router.get("/invite/accept") +def invite_accept_page(request: Request, token: str = ""): + if not token.strip(): + return RedirectResponse(url="/login", status_code=303) + return _render_invite_accept(request, token=token.strip()) + + +@router.post("/invite/accept") +def invite_accept_submit( + request: Request, + token: str = Form(...), + password: str = Form(...), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + token_clean = token.strip() + if not token_clean: + return RedirectResponse(url="/login", status_code=303) + db = CommonSessionLocal() + try: + try: + user = accept_invite(db, token_clean, password.strip()) + except ValueError as exc: + return _render_invite_accept(request, token=token_clean, flash=str(exc), status_code=400) + if not user: + return _render_invite_accept(request, token=token_clean, flash="Invalid or expired invite link.", status_code=400) + return RedirectResponse(url="/login", status_code=303) + finally: + db.close() + + +@router.get("/login") +def login_page(request: Request): + return _render_login(request) + + +@router.post("/login") +def login_submit( + request: Request, + email: str = Form(...), + password: str = Form(...), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + + email_clean = email.strip().lower() + ip = _client_ip(request) + key = _attempt_key(email_clean, ip) + + db = CommonSessionLocal() + try: + la = db.execute( + select(LoginAttempt).where(LoginAttempt.key == key) + ).scalar_one_or_none() + now = datetime.now(timezone.utc) + + if la and la.locked_until_utc and la.locked_until_utc.replace( + tzinfo=timezone.utc + ) > now: + return _render_login( + request, + flash=f"Account temporarily locked. Try again after {la.locked_until_utc}.", + status_code=429, + ) + + user = db.execute( + select(User).where(User.email == email_clean) + ).scalar_one_or_none() + + can_login, blocked_reason = _is_user_login_allowed(user) + password_ok = bool(user and verify_password(password, user.password_hash)) + + if not user or not can_login or not password_ok: + lock_attempts = 5 + lock_minutes = 15 + + if user: + bs = _get_branch_security_policy(db, user) + if bs: + lock_attempts = bs.lockout_attempts + lock_minutes = bs.lockout_minutes + + if not la: + la = LoginAttempt(key=key, attempts=0, updated_at_utc=now) + db.add(la) + + la.attempts = int(la.attempts or 0) + 1 + la.updated_at_utc = now + + if la.attempts >= lock_attempts: + la.locked_until_utc = now + timedelta(minutes=lock_minutes) + la.attempts = 0 + + db.commit() + + flash = blocked_reason or "Invalid credentials" + return _render_login(request, flash=flash, status_code=400) + + if la: + la.attempts = 0 + la.locked_until_utc = None + la.updated_at_utc = now + db.commit() + + user_id = int(user.id) + user_email = str(user.email) + tenant_id = getattr(user, "tenant_id", None) + branch_id = getattr(user, "branch_id", None) + must_change_password = bool(getattr(user, "must_change_password", False)) + + roles = _user_roles(db, user_id) + permissions = _user_permissions(db, user_id) + bs = _get_branch_security_policy(db, user) + + request.session[SESSION_USER_ID_KEY] = user_id + request.session[SESSION_LOGIN_AT_KEY] = now.isoformat() + request.session["user_email"] = user_email + tenant_code = _tenant_code(db, tenant_id) + branch_code = _branch_code(db, branch_id) + + request.session["tenant_id"] = tenant_id + request.session["branch_id"] = branch_id + request.session["active_tenant_id"] = tenant_id + request.session["active_branch_id"] = branch_id + if tenant_code: + request.session["tenant_code"] = tenant_code + request.session["active_tenant_code"] = tenant_code + else: + request.session.pop("tenant_code", None) + request.session.pop("active_tenant_code", None) + if branch_code: + request.session["branch_code"] = branch_code + request.session["active_branch_code"] = branch_code + else: + request.session.pop("branch_code", None) + request.session.pop("active_branch_code", None) + + active_financial_year = _default_financial_year_code(db, tenant_id) + if active_financial_year: + request.session["active_financial_year"] = active_financial_year + request.session["must_change_password"] = must_change_password + request.session["post_login_redirect"] = _post_login_redirect( + must_change_password, permissions, roles + ) + + if _otp_required(bs, roles): + code = start_otp(request) + try: + send_auth_otp_email(db, user=user, otp_code=code, purpose="login") + db.commit() + except Exception as exc: + print(f"[EMAIL OTP ERROR] user={user_email} error={exc}") + _log_dev_otp("login", user_email, code) + request.session["otp_verified"] = False + return RedirectResponse(url="/otp", status_code=303) + + request.session["otp_verified"] = True + return RedirectResponse( + url=request.session.get("post_login_redirect", "/system-settings"), + status_code=303, + ) + + finally: + db.close() + + +@router.get("/otp") +def otp_page(request: Request): + if not request.session.get(SESSION_USER_ID_KEY): + return RedirectResponse(url="/login", status_code=303) + return _render_otp(request) + + +@router.post("/otp") +def otp_submit( + request: Request, + otp: str = Form(...), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + + if not request.session.get(SESSION_USER_ID_KEY): + return RedirectResponse(url="/login", status_code=303) + + if verify_otp(request, otp): + request.session["otp_verified"] = True + return RedirectResponse( + url=request.session.get("post_login_redirect", "/system-settings"), + status_code=303, + ) + + return _render_otp( + request, + flash="Invalid OTP. Please check the OTP sent to your registered email.", + status_code=400, + ) + + +@router.get("/change-password") +def change_password_page(request: Request): + if not request.session.get(SESSION_USER_ID_KEY): + return RedirectResponse(url="/login", status_code=303) + return _render_change_password(request) + + +@router.post("/change-password") +def change_password_submit( + request: Request, + current_password: str = Form(...), + new_password: str = Form(...), + confirm_password: str = Form(...), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + + user_id = request.session.get(SESSION_USER_ID_KEY) + if not user_id: + return RedirectResponse(url="/login", status_code=303) + + if new_password != confirm_password: + return _render_change_password( + request, + flash="New password and confirm password do not match.", + status_code=400, + ) + + if len(new_password.strip()) < 8: + return _render_change_password( + request, + flash="New password must be at least 8 characters.", + status_code=400, + ) + + db = CommonSessionLocal() + try: + user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none() + if not user: + request.session.clear() + return RedirectResponse(url="/login", status_code=303) + + if not verify_password(current_password, user.password_hash): + return _render_change_password( + request, + flash="Current password is incorrect.", + status_code=400, + ) + + request.session["pending_password_change_hash"] = hash_password( + new_password.strip() + ) + request.session["pending_password_change_user_id"] = int(user.id) + + code = start_otp(request) + try: + send_auth_otp_email(db, user=user, otp_code=code, purpose="password_change") + db.commit() + except Exception as exc: + print(f"[EMAIL OTP ERROR] password-change user={user.email} error={exc}") + _log_dev_otp("password-change", str(user.email), code) + + return RedirectResponse(url="/change-password/otp", status_code=303) + finally: + db.close() + + +@router.get("/change-password/otp") +def change_password_otp_page(request: Request): + if not request.session.get(SESSION_USER_ID_KEY): + return RedirectResponse(url="/login", status_code=303) + + if not request.session.get("pending_password_change_hash"): + return RedirectResponse(url="/change-password", status_code=303) + + return _render_change_password_otp(request) + + +@router.post("/change-password/otp") +def change_password_otp_submit( + request: Request, + otp: str = Form(...), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + + user_id = request.session.get(SESSION_USER_ID_KEY) + pending_user_id = request.session.get("pending_password_change_user_id") + pending_hash = request.session.get("pending_password_change_hash") + + if not user_id: + return RedirectResponse(url="/login", status_code=303) + + if not pending_hash or not pending_user_id or int(user_id) != int(pending_user_id): + return RedirectResponse(url="/change-password", status_code=303) + + if not verify_otp(request, otp): + return _render_change_password_otp( + request, + flash="Invalid OTP. Please check the OTP sent to your registered email.", + status_code=400, + ) + + db = CommonSessionLocal() + try: + user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none() + if not user: + request.session.clear() + return RedirectResponse(url="/login", status_code=303) + + user.password_hash = pending_hash + user.must_change_password = False + user.password_changed_at_utc = datetime.now(timezone.utc) + try: + send_password_changed_email(db, user=user) + except Exception as exc: + print(f"[EMAIL PASSWORD CHANGED ERROR] user={user.email} error={exc}") + db.commit() + + roles = _user_roles(db, int(user.id)) + permissions = _user_permissions(db, int(user.id)) + + request.session.pop("pending_password_change_hash", None) + request.session.pop("pending_password_change_user_id", None) + request.session["must_change_password"] = False + request.session["post_login_redirect"] = _post_login_redirect(False, permissions, roles) + + return RedirectResponse(url=request.session.get("post_login_redirect", "/system-settings"), status_code=303) + finally: + db.close() + + +@router.get("/forgot-password") +def forgot_password_page(request: Request): + return _render_forgot_password(request) + + +@router.post("/forgot-password") +def forgot_password_submit( + request: Request, + email: str = Form(...), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + + email_clean = email.strip().lower() + + db = CommonSessionLocal() + try: + user = db.execute( + select(User).where(User.email == email_clean) + ).scalar_one_or_none() + + request.session.pop("password_reset_user_id", None) + request.session.pop("password_reset_email", None) + + if not user: + return _render_forgot_password( + request, + flash="If the login ID exists, password reset instructions have been sent to the registered email.", + status_code=200, + ) + + can_login, _ = _is_user_login_allowed(user) + if not can_login: + return _render_forgot_password( + request, + flash="If the login ID exists, password reset instructions have been sent to the registered email.", + status_code=200, + ) + + request.session["password_reset_user_id"] = int(user.id) + request.session["password_reset_email"] = str(user.email) + + code = start_otp(request) + try: + send_auth_otp_email(db, user=user, otp_code=code, purpose="password_reset") + db.commit() + except Exception as exc: + print(f"[EMAIL OTP ERROR] forgot-password user={user.email} error={exc}") + _log_dev_otp("forgot-password", str(user.email), code) + + return RedirectResponse(url="/reset-password", status_code=303) + finally: + db.close() + + +@router.get("/reset-password") +def reset_password_page(request: Request): + if not request.session.get("password_reset_user_id"): + return RedirectResponse(url="/forgot-password", status_code=303) + return _render_reset_password(request) + + +@router.post("/reset-password") +def reset_password_submit( + request: Request, + otp: str = Form(...), + new_password: str = Form(...), + confirm_password: str = Form(...), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + + user_id = request.session.get("password_reset_user_id") + if not user_id: + return RedirectResponse(url="/forgot-password", status_code=303) + + if not verify_otp(request, otp): + return _render_reset_password( + request, + flash="Invalid OTP. Please check the OTP sent to your registered email.", + status_code=400, + ) + + if new_password != confirm_password: + return _render_reset_password( + request, + flash="New password and confirm password do not match.", + status_code=400, + ) + + if len(new_password.strip()) < 8: + return _render_reset_password( + request, + flash="New password must be at least 8 characters.", + status_code=400, + ) + + db = CommonSessionLocal() + try: + user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none() + if not user: + request.session.pop("password_reset_user_id", None) + request.session.pop("password_reset_email", None) + return RedirectResponse(url="/forgot-password", status_code=303) + + user.password_hash = hash_password(new_password.strip()) + user.must_change_password = False + user.password_changed_at_utc = datetime.now(timezone.utc) + try: + send_password_changed_email(db, user=user) + except Exception as exc: + print(f"[EMAIL PASSWORD CHANGED ERROR] user={user.email} error={exc}") + db.commit() + + request.session.pop("password_reset_user_id", None) + request.session.pop("password_reset_email", None) + + return RedirectResponse(url="/login", status_code=303) + finally: + db.close() + + +@router.get("/password-reset/accept") +def password_reset_token_page(request: Request, token: str = ""): + return templates.TemplateResponse( + "modules/core/iam/templates/reset_password_token.html", + _template_context(request, title="Reset Password", extra={"token": token.strip()}), + ) + + +@router.post("/password-reset/accept") +def password_reset_token_submit( + request: Request, + token: str = Form(...), + new_password: str = Form(...), + confirm_password: str = Form(...), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + token_clean = token.strip() + if new_password != confirm_password: + return templates.TemplateResponse( + "modules/core/iam/templates/reset_password_token.html", + _template_context(request, title="Reset Password", flash="New password and confirm password do not match.", extra={"token": token_clean}), + status_code=400, + ) + if len(new_password.strip()) < get_settings().PASSWORD_MIN_LENGTH: + return templates.TemplateResponse( + "modules/core/iam/templates/reset_password_token.html", + _template_context(request, title="Reset Password", flash=f"New password must be at least {get_settings().PASSWORD_MIN_LENGTH} characters.", extra={"token": token_clean}), + status_code=400, + ) + db = CommonSessionLocal() + try: + try: + user = reset_password_with_token(db, token_clean, new_password.strip()) + except ValueError as exc: + return templates.TemplateResponse( + "modules/core/iam/templates/reset_password_token.html", + _template_context(request, title="Reset Password", flash=str(exc), extra={"token": token_clean}), + status_code=400, + ) + if not user: + return templates.TemplateResponse( + "modules/core/iam/templates/reset_password_token.html", + _template_context(request, title="Reset Password", flash="Invalid or expired password reset link.", extra={"token": token_clean}), + status_code=400, + ) + try: + send_password_changed_email(db, user=user) + except Exception as exc: + print(f"[EMAIL PASSWORD CHANGED ERROR] user={getattr(user, 'email', '')} error={exc}") + db.commit() + return RedirectResponse(url="/login", status_code=303) + finally: + db.close() + + +@router.get("/change-password-required") +def change_password_required(request: Request): + return RedirectResponse(url="/change-password", status_code=303) + + +@router.get("/logout") +def logout(request: Request): + request.session.clear() + return RedirectResponse(url="/login", status_code=303) diff --git a/app/ui/static/css/theme_tokens.css b/app/ui/static/css/theme_tokens.css new file mode 100644 index 0000000..81357ac --- /dev/null +++ b/app/ui/static/css/theme_tokens.css @@ -0,0 +1,357 @@ +/* Audit Firm ERP Phase 7Q.1 — Standard Theme Tokens + Purpose: one consistent colour, card, button, badge and form language across dashboards. + This file is plain CSS and works with the current Tailwind CDN setup. */ + +:root { + /* Brand / CA professional blue */ + --af-color-brand-50: #eff6ff; + --af-color-brand-100: #dbeafe; + --af-color-brand-200: #bfdbfe; + --af-color-brand-300: #93c5fd; + --af-color-brand-400: #60a5fa; + --af-color-brand-500: #2563eb; + --af-color-brand-600: #1d4ed8; + --af-color-brand-700: #1e40af; + --af-color-brand-800: #1e3a8a; + --af-color-brand-900: #172554; + + /* Neutral system */ + --af-color-bg: #f8fafc; + --af-color-surface: #ffffff; + --af-color-surface-muted: #f1f5f9; + --af-color-border: #e2e8f0; + --af-color-text: #0f172a; + --af-color-muted: #64748b; + + /* Status system */ + --af-color-success-50: #ecfdf5; + --af-color-success-600: #059669; + --af-color-success-700: #047857; + --af-color-warning-50: #fffbeb; + --af-color-warning-600: #d97706; + --af-color-warning-700: #b45309; + --af-color-danger-50: #fef2f2; + --af-color-danger-600: #dc2626; + --af-color-danger-700: #b91c1c; + --af-color-info-50: #eff6ff; + --af-color-info-600: #2563eb; + --af-color-info-700: #1d4ed8; + + /* Shape and elevation */ + --af-radius-card: 1rem; + --af-radius-control: 0.75rem; + --af-shadow-soft: 0 10px 30px rgba(15, 23, 42, 0.08); + --af-shadow-card: 0 18px 45px rgba(15, 23, 42, 0.10); +} + +body { + background: var(--af-color-bg); + color: var(--af-color-text); +} + +/* Reusable UI classes for upcoming dashboard refinements */ +.af-page-shell { + max-width: 90rem; + margin-left: auto; + margin-right: auto; +} + +.af-page-title { + font-size: 1.5rem; + line-height: 2rem; + font-weight: 700; + letter-spacing: -0.025em; + color: var(--af-color-text); +} + +.af-page-subtitle { + margin-top: 0.25rem; + font-size: 0.875rem; + color: var(--af-color-muted); +} + +.af-card { + border: 1px solid var(--af-color-border); + background: var(--af-color-surface); + border-radius: var(--af-radius-card); + box-shadow: var(--af-shadow-soft); +} + +.af-card-muted { + border: 1px solid var(--af-color-border); + background: linear-gradient(180deg, #ffffff 0%, var(--af-color-surface-muted) 100%); + border-radius: var(--af-radius-card); +} + +.af-metric-card { + border: 1px solid var(--af-color-border); + background: var(--af-color-surface); + border-radius: var(--af-radius-card); + box-shadow: var(--af-shadow-soft); + padding: 1rem; +} + +.af-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + border-radius: var(--af-radius-control); + padding: 0.5rem 0.875rem; + font-size: 0.875rem; + font-weight: 600; + transition: background-color 150ms ease, border-color 150ms ease, color 150ms ease, box-shadow 150ms ease; +} + +.af-btn:focus-visible { + outline: none; + box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.16); +} + +.af-btn-primary { + background: var(--af-color-brand-600); + color: #ffffff; +} + +.af-btn-primary:hover { + background: var(--af-color-brand-700); +} + +.af-btn-secondary { + border: 1px solid var(--af-color-border); + background: #ffffff; + color: #334155; +} + +.af-btn-secondary:hover { + background: #f8fafc; + color: #0f172a; +} + +.af-btn-danger { + background: var(--af-color-danger-600); + color: #ffffff; +} + +.af-btn-danger:hover { + background: var(--af-color-danger-700); +} + +.af-input, +.af-select, +.af-textarea { + width: 100%; + border: 1px solid #cbd5e1; + background: #ffffff; + color: var(--af-color-text); + border-radius: var(--af-radius-control); + padding: 0.5rem 0.75rem; + font-size: 0.875rem; +} + +.af-input:focus, +.af-select:focus, +.af-textarea:focus { + outline: none; + border-color: var(--af-color-brand-500); + box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.12); +} + +.af-badge { + display: inline-flex; + align-items: center; + border-radius: 9999px; + padding: 0.25rem 0.625rem; + font-size: 0.75rem; + font-weight: 700; + line-height: 1rem; +} + +.af-badge-neutral { background: #f1f5f9; color: #334155; } +.af-badge-brand { background: var(--af-color-brand-50); color: var(--af-color-brand-700); } +.af-badge-success { background: var(--af-color-success-50); color: var(--af-color-success-700); } +.af-badge-warning { background: var(--af-color-warning-50); color: var(--af-color-warning-700); } +.af-badge-danger { background: var(--af-color-danger-50); color: var(--af-color-danger-700); } +.af-badge-info { background: var(--af-color-info-50); color: var(--af-color-info-700); } + +.af-section-heading { + font-size: 0.75rem; + line-height: 1rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #64748b; +} + +.af-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; +} + +.af-table th { + background: #f8fafc; + color: #475569; + font-size: 0.75rem; + font-weight: 700; + text-align: left; + padding: 0.75rem; + border-bottom: 1px solid var(--af-color-border); +} + +.af-table td { + padding: 0.75rem; + border-bottom: 1px solid var(--af-color-border); + font-size: 0.875rem; +} + +.af-kanban-column { + border: 1px solid var(--af-color-border); + background: #f8fafc; + border-radius: var(--af-radius-card); + padding: 0.75rem; +} + +.af-kanban-card { + border: 1px solid var(--af-color-border); + background: #ffffff; + border-radius: 0.875rem; + padding: 0.75rem; + box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06); +} + +/* Phase 7Q common dashboard alignment refinement + Low-specificity padding/layout helpers for all dashboards. + Tailwind padding classes such as p-4/p-6 will still override these defaults. */ +:where(.af-card) { + padding: 1.25rem; + overflow: hidden; +} + +:where(.af-card > .flex:first-child), +:where(.af-card > .grid:first-child) { + min-width: 0; +} + +:where(.af-card h1, .af-card h2, .af-card h3) { + line-height: 1.35; +} + +:where(.af-card .af-btn) { + white-space: nowrap; +} + +:where(.af-metric-card) { + min-height: 7rem; + overflow: hidden; +} + +:where(.af-dashboard-grid) { + display: grid; + gap: 1.5rem; +} + +@media (min-width: 1280px) { + :where(.af-dashboard-grid-2) { + grid-template-columns: minmax(0, 1fr) minmax(20rem, 24rem); + align-items: start; + } +} + +:where(.af-panel-header) { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; + padding-bottom: 1rem; + border-bottom: 1px solid #f1f5f9; +} + +@media (max-width: 640px) { + :where(.af-panel-header) { + flex-direction: column; + } + :where(.af-card .af-btn) { + width: 100%; + } +} + +/* Phase 7Q dashboard alignment strong fix + Purpose: ensure all dashboard cards have safe internal spacing even when templates + use only class="af-card" without Tailwind p-* classes. */ +.af-card { + padding: 1.25rem !important; + overflow: hidden; + box-sizing: border-box; +} + +.af-card > .flex:first-child, +.af-card > .grid:first-child { + min-width: 0; +} + +.af-card > .flex:first-child { + gap: 0.875rem; + flex-wrap: wrap; +} + +.af-card > .flex:first-child > div, +.af-card > .flex:first-child > section, +.af-card > .flex:first-child > article { + min-width: 0; +} + +.af-card > .flex:first-child .af-btn, +.af-card > .flex:first-child a[class*="rounded"], +.af-card > .flex:first-child button[class*="rounded"] { + flex-shrink: 0; +} + +.af-card h1, +.af-card h2, +.af-card h3, +.af-card h4 { + margin-top: 0; + overflow-wrap: anywhere; +} + +.af-card dl, +.af-card p, +.af-card table, +.af-card form { + min-width: 0; +} + +.af-card .af-btn, +.af-card a.af-btn, +.af-card button.af-btn { + max-width: 100%; + white-space: nowrap; +} + +.af-dashboard-two-col { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 1.5rem; +} + +@media (min-width: 1280px) { + .af-dashboard-two-col { + grid-template-columns: minmax(0, 1fr) minmax(22rem, 24rem); + align-items: start; + } +} + +@media (max-width: 640px) { + .af-card { + padding: 1rem !important; + } + .af-card > .flex:first-child { + align-items: stretch; + } + .af-card > .flex:first-child .af-btn, + .af-card > .flex:first-child a[class*="rounded"], + .af-card > .flex:first-child button[class*="rounded"] { + width: 100%; + } +} diff --git a/app/ui/templates/base/layout.html b/app/ui/templates/base/layout.html new file mode 100644 index 0000000..8d6ffe7 --- /dev/null +++ b/app/ui/templates/base/layout.html @@ -0,0 +1,617 @@ + + + + + + {% set __title_user = current_user if current_user is defined else None %} + {% set __firm_branding = get_current_firm_branding(request, __title_user) %} + + {% if __firm_branding.favicon_url %}{% endif %} + {% set __title_auth = __title_user and request.session.get("otp_verified", False) %} + {% if __title_auth %} + {% set __title_firm = get_current_tenant_name(request, __title_user) %} + {% set __title_branch = get_current_branch_name(request, __title_user) %} + {{ title or "Workspace" }} | {{ __title_firm }}{% if __title_branch and __title_branch != "-" %} - {{ __title_branch }}{% endif %} + {% else %} + {{ title or "Welcome" }} | {{ __firm_branding.firm_name or "Audit Firm ERP" }} + {% endif %} + + + + + + + {% set otp_ok = request.session.get("otp_verified", False) %} + {% set full_auth = current_user and otp_ok %} + {% set ui_perms = current_user_permissions if full_auth else [] %} + {% set ui_roles = current_user_roles if full_auth else [] %} + {% set active_tenant_id = get_active_tenant_id(request, current_user) if full_auth else None %} + {% set active_branch_id = get_active_branch_id(request, current_user) if full_auth else None %} + {% set active_financial_year = get_active_financial_year(request, current_user) if full_auth else None %} + {% set active_assessment_year = get_active_assessment_year(request, current_user) if full_auth else None %} + {% set unread_alert_count = get_unread_alert_count(request, current_user) if full_auth else 0 %} + {% set current_path = request.url.path %} + {% set document_menu_roles = ["System Admin", "Firm Admin", "Partner"] %} + {% set can_view_documents_menu = full_auth and can_view_documents(current_user, ui_perms, ui_roles) and (ui_roles|select("in", document_menu_roles)|list|length > 0) %} + {% set management_menu_roles = ["System Admin", "Firm Admin", "Partner", "Manager", "Branch Manager"] %} + {% set can_view_management_menus = full_auth and (ui_roles|select("in", management_menu_roles)|list|length > 0) %} + {% set firm_branding = get_current_firm_branding(request, current_user) if full_auth else __firm_branding %} + {% set current_user_photo_url = get_user_profile_photo_url(current_user) if full_auth else None %} + {% set current_user_initials = get_user_initials(current_user) if full_auth else "U" %} + {% set current_firm_name = firm_branding.firm_name if firm_branding else "Audit Firm ERP" %} + {% set current_branch_name = firm_branding.branch_name if firm_branding else "" %} + {% set domain_context = get_domain_context(request) %} + {% set is_system_admin_user = full_auth and ("System Admin" in ui_roles) %} + {% set can_manage_local_storage_agent = full_auth and can_view_documents(current_user, ui_perms, ui_roles) and can_upload_documents(current_user, ui_perms, ui_roles) and (ui_roles|select("in", ["Firm Admin", "Partner", "Branch Manager"])|list|length > 0) %} + +
+ + +
+
+
+
+
+

{{ title or "Module Workspace" }}

+
+
+ {% if full_auth %} +
+
+
{{ current_user.full_name or current_user.email }}
+
{{ current_user.email }}
+ {% if current_user.qualification or current_user.designation %} +
{{ current_user.qualification or '' }}{% if current_user.qualification and current_user.designation %} • {% endif %}{{ current_user.designation or '' }}
+ {% endif %} +
+ Your Firm: {{ current_firm_name }} + • Branch: {{ current_branch_name }}{% if active_financial_year %} • FY: {{ active_financial_year }}{% endif %} +
+
+ {% if "Consultant" in ui_roles %} + My Profile + {% elif "Client" in ui_roles %} + My Profile + {% else %} + My Profile + {% endif %} + Change Password + Logout +
+
+ {% if current_user_photo_url %} + Profile photo + {% else %} +
{{ current_user_initials }}
+ {% endif %} +
+ {% else %} + {% if domain_context.is_resolved %} +
{{ current_firm_name }}{% if current_branch_name %} • {{ current_branch_name }}{% endif %}
+ {% endif %} + Login + {% endif %} +
+
+ + {% if full_auth and (can_switch_service_tenant(current_user, ui_perms, ui_roles) or can_switch_service_branch(current_user, ui_perms, ui_roles) or can_switch_employee_tenant(current_user, ui_perms, ui_roles) or can_switch_employee_branch(current_user, ui_perms, ui_roles) or can_view_settings(current_user, ui_perms, ui_roles)) %} +
+ {% if can_switch_service_tenant(current_user, ui_perms, ui_roles) or can_switch_employee_tenant(current_user, ui_perms, ui_roles) %} + {% set context_tenants = get_context_tenants(request, current_user, ui_perms, ui_roles) %} +
+ + +
+ {% endif %} + + {% if can_switch_service_branch(current_user, ui_perms, ui_roles) or can_switch_employee_branch(current_user, ui_perms, ui_roles) %} + {% set context_branches = get_context_branches(request, current_user, ui_perms, ui_roles) %} +
+ + +
+ {% endif %} + + {% if can_view_settings(current_user, ui_perms, ui_roles) %} + {% set context_financial_years = get_context_financial_years(request, current_user, ui_perms, ui_roles) %} +
+ + +
+ {% endif %} + +
+ Active Scope: + {{ current_firm_name }} + • {{ current_branch_name if active_branch_id else "All Branches" }}{% if active_financial_year %} • FY {{ active_financial_year }}{% endif %} +
+
+ {% endif %} +
+
+ +
+ {% if flash %} +
+ {{ flash }} +
+ {% endif %} + {% block content %}{% endblock %} +
+
+
+{% if full_auth %} +
+ +{% endif %} + + + diff --git a/app/ui/templates/components/macros.html b/app/ui/templates/components/macros.html new file mode 100644 index 0000000..1352ffb --- /dev/null +++ b/app/ui/templates/components/macros.html @@ -0,0 +1,63 @@ +{% macro page_shell(title, subtitle='', actions='') -%} +
+
+

{{ title }}

+ {% if subtitle %}

{{ subtitle }}

{% endif %} +
+ {% if actions %}
{{ actions | safe }}
{% endif %} +
+{%- endmacro %} + +{% macro alert(message, tone='amber') -%} +
+ {{ message }} +
+{%- endmacro %} + +{% macro badge(text, tone='slate') -%} +{{ text }} +{%- endmacro %} + +{% macro search_bar(action, q='', per_page=10, extra='') -%} +
+
+ + + {% if extra %}{{ extra | safe }}{% endif %} +
+
+ +
+
+{%- endmacro %} + +{% macro empty_state(title, subtitle='') -%} +
+
{{ title }}
+ {% if subtitle %}
{{ subtitle }}
{% endif %} +
+{%- endmacro %} + +{% macro pagination(page_obj, base_url, query='') -%} +{% if page_obj and page_obj.pages > 1 %} +
+
Page {{ page_obj.page }} of {{ page_obj.pages }} • {{ page_obj.total }} records
+
+ {% if page_obj.has_prev %} + Previous + {% else %} + Previous + {% endif %} + {% if page_obj.has_next %} + Next + {% else %} + Next + {% endif %} +
+
+{% endif %} +{%- endmacro %} diff --git a/app/ui/templates/modules/auth/login.html b/app/ui/templates/modules/auth/login.html new file mode 100644 index 0000000..10e81f3 --- /dev/null +++ b/app/ui/templates/modules/auth/login.html @@ -0,0 +1,23 @@ +{% extends "base/layout.html" %} +{% block content %} +
+

Login

+

Use bootstrap admin (first run). Lockout + CSRF are enabled.

+ +
+ + + + + + + +
+
+{% endblock %} diff --git a/app/ui/templates/modules/auth/otp.html b/app/ui/templates/modules/auth/otp.html new file mode 100644 index 0000000..cf3ac2b --- /dev/null +++ b/app/ui/templates/modules/auth/otp.html @@ -0,0 +1,18 @@ +{% extends "base/layout.html" %} +{% block content %} +
+

OTP Verification

+

Dev mode: OTP is printed in console.

+ +
+ + + + + +
+
+{% endblock %} diff --git a/app/ui/templates/modules/system_settings/branch_edit.html b/app/ui/templates/modules/system_settings/branch_edit.html new file mode 100644 index 0000000..c18adb8 --- /dev/null +++ b/app/ui/templates/modules/system_settings/branch_edit.html @@ -0,0 +1,226 @@ +{% extends "base/layout.html" %} +{% block content %} +

Edit Branch

+ +
+ + +
+
+ + + +
+ +
+ + +
+ +
+
SMTP Credentials
+
+ + + + + +
+
+ +
+
Local Storage Root
+ +
+ +
+
Branch Identity (Compliance)
+
+ + + + + + + +
+ +
+ + + +
+
+ +
+
Working Days & Holidays
+
+ + + +
+
+ +
+
Email Policy
+
+ + + + + + +
+
+ +
+
Storage Policy
+
+ + + + + +
+
+ +
+
Security Policy
+
+ + + + +
+
+ +
+ + Back +
+
+
+{% endblock %} diff --git a/app/ui/templates/modules/system_settings/branches_list.html b/app/ui/templates/modules/system_settings/branches_list.html new file mode 100644 index 0000000..12ec726 --- /dev/null +++ b/app/ui/templates/modules/system_settings/branches_list.html @@ -0,0 +1,33 @@ +{% extends "base/layout.html" %} +{% block content %} +

Branches

+ +
+ + + + + + + + + + + + + {% for b in branches %} + + + + + + + + + {% endfor %} + +
Audit Firm IDAudit FirmCodeNameTimezoneActions
{{ b.id }}{{ b.tenant_id }}{{ b.code }}{{ b.name }}{{ b.timezone }} + Edit +
+
+{% endblock %} diff --git a/app/ui/templates/modules/system_settings/dashboard.html b/app/ui/templates/modules/system_settings/dashboard.html new file mode 100644 index 0000000..a2c9d92 --- /dev/null +++ b/app/ui/templates/modules/system_settings/dashboard.html @@ -0,0 +1,17 @@ +{% extends "base/layout.html" %} +{% block content %} +

System Settings

+

Super-admin tools (audit firms, branches, policies).

+ + +{% endblock %} diff --git a/app/ui/templates/modules/system_settings/tenant_create.html b/app/ui/templates/modules/system_settings/tenant_create.html new file mode 100644 index 0000000..5f8ba0d --- /dev/null +++ b/app/ui/templates/modules/system_settings/tenant_create.html @@ -0,0 +1,25 @@ +{% extends "base/layout.html" %} +{% block content %} +

Create Audit Firm

+ +
+ + +
+ + + + +
+ + Cancel +
+
+
+{% endblock %} diff --git a/app/ui/templates/modules/system_settings/tenants_list.html b/app/ui/templates/modules/system_settings/tenants_list.html new file mode 100644 index 0000000..d040740 --- /dev/null +++ b/app/ui/templates/modules/system_settings/tenants_list.html @@ -0,0 +1,30 @@ +{% extends "base/layout.html" %} +{% block content %} +
+

Audit Firms

+ Add Audit Firm +
+ +
+ + + + + + + + + + + {% for t in tenants %} + + + + + + + {% endfor %} + +
Audit Firm IDAudit Firm CodeAudit Firm NameActive
{{ t.id }}{{ t.code }}{{ t.name }}{{ "Yes" if t.is_active else "No" }}
+
+{% endblock %} diff --git a/documents/PHASE_v2.0.3.1_NOTES.md b/documents/PHASE_v2.0.3.1_NOTES.md new file mode 100644 index 0000000..06076c1 --- /dev/null +++ b/documents/PHASE_v2.0.3.1_NOTES.md @@ -0,0 +1,16 @@ + +Audit_Firm_v2.0.3.1 + +Phase: Scope Hardening + +Key additions: +- Scope guard utilities for tenant and branch validation +- Intended to be used by IAM, RBAC and System Settings modules +- Prevents cross-tenant and cross-branch operations + +Next planned phases: +v2.0.3.2 Permission Guards +v2.0.3.4 Audit Trail +v2.0.3.4 User Lifecycle Controls +v2.0.3.5 Invite / Password Reset Flows +v2.0.3.6 Service Layer Refactor + UI Improvements diff --git a/documents/PHASE_v2.0.3.2_NOTES.md b/documents/PHASE_v2.0.3.2_NOTES.md new file mode 100644 index 0000000..dfbfc90 --- /dev/null +++ b/documents/PHASE_v2.0.3.2_NOTES.md @@ -0,0 +1,9 @@ +# Audit_Firm_v2.0.3.2 + +Phase 2 adds permission guards on top of v2.0.3.1: +- central permission registry +- reusable permission guard for UI and API +- permission-aware sidebar/menu +- UI route protection for Users, RBAC, and System Settings +- FastAPI dependencies now raise HTTP 403 instead of generic PermissionError +- template helper functions for button and menu visibility diff --git a/documents/PHASE_v2.0.3.3_NOTES.md b/documents/PHASE_v2.0.3.3_NOTES.md new file mode 100644 index 0000000..81ad234 --- /dev/null +++ b/documents/PHASE_v2.0.3.3_NOTES.md @@ -0,0 +1,51 @@ +# Audit_Firm_v2.0.3.4 — Phase 3 (Audit Trail) + +This phase adds a reusable audit logging layer across the core admin flows. + +## Added +- `app/modules/core/audit/models.py` +- `app/modules/core/audit/service.py` +- `app/modules/core/audit/templates/logs.html` +- enhanced `app/modules/core/audit/ui.py` + +## Included capabilities +- audit log table: `audit_logs` +- automatic creation of audit table on startup if missing +- audit entries for: + - user create/update (UI + API) + - tenant create/update + - branch create/update + - role create + - permission create + - role-permission update + - login success/failure/lockout/logout + - OTP success/failure/OTP-required + - token success/failure/refresh/logout +- actor context captured: + - user id + - email + - tenant + - branch + - IP address + - user agent +- target context captured: + - target tenant + - target branch +- before/after snapshots for update actions +- permission-based Audit Logs menu and screen + +## Permission added +- `audit.view` + +## Default role mapping +- System Admin → audit.view +- Firm Admin → audit.view +- Partner → audit.view +- Branch Manager → audit.view + +## Notes +- current implementation uses `details_json` text storage for maximum SQLite/Postgres compatibility +- logs are shown with scope filtering: + - System Admin → all audit logs + - tenant scoped roles → same tenant + - branch scoped roles → same tenant + same branch diff --git a/documents/PHASE_v2.0.3.4_NOTES.md b/documents/PHASE_v2.0.3.4_NOTES.md new file mode 100644 index 0000000..481c083 --- /dev/null +++ b/documents/PHASE_v2.0.3.4_NOTES.md @@ -0,0 +1,10 @@ +# Audit_Firm_v2.0.3.4 — Phase 4: User Lifecycle Controls + +Included in this phase: +- user lifecycle fields: allow_login, is_locked, locked_at_utc, deleted_at +- lifecycle helpers for activate/deactivate, login enable/disable, lock/unlock, soft delete/restore +- UI lifecycle actions on the user management screen +- API lifecycle endpoints for the same actions +- audit logging for lifecycle actions +- startup guard to add missing lifecycle columns on existing databases +- auth/session checks updated to respect login-disabled, locked, and soft-deleted users diff --git a/documents/PHASE_v2.0.3.5_NOTES.md b/documents/PHASE_v2.0.3.5_NOTES.md new file mode 100644 index 0000000..767a4e4 --- /dev/null +++ b/documents/PHASE_v2.0.3.5_NOTES.md @@ -0,0 +1,6 @@ +Phase 5: Invite and password flows +- invite token model and service +- forgot/reset password flows +- accept invite flow +- must_change_password enforcement +- invite generation from user list and user create form diff --git a/documents/PHASE_v2.0.3.6_NOTES.md b/documents/PHASE_v2.0.3.6_NOTES.md new file mode 100644 index 0000000..bd0207d --- /dev/null +++ b/documents/PHASE_v2.0.3.6_NOTES.md @@ -0,0 +1,9 @@ +# Phase v2.0.3.6 - Service Layer Refactor + UI Cleanup + +Included in this phase: +- introduced service modules for IAM, RBAC, and tenancy listing/filtering/pagination +- reduced route-level query assembly in UI handlers +- standardized reusable Tailwind UI macros for alerts, badges, search bars, empty states, and pagination +- added search + pagination for users, roles, permissions, tenants, branches, and audit logs +- corrected the broken branches list screen and replaced it with a proper directory view +- updated version markers to Audit_Firm_v2.0.3.6 diff --git a/documents/README.md b/documents/README.md new file mode 100644 index 0000000..86e8c74 --- /dev/null +++ b/documents/README.md @@ -0,0 +1,102 @@ +# Audit Firm v2 — Full Baseline (Tailwind CDN + HTML + API + Security + System Settings) + +This baseline is designed to be a **stable v2 foundation** before adding other modules. + +## What’s included +- FastAPI app with **HTML + API** side-by-side +- Tailwind CSS via **CDN** +- Modular templates: `app/ui/templates/modules//...` +- System Settings module (super-admin UI): + - Tenants + - Branches + - Branch policies (identity, holidays, email policy, storage policy, security policy) +- Security (built-in): + - Password hashing (bcrypt via passlib) + - Session auth (browser UI) + CSRF protection for HTML forms + - Secure cookie options (configurable) + security headers + CSP (Tailwind CDN allowed) + - RBAC (roles/permissions) with default roles seeded: + - System Admin, Firm Admin, Partner, Branch Manager, Staff, Client, Consultant + - Login lockout policy (attempts + lockout minutes) enforced + - Session expiry policy enforced (minutes) + - OTP step (dev mode): OTP code is printed to console (placeholder for SMS/Email provider) + +- DB: + - Common DB via SQLAlchemy (SQLite now; Postgres later) + - Tables are created on startup using `metadata.create_all()` to keep install smooth. + (Next step: replace with Alembic common + year.) + +- Tools: + - Local Storage Agent generator (`tools/storage_agent/`) + +## Quick start (Windows) +```bat +python -m venv venv +venv\Scripts\activate +pip install -r requirements.txt +copy .env.example .env + +uvicorn app.main:app --reload +``` + +Open: +- http://127.0.0.1:8000/health +- http://127.0.0.1:8000/login +- http://127.0.0.1:8000/system-settings + +Bootstrap admin (first run): +- Uses `BOOTSTRAP_ADMIN_EMAIL` and `BOOTSTRAP_ADMIN_PASSWORD` from `.env` + +## OTP (dev placeholder) +If OTP is required for your role (see Branch Settings → Security Policy), +the app will show an OTP page after password login and print the OTP code in the console logs. + +## JWT (API authentication) +Endpoints: +- POST `/api/auth/token` +- POST `/api/auth/refresh` +- POST `/api/auth/logout` +- GET `/api/auth/me` (debug helper) + +Notes: +- Access token is JWT (HS256) signed using `SECRET_KEY` +- Refresh token is opaque and stored hashed (sha256) in DB with rotation + +## Template structure refactor +Templates are now organized as: +- `app/modules/system_settings/templates/...` +- `app/modules/core/iam/templates/...` +- `app/modules/core/audit/templates/...` +- shared base remains at `app/ui/templates/base/layout.html` + +The Jinja loader resolves from `app/`, so module templates can safely extend: +`ui/templates/base/layout.html` + +## Alembic added to baseline +This baseline now uses **Alembic for the Common DB from the beginning**. + +### First-time setup +1. Create virtual environment and install requirements +2. Copy `.env.example` to `.env` +3. Run: + `alembic upgrade head` +4. Start app: + `uvicorn app.main:app --reload` + +### Create a new migration +`alembic revision --autogenerate -m "message"` + +### Apply migrations +`alembic upgrade head` + +### Downgrade one step +`alembic downgrade -1` + +### Windows helper scripts +- `scripts\migrate_up.bat` +- `scripts\new_migration.bat "message"` + +### Important +- App startup no longer creates schema automatically. +- Schema must be migrated using Alembic first. +- This baseline adds **Common DB Alembic only**. +- `alembic_year/` for year databases can be added next. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4c751c9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +sqlalchemy==2.0.36 +pydantic==2.10.4 +pydantic-settings==2.7.0 +python-multipart==0.0.20 +jinja2==3.1.4 +passlib[bcrypt]==1.7.4 +bcrypt==4.0.1 +PyJWT==2.10.1 +alembic==1.14.0 +psycopg[binary]==3.2.3 diff --git a/scripts/migrate_up.bat b/scripts/migrate_up.bat new file mode 100644 index 0000000..b1d3de7 --- /dev/null +++ b/scripts/migrate_up.bat @@ -0,0 +1,9 @@ +@echo off +setlocal +alembic upgrade head +if %errorlevel% neq 0 ( + echo Migration failed. + exit /b %errorlevel% +) +echo Migration complete. +endlocal diff --git a/scripts/new_migration.bat b/scripts/new_migration.bat new file mode 100644 index 0000000..e04878b --- /dev/null +++ b/scripts/new_migration.bat @@ -0,0 +1,8 @@ +@echo off +setlocal +if "%~1"=="" ( + echo Usage: new_migration.bat "message" + exit /b 1 +) +alembic revision --autogenerate -m "%~1" +endlocal diff --git a/scripts/verify_client_associations.py b/scripts/verify_client_associations.py new file mode 100644 index 0000000..4b35874 --- /dev/null +++ b/scripts/verify_client_associations.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from sqlalchemy import select, func + +from app.core.db.common import CommonSessionLocal +from app.modules.clients.association_models import ClientAssociation +from app.modules.clients.models import Client + + +def main(): + db = CommonSessionLocal() + try: + total_clients = db.execute(select(func.count()).select_from(Client)).scalar_one() + total_assoc = db.execute(select(func.count()).select_from(ClientAssociation)).scalar_one() + missing = db.execute(select(Client.id, Client.client_code, Client.client_name).outerjoin(ClientAssociation, ClientAssociation.client_id == Client.id).where(ClientAssociation.id.is_(None))).all() + print(f"Total clients : {total_clients}") + print(f"Total associations : {total_assoc}") + print(f"Missing associations: {len(missing)}") + for row in missing[:50]: + print(f"- {row.id} | {row.client_code} | {row.client_name}") + finally: + db.close() + + +if __name__ == '__main__': + main() diff --git a/tools/storage_agent/README.md b/tools/storage_agent/README.md new file mode 100644 index 0000000..e5af4e8 --- /dev/null +++ b/tools/storage_agent/README.md @@ -0,0 +1,16 @@ +# Local Storage Agent (Branch) — Generator + +Generates a small package to run on a branch PC to create / maintain folder structure. + +## Generate +```bash +python tools/storage_agent/generate_agent_package.py --branch-id 1 --storage-root "D:\\AuditFirm\\TenantA\\Branch1" --output "./agent_branch1" +``` + +Then on the branch PC run: +- `ensure_folders.bat` + +Later upgrades: +- package as EXE +- run as scheduled task / service +- sync/backup diff --git a/tools/storage_agent/generate_agent_package.py b/tools/storage_agent/generate_agent_package.py new file mode 100644 index 0000000..ebedc94 --- /dev/null +++ b/tools/storage_agent/generate_agent_package.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import argparse +import json +import shutil +from pathlib import Path + +DEFAULT_TEMPLATE = "{root}/Clients/{client_code}/{fy}/{service}/" + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--output", default="./agent_out", help="Output folder for generated agent package") + p.add_argument("--branch-id", type=int, required=True) + p.add_argument("--storage-root", required=True, help=r"Local storage root path (e.g. D:\AuditFirm\Tenant\Branch)") + p.add_argument("--folder-template", default=DEFAULT_TEMPLATE) + args = p.parse_args() + + out = Path(args.output).resolve() + if out.exists(): + shutil.rmtree(out) + out.mkdir(parents=True, exist_ok=True) + + cfg = { + "branch_id": args.branch_id, + "storage_root": args.storage_root, + "folder_template": args.folder_template, + "examples": [ + {"client_code": "C0001", "fy": "2025-26", "service": "GST"}, + {"client_code": "C0001", "fy": "2025-26", "service": "IT"}, + ], + } + + (out / "branch_config.json").write_text(json.dumps(cfg, indent=2), encoding="utf-8") + + (out / "ensure_folders.py").write_text( + ''' +import json, os +from pathlib import Path + +def render(tpl: str, root: str, client_code: str, fy: str, service: str) -> str: + return tpl.replace("{root}", root).replace("{client_code}", client_code).replace("{fy}", fy).replace("{service}", service) + +def ensure(path: str): + Path(path).mkdir(parents=True, exist_ok=True) + +def main(): + cfg = json.loads(Path("branch_config.json").read_text(encoding="utf-8")) + root = cfg["storage_root"] + tpl = cfg.get("folder_template", "{root}/Clients/{client_code}/{fy}/{service}/") + + for ex in cfg.get("examples", []): + folder = render(tpl, root, ex["client_code"], ex["fy"], ex["service"]) + folder = folder.replace("/", os.sep) + ensure(folder) + print("Ensured:", folder) + +if __name__ == "__main__": + main() +'''.strip() + "\n", + encoding="utf-8" + ) + + (out / "ensure_folders.bat").write_text( + "@echo off\r\n" + "python ensure_folders.py\r\n" + "pause\r\n", + encoding="utf-8" + ) + + print("Generated agent package at:", out) + +if __name__ == "__main__": + main()