diff --git a/apps/Backend/src/data/insuranceAliases.json b/apps/Backend/src/data/insuranceAliases.json index cacb05e1..26978ac2 100644 --- a/apps/Backend/src/data/insuranceAliases.json +++ b/apps/Backend/src/data/insuranceAliases.json @@ -12,6 +12,7 @@ { "keyword": "delta dental ins", "siteKey": "DELTA_INS" }, { "keyword": "delta ins", "siteKey": "DELTA_INS" }, + { "keyword": "deltains", "siteKey": "DELTA_INS" }, { "keyword": "delta dental", "siteKey": "DELTA_INS" }, { "keyword": "tufts sco", "siteKey": "TUFTS_SCO" }, diff --git a/apps/Backend/src/queue/jobRunner.ts b/apps/Backend/src/queue/jobRunner.ts index bc127ccd..8d38e3a9 100644 --- a/apps/Backend/src/queue/jobRunner.ts +++ b/apps/Backend/src/queue/jobRunner.ts @@ -20,6 +20,7 @@ import { runCCAEligibilityProcessor } from "./processors/ccaEligibilityProcessor import { runCCAClaimProcessor } from "./processors/ccaClaimProcessor"; import { runCCAPreAuthProcessor } from "./processors/ccaPreAuthProcessor"; import { runDDMAClaimProcessor } from "./processors/ddmaClaimProcessor"; +import { runDeltainsClaimProcessor } from "./processors/deltainsClaimProcessor"; import { runUnitedDHClaimProcessor } from "./processors/unitedDHClaimProcessor"; import { runUnitedDHPreAuthProcessor } from "./processors/unitedDHPreAuthProcessor"; import { runTuftsSCOPreAuthProcessor } from "./processors/tuftsSCOPreAuthProcessor"; @@ -191,6 +192,17 @@ export function enqueueSeleniumJob(data: SeleniumJobData): string { job.id ); } + if (jobType === "deltains-claim-submit") { + return runDeltainsClaimProcessor( + { + enrichedPayload: data.enrichedPayload, + userId: data.userId, + claimId: data.claimId, + socketId: data.socketId, + }, + job.id + ); + } if (jobType === "uniteddh-claim-submit") { return runUnitedDHClaimProcessor( { diff --git a/apps/Backend/src/queue/processors/deltainsClaimProcessor.ts b/apps/Backend/src/queue/processors/deltainsClaimProcessor.ts new file mode 100644 index 00000000..9e812feb --- /dev/null +++ b/apps/Backend/src/queue/processors/deltainsClaimProcessor.ts @@ -0,0 +1,185 @@ +/** + * Processor for "deltains-claim-submit" jobs. + * Opens a claim on the Delta Dental Ins provider portal via Selenium. + * + * Flow: + * 1. POST /deltains-claim to Python agent → get session_id + * 2. Emit selenium:deltains_claim_started to frontend + * 3. Poll until completed/error + * 4. Emit result + */ +import { + forwardToSeleniumDeltainsClaimAgent, + getSeleniumDeltainsClaimSessionStatus, +} from "../../services/seleniumDeltainsClaimClient"; +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(`Deltains claim polling timeout for session ${sessionId}`); + } + try { + const st = await getSeleniumDeltainsClaimSessionStatus(sessionId); + const status: string = st?.status ?? "unknown"; + log("deltains-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 Deltains 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 || `Deltains 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 Deltains 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(`Deltains 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("deltains-claim-processor", "PDF saved", { patientId, filename }); + } catch (err: any) { + log("deltains-claim-processor", "failed to save PDF (non-fatal)", { error: err?.message ?? err }); + } +} + +export interface DeltainsClaimProcessorInput { + enrichedPayload: any; + userId: number; + claimId?: number; + socketId?: string; +} + +export async function runDeltainsClaimProcessor( + input: DeltainsClaimProcessorInput, + jobId: string +): Promise<{ status: string; pdf_url?: string; claimNumber?: string }> { + const { enrichedPayload, userId, claimId, socketId } = input; + + log("deltains-claim-processor", "starting Python agent session", { claimId }); + const agentResp = await forwardToSeleniumDeltainsClaimAgent(enrichedPayload); + + if (!agentResp?.session_id) { + throw new Error("Python agent did not return a session_id for Deltains claim"); + } + + const sessionId = agentResp.session_id as string; + log("deltains-claim-processor", "got session_id", { sessionId }); + + emitToSocket(socketId, "selenium:deltains_claim_started", { session_id: sessionId, jobId }); + + const seleniumResult = await pollUntilDone(sessionId, socketId, jobId); + + if (!seleniumResult || seleniumResult.status === "error") { + throw new Error(seleniumResult?.message ?? "Deltains 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 + if (claimId) { + try { + const updates: Record = { status: "REVIEW" }; + if (claimNumber) updates.claimNumber = claimNumber; + await storage.updateClaim(claimId, updates); + log("deltains-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("deltains-claim-processor", "patient touched", { patientId: claim.patientId }); + } + } catch (e) { + log("deltains-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:deltains_claim_completed", { + jobId, + claimId, + claimNumber, + pdf_url, + message: claimNumber + ? `Deltains claim submitted — Claim #: ${claimNumber}` + : (seleniumResult?.message ?? "Deltains claim submitted successfully"), + }); + + log("deltains-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 eb6d065e..5ce8b1d9 100644 --- a/apps/Backend/src/queue/queues.ts +++ b/apps/Backend/src/queue/queues.ts @@ -15,6 +15,7 @@ export type SeleniumJobType = | "cca-claim-submit" | "cca-preauth-submit" | "ddma-claim-submit" + | "deltains-claim-submit" | "tuftssco-claim-submit" | "uniteducard-claim-submit" | "uniteddh-claim-submit" diff --git a/apps/Backend/src/routes/claims.ts b/apps/Backend/src/routes/claims.ts index 7edabbe4..27962187 100755 --- a/apps/Backend/src/routes/claims.ts +++ b/apps/Backend/src/routes/claims.ts @@ -588,6 +588,7 @@ function batchColumnDeriveSiteKey(provider: string): string { if (!p || p.includes("masshealth") || p === "mh" || p === "mass health") return "MH"; if (p.includes("commonwealth care alliance") || p === "cca") return "CCA"; if (p.includes("ddma") || p.includes("delta dental ma")) return "DDMA"; + if (p.includes("delta ins") || p === "deltains") return "DELTAINS"; 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"; @@ -599,6 +600,7 @@ function batchColumnCanonicalName(siteKey: string): string { switch (siteKey) { case "CCA": return "CCA"; case "DDMA": return "Delta Dental MA"; + case "DELTAINS": return "Delta Dental Ins"; case "TUFTS_SCO": return "Tufts SCO"; case "UNITED_SCO": return "United/DentalHub"; case "UNITED_UCARD": return "United Ucard"; @@ -947,6 +949,24 @@ router.post( enrichedPayload, claimId, }); + } else if (siteKey === "DELTAINS") { + const enrichedPayload = { + claim: { + ...baseClaimPayload, + insuranceProvider: "Delta Dental Ins", + insuranceSiteKey: "DELTAINS", + deltainsUsername: credentials.username, + deltainsPassword: credentials.password, + providerName: resolvedNpiProvider?.providerName ?? "", + claimFiles: diskClaimFiles, + }, + }; + jobId = enqueueSeleniumJob({ + jobType: "deltains-claim-submit", + userId: req.user.id, + enrichedPayload, + claimId, + }); } else if (siteKey === "TUFTS_SCO") { const enrichedPayload = { claim: { diff --git a/apps/Backend/src/routes/index.ts b/apps/Backend/src/routes/index.ts index 8f06c6fb..cd34e62e 100755 --- a/apps/Backend/src/routes/index.ts +++ b/apps/Backend/src/routes/index.ts @@ -20,6 +20,7 @@ import insuranceStatusCCARoutes from "./insuranceStatusCCA"; import insuranceStatusCCAClaimRoutes from "./insuranceStatusCCAClaim"; import insuranceStatusCCAPreAuthRoutes from "./insuranceStatusCCAPreAuth"; import insuranceStatusDDMAClaimRoutes from "./insuranceStatusDDMAClaim"; +import insuranceStatusDeltainsClaimRoutes from "./insuranceStatusDeltainsClaim"; import insuranceStatusUnitedDHClaimRoutes from "./insuranceStatusUnitedDHClaim"; import insuranceStatusUnitedDHPreAuthRoutes from "./insuranceStatusUnitedDHPreAuth"; import insuranceStatusTuftsSCOClaimRoutes from "./insuranceStatusTuftsSCOClaim"; @@ -75,6 +76,7 @@ router.use("/insurance-status-bcbs-ma", insuranceStatusBcbsMaRoutes); router.use("/claims", insuranceStatusCCAClaimRoutes); router.use("/claims", insuranceStatusCCAPreAuthRoutes); router.use("/claims", insuranceStatusDDMAClaimRoutes); +router.use("/claims", insuranceStatusDeltainsClaimRoutes); router.use("/claims", insuranceStatusUnitedDHClaimRoutes); router.use("/claims", insuranceStatusUnitedDHPreAuthRoutes); router.use("/claims", insuranceStatusTuftsSCOClaimRoutes); diff --git a/apps/Backend/src/routes/insuranceStatusDeltainsClaim.ts b/apps/Backend/src/routes/insuranceStatusDeltainsClaim.ts new file mode 100644 index 00000000..f1196b87 --- /dev/null +++ b/apps/Backend/src/routes/insuranceStatusDeltainsClaim.ts @@ -0,0 +1,103 @@ +import { Router, Request, Response } from "express"; +import { storage } from "../storage"; +import { enqueueSeleniumJob } from "../queue/jobRunner"; +import { forwardOtpToSeleniumDeltainsClaimAgent } from "../services/seleniumDeltainsClaimClient"; +import { io } from "../socket"; + +const router = Router(); + +/** + * POST /deltains-claim + * + * Enqueues a Delta Dental Ins 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("/deltains-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 Deltains credentials + const credentials = await storage.getInsuranceCredentialByUserAndSiteKey( + req.user.id, + "DELTAINS" + ); + if (!credentials) { + return res.status(404).json({ + error: "No Delta Dental Ins 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, + deltainsUsername: credentials.username, + deltainsPassword: credentials.password, + providerName, + }, + }; + + const socketId: string | undefined = req.body.socketId; + const claimId: number | undefined = claimData.claimId + ? Number(claimData.claimId) + : undefined; + + const jobId = enqueueSeleniumJob({ + jobType: "deltains-claim-submit", + userId: req.user.id, + socketId, + enrichedPayload, + claimId, + }); + + return res.json({ status: "queued", jobId }); + } catch (err: any) { + console.error("[deltains-claim route] error:", err); + return res.status(500).json({ + error: err.message || "Failed to enqueue Deltains claim job", + }); + } +}); + +/** + * POST /claims/deltains-claim/selenium/submit-otp + * Body: { session_id, otp, socketId? } + */ +router.post("/deltains-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 forwardOtpToSeleniumDeltainsClaimAgent(sessionId, otp); + if (socketId && io) { + io.to(socketId).emit("selenium:otp_submitted", { session_id: sessionId }); + } + return res.json(r); + } catch (err: any) { + console.error("[deltains-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/seleniumDeltainsClaimClient.ts b/apps/Backend/src/services/seleniumDeltainsClaimClient.ts new file mode 100644 index 00000000..375cc015 --- /dev/null +++ b/apps/Backend/src/services/seleniumDeltainsClaimClient.ts @@ -0,0 +1,35 @@ +import axios from "axios"; + +const SELENIUM_BASE = process.env.SELENIUM_SERVICE_URL ?? "http://localhost:5002"; + +/** + * POST /deltains-claim + * Returns { status: "started", session_id: "" } + */ +export async function forwardToSeleniumDeltainsClaimAgent( + data: Record +): Promise<{ status: string; session_id: string }> { + const resp = await axios.post(`${SELENIUM_BASE}/deltains-claim`, data); + return resp.data; +} + +/** + * GET /session/{sid}/status + */ +export async function getSeleniumDeltainsClaimSessionStatus( + sessionId: string +): Promise> { + const resp = await axios.get(`${SELENIUM_BASE}/session/${sessionId}/status`); + return resp.data; +} + +/** + * POST /session/{sid}/submit-otp + */ +export async function forwardOtpToSeleniumDeltainsClaimAgent( + 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 e97711b5..a9e09d08 100755 --- a/apps/Frontend/src/components/claims/claim-form.tsx +++ b/apps/Frontend/src/components/claims/claim-form.tsx @@ -98,6 +98,7 @@ interface ClaimFormProps { onHandleForUnitedDHSeleniumPreAuth: (data: ClaimPreAuthData) => void; onHandleForTuftsSCOSeleniumPreAuth: (data: ClaimPreAuthData) => void; onHandleForDDMASeleniumClaim: (data: ClaimFormData) => void; + onHandleForDeltainsSeleniumClaim: (data: ClaimFormData) => void; onHandleForUnitedDHSeleniumClaim: (data: ClaimFormData) => void; onHandleForTuftsSCOSeleniumClaim: (data: ClaimFormData) => void; onHandleForUnitedUcardSeleniumClaim: (data: ClaimFormData) => void; @@ -121,6 +122,7 @@ export function ClaimForm({ onHandleForUnitedDHSeleniumPreAuth, onHandleForTuftsSCOSeleniumPreAuth, onHandleForDDMASeleniumClaim, + onHandleForDeltainsSeleniumClaim, onHandleForUnitedDHSeleniumClaim, onHandleForTuftsSCOSeleniumClaim, onHandleForUnitedUcardSeleniumClaim, @@ -1257,6 +1259,73 @@ export function ClaimForm({ onClose(); }; + // Deltains Claim: saves to DB then submits via Selenium + const handleDeltainsClaim = 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: "Delta Dental Ins", + appointmentId: appointmentIdToUse, + claimFiles: claimFilesMeta, + ...(selectedNpiProviderId ? { npiProviderId: selectedNpiProviderId } : {}), + }); + + onHandleForDeltainsSeleniumClaim({ + ...f, + serviceLines: filteredServiceLines, + staffId: appointmentStaffId ?? Number(staff?.id), + patientId, + insuranceProvider: "Delta Dental Ins", + appointmentId: appointmentIdToUse, + insuranceSiteKey: "DELTAINS", + claimId: createdClaim.id, + claimFiles: claimFilesMeta, + }); + + onClose(); + }; + // United/DentalHub Claim: saves to DB then submits via Selenium const handleUnitedDHClaim = async (formToUse?: ClaimFormData & { uploadedFiles?: File[] }) => { const f = formToUse ?? form; @@ -1483,6 +1552,8 @@ export function ClaimForm({ runWithPriceCheck(handleCCAClaim); } else if (siteKey === "DDMA") { runWithPriceCheck(handleDDMAClaim); + } else if (siteKey === "DELTAINS" || siteKey === "DELTA_INS") { + runWithPriceCheck(handleDeltainsClaim); } else if (siteKey === "TUFTSSCO" || siteKey === "TUFTS_SCO") { runWithPriceCheck(handleTuftsSCOClaim); } else if (siteKey === "UNITEDSCO" || siteKey === "UNITEDDH" || siteKey === "UNITED_SCO") { @@ -1954,6 +2025,8 @@ export function ClaimForm({ handleCCAClaim(pricedForm); } else if (key === "ddma") { handleDDMAClaim(pricedForm); + } else if (key === "deltains" || key === "delta_ins") { + handleDeltainsClaim(pricedForm); } else if (key === "united_sco" || key === "unitedco" || key === "dentalhub") { handleUnitedDHClaim(pricedForm); } else if (key === "united_ucard" || key === "uniteducard" || key === "ucard") { @@ -2591,6 +2664,14 @@ export function ClaimForm({ > Delta MA Claim + + +

+ The Delta Dental Ins 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()) handleDeltainsClaimOtpSubmit(input.value.trim()); + }} className="space-y-4"> +
+ + +
+
+ + +
+
+ + + )} + {/* Tufts SCO Claim OTP Modal */} {tuftsSCOClaimOtpOpen && (
diff --git a/apps/SeleniumService/agent.py b/apps/SeleniumService/agent.py index dede3eb8..62d1b010 100755 --- a/apps/SeleniumService/agent.py +++ b/apps/SeleniumService/agent.py @@ -23,6 +23,7 @@ import helpers_cca_eligibility as hcca import helpers_cca_claim as hcca_claim import helpers_cca_preauth as hcca_preauth import helpers_ddma_claim as hddma_claim +import helpers_deltains_claim as hdeltains_claim import helpers_uniteddh_claim as huniteddh_claim import helpers_uniteddh_preauth as huniteddh_preauth import helpers_tuftssco_claim as htuftssco_claim @@ -749,6 +750,48 @@ async def ddma_claim(request: Request): return {"status": "started", "session_id": sid} +async def _deltains_claim_worker_wrapper(sid: str, data: dict, url: str): + """Background worker for Deltains claim submission.""" + global active_jobs, waiting_jobs + async with semaphore: + async with lock: + waiting_jobs -= 1 + active_jobs += 1 + try: + await hdeltains_claim.start_deltains_claim_run(sid, data, url) + finally: + async with lock: + active_jobs -= 1 + + +@app.post("/deltains-claim") +async def deltains_claim(request: Request): + """ + Starts a Deltains claim submission session in the background. + Logs in, searches patient, opens the member's eligibility/benefits page, + finds the claim-submission entry point, fills service lines, and submits. + Body: { "claim": { "deltainsUsername": "...", "deltainsPassword": "...", ... } } + Returns: { status: "started", session_id: "" } + """ + global waiting_jobs + + body = await request.json() + + sid = hdeltains_claim.make_session_entry() + hdeltains_claim.sessions[sid]["type"] = "deltains_claim" + hdeltains_claim.sessions[sid]["last_activity"] = time.time() + + async with lock: + waiting_jobs += 1 + + asyncio.create_task(_deltains_claim_worker_wrapper( + sid, body, + url="https://www.deltadentalins.com/ciam/login?TARGET=%2Fprovider-tools%2Fv2" + )) + + return {"status": "started", "session_id": sid} + + async def _uniteddh_claim_worker_wrapper(sid: str, data: dict, url: str): """Background worker for United/DentalHub claim submission.""" global active_jobs, waiting_jobs @@ -1025,6 +1068,8 @@ async def submit_otp(request: Request): res = hdentaquest.submit_otp(sid, otp) elif sid in hddma_claim.sessions: res = hddma_claim.submit_otp(sid, otp) + elif sid in hdeltains_claim.sessions: + res = hdeltains_claim.submit_otp(sid, otp) elif sid in huniteddh_claim.sessions: res = huniteddh_claim.submit_otp(sid, otp) elif sid in huniteddh_preauth.sessions: @@ -1066,6 +1111,8 @@ async def session_status(sid: str): s = hcca_preauth.get_session_status(sid) elif sid in hddma_claim.sessions: s = hddma_claim.get_session_status(sid) + elif sid in hdeltains_claim.sessions: + s = hdeltains_claim.get_session_status(sid) elif sid in huniteddh_claim.sessions: s = huniteddh_claim.get_session_status(sid) elif sid in huniteddh_preauth.sessions: diff --git a/apps/SeleniumService/helpers_deltains_claim.py b/apps/SeleniumService/helpers_deltains_claim.py new file mode 100644 index 00000000..33353108 --- /dev/null +++ b/apps/SeleniumService/helpers_deltains_claim.py @@ -0,0 +1,349 @@ +import os +import time +import asyncio +from typing import Dict, Any +from selenium.webdriver.common.by import By +from selenium.webdriver.common.keys import Keys +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_DeltaIns_claimSubmitWorker import AutomationDeltaInsClaimSubmit +from deltains_browser_manager import get_browser_manager + +sessions: Dict[str, Dict[str, Any]] = {} + +SESSION_OTP_TIMEOUT = int(os.getenv("SESSION_OTP_TIMEOUT", "240")) + + +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() + finally: + sessions.pop(sid, None) + print(f"[helpers_deltains_claim] cleaned session {sid}") + + +async def _remove_session_later(sid: str, delay: int = 30): + await asyncio.sleep(delay) + await cleanup_session(sid) + + +def _close_browser(bot): + """Save cookies and close the browser after task completion.""" + try: + bm = get_browser_manager() + try: + bm.save_cookies() + except Exception: + pass + try: + bm.quit_driver() + print("[Deltains Claim] Browser closed") + except Exception: + pass + except Exception as e: + print(f"[Deltains Claim] Could not close browser: {e}") + + +async def start_deltains_claim_run(sid: str, data: dict, url: str): + """ + Run the Deltains claim workflow — reuses the Okta login/OTP handling + proven in helpers_deltains_eligibility.py, then continues into + claim-specific steps mirroring helpers_ddma_claim.py's structure. + """ + 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 = AutomationDeltaInsClaimSubmit(data) + bot.config_driver() + + s["bot"] = bot + s["driver"] = bot.driver + s["last_activity"] = time.time() + + try: + bot.driver.maximize_window() + except Exception: + pass + + 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"]) + _close_browser(bot) + 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"]) + _close_browser(bot) + return {"status": "error", "message": s["message"]} + + # ── Already logged in (persistent session) ──────────────────────────── + if isinstance(login_result, str) and login_result == "ALREADY_LOGGED_IN": + print("[Deltains Claim] Session persisted — skipping OTP") + s["status"] = "running" + s["message"] = "Session persisted" + get_browser_manager().save_cookies() + + # ── OTP required ────────────────────────────────────────────────────── + elif isinstance(login_result, str) and login_result == "OTP_REQUIRED": + s["status"] = "waiting_for_otp" + s["message"] = "OTP required — please enter the code sent to your email" + s["last_activity"] = time.time() + + driver = s["driver"] + max_polls = SESSION_OTP_TIMEOUT + login_success = False + + print(f"[Deltains 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"[Deltains Claim OTP poll {poll+1}] OTP received from app, typing it in...") + try: + otp_input = driver.find_element(By.XPATH, + "//input[@name='credentials.passcode' and @type='text'] | " + "//input[contains(@name,'passcode')]") + otp_input.clear() + otp_input.send_keys(otp_value) + try: + verify_btn = driver.find_element(By.XPATH, + "//input[@type='submit'] | " + "//button[@type='submit']") + verify_btn.click() + except Exception: + otp_input.send_keys(Keys.RETURN) + print("[Deltains Claim OTP] OTP typed and submitted via app") + s["otp_value"] = None + await asyncio.sleep(8) + except Exception as type_err: + print(f"[Deltains Claim OTP] Failed to type OTP from app: {type_err}") + + current_url = driver.current_url.lower() + if poll % 10 == 0: + print(f"[Deltains Claim OTP poll {poll+1}/{max_polls}] URL: {current_url[:80]}") + + if "provider-tools" in current_url and "login" not in current_url and "ciam" not in current_url: + print("[Deltains Claim OTP] Login successful!") + login_success = True + break + + except Exception as poll_err: + if poll % 10 == 0: + print(f"[Deltains Claim OTP poll {poll+1}] Error: {poll_err}") + + if not login_success: + try: + current_url = driver.current_url.lower() + if "provider-tools" in current_url and "login" not in current_url and "ciam" not in current_url: + login_success = True + else: + s["status"] = "error" + s["message"] = "OTP timeout — login not completed" + await cleanup_session(sid) + _close_browser(bot) + return {"status": "error", "message": "OTP not completed in time"} + except Exception as final_err: + s["status"] = "error" + s["message"] = f"OTP verification failed: {final_err}" + await cleanup_session(sid) + _close_browser(bot) + return {"status": "error", "message": s["message"]} + + if login_success: + s["status"] = "running" + s["message"] = "Login successful after OTP" + print("[Deltains Claim OTP] Proceeding to claim steps...") + get_browser_manager().save_cookies() + + # ── Login succeeded without OTP ─────────────────────────────────────── + elif isinstance(login_result, str) and login_result == "SUCCESS": + print("[Deltains Claim] Login succeeded without OTP") + s["status"] = "running" + s["message"] = "Login succeeded" + get_browser_manager().save_cookies() + + # ── Login error ─────────────────────────────────────────────────────── + elif isinstance(login_result, str) and login_result.startswith("ERROR"): + s["status"] = "error" + s["message"] = login_result + await cleanup_session(sid) + _close_browser(bot) + return {"status": "error", "message": login_result} + + # --- Step 1: Search patient --- + step1_result = bot.step1_search_patient() + print(f"[Deltains 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) + _close_browser(bot) + return {"status": "error", "message": step1_result} + + # --- Step 2: Select transaction type (search lands directly on the --- + # --- "Submit a claim" page, so no separate member-page/create-claim --- + # --- click is needed here, unlike the DDMA flow) --- + step2_result = bot.step2_select_transaction_type() + print(f"[Deltains 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) + _close_browser(bot) + return {"status": "error", "message": step2_result} + + # --- Step 4: Fill service date + procedure lines --- + step4_result = bot.step4_fill_claim_form() + print(f"[Deltains 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) + _close_browser(bot) + return {"status": "error", "message": step4_result} + + # --- Step 5: Attach files --- + step5_result = bot.step5_attach_files() + print(f"[Deltains 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) + _close_browser(bot) + return {"status": "error", "message": step5_result} + + # --- Step 6: Click Next step --- + step6_result = bot.step6_click_next() + print(f"[Deltains 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) + _close_browser(bot) + return {"status": "error", "message": step6_result} + + # --- Step 7: Acknowledge + Submit claim --- + step7_result = bot.step7_submit_claim() + print(f"[Deltains 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) + _close_browser(bot) + return {"status": "error", "message": step7_result} + + # --- Step 8: Extract claim number + save confirmation PDF --- + step8_result = bot.step8_save_confirmation_pdf() + print(f"[Deltains Claim] step8 result: {step8_result}") + if isinstance(step8_result, str) and step8_result.startswith("ERROR"): + # Non-fatal: claim was submitted; log but don't abort + print(f"[Deltains Claim] step8 warning (non-fatal): {step8_result}") + step8_result = {} + + pdf_path = step8_result.get("pdf_path") if isinstance(step8_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"[Deltains Claim] pdf_url: {pdf_url}") + + claim_number = step8_result.get("claimNumber") if isinstance(step8_result, dict) else None + + result = { + "status": "success", + "message": "Deltains claim submitted successfully", + "claimNumber": claim_number, + "pdf_url": pdf_url, + } + s["status"] = "completed" + s["result"] = result + s["message"] = "completed" + + # Leave the browser window open on the "My claims" page (persistent session) + print("[Deltains Claim] Claim completed - browser left open on My claims page") + + 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}" + if bot: + _close_browser(bot) + 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_DeltaIns_claimSubmitWorker.py b/apps/SeleniumService/selenium_DeltaIns_claimSubmitWorker.py new file mode 100644 index 00000000..8459a1bc --- /dev/null +++ b/apps/SeleniumService/selenium_DeltaIns_claimSubmitWorker.py @@ -0,0 +1,821 @@ +from selenium.common.exceptions import TimeoutException +from selenium.webdriver.common.by import By +from selenium.webdriver.common.keys import Keys +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC +import time +import os +import re +import base64 + +from deltains_browser_manager import get_browser_manager + +LOGIN_URL = "https://www.deltadentalins.com/ciam/login?TARGET=%2Fprovider-tools%2Fv2" +PROVIDER_TOOLS_URL = "https://www.deltadentalins.com/provider-tools/v2" + +# Absolute path to the Backend app root (two levels up from this file's SeleniumService dir) +_SERVICE_DIR = os.path.dirname(os.path.abspath(__file__)) +_BACKEND_CWD = os.path.normpath(os.path.join(_SERVICE_DIR, "..", "Backend")) + + +class AutomationDeltaInsClaimSubmit: + def __init__(self, data): + self.headless = False + self.driver = None + + raw = data if isinstance(data, dict) else {} + claim = raw.get("claim", {}) or {} + + patient_name = (claim.get("patientName") or "").strip() + parts = patient_name.split() + first_name = parts[0] if parts else "" + last_name = " ".join(parts[1:]) if len(parts) > 1 else "" + + self.deltains_username = claim.get("deltainsUsername", "") or raw.get("deltainsUsername", "") + self.deltains_password = claim.get("deltainsPassword", "") or raw.get("deltainsPassword", "") + self.memberId = claim.get("memberId", "") + self.dateOfBirth = claim.get("dateOfBirth", "") + self.serviceDate = claim.get("serviceDate", "") + self.firstName = claim.get("firstName", "") or first_name + self.lastName = claim.get("lastName", "") or last_name + self.serviceLines = claim.get("serviceLines", []) or [] + self.claimFiles = claim.get("claimFiles", []) or [] + self.providerName = claim.get("providerName", "") or raw.get("providerName", "") + + self.download_dir = get_browser_manager().download_dir + os.makedirs(self.download_dir, exist_ok=True) + + print(f"[Deltains Claim] Init — member={self.memberId}, " + f"patient={self.firstName} {self.lastName}, " + f"lines={len(self.serviceLines)}") + + def config_driver(self): + self.driver = get_browser_manager().get_driver(self.headless) + + # ------------------------------------------------------------------ # + # Login — copied from AutomationDeltaInsEligibilityCheck.login() # + # (Okta-based, already proven working for the eligibility flow) # + # ------------------------------------------------------------------ # + def _dismiss_cookie_banner(self): + try: + accept_btn = WebDriverWait(self.driver, 5).until( + EC.element_to_be_clickable((By.ID, "onetrust-accept-btn-handler")) + ) + accept_btn.click() + print("[Deltains Claim login] Dismissed cookie consent banner") + time.sleep(1) + except TimeoutException: + print("[Deltains Claim login] No cookie consent banner found") + except Exception as e: + print(f"[Deltains Claim login] Error dismissing cookie banner: {e}") + + def _force_logout(self): + try: + print("[Deltains Claim login] Forcing logout due to credential change...") + browser_manager = get_browser_manager() + try: + self.driver.delete_all_cookies() + print("[Deltains Claim login] Cleared all cookies") + except Exception as e: + print(f"[Deltains Claim login] Error clearing cookies: {e}") + browser_manager.clear_credentials_hash() + print("[Deltains Claim login] Logout complete") + return True + except Exception as e: + print(f"[Deltains Claim login] Error during forced logout: {e}") + return False + + def login(self, url): + """ + Multi-step login flow for DeltaIns (Okta-based) — identical to the + eligibility worker's login(): + 1. Enter username (name='identifier') -> click Next + 2. Enter password (type='password') -> click Submit + 3. Handle MFA: click 'Send me an email' -> wait for OTP + Returns: ALREADY_LOGGED_IN, SUCCESS, OTP_REQUIRED, or ERROR:... + """ + wait = WebDriverWait(self.driver, 30) + browser_manager = get_browser_manager() + + try: + if self.deltains_username and browser_manager.credentials_changed(self.deltains_username): + self._force_logout() + self.driver.get(url) + time.sleep(3) + + try: + current_url = self.driver.current_url + print(f"[Deltains Claim login] Current URL: {current_url}") + if "provider-tools" in current_url and "login" not in current_url.lower() and "ciam" not in current_url.lower(): + print("[Deltains Claim login] Already on provider tools page - logged in") + return "ALREADY_LOGGED_IN" + except Exception as e: + print(f"[Deltains Claim login] Error checking current state: {e}") + + print("[Deltains Claim login] Trying provider-tools URL to check session...") + self.driver.get(PROVIDER_TOOLS_URL) + time.sleep(5) + + current_url = self.driver.current_url + print(f"[Deltains Claim login] After provider-tools nav URL: {current_url}") + + if "provider-tools" in current_url and "login" not in current_url.lower() and "ciam" not in current_url.lower(): + print("[Deltains Claim login] Session still valid - already logged in") + return "ALREADY_LOGGED_IN" + + print("[Deltains Claim login] Session not valid, navigating to login page...") + self.driver.get(url) + time.sleep(3) + + current_url = self.driver.current_url + print(f"[Deltains Claim login] After login nav URL: {current_url}") + + if "provider-tools" in current_url and "login" not in current_url.lower() and "ciam" not in current_url.lower(): + print("[Deltains Claim login] Already logged in - on provider tools") + return "ALREADY_LOGGED_IN" + + self._dismiss_cookie_banner() + + print("[Deltains Claim login] Looking for username field...") + username_entered = False + for sel in [ + (By.NAME, "identifier"), + (By.ID, "okta-signin-username"), + (By.XPATH, "//input[@type='text' and @autocomplete='username']"), + (By.XPATH, "//input[@type='text']"), + ]: + try: + field = WebDriverWait(self.driver, 8).until(EC.presence_of_element_located(sel)) + if field.is_displayed(): + field.clear() + field.send_keys(self.deltains_username) + username_entered = True + print(f"[Deltains Claim login] Username entered via {sel}") + break + except Exception: + continue + + if not username_entered: + return "ERROR: Could not find username field" + + time.sleep(1) + for sel in [ + (By.XPATH, "//input[@type='submit' and @value='Next']"), + (By.XPATH, "//input[@type='submit']"), + (By.XPATH, "//button[@type='submit']"), + ]: + try: + btn = self.driver.find_element(*sel) + if btn.is_displayed(): + btn.click() + print(f"[Deltains Claim login] Clicked Next via {sel}") + break + except Exception: + continue + + time.sleep(4) + + current_url = self.driver.current_url + if "provider-tools" in current_url and "login" not in current_url.lower() and "ciam" not in current_url.lower(): + return "ALREADY_LOGGED_IN" + + print("[Deltains Claim login] Looking for password field...") + pw_entered = False + for sel in [ + (By.XPATH, "//input[@type='password']"), + (By.ID, "okta-signin-password"), + (By.NAME, "password"), + ]: + try: + field = WebDriverWait(self.driver, 10).until(EC.presence_of_element_located(sel)) + if field.is_displayed(): + field.clear() + field.send_keys(self.deltains_password) + pw_entered = True + print(f"[Deltains Claim login] Password entered via {sel}") + break + except Exception: + continue + + if not pw_entered: + current_url = self.driver.current_url + if "provider-tools" in current_url and "login" not in current_url.lower(): + return "ALREADY_LOGGED_IN" + return "ERROR: Password field not found" + + time.sleep(1) + for sel in [ + (By.ID, "okta-signin-submit"), + (By.XPATH, "//input[@type='submit']"), + (By.XPATH, "//button[@type='submit']"), + ]: + try: + btn = self.driver.find_element(*sel) + if btn.is_displayed(): + btn.click() + print(f"[Deltains Claim login] Clicked Sign In via {sel}") + break + except Exception: + continue + + if self.deltains_username: + browser_manager.save_credentials_hash(self.deltains_username) + + time.sleep(6) + + current_url = self.driver.current_url + print(f"[Deltains Claim login] After password submit URL: {current_url}") + + if "provider-tools" in current_url and "login" not in current_url.lower() and "ciam" not in current_url.lower(): + print("[Deltains Claim login] Login successful - on provider tools") + return "SUCCESS" + + print("[Deltains Claim login] Handling MFA...") + + try: + body_text = self.driver.find_element(By.TAG_NAME, "body").text + if "security method" in body_text.lower() or "select from the following" in body_text.lower(): + print("[Deltains Claim login] MFA method selection page detected") + email_select = None + for sel in [ + (By.CSS_SELECTOR, "div[data-se='okta_email'] a.select-factor"), + (By.XPATH, "//div[@data-se='okta_email']//a[contains(@class,'select-factor')]"), + (By.XPATH, "//a[contains(@aria-label,'Select Email')]"), + (By.XPATH, "//div[@data-se='okta_email']//a[@data-se='button']"), + (By.CSS_SELECTOR, "a.select-factor.link-button"), + ]: + try: + btn = self.driver.find_element(*sel) + if btn.is_displayed(): + email_select = btn + print(f"[Deltains Claim login] Found Email Select via {sel}") + break + except Exception: + continue + + if email_select: + email_select.click() + print("[Deltains Claim login] Clicked 'Select' for Email MFA") + time.sleep(5) + else: + print("[Deltains Claim login] Could not find Email Select button") + except Exception as e: + print(f"[Deltains Claim login] Error checking MFA method selection: {e}") + + try: + send_btn = WebDriverWait(self.driver, 8).until( + EC.element_to_be_clickable((By.XPATH, + "//input[@type='submit' and @value='Send me an email'] | " + "//input[@value='Send me an email'] | " + "//button[contains(text(),'Send me an email')]")) + ) + send_btn.click() + print("[Deltains Claim login] Clicked 'Send me an email'") + time.sleep(5) + except TimeoutException: + print("[Deltains Claim login] No 'Send me an email' button, checking for OTP input...") + + try: + otp_input = WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located((By.XPATH, + "//input[@name='credentials.passcode' and @type='text'] | " + "//input[contains(@name,'passcode')]")) + ) + print("[Deltains Claim login] OTP input found -> OTP_REQUIRED") + return "OTP_REQUIRED" + except TimeoutException: + pass + + current_url = self.driver.current_url + if "provider-tools" in current_url and "login" not in current_url.lower() and "ciam" not in current_url.lower(): + return "SUCCESS" + + try: + error_elem = self.driver.find_element(By.XPATH, + "//*[contains(@class,'error') or contains(@class,'alert-error')]") + error_text = error_elem.text.strip()[:200] + if error_text: + return f"ERROR: {error_text}" + except Exception: + pass + + print("[Deltains Claim login] Could not determine login state - returning OTP_REQUIRED as fallback") + return "OTP_REQUIRED" + + except Exception as e: + print(f"[Deltains Claim login] Exception: {e}") + return f"ERROR:LOGIN FAILED: {e}" + + def _format_dob(self, dob_str): + if dob_str and "-" in dob_str: + dob_parts = dob_str.split("-") + if len(dob_parts) == 3: + return f"{dob_parts[1]}/{dob_parts[2]}/{dob_parts[0]}" + return dob_str + + # ------------------------------------------------------------------ # + # Step 1 — Search patient (same search form as the eligibility flow) # + # ------------------------------------------------------------------ # + def step1_search_patient(self): + """ + Click the 'Submit claim' dashboard tile, which opens a 'Patient search' + modal, and search the patient roster by full name. + """ + try: + print(f"[Deltains Claim step1] Starting — memberId={self.memberId}, " + f"patient={self.firstName} {self.lastName}") + + print("[Deltains Claim step1] Clicking 'Submit claim' dashboard tile...") + try: + submit_claim_tile = WebDriverWait(self.driver, 15).until( + EC.element_to_be_clickable((By.XPATH, + "//button[@data-testid='moduleTile'][.//a[contains(text(),'Submit claim')]] | " + "//a[@data-testid='moduleTileLabelLink'][contains(text(),'Submit claim')] | " + "//a[contains(text(),'Submit claim')]")) + ) + submit_claim_tile.click() + time.sleep(5) + print("[Deltains Claim step1] Clicked 'Submit claim' tile") + except TimeoutException: + return "ERROR: 'Submit claim' dashboard tile not found" + + print("[Deltains Claim step1] Waiting for 'Patient search' modal...") + try: + WebDriverWait(self.driver, 15).until( + EC.presence_of_element_located((By.XPATH, + "//h1[contains(text(),'Patient search')] | " + "//h2[contains(text(),'Patient search')]")) + ) + print("[Deltains Claim step1] 'Patient search' modal loaded") + except TimeoutException: + print("[Deltains Claim step1] 'Patient search' modal heading not found, continuing anyway...") + + full_name = f"{self.firstName} {self.lastName}".strip() + print(f"[Deltains Claim step1] Searching patient roster by name: '{full_name}'...") + try: + name_field = WebDriverWait(self.driver, 10).until( + EC.element_to_be_clickable((By.ID, "patientRosterAutocomplete")) + ) + name_field.click() + name_field.send_keys(Keys.CONTROL + "a") + name_field.send_keys(Keys.DELETE) + time.sleep(0.3) + name_field.send_keys(full_name) + time.sleep(1.5) + print(f"[Deltains Claim step1] Name entered: '{name_field.get_attribute('value')}'") + except TimeoutException: + return "ERROR: Patient roster search field not found" + + print("[Deltains Claim step1] Clicking search icon button...") + try: + search_btn = WebDriverWait(self.driver, 10).until( + EC.element_to_be_clickable((By.XPATH, + "//button[.//*[contains(@class,'dd-icon-Search')]] | " + "//button[@data-testid='autocompleteSearchButton']")) + ) + search_btn.click() + time.sleep(5) + print("[Deltains Claim step1] Clicked search icon button") + except TimeoutException: + return "ERROR: Search icon button not found" + + print("[Deltains Claim step1] Checking for results...") + try: + patient_card = WebDriverWait(self.driver, 15).until( + EC.presence_of_element_located((By.XPATH, + "//div[contains(@class,'patient-card-root')] | " + "//div[@data-testid='patientCard'] | " + "//div[starts-with(@data-testid,'patientCard')]")) + ) + print("[Deltains Claim step1] Patient card found!") + self._patient_card = patient_card + except TimeoutException: + try: + body_text = self.driver.find_element(By.TAG_NAME, "body").text + if "no results" in body_text.lower() or "not found" in body_text.lower() or "no patient" in body_text.lower(): + return "ERROR: No patient found with the provided name" + alerts = self.driver.find_elements(By.XPATH, "//*[@role='alert']") + for alert in alerts: + if alert.is_displayed(): + return f"ERROR: {alert.text.strip()[:200]}" + except Exception: + pass + return "ERROR: No patient results found within timeout" + + print("[Deltains Claim step1] Search completed") + return "SUCCESS" + + except Exception as e: + print(f"[Deltains Claim step1] Exception: {e}") + return f"ERROR: step1 failed: {e}" + + # ------------------------------------------------------------------ # + # Step 2 — Select transaction type on the "Submit a claim" page # + # (search lands directly on this page — no separate member-page or # + # "Create claim" click is needed, unlike the DDMA flow) # + # ------------------------------------------------------------------ # + def step2_select_transaction_type(self): + """ + Confirm the "Submit a claim" page loaded, then click the + "Statement of completed services" radio button under + "Type of transaction". + """ + try: + print(f"[Deltains Claim step2] Current URL: {self.driver.current_url}") + try: + WebDriverWait(self.driver, 20).until( + EC.presence_of_element_located((By.XPATH, + "//h1[contains(text(),'Submit a claim')] | " + "//*[contains(text(),'Type of transaction')]")) + ) + print("[Deltains Claim step2] 'Submit a claim' page loaded") + except TimeoutException: + page_text = self.driver.execute_script("return document.body.innerText;")[:400] + print(f"[Deltains Claim step2] Claim form not detected — page: {page_text}") + return "ERROR: step2 failed: 'Submit a claim' page did not load" + + print("[Deltains Claim step2] Clicking 'Statement of completed services' radio...") + try: + radio = WebDriverWait(self.driver, 10).until( + EC.element_to_be_clickable((By.XPATH, + "//input[@type='radio'][following-sibling::*[contains(text(),'Statement of completed services')]] | " + "//label[contains(.,'Statement of completed services')]//input[@type='radio'] | " + "//input[@type='radio'][ancestor::*[contains(.,'Statement of completed services')]][1]")) + ) + radio.click() + time.sleep(1) + print("[Deltains Claim step2] Selected 'Statement of completed services'") + except TimeoutException: + return "ERROR: step2 failed: 'Statement of completed services' radio not found" + + return "SUCCESS" + + except Exception as e: + print(f"[Deltains Claim step2] Exception: {e}") + return f"ERROR: step2 failed: {e}" + + # ------------------------------------------------------------------ # + # Step 4 — Fill service date and procedure lines # + # ------------------------------------------------------------------ # + def _fill_combobox(self, inp, value, label="field"): + """Type value into a combobox and select the first matching dropdown option.""" + try: + inp.click() + inp.send_keys(Keys.CONTROL + "a") + inp.send_keys(Keys.DELETE) + inp.send_keys(str(value)) + time.sleep(0.5) + listbox_id = inp.get_attribute("aria-controls") or "" + try: + if listbox_id: + option = WebDriverWait(self.driver, 4).until( + EC.element_to_be_clickable((By.XPATH, + f"//*[@id='{listbox_id}']//*[@role='option'][1]" + )) + ) + else: + option = WebDriverWait(self.driver, 4).until( + EC.element_to_be_clickable((By.XPATH, + f"//*[@role='listbox']//*[@role='option' and contains(normalize-space(.),'{value}')]" + f" | //*[@role='listbox']//*[@role='option'][1]" + )) + ) + option.click() + print(f"[Deltains Claim step4] {label}: selected '{value}'") + except TimeoutException: + inp.send_keys(Keys.TAB) + print(f"[Deltains Claim step4] {label}: typed '{value}' (no dropdown)") + except Exception as e: + print(f"[Deltains Claim step4] Warning: could not fill {label}: {e}") + + def _fill_by_id(self, element_id, value, label="field"): + """Fill a plain text/tel input located by its exact id attribute.""" + try: + inp = WebDriverWait(self.driver, 8).until( + EC.element_to_be_clickable((By.ID, element_id)) + ) + self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", inp) + inp.click() + inp.send_keys(Keys.CONTROL + "a") + inp.send_keys(Keys.DELETE) + inp.send_keys(str(value)) + time.sleep(0.2) + print(f"[Deltains Claim step4] {label}: typed '{value}'") + return inp + except Exception as e: + print(f"[Deltains Claim step4] Warning: could not fill {label} (#{element_id}): {e}") + return None + + def step4_fill_claim_form(self): + """ + Fill each service line: date of service, procedure code, and fee + via their per-line ids (serviceLines.{idx}.dateOfService, + serviceLines.{idx}.procedure, serviceLines.{idx}.fee — confirmed + against the live Deltains claim page). Tooth/arch/quad/surface + selectors below remain unverified. + """ + try: + active_lines = [ln for ln in self.serviceLines if str(ln.get("procedureCode") or "").strip()] + print(f"[Deltains Claim step4] {len(active_lines)} service line(s)") + + for idx, line in enumerate(active_lines): + code = str(line.get("procedureCode") or "").strip().upper() + tooth = str(line.get("toothNumber") or line.get("tooth") or "").strip() + arch = str(line.get("arch") or "").strip() + quad = str(line.get("quad") or line.get("quadrant") or "").strip() + surface = str(line.get("toothSurface") or line.get("surface") or "").strip().upper() + billed = str(line.get("totalBilled") or line.get("billedAmount") or line.get("fee") or "").strip() + billed = billed.replace("$", "").strip() + + print(f"[Deltains Claim step4] Line {idx}: code={code} tooth={tooth!r} arch={arch!r} " + f"quad={quad!r} surface={surface!r} billed={billed!r}") + + if idx > 0: + try: + add_btn = WebDriverWait(self.driver, 10).until( + EC.element_to_be_clickable((By.XPATH, + "//button[contains(normalize-space(text()),'Add service')]" + )) + ) + self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", add_btn) + add_btn.click() + print(f"[Deltains Claim step4] Clicked 'Add service' for line {idx}") + time.sleep(1) + except Exception as e: + print(f"[Deltains Claim step4] Could not click 'Add service': {e}") + + formatted_dos = self._format_dob(self.serviceDate) if self.serviceDate else "" + if formatted_dos: + self._fill_by_id(f"serviceLines.{idx}.dateOfService", formatted_dos, f"dateOfService[{idx}]") + + if code: + try: + proc_inp = WebDriverWait(self.driver, 8).until( + EC.element_to_be_clickable((By.ID, f"serviceLines.{idx}.procedure")) + ) + self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", proc_inp) + self._fill_combobox(proc_inp, code, f"procedureCode[{idx}]") + time.sleep(0.5) + except Exception as e: + print(f"[Deltains Claim step4] Could not fill procedure code: {e}") + + # Tooth number — MUI single-select (only rendered for codes + # that require it, e.g. fillings like D2392) + if tooth: + try: + tooth_select = WebDriverWait(self.driver, 8).until( + EC.element_to_be_clickable((By.ID, f"mui-component-select-serviceLines.{idx}.toothNumber")) + ) + self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", tooth_select) + tooth_select.click() + time.sleep(0.5) + padded = tooth.zfill(2) + option = WebDriverWait(self.driver, 5).until( + EC.element_to_be_clickable((By.XPATH, + f"//li[@role='option'][normalize-space(.)='{padded}'] | " + f"//li[@role='option'][normalize-space(.)='{tooth}']")) + ) + option.click() + time.sleep(0.3) + print(f"[Deltains Claim step4] tooth[{idx}]: selected '{tooth}'") + except TimeoutException: + print(f"[Deltains Claim step4] tooth[{idx}]: select not present for this procedure code") + except Exception as e: + print(f"[Deltains Claim step4] Could not fill tooth number: {e}") + + # Tooth surfaces — MUI multi-select; each letter (B, D, L, M, O...) + # is a checkbox option that stays open until clicked elsewhere. + if surface: + try: + surface_select = WebDriverWait(self.driver, 8).until( + EC.element_to_be_clickable((By.ID, f"serviceLines.{idx}.toothSurfaces")) + ) + self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", surface_select) + surface_select.click() + time.sleep(0.5) + surface_letters = [c for c in surface if c.isalpha()] + for letter in surface_letters: + try: + opt = WebDriverWait(self.driver, 5).until( + EC.element_to_be_clickable((By.XPATH, + f"//li[@role='option'][normalize-space(.)='{letter}']")) + ) + opt.click() + time.sleep(0.2) + print(f"[Deltains Claim step4] surface[{idx}]: checked '{letter}'") + except TimeoutException: + print(f"[Deltains Claim step4] surface[{idx}]: option '{letter}' not found") + self.driver.find_element(By.TAG_NAME, "body").send_keys(Keys.ESCAPE) + time.sleep(0.3) + except TimeoutException: + print(f"[Deltains Claim step4] surface[{idx}]: select not present for this procedure code") + except Exception as e: + print(f"[Deltains Claim step4] Could not fill tooth surfaces: {e}") + + if billed: + self._fill_by_id(f"serviceLines.{idx}.fee", billed, f"fee[{idx}]") + time.sleep(0.2) + + print("[Deltains Claim step4] Done") + return "SUCCESS" + + except Exception as e: + print(f"[Deltains Claim step4] Exception: {e}") + return f"ERROR: step4 failed: {e}" + + # ------------------------------------------------------------------ # + # Step 5 — Attach files # + # ------------------------------------------------------------------ # + def step5_attach_files(self): + """ + Expand the 'Attachments' accordion section under 'Additional + information' (max 5 files, PDF/JPEG only), then for each claimFile + with a filePath, send it directly to the hidden file input behind + the 'Choose a file' label. + """ + if not self.claimFiles: + print("[Deltains Claim step5] No files to attach") + return "SUCCESS" + + try: + section = WebDriverWait(self.driver, 10).until( + EC.element_to_be_clickable((By.XPATH, + "//*[contains(text(),'Attachments') and contains(text(),'x-rays')]")) + ) + if section.get_attribute("aria-expanded") == "false": + section.click() + time.sleep(1) + print("[Deltains Claim step5] Expanded 'Attachments' section") + except TimeoutException: + print("[Deltains Claim step5] 'Attachments' section header not found, " + "assuming already expanded") + + attached = 0 + for cf in self.claimFiles: + relative_path = cf.get("filePath") or "" + if not relative_path: + print(f"[Deltains 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"[Deltains Claim step5] File not found on disk: {abs_path}") + continue + + print(f"[Deltains Claim step5] Attaching: {abs_path}") + try: + file_input = WebDriverWait(self.driver, 8).until( + EC.presence_of_element_located((By.XPATH, + "//label[contains(@class,'dd-file-uploader-btn')]//input[@type='file'] | " + "//input[@type='file']")) + ) + self.driver.execute_script( + "arguments[0].style.display='block'; arguments[0].style.visibility='visible';", + file_input) + file_input.send_keys(abs_path) + time.sleep(1.5) + print(f"[Deltains Claim step5] Attached: {os.path.basename(abs_path)}") + attached += 1 + except Exception as e: + print(f"[Deltains Claim step5] Could not attach {abs_path}: {e}") + + print(f"[Deltains Claim step5] Attached {attached}/{len(self.claimFiles)} file(s)") + return "SUCCESS" + + # ------------------------------------------------------------------ # + # Step 6 — Click Review # + # ------------------------------------------------------------------ # + def step6_click_next(self): + """Click the 'Review' button (data-testid='ctaReviewButton') to + advance from the claim-entry form to the review screen.""" + try: + print(f"[Deltains Claim step6] Current URL: {self.driver.current_url}") + btn = WebDriverWait(self.driver, 15).until( + EC.element_to_be_clickable((By.XPATH, + "//button[@data-testid='ctaReviewButton']" + )) + ) + self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", btn) + time.sleep(0.5) + self.driver.execute_script(""" + var el = arguments[0]; + el.dispatchEvent(new PointerEvent('pointerover', {bubbles:true, cancelable:true, composed:true})); + el.dispatchEvent(new PointerEvent('pointerdown', {bubbles:true, cancelable:true, composed:true})); + el.dispatchEvent(new PointerEvent('pointerup', {bubbles:true, cancelable:true, composed:true})); + el.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true, composed:true})); + """, btn) + print("[Deltains Claim step6] Clicked 'Review'") + time.sleep(2) + print(f"[Deltains Claim step6] URL after Review: {self.driver.current_url}") + return "SUCCESS" + except Exception as e: + print(f"[Deltains Claim step6] Exception: {e}") + return f"ERROR: step6 failed: {e}" + + # ------------------------------------------------------------------ # + # Step 7 — Claims summary: check acknowledgement + submit # + # ------------------------------------------------------------------ # + def step7_submit_claim(self): + """Click the 'Submit' button (data-testid='ctaSubmitButton') on the + review page to finalize the claim.""" + try: + print(f"[Deltains Claim step7] Current URL: {self.driver.current_url}") + + submit_btn = WebDriverWait(self.driver, 15).until( + EC.element_to_be_clickable((By.XPATH, + "//button[@data-testid='ctaSubmitButton']" + )) + ) + self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", submit_btn) + time.sleep(0.3) + self.driver.execute_script(""" + var el = arguments[0]; + el.dispatchEvent(new PointerEvent('pointerover', {bubbles:true, cancelable:true, composed:true})); + el.dispatchEvent(new PointerEvent('pointerdown', {bubbles:true, cancelable:true, composed:true})); + el.dispatchEvent(new PointerEvent('pointerup', {bubbles:true, cancelable:true, composed:true})); + el.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true, composed:true})); + """, submit_btn) + print("[Deltains Claim step7] Clicked 'Submit'") + time.sleep(2) + print(f"[Deltains Claim step7] URL after submit: {self.driver.current_url}") + return "SUCCESS" + + except Exception as e: + print(f"[Deltains Claim step7] Exception: {e}") + return f"ERROR: step7 failed: {e}" + + def step8_save_confirmation_pdf(self): + """ + Wait for the 'Claim submitted successfully' confirmation modal, + extract the claim identification number, save the page as PDF + via CDP. + """ + try: + WebDriverWait(self.driver, 30).until( + lambda d: "claim submitted successfully" in d.page_source.lower() + ) + time.sleep(2) + print(f"[Deltains Claim step8] Confirmation page URL: {self.driver.current_url}") + + claim_number = None + try: + body_text = self.driver.find_element(By.TAG_NAME, "body").text + match = re.search(r'claim identification number\s*:\s*(\d+)', body_text, re.IGNORECASE) + if match: + claim_number = match.group(1) + print(f"[Deltains Claim step8] Extracted claim number: {claim_number}") + else: + match = re.search(r'\b(\d{10,})\b', body_text) + if match: + claim_number = match.group(1) + print(f"[Deltains Claim step8] Extracted claim number (fallback): {claim_number}") + except Exception as e: + print(f"[Deltains Claim step8] Could not extract claim number: {e}") + + shared_downloads = os.path.join(_SERVICE_DIR, "downloads") + os.makedirs(shared_downloads, exist_ok=True) + safe_member = "".join(c for c in str(self.memberId) if c.isalnum() or c in "-_.") + safe_claim = ("_" + claim_number[:20]) if claim_number else "" + timestamp = time.strftime("%Y%m%d_%H%M%S") + pdf_filename = f"deltains_claim_confirmation_{safe_member}{safe_claim}_{timestamp}.pdf" + pdf_path = os.path.join(shared_downloads, pdf_filename) + + try: + 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, + }) + pdf_bytes = base64.b64decode(pdf_data["data"]) + with open(pdf_path, "wb") as f: + f.write(pdf_bytes) + print(f"[Deltains Claim step8] PDF saved: {pdf_path}") + except Exception as e: + print(f"[Deltains Claim step8] PDF capture failed: {e}") + return f"ERROR: step8 PDF failed: {e}" + + print("[Deltains Claim step8] Clicking 'My claims'...") + try: + my_claims_btn = WebDriverWait(self.driver, 10).until( + EC.element_to_be_clickable((By.XPATH, + "//button[contains(normalize-space(text()),'My claims')]")) + ) + my_claims_btn.click() + time.sleep(3) + print(f"[Deltains Claim step8] Navigated to My claims: {self.driver.current_url}") + except TimeoutException: + print("[Deltains Claim step8] 'My claims' button not found — leaving confirmation modal open") + + return { + "status": "success", + "pdf_path": pdf_path, + "claimNumber": claim_number, + } + + except Exception as e: + print(f"[Deltains Claim step8] Exception: {e}") + return f"ERROR: step8 failed: {e}"