mirror of
https://github.com/temetro/temetro.git
synced 2026-08-26 02:17:22 +00:00
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:
@@ -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";
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
@@ -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);
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
|
||||
|
||||
@@ -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>;
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export type ActivityEntityType =
|
||||
| "note"
|
||||
| "appointment"
|
||||
| "prescription"
|
||||
| "invoice"
|
||||
| "inventory"
|
||||
| "task";
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user