From a5beeb7ba99ff1fadbfe765246656674ef919006 Mon Sep 17 00:00:00 2001 From: Gitead Date: Fri, 16 Oct 2026 00:01:39 -0400 Subject: [PATCH] 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 --- apps/Backend/src/queue/jobRunner.ts | 12 + .../queue/processors/metlifeClaimProcessor.ts | 184 +++++ apps/Backend/src/queue/queues.ts | 1 + apps/Backend/src/routes/feeSchedule.ts | 2 + apps/Backend/src/routes/index.ts | 2 + .../src/routes/insuranceStatusMetLifeClaim.ts | 103 +++ .../services/seleniumMetLifeClaimClient.ts | 35 + .../assets/data/procedureCodesMetLife.json | 209 ++++++ .../src/components/claims/claim-form.tsx | 81 +++ apps/Frontend/src/pages/appointments-page.tsx | 1 + apps/Frontend/src/pages/claims-page.tsx | 110 +++ .../src/utils/procedureCombosMapping.ts | 14 +- apps/SeleniumService/agent.py | 46 ++ apps/SeleniumService/helpers_metlife_claim.py | 316 ++++++++ .../selenium_MetLife_claimSubmitWorker.py | 678 ++++++++++++++++++ 15 files changed, 1793 insertions(+), 1 deletion(-) create mode 100644 apps/Backend/src/queue/processors/metlifeClaimProcessor.ts create mode 100644 apps/Backend/src/routes/insuranceStatusMetLifeClaim.ts create mode 100644 apps/Backend/src/services/seleniumMetLifeClaimClient.ts create mode 100644 apps/Frontend/src/assets/data/procedureCodesMetLife.json create mode 100644 apps/SeleniumService/helpers_metlife_claim.py create mode 100644 apps/SeleniumService/selenium_MetLife_claimSubmitWorker.py diff --git a/apps/Backend/src/queue/jobRunner.ts b/apps/Backend/src/queue/jobRunner.ts index 0af9266a..962d6d12 100644 --- a/apps/Backend/src/queue/jobRunner.ts +++ b/apps/Backend/src/queue/jobRunner.ts @@ -21,6 +21,7 @@ import { runCCAEligibilityProcessor } from "./processors/ccaEligibilityProcessor import { runCCAClaimProcessor } from "./processors/ccaClaimProcessor"; import { runCCAPreAuthProcessor } from "./processors/ccaPreAuthProcessor"; import { runDDMAClaimProcessor } from "./processors/ddmaClaimProcessor"; +import { runMetLifeClaimProcessor } from "./processors/metlifeClaimProcessor"; import { runDeltainsClaimProcessor } from "./processors/deltainsClaimProcessor"; import { runUnitedDHClaimProcessor } from "./processors/unitedDHClaimProcessor"; import { runUnitedDHPreAuthProcessor } from "./processors/unitedDHPreAuthProcessor"; @@ -223,6 +224,17 @@ export function enqueueSeleniumJob(data: SeleniumJobData): string { job.id ); } + if (jobType === "metlife-claim-submit") { + return runMetLifeClaimProcessor( + { + enrichedPayload: data.enrichedPayload, + userId: data.userId, + claimId: data.claimId, + socketId: data.socketId, + }, + job.id + ); + } if (jobType === "deltains-claim-submit") { return runDeltainsClaimProcessor( { diff --git a/apps/Backend/src/queue/processors/metlifeClaimProcessor.ts b/apps/Backend/src/queue/processors/metlifeClaimProcessor.ts new file mode 100644 index 00000000..743aeec3 --- /dev/null +++ b/apps/Backend/src/queue/processors/metlifeClaimProcessor.ts @@ -0,0 +1,184 @@ +/** + * Processor for "metlife-claim-submit" jobs. + * Opens a claim on the MetLife Dental provider portal via Selenium. + * + * Flow: + * 1. POST /metlife-claim to Python agent → get session_id + * 2. Emit selenium:metlife_claim_started to frontend + * 3. Poll until completed/error + * 4. Emit result + */ +import { + forwardToSeleniumMetLifeClaimAgent, + getSeleniumMetLifeClaimSessionStatus, +} from "../../services/seleniumMetLifeClaimClient"; +import { io } from "../../socket"; +import { storage } from "../../storage"; +import axios from "axios"; +import path from "path"; + +function log(tag: string, msg: string, ctx?: any) { + console.log(`${new Date().toISOString()} [${tag}] ${msg}`, ctx ?? ""); +} + +function emitToSocket(socketId: string | undefined, event: string, payload: any) { + if (!socketId || !io) return; + try { + const socket = io.sockets.sockets.get(socketId); + if (socket) socket.emit(event, payload); + } catch (_) {} +} + +async function pollUntilDone( + sessionId: string, + socketId: string | undefined, + jobId: string, + pollTimeoutMs = 10 * 60 * 1000 +): Promise { + const maxAttempts = 1200; + const pollIntervalMs = 500; + const maxTransientErrors = 12; + let transientErrors = 0; + let lastOtpEmit = 0; + const deadline = Date.now() + pollTimeoutMs; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + if (Date.now() > deadline) { + throw new Error(`MetLife claim polling timeout for session ${sessionId}`); + } + try { + const st = await getSeleniumMetLifeClaimSessionStatus(sessionId); + const status: string = st?.status ?? "unknown"; + log("metlife-claim-processor", `poll attempt=${attempt}`, { sessionId, status }); + transientErrors = 0; + + if (status === "waiting_for_otp") { + // Throttle: emit at most once every 5 s so the frontend isn't flooded + if (Date.now() - lastOtpEmit > 5000) { + emitToSocket(socketId, "selenium:otp_required", { + session_id: sessionId, + jobId, + message: "OTP required. Please enter the OTP shown by the MetLife portal.", + }); + lastOtpEmit = Date.now(); + } + await new Promise((r) => setTimeout(r, pollIntervalMs)); + continue; + } + + if (status === "completed") return st.result; + if (status === "error" || status === "not_found") { + throw new Error(st?.message || `MetLife claim session ended with status: ${status}`); + } + await new Promise((r) => setTimeout(r, pollIntervalMs)); + } catch (err: any) { + const isTerminal = + err?.response?.status === 404 || + (typeof err?.message === "string" && + (err.message.includes("not_found") || err.message.includes("polling timeout"))); + if (isTerminal) throw err; + transientErrors++; + if (transientErrors > maxTransientErrors) { + throw new Error(`Too many transient errors polling MetLife claim session ${sessionId}`); + } + const backoff = Math.min(30_000, 500 * Math.pow(2, transientErrors - 1)); + await new Promise((r) => setTimeout(r, backoff)); + } + } + throw new Error(`MetLife claim polling exhausted all attempts for session ${sessionId}`); +} + +async function savePdfFromSelenium(pdf_url: string, patientId: number, userId: number) { + try { + const filename = path.basename(new URL(pdf_url).pathname); + const seleniumPort = process.env.SELENIUM_PORT || "5002"; + const localUrl = `http://localhost:${seleniumPort}/downloads/${filename}`; + const resp = await axios.get(localUrl, { responseType: "arraybuffer", timeout: 30000 }); + const patient = await storage.getPatient(patientId); + const patientName = patient ? `${patient.firstName} ${patient.lastName}` : `Patient ${patientId}`; + await storage.savePdfToCloudStorage( + userId, + patientId, + patientName, + "Claims", + filename, + Buffer.from(resp.data) + ); + log("metlife-claim-processor", "PDF saved", { patientId, filename }); + } catch (err: any) { + log("metlife-claim-processor", "failed to save PDF (non-fatal)", { error: err?.message ?? err }); + } +} + +export interface MetLifeClaimProcessorInput { + enrichedPayload: any; + userId: number; + claimId?: number; + socketId?: string; +} + +export async function runMetLifeClaimProcessor( + input: MetLifeClaimProcessorInput, + jobId: string +): Promise<{ status: string; pdf_url?: string; claimNumber?: string }> { + const { enrichedPayload, userId, claimId, socketId } = input; + + log("metlife-claim-processor", "starting Python agent session", { claimId }); + const agentResp = await forwardToSeleniumMetLifeClaimAgent(enrichedPayload); + + if (!agentResp?.session_id) { + throw new Error("Python agent did not return a session_id for MetLife claim"); + } + + const sessionId = agentResp.session_id as string; + log("metlife-claim-processor", "got session_id", { sessionId }); + + emitToSocket(socketId, "selenium:metlife_claim_started", { session_id: sessionId, jobId }); + + const seleniumResult = await pollUntilDone(sessionId, socketId, jobId); + + if (!seleniumResult || seleniumResult.status === "error") { + throw new Error(seleniumResult?.message ?? "MetLife claim session returned an error"); + } + + const claimNumber: string | undefined = seleniumResult.claimNumber ?? undefined; + const pdf_url: string | undefined = seleniumResult.pdf_url ?? undefined; + + // Persist claim number and update status (only once the portal returned one) + if (claimId && claimNumber) { + try { + const updates: Record = { status: "REVIEW", claimNumber }; + await storage.updateClaim(claimId, updates); + log("metlife-claim-processor", "claim record updated", { claimId, claimNumber }); + + // Touch patient so they rise to top of the list across all pages + const claim = await storage.getClaim(claimId); + if (claim?.patientId) { + await storage.touchPatient(claim.patientId); + log("metlife-claim-processor", "patient touched", { patientId: claim.patientId }); + } + } catch (e) { + log("metlife-claim-processor", "failed to update claim record (non-fatal)", { error: e }); + } + } + + // Auto-save PDF for batch-column calls (no socketId = no frontend listener) + if (pdf_url && !socketId) { + const claim = claimId ? await storage.getClaim(claimId).catch(() => null) : null; + const patientId = claim?.patientId ?? enrichedPayload?.claim?.patientId ?? enrichedPayload?.patientId; + if (patientId) await savePdfFromSelenium(pdf_url, Number(patientId), userId); + } + + emitToSocket(socketId, "selenium:metlife_claim_completed", { + jobId, + claimId, + claimNumber, + pdf_url, + message: claimNumber + ? `MetLife claim submitted — Claim #: ${claimNumber}` + : (seleniumResult?.message ?? "MetLife claim submitted successfully"), + }); + + log("metlife-claim-processor", "done", { claimId, claimNumber }); + return { status: "success", pdf_url, claimNumber }; +} diff --git a/apps/Backend/src/queue/queues.ts b/apps/Backend/src/queue/queues.ts index 26339a0a..ec3104d1 100644 --- a/apps/Backend/src/queue/queues.ts +++ b/apps/Backend/src/queue/queues.ts @@ -16,6 +16,7 @@ export type SeleniumJobType = | "cca-claim-submit" | "cca-preauth-submit" | "ddma-claim-submit" + | "metlife-claim-submit" | "deltains-claim-submit" | "tuftssco-claim-submit" | "uniteducard-claim-submit" diff --git a/apps/Backend/src/routes/feeSchedule.ts b/apps/Backend/src/routes/feeSchedule.ts index 556c33d1..8d95da6c 100644 --- a/apps/Backend/src/routes/feeSchedule.ts +++ b/apps/Backend/src/routes/feeSchedule.ts @@ -12,6 +12,8 @@ const SCHEDULE_FILES: Record = { BCBS: "procedureCodesBCBS.json", BCBS_MA: "procedureCodesBCBS.json", ALTUS: "procedureCodesAltus.json", + METLIFE: "procedureCodesMetLife.json", + METLIFEDENTAL: "procedureCodesMetLife.json", TUFTSSCO: "procedureCodesTuftsSCO.json", TUFTS_SCO: "procedureCodesTuftsSCO.json", UNITEDDH: "procedureCodesUnitedDH.json", diff --git a/apps/Backend/src/routes/index.ts b/apps/Backend/src/routes/index.ts index 90ddca49..e0f09f70 100755 --- a/apps/Backend/src/routes/index.ts +++ b/apps/Backend/src/routes/index.ts @@ -21,6 +21,7 @@ import insuranceStatusCCARoutes from "./insuranceStatusCCA"; import insuranceStatusCCAClaimRoutes from "./insuranceStatusCCAClaim"; import insuranceStatusCCAPreAuthRoutes from "./insuranceStatusCCAPreAuth"; import insuranceStatusDDMAClaimRoutes from "./insuranceStatusDDMAClaim"; +import insuranceStatusMetLifeClaimRoutes from "./insuranceStatusMetLifeClaim"; import insuranceStatusDeltainsClaimRoutes from "./insuranceStatusDeltainsClaim"; import insuranceStatusUnitedDHClaimRoutes from "./insuranceStatusUnitedDHClaim"; import insuranceStatusUnitedDHPreAuthRoutes from "./insuranceStatusUnitedDHPreAuth"; @@ -82,6 +83,7 @@ router.use("/insurance-status-altus", insuranceStatusAltusRoutes); router.use("/claims", insuranceStatusCCAClaimRoutes); router.use("/claims", insuranceStatusCCAPreAuthRoutes); router.use("/claims", insuranceStatusDDMAClaimRoutes); +router.use("/claims", insuranceStatusMetLifeClaimRoutes); router.use("/claims", insuranceStatusDeltainsClaimRoutes); router.use("/claims", insuranceStatusUnitedDHClaimRoutes); router.use("/claims", insuranceStatusUnitedDHPreAuthRoutes); diff --git a/apps/Backend/src/routes/insuranceStatusMetLifeClaim.ts b/apps/Backend/src/routes/insuranceStatusMetLifeClaim.ts new file mode 100644 index 00000000..fe087dc0 --- /dev/null +++ b/apps/Backend/src/routes/insuranceStatusMetLifeClaim.ts @@ -0,0 +1,103 @@ +import { Router, Request, Response } from "express"; +import { storage } from "../storage"; +import { enqueueSeleniumJob } from "../queue/jobRunner"; +import { forwardOtpToSeleniumMetLifeClaimAgent } from "../services/seleniumMetLifeClaimClient"; +import { io } from "../socket"; + +const router = Router(); + +/** + * POST /metlife-claim + * + * Enqueues a MetLife Dental claim submission job. + * + * Body fields (JSON): + * data — claim payload (memberId, dateOfBirth, serviceDate, serviceLines, patientName, etc.) + * socketId — socket.io client id + * claimId — existing claim DB id (optional) + * + * Response: { status: "queued", jobId: "…" } + */ +router.post("/metlife-claim", async (req: Request, res: Response): Promise => { + if (!req.user?.id) { + return res.status(401).json({ error: "Unauthorized: user info missing" }); + } + + try { + const claimData = + typeof req.body.data === "string" + ? JSON.parse(req.body.data) + : req.body.data ?? req.body ?? {}; + + // Fetch MetLife credentials + const credentials = await storage.getInsuranceCredentialByUserAndSiteKey( + req.user.id, + "METLIFE" + ); + if (!credentials) { + return res.status(404).json({ + error: "No MetLife Dental credentials found. Please add them on the Settings page.", + }); + } + + // Use provider from the claim form if set; fall back to Provider #1 (default) + const npiProviders = await storage.getNpiProvidersByUser(req.user.id); + const primaryProvider = npiProviders[0]; + const providerName = + (claimData.npiProvider?.providerName as string | undefined)?.trim() || + primaryProvider?.providerName || + ""; + + const enrichedPayload = { + claim: { + ...claimData, + metlifeUsername: credentials.username, + metlifePassword: credentials.password, + providerName, + }, + }; + + const socketId: string | undefined = req.body.socketId; + const claimId: number | undefined = claimData.claimId + ? Number(claimData.claimId) + : undefined; + + const jobId = enqueueSeleniumJob({ + jobType: "metlife-claim-submit", + userId: req.user.id, + socketId, + enrichedPayload, + claimId, + }); + + return res.json({ status: "queued", jobId }); + } catch (err: any) { + console.error("[metlife-claim route] error:", err); + return res.status(500).json({ + error: err.message || "Failed to enqueue MetLife claim job", + }); + } +}); + +/** + * POST /claims/metlife-claim/selenium/submit-otp + * Body: { session_id, otp, socketId? } + */ +router.post("/metlife-claim/selenium/submit-otp", async (req: Request, res: Response): Promise => { + const { session_id: sessionId, otp, socketId } = req.body; + if (!sessionId || !otp) { + return res.status(400).json({ error: "session_id and otp are required" }); + } + try { + const r = await forwardOtpToSeleniumMetLifeClaimAgent(sessionId, otp); + if (socketId && io) { + io.to(socketId).emit("selenium:otp_submitted", { session_id: sessionId }); + } + return res.json(r); + } catch (err: any) { + console.error("[metlife-claim] submit-otp failed:", err?.message); + return res.status(500).json({ error: err?.message || "Failed to forward OTP" }); + } +}); + +export default router; diff --git a/apps/Backend/src/services/seleniumMetLifeClaimClient.ts b/apps/Backend/src/services/seleniumMetLifeClaimClient.ts new file mode 100644 index 00000000..c1738f29 --- /dev/null +++ b/apps/Backend/src/services/seleniumMetLifeClaimClient.ts @@ -0,0 +1,35 @@ +import axios from "axios"; + +const SELENIUM_BASE = process.env.SELENIUM_SERVICE_URL ?? "http://localhost:5002"; + +/** + * POST /metlife-claim + * Returns { status: "started", session_id: "" } + */ +export async function forwardToSeleniumMetLifeClaimAgent( + data: Record +): Promise<{ status: string; session_id: string }> { + const resp = await axios.post(`${SELENIUM_BASE}/metlife-claim`, data); + return resp.data; +} + +/** + * GET /session/{sid}/status + */ +export async function getSeleniumMetLifeClaimSessionStatus( + sessionId: string +): Promise> { + const resp = await axios.get(`${SELENIUM_BASE}/session/${sessionId}/status`); + return resp.data; +} + +/** + * POST /session/{sid}/submit-otp + */ +export async function forwardOtpToSeleniumMetLifeClaimAgent( + sessionId: string, + otp: string +): Promise> { + const resp = await axios.post(`${SELENIUM_BASE}/submit-otp`, { session_id: sessionId, otp }); + return resp.data; +} diff --git a/apps/Frontend/src/assets/data/procedureCodesMetLife.json b/apps/Frontend/src/assets/data/procedureCodesMetLife.json new file mode 100644 index 00000000..247c4b2b --- /dev/null +++ b/apps/Frontend/src/assets/data/procedureCodesMetLife.json @@ -0,0 +1,209 @@ +[ + { "Procedure Code": "D0120", "Description": "Periodic oral evaluation - established patient", "Price": 105 }, + { "Procedure Code": "D0140", "Description": "Limited oral evaluation - problem focused", "Price": 90 }, + { "Procedure Code": "D0145", "Description": "Oral evaluation for a patient under three years of age and counseling with primary caregiver", "Price": "NC" }, + { "Procedure Code": "D0150", "Description": "Comprehensive oral evaluation - new or established patient", "Price": 120 }, + { "Procedure Code": "D0180", "Description": "Comprehensive periodontal evaluation - new or established patient", "Price": 37 }, + { "Procedure Code": "D0190", "Description": "Screening of a patient (PHDH only)", "Price": 20 }, + { "Procedure Code": "D0191", "Description": "Assessment of a patient (PHDH only)", "Price": 20 }, + { "Procedure Code": "D0210", "Description": "Intraoral - complete series of radiographic images", "Price": 120 }, + { "Procedure Code": "D0220", "Description": "Intraoral - periapical, first radiographic image", "Price": 60 }, + { "Procedure Code": "D0230", "Description": "Intraoral - periapical, each additional radiographic image", "Price": 50 }, + { "Procedure Code": "D0240", "Description": "Intraoral - occlusal radiographic image", "Price": "NC" }, + { "Procedure Code": "D0270", "Description": "Bitewing - single radiographic image", "Price": 14 }, + { "Procedure Code": "D0272", "Description": "Bitewings - two radiographic images", "Price": 80 }, + { "Procedure Code": "D0273", "Description": "Bitewings - three radiographic images", "Price": 27 }, + { "Procedure Code": "D0274", "Description": "Bitewings - four radiographic images", "Price": 160 }, + { "Procedure Code": "D0330", "Description": "Panoramic radiographic image", "Price": 150 }, + { "Procedure Code": "D0340", "Description": "Cephalometric radiograph image (Oral surgeon only)", "Price": 74 }, + { "Procedure Code": "D0364", "Description": "CBCT - less than one jaw (interpret)", "Price": 350 }, + { "Procedure Code": "D0365", "Description": "CBCT - mandible (interpret)", "Price": 350 }, + { "Procedure Code": "D0366", "Description": "CBCT - maxilla (interpret)", "Price": 350 }, + { "Procedure Code": "D0367", "Description": "CBCT - both jaws (interpret)", "Price": 400 }, + { "Procedure Code": "D0368", "Description": "CBCT - include TMJ (interpret)", "Price": 375 }, + { "Procedure Code": "D0380", "Description": "CBCT - less than one jaw (capture only)", "Price": 300 }, + { "Procedure Code": "D0381", "Description": "CBCT - mandible (capture only)", "Price": 300 }, + { "Procedure Code": "D0382", "Description": "CBCT - maxilla (capture only)", "Price": 300 }, + { "Procedure Code": "D0383", "Description": "CBCT - both jaws / include TMJ (capture only)", "Price": 350 }, + { "Procedure Code": "D1110", "Description": "Prophylaxis - adult, 14 yo or older", "Price": 150 }, + { "Procedure Code": "D1120", "Description": "Prophylaxis - child, 0-13 yo", "Price": 120 }, + { "Procedure Code": "D1206", "Description": "Topical application of fluoride varnish", "Price": 26 }, + { "Procedure Code": "D1208", "Description": "Topical application of fluoride - excluding varnish", "Price": 90 }, + { "Procedure Code": "D1351", "Description": "Sealant - per tooth", "Price": 80 }, + { "Procedure Code": "D1354", "Description": "Application of caries arresting medicament - per tooth", "Price": 15 }, + { "Procedure Code": "D1510", "Description": "Space maintainer - fixed, unilateral - per quadrant", "Price": "NC" }, + { "Procedure Code": "D1516", "Description": "Space maintainer - fixed, bilateral, maxillary", "Price": "NC" }, + { "Procedure Code": "D1517", "Description": "Space maintainer - fixed, bilateral, mandibular", "Price": "NC" }, + { "Procedure Code": "D1520", "Description": "Space maintainer - removable, unilateral, per quadrant", "Price": "NC" }, + { "Procedure Code": "D1526", "Description": "Space maintainer - removable, bilateral, maxillary", "Price": "NC" }, + { "Procedure Code": "D1527", "Description": "Space maintainer - removable, bilateral, mandibular", "Price": "NC" }, + { "Procedure Code": "D1575", "Description": "Distal shoe space maintainer - fixed, unilateral, per quadrant", "Price": "NC" }, + { "Procedure Code": "D1701", "Description": "Pfizer-BioNTech Covid-19 vaccine - first dose", "Price": 45.87 }, + { "Procedure Code": "D1702", "Description": "Pfizer-BioNTech Covid-19 vaccine - second dose", "Price": 45.87 }, + { "Procedure Code": "D1707", "Description": "Janssen Covid-19 vaccine - single dose", "Price": 45.87 }, + { "Procedure Code": "D1708", "Description": "Pfizer-BioNTech Covid-19 vaccine - third dose", "Price": 45.87 }, + { "Procedure Code": "D1709", "Description": "Pfizer-BioNTech Covid-19 vaccine - booster dose", "Price": 45.87 }, + { "Procedure Code": "D1712", "Description": "Janssen Covid-19 vaccine - booster dose", "Price": 45.87 }, + { "Procedure Code": "D1713", "Description": "Pfizer-BioNTech Covid-19 vaccine pediatric - first dose", "Price": 45.87 }, + { "Procedure Code": "D1714", "Description": "Pfizer-BioNTech Covid-19 vaccine pediatric - second dose", "Price": 45.87 }, + { "Procedure Code": "D1999", "Description": "Unspecified preventive procedure, by report", "Price": 50 }, + { "Procedure Code": "D2140", "Description": "Amalgam - one surface, primary or permanent", "Price": 150 }, + { "Procedure Code": "D2150", "Description": "Amalgam - two surfaces, primary or permanent", "Price": 200 }, + { "Procedure Code": "D2160", "Description": "Amalgam - three surfaces, primary or permanent", "Price": 92 }, + { "Procedure Code": "D2161", "Description": "Amalgam - four or more surfaces, primary or permanent", "Price": 116 }, + { "Procedure Code": "D2330", "Description": "Resin-based composite - one surface, anterior", "Price": 180 }, + { "Procedure Code": "D2331", "Description": "Resin-based composite - two surfaces, anterior", "Price": 220 }, + { "Procedure Code": "D2332", "Description": "Resin-based composite - three surfaces, anterior", "Price": 280 }, + { "Procedure Code": "D2335", "Description": "Resin-based composite - four or more surfaces or involving incisal angle (anterior)", "Price": 350 }, + { "Procedure Code": "D2390", "Description": "Resin-based composite crown, anterior", "Price": "NC" }, + { "Procedure Code": "D2391", "Description": "Resin-based composite - one surface, posterior", "Price": 200 }, + { "Procedure Code": "D2392", "Description": "Resin-based composite - two surfaces, posterior", "Price": 250 }, + { "Procedure Code": "D2393", "Description": "Resin-based composite - three surfaces, posterior", "Price": 280 }, + { "Procedure Code": "D2394", "Description": "Resin-based composite - four or more surfaces, posterior", "Price": 320 }, + { "Procedure Code": "D2710", "Description": "Crown - resin-based composite (indirect)", "Price": "NC" }, + { "Procedure Code": "D2740", "Description": "Crown - porcelain/ceramic", "Price": 1300 }, + { "Procedure Code": "D2750", "Description": "Crown - porcelain fused to high noble metal", "Price": 1300 }, + { "Procedure Code": "D2751", "Description": "Crown - porcelain fused to predominantly base metal", "Price": 1200 }, + { "Procedure Code": "D2752", "Description": "Crown - porcelain fused to noble metal", "Price": 1200 }, + { "Procedure Code": "D2790", "Description": "Crown - full cast high noble metal", "Price": "NC" }, + { "Procedure Code": "D2910", "Description": "Re-cement or re-bond inlay, onlay or partial coverage restoration", "Price": 57 }, + { "Procedure Code": "D2920", "Description": "Re-cement or re-bond crown", "Price": 120 }, + { "Procedure Code": "D2929", "Description": "Prefabricated porcelain/ceramic crown - primary tooth", "Price": "NC" }, + { "Procedure Code": "D2930", "Description": "Prefabricated stainless steel crown - primary tooth", "Price": "NC" }, + { "Procedure Code": "D2931", "Description": "Prefabricated stainless steel crown - permanent tooth", "Price": 171 }, + { "Procedure Code": "D2932", "Description": "Prefabricated resin crown", "Price": "NC" }, + { "Procedure Code": "D2934", "Description": "Prefabricated esthetic coated stainless steel crown - primary tooth", "Price": "NC" }, + { "Procedure Code": "D2950", "Description": "Core buildup, including any pins when required", "Price": 350 }, + { "Procedure Code": "D2951", "Description": "Pin retention - per tooth, in addition to restoration", "Price": 27 }, + { "Procedure Code": "D2954", "Description": "Prefabricated post and core in addition to crown", "Price": 450 }, + { "Procedure Code": "D2955", "Description": "Post removal", "Price": 350 }, + { "Procedure Code": "D2980", "Description": "Crown repair necessitated by restorative material failure", "Price": 115 }, + { "Procedure Code": "D2999", "Description": "Unspecified restorative procedure, by report", "Price": "IC" }, + { "Procedure Code": "D3120", "Description": "Pulp cap - indirect (excluding final restoration)", "Price": 34 }, + { "Procedure Code": "D3220", "Description": "Therapeutic pulpotomy (excluding final restoration)", "Price": "NC" }, + { "Procedure Code": "D3230", "Description": "Pulpal therapy (resorbable filling) - anterior, primary tooth", "Price": 280 }, + { "Procedure Code": "D3240", "Description": "Pulpal therapy (resorbable filling) - posterior, primary tooth", "Price": 490 }, + { "Procedure Code": "D3310", "Description": "Endodontic therapy, anterior tooth (excluding final restoration)", "Price": 900 }, + { "Procedure Code": "D3320", "Description": "Endodontic therapy, premolar tooth (excluding final restoration)", "Price": 1050 }, + { "Procedure Code": "D3330", "Description": "Endodontic therapy, molar tooth (excluding final restoration)", "Price": 1250 }, + { "Procedure Code": "D3346", "Description": "Retreatment of previous root canal therapy - anterior", "Price": 950 }, + { "Procedure Code": "D3347", "Description": "Retreatment of previous root canal therapy - premolar", "Price": 1100 }, + { "Procedure Code": "D3348", "Description": "Retreatment of previous root canal therapy - molar", "Price": 1500 }, + { "Procedure Code": "D3410", "Description": "Apicoectomy - anterior", "Price": 407 }, + { "Procedure Code": "D3421", "Description": "Apicoectomy - premolar (first root)", "Price": 460 }, + { "Procedure Code": "D3425", "Description": "Apicoectomy - molar (first root)", "Price": 598 }, + { "Procedure Code": "D3426", "Description": "Apicoectomy (each additional root)", "Price": 230 }, + { "Procedure Code": "D4210", "Description": "Gingivectomy or gingivoplasty - four or more contiguous teeth per quadrant", "Price": 307 }, + { "Procedure Code": "D4211", "Description": "Gingivectomy or gingivoplasty - one to three contiguous teeth per quadrant", "Price": 111 }, + { "Procedure Code": "D4341", "Description": "Periodontal scaling and root planing - four or more teeth per quadrant", "Price": 250 }, + { "Procedure Code": "D4342", "Description": "Periodontal scaling and root planing - one to three teeth, per quadrant", "Price": 90 }, + { "Procedure Code": "D4346", "Description": "Scaling in presence of generalized moderate or severe gingival inflammation - full mouth", "Price": 60 }, + { "Procedure Code": "D4910", "Description": "Periodontal maintenance", "Price": 250 }, + { "Procedure Code": "D5110", "Description": "Complete denture - maxillary", "Price": 1700 }, + { "Procedure Code": "D5120", "Description": "Complete denture - mandibular", "Price": 1700 }, + { "Procedure Code": "D5130", "Description": "Immediate denture - maxillary", "Price": "NC" }, + { "Procedure Code": "D5140", "Description": "Immediate denture - mandibular", "Price": "NC" }, + { "Procedure Code": "D5211", "Description": "Maxillary partial denture - resin base", "Price": 1300 }, + { "Procedure Code": "D5212", "Description": "Mandibular partial denture - resin base", "Price": 1300 }, + { "Procedure Code": "D5213", "Description": "Maxillary partial denture - cast metal framework with resin denture bases", "Price": 1700 }, + { "Procedure Code": "D5214", "Description": "Mandibular partial denture - cast metal framework with resin denture bases", "Price": 1700 }, + { "Procedure Code": "D5225", "Description": "Maxillary partial denture - flexible base", "Price": "NC" }, + { "Procedure Code": "D5226", "Description": "Mandibular partial denture - flexible base", "Price": "NC" }, + { "Procedure Code": "D5510", "Description": "Repair broken complete denture base (QUAD)", "Price": 400 }, + { "Procedure Code": "D5511", "Description": "Repair broken complete denture base, mandibular", "Price": 85 }, + { "Procedure Code": "D5512", "Description": "Repair broken complete denture base, maxillary", "Price": 85 }, + { "Procedure Code": "D5520", "Description": "Replace missing or broken teeth - complete denture (each tooth)", "Price": 200 }, + { "Procedure Code": "D5611", "Description": "Repair broken resin partial denture base, mandibular", "Price": 77 }, + { "Procedure Code": "D5612", "Description": "Repair broken resin partial denture base, maxillary", "Price": 77 }, + { "Procedure Code": "D5621", "Description": "Repair broken cast partial denture base, mandibular", "Price": 104 }, + { "Procedure Code": "D5622", "Description": "Repair broken cast partial denture base, maxillary", "Price": 104 }, + { "Procedure Code": "D5630", "Description": "Repair or replace broken retentive/clasping materials - per tooth", "Price": 99 }, + { "Procedure Code": "D5640", "Description": "Replace broken teeth - per tooth", "Price": 77 }, + { "Procedure Code": "D5650", "Description": "Add tooth to existing partial denture", "Price": 92 }, + { "Procedure Code": "D5660", "Description": "Add clasp to existing partial denture per tooth", "Price": 98 }, + { "Procedure Code": "D5730", "Description": "Reline complete maxillary denture (chairside)", "Price": 500 }, + { "Procedure Code": "D5731", "Description": "Reline complete mandibular denture (chairside)", "Price": 500 }, + { "Procedure Code": "D5740", "Description": "Reline maxillary partial denture (chairside)", "Price": "NC" }, + { "Procedure Code": "D5741", "Description": "Reline mandibular partial denture (chairside)", "Price": "NC" }, + { "Procedure Code": "D5750", "Description": "Reline complete maxillary denture (laboratory)", "Price": 600 }, + { "Procedure Code": "D5751", "Description": "Reline complete mandibular denture (laboratory)", "Price": 600 }, + { "Procedure Code": "D5760", "Description": "Reline maxillary partial denture (laboratory)", "Price": "NC" }, + { "Procedure Code": "D5761", "Description": "Reline mandibular partial denture (laboratory)", "Price": "NC" }, + { "Procedure Code": "D6010", "Description": "Surgical placement of implant body", "Price": 1600 }, + { "Procedure Code": "D6056", "Description": "Prefabricated abutment", "Price": 750 }, + { "Procedure Code": "D6057", "Description": "Custom abutment", "Price": 800 }, + { "Procedure Code": "D6058", "Description": "Implant crown - porcelain/ceramic", "Price": 1400 }, + { "Procedure Code": "D6059", "Description": "Implant crown - PFM", "Price": 1400 }, + { "Procedure Code": "D6100", "Description": "Surgical removal of implant body", "Price": 320 }, + { "Procedure Code": "D6110", "Description": "Implant/abutment supported removable denture - maxillary", "Price": 1600 }, + { "Procedure Code": "D6111", "Description": "Implant/abutment supported removable denture - mandibular", "Price": 1600 }, + { "Procedure Code": "D6241", "Description": "Pontic - porcelain fused to metal", "Price": "NC" }, + { "Procedure Code": "D6242", "Description": "Pontic - noble metal", "Price": 1400 }, + { "Procedure Code": "D6245", "Description": "Pontic - porcelain/ceramic", "Price": 1400 }, + { "Procedure Code": "D6751", "Description": "Retainer crown - porcelain fused to metal", "Price": "NC" }, + { "Procedure Code": "D6930", "Description": "Re-cement or re-bond fixed partial denture", "Price": "NC" }, + { "Procedure Code": "D6980", "Description": "Fixed partial denture repair", "Price": "NC" }, + { "Procedure Code": "D6999", "Description": "Fixed prosthodontic procedure, by report", "Price": "IC" }, + { "Procedure Code": "D7111", "Description": "Extraction, coronal remnants - primary tooth", "Price": 120 }, + { "Procedure Code": "D7140", "Description": "Extraction, erupted tooth or exposed root (simple)", "Price": 150 }, + { "Procedure Code": "D7210", "Description": "Extraction, erupted tooth requiring removal of bone and/or sectioning (surgical)", "Price": 280 }, + { "Procedure Code": "D7220", "Description": "Removal of impacted tooth - soft tissue", "Price": 380 }, + { "Procedure Code": "D7230", "Description": "Removal of impacted tooth - partially bony", "Price": 450 }, + { "Procedure Code": "D7240", "Description": "Removal of impacted tooth - completely bony", "Price": 550 }, + { "Procedure Code": "D7250", "Description": "Surgical removal of residual tooth roots", "Price": 280 }, + { "Procedure Code": "D7251", "Description": "Coronectomy - intentional partial tooth removal, impacted teeth only", "Price": 134 }, + { "Procedure Code": "D7270", "Description": "Tooth reimplantation and/or stabilization of accidentally evulsed or displaced tooth", "Price": 106 }, + { "Procedure Code": "D7280", "Description": "Surgical access of an unerupted tooth", "Price": "NC" }, + { "Procedure Code": "D7283", "Description": "Placement of device to facilitate eruption of impacted tooth", "Price": "NC" }, + { "Procedure Code": "D7310", "Description": "Alveoloplasty in conjunction with extractions - four or more teeth per quadrant", "Price": 142 }, + { "Procedure Code": "D7311", "Description": "Alveoloplasty in conjunction with extractions - one to three teeth per quadrant", "Price": 128 }, + { "Procedure Code": "D7320", "Description": "Alveoloplasty not in conjunction with extractions - four or more teeth per quadrant", "Price": 187 }, + { "Procedure Code": "D7321", "Description": "Alveoloplasty not in conjunction with extractions - one to three teeth per quadrant", "Price": 149 }, + { "Procedure Code": "D7340", "Description": "Vestibuloplasty - ridge extension (second epithelialization)", "Price": 747 }, + { "Procedure Code": "D7350", "Description": "Vestibuloplasty - ridge extension (Oral surgeon only)", "Price": 943 }, + { "Procedure Code": "D7410", "Description": "Excision of benign lesion - diameter up to 1.25 cm", "Price": 115 }, + { "Procedure Code": "D7411", "Description": "Excision of benign lesion - diameter greater than 1.25 cm", "Price": 208 }, + { "Procedure Code": "D7450", "Description": "Removal of benign odontogenic cyst or tumor - up to 1.25 cm", "Price": 248 }, + { "Procedure Code": "D7451", "Description": "Removal of benign odontogenic cyst or tumor - greater than 1.25 cm", "Price": 288 }, + { "Procedure Code": "D7460", "Description": "Removal of benign nonodontogenic cyst or tumor - up to 1.25 cm", "Price": 121 }, + { "Procedure Code": "D7461", "Description": "Removal of benign nonodontogenic cyst or tumor - greater than 1.25 cm", "Price": 143 }, + { "Procedure Code": "D7471", "Description": "Removal of lateral exostosis (maxilla or mandible)", "Price": 143 }, + { "Procedure Code": "D7472", "Description": "Removal of torus palatinus", "Price": 143 }, + { "Procedure Code": "D7473", "Description": "Removal of torus mandibularis", "Price": 143 }, + { "Procedure Code": "D7910", "Description": "Suture of recent small wounds up to 5 cm", "Price": 400 }, + { "Procedure Code": "D7950", "Description": "Bone graft - maxilla/mandible", "Price": 800 }, + { "Procedure Code": "D7953", "Description": "Bone graft for socket preservation after extraction", "Price": 800 }, + { "Procedure Code": "D7961", "Description": "Buccal/labial frenectomy (frenulectomy)", "Price": 107 }, + { "Procedure Code": "D7962", "Description": "Lingual frenectomy (frenulectomy)", "Price": 107 }, + { "Procedure Code": "D7963", "Description": "Frenuloplasty", "Price": 416 }, + { "Procedure Code": "D7970", "Description": "Excision of hyperplastic tissue - per arch", "Price": 246 }, + { "Procedure Code": "D7999", "Description": "Unspecified oral surgery procedure, by report", "Price": "IC" }, + { "Procedure Code": "D8010", "Description": "Limited orthodontic treatment of the primary dentition (Orthodontist only)", "Price": "NC" }, + { "Procedure Code": "D8020", "Description": "Limited orthodontic treatment of the transitional dentition (Orthodontist only)", "Price": "NC" }, + { "Procedure Code": "D8030", "Description": "Limited orthodontic treatment of the adolescent dentition (Orthodontist only)", "Price": "NC" }, + { "Procedure Code": "D8040", "Description": "Limited orthodontic treatment of the adult dentition (Orthodontist only)", "Price": "NC" }, + { "Procedure Code": "D8070", "Description": "Comprehensive orthodontic treatment of the transitional dentition (Orthodontist only)", "Price": "NC" }, + { "Procedure Code": "D8080", "Description": "Comprehensive orthodontic treatment of the adolescent dentition (Orthodontist only)", "Price": "NC" }, + { "Procedure Code": "D8090", "Description": "Comprehensive orthodontic treatment of the adult dentition (Orthodontist only)", "Price": "NC" }, + { "Procedure Code": "D8660", "Description": "Pre-orthodontic treatment examination (Orthodontist only)", "Price": "NC" }, + { "Procedure Code": "D8670", "Description": "Periodic orthodontic treatment visit (Orthodontist only)", "Price": 215 }, + { "Procedure Code": "D8680", "Description": "Orthodontic retention (Orthodontist only)", "Price": 85 }, + { "Procedure Code": "D8703", "Description": "Replacement of lost or broken retainer - maxillary (Orthodontist only)", "Price": "NC" }, + { "Procedure Code": "D8704", "Description": "Replacement of lost or broken retainer - mandibular (Orthodontist only)", "Price": "NC" }, + { "Procedure Code": "D8999", "Description": "Unspecified orthodontic procedure, by report (Orthodontist only)", "Price": "NC" }, + { "Procedure Code": "D9110", "Description": "Palliative treatment of dental pain - per visit", "Price": 36 }, + { "Procedure Code": "D9222", "Description": "Deep sedation/general anesthesia - first 15 minutes", "Price": 90 }, + { "Procedure Code": "D9223", "Description": "Deep sedation/general anesthesia - each additional 15-minute increment", "Price": 90 }, + { "Procedure Code": "D9230", "Description": "Analgesia, anxiolysis, inhalation of nitrous oxide", "Price": 15 }, + { "Procedure Code": "D9248", "Description": "Nonintravenous conscious sedation", "Price": 45 }, + { "Procedure Code": "D9310", "Description": "Consultation - diagnostic service (Specialist only)", "Price": 63 }, + { "Procedure Code": "D9410", "Description": "House/extended care facility call, once per facility per day", "Price": 39 }, + { "Procedure Code": "D9450", "Description": "Rural add-on encounter payment", "Price": 31 }, + { "Procedure Code": "D9920", "Description": "Behavior management, by report", "Price": 86 }, + { "Procedure Code": "D9930", "Description": "Treatment of complications (postsurgical) - unusual circumstances, by report", "Price": 30 }, + { "Procedure Code": "D9941", "Description": "Fabrication of athletic mouthguard", "Price": "NC" }, + { "Procedure Code": "D9944", "Description": "Occlusal guard - hard appliance, full arch", "Price": "NC" }, + { "Procedure Code": "D9945", "Description": "Occlusal guard - soft appliance, full arch", "Price": "NC" }, + { "Procedure Code": "D9946", "Description": "Occlusal guard - hard appliance, partial arch", "Price": "NC" }, + { "Procedure Code": "D9999", "Description": "Unspecified adjunctive procedure, by report", "Price": "IC" } +] diff --git a/apps/Frontend/src/components/claims/claim-form.tsx b/apps/Frontend/src/components/claims/claim-form.tsx index 1447fbf1..7c3b7a15 100755 --- a/apps/Frontend/src/components/claims/claim-form.tsx +++ b/apps/Frontend/src/components/claims/claim-form.tsx @@ -99,6 +99,7 @@ interface ClaimFormProps { onHandleForUnitedDHSeleniumPreAuth: (data: ClaimPreAuthData) => void; onHandleForTuftsSCOSeleniumPreAuth: (data: ClaimPreAuthData) => void; onHandleForDDMASeleniumClaim: (data: ClaimFormData) => void; + onHandleForMetLifeSeleniumClaim: (data: ClaimFormData) => void; onHandleForDeltainsSeleniumClaim: (data: ClaimFormData) => void; onHandleForUnitedDHSeleniumClaim: (data: ClaimFormData) => void; onHandleForTuftsSCOSeleniumClaim: (data: ClaimFormData) => void; @@ -123,6 +124,7 @@ export function ClaimForm({ onHandleForUnitedDHSeleniumPreAuth, onHandleForTuftsSCOSeleniumPreAuth, onHandleForDDMASeleniumClaim, + onHandleForMetLifeSeleniumClaim, onHandleForDeltainsSeleniumClaim, onHandleForUnitedDHSeleniumClaim, onHandleForTuftsSCOSeleniumClaim, @@ -1282,6 +1284,73 @@ export function ClaimForm({ onClose(); }; + // MetLife Claim: saves to DB then submits via Selenium + const handleMetLifeClaim = async (formToUse?: ClaimFormData & { uploadedFiles?: File[] }) => { + const f = formToUse ?? form; + const missingFields: string[] = []; + if (!f.memberId?.trim()) missingFields.push("Member ID"); + if (!f.dateOfBirth?.trim()) missingFields.push("Date of Birth"); + if (!patient?.firstName?.trim()) missingFields.push("First Name"); + if (missingFields.length > 0) { + toast({ + title: "Missing Required Fields", + description: `Please fill out the following field(s): ${missingFields.join(", ")}`, + variant: "destructive", + }); + return; + } + + const filteredServiceLines = (f.serviceLines || []).filter( + (line) => (line.procedureCode ?? "").trim() !== "", + ); + if (filteredServiceLines.length === 0) { + toast({ + title: "No procedure codes", + description: "Please add at least one procedure code before submitting the claim.", + variant: "destructive", + }); + return; + } + + const appointmentIdToUse = appointmentId ?? null; + + const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = f; + + // Upload files to server so we get local filePaths for Selenium + const claimFilesMeta: ClaimFileMeta[] = mergeWithExistingAttachments( + uploadedFiles?.length ? await uploadAttachmentsToLocalFolder(uploadedFiles) : [], + ); + + const selectedNpiProviderId = npiProvider?.npiNumber + ? npiProviders.find((p) => p.npiNumber === npiProvider.npiNumber)?.id ?? null + : null; + + const createdClaim = await onSubmit({ + ...formToCreateClaim, + serviceLines: filteredServiceLines, + staffId: appointmentStaffId ?? Number(staff?.id), + patientId, + insuranceProvider: "MetLife Dental", + appointmentId: appointmentIdToUse, + claimFiles: claimFilesMeta, + ...(selectedNpiProviderId ? { npiProviderId: selectedNpiProviderId } : {}), + }); + + onHandleForMetLifeSeleniumClaim({ + ...f, + serviceLines: filteredServiceLines, + staffId: appointmentStaffId ?? Number(staff?.id), + patientId, + insuranceProvider: "MetLife Dental", + appointmentId: appointmentIdToUse, + insuranceSiteKey: "METLIFE", + claimId: createdClaim.id, + claimFiles: claimFilesMeta, + }); + + onClose(); + }; + // Deltains Claim: saves to DB then submits via Selenium const handleDeltainsClaim = async (formToUse?: ClaimFormData & { uploadedFiles?: File[] }) => { const f = formToUse ?? form; @@ -1577,6 +1646,8 @@ export function ClaimForm({ runWithPriceCheck(handleCCAClaim); } else if (siteKey === "DDMA") { runWithPriceCheck(handleDDMAClaim); + } else if (siteKey === "METLIFE" || siteKey === "METLIFEDENTAL") { + runWithPriceCheck(handleMetLifeClaim); } else if (siteKey === "DELTAINS" || siteKey === "DELTA_INS") { runWithPriceCheck(handleDeltainsClaim); } else if (siteKey === "TUFTSSCO" || siteKey === "TUFTS_SCO") { @@ -2161,6 +2232,8 @@ export function ClaimForm({ handleCCAClaim(pricedForm); } else if (key === "ddma") { handleDDMAClaim(pricedForm); + } else if (key === "metlife" || key === "metlifedental") { + handleMetLifeClaim(pricedForm); } else if (key === "deltains" || key === "delta_ins") { handleDeltainsClaim(pricedForm); } else if (key === "united_sco" || key === "unitedco" || key === "dentalhub") { @@ -2818,6 +2891,14 @@ export function ClaimForm({ > Delta MA Claim + + +

