feat: add BCBS MA eligibility check with OTP flow

- New Selenium worker (fresh Chrome per run, no persistent session)
  login → OTP modal → eTools → ConnectCenter → Verification →
  New Eligibility Request → fill form (NPI, member ID, DOB) →
  Expand All → CDP PDF back to app
- Backend route fetches BCBS_MA credentials + provider NPI from settings
- Frontend OTP modal with 6-digit code entry
- BCBS MA added to insurance credentials dropdown in settings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ff
2026-06-01 00:36:11 -04:00
parent 535619c286
commit e644d21cee
12 changed files with 1468 additions and 9 deletions

View File

@@ -23,6 +23,7 @@ import { runUnitedDHClaimProcessor } from "./processors/unitedDHClaimProcessor";
import { runTuftsSCOClaimProcessor } from "./processors/tuftsSCOClaimProcessor"; import { runTuftsSCOClaimProcessor } from "./processors/tuftsSCOClaimProcessor";
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 type { SeleniumJobData, OcrJobData } from "./queues"; import type { SeleniumJobData, OcrJobData } from "./queues";
// ── Queue instances ────────────────────────────────────────────────────────── // ── Queue instances ──────────────────────────────────────────────────────────
@@ -226,6 +227,20 @@ export function enqueueSeleniumJob(data: SeleniumJobData): string {
formDob: data.formDob, formDob: data.formDob,
}); });
} }
if (jobType === "bcbs-ma-eligibility-check") {
return runBcbsMaEligibilityProcessor(
{
enrichedPayload: data.enrichedPayload,
userId: data.userId,
insuranceId: data.insuranceId!,
formFirstName: data.formFirstName,
formLastName: data.formLastName,
formDob: data.formDob,
socketId: data.socketId,
},
job.id
);
}
throw new Error(`Unknown selenium jobType: ${jobType}`); throw new Error(`Unknown selenium jobType: ${jobType}`);
}); });

View File

