13
📒

Vitta Fin — Accounting — Blueprint

Fund-segregated books · 80G · FCRA · TDS · audit-ready

Vitta Fin — Implementation Blueprint

Module slug: vitta Kind: Accounting Owner: Architect Agent 1 (Pancha) Date drafted: 2026-05-02 Status: stub → designed (this document)


1. Module summary

Vitta Fin is the financial source-of-truth of every Aayojana tenant. It is a fund-segregated, double-entry, multi-tenant accounting engine purpose-built for Dharmika institutions whose money flows do not fit generic SaaS accounting tools — daily hundi counting with two-witness signoff, FCRA isolation enforced at the service layer (not just by policy), 80G donor certificates generated per-receipt and annually, sambhavana-as-a-payroll-category distinct from salary, and a samagri-vendor PO → bill → TDS-aware payment cycle.

Vitta does not duplicate transactional ledgers that already live elsewhere; it consumes them. DISA's transactions table remains the donation ledger of record — Vitta reads it, posts double-entry consequences against the right fund, and emits 80G certificates downstream. Subscription charges from Razorpay/Stripe arrive via payment_events and become journal entries automatically. Operational modules (Inventory, Maintenance, Events) raise vouchers; Vitta turns them into ledger postings.

Every money-touching action in any Aayojana module must terminate in a Vitta journal entry tagged with a fund. The Chart of Accounts roots in a small set of statutory funds (General · Annadana · Building · FCRA · Endowment) plus tenant-defined custom funds; fund segregation is sacred: a single posting cannot mix funds, and no automated process may move money across funds without an explicit interbranch_transfer or inter_fund_transfer voucher with two-key approval. Period locks (year/month) enforce close-the-books discipline; once locked, only a two-key override (using Audit module machinery designed by Agent 2) re-opens them.

Statutory output files (80G certificates, FC-4 schedules, GSTR-1 / GSTR-3B exports, TDS 24Q / 26Q files, Form 10B audit pack) are generated as upload-ready files. Direct portal e-filing is out of scope — Vitta produces the file; a human pushes it to the GSTN / TRACES / MHA / income-tax portal.


2. Core data model

All new tables inherit Base + AuditMixin + TenantMixin from src/aayojana/models/base.py. Sanskrit-bearing columns use Text. New module package: src/aayojana/vitta/models/. Enumerations are String columns at the schema level (a Python enum.StrEnum is used at the service boundary for type safety).

# src/aayojana/vitta/models/__init__.py
from aayojana.vitta.models.fund import Fund
from aayojana.vitta.models.account import Account
from aayojana.vitta.models.journal import JournalEntry, JournalLine
from aayojana.vitta.models.voucher import Voucher
from aayojana.vitta.models.hundi import HundiCollection, HundiDenomination
from aayojana.vitta.models.eighty_g import EightyGReceipt, EightyGAnnualSummary
from aayojana.vitta.models.fcra import FCRADonation, FCRADonorSource
from aayojana.vitta.models.payable import VendorPayable, VendorBill, VendorPayment
from aayojana.vitta.models.advance import StaffAdvance, StaffAdvanceSettlement, ImprestBill
from aayojana.vitta.models.payroll import PayrollRun, PayrollLine
from aayojana.vitta.models.bank_recon import BankReconImport, BankReconMatch
from aayojana.vitta.models.transfer import InterBranchTransfer
from aayojana.vitta.models.period_lock import PeriodLock
from aayojana.vitta.models.tds import TDSEntry, TDSReturn
from aayojana.vitta.models.gst import GSTInvoice, GSTReturn

2.1 Funds (Chart-of-Accounts root)

# src/aayojana/vitta/models/fund.py
from sqlalchemy import Boolean, ForeignKey, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column

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


class Fund(Base, AuditMixin, TenantMixin):
    """A legally-segregated pool of money. The five statutory funds
    (General, Annadana, Building, FCRA, Endowment) are seeded per-tenant
    when Vitta is provisioned; tenant admins may add custom funds
    (e.g. Goshala, Vidyalaya, Rakshana). FCRA is special: any account
    or journal line tagged to it cannot be commingled.
    """

    __tablename__ = "vitta_funds"
    __table_args__ = (
        UniqueConstraint("tenant_id", "code", name="uq_fund_per_tenant_code"),
    )

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
    code: Mapped[str] = mapped_column(String(20), nullable=False)
    # 'GEN' | 'ANNA' | 'BLDG' | 'FCRA' | 'ENDO' | tenant-defined
    name: Mapped[str] = mapped_column(Text, nullable=False)
    description: Mapped[str | None] = mapped_column(Text, nullable=True)
    fund_kind: Mapped[str] = mapped_column(String(20), nullable=False)
    # 'unrestricted' | 'restricted' | 'endowment' | 'fcra'
    is_fcra: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
    is_system: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
    # System funds (the seeded five) cannot be deleted
    is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)

2.2 Accounts (Chart-of-Accounts leaves)

# src/aayojana/vitta/models/account.py
class Account(Base, AuditMixin, TenantMixin):
    """A ledger head/sub-head, fund-tagged. Schedule III–style root types:
    asset, liability, income, expense, equity. Sub-heads can nest via
    parent_id to any depth (most COAs use 2-3 levels).
    """

    __tablename__ = "vitta_accounts"
    __table_args__ = (
        UniqueConstraint("tenant_id", "code", name="uq_account_per_tenant_code"),
        Index("ix_account_fund", "fund_id"),
        Index("ix_account_parent", "parent_id"),
    )

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
    code: Mapped[str] = mapped_column(String(32), nullable=False)
    # e.g. '1100' (Cash in Hand), '1100.01' (Hundi Cash), '4100' (Donation Income — General)
    name: Mapped[str] = mapped_column(Text, nullable=False)
    account_type: Mapped[str] = mapped_column(String(16), nullable=False)
    # 'asset' | 'liability' | 'income' | 'expense' | 'equity'
    fund_id: Mapped[str] = mapped_column(
        String(36), ForeignKey("vitta_funds.id"), nullable=False, index=True
    )
    parent_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("vitta_accounts.id"), nullable=True
    )
    branch_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("branches.id"), nullable=True, index=True
    )
    # Optional — if branch_id set, the account is branch-scoped (e.g. 'Cash — Mysuru')
    is_bank: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
    bank_account_number: Mapped[str | None] = mapped_column(String(32), nullable=True)
    bank_ifsc: Mapped[str | None] = mapped_column(String(16), nullable=True)
    is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
    notes: Mapped[str | None] = mapped_column(Text, nullable=True)

2.3 Journal entries + lines

# src/aayojana/vitta/models/journal.py
from datetime import date, datetime
from decimal import Decimal

from sqlalchemy import (
    CheckConstraint, Date, DateTime, ForeignKey, Index, Integer, Numeric,
    String, Text, UniqueConstraint, func,
)


class JournalEntry(Base, AuditMixin, TenantMixin):
    """Header for a balanced double-entry posting. Once `posted_at` is set
    the entry is immutable — corrections happen by posting a reversing
    entry (linked via `reverses_id`).
    """

    __tablename__ = "vitta_journal_entries"
    __table_args__ = (
        UniqueConstraint("tenant_id", "entry_number", name="uq_je_per_tenant"),
        Index("ix_je_posting_date", "posting_date"),
        Index("ix_je_voucher", "voucher_id"),
    )

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
    entry_number: Mapped[str] = mapped_column(String(32), nullable=False)
    # Format: <BranchCode>-JE-<FY>-<sequence>, e.g. MYS-JE-2526-001234
    posting_date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
    fy_label: Mapped[str] = mapped_column(String(7), nullable=False)
    # e.g. '2025-26' (Indian FY) — derived from posting_date but denormalised for fast filter
    branch_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("branches.id"), nullable=True, index=True
    )
    fund_id: Mapped[str] = mapped_column(
        String(36), ForeignKey("vitta_funds.id"), nullable=False, index=True
    )
    # Header-level fund tag — every line MUST belong to the same fund
    voucher_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("vitta_vouchers.id"), nullable=True
    )
    narration: Mapped[str] = mapped_column(Text, nullable=False)
    posted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
    posted_by_user_id: Mapped[int | None] = mapped_column(
        Integer, ForeignKey("users.id"), nullable=True
    )
    reverses_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("vitta_journal_entries.id"), nullable=True
    )
    # If non-null, this entry is a reversal of another. Reversals copy lines
    # with debits/credits swapped.
    locked_by_period: Mapped[bool] = mapped_column(
        Boolean, default=False, nullable=False
    )
    # Set true at period-close so the entry cannot even be soft-edited.


