diff --git a/alembic/versions/20260903_tds_withholding_tools.py b/alembic/versions/20260903_tds_withholding_tools.py new file mode 100644 index 0000000..a72dfbe --- /dev/null +++ b/alembic/versions/20260903_tds_withholding_tools.py @@ -0,0 +1,20 @@ +from alembic import op +import sqlalchemy as sa + +revision = "20260903_tds_withholding_tools" +down_revision = "20260903_cash_payment_rules" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table("accounting_tds_rules", + 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("rule_code",sa.String(80),nullable=False),sa.Column("name",sa.String(200),nullable=False),sa.Column("statutory_reference",sa.String(160),nullable=False,server_default="Income-tax Act, 2025 - section 393(1)"),sa.Column("legacy_section",sa.String(40)),sa.Column("nature_keywords",sa.Text(),nullable=False,server_default=""),sa.Column("effective_from",sa.Date(),nullable=False),sa.Column("effective_to",sa.Date()),sa.Column("threshold_mode",sa.String(30),nullable=False,server_default="ANNUAL"),sa.Column("single_threshold",sa.Float(),nullable=False,server_default="0"),sa.Column("aggregate_threshold",sa.Float(),nullable=False,server_default="0"),sa.Column("rate_individual_huf",sa.Float(),nullable=False,server_default="0"),sa.Column("rate_other",sa.Float(),nullable=False,server_default="0"),sa.Column("no_pan_rate",sa.Float(),nullable=False,server_default="20"),sa.Column("deduct_on_excess_only",sa.Boolean(),nullable=False,server_default=sa.false()),sa.Column("applies_to_purchases",sa.Boolean(),nullable=False,server_default=sa.false()),sa.Column("applies_to_expenses",sa.Boolean(),nullable=False,server_default=sa.true()),sa.Column("is_active",sa.Boolean(),nullable=False,server_default=sa.true()),sa.Column("is_system_default",sa.Boolean(),nullable=False,server_default=sa.true()),sa.Column("updated_by_user_id",sa.Integer(),sa.ForeignKey("users.id",ondelete="SET NULL")),sa.Column("created_at_utc",sa.DateTime(),nullable=False,server_default=sa.text("CURRENT_TIMESTAMP")),sa.Column("updated_at_utc",sa.DateTime(),nullable=False,server_default=sa.text("CURRENT_TIMESTAMP")),sa.UniqueConstraint("tenant_id","rule_code","effective_from",name="uq_accounting_tds_rule_version")) + op.create_index("ix_accounting_tds_rules_tenant","accounting_tds_rules",["tenant_id"]) + op.create_table("accounting_tds_posting_instructions", + 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("rule_id",sa.Integer(),sa.ForeignKey("accounting_tds_rules.id",ondelete="SET NULL")),sa.Column("company_guid",sa.String(255)),sa.Column("company_name",sa.String(255)),sa.Column("source_voucher_number",sa.String(120)),sa.Column("source_voucher_date",sa.Date()),sa.Column("party_ledger",sa.String(255),nullable=False),sa.Column("tds_ledger",sa.String(255),nullable=False,server_default="TDS Payable"),sa.Column("taxable_base",sa.Float(),nullable=False,server_default="0"),sa.Column("expected_tds",sa.Float(),nullable=False,server_default="0"),sa.Column("actual_tds",sa.Float(),nullable=False,server_default="0"),sa.Column("liability_amount",sa.Float(),nullable=False,server_default="0"),sa.Column("statutory_reference",sa.String(160)),sa.Column("narration",sa.Text()),sa.Column("erp_reference",sa.String(80),unique=True),sa.Column("status",sa.String(30),nullable=False,server_default="prepared"),sa.Column("tally_voucher_id",sa.String(120)),sa.Column("prepared_by_user_id",sa.Integer(),sa.ForeignKey("users.id",ondelete="SET NULL")),sa.Column("approved_by_user_id",sa.Integer(),sa.ForeignKey("users.id",ondelete="SET NULL")),sa.Column("posted_by_user_id",sa.Integer(),sa.ForeignKey("users.id",ondelete="SET NULL")),sa.Column("prepared_at_utc",sa.DateTime(),nullable=False,server_default=sa.text("CURRENT_TIMESTAMP")),sa.Column("approved_at_utc",sa.DateTime()),sa.Column("posted_at_utc",sa.DateTime())) + op.create_index("ix_accounting_tds_posting_tenant","accounting_tds_posting_instructions",["tenant_id"]); op.create_index("ix_accounting_tds_posting_client","accounting_tds_posting_instructions",["client_id"]); op.create_index("ix_accounting_tds_posting_status","accounting_tds_posting_instructions",["status"]) + + +def downgrade(): + op.drop_index("ix_accounting_tds_posting_status",table_name="accounting_tds_posting_instructions"); op.drop_index("ix_accounting_tds_posting_client",table_name="accounting_tds_posting_instructions"); op.drop_index("ix_accounting_tds_posting_tenant",table_name="accounting_tds_posting_instructions"); op.drop_table("accounting_tds_posting_instructions"); op.drop_index("ix_accounting_tds_rules_tenant",table_name="accounting_tds_rules"); op.drop_table("accounting_tds_rules") diff --git a/app/modules/accounting/tds_models.py b/app/modules/accounting/tds_models.py new file mode 100644 index 0000000..8b9eca7 --- /dev/null +++ b/app/modules/accounting/tds_models.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from datetime import date, datetime + +from sqlalchemy import Boolean, Date, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.db.common import CommonBase + + +class AccountingTDSRule(CommonBase): + __tablename__ = "accounting_tds_rules" + __table_args__ = ( + UniqueConstraint("tenant_id", "rule_code", "effective_from", name="uq_accounting_tds_rule_version"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), index=True) + rule_code: Mapped[str] = mapped_column(String(80), nullable=False) + name: Mapped[str] = mapped_column(String(200), nullable=False) + statutory_reference: Mapped[str] = mapped_column(String(160), default="Income-tax Act, 2025 - section 393(1)") + legacy_section: Mapped[str | None] = mapped_column(String(40), nullable=True) + nature_keywords: Mapped[str] = mapped_column(Text, default="") + effective_from: Mapped[date] = mapped_column(Date, nullable=False) + effective_to: Mapped[date | None] = mapped_column(Date, nullable=True) + threshold_mode: Mapped[str] = mapped_column(String(30), default="ANNUAL") + single_threshold: Mapped[float] = mapped_column(Float, default=0.0) + aggregate_threshold: Mapped[float] = mapped_column(Float, default=0.0) + rate_individual_huf: Mapped[float] = mapped_column(Float, default=0.0) + rate_other: Mapped[float] = mapped_column(Float, default=0.0) + no_pan_rate: Mapped[float] = mapped_column(Float, default=20.0) + deduct_on_excess_only: Mapped[bool] = mapped_column(Boolean, default=False) + applies_to_purchases: Mapped[bool] = mapped_column(Boolean, default=False) + applies_to_expenses: Mapped[bool] = mapped_column(Boolean, default=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + is_system_default: Mapped[bool] = mapped_column(Boolean, default=True) + updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at_utc: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at_utc: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + +class AccountingTDSPostingInstruction(CommonBase): + __tablename__ = "accounting_tds_posting_instructions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), index=True) + client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), index=True) + rule_id: Mapped[int | None] = mapped_column(ForeignKey("accounting_tds_rules.id", ondelete="SET NULL"), nullable=True) + company_guid: Mapped[str | None] = mapped_column(String(255), nullable=True) + company_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + source_voucher_number: Mapped[str | None] = mapped_column(String(120), nullable=True) + source_voucher_date: Mapped[date | None] = mapped_column(Date, nullable=True) + party_ledger: Mapped[str] = mapped_column(String(255), nullable=False) + tds_ledger: Mapped[str] = mapped_column(String(255), nullable=False, default="TDS Payable") + taxable_base: Mapped[float] = mapped_column(Float, default=0.0) + expected_tds: Mapped[float] = mapped_column(Float, default=0.0) + actual_tds: Mapped[float] = mapped_column(Float, default=0.0) + liability_amount: Mapped[float] = mapped_column(Float, default=0.0) + statutory_reference: Mapped[str | None] = mapped_column(String(160), nullable=True) + narration: Mapped[str | None] = mapped_column(Text, nullable=True) + erp_reference: Mapped[str | None] = mapped_column(String(80), nullable=True, unique=True) + status: Mapped[str] = mapped_column(String(30), default="prepared", index=True) + tally_voucher_id: Mapped[str | None] = mapped_column(String(120), nullable=True) + prepared_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + approved_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + posted_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + prepared_at_utc: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + approved_at_utc: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + posted_at_utc: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) diff --git a/app/modules/accounting/tds_ui.py b/app/modules/accounting/tds_ui.py new file mode 100644 index 0000000..46eb5ec --- /dev/null +++ b/app/modules/accounting/tds_ui.py @@ -0,0 +1,221 @@ +from __future__ import annotations + +from datetime import date, datetime +from decimal import Decimal, InvalidOperation +from urllib.parse import quote + +from fastapi import APIRouter, Request +from fastapi.responses import RedirectResponse +from sqlalchemy import 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.agent_bridge import request_agent_command +from app.modules.accounting.tds_models import AccountingTDSPostingInstruction, AccountingTDSRule +from app.modules.accounting.ui import _accounting_storage_payload, _financial_year_for_date, _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 + +router = APIRouter(prefix="/tools/accounting/tds", tags=["accounting-tds-ui"]) + +DEFAULT_RULES = [ + dict(rule_code="CONTRACT_INDIVIDUAL", name="Contract payments - individual/HUF payee", legacy_section="194C", nature_keywords="contract,contractor,labour,job work,freight,transport", threshold_mode="BOTH", single_threshold=30000, aggregate_threshold=100000, rate_individual_huf=1, rate_other=1, no_pan_rate=20, deduct_on_excess_only=False, applies_to_purchases=False, applies_to_expenses=True), + dict(rule_code="CONTRACT_OTHER", name="Contract payments - other payee", legacy_section="194C", nature_keywords="contract,contractor,labour,job work,freight,transport", threshold_mode="BOTH", single_threshold=30000, aggregate_threshold=100000, rate_individual_huf=2, rate_other=2, no_pan_rate=20, deduct_on_excess_only=False, applies_to_purchases=False, applies_to_expenses=True), + dict(rule_code="COMMISSION", name="Commission or brokerage", legacy_section="194H", nature_keywords="commission,brokerage,broker", threshold_mode="ANNUAL", single_threshold=0, aggregate_threshold=20000, rate_individual_huf=2, rate_other=2, no_pan_rate=20, deduct_on_excess_only=False, applies_to_purchases=False, applies_to_expenses=True), + dict(rule_code="RENT_PLANT", name="Rent - plant and machinery", legacy_section="194-I", nature_keywords="plant rent,machinery rent,equipment rent,hire charges", threshold_mode="MONTHLY", single_threshold=50000, aggregate_threshold=0, rate_individual_huf=2, rate_other=2, no_pan_rate=20, deduct_on_excess_only=False, applies_to_purchases=False, applies_to_expenses=True), + dict(rule_code="RENT_PROPERTY", name="Rent - land/building/furniture/fittings", legacy_section="194-I", nature_keywords="rent,lease rent,office rent,building rent,warehouse rent", threshold_mode="MONTHLY", single_threshold=50000, aggregate_threshold=0, rate_individual_huf=10, rate_other=10, no_pan_rate=20, deduct_on_excess_only=False, applies_to_purchases=False, applies_to_expenses=True), + dict(rule_code="TECHNICAL_SERVICES", name="Fees for technical services", legacy_section="194J", nature_keywords="technical fee,technical service,software support,technical consultancy", threshold_mode="ANNUAL", single_threshold=0, aggregate_threshold=50000, rate_individual_huf=2, rate_other=2, no_pan_rate=20, deduct_on_excess_only=False, applies_to_purchases=False, applies_to_expenses=True), + dict(rule_code="PROFESSIONAL_SERVICES", name="Professional services", legacy_section="194J", nature_keywords="professional fee,consultancy,consultant,audit fee,legal fee,architect,medical fee", threshold_mode="ANNUAL", single_threshold=0, aggregate_threshold=50000, rate_individual_huf=10, rate_other=10, no_pan_rate=20, deduct_on_excess_only=False, applies_to_purchases=False, applies_to_expenses=True), + dict(rule_code="PURCHASE_GOODS", name="Purchase of goods", legacy_section="194Q", nature_keywords="purchase,purchases,raw material,stores,goods,trading purchase,material", threshold_mode="ANNUAL", single_threshold=0, aggregate_threshold=5000000, rate_individual_huf=0.1, rate_other=0.1, no_pan_rate=5, deduct_on_excess_only=True, applies_to_purchases=True, applies_to_expenses=False), + dict(rule_code="PARTNER_PAYMENT", name="Partner remuneration / commission / bonus / interest", legacy_section="194T", nature_keywords="partner remuneration,partner salary,partner commission,partner bonus,interest on capital,partner interest", threshold_mode="ANNUAL", single_threshold=0, aggregate_threshold=20000, rate_individual_huf=10, rate_other=10, no_pan_rate=20, deduct_on_excess_only=False, applies_to_purchases=False, applies_to_expenses=True), +] + + +def _tenant_id(request: Request, user) -> int: + return int(request.session.get("active_tenant_id") or getattr(user, "tenant_id", 0) or 0) + + +def _seed_defaults(db, tenant_id: int) -> None: + exists = db.execute(select(AccountingTDSRule.id).where(AccountingTDSRule.tenant_id == tenant_id).limit(1)).first() + if exists: + return + for data in DEFAULT_RULES: + db.add(AccountingTDSRule(tenant_id=tenant_id, statutory_reference="Income-tax Act, 2025 - section 393(1)", effective_from=date(2026,4,1), is_system_default=True, is_active=True, **data)) + db.commit() + + +def _rules(db, tenant_id: int): + _seed_defaults(db, tenant_id) + return list(db.execute(select(AccountingTDSRule).where(AccountingTDSRule.tenant_id == tenant_id).order_by(AccountingTDSRule.name, AccountingTDSRule.effective_from.desc())).scalars()) + + +def _render(request, db, user, template, **context): + base = {"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)} + base.update(context) + return templates.TemplateResponse(template, base) + + +def _rule_payload(rows): + return [{"id": r.id, "rule_code": r.rule_code, "name": r.name, "statutory_reference": r.statutory_reference, "legacy_section": r.legacy_section or "", "nature_keywords": r.nature_keywords or "", "effective_from": r.effective_from.isoformat(), "effective_to": r.effective_to.isoformat() if r.effective_to else "", "threshold_mode": r.threshold_mode, "single_threshold": r.single_threshold, "aggregate_threshold": r.aggregate_threshold, "rate_individual_huf": r.rate_individual_huf, "rate_other": r.rate_other, "no_pan_rate": r.no_pan_rate, "deduct_on_excess_only": bool(r.deduct_on_excess_only), "applies_to_purchases": bool(r.applies_to_purchases), "applies_to_expenses": bool(r.applies_to_expenses)} for r in rows if r.is_active] + + +def _workspace(request, db, user, client_id, tally_guid, date_from, date_to, run): + 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) + node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id) + online = _node_online(node) + today=date.today(); fy=_financial_year_for_date(today); sy=int(fy.split('-',1)[0]) + start=date_from or date(sy,4,1).isoformat(); end=date_to or today.isoformat() + live=None; review=None; error="" + if selected and node and online: + st=request_agent_command(node.node_code,"phase4_status",_accounting_storage_payload(selected,_financial_year_for_date(date.fromisoformat(start))),timeout_seconds=20) + if st.get("ok"): live=st.get("result") or {} + else: error=str(st.get("error") or "Local Agent status failed.") + if run and tally_guid: + rr=request_agent_command(node.node_code,"accounting_tds_compliance",{**_accounting_storage_payload(selected,_financial_year_for_date(date.fromisoformat(start))),"tally_guid":tally_guid,"date_from":start,"date_to":end,"rules":_rule_payload(_rules(db,_tenant_id(request,user)))},timeout_seconds=120) + if rr.get("ok"): review=(rr.get("result") or {}).get("tds_review") or {} + else: error=str(rr.get("error") or "TDS review failed.") + return clients, selected, node, online, live, review, start, end, error + + +@router.get("/rules") +def rules_page(request: Request, saved: int=0, error: str=""): + db=CommonSessionLocal() + try: + user,response=_require_partner(request,db,"accounting.tally.view") + if response:return response + return _render(request,db,user,"modules/accounting/templates/accounting/tds_rules.html",rules=_rules(db,_tenant_id(request,user)),saved=saved,error=error) + finally: db.close() + + +@router.post("/rules/{rule_id}") +async def update_rule(request: Request, rule_id:int): + db=CommonSessionLocal() + try: + user,response=_require_partner(request,db,"accounting.tally.view") + if response:return response + form=await request.form(); validate_csrf(request,str(form.get("csrf_token") or "")) + tenant_id=_tenant_id(request,user) + row=db.execute(select(AccountingTDSRule).where(AccountingTDSRule.id==rule_id,AccountingTDSRule.tenant_id==tenant_id)).scalar_one_or_none() + if not row:return RedirectResponse("/tools/accounting/tds/rules?error="+quote("Rule not found."),status_code=303) + def num(name,default=0.0): + try:return float(Decimal(str(form.get(name) or default).replace(',',''))) + except (InvalidOperation,ValueError):raise ValueError(f"{name} must be numeric") + row.name=str(form.get("name") or row.name).strip(); row.statutory_reference=str(form.get("statutory_reference") or row.statutory_reference).strip(); row.legacy_section=str(form.get("legacy_section") or "").strip() or None + row.nature_keywords=str(form.get("nature_keywords") or "").strip(); row.threshold_mode=str(form.get("threshold_mode") or "ANNUAL").upper(); row.single_threshold=num("single_threshold"); row.aggregate_threshold=num("aggregate_threshold"); row.rate_individual_huf=num("rate_individual_huf"); row.rate_other=num("rate_other"); row.no_pan_rate=num("no_pan_rate",20); row.deduct_on_excess_only=bool(form.get("deduct_on_excess_only")); row.applies_to_purchases=bool(form.get("applies_to_purchases")); row.applies_to_expenses=bool(form.get("applies_to_expenses")); row.is_active=bool(form.get("is_active")); row.updated_by_user_id=user.id; row.updated_at_utc=datetime.utcnow(); db.commit() + return RedirectResponse("/tools/accounting/tds/rules?saved=1",status_code=303) + except Exception as exc: + db.rollback(); return RedirectResponse("/tools/accounting/tds/rules?error="+quote(str(exc)),status_code=303) + finally: db.close() + + +@router.get("/compliance") +def compliance(request:Request,client_id:int|None=None,tally_guid:str="",date_from:str="",date_to:str="",run:int=0): + db=CommonSessionLocal() + try: + user,response=_require_partner(request,db,"accounting.tally.view") + if response:return response + clients,selected,node,online,live,review,start,end,error=_workspace(request,db,user,client_id,tally_guid,date_from,date_to,run) + return _render(request,db,user,"modules/accounting/templates/accounting/tds_compliance.html",clients=clients,selected_client=selected,node=node,node_online=online,live=live,review=review,date_from=start,date_to=end,tally_guid=tally_guid,error=error,rules=_rules(db,_tenant_id(request,user))) + finally: db.close() + + +@router.get("/liability") +def liability(request:Request,client_id:int|None=None,tally_guid:str="",date_from:str="",date_to:str="",run:int=0,saved:int=0,error:str=""): + db=CommonSessionLocal() + try: + user,response=_require_partner(request,db,"accounting.tally.view") + if response:return response + clients,selected,node,online,live,review,start,end,run_error=_workspace(request,db,user,client_id,tally_guid,date_from,date_to,run) + instructions=[] + if selected: instructions=list(db.execute(select(AccountingTDSPostingInstruction).where(AccountingTDSPostingInstruction.tenant_id==_tenant_id(request,user),AccountingTDSPostingInstruction.client_id==selected.id).order_by(AccountingTDSPostingInstruction.id.desc()).limit(100)).scalars()) + return _render(request,db,user,"modules/accounting/templates/accounting/tds_liability.html",clients=clients,selected_client=selected,node=node,node_online=online,live=live,review=review,date_from=start,date_to=end,tally_guid=tally_guid,error=error or run_error,saved=saved,instructions=instructions) + finally:db.close() + + +@router.post("/liability/prepare") +async def prepare_liability(request:Request): + db=CommonSessionLocal() + try: + user,response=_require_partner(request,db,"accounting.tally.view") + if response:return response + form=await request.form(); validate_csrf(request,str(form.get("csrf_token") or "")); tenant=_tenant_id(request,user) + client_id=int(form.get("client_id")); amount=float(form.get("liability_amount") or 0) + if amount<=0: raise ValueError("Liability amount must be greater than zero.") + row=AccountingTDSPostingInstruction(tenant_id=tenant,client_id=client_id,rule_id=int(form.get("rule_id")) if form.get("rule_id") else None,company_guid=str(form.get("tally_guid") or ""),company_name=str(form.get("company_name") or ""),source_voucher_number=str(form.get("source_voucher_number") or ""),source_voucher_date=date.fromisoformat(str(form.get("source_voucher_date"))) if form.get("source_voucher_date") else None,party_ledger=str(form.get("party_ledger") or "").strip(),tds_ledger=str(form.get("tds_ledger") or "TDS Payable").strip(),taxable_base=float(form.get("taxable_base") or 0),expected_tds=float(form.get("expected_tds") or 0),actual_tds=float(form.get("actual_tds") or 0),liability_amount=amount,statutory_reference=str(form.get("statutory_reference") or ""),narration=str(form.get("narration") or "TDS liability correction prepared by ERP"),status="prepared",prepared_by_user_id=user.id) + if not row.party_ledger: raise ValueError("Party ledger is required.") + db.add(row); db.flush(); row.erp_reference=f"ERP-TDS-{tenant}-{row.id:07d}"; db.commit() + q=f"?client_id={client_id}&tally_guid={quote(str(form.get('tally_guid') or ''))}&saved=1"; return RedirectResponse("/tools/accounting/tds/liability"+q,status_code=303) + except Exception as exc: + db.rollback(); return RedirectResponse("/tools/accounting/tds/liability?error="+quote(str(exc)),status_code=303) + finally:db.close() + + +@router.post("/liability/{instruction_id}/post") +async def post_liability(request:Request,instruction_id:int): + db=CommonSessionLocal() + try: + user,response=_require_partner(request,db,"accounting.tally.view") + if response:return response + form=await request.form(); validate_csrf(request,str(form.get("csrf_token") or "")); tenant=_tenant_id(request,user) + row=db.execute(select(AccountingTDSPostingInstruction).where(AccountingTDSPostingInstruction.id==instruction_id,AccountingTDSPostingInstruction.tenant_id==tenant)).scalar_one_or_none() + if not row: raise ValueError("Posting instruction not found.") + clients,scope=_visible_clients(db,request,user); client=next((c for c in clients if int(c.id)==int(row.client_id)),None) + if not client: raise ValueError("Client is outside your current access scope.") + node=get_active_storage_node_for_branch(db,scope.tenant_id,scope.branch_id) + if not node or not _node_online(node): raise ValueError("Local Agent is offline.") + voucher_date=str(form.get("voucher_date") or (row.source_voucher_date.isoformat() if row.source_voucher_date else date.today().isoformat())) + result=request_agent_command(node.node_code,"accounting_post_tds_liability",{**_accounting_storage_payload(client,_financial_year_for_date(date.fromisoformat(voucher_date))),"tally_guid":str(form.get("tally_guid") or row.company_guid or ""),"voucher_date":voucher_date,"party_ledger":row.party_ledger,"tds_ledger":str(form.get("tds_ledger") or row.tds_ledger),"amount":row.liability_amount,"reference":row.erp_reference,"narration":row.narration or f"TDS liability {row.erp_reference}"},timeout_seconds=60) + if not result.get("ok"): raise ValueError(str(result.get("error") or "Tally posting failed.")) + rr=result.get("result") or {}; row.status="posted"; row.tds_ledger=str(form.get("tds_ledger") or row.tds_ledger); row.posted_by_user_id=user.id; row.posted_at_utc=datetime.utcnow(); row.tally_voucher_id=str(rr.get("last_voucher_id") or rr.get("voucher_id") or "") or None; db.commit() + return RedirectResponse(f"/tools/accounting/tds/liability?client_id={row.client_id}&saved=1",status_code=303) + except Exception as exc: + db.rollback(); return RedirectResponse("/tools/accounting/tds/liability?error="+quote(str(exc)),status_code=303) + finally:db.close() + + +def _parse_external(text:str): + rows=[] + for n,line in enumerate((text or '').splitlines(),1): + if not line.strip(): continue + parts=[x.strip() for x in line.split(',')] + if len(parts)<3: continue + try: amount=float(parts[2].replace(',','')) + except: continue + rows.append({"pan":parts[0].upper(),"section":parts[1].upper(),"amount":round(amount,2),"line":n}) + return rows + + +@router.get("/reconciliation") +def reconciliation(request:Request,client_id:int|None=None): + db=CommonSessionLocal() + try: + user,response=_require_partner(request,db,"accounting.tally.view") + if response:return response + 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) + return _render(request,db,user,"modules/accounting/templates/accounting/tds_reconciliation.html",clients=clients,selected_client=selected,result=None,error="") + finally:db.close() + + +@router.post("/reconciliation") +async def reconciliation_run(request:Request): + db=CommonSessionLocal() + try: + user,response=_require_partner(request,db,"accounting.tally.view") + if response:return response + form=await request.form(); validate_csrf(request,str(form.get("csrf_token") or "")); clients,scope=_visible_clients(db,request,user); client_id=int(form.get("client_id")); selected=next((c for c in clients if int(c.id)==client_id),None) + if not selected: raise ValueError("Select a client.") + node=get_active_storage_node_for_branch(db,scope.tenant_id,scope.branch_id) + if not node or not _node_online(node): raise ValueError("Local Agent is offline.") + dfrom=str(form.get("date_from") or ""); dto=str(form.get("date_to") or ""); guid=str(form.get("tally_guid") or "") + rr=request_agent_command(node.node_code,"accounting_tds_compliance",{**_accounting_storage_payload(selected,_financial_year_for_date(date.fromisoformat(dfrom))),"tally_guid":guid,"date_from":dfrom,"date_to":dto,"rules":_rule_payload(_rules(db,_tenant_id(request,user)))},timeout_seconds=120) + if not rr.get("ok"): raise ValueError(str(rr.get("error") or "Tally review failed.")) + review=(rr.get("result") or {}).get("tds_review") or {}; tally_total=round(sum(float(x.get("actual_tds") or 0) for x in review.get("transactions",[])),2); expected_total=round(sum(float(x.get("expected_tds") or 0) for x in review.get("transactions",[])),2) + returns=_parse_external(str(form.get("return_data") or "")); challans=_parse_external(str(form.get("challan_data") or "")); traces=_parse_external(str(form.get("traces_data") or "")) + result={"tally_actual":tally_total,"tally_expected":expected_total,"return_total":round(sum(x['amount'] for x in returns),2),"challan_total":round(sum(x['amount'] for x in challans),2),"traces_total":round(sum(x['amount'] for x in traces),2),"return_rows":len(returns),"challan_rows":len(challans),"traces_rows":len(traces)} + result["tally_vs_return"]=round(result["tally_actual"]-result["return_total"],2); result["return_vs_challan"]=round(result["return_total"]-result["challan_total"],2); result["return_vs_traces"]=round(result["return_total"]-result["traces_total"],2) + return _render(request,db,user,"modules/accounting/templates/accounting/tds_reconciliation.html",clients=clients,selected_client=selected,result=result,error="",date_from=dfrom,date_to=dto,tally_guid=guid,return_data=form.get("return_data") or "",challan_data=form.get("challan_data") or "",traces_data=form.get("traces_data") or "") + except Exception as exc: + db.rollback(); clients,_=_visible_clients(db,request,user); return _render(request,db,user,"modules/accounting/templates/accounting/tds_reconciliation.html",clients=clients,selected_client=None,result=None,error=str(exc)) + finally:db.close() diff --git a/app/modules/accounting/templates/accounting/tally.html b/app/modules/accounting/templates/accounting/tally.html index fb88351..3785def 100644 --- a/app/modules/accounting/templates/accounting/tally.html +++ b/app/modules/accounting/templates/accounting/tally.html @@ -53,6 +53,21 @@ +
+
+
+
TDS & Withholding Tax
+
Independent TDS tools covering expenses, purchases, capital purchases, liability entries and downstream reconciliation.
+
+ TDS Rule Master +
+
+ TDS Compliance Review + TDS Entry & Liability + TDS Reconciliation +
+
+
Sales & Income
diff --git a/app/modules/accounting/templates/accounting/tds_compliance.html b/app/modules/accounting/templates/accounting/tds_compliance.html new file mode 100644 index 0000000..bd332d9 --- /dev/null +++ b/app/modules/accounting/templates/accounting/tds_compliance.html @@ -0,0 +1,5 @@ +{% extends "base.html" %} +{% block content %} +
+

