285 lines
9.3 KiB
TypeScript
Executable File
285 lines
9.3 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
|
||
|
||
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;
|
||
},
|
||
};
|