@@ -0,0 +1,255 @@
import fs from "fs/promises";
import fsSync from "fs";
import path from "path";
import { storage } from "../../storage";
import { emptyFolderContainingFile } from "../../utils/emptyTempFolder";
import {
forwardToSeleniumBcbsMaEligibilityAgent,
getSeleniumBcbsMaSessionStatus,
} from "../../services/seleniumBcbsMaInsuranceEligibilityClient";
import { splitName, createOrUpdatePatientByInsuranceId } from "./_shared";
import { io } from "../../socket";
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 (err: any) {
log("bcbs-ma-processor", `emit failed for ${event}`, { err: err?.message });
}
}
export interface BcbsMaEligibilityProcessorInput {
enrichedPayload: any;
userId: number;
insuranceId: string;
formFirstName?: string;
formLastName?: string;
formDob?: string;
socketId?: string;
}
export interface BcbsMaEligibilityProcessorResult {
patientUpdateStatus?: string;
pdfUploadStatus?: string;
pdfFileId?: number | null;
pdfFilename?: string | null;
}
async function processBcbsMaResult(
userId: number,
insuranceId: string,
formFirstName: string | undefined,
formLastName: string | undefined,
formDob: string | undefined,
seleniumResult: any
): Promise<BcbsMaEligibilityProcessorResult> {
const output: BcbsMaEligibilityProcessorResult = {};
let createdPdfFileId: number | null = null;
try {
// Resolve patient name
const rawName =
typeof seleniumResult?.patientName === "string" ? seleniumResult.patientName.trim() : null;
let firstName: string;
let lastName: string;
if (rawName) {
if (rawName.includes(",")) {
const [last, ...firstParts] = rawName.split(",").map((s: string) => s.trim());
lastName = last || formLastName || "";
firstName = firstParts.join(" ").trim() || formFirstName || "";
} else {
const parsed = splitName(rawName);
if (!parsed.lastName) {
lastName = parsed.firstName || formLastName || "";
firstName = formFirstName || "";
} else {
firstName = parsed.firstName || formFirstName || "";
lastName = parsed.lastName || formLastName || "";
}
}
} else {
firstName = formFirstName ?? "";
lastName = formLastName ?? "";
}
await createOrUpdatePatientByInsuranceId({ insuranceId, firstName, lastName, dob: formDob, userId });
const normalizedInsuranceId = insuranceId.replace(/\s+/g, "");
const patient = await storage.getPatientByInsuranceId(normalizedInsuranceId);
if (!patient?.id) {
output.patientUpdateStatus = "Patient not found; no update performed";
return output;
}
const eligStatus = (seleniumResult?.eligibility ?? "").toLowerCase();
const newStatus =
eligStatus === "eligible" || eligStatus === "active" ? "ACTIVE" : "INACTIVE";
await storage.updatePatient(patient.id, {
status: newStatus,
insuranceProvider: "BCBS MA",
});
output.patientUpdateStatus = `Patient status updated to ${newStatus}`;
// Save PDF
let pdfBuffer: Buffer | null = null;
let pdfFilename: string | null = null;
const pdfPath: string | null = seleniumResult?.pdf_path ?? null;
if (pdfPath && fsSync.existsSync(pdfPath)) {
try {
pdfBuffer = await fs.readFile(pdfPath);
pdfFilename = path.basename(pdfPath);
} catch (e: any) {
output.pdfUploadStatus = `Failed to read PDF: ${e.message}`;
}
} else {
output.pdfUploadStatus = "No valid PDF path from Selenium.";
}
if (pdfBuffer && pdfFilename) {
const groupTitleKey = "ELIGIBILITY_STATUS";
const groupTitle = "Eligibility Status";
let group = await storage.findPdfGroupByPatientTitleKey(patient.id, groupTitleKey);
if (!group) group = await storage.createPdfGroup(patient.id, groupTitle, groupTitleKey);
if (!group?.id) throw new Error("PDF group creation failed");
const created = await storage.createPdfFile(group.id, pdfFilename, pdfBuffer);
if (created && typeof created === "object" && "id" in created) {
createdPdfFileId = Number(created.id);
}
output.pdfUploadStatus = `PDF saved to group: ${group.title}`;
output.pdfFilename = pdfFilename;
}
output.pdfFileId = createdPdfFileId;
return output;
} catch (err: any) {
log("bcbs-ma-processor", `processBcbsMaResult ERROR: ${err?.message}`, err);
return {
...output,
pdfUploadStatus: output.pdfUploadStatus ?? `Processing failed: ${err?.message}`,
pdfFileId: createdPdfFileId,
};
} finally {
const cleanupPath = seleniumResult?.pdf_path ?? null;
if (cleanupPath) {
try {
await emptyFolderContainingFile(cleanupPath);
} catch (_) {}
}
}
}
async function pollUntilDone(
sessionId: string,
socketId: string | undefined,
jobId: string,
pollTimeoutMs = 8 * 60 * 1000 // 8 min — accounts for fresh login + OTP wait
): Promise<any> {
const maxAttempts = 960; // 960 × 500ms = 8 min
const pollIntervalMs = 500;
const maxTransientErrors = 12;
const noProgressLimit = 120;
let transientErrors = 0;
let consecutiveNoProgress = 0;
let lastStatus: string | null = null;
const deadline = Date.now() + pollTimeoutMs;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
if (Date.now() > deadline) {
throw new Error(`BCBS MA polling timeout (${Math.round(pollTimeoutMs / 1000)}s) for session ${sessionId}`);
}
try {
const st = await getSeleniumBcbsMaSessionStatus(sessionId);
const status: string = st?.status ?? "unknown";
transientErrors = 0;
const isTerminal = status === "completed" || status === "error" || status === "not_found";
if (status === lastStatus && !isTerminal) consecutiveNoProgress++;
else consecutiveNoProgress = 0;
lastStatus = status;
if (consecutiveNoProgress >= noProgressLimit) {
throw new Error(`No progress from Python agent (status="${status}") after ${consecutiveNoProgress} polls`);
}
if (status === "waiting_for_otp") {
emitToSocket(socketId, "selenium:otp_required", {
session_id: sessionId,
jobId,
message: "OTP required. Please enter the 6-digit code from the BCBS MA email.",
});
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 || `BCBS MA 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") ||
err.message.includes("No progress")));
if (isTerminal) throw err;
transientErrors++;
if (transientErrors > maxTransientErrors) {
throw new Error(`Too many transient network errors polling BCBS MA session ${sessionId}`);
}
const backoff = Math.min(30_000, 500 * Math.pow(2, transientErrors - 1));
await new Promise((r) => setTimeout(r, backoff));
}
}
throw new Error(`BCBS MA polling exhausted all attempts for session ${sessionId}`);
}
export async function runBcbsMaEligibilityProcessor(
input: BcbsMaEligibilityProcessorInput,
jobId: string
): Promise<BcbsMaEligibilityProcessorResult> {
const { enrichedPayload, userId, insuranceId, formFirstName, formLastName, formDob, socketId } = input;
log("bcbs-ma-processor", "starting Python agent session", { insuranceId });
const agentResp = await forwardToSeleniumBcbsMaEligibilityAgent(enrichedPayload);
if (!agentResp?.session_id) {
throw new Error("Python agent did not return a session_id for BCBS MA eligibility");
}
const sessionId = agentResp.session_id as string;
log("bcbs-ma-processor", "got session_id", { sessionId });
emitToSocket(socketId, "selenium:bcbs_ma_session_started", { session_id: sessionId, jobId });
const seleniumResult = await pollUntilDone(sessionId, socketId, jobId);
if (!seleniumResult || seleniumResult.status === "error") {
throw new Error(seleniumResult?.message ?? "BCBS MA session returned an error result");
}
log("bcbs-ma-processor", "processing DB result", { insuranceId });
const result = await processBcbsMaResult(
userId, insuranceId, formFirstName, formLastName, formDob, seleniumResult
);
log("bcbs-ma-processor", "done", { result });
return result;
}

View File

@@ -18,7 +18,8 @@ export type SeleniumJobType =
| "uniteddh-claim-submit" | "uniteddh-claim-submit"
| "tuftssco-eligibility-check" | "tuftssco-eligibility-check"
| "mh-eligibility-history-check" | "mh-eligibility-history-check"
| "cmsp-eligibility-history-remaining-check"; | "cmsp-eligibility-history-remaining-check"
| "bcbs-ma-eligibility-check";
export interface SeleniumJobData { export interface SeleniumJobData {
jobType: SeleniumJobType; jobType: SeleniumJobType;

View File

@@ -40,6 +40,7 @@ import commissionsRoutes from "./commissions";
import shoppingVendorsRoutes from "./shopping-vendors"; import shoppingVendorsRoutes from "./shopping-vendors";
import feeScheduleRoutes from "./feeSchedule"; import feeScheduleRoutes from "./feeSchedule";
import licenseRoutes from "./license"; import licenseRoutes from "./license";
import insuranceStatusBcbsMaRoutes from "./insuranceStatusBcbsMa";
const router = Router(); const router = Router();
@@ -60,6 +61,7 @@ router.use("/insurance-status-deltains", insuranceStatusDeltaInsRoutes);
router.use("/insurance-status-unitedsco", insuranceStatusUnitedSCORoutes); router.use("/insurance-status-unitedsco", insuranceStatusUnitedSCORoutes);
router.use("/insurance-status-tuftssco", insuranceStatusTuftsSCORoutes); router.use("/insurance-status-tuftssco", insuranceStatusTuftsSCORoutes);
router.use("/insurance-status-cca", insuranceStatusCCARoutes); router.use("/insurance-status-cca", insuranceStatusCCARoutes);
router.use("/insurance-status-bcbs-ma", insuranceStatusBcbsMaRoutes);
router.use("/claims", insuranceStatusCCAClaimRoutes); router.use("/claims", insuranceStatusCCAClaimRoutes);
router.use("/claims", insuranceStatusCCAPreAuthRoutes); router.use("/claims", insuranceStatusCCAPreAuthRoutes);
router.use("/claims", insuranceStatusDDMAClaimRoutes); router.use("/claims", insuranceStatusDDMAClaimRoutes);

View File

@@ -0,0 +1,108 @@
import { Router, Request, Response } from "express";
import { storage } from "../storage";
import { forwardOtpToSeleniumBcbsMaAgent } from "../services/seleniumBcbsMaInsuranceEligibilityClient";
import { io } from "../socket";
import { enqueueSeleniumJob } from "../queue/jobRunner";
const router = Router();
function log(tag: string, msg: string, ctx?: any) {
console.log(`${new Date().toISOString()} [${tag}] ${msg}`, ctx ?? "");
}
function emitSafe(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 (err: any) {
log("bcbs-ma-route", "emit failed", { socketId, event, err: err?.message });
}
}
/**
* POST /bcbs-ma-eligibility
* Enqueues a BCBS MA eligibility check.
* Body: { data: { memberId, dateOfBirth, firstName, lastName, insuranceSiteKey }, socketId }
*/
router.post("/bcbs-ma-eligibility", async (req: Request, res: Response): Promise<any> => {
if (!req.body.data) {
return res.status(400).json({ error: "Missing data for BCBS MA eligibility check" });
}
if (!req.user?.id) {
return res.status(401).json({ error: "Unauthorized: user info missing" });
}
try {
const rawData =
typeof req.body.data === "string" ? JSON.parse(req.body.data) : req.body.data;
const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(req.user.id, "BCBS_MA");
if (!credentials) {
return res.status(404).json({
error: "No BCBS MA credentials found. Please add them in Settings → Insurance Credentials.",
});
}
// Fetch provider NPI — use the first one on file for this user
const npiProviders = await storage.getNpiProvidersByUser(req.user.id);
const npiNumber = npiProviders?.[0]?.npiNumber ?? "";
if (!npiNumber) {
return res.status(404).json({
error: "No NPI provider found. Please add one in Settings → NPI Providers.",
});
}
const enrichedData = {
...rawData,
bcbsMaUsername: credentials.username,
bcbsMaPassword: credentials.password,
providerNpi: npiNumber,
};
const socketId: string | undefined = req.body.socketId;
const jobId = enqueueSeleniumJob({
jobType: "bcbs-ma-eligibility-check",
userId: req.user.id,
socketId,
enrichedPayload: enrichedData,
insuranceId: String(rawData.memberId ?? "").trim(),
formFirstName: rawData.firstName,
formLastName: rawData.lastName,
formDob: rawData.dateOfBirth,
});
log("bcbs-ma-route", "job enqueued", { jobId, insuranceId: rawData.memberId });
return res.json({ status: "queued", jobId });
} catch (err: any) {
console.error("[bcbs-ma-route] enqueue failed:", err);
return res.status(500).json({ error: err.message || "Failed to enqueue BCBS MA selenium job" });
}
});
/**
* POST /selenium/submit-otp
* Forwards the OTP to the Python agent (side-channel, bypasses queue).
* Body: { session_id, otp, socketId? }
*/
router.post("/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 forwardOtpToSeleniumBcbsMaAgent(sessionId, otp);
emitSafe(socketId, "selenium:otp_submitted", { session_id: sessionId, result: r });
return res.json(r);
} catch (err: any) {
console.error("[bcbs-ma-route] submit-otp failed:", err?.message || err);
return res.status(500).json({
error: "Failed to forward OTP to selenium agent",
detail: err?.message || err,
});
}
});
export default router;

View File

@@ -0,0 +1,67 @@
import axios from "axios";
import http from "http";
import https from "https";
import dotenv from "dotenv";
dotenv.config();
const SELENIUM_AGENT_BASE = process.env.SELENIUM_AGENT_BASE_URL;
const httpAgent = new http.Agent({ keepAlive: true, keepAliveMsecs: 60_000 });
const httpsAgent = new https.Agent({ keepAlive: true, keepAliveMsecs: 60_000 });
const client = axios.create({
baseURL: SELENIUM_AGENT_BASE,
timeout: 5 * 60 * 1000,
httpAgent,
httpsAgent,
validateStatus: (s) => s >= 200 && s < 600,
});
async function requestWithRetries(config: any, retries = 4, baseBackoffMs = 300) {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const r = await client.request(config);
if (![502, 503, 504].includes(r.status)) return r;
} catch (err: any) {
const code = err?.code;
const isTransient =
code === "ECONNRESET" || code === "ECONNREFUSED" || code === "EPIPE" || code === "ETIMEDOUT";
if (!isTransient) throw err;
}
await new Promise((r) => setTimeout(r, baseBackoffMs * attempt));
}
return client.request(config);
}
function log(tag: string, msg: string, ctx?: any) {
console.log(`${new Date().toISOString()} [${tag}] ${msg}`, ctx ?? "");
}
export async function forwardToSeleniumBcbsMaEligibilityAgent(data: any): Promise<any> {
const payload = { data };
log("bcbs-ma-client", "POST bcbs-ma-eligibility", { keys: Object.keys(payload) });
const r = await requestWithRetries({ url: "/bcbs-ma-eligibility", method: "POST", data: payload }, 4);
log("bcbs-ma-client", "agent response", { status: r.status });
if (r.status >= 500) throw new Error(`Selenium agent server error: ${r.status}`);
return r.data;
}
export async function forwardOtpToSeleniumBcbsMaAgent(sessionId: string, otp: string): Promise<any> {
log("bcbs-ma-client", "POST submit-otp", { sessionId });
const r = await requestWithRetries(
{ url: "/submit-otp", method: "POST", data: { session_id: sessionId, otp } },
4
);
if (r.status >= 500) throw new Error(`Selenium agent server error on submit-otp: ${r.status}`);
return r.data;
}
export async function getSeleniumBcbsMaSessionStatus(sessionId: string): Promise<any> {
const r = await requestWithRetries({ url: `/session/${sessionId}/status`, method: "GET" }, 4);
if (r.status === 404) {
const e: any = new Error("not_found");
e.response = { status: 404, data: r.data };
throw e;
}
return r.data;
}

View File

@@ -0,0 +1,293 @@
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { CheckCircle, LoaderCircleIcon, X } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useAppDispatch } from "@/redux/hooks";
import { setTaskStatus } from "@/redux/slices/seleniumTaskSlice";
import { formatLocalDate } from "@/utils/dateUtils";
import { socket } from "@/lib/socket";
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
// ─── OTP Modal ────────────────────────────────────────────────────────────────
interface BcbsMaOtpModalProps {
open: boolean;
onClose: () => void;
onSubmit: (otp: string) => Promise<void> | void;
isSubmitting: boolean;
}
function BcbsMaOtpModal({ open, onClose, onSubmit, isSubmitting }: BcbsMaOtpModalProps) {
const [otp, setOtp] = useState("");
useEffect(() => {
if (!open) setOtp("");
}, [open]);
if (!open) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!otp.trim()) return;
await onSubmit(otp.trim());
};
return (
<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 BCBS MA</h2>
<button type="button" onClick={onClose} className="text-slate-500 hover:text-slate-800">
<X className="w-4 h-4" />
</button>
</div>
<p className="text-sm text-slate-500 mb-4">
Enter the last 6 digits of the one-time verification code sent by the BCBS MA Provider
Central portal to your registered email. The email shows a code like{" "}
<span className="font-mono font-medium">XXXX-XXXXXX</span> enter only the last 6 digits.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="bcbs-ma-otp">Last 6 digits of OTP</Label>
<Input
id="bcbs-ma-otp"
placeholder="e.g. 482913"
value={otp}
onChange={(e) => setOtp(e.target.value)}
maxLength={6}
autoFocus
/>
</div>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={onClose} disabled={isSubmitting}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting || !otp.trim()}>
{isSubmitting ? (
<>
<LoaderCircleIcon className="w-4 h-4 mr-2 animate-spin" />
Submitting...
</>
) : (
"Submit OTP"
)}
</Button>
</div>
</form>
</div>
</div>
);
}
// ─── Main component ───────────────────────────────────────────────────────────
interface BcbsMaEligibilityButtonProps {
memberId: string;
dateOfBirth: Date | null;
firstName?: string;
lastName?: string;
isFormIncomplete: boolean;
autoTrigger?: boolean;
onAutoTriggered?: () => void;
onPdfReady: (pdfId: number, fallbackFilename: string | null) => void;
}
export function BcbsMaEligibilityButton({
memberId,
dateOfBirth,
firstName,
lastName,
isFormIncomplete,
autoTrigger,
onAutoTriggered,
onPdfReady,
}: BcbsMaEligibilityButtonProps) {
const { toast } = useToast();
const dispatch = useAppDispatch();
const sessionIdRef = useRef<string | null>(null);
const autoTriggeredRef = useRef(false);
const [otpModalOpen, setOtpModalOpen] = useState(false);
const [isStarting, setIsStarting] = useState(false);
const [isSubmittingOtp, setIsSubmittingOtp] = useState(false);
const handleStart = async () => {
if (!memberId || !dateOfBirth) {
toast({
title: "Missing fields",
description: "Member ID and Date of Birth are required.",
variant: "destructive",
});
return;
}
const formattedDob = formatLocalDate(dateOfBirth);
const payload = {
memberId,
dateOfBirth: formattedDob,
firstName,
lastName,
insuranceSiteKey: "BCBS_MA",
};
setIsStarting(true);
try {
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "pending", message: "Starting BCBS MA eligibility check…" }));
const response = await apiRequest(
"POST",
"/api/insurance-status-bcbs-ma/bcbs-ma-eligibility",
{ data: JSON.stringify(payload), socketId: socket.id }
);
const result = await response.json();
if (!response.ok || result.error) {
throw new Error(result.error || `Server error (${response.status})`);
}
const jobId: string = result.jobId;
if (!jobId) throw new Error("No jobId returned from server");
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "pending", message: "BCBS MA job queued. Opening browser…" }));
const onSessionStarted = (data: any) => {
if (String(data?.jobId) !== String(jobId)) return;
sessionIdRef.current = data.session_id ?? null;
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "pending", message: "Browser started. Waiting for OTP…" }));
};
const onOtpRequired = (data: any) => {
if (String(data?.jobId) !== String(jobId)) return;
if (data.session_id) sessionIdRef.current = data.session_id;
setOtpModalOpen(true);
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "pending", message: "OTP required for BCBS MA. Please enter the code." }));
};
const onOtpSubmitted = (data: any) => {
if (data?.session_id && data.session_id !== sessionIdRef.current) return;
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "pending", message: "OTP submitted. Finishing BCBS MA eligibility check…" }));
};
function cleanup() {
socket.off("selenium:bcbs_ma_session_started", onSessionStarted);
socket.off("selenium:otp_required", onOtpRequired);
socket.off("selenium:otp_submitted", onOtpSubmitted);
socket.off("job:update", onJobUpdate);
}
const safetyTimer = setTimeout(() => {
cleanup();
setIsStarting(false);
setOtpModalOpen(false);
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: "BCBS MA job timed out." }));
}, 10 * 60 * 1000);
const onJobUpdate = (data: any) => {
if (String(data?.jobId) !== String(jobId)) return;
if (data.status === "active") {
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "pending", message: data.message ?? "Browser starting…" }));
return;
}
clearTimeout(safetyTimer);
cleanup();
if (data.status === "completed") {
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "success", message: "BCBS MA eligibility updated and PDF saved." }));
toast({ title: "BCBS MA eligibility complete", description: "Patient status was updated and the eligibility PDF was saved." });
queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
const pdfId = data.result?.pdfFileId;
if (pdfId) {
onPdfReady(Number(pdfId), data.result?.pdfFilename ?? `eligibility_bcbs_ma_${memberId}.pdf`);
}
} else if (data.status === "failed") {
const msg = data.error ?? "BCBS MA eligibility job failed.";
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: msg }));
toast({ title: "BCBS MA selenium error", description: msg, variant: "destructive" });
}
setIsStarting(false);
setOtpModalOpen(false);
};
socket.on("selenium:bcbs_ma_session_started", onSessionStarted);
socket.on("selenium:otp_required", onOtpRequired);
socket.on("selenium:otp_submitted", onOtpSubmitted);
socket.on("job:update", onJobUpdate);
} catch (err: any) {
console.error("BcbsMaEligibilityButton error:", err);
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: err?.message || "Failed to start BCBS MA eligibility" }));
toast({ title: "BCBS MA selenium error", description: err?.message || "Failed to start BCBS MA eligibility", variant: "destructive" });
setIsStarting(false);
}
};
const handleSubmitOtp = async (otp: string) => {
const sessionId = sessionIdRef.current;
if (!sessionId) {
toast({ title: "Session not ready", description: "Cannot submit OTP — session ID not yet available.", variant: "destructive" });
return;
}
try {
setIsSubmittingOtp(true);
const resp = await apiRequest("POST", "/api/insurance-status-bcbs-ma/selenium/submit-otp", {
session_id: sessionId,
otp,
socketId: socket.id,
});
const data = await resp.json();
if (!resp.ok || data.error) throw new Error(data.error || "Failed to submit OTP");
setOtpModalOpen(false);
} catch (err: any) {
toast({ title: "Failed to submit OTP", description: err?.message || "Error forwarding OTP to selenium agent", variant: "destructive" });
} finally {
setIsSubmittingOtp(false);
}
};
useEffect(() => {
if (!autoTrigger || autoTriggeredRef.current || isFormIncomplete) return;
autoTriggeredRef.current = true;
onAutoTriggered?.();
handleStart();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoTrigger, isFormIncomplete]);
return (
<>
<Button
className="w-full"
variant="outline"
disabled={isFormIncomplete || isStarting}
onClick={handleStart}
>
{isStarting ? (
<>
<LoaderCircleIcon className="h-4 w-4 mr-2 animate-spin" />
Processing...
</>
) : (
<>
<CheckCircle className="h-4 w-4 mr-2" />
BCBS MA
</>
)}
</Button>
<BcbsMaOtpModal
open={otpModalOpen}
onClose={() => setOtpModalOpen(false)}
onSubmit={handleSubmitOtp}
isSubmitting={isSubmittingOtp}
/>
</>
);
}

View File

@@ -22,6 +22,7 @@ const SITE_KEY_OPTIONS = [
{ value: "TUFTS_SCO", label: "Tufts SCO (TUFTS_SCO)" }, { value: "TUFTS_SCO", label: "Tufts SCO (TUFTS_SCO)" },
{ value: "UNITED_SCO", label: "United SCO / DentalHub (UNITED_SCO)" }, { value: "UNITED_SCO", label: "United SCO / DentalHub (UNITED_SCO)" },
{ value: "CCA", label: "CCA (CCA)" }, { value: "CCA", label: "CCA (CCA)" },
{ value: "BCBS_MA", label: "BCBS MA (BCBS_MA)" },
]; ];
export function CredentialForm({ onClose, userId, defaultValues }: CredentialFormProps) { export function CredentialForm({ onClose, userId, defaultValues }: CredentialFormProps) {

View File

@@ -41,6 +41,7 @@ import { DeltaInsEligibilityButton } from "@/components/insurance-status/deltain
import { TuftsSCOEligibilityButton } from "@/components/insurance-status/tufts-sco-button-modal"; import { TuftsSCOEligibilityButton } from "@/components/insurance-status/tufts-sco-button-modal";
import { UnitedSCOEligibilityButton } from "@/components/insurance-status/united-sco-button-modal"; import { UnitedSCOEligibilityButton } from "@/components/insurance-status/united-sco-button-modal";
import { CCAEligibilityButton } from "@/components/insurance-status/cca-button-modal"; import { CCAEligibilityButton } from "@/components/insurance-status/cca-button-modal";
import { BcbsMaEligibilityButton } from "@/components/insurance-status/bcbs-ma-button-modal";
import { useLicense } from "@/hooks/use-license"; import { useLicense } from "@/hooks/use-license";
/** /**
@@ -908,14 +909,22 @@ export default function InsuranceStatusPage() {
/> />
</div> </div>
<Button <div title={!isLicensed ? "License required" : undefined} className={!isLicensed ? "opacity-40 pointer-events-none" : ""}>
className="w-full" <BcbsMaEligibilityButton
variant="outline" memberId={memberId}
disabled={isFormIncomplete} dateOfBirth={dateOfBirth}
> firstName={firstName}
<CheckCircle className="h-4 w-4 mr-2" /> lastName={lastName}
BCBS isFormIncomplete={isFormIncomplete}
</Button> onPdfReady={(pdfId, fallbackFilename) => {
setPreviewPdfId(pdfId);
setPreviewFallbackFilename(
fallbackFilename ?? `eligibility_bcbs_ma_${memberId}.pdf`,
);
setPreviewOpen(true);
}}
/>
</div>
</div> </div>
{/* Row 2 */} {/* Row 2 */}

View File

@@ -23,6 +23,7 @@ import helpers_cca_preauth as hcca_preauth
import helpers_ddma_claim as hddma_claim import helpers_ddma_claim as hddma_claim
import helpers_uniteddh_claim as huniteddh_claim import helpers_uniteddh_claim as huniteddh_claim
import helpers_tuftssco_claim as htuftssco_claim import helpers_tuftssco_claim as htuftssco_claim
import helpers_bcbs_ma_eligibility as hbcbs_ma
# Import startup session-clear functions # Import startup session-clear functions
from ddma_browser_manager import clear_ddma_session_on_startup from ddma_browser_manager import clear_ddma_session_on_startup
@@ -547,6 +548,48 @@ async def cca_eligibility(request: Request):
return {"status": "started", "session_id": sid} return {"status": "started", "session_id": sid}
async def _bcbs_ma_worker_wrapper(sid: str, data: dict, url: str):
"""Background worker for BCBS MA eligibility — fresh browser, always OTP."""
global active_jobs, waiting_jobs
async with semaphore:
async with lock:
waiting_jobs -= 1
active_jobs += 1
try:
await hbcbs_ma.start_bcbs_ma_run(sid, data, url)
finally:
async with lock:
active_jobs -= 1
@app.post("/bcbs-ma-eligibility")
async def bcbs_ma_eligibility(request: Request):
"""
Starts a BCBS MA eligibility session in the background.
Fresh Chrome each time — no persistent session (OTP always required).
Body: { "data": { memberId, dateOfBirth, firstName, lastName, bcbsMaUsername, bcbsMaPassword } }
Returns: { status: "started", session_id: "<uuid>" }
"""
global waiting_jobs
body = await request.json()
data = body.get("data", {})
sid = hbcbs_ma.make_session_entry()
hbcbs_ma.sessions[sid]["type"] = "bcbs_ma_eligibility"
hbcbs_ma.sessions[sid]["last_activity"] = time.time()
async with lock:
waiting_jobs += 1
asyncio.create_task(_bcbs_ma_worker_wrapper(
sid, data,
url="https://provider.bluecrossma.com/ProviderHome/portal/"
))
return {"status": "started", "session_id": sid}
async def _cca_claim_worker_wrapper(sid: str, data: dict, url: str): async def _cca_claim_worker_wrapper(sid: str, data: dict, url: str):
"""Background worker for CCA claim submission.""" """Background worker for CCA claim submission."""
global active_jobs, waiting_jobs global active_jobs, waiting_jobs
@@ -782,6 +825,8 @@ async def submit_otp(request: Request):
res = huniteddh_claim.submit_otp(sid, otp) res = huniteddh_claim.submit_otp(sid, otp)
elif sid in htuftssco_claim.sessions: elif sid in htuftssco_claim.sessions:
res = htuftssco_claim.submit_otp(sid, otp) res = htuftssco_claim.submit_otp(sid, otp)
elif sid in hbcbs_ma.sessions:
res = hbcbs_ma.submit_otp(sid, otp)
else: else:
raise HTTPException(status_code=404, detail="session not found") raise HTTPException(status_code=404, detail="session not found")
@@ -813,6 +858,8 @@ async def session_status(sid: str):
s = huniteddh_claim.get_session_status(sid) s = huniteddh_claim.get_session_status(sid)
elif sid in htuftssco_claim.sessions: elif sid in htuftssco_claim.sessions:
s = htuftssco_claim.get_session_status(sid) s = htuftssco_claim.get_session_status(sid)
elif sid in hbcbs_ma.sessions:
s = hbcbs_ma.get_session_status(sid)
else: else:
s = {"status": "not_found"} s = {"status": "not_found"}
if s.get("status") == "not_found": if s.get("status") == "not_found":

View File

@@ -0,0 +1,199 @@
import os
import time
import asyncio
import uuid
from typing import Dict, Any
from selenium.common.exceptions import WebDriverException
from selenium_BCBS_MA_eligibilityCheckWorker import AutomationBCBSMAEligibilityCheck
# In-memory session store
sessions: Dict[str, Dict[str, Any]] = {}
SESSION_OTP_TIMEOUT = int(os.getenv("SESSION_OTP_TIMEOUT", "120")) # seconds
def make_session_entry() -> str:
sid = str(uuid.uuid4())
sessions[sid] = {
"status": "created", # created → running → waiting_for_otp → completed / error
"created_at": time.time(),
"last_activity": time.time(),
"bot": None,
"driver": None,
"otp_event": asyncio.Event(),
"otp_value": None,
"result": None,
"message": None,
"type": None,
}
return sid
async def _remove_session_later(sid: str, delay: int = 30):
await asyncio.sleep(delay)
sessions.pop(sid, None)
print(f"[helpers_bcbs_ma] cleaned session {sid}")
async def start_bcbs_ma_run(sid: str, data: dict, url: str):
"""
Full BCBS MA eligibility workflow for one session.
Always fresh Chrome — no persistent session because BCBS MA always
requires OTP (the prefix changes each login).
OTP handling:
a) Accept OTP submitted from the app via /submit-otp (sets otp_value)
b) Poll the browser directly to detect user entry in the open window
"""
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 = AutomationBCBSMAEligibilityCheck({"data": data})
bot.config_driver()
s["bot"] = bot
s["driver"] = bot.driver
# ── Login ──────────────────────────────────────────────────────────────
try:
login_result = bot.login(url)
except WebDriverException as e:
s["status"] = "error"
s["message"] = f"WebDriver error during login: {e}"
return {"status": "error", "message": s["message"]}
except Exception as e:
s["status"] = "error"
s["message"] = f"Login failed: {e}"
return {"status": "error", "message": s["message"]}
if isinstance(login_result, str) and login_result.startswith("ERROR"):
s["status"] = "error"
s["message"] = login_result
return {"status": "error", "message": login_result}
# ── OTP required (always for BCBS MA) ─────────────────────────────────
if isinstance(login_result, str) and login_result == "OTP_REQUIRED":
s["status"] = "waiting_for_otp"
s["message"] = "OTP required — enter the 6-digit code from the BCBS MA email"
s["last_activity"] = time.time()
driver = bot.driver
login_success = False
print(f"[BCBS MA] Waiting for OTP (up to {SESSION_OTP_TIMEOUT}s)...")
for poll in range(SESSION_OTP_TIMEOUT):
await asyncio.sleep(1)
s["last_activity"] = time.time()
try:
# a) App submitted OTP
otp_value = s.get("otp_value")
if otp_value:
print(f"[BCBS MA OTP poll {poll+1}] Submitting OTP from app...")
otp_result = bot.submit_otp_step(otp_value)
s["otp_value"] = None
if isinstance(otp_result, str) and otp_result == "SUCCESS":
login_success = True
break
elif isinstance(otp_result, str) and otp_result.startswith("ERROR"):
print(f"[BCBS MA OTP] submit_otp_step returned: {otp_result}")
# Don't abort yet — let the poll loop check the browser state
# b) Detect success by browser URL
current_url = driver.current_url.lower()
print(f"[BCBS MA OTP poll {poll+1}/{SESSION_OTP_TIMEOUT}] URL: {current_url[:70]}")
if "authsvc" not in current_url and "/mga/sps/" not in current_url:
# Left the OTP page — login completed (user entered OTP in browser)
print("[BCBS MA OTP] Browser left OTP page — login successful")
login_success = True
break
except Exception as poll_err:
print(f"[BCBS MA OTP poll {poll+1}] Error: {poll_err}")
if not login_success:
s["status"] = "error"
s["message"] = "OTP timeout — login not completed in time"
return {"status": "error", "message": s["message"]}
s["status"] = "running"
s["message"] = "Login successful after OTP"
print("[BCBS MA] OTP accepted, proceeding to eligibility search...")
await asyncio.sleep(2)
# ── Already logged in (no OTP path) ──────────────────────────────────
elif isinstance(login_result, str) and login_result == "SUCCESS":
print("[BCBS MA] Login succeeded without OTP")
s["status"] = "running"
# ── Step 1: search member ──────────────────────────────────────────────
step1_result = bot.step1()
if isinstance(step1_result, str) and step1_result.startswith("ERROR"):
s["status"] = "error"
s["message"] = step1_result
return {"status": "error", "message": step1_result}
# ── Step 2: extract + PDF ──────────────────────────────────────────────
step2_result = bot.step2()
if isinstance(step2_result, dict) and step2_result.get("status") == "success":
s["status"] = "completed"
s["result"] = step2_result
s["message"] = "completed"
asyncio.create_task(_remove_session_later(sid, 30))
return step2_result
else:
s["status"] = "error"
s["message"] = step2_result.get("message", "unknown error") if isinstance(step2_result, dict) else str(step2_result)
return {"status": "error", "message": s["message"]}
except Exception as e:
s["status"] = "error"
s["message"] = f"worker exception: {e}"
print(f"[helpers_bcbs_ma] Unexpected error: {e}")
return {"status": "error", "message": s["message"]}
finally:
# Always close the disposable browser
try:
if bot:
bot.close_driver()
except Exception:
pass
s["driver"] = None
def submit_otp(sid: str, otp: str) -> Dict[str, Any]:
"""Called by /submit-otp to hand OTP to the polling loop."""
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") == "completed" else None,
}

View File

@@ -0,0 +1,462 @@
import os
import time
import base64
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 TimeoutException, WebDriverException
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
DOWNLOAD_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "downloads", "bcbs_ma"))
def _fresh_driver() -> webdriver.Chrome:
"""Create a disposable Chrome instance for a single BCBS MA session."""
os.makedirs(DOWNLOAD_DIR, exist_ok=True)
options = Options()
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
options.add_argument("--disable-blink-features=AutomationControlled")
options.add_experimental_option("excludeSwitches", ["enable-automation"])
options.add_experimental_option("useAutomationExtension", False)
options.add_experimental_option("prefs", {
"download.default_directory": DOWNLOAD_DIR,
"download.prompt_for_download": False,
"plugins.always_open_pdf_externally": True,
})
headless = os.getenv("SELENIUM_HEADLESS", "false").lower() == "true"
if headless:
options.add_argument("--headless=new")
try:
from webdriver_manager.chrome import ChromeDriverManager
service = Service(ChromeDriverManager().install())
except Exception:
service = Service()
driver = webdriver.Chrome(service=service, options=options)
driver.maximize_window()
return driver
class AutomationBCBSMAEligibilityCheck:
"""
BCBS MA Provider Central eligibility check.
No persistent session — fresh Chrome every run because BCBS MA
always requires OTP on new login (the OTP prefix changes each time).
Flow: login(url) → OTP_REQUIRED → [caller polls for OTP] → submit_otp_step(otp)
→ step1() [search member] → step2() [extract + PDF]
"""
LOGIN_URL = "https://provider.bluecrossma.com/ProviderHome/portal/"
def __init__(self, data: dict):
raw = data.get("data", data)
self.member_id = raw.get("memberId", "")
self.dob = raw.get("dateOfBirth", "") # YYYY-MM-DD from frontend
self.first_name = raw.get("firstName", "")
self.last_name = raw.get("lastName", "")
self.username = raw.get("bcbsMaUsername", "")
self.password = raw.get("bcbsMaPassword", "")
self.provider_npi = raw.get("providerNpi", "")
self.driver: webdriver.Chrome | None = None
# ── Driver lifecycle ──────────────────────────────────────────────────────
def config_driver(self):
self.driver = _fresh_driver()
def close_driver(self):
try:
if self.driver:
self.driver.quit()
except Exception:
pass
self.driver = None
# ── Helpers ───────────────────────────────────────────────────────────────
def _wait(self, timeout=15):
return WebDriverWait(self.driver, timeout)
def _dob_mmddyyyy(self) -> str:
"""Convert YYYY-MM-DD → MM/DD/YYYY."""
try:
parts = self.dob.split("-")
return f"{parts[1]}/{parts[2]}/{parts[0]}"
except Exception:
return self.dob
# ── Step: Login ───────────────────────────────────────────────────────────
def login(self, url: str = "") -> str:
"""
Navigate to BCBS MA Provider Central, fill credentials, click Log in.
Returns:
"OTP_REQUIRED" redirected to MFA challenge page (/mga/sps/authsvc)
"SUCCESS" landed on dashboard without OTP
"ERROR:..." something went wrong
"""
target = url or self.LOGIN_URL
try:
print(f"[BCBS MA login] Navigating to {target}")
self.driver.get(target)
# Wait for username field: id="txtUsername0"
username_input = self._wait(20).until(
EC.presence_of_element_located((By.ID, "txtUsername0"))
)
username_input.clear()
username_input.send_keys(self.username)
print("[BCBS MA login] Username filled")
# Password field: id="txtPassword"
password_input = self._wait(10).until(
EC.presence_of_element_located((By.ID, "txtPassword"))
)
password_input.clear()
password_input.send_keys(self.password)
print("[BCBS MA login] Password filled")
# Log in button: id="ns_Z7_09ME1282N8N3B0QGV9ND6N20G2_loginSubmit"
login_btn = self._wait(10).until(
EC.element_to_be_clickable((By.ID, "ns_Z7_09ME1282N8N3B0QGV9ND6N20G2_loginSubmit"))
)
login_btn.click()
print("[BCBS MA login] Log in clicked, waiting for response...")
time.sleep(3)
return self._detect_post_login_state()
except Exception as e:
print(f"[BCBS MA login] Error: {e}")
return f"ERROR: login failed: {e}"
def _detect_post_login_state(self) -> str:
"""Check current URL/DOM to decide what happened after login."""
for _ in range(6):
time.sleep(1)
url = self.driver.current_url.lower()
print(f"[BCBS MA] post-login URL: {url[:80]}")
if "authsvc" in url or "/mga/sps/" in url:
print("[BCBS MA] OTP page detected")
return "OTP_REQUIRED"
if "providerhome" in url or "portal" in url:
# Check if we're on a real portal page (not login form)
try:
self.driver.find_element(By.XPATH,
"//*[contains(@href,'eTools') or contains(text(),'eTools') or "
"contains(@href,'eligibility') or contains(text(),'Eligibility')]"
)
print("[BCBS MA] Dashboard detected — logged in without OTP")
return "SUCCESS"
except Exception:
pass
print("[BCBS MA] Could not determine post-login state")
return "OTP_REQUIRED" # default assumption for BCBS MA
# ── Step: Submit OTP ──────────────────────────────────────────────────────
def submit_otp_step(self, otp: str) -> str:
"""
Enter the 6-digit OTP into the BCBS MA verification page and click Submit.
OTP input: id="otppswd", name="otp.user.otp"
Submit btn: id="submitButton" (starts disabled, enables after OTP typed)
Returns "SUCCESS" or "ERROR:..."
"""
try:
print(f"[BCBS MA OTP] Submitting OTP: {otp}")
# OTP input field: id="otppswd"
otp_input = self._wait(15).until(
EC.presence_of_element_located((By.ID, "otppswd"))
)
otp_input.clear()
otp_input.send_keys(otp)
print("[BCBS MA OTP] OTP entered")
# Submit button starts disabled — wait for it to become clickable
submit_btn = self._wait(10).until(
EC.element_to_be_clickable((By.ID, "submitButton"))
)
submit_btn.click()
print("[BCBS MA OTP] Submit clicked, waiting for dashboard...")
time.sleep(4)
# Wait for dashboard
for _ in range(15):
time.sleep(1)
url = self.driver.current_url.lower()
print(f"[BCBS MA OTP] URL: {url[:80]}")
if "authsvc" not in url and "/mga/sps/" not in url:
print("[BCBS MA OTP] Left OTP page — login successful")
return "SUCCESS"
return "ERROR: OTP page still visible after submission"
except Exception as e:
print(f"[BCBS MA OTP] Error: {e}")
return f"ERROR: OTP submission failed: {e}"
# ── Step 1: Navigate to ConnectCenter → New Eligibility Request ──────────
def step1(self) -> str:
"""
After OTP login:
1. Click eTools menu
2. Click ConnectCenter in the dropdown
3. Click Go Now on the ConnectCenter launch page
4. Click Continue in the popup (opens ConnectCenter in new tab)
5. Switch to new tab, click Verification
6. Click New Eligibility Request in dropdown
Returns "SUCCESS" (on Eligibility Identifier page) or "ERROR:..."
"""
try:
from selenium.webdriver.common.keys import Keys
# 1. Click eTools
print("[BCBS MA step1] Clicking eTools...")
etools = self._wait(15).until(
EC.element_to_be_clickable((By.XPATH,
"//span[text()='eTools'] | //a[.//span[text()='eTools']]"
))
)
etools.click()
print("[BCBS MA step1] eTools clicked, waiting for dropdown...")
time.sleep(2)
# 2. Click ConnectCenter link in the dropdown
print("[BCBS MA step1] Clicking ConnectCenter...")
connect_center = self._wait(15).until(
EC.element_to_be_clickable((By.XPATH,
"//a[contains(@href,'connectcenter')]"
))
)
connect_center.click()
print("[BCBS MA step1] ConnectCenter clicked, waiting for page to load...")
time.sleep(3)
# 3. Click Go Now on the ConnectCenter page
print("[BCBS MA step1] Clicking Go Now...")
go_now = self._wait(20).until(
EC.element_to_be_clickable((By.ID, "GoNow-2c338de9-6a2f-4d71-b5fb-a6e4ae14be80"))
)
go_now.click()
time.sleep(2)
print("[BCBS MA step1] Go Now clicked — popup opened, pressing Enter to continue...")
# 4. Press Enter to auto-confirm the popup
from selenium.webdriver.common.action_chains import ActionChains
ActionChains(self.driver).send_keys(Keys.ENTER).perform()
time.sleep(4)
print("[BCBS MA step1] Enter pressed — continuing to ConnectCenter")
# 5. Switch to the new tab that ConnectCenter opened
self._wait(10).until(lambda d: len(d.window_handles) > 1)
self.driver.switch_to.window(self.driver.window_handles[-1])
print(f"[BCBS MA step1] Switched to new tab: {self.driver.current_url[:60]}")
time.sleep(3)
# 6. Click Verification menu
print("[BCBS MA step1] Clicking Verification...")
verification = self._wait(15).until(
EC.element_to_be_clickable((By.XPATH,
"//a[text()='Verification' or normalize-space(text())='Verification']"
))
)
verification.click()
time.sleep(2)
print("[BCBS MA step1] Verification clicked")
# 7. Click New Eligibility Request in dropdown
print("[BCBS MA step1] Clicking New Eligibility Request...")
new_elig = self._wait(10).until(
EC.element_to_be_clickable((By.XPATH,
"//a[@ng-click='newEligibility();']"
))
)
new_elig.click()
time.sleep(3)
print("[BCBS MA step1] New Eligibility Request clicked — on Eligibility Identifier page")
return "SUCCESS"
except Exception as e:
print(f"[BCBS MA step1] Error: {e}")
return f"ERROR: step1 failed: {e}"
# ── Step 2: Fill Eligibility Identifier form and get results ─────────────
def step2(self) -> dict:
"""
On the Eligibility Identifier page:
1. Enter provider NPI → click Find Provider
2. Select payer search option: Member ID, Subscriber Date Of Birth (value=2)
3. Select service type: Dental Care [35] (value=33)
4. Select place of service: OFFICE [11] (value=30)
5. Enter member ID
6. Enter date of birth (MM/DD/YYYY)
7. Click Submit → wait for results page → PDF
"""
from selenium.webdriver.support.ui import Select
try:
print("[BCBS MA step2] Filling Eligibility Identifier form...")
# 1. Enter provider NPI
provider_id_input = self._wait(15).until(
EC.presence_of_element_located((By.ID, "providerID"))
)
provider_id_input.clear()
provider_id_input.send_keys(self.provider_npi)
print(f"[BCBS MA step2] Provider NPI entered: {self.provider_npi}")
# Click Find Provider
find_provider = self._wait(10).until(
EC.element_to_be_clickable((By.XPATH,
"//button[@ng-click='searchProviders()']"
))
)
find_provider.click()
print("[BCBS MA step2] Find Provider clicked, waiting...")
time.sleep(3)
# 2. Payer search options → value="2": Member ID, Subscriber Date Of Birth
payer_select = self._wait(10).until(
EC.presence_of_element_located((By.ID, "payerSearchOptions"))
)
Select(payer_select).select_by_value("2")
print("[BCBS MA step2] Payer search option selected: Member ID + DOB")
time.sleep(1)
# 3. Service type → value="33": Dental Care [35]
service_select = self._wait(10).until(
EC.presence_of_element_located((By.ID, "serviceType"))
)
Select(service_select).select_by_value("33")
print("[BCBS MA step2] Service type selected: Dental Care [35]")
# 4. Place of service → value="30": OFFICE [11]
pos_select = self._wait(10).until(
EC.presence_of_element_located((By.ID, "placeOfService"))
)
Select(pos_select).select_by_value("30")
print("[BCBS MA step2] Place of service selected: OFFICE [11]")
# 5. Member ID — field name is subscriberMedicaidID
member_input = self._wait(10).until(
EC.presence_of_element_located((By.XPATH,
"//input[@name='subscriberMedicaidID' or @id='subscriberMedicaidID']"
))
)
member_input.clear()
member_input.send_keys(self.member_id)
print(f"[BCBS MA step2] Member ID entered: {self.member_id}")
# 6. Date of birth — id="subscriberDateOfBirth", placeholder="mm/dd/yyyy"
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.keys import Keys
dob_formatted = self._dob_mmddyyyy()
dob_input = self._wait(10).until(
EC.presence_of_element_located((By.ID, "subscriberDateOfBirth"))
)
# Double-click to focus, then type directly via ActionChains
ActionChains(self.driver).double_click(dob_input).perform()
time.sleep(0.3)
ActionChains(self.driver).send_keys(dob_formatted).perform()
print(f"[BCBS MA step2] DOB typed: {dob_formatted}")
time.sleep(1)
# 7. Click Submit
submit_btn = self._wait(10).until(
EC.element_to_be_clickable((By.XPATH,
"//button[@ng-click='submit()' and @type='submit']"
))
)
submit_btn.click()
print("[BCBS MA step2] Submit clicked, waiting for results...")
time.sleep(5)
# Wait for results page to load
self._wait(20).until(
EC.presence_of_element_located((By.XPATH,
"//*[contains(text(),'Eligible') or contains(text(),'Not Eligible') or "
"contains(text(),'Active') or contains(text(),'Inactive') or "
"contains(text(),'Coverage') or contains(text(),'Benefit')]"
))
)
print("[BCBS MA step2] Results page loaded")
# Click Expand All to expand all eligibility sections before PDFing
try:
expand_all = self._wait(10).until(
EC.element_to_be_clickable((By.XPATH,
"//h6[normalize-space(text())='Expand All']"
))
)
expand_all.click()
print("[BCBS MA step2] Expand All clicked, waiting for sections to expand...")
time.sleep(3)
except Exception as e:
print(f"[BCBS MA step2] Expand All not found or failed: {e}")
# Extract eligibility status
page_text = self.driver.find_element(By.TAG_NAME, "body").text
text_lower = page_text.lower()
if "not eligible" in text_lower or "inactive" in text_lower or "terminated" in text_lower:
eligibility = "Not Eligible"
elif "eligible" in text_lower or "active" in text_lower or "covered" in text_lower:
eligibility = "Eligible"
else:
eligibility = "Unknown"
patient_name = f"{self.first_name} {self.last_name}".strip()
print(f"[BCBS MA step2] Eligibility: {eligibility}")
# PDF the results page via CDP
pdf_base64 = ""
pdf_path = None
try:
result = self.driver.execute_cdp_cmd("Page.printToPDF", {
"printBackground": True,
"paperWidth": 8.5,
"paperHeight": 11,
"marginTop": 0.5,
"marginBottom": 0.5,
"marginLeft": 0.5,
"marginRight": 0.5,
})
pdf_data = result.get("data", "")
if pdf_data:
os.makedirs(DOWNLOAD_DIR, exist_ok=True)
filename = f"bcbs_ma_eligibility_{self.member_id}_{int(time.time())}.pdf"
pdf_path = os.path.join(DOWNLOAD_DIR, filename)
with open(pdf_path, "wb") as f:
f.write(base64.b64decode(pdf_data))
pdf_base64 = pdf_data
print(f"[BCBS MA step2] PDF saved: {pdf_path}")
except Exception as e:
print(f"[BCBS MA step2] PDF generation failed: {e}")
return {
"status": "success",
"eligibility": eligibility,
"patientName": patient_name,
"memberId": self.member_id,
"insurerName": "BCBS MA",
"pdfBase64": pdf_base64,
"pdf_path": pdf_path,
}
except Exception as e:
print(f"[BCBS MA step2] Error: {e}")
return {"status": "error", "message": f"step2 failed: {e}"}