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

View File

@@ -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"
},

View File

@@ -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>{" "}

View File

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

View File

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

View File

@@ -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 />

View File

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

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

View File

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