MEK Charge Reconciliation: Waystar + Aquilaehr download, per-location reconciliation, web app

- mek_recon.py: Playwright download for both sites in parallel (headless on server)
- consolidate.py: Patient Name + DOS match, per-location and All Locations Excel
- app.py: sign-in (server-side password), Run button, per-file and ZIP downloads
- Dockerfile for Dokploy

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
VasanthandClaude Opus 5.5 committed 2026-10-27 17:38:27 +05:30
commit fd480fa874
10 files changed
+1335

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
# secrets
.env
# patient data (PHI) - never commit
processing/
output/
logs/
# browser profiles / caches
.chrome_profile*/
__pycache__/
.ruff_cache/
.git/
.claude/
+11
View File
@@ -0,0 +1,11 @@
# secrets
.env
# patient data (PHI) - never commit
processing/
output/
logs/
# browser profiles / caches
.chrome_profile*/
__pycache__/
.ruff_cache/
.claude/
+13
View File
@@ -0,0 +1,13 @@
# Playwright image ships Chromium + its OS deps for this exact Playwright version
FROM mcr.microsoft.com/playwright/python:v1.49.0-noble
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# headless, bundled Chromium (no Google Chrome in the image)
ENV HEADLESS=1 CHROME_CHANNEL= PYTHONUNBUFFERED=1 TZ=America/New_York
EXPOSE 8000
# one worker: run state is kept in memory
CMD ["gunicorn", "-w", "1", "--threads", "8", "-b", "0.0.0.0:8000", "--timeout", "120", "app:app"]
+141
View File
@@ -0,0 +1,141 @@
"""Web front end: sign in -> Run -> download per-location reports or a ZIP of all.
Env: APP_PASSWORD (sign-in password), SECRET_KEY (session signing; random if unset),
COOKIE_SECURE=1 behind HTTPS, plus the Waystar/Aquilaehr logins mek_recon.py reads.
Run locally: python app.py -> http://127.0.0.1:8000
"""
import hmac
import io
import os
import secrets
import subprocess
import sys
import threading
import time
import zipfile
from functools import wraps
from pathlib import Path
from flask import Flask, abort, jsonify, redirect, render_template, request, send_file, session
import consolidate
import mek_recon
ROOT = Path(__file__).parent
OUT = ROOT / "output"
LOG = ROOT / "logs" / "run.log"
mek_recon.load_env() # local .env; on the server the values come from the environment
app = Flask(__name__)
app.secret_key = os.environ.get("SECRET_KEY") or secrets.token_hex(32)
app.config.update(
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
SESSION_COOKIE_SECURE=os.environ.get("COOKIE_SECURE") == "1",
PERMANENT_SESSION_LIFETIME=12 * 3600,
)
# ponytail: one run at a time, state in memory - fine for one gunicorn worker; restart forgets the last run
run = {"proc": None, "started": None, "finished": None, "code": None}
lock = threading.Lock()
def signed_in(view):
@wraps(view)
def wrapper(*a, **kw):
if not session.get("ok"):
return (jsonify(error="sign in"), 401) if request.path != "/" else render_template("login.html")
return view(*a, **kw)
return wrapper
@app.post("/login")
def login():
expected = os.environ.get("APP_PASSWORD", "")
given = request.form.get("password", "")
if not expected or not hmac.compare_digest(given.encode(), expected.encode()):
time.sleep(1) # slow down guessing
return jsonify(error="wrong password"), 401
session.clear()
session.permanent = True
session["ok"] = True
return jsonify(ok=True)
@app.post("/logout")
def logout():
session.clear()
return redirect("/")
@app.get("/")
@signed_in
def index():
return render_template("index.html")
def reports():
return sorted(OUT.glob("*.xlsx"), key=lambda f: (f.name != consolidate.ALL_FILE, f.name.lower()))
@app.post("/run")
@signed_in
def start_run():
with lock:
if run["proc"] and run["proc"].poll() is None:
return jsonify(error="already running"), 409
# start clean: a location that fails must not leave last run's file looking current
for f in [*ROOT.glob("processing/*/*.csv"), *OUT.glob("*.xlsx")]:
f.unlink()
LOG.parent.mkdir(exist_ok=True)
env = {**os.environ, "HEADLESS": os.environ.get("HEADLESS", "1"),
"PYTHONUNBUFFERED": "1", "PYTHONIOENCODING": "utf-8"}
with LOG.open("w", encoding="utf-8") as log:
run["proc"] = subprocess.Popen([sys.executable, "mek_recon.py"], cwd=ROOT, env=env,
stdout=log, stderr=subprocess.STDOUT)
run.update(started=time.time(), finished=None, code=None)
return jsonify(ok=True)
@app.get("/status")
@signed_in
def status():
proc = run["proc"]
running = bool(proc and proc.poll() is None)
if proc and not running and run["finished"] is None:
run.update(finished=time.time(), code=proc.returncode)
log = LOG.read_text(encoding="utf-8", errors="replace") if LOG.exists() else ""
return jsonify(
running=running, started=run["started"], finished=run["finished"], code=run["code"],
log="\n".join(log.splitlines()[-300:]),
files=[{"name": f.name, "size": f.stat().st_size, "mtime": f.stat().st_mtime} for f in reports()],
)
@app.get("/download/<name>")
@signed_in
def download(name):
match = next((f for f in reports() if f.name == name), None) # only files we list
if not match:
abort(404)
return send_file(match, as_attachment=True, download_name=match.name)
@app.get("/download-all")
@signed_in
def download_all():
files = reports()
if not files:
abort(404)
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
for f in files:
z.write(f, f.name)
buf.seek(0)
stamp = time.strftime("%Y-%m-%d_%H%M", time.localtime(max(f.stat().st_mtime for f in files)))
return send_file(buf, as_attachment=True, download_name=f"MEK_Charge_Reconciliation_{stamp}.zip",
mimetype="application/zip")
if __name__ == "__main__":
app.run(host="127.0.0.1", port=int(os.environ.get("PORT", 8000)))
+180
View File
@@ -0,0 +1,180 @@
"""Per-location reconciliation: Aquilaehr visits vs Waystar claims, matched on
Patient Name + Date of Service. Writes output/<Location>.xlsx.
Aquilaehr: Patient ("Mary Lopez Garcia") + Visit Date
Waystar: Patient Name ("LOPEZ, MARY") + From Date
Match = same date, same first name, and at least one last-name word in common.
Run: python consolidate.py
"""
from pathlib import Path
import pandas as pd
ROOT = Path(__file__).parent
WAYSTAR_DIR = ROOT / "processing" / "Waystar"
AQUILAEHR_DIR = ROOT / "processing" / "Aquilaehr"
OUT_DIR = ROOT / "output"
SUFFIXES = {"JR", "SR", "II", "III", "IV"}
def words(s):
return [w for w in str(s).upper().replace(",", " ").replace(".", " ").split() if w not in SUFFIXES]
def join(values):
return ", ".join(dict.fromkeys(v for v in values if isinstance(v, str) and v.strip()))
def load_aquilaehr(path):
a = pd.read_csv(path, dtype=str).fillna("")
a["DOS"] = pd.to_datetime(a["Visit Date"], errors="coerce").dt.date
a["Name"] = a["Patient"].str.split().str.join(" ")
# one row per note/template -> one row per patient visit
v = a.groupby(["Account #", "Name", "DOS"], as_index=False).agg(
**{"Aquilaehr Claim ID": ("Claim ID", join), "Note Status": ("Note Status", join),
"Claim Status": ("Claim Status", join), "Provider": ("Provider", lambda s: join(" ".join(x.split()) for x in s))}
)
t = v["Name"].map(words)
v["first"] = t.str[0].fillna("")
v["last"] = t.str[1:].map(set)
return v
def load_waystar(path):
w = pd.read_csv(path, skiprows=7, dtype=str, index_col=False).fillna("")
for c in w.columns: # Excel-friendly CSV wraps values like ="..."
w[c] = w[c].str.replace(r'^="(.*)"$', r"\1", regex=True)
w["DOS"] = pd.to_datetime(w["From Date"], errors="coerce").dt.date
w["Charges"] = pd.to_numeric(w["Charges"].str.replace(r"[$,]", "", regex=True), errors="coerce").fillna(0)
w["Claim ID ref"] = w["Claim Number"].str.split().str[0]
v = w.groupby(["Patient Name", "DOS"], as_index=False).agg(
**{"Waystar Claim Number": ("Claim Number", join), "Charges": ("Charges", "sum"),
"Waystar Status": ("Status", join), "Claim ID ref": ("Claim ID ref", join)}
)
parts = v["Patient Name"].str.partition(",") # "LAST, FIRST MI"
v["first"] = [(words(f) or [""])[0] for f in parts[2]]
v["last"] = [set(words(n)) for n in parts[0]]
return v
def reconcile(a, w):
rows, used = [], set()
a_by_key = {}
for i, r in a.iterrows():
a_by_key.setdefault((r["first"], r["DOS"]), []).append(i)
a_claim_dates = {}
for _, r in a.iterrows():
for cid in r["Aquilaehr Claim ID"].split(", "):
if cid:
a_claim_dates.setdefault(cid, set()).add(r["DOS"])
w_claim_dates = {}
for _, r in w.iterrows():
for cid in r["Claim ID ref"].split(", "):
if cid:
w_claim_dates.setdefault(cid, set()).add(r["DOS"])
def fmt(dates):
return ", ".join(d.strftime("%m/%d/%Y") for d in sorted(dates) if pd.notna(d))
for _, r in w.iterrows():
same = [i for i in a_by_key.get((r["first"], r["DOS"]), []) if a.at[i, "last"] & r["last"]]
hit = next((i for i in same if i not in used), None)
row = {"Patient Name": r["Patient Name"], "DOS": r["DOS"],
"Waystar Claim Number": r["Waystar Claim Number"], "Charges": r["Charges"],
"Waystar Status": r["Waystar Status"]}
if hit is not None:
used.add(hit)
row.update(Result="Matched", **a.loc[hit, ["Aquilaehr Claim ID", "Note Status", "Claim Status", "Provider"]])
row["Aquilaehr Patient"] = a.at[hit, "Name"]
elif same: # visit already matched to another Waystar claim
row.update(Result="Duplicate in Waystar", **a.loc[same[0], ["Aquilaehr Claim ID", "Claim Status"]])
row["Aquilaehr Patient"] = a.at[same[0], "Name"]
row["Note"] = "Same patient + DOS already matched to another Waystar claim"
else:
row["Result"] = "Missing in Aquilaehr"
other = set().union(*(a_claim_dates.get(c, set()) for c in r["Claim ID ref"].split(", ")))
if other:
row["Note"] = f"Claim ID {r['Claim ID ref']} is in Aquilaehr with Visit Date {fmt(other)}"
rows.append(row)
for i, r in a.iterrows():
if i in used:
continue
row = {"Patient Name": r["Name"], "DOS": r["DOS"], "Result": "Missing in Waystar",
"Aquilaehr Patient": r["Name"], **r[["Aquilaehr Claim ID", "Note Status", "Claim Status", "Provider"]]}
other = set().union(*(w_claim_dates.get(c, set()) for c in r["Aquilaehr Claim ID"].split(", ") if c))
if other:
row["Note"] = f"Claim ID {r['Aquilaehr Claim ID']} is in Waystar with DOS {fmt(other)}"
rows.append(row)
cols = ["Result", "Patient Name", "DOS", "Aquilaehr Patient", "Aquilaehr Claim ID", "Note Status",
"Claim Status", "Provider", "Waystar Claim Number", "Charges", "Waystar Status", "Note"]
order = {"Missing in Waystar": 0, "Missing in Aquilaehr": 1, "Duplicate in Waystar": 2, "Matched": 3}
df = pd.DataFrame(rows).reindex(columns=cols)
return df.sort_values(["Result", "Patient Name", "DOS"], key=lambda s: s.map(order) if s.name == "Result" else s)
def write_report(df, location, out):
counts = df["Result"].value_counts()
summary = pd.DataFrame({
"Item": ["Location", "Aquilaehr visits", "Waystar visits", "Matched",
"Missing in Waystar", "Missing in Aquilaehr", "Duplicate in Waystar"],
"Value": [location, int(counts.get("Matched", 0) + counts.get("Missing in Waystar", 0)),
int((df["Result"] != "Missing in Waystar").sum()), int(counts.get("Matched", 0)),
int(counts.get("Missing in Waystar", 0)), int(counts.get("Missing in Aquilaehr", 0)),
int(counts.get("Duplicate in Waystar", 0))],
})
write_xlsx(out, summary, df)
return summary
def write_xlsx(out, summary, df):
with pd.ExcelWriter(out, engine="openpyxl") as xw:
summary.to_excel(xw, sheet_name="Summary", index=False)
df.to_excel(xw, sheet_name="Reconciliation", index=False)
for ws in xw.book.worksheets:
ws.freeze_panes = "A2"
ws.auto_filter.ref = ws.dimensions
for col in ws.columns:
width = max(len(str(c.value or "")) for c in col[:200])
ws.column_dimensions[col[0].column_letter].width = min(max(width + 2, 10), 60)
ws = xw.book["Reconciliation"]
for col in ws.iter_cols(min_row=1, max_row=ws.max_row):
fmt = {"DOS": "mm/dd/yyyy", "Charges": '"$"#,##0.00'}.get(col[0].value)
for c in col[1:] if fmt else ():
c.number_format = fmt
ALL_FILE = "All Locations.xlsx"
def main():
OUT_DIR.mkdir(exist_ok=True)
summaries, frames = [], []
for wf in sorted(WAYSTAR_DIR.glob("*.csv")):
af = AQUILAEHR_DIR / wf.name
if not af.exists():
print(f"SKIP {wf.stem}: no Aquilaehr file")
continue
df = reconcile(load_aquilaehr(af), load_waystar(wf))
summary = write_report(df, wf.stem, OUT_DIR / f"{wf.stem}.xlsx")
s = summary["Value"].tolist()
print(f"{wf.stem:40} Aquilaehr={s[1]:5} Waystar={s[2]:5} Matched={s[3]:5} "
f"Missing in Waystar={s[4]:4} Missing in Aquilaehr={s[5]:4} Duplicate in Waystar={s[6]:3}")
if s[1] and s[2] and s[3] < 0.5 * min(s[1], s[2]):
print(f" WARNING {wf.stem}: under half the visits match - wrong practice data downloaded?")
summaries.append(summary.set_index("Item")["Value"])
frames.append(df.assign(Location=wf.stem))
if frames: # one overall workbook: a row per location + every location's rows
overall = pd.DataFrame(summaries).reset_index(drop=True)
total = overall.drop(columns="Location").sum().to_dict()
overall = pd.concat([overall, pd.DataFrame([{"Location": "TOTAL", **total}])], ignore_index=True)
rows = pd.concat(frames, ignore_index=True)
write_xlsx(OUT_DIR / ALL_FILE, overall, rows[["Location", *rows.columns[:-1]]])
print(f"{'ALL LOCATIONS':40} -> output/{ALL_FILE}")
if __name__ == "__main__":
main()
+325
View File
@@ -0,0 +1,325 @@
"""MEK Charge reconciliation process.
Waystar (ZirMed): for each location -> switch account -> Claims -> Status All,
Transaction Date All -> Search -> Download CSV -> processing/Waystar/<location>.csv
Aquilaehr: Reports -> Encounter & Claim Monitor -> pick practice -> Export CSV
-> processing/Aquilaehr/<location>.csv (same file name as Waystar so the pair lines up)
Then consolidate.py matches Patient Name + DOS per location -> output/<location>.xlsx
Run: python mek_recon.py both sites at the same time, all locations
python mek_recon.py Aquilaehr only Aquilaehr
python mek_recon.py Waystar "Healing Bridge" only Waystar, only that location
Login is automatic from .env (WAYSTAR_USERNAME, WAYSTAR_PASSWORD, WAYSTAR_ANSWER_CAR,
WAYSTAR_ANSWER_JOB, AQUILAEHR_USERNAME, AQUILAEHR_PASSWORD). If something is
missing/unexpected, finish the login by hand in Chrome; the script waits.
"""
import os
import re
import sys
import time
from multiprocessing import Process
from pathlib import Path
from playwright.sync_api import sync_playwright
import consolidate
ROOT = Path(__file__).parent
WAYSTAR, AQUILAEHR = "Waystar", "Aquilaehr"
OUT = {site: ROOT / "processing" / site for site in (WAYSTAR, AQUILAEHR)}
# Waystar account name -> Aquilaehr practice dropdown names.
# Several Aquilaehr practices are exported and merged into one file.
# Adelman Health is skipped on both sites (no Aquilaehr practice).
LOCATIONS = {
"Alpha Well-Being LLC": ["ALPHA WELL-BEING LLC"],
"ARC Wound Care": ["ARC Woundcare LLC"],
"Cynthia DM Concierge NP Services LLC": ["Moona Concierge Nurse Practitioner Services LLC"],
"Healing Bridge": ["HEALING BRIDGE LLC"],
"Joy Solutions LLC": ["Joy Solutions CORP"],
"Lunas Home NP LLC": ["LUNAS HOME NP LLC"],
"Stem Regen NJ": ["Stem Regen Medical"],
"Sunwave Woundcare": ["Sunwave Woundcare"],
"WoundDocs LLC": ["WOUNDOCS LLC", "WOUNDOCS LLC - 2"],
"Wound Pros of FL": ["Wound Professionals of Florida LLC"],
}
WAYSTAR_LOGIN = "https://login.zirmed.com/ui"
WAYSTAR_CLAIMS = "https://claims.zirmed.com/Claims/Listing/Index?appid=1"
AQUILAEHR_REPORTS = "https://platform.aquilaehr.com/#/reports/clinical"
# time to finish a login by hand; headless (server) runs have no hand, so fail sooner
LOGIN_WAIT_MS = (3 if os.environ.get("HEADLESS") == "1" else 10) * 60 * 1000
def load_env():
env = ROOT / ".env"
if env.exists():
for line in env.read_text(encoding="utf-8").splitlines():
if "=" in line and not line.lstrip().startswith("#"):
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip().strip('"'))
def log(site, msg):
print(f"{site:9} {msg}", flush=True)
# ---------------- Waystar ----------------
def waystar_login(page):
page.goto(WAYSTAR_LOGIN)
account_box = page.locator("input.header-account-search-text")
if os.environ.get("WAYSTAR_USERNAME") and page.locator("#loginName").is_visible():
page.fill("#loginName", os.environ["WAYSTAR_USERNAME"])
page.fill("#password", os.environ.get("WAYSTAR_PASSWORD", ""))
page.click("#loginButton")
log(WAYSTAR, "login submitted")
# pages Waystar may show after "Log in" (ids taken from the G2 ChargeBilledAutomation bot)
answers = {"first car": os.environ.get("WAYSTAR_ANSWER_CAR"), "first job": os.environ.get("WAYSTAR_ANSWER_JOB")}
question = page.locator("#authDiv strong").first
alert = page.locator("#continueButton") # "Proactive Alert" page
sec_info = page.locator("h2", has_text="Security Information")
end = time.time() + LOGIN_WAIT_MS / 1000
while time.time() < end:
if account_box.is_visible():
log(WAYSTAR, "logged in")
return
if alert.is_visible():
alert.click()
log(WAYSTAR, "proactive alert continued")
elif question.is_visible():
text = question.inner_text().lower()
key = next((k for k in answers if k in text), None)
if not key or not answers[key]:
raise RuntimeError(f"unknown Waystar security question: {text}")
page.fill("#verifyAnswer", answers[key])
page.click("#VerifyButton")
log(WAYSTAR, f"security question ({key}) answered")
elif sec_info.is_visible():
page.click("#NextButton")
page.get_by_text("Skip This Step", exact=True).click()
log(WAYSTAR, "security information page skipped")
page.wait_for_timeout(1000)
raise RuntimeError("Waystar login did not finish in time")
def waystar_switch_account(page, location):
box = page.locator("input.header-account-search-text")
box.click()
box.fill("")
box.press_sequentially(location, delay=50)
items = page.locator("ul.ui-autocomplete li:visible")
# dropdown text looks like "Healing Bridge (313858)"; it shows the first 25
# accounts before the typed filter kicks in, so wait for the actual match
match = items.filter(has_text=location).first
try:
match.wait_for(timeout=15000)
except Exception:
raise RuntimeError(f"no account match in dropdown: {items.all_inner_texts()}")
match.click()
page.wait_for_url("**/SuccessfulAccountChange/**", timeout=30000)
def waystar_download(page, location):
waystar_switch_account(page, location)
page.goto(WAYSTAR_CLAIMS)
page.select_option("#SearchCriteria_Status", label="All")
page.select_option("#SearchCriteria_TransDate", label="All")
page.click("#ClaimListingSearchButtonTop")
page.get_by_text("Transaction Date: All").first.wait_for(timeout=60000)
page.wait_for_load_state("networkidle")
with page.expect_download(timeout=120000) as dl:
page.click("#downloadCsvBtn")
out = OUT[WAYSTAR] / f"{location}.csv"
dl.value.save_as(out)
return out
# ---------------- Aquilaehr ----------------
AQUILAEHR_PRACTICE = ".ant-select[style*='200px']" # practice dropdown at the top of the page
AQUILAEHR_EXPORT = "a[download]" # <a> inside the "Export CSV" button (blob URL)
def aquilaehr_login(page):
page.goto(AQUILAEHR_REPORTS)
email = page.locator("#normal_login_email")
logged_in = page.get_by_text("Reports", exact=True).first
submitted = False
end = time.time() + LOGIN_WAIT_MS / 1000
# SPA: either the login form or the app shows up after a few seconds
while time.time() < end:
if logged_in.is_visible():
log(AQUILAEHR, "logged in")
return
if not submitted and os.environ.get("AQUILAEHR_USERNAME") and email.is_visible():
email.fill(os.environ["AQUILAEHR_USERNAME"])
page.fill("#normal_login_password", os.environ.get("AQUILAEHR_PASSWORD", ""))
page.click("button[type=submit]")
submitted = True
log(AQUILAEHR, "login submitted")
page.wait_for_timeout(1000)
raise RuntimeError("Aquilaehr login did not finish in time")
def aquilaehr_download(page, location):
"""Export every practice for this location and merge into one CSV (header once)."""
out = OUT[AQUILAEHR] / f"{location}.csv"
lines = []
for practice in LOCATIONS[location]:
part = aquilaehr_export(page, practice, OUT[AQUILAEHR] / f"{location}.part.csv")
rows = part.read_text(encoding="utf-8-sig").splitlines()
part.unlink()
lines += rows if not lines else rows[1:]
log(AQUILAEHR, f" {practice}: {len(rows) - 1} rows")
out.write_text("\n".join(lines) + "\n", encoding="utf-8-sig")
return out
def norm(s):
return " ".join(s.split()).lower()
def aquilaehr_select_practice(page, practice):
# practice dropdown is a virtual list: scroll the open one until the option renders
page.locator(AQUILAEHR_PRACTICE).click()
page.locator(".ant-select-dropdown:not(.ant-select-dropdown-hidden)").wait_for()
found = page.evaluate(
"""async (title) => {
const norm = s => (s || '').split(/\\s+/).join(' ').trim().toLowerCase();
const dd = document.querySelector('.ant-select-dropdown:not(.ant-select-dropdown-hidden)');
const h = dd.querySelector('.rc-virtual-list-holder');
for (let y = 0; y <= h.scrollHeight + 100; y += 80) {
h.scrollTop = y;
await new Promise(r => setTimeout(r, 150));
const o = [...dd.querySelectorAll('.ant-select-item-option')]
.find(o => norm(o.getAttribute('title')) === norm(title));
if (o) { o.click(); return true; }
}
return false;
}""",
practice,
)
if not found:
raise RuntimeError(f"practice '{practice}' not in dropdown")
def aquilaehr_export(page, practice, out):
# practice ids whose report data has arrived (GET .../reports/clinical_notes?practice_id=<id>)
loaded = []
def on_response(r):
m = re.search(r"/reports/clinical_notes\?practice_id=([\w-]+)", r.url)
if m and r.ok:
loaded.append(m.group(1))
page.on("response", on_response)
try:
# Reports -> Encounter & Claim Monitor tab
page.goto(AQUILAEHR_REPORTS)
page.get_by_text("Encounter & Claim Monitor", exact=True).first.click()
page.wait_for_load_state("networkidle")
current = page.locator(f"{AQUILAEHR_PRACTICE} .ant-select-selection-item")
if norm(current.inner_text()) != norm(practice):
before = page.url
aquilaehr_select_practice(page, practice)
page.wait_for_url(lambda u: u != before and "practiceId=" in u, timeout=30000)
if norm(current.inner_text()) != norm(practice):
raise RuntimeError(f"dropdown shows '{current.inner_text()}', expected '{practice}'")
# wait for THIS practice's data; data for the previous practice can still be arriving
pid = re.search(r"practiceId=([\w-]+)", page.url)
end = time.time() + 120
while not (pid.group(1) in loaded if pid else loaded):
if time.time() > end:
raise RuntimeError(f"report data for '{practice}' did not load")
page.wait_for_timeout(500)
page.wait_for_load_state("networkidle")
page.wait_for_timeout(2000) # let the table + export link re-render
with page.expect_download(timeout=120000) as dl:
page.locator(AQUILAEHR_EXPORT).click()
dl.value.save_as(out)
finally:
page.remove_listener("response", on_response)
return out
# ---------------- runner ----------------
SITES = {
WAYSTAR: (waystar_login, waystar_download),
AQUILAEHR: (aquilaehr_login, aquilaehr_download),
}
def run_site(site, locations):
"""One site in its own Chrome (own profile), so both sites can run at the same time."""
load_env()
OUT[site].mkdir(parents=True, exist_ok=True)
login, download = SITES[site]
failed = []
seen = {} # file content -> location; two locations with identical exports = stale data
with sync_playwright() as p:
# server/Docker: HEADLESS=1 and CHROME_CHANNEL="" (Playwright's bundled Chromium)
headless = os.environ.get("HEADLESS") == "1"
ctx = p.chromium.launch_persistent_context(
str(ROOT / f".chrome_profile_{site.lower()}"),
channel=os.environ.get("CHROME_CHANNEL", "chrome") or None, headless=headless,
accept_downloads=True, no_viewport=not headless,
viewport={"width": 1440, "height": 900} if headless else None,
)
page = ctx.pages[0] if ctx.pages else ctx.new_page()
login(page)
for loc in locations:
try:
out = download(page, loc)
content = out.read_bytes()
if content in seen:
log(site, f"WARNING {loc}: same data as {seen[content]} - stale export?")
seen[content] = loc
log(site, f"OK {loc} -> {out.name} ({content.count(b'\n')} lines)")
except Exception as e:
failed.append(loc)
log(site, f"FAIL {loc}: {e}")
ctx.close()
log(site, f"failed: {failed or 'none'}")
def locked_files():
"""Files open in Excel can't be overwritten -> the run would silently keep old data."""
locked = []
for f in [*ROOT.glob("processing/*/*.csv"), *ROOT.glob("output/*.xlsx")]:
try:
with open(f, "a"):
pass
except PermissionError:
locked.append(f.relative_to(ROOT))
return locked
def main():
locked = locked_files()
if locked:
print("Close these files in Excel first, then run again:")
for f in locked:
print(f" {f}")
sys.exit(1)
args = sys.argv[1:]
sites = [args.pop(0)] if args and args[0] in SITES else list(SITES)
locations = [loc for loc in LOCATIONS if not args or loc in args]
if len(sites) == 1:
run_site(sites[0], locations)
else:
procs = [Process(target=run_site, args=(site, locations)) for site in sites]
for pr in procs:
pr.start()
for pr in procs:
pr.join()
print("Consolidating -> output/<Location>.xlsx", flush=True)
consolidate.main()
if __name__ == "__main__":
main()
+91
View File
@@ -0,0 +1,91 @@
# MEK Charge reconciliation process
Sites:
- Waystar (ZirMed) -> https://login.zirmed.com/ui
- Aquilaehr -> https://platform.aquilaehr.com/
Run: `python mek_recon.py` -> both sites at the same time, each in its own Chrome window
(own profile: .chrome_profile_waystar / .chrome_profile_aquilaehr).
One site only: `python mek_recon.py Waystar` or `python mek_recon.py Aquilaehr`.
Close processing/ and output/ files in Excel/WPS before running (the script stops and lists them otherwise).
Full run verified 10/27/2026: both sites 10/10 locations, reports in output/ (~1 min).
Logins come from .env (values never stored in code):
- Waystar: WAYSTAR_USERNAME, WAYSTAR_PASSWORD, WAYSTAR_ANSWER_CAR, WAYSTAR_ANSWER_JOB
-> fills #loginName / #password -> "Log in" -> security question page: answer by keyword
("first car" / "first job") -> click "Verify"
- Aquilaehr: AQUILAEHR_USERNAME, AQUILAEHR_PASSWORD
## Locations (run the whole process for each, on BOTH sites)
- ~~Adelman Health~~ (skipped)
- Alpha Well-Being LLC
- ARC Wound Care
- Cynthia DM Concierge NP Services LLC
- Healing Bridge
- Joy Solutions LLC
- Lunas Home NP LLC
- Stem Regen NJ
- Sunwave Woundcare
- WoundDocs LLC
- Wound Pros of FL
## Waystar steps
1. Open https://login.zirmed.com/ui and log in
2. Select location: top "Account" box -> type location name
-> pick from dropdown (e.g. "Healing Bridge (313858)")
-> lands on "Success - You have switched to the following account" page
3. Professional Claims > click "Claims" tab
-> https://claims.zirmed.com/Claims/Listing/Index?appid=1
4. Left panel filters:
- Status (select#SearchCriteria_Status) -> "All" (default was "All Rejected")
- Transaction Date (select#SearchCriteria_TransDate) -> "All" (default "Last 90 Days"; "All" = last 2 years)
5. Click "Search"
6. Click "Download CSV" (#downloadCsvBtn, "Excel-Friendly CSV" ticked)
-> save as processing/Waystar/<Location>.csv
CSV layout: 7 preamble lines (Generated by / Account: / Filters...), then header row:
Trans Date, Patient Name, Claim Number, Claim ID, Instance ID, Payer Name, Payer ID, From Date, To Date,
Rendering Provider, Charges, Status, Sequence, Source, Hidden, Remit #s, Total Remit Amount, Last Note, Last Event Message
7. Repeat 2-6 for every location.
## Aquilaehr steps
1. Open https://platform.aquilaehr.com/ (login page: #normal_login_email, #normal_login_password, "Login")
2. Sidebar "Reports" -> https://platform.aquilaehr.com/#/reports/clinical
3. Click "Encounter & Claim Monitor" tab
4. Top practice dropdown (antd select, virtual list - scroll to find) -> pick location
-> URL gets ?practiceId=<guid>; page reloads data. Date range left empty = all dates.
5. Wait until the data call for the NEW practice finishes
(GET aquila-api-prod-xv7xr.ondigitalocean.app/api/v1/reports/clinical_notes?practice_id=<new id>),
and the dropdown shows the chosen practice. (Exporting earlier gives the PREVIOUS practice's data.)
Then click "Export CSV" (it's <a download="e&c-monitor.csv" href=blob:...>, contains ALL rows, not just page 1)
-> save as processing/Aquilaehr/<Waystar location name>.csv
Columns: Account #, Patient, DOB, Visit Date, Provider, Location, Visit Reason, Room, Template,
Plan Name, Claim ID, Note Status, Claim Status
6. Repeat 4-5 for every location.
Waystar name -> Aquilaehr dropdown name: see LOCATIONS in mek_recon.py
- Adelman Health: SKIPPED on both sites (no Aquilaehr practice)
- Cynthia DM Concierge NP Services LLC -> "Moona Concierge Nurse Practitioner Services LLC"
- WoundDocs LLC -> "WOUNDOCS LLC" + "WOUNDOCS LLC - 2", both exported and merged into one file
## Consolidation
Match key (verified 10/27/2026, 3938 of 4027 Waystar claims found in Aquilaehr = 97.8%):
Waystar "Claim Number" = ="<Aquilaehr Claim ID> <Aquilaehr Account #>" e.g. ="42756 35055"
-> first token of Waystar Claim Number == Aquilaehr "Claim ID"
Parsing notes: Waystar CSV = skip 7 preamble lines, rows end with a trailing comma (read with index_col=False),
values wrapped like ="..." (strip them). Aquilaehr CSV: one row per note/template, so a Claim ID can repeat.
Report (consolidate.py, runs automatically at the end of mek_recon.py, or alone: `python consolidate.py`):
- Match on Patient Name + Date of Service, per location:
Aquilaehr Patient + Visit Date vs Waystar Patient Name + From Date
= same DOS, same first name, at least one last-name word in common
(handles "Mary Lopez Garcia" vs "LOPEZ, MARY", extra spaces, Jr/Sr)
- Aquilaehr rows grouped to one row per visit (Account # + Patient + Visit Date);
Waystar rows grouped per Patient Name + From Date (Charges summed)
- Output: output/<Location>.xlsx with 2 sheets
- Summary: Aquilaehr visits, Waystar visits, Matched, Missing in Waystar, Missing in Aquilaehr, Duplicate in Waystar
- Reconciliation: one row per visit, Result column:
Missing in Waystar = visit in Aquilaehr, no Waystar claim for that patient + DOS
Missing in Aquilaehr = Waystar claim, no Aquilaehr visit for that patient + DOS
Duplicate in Waystar = 2nd Waystar claim for an already-matched patient + DOS (e.g. two name spellings)
Matched
- Note column: when the same Claim ID exists on the other side under a different date
+5
View File
@@ -0,0 +1,5 @@
playwright==1.49.0
pandas==3.0.3
openpyxl==3.1.5
flask==3.1.0
gunicorn==23.0.0
+151
View File
@@ -0,0 +1,151 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MEK Charge Reconciliation</title>
<style>
/* same night palette as the sign-in page */
:root {
--night: #040b26;
--panel: #0c1638;
--panel-line: #26335e;
--field: #050c28;
--pill-line: #c9d5f2;
--ink: #eef2ff;
--ink-2: #a9b6d8;
--owl: #ec7d33;
--ok: #7fe0a8;
--error: #ff9a9a;
--ui: "Segoe UI Variable Text", "Segoe UI", system-ui, -apple-system, sans-serif;
color-scheme: dark;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--night); color: var(--ink); font: 15px/1.5 var(--ui); }
main { width: min(860px, 100%); margin: 0 auto; padding: 32px 16px 48px; }
header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 20px; }
h1 { margin: 0; font-size: clamp(20px, 5vw, 24px); font-weight: 650; letter-spacing: -.01em; }
h2 { margin: 0 0 12px; font-size: 17px; font-weight: 650; }
.card { background: var(--panel); border: 1px solid var(--panel-line); border-radius: 18px; padding: 22px 22px 20px; margin-bottom: 16px; }
.muted { color: var(--ink-2); }
.pill {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
height: 44px; padding: 0 22px; background: #0a1435; color: #fff; text-decoration: none;
border: 1.5px solid var(--pill-line); border-radius: 999px; font: 600 15px var(--ui); cursor: pointer;
}
.pill:hover { border-color: var(--owl); background: #101d45; }
.pill:focus-visible, a:focus-visible { outline: 2px solid var(--owl); outline-offset: 3px; }
.pill.primary { border-color: var(--owl); }
.pill[disabled] { opacity: .5; cursor: not-allowed; }
.pill.small { height: 34px; padding: 0 14px; font-size: 13px; }
.row { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; }
.status { font-weight: 600; }
.status.running { color: var(--owl); }
.status.ok { color: var(--ok); }
.status.fail { color: var(--error); }
pre {
margin: 14px 0 0; max-height: 280px; overflow: auto; padding: 12px 14px;
background: var(--field); border: 1px solid var(--panel-line); border-radius: 12px;
font: 12.5px/1.45 ui-monospace, Consolas, monospace; white-space: pre-wrap; word-break: break-word;
}
pre:empty { display: none; }
table { width: 100%; border-collapse: collapse; }
td { padding: 9px 4px; border-top: 1px solid var(--panel-line); vertical-align: middle; }
td:last-child { text-align: right; white-space: nowrap; }
.file { font-weight: 600; }
.file small { display: block; font-weight: 400; color: var(--ink-2); }
.empty { color: var(--ink-2); margin: 0; }
form.inline { margin: 0; }
</style>
</head>
<body>
<main>
<header>
<h1>MEK Charge Reconciliation</h1>
<form class="inline" method="post" action="/logout"><button class="pill small" type="submit">Sign out</button></form>
</header>
<section class="card" aria-labelledby="run-h">
<h2 id="run-h">Run</h2>
<p class="muted" style="margin:0 0 14px">Downloads Waystar claims and Aquilaehr encounters for every location at the same time,
then builds one reconciliation report per location (Patient Name + Date of Service).</p>
<div class="row">
<button class="pill primary" id="run" type="button">Run reconciliation</button>
<span class="status" id="status" aria-live="polite"></span>
</div>
<pre id="log" aria-label="Run log"></pre>
</section>
<section class="card" aria-labelledby="dl-h">
<div class="row" style="justify-content:space-between;margin-bottom:6px">
<h2 id="dl-h" style="margin:0">Reports</h2>
<a class="pill small" id="zip" href="/download-all">Download all (ZIP)</a>
</div>
<table><tbody id="files"></tbody></table>
<p class="empty" id="no-files" hidden>No reports yet. Run the reconciliation first.</p>
</section>
</main>
<script>
const runBtn = document.getElementById("run");
const statusEl = document.getElementById("status");
const logEl = document.getElementById("log");
const filesEl = document.getElementById("files");
const noFiles = document.getElementById("no-files");
const zip = document.getElementById("zip");
let timer;
const fmtTime = s => s ? new Date(s * 1000).toLocaleString() : "";
function render(st) {
runBtn.disabled = st.running;
statusEl.className = "status " + (st.running ? "running" : st.code === 0 ? "ok" : st.code == null ? "" : "fail");
statusEl.textContent = st.running ? `Running… started ${fmtTime(st.started)}`
: st.code === 0 ? `Finished ${fmtTime(st.finished)}`
: st.code == null ? "" : `Failed (exit ${st.code}) ${fmtTime(st.finished)}. See the log.`;
const atBottom = logEl.scrollTop + logEl.clientHeight >= logEl.scrollHeight - 8;
logEl.textContent = st.log;
if (atBottom) logEl.scrollTop = logEl.scrollHeight;
filesEl.innerHTML = "";
for (const f of st.files) {
const tr = document.createElement("tr");
const name = document.createElement("td");
name.className = "file";
name.textContent = f.name.replace(/\.xlsx$/, "");
const meta = document.createElement("small");
meta.textContent = `Updated ${fmtTime(f.mtime)} · ${(f.size / 1024).toFixed(0)} KB`;
name.append(meta);
const act = document.createElement("td");
const a = document.createElement("a");
a.className = "pill small";
a.href = "/download/" + encodeURIComponent(f.name);
a.textContent = "Download";
act.append(a);
tr.append(name, act);
filesEl.append(tr);
}
noFiles.hidden = st.files.length > 0;
zip.hidden = st.files.length === 0;
clearTimeout(timer);
timer = setTimeout(refresh, st.running ? 2000 : 15000);
}
async function refresh() {
const res = await fetch("/status");
if (res.status === 401) return location.href = "/";
render(await res.json());
}
runBtn.addEventListener("click", async () => {
runBtn.disabled = true;
const res = await fetch("/run", { method: "POST" });
if (res.status === 409) statusEl.textContent = "A run is already in progress.";
refresh();
});
refresh();
</script>
</body>
</html>
+406
View File
@@ -0,0 +1,406 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in · MEK Charge Reconciliation</title>
<style>
:root {
--night: #040b26;
--panel: #0c1638;
--panel-line: #26335e;
--field: #050c28;
--field-line: #56689c;
--pill-line: #c9d5f2;
--ink: #eef2ff;
--ink-2: #a9b6d8;
--owl: #ec7d33;
--owl-soft: #f5a25a;
--error: #ff9a9a;
--owl-w: 190px;
--ui: "Segoe UI Variable Text", "Segoe UI", system-ui, -apple-system, sans-serif;
color-scheme: dark;
scrollbar-color: var(--panel-line) var(--night);
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
background: var(--night);
color: var(--ink);
font: 15px/1.5 var(--ui);
caret-color: var(--owl);
}
::selection { background: var(--owl); color: var(--night); }
.sky { position: fixed; inset: 0; overflow: hidden; pointer-events: none; }
.sky span {
position: absolute;
width: var(--s);
height: var(--s);
border-radius: 50%;
background: var(--c);
opacity: var(--o);
}
.sky .tw { animation: twinkle 4s ease-in-out infinite; animation-delay: var(--d); }
@keyframes twinkle { 50% { opacity: .15; } }
.stage {
position: relative;
min-height: 100%;
display: flex;
flex-direction: column;
align-items: center;
/* Anchored near the middle rather than centered, so an error line grows the card downward instead of moving the owl. */
padding: max(32px, calc(50vh - 230px)) 16px 48px;
text-align: center;
}
h1 {
margin: 0;
font-size: clamp(20px, 5vw, 23px);
font-weight: 650;
line-height: 1.2;
letter-spacing: -.01em;
text-wrap: balance;
text-align: center;
}
.sub { margin: 4px 0 22px; color: var(--ink-2); text-align: center; }
.card {
position: relative;
width: min(360px, 100%);
margin-top: calc(var(--owl-w) * 50 / 48 + 12px);
padding: 28px 24px 24px;
background: var(--panel);
border: 1px solid var(--panel-line);
border-radius: 18px;
box-shadow: 0 18px 40px rgba(0, 3, 15, .5);
text-align: left;
}
.owl {
position: absolute;
left: 50%;
bottom: calc(100% - 7px);
width: var(--owl-w);
height: auto;
margin-left: calc(var(--owl-w) / -2);
overflow: visible;
}
label { display: block; font-weight: 600; margin-bottom: 8px; }
.field { position: relative; }
input {
width: 100%;
height: 46px;
padding: 0 52px 0 20px;
background: var(--field);
color: var(--ink);
border: 1px solid var(--field-line);
border-radius: 999px;
font: 16px var(--ui);
letter-spacing: .04em;
}
input:focus-visible { outline: 2px solid var(--owl); outline-offset: 2px; border-color: var(--owl); }
input[aria-invalid="true"] { border-color: var(--error); outline-color: var(--error); }
.reveal {
position: absolute;
top: 4px;
right: 4px;
width: 38px;
height: 38px;
display: grid;
place-items: center;
padding: 0;
background: none;
border: 0;
border-radius: 50%;
color: var(--ink-2);
cursor: pointer;
}
.reveal:hover { color: var(--ink); background: rgba(201, 213, 242, .1); }
.reveal:focus-visible { outline: 2px solid var(--owl); outline-offset: 0; color: var(--ink); }
.reveal svg {
width: 20px;
height: 20px;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.note, .error { margin: 8px 0 0; font-size: 14px; }
.note { color: var(--owl-soft); }
.error { color: var(--error); }
.error:empty { margin: 0; }
.pill {
width: 100%;
height: 46px;
margin-top: 20px;
background: #0a1435;
color: #fff;
border: 1.5px solid var(--pill-line);
border-radius: 999px;
font: 600 15px var(--ui);
letter-spacing: .02em;
cursor: pointer;
transition: border-color .15s ease-out, background-color .15s ease-out;
}
.pill:hover { border-color: var(--owl); background: #101d45; }
.pill:active { transform: translateY(1px); }
.pill:focus-visible { outline: 2px solid var(--owl); outline-offset: 3px; }
.done h2 { margin: 0; font-size: 20px; }
.done h2:focus { outline: none; }
.done p { margin: 6px 0 0; color: var(--ink-2); }
/* Owl. Origins are in the SVG's own units (viewBox 0 0 48 50). */
#bob, #head, #glasses, #wing-l, #wing-r { transform-box: view-box; }
#bob { transform-origin: 24px 30px; }
#head { transform-origin: 24px 30px; }
#glasses { transform-origin: 24px 24px; }
#wing-l { transform-origin: 11px 25px; }
#wing-r { transform-origin: 37px 25px; }
.iris { transition: transform .14s ease-out; }
.lid { transform-box: fill-box; transform-origin: 50% 0; transform: scaleY(0); transition: transform .08s ease-out; }
.owl.blink .lid { transform: scaleY(1); }
.smile { transform-box: fill-box; transform-origin: 50% 100%; transform: scaleY(0); transition: transform .12s ease-out; }
.owl.happy .smile { transform: none; }
.owl.idle #bob { animation: breathe 3.2s ease-in-out infinite; }
@keyframes breathe { 50% { transform: translateY(.4px); } }
.owl.sad #head { animation: droop 1.4s ease-in-out; }
@keyframes droop { 20%, 75% { transform: translateY(1.2px) rotate(-6deg); } }
.owl.sad .lid { animation: slow-blink 1.4s ease-in-out; }
@keyframes slow-blink { 30%, 55% { transform: scaleY(1); } }
.eye-rim { transition: stroke .15s ease-out, filter .15s ease-out; }
.owl.peek .eye-rim { stroke: #ffc400; stroke-width: 1.5; filter: drop-shadow(0 0 2px #ffd84d) drop-shadow(0 0 5px #ffb300); }
#glasses rect { transition: fill .15s ease-out, fill-opacity .15s ease-out, filter .15s ease-out; }
.owl.peek #glasses rect { fill: #ffe066; fill-opacity: .45; filter: drop-shadow(0 0 3px #ffc400); }
.owl.tilt #head { animation: tilt .8s ease-in-out; }
@keyframes tilt { 25%, 70% { transform: rotate(-10deg); } }
.owl { cursor: pointer; }
.owl.happy, .owl.jump { animation: hop .75s cubic-bezier(.3, .6, .35, 1); }
@keyframes hop { 40% { transform: translateY(-14px); } 70% { transform: none; } 82% { transform: translateY(-3px); } }
.owl.happy #wing-l, .owl.jump #wing-l { animation: flap-l .75s ease-in-out; }
.owl.happy #wing-r, .owl.jump #wing-r { animation: flap-r .75s ease-in-out; }
@keyframes flap-l { 20%, 60% { transform: rotate(32deg); } 40%, 80% { transform: rotate(8deg); } }
@keyframes flap-r { 20%, 60% { transform: rotate(-32deg); } 40%, 80% { transform: rotate(-8deg); } }
@media (max-width: 420px) {
:root { --owl-w: 160px; }
}
@media (prefers-reduced-motion: reduce) {
.sky .tw, .owl, .owl * { animation: none !important; transition: none !important; }
.pill { transition: none; }
}
</style>
</head>
<body>
<div class="sky" id="sky" aria-hidden="true"></div>
<main class="stage">
<section class="card">
<svg class="owl idle" id="owl" viewBox="0 0 48 50" aria-hidden="true">
<defs>
<radialGradient id="coat" cx="42%" cy="28%" r="78%">
<stop stop-color="#ff990e"/><stop offset=".6" stop-color="#f58200"/><stop offset="1" stop-color="#bd4b00"/>
</radialGradient>
<linearGradient id="wing" x2=".8" y2="1">
<stop stop-color="#ed7b00"/><stop offset="1" stop-color="#a73e00"/>
</linearGradient>
<radialGradient id="glow">
<stop offset="0" stop-color="#ffaa5a" stop-opacity=".35"/><stop offset="1" stop-color="#ffaa5a" stop-opacity="0"/>
</radialGradient>
<clipPath id="eye-l"><ellipse cx="17" cy="20" rx="4" ry="5"/></clipPath>
<clipPath id="eye-r"><ellipse cx="31" cy="20" rx="4" ry="5"/></clipPath>
</defs>
<ellipse cx="24" cy="48.5" rx="17" ry="2.6" fill="url(#glow)"/>
<g stroke="#38200b" stroke-width="1.2" stroke-linejoin="round">
<path id="wing-l" fill="url(#wing)" d="M10 24 Q5 24 4 32 Q3 38 8 39 L12 34Z"/>
<path id="wing-r" fill="url(#wing)" d="M38 24 Q43 24 44 32 Q45 38 40 39 L36 34Z"/>
<path fill="url(#coat)" d="M8 27 C8 18 7 11 13 6 Q17 2 24 2 Q31 2 35 6 C41 11 40 18 40 27 Q42 38 35 43 Q30 47 24 47 Q17 47 13 43 Q6 38 8 27Z"/>
<path fill="none" stroke="#ffbe42" stroke-width="1.25" stroke-linecap="round"
d="M13 33 l1.5 2 l1.5 -2 M19 33 l1.5 2 l1.5 -2 M25 33 l1.5 2 l1.5 -2 M31 33 l1.5 2 l1.5 -2 M16 38 l1.5 2 l1.5 -2 M22 38 l1.5 2 l1.5 -2 M28 38 l1.5 2 l1.5 -2 M20 43 l1.5 1 l1.5 -1 M26 43 l1.5 1 l1.5 -1"/>
<path fill="none" stroke="#ac4904" stroke-width="1" d="M12 8 Q18 6 24 14 Q21 6 18 4 M24 13 Q25 6 30 4 M26 13 Q31 7 36 9"/>
<g id="bob"><g id="head">
<path fill="#ffe8b3" stroke-width="1" d="M24 14 C19 8 11 11 11 18 V26 Q11 30 17 30 Q21 30 24 28 Q27 30 31 30 Q37 30 37 26 V18 C37 11 29 8 24 14Z"/>
<g clip-path="url(#eye-l)">
<ellipse cx="17" cy="20" rx="4" ry="5" fill="#fffdf4" stroke="none"/>
<g class="iris"><ellipse cx="17" cy="21" rx="2.7" ry="3.3" fill="#6b3518" stroke="none"/><ellipse cx="17" cy="21.5" rx="1.8" ry="2.5" fill="#170e08" stroke="none"/><circle cx="16.3" cy="19.5" r="1" fill="#fff" stroke="none"/></g>
<rect class="lid" x="13" y="15" width="8" height="10" fill="#ffe8b3" stroke="none"/>
<g class="smile"><path fill="#ffe8b3" stroke="none" d="M13 21Q17 16.5 21 21L21 25L13 25Z"/><path fill="none" stroke="#6b3518" stroke-width=".7" stroke-linecap="round" d="M13.6 20.6Q17 17 20.4 20.6"/></g>
</g>
<ellipse class="eye-rim" cx="17" cy="20" rx="4" ry="5" fill="none" stroke-width="1"/>
<g clip-path="url(#eye-r)">
<ellipse cx="31" cy="20" rx="4" ry="5" fill="#fffdf4" stroke="none"/>
<g class="iris"><ellipse cx="31" cy="21" rx="2.7" ry="3.3" fill="#6b3518" stroke="none"/><ellipse cx="31" cy="21.5" rx="1.8" ry="2.5" fill="#170e08" stroke="none"/><circle cx="30.3" cy="19.5" r="1" fill="#fff" stroke="none"/></g>
<rect class="lid" x="27" y="15" width="8" height="10" fill="#ffe8b3" stroke="none"/>
<g class="smile"><path fill="#ffe8b3" stroke="none" d="M27 21Q31 16.5 35 21L35 25L27 25Z"/><path fill="none" stroke="#6b3518" stroke-width=".7" stroke-linecap="round" d="M27.6 20.6Q31 17 34.4 20.6"/></g>
</g>
<ellipse class="eye-rim" cx="31" cy="20" rx="4" ry="5" fill="none" stroke-width="1"/>
<g id="glasses" fill="none" stroke="#2a180c" stroke-width="1">
<rect x="12" y="22" width="10" height="6" rx="1.5"/>
<rect x="26" y="22" width="10" height="6" rx="1.5"/>
<path d="M22 23 Q24 21 26 23 M10 21 L12 23 M36 23 L38 21"/>
</g>
<path fill="#eb870a" stroke-width=".8" d="M22 26 Q24 24 26 26 L24 30Z"/>
</g></g>
<path fill="#ed900f" stroke-width="1" d="M16 45 L14 48 Q17 49 20 48 L19 46Z M29 46 L28 48 Q31 49 34 48 L32 45Z"/>
</g>
</svg>
<h1>MEK Charge Reconciliation</h1>
<p class="sub" id="sub">Sign in</p>
<form id="login" method="post" action="/login" novalidate>
<label for="password">Password</label>
<div class="field">
<input id="password" name="password" type="password" autocomplete="current-password" autofocus
aria-describedby="password-msg caps" required>
<button type="button" class="reveal" id="reveal" aria-label="Show password" aria-pressed="false"></button>
</div>
<p class="note" id="caps" aria-live="polite" hidden>Caps Lock is on</p>
<p class="error" id="password-msg" role="alert"></p>
<button type="submit" class="pill">Sign in</button>
</form>
</section>
</main>
<script>
// Password is checked on the server (APP_PASSWORD); nothing secret lives in this page.
const EYE = '<path d="M2.5 12s3.5-6.5 9.5-6.5 9.5 6.5 9.5 6.5-3.5 6.5-9.5 6.5S2.5 12 2.5 12z"/><circle cx="12" cy="12" r="2.8"/>';
const EYE_OFF = '<path d="M4 4l16 16"/><path d="M9.9 5.7A10 10 0 0 1 12 5.5c6 0 9.5 6.5 9.5 6.5a16 16 0 0 1-2.6 3.4M6.7 7.2C4 9 2.5 12 2.5 12s3.5 6.5 9.5 6.5a9.6 9.6 0 0 0 4.7-1.2"/><path d="M10 10.1a2.8 2.8 0 0 0 3.9 3.9"/>';
const owl = document.getElementById("owl");
const form = document.getElementById("login");
const pw = document.getElementById("password");
const reveal = document.getElementById("reveal");
const caps = document.getElementById("caps");
const msg = document.getElementById("password-msg");
const irises = owl.querySelectorAll(".iris");
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)");
// Seeded so the stars stay put; none behind the text, owl or card.
function drawSky() {
let seed = 7;
const rand = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
const colors = ["#6ea8ff", "#4fd1c5", "#f29c4b", "#dfe7ff"];
const keepOut = [...document.querySelectorAll(".card, .owl")].map(e => e.getBoundingClientRect());
let html = "";
for (let i = 0; i < 60; i++) {
const tw = rand() < .35, x = rand() * innerWidth, y = rand() * innerHeight;
const size = (1.5 + rand() * 1.5).toFixed(1), color = colors[Math.floor(rand() * 4)];
const alpha = (.45 + rand() * .45).toFixed(2), delay = (rand() * 4).toFixed(2);
if (keepOut.some(r => x > r.left - 10 && x < r.right + 10 && y > r.top - 10 && y < r.bottom + 10)) continue;
html += `<span class="${tw ? "tw" : ""}" style="left:${x.toFixed(0)}px;top:${y.toFixed(0)}px;` +
`--s:${size}px;--c:${color};--o:${alpha};--d:${delay}s"></span>`;
}
document.getElementById("sky").innerHTML = html;
}
drawSky();
addEventListener("resize", drawSky);
function look(dx, dy) {
irises.forEach(g => { g.style.transform = `translate(${dx}px, ${dy}px)`; });
}
// Pupils slide from the left end of the field to the right as the typed text grows.
const measure = document.createElement("canvas").getContext("2d");
function followCaret() {
if (document.activeElement !== pw) return look(0, 0);
const cs = getComputedStyle(pw);
measure.font = cs.font;
measure.letterSpacing = cs.letterSpacing;
const caret = pw.selectionStart ?? pw.value.length;
const shown = pw.type === "password" ? "•".repeat(caret) : pw.value.slice(0, caret);
const room = pw.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
const ratio = Math.min(measure.measureText(shown).width / room, 1);
look(-1.2 + 2.4 * ratio, 1.2);
}
const timers = {};
function play(name, ms, then) {
clearTimeout(timers[name]);
owl.classList.remove(name);
void owl.getBoundingClientRect();
owl.classList.add(name);
timers[name] = setTimeout(() => { owl.classList.remove(name); then?.(); }, ms);
}
// Cleared first so a repeated identical message is announced again.
function showError(text) {
msg.textContent = "";
pw.setAttribute("aria-invalid", text ? "true" : "false");
if (text) setTimeout(() => { msg.textContent = text; }, 50);
}
function setReveal(show) {
pw.type = show ? "text" : "password";
reveal.setAttribute("aria-pressed", String(show));
reveal.setAttribute("aria-label", show ? "Hide password" : "Show password");
owl.classList.toggle("peek", show);
reveal.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true">${show ? EYE_OFF : EYE}</svg>`;
}
setReveal(false);
for (const ev of ["focus", "blur", "input", "keyup", "click", "select"]) pw.addEventListener(ev, followCaret);
pw.addEventListener("input", () => { if (msg.textContent) showError(""); });
for (const ev of ["keydown", "keyup"]) {
pw.addEventListener(ev, e => { caps.hidden = !e.getModifierState?.("CapsLock"); });
}
pw.addEventListener("blur", () => { caps.hidden = true; });
// Keep focus (and the owl's gaze) in the field when the eye button is clicked.
reveal.addEventListener("mousedown", e => e.preventDefault());
reveal.addEventListener("click", () => { setReveal(pw.type === "password"); followCaret(); });
form.addEventListener("submit", async e => {
e.preventDefault();
if (!pw.value) {
showError("Enter your password.");
play("tilt", 800);
pw.focus();
return;
}
let res;
try {
res = await fetch("/login", { method: "POST", body: new FormData(form) });
} catch {
showError("Can't reach the server. Try again.");
return;
}
if (!res.ok) {
showError(res.status === 401 ? "That password isn't right. Try again." : "Sign-in failed. Try again.");
pw.focus();
pw.select();
play("sad", 1400, followCaret);
return;
}
showError("");
pw.value = "";
setReveal(false);
const enter = () => { location.href = "/"; };
if (reducedMotion.matches) enter();
else play("happy", 750, enter);
});
// Tap the owl and it jumps.
owl.addEventListener("click", () => { if (!reducedMotion.matches) play("jump", 750); });
(function blink() {
setTimeout(() => {
if (!reducedMotion.matches && !owl.classList.contains("happy") && !owl.classList.contains("sad")) play("blink", 160);
blink();
}, 2500 + Math.random() * 3500);
})();
</script>
</body>
</html>