2
📅

Compliance — Annual & Periodical — Blueprint

Renewal calendar · returns · audit tracking

Blueprint — Compliance (Annual & Periodical)

Status: planned · Slug: compliance · Kind: ERP · Module #2 Depends on: Audit Trail (audit), Statutory Data (statutory), Communications Service (comms).

1. Module summary

Compliance is the recurring-deadline engine sitting on top of Statutory Data. Statutory holds what the trust is registered for; Compliance schedules when each obligation is due, tracks filings as they happen, and dispatches reminders before they lapse. Domain coverage: Form 10B (audit), FC-4 (FCRA annual), ITR-7 (income tax), GSTR-1 / GSTR-3B (monthly/quarterly GST), TDS 24Q + 26Q (quarterly), 12A / 80G / FCRA / FSSAI / fire-safety renewal cycles, statutory trustee meetings. The module models obligations as a master template (per regime) instantiated into individual filing rows with due_date / filed_date / status. A reminder scheduler dispatches T-30/T-7/T-1 notices via Comms; an audit-engagement table tracks each year's auditor relationship and management response.

2. Data model

src/aayojana/models/compliance.py. Tenant-scoped via TenantMixin. Filings, audit engagements, and trustee-meeting records carry __audit_log__ = True so every status change is captured. compliance_filings.update of filed_date / status is two-key (audit-relevant — back-dating a filing is a red flag).

# src/aayojana/models/compliance.py
from datetime import date, datetime
from sqlalchemy import (
    JSON, Boolean, Date, DateTime, ForeignKey, Integer, Numeric,
    String, Text, UniqueConstraint, Index, func,
)
from sqlalchemy.orm import Mapped, mapped_column

from aayojana.models.base import AuditMixin, Base, TenantMixin, uuid4_str


class ComplianceObligation(Base, TenantMixin, AuditMixin):
    """Master template — one row per recurring obligation a tenant
    is subject to. e.g. 'GSTR-3B monthly' for tenants with GSTIN, or
    'FC-4 annual' for tenants with FCRA registration.

    Some rows are seeded globally (regime-driven) and copied into a
    tenant when that tenant gains a relevant Statutory registration.
    Tenant-specific overrides allowed (e.g. custom internal report)."""

    __tablename__ = "compliance_obligations"
    __table_args__ = (
        UniqueConstraint(
            "tenant_id", "code", name="uq_obligation_code_per_tenant"
        ),
        Index("ix_obligation_regime", "regime", "is_active"),
    )
    __audit_log__ = True

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)

    code: Mapped[str] = mapped_column(String(40), nullable=False)
    # 'FORM-10B' | 'FC-4' | 'ITR-7' | 'GSTR-1' | 'GSTR-3B' |
    # 'TDS-24Q' | 'TDS-26Q' | '12A-RENEW' | '80G-RENEW' |
    # 'FCRA-RENEW' | 'FSSAI-RENEW' | 'FIRE-SAFETY' | 'BOARD-MEETING' | ...
    title: Mapped[str] = mapped_column(String(255), nullable=False)
    regime: Mapped[str] = mapped_column(String(40), nullable=False)
    # 'income-tax' | 'fcra' | 'gst' | 'tds' | 'state-renewal' |
    # 'safety' | 'governance' | 'other'

    # Cadence
    cadence: Mapped[str] = mapped_column(String(20), nullable=False)
    # 'monthly' | 'quarterly' | 'half-yearly' | 'annual' |
    # 'biennial' | '5-yearly' | 'on-event' | 'one-shot'
    cadence_anchor: Mapped[str | None] = mapped_column(String(40), nullable=True)
    # 'fy-end' | 'cy-end' | 'cert-issue-date' | 'fy-quarter-end' |
    # 'month-end' | 'meeting-date' | 'custom'
    due_offset_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
    # how many days after the anchor the filing is due
    # (e.g. Form 10B = anchor=fy-end, offset=212 → 31 Oct)

    # Linkage to statutory record (when the obligation is *contingent*
    # on the tenant having that registration)
    requires_tax_registration_kind: Mapped[str | None] = mapped_column(
        String(16), nullable=True
    )
    # '12A' | '80G' | 'FCRA' | 'GSTIN' | 'TAN' | null

    # Reminder schedule
    reminder_offsets_days: Mapped[list | None] = mapped_column(JSON, nullable=True)
    # JSON list of negative ints, e.g. [-30, -7, -1] → reminders T-30, T-7, T-1
    reminder_channels: Mapped[list | None] = mapped_column(JSON, nullable=True)
    # ['email', 'whatsapp', 'postal'] — null = email only

    # Ownership defaults
    default_responsible_user_id: Mapped[int | None] = mapped_column(
        Integer, ForeignKey("users.id"), nullable=True
    )

    # Lifecycle
    is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="true")
    is_global_seed: Mapped[bool] = mapped_column(
        Boolean, nullable=False, server_default="false"
    )
    # if True and tenant_id is null → master template loaded into every tenant
    notes: Mapped[str | None] = mapped_column(Text, nullable=True)


