From 62834f6eb9abe7f7dfb757123c1e3acd9594fb7b Mon Sep 17 00:00:00 2001 From: Potenz Date: Fri, 5 Sep 2025 00:17:18 +0530 Subject: [PATCH] feat(ocr) - route fixed, saving done --- apps/Backend/.env.example | 12 +- apps/Backend/src/routes/payments.ts | 36 ++++++ apps/Backend/src/services/paymentService.ts | 119 +++++++++++++++++- apps/Backend/src/storage/index.ts | 10 ++ apps/Backend/src/utils/DobParts.ts | 7 -- apps/Backend/src/utils/dateUtils.ts | 26 ++++ .../components/payments/payment-ocr-block.tsx | 79 +++++++----- .../payments/payments-recent-table.tsx | 21 +++- apps/PaymentOCRService/.env.example | 4 +- packages/db/types/claim-types.ts | 2 +- packages/db/types/payment-types.ts | 17 ++- 11 files changed, 279 insertions(+), 54 deletions(-) delete mode 100644 apps/Backend/src/utils/DobParts.ts create mode 100644 apps/Backend/src/utils/dateUtils.ts diff --git a/apps/Backend/.env.example b/apps/Backend/.env.example index fec3580..0737bf1 100644 --- a/apps/Backend/.env.example +++ b/apps/Backend/.env.example @@ -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 \ No newline at end of file diff --git a/apps/Backend/src/routes/payments.ts b/apps/Backend/src/routes/payments.ts index f0c9b20..4683aa1 100644 --- a/apps/Backend/src/routes/payments.ts +++ b/apps/Backend/src/routes/payments.ts @@ -152,6 +152,42 @@ router.get("/:id", async (req: Request, res: Response): Promise => { } }); +// POST /api/payments/full-ocr-import +router.post( + "/full-ocr-import", + async (req: Request, res: Response): Promise => { + 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 => { try { diff --git a/apps/Backend/src/services/paymentService.ts b/apps/Backend/src/services/paymentService.ts index 462b1bb..c454c3c 100644 --- a/apps/Backend/src/services/paymentService.ts +++ b/apps/Backend/src/services/paymentService.ts @@ -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; + }, +}; diff --git a/apps/Backend/src/storage/index.ts b/apps/Backend/src/storage/index.ts index 1fbbf9f..d5c8670 100644 --- a/apps/Backend/src/storage/index.ts +++ b/apps/Backend/src/storage/index.ts @@ -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, }, }); diff --git a/apps/Backend/src/utils/DobParts.ts b/apps/Backend/src/utils/DobParts.ts deleted file mode 100644 index 1076e5f..0000000 --- a/apps/Backend/src/utils/DobParts.ts +++ /dev/null @@ -1,7 +0,0 @@ -export function extractDobParts(date: Date) { - return { - dob_day: date.getUTCDate(), - dob_month: date.getUTCMonth() + 1, - dob_year: date.getUTCFullYear(), - }; -} diff --git a/apps/Backend/src/utils/dateUtils.ts b/apps/Backend/src/utils/dateUtils.ts new file mode 100644 index 0000000..6c81bcc --- /dev/null +++ b/apps/Backend/src/utils/dateUtils.ts @@ -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); +} diff --git a/apps/Frontend/src/components/payments/payment-ocr-block.tsx b/apps/Frontend/src/components/payments/payment-ocr-block.tsx index ab6c1d2..525a697 100644 --- a/apps/Frontend/src/components/payments/payment-ocr-block.tsx +++ b/apps/Frontend/src/components/payments/payment-ocr-block.tsx @@ -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"); diff --git a/apps/Frontend/src/components/payments/payments-recent-table.tsx b/apps/Frontend/src/components/payments/payments-recent-table.tsx index 293d916..dde5a2a 100644 --- a/apps/Frontend/src/components/payments/payments-recent-table.tsx +++ b/apps/Frontend/src/components/payments/payments-recent-table.tsx @@ -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({ Claim ID Patient Name Amount - Claim Submitted on + Service Date Status Actions @@ -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 ( {allowCheckbox && ( @@ -547,13 +560,13 @@ export default function PaymentsRecentTable({ className={`h-10 w-10 ${getAvatarColor(Number(payment.id))}`} > - {getInitials(payment.patientName)} + {getInitials(displayName)}
- {payment.patientName} + {displayName}
PID-{payment.patientId?.toString().padStart(4, "0")} @@ -585,7 +598,7 @@ export default function PaymentsRecentTable({
- {formatDateToHumanReadable(payment.paymentDate)} + {formatDateToHumanReadable(submittedOn)} diff --git a/apps/PaymentOCRService/.env.example b/apps/PaymentOCRService/.env.example index 6521493..4c04f97 100644 --- a/apps/PaymentOCRService/.env.example +++ b/apps/PaymentOCRService/.env.example @@ -1,3 +1,3 @@ GOOGLE_APPLICATION_CREDENTIALS=google_credentials.json -HOST="0.0.0.0" -PORT="5003" \ No newline at end of file +HOST=0.0.0.0 +PORT=5003 \ No newline at end of file diff --git a/packages/db/types/claim-types.ts b/packages/db/types/claim-types.ts index 28c2ee1..1863f60 100644 --- a/packages/db/types/claim-types.ts +++ b/packages/db/types/claim-types.ts @@ -52,7 +52,7 @@ export interface InputServiceLine { export type ClaimWithServiceLines = Claim & { serviceLines: { id: number; - claimId: number; + claimId: number | null; procedureCode: string; procedureDate: Date; oralCavityArea: string | null; diff --git a/packages/db/types/payment-types.ts b/packages/db/types/payment-types.ts index 5283e53..a7db811 100644 --- a/packages/db/types/payment-types.ts +++ b/packages/db/types/payment-types.ts @@ -74,7 +74,7 @@ export type PaymentWithExtras = Prisma.PaymentGetPayload<{ }; }; updatedBy: true; - patient: true; + patient: true; }; }> & { patientName: string; @@ -99,3 +99,18 @@ export const newTransactionPayloadSchema = z.object({ }); export type NewTransactionPayload = z.infer; + +// OCR Payment - row +export interface OcrRow { + patientName: string; + insuranceId: string | number; + icn?: string | null; + procedureCode: string; + toothNumber?: string | null; + toothSurface?: string | null; + procedureDate: string | null; + totalBilled: number; + totalAllowed?: number; + totalPaid: number; + sourceFile?: string | null; +}