initial commit
This commit is contained in:
Executable
+284
@@ -0,0 +1,284 @@
|
||||
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;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user