From 8f06f32e7d6e1d724562e1e3cba1a4dc2cba0173 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Wed, 15 Apr 2026 03:50:40 +0000 Subject: [PATCH] fix(invoices): wrap refund flow in transaction for idempotency safety - Wrap idempotency check + processRefund() + db.insert() in db.transaction() - This prevents duplicate Stripe refunds if the DB insert fails after Stripe processes the refund - Add migration 0027_refunds for the refunds table (was missing) - Removes out-of-scope changes from PR #278 (csrf.ts, appointmentGroups, appointments, book, groomingLogs, services, stripe-webhooks) Fixes GRO-637 per CTO review Co-Authored-By: Paperclip --- apps/api/src/routes/invoices.ts | 36 ++++++++++++----------- packages/db/migrations/0027_refunds.sql | 11 +++++++ packages/db/migrations/meta/_journal.json | 7 +++++ 3 files changed, 37 insertions(+), 17 deletions(-) create mode 100644 packages/db/migrations/0027_refunds.sql diff --git a/apps/api/src/routes/invoices.ts b/apps/api/src/routes/invoices.ts index 2d82af6..2714be4 100644 --- a/apps/api/src/routes/invoices.ts +++ b/apps/api/src/routes/invoices.ts @@ -395,26 +395,28 @@ invoicesRouter.post( return c.json({ error: "No Stripe payment intent found for this invoice" }, 422); } - if (body.idempotencyKey) { - const [existing] = await db - .select() - .from(refunds) - .where(eq(refunds.idempotencyKey, body.idempotencyKey)); - if (existing) { - return c.json({ refundId: existing.stripeRefundId }); + return await db.transaction(async (tx) => { + if (body.idempotencyKey) { + const [existing] = await tx + .select() + .from(refunds) + .where(eq(refunds.idempotencyKey, body.idempotencyKey)); + if (existing) { + return c.json({ refundId: existing.stripeRefundId }); + } } - } - const result = await processRefund(id, body.amountCents); - if (!result) return c.json({ error: "Refund failed" }, 500); + const result = await processRefund(id, body.amountCents); + if (!result) return c.json({ error: "Refund failed" }, 500); - await db.insert(refunds).values({ - invoiceId: id, - stripeRefundId: result.refundId, - idempotencyKey: body.idempotencyKey ?? null, - amountCents: body.amountCents ?? null, + await tx.insert(refunds).values({ + invoiceId: id, + stripeRefundId: result.refundId, + idempotencyKey: body.idempotencyKey ?? null, + amountCents: body.amountCents ?? null, + }); + + return c.json({ refundId: result.refundId }); }); - - return c.json({ refundId: result.refundId }); } ); diff --git a/packages/db/migrations/0027_refunds.sql b/packages/db/migrations/0027_refunds.sql new file mode 100644 index 0000000..ba8d6ea --- /dev/null +++ b/packages/db/migrations/0027_refunds.sql @@ -0,0 +1,11 @@ +CREATE TABLE "refunds" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "invoice_id" uuid NOT NULL REFERENCES "invoices"("id") ON DELETE RESTRICT, + "stripe_refund_id" text NOT NULL, + "idempotency_key" text UNIQUE, + "amount_cents" integer, + "created_at" timestamp NOT NULL DEFAULT NOW() +); + +CREATE INDEX "idx_refunds_invoice_id" ON "refunds"("invoice_id"); +CREATE INDEX "idx_refunds_idempotency_key" ON "refunds"("idempotency_key"); diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 96da64a..c5ad96e 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -190,6 +190,13 @@ "when": 1775568867192, "tag": "0026_stripe_payment", "breakpoints": true + }, + { + "idx": 27, + "version": "7", + "when": 1775655267192, + "tag": "0027_refunds", + "breakpoints": true } ] } \ No newline at end of file