TDS & Withholding Tax

TDS Compliance Review

Read-only Tally scan across expenses, purchases, capital/service purchases, journals and creditor postings. Rules and thresholds come from the editable TDS Rule Master.

{% if error %}
{{ error }}
{% endif %}
{% if review %}
{% for k,v in review.summary.items() %}
{{ k|replace('_',' ') }}
{{ v }}
{% endfor %}
{% for x in review.transactions %}{% endfor %}
DatePartyLedger / RuleBaseExpectedActualLiabilityStatus
{{ x.date }}{{ x.party }}{{ x.nature_ledger }}
{{ x.rule_name }} · {{ x.legacy_section }}
{{ '%.2f'|format(x.taxable_base) }}{{ '%.2f'|format(x.expected_tds) }}{{ '%.2f'|format(x.actual_tds) }}{{ '%.2f'|format(x.liability) }}{{ x.status }}
{% endif %}
+{% endblock %} diff --git a/app/modules/accounting/templates/accounting/tds_liability.html b/app/modules/accounting/templates/accounting/tds_liability.html new file mode 100644 index 0000000..7d98339 --- /dev/null +++ b/app/modules/accounting/templates/accounting/tds_liability.html @@ -0,0 +1,5 @@ +{% extends "base.html" %} +{% block content %} +
+

