feat(invoices): add indexes, pagination, and client name enrichment (GRO-504) #238
@@ -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
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user