feat: add United Ucard claim worker and fix batch-column site-key routing

Adds a "United Ucard Claim" button (claims/preauth insurance form) that
submits through a full Selenium pipeline mirroring the Tufts SCO claim
worker: frontend handler -> backend route/queue processor -> Python
agent, reusing the persistent login/OTP session from the existing
United Ucard eligibility check. Implements the UHC Dental claim wizard
end-to-end (Provider Information, Patient Information search/select,
Claim Information procedures + questions, Upload Documents, Review and
Sign, Submit); the final confirmation-page PDF/claim-number capture is
still a TODO stub pending that page's DOM.

Also wires United Ucard into the AI chatbot's eligibility flow
(insuranceAliases.json, siteKeyToAutoCheck, clarification options), and
fixes a bug where the AI-column batch claim feature silently defaulted
United Ucard patients to MassHealth because batchColumnDeriveSiteKey
had no matching branch and no job-dispatch case for UNITED_UCARD.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 23:27:39 -04:00
co-authored by Claude Sonnet 5
parent f30016e674
commit e570a0aa13
15 changed files with 1695 additions and 2 deletions
@@ -86,6 +86,7 @@ export function siteKeyToAutoCheck(siteKey: string, dob?: string | Date | null):
case "DELTA_INS": return "delta-ins"; case "DELTA_INS": return "delta-ins";
case "TUFTS_SCO": return "tufts-sco"; case "TUFTS_SCO": return "tufts-sco";
case "UNITED_SCO": return "united-sco"; case "UNITED_SCO": return "united-sco";
case "UNITED_UCARD": return "united-ucard";
default: { default: {
// MassHealth: patients under 21 use the CMSP button // MassHealth: patients under 21 use the CMSP button
if (dob) { if (dob) {
@@ -428,7 +429,7 @@ async function handleEligibilityById(
memberId, memberId,
dob: resolvedDob, dob: resolvedDob,
patient, 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, dob: resolvedDob,
patient, patient,
procedureNames: c.procedureNames ?? [], 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)"],
}, },
}; };
} }
@@ -29,6 +29,12 @@
{ "keyword": "dental hub", "siteKey": "UNITED_SCO" }, { "keyword": "dental hub", "siteKey": "UNITED_SCO" },
{ "keyword": "united_sco", "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": "blue cross blue shield", "siteKey": "BCBS_MA" },
{ "keyword": "bcbs ma", "siteKey": "BCBS_MA" }, { "keyword": "bcbs ma", "siteKey": "BCBS_MA" },
{ "keyword": "blue cross", "siteKey": "BCBS_MA" }, { "keyword": "blue cross", "siteKey": "BCBS_MA" },
+12
View File
@@ -24,6 +24,7 @@ import { runUnitedDHClaimProcessor } from "./processors/unitedDHClaimProcessor";
import { runUnitedDHPreAuthProcessor } from "./processors/unitedDHPreAuthProcessor"; import { runUnitedDHPreAuthProcessor } from "./processors/unitedDHPreAuthProcessor";
import { runTuftsSCOPreAuthProcessor } from "./processors/tuftsSCOPreAuthProcessor"; import { runTuftsSCOPreAuthProcessor } from "./processors/tuftsSCOPreAuthProcessor";
import { runTuftsSCOClaimProcessor } from "./processors/tuftsSCOClaimProcessor"; import { runTuftsSCOClaimProcessor } from "./processors/tuftsSCOClaimProcessor";
import { runUnitedUcardClaimProcessor } from "./processors/unitedUcardClaimProcessor";
import { runEligibilityHistoryProcessor } from "./processors/eligibilityHistoryProcessor"; import { runEligibilityHistoryProcessor } from "./processors/eligibilityHistoryProcessor";
import { runCmspEligibilityHistoryRemainingProcessor } from "./processors/cmspEligibilityHistoryRemainingProcessor"; import { runCmspEligibilityHistoryRemainingProcessor } from "./processors/cmspEligibilityHistoryRemainingProcessor";
import { runBcbsMaEligibilityProcessor } from "./processors/bcbsMaEligibilityProcessor"; import { runBcbsMaEligibilityProcessor } from "./processors/bcbsMaEligibilityProcessor";
@@ -234,6 +235,17 @@ export function enqueueSeleniumJob(data: SeleniumJobData): string {
job.id 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") { if (jobType === "cca-eligibility-check") {
return runCCAEligibilityProcessor( return runCCAEligibilityProcessor(
{ {
@@ -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<any> {
const maxAttempts = 1200;
const pollIntervalMs = 500;
const maxTransientErrors = 12;
let transientErrors = 0;
let lastOtpEmit = 0;
const deadline = Date.now() + pollTimeoutMs;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
if (Date.now() > deadline) {
throw new Error(`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<string, any> = { 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 };
}
+1
View File
@@ -16,6 +16,7 @@ export type SeleniumJobType =
| "cca-preauth-submit" | "cca-preauth-submit"
| "ddma-claim-submit" | "ddma-claim-submit"
| "tuftssco-claim-submit" | "tuftssco-claim-submit"
| "uniteducard-claim-submit"
| "uniteddh-claim-submit" | "uniteddh-claim-submit"
| "uniteddh-preauth-submit" | "uniteddh-preauth-submit"
| "tuftssco-preauth-submit" | "tuftssco-preauth-submit"
+20
View File
@@ -590,6 +590,7 @@ function batchColumnDeriveSiteKey(provider: string): string {
if (p.includes("ddma") || p.includes("delta dental ma")) return "DDMA"; 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("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("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 return "MH"; // default fallback
} }
@@ -600,6 +601,7 @@ function batchColumnCanonicalName(siteKey: string): string {
case "DDMA": return "Delta Dental MA"; case "DDMA": return "Delta Dental MA";
case "TUFTS_SCO": return "Tufts SCO"; case "TUFTS_SCO": return "Tufts SCO";
case "UNITED_SCO": return "United/DentalHub"; case "UNITED_SCO": return "United/DentalHub";
case "UNITED_UCARD": return "United Ucard";
default: return "MassHealth"; default: return "MassHealth";
} }
} }
@@ -979,6 +981,24 @@ router.post(
enrichedPayload, enrichedPayload,
claimId, 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 { } else {
resultItem.error = `Unsupported insurance type: ${siteKey}`; resultItem.error = `Unsupported insurance type: ${siteKey}`;
results.push(resultItem); results.push(resultItem);
+2
View File
@@ -24,6 +24,7 @@ import insuranceStatusUnitedDHClaimRoutes from "./insuranceStatusUnitedDHClaim";
import insuranceStatusUnitedDHPreAuthRoutes from "./insuranceStatusUnitedDHPreAuth"; import insuranceStatusUnitedDHPreAuthRoutes from "./insuranceStatusUnitedDHPreAuth";
import insuranceStatusTuftsSCOClaimRoutes from "./insuranceStatusTuftsSCOClaim"; import insuranceStatusTuftsSCOClaimRoutes from "./insuranceStatusTuftsSCOClaim";
import insuranceStatusTuftsSCOPreAuthRoutes from "./insuranceStatusTuftsSCOPreAuth"; import insuranceStatusTuftsSCOPreAuthRoutes from "./insuranceStatusTuftsSCOPreAuth";
import insuranceStatusUnitedUcardClaimRoutes from "./insuranceStatusUnitedUcardClaim";
import paymentsRoutes from "./payments"; import paymentsRoutes from "./payments";
import databaseManagementRoutes from "./database-management"; import databaseManagementRoutes from "./database-management";
import notificationsRoutes from "./notifications"; import notificationsRoutes from "./notifications";
@@ -78,6 +79,7 @@ router.use("/claims", insuranceStatusUnitedDHClaimRoutes);
router.use("/claims", insuranceStatusUnitedDHPreAuthRoutes); router.use("/claims", insuranceStatusUnitedDHPreAuthRoutes);
router.use("/claims", insuranceStatusTuftsSCOClaimRoutes); router.use("/claims", insuranceStatusTuftsSCOClaimRoutes);
router.use("/claims", insuranceStatusTuftsSCOPreAuthRoutes); router.use("/claims", insuranceStatusTuftsSCOPreAuthRoutes);
router.use("/claims", insuranceStatusUnitedUcardClaimRoutes);
router.use("/payments", paymentsRoutes); router.use("/payments", paymentsRoutes);
router.use("/database-management", databaseManagementRoutes); router.use("/database-management", databaseManagementRoutes);
router.use("/notifications", notificationsRoutes); router.use("/notifications", notificationsRoutes);
@@ -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<any> => {
if (!req.user?.id) {
return res.status(401).json({ error: "Unauthorized: user info missing" });
}
try {
const claimData =
typeof req.body.data === "string"
? JSON.parse(req.body.data)
: req.body.data ?? req.body ?? {};
// Fetch 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<any> => {
const { session_id: sessionId, otp, socketId } = req.body;
if (!sessionId || !otp) {
return res.status(400).json({ error: "session_id and otp are required" });
}
try {
const r = await 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;
@@ -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: "<uuid>" }
*/
export async function forwardToSeleniumUnitedUcardClaimAgent(
data: Record<string, any>
): 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<Record<string, any>> {
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<Record<string, any>> {
const resp = await axios.post(`${SELENIUM_BASE}/submit-otp`, { session_id: sessionId, otp });
return resp.data;
}
@@ -100,6 +100,7 @@ interface ClaimFormProps {
onHandleForDDMASeleniumClaim: (data: ClaimFormData) => void; onHandleForDDMASeleniumClaim: (data: ClaimFormData) => void;
onHandleForUnitedDHSeleniumClaim: (data: ClaimFormData) => void; onHandleForUnitedDHSeleniumClaim: (data: ClaimFormData) => void;
onHandleForTuftsSCOSeleniumClaim: (data: ClaimFormData) => void; onHandleForTuftsSCOSeleniumClaim: (data: ClaimFormData) => void;
onHandleForUnitedUcardSeleniumClaim: (data: ClaimFormData) => void;
onClose: () => void; onClose: () => void;
isLicensed?: boolean; isLicensed?: boolean;
} }
@@ -122,6 +123,7 @@ export function ClaimForm({
onHandleForDDMASeleniumClaim, onHandleForDDMASeleniumClaim,
onHandleForUnitedDHSeleniumClaim, onHandleForUnitedDHSeleniumClaim,
onHandleForTuftsSCOSeleniumClaim, onHandleForTuftsSCOSeleniumClaim,
onHandleForUnitedUcardSeleniumClaim,
onSubmit, onSubmit,
onClose, onClose,
isLicensed = false, isLicensed = false,
@@ -804,6 +806,7 @@ export function ClaimForm({
if (p.includes("delta ins") || p === "deltains") return "DeltaIns"; if (p.includes("delta ins") || p === "deltains") return "DeltaIns";
if (p.includes("tufts") || p.includes("dentaquest") || p === "tuftssco") return "TuftsSCO"; 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("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("bcbs") || p.includes("blue cross")) return "BCBS";
if (p.includes("united aapr") || p === "unitedaapr") return "UnitedAAPR"; if (p.includes("united aapr") || p === "unitedaapr") return "UnitedAAPR";
if (p.includes("aetna")) return "Aetna"; if (p.includes("aetna")) return "Aetna";
@@ -1396,6 +1399,82 @@ export function ClaimForm({
onClose(); 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 handleClaimAll = () => {
const siteKey = (form.insuranceSiteKey || deriveInsuranceSiteKey(form.insuranceProvider || "")).toUpperCase(); const siteKey = (form.insuranceSiteKey || deriveInsuranceSiteKey(form.insuranceProvider || "")).toUpperCase();
if (siteKey === "MH" || siteKey === "MASSHEALTH") { if (siteKey === "MH" || siteKey === "MASSHEALTH") {
@@ -2524,6 +2603,14 @@ export function ClaimForm({
> >
Tufts Claim Tufts Claim
</Button> </Button>
<Button
className="w-40 bg-cyan-600 hover:bg-cyan-700 text-white"
onClick={() => runWithPriceCheck(handleUnitedUcardClaim)}
disabled={!isLicensed}
title={!isLicensed ? "License required" : undefined}
>
United Ucard Claim
</Button>
<Button <Button
className="w-36 bg-emerald-600 hover:bg-emerald-700 text-white" className="w-36 bg-emerald-600 hover:bg-emerald-700 text-white"
onClick={() => runWithPriceCheck(handleClaimSaved)} onClick={() => runWithPriceCheck(handleClaimSaved)}
@@ -138,6 +138,7 @@ const INSURANCE_TYPE_OPTIONS: { value: string; label: string }[] = [
{ value: "ddma", label: "DDMA" }, { value: "ddma", label: "DDMA" },
{ value: "delta-ins", label: "Delta Dental" }, { value: "delta-ins", label: "Delta Dental" },
{ value: "united-sco", label: "United SCO" }, { value: "united-sco", label: "United SCO" },
{ value: "united-ucard", label: "United Ucard" },
{ value: "tufts-sco", label: "Tufts SCO" }, { value: "tufts-sco", label: "Tufts SCO" },
{ value: "cca", label: "CCA" }, { value: "cca", label: "CCA" },
]; ];
+108
View File
@@ -85,6 +85,9 @@ export default function ClaimsPage() {
const [tuftsSCOClaimOtpOpen, setTuftsSCOClaimOtpOpen] = useState(false); const [tuftsSCOClaimOtpOpen, setTuftsSCOClaimOtpOpen] = useState(false);
const [tuftsSCOClaimOtpSubmitting, setTuftsSCOClaimOtpSubmitting] = useState(false); const [tuftsSCOClaimOtpSubmitting, setTuftsSCOClaimOtpSubmitting] = useState(false);
const tuftsSCOClaimSessionIdRef = useRef<string | null>(null); const tuftsSCOClaimSessionIdRef = useRef<string | null>(null);
const [unitedUcardClaimOtpOpen, setUnitedUcardClaimOtpOpen] = useState(false);
const [unitedUcardClaimOtpSubmitting, setUnitedUcardClaimOtpSubmitting] = useState(false);
const unitedUcardClaimSessionIdRef = useRef<string | null>(null);
const [tuftsSCOPreAuthOtpOpen, setTuftsSCOPreAuthOtpOpen] = useState(false); const [tuftsSCOPreAuthOtpOpen, setTuftsSCOPreAuthOtpOpen] = useState(false);
const [tuftsSCOPreAuthOtpSubmitting, setTuftsSCOPreAuthOtpSubmitting] = useState(false); const [tuftsSCOPreAuthOtpSubmitting, setTuftsSCOPreAuthOtpSubmitting] = useState(false);
const tuftsSCOPreAuthSessionIdRef = useRef<string | null>(null); const tuftsSCOPreAuthSessionIdRef = useRef<string | null>(null);
@@ -902,6 +905,71 @@ export default function ClaimsPage() {
} }
}; };
const handleUnitedUcardClaimOtpSubmit = async (otp: string) => {
const sessionId = unitedUcardClaimSessionIdRef.current;
if (!sessionId) return;
try {
setUnitedUcardClaimOtpSubmitting(true);
const resp = await apiRequest("POST", "/api/claims/uniteducard-claim/selenium/submit-otp", {
session_id: sessionId,
otp,
socketId,
});
const data = await resp.json();
if (!resp.ok || data.error) throw new Error(data.error || "Failed to submit OTP");
setUnitedUcardClaimOtpOpen(false);
dispatch(setTaskStatus({ key: "claimSubmit", status: "pending", message: "OTP submitted. Continuing United Ucard claim..." }));
} catch (err: any) {
toast({ title: "Failed to submit OTP", description: err?.message || "Error submitting OTP", variant: "destructive" });
} finally {
setUnitedUcardClaimOtpSubmitting(false);
}
};
// United Ucard claim selenium handler
const handleUnitedUcardClaimSubmitSelenium = async (data: any) => {
try {
dispatch(setTaskStatus({ key: "claimSubmit", status: "pending", message: "Submitting United Ucard claim..." }));
const response = await apiRequest("POST", "/api/claims/uniteducard-claim", {
data,
socketId,
});
const result = await response.json();
if (result.error) throw new Error(result.error);
pendingClaimMeta.current = { patientId: selectedPatientId, groupKey: "INSURANCE_CLAIM" };
setPendingClaimJobId(result.jobId);
const jobId = result.jobId;
const onSessionStarted = (ev: any) => {
if (String(ev?.jobId) !== String(jobId)) return;
unitedUcardClaimSessionIdRef.current = ev.session_id ?? null;
};
const onOtpRequired = (ev: any) => {
if (String(ev?.jobId) !== String(jobId)) return;
if (ev.session_id) unitedUcardClaimSessionIdRef.current = ev.session_id;
setUnitedUcardClaimOtpOpen(true);
dispatch(setTaskStatus({ key: "claimSubmit", status: "pending", message: "OTP required for United Ucard. Please enter the code." }));
};
const onDone = (ev: any) => {
if (String(ev?.jobId) !== String(jobId)) return;
socket.off("selenium:uniteducard_claim_started", onSessionStarted);
socket.off("selenium:otp_required", onOtpRequired);
socket.off("job:update", onDone);
setUnitedUcardClaimOtpOpen(false);
unitedUcardClaimSessionIdRef.current = null;
};
socket.on("selenium:uniteducard_claim_started", onSessionStarted);
socket.on("selenium:otp_required", onOtpRequired);
socket.on("job:update", onDone);
dispatch(setTaskStatus({ key: "claimSubmit", status: "pending", message: "United Ucard claim queued. Awaiting Selenium..." }));
toast({ title: "United Ucard Claim queued", description: "Selenium is opening the claim form.", variant: "default" });
} catch (error: any) {
dispatch(setTaskStatus({ key: "claimSubmit", status: "error", message: error.message || "United Ucard claim failed" }));
toast({ title: "United Ucard Claim error", description: error.message || "An error occurred.", variant: "destructive" });
}
};
// CCA pre-auth selenium handler // CCA pre-auth selenium handler
const handleCCAPreAuthSubmitSelenium = async (data: any) => { const handleCCAPreAuthSubmitSelenium = async (data: any) => {
const formData = new FormData(); const formData = new FormData();
@@ -1293,6 +1361,7 @@ export default function ClaimsPage() {
onHandleForUnitedDHSeleniumClaim={handleUnitedDHClaimSubmitSelenium} onHandleForUnitedDHSeleniumClaim={handleUnitedDHClaimSubmitSelenium}
onHandleForTuftsSCOSeleniumPreAuth={handleTuftsSCOPreAuthSubmitSelenium} onHandleForTuftsSCOSeleniumPreAuth={handleTuftsSCOPreAuthSubmitSelenium}
onHandleForTuftsSCOSeleniumClaim={handleTuftsSCOClaimSubmitSelenium} onHandleForTuftsSCOSeleniumClaim={handleTuftsSCOClaimSubmitSelenium}
onHandleForUnitedUcardSeleniumClaim={handleUnitedUcardClaimSubmitSelenium}
isLicensed={isLicensed} isLicensed={isLicensed}
/> />
)} )}
@@ -1387,6 +1456,45 @@ export default function ClaimsPage() {
</div> </div>
)} )}
{/* United Ucard Claim OTP Modal */}
{unitedUcardClaimOtpOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white dark:bg-slate-900 rounded-xl shadow-lg w-full max-w-md p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Enter OTP — United Ucard Claim</h2>
<button type="button" onClick={() => setUnitedUcardClaimOtpOpen(false)} className="text-slate-500 hover:text-slate-800">✕</button>
</div>
<p className="text-sm text-slate-500 mb-4">
The United Ucard (UHC Dental) portal requires a one-time password (OTP) to continue claim submission.
</p>
<form onSubmit={(e) => {
e.preventDefault();
const input = (e.currentTarget.elements.namedItem("otp") as HTMLInputElement);
if (input?.value.trim()) handleUnitedUcardClaimOtpSubmit(input.value.trim());
}} className="space-y-4">
<div className="space-y-2">
<label htmlFor="uniteducard-claim-otp" className="text-sm font-medium">OTP</label>
<input
id="uniteducard-claim-otp"
name="otp"
placeholder="Enter OTP code"
autoFocus
className="w-full border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div className="flex justify-end gap-3">
<button type="button" onClick={() => setUnitedUcardClaimOtpOpen(false)} disabled={unitedUcardClaimOtpSubmitting}
className="px-4 py-2 text-sm border rounded-md hover:bg-slate-50 disabled:opacity-50">Cancel</button>
<button type="submit" disabled={unitedUcardClaimOtpSubmitting}
className="px-4 py-2 text-sm bg-primary text-white rounded-md hover:bg-primary/90 disabled:opacity-50">
{unitedUcardClaimOtpSubmitting ? "Submitting..." : "Submit OTP"}
</button>
</div>
</form>
</div>
</div>
)}
{/* United/DentalHub PreAuth OTP Modal */} {/* United/DentalHub PreAuth OTP Modal */}
{unitedDHPreAuthOtpOpen && ( {unitedDHPreAuthOtpOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
+47
View File
@@ -27,6 +27,7 @@ import helpers_uniteddh_claim as huniteddh_claim
import helpers_uniteddh_preauth as huniteddh_preauth import helpers_uniteddh_preauth as huniteddh_preauth
import helpers_tuftssco_claim as htuftssco_claim import helpers_tuftssco_claim as htuftssco_claim
import helpers_tuftssco_preauth as htuftssco_preauth import helpers_tuftssco_preauth as htuftssco_preauth
import helpers_uniteducard_claim as huniteducard_claim
import helpers_bcbs_ma_eligibility as hbcbs_ma import helpers_bcbs_ma_eligibility as hbcbs_ma
# Import startup session-clear functions # Import startup session-clear functions
@@ -832,6 +833,48 @@ async def tuftssco_claim(request: Request):
return {"status": "started", "session_id": sid} 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: "<uuid>" }
"""
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): async def _uniteddh_preauth_worker_wrapper(sid: str, data: dict, url: str):
"""Background worker for United/DentalHub pre-authorization submission.""" """Background worker for United/DentalHub pre-authorization submission."""
global active_jobs, waiting_jobs global active_jobs, waiting_jobs
@@ -990,6 +1033,8 @@ async def submit_otp(request: Request):
res = htuftssco_claim.submit_otp(sid, otp) res = htuftssco_claim.submit_otp(sid, otp)
elif sid in htuftssco_preauth.sessions: elif sid in htuftssco_preauth.sessions:
res = htuftssco_preauth.submit_otp(sid, otp) 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: elif sid in hbcbs_ma.sessions:
res = hbcbs_ma.submit_otp(sid, otp) res = hbcbs_ma.submit_otp(sid, otp)
else: else:
@@ -1029,6 +1074,8 @@ async def session_status(sid: str):
s = htuftssco_claim.get_session_status(sid) s = htuftssco_claim.get_session_status(sid)
elif sid in htuftssco_preauth.sessions: elif sid in htuftssco_preauth.sessions:
s = htuftssco_preauth.get_session_status(sid) 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: elif sid in hbcbs_ma.sessions:
s = hbcbs_ma.get_session_status(sid) s = hbcbs_ma.get_session_status(sid)
else: else:
@@ -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,
}
@@ -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: <msg>" - 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: <message>" 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 <div> ("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):
<a id="Claims" onclick="preLoginProviderSearch(event , 'Claims')"
href="/content/dental-benefits-provider/en/secure/dental-claim-form.html">Claims</a>
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 <input type="date"> — 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):
<input type="date" class="date-input date-of-service" placeholder="mm/dd/yyyy">
<input type="text" class="subscriber-id">
<input type="date" class="date-input date-of-birth" placeholder="mm/dd/yyyy">
<button class="search-button member-search">Search</button>
"""
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):
# <input type="button" class="btn btn-sm btn-primary pull-right margin-left-10"
# onclick="refineSearch(this);" value="select">
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
<input type="radio" name="PatientAdditionalCoverage" id="PatientAdditionalCoverage1" value="No">
"Does this claim include EPSDT / Title XIX?" -> No
<input type="radio" name="ClaimEpsdt" id="epsdtNo" value="No">
"Who should be reimbursed for this claim?" -> Provider
<input type="radio" name="PHSubscriberDirectPayment" id="PHSubscriberDirectPayment0" value="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
# <input type="radio" name="ClaimOrtho" id="ClaimOrtho1" value="No">
# "Does this claim include prosthesis (crown, bridge, or denture)?" -> No
# <input type="radio" name="ClaimProsthesis" id="ClaimProsthesis1" value="No">
# "Does this claim relate to an accident/injury/illness?" -> No
# <input type="radio" name="OrthAccidentInjury" id="OrthAccidentInjury1" value="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):
<div id="dentalProcedures" onclick="addProcCode()">Add a Procedure</div>
"""
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):
<input id="dentalProcCode" type="text" min="5" max="5"> (5-char code, e.g. D0120)
<input id="serviceDate" type="date">
<input id="ROSFeeX" type="number" class="currency-input">
<div onclick="saveProcCode()">Save Procedure</div>
"""
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):
<input type="file" id="file-input" class="file-input" multiple
accept=".pdf,.png,.jpg,.jpeg,.txt,.tif,.tiff,.gif,.bmp">
<div>...Review and Sign</div> (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):
<input type="checkbox" name="MemberPatientSignatureOnFile" id="MemberPatientSignatureOnFile_0" value="yes">
<input type="checkbox" name="PHSubscriberSignatureOnFile" id="PHSubscriberSignatureOnFile_0" value="yes">
"""
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):
# <input type="text" id="userSignatureText">
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):
# <div class="submit-claim" onclick="submitClaimForm();">Submit</div>
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 {}