Files
temetro/backend/src/lib/invoice-validation.ts
T
Khalid Abdi d096c4fe9d 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>
2026-06-14 19:58:08 +03:00

42 lines
1.6 KiB
TypeScript

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