Add lightweight GST operator agent for portal automation

This commit is contained in:
A R R R Associates
2026-09-11 11:15:12 +05:30
parent 2ad8e44312
commit 3e660ec0b9
7 changed files with 640 additions and 26 deletions
@@ -0,0 +1,18 @@
ARRR GST Operator Agent 1.0.0
Purpose
- Runs only on the ERP operator workstation.
- Listens on 127.0.0.1:8791 only.
- Opens installed Chrome/Edge visibly.
- Receives only a short-lived ERP job token from the ERP page.
- Redeems GST username/password directly from ERP Credential Vault over HTTPS.
- Autofills username/password; CAPTCHA/OTP remain manual.
- Downloads selected GST return data and uploads it back to ERP for transfer to the configured Storage Agent.
- Does not provide Tally, Accounting Mirror or client storage services.
Install
Right-click PowerShell and run:
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\install_gst_operator_agent.ps1
Requirement
Python 3.11 or later and installed Google Chrome or Microsoft Edge.
@@ -0,0 +1,370 @@
from __future__ import annotations
import json
import re
import shutil
import threading
import time
import zipfile
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlsplit
import requests
VERSION = "1.0.0"
PORT = 8791
ROOT = Path(__file__).resolve().parent
DATA = ROOT / "data"
CONFIG_PATH = ROOT / "config.json"
GST_LOGIN_URL = "https://services.gst.gov.in/services/login"
GSTR1_URL = "https://return.gst.gov.in/returns/auth/api/offline/download/generate?flag=0&rtn_prd={period}&rtn_typ=GSTR1"
GSTR2A_URL = "https://return.gst.gov.in/returns/auth/api/offline/download/generate?flag=0&rtn_prd={period}&rtn_typ=GSTR2A"
GSTR1_DOWNLOAD_URL = "https://return.gst.gov.in/returns/auth/api/offline/download/url?rtn_prd={period}&rtn_typ=GSTR1&file_num={file_num}"
GSTR2B_URL = "https://gstr2b.gst.gov.in/gstr2b/auth/api/gstr2b/getjson?rtnprd={period}"
GSTR3B_SUMMARY_URL = "https://return.gst.gov.in/returns/auth/api/gstr3b/summary?rtn_prd={period}"
GSTR3B_URL = "https://return.gst.gov.in/returns/auth/api/gstr3b/taxpayble?rtn_prd={period}"
THREADS: dict[str, threading.Thread] = {}
def now() -> str:
return datetime.now(timezone.utc).isoformat()
def read_config() -> dict:
try:
return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
except Exception:
return {"erp_base_url": "https://office.arrrassociates.com", "port": PORT}
def safe(value: str, fallback: str = "item") -> str:
value = re.sub(r"[^A-Za-z0-9._ -]+", "_", str(value or "").strip()).strip(" ._")
return value or fallback
def write_json(path: Path, payload) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
tmp.replace(path)
def read_json(path: Path) -> dict:
try:
value = json.loads(path.read_text(encoding="utf-8"))
return value if isinstance(value, dict) else {}
except Exception:
return {}
def job_path(job_id: str) -> Path:
return DATA / f"gst_job_{safe(job_id)}.json"
def walk(obj):
if isinstance(obj, dict):
yield obj
for value in obj.values():
yield from walk(value)
elif isinstance(obj, list):
for value in obj:
yield from walk(value)
def extract_file_num(text: str) -> str:
try:
data = json.loads(text or "{}")
except Exception:
return "1"
for obj in walk(data):
if isinstance(obj, dict):
for key in ("file_num", "fileNum", "filenum", "file_number", "fileNumber"):
if obj.get(key) not in (None, ""):
return str(obj.get(key))
return "1"
def extract_payload(text: str) -> str:
try:
data = json.loads(text or "{}")
except Exception:
return text or ""
if isinstance(data, dict):
for key in ("data", "payload", "json", "response"):
value = data.get(key)
if isinstance(value, (dict, list)):
return json.dumps(value, ensure_ascii=False)
if isinstance(value, str) and value.strip().startswith(("{", "[")):
return value
return text or ""
def api_text(context, url: str) -> str:
response = context.request.get(url, timeout=90000)
if not response.ok:
raise RuntimeError(f"GST portal request failed ({response.status}) for {url.split('?')[0]}")
return response.text()
def download_period(context, work_root: Path, period: str, return_types: list[str], progress) -> list[dict]:
raw_dir = work_root / period / "raw"
raw_dir.mkdir(parents=True, exist_ok=True)
selected = {str(x or "").upper() for x in return_types}
downloaded: list[dict] = []
if "GSTR1" in selected:
progress(stage=f"Downloading GSTR-1 {period}", message=f"Reading GSTR-1 for {period}.")
generated = api_text(context, GSTR1_URL.format(period=period))
(raw_dir / f"{period}_GSTR1_GENERATE.json").write_text(generated, encoding="utf-8")
file_num = extract_file_num(generated)
content = extract_payload(api_text(context, GSTR1_DOWNLOAD_URL.format(period=period, file_num=file_num)))
path = raw_dir / f"{period}_GSTR1.json"
path.write_text(content, encoding="utf-8")
downloaded.append({"return_type": "GSTR1", "path": str(path.relative_to(work_root)), "bytes": path.stat().st_size})
if "GSTR2B" in selected:
progress(stage=f"Downloading GSTR-2B {period}", message=f"Reading GSTR-2B for {period}.")
content = api_text(context, GSTR2B_URL.format(period=period))
path = raw_dir / f"{period}_GSTR2B.json"
path.write_text(content, encoding="utf-8")
downloaded.append({"return_type": "GSTR2B", "path": str(path.relative_to(work_root)), "bytes": path.stat().st_size})
if "GSTR3B" in selected:
progress(stage=f"Downloading GSTR-3B {period}", message=f"Reading GSTR-3B for {period}.")
summary = api_text(context, GSTR3B_SUMMARY_URL.format(period=period))
payable = api_text(context, GSTR3B_URL.format(period=period))
(raw_dir / f"{period}_GSTR3B_SUMMARY.json").write_text(summary, encoding="utf-8")
(raw_dir / f"{period}_GSTR3B_TAXPAYBLE.json").write_text(payable, encoding="utf-8")
try:
combined = json.loads(summary or "{}")
except Exception:
combined = {"summary_raw": summary}
if not isinstance(combined, dict):
combined = {"summary": combined}
try:
combined["taxpayble"] = json.loads(payable or "{}")
except Exception:
combined["taxpayble"] = {"raw": payable}
path = raw_dir / f"{period}_GSTR3B.json"
path.write_text(json.dumps(combined, ensure_ascii=False, indent=2), encoding="utf-8")
downloaded.append({"return_type": "GSTR3B", "path": str(path.relative_to(work_root)), "bytes": path.stat().st_size})
if "GSTR2A" in selected:
progress(stage=f"Requesting GSTR-2A {period}", message=f"Requesting GSTR-2A for {period}.")
content = api_text(context, GSTR2A_URL.format(period=period))
path = raw_dir / f"{period}_GSTR2A.json"
path.write_text(content, encoding="utf-8")
downloaded.append({"return_type": "GSTR2A", "path": str(path.relative_to(work_root)), "bytes": path.stat().st_size})
write_json(work_root / period / "download_manifest.json", {
"period": period, "downloaded_at_utc": now(), "source": "gst_lightweight_operator_agent", "downloaded": downloaded
})
return downloaded
def login_complete(page) -> bool:
url = (page.url or "").lower()
if "/dashboard" in url or "/returns" in url:
return True
try:
return bool(page.locator("text=Search Taxpayer").count() and not page.locator("#username").count())
except Exception:
return False
def browser_worker(payload: dict, token: str, path: Path) -> None:
job_id = str(payload.get("jti") or "")
work_root = DATA / "work" / safe(job_id)
package_path = DATA / f"gst_{safe(job_id)}.zip"
try:
shutil.rmtree(work_root, ignore_errors=True)
work_root.mkdir(parents=True, exist_ok=True)
def progress(**updates):
current = read_json(path)
current.update(updates)
current["updated_at_utc"] = now()
write_json(path, current)
progress(status="running", percent=5, stage="Opening GST Login", message="Opening GST Portal in visible Chrome/Edge on this workstation.")
from playwright.sync_api import sync_playwright
with sync_playwright() as pw:
context = None
errors = []
profile_root = DATA / "browser_profile"
profile_root.mkdir(parents=True, exist_ok=True)
for channel in ("chrome", "msedge"):
try:
context = pw.chromium.launch_persistent_context(
user_data_dir=str(profile_root / channel), channel=channel, headless=False,
no_viewport=True, accept_downloads=True,
args=["--start-maximized", "--no-first-run", "--disable-blink-features=AutomationControlled"],
)
break
except Exception as exc:
errors.append(f"{channel}: {exc}")
if context is None:
raise RuntimeError("Could not open installed Chrome or Edge. " + " | ".join(errors))
page = context.pages[0] if context.pages else context.new_page()
page.goto(GST_LOGIN_URL, wait_until="domcontentloaded", timeout=90000)
page.locator("#username").wait_for(state="visible", timeout=30000)
page.fill("#username", str(payload.get("username") or ""))
# GST portal has used user_pass for the password field; fall back to generic password locator.
password_filled = False
for selector in ("#user_pass", "input[type=password]"):
try:
locator = page.locator(selector).first
if locator.count():
locator.fill(str(payload.get("password") or ""))
password_filled = True
break
except Exception:
pass
if not password_filled:
raise RuntimeError("GST password field could not be located. The portal login page may have changed.")
progress(percent=10, stage="Waiting for CAPTCHA / OTP", message="GST username/password filled. Complete CAPTCHA/OTP in the visible GST browser.")
deadline = time.time() + int(payload.get("login_timeout_seconds") or 900)
while time.time() < deadline:
if login_complete(page):
break
time.sleep(2)
else:
raise RuntimeError("GST login was not completed within 15 minutes.")
periods = [str(p) for p in payload.get("periods") or []]
return_types = [str(r) for r in payload.get("return_types") or []]
all_downloaded = []
for index, period in enumerate(periods, 1):
base_pct = 10 + int((index - 1) * 75 / max(1, len(periods)))
progress(percent=base_pct, period=period, period_index=index, period_total=len(periods), stage=f"Period {index}/{len(periods)}", message=f"Downloading selected GST returns for {period}.")
all_downloaded.extend(download_period(context, work_root, period, return_types, progress))
context.close()
if package_path.exists():
package_path.unlink()
with zipfile.ZipFile(package_path, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
for item in sorted(work_root.rglob("*")):
if item.is_file():
archive.write(item, item.relative_to(work_root).as_posix())
progress(percent=90, stage="Transferring to Client Storage", message="Uploading GST return package to ERP for transfer to the configured Storage Agent.")
with package_path.open("rb") as handle:
response = requests.post(
str(payload.get("upload_url") or ""), data={"token": token},
files={"package": (package_path.name, handle, "application/zip")}, timeout=240,
)
try:
body = response.json()
except Exception:
body = {"ok": False, "error": response.text[:1000]}
if not response.ok or not body.get("ok"):
raise RuntimeError(body.get("error") or f"ERP storage transfer failed with HTTP {response.status_code}.")
progress(status="completed", percent=100, stage="Completed", message="GST returns downloaded and stored in the configured client local storage.", result={"stored": body.get("stored") or {}}, finished_at_utc=now())
except Exception as exc:
progress = read_json(path)
progress.update({"status": "failed", "percent": 100, "stage": "Failed", "message": str(exc), "error": str(exc), "finished_at_utc": now(), "updated_at_utc": now()})
write_json(path, progress)
finally:
shutil.rmtree(work_root, ignore_errors=True)
try:
package_path.unlink(missing_ok=True)
except Exception:
pass
def start_job(token: str) -> dict:
cfg = read_config()
erp = str(cfg.get("erp_base_url") or "").rstrip("/")
if not token:
raise ValueError("GST operator token is required.")
response = requests.post(erp + "/tools/accounting/gst-reconciliation/operator/redeem", json={"token": token}, timeout=30)
try:
body = response.json()
except Exception:
body = {"ok": False, "error": response.text[:1000]}
if not response.ok or not body.get("ok"):
raise RuntimeError(body.get("error") or "ERP could not authorize this GST download.")
payload = dict(body.get("payload") or {})
job_id = str(payload.get("jti") or "")
if not job_id:
raise RuntimeError("ERP did not return a GST job id.")
path = job_path(job_id)
current = read_json(path)
if current.get("status") in {"queued", "running"}:
return current
initial = {
"status": "queued", "percent": 1, "stage": "Queued", "message": "GST browser job queued.",
"job_id": job_id, "periods": payload.get("periods") or [], "return_types": payload.get("return_types") or [],
"started_at_utc": now(), "updated_at_utc": now(),
}
write_json(path, initial)
thread = threading.Thread(target=browser_worker, args=(payload, token, path), daemon=True, name=f"gst-{job_id}")
THREADS[job_id] = thread
thread.start()
return initial
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
return
def cors(self):
cfg = read_config()
origin = self.headers.get("Origin") or ""
allowed = str(cfg.get("erp_base_url") or "").rstrip("/")
if origin.rstrip("/") == allowed:
self.send_header("Access-Control-Allow-Origin", origin)
self.send_header("Vary", "Origin")
if (self.headers.get("Access-Control-Request-Private-Network") or "").lower() == "true":
self.send_header("Access-Control-Allow-Private-Network", "true")
def reply(self, payload, status=200):
data = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.cors()
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_OPTIONS(self):
self.send_response(204)
self.cors()
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.end_headers()
def do_GET(self):
path = urlsplit(self.path)
if path.path == "/api/status":
return self.reply({"ok": True, "name": "ARRR GST Operator Agent", "version": VERSION, "port": PORT})
if path.path == "/api/gst/status":
job_id = str((parse_qs(path.query).get("job_id") or [""])[0])
return self.reply({"ok": True, "job": read_json(job_path(job_id))})
return self.reply({"ok": False, "error": "Not found"}, 404)
def do_POST(self):
try:
if urlsplit(self.path).path != "/api/gst/start":
return self.reply({"ok": False, "error": "Not found"}, 404)
length = int(self.headers.get("Content-Length") or 0)
body = json.loads((self.rfile.read(length) if length else b"{}").decode("utf-8"))
job = start_job(str(body.get("token") or ""))
return self.reply({"ok": True, "job": job})
except Exception as exc:
return self.reply({"ok": False, "error": str(exc)}, 400)
def main():
DATA.mkdir(parents=True, exist_ok=True)
cfg = read_config()
port = int(cfg.get("port") or PORT)
server = ThreadingHTTPServer(("127.0.0.1", port), Handler)
server.serve_forever()
if __name__ == "__main__":
main()
@@ -0,0 +1,57 @@
param(
[string]$ErpBaseUrl = "https://office.arrrassociates.com"
)
$ErrorActionPreference = "Stop"
$Source = Split-Path -Parent $MyInvocation.MyCommand.Path
$InstallRoot = Join-Path $env:LOCALAPPDATA "ARRR\GSTOperatorAgent"
$Venv = Join-Path $InstallRoot ".venv"
$Startup = Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs\Startup\ARRR GST Operator Agent.cmd"
Write-Host "ARRR GST Operator Agent 1.0.0" -ForegroundColor Cyan
Write-Host "Install root: $InstallRoot"
$Python = $null
if (Get-Command py -ErrorAction SilentlyContinue) { $Python = @("py", "-3") }
elseif (Get-Command python -ErrorAction SilentlyContinue) { $Python = @("python") }
else { throw "Python 3 is required on this workstation. Install Python 3.11+ and run this installer again." }
New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null
Copy-Item (Join-Path $Source "gst_operator_agent.py") (Join-Path $InstallRoot "gst_operator_agent.py") -Force
Copy-Item (Join-Path $Source "requirements.txt") (Join-Path $InstallRoot "requirements.txt") -Force
$config = @{ erp_base_url = $ErpBaseUrl.TrimEnd('/'); port = 8791 } | ConvertTo-Json
[System.IO.File]::WriteAllText((Join-Path $InstallRoot "config.json"), $config, [System.Text.UTF8Encoding]::new($false))
if (-not (Test-Path (Join-Path $Venv "Scripts\python.exe"))) {
if ($Python.Count -eq 2) { & $Python[0] $Python[1] -m venv $Venv }
else { & $Python[0] -m venv $Venv }
if ($LASTEXITCODE -ne 0) { throw "Could not create GST Operator Agent virtual environment." }
}
$VenvPython = Join-Path $Venv "Scripts\python.exe"
& $VenvPython -m pip install --disable-pip-version-check --upgrade pip
if ($LASTEXITCODE -ne 0) { throw "pip upgrade failed." }
& $VenvPython -m pip install --disable-pip-version-check -r (Join-Path $InstallRoot "requirements.txt")
if ($LASTEXITCODE -ne 0) { throw "GST Operator Agent dependencies could not be installed." }
$Pythonw = Join-Path $Venv "Scripts\pythonw.exe"
$Cmd = "@echo off`r`nstart `"`" `"$Pythonw`" `"$InstallRoot\gst_operator_agent.py`"`r`n"
[System.IO.File]::WriteAllText($Startup, $Cmd, [System.Text.ASCIIEncoding]::new())
# Stop an older copy bound to the operator port, if present.
try {
$Connections = Get-NetTCPConnection -LocalPort 8791 -State Listen -ErrorAction SilentlyContinue
foreach ($Connection in $Connections) {
if ($Connection.OwningProcess) { Stop-Process -Id $Connection.OwningProcess -Force -ErrorAction SilentlyContinue }
}
} catch {}
Start-Process -FilePath $Pythonw -ArgumentList @((Join-Path $InstallRoot "gst_operator_agent.py")) -WorkingDirectory $InstallRoot
Start-Sleep -Seconds 2
try {
$Status = Invoke-RestMethod -Uri "http://127.0.0.1:8791/api/status" -TimeoutSec 5
Write-Host "Agent status: $($Status.name) v$($Status.version) - Online" -ForegroundColor Green
} catch {
throw "GST Operator Agent was installed but did not answer on http://127.0.0.1:8791. $($_.Exception.Message)"
}
Write-Host "Installed successfully. It will start automatically when this Windows user logs in." -ForegroundColor Green
@@ -0,0 +1,2 @@
requests==2.32.3
playwright==1.55.0
@@ -0,0 +1,11 @@
$ErrorActionPreference = "Stop"
$InstallRoot = Join-Path $env:LOCALAPPDATA "ARRR\GSTOperatorAgent"
$Startup = Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs\Startup\ARRR GST Operator Agent.cmd"
try {
Get-NetTCPConnection -LocalPort 8791 -State Listen -ErrorAction SilentlyContinue | ForEach-Object {
if ($_.OwningProcess) { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }
}
} catch {}
Remove-Item $Startup -Force -ErrorAction SilentlyContinue
Remove-Item $InstallRoot -Recurse -Force -ErrorAction SilentlyContinue
Write-Host "ARRR GST Operator Agent removed." -ForegroundColor Green
@@ -13,7 +13,7 @@ from urllib.parse import urlencode
import jwt import jwt
from fastapi import APIRouter, File, Form, Request, UploadFile from fastapi import APIRouter, File, Form, Request, UploadFile
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, StreamingResponse
from sqlalchemy import select from sqlalchemy import select
from app.core.db.common import CommonSessionLocal from app.core.db.common import CommonSessionLocal
@@ -144,9 +144,12 @@ def extract_gstr3b_itc(data) -> dict[str, float]:
return totals return totals
router = APIRouter(prefix="/tools/accounting/gst-reconciliation", tags=["accounting-gst-reconciliation-ui"]) router = APIRouter(prefix="/tools/accounting/gst-reconciliation", tags=["accounting-gst-reconciliation-ui"])
_TOKEN_PURPOSE = "gst_operator_browser_v1" _TOKEN_PURPOSE = "gst_lightweight_operator_v2"
_TOKEN_MINUTES = 30 _TOKEN_MINUTES = 15
_UPLOAD_ROOT = Path(tempfile.gettempdir()) / "audit_firm_gst_operator_uploads" _UPLOAD_ROOT = Path(tempfile.gettempdir()) / "audit_firm_gst_operator_uploads"
_OPERATOR_AGENT_VERSION = "1.0.0"
_OPERATOR_AGENT_PORT = 8791
_OPERATOR_RUNTIME_ROOT = Path(__file__).resolve().parent / "gst_operator_agent_runtime"
def _fy_bounds(fy: str) -> tuple[date, date]: def _fy_bounds(fy: str) -> tuple[date, date]:
@@ -410,12 +413,35 @@ def page(request: Request, client_id: int | None = None, registration_id: int |
return templates.TemplateResponse("modules/accounting/templates/accounting/gst_reconciliation.html",{ 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), "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, "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,"gst_login_url":GST_LOGIN_URL,"message":message,"error":error,"title":"GST Return Reconciliation", "period":period,"financial_year":financial_year,"download_mode":download_mode,"operator_job":operator_job,"gst_login_url":GST_LOGIN_URL,
"operator_agent_version":_OPERATOR_AGENT_VERSION,"operator_agent_port":_OPERATOR_AGENT_PORT,
"message":message,"error":error,"title":"GST Return Reconciliation",
}) })
finally: finally:
db.close() db.close()
@router.get("/operator-agent/download")
def download_operator_agent(request: Request):
db = CommonSessionLocal()
try:
user, response = _require_partner(request, db, "accounting.learning.view")
if response:
return response
if not _OPERATOR_RUNTIME_ROOT.is_dir():
return JSONResponse({"ok": False, "error": "GST Operator Agent runtime is missing from this ERP build."}, status_code=404)
memory = io.BytesIO()
with zipfile.ZipFile(memory, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
for path in sorted(_OPERATOR_RUNTIME_ROOT.rglob("*")):
if path.is_file() and "__pycache__" not in path.parts:
archive.write(path, Path("ARRR_GST_Operator_Agent") / path.relative_to(_OPERATOR_RUNTIME_ROOT))
memory.seek(0)
headers = {"Content-Disposition": f'attachment; filename="ARRR_GST_Operator_Agent_{_OPERATOR_AGENT_VERSION}.zip"'}
return StreamingResponse(memory, media_type="application/zip", headers=headers)
finally:
db.close()
@router.post("/download/start") @router.post("/download/start")
def start_download( def start_download(
request: Request, request: Request,
@@ -459,7 +485,7 @@ def start_download(
log_access(db,request,user,cred,"use_for_gst_download",reason=f"GST returns {financial_year}: {','.join(return_types)}",fields="username,secret",success=True) 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() db.commit()
request.session["gst_operator_job"]={"token":token,"job_id":jti,"periods":periods,"return_types":return_types} 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.") return _redirect(client_id,registration_id=registration_id,period=period,financial_year=financial_year,download_mode=download_mode,message="GST download prepared. The lightweight GST Operator Agent on this computer will open the visible GST browser and autofill the selected Credential Vault login. Complete CAPTCHA/OTP there; downloaded return data will then be transferred to the configured client storage.")
except Exception as exc: except Exception as exc:
db.rollback(); return _redirect(client_id,registration_id=registration_id,period=period,financial_year=financial_year,download_mode=download_mode,error=str(exc)) db.rollback(); return _redirect(client_id,registration_id=registration_id,period=period,financial_year=financial_year,download_mode=download_mode,error=str(exc))
finally: finally:
@@ -482,7 +508,7 @@ async def operator_redeem(request: Request):
raise ValueError("GST username/password is missing in Credential Vault.") raise ValueError("GST username/password is missing in Credential Vault.")
finally: finally:
db.close() 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}}) 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,"operator_agent_min_version":_OPERATOR_AGENT_VERSION}})
except Exception as exc: except Exception as exc:
return JSONResponse({"ok":False,"error":str(exc)},status_code=400) return JSONResponse({"ok":False,"error":str(exc)},status_code=400)
@@ -2,40 +2,90 @@
{% block content %} {% block content %}
<div class="mx-auto max-w-7xl space-y-5 p-4"> <div class="mx-auto max-w-7xl space-y-5 p-4">
<div class="flex items-center justify-between gap-3"> <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">Open GST Portal directly in your browser, import downloaded return files into the configured client local storage, and reconcile against Accounting Mirror.</p></div> <div>
<h1 class="text-2xl font-bold">GST Return Reconciliation</h1>
<p class="text-sm text-slate-600">Download GST returns through the lightweight operator agent, 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> <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>
{% if message %}<div class="rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-emerald-800">{{ message }}</div>{% endif %} {% 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 %} {% 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-5"> <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">Client
<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> <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">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">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> <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> <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> <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> </form>
{% if selected_client and selected_registration %} {% if selected_client and selected_registration %}
<div class="grid gap-4 lg:grid-cols-2"> <div class="grid gap-4 lg:grid-cols-2">
<div class="rounded-xl border bg-white p-4 space-y-4"> <form method="post" action="/tools/accounting/gst-reconciliation/download/start" class="rounded-xl border bg-white p-4 space-y-4">
<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 }}">
<div> <div>
<h2 class="font-semibold">1. Download from GST Portal</h2> <h2 class="font-semibold">1. Automatic GST Download</h2>
<p class="mt-1 text-sm text-slate-600">Open the official GST Portal directly in this browser. No ERP Local Agent is required on this computer. Complete login, CAPTCHA and OTP normally, then download the required return JSON/ZIP files.</p> <p class="mt-1 text-sm text-slate-600">The lightweight GST Operator Agent runs only on this workstation. It opens visible Chrome/Edge, autofills the selected Credential Vault username/password, and waits for you to complete CAPTCHA/OTP. It does not run Tally or hold the client storage.</p>
</div> </div>
<div class="flex flex-wrap items-center gap-2 rounded-lg border bg-slate-50 p-3 text-sm">
<span>GST Operator Agent:</span>
<span id="gst-agent-status" class="font-semibold text-slate-600">Checking…</span>
<span class="text-slate-400">v{{ operator_agent_version }}</span>
<a href="/tools/accounting/gst-reconciliation/operator-agent/download" class="ml-auto rounded border bg-white px-3 py-1.5 text-xs font-medium">Download / Install Agent</a>
</div>
<label class="text-sm">Credential Vault Entry <label class="text-sm">Credential Vault Entry
<select class="mt-1 w-full rounded border p-2" disabled> <select name="credential_id" required class="mt-1 w-full rounded border p-2" {% if not credentials %}disabled{% endif %}>
{% if credentials %}{% for c in credentials %}<option {% if credentials|length==1 %}selected{% endif %}>{{ c.title }}{% if c.reference_number %} — {{ c.reference_number }}{% endif %}</option>{% endfor %}{% else %}<option>No GST Portal credential found</option>{% endif %} {% if credentials %}
{% 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 %}
{% else %}
<option>No GST Portal credential found</option>
{% endif %}
</select> </select>
</label> </label>
<p class="text-xs text-slate-500">Credential Vault remains linked to this GSTIN for reference and future API-based workflows. The browser login is manual because the GST Portal is on a different website and ERP cannot read or autofill that page across browser security boundaries.</p> {% if credentials|length==1 %}<p class="text-xs text-slate-500">The only eligible GST Portal credential has been selected automatically.</p>{% endif %}
<a href="{{ gst_login_url }}" target="_blank" rel="noopener noreferrer" class="inline-flex rounded bg-indigo-600 px-4 py-2 text-white">Open GST Portal</a>
<div class="rounded-lg border bg-slate-50 p-3 text-xs text-slate-600"> <div class="rounded-lg border p-3">
<b>For Full Financial Year:</b> download the selected GSTR-1, GSTR-2B and GSTR-3B JSON/ZIP files for all required months. You can select multiple files below in one import. <div class="mb-2 text-sm font-medium">Returns to download</div>
<div class="grid grid-cols-2 gap-3 text-sm">
<label><input type="checkbox" name="gstr1" value="1" checked class="mr-2">GSTR-1</label>
<label><input type="checkbox" name="gstr2b" value="1" checked class="mr-2">GSTR-2B</label>
<label><input type="checkbox" name="gstr3b" value="1" checked class="mr-2">GSTR-3B</label>
<label><input type="checkbox" name="gstr2a" value="1" class="mr-2">GSTR-2A</label>
</div> </div>
</div> </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>
<p class="text-xs text-slate-500">For Full Financial Year, the job processes April through March and reports progress month by month. The password is redeemed directly by the localhost agent and is never placed in this page's HTML or URL.</p>
</form>
<form method="post" action="/tools/accounting/gst-reconciliation/import" enctype="multipart/form-data" class="rounded-xl border bg-white p-4 space-y-3"> <form method="post" action="/tools/accounting/gst-reconciliation/import" enctype="multipart/form-data" class="rounded-xl border bg-white p-4 space-y-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="client_id" value="{{ selected_client.id }}"> <input type="hidden" name="client_id" value="{{ selected_client.id }}">
@@ -44,23 +94,28 @@
<input type="hidden" name="financial_year" value="{{ financial_year }}"> <input type="hidden" name="financial_year" value="{{ financial_year }}">
<input type="hidden" name="download_mode" value="{{ download_mode }}"> <input type="hidden" name="download_mode" value="{{ download_mode }}">
<div> <div>
<h2 class="font-semibold">2. Import Downloaded Returns to Client Storage</h2> <h2 class="font-semibold">2. Manual Import Fallback</h2>
<p class="mt-1 text-sm text-slate-600">Select the JSON or ZIP files downloaded from GST Portal. ERP identifies each return period, normalizes the data, and transfers it to the configured Local Storage Agent under the client's FY/GST/GSTIN/period directory.</p> <p class="mt-1 text-sm text-slate-600">If a GST portal download cannot be automated, keep the workflow moving by importing the portal JSON/ZIP files. They use the same client GST storage hierarchy and the same reconciliation engine.</p>
</div> </div>
<label class="block text-sm">GSTR-1 files<input type="file" name="gstr1_files" accept=".json,.zip,application/json,application/zip" multiple class="mt-1 block w-full rounded border p-2"></label> <label class="block text-sm">GSTR-1 files<input type="file" name="gstr1_files" accept=".json,.zip,application/json,application/zip" multiple class="mt-1 block w-full rounded border p-2"></label>
<label class="block text-sm">GSTR-2B files<input type="file" name="gstr2b_files" accept=".json,.zip,application/json,application/zip" multiple class="mt-1 block w-full rounded border p-2"></label> <label class="block text-sm">GSTR-2B files<input type="file" name="gstr2b_files" accept=".json,.zip,application/json,application/zip" multiple class="mt-1 block w-full rounded border p-2"></label>
<label class="block text-sm">GSTR-3B files<input type="file" name="gstr3b_files" accept=".json,.zip,application/json,application/zip" multiple class="mt-1 block w-full rounded border p-2"></label> <label class="block text-sm">GSTR-3B files<input type="file" name="gstr3b_files" accept=".json,.zip,application/json,application/zip" multiple class="mt-1 block w-full rounded border p-2"></label>
<label class="block text-sm">GSTR-2A files <span class="text-slate-400">(optional)</span><input type="file" name="gstr2a_files" accept=".json,.zip,application/json,application/zip" multiple class="mt-1 block w-full rounded border p-2"></label> <label class="block text-sm">GSTR-2A files <span class="text-slate-400">(optional)</span><input type="file" name="gstr2a_files" accept=".json,.zip,application/json,application/zip" multiple class="mt-1 block w-full rounded border p-2"></label>
<button {% if not node_online %}disabled{% endif %} class="rounded bg-sky-700 px-4 py-2 text-white disabled:opacity-50">Import & Store GST Returns</button> <button {% if not node_online %}disabled{% endif %} class="rounded bg-sky-700 px-4 py-2 text-white disabled:opacity-50">Import & Store GST Returns</button>
<div class="text-xs">Configured Storage Agent: <b>{{ 'Online' if node_online else 'Offline' }}</b></div>
<div class="text-xs text-slate-500">No Local Agent is required on the computer where this browser is open.</div>
</form> </form>
</div> </div>
<div id="gst-job-panel" class="hidden rounded-xl border bg-white p-4 space-y-2">
<div class="flex items-center justify-between"><h2 class="font-semibold">Interactive GST Download</h2><span id="gst-job-percent" class="text-sm font-medium">0%</span></div>
<div class="h-2 overflow-hidden rounded bg-slate-200"><div id="gst-job-bar" class="h-full bg-indigo-600" style="width:0%"></div></div>
<div id="gst-job-stage" class="text-sm font-medium"></div>
<div id="gst-job-message" class="text-sm text-slate-600"></div>
</div>
<form method="post" action="/tools/accounting/gst-reconciliation/analyze" class="rounded-xl border bg-white p-4 space-y-3"> <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="financial_year" value="{{ financial_year }}"> <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">3. Reconcile Stored Data</h2> <h2 class="font-semibold">3. 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. Analysis reads only return data already stored in the client's local-storage GST directory.</p> <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 client data, so download and reconciliation remain independent.</p>
<div class="flex flex-wrap items-end gap-3"> <div class="flex flex-wrap items-end gap-3">
<label class="text-sm min-w-64">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> <label class="text-sm min-w-64">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> <button {% if not node_online %}disabled{% endif %} class="rounded bg-emerald-600 px-4 py-2 text-white disabled:opacity-50">Run Reconciliation</button>
@@ -78,5 +133,80 @@
{% endif %} {% endif %}
</div> </div>
<script>
(() => {
const port = {{ operator_agent_port|int }};
const base = `http://127.0.0.1:${port}`;
const statusEl = document.getElementById('gst-agent-status');
const panel = document.getElementById('gst-job-panel');
const bar = document.getElementById('gst-job-bar');
const pct = document.getElementById('gst-job-percent');
const stage = document.getElementById('gst-job-stage');
const msg = document.getElementById('gst-job-message');
async function agentStatus() {
if (!statusEl) return false;
try {
const r = await fetch(base + '/api/status', {cache:'no-store'});
const b = await r.json();
if (!r.ok || !b.ok) throw new Error(b.error || 'Agent unavailable');
statusEl.textContent = `Online · v${b.version}`;
statusEl.className = 'font-semibold text-emerald-700';
return true;
} catch (e) {
statusEl.textContent = 'Not installed / not running';
statusEl.className = 'font-semibold text-red-600';
return false;
}
}
function renderJob(job) {
panel?.classList.remove('hidden');
const total = Number(job.period_total || (job.periods || []).length || 1);
const index = Number(job.period_index || 0);
let percent = Number(job.percent || 0);
if (!percent && total) percent = Math.min(95, Math.round((index / total) * 100));
if (job.status === 'completed') percent = 100;
if (bar) bar.style.width = `${percent}%`;
if (pct) pct.textContent = `${percent}%`;
if (stage) stage.textContent = job.stage || job.status || '';
if (msg) msg.textContent = job.message || job.error || '';
}
async function poll(jobId) {
try {
const r = await fetch(base + '/api/gst/status?job_id=' + encodeURIComponent(jobId), {cache:'no-store'});
const b = await r.json();
if (b.job) renderJob(b.job);
if (b.job && !['completed','failed'].includes(b.job.status)) setTimeout(() => poll(jobId), 1800);
} catch (e) {
if (msg) msg.textContent = 'Could not read GST Operator Agent progress: ' + e.message;
}
}
agentStatus();
{% if operator_job %}
const prepared = {{ operator_job|tojson }};
(async () => {
panel?.classList.remove('hidden');
if (stage) stage.textContent = 'Connecting to GST Operator Agent';
if (msg) msg.textContent = 'Starting visible GST login on this workstation…';
try {
const online = await agentStatus();
if (!online) throw new Error('Install/start the GST Operator Agent using the button above, then retry Start GST Download.');
const r = await fetch(base + '/api/gst/start', {
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({token:prepared.token})
});
const b = await r.json();
if (!r.ok || !b.ok) throw new Error(b.error || 'Agent could not start GST browser.');
renderJob(b.job || {status:'queued',stage:'Queued',message:'GST browser job queued.'});
poll(prepared.job_id);
} catch (e) {
renderJob({status:'failed',percent:100,stage:'Could not start GST browser',message:e.message});
}
})();
{% endif %}
})();
</script>
{% endblock %} {% endblock %}