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:
committed by
GitHub
parent
eb9255eee0
commit
b767a00b5f
@@ -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;
|
||||
@@ -15,6 +15,13 @@
|
||||
"when": 1742241600000,
|
||||
"tag": "0001_pet_health_alerts",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1773777600000,
|
||||
"tag": "0002_invoices",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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<T> {
|
||||
items: T[];
|
||||
|
||||
Reference in New Issue
Block a user