Files
DentalManagement09/apps/SeleniumService/selenium_MetLife_claimSubmitWorker.py
T
GiteadandClaude Opus 5.5 a5beeb7ba9 feat: MetLife Dental claim submission via ClaimConnect; MetLife fee schedule
Reuses the MetLife eligibility login (persistent profile, email MFA, OTP
from app or browser), then Submit Claims -> ClaimConnect -> DentalXChange
Single Claim: billing/rendering provider, member ID, last name, payer
Metlife 65978, patient picked by DOB/name, signature on file Yes, one
claim item per CDT code (date, code, tooth, surfaces, fee; Add Claim Item
past 3), Finish, Submit Claim, then Back to Claim to read the Claim ID and
save the page as PDF. Waits out Wicket ajax/veil before each click.

MetLife now has its own fee schedule (seeded from DDMA) instead of falling
back to MassHealth prices.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 00:01:39 -04:00

679 lines
34 KiB
Python

from selenium.common.exceptions import (
TimeoutException, StaleElementReferenceException, ElementClickInterceptedException,
)
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support.ui import WebDriverWait, Select
from selenium.webdriver.support import expected_conditions as EC
import base64
import os
import re
import time
from selenium_MetLife_eligibilityCheckWorker import AutomationMetLifeEligibilityCheck, METLIFE_HOME_URL
# Home page card link: <a id="document-forms-link" href="javascript:;">Submit Claims</a>
SUBMIT_CLAIMS_LINK_XPATH = "//a[@id='document-forms-link']"
# Submit Claims page: <button id="claims-submission-button">ClaimConnect</button>
CLAIMCONNECT_BUTTON_XPATH = "//button[@id='claims-submission-button']"
# "Leaving MetLife Website" modal: <button id="confirmation-modal-primary-button">Continue</button>
LEAVING_CONTINUE_BUTTON_XPATH = "//button[@id='confirmation-modal-primary-button']"
# DentalXChange dashboard (register.dentalxchange.com/reg/dashboard), Claims card:
# <span class="quickLinkContent"><span>Single Claim</span>…</span> — the click bubbles to its link
SINGLE_CLAIM_LINK_XPATH = "//span[contains(@class,'quickLinkContent')][span[normalize-space()='Single Claim']]"
# DentalXChange "Start New Claim" (Wicket app — ids like id11/id12 change, so match on
# placeholder text / labels / names). Dropdowns are Select2 v3.
def _select2_container_xpath(placeholder):
return (
"//div[contains(@class,'select2-container')]"
f"[.//span[contains(@class,'select2-chosen')][normalize-space()='{placeholder}']]"
)
BILLING_SELECT_XPATH = _select2_container_xpath("Select Billing Provider")
RENDERING_SELECT_XPATH = _select2_container_xpath("Select Rendering Provider")
PAYER_SELECT_XPATH = _select2_container_xpath("Type Payer Name")
# Open Select2 dropdown (only one is open at a time)
SELECT2_DROP_XPATH = "//div[@id='select2-drop' and not(contains(@style,'display: none'))]"
SELECT2_RESULT_XPATH = SELECT2_DROP_XPATH + "//li[contains(@class,'select2-result-selectable')]"
SELECT2_SEARCH_INPUT_XPATH = SELECT2_DROP_XPATH + "//input[contains(@class,'select2-input')]"
MEMBER_ID_INPUT_XPATH = "//*[contains(normalize-space(text()),'Member ID or SSN')]/following::input[@type='text'][1]"
LAST_NAME_INPUT_XPATH = "//input[contains(@name,'enterPatientContainer:patient.address.lastName')]"
NEXT_BUTTON_XPATH = "//button[@name='buttons:next']"
# MetLife payer in the DentalXChange payer list ("Metlife - 65978"; not "Metlife Tricare - 89070")
METLIFE_PAYER_SEARCH = "metlife"
METLIFE_PAYER_ID = "65978"
# "Select Patient" page: one row per covered person, Name cell is a link
# (Name | Relationship | Date of Birth | Group/Policy # | Status)
PATIENT_ROW_XPATH = "//table//tr[td and .//a]"
# "Eligible Patient Review" page: <span>Create Claim</span> inside the action button
CREATE_CLAIM_BUTTON_XPATH = "//*[self::button or self::a][normalize-space(.)='Create Claim']"
# "Claim Detail" page — Signature on file radios (each click fires a Wicket ajax call)
RELEASE_YES_RADIO_ID = "releaseYesButton"
ASSIGNMENT_YES_RADIO_ID = "assignmentYesButton"
# "Claim Item Detail" page — one panel per procedure line (3 shown by default).
# Field names are stable apart from the line index; every field posts a Wicket ajax
# call on change/blur, which can re-render the line, so always re-find by name.
ITEM_PREFIX = "view:formPanel:claimForm:claimItemListContainer:claimItemList:{i}:claimItemPanel:claimItemForm:"
ITEM_DATE_NAME = ITEM_PREFIX + "startDateInput"
ITEM_PROC_NAME = ITEM_PREFIX + "procCode"
ITEM_FEE_NAME = ITEM_PREFIX + "feeInput"
ITEM_TOOTH_NAME = ITEM_PREFIX + "claimToothListViewContainer:claimToothListView:0:tooth"
ADD_CLAIM_ITEM_BUTTON_XPATH = "//button[@name='view:formPanel:claimForm:add']"
FINISH_BUTTON_XPATH = "//button[@name='buttons:finish']"
# The 5 Surfaces dropdowns sit right after the Tooth box; options are
# Buccal, Distal, Facial, Incisal, Lingual, Mesial, Occlusal (first letter = code)
MAX_SURFACES = 5
# Review page after Finish: <button type="button" id="id116">Submit Claim</button> (ajax;
# id changes). While any ajax call runs, Wicket shows the full-page #ajax-indicator-veil.
SUBMIT_CLAIM_BUTTON_XPATH = "//button[normalize-space(.)='Submit Claim']"
AJAX_VEIL_ID = "ajax-indicator-veil"
# Confirmation page: "Submitted to MetLife" banner + <button>Back to Claim</button>
BACK_TO_CLAIM_BUTTON_XPATH = "//button[normalize-space(.)='Back to Claim']"
# agent.py serves this folder at /downloads so the backend can fetch the PDF
SHARED_DOWNLOADS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "downloads")
# True when no Wicket ajax request is in flight (Wicket 1.4/1.5 channel manager)
WICKET_IDLE_JS = r"""
try {
if (typeof Wicket === 'undefined' || !Wicket.channelManager) return true;
var ch = Wicket.channelManager.channels || {};
for (var k in ch) { if (ch[k] && ch[k].busy) return false; }
return true;
} catch (e) { return true; }
"""
class AutomationMetLifeClaimSubmit(AutomationMetLifeEligibilityCheck):
"""
MetLife Dental claim submission on dentalprovider.metlife.com.
Login (persistent Chrome profile, PingOne + email MFA) is inherited from the
eligibility worker so both flows share one session.
"""
def __init__(self, data):
claim = data.get("claim", {}) if isinstance(data, dict) else {}
super().__init__({"data": claim})
self.claim = claim
self.serviceDate = claim.get("serviceDate", "")
self.serviceLines = claim.get("serviceLines", []) or []
self.claimFiles = claim.get("claimFiles", []) or []
self.providerName = claim.get("providerName", "")
self.remarks = claim.get("remarks", "")
# Claim payload carries the patient name as one field
if not (self.firstName or self.lastName):
parts = (claim.get("patientName", "") or "").split()
if parts:
self.firstName = parts[0]
self.lastName = " ".join(parts[1:])
def _click(self, xpath, label, timeout=20):
"""Click the first visible element matching `xpath`."""
WebDriverWait(self.driver, timeout).until(
lambda d: any(e.is_displayed() for e in d.find_elements(By.XPATH, xpath))
)
el = next(e for e in self.driver.find_elements(By.XPATH, xpath) if e.is_displayed())
try:
el.click()
except Exception:
# href="javascript:;" / overlay — the React onClick does the navigation
self.driver.execute_script("arguments[0].click();", el)
print(f"[MetLife Claim] Clicked '{label}'")
def _click_and_follow(self, xpath, label, timeout=20):
"""
Click the first visible element matching `xpath`, then wait for the page to
change. If the click opened a new tab, switch to it.
"""
start_url = self.driver.current_url
start_handles = set(self.driver.window_handles)
self._click(xpath, label, timeout)
# The click may route inside the SPA or open a new tab
try:
WebDriverWait(self.driver, 15).until(
lambda d: d.current_url != start_url or len(d.window_handles) > len(start_handles)
)
except TimeoutException:
print(f"[MetLife Claim] No navigation after '{label}'; still on {self.driver.current_url}")
new_handles = [h for h in self.driver.window_handles if h not in start_handles]
if new_handles:
self.driver.switch_to.window(new_handles[-1])
print(f"[MetLife Claim] '{label}' opened a new tab — switched to it")
time.sleep(2)
print(f"[MetLife Claim] After '{label}': {self.driver.current_url}")
def step1_open_submit_claims(self):
"""From the home page, click "Submit Claims"."""
try:
if "/home" not in self.driver.current_url:
self.driver.get(METLIFE_HOME_URL)
self._click_and_follow(SUBMIT_CLAIMS_LINK_XPATH, "Submit Claims")
return "Success"
except TimeoutException:
return "ERROR: 'Submit Claims' link not found on MetLife home page"
except Exception as e:
return f"ERROR: Could not open Submit Claims: {e}"
def step2_open_claimconnect(self):
"""
On the Submit Claims page, click "ClaimConnect", then "Continue" on the
"Leaving MetLife Website" modal.
"""
try:
self._click(CLAIMCONNECT_BUTTON_XPATH, "ClaimConnect")
except TimeoutException:
return "ERROR: 'ClaimConnect' button not found on MetLife Submit Claims page"
try:
self._click_and_follow(LEAVING_CONTINUE_BUTTON_XPATH, "Continue (Leaving MetLife)", timeout=10)
return "Success"
except TimeoutException:
return "ERROR: 'Leaving MetLife Website' Continue button not found"
except Exception as e:
return f"ERROR: Could not open ClaimConnect: {e}"
def step3_open_single_claim(self):
"""On the DentalXChange dashboard (new tab), click "Single Claim"."""
try:
self._click_and_follow(SINGLE_CLAIM_LINK_XPATH, "Single Claim", timeout=30)
return "Success"
except TimeoutException:
return f"ERROR: 'Single Claim' link not found on DentalXChange dashboard ({self.driver.current_url})"
except Exception as e:
return f"ERROR: Could not open Single Claim: {e}"
# ── DentalXChange Select2 helpers ─────────────────────────────────────────
def _select2_open(self, container_xpath, label, timeout=20):
container = WebDriverWait(self.driver, timeout).until(
EC.presence_of_element_located((By.XPATH, container_xpath))
)
choice = container.find_element(By.XPATH, ".//a[contains(@class,'select2-choice')]")
try:
choice.click()
except Exception:
self.driver.execute_script(
"arguments[0].dispatchEvent(new MouseEvent('mousedown', {bubbles: true}));", choice
)
WebDriverWait(self.driver, 10).until(EC.visibility_of_element_located((By.XPATH, SELECT2_DROP_XPATH)))
print(f"[MetLife Claim] Opened '{label}' dropdown")
def _select2_pick(self, label, match=None, timeout=15):
"""Click the first open-dropdown result (or the first whose text contains `match`)."""
def find_result(d):
for li in d.find_elements(By.XPATH, SELECT2_RESULT_XPATH):
if li.is_displayed() and (match is None or match.lower() in li.text.lower()):
return li
return False
li = WebDriverWait(self.driver, timeout).until(find_result)
text = li.text.strip()
li.click()
print(f"[MetLife Claim] {label}: picked '{text}'")
# Wicket re-renders parts of the form after a selection
time.sleep(1.5)
return text
def _select2_choose(self, container_xpath, label, search=None, match=None):
self._select2_open(container_xpath, label)
if search:
box = WebDriverWait(self.driver, 10).until(
EC.visibility_of_element_located((By.XPATH, SELECT2_SEARCH_INPUT_XPATH))
)
box.send_keys(search)
return self._select2_pick(label, match)
def _fill_text(self, xpath, value, label):
el = WebDriverWait(self.driver, 15).until(EC.visibility_of_element_located((By.XPATH, xpath)))
el.clear()
el.send_keys(value)
el.send_keys(Keys.TAB)
print(f"[MetLife Claim] Filled {label}")
time.sleep(0.5)
def _click_next(self, page, xpath=NEXT_BUTTON_XPATH, label="Next"):
"""
Click a wizard button ("Next" by default) and wait for the next page.
Returns "Success", or "ERROR: …" with the page's validation messages.
"""
# The last field's ajax call (e.g. Fee) may still be in flight and overlay the page
self._wait_wicket_idle()
try:
self._wait_veil_gone(timeout=30)
except TimeoutException:
print(f"[MetLife Claim] Ajax veil still showing before '{label}'")
next_btn = WebDriverWait(self.driver, 10).until(
EC.element_to_be_clickable((By.XPATH, xpath))
)
self.driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", next_btn)
time.sleep(0.5)
try:
next_btn.click()
except ElementClickInterceptedException:
# Something overlays the button (e.g. date picker / page footer) — the
# native click never landed, so a script click submits exactly once
print(f"[MetLife Claim] '{label}' click intercepted — clicking via script")
self.driver.execute_script("arguments[0].click();", next_btn)
print(f"[MetLife Claim] Clicked '{label}' on {page}")
try:
WebDriverWait(self.driver, 30).until(EC.staleness_of(next_btn))
except TimeoutException:
# Still on the page — surface any validation message (e.g. "Member ID is required.")
errors = [e.text.strip() for e in self.driver.find_elements(
By.XPATH, "//span[contains(@class,'feedbacklabel') and contains(@class,'ERROR')]"
) if e.text.strip()]
if errors:
return f"ERROR: DentalXChange {page}: {'; '.join(errors)}"
time.sleep(2)
print(f"[MetLife Claim] After {page}: {self.driver.current_url}")
return "Success"
def step4_start_new_claim(self):
"""
Start New Claim page: Billing + Rendering provider (first entry), Member ID,
patient last name, payer "Metlife - 65978", then Next.
"""
member_id = re.sub(r"[\s-]", "", self.memberId or "")
if not member_id:
return "ERROR: Member ID is required for MetLife claim"
try:
self._select2_choose(BILLING_SELECT_XPATH, "Billing provider")
self._select2_choose(RENDERING_SELECT_XPATH, "Rendering provider")
self._fill_text(MEMBER_ID_INPUT_XPATH, member_id, "Member ID")
if self.lastName:
self._fill_text(LAST_NAME_INPUT_XPATH, self.lastName, "Last Name")
self._select2_choose(PAYER_SELECT_XPATH, "Payer", search=METLIFE_PAYER_SEARCH, match=METLIFE_PAYER_ID)
return self._click_next("Start New Claim")
except TimeoutException as e:
return f"ERROR: DentalXChange Start New Claim — element not found ({e.msg or 'timeout'})"
except Exception as e:
return f"ERROR: DentalXChange Start New Claim failed: {e}"
@staticmethod
def _mmddyyyy(value):
"""YYYY-MM-DD[...] or MM/DD/YYYY as MM/DD/YYYY ("" if unparseable)."""
v = str(value or "").strip()
m = re.match(r"(\d{4})-(\d{1,2})-(\d{1,2})", v)
if m:
return f"{m.group(2).zfill(2)}/{m.group(3).zfill(2)}/{m.group(1)}"
m = re.match(r"(\d{1,2})/(\d{1,2})/(\d{4})", v)
if m:
return f"{m.group(1).zfill(2)}/{m.group(2).zfill(2)}/{m.group(3)}"
return ""
def _expected_dob(self):
return self._mmddyyyy(self.dateOfBirth)
def step5_select_patient(self):
"""
"Select Patient" page: click the covered person matching the claim's patient
(DOB first, then name). Then "Create Claim" on "Eligible Patient Review".
"""
try:
# Either the patient list, or straight to the review page (single match)
WebDriverWait(self.driver, 30).until(
lambda d: d.find_elements(By.XPATH, PATIENT_ROW_XPATH)
or d.find_elements(By.XPATH, CREATE_CLAIM_BUTTON_XPATH)
)
rows = [r for r in self.driver.find_elements(By.XPATH, PATIENT_ROW_XPATH) if r.is_displayed()]
if rows and not self.driver.find_elements(By.XPATH, CREATE_CLAIM_BUTTON_XPATH):
expected_dob = self._expected_dob()
first = (self.firstName or "").strip().upper()
last = (self.lastName or "").strip().upper()
parsed = []
for r in rows:
cells = [c.text.strip() for c in r.find_elements(By.TAG_NAME, "td")]
name = cells[0].upper() if cells else ""
dob = next((c for c in cells if re.fullmatch(r"\d{2}/\d{2}/\d{4}", c)), "")
parsed.append((r, name, dob))
print(f"[MetLife Claim step5] Row: {cells}")
def name_match(name):
return (not first or first in name) and (not last or last in name)
matches = [p for p in parsed if expected_dob and p[2] == expected_dob]
if len(matches) > 1:
matches = [p for p in matches if name_match(p[1])] or matches
if not matches:
matches = [p for p in parsed if name_match(p[1])]
if not matches:
listed = ", ".join(f"{p[1]} ({p[2]})" for p in parsed)
return (f"ERROR: Patient {first} {last} ({expected_dob}) not listed by MetLife. "
f"Covered: {listed}")
row, name, dob = matches[0]
link = row.find_element(By.XPATH, ".//a")
try:
link.click()
except Exception:
self.driver.execute_script("arguments[0].click();", link)
print(f"[MetLife Claim step5] Selected patient {name} ({dob})")
create_btn = WebDriverWait(self.driver, 30).until(
EC.element_to_be_clickable((By.XPATH, CREATE_CLAIM_BUTTON_XPATH))
)
create_btn.click()
print("[MetLife Claim step5] Clicked 'Create Claim'")
try:
WebDriverWait(self.driver, 30).until(EC.staleness_of(create_btn))
except TimeoutException:
print("[MetLife Claim step5] Page did not change after 'Create Claim'")
time.sleep(2)
print(f"[MetLife Claim step5] After Create Claim: {self.driver.current_url}")
return "Success"
except TimeoutException:
return f"ERROR: DentalXChange Select Patient / Create Claim not found ({self.driver.current_url})"
except Exception as e:
return f"ERROR: DentalXChange Select Patient failed: {e}"
def _check_radio(self, radio_id, label):
"""Select a radio by id and confirm it stuck (Wicket ajax may re-render it)."""
for attempt in range(3):
radio = WebDriverWait(self.driver, 20).until(
EC.presence_of_element_located((By.ID, radio_id))
)
if radio.is_selected():
print(f"[MetLife Claim] {label}: Yes")
return True
try:
radio.click()
except Exception:
self.driver.execute_script("arguments[0].click();", radio)
time.sleep(1.5) # let the ajax round-trip finish
radio = self.driver.find_element(By.ID, radio_id)
if radio.is_selected():
print(f"[MetLife Claim] {label}: Yes")
return True
return False
def step6_claim_detail(self):
"""
Claim Detail page: Signature on file — Release of information Yes,
Assignment of payment Yes — then Next.
"""
try:
if not self._check_radio(RELEASE_YES_RADIO_ID, "Release of information by provider"):
return "ERROR: Could not select 'Yes' for Release of information by provider"
if not self._check_radio(ASSIGNMENT_YES_RADIO_ID, "Assignment of payment to provider"):
return "ERROR: Could not select 'Yes' for Assignment of payment to provider"
return self._click_next("Claim Detail")
except TimeoutException:
return f"ERROR: DentalXChange Claim Detail page not found ({self.driver.current_url})"
except Exception as e:
return f"ERROR: DentalXChange Claim Detail failed: {e}"
# ── Claim Item Detail ─────────────────────────────────────────────────────
def _by_name(self, name, timeout=15):
return WebDriverWait(self.driver, timeout).until(
EC.presence_of_element_located((By.NAME, name))
)
def _wait_wicket_idle(self, timeout=15):
"""Wait for in-flight Wicket ajax calls (they re-render the line) to finish."""
time.sleep(0.3)
try:
WebDriverWait(self.driver, timeout).until(lambda d: d.execute_script(WICKET_IDLE_JS))
except TimeoutException:
print("[MetLife Claim step7] Wicket ajax still busy — continuing")
time.sleep(0.3)
@staticmethod
def _same_value(actual, expected):
"""Field value matches what was typed (fees compared as numbers, "$90.00" == "90")."""
try:
return abs(float(re.sub(r"[$,\s]", "", actual)) - float(expected)) < 0.005
except ValueError:
return re.sub(r"[^A-Z0-9]", "", actual.upper()) == re.sub(r"[^A-Z0-9]", "", expected.upper())
def _set_item_field(self, name, value, label):
"""
Type into a line field and tab out so its Wicket onchange/onblur fires.
The previous field's ajax call can re-render the line mid-typing, so wait
for it, retry on a stale element, and verify the value stuck.
"""
for attempt in range(1, 4):
self._wait_wicket_idle()
try:
el = self._by_name(name)
self.driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", el)
try:
el.click()
el.send_keys(Keys.CONTROL, "a")
el.send_keys(Keys.BACKSPACE)
el.send_keys(value)
el.send_keys(Keys.TAB)
except StaleElementReferenceException:
raise
except Exception as type_err:
# Not interactable — set it directly and fire the inline onchange/onblur
print(f"[MetLife Claim step7] {label}: typing failed ({type_err}); setting via JS")
self.driver.execute_script(
"arguments[0].value = arguments[1];"
"arguments[0].dispatchEvent(new Event('change', {bubbles: true}));"
"arguments[0].dispatchEvent(new Event('blur'));"
"if (arguments[0].onchange) arguments[0].onchange();"
"if (arguments[0].onblur) arguments[0].onblur();",
el, value,
)
self._wait_wicket_idle()
actual = (self._by_name(name).get_attribute("value") or "").strip()
if self._same_value(actual, value):
print(f"[MetLife Claim step7] {label} = {value}")
return
print(f"[MetLife Claim step7] {label}: expected {value!r}, field shows {actual!r} (attempt {attempt})")
except StaleElementReferenceException:
print(f"[MetLife Claim step7] {label}: field re-rendered (attempt {attempt}) — retrying")
raise RuntimeError(f"{label}: could not set {value!r}")
def _set_item_surfaces(self, i, surfaces):
tooth = self._by_name(ITEM_TOOTH_NAME.format(i=i))
selects = tooth.find_elements(By.XPATH, f"./following::select[position()<={MAX_SURFACES}]")
for n, code in enumerate(surfaces[:MAX_SURFACES]):
if n >= len(selects):
return f"ERROR: Line {i + 1}: only {len(selects)} surface dropdowns found"
sel = Select(selects[n])
option = next((o for o in sel.options
if (o.get_attribute("value") or "").upper() == code
or (o.text or "").strip().upper().startswith(code)), None)
if option is None:
return f"ERROR: Line {i + 1}: surface '{code}' not in dropdown"
sel.select_by_visible_text(option.text)
print(f"[MetLife Claim step7] Line {i + 1} surface{n + 1} = {option.text.strip()}")
time.sleep(0.5)
# Re-find after a possible re-render
tooth = self._by_name(ITEM_TOOTH_NAME.format(i=i))
selects = tooth.find_elements(By.XPATH, f"./following::select[position()<={MAX_SURFACES}]")
return None
def _ensure_item(self, i):
"""Click "Add Claim Item" until line index `i` exists."""
for _ in range(3):
if self.driver.find_elements(By.NAME, ITEM_DATE_NAME.format(i=i)):
return True
btn = WebDriverWait(self.driver, 10).until(
EC.element_to_be_clickable((By.XPATH, ADD_CLAIM_ITEM_BUTTON_XPATH))
)
btn.click()
print(f"[MetLife Claim step7] Clicked 'Add Claim Item' for line {i + 1}")
try:
self._by_name(ITEM_DATE_NAME.format(i=i), timeout=10)
return True
except TimeoutException:
continue
return False
def step7_fill_claim_items(self):
"""Claim Item Detail page: one line per procedure code, then Finish."""
lines = [l for l in self.serviceLines if str(l.get("procedureCode") or "").strip()]
if not lines:
return "ERROR: No procedure codes on the claim"
try:
self._by_name(ITEM_DATE_NAME.format(i=0), timeout=30)
for i, line in enumerate(lines):
code = str(line.get("procedureCode") or "").strip().upper()
date = self._mmddyyyy(line.get("procedureDate")) or self._mmddyyyy(self.serviceDate)
# Tooth box takes 2 characters (1-32, A-T); drop "#", spaces, etc.
tooth = re.sub(r"[^A-Za-z0-9]", "", str(line.get("toothNumber") or "")).upper()
surfaces = [c for c in str(line.get("toothSurface") or "").upper() if c.isalpha()]
billed = line.get("totalBilled") or line.get("billedAmount") or line.get("fee")
try:
fee = f"{float(billed):.2f}" if billed not in (None, "") else ""
except (TypeError, ValueError):
fee = ""
print(f"[MetLife Claim step7] Line {i + 1}: code={code} date={date} "
f"tooth={tooth!r} surf={surfaces} fee={fee}")
if not date:
return f"ERROR: Line {i + 1} ({code}): no service date"
if not self._ensure_item(i):
return f"ERROR: Could not add claim item line {i + 1}"
self._set_item_field(ITEM_DATE_NAME.format(i=i), date, f"Line {i + 1} date")
self._set_item_field(ITEM_PROC_NAME.format(i=i), code, f"Line {i + 1} procedure")
if tooth:
self._set_item_field(ITEM_TOOTH_NAME.format(i=i), tooth, f"Line {i + 1} tooth")
if surfaces:
err = self._set_item_surfaces(i, surfaces)
if err:
return err
if fee:
self._set_item_field(ITEM_FEE_NAME.format(i=i), fee, f"Line {i + 1} fee")
return self._click_next("Claim Item Detail", FINISH_BUTTON_XPATH, "Finish")
except TimeoutException:
return f"ERROR: DentalXChange Claim Item Detail field not found ({self.driver.current_url})"
except Exception as e:
return f"ERROR: DentalXChange Claim Item Detail failed: {e}"
def _wait_veil_gone(self, timeout=60):
"""Wait until Wicket's full-page ajax veil is hidden (it blocks clicks)."""
def gone(d):
veils = d.find_elements(By.ID, AJAX_VEIL_ID)
return not veils or not veils[0].is_displayed()
WebDriverWait(self.driver, timeout).until(gone)
def step8_submit_claim(self):
"""Review page: click "Submit Claim" once and wait for the portal's response."""
try:
self._wait_wicket_idle()
self._wait_veil_gone()
btn = WebDriverWait(self.driver, 30).until(
EC.element_to_be_clickable((By.XPATH, SUBMIT_CLAIM_BUTTON_XPATH))
)
self.driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", btn)
time.sleep(0.5)
try:
btn.click()
except ElementClickInterceptedException:
# The native click never landed, so a script click submits exactly once
print("[MetLife Claim step8] 'Submit Claim' click intercepted — clicking via script")
self.driver.execute_script("arguments[0].click();", btn)
print("[MetLife Claim step8] Clicked 'Submit Claim'")
# Submission is an ajax call: wait for the veil to clear and the button to go away
time.sleep(1)
self._wait_veil_gone(timeout=90)
self._wait_wicket_idle(timeout=60)
try:
WebDriverWait(self.driver, 30).until(
lambda d: not any(b.is_displayed() for b in d.find_elements(By.XPATH, SUBMIT_CLAIM_BUTTON_XPATH))
)
except TimeoutException:
errors = [e.text.strip() for e in self.driver.find_elements(
By.XPATH, "//span[contains(@class,'feedbacklabel') and contains(@class,'ERROR')]"
" | //*[contains(@class,'feedbackPanelERROR') or contains(@class,'alert-error')]"
) if e.text.strip()]
if errors:
return f"ERROR: DentalXChange Submit Claim: {'; '.join(errors)}"
return "ERROR: 'Submit Claim' did not complete — check the browser window before resubmitting"
time.sleep(2)
print(f"[MetLife Claim step8] After Submit Claim: {self.driver.current_url}")
return "Success"
except TimeoutException:
return f"ERROR: DentalXChange review page — 'Submit Claim' not found ({self.driver.current_url})"
except Exception as e:
return f"ERROR: DentalXChange Submit Claim failed: {e}"
def _extract_claim_id(self):
"""Claim ID from the claim page ("Claim ID 736537870"), else from the URL path."""
try:
text = self.driver.execute_script("return document.body.innerText;") or ""
except Exception:
text = ""
m = re.search(r"Claim\s*ID\s*[:#]?\s*(\d{6,})", text, re.IGNORECASE)
if m:
return m.group(1)
# Claim pages live at …/claim/<claimId>?<n>-<n>
m = re.search(r"/(\d{6,})(?:\?|$)", self.driver.current_url)
return m.group(1) if m else None
def _save_page_pdf(self, claim_id):
os.makedirs(SHARED_DOWNLOADS_DIR, exist_ok=True)
safe_member = "".join(c for c in str(self.memberId) if c.isalnum() or c in "-_.")
safe_claim = f"_{claim_id}" if claim_id else ""
timestamp = time.strftime("%Y%m%d_%H%M%S")
pdf_path = os.path.join(SHARED_DOWNLOADS_DIR, f"metlife_claim_{safe_member}{safe_claim}_{timestamp}.pdf")
pdf_data = self.driver.execute_cdp_cmd("Page.printToPDF", {
"printBackground": True,
"paperWidth": 8.5,
"paperHeight": 11,
"marginTop": 0.4,
"marginBottom": 0.4,
"marginLeft": 0.4,
"marginRight": 0.4,
})
with open(pdf_path, "wb") as f:
f.write(base64.b64decode(pdf_data["data"]))
print(f"[MetLife Claim step9] PDF saved: {pdf_path}")
return pdf_path
def step9_save_claim(self):
"""
Confirmation page ("Submitted to MetLife"): click "Back to Claim", read the
Claim ID and save the claim page as a PDF.
Returns {"claimNumber", "pdf_path"} or "ERROR: …".
"""
try:
self._wait_veil_gone(timeout=30)
try:
WebDriverWait(self.driver, 30).until(
lambda d: "Submitted to MetLife" in (d.execute_script("return document.body.innerText;") or "")
)
print("[MetLife Claim step9] 'Submitted to MetLife' confirmed")
except TimeoutException:
print("[MetLife Claim step9] 'Submitted to MetLife' banner not seen — continuing")
old_page = self.driver.find_element(By.TAG_NAME, "body")
self._click(BACK_TO_CLAIM_BUTTON_XPATH, "Back to Claim", timeout=30)
# Plain navigation (window.location.href)
try:
WebDriverWait(self.driver, 30).until(EC.staleness_of(old_page))
except TimeoutException:
print("[MetLife Claim step9] Page did not reload after 'Back to Claim'")
WebDriverWait(self.driver, 30).until(
lambda d: d.execute_script("return document.readyState") == "complete"
)
self._wait_wicket_idle()
time.sleep(1.5)
print(f"[MetLife Claim step9] Claim page: {self.driver.current_url}")
claim_id = self._extract_claim_id()
print(f"[MetLife Claim step9] Claim ID: {claim_id}")
try:
pdf_path = self._save_page_pdf(claim_id)
except Exception as e:
# The claim is already submitted — don't fail the job over the PDF
print(f"[MetLife Claim step9] PDF capture failed (non-fatal): {e}")
pdf_path = None
return {"claimNumber": claim_id, "pdf_path": pdf_path}
except TimeoutException:
return f"ERROR: 'Back to Claim' not found after submit ({self.driver.current_url})"
except Exception as e:
return f"ERROR: Reading submitted claim failed: {e}"