feat: add provider column, commission tracking, and report provider filter
- Claims & Payments: save npiProviderId when submitting MH claim; sync between claim and payment on update - Claims table: add Provider column showing rendering provider name - Payments table: add Provider column + purple Commissioned badge on status - Claim edit modal: add Rendering Provider dropdown (defaults to Mary Scannell) - Payment edit modal: add Rendering Provider dropdown + Commissioned metadata display - Reports page: add Provider filter dropdown (dynamic from NPI providers settings) - Reports page: remove Collections by Doctor report type and Select Doctor dropdown - Commission section: new section in reports page with date range + provider filter, shows eligible paid claims/payments per provider, multi-select checkboxes, Pay Commission modal with print + save, marks payments as commissioned so they are excluded from future cycles - DB: add CommissionBatch and CommissionBatchItem tables; backfill Payment.npiProviderId from linked claims - Backend: PATCH /api/payments/:id/provider syncs to linked claim; PUT /api/claims/:id syncs to linked payment; new /api/commissions routes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1063,6 +1063,16 @@ router.put("/:id", async (req: Request, res: Response): Promise<any> => {
|
||||
...(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) {
|
||||
|
||||
81
apps/Backend/src/routes/commissions.ts
Normal file
81
apps/Backend/src/routes/commissions.ts
Normal file
@@ -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<any> => {
|
||||
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<any> => {
|
||||
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<any> => {
|
||||
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;
|
||||
@@ -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;
|
||||
|
||||
@@ -17,8 +17,11 @@ router.get("/summary", async (req: Request, res: Response): Promise<any> => {
|
||||
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) {
|
||||
|
||||
@@ -200,10 +200,17 @@ router.post("/:claimId", async (req: Request, res: Response): Promise<any> => {
|
||||
|
||||
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<any> => {
|
||||
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<typeof prisma.$transaction>[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",
|
||||
|
||||
@@ -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<ClaimWithServiceLines | null>;
|
||||
},
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
193
apps/Backend/src/storage/commissions-storage.ts
Normal file
193
apps/Backend/src/storage/commissions-storage.ts
Normal file
@@ -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<EligiblePaymentRow[]> {
|
||||
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<CommissionBatchRecord[]> {
|
||||
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,
|
||||
}));
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
};
|
||||
|
||||
|
||||
@@ -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<GetPatientBalancesResult>;
|
||||
|
||||
/**
|
||||
* 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<GetPatientBalancesResult>;
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -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 } },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user