class ComplianceFiling(Base, TenantMixin, AuditMixin):
    """Concrete instance of an obligation for a specific period.
    Generated by the planner cron job (looks at obligations with
    cadence ≤ next-12-months and creates rows that don't yet exist).

    The `period_label` is a human-readable identifier — 'FY 2025-26',
    'Q1 FY26', 'Apr 2026' — and is unique with tenant + obligation."""

    __tablename__ = "compliance_filings"
    __table_args__ = (
        UniqueConstraint(
            "tenant_id", "obligation_id", "period_label",
            name="uq_filing_obligation_period",
        ),
        Index("ix_filing_due", "tenant_id", "due_date"),
        Index("ix_filing_status", "tenant_id", "status"),
    )
    __audit_log__ = True
    __audit_two_key__ = ("update", "delete")
    # actual coverage rule narrows two-key to filed_date and status
    # via audit_coverage rows seeded in 0013

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)

    obligation_id: Mapped[str] = mapped_column(
        String(36), ForeignKey("compliance_obligations.id"), nullable=False, index=True
    )
    period_label: Mapped[str] = mapped_column(String(40), nullable=False)
    period_start: Mapped[date | None] = mapped_column(Date, nullable=True)
    period_end: Mapped[date | None] = mapped_column(Date, nullable=True)

    # Lifecycle
    due_date: Mapped[date] = mapped_column(Date, nullable=False)
    extended_due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
    status: Mapped[str] = mapped_column(
        String(20), nullable=False, server_default="upcoming"
    )
    # 'upcoming' | 'in-progress' | 'filed' | 'accepted' | 'rejected' |
    # 'amended' | 'lapsed' | 'na'
    filed_on: Mapped[date | None] = mapped_column(Date, nullable=True)
    accepted_on: Mapped[date | None] = mapped_column(Date, nullable=True)

    # Filed details
    acknowledgement_number: Mapped[str | None] = mapped_column(String(127), nullable=True)
    portal_reference: Mapped[str | None] = mapped_column(String(127), nullable=True)
    filed_by_user_id: Mapped[int | None] = mapped_column(
        Integer, ForeignKey("users.id"), nullable=True
    )

    # Amendments
    amends_filing_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("compliance_filings.id"), nullable=True
    )
    amendment_reason: Mapped[str | None] = mapped_column(Text, nullable=True)

    # Statutory cross-link (e.g. a 12A renewal filing referencing the
    # specific tax_registration row)
    tax_registration_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("tax_registrations.id"), nullable=True
    )
    certificate_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("certificates.id"), nullable=True
    )

    # Custody pointer for the filing-acknowledgement scan
    custody_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("document_custody.id"), nullable=True
    )

    # Penalty / qualification
    penalty_amount: Mapped[float | None] = mapped_column(Numeric(18, 2), nullable=True)
    qualification_text: Mapped[str | None] = mapped_column(Text, nullable=True)
    # for filings that came back with auditor qualifications

    notes: Mapped[str | None] = mapped_column(Text, nullable=True)


