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:
Gitead
2026-05-07 12:53:50 -04:00
parent e204d30ff6
commit dd0df4a435
76 changed files with 1570 additions and 96 deletions

View File

@@ -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);

View 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;

View File

@@ -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 });

View File

@@ -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[] = [];