feat(ocr) - route fixed, saving done
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
HOST="localhost"
|
||||
HOST=localhost
|
||||
PORT=5000
|
||||
FRONTEND_URL="http://localhost:3000"
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
JWT_SECRET = 'dentalsecret'
|
||||
DB_HOST="localhost"
|
||||
DB_USER="postgres"
|
||||
DB_PASSWORD="mypassword"
|
||||
DB_NAME="dentalapp"
|
||||
DB_HOST=localhost
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=mypassword
|
||||
DB_NAME=dentalapp
|
||||
DATABASE_URL=postgresql://postgres:mypassword@localhost:5432/dentalapp
|
||||
@@ -152,6 +152,42 @@ router.get("/:id", async (req: Request, res: Response): Promise<any> => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/payments/full-ocr-import
|
||||
router.post(
|
||||
"/full-ocr-import",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||
|
||||
const { rows } = req.body;
|
||||
if (!rows || !Array.isArray(rows)) {
|
||||
return res.status(400).json({ message: "Invalid OCR payload" });
|
||||
}
|
||||
|
||||
const paymentIds = await paymentService.fullOcrPaymentService.importRows(
|
||||
rows,
|
||||
userId
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
message: "OCR rows imported successfully",
|
||||
paymentIds,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
if (err instanceof Error) {
|
||||
return res.status(500).json({ message: err.message });
|
||||
}
|
||||
|
||||
return res
|
||||
.status(500)
|
||||
.json({ message: "Unknown error importing OCR payments" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// POST /api/payments/:claimId
|
||||
router.post("/:claimId", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import Decimal from "decimal.js";
|
||||
import { NewTransactionPayload, Payment, PaymentStatus } from "@repo/db/types";
|
||||
import {
|
||||
NewTransactionPayload,
|
||||
OcrRow,
|
||||
Payment,
|
||||
PaymentMethod,
|
||||
paymentMethodOptions,
|
||||
PaymentStatus,
|
||||
} 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
|
||||
@@ -149,3 +157,112 @@ export async function updatePayment(
|
||||
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
|
||||
const due = billed.minus(paid).minus(adjusted); // patient responsibility
|
||||
|
||||
// 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"}`,
|
||||
},
|
||||
});
|
||||
|
||||
// 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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -820,12 +820,14 @@ export const storage: IStorage = {
|
||||
serviceLines: true,
|
||||
},
|
||||
},
|
||||
serviceLines: true,
|
||||
serviceLineTransactions: {
|
||||
include: {
|
||||
serviceLine: true,
|
||||
},
|
||||
},
|
||||
updatedBy: true,
|
||||
patient: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -852,12 +854,14 @@ export const storage: IStorage = {
|
||||
serviceLines: true,
|
||||
},
|
||||
},
|
||||
serviceLines: true,
|
||||
serviceLineTransactions: {
|
||||
include: {
|
||||
serviceLine: true,
|
||||
},
|
||||
},
|
||||
updatedBy: true,
|
||||
patient: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -882,12 +886,14 @@ export const storage: IStorage = {
|
||||
serviceLines: true,
|
||||
},
|
||||
},
|
||||
serviceLines: true,
|
||||
serviceLineTransactions: {
|
||||
include: {
|
||||
serviceLine: true,
|
||||
},
|
||||
},
|
||||
updatedBy: true,
|
||||
patient: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -915,12 +921,14 @@ export const storage: IStorage = {
|
||||
serviceLines: true,
|
||||
},
|
||||
},
|
||||
serviceLines: true,
|
||||
serviceLineTransactions: {
|
||||
include: {
|
||||
serviceLine: true,
|
||||
},
|
||||
},
|
||||
updatedBy: true,
|
||||
patient: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -950,12 +958,14 @@ export const storage: IStorage = {
|
||||
serviceLines: true,
|
||||
},
|
||||
},
|
||||
serviceLines: true,
|
||||
serviceLineTransactions: {
|
||||
include: {
|
||||
serviceLine: true,
|
||||
},
|
||||
},
|
||||
updatedBy: true,
|
||||
patient: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export function extractDobParts(date: Date) {
|
||||
return {
|
||||
dob_day: date.getUTCDate(),
|
||||
dob_month: date.getUTCMonth() + 1,
|
||||
dob_year: date.getUTCFullYear(),
|
||||
};
|
||||
}
|
||||
26
apps/Backend/src/utils/dateUtils.ts
Normal file
26
apps/Backend/src/utils/dateUtils.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
|
||||
/**
|
||||
* Convert any OCR string-like value into a safe string.
|
||||
*/
|
||||
export function toStr(val: string | number | null | undefined): string {
|
||||
if (val == null) return "";
|
||||
return String(val).trim();
|
||||
}
|
||||
/**
|
||||
* Convert OCR date strings like "070825" (MMDDYY) into a JS Date object.
|
||||
* Example: "070825" → 2025-08-07.
|
||||
*/
|
||||
export function convertOCRDate(input: string | number | null | undefined): Date {
|
||||
const raw = toStr(input);
|
||||
|
||||
if (!/^\d{6}$/.test(raw)) {
|
||||
throw new Error(`Invalid OCR date format: ${raw}`);
|
||||
}
|
||||
|
||||
const month = parseInt(raw.slice(0, 2), 10) - 1;
|
||||
const day = parseInt(raw.slice(2, 4), 10);
|
||||
const year2 = parseInt(raw.slice(4, 6), 10);
|
||||
const year = year2 < 50 ? 2000 + year2 : 1900 + year2;
|
||||
|
||||
return new Date(year, month, day);
|
||||
}
|
||||
@@ -156,41 +156,56 @@ export default function PaymentOCRBlock() {
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const payload = rows.map((row) => {
|
||||
const billed = Number(row["Billed Amount"] ?? 0);
|
||||
const allowed = Number(row["Allowed Amount"] ?? 0);
|
||||
const paid = Number(row["Paid Amount"] ?? 0);
|
||||
const skipped: string[] = [];
|
||||
|
||||
return {
|
||||
patientId: parseInt(row["Patient ID"] as string, 10),
|
||||
totalBilled: billed,
|
||||
totalPaid: paid,
|
||||
totalAdjusted: billed - allowed, // ❗ write-off
|
||||
totalDue: allowed - paid, // ❗ patient responsibility
|
||||
notes: `OCR import - CDT ${row["CDT Code"]}, Tooth ${row["Tooth"]}, Date ${row["Date SVC"]}`,
|
||||
serviceLine: {
|
||||
const payload = rows
|
||||
.map((row, idx) => {
|
||||
const patientName = row["Patient Name"];
|
||||
const patientId = row["Patient ID"];
|
||||
const procedureCode = row["CDT Code"];
|
||||
|
||||
if (!patientName || !patientId || !procedureCode) {
|
||||
skipped.push(`Row ${idx + 1} (missing name/id/procedureCode)`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
patientName,
|
||||
insuranceId: patientId,
|
||||
icn: row["ICN"] ?? null,
|
||||
procedureCode: row["CDT Code"],
|
||||
procedureDate: convertOCRDate(row["Date SVC"]), // you’ll parse "070825" → proper Date
|
||||
toothNumber: row["Tooth"],
|
||||
totalBilled: billed,
|
||||
totalPaid: paid,
|
||||
totalAdjusted: billed - allowed,
|
||||
totalDue: allowed - paid,
|
||||
},
|
||||
transaction: {
|
||||
paidAmount: paid,
|
||||
adjustedAmount: billed - allowed, // same as totalAdjusted
|
||||
method: "OTHER", // fallback, since OCR doesn’t give this
|
||||
receivedDate: new Date(),
|
||||
},
|
||||
};
|
||||
});
|
||||
toothNumber: row["Tooth"] ?? null,
|
||||
toothSurface: row["Surface"] ?? null,
|
||||
procedureDate: row["Date SVC"] ?? null,
|
||||
totalBilled: Number(row["Billed Amount"] ?? 0),
|
||||
totalAllowed: Number(row["Allowed Amount"] ?? 0),
|
||||
totalPaid: Number(row["Paid Amount"] ?? 0),
|
||||
sourceFile: row["Source File"] ?? null,
|
||||
};
|
||||
})
|
||||
.filter((r) => r !== null);
|
||||
|
||||
const res = await apiRequest(
|
||||
"POST",
|
||||
"/api/payments/ocr",
|
||||
JSON.stringify({ payments: payload })
|
||||
);
|
||||
if (skipped.length > 0) {
|
||||
toast({
|
||||
title:
|
||||
"Some rows skipped, because of either no patient Name or MemberId given.",
|
||||
description: skipped.join(", "),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
|
||||
if (payload.length === 0) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "No valid rows to save",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await apiRequest("POST", "/api/payments/full-ocr-import", {
|
||||
rows: payload,
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error("Failed to save OCR payments");
|
||||
|
||||
|
||||
@@ -370,6 +370,11 @@ export default function PaymentsRecentTable({
|
||||
paymentsData?.totalCount || 0
|
||||
);
|
||||
|
||||
const getName = (p: PaymentWithExtras) =>
|
||||
p.patient
|
||||
? `${p.patient.firstName} ${p.patient.lastName}`.trim()
|
||||
: (p.patientName ?? "Unknown");
|
||||
|
||||
const getInitials = (fullName: string) => {
|
||||
const parts = fullName.trim().split(/\s+/);
|
||||
const filteredParts = parts.filter((part) => part.length > 0);
|
||||
@@ -480,7 +485,7 @@ export default function PaymentsRecentTable({
|
||||
<TableHead>Claim ID</TableHead>
|
||||
<TableHead>Patient Name</TableHead>
|
||||
<TableHead>Amount</TableHead>
|
||||
<TableHead>Claim Submitted on</TableHead>
|
||||
<TableHead>Service Date</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
@@ -519,6 +524,14 @@ export default function PaymentsRecentTable({
|
||||
const totalPaid = Number(payment.totalPaid || 0);
|
||||
const totalDue = Number(payment.totalDue || 0);
|
||||
|
||||
const displayName = getName(payment);
|
||||
const submittedOn =
|
||||
payment.serviceLines?.[0]?.procedureDate ??
|
||||
payment.claim?.createdAt ??
|
||||
payment.createdAt ??
|
||||
payment.serviceLineTransactions?.[0]?.receivedDate ??
|
||||
null;
|
||||
|
||||
return (
|
||||
<TableRow key={payment.id}>
|
||||
{allowCheckbox && (
|
||||
@@ -547,13 +560,13 @@ export default function PaymentsRecentTable({
|
||||
className={`h-10 w-10 ${getAvatarColor(Number(payment.id))}`}
|
||||
>
|
||||
<AvatarFallback className="text-white">
|
||||
{getInitials(payment.patientName)}
|
||||
{getInitials(displayName)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{payment.patientName}
|
||||
{displayName}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
PID-{payment.patientId?.toString().padStart(4, "0")}
|
||||
@@ -585,7 +598,7 @@ export default function PaymentsRecentTable({
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{formatDateToHumanReadable(payment.paymentDate)}
|
||||
{formatDateToHumanReadable(submittedOn)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
GOOGLE_APPLICATION_CREDENTIALS=google_credentials.json
|
||||
HOST="0.0.0.0"
|
||||
PORT="5003"
|
||||
HOST=0.0.0.0
|
||||
PORT=5003
|
||||
Reference in New Issue
Block a user