Files
Wellspringmanagement01/apps/Backend/src/services/paymentService.ts
T
GiteadandClaude Sonnet 4.6 dd0df4a435 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>
2026-05-07 12:53:50 -04:00

433 lines
14 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
},
};