+ MetLife emailed a verification code. Enter it here, or type it straight into the browser window. +

+
{ + e.preventDefault(); + const input = (e.currentTarget.elements.namedItem("otp") as HTMLInputElement); + if (input?.value.trim()) handleMetlifeClaimOtpSubmit(input.value.trim()); + }} className="space-y-4"> +
+ + +
+
+ + +
+
+ + + )} + {/* Deltains Claim OTP Modal */} {deltainsClaimOtpOpen && (
diff --git a/apps/Frontend/src/utils/procedureCombosMapping.ts b/apps/Frontend/src/utils/procedureCombosMapping.ts index 73e5f626..5db68b32 100755 --- a/apps/Frontend/src/utils/procedureCombosMapping.ts +++ b/apps/Frontend/src/utils/procedureCombosMapping.ts @@ -9,6 +9,7 @@ import rawTuftsSCOCodeTable from "@/assets/data/procedureCodesTuftsSCO.json"; import rawDeltainsCodeTable from "@/assets/data/procedureCodesDeltains.json"; import rawBCBSCodeTable from "@/assets/data/procedureCodesBCBS.json"; import rawAltusCodeTable from "@/assets/data/procedureCodesAltus.json"; +import rawMetLifeCodeTable from "@/assets/data/procedureCodesMetLife.json"; import { PROCEDURE_COMBOS } from "./procedureCombos"; /* ----------------------------- Types ----------------------------- */ @@ -29,6 +30,7 @@ const TUFTSSCO_CODE_TABLE = rawTuftsSCOCodeTable as CodeRow[]; const DELTAINS_CODE_TABLE = rawDeltainsCodeTable as CodeRow[]; const BCBS_CODE_TABLE = rawBCBSCodeTable as CodeRow[]; const ALTUS_CODE_TABLE = rawAltusCodeTable as CodeRow[]; +const METLIFE_CODE_TABLE = rawMetLifeCodeTable as CodeRow[]; export type ClaimFormLike = { serviceDate: string; // form-level service date @@ -98,6 +100,15 @@ const ALTUS_CODE_MAP: Map = (() => { return m; })(); +const METLIFE_CODE_MAP: Map = (() => { + const m = new Map(); + for (const r of METLIFE_CODE_TABLE) { + const k = normalizeCode(String(r["Procedure Code"] || "")); + if (k && !m.has(k)) m.set(k, r); + } + return m; +})(); + const UNITEDDH_CODE_MAP: Map = (() => { const m = new Map(); for (const r of UNITEDDH_CODE_TABLE) { @@ -141,6 +152,7 @@ function getCodeMap(insuranceSiteKey?: string): Map { if (k === "ddma") return DDMA_CODE_MAP; if (k === "bcbs" || k === "bcbsma") return BCBS_CODE_MAP; if (k === "altus") return ALTUS_CODE_MAP; + if (k === "metlife" || k === "metlifedental") return METLIFE_CODE_MAP; if (k === "deltains") return DELTAINS_CODE_MAP; if (k === "unitedsco" || k === "uniteddh" || k === "dentalhub") return UNITEDDH_CODE_MAP; if (k === "uniteducard" || k === "ucard") return UNITEDUCARD_CODE_MAP; @@ -422,7 +434,7 @@ export function findPriceMismatches( patientDOB: string, serviceDate: string, ): PriceMismatch[] { - const supported = ["MH", "MASSHEALTH", "CCA", "DDMA", "UNITEDDH", "UNITEDSCO", "UNITED_UCARD", "UNITEDUCARD", "TUFTSSCO"]; + const supported = ["MH", "MASSHEALTH", "CCA", "DDMA", "UNITEDDH", "UNITEDSCO", "UNITED_UCARD", "UNITEDUCARD", "TUFTSSCO", "METLIFE", "METLIFEDENTAL"]; if (!insuranceSiteKey || !supported.includes(insuranceSiteKey.toUpperCase())) return []; const map = getCodeMap(insuranceSiteKey); diff --git a/apps/SeleniumService/agent.py b/apps/SeleniumService/agent.py index 0291d2fb..663854e4 100755 --- a/apps/SeleniumService/agent.py +++ b/apps/SeleniumService/agent.py @@ -33,6 +33,7 @@ import helpers_tuftssco_preauth as htuftssco_preauth import helpers_uniteducard_claim as huniteducard_claim import helpers_bcbs_ma_eligibility as hbcbs_ma import helpers_metlife_eligibility as hmetlife +import helpers_metlife_claim as hmetlife_claim # Import startup session-clear functions from ddma_browser_manager import clear_ddma_session_on_startup @@ -503,6 +504,47 @@ async def metlife_eligibility(request: Request): return {"status": "started", "session_id": sid} +async def _metlife_claim_worker_wrapper(sid: str, data: dict, url: str): + """Background worker for MetLife claim submission.""" + global active_jobs, waiting_jobs + async with semaphore: + async with lock: + waiting_jobs -= 1 + active_jobs += 1 + try: + await hmetlife_claim.start_metlife_claim_run(sid, data, url) + finally: + async with lock: + active_jobs -= 1 + + +@app.post("/metlife-claim") +async def metlife_claim(request: Request): + """ + Starts a MetLife claim submission session in the background. + Reuses the MetLife eligibility persistent browser/login. + Body: { "claim": { "metlifeUsername": "...", "metlifePassword": "...", ... } } + Returns: { status: "started", session_id: "" } + """ + global waiting_jobs + + body = await request.json() + + sid = hmetlife_claim.make_session_entry() + hmetlife_claim.sessions[sid]["type"] = "metlife_claim" + hmetlife_claim.sessions[sid]["last_activity"] = time.time() + + async with lock: + waiting_jobs += 1 + + asyncio.create_task(_metlife_claim_worker_wrapper( + sid, body, + url="https://dentalprovider.metlife.com/home" + )) + + return {"status": "started", "session_id": sid} + + async def _deltaindiv_worker_wrapper(sid: str, data: dict, url: str): """Background worker for Delta Indiv eligibility — acquires semaphore, updates counters.""" global active_jobs, waiting_jobs @@ -1208,6 +1250,8 @@ async def submit_otp(request: Request): res = hbcbs_ma.submit_otp(sid, otp) elif sid in hmetlife.sessions: res = hmetlife.submit_otp(sid, otp) + elif sid in hmetlife_claim.sessions: + res = hmetlife_claim.submit_otp(sid, otp) else: raise HTTPException(status_code=404, detail="session not found") @@ -1255,6 +1299,8 @@ async def session_status(sid: str): s = hbcbs_ma.get_session_status(sid) elif sid in hmetlife.sessions: s = hmetlife.get_session_status(sid) + elif sid in hmetlife_claim.sessions: + s = hmetlife_claim.get_session_status(sid) else: s = {"status": "not_found"} if s.get("status") == "not_found": diff --git a/apps/SeleniumService/helpers_metlife_claim.py b/apps/SeleniumService/helpers_metlife_claim.py new file mode 100644 index 00000000..d7b5a9a5 --- /dev/null +++ b/apps/SeleniumService/helpers_metlife_claim.py @@ -0,0 +1,316 @@ +import os +import time +import asyncio +from typing import Dict, Any +from selenium.common.exceptions import WebDriverException + +from selenium_MetLife_claimSubmitWorker import AutomationMetLifeClaimSubmit +from selenium_MetLife_eligibilityCheckWorker import METLIFE_HOME_URL + +sessions: Dict[str, Dict[str, Any]] = {} + +SESSION_OTP_TIMEOUT = int(os.getenv("SESSION_OTP_TIMEOUT", "120")) + + +def make_session_entry() -> str: + import uuid + sid = str(uuid.uuid4()) + sessions[sid] = { + "status": "created", + "created_at": time.time(), + "last_activity": time.time(), + "bot": None, + "driver": None, + "otp_event": asyncio.Event(), + "otp_value": None, + "result": None, + "message": None, + } + return sid + + +async def cleanup_session(sid: str, message: str | None = None): + """ + Set final state and remove the session entry. + Does NOT quit the browser — the persistent MetLife browser stays alive. + """ + s = sessions.get(sid) + if not s: + return + try: + if s.get("status") not in ("completed", "error", "not_found"): + s["status"] = "error" + if message: + s["message"] = message + ev = s.get("otp_event") + if ev and not ev.is_set(): + ev.set() + finally: + sessions.pop(sid, None) + print(f"[helpers_metlife_claim] cleaned session {sid}") + + +async def _remove_session_later(sid: str, delay: int = 20): + await asyncio.sleep(delay) + await cleanup_session(sid) + + +async def start_metlife_claim_run(sid: str, data: dict, url: str): + """ + Run the MetLife claim workflow — login/OTP mirrors helpers_metlife_eligibility, + then continues into claim-specific steps. + + OTP handling uses two complementary strategies: + 1. Accept OTP submitted from the app (via /submit-otp → otp_value) + 2. Poll the browser to detect when the user completes MFA themselves + """ + s = sessions.get(sid) + if not s: + return {"status": "error", "message": "session not found"} + + s["status"] = "running" + s["last_activity"] = time.time() + + try: + bot = AutomationMetLifeClaimSubmit(data) + bot.config_driver() + + s["bot"] = bot + s["driver"] = bot.driver + s["last_activity"] = time.time() + + try: + bot.driver.maximize_window() + except Exception: + pass + + # --- Login --- + try: + login_result = bot.login(url) + except WebDriverException as wde: + s["status"] = "error" + s["message"] = f"Selenium driver error during login: {wde}" + await cleanup_session(sid, s["message"]) + return {"status": "error", "message": s["message"]} + except Exception as e: + s["status"] = "error" + s["message"] = f"Unexpected error during login: {e}" + await cleanup_session(sid, s["message"]) + return {"status": "error", "message": s["message"]} + + # ── Already logged in (persistent session) ──────────────────────────── + if login_result == "ALREADY_LOGGED_IN": + print("[MetLife Claim] Session persisted — skipping OTP") + s["status"] = "running" + s["message"] = "Session persisted" + + # ── OTP required ────────────────────────────────────────────────────── + elif login_result == "OTP_REQUIRED": + s["status"] = "waiting_for_otp" + s["message"] = "OTP required for login — please enter OTP" + s["last_activity"] = time.time() + + driver = s["driver"] + login_success = False + + print(f"[MetLife Claim OTP] Polling for OTP completion (up to {SESSION_OTP_TIMEOUT}s)...") + + for poll in range(SESSION_OTP_TIMEOUT): + await asyncio.sleep(1) + s["last_activity"] = time.time() + + try: + # a) App submitted OTP via /submit-otp endpoint + otp_value = s.get("otp_value") + if otp_value: + print(f"[MetLife Claim OTP poll {poll+1}] OTP received from app, typing it in...") + s["otp_value"] = None + if bot.submit_otp_code(otp_value): + print("[MetLife Claim OTP] OTP typed and submitted via app") + await asyncio.sleep(3) + else: + print("[MetLife Claim OTP] OTP input not found — enter it in the browser window") + + # b) Back on the portal with the search form → login completed + if bot.is_logged_in(timeout=2): + print("[MetLife Claim OTP] Search form found — login successful!") + login_success = True + break + + print(f"[MetLife Claim OTP poll {poll+1}/{SESSION_OTP_TIMEOUT}] URL: {driver.current_url[:70]}...") + except Exception as poll_err: + print(f"[MetLife Claim OTP poll {poll+1}] Error: {poll_err}") + + if not login_success: + # Final check — navigate directly to the portal home page + try: + print("[MetLife Claim OTP] Final attempt — navigating to home page...") + driver.get(METLIFE_HOME_URL) + await asyncio.sleep(3) + login_success = bot.is_logged_in(timeout=10) + except Exception as final_err: + s["status"] = "error" + s["message"] = f"OTP verification failed: {final_err}" + await cleanup_session(sid) + return {"status": "error", "message": s["message"]} + + if not login_success: + s["status"] = "error" + s["message"] = "OTP timeout — login not completed" + await cleanup_session(sid) + return {"status": "error", "message": "OTP not completed in time"} + + s["status"] = "running" + s["message"] = "Login successful after OTP" + print("[MetLife Claim OTP] Proceeding to claim steps...") + + # ── Login succeeded without OTP ─────────────────────────────────────── + elif login_result == "SUCCESS": + print("[MetLife Claim] Login succeeded without OTP") + s["status"] = "running" + s["message"] = "Login succeeded" + + # ── Login error ─────────────────────────────────────────────────────── + else: + msg = login_result if isinstance(login_result, str) else "ERROR: unknown login result" + s["status"] = "error" + s["message"] = msg + await cleanup_session(sid) + return {"status": "error", "message": msg} + + # --- Step 1: Open Submit Claims --- + step1_result = bot.step1_open_submit_claims() + print(f"[MetLife Claim] step1 result: {step1_result}") + if isinstance(step1_result, str) and step1_result.startswith("ERROR"): + s["status"] = "error" + s["message"] = step1_result + await cleanup_session(sid) + return {"status": "error", "message": step1_result} + + # --- Step 2: Open ClaimConnect --- + step2_result = bot.step2_open_claimconnect() + print(f"[MetLife Claim] step2 result: {step2_result}") + if isinstance(step2_result, str) and step2_result.startswith("ERROR"): + s["status"] = "error" + s["message"] = step2_result + await cleanup_session(sid) + return {"status": "error", "message": step2_result} + + # --- Step 3: Open Single Claim on DentalXChange --- + step3_result = bot.step3_open_single_claim() + print(f"[MetLife Claim] step3 result: {step3_result}") + if isinstance(step3_result, str) and step3_result.startswith("ERROR"): + s["status"] = "error" + s["message"] = step3_result + await cleanup_session(sid) + return {"status": "error", "message": step3_result} + + # --- Step 4: Start New Claim (provider, patient, payer → Next) --- + step4_result = bot.step4_start_new_claim() + print(f"[MetLife Claim] step4 result: {step4_result}") + if isinstance(step4_result, str) and step4_result.startswith("ERROR"): + s["status"] = "error" + s["message"] = step4_result + await cleanup_session(sid) + return {"status": "error", "message": step4_result} + + # --- Step 5: Select Patient → Create Claim --- + step5_result = bot.step5_select_patient() + print(f"[MetLife Claim] step5 result: {step5_result}") + if isinstance(step5_result, str) and step5_result.startswith("ERROR"): + s["status"] = "error" + s["message"] = step5_result + await cleanup_session(sid) + return {"status": "error", "message": step5_result} + + # --- Step 6: Claim Detail — Signature on file → Next --- + step6_result = bot.step6_claim_detail() + print(f"[MetLife Claim] step6 result: {step6_result}") + if isinstance(step6_result, str) and step6_result.startswith("ERROR"): + s["status"] = "error" + s["message"] = step6_result + await cleanup_session(sid) + return {"status": "error", "message": step6_result} + + # --- Step 7: Claim Item Detail — procedure lines → Finish --- + step7_result = bot.step7_fill_claim_items() + print(f"[MetLife Claim] step7 result: {step7_result}") + if isinstance(step7_result, str) and step7_result.startswith("ERROR"): + s["status"] = "error" + s["message"] = step7_result + await cleanup_session(sid) + return {"status": "error", "message": step7_result} + + # --- Step 8: Review page → Submit Claim --- + step8_result = bot.step8_submit_claim() + print(f"[MetLife Claim] step8 result: {step8_result}") + if isinstance(step8_result, str) and step8_result.startswith("ERROR"): + s["status"] = "error" + s["message"] = step8_result + await cleanup_session(sid) + return {"status": "error", "message": step8_result} + + # --- Step 9: Back to Claim → Claim ID + PDF --- + # The claim is already submitted, so a failure here is non-fatal. + step9_result = bot.step9_save_claim() + print(f"[MetLife Claim] step9 result: {step9_result}") + if not isinstance(step9_result, dict): + step9_result = {} + + claim_number = step9_result.get("claimNumber") + pdf_path = step9_result.get("pdf_path") + pdf_url = None + if pdf_path: + port = os.getenv("PORT", "5002") + url_host = os.getenv("HOST", "localhost") + pdf_url = f"http://{url_host}:{port}/downloads/{os.path.basename(pdf_path)}" + print(f"[MetLife Claim] pdf_url: {pdf_url}") + + result = { + "status": "success", + "message": "MetLife claim submitted via DentalXChange", + "claimNumber": claim_number, + "pdf_url": pdf_url, + } + s["status"] = "completed" + s["result"] = result + s["message"] = "completed" + + asyncio.create_task(_remove_session_later(sid, 60)) + return result + + except Exception as e: + if s: + s["status"] = "error" + s["message"] = f"worker exception: {e}" + await cleanup_session(sid) + return {"status": "error", "message": f"worker exception: {e}"} + + +def submit_otp(sid: str, otp: str) -> Dict[str, Any]: + s = sessions.get(sid) + if not s: + return {"status": "error", "message": "session not found"} + if s.get("status") != "waiting_for_otp": + return {"status": "error", "message": f"session not waiting for otp (state={s.get('status')})"} + s["otp_value"] = otp + s["last_activity"] = time.time() + try: + s["otp_event"].set() + except Exception: + pass + return {"status": "ok", "message": "otp accepted"} + + +def get_session_status(sid: str) -> Dict[str, Any]: + s = sessions.get(sid) + if not s: + return {"status": "not_found"} + return { + "session_id": sid, + "status": s.get("status"), + "message": s.get("message"), + "created_at": s.get("created_at"), + "last_activity": s.get("last_activity"), + "result": s.get("result") if s.get("status") in ("completed", "error") else None, + } diff --git a/apps/SeleniumService/selenium_MetLife_claimSubmitWorker.py b/apps/SeleniumService/selenium_MetLife_claimSubmitWorker.py new file mode 100644 index 00000000..8d350c59 --- /dev/null +++ b/apps/SeleniumService/selenium_MetLife_claimSubmitWorker.py @@ -0,0 +1,678 @@ +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: Submit Claims +SUBMIT_CLAIMS_LINK_XPATH = "//a[@id='document-forms-link']" +# Submit Claims page: +CLAIMCONNECT_BUTTON_XPATH = "//button[@id='claims-submission-button']" +# "Leaving MetLife Website" modal: +LEAVING_CONTINUE_BUTTON_XPATH = "//button[@id='confirmation-modal-primary-button']" +# DentalXChange dashboard (register.dentalxchange.com/reg/dashboard), Claims card: +# Single Claim… — 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: Create Claim 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: (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 + +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/?- + 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}"