diff --git a/alembic/versions/20260627_phase_5_engagement_closure_checklist.py b/alembic/versions/20260627_phase_5_engagement_closure_checklist.py
new file mode 100644
index 0000000..de73df0
--- /dev/null
+++ b/alembic/versions/20260627_phase_5_engagement_closure_checklist.py
@@ -0,0 +1,77 @@
+"""phase 5 engagement closure checklist
+
+Revision ID: 20260627_phase_5_engagement_closure_checklist
+Revises: 20260626_phase_4_aqmm_task_evidence_review_notes
+Create Date: 2026-06-27
+"""
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+
+revision = "20260627_phase_5_engagement_closure_checklist"
+down_revision = "20260626_phase_4_aqmm_task_evidence_review_notes"
+branch_labels = None
+depends_on = None
+
+
+def _has_table(bind, table_name: str) -> bool:
+ return sa.inspect(bind).has_table(table_name)
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+ if not _has_table(bind, "engagement_closure_checklists"):
+ op.create_table(
+ "engagement_closure_checklists",
+ sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
+ sa.Column("tenant_id", sa.Integer(), nullable=False),
+ sa.Column("branch_id", sa.Integer(), nullable=True),
+ sa.Column("client_id", sa.Integer(), nullable=False),
+ sa.Column("subscription_id", sa.Integer(), nullable=False),
+ sa.Column("closure_status", sa.String(length=40), nullable=False, server_default="not_started"),
+ sa.Column("aqmm_acceptance_completed", sa.Boolean(), nullable=False, server_default=sa.false()),
+ sa.Column("aqmm_tasks_completed", sa.Boolean(), nullable=False, server_default=sa.false()),
+ sa.Column("evidence_review_completed", sa.Boolean(), nullable=False, server_default=sa.false()),
+ sa.Column("final_documents_released", sa.Boolean(), nullable=False, server_default=sa.false()),
+ sa.Column("udin_completed", sa.Boolean(), nullable=False, server_default=sa.false()),
+ sa.Column("normal_tasks_completed", sa.Boolean(), nullable=False, server_default=sa.false()),
+ sa.Column("deliverables_sent_to_client", sa.Boolean(), nullable=False, server_default=sa.false()),
+ sa.Column("billing_reviewed", sa.Boolean(), nullable=False, server_default=sa.false()),
+ sa.Column("open_points_closed", sa.Boolean(), nullable=False, server_default=sa.false()),
+ sa.Column("client_communication_completed", sa.Boolean(), nullable=False, server_default=sa.false()),
+ sa.Column("closure_note", sa.Text(), nullable=True),
+ sa.Column("closure_block_reason", sa.Text(), nullable=True),
+ sa.Column("closure_approved_by_user_id", sa.Integer(), nullable=True),
+ sa.Column("closure_approved_at_utc", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("reopened_by_user_id", sa.Integer(), nullable=True),
+ sa.Column("reopened_at_utc", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("reopen_reason", sa.Text(), nullable=True),
+ sa.Column("created_by_user_id", sa.Integer(), nullable=True),
+ sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
+ sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
+ sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
+ sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], ondelete="SET NULL"),
+ sa.ForeignKeyConstraint(["client_id"], ["clients.id"], ondelete="CASCADE"),
+ sa.ForeignKeyConstraint(["subscription_id"], ["client_service_subscriptions.id"], ondelete="CASCADE"),
+ sa.ForeignKeyConstraint(["closure_approved_by_user_id"], ["users.id"], ondelete="SET NULL"),
+ sa.ForeignKeyConstraint(["reopened_by_user_id"], ["users.id"], ondelete="SET NULL"),
+ sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"),
+ sa.UniqueConstraint("subscription_id", name="uq_engagement_closure_subscription"),
+ )
+ op.create_index("ix_engagement_closure_tenant", "engagement_closure_checklists", ["tenant_id"])
+ op.create_index("ix_engagement_closure_branch", "engagement_closure_checklists", ["branch_id"])
+ op.create_index("ix_engagement_closure_client", "engagement_closure_checklists", ["client_id"])
+ op.create_index("ix_engagement_closure_subscription", "engagement_closure_checklists", ["subscription_id"])
+ op.create_index("ix_engagement_closure_status", "engagement_closure_checklists", ["closure_status"])
+
+
+def downgrade() -> None:
+ bind = op.get_bind()
+ if _has_table(bind, "engagement_closure_checklists"):
+ op.drop_index("ix_engagement_closure_status", table_name="engagement_closure_checklists")
+ op.drop_index("ix_engagement_closure_subscription", table_name="engagement_closure_checklists")
+ op.drop_index("ix_engagement_closure_client", table_name="engagement_closure_checklists")
+ op.drop_index("ix_engagement_closure_branch", table_name="engagement_closure_checklists")
+ op.drop_index("ix_engagement_closure_tenant", table_name="engagement_closure_checklists")
+ op.drop_table("engagement_closure_checklists")
diff --git a/app/modules/services/engagements_ui.py b/app/modules/services/engagements_ui.py
index a3841aa..97f52d0 100644
--- a/app/modules/services/engagements_ui.py
+++ b/app/modules/services/engagements_ui.py
@@ -10,7 +10,13 @@ from app.core.security.session_auth import get_current_user
from app.core.templating import templates
from app.modules.services.models import ClientServiceSubscription, ClientServiceTaskInstance
from app.modules.services.due_dates import apply_due_date_rule_to_subscription
-from app.modules.services.execution import aqmm_task_summary_for_subscription
+from app.modules.services.execution import (
+ aqmm_task_summary_for_subscription,
+ update_engagement_closure_from_sources,
+ save_engagement_closure_confirmations,
+ approve_engagement_closure,
+ reopen_engagement_closure,
+)
from app.modules.clients.models import Client
from app.modules.services.client_services import (
SUBSCRIPTION_STATUSES,
@@ -400,6 +406,7 @@ def subscription_detail(request: Request, subscription_id: int):
kyc_verification = get_latest_engagement_kyc_verification(db, subscription_id=row.id)
engagement_letter = get_current_engagement_letter(db, subscription_id=row.id)
aqmm_task_summary = aqmm_task_summary_for_subscription(db, subscription_id=row.id)
+ closure_checklist, closure_summary = update_engagement_closure_from_sources(db, subscription=row, actor_user_id=user.id)
tasks = db.execute(
select(ClientServiceTaskInstance)
@@ -420,6 +427,8 @@ def subscription_detail(request: Request, subscription_id: int):
kyc_verification=kyc_verification,
engagement_letter=engagement_letter,
aqmm_task_summary=aqmm_task_summary,
+ closure_checklist=closure_checklist,
+ closure_summary=closure_summary,
can_manage=_can_manage_client_services(db, user),
)
finally:
@@ -578,6 +587,122 @@ def subscription_aqmm_approve(request: Request, subscription_id: int, csrf_token
db.close()
+@router.post("/{subscription_id}/closure/sync")
+def subscription_closure_sync(request: Request, subscription_id: int, csrf_token: str = Form(...)):
+ validate_csrf(request, csrf_token)
+ db = CommonSessionLocal()
+ try:
+ user = get_current_user(request, db=db)
+ if not user:
+ return RedirectResponse(url="/login", status_code=303)
+ try:
+ require_permission(db, user, "clients.view")
+ except Exception:
+ return _redirect_denied()
+ tenant_id = _active_tenant_id(request, user)
+ row = get_subscription(db, subscription_id=subscription_id, tenant_id=tenant_id)
+ if row:
+ update_engagement_closure_from_sources(db, subscription=row, actor_user_id=user.id)
+ db.commit()
+ return RedirectResponse(url=f"/services/engagements/{subscription_id}", status_code=303)
+ finally:
+ db.close()
+
+
+@router.post("/{subscription_id}/closure/update")
+def subscription_closure_update(
+ request: Request,
+ subscription_id: int,
+ deliverables_sent_to_client: str | None = Form(None),
+ billing_reviewed: str | None = Form(None),
+ open_points_closed: str | None = Form(None),
+ client_communication_completed: str | None = Form(None),
+ closure_note: str = Form(""),
+ csrf_token: str = Form(...),
+):
+ validate_csrf(request, csrf_token)
+ db = CommonSessionLocal()
+ try:
+ user = get_current_user(request, db=db)
+ if not user:
+ return RedirectResponse(url="/login", status_code=303)
+ try:
+ require_permission(db, user, "clients.edit")
+ except Exception:
+ return _redirect_denied()
+ tenant_id = _active_tenant_id(request, user)
+ row = get_subscription(db, subscription_id=subscription_id, tenant_id=tenant_id)
+ if row:
+ try:
+ save_engagement_closure_confirmations(
+ db,
+ subscription=row,
+ deliverables_sent_to_client=deliverables_sent_to_client is not None,
+ billing_reviewed=billing_reviewed is not None,
+ open_points_closed=open_points_closed is not None,
+ client_communication_completed=client_communication_completed is not None,
+ closure_note=closure_note,
+ actor_user_id=user.id,
+ )
+ db.commit()
+ except ValueError:
+ db.rollback()
+ return RedirectResponse(url=f"/services/engagements/{subscription_id}", status_code=303)
+ finally:
+ db.close()
+
+
+@router.post("/{subscription_id}/closure/approve")
+def subscription_closure_approve(request: Request, subscription_id: int, csrf_token: str = Form(...)):
+ validate_csrf(request, csrf_token)
+ db = CommonSessionLocal()
+ try:
+ user = get_current_user(request, db=db)
+ if not user:
+ return RedirectResponse(url="/login", status_code=303)
+ if not _can_lock_engagements(db, user):
+ return _redirect_denied()
+ tenant_id = _active_tenant_id(request, user)
+ row = get_subscription(db, subscription_id=subscription_id, tenant_id=tenant_id)
+ if row:
+ try:
+ approve_engagement_closure(db, subscription=row, actor_user_id=user.id)
+ db.commit()
+ except ValueError:
+ db.rollback()
+ return RedirectResponse(url=f"/services/engagements/{subscription_id}", status_code=303)
+ finally:
+ db.close()
+
+
+@router.post("/{subscription_id}/closure/reopen")
+def subscription_closure_reopen(
+ request: Request,
+ subscription_id: int,
+ reason: str = Form(""),
+ csrf_token: str = Form(...),
+):
+ validate_csrf(request, csrf_token)
+ db = CommonSessionLocal()
+ try:
+ user = get_current_user(request, db=db)
+ if not user:
+ return RedirectResponse(url="/login", status_code=303)
+ if not _can_lock_engagements(db, user):
+ return _redirect_denied()
+ tenant_id = _active_tenant_id(request, user)
+ row = get_subscription(db, subscription_id=subscription_id, tenant_id=tenant_id)
+ if row:
+ try:
+ reopen_engagement_closure(db, subscription=row, reason=reason, actor_user_id=user.id)
+ db.commit()
+ except ValueError:
+ db.rollback()
+ return RedirectResponse(url=f"/services/engagements/{subscription_id}", status_code=303)
+ finally:
+ db.close()
+
+
@router.get("/{subscription_id}/edit")
def subscription_edit_page(request: Request, subscription_id: int):
db = CommonSessionLocal()
diff --git a/app/modules/services/execution.py b/app/modules/services/execution.py
index a8614f0..3714cfa 100644
--- a/app/modules/services/execution.py
+++ b/app/modules/services/execution.py
@@ -12,6 +12,7 @@ from app.modules.services.client_services import quality_required_for_engagement
from app.modules.services.models import (
ClientServiceSubscription,
ClientServiceTaskInstance,
+ EngagementClosureChecklist,
ServiceTaskComment,
FirmServiceTaskTemplate,
ServiceCatalogue,
@@ -450,6 +451,245 @@ def assert_aqmm_quality_tasks_complete(db: Session, *, subscription_id: int) ->
raise ValueError("AQMM rework is open for one or more quality checklist tasks.")
+# -----------------------------------------------------------------------------
+# Phase 5 - Engagement closure checklist
+# -----------------------------------------------------------------------------
+
+CLOSURE_NOT_STARTED = "not_started"
+CLOSURE_PENDING = "pending"
+CLOSURE_READY = "ready_for_partner_closure"
+CLOSURE_CLOSED = "closed"
+CLOSURE_REOPENED = "reopened"
+
+
+def get_or_create_engagement_closure(
+ db: Session,
+ *,
+ subscription: ClientServiceSubscription,
+ actor_user_id: int | None = None,
+) -> EngagementClosureChecklist:
+ row = db.execute(
+ select(EngagementClosureChecklist).where(
+ EngagementClosureChecklist.subscription_id == subscription.id
+ )
+ ).scalar_one_or_none()
+ if row:
+ return row
+ row = EngagementClosureChecklist(
+ tenant_id=subscription.tenant_id,
+ branch_id=subscription.branch_id,
+ client_id=subscription.client_id,
+ subscription_id=subscription.id,
+ closure_status=CLOSURE_NOT_STARTED,
+ created_by_user_id=actor_user_id,
+ )
+ db.add(row)
+ return row
+
+
+def _normal_tasks_completed(db: Session, *, subscription_id: int) -> bool:
+ open_task = db.execute(
+ select(ClientServiceTaskInstance.id).where(
+ ClientServiceTaskInstance.subscription_id == subscription_id,
+ ClientServiceTaskInstance.is_active.is_(True),
+ ClientServiceTaskInstance.status.notin_(list(CLOSED_TASK_STATUSES)),
+ )
+ ).first()
+ return open_task is None
+
+
+def _final_documents_released(db: Session, *, subscription_id: int, require_final_document: bool) -> bool:
+ released = db.execute(
+ select(EngagementDocument.id).where(
+ EngagementDocument.engagement_id == subscription_id,
+ EngagementDocument.is_deleted.is_(False),
+ EngagementDocument.final_release_status == "released",
+ )
+ ).first()
+ if released:
+ return True
+ if not require_final_document:
+ # Non-assurance closures can proceed even where no final document release
+ # workflow was used for that engagement. Assurance closures need at least
+ # one released final document.
+ return True
+ return False
+
+
+def _udin_completed(db: Session, *, subscription_id: int) -> bool:
+ pending = db.execute(
+ select(EngagementDocument.id).where(
+ EngagementDocument.engagement_id == subscription_id,
+ EngagementDocument.is_deleted.is_(False),
+ EngagementDocument.udin_required.is_(True),
+ or_(
+ EngagementDocument.udin_number.is_(None),
+ EngagementDocument.udin_number == "",
+ EngagementDocument.udin_status.in_(["pending", "required", "not_generated"]),
+ ),
+ )
+ ).first()
+ return pending is None
+
+
+def closure_readiness_for_subscription(db: Session, *, subscription: ClientServiceSubscription) -> dict:
+ assurance = quality_required_for_engagement(subscription.engagement_type)
+ aqmm_summary = aqmm_task_summary_for_subscription(db, subscription_id=subscription.id)
+ normal_tasks_done = _normal_tasks_completed(db, subscription_id=subscription.id)
+
+ aqmm_acceptance_completed = (not assurance) or getattr(subscription, "quality_acceptance_status", None) == QUALITY_APPROVED
+ aqmm_tasks_completed = (not assurance) or (
+ aqmm_summary["mandatory_done"] == aqmm_summary["mandatory"]
+ and aqmm_summary["evidence_missing"] == 0
+ and aqmm_summary["manager_review_pending"] == 0
+ and aqmm_summary["partner_review_pending"] == 0
+ and aqmm_summary["review_partner_review_pending"] == 0
+ and aqmm_summary["rework_open"] == 0
+ )
+ evidence_review_completed = (not assurance) or (
+ aqmm_summary["evidence_missing"] == 0
+ and aqmm_summary["manager_review_pending"] == 0
+ and aqmm_summary["partner_review_pending"] == 0
+ and aqmm_summary["review_partner_review_pending"] == 0
+ and aqmm_summary["rework_open"] == 0
+ )
+ final_documents_released = _final_documents_released(
+ db, subscription_id=subscription.id, require_final_document=assurance
+ )
+ udin_completed = _udin_completed(db, subscription_id=subscription.id)
+
+ return {
+ "assurance": assurance,
+ "aqmm_task_summary": aqmm_summary,
+ "normal_tasks_completed": normal_tasks_done,
+ "aqmm_acceptance_completed": aqmm_acceptance_completed,
+ "aqmm_tasks_completed": aqmm_tasks_completed,
+ "evidence_review_completed": evidence_review_completed,
+ "final_documents_released": final_documents_released,
+ "udin_completed": udin_completed,
+ }
+
+
+def update_engagement_closure_from_sources(
+ db: Session,
+ *,
+ subscription: ClientServiceSubscription,
+ actor_user_id: int | None = None,
+) -> tuple[EngagementClosureChecklist, dict]:
+ row = get_or_create_engagement_closure(db, subscription=subscription, actor_user_id=actor_user_id)
+ summary = closure_readiness_for_subscription(db, subscription=subscription)
+
+ row.aqmm_acceptance_completed = bool(summary["aqmm_acceptance_completed"])
+ row.aqmm_tasks_completed = bool(summary["aqmm_tasks_completed"])
+ row.evidence_review_completed = bool(summary["evidence_review_completed"])
+ row.final_documents_released = bool(summary["final_documents_released"])
+ row.udin_completed = bool(summary["udin_completed"])
+ row.normal_tasks_completed = bool(summary["normal_tasks_completed"])
+
+ blockers: list[str] = []
+ if not row.normal_tasks_completed:
+ blockers.append("open work tracker tasks pending")
+ if summary["assurance"]:
+ if not row.aqmm_acceptance_completed:
+ blockers.append("AQMM acceptance not approved")
+ if not row.aqmm_tasks_completed:
+ blockers.append("mandatory AQMM tasks/evidence/reviews pending")
+ if not row.evidence_review_completed:
+ blockers.append("AQMM evidence or review notes pending")
+ if not row.final_documents_released:
+ blockers.append("final document not released")
+ if not row.udin_completed:
+ blockers.append("UDIN pending for UDIN-required document")
+ if not row.deliverables_sent_to_client:
+ blockers.append("deliverables sent to client not confirmed")
+ if not row.billing_reviewed:
+ blockers.append("billing/fee status not reviewed")
+ if not row.open_points_closed:
+ blockers.append("open points not closed")
+ if not row.client_communication_completed:
+ blockers.append("client communication not completed")
+
+ row.closure_block_reason = "; ".join(blockers) or None
+ if row.closure_status == CLOSURE_CLOSED:
+ pass
+ elif blockers:
+ row.closure_status = CLOSURE_PENDING if row.closure_status != CLOSURE_REOPENED else CLOSURE_REOPENED
+ else:
+ row.closure_status = CLOSURE_READY
+ return row, {**summary, "blockers": blockers}
+
+
+def save_engagement_closure_confirmations(
+ db: Session,
+ *,
+ subscription: ClientServiceSubscription,
+ deliverables_sent_to_client: bool,
+ billing_reviewed: bool,
+ open_points_closed: bool,
+ client_communication_completed: bool,
+ closure_note: str | None,
+ actor_user_id: int,
+) -> tuple[EngagementClosureChecklist, dict]:
+ row = get_or_create_engagement_closure(db, subscription=subscription, actor_user_id=actor_user_id)
+ if row.closure_status == CLOSURE_CLOSED:
+ raise ValueError("Closed engagement cannot be edited. Reopen it first.")
+ row.deliverables_sent_to_client = bool(deliverables_sent_to_client)
+ row.billing_reviewed = bool(billing_reviewed)
+ row.open_points_closed = bool(open_points_closed)
+ row.client_communication_completed = bool(client_communication_completed)
+ row.closure_note = (closure_note or "").strip() or None
+ return update_engagement_closure_from_sources(db, subscription=subscription, actor_user_id=actor_user_id)
+
+
+def approve_engagement_closure(
+ db: Session,
+ *,
+ subscription: ClientServiceSubscription,
+ actor_user_id: int,
+) -> EngagementClosureChecklist:
+ row, summary = update_engagement_closure_from_sources(db, subscription=subscription, actor_user_id=actor_user_id)
+ if summary["blockers"]:
+ raise ValueError(row.closure_block_reason or "Engagement closure checklist is not complete.")
+ now = datetime.now(timezone.utc)
+ row.closure_status = CLOSURE_CLOSED
+ row.closure_block_reason = None
+ row.closure_approved_by_user_id = actor_user_id
+ row.closure_approved_at_utc = now
+ subscription.status = "completed"
+ subscription.is_locked = True
+ subscription.locked_at_utc = now
+ subscription.locked_by_user_id = actor_user_id
+ subscription.updated_by_user_id = actor_user_id
+ return row
+
+
+def reopen_engagement_closure(
+ db: Session,
+ *,
+ subscription: ClientServiceSubscription,
+ reason: str,
+ actor_user_id: int,
+) -> EngagementClosureChecklist:
+ clean_reason = (reason or "").strip()
+ if not clean_reason:
+ raise ValueError("Reopen reason is required.")
+ row = get_or_create_engagement_closure(db, subscription=subscription, actor_user_id=actor_user_id)
+ now = datetime.now(timezone.utc)
+ row.closure_status = CLOSURE_REOPENED
+ row.reopened_by_user_id = actor_user_id
+ row.reopened_at_utc = now
+ row.reopen_reason = clean_reason
+ row.closure_approved_by_user_id = None
+ row.closure_approved_at_utc = None
+ subscription.is_locked = False
+ if subscription.status == "completed":
+ subscription.status = "active"
+ subscription.locked_at_utc = None
+ subscription.locked_by_user_id = None
+ subscription.updated_by_user_id = actor_user_id
+ return row
+
+
def _decorate_task_for_tracker(task: ClientServiceTaskInstance, *, today: date) -> ClientServiceTaskInstance:
target_date = getattr(task, "internal_target_date", None)
subscription = getattr(task, "subscription", None)
diff --git a/app/modules/services/models.py b/app/modules/services/models.py
index d6ec03c..eeb542f 100644
--- a/app/modules/services/models.py
+++ b/app/modules/services/models.py
@@ -563,6 +563,61 @@ class EngagementLetter(CommonBase):
created_by = relationship("User", foreign_keys=[created_by_user_id])
+class EngagementClosureChecklist(CommonBase):
+ """Engagement closure checklist attached to the existing engagement.
+
+ Closure is the final file-completion gate. It summarises evidence already
+ available in AQMM acceptance, AQMM-tagged tasks, task documents/reviews,
+ final released documents and UDIN controls. Only a few closure confirmations
+ are manual; the rest are calculated from the existing workflow.
+ """
+
+ __tablename__ = "engagement_closure_checklists"
+ __table_args__ = (
+ UniqueConstraint("subscription_id", name="uq_engagement_closure_subscription"),
+ )
+
+ 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)
+ subscription_id: Mapped[int] = mapped_column(ForeignKey("client_service_subscriptions.id", ondelete="CASCADE"), nullable=False, index=True)
+
+ closure_status: Mapped[str] = mapped_column(String(40), nullable=False, default="not_started", index=True)
+
+ aqmm_acceptance_completed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
+ aqmm_tasks_completed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
+ evidence_review_completed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
+ final_documents_released: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
+ udin_completed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
+ normal_tasks_completed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
+
+ deliverables_sent_to_client: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
+ billing_reviewed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
+ open_points_closed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
+ client_communication_completed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
+
+ closure_note: Mapped[str | None] = mapped_column(Text, nullable=True)
+ closure_block_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
+
+ closure_approved_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
+ closure_approved_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+
+ reopened_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
+ reopened_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ reopen_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
+
+ created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
+ created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
+ updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
+
+ subscription = relationship("ClientServiceSubscription")
+ client = relationship("Client")
+ closure_approved_by = relationship("User", foreign_keys=[closure_approved_by_user_id])
+ reopened_by = relationship("User", foreign_keys=[reopened_by_user_id])
+ created_by = relationship("User", foreign_keys=[created_by_user_id])
+
+
class ClientServiceTaskInstance(CommonBase):
"""Execution task generated from a firm service task template for a client-service subscription."""
diff --git a/app/modules/services/templates/services/engagements/detail.html b/app/modules/services/templates/services/engagements/detail.html
index a58b4a4..a761d3b 100644
--- a/app/modules/services/templates/services/engagements/detail.html
+++ b/app/modules/services/templates/services/engagements/detail.html
@@ -105,6 +105,53 @@
{% endif %}
+ {% if closure_checklist %}
+ Closure uses existing AQMM acceptance, AQMM-tagged tasks, task evidence/review notes, final document release and UDIN controls. Only final delivery confirmations are manual.Engagement Closure Checklist
+