fix(GRO-751): add server-side tip split validation to markPaid
- Extend updateInvoiceSchema to accept optional tipSplits array in PATCH body - Validate tip splits sum to 100% (10000 bps) when marking paid with tipCents > 0 - Return 422 if tipSplits not provided and no existing splits in DB - Save tip splits atomically in same DB transaction as invoice status update - Update frontend markPaid() to send tipSplits in PATCH body instead of separate POST - Remove non-atomic POST /tip-splits call from markPaid flow Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -42,6 +42,13 @@ const updateInvoiceSchema = z.object({
|
||||
taxCents: z.number().int().nonnegative().optional(),
|
||||
tipCents: z.number().int().nonnegative().optional(),
|
||||
notes: z.string().max(2000).nullable().optional(),
|
||||
tipSplits: z.array(
|
||||
z.object({
|
||||
staffId: z.string().uuid().nullable(),
|
||||
staffName: z.string().min(1).max(200),
|
||||
sharePct: z.number().min(0).max(100),
|
||||
})
|
||||
).optional(),
|
||||
});
|
||||
|
||||
// List invoices
|
||||
@@ -334,7 +341,30 @@ invoicesRouter.patch(
|
||||
}
|
||||
}
|
||||
|
||||
const update: Record<string, unknown> = { ...body, updatedAt: new Date() };
|
||||
// Tip split validation when marking as paid with a tip
|
||||
const effectiveTipCents = body.tipCents ?? current.tipCents;
|
||||
if (body.status === "paid" && effectiveTipCents > 0) {
|
||||
if (body.tipSplits !== undefined) {
|
||||
if (body.tipSplits.length === 0) {
|
||||
return c.json({ error: "Tip splits required when tip amount is greater than zero" }, 422);
|
||||
}
|
||||
const totalBps = body.tipSplits.reduce((sum, s) => sum + Math.round(s.sharePct * 100), 0);
|
||||
if (totalBps !== 10000) {
|
||||
return c.json({ error: "Split percentages must sum to 100" }, 422);
|
||||
}
|
||||
} else {
|
||||
const existingSplits = await db
|
||||
.select({ id: invoiceTipSplits.id })
|
||||
.from(invoiceTipSplits)
|
||||
.where(eq(invoiceTipSplits.invoiceId, id));
|
||||
if (existingSplits.length === 0) {
|
||||
return c.json({ error: "Tip splits required when tip amount is greater than zero" }, 422);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { tipSplits: incomingTipSplits, ...bodyWithoutSplits } = body;
|
||||
const update: Record<string, unknown> = { ...bodyWithoutSplits, updatedAt: new Date() };
|
||||
|
||||
// Auto-set paidAt when marking as paid
|
||||
if (body.status === "paid" && !body.paidAt && !current.paidAt) {
|
||||
@@ -348,11 +378,41 @@ invoicesRouter.patch(
|
||||
update.totalCents = current.subtotalCents + newTaxCents + newTipCents;
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(invoices)
|
||||
.set(update)
|
||||
.where(eq(invoices.id, id))
|
||||
.returning();
|
||||
const [updated] = await db.transaction(async (tx) => {
|
||||
const [upd] = await tx
|
||||
.update(invoices)
|
||||
.set(update)
|
||||
.where(eq(invoices.id, id))
|
||||
.returning();
|
||||
|
||||
// Atomically save tip splits when marking paid with provided splits
|
||||
if (
|
||||
body.status === "paid" &&
|
||||
effectiveTipCents > 0 &&
|
||||
incomingTipSplits !== undefined &&
|
||||
incomingTipSplits.length > 0
|
||||
) {
|
||||
await tx.delete(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id));
|
||||
|
||||
let remaining = effectiveTipCents;
|
||||
const rows = incomingTipSplits.map((s, i) => {
|
||||
const isLast = i === incomingTipSplits.length - 1;
|
||||
const shareCents = isLast ? remaining : Math.round((s.sharePct / 100) * effectiveTipCents);
|
||||
if (!isLast) remaining -= shareCents;
|
||||
return {
|
||||
invoiceId: id,
|
||||
staffId: s.staffId,
|
||||
staffName: s.staffName,
|
||||
sharePct: s.sharePct.toFixed(2),
|
||||
shareCents,
|
||||
};
|
||||
});
|
||||
|
||||
await tx.insert(invoiceTipSplits).values(rows);
|
||||
}
|
||||
|
||||
return [upd];
|
||||
});
|
||||
|
||||
const lineItems = await db
|
||||
.select()
|
||||
|
||||
@@ -221,35 +221,31 @@ function InvoiceDetailModal({
|
||||
}
|
||||
}
|
||||
try {
|
||||
const patchBody: {
|
||||
status: string;
|
||||
paymentMethod: string;
|
||||
tipCents: number;
|
||||
tipSplits?: Array<{ staffId: string | null; staffName: string; sharePct: number }>;
|
||||
} = { status: "paid", paymentMethod, tipCents };
|
||||
|
||||
if (showSplits && tipCents > 0 && tipSplits.length > 0) {
|
||||
patchBody.tipSplits = tipSplits.map((r) => ({
|
||||
staffId: r.staffId,
|
||||
staffName: r.staffName,
|
||||
sharePct: r.pct,
|
||||
}));
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/invoices/${invoice.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: "paid", paymentMethod, tipCents }),
|
||||
body: JSON.stringify(patchBody),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json()) as { error?: string };
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
// Save tip splits if applicable and tip > 0
|
||||
if (showSplits && tipCents > 0 && tipSplits.length > 0) {
|
||||
const totalPct = tipSplits.reduce((s, r) => s + r.pct, 0);
|
||||
if (Math.abs(totalPct - 100) < 0.01) {
|
||||
const splitsRes = await fetch(`/api/invoices/${invoice.id}/tip-splits`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
splits: tipSplits.map((r) => ({
|
||||
staffId: r.staffId,
|
||||
staffName: r.staffName,
|
||||
sharePct: r.pct,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
if (!splitsRes.ok) console.warn("Tip split save failed (non-blocking)");
|
||||
}
|
||||
}
|
||||
|
||||
onUpdated();
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Failed to update");
|
||||
|
||||
Reference in New Issue
Block a user