fix(GRO-785): wrap tip split save + invoice update in single transaction

Both tip split persistence (delete + insert) and the invoice PATCH update
are now inside one db.transaction() block. If the invoice update fails
after splits are written, the entire operation rolls back.

Also removed unnecessary eslint-disable comment on _tipSplits.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Flea Flicker
2026-04-17 22:29:30 +00:00
parent 20ca93b36d
commit 4aaf2a3b3f
+30 -48
View File
@@ -349,11 +349,10 @@ invoicesRouter.patch(
} }
} }
// Validate and persist tip splits when marking invoice as paid
const tipCents = body.tipCents ?? current.tipCents; const tipCents = body.tipCents ?? current.tipCents;
if (body.status === "paid" && tipCents > 0) {
// If incoming splits are provided in the request body, atomically replace them // Validate tip splits when marking invoice as paid
if (body.tipSplits !== undefined) { if (body.status === "paid" && tipCents > 0 && body.tipSplits !== undefined) {
if (body.tipSplits.length === 0) { if (body.tipSplits.length === 0) {
return c.json({ error: "Tip splits are required when tip amount is greater than zero" }, 400); return c.json({ error: "Tip splits are required when tip amount is greater than zero" }, 400);
} }
@@ -361,9 +360,29 @@ invoicesRouter.patch(
if (Math.abs(totalPct - 100) > 0.01) { if (Math.abs(totalPct - 100) > 0.01) {
return c.json({ error: "Tip split percentages must sum to 100%" }, 400); return c.json({ error: "Tip split percentages must sum to 100%" }, 400);
} }
await db.transaction(async (tx) => { }
// Destructure tipSplits out — it belongs to a separate table, not the invoices column
const { tipSplits: _tipSplits, ...updateBody } = body as Record<string, unknown>;
const update: Record<string, unknown> = { ...updateBody, updatedAt: new Date() };
// Auto-set paidAt when marking as paid
if (body.status === "paid" && !body.paidAt && !current.paidAt) {
update.paidAt = new Date();
}
// Recalculate total if tax or tip changed
const newTaxCents = body.taxCents ?? current.taxCents;
const newTipCents = body.tipCents ?? current.tipCents;
if (body.taxCents !== undefined || body.tipCents !== undefined) {
update.totalCents = current.subtotalCents + newTaxCents + newTipCents;
}
// Wrap tip split persistence and invoice update in a single atomic transaction
const [updated, lineItems] = await db.transaction(async (tx) => {
if (body.status === "paid" && tipCents > 0 && body.tipSplits !== undefined) {
await tx.delete(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)); await tx.delete(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id));
const splits = body.tipSplits!; const splits = body.tipSplits;
if (splits.length > 0) { if (splits.length > 0) {
let remaining = tipCents; let remaining = tipCents;
const rows = splits.map((s, i) => { const rows = splits.map((s, i) => {
@@ -380,59 +399,22 @@ invoicesRouter.patch(
}); });
await tx.insert(invoiceTipSplits).values(rows); 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 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); const [updatedInvoice] = await tx
if (totalBps !== 10000) {
return c.json(
{ error: "Tip split percentages must sum to 100%" },
400
);
}
}
}
// 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<string, unknown>;
const update: Record<string, unknown> = { ...updateBody, updatedAt: new Date() };
// Auto-set paidAt when marking as paid
if (body.status === "paid" && !body.paidAt && !current.paidAt) {
update.paidAt = new Date();
}
// Recalculate total if tax or tip changed
const newTaxCents = body.taxCents ?? current.taxCents;
const newTipCents = body.tipCents ?? current.tipCents;
if (body.taxCents !== undefined || body.tipCents !== undefined) {
update.totalCents = current.subtotalCents + newTaxCents + newTipCents;
}
const [updated] = await db
.update(invoices) .update(invoices)
.set(update) .set(update)
.where(eq(invoices.id, id)) .where(eq(invoices.id, id))
.returning(); .returning();
const lineItems = await db const lineItems = await tx
.select() .select()
.from(invoiceLineItems) .from(invoiceLineItems)
.where(eq(invoiceLineItems.invoiceId, id)); .where(eq(invoiceLineItems.invoiceId, id));
return [updatedInvoice, lineItems];
});
return c.json({ ...updated, lineItems }); return c.json({ ...updated, lineItems });
} }
); );