class JournalLine(Base, AuditMixin):
    """Debit or credit row. Sum of debits = sum of credits per JE
    (enforced via CHECK + service-layer assertion before posting).
    """

    __tablename__ = "vitta_journal_lines"
    __table_args__ = (
        CheckConstraint(
            "(debit >= 0) AND (credit >= 0) AND ((debit = 0) OR (credit = 0))",
            name="ck_jl_debit_xor_credit",
        ),
        Index("ix_jl_account", "account_id"),
        Index("ix_jl_je", "entry_id"),
    )

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
    entry_id: Mapped[str] = mapped_column(
        String(36), ForeignKey("vitta_journal_entries.id", ondelete="CASCADE"),
        nullable=False,
    )
    line_no: Mapped[int] = mapped_column(Integer, nullable=False)
    account_id: Mapped[str] = mapped_column(
        String(36), ForeignKey("vitta_accounts.id"), nullable=False
    )
    debit: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False, default=0)
    credit: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False, default=0)
    line_narration: Mapped[str | None] = mapped_column(Text, nullable=True)
    # Per-line ref — for split donations, vendor bill line items, etc.
    member_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("members.id"), nullable=True
    )
    # Pin the line to a member (donor) when applicable — helps 80G aggregation
    cost_centre: Mapped[str | None] = mapped_column(String(32), nullable=True)
    # Optional — event ID, samagri kit code, etc. (free-form for reporting)

2.4 Vouchers (source-document linkage)

# src/aayojana/vitta/models/voucher.py
class Voucher(Base, AuditMixin, TenantMixin):
    """Bridges a non-accounting source document (a donation transaction,
    a vendor bill, a hundi tally sheet, a payment_events row) to the
    journal entry it produced. One voucher → one or two JEs (the second
    being a reversal if needed).
    """

    __tablename__ = "vitta_vouchers"
    __table_args__ = (
        UniqueConstraint("tenant_id", "voucher_number", name="uq_voucher_per_tenant"),
        Index("ix_voucher_source", "source_type", "source_id"),
    )

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
    voucher_number: Mapped[str] = mapped_column(String(32), nullable=False)
    voucher_type: Mapped[str] = mapped_column(String(20), nullable=False)
    # 'receipt' | 'payment' | 'journal' | 'contra' | 'sales' | 'purchase'
    source_type: Mapped[str] = mapped_column(String(40), nullable=False)
    # 'transactions' | 'payment_events' | 'hundi_collection' | 'vendor_bill'
    # | 'imprest_bill' | 'payroll_run' | 'manual'
    source_id: Mapped[str] = mapped_column(String(64), nullable=False)
    # PK of the source row (string-coerced). Composite with source_type
    # makes a unique pointer.
    voucher_date: Mapped[date] = mapped_column(Date, nullable=False)
    description: Mapped[str | None] = mapped_column(Text, nullable=True)
    attachment_uri: Mapped[str | None] = mapped_column(String(512), nullable=True)
    # GCS URI for scanned bill / receipt / hundi tally sheet

2.5 Hundi collections

# src/aayojana/vitta/models/hundi.py
class HundiCollection(Base, AuditMixin, TenantMixin):
    """A daily hundi count. Two witnesses required before deposit.
    The denomination breakdown lives in HundiDenomination.
    """

    __tablename__ = "vitta_hundi_collections"
    __table_args__ = (
        UniqueConstraint(
            "tenant_id", "branch_id", "collection_date",
            name="uq_hundi_per_branch_per_day",
        ),
    )

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
    branch_id: Mapped[str] = mapped_column(
        String(36), ForeignKey("branches.id"), nullable=False
    )
    collection_date: Mapped[date] = mapped_column(Date, nullable=False)
    fund_id: Mapped[str] = mapped_column(
        String(36), ForeignKey("vitta_funds.id"), nullable=False
    )
    total_cash: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False)
    total_cheques: Mapped[Decimal] = mapped_column(
        Numeric(18, 2), nullable=False, default=0
    )
    total_other: Mapped[Decimal] = mapped_column(
        Numeric(18, 2), nullable=False, default=0
    )
    # Foreign currency, jewelry-as-offering valuation, etc.
    witness_1_user_id: Mapped[int] = mapped_column(
        Integer, ForeignKey("users.id"), nullable=False
    )
    witness_2_user_id: Mapped[int] = mapped_column(
        Integer, ForeignKey("users.id"), nullable=False
    )
    counted_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
    deposited_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
    deposited_to_account_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("vitta_accounts.id"), nullable=True
    )
    deposit_slip_number: Mapped[str | None] = mapped_column(String(32), nullable=True)
    deposit_slip_uri: Mapped[str | None] = mapped_column(String(512), nullable=True)
    voucher_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("vitta_vouchers.id"), nullable=True
    )
    notes: Mapped[str | None] = mapped_column(Text, nullable=True)


class HundiDenomination(Base):
    """Cash breakdown by denomination for one HundiCollection.
    Sum of (denomination * count) MUST equal HundiCollection.total_cash.
    """

    __tablename__ = "vitta_hundi_denominations"
    __table_args__ = (
        UniqueConstraint(
            "collection_id", "denomination",
            name="uq_denom_per_collection",
        ),
    )

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    collection_id: Mapped[str] = mapped_column(
        String(36),
        ForeignKey("vitta_hundi_collections.id", ondelete="CASCADE"),
        nullable=False,
    )
    denomination: Mapped[int] = mapped_column(Integer, nullable=False)
    # 1, 2, 5, 10, 20, 50, 100, 200, 500, 2000
    count: Mapped[int] = mapped_column(Integer, nullable=False)

2.6 80G receipts

We reuse the existing transactions table for raw donation amounts. The 80G receipt is its own row, generated on demand, and is what gets dispatched (postal / email).

# src/aayojana/vitta/models/eighty_g.py
class EightyGReceipt(Base, AuditMixin, TenantMixin):
    """Per-donation 80G certificate. Issued only against fully-realised
    domestic donations on accounts/funds whose `is_fcra=False`.
    """

    __tablename__ = "vitta_eighty_g_receipts"
    __table_args__ = (
        UniqueConstraint(
            "tenant_id", "receipt_number",
            name="uq_80g_per_tenant",
        ),
        Index("ix_80g_member", "member_id"),
        Index("ix_80g_fy", "fy_label"),
    )

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
    receipt_number: Mapped[str] = mapped_column(String(32), nullable=False)
    # Format: <TenantCode>-<FY>-<seq>  e.g. SGSDM-2526-000123
    transaction_id: Mapped[int] = mapped_column(
        Integer, ForeignKey("transactions.id"), nullable=False, index=True
    )
    member_id: Mapped[str] = mapped_column(
        String(36), ForeignKey("members.id"), nullable=False
    )
    issued_date: Mapped[date] = mapped_column(Date, nullable=False)
    fy_label: Mapped[str] = mapped_column(String(7), nullable=False)
    amount: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False)
    deduction_pct: Mapped[int] = mapped_column(Integer, nullable=False, default=50)
    # 50 (general) or 100 (qualifying funds — PM Relief etc.)
    pan_number: Mapped[str | None] = mapped_column(String(10), nullable=True)
    # Mandatory above ₹2,000
    template_version: Mapped[str] = mapped_column(String(8), nullable=False)
    # 'v1.2526' — versioned per FY because regulator updates layout
    pdf_uri: Mapped[str | None] = mapped_column(String(512), nullable=True)
    # GCS URI of generated PDF
    dispatched_email_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
    dispatched_postal_id: Mapped[int | None] = mapped_column(
        Integer, ForeignKey("dispatch_records.id"), nullable=True
    )
    voided: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
    void_reason: Mapped[str | None] = mapped_column(Text, nullable=True)


class EightyGAnnualSummary(Base, AuditMixin, TenantMixin):
    """Roll-up of all 80G receipts for one (member, FY).
    Generated at FY close for donor's IT filing.
    """

    __tablename__ = "vitta_eighty_g_annual"
    __table_args__ = (
        UniqueConstraint(
            "tenant_id", "member_id", "fy_label",
            name="uq_80g_annual_per_member_fy",
        ),
    )

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
    member_id: Mapped[str] = mapped_column(
        String(36), ForeignKey("members.id"), nullable=False
    )
    fy_label: Mapped[str] = mapped_column(String(7), nullable=False)
    total_amount: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False)
    receipt_count: Mapped[int] = mapped_column(Integer, nullable=False)
    pdf_uri: Mapped[str | None] = mapped_column(String(512), nullable=True)

2.7 FCRA donations

