feat: invoices — patient billing with installments + PDF export

Backend (new `invoice` RBAC resource, granted to clinicians + reception):
- invoices table (line items + installments as JSONB), types, zod validation,
  service (CRUD + splitIntoInstallments + auto invoice numbers), org-scoped
  REST routes mounted at /api/invoices, activity logging (migration 0015)

Frontend:
- lib/invoices.ts API client + money/date helpers
- /invoices page: list with KPIs and search, create/edit dialog (searchable
  patient combobox, inline line-item editor, live total), detail sheet to split
  a bill into equal monthly installments, delete, and Download PDF
- dependency-free PDF via a print-styled window (browser "Save as PDF")
- sidebar "Invoices" entry under the Patients group; "Added by AI" badge honored

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-14 19:58:08 +03:00
parent 9eb6353e00
commit d096c4fe9d
21 changed files with 4734 additions and 0 deletions
+1
View File
@@ -3,6 +3,7 @@ export * from "./patients.js";
export * from "./notes.js";
export * from "./appointments.js";
export * from "./prescriptions.js";
export * from "./invoices.js";
export * from "./inventory.js";
export * from "./tasks.js";
export * from "./activity.js";
+55
View File
@@ -0,0 +1,55 @@
import {
date,
index,
jsonb,
pgTable,
text,
timestamp,
uuid,
} from "drizzle-orm/pg-core";
import type {
InvoiceInstallment,
InvoiceLineItem,
InvoiceStatus,
} from "../../types/invoice.js";
import { organization, user } from "./auth.js";
// One row per patient invoice, scoped to a clinic (organization). Patient
// identity is denormalized (name/initials/file number) so the ledger renders
// without joining. Line items and installments are stored as JSONB. `issuedAt`
// defaults to today.
export const invoices = pgTable(
"invoices",
{
id: uuid("id").primaryKey().defaultRandom(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
patientFileNumber: text("patient_file_number").notNull().default(""),
patientName: text("patient_name").notNull(),
patientInitials: text("patient_initials").notNull(),
number: text("number").notNull(),
issuedAt: date("issued_at").defaultNow().notNull(),
dueAt: date("due_at"),
status: text("status").$type<InvoiceStatus>().notNull(),
lineItems: jsonb("line_items").$type<InvoiceLineItem[]>().notNull(),
installments: jsonb("installments")
.$type<InvoiceInstallment[]>()
.notNull(),
notes: text("notes"),
source: text("source").$type<"manual" | "ai">().notNull().default("manual"),
createdBy: text("created_by").references(() => user.id, {
onDelete: "set null",
}),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
},
(t) => [
index("invoices_org_idx").on(t.organizationId),
index("invoices_org_file_idx").on(t.organizationId, t.patientFileNumber),
],
);
+2
View File
@@ -15,6 +15,7 @@ import { appointmentsRouter } from "./routes/appointments.js";
import { chatRouter } from "./routes/chat.js";
import { conversationsRouter } from "./routes/conversations.js";
import { inventoryRouter } from "./routes/inventory.js";
import { invoicesRouter } from "./routes/invoices.js";
import { notesRouter } from "./routes/notes.js";
import { notificationsRouter } from "./routes/notifications.js";
import { patientsRouter } from "./routes/patients.js";
@@ -63,6 +64,7 @@ app.use("/api/notes", notesRouter);
app.use("/api/appointments", appointmentsRouter);
app.use("/api/prescriptions", prescriptionsRouter);
app.use("/api/inventory", inventoryRouter);
app.use("/api/invoices", invoicesRouter);
app.use("/api/tasks", tasksRouter);
app.use("/api/staff", staffRouter);
app.use("/api/activity", activityRouter);
+6
View File
@@ -17,6 +17,7 @@ export const statements = {
patient: ["read", "write", "delete"],
appointment: ["read", "write", "delete"],
prescription: ["read", "write", "delete"],
invoice: ["read", "write", "delete"],
inventory: ["read", "write", "delete"],
task: ["read", "write", "delete"],
lab: ["read", "write"],
@@ -40,6 +41,7 @@ export const owner = ac.newRole({
patient: ["read", "write", "delete"],
appointment: ["read", "write", "delete"],
prescription: ["read", "write", "delete"],
invoice: ["read", "write", "delete"],
inventory: ["read", "write", "delete"],
task: ["read", "write", "delete"],
lab: ["read", "write"],
@@ -50,6 +52,7 @@ export const admin = ac.newRole({
patient: ["read", "write", "delete"],
appointment: ["read", "write", "delete"],
prescription: ["read", "write", "delete"],
invoice: ["read", "write", "delete"],
inventory: ["read", "write", "delete"],
task: ["read", "write", "delete"],
lab: ["read", "write"],
@@ -61,6 +64,7 @@ export const member = ac.newRole({
patient: ["read", "write"],
appointment: ["read", "write", "delete"],
prescription: ["read", "write", "delete"],
invoice: ["read", "write", "delete"],
inventory: ["read", "write", "delete"],
task: ["read", "write", "delete"],
lab: ["read", "write"],
@@ -74,6 +78,7 @@ export const doctor = ac.newRole({
patient: ["read", "write"],
appointment: ["read", "write", "delete"],
prescription: ["read", "write", "delete"],
invoice: ["read", "write", "delete"],
inventory: ["read", "write", "delete"],
task: ["read", "write", "delete"],
lab: ["read", "write"],
@@ -88,6 +93,7 @@ export const reception = ac.newRole({
...memberAc.statements,
patient: ["read", "write"],
appointment: ["read", "write", "delete"],
invoice: ["read", "write", "delete"],
task: ["read", "write"],
});
+41
View File
@@ -0,0 +1,41 @@
import { z } from "zod";
import { initialsFromName } from "./initials.js";
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD.");
export const invoiceLineItemSchema = z.object({
description: z.string().trim().min(1, "Description is required.").max(300),
quantity: z.coerce.number().min(0).max(100_000).default(1),
unitPrice: z.coerce.number().min(0).max(10_000_000).default(0),
});
export const invoiceInstallmentSchema = z.object({
label: z.string().trim().max(120).default(""),
amount: z.coerce.number().min(0).max(10_000_000).default(0),
dueAt: isoDate.nullable().default(null),
paid: z.boolean().default(false),
});
// Payload accepted by POST/PUT /api/invoices. `number` and `issuedAt` are filled
// server-side on create when omitted; initials are derived from the name.
export const invoiceInputSchema = z
.object({
fileNumber: z.string().trim().default(""),
name: z.string().trim().min(1, "Patient name is required.").max(200),
initials: z.string().trim().max(4).default(""),
number: z.string().trim().max(60).optional(),
issuedAt: isoDate.optional(),
dueAt: isoDate.nullish(),
status: z.enum(["draft", "sent", "paid", "void"]).default("draft"),
lineItems: z.array(invoiceLineItemSchema).default([]),
installments: z.array(invoiceInstallmentSchema).default([]),
notes: z.string().max(5000).nullish(),
source: z.enum(["manual", "ai"]).default("manual"),
})
.transform((v) => ({
...v,
initials: v.initials || initialsFromName(v.name),
}));
export type InvoiceInput = z.infer<typeof invoiceInputSchema>;
+138
View File
@@ -0,0 +1,138 @@
import { Router } from "express";
import { z } from "zod";
import { HttpError } from "../lib/http-error.js";
import { invoiceInputSchema } from "../lib/invoice-validation.js";
import {
requireAuth,
requireOrg,
requirePermission,
} from "../middleware/auth.js";
import { recordActivity } from "../services/activity.js";
import * as service from "../services/invoices.js";
export const invoicesRouter = Router();
// Invoices are clinic-wide billing records, gated by the caller's role.
invoicesRouter.use(requireAuth, requireOrg);
invoicesRouter.get(
"/",
requirePermission({ invoice: ["read"] }),
async (req, res, next) => {
try {
res.json(await service.listInvoices(req.organizationId!));
} catch (err) {
next(err);
}
},
);
invoicesRouter.post(
"/",
requirePermission({ invoice: ["write"] }),
async (req, res, next) => {
try {
const input = invoiceInputSchema.parse(req.body);
const created = await service.createInvoice(
req.organizationId!,
req.user!.id,
input,
);
await recordActivity({
orgId: req.organizationId!,
actor: { id: req.user!.id, name: req.user!.name },
action: `Created invoice ${created.number} for ${created.name}`,
entityType: "invoice",
entityId: created.id,
patientName: created.name,
patientFileNumber: created.fileNumber || null,
});
res.status(201).json(created);
} catch (err) {
next(err);
}
},
);
invoicesRouter.put(
"/:id",
requirePermission({ invoice: ["write"] }),
async (req, res, next) => {
try {
const input = invoiceInputSchema.parse(req.body);
const updated = await service.updateInvoice(
req.organizationId!,
req.params.id as string,
input,
);
if (!updated) throw new HttpError(404, "Invoice not found.");
await recordActivity({
orgId: req.organizationId!,
actor: { id: req.user!.id, name: req.user!.name },
action: `Updated invoice ${updated.number}`,
entityType: "invoice",
entityId: updated.id,
patientName: updated.name,
patientFileNumber: updated.fileNumber || null,
});
res.json(updated);
} catch (err) {
next(err);
}
},
);
const splitSchema = z.object({ count: z.coerce.number().int().min(1).max(36) });
invoicesRouter.post(
"/:id/split",
requirePermission({ invoice: ["write"] }),
async (req, res, next) => {
try {
const { count } = splitSchema.parse(req.body);
const updated = await service.splitIntoInstallments(
req.organizationId!,
req.params.id as string,
count,
);
if (!updated) throw new HttpError(404, "Invoice not found.");
await recordActivity({
orgId: req.organizationId!,
actor: { id: req.user!.id, name: req.user!.name },
action: `Split invoice ${updated.number} into ${count} installments`,
entityType: "invoice",
entityId: updated.id,
patientName: updated.name,
patientFileNumber: updated.fileNumber || null,
});
res.json(updated);
} catch (err) {
next(err);
}
},
);
invoicesRouter.delete(
"/:id",
requirePermission({ invoice: ["delete"] }),
async (req, res, next) => {
try {
const ok = await service.deleteInvoice(
req.organizationId!,
req.params.id as string,
);
if (!ok) throw new HttpError(404, "Invoice not found.");
await recordActivity({
orgId: req.organizationId!,
actor: { id: req.user!.id, name: req.user!.name },
action: "Deleted invoice",
entityType: "invoice",
entityId: req.params.id as string,
});
res.status(204).end();
} catch (err) {
next(err);
}
},
);
+171
View File
@@ -0,0 +1,171 @@
import { and, desc, eq, sql } from "drizzle-orm";
import { db } from "../db/index.js";
import { invoices } from "../db/schema/invoices.js";
import type { InvoiceInput } from "../lib/invoice-validation.js";
import type { Invoice, InvoiceInstallment } from "../types/invoice.js";
type InvoiceRow = typeof invoices.$inferSelect;
// Postgres throws on a malformed uuid; treat non-uuid ids as "not found".
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function toInvoice(row: InvoiceRow): Invoice {
return {
id: row.id,
fileNumber: row.patientFileNumber,
name: row.patientName,
initials: row.patientInitials,
number: row.number,
issuedAt: row.issuedAt,
dueAt: row.dueAt,
status: row.status,
lineItems: row.lineItems,
installments: row.installments,
notes: row.notes,
source: row.source,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
function columns(orgId: string, input: InvoiceInput, createdBy?: string) {
return {
organizationId: orgId,
patientFileNumber: input.fileNumber,
patientName: input.name,
patientInitials: input.initials,
number: input.number ?? "",
status: input.status,
lineItems: input.lineItems,
installments: input.installments,
notes: input.notes ?? null,
source: input.source,
...(input.issuedAt ? { issuedAt: input.issuedAt } : {}),
dueAt: input.dueAt ?? null,
...(createdBy ? { createdBy } : {}),
};
}
export function invoiceTotal(input: {
lineItems: { quantity: number; unitPrice: number }[];
}): number {
return input.lineItems.reduce(
(sum, li) => sum + li.quantity * li.unitPrice,
0,
);
}
// Next human invoice number for an org: "INV-" + (max existing numeric suffix +
// 1, floored at 1000). Falls back cleanly when there are no prior invoices.
async function generateInvoiceNumber(orgId: string): Promise<string> {
const [r] = await db
.select({
max: sql<number>`coalesce(max(nullif(regexp_replace(${invoices.number}, '\\D', '', 'g'), '')::bigint), 999)`,
})
.from(invoices)
.where(eq(invoices.organizationId, orgId));
return `INV-${Number(r?.max ?? 999) + 1}`;
}
// Add `months` calendar months to an ISO (YYYY-MM-DD) date, returning an ISO
// date. Used to stagger installment due dates from the issue date.
function addMonths(iso: string, months: number): string {
const [y, m, d] = iso.split("-").map(Number);
const base = new Date(Date.UTC(y!, (m! - 1) + months, d!));
return base.toISOString().slice(0, 10);
}
export async function listInvoices(orgId: string): Promise<Invoice[]> {
const rows = await db
.select()
.from(invoices)
.where(eq(invoices.organizationId, orgId))
.orderBy(desc(invoices.issuedAt), desc(invoices.createdAt));
return rows.map(toInvoice);
}
export async function getInvoice(
orgId: string,
id: string,
): Promise<Invoice | null> {
if (!UUID_RE.test(id)) return null;
const [row] = await db
.select()
.from(invoices)
.where(and(eq(invoices.id, id), eq(invoices.organizationId, orgId)));
return row ? toInvoice(row) : null;
}
export async function createInvoice(
orgId: string,
userId: string,
input: InvoiceInput,
): Promise<Invoice> {
const number = input.number || (await generateInvoiceNumber(orgId));
const [row] = await db
.insert(invoices)
.values(columns(orgId, { ...input, number }, userId))
.returning();
return toInvoice(row!);
}
export async function updateInvoice(
orgId: string,
id: string,
input: InvoiceInput,
): Promise<Invoice | null> {
if (!UUID_RE.test(id)) return null;
const [row] = await db
.update(invoices)
.set(columns(orgId, input))
.where(and(eq(invoices.id, id), eq(invoices.organizationId, orgId)))
.returning();
return row ? toInvoice(row) : null;
}
export async function deleteInvoice(
orgId: string,
id: string,
): Promise<boolean> {
if (!UUID_RE.test(id)) return false;
const deleted = await db
.delete(invoices)
.where(and(eq(invoices.id, id), eq(invoices.organizationId, orgId)))
.returning({ id: invoices.id });
return deleted.length > 0;
}
// Split an invoice's total into `count` roughly-equal installments, staggered
// one month apart from the issue date. Amounts are computed in cents so they
// always sum back to the exact total (any remainder lands on the first slice).
export async function splitIntoInstallments(
orgId: string,
id: string,
count: number,
): Promise<Invoice | null> {
const invoice = await getInvoice(orgId, id);
if (!invoice) return null;
const n = Math.max(1, Math.min(36, Math.floor(count)));
const totalCents = Math.round(invoiceTotal(invoice) * 100);
const base = Math.floor(totalCents / n);
const remainder = totalCents - base * n;
const installments: InvoiceInstallment[] = Array.from(
{ length: n },
(_, i) => ({
label: `${i + 1} of ${n}`,
amount: (base + (i < remainder ? 1 : 0)) / 100,
dueAt: addMonths(invoice.issuedAt, i),
paid: false,
}),
);
const [row] = await db
.update(invoices)
.set({ installments })
.where(and(eq(invoices.id, id), eq(invoices.organizationId, orgId)))
.returning();
return row ? toInvoice(row) : null;
}
+1
View File
@@ -6,6 +6,7 @@ export type ActivityEntityType =
| "note"
| "appointment"
| "prescription"
| "invoice"
| "inventory"
| "task";
+36
View File
@@ -0,0 +1,36 @@
// The canonical Invoice shape returned by the API. Mirrors the frontend
// `lib/invoices.ts` Invoice type. Scoped to the active clinic; patient fields
// are denormalized for display and `fileNumber` links to a patient record.
export type InvoiceStatus = "draft" | "sent" | "paid" | "void";
export type InvoiceLineItem = {
description: string;
quantity: number;
unitPrice: number;
};
// One slice of a split bill. `amount` is the installment total; `dueAt` is an
// optional ISO date; `paid` tracks settlement.
export type InvoiceInstallment = {
label: string;
amount: number;
dueAt: string | null;
paid: boolean;
};
export type Invoice = {
id: string;
fileNumber: string;
name: string;
initials: string;
number: string; // human invoice number, e.g. "INV-1001"
issuedAt: string; // YYYY-MM-DD
dueAt: string | null; // YYYY-MM-DD
status: InvoiceStatus;
lineItems: InvoiceLineItem[];
installments: InvoiceInstallment[];
notes: string | null;
source: "manual" | "ai";
createdAt: string;
updatedAt: string;
};