feat(invoices): add indexes, pagination, and client name enrichment (GRO-504) #238

Merged
groombook-engineer[bot] merged 1 commits from fix/gro-504-invoice-pagination into main 2026-04-07 19:43:07 +00:00
5 changed files with 2330 additions and 46 deletions
+35 -5
View File
@@ -10,6 +10,8 @@ import {
invoiceTipSplits, invoiceTipSplits,
appointments, appointments,
services, services,
clients,
sql,
} from "@groombook/db"; } from "@groombook/db";
export const invoicesRouter = new Hono(); export const invoicesRouter = new Hono();
@@ -46,18 +48,46 @@ invoicesRouter.get("/", async (c) => {
const clientId = c.req.query("clientId"); const clientId = c.req.query("clientId");
const appointmentId = c.req.query("appointmentId"); const appointmentId = c.req.query("appointmentId");
const status = c.req.query("status"); const status = c.req.query("status");
const limit = Math.min(parseInt(c.req.query("limit") || "50", 10), 200);
const offset = parseInt(c.req.query("offset") || "0", 10);
const conditions = []; const conditions = [];
if (clientId) conditions.push(eq(invoices.clientId, clientId)); if (clientId) conditions.push(eq(invoices.clientId, clientId));
if (appointmentId) conditions.push(eq(invoices.appointmentId, appointmentId)); if (appointmentId) conditions.push(eq(invoices.appointmentId, appointmentId));
if (status) conditions.push(eq(invoices.status, status as "draft" | "pending" | "paid" | "void")); if (status) conditions.push(eq(invoices.status, status as "draft" | "pending" | "paid" | "void"));
const rows = const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
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); const [totalResult] = await db
.select({ count: sql<number>`count(*)` })
.from(invoices)
.where(whereClause);
const rows = await db
.select({
id: invoices.id,
appointmentId: invoices.appointmentId,
clientId: invoices.clientId,
clientName: clients.name,
subtotalCents: invoices.subtotalCents,
taxCents: invoices.taxCents,
tipCents: invoices.tipCents,
totalCents: invoices.totalCents,
status: invoices.status,
paymentMethod: invoices.paymentMethod,
paidAt: invoices.paidAt,
notes: invoices.notes,
createdAt: invoices.createdAt,
updatedAt: invoices.updatedAt,
})
.from(invoices)
.leftJoin(clients, eq(invoices.clientId, clients.id))
.where(whereClause)
.orderBy(invoices.createdAt)
.limit(limit)
.offset(offset);
return c.json({ data: rows, total: totalResult?.count ?? 0 });
}); });
// Get single invoice with line items and tip splits // Get single invoice with line items and tip splits
@@ -0,0 +1,5 @@
CREATE INDEX idx_invoices_client_id ON invoices(client_id);
CREATE INDEX idx_invoices_status ON invoices(status);
CREATE INDEX idx_invoices_created_at ON invoices(created_at);
CREATE INDEX idx_invoice_line_items_invoice_id ON invoice_line_items(invoice_id);
CREATE INDEX idx_invoice_tip_splits_invoice_id ON invoice_tip_splits(invoice_id);
File diff suppressed because it is too large Load Diff
@@ -169,6 +169,13 @@
"when": 1775309667192, "when": 1775309667192,
"tag": "0023_auth_provider_config", "tag": "0023_auth_provider_config",
"breakpoints": true "breakpoints": true
},
{
"idx": 24,
"version": "7",
"when": 1775396067192,
"tag": "0024_invoice_indexes",
"breakpoints": true
} }
] ]
} }
+57 -41
View File
@@ -234,51 +234,67 @@ export const appointments = pgTable("appointments", {
updatedAt: timestamp("updated_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(),
}); });
export const invoices = pgTable("invoices", { export const invoices = pgTable(
id: uuid("id").primaryKey().defaultRandom(), "invoices",
appointmentId: uuid("appointment_id").references(() => appointments.id, { {
onDelete: "restrict", id: uuid("id").primaryKey().defaultRandom(),
}), appointmentId: uuid("appointment_id").references(() => appointments.id, {
clientId: uuid("client_id") onDelete: "restrict",
.notNull() }),
.references(() => clients.id, { onDelete: "restrict" }), clientId: uuid("client_id")
subtotalCents: integer("subtotal_cents").notNull(), .notNull()
taxCents: integer("tax_cents").notNull().default(0), .references(() => clients.id, { onDelete: "restrict" }),
tipCents: integer("tip_cents").notNull().default(0), subtotalCents: integer("subtotal_cents").notNull(),
totalCents: integer("total_cents").notNull(), taxCents: integer("tax_cents").notNull().default(0),
status: invoiceStatusEnum("status").notNull().default("draft"), tipCents: integer("tip_cents").notNull().default(0),
paymentMethod: paymentMethodEnum("payment_method"), totalCents: integer("total_cents").notNull(),
paidAt: timestamp("paid_at"), status: invoiceStatusEnum("status").notNull().default("draft"),
notes: text("notes"), paymentMethod: paymentMethodEnum("payment_method"),
createdAt: timestamp("created_at").notNull().defaultNow(), paidAt: timestamp("paid_at"),
updatedAt: timestamp("updated_at").notNull().defaultNow(), notes: text("notes"),
}); createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
},
(t) => [
index("idx_invoices_client_id").on(t.clientId),
index("idx_invoices_status").on(t.status),
index("idx_invoices_created_at").on(t.createdAt),
]
);
export const invoiceLineItems = pgTable("invoice_line_items", { export const invoiceLineItems = pgTable(
id: uuid("id").primaryKey().defaultRandom(), "invoice_line_items",
invoiceId: uuid("invoice_id") {
.notNull() id: uuid("id").primaryKey().defaultRandom(),
.references(() => invoices.id, { onDelete: "cascade" }), invoiceId: uuid("invoice_id")
description: text("description").notNull(), .notNull()
quantity: integer("quantity").notNull().default(1), .references(() => invoices.id, { onDelete: "cascade" }),
unitPriceCents: integer("unit_price_cents").notNull(), description: text("description").notNull(),
totalCents: integer("total_cents").notNull(), quantity: integer("quantity").notNull().default(1),
createdAt: timestamp("created_at").notNull().defaultNow(), unitPriceCents: integer("unit_price_cents").notNull(),
}); totalCents: integer("total_cents").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(t) => [index("idx_invoice_line_items_invoice_id").on(t.invoiceId)]
);
// Per-staff tip allocation calculated when an invoice is paid. // Per-staff tip allocation calculated when an invoice is paid.
// staff_name is snapshotted at calculation time so reports remain accurate if staff is deleted. // staff_name is snapshotted at calculation time so reports remain accurate if staff is deleted.
export const invoiceTipSplits = pgTable("invoice_tip_splits", { export const invoiceTipSplits = pgTable(
id: uuid("id").primaryKey().defaultRandom(), "invoice_tip_splits",
invoiceId: uuid("invoice_id") {
.notNull() id: uuid("id").primaryKey().defaultRandom(),
.references(() => invoices.id, { onDelete: "cascade" }), invoiceId: uuid("invoice_id")
staffId: uuid("staff_id").references(() => staff.id, { onDelete: "set null" }), .notNull()
staffName: text("staff_name").notNull(), .references(() => invoices.id, { onDelete: "cascade" }),
sharePct: numeric("share_pct", { precision: 5, scale: 2 }).notNull(), staffId: uuid("staff_id").references(() => staff.id, { onDelete: "set null" }),
shareCents: integer("share_cents").notNull(), staffName: text("staff_name").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(), sharePct: numeric("share_pct", { precision: 5, scale: 2 }).notNull(),
}); shareCents: integer("share_cents").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(t) => [index("idx_invoice_tip_splits_invoice_id").on(t.invoiceId)]
);
// Tracks which reminder emails have been sent per appointment (prevents duplicates). // Tracks which reminder emails have been sent per appointment (prevents duplicates).
// reminder_type values: "confirmation", "24h", "2h" // reminder_type values: "confirmation", "24h", "2h"