From 8cf2b742a08b969c46b5894de1c42810839e67c6 Mon Sep 17 00:00:00 2001 From: PanAchy Date: Tue, 19 May 2026 16:11:12 -0500 Subject: [PATCH 1/2] fix(console): add idempotency checks to payment webhook handlers Two webhook handlers unconditionally added credits on every delivery: - checkout.session.completed (manual top-up) - invoice.payment_succeeded with billing_reason=manual (auto-reload) Stripe guarantees at-least-once delivery and retries on any 5xx or timeout. Each retry would insert a duplicate PaymentTable row and increment the workspace balance again, effectively granting free credits. Fix: - Add a unique index on PaymentTable.paymentID in the schema (requires migration generation by a maintainer with infra access) - In both handlers, check if a payment with this paymentID already exists and return early if so, before any balance or DB writes --- .../console/app/src/routes/stripe/webhook.ts | 22 ++++++++++++++++++- .../console/core/src/schema/billing.sql.ts | 2 +- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/console/app/src/routes/stripe/webhook.ts b/packages/console/app/src/routes/stripe/webhook.ts index 58522cacc46f..c808cc335381 100644 --- a/packages/console/app/src/routes/stripe/webhook.ts +++ b/packages/console/app/src/routes/stripe/webhook.ts @@ -60,6 +60,15 @@ export async function POST(input: APIEvent) { const customer = await Billing.get() if (customer?.customerID && customer.customerID !== customerID) throw new Error("Customer ID mismatch") + const existingPayment = await Database.use((tx) => + tx + .select({ id: PaymentTable.id }) + .from(PaymentTable) + .where(eq(PaymentTable.paymentID, paymentID)) + .then((rows) => rows[0]), + ) + if (existingPayment) return + // set customer metadata if (!customer?.customerID) { await Billing.stripe().customers.update(customerID, { @@ -265,6 +274,17 @@ export async function POST(input: APIEvent) { const invoice = await Billing.stripe().invoices.retrieve(invoiceID, { expand: ["payments"], }) + const paymentID = invoice.payments?.data[0].payment.payment_intent as string + + const existingPayment = await Database.use((tx) => + tx + .select({ id: PaymentTable.id }) + .from(PaymentTable) + .where(eq(PaymentTable.paymentID, paymentID)) + .then((rows) => rows[0]), + ) + if (existingPayment) return + await Database.transaction(async (tx) => { await tx .update(BillingTable) @@ -279,7 +299,7 @@ export async function POST(input: APIEvent) { id: Identifier.create("payment"), amount: centsToMicroCents(amountInCents), invoiceID, - paymentID: invoice.payments?.data[0].payment.payment_intent as string, + paymentID, customerID, }) }) diff --git a/packages/console/core/src/schema/billing.sql.ts b/packages/console/core/src/schema/billing.sql.ts index 915646cf3da0..d31144303a0a 100644 --- a/packages/console/core/src/schema/billing.sql.ts +++ b/packages/console/core/src/schema/billing.sql.ts @@ -107,7 +107,7 @@ export const PaymentTable = mysqlTable( } >(), }, - (table) => [...workspaceIndexes(table)], + (table) => [...workspaceIndexes(table), uniqueIndex("payment_payment_id").on(table.paymentID)], ) export const UsageTable = mysqlTable( From de2cf26d10e04978915495bb5d93986d3a1ca7c3 Mon Sep 17 00:00:00 2001 From: PanAchy Date: Tue, 19 May 2026 18:08:37 -0500 Subject: [PATCH 2/2] fix(console): use invoiceID for payment idempotency to handle null paymentID --- packages/console/app/src/routes/stripe/webhook.ts | 6 +++--- packages/console/core/src/schema/billing.sql.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/console/app/src/routes/stripe/webhook.ts b/packages/console/app/src/routes/stripe/webhook.ts index c808cc335381..dbad737d15c0 100644 --- a/packages/console/app/src/routes/stripe/webhook.ts +++ b/packages/console/app/src/routes/stripe/webhook.ts @@ -64,7 +64,7 @@ export async function POST(input: APIEvent) { tx .select({ id: PaymentTable.id }) .from(PaymentTable) - .where(eq(PaymentTable.paymentID, paymentID)) + .where(eq(PaymentTable.invoiceID, invoiceID)) .then((rows) => rows[0]), ) if (existingPayment) return @@ -274,13 +274,13 @@ export async function POST(input: APIEvent) { const invoice = await Billing.stripe().invoices.retrieve(invoiceID, { expand: ["payments"], }) - const paymentID = invoice.payments?.data[0].payment.payment_intent as string + const paymentID = invoice.payments?.data[0]?.payment.payment_intent as string | undefined const existingPayment = await Database.use((tx) => tx .select({ id: PaymentTable.id }) .from(PaymentTable) - .where(eq(PaymentTable.paymentID, paymentID)) + .where(eq(PaymentTable.invoiceID, invoiceID)) .then((rows) => rows[0]), ) if (existingPayment) return diff --git a/packages/console/core/src/schema/billing.sql.ts b/packages/console/core/src/schema/billing.sql.ts index d31144303a0a..ef32839c83a1 100644 --- a/packages/console/core/src/schema/billing.sql.ts +++ b/packages/console/core/src/schema/billing.sql.ts @@ -107,7 +107,7 @@ export const PaymentTable = mysqlTable( } >(), }, - (table) => [...workspaceIndexes(table), uniqueIndex("payment_payment_id").on(table.paymentID)], + (table) => [...workspaceIndexes(table), uniqueIndex("payment_invoice_id").on(table.invoiceID)], ) export const UsageTable = mysqlTable(