From b767a00b5fb5ea98e246e8f1696b84f20925eb52 Mon Sep 17 00:00:00 2001 From: "groombook-paperclip[bot]" <268890960+groombook-paperclip[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 20:02:04 +0000 Subject: [PATCH] feat: basic POS & invoicing (closes groombook/groombook#5) (#26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add invoice_status and payment_method enums to schema - Add invoices table: appointmentId, clientId, subtotal/tax/tip/total cents, status (draft/pending/paid/void), paymentMethod, paidAt, notes - Add invoice_line_items table: invoiceId, description, qty, unitPrice, total - Migration 0002_invoices.sql with FK constraints and journal entry - POST /api/invoices — create invoice with line items - POST /api/invoices/from-appointment/:id — one-click invoice from appointment, pre-populated with service name and price; returns 409 if already invoiced - GET /api/invoices — list with optional ?status/clientId/appointmentId filters - GET /api/invoices/:id — invoice with line items - PATCH /api/invoices/:id — update status, payment method, tip, notes; auto-sets paidAt when marking paid; blocks edits on voided invoices - Add Invoice/InvoiceLineItem types to @groombook/types - InvoicesPage: list view with status filter, create from appointment modal, detail modal with tip input, payment method selector, Mark as Paid/Void actions - Add Invoices nav link in App.tsx Co-authored-by: Groom Book CTO Co-authored-by: Paperclip --- apps/api/src/index.ts | 2 + apps/api/src/routes/invoices.ts | 245 ++++++++++ apps/web/src/App.tsx | 3 + apps/web/src/pages/Invoices.tsx | 520 ++++++++++++++++++++++ packages/db/migrations/0002_invoices.sql | 31 ++ packages/db/migrations/meta/_journal.json | 7 + packages/db/src/schema.ts | 46 ++ packages/types/src/index.ts | 30 ++ 8 files changed, 884 insertions(+) create mode 100644 apps/api/src/routes/invoices.ts create mode 100644 apps/web/src/pages/Invoices.tsx create mode 100644 packages/db/migrations/0002_invoices.sql diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 903a8f0..e263b57 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -7,6 +7,7 @@ import { petsRouter } from "./routes/pets.js"; import { servicesRouter } from "./routes/services.js"; import { appointmentsRouter } from "./routes/appointments.js"; import { staffRouter } from "./routes/staff.js"; +import { invoicesRouter } from "./routes/invoices.js"; import { authMiddleware } from "./middleware/auth.js"; const app = new Hono(); @@ -33,6 +34,7 @@ api.route("/pets", petsRouter); api.route("/services", servicesRouter); api.route("/appointments", appointmentsRouter); api.route("/staff", staffRouter); +api.route("/invoices", invoicesRouter); const port = Number(process.env.PORT ?? 3000); console.log(`API server listening on port ${port}`); diff --git a/apps/api/src/routes/invoices.ts b/apps/api/src/routes/invoices.ts new file mode 100644 index 0000000..2777d98 --- /dev/null +++ b/apps/api/src/routes/invoices.ts @@ -0,0 +1,245 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod"; +import { + and, + eq, + getDb, + invoices, + invoiceLineItems, + appointments, + services, +} from "@groombook/db"; + +export const invoicesRouter = new Hono(); + +const createInvoiceSchema = z.object({ + appointmentId: z.string().uuid().optional(), + clientId: z.string().uuid(), + lineItems: z + .array( + z.object({ + description: z.string().min(1).max(500), + quantity: z.number().int().positive().default(1), + unitPriceCents: z.number().int().nonnegative(), + }) + ) + .min(1), + taxCents: z.number().int().nonnegative().default(0), + tipCents: z.number().int().nonnegative().default(0), + notes: z.string().max(2000).optional(), +}); + +const updateInvoiceSchema = z.object({ + status: z.enum(["draft", "pending", "paid", "void"]).optional(), + paymentMethod: z.enum(["cash", "card", "check", "other"]).nullable().optional(), + paidAt: z.string().datetime().nullable().optional(), + taxCents: z.number().int().nonnegative().optional(), + tipCents: z.number().int().nonnegative().optional(), + notes: z.string().max(2000).nullable().optional(), +}); + +// List invoices +invoicesRouter.get("/", async (c) => { + const db = getDb(); + const clientId = c.req.query("clientId"); + const appointmentId = c.req.query("appointmentId"); + const status = c.req.query("status"); + + const conditions = []; + if (clientId) conditions.push(eq(invoices.clientId, clientId)); + if (appointmentId) conditions.push(eq(invoices.appointmentId, appointmentId)); + if (status) conditions.push(eq(invoices.status, status as "draft" | "pending" | "paid" | "void")); + + const rows = + conditions.length > 0 + ? await db.select().from(invoices).where(and(...conditions)).orderBy(invoices.createdAt) + : await db.select().from(invoices).orderBy(invoices.createdAt); + + return c.json(rows); +}); + +// Get single invoice with line items +invoicesRouter.get("/:id", async (c) => { + const db = getDb(); + const id = c.req.param("id"); + + const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id)); + if (!invoice) return c.json({ error: "Not found" }, 404); + + const lineItems = await db + .select() + .from(invoiceLineItems) + .where(eq(invoiceLineItems.invoiceId, id)); + + return c.json({ ...invoice, lineItems }); +}); + +// Create invoice (optionally pre-populated from an appointment) +invoicesRouter.post( + "/", + zValidator("json", createInvoiceSchema), + async (c) => { + const db = getDb(); + const body = c.req.valid("json"); + + // If appointmentId provided, verify it exists + if (body.appointmentId) { + const [appt] = await db + .select() + .from(appointments) + .where(eq(appointments.id, body.appointmentId)); + if (!appt) return c.json({ error: "Appointment not found" }, 404); + } + + const subtotalCents = body.lineItems.reduce( + (sum, item) => sum + item.quantity * item.unitPriceCents, + 0 + ); + const totalCents = subtotalCents + body.taxCents + body.tipCents; + + const [invoice] = await db + .insert(invoices) + .values({ + appointmentId: body.appointmentId ?? null, + clientId: body.clientId, + subtotalCents, + taxCents: body.taxCents, + tipCents: body.tipCents, + totalCents, + notes: body.notes ?? null, + }) + .returning(); + + if (!invoice) return c.json({ error: "Failed to create invoice" }, 500); + + const items = await db + .insert(invoiceLineItems) + .values( + body.lineItems.map((item) => ({ + invoiceId: invoice.id, + description: item.description, + quantity: item.quantity, + unitPriceCents: item.unitPriceCents, + totalCents: item.quantity * item.unitPriceCents, + })) + ) + .returning(); + + return c.json({ ...invoice, lineItems: items }, 201); + } +); + +// Create invoice from appointment (convenience endpoint) +invoicesRouter.post("/from-appointment/:appointmentId", async (c) => { + const db = getDb(); + const appointmentId = c.req.param("appointmentId"); + + const [appt] = await db + .select({ + id: appointments.id, + clientId: appointments.clientId, + serviceId: appointments.serviceId, + priceCents: appointments.priceCents, + serviceName: services.name, + serviceBasePriceCents: services.basePriceCents, + }) + .from(appointments) + .innerJoin(services, eq(appointments.serviceId, services.id)) + .where(eq(appointments.id, appointmentId)); + + if (!appt) return c.json({ error: "Appointment not found" }, 404); + + // Check if invoice already exists for this appointment + const [existing] = await db + .select({ id: invoices.id }) + .from(invoices) + .where(eq(invoices.appointmentId, appointmentId)) + .limit(1); + + if (existing) { + return c.json( + { error: "Invoice already exists for this appointment", invoiceId: existing.id }, + 409 + ); + } + + const unitPriceCents = appt.priceCents ?? appt.serviceBasePriceCents; + const subtotalCents = unitPriceCents; + const totalCents = subtotalCents; + + const [invoice] = await db + .insert(invoices) + .values({ + appointmentId, + clientId: appt.clientId, + subtotalCents, + taxCents: 0, + tipCents: 0, + totalCents, + }) + .returning(); + + if (!invoice) return c.json({ error: "Failed to create invoice" }, 500); + + const [lineItem] = await db + .insert(invoiceLineItems) + .values({ + invoiceId: invoice.id, + description: appt.serviceName, + quantity: 1, + unitPriceCents, + totalCents: unitPriceCents, + }) + .returning(); + + return c.json({ ...invoice, lineItems: [lineItem] }, 201); +}); + +// Update invoice +invoicesRouter.patch( + "/:id", + zValidator("json", updateInvoiceSchema), + async (c) => { + const db = getDb(); + const id = c.req.param("id"); + const body = c.req.valid("json"); + + const [current] = await db + .select() + .from(invoices) + .where(eq(invoices.id, id)); + if (!current) return c.json({ error: "Not found" }, 404); + + if (current.status === "void") { + return c.json({ error: "Cannot modify a voided invoice" }, 422); + } + + const update: Record = { ...body, updatedAt: new Date() }; + + // Auto-set paidAt when marking as paid + if (body.status === "paid" && !body.paidAt && !current.paidAt) { + update.paidAt = new Date(); + } + + // Recalculate total if tax or tip changed + const newTaxCents = body.taxCents ?? current.taxCents; + const newTipCents = body.tipCents ?? current.tipCents; + if (body.taxCents !== undefined || body.tipCents !== undefined) { + update.totalCents = current.subtotalCents + newTaxCents + newTipCents; + } + + const [updated] = await db + .update(invoices) + .set(update) + .where(eq(invoices.id, id)) + .returning(); + + const lineItems = await db + .select() + .from(invoiceLineItems) + .where(eq(invoiceLineItems.invoiceId, id)); + + return c.json({ ...updated, lineItems }); + } +); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index af0f877..5c39acd 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -3,12 +3,14 @@ import { AppointmentsPage } from "./pages/Appointments.js"; import { ClientsPage } from "./pages/Clients.js"; import { ServicesPage } from "./pages/Services.js"; import { StaffPage } from "./pages/Staff.js"; +import { InvoicesPage } from "./pages/Invoices.js"; const NAV_LINKS = [ { to: "/", label: "Appointments" }, { to: "/clients", label: "Clients" }, { to: "/services", label: "Services" }, { to: "/staff", label: "Staff" }, + { to: "/invoices", label: "Invoices" }, ]; export function App() { @@ -54,6 +56,7 @@ export function App() { } /> } /> } /> + } /> diff --git a/apps/web/src/pages/Invoices.tsx b/apps/web/src/pages/Invoices.tsx new file mode 100644 index 0000000..9102ff4 --- /dev/null +++ b/apps/web/src/pages/Invoices.tsx @@ -0,0 +1,520 @@ +import { useEffect, useState } from "react"; +import type { Invoice, Client, Appointment, Service } from "@groombook/types"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface InvoiceWithClient extends Invoice { + clientName?: string; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function fmtMoney(cents: number) { + return `$${(cents / 100).toFixed(2)}`; +} + +function fmtDate(iso: string | null) { + if (!iso) return "—"; + return new Date(iso).toLocaleDateString(); +} + +const STATUS_COLORS: Record = { + draft: { bg: "#f3f4f6", color: "#6b7280" }, + pending: { bg: "#fef3c7", color: "#92400e" }, + paid: { bg: "#d1fae5", color: "#065f46" }, + void: { bg: "#fee2e2", color: "#991b1b" }, +}; + +// ─── Invoice Status Badge ───────────────────────────────────────────────────── + +function StatusBadge({ status }: { status: string }) { + const { bg, color } = STATUS_COLORS[status] ?? { bg: "#f3f4f6", color: "#374151" }; + return ( + + {status} + + ); +} + +// ─── Create Invoice Form ────────────────────────────────────────────────────── + +interface CreateFromApptProps { + appointments: Appointment[]; + clients: Client[]; + services: Service[]; + onCreated: () => void; + onClose: () => void; +} + +function CreateFromAppointmentForm({ + appointments, + clients, + services, + onCreated, + onClose, +}: CreateFromApptProps) { + const [selectedApptId, setSelectedApptId] = useState(""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + // Only show completed appointments without an invoice already + const completedAppts = appointments.filter((a) => a.status === "completed"); + + function getClientName(clientId: string) { + return clients.find((c) => c.id === clientId)?.name ?? clientId; + } + + function getServiceName(serviceId: string) { + return services.find((s) => s.id === serviceId)?.name ?? serviceId; + } + + async function submit(e: React.FormEvent) { + e.preventDefault(); + if (!selectedApptId) return; + setSaving(true); + setError(null); + try { + const res = await fetch(`/api/invoices/from-appointment/${selectedApptId}`, { + method: "POST", + }); + if (!res.ok) { + const err = (await res.json()) as { error?: string }; + throw new Error(err.error ?? `HTTP ${res.status}`); + } + onCreated(); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Failed to create invoice"); + } finally { + setSaving(false); + } + } + + return ( + +

Create Invoice from Appointment

+
+ + + + {completedAppts.length === 0 && ( +

+ No completed appointments available. Mark an appointment as completed first. +

+ )} + {error &&

{error}

} +
+ + +
+
+
+ ); +} + +// ─── Invoice Detail Modal ───────────────────────────────────────────────────── + +function InvoiceDetailModal({ + invoice, + onClose, + onUpdated, +}: { + invoice: Invoice; + onClose: () => void; + onUpdated: () => void; +}) { + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2)); + const [paymentMethod, setPaymentMethod] = useState(invoice.paymentMethod ?? "cash"); + + async function markPaid() { + setSaving(true); + setError(null); + const tipCents = Math.round(parseFloat(tipStr) * 100) || 0; + try { + const res = await fetch(`/api/invoices/${invoice.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + status: "paid", + paymentMethod, + tipCents, + }), + }); + if (!res.ok) { + const err = (await res.json()) as { error?: string }; + throw new Error(err.error ?? `HTTP ${res.status}`); + } + onUpdated(); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Failed to update"); + } finally { + setSaving(false); + } + } + + async function voidInvoice() { + if (!confirm("Void this invoice? This cannot be undone.")) return; + setSaving(true); + setError(null); + try { + const res = await fetch(`/api/invoices/${invoice.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: "void" }), + }); + if (!res.ok) { + const err = (await res.json()) as { error?: string }; + throw new Error(err.error ?? `HTTP ${res.status}`); + } + onUpdated(); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Failed to void"); + } finally { + setSaving(false); + } + } + + const tipCentsCalc = Math.round(parseFloat(tipStr) * 100) || 0; + const newTotal = invoice.subtotalCents + invoice.taxCents + tipCentsCalc; + + return ( + +
+

