Add Phase 4 standard accounting classification taxonomy

This commit is contained in:
A R R R Associates
2026-08-22 12:37:20 +05:30
parent 397c887703
commit 45fedd0d96
9 changed files with 466 additions and 0 deletions
@@ -0,0 +1,55 @@
"""Phase 4 standard accounting classification taxonomy.
Revision ID: 20260822_accounting_taxonomy_p4
Revises: 20260822_business_profile_p3
"""
from alembic import op
import sqlalchemy as sa
revision = "20260822_accounting_taxonomy_p4"
down_revision = "20260822_business_profile_p3"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"accounting_natures",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
sa.Column("code", sa.String(length=80), nullable=False),
sa.Column("name", sa.String(length=160), nullable=False),
sa.Column("parent_code", sa.String(length=80), nullable=True),
sa.Column("classification_group", sa.String(length=40), nullable=False),
sa.Column("capital_revenue", sa.String(length=20), nullable=False, server_default="revenue"),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="100"),
sa.Column("is_posting_nature", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("is_system_defined", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("updated_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("tenant_id", "code", name="uq_accounting_natures_tenant_code"),
)
op.create_index("ix_accounting_natures_tenant_id", "accounting_natures", ["tenant_id"])
op.create_index("ix_accounting_natures_code", "accounting_natures", ["code"])
op.create_index("ix_accounting_natures_name", "accounting_natures", ["name"])
op.create_index("ix_accounting_natures_parent_code", "accounting_natures", ["parent_code"])
op.create_index("ix_accounting_natures_classification_group", "accounting_natures", ["classification_group"])
op.create_index("ix_accounting_natures_capital_revenue", "accounting_natures", ["capital_revenue"])
op.create_index("ix_accounting_natures_is_posting_nature", "accounting_natures", ["is_posting_nature"])
op.create_index("ix_accounting_natures_is_active", "accounting_natures", ["is_active"])
def downgrade() -> None:
op.drop_index("ix_accounting_natures_is_active", table_name="accounting_natures")
op.drop_index("ix_accounting_natures_is_posting_nature", table_name="accounting_natures")
op.drop_index("ix_accounting_natures_capital_revenue", table_name="accounting_natures")
op.drop_index("ix_accounting_natures_classification_group", table_name="accounting_natures")
op.drop_index("ix_accounting_natures_parent_code", table_name="accounting_natures")
op.drop_index("ix_accounting_natures_name", table_name="accounting_natures")
op.drop_index("ix_accounting_natures_code", table_name="accounting_natures")
op.drop_index("ix_accounting_natures_tenant_id", table_name="accounting_natures")
op.drop_table("accounting_natures")
+2
View File
@@ -340,6 +340,8 @@ ROLE_PERMISSION_MAP = {
"accounting.tally.sync_transactions", "accounting.tally.sync_transactions",
"accounting.tally.depreciation_it", "accounting.tally.depreciation_it",
"accounting.tally.writeback", "accounting.tally.writeback",
"accounting.taxonomy.view",
"accounting.taxonomy.manage",
"accounting.act.initialize", "accounting.act.initialize",
"clients.view.own_only", "clients.view.own_only",
"employees.dashboard.view", "employees.dashboard.view",
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db.common import CommonBase
class AccountingNature(CommonBase):
"""Firm-scoped canonical accounting nature used by the intelligence engine.
Phase 4 intentionally stores accounting meaning separately from any client's
Tally ledger name. Later mapping/learning phases can link a nature code to
client-specific ledgers without changing this taxonomy.
"""
__tablename__ = "accounting_natures"
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_accounting_natures_tenant_code"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
code: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(160), nullable=False, index=True)
parent_code: Mapped[str | None] = mapped_column(String(80), nullable=True, index=True)
classification_group: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
capital_revenue: Mapped[str] = mapped_column(String(20), nullable=False, default="revenue", index=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=100)
is_posting_nature: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
is_system_defined: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
+154
View File
@@ -0,0 +1,154 @@
from __future__ import annotations
from collections import defaultdict
from datetime import datetime, timezone
from sqlalchemy import select
from app.modules.accounting.taxonomy_models import AccountingNature
# Stable canonical codes are intentionally independent from Tally ledger names.
# Existing rows are never renamed/deleted automatically. ensure_standard_taxonomy()
# only inserts missing standard codes, so future releases remain non-destructive.
STANDARD_ACCOUNTING_NATURES = [
# code, name, parent, group, capital/revenue, posting, sort, description
("PURCHASE", "Purchases", None, "purchase", "revenue", False, 100, "Purchase-side classification root."),
("TRADING_PURCHASE", "Trading / Stock Purchase", "PURCHASE", "purchase", "revenue", True, 110, "Goods acquired principally for resale."),
("RAW_MATERIAL_PURCHASE", "Raw Material Purchase", "PURCHASE", "purchase", "revenue", True, 120, "Materials consumed in manufacturing or production."),
("CONSUMABLES", "Consumables", "PURCHASE", "purchase", "revenue", True, 130, "Consumables used in operations but not held primarily for resale."),
("PACKING_MATERIAL", "Packing Material", "PURCHASE", "purchase", "revenue", True, 140, "Packaging and packing materials."),
("STORES_SPARES", "Stores & Spares", "PURCHASE", "purchase", "revenue", True, 150, "Operational stores, spares and replacement parts."),
("OPERATING_EXPENSE", "Operating Expenses", None, "expense", "revenue", False, 200, "Revenue expenditure classification root."),
("TELEPHONE", "Telephone / Mobile Charges", "OPERATING_EXPENSE", "expense", "revenue", True, 210, "Telephone and mobile communication charges."),
("INTERNET", "Internet / Data Charges", "OPERATING_EXPENSE", "expense", "revenue", True, 220, "Broadband, leased line and data connectivity charges."),
("ELECTRICITY", "Electricity Charges", "OPERATING_EXPENSE", "expense", "revenue", True, 230, "Electricity and power utility charges not capitalized."),
("WATER_CHARGES", "Water Charges", "OPERATING_EXPENSE", "expense", "revenue", True, 240, "Water utility charges."),
("RENT", "Rent", "OPERATING_EXPENSE", "expense", "revenue", True, 250, "Premises/equipment rent treated as revenue expenditure."),
("INSURANCE", "Insurance", "OPERATING_EXPENSE", "expense", "revenue", True, 260, "General business insurance expenditure."),
("PROFESSIONAL_CHARGES", "Professional Charges", "OPERATING_EXPENSE", "expense", "revenue", True, 270, "Professional and consultancy fees."),
("LEGAL_FEES", "Legal Fees", "OPERATING_EXPENSE", "expense", "revenue", True, 280, "Legal and advocate fees."),
("AUDIT_FEES", "Audit Fees", "OPERATING_EXPENSE", "expense", "revenue", True, 290, "Statutory/internal/other audit fees."),
("ACCOUNTING_FEES", "Accounting / Bookkeeping Fees", "OPERATING_EXPENSE", "expense", "revenue", True, 300, "Accounting and bookkeeping service charges."),
("TRAVELLING", "Travelling Expenses", "OPERATING_EXPENSE", "expense", "revenue", True, 310, "Business travel expenditure excluding separately classified lodging."),
("HOTEL_ACCOMMODATION", "Hotel / Accommodation", "OPERATING_EXPENSE", "expense", "revenue", True, 320, "Business lodging and accommodation."),
("FREIGHT_CARRIAGE", "Freight / Carriage", "OPERATING_EXPENSE", "expense", "revenue", True, 330, "Freight, carriage and transport of goods where expensed."),
("COURIER_POSTAGE", "Courier / Postage", "OPERATING_EXPENSE", "expense", "revenue", True, 340, "Courier, postal and delivery charges."),
("PRINTING_STATIONERY", "Printing & Stationery", "OPERATING_EXPENSE", "expense", "revenue", True, 350, "Printing, stationery and office consumables."),
("OFFICE_EXPENSES", "Office Expenses", "OPERATING_EXPENSE", "expense", "revenue", True, 360, "General office operating expenses."),
("STAFF_WELFARE", "Staff Welfare", "OPERATING_EXPENSE", "expense", "revenue", True, 370, "Employee welfare expenditure."),
("SOFTWARE_SUBSCRIPTION", "Software / Subscription Charges", "OPERATING_EXPENSE", "expense", "revenue", True, 380, "Software subscriptions, SaaS and similar recurring services."),
("CLOUD_HOSTING", "Cloud / Hosting Charges", "OPERATING_EXPENSE", "expense", "revenue", True, 390, "Cloud infrastructure, hosting and domain-related recurring charges."),
("SECURITY_HOUSEKEEPING", "Security / Housekeeping", "OPERATING_EXPENSE", "expense", "revenue", True, 400, "Security, housekeeping and facility support services."),
("ADVERTISEMENT_MARKETING", "Advertisement / Marketing", "OPERATING_EXPENSE", "expense", "revenue", True, 410, "Advertising, promotion and marketing expenditure."),
("COMMISSION_BROKERAGE", "Commission / Brokerage", "OPERATING_EXPENSE", "expense", "revenue", True, 420, "Commission, brokerage and intermediary charges."),
("BANK_CHARGES", "Bank Charges", "OPERATING_EXPENSE", "expense", "revenue", True, 430, "Banking and transaction charges."),
("REPAIRS_MAINTENANCE", "Repairs & Maintenance", None, "repairs", "revenue", False, 500, "Repair and maintenance classification root."),
("GENERAL_REPAIRS", "General Repairs & Maintenance", "REPAIRS_MAINTENANCE", "repairs", "revenue", True, 510, "General repairs not fitting a more specific class."),
("BUILDING_MAINTENANCE", "Building Repairs & Maintenance", "REPAIRS_MAINTENANCE", "repairs", "revenue", True, 520, "Revenue repairs and maintenance of buildings/premises."),
("MACHINERY_MAINTENANCE", "Plant & Machinery Repairs", "REPAIRS_MAINTENANCE", "repairs", "revenue", True, 530, "Repairs and maintenance of plant and machinery."),
("VEHICLE_MAINTENANCE", "Vehicle Repairs & Maintenance", "REPAIRS_MAINTENANCE", "repairs", "revenue", True, 540, "Vehicle service, spares, tyres and repair expenditure."),
("COMPUTER_MAINTENANCE", "Computer Repairs & Maintenance", "REPAIRS_MAINTENANCE", "repairs", "revenue", True, 550, "Computer and IT hardware repair expenditure."),
("ELECTRICAL_MAINTENANCE", "Electrical Repairs & Maintenance", "REPAIRS_MAINTENANCE", "repairs", "revenue", True, 560, "Electrical repair and maintenance expenditure."),
("CAPITAL_ASSET", "Capital Assets", None, "capital", "capital", False, 600, "Capital expenditure classification root."),
("LAND", "Land", "CAPITAL_ASSET", "capital", "capital", True, 610, "Land and land acquisition cost."),
("BUILDING", "Building", "CAPITAL_ASSET", "capital", "capital", True, 620, "Building capital expenditure."),
("PLANT_MACHINERY", "Plant & Machinery", "CAPITAL_ASSET", "capital", "capital", True, 630, "Plant and machinery capital expenditure."),
("FURNITURE_FIXTURES", "Furniture & Fixtures", "CAPITAL_ASSET", "capital", "capital", True, 640, "Furniture and fixtures capital expenditure."),
("COMPUTERS", "Computers", "CAPITAL_ASSET", "capital", "capital", True, 650, "Computers and qualifying computer hardware capital expenditure."),
("OFFICE_EQUIPMENT", "Office Equipment", "CAPITAL_ASSET", "capital", "capital", True, 660, "Office equipment capital expenditure."),
("MOTOR_VEHICLES", "Motor Vehicles", "CAPITAL_ASSET", "capital", "capital", True, 670, "Motor vehicle capital expenditure."),
("INTANGIBLE_ASSET", "Intangible Assets", "CAPITAL_ASSET", "capital", "capital", True, 680, "Qualifying intangible asset expenditure."),
("LEASEHOLD_IMPROVEMENTS", "Leasehold Improvements", "CAPITAL_ASSET", "capital", "capital", True, 690, "Capitalized leasehold improvement expenditure."),
("CAPITAL_WIP", "Capital Work-in-Progress", "CAPITAL_ASSET", "capital", "capital", True, 700, "Expenditure accumulated as capital work-in-progress."),
("TAX_STATUTORY", "Taxes & Statutory Charges", None, "statutory", "revenue", False, 800, "Taxes and statutory charges classification root."),
("GST_EXPENSE", "GST / Indirect Tax Expense", "TAX_STATUTORY", "statutory", "revenue", True, 810, "Indirect taxes treated as expenditure rather than recoverable credit."),
("MUNICIPAL_TAX", "Municipal / Property Tax", "TAX_STATUTORY", "statutory", "revenue", True, 820, "Municipal and property taxes."),
("LICENSE_FEES", "Licence / Registration Fees", "TAX_STATUTORY", "statutory", "revenue", True, 830, "Business licence and registration charges normally expensed."),
("FINANCE_COST", "Finance Costs", None, "finance", "revenue", False, 900, "Finance cost classification root."),
("INTEREST_EXPENSE", "Interest Expense", "FINANCE_COST", "finance", "revenue", True, 910, "Interest and borrowing cost treated as revenue expenditure."),
("LOAN_PROCESSING_CHARGES", "Loan Processing / Finance Charges", "FINANCE_COST", "finance", "revenue", True, 920, "Loan processing and other finance charges normally expensed."),
("REVIEW_REQUIRED", "Unclassified / Review Required", None, "review", "review", True, 9990, "Fallback when available evidence is insufficient for a reliable classification."),
]
def ensure_standard_taxonomy(db, tenant_id: int, actor_user_id: int | None = None) -> int:
existing = set(
db.execute(select(AccountingNature.code).where(AccountingNature.tenant_id == tenant_id)).scalars().all()
)
inserted = 0
for code, name, parent_code, group, cap_rev, posting, sort_order, description in STANDARD_ACCOUNTING_NATURES:
if code in existing:
continue
db.add(
AccountingNature(
tenant_id=tenant_id,
code=code,
name=name,
parent_code=parent_code,
classification_group=group,
capital_revenue=cap_rev,
description=description,
sort_order=sort_order,
is_posting_nature=posting,
is_active=True,
is_system_defined=True,
created_by_user_id=actor_user_id,
updated_by_user_id=actor_user_id,
)
)
inserted += 1
if inserted:
db.commit()
return inserted
def list_taxonomy(db, tenant_id: int, *, status: str = "all", group: str = "") -> list[AccountingNature]:
stmt = select(AccountingNature).where(AccountingNature.tenant_id == tenant_id)
if status == "active":
stmt = stmt.where(AccountingNature.is_active.is_(True))
elif status == "inactive":
stmt = stmt.where(AccountingNature.is_active.is_(False))
if group:
stmt = stmt.where(AccountingNature.classification_group == group)
return db.execute(stmt.order_by(AccountingNature.sort_order.asc(), AccountingNature.name.asc())).scalars().all()
def grouped_taxonomy(rows: list[AccountingNature]) -> list[dict]:
children: dict[str | None, list[AccountingNature]] = defaultdict(list)
by_code = {row.code: row for row in rows}
for row in rows:
children[row.parent_code].append(row)
result = []
for root in children.get(None, []):
result.append({"root": root, "children": children.get(root.code, [])})
# If filters excluded roots, retain orphaned matching rows under their known parent label where possible.
seen = {item["root"].code for item in result}
for row in rows:
if row.parent_code and row.parent_code not in seen and row.parent_code not in by_code:
result.append({"root": None, "parent_code": row.parent_code, "children": [row]})
return result
def set_nature_active(db, *, tenant_id: int, nature_id: int, is_active: bool, actor_user_id: int | None) -> AccountingNature | None:
row = db.execute(
select(AccountingNature).where(AccountingNature.id == nature_id, AccountingNature.tenant_id == tenant_id)
).scalar_one_or_none()
if row is None:
return None
if not row.is_posting_nature:
raise ValueError("Classification group headings cannot be disabled individually.")
row.is_active = bool(is_active)
row.updated_by_user_id = actor_user_id
row.updated_at_utc = datetime.now(timezone.utc)
db.add(row)
db.commit()
db.refresh(row)
return row
+121
View File
@@ -0,0 +1,121 @@
from __future__ import annotations
from fastapi import APIRouter, Form, Request
from fastapi.responses import RedirectResponse
from app.core.db.common import CommonSessionLocal
from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
from app.core.security.session_auth import get_current_user
from app.core.templating import templates
from app.modules.accounting.taxonomy_service import ensure_standard_taxonomy, grouped_taxonomy, list_taxonomy, set_nature_active
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
from app.modules.core.rbac.permission_guard import require_permission
from app.modules.documents.services import build_document_scope
router = APIRouter(prefix="/tools/accounting/taxonomy", tags=["accounting-taxonomy-ui"])
def _denied():
from app.core.http_responses import ui_access_denied
return ui_access_denied()
def _require_partner_permission(request: Request, db, permission: str):
user = get_current_user(request, db)
if not user:
return None, RedirectResponse(url="/login", status_code=303)
roles = set(get_user_roles(db, user.id))
if "Partner" not in roles:
return None, _denied()
try:
require_permission(db, user, permission)
except Exception:
return None, _denied()
return user, None
def _context(request: Request, db, user, **extra):
data = {
"request": request,
"current_user": user,
"current_user_roles": get_user_roles(db, user.id),
"current_user_permissions": get_user_permissions(db, user.id),
"csrf_token": get_or_create_csrf_token(request),
}
data.update(extra)
return data
@router.get("")
def accounting_taxonomy(request: Request, status: str = "all", group: str = "", updated: int = 0):
db = CommonSessionLocal()
try:
user, response = _require_partner_permission(request, db, "accounting.taxonomy.view")
if response:
return response
scope = build_document_scope(request, db, user)
ensure_standard_taxonomy(db, scope.tenant_id, user.id)
status = status if status in {"all", "active", "inactive"} else "all"
allowed_groups = {"purchase", "expense", "repairs", "capital", "statutory", "finance", "review"}
group = group if group in allowed_groups else ""
rows = list_taxonomy(db, scope.tenant_id, status=status, group=group)
all_rows = list_taxonomy(db, scope.tenant_id)
counts = {
"total": sum(1 for r in all_rows if r.is_posting_nature),
"active": sum(1 for r in all_rows if r.is_posting_nature and r.is_active),
"inactive": sum(1 for r in all_rows if r.is_posting_nature and not r.is_active),
"capital": sum(1 for r in all_rows if r.is_posting_nature and r.capital_revenue == "capital"),
}
return templates.TemplateResponse(
"modules/accounting/templates/accounting/taxonomy.html",
_context(
request,
db,
user,
taxonomy_groups=grouped_taxonomy(rows),
rows=rows,
counts=counts,
selected_status=status,
selected_group=group,
updated=updated,
),
)
finally:
db.close()
@router.post("/{nature_id}/active")
def update_accounting_nature_active(
nature_id: int,
request: Request,
csrf_token: str = Form(...),
is_active: str = Form(...),
status: str = Form("all"),
group: str = Form(""),
):
db = CommonSessionLocal()
try:
user, response = _require_partner_permission(request, db, "accounting.taxonomy.manage")
if response:
return response
validate_csrf(request, csrf_token)
scope = build_document_scope(request, db, user)
try:
row = set_nature_active(
db,
tenant_id=scope.tenant_id,
nature_id=nature_id,
is_active=is_active.strip().lower() in {"1", "true", "yes", "on"},
actor_user_id=user.id,
)
except ValueError:
return _denied()
if row is None:
return _denied()
query = f"?updated=1&status={status if status in {'all','active','inactive'} else 'all'}"
if group:
query += f"&group={group}"
return RedirectResponse(url=f"/tools/accounting/taxonomy{query}", status_code=303)
finally:
db.close()
@@ -13,6 +13,7 @@
<p class="mt-1 text-sm text-slate-500">Company mapping, master sync and transaction sync remain available. Phase 5 adds review-only accounting tools without Tally write-back.</p> <p class="mt-1 text-sm text-slate-500">Company mapping, master sync and transaction sync remain available. Phase 5 adds review-only accounting tools without Tally write-back.</p>
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<a href="/tools/accounting/taxonomy" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Accounting Taxonomy</a>
{% if selected_client %}<a href="/tools/tally/depreciation?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Depreciation (IT)</a>{% endif %} {% if selected_client %}<a href="/tools/tally/depreciation?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Depreciation (IT)</a>{% endif %}
<a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}&refresh=1{% else %}?refresh=1{% endif %}" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white shadow-soft hover:bg-brand-700">Refresh Tally Companies</a> <a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}&refresh=1{% else %}?refresh=1{% endif %}" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white shadow-soft hover:bg-brand-700">Refresh Tally Companies</a>
</div> </div>
@@ -0,0 +1,90 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-brand-600">Accounting Intelligence · Phase 4</p>
<h1 class="mt-1 text-2xl font-semibold text-slate-900">Accounting Classification Taxonomy</h1>
<p class="mt-1 max-w-3xl text-sm text-slate-500">Canonical accounting natures used by future classification and ledger-learning features. These codes are independent from client-specific Tally ledger names and do not post anything to Tally.</p>
</div>
<a href="/tools/tally" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back to Tally Accounting</a>
</div>
{% if updated %}
<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800">Accounting nature status updated.</div>
{% endif %}
<section class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Selectable Natures</div><div class="mt-2 text-2xl font-semibold text-slate-900">{{ counts.total }}</div></div>
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Active</div><div class="mt-2 text-2xl font-semibold text-emerald-700">{{ counts.active }}</div></div>
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Inactive</div><div class="mt-2 text-2xl font-semibold text-slate-700">{{ counts.inactive }}</div></div>
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Capital Natures</div><div class="mt-2 text-2xl font-semibold text-slate-900">{{ counts.capital }}</div></div>
</section>
<section class="rounded-2xl bg-white p-5 shadow-soft">
<form method="get" action="/tools/accounting/taxonomy" class="grid gap-4 md:grid-cols-3 md:items-end">
<label class="text-sm font-medium text-slate-700">Status
<select name="status" class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm">
<option value="all" {% if selected_status == 'all' %}selected{% endif %}>All</option>
<option value="active" {% if selected_status == 'active' %}selected{% endif %}>Active</option>
<option value="inactive" {% if selected_status == 'inactive' %}selected{% endif %}>Inactive</option>
</select>
</label>
<label class="text-sm font-medium text-slate-700">Classification Group
<select name="group" class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm">
<option value="">All groups</option>
{% for value, label in [('purchase','Purchases'),('expense','Operating Expenses'),('repairs','Repairs & Maintenance'),('capital','Capital Assets'),('statutory','Taxes & Statutory'),('finance','Finance Costs'),('review','Review Required')] %}
<option value="{{ value }}" {% if selected_group == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<div class="flex gap-2"><button type="submit" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Apply Filters</button><a href="/tools/accounting/taxonomy" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700">Reset</a></div>
</form>
</section>
<div class="space-y-4">
{% for section in taxonomy_groups %}
<section class="overflow-hidden rounded-2xl bg-white shadow-soft">
{% if section.root %}
<div class="border-b border-slate-100 bg-slate-50 px-5 py-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<div><h2 class="font-semibold text-slate-900">{{ section.root.name }}</h2><p class="mt-1 text-xs text-slate-500">{{ section.root.code }} · {{ section.root.description or '' }}</p></div>
<span class="rounded-full bg-white px-3 py-1 text-xs font-semibold text-slate-600">{{ section.root.classification_group|replace('_',' ')|title }}</span>
</div>
</div>
{% endif %}
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-white"><tr><th class="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Accounting Nature</th><th class="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Stable Code</th><th class="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Treatment</th><th class="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Status</th><th class="px-5 py-3 text-right text-xs font-semibold uppercase tracking-wide text-slate-500">Control</th></tr></thead>
<tbody class="divide-y divide-slate-100">
{% for row in section.children %}
<tr>
<td class="px-5 py-4"><div class="font-medium text-slate-900">{{ row.name }}</div><div class="mt-1 max-w-xl text-xs text-slate-500">{{ row.description or '' }}</div></td>
<td class="px-5 py-4"><code class="rounded bg-slate-100 px-2 py-1 text-xs text-slate-700">{{ row.code }}</code></td>
<td class="px-5 py-4"><span class="rounded-full px-2.5 py-1 text-xs font-semibold {% if row.capital_revenue == 'capital' %}bg-violet-50 text-violet-700{% elif row.capital_revenue == 'review' %}bg-amber-50 text-amber-700{% else %}bg-blue-50 text-blue-700{% endif %}">{{ row.capital_revenue|title }}</span></td>
<td class="px-5 py-4"><span class="rounded-full px-2.5 py-1 text-xs font-semibold {% if row.is_active %}bg-emerald-50 text-emerald-700{% else %}bg-slate-100 text-slate-600{% endif %}">{{ 'Active' if row.is_active else 'Inactive' }}</span></td>
<td class="px-5 py-4 text-right">
{% if 'accounting.taxonomy.manage' in current_user_permissions %}
<form method="post" action="/tools/accounting/taxonomy/{{ row.id }}/active" class="inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="status" value="{{ selected_status }}" />
<input type="hidden" name="group" value="{{ selected_group }}" />
<input type="hidden" name="is_active" value="{{ 'false' if row.is_active else 'true' }}" />
<button type="submit" class="rounded-lg border px-3 py-1.5 text-xs font-semibold {% if row.is_active %}border-slate-300 bg-white text-slate-700 hover:bg-slate-50{% else %}border-emerald-200 bg-emerald-50 text-emerald-700 hover:bg-emerald-100{% endif %}">{{ 'Deactivate' if row.is_active else 'Activate' }}</button>
</form>
{% else %}<span class="text-xs text-slate-400">View only</span>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% else %}
<div class="rounded-2xl bg-white p-8 text-center text-sm text-slate-500 shadow-soft">No accounting natures match the selected filters.</div>
{% endfor %}
</div>
<div class="rounded-xl border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-800">Phase 4 is classification-only. Activating or deactivating a nature changes whether future intelligence workflows may select it; it does not create, rename, delete or post any Tally ledger or voucher.</div>
</div>
{% endblock %}
@@ -154,6 +154,8 @@ PERMISSIONS = {
"accounting.tally.sync_transactions": "Synchronize Tally Accounting Transactions", "accounting.tally.sync_transactions": "Synchronize Tally Accounting Transactions",
"accounting.tally.depreciation_it": "Calculate Income-tax Depreciation", "accounting.tally.depreciation_it": "Calculate Income-tax Depreciation",
"accounting.tally.writeback": "Approve and Post Accounting Entries to Tally", "accounting.tally.writeback": "Approve and Post Accounting Entries to Tally",
"accounting.taxonomy.view": "View Accounting Classification Taxonomy",
"accounting.taxonomy.manage": "Activate or Deactivate Accounting Classification Natures",
"accounting.act.initialize": "Initialize Client Accounting ACT Storage", "accounting.act.initialize": "Initialize Client Accounting ACT Storage",
"notice_cases.view": "View Notice and Case Management", "notice_cases.view": "View Notice and Case Management",
+2
View File
@@ -37,6 +37,7 @@ from app.modules.aqmm_dashboard.ui import router as aqmm_dashboard_router
from app.modules.peer_review_export.ui import router as peer_review_export_router from app.modules.peer_review_export.ui import router as peer_review_export_router
from app.modules.bank_statement_analyzer.ui import router as bank_statement_analyzer_router from app.modules.bank_statement_analyzer.ui import router as bank_statement_analyzer_router
from app.modules.accounting.ui import router as accounting_ui_router from app.modules.accounting.ui import router as accounting_ui_router
from app.modules.accounting.taxonomy_ui import router as accounting_taxonomy_ui_router
from app.modules.registrations.ui import router as registrations_ui_router from app.modules.registrations.ui import router as registrations_ui_router
from app.modules.credential_vault.ui import router as credential_vault_ui_router from app.modules.credential_vault.ui import router as credential_vault_ui_router
from app.modules.client_identity.ui import router as client_identity_ui_router from app.modules.client_identity.ui import router as client_identity_ui_router
@@ -60,6 +61,7 @@ def mount_ui(app: FastAPI) -> None:
app.include_router(peer_review_export_router) app.include_router(peer_review_export_router)
app.include_router(bank_statement_analyzer_router) app.include_router(bank_statement_analyzer_router)
app.include_router(accounting_ui_router) app.include_router(accounting_ui_router)
app.include_router(accounting_taxonomy_ui_router)
app.include_router(work_tracker_ui_router) app.include_router(work_tracker_ui_router)
app.include_router(billing_ui_router) app.include_router(billing_ui_router)
app.include_router(platform_billing_ui_router) app.include_router(platform_billing_ui_router)