250 lines
21 KiB
Python
250 lines
21 KiB
Python
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=""; 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):
|
|
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)
|
|
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)),history=history)
|
|
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}"},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))),"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()
|