# ───────────────────────────────────────────────────────────────── # 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 toggle handler — in the new SARS layout that component catches the click on a child and only toggles the submenu instead of routing it. `click(force=True)` dispatches the click directly on the 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 # 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 ), # whose click doesn't toggle the submenu reliably. The CSS # locator targets the 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 '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 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 # 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 > 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())