diff --git a/apps/Backend/src/routes/claims.ts b/apps/Backend/src/routes/claims.ts index b31ad952..d2f3211b 100755 --- a/apps/Backend/src/routes/claims.ts +++ b/apps/Backend/src/routes/claims.ts @@ -1063,6 +1063,16 @@ router.put("/:id", async (req: Request, res: Response): Promise => { ...(req.body.npiProviderId ? { npiProviderId: Number(req.body.npiProviderId) } : {}), }); const updatedClaim = await storage.updateClaim(claimId, claimData); + + // Propagate provider change to the linked payment so both stay in sync + if (req.body.npiProviderId) { + const { prisma: db } = await import("@repo/db/client"); + await db.payment.updateMany({ + where: { claimId }, + data: { npiProviderId: Number(req.body.npiProviderId) }, + }); + } + res.json(updatedClaim); } catch (error) { if (error instanceof z.ZodError) { diff --git a/apps/Backend/src/routes/commissions.ts b/apps/Backend/src/routes/commissions.ts new file mode 100644 index 00000000..78e68320 --- /dev/null +++ b/apps/Backend/src/routes/commissions.ts @@ -0,0 +1,81 @@ +import { Router } from "express"; +import type { Request, Response } from "express"; +import { storage } from "../storage"; + +const router = Router(); + +/** GET /api/commissions/eligible + * Query: npiProviderId (required), from?, to? + */ +router.get("/eligible", async (req: Request, res: Response): Promise => { + try { + const npiProviderId = Number(req.query.npiProviderId); + if (!npiProviderId || !Number.isFinite(npiProviderId)) { + return res.status(400).json({ message: "npiProviderId is required" }); + } + + const from = req.query.from ? new Date(String(req.query.from)) : null; + const to = req.query.to ? new Date(String(req.query.to)) : null; + + if (req.query.from && isNaN(from!.getTime())) + return res.status(400).json({ message: "Invalid 'from' date" }); + if (req.query.to && isNaN(to!.getTime())) + return res.status(400).json({ message: "Invalid 'to' date" }); + + const payments = await storage.getEligiblePayments(npiProviderId, from, to); + return res.json(payments); + } catch (err: any) { + console.error("GET /api/commissions/eligible error:", err); + return res.status(500).json({ message: err?.message ?? "Failed to fetch eligible payments" }); + } +}); + +/** GET /api/commissions/batches + * Query: npiProviderId? (optional filter) + */ +router.get("/batches", async (req: Request, res: Response): Promise => { + try { + const npiProviderId = req.query.npiProviderId + ? Number(req.query.npiProviderId) + : undefined; + const batches = await storage.getCommissionBatches(npiProviderId); + return res.json(batches); + } catch (err: any) { + console.error("GET /api/commissions/batches error:", err); + return res.status(500).json({ message: err?.message ?? "Failed to fetch commission batches" }); + } +}); + +/** POST /api/commissions + * Body: { npiProviderId, paymentIds[], totalCollection, commissionAmount, notes? } + */ +router.post("/", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + + const { npiProviderId, paymentIds, totalCollection, commissionAmount, notes } = req.body; + + if (!npiProviderId || !Array.isArray(paymentIds) || paymentIds.length === 0) { + return res.status(400).json({ message: "npiProviderId and at least one paymentId are required" }); + } + if (typeof totalCollection !== "number" || typeof commissionAmount !== "number") { + return res.status(400).json({ message: "totalCollection and commissionAmount must be numbers" }); + } + + const batch = await storage.createCommissionBatch({ + npiProviderId: Number(npiProviderId), + paymentIds: paymentIds.map(Number), + totalCollection, + commissionAmount, + notes: notes ?? undefined, + }); + + return res.status(201).json(batch); + } catch (err: any) { + console.error("POST /api/commissions error:", err); + return res.status(500).json({ message: err?.message ?? "Failed to create commission batch" }); + } +}); + +export default router; diff --git a/apps/Backend/src/routes/index.ts b/apps/Backend/src/routes/index.ts index f7879784..38c3546c 100755 --- a/apps/Backend/src/routes/index.ts +++ b/apps/Backend/src/routes/index.ts @@ -31,6 +31,7 @@ import officeHoursRoutes from "./office-hours"; import officeContactRoutes from "./office-contact"; import procedureTimeslotRoutes from "./procedure-timeslot"; import insuranceContactsRoutes from "./insurance-contacts"; +import commissionsRoutes from "./commissions"; const router = Router(); @@ -66,5 +67,6 @@ router.use("/office-hours", officeHoursRoutes); router.use("/office-contact", officeContactRoutes); router.use("/procedure-timeslot", procedureTimeslotRoutes); router.use("/insurance-contacts", insuranceContactsRoutes); +router.use("/commissions", commissionsRoutes); export default router; diff --git a/apps/Backend/src/routes/payments-reports.ts b/apps/Backend/src/routes/payments-reports.ts index 191cacf8..b2d939bc 100755 --- a/apps/Backend/src/routes/payments-reports.ts +++ b/apps/Backend/src/routes/payments-reports.ts @@ -17,8 +17,11 @@ router.get("/summary", async (req: Request, res: Response): Promise => { if (req.query.to && isNaN(to?.getTime() ?? NaN)) return res.status(400).json({ message: "Invalid 'to' date" }); - const summary = await storage.getSummary(from, to); - res.json(summary); + const npiProviderId = req.query.npiProviderId + ? Number(req.query.npiProviderId) + : null; + + const summary = await storage.getSummary(from, to, npiProviderId); } catch (err: any) { console.error( "GET /api/payments-reports/summary error:", @@ -60,11 +63,16 @@ router.get( return res.status(400).json({ message: "Invalid 'to' date" }); } + const npiProviderId = req.query.npiProviderId + ? Number(req.query.npiProviderId) + : null; + const data = await storage.getPatientsWithBalances( limit, cursor, from, - to + to, + npiProviderId ); // returns { balances, totalCount, nextCursor, hasMore } res.json(data); @@ -126,13 +134,18 @@ router.get( return res.status(400).json({ message: "Invalid 'to' date" }); } + const npiProviderId = req.query.npiProviderId + ? Number(req.query.npiProviderId) + : null; + // use the new storage method that returns only the paged balances const balancesResult = await storage.getPatientsBalancesByDoctor( staffId, limit, cursor, from, - to + to, + npiProviderId ); res.json({ @@ -192,8 +205,12 @@ router.get( return res.status(400).json({ message: "Invalid 'to' date" }); } + const npiProviderId = req.query.npiProviderId + ? Number(req.query.npiProviderId) + : null; + // use the new storage method that returns only the summary for the staff - const summary = await storage.getSummaryByDoctor(staffId, from, to); + const summary = await storage.getSummaryByDoctor(staffId, from, to, npiProviderId); res.json(summary); } catch (err: any) { diff --git a/apps/Backend/src/routes/payments.ts b/apps/Backend/src/routes/payments.ts index 49b3fe00..6c2266e9 100755 --- a/apps/Backend/src/routes/payments.ts +++ b/apps/Backend/src/routes/payments.ts @@ -200,10 +200,17 @@ router.post("/:claimId", async (req: Request, res: Response): Promise => { const claimId = parseIntOrError(req.params.claimId, "Claim ID"); + // Inherit npiProviderId from the linked claim so commission queries work + const linkedClaim = await prisma.claim.findUnique({ + where: { id: claimId }, + select: { npiProviderId: true }, + }); + const validated = insertPaymentSchema.safeParse({ ...req.body, claimId, userId, + ...(linkedClaim?.npiProviderId ? { npiProviderId: linkedClaim.npiProviderId } : {}), }); if (!validated.success) { @@ -427,6 +434,48 @@ router.patch( } ); +// PATCH /api/payments/:id/provider +router.patch( + "/:id/provider", + async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + + const paymentId = parseIntOrError(req.params.id, "Payment ID"); + const { npiProviderId } = req.body; + + const existing = await storage.getPayment(paymentId); + if (!existing) return res.status(404).json({ message: "Payment not found" }); + + // Update payment and linked claim atomically so the claims form + // picks up the new provider when it prefills from the existing claim. + const ops: Parameters[0] = [ + prisma.payment.update({ + where: { id: paymentId }, + data: { npiProviderId: npiProviderId ?? null, updatedById: userId }, + include: { npiProvider: true }, + }), + ]; + + if (existing.claimId) { + ops.push( + prisma.claim.update({ + where: { id: existing.claimId }, + data: { npiProviderId: npiProviderId ?? null }, + }) as any + ); + } + + const [updated] = await prisma.$transaction(ops); + return res.json(updated); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Failed to update provider"; + return res.status(500).json({ message }); + } + } +); + // PATCH /api/payments/:id/mh-paid-amount router.patch( "/:id/mh-paid-amount", diff --git a/apps/Backend/src/storage/claims-storage.ts b/apps/Backend/src/storage/claims-storage.ts index 4f718623..a52560db 100755 --- a/apps/Backend/src/storage/claims-storage.ts +++ b/apps/Backend/src/storage/claims-storage.ts @@ -46,6 +46,7 @@ export const claimsStorage: IStorage = { serviceLines: true, staff: true, claimFiles: true, + npiProvider: true, }, }); }, @@ -63,7 +64,7 @@ export const claimsStorage: IStorage = { status: { notIn: ["CANCELLED"] }, }, orderBy: { createdAt: "desc" }, - include: { serviceLines: true, claimFiles: true, staff: true }, + include: { serviceLines: true, claimFiles: true, staff: true, npiProvider: true }, }) as Promise; }, @@ -79,7 +80,7 @@ export const claimsStorage: IStorage = { orderBy: { createdAt: "desc" }, skip: offset, take: limit, - include: { serviceLines: true, staff: true, claimFiles: true }, + include: { serviceLines: true, staff: true, claimFiles: true, npiProvider: true }, }); }, diff --git a/apps/Backend/src/storage/commissions-storage.ts b/apps/Backend/src/storage/commissions-storage.ts new file mode 100644 index 00000000..2e5f3333 --- /dev/null +++ b/apps/Backend/src/storage/commissions-storage.ts @@ -0,0 +1,193 @@ +import { prisma as db } from "@repo/db/client"; + +export interface EligiblePaymentRow { + id: number; + claimNumber: string | null; + patientName: string; + serviceDate: Date | null; + mhPaidAmount: number; + copayment: number; + collectionAmount: number; + paymentDate: Date; + isOcr: boolean; +} + +export interface CommissionBatchRecord { + id: number; + npiProviderId: number; + totalCollection: number; + commissionAmount: number; + notes: string | null; + createdAt: Date; + providerName: string; + itemCount: number; +} + +export const commissionsStorage = { + /** + * Eligible payments for commission — two sources: + * 1. Claims owned by this provider (Claim.npiProviderId) whose linked payment + * has actual collection and has not been commissioned yet. + * 2. OCR / PDF-import payments that have no linked claim but carry the + * provider on Payment.npiProviderId directly. + */ + async getEligiblePayments( + npiProviderId: number, + from?: Date | null, + to?: Date | null + ): Promise { + const dateFilter: any = {}; + if (from) dateFilter.gte = from; + if (to) { + const toEnd = new Date(to); + toEnd.setUTCHours(23, 59, 59, 999); + dateFilter.lte = toEnd; + } + const hasDateFilter = Object.keys(dateFilter).length > 0; + + // ── 1. Claim-centric: provider lives on the Claim ────────────────────── + const claimWhere: any = { + npiProviderId, + payment: { + is: { + commissionBatchItems: { none: {} }, + OR: [{ mhPaidAmount: { gt: 0 } }, { copayment: { gt: 0 } }], + ...(hasDateFilter ? { createdAt: dateFilter } : {}), + }, + }, + }; + + const claims = await db.claim.findMany({ + where: claimWhere, + orderBy: { id: "desc" }, + include: { + payment: { + select: { id: true, mhPaidAmount: true, copayment: true, createdAt: true, notes: true }, + }, + }, + }); + + const rows: EligiblePaymentRow[] = claims + .filter((c) => c.payment !== null) + .map((c) => { + const p = c.payment!; + const mh = Number(p.mhPaidAmount ?? 0); + const co = Number(p.copayment ?? 0); + return { + id: p.id, + claimNumber: c.claimNumber ?? null, + patientName: c.patientName, + serviceDate: c.serviceDate, + mhPaidAmount: mh, + copayment: co, + collectionAmount: mh + co, + paymentDate: p.createdAt, + isOcr: false, + }; + }); + + // ── 2. OCR / PDF payments — no claim, matched by Payment.npiProviderId ── + const ocrWhere: any = { + npiProviderId, + claimId: null, + commissionBatchItems: { none: {} }, + OR: [{ mhPaidAmount: { gt: 0 } }, { copayment: { gt: 0 } }], + ...(hasDateFilter ? { createdAt: dateFilter } : {}), + }; + + const ocrPayments = await db.payment.findMany({ + where: ocrWhere, + orderBy: { createdAt: "desc" }, + include: { + patient: { select: { firstName: true, lastName: true } }, + }, + }); + + for (const p of ocrPayments) { + const mh = Number(p.mhPaidAmount ?? 0); + const co = Number(p.copayment ?? 0); + const patientName = + `${p.patient?.firstName ?? ""} ${p.patient?.lastName ?? ""}`.trim() || + "Unknown"; + rows.push({ + id: p.id, + claimNumber: null, + patientName, + serviceDate: null, + mhPaidAmount: mh, + copayment: co, + collectionAmount: mh + co, + paymentDate: p.createdAt, + isOcr: true, + }); + } + + // Sort combined list by most recent first + rows.sort((a, b) => b.paymentDate.getTime() - a.paymentDate.getTime()); + return rows; + }, + + /** Create a commission batch, linking the selected payments */ + async createCommissionBatch(data: { + npiProviderId: number; + paymentIds: number[]; + totalCollection: number; + commissionAmount: number; + notes?: string; + }) { + // Verify none of the payments are already commissioned + const alreadyDone = await db.commissionBatchItem.count({ + where: { paymentId: { in: data.paymentIds } }, + }); + if (alreadyDone > 0) { + throw new Error("One or more selected payments have already been commissioned."); + } + + // Calculate collection amounts per payment + const payments = await db.payment.findMany({ + where: { id: { in: data.paymentIds } }, + select: { id: true, mhPaidAmount: true, copayment: true }, + }); + + const batch = await db.commissionBatch.create({ + data: { + npiProviderId: data.npiProviderId, + totalCollection: data.totalCollection, + commissionAmount: data.commissionAmount, + notes: data.notes ?? null, + items: { + create: payments.map((p) => ({ + paymentId: p.id, + collectionAmount: Number(p.mhPaidAmount ?? 0) + Number(p.copayment ?? 0), + })), + }, + }, + include: { items: true, npiProvider: true }, + }); + + return batch; + }, + + /** Past commission batches for a provider */ + async getCommissionBatches(npiProviderId?: number): Promise { + const batches = await db.commissionBatch.findMany({ + where: npiProviderId ? { npiProviderId } : undefined, + orderBy: { createdAt: "desc" }, + include: { + npiProvider: { select: { providerName: true } }, + _count: { select: { items: true } }, + }, + }); + + return batches.map((b) => ({ + id: b.id, + npiProviderId: b.npiProviderId, + totalCollection: Number(b.totalCollection), + commissionAmount: Number(b.commissionAmount), + notes: b.notes, + createdAt: b.createdAt, + providerName: b.npiProvider.providerName, + itemCount: b._count.items, + })); + }, +}; diff --git a/apps/Backend/src/storage/index.ts b/apps/Backend/src/storage/index.ts index 18c8c337..c5811f07 100755 --- a/apps/Backend/src/storage/index.ts +++ b/apps/Backend/src/storage/index.ts @@ -23,6 +23,7 @@ import { officeHoursStorage } from "./office-hours-storage"; import { officeContactStorage } from "./office-contact-storage"; import { procedureTimeslotStorage } from "./procedure-timeslot-storage"; import { insuranceContactStorage } from "./insurance-contact-storage"; +import { commissionsStorage } from "./commissions-storage"; export const storage = { @@ -49,6 +50,7 @@ export const storage = { ...officeContactStorage, ...procedureTimeslotStorage, ...insuranceContactStorage, + ...commissionsStorage, }; diff --git a/apps/Backend/src/storage/payments-reports-storage.ts b/apps/Backend/src/storage/payments-reports-storage.ts index cedbb35f..ca90385b 100755 --- a/apps/Backend/src/storage/payments-reports-storage.ts +++ b/apps/Backend/src/storage/payments-reports-storage.ts @@ -5,10 +5,10 @@ import { } from "../../../../packages/db/types/payments-reports-types"; export interface IPaymentsReportsStorage { - // summary now returns an extra field patientsWithBalance getSummary( from?: Date | null, - to?: Date | null + to?: Date | null, + npiProviderId?: number | null ): Promise<{ totalPatients: number; totalOutstanding: number; @@ -16,44 +16,28 @@ export interface IPaymentsReportsStorage { patientsWithBalance: number; }>; - /** - * Cursor-based pagination: - * - limit: page size - * - cursorToken: base64(JSON) token for last-seen row (or null for first page) - * - from/to: optional date range filter applied to Payment."createdAt" - */ getPatientsWithBalances( limit: number, cursorToken?: string | null, from?: Date | null, - to?: Date | null + to?: Date | null, + npiProviderId?: number | null ): Promise; - /** - * Returns the paginated patient balances for a specific staff (doctor). - * Same semantics / columns / ordering / cursor behavior as the previous combined function. - * - * - staffId required - * - limit: page size - * - cursorToken: optional base64 cursor (must have been produced for same staffId) - * - from/to: optional date range applied to Payment."createdAt" - */ getPatientsBalancesByDoctor( staffId: number, limit: number, cursorToken?: string | null, from?: Date | null, - to?: Date | null + to?: Date | null, + npiProviderId?: number | null ): Promise; - /** - * Returns only the summary object for the given staff (doctor). - * Same summary shape as getSummary(), but scoped to claims/payments associated with the given staffId. - */ getSummaryByDoctor( staffId: number, from?: Date | null, - to?: Date | null + to?: Date | null, + npiProviderId?: number | null ): Promise<{ totalPatients: number; totalOutstanding: number; @@ -129,208 +113,72 @@ function decodeCursor(token?: string | null): { } export const paymentsReportsStorage: IPaymentsReportsStorage = { - async getSummary(from?: Date | null, to?: Date | null) { + async getSummary(from?: Date | null, to?: Date | null, npiProviderId?: number | null) { try { - const hasFrom = from !== undefined && from !== null; - const hasTo = to !== undefined && to !== null; + const fromStart = isoStartOfDayLiteral(from); + const toNextStart = isoStartOfNextDayLiteral(to); - // Use inclusive start-of-day for 'from' and exclusive start-of-next-day for 'to' - const fromStart = isoStartOfDayLiteral(from); // 'YYYY-MM-DDT00:00:00.000Z' - const toNextStart = isoStartOfNextDayLiteral(to); // 'YYYY-MM-DDT00:00:00.000Z' of next day + // Build a shared WHERE clause for Payment rows + const conditions: string[] = []; + if (from) conditions.push(`pay."createdAt" >= ${fromStart}`); + if (to) conditions.push(`pay."createdAt" <= ${toNextStart}`); + if (npiProviderId) conditions.push(`pay."npiProviderId" = ${Number(npiProviderId)}`); + const payWhereClause = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""; - // totalPatients: distinct patients who had payments in the date range - let patientsCountSql = ""; - if (hasFrom && hasTo) { - patientsCountSql = ` - SELECT COUNT(*)::int AS cnt FROM ( - SELECT pay."patientId" AS patient_id - FROM "Payment" pay - WHERE pay."createdAt" >= ${fromStart} AND pay."createdAt" <= ${toNextStart} - GROUP BY pay."patientId" - ) t - `; - } else if (hasFrom) { - patientsCountSql = ` - SELECT COUNT(*)::int AS cnt FROM ( - SELECT pay."patientId" AS patient_id - FROM "Payment" pay - WHERE pay."createdAt" >= ${fromStart} - GROUP BY pay."patientId" - ) t - `; - } else if (hasTo) { - patientsCountSql = ` - SELECT COUNT(*)::int AS cnt FROM ( - SELECT pay."patientId" AS patient_id - FROM "Payment" pay - WHERE pay."createdAt" <= ${toNextStart} - GROUP BY pay."patientId" - ) t - `; - } else { - patientsCountSql = `SELECT COUNT(DISTINCT "patientId")::int AS cnt FROM "Payment"`; - } - const patientsCntRows = (await prisma.$queryRawUnsafe( - patientsCountSql - )) as { cnt: number }[]; + const patientsCountSql = ` + SELECT COUNT(*)::int AS cnt FROM ( + SELECT pay."patientId" AS patient_id + FROM "Payment" pay + ${payWhereClause} + GROUP BY pay."patientId" + ) t + `; + const patientsCntRows = (await prisma.$queryRawUnsafe(patientsCountSql)) as { cnt: number }[]; const totalPatients = patientsCntRows?.[0]?.cnt ?? 0; - // totalOutstanding: totalBilled - mhPaidAmount - copayment - adjustment - let outstandingSql = ""; - if (hasFrom && hasTo) { - outstandingSql = ` - SELECT COALESCE(SUM( - GREATEST(COALESCE(pm.total_charges,0) - COALESCE(pm.mh_paid,0) - COALESCE(pm.copayment,0) - COALESCE(pm.adjustment,0), 0) - ),0)::numeric(14,2) AS outstanding - FROM ( - SELECT pay."patientId" AS patient_id, - SUM(pay."totalBilled")::numeric(14,2) AS total_charges, - SUM(COALESCE(pay."mhPaidAmount",0))::numeric(14,2) AS mh_paid, - SUM(pay."copayment")::numeric(14,2) AS copayment, - SUM(pay."adjustment")::numeric(14,2) AS adjustment - FROM "Payment" pay - WHERE pay."createdAt" >= ${fromStart} AND pay."createdAt" <= ${toNextStart} - GROUP BY pay."patientId" - ) pm - `; - } else if (hasFrom) { - outstandingSql = ` - SELECT COALESCE(SUM( - GREATEST(COALESCE(pm.total_charges,0) - COALESCE(pm.mh_paid,0) - COALESCE(pm.copayment,0) - COALESCE(pm.adjustment,0), 0) - ),0)::numeric(14,2) AS outstanding - FROM ( - SELECT pay."patientId" AS patient_id, - SUM(pay."totalBilled")::numeric(14,2) AS total_charges, - SUM(COALESCE(pay."mhPaidAmount",0))::numeric(14,2) AS mh_paid, - SUM(pay."copayment")::numeric(14,2) AS copayment, - SUM(pay."adjustment")::numeric(14,2) AS adjustment - FROM "Payment" pay - WHERE pay."createdAt" >= ${fromStart} - GROUP BY pay."patientId" - ) pm - `; - } else if (hasTo) { - outstandingSql = ` - SELECT COALESCE(SUM( - GREATEST(COALESCE(pm.total_charges,0) - COALESCE(pm.mh_paid,0) - COALESCE(pm.copayment,0) - COALESCE(pm.adjustment,0), 0) - ),0)::numeric(14,2) AS outstanding - FROM ( - SELECT pay."patientId" AS patient_id, - SUM(pay."totalBilled")::numeric(14,2) AS total_charges, - SUM(COALESCE(pay."mhPaidAmount",0))::numeric(14,2) AS mh_paid, - SUM(pay."copayment")::numeric(14,2) AS copayment, - SUM(pay."adjustment")::numeric(14,2) AS adjustment - FROM "Payment" pay - WHERE pay."createdAt" <= ${toNextStart} - GROUP BY pay."patientId" - ) pm - `; - } else { - outstandingSql = ` - SELECT COALESCE(SUM( - GREATEST(COALESCE(pm.total_charges,0) - COALESCE(pm.mh_paid,0) - COALESCE(pm.copayment,0) - COALESCE(pm.adjustment,0), 0) - ),0)::numeric(14,2) AS outstanding - FROM ( - SELECT pay."patientId" AS patient_id, - SUM(pay."totalBilled")::numeric(14,2) AS total_charges, - SUM(COALESCE(pay."mhPaidAmount",0))::numeric(14,2) AS mh_paid, - SUM(pay."copayment")::numeric(14,2) AS copayment, - SUM(pay."adjustment")::numeric(14,2) AS adjustment - FROM "Payment" pay - GROUP BY pay."patientId" - ) pm - `; - } - const outstandingRows = (await prisma.$queryRawUnsafe( - outstandingSql - )) as { outstanding: string }[]; + const outstandingSql = ` + SELECT COALESCE(SUM( + GREATEST(COALESCE(pm.total_charges,0) - COALESCE(pm.mh_paid,0) - COALESCE(pm.copayment,0) - COALESCE(pm.adjustment,0), 0) + ),0)::numeric(14,2) AS outstanding + FROM ( + SELECT pay."patientId" AS patient_id, + SUM(pay."totalBilled")::numeric(14,2) AS total_charges, + SUM(COALESCE(pay."mhPaidAmount",0))::numeric(14,2) AS mh_paid, + SUM(pay."copayment")::numeric(14,2) AS copayment, + SUM(pay."adjustment")::numeric(14,2) AS adjustment + FROM "Payment" pay + ${payWhereClause} + GROUP BY pay."patientId" + ) pm + `; + const outstandingRows = (await prisma.$queryRawUnsafe(outstandingSql)) as { outstanding: string }[]; const totalOutstanding = Number(outstandingRows?.[0]?.outstanding ?? 0); - // totalCollected: mhPaidAmount + copayment (adjustment is write-off, not collected) - let collSql = ""; - if (hasFrom && hasTo) { - collSql = `SELECT COALESCE(SUM(COALESCE("mhPaidAmount",0) + "copayment"),0)::numeric(14,2) AS collected FROM "Payment" WHERE "createdAt" >= ${fromStart} AND "createdAt" <= ${toNextStart}`; - } else if (hasFrom) { - collSql = `SELECT COALESCE(SUM(COALESCE("mhPaidAmount",0) + "copayment"),0)::numeric(14,2) AS collected FROM "Payment" WHERE "createdAt" >= ${fromStart}`; - } else if (hasTo) { - collSql = `SELECT COALESCE(SUM(COALESCE("mhPaidAmount",0) + "copayment"),0)::numeric(14,2) AS collected FROM "Payment" WHERE "createdAt" <= ${toNextStart}`; - } else { - collSql = `SELECT COALESCE(SUM(COALESCE("mhPaidAmount",0) + "copayment"),0)::numeric(14,2) AS collected FROM "Payment"`; - } - const collRows = (await prisma.$queryRawUnsafe(collSql)) as { - collected: string; - }[]; + const collSql = ` + SELECT COALESCE(SUM(COALESCE(pay."mhPaidAmount",0) + pay."copayment"),0)::numeric(14,2) AS collected + FROM "Payment" pay + ${payWhereClause} + `; + const collRows = (await prisma.$queryRawUnsafe(collSql)) as { collected: string }[]; const totalCollected = Number(collRows?.[0]?.collected ?? 0); - // patientsWithBalance: patients where (billed - mhPaid - copayment - adjustment) > 0 - let patientsWithBalanceSql = ""; - if (hasFrom && hasTo) { - patientsWithBalanceSql = ` - SELECT COUNT(*)::int AS cnt FROM ( - SELECT pay."patientId" AS patient_id, - SUM(pay."totalBilled")::numeric(14,2) AS total_charges, - SUM(COALESCE(pay."mhPaidAmount",0))::numeric(14,2) AS mh_paid, - SUM(pay."copayment")::numeric(14,2) AS copayment, - SUM(pay."adjustment")::numeric(14,2) AS adjustment - FROM "Payment" pay - WHERE pay."createdAt" >= ${fromStart} AND pay."createdAt" <= ${toNextStart} - GROUP BY pay."patientId" - ) t - WHERE (COALESCE(t.total_charges,0) - COALESCE(t.mh_paid,0) - COALESCE(t.copayment,0) - COALESCE(t.adjustment,0)) > 0 - `; - } else if (hasFrom) { - patientsWithBalanceSql = ` - SELECT COUNT(*)::int AS cnt FROM ( - SELECT pay."patientId" AS patient_id, - SUM(pay."totalBilled")::numeric(14,2) AS total_charges, - SUM(COALESCE(pay."mhPaidAmount",0))::numeric(14,2) AS mh_paid, - SUM(pay."copayment")::numeric(14,2) AS copayment, - SUM(pay."adjustment")::numeric(14,2) AS adjustment - FROM "Payment" pay - WHERE pay."createdAt" >= ${fromStart} - GROUP BY pay."patientId" - ) t - WHERE (COALESCE(t.total_charges,0) - COALESCE(t.mh_paid,0) - COALESCE(t.copayment,0) - COALESCE(t.adjustment,0)) > 0 - `; - } else if (hasTo) { - patientsWithBalanceSql = ` - SELECT COUNT(*)::int AS cnt FROM ( - SELECT pay."patientId" AS patient_id, - SUM(pay."totalBilled")::numeric(14,2) AS total_charges, - SUM(COALESCE(pay."mhPaidAmount",0))::numeric(14,2) AS mh_paid, - SUM(pay."copayment")::numeric(14,2) AS copayment, - SUM(pay."adjustment")::numeric(14,2) AS adjustment - FROM "Payment" pay - WHERE pay."createdAt" <= ${toNextStart} - GROUP BY pay."patientId" - ) t - WHERE (COALESCE(t.total_charges,0) - COALESCE(t.mh_paid,0) - COALESCE(t.copayment,0) - COALESCE(t.adjustment,0)) > 0 - `; - } else { - patientsWithBalanceSql = ` - SELECT COUNT(*)::int AS cnt FROM ( - SELECT pay."patientId" AS patient_id, - SUM(pay."totalBilled")::numeric(14,2) AS total_charges, - SUM(COALESCE(pay."mhPaidAmount",0))::numeric(14,2) AS mh_paid, - SUM(pay."copayment")::numeric(14,2) AS copayment, - SUM(pay."adjustment")::numeric(14,2) AS adjustment - FROM "Payment" pay - GROUP BY pay."patientId" - ) t - WHERE (COALESCE(t.total_charges,0) - COALESCE(t.mh_paid,0) - COALESCE(t.copayment,0) - COALESCE(t.adjustment,0)) > 0 - `; - } - const pwbRows = (await prisma.$queryRawUnsafe( - patientsWithBalanceSql - )) as { cnt: number }[]; + const patientsWithBalanceSql = ` + SELECT COUNT(*)::int AS cnt FROM ( + SELECT pay."patientId" AS patient_id, + SUM(pay."totalBilled")::numeric(14,2) AS total_charges, + SUM(COALESCE(pay."mhPaidAmount",0))::numeric(14,2) AS mh_paid, + SUM(pay."copayment")::numeric(14,2) AS copayment, + SUM(pay."adjustment")::numeric(14,2) AS adjustment + FROM "Payment" pay + ${payWhereClause} + GROUP BY pay."patientId" + ) t + WHERE (COALESCE(t.total_charges,0) - COALESCE(t.mh_paid,0) - COALESCE(t.copayment,0) - COALESCE(t.adjustment,0)) > 0 + `; + const pwbRows = (await prisma.$queryRawUnsafe(patientsWithBalanceSql)) as { cnt: number }[]; const patientsWithBalance = pwbRows?.[0]?.cnt ?? 0; - return { - totalPatients, - totalOutstanding, - totalCollected, - patientsWithBalance, - }; + return { totalPatients, totalOutstanding, totalCollected, patientsWithBalance }; } catch (err) { console.error("[paymentsReportsStorage.getSummary] error:", err); throw err; @@ -348,7 +196,8 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = { limit = 25, cursorToken?: string | null, from?: Date | null, - to?: Date | null + to?: Date | null, + npiProviderId?: number | null ) { try { type RawRow = { @@ -373,15 +222,12 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = { const fromStart = isoStartOfDayLiteral(from); // 'YYYY-MM-DDT00:00:00.000Z' const toNextStart = isoStartOfNextDayLiteral(to); // 'YYYY-MM-DDT00:00:00.000Z' of next day - // Build payment subquery (aggregated payments by patient, filtered by createdAt if provided) - const paymentWhereClause = - hasFrom && hasTo - ? `WHERE pay."createdAt" >= ${fromStart} AND pay."createdAt" <= ${toNextStart}` - : hasFrom - ? `WHERE pay."createdAt" >= ${fromStart}` - : hasTo - ? `WHERE pay."createdAt" <= ${toNextStart}` - : ""; + // Build payment subquery WHERE clause (date + optional provider filter) + const payConditions: string[] = []; + if (hasFrom) payConditions.push(`pay."createdAt" >= ${fromStart}`); + if (hasTo) payConditions.push(`pay."createdAt" <= ${toNextStart}`); + if (npiProviderId) payConditions.push(`pay."npiProviderId" = ${Number(npiProviderId)}`); + const paymentWhereClause = payConditions.length ? `WHERE ${payConditions.join(" AND ")}` : ""; const pmSubquery = ` ( @@ -542,7 +388,8 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = { limit = 25, cursorToken?: string | null, from?: Date | null, - to?: Date | null + to?: Date | null, + npiProviderId?: number | null ): Promise<{ balances: PatientBalanceRow[]; totalCount: number; @@ -571,15 +418,14 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = { const fromStart = isoStartOfDayLiteral(from); const toNextStart = isoStartOfNextDayLiteral(to); - // Filter payments by createdAt (time window) when provided - const paymentTimeFilter = - hasFrom && hasTo - ? `AND pay."createdAt" >= ${fromStart} AND pay."createdAt" <= ${toNextStart}` - : hasFrom - ? `AND pay."createdAt" >= ${fromStart}` - : hasTo - ? `AND pay."createdAt" <= ${toNextStart}` - : ""; + // Filter payments by createdAt and/or provider + const payAndConditions: string[] = []; + if (hasFrom) payAndConditions.push(`pay."createdAt" >= ${fromStart}`); + if (hasTo) payAndConditions.push(`pay."createdAt" <= ${toNextStart}`); + if (npiProviderId) payAndConditions.push(`pay."npiProviderId" = ${Number(npiProviderId)}`); + const paymentTimeFilter = payAndConditions.length + ? `AND ${payAndConditions.join(" AND ")}` + : ""; // Keyset predicate — prefer numeric epoch-ms comparison for stability let pageKeysetPredicate = ""; @@ -801,7 +647,8 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = { async getSummaryByDoctor( staffId: number, from?: Date | null, - to?: Date | null + to?: Date | null, + npiProviderId?: number | null ): Promise<{ totalPatients: number; totalOutstanding: number; @@ -818,14 +665,13 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = { const fromStart = isoStartOfDayLiteral(from); const toNextStart = isoStartOfNextDayLiteral(to); - const paymentTimeFilter = - hasFrom && hasTo - ? `AND pay."createdAt" >= ${fromStart} AND pay."createdAt" <= ${toNextStart}` - : hasFrom - ? `AND pay."createdAt" >= ${fromStart}` - : hasTo - ? `AND pay."createdAt" <= ${toNextStart}` - : ""; + const drAndConditions: string[] = []; + if (hasFrom) drAndConditions.push(`pay."createdAt" >= ${fromStart}`); + if (hasTo) drAndConditions.push(`pay."createdAt" <= ${toNextStart}`); + if (npiProviderId) drAndConditions.push(`pay."npiProviderId" = ${Number(npiProviderId)}`); + const paymentTimeFilter = drAndConditions.length + ? `AND ${drAndConditions.join(" AND ")}` + : ""; const summaryQuery = ` WITH diff --git a/apps/Backend/src/storage/payments-storage.ts b/apps/Backend/src/storage/payments-storage.ts index 119f5675..790fef47 100755 --- a/apps/Backend/src/storage/payments-storage.ts +++ b/apps/Backend/src/storage/payments-storage.ts @@ -109,6 +109,8 @@ export const paymentsStorage: IStorage = { }, updatedBy: true, patient: true, + npiProvider: true, + commissionBatchItems: { include: { commissionBatch: true } }, }, }); @@ -144,6 +146,8 @@ export const paymentsStorage: IStorage = { }, updatedBy: true, patient: true, + npiProvider: true, + commissionBatchItems: { include: { commissionBatch: true } }, }, }); @@ -177,6 +181,8 @@ export const paymentsStorage: IStorage = { }, updatedBy: true, patient: true, + npiProvider: true, + commissionBatchItems: { include: { commissionBatch: true } }, }, }); @@ -213,6 +219,8 @@ export const paymentsStorage: IStorage = { }, updatedBy: true, patient: true, + npiProvider: true, + commissionBatchItems: { include: { commissionBatch: true } }, }, }); @@ -251,6 +259,8 @@ export const paymentsStorage: IStorage = { }, updatedBy: true, patient: true, + npiProvider: true, + commissionBatchItems: { include: { commissionBatch: true } }, }, }); diff --git a/apps/Frontend/src/components/claims/claim-edit-modal.tsx b/apps/Frontend/src/components/claims/claim-edit-modal.tsx index c13cda4c..d20b456f 100755 --- a/apps/Frontend/src/components/claims/claim-edit-modal.tsx +++ b/apps/Frontend/src/components/claims/claim-edit-modal.tsx @@ -14,8 +14,10 @@ import { SelectValue, } from "@/components/ui/select"; import { formatDateToHumanReadable } from "@/utils/dateUtils"; -import React, { useState } from "react"; -import { ClaimStatus, ClaimWithServiceLines } from "@repo/db/types"; +import React, { useEffect, useState } from "react"; +import { ClaimStatus, ClaimWithServiceLines, NpiProvider } from "@repo/db/types"; +import { useQuery } from "@tanstack/react-query"; +import { apiRequest } from "@/lib/queryClient"; import { safeParseMissingTeeth, splitTeeth, @@ -41,6 +43,28 @@ export default function ClaimEditModal({ const [status, setStatus] = useState( claim?.status ?? ("PENDING" as ClaimStatus) ); + const [selectedNpiProviderId, setSelectedNpiProviderId] = useState( + (claim as any)?.npiProviderId ?? null + ); + + const { data: npiProviders = [] } = useQuery({ + queryKey: ["/api/npiProviders/"], + queryFn: async () => { + const res = await apiRequest("GET", "/api/npiProviders/"); + return res.json(); + }, + }); + + // Default to Mary Scannell (or first provider) only when no provider is set + useEffect(() => { + if (!npiProviders.length) return; + if (selectedNpiProviderId !== null) return; + const mary = npiProviders.find((p) => + p.providerName.toLowerCase().includes("mary scannell") + ); + const fallback = mary ?? npiProviders[0]; + if (fallback) setSelectedNpiProviderId(fallback.id); + }, [npiProviders]); if (!claim) return null; @@ -48,7 +72,9 @@ export default function ClaimEditModal({ const updatedClaim: ClaimWithServiceLines = { ...claim, status, - }; + npiProviderId: selectedNpiProviderId, + npiProvider: npiProviders.find((p) => p.id === selectedNpiProviderId) ?? null, + } as ClaimWithServiceLines; onSave(updatedClaim); onOpenChange(false); @@ -109,6 +135,25 @@ export default function ClaimEditModal({ + +
+ Rendering Provider: + +
diff --git a/apps/Frontend/src/components/claims/claim-form.tsx b/apps/Frontend/src/components/claims/claim-form.tsx index 5a46798b..39e79575 100755 --- a/apps/Frontend/src/components/claims/claim-form.tsx +++ b/apps/Frontend/src/components/claims/claim-form.tsx @@ -788,6 +788,10 @@ export function ClaimForm({ mimeType: f.type, })); + const selectedNpiProviderId = npiProvider?.npiNumber + ? npiProviders.find((p) => p.npiNumber === npiProvider.npiNumber)?.id ?? null + : null; + const createdClaim = await onSubmit({ ...formToCreateClaim, serviceLines: filteredServiceLines, @@ -796,6 +800,7 @@ export function ClaimForm({ insuranceProvider: "MassHealth", appointmentId: appointmentIdToUse!, claimFiles: claimFilesMeta, + ...(selectedNpiProviderId ? { npiProviderId: selectedNpiProviderId } : {}), }); // 4. sending form data to selenium service diff --git a/apps/Frontend/src/components/claims/claims-recent-table.tsx b/apps/Frontend/src/components/claims/claims-recent-table.tsx index bc2b4ac3..6deb027c 100755 --- a/apps/Frontend/src/components/claims/claims-recent-table.tsx +++ b/apps/Frontend/src/components/claims/claims-recent-table.tsx @@ -136,6 +136,9 @@ export default function ClaimsRecentTable({ mutationFn: async (claim: ClaimWithServiceLines) => { const response = await apiRequest("PUT", `/api/claims/${claim.id}`, { status: claim.status, + ...((claim as any).npiProviderId != null + ? { npiProviderId: (claim as any).npiProviderId } + : {}), }); if (!response.ok) { const error = await response.json(); @@ -315,6 +318,7 @@ export default function ClaimsRecentTable({ Total Billed Status Attachments + Provider Actions @@ -448,6 +452,12 @@ export default function ClaimsRecentTable({ )} + +
+ {claim.npiProvider?.providerName ?? "—"} +
+
+
{allowDelete && ( diff --git a/apps/Frontend/src/components/payments/payment-edit-modal.tsx b/apps/Frontend/src/components/payments/payment-edit-modal.tsx index 49a9fec1..e8653a96 100755 --- a/apps/Frontend/src/components/payments/payment-edit-modal.tsx +++ b/apps/Frontend/src/components/payments/payment-edit-modal.tsx @@ -21,6 +21,7 @@ import { paymentStatusArray, paymentMethodArray, } from "@repo/db/types"; +import { NpiProvider } from "@repo/db/types"; import { Select, SelectContent, @@ -33,7 +34,7 @@ import { toast } from "@/hooks/use-toast"; import { X } from "lucide-react"; import { DateInput } from "@/components/ui/dateInput"; import { apiRequest } from "@/lib/queryClient"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; type PaymentEditModalProps = { isOpen: boolean; @@ -84,6 +85,57 @@ export default function PaymentEditModal({ const [paymentStatus, setPaymentStatus] = useState( (paymentProp ?? null)?.status ?? ("PENDING" as PaymentStatus) ); + const [selectedNpiProviderId, setSelectedNpiProviderId] = useState( + (paymentProp ?? null)?.npiProviderId ?? null + ); + const [isUpdatingProvider, setIsUpdatingProvider] = useState(false); + + // Fetch all NPI providers + const { data: npiProviders = [] } = useQuery({ + queryKey: ["/api/npiProviders/"], + queryFn: async () => { + const res = await apiRequest("GET", "/api/npiProviders/"); + return res.json(); + }, + }); + + // Default to Mary Scannell (or first provider) if no provider set yet + useEffect(() => { + if (!npiProviders.length) return; + if (selectedNpiProviderId !== null) return; + const mary = npiProviders.find((p) => + p.providerName.toLowerCase().includes("mary scannell") + ); + const fallback = mary ?? npiProviders[0]; + if (fallback) setSelectedNpiProviderId(fallback.id); + }, [npiProviders]); + + // Sync provider when payment changes (e.g. fetched from API) + useEffect(() => { + if (payment?.npiProviderId !== undefined) { + setSelectedNpiProviderId(payment.npiProviderId ?? null); + } + }, [payment?.id]); + + const handleUpdateProvider = async () => { + if (!payment) return; + setIsUpdatingProvider(true); + try { + const res = await apiRequest("PATCH", `/api/payments/${payment.id}/provider`, { + npiProviderId: selectedNpiProviderId, + }); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.message || "Failed to update provider"); + } + toast({ title: "Success", description: "Provider updated successfully." }); + await refetchPayment(payment.id); + } catch (err: any) { + toast({ title: "Error", description: err?.message ?? "Failed to update provider.", variant: "destructive" }); + } finally { + setIsUpdatingProvider(false); + } + }; const [formState, setFormState] = useState(() => { return { @@ -588,6 +640,36 @@ export default function PaymentEditModal({ > {isUpdatingStatus ? "Updating..." : "Update Status"} + + {/* Provider Selector */} +
+ + +
+ +
@@ -607,6 +689,23 @@ export default function PaymentEditModal({ ? formatDateToHumanReadable(payment.updatedAt) : "N/A"}

+ {(payment as any).commissionBatchItems?.length > 0 ? ( +
+ + ✓ Commissioned + +

+ Paid as commission on{" "} + {formatDateToHumanReadable( + (payment as any).commissionBatchItems[0].commissionBatch.createdAt + )} +

+
+ ) : ( +

+ Not yet commissioned +

+ )} diff --git a/apps/Frontend/src/components/payments/payments-recent-table.tsx b/apps/Frontend/src/components/payments/payments-recent-table.tsx index 0183b8a2..2867fe9e 100755 --- a/apps/Frontend/src/components/payments/payments-recent-table.tsx +++ b/apps/Frontend/src/components/payments/payments-recent-table.tsx @@ -612,6 +612,7 @@ export default function PaymentsRecentTable({ Service Date Status Attachments + Provider MH Paid Copayment Adjustment @@ -738,20 +739,28 @@ export default function PaymentsRecentTable({
-
+
{payment.status === "VOID" ? ( - + Void ) : payment.status === "PAID" ? ( - + Paid in Full ) : ( - + Balance )} + {(payment as any).commissionBatchItems?.length > 0 && ( + + ✓ Commissioned + + )}
@@ -775,6 +784,12 @@ export default function PaymentsRecentTable({ )} + +
+ {(payment as any).npiProvider?.providerName ?? "—"} +
+
+ {editingMhPaidId === payment.id ? ( (""); @@ -76,6 +78,7 @@ export default function CollectionsByDoctorReport({ perPage, startDate, endDate, + npiProviderId ?? "all", ], queryFn: async () => { const params = new URLSearchParams(); @@ -84,6 +87,7 @@ export default function CollectionsByDoctorReport({ if (staffId) params.set("staffId", staffId); if (startDate) params.set("from", startDate); if (endDate) params.set("to", endDate); + if (npiProviderId) params.set("npiProviderId", String(npiProviderId)); const res = await apiRequest( "GET", @@ -116,12 +120,13 @@ export default function CollectionsByDoctorReport({ }, Error >({ - queryKey: ["collections-by-doctor-summary", staffId, startDate, endDate], + queryKey: ["collections-by-doctor-summary", staffId, startDate, endDate, npiProviderId ?? "all"], queryFn: async () => { const params = new URLSearchParams(); if (staffId) params.set("staffId", staffId); if (startDate) params.set("from", startDate); if (endDate) params.set("to", endDate); + if (npiProviderId) params.set("npiProviderId", String(npiProviderId)); const res = await apiRequest( "GET", @@ -152,7 +157,7 @@ export default function CollectionsByDoctorReport({ useEffect(() => { setCursorStack([null]); setCursorIndex(0); - }, [staffId, startDate, endDate]); + }, [staffId, startDate, endDate, npiProviderId]); const handlePrev = useCallback(() => { setCursorIndex((i) => Math.max(0, i - 1)); diff --git a/apps/Frontend/src/components/reports/commission-section.tsx b/apps/Frontend/src/components/reports/commission-section.tsx new file mode 100644 index 00000000..a9095c23 --- /dev/null +++ b/apps/Frontend/src/components/reports/commission-section.tsx @@ -0,0 +1,516 @@ +import React, { useEffect, useRef, useState } from "react"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { DateInput } from "@/components/ui/dateInput"; +import { formatLocalDate, parseLocalDate, formatDateToHumanReadable } from "@/utils/dateUtils"; +import { apiRequest, queryClient } from "@/lib/queryClient"; +import { useToast } from "@/hooks/use-toast"; +import { DollarSign } from "lucide-react"; +import { NpiProvider } from "@repo/db/types"; + +interface EligiblePayment { + id: number; + claimNumber: string | null; + patientName: string; + serviceDate: string | null; + mhPaidAmount: number; + copayment: number; + collectionAmount: number; + paymentDate: string; + isOcr: boolean; +} + +interface CommissionBatch { + id: number; + providerName: string; + totalCollection: number; + commissionAmount: number; + notes: string | null; + createdAt: string; + itemCount: number; +} + +function fmt(n: number) { + return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(n); +} + +export default function CommissionSection() { + const { toast } = useToast(); + const printRef = useRef(null); + + // Filters + const [fromDate, setFromDate] = useState(() => { + const d = new Date(); + d.setMonth(d.getMonth() - 1); + return d.toISOString().split("T")[0] ?? ""; + }); + const [toDate, setToDate] = useState( + () => new Date().toISOString().split("T")[0] ?? "" + ); + const [selectedProviderId, setSelectedProviderId] = useState(null); + + // Selection + const [selectedIds, setSelectedIds] = useState>(new Set()); + + // Pay modal + const [showModal, setShowModal] = useState(false); + const [commissionAmount, setCommissionAmount] = useState(""); + const [notes, setNotes] = useState(""); + + // Providers + const { data: providers = [] } = useQuery({ + queryKey: ["/api/npiProviders/"], + queryFn: async () => { + const res = await apiRequest("GET", "/api/npiProviders/"); + return res.json(); + }, + staleTime: 60_000, + }); + + // Eligible payments + const { + data: payments = [], + isLoading, + isError, + } = useQuery({ + queryKey: ["/api/commissions/eligible", selectedProviderId, fromDate, toDate], + queryFn: async () => { + const params = new URLSearchParams(); + params.set("npiProviderId", String(selectedProviderId)); + if (fromDate) params.set("from", fromDate); + if (toDate) params.set("to", toDate); + const res = await apiRequest("GET", `/api/commissions/eligible?${params}`); + if (!res.ok) throw new Error("Failed to fetch eligible payments"); + return res.json(); + }, + enabled: !!selectedProviderId, + }); + + // Past batches + const { data: batches = [] } = useQuery({ + queryKey: ["/api/commissions/batches", selectedProviderId], + queryFn: async () => { + const params = selectedProviderId + ? `?npiProviderId=${selectedProviderId}` + : ""; + const res = await apiRequest("GET", `/api/commissions/batches${params}`); + if (!res.ok) throw new Error("Failed to fetch batches"); + return res.json(); + }, + }); + + // Reset selection when provider/dates change + useEffect(() => { + setSelectedIds(new Set()); + }, [selectedProviderId, fromDate, toDate]); + + // Create commission batch mutation + const createMutation = useMutation({ + mutationFn: async (payload: { + npiProviderId: number; + paymentIds: number[]; + totalCollection: number; + commissionAmount: number; + notes?: string; + }) => { + const res = await apiRequest("POST", "/api/commissions", payload); + if (!res.ok) { + const err = await res.json().catch(() => null); + throw new Error(err?.message ?? "Failed to save commission"); + } + return res.json(); + }, + onSuccess: () => { + toast({ title: "Commission saved", description: "Payments have been marked as commissioned." }); + setShowModal(false); + setSelectedIds(new Set()); + queryClient.invalidateQueries({ queryKey: ["/api/commissions/eligible"] }); + queryClient.invalidateQueries({ queryKey: ["/api/commissions/batches"] }); + }, + onError: (err: any) => { + toast({ title: "Error", description: err?.message ?? "Failed to save", variant: "destructive" }); + }, + }); + + const selectedPayments = payments.filter((p) => selectedIds.has(p.id)); + const totalCollection = selectedPayments.reduce((s, p) => s + p.collectionAmount, 0); + + // Sync commission amount when selection changes + useEffect(() => { + setCommissionAmount(totalCollection.toFixed(2)); + }, [totalCollection]); + + const allSelected = payments.length > 0 && selectedIds.size === payments.length; + const someSelected = selectedIds.size > 0 && !allSelected; + + const toggleAll = () => { + if (allSelected) { + setSelectedIds(new Set()); + } else { + setSelectedIds(new Set(payments.map((p) => p.id))); + } + }; + + const toggleOne = (id: number) => { + setSelectedIds((prev) => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + }; + + const handlePrint = () => { + const provider = providers.find((p) => p.id === selectedProviderId); + const win = window.open("", "_blank"); + if (!win) return; + win.document.write(` + Commission Summary + +

Commission Summary — ${provider?.providerName ?? "Provider"}

+

Date Range: ${fromDate || "—"} to ${toDate || "—"}

+

Generated: ${new Date().toLocaleDateString()}

+ + + + + + + ${selectedPayments + .map( + (p, i) => ` + + + + + + + + ` + ) + .join("")} + +
#Claim / SourcePatientService DateMH PaidCopaymentCollection
${i + 1}${p.claimNumber ?? (p.isOcr ? "PDF Import" : "—")}${p.patientName}${p.serviceDate ? new Date(p.serviceDate).toLocaleDateString() : "—"}${fmt(p.mhPaidAmount)}${fmt(p.copayment)}${fmt(p.collectionAmount)}
+

Total Collection: ${fmt(totalCollection)}

+

Commission Amount: ${fmt(Number(commissionAmount) || 0)}

+ ${notes ? `

Notes: ${notes}

` : ""} + + + `); + win.document.close(); + win.print(); + }; + + const handleSave = () => { + if (!selectedProviderId || selectedPayments.length === 0) return; + const amount = Number(commissionAmount); + if (isNaN(amount) || amount < 0) { + toast({ title: "Invalid amount", description: "Enter a valid commission amount.", variant: "destructive" }); + return; + } + createMutation.mutate({ + npiProviderId: selectedProviderId, + paymentIds: selectedPayments.map((p) => p.id), + totalCollection, + commissionAmount: amount, + notes: notes.trim() || undefined, + }); + }; + + const fromDateObj = fromDate ? (() => { try { return parseLocalDate(fromDate); } catch { return null; } })() : null; + const toDateObj = toDate ? (() => { try { return parseLocalDate(toDate); } catch { return null; } })() : null; + + return ( + + + + Commission + + + + + {/* Filters */} +
+ setFromDate(d ? formatLocalDate(d) : "")} + disableFuture + /> + setToDate(d ? formatLocalDate(d) : "")} + disableFuture + /> +
+ + +
+
+ + {/* Eligible payments table */} + {selectedProviderId && ( +
+
+

+ {isLoading + ? "Loading…" + : `${payments.length} eligible payment(s) — not yet commissioned`} +

+ {selectedIds.size > 0 && ( + + )} +
+ + {isError && ( +

Failed to load payments.

+ )} + + {!isLoading && !isError && payments.length === 0 && ( +

+ No uncommissioned payments found for this provider and date range. +

+ )} + + {payments.length > 0 && ( +
+ + + + + + + + + + + + + + {payments.map((p) => ( + toggleOne(p.id)} + > + + + + + + + + + ))} + + {selectedIds.size > 0 && ( + + + + + + + )} +
+ + Claim / SourcePatientService DateMH PaidCopaymentCollection
+ toggleOne(p.id)} + onClick={(e) => e.stopPropagation()} + /> + + {p.claimNumber ?? (p.isOcr ? ( + PDF Import + ) : "—")} + {p.patientName} + {p.serviceDate + ? new Date(p.serviceDate).toLocaleDateString() + : "—"} + {fmt(p.mhPaidAmount)}{fmt(p.copayment)}{fmt(p.collectionAmount)}
+ Selected Total ({selectedIds.size} items): + + {fmt(totalCollection)} +
+
+ )} +
+ )} + + {/* Past commissions */} + {batches.length > 0 && ( +
+

Past Commission Batches

+
+ + + + + + + + + + + + + {batches.map((b) => ( + + + + + + + + + ))} + +
Date PaidProviderClaimsTotal CollectionCommission PaidNotes
{formatDateToHumanReadable(b.createdAt)}{b.providerName}{b.itemCount}{fmt(b.totalCollection)}{fmt(b.commissionAmount)}{b.notes ?? "—"}
+
+
+ )} +
+ + {/* Pay Commission Modal */} + + + + Pay Commission + + Review the selected payments and confirm the commission amount. + + + +
+ {/* Selected rows summary */} +
+ + + + + + + + + + + + {selectedPayments.map((p) => ( + + + + + + + + ))} + + + + + + + +
Claim / SourcePatientMH PaidCopaymentCollection
+ {p.claimNumber ?? (p.isOcr ? "PDF Import" : "—")} + {p.patientName}{fmt(p.mhPaidAmount)}{fmt(p.copayment)}{fmt(p.collectionAmount)}
Total Collection:{fmt(totalCollection)}
+
+ + {/* Commission amount input */} +
+
+ + setCommissionAmount(e.target.value)} + placeholder="Enter commission amount" + /> +

Defaults to total collection. Adjust if using a rate.

+
+
+ + setNotes(e.target.value)} + placeholder="e.g. May 2026 commission @ 20%" + /> +
+
+ +
+ + + +
+
+
+
+
+ ); +} diff --git a/apps/Frontend/src/components/reports/patients-with-balance-report.tsx b/apps/Frontend/src/components/reports/patients-with-balance-report.tsx index d2215fe4..cad9dce1 100755 --- a/apps/Frontend/src/components/reports/patients-with-balance-report.tsx +++ b/apps/Frontend/src/components/reports/patients-with-balance-report.tsx @@ -15,9 +15,11 @@ type Resp = { export default function PatientsWithBalanceReport({ startDate, endDate, + npiProviderId, }: { startDate: string; endDate: string; + npiProviderId?: number | null; }) { const balancesPerPage = 10; const [cursorStack, setCursorStack] = useState<(string | null)[]>([null]); @@ -32,6 +34,7 @@ export default function PatientsWithBalanceReport({ balancesPerPage, startDate, endDate, + npiProviderId ?? "all", ], queryFn: async () => { const params = new URLSearchParams(); @@ -39,6 +42,7 @@ export default function PatientsWithBalanceReport({ if (currentCursor) params.set("cursor", currentCursor); if (startDate) params.set("from", startDate); if (endDate) params.set("to", endDate); + if (npiProviderId) params.set("npiProviderId", String(npiProviderId)); const res = await apiRequest( "GET", `/api/payments-reports/patients-with-balances?${params.toString()}` @@ -63,7 +67,7 @@ export default function PatientsWithBalanceReport({ setCursorStack([null]); setCursorIndex(0); refetch(); - }, [startDate, endDate]); + }, [startDate, endDate, npiProviderId]); const handleNext = useCallback(() => { const idx = cursorIndex; diff --git a/apps/Frontend/src/components/reports/report-config.tsx b/apps/Frontend/src/components/reports/report-config.tsx index 01e00c82..b4d4c92e 100755 --- a/apps/Frontend/src/components/reports/report-config.tsx +++ b/apps/Frontend/src/components/reports/report-config.tsx @@ -11,12 +11,14 @@ import { import { Calendar } from "lucide-react"; import { formatLocalDate, parseLocalDate } from "@/utils/dateUtils"; import { DateInput } from "@/components/ui/dateInput"; +import { useQuery } from "@tanstack/react-query"; +import { apiRequest } from "@/lib/queryClient"; +import { NpiProvider } from "@repo/db/types"; type ReportType = | "patients_with_balance" | "patients_no_balance" | "monthly_collections" - | "collections_by_doctor" | "procedure_codes_by_doctor" | "payment_methods" | "insurance_vs_patient_payments" @@ -29,34 +31,37 @@ export default function ReportConfig({ setEndDate, selectedReportType, setSelectedReportType, + npiProviderId, + setNpiProviderId, }: { - startDate: string; // "" or "YYYY-MM-DD" + startDate: string; endDate: string; setStartDate: (s: string) => void; setEndDate: (s: string) => void; selectedReportType: ReportType; setSelectedReportType: (r: ReportType) => void; + npiProviderId: number | null; + setNpiProviderId: (id: number | null) => void; }) { - // Convert incoming string -> Date | null using your parseLocalDate utility. - // parseLocalDate can throw for invalid strings, so guard with try/catch. let startDateObj: Date | null = null; if (startDate) { - try { - startDateObj = parseLocalDate(startDate); - } catch { - startDateObj = null; - } + try { startDateObj = parseLocalDate(startDate); } catch { startDateObj = null; } } let endDateObj: Date | null = null; if (endDate) { - try { - endDateObj = parseLocalDate(endDate); - } catch { - endDateObj = null; - } + try { endDateObj = parseLocalDate(endDate); } catch { endDateObj = null; } } + const { data: npiProviders = [] } = useQuery({ + queryKey: ["/api/npiProviders/"], + queryFn: async () => { + const res = await apiRequest("GET", "/api/npiProviders/"); + return res.json(); + }, + staleTime: 60_000, + }); + return ( @@ -67,17 +72,15 @@ export default function ReportConfig({
- Choose the report type and date range. + Choose the report type, date range, and provider.
-
+
{ - setStartDate(d ? formatLocalDate(d) : ""); - }} + onChange={(d) => setStartDate(d ? formatLocalDate(d) : "")} disableFuture />
@@ -86,13 +89,33 @@ export default function ReportConfig({ { - setEndDate(d ? formatLocalDate(d) : ""); - }} + onChange={(d) => setEndDate(d ? formatLocalDate(d) : "")} disableFuture />
+
+ + +
+