diff --git a/alembic/versions/20260620_phase_204f_platform_smtp.py b/alembic/versions/20260620_phase_204f_platform_smtp.py
new file mode 100644
index 0000000..6790145
--- /dev/null
+++ b/alembic/versions/20260620_phase_204f_platform_smtp.py
@@ -0,0 +1,60 @@
+"""Phase v2.0.4-F - Platform SMTP settings
+
+Revision ID: 20260620_phase_204f_platform_smtp
+Revises: 20260619_phase_204e_year_lock_backup
+Create Date: 2026-06-20
+"""
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+
+revision = "20260620_phase_204f_platform_smtp"
+down_revision = "20260619_phase_204e_year_lock_backup"
+branch_labels = None
+depends_on = None
+
+
+def _has_table(bind, table_name: str) -> bool:
+ return sa.inspect(bind).has_table(table_name)
+
+
+def _has_index(bind, table_name: str, index_name: str) -> bool:
+ try:
+ return any(idx.get("name") == index_name for idx in sa.inspect(bind).get_indexes(table_name))
+ except Exception:
+ return False
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+ if not _has_table(bind, "platform_email_settings"):
+ op.create_table(
+ "platform_email_settings",
+ sa.Column("id", sa.Integer(), primary_key=True, autoincrement=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("send_auth_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()),
+ )
+ if not _has_index(bind, "platform_email_settings", "ix_platform_email_settings_is_active"):
+ op.create_index("ix_platform_email_settings_is_active", "platform_email_settings", ["is_active"])
+
+
+def downgrade() -> None:
+ bind = op.get_bind()
+ if _has_table(bind, "platform_email_settings"):
+ if _has_index(bind, "platform_email_settings", "ix_platform_email_settings_is_active"):
+ op.drop_index("ix_platform_email_settings_is_active", table_name="platform_email_settings")
+ op.drop_table("platform_email_settings")
diff --git a/app/modules/email_integration/models.py b/app/modules/email_integration/models.py
index a92082c..9aa7c02 100644
--- a/app/modules/email_integration/models.py
+++ b/app/modules/email_integration/models.py
@@ -68,6 +68,38 @@ class EmailSetting(CommonBase):
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
+class PlatformEmailSetting(CommonBase):
+ """Platform-level SMTP settings controlled by System Admin.
+
+ Used before a tenant/firm is operational, for example Firm Creation
+ Wizard invites, platform password/reset notices and system security emails.
+ Firm/branch SMTP remains in EmailSetting and is not changed by this table.
+ """
+
+ __tablename__ = "platform_email_settings"
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+
+ smtp_host: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ smtp_port: Mapped[int | None] = mapped_column(Integer, nullable=True)
+ smtp_username: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ smtp_password: Mapped[str | None] = mapped_column(String(500), nullable=True)
+ smtp_security: Mapped[str] = mapped_column(String(20), nullable=False, default="SSL") # SSL|STARTTLS|NONE
+ smtp_timeout_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=20)
+
+ from_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ from_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ reply_to_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
+
+ send_auth_emails: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
+ is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
+
+ created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
+ updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
+ created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
+ updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
+
+
class EmailTemplate(CommonBase):
__tablename__ = "email_templates"
__table_args__ = (
diff --git a/app/modules/email_integration/services.py b/app/modules/email_integration/services.py
index 84e2210..bc2a5cb 100644
--- a/app/modules/email_integration/services.py
+++ b/app/modules/email_integration/services.py
@@ -11,7 +11,7 @@ from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
-from app.modules.email_integration.models import EmailLog, EmailSetting, EmailTemplate
+from app.modules.email_integration.models import EmailLog, EmailSetting, EmailTemplate, PlatformEmailSetting
from app.modules.email_integration.attachment_service import EmailAttachment
from app.modules.core.iam.models import User
from app.core.settings import get_settings
@@ -232,6 +232,38 @@ def seed_default_email_templates(db: Session, tenant_id: int | None = None, bran
existing_codes.add(code)
+
+def get_platform_email_setting(db: Session) -> PlatformEmailSetting | None:
+ return db.execute(
+ select(PlatformEmailSetting).order_by(PlatformEmailSetting.id.asc())
+ ).scalars().first()
+
+
+def get_or_create_platform_email_setting(db: Session, actor_user_id: int | None = None) -> PlatformEmailSetting:
+ row = get_platform_email_setting(db)
+ if row:
+ return row
+
+ settings = get_settings()
+ row = PlatformEmailSetting(
+ smtp_host=getattr(settings, "SMTP_HOST", None) or "mail.vavalam.com",
+ smtp_port=int(getattr(settings, "SMTP_PORT", 465) or 465),
+ smtp_security="SSL",
+ smtp_username=getattr(settings, "SMTP_USERNAME", None) or "no-reply@vavalam.com",
+ smtp_password=getattr(settings, "SMTP_PASSWORD", None),
+ from_email=getattr(settings, "SMTP_FROM_EMAIL", None) or "no-reply@vavalam.com",
+ from_name=getattr(settings, "SMTP_FROM_NAME", None) or "ERP Platform",
+ reply_to_email=getattr(settings, "SMTP_FROM_EMAIL", None) or "no-reply@vavalam.com",
+ send_auth_emails=True,
+ is_active=True,
+ created_by_user_id=actor_user_id,
+ updated_by_user_id=actor_user_id,
+ )
+ db.add(row)
+ db.flush()
+ return row
+
+
def get_email_setting(db: Session, tenant_id: int | None, branch_id: int | None) -> EmailSetting | None:
if tenant_id is None:
return None
@@ -585,6 +617,123 @@ def process_pending_email_queue(
db.flush()
return result
+
+def send_platform_email_log_now(db: Session, log: EmailLog) -> EmailLog:
+ """Attempt immediate send through System Admin platform SMTP."""
+ setting = get_platform_email_setting(db)
+ now = datetime.now(timezone.utc)
+ log.processing_started_at = now
+ log.last_attempt_at = now
+ log.attempt_count = int(getattr(log, "attempt_count", 0) or 0) + 1
+
+ if not setting or not setting.is_active:
+ log.status = "SKIPPED"
+ log.error_message = "Platform SMTP settings not configured or inactive."
+ log.is_retryable = False
+ log.processing_started_at = None
+ db.flush()
+ return log
+
+ try:
+ _send_smtp(setting, log.recipient_email, log.subject, log.body or "", is_html=False)
+ log.status = "SENT"
+ log.sent_at = datetime.now(timezone.utc)
+ log.error_message = None
+ log.next_retry_at = None
+ log.processing_started_at = None
+ log.is_retryable = False
+ except Exception as exc:
+ log.status = "FAILED"
+ log.error_message = str(exc)
+ log.processing_started_at = None
+ log.is_retryable = False
+ log.next_retry_at = None
+ db.flush()
+ return log
+
+
+def send_platform_template_email(
+ db: Session,
+ *,
+ recipient_email: str,
+ template_code: str,
+ context: dict[str, Any] | None = None,
+ related_module: str | None = None,
+ related_id: int | None = None,
+ send_immediately: bool = True,
+ queue_priority: int = 10,
+) -> EmailLog:
+ """Queue/send a platform email without tenant SMTP dependency."""
+ context = dict(context or {})
+ context.setdefault("firm_name", "ERP Platform")
+ context.setdefault("support_email", "")
+
+ template = _get_template(db, None, None, template_code)
+ if not template:
+ subject = template_code
+ body = ""
+ else:
+ subject = render_template_text(template.subject_template, context)
+ body = render_template_text(template.body_template, context)
+
+ log = EmailLog(
+ tenant_id=None,
+ branch_id=None,
+ recipient_email=recipient_email,
+ subject=subject[:500],
+ body=body,
+ status="PENDING",
+ related_module=related_module,
+ related_id=related_id,
+ template_code=template_code,
+ queued_at=datetime.now(timezone.utc),
+ max_attempts=1,
+ queue_priority=int(queue_priority or 10),
+ is_retryable=False,
+ )
+ db.add(log)
+ db.flush()
+
+ setting = get_platform_email_setting(db)
+ if not setting or not setting.is_active:
+ log.status = "SKIPPED"
+ log.error_message = "Platform SMTP settings not configured or inactive."
+ db.flush()
+ return log
+
+ if template_code.upper().startswith("AUTH_") and not getattr(setting, "send_auth_emails", True):
+ log.status = "SKIPPED"
+ log.error_message = "Platform authentication/invite emails are disabled."
+ db.flush()
+ return log
+
+ if send_immediately:
+ send_platform_email_log_now(db, log)
+ return log
+
+
+def send_platform_user_invite_email(db: Session, *, user: User, invite_token: str, firm_name: str | None = None) -> EmailLog | None:
+ if not getattr(user, "email", None):
+ return None
+ invite_link = f"{_public_base_url()}/invite/accept?token={invite_token}"
+ return send_platform_template_email(
+ db,
+ recipient_email=str(user.email),
+ template_code="AUTH_USER_INVITE",
+ context={
+ "firm_name": firm_name or "ERP Platform",
+ "user_name": _user_display_name(user),
+ "user_email": str(user.email),
+ "invite_link": invite_link,
+ "expiry_hours": str(get_settings().INVITE_TOKEN_HOURS),
+ "support_email": "",
+ },
+ related_module="wizard_firm_invite",
+ related_id=int(user.id),
+ queue_priority=5,
+ )
+
+
def send_template_email(
db: Session,
*,
diff --git a/app/modules/email_integration/templates/email_integration/platform_smtp.html b/app/modules/email_integration/templates/email_integration/platform_smtp.html
new file mode 100644
index 0000000..15cbf4b
--- /dev/null
+++ b/app/modules/email_integration/templates/email_integration/platform_smtp.html
@@ -0,0 +1,78 @@
+{% extends "ui/templates/base/layout.html" %}
+{% block content %}
+
+
+
+
+
Platform SMTP Settings
+
System Admin SMTP used for firm creation invites, primary Firm Admin onboarding, password/reset emails and platform notices before a firm configures its own SMTP.
+
+
+
+ {% if flash %}
+
{{ flash }}
+ {% endif %}
+
+
+
+
+
+
+
+ Recent Platform Email Attempts
+
+
+ | Time | Recipient | Subject | Status | Error |
+
+ {% for log in recent_logs %}
+ | {{ log.created_at_utc }} | {{ log.recipient_email }} | {{ log.subject }} | {{ log.status }} | {{ log.error_message or '' }} |
+ {% else %}
+ | No platform email attempts yet. |
+ {% endfor %}
+
+
+
+
+
+{% endblock %}
diff --git a/app/modules/email_integration/ui.py b/app/modules/email_integration/ui.py
index 7f0f7c2..b75d15c 100644
--- a/app/modules/email_integration/ui.py
+++ b/app/modules/email_integration/ui.py
@@ -12,7 +12,7 @@ from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
from app.core.security.session_auth import get_current_user
from app.core.templating import templates
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
-from app.modules.email_integration.models import EmailIncomingAttachment, EmailIncomingMessage, EmailLog, EmailSetting, EmailTemplate
+from app.modules.email_integration.models import EmailIncomingAttachment, EmailIncomingMessage, EmailLog, EmailSetting, EmailTemplate, PlatformEmailSetting
from app.modules.email_integration.imap_service import fetch_incoming_emails
from app.modules.email_integration.mapping_service import apply_email_mapping, auto_map_unmapped_emails
from app.modules.billing.models import BillingInvoice
@@ -23,6 +23,8 @@ from app.modules.email_integration.services import (
seed_default_email_templates,
send_template_email,
process_pending_email_queue,
+ get_or_create_platform_email_setting,
+ send_platform_template_email,
)
router = APIRouter(prefix="/email", tags=["email-integration-ui"])
@@ -32,6 +34,11 @@ def _redirect_login():
return RedirectResponse(url="/login", status_code=303)
+
+def _user_is_system_admin(db, user) -> bool:
+ return "System Admin" in set(get_user_roles(db, int(user.id)))
+
+
def _user_can_manage_email(db, user) -> bool:
roles = set(get_user_roles(db, int(user.id)))
perms = set(get_user_permissions(db, int(user.id)))
@@ -58,6 +65,108 @@ def _ctx(request: Request, db, user, **extra):
return ctx
+@router.get("/platform-smtp")
+def platform_smtp_page(request: Request, flash: str | None = None):
+ db = CommonSessionLocal()
+ try:
+ user = get_current_user(request, db=db)
+ if not user:
+ return _redirect_login()
+ if not _user_is_system_admin(db, user):
+ return forbidden_response(request, "Access denied: platform SMTP is restricted to System Admin")
+ setting = get_or_create_platform_email_setting(db, actor_user_id=int(user.id))
+ db.commit()
+ recent_logs = db.execute(
+ select(EmailLog)
+ .where(EmailLog.tenant_id.is_(None), EmailLog.related_module.in_(["platform_smtp_test", "wizard_firm_invite"]))
+ .order_by(desc(EmailLog.created_at_utc))
+ .limit(10)
+ ).scalars().all()
+ return templates.TemplateResponse(
+ "modules/email_integration/templates/email_integration/platform_smtp.html",
+ _ctx(request, db, user, title="Platform SMTP Settings", setting=setting, recent_logs=recent_logs, flash=flash),
+ )
+ finally:
+ db.close()
+
+
+@router.post("/platform-smtp")
+def save_platform_smtp(
+ request: Request,
+ csrf_token: str = Form(...),
+ smtp_host: str = Form(""),
+ smtp_port: int = Form(465),
+ smtp_username: str = Form(""),
+ smtp_password: str = Form(""),
+ smtp_security: str = Form("SSL"),
+ smtp_timeout_seconds: int = Form(20),
+ from_email: str = Form(""),
+ from_name: str = Form(""),
+ reply_to_email: str = Form(""),
+ send_auth_emails: str | None = Form(None),
+ is_active: str | None = Form(None),
+):
+ validate_csrf(request, csrf_token)
+ db = CommonSessionLocal()
+ try:
+ user = get_current_user(request, db=db)
+ if not user:
+ return _redirect_login()
+ if not _user_is_system_admin(db, user):
+ return forbidden_response(request, "Access denied: platform SMTP is restricted to System Admin")
+
+ setting = get_or_create_platform_email_setting(db, actor_user_id=int(user.id))
+ setting.smtp_host = smtp_host.strip() or None
+ setting.smtp_port = int(smtp_port or 465)
+ setting.smtp_username = smtp_username.strip() or None
+ if smtp_password.strip():
+ setting.smtp_password = smtp_password.strip()
+ setting.smtp_security = (smtp_security or "SSL").upper()
+ setting.smtp_timeout_seconds = max(5, min(int(smtp_timeout_seconds or 20), 120))
+ setting.from_email = from_email.strip() or None
+ setting.from_name = from_name.strip() or None
+ setting.reply_to_email = reply_to_email.strip() or None
+ setting.send_auth_emails = bool(send_auth_emails)
+ setting.is_active = bool(is_active)
+ setting.updated_by_user_id = int(user.id)
+ db.commit()
+ return RedirectResponse(url="/email/platform-smtp?flash=Platform SMTP settings saved.", status_code=303)
+ finally:
+ db.close()
+
+
+@router.post("/platform-smtp/test")
+def test_platform_smtp(request: Request, csrf_token: str = Form(...), test_email: str = Form("")):
+ validate_csrf(request, csrf_token)
+ db = CommonSessionLocal()
+ try:
+ user = get_current_user(request, db=db)
+ if not user:
+ return _redirect_login()
+ if not _user_is_system_admin(db, user):
+ return forbidden_response(request, "Access denied: platform SMTP is restricted to System Admin")
+
+ recipient = (test_email or getattr(user, "email", "")).strip()
+ send_platform_template_email(
+ db,
+ recipient_email=recipient,
+ template_code="AUTH_USER_INVITE",
+ context={
+ "firm_name": "ERP Platform",
+ "user_name": getattr(user, "full_name", None) or recipient,
+ "invite_link": "This is a platform SMTP test email. No action is required.",
+ "expiry_hours": "0",
+ },
+ related_module="platform_smtp_test",
+ related_id=int(user.id),
+ queue_priority=1,
+ )
+ db.commit()
+ return RedirectResponse(url="/email/platform-smtp?flash=Test email attempted. Check recent logs below.", status_code=303)
+ finally:
+ db.close()
+
+
@router.get("/audit")
diff --git a/app/modules/system_settings/ui.py b/app/modules/system_settings/ui.py
index edb4398..200cb89 100644
--- a/app/modules/system_settings/ui.py
+++ b/app/modules/system_settings/ui.py
@@ -1,4 +1,4 @@
-from __future__ import annotations
+from __future__ import annotations
from datetime import date, datetime, time, timezone
from pathlib import Path
@@ -1492,4 +1492,3 @@ def switch_active_branch(request: Request, branch_id: int):
return _redirect_back(request)
finally:
db.close()
-
diff --git a/app/modules/wizards/service.py b/app/modules/wizards/service.py
index c705d59..5689c71 100644
--- a/app/modules/wizards/service.py
+++ b/app/modules/wizards/service.py
@@ -1,4 +1,4 @@
-from __future__ import annotations
+from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime, timedelta
@@ -19,6 +19,7 @@ from app.modules.core.rbac.models import Role, UserRole
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
+from app.modules.email_integration.services import send_platform_user_invite_email
class FirmWizardError(ValueError):
@@ -33,6 +34,8 @@ class FirmWizardResult:
financial_year: FinancialYear | None
invite_url: str
invite_token: str
+ invite_email_status: str | None = None
+ invite_email_error: str | None = None
def normalize_code(value: str, *, upper: bool = True) -> str:
@@ -202,6 +205,19 @@ def validate_firm_wizard_payload(db: Session, payload: dict[str, Any]) -> list[s
return errors
+
+def safe_employee_code_for_firm_admin(db: Session, tenant_id: int, tenant_code: str, user_id: int) -> str:
+ base = normalize_code(f"{tenant_code}_ADMIN_{user_id}")[:50]
+ employee_code = base
+ suffix = 1
+ while db.execute(
+ select(Employee).where(Employee.tenant_id == tenant_id, Employee.employee_code == employee_code)
+ ).scalar_one_or_none():
+ suffix += 1
+ employee_code = f"{base[:44]}_{suffix}"[:50]
+ return employee_code
+
+
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
@@ -295,14 +311,11 @@ def create_firm_from_wizard(db: Session, payload: dict[str, Any]) -> FirmWizardR
db.flush()
db.add(UserRole(user_id=firm_admin.id, role_id=role.id))
- # Auto-create and link Employee Master for the primary Firm Admin.
- # Employee Portal requires employees.user_id to match the logged-in user.
- employee_code = normalize_code(f"{payload['tenant_code']}_ADMIN")[:50]
firm_admin_employee = Employee(
tenant_id=tenant.id,
branch_id=branch.id,
user_id=firm_admin.id,
- employee_code=employee_code,
+ employee_code=safe_employee_code_for_firm_admin(db, tenant.id, tenant.code, firm_admin.id),
full_name=payload["admin_full_name"],
email=payload["admin_email"],
mobile=payload["admin_mobile"] or None,
@@ -338,6 +351,23 @@ def create_firm_from_wizard(db: Session, payload: dict[str, Any]) -> FirmWizardR
invite_token = create_invite_token_without_commit(db, firm_admin)
invite_url = public_invite_url(invite_token)
+ invite_email_status = None
+ invite_email_error = None
+ try:
+ invite_email_log = send_platform_user_invite_email(
+ db,
+ user=firm_admin,
+ invite_token=invite_token,
+ firm_name=tenant.display_name or tenant.name,
+ )
+ if invite_email_log:
+ invite_email_status = invite_email_log.status
+ invite_email_error = invite_email_log.error_message
+ except Exception as exc:
+ # Firm creation must not fail only because SMTP is unavailable.
+ invite_email_status = "FAILED"
+ invite_email_error = str(exc)
+
return FirmWizardResult(
tenant=tenant,
branch=branch,
@@ -345,5 +375,6 @@ def create_firm_from_wizard(db: Session, payload: dict[str, Any]) -> FirmWizardR
financial_year=financial_year,
invite_url=invite_url,
invite_token=invite_token,
+ invite_email_status=invite_email_status,
+ invite_email_error=invite_email_error,
)
-
diff --git a/app/modules/wizards/service.py.bak_employee_link_20260702_150318 b/app/modules/wizards/service.py.bak_employee_link_20260702_150318
new file mode 100644
index 0000000..a28a99d
--- /dev/null
+++ b/app/modules/wizards/service.py.bak_employee_link_20260702_150318
@@ -0,0 +1,323 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import date, datetime, timedelta
+import hashlib
+import re
+import secrets
+from typing import Any
+
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from app.core.security.jwt_tokens import utcnow
+from app.core.security.passwords import hash_password
+from app.core.settings import get_settings
+from app.modules.core.iam.models import User
+from app.modules.core.iam.password_flows_models import InviteToken
+from app.modules.core.rbac.models import Role, UserRole
+from app.modules.core.tenancy.models import Branch, FinancialYear, Tenant
+from app.modules.core.tenancy.settings_models import BranchSettings
+
+
+class FirmWizardError(ValueError):
+ """Raised when the firm creation wizard receives invalid data."""
+
+
+@dataclass(slots=True)
+class FirmWizardResult:
+ tenant: Tenant
+ branch: Branch
+ firm_admin: User
+ financial_year: FinancialYear | None
+ invite_url: str
+ invite_token: str
+
+
+def normalize_code(value: str, *, upper: bool = True) -> str:
+ value = (value or "").strip()
+ value = re.sub(r"\s+", "_", value)
+ value = re.sub(r"[^A-Za-z0-9_\-]", "", value)
+ return value.upper() if upper else value
+
+
+def clean_text(value: str | None) -> str:
+ return (value or "").strip()
+
+
+def parse_bool(value: Any) -> bool:
+ if isinstance(value, bool):
+ return value
+ return str(value or "").strip().lower() in {"1", "true", "yes", "on", "y"}
+
+
+def parse_int(value: Any, default: int) -> int:
+ try:
+ return int(str(value).strip())
+ except Exception:
+ return default
+
+
+def parse_iso_date(value: str | None) -> date | None:
+ value = clean_text(value)
+ if not value:
+ return None
+ return date.fromisoformat(value)
+
+
+def default_ay_from_fy(year_code: str) -> str:
+ year_code = clean_text(year_code)
+ try:
+ start_year = int(year_code.split("-", 1)[0])
+ except Exception:
+ return ""
+ ay_start = start_year + 1
+ return f"{ay_start}-{str(ay_start + 1)[-2:]}"
+
+
+def default_dates_from_fy(year_code: str) -> tuple[date | None, date | None]:
+ year_code = clean_text(year_code)
+ try:
+ start_year = int(year_code.split("-", 1)[0])
+ except Exception:
+ return None, None
+ return date(start_year, 4, 1), date(start_year + 1, 3, 31)
+
+
+def public_invite_url(invite_token: str) -> str:
+ base = (get_settings().ERP_PUBLIC_BASE_URL or "").strip().rstrip("/") or "http://localhost:8000"
+ return f"{base}/invite/accept?token={invite_token}"
+
+
+def build_firm_wizard_payload(form: dict[str, Any]) -> dict[str, Any]:
+ """Return a normalized payload used by preview and confirm pages."""
+ tenant_code = normalize_code(str(form.get("tenant_code") or form.get("firm_code") or ""))
+ branch_code = normalize_code(str(form.get("branch_code") or "HO"))
+ admin_email = clean_text(str(form.get("admin_email") or "")).lower()
+
+ fy_enabled = parse_bool(form.get("create_financial_year"))
+ fy_code = clean_text(str(form.get("fy_year_code") or ""))
+ ay_code = clean_text(str(form.get("fy_assessment_year") or ""))
+ fy_start_date = clean_text(str(form.get("fy_start_date") or ""))
+ fy_end_date = clean_text(str(form.get("fy_end_date") or ""))
+
+ if fy_enabled and fy_code:
+ if not ay_code:
+ ay_code = default_ay_from_fy(fy_code)
+ if not fy_start_date or not fy_end_date:
+ start, end = default_dates_from_fy(fy_code)
+ fy_start_date = fy_start_date or (start.isoformat() if start else "")
+ fy_end_date = fy_end_date or (end.isoformat() if end else "")
+
+ return {
+ "tenant_code": tenant_code,
+ "tenant_name": clean_text(str(form.get("tenant_name") or form.get("firm_name") or "")),
+ "firm_type": clean_text(str(form.get("firm_type") or "partnership")) or "partnership",
+ "default_timezone": clean_text(str(form.get("default_timezone") or "Asia/Kolkata")) or "Asia/Kolkata",
+ "default_session_duration_minutes": parse_int(form.get("default_session_duration_minutes"), 480),
+ "default_otp_required_roles_csv": clean_text(str(form.get("default_otp_required_roles_csv") or "Partner,System Admin")) or "Partner,System Admin",
+ "default_storage_mode": clean_text(str(form.get("default_storage_mode") or "local_only")) or "local_only",
+ "branch_code": branch_code,
+ "branch_name": clean_text(str(form.get("branch_name") or "Head Office")) or "Head Office",
+ "branch_timezone": clean_text(str(form.get("branch_timezone") or form.get("default_timezone") or "Asia/Kolkata")) or "Asia/Kolkata",
+ "branch_address_line1": clean_text(str(form.get("branch_address_line1") or "")),
+ "branch_address_line2": clean_text(str(form.get("branch_address_line2") or "")),
+ "branch_city": clean_text(str(form.get("branch_city") or "")),
+ "branch_state": clean_text(str(form.get("branch_state") or "")),
+ "branch_pin_code": clean_text(str(form.get("branch_pin_code") or "")),
+ "branch_gstin": clean_text(str(form.get("branch_gstin") or "")).upper(),
+ "branch_pan": clean_text(str(form.get("branch_pan") or "")).upper(),
+ "admin_full_name": clean_text(str(form.get("admin_full_name") or "")),
+ "admin_email": admin_email,
+ "admin_mobile": clean_text(str(form.get("admin_mobile") or "")),
+ "admin_designation": clean_text(str(form.get("admin_designation") or "Firm Admin")) or "Firm Admin",
+ "create_financial_year": fy_enabled,
+ "fy_year_code": fy_code,
+ "fy_assessment_year": ay_code,
+ "fy_start_date": fy_start_date,
+ "fy_end_date": fy_end_date,
+ "fy_is_current": parse_bool(form.get("fy_is_current")) if fy_enabled else False,
+ }
+
+
+def validate_firm_wizard_payload(db: Session, payload: dict[str, Any]) -> list[str]:
+ errors: list[str] = []
+
+ if not payload["tenant_code"]:
+ errors.append("Firm code is required.")
+ if not payload["tenant_name"]:
+ errors.append("Firm name is required.")
+ if not payload["branch_code"]:
+ errors.append("Primary branch code is required.")
+ if not payload["branch_name"]:
+ errors.append("Primary branch name is required.")
+ if not payload["admin_full_name"]:
+ errors.append("Primary Firm Admin full name is required.")
+ if not payload["admin_email"]:
+ errors.append("Primary Firm Admin email is required.")
+ elif "@" not in payload["admin_email"]:
+ errors.append("Primary Firm Admin email is invalid.")
+
+ if payload["default_session_duration_minutes"] < 15:
+ errors.append("Session duration must be at least 15 minutes.")
+
+ allowed_firm_types = {"partnership", "proprietorship", "individual"}
+ if payload["firm_type"] not in allowed_firm_types:
+ errors.append("Invalid firm type.")
+
+ allowed_storage_modes = {"local_only", "cloud_only", "hybrid"}
+ if payload["default_storage_mode"] not in allowed_storage_modes:
+ errors.append("Invalid default storage mode.")
+
+ if payload["tenant_code"]:
+ existing_tenant = db.execute(select(Tenant).where(Tenant.code == payload["tenant_code"])).scalar_one_or_none()
+ if existing_tenant:
+ errors.append("Firm code already exists.")
+
+ if payload["admin_email"]:
+ existing_user = db.execute(select(User).where(User.email == payload["admin_email"])).scalar_one_or_none()
+ if existing_user:
+ errors.append("Primary Firm Admin email already exists as a user.")
+
+ role = db.execute(select(Role).where(Role.name == "Firm Admin", Role.is_active.is_(True))).scalar_one_or_none()
+ if not role:
+ errors.append("Firm Admin role is missing or inactive. Please seed roles before using this wizard.")
+
+ if payload["create_financial_year"]:
+ if not payload["fy_year_code"]:
+ errors.append("Financial year code is required when default FY is enabled.")
+ if not payload["fy_assessment_year"]:
+ errors.append("Assessment year is required when default FY is enabled.")
+ try:
+ start = parse_iso_date(payload["fy_start_date"])
+ end = parse_iso_date(payload["fy_end_date"])
+ if not start or not end:
+ errors.append("Financial year start and end date are required.")
+ elif end <= start:
+ errors.append("Financial year end date must be after start date.")
+ except Exception:
+ errors.append("Financial year dates must be valid ISO dates, for example 2026-04-01.")
+
+ return errors
+
+
+def _hash_token(token: str) -> str:
+ return hashlib.sha256(token.encode("utf-8")).hexdigest()
+
+
+def create_invite_token_without_commit(db: Session, user: User) -> str:
+ plain = secrets.token_urlsafe(32)
+ now = utcnow()
+ db.add(
+ InviteToken(
+ user_id=user.id,
+ token_hash=_hash_token(plain),
+ created_at_utc=now.replace(tzinfo=None),
+ expires_at_utc=(now + timedelta(hours=get_settings().INVITE_TOKEN_HOURS)).replace(tzinfo=None),
+ used_at_utc=None,
+ )
+ )
+ user.must_change_password = True
+ return plain
+
+
+def create_firm_from_wizard(db: Session, payload: dict[str, Any]) -> FirmWizardResult:
+ errors = validate_firm_wizard_payload(db, payload)
+ if errors:
+ raise FirmWizardError(" ".join(errors))
+
+ role = db.execute(select(Role).where(Role.name == "Firm Admin", Role.is_active.is_(True))).scalar_one()
+ temp_password = secrets.token_urlsafe(18)
+
+ tenant = Tenant(
+ code=payload["tenant_code"],
+ name=payload["tenant_name"],
+ display_name=payload["tenant_name"],
+ is_active=True,
+ firm_type=payload["firm_type"],
+ default_timezone=payload["default_timezone"],
+ default_session_duration_minutes=payload["default_session_duration_minutes"],
+ default_otp_required_roles_csv=payload["default_otp_required_roles_csv"],
+ default_storage_mode=payload["default_storage_mode"],
+ contact_email=payload["admin_email"],
+ contact_mobile=payload["admin_mobile"] or None,
+ )
+ db.add(tenant)
+ db.flush()
+
+ branch = Branch(
+ tenant_id=tenant.id,
+ code=payload["branch_code"],
+ name=payload["branch_name"],
+ is_active=True,
+ timezone=payload["branch_timezone"],
+ allow_login=True,
+ allow_new_assignments=True,
+ is_head_office=True,
+ smtp_use_tls=True,
+ )
+ db.add(branch)
+ db.flush()
+
+ branch_settings = BranchSettings(
+ branch_id=branch.id,
+ address_line1=payload["branch_address_line1"] or None,
+ address_line2=payload["branch_address_line2"] or None,
+ city=payload["branch_city"] or None,
+ state=payload["branch_state"] or None,
+ pin_code=payload["branch_pin_code"] or None,
+ gstin=payload["branch_gstin"] or None,
+ pan=payload["branch_pan"] or None,
+ storage_mode=payload["default_storage_mode"],
+ otp_required_roles_csv=payload["default_otp_required_roles_csv"],
+ session_duration_minutes=payload["default_session_duration_minutes"],
+ )
+ db.add(branch_settings)
+ db.flush()
+
+ firm_admin = User(
+ email=payload["admin_email"],
+ full_name=payload["admin_full_name"],
+ password_hash=hash_password(temp_password),
+ tenant_id=tenant.id,
+ branch_id=branch.id,
+ is_active=True,
+ allow_login=True,
+ is_locked=False,
+ deleted_at=None,
+ must_change_password=True,
+ password_changed_at_utc=None,
+ mobile=payload["admin_mobile"] or None,
+ designation=payload["admin_designation"] or "Firm Admin",
+ )
+ db.add(firm_admin)
+ db.flush()
+ db.add(UserRole(user_id=firm_admin.id, role_id=role.id))
+
+ financial_year = None
+ if payload["create_financial_year"]:
+ financial_year = FinancialYear(
+ tenant_id=tenant.id,
+ year_code=payload["fy_year_code"],
+ assessment_year=payload["fy_assessment_year"],
+ start_date=parse_iso_date(payload["fy_start_date"]),
+ end_date=parse_iso_date(payload["fy_end_date"]),
+ is_current=payload["fy_is_current"],
+ is_locked=False,
+ created_at_utc=datetime.utcnow(),
+ updated_at_utc=datetime.utcnow(),
+ )
+ db.add(financial_year)
+ db.flush()
+
+ invite_token = create_invite_token_without_commit(db, firm_admin)
+ invite_url = public_invite_url(invite_token)
+
+ return FirmWizardResult(
+ tenant=tenant,
+ branch=branch,
+ firm_admin=firm_admin,
+ financial_year=financial_year,
+ invite_url=invite_url,
+ invite_token=invite_token,
+ )
diff --git a/app/modules/wizards/templates/wizards/system_firm_complete.html b/app/modules/wizards/templates/wizards/system_firm_complete.html
index 3347acb..5e366ed 100644
--- a/app/modules/wizards/templates/wizards/system_firm_complete.html
+++ b/app/modules/wizards/templates/wizards/system_firm_complete.html
@@ -35,8 +35,14 @@
-
Firm Admin invite link
-
Copy and share this link with the Firm Admin. The user will set their password through the invite acceptance page.
+
Firm Admin invite
+ {% if invite_email_status == 'SENT' %}
+
Invite email was sent through Platform SMTP. The link is also shown below as backup.
+ {% elif invite_email_status in ['FAILED', 'SKIPPED'] %}
+
Invite email status: {{ invite_email_status }}{% if invite_email_error %} - {{ invite_email_error }}{% endif %}. Copy and share this link manually, or configure Platform SMTP and resend invite from Users.
+ {% else %}
+
Copy and share this link with the Firm Admin. Configure Platform SMTP to send this automatically for future firms.
+ {% endif %}
{{ invite_url }}
diff --git a/app/modules/wizards/ui.py b/app/modules/wizards/ui.py
index 67d7d4c..2c1f149 100644
--- a/app/modules/wizards/ui.py
+++ b/app/modules/wizards/ui.py
@@ -231,6 +231,8 @@ def system_firm_confirm(
branch_id = result.branch.id
admin_id = result.firm_admin.id
fy_id = result.financial_year.id if result.financial_year else None
+ invite_email_status = result.invite_email_status
+ invite_email_error = result.invite_email_error
db.commit()
except FirmWizardError as exc:
db.rollback()
@@ -282,6 +284,8 @@ def system_firm_confirm(
firm_admin=firm_admin,
financial_year=financial_year,
invite_url=result.invite_url,
+ invite_email_status=invite_email_status,
+ invite_email_error=invite_email_error,
),
)
finally:
diff --git a/app/ui/app.py b/app/ui/app.py
index 8a70815..aa1aa1c 100644
--- a/app/ui/app.py
+++ b/app/ui/app.py
@@ -1,4 +1,4 @@
-from fastapi import FastAPI
+from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from app.modules.clients.ui import router as clients_ui_router, portal_router as client_portal_router
@@ -56,4 +56,3 @@ def mount_ui(app: FastAPI) -> None:
app.include_router(engagements_ui_router)
app.include_router(client_portal_router)
app.include_router(consultant_portal_router)
-
diff --git a/app/ui/templates/base/layout.html b/app/ui/templates/base/layout.html
index 072cf26..0c73959 100644
--- a/app/ui/templates/base/layout.html
+++ b/app/ui/templates/base/layout.html
@@ -1,4 +1,4 @@
-
+
@@ -108,7 +108,7 @@
{% if firm_branding.logo_url %}
{% else %}{{ (current_firm_name[:2] if current_firm_name else 'AF')|upper }}
{% endif %}
{{ current_firm_name }}
-
{% if full_auth %}{{ current_branch_name }}{% if current_branch_name and current_branch_name != "-" %} Branch{% endif %} | Workspace{% elif domain_context.is_resolved %}{{ current_branch_name or 'Domain Workspace' }}{% else %}Secure Practice Workspace{% endif %}
+
{% if full_auth %}{{ current_branch_name }}{% if current_branch_name and current_branch_name != "-" %} Branch{% endif %} • Workspace{% elif domain_context.is_resolved %}{{ current_branch_name or 'Domain Workspace' }}{% else %}Secure Practice Workspace{% endif %}
@@ -153,7 +153,7 @@
My Workspace
- >
+ ›
{% if can_view_employee_portal(current_user, ui_perms, ui_roles) %}
@@ -186,7 +186,7 @@
Partner Workspace
- >
+ ›
Overview
@@ -201,7 +201,7 @@
Team Workspace
- >
+ ›
{% if can_manage_employee_work(current_user, ui_perms, ui_roles) %}
@@ -226,7 +226,7 @@
Billing
- >
+ ›
Invoices
@@ -244,7 +244,7 @@
Team Administration
- >
+ ›
{% if can_view_employee_dashboard(current_user, ui_perms, ui_roles) %}
HR Dashboard{% endif %}
@@ -282,7 +282,7 @@
Firm Administration
- >
+ ›
{% if "Firm Admin" in ui_roles or is_system_admin_user %}
@@ -338,7 +338,7 @@
Platform
- >
+ ›
Domain Configuration
@@ -348,11 +348,11 @@
Consultant Domains
DNS Verification
SSL Status
- {% if can_view_platform_billing(current_user, ui_perms, ui_roles) or can_view_marketplace_leads(current_user, ui_perms, ui_roles) %}
Platform Operations
+
Platform SMTP
+
Create Firm Wizard
{% if can_view_platform_billing(current_user, ui_perms, ui_roles) %}
Platform Billing{% endif %}
{% if can_view_marketplace_leads(current_user, ui_perms, ui_roles) %}
Marketplace{% endif %}
- {% endif %}
{% endif %}
@@ -360,7 +360,7 @@
Core Setup
- >
+ ›
{% if can_view_settings(current_user, ui_perms, ui_roles) %}
@@ -400,11 +400,11 @@
{{ current_user.full_name or current_user.email }}
{{ current_user.email }}
{% if current_user.qualification or current_user.designation %}
-
{{ current_user.qualification or '' }}{% if current_user.qualification and current_user.designation %} | {% endif %}{{ current_user.designation or '' }}
+
{{ current_user.qualification or '' }}{% if current_user.qualification and current_user.designation %} • {% endif %}{{ current_user.designation or '' }}
{% endif %}
Your Firm: {{ current_firm_name }}
- > Branch: {{ current_branch_name }}{% if active_financial_year %} > FY: {{ active_financial_year }}{% endif %}
+ • Branch: {{ current_branch_name }}{% if active_financial_year %} • FY: {{ active_financial_year }}{% endif %}
{% if "Consultant" in ui_roles %}
@@ -426,7 +426,7 @@
{% else %}
{% if domain_context.is_resolved %}
-
{{ current_firm_name }}{% if current_branch_name %} | {{ current_branch_name }}{% endif %}
+
{{ current_firm_name }}{% if current_branch_name %} • {{ current_branch_name }}{% endif %}
{% endif %}
Login
{% endif %}
@@ -472,20 +472,10 @@
{% endif %}
-
- {% if is_system_admin_user %}
-
- {% endif %}
Active Scope:
{{ current_firm_name }}
- | {{ current_branch_name if active_branch_id else "All Branches" }}{% if active_financial_year %} | FY {{ active_financial_year }}{% endif %}
+ • {{ current_branch_name if active_branch_id else "All Branches" }}{% if active_financial_year %} • FY {{ active_financial_year }}{% endif %}
{% endif %}
@@ -557,7 +547,7 @@
wrapper.className = "overflow-hidden rounded-2xl border shadow-soft " + priorityClasses(alert.priority);
wrapper.innerHTML = `
-
🔔
+
🔔
New Alert
@@ -625,7 +615,3 @@
-
-
-
-