TDS & Withholding Tax

TDS Entry & Liability

Calculate missing/short TDS, prepare structured ERP posting instructions, then post only after explicit user action. Prepared instructions are future-agent ready and idempotently referenced.

Tally
{% if saved %}
TDS instruction updated.
{% endif %}{% if error %}
{{ error }}
{% endif %}
{% if review %}

Unbooked / short TDS

{% for x in review.transactions if x.liability>0 %}
Party
{{ x.party }}
Voucher
{{ x.date }} · {{ x.voucher_number }}
Rule
{{ x.rule_name }}
Expected
{{ '%.2f'|format(x.expected_tds) }}
Already booked
{{ '%.2f'|format(x.actual_tds) }}
{% else %}
No positive liability identified in this scan.
{% endfor %}
{% endif %}{% if instructions %}

Prepared / Posted Instructions

{% for i in instructions %}
{{ i.erp_reference }} · {{ i.party_ledger }}
{{ i.statutory_reference }} · Status {{ i.status }}
₹{{ '%.2f'|format(i.liability_amount) }}
{% if i.status!='posted' %}
{% else %}
Posted to Tally{% if i.tally_voucher_id %} · Voucher {{ i.tally_voucher_id }}{% endif %}
{% endif %}
{% endfor %}
{% endif %}
+{% endblock %} diff --git a/app/modules/accounting/templates/accounting/tds_reconciliation.html b/app/modules/accounting/templates/accounting/tds_reconciliation.html new file mode 100644 index 0000000..710589b --- /dev/null +++ b/app/modules/accounting/templates/accounting/tds_reconciliation.html @@ -0,0 +1,5 @@ +{% extends "base.html" %} +{% block content %} +
+

