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