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