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[] = [];
|
||||
|
||||
@@ -85,6 +85,7 @@
|
||||
"vaul": "^1.1.2",
|
||||
"wouter": "^3.7.0",
|
||||
"ws": "^8.18.0",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^3.24.2",
|
||||
"zod-validation-error": "^3.4.0"
|
||||
},
|
||||
|
||||
@@ -504,6 +504,10 @@ export default function PaymentEditModal({
|
||||
<span className="bg-gray-100 text-gray-800 px-2 py-0.5 rounded-full font-medium">
|
||||
Claim #{payment.claimId.toString().padStart(4, "0")}
|
||||
</span>
|
||||
) : payment.notes?.startsWith("PDF import") ? (
|
||||
<span className="bg-blue-100 text-blue-800 px-2 py-0.5 rounded-full font-medium">
|
||||
PDF Import
|
||||
</span>
|
||||
) : (
|
||||
<span className="bg-gray-100 text-gray-800 px-2 py-0.5 rounded-full font-medium">
|
||||
OCR Imported Payment
|
||||
@@ -628,6 +632,28 @@ export default function PaymentEditModal({
|
||||
{line.procedureCode}
|
||||
</span>
|
||||
</p>
|
||||
{(line as any).icn && (
|
||||
<p>
|
||||
<span className="text-gray-500">ICN:</span>{" "}
|
||||
<span className="font-medium font-mono text-xs">
|
||||
{(line as any).icn}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{(line as any).paidCode && (line as any).paidCode !== line.procedureCode && (
|
||||
<p>
|
||||
<span className="text-gray-500">Paid Code:</span>{" "}
|
||||
<span className="font-medium">{(line as any).paidCode}</span>
|
||||
</p>
|
||||
)}
|
||||
{(line as any).allowedAmount != null && (
|
||||
<p>
|
||||
<span className="text-gray-500">Allowed:</span>{" "}
|
||||
<span className="font-medium text-blue-600">
|
||||
${Number((line as any).allowedAmount).toFixed(2)}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{(line as any).quad && (
|
||||
<p>
|
||||
<span className="text-gray-500">Quad:</span>{" "}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import * as React from "react";
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FileText, X, Download, DatabaseIcon } from "lucide-react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import { QK_PAYMENTS_RECENT_BASE } from "@/components/payments/payments-recent-table";
|
||||
import {
|
||||
MultipleFileUploadZone,
|
||||
MultipleFileUploadZoneHandle,
|
||||
} from "../file-upload/multiple-file-upload-zone";
|
||||
|
||||
type Row = Record<string, string>;
|
||||
type Header = Record<string, string>;
|
||||
|
||||
const COLUMNS: { key: string; label: string }[] = [
|
||||
{ key: "Patient Name", label: "Patient Name" },
|
||||
{ key: "Member #", label: "Member #" },
|
||||
{ key: "ICN", label: "ICN" },
|
||||
{ key: "Submitted Code", label: "Submitted Code" },
|
||||
{ key: "Paid Code", label: "Paid Code" },
|
||||
{ key: "Tooth", label: "Tooth" },
|
||||
{ key: "Date of Service", label: "Date of Service" },
|
||||
{ key: "Submitted Amount", label: "Submitted ($)" },
|
||||
{ key: "Allowed Amount", label: "Allowed ($)" },
|
||||
{ key: "Paid Amount", label: "Paid ($)" },
|
||||
];
|
||||
|
||||
const SUMMARY_FIELDS = [
|
||||
"Source File",
|
||||
"Payee ID",
|
||||
"Business NPI",
|
||||
"Run #",
|
||||
"RA #",
|
||||
"RA Date",
|
||||
"Claim Detail Amount",
|
||||
"Claim Adjustment Amount",
|
||||
"Misc. Adjustment Amount",
|
||||
"Payment Amount",
|
||||
];
|
||||
|
||||
// Convert a string value to a number if it looks numeric, otherwise keep as string.
|
||||
// Handles: "36.00" → 36, "$14,369.00" → 14369, "($3,107.39)" → -3107.39
|
||||
function toExcelValue(val: string): string | number {
|
||||
if (!val) return "";
|
||||
const stripped = val
|
||||
.replace(/\$/g, "")
|
||||
.replace(/,/g, "")
|
||||
.replace(/^\((.+)\)$/, "-$1") // (3,107.39) → -3107.39
|
||||
.trim();
|
||||
const num = Number(stripped);
|
||||
return stripped !== "" && !isNaN(num) ? num : val;
|
||||
}
|
||||
|
||||
function downloadExcel(rows: Row[], headers: Header[], sourceFileName: string) {
|
||||
const wb = XLSX.utils.book_new();
|
||||
|
||||
// Sheet 1 — RA Summary (one row per uploaded PDF)
|
||||
if (headers.length > 0) {
|
||||
const summaryData = headers.map((h) => {
|
||||
const out: Record<string, string | number> = {};
|
||||
SUMMARY_FIELDS.forEach((f) => { out[f] = toExcelValue(h[f] ?? ""); });
|
||||
return out;
|
||||
});
|
||||
const wsSummary = XLSX.utils.json_to_sheet(summaryData, {
|
||||
header: SUMMARY_FIELDS,
|
||||
});
|
||||
XLSX.utils.book_append_sheet(wb, wsSummary, "RA Summary");
|
||||
}
|
||||
|
||||
// Sheet 2 — Payment Data (one row per ICN)
|
||||
const data = rows.map((row) => {
|
||||
const out: Record<string, string | number> = {};
|
||||
COLUMNS.forEach(({ key, label }) => { out[label] = toExcelValue(row[key] ?? ""); });
|
||||
return out;
|
||||
});
|
||||
const wsData = XLSX.utils.json_to_sheet(data, {
|
||||
header: COLUMNS.map((c) => c.label),
|
||||
});
|
||||
XLSX.utils.book_append_sheet(wb, wsData, "Payment Data");
|
||||
|
||||
const name = sourceFileName.replace(/\.pdf$/i, "") || "payment_extract";
|
||||
XLSX.writeFile(wb, `${name}.xlsx`);
|
||||
}
|
||||
|
||||
export default function PaymentUploadDocumentsBlock() {
|
||||
const MAX_FILES = 10;
|
||||
const ACCEPTED_FILE_TYPES = "application/pdf";
|
||||
|
||||
const uploadZoneRef = React.useRef<MultipleFileUploadZoneHandle | null>(null);
|
||||
const [filesForUI, setFilesForUI] = React.useState<File[]>([]);
|
||||
const [isUploading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
// ── shared extract helper ────────────────────────────────────────────────
|
||||
const extractData = async (files: File[]): Promise<{ rows: Row[]; headers: Header[] }> => {
|
||||
const formData = new FormData();
|
||||
files.forEach((file) => formData.append("files", file, file.name));
|
||||
const res = await apiRequest("POST", "/api/payment-pdf/extract", formData);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err?.error || "Failed to extract PDF data");
|
||||
}
|
||||
const data = (await res.json()) as { rows: Row[]; headers: Header[] };
|
||||
return { rows: data.rows ?? [], headers: data.headers ?? [] };
|
||||
};
|
||||
|
||||
const getFiles = () => {
|
||||
const files = uploadZoneRef.current?.getFiles() ?? [];
|
||||
if (!files.length) {
|
||||
setError("Please upload at least one PDF file.");
|
||||
return null;
|
||||
}
|
||||
setError(null);
|
||||
return files;
|
||||
};
|
||||
|
||||
// ── Extract & Download ───────────────────────────────────────────────────
|
||||
const downloadMutation = useMutation({
|
||||
mutationFn: async (files: File[]) => {
|
||||
const { rows, headers } = await extractData(files);
|
||||
return { rows, headers, sourceName: files[0]?.name ?? "payment_extract" };
|
||||
},
|
||||
onSuccess: ({ rows, headers, sourceName }) => {
|
||||
if (rows.length === 0) {
|
||||
toast({ title: "No data", description: "No rows were extracted from the PDF.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
downloadExcel(rows, headers, sourceName);
|
||||
toast({ title: "Downloaded", description: `${rows.length} rows exported to Excel.` });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast({ title: "Error", description: err.message, variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
// ── Extract & Import ─────────────────────────────────────────────────────
|
||||
const importMutation = useMutation({
|
||||
mutationFn: async (files: File[]) => {
|
||||
const { rows } = await extractData(files);
|
||||
if (rows.length === 0) throw new Error("No rows extracted from the PDF.");
|
||||
const res = await apiRequest("POST", "/api/payment-pdf/import", { rows });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err?.error || "Import failed");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: async (data) => {
|
||||
toast({
|
||||
title: "Imported",
|
||||
description: `${data.paymentIds?.length ?? 0} payment(s) created successfully.`,
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: QK_PAYMENTS_RECENT_BASE });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast({ title: "Error", description: err.message, variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
const handleZoneFilesChange = React.useCallback((files: File[]) => {
|
||||
setFilesForUI(files);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const removeUploadedFile = React.useCallback((index: number) => {
|
||||
uploadZoneRef.current?.removeFile(index);
|
||||
}, []);
|
||||
|
||||
const busy = downloadMutation.isPending || importMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Upload Payment Documents</CardTitle>
|
||||
<CardDescription>
|
||||
Upload up to 10 MassHealth remittance PDFs. Extract and download as
|
||||
Excel, or import directly into the database.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="bg-gray-100 p-4 rounded-md space-y-4">
|
||||
<MultipleFileUploadZone
|
||||
ref={uploadZoneRef}
|
||||
isUploading={isUploading}
|
||||
acceptedFileTypes={ACCEPTED_FILE_TYPES}
|
||||
maxFiles={MAX_FILES}
|
||||
onFilesChange={handleZoneFilesChange}
|
||||
/>
|
||||
|
||||
{filesForUI.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-2">
|
||||
Uploaded ({filesForUI.length}/{MAX_FILES})
|
||||
</p>
|
||||
<ul className="space-y-2 max-h-48 overflow-auto">
|
||||
{filesForUI.map((file, idx) => (
|
||||
<li
|
||||
key={`${file.name}-${file.size}-${idx}`}
|
||||
className="flex items-center justify-between border rounded-md p-2 bg-white"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="h-6 w-6 text-blue-500" />
|
||||
<div className="text-left">
|
||||
<p className="font-medium text-blue-700 truncate">{file.name}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{(file.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => removeUploadedFile(idx)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex gap-3">
|
||||
{/* Extract & Download */}
|
||||
<Button
|
||||
className="flex-1 h-12 gap-2 bg-blue-600 hover:bg-blue-700 text-white"
|
||||
type="button"
|
||||
disabled={busy || !filesForUI.length}
|
||||
onClick={() => {
|
||||
const files = getFiles();
|
||||
if (files) downloadMutation.mutate(files);
|
||||
}}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{downloadMutation.isPending ? "Extracting…" : "Extract & Download"}
|
||||
</Button>
|
||||
|
||||
{/* Extract & Import */}
|
||||
<Button
|
||||
className="flex-1 h-12 gap-2 bg-teal-600 hover:bg-teal-700 text-white"
|
||||
type="button"
|
||||
disabled={busy || !filesForUI.length}
|
||||
onClick={() => {
|
||||
const files = getFiles();
|
||||
if (files) importMutation.mutate(files);
|
||||
}}
|
||||
>
|
||||
<DatabaseIcon className="h-4 w-4" />
|
||||
{importMutation.isPending ? "Importing…" : "Extract & Import"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -103,8 +103,6 @@ export default function PaymentsRecentTable({
|
||||
const [editingMhPaidValue, setEditingMhPaidValue] = useState<string>("");
|
||||
const [editingCopaymentId, setEditingCopaymentId] = useState<number | null>(null);
|
||||
const [editingCopaymentValue, setEditingCopaymentValue] = useState<string>("");
|
||||
const [editingAdjustmentId, setEditingAdjustmentId] = useState<number | null>(null);
|
||||
const [editingAdjustmentValue, setEditingAdjustmentValue] = useState<string>("");
|
||||
|
||||
const [isRevertOpen, setIsRevertOpen] = useState(false);
|
||||
const [revertPaymentId, setRevertPaymentId] = useState<number | null>(null);
|
||||
@@ -394,6 +392,12 @@ export default function PaymentsRecentTable({
|
||||
const [isUnvoidOpen, setIsUnvoidOpen] = useState(false);
|
||||
const [unvoidPaymentId, setUnvoidPaymentId] = useState<number | null>(null);
|
||||
|
||||
const [isPaidInFullOpen, setIsPaidInFullOpen] = useState(false);
|
||||
const [paidInFullPaymentId, setPaidInFullPaymentId] = useState<number | null>(null);
|
||||
|
||||
const [isRevertPaidOpen, setIsRevertPaidOpen] = useState(false);
|
||||
const [revertPaidPaymentId, setRevertPaidPaymentId] = useState<number | null>(null);
|
||||
|
||||
const handleConfirmVoid = () => {
|
||||
if (!voidPaymentId) return;
|
||||
handleVoid(voidPaymentId);
|
||||
@@ -408,6 +412,20 @@ export default function PaymentsRecentTable({
|
||||
setIsUnvoidOpen(false);
|
||||
};
|
||||
|
||||
const handleConfirmPaidInFull = () => {
|
||||
if (!paidInFullPaymentId) return;
|
||||
updatePaymentStatusMutation.mutate({ paymentId: paidInFullPaymentId, status: "PAID" });
|
||||
setPaidInFullPaymentId(null);
|
||||
setIsPaidInFullOpen(false);
|
||||
};
|
||||
|
||||
const handleConfirmRevertPaid = () => {
|
||||
if (!revertPaidPaymentId) return;
|
||||
updatePaymentStatusMutation.mutate({ paymentId: revertPaidPaymentId, status: "PENDING" });
|
||||
setRevertPaidPaymentId(null);
|
||||
setIsRevertPaidOpen(false);
|
||||
};
|
||||
|
||||
// Pagination
|
||||
useEffect(() => {
|
||||
if (onPageChange) onPageChange(currentPage);
|
||||
@@ -660,9 +678,13 @@ export default function PaymentsRecentTable({
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
<span className="text-sm font-mono">
|
||||
{payment.claim?.claimNumber ?? <span className="text-gray-400">—</span>}
|
||||
</span>
|
||||
{payment.claim?.claimNumber ? (
|
||||
<span className="text-sm font-mono">{payment.claim.claimNumber}</span>
|
||||
) : payment.notes?.startsWith("PDF import") ? (
|
||||
<span className="text-xs font-medium bg-blue-100 text-blue-800 px-2 py-0.5 rounded-full">PDF Import</span>
|
||||
) : (
|
||||
<span className="text-gray-400">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
@@ -717,37 +739,19 @@ export default function PaymentsRecentTable({
|
||||
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{(() => {
|
||||
// VOID and DENIED are manual decisions — show as-is
|
||||
if (payment.status === "VOID" || payment.status === "DENIED" || payment.status === "OVERPAID") {
|
||||
const { label, color, icon } = getStatusInfo(payment.status);
|
||||
return (
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded-full ${color}`}>
|
||||
<span className="flex items-center">{icon}{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// Compute status from numbers
|
||||
if (totalDue === 0) {
|
||||
return (
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-teal-100 text-teal-800">
|
||||
<span className="flex items-center"><CheckCircle className="h-3 w-3 mr-1" />Paid in Full</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (totalCollected > 0) {
|
||||
return (
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-blue-100 text-blue-800">
|
||||
<span className="flex items-center"><DollarSign className="h-3 w-3 mr-1" />Partially Paid</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-red-100 text-red-800">
|
||||
<span className="flex items-center"><Clock className="h-3 w-3 mr-1" />Pending</span>
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
{payment.status === "VOID" ? (
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-gray-100 text-gray-800 flex items-center">
|
||||
<Ban className="h-3 w-3 mr-1" />Void
|
||||
</span>
|
||||
) : payment.status === "PAID" ? (
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-teal-100 text-teal-800 flex items-center">
|
||||
<CheckCircle className="h-3 w-3 mr-1" />Paid in Full
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-yellow-100 text-yellow-800 flex items-center">
|
||||
<Clock className="h-3 w-3 mr-1" />Balance
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
@@ -875,50 +879,11 @@ export default function PaymentsRecentTable({
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{/* Adjustment */}
|
||||
{/* Adjustment — auto-computed: totalBilled - mhPaid - copayment */}
|
||||
<TableCell>
|
||||
{editingAdjustmentId === payment.id ? (
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
autoFocus
|
||||
className="w-24 border border-blue-400 rounded px-1 py-0.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-300"
|
||||
value={editingAdjustmentValue}
|
||||
onChange={(e) => setEditingAdjustmentValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") e.currentTarget.blur();
|
||||
else if (e.key === "Escape") setEditingAdjustmentId(null);
|
||||
}}
|
||||
onBlur={async () => {
|
||||
const val = parseFloat(editingAdjustmentValue);
|
||||
if (!isNaN(val) && val >= 0) {
|
||||
try {
|
||||
const res = await apiRequest("PATCH", `/api/payments/${payment.id}/adjustment`, { adjustment: val });
|
||||
if (res.ok) {
|
||||
await queryClient.invalidateQueries({ queryKey: QK_PAYMENTS_RECENT_BASE });
|
||||
} else {
|
||||
toast({ title: "Error", description: "Failed to save adjustment.", variant: "destructive" });
|
||||
}
|
||||
} catch {
|
||||
toast({ title: "Error", description: "Failed to save adjustment.", variant: "destructive" });
|
||||
}
|
||||
}
|
||||
setEditingAdjustmentId(null);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="text-sm font-medium text-orange-700 cursor-pointer hover:underline hover:text-orange-900"
|
||||
title="Click to edit"
|
||||
onClick={() => {
|
||||
setEditingAdjustmentId(payment.id);
|
||||
setEditingAdjustmentValue(Number(payment.adjustment ?? 0).toFixed(2));
|
||||
}}
|
||||
>
|
||||
${Number(payment.adjustment ?? 0).toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm font-medium text-orange-700">
|
||||
${adjustment.toFixed(2)}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-right">
|
||||
@@ -949,6 +914,38 @@ export default function PaymentsRecentTable({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Paid in Full — only when not already paid or voided */}
|
||||
{payment.status !== "PAID" &&
|
||||
payment.status !== "VOID" &&
|
||||
payment.status !== "DENIED" && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="bg-teal-600 hover:bg-teal-700 text-white"
|
||||
onClick={() => {
|
||||
setPaidInFullPaymentId(payment.id);
|
||||
setIsPaidInFullOpen(true);
|
||||
}}
|
||||
>
|
||||
Paid in Full
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Revert — only when already Paid in Full */}
|
||||
{payment.status === "PAID" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-teal-400 text-teal-700 hover:bg-teal-50"
|
||||
onClick={() => {
|
||||
setRevertPaidPaymentId(payment.id);
|
||||
setIsRevertPaidOpen(true);
|
||||
}}
|
||||
>
|
||||
Revert
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Show Void unless already voided or denied */}
|
||||
{payment.status !== "VOID" &&
|
||||
payment.status !== "DENIED" && (
|
||||
@@ -1010,6 +1007,28 @@ export default function PaymentsRecentTable({
|
||||
onCancel={() => setIsRevertOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Revert Paid in Full Confirmation Dialog */}
|
||||
<ConfirmationDialog
|
||||
isOpen={isRevertPaidOpen}
|
||||
title="Revert Paid in Full?"
|
||||
message="This will revert the status back to Balance. The amounts stay unchanged. Continue?"
|
||||
confirmLabel="Revert"
|
||||
confirmColor="bg-yellow-600 hover:bg-yellow-700"
|
||||
onConfirm={handleConfirmRevertPaid}
|
||||
onCancel={() => setIsRevertPaidOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Paid in Full Confirmation Dialog */}
|
||||
<ConfirmationDialog
|
||||
isOpen={isPaidInFullOpen}
|
||||
title="Mark as Paid in Full?"
|
||||
message="This will set the status to Paid in Full and close the balance for this payment. Continue?"
|
||||
confirmLabel="Paid in Full"
|
||||
confirmColor="bg-teal-600 hover:bg-teal-700"
|
||||
onConfirm={handleConfirmPaidInFull}
|
||||
onCancel={() => setIsPaidInFullOpen(false)}
|
||||
/>
|
||||
|
||||
{/* NEW: Void Confirmation Dialog */}
|
||||
<ConfirmationDialog
|
||||
isOpen={isVoidOpen}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { Calendar } from "@/components/ui/calendar";
|
||||
import PaymentsRecentTable from "@/components/payments/payments-recent-table";
|
||||
import PaymentsOfPatientModal from "@/components/payments/payments-of-patient-table";
|
||||
import PaymentOCRBlock from "@/components/payments/payment-ocr-block";
|
||||
import PaymentUploadDocumentsBlock from "@/components/payments/payment-upload-documents-block";
|
||||
import { useLocation } from "wouter";
|
||||
import { Patient, PaymentWithExtras } from "@repo/db/types";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
@@ -330,6 +331,9 @@ export default function PaymentsPage() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Upload Payment Documents Section */}
|
||||
<PaymentUploadDocumentsBlock />
|
||||
|
||||
{/* OCR Image Upload Section*/}
|
||||
<PaymentOCRBlock />
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
from complete_pipeline_adapter import process_images_to_rows,rows_to_csv_bytes
|
||||
from pdf_extractor import extract_ra_pdf
|
||||
|
||||
app = FastAPI(
|
||||
title="Payment OCR Services API",
|
||||
@@ -35,7 +36,8 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
ALLOWED_EXTS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".bmp"}
|
||||
ALLOWED_EXTS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".bmp"}
|
||||
ALLOWED_PDF_EXTS = {".pdf"}
|
||||
|
||||
# -------------------------------------------------
|
||||
# Health + status
|
||||
@@ -122,6 +124,38 @@ async def extract_csvtext(files: List[UploadFile] = File(...)):
|
||||
async with lock:
|
||||
active_jobs -= 1
|
||||
|
||||
@app.post("/extract/pdf/json")
|
||||
async def extract_pdf_json(files: List[UploadFile] = File(...)):
|
||||
bad = [f.filename for f in files if os.path.splitext(f.filename or "")[1].lower() not in ALLOWED_PDF_EXTS]
|
||||
if bad:
|
||||
raise HTTPException(status_code=415, detail=f"Only PDF files allowed. Got: {', '.join(bad)}")
|
||||
|
||||
async with lock:
|
||||
global waiting_jobs
|
||||
waiting_jobs += 1
|
||||
|
||||
async with semaphore:
|
||||
async with lock:
|
||||
waiting_jobs -= 1
|
||||
global active_jobs
|
||||
active_jobs += 1
|
||||
|
||||
try:
|
||||
all_rows = []
|
||||
all_headers = []
|
||||
for f in files:
|
||||
blob = await f.read()
|
||||
result = extract_ra_pdf(blob, f.filename or "upload.pdf")
|
||||
all_rows.extend(result["rows"])
|
||||
all_headers.append(result["header"])
|
||||
return JSONResponse(content={"rows": all_rows, "headers": all_headers})
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"PDF extraction error: {e}")
|
||||
finally:
|
||||
async with lock:
|
||||
active_jobs -= 1
|
||||
|
||||
|
||||
@app.post("/extract/csv")
|
||||
async def extract_csv(files: List[UploadFile] = File(...), filename: Optional[str] = None):
|
||||
_validate_files(files)
|
||||
|
||||
224
apps/PaymentOCRService/pdf_extractor.py
Normal file
224
apps/PaymentOCRService/pdf_extractor.py
Normal file
@@ -0,0 +1,224 @@
|
||||
import io
|
||||
import re
|
||||
import pdfplumber
|
||||
|
||||
DCODE_RE = re.compile(r'^D\d{4}$')
|
||||
MEMBER_RE = re.compile(r'\b(\d{12})\b') # MassHealth member IDs are always 12 digits
|
||||
|
||||
# ── Page-1 header patterns ────────────────────────────────────────────────────
|
||||
_H = {
|
||||
"Payee ID": re.compile(r'Payee ID:\s*(\S+)'),
|
||||
"Business NPI": re.compile(r'Business NPI:\s*(\S+)'),
|
||||
"Run #": re.compile(r'Run #:\s*(\S+)'),
|
||||
"RA #": re.compile(r'RA #:\s*(\S+)'),
|
||||
"RA Date": re.compile(r'RA Date:\s*(\S+)'),
|
||||
"Claim Detail Amount": re.compile(r'Claim Detail Amount:\s*([\$\d,\.]+)'),
|
||||
"Claim Adjustment Amount": re.compile(r'Claim Adjustment Amount:\s*([\$\(\)\d,\.]+)'),
|
||||
"Misc. Adjustment Amount": re.compile(r'Misc\. Adjustment Amount:\s*([\$\(\)\d,\.]+)'),
|
||||
"Payment Amount": re.compile(r'Payment Amount:\s*([\$\d,\.]+)'),
|
||||
}
|
||||
|
||||
|
||||
def extract_ra_header(pdf_bytes: bytes, filename: str) -> dict:
|
||||
"""Extract the cover-page summary (Payee ID, RA #, Payment Amount, etc.)."""
|
||||
header: dict[str, str] = {"Source File": filename}
|
||||
with pdfplumber.open(io.BytesIO(pdf_bytes)) as pdf:
|
||||
# Header info lives on pages 1 and 2 — scan both to be safe
|
||||
for page in pdf.pages[:2]:
|
||||
text = page.extract_text() or ""
|
||||
for field, pattern in _H.items():
|
||||
if field not in header or not header[field]:
|
||||
m = pattern.search(text)
|
||||
if m:
|
||||
header[field] = m.group(1).strip()
|
||||
return header
|
||||
|
||||
|
||||
def _c(val) -> str:
|
||||
return str(val).replace("\n", " ").strip() if val else ""
|
||||
|
||||
|
||||
def _amt(val: str) -> str:
|
||||
return val.replace("$", "").replace(",", "").strip() if val else ""
|
||||
|
||||
|
||||
def _col(headers: list[str], *keywords) -> int | None:
|
||||
for i, h in enumerate(headers):
|
||||
hl = h.lower()
|
||||
if all(k.lower() in hl for k in keywords):
|
||||
return i
|
||||
return None
|
||||
|
||||
|
||||
def _find_header_row(table: list[list]) -> tuple[int | None, list[str]]:
|
||||
for i, row in enumerate(table):
|
||||
# Skip merged context rows (only one non-None cell)
|
||||
if sum(1 for c in row if c) <= 1:
|
||||
continue
|
||||
flat = [_c(c) for c in row]
|
||||
j = " ".join(flat).lower()
|
||||
if "patient name" in j and "icn" in j and "code" not in j:
|
||||
return i, flat
|
||||
if ("submitted" in j and "code" in j) or "paid code" in j:
|
||||
return i, flat
|
||||
return None, []
|
||||
|
||||
|
||||
def _is_summary(h: list[str]) -> bool:
|
||||
j = " ".join(h).lower()
|
||||
return "patient name" in j and "icn" in j and "code" not in j
|
||||
|
||||
|
||||
def _is_detail(h: list[str]) -> bool:
|
||||
j = " ".join(h).lower()
|
||||
return ("submitted" in j and "code" in j) or "paid code" in j
|
||||
|
||||
|
||||
def _merge_headers(table: list[list], hdr_idx: int) -> list[str]:
|
||||
n = max(len(r) for r in table[: hdr_idx + 1])
|
||||
merged = []
|
||||
for ci in range(n):
|
||||
parts = [_c(table[ri][ci]) for ri in range(hdr_idx + 1)
|
||||
if ci < len(table[ri]) and table[ri][ci]]
|
||||
merged.append(" ".join(parts))
|
||||
return merged
|
||||
|
||||
|
||||
# ── Pass 1: summary pages → {icn: patient_name} ──────────────────────────────
|
||||
|
||||
def _build_patient_map(pdf) -> dict[str, str]:
|
||||
patient_map: dict[str, str] = {}
|
||||
|
||||
for page in pdf.pages:
|
||||
for tobj in page.find_tables():
|
||||
table = tobj.extract()
|
||||
if not table or len(table) < 2:
|
||||
continue
|
||||
hdr_idx, headers = _find_header_row(table)
|
||||
if hdr_idx is None or not _is_summary(headers):
|
||||
continue
|
||||
|
||||
pi = _col(headers, "Patient Name")
|
||||
ii = _col(headers, "ICN")
|
||||
if pi is None or ii is None:
|
||||
continue
|
||||
|
||||
for row in table[hdr_idx + 1:]:
|
||||
if not row:
|
||||
continue
|
||||
patient = _c(row[pi]) if pi < len(row) else ""
|
||||
icn = _c(row[ii]) if ii < len(row) else ""
|
||||
if not patient or not icn:
|
||||
continue
|
||||
if "Total" in patient or not icn.replace(" ", "").isdigit():
|
||||
continue
|
||||
patient_map[icn] = patient
|
||||
|
||||
return patient_map
|
||||
|
||||
|
||||
# ── Pass 2: detail pages → {icn: procedure_dict} ─────────────────────────────
|
||||
|
||||
def _build_detail_map(pdf) -> dict[str, dict]:
|
||||
detail_map: dict[str, dict] = {}
|
||||
|
||||
for page in pdf.pages:
|
||||
for tobj in page.find_tables():
|
||||
table = tobj.extract()
|
||||
if not table or len(table) < 2:
|
||||
continue
|
||||
hdr_idx, headers = _find_header_row(table)
|
||||
if hdr_idx is None or not _is_detail(headers):
|
||||
continue
|
||||
|
||||
# ICN is in the merged first cell (row 0)
|
||||
context_cell = str(table[0][0]) if table[0] and table[0][0] else ""
|
||||
icn_m = re.search(r'ICN:\s*(\d+)', context_cell)
|
||||
member_m = MEMBER_RE.search(context_cell)
|
||||
icn = icn_m.group(1) if icn_m else ""
|
||||
member = member_m.group(1) if member_m else ""
|
||||
if not icn:
|
||||
continue
|
||||
|
||||
h = _merge_headers(table, hdr_idx)
|
||||
|
||||
sub_code_i = _col(h, "Submitted", "Code")
|
||||
paid_code_i = _col(h, "Paid", "Code")
|
||||
tooth_i = _col(h, "Tooth")
|
||||
date_i = _col(h, "Date")
|
||||
allowed_i = _col(h, "Allowed")
|
||||
|
||||
sub_amt_i = paid_amt_i = None
|
||||
for i, col_h in enumerate(h):
|
||||
lh = col_h.lower()
|
||||
if "submitted" in lh and "code" not in lh and sub_amt_i is None:
|
||||
sub_amt_i = i
|
||||
if "paid" in lh and "code" not in lh and ("amount" in lh or paid_amt_i is None):
|
||||
paid_amt_i = i
|
||||
|
||||
for row in table[hdr_idx + 1:]:
|
||||
if not row:
|
||||
continue
|
||||
cdt = _c(row[sub_code_i]) if sub_code_i is not None and sub_code_i < len(row) else ""
|
||||
if not DCODE_RE.match(cdt):
|
||||
continue
|
||||
|
||||
paid_code = _c(row[paid_code_i]) if paid_code_i is not None and paid_code_i < len(row) else ""
|
||||
tooth = _c(row[tooth_i]) if tooth_i is not None and tooth_i < len(row) else ""
|
||||
date = _c(row[date_i]) if date_i is not None and date_i < len(row) else ""
|
||||
sub_a = _amt(_c(row[sub_amt_i])) if sub_amt_i is not None and sub_amt_i < len(row) else ""
|
||||
allow_a = _amt(_c(row[allowed_i])) if allowed_i is not None and allowed_i < len(row) else ""
|
||||
paid_a = _amt(_c(row[paid_amt_i])) if paid_amt_i is not None and paid_amt_i < len(row) else ""
|
||||
|
||||
detail_map[icn] = {
|
||||
"Member #": member,
|
||||
"Submitted Code": cdt,
|
||||
"Paid Code": paid_code,
|
||||
"Tooth": tooth,
|
||||
"Date of Service": date,
|
||||
"Submitted Amount": sub_a,
|
||||
"Allowed Amount": allow_a,
|
||||
"Paid Amount": paid_a,
|
||||
}
|
||||
|
||||
return detail_map
|
||||
|
||||
|
||||
# ── Main: join on ICN ─────────────────────────────────────────────────────────
|
||||
|
||||
def extract_ra_pdf(pdf_bytes: bytes, filename: str) -> dict:
|
||||
"""
|
||||
Two-pass extraction of a MassHealth Remittance Advice PDF.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"header": { Payee ID, Business NPI, Run #, RA #, RA Date,
|
||||
Claim Detail Amount, Claim Adjustment Amount,
|
||||
Misc. Adjustment Amount, Payment Amount },
|
||||
"rows": [ one dict per ICN … ]
|
||||
}
|
||||
"""
|
||||
header = extract_ra_header(pdf_bytes, filename)
|
||||
|
||||
with pdfplumber.open(io.BytesIO(pdf_bytes)) as pdf:
|
||||
patient_map = _build_patient_map(pdf)
|
||||
detail_map = _build_detail_map(pdf)
|
||||
|
||||
rows = []
|
||||
for icn, patient_name in patient_map.items():
|
||||
detail = detail_map.get(icn, {})
|
||||
rows.append({
|
||||
"Patient Name": patient_name,
|
||||
"Member #": detail.get("Member #", ""),
|
||||
"ICN": icn,
|
||||
"Submitted Code": detail.get("Submitted Code", ""),
|
||||
"Paid Code": detail.get("Paid Code", ""),
|
||||
"Tooth": detail.get("Tooth", ""),
|
||||
"Date of Service": detail.get("Date of Service", ""),
|
||||
"Submitted Amount": detail.get("Submitted Amount", ""),
|
||||
"Allowed Amount": detail.get("Allowed Amount", ""),
|
||||
"Paid Amount": detail.get("Paid Amount", ""),
|
||||
"Source File": filename,
|
||||
})
|
||||
|
||||
return {"header": header, "rows": rows}
|
||||
@@ -24,3 +24,4 @@ typing_extensions==4.15.0
|
||||
tzdata==2025.2
|
||||
uvicorn==0.35.0
|
||||
python-multipart==0.0.20
|
||||
pdfplumber==0.11.4
|
||||
|
||||
Reference in New Issue
Block a user