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>
This commit is contained in:
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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<any> {
|
||||
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<string, any> = { 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 };
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -12,6 +12,8 @@ const SCHEDULE_FILES: Record<string, string> = {
|
||||
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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<any> => {
|
||||
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<any> => {
|
||||
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;
|
||||
@@ -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: "<uuid>" }
|
||||
*/
|
||||
export async function forwardToSeleniumMetLifeClaimAgent(
|
||||
data: Record<string, any>
|
||||
): 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<Record<string, any>> {
|
||||
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<Record<string, any>> {
|
||||
const resp = await axios.post(`${SELENIUM_BASE}/submit-otp`, { session_id: sessionId, otp });
|
||||
return resp.data;
|
||||
}
|
||||
@@ -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" }
|
||||
]
|
||||
@@ -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
|
||||
</Button>
|
||||
<Button
|
||||
className="w-36 bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||
onClick={() => runWithPriceCheck(handleMetLifeClaim)}
|
||||
disabled={!isLicensed}
|
||||
title={!isLicensed ? "License required" : undefined}
|
||||
>
|
||||
MetLife Claim
|
||||
</Button>
|
||||
<Button
|
||||
className="w-36 bg-indigo-600 hover:bg-indigo-700 text-white"
|
||||
onClick={() => runWithPriceCheck(handleDeltainsClaim)}
|
||||
|
||||
@@ -2557,6 +2557,7 @@ export default function AppointmentsPage() {
|
||||
onHandleForCCASeleniumPreAuth={() => {}}
|
||||
onHandleForUnitedDHSeleniumPreAuth={() => {}}
|
||||
onHandleForDDMASeleniumClaim={() => {}}
|
||||
onHandleForMetLifeSeleniumClaim={() => {}}
|
||||
onHandleForUnitedDHSeleniumClaim={() => {}}
|
||||
onHandleForTuftsSCOSeleniumPreAuth={() => {}}
|
||||
onHandleForTuftsSCOSeleniumClaim={() => {}}
|
||||
|
||||
@@ -76,6 +76,9 @@ export default function ClaimsPage() {
|
||||
const [ddmaClaimOtpOpen, setDdmaClaimOtpOpen] = useState(false);
|
||||
const [ddmaClaimOtpSubmitting, setDdmaClaimOtpSubmitting] = useState(false);
|
||||
const ddmaClaimSessionIdRef = useRef<string | null>(null);
|
||||
const [metlifeClaimOtpOpen, setMetlifeClaimOtpOpen] = useState(false);
|
||||
const [metlifeClaimOtpSubmitting, setMetlifeClaimOtpSubmitting] = useState(false);
|
||||
const metlifeClaimSessionIdRef = useRef<string | null>(null);
|
||||
// Deltains claim OTP modal
|
||||
const [deltainsClaimOtpOpen, setDeltainsClaimOtpOpen] = useState(false);
|
||||
const [deltainsClaimOtpSubmitting, setDeltainsClaimOtpSubmitting] = useState(false);
|
||||
@@ -659,6 +662,73 @@ export default function ClaimsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// MetLife claim OTP submit handler
|
||||
const handleMetlifeClaimOtpSubmit = async (otp: string) => {
|
||||
const sessionId = metlifeClaimSessionIdRef.current;
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
setMetlifeClaimOtpSubmitting(true);
|
||||
const resp = await apiRequest("POST", "/api/claims/metlife-claim/selenium/submit-otp", {
|
||||
session_id: sessionId,
|
||||
otp,
|
||||
socketId,
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok || data.error) throw new Error(data.error || "Failed to submit OTP");
|
||||
setMetlifeClaimOtpOpen(false);
|
||||
dispatch(setTaskStatus({ key: "claimSubmit", status: "pending", message: "OTP submitted. Continuing MetLife claim..." }));
|
||||
} catch (err: any) {
|
||||
toast({ title: "Failed to submit OTP", description: err?.message || "Error submitting OTP", variant: "destructive" });
|
||||
} finally {
|
||||
setMetlifeClaimOtpSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// MetLife claim selenium handler
|
||||
const handleMetLifeClaimSubmitSelenium = async (data: any) => {
|
||||
try {
|
||||
dispatch(setTaskStatus({ key: "claimSubmit", status: "pending", message: "Submitting MetLife claim..." }));
|
||||
const response = await apiRequest("POST", "/api/claims/metlife-claim", {
|
||||
data,
|
||||
socketId,
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.error) throw new Error(result.error);
|
||||
pendingClaimMeta.current = { patientId: selectedPatientId, groupKey: "INSURANCE_CLAIM" };
|
||||
setPendingClaimJobId(result.jobId);
|
||||
|
||||
// Listen for session_id so we can forward OTP if needed
|
||||
const jobId = result.jobId;
|
||||
const onSessionStarted = (ev: any) => {
|
||||
if (String(ev?.jobId) !== String(jobId)) return;
|
||||
metlifeClaimSessionIdRef.current = ev.session_id ?? null;
|
||||
};
|
||||
const onOtpRequired = (ev: any) => {
|
||||
if (String(ev?.jobId) !== String(jobId)) return;
|
||||
if (ev.session_id) metlifeClaimSessionIdRef.current = ev.session_id;
|
||||
setMetlifeClaimOtpOpen(true);
|
||||
dispatch(setTaskStatus({ key: "claimSubmit", status: "pending", message: "OTP required for MetLife. Please enter the code." }));
|
||||
};
|
||||
const onDone = (ev: any) => {
|
||||
if (String(ev?.jobId) !== String(jobId)) return;
|
||||
socket.off("selenium:metlife_claim_started", onSessionStarted);
|
||||
socket.off("selenium:otp_required", onOtpRequired);
|
||||
socket.off("job:update", onDone);
|
||||
setMetlifeClaimOtpOpen(false);
|
||||
metlifeClaimSessionIdRef.current = null;
|
||||
};
|
||||
socket.on("selenium:metlife_claim_started", onSessionStarted);
|
||||
socket.on("selenium:otp_required", onOtpRequired);
|
||||
socket.on("job:update", onDone);
|
||||
|
||||
dispatch(setTaskStatus({ key: "claimSubmit", status: "pending", message: "MetLife claim queued. Awaiting Selenium..." }));
|
||||
toast({ title: "MetLife Claim queued", description: "Selenium is logging in to the MetLife portal.", variant: "default" });
|
||||
} catch (error: any) {
|
||||
dispatch(setTaskStatus({ key: "claimSubmit", status: "error", message: error.message || "MetLife claim failed" }));
|
||||
toast({ title: "MetLife Claim error", description: error.message || "An error occurred.", variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
// Deltains claim OTP submit handler
|
||||
const handleDeltainsClaimOtpSubmit = async (otp: string) => {
|
||||
const sessionId = deltainsClaimSessionIdRef.current;
|
||||
@@ -1450,6 +1520,7 @@ export default function ClaimsPage() {
|
||||
onHandleForCCASeleniumPreAuth={handleCCAPreAuthSubmitSelenium}
|
||||
onHandleForUnitedDHSeleniumPreAuth={handleUnitedDHPreAuthSubmitSelenium}
|
||||
onHandleForDDMASeleniumClaim={handleDDMAClaimSubmitSelenium}
|
||||
onHandleForMetLifeSeleniumClaim={handleMetLifeClaimSubmitSelenium}
|
||||
onHandleForDeltainsSeleniumClaim={handleDeltainsClaimSubmitSelenium}
|
||||
onHandleForUnitedDHSeleniumClaim={handleUnitedDHClaimSubmitSelenium}
|
||||
onHandleForTuftsSCOSeleniumPreAuth={handleTuftsSCOPreAuthSubmitSelenium}
|
||||
@@ -1510,6 +1581,45 @@ export default function ClaimsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MetLife Claim OTP Modal */}
|
||||
{metlifeClaimOtpOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white dark:bg-slate-900 rounded-xl shadow-lg w-full max-w-md p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">Enter OTP — MetLife Claim</h2>
|
||||
<button type="button" onClick={() => setMetlifeClaimOtpOpen(false)} className="text-slate-500 hover:text-slate-800">✕</button>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 mb-4">
|
||||
MetLife emailed a verification code. Enter it here, or type it straight into the browser window.
|
||||
</p>
|
||||
<form onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const input = (e.currentTarget.elements.namedItem("otp") as HTMLInputElement);
|
||||
if (input?.value.trim()) handleMetlifeClaimOtpSubmit(input.value.trim());
|
||||
}} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="metlife-claim-otp" className="text-sm font-medium">OTP</label>
|
||||
<input
|
||||
id="metlife-claim-otp"
|
||||
name="otp"
|
||||
placeholder="Enter OTP code"
|
||||
autoFocus
|
||||
className="w-full border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button type="button" onClick={() => setMetlifeClaimOtpOpen(false)} disabled={metlifeClaimOtpSubmitting}
|
||||
className="px-4 py-2 text-sm border rounded-md hover:bg-slate-50 disabled:opacity-50">Cancel</button>
|
||||
<button type="submit" disabled={metlifeClaimOtpSubmitting}
|
||||
className="px-4 py-2 text-sm bg-primary text-white rounded-md hover:bg-primary/90 disabled:opacity-50">
|
||||
{metlifeClaimOtpSubmitting ? "Submitting..." : "Submit OTP"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Deltains Claim OTP Modal */}
|
||||
{deltainsClaimOtpOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
|
||||
@@ -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<string, CodeRow> = (() => {
|
||||
return m;
|
||||
})();
|
||||
|
||||
const METLIFE_CODE_MAP: Map<string, CodeRow> = (() => {
|
||||
const m = new Map<string, CodeRow>();
|
||||
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<string, CodeRow> = (() => {
|
||||
const m = new Map<string, CodeRow>();
|
||||
for (const r of UNITEDDH_CODE_TABLE) {
|
||||
@@ -141,6 +152,7 @@ function getCodeMap(insuranceSiteKey?: string): Map<string, CodeRow> {
|
||||
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);
|
||||
|
||||
@@ -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: "<uuid>" }
|
||||
"""
|
||||
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":
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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: <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}"
|
||||
Reference in New Issue
Block a user