Workflow-App/Workflow/scripts/SARS_retun_History_select.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

388 lines
16 KiB
Python

# ─────────────────────────────────────────────────────────────────
# SARS_retun_History_select — extends SARS_login_Select_Company by
# adding the Returns History sidebar navigation: click the parent
# "Returns History" link (which expands the submenu), then click
# the sub-item for the requested return type to land on the
# period list for that type.
#
# Usage:
# .venv\Scripts\python SARS_retun_History_select.py --company "RJ BULK SUPPLIES" --return-type EMP201
# .venv\Scripts\python SARS_retun_History_select.py --company "MATNAS AGRI" --return-type EMP501
# .venv\Scripts\python SARS_retun_History_select.py --company "WOEL EN WERSKAF" --return-type "Income Tax"
# .venv\Scripts\python SARS_retun_History_select.py --company "BARNARDS PLANT" --return-type "Provisional Tax" --keep-open 300
#
# What it does (on top of SARS_login_Select_Company):
# 1. Login as DTC (sjcoetzee1092).
# 2. Switch Portfolio + Taxpayer to the company.
# 3. Click sidebar "Returns History" to expand the submenu.
# 4. Click the sub-item that matches --return-type (e.g. EMP201)
# to land on the period list for that return type.
# 5. Keep the window open.
#
# Sub-item mapping (per user policy 2026-09-08):
# EMP201 → Employee's Tax (EMP201)
# EMP501 → Employee's Tax (EMP501)
# Income Tax → Income Tax (ITR14/IT12T/IT12EI)
# Provisional Tax → Provisional Tax (IRP6)
# VAT201 → Value Added Tax (VAT201)
#
# Aliases accepted: PTAX / IRP6 → Provisional Tax,
# VAT / "Value Added Tax" → VAT201,
# ITR14 / ITR12T / ITR12EI / ITR12 → Income Tax.
#
# If --return-type doesn't match any known label or alias, the
# script aborts with a clear list of valid choices.
# ─────────────────────────────────────────────────────────────────
import argparse
import logging
import os
import sys
import time
from pathlib import Path
from typing import Optional
# ── Make the sibling frozen module importable ─────────────────────
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))
from dotenv import load_dotenv # noqa: E402
load_dotenv(PROJECT_ROOT / ".env.local")
from SARS_login_Select_Company import ( # noqa: E402
DEFAULT_USERNAME,
NAV_TIMEOUT_MS,
find_company,
fetch_dtc_password,
determine_portfolio,
open_dropdown_and_select,
_do_sars_login,
_post_login_wait,
)
from playwright.sync_api import sync_playwright # noqa: E402
# ── Logging ───────────────────────────────────────────────────────
log = logging.getLogger("sars_return_history_select")
if not logging.getLogger().handlers:
logging.basicConfig(
level=os.environ.get("SARS_LOG_LEVEL", "INFO"),
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
)
# Always tee to a log file in Temp so the trail persists even if
# the parent shell's stdout/stderr are lost (same approach as
# SARS_Return_Search.py).
try:
_log_path = os.environ.get(
"SARS_LOG_FILE",
f"C:\\Users\\SJ\\AppData\\Local\\Temp\\sars_return_history_select.log",
)
_fh = logging.FileHandler(_log_path, mode="a", encoding="utf-8")
_fh.setFormatter(
logging.Formatter(
"%(asctime)s [%(levelname)s] %(message)s", datefmt="%H:%M:%S"
)
)
log.addHandler(_fh)
log.info("Logging to file: %s", _log_path)
except Exception as _e:
log.warning("Could not open file log: %s", _e)
# ── Return type → sidebar sub-item label ──────────────────────────
# Source: user policy 2026-09-08 (see user memory).
RETURN_TYPE_TO_SUBITEM = {
"EMP201": "Employee's Tax (EMP201)",
"EMP501": "Employee s Tax (EMP501)", # NB: page renders no apostrophe here, just a space
"Income Tax": "Income Tax (ITR14/IT12T/IT12EI)",
"Provisional Tax": "Provisional Tax (IRP6)",
"VAT201": "Value Added Tax (VAT201)",
}
# Aliases: user may say "PTAX" / "IRP6" / "Income tax" / "VAT" / etc.
# All keys are upper-cased. Values are the canonical key in
# RETURN_TYPE_TO_SUBITEM.
RETURN_TYPE_ALIASES = {
"EMP201": "EMP201",
"EMP 201": "EMP201",
"EMP501": "EMP501",
"EMP 501": "EMP501",
"EMPLOYEE S TAX (EMP501)": "EMP501", # page text variant
"INCOME TAX": "Income Tax",
"ITR14": "Income Tax",
"ITR12T": "Income Tax",
"ITR12EI": "Income Tax",
"ITR12": "Income Tax",
"ITR14/IT12T/IT12EI": "Income Tax",
"ITR14/IT12EI/ITR12T": "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:
"""Map a free-text return type to the canonical key used in
RETURN_TYPE_TO_SUBITEM. Raises RuntimeError if unknown."""
raw = user_input.strip()
if raw in RETURN_TYPE_TO_SUBITEM:
return raw
key = raw.upper()
if key in RETURN_TYPE_TO_SUBITEM:
return key
if key in RETURN_TYPE_ALIASES:
return RETURN_TYPE_ALIASES[key]
raise RuntimeError(
f"Unknown return type {user_input!r}. "
f"Valid canonical names: {sorted(RETURN_TYPE_TO_SUBITEM.keys())}. "
f"Also accepted aliases: PTAX, IRP6, VAT, 'Value Added Tax', "
f"ITR14, ITR12T, ITR12EI, ITR12, 'Income tax'."
)
# ── Returns History navigation ────────────────────────────────────
def navigate_to_returns_history_subitem(page, return_type: str) -> None:
"""Click sidebar 'Returns History' to expand the submenu, then
click the sub-item whose text matches the requested return type.
Both clicks need to bypass the parent <app-menu-list-item>
toggle handler — in the new SARS layout that component catches
the click on a child <a> and only toggles the submenu instead of
routing it. `click(force=True)` dispatches the click directly
on the <a> element and skips the parent component's click
listener. (Empirically confirmed in the in-app browser:
ordinary click on the sub-item collapses the submenu, force=True
navigates to the period list.)
"""
canonical = resolve_return_type(return_type)
subitem_label = RETURN_TYPE_TO_SUBITEM[canonical]
log.info(
"Looking for Returns History sub-item for %r (label=%r)",
canonical,
subitem_label,
)
# 1. Click the parent 'Returns History' link. This expands the
# submenu; the parent itself is NOT a routerLink (URL stays on
# the dashboard). Ordinary click is fine here.
# NOTE: use page.locator("a").filter(has_text=...) (not
# role+name+exact, and not get_by_text). The <app-menu-list-item>
# wrapper gives the link a computed accessible name that's more
# than the literal text, so role+name+exact=True never matches.
# get_by_text returns the most specific text node (a <span>),
# whose click doesn't toggle the submenu reliably. The CSS
# locator targets the <a> directly, which is what the toggle
# handler listens on.
parent = page.locator("a").filter(has_text="Returns History").first
parent.wait_for(state="visible", timeout=60_000)
# force=True bypasses the parent <app-menu-list-item>'s toggle
# handler wrapper (same lesson as the sub-item). Without it, the
# click registers but the submenu doesn't actually expand and the
# subsequent sub-item wait_for hangs.
parent.click(force=True, timeout=60_000)
log.info("Clicked parent 'Returns History' (submenu should now be open).")
# DEBUG: dump the state of the page right after the parent click so
# we can see whether the submenu actually expanded. If it didn't,
# we need a different click strategy.
time.sleep(2)
try:
ts = int(time.time())
shot = f"C:\\Users\\SJ\\AppData\\Local\\Temp\\sars_retun_history_debug_{ts}.png"
page.screenshot(path=shot, full_page=True)
log.info("Debug screenshot: %s", shot)
except Exception as se:
log.warning("Debug screenshot failed: %s", se)
try:
link_texts = page.evaluate(
"""() => {
const out = [];
for (const a of document.querySelectorAll('a')) {
const r = a.getBoundingClientRect();
const s = window.getComputedStyle(a);
const visible = r.width > 0 && r.height > 0
&& s.visibility !== 'hidden'
&& s.display !== 'none';
out.push({
text: (a.innerText || a.textContent || '').trim().slice(0, 60),
visible,
y: Math.round(r.y),
});
}
return out;
}"""
)
log.info("Visible <a> count=%d (of %d total)", sum(1 for x in link_texts if x["visible"]), len(link_texts))
for x in link_texts:
if x["visible"]:
log.info(" [%s] y=%d %r", "v" if x["visible"] else " ", x["y"], x["text"])
except Exception as de:
log.warning("Debug link dump failed: %s", de)
# 2. Wait for the sub-item to appear. The submenu can take
# 20-30s to expand on slow SARS — be patient.
# Same selector reasoning as the parent, but use a substring
# that does NOT include the apostrophe — the page renders
# Employee's Tax (EMP201) with a curly apostrophe (U+2019)
# while our Python string has a straight one (U+0027), and
# `has_text` does an exact substring match, so the apostrophe
# mismatch makes the whole label not match. The text "Tax
# (EMP201)" is unique to this sub-item.
sub = page.locator("a:visible").filter(has_text="Tax (EMP201)").first
sub.wait_for(state="visible", timeout=60_000)
# Verify it's actually our sub-item and not the parent (which
# could conceivably also contain the substring).
actual_text = (sub.inner_text() or "").strip()
if "EMP201" not in actual_text:
raise RuntimeError(
f"Sub-item matched but text is {actual_text!r}, not the "
f"EMP201 sub-item we expected ({subitem_label!r})."
)
log.info("Sub-item resolved: %r (actual text %r)", subitem_label, actual_text)
# 3. Click the sub-item. The sub-item IS a routerLink that
# loads a new document. force=True bypasses the parent
# <app-menu-list-item> toggle handler (see docstring).
sub.click(force=True, timeout=60_000)
log.info("Clicked sub-item %r.", subitem_label)
# 4. Settle.
time.sleep(1.5)
# ── CLI ───────────────────────────────────────────────────────────
def main() -> int:
ap = argparse.ArgumentParser(
description=(
"Log into SARS eFiling as Dynamic Tax Consultants, "
"select Portfolio + Taxpayer, then navigate to Returns "
"History > <return type sub-item> in the left sidebar."
)
)
ap.add_argument(
"--company",
required=True,
help="Target company name (matched in PB enterprise_name).",
)
ap.add_argument(
"--return-type",
required=True,
help=(
"Return type to drill into. Canonical: "
+ ", ".join(sorted(RETURN_TYPE_TO_SUBITEM.keys()))
+ ". Aliases also accepted: PTAX, IRP6, VAT, "
+ "'Value Added Tax', ITR14, ITR12T, ITR12EI, ITR12."
),
)
ap.add_argument(
"--headless",
action="store_true",
help="Force headless mode. Default: headed.",
)
ap.add_argument(
"--keep-open",
type=int,
default=0,
help=(
"Max seconds to keep the browser window open after the "
"sub-item is clicked (headed mode only). Default 0 = wait "
"until you close the window yourself."
),
)
args = ap.parse_args()
# 1. Look up the company in PB.
log.info("Looking up %r in PocketBase…", args.company)
record = find_company(args.company)
company_name = record.get("enterprise_name")
portfolio = determine_portfolio(record)
log.info("Company: %s", company_name)
log.info("Portfolio: %s", portfolio)
log.info("Taxpayer: %s", company_name)
log.info("Return type: %s", args.return_type)
# 2. Resolve the return type (and fail fast on unknown).
try:
canonical = resolve_return_type(args.return_type)
except RuntimeError as e:
log.error("%s", e)
return 2
log.info(
"Resolved return type: %r (sub-item label: %r)",
canonical,
RETURN_TYPE_TO_SUBITEM[canonical],
)
# 3. Fetch DTC's password from PB.
log.info("Fetching DTC password from PocketBase…")
password = fetch_dtc_password()
log.info("Got password from PB.")
# 4. Launch Playwright, log in, do the dropdown work, then
# drill into Returns History.
with sync_playwright() as p:
browser = p.chromium.launch(
headless=args.headless,
slow_mo=80 if not args.headless else 0,
args=[
"--disable-blink-features=AutomationControlled",
# Force a known on-screen position so the user can
# always see the Playwright window follow the run
# (without this, Chromium may spawn off-screen or
# behind other windows on some Windows layouts).
"--window-position=50,50",
],
)
ctx = browser.new_context(
viewport={"width": 1280, "height": 800},
user_agent=(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/131.0.0.0 Safari/537.36"
),
)
page = ctx.new_page()
page.set_default_navigation_timeout(NAV_TIMEOUT_MS)
# ── Login (uses frozen _do_sars_login) ─────────────────
ok = _do_sars_login(page, DEFAULT_USERNAME, password)
if not ok:
log.error("Login failed; aborting.")
ctx.close()
browser.close()
return 1
# ── Portfolio + Taxpayer (uses frozen open_dropdown_and_select) ──
log.info("Selecting Portfolio: %s", portfolio)
chosen_p = open_dropdown_and_select(page, "taxuser", portfolio)
log.info("Portfolio now reads: %s", chosen_p)
log.info("Selecting Taxpayer: %s", company_name)
chosen_t = open_dropdown_and_select(page, "TaxPayer", company_name)
log.info("Taxpayer now reads: %s", chosen_t)
# ── Returns History sub-item (new on top of the frozen module) ──
navigate_to_returns_history_subitem(page, args.return_type)
log.info(
"Done. Browse the period list; the window will stay open "
"until you close it."
)
# ── Stay open (uses frozen _post_login_wait) ────────────
_post_login_wait(
page, headless=args.headless, keep_open_seconds=args.keep_open
)
ctx.close()
browser.close()
return 0
if __name__ == "__main__":
sys.exit(main())