From 6e55e4ef5cfcdd9953b2a4473646321976b5dba1 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Fri, 17 Apr 2026 22:15:48 +0000 Subject: [PATCH] fix(GRO-785): restore atomic tip split save in PATCH and fix error message - When body.tipSplits is provided in PATCH /invoices/:id, validate sum first then atomically replace existing splits (delete + insert) - When no incoming splits, validate existing DB splits with corrected message: "Tip splits are required when tip amount is greater than zero" (previously misleading "must sum to 100%" when no splits existed) Co-Authored-By: Paperclip --- apps/api/src/routes/invoices.ts | 62 ++++++++++++++++++++++++--------- 1 file changed, 45 insertions(+), 17 deletions(-) diff --git a/apps/api/src/routes/invoices.ts b/apps/api/src/routes/invoices.ts index 69afe01..5d0cfa6 100644 --- a/apps/api/src/routes/invoices.ts +++ b/apps/api/src/routes/invoices.ts @@ -349,26 +349,54 @@ invoicesRouter.patch( } } - // Validate tip splits when marking invoice as paid + // Validate and persist tip splits when marking invoice as paid if (body.status === "paid" && current.tipCents > 0) { - const splits = await db - .select() - .from(invoiceTipSplits) - .where(eq(invoiceTipSplits.invoiceId, id)); + // If incoming splits are provided in the request body, atomically replace them + if (body.tipSplits !== undefined) { + 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); + 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); + } + }); + } else { + // No incoming splits — validate existing DB splits + const splits = await db + .select() + .from(invoiceTipSplits) + .where(eq(invoiceTipSplits.invoiceId, id)); - if (splits.length === 0) { - return c.json( - { error: "Tip split percentages must sum to 100%" }, - 400 - ); - } + if (splits.length === 0) { + return c.json( + { error: "Tip splits are required when tip amount is greater than zero" }, + 400 + ); + } - const totalBps = splits.reduce((sum, s) => sum + Math.round(Number(s.sharePct) * 100), 0); - if (totalBps !== 10000) { - return c.json( - { error: "Tip split percentages must sum to 100%" }, - 400 - ); + const totalBps = splits.reduce((sum, s) => sum + Math.round(Number(s.sharePct) * 100), 0); + if (totalBps !== 10000) { + return c.json( + { error: "Tip split percentages must sum to 100%" }, + 400 + ); + } } }