|
|
|
@@ -0,0 +1,321 @@
|
|
|
|
|
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 and their dates are intentionally left blank for wet signing.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
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",
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type AdaClaimInput = {
|
|
|
|
|
claim: {
|
|
|
|
|
id: number;
|
|
|
|
|
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;
|
|
|
|
|
zipCode?: string | null;
|
|
|
|
|
insuranceId?: string | null;
|
|
|
|
|
groupNumber?: string | null;
|
|
|
|
|
policyHolder?: string | null;
|
|
|
|
|
};
|
|
|
|
|
office: {
|
|
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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()}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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.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 isSelf =
|
|
|
|
|
!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}`);
|
|
|
|
|
check(form, "18 Check Box1");
|
|
|
|
|
} else {
|
|
|
|
|
// Subscriber is someone else: name only; DOB, gender and relationship are completed by hand.
|
|
|
|
|
setText(form, "12 PolicyholderSubscriber Name Last First Middle Initial Suffix", holder);
|
|
|
|
|
}
|
|
|
|
|
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}`);
|
|
|
|
|
setText(form, "23 Patient IDAccount Assigned by Dentist", String(patient.id));
|
|
|
|
|
|
|
|
|
|
// 24-31. Service lines
|
|
|
|
|
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(line.procedureDate));
|
|
|
|
|
setText(form, `25 Area of Oral Cavity${s}`, oralCavityArea(line.quad, line.arch));
|
|
|
|
|
setText(form, `26 Tooth System${s}`, tooth ? "JP" : "");
|
|
|
|
|
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");
|
|
|
|
|
|
|
|
|
|
// 48-52. Billing dentist (office NPI)
|
|
|
|
|
const officeCsz = cityStateZip(office?.city, office?.state, office?.zipCode);
|
|
|
|
|
const officeLines = [office?.billingName, office?.officeName, office?.streetAddress]
|
|
|
|
|
.map((v) => v?.trim())
|
|
|
|
|
.filter(Boolean) as string[];
|
|
|
|
|
setText(form, "48 Name", officeLines[0]);
|
|
|
|
|
setText(form, "48 Address", officeLines[1]);
|
|
|
|
|
setText(form, "48 Address 2", officeLines[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]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 54-56. Treating dentist and location
|
|
|
|
|
setText(form, "54 NPI", treatingProvider?.npiNumber);
|
|
|
|
|
setText(form, "55 License Number", treatingProvider?.licenseNumber || office?.billingLicense);
|
|
|
|
|
setText(form, "56 Address 1", office?.streetAddress);
|
|
|
|
|
setText(form, "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();
|
|
|
|
|
}
|