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

+759
View File
@@ -0,0 +1,759 @@
# Salud eCW Charge Entry Bot
Single-client eCW automation for Salud. Login is done; the charge-entry phases
get built on top of the session it hands back.
Structurally a trimmed rework of the NAHMS Combined Reports bot — same portal,
same eCW V12 login markup, so the proven login handling is reused. What is
different: one client instead of a spreadsheet loop, credentials in `.env`
instead of `Input\input.xlsx`, and the second factor read from the mailbox
because this account has no authenticator secret.
The second factor is **not** a 6-digit code. eCW mails this account a
"Push Notification" containing an approval link
(`…/login/authenticate/openLoginConfirmation?token=<JWT>`, valid 10 minutes)
and the login page waits until it is confirmed. The bot reads the mail over
IMAP, opens the link in a side tab, clicks "Yes, it's me", and closes it.
`auth/mailbox.py` still handles a mailed 6-digit code and a TOTP secret, so a different
account needs no code change.
## Install
```bash
pip install -r requirements.txt
playwright install chromium
```
## Configure
`.env` at the project root holds every credential and is gitignored.
`.env.example` is the template.
## Run
```bash
python run.py
```
`run.py` is the only supported entry point. It puts `src\` on the import path
and calls the flow — which is why it exists: the old layout worked *only*
because Python happens to put a script's own directory on `sys.path`, so
`python src\main.py` resolved `import config` and nothing else did.
`python -m ecw_bot` works too, with `PYTHONPATH=src`.
Network preflight — run this first if anything about login or 2FA misbehaves:
```bash
python tools\check_net.py
```
Mailbox probe — run it if the second factor starts failing, or when pointing
the bot at a different account:
```bash
python tools\check_imap.py
```
It tries each plausible IMAP host, logs in, and prints recent subjects. As of
2026-09-01 the configured `cpanel1.methodhosting.net` passes.
Tests. No pytest — the hand-rolled `.env` parser exists for the same reason,
that every added dependency on these machines is one more approval:
```bash
python tests\test_settings.py
```
```bash
python tests\test_guard.py
```
```bash
python tests\test_dead_end.py
```
```bash
python tests\test_claim_file.py
```
```bash
python tests\test_claim_form.py
```
```bash
python tests\test_progress_note.py
```
```bash
python tests\test_input_archive.py
```
`test_claim_form.py` launches a **local headless chromium** against
`tests\fixtures\ub_claim.html`. It never touches eCW — no proxy, no
login, no 2FA mail, no attempt against the guard — so it costs nothing and can
be run as often as you like. That matters: the only other way to exercise that
code is a three-minute login and one of four hourly attempts.
## Layout
```
run.py the entry point
src\ecw_bot\
settings.py .env values, and nothing else
claim_file.py reads the saved report; finds CLAIMS#
selectors\ every CSS selector, and nothing else
login.py shell.py claim_lookup.py claim_form.py
core\ no eCW knowledge — reusable as-is
browser.py chromium.launch(proxy=…) — see below
page_utils.py shot / visible / text_of / is_disabled / goto
spreadsheet.py xlsx / HTML / CSV reader, stdlib only
msgbox.py operator prompts; every one times out
guard.py attempt cooldown and burst limit
runlog.py tees the run to Logs\run_<stamp>.log
auth\
login.py the login itself
captcha.py two tries, incognito, one last try
twofactor.py the mailed approval link
mailbox.py imaplib reader over a raw socket
restricted.py eCW's refusal page + the exception
pages\ one module per eCW screen
leftnav.py left panel → Billing → Claims
claim_filters.py status, date type, date range, Balance
claim_results.py Lookup, and waiting for the grid honestly
claim_report.py Billing → View Claims Report → EXCEL
claim_form.py the UB Claim modal: open, read, close
progress_note.py the note: find it, read prisma-sections
flows\
export_medcor_ready.py the step order, and the exit codes
tools\ diagnostics you run by hand
check_net.py check_imap.py
tests\
test_settings.py test_guard.py
test_dead_end.py test_claim_file.py test_claim_form.py
test_progress_note.py test_input_archive.py
fixtures\ub_claim.html fixtures\progress_note.html
fixtures\progress_note_host.html
Logs\ Screenshots\ Output\ all gitignored
Input\ Claim Report_<date>.xlsx the ONE current report
Archive\ every earlier one
```
Nothing imports a layer above it, so the graph is acyclic and any module can be
read without chasing a cycle. `tests\test_settings.py` asserts that, along with
`settings.ROOT` resolving to the project root and no selector leaking back into
`settings`.
**Why it is split this way.** It started as 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. These things change for entirely different reasons: a selector
changes when eCW ships a new build, a setting when the process changes, a flow
when the work changes. In one file, editing a date range and editing a selector
were edits to the same place.
## Login flow
```
goto ECW_URL (retried — the proxy kills the first goto intermittently)
→ #doctorID → #nextStep
→ #err check ("User is Inactive" fails fast)
→ security-image page → message box, operator continues
→ #passwordField → #Login
→ CAPTCHA → 2 manual attempts → incognito relaunch → final manual attempt
→ 2FA → mailbox poll, 10min → Resend OTP → 10min
(patient on purpose: the mail can take 10 minutes)
link → open in side tab → "Yes, it's me" → page proceeds
code → #OTPCode → #Submit
→ #jellybean-panelLink4 (home confirmed)
→ goto ECW_LANDING_URL (eCW drops the fragment during the login redirect)
```
Screenshots of each notable step land in `Screenshots\<MM-DD-YYYY>\`.
## Navigation
```
#jellybean-panelLink4 (left panel — only if it is not already open)
→ #Billing_menu > a (waited for: injected late)
→ #CLAIMS_BILLING span.padl10
→ claimLookup.jsp (URL check + #routeLoader hidden + screenshot)
```
`pages/leftnav.py` is written defensively for three reasons, all of them properties of
eCW's own markup rather than caution for its own sake:
- **The hamburger is a toggle, not an "open".** `launchLeftPanel()` flips a
`visible` class, so clicking it on an open panel closes it. State is checked
before every click.
- **`#jellybean-panelLink4` matches two elements.** eCW renders
`#JellyBeanCountCntrl` twice, and Playwright's strict mode refuses a plain
`click()` on that. Every locator is `.first`.
- **The menu does not exist when login is confirmed.** `getleftnavhtml()`
injects it on `DOMContentLoaded`, so `#Billing_menu` is waited for. A timeout
there means the left nav never rendered — a different bug from a moved item,
and the message says so.
Claims is clicked on its label span, not the anchor: the anchor also holds
`#favorite_16`, whose own `ng-click` stars the item instead of navigating.
If any of that fails the run does not stop — it navigates straight to
`CLAIMS_HASH`, the same hash the menu item's `openURL()` would have used, and
logs `[NAV] … falling back to the hash route`. Worth fixing the selector when
you see that line, rather than leaving the bot on the fallback permanently.
No selector on the claims screen itself is asserted — `claim_filters.py` waits for the
form's own controls, so nav only has to get the route right.
## Claim lookup
```
#claimStatusCodeId ← 108ME "Medcor Ready" FIRST — everything hangs off it
span.toggledropdown[ng-bind='selectedDateType'] (reads "Service Dt(s)")
→ #claimLookupUl2 li a span:text-is("Claim Dt(s)")
→ toggle reads back "Claim Dt(s)" (the confirmation)
#fromdate ← CLAIM_FROM_DATE (06/01/2020) Escape closes the datepicker
#todate ← today, computed at run time
Balance row cleared — all five controls, when eCW has them enabled
→ already empty? left untouched, nothing to clear
→ filters logged + claims_before_lookup.png (no box: CONFIRM_BEFORE_LOOKUP=0)
#btnclaimlookup (NOT #btnclaimlookupClear)
→ poll "Total Counts" until it changes (NOT networkidle)
#claimLookupBtn17 "Billing" toolbar, not the left nav
→ #claimLookupBtn71 "View Claims Report" hover, submenu
→ button[ng-click*="EXCEL"] NOT #claimLookupBtn1002
→ archive every earlier report to Input\Archive\
→ Input\Claim Report_<MM-DD-YYYY>.xlsx the ONE current report
→ message box: rows, file, size, folder
read the file back → the CLAIMS# column
→ message box: count, first 15, last 5 (full list to the log)
```
### Waiting for the results grid
`run_lookup` used to call `wait_for_load_state("networkidle")` after pressing
Lookup. **That 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, and
the call printed nothing at all for its full timeout. Every run so far was
killed at exactly that line — which is why the export below had never once run,
and why it looked like the export was broken when it was simply never reached.
It now polls the grid's own **Total Counts** every `LOOKUP_POLL_SECONDS`, up to
`LOOKUP_TIMEOUT_MS`, printing progress every 15 s.
**One changed reading is not enough.** eCW blanks Total Counts to 0 the instant
Lookup is pressed. The first version accepted any figure that differed from the
pre-click one, so it saw `0 != 894` on the very first poll, logged
`Results in after 0s` and recorded a row count of **0** for a search that had
returned 894 — a wrong number in the log and in the message box, for a run that
otherwise succeeded. A figure now has to hold across `_STABLE_POLLS`
consecutive reads with `#routeLoader` hidden, and a `0` — the one value that is
both a legitimate answer and the transient — has to hold for twice as long.
### Exporting the report
Three things about that submenu:
- **CSV and EXCEL share an id.** Both are `#claimLookupBtn1002`. Clicking it
with `.first` exports **CSV** — a real file downloads, the log looks clean,
and the format is wrong. The format is matched on `ng-click` instead, which is
unique per format (`mnuViewReport_Click('EXCEL');`).
- **"View Claims Report" is a `dropdown-submenu` with no `ng-click`.** It opens
on hover, so `claim_report.py` hovers first and only clicks as a fallback — on a
submenu a click can close the parent dropdown as easily as open the child.
- **"Billing" here is not the left-nav Billing.** `#claimLookupBtn17` is on the
toolbar above the results grid; `pages/leftnav.py`'s Billing is the sidebar menu.
**The page is left scrolled.** Playwright scrolls an element into view before
clicking it, so reaching the toolbar's Billing menu scrolls the whole filter
form off the top. Nothing scrolled it back, so a successful run ended on a
screen showing six grid rows, an eCW spacer and a lot of white — reported as
"the portal looks broken" when the form was simply above the viewport.
`_restore_view()` closes the dropdown (Escape, then toggling
`#claimLookupBtn17` if Escape did not take), scrolls the form back, and takes
`claims_report_done`. Never fatal: the file is already saved by then.
The first version of it used `window.scrollTo(0, 0)` and **that did nothing** —
`claims_report_done` came back with the dropdown correctly closed and the page
still scrolled, which is what isolated the failing half. On this SPA the
document does not scroll; an inner container does, so scrolling the window is a
no-op on an element that was never offset. It now calls
`scroll_into_view_if_needed()` on `#claimStatusCodeId` and lets Playwright walk
up to whatever actually scrolls, then sweeps any element with `scrollTop > 0`
and **logs which ones they were** — so nobody has to rediscover that the window
is not the scroller.
**The report goes to `Input\`, not `Output\`.** It reads like output — the bot
downloaded it — but it is the **input to the charge-entry phase**, which walks
its claim ids one at a time. Calling it output would invite someone to treat it
as a finished deliverable and clear it out between runs, stranding the phase
that consumes it. Saved under a fixed name, `Claim Report_<MM-DD-YYYY>.xlsx`,
because the next phase has to find it without being told where it is — eCW's
own name is a millisecond timestamp (`CwReport_1788783056306.xlsx`), which is
useless for that.
The extension follows what eCW actually sent, not the format that was asked
for: its "EXCEL" export has been a real xlsx (a PK zip) and, on other builds,
an HTML table — and calling HTML `.xlsx` makes Excel refuse to open it.
A second run on the same day does **not** overwrite the first. The earlier copy
is moved to `Claim Report_<date> (2).xlsx` and kept: it is patient data, and
the next phase may already be partway through it. Same-day re-runs are the norm
here, not the exception.
### Searching one claim at a time
```
#claimLookupIpt10 ← claim id ng-model="_InvId" (fill(), not evaluate)
→ #btnclaimlookup
→ wait for that claim id to appear in the grid
→ message box: found / not found, Total Counts, seconds
```
`fill("")` before every id. The box keeps the previous claim's number, and
appending instead of replacing would search for `55151455509935` — zero rows,
with the screen looking almost right.
**The check is the claim id coming back in the grid, not the count.** Two
reasons, and the first is the one that matters:
- The Claim No box's meaning depends on a type selector beside it. eCW switches
the date-type dropdown's `pointer-events` off while that reads
`Rule Engine ID#` — the only observable sign that this box is not searching
claim numbers at all. No selector for it has been captured, so rather than
invent one, `search_claim` checks the *outcome*: if the id it typed comes back
in the grid, the box meant what was assumed.
- A count is a poor signal. 541 rows → 1 is a change the settle-wait can see,
but the next claim goes 1 → 1 and looks identical to a grid that never
refreshed.
A claim that is not found does **not** stop the loop — the report was exported
minutes earlier and a claim can leave the queue in between. It is logged, a
screenshot is taken, and the run moves to the next id.
`CLAIM_PROCESS_LIMIT` bounds it, **default 1**. About 540 ids at one
server-side lookup each is a run measured in hours, which is not something to
discover by accident while testing a new step. `SHOW_EACH_CLAIM=0` when you
raise the limit, or it is one dialog per claim; the summary box and the log
appear either way. The Claim No box is cleared at the end, since a leftover id
would narrow the *next* whole-queue lookup to a single claim.
### The UB Claim form
```
click the claim number in the grid
→ #UBClaimDiv the modal-dialog, NOT the backdrop
→ a[ng-click*="cptTabUpdate('cptHcpcs')"] the CPT/HCPCS tab
→ extract every field in one page.evaluate
→ #UBClaimProgressNoteBtn Prog. Notes
→ the note, in an iframe read by prisma-section
→ closeProgressNoteViwer() eCW's own typo
→ Output\claims\<claim>.json
→ #UBClaimBtn17 Cancel NEVER #UBClaimBtn18
```
**One rule governs this screen: never use an id with a number in it.** eCW
bakes a per-open timestamp into most of them:
```
<div id="UBClaimDiv1788790287089"> the backdrop
<select id="claimStatusSel1788790287089"> claim status
<table id="claimCPTCodesTable1788790287089">
```
That number is `Date.now()` at the moment the modal opened — different for
every claim, and different for the same claim opened twice. A selector holding
it works exactly once and then silently matches nothing, which is the worst
failure shape available: the run looks clean and the data comes back empty.
So everything is matched on an id with no digits (`#BillType`,
`#UBClaimProgressNoteBtn`), or on an `ng-model` / `ng-bind` / `ng-click`.
`tests\test_claim_form.py` asserts that no selector value in
`selectors\claim_form.py` contains a long run of digits, and its fixture uses
a *different* timestamp so a baked-in one fails the other assertions too.
Two more traps, both of which produce a clean-looking run with empty data:
- **Values live in inputs, not text.** The ICD code is
`<input value="R69">` while its description beside it is plain text; the
claim status is a `<select>`; every CPT column is an input. Reading
`textContent` returns blanks for half the form. The table reader takes an
input's `value`, then a select's selected label, then the cell's text.
- **Every grid is padded with blank rows.** eCW appends empty `ng-repeat`
rows to hold a fixed height, plus a `*` entry row at the bottom. Counting
those reports five CPT lines on a claim that has two.
**`#UBClaimBtn18` is OK and it saves** (`saveAllData(true)`). This phase only
reads, so the form is always closed with Cancel (`#UBClaimBtn17`) — closing
with OK would turn every read into an unintended write.
**Prog. Notes is discovered, not assumed.** Its destination has never been
captured, so the code looks at what actually changed — a new tab or a new
in-page modal — screenshots it, and captures its text wholesale. The button
also carries `ng-disabled="!FormData.EnableProgressNote"`, so it can be
perfectly visible and do nothing; that is checked and reported as a fact about
the claim rather than as an error.
The extracted claim is returned as one dict **and** written to
`Output\claims\<claim_no>.json` — a dict in a variable is gone when the run
ends, and this is the record of what a claim said at the moment the bot looked
at it. In `Output\`, not `Input\`: this is output, and `Input\` is what the run
consumes.
### The Progress Note
**eCW tags every section of the note with `prisma-section`**, which is the
best thing about this screen:
```
<tr prisma-section="Assessments">
<tr prisma-section="Procedure Codes">
<tr prisma-section="History of Present Illness">
```
So the note is not scraped as a wall of text — it comes back as a dict keyed
by eCW's own labels. Those labels drive its export/PRISMA feature, which makes
them considerably more stable than the table markup around them.
Two sections carry what charge entry actually needs, so they are parsed
individually rather than left as prose:
```
Assessments 1. Illness, unspecified - R69 (Primary)
→ {code: "R69", primary: true}
Procedure Codes H0023 Behavioral Health Outreach Service (TH). Modifiers: GT
→ {code: "H0023", modifiers: ["GT"]}
```
Those are the **same ICD and CPT the UB Claim form shows**. Having both sides
as structure is what will later make "does the claim match the note?" a
comparison instead of a person reading two screens; for now both appear in the
same message box, side by side.
Four traps, every one of which produces a clean-looking run with nothing in it:
- **The note is a whole document.** It has its own `<html>`, so it arrives in
an **iframe** (or a separate tab), never inline. Reading the hosting modal's
`innerText` finds none of it — iframe content is not part of it — which is
exactly what an earlier version of this code did. `progress_note.find()`
walks the tabs and then every frame, and reports where it landed.
- **Close lives on the parent, not in the note.** And its handler is
`closeProgressNoteViwer()` — **Viwer**, eCW's own typo. Spelling it
correctly matches nothing. Closing matters: the viewer sits on top of the UB
Claim form and blocks the Cancel button underneath, so a note left open
would make the next claim open its form behind a stale one.
- **`prisma-section="Footer"` repeats four times.** Building the dict by
overwriting keeps only the last — the facility block — and silently loses
the signature and the sign-off status. Repeats are joined.
- **`#chartLockedId` is `display:none`.** `innerText` returns `""` for a
hidden element, so an innerText-only reader reports the chart as *unlocked*
— the opposite of the truth, on a field that decides whether a claim should
be touched at all. Read with `textContent`.
`tests\test_progress_note.py` covers all four against a fixture that serves the
note in an iframe with Close on the host page, in a local headless chromium.
### One report in Input, always the current one
Reported symptom: run the bot a second time and it takes the **old** file's
claims. Three days of testing had left six reports sitting in `Input\`:
```
Claim Report_09-07-2026 (2).xlsx
Claim Report_09-07-2026 (3).xlsx
Claim Report_09-07-2026.xlsx
Claim Report_09-08-2026 (2).xlsx
Claim Report_09-08-2026.xlsx
Claim Report_09-09-2026.xlsx
```
Once more than one is there, "which report is current?" stops being obvious
— to a person opening the folder, and to any code that has to find it
without being told the name. And the wrong answer is silent: the run looks
clean while working a stale queue.
So the export now **archives every earlier report before saving the new one**.
`Input\` ends up holding exactly one file, which makes the answer structural
instead of a convention someone has to remember:
```
Input\
Claim Report_09-09-2026.xlsx the current one
Archive\
Claim Report_09-07-2026__20260907_200644.xlsx
Claim Report_09-08-2026__20260908_211100.xlsx
```
Moved, never deleted: patient data, and each one is the evidence of what an
earlier run actually worked from. The archived name carries the file's own
modification time, so two runs on the same day cannot collide.
`latest_report()` picks by **modification time, never by name**. Sorting names
is the trap here: `Claim Report_09-09-2026 (2).xlsx` sorts *before*
`Claim Report_09-09-2026.xlsx`, because a space (0x20) is lower than a dot
(0x2e) — so the alphabetically first file can be the older one. It also warns
if it finds more than one report, rather than quietly choosing; with archiving
on, more than one means something was left behind and the pick has become a
guess.
`ARCHIVE_OLD_INPUTS=0` leaves them all in `Input\` side by side.
### Reading the claim ids back
The last step of the export opens the file it just saved and pulls the claim ids
out of it, then shows them in a message box (`SHOW_CLAIM_IDS`) — count, first
15, last 5, and any duplicates or blank rows skipped. The **full list always
goes to the log**, which is searchable in a way a MessageBox is not, and is what
makes a run auditable: which claims were in scope that day, in the order they
would be worked.
It reads the **file on disk**, not the grid it came from. That is the point: the
file is what the charge-entry phase consumes, so this checks the thing that
actually matters — that it parses, that the claim-id column is where it is
expected, and that the count matches the lookup. A grid read would prove none of
that. It is never fatal; the report is already saved.
Two things came out of reading the real 391 KB file eCW produced on 2026-09-07,
neither of which could have been guessed:
- **The header is `CLAIMS#`,** not `CLAIM #` as the on-screen grid labels the
same column. Taking the name off the screen would have found nothing.
- **There is no `sharedStrings.xml`.** Every cell is an inline `t="str"`, which
makes the parser much simpler. The shared-string path is implemented anyway —
"this build does not use it" is not "no build will".
The claim id is in column C with a blank column A before it, so nothing reads
positionally: cells are addressed by their column letter, because eCW can omit
an empty cell entirely and reading in order would shift every column after it.
`core\spreadsheet.py` handles xlsx, HTML-as-.xls and CSV, sniffed from the
file's own first bytes rather than its extension — the same reason the export
does not assume `.xlsx`. **No openpyxl:** an xlsx is a zip of XML and `zipfile`
plus `xml.etree` cover it, which matters on machines where every added package
is an approval. A legacy binary `.xls` (OLE2) is the one thing it refuses, and
it says so with the CSV alternative rather than returning nothing.
**Orphaned downloads.** Chromium writes a download to
`Output\_pw_downloads\<guid>` with no extension, and Playwright only gives it a
real name when `download.save_as()` runs. A run killed between those two points
leaves a complete, correct report on disk looking like nothing at all — a 552 KB
`.xlsx` sat there unnoticed while the export was being reported as broken.
`_rescue_orphans()` sweeps that folder into `Output\` at the start of the export
step and again if the download event is missed, sniffing the file header
(`PK` → `.xlsx`, `<` → HTML-table `.xls`) rather than assuming a format.
The step ends with a message box (`NOTIFY_AFTER_EXPORT`) reporting the row
count, file name, size and folder. It is raised **after** the file is on disk,
so nothing about it can lose the export — if nobody clicks OK it times out and
the run continues. It also fires on the no-download path, so a failed export is
not something you have to go looking in the log for.
`export_claims_report()` returns the saved `Path`, or `None` if eCW answered
with something other than a download — in which case it logs any new tab's URL
rather than raising, because the search has already succeeded and that URL is
what the next step needs. An empty file is called out too: it usually means the
filter matched nothing.
**Claim Status runs first, and it is the load-bearing step.** It decides whether
a claim-based date type can match anything (rows with no claim have no claim
date) and whether the Balance row is editable at all — its
`ng-change="enableInsurance()"` is what sets `enableBalanceFlag`. Get the order
wrong and both later steps misbehave for reasons that look unrelated.
It is a plain `<select>`, chosen **by option value with the label verified
afterwards**. Both halves earn their place: `108ME` is the only unambiguous
handle, since "Medcor Ready" sits one line from "Medcor Testing" (`113ME`) and a
loose text match could take either; and the label check catches eCW reassigning
a code, which would otherwise search the wrong queue and still look like a clean
run. `#claimStatusCodeId` is also `ng-show="ClaimStatusFlag"` — absent means the
mode selector beside it has moved off "Claim Status", so the list is hidden
rather than gone, and the error says which.
Options in the date-type dropdown are matched by **label, never by id**. eCW numbers them
`claimLookupLink2ngR0…ngR10` from the ngRepeat index, so `ngR1` means "the
second option" rather than "Claim Dt(s)" — reorder `cboDateType` upstream and an
id-based click silently selects the wrong filter, which is the worst kind of
bug here because the search still runs and still returns claims.
Two other properties of that control, both load-bearing:
- **Its text is the current value** (`ng-bind="selectedDateType"`), so the
selection is verified by reading the trigger back, not by trusting the click.
It also makes the step idempotent — already-correct is a no-op.
- **It can be switched off.** The `ng-style` sets `pointer-events: none` while
the claim-number type is `Rule Engine ID#`. Playwright reports that as a
generic timeout that reads like a dead selector, so it is checked for and
named explicitly.
A missing label fails with the list of options eCW actually offered, so a rename
is a one-line fix rather than another login attempt spent looking.
The date inputs carry jQuery UI's `hasDatepicker`, so focusing one opens a
calendar that can cover the next control — Escape closes it before the next
click. Values go in with `fill()`, which fires the `input` event `ng-model`
listens for; setting `.value` through `evaluate` would update the DOM and leave
Angular's scope stale. Each one is read back to confirm eCW did not reformat it.
**The date type has to match the Claim Status.** Claim Status
"Encounters Without Claims" returns rows that have no claim — so they have no
claim date, no submitted date and no payment date. Pairing it with anything but
`Service Dt(s)` returns nothing. Measured 2026-09-02 on the same 06/01/2020 →
today range:
| Date type | Total Counts |
|---|---|
| `Service Dt(s)` | 936 (47 pages) |
| `Claim Dt(s)` | 0 |
It is a nasty failure because it does not look like one: the grid keeps
rendering the *previous* result set while Total Counts silently drops to 0. So
`run_lookup()` logs that number and `set_date_type()` warns on the contradictory
pair before spending a lookup.
`CLAIM_DATE_TYPE` is `Claim Dt(s)` — the process asks for it, and the
expectation is that a Claim Status step lands before this one and makes the pair
sensible. Until it does, the warning fires and the lookup returns 0. Set
`CLAIM_DATE_TYPE=Service Dt(s)` in `.env` to search the encounters-without-claims
queue instead, with no code change.
**The Balance row is usually disabled, and disabled does not mean inactive.**
All five controls carry `ng-disabled="!enableBalanceFlag"`, which eCW drives off
the Claim Status selection. With "Encounters Without Claims" chosen they are
greyed out *while still holding `> 0.0`* — and eCW builds its query from the
scope values, not from the inputs. So the filter narrows every search while
being impossible to click or type into.
`clear_balance()` therefore has two paths. Enabled: blank the controls the way a
user would. Disabled: `_force_clear_balance()` sets the five `ng-model`s
(`_condition1`, `_conditionVal1`, `andOrCondition`, `_condition2`,
`_conditionVal2`) to `''` inside `$apply`, which is the only thing that actually
clears the filter.
Inside `$apply` matters. Angular re-renders the inputs from the new model, so
the form the operator confirms is the form eCW will search on — and the cleared
value is then verified by reading the input back. Writing `el.value` directly
would do the reverse: change the display and leave the query untouched.
This leans on `angular.element(el).scope()`, available only because eCW ships
with debug info on — the `ng-scope` / `ng-binding` classes in the DOM are the
evidence. If that ever changes the helper reports why it failed instead of
claiming a clear that did not happen. `FORCE_CLEAR_BALANCE=0` opts out.
### Categorizing by payer
Right after the claim ids are read back, every row of the just-saved report
gets a `Group Name` (`Commercial`, `Medicaid`, `Medicare`, `Dentaquest`,
`Program`, ...) based on its `PAYER`, and the result is saved to
`Output\Medcor Ready_<MM-DD-YYYY>.xlsx`. The original `Input\` file is never
touched — `claim_file.py` and the charge-entry phase depend on its exact
column layout.
The mapping is `src\ecw_bot\payer_groups.json` — a flat, hand-editable
`{"exact PAYER string": "Group Name"}` table. **Exact match only, no fuzzy
matching**: 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 real values), so a PAYER string
with no entry gets a **blank** Group Name rather than a guess. Unmapped rows
are logged and, unless `NOTIFY_UNMAPPED_PAYERS=0`, shown in a message box —
adding one is a one-line edit to the JSON file, no code change.
Never fatal: a missing PAYER column, an unreadable report, or a write
failure is logged and the run continues — the export already succeeded by
the time this step runs.
## Known gaps
- **The 6-digit code path is still untested.** The whole login has now run end
to end (2026-09-02 16:16 — mailbox poll, approval link, "Yes, it's me",
confirmed, landed) but only via the *link* branch, which is what this account
actually gets. `Challenge("code", …)` and `#OTPCode` have never fired.
- **eCW's push mail is delayed by minutes, and that looks like a failure.**
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` — about seven
minutes late, and it has been seen taking ten. An earlier diagnosis here
claimed eCW simply wasn't sending; that was wrong, and the "Resend OTP" work
built on it initially made things worse by shortening the first poll to 60s.
The schedule is now **10 min → Resend OTP → 10 min**: the full observed delay
is waited out before resending, because a second mail only joins the same slow
queue while the first is still in flight. The poll logs the lag from
IMAP's `INTERNALDATE`, so this stays visible instead of being guessed at.
A challenge past `OTP_LINK_AGE_WARN_SECONDS` is **warned about and still
used** — with a ten-minute mail delay against a ten-minute JWT a link can
arrive nearly expired, and refusing to click it is a guaranteed failure while
clicking it costs nothing already unspent.
- **Twenty minutes of waiting may outlive eCW's own login session.** If the page
falls back to the username box, `_challenge_with_resend` stops with that as
the stated reason rather than resending into a dead session — but it only
checks *between* windows, so a session that dies at minute 4 is not noticed
until minute 10. Whether eCW times out that fast is still unknown. The fix if
it does is a *lower* `OTP_RESEND_WAIT_SECONDS`, not a higher one.
- **CAPTCHA needs a human.** Two attempts, then incognito, then one more —
all via message box. Unattended scheduling is not possible until this is
either solved automatically or confirmed not to appear for this account.
## The proxy / webmail deadlock
Doing this login by hand is a deadlock, and it is worth knowing why the bot is
not stuck in it. Measured 2026-09-01:
| | without proxy | through proxy |
|---|---|---|
| eCW portal | HTTP 400 | **HTTP 200** |
| webmail (cpanel HTTP) | **HTTP 200** | 403, refused by the proxy |
| mailbox (IMAP :993) | **connects** | n/a — never proxied |
So a human has to flip the system proxy on to reach eCW, off to read the
approval mail, and on again to confirm the link.
The bot avoids it by never opening the webmail *website*. The proxy is set
**per-browser** at `chromium.launch(proxy=…)`, not system-wide, and the mail is
read with `imaplib` — a raw TCP socket that ignores WinINET and the
`HTTP(S)_PROXY` environment variables because it never speaks HTTP. The browser
sits behind the proxy and Python talks to the mailbox directly, at the same
time.
**Leave the Windows system proxy OFF** (`ProxyEnable = 0`). Turning it on is
what breaks the mailbox side; `tools\check_net.py` fails loudly if it is on.
## Rate limiting
eCW restricts an account that is retried too hard. On 2026-09-01 seven attempts
inside forty-five minutes produced:
> Access to the application has been restricted.
The nasty part is what happens next: the portal goes on rendering the ordinary
"eClinicalWorks sent a notification to your email" screen, but sends no mail at
all. A run then polls an empty mailbox for two minutes and blames the mailbox.
Three things guard against repeating that:
* `auth/restricted.py` is checked at every step at every step and raises
`Restricted` immediately — exit code **4**, distinct from an ordinary
failure.
* `core/guard.py` records each attempt in `Logsttempts.json` and refuses one that
is too frequent (`LOGIN_MAX_PER_HOUR`, default 4) or that follows the
restriction page — exit code **3**. That lockout is 5 minutes
(`RESTRICTED_LOCKOUT`), kept short because the usual cause is a lost proxy,
which waiting does not fix.
* A **plain cooldown is waited out, not refused.** `wait_if_cooling()` sleeps
the remainder of `LOGIN_COOLDOWN_SECONDS` (up to
`COOLDOWN_MAX_WAIT_SECONDS`) and then proceeds. It used to exit 3, and five
of six consecutive runs died that way — the last of them with **3 seconds**
left. Re-running by hand until the clock clears is precisely the pattern the
cooldown exists to discourage, so sleeping honours it just as well and
finishes the job. The burst limit and the restriction lockout are never
waited out: those mean stop, not "not yet".
* Every message box times out (`MSGBOX_TIMEOUT_SECONDS`), so an unattended run
can no longer sit on a modal holding a browser open.
`ECW_FORCE=1` overrides the guard when you genuinely need to retry now.
## Exit codes
| Code | Meaning |
|---|---|
| 0 | logged in, and landed on the claims screen |
| 1 | login failed |
| 2 | config incomplete |
| 3 | refused by the attempt guard — wait, or `ECW_FORCE=1` |
| 4 | eCW has restricted the account — stop retrying |
| 5 | logged in, but navigation to Claims failed — kept separate because the login is the expensive part and should not read as broken |