class RenewalCalendar(Base, TenantMixin, AuditMixin):
    """Specifically: long-cycle renewals (12A 5-year, 80G 5-year, FCRA 5-year,
    FSSAI 1-year, fire-safety annual). Distinct from `compliance_filings`
    because:
    - a renewal is not a filing per se — it produces a NEW
      tax_registration / certificate row (with a new number)
    - the cycle is anchored to the prior cert's expiry, not to FY-end
    - the workflow involves application → acknowledgement →
      issuance, which can span months

    One row per upcoming renewal; closed when the new statutory record
    is created."""

    __tablename__ = "renewal_calendar"
    __table_args__ = (
        Index("ix_renewal_due", "tenant_id", "expected_renewal_date"),
        Index("ix_renewal_status", "tenant_id", "status"),
    )
    __audit_log__ = True

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)

    target_kind: Mapped[str] = mapped_column(String(16), nullable=False)
    # '12A' | '80G' | 'FCRA' | 'FSSAI' | 'fire-safety' | 'pt' | 'other'
    target_tax_registration_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("tax_registrations.id"), nullable=True
    )
    target_certificate_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("certificates.id"), nullable=True
    )

    expected_renewal_date: Mapped[date] = mapped_column(Date, nullable=False)
    application_window_opens_on: Mapped[date | None] = mapped_column(Date, nullable=True)
    application_window_closes_on: Mapped[date | None] = mapped_column(Date, nullable=True)

    status: Mapped[str] = mapped_column(
        String(20), nullable=False, server_default="upcoming"
    )
    # 'upcoming' | 'application-prepared' | 'submitted' |
    # 'queries-raised' | 'issued' | 'rejected' | 'lapsed' | 'na'
    application_submitted_on: Mapped[date | None] = mapped_column(Date, nullable=True)
    application_reference: Mapped[str | None] = mapped_column(String(127), nullable=True)
    issued_on: Mapped[date | None] = mapped_column(Date, nullable=True)
    new_tax_registration_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("tax_registrations.id"), nullable=True
    )
    new_certificate_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("certificates.id"), nullable=True
    )

    responsible_user_id: Mapped[int | None] = mapped_column(
        Integer, ForeignKey("users.id"), nullable=True
    )
    notes: Mapped[str | None] = mapped_column(Text, nullable=True)


class AuditEngagement(Base, TenantMixin, AuditMixin):
    """Auditor relationship per FY. Captures appointment, scope,
    progress, completion, qualifications, management response.
    One row per (tenant, fy, auditor) — typically one per FY but
    history shows when auditors change."""

    __tablename__ = "audit_engagements"
    __table_args__ = (
        UniqueConstraint(
            "tenant_id", "fy_label", "auditor_firm_name",
            name="uq_audit_eng_tenant_fy_firm",
        ),
        Index("ix_audit_eng_status", "tenant_id", "status"),
    )
    __audit_log__ = True
    __audit_two_key__ = ("update", "delete")

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)

    fy_label: Mapped[str] = mapped_column(String(20), nullable=False)
    # 'FY 2025-26'
    fy_start: Mapped[date] = mapped_column(Date, nullable=False)
    fy_end: Mapped[date] = mapped_column(Date, nullable=False)

    # Auditor
    auditor_firm_name: Mapped[str] = mapped_column(String(255), nullable=False)
    auditor_frn: Mapped[str | None] = mapped_column(String(40), nullable=True)
    # firm registration number (ICAI)
    engagement_partner_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
    engagement_partner_membership: Mapped[str | None] = mapped_column(String(40), nullable=True)
    contact_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
    contact_phone: Mapped[str | None] = mapped_column(String(48), nullable=True)

    # Lifecycle
    appointed_on: Mapped[date | None] = mapped_column(Date, nullable=True)
    appointment_resolution_ref: Mapped[str | None] = mapped_column(String(127), nullable=True)
    fieldwork_started_on: Mapped[date | None] = mapped_column(Date, nullable=True)
    draft_received_on: Mapped[date | None] = mapped_column(Date, nullable=True)
    final_signed_on: Mapped[date | None] = mapped_column(Date, nullable=True)
    status: Mapped[str] = mapped_column(
        String(20), nullable=False, server_default="appointed"
    )
    # 'appointed' | 'in-fieldwork' | 'draft' | 'signed' | 'qualified' |
    # 'adverse' | 'disclaimer' | 'na'

    # Outcome
    opinion: Mapped[str | None] = mapped_column(String(40), nullable=True)
    # 'unqualified' | 'qualified' | 'adverse' | 'disclaimer'
    qualifications_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
    management_response: Mapped[str | None] = mapped_column(Text, nullable=True)
    response_recorded_on: Mapped[date | None] = mapped_column(Date, nullable=True)

    # Linked filing (Form 10B etc.)
    filing_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("compliance_filings.id"), nullable=True
    )
    custody_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("document_custody.id"), nullable=True
    )

    fees_amount: Mapped[float | None] = mapped_column(Numeric(18, 2), nullable=True)
    notes: Mapped[str | None] = mapped_column(Text, nullable=True)


