Files
ManikandanandClaude Opus 5.5 ba15a574ab 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>
2026-10-23 18:38:17 +05:30

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=1 overrides 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).