# src/aayojana/vitta/models/fcra.py
class FCRADonation(Base, AuditMixin, TenantMixin):
    """A foreign-source donation. Lives separately from `transactions`
    because FC-4 reporting needs source country, currency, FX rate,
    and donor identity proofs that domestic donations don't carry.
    The FCRA bank account into which it is received is referenced via
    `account_id` (which must point to an Account whose fund.is_fcra=True).
    """

    __tablename__ = "vitta_fcra_donations"
    __table_args__ = (
        Index("ix_fcra_fy", "fy_label"),
        Index("ix_fcra_donor", "donor_source_id"),
    )

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
    receipt_number: Mapped[str] = mapped_column(String(32), nullable=False, unique=True)
    received_date: Mapped[date] = mapped_column(Date, nullable=False)
    fy_label: Mapped[str] = mapped_column(String(7), nullable=False)
    donor_source_id: Mapped[str] = mapped_column(
        String(36), ForeignKey("vitta_fcra_donor_sources.id"), nullable=False
    )
    foreign_amount: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False)
    foreign_currency: Mapped[str] = mapped_column(String(3), nullable=False)
    fx_rate: Mapped[Decimal] = mapped_column(Numeric(12, 6), nullable=False)
    inr_amount: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False)
    purpose: Mapped[str] = mapped_column(Text, nullable=False)
    # FCRA mandates purpose at receipt — 'Religious activities' / 'Education' etc.
    bank_charges: Mapped[Decimal] = mapped_column(
        Numeric(18, 2), nullable=False, default=0
    )
    account_id: Mapped[str] = mapped_column(
        String(36), ForeignKey("vitta_accounts.id"), nullable=False
    )
    voucher_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("vitta_vouchers.id"), nullable=True
    )
    swift_ref: Mapped[str | None] = mapped_column(String(64), nullable=True)


class FCRADonorSource(Base, AuditMixin, TenantMixin):
    """Foreign donor identity — the registry FC-4 cites.
    Per donor we capture: name, country, donor type (individual /
    institution), passport-or-FCRA-permitted-id.
    """

    __tablename__ = "vitta_fcra_donor_sources"

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
    name: Mapped[str] = mapped_column(Text, nullable=False)
    donor_type: Mapped[str] = mapped_column(String(20), nullable=False)
    # 'individual' | 'institution' | 'foundation' | 'corporate'
    country: Mapped[str] = mapped_column(String(63), nullable=False)
    address: Mapped[str | None] = mapped_column(Text, nullable=True)
    identifier: Mapped[str | None] = mapped_column(String(127), nullable=True)
    # passport / institution-id / website
    member_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("members.id"), nullable=True
    )
    # If donor is also a Member (rare for FCRA but possible)

2.8 Vendor payables

# src/aayojana/vitta/models/payable.py
class VendorPayable(Base, AuditMixin, TenantMixin):
    """Master record per vendor — samagri suppliers, contractors,
    external archakas, AMC providers. PO lifecycle is on VendorBill.
    """

    __tablename__ = "vitta_vendor_payables"
    __table_args__ = (
        UniqueConstraint(
            "tenant_id", "vendor_code",
            name="uq_vendor_per_tenant",
        ),
    )

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
    vendor_code: Mapped[str] = mapped_column(String(20), nullable=False)
    name: Mapped[str] = mapped_column(Text, nullable=False)
    pan: Mapped[str | None] = mapped_column(String(10), nullable=True)
    gstin: Mapped[str | None] = mapped_column(String(15), nullable=True)
    tds_section: Mapped[str | None] = mapped_column(String(8), nullable=True)
    # default '194C', '194J', '194I' — overridable per bill
    bank_account_number: Mapped[str | None] = mapped_column(String(32), nullable=True)
    bank_ifsc: Mapped[str | None] = mapped_column(String(16), nullable=True)
    contact_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
    contact_phone: Mapped[str | None] = mapped_column(String(48), nullable=True)
    address: Mapped[str | None] = mapped_column(Text, nullable=True)
    is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
    notes: Mapped[str | None] = mapped_column(Text, nullable=True)


class VendorBill(Base, AuditMixin, TenantMixin):
    """A bill received from a vendor. Created either from a PO or
    free-form. Goes through stages: 'received' → 'verified' →
    'approved' → 'paid' (or 'rejected'). TDS is computed at approval.
    """

    __tablename__ = "vitta_vendor_bills"
    __table_args__ = (
        UniqueConstraint(
            "tenant_id", "vendor_id", "vendor_invoice_number",
            name="uq_bill_per_vendor_invoice",
        ),
        Index("ix_bill_status", "status"),
    )

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
    vendor_id: Mapped[str] = mapped_column(
        String(36), ForeignKey("vitta_vendor_payables.id"), nullable=False
    )
    bill_number: Mapped[str] = mapped_column(String(32), nullable=False)
    # internal Aayojana sequence
    vendor_invoice_number: Mapped[str] = mapped_column(String(48), nullable=False)
    vendor_invoice_date: Mapped[date] = mapped_column(Date, nullable=False)
    received_date: Mapped[date] = mapped_column(Date, nullable=False)
    fund_id: Mapped[str] = mapped_column(
        String(36), ForeignKey("vitta_funds.id"), nullable=False
    )
    branch_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("branches.id"), nullable=True
    )
    expense_account_id: Mapped[str] = mapped_column(
        String(36), ForeignKey("vitta_accounts.id"), nullable=False
    )
    purchase_order_ref: Mapped[str | None] = mapped_column(String(48), nullable=True)
    # Soft FK to Inventory module's PO when issued via PO flow
    subtotal: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False)
    gst_amount: Mapped[Decimal] = mapped_column(Numeric(18, 2), default=0, nullable=False)
    other_charges: Mapped[Decimal] = mapped_column(Numeric(18, 2), default=0, nullable=False)
    total_amount: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False)
    tds_section: Mapped[str | None] = mapped_column(String(8), nullable=True)
    tds_rate: Mapped[Decimal | None] = mapped_column(Numeric(5, 2), nullable=True)
    tds_amount: Mapped[Decimal] = mapped_column(Numeric(18, 2), default=0, nullable=False)
    net_payable: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False)
    status: Mapped[str] = mapped_column(String(16), nullable=False, default="received")
    # 'received' | 'verified' | 'approved' | 'paid' | 'partly_paid' | 'rejected'
    approved_by_user_id: Mapped[int | None] = mapped_column(
        Integer, ForeignKey("users.id"), nullable=True
    )
    approved_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
    voucher_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("vitta_vouchers.id"), nullable=True
    )
    bill_uri: Mapped[str | None] = mapped_column(String(512), nullable=True)


class VendorPayment(Base, AuditMixin, TenantMixin):
    """A payment against one or more VendorBills. Splits possible
    via VendorPaymentAllocation if the cheque clears multiple bills.
    """

    __tablename__ = "vitta_vendor_payments"

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
    payment_number: Mapped[str] = mapped_column(String(32), nullable=False)
    bill_id: Mapped[str] = mapped_column(
        String(36), ForeignKey("vitta_vendor_bills.id"), nullable=False
    )
    payment_date: Mapped[date] = mapped_column(Date, nullable=False)
    amount: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False)
    mode: Mapped[str] = mapped_column(String(20), nullable=False)
    # 'neft' | 'rtgs' | 'imps' | 'upi' | 'cheque' | 'cash'
    bank_account_id: Mapped[str] = mapped_column(
        String(36), ForeignKey("vitta_accounts.id"), nullable=False
    )
    utr_number: Mapped[str | None] = mapped_column(String(32), nullable=True)
    cheque_number: Mapped[str | None] = mapped_column(String(16), nullable=True)
    voucher_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("vitta_vouchers.id"), nullable=True
    )

2.9 Staff advances (Imprest)

# src/aayojana/vitta/models/advance.py
class StaffAdvance(Base, AuditMixin, TenantMixin):
    """An imprest issued to a staff member / sevadar for samagri,
    travel, or sundry expense. Closed by either bills+balance-return
    or salary-deduction settlement.
    """

    __tablename__ = "vitta_staff_advances"
    __table_args__ = (
        Index("ix_advance_member", "member_id"),
        Index("ix_advance_status", "status"),
    )

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
    advance_number: Mapped[str] = mapped_column(String(32), nullable=False)
    member_id: Mapped[str] = mapped_column(
        String(36), ForeignKey("members.id"), nullable=False
    )
    issue_date: Mapped[date] = mapped_column(Date, nullable=False)
    amount: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False)
    fund_id: Mapped[str] = mapped_column(
        String(36), ForeignKey("vitta_funds.id"), nullable=False
    )
    purpose: Mapped[str] = mapped_column(Text, nullable=False)
    expected_settlement_by: Mapped[date | None] = mapped_column(Date, nullable=True)
    settled_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
    status: Mapped[str] = mapped_column(String(16), nullable=False, default="open")
    # 'open' | 'partly_settled' | 'settled' | 'written_off'
    bills_total: Mapped[Decimal] = mapped_column(Numeric(18, 2), default=0, nullable=False)
    refund_total: Mapped[Decimal] = mapped_column(Numeric(18, 2), default=0, nullable=False)
    voucher_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("vitta_vouchers.id"), nullable=True
    )


