Eligibility: rebuilt the Delta Indiv worker for my.deltadentalcoversme.com — Dentist tab -> Universal Delta Sign In -> username/Next -> password -> SIGN IN, confirmation-code (MFA) page detected by URL so the app's OTP popup shows, then Search for Patient -> Last Name + DOB -> Submit. On the "Benefits for" details page it reads the patient name, Subscriber # and Eligible through (active while that date is today or later) and saves the PDF downloaded by the Print link. Session persists: login is skipped while Search for Patient is reachable, and Delta Indiv cookies are no longer wiped on agent startup. The button needs Last Name + DOB (member ID optional; backend falls back to the scraped Subscriber #). Chatbot gains a Delta Indiv option and aliases. Claims: Delta Indiv uses the same 2024 ADA form as BCBS with the payer address Delta Dental, P.O. Box 103, Stevens Point, WI 54481-0103. Adds Delta Indiv Claim/PreAuth (ADA Form) buttons, Claim All and AI queue routing, Print ADA Form in the claim viewer, a Delta Indiv fee schedule (copied from BCBS), and Delta Indiv as its own insurance type so it is no longer treated as Deltains. Also renames eligibility buttons: Delta Indiv (Last name, DOB or ID), Metlife Dental (ID or SSN), Altus (ID, First name, DOB). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
441 lines
17 KiB
TypeScript
441 lines
17 KiB
TypeScript
import fs from "fs";
|
|
import path from "path";
|
|
import { PDFDocument, PDFFont, PDFForm, StandardFonts } from "pdf-lib";
|
|
|
|
/**
|
|
* Fills the 2024 ADA Dental Claim Form (J43024) for payers that only accept
|
|
* paper claims (e.g. BCBS MA). Field names come from the template's AcroForm;
|
|
* signatures are left blank for wet signing; only item 53's date is filled (claim date).
|
|
*/
|
|
|
|
const TEMPLATE_PATH = path.join(__dirname, "../data/templates/ada-claim-form-2024.pdf");
|
|
const PROCEDURE_CODES_PATH = path.join(__dirname, "../data/procedureCodes.json");
|
|
|
|
const LINES_PER_FORM = 10;
|
|
const FONT_SIZE = 10;
|
|
const MIN_FONT_SIZE = 6;
|
|
|
|
export type AdaPayer = {
|
|
name: string;
|
|
address1: string;
|
|
address2?: string;
|
|
cityStateZip: string;
|
|
};
|
|
|
|
export const ADA_PAYERS: Record<string, AdaPayer> = {
|
|
BCBS_MA: {
|
|
name: "Blue Cross Blue Shield of MA",
|
|
address1: "P.O. Box 986005",
|
|
cityStateZip: "Boston, MA 02298",
|
|
},
|
|
ALTUS: {
|
|
name: "Altus Dental",
|
|
address1: "P.O. Box 1557",
|
|
cityStateZip: "Providence, RI 02901-1557",
|
|
},
|
|
DELTA_INDIV: {
|
|
name: "Delta Dental",
|
|
address1: "P.O. Box 103",
|
|
cityStateZip: "Stevens Point, WI 54481-0103",
|
|
},
|
|
};
|
|
|
|
/** ADA payer key for a claim's insurance provider text (defaults to BCBS MA). */
|
|
export function adaPayerKeyForProvider(provider?: string | null): string {
|
|
const p = provider ?? "";
|
|
if (/altus/i.test(p)) return "ALTUS";
|
|
if (/delta\s*(dental\s*)?indiv/i.test(p)) return "DELTA_INDIV";
|
|
return "BCBS_MA";
|
|
}
|
|
|
|
export type AdaClaimInput = {
|
|
claim: {
|
|
id: number;
|
|
createdAt?: Date | string | null;
|
|
memberId?: string | null;
|
|
remarks?: string | null;
|
|
status?: string | null;
|
|
preAuthNumber?: string | null;
|
|
missingTeeth?: unknown;
|
|
serviceLines: {
|
|
procedureCode: string;
|
|
procedureDate: Date | string;
|
|
quad?: string | null;
|
|
arch?: string | null;
|
|
toothNumber?: string | null;
|
|
toothSurface?: string | null;
|
|
totalBilled: unknown;
|
|
}[];
|
|
};
|
|
patient: {
|
|
id: number;
|
|
firstName: string;
|
|
lastName: string;
|
|
dateOfBirth?: Date | string | null;
|
|
gender?: string | null;
|
|
address?: string | null;
|
|
city?: string | null;
|
|
state?: string | null;
|
|
zipCode?: string | null;
|
|
insuranceId?: string | null;
|
|
groupNumber?: string | null;
|
|
policyHolder?: string | null;
|
|
policyHolderRelationship?: string | null;
|
|
};
|
|
/** Policy holder's own patient record (Patient.policyHolderPatientId), when not self */
|
|
policyHolderPatient?: {
|
|
firstName: string;
|
|
lastName: string;
|
|
dateOfBirth?: Date | string | null;
|
|
gender?: string | null;
|
|
address?: string | null;
|
|
city?: string | null;
|
|
state?: string | null;
|
|
zipCode?: string | null;
|
|
} | null;
|
|
office: {
|
|
legalName?: string | null;
|
|
officeName?: string | null;
|
|
phoneNumber?: string | null;
|
|
streetAddress?: string | null;
|
|
city?: string | null;
|
|
state?: string | null;
|
|
zipCode?: string | null;
|
|
billingName?: string | null;
|
|
billingNpi?: string | null;
|
|
billingLicense?: string | null;
|
|
billingTaxId?: string | null;
|
|
} | null;
|
|
treatingProvider: {
|
|
npiNumber: string;
|
|
licenseNumber?: string | null;
|
|
} | null;
|
|
payer: AdaPayer;
|
|
/** Appointment notes — item 43/44 (replacement of prosthesis) */
|
|
appointmentNotes?: string | null;
|
|
};
|
|
|
|
let descriptionByCode: Map<string, string> | null = null;
|
|
function cdtDescription(code: string): string {
|
|
if (!descriptionByCode) {
|
|
descriptionByCode = new Map();
|
|
try {
|
|
const rows = JSON.parse(fs.readFileSync(PROCEDURE_CODES_PATH, "utf8")) as {
|
|
"Procedure Code": string;
|
|
Description: string;
|
|
}[];
|
|
for (const r of rows) descriptionByCode.set(r["Procedure Code"].toUpperCase(), r.Description);
|
|
} catch {
|
|
// descriptions are optional on the form
|
|
}
|
|
}
|
|
return descriptionByCode.get(code.toUpperCase()) ?? "";
|
|
}
|
|
|
|
function fmtDate(d: Date | string | null | undefined): string {
|
|
if (!d) return "";
|
|
const date = d instanceof Date ? d : new Date(d);
|
|
if (isNaN(date.getTime())) return "";
|
|
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
|
|
const dd = String(date.getUTCDate()).padStart(2, "0");
|
|
return `${mm}/${dd}/${date.getUTCFullYear()}`;
|
|
}
|
|
|
|
/** Preauth forms show procedures as planned this many days after the preauth (appointment) date. */
|
|
const PREAUTH_PROCEDURE_DAYS_AFTER = 6;
|
|
|
|
/** Shifts a date-only value (stored as UTC midnight) by whole days. */
|
|
function addDays(d: Date | string | null | undefined, days: number): Date | null {
|
|
if (!d) return null;
|
|
const date = new Date(d instanceof Date ? d.getTime() : d);
|
|
if (isNaN(date.getTime())) return null;
|
|
date.setUTCDate(date.getUTCDate() + days);
|
|
return date;
|
|
}
|
|
|
|
/** A timestamp as the office's local calendar date (fmtDate is for date-only values stored as UTC midnight). */
|
|
function fmtLocalDate(d: Date | string | null | undefined): string {
|
|
if (!d) return "";
|
|
const date = d instanceof Date ? d : new Date(d);
|
|
if (isNaN(date.getTime())) return "";
|
|
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
|
const dd = String(date.getDate()).padStart(2, "0");
|
|
return `${mm}/${dd}/${date.getFullYear()}`;
|
|
}
|
|
|
|
const PROSTHESIS_WORDS = "crowns?|bridges?|dentures?|partials?|prosthes[ie]s|prosthetics?|pontics?|veneers?|onlays?|inlays?|fpd|rpd|implants?";
|
|
const REPLACE_WORDS = "replac(?:e|ed|es|ing|ement)|remake|re-make|redo|re-do";
|
|
|
|
/**
|
|
* Item 43/44: does the note say an existing prosthesis is being replaced
|
|
* (e.g. "replace old crown #3", "bridge replacement", "replacement of prosthesis")?
|
|
* Returns the prior placement date when the note gives one as MM/DD/YYYY.
|
|
*/
|
|
export function prosthesisReplacement(notes?: string | null): { replacement: boolean; priorDate?: string } {
|
|
const s = (notes ?? "").toLowerCase();
|
|
const replaceThenProsthesis = new RegExp(`\\b(?:${REPLACE_WORDS})\\b[^.;\\n]{0,30}?\\b(?:${PROSTHESIS_WORDS})\\b`);
|
|
const prosthesisThenReplace = new RegExp(`\\b(?:${PROSTHESIS_WORDS})\\b[^.;\\n]{0,15}?\\b(?:${REPLACE_WORDS})\\b`);
|
|
// "replace missing #19 with bridge" is a new prosthesis for a lost tooth, not a replacement
|
|
const hit = [s.match(replaceThenProsthesis), s.match(prosthesisThenReplace)].find(
|
|
(m) => m && !/\b(?:missing|extracted|lost)\b/.test(m[0]),
|
|
);
|
|
if (!hit) return { replacement: false };
|
|
const m = s.match(/\b(\d{1,2})[/-](\d{1,2})[/-](\d{4}|\d{2})\b/);
|
|
if (!m) return { replacement: true };
|
|
const year = m[3]!.length === 2 ? `20${m[3]}` : m[3]!;
|
|
return { replacement: true, priorDate: `${m[1]!.padStart(2, "0")}/${m[2]!.padStart(2, "0")}/${year}` };
|
|
}
|
|
|
|
function fmtMoney(v: unknown): string {
|
|
const n = Number(v ?? 0);
|
|
return isNaN(n) ? "" : n.toFixed(2);
|
|
}
|
|
|
|
function cityStateZip(city?: string | null, state?: string | null, zip?: string | null): string {
|
|
const left = [city?.trim(), state?.trim()].filter(Boolean).join(", ");
|
|
return [left, zip?.trim()].filter(Boolean).join(" ");
|
|
}
|
|
|
|
/** Item 25: ADA area-of-oral-cavity codes. */
|
|
function oralCavityArea(quad?: string | null, arch?: string | null): string {
|
|
switch ((quad ?? "").toUpperCase()) {
|
|
case "UR": return "10";
|
|
case "UL": return "20";
|
|
case "LL": return "30";
|
|
case "LR": return "40";
|
|
}
|
|
switch ((arch ?? "").toLowerCase()) {
|
|
case "maxilla": return "01";
|
|
case "mandible": return "02";
|
|
}
|
|
return "";
|
|
}
|
|
|
|
/** "M"/"F"/"U" from free-text gender. */
|
|
function genderCode(g?: string | null): "M" | "F" | "U" {
|
|
const s = (g ?? "").trim().toLowerCase();
|
|
if (s.startsWith("m")) return "M";
|
|
if (s.startsWith("f")) return "F";
|
|
return "U";
|
|
}
|
|
|
|
function splitPhone(phone?: string | null): [string, string, string] | null {
|
|
const digits = (phone ?? "").replace(/\D/g, "").replace(/^1(?=\d{10}$)/, "");
|
|
if (digits.length !== 10) return null;
|
|
return [digits.slice(0, 3), digits.slice(3, 6), digits.slice(6)];
|
|
}
|
|
|
|
function normName(s: string): string {
|
|
return s.toLowerCase().replace(/[^a-z]/g, " ").split(/\s+/).filter(Boolean).sort().join(" ");
|
|
}
|
|
|
|
function setText(form: PDFForm, name: string, value: string | null | undefined) {
|
|
if (!value) return;
|
|
const field = form.getTextField(name);
|
|
field.setFontSize(FONT_SIZE);
|
|
field.setText(value);
|
|
}
|
|
|
|
/** Left-aligned text shrunk (then truncated) to fit the field width. */
|
|
function setFittedText(form: PDFForm, font: PDFFont, name: string, value: string) {
|
|
if (!value) return;
|
|
const field = form.getTextField(name);
|
|
const width = field.acroField.getWidgets()[0]!.getRectangle().width - 4;
|
|
let size = FONT_SIZE;
|
|
while (size > MIN_FONT_SIZE && font.widthOfTextAtSize(value, size) > width) size -= 0.5;
|
|
let text = value;
|
|
while (text.length > 1 && font.widthOfTextAtSize(text, size) > width) text = text.slice(0, -1);
|
|
field.setAlignment(0);
|
|
field.setFontSize(size);
|
|
field.setText(text);
|
|
}
|
|
|
|
function check(form: PDFForm, name: string) {
|
|
form.getCheckBox(name).check();
|
|
}
|
|
|
|
/** Field-name suffix for service line i (0-based): "1".."9", then "Row10" (Fee uses "10"). */
|
|
function lineSuffix(i: number, field: "fee" | "other"): string {
|
|
if (i < 9) return String(i + 1);
|
|
return field === "fee" ? "10" : "Row10";
|
|
}
|
|
|
|
async function fillOnePage(
|
|
template: Uint8Array,
|
|
input: AdaClaimInput,
|
|
lines: AdaClaimInput["claim"]["serviceLines"],
|
|
): Promise<PDFDocument> {
|
|
const doc = await PDFDocument.load(template);
|
|
// Keep page 1 only (pages 2-3 are instructions / fraud warnings)
|
|
while (doc.getPageCount() > 1) doc.removePage(doc.getPageCount() - 1);
|
|
const form = doc.getForm();
|
|
const helvetica = await doc.embedFont(StandardFonts.Helvetica);
|
|
const { claim, patient, office, treatingProvider, payer } = input;
|
|
const officeState = office?.state ?? "";
|
|
|
|
// 1-2. Header
|
|
if (claim.status === "PREAUTH") {
|
|
check(form, "1 Check Box 1");
|
|
setText(form, "2 PredeterminationPreauthorization Number", claim.preAuthNumber);
|
|
} else {
|
|
check(form, "1 Check Box 2");
|
|
}
|
|
|
|
// 3. Payer
|
|
setText(form, "3 CompanyPlan Name", payer.name);
|
|
setText(form, "3 Address 1", payer.address1);
|
|
setText(form, "3 Address 2", payer.address2);
|
|
setText(form, "3 City, State, Zip Code", payer.cityStateZip);
|
|
|
|
// 12-23. Subscriber / patient
|
|
const patientName = `${patient.lastName}, ${patient.firstName}`;
|
|
const patientCsz = cityStateZip(
|
|
patient.city,
|
|
patient.state || (patient.address || patient.city ? officeState : ""),
|
|
patient.zipCode,
|
|
);
|
|
const dob = fmtDate(patient.dateOfBirth);
|
|
const gender = genderCode(patient.gender);
|
|
const genderBox = { M: "Check Box1", F: "Check Box2", U: "Check Box3" }[gender];
|
|
|
|
const holder = (patient.policyHolder ?? "").trim();
|
|
const relationship = (patient.policyHolderRelationship ?? "").trim().toUpperCase();
|
|
const isSelf = relationship
|
|
? relationship === "SELF"
|
|
: !holder ||
|
|
holder.toLowerCase() === "self" ||
|
|
normName(holder) === normName(`${patient.firstName} ${patient.lastName}`);
|
|
|
|
if (isSelf) {
|
|
setText(form, "12 PolicyholderSubscriber Name Last First Middle Initial Suffix", patientName);
|
|
setText(form, "12 Address 1", patient.address);
|
|
setText(form, "12 City, State, Zip", patientCsz);
|
|
setText(form, "13 Date of Birth MMDDCCYY", dob);
|
|
check(form, `14 ${genderBox}`);
|
|
} else if (input.policyHolderPatient) {
|
|
// Subscriber is another patient in the system: fill 12-14 from their record
|
|
const h = input.policyHolderPatient;
|
|
setText(form, "12 PolicyholderSubscriber Name Last First Middle Initial Suffix", `${h.lastName}, ${h.firstName}`);
|
|
setText(form, "12 Address 1", h.address);
|
|
setText(
|
|
form,
|
|
"12 City, State, Zip",
|
|
cityStateZip(h.city, h.state || (h.address || h.city ? officeState : ""), h.zipCode),
|
|
);
|
|
setText(form, "13 Date of Birth MMDDCCYY", fmtDate(h.dateOfBirth));
|
|
const hGenderBox = { M: "Check Box1", F: "Check Box2", U: "Check Box3" }[genderCode(h.gender)];
|
|
check(form, `14 ${hGenderBox}`);
|
|
} else {
|
|
// Subscriber typed by name only: DOB, gender and address are completed by hand.
|
|
setText(form, "12 PolicyholderSubscriber Name Last First Middle Initial Suffix", holder);
|
|
}
|
|
// 18. Relationship to policyholder: Self, Spouse, Dependent Child, Other (left to right)
|
|
const relationshipBox = isSelf
|
|
? "Check Box1"
|
|
: { SPOUSE: "Check Box2", DEPENDENT: "Check Box3", OTHER: "Check Box4" }[relationship];
|
|
if (relationshipBox) check(form, `18 ${relationshipBox}`);
|
|
setText(form, "15 Policyho derSubscriber ID Assigned by Plan", claim.memberId || patient.insuranceId);
|
|
setText(form, "16 PlanGroup Number", patient.groupNumber);
|
|
|
|
setText(form, "20 Name Last First Middle Initial Suffix", patientName);
|
|
setText(form, "20 Address 1", patient.address);
|
|
setText(form, "20 City, State, Zip", patientCsz);
|
|
setText(form, "21 Date of Birth MMDDCCYY", dob);
|
|
check(form, `22 ${genderBox}`);
|
|
|
|
// 24-31. Service lines (a preauth lists the planned date: 6 days after the preauth date)
|
|
const isPreauth = claim.status === "PREAUTH";
|
|
let total = 0;
|
|
lines.forEach((line, i) => {
|
|
const s = lineSuffix(i, "other");
|
|
const tooth = (line.toothNumber ?? "").trim();
|
|
setText(
|
|
form,
|
|
`24 Procedure Date MMDDCCYY${s}`,
|
|
fmtDate(isPreauth ? addDays(line.procedureDate, PREAUTH_PROCEDURE_DAYS_AFTER) : line.procedureDate),
|
|
);
|
|
setText(form, `25 Area of Oral Cavity${s}`, oralCavityArea(line.quad, line.arch));
|
|
setText(form, `27 Tooth Numbers or Letters${s}`, tooth);
|
|
setText(form, `28 Tooth Surface${s}`, (line.toothSurface ?? "").trim());
|
|
setText(form, `29 Procedure Code${s}`, line.procedureCode.trim().toUpperCase());
|
|
setFittedText(form, helvetica, `30 Description${s}`, cdtDescription(line.procedureCode.trim()));
|
|
setText(form, `31 Fee${lineSuffix(i, "fee")}`, fmtMoney(line.totalBilled));
|
|
total += Number(line.totalBilled ?? 0) || 0;
|
|
});
|
|
setText(form, "32 Total Fee", total.toFixed(2));
|
|
|
|
// 33. Missing teeth — { "T_14": "X", ... }; only permanent teeth 1-32 have boxes
|
|
if (claim.missingTeeth && typeof claim.missingTeeth === "object") {
|
|
for (const [key, val] of Object.entries(claim.missingTeeth as Record<string, unknown>)) {
|
|
const n = Number(key.replace(/^T_/, ""));
|
|
if (val === "X" && Number.isInteger(n) && n >= 1 && n <= 32) check(form, `33 box${n}`);
|
|
}
|
|
}
|
|
|
|
// 35. Remarks
|
|
setText(form, "35 Remarks", claim.remarks?.trim());
|
|
|
|
// 38-40. Ancillary
|
|
setText(form, "38 Place of Treatment", "11");
|
|
check(form, "40 Check Box1");
|
|
// 43-44. Replacement of prosthesis: Yes only when the appointment notes say so
|
|
const replacement = prosthesisReplacement(input.appointmentNotes);
|
|
if (replacement.replacement) {
|
|
check(form, "43 Check Box2");
|
|
setText(form, "44 Date of Prior Placement MMDDCCYY", replacement.priorDate);
|
|
} else {
|
|
check(form, "43 Check Box1");
|
|
}
|
|
|
|
// 48-52. Billing dentist: legal name, office name (DBA) and address (Settings > Office Contact)
|
|
const officeCsz = cityStateZip(office?.city, office?.state, office?.zipCode);
|
|
const legalName = (office?.legalName || office?.billingName || "").trim();
|
|
const dbaName = office?.officeName?.trim() ?? "";
|
|
const street = office?.streetAddress?.trim() ?? "";
|
|
const billingLines = [legalName, dbaName !== legalName ? dbaName : "", street].filter(Boolean);
|
|
setText(form, "48 Name", billingLines[0]);
|
|
setText(form, "48 Address", billingLines[1]);
|
|
setText(form, "48 Address 2", billingLines[2]);
|
|
setText(form, "48 City, State, Zip", officeCsz);
|
|
setText(form, "49 NPI", office?.billingNpi);
|
|
setText(form, "50 License Number", office?.billingLicense);
|
|
setText(form, "51 SSN or TIN", office?.billingTaxId);
|
|
|
|
const phone = splitPhone(office?.phoneNumber);
|
|
if (phone) {
|
|
for (const item of ["52", "57"]) {
|
|
setText(form, `${item} area code`, phone[0]);
|
|
setText(form, `${item} phone 1`, phone[1]);
|
|
setText(form, `${item} phone 2`, phone[2]);
|
|
}
|
|
}
|
|
|
|
// 53. Signature stays blank; date = claim date (when the claim was made)
|
|
setText(form, "53 Date", fmtLocalDate(claim.createdAt ?? new Date()));
|
|
|
|
// 54-56. Treating dentist and location
|
|
setText(form, "54 NPI", treatingProvider?.npiNumber);
|
|
setText(form, "55 License Number", treatingProvider?.licenseNumber || office?.billingLicense);
|
|
// 56. Treatment location: office address only
|
|
setFittedText(form, helvetica, "56 Address 1", street);
|
|
setFittedText(form, helvetica, "56 Address 2", officeCsz);
|
|
|
|
form.updateFieldAppearances(helvetica);
|
|
form.flatten();
|
|
return doc;
|
|
}
|
|
|
|
/** Returns the filled form; claims with more than 10 lines continue on additional complete forms. */
|
|
export async function buildAdaClaimPdf(input: AdaClaimInput): Promise<Uint8Array> {
|
|
const template = fs.readFileSync(TEMPLATE_PATH);
|
|
const lines = input.claim.serviceLines;
|
|
const out = await PDFDocument.create();
|
|
for (let i = 0; i < Math.max(lines.length, 1); i += LINES_PER_FORM) {
|
|
const filled = await fillOnePage(template, input, lines.slice(i, i + LINES_PER_FORM));
|
|
const [page] = await out.copyPages(filled, [0]);
|
|
out.addPage(page!);
|
|
}
|
|
out.setTitle(`ADA Claim Form - ${input.patient.lastName}, ${input.patient.firstName}`);
|
|
return out.save();
|
|
}
|