Statutory Data — Blueprint
Blueprint — Statutory Data
Status: planned · Slug:
statutory· Kind: ERP · Module #1 Depends on: Audit Trail (audit) — every legal-weight write logs/two-keys through it.
1. Module summary
Statutory Data is Aayojana's vault of legal records — the paper-and-PDF facts that prove a Trust exists and explain what it owns. Trust Deed, 12A/80G/FCRA/PAN/TAN/GSTIN, fire-safety / FSSAI / professional-tax certificates, document custody chain (locker IDs, shelf addresses, custodian names), bank-loan agreements, and asset-acquisition records (sale deeds, gift deeds, link documents). Unlike operational records, these documents have legal weight: every change must be reasoned, two-key signed, and audit-logged. The module is intentionally narrow on workflow (no business rules around the data — it just stores and surfaces it) but rich on metadata (custody chain, expiry, scan-blob hash, link-document trail).
2. Data model
src/aayojana/models/statutory.py. Every table is tenant-scoped via TenantMixin,
carries AuditMixin, and (for legal-weight entities) opts into the audit bridge.
Document scans live in GCS; the DB stores only metadata + content hash + GCS path.
# src/aayojana/models/statutory.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 TrustRegistration(Base, TenantMixin, AuditMixin):
"""The Trust Deed metadata. Exactly one *active* per tenant
(a tenant can carry historical/superseded rows for reference)."""
__tablename__ = "trust_registrations"
__table_args__ = (
Index("ix_trust_reg_tenant_active", "tenant_id", "is_active"),
)
__audit_log__ = True
__audit_two_key__ = ("update", "delete")
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
# Identification
registered_name: Mapped[str] = mapped_column(String(255), nullable=False)
deed_number: Mapped[str] = mapped_column(String(127), nullable=False)
registration_date: Mapped[date] = mapped_column(Date, nullable=False)
registering_authority: Mapped[str | None] = mapped_column(String(255), nullable=True)
place_of_registration: Mapped[str | None] = mapped_column(String(127), nullable=True)
sub_registrar_office: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Classification
trust_type: Mapped[str | None] = mapped_column(String(40), nullable=True)
# 'Public Charitable' | 'Religious' | 'Public Religious' | 'Section 8' | ...
parampara: Mapped[str | None] = mapped_column(String(127), nullable=True)
sampradaya: Mapped[str | None] = mapped_column(String(127), nullable=True)
# Validity
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="true")
superseded_by_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("trust_registrations.id"), nullable=True
)
superseded_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
# Custody pointer
custody_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("document_custody.id"), nullable=True
)
# Notes
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
class TaxRegistration(Base, TenantMixin, AuditMixin):
"""12A · 80G · FCRA · PAN · TAN · GSTIN (and any other tax-side
registration). One row per registration kind per tenant; multiple
rows allowed only when there's a renewal cycle (`replaces_id`)."""
__tablename__ = "tax_registrations"
__table_args__ = (
UniqueConstraint(
"tenant_id", "kind", "certificate_number",
name="uq_tax_reg_kind_number_per_tenant",
),
Index("ix_tax_reg_kind_active", "kind", "is_active"),
Index("ix_tax_reg_expires", "expires_on"),
)
__audit_log__ = True
__audit_two_key__ = ("update", "delete")
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
kind: Mapped[str] = mapped_column(String(16), nullable=False)
# '12A' | '80G' | 'FCRA' | 'PAN' | 'TAN' | 'GSTIN' | 'PT' | 'OTHER'
certificate_number: Mapped[str] = mapped_column(String(127), nullable=False)
issued_on: Mapped[date | None] = mapped_column(Date, nullable=True)
expires_on: Mapped[date | None] = mapped_column(Date, nullable=True)
# null = perpetual (PAN, TAN); date = renewable (12A 5-yr, 80G 5-yr, FCRA 5-yr)
issuing_authority: Mapped[str | None] = mapped_column(String(255), nullable=True)
jurisdiction: Mapped[str | None] = mapped_column(String(127), nullable=True)
# state for PT/GSTIN; central for FCRA/PAN/TAN
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="true")
replaces_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("tax_registrations.id"), nullable=True
)
# FCRA-specific extras (nullable; only filled when kind=='FCRA')
fcra_account_number: Mapped[str | None] = mapped_column(String(40), nullable=True)
fcra_account_bank: Mapped[str | None] = mapped_column(String(127), nullable=True)
fcra_branch_address: Mapped[str | None] = mapped_column(String(511), nullable=True)
# GSTIN-specific extras
gst_state_code: Mapped[str | None] = mapped_column(String(4), nullable=True)
gst_filing_frequency: Mapped[str | None] = mapped_column(String(16), nullable=True)
# 'monthly' | 'quarterly'
custody_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("document_custody.id"), nullable=True
)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
class Certificate(Base, TenantMixin, AuditMixin):
"""Non-tax statutory certificates: Fire Safety, FSSAI, Pollution
NoC, Environmental clearance, building plan sanction, lift safety,
lightning arrester. One certificate per row; renewals chain via
`replaces_id` like tax_registrations."""
__tablename__ = "certificates"
__table_args__ = (
Index("ix_certificates_kind", "tenant_id", "kind", "is_active"),
Index("ix_certificates_expires", "expires_on"),
)
__audit_log__ = True
__audit_two_key__ = ("delete",)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
kind: Mapped[str] = mapped_column(String(40), nullable=False)
# 'fire-safety' | 'fssai' | 'professional-tax' | 'environmental' |
# 'building-plan' | 'lift-safety' | 'lightning-arrester' | 'noc' | 'other'
title: Mapped[str] = mapped_column(String(255), nullable=False)
certificate_number: Mapped[str | None] = mapped_column(String(127), nullable=True)
issued_on: Mapped[date | None] = mapped_column(Date, nullable=True)
expires_on: Mapped[date | None] = mapped_column(Date, nullable=True)
issuing_authority: Mapped[str | None] = mapped_column(String(255), nullable=True)
branch_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("branches.id"), nullable=True, index=True
)
# certificates are often branch-specific (fire safety @ Mysuru ashrama)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="true")
replaces_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("certificates.id"), nullable=True
)
custody_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("document_custody.id"), nullable=True
)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
class DocumentCustody(Base, TenantMixin, AuditMixin):
"""Where a physical original lives, who holds it, when it was last
eyeballed. Other tables FK *into* this row — one custody record may
cover multiple linked documents (e.g. a single locker holds the
Trust Deed + 12A original + 80G original together).
Two-key on changes to `locker_id`, `custodian_user_id`,
`physical_location` because *moving* an original document is
a legally significant event."""
__tablename__ = "document_custody"
__table_args__ = (
Index("ix_custody_tenant_locker", "tenant_id", "locker_id"),
)
__audit_log__ = True
__audit_two_key__ = ("update", "delete") # restricted to certain columns
# via audit_coverage rule
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
title: Mapped[str] = mapped_column(String(255), nullable=False)
# Free-text label e.g. 'Trust Deed Original (1985)'
# Physical location — structured, not free-text
branch_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("branches.id"), nullable=True, index=True
)
building_code: Mapped[str | None] = mapped_column(String(20), nullable=True)
# references the Asset Mgmt building master codes (RSK, GK, BK, NM, ...)
room_or_office: Mapped[str | None] = mapped_column(String(127), nullable=True)
locker_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
shelf_or_drawer: Mapped[str | None] = mapped_column(String(64), nullable=True)
physical_location: Mapped[str | None] = mapped_column(String(511), nullable=True)
# free-text fallback / additional detail
custodian_user_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("users.id"), nullable=True
)
custodian_name_text: Mapped[str | None] = mapped_column(String(255), nullable=True)
# for custodians who aren't (yet) Aayojana users
# Soft-copy
scan_gcs_path: Mapped[str | None] = mapped_column(String(511), nullable=True)
scan_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
scan_uploaded_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
scan_size_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Verification cadence
last_verified_on: Mapped[date | None] = mapped_column(Date, nullable=True)
last_verified_by_user_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("users.id"), nullable=True
)
next_verification_due: Mapped[date | None] = mapped_column(Date, nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
class BankLoan(Base, TenantMixin, AuditMixin):
"""Active and closed loans the trust has taken — building loans,
vehicle loans, working-capital, equipment finance. NOT to be
confused with Vitta's payable ledger; this is the *legal record*
of the loan agreement."""
__tablename__ = "bank_loans"
__table_args__ = (
Index("ix_bank_loans_tenant_active", "tenant_id", "status"),
)
__audit_log__ = True
__audit_two_key__ = ("create", "update", "delete")
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
lender_name: Mapped[str] = mapped_column(String(255), nullable=False)
lender_branch: Mapped[str | None] = mapped_column(String(255), nullable=True)
loan_account_number: Mapped[str] = mapped_column(String(64), nullable=False)
loan_kind: Mapped[str | None] = mapped_column(String(40), nullable=True)
# 'building' | 'vehicle' | 'working-capital' | 'equipment' | 'mortgage' | 'other'
purpose: Mapped[str | None] = mapped_column(Text, nullable=True)
# Terms
principal_amount: Mapped[float | None] = mapped_column(
Numeric(18, 2), nullable=True
)
interest_rate_pct: Mapped[float | None] = mapped_column(Numeric(6, 4), nullable=True)
interest_type: Mapped[str | None] = mapped_column(String(16), nullable=True)
# 'fixed' | 'floating'
tenure_months: Mapped[int | None] = mapped_column(Integer, nullable=True)
emi_amount: Mapped[float | None] = mapped_column(Numeric(18, 2), nullable=True)
emi_due_day: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Lifecycle
sanctioned_on: Mapped[date | None] = mapped_column(Date, nullable=True)
disbursed_on: Mapped[date | None] = mapped_column(Date, nullable=True)
first_emi_on: Mapped[date | None] = mapped_column(Date, nullable=True)
closed_on: Mapped[date | None] = mapped_column(Date, nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default="active")
# 'sanctioned' | 'active' | 'restructured' | 'closed' | 'defaulted'
# Security
security_kind: Mapped[str | None] = mapped_column(String(40), nullable=True)
# 'mortgage-property' | 'hypothecation' | 'pledge' | 'unsecured' | 'guarantee'
security_description: Mapped[str | None] = mapped_column(Text, nullable=True)
secured_asset_acquisition_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("asset_acquisition_records.id"), nullable=True
)
custody_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("document_custody.id"), nullable=True
)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
class AssetAcquisitionRecord(Base, TenantMixin, AuditMixin):
"""*Legal* record of how a property/asset entered the trust — sale
deed, gift deed, dedication, court order. Distinct from the
operational asset register (Asset Management module) which tracks
*current* state. This table is the chain-of-title."""
__tablename__ = "asset_acquisition_records"
__table_args__ = (
Index("ix_acq_tenant_kind", "tenant_id", "asset_kind"),
Index("ix_acq_survey", "survey_number"),
)
__audit_log__ = True
__audit_two_key__ = ("create", "update", "delete")
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
asset_kind: Mapped[str] = mapped_column(String(40), nullable=False)
# 'land' | 'building' | 'vehicle' | 'movable' | 'mixed'
title: Mapped[str] = mapped_column(String(255), nullable=False)
# human label e.g. 'Sy. No. 234/2 Mysuru — 2.5 acres'
acquisition_mode: Mapped[str] = mapped_column(String(40), nullable=False)
# 'purchase' | 'gift' | 'dedication' | 'inheritance' | 'court-order' |
# 'lease' | 'allotment' | 'exchange'
acquired_on: Mapped[date | None] = mapped_column(Date, nullable=True)
# Counterparty
transferor_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
transferor_address: Mapped[str | None] = mapped_column(Text, nullable=True)
# Land specifics (nullable; filled when asset_kind == 'land')
survey_number: Mapped[str | None] = mapped_column(String(127), nullable=True)
extent_acres: Mapped[float | None] = mapped_column(Numeric(12, 6), nullable=True)
extent_description: Mapped[str | None] = mapped_column(String(255), nullable=True)
# 'Wet 2.10 ac + Dry 1.50 ac' style
nature_of_land: Mapped[str | None] = mapped_column(String(127), nullable=True)
village_or_locality: Mapped[str | None] = mapped_column(String(255), nullable=True)
taluk: Mapped[str | None] = mapped_column(String(127), nullable=True)
district: Mapped[str | None] = mapped_column(String(127), nullable=True)
state: Mapped[str | None] = mapped_column(String(127), nullable=True)
# Financial
consideration_amount: Mapped[float | None] = mapped_column(Numeric(18, 2), nullable=True)
stamp_duty_paid: Mapped[float | None] = mapped_column(Numeric(18, 2), nullable=True)
registration_fee_paid: Mapped[float | None] = mapped_column(Numeric(18, 2), nullable=True)
# Registration of THIS deed
registration_office: Mapped[str | None] = mapped_column(String(255), nullable=True)
deed_book_number: Mapped[str | None] = mapped_column(String(64), nullable=True)
deed_volume: Mapped[str | None] = mapped_column(String(40), nullable=True)
deed_page: Mapped[str | None] = mapped_column(String(40), nullable=True)
deed_serial_number: Mapped[str | None] = mapped_column(String(40), nullable=True)
deed_date: Mapped[date | None] = mapped_column(Date, nullable=True)
# Mutation / EC (land only)
mutation_status: Mapped[str | None] = mapped_column(String(40), nullable=True)
# 'pending' | 'in-process' | 'mutated' | 'objection' | 'na'
mutation_date: Mapped[date | None] = mapped_column(Date, nullable=True)
last_ec_on: Mapped[date | None] = mapped_column(Date, nullable=True)
next_ec_due: Mapped[date | None] = mapped_column(Date, nullable=True)
# Link to operational asset (Asset Management module — soft FK by
# string, since that module isn't built yet)
operational_asset_ref: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 30-year link-document chain (JSON list of { deed_ref, year, custody_id, notes })
link_document_chain: Mapped[list | None] = mapped_column(JSON, nullable=True)
custody_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("document_custody.id"), nullable=True
)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
3. Reuse map
| Concept | Use existing | Do NOT redefine |
|---|---|---|
| Tenant | organisations (with sampradaya, parampara) |
new tenant table |
| Branch (per-branch certs) | branches.id |
re-derive |
| Custodian / actor | users.id |
new contact table |
| Audit logging / two-key | services/audit.record_audit_event + submit_two_key_change |
per-table audit logic |
| Multi-tenancy mixin | TenantMixin from models/base.py |
new tenant_id column manually |
| Created/updated stamps | AuditMixin |
re-add |
| Document scan storage | GCS bucket already used by DISA dispatch | new file-store |
| Building codes (RSK, GK, BK, NM ...) | Asset Mgmt building master (Agent 4) — use building_code as soft FK in document_custody |
embed copies of building catalog |
| Land operational state | Asset Management lands (Agent 4) — link via operational_asset_ref |
duplicate land details |
| Vitta loan ledger | Vitta Fin loan_ledger (Agent 1) — bank_loans is the legal record only |
replicate balance + EMI ledger here |
4. API surface
All under /api/statutory/. Tenant-scoped via JWT.
| Method | Path | Purpose | Roles |
|---|---|---|---|
| GET | /api/statutory/trust-registrations |
List (only one active per tenant in normal use) | tenant-admin, module-admin |
| POST | /api/statutory/trust-registrations |
Create new (or supersede previous) | tenant-admin |
| GET | /api/statutory/trust-registrations/{id} |
Detail | tenant-admin, module-admin |
| PATCH | /api/statutory/trust-registrations/{id} |
Edit (always two-key) | tenant-admin |
| GET | /api/statutory/tax-registrations |
List (filter by kind, is_active) |
tenant-admin, module-admin |
| POST | /api/statutory/tax-registrations |
Add (12A/80G/FCRA/PAN/TAN/GSTIN) | tenant-admin |
| GET | /api/statutory/tax-registrations/{id} |
Detail | tenant-admin, module-admin |
| PATCH | /api/statutory/tax-registrations/{id} |
Edit (two-key for certificate_number and expires_on) |
tenant-admin |
| POST | /api/statutory/tax-registrations/{id}/renew |
Create successor row, mark this is_active=false |
tenant-admin |
| GET | /api/statutory/certificates |
List | tenant-admin, module-admin |
| POST | /api/statutory/certificates |
Add (fire safety, FSSAI, etc.) | tenant-admin |
| PATCH | /api/statutory/certificates/{id} |
Edit | tenant-admin |
| POST | /api/statutory/certificates/{id}/renew |
Successor row | tenant-admin |
| GET | /api/statutory/document-custody |
List custody records | tenant-admin, module-admin |
| POST | /api/statutory/document-custody |
New custody record | tenant-admin |
| PATCH | /api/statutory/document-custody/{id} |
Edit (two-key on locker_id, custodian_user_id) |
tenant-admin |
| POST | /api/statutory/document-custody/{id}/verify |
Record a periodic verification (sets last_verified_on) |
tenant-admin, module-admin |
| POST | /api/statutory/document-custody/{id}/upload-scan |
Upload PDF/image to GCS, sets scan_gcs_path and scan_sha256 |
tenant-admin |
| GET | /api/statutory/bank-loans |
List | tenant-admin, module-admin |
| POST | /api/statutory/bank-loans |
Add (two-key) | tenant-admin |
| PATCH | /api/statutory/bank-loans/{id} |
Edit (two-key) | tenant-admin |
| POST | /api/statutory/bank-loans/{id}/close |
Mark closed (two-key; needs reason) | tenant-admin |
| GET | /api/statutory/asset-acquisition-records |
List | tenant-admin, module-admin |
| POST | /api/statutory/asset-acquisition-records |
Add (two-key) | tenant-admin |
| PATCH | /api/statutory/asset-acquisition-records/{id} |
Edit (two-key) | tenant-admin |
| GET | /api/statutory/expiring |
Cross-table view: anything expiring in next N days (default 90) | tenant-admin, module-admin |
| GET | /api/statutory/dashboard |
Stats: counts, expiring soon, custody verification overdue | tenant-admin |
| GET | /statutory/ (HTML) |
Module landing | tenant-admin, module-admin |
| GET | /statutory/{kind} (HTML) |
List per kind | tenant-admin, module-admin |
| GET | /statutory/{kind}/{id} (HTML) |
Detail page with embedded Audit history sidebar | tenant-admin, module-admin |
5. Service layer
src/aayojana/services/statutory.py — thin (CRUD + custody + expiry queries).
def create_trust_registration(...) -> TrustRegistration: ...
def supersede_trust_registration(old_id: str, new_data: dict, actor_id: int,
reason: str, db: Session) -> PendingWrite:
"""Two-key: writes pending; old row deactivates only on cosign."""
def renew_tax_registration(old_id: str, new_data: dict, actor_id: int,
db: Session) -> TaxRegistration:
"""Insert successor; mark old is_active=false. Audit-logged."""
def renew_certificate(old_id: str, new_data: dict, actor_id: int,
db: Session) -> Certificate: ...
def transfer_custody(custody_id: str, new_custodian_id: int,
new_location: dict, reason: str, actor_id: int,
db: Session) -> PendingWrite:
"""Always two-key. `new_location` carries locker_id, shelf, building."""
def record_custody_verification(custody_id: str, verifier_user_id: int,
cadence_months: int = 12, db: Session) -> DocumentCustody:
"""Sets last_verified_on=today, next_verification_due=+N months."""
def upload_scan(custody_id: str, file_bytes: bytes, filename: str,
actor_id: int, db: Session) -> str:
"""Streams to GCS, computes SHA256, updates custody row. Audit-logs upload."""
def list_expiring(tenant_id: str, within_days: int, db: Session) -> dict:
"""{ tax_registrations: [...], certificates: [...] } sorted by expires_on."""
def list_custody_overdue(tenant_id: str, db: Session) -> list[DocumentCustody]:
"""rows where next_verification_due < today."""
def open_loan(data: dict, actor_id: int, reason: str, db: Session) -> PendingWrite: ...
def close_loan(loan_id: str, closed_on: date, actor_id: int, reason: str,
db: Session) -> PendingWrite: ...
def add_acquisition_record(data: dict, actor_id: int, reason: str,
db: Session) -> PendingWrite: ...
def append_link_document(acq_id: str, link: dict, actor_id: int,
db: Session) -> AssetAcquisitionRecord:
"""Appends to link_document_chain JSON list. Audit-logged but not two-key."""
6. UI / Templates
Templates under src/aayojana/templates/statutory/. Inherits Aayojana admin shell.
| Page | Route | Highlights |
|---|---|---|
| Module landing | /statutory/ |
Tile grid: Trust Registration · Tax Registrations · Certificates · Document Custody · Bank Loans · Asset Acquisitions · Expiring Soon |
| Trust profile | /statutory/trust-registrations |
Single-row card view (active deed); supersession history below |
| Tax registrations list | /statutory/tax-registrations |
Grouped by kind (12A, 80G, FCRA, PAN, TAN, GSTIN); each shows current cert + renewal countdown |
| Tax registration detail | /statutory/tax-registrations/{id} |
All fields + custody pointer + scan preview + audit history sidebar |
| Certificates list | /statutory/certificates |
Filterable by kind & branch; expiring-soon badge in red |
| Certificate detail | /statutory/certificates/{id} |
Detail + renew button |
| Document custody | /statutory/document-custody |
Table: title, location (building/room/locker/shelf), custodian, last verified, next due, scan attached y/n |
| Custody detail | /statutory/document-custody/{id} |
Full record + scan upload + verify-now button + transfer-custody form (two-key) |
| Bank loans list | /statutory/bank-loans |
Active loans first; closed below in collapsible section |
| Loan detail | /statutory/bank-loans/{id} |
Terms + EMI calendar + linked Vitta loan ledger snapshot + audit history |
| Asset acquisitions | /statutory/asset-acquisition-records |
Table with search by survey number, deed number, asset kind |
| Acquisition detail | /statutory/asset-acquisition-records/{id} |
Full deed metadata + 30-year link-doc timeline + EC schedule + audit history |
| Expiring dashboard | /statutory/expiring |
Combined view: every renewable item due in next 90 days, sorted by date |
Edit forms
Every legal-weight edit form has a mandatory Reason textarea. On submit:
service layer detects coverage rule, routes to submit_two_key_change,
and the user gets a "Submitted for cosign" confirmation with link to
the pending-queue.
7. Migration plan
Order:
0013 — Audit Trail tables (must precede)
0014 — Statutory Data tables <-- THIS MODULE
trust_registrations, tax_registrations, certificates,
document_custody, bank_loans, asset_acquisition_records
0015 — Compliance tables
Style follows 0006_payments.py:
- Idempotent (if not insp.has_table(...), if not _has_column(...)).
- Additive only, offline-mode safe.
- All FKs use existing tables (organisations, branches, users).
- Seed audit_coverage rows for these tables (also done in 0013 alongside global seeds).
document_custody is created BEFORE the other statutory tables in the same revision
because they FK into it.
8. Cross-module dependencies
Reads from
organisations.sampradaya,organisations.parampara— defaults fortrust_registrationsbranches— per-branch certificate filteringusers— forcustodian_user_id,last_verified_by_user_id
Writes to
- Audit Trail (mandatory):
record_audit_eventon every row change (via bridge auto-log because every model carries__audit_log__ = True).submit_two_key_changeon creates/updates ofbank_loans,asset_acquisition_records, and onupdate/deleteoftrust_registrations,tax_registrations,document_custody,certificates.- PII-view audit when scan blobs containing Aadhaar (encountered in some sale deeds) are downloaded.
Read by
- Compliance module —
tax_registrations.expires_onandcertificates.expires_onfeed the renewal calendar;compliance_filingsmay FK back viatax_registration_idto track which 12A/80G filing belongs to which row. - Vitta Fin — bank-loan EMI schedule cross-checks against
bank_loans.emi_amount,emi_due_day. FCRA bank account number fromtax_registrations(kind='FCRA') anchors FCRA bank-account exclusivity in Vitta. - Asset Management —
asset_acquisition_records.operational_asset_ref↔lands/buildings/vehicles/equipment. Soft-FK (string) initially, hardened in a later migration. - Reports & Exports — Statutory export bundle for trustee pack and auditor handoff.
Communications hooks
- T-30 / T-7 / T-1 reminder dispatch from Compliance reads
expires_onontax_registrationsandcertificates. Statutory module exposeslist_expiring(tenant_id, within_days)which Compliance wraps.
9. Implementation phases
Phase A — Schemas + basic CRUD (Sprint 1, ~1.5 weeks).
- Migration 0014 with all 6 tables.
- Models in aayojana/models/statutory.py.
- Service layer (CRUD-only first).
- Read routes (list + detail) for all 6 tables.
- HTML pages: module landing, list pages (read-only).
- Audit bridge auto-log enabled (Phase A of Audit must be in place).
Phase B — Two-key flow + scan upload (Sprint 2, ~1.5 weeks).
- POST/PATCH routes with reason field.
- Routing through submit_two_key_change for covered ops.
- Scan upload to GCS (custody.scan_gcs_path).
- Renew flow for tax registrations and certificates.
- Custody transfer flow (two-key).
- HTML edit forms with reason textarea.
- Pending-queue link in confirmation flow.
Phase C — Dashboards + bulk operations (Sprint 3, ~1 week). - Expiring dashboard. - Custody-verification-overdue dashboard. - 30-year link-document timeline UI. - Bulk import of existing certificates (CSV) for first tenant onboarding. - Statutory export bundle for trustee pack.
10. Open questions
- Document custody location format. Structured (branch + building_code + room +
locker_id + shelf_or_drawer) plus free-text fallback (
physical_location). Confirmed structured wins, but shouldbuilding_codevalidate against Asset Mgmt's building master? Recommend: soft-validate — log warning if unknown code, but don't block (Statutory may onboard before Asset Mgmt is built). - Trust deed supersession. A correction in the deed (e.g. 2010 amendment)
is a new row with
superseded_by_idlinking back. Should the old row stay editable? Recommend NO — historical rows are read-only; corrections create another supersession. - FCRA fields on
tax_registrations. FCRA-specific extras (account number, bank, branch) live on the same row as kind='FCRA'. Alternative: a separatefcra_detailstable. Recommend: keep on same row — FCRA always has exactly one set of these, low cardinality. - GSTIN per branch. A multi-state trust may have multiple GSTINs (one per
state of registration). Schema supports it (
gst_state_code+ tenant-scoped uniqueness already covers it), but UI grouping needs care. Recommend: tab per state in the GSTIN section. - Land — survey-number normalisation. Survey numbers are state-specific
(Karnataka uses
123/2A, Tamil Nadu usesS.F. No. 234/2). Free-text for now; structured-by-state later if a state-specific report needs it. - Link-document chain depth. 30-year chain requirement implies up to ~10–15
prior deeds per land record. JSON list is fine for that scale; if a tenant
has hundreds of historical link docs they go into individual
asset_acquisition_recordsrows withsuperseded_bystyle chaining. - Scan storage size limits. GCS path is unlimited but uploads via the web form should be capped. Recommend: 25 MB hard cap per scan; multi-page scans must be merged into one PDF before upload.
- PII in scans. Sale deeds and Aadhaar-bearing certs contain PII.
Storing in GCS is fine if bucket is encrypted at rest (default on GCP)
and download requires JWT-authenticated tenant-admin role. Recommend:
add
is_pii_bearing: boolflag ondocument_custodyrows so a stricter read policy (audit-event on every download) can target them specifically. - Branch-only admin scope. A branch-admin in Mysuru should NOT be able
to view the central trust deed. Enforced at service layer: rows with
branch_id IS NULLare tenant-admin only; branch-scoped certificates (branch_id IS NOT NULL) are visible to that branch's admins. - Trust-deed plural. Some institutions are governed by multiple deeds
(founding deed + supplemental deeds). Schema permits multiple
is_active=truerows per tenant ifsuperseded_by_idis NULL. Add arelationship_to_main_deed: enum('main','supplemental','amendment')column? Recommend: defer until first tenant actually has this.