feat: MassHealth PDF import auto-pays full balance + patient name fix

- PDF import now marks payments as PAID when MassHealth patient's
  mhPaidAmount >= totalBilled (no patient balance)
- Newly created patients from MH vouchers get insuranceProvider = 'MassHealth'
- Existing patients with blank insuranceProvider get it filled on import
- Fix: update patient name from PDF if existing record has empty name
- Various frontend/selenium/route updates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 00:16:31 -04:00
parent 9efe5c8469
commit b7e06adf2f
15 changed files with 611 additions and 448 deletions

View File

@@ -16,6 +16,51 @@ import { prisma } from "@repo/db/client";
import { PaymentStatusSchema } from "@repo/db/types";
import * as paymentService from "../services/paymentService";
import { callPythonSync } from "../queue/processors/_shared";
import fs from "fs";
import path from "path";
import axios from "axios";
import FormData from "form-data";
const VOUCHER_DIR = path.join(__dirname, "..", "..", "uploads", "MHVoucher");
const IMPORTED_LOG = path.join(VOUCHER_DIR, "imported_vouchers.json");
const OCR_BASE_URL = process.env.OCR_SERVICE_BASE_URL || "http://localhost:5003";
function loadImportedVouchers(): Set<string> {
try {
if (!fs.existsSync(IMPORTED_LOG)) return new Set();
const data = JSON.parse(fs.readFileSync(IMPORTED_LOG, "utf-8"));
return new Set(data.imported ?? []);
} catch {
return new Set();
}
}
function saveImportedVoucher(voucher: string) {
const existing = loadImportedVouchers();
existing.add(voucher);
fs.writeFileSync(IMPORTED_LOG, JSON.stringify({ imported: [...existing].sort() }, null, 2));
}
async function extractAndImportVoucherPdf(filePath: string, userId: number): Promise<{ paymentIds: number[]; rowCount: number }> {
const buffer = fs.readFileSync(filePath);
const filename = path.basename(filePath);
const form = new FormData();
form.append("files", buffer, { filename, contentType: "application/pdf", knownLength: buffer.length });
const resp = await axios.post<{ rows: any[] }>(
`${OCR_BASE_URL}/extract/pdf/json`,
form,
{ headers: form.getHeaders(), maxBodyLength: Infinity, maxContentLength: Infinity, timeout: 120_000 }
);
const rows = resp.data?.rows ?? [];
console.log(`[mh-batch] Extracted ${rows.length} rows from ${filename}`);
if (rows.length === 0) return { paymentIds: [], rowCount: 0 };
const paymentIds = await paymentService.pdfImportService.importRows(rows, userId);
return { paymentIds, rowCount: rows.length };
}
const paymentFilterSchema = z.object({
from: z.string().datetime(),
@@ -192,6 +237,76 @@ router.post(
}
);
// POST /api/payments/mh-batch-payment-check
router.post(
"/mh-batch-payment-check",
async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const { fromDate, toDate } = req.body;
const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(userId, "MH");
if (!credentials) {
return res.status(404).json({
message: "No MassHealth credentials found. Please add them in Settings.",
});
}
const seleniumResult = await callPythonSync("/mh-batch-payment-check", {
data: {
massdhpUsername: credentials.username,
massdhpPassword: credentials.password,
fromDate,
toDate,
},
});
// --- PDF import phase ---
const alreadyImported = loadImportedVouchers();
const allPdfs = fs.existsSync(VOUCHER_DIR)
? fs.readdirSync(VOUCHER_DIR).filter((f) => f.endsWith(".pdf") && !f.startsWith("remittance_search_"))
: [];
const newPdfs = allPdfs.filter((f) => !alreadyImported.has(f.replace(".pdf", "")));
console.log(`[mh-batch] ${allPdfs.length} voucher PDFs total, ${newPdfs.length} new to import`);
const importResults: { voucher: string; paymentIds: number[]; rowCount: number; error?: string }[] = [];
for (const pdfFile of newPdfs) {
const voucher = pdfFile.replace(".pdf", "");
const filePath = path.join(VOUCHER_DIR, pdfFile);
console.log(`[mh-batch] Extracting and importing: ${voucher}`);
try {
const { paymentIds, rowCount } = await extractAndImportVoucherPdf(filePath, userId);
saveImportedVoucher(voucher);
importResults.push({ voucher, paymentIds, rowCount });
console.log(`[mh-batch] ✓ ${voucher}: ${rowCount} rows → ${paymentIds.length} payment(s)`);
} catch (err: any) {
const errMsg = err?.response?.data?.detail ?? err.message ?? "Unknown error";
console.error(`[mh-batch] ✗ ${voucher}: ${errMsg}`);
importResults.push({ voucher, paymentIds: [], rowCount: 0, error: errMsg });
}
}
const succeeded = importResults.filter((r) => !r.error);
const failed = importResults.filter((r) => r.error);
return res.json({
...seleniumResult,
importResults,
importSummary: newPdfs.length === 0
? "All PDFs already imported."
: `${succeeded.length} of ${newPdfs.length} PDFs imported successfully${failed.length ? `, ${failed.length} failed` : ""}.`,
});
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "MH batch payment check failed";
return res.status(500).json({ message });
}
}
);
// POST /api/payments/:claimId
router.post("/:claimId", async (req: Request, res: Response): Promise<any> => {
try {

View File

@@ -261,27 +261,44 @@ export const pdfImportService = {
data: {
firstName,
lastName,
insuranceId: memberNo || null,
insuranceId: memberNo || null,
insuranceProvider: "MassHealth",
dateOfBirth: new Date(Date.UTC(1900, 0, 1)),
gender: "",
phone: "",
userId,
},
});
} else {
const updates: Record<string, string> = {};
if (patientName && !patient.firstName && !patient.lastName) {
const { firstName, lastName } = parsePdfName(patientName);
updates.firstName = firstName;
updates.lastName = lastName;
}
if (!patient.insuranceProvider) {
updates.insuranceProvider = "MassHealth";
}
if (Object.keys(updates).length > 0) {
patient = await tx.patient.update({ where: { id: patient.id }, data: updates });
}
}
const isMassHealth = patient.insuranceProvider?.startsWith("MassHealth") ?? false;
const paidInFull = isMassHealth && totalMhPaid.gte(totalBilled);
// 2. Create payment
const payment = await tx.payment.create({
data: {
patientId: patient.id,
patientId: patient.id,
userId,
totalBilled,
totalPaid: new Decimal(0),
totalPaid: paidInFull ? totalMhPaid : new Decimal(0),
totalAdjusted: new Decimal(0),
totalDue: totalBilled,
mhPaidAmount: totalMhPaid,
totalDue: paidInFull ? new Decimal(0) : totalBilled,
mhPaidAmount: totalMhPaid,
adjustment,
status: "PENDING",
status: paidInFull ? "PAID" : "PENDING",
notes: `PDF import from ${first["Source File"] ?? "unknown file"}`,
},
});
@@ -290,7 +307,8 @@ export const pdfImportService = {
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 linePaidInFull = isMassHealth && mhPaid.gte(billed);
const due = linePaidInFull ? new Decimal(0) : Decimal.max(0, billed.minus(mhPaid));
const { toothNumber, toothSurface } = parseTooth(row["Tooth"]);
const allowed = new Decimal(row["Allowed Amount"] || 0);
@@ -309,7 +327,7 @@ export const pdfImportService = {
totalPaid: mhPaid,
totalAdjusted: new Decimal(0),
totalDue: due,
status: mhPaid.gt(0) ? "PARTIALLY_PAID" : "UNPAID",
status: linePaidInFull ? "PAID" : mhPaid.gt(0) ? "PARTIALLY_PAID" : "UNPAID",
},
});
}