Files
DentalManagement09/apps/Backend/src/storage/commissions-storage.ts
T
GiteadandClaude Sonnet 4.6 7360b1930b 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>
2026-05-15 23:51:39 -04:00

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