Add TDS compliance liability reconciliation and rule master tools

This commit is contained in:
A R R R Associates
2026-09-03 22:43:44 +05:30
parent e341de5ecb
commit c03703efa4
13 changed files with 445 additions and 2 deletions
+69
View File
@@ -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)
+221
View File
@@ -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()
@@ -53,6 +53,21 @@
</div>
</div>
<div class="rounded-2xl border border-violet-200 bg-violet-50/40 p-4">
<div class="mb-3 flex flex-wrap items-start justify-between gap-3">
<div>
<div class="text-xs font-semibold uppercase tracking-[0.14em] text-violet-700">TDS &amp; Withholding Tax</div>
<div class="text-xs text-slate-500">Independent TDS tools covering expenses, purchases, capital purchases, liability entries and downstream reconciliation.</div>
</div>
<a href="/tools/accounting/tds/rules" class="rounded-xl border border-violet-300 bg-white px-3 py-2 text-sm font-semibold text-violet-800">TDS Rule Master</a>
</div>
<div class="flex flex-wrap gap-2">
<a href="/tools/accounting/tds/compliance{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-violet-300 bg-white px-3 py-2 text-sm font-semibold text-violet-800">TDS Compliance Review</a>
<a href="/tools/accounting/tds/liability{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-violet-300 bg-white px-3 py-2 text-sm font-semibold text-violet-800">TDS Entry &amp; Liability</a>
<a href="/tools/accounting/tds/reconciliation{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-violet-300 bg-white px-3 py-2 text-sm font-semibold text-violet-800">TDS Reconciliation</a>
</div>
</div>
<div class="rounded-2xl border border-emerald-200 bg-emerald-50/40 p-4">
<div class="mb-3">
<div class="text-xs font-semibold uppercase tracking-[0.14em] text-emerald-700">Sales & Income</div>
@@ -0,0 +1,5 @@
{% extends "base.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-3"><div><p class="text-xs font-semibold uppercase tracking-[0.16em] text-violet-600">TDS &amp; Withholding Tax</p><h1 class="mt-1 text-2xl font-semibold text-slate-900">TDS Compliance Review</h1><p class="mt-1 text-sm text-slate-500">Read-only Tally scan across expenses, purchases, capital/service purchases, journals and creditor postings. Rules and thresholds come from the editable TDS Rule Master.</p></div><div class="flex gap-2"><a href="/tools/accounting/tds/rules" class="rounded-xl border px-3 py-2 text-sm font-semibold">Rule Master</a><a href="/tools/tally" class="rounded-xl border px-3 py-2 text-sm font-semibold">Tally</a></div></div>{% if error %}<div class="rounded-xl bg-red-50 p-3 text-sm text-red-800">{{ error }}</div>{% endif %}<form method="get" class="rounded-2xl bg-white p-5 shadow-soft"><div class="grid gap-3 lg:grid-cols-4"><label class="text-sm">Client<select name="client_id" required class="mt-1 w-full rounded-xl border px-3 py-2"><option value="">Select client</option>{% for c in clients %}<option value="{{ c.id }}" {% if selected_client and c.id==selected_client.id %}selected{% endif %}>{{ c.client_name }}</option>{% endfor %}</select></label><label class="text-sm">Tally company GUID<input name="tally_guid" value="{{ tally_guid or '' }}" required class="mt-1 w-full rounded-xl border px-3 py-2" placeholder="Select/copy GUID from mapped/open company"></label><label class="text-sm">From<input type="date" name="date_from" value="{{ date_from }}" required class="mt-1 w-full rounded-xl border px-3 py-2"></label><label class="text-sm">To<input type="date" name="date_to" value="{{ date_to }}" required class="mt-1 w-full rounded-xl border px-3 py-2"></label></div><input type="hidden" name="run" value="1"><button class="mt-4 rounded-xl bg-violet-700 px-4 py-2 text-sm font-semibold text-white">Run TDS Review</button></form>{% if review %}<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">{% for k,v in review.summary.items() %}<div class="rounded-xl bg-white p-4 shadow-soft"><div class="text-xs uppercase text-slate-500">{{ k|replace('_',' ') }}</div><div class="mt-1 text-xl font-semibold">{{ v }}</div></div>{% endfor %}</div><div class="overflow-x-auto rounded-2xl bg-white shadow-soft"><table class="min-w-full text-sm"><thead class="bg-slate-50"><tr><th class="p-3 text-left">Date</th><th class="p-3 text-left">Party</th><th class="p-3 text-left">Ledger / Rule</th><th class="p-3 text-right">Base</th><th class="p-3 text-right">Expected</th><th class="p-3 text-right">Actual</th><th class="p-3 text-right">Liability</th><th class="p-3 text-left">Status</th></tr></thead><tbody>{% for x in review.transactions %}<tr class="border-t"><td class="p-3">{{ x.date }}</td><td class="p-3">{{ x.party }}</td><td class="p-3">{{ x.nature_ledger }}<div class="text-xs text-slate-500">{{ x.rule_name }} · {{ x.legacy_section }}</div></td><td class="p-3 text-right">{{ '%.2f'|format(x.taxable_base) }}</td><td class="p-3 text-right">{{ '%.2f'|format(x.expected_tds) }}</td><td class="p-3 text-right">{{ '%.2f'|format(x.actual_tds) }}</td><td class="p-3 text-right font-semibold">{{ '%.2f'|format(x.liability) }}</td><td class="p-3">{{ x.status }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}</div>
{% endblock %}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,5 @@
{% extends "base.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-3"><div><p class="text-xs font-semibold uppercase tracking-[0.16em] text-violet-600">TDS &amp; Withholding Tax</p><h1 class="mt-1 text-2xl font-semibold text-slate-900">TDS Reconciliation</h1><p class="mt-1 text-sm text-slate-500">Compare live Tally TDS with return/form, challan and TRACES totals. Paste exports as one row per line: PAN, section/code, amount.</p></div><a href="/tools/tally" class="rounded-xl border px-3 py-2 text-sm font-semibold">Tally</a></div>{% if error %}<div class="rounded-xl bg-red-50 p-3 text-sm text-red-800">{{ error }}</div>{% endif %}<form method="post" class="rounded-2xl bg-white p-5 shadow-soft"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><div class="grid gap-3 lg:grid-cols-4"><label class="text-sm">Client<select name="client_id" required class="mt-1 w-full rounded-xl border px-3 py-2"><option value="">Select client</option>{% for c in clients %}<option value="{{ c.id }}" {% if selected_client and c.id==selected_client.id %}selected{% endif %}>{{ c.client_name }}</option>{% endfor %}</select></label><label class="text-sm">Tally GUID<input name="tally_guid" value="{{ tally_guid or '' }}" required class="mt-1 w-full rounded-xl border px-3 py-2"></label><label class="text-sm">From<input type="date" name="date_from" value="{{ date_from or '' }}" required class="mt-1 w-full rounded-xl border px-3 py-2"></label><label class="text-sm">To<input type="date" name="date_to" value="{{ date_to or '' }}" required class="mt-1 w-full rounded-xl border px-3 py-2"></label></div><div class="mt-4 grid gap-3 lg:grid-cols-3"><label class="text-sm">TDS return / form export<textarea name="return_data" rows="8" class="mt-1 w-full rounded-xl border p-2 font-mono text-xs">{{ return_data or '' }}</textarea></label><label class="text-sm">Challan export<textarea name="challan_data" rows="8" class="mt-1 w-full rounded-xl border p-2 font-mono text-xs">{{ challan_data or '' }}</textarea></label><label class="text-sm">TRACES export<textarea name="traces_data" rows="8" class="mt-1 w-full rounded-xl border p-2 font-mono text-xs">{{ traces_data or '' }}</textarea></label></div><button class="mt-4 rounded-xl bg-violet-700 px-4 py-2 text-sm font-semibold text-white">Run Reconciliation</button></form>{% if result %}<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">{% 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)] %}<div class="rounded-xl bg-white p-4 shadow-soft"><div class="text-xs uppercase text-slate-500">{{ label }}</div><div class="mt-1 text-xl font-semibold">₹{{ '%.2f'|format(value) }}</div></div>{% endfor %}</div><div class="grid gap-3 md:grid-cols-3"><div class="rounded-xl bg-white p-4 shadow-soft">Tally − Return: <b>₹{{ '%.2f'|format(result.tally_vs_return) }}</b></div><div class="rounded-xl bg-white p-4 shadow-soft">Return − Challan: <b>₹{{ '%.2f'|format(result.return_vs_challan) }}</b></div><div class="rounded-xl bg-white p-4 shadow-soft">Return − TRACES: <b>₹{{ '%.2f'|format(result.return_vs_traces) }}</b></div></div>{% endif %}</div>
{% endblock %}
@@ -0,0 +1,7 @@
{% extends "base.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-3"><div><p class="text-xs font-semibold uppercase tracking-[0.16em] text-violet-600">TDS &amp; Withholding Tax</p><h1 class="mt-1 text-2xl font-semibold text-slate-900">TDS Rule Master</h1><p class="mt-1 text-sm text-slate-500">Default rules are seeded for each firm and remain editable. Historical effective-date versions can be retained instead of hard-coding thresholds in Python.</p></div><a href="/tools/tally" class="rounded-xl border px-3 py-2 text-sm font-semibold">Back to Tally</a></div>
{% if saved %}<div class="rounded-xl bg-emerald-50 p-3 text-sm text-emerald-800">Rule saved.</div>{% endif %}{% if error %}<div class="rounded-xl bg-red-50 p-3 text-sm text-red-800">{{ error }}</div>{% endif %}
<div class="space-y-4">{% for r in rules %}<form method="post" action="/tools/accounting/tds/rules/{{ r.id }}" class="rounded-2xl bg-white p-5 shadow-soft"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><div class="grid gap-3 lg:grid-cols-4"><label class="text-sm">Rule name<input name="name" value="{{ r.name }}" class="mt-1 w-full rounded-xl border px-3 py-2"></label><label class="text-sm">New Act reference<input name="statutory_reference" value="{{ r.statutory_reference }}" class="mt-1 w-full rounded-xl border px-3 py-2"></label><label class="text-sm">Legacy section<input name="legacy_section" value="{{ r.legacy_section or '' }}" class="mt-1 w-full rounded-xl border px-3 py-2"></label><label class="text-sm">Threshold mode<select name="threshold_mode" class="mt-1 w-full rounded-xl border px-3 py-2">{% for m in ['ANNUAL','SINGLE','BOTH','MONTHLY'] %}<option {% if r.threshold_mode==m %}selected{% endif %}>{{ m }}</option>{% endfor %}</select></label><label class="text-sm">Single / monthly threshold<input name="single_threshold" value="{{ r.single_threshold }}" class="mt-1 w-full rounded-xl border px-3 py-2"></label><label class="text-sm">Annual aggregate threshold<input name="aggregate_threshold" value="{{ r.aggregate_threshold }}" class="mt-1 w-full rounded-xl border px-3 py-2"></label><label class="text-sm">Rate - Individual/HUF %<input name="rate_individual_huf" value="{{ r.rate_individual_huf }}" class="mt-1 w-full rounded-xl border px-3 py-2"></label><label class="text-sm">Rate - Other %<input name="rate_other" value="{{ r.rate_other }}" class="mt-1 w-full rounded-xl border px-3 py-2"></label><label class="text-sm">No-PAN rate %<input name="no_pan_rate" value="{{ r.no_pan_rate }}" class="mt-1 w-full rounded-xl border px-3 py-2"></label><label class="text-sm lg:col-span-3">Ledger/nature keywords<input name="nature_keywords" value="{{ r.nature_keywords }}" class="mt-1 w-full rounded-xl border px-3 py-2"></label></div><div class="mt-3 flex flex-wrap gap-4 text-sm"><label><input type="checkbox" name="deduct_on_excess_only" {% if r.deduct_on_excess_only %}checked{% endif %}> Deduct only on amount exceeding threshold</label><label><input type="checkbox" name="applies_to_purchases" {% if r.applies_to_purchases %}checked{% endif %}> Purchases</label><label><input type="checkbox" name="applies_to_expenses" {% if r.applies_to_expenses %}checked{% endif %}> Expenses/services</label><label><input type="checkbox" name="is_active" {% if r.is_active %}checked{% endif %}> Active</label></div><button class="mt-4 rounded-xl bg-violet-700 px-4 py-2 text-sm font-semibold text-white">Save Rule</button></form>{% endfor %}</div></div>
{% endblock %}
+1 -1
View File
@@ -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)
@@ -1,2 +1,2 @@
__version__ = "1.22.4"
__version__ = "1.22.5"
AGENT_NAME = "ERP Local Agent"
@@ -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)
+12
View File
@@ -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",
),
)