Initial import: eCW Charge Validation bot and results Portal

Snapshot of the working project as of 2026-10-23 (local 2d4d4fc), one
commit with no earlier history: the tracked files were de-identified
first (synthetic claim ids and patient data only; see Context.md
section 7). Credentials, run logs, screenshots, claim reports and client
workbooks are gitignored and not included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ManikandanandClaude Opus 5.5 committed 2026-10-23 18:38:17 +05:30
commit ba15a574ab
205 files changed
+56772

No files matched your search

+760
View File
@@ -0,0 +1,760 @@
"""
Salud eCW Charge Entry Bot — settings.
Single-client build. Everything that changes between environments lives in
`.env` at the project root (never committed). No credentials in code, and no
credentials in a shared spreadsheet the way the multi-client NAHMS bot did it.
Read order for every setting:
1. real process environment variable (wins — good for CI / scheduled runs)
2. .env file at the project root
3. the default in this file
**Selectors do not live here.** They used to, and a 442-line module mixing DOM
ids with operational settings meant changing a date range and changing a
selector were edits to the same file. They are in `selectors\\` now; this
module holds only values a person would want to change without touching code.
"""
import os
import re
import sys
from pathlib import Path
from datetime import datetime
from .selectors.claim_lookup import DATE_TYPE_CLAIM
# ── Project roots ─────────────────────────────────────────────────────────────
# Three parents, not one: this file is src\ecw_bot\settings.py, so
# settings.py → ecw_bot → src → the project root that holds .env and Logs\.
if getattr(sys, "frozen", False):
ROOT = Path(sys.executable).parent
else:
ROOT = Path(__file__).resolve().parent.parent.parent
ENV_PATH = ROOT / ".env"
TODAY = datetime.now().strftime("%m-%d-%Y")
SCREENSHOTS = ROOT / "Screenshots" / TODAY
LOGS = ROOT / "Logs"
DOWNLOADS_TMP = ROOT / "Input" / "_pw_downloads"
# ── .env loader ───────────────────────────────────────────────────────────────
# Deliberately dependency-free: these machines are locked down and adding
# python-dotenv is one more thing to get approved. The format is the usual
# KEY=value, one per line, '#' comments, optional surrounding quotes.
#
# Inline comments: an UNQUOTED value ends at the first whitespace followed by
# '#', so `SAVE_CORRECTIONS=0 # verification` reads as "0". Before 2026-10-05
# the whole tail was kept, "0 # verification" was not a recognised yes/no
# word, and the setting fell back to its code default — which for
# SAVE_CORRECTIONS was ON. A comment meant to document "saving is off" turned
# saving on, silently (review item P1). `CLAIM_PROCESS_LIMIT=5 # demo` read as
# the default 1 the same way.
#
# A '#' with NO whitespace before it stays part of the value: real values here
# contain one — the landing URL's "#/..." fragment, and a mailbox password —
# and neither has a space in front of it.
#
# **Quote a value that needs ` #`** (space then hash) inside it:
# `KEY="a # b"` reads as `a # b`. For a quoted value the text between the
# quotes is the value and anything after the closing quote (` # note`) is
# dropped. A value that is quoted end to end with nothing after it reads
# exactly as it always did, inner quotes included (`"pa"ss"` → `pa"ss`,
# `"Ab"#12"` → `Ab"#12`): after a closing quote, as everywhere else, a '#'
# starts a comment only when whitespace comes before it. The one shape that
# cannot be told apart from a trailing comment is an inner quote of the SAME
# kind followed by whitespace and '#' (`"a" #b"` reads as `a`); write such a
# value in the other kind of quotes (`'a" #b'` reads as `a" #b`).
#
# Text after the closing quote that is NOT a comment (`"1"0`, `'1'x`) reads
# as the quoted part for a text setting, but stops the run (exit 2, before
# login) for a yes/no or whole-number one — see _QUOTE_TRAILING.
# Opening quote, the shortest text that still lets the rest of the line be
# nothing but an optional ` #` comment, then the same quote. Shortest, so a
# quote inside the comment cannot pull the comment into the value; "still lets
# the rest be a comment", so a quote inside the value does not cut it short.
# The comment needs whitespace before its '#' — the same rule as an unquoted
# value — so `"Ab"#12"` is one value, not `Ab` plus a comment (fix round 1 of
# the unit-A review: the first version cut a password there, silently).
_QUOTED_VALUE = re.compile(r"""^(["'])(.*?)\1(?:\s+#.*)?$""")
_INLINE_COMMENT = re.compile(r"\s+#")
# Keys whose .env value was quoted and then followed by text that is NOT a
# comment (`SAVE_CORRECTIONS="1"0`), as {key: the text after the '='}. Such
# a value still reads as the quoted part — a password written that way keeps
# working as it always did — but a yes/no or whole-number setting must never
# be guessed at, so `_env_bool` / `_env_int` record it as a bad value (exit 2
# before login) instead of reading `"1"0` as 1 = LIVE-WRITE. The same text
# unquoted (`10`, `1x`) was already refused (final review, 2026-10-05).
_QUOTE_TRAILING: dict = {}
def _parse_env_value(raw: str) -> tuple:
"""
`(value, trailing)` for one `KEY=value` line's text after the '='.
`trailing` is True when a quoted value has non-comment text after its
closing quote — read as the quoted part, but not a clean value.
"""
text = raw.strip()
if text[:1] in ("\"", "'"):
m = _QUOTED_VALUE.match(text)
if m:
return m.group(2), False
# Text after the closing quote that is not a comment (`"abc"xyz`):
# the quotes still say where the value ends.
end = text.find(text[0], 1)
if end != -1:
return text[1:end], True
# No closing quote at all: not a quoted value, so the quote character
# is just part of an unquoted one — what the loader always did.
# Cut on the raw text, not the stripped one, so `KEY= # nothing yet`
# (whitespace right after the '=') reads as blank rather than "# nothing".
return _INLINE_COMMENT.split(raw, maxsplit=1)[0].strip(), False
def _env_file_value(raw: str) -> str:
"""The value part of one `KEY=value` line, as written after the '='."""
return _parse_env_value(raw)[0]
# The keys this process took from .env, not from the real environment. A
# child process started from here — the results page's bot run,
# web/bot_runner.py — is given the environment WITHOUT them, so its own
# settings.py reads .env afresh: an .env edited after the web server started
# is seen, as a terminal run sees it (review, 2026-10-14). Inherited, the
# server's copy would win over the file through the setdefault below.
ENV_FILE_KEYS = set()
def _load_env_file(path: Path) -> None:
if not path.exists():
return
for line in path.read_text(encoding="utf-8-sig").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, raw = line.partition("=")
key = key.strip()
val, trailing = _parse_env_value(raw)
if trailing and key not in os.environ:
# Only when the file's value is the one used (real env wins).
_QUOTE_TRAILING[key] = raw.strip()
# Real environment wins over the file.
if key not in os.environ:
# As os.environ lists it: Windows stores keys upper-cased, and
# the child-env filter walks os.environ.
ENV_FILE_KEYS.add(key.upper() if os.name == "nt" else key)
os.environ.setdefault(key, val)
_load_env_file(ENV_PATH)
def _env(key: str, default: str = "") -> str:
return str(os.environ.get(key, default) or "").strip()
# Every yes/no or whole-number setting whose value could not be read, as
# (key, raw value, what it should have been). validate() turns each into a
# problem, so the run stops with exit 2 BEFORE login — where stopping costs
# nothing — instead of quietly running on the code default.
#
# Strict on purpose (P1, 2026-10-01 review): the old parsers fell back to the
# default for anything they did not recognise, so `SAVE_CORRECTIONS=O` (the
# letter O), a stray comment, or a typo all read as the default — which for
# SAVE_CORRECTIONS was "save to live eCW". A setting that controls writes must
# never be guessed at. Blank or unset is NOT recorded: that genuinely means
# "use the default".
_BAD_VALUES: list = []
def _record_bad(key: str, raw: str, expected: str) -> None:
if (key, raw, expected) not in _BAD_VALUES:
_BAD_VALUES.append((key, raw, expected))
def _env_int(key: str, default: int) -> int:
if key in _QUOTE_TRAILING:
_record_bad(key, _QUOTE_TRAILING[key], "a whole number")
return default
raw = _env(key)
if not raw:
return default
# isascii() too: "²" is a str.isdigit() digit that int() refuses, and a
# crash at import would hide the clear validate() message below.
if raw.isascii() and raw.isdigit():
return int(raw)
_record_bad(key, raw, "a whole number")
return default
def _env_bool(key: str, default: bool = False) -> bool:
if key in _QUOTE_TRAILING:
_record_bad(key, _QUOTE_TRAILING[key], "a valid yes/no value")
return default
raw = _env(key)
word = raw.lower()
if word in ("1", "true", "yes", "y", "on"):
return True
if word in ("0", "false", "no", "n", "off"):
return False
if raw:
_record_bad(key, raw, "a valid yes/no value")
return default
# ── eCW portal ────────────────────────────────────────────────────────────────
ECW_URL = _env("ECW_URL")
ECW_USERNAME = _env("ECW_USERNAME")
ECW_PASSWORD = _env("ECW_PASSWORD")
# Blank for this client — kept so a TOTP-enabled login needs no code change.
ECW_SECRET_KEY = _env("ECW_SECRET_KEY")
# Optional deep link to open once login is confirmed (the provider schedule
# page). eCW drops the URL fragment during the login redirect, so navigating
# again after login is the only reliable way to land on it.
ECW_LANDING_URL = _env("ECW_LANDING_URL", ECW_URL)
# ── OTP / two-factor ──────────────────────────────────────────────────────────
# auto — TOTP if ECW_SECRET_KEY is set, else mail if configured, else manual
# totp — authenticator secret only
# mail — read the challenge out of the mailbox. eCW sends either a 6-digit
# code or a "Push Notification" approval link; auth/mailbox.py detects
# which, so one mode covers both. (`imap` is an accepted old alias.)
# manual — pause with a message box and let a human handle it
OTP_MODE = _env("OTP_MODE", "auto").lower()
IMAP_HOST = _env("IMAP_HOST")
IMAP_PORT = _env_int("IMAP_PORT", 993)
IMAP_USERNAME = _env("IMAP_USERNAME")
IMAP_PASSWORD = _env("IMAP_PASSWORD")
IMAP_FOLDER = _env("IMAP_FOLDER", "INBOX")
IMAP_SSL = _env_bool("IMAP_SSL", True)
# How long to keep polling the mailbox for the OTP mail, and how often.
#
# Ten minutes. eCW's push mail is DELAYED, not dropped — proven by delivery
# order on 2026-09-02: the message eCW stamped 11:19:10 arrived in the mailbox
# *after* the one stamped 11:26:10, so roughly seven minutes late, and ten has
# been seen. A short window gives up while the mail is still in flight and the
# run then blames the mailbox. Quitting early is what costs a login attempt.
OTP_WAIT_SECONDS = _env_int("OTP_WAIT_SECONDS", 600)
OTP_POLL_SECONDS = _env_int("OTP_POLL_SECONDS", 5)
# A challenge older than this gets a warning in the log — it is NOT skipped.
#
# eCW's JWT is good for about ten minutes and the mail itself sometimes takes
# ten to arrive, so a link can land already near expiry. Refusing to click it
# then guarantees failure, while clicking it costs nothing that has not already
# been spent. So this only marks the age, giving the log a reason to point at
# when eCW answers "this verification link has expired" — which otherwise reads
# exactly like a wrong password.
OTP_LINK_AGE_WARN_SECONDS = _env_int("OTP_LINK_AGE_WARN_SECONDS", 540)
# eCW's push notification screen offers a "Resend OTP" button, and pressing it
# beats handing the step to a human.
#
# But resend LATE, not early — a full ten minutes of waiting first.
#
# Ten because that is the longest delivery delay actually observed: the mail is
# late rather than missing, so before ten minutes have passed "still in flight"
# is the likeliest explanation, and a second message would only join the same
# slow queue behind the first.
#
# One resend, so the schedule is 600s → Resend → 600s = 20 minutes before a
# human is asked. Raise OTP_RESEND_ATTEMPTS for more rounds, but note the two
# ceilings that are NOT ours to raise: eCW's JWT is good for about ten minutes,
# and its login page may expire before the wait does — auth/twofactor.py checks
# for that and stops with it as the stated reason.
OTP_RESEND_ATTEMPTS = _env_int("OTP_RESEND_ATTEMPTS", 1)
OTP_RESEND_WAIT_SECONDS = _env_int("OTP_RESEND_WAIT_SECONDS", 600)
# Ignore anything already sitting in the inbox from before the login attempt.
OTP_MAX_AGE_SECONDS = _env_int("OTP_MAX_AGE_SECONDS", 300)
# ── Proxy ─────────────────────────────────────────────────────────────────────
PROXY_IP = _env("PROXY_IP")
PROXY_PORT = _env("PROXY_PORT")
def proxy_settings():
"""Playwright proxy dict, or None when no usable proxy is configured."""
import re
if not (PROXY_IP and PROXY_PORT):
return None
ip = re.sub(r"^https?://", "", PROXY_IP).strip().rstrip("/")
port = PROXY_PORT.strip()
if not ip or not port.isdigit():
return None
return {"server": f"http://{ip}:{port}"}
# ── Attempt guard ─────────────────────────────────────────────────────────────
# eCW restricts an account that is retried too hard — seven attempts in
# forty-five minutes was enough, and afterwards it stopped mailing approval
# links while the page still claimed it had sent one. core/guard.py enforces
# these.
LOGIN_COOLDOWN_SECONDS = _env_int("LOGIN_COOLDOWN_SECONDS", 300) # 5 min
LOGIN_MAX_PER_HOUR = _env_int("LOGIN_MAX_PER_HOUR", 4)
# If a plain cooldown has no more than this left to run, the bot waits it out
# rather than exiting 3. Refusing a run over the last few seconds of a cooldown
# just moves the retrying into the operator's hands, which is worse: five of six
# consecutive runs died on this, one of them with 3s left. A restriction lockout
# and the per-hour burst limit are never waited out — those mean stop.
# 0 disables waiting and restores the old exit-immediately behaviour.
COOLDOWN_MAX_WAIT_SECONDS = _env_int("COOLDOWN_MAX_WAIT_SECONDS",
LOGIN_COOLDOWN_SECONDS)
# How many times to start the login over when eCW's 2FA screen times out.
#
# "Your log in request has timed out. Please try again" is a dead end: the page
# has stopped polling for the approval and the emailed link is spent, so only a
# fresh login helps. The browser is closed and the whole login runs again.
#
# Each retry goes through the guard above — cooldown waited out, burst limit
# still enforced — because an unguarded retry loop against this portal is
# exactly what earned the account its restriction. With the defaults that means
# a retry costs a 5-minute wait and one of the four hourly attempts, so 1 is
# deliberately modest: two shots at a factor that is delayed rather than
# broken. 0 turns the retry off and the run stops with exit 6.
LOGIN_RETRY_ON_TIMEOUT = _env_int("LOGIN_RETRY_ON_TIMEOUT", 1)
# ── Operator prompts ──────────────────────────────────────────────────────────
# Every message box times out, so an unattended run fails instead of holding a
# browser open indefinitely.
MSGBOX_TIMEOUT_SECONDS = _env_int("MSGBOX_TIMEOUT_SECONDS", 180)
# ── Disk space ────────────────────────────────────────────────────────────────
# Checked on the system drive before login (2026-10-22): with C: at 0 MB the
# browser died mid-login twice on 2026-10-21. Under WARN = a Warning on the
# Runs page; under STOP = the Run stops with exit 2, no login attempt spent.
DISK_WARN_MB = _env_int("DISK_WARN_MB", 2048)
DISK_STOP_MB = _env_int("DISK_STOP_MB", 500)
# ── Claim watchdog ────────────────────────────────────────────────────────────
# No progress on a claim for this many minutes = the page froze (2026-10-22:
# a run sat 28 minutes on one claim). The driver is closed and the run stops
# with exit 8, recorded. Never under a message box's timeout + 2 minutes.
# 0 = off. core/watchdog.py.
WATCHDOG_MINUTES = _env_int("WATCHDOG_MINUTES", 10)
# ── Browser ───────────────────────────────────────────────────────────────────
HEADLESS = _env_bool("HEADLESS", False)
CHROME_PATH = _env("CHROME_PATH") # blank → Playwright's bundled chromium
# Attach to a Chrome the operator started with start_ecw_chrome.bat instead of
# launching one, so the run can be watched live (Claude extension in that
# Chrome). Blank → launch as before. Loopback only: whatever reaches this port
# can drive a browser logged in to eCW. In this mode the proxy is Chrome's own
# (the .bat passes it), and eCW's cookies are cleared at the start and end of
# every run.
BROWSER_CDP_URL = _env("BROWSER_CDP_URL")
# The profile that Chrome uses — on D:, because C: running out of space killed
# the browser mid-login (2026-10-21).
CDP_PROFILE_DIR = _env("CDP_PROFILE_DIR", r"D:\EcwChrome")
# A Chrome the bot opened itself (none on the port at Start) is closed again
# at the end, so its debug port is not left open. A Chrome the operator
# opened is never closed. 0 = leave it open.
CDP_CLOSE_AFTER_RUN = _env_bool("CDP_CLOSE_AFTER_RUN", True)
def cdp_url_problem(url: str):
"""Why `url` is not a usable BROWSER_CDP_URL, or None if it is."""
from urllib.parse import urlsplit
try:
parts = urlsplit(url)
port = parts.port
except ValueError:
return f"BROWSER_CDP_URL={url!r} is not a URL"
if parts.scheme != "http" or not port:
return f"BROWSER_CDP_URL={url!r} must look like http://127.0.0.1:9222"
if parts.hostname not in ("127.0.0.1", "localhost"):
return (f"BROWSER_CDP_URL={url!r} must be on 127.0.0.1 — anything that "
f"reaches the port can drive the logged-in browser")
return None
# ── Timeouts (ms — Playwright's unit) ─────────────────────────────────────────
LOGIN_TIMEOUT = _env_int("LOGIN_TIMEOUT_MS", 120_000) # 2 min
NAV_TIMEOUT = _env_int("NAV_TIMEOUT_MS", 300_000) # 5 min
NAV_RETRIES = _env_int("NAV_RETRIES", 3) # proxy ERR_ABORTED retries
# Captcha is solved by a human via message box; two tries, then a fresh
# incognito browser, then one last try. Same escalation the NAHMS bot settled
# on after the OCR route proved unreliable.
CAPTCHA_ATTEMPTS = _env_int("CAPTCHA_ATTEMPTS", 2)
# ── Claim lookup: what to search for ──────────────────────────────────────────
# Selected by VALUE, with the label checked afterwards. The value is what eCW
# submits and the only unambiguous handle — the list holds "Medcor Ready"
# (108ME) and "Medcor Testing" (113ME), so a loose text match could pick the
# wrong queue and the search would still succeed. The label check then catches
# the opposite risk: eCW reassigning a code to a different status.
CLAIM_STATUS = _env("CLAIM_STATUS", "108ME")
CLAIM_STATUS_LABEL = _env("CLAIM_STATUS_LABEL", "Medcor Ready")
# Which date the search filters on. The process asks for Claim Dt(s).
#
# Be aware of what it pairs with: Claim Status "Encounters Without Claims"
# returns rows that have no claim, so they have no claim date and this returns
# nothing. Measured 2026-09-02, same 06/01/2020 → today range — 936 rows over
# 47 pages on Service Dt(s), 0 on Claim Dt(s). claim_filters._warn_if_dateless()
# says so in the log rather than leaving an empty grid to be puzzled over.
CLAIM_DATE_TYPE = _env("CLAIM_DATE_TYPE", DATE_TYPE_CLAIM)
# From is fixed by the process; To is "today" unless overridden. MM/DD/YYYY,
# matching the inputs' own placeholder and their idt-maxlength="10".
CLAIM_FROM_DATE = _env("CLAIM_FROM_DATE", "06/01/2020")
CLAIM_TO_DATE = _env("CLAIM_TO_DATE") # blank → today
# The Balance row arrives holding "> 0.0" with every control disabled, and eCW
# queries on the Angular model rather than the inputs — so the filter is live
# and unclickable. 1 clears it through the scope; 0 leaves it alone.
#
# The ng-scope / ng-binding classes in the DOM confirm Angular's debugInfo is
# enabled, which is what makes angular.element(el).scope() available. If eCW
# ever ships with it disabled this stops working, and it says so rather than
# reporting a clear that did not happen.
FORCE_CLEAR_BALANCE = _env_bool("FORCE_CLEAR_BALANCE", True)
# How long to wait for the results grid after Lookup is pressed.
# NOT wait_for_load_state("networkidle"): eCW's Angular shell keeps a heartbeat
# running, so the network never goes idle and that call sat silent for its whole
# timeout while the run looked frozen — every run was killed there, before the
# export step was ever reached. claim_results.py polls the grid's own
# "Total Counts" and says what it sees, so a slow query reads as slow.
LOOKUP_TIMEOUT = _env_int("LOOKUP_TIMEOUT_MS", 180_000) # 3 min
LOOKUP_POLL_SECONDS = _env_int("LOOKUP_POLL_SECONDS", 3)
# ── Export ────────────────────────────────────────────────────────────────────
CLAIM_REPORT_FORMAT = _env("CLAIM_REPORT_FORMAT", "EXCEL") # EXCEL | CSV | TEXT
# Where the exported report is saved: `Input\`, not `Output\`.
#
# It reads like output — the bot downloaded it — but it is the **input to the
# charge-entry phase**: the next step walks its claim ids one at a time. Naming
# it Output would invite someone to treat it as a finished deliverable and
# clear it out between runs, which would strand the phase that consumes it.
#
# Gitignored, like Output — it is a claims report, so it holds patient data.
INPUTS = ROOT / "Input"
# The saved file is `<CLAIM_REPORT_NAME>_<MM-DD-YYYY>.<ext>`, e.g.
# `Claim Report_09-07-2026.xlsx`. A fixed, predictable name because the next
# phase has to find it without being told; the date because the queue is a
# daily one. The extension is whatever eCW actually sent — its "EXCEL" export
# has been both a real xlsx and an HTML table, and renaming HTML to .xlsx makes
# Excel refuse to open it.
CLAIM_REPORT_NAME = _env("CLAIM_REPORT_NAME", "Claim Report")
# Where the previous run's report goes when a new one is downloaded.
#
# Input\ ends up holding EXACTLY ONE report, always the current one. Six of
# them accumulated over three days of testing, and once more than one is
# sitting there "which file is current?" stops being obvious - to a person
# reading the folder, and to any code that has to find it without being told.
# Moving the old ones out is what makes the answer unambiguous instead of a
# rule someone has to remember.
#
# Moved, not deleted: they are patient data and the earlier run's evidence.
INPUTS_ARCHIVE = INPUTS / "Archive"
# 0 leaves old reports in Input\ alongside the new one. Only useful if
# something outside this bot is watching that folder for every file.
ARCHIVE_OLD_INPUTS = _env_bool("ARCHIVE_OLD_INPUTS", True)
# After the export, read the claim ids back out of the saved file and show them
# in a message box — count, the first and last few, and any duplicates or blank
# rows skipped. The full list always goes to the log regardless of this.
#
# Worth doing as its own step: it proves the file the next phase will consume is
# actually readable and holds what it should, at the point where the browser is
# still open and a problem is cheap to look at. Set 0 for unattended runs.
SHOW_CLAIM_IDS = _env_bool("SHOW_CLAIM_IDS", True)
# After categorizing by payer, tell the operator about any PAYER value with
# no entry in payer_groups.json — those rows are saved with a blank Group
# Name rather than a guess. Informational only, same reasoning as
# NOTIFY_AFTER_EXPORT above. Set 0 for unattended runs; the full list still
# goes to the log either way.
NOTIFY_UNMAPPED_PAYERS = _env_bool("NOTIFY_UNMAPPED_PAYERS", True)
# TEMPORARY testing override. Blank (the default) leaves the live search +
# export flow above untouched. Set to a file path and `_claims_phase` skips
# the live bulk lookup and export entirely, reading claim ids straight from
# that file instead — the claim-status/date/balance filters are still set
# and every id is still searched and opened exactly the way a live-queue
# claim is, only the *source of the id list* changes.
#
# Built for a hand-maintained "standard" sheet listing one known claim per
# implemented condition: a run against it exercises every condition against
# a real claim in one short pass, so a new condition (or a regression in an
# old one) shows up in minutes instead of after a full live-queue run.
# `claim_file.claim_ids()` already reads any file with a recognizable
# claim-id header or column — this just points it at a fixed file instead
# of the report the export step just saved.
#
# Set, it also marks the run as a TEST run (review item D1, 2026-10-05):
# `[MODE]`/`[TEST MODE]` log lines say so, the Charge Summary goes to
# Charge Summary_TEST_<date>.xlsx (never the live summary's name), a path
# that does not exist stops the run before login (exit 2), and a sheet that
# exists but cannot be read stops it with exit 5 instead of a quiet exit 0.
# The exit-2 check lives in validate(), so it also stops
# tools/check_claims_from_excel.py, which does not use this setting: a stale
# path left here must be cleared (or fixed) before that tool will run either.
# Accepted on purpose — a path in .env that points at nothing is a config
# mistake whichever program reads the file (unit B fix round 1, 2026-10-05).
TEST_CLAIM_IDS_FILE = _env("TEST_CLAIM_IDS_FILE", "")
# ── Per-claim processing ──────────────────────────────────────────────────────
# How many claim ids to search, one at a time, after the export. 0 = all of
# them.
#
# **The default is 1 on purpose.** The report holds 541 ids and each one is a
# full server-side lookup, so "all" is a run measured in hours — not something
# to discover by accident while testing a new step. Raise it deliberately once
# the per-claim work actually does something.
CLAIM_PROCESS_LIMIT = _env_int("CLAIM_PROCESS_LIMIT", 1)
# A message box after each claim. Fine at a limit of 1 or 2; at 50 it is 50
# dialogs, so raise CLAIM_PROCESS_LIMIT and set this to 0 together. The
# end-of-run summary box appears either way, and every claim is logged.
#
# At most one box per claim (review item D9, 2026-10-05): a claim whose
# REVIEW_BEFORE_CLOSE box was shown (a condition changed it, saving off) has
# had its one box, with this box's header in it, and gets no second one.
# Dental claims show their HIGH RISK / sliding fee results here too — so 0
# also turns off the Dental box, which used to appear whatever was set (P6).
SHOW_EACH_CLAIM = _env_bool("SHOW_EACH_CLAIM", True)
# Information boxes go to the log as one "[BOX] title: first line" line
# instead of the screen. The results page sets it for the runs it starts
# (2026-10-14). Boxes a person must act on — CAPTCHA, 2FA manual step,
# security image, LIVE-WRITE confirm — are never quieted.
QUIET_BOXES = _env_bool("QUIET_BOXES", False)
# A failure screenshot (`page_utils.shot` with a failure tag) also saves the
# page's DOM beside it as .html (2026-10-21): the HTML to fix a changed
# selector from, without a re-run or a hand copy into Dom.txt. PHI, like the
# screenshots — it never leaves the Screenshots folder. SAVE_DOM_LIMIT caps
# the files per run (an eCW page is ~1 MB with its frames).
SAVE_DOM_ON_FAILURE = _env_bool("SAVE_DOM_ON_FAILURE", True)
SAVE_DOM_LIMIT = _env_int("SAVE_DOM_LIMIT", 30)
# Pre-flight selector check (pages/preflight.py, 2026-10-21): before the
# first claim the claims search page's selectors, and on the first claim of
# each screen type (UB / HCFA / Dental) the claim form's, are checked. A
# missing one is named in the log, its DOM saved, and — PREFLIGHT_STOP on —
# the run stops (exit 7) instead of sending every claim after it to Review.
PREFLIGHT = _env_bool("PREFLIGHT", True)
PREFLIGHT_STOP = _env_bool("PREFLIGHT_STOP", True)
# For each claim, open the UB Claim form, read it, and close it again.
#
# Closed with Cancel (#UBClaimBtn17) unless SAVE_CORRECTIONS=1 and the
# operator clicks OK on that claim's save box (shown only when a condition
# fired), in which case that one claim closes with OK instead (#UBClaimBtn18,
# saveAllData(true)). See SAVE_CORRECTIONS for the guard.
OPEN_CLAIM_FORM = _env_bool("OPEN_CLAIM_FORM", True)
# Apply the write-path corrections in pages/claim_form.py (telehealth
# POS/modifier is the first one) once their trigger condition is detected.
#
# This gates whether the conditions RUN at all — detect, then fill the open
# form in. It does not click OK: whether a filled-in form is ever saved is
# SAVE_CORRECTIONS below, and with that off the form is closed with Cancel.
# 0 restores the pre-correction behaviour: read, extract, always close with
# Cancel, check nothing.
#
# Default stays ON (operator ruling, 2026-10-05), unlike SAVE_CORRECTIONS.
# Applying only fills in a form that is then closed with Cancel while saving
# is off. Review item P16 is answered (audit log, 2026-10-16): typed edits
# (POS, modifiers, fees, pointers) are discarded by Cancel, but ticking a
# bill-to checkbox (updateBillToIns()) and adding a CPT row
# (addClaimFSCPTCode('save')) save on the server at once. So with
# SAVE_CORRECTIONS off neither is done — see SAVE_CORRECTIONS below. The demo
# .env does not set this, so defaulting it off would make every such run
# check nothing at all.
APPLY_CORRECTIONS = _env_bool("APPLY_CORRECTIONS", True)
# The actual write gate, one level down from APPLY_CORRECTIONS — asked
# through `pages/claim_form.writes_allowed()`. With this at 0 (VERIFY) a
# condition still fires and fills the form ON SCREEN — so it stays visible
# and in the per-claim summary box, for review — but nothing reaches eCW:
# a bill-to box is set on screen with no click (the click saves at once), no
# CPT row is added (the picker's OK saves at once; the claim is Review, "add
# by hand"), the confirmation box never appears and the claim is always
# closed with Cancel. At 1 (LIVE-WRITE) box clicks and row adds save in eCW
# the moment they are made, before the per-claim OK, and Cancel does not
# undo them. Dental claims are never written in either mode.
#
# 2026-09-17: turned off on purpose in .env. A real claim (9103000) got saved
# for real during a test run because someone answered the confirmation box —
# this is the change that makes that impossible while conditions are still
# being verified against live claims.
#
# 2026-10-05: the CODE default is off too (review item P1). Until then only
# the .env line kept saving off, and the default behind it was ON — so a
# missing line, a blank value, a typo, or an inline comment the loader did not
# strip all turned writes back on without a word. Now a missing or blank line
# can never turn saving (the OK click) on; only an explicit SAVE_CORRECTIONS=1
# can, and the run then asks for an OK at start (the LIVE-WRITE box in
# flows/export_medcor_ready.py) and before every save. P16 is answered (see
# APPLY_CORRECTIONS above): while this is off nothing is written (option C,
# 2026-10-16). Turn it on only once a condition has been confirmed correct
# and the client is ready for it to write.
SAVE_CORRECTIONS = _env_bool("SAVE_CORRECTIONS", False)
# Pause on a message box before the claim form is closed, so the corrections a
# condition just filled in can be checked on screen while they are still there.
#
# Only reached when SAVE_CORRECTIONS is off. With it on, the save confirmation
# box is already the pause before closing — this is the one that was missing
# underneath it. Without this, SAVE_CORRECTIONS=0 fills the form in and then
# clicks Cancel in the same breath, so "applied for review" was only ever
# reviewable in the log and the screenshot, never on the actual screen.
#
# The box is informational: nothing is saved either way, and the claim still
# closes with Cancel the moment it is answered. It is app-modal to the bot's
# own thread, not to Chrome, so the form behind it stays scrollable and
# inspectable while it is up. Times out on MSGBOX_TIMEOUT_SECONDS like every
# other prompt — raise that if a claim needs longer than 180s to check. Set 0
# for unattended runs, or it adds up to that per corrected claim.
#
# It is the corrected claim's ONE box (review item D9, 2026-10-05): it carries
# the SHOW_EACH_CLAIM header and Summary line, and the per-claim box after
# close is then skipped. The "N condition(s) matched — about to apply" box
# that used to come first is now a log line only.
REVIEW_BEFORE_CLOSE = _env_bool("REVIEW_BEFORE_CLOSE", True)
# Also click "Prog. Notes" and capture whatever screen it opens.
#
# Separate from OPEN_CLAIM_FORM because it is the one part whose destination
# has never been seen: the button carries ng-disabled="!FormData.
# EnableProgressNote", so on some claims it does nothing at all, and what it
# opens (a modal or a new tab) is discovered at run time rather than assumed.
OPEN_PROGRESS_NOTES = _env_bool("OPEN_PROGRESS_NOTES", True)
# How long, after clicking Prog. Notes, to keep polling (every 250 ms) for the
# note document before reporting "nothing". Replaces a fixed 3s sleep and one
# probe, which missed 3 of ~115 notes in the logs — every one a viewer that had
# opened on a document still loading, so the claim went out Clean with
# telehealth never checked (review item D8, 2026-10-01). A note that loads
# normally is accepted in well under a second; only a slow or missing one uses
# the full wait. 20s sits in the review's suggested 15-25s.
PROGRESS_NOTE_WAIT_MS = _env_int("PROGRESS_NOTE_WAIT_MS", 20_000)
# Dental claims only (condition #8, "All Dental Payer"): open Info ->
# Sliding Fee Schedule and check the patient's sliding fee is still valid on the claim's DOS (active / expired / not_started / none).
# Read-only: Patient Information closes with Cancel, never Save. A temporary
# process for now (2026-09-30); the Excel output column comes next.
CHECK_SLIDING_FEE = _env_bool("CHECK_SLIDING_FEE", True)
# Dental claims only (condition #15): report whether the Billing Notes say
# "HIGH RISK" — such claims go through the DentaQuest web portal. Read-only,
# shown with the sliding fee in the claim's SHOW_EACH_CLAIM box.
CHECK_HIGH_RISK = _env_bool("CHECK_HIGH_RISK", True)
# At the end of every claims run, write Output\Charge Summary_<date>.xlsx in
# the operator's 24-column format: the run's report rows (A-R) plus what the
# run found per claim (S-X). See core/charge_summary.py. A TEST_CLAIM_IDS_FILE
# run writes Charge Summary_TEST_<date>.xlsx instead. If that day's file is
# open in Excel, the new sheet is saved as <name>__<HHMMSS>.xlsx next to it.
WRITE_CHARGE_SUMMARY = _env_bool("WRITE_CHARGE_SUMMARY", True)
# "Assigned To" on every claim the run read.
SUMMARY_ASSIGNED_TO = _env("SUMMARY_ASSIGNED_TO", "eCW Bot")
# ── Results database (SQL Server) ─────────────────────────────────────────────
# Also save the Charge Summary rows to SQL Server (core/results_db.py) for the
# results web page (web/). Off by default; a database error is a log line
# only — the run and its Excel summary carry on. The default connection is
# the Windows login (no password) to the operator's Charge_Validation DB.
RESULTS_DB = _env_bool("RESULTS_DB", False)
RESULTS_DB_CONN = _env(
"RESULTS_DB_CONN",
"DRIVER={ODBC Driver 18 for SQL Server};SERVER=MECARE331\\MECARE331;"
"DATABASE=Charge_Validation;Trusted_Connection=yes;"
"TrustServerCertificate=yes;")
# eCW builds the file server-side over a wide date range, so this is generous.
DOWNLOAD_TIMEOUT = _env_int("DOWNLOAD_TIMEOUT_MS", 300_000) # 5 min
# ── Operator checkpoints ──────────────────────────────────────────────────────
# Show the operator the filters and wait for an OK before running the search.
# Cancel aborts; a TIMEOUT carries on, because aborting there threw away a
# completed login and a mailed 2FA approval link when nobody was at the desk,
# and the lookup only reads. 0 skips the box entirely.
CONFIRM_BEFORE_LOOKUP = _env_bool("CONFIRM_BEFORE_LOOKUP", True)
# Tell the operator how the export finished — row count, file name, size, and
# where it landed. Informational only, so it never blocks the result: the file
# is already saved by the time it appears. Set 0 for unattended runs, or it adds
# up to MSGBOX_TIMEOUT_SECONDS to every run with nobody there to click OK.
NOTIFY_AFTER_EXPORT = _env_bool("NOTIFY_AFTER_EXPORT", True)
def validate() -> list:
"""Return a list of human-readable problems with the current config."""
problems = []
if not ENV_PATH.exists():
problems.append(f".env not found at {ENV_PATH} — copy .env.example and fill it in")
for key in ("ECW_URL", "ECW_USERNAME", "ECW_PASSWORD"):
if not globals()[key]:
problems.append(f"{key} is not set")
if OTP_MODE in ("mail", "imap") and not (IMAP_HOST and IMAP_USERNAME and IMAP_PASSWORD):
problems.append(f"OTP_MODE={OTP_MODE} but IMAP_HOST / IMAP_USERNAME / IMAP_PASSWORD are incomplete")
if OTP_MODE == "totp" and not ECW_SECRET_KEY:
problems.append("OTP_MODE=totp but ECW_SECRET_KEY is not set")
if OTP_MODE not in ("auto", "totp", "mail", "imap", "manual"):
problems.append(f"OTP_MODE={OTP_MODE!r} is not one of auto/totp/mail/manual")
# Checked here, before login, because the run cannot do anything useful
# without it: the test path skips the live export, so a missing sheet
# used to cost a login and then exit 0 having processed nothing (review
# item D1). Resolved exactly as the flow resolves it (Path(...) as
# written, a relative path against the working folder), so this checks
# the same file the run would open. A file that exists but cannot be
# read still fails later, with exit 5.
if TEST_CLAIM_IDS_FILE and not Path(TEST_CLAIM_IDS_FILE).exists():
problems.append(f"TEST_CLAIM_IDS_FILE={TEST_CLAIM_IDS_FILE} does not exist")
if BROWSER_CDP_URL and cdp_url_problem(BROWSER_CDP_URL):
problems.append(cdp_url_problem(BROWSER_CDP_URL))
# Values the strict parsers could not read (see _BAD_VALUES). Every one
# stops the run here, before login: running on a guessed default is how
# SAVE_CORRECTIONS=O used to mean "save".
for key, raw, expected in _BAD_VALUES:
hint = (" (use 1/0, true/false, yes/no or on/off)"
if expected == "a valid yes/no value" else " (digits only)")
# This list is printed to the console and the run log. No PASSWORD/
# SECRET/KEY setting is a yes/no or a number today, but a typo there
# must still never put the value itself on screen.
if any(s in key.upper() for s in ("PASSWORD", "SECRET", "KEY")):
problems.append(f"{key} (value hidden) is not {expected}{hint}")
else:
problems.append(f"{key}={raw!r} is not {expected}{hint}")
return problems
def resolved_otp_mode() -> str:
"""Turn OTP_MODE=auto into the concrete mode that will actually be used."""
if OTP_MODE == "imap": # pre-push-link name for the same thing
return "mail"
if OTP_MODE != "auto":
return OTP_MODE
if ECW_SECRET_KEY:
return "totp"
if IMAP_HOST and IMAP_USERNAME and IMAP_PASSWORD:
return "mail"
return "manual"