""" 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 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: ... "" ... , 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("<", "<") .replace(">", ">") .replace(""", '"') .replace("&", "&") ) 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 ` 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 `, 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 > , 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())