class ImprestBill(Base, AuditMixin, TenantMixin):
    """A receipt/bill submitted by a staff member against an open
    advance. Treasurer approves; on approval it credits StaffAdvance.bills_total.
    """

    __tablename__ = "vitta_imprest_bills"

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
    advance_id: Mapped[str] = mapped_column(
        String(36), ForeignKey("vitta_staff_advances.id"), nullable=False
    )
    bill_date: Mapped[date] = mapped_column(Date, nullable=False)
    description: Mapped[str] = mapped_column(Text, nullable=False)
    amount: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False)
    expense_account_id: Mapped[str] = mapped_column(
        String(36), ForeignKey("vitta_accounts.id"), nullable=False
    )
    bill_uri: Mapped[str | None] = mapped_column(String(512), nullable=True)
    status: Mapped[str] = mapped_column(String(16), nullable=False, default="submitted")
    # 'submitted' | 'approved' | 'rejected' | 'reimbursed'
    approved_by_user_id: Mapped[int | None] = mapped_column(
        Integer, ForeignKey("users.id"), nullable=True
    )
    approved_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
    rejection_reason: Mapped[str | None] = mapped_column(Text, nullable=True)


class StaffAdvanceSettlement(Base, AuditMixin, TenantMixin):
    """Final settlement event — closes the advance.
    Computed = bills_total + refund_total = advance.amount.
    """

    __tablename__ = "vitta_staff_advance_settlements"

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
    advance_id: Mapped[str] = mapped_column(
        String(36),
        ForeignKey("vitta_staff_advances.id"),
        nullable=False,
        unique=True,
    )
    settled_on: Mapped[date] = mapped_column(Date, nullable=False)
    refund_amount: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False)
    refund_mode: Mapped[str | None] = mapped_column(String(20), nullable=True)
    # 'cash' | 'neft' | 'salary_deduction' | 'none'
    voucher_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("vitta_vouchers.id"), nullable=True
    )

2.10 Payroll + sambhavana

# src/aayojana/vitta/models/payroll.py
class PayrollRun(Base, AuditMixin, TenantMixin):
    """Header for a monthly payroll batch. One run per (tenant, branch,
    month, category). Categories are kept distinct because TDS treatment
    and statutory return mapping (24Q vs 26Q) differs.
    """

    __tablename__ = "vitta_payroll_runs"
    __table_args__ = (
        UniqueConstraint(
            "tenant_id", "branch_id", "period_year", "period_month", "category",
            name="uq_payroll_run_period",
        ),
    )

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
    branch_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("branches.id"), nullable=True
    )
    period_year: Mapped[int] = mapped_column(Integer, nullable=False)
    period_month: Mapped[int] = mapped_column(Integer, nullable=False)
    category: Mapped[str] = mapped_column(String(20), nullable=False)
    # 'salary' | 'sambhavana_archaka' | 'sambhavana_examiner' |
    # 'volunteer_stipend' | 'consultant'
    fund_id: Mapped[str] = mapped_column(
        String(36), ForeignKey("vitta_funds.id"), nullable=False
    )
    status: Mapped[str] = mapped_column(String(16), nullable=False, default="draft")
    # 'draft' | 'approved' | 'paid' | 'reversed'
    gross_total: Mapped[Decimal] = mapped_column(Numeric(18, 2), default=0, nullable=False)
    deduction_total: Mapped[Decimal] = mapped_column(Numeric(18, 2), default=0, nullable=False)
    net_total: Mapped[Decimal] = mapped_column(Numeric(18, 2), default=0, nullable=False)
    approved_by_user_id: Mapped[int | None] = mapped_column(
        Integer, ForeignKey("users.id"), nullable=True
    )
    approved_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
    voucher_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("vitta_vouchers.id"), nullable=True
    )


class PayrollLine(Base, AuditMixin):
    """One employee/archaka/examiner per run.
    Sambhavana lines map to TDS section 194J (professional) by default;
    salary lines map to 192 (salary). EPF/ESI computed only for 'salary'.
    """

    __tablename__ = "vitta_payroll_lines"
    __table_args__ = (
        UniqueConstraint(
            "run_id", "member_id",
            name="uq_payroll_line_per_member",
        ),
    )

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
    run_id: Mapped[str] = mapped_column(
        String(36), ForeignKey("vitta_payroll_runs.id", ondelete="CASCADE"),
        nullable=False,
    )
    member_id: Mapped[str] = mapped_column(
        String(36), ForeignKey("members.id"), nullable=False
    )
    basic: Mapped[Decimal] = mapped_column(Numeric(18, 2), default=0, nullable=False)
    hra: Mapped[Decimal] = mapped_column(Numeric(18, 2), default=0, nullable=False)
    food_allowance: Mapped[Decimal] = mapped_column(Numeric(18, 2), default=0, nullable=False)
    accommodation_in_kind: Mapped[Decimal] = mapped_column(Numeric(18, 2), default=0, nullable=False)
    sambhavana: Mapped[Decimal] = mapped_column(Numeric(18, 2), default=0, nullable=False)
    other_earnings: Mapped[Decimal] = mapped_column(Numeric(18, 2), default=0, nullable=False)
    gross: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False)
    epf: Mapped[Decimal] = mapped_column(Numeric(18, 2), default=0, nullable=False)
    esi: Mapped[Decimal] = mapped_column(Numeric(18, 2), default=0, nullable=False)
    professional_tax: Mapped[Decimal] = mapped_column(Numeric(18, 2), default=0, nullable=False)
    tds: Mapped[Decimal] = mapped_column(Numeric(18, 2), default=0, nullable=False)
    tds_section: Mapped[str | None] = mapped_column(String(8), nullable=True)
    advance_recovery: Mapped[Decimal] = mapped_column(Numeric(18, 2), default=0, nullable=False)
    # Auto-pulled from open StaffAdvances
    other_deductions: Mapped[Decimal] = mapped_column(Numeric(18, 2), default=0, nullable=False)
    net: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False)
    payment_mode: Mapped[str | None] = mapped_column(String(20), nullable=True)
    payment_ref: Mapped[str | None] = mapped_column(String(32), nullable=True)
    paid_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)

2.11 Bank reconciliation

# src/aayojana/vitta/models/bank_recon.py
class BankReconImport(Base, AuditMixin, TenantMixin):
    """A bank-statement upload. The raw file (CSV/Excel/MT940) lives in
    GCS; rows are parsed into a temp staging structure in `payload`.
    """

    __tablename__ = "vitta_bank_recon_imports"

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
    account_id: Mapped[str] = mapped_column(
        String(36), ForeignKey("vitta_accounts.id"), nullable=False
    )
    statement_from: Mapped[date] = mapped_column(Date, nullable=False)
    statement_to: Mapped[date] = mapped_column(Date, nullable=False)
    file_uri: Mapped[str] = mapped_column(String(512), nullable=False)
    file_format: Mapped[str] = mapped_column(String(8), nullable=False)
    # 'csv' | 'xls' | 'mt940' | 'json'
    rows_total: Mapped[int] = mapped_column(Integer, nullable=False)
    rows_matched: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
    rows_unmatched: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
    payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
    status: Mapped[str] = mapped_column(String(16), nullable=False, default="parsed")
    # 'parsed' | 'matching' | 'completed' | 'failed'


class BankReconMatch(Base, AuditMixin):
    """A pairing between one statement row and one journal line.
    Auto-matchers populate `confidence`; an unmatched row has match_status='unmatched'.
    """

    __tablename__ = "vitta_bank_recon_matches"
    __table_args__ = (
        Index("ix_recon_import", "import_id"),
        Index("ix_recon_status", "match_status"),
    )

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    import_id: Mapped[str] = mapped_column(
        String(36), ForeignKey("vitta_bank_recon_imports.id"), nullable=False
    )
    statement_row: Mapped[dict] = mapped_column(JSON, nullable=False)
    # parsed row: {date, narration, ref, debit, credit, balance}
    journal_line_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("vitta_journal_lines.id"), nullable=True
    )
    match_status: Mapped[str] = mapped_column(String(16), nullable=False)
    # 'auto' | 'manual' | 'unmatched' | 'rejected'
    confidence: Mapped[Decimal | None] = mapped_column(Numeric(5, 4), nullable=True)
    matched_by_user_id: Mapped[int | None] = mapped_column(
        Integer, ForeignKey("users.id"), nullable=True
    )

2.12 Inter-branch transfers

