diff --git a/apps/Backend/src/ai/internal-chat-workflow.ts b/apps/Backend/src/ai/internal-chat-workflow.ts index 0f76a899..7de7495e 100644 --- a/apps/Backend/src/ai/internal-chat-workflow.ts +++ b/apps/Backend/src/ai/internal-chat-workflow.ts @@ -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) { diff --git a/apps/Backend/src/data/insuranceAliases.json b/apps/Backend/src/data/insuranceAliases.json index 248e4eee..5f02ea5b 100644 --- a/apps/Backend/src/data/insuranceAliases.json +++ b/apps/Backend/src/data/insuranceAliases.json @@ -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" } ] diff --git a/apps/Backend/src/queue/jobRunner.ts b/apps/Backend/src/queue/jobRunner.ts index 549e7960..0af9266a 100644 --- a/apps/Backend/src/queue/jobRunner.ts +++ b/apps/Backend/src/queue/jobRunner.ts @@ -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}`); }); diff --git a/apps/Backend/src/queue/processors/altusEligibilityProcessor.ts b/apps/Backend/src/queue/processors/altusEligibilityProcessor.ts new file mode 100644 index 00000000..9dd084bf --- /dev/null +++ b/apps/Backend/src/queue/processors/altusEligibilityProcessor.ts @@ -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 { + 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; +} diff --git a/apps/Backend/src/queue/queues.ts b/apps/Backend/src/queue/queues.ts index 20f5caa7..26339a0a 100644 --- a/apps/Backend/src/queue/queues.ts +++ b/apps/Backend/src/queue/queues.ts @@ -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; diff --git a/apps/Backend/src/routes/claims.ts b/apps/Backend/src/routes/claims.ts index 9954a2fd..cc78b11d 100755 --- a/apps/Backend/src/routes/claims.ts +++ b/apps/Backend/src/routes/claims.ts @@ -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 { 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 { 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 = diff --git a/apps/Backend/src/routes/feeSchedule.ts b/apps/Backend/src/routes/feeSchedule.ts index 21196b33..556c33d1 100644 --- a/apps/Backend/src/routes/feeSchedule.ts +++ b/apps/Backend/src/routes/feeSchedule.ts @@ -11,6 +11,7 @@ const SCHEDULE_FILES: Record = { DDMA: "procedureCodesDDMA.json", BCBS: "procedureCodesBCBS.json", BCBS_MA: "procedureCodesBCBS.json", + ALTUS: "procedureCodesAltus.json", TUFTSSCO: "procedureCodesTuftsSCO.json", TUFTS_SCO: "procedureCodesTuftsSCO.json", UNITEDDH: "procedureCodesUnitedDH.json", diff --git a/apps/Backend/src/routes/index.ts b/apps/Backend/src/routes/index.ts index 66a35266..90ddca49 100755 --- a/apps/Backend/src/routes/index.ts +++ b/apps/Backend/src/routes/index.ts @@ -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); diff --git a/apps/Backend/src/routes/insuranceStatusAltus.ts b/apps/Backend/src/routes/insuranceStatusAltus.ts new file mode 100644 index 00000000..f2ccf0a5 --- /dev/null +++ b/apps/Backend/src/routes/insuranceStatusAltus.ts @@ -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 => { + 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; diff --git a/apps/Backend/src/services/adaClaimForm.ts b/apps/Backend/src/services/adaClaimForm.ts index 852eb62f..4350aef6 100644 --- a/apps/Backend/src/services/adaClaimForm.ts +++ b/apps/Backend/src/services/adaClaimForm.ts @@ -28,8 +28,18 @@ export const ADA_PAYERS: Record = { 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; diff --git a/apps/Frontend/src/assets/data/procedureCodesAltus.json b/apps/Frontend/src/assets/data/procedureCodesAltus.json new file mode 100644 index 00000000..247c4b2b --- /dev/null +++ b/apps/Frontend/src/assets/data/procedureCodesAltus.json @@ -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" } +] diff --git a/apps/Frontend/src/components/claims/claim-form.tsx b/apps/Frontend/src/components/claims/claim-form.tsx index fad1821d..1447fbf1 100755 --- a/apps/Frontend/src/components/claims/claim-form.tsx +++ b/apps/Frontend/src/components/claims/claim-form.tsx @@ -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) + + diff --git a/apps/Frontend/src/components/claims/claim-view-modal.tsx b/apps/Frontend/src/components/claims/claim-view-modal.tsx index 3178d9f3..616aa2f3 100755 --- a/apps/Frontend/src/components/claims/claim-view-modal.tsx +++ b/apps/Frontend/src/components/claims/claim-view-modal.tsx @@ -354,12 +354,16 @@ export default function ClaimViewModal({ - {/bcbs|blue cross/i.test(claim.insuranceProvider ?? "") && ( + {/bcbs|blue cross|altus/i.test(claim.insuranceProvider ?? "") && ( + ); +} diff --git a/apps/Frontend/src/components/layout/chatbot.tsx b/apps/Frontend/src/components/layout/chatbot.tsx index 24350269..baa5344f 100644 --- a/apps/Frontend/src/components/layout/chatbot.tsx +++ b/apps/Frontend/src/components/layout/chatbot.tsx @@ -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" { diff --git a/apps/Frontend/src/components/settings/InsuranceCredForm.tsx b/apps/Frontend/src/components/settings/InsuranceCredForm.tsx index b07feced..563a132e 100755 --- a/apps/Frontend/src/components/settings/InsuranceCredForm.tsx +++ b/apps/Frontend/src/components/settings/InsuranceCredForm.tsx @@ -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) { diff --git a/apps/Frontend/src/pages/appointments-page.tsx b/apps/Frontend/src/pages/appointments-page.tsx index adeb91e6..647d80ec 100755 --- a/apps/Frontend/src/pages/appointments-page.tsx +++ b/apps/Frontend/src/pages/appointments-page.tsx @@ -121,6 +121,7 @@ const PREAUTH_SITE_KEYS: Record = { 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}`); diff --git a/apps/Frontend/src/pages/insurance-status-page.tsx b/apps/Frontend/src/pages/insurance-status-page.tsx index 097bba27..9eddf94b 100755 --- a/apps/Frontend/src/pages/insurance-status-page.tsx +++ b/apps/Frontend/src/pages/insurance-status-page.tsx @@ -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(null); const [cmspAccumulatorFilename, setCmspAccumulatorFilename] = useState(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(null); const pendingScrollTo = useRef(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() {
- + Aetna - +
+ 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(); + }} + /> +
= (() => { return m; })(); +const ALTUS_CODE_MAP: Map = (() => { + const m = new Map(); + 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 = (() => { const m = new Map(); for (const r of UNITEDDH_CODE_TABLE) { @@ -129,6 +140,7 @@ function getCodeMap(insuranceSiteKey?: string): Map { 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; diff --git a/apps/SeleniumService/agent.py b/apps/SeleniumService/agent.py index a41fffce..0291d2fb 100755 --- a/apps/SeleniumService/agent.py +++ b/apps/SeleniumService/agent.py @@ -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): diff --git a/apps/SeleniumService/selenium_Altus_eligibilityCheckWorker.py b/apps/SeleniumService/selenium_Altus_eligibilityCheckWorker.py new file mode 100644 index 00000000..062cb801 --- /dev/null +++ b/apps/SeleniumService/selenium_Altus_eligibilityCheckWorker.py @@ -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" () + 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 → + 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()