Add operator workstation GST browser and full FY return download

This commit is contained in:
A R R R Associates
2026-09-10 22:18:32 +05:30
parent 90d7769bee
commit 0ce2954674
7 changed files with 497 additions and 115 deletions
+191 -85
View File
@@ -2,16 +2,20 @@ from __future__ import annotations
import calendar
import re
from datetime import date
import tempfile
import uuid
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from urllib.parse import urlencode
from fastapi import APIRouter, Form, Request
from fastapi.responses import RedirectResponse
import jwt
from fastapi import APIRouter, File, Form, Request, UploadFile
from fastapi.responses import FileResponse, JSONResponse, 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.settings import get_settings
from app.core.templating import templates
from app.modules.accounting.agent_bridge import request_agent_command
from app.modules.accounting.ui import _find_visible_client, _require_partner, _visible_clients, _node_online
@@ -19,10 +23,13 @@ from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
from app.modules.credential_vault.crypto import decrypt_value
from app.modules.credential_vault.models import CredentialVaultEntry
from app.modules.credential_vault.service import can_view_entry, log_access
from app.modules.documents.services import build_document_scope, client_folder_parts, get_active_storage_node_for_branch, sanitize_segment
from app.modules.documents.services import client_folder_parts, get_active_storage_node_for_branch, sanitize_segment
from app.modules.registrations.models import ClientRegistration, RegistrationType
router = APIRouter(prefix="/tools/accounting/gst-reconciliation", tags=["accounting-gst-reconciliation-ui"])
_TOKEN_PURPOSE = "gst_operator_browser_v1"
_TOKEN_MINUTES = 30
_UPLOAD_ROOT = Path(tempfile.gettempdir()) / "audit_firm_gst_operator_uploads"
def _fy_bounds(fy: str) -> tuple[date, date]:
@@ -33,6 +40,18 @@ def _fy_bounds(fy: str) -> tuple[date, date]:
return date(y, 4, 1), date(y + 1, 3, 31)
def _periods_for_fy(fy: str) -> list[str]:
start, _ = _fy_bounds(fy)
periods=[]
y=start.year; m=4
for _ in range(12):
periods.append(f"{m:02d}{y:04d}")
m += 1
if m == 13:
m = 1; y += 1
return periods
def _fy_for_period(period: str) -> str:
digits = re.sub(r"\D", "", period or "")
if len(digits) != 6:
@@ -75,24 +94,15 @@ def _credential_is_gst_portal(entry: CredentialVaultEntry) -> bool:
title = str(entry.title or "").strip().lower()
portal_url = str(entry.portal_url or "").strip().lower()
reference = str(entry.reference_number or "").strip().lower()
if category == "gst_portal":
return True
# Older/client-level vault entries may have been saved under a generic category.
# Accept them only when the entry itself clearly identifies a GST portal login.
haystack = " ".join((title, portal_url, reference))
gst_hint = (
"gst portal" in haystack
or "gst login" in haystack
or "services.gst.gov.in" in portal_url
or "www.gst.gov.in" in portal_url
"gst portal" in haystack or "gst login" in haystack
or "services.gst.gov.in" in portal_url or "www.gst.gov.in" in portal_url
)
if not gst_hint:
return False
# Do not silently substitute E-Way Bill, E-Invoice or API/provider secrets for
# a GST portal username/password unless the title explicitly says GST Portal/Login.
if category in {"eway_bill", "einvoice", "api_key"}:
return "gst portal" in title or "gst login" in title
return True
@@ -103,8 +113,6 @@ def _credential_matches_gstin(entry: CredentialVaultEntry, registration_id: int,
return True
if _norm_gstin(entry.reference_number) == _norm_gstin(gstin):
return True
# Client-level GST Portal credentials created before registration-level vault
# linking remain valid candidates for the selected client's GST registration.
return entry.registration_id is None
@@ -117,25 +125,14 @@ def _vault_entries(db, user, request, tenant_id: int, client_id: int, registrati
rows = db.execute(q.order_by(CredentialVaultEntry.title.asc())).scalars().all()
branch_id = request.session.get("active_branch_id") or getattr(user, "branch_id", None)
visible = [r for r in rows if can_view_entry(db, user, r, int(branch_id) if branch_id else None)]
if not registration_id:
return [r for r in visible if _credential_is_gst_portal(r)]
eligible = [
r for r in visible
if _credential_is_gst_portal(r) and _credential_matches_gstin(r, int(registration_id), gstin)
]
eligible = [r for r in visible if _credential_is_gst_portal(r) and _credential_matches_gstin(r, int(registration_id), gstin)]
def rank(entry: CredentialVaultEntry):
exact_registration = int(entry.registration_id or 0) == int(registration_id)
exact_gstin = _norm_gstin(entry.reference_number) == _norm_gstin(gstin)
client_level = entry.registration_id is None
return (
0 if exact_registration else 1 if exact_gstin else 2 if client_level else 3,
str(entry.title or "").lower(),
int(entry.id or 0),
)
return (0 if exact_registration else 1 if exact_gstin else 2 if client_level else 3, str(entry.title or "").lower(), int(entry.id or 0))
return sorted(eligible, key=rank)
@@ -143,9 +140,7 @@ def _storage_payload(client, fy: str, gstin: str) -> tuple[str, str]:
fy_folder = sanitize_segment(f"FY{fy}", "FY")
letter, client_folder = client_folder_parts(client, int(client.id))
root = Path(fy_folder) / "Clients" / letter / client_folder
accounting = root / "Accounting"
gst = root / "GST" / sanitize_segment(f"GSTIN_{gstin}", "GSTIN")
return accounting.as_posix(), gst.as_posix()
return (root / "Accounting").as_posix(), (root / "GST" / sanitize_segment(f"GSTIN_{gstin}", "GSTIN")).as_posix()
def _redirect(client_id: int, **params):
@@ -153,8 +148,30 @@ def _redirect(client_id: int, **params):
return RedirectResponse("/tools/accounting/gst-reconciliation?" + urlencode(data), status_code=303)
def _encode_operator_token(payload: dict) -> str:
now = datetime.now(timezone.utc)
body = {**payload, "purpose": _TOKEN_PURPOSE, "iat": now, "exp": now + timedelta(minutes=_TOKEN_MINUTES)}
return jwt.encode(body, get_settings().SECRET_KEY, algorithm="HS256")
def _decode_operator_token(token: str) -> dict:
data = jwt.decode(token, get_settings().SECRET_KEY, algorithms=["HS256"])
if data.get("purpose") != _TOKEN_PURPOSE:
raise ValueError("Invalid GST operator token.")
return data
def _selected_periods(download_mode: str, financial_year: str, period: str) -> list[str]:
if download_mode == "full_fy":
return _periods_for_fy(financial_year)
digits = re.sub(r"\D", "", period or "")
if len(digits) != 6:
raise ValueError("Enter a valid MMYYYY period for Single Month mode.")
return [digits]
@router.get("")
def page(request: Request, client_id: int | None = None, registration_id: int | None = None, period: str = "", message: str = "", error: str = ""):
def page(request: Request, client_id: int | None = None, registration_id: int | None = None, period: str = "", financial_year: str = "", download_mode: str = "single", message: str = "", error: str = ""):
db = CommonSessionLocal()
try:
user, response = _require_partner(request, db, "accounting.learning.view")
@@ -162,40 +179,37 @@ def page(request: Request, client_id: int | None = None, registration_id: int |
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)
registrations=[]; selected_reg=None; credentials=[]; node=None; status={}; analysis={}
registrations=[]; selected_reg=None; credentials=[]; node=None
if not financial_year:
financial_year = str(request.session.get("active_financial_year") or request.session.get("active_fy") or "2025-26")
if download_mode not in {"single", "full_fy"}:
download_mode = "single"
if selected:
registrations=[(r,t) for r,t in _gst_regs(db,scope.tenant_id,selected.id) if _is_gstin(r,t)]
selected_reg=next((r for r,t in registrations if registration_id and int(r.id)==int(registration_id)),None)
if not selected_reg and registrations:
selected_reg=registrations[0][0]
selected_gstin = _norm_gstin(getattr(selected_reg, "registration_number", "")) if selected_reg else ""
credentials=_vault_entries(
db,user,request,scope.tenant_id,selected.id,
int(selected_reg.id) if selected_reg else None, selected_gstin,
)
credentials=_vault_entries(db,user,request,scope.tenant_id,selected.id,int(selected_reg.id) if selected_reg else None, selected_gstin)
node=get_active_storage_node_for_branch(db,scope.tenant_id,scope.branch_id)
if selected_reg and period and node and _node_online(node):
gstin=re.sub(r"\s+","",str(selected_reg.registration_number or "").upper())
try:
status=(request_agent_command(node.node_code,"gst_return_download_status",{"client_id":selected.id,"gstin":gstin,"period":re.sub(r"\D","",period)},timeout_seconds=8).get("result") or {}).get("job") or {}
except Exception:
status={}
try:
job_result=status.get("result") or {}
# analysis is loaded only after an explicit Analyze action; status result is download manifest.
analysis={}
except Exception:
pass
operator_job = request.session.pop("gst_operator_job", None)
return templates.TemplateResponse("modules/accounting/templates/accounting/gst_reconciliation.html",{
"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),
"clients":clients,"selected_client":selected,"registrations":registrations,"selected_registration":selected_reg,"credentials":credentials,"node":node,"node_online":_node_online(node) if node else False,"period":period,"status":status,"analysis":analysis,"message":message,"error":error,"title":"GST Return Reconciliation",
"clients":clients,"selected_client":selected,"registrations":registrations,"selected_registration":selected_reg,"credentials":credentials,"node":node,"node_online":_node_online(node) if node else False,
"period":period,"financial_year":financial_year,"download_mode":download_mode,"operator_job":operator_job,"message":message,"error":error,"title":"GST Return Reconciliation",
})
finally:
db.close()
@router.post("/download/start")
def start_download(request: Request, client_id: int=Form(...), registration_id: int=Form(...), credential_id: int=Form(...), period: str=Form(...), include_2a: str=Form(""), csrf_token: str=Form(...)):
def start_download(
request: Request,
client_id: int=Form(...), registration_id: int=Form(...), credential_id: int=Form(...),
period: str=Form(""), financial_year: str=Form(...), download_mode: str=Form("single"),
gstr1: str=Form(""), gstr2b: str=Form(""), gstr3b: str=Form(""), gstr2a: str=Form(""),
csrf_token: str=Form(...),
):
validate_csrf(request,csrf_token)
db=CommonSessionLocal()
try:
@@ -205,36 +219,107 @@ def start_download(request: Request, client_id: int=Form(...), registration_id:
if not client: return _redirect(client_id,error="Client is not available in your scope.")
pair=next(((r,t) for r,t in _gst_regs(db,scope.tenant_id,client.id) if int(r.id)==registration_id and _is_gstin(r,t)),None)
if not pair: return _redirect(client_id,error="Select a valid GSTIN registration.")
reg,_=pair; gstin=re.sub(r"\s+","",str(reg.registration_number or "").upper())
reg,_=pair; gstin=_norm_gstin(reg.registration_number)
cred=db.get(CredentialVaultEntry,credential_id)
credential_ok = bool(
cred
and int(cred.tenant_id or 0) == int(scope.tenant_id)
and int(cred.client_id or 0) == int(client.id)
and str(cred.status or "").lower() != "archived"
and can_view_entry(db,user,cred,scope.branch_id)
and _credential_is_gst_portal(cred)
and _credential_matches_gstin(cred,int(reg.id),gstin)
)
credential_ok = bool(cred and int(cred.tenant_id or 0)==int(scope.tenant_id) and int(cred.client_id or 0)==int(client.id) and str(cred.status or "").lower()!="archived" and can_view_entry(db,user,cred,scope.branch_id) and _credential_is_gst_portal(cred) and _credential_matches_gstin(cred,int(reg.id),gstin))
if not credential_ok:
return _redirect(client_id,registration_id=registration_id,period=period,error="Selected GST Portal credential is not available for this client/GSTIN.")
username=decrypt_value(cred.tenant_id,cred.username_encrypted) or ""; password=decrypt_value(cred.tenant_id,cred.secret_encrypted) or ""
if not username or not password: return _redirect(client_id,registration_id=registration_id,period=period,error="GST username/password is missing in Credential Vault.")
fy=_fy_for_period(period); accounting_dir,gst_dir=_storage_payload(client,fy,gstin)
return _redirect(client_id,registration_id=registration_id,period=period,financial_year=financial_year,download_mode=download_mode,error="Selected GST Portal credential is not available for this client/GSTIN.")
node=get_active_storage_node_for_branch(db,scope.tenant_id,scope.branch_id)
if not node or not _node_online(node): return _redirect(client_id,registration_id=registration_id,period=period,error="Local Storage Agent is offline.")
result=request_agent_command(node.node_code,"gst_return_download_start",{"client_id":client.id,"client_name":client.client_name,"gstin":gstin,"financial_year":fy,"period":re.sub(r"\D","",period),"gst_relative_dir":gst_dir,"accounting_relative_dir":accounting_dir,"username":username,"password":password,"include_2a":bool(include_2a),"login_timeout_seconds":900},timeout_seconds=15)
log_access(db,request,user,cred,"use_for_gst_download",reason=f"GST return download {period}",fields="username,secret",success=bool(result.get("ok")))
if not node or not _node_online(node):
return _redirect(client_id,registration_id=registration_id,period=period,financial_year=financial_year,download_mode=download_mode,error="Local Storage Agent is offline.")
periods=_selected_periods(download_mode,financial_year,period)
return_types=[name for name,flag in (("GSTR1",gstr1),("GSTR2B",gstr2b),("GSTR3B",gstr3b),("GSTR2A",gstr2a)) if flag]
if not return_types:
return _redirect(client_id,registration_id=registration_id,period=period,financial_year=financial_year,download_mode=download_mode,error="Select at least one GST return to download.")
# Ensure FY is consistent in single-month mode.
if download_mode == "single":
financial_year=_fy_for_period(periods[0])
accounting_dir,gst_dir=_storage_payload(client,financial_year,gstin)
jti=uuid.uuid4().hex
token=_encode_operator_token({
"jti":jti,"user_id":int(user.id),"tenant_id":int(scope.tenant_id),"branch_id":int(scope.branch_id),
"node_code":str(node.node_code),"client_id":int(client.id),"client_name":str(client.client_name or ""),
"registration_id":int(reg.id),"credential_id":int(cred.id),"gstin":gstin,"financial_year":financial_year,
"periods":periods,"return_types":return_types,"gst_relative_dir":gst_dir,"accounting_relative_dir":accounting_dir,
})
log_access(db,request,user,cred,"use_for_gst_download",reason=f"GST returns {financial_year}: {','.join(return_types)}",fields="username,secret",success=True)
db.commit()
if not result.get("ok"): return _redirect(client_id,registration_id=registration_id,period=period,error=result.get("error") or "GST download could not be started.")
return _redirect(client_id,registration_id=registration_id,period=period,message="GST browser started on the Local Storage workstation. Complete captcha/OTP there; downloaded returns will be stored in the client GST directory.")
request.session["gst_operator_job"]={"token":token,"job_id":jti,"periods":periods,"return_types":return_types}
return _redirect(client_id,registration_id=registration_id,period=period,financial_year=financial_year,download_mode=download_mode,message="GST download prepared. The ERP will now ask the Local Agent on this computer to open the visible GST browser; CAPTCHA/OTP will appear here. Completed files will be transferred to the configured client local storage.")
except Exception as exc:
db.rollback(); return _redirect(client_id,registration_id=registration_id,period=period,error=str(exc))
finally: db.close()
db.rollback(); return _redirect(client_id,registration_id=registration_id,period=period,financial_year=financial_year,download_mode=download_mode,error=str(exc))
finally:
db.close()
@router.post("/operator/redeem")
async def operator_redeem(request: Request):
body=await request.json(); token=str(body.get("token") or "")
try:
data=_decode_operator_token(token)
db=CommonSessionLocal()
try:
cred=db.get(CredentialVaultEntry,int(data["credential_id"]))
if not cred or int(cred.tenant_id or 0)!=int(data["tenant_id"]) or int(cred.client_id or 0)!=int(data["client_id"]) or str(cred.status or "").lower()=="archived":
raise ValueError("GST credential is no longer available.")
username=decrypt_value(cred.tenant_id,cred.username_encrypted) or ""
password=decrypt_value(cred.tenant_id,cred.secret_encrypted) or ""
if not username or not password:
raise ValueError("GST username/password is missing in Credential Vault.")
finally:
db.close()
return JSONResponse({"ok":True,"payload":{**{k:v for k,v in data.items() if k not in {"iat","exp","purpose"}},"username":username,"password":password,"upload_url":str(get_settings().ERP_PUBLIC_BASE_URL).rstrip("/")+"/tools/accounting/gst-reconciliation/operator/upload","login_timeout_seconds":900}})
except Exception as exc:
return JSONResponse({"ok":False,"error":str(exc)},status_code=400)
@router.post("/operator/upload")
async def operator_upload(token: str=Form(...), package: UploadFile=File(...)):
try:
data=_decode_operator_token(token); jti=str(data.get("jti") or "")
if not jti:
raise ValueError("GST operator job id is missing.")
_UPLOAD_ROOT.mkdir(parents=True,exist_ok=True)
package_path=_UPLOAD_ROOT/f"{jti}.zip"
total=0
with package_path.open("wb") as out:
while True:
chunk=await package.read(1024*1024)
if not chunk: break
total += len(chunk)
if total > 250*1024*1024:
raise ValueError("GST download package exceeds the 250 MB safety limit.")
out.write(chunk)
package_url=str(get_settings().ERP_PUBLIC_BASE_URL).rstrip("/")+f"/tools/accounting/gst-reconciliation/operator/package/{jti}?token={token}"
result=request_agent_command(str(data["node_code"]),"gst_return_package_store",{
"client_id":int(data["client_id"]),"gstin":str(data["gstin"]),"financial_year":str(data["financial_year"]),
"gst_relative_dir":str(data["gst_relative_dir"]),"package_url":package_url,"periods":data.get("periods") or [],"return_types":data.get("return_types") or [],
},timeout_seconds=120)
if not result.get("ok"):
raise RuntimeError(result.get("error") or "Local Storage Agent could not store the GST return package.")
try: package_path.unlink(missing_ok=True)
except Exception: pass
return JSONResponse({"ok":True,"stored":result.get("result") or {}})
except Exception as exc:
return JSONResponse({"ok":False,"error":str(exc)},status_code=400)
@router.get("/operator/package/{job_id}")
def operator_package(job_id: str, token: str):
try:
data=_decode_operator_token(token)
if str(data.get("jti") or "") != str(job_id):
raise ValueError("GST package token does not match the requested job.")
path=_UPLOAD_ROOT/f"{job_id}.zip"
if not path.is_file():
return JSONResponse({"ok":False,"error":"GST package is no longer available."},status_code=404)
return FileResponse(path,media_type="application/zip",filename=f"gst_returns_{job_id}.zip")
except Exception as exc:
return JSONResponse({"ok":False,"error":str(exc)},status_code=400)
@router.post("/analyze")
def analyze(request: Request, client_id: int=Form(...), registration_id: int=Form(...), period: str=Form(...), csrf_token: str=Form(...)):
def analyze(request: Request, client_id: int=Form(...), registration_id: int=Form(...), period: str=Form(""), financial_year: str=Form(...), analyze_mode: str=Form("single"), csrf_token: str=Form(...)):
validate_csrf(request,csrf_token)
db=CommonSessionLocal()
try:
@@ -244,15 +329,36 @@ def analyze(request: Request, client_id: int=Form(...), registration_id: int=For
if not client: return _redirect(client_id,error="Client is not available in your scope.")
pair=next(((r,t) for r,t in _gst_regs(db,scope.tenant_id,client.id) if int(r.id)==registration_id and _is_gstin(r,t)),None)
if not pair: return _redirect(client_id,error="GSTIN registration was not found.")
reg,_=pair; gstin=re.sub(r"\s+","",str(reg.registration_number or "").upper()); fy=_fy_for_period(period)
accounting_dir,gst_dir=_storage_payload(client,fy,gstin); date_from,date_to=_period_bounds(period)
reg,_=pair; gstin=_norm_gstin(reg.registration_number)
periods=_periods_for_fy(financial_year) if analyze_mode=="full_fy" else [re.sub(r"\D","",period or "")]
if any(len(p)!=6 for p in periods): raise ValueError("Enter a valid MMYYYY period.")
accounting_dir,gst_dir=_storage_payload(client,financial_year,gstin)
node=get_active_storage_node_for_branch(db,scope.tenant_id,scope.branch_id)
if not node or not _node_online(node): return _redirect(client_id,registration_id=registration_id,period=period,error="Local Storage Agent is offline.")
res=request_agent_command(node.node_code,"gst_reconciliation_analyze",{"client_id":client.id,"gstin":gstin,"financial_year":fy,"period":re.sub(r"\D","",period),"gst_relative_dir":gst_dir,"accounting_relative_dir":accounting_dir,"date_from":date_from,"date_to":date_to},timeout_seconds=25)
if not res.get("ok"): return _redirect(client_id,registration_id=registration_id,period=period,error=res.get("error") or "GST reconciliation failed.")
# Save compact analysis in session for immediate display; no GST raw data or credentials are stored on VPS.
request.session["gst_reconciliation_result"]=(res.get("result") or {}).get("analysis") or {}
return _redirect(client_id,registration_id=registration_id,period=period,message="GST Purchase, Sales and ITC reconciliation completed from local stored return data and Accounting Mirror.")
if not node or not _node_online(node): return _redirect(client_id,registration_id=registration_id,period=period,financial_year=financial_year,error="Local Storage Agent is offline.")
results=[]
for p in periods:
date_from,date_to=_period_bounds(p)
res=request_agent_command(node.node_code,"gst_reconciliation_analyze",{"client_id":client.id,"gstin":gstin,"financial_year":financial_year,"period":p,"gst_relative_dir":gst_dir,"accounting_relative_dir":accounting_dir,"date_from":date_from,"date_to":date_to},timeout_seconds=30)
if res.get("ok"):
results.append((res.get("result") or {}).get("analysis") or {})
if not results: raise RuntimeError("No stored GST periods could be reconciled.")
if analyze_mode=="full_fy":
def sum_counts(section):
out={}
for r in results:
for k,v in (((r.get(section) or {}).get("counts") or {}).items()): out[k]=out.get(k,0)+int(v or 0)
return out
taxes={k:{"gstr2b":0.0,"gstr3b":0.0,"difference":0.0} for k in ("igst","cgst","sgst","cess")}
for r in results:
for k,row in (r.get("itc_reconciliation") or {}).items():
if k in taxes:
for f in taxes[k]: taxes[k][f]=round(taxes[k][f]+float(row.get(f) or 0),2)
analysis={"period":financial_year,"sales_reconciliation":{"counts":sum_counts("sales_reconciliation")},"purchase_reconciliation":{"counts":sum_counts("purchase_reconciliation")},"itc_reconciliation":taxes}
else:
analysis=results[0]
request.session["gst_reconciliation_result"]=analysis
return _redirect(client_id,registration_id=registration_id,period=period,financial_year=financial_year,download_mode=("full_fy" if analyze_mode=="full_fy" else "single"),message="GST Purchase, Sales and ITC reconciliation completed from stored local return data and Accounting Mirror.")
except Exception as exc:
return _redirect(client_id,registration_id=registration_id,period=period,error=str(exc))
finally: db.close()
return _redirect(client_id,registration_id=registration_id,period=period,financial_year=financial_year,error=str(exc))
finally:
db.close()
@@ -1,49 +1,56 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="mx-auto max-w-7xl space-y-5 p-4">
<div class="flex items-center justify-between gap-3"><div><h1 class="text-2xl font-bold">GST Return Reconciliation</h1><p class="text-sm text-slate-600">Download GST portal data through Credential Vault, store it in client local storage, and reconcile against Accounting Mirror.</p></div><a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-lg border px-3 py-2 text-sm">Back to Accounting</a></div>
<div class="flex items-center justify-between gap-3">
<div><h1 class="text-2xl font-bold">GST Return Reconciliation</h1><p class="text-sm text-slate-600">Download GST returns on the workstation where you are using ERP, store them in the configured client local storage, and reconcile against Accounting Mirror.</p></div>
<a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-lg border px-3 py-2 text-sm">Back to Accounting</a>
</div>
{% if message %}<div class="rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-emerald-800">{{ message }}</div>{% endif %}
{% if error %}<div class="rounded-lg border border-red-200 bg-red-50 p-3 text-red-800">{{ error }}</div>{% endif %}
<form method="get" class="grid gap-3 rounded-xl border bg-white p-4 md:grid-cols-4">
<form method="get" class="grid gap-3 rounded-xl border bg-white p-4 md:grid-cols-5">
<label class="text-sm">Client<select name="client_id" class="mt-1 w-full rounded border p-2" onchange="this.form.submit()"><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">GSTIN<select name="registration_id" class="mt-1 w-full rounded border p-2" onchange="this.form.submit()"><option value="">Select GSTIN</option>{% for r,t in registrations %}<option value="{{ r.id }}" {% if selected_registration and r.id==selected_registration.id %}selected{% endif %}>{{ r.registration_number }}{% if r.trade_name %} — {{ r.trade_name }}{% endif %}</option>{% endfor %}</select></label>
<label class="text-sm">Return Period (MMYYYY)<input name="period" value="{{ period }}" pattern="[0-9]{6}" placeholder="042026" class="mt-1 w-full rounded border p-2"></label>
<label class="text-sm">Financial Year<input name="financial_year" value="{{ financial_year }}" pattern="[0-9]{4}-[0-9]{2}" placeholder="2025-26" class="mt-1 w-full rounded border p-2"></label>
<label class="text-sm">Mode<select name="download_mode" class="mt-1 w-full rounded border p-2"><option value="single" {% if download_mode=='single' %}selected{% endif %}>Single Month</option><option value="full_fy" {% if download_mode=='full_fy' %}selected{% endif %}>Full Financial Year</option></select></label>
<div class="flex items-end"><button class="w-full rounded bg-slate-800 px-3 py-2 text-white">Load</button></div>
<label class="text-sm md:col-span-2">Single Month Period (MMYYYY)<input name="period" value="{{ period }}" pattern="[0-9]{6}" placeholder="042025" class="mt-1 w-full rounded border p-2"><span class="text-xs text-slate-500">Used only when Mode = Single Month.</span></label>
</form>
{% if selected_client and selected_registration %}
<div class="grid gap-4 lg:grid-cols-2">
<form method="post" action="/tools/accounting/gst-reconciliation/download/start" class="rounded-xl border bg-white p-4 space-y-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}"><input type="hidden" name="registration_id" value="{{ selected_registration.id }}"><input type="hidden" name="period" value="{{ period }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}"><input type="hidden" name="registration_id" value="{{ selected_registration.id }}">
<input type="hidden" name="period" value="{{ period }}"><input type="hidden" name="financial_year" value="{{ financial_year }}"><input type="hidden" name="download_mode" value="{{ download_mode }}">
<h2 class="font-semibold">1. Download from GST Portal</h2>
<p class="text-xs text-slate-500">The Local Storage Agent opens GST portal on the workstation. Username/password are taken from Credential Vault; captcha/OTP remains interactive. Raw return data is saved under the client's FY/GST/GSTIN/period directory.</p>
<label class="text-sm">Credential Vault Entry
<select name="credential_id" required class="mt-1 w-full rounded border p-2">
<option value="">Select GST credential</option>
{% for c in credentials %}
<option value="{{ c.id }}" {% if credentials|length == 1 %}selected{% endif %}>{{ c.title }}{% if c.reference_number %} — {{ c.reference_number }}{% endif %}</option>
{% endfor %}
</select>
</label>
{% if not credentials %}
<div class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900">
No usable GST Portal credential is linked to this client/GSTIN.
<a class="font-semibold underline" href="/credential-vault/new?client_id={{ selected_client.id }}&registration_id={{ selected_registration.id }}">Add GST Portal credential</a>.
<p class="text-xs text-slate-500">The visible GST browser opens on <b>this computer</b> through the ERP Local Agent at 127.0.0.1:8788. CAPTCHA/OTP remains visible here. After download, return data is transferred to the configured Local Storage Agent and stored under the client's FY/GST/GSTIN/period directory.</p>
<label class="text-sm">Credential Vault Entry<select name="credential_id" required class="mt-1 w-full rounded border p-2"><option value="">Select GST credential</option>{% for c in credentials %}<option value="{{ c.id }}" {% if credentials|length==1 %}selected{% endif %}>{{ c.title }}{% if c.reference_number %} — {{ c.reference_number }}{% endif %}</option>{% endfor %}</select></label>
{% if credentials|length==1 %}<div class="text-xs text-slate-500">The only eligible GST Portal credential has been selected automatically.</div>{% endif %}
<div class="rounded-lg border p-3">
<div class="mb-2 text-sm font-medium">Returns to download</div>
<div class="grid gap-2 sm:grid-cols-2"><label class="flex gap-2 text-sm"><input type="checkbox" name="gstr1" value="1" checked> GSTR-1</label><label class="flex gap-2 text-sm"><input type="checkbox" name="gstr2b" value="1" checked> GSTR-2B</label><label class="flex gap-2 text-sm"><input type="checkbox" name="gstr3b" value="1" checked> GSTR-3B</label><label class="flex gap-2 text-sm"><input type="checkbox" name="gstr2a" value="1"> GSTR-2A</label></div>
</div>
{% elif credentials|length == 1 %}
<div class="text-xs text-slate-500">The only eligible GST Portal credential has been selected automatically.</div>
{% endif %}
<label class="flex gap-2 text-sm"><input type="checkbox" name="include_2a" value="1"> Also download GSTR-2A</label>
<button {% if not period or not node_online or not credentials %}disabled{% endif %} class="rounded bg-indigo-600 px-4 py-2 text-white disabled:opacity-50">Start GST Download</button>
<div class="text-xs">Storage Agent: <b>{{ 'Online' if node_online else 'Offline' }}</b></div>
<button {% if not node_online or not credentials %}disabled{% endif %} class="rounded bg-indigo-600 px-4 py-2 text-white disabled:opacity-50">Start GST Download</button>
<div class="text-xs">Configured Storage Agent: <b>{{ 'Online' if node_online else 'Offline' }}</b></div>
<div class="text-xs text-slate-500">The operator workstation also needs ERP Local Agent 1.26.15+ running so the browser can open here.</div>
</form>
<form method="post" action="/tools/accounting/gst-reconciliation/analyze" class="rounded-xl border bg-white p-4 space-y-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}"><input type="hidden" name="registration_id" value="{{ selected_registration.id }}"><input type="hidden" name="period" value="{{ period }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}"><input type="hidden" name="registration_id" value="{{ selected_registration.id }}"><input type="hidden" name="period" value="{{ period }}"><input type="hidden" name="financial_year" value="{{ financial_year }}">
<h2 class="font-semibold">2. Reconcile Stored Data</h2>
<p class="text-sm text-slate-600">Sales: Accounting Mirror vs GSTR-1. Purchases: Accounting Mirror vs GSTR-2B. ITC: GSTR-2B vs GSTR-3B. Reconciliation JSON is retained in the same local client GST directory.</p>
<button {% if not period or not node_online %}disabled{% endif %} class="rounded bg-emerald-600 px-4 py-2 text-white disabled:opacity-50">Run Reconciliation</button>
<p class="text-sm text-slate-600">Sales: Accounting Mirror vs GSTR-1. Purchases: Accounting Mirror vs GSTR-2B. ITC: GSTR-2B vs GSTR-3B. Analysis reads only stored local return data after download.</p>
<label class="text-sm">Analysis scope<select name="analyze_mode" class="mt-1 w-full rounded border p-2"><option value="single">Single Month</option><option value="full_fy" {% if download_mode=='full_fy' %}selected{% endif %}>Full Financial Year</option></select></label>
<button {% if not node_online %}disabled{% endif %} class="rounded bg-emerald-600 px-4 py-2 text-white disabled:opacity-50">Run Reconciliation</button>
</form>
</div>
{% if status %}<div class="rounded-xl border bg-white p-4"><h2 class="font-semibold">Download Status</h2><div class="mt-2 grid gap-2 text-sm md:grid-cols-3"><div>Status: <b>{{ status.status or '-' }}</b></div><div>Stage: {{ status.stage or '-' }}</div><div>{{ status.message or '' }}</div></div></div>{% endif %}
<div id="operatorStatus" class="hidden rounded-xl border bg-white p-4">
<div class="flex items-center justify-between"><h2 class="font-semibold">Interactive GST Download</h2><span id="operatorPct" class="text-sm font-semibold"></span></div>
<div class="mt-2 h-2 overflow-hidden rounded bg-slate-100"><div id="operatorBar" class="h-2 bg-indigo-600" style="width:0%"></div></div>
<div id="operatorMessage" class="mt-3 text-sm text-slate-700"></div>
<div id="operatorDetail" class="mt-1 text-xs text-slate-500"></div>
</div>
{% set a=request.session.get('gst_reconciliation_result') or {} %}
{% if a %}
{% set s=a.get('sales_reconciliation',{}).get('counts',{}) %}{% set p=a.get('purchase_reconciliation',{}).get('counts',{}) %}
@@ -54,4 +61,34 @@
{% endif %}
{% endif %}
</div>
{% if operator_job %}
<script>
(function(){
const token={{ operator_job.token|tojson }};
const jobId={{ operator_job.job_id|tojson }};
const box=document.getElementById('operatorStatus'), msg=document.getElementById('operatorMessage'), detail=document.getElementById('operatorDetail'), bar=document.getElementById('operatorBar'), pct=document.getElementById('operatorPct');
box.classList.remove('hidden');
function show(text,extra,p){msg.textContent=text||'';detail.textContent=extra||''; if(p!==undefined){const n=Math.max(0,Math.min(100,Number(p)||0));bar.style.width=n+'%';pct.textContent=Math.round(n)+'%';}}
async function poll(){
try{
const r=await fetch('http://127.0.0.1:8788/api/gst/operator/status?job_id='+encodeURIComponent(jobId),{cache:'no-store'}); const d=await r.json(); const j=d.job||{};
let p=0; if(j.period_total){p=Math.round(((Number(j.period_index||1)-1)/Number(j.period_total))*90)+5;} if(j.status==='completed'||j.status==='failed')p=100;
show(j.message||j.stage||j.status||'Working…',j.period?('Current period: '+j.period):'',p);
if(j.status==='completed'){detail.textContent='Completed. Return data has been transferred to the configured client local storage.';return;}
if(j.status==='failed'){msg.className='mt-3 text-sm text-red-700';return;}
}catch(e){show('Waiting for ERP Local Agent on this computer…','Ensure ERP Local Agent 1.26.15+ is running at http://127.0.0.1:8788.',2);}
setTimeout(poll,1500);
}
async function start(){
show('Connecting to ERP Local Agent on this computer…','The visible GST browser will open here.',2);
try{
const r=await fetch('http://127.0.0.1:8788/api/gst/operator/start',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:token})}); const d=await r.json(); if(!r.ok||!d.ok)throw new Error(d.error||'Could not start local GST browser.');
poll();
}catch(e){msg.className='mt-3 text-sm text-red-700';show('Could not start GST browser on this computer.',e.message+' Ensure ERP Local Agent 1.26.15+ is running locally.',100);}
}
start();
})();
</script>
{% endif %}
{% endblock %}