Invoice

+ +
+ + + + + {["Description", "Qty", "Unit Price", "Total"].map((h) => ( + + ))} + + + + {(invoice.lineItems ?? []).map((item) => ( + + + + + + + ))} + +
+ {h} +
{item.description}{item.quantity}{fmtMoney(item.unitPriceCents)}{fmtMoney(item.totalCents)}
+ +
+ + + {invoice.status !== "paid" && invoice.status !== "void" ? ( +
+ Tip + setTipStr(e.target.value)} + style={{ ...inputStyle, width: 80, textAlign: "right" }} + /> +
+ ) : ( + + )} + + {invoice.paidAt && } + {invoice.paymentMethod && } +
+ + {invoice.status !== "paid" && invoice.status !== "void" && ( +
+ + + + {error &&

{error}

} +
+ + + +
+
+ )} + {(invoice.status === "paid" || invoice.status === "void") && ( +
+ +
+ )} +
+ ); +} + +function SummaryRow({ label, value, bold }: { label: string; value: string; bold?: boolean }) { + return ( +
+ {label} + {value} +
+ ); +} + +// ─── Main Page ──────────────────────────────────────────────────────────────── + +export function InvoicesPage() { + const [invoiceList, setInvoiceList] = useState([]); + const [clients, setClients] = useState([]); + const [appointments, setAppointments] = useState([]); + const [services, setServices] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [showCreate, setShowCreate] = useState(false); + const [selectedInvoice, setSelectedInvoice] = useState(null); + const [statusFilter, setStatusFilter] = useState(""); + + async function loadAll() { + const [invRes, clientRes, apptRes, svcRes] = await Promise.all([ + fetch("/api/invoices" + (statusFilter ? `?status=${statusFilter}` : "")), + fetch("/api/clients"), + fetch("/api/appointments"), + fetch("/api/services?includeInactive=true"), + ]); + + if (!invRes.ok || !clientRes.ok || !apptRes.ok || !svcRes.ok) { + throw new Error("Failed to load data"); + } + + const [invData, clientData, apptData, svcData] = await Promise.all([ + invRes.json() as Promise, + clientRes.json() as Promise, + apptRes.json() as Promise, + svcRes.json() as Promise, + ]); + + const clientMap = new Map(clientData.map((c) => [c.id, c.name])); + const enriched: InvoiceWithClient[] = invData.map((inv) => ({ + ...inv, + clientName: clientMap.get(inv.clientId), + })); + + setInvoiceList(enriched); + setClients(clientData); + setAppointments(apptData); + setServices(svcData); + } + + useEffect(() => { + setLoading(true); + loadAll() + .catch((e: unknown) => setError(e instanceof Error ? e.message : "Unknown error")) + .finally(() => setLoading(false)); + }, [statusFilter]); + + async function openInvoiceDetail(inv: InvoiceWithClient) { + const res = await fetch(`/api/invoices/${inv.id}`); + if (!res.ok) return; + const data = (await res.json()) as Invoice; + setSelectedInvoice(data); + } + + if (loading) return