class TrusteeMeeting(Base, TenantMixin, AuditMixin):
    """Statutory minutes calendar — board meetings, AGMs, special
    resolutions. Tracks notice issuance, attendance, minutes
    finalisation. AGM is a once-a-year obligation in many regimes
    (8 months from FY-end for trusts under various statutes)."""

    __tablename__ = "trustee_meetings"
    __table_args__ = (
        Index("ix_trustee_mtg_when", "tenant_id", "scheduled_on"),
    )
    __audit_log__ = True

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)

    kind: Mapped[str] = mapped_column(String(20), nullable=False)
    # 'agm' | 'board' | 'special' | 'committee'
    title: Mapped[str] = mapped_column(String(255), nullable=False)
    scheduled_on: Mapped[date] = mapped_column(Date, nullable=False)
    scheduled_time: Mapped[str | None] = mapped_column(String(8), nullable=True)
    venue: Mapped[str | None] = mapped_column(String(511), nullable=True)
    is_virtual: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")

    # Notice
    notice_issued_on: Mapped[date | None] = mapped_column(Date, nullable=True)
    notice_required_lead_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
    quorum_required: Mapped[int | None] = mapped_column(Integer, nullable=True)
    quorum_met: Mapped[bool | None] = mapped_column(Boolean, nullable=True)

    # Outcome
    status: Mapped[str] = mapped_column(
        String(20), nullable=False, server_default="scheduled"
    )
    # 'scheduled' | 'held' | 'adjourned' | 'cancelled'
    held_on: Mapped[date | None] = mapped_column(Date, nullable=True)
    attendees: Mapped[list | None] = mapped_column(JSON, nullable=True)
    # [{ user_id, name, attended_y_n, role }]

    # Minutes
    minutes_drafted_by_user_id: Mapped[int | None] = mapped_column(
        Integer, ForeignKey("users.id"), nullable=True
    )
    minutes_signed_on: Mapped[date | None] = mapped_column(Date, nullable=True)
    minutes_custody_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("document_custody.id"), nullable=True
    )

    # Linked resolutions (free-form list)
    resolutions: Mapped[list | None] = mapped_column(JSON, nullable=True)
    # [{ number, subject, type ('ordinary'|'special'), passed, votes_for, votes_against }]

    # AGM may link to a compliance_filings row (annual return)
    related_filing_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("compliance_filings.id"), nullable=True
    )
    notes: Mapped[str | None] = mapped_column(Text, nullable=True)


