Files
Wellspringmanagement01/apps/Backend/src/services/paymentService.ts
T
2026-04-04 22:13:55 -04:00

285 lines
9.3 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
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;
},
};