diff --git a/alembic/versions/20260822_accounting_taxonomy_phase4.py b/alembic/versions/20260822_accounting_taxonomy_phase4.py new file mode 100644 index 0000000..52790ff --- /dev/null +++ b/alembic/versions/20260822_accounting_taxonomy_phase4.py @@ -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") diff --git a/app/core/startup.py b/app/core/startup.py index 200d3f4..03b01a9 100644 --- a/app/core/startup.py +++ b/app/core/startup.py @@ -340,6 +340,8 @@ ROLE_PERMISSION_MAP = { "accounting.tally.sync_transactions", "accounting.tally.depreciation_it", "accounting.tally.writeback", + "accounting.taxonomy.view", + "accounting.taxonomy.manage", "accounting.act.initialize", "clients.view.own_only", "employees.dashboard.view", diff --git a/app/modules/accounting/taxonomy_models.py b/app/modules/accounting/taxonomy_models.py new file mode 100644 index 0000000..6e94422 --- /dev/null +++ b/app/modules/accounting/taxonomy_models.py @@ -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) diff --git a/app/modules/accounting/taxonomy_service.py b/app/modules/accounting/taxonomy_service.py new file mode 100644 index 0000000..308a18f --- /dev/null +++ b/app/modules/accounting/taxonomy_service.py @@ -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 diff --git a/app/modules/accounting/taxonomy_ui.py b/app/modules/accounting/taxonomy_ui.py new file mode 100644 index 0000000..f846cb2 --- /dev/null +++ b/app/modules/accounting/taxonomy_ui.py @@ -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() diff --git a/app/modules/accounting/templates/accounting/tally.html b/app/modules/accounting/templates/accounting/tally.html index 5e88099..790e6b5 100644 --- a/app/modules/accounting/templates/accounting/tally.html +++ b/app/modules/accounting/templates/accounting/tally.html @@ -13,6 +13,7 @@
Company mapping, master sync and transaction sync remain available. Phase 5 adds review-only accounting tools without Tally write-back.
diff --git a/app/modules/accounting/templates/accounting/taxonomy.html b/app/modules/accounting/templates/accounting/taxonomy.html new file mode 100644 index 0000000..9d632d1 --- /dev/null +++ b/app/modules/accounting/templates/accounting/taxonomy.html @@ -0,0 +1,90 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +Accounting Intelligence · Phase 4
+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.
+{{ section.root.code }} · {{ section.root.description or '' }}
| Accounting Nature | Stable Code | Treatment | Status | Control |
|---|---|---|---|---|
{{ row.name }} {{ row.description or '' }} |
+ {{ row.code }} |
+ {{ row.capital_revenue|title }} | +{{ 'Active' if row.is_active else 'Inactive' }} | ++ {% if 'accounting.taxonomy.manage' in current_user_permissions %} + + {% else %}View only{% endif %} + | +