class ReminderSchedule(Base, TenantMixin, AuditMixin):
    """Concrete reminder dispatch entries — one per (filing/renewal × offset).
    Generated from the obligation's `reminder_offsets_days` when the
    filing or renewal row is created. Cron picks up due rows, calls
    Comms, marks dispatched."""

    __tablename__ = "reminder_schedules"
    __table_args__ = (
        Index("ix_reminders_send_at", "send_at", "status"),
    )

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)

    # Source — exactly one of these is set
    filing_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("compliance_filings.id"), nullable=True, index=True
    )
    renewal_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("renewal_calendar.id"), nullable=True, index=True
    )
    audit_engagement_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("audit_engagements.id"), nullable=True, index=True
    )
    trustee_meeting_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("trustee_meetings.id"), nullable=True, index=True
    )

    offset_days: Mapped[int] = mapped_column(Integer, nullable=False)
    # negative for "before due", positive for "after"
    send_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
    channels: Mapped[list] = mapped_column(JSON, nullable=False)
    # ['email','whatsapp','postal']
    target_user_id: Mapped[int | None] = mapped_column(
        Integer, ForeignKey("users.id"), nullable=True
    )
    target_role: Mapped[str | None] = mapped_column(String(40), nullable=True)
    # 'tenant-admin' | 'module-admin' | (specific user via target_user_id)
    template_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
    # template registered with Comms

    status: Mapped[str] = mapped_column(
        String(20), nullable=False, server_default="scheduled"
    )
    # 'scheduled' | 'dispatched' | 'failed' | 'cancelled' | 'superseded'
    dispatched_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
    comms_log_ref: Mapped[str | None] = mapped_column(String(64), nullable=True)
    last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
    attempts: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0")

3. Reuse map

Concept Use existing Do NOT redefine
Tenant organisations new tenant table
Branch branches (some filings, e.g. fire safety, are branch-specific) re-derive
User / responsible users.id new contact table
Statutory anchor tax_registrations, certificates, document_custody (Statutory module) duplicate cert details
Audit logging / two-key services/audit.record_audit_event + submit_two_key_change per-table audit
Document scans document_custody (Statutory) — link via custody_id for ack-letter scans new file table
Multi-tenancy mixin TenantMixin manual tenant_id
Communications dispatch aayojana.comms Communications Service — ReminderSchedule references its templates and writes to its log new mailer here
Period-locking on Vitta Vitta Fin's period-lock mechanism — Form 10B / FC-4 filings should not allow edit if Vitta period is locked for that FY own period model

4. API surface

All under /api/compliance/. Tenant-scoped via JWT.

Method Path Purpose Roles
GET /api/compliance/obligations List obligations applicable to tenant tenant-admin, module-admin
POST /api/compliance/obligations Add tenant-specific obligation (rare; usually seeded) tenant-admin
PATCH /api/compliance/obligations/{id} Edit cadence/owner tenant-admin
GET /api/compliance/filings List filings (filter: status, due-window, regime, period) tenant-admin, module-admin
GET /api/compliance/filings/upcoming Filings due in next N days (default 90) tenant-admin, module-admin
GET /api/compliance/filings/overdue Filings past due date and not filed tenant-admin
POST /api/compliance/filings Manual create (planner usually does this) tenant-admin
GET /api/compliance/filings/{id} Detail tenant-admin, module-admin
PATCH /api/compliance/filings/{id} Edit (two-key on filed_on, status, acknowledgement_number) tenant-admin
POST /api/compliance/filings/{id}/mark-filed Convenience: status→filed (two-key) tenant-admin
POST /api/compliance/filings/{id}/upload-ack Upload acknowledgement scan tenant-admin
POST /api/compliance/filings/{id}/amend Create amending filing row tenant-admin
GET /api/compliance/renewals Renewal calendar entries tenant-admin, module-admin
POST /api/compliance/renewals Add renewal (usually auto-generated) tenant-admin
PATCH /api/compliance/renewals/{id} Edit tenant-admin
POST /api/compliance/renewals/{id}/submit-application Mark submitted tenant-admin
POST /api/compliance/renewals/{id}/issue Mark issued, link new statutory row tenant-admin
GET /api/compliance/audit-engagements List tenant-admin, module-admin
POST /api/compliance/audit-engagements New engagement (two-key) tenant-admin
PATCH /api/compliance/audit-engagements/{id} Edit (two-key) tenant-admin
POST /api/compliance/audit-engagements/{id}/sign-off Mark final-signed (two-key) tenant-admin
GET /api/compliance/trustee-meetings List tenant-admin, module-admin
POST /api/compliance/trustee-meetings Schedule tenant-admin
PATCH /api/compliance/trustee-meetings/{id} Edit tenant-admin
POST /api/compliance/trustee-meetings/{id}/finalise-minutes Sign minutes tenant-admin
GET /api/compliance/reminders Scheduled reminders tenant-admin, module-admin
POST /api/compliance/reminders/{id}/cancel Cancel a scheduled reminder tenant-admin
POST /api/compliance/reminders/dispatch-now Force-run dispatcher (admin/dev) tenant-admin
GET /api/compliance/calendar Calendar JSON: all filings + renewals + meetings + reminders for the date range tenant-admin, module-admin
GET /api/compliance/dashboard Stats: overdue count, this-week count, upcoming-30, dispatcher health tenant-admin
GET /compliance/ (HTML) Module landing tenant-admin, module-admin
GET /compliance/calendar (HTML) Calendar view tenant-admin, module-admin
GET /compliance/filings (HTML) Filings list tenant-admin, module-admin
GET /compliance/renewals (HTML) Renewal pipeline tenant-admin, module-admin
GET /compliance/audit-engagements (HTML) Auditor list tenant-admin
GET /compliance/trustee-meetings (HTML) Meetings list tenant-admin
GET /compliance/reminders (HTML) Dispatcher monitor tenant-admin

