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
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."""
@@ -0,0 +1,138 @@
from __future__ import annotations
from decimal import Decimal
from typing import Any
from urllib.parse import quote
from sqlalchemy import or_, select
from sqlalchemy.orm import Session, selectinload
from app.modules.billing.models import BillingInvoice, BillingInvoiceLine, BillingPayment
from app.modules.billing.services import build_invoice_print_context, is_cashfree_ready, is_payumoney_ready, money
CLIENT_VISIBLE_INVOICE_STATUSES = {"ISSUED", "PARTLY_PAID", "PAID", "OVERDUE"}
def _client_ids(client_row: Any) -> tuple[int, int]:
"""Return (tenant_id, client_id) from dict/row/model style client payload."""
if isinstance(client_row, dict):
return int(client_row.get("tenant_id") or 0), int(client_row.get("id") or 0)
return int(getattr(client_row, "tenant_id", 0) or 0), int(getattr(client_row, "id", 0) or 0)
def list_client_portal_invoices(db: Session, client_row: Any, *, q: str = "", include_paid: bool = True, financial_year: str | None = None) -> list[BillingInvoice]:
tenant_id, client_id = _client_ids(client_row)
stmt = (
select(BillingInvoice)
.options(
selectinload(BillingInvoice.lines).selectinload(BillingInvoiceLine.service),
selectinload(BillingInvoice.payments),
)
.where(
BillingInvoice.tenant_id == tenant_id,
BillingInvoice.client_id == client_id,
BillingInvoice.status.in_(CLIENT_VISIBLE_INVOICE_STATUSES),
)
)
if not include_paid:
stmt = stmt.where(BillingInvoice.status != "PAID")
if financial_year and financial_year.upper() != "ALL":
stmt = stmt.where(BillingInvoice.financial_year == financial_year)
if q.strip():
term = f"%{q.strip()}%"
stmt = stmt.where(or_(BillingInvoice.invoice_no.ilike(term), BillingInvoice.invoice_title.ilike(term)))
return db.execute(stmt.order_by(BillingInvoice.invoice_date.desc(), BillingInvoice.id.desc())).scalars().unique().all()
def get_client_portal_invoice(db: Session, client_row: Any, invoice_id: int, *, financial_year: str | None = None) -> BillingInvoice | None:
tenant_id, client_id = _client_ids(client_row)
stmt = (
select(BillingInvoice)
.options(
selectinload(BillingInvoice.client),
selectinload(BillingInvoice.lines).selectinload(BillingInvoiceLine.service),
selectinload(BillingInvoice.payments),
)
.where(
BillingInvoice.id == invoice_id,
BillingInvoice.tenant_id == tenant_id,
BillingInvoice.client_id == client_id,
BillingInvoice.status.in_(CLIENT_VISIBLE_INVOICE_STATUSES),
)
)
if financial_year and financial_year.upper() != "ALL":
stmt = stmt.where(BillingInvoice.financial_year == financial_year)
return db.execute(stmt).scalars().unique().one_or_none()
def get_client_portal_payment(db: Session, client_row: Any, payment_id: int, *, financial_year: str | None = None) -> BillingPayment | None:
tenant_id, client_id = _client_ids(client_row)
stmt = (
select(BillingPayment)
.options(
selectinload(BillingPayment.invoice).selectinload(BillingInvoice.lines),
selectinload(BillingPayment.client),
)
.where(
BillingPayment.id == payment_id,
BillingPayment.tenant_id == tenant_id,
BillingPayment.client_id == client_id,
BillingPayment.status == "RECEIVED",
)
)
if financial_year and financial_year.upper() != "ALL":
stmt = stmt.where(BillingPayment.financial_year == financial_year)
return db.execute(stmt).scalars().unique().one_or_none()
def build_client_billing_summary(db: Session, client_row: Any, *, financial_year: str | None = None) -> dict[str, Any]:
invoices = list_client_portal_invoices(db, client_row, include_paid=True, financial_year=financial_year)
open_invoices = [row for row in invoices if row.status in {"ISSUED", "PARTLY_PAID", "OVERDUE"} and money(row.balance_amount) > Decimal("0.00")]
paid_invoices = [row for row in invoices if row.status == "PAID"]
outstanding = sum((money(row.balance_amount) for row in open_invoices), Decimal("0.00"))
latest_invoice = invoices[0] if invoices else None
latest_due_invoice = open_invoices[0] if open_invoices else None
return {
"billing_invoices": invoices,
"billing_open_invoices": open_invoices,
"billing_paid_invoices": paid_invoices,
"billing_outstanding_amount": money(outstanding),
"billing_latest_invoice": latest_invoice,
"billing_latest_due_invoice": latest_due_invoice,
"billing_open_count": len(open_invoices),
"billing_paid_count": len(paid_invoices),
"billing_total_count": len(invoices),
}
def build_client_payment_context(db: Session, invoice: BillingInvoice) -> dict[str, Any]:
invoice_ctx = build_invoice_print_context(db, invoice)
settings = invoice_ctx.get("settings")
amount_due = money(invoice.balance_amount)
firm_name = invoice_ctx.get("firm_name") or "Audit Firm"
upi_id = getattr(settings, "upi_id", None) if settings else None
upi_link = None
if upi_id and amount_due > Decimal("0.00"):
upi_link = (
"upi://pay?"
f"pa={quote(str(upi_id))}"
f"&pn={quote(str(firm_name))}"
f"&am={quote(str(amount_due))}"
"&cu=INR"
f"&tn={quote('Invoice ' + str(invoice.invoice_no))}"
)
return {
"invoice_ctx": invoice_ctx,
"amount_due": amount_due,
"upi_link": upi_link,
"upi_id": upi_id,
"bank_name": invoice_ctx.get("bank_name"),
"bank_account_name": invoice_ctx.get("bank_account_name"),
"bank_account_number": invoice_ctx.get("bank_account_number"),
"bank_ifsc": invoice_ctx.get("bank_ifsc"),
"payment_instructions": getattr(settings, "bank_details", None) if settings else None,
"payumoney_enabled": is_payumoney_ready(settings),
"payumoney_mode": getattr(settings, "payumoney_mode", "TEST") if settings else "TEST",
"cashfree_enabled": is_cashfree_ready(settings),
"cashfree_mode": getattr(settings, "cashfree_mode", "TEST") if settings else "TEST",
}
+320
View File
@@ -0,0 +1,320 @@
from __future__ import annotations
from datetime import date, datetime, timezone
from decimal import Decimal
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, Numeric, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.db.common import CommonBase
class BillingSettings(CommonBase):
__tablename__ = "billing_settings"
__table_args__ = (
UniqueConstraint("tenant_id", "branch_id", name="uq_billing_settings_tenant_branch"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True)
invoice_prefix: Mapped[str] = mapped_column(String(40), nullable=False, default="INV")
next_invoice_no: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
padding: Mapped[int] = mapped_column(Integer, nullable=False, default=4)
default_gst_rate: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=Decimal("18.00"))
default_tax_type: Mapped[str] = mapped_column(String(20), nullable=False, default="CGST_SGST")
legal_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
gstin: Mapped[str | None] = mapped_column(String(20), nullable=True)
pan: Mapped[str | None] = mapped_column(String(10), nullable=True)
state_code: Mapped[str | None] = mapped_column(String(2), nullable=True)
billing_address: Mapped[str | None] = mapped_column(Text, nullable=True)
contact_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
contact_mobile: Mapped[str | None] = mapped_column(String(50), nullable=True)
website_url: Mapped[str | None] = mapped_column(String(255), nullable=True)
invoice_title: Mapped[str | None] = mapped_column(String(80), nullable=True)
invoice_number_format: Mapped[str | None] = mapped_column(String(120), nullable=True)
default_due_days: Mapped[int] = mapped_column(Integer, nullable=False, default=15)
default_sac_code: Mapped[str | None] = mapped_column(String(20), nullable=True)
bank_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
bank_account_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
bank_account_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
bank_ifsc: Mapped[str | None] = mapped_column(String(20), nullable=True)
upi_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
bank_details: Mapped[str | None] = mapped_column(Text, nullable=True)
payumoney_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
payumoney_mode: Mapped[str] = mapped_column(String(20), nullable=False, default="TEST")
payumoney_merchant_key: Mapped[str | None] = mapped_column(String(120), nullable=True)
payumoney_merchant_salt: Mapped[str | None] = mapped_column(String(200), nullable=True)
payumoney_merchant_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
payumoney_product_info: Mapped[str | None] = mapped_column(String(200), nullable=True)
cashfree_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
cashfree_mode: Mapped[str] = mapped_column(String(20), nullable=False, default="TEST")
cashfree_client_id: Mapped[str | None] = mapped_column(String(180), nullable=True)
cashfree_client_secret: Mapped[str | None] = mapped_column(String(240), nullable=True)
cashfree_api_version: Mapped[str] = mapped_column(String(20), nullable=False, default="2023-08-01")
cashfree_order_note: Mapped[str | None] = mapped_column(String(250), nullable=True)
authorised_signatory_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
declaration: Mapped[str | None] = mapped_column(Text, nullable=True)
terms: Mapped[str | None] = mapped_column(Text, nullable=True)
footer_note: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
class BillingInvoiceGenerationBatch(CommonBase):
__tablename__ = "billing_invoice_generation_batches"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True)
billing_period_from: Mapped[date] = mapped_column(Date, nullable=False, index=True)
billing_period_to: Mapped[date] = mapped_column(Date, nullable=False, index=True)
financial_year: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
frequency: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="DRAFT_CREATED", index=True)
selected_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_invoice_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
skipped_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
error_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
remarks: Mapped[str | None] = mapped_column(Text, nullable=True)
generated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
generated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
invoices = relationship("BillingInvoice", back_populates="generation_batch")
class BillingInvoice(CommonBase):
__tablename__ = "billing_invoices"
__table_args__ = (
UniqueConstraint("tenant_id", "invoice_no", name="uq_billing_invoices_tenant_invoice_no"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True)
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="RESTRICT"), nullable=False, index=True)
engagement_id: Mapped[int | None] = mapped_column(ForeignKey("client_service_subscriptions.id", ondelete="SET NULL"), nullable=True, index=True)
generation_batch_id: Mapped[int | None] = mapped_column(ForeignKey("billing_invoice_generation_batches.id", ondelete="SET NULL"), nullable=True, index=True)
invoice_no: Mapped[str] = mapped_column(String(60), nullable=False, index=True)
invoice_date: Mapped[date] = mapped_column(Date, nullable=False, default=date.today, index=True)
due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
billing_period_from: Mapped[date | None] = mapped_column(Date, nullable=True)
billing_period_to: Mapped[date | None] = mapped_column(Date, nullable=True)
financial_year: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
invoice_title: Mapped[str | None] = mapped_column(String(80), nullable=True)
place_of_supply: Mapped[str | None] = mapped_column(String(120), nullable=True)
reverse_charge: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
client_legal_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
client_trade_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
client_gstin: Mapped[str | None] = mapped_column(String(20), nullable=True)
client_pan: Mapped[str | None] = mapped_column(String(20), nullable=True)
client_billing_address: Mapped[str | None] = mapped_column(Text, nullable=True)
client_state: Mapped[str | None] = mapped_column(String(100), nullable=True)
client_state_code: Mapped[str | None] = mapped_column(String(2), nullable=True)
client_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
client_mobile: Mapped[str | None] = mapped_column(String(50), nullable=True)
tax_type: Mapped[str] = mapped_column(String(20), nullable=False, default="CGST_SGST")
subtotal: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
discount_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
taxable_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
cgst_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
sgst_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
igst_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
round_off: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
total_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
amount_received: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
tds_deducted: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
bank_charges: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
balance_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
amount_in_words: Mapped[str | None] = mapped_column(String(500), nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="DRAFT", index=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
terms: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
approved_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
posted_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
client = relationship("Client")
engagement = relationship("ClientServiceSubscription", foreign_keys=[engagement_id])
generation_batch = relationship("BillingInvoiceGenerationBatch", back_populates="invoices")
lines = relationship("BillingInvoiceLine", back_populates="invoice", cascade="all, delete-orphan", order_by="BillingInvoiceLine.sort_order.asc()")
payments = relationship("BillingPayment", back_populates="invoice", cascade="all, delete-orphan", order_by="BillingPayment.payment_date.desc(), BillingPayment.id.desc()")
online_transactions = relationship("BillingOnlinePaymentTransaction", back_populates="invoice", cascade="all, delete-orphan", order_by="BillingOnlinePaymentTransaction.created_at_utc.desc(), BillingOnlinePaymentTransaction.id.desc()")
class BillingPayment(CommonBase):
__tablename__ = "billing_payments"
__table_args__ = (
UniqueConstraint("tenant_id", "receipt_no", name="uq_billing_payments_tenant_receipt_no"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True)
invoice_id: Mapped[int] = mapped_column(ForeignKey("billing_invoices.id", ondelete="CASCADE"), nullable=False, index=True)
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="RESTRICT"), nullable=False, index=True)
receipt_no: Mapped[str] = mapped_column(String(60), nullable=False, index=True)
receipt_date: Mapped[date] = mapped_column(Date, nullable=False, default=date.today, index=True)
payment_date: Mapped[date] = mapped_column(Date, nullable=False, default=date.today, index=True)
financial_year: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
amount_received: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
tds_deducted: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
bank_charges: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
mode: Mapped[str] = mapped_column(String(30), nullable=False, default="BANK")
reference_no: Mapped[str | None] = mapped_column(String(120), nullable=True)
payment_gateway: Mapped[str | None] = mapped_column(String(50), nullable=True)
gateway_transaction_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
remarks: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="RECEIVED", index=True)
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
invoice = relationship("BillingInvoice", back_populates="payments")
client = relationship("Client")
class BillingOnlinePaymentTransaction(CommonBase):
__tablename__ = "billing_online_payment_transactions"
__table_args__ = (
UniqueConstraint("tenant_id", "txnid", name="uq_billing_online_payment_tenant_txnid"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True)
invoice_id: Mapped[int] = mapped_column(ForeignKey("billing_invoices.id", ondelete="CASCADE"), nullable=False, index=True)
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="RESTRICT"), nullable=False, index=True)
provider: Mapped[str] = mapped_column(String(40), nullable=False, default="PAYUMONEY", index=True)
mode: Mapped[str] = mapped_column(String(20), nullable=False, default="TEST")
txnid: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
productinfo: Mapped[str | None] = mapped_column(String(250), nullable=True)
firstname: Mapped[str | None] = mapped_column(String(120), nullable=True)
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
phone: Mapped[str | None] = mapped_column(String(50), nullable=True)
payu_payment_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
cashfree_order_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
cashfree_cf_order_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
cashfree_payment_session_id: Mapped[str | None] = mapped_column(String(500), nullable=True)
cashfree_payment_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
webhook_event_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
bank_ref_num: Mapped[str | None] = mapped_column(String(120), nullable=True)
mihpayid: Mapped[str | None] = mapped_column(String(120), nullable=True)
status: Mapped[str] = mapped_column(String(30), nullable=False, default="INITIATED", index=True)
gateway_status: Mapped[str | None] = mapped_column(String(80), nullable=True)
response_hash: Mapped[str | None] = mapped_column(String(200), nullable=True)
raw_response: Mapped[str | None] = mapped_column(Text, nullable=True)
receipt_payment_id: Mapped[int | None] = mapped_column(ForeignKey("billing_payments.id", ondelete="SET NULL"), nullable=True, index=True)
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
completed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
invoice = relationship("BillingInvoice", back_populates="online_transactions")
client = relationship("Client")
receipt_payment = relationship("BillingPayment", foreign_keys=[receipt_payment_id])
class BillingInvoiceLine(CommonBase):
__tablename__ = "billing_invoice_lines"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
invoice_id: Mapped[int] = mapped_column(ForeignKey("billing_invoices.id", ondelete="CASCADE"), nullable=False, index=True)
service_id: Mapped[int | None] = mapped_column(ForeignKey("service_catalogues.id", ondelete="SET NULL"), nullable=True, index=True)
engagement_id: Mapped[int | None] = mapped_column(ForeignKey("client_service_subscriptions.id", ondelete="SET NULL"), nullable=True, index=True)
fee_group_id: Mapped[int | None] = mapped_column(ForeignKey("billing_fee_groups.id", ondelete="SET NULL"), nullable=True, index=True)
description: Mapped[str] = mapped_column(String(500), nullable=False)
sac_code: Mapped[str | None] = mapped_column(String(20), nullable=True)
billing_period_from: Mapped[date | None] = mapped_column(Date, nullable=True)
billing_period_to: Mapped[date | None] = mapped_column(Date, nullable=True)
quantity: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False, default=Decimal("1.00"))
rate: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
discount_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
taxable_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
gst_rate: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=Decimal("18.00"))
cgst_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
sgst_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
igst_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
line_total: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
invoice = relationship("BillingInvoice", back_populates="lines")
service = relationship("ServiceCatalogue")
class BillingFeeGroup(CommonBase):
__tablename__ = "billing_fee_groups"
__table_args__ = (
UniqueConstraint("tenant_id", "group_code", name="uq_billing_fee_groups_tenant_code"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True)
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True)
partner_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
group_code: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
group_name: Mapped[str] = mapped_column(String(200), nullable=False)
billing_mode: Mapped[str] = mapped_column(String(20), nullable=False, default="PACKAGE")
frequency: Mapped[str] = mapped_column(String(20), nullable=False, default="Monthly")
fee_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
gst_rate: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=Decimal("18.00"))
tax_type: Mapped[str] = mapped_column(String(20), nullable=False, default="CGST_SGST")
effective_from: Mapped[date | None] = mapped_column(Date, nullable=True)
effective_to: Mapped[date | None] = mapped_column(Date, nullable=True)
auto_generate: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
client = relationship("Client")
services = relationship("BillingFeeGroupService", back_populates="fee_group", cascade="all, delete-orphan", order_by="BillingFeeGroupService.sort_order.asc()")
class BillingFeeGroupService(CommonBase):
__tablename__ = "billing_fee_group_services"
__table_args__ = (
UniqueConstraint("fee_group_id", "service_id", name="uq_billing_fee_group_services_group_service"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
fee_group_id: Mapped[int] = mapped_column(ForeignKey("billing_fee_groups.id", ondelete="CASCADE"), nullable=False, index=True)
service_id: Mapped[int] = mapped_column(ForeignKey("service_catalogues.id", ondelete="RESTRICT"), nullable=False, index=True)
line_description: Mapped[str | None] = mapped_column(String(500), nullable=True)
allocation_type: Mapped[str] = mapped_column(String(20), nullable=False, default="Included")
line_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
percentage: Mapped[Decimal | None] = mapped_column(Numeric(5, 2), nullable=True)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
fee_group = relationship("BillingFeeGroup", back_populates="services")
service = relationship("ServiceCatalogue")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,63 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
{% include "modules/clients/templates/clients/_client_tabs.html" %}
<div class="space-y-6">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="text-2xl font-semibold text-slate-900">Invoice {{ invoice.invoice_no }}</h2>
<p class="mt-1 text-sm text-slate-500">Issued on {{ invoice.invoice_date.strftime('%d-%m-%Y') if invoice.invoice_date else '-' }}{% if invoice.due_date %} • Due {{ invoice.due_date.strftime('%d-%m-%Y') }}{% endif %}</p>
</div>
<div class="flex flex-wrap gap-2">
<a href="/client/billing" class="af-btn af-btn-secondary">Back to Bills</a>
<a href="/client/billing/{{ invoice.id }}/print" class="af-btn af-btn-secondary">Print / Save PDF</a>
{% if invoice.balance_amount and invoice.balance_amount > 0 %}<a href="/client/billing/{{ invoice.id }}/pay-now" class="af-btn af-btn-primary">Pay Now</a>{% endif %}
</div>
</div>
<section class="grid gap-4 md:grid-cols-4">
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Invoice Total</div><div class="mt-2 text-2xl font-semibold">₹ {{ '%.2f'|format(invoice.total_amount or 0) }}</div></div>
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Received</div><div class="mt-2 text-2xl font-semibold text-emerald-700">₹ {{ '%.2f'|format(invoice.amount_received or 0) }}</div></div>
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">TDS</div><div class="mt-2 text-2xl font-semibold text-slate-900">₹ {{ '%.2f'|format(invoice.tds_deducted or 0) }}</div></div>
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Balance</div><div class="mt-2 text-2xl font-semibold text-amber-700">₹ {{ '%.2f'|format(invoice.balance_amount or 0) }}</div></div>
</section>
<div class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<div class="af-card">
<div class="flex items-center justify-between gap-3"><h3 class="text-lg font-semibold text-slate-900">Invoice Lines</h3><span class="af-badge {% if invoice.status == 'PAID' %}af-badge-success{% else %}af-badge-warning{% endif %}">{{ invoice.status.replace('_', ' ') }}</span></div>
<div class="mt-5 overflow-x-auto rounded-2xl border border-slate-200">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500"><tr><th class="px-4 py-3">Description</th><th class="px-4 py-3">SAC</th><th class="px-4 py-3 text-right">Taxable</th><th class="px-4 py-3 text-right">GST</th><th class="px-4 py-3 text-right">Total</th></tr></thead>
<tbody class="divide-y divide-slate-100 bg-white">
{% for line in invoice.lines %}
<tr><td class="px-4 py-3 font-medium text-slate-900 whitespace-pre-line">{{ line.description }}</td><td class="px-4 py-3 text-slate-600">{{ line.sac_code or '-' }}</td><td class="px-4 py-3 text-right">₹ {{ '%.2f'|format(line.taxable_amount or 0) }}</td><td class="px-4 py-3 text-right">₹ {{ '%.2f'|format((line.cgst_amount or 0) + (line.sgst_amount or 0) + (line.igst_amount or 0)) }}</td><td class="px-4 py-3 text-right font-semibold">₹ {{ '%.2f'|format(line.line_total or 0) }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<aside class="space-y-6">
<div class="af-card">
<h3 class="text-base font-semibold text-slate-900">Payment Status</h3>
<div class="mt-4 space-y-3 text-sm">
<div class="flex justify-between"><span class="text-slate-500">Status</span><span class="font-semibold">{{ invoice.status.replace('_', ' ') }}</span></div>
<div class="flex justify-between"><span class="text-slate-500">Due Amount</span><span class="font-semibold text-amber-700">₹ {{ '%.2f'|format(invoice.balance_amount or 0) }}</span></div>
{% if invoice.balance_amount and invoice.balance_amount > 0 %}<a href="/client/billing/{{ invoice.id }}/pay-now" class="mt-2 w-full justify-center af-btn af-btn-primary">Pay Now</a>{% endif %}
</div>
</div>
<div class="af-card">
<h3 class="text-base font-semibold text-slate-900">Receipts</h3>
<div class="mt-4 space-y-3 text-sm">
{% for p in invoice.payments %}
{% if p.status == 'RECEIVED' %}
<a href="/client/billing/receipts/{{ p.id }}" class="block rounded-2xl border border-slate-200 p-3 hover:bg-slate-50"><div class="font-semibold text-brand-700">{{ p.receipt_no }}</div><div class="mt-1 text-xs text-slate-500">{{ p.payment_date.strftime('%d-%m-%Y') if p.payment_date else '-' }} • ₹ {{ '%.2f'|format(p.amount_received or 0) }}</div></a>
{% endif %}
{% else %}
<div class="rounded-2xl border border-dashed border-slate-300 p-4 text-slate-500">No receipts recorded yet.</div>
{% endfor %}
</div>
</div>
</aside>
</div>
</div>
{% endblock %}
@@ -0,0 +1,75 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
{% include "modules/clients/templates/clients/_client_tabs.html" %}
<div class="space-y-6">
<section class="rounded-3xl bg-gradient-to-r from-brand-700 to-slate-900 p-6 text-white shadow-soft">
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-brand-100">Client Portal</p>
<h2 class="mt-2 text-2xl font-semibold">My Bills & Payments</h2>
<p class="mt-2 max-w-3xl text-sm text-brand-100">View invoices issued by your audit firm, download receipts and use Pay Now for pending bills.</p>
<p class="mt-1 text-xs text-brand-100">Active FY: {{ active_financial_year or 'All Years' }}</p>
</div>
{% if billing_latest_due_invoice %}
<a href="/client/billing/{{ billing_latest_due_invoice.id }}/pay-now" class="rounded-xl bg-white px-4 py-2 text-sm font-semibold text-brand-700 hover:bg-brand-50">Pay Latest Due</a>
{% endif %}
</div>
</section>
<section class="grid gap-4 md:grid-cols-3">
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Outstanding</div><div class="mt-2 text-3xl font-semibold text-amber-700">₹ {{ '%.2f'|format(billing_outstanding_amount or 0) }}</div><div class="mt-1 text-xs text-slate-500">{{ billing_open_count or 0 }} open bill(s)</div></div>
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Total Invoices</div><div class="mt-2 text-3xl font-semibold text-slate-900">{{ billing_total_count or 0 }}</div><div class="mt-1 text-xs text-slate-500">Issued by firm</div></div>
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Paid</div><div class="mt-2 text-3xl font-semibold text-emerald-700">{{ billing_paid_count or 0 }}</div><div class="mt-1 text-xs text-slate-500">Completed payments</div></div>
</section>
<div class="af-card">
<div class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<h3 class="text-lg font-semibold text-slate-900">Invoices</h3>
<p class="mt-1 text-sm text-slate-500">Draft and cancelled invoices are not shown in the client portal.</p>
</div>
<form method="get" action="/client/billing" class="flex flex-col gap-2 sm:flex-row sm:items-center">
<input name="q" value="{{ q or '' }}" placeholder="Search invoice no" class="rounded-xl border border-slate-300 px-3 py-2 text-sm" />
<select name="include_paid" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
<option value="yes" {% if include_paid != 'no' %}selected{% endif %}>All invoices</option>
<option value="no" {% if include_paid == 'no' %}selected{% endif %}>Only pending</option>
</select>
<button class="af-btn af-btn-secondary" type="submit">Filter</button>
</form>
</div>
<div class="mt-5 overflow-x-auto rounded-2xl border border-slate-200">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
<tr>
<th class="px-4 py-3">Invoice</th>
<th class="px-4 py-3">Date</th><th class="px-4 py-3">FY</th>
<th class="px-4 py-3">Due Date</th>
<th class="px-4 py-3 text-right">Total</th>
<th class="px-4 py-3 text-right">Balance</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3 text-right">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 bg-white">
{% for row in rows %}
<tr class="hover:bg-slate-50">
<td class="px-4 py-3 font-semibold text-slate-900"><a class="text-brand-700 hover:underline" href="/client/billing/{{ row.id }}">{{ row.invoice_no }}</a></td>
<td class="px-4 py-3 text-slate-600">{{ row.invoice_date.strftime('%d-%m-%Y') if row.invoice_date else '-' }}</td>
<td class="px-4 py-3 text-slate-600">{{ row.due_date.strftime('%d-%m-%Y') if row.due_date else '-' }}</td>
<td class="px-4 py-3 text-right font-medium">₹ {{ '%.2f'|format(row.total_amount or 0) }}</td>
<td class="px-4 py-3 text-right font-medium {% if row.balance_amount and row.balance_amount > 0 %}text-amber-700{% else %}text-emerald-700{% endif %}">₹ {{ '%.2f'|format(row.balance_amount or 0) }}</td>
<td class="px-4 py-3"><span class="af-badge {% if row.status == 'PAID' %}af-badge-success{% elif row.status == 'OVERDUE' %}af-badge-danger{% else %}af-badge-warning{% endif %}">{{ row.status.replace('_', ' ') }}</span></td>
<td class="px-4 py-3 text-right">
{% if row.balance_amount and row.balance_amount > 0 %}<a href="/client/billing/{{ row.id }}/pay-now" class="af-btn af-btn-primary">Pay Now</a>{% else %}<a href="/client/billing/{{ row.id }}" class="af-btn af-btn-secondary">View</a>{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="7" class="px-4 py-8 text-center text-slate-500">No invoices found.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,94 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
{% include "modules/clients/templates/clients/_client_tabs.html" %}
<div class="mx-auto max-w-4xl space-y-6">
<section class="rounded-3xl bg-gradient-to-r from-brand-700 to-slate-900 p-6 text-white shadow-soft">
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-brand-100">Pay Now</p>
<h2 class="mt-2 text-2xl font-semibold">Invoice {{ invoice.invoice_no }}</h2>
<p class="mt-2 text-sm text-brand-100">Pay the outstanding amount using online gateway, UPI or bank transfer. Online gateway receipts are created automatically after successful verification.</p>
</section>
<div class="grid gap-6 md:grid-cols-[minmax(0,1fr)_320px]">
<div class="af-card">
<h3 class="text-lg font-semibold text-slate-900">Payment Options</h3>
<div class="mt-5 rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
<div class="font-semibold">Amount payable: ₹ {{ '%.2f'|format(amount_due or 0) }}</div>
<div class="mt-1">Invoice balance only is shown here. TDS or bank charges will be adjusted by the firm while recording receipt.</div>
</div>
{% if payumoney_enabled %}
<div class="mt-5 rounded-2xl border border-emerald-200 bg-emerald-50 p-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div class="text-sm font-semibold text-emerald-900">Online Payment Gateway</div>
<p class="mt-1 text-sm text-emerald-800">Pay securely through PayUMoney / PayU. Receipt will be created automatically after successful confirmation.</p>
{% if payumoney_mode != 'LIVE' %}<p class="mt-1 text-xs font-semibold text-amber-700">Currently running in TEST mode.</p>{% endif %}
</div>
<form method="post" action="/client/billing/{{ invoice.id }}/payumoney/start">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<button type="submit" class="af-btn af-btn-primary whitespace-nowrap">Pay Online</button>
</form>
</div>
</div>
{% endif %}
{% if cashfree_enabled %}
<div class="mt-5 rounded-2xl border border-sky-200 bg-sky-50 p-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div class="text-sm font-semibold text-sky-900">Cashfree Payment Gateway</div>
<p class="mt-1 text-sm text-sky-800">Pay securely through Cashfree checkout. Receipt will be created automatically after successful confirmation.</p>
{% if cashfree_mode != 'LIVE' %}<p class="mt-1 text-xs font-semibold text-amber-700">Currently running in TEST / Sandbox mode.</p>{% endif %}
</div>
<form method="post" action="/client/billing/{{ invoice.id }}/cashfree/start">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<button type="submit" class="af-btn af-btn-primary whitespace-nowrap">Pay with Cashfree</button>
</form>
</div>
</div>
{% endif %}
{% if upi_link %}
<div class="mt-5 rounded-2xl border border-brand-200 bg-brand-50 p-4">
<div class="text-sm font-semibold text-brand-800">UPI Payment</div>
<div class="mt-2 text-sm text-slate-700">UPI ID: <span class="font-semibold">{{ upi_id }}</span></div>
<a href="{{ upi_link }}" class="mt-4 inline-flex af-btn af-btn-primary">Open UPI App</a>
<p class="mt-3 text-xs text-slate-500">This opens a UPI app on supported devices. After payment, share the UTR/reference number with the firm if requested.</p>
</div>
{% endif %}
<div class="mt-5 rounded-2xl border border-slate-200 p-4">
<div class="text-sm font-semibold text-slate-900">Bank Transfer</div>
<dl class="mt-3 grid gap-3 text-sm sm:grid-cols-2">
<div><dt class="text-xs uppercase text-slate-500">Bank</dt><dd class="font-medium">{{ bank_name or '-' }}</dd></div>
<div><dt class="text-xs uppercase text-slate-500">Account Name</dt><dd class="font-medium">{{ bank_account_name or '-' }}</dd></div>
<div><dt class="text-xs uppercase text-slate-500">Account No.</dt><dd class="font-medium">{{ bank_account_number or '-' }}</dd></div>
<div><dt class="text-xs uppercase text-slate-500">IFSC</dt><dd class="font-medium">{{ bank_ifsc or '-' }}</dd></div>
</dl>
{% if payment_instructions %}<div class="mt-4 whitespace-pre-line rounded-xl bg-slate-50 p-3 text-sm text-slate-700">{{ payment_instructions }}</div>{% endif %}
</div>
</div>
<aside class="space-y-6">
<div class="af-card">
<h3 class="text-base font-semibold text-slate-900">Invoice Summary</h3>
<div class="mt-4 space-y-3 text-sm">
<div class="flex justify-between"><span class="text-slate-500">Invoice Total</span><span class="font-semibold">₹ {{ '%.2f'|format(invoice.total_amount or 0) }}</span></div>
<div class="flex justify-between"><span class="text-slate-500">Received</span><span class="font-semibold">₹ {{ '%.2f'|format(invoice.amount_received or 0) }}</span></div>
<div class="flex justify-between"><span class="text-slate-500">TDS</span><span class="font-semibold">₹ {{ '%.2f'|format(invoice.tds_deducted or 0) }}</span></div>
<div class="border-t border-slate-200 pt-3 flex justify-between"><span class="text-slate-500">Balance</span><span class="font-semibold text-amber-700">₹ {{ '%.2f'|format(invoice.balance_amount or 0) }}</span></div>
</div>
<div class="mt-5 grid gap-2">
<a href="/client/billing/{{ invoice.id }}" class="af-btn af-btn-secondary justify-center">View Invoice</a>
<a href="/client/billing/{{ invoice.id }}/print" class="af-btn af-btn-secondary justify-center">Print / Save PDF</a>
</div>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-4 text-xs leading-5 text-slate-500 shadow-soft">
{% if payumoney_enabled or cashfree_enabled %}Online gateway confirmation is enabled. UPI/bank transfer can still be used when the client prefers manual payment.{% else %}Online gateway is not enabled yet. This page helps the client pay through UPI/bank details and the firm records receipt manually.{% endif %}
</div>
</aside>
</div>
</div>
{% endblock %}
@@ -0,0 +1,27 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
{% include "modules/clients/templates/clients/_client_tabs.html" %}
<div class="mx-auto max-w-3xl space-y-6">
<section class="af-card p-6">
<p class="text-xs font-semibold uppercase tracking-[0.22em] {% if result == 'success' %}text-emerald-700{% else %}text-rose-700{% endif %}">PayUMoney Payment</p>
<h1 class="mt-2 text-2xl font-bold text-slate-900">{{ heading }}</h1>
<p class="mt-2 text-sm text-slate-600">{{ message }}</p>
{% if transaction %}
<dl class="mt-5 grid gap-3 rounded-2xl bg-slate-50 p-4 text-sm sm:grid-cols-2">
<div><dt class="text-xs uppercase text-slate-500">Invoice</dt><dd class="font-semibold">{{ transaction.invoice.invoice_no }}</dd></div>
<div><dt class="text-xs uppercase text-slate-500">Amount</dt><dd class="font-semibold">₹ {{ '%.2f'|format(transaction.amount or 0) }}</dd></div>
<div><dt class="text-xs uppercase text-slate-500">Txn ID</dt><dd class="font-mono text-xs font-semibold">{{ transaction.txnid }}</dd></div>
<div><dt class="text-xs uppercase text-slate-500">Gateway Status</dt><dd class="font-semibold">{{ transaction.gateway_status or transaction.status }}</dd></div>
{% if transaction.bank_ref_num %}<div><dt class="text-xs uppercase text-slate-500">Bank Ref.</dt><dd class="font-semibold">{{ transaction.bank_ref_num }}</dd></div>{% endif %}
{% if transaction.mihpayid %}<div><dt class="text-xs uppercase text-slate-500">PayU ID</dt><dd class="font-semibold">{{ transaction.mihpayid }}</dd></div>{% endif %}
</dl>
{% endif %}
<div class="mt-6 flex flex-wrap gap-3">
{% if transaction %}<a href="/client/billing/{{ transaction.invoice_id }}" class="af-btn af-btn-primary">View Invoice</a>{% endif %}
<a href="/client/billing" class="af-btn af-btn-secondary">Back to My Bills</a>
</div>
</section>
</div>
{% endblock %}
@@ -0,0 +1,124 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<h1 class="text-2xl font-semibold text-slate-900">Create GST Invoice</h1>
<p class="mt-1 text-sm text-slate-500">Prepare a professional tax invoice with SAC, GST breakup, place of supply and firm billing defaults.</p>
<p class="mt-1 text-xs text-slate-400">Invoice will be tagged to active FY: <span class="font-semibold text-slate-600">{{ active_financial_year or 'Current FY' }}</span></p>
</div>
<a href="/billing/settings" class="af-btn af-btn-secondary">Billing Settings</a>
</div>
<form method="post" class="space-y-6 af-card">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<section class="space-y-4">
<div class="af-panel-header">
<div>
<h2 class="text-base font-semibold text-slate-900">Invoice Header</h2>
<p class="text-xs text-slate-500">Client, date, GST treatment and billing period.</p>
</div>
<span class="af-badge af-badge-info">{{ settings.invoice_title or 'Tax Invoice' }}</span>
</div>
<div class="grid gap-4 md:grid-cols-3">
<label class="block">
<span class="text-sm font-medium text-slate-700">Client</span>
<select name="client_id" required class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
<option value="">Select client</option>
{% for client in clients %}
<option value="{{ client.id }}">{{ client.client_code }} - {{ client.client_name }}</option>
{% endfor %}
</select>
</label>
<label class="block">
<span class="text-sm font-medium text-slate-700">Invoice Date</span>
<input type="date" name="invoice_date" value="{{ today }}" required class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
</label>
<label class="block">
<span class="text-sm font-medium text-slate-700">Due Date</span>
<input type="date" name="due_date" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
</label>
<label class="block">
<span class="text-sm font-medium text-slate-700">Billing Period From</span>
<input type="date" name="billing_period_from" value="{{ default_billing_period_from or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
</label>
<label class="block">
<span class="text-sm font-medium text-slate-700">Billing Period To</span>
<input type="date" name="billing_period_to" value="{{ default_billing_period_to or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
</label>
<label class="block">
<span class="text-sm font-medium text-slate-700">Tax Type</span>
<select name="tax_type" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
{% for tax_type in tax_types %}<option value="{{ tax_type }}" {% if settings.default_tax_type == tax_type %}selected{% endif %}>{{ tax_type }}</option>{% endfor %}
</select>
</label>
<label class="block">
<span class="text-sm font-medium text-slate-700">Place of Supply</span>
<input name="place_of_supply" placeholder="State / Union Territory" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
</label>
<label class="block">
<span class="text-sm font-medium text-slate-700">Client State Code</span>
<input name="client_state_code" maxlength="2" placeholder="e.g. 33" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
</label>
<label class="mt-7 inline-flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="reverse_charge" value="yes" class="rounded border-slate-300" />
Reverse charge applicable
</label>
</div>
</section>
<section class="space-y-3">
<div class="af-panel-header">
<div>
<h2 class="text-base font-semibold text-slate-900">Invoice Lines</h2>
<p class="text-xs text-slate-500">SAC defaults to billing settings if left blank. Blank description rows are ignored.</p>
</div>
</div>
<div class="overflow-x-auto rounded-xl border border-slate-200">
<table class="min-w-full text-sm">
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase text-slate-500">
<tr>
<th class="px-3 py-2">Service</th>
<th class="px-3 py-2">Description</th>
<th class="px-3 py-2">SAC</th>
<th class="px-3 py-2">Qty</th>
<th class="px-3 py-2">Rate</th>
<th class="px-3 py-2">Discount</th>
<th class="px-3 py-2">GST %</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for i in range(1, 8) %}
<tr>
<td class="px-3 py-2">
<select name="line_service_id" class="w-48 rounded-lg border border-slate-300 px-2 py-1.5">
<option value="">No service</option>
{% for service in services %}<option value="{{ service.id }}">{{ service.service_code }} - {{ service.service_name }}</option>{% endfor %}
</select>
</td>
<td class="px-3 py-2"><input name="line_description" class="w-80 rounded-lg border border-slate-300 px-2 py-1.5" placeholder="Professional fees / service description" /></td>
<td class="px-3 py-2"><input name="line_sac_code" value="{{ settings.default_sac_code or '' }}" class="w-24 rounded-lg border border-slate-300 px-2 py-1.5" /></td>
<td class="px-3 py-2"><input name="line_quantity" value="1" class="w-20 rounded-lg border border-slate-300 px-2 py-1.5" /></td>
<td class="px-3 py-2"><input name="line_rate" value="0" class="w-28 rounded-lg border border-slate-300 px-2 py-1.5" /></td>
<td class="px-3 py-2"><input name="line_discount" value="0" class="w-28 rounded-lg border border-slate-300 px-2 py-1.5" /></td>
<td class="px-3 py-2"><input name="line_gst_rate" value="{{ settings.default_gst_rate or 18 }}" class="w-20 rounded-lg border border-slate-300 px-2 py-1.5" /></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
<div class="grid gap-4 md:grid-cols-2">
<label class="block"><span class="text-sm font-medium text-slate-700">Declaration / Notes</span><textarea name="notes" rows="3" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ settings.declaration or '' }}</textarea></label>
<label class="block"><span class="text-sm font-medium text-slate-700">Terms & Conditions</span><textarea name="terms" rows="3" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ settings.terms or '' }}</textarea></label>
</div>
<div class="flex justify-end gap-3">
<a href="/billing" class="af-btn af-btn-secondary">Cancel</a>
<button class="af-btn af-btn-primary">Save Draft Invoice</button>
</div>
</form>
</div>
{% endblock %}
@@ -0,0 +1,120 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<h1 class="text-2xl font-semibold text-slate-900">{{ invoice_ctx.invoice_title }} {{ invoice.invoice_no }}</h1>
<p class="mt-1 text-sm text-slate-500">{{ invoice.client_legal_name or (invoice.client.client_name if invoice.client else '') }} • {{ invoice.invoice_date }}</p>
</div>
<div class="flex flex-wrap gap-2">
{% if invoice.status == 'DRAFT' %}
<form method="post" action="/billing/{{ invoice.id }}/issue">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<button class="af-btn af-btn-primary">Issue Invoice</button>
</form>
{% endif %}
{% if can_record_payment and invoice.status not in ['DRAFT','CANCELLED','PAID'] %}
<a href="/billing/{{ invoice.id }}/payments/new" class="af-btn af-btn-primary">Record Payment</a>
{% endif %}
<a href="/billing/{{ invoice.id }}/print" target="_blank" class="af-btn af-btn-secondary">Print / PDF</a>
<a href="/billing" class="af-btn af-btn-secondary">Back</a>
</div>
</div>
<div class="af-card space-y-6">
<div class="grid gap-4 md:grid-cols-4 lg:grid-cols-7">
<div><div class="text-xs uppercase text-slate-500">Status</div><div class="font-semibold">{{ invoice.status }}</div></div>
<div><div class="text-xs uppercase text-slate-500">Due Date</div><div class="font-semibold">{{ invoice.due_date or '-' }}</div></div>
<div><div class="text-xs uppercase text-slate-500">Place of Supply</div><div class="font-semibold">{{ invoice.place_of_supply or '-' }}</div></div>
<div><div class="text-xs uppercase text-slate-500">Total</div><div class="font-semibold">₹ {{ '%.2f'|format(invoice.total_amount or 0) }}</div></div>
<div><div class="text-xs uppercase text-slate-500">Amount Received</div><div class="font-semibold text-emerald-700">₹ {{ '%.2f'|format(invoice.amount_received or 0) }}</div></div>
<div><div class="text-xs uppercase text-slate-500">TDS Deducted</div><div class="font-semibold text-blue-700">₹ {{ '%.2f'|format(invoice.tds_deducted or 0) }}</div></div>
<div><div class="text-xs uppercase text-slate-500">Balance</div><div class="font-semibold {% if invoice.balance_amount and invoice.balance_amount > 0 %}text-amber-700{% else %}text-emerald-700{% endif %}">₹ {{ '%.2f'|format(invoice.balance_amount or 0) }}</div></div>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div class="rounded-xl border border-slate-200 p-4">
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Supplier</h2>
<div class="mt-2 font-semibold text-slate-900">{{ invoice_ctx.firm_name }}</div>
<div class="text-sm text-slate-600 whitespace-pre-line">{{ invoice_ctx.firm_address or '-' }}</div>
<div class="mt-2 text-sm text-slate-600">GSTIN: {{ invoice_ctx.firm_gstin or '-' }} • PAN: {{ invoice_ctx.firm_pan or '-' }}</div>
</div>
<div class="rounded-xl border border-slate-200 p-4">
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Bill To</h2>
<div class="mt-2 font-semibold text-slate-900">{{ invoice.client_legal_name or '-' }}</div>
<div class="text-sm text-slate-600">{{ invoice.client_billing_address or '-' }}</div>
<div class="mt-2 text-sm text-slate-600">GSTIN: {{ invoice.client_gstin or '-' }} • PAN: {{ invoice.client_pan or '-' }}</div>
</div>
</div>
</div>
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase text-slate-500">
<tr><th class="px-4 py-3">Description</th><th class="px-4 py-3">SAC</th><th class="px-4 py-3 text-right">Qty</th><th class="px-4 py-3 text-right">Rate</th><th class="px-4 py-3 text-right">Taxable</th><th class="px-4 py-3 text-right">GST</th><th class="px-4 py-3 text-right">Total</th></tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for line in invoice.lines %}
<tr>
<td class="px-4 py-3"><div class="font-medium text-slate-900">{{ line.description }}</div><div class="text-xs text-slate-500">{{ line.service.service_name if line.service else '' }}</div></td>
<td class="px-4 py-3">{{ line.sac_code or '-' }}</td>
<td class="px-4 py-3 text-right">{{ line.quantity }}</td>
<td class="px-4 py-3 text-right">₹ {{ '%.2f'|format(line.rate or 0) }}</td>
<td class="px-4 py-3 text-right">₹ {{ '%.2f'|format(line.taxable_amount or 0) }}</td>
<td class="px-4 py-3 text-right">{{ line.gst_rate }}%</td>
<td class="px-4 py-3 text-right font-semibold">₹ {{ '%.2f'|format(line.line_total or 0) }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot class="bg-slate-50 text-sm font-semibold">
<tr><td colspan="6" class="px-4 py-3 text-right">Subtotal</td><td class="px-4 py-3 text-right">₹ {{ '%.2f'|format(invoice.subtotal or 0) }}</td></tr>
<tr><td colspan="6" class="px-4 py-3 text-right">Discount</td><td class="px-4 py-3 text-right">₹ {{ '%.2f'|format(invoice.discount_amount or 0) }}</td></tr>
<tr><td colspan="6" class="px-4 py-3 text-right">Taxable Value</td><td class="px-4 py-3 text-right">₹ {{ '%.2f'|format(invoice.taxable_amount or 0) }}</td></tr>
<tr><td colspan="6" class="px-4 py-3 text-right">CGST</td><td class="px-4 py-3 text-right">₹ {{ '%.2f'|format(invoice.cgst_amount or 0) }}</td></tr>
<tr><td colspan="6" class="px-4 py-3 text-right">SGST</td><td class="px-4 py-3 text-right">₹ {{ '%.2f'|format(invoice.sgst_amount or 0) }}</td></tr>
<tr><td colspan="6" class="px-4 py-3 text-right">IGST</td><td class="px-4 py-3 text-right">₹ {{ '%.2f'|format(invoice.igst_amount or 0) }}</td></tr>
<tr><td colspan="6" class="px-4 py-3 text-right text-base">Grand Total</td><td class="px-4 py-3 text-right text-base">₹ {{ '%.2f'|format(invoice.total_amount or 0) }}</td></tr>
</tfoot>
</table>
</div>
<div class="af-card space-y-4">
<div class="flex items-center justify-between gap-3">
<div>
<h2 class="font-semibold text-slate-900">Payment History</h2>
<p class="text-sm text-slate-500">Receipts, TDS deductions and outstanding balance for this invoice.</p>
</div>
{% if can_record_payment and invoice.status not in ['DRAFT','CANCELLED','PAID'] %}
<a href="/billing/{{ invoice.id }}/payments/new" class="af-btn af-btn-primary">Record Payment</a>
{% endif %}
</div>
<div class="overflow-hidden rounded-xl border border-slate-200">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase text-slate-500">
<tr><th class="px-4 py-3">Receipt</th><th class="px-4 py-3">Date</th><th class="px-4 py-3">Mode</th><th class="px-4 py-3">Reference</th><th class="px-4 py-3 text-right">Received</th><th class="px-4 py-3 text-right">TDS</th><th class="px-4 py-3"></th></tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for payment in invoice.payments %}
<tr>
<td class="px-4 py-3 font-medium text-slate-900">{{ payment.receipt_no }}</td>
<td class="px-4 py-3 text-slate-600">{{ payment.payment_date }}</td>
<td class="px-4 py-3 text-slate-600">{{ payment.mode }}</td>
<td class="px-4 py-3 text-slate-600">{{ payment.reference_no or '-' }}</td>
<td class="px-4 py-3 text-right font-semibold">₹ {{ '%.2f'|format(payment.amount_received or 0) }}</td>
<td class="px-4 py-3 text-right">₹ {{ '%.2f'|format(payment.tds_deducted or 0) }}</td>
<td class="px-4 py-3 text-right"><a href="/billing/payments/{{ payment.id }}/receipt" target="_blank" class="text-brand-600 hover:underline">Receipt</a></td>
</tr>
{% else %}
<tr><td colspan="7" class="px-4 py-6 text-center text-slate-500">No payments recorded yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div class="af-card"><h2 class="font-semibold text-slate-900">Amount in Words</h2><p class="mt-2 text-sm text-slate-600">{{ invoice.amount_in_words or '-' }}</p></div>
<div class="af-card"><h2 class="font-semibold text-slate-900">Bank / UPI Details</h2><p class="mt-2 text-sm text-slate-600 whitespace-pre-line">{% if invoice_ctx.bank_name %}{{ invoice_ctx.bank_name }}{% endif %}{% if invoice_ctx.bank_account_number %}\nA/c: {{ invoice_ctx.bank_account_number }}{% endif %}{% if invoice_ctx.bank_ifsc %}\nIFSC: {{ invoice_ctx.bank_ifsc }}{% endif %}{% if invoice_ctx.upi_id %}\nUPI: {{ invoice_ctx.upi_id }}{% endif %}</p></div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,53 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex items-start justify-between">
<div>
<h1 class="text-2xl font-semibold text-slate-900">Import Fee Structure</h1>
<p class="mt-1 text-sm text-slate-500">Upload Excel with Fee_Structure and Fee_Services sheets.</p>
</div>
<div class="flex flex-wrap gap-2">
<a href="/billing/fee-structures/template" class="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-2 text-sm font-medium text-emerald-800 shadow-sm hover:bg-emerald-100">Download Excel Template</a>
<a href="/billing/fee-structures/list" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">Fee Structure List</a>
</div>
</div>
{% if result %}
<div class="rounded-2xl border {{ 'border-emerald-200 bg-emerald-50 text-emerald-900' if result.success else 'border-red-200 bg-red-50 text-red-900' }} p-4">
{% if result.success %}
<div class="font-semibold">Import completed</div>
<div class="mt-1 text-sm">Created: {{ result.created }} | Updated: {{ result.updated }}</div>
{% else %}
<div class="font-semibold">Import failed</div>
<ul class="mt-2 list-disc pl-5 text-sm">
{% for error in result.errors %}<li>{{ error }}</li>{% endfor %}
</ul>
{% endif %}
</div>
{% endif %}
<form method="post" enctype="multipart/form-data" class="space-y-5 rounded-2xl border border-slate-200 bg-white p-6 shadow-soft">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<label class="block">
<span class="text-sm font-medium text-slate-700">Excel File</span>
<input type="file" name="import_file" accept=".xlsx,.xlsm" required class="mt-1 block w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
</label>
<div class="rounded-xl bg-slate-50 p-4 text-sm text-slate-600">
<div class="font-semibold text-slate-800">How to import using template</div>
<ol class="mt-2 list-decimal space-y-1 pl-5">
<li>Click <strong>Download Excel Template</strong>.</li>
<li>Fill <strong>Fee_Structure</strong> for client-wise package/header details.</li>
<li>Fill <strong>Fee_Services</strong> for services included in each package.</li>
<li>Upload the completed file here. Imported fee structures can then be used in <strong>Generate Bills</strong>.</li>
</ol>
<div class="mt-4 font-semibold text-slate-800">Required sheets</div>
<div class="mt-1">Fee_Structure: client, billing group, mode, frequency, fee and tax details.</div>
<div>Fee_Services: services included in each billing group.</div>
</div>
<div class="flex justify-end gap-3">
<a href="/billing/fee-structures/list" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700">Back</a>
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Import Fee Structure</button>
</div>
</form>
</div>
{% endblock %}
@@ -0,0 +1,63 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-semibold text-slate-900">Fee Structure</h1>
<p class="mt-1 text-sm text-slate-500">Client-wise billing packages with multiple services grouped for future invoice generation.</p>
</div>
<div class="flex flex-wrap gap-2">
<a href="/billing/generate" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">Generate Bills</a>
<a href="/billing" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">Invoices</a>
{% if can_import %}
<a href="/billing/fee-structures/template" class="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-2 text-sm font-medium text-emerald-800 shadow-sm hover:bg-emerald-100">Download Template</a>
<a href="/billing/fee-structures/import" class="rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white shadow-soft hover:bg-emerald-700">Import Using Template</a>
{% endif %}
</div>
</div>
<form method="get" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
<div class="flex gap-3">
<input name="q" value="{{ q or '' }}" placeholder="Search group, client code or client name" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none" />
<button class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white">Search</button>
</div>
</form>
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
<tr>
<th class="px-4 py-3">Group Code</th>
<th class="px-4 py-3">Client</th>
<th class="px-4 py-3">Package</th>
<th class="px-4 py-3">Mode</th>
<th class="px-4 py-3">Frequency</th>
<th class="px-4 py-3 text-right">Fee</th>
<th class="px-4 py-3">Services</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for row in rows %}
<tr class="align-top hover:bg-slate-50">
<td class="px-4 py-3 font-medium text-slate-900">{{ row.group_code }}</td>
<td class="px-4 py-3 text-slate-700">{{ row.client.client_name if row.client else row.client_id }}</td>
<td class="px-4 py-3 text-slate-700">{{ row.group_name }}</td>
<td class="px-4 py-3"><span class="rounded-full bg-slate-100 px-2 py-1 text-xs">{{ row.billing_mode }}</span></td>
<td class="px-4 py-3">{{ row.frequency }}</td>
<td class="px-4 py-3 text-right font-semibold">₹ {{ '%.2f'|format(row.fee_amount or 0) }}</td>
<td class="px-4 py-3 text-xs text-slate-600">
{% for item in row.services %}
<div>{{ item.service.service_code if item.service else item.service_id }} - {{ item.line_description or (item.service.service_name if item.service else '') }}</div>
{% else %}
<span class="text-slate-400">No services mapped</span>
{% endfor %}
</td>
</tr>
{% else %}
<tr><td colspan="7" class="px-4 py-8 text-center text-slate-500">No fee structures found.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
@@ -0,0 +1,182 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-semibold text-slate-900">Generate Draft Invoices</h1>
<p class="mt-1 text-sm text-slate-500">Create draft GST invoices from fee structures and automatically link matching client service subscriptions / engagements for the selected financial year. Existing invoices for the same fee group and period are skipped by default.</p>
</div>
<div class="flex gap-2">
<a href="/billing" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">Invoices</a>
<a href="/billing/fee-structures/list" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">Fee Structure</a>
</div>
</div>
{% if result %}
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
<h2 class="text-lg font-semibold text-slate-900">Generation Result</h2>
<div class="mt-3 grid gap-3 sm:grid-cols-3">
<div class="rounded-xl bg-emerald-50 p-3 text-sm text-emerald-800"><div class="text-xs uppercase tracking-wide">Draft invoices created</div><div class="mt-1 text-2xl font-bold">{{ result.created|length }}</div></div>
<div class="rounded-xl bg-amber-50 p-3 text-sm text-amber-800"><div class="text-xs uppercase tracking-wide">Skipped</div><div class="mt-1 text-2xl font-bold">{{ result.skipped|length }}</div></div>
<div class="rounded-xl bg-rose-50 p-3 text-sm text-rose-800"><div class="text-xs uppercase tracking-wide">Errors</div><div class="mt-1 text-2xl font-bold">{{ result.errors|length }}</div></div>
</div>
{% if result.created %}
<div class="mt-4">
<div class="text-sm font-semibold text-slate-700">Created Draft Invoices</div>
<div class="mt-2 overflow-hidden rounded-xl border border-slate-200">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500"><tr><th class="px-3 py-2">Invoice</th><th class="px-3 py-2">Client</th><th class="px-3 py-2 text-right">Amount</th></tr></thead>
<tbody class="divide-y divide-slate-100">
{% for invoice in result.created %}
<tr>
<td class="px-3 py-2"><a href="/billing/{{ invoice.id }}" class="font-semibold text-brand-700 hover:underline">{{ invoice.invoice_no }}</a></td>
<td class="px-3 py-2">{{ invoice.client.client_name if invoice.client else invoice.client_id }}</td>
<td class="px-3 py-2 text-right">₹ {{ '%.2f'|format(invoice.total_amount or 0) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% if result.skipped %}
<div class="mt-4 rounded-xl bg-amber-50 p-3 text-sm text-amber-800">
<div class="font-semibold">Skipped rows</div>
<ul class="mt-1 list-disc space-y-1 pl-5">
{% for item in result.skipped %}<li>{{ item }}</li>{% endfor %}
</ul>
</div>
{% endif %}
{% if result.errors %}
<div class="mt-4 rounded-xl bg-rose-50 p-3 text-sm text-rose-800">
<div class="font-semibold">Errors</div>
<ul class="mt-1 list-disc space-y-1 pl-5">
{% for item in result.errors %}<li>{{ item }}</li>{% endfor %}
</ul>
</div>
{% endif %}
</div>
{% endif %}
<div class="rounded-2xl border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900">
<div class="font-semibold">Engagement-to-invoice refinement</div>
<div class="mt-1">This screen continues to use your existing fee-structure billing logic. During generation, the system checks the client, service and active financial year ({{ active_financial_year or 'current FY' }}) and links the invoice / invoice lines to the matching client service subscription wherever available. No duplicate module is created.</div>
</div>
<form method="get" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
<div class="grid gap-3 lg:grid-cols-6">
<div>
<label class="text-xs font-semibold uppercase tracking-wide text-slate-500">Frequency</label>
<select name="frequency" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none">
<option value="">All</option>
{% for f in frequencies %}<option value="{{ f }}" {% if frequency == f %}selected{% endif %}>{{ f }}</option>{% endfor %}
</select>
</div>
<div>
<label class="text-xs font-semibold uppercase tracking-wide text-slate-500">Period From</label>
<input type="date" name="billing_period_from" value="{{ billing_period_from }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none" />
</div>
<div>
<label class="text-xs font-semibold uppercase tracking-wide text-slate-500">Period To</label>
<input type="date" name="billing_period_to" value="{{ billing_period_to }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none" />
</div>
<div>
<label class="text-xs font-semibold uppercase tracking-wide text-slate-500">Auto Generate</label>
<select name="auto_generate_only" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none">
<option value="yes" {% if auto_generate_only != 'no' %}selected{% endif %}>Only Yes</option>
<option value="no" {% if auto_generate_only == 'no' %}selected{% endif %}>All Active</option>
</select>
</div>
<div class="lg:col-span-2">
<label class="text-xs font-semibold uppercase tracking-wide text-slate-500">Search</label>
<div class="mt-1 flex gap-2">
<input name="q" value="{{ q or '' }}" placeholder="Client / group code / package" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none" />
<button class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white">Filter</button>
</div>
</div>
</div>
</form>
<form method="post" class="rounded-2xl border border-slate-200 bg-white shadow-soft">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="frequency" value="{{ frequency or '' }}" />
<input type="hidden" name="billing_period_from" value="{{ billing_period_from }}" />
<input type="hidden" name="billing_period_to" value="{{ billing_period_to }}" />
<input type="hidden" name="auto_generate_only" value="{{ auto_generate_only }}" />
<input type="hidden" name="q" value="{{ q or '' }}" />
<div class="flex flex-col gap-3 border-b border-slate-200 p-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<div class="font-semibold text-slate-900">Eligible Fee Structures</div>
<div class="text-sm text-slate-500">Select packages and create draft invoices for {{ billing_period_from }} to {{ billing_period_to }}.</div>
</div>
<label class="inline-flex items-center gap-2 text-sm text-slate-600">
<input type="checkbox" name="skip_duplicates" value="yes" checked class="rounded border-slate-300 text-brand-600" />
Skip duplicates
</label>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
<tr>
<th class="px-4 py-3"><input type="checkbox" onclick="document.querySelectorAll('.fee-check').forEach(cb => cb.checked = this.checked && !cb.disabled)" /></th>
<th class="px-4 py-3">Group Code</th>
<th class="px-4 py-3">Client</th>
<th class="px-4 py-3">Package</th>
<th class="px-4 py-3">Services / Engagement Source</th>
<th class="px-4 py-3">Mode</th>
<th class="px-4 py-3 text-right">Fee</th>
<th class="px-4 py-3">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for row in rows %}
{% set duplicate = duplicate_map.get(row.id) %}
<tr class="align-top hover:bg-slate-50">
<td class="px-4 py-3"><input class="fee-check rounded border-slate-300 text-brand-600" type="checkbox" name="fee_group_ids" value="{{ row.id }}" {% if duplicate %}disabled{% endif %} /></td>
<td class="px-4 py-3 font-medium text-slate-900">{{ row.group_code }}</td>
<td class="px-4 py-3 text-slate-700">{{ row.client.client_name if row.client else row.client_id }}</td>
<td class="px-4 py-3 text-slate-700">
<div class="font-medium text-slate-900">{{ row.group_name }}</div>
<div class="mt-1 text-xs text-slate-500">{{ row.frequency }} billing</div>
</td>
<td class="px-4 py-3 text-xs text-slate-600">
{% if row.services %}
<div class="flex flex-wrap gap-1">
{% for item in row.services[:4] %}
<span class="rounded-full bg-slate-100 px-2 py-1">{{ item.service.service_name if item.service else item.service_id }}</span>
{% endfor %}
{% if row.services|length > 4 %}<span class="rounded-full bg-slate-100 px-2 py-1">+{{ row.services|length - 4 }}</span>{% endif %}
</div>
<div class="mt-1 text-[11px] text-slate-400">Matching active subscriptions are linked during generation.</div>
{% else %}
<span class="text-slate-400">Package line only</span>
{% endif %}
</td>
<td class="px-4 py-3"><span class="rounded-full bg-slate-100 px-2 py-1 text-xs">{{ row.billing_mode }}</span></td>
<td class="px-4 py-3 text-right font-semibold">₹ {{ '%.2f'|format(row.fee_amount or 0) }}</td>
<td class="px-4 py-3 text-xs">
{% if duplicate %}
<span class="rounded-full bg-amber-100 px-2 py-1 font-medium text-amber-800">Already billed: {{ duplicate.invoice_no }}</span>
{% else %}
<span class="rounded-full bg-emerald-100 px-2 py-1 font-medium text-emerald-800">Ready</span>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="8" class="px-4 py-8 text-center text-slate-500">No eligible fee structures found for the selected filter.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="flex justify-end border-t border-slate-200 p-4">
<button class="rounded-xl bg-brand-600 px-5 py-2 text-sm font-semibold text-white shadow-soft hover:bg-brand-700">Generate Draft Invoices</button>
</div>
</form>
</div>
{% endblock %}
@@ -0,0 +1,120 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ invoice_ctx.invoice_title }} {{ invoice.invoice_no }}</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
@page { size: A4; margin: 14mm; }
@media print { .no-print { display: none !important; } body { background: white !important; } }
</style>
</head>
<body class="bg-slate-100 text-slate-900">
<div class="no-print mx-auto my-4 flex max-w-5xl justify-end gap-2">
<button onclick="window.print()" class="rounded-lg bg-slate-900 px-4 py-2 text-sm font-semibold text-white">Print / Save PDF</button>
<a href="/billing/{{ invoice.id }}" class="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700">Back</a>
</div>
<main class="mx-auto max-w-5xl bg-white p-8 shadow print:shadow-none">
<header class="border-b-2 border-slate-900 pb-4">
<div class="flex items-start justify-between gap-6">
<div>
<div class="text-2xl font-bold">{{ invoice_ctx.firm_name }}</div>
<div class="mt-1 whitespace-pre-line text-sm text-slate-600">{{ invoice_ctx.firm_address or '' }}</div>
<div class="mt-2 text-sm text-slate-700">GSTIN: <b>{{ invoice_ctx.firm_gstin or '-' }}</b> | PAN: <b>{{ invoice_ctx.firm_pan or '-' }}</b></div>
<div class="text-sm text-slate-700">Email: {{ invoice_ctx.firm_contact_email or '-' }} | Mobile: {{ invoice_ctx.firm_contact_mobile or '-' }}</div>
</div>
<div class="text-right">
<div class="text-2xl font-bold uppercase">{{ invoice_ctx.invoice_title }}</div>
<div class="mt-2 text-sm">Invoice No: <b>{{ invoice.invoice_no }}</b></div>
<div class="text-sm">Invoice Date: <b>{{ invoice.invoice_date }}</b></div>
<div class="text-sm">Due Date: <b>{{ invoice.due_date or '-' }}</b></div>
<div class="text-sm">Status: <b>{{ invoice.status }}</b></div>
</div>
</div>
</header>
<section class="mt-5 grid grid-cols-2 gap-4 text-sm">
<div class="rounded-lg border border-slate-300 p-4">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Bill To</div>
<div class="mt-2 text-base font-bold">{{ invoice.client_legal_name or '-' }}</div>
<div class="mt-1 text-slate-700">{{ invoice.client_billing_address or '-' }}</div>
<div class="mt-2">GSTIN: <b>{{ invoice.client_gstin or '-' }}</b></div>
<div>PAN: <b>{{ invoice.client_pan or '-' }}</b></div>
</div>
<div class="rounded-lg border border-slate-300 p-4">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Tax Particulars</div>
<div class="mt-2">Place of Supply: <b>{{ invoice.place_of_supply or '-' }}</b></div>
<div>Tax Type: <b>{{ invoice.tax_type }}</b></div>
<div>Reverse Charge: <b>{{ 'Yes' if invoice.reverse_charge else 'No' }}</b></div>
<div>Client State Code: <b>{{ invoice.client_state_code or '-' }}</b></div>
</div>
</section>
<table class="mt-5 w-full border-collapse text-sm">
<thead>
<tr class="bg-slate-100">
<th class="border border-slate-300 px-2 py-2 text-left">#</th>
<th class="border border-slate-300 px-2 py-2 text-left">Description</th>
<th class="border border-slate-300 px-2 py-2 text-left">SAC</th>
<th class="border border-slate-300 px-2 py-2 text-right">Qty</th>
<th class="border border-slate-300 px-2 py-2 text-right">Rate</th>
<th class="border border-slate-300 px-2 py-2 text-right">Taxable</th>
<th class="border border-slate-300 px-2 py-2 text-right">GST %</th>
<th class="border border-slate-300 px-2 py-2 text-right">Total</th>
</tr>
</thead>
<tbody>
{% for line in invoice.lines %}
<tr>
<td class="border border-slate-300 px-2 py-2">{{ loop.index }}</td>
<td class="border border-slate-300 px-2 py-2">{{ line.description }}</td>
<td class="border border-slate-300 px-2 py-2">{{ line.sac_code or '-' }}</td>
<td class="border border-slate-300 px-2 py-2 text-right">{{ line.quantity }}</td>
<td class="border border-slate-300 px-2 py-2 text-right">{{ '%.2f'|format(line.rate or 0) }}</td>
<td class="border border-slate-300 px-2 py-2 text-right">{{ '%.2f'|format(line.taxable_amount or 0) }}</td>
<td class="border border-slate-300 px-2 py-2 text-right">{{ line.gst_rate }}</td>
<td class="border border-slate-300 px-2 py-2 text-right">{{ '%.2f'|format(line.line_total or 0) }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr><td colspan="7" class="border border-slate-300 px-2 py-2 text-right font-semibold">Subtotal</td><td class="border border-slate-300 px-2 py-2 text-right font-semibold">{{ '%.2f'|format(invoice.subtotal or 0) }}</td></tr>
<tr><td colspan="7" class="border border-slate-300 px-2 py-2 text-right font-semibold">Discount</td><td class="border border-slate-300 px-2 py-2 text-right font-semibold">{{ '%.2f'|format(invoice.discount_amount or 0) }}</td></tr>
<tr><td colspan="7" class="border border-slate-300 px-2 py-2 text-right font-semibold">Taxable Value</td><td class="border border-slate-300 px-2 py-2 text-right font-semibold">{{ '%.2f'|format(invoice.taxable_amount or 0) }}</td></tr>
<tr><td colspan="7" class="border border-slate-300 px-2 py-2 text-right font-semibold">CGST</td><td class="border border-slate-300 px-2 py-2 text-right font-semibold">{{ '%.2f'|format(invoice.cgst_amount or 0) }}</td></tr>
<tr><td colspan="7" class="border border-slate-300 px-2 py-2 text-right font-semibold">SGST</td><td class="border border-slate-300 px-2 py-2 text-right font-semibold">{{ '%.2f'|format(invoice.sgst_amount or 0) }}</td></tr>
<tr><td colspan="7" class="border border-slate-300 px-2 py-2 text-right font-semibold">IGST</td><td class="border border-slate-300 px-2 py-2 text-right font-semibold">{{ '%.2f'|format(invoice.igst_amount or 0) }}</td></tr>
<tr class="bg-slate-100"><td colspan="7" class="border border-slate-300 px-2 py-2 text-right text-base font-bold">Grand Total</td><td class="border border-slate-300 px-2 py-2 text-right text-base font-bold">₹ {{ '%.2f'|format(invoice.total_amount or 0) }}</td></tr>
</tfoot>
</table>
<section class="mt-5 grid grid-cols-2 gap-4 text-sm">
<div class="rounded-lg border border-slate-300 p-4">
<div class="font-semibold">Amount in Words</div>
<div class="mt-1">{{ invoice.amount_in_words or '-' }}</div>
</div>
<div class="rounded-lg border border-slate-300 p-4">
<div class="font-semibold">Payment Details</div>
<div class="mt-1">Bank: {{ invoice_ctx.bank_name or '-' }}</div>
<div>A/c: {{ invoice_ctx.bank_account_number or '-' }}</div>
<div>IFSC: {{ invoice_ctx.bank_ifsc or '-' }}</div>
<div>UPI: {{ invoice_ctx.upi_id or '-' }}</div>
</div>
</section>
<section class="mt-5 text-sm">
{% if invoice_ctx.terms %}<div><b>Terms:</b> {{ invoice_ctx.terms }}</div>{% endif %}
{% if invoice_ctx.declaration %}<div class="mt-2"><b>Declaration:</b> {{ invoice_ctx.declaration }}</div>{% endif %}
</section>
<footer class="mt-12 flex items-end justify-between text-sm">
<div>{{ invoice_ctx.footer_note or '' }}</div>
<div class="text-center">
<div class="mb-10">For {{ invoice_ctx.firm_name }}</div>
<div class="border-t border-slate-500 px-8 pt-2">{{ invoice_ctx.authorised_signatory_name or 'Authorised Signatory' }}</div>
</div>
</footer>
</main>
</body>
</html>
@@ -0,0 +1,81 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-semibold text-slate-900">Billing Invoices</h1>
<p class="mt-1 text-sm text-slate-500">Create, issue and print GST-ready client invoices with SAC and tax breakup.</p>
<p class="mt-1 text-xs text-slate-400">Showing billing records for active FY: <span class="font-semibold text-slate-600">{{ active_financial_year or 'All Years' }}</span></p>
</div>
<div class="flex flex-wrap gap-2">
{% if can_generate %}
<a href="/billing/generate" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">Generate Bills</a>
{% endif %}
{% if can_view_fee_structure %}
<a href="/billing/fee-structures/list" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">Fee Structure</a>
{% endif %}
{% if can_import_fee_structure %}
<a href="/billing/fee-structures/template" class="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-2 text-sm font-medium text-emerald-800 shadow-sm hover:bg-emerald-100">Download Fee Template</a>
<a href="/billing/fee-structures/import" class="rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white shadow-soft hover:bg-emerald-700">Import Fee Excel</a>
{% endif %}
<a href="/billing/payments" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">Payments</a>
<a href="/billing/settings" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">Billing Settings</a>
{% if can_create %}
<a href="/billing/new" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white shadow-soft hover:bg-brand-700">New Invoice</a>
{% endif %}
</div>
</div>
<form method="get" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
<div class="flex gap-3">
<input name="q" value="{{ q or '' }}" placeholder="Search invoice no, client code or client name" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none" />
<button class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white">Search</button>
</div>
</form>
{% if report_summary %}
<section class="grid gap-4 md:grid-cols-4">
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Billed</div><div class="mt-2 text-2xl font-semibold text-slate-900">₹ {{ '%.2f'|format(report_summary.total_billed or 0) }}</div><div class="mt-1 text-xs text-slate-500">{{ report_summary.invoice_count }} invoice(s)</div></div>
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Collected + TDS</div><div class="mt-2 text-2xl font-semibold text-emerald-700">₹ {{ '%.2f'|format(report_summary.total_collected_with_tds or 0) }}</div><div class="mt-1 text-xs text-slate-500">{{ report_summary.payment_count }} receipt(s)</div></div>
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Outstanding</div><div class="mt-2 text-2xl font-semibold text-amber-700">₹ {{ '%.2f'|format(report_summary.outstanding or 0) }}</div><div class="mt-1 text-xs text-slate-500">Active issued bills</div></div>
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Status</div><div class="mt-2 text-sm font-semibold text-slate-800">Draft {{ report_summary.draft_count }} · Open {{ report_summary.issued_count }} · Paid {{ report_summary.paid_count }}</div><div class="mt-1 text-xs text-slate-500">FY-filtered billing report</div></div>
</section>
{% endif %}
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
<tr>
<th class="px-4 py-3">Invoice No</th>
<th class="px-4 py-3">Date</th>
<th class="px-4 py-3">FY</th>
<th class="px-4 py-3">Client</th>
<th class="px-4 py-3 text-right">Amount</th>
<th class="px-4 py-3 text-right">Received/TDS</th>
<th class="px-4 py-3 text-right">Balance</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for row in rows %}
<tr class="hover:bg-slate-50">
<td class="px-4 py-3 font-medium text-slate-900">{{ row.invoice_no }}</td>
<td class="px-4 py-3 text-slate-600">{{ row.invoice_date }}</td>
<td class="px-4 py-3 text-xs text-slate-500">{{ row.financial_year or '-' }}</td>
<td class="px-4 py-3 text-slate-700">{{ row.client.client_name if row.client else row.client_id }}</td>
<td class="px-4 py-3 text-right font-semibold">₹ {{ '%.2f'|format(row.total_amount or 0) }}</td>
<td class="px-4 py-3 text-right text-slate-700">₹ {{ '%.2f'|format((row.amount_received or 0) + (row.tds_deducted or 0)) }}</td>
<td class="px-4 py-3 text-right font-semibold {% if row.balance_amount and row.balance_amount > 0 %}text-amber-700{% else %}text-emerald-700{% endif %}">₹ {{ '%.2f'|format(row.balance_amount or row.total_amount or 0) }}</td>
<td class="px-4 py-3"><span class="rounded-full bg-slate-100 px-2 py-1 text-xs font-medium text-slate-700">{{ row.status }}</span></td>
<td class="px-4 py-3 text-right"><div class="flex justify-end gap-3"><a href="/billing/{{ row.id }}" class="text-brand-600 hover:underline">View</a>{% if can_record_payment and row.status not in ['DRAFT','CANCELLED','PAID'] %}<a href="/billing/{{ row.id }}/payments/new" class="text-emerald-700 hover:underline">Payment</a>{% endif %}<a href="/billing/{{ row.id }}/print" target="_blank" class="text-slate-600 hover:underline">Print</a></div></td>
</tr>
{% else %}
<tr><td colspan="9" class="px-4 py-8 text-center text-slate-500">No invoices found.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
@@ -0,0 +1,22 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div><h1 class="text-2xl font-semibold text-slate-900">Payments & Receipts</h1><p class="mt-1 text-sm text-slate-500">Track invoice collections, TDS deductions and receipt printouts.</p><p class="mt-1 text-xs text-slate-400">Showing receipts for active FY: <span class="font-semibold text-slate-600">{{ active_financial_year or 'All Years' }}</span></p></div>
<a href="/billing" class="af-btn af-btn-secondary">Invoices</a>
</div>
<form method="get" class="af-card"><div class="flex gap-3"><input name="q" value="{{ q or '' }}" placeholder="Search receipt, invoice or client" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" /><button class="af-btn af-btn-primary">Search</button></div></form>
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase text-slate-500"><tr><th class="px-4 py-3">Receipt</th><th class="px-4 py-3">Invoice</th><th class="px-4 py-3">Client</th><th class="px-4 py-3">Date</th><th class="px-4 py-3">FY</th><th class="px-4 py-3">Mode</th><th class="px-4 py-3 text-right">Received</th><th class="px-4 py-3 text-right">TDS</th><th class="px-4 py-3"></th></tr></thead>
<tbody class="divide-y divide-slate-100">
{% for row in rows %}
<tr><td class="px-4 py-3 font-medium">{{ row.receipt_no }}</td><td class="px-4 py-3">{{ row.invoice.invoice_no if row.invoice else row.invoice_id }}</td><td class="px-4 py-3">{{ row.client.client_name if row.client else row.client_id }}</td><td class="px-4 py-3">{{ row.payment_date }}</td><td class="px-4 py-3 text-xs text-slate-500">{{ row.financial_year or '-' }}</td><td class="px-4 py-3">{{ row.mode }}</td><td class="px-4 py-3 text-right font-semibold">₹ {{ '%.2f'|format(row.amount_received or 0) }}</td><td class="px-4 py-3 text-right">₹ {{ '%.2f'|format(row.tds_deducted or 0) }}</td><td class="px-4 py-3 text-right"><a href="/billing/payments/{{ row.id }}/receipt" target="_blank" class="text-brand-600 hover:underline">Receipt</a></td></tr>
{% else %}
<tr><td colspan="9" class="px-4 py-8 text-center text-slate-500">No payments recorded.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
@@ -0,0 +1,24 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6 max-w-4xl">
<div>
<h1 class="text-2xl font-semibold text-slate-900">Record Payment</h1>
<p class="mt-1 text-sm text-slate-500">Invoice {{ invoice.invoice_no }} • Balance ₹ {{ '%.2f'|format(invoice.balance_amount or invoice.total_amount or 0) }}</p>
</div>
<form method="post" class="af-card space-y-5">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<div class="grid gap-4 md:grid-cols-3">
<div><label class="text-sm font-medium text-slate-700">Payment date</label><input type="date" name="payment_date" value="{{ today }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" required /></div>
<div><label class="text-sm font-medium text-slate-700">Amount received</label><input type="number" step="0.01" name="amount_received" value="{{ '%.2f'|format(invoice.balance_amount or invoice.total_amount or 0) }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" /></div>
<div><label class="text-sm font-medium text-slate-700">TDS deducted</label><input type="number" step="0.01" name="tds_deducted" value="0.00" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" /></div>
</div>
<div class="grid gap-4 md:grid-cols-3">
<div><label class="text-sm font-medium text-slate-700">Bank charges</label><input type="number" step="0.01" name="bank_charges" value="0.00" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" /></div>
<div><label class="text-sm font-medium text-slate-700">Mode</label><select name="mode" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{% for mode in payment_modes %}<option value="{{ mode }}">{{ mode }}</option>{% endfor %}</select></div>
<div><label class="text-sm font-medium text-slate-700">Reference no.</label><input name="reference_no" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="UTR / cheque / transaction id" /></div>
</div>
<div><label class="text-sm font-medium text-slate-700">Remarks</label><textarea name="remarks" rows="3" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm"></textarea></div>
<div class="flex justify-end gap-2"><a href="/billing/{{ invoice.id }}" class="af-btn af-btn-secondary">Cancel</a><button class="af-btn af-btn-primary">Save & Print Receipt</button></div>
</form>
</div>
{% endblock %}
@@ -0,0 +1,19 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="mx-auto max-w-4xl bg-white p-8 print:p-0">
<div class="mb-4 flex justify-end print:hidden"><button onclick="window.print()" class="af-btn af-btn-primary">Print Receipt</button></div>
<div class="rounded-2xl border border-slate-300 p-8">
<div class="flex items-start justify-between border-b border-slate-200 pb-5">
<div><h1 class="text-2xl font-bold text-slate-900">{{ invoice_ctx.firm_name }}</h1><p class="mt-1 whitespace-pre-line text-sm text-slate-600">{{ invoice_ctx.firm_address or '' }}</p><p class="mt-1 text-sm text-slate-600">GSTIN: {{ invoice_ctx.firm_gstin or '-' }} • PAN: {{ invoice_ctx.firm_pan or '-' }}</p></div>
<div class="text-right"><div class="text-xl font-bold text-slate-900">Receipt</div><div class="mt-1 text-sm text-slate-600">{{ payment.receipt_no }}</div><div class="text-sm text-slate-600">{{ payment.receipt_date }}</div></div>
</div>
<div class="mt-6 grid gap-4 md:grid-cols-2">
<div><div class="text-xs font-semibold uppercase text-slate-500">Received From</div><div class="mt-1 font-semibold text-slate-900">{{ payment.client.client_name if payment.client else invoice.client_legal_name }}</div><div class="text-sm text-slate-600">Invoice: {{ invoice.invoice_no }}</div></div>
<div class="rounded-xl bg-slate-50 p-4"><div class="grid gap-2 text-sm"><div class="flex justify-between"><span>Amount Received</span><strong>₹ {{ '%.2f'|format(payment.amount_received or 0) }}</strong></div><div class="flex justify-between"><span>TDS Deducted</span><strong>₹ {{ '%.2f'|format(payment.tds_deducted or 0) }}</strong></div><div class="flex justify-between"><span>Bank Charges</span><strong>₹ {{ '%.2f'|format(payment.bank_charges or 0) }}</strong></div></div></div>
</div>
<div class="mt-6 grid gap-4 md:grid-cols-3 text-sm"><div><span class="text-slate-500">Mode</span><div class="font-semibold">{{ payment.mode }}</div></div><div><span class="text-slate-500">Payment Date</span><div class="font-semibold">{{ payment.payment_date }}</div></div><div><span class="text-slate-500">Reference</span><div class="font-semibold">{{ payment.reference_no or '-' }}</div></div></div>
{% if payment.remarks %}<div class="mt-6 rounded-xl border border-slate-200 p-4 text-sm text-slate-600">{{ payment.remarks }}</div>{% endif %}
<div class="mt-10 flex justify-end"><div class="text-center"><div class="h-12"></div><div class="border-t border-slate-400 px-8 pt-2 text-sm font-semibold">Authorised Signatory</div></div></div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,244 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-brand-600">Phase 7R.1</p>
<h1 class="text-2xl font-bold text-slate-900">Firm Billing Settings</h1>
<p class="mt-1 text-sm text-slate-500">Configure firm GST, invoice numbering, payment details and invoice footer defaults.</p>
</div>
<div class="rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm shadow-sm">
<div class="font-semibold text-slate-900">{{ tenant_name }}</div>
<div class="text-xs text-slate-500">{% if branch_name %}Branch: {{ branch_name }}{% else %}Firm-wide default{% endif %}</div>
<div class="mt-2 text-xs text-slate-500">Next invoice preview</div>
<div class="font-mono text-sm font-semibold text-brand-700">{{ preview_invoice_no }}</div>
</div>
</div>
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_320px]">
<form method="post" action="/billing/settings" class="space-y-6">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<section class="af-card p-5">
<div class="mb-4 flex items-center justify-between gap-3">
<div>
<h2 class="text-lg font-semibold text-slate-900">Scope</h2>
<p class="text-sm text-slate-500">Keep branch-specific settings for branch-wise invoice series, or use firm-wide default if you are working across branches.</p>
</div>
</div>
<div class="grid gap-4 md:grid-cols-2">
<label class="rounded-2xl border border-slate-200 p-4 text-sm">
<input type="radio" name="branch_scope" value="active" class="mr-2" {% if branch_scope != 'firm' %}checked{% endif %} />
Active branch settings
<div class="mt-1 text-xs text-slate-500">Recommended for branch-wise invoice numbering.</div>
</label>
<label class="rounded-2xl border border-slate-200 p-4 text-sm">
<input type="radio" name="branch_scope" value="firm" class="mr-2" {% if branch_scope == 'firm' %}checked{% endif %} />
Firm-wide default
<div class="mt-1 text-xs text-slate-500">Available when cross-branch billing permission is active.</div>
</label>
</div>
</section>
<section class="af-card p-5">
<h2 class="text-lg font-semibold text-slate-900">Firm GST & Contact Details</h2>
<div class="mt-4 grid gap-4 md:grid-cols-2">
<label class="text-sm font-medium text-slate-700">Legal / Billing Name
<input name="legal_name" value="{{ settings.legal_name or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
</label>
<label class="text-sm font-medium text-slate-700">GSTIN
<input name="gstin" value="{{ settings.gstin or '' }}" maxlength="15" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm uppercase" />
</label>
<label class="text-sm font-medium text-slate-700">PAN
<input name="pan" value="{{ settings.pan or '' }}" maxlength="10" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm uppercase" />
</label>
<label class="text-sm font-medium text-slate-700">State Code
<input name="state_code" value="{{ settings.state_code or '' }}" maxlength="2" placeholder="33" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
</label>
<label class="text-sm font-medium text-slate-700">Contact Email
<input name="contact_email" value="{{ settings.contact_email or '' }}" type="email" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
</label>
<label class="text-sm font-medium text-slate-700">Contact Mobile
<input name="contact_mobile" value="{{ settings.contact_mobile or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
</label>
<label class="text-sm font-medium text-slate-700 md:col-span-2">Website
<input name="website_url" value="{{ settings.website_url or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
</label>
<label class="text-sm font-medium text-slate-700 md:col-span-2">Billing Address
<textarea name="billing_address" rows="3" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ settings.billing_address or '' }}</textarea>
</label>
</div>
</section>
<section class="af-card p-5">
<h2 class="text-lg font-semibold text-slate-900">Invoice Numbering & Tax Defaults</h2>
<div class="mt-4 grid gap-4 md:grid-cols-3">
<label class="text-sm font-medium text-slate-700">Invoice Title
<input name="invoice_title" value="{{ settings.invoice_title or 'Tax Invoice' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
</label>
<label class="text-sm font-medium text-slate-700">Prefix
<input name="invoice_prefix" value="{{ settings.invoice_prefix or 'INV' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm uppercase" />
</label>
<label class="text-sm font-medium text-slate-700">Next Number
<input name="next_invoice_no" value="{{ settings.next_invoice_no or 1 }}" type="number" min="1" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
</label>
<label class="text-sm font-medium text-slate-700">Padding
<input name="padding" value="{{ settings.padding or 4 }}" type="number" min="1" max="10" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
</label>
<label class="text-sm font-medium text-slate-700 md:col-span-2">Number Format
<input name="invoice_number_format" value="{{ settings.invoice_number_format or '{prefix}/{fy}/{number}' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 font-mono text-sm" />
<span class="mt-1 block text-xs text-slate-500">Tokens: {prefix}, {fy}, {number}, {branch_id}</span>
</label>
<label class="text-sm font-medium text-slate-700">Default Due Days
<input name="default_due_days" value="{{ settings.default_due_days or 15 }}" type="number" min="0" max="365" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
</label>
<label class="text-sm font-medium text-slate-700">Default GST Rate %
<input name="default_gst_rate" value="{{ settings.default_gst_rate or '18.00' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
</label>
<label class="text-sm font-medium text-slate-700">Default Tax Type
<select name="default_tax_type" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
{% for tax in tax_types %}<option value="{{ tax }}" {% if settings.default_tax_type == tax %}selected{% endif %}>{{ tax }}</option>{% endfor %}
</select>
</label>
<label class="text-sm font-medium text-slate-700">Default SAC Code
<input name="default_sac_code" value="{{ settings.default_sac_code or '' }}" placeholder="9982" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
</label>
</div>
</section>
<section class="af-card p-5">
<h2 class="text-lg font-semibold text-slate-900">Bank, UPI & Payment Details</h2>
<div class="mt-4 grid gap-4 md:grid-cols-2">
<label class="text-sm font-medium text-slate-700">Bank Name
<input name="bank_name" value="{{ settings.bank_name or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
</label>
<label class="text-sm font-medium text-slate-700">Account Name
<input name="bank_account_name" value="{{ settings.bank_account_name or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
</label>
<label class="text-sm font-medium text-slate-700">Account Number
<input name="bank_account_number" value="{{ settings.bank_account_number or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
</label>
<label class="text-sm font-medium text-slate-700">IFSC
<input name="bank_ifsc" value="{{ settings.bank_ifsc or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm uppercase" />
</label>
<label class="text-sm font-medium text-slate-700 md:col-span-2">UPI ID
<input name="upi_id" value="{{ settings.upi_id or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
</label>
<label class="text-sm font-medium text-slate-700 md:col-span-2">Additional Bank Details / Payment Instructions
<textarea name="bank_details" rows="3" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ settings.bank_details or '' }}</textarea>
</label>
</div>
</section>
<section class="af-card p-5">
<h2 class="text-lg font-semibold text-slate-900">PayUMoney / PayU Online Payment Gateway</h2>
<p class="mt-1 text-sm text-slate-500">Enable this only after entering valid PayU/PayUMoney merchant credentials. Test mode posts to PayU test checkout.</p>
<div class="mt-4 grid gap-4 md:grid-cols-2">
<label class="flex items-center gap-3 rounded-2xl border border-slate-200 p-4 text-sm font-medium text-slate-700 md:col-span-2">
<input type="checkbox" name="payumoney_enabled" value="1" {% if settings.payumoney_enabled %}checked{% endif %} />
Enable PayUMoney / PayU Pay Now for client portal
</label>
<label class="text-sm font-medium text-slate-700">Mode
<select name="payumoney_mode" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
<option value="TEST" {% if settings.payumoney_mode != 'LIVE' %}selected{% endif %}>TEST / Sandbox</option>
<option value="LIVE" {% if settings.payumoney_mode == 'LIVE' %}selected{% endif %}>LIVE / Production</option>
</select>
</label>
<label class="text-sm font-medium text-slate-700">Merchant ID, optional
<input name="payumoney_merchant_id" value="{{ settings.payumoney_merchant_id or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
</label>
<label class="text-sm font-medium text-slate-700">Merchant Key
<input name="payumoney_merchant_key" value="{{ settings.payumoney_merchant_key or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" autocomplete="off" />
</label>
<label class="text-sm font-medium text-slate-700">Merchant Salt
<input name="payumoney_merchant_salt" value="{{ settings.payumoney_merchant_salt or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" autocomplete="off" />
</label>
<label class="text-sm font-medium text-slate-700 md:col-span-2">Product Info Label
<input name="payumoney_product_info" value="{{ settings.payumoney_product_info or 'Professional Services Invoice' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
</label>
</div>
<div class="mt-4 rounded-2xl border border-amber-200 bg-amber-50 p-4 text-xs leading-5 text-amber-900">
Store separate test and live credentials carefully. Do not enable LIVE until callback testing is completed from an accessible public URL.
</div>
</section>
<section class="af-card p-5">
<h2 class="text-lg font-semibold text-slate-900">Cashfree Online Payment Gateway</h2>
<p class="mt-1 text-sm text-slate-500">Enable Cashfree only after adding valid Cashfree PG credentials. Sandbox mode uses Cashfree sandbox APIs.</p>
<div class="mt-4 grid gap-4 md:grid-cols-2">
<label class="flex items-center gap-3 rounded-2xl border border-slate-200 p-4 text-sm font-medium text-slate-700 md:col-span-2">
<input type="checkbox" name="cashfree_enabled" value="1" {% if settings.cashfree_enabled %}checked{% endif %} />
Enable Cashfree Pay Now for client portal
</label>
<label class="text-sm font-medium text-slate-700">Mode
<select name="cashfree_mode" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
<option value="TEST" {% if settings.cashfree_mode != 'LIVE' %}selected{% endif %}>TEST / Sandbox</option>
<option value="LIVE" {% if settings.cashfree_mode == 'LIVE' %}selected{% endif %}>LIVE / Production</option>
</select>
</label>
<label class="text-sm font-medium text-slate-700">API Version
<input name="cashfree_api_version" value="{{ settings.cashfree_api_version or '2023-08-01' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
</label>
<label class="text-sm font-medium text-slate-700">Client ID / App ID
<input name="cashfree_client_id" value="{{ settings.cashfree_client_id or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" autocomplete="off" />
</label>
<label class="text-sm font-medium text-slate-700">Client Secret
<input name="cashfree_client_secret" value="{{ settings.cashfree_client_secret or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" autocomplete="off" />
</label>
<label class="text-sm font-medium text-slate-700 md:col-span-2">Order Note
<input name="cashfree_order_note" value="{{ settings.cashfree_order_note or 'Professional Services Invoice' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
</label>
</div>
<div class="mt-4 rounded-2xl border border-sky-200 bg-sky-50 p-4 text-xs leading-5 text-sky-900">
Cashfree checkout creates an order from the server and uses payment_session_id for hosted checkout. Webhook URL: <span class="font-mono">/client/billing/cashfree/webhook</span>
</div>
</section>
<section class="af-card p-5">
<h2 class="text-lg font-semibold text-slate-900">Invoice Notes, Terms & Signatory</h2>
<div class="mt-4 grid gap-4">
<label class="text-sm font-medium text-slate-700">Default Terms
<textarea name="terms" rows="3" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ settings.terms or '' }}</textarea>
</label>
<label class="text-sm font-medium text-slate-700">Declaration
<textarea name="declaration" rows="3" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ settings.declaration or '' }}</textarea>
</label>
<label class="text-sm font-medium text-slate-700">Invoice Footer Note
<textarea name="footer_note" rows="2" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ settings.footer_note or '' }}</textarea>
</label>
<label class="text-sm font-medium text-slate-700">Authorised Signatory Name
<input name="authorised_signatory_name" value="{{ settings.authorised_signatory_name or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
</label>
</div>
</section>
<div class="flex items-center justify-end gap-3">
<a href="/billing" class="af-btn af-btn-secondary">Back to Invoices</a>
{% if can_edit_settings %}
<button type="submit" class="af-btn af-btn-primary">Save Billing Settings</button>
{% else %}
<span class="text-sm text-slate-500">View-only access</span>
{% endif %}
</div>
</form>
<aside class="space-y-4">
<div class="af-card p-5">
<h3 class="font-semibold text-slate-900">Why this matters</h3>
<ul class="mt-3 space-y-2 text-sm text-slate-600">
<li>• GST invoice format will use these details in Phase 7R.2.</li>
<li>• Payment and receipt tracking will use bank/UPI details in Phase 7R.4.</li>
<li>• Client portal Pay Now uses UPI, PayUMoney and Cashfree settings from Phase 7R.5 / 7R.6 / 7R.6A.</li>
</ul>
</div>
<div class="af-card p-5">
<h3 class="font-semibold text-slate-900">Recommended invoice format</h3>
<p class="mt-2 rounded-xl bg-slate-50 px-3 py-2 font-mono text-sm text-slate-700">{prefix}/{fy}/{number}</p>
<p class="mt-2 text-xs text-slate-500">Example: INV/2026-27/0001</p>
</div>
</aside>
</div>
</div>
{% endblock %}
+880
View File
@@ -0,0 +1,880 @@
from __future__ import annotations
from datetime import date
from decimal import Decimal
from fastapi import APIRouter, File, Form, Request, UploadFile
from fastapi.responses import RedirectResponse, StreamingResponse
from sqlalchemy import select
from app.core.db.common import CommonSessionLocal
from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
from app.core.security.session_auth import get_current_user
from app.core.templating import templates
from app.modules.billing.models import BillingFeeGroup, BillingSettings
from app.modules.billing.services import (
BILLING_MODES,
FREQUENCIES,
PAYMENT_MODES,
TAX_TYPES,
build_fee_structure_template,
build_invoice_print_context,
build_billing_report_summary,
billing_financial_year,
create_invoice,
fee_group_already_billed,
generate_draft_invoices_from_fee_groups,
get_invoice,
import_fee_structure_excel,
issue_invoice,
list_clients_for_billing,
list_fee_groups,
list_fee_groups_for_generation,
list_invoices,
list_payments,
list_services_for_billing,
parse_date,
preview_invoice_number,
record_invoice_payment,
get_payment,
)
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
from app.modules.core.rbac.permission_guard import require_permission
from app.modules.core.tenancy.models import Branch, Tenant
from app.modules.core.tenancy.year_control import redirect_if_financial_year_locked, is_row_financial_year_locked
router = APIRouter(prefix="/billing", tags=["billing-ui"])
def _base_ctx(request: Request, user, db, **ctx):
base = {
"request": request,
"current_user": user,
"current_user_roles": get_user_roles(db, user.id),
"current_user_permissions": get_user_permissions(db, user.id),
"csrf_token": get_or_create_csrf_token(request),
"tax_types": TAX_TYPES,
"billing_modes": BILLING_MODES,
"frequencies": FREQUENCIES,
"payment_modes": PAYMENT_MODES,
}
base.update(ctx)
return base
def _render(request: Request, template: str, db, user, **ctx):
return templates.TemplateResponse(template, _base_ctx(request, user, db, **ctx))
def _redirect_denied():
return RedirectResponse(url="/system-settings", status_code=303)
def _has_perm(db, user, code: str) -> bool:
try:
require_permission(db, user, code)
return True
except Exception:
return False
def _role_names(db, user) -> set[str]:
return {str(r or "").strip() for r in get_user_roles(db, user.id)}
def _can_manage_billing_settings(db, user) -> bool:
roles = _role_names(db, user)
return bool({"System Admin", "Firm Admin", "Partner"}.intersection(roles)) or _has_perm(db, user, "billing.edit")
def _get_or_create_billing_settings(db, *, tenant_id: int, branch_id: int | None) -> BillingSettings:
row = db.execute(
select(BillingSettings).where(BillingSettings.tenant_id == tenant_id, BillingSettings.branch_id == branch_id)
).scalar_one_or_none()
if row:
return row
row = BillingSettings(tenant_id=tenant_id, branch_id=branch_id)
db.add(row)
db.flush()
return row
def _decimal_form(value: str | None, default: str = "0.00") -> Decimal:
try:
return Decimal(str(value or default)).quantize(Decimal("0.01"))
except Exception:
return Decimal(default).quantize(Decimal("0.01"))
def _int_form(value: str | int | None, default: int, minimum: int | None = None, maximum: int | None = None) -> int:
try:
parsed = int(value)
except Exception:
parsed = default
if minimum is not None:
parsed = max(minimum, parsed)
if maximum is not None:
parsed = min(maximum, parsed)
return parsed
def _billing_context_names(db, *, tenant_id: int, branch_id: int | None) -> tuple[str, str | None]:
tenant = db.get(Tenant, tenant_id)
branch = db.get(Branch, branch_id) if branch_id else None
return (getattr(tenant, "name", None) or f"Audit Firm {tenant_id}", getattr(branch, "name", None) if branch else None)
def _active_tenant_id(request: Request, user) -> int:
return int(request.session.get("active_tenant_id") or request.session.get("selected_tenant_id") or request.session.get("tenant_id") or user.tenant_id)
def _active_branch_id(request: Request, user, db) -> int | None:
value = request.session.get("active_branch_id")
if value in (None, "", 0, "0"):
if _has_perm(db, user, "billing.cross_branch"):
return None
return int(getattr(user, "branch_id", 0) or 0) or None
return int(value)
def _active_financial_year(request: Request) -> str | None:
value = request.session.get("active_financial_year") or getattr(request.state, "year_code", None)
value = (value or "").strip()
if not value or value.upper() == "ALL":
return None
return value
def _period_start_for_fy(financial_year: str | None) -> date:
try:
start_year = int(str(financial_year or "").split("-")[0])
return date(start_year, 4, 1)
except Exception:
today = date.today()
return date(today.year if today.month >= 4 else today.year - 1, 4, 1)
def _period_end_for_fy(financial_year: str | None) -> date:
start = _period_start_for_fy(financial_year)
return date(start.year + 1, 3, 31)
def _locked_partner_id(db, user) -> int | None:
return int(user.id) if _has_perm(db, user, "billing.view_own") else None
def _require_billing_user(request: Request, db, permission_code: str):
user = get_current_user(request, db=db)
if not user:
return None, RedirectResponse(url="/login", status_code=303)
try:
require_permission(db, user, permission_code)
except Exception:
return user, _redirect_denied()
return user, None
@router.get("")
def invoice_list(request: Request, q: str = ""):
db = CommonSessionLocal()
try:
user, response = _require_billing_user(request, db, "billing.view")
if response:
return response
tenant_id = _active_tenant_id(request, user)
branch_id = _active_branch_id(request, user, db)
partner_id = _locked_partner_id(db, user)
financial_year = _active_financial_year(request)
rows = list_invoices(db, tenant_id=tenant_id, branch_id=branch_id, partner_id=partner_id, financial_year=financial_year, q=q)
report_summary = build_billing_report_summary(db, tenant_id=tenant_id, branch_id=branch_id, partner_id=partner_id, financial_year=financial_year)
return _render(
request,
"modules/billing/templates/billing/list.html",
db,
user,
title="Billing - Invoices",
q=q,
active_financial_year=financial_year,
rows=rows,
report_summary=report_summary,
can_create=_has_perm(db, user, "billing.create"),
can_import_fee_structure=_has_perm(db, user, "billing_fee_structure.import"),
can_generate=_has_perm(db, user, "billing_invoice.generate"),
can_view_fee_structure=_has_perm(db, user, "billing_fee_structure.view"),
can_record_payment=_has_perm(db, user, "billing_payment.record") or _has_perm(db, user, "billing.create"),
)
finally:
db.close()
@router.get("/payments")
def payment_list(request: Request, q: str = ""):
db = CommonSessionLocal()
try:
user, response = _require_billing_user(request, db, "billing.view")
if response:
return response
financial_year = _active_financial_year(request)
rows = list_payments(
db,
tenant_id=_active_tenant_id(request, user),
branch_id=_active_branch_id(request, user, db),
partner_id=_locked_partner_id(db, user),
financial_year=financial_year,
q=q,
)
return _render(request, "modules/billing/templates/billing/payments/list.html", db, user, title="Payments & Receipts", rows=rows, q=q, active_financial_year=financial_year)
finally:
db.close()
@router.get("/payments/{payment_id}/receipt")
def payment_receipt_print(request: Request, payment_id: int):
db = CommonSessionLocal()
try:
user, response = _require_billing_user(request, db, "billing.view")
if response:
return response
payment = get_payment(db, payment_id=payment_id, tenant_id=_active_tenant_id(request, user), partner_id=_locked_partner_id(db, user), financial_year=_active_financial_year(request))
if not payment:
return _redirect_denied()
invoice_ctx = build_invoice_print_context(db, payment.invoice)
return _render(request, "modules/billing/templates/billing/payments/receipt_print.html", db, user, title=f"Receipt {payment.receipt_no}", payment=payment, invoice=payment.invoice, invoice_ctx=invoice_ctx)
finally:
db.close()
@router.get("/settings")
def billing_settings_page(request: Request, branch_scope: str = "active"):
db = CommonSessionLocal()
try:
user, response = _require_billing_user(request, db, "billing.view")
if response:
return response
tenant_id = _active_tenant_id(request, user)
active_branch_id = _active_branch_id(request, user, db)
branch_id = None if branch_scope == "firm" and _has_perm(db, user, "billing.cross_branch") else active_branch_id
settings = _get_or_create_billing_settings(db, tenant_id=tenant_id, branch_id=branch_id)
tenant_name, branch_name = _billing_context_names(db, tenant_id=tenant_id, branch_id=branch_id)
return _render(
request,
"modules/billing/templates/billing/settings.html",
db,
user,
title="Billing Settings",
settings=settings,
preview_invoice_no=preview_invoice_number(settings, branch_id=branch_id, financial_year=_active_financial_year(request)),
tenant_name=tenant_name,
branch_name=branch_name,
branch_scope="firm" if branch_id is None else "active",
can_edit_settings=_can_manage_billing_settings(db, user),
)
finally:
db.close()
@router.post("/settings")
def billing_settings_submit(
request: Request,
branch_scope: str = Form("active"),
legal_name: str | None = Form(None),
gstin: str | None = Form(None),
pan: str | None = Form(None),
state_code: str | None = Form(None),
billing_address: str | None = Form(None),
contact_email: str | None = Form(None),
contact_mobile: str | None = Form(None),
website_url: str | None = Form(None),
invoice_title: str | None = Form(None),
invoice_prefix: str = Form("INV"),
invoice_number_format: str | None = Form("{prefix}/{fy}/{number}"),
next_invoice_no: int = Form(1),
padding: int = Form(4),
default_due_days: int = Form(15),
default_gst_rate: str = Form("18.00"),
default_tax_type: str = Form("CGST_SGST"),
default_sac_code: str | None = Form(None),
bank_name: str | None = Form(None),
bank_account_name: str | None = Form(None),
bank_account_number: str | None = Form(None),
bank_ifsc: str | None = Form(None),
upi_id: str | None = Form(None),
bank_details: str | None = Form(None),
terms: str | None = Form(None),
footer_note: str | None = Form(None),
declaration: str | None = Form(None),
authorised_signatory_name: str | None = Form(None),
payumoney_enabled: str | None = Form(None),
payumoney_mode: str = Form("TEST"),
payumoney_merchant_key: str | None = Form(None),
payumoney_merchant_salt: str | None = Form(None),
payumoney_merchant_id: str | None = Form(None),
payumoney_product_info: str | None = Form(None),
cashfree_enabled: str | None = Form(None),
cashfree_mode: str = Form("TEST"),
cashfree_client_id: str | None = Form(None),
cashfree_client_secret: str | None = Form(None),
cashfree_api_version: str | None = Form("2023-08-01"),
cashfree_order_note: str | None = Form(None),
csrf_token: str = Form(...),
):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user, response = _require_billing_user(request, db, "billing.view")
if response:
return response
if not _can_manage_billing_settings(db, user):
return _redirect_denied()
tenant_id = _active_tenant_id(request, user)
active_branch_id = _active_branch_id(request, user, db)
branch_id = None if branch_scope == "firm" and _has_perm(db, user, "billing.cross_branch") else active_branch_id
settings = _get_or_create_billing_settings(db, tenant_id=tenant_id, branch_id=branch_id)
settings.legal_name = (legal_name or "").strip() or None
settings.gstin = (gstin or "").strip().upper() or None
settings.pan = (pan or "").strip().upper() or None
settings.state_code = (state_code or "").strip()[:2] or None
settings.billing_address = (billing_address or "").strip() or None
settings.contact_email = (contact_email or "").strip() or None
settings.contact_mobile = (contact_mobile or "").strip() or None
settings.website_url = (website_url or "").strip() or None
settings.invoice_title = (invoice_title or "").strip() or None
settings.invoice_prefix = (invoice_prefix or "INV").strip().upper()[:40] or "INV"
settings.invoice_number_format = (invoice_number_format or "{prefix}/{fy}/{number}").strip()[:120] or "{prefix}/{fy}/{number}"
settings.next_invoice_no = _int_form(next_invoice_no, 1, minimum=1)
settings.padding = _int_form(padding, 4, minimum=1, maximum=10)
settings.default_due_days = _int_form(default_due_days, 15, minimum=0, maximum=365)
settings.default_gst_rate = _decimal_form(default_gst_rate, "18.00")
settings.default_tax_type = default_tax_type if default_tax_type in TAX_TYPES else "CGST_SGST"
settings.default_sac_code = (default_sac_code or "").strip()[:20] or None
settings.bank_name = (bank_name or "").strip() or None
settings.bank_account_name = (bank_account_name or "").strip() or None
settings.bank_account_number = (bank_account_number or "").strip() or None
settings.bank_ifsc = (bank_ifsc or "").strip().upper() or None
settings.upi_id = (upi_id or "").strip() or None
settings.bank_details = (bank_details or "").strip() or None
settings.terms = (terms or "").strip() or None
settings.footer_note = (footer_note or "").strip() or None
settings.declaration = (declaration or "").strip() or None
settings.authorised_signatory_name = (authorised_signatory_name or "").strip() or None
settings.payumoney_enabled = bool(payumoney_enabled)
settings.payumoney_mode = (payumoney_mode or "TEST").strip().upper() if (payumoney_mode or "TEST").strip().upper() in {"TEST", "LIVE"} else "TEST"
settings.payumoney_merchant_key = (payumoney_merchant_key or "").strip() or None
settings.payumoney_merchant_salt = (payumoney_merchant_salt or "").strip() or None
settings.payumoney_merchant_id = (payumoney_merchant_id or "").strip() or None
settings.payumoney_product_info = (payumoney_product_info or "").strip() or None
db.commit()
suffix = "?branch_scope=firm" if branch_id is None else ""
return RedirectResponse(url=f"/billing/settings{suffix}", status_code=303)
except Exception:
db.rollback()
raise
finally:
db.close()
@router.get("/new")
def invoice_create_page(request: Request):
db = CommonSessionLocal()
try:
user, response = _require_billing_user(request, db, "billing.create")
if response:
return response
tenant_id = _active_tenant_id(request, user)
branch_id = _active_branch_id(request, user, db)
partner_id = _locked_partner_id(db, user)
clients = list_clients_for_billing(db, tenant_id=tenant_id, branch_id=branch_id, partner_id=partner_id)
services = list_services_for_billing(db)
settings = _get_or_create_billing_settings(db, tenant_id=tenant_id, branch_id=branch_id)
return _render(
request,
"modules/billing/templates/billing/create.html",
db,
user,
title="Create Invoice",
active_financial_year=financial_year,
default_billing_period_from=_period_start_for_fy(_active_financial_year(request)).isoformat(),
default_billing_period_to=_period_end_for_fy(_active_financial_year(request)).isoformat(),
clients=clients,
services=services,
settings=settings,
today=date.today().isoformat(),
)
finally:
db.close()
@router.post("/new")
def invoice_create_submit(
request: Request,
client_id: int = Form(...),
invoice_date: str = Form(...),
due_date: str | None = Form(None),
billing_period_from: str | None = Form(None),
billing_period_to: str | None = Form(None),
tax_type: str = Form("CGST_SGST"),
place_of_supply: str | None = Form(None),
client_state_code: str | None = Form(None),
reverse_charge: str | None = Form(None),
notes: str | None = Form(None),
terms: str | None = Form(None),
line_description: list[str] = Form(default=[]),
line_service_id: list[str] = Form(default=[]),
line_quantity: list[str] = Form(default=[]),
line_rate: list[str] = Form(default=[]),
line_discount: list[str] = Form(default=[]),
line_gst_rate: list[str] = Form(default=[]),
line_sac_code: list[str] = Form(default=[]),
csrf_token: str = Form(...),
):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user, response = _require_billing_user(request, db, "billing.create")
if response:
return response
tenant_id = _active_tenant_id(request, user)
branch_id = _active_branch_id(request, user, db)
partner_id = _locked_partner_id(db, user)
financial_year = _active_financial_year(request)
locked_response = redirect_if_financial_year_locked(db, tenant_id=tenant_id, year_code=financial_year, redirect_url=f"/billing?financial_year={financial_year or ''}")
if locked_response:
return locked_response
allowed_clients = {c.id for c in list_clients_for_billing(db, tenant_id=tenant_id, branch_id=branch_id, partner_id=partner_id)}
if client_id not in allowed_clients:
return _redirect_denied()
raw_lines = []
max_len = max(len(line_description), len(line_service_id), len(line_quantity), len(line_rate), len(line_discount), len(line_gst_rate), len(line_sac_code), 0)
for idx in range(max_len):
raw_lines.append({
"description": line_description[idx] if idx < len(line_description) else "",
"service_id": line_service_id[idx] if idx < len(line_service_id) else "",
"quantity": line_quantity[idx] if idx < len(line_quantity) else "1",
"rate": line_rate[idx] if idx < len(line_rate) else "0",
"discount_amount": line_discount[idx] if idx < len(line_discount) else "0",
"gst_rate": line_gst_rate[idx] if idx < len(line_gst_rate) else "18",
"sac_code": line_sac_code[idx] if idx < len(line_sac_code) else "",
})
invoice = create_invoice(
db,
tenant_id=tenant_id,
branch_id=branch_id,
client_id=client_id,
invoice_date=parse_date(invoice_date) or date.today(),
due_date=parse_date(due_date),
billing_period_from=parse_date(billing_period_from),
billing_period_to=parse_date(billing_period_to),
tax_type=tax_type,
notes=notes,
terms=terms,
place_of_supply=place_of_supply,
client_state_code=client_state_code,
reverse_charge=(reverse_charge == "yes"),
created_by_user_id=user.id,
raw_lines=raw_lines,
financial_year=_active_financial_year(request),
)
db.commit()
return RedirectResponse(url=f"/billing/{invoice.id}", status_code=303)
except ValueError:
db.rollback()
return RedirectResponse(url="/billing/new", status_code=303)
finally:
db.close()
@router.get("/fee-structures/list")
def fee_structure_list(request: Request, q: str = ""):
db = CommonSessionLocal()
try:
user, response = _require_billing_user(request, db, "billing_fee_structure.view")
if response:
return response
rows = list_fee_groups(
db,
tenant_id=_active_tenant_id(request, user),
branch_id=_active_branch_id(request, user, db),
partner_id=_locked_partner_id(db, user),
q=q,
)
return _render(
request,
"modules/billing/templates/billing/fee_structures/list.html",
db,
user,
title="Fee Structure",
q=q,
active_financial_year=financial_year,
rows=rows,
can_import=_has_perm(db, user, "billing_fee_structure.import"),
)
finally:
db.close()
@router.get("/fee-structures/import")
def fee_structure_import_page(request: Request):
db = CommonSessionLocal()
try:
user, response = _require_billing_user(request, db, "billing_fee_structure.import")
if response:
return response
return _render(request, "modules/billing/templates/billing/fee_structures/import.html", db, user, title="Import Fee Structure", result=None)
finally:
db.close()
@router.get("/fee-structures/template")
def fee_structure_template_download(request: Request):
db = CommonSessionLocal()
try:
user, response = _require_billing_user(request, db, "billing_fee_structure.import")
if response:
return response
data = build_fee_structure_template()
return StreamingResponse(
iter([data]),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": "attachment; filename=billing_fee_structure_template.xlsx"},
)
finally:
db.close()
@router.post("/fee-structures/import")
async def fee_structure_import_submit(request: Request, import_file: UploadFile = File(...), csrf_token: str = Form(...)):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user, response = _require_billing_user(request, db, "billing_fee_structure.import")
if response:
return response
filename = (import_file.filename or "").lower()
if not filename.endswith((".xlsx", ".xlsm")):
result = {"success": False, "created": 0, "updated": 0, "errors": ["Please upload an .xlsx file."]}
else:
content = await import_file.read()
if len(content) > 5 * 1024 * 1024:
result = {"success": False, "created": 0, "updated": 0, "errors": ["File size must be 5 MB or less."]}
else:
result = import_fee_structure_excel(
db,
tenant_id=_active_tenant_id(request, user),
branch_id=_active_branch_id(request, user, db),
created_by_user_id=user.id,
file_bytes=content,
)
return _render(request, "modules/billing/templates/billing/fee_structures/import.html", db, user, title="Import Fee Structure", result=result)
finally:
db.close()
@router.get("/generate")
def generate_invoices_page(
request: Request,
frequency: str = "Monthly",
billing_period_from: str | None = None,
billing_period_to: str | None = None,
auto_generate_only: str = "yes",
q: str = "",
):
db = CommonSessionLocal()
try:
user, response = _require_billing_user(request, db, "billing_invoice.generate")
if response:
return response
tenant_id = _active_tenant_id(request, user)
branch_id = _active_branch_id(request, user, db)
partner_id = _locked_partner_id(db, user)
financial_year = _active_financial_year(request)
locked_response = redirect_if_financial_year_locked(db, tenant_id=tenant_id, year_code=financial_year, redirect_url=f"/billing?financial_year={financial_year or ''}")
if locked_response:
return locked_response
period_from = parse_date(billing_period_from) or _period_start_for_fy(financial_year)
period_to = parse_date(billing_period_to) or _period_end_for_fy(financial_year)
rows = list_fee_groups_for_generation(
db,
tenant_id=tenant_id,
branch_id=branch_id,
partner_id=partner_id,
frequency=frequency or None,
auto_generate_only=(auto_generate_only != "no"),
q=q,
)
duplicate_map = {
row.id: fee_group_already_billed(db, tenant_id=tenant_id, fee_group_id=row.id, period_from=period_from, period_to=period_to)
for row in rows
}
return _render(
request,
"modules/billing/templates/billing/generate.html",
db,
user,
title="Generate Draft Invoices",
rows=rows,
duplicate_map=duplicate_map,
frequencies=FREQUENCIES,
frequency=frequency,
billing_period_from=period_from.isoformat(),
billing_period_to=period_to.isoformat(),
auto_generate_only=auto_generate_only,
q=q,
active_financial_year=financial_year,
result=None,
)
finally:
db.close()
@router.post("/generate")
def generate_invoices_submit(
request: Request,
frequency: str = Form("Monthly"),
billing_period_from: str = Form(...),
billing_period_to: str = Form(...),
auto_generate_only: str = Form("yes"),
q: str = Form(""),
fee_group_ids: list[int] = Form(default=[]),
skip_duplicates: str = Form("yes"),
csrf_token: str = Form(...),
):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user, response = _require_billing_user(request, db, "billing_invoice.generate")
if response:
return response
tenant_id = _active_tenant_id(request, user)
branch_id = _active_branch_id(request, user, db)
partner_id = _locked_partner_id(db, user)
financial_year = _active_financial_year(request)
locked_response = redirect_if_financial_year_locked(db, tenant_id=tenant_id, year_code=financial_year, redirect_url=f"/billing/generate?year_locked=1")
if locked_response:
return locked_response
period_from = parse_date(billing_period_from)
period_to = parse_date(billing_period_to)
if financial_year and period_from and billing_financial_year(billing_period_from=period_from) != financial_year:
result = {"created": [], "skipped": [], "errors": [f"Billing period must fall within active FY {financial_year}."], "batch": None}
elif period_from is None or period_to is None:
result = {"created": [], "skipped": [], "errors": ["Billing period From and To are required."], "batch": None}
else:
result = generate_draft_invoices_from_fee_groups(
db,
tenant_id=tenant_id,
branch_id=branch_id,
partner_id=partner_id,
generated_by_user_id=user.id,
billing_period_from=period_from,
billing_period_to=period_to,
frequency=frequency or None,
fee_group_ids=fee_group_ids,
skip_duplicates=(skip_duplicates != "no"),
)
db.commit()
rows = list_fee_groups_for_generation(
db,
tenant_id=tenant_id,
branch_id=branch_id,
partner_id=partner_id,
frequency=frequency or None,
auto_generate_only=(auto_generate_only != "no"),
q=q,
)
duplicate_map = {
row.id: fee_group_already_billed(db, tenant_id=tenant_id, fee_group_id=row.id, period_from=period_from or date.today(), period_to=period_to or date.today())
for row in rows
}
return _render(
request,
"modules/billing/templates/billing/generate.html",
db,
user,
title="Generate Draft Invoices",
rows=rows,
duplicate_map=duplicate_map,
frequencies=FREQUENCIES,
frequency=frequency,
billing_period_from=(period_from or date.today()).isoformat(),
billing_period_to=(period_to or date.today()).isoformat(),
auto_generate_only=auto_generate_only,
q=q,
active_financial_year=financial_year,
result=result,
)
except ValueError as exc:
db.rollback()
rows = []
result = {"created": [], "skipped": [], "errors": [str(exc)], "batch": None}
return _render(
request,
"modules/billing/templates/billing/generate.html",
db,
user,
title="Generate Draft Invoices",
rows=rows,
duplicate_map={},
frequencies=FREQUENCIES,
frequency=frequency,
billing_period_from=billing_period_from,
billing_period_to=billing_period_to,
auto_generate_only=auto_generate_only,
q=q,
active_financial_year=_active_financial_year(request),
result=result,
)
except Exception as exc:
db.rollback()
result = {"created": [], "skipped": [], "errors": [f"Generation failed: {exc}"], "batch": None}
return _render(
request,
"modules/billing/templates/billing/generate.html",
db,
user,
title="Generate Draft Invoices",
rows=[],
duplicate_map={},
frequencies=FREQUENCIES,
frequency=frequency,
billing_period_from=billing_period_from,
billing_period_to=billing_period_to,
auto_generate_only=auto_generate_only,
q=q,
active_financial_year=_active_financial_year(request),
result=result,
)
finally:
db.close()
@router.get("/{invoice_id}/payments/new")
def invoice_payment_page(request: Request, invoice_id: int):
db = CommonSessionLocal()
try:
user, response = _require_billing_user(request, db, "billing.view")
if response:
return response
invoice = get_invoice(db, invoice_id=invoice_id, tenant_id=_active_tenant_id(request, user), partner_id=_locked_partner_id(db, user), financial_year=_active_financial_year(request))
if not invoice:
return _redirect_denied()
if invoice.status in {"DRAFT", "CANCELLED"}:
return RedirectResponse(url=f"/billing/{invoice.id}", status_code=303)
can_record = _has_perm(db, user, "billing_payment.record") or _has_perm(db, user, "billing.create")
if not can_record:
return _redirect_denied()
return _render(request, "modules/billing/templates/billing/payments/new.html", db, user, title=f"Record Payment - {invoice.invoice_no}", invoice=invoice, today=date.today().isoformat())
finally:
db.close()
@router.post("/{invoice_id}/payments/new")
def invoice_payment_submit(
request: Request,
invoice_id: int,
payment_date: str = Form(...),
amount_received: str = Form("0.00"),
tds_deducted: str = Form("0.00"),
bank_charges: str = Form("0.00"),
mode: str = Form("BANK"),
reference_no: str | None = Form(None),
remarks: str | None = Form(None),
csrf_token: str = Form(...),
):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user, response = _require_billing_user(request, db, "billing.view")
if response:
return response
can_record = _has_perm(db, user, "billing_payment.record") or _has_perm(db, user, "billing.create")
if not can_record:
return _redirect_denied()
invoice = get_invoice(db, invoice_id=invoice_id, tenant_id=_active_tenant_id(request, user), partner_id=_locked_partner_id(db, user), financial_year=_active_financial_year(request))
if not invoice:
return _redirect_denied()
if is_row_financial_year_locked(db, invoice):
return RedirectResponse(url=f"/billing/{invoice.id}?year_locked=1", status_code=303)
payment = record_invoice_payment(
db,
invoice=invoice,
payment_date=parse_date(payment_date) or date.today(),
amount_received=_decimal_form(amount_received, "0.00"),
tds_deducted=_decimal_form(tds_deducted, "0.00"),
bank_charges=_decimal_form(bank_charges, "0.00"),
mode=mode,
reference_no=reference_no,
remarks=remarks,
created_by_user_id=user.id,
)
db.commit()
return RedirectResponse(url=f"/billing/payments/{payment.id}/receipt", status_code=303)
except ValueError:
db.rollback()
return RedirectResponse(url=f"/billing/{invoice_id}", status_code=303)
except Exception:
db.rollback()
raise
finally:
db.close()
@router.get("/{invoice_id}/print")
def invoice_print(request: Request, invoice_id: int):
db = CommonSessionLocal()
try:
user, response = _require_billing_user(request, db, "billing.view")
if response:
return response
invoice = get_invoice(db, invoice_id=invoice_id, tenant_id=_active_tenant_id(request, user), partner_id=_locked_partner_id(db, user), financial_year=_active_financial_year(request))
if not invoice:
return _redirect_denied()
invoice_ctx = build_invoice_print_context(db, invoice)
return _render(request, "modules/billing/templates/billing/invoice_print.html", db, user, title=f"Print Invoice {invoice.invoice_no}", invoice=invoice, invoice_ctx=invoice_ctx)
finally:
db.close()
@router.post("/{invoice_id}/issue")
def invoice_issue_submit(request: Request, invoice_id: int, csrf_token: str = Form(...)):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user, response = _require_billing_user(request, db, "billing.create")
if response:
return response
invoice = get_invoice(db, invoice_id=invoice_id, tenant_id=_active_tenant_id(request, user), partner_id=_locked_partner_id(db, user), financial_year=_active_financial_year(request))
if not invoice:
return _redirect_denied()
if is_row_financial_year_locked(db, invoice):
return RedirectResponse(url=f"/billing/{invoice.id}?year_locked=1", status_code=303)
issue_invoice(db, invoice, user_id=user.id)
db.commit()
return RedirectResponse(url=f"/billing/{invoice.id}", status_code=303)
except Exception:
db.rollback()
raise
finally:
db.close()
@router.get("/{invoice_id}")
def invoice_detail(request: Request, invoice_id: int):
db = CommonSessionLocal()
try:
user, response = _require_billing_user(request, db, "billing.view")
if response:
return response
invoice = get_invoice(db, invoice_id=invoice_id, tenant_id=_active_tenant_id(request, user), partner_id=_locked_partner_id(db, user), financial_year=_active_financial_year(request))
if not invoice:
return _redirect_denied()
invoice_ctx = build_invoice_print_context(db, invoice)
return _render(request, "modules/billing/templates/billing/detail.html", db, user, title=f"Invoice {invoice.invoice_no}", invoice=invoice, invoice_ctx=invoice_ctx, can_record_payment=_has_perm(db, user, "billing_payment.record") or _has_perm(db, user, "billing.create"))
finally:
db.close()
+4
View File
@@ -0,0 +1,4 @@
from .api import router as api_router
from .ui import router as ui_router
__all__ = ["api_router", "ui_router"]
+76
View File
@@ -0,0 +1,76 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class ClientAccessScope:
tenant_id: int
branch_id: int | None
allow_cross_branch: bool
allow_cross_tenant: bool
allow_all_clients: bool
own_only: bool
locked_partner_id: int | None
can_assign_partner: bool
can_change_branch: bool
can_change_tenant: bool
def build_scope(request, user, permission_checker):
active_tenant_id = int(request.session.get("active_tenant_id") or user.tenant_id)
active_branch_value = request.session.get("active_branch_id")
active_branch_id = None if active_branch_value in (None, "", 0, "0") else int(active_branch_value)
allow_cross_branch = permission_checker("clients.cross_branch")
allow_cross_tenant = permission_checker("clients.cross_tenant")
can_assign_partner = permission_checker("clients.assign_partner")
own_only = permission_checker("clients.view.own_only")
allow_all_clients = bool((allow_cross_tenant and allow_cross_branch and not own_only) or permission_checker("clients.view.all"))
locked_partner_id = user.id if own_only else None
can_change_branch = allow_cross_branch
can_change_tenant = allow_cross_tenant
return ClientAccessScope(
tenant_id=active_tenant_id,
branch_id=active_branch_id or getattr(user, "branch_id", None),
allow_cross_branch=allow_cross_branch,
allow_cross_tenant=allow_cross_tenant,
allow_all_clients=allow_all_clients,
own_only=own_only,
locked_partner_id=locked_partner_id,
can_assign_partner=can_assign_partner,
can_change_branch=can_change_branch,
can_change_tenant=can_change_tenant,
)
def effective_partner_id(row: dict):
return row.get("assoc_partner_user_id") or row.get("partner_id")
def effective_tenant_id(row: dict):
return row.get("assoc_firm_tenant_id") or row.get("tenant_id")
def effective_branch_id(row: dict):
return row.get("branch_id")
def can_view_client_row(scope: ClientAccessScope, row: dict, *, user_id: int) -> bool:
if scope.allow_all_clients:
return True
if not scope.allow_cross_tenant and effective_tenant_id(row) != scope.tenant_id:
return False
branch_id = effective_branch_id(row)
if not scope.allow_cross_branch and scope.branch_id and branch_id and branch_id != scope.branch_id:
return False
if scope.own_only and scope.locked_partner_id and effective_partner_id(row) != scope.locked_partner_id:
return False
return True
+178
View File
@@ -0,0 +1,178 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import Response
from sqlalchemy.orm import Session
from app.core.db.deps import get_common_db
from app.core.security.session_auth import require_login
from app.modules.clients.access import ClientAccessScope
from app.modules.clients.schemas import ClientAuditLogOut, ClientFilterOptions, ClientListResponse, ClientOut, ClientUpdate, ClientCreate
from app.modules.clients.service import (
activate_client_service,
archive_client_service,
create_client_service,
deactivate_client_service,
export_clients_csv,
get_client_or_404,
get_filter_options,
list_client_audit_logs,
list_clients_payload,
restore_client_service,
update_client_service,
)
from app.modules.core.rbac.permission_guard import require_permission
router = APIRouter(prefix="/api/v1/clients", tags=["clients-api"])
def _api_scope_from_user(db, user):
def has(code: str):
try:
require_permission(db, user, code)
return True
except Exception:
return False
own_only = has("clients.view.own_only") or not has("clients.assign_partner")
return ClientAccessScope(
tenant_id=user.tenant_id,
branch_id=user.branch_id,
allow_cross_branch=has("clients.cross_branch"),
allow_cross_tenant=has("clients.cross_tenant"),
own_only=own_only,
locked_partner_id=user.id if own_only else None,
can_assign_partner=has("clients.assign_partner"),
can_change_branch=has("clients.cross_branch"),
can_change_tenant=has("clients.cross_tenant"),
)
@router.get("/filters", response_model=ClientFilterOptions)
def api_client_filters():
return get_filter_options()
@router.get("", response_model=ClientListResponse)
def api_list_clients(
q: str = Query("", max_length=100),
status: str = Query("", max_length=20),
client_type: str = Query("", max_length=100),
partner_id: int | None = Query(None),
include_archived: bool = Query(False),
page: int = Query(1, ge=1),
per_page: int = Query(10, ge=1, le=100),
sort_by: str = Query("client_name"),
sort_order: str = Query("asc"),
db: Session = Depends(get_common_db),
user=Depends(require_login),
):
require_permission(db, user, "clients.view")
scope = _api_scope_from_user(db, user)
if scope.own_only:
partner_id = scope.locked_partner_id
return list_clients_payload(
db,
tenant_id=scope.tenant_id,
branch_id=scope.branch_id,
allow_cross_branch=scope.allow_cross_branch,
partner_id=partner_id,
q=q,
status=status,
client_type=client_type,
include_archived=include_archived,
page=page,
per_page=per_page,
sort_by=sort_by,
sort_order=sort_order,
)
@router.get("/export")
def api_export_clients(
q: str = Query("", max_length=100),
status: str = Query("", max_length=20),
client_type: str = Query("", max_length=100),
partner_id: int | None = Query(None),
include_archived: bool = Query(False),
sort_by: str = Query("client_name"),
sort_order: str = Query("asc"),
db: Session = Depends(get_common_db),
user=Depends(require_login),
):
require_permission(db, user, "clients.export")
scope = _api_scope_from_user(db, user)
if scope.own_only:
partner_id = scope.locked_partner_id
payload = list_clients_payload(
db,
tenant_id=scope.tenant_id,
branch_id=scope.branch_id,
allow_cross_branch=scope.allow_cross_branch,
partner_id=partner_id,
q=q,
status=status,
client_type=client_type,
include_archived=include_archived,
page=1,
per_page=10000,
sort_by=sort_by,
sort_order=sort_order,
)
csv_text = export_clients_csv(payload)
return Response(content=csv_text, media_type="text/csv", headers={"Content-Disposition": "attachment; filename=clients_export.csv"})
@router.get("/{client_id}", response_model=ClientOut)
def api_get_client(client_id: int, db: Session = Depends(get_common_db), user=Depends(require_login)):
require_permission(db, user, "clients.view")
scope = _api_scope_from_user(db, user)
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch)
if scope.own_only and row.partner_id != scope.locked_partner_id:
raise HTTPException(status_code=404, detail="Client not found.")
return row
@router.post("", response_model=ClientOut, status_code=201)
def api_create_client(data: ClientCreate, db: Session = Depends(get_common_db), user=Depends(require_login)):
require_permission(db, user, "clients.create")
scope = _api_scope_from_user(db, user)
return create_client_service(db, data=data, actor_user_id=user.id, scope=scope)
@router.put("/{client_id}", response_model=ClientOut)
def api_update_client(client_id: int, data: ClientUpdate, db: Session = Depends(get_common_db), user=Depends(require_login)):
require_permission(db, user, "clients.edit")
scope = _api_scope_from_user(db, user)
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch)
if scope.own_only and row.partner_id != scope.locked_partner_id:
raise HTTPException(status_code=404, detail="Client not found.")
return update_client_service(db, row=row, data=data, actor_user_id=user.id, scope=scope)
@router.post("/{client_id}/deactivate", response_model=ClientOut)
def api_deactivate_client(client_id: int, db: Session = Depends(get_common_db), user=Depends(require_login)):
require_permission(db, user, "clients.deactivate")
scope = _api_scope_from_user(db, user)
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch)
return deactivate_client_service(db, row=row, actor_user_id=user.id)
@router.post("/{client_id}/activate", response_model=ClientOut)
def api_activate_client(client_id: int, db: Session = Depends(get_common_db), user=Depends(require_login)):
require_permission(db, user, "clients.activate")
scope = _api_scope_from_user(db, user)
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch)
return activate_client_service(db, row=row, actor_user_id=user.id)
@router.post("/{client_id}/archive", response_model=ClientOut)
def api_archive_client(client_id: int, db: Session = Depends(get_common_db), user=Depends(require_login)):
require_permission(db, user, "clients.archive")
scope = _api_scope_from_user(db, user)
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch)
return archive_client_service(db, row=row, actor_user_id=user.id)
@router.post("/{client_id}/restore", response_model=ClientOut)
def api_restore_client(client_id: int, db: Session = Depends(get_common_db), user=Depends(require_login)):
require_permission(db, user, "clients.restore")
scope = _api_scope_from_user(db, user)
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch)
return restore_client_service(db, row=row, actor_user_id=user.id)
@router.get("/{client_id}/audit-logs", response_model=list[ClientAuditLogOut])
def api_client_audit_logs(client_id: int, limit: int = Query(50, ge=1, le=200), db: Session = Depends(get_common_db), user=Depends(require_login)):
require_permission(db, user, "clients.audit_log.view")
scope = _api_scope_from_user(db, user)
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch)
return list_client_audit_logs(db, row=row, limit=limit)
@@ -0,0 +1,42 @@
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.modules.clients.association_models import ClientAssociation
def get_active_association(db: Session, client_id: int):
stmt = (
select(ClientAssociation)
.where(ClientAssociation.client_id == client_id)
.limit(1)
)
return db.execute(stmt).scalar_one_or_none()
def ensure_active_association(db: Session, client_id: int):
row = get_active_association(db, client_id)
if row:
return row
row = ClientAssociation(
client_id=client_id,
association_type="firm",
created_source="system_admin",
)
db.add(row)
db.commit()
db.refresh(row)
return row
def update_association_fields(db: Session, client_id: int, **fields):
row = ensure_active_association(db, client_id)
for key, value in fields.items():
if hasattr(row, key):
setattr(row, key, value)
db.add(row)
db.commit()
db.refresh(row)
return row
+18
View File
@@ -0,0 +1,18 @@
from __future__ import annotations
from sqlalchemy import Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db.common import CommonBase
class ClientAssociation(CommonBase):
__tablename__ = "client_associations"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
client_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
association_type: Mapped[str] = mapped_column(String(50), nullable=False, default="firm")
firm_tenant_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
consultant_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
partner_user_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
created_source: Mapped[str] = mapped_column(String(50), nullable=False, default="system_admin")
@@ -0,0 +1,27 @@
def build_client_association(current_user, role_name, selected_partner_id=None):
role = (role_name or '').lower()
if role in ('system admin', 'firm admin'):
return {
'association_type': 'firm',
'firm_tenant_id': getattr(current_user, 'tenant_id', None),
'partner_user_id': selected_partner_id,
'created_source': 'firm_admin',
}
if role == 'partner':
return {
'association_type': 'firm',
'firm_tenant_id': getattr(current_user, 'tenant_id', None),
'partner_user_id': current_user.id,
'created_source': 'partner',
}
if role == 'consultant':
return {
'association_type': 'consultant',
'consultant_id': current_user.id,
'created_source': 'consultant',
}
return {
'association_type': 'self_service_unassigned',
'created_source': 'self_service',
}
+120
View File
@@ -0,0 +1,120 @@
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.modules.core.iam.models import User
from app.modules.core.iam.profile_service import profile_photo_url
from app.modules.core.tenancy.models import Branch, Tenant
def _initials(name: str | None, email: str | None = None) -> str:
source = (name or email or "Auditor").strip()
parts = [p for p in source.replace("@", " ").replace(".", " ").split() if p]
if not parts:
return "AU"
if len(parts) == 1:
return parts[0][:2].upper()
return (parts[0][:1] + parts[-1][:1]).upper()
def _contact_card_from_user(
*,
user: User | None,
tenant_name: str | None,
branch_name: str | None,
source_label: str,
) -> dict:
if not user:
return {
"available": False,
"name": "Firm team",
"designation": "Audit support team",
"qualification": None,
"email": None,
"mobile": None,
"photo_url": None,
"initials": "FT",
"firm_name": tenant_name,
"branch_name": branch_name,
"source_label": source_label,
}
name = getattr(user, "full_name", None) or getattr(user, "email", None) or "Firm team"
designation = getattr(user, "designation", None) or source_label or "Auditor"
return {
"available": True,
"name": name,
"designation": designation,
"qualification": getattr(user, "qualification", None),
"email": getattr(user, "email", None),
"mobile": getattr(user, "mobile", None),
"photo_url": profile_photo_url(user),
"initials": _initials(name, getattr(user, "email", None)),
"firm_name": tenant_name,
"branch_name": branch_name,
"source_label": source_label,
}
def build_client_auditor_card(db: Session, client_row: dict | None) -> dict:
"""Return a client-facing contact card for the assigned auditor/partner.
Priority:
1. Client assigned partner (`partner_id`).
2. Default review partner, if no assigned partner exists.
3. Firm team fallback using tenant/branch names.
This reuses Phase 7Q.3 user profile fields and does not create new tables.
"""
if not client_row:
return _contact_card_from_user(
user=None,
tenant_name=None,
branch_name=None,
source_label="Firm team",
)
tenant_name = client_row.get("tenant_name")
branch_name = client_row.get("branch_name")
tenant_id = client_row.get("tenant_id")
branch_id = client_row.get("branch_id")
partner_id = client_row.get("partner_id") or client_row.get("assoc_partner_user_id")
review_partner_id = client_row.get("default_review_partner_user_id")
target_user_id = partner_id or review_partner_id
source_label = "Assigned Auditor" if partner_id else "Review Partner"
if not target_user_id:
return _contact_card_from_user(
user=None,
tenant_name=tenant_name,
branch_name=branch_name,
source_label="Firm team",
)
stmt = (
select(User, Tenant.name.label("tenant_name"), Branch.name.label("branch_name"))
.join(Tenant, Tenant.id == User.tenant_id, isouter=True)
.join(Branch, Branch.id == User.branch_id, isouter=True)
.where(User.id == int(target_user_id), User.deleted_at.is_(None))
)
if tenant_id:
stmt = stmt.where(User.tenant_id == int(tenant_id))
result = db.execute(stmt).first()
if not result:
return _contact_card_from_user(
user=None,
tenant_name=tenant_name,
branch_name=branch_name,
source_label="Firm team",
)
user, resolved_tenant_name, resolved_branch_name = result
return _contact_card_from_user(
user=user,
tenant_name=resolved_tenant_name or tenant_name,
branch_name=resolved_branch_name or branch_name,
source_label=source_label,
)
+44
View File
@@ -0,0 +1,44 @@
ENGAGEMENT_MODES = [
"internal_managed",
"self_tracked",
"hybrid",
]
ASSOCIATION_TYPES = [
"firm",
"consultant",
"firm_consultant",
"self_service_unassigned",
]
CLIENT_TYPES = [
"Proprietorship",
"Partnership",
"LLP",
"Private Limited Company",
"Public Limited Company",
"Trust",
"Society",
"AOP",
"HUF",
"NRI",
"Other",
]
CLIENT_STATUS = ["active", "inactive", "archived"]
CLIENT_CATEGORY_OPTIONS = [
"Audit", "Tax", "GST", "Compliance", "Payroll", "Advisory", "Litigation", "Internal", "Other",
]
RISK_CATEGORIES = ["low", "medium", "high", "critical"]
CLIENT_SORT_FIELDS = {
"client_code": "client_code",
"client_name": "client_name",
"client_type": "client_type",
"status": "status",
"created_at_utc": "created_at_utc",
"updated_at_utc": "updated_at_utc",
"onboarding_date": "onboarding_date",
}
+36
View File
@@ -0,0 +1,36 @@
from dataclasses import dataclass
@dataclass
class ClientListFilters:
q: str = ""
status: str = ""
client_type: str = ""
partner_id: int | None = None
include_archived: bool = False
page: int = 1
per_page: int = 10
sort_by: str = "client_name"
sort_order: str = "asc"
@classmethod
def from_params(cls, **kwargs):
partner_id = kwargs.get("partner_id")
if partner_id in ("", None):
partner_id = None
elif not isinstance(partner_id, int):
partner_id = int(partner_id)
include_archived = kwargs.get("include_archived", False)
if isinstance(include_archived, str):
include_archived = include_archived.lower() in ("1", "true", "yes", "on")
return cls(
q=kwargs.get("q", "") or "",
status=kwargs.get("status", "") or "",
client_type=kwargs.get("client_type", "") or "",
partner_id=partner_id,
include_archived=include_archived,
page=max(int(kwargs.get("page", 1) or 1), 1),
per_page=min(max(int(kwargs.get("per_page", 10) or 10), 1), 100),
sort_by=kwargs.get("sort_by", "client_name") or "client_name",
sort_order=kwargs.get("sort_order", "asc") or "asc",
)
+281
View File
@@ -0,0 +1,281 @@
from __future__ import annotations
import io
import json
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from openpyxl import Workbook, load_workbook
from sqlalchemy.orm import Session
from app.modules.clients import repository
from app.modules.clients.schemas import ClientCreate
from app.modules.clients.service import create_client_service
TEMPLATE_COLUMNS = [
"uploader_user_id",
"firm_tenant_id",
"partner_user_id",
"branch_id",
"client_code",
"client_name",
"client_type",
"engagement_mode",
"email",
"portal_password",
"portal_password_confirm",
"mobile",
"pan",
"gstin",
"tan",
"cin_llpin",
"msme_no",
"iec_code",
"contact_person_name",
"contact_person_designation",
"alternate_mobile",
"alternate_email",
"address_line_1",
"address_line_2",
"city",
"state",
"pincode",
"country",
"client_category",
"risk_category",
"onboarding_date",
"closing_date",
"notes",
"status",
"gst_applicable",
"income_tax_applicable",
"tds_applicable",
"roc_applicable",
"audit_applicable",
"pf_applicable",
"esi_applicable",
"professional_tax_applicable",
"payroll_applicable",
"msme_applicable",
"import_export_applicable",
]
BOOL_FIELDS = {
"gst_applicable", "income_tax_applicable", "tds_applicable", "roc_applicable",
"audit_applicable", "pf_applicable", "esi_applicable", "professional_tax_applicable",
"payroll_applicable", "msme_applicable", "import_export_applicable",
}
@dataclass
class ImportPreview:
valid_rows: list[dict]
errors: list[dict]
total_rows: int
def _clean(value: Any) -> str | None:
if value is None:
return None
txt = str(value).strip()
return txt or None
def _to_bool(value: Any) -> bool:
txt = str(value or '').strip().lower()
return txt in {'1','true','yes','y','on'}
def build_client_import_template_bytes(*, current_user, tenant_id: int, partner_id: int | None) -> bytes:
wb = Workbook()
ws = wb.active
ws.title = 'clients_import'
ws.append(TEMPLATE_COLUMNS)
sample = [
current_user.id, tenant_id, partner_id or current_user.id, getattr(current_user, 'branch_id', '') or '',
'CLT-001', 'Sample Client', 'Other', 'internal_managed', 'client@example.com', 'ChangeMe@123', 'ChangeMe@123',
'9876543210', '', '', '', '', '', '', 'Client Contact', 'Proprietor', '', '', 'Address line 1', '', 'Chennai', 'Tamil Nadu', '600001', 'India', '', '', '', '', '', 'active',
'yes', 'yes', 'no', 'no', 'no', 'no', 'no', 'no', 'no', 'no', 'no'
]
ws.append(sample)
ref = wb.create_sheet('instructions')
ref.append(['Field', 'Notes'])
ref.append(['uploader_user_id', 'Must match the logged-in uploader user id exactly.'])
ref.append(['firm_tenant_id', 'Must match the active firm/tenant context of the upload.'])
ref.append(['partner_user_id', 'Must be an active Partner user mapped to the same firm.'])
ref.append(['branch_id', 'Optional. If blank, uploader branch or partner branch will be used.'])
ref.append(['email', 'Used as the client frontend login email.'])
ref.append(['portal_password', 'Minimum 8 characters.'])
ref.append(['portal_password_confirm', 'Must match portal_password.'])
bio = io.BytesIO()
wb.save(bio)
return bio.getvalue()
def _row_dict(ws, row_idx: int) -> dict[str, Any]:
headers = [str(c.value).strip() if c.value is not None else '' for c in ws[1]]
values = [c.value for c in ws[row_idx]]
return {headers[i]: values[i] if i < len(values) else None for i in range(len(headers)) if headers[i]}
def build_preview(db: Session, *, current_user, scope, role_names: set[str], upload_bytes: bytes) -> ImportPreview:
wb = load_workbook(io.BytesIO(upload_bytes), data_only=True)
ws = wb[wb.sheetnames[0]]
headers = [str(c.value).strip() if c.value is not None else '' for c in ws[1]]
missing = [c for c in TEMPLATE_COLUMNS if c not in headers]
if missing:
return ImportPreview(valid_rows=[], errors=[{'row_number': 1, 'messages': [f'Missing required columns: {", ".join(missing)}']}], total_rows=0)
valid_rows = []
errors = []
active_tenant_id = int(scope.tenant_id)
active_branch_id = int(scope.branch_id or getattr(current_user, 'branch_id', 0) or 0)
for row_idx in range(2, ws.max_row + 1):
raw = _row_dict(ws, row_idx)
if not any(v not in (None, '') for v in raw.values()):
continue
msgs: list[str] = []
cleaned = {k: (_to_bool(v) if k in BOOL_FIELDS else _clean(v)) for k, v in raw.items()}
try:
uploader_user_id = int(cleaned.get('uploader_user_id') or 0)
except Exception:
uploader_user_id = 0
try:
firm_tenant_id = int(cleaned.get('firm_tenant_id') or 0)
except Exception:
firm_tenant_id = 0
try:
partner_user_id = int(cleaned.get('partner_user_id') or 0)
except Exception:
partner_user_id = 0
try:
branch_id = int(cleaned.get('branch_id') or 0)
except Exception:
branch_id = 0
if uploader_user_id != int(current_user.id):
msgs.append('uploader_user_id must match the currently logged-in user id.')
if firm_tenant_id != active_tenant_id:
msgs.append('firm_tenant_id must match the active firm/tenant context of the uploader.')
partner = repository.get_partner_for_tenant(db, partner_user_id=partner_user_id, tenant_id=firm_tenant_id) if partner_user_id else None
if not partner:
msgs.append('partner_user_id must belong to an active Partner user in the same firm.')
if 'partner' in role_names and partner_user_id != int(current_user.id):
msgs.append('Partner uploader can import only for their own partner_user_id.')
if branch_id:
branch = repository.get_branch(db, branch_id)
if not branch or int(branch.tenant_id) != firm_tenant_id:
msgs.append('branch_id must belong to the same firm/tenant.')
else:
branch_id = int(getattr(partner, 'branch_id', None) or active_branch_id or getattr(current_user, 'branch_id', 0) or 0)
if not branch_id:
msgs.append('branch_id is required when uploader and partner have no branch mapped.')
payload = {
'tenant_id': firm_tenant_id,
'branch_id': branch_id,
'partner_id': partner_user_id or None,
'engagement_mode': cleaned.get('engagement_mode') or 'internal_managed',
'client_code': cleaned.get('client_code') or '',
'client_name': cleaned.get('client_name') or '',
'trade_name': None,
'client_type': cleaned.get('client_type') or 'Other',
'pan': cleaned.get('pan'),
'gstin': cleaned.get('gstin'),
'tan': cleaned.get('tan'),
'cin_llpin': cleaned.get('cin_llpin'),
'msme_no': cleaned.get('msme_no'),
'iec_code': cleaned.get('iec_code'),
'contact_person_name': cleaned.get('contact_person_name'),
'contact_person_designation': cleaned.get('contact_person_designation'),
'mobile': cleaned.get('mobile'),
'alternate_mobile': cleaned.get('alternate_mobile'),
'email': cleaned.get('email'),
'alternate_email': cleaned.get('alternate_email'),
'address_line_1': cleaned.get('address_line_1'),
'address_line_2': cleaned.get('address_line_2'),
'city': cleaned.get('city'),
'state': cleaned.get('state'),
'pincode': cleaned.get('pincode'),
'country': cleaned.get('country') or 'India',
'status': cleaned.get('status') or 'active',
'client_category': cleaned.get('client_category'),
'risk_category': cleaned.get('risk_category'),
'onboarding_date': cleaned.get('onboarding_date'),
'closing_date': cleaned.get('closing_date'),
'notes': cleaned.get('notes'),
'gst_applicable': cleaned.get('gst_applicable') or False,
'income_tax_applicable': cleaned.get('income_tax_applicable') or False,
'tds_applicable': cleaned.get('tds_applicable') or False,
'roc_applicable': cleaned.get('roc_applicable') or False,
'audit_applicable': cleaned.get('audit_applicable') or False,
'pf_applicable': cleaned.get('pf_applicable') or False,
'esi_applicable': cleaned.get('esi_applicable') or False,
'professional_tax_applicable': cleaned.get('professional_tax_applicable') or False,
'payroll_applicable': cleaned.get('payroll_applicable') or False,
'msme_applicable': cleaned.get('msme_applicable') or False,
'import_export_applicable': cleaned.get('import_export_applicable') or False,
}
try:
ClientCreate(**payload)
except Exception as exc:
msgs.append(str(exc))
if not cleaned.get('portal_password'):
msgs.append('portal_password is required for imported clients.')
if cleaned.get('portal_password') != cleaned.get('portal_password_confirm'):
msgs.append('portal_password and portal_password_confirm must match.')
# intra-file duplicate client codes
if any(v.get('client_code') == payload['client_code'] and v.get('tenant_id') == firm_tenant_id for v in valid_rows):
msgs.append('Duplicate client_code found within the same upload file.')
if msgs:
errors.append({'row_number': row_idx, 'messages': msgs, 'row': cleaned})
continue
valid_rows.append({
'row_number': row_idx,
'tenant_id': firm_tenant_id,
'branch_id': branch_id,
'partner_id': partner_user_id,
'client_payload': payload,
'portal_password': cleaned.get('portal_password'),
'portal_password_confirm': cleaned.get('portal_password_confirm'),
})
return ImportPreview(valid_rows=valid_rows, errors=errors, total_rows=len(valid_rows) + len(errors))
def serialize_preview_rows(valid_rows: list[dict]) -> str:
return json.dumps(valid_rows, default=str)
def deserialize_preview_rows(raw: str) -> list[dict]:
rows = json.loads(raw or '[]')
return rows if isinstance(rows, list) else []
def commit_import(db: Session, *, current_user, scope, current_user_roles: list[str], preview_rows: list[dict]) -> dict:
created = []
failures = []
for item in preview_rows:
try:
data = ClientCreate(**item['client_payload'])
row = create_client_service(
db,
data=data,
actor_user_id=current_user.id,
scope=scope,
current_user_roles=current_user_roles,
portal_password=item.get('portal_password'),
portal_password_confirm=item.get('portal_password_confirm'),
)
created.append({'id': row.id, 'client_code': row.client_code, 'client_name': row.client_name})
except Exception as exc:
failures.append({'row_number': item.get('row_number'), 'message': str(getattr(exc, 'detail', exc))})
return {'created': created, 'failures': failures}
+85
View File
@@ -0,0 +1,85 @@
from __future__ import annotations
from datetime import date, datetime, timezone
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, JSON, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db.common import CommonBase
class Client(CommonBase):
__tablename__ = "clients"
__table_args__ = (
UniqueConstraint("tenant_id", "client_code", name="uq_clients_tenant_code"),
UniqueConstraint("tenant_id", "pan", name="uq_clients_tenant_pan"),
UniqueConstraint("tenant_id", "gstin", name="uq_clients_tenant_gstin"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id"), nullable=False, index=True)
branch_id: Mapped[int] = mapped_column(ForeignKey("branches.id"), nullable=False, index=True)
partner_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
default_review_partner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
engagement_mode: Mapped[str] = mapped_column(String(30), nullable=False, default="internal_managed", index=True)
client_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
client_name: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
trade_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
client_type: Mapped[str] = mapped_column(String(100), nullable=False, default="Other")
pan: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
gstin: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
tan: Mapped[str | None] = mapped_column(String(20), nullable=True)
cin_llpin: Mapped[str | None] = mapped_column(String(30), nullable=True)
msme_no: Mapped[str | None] = mapped_column(String(50), nullable=True)
iec_code: Mapped[str | None] = mapped_column(String(30), nullable=True)
contact_person_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
contact_person_designation: Mapped[str | None] = mapped_column(String(200), nullable=True)
mobile: Mapped[str | None] = mapped_column(String(20), nullable=True)
alternate_mobile: Mapped[str | None] = mapped_column(String(20), nullable=True)
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
alternate_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
address_line_1: Mapped[str | None] = mapped_column(String(255), nullable=True)
address_line_2: Mapped[str | None] = mapped_column(String(255), nullable=True)
city: Mapped[str | None] = mapped_column(String(100), nullable=True)
state: Mapped[str | None] = mapped_column(String(100), nullable=True)
pincode: Mapped[str | None] = mapped_column(String(20), nullable=True)
country: Mapped[str | None] = mapped_column(String(100), nullable=True, default="India")
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active", index=True)
client_category: Mapped[str | None] = mapped_column(String(100), nullable=True)
risk_category: Mapped[str | None] = mapped_column(String(50), nullable=True)
onboarding_date: Mapped[date | None] = mapped_column(Date, nullable=True)
closing_date: Mapped[date | None] = mapped_column(Date, nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
gst_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
income_tax_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
tds_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
roc_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
audit_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
pf_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
esi_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
professional_tax_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
payroll_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
msme_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
import_export_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
is_archived: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
archived_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
portal_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
class ClientAuditLog(CommonBase):
__tablename__ = "client_audit_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id"), nullable=False, index=True)
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id"), nullable=False, index=True)
branch_id: Mapped[int] = mapped_column(ForeignKey("branches.id"), nullable=False, index=True)
actor_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
action: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
summary: Mapped[str] = mapped_column(String(255), nullable=False)
payload_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
+15
View File
@@ -0,0 +1,15 @@
CLIENT_PERMISSION_CODES = {
"view": "clients.view",
"create": "clients.create",
"edit": "clients.edit",
"deactivate": "clients.deactivate",
"activate": "clients.activate",
"archive": "clients.archive",
"restore": "clients.restore",
"assign_partner": "clients.assign_partner",
"cross_branch": "clients.cross_branch",
"cross_tenant": "clients.cross_tenant",
"export": "clients.export",
"audit_log_view": "clients.audit_log.view",
"view_own_only": "clients.view.own_only",
}
+244
View File
@@ -0,0 +1,244 @@
from __future__ import annotations
from collections import Counter
from datetime import date
from typing import Any
from sqlalchemy import select, func
from sqlalchemy.orm import Session, selectinload
from app.modules.documents.models import EngagementDocument, PermanentClientDocument
from app.modules.services.models import (
ClientServiceSubscription,
ClientServiceTaskInstance,
ServiceTaskComment,
)
OPEN_TASK_STATUSES = {"pending", "in_progress", "blocked", "ready_for_review", "rework"}
CLOSED_TASK_STATUSES = {"completed", "approved", "closed", "not_applicable"}
def _client_id(client_row: dict[str, Any]) -> int:
return int(client_row.get("id") or 0)
def _tenant_id(client_row: dict[str, Any]) -> int:
return int(client_row.get("tenant_id") or 0)
def list_client_engagements(db: Session, client_row: dict[str, Any], *, limit: int = 200, financial_year: str | None = None) -> list[ClientServiceSubscription]:
"""Return engagements/subscriptions visible to the logged-in client."""
query = (
select(ClientServiceSubscription)
.options(
selectinload(ClientServiceSubscription.catalogue),
selectinload(ClientServiceSubscription.assigned_partner),
selectinload(ClientServiceSubscription.assigned_manager),
selectinload(ClientServiceSubscription.assigned_staff),
)
.where(
ClientServiceSubscription.tenant_id == _tenant_id(client_row),
ClientServiceSubscription.client_id == _client_id(client_row),
ClientServiceSubscription.is_active.is_(True),
)
)
if financial_year:
query = query.where(ClientServiceSubscription.financial_year == financial_year.strip())
rows = db.execute(
query.order_by(
ClientServiceSubscription.current_due_date.asc().nulls_last(),
ClientServiceSubscription.updated_at_utc.desc(),
)
.limit(max(1, min(int(limit or 200), 500)))
).scalars().all()
return rows
def list_client_tasks_for_engagement(db: Session, client_row: dict[str, Any], engagement_id: int) -> list[ClientServiceTaskInstance]:
return db.execute(
select(ClientServiceTaskInstance)
.options(
selectinload(ClientServiceTaskInstance.catalogue),
selectinload(ClientServiceTaskInstance.assigned_to),
selectinload(ClientServiceTaskInstance.comments).selectinload(ServiceTaskComment.created_by),
)
.where(
ClientServiceTaskInstance.tenant_id == _tenant_id(client_row),
ClientServiceTaskInstance.client_id == _client_id(client_row),
ClientServiceTaskInstance.subscription_id == int(engagement_id),
ClientServiceTaskInstance.is_active.is_(True),
)
.order_by(ClientServiceTaskInstance.sequence_no.asc(), ClientServiceTaskInstance.id.asc())
).scalars().all()
def get_client_engagement(db: Session, client_row: dict[str, Any], engagement_id: int, *, financial_year: str | None = None) -> ClientServiceSubscription | None:
query = (
select(ClientServiceSubscription)
.options(
selectinload(ClientServiceSubscription.catalogue),
selectinload(ClientServiceSubscription.assigned_partner),
selectinload(ClientServiceSubscription.assigned_manager),
selectinload(ClientServiceSubscription.assigned_staff),
)
.where(
ClientServiceSubscription.id == int(engagement_id),
ClientServiceSubscription.tenant_id == _tenant_id(client_row),
ClientServiceSubscription.client_id == _client_id(client_row),
ClientServiceSubscription.is_active.is_(True),
)
)
if financial_year:
query = query.where(ClientServiceSubscription.financial_year == financial_year.strip())
return db.execute(query).scalar_one_or_none()
def get_client_task(db: Session, client_row: dict[str, Any], task_id: int, *, financial_year: str | None = None) -> ClientServiceTaskInstance | None:
query = (
select(ClientServiceTaskInstance)
.where(
ClientServiceTaskInstance.id == int(task_id),
ClientServiceTaskInstance.tenant_id == _tenant_id(client_row),
ClientServiceTaskInstance.client_id == _client_id(client_row),
ClientServiceTaskInstance.is_active.is_(True),
)
)
if financial_year:
query = query.where(ClientServiceTaskInstance.financial_year == financial_year.strip())
return db.execute(query).scalar_one_or_none()
def list_client_visible_comments(db: Session, client_row: dict[str, Any], *, limit: int = 100, financial_year: str | None = None) -> list[ServiceTaskComment]:
query = (
select(ServiceTaskComment)
.options(
selectinload(ServiceTaskComment.created_by),
selectinload(ServiceTaskComment.task).selectinload(ClientServiceTaskInstance.catalogue),
selectinload(ServiceTaskComment.subscription).selectinload(ClientServiceSubscription.catalogue),
)
.join(ClientServiceTaskInstance, ClientServiceTaskInstance.id == ServiceTaskComment.task_instance_id)
.where(
ServiceTaskComment.tenant_id == _tenant_id(client_row),
ServiceTaskComment.visibility == "client",
ServiceTaskComment.is_deleted.is_(False),
ClientServiceTaskInstance.client_id == _client_id(client_row),
ClientServiceTaskInstance.tenant_id == _tenant_id(client_row),
ClientServiceTaskInstance.is_active.is_(True),
)
)
if financial_year:
query = query.where(ClientServiceTaskInstance.financial_year == financial_year.strip())
return db.execute(
query.order_by(ServiceTaskComment.created_at_utc.desc(), ServiceTaskComment.id.desc())
.limit(max(1, min(int(limit or 100), 300)))
).scalars().all()
def create_client_reply(db: Session, *, client_row: dict[str, Any], task: ClientServiceTaskInstance, message: str, user) -> ServiceTaskComment:
clean_message = (message or "").strip()
if not clean_message:
raise ValueError("Reply message is required.")
if len(clean_message) > 4000:
raise ValueError("Reply message is too long. Please keep it within 4000 characters.")
comment = ServiceTaskComment(
tenant_id=task.tenant_id,
branch_id=task.branch_id,
subscription_id=task.subscription_id,
task_instance_id=task.id,
comment_type="client_clarification",
visibility="client",
message=clean_message,
created_by_user_id=getattr(user, "id", None),
)
db.add(comment)
db.flush()
return comment
def list_client_engagement_documents(db: Session, client_row: dict[str, Any], *, engagement_id: int | None = None, financial_year: str | None = None) -> list[EngagementDocument]:
stmt = (
select(EngagementDocument)
.options(selectinload(EngagementDocument.versions), selectinload(EngagementDocument.engagement).selectinload(ClientServiceSubscription.catalogue))
.where(
EngagementDocument.tenant_id == _tenant_id(client_row),
EngagementDocument.client_id == _client_id(client_row),
EngagementDocument.is_deleted.is_(False),
)
)
if engagement_id is not None:
stmt = stmt.where(EngagementDocument.engagement_id == int(engagement_id))
if financial_year:
stmt = stmt.where(EngagementDocument.financial_year == financial_year.strip())
return db.execute(stmt.order_by(EngagementDocument.updated_at_utc.desc())).unique().scalars().all()
def list_client_permanent_documents(db: Session, client_row: dict[str, Any]) -> list[PermanentClientDocument]:
return db.execute(
select(PermanentClientDocument)
.options(selectinload(PermanentClientDocument.versions))
.where(
PermanentClientDocument.tenant_id == _tenant_id(client_row),
PermanentClientDocument.client_id == _client_id(client_row),
PermanentClientDocument.is_deleted.is_(False),
)
.order_by(PermanentClientDocument.category.asc(), PermanentClientDocument.updated_at_utc.desc())
).unique().scalars().all()
def build_client_portal_summary(db: Session, client_row: dict[str, Any], *, financial_year: str | None = None) -> dict[str, Any]:
engagements = list_client_engagements(db, client_row, limit=500, financial_year=financial_year)
engagement_ids = [row.id for row in engagements]
today = date.today()
task_rows: list[ClientServiceTaskInstance] = []
if engagement_ids:
task_rows = db.execute(
select(ClientServiceTaskInstance).where(
ClientServiceTaskInstance.tenant_id == _tenant_id(client_row),
ClientServiceTaskInstance.client_id == _client_id(client_row),
ClientServiceTaskInstance.subscription_id.in_(engagement_ids),
ClientServiceTaskInstance.is_active.is_(True),
)
).scalars().all()
status_counter = Counter((task.status or "pending") for task in task_rows)
open_tasks = [task for task in task_rows if (task.status or "pending") in OPEN_TASK_STATUSES]
overdue_tasks = [
task for task in open_tasks
if task.internal_target_date is not None and task.internal_target_date < today
]
due_soon_engagements = [
row for row in engagements
if row.current_due_date is not None and row.current_due_date >= today
][:10]
pending_from_client = 0
with_firm = 0
completed = 0
clarification_required = 0
for row in engagements:
tasks_for_eng = [t for t in task_rows if t.subscription_id == row.id]
statuses = {(t.status or "pending") for t in tasks_for_eng}
if statuses & {"blocked", "client_pending", "clarification_required"}:
clarification_required += 1
elif tasks_for_eng and all((t.status or "pending") in CLOSED_TASK_STATUSES for t in tasks_for_eng):
completed += 1
elif statuses & {"pending"}:
pending_from_client += 1
else:
with_firm += 1
return {
"engagements": engagements,
"task_rows": task_rows,
"status_counter": status_counter,
"total_engagements": len(engagements),
"open_tasks": len(open_tasks),
"overdue_tasks": len(overdue_tasks),
"completed_tasks": status_counter.get("completed", 0) + status_counter.get("approved", 0) + status_counter.get("closed", 0),
"due_soon_engagements": due_soon_engagements,
"pending_from_client": pending_from_client,
"with_firm": with_firm,
"clarification_required": clarification_required,
"completed_engagements": completed,
}
+523
View File
@@ -0,0 +1,523 @@
from __future__ import annotations
from math import ceil
from sqlalchemy import asc, case, desc, func, or_, select
from sqlalchemy.orm import Session
from app.core.security.passwords import hash_password
from app.modules.clients.association_models import ClientAssociation
from app.modules.clients.constants import CLIENT_SORT_FIELDS
from app.modules.clients.models import Client, ClientAuditLog
from app.modules.core.iam.models import User
from app.modules.core.rbac.models import Role, UserRole
from app.modules.core.tenancy.models import Branch, Tenant
def _safe_sort(sort_by: str, sort_order: str):
attr_name = CLIENT_SORT_FIELDS.get(sort_by, "client_name")
column = getattr(Client, attr_name)
return desc(column) if sort_order == "desc" else asc(column)
def build_clients_query(
*,
tenant_id: int,
branch_id: int | None = None,
allow_cross_branch: bool = False,
allow_all_clients: bool = False,
partner_id: int | None = None,
q: str = "",
status: str = "",
client_type: str = "",
include_archived: bool = False,
):
assoc = ClientAssociation
stmt = (
select(
Client,
User.full_name.label("partner_name"),
Branch.name.label("branch_name"),
Tenant.name.label("tenant_name"),
assoc.association_type.label("association_type"),
assoc.firm_tenant_id.label("assoc_firm_tenant_id"),
assoc.consultant_id.label("assoc_consultant_id"),
assoc.partner_user_id.label("assoc_partner_user_id"),
assoc.created_source.label("assoc_created_source"),
)
.outerjoin(assoc, assoc.client_id == Client.id)
.join(User, User.id == Client.partner_id, isouter=True)
.join(Branch, Branch.id == Client.branch_id, isouter=True)
.join(Tenant, Tenant.id == Client.tenant_id, isouter=True)
)
if not allow_all_clients:
stmt = stmt.where(Client.tenant_id == tenant_id)
if not include_archived:
stmt = stmt.where(Client.is_archived.is_(False))
if branch_id and not allow_all_clients and not allow_cross_branch:
stmt = stmt.where(Client.branch_id == branch_id)
if partner_id:
stmt = stmt.where(
(Client.partner_id == partner_id) | (assoc.partner_user_id == partner_id)
)
if status:
stmt = stmt.where(Client.status == status)
if client_type:
stmt = stmt.where(Client.client_type == client_type)
if q:
like = f"%{q.strip()}%"
stmt = stmt.where(
or_(
Client.client_code.ilike(like),
Client.client_name.ilike(like),
Client.trade_name.ilike(like),
Client.pan.ilike(like),
Client.gstin.ilike(like),
Client.mobile.ilike(like),
Client.email.ilike(like),
)
)
return stmt
def list_clients(
db: Session,
*,
tenant_id: int,
branch_id: int | None = None,
allow_cross_branch: bool = False,
allow_all_clients: bool = False,
partner_id: int | None = None,
q: str = "",
status: str = "",
client_type: str = "",
include_archived: bool = False,
page: int = 1,
per_page: int = 10,
sort_by: str = "client_name",
sort_order: str = "asc",
) -> dict:
stmt = build_clients_query(
tenant_id=tenant_id,
branch_id=branch_id,
allow_cross_branch=allow_cross_branch,
allow_all_clients=allow_all_clients,
partner_id=partner_id,
q=q,
status=status,
client_type=client_type,
include_archived=include_archived,
)
total = db.execute(select(func.count()).select_from(stmt.subquery())).scalar_one()
result = db.execute(
stmt.order_by(_safe_sort(sort_by, sort_order))
.offset((page - 1) * per_page)
.limit(per_page)
).all()
rows = []
for (
client,
partner_name,
branch_name,
tenant_name,
association_type,
assoc_firm_tenant_id,
assoc_consultant_id,
assoc_partner_user_id,
assoc_created_source,
) in result:
row = {**client.__dict__}
row.pop("_sa_instance_state", None)
row.update(
{
"partner_name": partner_name,
"branch_name": branch_name,
"tenant_name": tenant_name,
"association_type": association_type,
"assoc_firm_tenant_id": assoc_firm_tenant_id,
"assoc_consultant_id": assoc_consultant_id,
"assoc_partner_user_id": assoc_partner_user_id,
"assoc_created_source": assoc_created_source,
"effective_partner_id": assoc_partner_user_id or row.get("partner_id"),
}
)
rows.append(row)
stats_stmt = select(
func.count(Client.id),
func.sum(case((Client.status == "active", 1), else_=0)),
func.sum(case((Client.status == "inactive", 1), else_=0)),
func.sum(case((Client.status == "archived", 1), else_=0)),
)
if not allow_all_clients:
stats_stmt = stats_stmt.where(Client.tenant_id == tenant_id)
if branch_id and not allow_cross_branch:
stats_stmt = stats_stmt.where(Client.branch_id == branch_id)
if partner_id:
stats_stmt = stats_stmt.where(Client.partner_id == partner_id)
total_all, active, inactive, archived = db.execute(stats_stmt).one()
pages = ceil(total / per_page) if per_page else 1
return {
"rows": rows,
"meta": {
"total": total,
"page": page,
"per_page": per_page,
"pages": max(pages, 1),
},
"stats": {
"total": int(total_all or 0),
"active": int(active or 0),
"inactive": int(inactive or 0),
"archived": int(archived or 0),
},
}
def get_client_detail_payload(db: Session, client_id: int):
assoc = ClientAssociation
stmt = (
select(
Client,
assoc.association_type.label("association_type"),
assoc.firm_tenant_id.label("assoc_firm_tenant_id"),
assoc.consultant_id.label("assoc_consultant_id"),
assoc.partner_user_id.label("assoc_partner_user_id"),
assoc.created_source.label("assoc_created_source"),
)
.outerjoin(assoc, assoc.client_id == Client.id)
.where(Client.id == client_id)
)
result = db.execute(stmt).one_or_none()
if not result:
return None
(
client,
association_type,
assoc_firm_tenant_id,
assoc_consultant_id,
assoc_partner_user_id,
assoc_created_source,
) = result
row = {**client.__dict__}
row.pop("_sa_instance_state", None)
row.update(
{
"association_type": association_type,
"assoc_firm_tenant_id": assoc_firm_tenant_id,
"assoc_consultant_id": assoc_consultant_id,
"assoc_partner_user_id": assoc_partner_user_id,
"assoc_created_source": assoc_created_source,
"effective_partner_id": assoc_partner_user_id or row.get("partner_id"),
}
)
return row
def get_client_by_id(db: Session, client_id: int):
return db.get(Client, client_id)
def get_client_by_code(db: Session, *, tenant_id: int, client_code: str):
return db.execute(
select(Client).where(Client.tenant_id == tenant_id, Client.client_code == client_code)
).scalar_one_or_none()
def get_client_by_pan(db: Session, *, tenant_id: int, pan: str):
return db.execute(
select(Client).where(Client.tenant_id == tenant_id, Client.pan == pan)
).scalar_one_or_none()
def get_client_by_gstin(db: Session, *, tenant_id: int, gstin: str):
return db.execute(
select(Client).where(Client.tenant_id == tenant_id, Client.gstin == gstin)
).scalar_one_or_none()
def create_client(db: Session, payload: dict):
row = Client(**payload)
db.add(row)
db.commit()
db.refresh(row)
return row
def update_client(db: Session, row: Client, payload: dict):
for key, value in payload.items():
setattr(row, key, value)
db.add(row)
db.commit()
db.refresh(row)
return row
def write_audit_log(db: Session, **kwargs):
row = ClientAuditLog(**kwargs)
db.add(row)
db.commit()
db.refresh(row)
return row
def list_audit_logs(db: Session, *, client_id: int, limit: int = 50):
stmt = (
select(ClientAuditLog)
.where(ClientAuditLog.client_id == client_id)
.order_by(ClientAuditLog.created_at_utc.desc())
.limit(limit)
)
return db.execute(stmt).scalars().all()
def list_tenants(db: Session):
return db.execute(
select(Tenant).where(Tenant.is_active.is_(True)).order_by(Tenant.name.asc())
).scalars().all()
def list_branches_for_tenant(db: Session, tenant_id: int):
stmt = (
select(Branch)
.where(Branch.tenant_id == tenant_id, Branch.is_active.is_(True))
.order_by(Branch.name.asc())
)
return db.execute(stmt).scalars().all()
def list_partners_for_scope(db: Session, *, tenant_id: int, branch_id: int | None = None):
stmt = (
select(User)
.join(UserRole, UserRole.user_id == User.id)
.join(Role, Role.id == UserRole.role_id)
.where(
Role.name == "Partner",
User.tenant_id == tenant_id,
User.is_active.is_(True),
User.deleted_at.is_(None),
)
.order_by(User.full_name.asc(), User.email.asc())
)
if branch_id:
stmt = stmt.where(User.branch_id == branch_id)
return db.execute(stmt).scalars().all()
def get_branch(db: Session, branch_id: int):
return db.execute(
select(Branch).where(Branch.id == branch_id, Branch.is_active.is_(True))
).scalar_one_or_none()
def get_partner(db: Session, partner_id: int):
return db.execute(
select(User).where(User.id == partner_id, User.is_active.is_(True), User.deleted_at.is_(None))
).scalar_one_or_none()
def list_all_branches(db: Session):
stmt = (
select(
Branch,
Tenant.name.label("tenant_name"),
)
.join(Tenant, Tenant.id == Branch.tenant_id)
.where(Branch.is_active.is_(True))
.order_by(Tenant.name.asc(), Branch.name.asc())
)
rows = []
for branch, tenant_name in db.execute(stmt).all():
branch.tenant_name = tenant_name
rows.append(branch)
return rows
def list_all_partners(db: Session):
stmt = (
select(
User,
Tenant.name.label("tenant_name"),
)
.join(UserRole, UserRole.user_id == User.id)
.join(Role, Role.id == UserRole.role_id)
.join(Tenant, Tenant.id == User.tenant_id, isouter=True)
.where(
Role.name == "Partner",
User.is_active.is_(True),
User.deleted_at.is_(None),
)
.order_by(Tenant.name.asc(), User.full_name.asc(), User.email.asc())
)
rows = []
for user, tenant_name in db.execute(stmt).all():
user.tenant_name = tenant_name
rows.append(user)
return rows
def list_all_branches(db: Session):
stmt = (
select(
Branch,
Tenant.name.label("tenant_name"),
)
.join(Tenant, Tenant.id == Branch.tenant_id)
.where(Branch.is_active.is_(True))
.order_by(Tenant.name.asc(), Branch.name.asc())
)
rows = []
for branch, tenant_name in db.execute(stmt).all():
branch.tenant_name = tenant_name
rows.append(branch)
return rows
def list_all_partners(db: Session):
stmt = (
select(
User,
Tenant.name.label("tenant_name"),
)
.join(UserRole, UserRole.user_id == User.id)
.join(Role, Role.id == UserRole.role_id)
.join(Tenant, Tenant.id == User.tenant_id, isouter=True)
.where(
Role.name == "Partner",
User.is_active.is_(True),
User.deleted_at.is_(None),
)
.order_by(Tenant.name.asc(), User.full_name.asc(), User.email.asc())
)
rows = []
for user, tenant_name in db.execute(stmt).all():
user.tenant_name = tenant_name
rows.append(user)
return rows
def get_portal_client_for_user(db: Session, *, user: User):
email = (getattr(user, "email", "") or "").strip().lower()
tenant_id = getattr(user, "tenant_id", None)
if not email or not tenant_id:
return None
stmt = (
select(
Client,
User.full_name.label("partner_name"),
Branch.name.label("branch_name"),
Tenant.name.label("tenant_name"),
)
.join(User, User.id == Client.partner_id, isouter=True)
.join(Branch, Branch.id == Client.branch_id, isouter=True)
.join(Tenant, Tenant.id == Client.tenant_id, isouter=True)
.where(
Client.tenant_id == tenant_id,
Client.is_archived.is_(False),
or_(Client.email.ilike(email), Client.alternate_email.ilike(email)),
)
.order_by(
case((Client.status == "active", 0), else_=1),
Client.client_name.asc(),
Client.id.asc(),
)
)
result = db.execute(stmt).first()
if not result:
return None
client, partner_name, branch_name, tenant_name = result
row = {**client.__dict__}
row.pop("_sa_instance_state", None)
row.update(
{
"partner_name": partner_name,
"branch_name": branch_name,
"tenant_name": tenant_name,
}
)
return row
def get_user_by_email(db: Session, *, email: str, exclude_user_id: int | None = None):
email_clean = (email or "").strip().lower()
if not email_clean:
return None
stmt = select(User).where(User.email.ilike(email_clean), User.deleted_at.is_(None))
if exclude_user_id:
stmt = stmt.where(User.id != exclude_user_id)
return db.execute(stmt).scalar_one_or_none()
def get_tenant(db: Session, tenant_id: int):
return db.execute(select(Tenant).where(Tenant.id == tenant_id, Tenant.is_active.is_(True))).scalar_one_or_none()
def get_partner_for_tenant(db: Session, *, partner_user_id: int, tenant_id: int):
stmt = (
select(User)
.join(UserRole, UserRole.user_id == User.id)
.join(Role, Role.id == UserRole.role_id)
.where(
User.id == partner_user_id,
User.tenant_id == tenant_id,
User.is_active.is_(True),
User.deleted_at.is_(None),
Role.name == "Partner",
)
)
return db.execute(stmt).scalar_one_or_none()
def get_role_by_name(db: Session, role_name: str):
return db.execute(select(Role).where(Role.name == role_name, Role.is_active.is_(True))).scalar_one_or_none()
def create_portal_user(db: Session, *, email: str, full_name: str, tenant_id: int, branch_id: int, password: str):
row = User(
email=(email or '').strip().lower(),
full_name=(full_name or '').strip(),
password_hash=hash_password(password),
tenant_id=tenant_id,
branch_id=branch_id,
is_active=True,
allow_login=True,
is_locked=False,
must_change_password=False,
)
db.add(row)
db.commit()
db.refresh(row)
return row
def ensure_user_role(db: Session, *, user_id: int, role_id: int):
existing = db.execute(select(UserRole).where(UserRole.user_id == user_id, UserRole.role_id == role_id)).scalar_one_or_none()
if existing:
return existing
row = UserRole(user_id=user_id, role_id=role_id)
db.add(row)
db.commit()
db.refresh(row)
return row
+386
View File
@@ -0,0 +1,386 @@
from __future__ import annotations
from datetime import date, datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, EmailStr, field_validator, model_validator
from app.modules.clients.constants import (
CLIENT_CATEGORY_OPTIONS,
CLIENT_SORT_FIELDS,
CLIENT_STATUS,
CLIENT_TYPES,
ENGAGEMENT_MODES,
RISK_CATEGORIES,
)
from app.modules.clients.utils import GSTIN_RE, MOBILE_RE, PAN_RE, PIN_RE, TAN_RE, normalize_text, normalize_upper
class ClientBase(BaseModel):
tenant_id: int
branch_id: int
partner_id: Optional[int] = None
default_review_partner_user_id: Optional[int] = None
engagement_mode: str = "internal_managed"
client_code: str
client_name: str
trade_name: Optional[str] = None
client_type: str = "Other"
pan: Optional[str] = None
gstin: Optional[str] = None
tan: Optional[str] = None
cin_llpin: Optional[str] = None
msme_no: Optional[str] = None
iec_code: Optional[str] = None
contact_person_name: Optional[str] = None
contact_person_designation: Optional[str] = None
mobile: Optional[str] = None
alternate_mobile: Optional[str] = None
email: Optional[EmailStr] = None
alternate_email: Optional[EmailStr] = None
address_line_1: Optional[str] = None
address_line_2: Optional[str] = None
city: Optional[str] = None
state: Optional[str] = None
pincode: Optional[str] = None
country: Optional[str] = "India"
status: str = "active"
client_category: Optional[str] = None
risk_category: Optional[str] = None
onboarding_date: Optional[date] = None
closing_date: Optional[date] = None
notes: Optional[str] = None
gst_applicable: bool = False
income_tax_applicable: bool = False
tds_applicable: bool = False
roc_applicable: bool = False
audit_applicable: bool = False
pf_applicable: bool = False
esi_applicable: bool = False
professional_tax_applicable: bool = False
payroll_applicable: bool = False
msme_applicable: bool = False
import_export_applicable: bool = False
@field_validator("client_code", "client_name", mode="before")
@classmethod
def required_text(cls, value):
value = normalize_text(value)
if not value:
raise ValueError("This field is required.")
return value
@field_validator(
"trade_name", "cin_llpin", "msme_no", "iec_code", "contact_person_name", "contact_person_designation",
"address_line_1", "address_line_2", "city", "state", "country", "client_category", "risk_category", "notes",
mode="before",
)
@classmethod
def clean_text(cls, value):
return normalize_text(value)
@field_validator("pan", "gstin", "tan", mode="before")
@classmethod
def uppercase_codes(cls, value):
return normalize_upper(value)
@field_validator("engagement_mode", mode="before")
@classmethod
def clean_engagement_mode(cls, value):
value = normalize_text(value) or "internal_managed"
return value.lower()
@field_validator("engagement_mode")
@classmethod
def validate_engagement_mode(cls, value):
if value not in ENGAGEMENT_MODES:
raise ValueError("Invalid engagement mode.")
return value
@field_validator("mobile", "alternate_mobile", mode="before")
@classmethod
def clean_mobile(cls, value):
value = normalize_text(value)
if value is None:
return None
value = value.replace(" ", "").replace("-", "")
if value.startswith("+91"):
value = value[3:]
return value
@field_validator("pan")
@classmethod
def validate_pan(cls, value):
if value and not PAN_RE.match(value):
raise ValueError("Invalid PAN format.")
return value
@field_validator("gstin")
@classmethod
def validate_gstin(cls, value):
if value and not GSTIN_RE.match(value):
raise ValueError("Invalid GSTIN format.")
return value
@field_validator("tan")
@classmethod
def validate_tan(cls, value):
if value and not TAN_RE.match(value):
raise ValueError("Invalid TAN format.")
return value
@field_validator("mobile", "alternate_mobile")
@classmethod
def validate_mobile(cls, value):
if value and not MOBILE_RE.match(value):
raise ValueError("Mobile number must be a valid 10-digit Indian mobile.")
return value
@field_validator("pincode", mode="before")
@classmethod
def clean_pincode(cls, value):
return normalize_text(value)
@field_validator("pincode")
@classmethod
def validate_pincode(cls, value):
if value and not PIN_RE.match(value):
raise ValueError("Pincode must be a valid 6-digit code.")
return value
@model_validator(mode="after")
def validate_dates_and_assignment(self):
if self.onboarding_date and self.closing_date and self.closing_date < self.onboarding_date:
raise ValueError("Closing date cannot be earlier than onboarding date.")
if self.engagement_mode == "internal_managed" and not self.partner_id:
raise ValueError("Partner is required for internal managed clients.")
return self
class ClientCreate(ClientBase):
pass
class ClientUpdate(BaseModel):
tenant_id: Optional[int] = None
branch_id: Optional[int] = None
partner_id: Optional[int] = None
default_review_partner_user_id: Optional[int] = None
engagement_mode: Optional[str] = None
client_name: Optional[str] = None
trade_name: Optional[str] = None
client_type: Optional[str] = None
pan: Optional[str] = None
gstin: Optional[str] = None
tan: Optional[str] = None
cin_llpin: Optional[str] = None
msme_no: Optional[str] = None
iec_code: Optional[str] = None
contact_person_name: Optional[str] = None
contact_person_designation: Optional[str] = None
mobile: Optional[str] = None
alternate_mobile: Optional[str] = None
email: Optional[EmailStr] = None
alternate_email: Optional[EmailStr] = None
address_line_1: Optional[str] = None
address_line_2: Optional[str] = None
city: Optional[str] = None
state: Optional[str] = None
pincode: Optional[str] = None
country: Optional[str] = None
status: Optional[str] = None
client_category: Optional[str] = None
risk_category: Optional[str] = None
onboarding_date: Optional[date] = None
closing_date: Optional[date] = None
notes: Optional[str] = None
gst_applicable: Optional[bool] = None
income_tax_applicable: Optional[bool] = None
tds_applicable: Optional[bool] = None
roc_applicable: Optional[bool] = None
audit_applicable: Optional[bool] = None
pf_applicable: Optional[bool] = None
esi_applicable: Optional[bool] = None
professional_tax_applicable: Optional[bool] = None
payroll_applicable: Optional[bool] = None
msme_applicable: Optional[bool] = None
import_export_applicable: Optional[bool] = None
model_config = ConfigDict(extra="forbid")
@field_validator(
"client_name", "trade_name", "cin_llpin", "msme_no", "iec_code", "contact_person_name", "contact_person_designation",
"address_line_1", "address_line_2", "city", "state", "country", "client_category", "risk_category", "notes",
mode="before",
)
@classmethod
def clean_text(cls, value):
return normalize_text(value)
@field_validator("pan", "gstin", "tan", mode="before")
@classmethod
def uppercase_codes(cls, value):
return normalize_upper(value)
@field_validator("engagement_mode", mode="before")
@classmethod
def clean_engagement_mode(cls, value):
if value is None:
return None
value = normalize_text(value) or None
return value.lower() if value else None
@field_validator("engagement_mode")
@classmethod
def validate_engagement_mode(cls, value):
if value is not None and value not in ENGAGEMENT_MODES:
raise ValueError("Invalid engagement mode.")
return value
@field_validator("mobile", "alternate_mobile", mode="before")
@classmethod
def clean_mobile(cls, value):
value = normalize_text(value)
if value is None:
return None
value = value.replace(" ", "").replace("-", "")
if value.startswith("+91"):
value = value[3:]
return value
@field_validator("pan")
@classmethod
def validate_pan(cls, value):
if value and not PAN_RE.match(value):
raise ValueError("Invalid PAN format.")
return value
@field_validator("gstin")
@classmethod
def validate_gstin(cls, value):
if value and not GSTIN_RE.match(value):
raise ValueError("Invalid GSTIN format.")
return value
@field_validator("tan")
@classmethod
def validate_tan(cls, value):
if value and not TAN_RE.match(value):
raise ValueError("Invalid TAN format.")
return value
@field_validator("mobile", "alternate_mobile")
@classmethod
def validate_mobile(cls, value):
if value and not MOBILE_RE.match(value):
raise ValueError("Mobile number must be a valid 10-digit Indian mobile.")
return value
@field_validator("pincode", mode="before")
@classmethod
def clean_pincode(cls, value):
return normalize_text(value)
@field_validator("pincode")
@classmethod
def validate_pincode(cls, value):
if value and not PIN_RE.match(value):
raise ValueError("Pincode must be a valid 6-digit code.")
return value
class ClientOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
tenant_id: int
branch_id: int
partner_id: Optional[int] = None
default_review_partner_user_id: Optional[int] = None
engagement_mode: str
client_code: str
client_name: str
trade_name: Optional[str] = None
client_type: str
pan: Optional[str] = None
gstin: Optional[str] = None
tan: Optional[str] = None
cin_llpin: Optional[str] = None
msme_no: Optional[str] = None
iec_code: Optional[str] = None
contact_person_name: Optional[str] = None
contact_person_designation: Optional[str] = None
mobile: Optional[str] = None
alternate_mobile: Optional[str] = None
email: Optional[str] = None
alternate_email: Optional[str] = None
address_line_1: Optional[str] = None
address_line_2: Optional[str] = None
city: Optional[str] = None
state: Optional[str] = None
pincode: Optional[str] = None
country: Optional[str] = None
status: str
client_category: Optional[str] = None
risk_category: Optional[str] = None
onboarding_date: Optional[date] = None
closing_date: Optional[date] = None
notes: Optional[str] = None
gst_applicable: bool
income_tax_applicable: bool
tds_applicable: bool
roc_applicable: bool
audit_applicable: bool
pf_applicable: bool
esi_applicable: bool
professional_tax_applicable: bool
payroll_applicable: bool
msme_applicable: bool
import_export_applicable: bool
is_active: bool
is_archived: bool
created_at_utc: datetime
updated_at_utc: datetime
class ClientListRow(ClientOut):
partner_name: Optional[str] = None
branch_name: Optional[str] = None
tenant_name: Optional[str] = None
class ClientAuditLogOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
client_id: int
tenant_id: int
branch_id: int
actor_user_id: Optional[int] = None
action: str
summary: str
payload_json: Optional[dict] = None
created_at_utc: datetime
class PaginationMeta(BaseModel):
total: int
page: int
per_page: int
class ClientFilterOptions(BaseModel):
client_types: list[str]
client_statuses: list[str]
client_categories: list[str]
risk_categories: list[str]
class ClientListStats(BaseModel):
total: int = 0
active: int = 0
inactive: int = 0
archived: int = 0
class ClientListResponse(BaseModel):
rows: list[ClientOut]
meta: PaginationMeta
stats: ClientListStats | None = None
filter_options: ClientFilterOptions | None = None
+452
View File
@@ -0,0 +1,452 @@
from __future__ import annotations
import csv
import io
from fastapi import HTTPException
from app.modules.clients import repository
from app.core.security.passwords import hash_password
from app.modules.clients.association_admin_service import (
ensure_active_association,
update_association_fields,
)
from app.modules.clients.constants import (
CLIENT_CATEGORY_OPTIONS,
CLIENT_STATUS,
CLIENT_TYPES,
RISK_CATEGORIES,
)
def _payload_from_schema(data):
return data.model_dump(exclude_none=True) if hasattr(data, "model_dump") else data.dict(exclude_none=True)
def _ensure_portal_passwords(email: str | None, portal_password: str | None, portal_password_confirm: str | None, *, required: bool):
email_clean = (email or '').strip().lower()
pw = (portal_password or '').strip()
pw2 = (portal_password_confirm or '').strip()
if not required and not pw and not pw2:
return email_clean, None
if not email_clean:
raise HTTPException(status_code=400, detail="Email is required to create the client frontend login.")
if len(pw) < 8:
raise HTTPException(status_code=400, detail="Portal password must be at least 8 characters.")
if pw != pw2:
raise HTTPException(status_code=400, detail="Portal password and confirm password do not match.")
return email_clean, pw
def _sync_client_portal_user(db, *, row, portal_password: str | None = None, portal_password_confirm: str | None = None):
email_clean, pw = _ensure_portal_passwords(
getattr(row, 'email', None),
portal_password,
portal_password_confirm,
required=bool(portal_password or portal_password_confirm or not getattr(row, 'portal_user_id', None)),
)
if not pw:
return row
existing_user = repository.get_user_by_email(db, email=email_clean, exclude_user_id=getattr(row, 'portal_user_id', None))
if existing_user:
raise HTTPException(status_code=400, detail="That email is already used by another login.")
if row.portal_user_id:
user = db.get(repository.User, int(row.portal_user_id))
if not user:
row = repository.update_client(db, row, {'portal_user_id': None})
else:
user.email = email_clean
user.full_name = (row.client_name or '').strip()
user.password_hash = hash_password(pw)
user.tenant_id = row.tenant_id
user.branch_id = row.branch_id
user.is_active = True
user.allow_login = True
user.is_locked = False
user.must_change_password = False
db.add(user)
db.commit()
db.refresh(user)
return row
if not row.portal_user_id:
user = repository.create_portal_user(
db,
email=email_clean,
full_name=row.client_name,
tenant_id=row.tenant_id,
branch_id=row.branch_id,
password=pw,
)
role = repository.get_role_by_name(db, 'Client')
if not role:
raise HTTPException(status_code=500, detail='Client role is not available.')
repository.ensure_user_role(db, user_id=user.id, role_id=role.id)
row = repository.update_client(db, row, {'portal_user_id': user.id})
return row
def _validate_scope_for_create(data, scope, actor_user_id):
if scope.own_only:
data.partner_id = scope.locked_partner_id or actor_user_id
if not scope.allow_cross_tenant:
data.tenant_id = scope.tenant_id
if not scope.allow_cross_branch and scope.branch_id:
data.branch_id = scope.branch_id
return data
def _validate_scope_for_edit(data, scope, actor_user_id, *, existing_row, current_user_roles):
role_names = {str(r).lower() for r in current_user_roles}
if data.tenant_id is None:
data.tenant_id = existing_row.tenant_id
if data.branch_id is None:
data.branch_id = existing_row.branch_id
if data.partner_id is None:
data.partner_id = existing_row.partner_id
if "partner" in role_names and data.partner_id and data.partner_id != actor_user_id:
raise HTTPException(status_code=400, detail="Partner users cannot assign clients to another partner.")
if "consultant" in role_names and data.partner_id and data.partner_id != existing_row.partner_id:
raise HTTPException(status_code=400, detail="Consultants cannot assign or change partner mapping.")
if "firm admin" in role_names:
if existing_row.tenant_id != scope.tenant_id:
raise HTTPException(status_code=403, detail="Firm Admin can only manage clients within own firm.")
data.tenant_id = scope.tenant_id
if not scope.allow_cross_branch and data.branch_id != scope.branch_id:
raise HTTPException(status_code=403, detail="Branch change is not allowed in current scope.")
return data
if "system admin" in role_names:
return data
if scope.own_only:
data.partner_id = scope.locked_partner_id or actor_user_id
if existing_row.partner_id != actor_user_id:
raise HTTPException(status_code=403, detail="You can only edit your own associated clients.")
return data
def _write_association_from_client(db, client_row, *, actor_user_id, current_user_roles):
roles = {str(r).lower() for r in current_user_roles}
ensure_active_association(db, client_row.id)
if "system admin" in roles:
return update_association_fields(
db,
client_row.id,
association_type="firm" if client_row.tenant_id else "self_service_unassigned",
firm_tenant_id=client_row.tenant_id,
partner_user_id=client_row.partner_id,
created_source="system_admin",
)
if "firm admin" in roles:
return update_association_fields(
db,
client_row.id,
association_type="firm",
firm_tenant_id=client_row.tenant_id,
partner_user_id=client_row.partner_id,
created_source="firm_admin",
)
if "partner" in roles:
return update_association_fields(
db,
client_row.id,
association_type="firm",
firm_tenant_id=client_row.tenant_id,
partner_user_id=actor_user_id,
created_source="partner",
)
if "consultant" in roles:
return update_association_fields(
db,
client_row.id,
association_type="consultant",
consultant_id=actor_user_id,
created_source="consultant",
)
return update_association_fields(
db,
client_row.id,
association_type="self_service_unassigned",
created_source="self_service",
)
def create_client_service(db, *, data, actor_user_id: int, scope, current_user_roles=None, portal_password: str | None = None, portal_password_confirm: str | None = None):
current_user_roles = current_user_roles or []
data = _validate_scope_for_create(data, scope, actor_user_id)
existing = repository.get_client_by_code(db, tenant_id=data.tenant_id, client_code=data.client_code)
if existing:
raise HTTPException(status_code=400, detail="Client code already exists.")
if data.pan:
existing_pan = repository.get_client_by_pan(db, tenant_id=data.tenant_id, pan=data.pan)
if existing_pan:
raise HTTPException(status_code=400, detail="PAN already exists for another client in this tenant.")
if data.gstin:
existing_gstin = repository.get_client_by_gstin(db, tenant_id=data.tenant_id, gstin=data.gstin)
if existing_gstin:
raise HTTPException(status_code=400, detail="GSTIN already exists for another client in this tenant.")
payload = _payload_from_schema(data)
row = repository.create_client(db, payload)
row = _sync_client_portal_user(db, row=row, portal_password=portal_password, portal_password_confirm=portal_password_confirm)
_write_association_from_client(db, row, actor_user_id=actor_user_id, current_user_roles=current_user_roles)
repository.write_audit_log(
db,
client_id=row.id,
tenant_id=row.tenant_id,
branch_id=row.branch_id,
actor_user_id=actor_user_id,
action="created",
summary="Client created with association sync.",
payload_json={"client_id": row.id, "partner_id": row.partner_id},
)
return row
def update_client_service(db, *, row, data, actor_user_id: int, scope, current_user_roles=None, portal_password: str | None = None, portal_password_confirm: str | None = None):
current_user_roles = current_user_roles or []
data = _validate_scope_for_edit(
data,
scope,
actor_user_id,
existing_row=row,
current_user_roles=current_user_roles,
)
payload = _payload_from_schema(data)
if payload.get("pan"):
existing_pan = repository.get_client_by_pan(db, tenant_id=payload["tenant_id"], pan=payload["pan"])
if existing_pan and existing_pan.id != row.id:
raise HTTPException(status_code=400, detail="PAN already exists for another client in this tenant.")
if payload.get("gstin"):
existing_gstin = repository.get_client_by_gstin(db, tenant_id=payload["tenant_id"], gstin=payload["gstin"])
if existing_gstin and existing_gstin.id != row.id:
raise HTTPException(status_code=400, detail="GSTIN already exists for another client in this tenant.")
updated = repository.update_client(db, row, payload)
updated = _sync_client_portal_user(db, row=updated, portal_password=portal_password, portal_password_confirm=portal_password_confirm)
_write_association_from_client(db, updated, actor_user_id=actor_user_id, current_user_roles=current_user_roles)
repository.write_audit_log(
db,
client_id=updated.id,
tenant_id=updated.tenant_id,
branch_id=updated.branch_id,
actor_user_id=actor_user_id,
action="updated",
summary="Client updated with association sync.",
payload_json={"client_id": updated.id, "partner_id": updated.partner_id},
)
return updated
def get_client_or_404(
db,
*,
client_id: int,
tenant_id: int,
branch_id: int | None,
allow_cross_branch: bool,
allow_all_clients: bool = False,
):
row = repository.get_client_by_id(db, client_id)
if not row:
raise HTTPException(status_code=404, detail="Client not found.")
if not allow_all_clients and row.tenant_id != tenant_id:
raise HTTPException(status_code=404, detail="Client not found in current tenant.")
if not allow_all_clients and not allow_cross_branch and branch_id and row.branch_id != branch_id:
raise HTTPException(status_code=404, detail="Client not found in current branch.")
return row
def list_clients_payload(db, **kwargs):
return repository.list_clients(db, **kwargs)
def list_client_audit_logs(db, *, row, limit: int = 50):
return repository.list_audit_logs(db, client_id=row.id, limit=limit)
def deactivate_client_service(db, *, row, actor_user_id: int):
row = repository.update_client(db, row, {"status": "inactive"})
repository.write_audit_log(
db,
client_id=row.id,
tenant_id=row.tenant_id,
branch_id=row.branch_id,
actor_user_id=actor_user_id,
action="deactivated",
summary="Client deactivated.",
payload_json=None,
)
return row
def activate_client_service(db, *, row, actor_user_id: int):
row = repository.update_client(db, row, {"status": "active"})
repository.write_audit_log(
db,
client_id=row.id,
tenant_id=row.tenant_id,
branch_id=row.branch_id,
actor_user_id=actor_user_id,
action="activated",
summary="Client activated.",
payload_json=None,
)
return row
def archive_client_service(db, *, row, actor_user_id: int):
row = repository.update_client(db, row, {"status": "archived", "is_archived": True})
repository.write_audit_log(
db,
client_id=row.id,
tenant_id=row.tenant_id,
branch_id=row.branch_id,
actor_user_id=actor_user_id,
action="archived",
summary="Client archived.",
payload_json=None,
)
return row
def restore_client_service(db, *, row, actor_user_id: int):
row = repository.update_client(db, row, {"status": "active", "is_archived": False})
repository.write_audit_log(
db,
client_id=row.id,
tenant_id=row.tenant_id,
branch_id=row.branch_id,
actor_user_id=actor_user_id,
action="restored",
summary="Client restored from archive.",
payload_json=None,
)
return row
def export_clients_csv(payload: dict) -> str:
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(
[
"client_code",
"client_name",
"client_type",
"status",
"pan",
"gstin",
"partner",
"association_type",
"association_source",
]
)
for row in payload.get("rows", []):
writer.writerow(
[
row.get("client_code"),
row.get("client_name"),
row.get("client_type"),
row.get("status"),
row.get("pan"),
row.get("gstin"),
row.get("partner_name") or row.get("effective_partner_id"),
row.get("association_type"),
row.get("assoc_created_source"),
]
)
return output.getvalue()
def get_filter_options():
return {
"client_types": CLIENT_TYPES,
"client_statuses": CLIENT_STATUS,
"client_categories": CLIENT_CATEGORY_OPTIONS,
"risk_categories": RISK_CATEGORIES,
}
SELF_SERVICE_EDITABLE_FIELDS = {
"client_name",
"trade_name",
"contact_person_name",
"contact_person_designation",
"mobile",
"alternate_mobile",
"email",
"alternate_email",
"address_line_1",
"address_line_2",
"city",
"state",
"pincode",
"country",
"notes",
}
def update_client_self_profile_service(db, *, row, data, current_user):
payload = _payload_from_schema(data)
payload = {key: value for key, value in payload.items() if key in SELF_SERVICE_EDITABLE_FIELDS}
new_email = (payload.get("email") or "").strip().lower()
if new_email:
existing_user = repository.get_user_by_email(db, email=new_email, exclude_user_id=int(current_user.id))
if existing_user:
raise HTTPException(status_code=400, detail="That email is already used by another login.")
updated = repository.update_client(db, row, payload)
if new_email and new_email != (getattr(current_user, "email", "") or "").strip().lower():
current_user.email = new_email
db.add(current_user)
db.commit()
db.refresh(current_user)
repository.write_audit_log(
db,
client_id=updated.id,
tenant_id=updated.tenant_id,
branch_id=updated.branch_id,
actor_user_id=current_user.id,
action="client_self_profile_updated",
summary="Client updated own contact profile.",
payload_json={"fields": sorted(payload.keys())},
)
return updated
def reset_client_portal_password_service(db, *, current_user, new_password: str):
if len((new_password or "").strip()) < 8:
raise HTTPException(status_code=400, detail="New password must be at least 8 characters.")
current_user.password_hash = hash_password(new_password.strip())
current_user.must_change_password = False
db.add(current_user)
db.commit()
db.refresh(current_user)
return current_user
@@ -0,0 +1,12 @@
<div class="mb-6 overflow-x-auto rounded-2xl border border-slate-200 bg-white p-2 shadow-soft">
<div class="flex min-w-max items-center gap-2">
{% set path = request.url.path %}
<a href="/client/dashboard" class="rounded-xl px-4 py-2 text-sm font-semibold transition {{ 'bg-brand-600 text-white' if path == '/client/dashboard' else 'text-slate-700 hover:bg-slate-100' }}">Overview</a>
<a href="/client/compliance" class="rounded-xl px-4 py-2 text-sm font-semibold transition {{ 'bg-brand-600 text-white' if path.startswith('/client/compliance') or path.startswith('/client/engagements') else 'text-slate-700 hover:bg-slate-100' }}">My Compliance</a>
<a href="/client/documents" class="rounded-xl px-4 py-2 text-sm font-semibold transition {{ 'bg-brand-600 text-white' if path.startswith('/client/documents') else 'text-slate-700 hover:bg-slate-100' }}">My Documents</a>
<a href="/client/messages" class="rounded-xl px-4 py-2 text-sm font-semibold transition {{ 'bg-brand-600 text-white' if path.startswith('/client/messages') else 'text-slate-700 hover:bg-slate-100' }}">My Messages</a>
<a href="/client/billing" class="rounded-xl px-4 py-2 text-sm font-semibold transition {{ 'bg-brand-600 text-white' if path.startswith('/client/billing') else 'text-slate-700 hover:bg-slate-100' }}">My Bills</a>
<a href="/client/profile" class="rounded-xl px-4 py-2 text-sm font-semibold transition {{ 'bg-brand-600 text-white' if path.startswith('/client/profile') else 'text-slate-700 hover:bg-slate-100' }}">My Profile</a>
<a href="/alerts" class="rounded-xl px-4 py-2 text-sm font-semibold transition {{ 'bg-brand-600 text-white' if path == '/alerts' else 'text-slate-700 hover:bg-slate-100' }}">My Alert</a>
</div>
</div>
@@ -0,0 +1 @@
{% extends "ui/templates/base/layout.html" %}{% block content %}<div class="space-y-6"><div><h2 class="text-2xl font-semibold text-slate-900">Add Client</h2><p class="text-sm text-slate-500">Create a validated client master with ownership and branch-safe rules.</p></div>{% if form_errors %}<div class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-900 shadow-soft">{% for err in form_errors %}<div>{{ err }}</div>{% endfor %}</div>{% endif %}<form method="post" action="/clients" class="space-y-6"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">{% include "modules/clients/templates/clients/partials/form.html" %}<div class="flex justify-end gap-3"><a href="/clients" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Cancel</a><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Save Client</button></div></form></div>{% endblock %}
@@ -0,0 +1,23 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
{% include "modules/clients/templates/clients/_client_tabs.html" %}
<div class="space-y-6">
<div><h2 class="text-2xl font-semibold text-slate-900">My Compliance</h2><p class="text-sm text-slate-500">Service-wise status and pending actions visible to you.</p></div>
<div class="grid gap-4 md:grid-cols-4">
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-sm text-slate-500">Pending from Client</div><div class="mt-2 text-2xl font-semibold">{{ pending_from_client or 0 }}</div></div>
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-sm text-slate-500">With Firm</div><div class="mt-2 text-2xl font-semibold">{{ with_firm or 0 }}</div></div>
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-sm text-slate-500">Clarification Required</div><div class="mt-2 text-2xl font-semibold">{{ clarification_required or 0 }}</div></div>
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-sm text-slate-500">Completed</div><div class="mt-2 text-2xl font-semibold">{{ completed_engagements or 0 }}</div></div>
</div>
<div class="grid gap-4 lg:grid-cols-2">
{% for row in engagements %}
<a href="/work/engagements/{{ row.id }}" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft hover:bg-slate-50">
<div class="flex items-start justify-between gap-3"><div><h3 class="font-semibold text-slate-900">{{ row.catalogue.service_name if row.catalogue else 'Service' }}</h3><p class="text-xs text-slate-500">FY {{ row.financial_year }}{% if row.assessment_year %} • AY {{ row.assessment_year }}{% endif %}</p></div><span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-700">{{ row.status|replace('_',' ')|title }}</span></div>
<div class="mt-4 grid gap-3 text-sm md:grid-cols-3"><div><div class="text-xs text-slate-500">Due Date</div><div>{{ row.current_due_date.strftime('%d-%m-%Y') if row.current_due_date else '-' }}</div></div><div><div class="text-xs text-slate-500">Firm Contact</div><div>{{ row.assigned_manager.full_name if row.assigned_manager else (row.assigned_partner.full_name if row.assigned_partner else 'Firm team') }}</div></div><div><div class="text-xs text-slate-500">Status</div><div>{{ row.status|replace('_',' ')|title }}</div></div></div>
</a>
{% else %}
<div class="rounded-2xl border border-dashed border-slate-300 bg-white p-8 text-sm text-slate-500 lg:col-span-2">No active compliance services are assigned yet.</div>
{% endfor %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,92 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="text-2xl font-semibold text-slate-900">{{ row.client_name }}</h2>
<p class="text-sm text-slate-500">{{ row.client_code }} • {{ row.client_type }} • {{ row.status|title }}</p>
</div>
<div class="flex flex-wrap gap-3">
{% if can_edit %}
<a href="/clients/{{ row.id }}/edit"
class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
Edit
</a>
{% endif %}
{% if can_activate and row.status != 'active' and row.status != 'archived' %}
<form method="post" action="/clients/{{ row.id }}/activate">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="rounded-xl border border-emerald-300 px-4 py-2 text-sm font-medium text-emerald-700 hover:bg-emerald-50">
Activate
</button>
</form>
{% endif %}
{% if can_deactivate and row.status == 'active' %}
<form method="post" action="/clients/{{ row.id }}/deactivate">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="rounded-xl border border-rose-300 px-4 py-2 text-sm font-medium text-rose-700 hover:bg-rose-50">
Deactivate
</button>
</form>
{% endif %}
{% if can_archive and row.status != 'archived' %}
<form method="post" action="/clients/{{ row.id }}/archive">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="rounded-xl border border-amber-300 px-4 py-2 text-sm font-medium text-amber-800 hover:bg-amber-50">
Archive
</button>
</form>
{% endif %}
{% if can_restore and row.status == 'archived' %}
<form method="post" action="/clients/{{ row.id }}/restore">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="rounded-xl border border-sky-300 px-4 py-2 text-sm font-medium text-sky-700 hover:bg-sky-50">
Restore
</button>
</form>
{% endif %}
</div>
</div>
<div class="grid gap-6 xl:grid-cols-3">
<section class="rounded-2xl bg-white p-6 shadow-soft xl:col-span-2">
<h3 class="text-base font-semibold text-slate-900">Profile</h3>
<div class="mt-4 grid gap-4 md:grid-cols-2">
{% for label, value in [
('Trade Name', row.trade_name),
('PAN', row.pan),
('GSTIN', row.gstin),
('TAN', row.tan),
('Contact Person', row.contact_person_name),
('Designation', row.contact_person_designation),
('Mobile', row.mobile),
('Email', row.email)
] %}
<div class="rounded-xl border border-slate-200 px-4 py-3">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">{{ label }}</div>
<div class="mt-1 text-sm text-slate-800">{{ value or '-' }}</div>
</div>
{% endfor %}
</div>
</section>
<section class="rounded-2xl bg-white p-6 shadow-soft">
<h3 class="text-base font-semibold text-slate-900">Association</h3>
<div class="mt-4 space-y-2 text-sm text-slate-700">
<div><span class="font-medium">Type:</span> {{ row.association_type or 'legacy_firm' }}</div>
<div><span class="font-medium">Source:</span> {{ row.assoc_created_source or 'legacy' }}</div>
<div><span class="font-medium">Audit Firm:</span> {{ row.assoc_firm_tenant_id or row.tenant_id or '-' }}</div>
<div><span class="font-medium">Branch:</span> {{ row.branch_id or '-' }}</div>
<div><span class="font-medium">Partner:</span> {{ row.assoc_partner_user_id or row.partner_id or '-' }}</div>
<div><span class="font-medium">Default Review Partner:</span> {{ row.default_review_partner_user_id or '-' }}</div>
<div><span class="font-medium">Consultant:</span> {{ row.assoc_consultant_id or '-' }}</div>
</div>
</section>
</div>
</div>
{% endblock %}
@@ -0,0 +1,9 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
{% include "modules/clients/templates/clients/_client_tabs.html" %}
<div class="space-y-6">
<div><h2 class="text-2xl font-semibold text-slate-900">My Documents</h2><p class="text-sm text-slate-500">View documents shared by your audit firm.</p></div>
<section class="rounded-2xl bg-white p-6 shadow-soft"><h3 class="font-semibold text-slate-900">Engagement Documents</h3><div class="mt-4 overflow-hidden rounded-2xl border border-slate-200"><table class="min-w-full divide-y divide-slate-200 text-sm"><thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500"><tr><th class="px-4 py-3">Document</th><th class="px-4 py-3">Service</th><th class="px-4 py-3">Type</th><th class="px-4 py-3">Version</th><th class="px-4 py-3 text-right">Action</th></tr></thead><tbody class="divide-y divide-slate-100">{% for doc in engagement_documents %}{% set ver = doc.versions[0] if doc.versions else None %}<tr><td class="px-4 py-3 font-medium text-slate-900">{{ doc.title }}</td><td class="px-4 py-3 text-slate-600">{{ doc.engagement.catalogue.service_name if doc.engagement and doc.engagement.catalogue else '-' }}</td><td class="px-4 py-3 text-slate-600">{{ doc.document_type }}</td><td class="px-4 py-3 text-slate-600">v{{ doc.current_version_no }}</td><td class="px-4 py-3 text-right">{% if ver %}<a href="/client/documents/engagement-versions/{{ ver.id }}/download" class="font-semibold text-brand-700 hover:underline">Download</a>{% endif %}</td></tr>{% else %}<tr><td colspan="5" class="px-4 py-8 text-center text-slate-500">No engagement documents shared yet.</td></tr>{% endfor %}</tbody></table></div></section>
<section class="rounded-2xl bg-white p-6 shadow-soft"><h3 class="font-semibold text-slate-900">Permanent Documents</h3><div class="mt-4 overflow-hidden rounded-2xl border border-slate-200"><table class="min-w-full divide-y divide-slate-200 text-sm"><thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500"><tr><th class="px-4 py-3">Document</th><th class="px-4 py-3">Category</th><th class="px-4 py-3">Version</th><th class="px-4 py-3 text-right">Action</th></tr></thead><tbody class="divide-y divide-slate-100">{% for doc in permanent_documents %}{% set ver = doc.versions[0] if doc.versions else None %}<tr><td class="px-4 py-3 font-medium text-slate-900">{{ doc.title }}</td><td class="px-4 py-3 text-slate-600">{{ doc.category }}</td><td class="px-4 py-3 text-slate-600">v{{ doc.current_version_no }}</td><td class="px-4 py-3 text-right">{% if ver %}<a href="/client/documents/permanent-versions/{{ ver.id }}/download" class="font-semibold text-brand-700 hover:underline">Download</a>{% endif %}</td></tr>{% else %}<tr><td colspan="4" class="px-4 py-8 text-center text-slate-500">No permanent documents shared yet.</td></tr>{% endfor %}</tbody></table></div></section>
</div>
{% endblock %}
@@ -0,0 +1,26 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div>
<h2 class="text-2xl font-semibold text-slate-900">Edit Client</h2>
<p class="text-sm text-slate-500">B5 role-aware edit flow.</p>
</div>
{% if form_errors %}
<div class="rounded-2xl border border-rose-200 bg-rose-50 p-4">
<ul class="list-disc pl-5 text-sm text-rose-700">
{% for err in form_errors %}<li>{{ err }}</li>{% endfor %}
</ul>
</div>
{% endif %}
<form method="post" action="/clients/{{ row.id }}/edit" class="space-y-6">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
{% include "modules/clients/templates/clients/partials/form.html" %}
<div class="flex items-center justify-end gap-3">
<a href="/clients/{{ row.id }}" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Cancel</a>
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Save Changes</button>
</div>
</form>
</div>
{% endblock %}
@@ -0,0 +1,14 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
{% include "modules/clients/templates/clients/_client_tabs.html" %}
<div class="space-y-6">
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between"><div><h2 class="text-2xl font-semibold text-slate-900">{{ engagement.catalogue.service_name if engagement.catalogue else 'Engagement' }}</h2><p class="text-sm text-slate-500">FY {{ engagement.financial_year }}{% if engagement.assessment_year %} • AY {{ engagement.assessment_year }}{% endif %} • Due {{ engagement.current_due_date.strftime('%d-%m-%Y') if engagement.current_due_date else '-' }}</p></div><a href="/client/compliance" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back to My Compliance</a></div>
<div class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<section class="space-y-4">
<div class="rounded-2xl bg-white p-6 shadow-soft"><h3 class="font-semibold text-slate-900">Task / Action Status</h3><div class="mt-4 space-y-3">{% for task in tasks %}<details class="rounded-2xl border border-slate-200 p-4" {% if loop.first %}open{% endif %}><summary class="cursor-pointer list-none"><div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between"><div><div class="font-semibold text-slate-900">{{ task.task_name }}</div><div class="text-xs text-slate-500">{{ task.description or '' }}</div></div><span class="w-fit rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-700">{{ task.status|replace('_',' ')|title }}</span></div></summary><div class="mt-4 border-t border-slate-100 pt-4"><form method="post" action="/client/tasks/{{ task.id }}/reply" class="space-y-3"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><label class="block text-sm font-medium text-slate-700">Reply / clarification for firm</label><textarea name="message" rows="3" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" placeholder="Type your clarification, confirmation or query for the firm..."></textarea><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Send Reply</button></form></div></details>{% else %}<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-sm text-slate-500">No task details available.</div>{% endfor %}</div></div>
<div class="rounded-2xl bg-white p-6 shadow-soft"><h3 class="font-semibold text-slate-900">Communication Timeline</h3><div class="mt-4 space-y-3">{% for note in comments %}<div class="rounded-2xl border border-slate-200 p-4 text-sm"><div class="flex justify-between gap-3"><div class="font-semibold text-slate-900">{{ note.task.task_name if note.task else 'Message' }}</div><div class="text-xs text-slate-500">{{ note.created_at_utc.strftime('%d-%m-%Y %I:%M %p') if note.created_at_utc else '' }}</div></div><p class="mt-2 whitespace-pre-line text-slate-700">{{ note.message }}</p></div>{% else %}<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-sm text-slate-500">No visible communication yet.</div>{% endfor %}</div></div>
</section>
<aside class="rounded-2xl bg-white p-6 shadow-soft"><h3 class="font-semibold text-slate-900">Engagement Documents</h3><div class="mt-4 space-y-3">{% for doc in documents %}{% set ver = doc.versions[0] if doc.versions else None %}<div class="rounded-2xl border border-slate-200 p-4"><div class="font-semibold text-slate-900">{{ doc.title }}</div><div class="text-xs text-slate-500">{{ doc.document_type }} • v{{ doc.current_version_no }}</div>{% if ver %}<a href="/client/documents/engagement-versions/{{ ver.id }}/download" class="mt-3 inline-flex rounded-xl border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">Download</a>{% endif %}</div>{% else %}<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-sm text-slate-500">No documents uploaded for this engagement yet.</div>{% endfor %}</div></aside>
</div>
</div>
{% endblock %}
@@ -0,0 +1,59 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex items-start justify-between gap-4">
<div>
<h2 class="text-2xl font-semibold text-slate-900">Import Clients</h2>
<p class="text-sm text-slate-500">Bulk upload clients for the active audit firm with partner validation.</p>
</div>
<a href="/clients/import/template" class="inline-flex rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Download Template</a>
</div>
<div class="rounded-2xl bg-white p-6 shadow-soft space-y-4">
<div class="grid gap-4 md:grid-cols-2">
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-700">
<div class="font-semibold text-slate-900">Active Audit Firm</div>
<div class="mt-1">{{ current_tenant.name if current_tenant else scope.tenant_id }} (ID: {{ scope.tenant_id }})</div>
</div>
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-700">
<div class="font-semibold text-slate-900">Logged-in uploader</div>
<div class="mt-1">{{ current_user.full_name or current_user.email }} — User ID {{ current_user.id }}</div>
</div>
</div>
<div class="rounded-xl border border-sky-200 bg-sky-50 p-4 text-sm text-sky-900">
Template includes <strong>uploader_user_id</strong>, <strong>firm_tenant_id</strong>, and <strong>partner_user_id</strong>.
Validation checks that uploader_user_id matches the logged-in user, firm_tenant_id matches the active audit firm, and partner_user_id belongs to an active Partner in that same audit firm.
</div>
<div class="rounded-xl border border-slate-200 p-4">
<div class="text-sm font-semibold text-slate-900">Partners available in this audit firm</div>
<div class="mt-2 text-sm text-slate-600">
{% for p in partners %}
<div>{{ p.id }} — {{ p.full_name or p.email }}</div>
{% else %}
<div>No active partners found for this audit firm.</div>
{% endfor %}
</div>
</div>
{% if import_errors %}
<div class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-900">
{% for err in import_errors %}<div>{{ err }}</div>{% endfor %}
</div>
{% endif %}
<form method="post" action="/clients/import/preview" enctype="multipart/form-data" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div>
<label class="block text-sm font-medium text-slate-700">Excel file</label>
<input type="file" name="excel_file" accept=".xlsx" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" required>
</div>
<div class="flex justify-end gap-3">
<a href="/clients" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Back</a>
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Validate File</button>
</div>
</form>
</div>
</div>
{% endblock %}
@@ -0,0 +1,71 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div>
<h2 class="text-2xl font-semibold text-slate-900">Import Clients Preview</h2>
<p class="text-sm text-slate-500">Review validation result before final import.</p>
</div>
{% if import_result is defined and import_result %}
<div class="rounded-2xl bg-white p-6 shadow-soft space-y-4">
<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-900">Created {{ import_result.created|length }} clients.</div>
{% if import_result.created %}
<div class="rounded-xl border border-slate-200 p-4 text-sm">
{% for row in import_result.created %}<div>{{ row.client_code }} — {{ row.client_name }} (ID {{ row.id }})</div>{% endfor %}
</div>
{% endif %}
{% if import_result.failures %}
<div class="rounded-xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-900">
{% for err in import_result.failures %}<div>Row {{ err.row_number }}: {{ err.message }}</div>{% endfor %}
</div>
{% endif %}
<div class="flex justify-end"><a href="/clients" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Back to Clients</a></div>
</div>
{% else %}
<div class="grid gap-6 lg:grid-cols-2">
<section class="rounded-2xl bg-white p-6 shadow-soft">
<h3 class="text-base font-semibold text-slate-900">Valid rows</h3>
<div class="mt-3 text-sm text-slate-600">{{ preview.valid_rows|length }} of {{ preview.total_rows }} rows are ready to import.</div>
<div class="mt-4 max-h-[28rem] overflow-auto rounded-xl border border-slate-200">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50"><tr><th class="px-4 py-2 text-left">Row</th><th class="px-4 py-2 text-left">Audit Firm ID</th><th class="px-4 py-2 text-left">Partner</th><th class="px-4 py-2 text-left">Client Code</th><th class="px-4 py-2 text-left">Client Name</th></tr></thead>
<tbody class="divide-y divide-slate-100 bg-white">
{% for item in preview.valid_rows %}
<tr><td class="px-4 py-2">{{ item.row_number }}</td><td class="px-4 py-2">{{ item.tenant_id }}</td><td class="px-4 py-2">{{ item.partner_id }}</td><td class="px-4 py-2">{{ item.client_payload.client_code }}</td><td class="px-4 py-2">{{ item.client_payload.client_name }}</td></tr>
{% else %}
<tr><td colspan="5" class="px-4 py-6 text-center text-slate-500">No valid rows found.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
<section class="rounded-2xl bg-white p-6 shadow-soft">
<h3 class="text-base font-semibold text-slate-900">Validation errors</h3>
<div class="mt-3 text-sm text-slate-600">{{ preview.errors|length }} rows have issues.</div>
<div class="mt-4 max-h-[28rem] space-y-3 overflow-auto">
{% for err in preview.errors %}
<div class="rounded-xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-900">
<div class="font-semibold">Row {{ err.row_number }}</div>
<ul class="mt-2 list-disc space-y-1 pl-5">{% for msg in err.messages %}<li>{{ msg }}</li>{% endfor %}</ul>
</div>
{% else %}
<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-900">No validation errors found.</div>
{% endfor %}
</div>
</section>
</div>
<div class="flex justify-end gap-3">
<a href="/clients/import" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Back</a>
{% if preview.valid_rows %}
<form method="post" action="/clients/import/commit">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<textarea name="preview_payload" hidden>{{ preview_payload }}</textarea>
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Import {{ preview.valid_rows|length }} Valid Rows</button>
</form>
{% endif %}
</div>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,36 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="text-2xl font-semibold text-slate-900">Clients</h2>
<p class="text-sm text-slate-500">Association-aware list view.</p>
</div>
<div class="flex gap-3">
{% if can_export %}
<a href="/clients/export?q={{ q }}&status={{ status }}&client_type={{ client_type }}&include_archived={{ include_archived }}&sort_by={{ sort_by }}&sort_order={{ sort_order }}"
class="inline-flex rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
Export CSV
</a>
{% endif %}
{% if can_import %}
<a href="/clients/import"
class="inline-flex rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
Import Clients
</a>
{% endif %}
{% if can_create %}
<a href="/clients/new"
class="inline-flex rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">
Add Client
</a>
{% endif %}
</div>
</div>
{% include "modules/clients/templates/clients/partials/table.html" %}
</div>
{% endblock %}
@@ -0,0 +1,5 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
{% include "modules/clients/templates/clients/_client_tabs.html" %}
<div class="space-y-6"><div><h2 class="text-2xl font-semibold text-slate-900">My Messages</h2><p class="text-sm text-slate-500">Client-visible communications and clarifications from your firm.</p></div><section class="rounded-2xl bg-white p-6 shadow-soft"><div class="space-y-4">{% for note in comments %}<article class="rounded-2xl border border-slate-200 p-4"><div class="flex flex-col gap-2 md:flex-row md:items-start md:justify-between"><div><div class="text-xs font-semibold uppercase tracking-wide text-brand-700">{{ note.comment_type|replace('_',' ')|title }}</div><h3 class="mt-1 font-semibold text-slate-900">{{ note.task.task_name if note.task else 'Message' }}</h3><div class="text-xs text-slate-500">{{ note.subscription.catalogue.service_name if note.subscription and note.subscription.catalogue else '' }}</div></div><div class="text-xs text-slate-500">{{ note.created_at_utc.strftime('%d-%m-%Y %I:%M %p') if note.created_at_utc else '' }}</div></div><p class="mt-3 whitespace-pre-line text-sm leading-6 text-slate-700">{{ note.message }}</p><div class="mt-3 text-xs text-slate-500">From: {% if note.created_by %}{{ note.created_by.full_name or note.created_by.email }}{% else %}Firm team{% endif %}</div>{% if note.task %}<a href="/client/engagements/{{ note.task.subscription_id }}" class="mt-3 inline-flex text-sm font-semibold text-brand-700 hover:underline">Open related work</a>{% endif %}</article>{% else %}<div class="rounded-2xl border border-dashed border-slate-300 p-8 text-center text-sm text-slate-500">No messages found.</div>{% endfor %}</div></section></div>
{% endblock %}
@@ -0,0 +1 @@
<div class="overflow-hidden rounded-2xl border border-slate-200"><table class="min-w-full divide-y divide-slate-200"><thead class="bg-slate-50"><tr><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">When</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Action</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Summary</th></tr></thead><tbody class="divide-y divide-slate-100">{% for log in audit_logs %}<tr><td class="px-4 py-3 text-sm text-slate-700">{{ log.created_at_utc }}</td><td class="px-4 py-3 text-sm text-slate-700">{{ log.action }}</td><td class="px-4 py-3 text-sm text-slate-700">{{ log.summary }}</td></tr>{% else %}<tr><td colspan="3" class="px-4 py-6 text-center text-sm text-slate-500">No audit entries yet.</td></tr>{% endfor %}</tbody></table></div>
@@ -0,0 +1 @@
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">{% for label, value in [('GST', row.gst_applicable),('Income Tax', row.income_tax_applicable),('TDS', row.tds_applicable),('ROC', row.roc_applicable),('Audit', row.audit_applicable),('PF', row.pf_applicable),('ESI', row.esi_applicable),('Professional Tax', row.professional_tax_applicable),('Payroll', row.payroll_applicable),('MSME', row.msme_applicable),('Import / Export', row.import_export_applicable)] %}<div class="rounded-xl border border-slate-200 px-3 py-3 text-sm"><div class="font-medium text-slate-700">{{ label }}</div><div class="mt-1 {% if value %}text-emerald-700{% else %}text-slate-500{% endif %}">{% if value %}Applicable{% else %}Not Applicable{% endif %}</div></div>{% endfor %}</div>
@@ -0,0 +1,372 @@
{% set is_edit = row is defined and row %}
<div class="grid gap-6 xl:grid-cols-3">
<section class="rounded-2xl bg-white p-6 shadow-soft xl:col-span-2">
<h3 class="text-base font-semibold text-slate-900">Basic Profile</h3>
<div class="mt-4 grid gap-4 md:grid-cols-2">
<div>
<label class="block text-sm font-medium text-slate-700">Client Code</label>
<input name="client_code" value="{{ form_data.client_code or (row.client_code if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" {% if is_edit %}readonly{% endif %}>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Client Name</label>
<input name="client_name" value="{{ form_data.client_name or (row.client_name if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Trade Name</label>
<input name="trade_name" value="{{ form_data.trade_name or (row.trade_name if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Client Type</label>
<select name="client_type" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
{% for opt in client_types %}
<option value="{{ opt }}" {% if (form_data.client_type or (row.client_type if is_edit else 'Other')) == opt %}selected{% endif %}>{{ opt }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">PAN</label>
<input name="pan" value="{{ form_data.pan or (row.pan if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">GSTIN</label>
<input name="gstin" value="{{ form_data.gstin or (row.gstin if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">TAN</label>
<input name="tan" value="{{ form_data.tan or (row.tan if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">CIN / LLPIN</label>
<input name="cin_llpin" value="{{ form_data.cin_llpin or (row.cin_llpin if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">MSME Number</label>
<input name="msme_no" value="{{ form_data.msme_no or (row.msme_no if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">IEC Code</label>
<input name="iec_code" value="{{ form_data.iec_code or (row.iec_code if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Mobile</label>
<input name="mobile" value="{{ form_data.mobile or (row.mobile if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Alternate Mobile</label>
<input name="alternate_mobile" value="{{ form_data.alternate_mobile or (row.alternate_mobile if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Email (used for frontend login)</label>
<input name="email" value="{{ form_data.email or (row.email if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Alternate Email</label>
<input name="alternate_email" value="{{ form_data.alternate_email or (row.alternate_email if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Contact Person</label>
<input name="contact_person_name" value="{{ form_data.contact_person_name or (row.contact_person_name if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Designation</label>
<input name="contact_person_designation" value="{{ form_data.contact_person_designation or (row.contact_person_designation if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Client Category</label>
<select name="client_category" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
<option value="">-- Select --</option>
{% for opt in client_categories %}
<option value="{{ opt }}" {% if (form_data.client_category or (row.client_category if is_edit else '')) == opt %}selected{% endif %}>{{ opt }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Risk Category</label>
<select name="risk_category" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
<option value="">-- Select --</option>
{% for opt in risk_categories %}
<option value="{{ opt }}" {% if (form_data.risk_category or (row.risk_category if is_edit else '')) == opt %}selected{% endif %}>{{ opt }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Onboarding Date</label>
<input type="date" name="onboarding_date" value="{{ form_data.onboarding_date or (row.onboarding_date if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Closing Date</label>
<input type="date" name="closing_date" value="{{ form_data.closing_date or (row.closing_date if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-slate-700">Address Line 1</label>
<input name="address_line_1" value="{{ form_data.address_line_1 or (row.address_line_1 if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-slate-700">Address Line 2</label>
<input name="address_line_2" value="{{ form_data.address_line_2 or (row.address_line_2 if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">City</label>
<input name="city" value="{{ form_data.city or (row.city if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">State</label>
<input name="state" value="{{ form_data.state or (row.state if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Pincode</label>
<input name="pincode" value="{{ form_data.pincode or (row.pincode if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Country</label>
<input name="country" value="{{ form_data.country or (row.country if is_edit else 'India') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-slate-700">Notes</label>
<textarea name="notes" rows="4" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">{{ form_data.notes or (row.notes if is_edit else '') }}</textarea>
</div>
</div>
</section>
<section class="rounded-2xl bg-white p-6 shadow-soft">
<h3 class="text-base font-semibold text-slate-900">Assignment & Scope</h3>
<div class="mt-4 space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700">Engagement Mode</label>
<select name="engagement_mode" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
{% for opt in ['internal_managed','self_tracked','hybrid'] %}
<option value="{{ opt }}" {% if (form_data.engagement_mode or (row.engagement_mode if is_edit else 'internal_managed')) == opt %}selected{% endif %}>{{ opt }}</option>
{% endfor %}
</select>
</div>
{% if form_mode == 'firm_admin' %}
<div>
<label class="block text-sm font-medium text-slate-700">Audit Firm</label>
<div class="mt-1 rounded-xl border border-slate-300 bg-slate-50 px-4 py-2 text-sm text-slate-700">
{% for t in form_options.tenants %}
{{ t.name }}
{% endfor %}
</div>
<input type="hidden" name="tenant_id" value="{{ form_data.tenant_id or (row.tenant_id if is_edit else form_options.active_tenant_id) }}">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Branch</label>
<select name="branch_id" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
{% for b in form_options.branches %}
<option value="{{ b.id }}" {% if (form_data.branch_id or (row.branch_id if is_edit else form_options.active_branch_id)) == b.id %}selected{% endif %}>{{ b.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Partner</label>
<select name="partner_id" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
<option value="">-- Select Partner --</option>
{% for p in form_options.partners %}
<option value="{{ p.id }}" {% if (form_data.partner_id or (row.partner_id if is_edit else None)) == p.id %}selected{% endif %}>{{ p.full_name or p.email }}</option>
{% endfor %}
</select>
</div>
{% elif form_mode == 'system_admin' %}
<div>
<label class="block text-sm font-medium text-slate-700">Audit Firm</label>
<select name="tenant_id" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
{% for t in form_options.tenants %}
<option value="{{ t.id }}" {% if (form_data.tenant_id or (row.tenant_id if is_edit else form_options.active_tenant_id)) == t.id %}selected{% endif %}>{{ t.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Branch</label>
<select name="branch_id" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
<option value="">-- Select Branch --</option>
{% for b in form_options.branches %}
<option value="{{ b.id }}" {% if (form_data.branch_id or (row.branch_id if is_edit else form_options.active_branch_id)) == b.id %}selected{% endif %}>
{{ b.name }}{% if b.tenant_name %} ({{ b.tenant_name }}){% endif %}
</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Partner</label>
<select name="partner_id" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
<option value="">-- Select Partner --</option>
{% for p in form_options.partners %}
<option value="{{ p.id }}" {% if (form_data.partner_id or (row.partner_id if is_edit else None)) == p.id %}selected{% endif %}>
{{ p.full_name or p.email }}{% if p.tenant_name %} ({{ p.tenant_name }}){% endif %}
</option>
{% endfor %}
</select>
</div>
{% elif form_mode == 'partner' %}
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-700">
Partner assignment is locked to your own user.
<input type="hidden" name="tenant_id" value="{{ form_data.tenant_id or (row.tenant_id if is_edit else form_options.active_tenant_id) }}">
<input type="hidden" name="branch_id" value="{{ form_data.branch_id or (row.branch_id if is_edit else form_options.active_branch_id) }}">
<input type="hidden" name="partner_id" value="{{ current_user.id }}">
</div>
{% elif form_mode == 'consultant' %}
<div class="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
Consultant users cannot assign or reassign partner mappings.
<input type="hidden" name="tenant_id" value="{{ form_data.tenant_id or (row.tenant_id if is_edit else form_options.active_tenant_id) }}">
<input type="hidden" name="branch_id" value="{{ form_data.branch_id or (row.branch_id if is_edit else form_options.active_branch_id) }}">
<input type="hidden" name="partner_id" value="{{ form_data.partner_id or (row.partner_id if is_edit else '') }}">
</div>
{% elif form_mode == 'self_service' %}
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-700">
This client is currently unassigned. Initial association to firm, branch, and partner must be done by System Admin.
</div>
{% endif %}
<div>
<label class="block text-sm font-medium text-slate-700">Default Review Partner</label>
<select name="default_review_partner_user_id" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
<option value="">-- Not required / Select later --</option>
{% for p in form_options.review_partners or [] %}
<option value="{{ p.id }}" {% if (form_data.default_review_partner_user_id or (row.default_review_partner_user_id if is_edit else None)) == p.id %}selected{% endif %}>
{{ p.full_name or p.email }}{% if p.tenant_name %} ({{ p.tenant_name }}){% endif %}
</option>
{% endfor %}
</select>
<p class="mt-1 text-xs text-slate-500">Used automatically for assurance engagements only when the audit firm is a partnership firm.</p>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Status</label>
<select name="status" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
{% for opt in client_statuses %}
<option value="{{ opt }}" {% if (form_data.status or (row.status if is_edit else 'active')) == opt %}selected{% endif %}>{{ opt|title }}</option>
{% endfor %}
</select>
</div>
<div class="border-t border-slate-200 pt-4">
<h4 class="mb-3 text-sm font-semibold text-slate-900">Client Frontend Login</h4>
<div class="rounded-xl border border-sky-200 bg-sky-50 p-3 text-xs text-sky-800">
{% if is_edit and row.portal_user_id %}
Linked portal user already exists. Leave password blank to keep the current password, or enter a new password to reset it.
{% elif is_edit %}
This existing client does not yet have a linked login. Enter email and password below to create the client login now.
{% else %}
Creating a client will also create a frontend login using the client email and password below.
{% endif %}
</div>
<div class="mt-4 grid gap-4">
<div>
<label class="block text-sm font-medium text-slate-700">{% if is_edit and row.portal_user_id %}New Password{% else %}Password{% endif %}</label>
<input name="portal_password" type="password" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" {% if not is_edit %}required{% endif %}>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">{% if is_edit and row.portal_user_id %}Confirm New Password{% else %}Confirm Password{% endif %}</label>
<input name="portal_password_confirm" type="password" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" {% if not is_edit %}required{% endif %}>
</div>
{% if is_edit and row.portal_user_id %}
<div class="text-xs text-slate-500">
Portal user id linked: {{ row.portal_user_id }}
</div>
{% endif %}
</div>
</div>
<div class="border-t border-slate-200 pt-4">
<h4 class="mb-3 text-sm font-semibold text-slate-900">Compliance Applicability</h4>
<div class="grid gap-3">
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="gst_applicable" value="1" {% if form_data.gst_applicable or (row.gst_applicable if is_edit else false) %}checked{% endif %}>
GST Applicable
</label>
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="income_tax_applicable" value="1" {% if form_data.income_tax_applicable or (row.income_tax_applicable if is_edit else false) %}checked{% endif %}>
Income Tax Applicable
</label>
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="tds_applicable" value="1" {% if form_data.tds_applicable or (row.tds_applicable if is_edit else false) %}checked{% endif %}>
TDS Applicable
</label>
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="roc_applicable" value="1" {% if form_data.roc_applicable or (row.roc_applicable if is_edit else false) %}checked{% endif %}>
ROC Applicable
</label>
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="audit_applicable" value="1" {% if form_data.audit_applicable or (row.audit_applicable if is_edit else false) %}checked{% endif %}>
Audit Applicable
</label>
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="pf_applicable" value="1" {% if form_data.pf_applicable or (row.pf_applicable if is_edit else false) %}checked{% endif %}>
PF Applicable
</label>
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="esi_applicable" value="1" {% if form_data.esi_applicable or (row.esi_applicable if is_edit else false) %}checked{% endif %}>
ESI Applicable
</label>
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="professional_tax_applicable" value="1" {% if form_data.professional_tax_applicable or (row.professional_tax_applicable if is_edit else false) %}checked{% endif %}>
Professional Tax Applicable
</label>
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="payroll_applicable" value="1" {% if form_data.payroll_applicable or (row.payroll_applicable if is_edit else false) %}checked{% endif %}>
Payroll Applicable
</label>
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="msme_applicable" value="1" {% if form_data.msme_applicable or (row.msme_applicable if is_edit else false) %}checked{% endif %}>
MSME Applicable
</label>
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="import_export_applicable" value="1" {% if form_data.import_export_applicable or (row.import_export_applicable if is_edit else false) %}checked{% endif %}>
Import / Export Applicable
</label>
</div>
</div>
</div>
</section>
</div>
@@ -0,0 +1,2 @@
<div class="overflow-hidden rounded-2xl bg-white shadow-soft"><table class="min-w-full divide-y divide-slate-200"><thead class="bg-slate-50"><tr><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Code</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Client</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Association</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Partner</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Branch</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Status</th><th class="px-4 py-3"></th></tr></thead><tbody class="divide-y divide-slate-100">{% for row in rows %}<tr><td class="px-4 py-3 text-sm font-medium text-slate-900">{{ row.client_code }}</td><td class="px-4 py-3 text-sm text-slate-700"><div class="font-medium">{{ row.client_name }}</div><div class="text-xs text-slate-500">{{ row.pan or row.gstin or '-' }}</div></td><td class="px-4 py-3 text-sm text-slate-700"><div>{{ row.association_type or 'legacy_firm' }}</div><div class="text-xs text-slate-500">{{ row.assoc_created_source or 'legacy' }}</div></td><td class="px-4 py-3 text-sm text-slate-700">{{ row.partner_name or row.effective_partner_id or '-' }}</td><td class="px-4 py-3 text-sm text-slate-700">{{ row.branch_name or row.assoc_firm_branch_id or row.branch_id or '-' }}</td><td class="px-4 py-3 text-sm">{% if row.status == 'active' %}<span class="rounded-full bg-emerald-100 px-2 py-1 text-xs font-medium text-emerald-700">Active</span>{% elif row.status == 'archived' %}<span class="rounded-full bg-amber-100 px-2 py-1 text-xs font-medium text-amber-800">Archived</span>{% else %}<span class="rounded-full bg-slate-200 px-2 py-1 text-xs font-medium text-slate-700">Inactive</span>{% endif %}</td><td class="px-4 py-3 text-right"><a href="/clients/{{ row.id }}" class="text-sm font-medium text-brand-700 hover:underline">Open</a></td></tr>{% else %}<tr><td colspan="7" class="px-4 py-8 text-center text-sm text-slate-500">No clients found.</td></tr>{% endfor %}</tbody></table></div>
@@ -0,0 +1,93 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
{% include "modules/clients/templates/clients/_client_tabs.html" %}
<div class="space-y-6">
<section class="rounded-3xl bg-gradient-to-r from-brand-700 to-slate-900 p-6 text-white shadow-soft">
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-brand-100">Client Portal</p>
<h2 class="mt-2 text-2xl font-semibold">My Compliance & Firm Communication</h2>
<p class="mt-2 max-w-3xl text-sm text-brand-100">Track your compliance status, pending actions, required documents, messages and firm updates.</p>
</div>
{% if client_row %}<a href="/client/profile" class="rounded-xl bg-white px-4 py-2 text-sm font-semibold text-brand-700 hover:bg-brand-50">Update My Profile</a>{% endif %}
</div>
</section>
{% if not client_row %}
<div class="rounded-2xl border border-amber-200 bg-amber-50 p-5 text-sm text-amber-900 shadow-soft">
We could not find a client master linked to your login email in the current audit firm. Please contact your firm admin to map this login to the correct client record.
</div>
{% else %}
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
<a href="/client/compliance" class="af-metric-card hover:border-brand-200"><div class="text-xs font-semibold uppercase text-slate-500">Active Compliance</div><div class="mt-2 text-3xl font-semibold text-slate-900">{{ total_engagements or 0 }}</div><div class="mt-1 text-xs text-slate-500">Services / filings</div></a>
<a href="/client/compliance" class="af-metric-card border-amber-200 bg-amber-50 hover:border-amber-300"><div class="text-xs font-semibold uppercase text-amber-700">Pending Action</div><div class="mt-2 text-3xl font-semibold text-amber-700">{{ pending_from_client or 0 }}</div><div class="mt-1 text-xs text-amber-700">Required from you</div></a>
<a href="/client/compliance" class="af-metric-card hover:border-brand-200"><div class="text-xs font-semibold uppercase text-slate-500">With Firm</div><div class="mt-2 text-3xl font-semibold text-brand-700">{{ with_firm or 0 }}</div><div class="mt-1 text-xs text-slate-500">Being handled</div></a>
<a href="/client/documents" class="af-metric-card hover:border-brand-200"><div class="text-xs font-semibold uppercase text-slate-500">Documents</div><div class="mt-2 text-3xl font-semibold text-slate-900">{{ recent_documents|length if recent_documents else 0 }}</div><div class="mt-1 text-xs text-slate-500">Recent uploads</div></a>
<a href="/client/billing" class="af-metric-card border-amber-200 bg-amber-50 hover:border-amber-300"><div class="text-xs font-semibold uppercase text-amber-700">Outstanding Bills</div><div class="mt-2 text-3xl font-semibold text-amber-700">₹ {{ '%.2f'|format(billing_outstanding_amount or 0) }}</div><div class="mt-1 text-xs text-amber-700">{{ billing_open_count or 0 }} open bill(s)</div></a>
</section>
<section class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_380px]">
<div class="space-y-6">
<div class="af-card">
<div class="flex items-center justify-between gap-3"><div><h3 class="text-lg font-semibold text-slate-900">Compliance Status</h3><p class="mt-1 text-sm text-slate-500">Simple client-facing status of your active services.</p></div><a href="/client/compliance" class="af-btn af-btn-primary">View All</a></div>
<div class="mt-5 grid gap-3 md:grid-cols-4 text-sm">
<div class="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3"><div class="text-amber-700">Pending from You</div><div class="mt-1 text-2xl font-semibold text-amber-700">{{ pending_from_client or 0 }}</div></div>
<div class="rounded-2xl border border-brand-200 bg-brand-50 px-4 py-3"><div class="text-brand-700">With Firm</div><div class="mt-1 text-2xl font-semibold text-brand-700">{{ with_firm or 0 }}</div></div>
<div class="rounded-2xl border border-blue-200 bg-blue-50 px-4 py-3"><div class="text-blue-700">Clarification</div><div class="mt-1 text-2xl font-semibold text-blue-700">{{ clarification_required or 0 }}</div></div>
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3"><div class="text-emerald-700">Completed</div><div class="mt-1 text-2xl font-semibold text-emerald-700">{{ completed_engagements or 0 }}</div></div>
</div>
<div class="mt-5 space-y-3">
{% for row in due_soon_engagements[:6] %}
<a href="/client/engagements/{{ row.id }}" class="block rounded-2xl border border-slate-200 p-4 hover:bg-slate-50">
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div><div class="font-semibold text-slate-900">{{ row.catalogue.service_name if row.catalogue else 'Service' }}</div><div class="text-xs text-slate-500">FY {{ row.financial_year }}{% if row.assessment_year %} • AY {{ row.assessment_year }}{% endif %}</div></div>
<div class="text-sm text-slate-600">Due: {{ row.current_due_date.strftime('%d-%m-%Y') if row.current_due_date else '-' }}</div>
</div>
</a>
{% else %}
<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-sm text-slate-500">No active compliance items found.</div>
{% endfor %}
</div>
</div>
<div class="af-card">
<div class="flex items-center justify-between gap-3"><h3 class="text-lg font-semibold text-slate-900">Latest Messages</h3><a href="/client/messages" class="text-sm font-semibold text-brand-700 hover:underline">View all</a></div>
<div class="mt-4 space-y-3">
{% for note in client_visible_comments[:5] %}
<div class="rounded-2xl border border-slate-200 p-4 text-sm"><div class="font-semibold text-slate-900">{{ note.task.task_name if note.task else 'Message' }}</div><p class="mt-2 text-slate-700">{{ note.message }}</p><div class="mt-2 text-xs text-slate-500">{{ note.created_at_utc.strftime('%d-%m-%Y %I:%M %p') if note.created_at_utc else '' }}</div></div>
{% else %}
<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-sm text-slate-500">No messages yet.</div>
{% endfor %}
</div>
</div>
</div>
<aside class="space-y-6">
<div class="af-card">
<h3 class="text-base font-semibold text-slate-900">My Client Profile</h3>
<div class="mt-4 space-y-3 text-sm">
<div><div class="text-xs uppercase tracking-wide text-slate-500">Client</div><div class="font-semibold">{{ client_row.client_name }}</div></div>
<div><div class="text-xs uppercase tracking-wide text-slate-500">PAN / GSTIN</div><div>{{ client_row.pan or '-' }}{% if client_row.gstin %} / {{ client_row.gstin }}{% endif %}</div></div>
<div><div class="text-xs uppercase tracking-wide text-slate-500">Firm Contact</div><div>{{ client_row.partner_name or 'Firm team' }}</div></div>
<div><div class="text-xs uppercase tracking-wide text-slate-500">Branch</div><div>{{ client_row.branch_name or '-' }}</div></div>
</div>
<a href="/client/profile" class="mt-5 inline-flex af-btn af-btn-secondary">Update Profile</a>
</div>
<div class="af-card">
<h3 class="font-semibold text-slate-900">Quick Actions</h3>
<div class="mt-4 grid gap-2 text-sm">
<a href="/client/compliance" class="rounded-xl border border-slate-200 px-4 py-3 font-semibold text-slate-700 hover:bg-slate-50">View My Compliance</a>
<a href="/client/documents" class="rounded-xl border border-slate-200 px-4 py-3 font-semibold text-slate-700 hover:bg-slate-50">Upload / View Documents</a>
<a href="/client/messages" class="rounded-xl border border-slate-200 px-4 py-3 font-semibold text-slate-700 hover:bg-slate-50">Messages from Firm</a>
<a href="/client/billing" class="rounded-xl border border-slate-200 px-4 py-3 font-semibold text-slate-700 hover:bg-slate-50">View Bills & Receipts</a>
{% if billing_latest_due_invoice %}<a href="/client/billing/{{ billing_latest_due_invoice.id }}/pay-now" class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 font-semibold text-amber-700 hover:bg-amber-100">Pay Latest Due</a>{% endif %}
</div>
</div>
</aside>
</section>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,109 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
{% include "modules/clients/templates/clients/_client_tabs.html" %}
<div class="space-y-6">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="text-2xl font-semibold text-slate-900">Edit My Profile</h2>
<p class="text-sm text-slate-500">You can update contact and communication details here. PAN, GSTIN and other compliance identity fields stay read-only.</p>
</div>
<div class="flex gap-3">
<a href="/change-password" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Change Password</a>
</div>
</div>
{% if form_errors %}
<div class="rounded-2xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-800">
<ul class="list-disc space-y-1 pl-5">
{% for error in form_errors %}<li>{{ error }}</li>{% endfor %}
</ul>
</div>
{% endif %}
<form method="post" action="/client/profile" class="space-y-6">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<section class="rounded-2xl bg-white p-6 shadow-soft">
<h3 class="text-base font-semibold text-slate-900">Read-only compliance identity</h3>
<div class="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4 text-sm">
<div class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3"><div class="text-xs font-medium uppercase tracking-wide text-slate-500">PAN</div><div class="mt-1 text-slate-900">{{ client_row.pan or '-' }}</div></div>
<div class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3"><div class="text-xs font-medium uppercase tracking-wide text-slate-500">GSTIN</div><div class="mt-1 text-slate-900">{{ client_row.gstin or '-' }}</div></div>
<div class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3"><div class="text-xs font-medium uppercase tracking-wide text-slate-500">TAN</div><div class="mt-1 text-slate-900">{{ client_row.tan or '-' }}</div></div>
<div class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3"><div class="text-xs font-medium uppercase tracking-wide text-slate-500">CIN / LLPIN</div><div class="mt-1 text-slate-900">{{ client_row.cin_llpin or '-' }}</div></div>
</div>
</section>
<section class="rounded-2xl bg-white p-6 shadow-soft">
<h3 class="text-base font-semibold text-slate-900">Editable profile details</h3>
<div class="mt-4 grid gap-4 md:grid-cols-2">
<div>
<label class="block text-sm font-medium text-slate-700">Client Name</label>
<input name="client_name" value="{{ form_data.client_name or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" required>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Trade Name</label>
<input name="trade_name" value="{{ form_data.trade_name or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Contact Person Name</label>
<input name="contact_person_name" value="{{ form_data.contact_person_name or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Designation</label>
<input name="contact_person_designation" value="{{ form_data.contact_person_designation or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Mobile</label>
<input name="mobile" value="{{ form_data.mobile or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Alternate Mobile</label>
<input name="alternate_mobile" value="{{ form_data.alternate_mobile or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Login Email</label>
<input type="email" name="email" value="{{ form_data.email or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
<p class="mt-1 text-xs text-slate-500">If you change this, your next login will use the new email.</p>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Alternate Email</label>
<input type="email" name="alternate_email" value="{{ form_data.alternate_email or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-slate-700">Address Line 1</label>
<input name="address_line_1" value="{{ form_data.address_line_1 or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-slate-700">Address Line 2</label>
<input name="address_line_2" value="{{ form_data.address_line_2 or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">City</label>
<input name="city" value="{{ form_data.city or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">State</label>
<input name="state" value="{{ form_data.state or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Pincode</label>
<input name="pincode" value="{{ form_data.pincode or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Country</label>
<input name="country" value="{{ form_data.country or 'India' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-slate-700">Notes</label>
<textarea name="notes" rows="4" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">{{ form_data.notes or '' }}</textarea>
</div>
</div>
</section>
<div class="flex justify-end">
<button type="submit" class="rounded-xl bg-brand-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-brand-700">Save Profile</button>
</div>
</form>
</div>
{% endblock %}
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
import csv
import io
import re
PAN_RE = re.compile(r"^[A-Z]{5}[0-9]{4}[A-Z]$")
GSTIN_RE = re.compile(r"^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$")
TAN_RE = re.compile(r"^[A-Z]{4}[0-9]{5}[A-Z]$")
MOBILE_RE = re.compile(r"^[6-9][0-9]{9}$")
PIN_RE = re.compile(r"^[0-9]{6}$")
def normalize_text(value):
if value is None:
return None
text = str(value).strip()
return text or None
def normalize_upper(value):
value = normalize_text(value)
return value.upper() if value else None
def build_csv(rows, headers):
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(headers)
for row in rows:
writer.writerow(row)
return output.getvalue()
+1
View File
@@ -0,0 +1 @@
"""Consultant portal foundation module."""
+311
View File
@@ -0,0 +1,311 @@
from __future__ import annotations
from datetime import date, datetime, timezone
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.db.common import CommonBase
class ConsultantProfile(CommonBase):
"""Portal-enabled consultant / ecosystem partner profile.
The login/security account remains in users. This table stores consultant
business/profile details and links the consultant user to firm clients.
"""
__tablename__ = "consultant_profiles"
__table_args__ = (
UniqueConstraint("tenant_id", "user_id", name="uq_consultant_profiles_tenant_user"),
UniqueConstraint("tenant_id", "email", name="uq_consultant_profiles_tenant_email"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True)
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
consultant_type: Mapped[str] = mapped_column(String(50), nullable=False, default="external_consultant", index=True)
firm_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
contact_person: Mapped[str] = mapped_column(String(200), nullable=False)
email: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
mobile: Mapped[str | None] = mapped_column(String(20), nullable=True)
specialisation: Mapped[str | None] = mapped_column(String(200), nullable=True)
gstin: Mapped[str | None] = mapped_column(String(20), nullable=True)
pan: Mapped[str | None] = mapped_column(String(20), nullable=True)
address: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(30), nullable=False, default="active", index=True)
onboarding_status: Mapped[str] = mapped_column(String(30), nullable=False, default="approved", index=True)
is_platform_partner: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_franchise_partner: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_saas_customer: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
remarks: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
created_at_utc: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
)
updated_at_utc: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
nullable=False,
)
user = relationship("User", foreign_keys=[user_id])
links = relationship(
"ClientConsultantLink",
back_populates="consultant",
cascade="all, delete-orphan",
passive_deletes=True,
)
workspace = relationship(
"ConsultantWorkspace",
back_populates="consultant",
uselist=False,
cascade="all, delete-orphan",
passive_deletes=True,
)
managed_clients = relationship(
"ConsultantManagedClient",
back_populates="consultant",
cascade="all, delete-orphan",
passive_deletes=True,
)
service_requests = relationship(
"ConsultantServiceRequest",
back_populates="consultant",
cascade="all, delete-orphan",
passive_deletes=True,
)
class ConsultantWorkspace(CommonBase):
"""SaaS/franchise workspace settings for a consultant portal account.
This is a foundation table only. It does not change firm-owned clients or
consultant-managed clients. It records whether the consultant operates as a
SaaS customer, franchise partner, platform partner, or a normal external
consultant, along with soft limits used by later subscription/billing phases.
"""
__tablename__ = "consultant_workspaces"
__table_args__ = (
UniqueConstraint("tenant_id", "consultant_id", name="uq_consultant_workspaces_tenant_consultant"),
UniqueConstraint("tenant_id", "workspace_code", name="uq_consultant_workspaces_tenant_code"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True)
consultant_id: Mapped[int] = mapped_column(
ForeignKey("consultant_profiles.id", ondelete="CASCADE"), nullable=False, index=True
)
workspace_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
workspace_name: Mapped[str] = mapped_column(String(200), nullable=False)
workspace_type: Mapped[str] = mapped_column(String(50), nullable=False, default="consultant_saas", index=True)
plan_code: Mapped[str] = mapped_column(String(50), nullable=False, default="starter", index=True)
billing_cycle: Mapped[str] = mapped_column(String(30), nullable=False, default="manual", index=True)
subscription_status: Mapped[str] = mapped_column(String(30), nullable=False, default="trial", index=True)
subscription_start_date: Mapped[date | None] = mapped_column(Date, nullable=True)
subscription_end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
max_managed_clients: Mapped[int] = mapped_column(Integer, nullable=False, default=25)
max_user_accounts: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
allow_client_portal: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
allow_firm_referrals: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
allow_service_marketplace: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
remarks: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
created_at_utc: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
)
updated_at_utc: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
nullable=False,
)
consultant = relationship("ConsultantProfile", back_populates="workspace")
class ClientConsultantLink(CommonBase):
"""Explicit link between a firm client and a consultant portal profile."""
__tablename__ = "client_consultant_links"
__table_args__ = (
UniqueConstraint("tenant_id", "client_id", "consultant_id", name="uq_client_consultant_links_client_consultant"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True)
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True)
consultant_id: Mapped[int] = mapped_column(
ForeignKey("consultant_profiles.id", ondelete="CASCADE"), nullable=False, index=True
)
service_catalogue_id: Mapped[int | None] = mapped_column(ForeignKey("service_catalogues.id", ondelete="SET NULL"), nullable=True, index=True)
relationship_type: Mapped[str] = mapped_column(String(50), nullable=False, default="accounts_consultant", index=True)
is_primary: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
can_view_client: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
can_view_services: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
can_view_due_dates: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
can_view_communications: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
remarks: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
created_at_utc: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
)
updated_at_utc: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
nullable=False,
)
consultant = relationship("ConsultantProfile", back_populates="links")
client = relationship("Client")
service_catalogue = relationship("ServiceCatalogue")
class ConsultantManagedClient(CommonBase):
"""Client/contact managed by a consultant inside the consultant portal.
This table is intentionally separate from the firm `clients` master. It lets
consultants maintain their own client book without affecting audit-firm
client records. A later phase can convert/link a managed client to the firm
client master through an approval workflow.
"""
__tablename__ = "consultant_managed_clients"
__table_args__ = (
UniqueConstraint("tenant_id", "consultant_id", "client_code", name="uq_consultant_managed_clients_code"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True)
consultant_id: Mapped[int] = mapped_column(
ForeignKey("consultant_profiles.id", ondelete="CASCADE"), nullable=False, index=True
)
linked_firm_client_id: Mapped[int | None] = mapped_column(ForeignKey("clients.id", ondelete="SET NULL"), nullable=True, index=True)
client_code: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True)
client_name: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
trade_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
client_type: Mapped[str] = mapped_column(String(100), nullable=False, default="Other")
pan: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
gstin: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
tan: Mapped[str | None] = mapped_column(String(20), nullable=True)
contact_person_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
mobile: Mapped[str | None] = mapped_column(String(20), nullable=True)
email: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
address_line_1: Mapped[str | None] = mapped_column(String(255), nullable=True)
address_line_2: Mapped[str | None] = mapped_column(String(255), nullable=True)
city: Mapped[str | None] = mapped_column(String(100), nullable=True)
state: Mapped[str | None] = mapped_column(String(100), nullable=True)
pincode: Mapped[str | None] = mapped_column(String(20), nullable=True)
country: Mapped[str | None] = mapped_column(String(100), nullable=True, default="India")
service_interest: Mapped[str | None] = mapped_column(Text, nullable=True)
relationship_stage: Mapped[str] = mapped_column(String(30), nullable=False, default="managed", index=True)
status: Mapped[str] = mapped_column(String(30), nullable=False, default="active", index=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
conversion_status: Mapped[str] = mapped_column(String(30), nullable=False, default="not_requested", index=True)
conversion_requested_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
conversion_requested_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
conversion_reviewed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
conversion_reviewed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
conversion_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
conversion_firm_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
created_at_utc: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
)
updated_at_utc: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
nullable=False,
)
consultant = relationship("ConsultantProfile", back_populates="managed_clients")
linked_firm_client = relationship("Client")
class ConsultantServiceRequest(CommonBase):
"""Service request raised by a consultant to the audit firm.
A request can relate either to a consultant-managed client or to an already
linked firm client. The request itself does not create engagements; firm
users review it first and decide whether to accept, reject, or keep it under
review.
"""
__tablename__ = "consultant_service_requests"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True)
consultant_id: Mapped[int] = mapped_column(
ForeignKey("consultant_profiles.id", ondelete="CASCADE"), nullable=False, index=True
)
managed_client_id: Mapped[int | None] = mapped_column(
ForeignKey("consultant_managed_clients.id", ondelete="SET NULL"), nullable=True, index=True
)
firm_client_id: Mapped[int | None] = mapped_column(ForeignKey("clients.id", ondelete="SET NULL"), nullable=True, index=True)
service_catalogue_id: Mapped[int | None] = mapped_column(
ForeignKey("service_catalogues.id", ondelete="SET NULL"), nullable=True, index=True
)
request_no: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
request_type: Mapped[str] = mapped_column(String(50), nullable=False, default="service_request", index=True)
status: Mapped[str] = mapped_column(String(40), nullable=False, default="submitted", index=True)
priority: Mapped[str] = mapped_column(String(30), nullable=False, default="normal", index=True)
requested_service_name: Mapped[str] = mapped_column(String(200), nullable=False)
requested_due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
subject: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
consultant_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
firm_response: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
reviewed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
reviewed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at_utc: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
)
updated_at_utc: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
nullable=False,
)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
consultant = relationship("ConsultantProfile", back_populates="service_requests")
managed_client = relationship("ConsultantManagedClient")
firm_client = relationship("Client")
service_catalogue = relationship("ServiceCatalogue")
created_by = relationship("User", foreign_keys=[created_by_user_id])
reviewed_by = relationship("User", foreign_keys=[reviewed_by_user_id])
+245
View File
@@ -0,0 +1,245 @@
from __future__ import annotations
from collections import OrderedDict
from datetime import date, timedelta
from typing import Any
from sqlalchemy import or_, select
from sqlalchemy.orm import Session, selectinload
from app.modules.clients.models import Client
from app.modules.consultants.models import ClientConsultantLink, ConsultantProfile, ConsultantServiceRequest
from app.modules.documents.models import EngagementDocument, PermanentClientDocument
from app.modules.services.models import (
ClientServiceSubscription,
ClientServiceTaskInstance,
ServiceCatalogue,
ServiceTaskComment,
)
CONSULTANT_BOARD_COLUMNS = OrderedDict(
[
("assigned", "Assigned"),
("awaiting_documents", "Awaiting Documents"),
("in_progress", "In Progress"),
("submitted", "Submitted"),
("accepted", "Accepted"),
("closed", "Closed"),
]
)
def _allowed_client_ids(db: Session, *, consultant: ConsultantProfile, require_communications: bool = False) -> list[int]:
query = select(ClientConsultantLink.client_id).where(
ClientConsultantLink.tenant_id == consultant.tenant_id,
ClientConsultantLink.consultant_id == consultant.id,
ClientConsultantLink.is_active.is_(True),
)
if require_communications:
query = query.where(ClientConsultantLink.can_view_communications.is_(True))
return [int(x) for x in db.execute(query).scalars().all()]
def _board_key_for_status(status: str | None) -> str:
value = (status or "pending").strip().lower()
if value in {"blocked", "awaiting_documents", "document_pending", "clarification_required"}:
return "awaiting_documents"
if value in {"in_progress", "under_process", "processing", "started"}:
return "in_progress"
if value in {"pending_review", "ready_for_review", "submitted", "completed"}:
return "submitted"
if value in {"approved", "accepted"}:
return "accepted"
if value in {"closed", "locked", "cancelled", "inactive"}:
return "closed"
return "assigned"
def _matches_search(*values: Any, q: str = "") -> bool:
term = (q or "").strip().lower()
if not term:
return True
return any(term in str(v or "").lower() for v in values)
def get_consultant_work_board(db: Session, *, consultant: ConsultantProfile, q: str = "", status: str = "") -> dict:
"""Build consultant work board from consultant-visible firm task communications.
The board intentionally uses existing task/comment visibility rules only. A consultant sees a task here only when
the firm has linked the consultant to the client and has created a consultant-visible communication for that task.
"""
client_ids = _allowed_client_ids(db, consultant=consultant, require_communications=True)
columns = {key: {"label": label, "items": []} for key, label in CONSULTANT_BOARD_COLUMNS.items()}
latest_by_task: dict[int, dict] = {}
if client_ids:
rows = db.execute(
select(ServiceTaskComment, ClientServiceTaskInstance, ClientServiceSubscription, Client, ServiceCatalogue)
.join(ClientServiceTaskInstance, ClientServiceTaskInstance.id == ServiceTaskComment.task_instance_id)
.join(ClientServiceSubscription, ClientServiceSubscription.id == ServiceTaskComment.subscription_id)
.join(Client, Client.id == ClientServiceTaskInstance.client_id)
.join(ServiceCatalogue, ServiceCatalogue.id == ClientServiceTaskInstance.service_catalogue_id)
.where(
ServiceTaskComment.tenant_id == consultant.tenant_id,
ServiceTaskComment.visibility == "consultant",
ServiceTaskComment.is_deleted.is_(False),
ClientServiceTaskInstance.client_id.in_(client_ids),
ClientServiceTaskInstance.tenant_id == consultant.tenant_id,
ClientServiceTaskInstance.is_active.is_(True),
)
.order_by(ServiceTaskComment.created_at_utc.desc(), ServiceTaskComment.id.desc())
.limit(300)
).all()
consultant_user_id = int(getattr(consultant, "user_id", 0) or 0)
for comment, task, subscription, client, catalogue in rows:
if int(task.id) in latest_by_task:
continue
if not _matches_search(client.client_name, getattr(client, "client_code", ""), catalogue.service_name, task.task_name, comment.message, q=q):
continue
key = _board_key_for_status(task.status)
if status and key != status:
continue
latest_by_task[int(task.id)] = {
"comment": comment,
"task": task,
"subscription": subscription,
"client": client,
"catalogue": catalogue,
"board_key": key,
"last_message_from_consultant": int(getattr(comment, "created_by_user_id", 0) or 0) == consultant_user_id,
"is_overdue": bool(getattr(task, "internal_target_date", None) and task.internal_target_date < date.today()),
}
for item in latest_by_task.values():
columns[item["board_key"]]["items"].append(item)
service_requests = db.execute(
select(ConsultantServiceRequest)
.options(
selectinload(ConsultantServiceRequest.managed_client),
selectinload(ConsultantServiceRequest.firm_client),
selectinload(ConsultantServiceRequest.service_catalogue),
)
.where(
ConsultantServiceRequest.tenant_id == consultant.tenant_id,
ConsultantServiceRequest.consultant_id == consultant.id,
ConsultantServiceRequest.is_active.is_(True),
)
.order_by(ConsultantServiceRequest.created_at_utc.desc(), ConsultantServiceRequest.id.desc())
.limit(50)
).scalars().all()
return {
"columns": columns,
"column_options": list(CONSULTANT_BOARD_COLUMNS.items()),
"total_tasks": len(latest_by_task),
"service_requests": service_requests,
}
def get_consultant_assignment_detail(db: Session, *, consultant: ConsultantProfile, task_id: int) -> dict | None:
client_ids = _allowed_client_ids(db, consultant=consultant, require_communications=True)
if not client_ids:
return None
row = db.execute(
select(ClientServiceTaskInstance, ClientServiceSubscription, Client, ServiceCatalogue)
.join(ClientServiceSubscription, ClientServiceSubscription.id == ClientServiceTaskInstance.subscription_id)
.join(Client, Client.id == ClientServiceTaskInstance.client_id)
.join(ServiceCatalogue, ServiceCatalogue.id == ClientServiceTaskInstance.service_catalogue_id)
.where(
ClientServiceTaskInstance.id == task_id,
ClientServiceTaskInstance.tenant_id == consultant.tenant_id,
ClientServiceTaskInstance.client_id.in_(client_ids),
ClientServiceTaskInstance.is_active.is_(True),
)
).first()
if not row:
return None
task, subscription, client, catalogue = row
timeline = db.execute(
select(ServiceTaskComment)
.options(selectinload(ServiceTaskComment.created_by))
.where(
ServiceTaskComment.tenant_id == consultant.tenant_id,
ServiceTaskComment.task_instance_id == task.id,
ServiceTaskComment.visibility == "consultant",
ServiceTaskComment.is_deleted.is_(False),
)
.order_by(ServiceTaskComment.created_at_utc.asc(), ServiceTaskComment.id.asc())
).scalars().all()
engagement_documents = db.execute(
select(EngagementDocument)
.options(selectinload(EngagementDocument.versions))
.where(
EngagementDocument.tenant_id == consultant.tenant_id,
EngagementDocument.client_id == client.id,
EngagementDocument.engagement_id == subscription.id,
EngagementDocument.is_deleted.is_(False),
)
.order_by(EngagementDocument.document_type.asc(), EngagementDocument.title.asc())
.limit(50)
).scalars().all()
permanent_documents = db.execute(
select(PermanentClientDocument)
.options(selectinload(PermanentClientDocument.versions))
.where(
PermanentClientDocument.tenant_id == consultant.tenant_id,
PermanentClientDocument.client_id == client.id,
PermanentClientDocument.is_deleted.is_(False),
)
.order_by(PermanentClientDocument.category.asc(), PermanentClientDocument.title.asc())
.limit(50)
).scalars().all()
return {
"task": task,
"subscription": subscription,
"client": client,
"catalogue": catalogue,
"timeline": timeline,
"engagement_documents": engagement_documents,
"permanent_documents": permanent_documents,
}
def get_consultant_document_centre(db: Session, *, consultant: ConsultantProfile, q: str = "") -> dict:
client_ids = _allowed_client_ids(db, consultant=consultant, require_communications=False)
if not client_ids:
return {"engagement_documents": [], "permanent_documents": [], "total": 0}
engagement_query = (
select(EngagementDocument)
.options(selectinload(EngagementDocument.client), selectinload(EngagementDocument.engagement), selectinload(EngagementDocument.versions))
.where(
EngagementDocument.tenant_id == consultant.tenant_id,
EngagementDocument.client_id.in_(client_ids),
EngagementDocument.is_deleted.is_(False),
)
)
permanent_query = (
select(PermanentClientDocument)
.options(selectinload(PermanentClientDocument.client), selectinload(PermanentClientDocument.versions))
.where(
PermanentClientDocument.tenant_id == consultant.tenant_id,
PermanentClientDocument.client_id.in_(client_ids),
PermanentClientDocument.is_deleted.is_(False),
)
)
if (q or "").strip():
term = f"%{q.strip()}%"
engagement_query = engagement_query.where(
or_(EngagementDocument.title.ilike(term), EngagementDocument.document_type.ilike(term), EngagementDocument.document_code.ilike(term))
)
permanent_query = permanent_query.where(
or_(PermanentClientDocument.title.ilike(term), PermanentClientDocument.category.ilike(term), PermanentClientDocument.document_code.ilike(term))
)
engagement_documents = db.execute(
engagement_query.order_by(EngagementDocument.created_at_utc.desc(), EngagementDocument.id.desc()).limit(200)
).scalars().all()
permanent_documents = db.execute(
permanent_query.order_by(PermanentClientDocument.created_at_utc.desc(), PermanentClientDocument.id.desc()).limit(200)
).scalars().all()
return {
"engagement_documents": engagement_documents,
"permanent_documents": permanent_documents,
"total": len(engagement_documents) + len(permanent_documents),
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,14 @@
{% set path = request.url.path %}
<div class="mb-5 overflow-x-auto rounded-2xl border border-slate-200 bg-white p-2 shadow-soft">
<nav class="flex min-w-max gap-2 text-sm font-semibold">
<a href="/consultant/dashboard" class="rounded-xl px-3 py-2 {{ 'bg-slate-900 text-white' if path == '/consultant/dashboard' else 'text-slate-700 hover:bg-slate-100' }}">Overview</a>
<a href="/consultant/work" class="rounded-xl px-3 py-2 {{ 'bg-slate-900 text-white' if path.startswith('/consultant/work') or path.startswith('/consultant/assignments') else 'text-slate-700 hover:bg-slate-100' }}">My Work Board</a>
<a href="/consultant/communications" class="rounded-xl px-3 py-2 {{ 'bg-slate-900 text-white' if path.startswith('/consultant/communications') else 'text-slate-700 hover:bg-slate-100' }}">My Messages</a>
<a href="/consultant/documents" class="rounded-xl px-3 py-2 {{ 'bg-slate-900 text-white' if path.startswith('/consultant/documents') else 'text-slate-700 hover:bg-slate-100' }}">Shared Documents</a>
<a href="/consultant/service-requests" class="rounded-xl px-3 py-2 {{ 'bg-slate-900 text-white' if path.startswith('/consultant/service-requests') else 'text-slate-700 hover:bg-slate-100' }}">Service Requests</a>
<a href="/consultant/managed-clients" class="rounded-xl px-3 py-2 {{ 'bg-slate-900 text-white' if path.startswith('/consultant/managed-clients') else 'text-slate-700 hover:bg-slate-100' }}">Managed Clients</a>
<a href="/consultant/workspace" class="rounded-xl px-3 py-2 {{ 'bg-slate-900 text-white' if path.startswith('/consultant/workspace') else 'text-slate-700 hover:bg-slate-100' }}">Workspace</a>
<a href="/consultant/profile" class="rounded-xl px-3 py-2 {{ 'bg-slate-900 text-white' if path.startswith('/consultant/profile') else 'text-slate-700 hover:bg-slate-100' }}">My Profile</a>
<a href="/alerts" class="rounded-xl px-3 py-2 {{ 'bg-slate-900 text-white' if path.startswith('/alerts') else 'text-slate-700 hover:bg-slate-100' }}">My Alert</a>
</nav>
</div>
@@ -0,0 +1,78 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %}
<div class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="text-xl font-semibold text-slate-900">{{ client.client_name }} — {{ catalogue.service_name }}</h2>
<p class="text-sm text-slate-500">{{ task.task_name }}{% if task.internal_target_date %} • Target {{ task.internal_target_date.strftime('%d-%m-%Y') }}{% endif %}</p>
</div>
<a href="/consultant/work" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back to My Work Board</a>
</div>
<div class="grid gap-6 xl:grid-cols-[1fr_360px]">
<div class="space-y-6">
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
<div class="grid gap-4 md:grid-cols-4">
<div><div class="text-xs uppercase tracking-wide text-slate-500">Task Status</div><div class="mt-1 font-semibold text-slate-900">{{ task.status.replace('_',' ').title() }}</div></div>
<div><div class="text-xs uppercase tracking-wide text-slate-500">Priority</div><div class="mt-1 font-semibold text-slate-900">{{ task.priority.replace('_',' ').title() }}</div></div>
<div><div class="text-xs uppercase tracking-wide text-slate-500">Financial Year</div><div class="mt-1 font-semibold text-slate-900">{{ task.financial_year }}</div></div>
<div><div class="text-xs uppercase tracking-wide text-slate-500">Engagement Status</div><div class="mt-1 font-semibold text-slate-900">{{ subscription.status.replace('_',' ').title() }}</div></div>
</div>
{% if task.description %}<div class="mt-5 rounded-xl bg-slate-50 p-4 text-sm text-slate-700 whitespace-pre-line">{{ task.description }}</div>{% endif %}
</div>
<div class="rounded-2xl border border-slate-200 bg-white shadow-soft">
<div class="border-b border-slate-200 px-5 py-4"><h3 class="font-semibold text-slate-900">Consultant Communication Timeline</h3></div>
<div class="divide-y divide-slate-100">
{% for note in timeline %}
<div class="p-5">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="text-sm font-semibold text-slate-900">{{ note.comment_type.replace('_',' ').title() }}</div>
<div class="text-xs text-slate-500">{{ note.created_at_utc.strftime('%d-%m-%Y %H:%M') if note.created_at_utc else '' }}</div>
</div>
<div class="mt-2 whitespace-pre-line rounded-xl bg-slate-50 p-3 text-sm text-slate-700">{{ note.message }}</div>
</div>
{% else %}
<div class="p-6 text-sm text-slate-500">No consultant-visible timeline yet.</div>
{% endfor %}
</div>
</div>
<form method="post" action="/consultant/assignments/{{ task.id }}/reply" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<h3 class="font-semibold text-slate-900">Send Reply / Submit Update</h3>
{% if errors %}<div class="mt-3 rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-700">{{ errors|join(' ') }}</div>{% endif %}
<textarea name="message" rows="5" required placeholder="Type clarification reply, submission note, or work update" class="mt-4 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm"></textarea>
<div class="mt-4 flex justify-end"><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Send Update</button></div>
</form>
</div>
<aside class="space-y-6">
<div class="rounded-2xl border border-slate-200 bg-white shadow-soft">
<div class="border-b border-slate-200 px-4 py-3"><h3 class="font-semibold text-slate-900">Engagement Documents</h3></div>
<div class="divide-y divide-slate-100">
{% for doc in engagement_documents %}
<div class="p-4">
<div class="font-semibold text-slate-900">{{ doc.title }}</div>
<div class="text-xs text-slate-500">{{ doc.document_type }} • v{{ doc.current_version_no }}</div>
{% if doc.versions %}<div class="mt-1 text-xs text-slate-500">Latest: {{ doc.versions[0].original_filename }}</div>{% endif %}
</div>
{% else %}<div class="p-4 text-sm text-slate-500">No shared engagement documents found.</div>{% endfor %}
</div>
</div>
<div class="rounded-2xl border border-slate-200 bg-white shadow-soft">
<div class="border-b border-slate-200 px-4 py-3"><h3 class="font-semibold text-slate-900">Permanent Documents</h3></div>
<div class="divide-y divide-slate-100">
{% for doc in permanent_documents %}
<div class="p-4">
<div class="font-semibold text-slate-900">{{ doc.title }}</div>
<div class="text-xs text-slate-500">{{ doc.category }} • v{{ doc.current_version_no }}</div>
</div>
{% else %}<div class="p-4 text-sm text-slate-500">No shared permanent documents found.</div>{% endfor %}
</div>
</div>
</aside>
</div>
</div>
{% endblock %}
@@ -0,0 +1,59 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %}
<div class="mx-auto max-w-4xl space-y-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-xl font-semibold text-slate-900">Communication Detail</h2>
<p class="text-sm text-slate-500">{{ client.client_name }} • {{ catalogue.service_name }} • {{ task.task_name }}</p>
</div>
<a href="/consultant/communications" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back</a>
</div>
{% if errors %}
<div class="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
{% for error in errors %}<div>{{ error }}</div>{% endfor %}
</div>
{% endif %}
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
<div class="grid gap-4 text-sm md:grid-cols-3">
<div><div class="text-xs uppercase tracking-wide text-slate-500">Client</div><div class="font-medium text-slate-900">{{ client.client_name }}</div></div>
<div><div class="text-xs uppercase tracking-wide text-slate-500">Service</div><div class="font-medium text-slate-900">{{ catalogue.service_name }}</div></div>
<div><div class="text-xs uppercase tracking-wide text-slate-500">Task Status</div><div class="font-medium text-slate-900">{{ task.status.replace('_',' ').title() }}</div></div>
</div>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
<h3 class="font-semibold text-slate-900">Consultant-visible Timeline</h3>
<div class="mt-4 space-y-4">
{% for item in timeline %}
<div class="rounded-2xl border border-slate-200 p-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-2">
<span class="rounded-full bg-slate-900 px-3 py-1 text-xs font-medium text-white">{{ item.comment_type.replace('_',' ').title() }}</span>
<span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700">{{ item.visibility.replace('_',' ').title() }}</span>
</div>
<div class="text-xs text-slate-500">{{ item.created_at_utc.strftime('%d-%m-%Y %H:%M') if item.created_at_utc else '-' }}</div>
</div>
<div class="mt-2 text-sm font-medium text-slate-700">{{ item.created_by.full_name or item.created_by.email if item.created_by else 'System' }}</div>
<p class="mt-3 whitespace-pre-wrap text-sm leading-6 text-slate-700">{{ item.message }}</p>
</div>
{% else %}
<div class="rounded-xl border border-dashed border-slate-300 px-4 py-6 text-center text-sm text-slate-500">No consultant-visible timeline found.</div>
{% endfor %}
</div>
</div>
{% if not task.is_locked and not (task.subscription and task.subscription.is_locked) %}
<form method="post" action="/consultant/communications/{{ comment.id }}/reply" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label class="mb-2 block text-sm font-medium text-slate-700">Reply to firm</label>
<textarea name="message" rows="5" required class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" placeholder="Type consultant clarification reply..."></textarea>
<div class="mt-4 flex justify-end"><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Send Reply</button></div>
</form>
{% else %}
<div class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">This task/engagement is locked. Replies are disabled.</div>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,41 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %}
<div class="space-y-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-xl font-semibold text-slate-900">Consultant Communications</h2>
<p class="text-sm text-slate-500">Only task messages marked with Visibility = Consultant are listed here.</p>
</div>
<a href="/consultant/dashboard" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back to Dashboard</a>
</div>
<form method="get" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
<div class="grid gap-3 md:grid-cols-[1fr_auto]">
<input name="q" value="{{ q or '' }}" placeholder="Search client, service, task or message" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Filter</button>
</div>
</form>
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
<tr><th class="px-4 py-3">Client / Service</th><th class="px-4 py-3">Task</th><th class="px-4 py-3">Type</th><th class="px-4 py-3">Date</th><th class="px-4 py-3 text-right">Action</th></tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for comment, task, subscription, client, catalogue in rows %}
<tr class="hover:bg-slate-50">
<td class="px-4 py-3"><div class="font-semibold text-slate-900">{{ client.client_name }}</div><div class="text-xs text-slate-500">{{ catalogue.service_name }}</div></td>
<td class="px-4 py-3 text-slate-700">{{ task.task_name }}</td>
<td class="px-4 py-3"><span class="rounded-full bg-purple-50 px-2 py-1 text-xs font-semibold text-purple-700">{{ comment.comment_type.replace('_',' ').title() }}</span></td>
<td class="px-4 py-3 text-slate-500">{{ comment.created_at_utc.strftime('%d-%m-%Y %H:%M') if comment.created_at_utc else '-' }}</td>
<td class="px-4 py-3 text-right"><a href="/consultant/communications/{{ comment.id }}" class="rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">Open / Reply</a></td>
</tr>
{% else %}
<tr><td colspan="5" class="px-4 py-8 text-center text-slate-500">No consultant-visible communication found.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
@@ -0,0 +1,12 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="mx-auto max-w-2xl rounded-3xl bg-white p-6 shadow-soft">
<h2 class="text-xl font-semibold text-slate-900">Consultant Invite Link Generated</h2>
<p class="mt-1 text-sm text-slate-500">Share this link with {{ consultant.contact_person }} to set password and activate consultant portal login.</p>
<div class="mt-5 rounded-2xl border border-brand-200 bg-brand-50 p-4 text-sm text-brand-900 break-all">{{ invite_url }}</div>
<div class="mt-5 flex flex-wrap gap-3">
<a href="/consultants/{{ consultant.id }}" class="rounded-xl bg-brand-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-brand-700">Open Consultant</a>
<a href="/consultants" class="rounded-xl border border-slate-300 px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50">Back to Consultants</a>
</div>
</div>
{% endblock %}
@@ -0,0 +1,70 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-xl font-semibold text-slate-900">Conversion Request: {{ managed_client.client_name }}</h2>
<p class="text-sm text-slate-500">Consultant: {{ consultant.firm_name or consultant.contact_person if consultant else '-' }}</p>
</div>
<a href="/consultants/conversion-requests" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back</a>
</div>
{% if errors %}
<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-700 shadow-soft">
{% for error in errors %}<div>{{ error }}</div>{% endfor %}
</div>
{% endif %}
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
<h3 class="font-semibold text-slate-900">Managed Client Details</h3>
<div class="mt-4 grid gap-4 text-sm md:grid-cols-3">
<div><div class="text-xs uppercase tracking-wide text-slate-500">Client Code</div><div class="font-medium text-slate-900">{{ managed_client.client_code or '-' }}</div></div>
<div><div class="text-xs uppercase tracking-wide text-slate-500">Client Type</div><div class="font-medium text-slate-900">{{ managed_client.client_type }}</div></div>
<div><div class="text-xs uppercase tracking-wide text-slate-500">Conversion Status</div><div class="font-medium text-slate-900">{{ managed_client.conversion_status.replace('_',' ').title() }}</div></div>
<div><div class="text-xs uppercase tracking-wide text-slate-500">PAN</div><div class="font-medium text-slate-900">{{ managed_client.pan or '-' }}</div></div>
<div><div class="text-xs uppercase tracking-wide text-slate-500">GSTIN</div><div class="font-medium text-slate-900">{{ managed_client.gstin or '-' }}</div></div>
<div><div class="text-xs uppercase tracking-wide text-slate-500">Mobile / Email</div><div class="font-medium text-slate-900">{{ managed_client.mobile or managed_client.email or '-' }}</div></div>
</div>
<div class="mt-4 grid gap-4 md:grid-cols-2">
<div class="rounded-xl bg-slate-50 p-3 text-sm text-slate-700"><strong>Consultant notes</strong><br>{{ managed_client.conversion_notes or '-' }}</div>
<div class="rounded-xl bg-slate-50 p-3 text-sm text-slate-700"><strong>Firm notes</strong><br>{{ managed_client.conversion_firm_notes or '-' }}</div>
</div>
</div>
{% if managed_client.linked_firm_client %}
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-5 text-sm text-emerald-900 shadow-soft">
Already converted and linked to firm client: <strong>{{ managed_client.linked_firm_client.client_code }} — {{ managed_client.linked_firm_client.client_name }}</strong>
</div>
{% else %}
<form method="post" action="/consultants/conversion-requests/{{ managed_client.id }}/review" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<h3 class="font-semibold text-slate-900">Firm Review</h3>
<div class="mt-4 grid gap-4 md:grid-cols-3">
<div>
<label class="text-sm font-semibold text-slate-700">Action</label>
<select name="action" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" required>
<option value="under_review">Mark Under Review</option>
<option value="approve">Approve & Create Firm Client</option>
<option value="reject">Reject</option>
</select>
</div>
<div>
<label class="text-sm font-semibold text-slate-700">Firm Client Code</label>
<input name="client_code" value="FC-{{ managed_client.client_code or managed_client.id }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
<p class="mt-1 text-xs text-slate-500">Used only when approving.</p>
</div>
<div>
<label class="text-sm font-semibold text-slate-700">Partner User ID</label>
<input name="partner_user_id" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Optional">
<p class="mt-1 text-xs text-slate-500">Optional for now. You can assign partner later from client master.</p>
</div>
</div>
<div class="mt-4">
<label class="text-sm font-semibold text-slate-700">Firm Notes / Reason</label>
<textarea name="firm_notes" rows="4" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm"></textarea>
</div>
<button class="mt-4 rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Save Review</button>
</form>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,111 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-xl font-semibold text-slate-900">{{ consultant.contact_person }}</h2>
<p class="text-sm text-slate-500">{{ consultant.firm_name or 'Individual consultant' }}{% if consultant.specialisation %} • {{ consultant.specialisation }}{% endif %}</p>
</div>
<div class="flex gap-2">
<a href="/consultants" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back</a>
{% if can_manage %}<a href="/consultants/{{ consultant.id }}/edit" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Edit</a>{% endif %}
</div>
</div>
<div class="grid gap-4 md:grid-cols-4">
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft md:col-span-3">
<div class="grid gap-4 text-sm md:grid-cols-3">
<div><div class="text-xs uppercase tracking-wide text-slate-500">Email</div><div class="font-medium text-slate-900">{{ consultant.email or '-' }}</div></div>
<div><div class="text-xs uppercase tracking-wide text-slate-500">Mobile</div><div class="font-medium text-slate-900">{{ consultant.mobile or '-' }}</div></div>
<div><div class="text-xs uppercase tracking-wide text-slate-500">Type</div><div class="font-medium text-slate-900">{{ consultant.consultant_type.replace('_',' ').title() }}</div></div>
<div><div class="text-xs uppercase tracking-wide text-slate-500">PAN</div><div class="font-medium text-slate-900">{{ consultant.pan or '-' }}</div></div>
<div><div class="text-xs uppercase tracking-wide text-slate-500">GSTIN</div><div class="font-medium text-slate-900">{{ consultant.gstin or '-' }}</div></div>
<div><div class="text-xs uppercase tracking-wide text-slate-500">Login user</div><div class="font-medium text-slate-900">{{ consultant.user.email if consultant.user else '-' }}</div></div>
</div>
{% if consultant.address or consultant.remarks %}
<div class="mt-4 grid gap-4 text-sm md:grid-cols-2">
<div><div class="text-xs uppercase tracking-wide text-slate-500">Address</div><div class="text-slate-700 whitespace-pre-line">{{ consultant.address or '-' }}</div></div>
<div><div class="text-xs uppercase tracking-wide text-slate-500">Remarks</div><div class="text-slate-700 whitespace-pre-line">{{ consultant.remarks or '-' }}</div></div>
</div>
{% endif %}
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
<div class="text-xs uppercase tracking-wide text-slate-500">Status</div>
<div class="mt-2"><span class="rounded-full px-2 py-1 text-xs font-semibold {% if consultant.is_active %}bg-emerald-50 text-emerald-700{% else %}bg-slate-100 text-slate-500{% endif %}">{{ consultant.status }}</span></div>
<div class="mt-4 space-y-2 text-sm text-slate-700">
<div>Platform partner: <b>{{ 'Yes' if consultant.is_platform_partner else 'No' }}</b></div>
<div>Franchise partner: <b>{{ 'Yes' if consultant.is_franchise_partner else 'No' }}</b></div>
<div>SaaS customer: <b>{{ 'Yes' if consultant.is_saas_customer else 'No' }}</b></div>
</div>
</div>
</div>
{% if can_link %}
<form method="post" action="/consultants/{{ consultant.id }}/links" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<h3 class="mb-4 text-base font-semibold text-slate-900">Link client to consultant</h3>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div class="lg:col-span-2">
<label class="mb-1 block text-sm font-medium text-slate-700">Client</label>
<select name="client_id" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" required>
<option value="">Select client</option>
{% for client in clients %}<option value="{{ client.id }}">{{ client.client_name }} ({{ client.client_code }})</option>{% endfor %}
</select>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">Relationship</label>
<select name="relationship_type" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
{% for code,label in relationship_types %}<option value="{{ code }}">{{ label }}</option>{% endfor %}
</select>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">Remarks</label>
<input name="remarks" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
</div>
</div>
<div class="mt-4 flex flex-wrap gap-4 text-sm text-slate-700">
<label class="inline-flex items-center gap-2"><input type="checkbox" name="is_primary"> Primary consultant</label>
<label class="inline-flex items-center gap-2"><input type="checkbox" name="can_view_client" checked> Client details</label>
<label class="inline-flex items-center gap-2"><input type="checkbox" name="can_view_services" checked> Services</label>
<label class="inline-flex items-center gap-2"><input type="checkbox" name="can_view_due_dates" checked> Due dates</label>
<label class="inline-flex items-center gap-2"><input type="checkbox" name="can_view_communications" checked> Consultant communications</label>
</div>
<div class="mt-4"><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Link Client</button></div>
</form>
{% endif %}
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
<div class="border-b border-slate-200 px-4 py-3">
<h3 class="font-semibold text-slate-900">Linked Clients</h3>
</div>
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
<tr><th class="px-4 py-3">Client</th><th class="px-4 py-3">Relationship</th><th class="px-4 py-3">Access</th><th class="px-4 py-3">Status</th><th class="px-4 py-3 text-right">Action</th></tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for link in links %}
<tr>
<td class="px-4 py-3"><div class="font-semibold text-slate-900">{{ link.client.client_name if link.client else '-' }}</div><div class="text-xs text-slate-500">{{ link.client.client_code if link.client else '' }}</div></td>
<td class="px-4 py-3 text-slate-600">{{ link.relationship_type.replace('_',' ').title() }}{% if link.is_primary %}<span class="ml-2 rounded-full bg-blue-50 px-2 py-1 text-xs font-semibold text-blue-700">Primary</span>{% endif %}</td>
<td class="px-4 py-3 text-xs text-slate-600">
Client {{ '✓' if link.can_view_client else '×' }} • Services {{ '✓' if link.can_view_services else '×' }} • Due {{ '✓' if link.can_view_due_dates else '×' }} • Comm {{ '✓' if link.can_view_communications else '×' }}
</td>
<td class="px-4 py-3"><span class="rounded-full px-2 py-1 text-xs font-semibold {% if link.is_active %}bg-emerald-50 text-emerald-700{% else %}bg-slate-100 text-slate-500{% endif %}">{{ 'Active' if link.is_active else 'Inactive' }}</span></td>
<td class="px-4 py-3 text-right">
{% if can_link %}
<form method="post" action="/consultants/links/{{ link.id }}/toggle" class="inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="active" value="{{ '0' if link.is_active else '1' }}">
<button class="rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">{{ 'Disable' if link.is_active else 'Enable' }}</button>
</form>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="5" class="px-4 py-8 text-center text-slate-500">No linked clients.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
@@ -0,0 +1,57 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %}
<div class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="text-xl font-semibold text-slate-900">Shared Documents</h2>
<p class="text-sm text-slate-500">Documents visible through your active client links. Internal firm-only documents are not listed.</p>
</div>
</div>
<form method="get" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
<div class="grid gap-3 md:grid-cols-[1fr_auto]">
<input name="q" value="{{ q or '' }}" placeholder="Search document title, code, category or type" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Search</button>
</div>
</form>
<div class="grid gap-6 xl:grid-cols-2">
<div class="rounded-2xl border border-slate-200 bg-white shadow-soft">
<div class="border-b border-slate-200 px-5 py-4"><h3 class="font-semibold text-slate-900">Engagement Documents</h3></div>
<div class="divide-y divide-slate-100">
{% for doc in docs.engagement_documents %}
<div class="p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="font-semibold text-slate-900">{{ doc.title }}</div>
<div class="text-xs text-slate-500">{{ doc.client.client_name if doc.client else 'Client' }} • {{ doc.document_type }} • {{ doc.financial_year }}</div>
</div>
<span class="rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold text-slate-600">v{{ doc.current_version_no }}</span>
</div>
{% if doc.versions %}<div class="mt-2 text-xs text-slate-500">Latest file: {{ doc.versions[0].original_filename }}</div>{% endif %}
</div>
{% else %}<div class="p-6 text-sm text-slate-500">No engagement documents found.</div>{% endfor %}
</div>
</div>
<div class="rounded-2xl border border-slate-200 bg-white shadow-soft">
<div class="border-b border-slate-200 px-5 py-4"><h3 class="font-semibold text-slate-900">Permanent Documents</h3></div>
<div class="divide-y divide-slate-100">
{% for doc in docs.permanent_documents %}
<div class="p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="font-semibold text-slate-900">{{ doc.title }}</div>
<div class="text-xs text-slate-500">{{ doc.client.client_name if doc.client else 'Client' }} • {{ doc.category }}</div>
</div>
<span class="rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold text-slate-600">v{{ doc.current_version_no }}</span>
</div>
{% if doc.versions %}<div class="mt-2 text-xs text-slate-500">Latest file: {{ doc.versions[0].original_filename }}</div>{% endif %}
</div>
{% else %}<div class="p-6 text-sm text-slate-500">No permanent documents found.</div>{% endfor %}
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,136 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
{% set is_dict = consultant is mapping %}
{% set is_edit = consultant and not is_dict and consultant.id %}
<div class="mx-auto max-w-4xl space-y-6">
<div class="flex items-center justify-between gap-3">
<div>
<h2 class="text-xl font-semibold text-slate-900">{{ title }}</h2>
<p class="text-sm text-slate-500">Create or update a consultant portal profile and optionally create the consultant login user from this page.</p>
</div>
<a href="/consultants" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back</a>
</div>
{% if errors %}
<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">
<ul class="list-disc pl-5">
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
</ul>
</div>
{% endif %}
<form method="post" class="space-y-6 rounded-2xl border border-slate-200 bg-white p-6 shadow-soft">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="rounded-2xl border border-brand-100 bg-brand-50 p-4">
<h3 class="font-semibold text-slate-900">Consultant Login</h3>
<p class="mt-1 text-sm text-slate-600">Link an existing Consultant-role user or create a new login for this consultant.</p>
<div class="mt-4 grid gap-4 md:grid-cols-2">
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">Existing consultant user</label>
{% set current_user_id = consultant.user_id if consultant and not is_dict else consultant.get('user_id') if consultant else None %}
<select name="user_id" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
<option value="">No existing user selected</option>
{% for u in consultant_users %}
<option value="{{ u.id }}" {% if current_user_id == u.id %}selected{% endif %}>{{ u.full_name or u.email }} — {{ u.email }}</option>
{% endfor %}
</select>
<p class="mt-1 text-xs text-slate-500">Leave blank if you want to create a new login below.</p>
</div>
<div class="space-y-2 rounded-xl bg-white p-3">
<label class="inline-flex items-center gap-2 text-sm font-medium text-slate-700">
<input type="checkbox" name="create_login_user" class="h-4 w-4 rounded border-slate-300" {% if consultant and is_dict and consultant.get('create_login_user') %}checked{% endif %}>
Create / enable consultant login
</label>
<label class="inline-flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="invite_login_user" class="h-4 w-4 rounded border-slate-300" {% if consultant and is_dict and consultant.get('invite_login_user') %}checked{% endif %}>
Generate invite link instead of using temporary password
</label>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">Login ID / Email</label>
<input type="email" name="login_email" value="{{ consultant.get('login_email','') if consultant and is_dict else consultant.email if consultant and not is_dict else '' }}" placeholder="consultant@example.com" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">Temporary password</label>
<input type="password" name="temporary_password" placeholder="Minimum 8 characters" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
<p class="mt-1 text-xs text-slate-500">Used only when invite link is not selected. Consultant must change password after login.</p>
</div>
</div>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">Consultant type</label>
{% set current_type = consultant.consultant_type if consultant and not is_dict else consultant.get('consultant_type') if consultant else 'external_consultant' %}
<select name="consultant_type" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
{% for code,label in consultant_types %}<option value="{{ code }}" {% if current_type == code %}selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">Contact person / Consultant name *</label>
<input name="contact_person" value="{{ consultant.contact_person if consultant and not is_dict else consultant.get('contact_person','') if consultant else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" required>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">Firm name</label>
<input name="firm_name" value="{{ consultant.firm_name if consultant and not is_dict else consultant.get('firm_name','') if consultant else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">Email</label>
<input type="email" name="email" value="{{ consultant.email if consultant and not is_dict else consultant.get('email','') if consultant else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">Mobile</label>
<input name="mobile" value="{{ consultant.mobile if consultant and not is_dict else consultant.get('mobile','') if consultant else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">PAN</label>
<input name="pan" value="{{ consultant.pan if consultant and not is_dict else consultant.get('pan','') if consultant else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">GSTIN</label>
<input name="gstin" value="{{ consultant.gstin if consultant and not is_dict else consultant.get('gstin','') if consultant else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
</div>
<div class="md:col-span-2">
<label class="mb-1 block text-sm font-medium text-slate-700">Specialisation</label>
<input name="specialisation" value="{{ consultant.specialisation if consultant and not is_dict else consultant.get('specialisation','') if consultant else '' }}" placeholder="GST, ROC, Payroll, Accounts, Tax filing" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
</div>
<div class="md:col-span-2">
<label class="mb-1 block text-sm font-medium text-slate-700">Address</label>
<textarea name="address" rows="2" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ consultant.address if consultant and not is_dict else consultant.get('address','') if consultant else '' }}</textarea>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">Status</label>
{% set st = consultant.status if consultant and not is_dict else consultant.get('status') if consultant else 'active' %}
<select name="status" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
{% for code in ['active','inactive','on_hold','suspended'] %}<option value="{{ code }}" {% if st == code %}selected{% endif %}>{{ code.replace('_',' ').title() }}</option>{% endfor %}
</select>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">Onboarding status</label>
{% set os = consultant.onboarding_status if consultant and not is_dict else consultant.get('onboarding_status') if consultant else 'active' %}
<select name="onboarding_status" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
{% for code,label in onboarding_statuses %}<option value="{{ code }}" {% if os == code %}selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
</div>
</div>
<div class="grid gap-3 rounded-2xl bg-slate-50 p-4 text-sm text-slate-700 md:grid-cols-4">
<label class="inline-flex items-center gap-2"><input type="checkbox" name="is_platform_partner" {% if consultant and ((not is_dict and consultant.is_platform_partner) or (is_dict and consultant.get('is_platform_partner'))) %}checked{% endif %}> Platform partner</label>
<label class="inline-flex items-center gap-2"><input type="checkbox" name="is_franchise_partner" {% if consultant and ((not is_dict and consultant.is_franchise_partner) or (is_dict and consultant.get('is_franchise_partner'))) %}checked{% endif %}> Franchise partner</label>
<label class="inline-flex items-center gap-2"><input type="checkbox" name="is_saas_customer" {% if consultant and ((not is_dict and consultant.is_saas_customer) or (is_dict and consultant.get('is_saas_customer'))) %}checked{% endif %}> SaaS customer</label>
<label class="inline-flex items-center gap-2"><input type="checkbox" name="is_active" {% if not consultant or (consultant and ((not is_dict and consultant.is_active) or (is_dict and consultant.get('is_active', True)))) %}checked{% endif %}> Active</label>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">Remarks</label>
<textarea name="remarks" rows="3" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ consultant.remarks if consultant and not is_dict else consultant.get('remarks','') if consultant else '' }}</textarea>
</div>
<div class="flex justify-end gap-3">
<a href="/consultants" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Cancel</a>
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Save Consultant</button>
</div>
</form>
</div>
{% endblock %}
@@ -0,0 +1,59 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-xl font-semibold text-slate-900">Consultant Client Conversion Requests</h2>
<p class="text-sm text-slate-500">Review consultant-managed clients requested for conversion into firm client master.</p>
</div>
<div class="flex gap-2">
<a href="/consultants" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Consultants</a>
<a href="/consultants/service-requests" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Service Requests</a>
</div>
</div>
<form method="get" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
<div class="grid gap-3 md:grid-cols-[1fr_auto]">
<select name="status" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
<option value="">All conversion statuses</option>
{% for code, label in conversion_statuses %}
<option value="{{ code }}" {% if status == code %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Filter</button>
</div>
</form>
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
<tr>
<th class="px-4 py-3">Managed Client</th>
<th class="px-4 py-3">Consultant</th>
<th class="px-4 py-3">PAN / GSTIN</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Requested</th>
<th class="px-4 py-3 text-right">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for row in rows %}
<tr class="hover:bg-slate-50">
<td class="px-4 py-3">
<div class="font-semibold text-slate-900">{{ row.client_name }}</div>
<div class="text-xs text-slate-500">{{ row.client_code or '-' }}{% if row.linked_firm_client %} • Linked: {{ row.linked_firm_client.client_code }}{% endif %}</div>
</td>
<td class="px-4 py-3 text-slate-600">{{ row.consultant.firm_name or row.consultant.contact_person if row.consultant else '-' }}</td>
<td class="px-4 py-3 text-slate-600"><div>{{ row.pan or '-' }}</div><div class="text-xs">{{ row.gstin or '-' }}</div></td>
<td class="px-4 py-3"><span class="rounded-full bg-blue-50 px-2 py-1 text-xs font-semibold text-blue-700">{{ row.conversion_status.replace('_',' ').title() }}</span></td>
<td class="px-4 py-3 text-slate-600">{{ row.conversion_requested_at_utc.strftime('%d-%m-%Y') if row.conversion_requested_at_utc else '-' }}</td>
<td class="px-4 py-3 text-right"><a href="/consultants/conversion-requests/{{ row.id }}" class="rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">Review</a></td>
</tr>
{% else %}
<tr><td colspan="6" class="px-4 py-8 text-center text-slate-500">No conversion requests found.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
@@ -0,0 +1,8 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-wrap items-center justify-between gap-3"><div><h2 class="text-xl font-semibold text-slate-900">Consultant Service Requests</h2><p class="text-sm text-slate-500">Review requests raised by consultants.</p></div><a href="/consultants" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Consultants</a></div>
<form method="get" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"><div class="grid gap-3 md:grid-cols-[1fr_auto]"><select name="status" class="rounded-xl border border-slate-300 px-3 py-2 text-sm"><option value="">All statuses</option>{% for code,label in service_request_statuses %}<option value="{{ code }}" {% if status == code %}selected{% endif %}>{{ label }}</option>{% endfor %}</select><button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Filter</button></div></form>
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft"><table class="min-w-full divide-y divide-slate-200 text-sm"><thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500"><tr><th class="px-4 py-3">Request</th><th class="px-4 py-3">Consultant</th><th class="px-4 py-3">Client</th><th class="px-4 py-3">Service</th><th class="px-4 py-3">Status</th><th class="px-4 py-3 text-right">Action</th></tr></thead><tbody class="divide-y divide-slate-100">{% for row in rows %}<tr class="hover:bg-slate-50"><td class="px-4 py-3"><div class="font-semibold text-slate-900">{{ row.request_no }}</div><div class="text-xs text-slate-500">{{ row.subject }}</div></td><td class="px-4 py-3 text-slate-700">{{ row.consultant.contact_person if row.consultant else '-' }}</td><td class="px-4 py-3 text-slate-700">{{ row.managed_client.client_name if row.managed_client else (row.firm_client.client_name if row.firm_client else '-') }}</td><td class="px-4 py-3 text-slate-700">{{ row.requested_service_name }}</td><td class="px-4 py-3"><span class="rounded-full bg-blue-50 px-2 py-1 text-xs font-semibold text-blue-700">{{ row.status.replace('_',' ').title() }}</span></td><td class="px-4 py-3 text-right"><a href="/consultants/service-requests/{{ row.id }}" class="rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">Open</a></td></tr>{% else %}<tr><td colspan="6" class="px-4 py-8 text-center text-slate-500">No consultant service requests found.</td></tr>{% endfor %}</tbody></table></div>
</div>
{% endblock %}
@@ -0,0 +1,71 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-xl font-semibold text-slate-900">Consultants</h2>
<p class="text-sm text-slate-500">Portal-enabled consultants, franchise partners, and external ecosystem collaborators.</p>
</div>
<div class="flex flex-wrap gap-2">
{% if can_manage_consultant_service_requests(current_user, current_user_permissions, current_user_roles) %}
<a href="/consultants/service-requests" class="inline-flex rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Service Requests</a>
{% endif %}
{% if can_manage_consultant_conversions(current_user, current_user_permissions, current_user_roles) %}
<a href="/consultants/conversion-requests" class="inline-flex rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Conversions</a>
{% endif %}
{% if can_manage %}
<a href="/consultants/new" class="inline-flex rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white shadow-soft hover:bg-brand-700">Add Consultant</a>
{% endif %}
</div>
</div>
<form method="get" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
<div class="grid gap-3 md:grid-cols-[1fr_auto_auto]">
<input name="q" value="{{ q or '' }}" placeholder="Search by name, firm, email, mobile, specialisation" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
<label class="inline-flex items-center gap-2 rounded-xl border border-slate-300 px-3 py-2 text-sm text-slate-700">
<input type="checkbox" name="include_inactive" value="1" {% if include_inactive %}checked{% endif %}> Include inactive
</label>
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Filter</button>
</div>
</form>
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
<tr>
<th class="px-4 py-3">Consultant</th>
<th class="px-4 py-3">Contact</th>
<th class="px-4 py-3">Type</th>
<th class="px-4 py-3">Specialisation</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3 text-right">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for row in rows %}
<tr class="hover:bg-slate-50">
<td class="px-4 py-3">
<div class="font-semibold text-slate-900">{{ row.contact_person }}</div>
<div class="text-xs text-slate-500">{{ row.firm_name or 'Individual consultant' }}</div>
</td>
<td class="px-4 py-3 text-slate-600">
<div>{{ row.email or '-' }}</div>
<div class="text-xs">{{ row.mobile or '-' }}</div>
</td>
<td class="px-4 py-3 text-slate-600">{{ row.consultant_type.replace('_', ' ').title() }}</td>
<td class="px-4 py-3 text-slate-600">{{ row.specialisation or '-' }}</td>
<td class="px-4 py-3">
<span class="rounded-full px-2 py-1 text-xs font-semibold {% if row.is_active %}bg-emerald-50 text-emerald-700{% else %}bg-slate-100 text-slate-500{% endif %}">{{ row.status }}</span>
</td>
<td class="px-4 py-3 text-right">
<a href="/consultants/{{ row.id }}" class="rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">Open</a>
</td>
</tr>
{% else %}
<tr><td colspan="6" class="px-4 py-8 text-center text-slate-500">No consultants found.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
@@ -0,0 +1,64 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %}
<div class="space-y-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-xl font-semibold text-slate-900">{{ managed_client.client_name }}</h2>
<p class="text-sm text-slate-500">{{ managed_client.client_code or 'Managed client' }}{% if managed_client.trade_name %} • {{ managed_client.trade_name }}{% endif %}</p>
</div>
<div class="flex gap-2">
<a href="/consultant/managed-clients" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back</a>
<a href="/consultant/managed-clients/{{ managed_client.id }}/edit" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Edit</a>
</div>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
<div class="grid gap-4 text-sm md:grid-cols-3">
<div><div class="text-xs uppercase tracking-wide text-slate-500">Client Type</div><div class="font-medium text-slate-900">{{ managed_client.client_type }}</div></div>
<div><div class="text-xs uppercase tracking-wide text-slate-500">PAN</div><div class="font-medium text-slate-900">{{ managed_client.pan or '-' }}</div></div>
<div><div class="text-xs uppercase tracking-wide text-slate-500">GSTIN</div><div class="font-medium text-slate-900">{{ managed_client.gstin or '-' }}</div></div>
<div><div class="text-xs uppercase tracking-wide text-slate-500">Contact Person</div><div class="font-medium text-slate-900">{{ managed_client.contact_person_name or '-' }}</div></div>
<div><div class="text-xs uppercase tracking-wide text-slate-500">Mobile</div><div class="font-medium text-slate-900">{{ managed_client.mobile or '-' }}</div></div>
<div><div class="text-xs uppercase tracking-wide text-slate-500">Email</div><div class="font-medium text-slate-900">{{ managed_client.email or '-' }}</div></div>
<div><div class="text-xs uppercase tracking-wide text-slate-500">Stage</div><div class="font-medium text-slate-900">{{ managed_client.relationship_stage.replace('_',' ').title() }}</div></div>
<div><div class="text-xs uppercase tracking-wide text-slate-500">Status</div><div class="font-medium text-slate-900">{{ managed_client.status.replace('_',' ').title() }}</div></div>
<div><div class="text-xs uppercase tracking-wide text-slate-500">Active</div><div class="font-medium text-slate-900">{{ 'Yes' if managed_client.is_active else 'No' }}</div></div>
</div>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
<h3 class="font-semibold text-slate-900">Firm Client Conversion</h3>
<div class="mt-3 grid gap-4 text-sm md:grid-cols-3">
<div><div class="text-xs uppercase tracking-wide text-slate-500">Conversion Status</div><div class="font-medium text-slate-900">{{ managed_client.conversion_status.replace('_',' ').title() if managed_client.conversion_status else 'Not Requested' }}</div></div>
<div><div class="text-xs uppercase tracking-wide text-slate-500">Requested On</div><div class="font-medium text-slate-900">{{ managed_client.conversion_requested_at_utc.strftime('%d-%m-%Y %H:%M') if managed_client.conversion_requested_at_utc else '-' }}</div></div>
<div><div class="text-xs uppercase tracking-wide text-slate-500">Linked Firm Client</div><div class="font-medium text-slate-900">{% if managed_client.linked_firm_client %}{{ managed_client.linked_firm_client.client_name }}{% else %}-{% endif %}</div></div>
</div>
{% if managed_client.conversion_notes %}<div class="mt-3 rounded-xl bg-slate-50 p-3 text-sm text-slate-700"><strong>Consultant notes:</strong><br>{{ managed_client.conversion_notes }}</div>{% endif %}
{% if managed_client.conversion_firm_notes %}<div class="mt-3 rounded-xl bg-blue-50 p-3 text-sm text-blue-900"><strong>Firm response:</strong><br>{{ managed_client.conversion_firm_notes }}</div>{% endif %}
{% if can_request_conversion %}
<form method="post" action="/consultant/managed-clients/{{ managed_client.id }}/request-conversion" class="mt-4 space-y-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label class="block text-sm font-semibold text-slate-700">Request conversion to audit firm client</label>
<textarea name="conversion_notes" rows="3" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Mention service requirement, preferred partner, urgency, or client background"></textarea>
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Request Conversion</button>
</form>
{% endif %}
</div>
<div class="grid gap-6 md:grid-cols-2">
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
<h3 class="font-semibold text-slate-900">Address</h3>
<p class="mt-3 whitespace-pre-line text-sm text-slate-700">{{ [managed_client.address_line_1, managed_client.address_line_2, managed_client.city, managed_client.state, managed_client.pincode, managed_client.country] | select | join('\n') or '-' }}</p>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
<h3 class="font-semibold text-slate-900">Service Interest / Notes</h3>
<div class="mt-3 space-y-3 text-sm text-slate-700">
<div><div class="text-xs uppercase tracking-wide text-slate-500">Service Interest</div><div class="whitespace-pre-line">{{ managed_client.service_interest or '-' }}</div></div>
<div><div class="text-xs uppercase tracking-wide text-slate-500">Notes</div><div class="whitespace-pre-line">{{ managed_client.notes or '-' }}</div></div>
</div>
</div>
</div>
</div>
{% endblock %}

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