Compliance — Annual & Periodical — Blueprint
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
cron_plan— daily at 02:00: runsplan_filings,plan_renewals,plan_remindersper tenant. Idempotent.cron_dispatch— every 30 minutes: runsdispatch_due_reminders. Honours per-tenant rate limits set inapps_enabled.cron_lapse— daily at 03:00: any filing pastextended_due_datewith status not in {filed, accepted, na} flips tolapsedand emits an AuditEvent + escalation reminder.
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
- Heat strip across top — 12 months — showing density of obligations.
- Hover on any event → tooltip with regime + status.
- Right rail: "today" panel showing what's actually due now.
- Below the calendar: "next 30 days" agenda list for printing/sharing.
Reminder monitor specifics
- Dispatcher health: green if
last_run_atwithin 60 min and 24h failure rate < 5%. - Failed reminders show inline retry button (admin only).
- Per-channel split: email / WhatsApp / postal counts.
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
- Statutory —
tax_registrations.expires_on,certificates.expires_on,document_custodyfor ack-scan storage.materialise_obligations_for_tenantinspects which tax-registration kinds the tenant has and only creates obligations that apply. - Tenancy/Identity — tenant
apps_enabledflag controls whether certain regimes (e.g. FCRA, GST) are even shown.branchesfor branch-specific certificate filings (fire safety per branch). - Vitta Fin — period-lock state (read-only). Form 10B sign-off refuses if Vitta hasn't locked the corresponding FY.
- Members Suite / Staff — responsible-user assignment.
Writes to
- Audit Trail (mandatory):
record_audit_eventon every status flip (auto via bridge —__audit_log__ = Trueon every model).submit_two_key_changeoncompliance_filings.updateoffiled_on/status/acknowledgement_number, and onaudit_engagements.updateandaudit_engagements.create(auditor identity is legal-weight).- Statutory (when a renewal issues) — creates the successor
tax_registrations/certificatesrow and links viarenewal_calendar.new_*_id. The successor write itself goes through the Statutory service (which is two-key on its own terms). - Comms —
ReminderSchedule.dispatchinvokesaayojana.comms.send(template, recipient, channel)and stores the returned ref incomms_log_ref. If Comms is in stub state (current status), reminders fall back to email-only via the existing SMTP scaffold.
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
- 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.
- Channels per reminder. Email always. WhatsApp opt-in per tenant via
apps_enabled. Postal only for AGMs. Recommend: per-obligationreminder_channelsJSON list — defaults vary by regime. - 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.
- 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. - Extended due dates. CBDT extends Form 10B due date by notification.
extended_due_datefield overridesdue_datefor reminder timing. Should reminders auto-cancel and re-plan when an extension is set? Recommend YES —plan_remindersis idempotent and supersedes earlier schedules withstatus='superseded'. - 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.
- Trustee meeting attendees JSON. Free JSON list now; structured
trustee_meeting_attendeestable later if attendance reports get complex (e.g. computed quorum, voting tallies). Recommend: defer table-isation until first tenant actually needs voting tallies. - GSTR per branch. A multi-state trust files GSTR-3B per state-GSTIN.
Should
compliance_filingscarrybranch_id? Recommend: add nullablebranch_idcolumn (cheap) — UI groups by branch when relevant. - 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 toorganisationsif absent). - Manual obligation creation. A tenant has an internal compliance
item (e.g. 'sister-trust quarterly report') — they'd POST to
/api/compliance/obligationswithis_global_seed=false. Approved. But UI for creating one — deferred to Phase C. - Filings amendment chain depth.
amends_filing_idallows a chain. Should the ORIGINAL row's status flip toamendedon first amendment? Recommend YES — prevents the original from showing as 'filed' after amendment supersedes it; auto-managed byamend_filing.