5. Service layer

src/aayojana/services/compliance.py.

def seed_global_obligations(db: Session) -> int:
    """One-shot: populate global ComplianceObligation rows
    (FORM-10B, FC-4, ITR-7, GSTR-1, GSTR-3B, TDS-24Q, TDS-26Q,
    12A-RENEW, 80G-RENEW, FCRA-RENEW, FSSAI-RENEW, FIRE-SAFETY,
    BOARD-MEETING, AGM). is_global_seed=True, tenant_id=NULL."""

def materialise_obligations_for_tenant(tenant_id: str, db: Session) -> list[ComplianceObligation]:
    """Copy global seeds (filtered by `requires_tax_registration_kind`
    matching the tenant's actual statutory rows) into tenant-scoped
    rows. Idempotent — skips ones already present."""

def plan_filings(tenant_id: str, lookahead_days: int = 365, db: Session) -> int:
    """Cron — for each active obligation, materialise filing rows
    for the next N days. Idempotent via (tenant, obligation, period_label)
    unique constraint. Returns count created."""

def plan_renewals(tenant_id: str, db: Session) -> int:
    """Cron — for each tax_registration / certificate with expires_on,
    create a RenewalCalendar row if not present. Idempotent."""

def plan_reminders(filing_id: str | None = None, renewal_id: str | None = None,
                    audit_engagement_id: str | None = None,
                    trustee_meeting_id: str | None = None,
                    db: Session) -> int:
    """For the given source row, look up reminder_offsets_days on the
    obligation (or sensible defaults for renewals/meetings), generate
    ReminderSchedule rows. Idempotent."""

def dispatch_due_reminders(now: datetime, db: Session) -> dict:
    """Cron (every 30 min) — picks ReminderSchedule rows where
    send_at <= now and status='scheduled'. Calls Comms with
    template + recipient. Marks dispatched/failed. Returns summary."""

def mark_filing_filed(filing_id: str, filed_on: date, ack_number: str,
                       actor_id: int, reason: str, db: Session) -> PendingWrite:
    """Two-key: filed_on / acknowledgement_number / status flip."""

def amend_filing(filing_id: str, new_data: dict, actor_id: int,
                  reason: str, db: Session) -> ComplianceFiling:
    """Create successor row with amends_filing_id pointing back. Audit-logged."""

