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

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