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>
17 KiB
Changelog — Salud eCW Charge Entry Bot
What changed, when, where, and why. The why matters more than the what: most of the decisions below look strange until you know which eCW behaviour forced them, and without that a future change quietly undoes them.
Dates are the working dates on this machine (IST).
Day 1 — 2026-09-02
Status at end of day
| Phase | State |
|---|---|
| Login (proxy + 2FA) | working, proven end to end |
| Navigate to Billing → Claims | working, proven |
| Claim Lookup filters | working, proven |
| Lookup results | click proven; Total Counts never captured |
| Charge entry | not started |
Last verified run: Logs/run_20260902_181115.log.
What was built
src/config.py — every setting, selector and timeout in one place, loaded
from .env. Written without python-dotenv on purpose: these machines are
locked down and each new dependency is one more thing to get approved.
src/auth.py — the login. Navigation retries, the security-image page,
CAPTCHA escalation, two-factor, and the landing page.
src/otp.py — reads the second factor out of the mailbox over IMAP and
reports what kind of challenge it is, so auth.py can drive the browser
accordingly.
src/nav.py — post-login navigation to the Claim Lookup screen.
src/claims.py — the Claim Lookup form: status, date type, date range,
balance, Lookup.
src/guard.py — refuses to run when attempts come too close together.
src/ui.py — operator message boxes that always time out.
src/runlog.py — tees the run to Logs/run_<timestamp>.log, flushed per
line so a killed run still leaves a readable log.
src/check_net.py, src/check_imap.py — preflight probes. Run these before
blaming the bot.
Decisions that are load-bearing
The proxy is per-browser, not system-wide. chromium.launch(proxy=…) in
auth.py. This is the single thing that makes the project possible. Measured:
| no proxy | via proxy | |
|---|---|---|
| eCW portal | HTTP 400 | 200 |
| approval link | HTTP 400 → "restricted" | 200 |
| webmail site (:2096) | 200 | 403, blocked by proxy |
| mailbox IMAP :993 | connects | not proxied at all |
A human is deadlocked by that table — proxy on for eCW, off for the mail, on
again to confirm, and no ordering works. The bot escapes it by never opening the
webmail website: the browser is proxied, and mail is read by imaplib over a
raw socket that ignores the proxy entirely. The Windows system proxy stays off.
"Access to the application has been restricted" is an IP check, not a lockout. Same link: 400 unproxied, 200 proxied, seconds apart. So it almost always means the browser lost the proxy — not that the account is locked. This was initially misdiagnosed as rate limiting; the comments and the 30-minute lockout that came from that were corrected.
The second factor is a push-approval link, not a 6-digit code. The mail
contains no digits at all. An earlier build assumed a code and "found" 000000,
scraped out of color: #000000 in the mail's stylesheet. Fixed by stripping
<style>/<script> before any digit hunting, deleting the bare-6-digit
fallback, and modelling the challenge as Challenge(kind, value).
cPanel's webmail 2FA does not apply to IMAP. Roundcube now demands a
security code; IMAP/POP3/SMTP are exempt — verified live (CAPABILITY offers
AUTH=PLAIN, AUTH=LOGIN, no 2FA mechanism). No code change was needed. Noted
so nobody later "fixes" the bot by automating Roundcube.
eCW's push mail is delayed, not dropped. Proven by delivery order: the
message eCW stamped 11:19:10 arrived after the one stamped 11:26:10 —
about seven minutes late, and it has been seen taking ten. An earlier diagnosis
claimed eCW simply was not sending, and the "Resend OTP" work built on that made
things worse by shortening the first poll to 60s. Now: 300 + 300 + 300s, resend
late rather than early, because a second mail only joins the same slow queue.
The send-to-deliver lag is logged from IMAP's INTERNALDATE so this stays
visible instead of being guessed at again.
The left-nav toggle is a toggle. #jellybean-panelLink4 runs
launchLeftPanel(), which flips a class — clicking it on an open panel closes
it. Its id is also duplicated in the DOM, so every locator needs .first, and
the panel's contents are injected after DOMContentLoaded, so they must be
waited for.
Menu and dropdown options are matched by label, never by generated id.
claimLookupLink2ngR1 is an ngRepeat index — it means "the second option",
not "Claim Dt(s)". If eCW reorders the list, an id-based click selects a
different filter and the search still succeeds. A wrong answer with no error is
worse than a crash.
Claim Status runs before everything else on the form. It decides whether a
claim-based date type can match anything, and whether the Balance row is
editable (ng-change="enableInsurance()" sets enableBalanceFlag). It is
chosen by option value (108ME) with the label verified after — "Medcor
Ready" sits one line from "Medcor Testing" (113ME), and picking the wrong one
returns the wrong queue without any error.
Date type must match Claim Status. With "Encounters Without Claims" — rows
that have no claim — Claim Dt(s) returns 0, because those rows have no claim
date. Measured on the same range: 936 rows on Service Dt(s), 0 on
Claim Dt(s). It does not look like a failure: the grid keeps rendering the
previous result set while Total Counts silently drops to 0. Hence the
warning, and hence Total Counts now goes into the log.
A disabled control is not an inactive one. The Balance row arrives holding
> 0.0 with all five controls disabled, and eCW builds its query from the
Angular scope rather than from the inputs — so the filter was narrowing every
search while being impossible to click. When the row is disabled,
_force_clear_balance() clears the models inside $apply. Inside $apply
matters: Angular re-renders the inputs, so what the operator confirms is what
eCW will search on. Writing el.value would do the opposite.
Dates go in with fill(), not evaluate. fill() fires the input event
that ng-model listens for. Setting .value directly updates the display and
leaves Angular's scope stale, so eCW would search on the old value. The jQuery
calendar is also closed explicitly and checked — left open it covers the grid
and a later click can land on a date cell.
Mistakes made and corrected, same day
- Diagnosed the restriction page as rate limiting. It is an IP check. Corrected
in
config.py,guard.py, and the lockout duration. - Diagnosed the missing 2FA mail as "eCW never sent it". It was delayed. The resend feature built on that diagnosis initially made the problem worse.
- Added a rule skipping links older than 9 minutes as expired. With a 10-minute mail delay that would discard a link the moment it arrived; it now warns and clicks anyway.
- Built the fallback claims URL as
index.jsp/#/…, which loads nothing.
Operational notes
LOGIN_COOLDOWN_SECONDS=300,LOGIN_MAX_PER_HOUR=4— roughly two clean attempts per half hour.ECW_FORCE=1overrides and is usually the wrong reflex.- CAPTCHA still needs a human, so unattended scheduling is not possible yet.
- Screenshots land in
Screenshots/<MM-DD-YYYY>/and are the input for the next step's selectors.
Day 2 — 2026-09-03
The claim lookup and the Excel export were finished, and three of the four bugs below were found in the bot's own screenshots rather than by guessing.
Claim Status first. The lookup was returning 0 rows against a grid showing
936. Cause: Encounters Without Claims returns rows with no claim, so they have
no claim date and Claim Dt(s) cannot match. Medcor Ready (108ME) is now
selected before anything else, which also enables the Balance row.
wait_for_load_state("networkidle") never returns on this page. eCW's
Angular shell polls in the background, so the network is never quiet for the
500 ms Playwright wants. It printed nothing for its full 5-minute timeout, every
run looked hung and was killed at exactly that line — which is why the export
had never once run. Replaced with a poll of the grid's own "Total Counts" that
narrates the wait.
A single changed count is not enough. eCW blanks Total Counts to 0 the
instant Lookup is pressed, so the first poll saw 0 != 894 and logged a row
count of 0 for a search that returned 894. A figure now has to hold across
consecutive reads, and a 0 for twice as long — it is both a legitimate answer
and the transient.
download.save_as() copies. Chromium's _pw_downloads\<guid> survives it,
byte-identical to the saved report. The orphan sweep at the top of the next
export would have adopted it and written a second copy of a report already
saved. download.delete() now runs after the save. A real 552 KB xlsx was found
sitting in that folder unnamed, from a run killed before save_as.
The page was left scrolled. Playwright scrolls an element into view before
clicking it, so reaching the toolbar's Billing menu scrolled the filter form off
the top. Reported as "the portal looks broken" when the form was simply above
the viewport. The first fix used window.scrollTo(0, 0) and did nothing —
on this SPA the document does not scroll, an inner container does. Now
scroll_into_view_if_needed() lets Playwright find the real container, and the
sweep logs which element it was.
The guard was refusing runs it should have waited out. Five of six
consecutive runs died on the cooldown, the last with 3 seconds left.
Re-running by hand until the clock clears is exactly the pattern the cooldown
exists to discourage, so wait_if_cooling() now sleeps a plain cooldown and
proceeds. The burst limit and the restriction lockout still refuse. Writing the
test caught a bug in the fix itself: it measured with time.time() while
check() measured with datetime.now(), and two clocks for one deadline is how
a run waits out its cooldown and is then refused for being a fraction early.
Also: the confirm box before Lookup no longer aborts on a timeout (it only reads, and aborting threw away a completed login), then was turned off entirely; the Balance row is left untouched when it is already empty; and a message box reports the export.
Day 3 — 2026-09-07
Restructured into a package. No behaviour change — this was moves and imports, verified rather than assumed.
It had grown to eleven flat modules in src\, 3,253 lines, with claims.py
alone at 952 (nearly a third of the project in one file) and config.py mixing
43 DOM selectors in with the date range and the proxy. Those change for
different reasons — a selector when eCW ships a build, a setting when the
process changes, a flow when the work changes — so in one file, editing a date
range and editing a selector were edits to the same place. The charge-entry
phases would have pushed claims.py past 1,500 lines.
Now run.py plus src\ecw_bot\ with six layers, none importing a layer above
it: settings → selectors → core → auth / pages → flows. Diagnostics
moved to tools\, and tests\ exists for the first time.
run.py is the real fix hiding inside the cosmetics. python src\main.py
worked only because Python puts a script's own directory on sys.path, so
import config resolved and no other way of starting the bot did — not VS
Code's "Run File" on a different file, not Task Scheduler, not pytest. The
entry point no longer depends on which file you happened to launch.
Verified: 31 files compile with SyntaxWarning as an error (which caught five
Windows paths in docstrings needing raw strings), all 24 modules import, an AST
pass found 0 undefined names across the split, every one of the old
definitions is accounted for in a new home, tests\test_settings.py 3/3,
tests\test_guard.py 6/6, and tools\check_imap.py still reaches the mailbox
through the new import path.
Not yet run live against eCW. The next run is the first real test of the new layout, and of the scroll and Total Counts fixes from Day 2.
Day 4 — 2026-09-15
Automated the payer categorization step. Reviewed against
Salud-Charge Entry_Medcor Ready.mp4 (2:45-10:00): after the Medcor Ready
export, an operator was filtering the report by PAYER and hand-filling a
Group Name column (Commercial, Medicaid, Medicare, Dentaquest,
Program, ...) — the same manual, repetitive, rule-based step every other
phase of this bot exists to remove.
core/payer_groups.py does the lookup: exact string match against
payer_groups.json, on purpose — eCW's own PAYER values already differ by
case and spacing for what is really the same carrier (United Healthcare /
United Health Care / United healthcare are three separate values in one
real export), so anything looser has no safe way to pick which known string
a new one is closest to. An unrecognized payer gets a blank Group Name,
not a guess, and is logged and message-boxed — this is billing data, and
wrong is worse than blank.
core/spreadsheet.py gained a write side (write_xlsx()) to go with its
read side, built the same way — stdlib only, no openpyxl — but with the full
minimal OOXML part set ([Content_Types].xml, both .rels files,
workbook.xml) the reader doesn't need but real Excel does, since this file
is meant to be opened by a person.
The categorized output is a new file, Output\Medcor Ready_<date>.xlsx
— Input\Claim Report_<date>.xlsx is never modified, because claim_file.py
and the charge-entry phase read its columns by position.
Seeded, not complete. The mapping table ships with 23 payers covering
roughly 95% of a real export's row volume (Medicaid, DentaQuest, and the
clearly-commercial carriers), derived from the actual current export rather
than guessed from the video. 17 payers (BH COLORADO ACCESS, the Delta
Dental variants, Tricare, Devoted, several TPA names, ...) are
deliberately left out — first run will flag them, and filling them in is a
one-line JSON edit, no redeploy.
Out of scope for this round: splitting rows into the Patient /
Pending with Error / Program sibling workbooks the video also shows, and
writing anything to the network Charge\Allocation\... folder — both stay
manual for now.
Day 5 — 2026-09-15
The bot's first write to eCW. Reviewed the rest of the same video (10:00-45:32): the actual "Charge Validation" work is a per-claim clinical audit — open the UB form, cross-check ICD/CPT/modifiers against the Progress Note and a payer-specific rules sheet, fix one of a handful of standard defect types, save, and log the outcome. That's a much bigger surface than the categorization step, so it's being built one condition at a time rather than all at once — this is the first.
Condition 1: telehealth visit → POS=11, M1=GT. claim_form.py gained
three functions: note_mentions_telehealth() (a plain case-insensitive
check against the Progress Note's own text — deliberately not narrower,
since the note's own heading for this is written inconsistently:
TELEHEALTH:, BHTelehealth Visit, Session Setting (PoS): Telehealth),
set_cpt_pos_modifier() (sets POS and M1 on every real CPT/HCPCS row —
skipping eCW's blank padding rows and the trailing * entry row, the same
filter extract() already applies — and reads every field back so a write
that silently didn't take is a loud failure, not a quiet gap), and
save_claim() (the first place in this codebase that clicks OK instead of
Cancel — saveAllData(true)).
Nothing saves without an explicit per-claim confirmation box. This is
the first write path this bot has ever had, so the new APPLY_CORRECTIONS
setting gates whether the check even runs, and even then every fix stops
for an OK/Cancel box naming exactly what changed on which rows before
save_claim() is ever called. Declining leaves the claim exactly as it
was — the field changes only ever live in the DOM until OK is clicked, so
Cancel discards them for free.
The selectors needed for the write (uBClaimPOS*, uBClaimMod1* inside
each CPTROW_*) needed no new live-eCW inspection — they fell out of
extract()'s own existing id-prefix reader, which already had to name them
to read the grid.
tests/test_telehealth_fix.py reuses the existing ub_claim.html /
progress_note.html fixtures rather than adding new ones — the real note
eCW served on 2026-09-07 already documents a Telehealth visit, so the
positive case needs no new fixture, and detection never touches the DOM at
all, so the negative case is a plain string. ub_claim.html's Cancel and
OK buttons needed a few lines of script to actually hide the modal on
click, which they never did before — the original fixture only ever had to
test reading, never closing, so this is the first thing to exercise that
path either.
Not yet run live. CLAIM_PROCESS_LIMIT=1 is still the default, so the
first real run after this lands tests on exactly one real claim — the
first live proof that the CPT/HCPCS tab is still the active tab-pane by
the time the fix runs (nothing between open_cpt_hcpcs_tab() and here
switches away from it, but this is the one assumption the fixture cannot
prove, since Bootstrap's tab-pane visibility isn't something a static test
page reproduces the same way live Angular does).