Workflow-App/Workflow/scripts/SARS Retun History Period.py
sjcoetzeesj-coder 11d0178ab1 feat: commit Workflow app source; move CIPC credentials to env
Adds the full Next.js app (app/, components/, lib/, scripts/, assets/,
docs/, Dockerfile, compose, configs) to version control.

Security:
- CIPC BizPortal customer-code logins (Login 1 + Login 3) now read
  CIPC_LOGIN1_CODE / CIPC_LOGIN1_PASSWORD / CIPC_LOGIN3_CODE /
  CIPC_LOGIN3_PASSWORD from .env.local instead of source.
- BizPortal personal login in apply-bizportal route reads
  BIZPORTAL_ID_NO / BIZPORTAL_PASSWORD.
- Added .env.example as the committed template.

.gitignore excludes .env*, node_modules, .next, logs, sqlite, Playwright
storage_state.json (live CIPC cookies), scratch dirs, client PDFs and
debug screenshots.
2026-10-18 15:21:45 +02:00

553 lines
21 KiB
Python

"""
SARS Retun History Period — extracts the EMP201 payment breakdown for one
or more periods, using agent-browser (Rust CLI on CDP) for the parts of
the flow that the cross-origin iframe blocks Playwright from doing.
Inputs:
--company Company name as stored in PocketBase (e.g. "RJ BULK SUPPLIES")
--return-type Return type (default EMP201). Aliases also accepted:
PTAX, IRP6, VAT, "Value Added Tax", ITR14, ITR12T, ITR12EI, ITR12.
--periods One period "202606" or a contiguous range "202603-202605".
Output (printed to stdout):
Company name
PAYE no
Payment Period <YYYYMM>
PAYE Payable
SDL Payable
UIF Payable
Total Payable
Payment Reference No.
... (one block per period; for ranges, blocks repeat)
Implementation notes:
* Login + Portfolio + Taxpayer + Returns History nav + period clicks
all driven via agent-browser subprocess.
* The agent-browser skill's `find` command and CSS-selector-based
`click` are unreliable on the SARS app (Playwright-only syntax or
selectors that don't match what SARS renders). The reliable path
is: take a `snapshot -i`, parse the @ref for the element we need,
then `fill @ref` / `click @ref`. This is what we do here.
* Password and Portfolio are looked up in PocketBase via the same
helpers SARS_login_Select_Company exposes.
"""
import argparse
import os
import re
import shutil
import subprocess
import sys
import time
from pathlib import Path
from typing import List, Optional
from xml.etree import ElementTree as ET
# Make the sibling frozen module importable so we can reuse find_company /
# determine_portfolio / fetch_dtc_password.
SCRIPTS_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = SCRIPTS_DIR.parent
if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))
# ── agent-browser executable discovery ─────────────────────────────
def _find_agent_browser_exe() -> str:
"""Locate the agent-browser executable.
Python's subprocess.run on Windows does NOT do PowerShell's PATHEXT
resolution, so passing ["agent-browser", ...] fails with
FileNotFoundError even though `agent-browser` works from a normal
PowerShell. We probe for the .cmd / .exe / .ps1 shims in PATH and
fall back to the standard npm global install path.
"""
for name in ("agent-browser.cmd", "agent-browser.exe",
"agent-browser.bat", "agent-browser"):
path = shutil.which(name)
if path:
return path
npm_dir = Path(os.path.expanduser("~")) / "AppData" / "Roaming" / "npm"
for name in ("agent-browser.cmd", "agent-browser.exe", "agent-browser.ps1"):
candidate = npm_dir / name
if candidate.exists():
return str(candidate)
raise RuntimeError(
"Could not find agent-browser. Tried: agent-browser.cmd, "
"agent-browser.exe, agent-browser.bat, agent-browser, and the "
"npm global install dir. Install with: npm i -g agent-browser"
)
AGENT_BROWSER_EXE = _find_agent_browser_exe()
def run_ab(*args, check=True):
"""Run an agent-browser command, return its stdout (stripped).
NOTE: do NOT use `capture_output=True` / `stdout=PIPE`. The
agent-browser .exe does TTY detection on launch; when stdio is
piped (no TTY) it enters an interactive wait and never returns.
Verified by direct comparison: capture_output=False -> 0.8s,
capture_output=True -> hangs indefinitely. We redirect stdout to a
temp file instead so the child still writes to a file (not a pipe)
while the parent can read the result back.
"""
import tempfile
tmp = tempfile.NamedTemporaryFile(
mode="w", delete=False, encoding="utf-8", errors="replace",
prefix="ab_", suffix=".out",
)
tmp_path = tmp.name
tmp.close()
try:
result = subprocess.run(
[AGENT_BROWSER_EXE, *args],
stdout=open(tmp_path, "w", encoding="utf-8", errors="replace"),
stderr=subprocess.DEVNULL,
)
with open(tmp_path, "r", encoding="utf-8", errors="replace") as f:
out = f.read().strip()
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
if check and result.returncode != 0:
raise RuntimeError(
f"agent-browser {args!r} failed (rc={result.returncode}): {out}"
)
return out
# ── Snapshot parsing ──────────────────────────────────────────────
def snapshot() -> str:
"""Return the current accessibility-tree snapshot (interactive only)."""
return run_ab("snapshot", "-i")
def find_ref(snap: str, role: str, name: Optional[str] = None) -> Optional[str]:
"""Find the @ref for an element in the snapshot.
`role` is the accessibility role (e.g. "textbox", "button", "link").
`name` is the accessible name to match (e.g. "Username", "Next").
Returns the @ref string (e.g. "e9") or None if not found.
The snapshot format is:
- textbox "Username" [required, ref=e9]
- button "Next" [ref=e7]
- heading "Welcome" [level=1, ref=e3]
Note: the `ref` is the LAST attribute inside the `[...]` block, not
the whole block. So we match `,\\s*ref=eN]` (or `ref=eN]` at the
start of an empty attrs list).
"""
if name is None:
pattern = rf"\b{re.escape(role)}\b[^\n]*?[,]?\s*ref=(e\d+)\]"
else:
# Match: <role> ... "<name>" ... , ref=eN]
pattern = (
rf"\b{re.escape(role)}\b[^\n]*?['\"]({re.escape(name)})['\"][^\n]*?"
rf"[,]?\s*ref=(e\d+)\]"
)
m = re.search(pattern, snap, re.MULTILINE)
if m:
return m.group(2) if m.lastindex and m.lastindex >= 2 else m.group(1)
return None
def click_text(target: str) -> None:
"""Click a link or button whose accessible name is `target`."""
snap = snapshot()
# Try link first, then button
ref = find_ref(snap, "link", target) or find_ref(snap, "button", target)
if not ref:
raise RuntimeError(f"Could not find clickable element with text {target!r}")
run_ab("click", f"@{ref}")
# ── Period parsing ────────────────────────────────────────────────
def expand_periods(spec: str) -> List[str]:
"""Expand a period spec into a list of YYYYMM strings.
"202606" -> ["202606"]
"202603-202605" -> ["202603", "202604", "202605"]
Handles year wrap (e.g. "202611-202602" -> 202611, 202612, 202601, 202602).
"""
spec = spec.strip()
if "-" in spec:
start, end = spec.split("-", 1)
start, end = start.strip(), end.strip()
if (len(start) != 6 or len(end) != 6
or not start.isdigit() or not end.isdigit()):
raise ValueError(f"Invalid period range: {spec!r}")
si, ei = int(start), int(end)
if si > ei:
raise ValueError(f"Start period {start} is after end period {end}")
out = []
i = si
while i <= ei:
out.append(f"{i:06d}")
y, m = divmod(i, 100)
m += 1
if m > 12:
m = 1
y += 1
i = y * 100 + m
return out
if len(spec) != 6 or not spec.isdigit():
raise ValueError(f"Invalid period: {spec!r}")
return [spec]
# ── PocketBase lookups (reused from the frozen module) ─────────────
def fetch_company_and_portfolio(company: str):
from SARS_login_Select_Company import find_company, determine_portfolio
record = find_company(company)
name = record.get("enterprise_name") or company
portfolio = determine_portfolio(record)
return name, portfolio
def fetch_dtc_password() -> str:
from SARS_login_Select_Company import fetch_dtc_password as _fetch
return _fetch()
# ── Return-type alias resolution ──────────────────────────────────
SUBITEM_LABELS = {
# NB: the agent-browser snapshot output renders the page's curly
# apostrophe (U+2019) as an ASCII one (U+0027). Match against what
# the snapshot actually shows, not the page's DOM.
"EMP201": "Employee's Tax (EMP201)",
"EMP501": "Employee s Tax (EMP501)", # NB: no apostrophe at all here
"Income Tax": "Income Tax (ITR14/ITR12T/IT12EI)",
"Provisional Tax": "Provisional Tax (IRP6)",
"VAT201": "Value Added Tax (VAT201)",
}
ALIASES = {
"EMP201": "EMP201", "EMP 201": "EMP201",
"EMP501": "EMP501", "EMP 501": "EMP501",
"INCOME TAX": "Income Tax",
"ITR14": "Income Tax", "ITR12T": "Income Tax",
"ITR12EI": "Income Tax", "ITR12": "Income Tax",
"PTAX": "Provisional Tax", "IRP6": "Provisional Tax",
"PROVISIONAL TAX": "Provisional Tax",
"VAT": "VAT201", "VAT201": "VAT201", "VALUE ADDED TAX": "VAT201",
}
def resolve_return_type(user_input: str) -> str:
raw = user_input.strip()
if raw in SUBITEM_LABELS:
return raw
up = raw.upper()
if up in SUBITEM_LABELS:
return up
if up in ALIASES:
return ALIASES[up]
raise ValueError(
f"Unknown return type {user_input!r}. "
f"Valid canonical names: {sorted(SUBITEM_LABELS.keys())}. "
f"Aliases: PTAX, IRP6, VAT, 'Value Added Tax', ITR14, ITR12T, ITR12EI, ITR12."
)
# ── InputXML parser ──────────────────────────────────────────────
def parse_input_xml(xml_str: str) -> dict:
"""Extract the 8 fields the user wants from the EMP201 InputXML string."""
xml_str = (
xml_str.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", '"')
.replace("&amp;", "&")
)
root = ET.fromstring(xml_str)
employer = root.find("EMPLOYER_DETAILS")
payment = root.find("PAYMENT_DETAILS")
alloc = payment.find("PAYMENT_ALLOCATION") if payment is not None else None
def txt(parent, tag):
if parent is None:
return ""
el = parent.find(tag)
return (el.text or "").strip() if el is not None else ""
return {
"company_name": txt(employer, "TRADING_NAME"),
"paye_no": txt(employer, "PAYE_REF_NO"),
"payment_period": txt(alloc, "PAYMENT_PERIOD"),
"paye_payable": txt(alloc, "PAYE_AMT"),
"sdl_payable": txt(alloc, "SDL_AMT"),
"uif_payable": txt(alloc, "UIF_AMT"),
"total_payable": txt(payment, "TOTAL_PAYMENT"),
"payment_ref_no": txt(payment, "PAYMENT_REF_NO"),
}
# ── agent-browser flow ───────────────────────────────────────────
def login(username: str, password: str) -> None:
"""Log in to SARS eFiling via agent-browser using snapshot + refs."""
print(" [login] close --all ...", flush=True)
run_ab("close", "--all")
# NOTE: `open <url>` waits for network-idle, which the SARS login page
# never reaches (its AI assistant chat region keeps a WebSocket open
# forever). We split into `open` (no URL, instant launch on about:blank)
# + `navigate <url>`, which returns as soon as the page is interactive.
print(" [login] open (no url) ...", flush=True)
run_ab("open")
print(" [login] navigate to login page ...", flush=True)
run_ab("navigate", "https://secure.sarsefiling.co.za/app/login")
print(" [login] waiting 3.5s for page to render ...", flush=True)
run_ab("wait", "3500")
# Username + Next
print(" [login] snapshot for username page ...", flush=True)
snap = snapshot()
user_ref = find_ref(snap, "textbox", "Username")
if not user_ref:
raise RuntimeError("Username textbox not found in snapshot")
print(f" [login] fill username @e{user_ref} ...", flush=True)
run_ab("fill", f"@{user_ref}", username)
next_ref = find_ref(snap, "button", "Next")
if not next_ref:
raise RuntimeError("Next button not found in snapshot")
print(f" [login] click Next @e{next_ref} ...", flush=True)
run_ab("click", f"@{next_ref}")
print(" [login] waiting 3s for password page ...", flush=True)
run_ab("wait", "3000")
# Password + Login
print(" [login] snapshot for password page ...", flush=True)
snap = snapshot()
pw_ref = find_ref(snap, "textbox", "Password")
if not pw_ref:
raise RuntimeError("Password textbox not found in snapshot")
print(f" [login] fill password @e{pw_ref} ...", flush=True)
run_ab("fill", f"@{pw_ref}", password)
login_ref = find_ref(snap, "button", "Login")
if not login_ref:
raise RuntimeError("Login button not found in snapshot")
print(f" [login] click Login @e{login_ref} ...", flush=True)
run_ab("click", f"@{login_ref}")
print(" [login] waiting 4s for dashboard ...", flush=True)
run_ab("wait", "4000")
print(" [login] done.", flush=True)
def select_portfolio_taxpayer(portfolio: str, taxpayer: str) -> None:
"""Click the Portfolio combobox, pick `portfolio`, then Taxpayer, pick `taxpayer`."""
# The dashboard uses Angular Material mat-select. We open the panel
# by clicking the combobox trigger, then pick the option by text via
# a JavaScript eval (more reliable than find-by-text on virtual-
# scrolled lists).
# NOTE: each eval runs in the same global scope, so `const`/`let`
# identifiers cannot be redeclared. Use unique names (optsA, optsB)
# per call.
print(" [dashboard] select Portfolio ...", flush=True)
run_ab("eval", "document.querySelector('mat-select[name=\"taxuser\"]').click()")
run_ab("wait", "1500")
run_ab("eval", f"""
const optsA = document.querySelectorAll('.cdk-overlay-pane mat-option');
for (const o of optsA) {{
if (o.textContent.trim() === {portfolio!r}) {{ o.click(); break; }}
}}
""")
run_ab("wait", "2500")
print(" [dashboard] select Taxpayer ...", flush=True)
run_ab("eval", "document.querySelector('mat-select[name=\"TaxPayer\"]').click()")
run_ab("wait", "1500")
run_ab("eval", f"""
const optsB = document.querySelectorAll('.cdk-overlay-pane mat-option');
for (const o of optsB) {{
if (o.textContent.trim() === {taxpayer!r}) {{ o.click(); break; }}
}}
""")
run_ab("wait", "2500")
print(" [dashboard] done.", flush=True)
def navigate_to_returns_history_subitem(return_type: str) -> None:
"""Click Returns History parent, then the sub-item for the return type."""
canonical = resolve_return_type(return_type)
label = SUBITEM_LABELS[canonical]
# Parent: link with exact text "Returns History"
click_text("Returns History")
run_ab("wait", "2500")
# Sub-item: link with exact text == subitem label
click_text(label)
run_ab("wait", "3500")
def open_period_and_extract(period: str) -> Optional[dict]:
"""Click Open for `period`, click the return-type link in the work page,
extract InputXML, and return the parsed fields dict (or None on failure)."""
# 1. Click Open for the period. The Open link has no text but its
# href contains Period=YYYYMM, so we use JS to find and click.
run_ab("eval", f"""
const links = document.querySelectorAll('a');
for (const a of links) {{
if ((a.getAttribute('href') || '').includes('Period={period}')) {{
a.click();
break;
}}
}}
""")
run_ab("wait", "3500")
# 2. Click the return-type link inside the work page's small table row.
# The work page is inside a cross-origin iframe; agent-browser
# handles that via CDP, so the click reaches the right frame.
run_ab("eval", """
const rows = document.querySelectorAll('table tr');
for (const row of rows) {
const link = row.querySelector('a');
if (link && row.textContent.includes('EMP201')) {
link.click();
break;
}
}
""")
run_ab("wait", "3500")
# 3. Read the hidden InputXML the form embeds.
xml = run_ab(
"eval",
"(document.querySelector('input[name=\"InputXML\"]') || {}).value || ''",
)
if not xml:
return None
return parse_input_xml(xml)
def go_back_to_period_list() -> None:
"""Navigate back from the work page to the period list."""
run_ab("eval", "history.back()")
run_ab("wait", "3500")
# ── Report formatting ─────────────────────────────────────────────
def print_report(periods_with_data: List[tuple]) -> None:
"""Print the report in the user's requested format."""
first_data = next((d for _, d in periods_with_data if d is not None), {})
print()
print("=" * 60)
print("REPORT")
print("=" * 60)
print(f"Company name {first_data.get('company_name', '')}")
print(f"PAYE no {first_data.get('paye_no', '')}")
print()
for period, data in periods_with_data:
if data is None:
print(f"--- Period {period} ---")
print(" (no data — Open link or work page not found)")
print()
continue
print(f"--- Payment Period {data.get('payment_period') or period} ---")
print(f"PAYE Payable {data.get('paye_payable', '')}")
print(f"SDL Payable {data.get('sdl_payable', '')}")
print(f"UIF Payable {data.get('uif_payable', '')}")
print(f"Total Payable {data.get('total_payable', '')}")
print(f"Payment Reference No. {data.get('payment_ref_no', '')}")
print()
# ── CLI ───────────────────────────────────────────────────────────
def main() -> int:
ap = argparse.ArgumentParser(
description=(
"Log into SARS eFiling as Dynamic Tax Consultants, navigate "
"Returns History > <return type>, and extract the payment "
"breakdown for one or more periods."
)
)
ap.add_argument("--company", required=True,
help="Company name (matched in PocketBase enterprise_name).")
ap.add_argument("--return-type", default="EMP201",
help="Return type (default EMP201). Aliases accepted: "
"PTAX, IRP6, VAT, 'Value Added Tax', ITR14, ITR12T, "
"ITR12EI, ITR12, 'Income tax', 'Provisional tax'.")
ap.add_argument("--periods", required=True,
help='One period "202606" or a contiguous range "202603-202605".')
args = ap.parse_args()
# --- Validate and resolve inputs ---
try:
periods = expand_periods(args.periods)
except ValueError as e:
print(f"Error: {e}")
return 2
try:
canonical_type = resolve_return_type(args.return_type)
except ValueError as e:
print(f"Error: {e}")
return 2
# --- Look up company + DTC password ---
try:
company_name, portfolio = fetch_company_and_portfolio(args.company)
except Exception as e:
print(f"Error looking up company in PocketBase: {e}")
return 1
try:
password = fetch_dtc_password()
except Exception as e:
print(f"Error fetching DTC password from PocketBase: {e}")
return 1
print(f"Company: {company_name}")
print(f"Portfolio: {portfolio}")
print(f"Taxpayer: {company_name}")
print(f"Return: {canonical_type} (sidebar: {SUBITEM_LABELS[canonical_type]!r})")
print(f"Periods: {periods}")
print()
# --- Drive the flow via agent-browser ---
try:
login("sjcoetzee1092", password)
select_portfolio_taxpayer(portfolio, company_name)
navigate_to_returns_history_subitem(canonical_type)
except Exception as e:
print(f"Error during navigation: {e}")
run_ab("close", "--all", check=False)
return 1
# --- Extract one period at a time ---
results: List[tuple] = []
for i, period in enumerate(periods):
print(f"[{i+1}/{len(periods)}] Extracting period {period} ...", flush=True)
try:
data = open_period_and_extract(period)
except Exception as e:
print(f" Error: {e}", flush=True)
data = None
results.append((period, data))
if i < len(periods) - 1:
try:
go_back_to_period_list()
except Exception as e:
print(f" Warning: could not go back to period list: {e}",
flush=True)
# --- Close the browser ---
run_ab("close", "--all", check=False)
# --- Report ---
print_report(results)
return 0
if __name__ == "__main__":
sys.exit(main())