def open_audit_engagement(data: dict, actor_id: int, reason: str,
                           db: Session) -> PendingWrite:
    """Two-key (auditor identity is legal-weight)."""

def signoff_audit_engagement(eng_id: str, opinion: str, qualifications: str | None,
                              actor_id: int, db: Session) -> PendingWrite: ...

def finalise_meeting_minutes(meeting_id: str, signed_on: date,
                              custody_id: str, actor_id: int,
                              db: Session) -> TrusteeMeeting: ...

def list_overdue(tenant_id: str, db: Session) -> dict:
    """{ filings: [...], renewals: [...] } where due_date < today and
    status not yet final."""

def calendar_view(tenant_id: str, range_start: date, range_end: date,
                   db: Session) -> list[dict]:
    """Mixed list of filings + renewals + meetings + reminders for
    the calendar UI. Each entry: {when, kind, label, status, link}."""

def reminder_dispatcher_health(tenant_id: str, db: Session) -> dict:
    """{ scheduled, dispatched_today, failed_today, last_run_at }
    for monitor UI."""

Cron jobs

6. UI / Templates

Templates under src/aayojana/templates/compliance/. Inherits Aayojana admin shell.

Page Route Highlights
Module landing /compliance/ Dashboard tiles: overdue count, due this week, due in 30 days, in-flight renewals, current audit engagement, next trustee meeting
Calendar view /compliance/calendar Month grid, colour by regime (income-tax red, FCRA purple, GST blue, governance green); each cell shows event count; click → drawer with details. Toggle: month / week / agenda list
Filings list /compliance/filings Filter by regime, status, period; table with due date, status pill, responsible user, ack number, mark-filed button
Filing detail /compliance/filings/{id} Full record, ack scan, amendment history, audit history sidebar, mark-filed form (with reason for two-key)
Renewal pipeline /compliance/renewals Pipeline view (Kanban: upcoming → application-prepared → submitted → queries-raised → issued); each card shows expected date and days remaining
Renewal detail /compliance/renewals/{id} Application timeline; submit/issue actions; link to new statutory row when issued
Audit engagements /compliance/audit-engagements One row per FY, shows status, partner, fees, opinion outcome
Audit engagement detail /compliance/audit-engagements/{id} Full timeline; opinion + qualifications + management response section; sign-off action (two-key)
Trustee meetings /compliance/trustee-meetings Calendar + list; AGM highlighted
Meeting detail /compliance/trustee-meetings/{id} Notice/quorum/attendance/resolutions/minutes
Reminders monitor /compliance/reminders Dispatcher health card (last run, success rate); upcoming reminders table; failed-reminder alerts

Calendar UI specifics

Reminder monitor specifics

7. Migration plan

0013   — Audit Trail tables
0014   — Statutory Data tables
0015   — Compliance tables                          <-- THIS MODULE
           compliance_obligations, compliance_filings,
           renewal_calendar, audit_engagements, trustee_meetings,
           reminder_schedules
0016+  — Other agents

Compliance is 0015 because: - It FKs into tax_registrations and certificates (built in 0014). - It FKs into document_custody (built in 0014). - It depends on Audit Trail coverage rules (built in 0013).

In the same revision: - Seed global compliance_obligations rows (is_global_seed=true, tenant_id=null) for the 14 standard codes listed above. - Seed audit-coverage rows: compliance_filings.update.filed_date, compliance_filings.update.status → two-key; audit_engagements.* → two-key.

Style: idempotent (if not insp.has_table(...)), additive-only, offline-mode-safe.

8. Cross-module dependencies

Reads from

Writes to

Contract every consumer calls

Caller Calls When
Statutory renew_tax_registration compliance.renewals.mark_issued a new tax reg is issued — closes the renewal entry
Statutory add_certificate compliance.plan_renewals new cert with expiry → renewal entry materialised
Statutory add_tax_registration (if it has a renewable kind) compliance.materialise_obligations_for_tenant tenant gains a new regime → applicable obligations seeded
Vitta lock_period compliance.filings.list_for_fy inform the user which filings are blocked by the lock

