Files
DentalManagement09/apps/Backend/src/services/adaClaimForm.ts
T
GiteadandClaude Opus 5.5 0082b7bd53 feat: Delta Indiv eligibility on coversme portal and ADA paper claim/preauth
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>
2026-10-16 22:03:30 -04:00

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();
}