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,
appointments,
services,
clients,
sql,
} from "@groombook/db";
export const invoicesRouter = new Hono();
@@ -46,18 +48,46 @@ invoicesRouter.get("/", async (c) => {
const clientId = c.req.query("clientId");
const appointmentId = c.req.query("appointmentId");
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 = [];
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);
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
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
@@ -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,
"tag": "0023_auth_provider_config",
"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(),
});
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 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(),
},
(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", {
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(),
});
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(),
},
(t) => [index("idx_invoice_line_items_invoice_id").on(t.invoiceId)]
);
// 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.
export const invoiceTipSplits = pgTable("invoice_tip_splits", {
id: uuid("id").primaryKey().defaultRandom(),
invoiceId: uuid("invoice_id")
.notNull()
.references(() => invoices.id, { onDelete: "cascade" }),
staffId: uuid("staff_id").references(() => staff.id, { onDelete: "set null" }),
staffName: text("staff_name").notNull(),
sharePct: numeric("share_pct", { precision: 5, scale: 2 }).notNull(),
shareCents: integer("share_cents").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
export const invoiceTipSplits = pgTable(
"invoice_tip_splits",
{
id: uuid("id").primaryKey().defaultRandom(),
invoiceId: uuid("invoice_id")
.notNull()
.references(() => invoices.id, { onDelete: "cascade" }),
staffId: uuid("staff_id").references(() => staff.id, { onDelete: "set null" }),
staffName: text("staff_name").notNull(),
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).
// reminder_type values: "confirmation", "24h", "2h"