Add mirror based trial balance and financial statements

This commit is contained in:
A R R R Associates
2026-09-21 16:00:22 +05:30
parent 84220554c4
commit 918850ee78
10 changed files with 1242 additions and 3 deletions
@@ -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)
@@ -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()
@@ -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()
@@ -0,0 +1,117 @@
{% 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">Tools · Accounting</p>
<h1 class="mt-1 text-2xl font-semibold text-slate-900">Trial Balance & Financial Statements</h1>
<p class="mt-1 max-w-4xl text-sm text-slate-500">Generate Trial Balance, Profit & Loss Account, Balance Sheet and ledger schedules directly from the registered Client/FY Accounting Mirror. The mirror remains read-only.</p>
</div>
<div class="flex gap-2">
<a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700">Back to Tally Accounting</a>
{% if selected_client and report %}<a href="/tools/accounting/financial-statements/excel?client_id={{ selected_client.id }}&financial_year={{ financial_year }}" class="rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white shadow-soft hover:bg-emerald-700">Download Excel</a>{% endif %}
</div>
</div>
{% if message %}<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">{{ message }}</div>{% endif %}
{% if command_error %}<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">{{ command_error }}</div>{% endif %}
<section class="rounded-2xl bg-white p-5 shadow-soft">
<form method="get" class="grid gap-4 md:grid-cols-[minmax(0,1fr)_220px_auto] md:items-end">
<label class="block">
<span class="text-xs font-semibold uppercase tracking-wide text-slate-500">ERP Client</span>
<select name="client_id" class="mt-2 w-full rounded-xl border-slate-300 text-sm">
<option value="">Select client</option>
{% for client in clients %}<option value="{{ client.id }}" {% if selected_client and client.id == selected_client.id %}selected{% endif %}>{{ client.client_name }}</option>{% endfor %}
</select>
</label>
<label class="block">
<span class="text-xs font-semibold uppercase tracking-wide text-slate-500">Financial Year</span>
<select name="financial_year" class="mt-2 w-full rounded-xl border-slate-300 text-sm">
{% for fy in financial_year_options %}<option value="{{ fy }}" {% if fy == financial_year %}selected{% endif %}>FY {{ fy }}</option>{% endfor %}
</select>
</label>
<button class="rounded-xl bg-brand-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-brand-700">Load from Mirror</button>
</form>
</section>
{% if selected_client and report %}
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Mirror FY</div><div class="mt-2 text-lg font-semibold text-slate-900">{{ financial_year }}</div><div class="mt-1 text-xs text-slate-500">Version {{ report.current_payload.mirror_version_no or report.current_payload.version_no or '-' }}</div></div>
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Ledgers</div><div class="mt-2 text-2xl font-semibold text-slate-900">{{ report.current.count }}</div><div class="mt-1 text-xs text-slate-500">{{ report.current.group_count }} Tally groups loaded</div></div>
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Previous Year</div><div class="mt-2 text-lg font-semibold {% if previous_available %}text-emerald-700{% else %}text-amber-700{% endif %}">{{ previous_fy }}</div><div class="mt-1 text-xs text-slate-500">{{ 'Comparative mirror loaded' if previous_available else 'No registered comparative mirror' }}</div></div>
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Mapping Review</div><div class="mt-2 text-2xl font-semibold {% if statements.unmapped|length == 0 %}text-emerald-700{% else %}text-amber-700{% endif %}">{{ statements.unmapped|length }}</div><div class="mt-1 text-xs text-slate-500">Unmapped ledgers</div></div>
</section>
{% if report.current.hierarchy_warning %}
<div class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">The mirror Trial Balance loaded, but the Local Agent could not return the full Tally group hierarchy. Update the Local Agent to the latest version for automatic custom-group resolution. Immediate parent-group classification is still being used.</div>
{% endif %}
{% if statements.balance_sheet_difference|abs > 1 %}
<div class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">Balance Sheet review required: Assets and Equity/Liabilities differ by ₹ {{ '{:,.2f}'.format(statements.balance_sheet_difference) }} after mapped finalisation. Review unmapped or incorrectly classified ledgers before using the statements as final.</div>
{% elif statements.profit_transfer_applied %}
<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">Current-year profit/loss was not yet reflected in the source capital balances, so the ERP applied a read-only finalisation transfer for this financial statement view. The Accounting Mirror was not changed.</div>
{% endif %}
<div class="grid gap-6 xl:grid-cols-2">
<section class="overflow-hidden rounded-2xl bg-white shadow-soft">
<div class="border-b border-slate-200 px-5 py-4"><h2 class="font-semibold text-slate-900">Balance Sheet</h2><p class="text-xs text-slate-500">Presentation follows the ruled comparative format used in your existing statements.</p></div>
<div class="overflow-x-auto p-5">
<table class="w-full border-collapse text-sm">
<thead><tr class="bg-slate-50"><th class="border border-slate-400 px-3 py-2 text-left">Particulars</th><th class="border border-slate-400 px-3 py-2">Note</th><th class="border border-slate-400 px-3 py-2 text-right">31 Mar {{ financial_year[:4]|int + 1 }}</th><th class="border border-slate-400 px-3 py-2 text-right">31 Mar {{ financial_year[:4] }}</th></tr></thead>
<tbody>
<tr><td colspan="4" class="border-x border-slate-400 px-3 py-2 font-bold">EQUITY AND LIABILITIES</td></tr>
{% for code, meta in schedules.items() if meta.statement == 'BS' and meta.bs_side == 'LIABILITY' and (statements.current_bs.get(code,0) or statements.previous_bs.get(code,0)) %}
<tr><td class="border-x border-slate-300 px-3 py-1.5">{{ meta.label }}</td><td class="border-x border-slate-300 px-3 py-1.5 text-center">{{ loop.index }}</td><td class="border-x border-slate-300 px-3 py-1.5 text-right">{{ '{:,.0f}'.format(statements.current_bs.get(code,0)) }}</td><td class="border-x border-slate-300 px-3 py-1.5 text-right">{{ '{:,.0f}'.format(statements.previous_bs.get(code,0)) }}</td></tr>
{% endfor %}
<tr class="font-bold"><td class="border border-slate-500 px-3 py-2">Total Liabilities</td><td class="border border-slate-500"></td><td class="border border-slate-500 px-3 py-2 text-right">{{ '{:,.0f}'.format(statements.current_liabilities) }}</td><td class="border border-slate-500 px-3 py-2 text-right">{{ '{:,.0f}'.format(statements.previous_liabilities) }}</td></tr>
<tr><td colspan="4" class="border-x border-slate-400 px-3 py-2 font-bold">ASSETS</td></tr>
{% for code, meta in schedules.items() if meta.statement == 'BS' and meta.bs_side == 'ASSET' and (statements.current_bs.get(code,0) or statements.previous_bs.get(code,0)) %}
<tr><td class="border-x border-slate-300 px-3 py-1.5">{{ meta.label }}</td><td class="border-x border-slate-300 px-3 py-1.5 text-center">-</td><td class="border-x border-slate-300 px-3 py-1.5 text-right">{{ '{:,.0f}'.format(statements.current_bs.get(code,0)) }}</td><td class="border-x border-slate-300 px-3 py-1.5 text-right">{{ '{:,.0f}'.format(statements.previous_bs.get(code,0)) }}</td></tr>
{% endfor %}
<tr class="font-bold"><td class="border border-slate-500 px-3 py-2">Total Assets</td><td class="border border-slate-500"></td><td class="border border-slate-500 px-3 py-2 text-right">{{ '{:,.0f}'.format(statements.current_assets) }}</td><td class="border border-slate-500 px-3 py-2 text-right">{{ '{:,.0f}'.format(statements.previous_assets) }}</td></tr>
</tbody>
</table>
</div>
</section>
<section class="overflow-hidden rounded-2xl bg-white shadow-soft">
<div class="border-b border-slate-200 px-5 py-4"><h2 class="font-semibold text-slate-900">Profit & Loss Account</h2><p class="text-xs text-slate-500">Generated from mapped mirror ledgers; inventory movement is used for cost of goods sold.</p></div>
<div class="overflow-x-auto p-5">
{% set cp=statements.current_pl %}{% set pp=statements.previous_pl %}
<table class="w-full border-collapse text-sm">
<thead><tr class="bg-slate-50"><th class="border border-slate-400 px-3 py-2 text-left">Particulars</th><th class="border border-slate-400 px-3 py-2 text-right">Current FY</th><th class="border border-slate-400 px-3 py-2 text-right">Previous FY</th></tr></thead>
<tbody>
{% for label,key in [('Revenue from operations','REVENUE'),('Other Income','OTHER_INCOME'),('Total Income','TOTAL_INCOME'),('Cost of goods sold','COST_OF_GOODS_SOLD'),('Direct Expenses','DIRECT_EXPENSES'),('Employee benefits expense','EMPLOYEE_BENEFITS'),('Finance Cost','FINANCE_COST'),('Depreciation and amortization expense','DEPRECIATION'),('Other expenses','OTHER_EXPENSES'),('Total expenses','TOTAL_EXPENSES'),('Profit/(Loss) for the year','PROFIT')] %}
<tr class="{% if key in ['TOTAL_INCOME','TOTAL_EXPENSES','PROFIT'] %}font-bold{% endif %}"><td class="border border-slate-300 px-3 py-1.5">{{ label }}</td><td class="border border-slate-300 px-3 py-1.5 text-right">{{ '{:,.0f}'.format(cp.get(key,0)) }}</td><td class="border border-slate-300 px-3 py-1.5 text-right">{{ '{:,.0f}'.format(pp.get(key,0)) }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
</div>
{% if is_partnership %}
<section class="rounded-2xl bg-white p-5 shadow-soft">
<div class="flex flex-wrap items-center justify-between gap-3"><div><h2 class="font-semibold text-slate-900">Partnership Profit Allocation</h2><p class="mt-1 text-sm text-slate-500">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.</p></div><div class="text-sm font-semibold {% if statements.profit_share_total == 100 %}text-emerald-700{% else %}text-amber-700{% endif %}">Configured ratio: {{ '%.2f'|format(statements.profit_share_total) }}%</div></div>
<form method="post" action="/tools/accounting/financial-statements/partnership" class="mt-4 space-y-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}"><input type="hidden" name="financial_year" value="{{ financial_year }}">
<div class="overflow-x-auto"><table class="w-full text-sm"><thead><tr class="bg-slate-50"><th class="px-3 py-2 text-left">Partner</th><th class="px-3 py-2 text-right">Profit Share %</th><th class="px-3 py-2 text-left">Capital / Current Ledger (optional)</th></tr></thead><tbody>
{% for row in partner_rows %}<tr><td class="px-3 py-2"><input name="partner_name" value="{{ row.partner_name }}" class="w-full rounded-lg border-slate-300"></td><td class="px-3 py-2"><input name="profit_share_percent" type="number" step="0.0001" value="{{ row.profit_share_percent }}" class="w-32 rounded-lg border-slate-300 text-right"></td><td class="px-3 py-2"><input name="target_ledger_name" value="{{ row.target_ledger_name }}" class="w-full rounded-lg border-slate-300" placeholder="e.g. Partner Current Account"></td></tr>{% endfor %}
{% if not partner_rows %}{% for i in range(2) %}<tr><td class="px-3 py-2"><input name="partner_name" class="w-full rounded-lg border-slate-300"></td><td class="px-3 py-2"><input name="profit_share_percent" type="number" step="0.0001" class="w-32 rounded-lg border-slate-300 text-right"></td><td class="px-3 py-2"><input name="target_ledger_name" class="w-full rounded-lg border-slate-300"></td></tr>{% endfor %}{% endif %}
</tbody></table></div>
<button class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white">Save Ratio & Recalculate</button>
</form>
{% if statements.partnership_allocations %}<div class="mt-4 grid gap-2 md:grid-cols-2 xl:grid-cols-5">{% for row in statements.partnership_allocations %}<div class="rounded-xl border border-slate-200 p-3"><div class="text-xs text-slate-500">{{ row.partner_name }} · {{ row.profit_share_percent }}%</div><div class="mt-1 font-semibold text-slate-900">₹ {{ '{:,.0f}'.format(row.amount) }}</div></div>{% endfor %}</div>{% endif %}
</section>
{% endif %}
<section class="rounded-2xl bg-white p-5 shadow-soft">
<div class="flex items-center justify-between gap-3"><div><h2 class="font-semibold text-slate-900">Trial Balance & Ledger Mapping</h2><p class="mt-1 text-sm text-slate-500">Default classification follows the complete Tally group hierarchy. Use an override only when a ledger needs a different financial-statement schedule.</p></div></div>
<div class="mt-4 overflow-x-auto"><table class="min-w-full text-xs"><thead><tr class="bg-slate-50"><th class="px-2 py-2 text-left">Ledger</th><th class="px-2 py-2 text-left">Tally Group</th><th class="px-2 py-2 text-left">Root Group</th><th class="px-2 py-2 text-left">Schedule</th><th class="px-2 py-2 text-right">Closing Dr</th><th class="px-2 py-2 text-right">Closing Cr</th><th class="px-2 py-2">Mapping</th></tr></thead><tbody class="divide-y divide-slate-100">
{% for line in current_lines %}<tr class="{% if line.schedule_code == 'UNMAPPED' %}bg-amber-50{% endif %}"><td class="px-2 py-2 font-medium">{{ line.ledger_name }}</td><td class="px-2 py-2">{{ line.parent_group }}</td><td class="px-2 py-2">{{ line.root_group }}</td><td class="px-2 py-2">{{ line.schedule_label }}</td><td class="px-2 py-2 text-right">{{ '{:,.0f}'.format(line.closing_debit) }}</td><td class="px-2 py-2 text-right">{{ '{:,.0f}'.format(line.closing_credit) }}</td><td class="px-2 py-2"><form method="post" action="/tools/accounting/financial-statements/mapping" class="flex min-w-[300px] items-center gap-2"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}"><input type="hidden" name="financial_year" value="{{ financial_year }}"><input type="hidden" name="ledger_name" value="{{ line.ledger_name }}"><select name="schedule_code" class="w-52 rounded-lg border-slate-300 text-xs">{% for code,label,statement in mapping_choices %}<option value="{{ code }}" {% if code == line.schedule_code %}selected{% endif %}>{{ label }}</option>{% endfor %}</select><button class="rounded-lg border border-slate-300 bg-white px-2 py-1 font-semibold">Save</button></form></td></tr>{% endfor %}
</tbody></table></div>
</section>
{% endif %}
</div>
{% endblock %}
@@ -25,6 +25,7 @@
<a href="/tools/accounting/master-integrity{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="block rounded-lg px-3 py-2 text-sm hover:bg-white">Master Integrity Engine</a>
<a href="/tools/accounting/stock-items{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="block rounded-lg px-3 py-2 text-sm hover:bg-white">Stock Item Intelligence</a>
<a href="/tools/accounting/opening-balances{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="block rounded-lg px-3 py-2 text-sm hover:bg-white">Opening Balance Review</a>
<a href="/tools/accounting/financial-statements{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="block rounded-lg bg-indigo-50 px-3 py-2 text-sm font-semibold text-indigo-800 hover:bg-indigo-100">Trial Balance & Financial Statements</a>
<a href="/tools/accounting/voucher-engine{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="block rounded-lg px-3 py-2 text-sm hover:bg-white">Accounting Voucher Engine</a>
</div></details>
+1 -1
View File
@@ -4,7 +4,7 @@ import io
from pathlib import Path
import zipfile
ERP_LOCAL_AGENT_VERSION = "1.26.20"
ERP_LOCAL_AGENT_VERSION = "1.26.21"
ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
@@ -1,2 +1 @@
__version__ = "1.26.20"
AGENT_NAME = "ERP Local Agent"
__version__ = "1.26.21"
@@ -4566,6 +4566,11 @@ class AgentCommandProcessor:
GROUP BY lm.ledger_guid,lm.ledger_name,lm.parent_group,lm.opening_balance,lm.closing_balance
ORDER BY lm.parent_group,lm.ledger_name LIMIT ?""",(limit,)).fetchall()]
return {"rows":rows,"count":len(rows)}
if query == "group_master":
rows=[dict(r) for r in db.execute(
"""SELECT master_guid,name,parent,reserved_name,is_revenue,is_deemed_positive,is_active
FROM group_master ORDER BY lower(name),name LIMIT ?""",(limit,)).fetchall()]
return {"rows":rows,"count":len(rows)}
if query == "stock_items":
rows=[dict(r) for r in db.execute("SELECT * FROM stock_item_master ORDER BY product_name LIMIT ?",(limit,)).fetchall()]
return {"rows":rows,"count":len(rows)}
+2
View File
@@ -63,6 +63,7 @@ from app.modules.accounting.cash_payment_ui import router as accounting_cash_pay
from app.modules.accounting.creditors_aging_ui import router as accounting_creditors_aging_ui_router
from app.modules.accounting.gst_reconciliation_ui import router as accounting_gst_reconciliation_ui_router
from app.modules.accounting.tds_ui import router as accounting_tds_ui_router
from app.modules.accounting.financial_statements_ui import router as accounting_financial_statements_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.client_identity.ui import router as client_identity_ui_router
@@ -110,6 +111,7 @@ def mount_ui(app: FastAPI) -> None:
app.include_router(accounting_creditors_aging_ui_router)
app.include_router(accounting_gst_reconciliation_ui_router)
app.include_router(accounting_tds_ui_router)
app.include_router(accounting_financial_statements_ui_router)
app.include_router(work_tracker_ui_router)
app.include_router(billing_ui_router)
app.include_router(platform_billing_ui_router)