# src/aayojana/vitta/models/transfer.py
class InterBranchTransfer(Base, AuditMixin, TenantMixin):
    """Money movement between branches of one tenant.
    Cannot cross funds — same fund_id on both legs.
    Posts two JEs: debit destination, credit source, both via
    'inter-branch suspense' contra account.
    """

    __tablename__ = "vitta_interbranch_transfers"

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
    transfer_number: Mapped[str] = mapped_column(String(32), nullable=False)
    transfer_date: Mapped[date] = mapped_column(Date, nullable=False)
    fund_id: Mapped[str] = mapped_column(
        String(36), ForeignKey("vitta_funds.id"), nullable=False
    )
    from_branch_id: Mapped[str] = mapped_column(
        String(36), ForeignKey("branches.id"), nullable=False
    )
    to_branch_id: Mapped[str] = mapped_column(
        String(36), ForeignKey("branches.id"), nullable=False
    )
    from_account_id: Mapped[str] = mapped_column(
        String(36), ForeignKey("vitta_accounts.id"), nullable=False
    )
    to_account_id: Mapped[str] = mapped_column(
        String(36), ForeignKey("vitta_accounts.id"), nullable=False
    )
    amount: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False)
    mode: Mapped[str] = mapped_column(String(20), nullable=False)
    utr_number: Mapped[str | None] = mapped_column(String(32), nullable=True)
    out_voucher_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("vitta_vouchers.id"), nullable=True
    )
    in_voucher_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("vitta_vouchers.id"), nullable=True
    )
    confirmed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)

2.13 Period locks

# src/aayojana/vitta/models/period_lock.py
class PeriodLock(Base, AuditMixin, TenantMixin):
    """Locks a (year, month) for posting. Once locked, no JE can be
    posted with posting_date in that period without two-key override
    (hooks into Audit module's two-key machinery — Agent 2 designs).
    Lock can be at branch granularity for organisations that close
    branch-by-branch before consolidating.
    """

    __tablename__ = "vitta_period_locks"
    __table_args__ = (
        UniqueConstraint(
            "tenant_id", "branch_id", "period_year", "period_month",
            name="uq_period_lock",
        ),
    )

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
    branch_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("branches.id"), nullable=True
    )
    period_year: Mapped[int] = mapped_column(Integer, nullable=False)
    period_month: Mapped[int] = mapped_column(Integer, nullable=False)
    locked_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
    locked_by_user_id: Mapped[int] = mapped_column(
        Integer, ForeignKey("users.id"), nullable=False
    )
    second_signer_user_id: Mapped[int] = mapped_column(
        Integer, ForeignKey("users.id"), nullable=False
    )
    # Two-key: lock requires two signatures
    note: Mapped[str | None] = mapped_column(Text, nullable=True)
    overridden_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
    overridden_by_user_id: Mapped[int | None] = mapped_column(
        Integer, ForeignKey("users.id"), nullable=True
    )

2.14 TDS entries + returns

# src/aayojana/vitta/models/tds.py
class TDSEntry(Base, AuditMixin, TenantMixin):
    """One TDS deduction event. Aggregated quarterly into 24Q (salary,
    section 192) and 26Q (other, sections 194C/194I/194J etc.).
    """

    __tablename__ = "vitta_tds_entries"
    __table_args__ = (
        Index("ix_tds_quarter", "fy_label", "quarter"),
        Index("ix_tds_section", "section"),
    )

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
    deducted_date: Mapped[date] = mapped_column(Date, nullable=False)
    fy_label: Mapped[str] = mapped_column(String(7), nullable=False)
    quarter: Mapped[int] = mapped_column(Integer, nullable=False)  # 1-4
    section: Mapped[str] = mapped_column(String(8), nullable=False)
    deductee_member_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("members.id"), nullable=True
    )
    deductee_vendor_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("vitta_vendor_payables.id"), nullable=True
    )
    deductee_pan: Mapped[str | None] = mapped_column(String(10), nullable=True)
    gross_amount: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False)
    rate: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False)
    tds_amount: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False)
    challan_number: Mapped[str | None] = mapped_column(String(20), nullable=True)
    challan_paid_date: Mapped[date | None] = mapped_column(Date, nullable=True)
    bsr_code: Mapped[str | None] = mapped_column(String(8), nullable=True)
    source_voucher_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("vitta_vouchers.id"), nullable=True
    )


class TDSReturn(Base, AuditMixin, TenantMixin):
    """Quarterly TDS return (24Q / 26Q) — a generated upload-ready file."""

    __tablename__ = "vitta_tds_returns"

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
    return_form: Mapped[str] = mapped_column(String(8), nullable=False)
    # '24Q' | '26Q' | '27Q' (FCRA/foreign) | '27EQ'
    fy_label: Mapped[str] = mapped_column(String(7), nullable=False)
    quarter: Mapped[int] = mapped_column(Integer, nullable=False)
    file_uri: Mapped[str | None] = mapped_column(String(512), nullable=True)
    rpu_version: Mapped[str | None] = mapped_column(String(16), nullable=True)
    generated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
    submitted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
    status: Mapped[str] = mapped_column(String(16), nullable=False, default="draft")

2.15 GST invoices + returns

# src/aayojana/vitta/models/gst.py
class GSTInvoice(Base, AuditMixin, TenantMixin):
    """A GST-applicable outward supply (rare for trusts, but possible
    for publications, kalyanotsavam-as-service, prasadam packaging).
    """

    __tablename__ = "vitta_gst_invoices"
    __table_args__ = (
        UniqueConstraint(
            "tenant_id", "invoice_number",
            name="uq_gst_invoice_per_tenant",
        ),
    )

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
    invoice_number: Mapped[str] = mapped_column(String(32), nullable=False)
    invoice_date: Mapped[date] = mapped_column(Date, nullable=False)
    fy_label: Mapped[str] = mapped_column(String(7), nullable=False)
    customer_name: Mapped[str] = mapped_column(Text, nullable=False)
    customer_gstin: Mapped[str | None] = mapped_column(String(15), nullable=True)
    place_of_supply: Mapped[str] = mapped_column(String(2), nullable=False)
    # State code
    hsn_sac_code: Mapped[str | None] = mapped_column(String(8), nullable=True)
    taxable_value: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False)
    cgst: Mapped[Decimal] = mapped_column(Numeric(18, 2), default=0, nullable=False)
    sgst: Mapped[Decimal] = mapped_column(Numeric(18, 2), default=0, nullable=False)
    igst: Mapped[Decimal] = mapped_column(Numeric(18, 2), default=0, nullable=False)
    cess: Mapped[Decimal] = mapped_column(Numeric(18, 2), default=0, nullable=False)
    total: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False)
    voucher_id: Mapped[str | None] = mapped_column(
        String(36), ForeignKey("vitta_vouchers.id"), nullable=True
    )


class GSTReturn(Base, AuditMixin, TenantMixin):
    """Generated GSTR-1 / GSTR-3B file."""

    __tablename__ = "vitta_gst_returns"

    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
    return_form: Mapped[str] = mapped_column(String(16), nullable=False)
    # 'GSTR-1' | 'GSTR-3B' | 'GSTR-9' (annual)
    period_year: Mapped[int] = mapped_column(Integer, nullable=False)
    period_month: Mapped[int | None] = mapped_column(Integer, nullable=True)
    quarter: Mapped[int | None] = mapped_column(Integer, nullable=True)
    file_uri: Mapped[str | None] = mapped_column(String(512), nullable=True)
    generated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
    arn: Mapped[str | None] = mapped_column(String(32), nullable=True)
    # Acknowledgement Reference Number returned by GSTN portal
    status: Mapped[str] = mapped_column(String(16), nullable=False, default="draft")

3. Reuse map

Vitta is a heavy consumer of existing Aayojana data. The following are read (not duplicated) and (where appropriate) extended via FK:

Vitta will not redefine any of the above. New tables introduce no tenant-scope tables that duplicate organisation / branch / member identity.


4. API surface

All routes mounted under /api/vitta/. Auth: every route requires a JWT carrying tenant_id; tenant scoping enforced in service layer. Roles checked via require_role(...) dependency:

