feat: BCBS MA paper claim — fill 2024 ADA claim form; office NPI setting

- New GET /api/claims/:id/ada-form fills the 2024 ADA Dental Claim Form
  (J43024) with payer address, patient/subscriber, CDT lines, fees,
  missing teeth, billing (office) NPI and treating NPI; signatures blank
- "BCBS Claim (ADA Form)" button saves the claim and opens the PDF;
  Claim All routes BCBS patients to it; "Print ADA Form" in claim view
- Settings > NPI Providers: "Add Office NPI" (billing name, NPI, license,
  TIN) and optional per-provider license number
- Migration: office_contact billing fields, NpiProvider.licenseNumber

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 22:41:44 -04:00
co-authored by Claude Opus 5.5
parent d67e68c957
commit 2c0c595af5
294 changed files with 13054 additions and 1776 deletions
+1
View File
@@ -32,6 +32,7 @@
"node-cron": "^4.2.1",
"passport": "^0.7.0",
"passport-local": "^1.0.0",
"pdf-lib": "^1.17.1",
"pdfkit": "^0.17.2",
"sharp": "^0.35.3",
"socket.io": "^4.8.1",
File diff suppressed because it is too large Load Diff
+50
View File
@@ -19,6 +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 { backupScreenshots } from "../utils/screenshotBackup";
const router = Router();
@@ -1230,6 +1231,55 @@ router.get("/all", async (req: Request, res: Response) => {
});
// Get a single claim by ID
// GET /api/claims/:id/ada-form?payer=BCBS_MA
// Paper claim: returns the 2024 ADA Dental Claim Form filled from the saved claim.
router.get("/:id/ada-form", async (req: Request, res: Response): Promise<any> => {
try {
if (!req.user?.id) return res.status(401).json({ message: "Unauthorized" });
const claimId = parseInt(String(req.params.id), 10);
if (isNaN(claimId)) return res.status(400).json({ error: "Invalid claim ID" });
const payerKey = String(req.query.payer || "BCBS_MA").toUpperCase();
const payer = ADA_PAYERS[payerKey];
if (!payer) return res.status(400).json({ error: `Unknown payer "${payerKey}"` });
const { prisma: db } = await import("@repo/db/client");
const claim = await db.claim.findUnique({
where: { id: claimId },
include: {
serviceLines: { orderBy: { id: "asc" } },
patient: true,
npiProvider: true,
},
});
if (!claim || claim.userId !== req.user.id) {
return res.status(404).json({ message: "Claim not found" });
}
const office = await storage.getOfficeContact(req.user.id);
// Fall back to the default (first) NPI provider when the claim has none
const treatingProvider =
claim.npiProvider ?? (await storage.getNpiProvidersByUser(req.user.id))[0] ?? null;
const pdf = await buildAdaClaimPdf({
claim,
patient: claim.patient,
office,
treatingProvider,
payer,
});
const filename = `ADA_Claim_${claim.patient.lastName}_${claim.patient.firstName}_${claimId}.pdf`
.replace(/[^\w.-]+/g, "_");
res.setHeader("Content-Type", "application/pdf");
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
return res.send(Buffer.from(pdf));
} catch (error) {
console.error("[ada-form] failed:", error);
return res.status(500).json({ message: "Failed to generate ADA claim form", details: String(error) });
}
});
router.get("/:id", async (req: Request, res: Response): Promise<any> => {
try {
const idParam = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
+8 -1
View File
@@ -22,7 +22,10 @@ router.put("/", async (req: Request, res: Response): Promise<any> => {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const { officeName, receptionistName, dentistName, phoneNumber, email, fax, streetAddress, city, state, zipCode } = req.body;
const {
officeName, receptionistName, dentistName, phoneNumber, email, fax, streetAddress, city, state, zipCode,
billingName, billingNpi, billingLicense, billingTaxId,
} = req.body;
const record = await storage.upsertOfficeContact(userId, {
officeName: officeName ?? undefined,
receptionistName: receptionistName ?? undefined,
@@ -34,6 +37,10 @@ router.put("/", async (req: Request, res: Response): Promise<any> => {
city: city ?? undefined,
state: state ?? undefined,
zipCode: zipCode ?? undefined,
billingName: billingName ?? undefined,
billingNpi: billingNpi ?? undefined,
billingLicense: billingLicense ?? undefined,
billingTaxId: billingTaxId ?? undefined,
});
return res.status(200).json(record);
} catch (err) {
+321
View File
@@ -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();
}
@@ -16,6 +16,10 @@ export const officeContactStorage = {
city?: string;
state?: string;
zipCode?: string;
billingName?: string;
billingNpi?: string;
billingLicense?: string;
billingTaxId?: string;
}) {
return db.officeContact.upsert({
where: { userId },