From 20ca93b36dc02c85f16df472bcfa637e302865d9 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Fri, 17 Apr 2026 22:21:19 +0000 Subject: [PATCH] fix(GRO-785): address invoice tip split regression - Use body.tipCents ?? current.tipCents for validation condition so that simultaneous status=paid + tipCents=0 skip split validation - Use body.tipCents (now aliased as tipCents) instead of current.tipCents inside the atomic transaction for shareCents calculation - Add explicit check for empty tipSplits array with appropriate error message ("Tip splits are required when tip amount is greater than zero") before the sum-to-100% check - Destructure tipSplits out of body before spreading into update object to prevent it from leaking into the invoices table SET clause Co-Authored-By: Paperclip --- apps/api/src/routes/invoices.ts | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/apps/api/src/routes/invoices.ts b/apps/api/src/routes/invoices.ts index 5d0cfa6..ef6b96f 100644 --- a/apps/api/src/routes/invoices.ts +++ b/apps/api/src/routes/invoices.ts @@ -350,20 +350,25 @@ invoicesRouter.patch( } // Validate and persist tip splits when marking invoice as paid - if (body.status === "paid" && current.tipCents > 0) { + const tipCents = body.tipCents ?? current.tipCents; + if (body.status === "paid" && tipCents > 0) { // If incoming splits are provided in the request body, atomically replace them if (body.tipSplits !== undefined) { + if (body.tipSplits.length === 0) { + return c.json({ error: "Tip splits are required when tip amount is greater than zero" }, 400); + } const totalPct = body.tipSplits.reduce((sum, s) => sum + s.sharePct, 0); if (Math.abs(totalPct - 100) > 0.01) { return c.json({ error: "Tip split percentages must sum to 100%" }, 400); } await db.transaction(async (tx) => { await tx.delete(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)); - if (body.tipSplits.length > 0) { - let remaining = current.tipCents; - const rows = body.tipSplits.map((s, i) => { - const isLast = i === body.tipSplits.length - 1; - const shareCents = isLast ? remaining : Math.round((s.sharePct / 100) * current.tipCents); + const splits = body.tipSplits!; + if (splits.length > 0) { + let remaining = tipCents; + const rows = splits.map((s, i) => { + const isLast = i === splits.length - 1; + const shareCents = isLast ? remaining : Math.round((s.sharePct / 100) * tipCents); if (!isLast) remaining -= shareCents; return { invoiceId: id, @@ -400,7 +405,10 @@ invoicesRouter.patch( } } - const update: Record = { ...body, updatedAt: new Date() }; + // Destructure tipSplits out — it belongs to a separate table, not the invoices column + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { tipSplits: _tipSplits, ...updateBody } = body as Record; + const update: Record = { ...updateBody, updatedAt: new Date() }; // Auto-set paidAt when marking as paid if (body.status === "paid" && !body.paidAt && !current.paidAt) {