Method Path Auth role Purpose
GET /api/vitta/ any Module index / health
GET /api/vitta/dashboard accountant Fund balances + MRR + MTD I&E summary (HTML + JSON)
Chart of Accounts
GET /api/vitta/funds any List funds for tenant
POST /api/vitta/funds treasurer Create custom fund (system funds seeded at provisioning)
PATCH /api/vitta/funds/{id} treasurer Rename / deactivate (system funds rename only)
GET /api/vitta/accounts any Tree-view of accounts (filter by fund / type / branch)
POST /api/vitta/accounts accountant Create account head/sub-head
PATCH /api/vitta/accounts/{id} accountant Edit (soft) — locked accounts return 409
Journal & Ledger
GET /api/vitta/journal any Filter by date range, fund, branch, account, voucher
GET /api/vitta/journal/{id} any One JE with lines
POST /api/vitta/journal accountant Create draft JE (not yet posted)
POST /api/vitta/journal/{id}/post accountant Validate balanced + post (sets posted_at)
POST /api/vitta/journal/{id}/reverse treasurer Create reversal entry
GET /api/vitta/ledger/{account_id} any Account ledger with running balance
GET /api/vitta/trial-balance any TB at given date, fund-wise or consolidated
GET /api/vitta/income-expenditure any I&E for date range
GET /api/vitta/balance-sheet any BS at date
Hundi
POST /api/vitta/hundi accountant Create count with denomination breakdown + 2 witnesses
POST /api/vitta/hundi/{id}/deposit treasurer Mark deposited to bank → posts JE
GET /api/vitta/hundi any List by branch + date range
GET /api/vitta/hundi/{id}/slip accountant Generate deposit slip PDF
Donations & 80G
GET /api/vitta/donations any View bridging transactions (read-only)
POST /api/vitta/donations/{tx_id}/post accountant Generate JE for an existing donation row
POST /api/vitta/eighty-g/{tx_id} accountant Generate 80G receipt PDF (single)
POST /api/vitta/eighty-g/bulk treasurer Bulk-generate by FY / member-list
POST /api/vitta/eighty-g/{id}/send accountant Email or hand-off to dispatch (postal)
POST /api/vitta/eighty-g/annual/{member_id} accountant Roll-up annual summary
FCRA
POST /api/vitta/fcra/donor-sources treasurer Register foreign donor
POST /api/vitta/fcra/donations treasurer Record FCRA donation (two-key)
GET /api/vitta/fcra/ledger any FCRA-only ledger view
GET /api/vitta/fcra/fc4 treasurer Generate FC-4 file for FY
Vendor Payables
GET /api/vitta/vendors accountant List vendors
POST /api/vitta/vendors accountant Create vendor
POST /api/vitta/vendor-bills accountant Receive bill (uploads invoice PDF)
POST /api/vitta/vendor-bills/{id}/verify accountant Mark verified
POST /api/vitta/vendor-bills/{id}/approve treasurer Approve → posts payable JE
POST /api/vitta/vendor-payments treasurer Record payment → posts JE
Staff Advances (Imprest)
POST /api/vitta/advances treasurer Issue advance
POST /api/vitta/advances/{id}/bills member Submit bill (member-facing)
POST /api/vitta/imprest-bills/{id}/approve treasurer Approve bill (treasurer queue)
POST /api/vitta/imprest-bills/{id}/reject treasurer Reject with reason
POST /api/vitta/advances/{id}/settle treasurer Final settlement
GET /api/vitta/advances/aging accountant Aging report — open advances by age bucket
Payroll
POST /api/vitta/payroll-runs accountant Create draft run
POST /api/vitta/payroll-runs/{id}/lines accountant Add/edit lines
POST /api/vitta/payroll-runs/{id}/approve treasurer Approve → posts JE + emits TDS entries
POST /api/vitta/payroll-runs/{id}/pay treasurer Mark paid (per-line bank ref)
GET /api/vitta/payroll-runs/{id}/payslips/{member_id} any Generate payslip PDF
Bank Reconciliation
POST /api/vitta/bank-recon/import accountant Upload statement file
POST /api/vitta/bank-recon/{id}/auto-match accountant Run auto-matcher
POST /api/vitta/bank-recon/match accountant Manual single-row match
GET /api/vitta/bank-recon/{id}/unmatched any Unmatched queue
POST /api/vitta/bank-recon/{id}/finalise treasurer Close reconciliation
Inter-branch Transfers
POST /api/vitta/transfers treasurer Create + post both legs
POST /api/vitta/transfers/{id}/confirm treasurer Mark UTR confirmed
GET /api/vitta/transfers any List with filters
Period Locks
POST /api/vitta/periods/lock treasurer Lock (year, month) — two-key body
POST /api/vitta/periods/unlock treasurer Override (audit-logged)
GET /api/vitta/periods any List locks
Statutory Returns
GET /api/vitta/returns/gstr1 treasurer Generate GSTR-1 file
GET /api/vitta/returns/gstr3b treasurer Generate GSTR-3B file
GET /api/vitta/returns/24q treasurer TDS quarterly salary
GET /api/vitta/returns/26q treasurer TDS quarterly other
GET /api/vitta/returns/form10b treasurer Audit return
GET /api/vitta/returns/fc4 treasurer FCRA annual return
Audit Pack
POST /api/vitta/audit-pack/{fy} treasurer Generate annual audit ZIP
Webhook bridge
POST /api/vitta/internal/process-payment-event/{id} system Internal endpoint (job runner)

5. Service layer

Located at src/aayojana/vitta/services/. Each function takes an AsyncSession, the calling user, and the active tenant_id; tenant filtering is non-negotiable.

# src/aayojana/vitta/services/journal.py
from decimal import Decimal
from typing import Sequence

async def post_journal_entry(
    db: AsyncSession,
    *,
    tenant_id: str,
    posting_date: date,
    fund_id: str,
    narration: str,
    lines: Sequence[JournalLineSpec],
    voucher_id: str | None = None,
    branch_id: str | None = None,
    posted_by_user_id: int,
) -> JournalEntry:
    """Validates: sum(debits) == sum(credits); all lines reference accounts
    with the same fund_id as the header; period not locked (or override
    supplied separately); accounts active.
    Raises VittaImbalanceError | VittaPeriodLockedError | VittaFundMismatchError.
    """


async def reverse_journal_entry(
    db, *, tenant_id, original_id: str, narration: str, posted_by_user_id: int,
) -> JournalEntry:
    """Creates a reversing JE with debit/credit swapped on every line."""


async def list_ledger(
    db, *, tenant_id, account_id: str, from_date: date, to_date: date,
) -> LedgerView: ...


async def trial_balance(
    db, *, tenant_id, as_of: date, fund_id: str | None = None,
) -> TrialBalanceView: ...


# src/aayojana/vitta/services/donations.py
async def post_donation_journal(
    db, *, tenant_id, transaction_id: int, posted_by_user_id: int,
) -> JournalEntry:
    """Reads `transactions.id`, infers fund (from sevaCategory tag or
    member's default), posts:
       Dr. Bank/Cash    (selected by paymentmethod)
            Cr. Donation Income — <fund>
    Idempotent: if voucher already exists for this (source_type='transactions',
    source_id=transaction_id), returns existing JE.
    """


async def generate_80g_receipt(
    db, *, tenant_id, transaction_id: int, issued_by_user_id: int,
) -> EightyGReceipt:
    """Resolves: member, amount, FY, deduction percentage (config per fund);
    pulls next sequence; renders PDF via Jinja2 + WeasyPrint; stores in GCS;
    returns row.
    Raises VittaFCRAExclusionError if the donation's fund.is_fcra=True
    (FCRA donations don't get 80G).
    """


async def bulk_generate_80g(
    db, *, tenant_id, fy_label: str, member_ids: Sequence[str] | None = None,
) -> list[EightyGReceipt]: ...


async def send_80g_receipt(
    db, *, tenant_id, receipt_id: str, channel: str,
) -> None:
    """channel='email' or 'postal'. Postal creates a dispatch_records row."""


# src/aayojana/vitta/services/payment_events.py
async def process_subscription_payment(
    db, *, payment_event_id: int,
) -> JournalEntry | None:
    """Webhook side-effect handler. Called from a job runner that polls
    `payment_events WHERE processed=true AND vitta_processed=false`
    (we add a `vitta_processed` column in 0006-followup, OR keep this
    as a separate vitta_processed_payment_events tracking table).
    For 'subscription.charged' / 'invoice.payment_succeeded':
       Dr. Bank — Razorpay Receivable    (net amount)
       Dr. Razorpay Fees & GST           (charges)
            Cr. Subscription Revenue — General Fund
    For 'payment.failed': no JE; emits comms alert.
    Returns the JE or None if event-type has no accounting consequence.
    """


# src/aayojana/vitta/services/hundi.py
async def record_hundi_count(
    db, *, tenant_id, branch_id, collection_date: date, fund_id,
    denominations: list[tuple[int, int]],  # (denom, count) pairs
    cheques_total: Decimal, other_total: Decimal,
    witness_1_user_id: int, witness_2_user_id: int,
    counted_at: datetime,
) -> HundiCollection:
    """Validates: witnesses are distinct, both have role 'witness' or above,
    sum(denominations) matches total_cash. Does NOT post a JE — that
    happens at deposit_hundi.
    """


async def deposit_hundi(
    db, *, tenant_id, collection_id: str, deposited_to_account_id: str,
    deposit_slip_number: str, deposited_at: datetime,
    posted_by_user_id: int,
) -> JournalEntry:
    """Posts:
       Dr. Bank Account (deposited_to)
            Cr. Cash on Hand — Hundi (branch+fund)
    Sets HundiCollection.deposited_at + voucher_id.
    """


# src/aayojana/vitta/services/vendor.py
async def receive_vendor_bill(...) -> VendorBill: ...

