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"; import * as patients from "./patients.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 { const [r] = await db .select({ max: sql`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 { 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 { 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 { const number = input.number || (await generateInvoiceNumber(orgId)); // Link to a patient — creating one when the invoice has no file number (e.g. // an AI invoice built from an uploaded purchase list), so the client shows up // on the Patients page. const fileNumber = await patients.ensurePatient(orgId, userId, { fileNumber: input.fileNumber, name: input.name, initials: input.initials, }); const [row] = await db .insert(invoices) .values(columns(orgId, { ...input, number, fileNumber }, userId)) .returning(); return toInvoice(row!); } export async function updateInvoice( orgId: string, id: string, input: InvoiceInput, ): Promise { 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 { 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 { 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; }