- 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>
433 lines
14 KiB
TypeScript
Executable File
433 lines
14 KiB
TypeScript
Executable File
import Decimal from "decimal.js";
|
||
import {
|
||
NewTransactionPayload,
|
||
OcrRow,
|
||
Payment,
|
||
PaymentMethod,
|
||
PaymentStatus,
|
||
ClaimStatus,
|
||
} from "@repo/db/types";
|
||
import { storage } from "../storage";
|
||
import { prisma } from "@repo/db/client";
|
||
import { convertOCRDate } from "../utils/dateUtils";
|
||
|
||
/**
|
||
* Validate transactions against a payment record
|
||
*/
|
||
export async function validateTransactions(
|
||
paymentId: number,
|
||
serviceLineTransactions: NewTransactionPayload["serviceLineTransactions"],
|
||
options?: { isReversal?: boolean }
|
||
) {
|
||
const paymentRecord = await storage.getPaymentById(paymentId);
|
||
if (!paymentRecord) {
|
||
throw new Error("Payment not found");
|
||
}
|
||
|
||
// Choose service lines from claim if present, otherwise direct payment service lines(OCR Based datas)
|
||
const serviceLines = paymentRecord.claim
|
||
? paymentRecord.claim.serviceLines
|
||
: paymentRecord.serviceLines;
|
||
|
||
if (!serviceLines || serviceLines.length === 0) {
|
||
throw new Error("No service lines available for this payment");
|
||
}
|
||
|
||
for (const txn of serviceLineTransactions) {
|
||
const line = serviceLines.find((sl) => sl.id === txn.serviceLineId);
|
||
|
||
if (!line) {
|
||
throw new Error(`Invalid service line: ${txn.serviceLineId}`);
|
||
}
|
||
|
||
const paidAmount = new Decimal(txn.paidAmount ?? 0);
|
||
const adjustedAmount = new Decimal(txn.adjustedAmount ?? 0);
|
||
|
||
if (!options?.isReversal && (paidAmount.lt(0) || adjustedAmount.lt(0))) {
|
||
throw new Error("Amounts cannot be negative");
|
||
}
|
||
|
||
if (paidAmount.eq(0) && adjustedAmount.eq(0)) {
|
||
throw new Error("Must provide a payment or adjustment");
|
||
}
|
||
if (!options?.isReversal && paidAmount.gt(line.totalDue)) {
|
||
throw new Error(
|
||
`Paid amount exceeds due for service line ${txn.serviceLineId}`
|
||
);
|
||
}
|
||
}
|
||
|
||
return paymentRecord;
|
||
}
|
||
|
||
/**
|
||
* Apply transactions to a payment & recalc totals
|
||
*/
|
||
export async function applyTransactions(
|
||
paymentId: number,
|
||
serviceLineTransactions: NewTransactionPayload["serviceLineTransactions"],
|
||
userId: number
|
||
): Promise<Payment> {
|
||
return prisma.$transaction(async (tx) => {
|
||
// 1. Insert service line transactions + recalculate each serviceLines
|
||
for (const txn of serviceLineTransactions) {
|
||
await tx.serviceLineTransaction.create({
|
||
data: {
|
||
paymentId,
|
||
serviceLineId: txn.serviceLineId,
|
||
transactionId: txn.transactionId,
|
||
paidAmount: new Decimal(txn.paidAmount),
|
||
adjustedAmount: new Decimal(txn.adjustedAmount || 0),
|
||
method: txn.method,
|
||
receivedDate: txn.receivedDate,
|
||
payerName: txn.payerName,
|
||
notes: txn.notes,
|
||
},
|
||
});
|
||
|
||
// Recalculate Claim - serviceLines model totals and updates along with Claim-serviceLine status
|
||
const aggLine = await tx.serviceLineTransaction.aggregate({
|
||
_sum: { paidAmount: true, adjustedAmount: true },
|
||
where: { serviceLineId: txn.serviceLineId },
|
||
});
|
||
|
||
const serviceLine = await tx.serviceLine.findUniqueOrThrow({
|
||
where: { id: txn.serviceLineId },
|
||
select: { totalBilled: true },
|
||
});
|
||
|
||
const totalPaid = aggLine._sum.paidAmount || new Decimal(0);
|
||
const totalAdjusted = aggLine._sum.adjustedAmount || new Decimal(0);
|
||
const totalDue = serviceLine.totalBilled
|
||
.minus(totalPaid)
|
||
.minus(totalAdjusted);
|
||
|
||
await tx.serviceLine.update({
|
||
where: { id: txn.serviceLineId },
|
||
data: {
|
||
totalPaid,
|
||
totalAdjusted,
|
||
totalDue,
|
||
status:
|
||
totalDue.lte(0) && totalPaid.gt(0)
|
||
? "PAID"
|
||
: totalPaid.gt(0)
|
||
? "PARTIALLY_PAID"
|
||
: "UNPAID",
|
||
},
|
||
});
|
||
}
|
||
|
||
// 2. Recalc payment model totals based on serviceLineTransactions, and update PaymentStatus.
|
||
const aggPayment = await tx.serviceLineTransaction.aggregate({
|
||
_sum: { paidAmount: true, adjustedAmount: true },
|
||
where: { paymentId },
|
||
});
|
||
|
||
const payment = await tx.payment.findUniqueOrThrow({
|
||
where: { id: paymentId },
|
||
select: { totalBilled: true },
|
||
});
|
||
|
||
const totalPaid = aggPayment._sum.paidAmount || new Decimal(0);
|
||
const totalAdjusted = aggPayment._sum.adjustedAmount || new Decimal(0);
|
||
const totalDue = payment.totalBilled.minus(totalPaid).minus(totalAdjusted);
|
||
|
||
let status: PaymentStatus;
|
||
if (totalDue.lte(0) && totalPaid.gt(0)) status = "PAID";
|
||
else if (totalPaid.gt(0)) status = "PARTIALLY_PAID";
|
||
else status = "PENDING";
|
||
|
||
const updatedPayment = await tx.payment.update({
|
||
where: { id: paymentId },
|
||
data: { totalPaid, totalAdjusted, totalDue, status, updatedById: userId },
|
||
});
|
||
|
||
// 3. Update Claim Model Status based on serviceLineTransaction and Payment values.(as they hold the same values
|
||
// as per, ServiceLine.totalPaid and totalAdjusted and Claim.totalBilled) Hence not fetching unneccessary.
|
||
const claimId = updatedPayment.claimId ?? null;
|
||
if (claimId) {
|
||
let newClaimStatus: ClaimStatus;
|
||
if (totalDue.lte(0) && totalPaid.gt(0)) newClaimStatus = "APPROVED";
|
||
else newClaimStatus = "PENDING";
|
||
|
||
await tx.claim.update({
|
||
where: { id: claimId },
|
||
data: { status: newClaimStatus },
|
||
});
|
||
}
|
||
|
||
return updatedPayment;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Main entry point for updating payments
|
||
*/
|
||
export async function updatePayment(
|
||
paymentId: number,
|
||
serviceLineTransactions: NewTransactionPayload["serviceLineTransactions"],
|
||
userId: number,
|
||
options?: { isReversal?: boolean }
|
||
): Promise<Payment> {
|
||
await validateTransactions(paymentId, serviceLineTransactions, options);
|
||
return applyTransactions(paymentId, serviceLineTransactions, userId);
|
||
}
|
||
|
||
// 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[] = [];
|
||
|
||
for (const [index, row] of rows.entries()) {
|
||
try {
|
||
if (!row.patientName || !row.insuranceId) {
|
||
throw new Error(
|
||
`Row ${index + 1}: missing patientName or insuranceId`
|
||
);
|
||
}
|
||
if (!row.procedureCode) {
|
||
throw new Error(`Row ${index + 1}: missing procedureCode`);
|
||
}
|
||
|
||
const billed = new Decimal(row.totalBilled ?? 0);
|
||
const allowed = new Decimal(row.totalAllowed ?? row.totalBilled ?? 0);
|
||
const paid = new Decimal(row.totalPaid ?? 0);
|
||
|
||
const adjusted = billed.minus(allowed); // write-off
|
||
|
||
// Step 1–3 in a transaction
|
||
const { paymentId, serviceLineId } = await prisma.$transaction(
|
||
async (tx) => {
|
||
// 1. Find or create patient
|
||
let patient = await tx.patient.findFirst({
|
||
where: { insuranceId: row.insuranceId.toString() },
|
||
});
|
||
|
||
if (!patient) {
|
||
const [firstNameRaw, ...rest] = (row.patientName ?? "")
|
||
.trim()
|
||
.split(" ");
|
||
const firstName = firstNameRaw || "Unknown";
|
||
const lastName = rest.length > 0 ? rest.join(" ") : "Unknown";
|
||
|
||
patient = await tx.patient.create({
|
||
data: {
|
||
firstName,
|
||
lastName,
|
||
insuranceId: row.insuranceId.toString(),
|
||
dateOfBirth: new Date(Date.UTC(1900, 0, 1)), // fallback (1900, jan, 1)
|
||
gender: "",
|
||
phone: "",
|
||
userId,
|
||
},
|
||
});
|
||
}
|
||
|
||
// 2. Create payment (claimId null) — IMPORTANT: start with zeros, due = billed
|
||
const payment = await tx.payment.create({
|
||
data: {
|
||
patientId: patient.id,
|
||
userId,
|
||
totalBilled: billed,
|
||
totalPaid: new Decimal(0),
|
||
totalAdjusted: new Decimal(0),
|
||
totalDue: billed,
|
||
status: "PENDING", // updatePayment will fix it
|
||
notes: `OCR import from ${row.sourceFile ?? "Unknown file"}`,
|
||
icn: row.icn ?? "",
|
||
},
|
||
});
|
||
|
||
// 3. Create service line — IMPORTANT: start with zeros, due = billed
|
||
const serviceLine = await tx.serviceLine.create({
|
||
data: {
|
||
paymentId: payment.id,
|
||
procedureCode: row.procedureCode,
|
||
toothNumber: row.toothNumber ?? null,
|
||
toothSurface: row.toothSurface ?? null,
|
||
procedureDate: convertOCRDate(row.procedureDate),
|
||
totalBilled: billed,
|
||
totalPaid: new Decimal(0),
|
||
totalAdjusted: new Decimal(0),
|
||
totalDue: billed,
|
||
},
|
||
});
|
||
|
||
return { paymentId: payment.id, serviceLineId: serviceLine.id };
|
||
}
|
||
);
|
||
|
||
// Step 4: AFTER commit, recalc using updatePayment (global prisma can see it now)
|
||
// Build transaction & let updatePayment handle recalculation
|
||
const txn = {
|
||
serviceLineId,
|
||
paidAmount: paid.toNumber(),
|
||
adjustedAmount: adjusted.toNumber(),
|
||
method: "OTHER" as PaymentMethod,
|
||
receivedDate: new Date(),
|
||
notes: "OCR import",
|
||
};
|
||
|
||
await updatePayment(paymentId, [txn], userId);
|
||
|
||
results.push(paymentId);
|
||
} catch (err) {
|
||
console.error(`❌ Failed to import OCR row ${index + 1}:`, err);
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
return results;
|
||
},
|
||
};
|