feat: Altus Dental eligibility check and ADA paper claim/preauth
Eligibility (no OTP, fresh browser per run like MH): logs in, opens Benefits & Claims Information, searches by Subscriber ID (or Subscriber Last Name via "Click Here"), First Name and DOB, picks the matching row from the patient list, sets ACTIVE/INACTIVE from the Coverage range, reads Group Number into the patient's Group Number, opens View Details for the last name and plan name, and saves the details page PDF to the patient's Eligibility folder. A patient switching plans is matched by name + DOB so Altus overwrites the old insurance instead of creating a duplicate. Claims: Altus uses the same 2024 ADA form as BCBS with the Altus payer address (P.O. Box 1557, Providence, RI 02901-1557). Adds Altus Claim/PreAuth (ADA Form) buttons, Claim All and AI queue routing, Print ADA Form in the claim viewer, and an Altus fee schedule (copied from BCBS). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -89,6 +89,7 @@ export function siteKeyToAutoCheck(siteKey: string, dob?: string | Date | null):
|
||||
case "UNITED_UCARD": return "united-ucard";
|
||||
case "BCBS_MA": return "bcbs-ma";
|
||||
case "METLIFE": return "metlife";
|
||||
case "ALTUS": return "altus";
|
||||
default: {
|
||||
// MassHealth: patients under 21 use the CMSP button
|
||||
if (dob) {
|
||||
|
||||
@@ -43,5 +43,7 @@
|
||||
|
||||
{ "keyword": "metlife", "siteKey": "METLIFE" },
|
||||
{ "keyword": "met life", "siteKey": "METLIFE" },
|
||||
{ "keyword": "metdental", "siteKey": "METLIFE" }
|
||||
{ "keyword": "metdental", "siteKey": "METLIFE" },
|
||||
|
||||
{ "keyword": "altus", "siteKey": "ALTUS" }
|
||||
]
|
||||
|
||||
@@ -31,6 +31,7 @@ import { runEligibilityHistoryProcessor } from "./processors/eligibilityHistoryP
|
||||
import { runCmspEligibilityHistoryRemainingProcessor } from "./processors/cmspEligibilityHistoryRemainingProcessor";
|
||||
import { runBcbsMaEligibilityProcessor } from "./processors/bcbsMaEligibilityProcessor";
|
||||
import { runMetLifeEligibilityProcessor } from "./processors/metlifeEligibilityProcessor";
|
||||
import { runAltusEligibilityProcessor } from "./processors/altusEligibilityProcessor";
|
||||
import type { SeleniumJobData, OcrJobData } from "./queues";
|
||||
|
||||
// ── Queue instances ──────────────────────────────────────────────────────────
|
||||
@@ -336,6 +337,16 @@ export function enqueueSeleniumJob(data: SeleniumJobData): string {
|
||||
job.id
|
||||
);
|
||||
}
|
||||
if (jobType === "altus-eligibility-check") {
|
||||
return runAltusEligibilityProcessor({
|
||||
enrichedPayload: data.enrichedPayload,
|
||||
userId: data.userId,
|
||||
insuranceId: data.insuranceId!,
|
||||
formFirstName: data.formFirstName,
|
||||
formLastName: data.formLastName,
|
||||
formDob: data.formDob,
|
||||
});
|
||||
}
|
||||
throw new Error(`Unknown selenium jobType: ${jobType}`);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Processor for "altus-eligibility-check" jobs.
|
||||
* Runs the Altus Dental eligibility selenium flow (sync, no OTP) and saves the
|
||||
* PDF to the patient's Eligibility folder.
|
||||
*/
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { storage } from "../../storage";
|
||||
import { emptyFolderContainingFile } from "../../utils/emptyTempFolder";
|
||||
import { callPythonSync, createOrUpdatePatientByInsuranceId } from "./_shared";
|
||||
|
||||
export interface AltusEligibilityProcessorInput {
|
||||
enrichedPayload: any;
|
||||
userId: number;
|
||||
insuranceId: string;
|
||||
formFirstName?: string;
|
||||
formLastName?: string;
|
||||
formDob?: string;
|
||||
}
|
||||
|
||||
export interface AltusEligibilityProcessorResult {
|
||||
patientUpdateStatus?: string;
|
||||
pdfUploadStatus?: string;
|
||||
pdfFileId?: number | string | null;
|
||||
pdfFilename?: string | null;
|
||||
}
|
||||
|
||||
export async function runAltusEligibilityProcessor(
|
||||
input: AltusEligibilityProcessorInput
|
||||
): Promise<AltusEligibilityProcessorResult> {
|
||||
const { enrichedPayload, userId, insuranceId, formFirstName, formLastName, formDob } = input;
|
||||
|
||||
const seleniumResult = await callPythonSync("/altus-eligibility-check", {
|
||||
data: enrichedPayload,
|
||||
});
|
||||
|
||||
const outputResult: AltusEligibilityProcessorResult = {};
|
||||
|
||||
const seleniumInsurance = seleniumResult.insurance?.trim() || "Altus Dental";
|
||||
const seleniumGroup = String(seleniumResult.group ?? "").trim() || null;
|
||||
|
||||
// Name searches (no Subscriber ID typed) use the ID shown in the results row.
|
||||
const scrapedId = String(seleniumResult.memberId ?? "").replace(/\s+/g, "");
|
||||
const altusId = insuranceId || scrapedId;
|
||||
|
||||
// First name: as typed in the app. Last name: read from the Altus details page,
|
||||
// else the one typed in the app.
|
||||
const firstName = (formFirstName ?? "").trim();
|
||||
const lastName = String(seleniumResult.lastName ?? "").trim() || (formLastName ?? "").trim();
|
||||
const dobDate = formDob ? new Date(formDob) : null;
|
||||
const validDob = dobDate && !isNaN(dobDate.getTime()) ? dobDate : null;
|
||||
|
||||
// Resolve the patient. A patient switching insurance (e.g. MetLife → Altus) is
|
||||
// still on file under the old plan's member ID, so after the Altus ID lookup
|
||||
// fall back to first name (+ last name) + DOB before creating anyone new.
|
||||
let patient: any = null;
|
||||
try {
|
||||
if (altusId) patient = await storage.getPatientByInsuranceId(altusId);
|
||||
|
||||
if (!patient && firstName && validDob) {
|
||||
const matches = await storage.searchPatients({
|
||||
filters: {
|
||||
firstName: { equals: firstName, mode: "insensitive" },
|
||||
...(lastName ? { lastName: { equals: lastName, mode: "insensitive" } } : {}),
|
||||
dateOfBirth: validDob,
|
||||
},
|
||||
limit: 2,
|
||||
offset: 0,
|
||||
});
|
||||
// Only take an unambiguous match
|
||||
if (matches?.length === 1) patient = matches[0];
|
||||
}
|
||||
|
||||
if (!patient) {
|
||||
patient = altusId
|
||||
? await createOrUpdatePatientByInsuranceId({
|
||||
insuranceId: altusId,
|
||||
firstName: firstName || null,
|
||||
lastName: lastName || null,
|
||||
dob: formDob,
|
||||
userId,
|
||||
})
|
||||
: await storage.createPatient({
|
||||
firstName,
|
||||
lastName,
|
||||
dateOfBirth: validDob,
|
||||
gender: "",
|
||||
phone: "",
|
||||
userId,
|
||||
} as any);
|
||||
}
|
||||
} catch (e: any) {
|
||||
throw new Error(`Failed to create/update patient: ${e.message}`);
|
||||
}
|
||||
|
||||
if (patient && patient.id !== undefined) {
|
||||
let newStatus = "UNKNOWN";
|
||||
if (seleniumResult.eligibility === "Y") newStatus = "ACTIVE";
|
||||
else if (seleniumResult.eligibility === "N") newStatus = "INACTIVE";
|
||||
|
||||
// Altus becomes the patient's current insurance — overwrite the previous plan's
|
||||
// provider/ID/group. Earlier eligibility PDFs stay in the Eligibility folder.
|
||||
const updates: any = { status: newStatus, insuranceProvider: seleniumInsurance };
|
||||
if (firstName) updates.firstName = firstName;
|
||||
if (lastName) updates.lastName = lastName; // never blank out an existing last name
|
||||
if (altusId) updates.insuranceId = altusId;
|
||||
if (seleniumGroup) updates.groupNumber = seleniumGroup;
|
||||
const previousProvider = patient.insuranceProvider ?? null;
|
||||
await storage.updatePatient(patient.id, updates);
|
||||
outputResult.patientUpdateStatus =
|
||||
`Patient status updated to ${newStatus}` +
|
||||
(previousProvider && previousProvider !== seleniumInsurance
|
||||
? ` (insurance changed from ${previousProvider} to ${seleniumInsurance})`
|
||||
: "");
|
||||
|
||||
if (seleniumResult.pdf_path?.endsWith(".pdf")) {
|
||||
const patientName =
|
||||
`${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim() || `Patient ${patient.id}`;
|
||||
try {
|
||||
const buffer = await fs.readFile(seleniumResult.pdf_path);
|
||||
const cloudFile = await storage.savePdfToCloudStorage(
|
||||
userId,
|
||||
patient.id,
|
||||
patientName,
|
||||
"Eligibility",
|
||||
path.basename(seleniumResult.pdf_path),
|
||||
buffer
|
||||
);
|
||||
outputResult.pdfFileId = `cloud:${cloudFile.id}`;
|
||||
outputResult.pdfUploadStatus = "Eligibility PDF saved";
|
||||
} catch (e: any) {
|
||||
console.error("[altusEligibilityProcessor] PDF save failed:", e.message);
|
||||
outputResult.pdfFileId = null;
|
||||
outputResult.pdfUploadStatus = "Eligibility PDF upload failed";
|
||||
}
|
||||
outputResult.pdfFilename = path.basename(seleniumResult.pdf_path);
|
||||
} else {
|
||||
outputResult.pdfUploadStatus = "No eligibility PDF returned by Selenium";
|
||||
}
|
||||
} else {
|
||||
outputResult.patientUpdateStatus = "Patient not found or missing ID; no update performed";
|
||||
}
|
||||
|
||||
try {
|
||||
if (seleniumResult.pdf_path) await emptyFolderContainingFile(seleniumResult.pdf_path);
|
||||
} catch (e) {
|
||||
console.error("[altusEligibilityProcessor] cleanup failed:", e);
|
||||
}
|
||||
|
||||
return outputResult;
|
||||
}
|
||||
@@ -26,7 +26,8 @@ export type SeleniumJobType =
|
||||
| "mh-eligibility-history-check"
|
||||
| "cmsp-eligibility-history-remaining-check"
|
||||
| "bcbs-ma-eligibility-check"
|
||||
| "metlife-eligibility-check";
|
||||
| "metlife-eligibility-check"
|
||||
| "altus-eligibility-check";
|
||||
|
||||
export interface SeleniumJobData {
|
||||
jobType: SeleniumJobType;
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from "@repo/db/types";
|
||||
import { forwardToSeleniumClaimPreAuthAgent } from "../services/seleniumInsuranceClaimPreAuthClient";
|
||||
import { formatDobForAgent } from "../utils/dateUtils";
|
||||
import { ADA_PAYERS, buildAdaClaimPdf } from "../services/adaClaimForm";
|
||||
import { ADA_PAYERS, adaPayerKeyForProvider, buildAdaClaimPdf } from "../services/adaClaimForm";
|
||||
import { backupScreenshots } from "../utils/screenshotBackup";
|
||||
import { appendAppointmentFiles } from "../utils/appointmentAttachments";
|
||||
|
||||
@@ -1235,10 +1235,6 @@ async function buildAdaFormForRequest(req: Request): Promise<AdaFormResult> {
|
||||
const claimId = parseInt(String(req.params.id), 10);
|
||||
if (isNaN(claimId)) return { ok: false, status: 400, error: "Invalid claim ID" };
|
||||
|
||||
const payerKey = String(req.query.payer || "BCBS_MA").toUpperCase();
|
||||
const payer = ADA_PAYERS[payerKey];
|
||||
if (!payer) return { ok: false, status: 400, error: `Unknown payer "${payerKey}"` };
|
||||
|
||||
const { prisma: db } = await import("@repo/db/client");
|
||||
const claim = await db.claim.findUnique({
|
||||
where: { id: claimId },
|
||||
@@ -1253,6 +1249,11 @@ async function buildAdaFormForRequest(req: Request): Promise<AdaFormResult> {
|
||||
return { ok: false, status: 404, error: "Claim not found" };
|
||||
}
|
||||
|
||||
// Payer: explicit ?payer=, else derived from the claim's insurance (e.g. Altus → ALTUS)
|
||||
const payerKey = String(req.query.payer || adaPayerKeyForProvider(claim.insuranceProvider)).toUpperCase();
|
||||
const payer = ADA_PAYERS[payerKey];
|
||||
if (!payer) return { ok: false, status: 400, error: `Unknown payer "${payerKey}"` };
|
||||
|
||||
const office = await storage.getOfficeContact(req.user!.id);
|
||||
// Fall back to the default (first) NPI provider when the claim has none
|
||||
const treatingProvider =
|
||||
|
||||
@@ -11,6 +11,7 @@ const SCHEDULE_FILES: Record<string, string> = {
|
||||
DDMA: "procedureCodesDDMA.json",
|
||||
BCBS: "procedureCodesBCBS.json",
|
||||
BCBS_MA: "procedureCodesBCBS.json",
|
||||
ALTUS: "procedureCodesAltus.json",
|
||||
TUFTSSCO: "procedureCodesTuftsSCO.json",
|
||||
TUFTS_SCO: "procedureCodesTuftsSCO.json",
|
||||
UNITEDDH: "procedureCodesUnitedDH.json",
|
||||
|
||||
@@ -49,6 +49,7 @@ import feeScheduleRoutes from "./feeSchedule";
|
||||
import licenseRoutes from "./license";
|
||||
import insuranceStatusBcbsMaRoutes from "./insuranceStatusBcbsMa";
|
||||
import insuranceStatusMetLifeRoutes from "./insuranceStatusMetLife";
|
||||
import insuranceStatusAltusRoutes from "./insuranceStatusAltus";
|
||||
import labRxRoutes from "./lab-rx";
|
||||
import seleniumSettingsRoutes from "./selenium-settings";
|
||||
import typeAgentRoutes from "./type-agent";
|
||||
@@ -77,6 +78,7 @@ router.use("/insurance-status-tuftssco", insuranceStatusTuftsSCORoutes);
|
||||
router.use("/insurance-status-cca", insuranceStatusCCARoutes);
|
||||
router.use("/insurance-status-bcbs-ma", insuranceStatusBcbsMaRoutes);
|
||||
router.use("/insurance-status-metlife", insuranceStatusMetLifeRoutes);
|
||||
router.use("/insurance-status-altus", insuranceStatusAltusRoutes);
|
||||
router.use("/claims", insuranceStatusCCAClaimRoutes);
|
||||
router.use("/claims", insuranceStatusCCAPreAuthRoutes);
|
||||
router.use("/claims", insuranceStatusDDMAClaimRoutes);
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Router, Request, Response } from "express";
|
||||
import { storage } from "../storage";
|
||||
import { enqueueSeleniumJob } from "../queue/jobRunner";
|
||||
|
||||
const router = Router();
|
||||
|
||||
function log(tag: string, msg: string, ctx?: any) {
|
||||
console.log(`${new Date().toISOString()} [${tag}] ${msg}`, ctx ?? "");
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /altus-eligibility
|
||||
*
|
||||
* Enqueues an Altus Dental eligibility check.
|
||||
* No OTP — fresh browser per run (same model as MassHealth).
|
||||
*
|
||||
* Body:
|
||||
* data — patient + search fields (memberId, dateOfBirth, firstName, lastName)
|
||||
* socketId — socket.io client id for real-time updates
|
||||
*
|
||||
* Response: { status: "queued", jobId: "…" }
|
||||
*/
|
||||
router.post(
|
||||
"/altus-eligibility",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
if (!req.body.data) {
|
||||
return res.status(400).json({ error: "Missing eligibility data for selenium" });
|
||||
}
|
||||
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;
|
||||
|
||||
// Altus searches by Subscriber ID, or by Subscriber Last Name when the ID is unknown.
|
||||
const insuranceId = String(rawData.memberId ?? "").trim();
|
||||
const lastName = String(rawData.lastName ?? "").trim();
|
||||
if (!String(rawData.firstName ?? "").trim() || !rawData.dateOfBirth || (!insuranceId && !lastName)) {
|
||||
return res.status(400).json({
|
||||
error: "Altus requires First Name, Date of Birth, and Member ID or Last Name.",
|
||||
});
|
||||
}
|
||||
|
||||
const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(
|
||||
req.user.id,
|
||||
"ALTUS"
|
||||
);
|
||||
if (!credentials) {
|
||||
return res.status(404).json({
|
||||
error: "No credentials found for Altus. Please add them on the Settings page.",
|
||||
});
|
||||
}
|
||||
|
||||
const enrichedData = {
|
||||
...rawData,
|
||||
altusUsername: credentials.username,
|
||||
altusPassword: credentials.password,
|
||||
};
|
||||
|
||||
const jobId = enqueueSeleniumJob({
|
||||
jobType: "altus-eligibility-check",
|
||||
userId: req.user.id,
|
||||
socketId: req.body.socketId,
|
||||
enrichedPayload: enrichedData,
|
||||
insuranceId,
|
||||
formFirstName: rawData.firstName,
|
||||
formLastName: rawData.lastName,
|
||||
formDob: rawData.dateOfBirth,
|
||||
});
|
||||
|
||||
log("altus-route", "job enqueued", { jobId, insuranceId });
|
||||
|
||||
return res.json({ status: "queued", jobId });
|
||||
} catch (err: any) {
|
||||
console.error("[altus-route] enqueue failed:", err);
|
||||
return res.status(500).json({
|
||||
error: err.message || "Failed to enqueue Altus selenium job",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -28,8 +28,18 @@ export const ADA_PAYERS: Record<string, AdaPayer> = {
|
||||
address1: "P.O. Box 986005",
|
||||
cityStateZip: "Boston, MA 02298",
|
||||
},
|
||||
ALTUS: {
|
||||
name: "Altus Dental",
|
||||
address1: "P.O. Box 1557",
|
||||
cityStateZip: "Providence, RI 02901-1557",
|
||||
},
|
||||
};
|
||||
|
||||
/** ADA payer key for a claim's insurance provider text (defaults to BCBS MA). */
|
||||
export function adaPayerKeyForProvider(provider?: string | null): string {
|
||||
return /altus/i.test(provider ?? "") ? "ALTUS" : "BCBS_MA";
|
||||
}
|
||||
|
||||
export type AdaClaimInput = {
|
||||
claim: {
|
||||
id: number;
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
[
|
||||
{ "Procedure Code": "D0120", "Description": "Periodic oral evaluation - established patient", "Price": 105 },
|
||||
{ "Procedure Code": "D0140", "Description": "Limited oral evaluation - problem focused", "Price": 90 },
|
||||
{ "Procedure Code": "D0145", "Description": "Oral evaluation for a patient under three years of age and counseling with primary caregiver", "Price": "NC" },
|
||||
{ "Procedure Code": "D0150", "Description": "Comprehensive oral evaluation - new or established patient", "Price": 120 },
|
||||
{ "Procedure Code": "D0180", "Description": "Comprehensive periodontal evaluation - new or established patient", "Price": 37 },
|
||||
{ "Procedure Code": "D0190", "Description": "Screening of a patient (PHDH only)", "Price": 20 },
|
||||
{ "Procedure Code": "D0191", "Description": "Assessment of a patient (PHDH only)", "Price": 20 },
|
||||
{ "Procedure Code": "D0210", "Description": "Intraoral - complete series of radiographic images", "Price": 120 },
|
||||
{ "Procedure Code": "D0220", "Description": "Intraoral - periapical, first radiographic image", "Price": 60 },
|
||||
{ "Procedure Code": "D0230", "Description": "Intraoral - periapical, each additional radiographic image", "Price": 50 },
|
||||
{ "Procedure Code": "D0240", "Description": "Intraoral - occlusal radiographic image", "Price": "NC" },
|
||||
{ "Procedure Code": "D0270", "Description": "Bitewing - single radiographic image", "Price": 14 },
|
||||
{ "Procedure Code": "D0272", "Description": "Bitewings - two radiographic images", "Price": 80 },
|
||||
{ "Procedure Code": "D0273", "Description": "Bitewings - three radiographic images", "Price": 27 },
|
||||
{ "Procedure Code": "D0274", "Description": "Bitewings - four radiographic images", "Price": 160 },
|
||||
{ "Procedure Code": "D0330", "Description": "Panoramic radiographic image", "Price": 150 },
|
||||
{ "Procedure Code": "D0340", "Description": "Cephalometric radiograph image (Oral surgeon only)", "Price": 74 },
|
||||
{ "Procedure Code": "D0364", "Description": "CBCT - less than one jaw (interpret)", "Price": 350 },
|
||||
{ "Procedure Code": "D0365", "Description": "CBCT - mandible (interpret)", "Price": 350 },
|
||||
{ "Procedure Code": "D0366", "Description": "CBCT - maxilla (interpret)", "Price": 350 },
|
||||
{ "Procedure Code": "D0367", "Description": "CBCT - both jaws (interpret)", "Price": 400 },
|
||||
{ "Procedure Code": "D0368", "Description": "CBCT - include TMJ (interpret)", "Price": 375 },
|
||||
{ "Procedure Code": "D0380", "Description": "CBCT - less than one jaw (capture only)", "Price": 300 },
|
||||
{ "Procedure Code": "D0381", "Description": "CBCT - mandible (capture only)", "Price": 300 },
|
||||
{ "Procedure Code": "D0382", "Description": "CBCT - maxilla (capture only)", "Price": 300 },
|
||||
{ "Procedure Code": "D0383", "Description": "CBCT - both jaws / include TMJ (capture only)", "Price": 350 },
|
||||
{ "Procedure Code": "D1110", "Description": "Prophylaxis - adult, 14 yo or older", "Price": 150 },
|
||||
{ "Procedure Code": "D1120", "Description": "Prophylaxis - child, 0-13 yo", "Price": 120 },
|
||||
{ "Procedure Code": "D1206", "Description": "Topical application of fluoride varnish", "Price": 26 },
|
||||
{ "Procedure Code": "D1208", "Description": "Topical application of fluoride - excluding varnish", "Price": 90 },
|
||||
{ "Procedure Code": "D1351", "Description": "Sealant - per tooth", "Price": 80 },
|
||||
{ "Procedure Code": "D1354", "Description": "Application of caries arresting medicament - per tooth", "Price": 15 },
|
||||
{ "Procedure Code": "D1510", "Description": "Space maintainer - fixed, unilateral - per quadrant", "Price": "NC" },
|
||||
{ "Procedure Code": "D1516", "Description": "Space maintainer - fixed, bilateral, maxillary", "Price": "NC" },
|
||||
{ "Procedure Code": "D1517", "Description": "Space maintainer - fixed, bilateral, mandibular", "Price": "NC" },
|
||||
{ "Procedure Code": "D1520", "Description": "Space maintainer - removable, unilateral, per quadrant", "Price": "NC" },
|
||||
{ "Procedure Code": "D1526", "Description": "Space maintainer - removable, bilateral, maxillary", "Price": "NC" },
|
||||
{ "Procedure Code": "D1527", "Description": "Space maintainer - removable, bilateral, mandibular", "Price": "NC" },
|
||||
{ "Procedure Code": "D1575", "Description": "Distal shoe space maintainer - fixed, unilateral, per quadrant", "Price": "NC" },
|
||||
{ "Procedure Code": "D1701", "Description": "Pfizer-BioNTech Covid-19 vaccine - first dose", "Price": 45.87 },
|
||||
{ "Procedure Code": "D1702", "Description": "Pfizer-BioNTech Covid-19 vaccine - second dose", "Price": 45.87 },
|
||||
{ "Procedure Code": "D1707", "Description": "Janssen Covid-19 vaccine - single dose", "Price": 45.87 },
|
||||
{ "Procedure Code": "D1708", "Description": "Pfizer-BioNTech Covid-19 vaccine - third dose", "Price": 45.87 },
|
||||
{ "Procedure Code": "D1709", "Description": "Pfizer-BioNTech Covid-19 vaccine - booster dose", "Price": 45.87 },
|
||||
{ "Procedure Code": "D1712", "Description": "Janssen Covid-19 vaccine - booster dose", "Price": 45.87 },
|
||||
{ "Procedure Code": "D1713", "Description": "Pfizer-BioNTech Covid-19 vaccine pediatric - first dose", "Price": 45.87 },
|
||||
{ "Procedure Code": "D1714", "Description": "Pfizer-BioNTech Covid-19 vaccine pediatric - second dose", "Price": 45.87 },
|
||||
{ "Procedure Code": "D1999", "Description": "Unspecified preventive procedure, by report", "Price": 50 },
|
||||
{ "Procedure Code": "D2140", "Description": "Amalgam - one surface, primary or permanent", "Price": 150 },
|
||||
{ "Procedure Code": "D2150", "Description": "Amalgam - two surfaces, primary or permanent", "Price": 200 },
|
||||
{ "Procedure Code": "D2160", "Description": "Amalgam - three surfaces, primary or permanent", "Price": 92 },
|
||||
{ "Procedure Code": "D2161", "Description": "Amalgam - four or more surfaces, primary or permanent", "Price": 116 },
|
||||
{ "Procedure Code": "D2330", "Description": "Resin-based composite - one surface, anterior", "Price": 180 },
|
||||
{ "Procedure Code": "D2331", "Description": "Resin-based composite - two surfaces, anterior", "Price": 220 },
|
||||
{ "Procedure Code": "D2332", "Description": "Resin-based composite - three surfaces, anterior", "Price": 280 },
|
||||
{ "Procedure Code": "D2335", "Description": "Resin-based composite - four or more surfaces or involving incisal angle (anterior)", "Price": 350 },
|
||||
{ "Procedure Code": "D2390", "Description": "Resin-based composite crown, anterior", "Price": "NC" },
|
||||
{ "Procedure Code": "D2391", "Description": "Resin-based composite - one surface, posterior", "Price": 200 },
|
||||
{ "Procedure Code": "D2392", "Description": "Resin-based composite - two surfaces, posterior", "Price": 250 },
|
||||
{ "Procedure Code": "D2393", "Description": "Resin-based composite - three surfaces, posterior", "Price": 280 },
|
||||
{ "Procedure Code": "D2394", "Description": "Resin-based composite - four or more surfaces, posterior", "Price": 320 },
|
||||
{ "Procedure Code": "D2710", "Description": "Crown - resin-based composite (indirect)", "Price": "NC" },
|
||||
{ "Procedure Code": "D2740", "Description": "Crown - porcelain/ceramic", "Price": 1300 },
|
||||
{ "Procedure Code": "D2750", "Description": "Crown - porcelain fused to high noble metal", "Price": 1300 },
|
||||
{ "Procedure Code": "D2751", "Description": "Crown - porcelain fused to predominantly base metal", "Price": 1200 },
|
||||
{ "Procedure Code": "D2752", "Description": "Crown - porcelain fused to noble metal", "Price": 1200 },
|
||||
{ "Procedure Code": "D2790", "Description": "Crown - full cast high noble metal", "Price": "NC" },
|
||||
{ "Procedure Code": "D2910", "Description": "Re-cement or re-bond inlay, onlay or partial coverage restoration", "Price": 57 },
|
||||
{ "Procedure Code": "D2920", "Description": "Re-cement or re-bond crown", "Price": 120 },
|
||||
{ "Procedure Code": "D2929", "Description": "Prefabricated porcelain/ceramic crown - primary tooth", "Price": "NC" },
|
||||
{ "Procedure Code": "D2930", "Description": "Prefabricated stainless steel crown - primary tooth", "Price": "NC" },
|
||||
{ "Procedure Code": "D2931", "Description": "Prefabricated stainless steel crown - permanent tooth", "Price": 171 },
|
||||
{ "Procedure Code": "D2932", "Description": "Prefabricated resin crown", "Price": "NC" },
|
||||
{ "Procedure Code": "D2934", "Description": "Prefabricated esthetic coated stainless steel crown - primary tooth", "Price": "NC" },
|
||||
{ "Procedure Code": "D2950", "Description": "Core buildup, including any pins when required", "Price": 350 },
|
||||
{ "Procedure Code": "D2951", "Description": "Pin retention - per tooth, in addition to restoration", "Price": 27 },
|
||||
{ "Procedure Code": "D2954", "Description": "Prefabricated post and core in addition to crown", "Price": 450 },
|
||||
{ "Procedure Code": "D2955", "Description": "Post removal", "Price": 350 },
|
||||
{ "Procedure Code": "D2980", "Description": "Crown repair necessitated by restorative material failure", "Price": 115 },
|
||||
{ "Procedure Code": "D2999", "Description": "Unspecified restorative procedure, by report", "Price": "IC" },
|
||||
{ "Procedure Code": "D3120", "Description": "Pulp cap - indirect (excluding final restoration)", "Price": 34 },
|
||||
{ "Procedure Code": "D3220", "Description": "Therapeutic pulpotomy (excluding final restoration)", "Price": "NC" },
|
||||
{ "Procedure Code": "D3230", "Description": "Pulpal therapy (resorbable filling) - anterior, primary tooth", "Price": 280 },
|
||||
{ "Procedure Code": "D3240", "Description": "Pulpal therapy (resorbable filling) - posterior, primary tooth", "Price": 490 },
|
||||
{ "Procedure Code": "D3310", "Description": "Endodontic therapy, anterior tooth (excluding final restoration)", "Price": 900 },
|
||||
{ "Procedure Code": "D3320", "Description": "Endodontic therapy, premolar tooth (excluding final restoration)", "Price": 1050 },
|
||||
{ "Procedure Code": "D3330", "Description": "Endodontic therapy, molar tooth (excluding final restoration)", "Price": 1250 },
|
||||
{ "Procedure Code": "D3346", "Description": "Retreatment of previous root canal therapy - anterior", "Price": 950 },
|
||||
{ "Procedure Code": "D3347", "Description": "Retreatment of previous root canal therapy - premolar", "Price": 1100 },
|
||||
{ "Procedure Code": "D3348", "Description": "Retreatment of previous root canal therapy - molar", "Price": 1500 },
|
||||
{ "Procedure Code": "D3410", "Description": "Apicoectomy - anterior", "Price": 407 },
|
||||
{ "Procedure Code": "D3421", "Description": "Apicoectomy - premolar (first root)", "Price": 460 },
|
||||
{ "Procedure Code": "D3425", "Description": "Apicoectomy - molar (first root)", "Price": 598 },
|
||||
{ "Procedure Code": "D3426", "Description": "Apicoectomy (each additional root)", "Price": 230 },
|
||||
{ "Procedure Code": "D4210", "Description": "Gingivectomy or gingivoplasty - four or more contiguous teeth per quadrant", "Price": 307 },
|
||||
{ "Procedure Code": "D4211", "Description": "Gingivectomy or gingivoplasty - one to three contiguous teeth per quadrant", "Price": 111 },
|
||||
{ "Procedure Code": "D4341", "Description": "Periodontal scaling and root planing - four or more teeth per quadrant", "Price": 250 },
|
||||
{ "Procedure Code": "D4342", "Description": "Periodontal scaling and root planing - one to three teeth, per quadrant", "Price": 90 },
|
||||
{ "Procedure Code": "D4346", "Description": "Scaling in presence of generalized moderate or severe gingival inflammation - full mouth", "Price": 60 },
|
||||
{ "Procedure Code": "D4910", "Description": "Periodontal maintenance", "Price": 250 },
|
||||
{ "Procedure Code": "D5110", "Description": "Complete denture - maxillary", "Price": 1700 },
|
||||
{ "Procedure Code": "D5120", "Description": "Complete denture - mandibular", "Price": 1700 },
|
||||
{ "Procedure Code": "D5130", "Description": "Immediate denture - maxillary", "Price": "NC" },
|
||||
{ "Procedure Code": "D5140", "Description": "Immediate denture - mandibular", "Price": "NC" },
|
||||
{ "Procedure Code": "D5211", "Description": "Maxillary partial denture - resin base", "Price": 1300 },
|
||||
{ "Procedure Code": "D5212", "Description": "Mandibular partial denture - resin base", "Price": 1300 },
|
||||
{ "Procedure Code": "D5213", "Description": "Maxillary partial denture - cast metal framework with resin denture bases", "Price": 1700 },
|
||||
{ "Procedure Code": "D5214", "Description": "Mandibular partial denture - cast metal framework with resin denture bases", "Price": 1700 },
|
||||
{ "Procedure Code": "D5225", "Description": "Maxillary partial denture - flexible base", "Price": "NC" },
|
||||
{ "Procedure Code": "D5226", "Description": "Mandibular partial denture - flexible base", "Price": "NC" },
|
||||
{ "Procedure Code": "D5510", "Description": "Repair broken complete denture base (QUAD)", "Price": 400 },
|
||||
{ "Procedure Code": "D5511", "Description": "Repair broken complete denture base, mandibular", "Price": 85 },
|
||||
{ "Procedure Code": "D5512", "Description": "Repair broken complete denture base, maxillary", "Price": 85 },
|
||||
{ "Procedure Code": "D5520", "Description": "Replace missing or broken teeth - complete denture (each tooth)", "Price": 200 },
|
||||
{ "Procedure Code": "D5611", "Description": "Repair broken resin partial denture base, mandibular", "Price": 77 },
|
||||
{ "Procedure Code": "D5612", "Description": "Repair broken resin partial denture base, maxillary", "Price": 77 },
|
||||
{ "Procedure Code": "D5621", "Description": "Repair broken cast partial denture base, mandibular", "Price": 104 },
|
||||
{ "Procedure Code": "D5622", "Description": "Repair broken cast partial denture base, maxillary", "Price": 104 },
|
||||
{ "Procedure Code": "D5630", "Description": "Repair or replace broken retentive/clasping materials - per tooth", "Price": 99 },
|
||||
{ "Procedure Code": "D5640", "Description": "Replace broken teeth - per tooth", "Price": 77 },
|
||||
{ "Procedure Code": "D5650", "Description": "Add tooth to existing partial denture", "Price": 92 },
|
||||
{ "Procedure Code": "D5660", "Description": "Add clasp to existing partial denture per tooth", "Price": 98 },
|
||||
{ "Procedure Code": "D5730", "Description": "Reline complete maxillary denture (chairside)", "Price": 500 },
|
||||
{ "Procedure Code": "D5731", "Description": "Reline complete mandibular denture (chairside)", "Price": 500 },
|
||||
{ "Procedure Code": "D5740", "Description": "Reline maxillary partial denture (chairside)", "Price": "NC" },
|
||||
{ "Procedure Code": "D5741", "Description": "Reline mandibular partial denture (chairside)", "Price": "NC" },
|
||||
{ "Procedure Code": "D5750", "Description": "Reline complete maxillary denture (laboratory)", "Price": 600 },
|
||||
{ "Procedure Code": "D5751", "Description": "Reline complete mandibular denture (laboratory)", "Price": 600 },
|
||||
{ "Procedure Code": "D5760", "Description": "Reline maxillary partial denture (laboratory)", "Price": "NC" },
|
||||
{ "Procedure Code": "D5761", "Description": "Reline mandibular partial denture (laboratory)", "Price": "NC" },
|
||||
{ "Procedure Code": "D6010", "Description": "Surgical placement of implant body", "Price": 1600 },
|
||||
{ "Procedure Code": "D6056", "Description": "Prefabricated abutment", "Price": 750 },
|
||||
{ "Procedure Code": "D6057", "Description": "Custom abutment", "Price": 800 },
|
||||
{ "Procedure Code": "D6058", "Description": "Implant crown - porcelain/ceramic", "Price": 1400 },
|
||||
{ "Procedure Code": "D6059", "Description": "Implant crown - PFM", "Price": 1400 },
|
||||
{ "Procedure Code": "D6100", "Description": "Surgical removal of implant body", "Price": 320 },
|
||||
{ "Procedure Code": "D6110", "Description": "Implant/abutment supported removable denture - maxillary", "Price": 1600 },
|
||||
{ "Procedure Code": "D6111", "Description": "Implant/abutment supported removable denture - mandibular", "Price": 1600 },
|
||||
{ "Procedure Code": "D6241", "Description": "Pontic - porcelain fused to metal", "Price": "NC" },
|
||||
{ "Procedure Code": "D6242", "Description": "Pontic - noble metal", "Price": 1400 },
|
||||
{ "Procedure Code": "D6245", "Description": "Pontic - porcelain/ceramic", "Price": 1400 },
|
||||
{ "Procedure Code": "D6751", "Description": "Retainer crown - porcelain fused to metal", "Price": "NC" },
|
||||
{ "Procedure Code": "D6930", "Description": "Re-cement or re-bond fixed partial denture", "Price": "NC" },
|
||||
{ "Procedure Code": "D6980", "Description": "Fixed partial denture repair", "Price": "NC" },
|
||||
{ "Procedure Code": "D6999", "Description": "Fixed prosthodontic procedure, by report", "Price": "IC" },
|
||||
{ "Procedure Code": "D7111", "Description": "Extraction, coronal remnants - primary tooth", "Price": 120 },
|
||||
{ "Procedure Code": "D7140", "Description": "Extraction, erupted tooth or exposed root (simple)", "Price": 150 },
|
||||
{ "Procedure Code": "D7210", "Description": "Extraction, erupted tooth requiring removal of bone and/or sectioning (surgical)", "Price": 280 },
|
||||
{ "Procedure Code": "D7220", "Description": "Removal of impacted tooth - soft tissue", "Price": 380 },
|
||||
{ "Procedure Code": "D7230", "Description": "Removal of impacted tooth - partially bony", "Price": 450 },
|
||||
{ "Procedure Code": "D7240", "Description": "Removal of impacted tooth - completely bony", "Price": 550 },
|
||||
{ "Procedure Code": "D7250", "Description": "Surgical removal of residual tooth roots", "Price": 280 },
|
||||
{ "Procedure Code": "D7251", "Description": "Coronectomy - intentional partial tooth removal, impacted teeth only", "Price": 134 },
|
||||
{ "Procedure Code": "D7270", "Description": "Tooth reimplantation and/or stabilization of accidentally evulsed or displaced tooth", "Price": 106 },
|
||||
{ "Procedure Code": "D7280", "Description": "Surgical access of an unerupted tooth", "Price": "NC" },
|
||||
{ "Procedure Code": "D7283", "Description": "Placement of device to facilitate eruption of impacted tooth", "Price": "NC" },
|
||||
{ "Procedure Code": "D7310", "Description": "Alveoloplasty in conjunction with extractions - four or more teeth per quadrant", "Price": 142 },
|
||||
{ "Procedure Code": "D7311", "Description": "Alveoloplasty in conjunction with extractions - one to three teeth per quadrant", "Price": 128 },
|
||||
{ "Procedure Code": "D7320", "Description": "Alveoloplasty not in conjunction with extractions - four or more teeth per quadrant", "Price": 187 },
|
||||
{ "Procedure Code": "D7321", "Description": "Alveoloplasty not in conjunction with extractions - one to three teeth per quadrant", "Price": 149 },
|
||||
{ "Procedure Code": "D7340", "Description": "Vestibuloplasty - ridge extension (second epithelialization)", "Price": 747 },
|
||||
{ "Procedure Code": "D7350", "Description": "Vestibuloplasty - ridge extension (Oral surgeon only)", "Price": 943 },
|
||||
{ "Procedure Code": "D7410", "Description": "Excision of benign lesion - diameter up to 1.25 cm", "Price": 115 },
|
||||
{ "Procedure Code": "D7411", "Description": "Excision of benign lesion - diameter greater than 1.25 cm", "Price": 208 },
|
||||
{ "Procedure Code": "D7450", "Description": "Removal of benign odontogenic cyst or tumor - up to 1.25 cm", "Price": 248 },
|
||||
{ "Procedure Code": "D7451", "Description": "Removal of benign odontogenic cyst or tumor - greater than 1.25 cm", "Price": 288 },
|
||||
{ "Procedure Code": "D7460", "Description": "Removal of benign nonodontogenic cyst or tumor - up to 1.25 cm", "Price": 121 },
|
||||
{ "Procedure Code": "D7461", "Description": "Removal of benign nonodontogenic cyst or tumor - greater than 1.25 cm", "Price": 143 },
|
||||
{ "Procedure Code": "D7471", "Description": "Removal of lateral exostosis (maxilla or mandible)", "Price": 143 },
|
||||
{ "Procedure Code": "D7472", "Description": "Removal of torus palatinus", "Price": 143 },
|
||||
{ "Procedure Code": "D7473", "Description": "Removal of torus mandibularis", "Price": 143 },
|
||||
{ "Procedure Code": "D7910", "Description": "Suture of recent small wounds up to 5 cm", "Price": 400 },
|
||||
{ "Procedure Code": "D7950", "Description": "Bone graft - maxilla/mandible", "Price": 800 },
|
||||
{ "Procedure Code": "D7953", "Description": "Bone graft for socket preservation after extraction", "Price": 800 },
|
||||
{ "Procedure Code": "D7961", "Description": "Buccal/labial frenectomy (frenulectomy)", "Price": 107 },
|
||||
{ "Procedure Code": "D7962", "Description": "Lingual frenectomy (frenulectomy)", "Price": 107 },
|
||||
{ "Procedure Code": "D7963", "Description": "Frenuloplasty", "Price": 416 },
|
||||
{ "Procedure Code": "D7970", "Description": "Excision of hyperplastic tissue - per arch", "Price": 246 },
|
||||
{ "Procedure Code": "D7999", "Description": "Unspecified oral surgery procedure, by report", "Price": "IC" },
|
||||
{ "Procedure Code": "D8010", "Description": "Limited orthodontic treatment of the primary dentition (Orthodontist only)", "Price": "NC" },
|
||||
{ "Procedure Code": "D8020", "Description": "Limited orthodontic treatment of the transitional dentition (Orthodontist only)", "Price": "NC" },
|
||||
{ "Procedure Code": "D8030", "Description": "Limited orthodontic treatment of the adolescent dentition (Orthodontist only)", "Price": "NC" },
|
||||
{ "Procedure Code": "D8040", "Description": "Limited orthodontic treatment of the adult dentition (Orthodontist only)", "Price": "NC" },
|
||||
{ "Procedure Code": "D8070", "Description": "Comprehensive orthodontic treatment of the transitional dentition (Orthodontist only)", "Price": "NC" },
|
||||
{ "Procedure Code": "D8080", "Description": "Comprehensive orthodontic treatment of the adolescent dentition (Orthodontist only)", "Price": "NC" },
|
||||
{ "Procedure Code": "D8090", "Description": "Comprehensive orthodontic treatment of the adult dentition (Orthodontist only)", "Price": "NC" },
|
||||
{ "Procedure Code": "D8660", "Description": "Pre-orthodontic treatment examination (Orthodontist only)", "Price": "NC" },
|
||||
{ "Procedure Code": "D8670", "Description": "Periodic orthodontic treatment visit (Orthodontist only)", "Price": 215 },
|
||||
{ "Procedure Code": "D8680", "Description": "Orthodontic retention (Orthodontist only)", "Price": 85 },
|
||||
{ "Procedure Code": "D8703", "Description": "Replacement of lost or broken retainer - maxillary (Orthodontist only)", "Price": "NC" },
|
||||
{ "Procedure Code": "D8704", "Description": "Replacement of lost or broken retainer - mandibular (Orthodontist only)", "Price": "NC" },
|
||||
{ "Procedure Code": "D8999", "Description": "Unspecified orthodontic procedure, by report (Orthodontist only)", "Price": "NC" },
|
||||
{ "Procedure Code": "D9110", "Description": "Palliative treatment of dental pain - per visit", "Price": 36 },
|
||||
{ "Procedure Code": "D9222", "Description": "Deep sedation/general anesthesia - first 15 minutes", "Price": 90 },
|
||||
{ "Procedure Code": "D9223", "Description": "Deep sedation/general anesthesia - each additional 15-minute increment", "Price": 90 },
|
||||
{ "Procedure Code": "D9230", "Description": "Analgesia, anxiolysis, inhalation of nitrous oxide", "Price": 15 },
|
||||
{ "Procedure Code": "D9248", "Description": "Nonintravenous conscious sedation", "Price": 45 },
|
||||
{ "Procedure Code": "D9310", "Description": "Consultation - diagnostic service (Specialist only)", "Price": 63 },
|
||||
{ "Procedure Code": "D9410", "Description": "House/extended care facility call, once per facility per day", "Price": 39 },
|
||||
{ "Procedure Code": "D9450", "Description": "Rural add-on encounter payment", "Price": 31 },
|
||||
{ "Procedure Code": "D9920", "Description": "Behavior management, by report", "Price": 86 },
|
||||
{ "Procedure Code": "D9930", "Description": "Treatment of complications (postsurgical) - unusual circumstances, by report", "Price": 30 },
|
||||
{ "Procedure Code": "D9941", "Description": "Fabrication of athletic mouthguard", "Price": "NC" },
|
||||
{ "Procedure Code": "D9944", "Description": "Occlusal guard - hard appliance, full arch", "Price": "NC" },
|
||||
{ "Procedure Code": "D9945", "Description": "Occlusal guard - soft appliance, full arch", "Price": "NC" },
|
||||
{ "Procedure Code": "D9946", "Description": "Occlusal guard - hard appliance, partial arch", "Price": "NC" },
|
||||
{ "Procedure Code": "D9999", "Description": "Unspecified adjunctive procedure, by report", "Price": "IC" }
|
||||
]
|
||||
@@ -1587,6 +1587,8 @@ export function ClaimForm({
|
||||
runWithPriceCheck(handleUnitedUcardClaim);
|
||||
} else if (siteKey === "BCBS" || siteKey === "BCBS_MA") {
|
||||
runWithPriceCheck(() => handleBCBSClaim());
|
||||
} else if (siteKey === "ALTUS") {
|
||||
runWithPriceCheck(() => handleAltusClaim());
|
||||
} else {
|
||||
toast({
|
||||
title: "No automated claim for this insurance",
|
||||
@@ -1916,15 +1918,22 @@ export function ClaimForm({
|
||||
|
||||
// Saves claim to DB with all info (member ID, DOB, service date, provider, CDT codes, NPI).
|
||||
// No Selenium action — works like a real MassHealth claim for payment/report tracking.
|
||||
// BCBS MA has no online claim submission: save the claim, then open the filled
|
||||
// 2024 ADA paper form to print, sign and mail.
|
||||
// BCBS MA and Altus have no online claim submission: save the claim, then open the
|
||||
// filled 2024 ADA paper form (payer address per insurance) to print, sign and mail.
|
||||
// `auto` (AI claim queue): no tab — just save the filled form to Documents > Claims.
|
||||
// `preauth`: same ADA form with box 1 "Request for Predetermination/Preauthorization"
|
||||
// ticked (claim saved as PREAUTH), saved to Documents > PreAuth.
|
||||
const handleBCBSClaim = async (
|
||||
const ADA_PAPER_PAYERS = {
|
||||
BCBS_MA: { label: "BCBS", insuranceProvider: "BCBS MA", mailTo: "BCBS MA" },
|
||||
ALTUS: { label: "Altus", insuranceProvider: "Altus Dental", mailTo: "Altus Dental" },
|
||||
} as const;
|
||||
|
||||
const handleAdaPaperClaim = async (
|
||||
payerKey: keyof typeof ADA_PAPER_PAYERS,
|
||||
formToUse?: ClaimFormData & { uploadedFiles?: File[] },
|
||||
{ auto = false, preauth = false }: { auto?: boolean; preauth?: boolean } = {},
|
||||
) => {
|
||||
const payer = ADA_PAPER_PAYERS[payerKey];
|
||||
const kind = preauth ? "preauth" : "claim";
|
||||
const f = formToUse ?? form;
|
||||
const missingFields: string[] = [];
|
||||
@@ -1972,7 +1981,7 @@ export function ClaimForm({
|
||||
serviceLines: filteredServiceLines,
|
||||
staffId: appointmentStaffId ?? Number(staff?.id),
|
||||
patientId: patientId,
|
||||
insuranceProvider: "BCBS MA",
|
||||
insuranceProvider: payer.insuranceProvider,
|
||||
appointmentId: appointmentIdToUse,
|
||||
claimFiles: claimFilesMeta,
|
||||
...(selectedNpiProviderId ? { npiProviderId: selectedNpiProviderId } : {}),
|
||||
@@ -1980,29 +1989,39 @@ export function ClaimForm({
|
||||
} as any);
|
||||
|
||||
if (auto) {
|
||||
await saveAdaClaimForm(createdClaim.id!);
|
||||
await saveAdaClaimForm(createdClaim.id!, payerKey);
|
||||
} else {
|
||||
await showAdaClaimForm(createdClaim.id!, tab, "BCBS_MA", { save: true });
|
||||
await showAdaClaimForm(createdClaim.id!, tab, payerKey, { save: true });
|
||||
}
|
||||
// Saving the form marks the claim submitted — show the appointment as claimed
|
||||
queryClient.invalidateQueries({ queryKey: ["appointments", "day"] });
|
||||
toast({
|
||||
title: preauth ? "BCBS preauth form ready" : "BCBS claim form ready",
|
||||
title: `${payer.label} ${kind} form ready`,
|
||||
description: preauth
|
||||
? "PreAuth saved and ADA form added to the patient's Documents > PreAuth. Print, sign, and mail it to BCBS MA."
|
||||
: "Claim saved and ADA form added to the patient's Documents > Claims. Print, sign, and mail it to BCBS MA.",
|
||||
? `PreAuth saved and ADA form added to the patient's Documents > PreAuth. Print, sign, and mail it to ${payer.mailTo}.`
|
||||
: `Claim saved and ADA form added to the patient's Documents > Claims. Print, sign, and mail it to ${payer.mailTo}.`,
|
||||
});
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
tab?.close();
|
||||
toast({
|
||||
title: preauth ? "BCBS preauth failed" : "BCBS claim failed",
|
||||
title: `${payer.label} ${kind} failed`,
|
||||
description: err?.message ?? `Failed to create the ADA ${kind} form.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleBCBSClaim = (
|
||||
formToUse?: ClaimFormData & { uploadedFiles?: File[] },
|
||||
opts?: { auto?: boolean; preauth?: boolean },
|
||||
) => handleAdaPaperClaim("BCBS_MA", formToUse, opts);
|
||||
|
||||
const handleAltusClaim = (
|
||||
formToUse?: ClaimFormData & { uploadedFiles?: File[] },
|
||||
opts?: { auto?: boolean; preauth?: boolean },
|
||||
) => handleAdaPaperClaim("ALTUS", formToUse, opts);
|
||||
|
||||
const handleClaimSaved = async () => {
|
||||
const missingFields: string[] = [];
|
||||
if (!form.memberId?.trim()) missingFields.push("Member ID");
|
||||
@@ -2150,6 +2169,8 @@ export function ClaimForm({
|
||||
handleUnitedUcardClaim(pricedForm);
|
||||
} else if (key === "bcbs" || key === "bcbs_ma") {
|
||||
handleBCBSClaim(pricedForm, { auto: true });
|
||||
} else if (key === "altus") {
|
||||
handleAltusClaim(pricedForm, { auto: true });
|
||||
} else {
|
||||
handleMHSubmit(pricedForm);
|
||||
}
|
||||
@@ -2177,6 +2198,13 @@ export function ClaimForm({
|
||||
insuranceSiteKey: "bcbs",
|
||||
});
|
||||
handleBCBSClaim(pricedForm, { auto: true, preauth: true });
|
||||
} else if (key === "altus") {
|
||||
const pricedForm = mapPricesForForm({
|
||||
form: { ...form, insuranceSiteKey: "altus" },
|
||||
patientDOB: patient?.dateOfBirth ?? "",
|
||||
insuranceSiteKey: "altus",
|
||||
});
|
||||
handleAltusClaim(pricedForm, { auto: true, preauth: true });
|
||||
} else {
|
||||
// Re-apply fee-schedule prices with the now-loaded patient DOB before submitting —
|
||||
// the earlier chatbot-prefill pass may have run before `patient` finished loading,
|
||||
@@ -2829,6 +2857,13 @@ export function ClaimForm({
|
||||
>
|
||||
BCBS Claim (ADA Form)
|
||||
</Button>
|
||||
<Button
|
||||
className="w-44 bg-indigo-700 hover:bg-indigo-800 text-white"
|
||||
onClick={() => runWithPriceCheck(() => handleAltusClaim())}
|
||||
title="Save the claim and open a filled ADA paper form to print and mail to Altus Dental"
|
||||
>
|
||||
Altus Claim (ADA Form)
|
||||
</Button>
|
||||
<Button
|
||||
className="w-36 bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||
onClick={() => runWithPriceCheck(handleClaimSaved)}
|
||||
@@ -3371,6 +3406,13 @@ export function ClaimForm({
|
||||
>
|
||||
BCBS PreAuth (ADA Form)
|
||||
</Button>
|
||||
<Button
|
||||
className="w-44 bg-indigo-700 hover:bg-indigo-800 text-white"
|
||||
onClick={() => handleAltusClaim(undefined, { preauth: true })}
|
||||
title="Save the preauth and open a filled ADA form (Request for Predetermination/Preauthorization) to print and mail to Altus Dental"
|
||||
>
|
||||
Altus PreAuth (ADA Form)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -354,12 +354,16 @@ export default function ClaimViewModal({
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
{/bcbs|blue cross/i.test(claim.insuranceProvider ?? "") && (
|
||||
{/bcbs|blue cross|altus/i.test(claim.insuranceProvider ?? "") && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const tab = openPendingTab();
|
||||
showAdaClaimForm(claim.id!, tab).catch((err) =>
|
||||
showAdaClaimForm(
|
||||
claim.id!,
|
||||
tab,
|
||||
/altus/i.test(claim.insuranceProvider ?? "") ? "ALTUS" : "BCBS_MA",
|
||||
).catch((err) =>
|
||||
toast({
|
||||
title: "ADA form failed",
|
||||
description: err?.message ?? "Failed to generate ADA claim form.",
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CheckCircle, LoaderCircleIcon } 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";
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────────
|
||||
|
||||
interface AltusEligibilityButtonProps {
|
||||
memberId: string;
|
||||
dateOfBirth: Date | null;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
isFormIncomplete: boolean;
|
||||
autoTrigger?: boolean;
|
||||
onAutoTriggered?: () => void;
|
||||
onPdfReady: (pdfId: number | string, fallbackFilename: string | null) => void;
|
||||
}
|
||||
|
||||
export function AltusEligibilityButton({
|
||||
memberId,
|
||||
dateOfBirth,
|
||||
firstName,
|
||||
lastName,
|
||||
isFormIncomplete,
|
||||
autoTrigger,
|
||||
onAutoTriggered,
|
||||
onPdfReady,
|
||||
}: AltusEligibilityButtonProps) {
|
||||
const { toast } = useToast();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const autoTriggeredRef = useRef(false);
|
||||
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
|
||||
const handleStart = async () => {
|
||||
if (!dateOfBirth || !firstName?.trim() || (!memberId && !lastName?.trim())) {
|
||||
toast({
|
||||
title: "Missing fields",
|
||||
description: "Altus requires First Name, Date of Birth, and Member ID or Last Name.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const formattedDob = formatLocalDate(dateOfBirth);
|
||||
const payload = {
|
||||
memberId,
|
||||
dateOfBirth: formattedDob,
|
||||
firstName: firstName?.trim(),
|
||||
lastName: lastName?.trim(),
|
||||
insuranceSiteKey: "ALTUS",
|
||||
};
|
||||
|
||||
setIsStarting(true);
|
||||
|
||||
try {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "eligibilityCheck",
|
||||
status: "pending",
|
||||
message: "Starting Altus eligibility check…",
|
||||
})
|
||||
);
|
||||
|
||||
const response = await apiRequest(
|
||||
"POST",
|
||||
"/api/insurance-status-altus/altus-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: "Altus job queued. Logging in…",
|
||||
})
|
||||
);
|
||||
|
||||
function cleanup() {
|
||||
clearTimeout(safetyTimer);
|
||||
socket.off("job:update", onJobUpdate);
|
||||
}
|
||||
|
||||
const onJobUpdate = (data: any) => {
|
||||
if (String(data?.jobId) !== String(jobId)) return;
|
||||
|
||||
if (data.status === "active") {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "eligibilityCheck",
|
||||
status: "pending",
|
||||
message: data.message ?? "Selenium browser starting…",
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
cleanup();
|
||||
|
||||
if (data.status === "completed") {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "eligibilityCheck",
|
||||
status: "success",
|
||||
message: "Altus eligibility updated and PDF attached to patient documents.",
|
||||
})
|
||||
);
|
||||
toast({
|
||||
title: "Altus 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(pdfId, data.result?.pdfFilename ?? `eligibility_altus_${memberId || `${lastName}_${firstName}`}.pdf`);
|
||||
}
|
||||
} else if (data.status === "failed") {
|
||||
const msg = data.error ?? "Altus eligibility job failed.";
|
||||
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: msg }));
|
||||
toast({ title: "Altus selenium error", description: msg, variant: "destructive" });
|
||||
}
|
||||
|
||||
setIsStarting(false);
|
||||
};
|
||||
|
||||
socket.on("job:update", onJobUpdate);
|
||||
|
||||
const safetyTimer = setTimeout(() => {
|
||||
cleanup();
|
||||
setIsStarting(false);
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "eligibilityCheck",
|
||||
status: "error",
|
||||
message: "Altus job timed out waiting for completion.",
|
||||
})
|
||||
);
|
||||
}, 6 * 60 * 1000);
|
||||
|
||||
} catch (err: any) {
|
||||
console.error("AltusEligibilityButton error:", err);
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "eligibilityCheck",
|
||||
status: "error",
|
||||
message: err?.message || "Failed to start Altus eligibility",
|
||||
})
|
||||
);
|
||||
toast({
|
||||
title: "Altus selenium error",
|
||||
description: err?.message || "Failed to start Altus eligibility",
|
||||
variant: "destructive",
|
||||
});
|
||||
setIsStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoTrigger) { autoTriggeredRef.current = false; return; }
|
||||
if (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="default"
|
||||
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" />
|
||||
Altus
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -143,6 +143,7 @@ const INSURANCE_TYPE_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: "cca", label: "CCA" },
|
||||
{ value: "bcbs-ma", label: "BCBS MA" },
|
||||
{ value: "metlife", label: "MetLife" },
|
||||
{ value: "altus", label: "Altus" },
|
||||
];
|
||||
|
||||
function getAutoCheck(_dobISO: string): "mh" {
|
||||
|
||||
@@ -26,6 +26,7 @@ const SITE_KEY_OPTIONS = [
|
||||
{ value: "CCA", label: "CCA (CCA)" },
|
||||
{ value: "BCBS_MA", label: "BCBS MA (BCBS_MA)" },
|
||||
{ value: "METLIFE", label: "MetLife Dental (METLIFE)" },
|
||||
{ value: "ALTUS", label: "Altus Dental (ALTUS)" },
|
||||
];
|
||||
|
||||
export function CredentialForm({ onClose, userId, defaultValues }: CredentialFormProps) {
|
||||
|
||||
@@ -121,6 +121,7 @@ const PREAUTH_SITE_KEYS: Record<string, string> = {
|
||||
UNITEDSCO: "UNITED_SCO",
|
||||
BCBS: "BCBS", // paper ADA form, filled and saved to Documents > PreAuth
|
||||
BCBSMA: "BCBS",
|
||||
ALTUS: "ALTUS", // paper ADA form, like BCBS
|
||||
};
|
||||
const toPreauthSiteKey = (siteKey: string): string | undefined =>
|
||||
PREAUTH_SITE_KEYS[siteKey.replace(/_/g, "").toUpperCase()];
|
||||
@@ -1198,6 +1199,8 @@ export default function AppointmentsPage() {
|
||||
autoCheck = "cca";
|
||||
} else if (p.includes("metlife") || p.includes("met life")) {
|
||||
autoCheck = "metlife";
|
||||
} else if (p.includes("altus")) {
|
||||
autoCheck = "altus";
|
||||
}
|
||||
|
||||
if (autoCheck === "other-providers") {
|
||||
@@ -1740,8 +1743,8 @@ export default function AppointmentsPage() {
|
||||
autoSubmit: true,
|
||||
}));
|
||||
}
|
||||
// BCBS is a paper form filled and saved in the background — come straight back here
|
||||
if (/^bcbs/i.test(siteKey)) sessionStorage.setItem("ai_claim_return_to_schedule", "1");
|
||||
// BCBS / Altus are paper forms filled and saved in the background — come straight back here
|
||||
if (/^(bcbs|altus)/i.test(siteKey)) sessionStorage.setItem("ai_claim_return_to_schedule", "1");
|
||||
else sessionStorage.removeItem("ai_claim_return_to_schedule");
|
||||
setAiClaimModalOpen(false);
|
||||
setLocation(`/claims?appointmentId=${appointmentId}`);
|
||||
|
||||
@@ -39,6 +39,7 @@ import { DualPdfPreviewModal } from "@/components/insurance-status/dual-pdf-prev
|
||||
import { useLocation } from "wouter";
|
||||
import { DdmaEligibilityButton } from "@/components/insurance-status/ddma-buton-modal";
|
||||
import { MetLifeEligibilityButton } from "@/components/insurance-status/metlife-button-modal";
|
||||
import { AltusEligibilityButton } from "@/components/insurance-status/altus-button-modal";
|
||||
import { DeltaIndivEligibilityButton } from "@/components/insurance-status/deltaindiv-button-modal";
|
||||
import { DeltaInsEligibilityButton } from "@/components/insurance-status/deltains-button-modal";
|
||||
import { TuftsSCOEligibilityButton } from "@/components/insurance-status/tufts-sco-button-modal";
|
||||
@@ -119,6 +120,8 @@ export default function InsuranceStatusPage() {
|
||||
const isFormIncomplete = !memberId || !dateOfBirth;
|
||||
// United Ucard can also search by first name + last name + DOB when there's no member ID.
|
||||
const isUcardFormIncomplete = !dateOfBirth || (!memberId && !(firstName && lastName));
|
||||
// Altus searches by Member ID, or by Subscriber Last Name when the ID is unknown; First Name + DOB always required.
|
||||
const isAltusFormIncomplete = !dateOfBirth || !firstName?.trim() || (!memberId && !lastName?.trim());
|
||||
const [isCheckingEligibilityStatus, setIsCheckingEligibilityStatus] =
|
||||
useState(false);
|
||||
const [isCheckingEligibilityAppointment, setIsCheckingEligibilityAppointment] =
|
||||
@@ -176,7 +179,7 @@ export default function InsuranceStatusPage() {
|
||||
const [cmspAccumulatorPdfId, setCmspAccumulatorPdfId] = useState<number | string | null>(null);
|
||||
const [cmspAccumulatorFilename, setCmspAccumulatorFilename] = useState<string | null>(null);
|
||||
|
||||
const pendingAutoCheck = useRef<"mh" | "mh-history" | "cmsp" | "ddma" | "delta-ins" | "delta-indiv" | "united-sco" | "united-ucard" | "tufts-sco" | "cca" | "bcbs-ma" | "metlife" | null>(null);
|
||||
const pendingAutoCheck = useRef<"mh" | "mh-history" | "cmsp" | "ddma" | "delta-ins" | "delta-indiv" | "united-sco" | "united-ucard" | "tufts-sco" | "cca" | "bcbs-ma" | "metlife" | "altus" | null>(null);
|
||||
const [triggerTarget, setTriggerTarget] = useState<string | null>(null);
|
||||
const pendingScrollTo = useRef<string | null>(null);
|
||||
const [prefillTick, setPrefillTick] = useState(0);
|
||||
@@ -756,7 +759,7 @@ export default function InsuranceStatusPage() {
|
||||
// Auto-trigger after prefill (from chatbot or schedule page "Check Eligibility")
|
||||
useEffect(() => {
|
||||
if (!pendingAutoCheck.current || !dateOfBirth) return;
|
||||
if (!memberId && !(pendingAutoCheck.current === "united-ucard" && firstName && lastName)) return;
|
||||
if (!memberId && !((pendingAutoCheck.current === "united-ucard" || pendingAutoCheck.current === "altus") && firstName && lastName)) return;
|
||||
const check = pendingAutoCheck.current;
|
||||
pendingAutoCheck.current = null;
|
||||
if (check === "mh" || check === "mh-history") {
|
||||
@@ -917,7 +920,7 @@ export default function InsuranceStatusPage() {
|
||||
const autoCheck = params.get("autoCheck") as typeof pendingAutoCheck.current;
|
||||
const scrollTo = params.get("scrollTo");
|
||||
|
||||
const knownAutoChecks = ["mh", "mh-history", "cmsp", "ddma", "delta-ins", "delta-indiv", "united-sco", "united-ucard", "tufts-sco", "cca", "bcbs-ma", "metlife"] as const;
|
||||
const knownAutoChecks = ["mh", "mh-history", "cmsp", "ddma", "delta-ins", "delta-indiv", "united-sco", "united-ucard", "tufts-sco", "cca", "bcbs-ma", "metlife", "altus"] as const;
|
||||
if (autoCheck && (knownAutoChecks as readonly string[]).includes(autoCheck)) {
|
||||
pendingAutoCheck.current = autoCheck;
|
||||
} else if (scrollTo) {
|
||||
@@ -1010,7 +1013,7 @@ export default function InsuranceStatusPage() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="firstName">First Name <span className="text-muted-foreground font-normal">(Optional)</span></Label>
|
||||
<Label htmlFor="firstName">First Name <span className="text-muted-foreground font-normal">(required by Altus)</span></Label>
|
||||
<Input
|
||||
id="firstName"
|
||||
placeholder="Enter first name"
|
||||
@@ -1266,14 +1269,28 @@ export default function InsuranceStatusPage() {
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Aetna
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
disabled={isFormIncomplete}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Altus
|
||||
</Button>
|
||||
<div title={!isLicensed ? "License required" : undefined} className={!isLicensed ? "opacity-40 pointer-events-none" : ""}>
|
||||
<AltusEligibilityButton
|
||||
memberId={memberId}
|
||||
dateOfBirth={dateOfBirth}
|
||||
firstName={firstName}
|
||||
lastName={lastName}
|
||||
isFormIncomplete={isAltusFormIncomplete}
|
||||
autoTrigger={triggerTarget === "altus"}
|
||||
onAutoTriggered={() => setTriggerTarget(null)}
|
||||
onPdfReady={async (pdfId, fallbackFilename) => {
|
||||
await queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
|
||||
const claimed = await tryClaimFromChatbot(selectedPatient?.id);
|
||||
void tryAppointmentFromChatbot();
|
||||
if (!claimed) {
|
||||
setPreviewPdfId(pdfId);
|
||||
setPreviewFallbackFilename(fallbackFilename ?? `eligibility_altus_${memberId}.pdf`);
|
||||
setPreviewOpen(true);
|
||||
}
|
||||
processNextInQueue();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div title={!isLicensed ? "License required" : undefined} className={!isLicensed ? "opacity-40 pointer-events-none" : ""}>
|
||||
<MetLifeEligibilityButton
|
||||
memberId={memberId}
|
||||
|
||||
@@ -8,6 +8,7 @@ import rawUnitedUcardCodeTable from "@/assets/data/procedureCodesUnitedUcard.jso
|
||||
import rawTuftsSCOCodeTable from "@/assets/data/procedureCodesTuftsSCO.json";
|
||||
import rawDeltainsCodeTable from "@/assets/data/procedureCodesDeltains.json";
|
||||
import rawBCBSCodeTable from "@/assets/data/procedureCodesBCBS.json";
|
||||
import rawAltusCodeTable from "@/assets/data/procedureCodesAltus.json";
|
||||
import { PROCEDURE_COMBOS } from "./procedureCombos";
|
||||
|
||||
/* ----------------------------- Types ----------------------------- */
|
||||
@@ -27,6 +28,7 @@ const UNITEDUCARD_CODE_TABLE = rawUnitedUcardCodeTable as CodeRow[];
|
||||
const TUFTSSCO_CODE_TABLE = rawTuftsSCOCodeTable as CodeRow[];
|
||||
const DELTAINS_CODE_TABLE = rawDeltainsCodeTable as CodeRow[];
|
||||
const BCBS_CODE_TABLE = rawBCBSCodeTable as CodeRow[];
|
||||
const ALTUS_CODE_TABLE = rawAltusCodeTable as CodeRow[];
|
||||
|
||||
export type ClaimFormLike = {
|
||||
serviceDate: string; // form-level service date
|
||||
@@ -87,6 +89,15 @@ const BCBS_CODE_MAP: Map<string, CodeRow> = (() => {
|
||||
return m;
|
||||
})();
|
||||
|
||||
const ALTUS_CODE_MAP: Map<string, CodeRow> = (() => {
|
||||
const m = new Map<string, CodeRow>();
|
||||
for (const r of ALTUS_CODE_TABLE) {
|
||||
const k = normalizeCode(String(r["Procedure Code"] || ""));
|
||||
if (k && !m.has(k)) m.set(k, r);
|
||||
}
|
||||
return m;
|
||||
})();
|
||||
|
||||
const UNITEDDH_CODE_MAP: Map<string, CodeRow> = (() => {
|
||||
const m = new Map<string, CodeRow>();
|
||||
for (const r of UNITEDDH_CODE_TABLE) {
|
||||
@@ -129,6 +140,7 @@ function getCodeMap(insuranceSiteKey?: string): Map<string, CodeRow> {
|
||||
if (k === "cca") return CCA_CODE_MAP;
|
||||
if (k === "ddma") return DDMA_CODE_MAP;
|
||||
if (k === "bcbs" || k === "bcbsma") return BCBS_CODE_MAP;
|
||||
if (k === "altus") return ALTUS_CODE_MAP;
|
||||
if (k === "deltains") return DELTAINS_CODE_MAP;
|
||||
if (k === "unitedsco" || k === "uniteddh" || k === "dentalhub") return UNITEDDH_CODE_MAP;
|
||||
if (k === "uniteducard" || k === "ucard") return UNITEDUCARD_CODE_MAP;
|
||||
|
||||
@@ -12,6 +12,7 @@ from selenium_claimStatusCheckWorker import AutomationMassHealthClaimStatusCheck
|
||||
from selenium_preAuthWorker import AutomationMassHealthPreAuth
|
||||
from selenium_MHSinglePaymentCheckWorker import AutomationMassHealthSinglePaymentCheck
|
||||
from selenium_MHBatchPaymentCheckWorker import AutomationMassHealthBatchPaymentCheck
|
||||
from selenium_Altus_eligibilityCheckWorker import AutomationAltusEligibilityCheck
|
||||
import os
|
||||
import time
|
||||
import helpers_ddma_eligibility as hddma
|
||||
@@ -195,6 +196,42 @@ async def mh_eligibility_history_check(request: Request):
|
||||
async with lock:
|
||||
active_jobs -= 1
|
||||
|
||||
# Endpoint: 2c — Altus Dental eligibility (no OTP, fresh browser per run)
|
||||
@app.post("/altus-eligibility-check")
|
||||
async def altus_eligibility_check(request: Request):
|
||||
global active_jobs, waiting_jobs
|
||||
data = await request.json()
|
||||
|
||||
async with lock:
|
||||
waiting_jobs += 1
|
||||
|
||||
async with semaphore:
|
||||
async with lock:
|
||||
waiting_jobs -= 1
|
||||
active_jobs += 1
|
||||
try:
|
||||
bot = AutomationAltusEligibilityCheck(data)
|
||||
# Selenium is blocking — run off the event loop so /status etc. stay responsive
|
||||
result = await asyncio.to_thread(
|
||||
bot.main_workflow, "https://www.altusdental.com/Provider/Login?timedOut=False"
|
||||
)
|
||||
|
||||
if result.get("status") == "error":
|
||||
return {"status": "error", "message": result.get("message")}
|
||||
|
||||
if result.get("pdf_path"):
|
||||
filename = os.path.basename(result["pdf_path"])
|
||||
port = os.getenv("PORT", "5002")
|
||||
url_host = os.getenv("HOST", "localhost")
|
||||
result["pdf_url"] = f"http://{url_host}:{port}/downloads/{filename}"
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
finally:
|
||||
async with lock:
|
||||
active_jobs -= 1
|
||||
|
||||
# Endpoint: 2b — CMSP Eligibility + Service History + Accumulator (Remaining)
|
||||
@app.post("/cmsp-eligibility-history-remaining-check")
|
||||
async def cmsp_eligibility_history_remaining_check(request: Request):
|
||||
|
||||
@@ -0,0 +1,562 @@
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.service import Service
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
from webdriver_manager.chrome import ChromeDriverManager
|
||||
import time
|
||||
import os
|
||||
import re
|
||||
import base64
|
||||
from datetime import date, datetime
|
||||
|
||||
# Case-insensitive XPath text match helper
|
||||
_LOWER = "translate({0}, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')"
|
||||
|
||||
|
||||
def _ci_contains(expr, needle):
|
||||
return f"contains({_LOWER.format(expr)}, '{needle.lower()}')"
|
||||
|
||||
|
||||
class AutomationAltusEligibilityCheck:
|
||||
"""
|
||||
Altus Dental provider portal eligibility check.
|
||||
Plain username/password login (no OTP) — fresh browser per run, like MH.
|
||||
"""
|
||||
|
||||
def __init__(self, data):
|
||||
self.headless = False
|
||||
self.driver = None
|
||||
self.extracted_data = {}
|
||||
|
||||
self.data = data.get("data")
|
||||
|
||||
self.altus_username = self.data.get("altusUsername", "")
|
||||
self.altus_password = self.data.get("altusPassword", "")
|
||||
self.dateOfBirth = self.data.get("dateOfBirth", "")
|
||||
self.memberId = self.data.get("memberId", "")
|
||||
self.firstName = self.data.get("firstName", "") or ""
|
||||
self.lastName = self.data.get("lastName", "") or ""
|
||||
|
||||
# Convert dateOfBirth from YYYY-MM-DD to MM/DD/YYYY
|
||||
if self.dateOfBirth and "-" in self.dateOfBirth:
|
||||
parts = self.dateOfBirth.split("-")
|
||||
if len(parts) == 3:
|
||||
year, month, day = parts
|
||||
self.dateOfBirth = f"{month.zfill(2)}/{day.zfill(2)}/{year}"
|
||||
|
||||
self.download_dir = os.path.abspath("downloads")
|
||||
os.makedirs(self.download_dir, exist_ok=True)
|
||||
|
||||
# ── driver ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def config_driver(self):
|
||||
options = webdriver.ChromeOptions()
|
||||
if self.headless:
|
||||
options.add_argument("--headless")
|
||||
|
||||
prefs = {
|
||||
"download.default_directory": self.download_dir,
|
||||
"plugins.always_open_pdf_externally": False,
|
||||
"download.prompt_for_download": False,
|
||||
"download.directory_upgrade": True,
|
||||
}
|
||||
options.add_experimental_option("prefs", prefs)
|
||||
|
||||
s = Service(ChromeDriverManager().install())
|
||||
self.driver = webdriver.Chrome(service=s, options=options)
|
||||
|
||||
# ── debug ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _dump_debug(self, tag):
|
||||
"""Save page HTML + screenshot so selectors can be tuned after a failure."""
|
||||
try:
|
||||
base = os.path.join(self.download_dir, f"altus_debug_{tag}")
|
||||
with open(base + ".html", "w", encoding="utf-8") as f:
|
||||
f.write(self.driver.page_source)
|
||||
self.driver.save_screenshot(base + ".png")
|
||||
print(f"[debug] saved {base}.html / .png URL={self.driver.current_url}")
|
||||
except Exception as e:
|
||||
print(f"[debug] dump failed: {e}")
|
||||
|
||||
def _visible(self, xpath):
|
||||
for el in self.driver.find_elements(By.XPATH, xpath):
|
||||
try:
|
||||
if el.is_displayed():
|
||||
return el
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
def _type(self, el, value):
|
||||
el.click()
|
||||
el.send_keys(Keys.CONTROL, "a")
|
||||
el.send_keys(Keys.DELETE)
|
||||
el.send_keys(value)
|
||||
|
||||
# ── login ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def login(self):
|
||||
wait = WebDriverWait(self.driver, 30)
|
||||
if not self.altus_username or not self.altus_password:
|
||||
return "ERROR: Altus username/password missing — update them in Settings (ALTUS)."
|
||||
try:
|
||||
username_field = wait.until(EC.element_to_be_clickable((By.ID, "Username")))
|
||||
password_field = wait.until(EC.element_to_be_clickable((By.ID, "Password")))
|
||||
|
||||
# Fields use their value as a placeholder ("Username"/"Password"), swapped
|
||||
# by onfocus/onblur, and jQuery validate rejects empty or placeholder
|
||||
# values. Type into each field, then verify; fall back to setting the
|
||||
# value via JS if the onfocus/onblur handlers interfered.
|
||||
self._type(username_field, self.altus_username)
|
||||
self._type(password_field, self.altus_password)
|
||||
|
||||
for el_id, expected in (("Username", self.altus_username), ("Password", self.altus_password)):
|
||||
actual = self.driver.execute_script(f"return document.getElementById('{el_id}').value;")
|
||||
if actual != expected:
|
||||
print(f"[login] {el_id} value mismatch after typing — setting via JS")
|
||||
self.driver.execute_script(
|
||||
"var el = document.getElementById(arguments[0]); el.value = arguments[1];"
|
||||
"el.dispatchEvent(new Event('input', {bubbles: true}));"
|
||||
"el.dispatchEvent(new Event('change', {bubbles: true}));",
|
||||
el_id, expected,
|
||||
)
|
||||
|
||||
# Click "Log In" (<a class="smallButton" onclick="SubmitForm()">)
|
||||
try:
|
||||
login_link = self.driver.find_element(
|
||||
By.XPATH, "//form[@id='LoginForm']//a[contains(@class,'smallButton') and normalize-space()='Log In']"
|
||||
)
|
||||
self.driver.execute_script("arguments[0].click();", login_link)
|
||||
except Exception:
|
||||
self.driver.execute_script("SubmitForm();")
|
||||
|
||||
time.sleep(1)
|
||||
if "/provider/login" in self.driver.current_url.lower():
|
||||
invalid = self.driver.find_elements(By.CSS_SELECTOR, "#LoginForm input.error[aria-invalid='true']")
|
||||
if invalid:
|
||||
ids = [el.get_attribute("id") for el in invalid]
|
||||
self._dump_debug("login_validation")
|
||||
return f"ERROR: Altus login form rejected {', '.join(ids)} (empty or placeholder value)."
|
||||
|
||||
# Wait to leave the login page, or for a visible validation error
|
||||
deadline = time.time() + 30
|
||||
while time.time() < deadline:
|
||||
time.sleep(0.5)
|
||||
url = self.driver.current_url.lower()
|
||||
if "/provider/login" not in url and "/authorization/login" not in url:
|
||||
break
|
||||
err = self._visible("//div[contains(@class,'formValidationError')]")
|
||||
if err and err.text.strip():
|
||||
print(f"[login] portal error: {err.text.strip()}")
|
||||
return f"ERROR: Altus login failed — {err.text.strip()}"
|
||||
else:
|
||||
self._dump_debug("login")
|
||||
return "ERROR: Altus login timed out — check credentials or portal availability."
|
||||
|
||||
print(f"[login] logged in, URL: {self.driver.current_url}")
|
||||
self._accept_agreement_if_shown()
|
||||
return "Success"
|
||||
except Exception as e:
|
||||
print(f"[login] Error: {e}")
|
||||
self._dump_debug("login")
|
||||
return "ERROR:LOGIN FAILED"
|
||||
|
||||
def _accept_agreement_if_shown(self):
|
||||
"""The login form has a ShowOnlineAccessAgreement flag — accept it if it appears."""
|
||||
time.sleep(2)
|
||||
btn = self._visible(
|
||||
"//*[self::a or self::button or self::input[@type='submit' or @type='button']]"
|
||||
f"[{_ci_contains('normalize-space(.)', 'accept')} or {_ci_contains('@value', 'accept')}"
|
||||
f" or {_ci_contains('normalize-space(.)', 'i agree')} or {_ci_contains('@value', 'agree')}]"
|
||||
)
|
||||
if btn:
|
||||
print("[login] accepting online access agreement")
|
||||
self.driver.execute_script("arguments[0].click();", btn)
|
||||
time.sleep(2)
|
||||
|
||||
# ── step 1 — open eligibility search, search member ─────────────────────────
|
||||
|
||||
def _fill_verified(self, el, value, digits_fallback=False):
|
||||
"""Type into a field and confirm it took; retry digits-only (masked inputs) then JS."""
|
||||
self._type(el, value)
|
||||
if (el.get_attribute("value") or "") == value:
|
||||
return
|
||||
if digits_fallback:
|
||||
digits = "".join(c for c in value if c.isdigit())
|
||||
self._type(el, digits)
|
||||
if (el.get_attribute("value") or "") in (value, digits):
|
||||
return
|
||||
print(f"[step1] {el.get_attribute('id')} value mismatch — setting via JS")
|
||||
self.driver.execute_script(
|
||||
"arguments[0].value = arguments[1];"
|
||||
"arguments[0].dispatchEvent(new Event('change', {bubbles: true}));",
|
||||
el, value,
|
||||
)
|
||||
|
||||
def step1(self):
|
||||
wait = WebDriverWait(self.driver, 30)
|
||||
substep = "init"
|
||||
try:
|
||||
# Left menu on Dentist Home: "Benefits & Claims Information" → /Provider/SearchBenefits
|
||||
substep = "benefits_claims_link"
|
||||
try:
|
||||
link = WebDriverWait(self.driver, 15).until(
|
||||
EC.element_to_be_clickable((By.CSS_SELECTOR, "a[href*='/Provider/SearchBenefits']"))
|
||||
)
|
||||
print(f"[step1] clicking '{link.text.strip()}'")
|
||||
self.driver.execute_script("arguments[0].click();", link)
|
||||
except Exception:
|
||||
print("[step1] Benefits & Claims link not found — navigating directly")
|
||||
self.driver.get("https://www.altusdental.com/Provider/SearchBenefits")
|
||||
|
||||
wait.until(lambda d: "/provider/searchbenefits" in d.current_url.lower())
|
||||
wait.until(lambda d: d.execute_script("return document.readyState") == "complete")
|
||||
time.sleep(2)
|
||||
print(f"[step1] URL: {self.driver.current_url}")
|
||||
|
||||
# Patient Lookup: Subscriber ID + Patient First Name + Patient D.O.B.
|
||||
# Without a Subscriber ID, "Click Here" switches the first box to Subscriber Last Name.
|
||||
if self.memberId:
|
||||
substep = "subscriber_id_input"
|
||||
subscriber_input = wait.until(EC.element_to_be_clickable((By.ID, "SubscriberId")))
|
||||
self._fill_verified(subscriber_input, self.memberId)
|
||||
else:
|
||||
substep = "alternate_search"
|
||||
alt_btn = wait.until(EC.element_to_be_clickable((By.ID, "alternateSearch")))
|
||||
alt_btn.click() # onclick="return ShowAlternateSearch();"
|
||||
substep = "last_name_input"
|
||||
last_input = wait.until(EC.element_to_be_clickable((By.ID, "LastName")))
|
||||
self._fill_verified(last_input, self.lastName)
|
||||
|
||||
substep = "first_name_input"
|
||||
self._fill_verified(self.driver.find_element(By.ID, "FirstName"), self.firstName)
|
||||
|
||||
substep = "dob_input"
|
||||
dob_input = self.driver.find_element(By.ID, "DateOfBirth")
|
||||
self._fill_verified(dob_input, self.dateOfBirth, digits_fallback=True)
|
||||
|
||||
substep = "submit_button"
|
||||
submit_btn = self.driver.find_element(By.ID, "submitPatientLookup")
|
||||
# Native click so the button's onclick="return ProcessPatientSearch();" runs
|
||||
submit_btn.click()
|
||||
|
||||
# ProcessPatientSearch() may reject input with a JS alert
|
||||
try:
|
||||
alert = WebDriverWait(self.driver, 2).until(EC.alert_is_present())
|
||||
alert_text = alert.text
|
||||
alert.accept()
|
||||
print(f"[step1] portal alert: {alert_text}")
|
||||
return f"ERROR: Altus search rejected — {alert_text}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Results: patient list table (holds up to 20 patients from this session's searches)
|
||||
substep = "wait_results"
|
||||
row = WebDriverWait(self.driver, 30).until(lambda d: self._find_result_row() or self._not_found())
|
||||
if row is True:
|
||||
self.extracted_data = {"eligibility": None, "notFound": True}
|
||||
return "Success"
|
||||
|
||||
self.extracted_data = self._extract_row_data(row)
|
||||
print(f"[step1] row data: {self.extracted_data}")
|
||||
|
||||
# View Details → <a class="smallButton" href="javascript:ViewPatientBenefits(...)">
|
||||
substep = "view_details"
|
||||
details_link = row.find_element(By.XPATH, ".//a[contains(@href,'ViewPatientBenefits')]")
|
||||
list_url = self.driver.current_url
|
||||
handles_before = set(self.driver.window_handles)
|
||||
self.driver.execute_script("arguments[0].click();", details_link)
|
||||
|
||||
try:
|
||||
WebDriverWait(self.driver, 30).until(
|
||||
lambda d: len(d.window_handles) > len(handles_before)
|
||||
or d.current_url != list_url
|
||||
or not self._is_element_attached(details_link)
|
||||
)
|
||||
except Exception:
|
||||
# Details may render in place without a navigation — print whatever is shown
|
||||
print("[step1] no navigation detected after View Details — continuing")
|
||||
self._dump_debug("view_details")
|
||||
new_handles = [h for h in self.driver.window_handles if h not in handles_before]
|
||||
if new_handles:
|
||||
self.driver.switch_to.window(new_handles[0])
|
||||
WebDriverWait(self.driver, 30).until(
|
||||
lambda d: d.execute_script("return document.readyState") == "complete"
|
||||
)
|
||||
time.sleep(2)
|
||||
print(f"[step1] benefit details URL: {self.driver.current_url}")
|
||||
|
||||
# The results row only has First Name — read the last name from the details page
|
||||
last_name = self._extract_last_name()
|
||||
if last_name:
|
||||
self.extracted_data["lastName"] = last_name
|
||||
|
||||
# Fill in plan name from the details page if it shows one
|
||||
if not self.extracted_data.get("group"):
|
||||
self.extracted_data["group"] = self._value_after_label(["Group Number", "Group #", "Group"])
|
||||
plan_name = self._value_after_label(["Plan Name", "Group Name"])
|
||||
if plan_name:
|
||||
self.extracted_data["insurance"] = f"Altus Dental {plan_name}"
|
||||
return "Success"
|
||||
except Exception as e:
|
||||
print(f"[step1] FAILED at substep='{substep}': {e}")
|
||||
print(f"[step1] URL at failure: {self.driver.current_url}")
|
||||
self._dump_debug(f"step1_{substep}")
|
||||
return f"ERROR:STEP1:{substep}"
|
||||
|
||||
# ── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
_NOT_FOUND = ["no records found", "no member found", "no patients found", "not found",
|
||||
"no results", "unable to locate", "does not match", "could not be found"]
|
||||
|
||||
def _page_text(self):
|
||||
try:
|
||||
return (self.driver.execute_script("return document.body.innerText;") or "").lower()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def _not_found(self):
|
||||
text = self._page_text()
|
||||
return any(p in text for p in self._NOT_FOUND)
|
||||
|
||||
def _is_element_attached(self, el):
|
||||
try:
|
||||
el.is_enabled()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _normalize_id(self, s):
|
||||
# Subscriber IDs are shown zero-padded (0001249957) — compare without leading zeros
|
||||
return "".join(c for c in str(s) if c.isalnum()).lower().lstrip("0")
|
||||
|
||||
_DATE_RE = re.compile(r"^\d{2}/\d{2}/\d{4}$")
|
||||
|
||||
def _row_cells(self, row):
|
||||
return [(td.text or "").strip() for td in row.find_elements(By.TAG_NAME, "td")]
|
||||
|
||||
def _column_value(self, row, header):
|
||||
"""Cell under the results-table column titled `header` (e.g. "Group")."""
|
||||
try:
|
||||
table = row.find_element(By.XPATH, "./ancestor::table[1]")
|
||||
headers = table.find_elements(By.XPATH, ".//th")
|
||||
if not headers:
|
||||
headers = table.find_elements(By.XPATH, ".//tr[1]/td")
|
||||
names = [(h.text or "").strip().lower() for h in headers]
|
||||
if header.lower() not in names:
|
||||
return ""
|
||||
cells = self._row_cells(row)
|
||||
if len(cells) != len(names):
|
||||
return "" # column layout doesn't line up — let the caller fall back
|
||||
return cells[names.index(header.lower())]
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def _find_result_row(self):
|
||||
"""Row whose Subscriber ID (if searched), First Name and D.O.B. match this patient."""
|
||||
rows = self.driver.find_elements(
|
||||
By.XPATH, "//tr[.//a[contains(@href,'ViewPatientBenefits')]]"
|
||||
)
|
||||
first = self.firstName.strip().lower()
|
||||
for row in rows:
|
||||
try:
|
||||
cells = self._row_cells(row)
|
||||
except Exception:
|
||||
continue
|
||||
lowered = [c.lower() for c in cells]
|
||||
if self.dateOfBirth not in cells or first not in lowered:
|
||||
continue
|
||||
if self.memberId:
|
||||
href = row.find_element(By.XPATH, ".//a[contains(@href,'ViewPatientBenefits')]").get_attribute("href") or ""
|
||||
ids = [self._normalize_id(c) for c in cells] + [self._normalize_id(x) for x in re.findall(r"'(\w+)'", href)]
|
||||
if self._normalize_id(self.memberId) not in ids:
|
||||
continue
|
||||
return row
|
||||
return None
|
||||
|
||||
def _parse_date(self, s):
|
||||
try:
|
||||
return datetime.strptime(s.strip(), "%m/%d/%Y").date()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _extract_row_data(self, row):
|
||||
"""Row columns: Subscriber ID | First Name | Group | D.O.B. | Coverage | View Details."""
|
||||
cells = self._row_cells(row)
|
||||
coverage = next((c for c in cells if " - " in c), "")
|
||||
subscriber_id = next((c for c in cells if re.fullmatch(r"\d{6,}", c)), "")
|
||||
# Group number (saved to the patient's Group Number — used on ADA claim box 16)
|
||||
group = self._column_value(row, "Group") or next(
|
||||
(c for c in cells if re.fullmatch(r"\d+-\d+", c)), ""
|
||||
)
|
||||
first = next((c for c in cells if c.lower() == self.firstName.strip().lower()), self.firstName)
|
||||
|
||||
# Coverage "MM/DD/YYYY - Present" or "MM/DD/YYYY - MM/DD/YYYY": active if today is inside it.
|
||||
eligibility = None
|
||||
if coverage:
|
||||
start_s, end_s = [p.strip() for p in coverage.split(" - ", 1)]
|
||||
start = self._parse_date(start_s)
|
||||
end = None if end_s.lower() == "present" else self._parse_date(end_s)
|
||||
today = date.today()
|
||||
if start:
|
||||
eligibility = "Y" if start <= today and (end is None or today <= end) else "N"
|
||||
|
||||
# The (i) icon marks inactive coverage — trust it over the date range
|
||||
try:
|
||||
row_html = (row.get_attribute("innerHTML") or "").lower()
|
||||
if "inactive" in row_html:
|
||||
eligibility = "N"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print(f"[extraction] subscriber='{subscriber_id}' first='{first}' group='{group}' coverage='{coverage}' → {eligibility}")
|
||||
return {
|
||||
"eligibility": eligibility,
|
||||
"memberId": subscriber_id,
|
||||
# First name: always the one typed in the app (the row shows it upper-cased)
|
||||
"firstName": self.firstName.strip(),
|
||||
"coverage": coverage,
|
||||
"group": group,
|
||||
"insurance": "Altus Dental",
|
||||
}
|
||||
|
||||
_NAME_LABELS = ["Patient Name", "Member Name", "Patient", "Member", "Name", "Subscriber Name", "Subscriber"]
|
||||
|
||||
def _split_last_name(self, full_name):
|
||||
"""Last name from 'LAST, FIRST M' or 'FIRST M LAST' — only if it contains our first name."""
|
||||
first = self.firstName.strip().lower()
|
||||
full_name = re.sub(r"\s+", " ", full_name or "").strip()
|
||||
if not first or first not in full_name.lower():
|
||||
return ""
|
||||
if "," in full_name:
|
||||
last, rest = [p.strip() for p in full_name.split(",", 1)]
|
||||
return last if first in rest.lower() else ""
|
||||
tokens = full_name.split(" ")
|
||||
lowered = [t.lower() for t in tokens]
|
||||
if first not in lowered:
|
||||
return ""
|
||||
i = lowered.index(first)
|
||||
after = [t for t in tokens[i + 1:] if len(t.strip(".")) > 1] # skip middle initials
|
||||
if after:
|
||||
return " ".join(after)
|
||||
before = [t for t in tokens[:i] if len(t.strip(".")) > 1]
|
||||
return " ".join(before)
|
||||
|
||||
def _extract_last_name(self):
|
||||
"""Find the patient's full name on the benefit details page and return the last name."""
|
||||
first = self.firstName.strip()
|
||||
if not first:
|
||||
return ""
|
||||
candidates = []
|
||||
# 1) Value next to an exact name label (exact match so headers like
|
||||
# "Patient First Name" are never mistaken for a value)
|
||||
for label in self._NAME_LABELS:
|
||||
for lab in (label, label + ":"):
|
||||
xp = (
|
||||
f"//*[self::td or self::th or self::dt or self::label or self::span or self::strong or self::b]"
|
||||
f"[normalize-space(.)='{lab}']"
|
||||
"/following-sibling::*[1]"
|
||||
)
|
||||
for el in self.driver.find_elements(By.XPATH, xp):
|
||||
try:
|
||||
if el.is_displayed() and el.text.strip():
|
||||
candidates.append(el.text.strip())
|
||||
except Exception:
|
||||
continue
|
||||
# 2) Any line of page text containing the first name next to another word
|
||||
text = self.driver.execute_script("return document.body.innerText;") or ""
|
||||
pat = re.compile(
|
||||
rf"([A-Za-z'\-]+,[ ]*{re.escape(first)}(?:[ ]+[A-Za-z]\.?)?\b"
|
||||
rf"|\b{re.escape(first)}[ ]+(?:[A-Za-z]\.?[ ]+)?[A-Za-z'\-]{{2,}}(?:[ ]+[A-Za-z'\-]{{2,}})?)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
candidates += [m.group(0) for m in pat.finditer(text)]
|
||||
|
||||
for cand in candidates:
|
||||
last = self._split_last_name(cand)
|
||||
# Reject label words that can sit next to a name in page text
|
||||
if last and last.lower() not in {"dob", "d.o.b.", "id", "subscriber", "group", "patient", "member"}:
|
||||
last = last.title() if last.isupper() else last
|
||||
print(f"[extraction] last name '{last}' from '{cand}'")
|
||||
return last
|
||||
print("[extraction] last name not found on details page")
|
||||
return ""
|
||||
|
||||
def _value_after_label(self, labels):
|
||||
"""Read the value shown next to a label (table cell, dt/dd, or 'Label: value')."""
|
||||
for label in labels:
|
||||
xps = [
|
||||
f"//td[{_ci_contains('normalize-space(.)', label)}]/following-sibling::td[1]",
|
||||
f"//th[{_ci_contains('normalize-space(.)', label)}]/following-sibling::td[1]",
|
||||
f"//dt[{_ci_contains('normalize-space(.)', label)}]/following-sibling::dd[1]",
|
||||
f"//label[{_ci_contains('normalize-space(.)', label)}]/following-sibling::*[1]",
|
||||
f"//span[{_ci_contains('normalize-space(.)', label)}]/following-sibling::span[1]",
|
||||
]
|
||||
for xp in xps:
|
||||
el = self._visible(xp)
|
||||
if el and el.text.strip():
|
||||
return el.text.strip()
|
||||
return ""
|
||||
|
||||
# ── step 2 — print results page as PDF via CDP ──────────────────────────────
|
||||
|
||||
def step2_eligibility_pdf(self):
|
||||
try:
|
||||
WebDriverWait(self.driver, 30).until(
|
||||
lambda d: d.execute_script("return document.readyState") == "complete"
|
||||
)
|
||||
time.sleep(1)
|
||||
key = self.memberId or f"{self.lastName}_{self.firstName}"
|
||||
safe_member = "".join(c for c in str(key) if c.isalnum() or c in "-_.")
|
||||
pdf_filename = f"eligibility_altus_{safe_member}.pdf"
|
||||
pdf_data = self.driver.execute_cdp_cmd("Page.printToPDF", {"printBackground": True})
|
||||
pdf_path = os.path.join(self.download_dir, pdf_filename)
|
||||
with open(pdf_path, "wb") as f:
|
||||
f.write(base64.b64decode(pdf_data["data"]))
|
||||
print("Eligibility PDF saved at:", pdf_path)
|
||||
return pdf_path
|
||||
except Exception as e:
|
||||
print(f"[step2_eligibility_pdf] failed: {e}")
|
||||
return None
|
||||
|
||||
# ── main workflow ────────────────────────────────────────────────────────────
|
||||
|
||||
def main_workflow(self, url):
|
||||
if not (self.firstName and self.dateOfBirth and (self.memberId or self.lastName)):
|
||||
return {"status": "error", "message": "Altus requires First Name, Date of Birth, and Member ID or Last Name."}
|
||||
try:
|
||||
self.config_driver()
|
||||
self.driver.maximize_window()
|
||||
self.driver.get(url)
|
||||
time.sleep(3)
|
||||
|
||||
login_result = self.login()
|
||||
if login_result.startswith("ERROR"):
|
||||
return {"status": "error", "message": login_result}
|
||||
|
||||
step1_result = self.step1()
|
||||
if step1_result.startswith("ERROR"):
|
||||
return {"status": "error", "message": step1_result}
|
||||
|
||||
if self.extracted_data.get("notFound"):
|
||||
return {"status": "error", "message": "Altus: member not found — check Member ID / Last Name, First Name and DOB."}
|
||||
|
||||
pdf_path = self.step2_eligibility_pdf()
|
||||
|
||||
result = {
|
||||
"status": "success",
|
||||
"pdf_path": pdf_path,
|
||||
"file_type": "pdf",
|
||||
"message": "Altus eligibility PDF captured",
|
||||
}
|
||||
if self.extracted_data:
|
||||
result.update(self.extracted_data)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
finally:
|
||||
if self.driver:
|
||||
self.driver.quit()
|
||||
Reference in New Issue
Block a user