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;
|
||||
|
||||
Reference in New Issue
Block a user