async def approve_vendor_bill(
    db, *, tenant_id, bill_id, approver_user_id: int,
) -> JournalEntry:
    """Posts:
       Dr. Expense (or Inventory)        (subtotal)
       Dr. Input GST (if claimable)      (gst_amount)
            Cr. Vendor Payable           (net_payable)
            Cr. TDS Payable — <section>  (tds_amount)
    """

async def pay_vendor_bill(...) -> JournalEntry: ...


# src/aayojana/vitta/services/advance.py
async def issue_advance(...) -> StaffAdvance:
    """JE:
       Dr. Staff Advance — <member>
            Cr. Bank/Cash
    """


async def submit_imprest_bill(...) -> ImprestBill: ...


async def approve_imprest_bill(
    db, *, tenant_id, bill_id, approver_user_id: int,
) -> JournalEntry:
    """JE:
       Dr. Expense Account
            Cr. Staff Advance — <member>
    """


async def settle_advance(...) -> StaffAdvanceSettlement: ...


# src/aayojana/vitta/services/payroll.py
async def create_payroll_run(...) -> PayrollRun: ...

async def add_payroll_lines(
    db, *, tenant_id, run_id, member_ids: Sequence[str],
) -> list[PayrollLine]:
    """Pulls the latest pay grade per member from Staff module; computes
    EPF/ESI/PT for category='salary'; computes TDS per category default.
    Auto-recovers any open StaffAdvance balance into advance_recovery.
    """


async def approve_payroll(
    db, *, tenant_id, run_id, approver_user_id: int,
) -> JournalEntry:
    """Aggregates all lines; posts one JE:
       Dr. Salary / Sambhavana / Stipend  (gross_total)
            Cr. Salary Payable            (net_total)
            Cr. EPF Payable
            Cr. ESI Payable
            Cr. TDS Payable — 192 / 194J
            Cr. Staff Advance — <each>    (recoveries)
    Emits one TDSEntry per line where tds > 0.
    """


# src/aayojana/vitta/services/bank_recon.py
async def import_statement(
    db, *, tenant_id, account_id, file_uri, file_format,
) -> BankReconImport: ...


async def auto_match(
    db, *, tenant_id, import_id,
) -> tuple[int, int]:
    """Match strategies in order:
    1. Exact UTR / cheque-number match against journal_lines
    2. Amount + ±2-day date window + narration token match
    3. Recurring rule match (saved per-account from prior reconciliations)
    Returns (matched_count, unmatched_count).
    """


async def manual_match(
    db, *, tenant_id, import_id, statement_row_index: int, journal_line_id: str,
    matched_by_user_id: int,
) -> BankReconMatch: ...


# src/aayojana/vitta/services/period_lock.py
async def lock_period(
    db, *, tenant_id, period_year: int, period_month: int,
    branch_id: str | None,
    locked_by_user_id: int, second_signer_user_id: int, note: str,
) -> PeriodLock:
    """Two-key: requires distinct signers, both with role >= 'treasurer'.
    Sets locked_by_period=True on every JE in the window.
    Audit: emits an audit_event via Agent 2's machinery.
    """


async def override_lock(
    db, *, tenant_id, lock_id, override_user_id: int, second_signer_user_id: int,
    reason: str,
) -> PeriodLock: ...


# src/aayojana/vitta/services/transfers.py
async def post_interbranch_transfer(...) -> InterBranchTransfer:
    """Two JEs:
       (1) From-branch:
           Dr. Inter-Branch Suspense
                Cr. Bank — From
       (2) To-branch:
           Dr. Bank — To
                Cr. Inter-Branch Suspense
    Both must share fund_id.
    """


# src/aayojana/vitta/services/fcra.py
async def record_fcra_donation(
    db, *, tenant_id, donor_source_id, foreign_amount, foreign_currency,
    fx_rate, purpose, account_id, received_date,
    recorded_by_user_id: int, second_signer_user_id: int,
) -> FCRADonation:
    """Validates account.fund.is_fcra=True. Two-key on entry."""


async def generate_fc4(db, *, tenant_id, fy_label) -> bytes: ...


# src/aayojana/vitta/services/returns.py
async def generate_gstr1(db, *, tenant_id, period_year, period_month) -> bytes: ...
async def generate_gstr3b(db, *, tenant_id, period_year, period_month) -> bytes: ...
async def generate_24q(db, *, tenant_id, fy_label, quarter) -> bytes: ...
async def generate_26q(db, *, tenant_id, fy_label, quarter) -> bytes: ...
async def generate_form10b(db, *, tenant_id, fy_label) -> bytes: ...


# src/aayojana/vitta/services/audit_pack.py
async def build_annual_audit_pack(
    db, *, tenant_id, fy_label,
) -> str:
    """Bundles: Trial Balance, I&E (fund-wise + consolidated), BS,
    every voucher (PDF), 80G register, FCRA register, payroll register,
    TDS challans, vendor ledger, jewel valuation snapshot (from Assets module).
    Returns GCS URI of the ZIP.
    """

6. UI / Templates

Jinja2 templates under src/aayojana/templates/vitta/. All extend templates/base_admin.html.

Template Page
vitta/dashboard.html Tenant dashboard — fund balances (5 cards: GEN/ANNA/BLDG/FCRA/ENDO), MRR ribbon, MTD Income vs Expenditure bar, alerts (overdue advances, unmatched bank rows, locked-period nudge)
vitta/coa/index.html Chart of Accounts editor — tree by fund × account-type, drag-to-reparent, inline rename
vitta/coa/account_form.html Create / edit account modal
vitta/journal/composer.html Journal entry composer — drag-and-drop debit/credit lines, live debit=credit indicator, fund-restricted account picker
vitta/journal/list.html Browse JEs with filters; inline drill into ledger
vitta/journal/detail.html View JE + lines + voucher + reverse button
vitta/ledger/account.html Account ledger with running balance + export buttons
vitta/statements/trial_balance.html TB at-date with fund toggle
vitta/statements/income_expenditure.html I&E for date range
vitta/statements/balance_sheet.html BS at-date
vitta/hundi/count_form.html Hundi count entry — denomination grid (1/2/5/10/20/50/100/200/500/2000), live total, two-witness signature blocks
vitta/hundi/deposit_slip.html Print-ready deposit slip
vitta/hundi/list.html List by branch + date with deposit status
vitta/eighty_g/generator.html 80G receipt generator (single + bulk wizard)
vitta/eighty_g/template_v_2526.html The receipt PDF (server-side rendered with WeasyPrint) — versioned per FY
vitta/eighty_g/bulk_mailer.html Bulk-mail wizard: filter donors → preview → dispatch via email/postal
vitta/eighty_g/annual.html Annual summary view per donor
vitta/fcra/donor_sources.html FCRA foreign donor registry
vitta/fcra/donation_form.html FCRA donation entry — two-key body
vitta/fcra/ledger.html FCRA-only ledger view
vitta/fcra/fc4_preview.html FC-4 preview before download
vitta/vendors/index.html Vendor list
vitta/vendors/bills_queue.html Treasurer approval queue
vitta/vendors/bill_form.html Bill receive/edit form
vitta/vendors/payment_form.html Payment entry
vitta/advances/queue.html Imprest queue — two tabs: 'Open advances' and 'Bills pending approval' (treasurer view)
vitta/advances/issue_form.html Issue advance
vitta/advances/bills_submit.html Member-facing bill submission
vitta/advances/settle.html Settlement screen — bills upload + balance return
vitta/advances/aging.html Aging buckets (0-30 / 31-60 / 61-90 / 90+)
vitta/payroll/runs.html Payroll runs list
vitta/payroll/composer.html Run composer — pulls members, computes deductions, table editor
vitta/payroll/payslip.html Payslip PDF template
vitta/bank_recon/upload.html Statement upload form (CSV/Excel/MT940)
vitta/bank_recon/worksheet.html Two-pane worksheet: statement rows ↔ journal lines, drag to match
vitta/bank_recon/unmatched.html Queue of unmatched rows
vitta/transfers/form.html Inter-branch transfer form
vitta/transfers/list.html Transfers list
vitta/period_close/wizard.html Period-end close — pre-checks (unposted JEs, open advances, unreconciled bank), two-signature block, lock button
vitta/returns/index.html Statutory returns hub — generate buttons per form
vitta/returns/gstr1.html, vitta/returns/gstr3b.html, vitta/returns/24q.html, vitta/returns/26q.html, vitta/returns/form10b.html, vitta/returns/fc4.html Per-form preview + download
vitta/audit_pack/builder.html Annual audit pack builder + history

7. Migration plan

Vitta is split across 6 Alembic revisions, all chaining from 0006 (payments). Each revision is idempotent (the 0006-style _inspector() / _has_column / has_table pattern). Foreign-key columns reference organisations.id / branches.id / users.id / members.id / transactions.id / dispatch_records.id — all existing.

