Switch GST operator agent to custom protocol launch

This commit is contained in:
A R R R Associates
2026-09-11 12:40:00 +05:30
parent a32132348d
commit b361a66e44
7 changed files with 181 additions and 287 deletions
@@ -1,27 +1,25 @@
ARRR GST Operator Agent 1.0.1
ARRR GST Operator Agent 1.1.0
Purpose
- Runs only on the GST operator's Windows workstation.
- Listens only on 127.0.0.1:8791.
- Exposes a trusted local HTTPS endpoint at https://localhost:8791.
- Opens visible Chrome/Edge for GST Portal authentication and return download.
- Autofills the selected Credential Vault username/password supplied through the ERP short-lived job token.
- CAPTCHA/OTP remains interactive with the operator.
- Does not run Tally and does not hold the client storage.
- Completed GST packages continue through the ERP to the already configured Local Storage Agent.
-------
Runs only on the GST operator workstation. It opens visible Chrome/Edge, redeems a short-lived GST job token from ARRR ERP, fills the selected Credential Vault username/password, waits for manual CAPTCHA/OTP, downloads selected GST return data, and uploads the package back to ERP for transfer to the configured Storage Agent.
Install
1. Extract the downloaded ZIP.
2. Run PowerShell in the extracted ARRR_GST_Operator_Agent folder:
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\install_gst_operator_agent.ps1
3. The installer creates a localhost TLS certificate and trusts it only for the current Windows user.
4. Verify https://localhost:8791/api/status.
5. Refresh the ERP GST Return Reconciliation page.
Architecture
------------
ARRR ERP starts the agent through the Windows custom URL protocol arrrgst://. No browser-to-localhost HTTP/HTTPS request is used. No localhost TLS certificate, local web server, Tally access, or client storage access is required on the operator workstation.
Upgrade from 1.0.0
- Download the current agent ZIP from ERP and run install_gst_operator_agent.ps1 again.
- The installer stops the old HTTP listener, replaces the runtime, installs the trusted localhost certificate and starts v1.0.1 over HTTPS.
Installation
------------
Run install_gst_operator_agent.ps1 as the Windows user who will operate GST downloads. Administrator rights are not required. Python 3.11+ and installed Chrome or Edge are required.
Browser prompt
--------------
The first time ARRR ERP opens arrrgst://, Chrome/Edge may ask whether to open ARRR GST Operator Agent. Choose Open/Allow.
Security
--------
The custom URL contains only a short-lived signed job token. GST username/password are redeemed by the local agent directly from ARRR ERP and are never inserted in ERP page HTML or in the custom URL. CAPTCHA/OTP remains manual.
Uninstall
- Run uninstall_gst_operator_agent.ps1 from the installed/downloaded package.
- The installer-created certificate is also removed from the current user's trusted root store.
---------
Run uninstall_gst_operator_agent.ps1. It removes the protocol registration and also cleans legacy v1.0.x localhost listener/startup/certificate state.
@@ -1,59 +0,0 @@
from __future__ import annotations
import ipaddress
from datetime import datetime, timedelta, timezone
from pathlib import Path
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
ROOT = Path(__file__).resolve().parent
CERT_PEM = ROOT / "localhost-cert.pem"
KEY_PEM = ROOT / "localhost-key.pem"
CERT_DER = ROOT / "localhost-cert.cer"
def main() -> None:
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
name = x509.Name([
x509.NameAttribute(NameOID.COMMON_NAME, "ARRR GST Operator Agent localhost"),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "ARRR & Associates"),
])
now = datetime.now(timezone.utc)
cert = (
x509.CertificateBuilder()
.subject_name(name)
.issuer_name(name)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - timedelta(days=1))
.not_valid_after(now + timedelta(days=825))
.add_extension(
x509.SubjectAlternativeName([
x509.DNSName("localhost"),
x509.IPAddress(ipaddress.ip_address("127.0.0.1")),
]),
critical=False,
)
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
.add_extension(
x509.ExtendedKeyUsage([x509.oid.ExtendedKeyUsageOID.SERVER_AUTH]),
critical=False,
)
.sign(key, hashes.SHA256())
)
KEY_PEM.write_bytes(
key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
)
)
CERT_PEM.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
CERT_DER.write_bytes(cert.public_bytes(serialization.Encoding.DER))
if __name__ == "__main__":
main()
@@ -3,19 +3,17 @@ from __future__ import annotations
import json
import re
import shutil
import ssl
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 sys
import traceback
import requests
VERSION = "1.0.1"
PORT = 8791
VERSION = "1.1.0"
ROOT = Path(__file__).resolve().parent
DATA = ROOT / "data"
CONFIG_PATH = ROOT / "config.json"
@@ -26,7 +24,6 @@ GSTR1_DOWNLOAD_URL = "https://return.gst.gov.in/returns/auth/api/offline/downloa
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:
@@ -37,7 +34,7 @@ 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, "certfile": str(ROOT / "localhost-cert.pem"), "keyfile": str(ROOT / "localhost-key.pem")}
return {"erp_base_url": "https://office.arrrassociates.com", "protocol": "arrrgst"}
def safe(value: str, fallback: str = "item") -> str:
@@ -275,12 +272,40 @@ def browser_worker(payload: dict, token: str, path: Path) -> None:
pass
def start_job(token: str) -> dict:
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:
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)
if not erp.startswith("https://"):
raise RuntimeError("ERP base URL must use HTTPS.")
response = requests.post(
erp + "/tools/accounting/gst-reconciliation/operator/redeem",
json={"token": token},
timeout=30,
)
try:
body = response.json()
except Exception:
@@ -291,88 +316,75 @@ def start_job(token: str) -> dict:
job_id = str(payload.get("jti") or "")
if not job_id:
raise RuntimeError("ERP did not return a GST job id.")
return payload
def run_job_foreground(token: str) -> None:
payload = redeem_job(token)
job_id = str(payload.get("jti") or "")
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 [],
"status": "queued", "percent": 1, "stage": "Queued",
"message": "GST browser job launched from ARRR ERP.",
"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
_log(f"Starting GST job {job_id}")
browser_worker(payload, token, path)
final = read_json(path)
if final.get("status") == "failed":
_show_error(str(final.get("error") or final.get("message") or "GST download failed."))
else:
_log(f"Completed GST job {job_id}")
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
return
def handle_protocol_url(raw_url: str) -> None:
parsed = urlsplit(str(raw_url or "").strip())
if parsed.scheme.lower() != "arrrgst":
raise ValueError("Invalid ARRR GST protocol URL.")
action = (parsed.netloc or parsed.path.lstrip("/") or "").lower()
if action != "start":
raise ValueError("Unsupported ARRR GST action.")
token = str((parse_qs(parsed.query).get("token") or [""])[0]).strip()
if not token:
raise ValueError("GST operator token is missing from the launch request.")
run_job_foreground(token)
def cors(self):
def self_test() -> int:
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, "transport": "https", "host": "localhost"})
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):
erp = str(cfg.get("erp_base_url") or "")
if not erp.startswith("https://"):
print("ERROR: ERP base URL must use HTTPS.")
return 1
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})
import playwright # noqa: F401
except Exception as exc:
return self.reply({"ok": False, "error": str(exc)}, 400)
print(f"ERROR: Playwright is unavailable: {exc}")
return 1
print(f"ARRR GST Operator Agent {VERSION} ready")
print(f"ERP: {erp}")
print("Protocol: arrrgst://start?token=<short-lived-token>")
return 0
def main():
DATA.mkdir(parents=True, exist_ok=True)
cfg = read_config()
port = int(cfg.get("port") or PORT)
certfile = Path(str(cfg.get("certfile") or (ROOT / "localhost-cert.pem")))
keyfile = Path(str(cfg.get("keyfile") or (ROOT / "localhost-key.pem")))
if not certfile.is_file() or not keyfile.is_file():
raise RuntimeError("GST Operator Agent localhost TLS certificate is missing. Re-run the agent installer.")
server = ThreadingHTTPServer(("127.0.0.1", port), Handler)
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.minimum_version = ssl.TLSVersion.TLSv1_2
context.load_cert_chain(certfile=str(certfile), keyfile=str(keyfile))
server.socket = context.wrap_socket(server.socket, server_side=True)
server.serve_forever()
if len(sys.argv) >= 2 and sys.argv[1] == "--self-test":
raise SystemExit(self_test())
if len(sys.argv) >= 2 and str(sys.argv[1]).lower().startswith("arrrgst:"):
try:
handle_protocol_url(sys.argv[1])
except Exception as exc:
_log(traceback.format_exc())
_show_error(str(exc))
raise SystemExit(1)
return
print(f"ARRR GST Operator Agent {VERSION}")
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__":
@@ -7,10 +7,25 @@ $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"
$ThumbprintFile = Join-Path $InstallRoot "cert_thumbprint.txt"
$ProtocolKey = "HKCU:\Software\Classes\arrrgst"
Write-Host "ARRR GST Operator Agent 1.0.1" -ForegroundColor Cyan
Write-Host "ARRR GST Operator Agent 1.1.0 - clean installation" -ForegroundColor Cyan
Write-Host "Install root: $InstallRoot"
# Clean legacy v1.0.x listener/startup/certificate/protocol state first.
try {
Get-NetTCPConnection -LocalPort 8791 -State Listen -ErrorAction SilentlyContinue | ForEach-Object {
if ($_.OwningProcess) { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }
}
} catch {}
if (Test-Path $ThumbprintFile) {
$Thumbprint = (Get-Content $ThumbprintFile -Raw -ErrorAction SilentlyContinue).Trim()
if ($Thumbprint) { Remove-Item "Cert:\CurrentUser\Root\$Thumbprint" -Force -ErrorAction SilentlyContinue }
}
Remove-Item $Startup -Force -ErrorAction SilentlyContinue
Remove-Item $ProtocolKey -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item $InstallRoot -Recurse -Force -ErrorAction SilentlyContinue
$Python = $null
if (Get-Command py -ErrorAction SilentlyContinue) { $Python = @("py", "-3") }
elseif (Get-Command python -ErrorAction SilentlyContinue) { $Python = @("python") }
@@ -19,7 +34,6 @@ else { throw "Python 3 is required on this workstation. Install Python 3.11+ and
New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null
$RuntimeFiles = @(
"gst_operator_agent.py",
"generate_localhost_cert.py",
"requirements.txt",
"README.txt",
"uninstall_gst_operator_agent.ps1"
@@ -32,58 +46,39 @@ foreach ($Name in $RuntimeFiles) {
$config = @{
erp_base_url = $ErpBaseUrl.TrimEnd('/')
port = 8791
certfile = (Join-Path $InstallRoot "localhost-cert.pem")
keyfile = (Join-Path $InstallRoot "localhost-key.pem")
protocol = "arrrgst"
} | 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." }
}
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"
$Pythonw = Join-Path $Venv "Scripts\pythonw.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." }
# Remove only the certificate previously installed by this agent instance.
if (Test-Path $ThumbprintFile) {
$OldThumbprint = (Get-Content $ThumbprintFile -Raw -ErrorAction SilentlyContinue).Trim()
if ($OldThumbprint) {
Remove-Item "Cert:\CurrentUser\Root\$OldThumbprint" -Force -ErrorAction SilentlyContinue
}
}
# Register per-user custom protocol. No administrator rights, localhost server, TLS certificate, or startup task is required.
New-Item -Path $ProtocolKey -Force | Out-Null
Set-ItemProperty -Path $ProtocolKey -Name "(Default)" -Value "URL:ARRR GST Operator Agent"
New-ItemProperty -Path $ProtocolKey -Name "URL Protocol" -Value "" -PropertyType String -Force | Out-Null
New-Item -Path "$ProtocolKey\DefaultIcon" -Force | Out-Null
Set-ItemProperty -Path "$ProtocolKey\DefaultIcon" -Name "(Default)" -Value "`"$Pythonw`",0"
New-Item -Path "$ProtocolKey\shell\open\command" -Force | Out-Null
$Command = "`"$Pythonw`" `"$InstallRoot\gst_operator_agent.py`" `"%1`""
Set-ItemProperty -Path "$ProtocolKey\shell\open\command" -Name "(Default)" -Value $Command
# Create a localhost-only TLS certificate and trust it for the current Windows user.
& $VenvPython (Join-Path $InstallRoot "generate_localhost_cert.py")
if ($LASTEXITCODE -ne 0) { throw "Could not create the GST Operator Agent localhost certificate." }
$CertFile = Join-Path $InstallRoot "localhost-cert.cer"
$ImportedCert = Import-Certificate -FilePath $CertFile -CertStoreLocation "Cert:\CurrentUser\Root"
if (-not $ImportedCert -or -not $ImportedCert.Thumbprint) { throw "Could not trust the GST Operator Agent localhost certificate." }
[System.IO.File]::WriteAllText($ThumbprintFile, $ImportedCert.Thumbprint, [System.Text.ASCIIEncoding]::new())
& $VenvPython (Join-Path $InstallRoot "gst_operator_agent.py") --self-test
if ($LASTEXITCODE -ne 0) { throw "GST Operator Agent self-test failed." }
$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())
$Registered = (Get-ItemProperty "$ProtocolKey\shell\open\command" -ErrorAction Stop)."(default)"
if (-not $Registered) { throw "ARRR GST custom protocol registration failed." }
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 3
try {
$Status = Invoke-RestMethod -Uri "https://localhost:8791/api/status" -TimeoutSec 8
Write-Host "Agent status: $($Status.name) v$($Status.version) - Online via HTTPS" -ForegroundColor Green
} catch {
throw "GST Operator Agent was installed but did not answer on https://localhost:8791. $($_.Exception.Message)"
}
Write-Host "Installed successfully. It will start automatically when this Windows user logs in." -ForegroundColor Green
Write-Host "Local status URL: https://localhost:8791/api/status" -ForegroundColor Cyan
Write-Host ""
Write-Host "Installed successfully." -ForegroundColor Green
Write-Host "Protocol registered: arrrgst://" -ForegroundColor Green
Write-Host "No localhost web server or trusted localhost certificate is used." -ForegroundColor DarkGray
Write-Host "Chrome/Edge may ask once whether ARRR GST Operator Agent can be opened. Choose Open/Allow." -ForegroundColor Yellow
@@ -2,19 +2,18 @@ $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"
$ThumbprintFile = Join-Path $InstallRoot "cert_thumbprint.txt"
$ProtocolKey = "HKCU:\Software\Classes\arrrgst"
try {
Get-NetTCPConnection -LocalPort 8791 -State Listen -ErrorAction SilentlyContinue | ForEach-Object {
if ($_.OwningProcess) { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }
}
} catch {}
if (Test-Path $ThumbprintFile) {
$Thumbprint = (Get-Content $ThumbprintFile -Raw -ErrorAction SilentlyContinue).Trim()
if ($Thumbprint) {
Remove-Item "Cert:\CurrentUser\Root\$Thumbprint" -Force -ErrorAction SilentlyContinue
}
if ($Thumbprint) { Remove-Item "Cert:\CurrentUser\Root\$Thumbprint" -Force -ErrorAction SilentlyContinue }
}
Remove-Item $Startup -Force -ErrorAction SilentlyContinue
Remove-Item $ProtocolKey -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item $InstallRoot -Recurse -Force -ErrorAction SilentlyContinue
Write-Host "ARRR GST Operator Agent removed." -ForegroundColor Green
Write-Host "ARRR GST Operator Agent removed, including legacy localhost certificate/startup state." -ForegroundColor Green
@@ -144,12 +144,12 @@ def extract_gstr3b_itc(data) -> dict[str, float]:
return totals
router = APIRouter(prefix="/tools/accounting/gst-reconciliation", tags=["accounting-gst-reconciliation-ui"])
_TOKEN_PURPOSE = "gst_lightweight_operator_v2"
_TOKEN_PURPOSE = "gst_lightweight_operator_v3"
_TOKEN_MINUTES = 15
_UPLOAD_ROOT = Path(tempfile.gettempdir()) / "audit_firm_gst_operator_uploads"
_OPERATOR_AGENT_VERSION = "1.0.1"
_OPERATOR_AGENT_PORT = 8791
_OPERATOR_AGENT_VERSION = "1.1.0"
_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")
def _fy_bounds(fy: str) -> tuple[date, date]:
@@ -414,7 +414,7 @@ def page(request: Request, client_id: int | None = None, registration_id: int |
"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,"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,
"operator_agent_version":_OPERATOR_AGENT_VERSION,
"message":message,"error":error,"title":"GST Return Reconciliation",
})
finally:
@@ -432,9 +432,11 @@ def download_operator_agent(request: Request):
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))
for name in _OPERATOR_PACKAGE_FILES:
path = _OPERATOR_RUNTIME_ROOT / name
if not path.is_file():
return JSONResponse({"ok": False, "error": f"GST Operator Agent package file is missing: {name}"}, status_code=500)
archive.write(path, Path("ARRR_GST_Operator_Agent") / name)
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)
@@ -484,8 +486,8 @@ 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)
db.commit()
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 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.")
request.session["gst_operator_job"]={"job_id":jti,"periods":periods,"return_types":return_types,"launch_url":"arrrgst://start?"+urlencode({"token":token})}
return _redirect(client_id,registration_id=registration_id,period=period,financial_year=financial_year,download_mode=download_mode,message="GST download prepared. Windows will launch the lightweight GST Operator Agent through the ARRR GST protocol. The agent 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:
db.rollback(); return _redirect(client_id,registration_id=registration_id,period=period,financial_year=financial_year,download_mode=download_mode,error=str(exc))
finally:
@@ -55,10 +55,11 @@
<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="font-semibold text-slate-700">Windows protocol launcher</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>
<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 / Clean Install Agent</a>
</div>
<p class="text-xs text-slate-500">No browser-to-localhost connection is required. When you start a GST download, Windows opens the installed agent through <b>arrrgst://</b>. Chrome/Edge may ask once for permission to open the ARRR GST Operator Agent.</p>
<label class="text-sm">Credential Vault Entry
<select name="credential_id" required class="mt-1 w-full rounded border p-2" {% if not credentials %}disabled{% endif %}>
@@ -83,7 +84,7 @@
<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>
<p class="text-xs text-slate-500">For Full Financial Year, the job processes April through March and reports progress month by month. The custom protocol contains only a short-lived signed job token. GST username/password are redeemed directly by the agent and are never placed in this page's HTML or protocol 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">
@@ -106,10 +107,11 @@
</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>
<h2 class="font-semibold">Interactive GST Download</h2>
<div id="gst-job-stage" class="text-sm font-medium"></div>
<div id="gst-job-message" class="text-sm text-slate-600"></div>
<a id="gst-agent-open" href="#" class="hidden inline-block rounded bg-indigo-600 px-4 py-2 text-sm text-white">Open GST Operator Agent</a>
<p class="text-xs text-slate-500">If Chrome/Edge asks whether to open ARRR GST Operator Agent, choose Open/Allow. The GST browser then opens separately and the operator completes CAPTCHA/OTP there.</p>
</div>
<form method="post" action="/tools/accounting/gst-reconciliation/analyze" class="rounded-xl border bg-white p-4 space-y-3">
@@ -135,78 +137,23 @@
<script>
(() => {
const port = {{ operator_agent_port|int }};
const base = `https://localhost:${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 = 'Browser cannot reach local agent';
statusEl.className = 'font-semibold text-amber-700';
statusEl.title = e && e.message ? e.message : 'Local HTTPS connection failed';
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();
const openLink = document.getElementById('gst-agent-open');
{% 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('The GST Operator Agent is not reachable over local HTTPS. Install/update the agent using the button above, then refresh this page.');
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});
if (panel) panel.classList.remove('hidden');
if (stage) stage.textContent = 'Launching GST Operator Agent';
if (msg) msg.textContent = 'Windows is being asked to open the lightweight GST agent. If the browser shows an external-app confirmation, choose Open/Allow.';
if (openLink) {
openLink.href = prepared.launch_url;
openLink.classList.remove('hidden');
}
})();
// The protocol launch replaces the previous browser-to-localhost HTTPS fetch.
// A short delay lets the ERP page finish rendering before Chrome/Edge opens the registered Windows handler.
window.setTimeout(() => { window.location.href = prepared.launch_url; }, 250);
{% endif %}
})();
</script>