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 { 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 { 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(); 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; }, };