Add TDS compliance liability reconciliation and rule master tools
This commit is contained in:
@@ -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)
|
||||
@@ -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 & 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 & 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 & 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 & 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 & 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 %}
|
||||
Reference in New Issue
Block a user