Integrate GST operator agent authenticated browser download flow

This commit is contained in:
A R R R Associates
2026-09-11 22:14:52 +05:30
parent b361a66e44
commit 756daf67da
4 changed files with 338 additions and 101 deletions
@@ -1,4 +1,4 @@
ARRR GST Operator Agent 1.1.0 ARRR GST Operator Agent 1.2.0
Purpose Purpose
------- -------
@@ -3,21 +3,25 @@ from __future__ import annotations
import json import json
import re import re
import shutil import shutil
import sys
import time import time
import traceback
import zipfile import zipfile
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from urllib.parse import parse_qs, urlsplit from urllib.parse import parse_qs, urlsplit
import sys
import traceback
import requests import requests
VERSION = "1.1.0" VERSION = "1.2.0"
ROOT = Path(__file__).resolve().parent ROOT = Path(__file__).resolve().parent
DATA = ROOT / "data" DATA = ROOT / "data"
CONFIG_PATH = ROOT / "config.json" CONFIG_PATH = ROOT / "config.json"
GST_LOGIN_URL = "https://services.gst.gov.in/services/login" GST_LOGIN_URL = "https://services.gst.gov.in/services/login"
SERVICES_DASHBOARD_URL = "https://services.gst.gov.in/services/auth/dashboard"
RETURN_DASHBOARD_URL = "https://return.gst.gov.in/returns/auth/dashboard"
OFFLINE_DOWNLOAD_PAGE_URL = "https://return.gst.gov.in/returns/auth/gstr/offlinedownload"
GSTR2B_PAGE_URL = "https://gstr2b.gst.gov.in/gstr2b/auth/gstr2bdwld"
GSTR1_URL = "https://return.gst.gov.in/returns/auth/api/offline/download/generate?flag=0&rtn_prd={period}&rtn_typ=GSTR1" 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" 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}" GSTR1_DOWNLOAD_URL = "https://return.gst.gov.in/returns/auth/api/offline/download/url?rtn_prd={period}&rtn_typ=GSTR1&file_num={file_num}"
@@ -90,49 +94,248 @@ def extract_payload(text: str) -> str:
except Exception: except Exception:
return text or "" return text or ""
if isinstance(data, dict): if isinstance(data, dict):
for key in ("data", "payload", "json", "response"): for key in ("data", "payload", "json", "response", "content", "fileContent"):
value = data.get(key) value = data.get(key)
if isinstance(value, (dict, list)): if isinstance(value, (dict, list)):
return json.dumps(value, ensure_ascii=False) return json.dumps(value, ensure_ascii=False)
if isinstance(value, str) and value.strip().startswith(("{", "[")): if isinstance(value, str) and value.strip().startswith(("{", "[")):
return value return value
if any(k in data for k in ("b2b", "b2cl", "b2cs", "cdnr", "gstin")):
return text
return text or "" return text or ""
def api_text(context, url: str) -> str: def _log(message: str) -> None:
response = context.request.get(url, timeout=90000) try:
if not response.ok: DATA.mkdir(parents=True, exist_ok=True)
raise RuntimeError(f"GST portal request failed ({response.status}) for {url.split('?')[0]}") with (DATA / "operator_agent.log").open("a", encoding="utf-8") as handle:
return response.text() handle.write(f"[{now()}] {message}\n")
except Exception:
pass
def download_period(context, work_root: Path, period: str, return_types: list[str], progress) -> list[dict]: def _show_error(message: str) -> None:
_log("ERROR: " + message)
try:
import tkinter as tk
from tkinter import messagebox
root = tk.Tk()
root.withdraw()
messagebox.showerror("ARRR GST Operator Agent", message)
root.destroy()
except Exception:
pass
def is_access_denied_page(page) -> bool:
try:
url = (page.url or "").lower()
if "accessdenied" in url or "session expired" in url:
return True
try:
text = (page.locator("body").inner_text(timeout=1500) or "").lower()
except Exception:
text = ""
return "access denied" in text or "session expired" in text or "your session expired" in text
except Exception:
return False
def inject_captcha_overlay(page) -> None:
js = r"""
async () => {
return await new Promise((resolve) => {
const old = document.getElementById('arrr-gst-captcha-overlay'); if (old) old.remove();
const overlay = document.createElement('div'); overlay.id='arrr-gst-captcha-overlay';
Object.assign(overlay.style,{position:'fixed',inset:'0',zIndex:'2147483647',background:'rgba(15,23,42,.60)',display:'flex',alignItems:'center',justifyContent:'center',fontFamily:'Arial,sans-serif'});
const box=document.createElement('div'); Object.assign(box.style,{background:'#fff',borderRadius:'12px',boxShadow:'0 20px 60px rgba(0,0,0,.35)',width:'540px',maxWidth:'90vw',padding:'24px',color:'#111827'});
const title=document.createElement('div'); title.innerText='GST Login Captcha Required'; Object.assign(title.style,{fontWeight:'700',fontSize:'20px',marginBottom:'12px'});
const body=document.createElement('div'); body.innerText='Username and password are filled.\\n\\nClick OK, enter CAPTCHA in the GST page, complete OTP if asked, and Login.\\n\\nARRR GST Operator Agent will verify the authenticated session automatically and then download the selected returns.'; Object.assign(body.style,{fontSize:'15px',lineHeight:'1.5',whiteSpace:'pre-line',marginBottom:'18px'});
const btn=document.createElement('button'); btn.innerText='OK - Enter Captcha'; btn.type='button'; Object.assign(btn.style,{background:'#1d4ed8',color:'#fff',border:'0',borderRadius:'8px',padding:'10px 16px',fontWeight:'700',cursor:'pointer'});
btn.onclick=()=>{overlay.remove(); setTimeout(()=>{const c=document.querySelector('#captcha, input[name="captcha"], input[placeholder*="Captcha"]'); if(c){c.scrollIntoView({block:'center'});c.focus();c.click();}},150); resolve(true);};
box.append(title,body,btn); overlay.appendChild(box); document.body.appendChild(overlay); btn.focus();
});
}
"""
try:
page.evaluate(js)
except Exception as exc:
_log(f"Captcha overlay warning: {exc}")
def show_status_overlay(page, message: str, color: str = "#0f172a") -> None:
try:
page.evaluate(
"""({message,color})=>{let b=document.getElementById('arrr-gst-status');if(!b){b=document.createElement('div');b.id='arrr-gst-status';Object.assign(b.style,{position:'fixed',right:'18px',bottom:'18px',zIndex:'2147483647',color:'#fff',borderRadius:'12px',padding:'14px 18px',boxShadow:'0 12px 40px rgba(0,0,0,.35)',fontFamily:'Arial,sans-serif',fontSize:'14px',maxWidth:'440px'});document.body.appendChild(b);}b.style.background=color;b.innerText=message;}""",
{"message": message, "color": color},
)
except Exception:
pass
def _looks_like_valid_ustatus_json(data) -> bool:
if not isinstance(data, dict) or not data:
return False
low = json.dumps(data, default=str).lower()[:3000]
if any(x in low for x in ("access denied", "accessdenied", "session expired", "login required", "invalid session")):
return False
keys = {str(k).lower() for k in data}
if keys & {"utype", "llogin", "lastlogin", "gstin", "gstinno", "username", "legalname", "tradename", "lgnm", "trade_name"}:
return True
meaningful = sum(
1
for value in data.values()
if isinstance(value, (str, int, float, bool)) and str(value).strip() not in ("", "None", "null")
)
return meaningful >= 3
def check_gst_session(page) -> tuple[bool, str]:
for url in (
"https://services.gst.gov.in/services/api/ustatus",
"https://return.gst.gov.in/services/api/ustatus",
):
try:
resp = page.context.request.get(url, timeout=20000)
text = resp.text() or ""
ctype = (resp.headers.get("content-type") or "").lower()
if resp.status == 200 and "json" in ctype:
try:
data = json.loads(text or "{}")
except Exception:
data = None
if _looks_like_valid_ustatus_json(data):
return True, f"authenticated session confirmed from {url}"
except Exception as exc:
_log(f"GST session check warning {url}: {exc}")
return False, "GST authenticated session not confirmed yet"
def wait_for_login(page, progress, timeout_seconds: int = 900) -> None:
inject_captcha_overlay(page)
progress(
percent=10,
stage="Waiting for CAPTCHA / OTP",
message="Username/password filled. Complete CAPTCHA/OTP and login in the visible GST browser. Session is checked automatically.",
)
started = time.time()
while time.time() - started < timeout_seconds:
if is_access_denied_page(page):
raise RuntimeError(
"GST portal reported Access Denied / Session Expired. Close other GST sessions and start the download again."
)
ok, reason = check_gst_session(page)
if ok:
progress(percent=15, stage="GST Login Verified", message=reason)
show_status_overlay(
page,
"GST login verified. ARRR is downloading the selected GST returns now.",
"#065f46",
)
return
time.sleep(5)
raise RuntimeError("GST login/session was not confirmed within 15 minutes.")
def _origin_for_url(url: str) -> str:
for origin in (
"https://gstr2b.gst.gov.in",
"https://return.gst.gov.in",
"https://services.gst.gov.in",
"https://payment.gst.gov.in",
):
if origin.split("//", 1)[1] in (url or ""):
return origin
return "https://return.gst.gov.in"
def open_visible_page(page, url: str, label: str = "GST page", wait_ms: int = 1800) -> None:
try:
page.bring_to_front()
except Exception:
pass
try:
page.goto(url, wait_until="domcontentloaded", timeout=90000)
except Exception:
page.evaluate("url=>window.location.href=url", url)
page.wait_for_load_state("domcontentloaded", timeout=90000)
page.wait_for_timeout(wait_ms)
if is_access_denied_page(page):
raise RuntimeError(f"GST portal returned Access Denied / Session Expired while opening {label}.")
def ensure_origin_page(page, url: str) -> None:
origin = _origin_for_url(url)
if (page.url or "").startswith(origin):
return
if origin == "https://gstr2b.gst.gov.in":
open_visible_page(page, GSTR2B_PAGE_URL, "GSTR-2B download page", 2500)
elif origin == "https://return.gst.gov.in":
open_visible_page(page, RETURN_DASHBOARD_URL, "GST Return Dashboard", 2500)
elif origin == "https://services.gst.gov.in":
open_visible_page(page, SERVICES_DASHBOARD_URL, "GST services dashboard", 1800)
def page_fetch_text(page, url: str, method: str = "GET", payload=None) -> str:
ensure_origin_page(page, url)
result = page.evaluate(
"""async ({url,method,payload})=>{const headers={Accept:'application/json, text/plain, */*'};const opt={method,credentials:'include',headers};if(payload!==null&&payload!==undefined){headers['Content-Type']='application/json;charset=UTF-8';opt.body=JSON.stringify(payload);}const r=await fetch(url,opt);return {status:r.status,ctype:r.headers.get('content-type')||'',text:await r.text(),url:r.url};}""",
{"url": url, "method": method, "payload": payload},
)
status = int(result.get("status") or 0)
text = result.get("text") or ""
ctype = (result.get("ctype") or "").lower()
sample = text.lstrip().lower()
if status >= 400:
raise RuntimeError(f"GST API HTTP {status}: {text[:500]}")
if ("text/html" in ctype or sample.startswith(("<!doctype", "<html"))) and not sample.startswith(("{", "[")):
raise RuntimeError(
"GST returned an HTML/login page instead of return JSON. The portal session/module is not ready."
)
return text
def prepare_portal_context(page, return_types: list[str]) -> None:
selected = {str(x or "").upper() for x in return_types}
if "GSTR1" in selected or "GSTR2A" in selected:
open_visible_page(page, OFFLINE_DOWNLOAD_PAGE_URL, "GST offline return download page", 2500)
if "GSTR2B" in selected:
open_visible_page(page, GSTR2B_PAGE_URL, "GSTR-2B download page", 2500)
if "GSTR3B" in selected:
open_visible_page(page, RETURN_DASHBOARD_URL, "GST Return Dashboard", 2500)
def download_period(page, work_root: Path, period: str, return_types: list[str], progress) -> list[dict]:
raw_dir = work_root / period / "raw" raw_dir = work_root / period / "raw"
raw_dir.mkdir(parents=True, exist_ok=True) raw_dir.mkdir(parents=True, exist_ok=True)
selected = {str(x or "").upper() for x in return_types} selected = {str(x or "").upper() for x in return_types}
downloaded: list[dict] = [] downloaded: list[dict] = []
if "GSTR1" in selected: if "GSTR1" in selected:
progress(stage=f"Downloading GSTR-1 {period}", message=f"Reading GSTR-1 for {period}.") progress(stage=f"Downloading GSTR-1 {period}", message=f"Generating and downloading GSTR-1 for {period}.")
generated = api_text(context, GSTR1_URL.format(period=period)) open_visible_page(page, OFFLINE_DOWNLOAD_PAGE_URL, "GSTR-1 offline download page", 1800)
generated = page_fetch_text(page, GSTR1_URL.format(period=period))
(raw_dir / f"{period}_GSTR1_GENERATE.json").write_text(generated, encoding="utf-8") (raw_dir / f"{period}_GSTR1_GENERATE.json").write_text(generated, encoding="utf-8")
file_num = extract_file_num(generated) file_num = extract_file_num(generated)
content = extract_payload(api_text(context, GSTR1_DOWNLOAD_URL.format(period=period, file_num=file_num))) content = extract_payload(page_fetch_text(page, GSTR1_DOWNLOAD_URL.format(period=period, file_num=file_num)))
path = raw_dir / f"{period}_GSTR1.json" path = raw_dir / f"{period}_GSTR1.json"
path.write_text(content, encoding="utf-8") path.write_text(content, encoding="utf-8")
downloaded.append({"return_type": "GSTR1", "path": str(path.relative_to(work_root)), "bytes": path.stat().st_size}) downloaded.append({"return_type": "GSTR1", "path": str(path.relative_to(work_root)), "bytes": path.stat().st_size})
if "GSTR2B" in selected: if "GSTR2B" in selected:
progress(stage=f"Downloading GSTR-2B {period}", message=f"Reading GSTR-2B for {period}.") progress(stage=f"Downloading GSTR-2B {period}", message=f"Downloading GSTR-2B JSON for {period}.")
content = api_text(context, GSTR2B_URL.format(period=period)) open_visible_page(page, GSTR2B_PAGE_URL, "GSTR-2B download page", 1800)
content = page_fetch_text(page, GSTR2B_URL.format(period=period))
path = raw_dir / f"{period}_GSTR2B.json" path = raw_dir / f"{period}_GSTR2B.json"
path.write_text(content, encoding="utf-8") path.write_text(content, encoding="utf-8")
downloaded.append({"return_type": "GSTR2B", "path": str(path.relative_to(work_root)), "bytes": path.stat().st_size}) downloaded.append({"return_type": "GSTR2B", "path": str(path.relative_to(work_root)), "bytes": path.stat().st_size})
if "GSTR3B" in selected: if "GSTR3B" in selected:
progress(stage=f"Downloading GSTR-3B {period}", message=f"Reading GSTR-3B for {period}.") progress(stage=f"Downloading GSTR-3B {period}", message=f"Downloading GSTR-3B summary and tax payable for {period}.")
summary = api_text(context, GSTR3B_SUMMARY_URL.format(period=period)) open_visible_page(page, RETURN_DASHBOARD_URL, "GST Return Dashboard", 1800)
payable = api_text(context, GSTR3B_URL.format(period=period)) summary = page_fetch_text(page, GSTR3B_SUMMARY_URL.format(period=period))
payable = page_fetch_text(page, GSTR3B_URL.format(period=period))
(raw_dir / f"{period}_GSTR3B_SUMMARY.json").write_text(summary, encoding="utf-8") (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") (raw_dir / f"{period}_GSTR3B_TAXPAYBLE.json").write_text(payable, encoding="utf-8")
try: try:
@@ -150,35 +353,24 @@ def download_period(context, work_root: Path, period: str, return_types: list[st
downloaded.append({"return_type": "GSTR3B", "path": str(path.relative_to(work_root)), "bytes": path.stat().st_size}) downloaded.append({"return_type": "GSTR3B", "path": str(path.relative_to(work_root)), "bytes": path.stat().st_size})
if "GSTR2A" in selected: if "GSTR2A" in selected:
progress(stage=f"Requesting GSTR-2A {period}", message=f"Requesting GSTR-2A for {period}.") progress(stage=f"Requesting GSTR-2A {period}", message=f"Requesting GSTR-2A offline return for {period}.")
content = api_text(context, GSTR2A_URL.format(period=period)) open_visible_page(page, OFFLINE_DOWNLOAD_PAGE_URL, "GSTR-2A offline download page", 1800)
content = page_fetch_text(page, GSTR2A_URL.format(period=period))
path = raw_dir / f"{period}_GSTR2A.json" path = raw_dir / f"{period}_GSTR2A.json"
path.write_text(content, encoding="utf-8") path.write_text(content, encoding="utf-8")
downloaded.append({"return_type": "GSTR2A", "path": str(path.relative_to(work_root)), "bytes": path.stat().st_size}) 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", { write_json(
"period": period, "downloaded_at_utc": now(), "source": "gst_lightweight_operator_agent", "downloaded": downloaded work_root / period / "download_manifest.json",
}) {"period": period, "downloaded_at_utc": now(), "source": "arrr_gst_operator_agent_1.2.0", "downloaded": downloaded},
)
return 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: def browser_worker(payload: dict, token: str, path: Path) -> None:
job_id = str(payload.get("jti") or "") job_id = str(payload.get("jti") or "")
work_root = DATA / "work" / safe(job_id) work_root = DATA / "work" / safe(job_id)
package_path = DATA / f"gst_{safe(job_id)}.zip" 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): def progress(**updates):
current = read_json(path) current = read_json(path)
@@ -186,8 +378,12 @@ def browser_worker(payload: dict, token: str, path: Path) -> None:
current["updated_at_utc"] = now() current["updated_at_utc"] = now()
write_json(path, current) write_json(path, current)
progress(status="running", percent=5, stage="Opening GST Login", message="Opening GST Portal in visible Chrome/Edge on this workstation.") try:
shutil.rmtree(work_root, ignore_errors=True)
work_root.mkdir(parents=True, exist_ok=True)
progress(status="running", percent=5, stage="Opening GST Login", message="Opening GST Portal in visible Chrome/Edge.")
from playwright.sync_api import sync_playwright from playwright.sync_api import sync_playwright
with sync_playwright() as pw: with sync_playwright() as pw:
context = None context = None
errors = [] errors = []
@@ -196,20 +392,24 @@ def browser_worker(payload: dict, token: str, path: Path) -> None:
for channel in ("chrome", "msedge"): for channel in ("chrome", "msedge"):
try: try:
context = pw.chromium.launch_persistent_context( context = pw.chromium.launch_persistent_context(
user_data_dir=str(profile_root / channel), channel=channel, headless=False, user_data_dir=str(profile_root / f"{channel}_{safe(job_id)}"),
no_viewport=True, accept_downloads=True, channel=channel,
headless=False,
no_viewport=True,
accept_downloads=True,
args=["--start-maximized", "--no-first-run", "--disable-blink-features=AutomationControlled"], args=["--start-maximized", "--no-first-run", "--disable-blink-features=AutomationControlled"],
timeout=45000,
) )
break break
except Exception as exc: except Exception as exc:
errors.append(f"{channel}: {exc}") errors.append(f"{channel}: {exc}")
if context is None: if context is None:
raise RuntimeError("Could not open installed Chrome or Edge. " + " | ".join(errors)) raise RuntimeError("Could not open installed Chrome or Edge. " + " | ".join(errors))
page = context.pages[0] if context.pages else context.new_page() page = context.pages[0] if context.pages else context.new_page()
page.goto(GST_LOGIN_URL, wait_until="domcontentloaded", timeout=90000) open_visible_page(page, GST_LOGIN_URL, "GST Login", 1000)
page.locator("#username").wait_for(state="visible", timeout=30000) page.locator("#username").wait_for(state="visible", timeout=30000)
page.fill("#username", str(payload.get("username") or "")) 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 password_filled = False
for selector in ("#user_pass", "input[type=password]"): for selector in ("#user_pass", "input[type=password]"):
try: try:
@@ -221,25 +421,44 @@ def browser_worker(payload: dict, token: str, path: Path) -> None:
except Exception: except Exception:
pass pass
if not password_filled: if not password_filled:
raise RuntimeError("GST password field could not be located. The portal login page may have changed.") raise RuntimeError("GST password field could not be located. The GST 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.")
wait_for_login(page, progress, int(payload.get("login_timeout_seconds") or 900))
periods = [str(p) for p in payload.get("periods") or []] periods = [str(p) for p in payload.get("periods") or []]
return_types = [str(r) for r in payload.get("return_types") or []] return_types = [str(r) for r in payload.get("return_types") or []]
prepare_portal_context(page, return_types)
all_downloaded = [] all_downloaded = []
for index, period in enumerate(periods, 1): for index, period in enumerate(periods, 1):
base_pct = 10 + int((index - 1) * 75 / max(1, len(periods))) pct = 15 + int((index - 1) * 70 / 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}.") progress(
all_downloaded.extend(download_period(context, work_root, period, return_types, progress)) percent=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(page, work_root, period, return_types, progress))
show_status_overlay(
page,
"Selected GST return downloads are complete. Transferring them to ARRR client storage.",
"#065f46",
)
context.close() context.close()
write_json(
work_root / "job_manifest.json",
{
"job_id": job_id,
"gstin": payload.get("gstin"),
"financial_year": payload.get("financial_year"),
"periods": payload.get("periods") or [],
"return_types": payload.get("return_types") or [],
"downloaded": all_downloaded,
"completed_at_utc": now(),
},
)
if package_path.exists(): if package_path.exists():
package_path.unlink() package_path.unlink()
with zipfile.ZipFile(package_path, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as archive: with zipfile.ZipFile(package_path, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
@@ -247,11 +466,20 @@ def browser_worker(payload: dict, token: str, path: Path) -> None:
if item.is_file(): if item.is_file():
archive.write(item, item.relative_to(work_root).as_posix()) 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.") progress(
percent=90,
stage="Transferring to Client Storage",
message="Uploading GST return package to ERP for transfer to configured Storage Agent.",
)
upload_url = str(payload.get("upload_url") or "").strip()
if not upload_url.startswith("https://"):
raise RuntimeError(f"ERP upload URL is invalid: {upload_url or '(empty)'}")
with package_path.open("rb") as handle: with package_path.open("rb") as handle:
response = requests.post( response = requests.post(
str(payload.get("upload_url") or ""), data={"token": token}, upload_url,
files={"package": (package_path.name, handle, "application/zip")}, timeout=240, data={"token": token},
files={"package": (package_path.name, handle, "application/zip")},
timeout=240,
) )
try: try:
body = response.json() body = response.json()
@@ -259,11 +487,29 @@ def browser_worker(payload: dict, token: str, path: Path) -> None:
body = {"ok": False, "error": response.text[:1000]} body = {"ok": False, "error": response.text[:1000]}
if not response.ok or not body.get("ok"): 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}.") 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()) progress(
status="completed",
percent=100,
stage="Completed",
message="GST returns downloaded and stored in configured client local storage.",
result={"stored": body.get("stored") or {}},
finished_at_utc=now(),
)
except Exception as exc: except Exception as exc:
progress = read_json(path) current = 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()}) current.update(
write_json(path, progress) {
"status": "failed",
"percent": 100,
"stage": "Failed",
"message": str(exc),
"error": str(exc),
"finished_at_utc": now(),
"updated_at_utc": now(),
}
)
write_json(path, current)
_log(traceback.format_exc())
finally: finally:
shutil.rmtree(work_root, ignore_errors=True) shutil.rmtree(work_root, ignore_errors=True)
try: try:
@@ -272,28 +518,6 @@ def browser_worker(payload: dict, token: str, path: Path) -> None:
pass pass
def _log(message: str) -> None:
try:
DATA.mkdir(parents=True, exist_ok=True)
with (DATA / "operator_agent.log").open("a", encoding="utf-8") as handle:
handle.write(f"[{now()}] {message}\n")
except Exception:
pass
def _show_error(message: str) -> None:
_log("ERROR: " + message)
try:
import tkinter as tk
from tkinter import messagebox
root = tk.Tk()
root.withdraw()
messagebox.showerror("ARRR GST Operator Agent", message)
root.destroy()
except Exception:
pass
def redeem_job(token: str) -> dict: def redeem_job(token: str) -> dict:
cfg = read_config() cfg = read_config()
erp = str(cfg.get("erp_base_url") or "").rstrip("/") erp = str(cfg.get("erp_base_url") or "").rstrip("/")
@@ -313,8 +537,7 @@ def redeem_job(token: str) -> dict:
if not response.ok or not body.get("ok"): if not response.ok or not body.get("ok"):
raise RuntimeError(body.get("error") or "ERP could not authorize this GST download.") raise RuntimeError(body.get("error") or "ERP could not authorize this GST download.")
payload = dict(body.get("payload") or {}) payload = dict(body.get("payload") or {})
job_id = str(payload.get("jti") or "") if not str(payload.get("jti") or ""):
if not job_id:
raise RuntimeError("ERP did not return a GST job id.") raise RuntimeError("ERP did not return a GST job id.")
return payload return payload
@@ -323,14 +546,20 @@ def run_job_foreground(token: str) -> None:
payload = redeem_job(token) payload = redeem_job(token)
job_id = str(payload.get("jti") or "") job_id = str(payload.get("jti") or "")
path = job_path(job_id) path = job_path(job_id)
initial = { write_json(
"status": "queued", "percent": 1, "stage": "Queued", path,
{
"status": "queued",
"percent": 1,
"stage": "Queued",
"message": "GST browser job launched from ARRR ERP.", "message": "GST browser job launched from ARRR ERP.",
"job_id": job_id, "periods": payload.get("periods") or [], "job_id": job_id,
"periods": payload.get("periods") or [],
"return_types": payload.get("return_types") or [], "return_types": payload.get("return_types") or [],
"started_at_utc": now(), "updated_at_utc": now(), "started_at_utc": now(),
} "updated_at_utc": now(),
write_json(path, initial) },
)
_log(f"Starting GST job {job_id}") _log(f"Starting GST job {job_id}")
browser_worker(payload, token, path) browser_worker(payload, token, path)
final = read_json(path) final = read_json(path)
@@ -384,7 +613,6 @@ def main():
return return
print(f"ARRR GST Operator Agent {VERSION}") print(f"ARRR GST Operator Agent {VERSION}")
print("This agent is launched by the arrrgst:// Windows protocol from ARRR ERP.") print("This agent is launched by the arrrgst:// Windows protocol from ARRR ERP.")
print("Run with --self-test to validate this installation.")
if __name__ == "__main__": if __name__ == "__main__":
@@ -9,7 +9,7 @@ $Startup = Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs\Startup
$ThumbprintFile = Join-Path $InstallRoot "cert_thumbprint.txt" $ThumbprintFile = Join-Path $InstallRoot "cert_thumbprint.txt"
$ProtocolKey = "HKCU:\Software\Classes\arrrgst" $ProtocolKey = "HKCU:\Software\Classes\arrrgst"
Write-Host "ARRR GST Operator Agent 1.1.0 - clean installation" -ForegroundColor Cyan Write-Host "ARRR GST Operator Agent 1.2.0 - clean installation" -ForegroundColor Cyan
Write-Host "Install root: $InstallRoot" Write-Host "Install root: $InstallRoot"
# Clean legacy v1.0.x listener/startup/certificate/protocol state first. # Clean legacy v1.0.x listener/startup/certificate/protocol state first.
@@ -147,7 +147,7 @@ router = APIRouter(prefix="/tools/accounting/gst-reconciliation", tags=["account
_TOKEN_PURPOSE = "gst_lightweight_operator_v3" _TOKEN_PURPOSE = "gst_lightweight_operator_v3"
_TOKEN_MINUTES = 15 _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.1.0" _OPERATOR_AGENT_VERSION = "1.2.0"
_OPERATOR_RUNTIME_ROOT = Path(__file__).resolve().parent / "gst_operator_agent_runtime" _OPERATOR_RUNTIME_ROOT = Path(__file__).resolve().parent / "gst_operator_agent_runtime"
_OPERATOR_PACKAGE_FILES = ("gst_operator_agent.py", "requirements.txt", "README.txt", "install_gst_operator_agent.ps1", "uninstall_gst_operator_agent.ps1") _OPERATOR_PACKAGE_FILES = ("gst_operator_agent.py", "requirements.txt", "README.txt", "install_gst_operator_agent.ps1", "uninstall_gst_operator_agent.ps1")
@@ -444,6 +444,14 @@ def download_operator_agent(request: Request):
db.close() db.close()
def _operator_public_base_url(request: Request) -> str:
proto = str(request.headers.get("x-forwarded-proto") or request.url.scheme or "https").split(",")[0].strip()
host = str(request.headers.get("x-forwarded-host") or request.headers.get("host") or request.url.netloc).split(",")[0].strip()
if not host:
return "https://office.arrrassociates.com"
return f"{proto}://{host}".rstrip("/")
@router.post("/download/start") @router.post("/download/start")
def start_download( def start_download(
request: Request, request: Request,
@@ -483,6 +491,7 @@ def start_download(
"node_code":str(node.node_code),"client_id":int(client.id),"client_name":str(client.client_name or ""), "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, "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, "periods":periods,"return_types":return_types,"gst_relative_dir":gst_dir,"accounting_relative_dir":accounting_dir,
"erp_base_url":_operator_public_base_url(request),
}) })
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()
@@ -510,7 +519,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,"operator_agent_min_version":_OPERATOR_AGENT_VERSION}}) 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(data.get("erp_base_url") or "https://office.arrrassociates.com").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)