feat: basic POS & invoicing (closes groombook/groombook#5) (#26)

- 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 <cto@groombook.app>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit was merged in pull request #26.
This commit is contained in:
groombook-paperclip[bot]
2026-03-17 20:02:04 +00:00
committed by GitHub
parent eb9255eee0
commit b767a00b5f
8 changed files with 884 additions and 0 deletions
+46
View File
@@ -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(),
});