1
📜

Statutory Data — Blueprint

Registration, certificates, document custody, bank loans, asset acquisition

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

Writes to

Read by

Communications hooks

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

  1. Document custody location format. Structured (branch + building_code + room + locker_id + shelf_or_drawer) plus free-text fallback (physical_location). Confirmed structured wins, but should building_code validate 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).
  2. Trust deed supersession. A correction in the deed (e.g. 2010 amendment) is a new row with superseded_by_id linking back. Should the old row stay editable? Recommend NO — historical rows are read-only; corrections create another supersession.
  3. FCRA fields on tax_registrations. FCRA-specific extras (account number, bank, branch) live on the same row as kind='FCRA'. Alternative: a separate fcra_details table. Recommend: keep on same row — FCRA always has exactly one set of these, low cardinality.
  4. 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.
  5. Land — survey-number normalisation. Survey numbers are state-specific (Karnataka uses 123/2A, Tamil Nadu uses S.F. No. 234/2). Free-text for now; structured-by-state later if a state-specific report needs it.
  6. 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_records rows with superseded_by style chaining.
  7. 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.
  8. 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: bool flag on document_custody rows so a stricter read policy (audit-event on every download) can target them specifically.
  9. 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 NULL are tenant-admin only; branch-scoped certificates (branch_id IS NOT NULL) are visible to that branch's admins.
  10. Trust-deed plural. Some institutions are governed by multiple deeds (founding deed + supplemental deeds). Schema permits multiple is_active=true rows per tenant if superseded_by_id is NULL. Add a relationship_to_main_deed: enum('main','supplemental','amendment') column? Recommend: defer until first tenant actually has this.