- 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>
194 lines
5.7 KiB
TypeScript
194 lines
5.7 KiB
TypeScript
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,
|
|
}));
|
|
},
|
|
};
|