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
Read-only Tally scan across expenses, purchases, capital/service purchases, journals and creditor postings. Rules and thresholds come from the editable TDS Rule Master.
| Date | Party | Ledger / Rule | Base | Expected | Actual | Liability | Status |
|---|---|---|---|---|---|---|---|
| {{ 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 }} |
TDS & Withholding Tax
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.
TDS & Withholding Tax
Compare live Tally TDS with return/form, challan and TRACES totals. Paste exports as one row per line: PAN, section/code, amount.
TDS & Withholding Tax
Default rules are seeded for each firm and remain editable. Historical effective-date versions can be retained instead of hard-coding thresholds in Python.