9. Implementation phases

Phase A — Schemas + basic CRUD (Sprint 1, ~1.5 weeks). - Migration 0015 with 6 tables + 14 seeded global obligations. - Models in aayojana/models/compliance.py. - Service layer: seed_global_obligations, materialise_obligations_for_tenant, plan_filings, plan_renewals. Read routes for all 6 tables. List + detail HTML pages (read-only). Audit auto-log enabled.

Phase B — Two-key flow + reminders (Sprint 2, ~1.5 weeks). - POST/PATCH routes; mark_filing_filed, signoff_audit_engagement etc. - Two-key routing for filed_on / status / engagement edits. - ReminderSchedule generation on filing/renewal/engagement/meeting create. - dispatch_due_reminders cron + Comms integration (email-only if WhatsApp not yet live). - Reminder monitor UI.

Phase C — Dashboards + bulk operations (Sprint 3, ~1 week). - Calendar UI with regime colour-coding. - Renewal Kanban pipeline. - Bulk import (existing filings as CSV) for first tenant onboarding. - Audit engagement opinion/qualification reports. - Cross-module: Vitta period-lock check on filing sign-off; Statutory renewal-issue closing the renewal calendar entry.

10. Open questions

  1. Reminder cadence default. T-30 / T-7 / T-1 is the requested baseline. Per-obligation override exists. Should AGMs (which require 14- or 21-day notice depending on regime) default to T-30/T-21/T-14/T-7/T-1? Recommend YES — meeting reminders use a richer default; tax filings stick with T-30/T-7/T-1.
  2. Channels per reminder. Email always. WhatsApp opt-in per tenant via apps_enabled. Postal only for AGMs. Recommend: per-obligation reminder_channels JSON list — defaults vary by regime.
  3. Period_label format. 'FY 2025-26', 'Q1 FY26', 'Apr 2026', 'Jun-Q 26'. No global standard. Recommend: per-cadence template stored on the obligation (e.g. 'Apr-{yyyy}' for monthly GSTR), so labels are mechanical and unique.
  4. Obligation requires_tax_registration_kind matching. A tenant may have 12A but no FCRA — FC-4 obligation must NOT materialise. Schema supports it via requires_tax_registration_kind. Multi-requirement (e.g. needs BOTH 12A AND TAN) is uncommon — defer until a real case.
  5. Extended due dates. CBDT extends Form 10B due date by notification. extended_due_date field overrides due_date for reminder timing. Should reminders auto-cancel and re-plan when an extension is set? Recommend YES — plan_reminders is idempotent and supersedes earlier schedules with status='superseded'.
  6. Audit retention for filings. Form 10B + workpapers must be retained 8 years. Audit engagements retain forever in DB; ack scans in GCS under cold-line tier after 2 years to save cost. Defer until storage bill argues for it.
  7. Trustee meeting attendees JSON. Free JSON list now; structured trustee_meeting_attendees table later if attendance reports get complex (e.g. computed quorum, voting tallies). Recommend: defer table-isation until first tenant actually needs voting tallies.
  8. GSTR per branch. A multi-state trust files GSTR-3B per state-GSTIN. Should compliance_filings carry branch_id? Recommend: add nullable branch_id column (cheap) — UI groups by branch when relevant.
  9. Reminder dispatcher latency. 30-min cron is fine for T-30/T-7/T-1 but T-1 for an evening filing means morning-cron-after-deadline. Recommend: T-1 reminders fire at 9 AM tenant-local time; cron checks tenant time_zone (add column to organisations if absent).
  10. Manual obligation creation. A tenant has an internal compliance item (e.g. 'sister-trust quarterly report') — they'd POST to /api/compliance/obligations with is_global_seed=false. Approved. But UI for creating one — deferred to Phase C.
  11. Filings amendment chain depth. amends_filing_id allows a chain. Should the ORIGINAL row's status flip to amended on first amendment? Recommend YES — prevents the original from showing as 'filed' after amendment supersedes it; auto-managed by amend_filing.