Loading…

; + if (error) return

Error: {error}

; + + return ( +
+
+

Invoices

+ + +
+ + {invoiceList.length === 0 ? ( +

+ No invoices yet. Create one from a completed appointment. +

+ ) : ( + + + + {["Date", "Client", "Subtotal", "Tax", "Tip", "Total", "Status", ""].map((h) => ( + + ))} + + + + {invoiceList.map((inv) => ( + + + + + + + + + + + ))} + +
+ {h} +
{fmtDate(inv.createdAt)}{inv.clientName ?? "—"}{fmtMoney(inv.subtotalCents)}{fmtMoney(inv.taxCents)}{fmtMoney(inv.tipCents)}{fmtMoney(inv.totalCents)} + + + +
+ )} + + {showCreate && ( + { + setShowCreate(false); + loadAll().catch(() => {}); + }} + onClose={() => setShowCreate(false)} + /> + )} + + {selectedInvoice && ( + setSelectedInvoice(null)} + onUpdated={() => { + setSelectedInvoice(null); + loadAll().catch(() => {}); + }} + /> + )} +
+ ); +} + +// ─── Shared UI helpers ──────────────────────────────────────────────────────── + +function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) { + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + > +
+ {children} +
+
+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} + +const btnStyle: React.CSSProperties = { + padding: "0.35rem 0.75rem", border: "1px solid #d1d5db", + borderRadius: 4, background: "#f9fafb", cursor: "pointer", fontSize: 13, +}; + +const inputStyle: React.CSSProperties = { + width: "100%", padding: "0.4rem 0.5rem", border: "1px solid #d1d5db", + borderRadius: 4, fontSize: 14, boxSizing: "border-box", +}; + +const tdStyle: React.CSSProperties = { + padding: "0.5rem 0.75rem", borderBottom: "1px solid #e2e8f0", +}; diff --git a/packages/db/migrations/0002_invoices.sql b/packages/db/migrations/0002_invoices.sql new file mode 100644 index 0000000..b056a23 --- /dev/null +++ b/packages/db/migrations/0002_invoices.sql @@ -0,0 +1,31 @@ +CREATE TYPE "public"."invoice_status" AS ENUM('draft', 'pending', 'paid', 'void');--> statement-breakpoint +CREATE TYPE "public"."payment_method" AS ENUM('cash', 'card', 'check', 'other');--> statement-breakpoint +CREATE TABLE "invoices" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "appointment_id" uuid, + "client_id" uuid NOT NULL, + "subtotal_cents" integer NOT NULL, + "tax_cents" integer DEFAULT 0 NOT NULL, + "tip_cents" integer DEFAULT 0 NOT NULL, + "total_cents" integer NOT NULL, + "status" "invoice_status" DEFAULT 'draft' NOT NULL, + "payment_method" "payment_method", + "paid_at" timestamp, + "notes" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "invoice_line_items" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "invoice_id" uuid NOT NULL, + "description" text NOT NULL, + "quantity" integer DEFAULT 1 NOT NULL, + "unit_price_cents" integer NOT NULL, + "total_cents" integer NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "invoices" ADD CONSTRAINT "invoices_appointment_id_appointments_id_fk" FOREIGN KEY ("appointment_id") REFERENCES "public"."appointments"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "invoices" ADD CONSTRAINT "invoices_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "invoice_line_items" ADD CONSTRAINT "invoice_line_items_invoice_id_invoices_id_fk" FOREIGN KEY ("invoice_id") REFERENCES "public"."invoices"("id") ON DELETE cascade ON UPDATE no action; diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index c4854b5..b1597d5 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1742241600000, "tag": "0001_pet_health_alerts", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1773777600000, + "tag": "0002_invoices", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 1edc666..45802a8 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -26,6 +26,20 @@ export const staffRoleEnum = pgEnum("staff_role", [ "manager", ]); +export const invoiceStatusEnum = pgEnum("invoice_status", [ + "draft", + "pending", + "paid", + "void", +]); + +export const paymentMethodEnum = pgEnum("payment_method", [ + "cash", + "card", + "check", + "other", +]); + // ─── Tables ─────────────────────────────────────────────────────────────────── export const clients = pgTable("clients", { @@ -101,3 +115,35 @@ export const appointments = pgTable("appointments", { createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }); + +export const invoices = pgTable("invoices", { + id: uuid("id").primaryKey().defaultRandom(), + appointmentId: uuid("appointment_id").references(() => appointments.id, { + onDelete: "restrict", + }), + clientId: uuid("client_id") + .notNull() + .references(() => clients.id, { onDelete: "restrict" }), + subtotalCents: integer("subtotal_cents").notNull(), + taxCents: integer("tax_cents").notNull().default(0), + tipCents: integer("tip_cents").notNull().default(0), + totalCents: integer("total_cents").notNull(), + status: invoiceStatusEnum("status").notNull().default("draft"), + paymentMethod: paymentMethodEnum("payment_method"), + paidAt: timestamp("paid_at"), + notes: text("notes"), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), +}); + +export const invoiceLineItems = pgTable("invoice_line_items", { + id: uuid("id").primaryKey().defaultRandom(), + invoiceId: uuid("invoice_id") + .notNull() + .references(() => invoices.id, { onDelete: "cascade" }), + description: text("description").notNull(), + quantity: integer("quantity").notNull().default(1), + unitPriceCents: integer("unit_price_cents").notNull(), + totalCents: integer("total_cents").notNull(), + createdAt: timestamp("created_at").notNull().defaultNow(), +}); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 0c2c59b..a2cf1cd 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -69,6 +69,36 @@ export interface Appointment { updatedAt: string; } +export type InvoiceStatus = "draft" | "pending" | "paid" | "void"; +export type PaymentMethod = "cash" | "card" | "check" | "other"; + +export interface InvoiceLineItem { + id: string; + invoiceId: string; + description: string; + quantity: number; + unitPriceCents: number; + totalCents: number; + createdAt: string; +} + +export interface Invoice { + id: string; + appointmentId: string | null; + clientId: string; + subtotalCents: number; + taxCents: number; + tipCents: number; + totalCents: number; + status: InvoiceStatus; + paymentMethod: PaymentMethod | null; + paidAt: string | null; + notes: string | null; + createdAt: string; + updatedAt: string; + lineItems?: InvoiceLineItem[]; +} + // Paginated list response export interface PaginatedList { items: T[];