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:
@@ -176,6 +176,154 @@ export async function updatePayment(
|
||||
|
||||
// handling full-ocr-payments-import
|
||||
|
||||
// ── helpers shared by the PDF import ──────────────────────────────────────────
|
||||
|
||||
function parsePdfDate(dateStr: string): Date {
|
||||
// "MM/DD/YY" → Date UTC
|
||||
if (!dateStr) return new Date();
|
||||
const parts = dateStr.split("/");
|
||||
if (parts.length !== 3) return new Date();
|
||||
const month = parseInt(parts[0]!, 10) - 1;
|
||||
const day = parseInt(parts[1]!, 10);
|
||||
const year = 2000 + parseInt(parts[2]!, 10);
|
||||
return new Date(Date.UTC(year, month, day));
|
||||
}
|
||||
|
||||
function parseTooth(tooth: string): { toothNumber: string | null; toothSurface: string | null } {
|
||||
if (!tooth?.trim()) return { toothNumber: null, toothSurface: null };
|
||||
const parts = tooth.trim().split(/\s+/);
|
||||
return {
|
||||
toothNumber: parts[0] || null,
|
||||
toothSurface: parts.slice(1).join(" ") || null,
|
||||
};
|
||||
}
|
||||
|
||||
function parsePdfName(fullName: string): { firstName: string; lastName: string } {
|
||||
// "LAST, FIRST" or "LAST LAST, FIRST"
|
||||
const commaIdx = fullName.indexOf(", ");
|
||||
if (commaIdx === -1) return { firstName: fullName, lastName: "Unknown" };
|
||||
return {
|
||||
lastName: fullName.slice(0, commaIdx).trim(),
|
||||
firstName: fullName.slice(commaIdx + 2).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export type PdfRow = {
|
||||
"Patient Name": string;
|
||||
"Member #": string;
|
||||
"ICN": string;
|
||||
"Submitted Code": string;
|
||||
"Paid Code": string;
|
||||
"Tooth": string;
|
||||
"Date of Service": string;
|
||||
"Submitted Amount": string;
|
||||
"Allowed Amount": string;
|
||||
"Paid Amount": string;
|
||||
"Source File": string;
|
||||
};
|
||||
|
||||
export const pdfImportService = {
|
||||
async importRows(rows: PdfRow[], userId: number) {
|
||||
// Group by Member # — one payment per patient
|
||||
const byMember = new Map<string, PdfRow[]>();
|
||||
for (const row of rows) {
|
||||
const key = row["Member #"] || row["Patient Name"];
|
||||
if (!byMember.has(key)) byMember.set(key, []);
|
||||
byMember.get(key)!.push(row);
|
||||
}
|
||||
|
||||
const paymentIds: number[] = [];
|
||||
|
||||
for (const [, patientRows] of byMember) {
|
||||
const first = patientRows[0]!;
|
||||
const memberNo = first["Member #"];
|
||||
const patientName = first["Patient Name"];
|
||||
|
||||
const totalBilled = patientRows.reduce(
|
||||
(s, r) => s.plus(new Decimal(r["Submitted Amount"] || 0)),
|
||||
new Decimal(0)
|
||||
);
|
||||
const totalMhPaid = patientRows.reduce(
|
||||
(s, r) => s.plus(new Decimal(r["Paid Amount"] || 0)),
|
||||
new Decimal(0)
|
||||
);
|
||||
const adjustment = Decimal.max(0, totalBilled.minus(totalMhPaid));
|
||||
|
||||
const paymentId = await prisma.$transaction(async (tx: typeof prisma) => {
|
||||
// 1. Find or create patient
|
||||
let patient = memberNo
|
||||
? await tx.patient.findFirst({ where: { insuranceId: memberNo } })
|
||||
: null;
|
||||
|
||||
if (!patient) {
|
||||
const { firstName, lastName } = parsePdfName(patientName);
|
||||
patient = await tx.patient.create({
|
||||
data: {
|
||||
firstName,
|
||||
lastName,
|
||||
insuranceId: memberNo || null,
|
||||
dateOfBirth: new Date(Date.UTC(1900, 0, 1)),
|
||||
gender: "",
|
||||
phone: "",
|
||||
userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Create payment
|
||||
const payment = await tx.payment.create({
|
||||
data: {
|
||||
patientId: patient.id,
|
||||
userId,
|
||||
totalBilled,
|
||||
totalPaid: new Decimal(0),
|
||||
totalAdjusted: new Decimal(0),
|
||||
totalDue: totalBilled,
|
||||
mhPaidAmount: totalMhPaid,
|
||||
adjustment,
|
||||
status: "PENDING",
|
||||
notes: `PDF import from ${first["Source File"] ?? "unknown file"}`,
|
||||
},
|
||||
});
|
||||
|
||||
// 3. Create one service line per row
|
||||
for (const row of patientRows) {
|
||||
const billed = new Decimal(row["Submitted Amount"] || 0);
|
||||
const mhPaid = new Decimal(row["Paid Amount"] || 0);
|
||||
const due = Decimal.max(0, billed.minus(mhPaid));
|
||||
const { toothNumber, toothSurface } = parseTooth(row["Tooth"]);
|
||||
|
||||
const allowed = new Decimal(row["Allowed Amount"] || 0);
|
||||
|
||||
await tx.serviceLine.create({
|
||||
data: {
|
||||
paymentId: payment.id,
|
||||
icn: row["ICN"] || null,
|
||||
procedureCode: row["Submitted Code"] || "",
|
||||
paidCode: row["Paid Code"] || null,
|
||||
allowedAmount: allowed,
|
||||
toothNumber,
|
||||
toothSurface,
|
||||
procedureDate: parsePdfDate(row["Date of Service"]),
|
||||
totalBilled: billed,
|
||||
totalPaid: mhPaid,
|
||||
totalAdjusted: new Decimal(0),
|
||||
totalDue: due,
|
||||
status: mhPaid.gt(0) ? "PARTIALLY_PAID" : "UNPAID",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return payment.id;
|
||||
});
|
||||
|
||||
paymentIds.push(paymentId);
|
||||
}
|
||||
|
||||
return paymentIds;
|
||||
},
|
||||
};
|
||||
|
||||
export const fullOcrPaymentService = {
|
||||
async importRows(rows: OcrRow[], userId: number) {
|
||||
const results: number[] = [];
|
||||
|
||||
Reference in New Issue
Block a user