diff --git a/alembic/versions/20260921_financial_statements_reporting.py b/alembic/versions/20260921_financial_statements_reporting.py new file mode 100644 index 0000000..f548850 --- /dev/null +++ b/alembic/versions/20260921_financial_statements_reporting.py @@ -0,0 +1,65 @@ +"""Add mirror-driven financial statement reporting configuration. + +Revision ID: 20260921_financial_statements +Revises: f8aac1809dcd +""" +from alembic import op +import sqlalchemy as sa + +revision = "20260921_financial_statements" +down_revision = "f8aac1809dcd" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "accounting_financial_ledger_mappings", + 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("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False), + sa.Column("ledger_name", sa.String(length=255), nullable=False), + sa.Column("statement_code", sa.String(length=20), nullable=False), + sa.Column("schedule_code", sa.String(length=60), nullable=False), + sa.Column("display_group", sa.String(length=160), nullable=False, server_default=""), + 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.text("CURRENT_TIMESTAMP")), + sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")), + sa.UniqueConstraint("tenant_id", "client_id", "ledger_name", name="uq_accounting_fs_mapping_client_ledger"), + ) + op.create_index("ix_accounting_financial_ledger_mappings_tenant_id", "accounting_financial_ledger_mappings", ["tenant_id"]) + op.create_index("ix_accounting_financial_ledger_mappings_client_id", "accounting_financial_ledger_mappings", ["client_id"]) + op.create_index("ix_accounting_financial_ledger_mappings_ledger_name", "accounting_financial_ledger_mappings", ["ledger_name"]) + op.create_index("ix_accounting_financial_ledger_mappings_statement_code", "accounting_financial_ledger_mappings", ["statement_code"]) + op.create_index("ix_accounting_financial_ledger_mappings_schedule_code", "accounting_financial_ledger_mappings", ["schedule_code"]) + + op.create_table( + "accounting_partnership_profit_shares", + 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("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False), + sa.Column("financial_year", sa.String(length=7), nullable=False), + sa.Column("partner_name", sa.String(length=200), nullable=False), + sa.Column("profit_share_percent", sa.Numeric(7, 4), nullable=False, server_default="0"), + sa.Column("target_ledger_name", sa.String(length=255), nullable=False, server_default=""), + 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.text("CURRENT_TIMESTAMP")), + sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")), + sa.UniqueConstraint("tenant_id", "client_id", "financial_year", "partner_name", name="uq_accounting_partner_share_client_fy_name"), + ) + op.create_index("ix_accounting_partnership_profit_shares_tenant_id", "accounting_partnership_profit_shares", ["tenant_id"]) + op.create_index("ix_accounting_partnership_profit_shares_client_id", "accounting_partnership_profit_shares", ["client_id"]) + op.create_index("ix_accounting_partnership_profit_shares_financial_year", "accounting_partnership_profit_shares", ["financial_year"]) + + +def downgrade() -> None: + op.drop_index("ix_accounting_partnership_profit_shares_financial_year", table_name="accounting_partnership_profit_shares") + op.drop_index("ix_accounting_partnership_profit_shares_client_id", table_name="accounting_partnership_profit_shares") + op.drop_index("ix_accounting_partnership_profit_shares_tenant_id", table_name="accounting_partnership_profit_shares") + op.drop_table("accounting_partnership_profit_shares") + op.drop_index("ix_accounting_financial_ledger_mappings_schedule_code", table_name="accounting_financial_ledger_mappings") + op.drop_index("ix_accounting_financial_ledger_mappings_statement_code", table_name="accounting_financial_ledger_mappings") + op.drop_index("ix_accounting_financial_ledger_mappings_ledger_name", table_name="accounting_financial_ledger_mappings") + op.drop_index("ix_accounting_financial_ledger_mappings_client_id", table_name="accounting_financial_ledger_mappings") + op.drop_index("ix_accounting_financial_ledger_mappings_tenant_id", table_name="accounting_financial_ledger_mappings") + op.drop_table("accounting_financial_ledger_mappings") diff --git a/app/modules/accounting/financial_statements_models.py b/app/modules/accounting/financial_statements_models.py new file mode 100644 index 0000000..0df0d39 --- /dev/null +++ b/app/modules/accounting/financial_statements_models.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +from sqlalchemy import DateTime, ForeignKey, Integer, Numeric, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.db.common import CommonBase + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +class AccountingFinancialLedgerMapping(CommonBase): + """Client-specific reporting override for a mirrored Tally ledger. + + The default classifier remains Tally-group driven. Rows in this table are + only explicit overrides and therefore remain reusable across financial years. + """ + + __tablename__ = "accounting_financial_ledger_mappings" + __table_args__ = ( + UniqueConstraint("tenant_id", "client_id", "ledger_name", name="uq_accounting_fs_mapping_client_ledger"), + ) + + 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) + client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True) + ledger_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + statement_code: Mapped[str] = mapped_column(String(20), nullable=False, index=True) + schedule_code: Mapped[str] = mapped_column(String(60), nullable=False, index=True) + display_group: Mapped[str] = mapped_column(String(160), nullable=False, default="") + 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), nullable=False, default=_utcnow) + updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=_utcnow, onupdate=_utcnow) + + +class AccountingPartnershipProfitShare(CommonBase): + """Client/FY partner profit-sharing configuration used only for finalisation.""" + + __tablename__ = "accounting_partnership_profit_shares" + __table_args__ = ( + UniqueConstraint( + "tenant_id", "client_id", "financial_year", "partner_name", + name="uq_accounting_partner_share_client_fy_name", + ), + ) + + 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) + client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True) + financial_year: Mapped[str] = mapped_column(String(7), nullable=False, index=True) + partner_name: Mapped[str] = mapped_column(String(200), nullable=False) + profit_share_percent: Mapped[float] = mapped_column(Numeric(7, 4), nullable=False, default=0) + target_ledger_name: Mapped[str] = mapped_column(String(255), nullable=False, default="") + 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), nullable=False, default=_utcnow) + updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=_utcnow, onupdate=_utcnow) diff --git a/app/modules/accounting/financial_statements_service.py b/app/modules/accounting/financial_statements_service.py new file mode 100644 index 0000000..f8a9843 --- /dev/null +++ b/app/modules/accounting/financial_statements_service.py @@ -0,0 +1,619 @@ +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass +from io import BytesIO +from typing import Any, Iterable + +from openpyxl import Workbook +from openpyxl.styles import Alignment, Border, Font, PatternFill, Side +from openpyxl.utils import get_column_letter +from sqlalchemy import select + +from app.modules.accounting.accounting_mirror_service import mirror_query, trial_balance +from app.modules.accounting.financial_statements_models import AccountingFinancialLedgerMapping + + +# Canonical schedules. The labels intentionally follow the conventional +# financial-statement presentation seen in the firm's existing statements. +SCHEDULES: dict[str, dict[str, Any]] = { + "PARTNER_FUNDS": {"statement": "BS", "label": "Partner's / Proprietor's Funds", "bs_side": "LIABILITY", "order": 10}, + "RESERVES": {"statement": "BS", "label": "Reserves & Surplus", "bs_side": "LIABILITY", "order": 20}, + "NONCURRENT_BORROWINGS": {"statement": "BS", "label": "Long-Term Borrowings", "bs_side": "LIABILITY", "order": 30}, + "SHORT_TERM_BORROWINGS": {"statement": "BS", "label": "Short-Term Borrowings", "bs_side": "LIABILITY", "order": 40}, + "TRADE_PAYABLES": {"statement": "BS", "label": "Trade Payables", "bs_side": "LIABILITY", "order": 50}, + "STATUTORY_LIABILITIES": {"statement": "BS", "label": "Statutory Liabilities", "bs_side": "LIABILITY", "order": 60}, + "PROVISIONS": {"statement": "BS", "label": "Short-Term Provisions", "bs_side": "LIABILITY", "order": 70}, + "OTHER_CURRENT_LIABILITIES": {"statement": "BS", "label": "Other Current Liabilities", "bs_side": "LIABILITY", "order": 80}, + "PPE": {"statement": "BS", "label": "Property, Plant and Equipment", "bs_side": "ASSET", "order": 110}, + "INVESTMENTS": {"statement": "BS", "label": "Investments", "bs_side": "ASSET", "order": 120}, + "INVENTORIES": {"statement": "BS", "label": "Inventories", "bs_side": "ASSET", "order": 130}, + "LOANS_ADVANCES": {"statement": "BS", "label": "Short-Term Loans and Advances", "bs_side": "ASSET", "order": 140}, + "TRADE_RECEIVABLES": {"statement": "BS", "label": "Trade Receivables", "bs_side": "ASSET", "order": 150}, + "CASH_BANK": {"statement": "BS", "label": "Cash and Bank Balances", "bs_side": "ASSET", "order": 160}, + "OTHER_CURRENT_ASSETS": {"statement": "BS", "label": "Other Current Assets", "bs_side": "ASSET", "order": 170}, + "MISC_ASSET": {"statement": "BS", "label": "Miscellaneous Expenditure / Other Assets", "bs_side": "ASSET", "order": 180}, + "REVENUE": {"statement": "PL", "label": "Revenue from operations", "pl_side": "INCOME", "order": 210}, + "OTHER_INCOME": {"statement": "PL", "label": "Other Income", "pl_side": "INCOME", "order": 220}, + "PURCHASES": {"statement": "PL", "label": "Purchases", "pl_side": "EXPENSE", "order": 230}, + "DIRECT_EXPENSES": {"statement": "PL", "label": "Direct Expenses", "pl_side": "EXPENSE", "order": 240}, + "EMPLOYEE_BENEFITS": {"statement": "PL", "label": "Employee Benefits Expense", "pl_side": "EXPENSE", "order": 250}, + "FINANCE_COST": {"statement": "PL", "label": "Finance Cost", "pl_side": "EXPENSE", "order": 260}, + "DEPRECIATION": {"statement": "PL", "label": "Depreciation and Amortization Expense", "pl_side": "EXPENSE", "order": 270}, + "OTHER_EXPENSES": {"statement": "PL", "label": "Other Expenses", "pl_side": "EXPENSE", "order": 280}, + "UNMAPPED": {"statement": "UNMAPPED", "label": "Unmapped / Review Required", "order": 999}, +} + +MAPPING_CHOICES = [(code, meta["label"], meta["statement"]) for code, meta in SCHEDULES.items() if code != "UNMAPPED"] + + +def _norm(value: Any) -> str: + return " ".join(str(value or "").strip().lower().replace("&", "and").split()) + + +def _amount(value: Any) -> float: + try: + return round(float(value or 0), 2) + except (TypeError, ValueError): + return 0.0 + + +def _root_group(group_name: str, parents: dict[str, str]) -> str: + current = str(group_name or "").strip() + if not current: + return "" + seen: set[str] = set() + last = current + while current and _norm(current) not in seen: + seen.add(_norm(current)) + last = current + parent = parents.get(_norm(current), "") + if not parent or _norm(parent) in {"primary", ""}: + break + current = parent + return last + + +def _keyword_any(text: str, words: Iterable[str]) -> bool: + value = _norm(text) + return any(_norm(word) in value for word in words) + + +def classify_default(ledger_name: str, parent_group: str, root_group: str) -> str: + ledger = _norm(ledger_name) + parent = _norm(parent_group) + root = _norm(root_group) + combined = f"{ledger} {parent} {root}" + + if root in {"sales accounts", "sales account"}: + return "REVENUE" + if root in {"purchase accounts", "purchase account"}: + return "PURCHASES" + if root in {"direct expenses", "direct expense"}: + return "DIRECT_EXPENSES" + if root in {"direct incomes", "direct income"}: + return "REVENUE" + if root in {"indirect incomes", "indirect income"}: + return "OTHER_INCOME" + if root in {"indirect expenses", "indirect expense"}: + if _keyword_any(combined, ["salary", "salaries", "wages", "bonus", "staff welfare", "employee", "remuneration"]): + return "EMPLOYEE_BENEFITS" + if _keyword_any(combined, ["interest", "bank charge", "finance charge", "loan processing", "od interest"]): + return "FINANCE_COST" + if _keyword_any(combined, ["depreciation", "amortisation", "amortization"]): + return "DEPRECIATION" + return "OTHER_EXPENSES" + + if root in {"capital account", "capital accounts"}: + if "reserve" in combined or "surplus" in combined: + return "RESERVES" + return "PARTNER_FUNDS" + if root in {"loans (liability)", "loans liability"}: + if _keyword_any(combined, ["bank od", "overdraft", "cash credit", "cc a/c", "cc account", "working capital"]): + return "SHORT_TERM_BORROWINGS" + return "NONCURRENT_BORROWINGS" + if root in {"current liabilities", "current liability"}: + if parent in {"sundry creditors", "sundry creditor"} or "creditor" in combined or "trade payable" in combined: + return "TRADE_PAYABLES" + if parent in {"duties and taxes", "duties & taxes"} or _keyword_any(combined, ["gst", "tds", "tcs", "pf payable", "esi payable", "tax payable"]): + return "STATUTORY_LIABILITIES" + if parent == "provisions" or "provision" in combined or "payable" in ledger: + return "PROVISIONS" + return "OTHER_CURRENT_LIABILITIES" + if root in {"fixed assets", "fixed asset"}: + return "PPE" + if root in {"investments", "investment"}: + return "INVESTMENTS" + if root in {"current assets", "current asset"}: + if parent in {"stock-in-hand", "stock in hand"} or _keyword_any(combined, ["stock in hand", "inventory", "closing stock"]): + return "INVENTORIES" + if parent in {"sundry debtors", "sundry debtor"} or "debtor" in combined or "trade receivable" in combined: + return "TRADE_RECEIVABLES" + if parent in {"cash-in-hand", "cash in hand", "bank accounts", "bank account"} or _keyword_any(combined, ["cash in hand", "bank account"]): + return "CASH_BANK" + if parent in {"loans and advances (asset)", "loans & advances (asset)", "deposits (asset)", "deposits asset"} or _keyword_any(combined, ["deposit", "advance tax", "tds receivable", "tcs receivable", "gst input", "advance"]): + return "LOANS_ADVANCES" + return "OTHER_CURRENT_ASSETS" + if root in {"misc. expenses (asset)", "misc expenses (asset)", "suspense a/c", "suspense account"}: + return "MISC_ASSET" + if root in {"branch/divisions", "branch / divisions"}: + return "OTHER_CURRENT_ASSETS" + return "UNMAPPED" + + +@dataclass +class LedgerLine: + ledger_name: str + parent_group: str + root_group: str + opening_balance: float + debit: float + credit: float + closing_balance: float + opening_debit: float + opening_credit: float + closing_debit: float + closing_credit: float + schedule_code: str + schedule_label: str + statement_code: str + mapped_by: str + + @property + def closing_abs(self) -> float: + return abs(self.closing_balance) + + +def _balance_split(value: float) -> tuple[float, float]: + # Tally mirror convention: positive balances are debit, negative are credit. + value = _amount(value) + return (value if value > 0 else 0.0, abs(value) if value < 0 else 0.0) + + +def read_overrides(db, *, tenant_id: int, client_id: int) -> dict[str, AccountingFinancialLedgerMapping]: + rows = db.execute( + select(AccountingFinancialLedgerMapping).where( + AccountingFinancialLedgerMapping.tenant_id == int(tenant_id), + AccountingFinancialLedgerMapping.client_id == int(client_id), + ) + ).scalars().all() + return {_norm(row.ledger_name): row for row in rows} + + +def load_mirror_trial_balance( + *, node_code: str, accounting_payload: dict[str, Any], db=None, tenant_id: int = 0, client_id: int = 0 +) -> dict[str, Any]: + tb = trial_balance(node_code=node_code, accounting_payload=accounting_payload, limit=5000) + group_rows: list[dict[str, Any]] = [] + hierarchy_warning = "" + try: + groups = mirror_query( + node_code=node_code, + accounting_payload=accounting_payload, + query="group_master", + filters={"limit": 5000}, + ) + group_rows = list(groups.get("rows") or []) + except Exception as exc: + hierarchy_warning = str(exc) + + parents = { + _norm(row.get("name")): str(row.get("parent") or "").strip() + for row in group_rows + if str(row.get("name") or "").strip() + } + overrides = read_overrides(db, tenant_id=tenant_id, client_id=client_id) if db is not None else {} + + lines: list[LedgerLine] = [] + for row in tb.get("rows") or []: + name = str(row.get("ledger_name") or "").strip() + if not name: + continue + parent = str(row.get("parent_group") or "").strip() + root = _root_group(parent, parents) if parents else parent + override = overrides.get(_norm(name)) + if override and override.schedule_code in SCHEDULES: + code = override.schedule_code + mapped_by = "manual" + else: + code = classify_default(name, parent, root) + mapped_by = "tally_group" + meta = SCHEDULES.get(code, SCHEDULES["UNMAPPED"]) + opening = _amount(row.get("opening_balance")) + closing = _amount(row.get("closing_balance")) + od, oc = _balance_split(opening) + cd, cc = _balance_split(closing) + lines.append(LedgerLine( + ledger_name=name, + parent_group=parent, + root_group=root, + opening_balance=opening, + debit=_amount(row.get("debit")), + credit=_amount(row.get("credit")), + closing_balance=closing, + opening_debit=od, + opening_credit=oc, + closing_debit=cd, + closing_credit=cc, + schedule_code=code, + schedule_label=str(meta.get("label") or code), + statement_code=str(meta.get("statement") or "UNMAPPED"), + mapped_by=mapped_by, + )) + return { + "lines": lines, + "group_count": len(group_rows), + "hierarchy_warning": hierarchy_warning, + "count": len(lines), + } + + +def previous_financial_year(financial_year: str) -> str: + start = int(str(financial_year).split("-")[0]) + return f"{start - 1}-{str(start)[-2:]}" + + +def _schedule_totals(lines: list[LedgerLine]) -> dict[str, float]: + totals: dict[str, float] = defaultdict(float) + for line in lines: + totals[line.schedule_code] += line.closing_abs + return {k: round(v, 2) for k, v in totals.items()} + + +def _inventory_values(lines: list[LedgerLine]) -> tuple[float, float]: + opening = sum(abs(x.opening_balance) for x in lines if x.schedule_code == "INVENTORIES") + closing = sum(abs(x.closing_balance) for x in lines if x.schedule_code == "INVENTORIES") + return round(opening, 2), round(closing, 2) + + +def build_financial_statements( + *, current_lines: list[LedgerLine], previous_lines: list[LedgerLine] | None = None, + partnership_shares: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + previous_lines = previous_lines or [] + current_totals = _schedule_totals(current_lines) + previous_totals = _schedule_totals(previous_lines) + + def pl_values(lines: list[LedgerLine]) -> dict[str, float]: + totals = _schedule_totals(lines) + opening_stock, closing_stock = _inventory_values(lines) + purchases = totals.get("PURCHASES", 0.0) + cost_of_goods = round(opening_stock + purchases - closing_stock, 2) + revenue = totals.get("REVENUE", 0.0) + other_income = totals.get("OTHER_INCOME", 0.0) + direct = totals.get("DIRECT_EXPENSES", 0.0) + employee = totals.get("EMPLOYEE_BENEFITS", 0.0) + finance = totals.get("FINANCE_COST", 0.0) + depreciation = totals.get("DEPRECIATION", 0.0) + other_exp = totals.get("OTHER_EXPENSES", 0.0) + total_income = round(revenue + other_income, 2) + total_expenses = round(cost_of_goods + direct + employee + finance + depreciation + other_exp, 2) + profit = round(total_income - total_expenses, 2) + return { + "REVENUE": revenue, "OTHER_INCOME": other_income, + "OPENING_STOCK": opening_stock, "PURCHASES": purchases, "CLOSING_STOCK": closing_stock, + "COST_OF_GOODS_SOLD": cost_of_goods, "DIRECT_EXPENSES": direct, + "EMPLOYEE_BENEFITS": employee, "FINANCE_COST": finance, + "DEPRECIATION": depreciation, "OTHER_EXPENSES": other_exp, + "TOTAL_INCOME": total_income, "TOTAL_EXPENSES": total_expenses, "PROFIT": profit, + } + + current_pl = pl_values(current_lines) + previous_pl = pl_values(previous_lines) + + allocations: list[dict[str, Any]] = [] + shares = partnership_shares or [] + ratio_total = round(sum(float(x.get("profit_share_percent") or 0) for x in shares), 4) + if shares and abs(ratio_total - 100.0) < 0.0001: + remaining = current_pl["PROFIT"] + for idx, share in enumerate(shares): + if idx == len(shares) - 1: + amount = round(remaining, 2) + else: + amount = round(current_pl["PROFIT"] * float(share.get("profit_share_percent") or 0) / 100.0, 2) + remaining = round(remaining - amount, 2) + allocations.append({**share, "amount": amount}) + + # The mirror may contain either pre-appropriation or already-finalised capital balances. + # Do not blindly add profit to capital: first compare the raw Balance Sheet gap with + # the P&L result. If Assets - Liabilities equals current-year profit/loss, the books + # are pre-appropriation and the ERP adds that result to Partner/Proprietor Funds. + # If the Balance Sheet already balances, the profit is already reflected in the + # source capital/current accounts and no duplicate adjustment is posted. + allocation_total = round(sum(x["amount"] for x in allocations), 2) + liability_codes = [k for k,v in SCHEDULES.items() if v.get("statement") == "BS" and v.get("bs_side") == "LIABILITY"] + asset_codes = [k for k,v in SCHEDULES.items() if v.get("statement") == "BS" and v.get("bs_side") == "ASSET"] + + current_bs = dict(current_totals) + raw_current_liabilities = round(sum(current_bs.get(k,0.0) for k in liability_codes),2) + current_assets = round(sum(current_bs.get(k,0.0) for k in asset_codes),2) + current_gap = round(current_assets - raw_current_liabilities, 2) + current_profit = round(current_pl["PROFIT"], 2) + tolerance = max(1.0, abs(current_profit) * 0.0001) + profit_transfer_applied = False + profit_transfer_status = "already_reflected" if abs(current_gap) <= tolerance else "review_required" + if abs(current_gap - current_profit) <= tolerance: + current_bs["PARTNER_FUNDS"] = round(current_bs.get("PARTNER_FUNDS", 0.0) + current_profit, 2) + profit_transfer_applied = True + profit_transfer_status = "erp_finalisation_adjustment" + current_liabilities = round(sum(current_bs.get(k,0.0) for k in liability_codes),2) + + previous_bs = dict(previous_totals) + raw_previous_liabilities = round(sum(previous_bs.get(k,0.0) for k in liability_codes),2) + previous_assets = round(sum(previous_bs.get(k,0.0) for k in asset_codes),2) + previous_gap = round(previous_assets - raw_previous_liabilities, 2) + previous_profit = round(previous_pl["PROFIT"], 2) + previous_tolerance = max(1.0, abs(previous_profit) * 0.0001) + if abs(previous_gap - previous_profit) <= previous_tolerance: + previous_bs["PARTNER_FUNDS"] = round(previous_bs.get("PARTNER_FUNDS", 0.0) + previous_profit, 2) + previous_liabilities = round(sum(previous_bs.get(k,0.0) for k in liability_codes),2) + + return { + "current_totals": current_totals, + "previous_totals": previous_totals, + "previous_bs": previous_bs, + "current_pl": current_pl, + "previous_pl": previous_pl, + "current_bs": current_bs, + "current_liabilities": current_liabilities, + "current_assets": current_assets, + "previous_liabilities": previous_liabilities, + "previous_assets": previous_assets, + "partnership_allocations": allocations, + "profit_share_total": ratio_total, + "profit_transfer_applied": profit_transfer_applied, + "profit_transfer_status": profit_transfer_status, + "balance_sheet_difference": round(current_assets - current_liabilities, 2), + "unmapped": [x for x in current_lines if x.schedule_code == "UNMAPPED"], + } + + +THIN = Side(style="thin", color="000000") +MEDIUM = Side(style="medium", color="000000") +DOUBLE = Side(style="double", color="000000") + + +def _set_page(ws, *, landscape: bool = False, repeat_rows: str | None = None) -> None: + ws.sheet_view.showGridLines = False + ws.freeze_panes = "A5" + ws.page_setup.orientation = "landscape" if landscape else "portrait" + ws.page_setup.paperSize = ws.PAPERSIZE_A4 + ws.page_setup.fitToWidth = 1 + ws.page_setup.fitToHeight = 0 + ws.sheet_properties.pageSetUpPr.fitToPage = True + ws.print_options.horizontalCentered = True + ws.page_margins.left = 0.25 + ws.page_margins.right = 0.25 + ws.page_margins.top = 0.35 + ws.page_margins.bottom = 0.35 + if repeat_rows: + ws.print_title_rows = repeat_rows + + +def _title(ws, client_name: str, address: str, title: str, end_date: str, cols: int = 4) -> int: + ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=cols) + ws.cell(1,1,client_name).font = Font(bold=True, size=11) + ws.merge_cells(start_row=2, start_column=1, end_row=2, end_column=cols) + ws.cell(2,1,address).font = Font(size=9) + ws.merge_cells(start_row=3, start_column=1, end_row=3, end_column=cols) + ws.cell(3,1,f"{title} for the Year Ended {end_date}").font = Font(bold=True, underline="single", size=10) + return 5 + + +def _money(cell, value: float) -> None: + cell.value = round(float(value or 0),2) + cell.number_format = '#,##0;[Red](#,##0);-' + cell.alignment = Alignment(horizontal="right") + + +def _apply_table_border(ws, min_row: int, max_row: int, min_col: int, max_col: int, outer_medium: bool = True) -> None: + for row in ws.iter_rows(min_row=min_row, max_row=max_row, min_col=min_col, max_col=max_col): + for cell in row: + left = MEDIUM if outer_medium and cell.column == min_col else THIN + right = MEDIUM if outer_medium and cell.column == max_col else THIN + top = MEDIUM if outer_medium and cell.row == min_row else THIN + bottom = MEDIUM if outer_medium and cell.row == max_row else THIN + cell.border = Border(left=left,right=right,top=top,bottom=bottom) + + +def _header_row(ws, row: int, labels: list[str]) -> None: + for col, label in enumerate(labels,1): + c=ws.cell(row,col,label) + c.font=Font(bold=True,size=9) + c.alignment=Alignment(horizontal="center", vertical="center", wrap_text=True) + c.fill=PatternFill("solid", fgColor="F2F2F2") + c.border=Border(top=MEDIUM,bottom=MEDIUM,left=THIN,right=THIN) + + +def _schedule_sheet_name(note_no: int, label: str) -> str: + clean = ''.join(ch for ch in label if ch not in '[]:*?/\\') + return f"N{note_no:02d} {clean}"[:31] + + +def build_excel_workbook( + *, client_name: str, address: str, financial_year: str, previous_fy: str, + current_lines: list[LedgerLine], previous_lines: list[LedgerLine], + statements: dict[str, Any], partnership_shares: list[dict[str, Any]] | None = None, +) -> bytes: + wb=Workbook() + wb.remove(wb.active) + start_year=int(financial_year.split('-')[0]) + current_end=f"31st March {start_year+1}" + previous_end=f"31st March {start_year}" + + schedule_codes=[] + for code,meta in sorted(SCHEDULES.items(), key=lambda kv:int(kv[1].get('order',999))): + if code=="UNMAPPED": + continue + if any(x.schedule_code==code for x in current_lines) or any(x.schedule_code==code for x in previous_lines): + schedule_codes.append(code) + note_map={code:i+1 for i,code in enumerate(schedule_codes)} + + # Trial Balance + ws=wb.create_sheet("Trial Balance") + _set_page(ws, landscape=True, repeat_rows="1:5") + _title(ws,client_name,address,"Trial Balance",current_end,8) + _header_row(ws,5,["Ledger","Tally Group","Opening Dr","Opening Cr","Current Dr","Current Cr","Closing Dr","Closing Cr"]) + r=6 + for line in sorted(current_lines,key=lambda x:(x.root_group.lower(),x.parent_group.lower(),x.ledger_name.lower())): + ws.cell(r,1,line.ledger_name); ws.cell(r,2,line.parent_group) + for col,val in [(3,line.opening_debit),(4,line.opening_credit),(5,line.debit),(6,line.credit),(7,line.closing_debit),(8,line.closing_credit)]: _money(ws.cell(r,col),val) + r+=1 + ws.cell(r,1,"Total").font=Font(bold=True) + for col in range(3,9): + _money(ws.cell(r,col),sum(float(ws.cell(rr,col).value or 0) for rr in range(6,r))) + ws.cell(r,col).font=Font(bold=True); ws.cell(r,col).border=Border(top=DOUBLE,bottom=DOUBLE) + _apply_table_border(ws,5,r,1,8) + widths=[34,24,14,14,14,14,14,14] + for i,w in enumerate(widths,1): ws.column_dimensions[get_column_letter(i)].width=w + + # Balance Sheet in sample format + ws=wb.create_sheet("Balance Sheet") + _set_page(ws, repeat_rows="1:5") + _title(ws,client_name,address,"Statement of Balance Sheet",current_end,4) + _header_row(ws,5,["Particulars","Note",current_end,previous_end]) + r=6 + ws.cell(r,1,"I EQUITY AND LIABILITIES").font=Font(bold=True); r+=1 + liability_codes=[c for c in schedule_codes if SCHEDULES[c].get("bs_side")=="LIABILITY"] + for code in liability_codes: + meta=SCHEDULES[code]; ws.cell(r,1,meta["label"]); ws.cell(r,2,note_map[code]) + ws.cell(r,2).hyperlink=f"#'{_schedule_sheet_name(note_map[code],meta['label'])}'!A1" + _money(ws.cell(r,3),statements["current_bs"].get(code,0)); _money(ws.cell(r,4),statements.get("previous_bs", statements["previous_totals"]).get(code,0)); r+=1 + ws.cell(r,1,"Total Liabilities").font=Font(bold=True) + _money(ws.cell(r,3),statements["current_liabilities"]); _money(ws.cell(r,4),statements["previous_liabilities"]) + for c in range(1,5): ws.cell(r,c).border=Border(top=DOUBLE,bottom=DOUBLE) + r+=2; ws.cell(r,1,"II ASSETS").font=Font(bold=True); r+=1 + asset_codes=[c for c in schedule_codes if SCHEDULES[c].get("bs_side")=="ASSET"] + for code in asset_codes: + meta=SCHEDULES[code]; ws.cell(r,1,meta["label"]); ws.cell(r,2,note_map[code]) + ws.cell(r,2).hyperlink=f"#'{_schedule_sheet_name(note_map[code],meta['label'])}'!A1" + _money(ws.cell(r,3),statements["current_bs"].get(code,0)); _money(ws.cell(r,4),statements.get("previous_bs", statements["previous_totals"]).get(code,0)); r+=1 + ws.cell(r,1,"Total Assets").font=Font(bold=True) + _money(ws.cell(r,3),statements["current_assets"]); _money(ws.cell(r,4),statements["previous_assets"]) + for c in range(1,5): ws.cell(r,c).border=Border(top=DOUBLE,bottom=DOUBLE) + r+=2; ws.merge_cells(start_row=r,start_column=1,end_row=r,end_column=4) + ws.cell(r,1,"The accompanying notes are an integral part of the financial statements").font=Font(italic=True,size=8) + _apply_table_border(ws,5,r-2,1,4) + for col,w in {1:46,2:9,3:18,4:18}.items(): ws.column_dimensions[get_column_letter(col)].width=w + + # Profit and Loss + ws=wb.create_sheet("Profit and Loss") + _set_page(ws, repeat_rows="1:5") + _title(ws,client_name,address,"Statement of Profit and Loss",current_end,4) + _header_row(ws,5,["Particulars","Note",current_end,previous_end]) + cp=statements["current_pl"]; pp=statements["previous_pl"] + rows=[ + ("I", "Revenue from operations", "REVENUE", "REVENUE"), + ("II", "Other Income", "OTHER_INCOME", "OTHER_INCOME"), + ("III", "Total Income (I+II)", None, "TOTAL_INCOME"), + ("IV", "Expenses:", None, None), + ("(a)", "Cost of goods sold", None, "COST_OF_GOODS_SOLD"), + ("(b)", "Direct Expenses", "DIRECT_EXPENSES", "DIRECT_EXPENSES"), + ("(c)", "Employee benefits expense", "EMPLOYEE_BENEFITS", "EMPLOYEE_BENEFITS"), + ("(d)", "Finance Cost", "FINANCE_COST", "FINANCE_COST"), + ("(e)", "Depreciation and amortization expense", "DEPRECIATION", "DEPRECIATION"), + ("(f)", "Other expenses", "OTHER_EXPENSES", "OTHER_EXPENSES"), + ("", "Total expenses", None, "TOTAL_EXPENSES"), + ("V", "Profit/(loss) before exceptional and extraordinary items (III-IV)", None, "PROFIT"), + ("VI", "Exceptional items", None, None), + ("VII", "Profit/(loss) before extraordinary items (V-VI)", None, "PROFIT"), + ("VIII", "Extraordinary items", None, None), + ("IX", "Profit before tax (VII-VIII)", None, "PROFIT"), + ("X", "Profit/(Loss) for the year", None, "PROFIT"), + ] + r=6 + for roman,label,note_code,key in rows: + ws.cell(r,1,f"{roman} {label}".strip()) + if roman in {"III","IV","V","VII","IX","X"} or label=="Total expenses": ws.cell(r,1).font=Font(bold=True) + if note_code and note_code in note_map: + ws.cell(r,2,note_map[note_code]); ws.cell(r,2).hyperlink=f"#'{_schedule_sheet_name(note_map[note_code],SCHEDULES[note_code]['label'])}'!A1" + if key: + _money(ws.cell(r,3),cp.get(key,0)); _money(ws.cell(r,4),pp.get(key,0)) + if roman in {"III","V","VII","IX","X"} or label=="Total expenses": + for c in range(1,5): ws.cell(r,c).border=Border(top=THIN,bottom=DOUBLE) + r+=1 + r+=1; ws.merge_cells(start_row=r,start_column=1,end_row=r,end_column=4) + ws.cell(r,1,"The accompanying notes are an integral part of the financial statements").font=Font(italic=True,size=8) + _apply_table_border(ws,5,r-2,1,4) + for col,w in {1:56,2:9,3:18,4:18}.items(): ws.column_dimensions[get_column_letter(col)].width=w + + # Cost of goods sold schedule (opening inventory + purchases - closing inventory). + ws=wb.create_sheet("Cost of Goods Sold") + _set_page(ws) + _title(ws,client_name,address,"Note - Cost of Goods Sold",current_end,3) + _header_row(ws,5,["Particulars",current_end,previous_end]) + cpl=statements["current_pl"]; ppl=statements["previous_pl"] + cogs_rows=[ + ("Inventory at the beginning of the year","OPENING_STOCK"), + ("Add: Purchases during the year","PURCHASES"), + ("Less: Inventory at the end of the year","CLOSING_STOCK"), + ("Cost of goods sold","COST_OF_GOODS_SOLD"), + ] + r=6 + for label,key in cogs_rows: + ws.cell(r,1,label); _money(ws.cell(r,2),cpl.get(key,0)); _money(ws.cell(r,3),ppl.get(key,0)) + if key=="COST_OF_GOODS_SOLD": + ws.cell(r,1).font=Font(bold=True) + for c in range(1,4): ws.cell(r,c).border=Border(top=DOUBLE,bottom=DOUBLE) + r+=1 + _apply_table_border(ws,5,r-1,1,3) + ws.column_dimensions['A'].width=52; ws.column_dimensions['B'].width=18; ws.column_dimensions['C'].width=18 + + # Notes/schedules + prev_by_name={_norm(x.ledger_name):x for x in previous_lines} + for code in schedule_codes: + meta=SCHEDULES[code]; note=note_map[code] + ws=wb.create_sheet(_schedule_sheet_name(note,meta["label"])) + _set_page(ws, repeat_rows="1:5") + _title(ws,client_name,address,f"Note - {note}: {meta['label']}",current_end,3) + _header_row(ws,5,["Particulars",current_end,previous_end]) + cur=[x for x in current_lines if x.schedule_code==code] + r=6 + for line in sorted(cur,key=lambda x:x.ledger_name.lower()): + ws.cell(r,1,line.ledger_name); _money(ws.cell(r,2),line.closing_abs) + old=prev_by_name.get(_norm(line.ledger_name)); _money(ws.cell(r,3),old.closing_abs if old else 0); r+=1 + if code=="PARTNER_FUNDS" and statements.get("profit_transfer_applied"): + if statements.get("partnership_allocations"): + for alloc in statements["partnership_allocations"]: + ws.cell(r,1,f"Add: Share of Profit / (Loss) - {alloc.get('partner_name')}") + _money(ws.cell(r,2),alloc.get("amount") or 0); _money(ws.cell(r,3),0); r+=1 + else: + ws.cell(r,1,"Add: Current Year Profit / (Loss)") + _money(ws.cell(r,2),statements.get("current_pl",{}).get("PROFIT",0)); _money(ws.cell(r,3),0); r+=1 + ws.cell(r,1,"Total").font=Font(bold=True) + _money(ws.cell(r,2),statements["current_bs"].get(code,statements["current_totals"].get(code,0))) + _money(ws.cell(r,3),statements.get("previous_bs", statements["previous_totals"]).get(code,0)) + for c in range(1,4): ws.cell(r,c).border=Border(top=DOUBLE,bottom=DOUBLE) + _apply_table_border(ws,5,r,1,3) + ws.column_dimensions['A'].width=52; ws.column_dimensions['B'].width=18; ws.column_dimensions['C'].width=18 + + if partnership_shares: + ws=wb.create_sheet("Partner Profit Allocation") + _set_page(ws) + _title(ws,client_name,address,"Partnership Profit Allocation",current_end,4) + _header_row(ws,5,["Partner","Profit Share %","Target Capital / Current Ledger","Share of Profit / (Loss)"]) + alloc_by_name={_norm(x.get('partner_name')):x for x in statements.get('partnership_allocations') or []} + r=6 + for share in partnership_shares: + ws.cell(r,1,share.get('partner_name')); ws.cell(r,2,float(share.get('profit_share_percent') or 0)); ws.cell(r,2).number_format='0.00%' + # Value stored as percent e.g. 20; Excel percentage needs /100 + ws.cell(r,2).value=float(share.get('profit_share_percent') or 0)/100.0 + ws.cell(r,3,share.get('target_ledger_name') or '') + alloc=alloc_by_name.get(_norm(share.get('partner_name'))); _money(ws.cell(r,4),(alloc or {}).get('amount') or 0); r+=1 + ws.cell(r,1,"Total").font=Font(bold=True); ws.cell(r,2,sum(float(x.get('profit_share_percent') or 0) for x in partnership_shares)/100.0); ws.cell(r,2).number_format='0.00%' + _money(ws.cell(r,4),sum(float((alloc_by_name.get(_norm(x.get('partner_name'))) or {}).get('amount') or 0) for x in partnership_shares)) + _apply_table_border(ws,5,r,1,4) + for col,w in {1:32,2:16,3:38,4:20}.items(): ws.column_dimensions[get_column_letter(col)].width=w + + if statements.get("unmapped"): + ws=wb.create_sheet("Unmapped Ledgers") + _set_page(ws, landscape=True) + _title(ws,client_name,address,"Unmapped Ledgers - Review Required",current_end,5) + _header_row(ws,5,["Ledger","Immediate Tally Group","Resolved Root Group","Closing Debit","Closing Credit"]) + r=6 + for line in statements["unmapped"]: + ws.cell(r,1,line.ledger_name); ws.cell(r,2,line.parent_group); ws.cell(r,3,line.root_group); _money(ws.cell(r,4),line.closing_debit); _money(ws.cell(r,5),line.closing_credit); r+=1 + _apply_table_border(ws,5,r-1,1,5) + for col,w in {1:35,2:28,3:28,4:18,5:18}.items(): ws.column_dimensions[get_column_letter(col)].width=w + + bio=BytesIO(); wb.save(bio); return bio.getvalue() diff --git a/app/modules/accounting/financial_statements_ui.py b/app/modules/accounting/financial_statements_ui.py new file mode 100644 index 0000000..f69f383 --- /dev/null +++ b/app/modules/accounting/financial_statements_ui.py @@ -0,0 +1,372 @@ +from __future__ import annotations + +from urllib.parse import urlencode + +from fastapi import APIRouter, Form, Request +from fastapi.responses import RedirectResponse, StreamingResponse +from sqlalchemy import delete, select + +from app.core.db.common import CommonSessionLocal +from app.core.security.csrf import get_or_create_csrf_token, validate_csrf +from app.core.templating import templates +from app.modules.accounting.accounting_mirror_service import get_registered_mirror +from app.modules.accounting.financial_statements_models import ( + AccountingFinancialLedgerMapping, + AccountingPartnershipProfitShare, +) +from app.modules.accounting.financial_statements_service import ( + MAPPING_CHOICES, + SCHEDULES, + build_excel_workbook, + build_financial_statements, + load_mirror_trial_balance, + previous_financial_year, +) +from app.modules.accounting.ui import ( + _active_financial_year, + _analysis_accounting_storage_payload, + _financial_year_options, + _find_visible_client, + _node_online, + _require_partner, + _visible_clients, +) +from app.modules.core.rbac.deps import get_user_permissions, get_user_roles +from app.modules.documents.services import get_active_storage_node_for_branch +from app.modules.registrations.models import ClientRelatedPerson + +router = APIRouter(prefix="/tools/accounting/financial-statements", tags=["accounting-financial-statements-ui"]) + + +def _go(client_id: int = 0, financial_year: str = "", message: str = "", error: str = ""): + q = {} + if client_id: + q["client_id"] = client_id + if financial_year: + q["financial_year"] = financial_year + if message: + q["message"] = message[:450] + if error: + q["error"] = error[:450] + return RedirectResponse("/tools/accounting/financial-statements" + ("?" + urlencode(q) if q else ""), status_code=303) + + +def _is_partnership(client) -> bool: + text = str(getattr(client, "client_type", "") or "").strip().lower() + return any(token in text for token in ("partnership", "firm", "llp")) + + +def _partner_shares(db, *, tenant_id: int, client_id: int, financial_year: str): + return db.execute( + select(AccountingPartnershipProfitShare) + .where( + AccountingPartnershipProfitShare.tenant_id == int(tenant_id), + AccountingPartnershipProfitShare.client_id == int(client_id), + AccountingPartnershipProfitShare.financial_year == financial_year, + ) + .order_by(AccountingPartnershipProfitShare.partner_name.asc()) + ).scalars().all() + + +def _share_payload(rows): + return [ + { + "partner_name": row.partner_name, + "profit_share_percent": float(row.profit_share_percent or 0), + "target_ledger_name": row.target_ledger_name or "", + } + for row in rows + ] + + +def _default_partners(db, *, tenant_id: int, client_id: int): + rows = db.execute( + select(ClientRelatedPerson).where( + ClientRelatedPerson.tenant_id == int(tenant_id), + ClientRelatedPerson.client_id == int(client_id), + ClientRelatedPerson.is_active.is_(True), + ).order_by(ClientRelatedPerson.full_name.asc()) + ).scalars().all() + return [row for row in rows if "partner" in str(row.person_type or "").lower()] + + +def _load_report(db, request: Request, user, client, scope, financial_year: str): + node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id) + if not node or not _node_online(node): + raise ValueError("ERP Local Agent for the active branch is offline.") + + current_payload = _analysis_accounting_storage_payload( + client, financial_year, db=db, tenant_id=scope.tenant_id + ) + current = load_mirror_trial_balance( + node_code=node.node_code, + accounting_payload=current_payload, + db=db, + tenant_id=scope.tenant_id, + client_id=client.id, + ) + + previous_fy = previous_financial_year(financial_year) + previous = {"lines": [], "count": 0, "group_count": 0, "hierarchy_warning": ""} + previous_available = False + try: + previous_payload = _analysis_accounting_storage_payload( + client, previous_fy, db=db, tenant_id=scope.tenant_id + ) + previous = load_mirror_trial_balance( + node_code=node.node_code, + accounting_payload=previous_payload, + db=db, + tenant_id=scope.tenant_id, + client_id=client.id, + ) + previous_available = True + except Exception: + pass + + shares = _partner_shares( + db, tenant_id=scope.tenant_id, client_id=client.id, financial_year=financial_year + ) if _is_partnership(client) else [] + share_payload = _share_payload(shares) + statements = build_financial_statements( + current_lines=current["lines"], + previous_lines=previous["lines"], + partnership_shares=share_payload, + ) + return { + "node": node, + "current_payload": current_payload, + "current": current, + "previous_fy": previous_fy, + "previous": previous, + "previous_available": previous_available, + "shares": shares, + "share_payload": share_payload, + "statements": statements, + } + + +@router.get("") +def page( + request: Request, + client_id: int | None = None, + financial_year: str = "", + message: str = "", + error: str = "", +): + db = CommonSessionLocal() + try: + user, denied = _require_partner(request, db, "accounting.tally.view") + if denied: + return denied + clients, scope = _visible_clients(db, request, user) + selected = next((c for c in clients if client_id and int(c.id) == int(client_id)), None) + fy = str(financial_year or _active_financial_year(request)).strip() + if fy not in _financial_year_options(_active_financial_year(request), years_back=10): + fy = _active_financial_year(request) + + report = None + command_error = error or "" + if selected: + try: + report = _load_report(db, request, user, selected, scope, fy) + except Exception as exc: + command_error = str(exc) + + default_partners = _default_partners(db, tenant_id=scope.tenant_id, client_id=selected.id) if selected and _is_partnership(selected) else [] + existing_names = {str(x.partner_name or "").strip().casefold() for x in (report["shares"] if report else [])} + partner_rows = list(report["shares"] if report else []) + for person in default_partners: + if str(person.full_name or "").strip().casefold() not in existing_names: + partner_rows.append(type("PartnerDraft", (), { + "partner_name": person.full_name, + "profit_share_percent": 0, + "target_ledger_name": "", + })()) + + return templates.TemplateResponse( + "accounting/financial_statements.html", + { + "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), + "clients": clients, + "selected_client": selected, + "financial_year": fy, + "financial_year_options": _financial_year_options(_active_financial_year(request), years_back=10), + "report": report, + "statements": report["statements"] if report else None, + "current_lines": report["current"]["lines"] if report else [], + "previous_fy": report["previous_fy"] if report else previous_financial_year(fy), + "previous_available": bool(report and report["previous_available"]), + "partner_rows": partner_rows, + "is_partnership": bool(selected and _is_partnership(selected)), + "mapping_choices": MAPPING_CHOICES, + "schedules": SCHEDULES, + "message": message, + "command_error": command_error, + }, + ) + finally: + db.close() + + +@router.post("/mapping") +def save_mapping( + request: Request, + client_id: int = Form(...), + financial_year: str = Form(...), + ledger_name: str = Form(...), + schedule_code: str = Form(...), + csrf_token: str = Form(...), +): + db = CommonSessionLocal() + try: + user, denied = _require_partner(request, db, "accounting.tally.view") + if denied: + return denied + validate_csrf(request, csrf_token) + client, _, scope = _find_visible_client(db, request, user, client_id) + if not client: + return _go(error="Client is not available in the active workspace.") + code = str(schedule_code or "").strip().upper() + if code not in SCHEDULES or code == "UNMAPPED": + return _go(client_id, financial_year, error="Select a valid financial statement schedule.") + name = str(ledger_name or "").strip() + if not name: + return _go(client_id, financial_year, error="Ledger name is required.") + row = db.execute(select(AccountingFinancialLedgerMapping).where( + AccountingFinancialLedgerMapping.tenant_id == scope.tenant_id, + AccountingFinancialLedgerMapping.client_id == client.id, + AccountingFinancialLedgerMapping.ledger_name == name, + )).scalar_one_or_none() + meta = SCHEDULES[code] + if row is None: + row = AccountingFinancialLedgerMapping( + tenant_id=scope.tenant_id, client_id=client.id, ledger_name=name, + statement_code=meta["statement"], schedule_code=code, + display_group=meta["label"], updated_by_user_id=user.id, + ) + db.add(row) + else: + row.statement_code=meta["statement"] + row.schedule_code=code + row.display_group=meta["label"] + row.updated_by_user_id=user.id + db.commit() + return _go(client.id, financial_year, message=f"Reporting mapping saved for {name}.") + except Exception as exc: + db.rollback() + return _go(client_id, financial_year, error=str(exc)) + finally: + db.close() + + +@router.post("/mapping/reset") +def reset_mapping( + request: Request, + client_id: int = Form(...), + financial_year: str = Form(...), + ledger_name: str = Form(...), + csrf_token: str = Form(...), +): + db=CommonSessionLocal() + try: + user, denied=_require_partner(request,db,"accounting.tally.view") + if denied: return denied + validate_csrf(request,csrf_token) + client,_,scope=_find_visible_client(db,request,user,client_id) + if not client: return _go(error="Client is not available in the active workspace.") + db.execute(delete(AccountingFinancialLedgerMapping).where( + AccountingFinancialLedgerMapping.tenant_id==scope.tenant_id, + AccountingFinancialLedgerMapping.client_id==client.id, + AccountingFinancialLedgerMapping.ledger_name==str(ledger_name or "").strip(), + )) + db.commit() + return _go(client.id,financial_year,message="Manual mapping removed; Tally group mapping is active again.") + except Exception as exc: + db.rollback(); return _go(client_id,financial_year,error=str(exc)) + finally: db.close() + + +@router.post("/partnership") +def save_partnership( + request: Request, + client_id: int = Form(...), + financial_year: str = Form(...), + partner_name: list[str] = Form(default=[]), + profit_share_percent: list[str] = Form(default=[]), + target_ledger_name: list[str] = Form(default=[]), + csrf_token: str = Form(...), +): + db=CommonSessionLocal() + try: + user, denied=_require_partner(request,db,"accounting.tally.view") + if denied: return denied + validate_csrf(request,csrf_token) + client,_,scope=_find_visible_client(db,request,user,client_id) + if not client: return _go(error="Client is not available in the active workspace.") + if not _is_partnership(client): return _go(client.id,financial_year,error="Partnership allocation is available only for partnership/firm/LLP clients.") + entries=[] + for idx,name in enumerate(partner_name): + name=str(name or "").strip() + if not name: continue + try: pct=round(float(profit_share_percent[idx] if idx < len(profit_share_percent) else 0),4) + except Exception: return _go(client.id,financial_year,error=f"Invalid profit-sharing percentage for {name}.") + ledger=str(target_ledger_name[idx] if idx < len(target_ledger_name) else "").strip() + entries.append((name,pct,ledger)) + total=round(sum(x[1] for x in entries),4) + if entries and abs(total-100.0)>0.0001: + return _go(client.id,financial_year,error=f"Partner profit-sharing ratio must total 100%. Current total: {total:.4f}%.") + db.execute(delete(AccountingPartnershipProfitShare).where( + AccountingPartnershipProfitShare.tenant_id==scope.tenant_id, + AccountingPartnershipProfitShare.client_id==client.id, + AccountingPartnershipProfitShare.financial_year==financial_year, + )) + for name,pct,ledger in entries: + db.add(AccountingPartnershipProfitShare( + tenant_id=scope.tenant_id,client_id=client.id,financial_year=financial_year, + partner_name=name,profit_share_percent=pct,target_ledger_name=ledger, + updated_by_user_id=user.id, + )) + db.commit() + return _go(client.id,financial_year,message="Partnership profit-sharing ratio saved. Final profit allocation has been refreshed.") + except Exception as exc: + db.rollback(); return _go(client_id,financial_year,error=str(exc)) + finally: db.close() + + +@router.get("/excel") +def download_excel(request: Request, client_id: int, financial_year: str = ""): + db=CommonSessionLocal() + try: + user,denied=_require_partner(request,db,"accounting.tally.view") + if denied: return denied + client,_,scope=_find_visible_client(db,request,user,client_id) + if not client: return _go(error="Client is not available in the active workspace.") + fy=str(financial_year or _active_financial_year(request)).strip() + report=_load_report(db,request,user,client,scope,fy) + address=", ".join(x for x in [ + str(getattr(client,"address_line_1","") or "").strip(), + str(getattr(client,"address_line_2","") or "").strip(), + str(getattr(client,"city","") or "").strip(), + str(getattr(client,"pincode","") or "").strip(), + ] if x) + content=build_excel_workbook( + client_name=str(client.client_name or "Client"), address=address, + financial_year=fy, previous_fy=report["previous_fy"], + current_lines=report["current"]["lines"], previous_lines=report["previous"]["lines"], + statements=report["statements"], partnership_shares=report["share_payload"], + ) + safe="".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in str(client.client_name or "Client")) + filename=f"{safe}_Financial_Statements_{fy}.xlsx" + return StreamingResponse( + iter([content]), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition":f'attachment; filename="{filename}"'}, + ) + except Exception as exc: + return _go(client_id,financial_year,error=str(exc)) + finally: db.close() diff --git a/app/modules/accounting/templates/accounting/financial_statements.html b/app/modules/accounting/templates/accounting/financial_statements.html new file mode 100644 index 0000000..5490c67 --- /dev/null +++ b/app/modules/accounting/templates/accounting/financial_statements.html @@ -0,0 +1,117 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
Tools · Accounting
+Generate Trial Balance, Profit & Loss Account, Balance Sheet and ledger schedules directly from the registered Client/FY Accounting Mirror. The mirror remains read-only.
+Presentation follows the ruled comparative format used in your existing statements.
| Particulars | Note | 31 Mar {{ financial_year[:4]|int + 1 }} | 31 Mar {{ financial_year[:4] }} |
|---|---|---|---|
| EQUITY AND LIABILITIES | |||
| {{ meta.label }} | {{ loop.index }} | {{ '{:,.0f}'.format(statements.current_bs.get(code,0)) }} | {{ '{:,.0f}'.format(statements.previous_bs.get(code,0)) }} |
| Total Liabilities | {{ '{:,.0f}'.format(statements.current_liabilities) }} | {{ '{:,.0f}'.format(statements.previous_liabilities) }} | |
| ASSETS | |||
| {{ meta.label }} | - | {{ '{:,.0f}'.format(statements.current_bs.get(code,0)) }} | {{ '{:,.0f}'.format(statements.previous_bs.get(code,0)) }} |
| Total Assets | {{ '{:,.0f}'.format(statements.current_assets) }} | {{ '{:,.0f}'.format(statements.previous_assets) }} | |
Generated from mapped mirror ledgers; inventory movement is used for cost of goods sold.
| Particulars | Current FY | Previous FY |
|---|---|---|
| {{ label }} | {{ '{:,.0f}'.format(cp.get(key,0)) }} | {{ '{:,.0f}'.format(pp.get(key,0)) }} |
Ratios are Client/FY-specific. When they total 100%, the year's profit/loss is allocated to partner funds for the final Balance Sheet without changing the mirror.
Default classification follows the complete Tally group hierarchy. Use an override only when a ledger needs a different financial-statement schedule.
| Ledger | Tally Group | Root Group | Schedule | Closing Dr | Closing Cr | Mapping |
|---|---|---|---|---|---|---|
| {{ line.ledger_name }} | {{ line.parent_group }} | {{ line.root_group }} | {{ line.schedule_label }} | {{ '{:,.0f}'.format(line.closing_debit) }} | {{ '{:,.0f}'.format(line.closing_credit) }} |