Prepare ERP source for Gitea deployment

This commit is contained in:
A R R R Associates
2026-06-20 15:01:44 +05:30
commit 5c75eb6bd9
450 changed files with 67698 additions and 0 deletions
+50
View File
@@ -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
+36
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
.env
__pycache__/
*.pyc
data/*.db
data/*.sqlite3
.pytest_cache/
venv/
+36
View File
@@ -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
+10
View File
@@ -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
+108
View File
@@ -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()
+23
View File
@@ -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"}
@@ -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")
@@ -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")
@@ -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')
"""
)
)
@@ -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")
@@ -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
@@ -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")
@@ -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
@@ -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")
@@ -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},
)
@@ -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")
@@ -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")
@@ -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
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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)
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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)
@@ -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
@@ -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)
@@ -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
@@ -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
@@ -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
@@ -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")
@@ -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
@@ -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")
@@ -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)
@@ -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
@@ -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
@@ -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
@@ -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")
@@ -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
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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 ###
@@ -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
View File
View File
+16
View File
@@ -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)
View File
+9
View File
@@ -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)
+10
View File
@@ -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()
+30
View File
@@ -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)
View File
+133
View File
@@ -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)
+106
View File
@@ -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
+21
View File
@@ -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
View File
+16
View File
@@ -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")
+35
View File
@@ -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
+41
View File
@@ -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
+23
View File
@@ -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))
+8
View File
@@ -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)
+52
View File
@@ -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
+76
View File
@@ -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
+951
View File
@@ -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()
+622
View File
@@ -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,
)
+40
View File
@@ -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()
View File
+1
View File
@@ -0,0 +1 @@
from __future__ import annotations
+41
View File
@@ -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])
+174
View File
@@ -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)
@@ -0,0 +1,73 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
{% 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 %}
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 class="text-xl font-semibold text-slate-900">My Alerts</h2>
<p class="mt-1 text-sm text-slate-500">Role-wise alerts for tasks, documents, attendance, leave, payroll, client and consultant workflows.</p>
</div>
<form method="post" action="/alerts/read-all">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50" {% if unread_count == 0 %}disabled{% endif %}>Mark all as read</button>
</form>
</div>
<div class="grid gap-4 md:grid-cols-3">
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Unread</div><div class="mt-1 text-2xl font-semibold">{{ unread_count }}</div></div>
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Showing</div><div class="mt-1 text-2xl font-semibold">{{ alerts|length }}</div></div>
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Filter</div><div class="mt-1 text-sm text-slate-600">{{ status.replace('_',' ').title() }} · {{ priority.title() }}</div></div>
</div>
<form method="get" action="/alerts" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
<div class="grid gap-3 md:grid-cols-[220px_220px_auto]">
<select name="status" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
<option value="all" {% if status == 'all' %}selected{% endif %}>All alerts</option>
<option value="unread" {% if status == 'unread' %}selected{% endif %}>Unread only</option>
<option value="read" {% if status == 'read' %}selected{% endif %}>Read only</option>
</select>
<select name="priority" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
<option value="all" {% if priority == 'all' %}selected{% endif %}>All priorities</option>
{% for p in priorities %}<option value="{{ p }}" {% if priority == p %}selected{% endif %}>{{ p.title() }}</option>{% endfor %}
</select>
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Apply Filter</button>
</div>
</form>
<div class="space-y-3">
{% for alert in alerts %}
<div class="rounded-2xl border {% if alert.is_read %}border-slate-200 bg-white{% else %}border-brand-100 bg-brand-50{% endif %} p-5 shadow-soft">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h3 class="font-semibold text-slate-900">{{ alert.title }}</h3>
<span class="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-slate-600">{{ alert.priority }}</span>
{% if not alert.is_read %}<span class="rounded-full bg-brand-600 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-white">Unread</span>{% endif %}
</div>
<div class="mt-1 text-xs text-slate-500">{{ 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 '-' }}</div>
{% if alert.message %}<p class="mt-3 text-sm text-slate-700">{{ alert.message }}</p>{% endif %}
</div>
<div class="flex shrink-0 flex-wrap justify-end gap-2">
{% if alert.target_url %}<a href="{{ alert.target_url }}" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Open</a>{% endif %}
{% if not alert.is_read %}
<form method="post" action="/alerts/{{ alert.id }}/read">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Mark read</button>
</form>
{% endif %}
</div>
</div>
</div>
{% else %}
<div class="rounded-2xl border border-slate-200 bg-white p-8 text-center text-sm text-slate-500 shadow-soft">No alerts found for the selected filter.</div>
{% endfor %}
</div>
</div>
{% endblock %}
+127
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
"""Billing module for firm-level invoices and fee structure imports."""

Some files were not shown because too many files have changed in this diff Show More