from __future__ import annotations from datetime import date, datetime from decimal import Decimal, InvalidOperation from urllib.parse import quote from uuid import uuid4 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=""; history=[] if selected and node and online: storage = _accounting_storage_payload(selected,_financial_year_for_date(date.fromisoformat(start))) st=request_agent_command(node.node_code,"accounting_mirror_status",storage,timeout_seconds=20) if st.get("ok"): live=st.get("result") or {} else: error=str(st.get("error") or "Accounting Mirror status failed.") if run and not error: rr=request_agent_command( node.node_code, "accounting_tds_compliance", { **storage, "date_from":start, "date_to":end, "rules":_rule_payload(_rules(db,_tenant_id(request,user))), "requested_by_user_id":int(user.id), }, 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.") try: hist=request_agent_command( node.node_code,"accounting_analysis_history", {**storage,"analysis_type":"TDS_COMPLIANCE","limit":20},timeout_seconds=20 ) if hist.get("ok"): history=(hist.get("result") or {}).get("runs") or [] except Exception: history=[] return clients, selected, node, online, live, review, start, end, error, history @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("") @router.get("/") def tds_root(request: Request, client_id: int | None = None): """Backward-compatible TDS landing route.""" suffix = f"?client_id={int(client_id)}" if client_id else "" return RedirectResponse(url="/tools/accounting/tds/compliance" + suffix, status_code=302) @router.get("/compliance") def compliance(request:Request,client_id:int|None=None,tally_guid:str="",date_from:str="",date_to:str="",run:int=0,run_token: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,error,history=_workspace(request,db,user,client_id,tally_guid,date_from,date_to,run) response=_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)),history=history, run_token=(run_token or uuid4().hex) ) response.headers["Cache-Control"]="no-store, no-cache, must-revalidate, max-age=0" response.headers["Pragma"]="no-cache" return response 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,history=_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,history=history) 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}","posted_by_user_id":int(user.id)},timeout_seconds=120) 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))),"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()