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.
388 lines
16 KiB
Python
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())
|