TDS & Withholding Tax

TDS Reconciliation

Compare live Tally TDS with return/form, challan and TRACES totals. Paste exports as one row per line: PAN, section/code, amount.

Tally
{% if error %}
{{ error }}
{% endif %}
{% if result %}
{% for label,value in [('Tally booked TDS',result.tally_actual),('Expected TDS',result.tally_expected),('Return / Forms',result.return_total),('Challans',result.challan_total),('TRACES',result.traces_total)] %}
{{ label }}
₹{{ '%.2f'|format(value) }}
{% endfor %}
Tally − Return: ₹{{ '%.2f'|format(result.tally_vs_return) }}
Return − Challan: ₹{{ '%.2f'|format(result.return_vs_challan) }}
Return − TRACES: ₹{{ '%.2f'|format(result.return_vs_traces) }}
{% endif %}
+{% endblock %} diff --git a/app/modules/accounting/templates/accounting/tds_rules.html b/app/modules/accounting/templates/accounting/tds_rules.html new file mode 100644 index 0000000..38ffd7f --- /dev/null +++ b/app/modules/accounting/templates/accounting/tds_rules.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} +{% block content %} +
+

TDS & Withholding Tax

TDS Rule Master

Default rules are seeded for each firm and remain editable. Historical effective-date versions can be retained instead of hard-coding thresholds in Python.

