diff --git a/apps/Backend/src/ai/internal-chat-workflow.ts b/apps/Backend/src/ai/internal-chat-workflow.ts index 13ea8cfa..048467df 100644 --- a/apps/Backend/src/ai/internal-chat-workflow.ts +++ b/apps/Backend/src/ai/internal-chat-workflow.ts @@ -86,6 +86,7 @@ export function siteKeyToAutoCheck(siteKey: string, dob?: string | Date | null): case "DELTA_INS": return "delta-ins"; case "TUFTS_SCO": return "tufts-sco"; case "UNITED_SCO": return "united-sco"; + case "UNITED_UCARD": return "united-ucard"; default: { // MassHealth: patients under 21 use the CMSP button if (dob) { @@ -428,7 +429,7 @@ async function handleEligibilityById( memberId, dob: resolvedDob, patient, - options: ["United SCO / Dental Hub", "Other United (not in app)"], + options: ["United SCO / Dental Hub", "United Ucard", "Other United (not in app)"], }, }; } @@ -969,7 +970,7 @@ async function handleCheckAndClaim( dob: resolvedDob, patient, procedureNames: c.procedureNames ?? [], - options: ["United SCO / Dental Hub", "Other United (not in app)"], + options: ["United SCO / Dental Hub", "United Ucard", "Other United (not in app)"], }, }; } diff --git a/apps/Backend/src/data/insuranceAliases.json b/apps/Backend/src/data/insuranceAliases.json index 70f52d9b..cacb05e1 100644 --- a/apps/Backend/src/data/insuranceAliases.json +++ b/apps/Backend/src/data/insuranceAliases.json @@ -29,6 +29,12 @@ { "keyword": "dental hub", "siteKey": "UNITED_SCO" }, { "keyword": "united_sco", "siteKey": "UNITED_SCO" }, + { "keyword": "united ucard", "siteKey": "UNITED_UCARD" }, + { "keyword": "uniteducard", "siteKey": "UNITED_UCARD" }, + { "keyword": "ucard", "siteKey": "UNITED_UCARD" }, + { "keyword": "onehealthcareid", "siteKey": "UNITED_UCARD" }, + { "keyword": "one healthcare id", "siteKey": "UNITED_UCARD" }, + { "keyword": "blue cross blue shield", "siteKey": "BCBS_MA" }, { "keyword": "bcbs ma", "siteKey": "BCBS_MA" }, { "keyword": "blue cross", "siteKey": "BCBS_MA" }, diff --git a/apps/Backend/src/queue/jobRunner.ts b/apps/Backend/src/queue/jobRunner.ts index 39a420f9..bc127ccd 100644 --- a/apps/Backend/src/queue/jobRunner.ts +++ b/apps/Backend/src/queue/jobRunner.ts @@ -24,6 +24,7 @@ import { runUnitedDHClaimProcessor } from "./processors/unitedDHClaimProcessor"; import { runUnitedDHPreAuthProcessor } from "./processors/unitedDHPreAuthProcessor"; import { runTuftsSCOPreAuthProcessor } from "./processors/tuftsSCOPreAuthProcessor"; import { runTuftsSCOClaimProcessor } from "./processors/tuftsSCOClaimProcessor"; +import { runUnitedUcardClaimProcessor } from "./processors/unitedUcardClaimProcessor"; import { runEligibilityHistoryProcessor } from "./processors/eligibilityHistoryProcessor"; import { runCmspEligibilityHistoryRemainingProcessor } from "./processors/cmspEligibilityHistoryRemainingProcessor"; import { runBcbsMaEligibilityProcessor } from "./processors/bcbsMaEligibilityProcessor"; @@ -234,6 +235,17 @@ export function enqueueSeleniumJob(data: SeleniumJobData): string { job.id ); } + if (jobType === "uniteducard-claim-submit") { + return runUnitedUcardClaimProcessor( + { + enrichedPayload: data.enrichedPayload, + userId: data.userId, + claimId: data.claimId, + socketId: data.socketId, + }, + job.id + ); + } if (jobType === "cca-eligibility-check") { return runCCAEligibilityProcessor( { diff --git a/apps/Backend/src/queue/processors/unitedUcardClaimProcessor.ts b/apps/Backend/src/queue/processors/unitedUcardClaimProcessor.ts new file mode 100644 index 00000000..3defca80 --- /dev/null +++ b/apps/Backend/src/queue/processors/unitedUcardClaimProcessor.ts @@ -0,0 +1,176 @@ +/** + * Processor for "uniteducard-claim-submit" jobs. + * Opens a claim on the United Ucard (UHC Dental) provider portal via Selenium. + * + * Flow: + * 1. POST /uniteducard-claim to Python agent → get session_id + * 2. Emit selenium:uniteducard_claim_started to frontend + * 3. Poll until completed/error + * 4. Emit result + */ +import { + forwardToSeleniumUnitedUcardClaimAgent, + getSeleniumUnitedUcardClaimSessionStatus, +} from "../../services/seleniumUnitedUcardClaimClient"; +import { io } from "../../socket"; +import { storage } from "../../storage"; +import axios from "axios"; +import path from "path"; + +function log(tag: string, msg: string, ctx?: any) { + console.log(`${new Date().toISOString()} [${tag}] ${msg}`, ctx ?? ""); +} + +function emitToSocket(socketId: string | undefined, event: string, payload: any) { + if (!socketId || !io) return; + try { + const socket = io.sockets.sockets.get(socketId); + if (socket) socket.emit(event, payload); + } catch (_) {} +} + +async function pollUntilDone( + sessionId: string, + socketId: string | undefined, + jobId: string, + pollTimeoutMs = 10 * 60 * 1000 +): Promise { + const maxAttempts = 1200; + const pollIntervalMs = 500; + const maxTransientErrors = 12; + let transientErrors = 0; + let lastOtpEmit = 0; + const deadline = Date.now() + pollTimeoutMs; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + if (Date.now() > deadline) { + throw new Error(`United Ucard claim polling timeout for session ${sessionId}`); + } + try { + const st = await getSeleniumUnitedUcardClaimSessionStatus(sessionId); + const status: string = st?.status ?? "unknown"; + log("uniteducard-claim-processor", `poll attempt=${attempt}`, { sessionId, status }); + transientErrors = 0; + + if (status === "waiting_for_otp") { + if (Date.now() - lastOtpEmit > 5000) { + emitToSocket(socketId, "selenium:otp_required", { + session_id: sessionId, + jobId, + message: "OTP required. Please enter the OTP shown by the United Ucard 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 || `United Ucard 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 United Ucard 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(`United Ucard 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 ?? ""}`.trim() : ""; + await storage.savePdfToCloudStorage( + userId, + patientId, + patientName || `Patient ${patientId}`, + "Claims", + filename, + Buffer.from(resp.data) + ); + log("uniteducard-claim-processor", "PDF saved", { patientId, filename }); + } catch (err: any) { + log("uniteducard-claim-processor", "failed to save PDF (non-fatal)", { error: err?.message ?? err }); + } +} + +export interface UnitedUcardClaimProcessorInput { + enrichedPayload: any; + userId: number; + claimId?: number; + socketId?: string; +} + +export async function runUnitedUcardClaimProcessor( + input: UnitedUcardClaimProcessorInput, + jobId: string +): Promise<{ status: string; pdf_url?: string; claimNumber?: string }> { + const { enrichedPayload, userId, claimId, socketId } = input; + + log("uniteducard-claim-processor", "starting Python agent session", { claimId }); + const agentResp = await forwardToSeleniumUnitedUcardClaimAgent(enrichedPayload); + + if (!agentResp?.session_id) { + throw new Error("Python agent did not return a session_id for United Ucard claim"); + } + + const sessionId = agentResp.session_id as string; + log("uniteducard-claim-processor", "got session_id", { sessionId }); + + emitToSocket(socketId, "selenium:uniteducard_claim_started", { session_id: sessionId, jobId }); + + const seleniumResult = await pollUntilDone(sessionId, socketId, jobId); + + if (!seleniumResult || seleniumResult.status === "error") { + throw new Error(seleniumResult?.message ?? "United Ucard claim session returned an error"); + } + + const claimNumber: string | undefined = seleniumResult.claimNumber ?? undefined; + const pdf_url: string | undefined = seleniumResult.pdf_url ?? undefined; + + if (claimId) { + try { + const updates: Record = { status: "REVIEW" }; + if (claimNumber) updates.claimNumber = claimNumber; + await storage.updateClaim(claimId, updates); + log("uniteducard-claim-processor", "claim record updated", { claimId, claimNumber }); + } catch (e) { + log("uniteducard-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:uniteducard_claim_completed", { + jobId, + claimId, + claimNumber, + pdf_url, + message: claimNumber + ? `United Ucard claim submitted — Claim #: ${claimNumber}` + : (seleniumResult?.message ?? "United Ucard claim submitted successfully"), + }); + + log("uniteducard-claim-processor", "done", { claimId, claimNumber }); + return { status: "success", pdf_url, claimNumber }; +} diff --git a/apps/Backend/src/queue/queues.ts b/apps/Backend/src/queue/queues.ts index ed263a34..eb6d065e 100644 --- a/apps/Backend/src/queue/queues.ts +++ b/apps/Backend/src/queue/queues.ts @@ -16,6 +16,7 @@ export type SeleniumJobType = | "cca-preauth-submit" | "ddma-claim-submit" | "tuftssco-claim-submit" + | "uniteducard-claim-submit" | "uniteddh-claim-submit" | "uniteddh-preauth-submit" | "tuftssco-preauth-submit" diff --git a/apps/Backend/src/routes/claims.ts b/apps/Backend/src/routes/claims.ts index ba01515f..7edabbe4 100755 --- a/apps/Backend/src/routes/claims.ts +++ b/apps/Backend/src/routes/claims.ts @@ -590,6 +590,7 @@ function batchColumnDeriveSiteKey(provider: string): string { if (p.includes("ddma") || p.includes("delta dental ma")) return "DDMA"; if (p.includes("tufts") || p.includes("dentaquest") || p === "tuftssco") return "TUFTS_SCO"; if ((p.includes("united") && p.includes("sco")) || p.includes("dentalhub") || p === "united_sco") return "UNITED_SCO"; + if (p.includes("ucard") || p.includes("onehealthcareid") || p.includes("one healthcare id")) return "UNITED_UCARD"; return "MH"; // default fallback } @@ -600,6 +601,7 @@ function batchColumnCanonicalName(siteKey: string): string { case "DDMA": return "Delta Dental MA"; case "TUFTS_SCO": return "Tufts SCO"; case "UNITED_SCO": return "United/DentalHub"; + case "UNITED_UCARD": return "United Ucard"; default: return "MassHealth"; } } @@ -979,6 +981,24 @@ router.post( enrichedPayload, claimId, }); + } else if (siteKey === "UNITED_UCARD") { + const enrichedPayload = { + claim: { + ...baseClaimPayload, + insuranceProvider: "United Ucard", + insuranceSiteKey: "UNITED_UCARD", + uniteducardUsername: credentials.username, + uniteducardPassword: credentials.password, + providerName: resolvedNpiProvider?.providerName ?? "", + claimFiles: diskClaimFiles, + }, + }; + jobId = enqueueSeleniumJob({ + jobType: "uniteducard-claim-submit", + userId: req.user.id, + enrichedPayload, + claimId, + }); } else { resultItem.error = `Unsupported insurance type: ${siteKey}`; results.push(resultItem); diff --git a/apps/Backend/src/routes/index.ts b/apps/Backend/src/routes/index.ts index 2150614c..8f06c6fb 100755 --- a/apps/Backend/src/routes/index.ts +++ b/apps/Backend/src/routes/index.ts @@ -24,6 +24,7 @@ import insuranceStatusUnitedDHClaimRoutes from "./insuranceStatusUnitedDHClaim"; import insuranceStatusUnitedDHPreAuthRoutes from "./insuranceStatusUnitedDHPreAuth"; import insuranceStatusTuftsSCOClaimRoutes from "./insuranceStatusTuftsSCOClaim"; import insuranceStatusTuftsSCOPreAuthRoutes from "./insuranceStatusTuftsSCOPreAuth"; +import insuranceStatusUnitedUcardClaimRoutes from "./insuranceStatusUnitedUcardClaim"; import paymentsRoutes from "./payments"; import databaseManagementRoutes from "./database-management"; import notificationsRoutes from "./notifications"; @@ -78,6 +79,7 @@ router.use("/claims", insuranceStatusUnitedDHClaimRoutes); router.use("/claims", insuranceStatusUnitedDHPreAuthRoutes); router.use("/claims", insuranceStatusTuftsSCOClaimRoutes); router.use("/claims", insuranceStatusTuftsSCOPreAuthRoutes); +router.use("/claims", insuranceStatusUnitedUcardClaimRoutes); router.use("/payments", paymentsRoutes); router.use("/database-management", databaseManagementRoutes); router.use("/notifications", notificationsRoutes); diff --git a/apps/Backend/src/routes/insuranceStatusUnitedUcardClaim.ts b/apps/Backend/src/routes/insuranceStatusUnitedUcardClaim.ts new file mode 100644 index 00000000..6228514a --- /dev/null +++ b/apps/Backend/src/routes/insuranceStatusUnitedUcardClaim.ts @@ -0,0 +1,104 @@ +import { Router, Request, Response } from "express"; +import { storage } from "../storage"; +import { enqueueSeleniumJob } from "../queue/jobRunner"; +import { forwardOtpToSeleniumUnitedUcardClaimAgent } from "../services/seleniumUnitedUcardClaimClient"; +import { io } from "../socket"; +import { prisma as db } from "@repo/db/client"; + +const router = Router(); + +/** + * POST /uniteducard-claim + * + * Enqueues a United Ucard (UHC 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("/uniteducard-claim", async (req: Request, res: Response): Promise => { + if (!req.user?.id) { + return res.status(401).json({ error: "Unauthorized: user info missing" }); + } + + try { + const claimData = + typeof req.body.data === "string" + ? JSON.parse(req.body.data) + : req.body.data ?? req.body ?? {}; + + // Fetch United Ucard (UHC Dental / OneHealthcareID) credentials + const credentials = await storage.getInsuranceCredentialByUserAndSiteKey( + req.user.id, + "UNITED_UCARD" + ); + if (!credentials) { + return res.status(404).json({ + error: "No United Ucard credentials found. Please add them on the Settings page.", + }); + } + + const seleniumSettings = await db.seleniumSettings.findUnique({ where: { userId: req.user.id } }); + + // Default provider (sorted by sortOrder asc, then id asc) — used for the + // provider signature field on the Review and Sign step. + const npiProviders = await storage.getNpiProvidersByUser(req.user.id); + const primaryProvider = npiProviders[0]; + + const enrichedPayload = { + claim: { + ...claimData, + uniteducardUsername: credentials.username, + uniteducardPassword: credentials.password, + paymentGroupId: seleniumSettings?.paymentGroupId ?? "", + providerName: primaryProvider?.providerName ?? "", + }, + }; + + const socketId: string | undefined = req.body.socketId; + const claimId: number | undefined = claimData.claimId + ? Number(claimData.claimId) + : undefined; + + const jobId = enqueueSeleniumJob({ + jobType: "uniteducard-claim-submit", + userId: req.user.id, + socketId, + enrichedPayload, + claimId, + }); + + return res.json({ status: "queued", jobId }); + } catch (err: any) { + console.error("[uniteducard-claim route] error:", err); + return res.status(500).json({ + error: err.message || "Failed to enqueue United Ucard claim job", + }); + } +}); + +/** + * POST /claims/uniteducard-claim/selenium/submit-otp + * Body: { session_id, otp, socketId? } + */ +router.post("/uniteducard-claim/selenium/submit-otp", async (req: Request, res: Response): Promise => { + const { session_id: sessionId, otp, socketId } = req.body; + if (!sessionId || !otp) { + return res.status(400).json({ error: "session_id and otp are required" }); + } + try { + const r = await forwardOtpToSeleniumUnitedUcardClaimAgent(sessionId, otp); + if (socketId && io) { + io.to(socketId).emit("selenium:otp_submitted", { session_id: sessionId }); + } + return res.json(r); + } catch (err: any) { + console.error("[uniteducard-claim] submit-otp failed:", err?.message); + return res.status(500).json({ error: err?.message || "Failed to forward OTP" }); + } +}); + +export default router; diff --git a/apps/Backend/src/services/seleniumUnitedUcardClaimClient.ts b/apps/Backend/src/services/seleniumUnitedUcardClaimClient.ts new file mode 100644 index 00000000..56eaf4b1 --- /dev/null +++ b/apps/Backend/src/services/seleniumUnitedUcardClaimClient.ts @@ -0,0 +1,35 @@ +import axios from "axios"; + +const SELENIUM_BASE = process.env.SELENIUM_SERVICE_URL ?? "http://localhost:5002"; + +/** + * POST /uniteducard-claim + * Returns { status: "started", session_id: "" } + */ +export async function forwardToSeleniumUnitedUcardClaimAgent( + data: Record +): Promise<{ status: string; session_id: string }> { + const resp = await axios.post(`${SELENIUM_BASE}/uniteducard-claim`, data); + return resp.data; +} + +/** + * GET /session/{sid}/status + */ +export async function getSeleniumUnitedUcardClaimSessionStatus( + sessionId: string +): Promise> { + const resp = await axios.get(`${SELENIUM_BASE}/session/${sessionId}/status`); + return resp.data; +} + +/** + * POST /submit-otp + */ +export async function forwardOtpToSeleniumUnitedUcardClaimAgent( + sessionId: string, + otp: string +): Promise> { + const resp = await axios.post(`${SELENIUM_BASE}/submit-otp`, { session_id: sessionId, otp }); + return resp.data; +} diff --git a/apps/Frontend/src/components/claims/claim-form.tsx b/apps/Frontend/src/components/claims/claim-form.tsx index fd441b94..0993358f 100755 --- a/apps/Frontend/src/components/claims/claim-form.tsx +++ b/apps/Frontend/src/components/claims/claim-form.tsx @@ -100,6 +100,7 @@ interface ClaimFormProps { onHandleForDDMASeleniumClaim: (data: ClaimFormData) => void; onHandleForUnitedDHSeleniumClaim: (data: ClaimFormData) => void; onHandleForTuftsSCOSeleniumClaim: (data: ClaimFormData) => void; + onHandleForUnitedUcardSeleniumClaim: (data: ClaimFormData) => void; onClose: () => void; isLicensed?: boolean; } @@ -122,6 +123,7 @@ export function ClaimForm({ onHandleForDDMASeleniumClaim, onHandleForUnitedDHSeleniumClaim, onHandleForTuftsSCOSeleniumClaim, + onHandleForUnitedUcardSeleniumClaim, onSubmit, onClose, isLicensed = false, @@ -804,6 +806,7 @@ export function ClaimForm({ if (p.includes("delta ins") || p === "deltains") return "DeltaIns"; if (p.includes("tufts") || p.includes("dentaquest") || p === "tuftssco") return "TuftsSCO"; if ((p.includes("united") && p.includes("sco")) || p === "unitedsco") return "UnitedSCO"; + if (p.includes("ucard") || p.includes("onehealthcareid") || p.includes("one healthcare id")) return "UnitedUcard"; if (p.includes("bcbs") || p.includes("blue cross")) return "BCBS"; if (p.includes("united aapr") || p === "unitedaapr") return "UnitedAAPR"; if (p.includes("aetna")) return "Aetna"; @@ -1396,6 +1399,82 @@ export function ClaimForm({ onClose(); }; + // United Ucard Claim: saves to DB then submits via Selenium + const handleUnitedUcardClaim = 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; + + const claimFilesMeta: ClaimFileMeta[] = mergeWithExistingAttachments( + uploadedFiles?.length ? await uploadAttachmentsToLocalFolder(uploadedFiles) : [], + ); + + const selectedNpiProviderId = npiProvider?.npiNumber + ? npiProviders.find((p) => p.npiNumber === npiProvider.npiNumber)?.id ?? null + : null; + + let createdClaim: any; + try { + createdClaim = await onSubmit({ + ...formToCreateClaim, + serviceLines: filteredServiceLines, + staffId: appointmentStaffId ?? Number(staff?.id), + patientId, + insuranceProvider: "United Ucard", + appointmentId: appointmentIdToUse, + claimFiles: claimFilesMeta, + ...(selectedNpiProviderId ? { npiProviderId: selectedNpiProviderId } : {}), + }); + } catch (err: any) { + toast({ + title: "Failed to save claim", + description: err?.message || "An error occurred saving the claim.", + variant: "destructive", + }); + return; + } + + onHandleForUnitedUcardSeleniumClaim({ + ...f, + serviceLines: filteredServiceLines, + staffId: appointmentStaffId ?? Number(staff?.id), + patientId, + insuranceProvider: "United Ucard", + appointmentId: appointmentIdToUse, + insuranceSiteKey: "UNITED_UCARD", + claimId: createdClaim.id, + claimFiles: claimFilesMeta, + }); + + onClose(); + }; + const handleClaimAll = () => { const siteKey = (form.insuranceSiteKey || deriveInsuranceSiteKey(form.insuranceProvider || "")).toUpperCase(); if (siteKey === "MH" || siteKey === "MASSHEALTH") { @@ -2524,6 +2603,14 @@ export function ClaimForm({ > Tufts Claim + + +

+ The United Ucard (UHC Dental) portal requires a one-time password (OTP) to continue claim submission. +

+
{ + e.preventDefault(); + const input = (e.currentTarget.elements.namedItem("otp") as HTMLInputElement); + if (input?.value.trim()) handleUnitedUcardClaimOtpSubmit(input.value.trim()); + }} className="space-y-4"> +
+ + +
+
+ + +
+
+ + + )} + {/* United/DentalHub PreAuth OTP Modal */} {unitedDHPreAuthOtpOpen && (
diff --git a/apps/SeleniumService/agent.py b/apps/SeleniumService/agent.py index ae800060..dede3eb8 100755 --- a/apps/SeleniumService/agent.py +++ b/apps/SeleniumService/agent.py @@ -27,6 +27,7 @@ import helpers_uniteddh_claim as huniteddh_claim import helpers_uniteddh_preauth as huniteddh_preauth import helpers_tuftssco_claim as htuftssco_claim import helpers_tuftssco_preauth as htuftssco_preauth +import helpers_uniteducard_claim as huniteducard_claim import helpers_bcbs_ma_eligibility as hbcbs_ma # Import startup session-clear functions @@ -832,6 +833,48 @@ async def tuftssco_claim(request: Request): return {"status": "started", "session_id": sid} +async def _uniteducard_claim_worker_wrapper(sid: str, data: dict, url: str): + """Background worker for United Ucard claim — acquires semaphore, updates counters.""" + global active_jobs, waiting_jobs + async with semaphore: + async with lock: + waiting_jobs -= 1 + active_jobs += 1 + try: + await huniteducard_claim.start_uniteducard_claim_run(sid, data, url) + finally: + async with lock: + active_jobs -= 1 + + +@app.post("/uniteducard-claim") +async def uniteducard_claim(request: Request): + """ + Starts a United Ucard (UHC Dental) claim submission session in the background. + Logs in (shares the persistent browser/profile with the eligibility check, + so a trusted device skips OTP), then runs the claim-form steps. + Body: { "claim": { "uniteducardUsername": "...", "uniteducardPassword": "...", ... } } + Returns: { status: "started", session_id: "" } + """ + global waiting_jobs + + body = await request.json() + + sid = huniteducard_claim.make_session_entry() + huniteducard_claim.sessions[sid]["type"] = "uniteducard_claim" + huniteducard_claim.sessions[sid]["last_activity"] = time.time() + + async with lock: + waiting_jobs += 1 + + asyncio.create_task(_uniteducard_claim_worker_wrapper( + sid, body, + url="https://www.uhcdental.com/content/provider/dental.html" + )) + + return {"status": "started", "session_id": sid} + + async def _uniteddh_preauth_worker_wrapper(sid: str, data: dict, url: str): """Background worker for United/DentalHub pre-authorization submission.""" global active_jobs, waiting_jobs @@ -990,6 +1033,8 @@ async def submit_otp(request: Request): res = htuftssco_claim.submit_otp(sid, otp) elif sid in htuftssco_preauth.sessions: res = htuftssco_preauth.submit_otp(sid, otp) + elif sid in huniteducard_claim.sessions: + res = huniteducard_claim.submit_otp(sid, otp) elif sid in hbcbs_ma.sessions: res = hbcbs_ma.submit_otp(sid, otp) else: @@ -1029,6 +1074,8 @@ async def session_status(sid: str): s = htuftssco_claim.get_session_status(sid) elif sid in htuftssco_preauth.sessions: s = htuftssco_preauth.get_session_status(sid) + elif sid in huniteducard_claim.sessions: + s = huniteducard_claim.get_session_status(sid) elif sid in hbcbs_ma.sessions: s = hbcbs_ma.get_session_status(sid) else: diff --git a/apps/SeleniumService/helpers_uniteducard_claim.py b/apps/SeleniumService/helpers_uniteducard_claim.py new file mode 100644 index 00000000..79dbd1a5 --- /dev/null +++ b/apps/SeleniumService/helpers_uniteducard_claim.py @@ -0,0 +1,322 @@ +import os +import time +import asyncio +from typing import Dict, Any +from selenium.webdriver.common.by import By +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC +from selenium.common.exceptions import WebDriverException, TimeoutException + +from selenium_UnitedUcard_claimSubmitWorker import AutomationUnitedUcardClaimSubmit + +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): + 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() + # NOTE: Do NOT quit driver - the persistent browser (uniteducard_browser_manager) + # is shared with the eligibility check worker; keep it alive for the next run. + finally: + sessions.pop(sid, None) + print(f"[helpers_uniteducard_claim] cleaned session {sid}") + + +async def _remove_session_later(sid: str, delay: int = 20): + await asyncio.sleep(delay) + await cleanup_session(sid) + + +def _minimize_browser(bot): + try: + if bot and bot.driver: + try: + bot.driver.get("about:blank") + except Exception: + pass + try: + bot.driver.minimize_window() + print("[UnitedUcard Claim] Browser minimized after error") + return + except Exception: + pass + try: + bot.driver.set_window_position(-10000, -10000) + print("[UnitedUcard Claim] Browser moved off-screen after error") + except Exception: + pass + except Exception as e: + print(f"[UnitedUcard Claim] Could not hide browser: {e}") + + +async def start_uniteducard_claim_run(sid: str, data: dict, url: str): + """ + Run the United Ucard (UHC Dental) claim workflow. + Login/OTP handling mirrors helpers_uniteducard_eligibility.py — same + persistent browser/profile, so a device already trusted by a prior + eligibility check (or claim) skips OTP here too. + Claim-specific steps call selenium_UnitedUcard_claimSubmitWorker (currently + TODO stubs — see that file). + """ + s = sessions.get(sid) + if not s: + return {"status": "error", "message": "session not found"} + + s["status"] = "running" + s["last_activity"] = time.time() + + bot = None + try: + bot = AutomationUnitedUcardClaimSubmit(data) + bot.config_driver() + + s["bot"] = bot + s["driver"] = bot.driver + s["last_activity"] = time.time() + + try: + bot.driver.maximize_window() + bot.driver.get(url) + await asyncio.sleep(1) + except Exception as e: + s["status"] = "error" + s["message"] = f"Navigation failed: {e}" + await cleanup_session(sid) + return {"status": "error", "message": s["message"]} + + # --- 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 --- + if isinstance(login_result, str) and login_result == "ALREADY_LOGGED_IN": + print("[UnitedUcard Claim] Session persisted — skipping OTP") + s["status"] = "running" + s["message"] = "Session persisted" + + # --- OTP required --- + elif isinstance(login_result, str) and 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"] + max_polls = SESSION_OTP_TIMEOUT + login_success = False + + print(f"[UnitedUcard Claim OTP] Polling for OTP completion (up to {SESSION_OTP_TIMEOUT}s)...") + + for poll in range(max_polls): + await asyncio.sleep(1) + s["last_activity"] = time.time() + + try: + otp_value = s.get("otp_value") + if otp_value: + print(f"[UnitedUcard Claim OTP poll {poll+1}] OTP received from app, typing...") + try: + otp_input = driver.find_element(By.XPATH, + "//input[@id='otpBox' or contains(@id,'otp') or contains(@name,'otp') or " + "contains(@name,'code') or @type='tel' or contains(@aria-label,'Verification') or " + "contains(@placeholder,'code')]" + ) + otp_input.clear() + otp_input.send_keys(otp_value) + try: + try: + verify_btn = driver.find_element(By.ID, "continuebtn") + except Exception: + verify_btn = driver.find_element(By.XPATH, + "//button[@type='submit' or contains(text(),'Verify') or " + "contains(text(),'Submit') or contains(text(),'Continue')]" + ) + driver.execute_script("arguments[0].scrollIntoView({block:'center'});", verify_btn) + verify_btn.click() + print("[UnitedUcard Claim OTP] Clicked verify button") + except Exception: + otp_input.send_keys("\n") + print("[UnitedUcard Claim OTP] Pressed Enter as fallback") + print("[UnitedUcard Claim OTP] OTP typed and submitted via app") + s["otp_value"] = None + await asyncio.sleep(3) + except Exception as type_err: + print(f"[UnitedUcard Claim OTP] Failed to type OTP from app: {type_err}") + + current_url = driver.current_url.lower() + print(f"[UnitedUcard Claim OTP Poll {poll+1}/{max_polls}] URL: {current_url[:60]}...") + + if "secure.uhcdental.com" in current_url: + print("[UnitedUcard Claim OTP] Reached UHC Dental dashboard - login successful!") + login_success = True + break + + try: + driver.find_element(By.XPATH, + "//input[@id='otpBox' or contains(@id,'otp') or contains(@name,'otp') or " + "contains(@name,'code') or @type='tel' or contains(@aria-label,'Verification') or " + "contains(@placeholder,'code') or contains(@placeholder,'Code')]" + ) + print(f"[UnitedUcard Claim OTP Poll {poll+1}] OTP input still visible - waiting...") + except Exception: + print(f"[UnitedUcard Claim OTP Poll {poll+1}] OTP input not found, continuing to poll...") + + except Exception as poll_err: + print(f"[UnitedUcard Claim OTP Poll {poll+1}] Error: {poll_err}") + + 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("[UnitedUcard Claim OTP] Proceeding to claim steps...") + + elif isinstance(login_result, str) and login_result.startswith("ERROR"): + s["status"] = "error" + s["message"] = login_result + await cleanup_session(sid) + return {"status": "error", "message": login_result} + + elif isinstance(login_result, str) and login_result == "SUCCESS": + print("[UnitedUcard Claim] Login succeeded without OTP") + s["status"] = "running" + s["message"] = "Login succeeded" + + # --- Claim steps (Dental Claims wizard) --- + for step_name, step_fn in [ + ("step1_open_claims_page", bot.step1_open_claims_page), + ("step2_confirm_provider_information", bot.step2_confirm_provider_information), + ("step3_fill_patient_information", bot.step3_fill_patient_information), + ("step4_fill_claim_information", bot.step4_fill_claim_information), + ("step5_upload_documents", bot.step5_upload_documents), + ("step6_review_and_sign", bot.step6_review_and_sign), + ]: + result = step_fn() + print(f"[UnitedUcard Claim] {step_name} result: {result}") + if isinstance(result, str) and result.startswith("ERROR"): + s["status"] = "error" + s["message"] = result + _minimize_browser(bot) + asyncio.create_task(_remove_session_later(sid, 30)) + return {"status": "error", "message": result} + + # --- PDF + claim number --- + step7_result = bot.step7_save_confirmation_pdf() + print(f"[UnitedUcard Claim] step7 result: {step7_result}") + if isinstance(step7_result, str) and step7_result.startswith("ERROR"): + print(f"[UnitedUcard Claim] step7 warning (non-fatal): {step7_result}") + step7_result = {} + + pdf_path = step7_result.get("pdf_path") if isinstance(step7_result, dict) else None + pdf_url = None + if pdf_path: + import os as _os + filename = _os.path.basename(pdf_path) + port = _os.getenv("PORT", "5002") + url_host = _os.getenv("HOST", "localhost") + pdf_url = f"http://{url_host}:{port}/downloads/{filename}" + print(f"[UnitedUcard Claim] pdf_url: {pdf_url}") + + claim_number = step7_result.get("claimNumber") if isinstance(step7_result, dict) else None + + result = { + "status": "success", + "message": "United Ucard claim submitted successfully", + "claimNumber": claim_number, + "pdf_url": pdf_url, + } + s["status"] = "completed" + s["result"] = result + s["message"] = "completed" + + try: + bot.driver.get("about:blank") + try: + bot.driver.minimize_window() + except Exception: + try: + bot.driver.set_window_position(-10000, -10000) + except Exception: + pass + print("[UnitedUcard Claim] Browser minimized - session preserved for next run") + except Exception as close_err: + print(f"[UnitedUcard Claim] Could not minimize browser (non-fatal): {close_err}") + + asyncio.create_task(_remove_session_later(sid, 60)) + return result + + except Exception as e: + if s: + s["status"] = "error" + s["message"] = f"worker exception: {e}" + await cleanup_session(sid) + return {"status": "error", "message": f"worker exception: {e}"} + + +def submit_otp(sid: str, otp: str) -> Dict[str, Any]: + s = sessions.get(sid) + if not s: + return {"status": "error", "message": "session not found"} + if s.get("status") != "waiting_for_otp": + return {"status": "error", "message": f"session not waiting for otp (state={s.get('status')})"} + s["otp_value"] = otp + s["last_activity"] = time.time() + try: + s["otp_event"].set() + except Exception: + pass + return {"status": "ok", "message": "otp accepted"} + + +def get_session_status(sid: str) -> Dict[str, Any]: + s = sessions.get(sid) + if not s: + return {"status": "not_found"} + return { + "session_id": sid, + "status": s.get("status"), + "message": s.get("message"), + "created_at": s.get("created_at"), + "last_activity": s.get("last_activity"), + "result": s.get("result") if s.get("status") in ("completed", "error") else None, + } diff --git a/apps/SeleniumService/selenium_UnitedUcard_claimSubmitWorker.py b/apps/SeleniumService/selenium_UnitedUcard_claimSubmitWorker.py new file mode 100644 index 00000000..11bde7b9 --- /dev/null +++ b/apps/SeleniumService/selenium_UnitedUcard_claimSubmitWorker.py @@ -0,0 +1,771 @@ +""" +United Ucard (UHC Dental / OneHealthcareID) claim submission worker. + +Scaffolded from selenium_UnitedUcard_eligibilityCheckWorker.py — same shape +(config_driver / login / claim steps) so it plugs into the same +helpers_uniteducard_claim.py + agent.py session/OTP pattern, and shares the +persistent browser (uniteducard_browser_manager.py) with the eligibility +worker so a device already trusted by an eligibility check skips OTP here too. + +Login flow is identical to the eligibility worker (confirmed against the +live site, 2026-09-13) — see selenium_UnitedUcard_eligibilityCheckWorker.py +for the full annotated step-by-step. + +The claim-specific steps below (search patient / open claim form / fill +service lines / attach files / submit / save confirmation PDF) are left as +TODO stubs — fill these in once we know the exact DOM for the claim +submission flow on the UHC Dental provider dashboard. +""" +from selenium.common.exceptions import WebDriverException, TimeoutException +from selenium.webdriver.common.by import By +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC +import time +import os + +from uniteducard_browser_manager import get_browser_manager + +_SERVICE_DIR = os.path.dirname(os.path.abspath(__file__)) +_BACKEND_CWD = os.path.normpath(os.path.join(_SERVICE_DIR, "..", "Backend")) + + +class AutomationUnitedUcardClaimSubmit: + def __init__(self, data): + self.headless = False + self.driver = None + + self.data = data.get("claim", {}) if isinstance(data, dict) else {} + + # Flatten values for convenience + self.memberId = self.data.get("memberId", "") + self.dateOfBirth = self.data.get("dateOfBirth", "") + self.serviceDate = self.data.get("serviceDate", "") + self.firstName = self.data.get("firstName", "") + self.lastName = self.data.get("lastName", "") + self.serviceLines = self.data.get("serviceLines", []) + self.claimFiles = self.data.get("claimFiles", []) + self.providerName = self.data.get("providerName", "") + self.uniteducard_username = self.data.get("uniteducardUsername", "") + self.uniteducard_password = self.data.get("uniteducardPassword", "") + self.payment_group_id = self.data.get("paymentGroupId", "") + + # Use browser manager's download dir (shared with eligibility worker) + self.download_dir = get_browser_manager().download_dir + os.makedirs(self.download_dir, exist_ok=True) + + def config_driver(self): + # Use persistent browser from manager (keeps device trust tokens, + # shared with the eligibility worker's login session) + self.driver = get_browser_manager().get_driver(self.headless) + + def _force_logout(self): + """Force logout by clearing cookies for the One Healthcare ID domain.""" + try: + print("[UnitedUcard Claim login] Forcing logout due to credential change...") + browser_manager = get_browser_manager() + try: + self.driver.delete_all_cookies() + print("[UnitedUcard Claim login] Cleared all cookies") + except Exception as e: + print(f"[UnitedUcard Claim login] Error clearing cookies: {e}") + browser_manager.clear_credentials_hash() + print("[UnitedUcard Claim login] Logout complete") + return True + except Exception as e: + print(f"[UnitedUcard Claim login] Error during forced logout: {e}") + return False + + def login(self, url): + """ + Log in via https://www.uhcdental.com/content/provider/dental.html (Sign In -> + Provider Login), which OIDC-redirects through identity.onehealthcareid.com. + Identical flow to AutomationUnitedUcardEligibilityCheck.login(). + + Returns one of: + "ALREADY_LOGGED_IN" / "SUCCESS" - logged in, caller should proceed to claim steps + "OTP_REQUIRED" - caller should poll for OTP entry + "ERROR: " - login failed + """ + wait = WebDriverWait(self.driver, 30) + browser_manager = get_browser_manager() + + try: + if self.uniteducard_username and browser_manager.credentials_changed(self.uniteducard_username): + self._force_logout() + self.driver.get(url) + time.sleep(2) + + self.driver.get(url) + time.sleep(3) + + current_url = self.driver.current_url + print(f"[UnitedUcard Claim login] Current URL: {current_url}") + + if "secure.uhcdental.com" in current_url.lower(): + print("[UnitedUcard Claim login] Already logged in - on UHC Dental dashboard") + return "ALREADY_LOGGED_IN" + + if "onehealthcareid.com" not in current_url.lower(): + try: + signin_btn = WebDriverWait(self.driver, 10).until( + EC.element_to_be_clickable((By.XPATH, + "//span[contains(@class,'header-signin-btn') or contains(text(),'Sign In')]")) + ) + signin_btn.click() + print("[UnitedUcard Claim login] Clicked 'Sign In' to open dropdown") + time.sleep(1) + except TimeoutException: + return "ERROR: 'Sign In' button not found on UHC Dental provider site" + + clicked_provider_login = False + try: + provider_login_link = WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located((By.XPATH, + "//a[normalize-space(text())='Provider Login']")) + ) + self.driver.execute_script( + "arguments[0].scrollIntoView({block:'center'});", provider_login_link + ) + time.sleep(0.3) + try: + provider_login_link.click() + print("[UnitedUcard Claim login] Clicked 'Provider Login' (direct)") + clicked_provider_login = True + except Exception as direct_click_err: + print(f"[UnitedUcard Claim login] Direct click failed ({direct_click_err}); trying JS click") + self.driver.execute_script("arguments[0].click();", provider_login_link) + print("[UnitedUcard Claim login] Clicked 'Provider Login' (JS)") + clicked_provider_login = True + time.sleep(3) + except TimeoutException: + pass + + if not clicked_provider_login: + try: + dropdown_link = WebDriverWait(self.driver, 5).until( + EC.presence_of_element_located((By.XPATH, + "(//a[contains(@class,'sign-in-button-link')])[1]")) + ) + self.driver.execute_script("arguments[0].click();", dropdown_link) + print("[UnitedUcard Claim login] Clicked first dropdown link (Provider Login, by position)") + clicked_provider_login = True + time.sleep(3) + except Exception as e: + return f"ERROR: 'Provider Login' link not found in Sign In dropdown: {e}" + + current_url = self.driver.current_url + print(f"[UnitedUcard Claim login] After Provider Login click URL: {current_url}") + + if "secure.uhcdental.com" in current_url.lower(): + print("[UnitedUcard Claim login] Already logged in - redirected straight to dashboard") + return "ALREADY_LOGGED_IN" + + try: + WebDriverWait(self.driver, 3).until( + EC.presence_of_element_located((By.XPATH, + "//input[@type='tel' or contains(@placeholder,'code') or contains(@placeholder,'Code') or " + "contains(@aria-label,'Verification') or contains(@id,'otp') or contains(@name,'otp')]")) + ) + print("[UnitedUcard Claim login] OTP input found on landing") + return "OTP_REQUIRED" + except TimeoutException: + pass + + try: + username_field = wait.until( + EC.element_to_be_clickable((By.ID, "username")) + ) + username_field.clear() + username_field.send_keys(self.uniteducard_username) + print(f"[UnitedUcard Claim login] Entered username: {self.uniteducard_username}") + except TimeoutException: + return "ERROR: Username field not found on login page" + + try: + continue_btn = wait.until( + EC.element_to_be_clickable((By.ID, "btnLogin")) + ) + continue_btn.click() + print("[UnitedUcard Claim login] Clicked Continue (btnLogin) after username") + time.sleep(2) + except TimeoutException: + return "ERROR: Continue button (btnLogin) not found after entering username" + + try: + password_field = wait.until( + EC.presence_of_element_located((By.ID, "login-pwd")) + ) + password_field.clear() + password_field.send_keys(self.uniteducard_password) + print("[UnitedUcard Claim login] Entered password") + except TimeoutException: + return "ERROR: Password field not found on login page" + + try: + signin_button = wait.until( + EC.element_to_be_clickable((By.ID, "btnLogin")) + ) + signin_button.click() + print("[UnitedUcard Claim login] Clicked Continue (btnLogin) on password page") + except TimeoutException: + return "ERROR: Continue button (btnLogin) not found on password page" + + if self.uniteducard_username: + browser_manager.save_credentials_hash(self.uniteducard_username) + + time.sleep(5) + + current_url_after_login = self.driver.current_url.lower() + print(f"[UnitedUcard Claim login] After login URL: {current_url_after_login}") + + if "secure.uhcdental.com" in current_url_after_login: + print("[UnitedUcard Claim login] Login successful - redirected to UHC Dental dashboard") + return "SUCCESS" + + if "rba/options" in current_url_after_login: + try: + text_msg_btn = WebDriverWait(self.driver, 10).until( + EC.element_to_be_clickable((By.ID, "textMsg")) + ) + text_msg_btn.click() + print("[UnitedUcard Claim login] Clicked 'Via Text Message' on MFA options page") + time.sleep(3) + except TimeoutException: + print("[UnitedUcard Claim login] MFA options page detected but 'Via Text Message' button not found") + + current_url_after_login = self.driver.current_url.lower() + print(f"[UnitedUcard Claim login] After MFA method selection URL: {current_url_after_login}") + + try: + WebDriverWait(self.driver, 15).until( + EC.presence_of_element_located((By.XPATH, + "//input[@type='tel' or contains(@placeholder,'code') or contains(@placeholder,'Code') or " + "contains(@aria-label,'Verification') or contains(@aria-label,'verification') or " + "contains(@name,'otp') or contains(@name,'code') or contains(@id,'otp') or contains(@id,'code')]" + )) + ) + print("[UnitedUcard Claim login] OTP input detected -> OTP_REQUIRED") + return "OTP_REQUIRED" + except TimeoutException: + print("[UnitedUcard Claim login] No OTP input detected") + + current_url_after_login = self.driver.current_url.lower() + if "secure.uhcdental.com" in current_url_after_login: + print("[UnitedUcard Claim login] Login successful - redirected to UHC Dental dashboard") + return "SUCCESS" + + try: + error_elem = self.driver.find_element(By.XPATH, + "//*[contains(@class,'error') or contains(@class,'alert')]") + error_text = error_elem.text + if error_text: + print(f"[UnitedUcard Claim login] Error on page: {error_text}") + return f"ERROR: {error_text}" + except Exception: + pass + + return "ERROR: Login did not complete - still on identity domain" + + except Exception as e: + print(f"[UnitedUcard Claim login] Exception: {e}") + return f"ERROR:LOGIN FAILED: {e}" + + # ------------------------------------------------------------------ + # Claim steps — the UHC Dental "Dental Claims" flow is a wizard: + # Provider Information -> Patient Information -> Claim Information -> + # Upload Documents -> Review and Sign + # Each step should return "ERROR: " on failure, or a truthy + # result on success. + # ------------------------------------------------------------------ + + def _click_next(self, context: str = ""): + """Click the wizard's bottom 'Next' pill button. + + Confirmed against the live site (2026-09-13): on most wizard steps + the visible label is a plain
("Next"); on the Claim Information + step it's instead a chevron_right.jpg image with no text. Either way + the click target is whichever clickable ancestor wraps it. + """ + try: + next_label = WebDriverWait(self.driver, 15).until( + EC.presence_of_element_located((By.XPATH, + "//div[normalize-space(text())='Next'] | " + "//img[contains(@src,'chevron_right')]")) + ) + self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", next_label) + time.sleep(0.3) + # Prefer clicking a clickable ancestor (button/a/[role=button]) so + # the wizard's own click handler fires; fall back to the div itself. + target = next_label + try: + target = next_label.find_element(By.XPATH, + "ancestor::*[self::button or self::a or @role='button'][1]") + except Exception: + pass + try: + target.click() + except Exception: + self.driver.execute_script("arguments[0].click();", target) + print(f"[UnitedUcard Claim] Clicked 'Next'{f' ({context})' if context else ''}") + time.sleep(2) + return True + except TimeoutException: + return f"ERROR: 'Next' button not found{f' on {context}' if context else ''}" + + def step1_open_claims_page(self): + """ + Click 'Claims' in the top nav to open the Dental Claims wizard. + + Confirmed against the live site (2026-09-13): + Claims + Lands on the "Provider Information" step, pre-filled from the + logged-in account (phone, email, treating location, NPI, TIN). + """ + try: + claims_link = WebDriverWait(self.driver, 15).until( + EC.element_to_be_clickable((By.ID, "Claims")) + ) + claims_link.click() + print("[UnitedUcard Claim] Clicked 'Claims' nav link") + except Exception as click_err: + try: + self.driver.get( + "https://www.uhcdental.com/content/dental-benefits-provider/en/secure/dental-claim-form.html" + ) + print("[UnitedUcard Claim] Navigated directly to dental-claim-form.html (nav click failed)") + except Exception as nav_err: + return f"ERROR: Could not open Claims page: {click_err} / {nav_err}" + + try: + WebDriverWait(self.driver, 20).until( + EC.presence_of_element_located((By.XPATH, + "//*[contains(text(),'Provider Information')]")) + ) + print("[UnitedUcard Claim] Dental Claims wizard loaded - Provider Information step") + return True + except TimeoutException: + return "ERROR: Provider Information step did not load after clicking Claims" + + def step2_confirm_provider_information(self): + """ + Provider Information is pre-filled from the account (Phone, Email, + Treating Location, NPI, TIN) — nothing to fill, just advance. + """ + return self._click_next("Provider Information step") + + def _format_date_digits(self, date_str): + """Convert YYYY-MM-DD to MMDDYYYY (no separators) for typing into a + native — Chrome's date widget auto-advances + between the month/day/year segments as digits are typed.""" + if date_str and "-" in date_str: + parts = date_str.split("-") + if len(parts) == 3: + yyyy, mm, dd = parts + return f"{mm}{dd}{yyyy}" + return date_str + + def step3_fill_patient_information(self): + """ + Fill the Patient Information step: Date of Service, Subscriber ID, + Date of Birth, then click Search to look up the member. + + Confirmed against the live site (2026-09-13): + + + + + """ + try: + service_date_field = WebDriverWait(self.driver, 20).until( + EC.element_to_be_clickable((By.CSS_SELECTOR, "input.date-of-service")) + ) + service_date_field.click() + service_date_field.send_keys(self._format_date_digits(self.serviceDate)) + print(f"[UnitedUcard Claim] Entered Date of Service: {self.serviceDate}") + except TimeoutException: + return "ERROR: Date of Service field not found on Patient Information step" + + try: + subscriber_id_field = self.driver.find_element(By.CSS_SELECTOR, "input.subscriber-id") + subscriber_id_field.clear() + subscriber_id_field.send_keys(self.memberId) + print(f"[UnitedUcard Claim] Entered Subscriber ID: {self.memberId}") + except Exception as e: + return f"ERROR: Subscriber ID field not found on Patient Information step: {e}" + + try: + dob_field = self.driver.find_element(By.CSS_SELECTOR, "input.date-of-birth") + dob_field.click() + dob_field.send_keys(self._format_date_digits(self.dateOfBirth)) + print(f"[UnitedUcard Claim] Entered Date of Birth: {self.dateOfBirth}") + except Exception as e: + return f"ERROR: Date of Birth field not found on Patient Information step: {e}" + + try: + search_btn = WebDriverWait(self.driver, 10).until( + EC.element_to_be_clickable((By.CSS_SELECTOR, "button.search-button.member-search")) + ) + search_btn.click() + print("[UnitedUcard Claim] Clicked Search (member-search)") + time.sleep(2) + except TimeoutException: + return "ERROR: Search button not found on Patient Information step" + + return self._select_last_search_result() + + def _select_last_search_result(self): + """ + When the Subscriber ID + DOB search matches multiple coverage records + ("Duplicate records have been found"), select the LAST row in the + results table (per business rule — the last entry is the current/ + most-recent coverage record) and click SELECT. + + Confirmed against the live site (2026-09-13): a results table with + columns Name / Group / Coverage End Date / Address, a radio button + in the first cell of each row, and CANCEL / SELECT pill buttons + below the table. + """ + try: + result_rows = WebDriverWait(self.driver, 15).until( + lambda d: d.find_elements(By.CSS_SELECTOR, "table tbody tr") or None + ) + except TimeoutException: + # A single unambiguous match may skip the results table entirely + # and advance straight to Claim Information. + try: + WebDriverWait(self.driver, 5).until( + EC.presence_of_element_located((By.XPATH, "//*[contains(text(),'Claim Information')]")) + ) + print("[UnitedUcard Claim] Single match — already advanced to Claim Information") + return True + except TimeoutException: + return "ERROR: No search results table found after Search" + + if not result_rows: + return "ERROR: No matching patient records found" + + last_row = result_rows[-1] + try: + radio = last_row.find_element(By.CSS_SELECTOR, "input[type='radio']") + except Exception: + try: + radio = last_row.find_element(By.XPATH, ".//*[@role='radio' or contains(@class,'radio')]") + except Exception as e: + return f"ERROR: Could not find radio button in last search result row: {e}" + + self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", radio) + time.sleep(0.3) + try: + radio.click() + except Exception: + self.driver.execute_script("arguments[0].click();", radio) + print(f"[UnitedUcard Claim] Selected last matching patient record ({len(result_rows)} found)") + + # Confirmed against the live site (2026-09-13): + # + try: + select_btn = WebDriverWait(self.driver, 10).until( + EC.element_to_be_clickable((By.CSS_SELECTOR, "input[type='button'][value='select']")) + ) + select_btn.click() + print("[UnitedUcard Claim] Clicked SELECT (refineSearch)") + time.sleep(2) + except TimeoutException: + try: + select_btn = WebDriverWait(self.driver, 5).until( + EC.element_to_be_clickable((By.XPATH, + "//input[@onclick='refineSearch(this);'] | " + "//*[self::button or self::input][translate(@value,'SELECT','select')='select' or " + "normalize-space(text())='SELECT' or normalize-space(text())='Select']")) + ) + select_btn.click() + print("[UnitedUcard Claim] Clicked SELECT (fallback selector)") + time.sleep(2) + except TimeoutException: + return "ERROR: SELECT button not found after choosing patient record" + + # After SELECT, a "Member Details" table appears below with the + # patient's name as a link. Clicking it does NOT navigate away — + # it replaces the table with a confirmation card (avatar initials + + # name, Group Number, Subscriber ID, DOB, Address) on the same + # Patient Information page, and enables the bottom "Next" button. + try: + member_link = WebDriverWait(self.driver, 15).until( + EC.element_to_be_clickable((By.XPATH, + "//*[contains(text(),'Member Details')]/following::table[1]//a[1]")) + ) + member_name = member_link.text.strip() + member_link.click() + print(f"[UnitedUcard Claim] Clicked member link: {member_name}") + time.sleep(2) + except TimeoutException: + return "ERROR: Member Details link not found after selecting patient record" + + return self._click_next("Patient Information step") + + def _click_radio_by_id(self, element_id, label=""): + try: + radio = WebDriverWait(self.driver, 10).until( + EC.element_to_be_clickable((By.ID, element_id)) + ) + self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", radio) + try: + radio.click() + except Exception: + self.driver.execute_script("arguments[0].click();", radio) + print(f"[UnitedUcard Claim] Selected '{label or element_id}'") + return True + except TimeoutException: + return f"ERROR: Radio button #{element_id} ({label}) not found on Claim Information step" + + def step4_fill_claim_information(self): + """ + Fill the top of the Claim Information step's claim-level questions, + then (TODO) the Procedure section (Procedure Code / Date of Service / + Procedure Fee / Diagnosis Code(s)) from self.serviceLines. + + Confirmed against the live site (2026-09-13): + "Does the patient have additional insurance...?" -> No + + "Does this claim include EPSDT / Title XIX?" -> No + + "Who should be reimbursed for this claim?" -> Provider + + """ + for element_id, label in [ + ("PatientAdditionalCoverage1", "Additional Insurance: No"), + ("epsdtNo", "EPSDT / Title XIX: No"), + ("PHSubscriberDirectPayment0", "Reimbursement: Provider"), + ]: + result = self._click_radio_by_id(element_id, label) + if isinstance(result, str) and result.startswith("ERROR"): + return result + + if not self.serviceLines: + return "ERROR: No procedure/service lines to submit" + + for idx, line in enumerate(self.serviceLines): + if idx > 0: + add_result = self._click_add_procedure() + if isinstance(add_result, str) and add_result.startswith("ERROR"): + return add_result + result = self._fill_and_save_procedure(line, idx) + if isinstance(result, str) and result.startswith("ERROR"): + return result + + # After all procedures are saved, three more claim-level questions + # appear. Confirmed against the live site (2026-09-13): + # "Does this claim include orthodontics?" -> No + # + # "Does this claim include prosthesis (crown, bridge, or denture)?" -> No + # + # "Does this claim relate to an accident/injury/illness?" -> No + # + for element_id, label in [ + ("ClaimOrtho1", "Orthodontics: No"), + ("ClaimProsthesis1", "Prosthesis: No"), + ("OrthAccidentInjury1", "Accident/Injury/Illness: No"), + ]: + result = self._click_radio_by_id(element_id, label) + if isinstance(result, str) and result.startswith("ERROR"): + return result + + return self._click_next("Claim Information step") + + def _click_add_procedure(self): + """ + For a 2nd+ procedure line, click "Add a Procedure" to open a fresh + Procedure Code / Date of Service / Procedure Fee row before filling it. + + Confirmed against the live site (2026-09-13): +
Add a Procedure
+ """ + try: + add_btn = WebDriverWait(self.driver, 10).until( + EC.element_to_be_clickable((By.XPATH, "//*[@onclick='addProcCode()']")) + ) + add_btn.click() + print("[UnitedUcard Claim] Clicked 'Add a Procedure'") + time.sleep(1) + return True + except TimeoutException: + return "ERROR: 'Add a Procedure' button not found" + + def _fill_and_save_procedure(self, line: dict, idx: int): + """ + Fill one procedure row (Procedure Code, Date of Service, Procedure + Fee) and click Save Procedure. + + Confirmed against the live site (2026-09-13): + (5-char code, e.g. D0120) + + +
Save Procedure
+ """ + procedure_code = str(line.get("procedureCode", "")).strip() + fee = line.get("fee") + procedure_date = line.get("procedureDate") or self.serviceDate + + if not procedure_code: + return f"ERROR: Procedure line {idx + 1} has no procedure code" + + try: + code_field = WebDriverWait(self.driver, 15).until( + EC.element_to_be_clickable((By.ID, "dentalProcCode")) + ) + code_field.clear() + code_field.send_keys(procedure_code) + print(f"[UnitedUcard Claim] Entered Procedure Code: {procedure_code}") + except TimeoutException: + return f"ERROR: Procedure Code field not found for line {idx + 1}" + + try: + date_field = self.driver.find_element(By.ID, "serviceDate") + date_field.click() + date_field.send_keys(self._format_date_digits(procedure_date)) + print(f"[UnitedUcard Claim] Entered procedure Date of Service: {procedure_date}") + except Exception as e: + return f"ERROR: Date of Service field not found for line {idx + 1}: {e}" + + try: + fee_field = self.driver.find_element(By.ID, "ROSFeeX") + fee_field.clear() + fee_field.send_keys(str(fee) if fee is not None else "") + print(f"[UnitedUcard Claim] Entered Procedure Fee: {fee}") + except Exception as e: + return f"ERROR: Procedure Fee field not found for line {idx + 1}: {e}" + + try: + save_btn = WebDriverWait(self.driver, 10).until( + EC.element_to_be_clickable((By.XPATH, "//*[@onclick='saveProcCode()']")) + ) + save_btn.click() + print(f"[UnitedUcard Claim] Clicked Save Procedure for line {idx + 1}") + time.sleep(1.5) + except TimeoutException: + return f"ERROR: Save Procedure button not found for line {idx + 1}" + + return True + + def step5_upload_documents(self): + """ + Upload claim attachments via the dropzone's hidden file input, then + click "Review and Sign" to advance. + + Confirmed against the live site (2026-09-13): + +
...Review and Sign
(48px pill button, below Helpful Resources) + """ + attached = 0 + for cf in self.claimFiles: + relative_path = cf.get("filePath") or "" + if not relative_path: + print(f"[UnitedUcard Claim step5] Skipping file with no filePath: {cf}") + continue + + abs_path = os.path.normpath(os.path.join(_BACKEND_CWD, relative_path.lstrip("/"))) + if not os.path.isfile(abs_path): + print(f"[UnitedUcard Claim step5] File not found on disk: {abs_path}") + continue + + try: + # Don't click the visible "Browse" button — it's wired to the + # hidden #file-input and triggers the OS-native file picker, + # which WebDriver cannot control. Send the path directly to + # the input instead (chromedriver allows send_keys on file + # inputs even while display:none/hidden by the dropzone CSS). + file_input = WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located((By.ID, "file-input")) + ) + file_input.send_keys(abs_path) + time.sleep(1.5) + print(f"[UnitedUcard Claim step5] Attached: {os.path.basename(abs_path)}") + attached += 1 + except Exception as e: + print(f"[UnitedUcard Claim step5] Could not attach {abs_path}: {type(e).__name__}: {e}") + + print(f"[UnitedUcard Claim step5] Attached {attached}/{len(self.claimFiles)} file(s)") + + try: + review_label = WebDriverWait(self.driver, 15).until( + EC.presence_of_element_located((By.XPATH, + "//div[normalize-space(text())='Review and Sign']")) + ) + self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", review_label) + time.sleep(0.3) + target = review_label + try: + target = review_label.find_element(By.XPATH, + "ancestor::*[self::button or self::a or @role='button' or @data-state][1]") + except Exception: + pass + try: + target.click() + except Exception: + self.driver.execute_script("arguments[0].click();", target) + print("[UnitedUcard Claim] Clicked 'Review and Sign'") + time.sleep(2) + return True + except TimeoutException: + return "ERROR: 'Review and Sign' button not found on Upload Documents step" + + def step6_review_and_sign(self): + """ + Check the two "signature on file" attestation checkboxes at the + bottom of the Review and Sign step. + + Confirmed against the live site (2026-09-13): + + + """ + for element_id, label in [ + ("MemberPatientSignatureOnFile_0", "Patient signature on file"), + ("PHSubscriberSignatureOnFile_0", "Subscriber signature on file"), + ]: + result = self._click_radio_by_id(element_id, label) + if isinstance(result, str) and result.startswith("ERROR"): + return result + + # Provider signature — the default provider configured in Settings + # (sortOrder asc, first entry), passed through as self.providerName. + # Confirmed against the live site (2026-09-13): + # + try: + signature_field = WebDriverWait(self.driver, 15).until( + EC.element_to_be_clickable((By.ID, "userSignatureText")) + ) + signature_field.clear() + signature_field.send_keys(self.providerName) + print(f"[UnitedUcard Claim] Entered provider signature: {self.providerName}") + except TimeoutException: + return "ERROR: Provider signature field (#userSignatureText) not found on Review and Sign step" + + # Confirmed against the live site (2026-09-13): + #
Submit
+ try: + submit_btn = WebDriverWait(self.driver, 10).until( + EC.element_to_be_clickable((By.CSS_SELECTOR, ".submit-claim")) + ) + submit_btn.click() + print("[UnitedUcard Claim] Clicked Submit (submitClaimForm)") + time.sleep(2) + except TimeoutException: + try: + submit_btn = WebDriverWait(self.driver, 5).until( + EC.element_to_be_clickable((By.XPATH, "//*[@onclick='submitClaimForm();']")) + ) + submit_btn.click() + print("[UnitedUcard Claim] Clicked Submit (fallback selector)") + time.sleep(2) + except TimeoutException: + return "ERROR: Submit button (.submit-claim) not found on Review and Sign step" + + return True + + def step7_save_confirmation_pdf(self): + """TODO: capture the claim confirmation number and save/print the confirmation as PDF + into self.download_dir. Return {"pdf_path": "...", "claimNumber": "..."}.""" + return {}