Vitta Fin — Accounting — Blueprint
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:
organisations(String(36)PK) — TENANT scope on every Vitta row viaTenantMixin.tenant_id.plan_tier,subscription_status,razorpay_subscription_id,stripe_subscription_idare read for billing journals.branches(String(36)PK) — branch-scoped accounts, payroll runs, hundi collections, transfers.users(IntegerPK) — every two-key column FKs here (witnesses, approvers, lockers, second-signers).members(String(36)PK) — donor reference for 80G receipts; deductee reference for staff TDS / sambhavana TDS; staff reference for advances and payroll.transactions(existing DISA donation table) — Vitta READS this table to produce journal entries and 80G receipts. No duplication. A light bridge view (v_vitta_donations, see §6) joinstransactions×members×sevaCategoriesfor the 80G generator.payment_events(newly added in 0006) — webhook events triggerprocess_subscription_payment(event_id)which posts the corresponding JE.dispatch_records(existing postal letter tracking) — reused unchanged for postal 80G receipt dispatch.EightyGReceipt.dispatched_postal_idFKs here.sequenceNumbers(existing receipt-sequence helper) — extended with new keys for Vitta sequences (JE-<branch>,RCPT-80G-<tenant>,BILL-<tenant>, etc.).sevas/sevaCategories— soft references in JournalLine.cost_centre when a donation maps to a particular seva.
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:
treasurer— full Vitta access, can post / approve / lock periodsaccountant— post journals, generate receipts, run payroll-draftauditor— read-only across allbranch-admin— read + post for own branch only
| 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
- 0007, 0008, 0011 migrations (CoA, Journal, Bank Recon, Period Locks, Transfers; 0009 vendor + advance subset)
- COA editor UI + system-fund seeder on tenant provisioning
- Journal entry composer + post / reverse
- Trial Balance + Account Ledger views
- Bank Reconciliation worksheet (CSV import only) + auto-matcher v1
- Vendor payables happy path (receive → approve → pay)
- Staff advance issue + treasurer queue + settlement
- Income & Expenditure + Balance Sheet (basic, not yet schedule-III formatted)
- Period lock wizard with two-key
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
- 0010 + 0012 migrations
- 80G receipt generator + bulk-mailer (per-receipt and annual)
- 80G template v2526 PDF
- DISA-transactions → JE bridge (
post_donation_journal) - Payment-event → JE bridge (
process_subscription_payment) - FCRA donor source + donation entry (two-key) + FCRA-only ledger
- Payroll run composer with sambhavana + salary categories
- TDS computation + TDSEntry emission per payroll line
- Inter-branch transfer flow
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
- GSTR-1 / GSTR-3B file generators (off-line file format, not portal API)
- TDS 24Q / 26Q quarterly file generators
- Form 10B audit return file
- FC-4 annual return file
- Annual Audit Pack builder (ZIP)
- Period-end close wizard with pre-checks
- Trustee Pack quarterly PDF
- Hardening: idempotency tests, two-key edge cases, downgrade scripts
Ship criterion: Auditor receives one ZIP at FY-close that contains everything required for sign-off.
10. Open questions
-
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 Sheettriplet preferred by Karnataka religious-trust audits? Recommendation: Trust-triplet by default, with a Schedule III toggle for organisations registered as Section 8. -
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_labelcolumn on JE for parallel ritual-year reporting only. -
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 -
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. -
80G template versioning — how do we handle a mid-year regulator format change? Embed
template_versionin the row + keep a registry of WeasyPrint templates by version, regenerate-on-demand for re-prints? -
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.
-
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?
-
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_numberfromsequenceNumbers? -
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.
-
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.
-
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.
-
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?
-
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?
-
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).
-
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.
-
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.