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

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