feat: BCBS AI column claim fills ADA form, saves to Documents; form fixes
- Claim for column with AI: BCBS patients auto-generate the 2024 ADA form (no tab) and save it to the patient's Documents > Claims folder; the manual BCBS button saves there too (new POST /api/claims/:id/ada-form) - Saved BCBS form sets the claim to REVIEW; appointments with a claim number or REVIEW/APPROVED status show as claimed (dark gray) - CDT lookup: "comp on #18 O, 20 O, 21 O" applies composite to every tooth - BCBS fee schedule (procedureCodesBCBS.json, copied from DDMA prices) - Office Contact: Legal Name field; "Dental Office Name (DBA)" label - ADA form: 48 = legal name, DBA, address; 56 = address only; 53 date = claim date; 23 and 26 left blank; 43 = No unless appointment notes say a prosthesis is replaced (then Yes, with 44 prior date when given) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -599,6 +599,37 @@ function matchOne(input: string): CdtMatch | null {
|
||||
return { code: bestEntry.code, description: bestEntry.description, input };
|
||||
}
|
||||
|
||||
const TOOTH_SURFACE_RE = /#?\s*(\d{1,2})\s+([OMDBLFIV]{1,5})\b/gi;
|
||||
const BARE_TOOTH_SURFACE_RE = /^#?\s*(\d{1,2})\s+([OMDBLFIV]{1,5})$/i;
|
||||
|
||||
/**
|
||||
* Composite lists often give the "#" only on the first tooth:
|
||||
* "comp on #18 O, 20 O, 21 O" → ["comp #18 O", "comp #20 O", "comp #21 O"].
|
||||
* Also handles the LLM splitting that list into ["comp on #18 O", "20 O", ...]:
|
||||
* a bare "20 O" right after a composite entry is another composite.
|
||||
*/
|
||||
function expandCompositeLists(procedureNames: string[]): string[] {
|
||||
const out: string[] = [];
|
||||
let prevComposite = false;
|
||||
for (const name of procedureNames) {
|
||||
const s = name.trim();
|
||||
if (prevComposite && BARE_TOOTH_SURFACE_RE.test(s)) {
|
||||
const m = s.match(BARE_TOOTH_SURFACE_RE)!;
|
||||
out.push(`comp #${m[1]} ${m[2]}`);
|
||||
continue;
|
||||
}
|
||||
const isComposite = /^(comp|composite|resin|filling)\b/i.test(s) || /^#\s*\d{1,2}\s+[OMDBLFIV]{1,5}\b/i.test(s);
|
||||
const teeth = isComposite ? [...s.matchAll(TOOTH_SURFACE_RE)] : [];
|
||||
if (teeth.length > 1) {
|
||||
for (const m of teeth) out.push(`comp #${m[1]} ${m[2]}`);
|
||||
} else {
|
||||
out.push(name);
|
||||
}
|
||||
prevComposite = teeth.length > 0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an array of free-text procedure names to CDT codes.
|
||||
* customAliases (from DB) are checked before the hardcoded ALIAS_MAP.
|
||||
@@ -617,7 +648,7 @@ export function lookupCdtCodes(
|
||||
// Pontics with no stated material — re-coded below to match the crowns
|
||||
const inferredPontics: CdtMatch[] = [];
|
||||
|
||||
const results = procedureNames.flatMap((name): (CdtMatch | { code: null; description: string; input: string })[] => {
|
||||
const results = expandCompositeLists(procedureNames).flatMap((name): (CdtMatch | { code: null; description: string; input: string })[] => {
|
||||
const cleaned = name.trim().toLowerCase();
|
||||
|
||||
// If the LLM emitted a bare tooth-number token like "# 32" or "#32" with no procedure,
|
||||
|
||||
@@ -166,8 +166,12 @@ Rules:
|
||||
"with the scan", "with the photo", "with the document", or any variation meaning an uploaded/attached file,
|
||||
do NOT include it in procedureNames. It refers to a file attachment, not a billable procedure.
|
||||
Only include actual clinical procedures in procedureNames.
|
||||
- Replacement wording and prior placement dates are claim-form details, not procedures:
|
||||
"replace old crown #3" → ["crown #3"]; "bridge #3-5 replacement, placed 5/2/18" → ["bridge #3-5"]
|
||||
- For composite fillings with a tooth number, preserve the EXACT notation including tooth# and surfaces:
|
||||
e.g. "composite #29 O", "#8 MO", "composite #11 MOD", "# 10 DL", "# 11 ML", "#8 ml", "comp #8 ml" — keep the #number and surface letters together as one entry
|
||||
A tooth list after one composite keeps the composite for every tooth: "comp on #18 O, 20 O, 21 O" →
|
||||
["comp #18 O", "comp #20 O", "comp #21 O"]
|
||||
Note: "comp" is short for "composite". "# 10 DL", "composite on # 10 DL", and "comp # 10 DL" are all the same — preserve the space-after-# as-is
|
||||
IMPORTANT: Any letter(s) immediately after a tooth number are DENTAL SURFACE abbreviations indicating a composite filling:
|
||||
M = Mesial, D = Distal, L = Lingual, F = Facial, B = Buccal, O = Occlusal, V = Vestibular, I = Incisal
|
||||
|
||||
@@ -1230,56 +1230,104 @@ 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.
|
||||
// Paper claim: the 2024 ADA Dental Claim Form filled from a saved claim.
|
||||
type AdaFormResult =
|
||||
| { ok: false; status: number; error: string }
|
||||
| {
|
||||
ok: true;
|
||||
pdf: Buffer;
|
||||
filename: string;
|
||||
claimId: number;
|
||||
claimStatus: string;
|
||||
patient: { id: number; firstName: string; lastName: string };
|
||||
};
|
||||
|
||||
async function buildAdaFormForRequest(req: Request): Promise<AdaFormResult> {
|
||||
const claimId = parseInt(String(req.params.id), 10);
|
||||
if (isNaN(claimId)) return { ok: false, status: 400, error: "Invalid claim ID" };
|
||||
|
||||
const payerKey = String(req.query.payer || "BCBS_MA").toUpperCase();
|
||||
const payer = ADA_PAYERS[payerKey];
|
||||
if (!payer) return { ok: false, status: 400, 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,
|
||||
appointment: { select: { notes: true } },
|
||||
},
|
||||
});
|
||||
if (!claim || claim.userId !== req.user!.id) {
|
||||
return { ok: false, status: 404, error: "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,
|
||||
appointmentNotes: claim.appointment?.notes ?? null,
|
||||
});
|
||||
|
||||
const filename = `ADA_Claim_${claim.patient.lastName}_${claim.patient.firstName}_${claimId}.pdf`
|
||||
.replace(/[^\w.-]+/g, "_");
|
||||
return { ok: true, pdf: Buffer.from(pdf), filename, claimId, claimStatus: claim.status, patient: claim.patient };
|
||||
}
|
||||
|
||||
// GET /api/claims/:id/ada-form?payer=BCBS_MA — view/print only
|
||||
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, "_");
|
||||
const result = await buildAdaFormForRequest(req);
|
||||
if (!result.ok) return res.status(result.status).json({ message: result.error });
|
||||
res.setHeader("Content-Type", "application/pdf");
|
||||
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
|
||||
return res.send(Buffer.from(pdf));
|
||||
res.setHeader("Content-Disposition", `inline; filename="${result.filename}"`);
|
||||
return res.send(result.pdf);
|
||||
} catch (error) {
|
||||
console.error("[ada-form] failed:", error);
|
||||
return res.status(500).json({ message: "Failed to generate ADA claim form", details: String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/claims/:id/ada-form?payer=BCBS_MA — also saves the PDF to the
|
||||
// patient's Documents "Claims" folder, then returns it
|
||||
router.post("/:id/ada-form", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
if (!req.user?.id) return res.status(401).json({ message: "Unauthorized" });
|
||||
const result = await buildAdaFormForRequest(req);
|
||||
if (!result.ok) return res.status(result.status).json({ message: result.error });
|
||||
const { patient } = result;
|
||||
await storage.savePdfToCloudStorage(
|
||||
req.user.id,
|
||||
patient.id,
|
||||
`${patient.firstName} ${patient.lastName}`,
|
||||
"Claims",
|
||||
result.filename,
|
||||
result.pdf,
|
||||
);
|
||||
// The form is the submission: mark it submitted so the appointment shows as claimed
|
||||
if (result.claimStatus === "PENDING") {
|
||||
await storage.updateClaimStatus(result.claimId, "REVIEW");
|
||||
}
|
||||
res.setHeader("Content-Type", "application/pdf");
|
||||
res.setHeader("Content-Disposition", `inline; filename="${result.filename}"`);
|
||||
return res.send(result.pdf);
|
||||
} catch (error) {
|
||||
console.error("[ada-form] failed:", error);
|
||||
return res.status(500).json({ message: "Failed to generate ADA claim form", details: String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Get a single claim by ID
|
||||
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;
|
||||
|
||||
@@ -9,6 +9,8 @@ const SCHEDULE_FILES: Record<string, string> = {
|
||||
MASSHEALTH: "procedureCodesMH.json",
|
||||
CCA: "procedureCodesCCA.json",
|
||||
DDMA: "procedureCodesDDMA.json",
|
||||
BCBS: "procedureCodesBCBS.json",
|
||||
BCBS_MA: "procedureCodesBCBS.json",
|
||||
TUFTSSCO: "procedureCodesTuftsSCO.json",
|
||||
TUFTS_SCO: "procedureCodesTuftsSCO.json",
|
||||
UNITEDDH: "procedureCodesUnitedDH.json",
|
||||
|
||||
@@ -23,10 +23,11 @@ router.put("/", async (req: Request, res: Response): Promise<any> => {
|
||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||
|
||||
const {
|
||||
officeName, receptionistName, dentistName, phoneNumber, email, fax, streetAddress, city, state, zipCode,
|
||||
legalName, officeName, receptionistName, dentistName, phoneNumber, email, fax, streetAddress, city, state, zipCode,
|
||||
billingName, billingNpi, billingLicense, billingTaxId,
|
||||
} = req.body;
|
||||
const record = await storage.upsertOfficeContact(userId, {
|
||||
legalName: legalName ?? undefined,
|
||||
officeName: officeName ?? undefined,
|
||||
receptionistName: receptionistName ?? undefined,
|
||||
dentistName: dentistName ?? undefined,
|
||||
|
||||
@@ -5,7 +5,7 @@ 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.
|
||||
* 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");
|
||||
@@ -33,6 +33,7 @@ export const ADA_PAYERS: Record<string, AdaPayer> = {
|
||||
export type AdaClaimInput = {
|
||||
claim: {
|
||||
id: number;
|
||||
createdAt?: Date | string | null;
|
||||
memberId?: string | null;
|
||||
remarks?: string | null;
|
||||
status?: string | null;
|
||||
@@ -63,6 +64,7 @@ export type AdaClaimInput = {
|
||||
policyHolder?: string | null;
|
||||
};
|
||||
office: {
|
||||
legalName?: string | null;
|
||||
officeName?: string | null;
|
||||
phoneNumber?: string | null;
|
||||
streetAddress?: string | null;
|
||||
@@ -79,6 +81,8 @@ export type AdaClaimInput = {
|
||||
licenseNumber?: string | null;
|
||||
} | null;
|
||||
payer: AdaPayer;
|
||||
/** Appointment notes — item 43/44 (replacement of prosthesis) */
|
||||
appointmentNotes?: string | null;
|
||||
};
|
||||
|
||||
let descriptionByCode: Map<string, string> | null = null;
|
||||
@@ -107,6 +111,39 @@ function fmtDate(d: Date | string | null | undefined): string {
|
||||
return `${mm}/${dd}/${date.getUTCFullYear()}`;
|
||||
}
|
||||
|
||||
/** 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);
|
||||
@@ -244,7 +281,6 @@ async function fillOnePage(
|
||||
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;
|
||||
@@ -253,7 +289,6 @@ async function fillOnePage(
|
||||
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());
|
||||
@@ -277,15 +312,24 @@ async function fillOnePage(
|
||||
// 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 (office NPI)
|
||||
// 48-52. Billing dentist: legal name, office name (DBA) and address (Settings > Office Contact)
|
||||
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]);
|
||||
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);
|
||||
@@ -300,11 +344,15 @@ async function fillOnePage(
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
setText(form, "56 Address 1", office?.streetAddress);
|
||||
setText(form, "56 Address 2", officeCsz);
|
||||
// 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();
|
||||
|
||||
@@ -126,10 +126,15 @@ export const claimsStorage: IStorage = {
|
||||
async getAppointmentIdsWithClaimNumbers(ids: number[]): Promise<Set<number>> {
|
||||
if (!ids.length) return new Set();
|
||||
const rows = await db.claim.findMany({
|
||||
where: { appointmentId: { in: ids }, claimNumber: { not: null } },
|
||||
// Same "already submitted" rule as batch-column: a claim number, or REVIEW/APPROVED
|
||||
// (paper claims like BCBS have no claim number and are set to REVIEW once the form is saved)
|
||||
where: {
|
||||
appointmentId: { in: ids },
|
||||
OR: [{ claimNumber: { not: null } }, { status: { in: ["REVIEW", "APPROVED"] as ClaimStatus[] } }],
|
||||
},
|
||||
select: { appointmentId: true },
|
||||
distinct: ["appointmentId"],
|
||||
});
|
||||
return new Set(rows.map((r) => r.appointmentId));
|
||||
return new Set(rows.map((r: { appointmentId: number | null }) => r.appointmentId));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ export const officeContactStorage = {
|
||||
},
|
||||
|
||||
async upsertOfficeContact(userId: number, data: {
|
||||
legalName?: string;
|
||||
officeName?: string;
|
||||
receptionistName?: string;
|
||||
dentistName?: string;
|
||||
|
||||
Reference in New Issue
Block a user