feat: payment PDF extraction, import, and remittance tracking
- Add Upload Payment Documents section with Extract & Download (Excel) and Extract & Import (database) buttons - PDF extractor (pdfplumber) parses MassHealth RA PDFs: two-pass strategy joins summary-page ICN/patient map with detail-page procedure data (CDT code, paid code, tooth, date, allowed amount) - RA cover-page summary (Payee ID, RA #, Payment Amount, etc.) included as separate Excel sheet; numeric values written as numbers - Backend PDF import route groups rows by Member #, finds/creates patient, creates Payment + ServiceLines with ICN per procedure - Add icn, paidCode, allowedAmount fields to ServiceLine schema - Payments table: status simplified to Paid in Full / Balance; adjustment auto-computed on mhPaidAmount/copayment change; Paid in Full and Revert buttons with confirmation dialogs - Edit Payment modal: shows ICN, Paid Code, Allowed Amount per line - PDF Import badge distinguishes from OCR imports in payments table Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@ import paymentsRoutes from "./payments";
|
||||
import databaseManagementRoutes from "./database-management";
|
||||
import notificationsRoutes from "./notifications";
|
||||
import paymentOcrRoutes from "./paymentOcrExtraction";
|
||||
import paymentPdfRoutes from "./paymentPdfExtraction";
|
||||
import cloudStorageRoutes from "./cloud-storage";
|
||||
import paymentsReportsRoutes from "./payments-reports";
|
||||
import exportPaymentsReportsRoutes from "./export-payments-reports";
|
||||
@@ -53,6 +54,7 @@ router.use("/payments", paymentsRoutes);
|
||||
router.use("/database-management", databaseManagementRoutes);
|
||||
router.use("/notifications", notificationsRoutes);
|
||||
router.use("/payment-ocr", paymentOcrRoutes);
|
||||
router.use("/payment-pdf", paymentPdfRoutes);
|
||||
router.use("/cloud-storage", cloudStorageRoutes);
|
||||
router.use("/payments-reports", paymentsReportsRoutes);
|
||||
router.use("/export-payments-reports", exportPaymentsReportsRoutes);
|
||||
|
||||
78
apps/Backend/src/routes/paymentPdfExtraction.ts
Normal file
78
apps/Backend/src/routes/paymentPdfExtraction.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Router, Request, Response } from "express";
|
||||
import multer from "multer";
|
||||
import axios from "axios";
|
||||
import FormData from "form-data";
|
||||
import { pdfImportService, PdfRow } from "../services/paymentService";
|
||||
|
||||
const router = Router();
|
||||
const upload = multer({ storage: multer.memoryStorage() });
|
||||
|
||||
const OCR_BASE_URL = process.env.OCR_SERVICE_BASE_URL || "http://localhost:5003";
|
||||
|
||||
router.post(
|
||||
"/extract",
|
||||
upload.array("files"),
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
const files = req.files as Express.Multer.File[] | undefined;
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
return res.status(400).json({ error: "No PDF files uploaded. Use field name 'files'." });
|
||||
}
|
||||
|
||||
const bad = files.filter((f) => !f.originalname.toLowerCase().endsWith(".pdf"));
|
||||
if (bad.length) {
|
||||
return res.status(415).json({ error: `Only PDF files are allowed: ${bad.map((b) => b.originalname).join(", ")}` });
|
||||
}
|
||||
|
||||
const form = new FormData();
|
||||
for (const f of files) {
|
||||
form.append("files", f.buffer, {
|
||||
filename: f.originalname,
|
||||
contentType: "application/pdf",
|
||||
knownLength: f.size,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await axios.post<{ rows: any[] }>(
|
||||
`${OCR_BASE_URL}/extract/pdf/json`,
|
||||
form,
|
||||
{
|
||||
headers: form.getHeaders(),
|
||||
maxBodyLength: Infinity,
|
||||
maxContentLength: Infinity,
|
||||
timeout: 60_000,
|
||||
}
|
||||
);
|
||||
return res.json({ rows: resp.data?.rows ?? [], headers: resp.data?.headers ?? [] });
|
||||
} catch (err: any) {
|
||||
const status = err?.response?.status;
|
||||
const detail = err?.response?.data?.detail || err?.message || "Unknown error";
|
||||
return res.status(500).json({ error: `PDF extraction failed${status ? ` (${status})` : ""}: ${detail}` });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// POST /api/payment-pdf/import
|
||||
// Accepts already-extracted rows and imports them into the database.
|
||||
router.post(
|
||||
"/import",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ error: "Unauthorized" });
|
||||
|
||||
const { rows } = req.body as { rows: PdfRow[] };
|
||||
if (!rows || !Array.isArray(rows) || rows.length === 0) {
|
||||
return res.status(400).json({ error: "No rows provided." });
|
||||
}
|
||||
|
||||
try {
|
||||
const paymentIds = await pdfImportService.importRows(rows, userId);
|
||||
return res.json({ message: "PDF rows imported successfully", paymentIds });
|
||||
} catch (err: any) {
|
||||
return res.status(500).json({ error: err?.message ?? "Import failed" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -442,12 +442,19 @@ router.patch(
|
||||
return res.status(400).json({ message: "Invalid mhPaidAmount value" });
|
||||
}
|
||||
|
||||
const existing = await prisma.payment.findUnique({ where: { id: paymentId } });
|
||||
if (!existing) return res.status(404).json({ message: "Payment not found" });
|
||||
|
||||
const totalBilled = Number(existing.totalBilled);
|
||||
const copayment = Number(existing.copayment ?? 0);
|
||||
const adjustment = Math.max(0, totalBilled - mhPaidAmount - copayment);
|
||||
|
||||
const updated = await prisma.payment.update({
|
||||
where: { id: paymentId },
|
||||
data: { mhPaidAmount, updatedById: userId },
|
||||
data: { mhPaidAmount, adjustment, updatedById: userId },
|
||||
});
|
||||
|
||||
return res.json({ ...updated, mhPaidAmount: Number(updated.mhPaidAmount) });
|
||||
return res.json({ ...updated, mhPaidAmount: Number(updated.mhPaidAmount), adjustment: Number(updated.adjustment) });
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Failed to update MH paid amount";
|
||||
return res.status(500).json({ message });
|
||||
@@ -469,12 +476,19 @@ router.patch(
|
||||
return res.status(400).json({ message: "Invalid copayment value" });
|
||||
}
|
||||
|
||||
const existing = await prisma.payment.findUnique({ where: { id: paymentId } });
|
||||
if (!existing) return res.status(404).json({ message: "Payment not found" });
|
||||
|
||||
const totalBilled = Number(existing.totalBilled);
|
||||
const mhPaidAmount = Number(existing.mhPaidAmount ?? 0);
|
||||
const adjustment = Math.max(0, totalBilled - mhPaidAmount - val);
|
||||
|
||||
const updated = await prisma.payment.update({
|
||||
where: { id: paymentId },
|
||||
data: { copayment: val, updatedById: userId },
|
||||
data: { copayment: val, adjustment, updatedById: userId },
|
||||
});
|
||||
|
||||
return res.json({ ...updated, copayment: Number(updated.copayment) });
|
||||
return res.json({ ...updated, copayment: Number(updated.copayment), adjustment: Number(updated.adjustment) });
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Failed to update copayment";
|
||||
return res.status(500).json({ message });
|
||||
|
||||
@@ -176,6 +176,154 @@ export async function updatePayment(
|
||||
|
||||
// handling full-ocr-payments-import
|
||||
|
||||
// ── helpers shared by the PDF import ──────────────────────────────────────────
|
||||
|
||||
function parsePdfDate(dateStr: string): Date {
|
||||
// "MM/DD/YY" → Date UTC
|
||||
if (!dateStr) return new Date();
|
||||
const parts = dateStr.split("/");
|
||||
if (parts.length !== 3) return new Date();
|
||||
const month = parseInt(parts[0]!, 10) - 1;
|
||||
const day = parseInt(parts[1]!, 10);
|
||||
const year = 2000 + parseInt(parts[2]!, 10);
|
||||
return new Date(Date.UTC(year, month, day));
|
||||
}
|
||||
|
||||
function parseTooth(tooth: string): { toothNumber: string | null; toothSurface: string | null } {
|
||||
if (!tooth?.trim()) return { toothNumber: null, toothSurface: null };
|
||||
const parts = tooth.trim().split(/\s+/);
|
||||
return {
|
||||
toothNumber: parts[0] || null,
|
||||
toothSurface: parts.slice(1).join(" ") || null,
|
||||
};
|
||||
}
|
||||
|
||||
function parsePdfName(fullName: string): { firstName: string; lastName: string } {
|
||||
// "LAST, FIRST" or "LAST LAST, FIRST"
|
||||
const commaIdx = fullName.indexOf(", ");
|
||||
if (commaIdx === -1) return { firstName: fullName, lastName: "Unknown" };
|
||||
return {
|
||||
lastName: fullName.slice(0, commaIdx).trim(),
|
||||
firstName: fullName.slice(commaIdx + 2).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export type PdfRow = {
|
||||
"Patient Name": string;
|
||||
"Member #": string;
|
||||
"ICN": string;
|
||||
"Submitted Code": string;
|
||||
"Paid Code": string;
|
||||
"Tooth": string;
|
||||
"Date of Service": string;
|
||||
"Submitted Amount": string;
|
||||
"Allowed Amount": string;
|
||||
"Paid Amount": string;
|
||||
"Source File": string;
|
||||
};
|
||||
|
||||
export const pdfImportService = {
|
||||
async importRows(rows: PdfRow[], userId: number) {
|
||||
// Group by Member # — one payment per patient
|
||||
const byMember = new Map<string, PdfRow[]>();
|
||||
for (const row of rows) {
|
||||
const key = row["Member #"] || row["Patient Name"];
|
||||
if (!byMember.has(key)) byMember.set(key, []);
|
||||
byMember.get(key)!.push(row);
|
||||
}
|
||||
|
||||
const paymentIds: number[] = [];
|
||||
|
||||
for (const [, patientRows] of byMember) {
|
||||
const first = patientRows[0]!;
|
||||
const memberNo = first["Member #"];
|
||||
const patientName = first["Patient Name"];
|
||||
|
||||
const totalBilled = patientRows.reduce(
|
||||
(s, r) => s.plus(new Decimal(r["Submitted Amount"] || 0)),
|
||||
new Decimal(0)
|
||||
);
|
||||
const totalMhPaid = patientRows.reduce(
|
||||
(s, r) => s.plus(new Decimal(r["Paid Amount"] || 0)),
|
||||
new Decimal(0)
|
||||
);
|
||||
const adjustment = Decimal.max(0, totalBilled.minus(totalMhPaid));
|
||||
|
||||
const paymentId = await prisma.$transaction(async (tx: typeof prisma) => {
|
||||
// 1. Find or create patient
|
||||
let patient = memberNo
|
||||
? await tx.patient.findFirst({ where: { insuranceId: memberNo } })
|
||||
: null;
|
||||
|
||||
if (!patient) {
|
||||
const { firstName, lastName } = parsePdfName(patientName);
|
||||
patient = await tx.patient.create({
|
||||
data: {
|
||||
firstName,
|
||||
lastName,
|
||||
insuranceId: memberNo || null,
|
||||
dateOfBirth: new Date(Date.UTC(1900, 0, 1)),
|
||||
gender: "",
|
||||
phone: "",
|
||||
userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Create payment
|
||||
const payment = await tx.payment.create({
|
||||
data: {
|
||||
patientId: patient.id,
|
||||
userId,
|
||||
totalBilled,
|
||||
totalPaid: new Decimal(0),
|
||||
totalAdjusted: new Decimal(0),
|
||||
totalDue: totalBilled,
|
||||
mhPaidAmount: totalMhPaid,
|
||||
adjustment,
|
||||
status: "PENDING",
|
||||
notes: `PDF import from ${first["Source File"] ?? "unknown file"}`,
|
||||
},
|
||||
});
|
||||
|
||||
// 3. Create one service line per row
|
||||
for (const row of patientRows) {
|
||||
const billed = new Decimal(row["Submitted Amount"] || 0);
|
||||
const mhPaid = new Decimal(row["Paid Amount"] || 0);
|
||||
const due = Decimal.max(0, billed.minus(mhPaid));
|
||||
const { toothNumber, toothSurface } = parseTooth(row["Tooth"]);
|
||||
|
||||
const allowed = new Decimal(row["Allowed Amount"] || 0);
|
||||
|
||||
await tx.serviceLine.create({
|
||||
data: {
|
||||
paymentId: payment.id,
|
||||
icn: row["ICN"] || null,
|
||||
procedureCode: row["Submitted Code"] || "",
|
||||
paidCode: row["Paid Code"] || null,
|
||||
allowedAmount: allowed,
|
||||
toothNumber,
|
||||
toothSurface,
|
||||
procedureDate: parsePdfDate(row["Date of Service"]),
|
||||
totalBilled: billed,
|
||||
totalPaid: mhPaid,
|
||||
totalAdjusted: new Decimal(0),
|
||||
totalDue: due,
|
||||
status: mhPaid.gt(0) ? "PARTIALLY_PAID" : "UNPAID",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return payment.id;
|
||||
});
|
||||
|
||||
paymentIds.push(paymentId);
|
||||
}
|
||||
|
||||
return paymentIds;
|
||||
},
|
||||
};
|
||||
|
||||
export const fullOcrPaymentService = {
|
||||
async importRows(rows: OcrRow[], userId: number) {
|
||||
const results: number[] = [];
|
||||
|
||||
Reference in New Issue
Block a user