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",
|
||||
|
||||
Reference in New Issue
Block a user