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(),
|
taxCents: z.number().int().nonnegative().optional(),
|
||||||
tipCents: z.number().int().nonnegative().optional(),
|
tipCents: z.number().int().nonnegative().optional(),
|
||||||
notes: z.string().max(2000).nullable().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
|
// 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
|
// Auto-set paidAt when marking as paid
|
||||||
if (body.status === "paid" && !body.paidAt && !current.paidAt) {
|
if (body.status === "paid" && !body.paidAt && !current.paidAt) {
|
||||||
@@ -348,11 +378,41 @@ invoicesRouter.patch(
|
|||||||
update.totalCents = current.subtotalCents + newTaxCents + newTipCents;
|
update.totalCents = current.subtotalCents + newTaxCents + newTipCents;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db.transaction(async (tx) => {
|
||||||
.update(invoices)
|
const [upd] = await tx
|
||||||
.set(update)
|
.update(invoices)
|
||||||
.where(eq(invoices.id, id))
|
.set(update)
|
||||||
.returning();
|
.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
|
const lineItems = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -221,35 +221,31 @@ function InvoiceDetailModal({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
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}`, {
|
const res = await fetch(`/api/invoices/${invoice.id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ status: "paid", paymentMethod, tipCents }),
|
body: JSON.stringify(patchBody),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = (await res.json()) as { error?: string };
|
const err = (await res.json()) as { error?: string };
|
||||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
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();
|
onUpdated();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
setError(e instanceof Error ? e.message : "Failed to update");
|
setError(e instanceof Error ? e.message : "Failed to update");
|
||||||
|
|||||||
Reference in New Issue
Block a user