From b65d5ff0d66d56096ec4f5ff17867c8cf844031e Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 17 Apr 2026 12:21:59 +0000 Subject: [PATCH] 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 --- apps/api/src/routes/invoices.ts | 72 ++++++++++++++++++++++++++++++--- apps/web/src/pages/Invoices.tsx | 36 ++++++++--------- 2 files changed, 82 insertions(+), 26 deletions(-) diff --git a/apps/api/src/routes/invoices.ts b/apps/api/src/routes/invoices.ts index 2714be4..1527128 100644 --- a/apps/api/src/routes/invoices.ts +++ b/apps/api/src/routes/invoices.ts @@ -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 = { ...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 = { ...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() diff --git a/apps/web/src/pages/Invoices.tsx b/apps/web/src/pages/Invoices.tsx index 2cbf3ae..0712b9c 100644 --- a/apps/web/src/pages/Invoices.tsx +++ b/apps/web/src/pages/Invoices.tsx @@ -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"); -- 2.52.0