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:
Gitead
2026-05-15 23:51:39 -04:00
parent 25a20e8a16
commit 7360b1930b
366 changed files with 10822 additions and 388 deletions

View File

@@ -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) {

View 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;

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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",

View File

@@ -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 },
});
},

View 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,
}));
},
};

View File

@@ -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,
};

View File

@@ -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

View File

@@ -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 } },
},
});