Back to Tally
+{% if saved %}
Rule saved.
{% endif %}{% if error %}
{{ error }}
{% endif %} +
{% for r in rules %}
{% endfor %}
+{% endblock %} diff --git a/app/modules/documents/agent_package.py b/app/modules/documents/agent_package.py index 5199fb6..e3f0d9c 100644 --- a/app/modules/documents/agent_package.py +++ b/app/modules/documents/agent_package.py @@ -4,7 +4,7 @@ import io from pathlib import Path import zipfile -ERP_LOCAL_AGENT_VERSION = "1.22.4" +ERP_LOCAL_AGENT_VERSION = "1.22.5" 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) diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py index eea0808..38f20f2 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py @@ -1,2 +1,2 @@ -__version__ = "1.22.4" +__version__ = "1.22.5" AGENT_NAME = "ERP Local Agent" diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py b/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py index 302e12e..9f1adae 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py @@ -58,6 +58,10 @@ class AgentCommandProcessor: result = self._bank_reconciliation_extract(payload) elif action == "accounting_cash_payment_compliance": result = self._cash_payment_compliance(payload) + elif action == "accounting_tds_compliance": + result = self._tds_compliance(payload) + elif action == "accounting_post_tds_liability": + result = self._post_tds_liability(payload) elif action == "accounting_post_bank_voucher": result = self._post_bank_voucher(payload) elif action == "accounting_native_voucher_preflight": @@ -799,6 +803,84 @@ class AgentCommandProcessor: "agent": self._agent_info(), } + def _tds_compliance(self, payload: dict[str, Any]) -> dict[str, Any]: + from collections import defaultdict + from datetime import date as _date + + company, company_name = self._resolve_open_company(payload) + date_from = str(payload.get("date_from") or "").strip() + date_to = str(payload.get("date_to") or "").strip() + rules = list(payload.get("rules") or []) + if not rules: + raise ValueError("No active TDS rules were supplied by ERP.") + vouchers = self.tally.export_vouchers(company_name, date_from, date_to) + # TDS ledgers are identified by both ledger name and parent/group text; this is deliberately broader than one fixed ledger name. + masters = self.tally.export_master_collection(company_name, "ledgers") + tds_ledger_names = set() + for led in masters: + name = str(led.get("name") or "").strip() + parent = str(led.get("parent") or "").strip().casefold() + text = (name + " " + parent).casefold() + if name and ("tds" in text or "tax deducted" in text or "duties & taxes" in parent and "income tax" in text): + tds_ledger_names.add(name.casefold()) + + candidates = [] + for v in vouchers: + if str(v.get("is_cancelled") or "").lower() in {"yes","true","1"} or str(v.get("is_optional") or "").lower() in {"yes","true","1"}: + continue + entries = list(v.get("ledger_entries") or []) + actual_tds = round(sum(abs(float(e.get("amount") or 0)) for e in entries if str(e.get("ledger_name") or "").strip().casefold() in tds_ledger_names), 2) + party = str(v.get("party_ledger_name") or "").strip() + non_tds = [e for e in entries if str(e.get("ledger_name") or "").strip().casefold() not in tds_ledger_names] + for e in non_tds: + lname = str(e.get("ledger_name") or "").strip() + amount = abs(float(e.get("amount") or 0)) + if not lname or amount <= 0 or (party and lname.casefold() == party.casefold()): + continue + text = lname.casefold() + matches=[] + for r in rules: + kws=[x.strip().casefold() for x in str(r.get("nature_keywords") or "").split(',') if x.strip()] + if any(k in text for k in kws): matches.append(r) + if not matches: continue + # More specific/longer keyword match first; user can refine keywords in Rule Master. + rule=max(matches,key=lambda r:max([len(k.strip()) for k in str(r.get("nature_keywords") or "").split(',') if k.strip()] or [0])) + candidates.append({"date":str(v.get("date") or v.get("effective_date") or ""),"party":party or "Unidentified party","nature_ledger":lname,"taxable_base":round(amount,2),"actual_tds":actual_tds,"voucher_number":str(v.get("voucher_number") or ""),"voucher_type":str(v.get("voucher_type_name") or ""),"reference":str(v.get("reference") or ""),"rule":rule}) + + by_party_rule=defaultdict(list) + for x in candidates: by_party_rule[(x["party"].casefold(),str(x["rule"].get("rule_code")))].append(x) + transactions=[] + for (_party, _code), rows in by_party_rule.items(): + rows.sort(key=lambda x:x["date"]) + cumulative=0.0; monthly=defaultdict(float) + for x in rows: + r=x["rule"]; base=x["taxable_base"]; cumulative=round(cumulative+base,2); month=str(x["date"] or "")[:7]; monthly[month]=round(monthly[month]+base,2) + mode=str(r.get("threshold_mode") or "ANNUAL").upper(); single=float(r.get("single_threshold") or 0); agg=float(r.get("aggregate_threshold") or 0) + applicable=False; taxable_for_tds=base + if mode=="SINGLE": applicable=base>single + elif mode=="MONTHLY": applicable=monthly[month]>single + elif mode=="BOTH": applicable=(single>0 and base>single) or (agg>0 and cumulative>agg) + else: applicable=(agg<=0 or cumulative>agg) + if applicable and bool(r.get("deduct_on_excess_only")) and agg>0: + prior=max(0.0,cumulative-base); taxable_for_tds=max(0.0,cumulative-max(prior,agg)); taxable_for_tds=min(base,taxable_for_tds) + rate=float(r.get("rate_other") or r.get("rate_individual_huf") or 0) + expected=round(taxable_for_tds*rate/100.0,2) if applicable else 0.0 + actual=min(round(float(x["actual_tds"] or 0),2), expected if expected>0 else float(x["actual_tds"] or 0)) + liability=round(max(0.0,expected-actual),2) + status="OK" if expected>0 and liability<=0.009 else ("TDS NOT DEDUCTED" if expected>0 and actual<=0.009 else ("SHORT DEDUCTION" if liability>0 else "BELOW / OUTSIDE THRESHOLD")) + transactions.append({**{k:v for k,v in x.items() if k!='rule'},"rule_id":r.get("id"),"rule_code":r.get("rule_code"),"rule_name":r.get("name"),"legacy_section":r.get("legacy_section"),"statutory_reference":r.get("statutory_reference"),"cumulative_amount":cumulative,"expected_tds":expected,"actual_tds":actual,"liability":liability,"status":status}) + return {"tds_review":{"company_name":company_name,"company_guid":str(company.get("guid") or ""),"date_from":date_from,"date_to":date_to,"summary":{"vouchers_reviewed":len(vouchers),"candidate_transactions":len(transactions),"tds_not_deducted":sum(1 for x in transactions if x["status"]=="TDS NOT DEDUCTED"),"short_deduction":sum(1 for x in transactions if x["status"]=="SHORT DEDUCTION"),"purchase_transactions":sum(1 for x in transactions if x["rule_code"]=="PURCHASE_GOODS")},"transactions":transactions,"tds_ledgers":sorted(tds_ledger_names),"read_only":True},"agent":self._agent_info()} + + def _post_tds_liability(self, payload: dict[str, Any]) -> dict[str, Any]: + company, company_name = self._resolve_open_company(payload) + voucher_date=str(payload.get("voucher_date") or "").strip(); party=str(payload.get("party_ledger") or "").strip(); tds=str(payload.get("tds_ledger") or "").strip(); amount=round(float(payload.get("amount") or 0),2); ref=str(payload.get("reference") or "").strip() + if not voucher_date or not party or not tds or amount<=0 or not ref: raise ValueError("Voucher date, party ledger, TDS ledger, positive amount and ERP reference are required.") + for v in self.tally.export_vouchers(company_name,voucher_date,voucher_date): + if ref and ref in {str(v.get("reference") or "").strip(), str(v.get("narration") or "").strip()}: + raise ValueError(f"TDS entry {ref} already exists in Tally; duplicate posting blocked.") + result=self.tally.post_journal_voucher(company_name,voucher_date=voucher_date,debit_ledger=party,credit_ledger=tds,amount=amount,narration=str(payload.get("narration") or f"TDS liability {ref}"),reference=ref) + return {"posted":True,"company_name":company_name,"reference":ref,"last_voucher_id":result.get("last_voucher_id"),"result":result,"agent":self._agent_info()} + def _bank_posting_preflight(self, payload: dict[str, Any]) -> dict[str, Any]: company, company_name = self._resolve_open_company(payload) ledgers = self.tally.bank_posting_ledgers(company_name) diff --git a/app/modules/services/task_tools.py b/app/modules/services/task_tools.py index 8e61be4..22f490c 100644 --- a/app/modules/services/task_tools.py +++ b/app/modules/services/task_tools.py @@ -37,6 +37,18 @@ _TASK_TOOLS: tuple[TaskToolDefinition, ...] = ( description="Review Tally cash payments against the configurable limit, same-day aggregation and possible split-payment patterns.", launch_url="/tools/accounting/cash-payments", ), + TaskToolDefinition( + code="ACCOUNTING_TDS_COMPLIANCE", + name="TDS Compliance Review", + description="Review expenses, purchases, capital purchases and creditor transactions in Tally for TDS applicability, threshold crossing and short/non-deduction.", + launch_url="/tools/accounting/tds/compliance", + ), + TaskToolDefinition( + code="ACCOUNTING_TDS_RECONCILIATION", + name="TDS Reconciliation", + description="Compare Tally TDS liability with return/form, challan and TRACES data.", + launch_url="/tools/accounting/tds/reconciliation", + ), ) diff --git a/app/ui/app.py b/app/ui/app.py index a4c809e..4a95467 100644 --- a/app/ui/app.py +++ b/app/ui/app.py @@ -60,6 +60,7 @@ from app.modules.accounting.opening_balance_ui import router as accounting_openi from app.modules.accounting.automatic_voucher_ui import router as accounting_automatic_voucher_ui_router from app.modules.accounting.master_integrity_ui import router as accounting_master_integrity_ui_router from app.modules.accounting.cash_payment_ui import router as accounting_cash_payment_ui_router +from app.modules.accounting.tds_ui import router as accounting_tds_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 @@ -104,6 +105,7 @@ def mount_ui(app: FastAPI) -> None: app.include_router(accounting_automatic_voucher_ui_router) app.include_router(accounting_master_integrity_ui_router) app.include_router(accounting_cash_payment_ui_router) + app.include_router(accounting_tds_ui_router) app.include_router(work_tracker_ui_router) app.include_router(billing_ui_router) app.include_router(platform_billing_ui_router)