0007 — Chart of Accounts

Adds: - vitta_funds (id, code, name, fund_kind, is_fcra, is_system, is_active, tenant_id, audit cols) - vitta_accounts (id, code, name, account_type, fund_id, parent_id, branch_id, is_bank, bank_account_number, bank_ifsc, is_active, notes, tenant_id, audit cols) - Seeder script (separate from migration): insert 5 system funds per tenant on tenant provisioning.

0008 — Journal & Ledger

Adds: - vitta_vouchers - vitta_journal_entries - vitta_journal_lines (with ck_jl_debit_xor_credit) - Indexes on posting_date, voucher_id, account_id, entry_id

0009 — Hundi, Vendor Payables, Staff Advances

Adds: - vitta_hundi_collections - vitta_hundi_denominations - vitta_vendor_payables - vitta_vendor_bills - vitta_vendor_payments - vitta_staff_advances - vitta_imprest_bills - vitta_staff_advance_settlements

0010 — Payroll & TDS

Adds: - vitta_payroll_runs - vitta_payroll_lines - vitta_tds_entries - vitta_tds_returns

0011 — Bank Reconciliation, Period Locks, Transfers

Adds: - vitta_bank_recon_imports - vitta_bank_recon_matches - vitta_period_locks - vitta_interbranch_transfers

0012 — 80G, FCRA, GST, Audit-pack tables

Adds: - vitta_eighty_g_receipts - vitta_eighty_g_annual - vitta_fcra_donor_sources - vitta_fcra_donations - vitta_gst_invoices - vitta_gst_returns - (Audit-pack tables — index of generated bundles — designed as vitta_audit_packs here too)

Each migration includes downgrades that drop in reverse order. Runtime safety: every op.create_table is guarded by if not insp.has_table(...).


8. Cross-module dependencies

Reads from: - Members Suite — donor identity for 80G, deductee identity for sambhavana TDS - CRM transactions — raw donation amounts that drive donation JEs and 80G receipts - Payment Events (payment_events) — subscription charges → automatic JE - Staff Details (Agent 3 designs) — pay grade, EPF/ESI eligibility, TDS section default per category - Inventory (Agent 4) — samagri purchases → vendor bill creation; PO numbers as purchase_order_ref - Maintenance (Agent 4) — AMC bill posting; one-time repair bill posting - Events Planner (Agent 4) — samagri indents → POs → vendor bills - Education (Agent 4) — examiner registry → sambhavana payroll line - Statutory Data (Agent 2) — Bank Account legal record cross-checks Vitta's is_bank accounts - Asset Management (Agent 4) — jewel valuation pulled into audit pack

Writes to: - Audit Trail (Agent 2 designs) — every period-lock and override; every FCRA donation entry; every two-key signoff; every receipt void; every COA edit on system funds - Communications Service (Agent 5 designs) — 80G receipt email + postal dispatch; payment-failed alerts to treasurer; period-close completion notice; vendor payment intimation - Reports & Exports (Agent 5) — TB / I&E / BS / Trustee Pack quarterly bundles

Comms triggers from Vitta: - 80G receipt issued → email to donor + optional postal queue - Payment failed (subscription) → email/WhatsApp to tenant treasurer - Vendor bill approved → email to vendor with payment ETA - Period locked → email to all module-admins - FY-end audit pack ready → email to trustees + auditor

Reports outputs: - Trial Balance (fund-wise / consolidated) — daily snapshot job - Income & Expenditure — monthly auto-mail to treasurer - Balance Sheet — quarterly auto-mail to trustees - Trustee Pack quarterly — PDF compendium (financials + KPIs from peer modules) - Annual Audit Pack — March 31 close + 2 weeks


9. Implementation phases

Phase A (4 weeks) — foundation, ship usable v1

Ship criterion: A treasurer can post the month, reconcile two bank accounts, cut payments to two samagri vendors, and pull a usable I&E for the trustee meeting — without using Excel.

Phase B (3 weeks) — donor-side & payroll

Ship criterion: A tenant can issue 1,000 80G receipts for the FY and dispatch them via email + postal in one bulk run; a payroll run with mixed staff + archakas posts cleanly with TDS.

Phase C (3 weeks) — statutory & close

Ship criterion: Auditor receives one ZIP at FY-close that contains everything required for sign-off.


10. Open questions

  1. Default Chart of Accounts format — adopt Schedule III format (Companies Act / Trust accounts presentation) for I&E + BS, or a Trust-specific format closer to the Receipts and Payments + Income and Expenditure + Balance Sheet triplet preferred by Karnataka religious-trust audits? Recommendation: Trust-triplet by default, with a Schedule III toggle for organisations registered as Section 8.

  2. Financial Year boundary — Indian FY ends March 31 (April 1 – March 31). Are there any tenants we should accommodate on the older April 1 boundary or on a Vikrama-Samvatsara cycle for ritual books? Recommendation: April 1 – March 31 for statutory books; allow a separate samvatsara_label column on JE for parallel ritual-year reporting only.

  3. Sambhavana TDS section by default — section 194J (professional fees, 10% with PAN) for archaka and examiner sambhavana, or 192 (salary) where the archaka is a permanent employee on the rolls? Recommendation: Per-category default with per-line override: - sambhavana_archaka → 194J unless member has employment_status='permanent' - sambhavana_examiner → 194J always - volunteer_stipend → no TDS unless > ₹30k/year aggregate - consultant → 194J - salary → 192

  4. Receipt number format for 80G<TenantCode>-<FY>-<seq> (e.g. SGSDM-2526-000123) or <BranchCode>-<FY>-<seq> (multiple branches running parallel sequences)? Recommendation: TenantCode-FY-Sequence by default; branch-scoped sequence as a per-tenant config flag, since splitting by branch causes donor confusion when a person donates to two branches.

  5. 80G template versioning — how do we handle a mid-year regulator format change? Embed template_version in the row + keep a registry of WeasyPrint templates by version, regenerate-on-demand for re-prints?

  6. FCRA bank-account exclusivity enforcement — strict (any JE that touches an FCRA account rejects if it has a non-FCRA leg) or warning (post but log to audit)? Recommendation: Strict at service layer; bypass requires two-key + audit event.

  7. Hundi denomination exhaustiveness — list-of-denominations is currently {1,2,5,10,20,50,100,200,500,2000}. ₹2000 is being demonetised; coins denominations vary by branch. Allow tenant-config override?

  8. Vendor bill numbering — Aayojana-internal sequence vs vendor's invoice number. Both stored; uniqueness enforced on (vendor_id, vendor_invoice_number). Should we also auto-fill bill_number from sequenceNumbers?

  9. Two-key signer pool — must the two signers have role >= treasurer, or can a treasurer + branch-admin combo suffice for branch-scoped locks? Recommendation: treasurer + treasurer for tenant-level locks; treasurer + branch-admin for branch-level locks.

  10. Reversing entry policy — auto-reverse-on-period-open (so when a locked period is overridden, all reversals from prior month reverse themselves) or manual-only? Recommendation: Manual only — auto-reversals create accounting confusion in audit reviews.

  11. Bank statement ingestion formats — start with CSV only (Phase A) and add MT940 / OFX in Phase B? Indian banks send mostly CSV/Excel; SBI corporate sends MT940.

  12. Currency rounding — INR is paisa-precise (Numeric(18,2)) but bank statements often have rupee rounding. How do we treat 0.50 paisa differences in reconciliation — auto-write-off threshold per tenant?

  13. Restricted vs unrestricted within General Fund — does the General fund need internal sub-buckets (e.g. earmarked-by-donor for a specific purpose) without becoming a separate fund, or are those promoted to custom funds?

  14. Razorpay subscription fees accounting — Razorpay deducts its 2% + GST from the gross. Do we book gross-then-fee (cleaner for revenue recognition) or net (simpler reconciliation with Razorpay payouts)? Recommendation: Gross-then-fee — Dr Bank Receivable (net) + Dr Razorpay Fees (charges) + Cr Subscription Revenue (gross).

  15. Anonymous hundi donations and 80G — current law forbids 80G for anonymous donations beyond the threshold. Hundi cash is anonymous. Do we issue 80G against hundi at all? Recommendation: Never. Hundi income is booked against the fund, no 80G. Document this in template.

  16. Multi-tenant audit pack confidentiality — when generating the audit pack, do we ever include cross-tenant data? Strict no — but do we let an enterprise tenant generate a consolidated pack across its child organisations (parentId chain in organisations)? Phase C decision.


Blueprint authored by Architect Agent 1 of the Pancha. Vitta Fin module — 13 sub-modules, 6 Alembic revisions, ~25 new tables, ~50 service-layer functions, ~40 API endpoints. Intended length 600-1000 lines; this draft sits at the upper end intentionally because Vitta is the heaviest single module in the catalog.