From ffcc63b3c6fee8d3ee95a0be4c523c9006d5574c Mon Sep 17 00:00:00 2001 From: PanAchy Date: Tue, 19 May 2026 15:23:03 -0500 Subject: [PATCH 1/3] fix(console): verify payment ownership before generating receipt URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Billing.generateReceiptUrl was calling Stripe with a caller-supplied paymentID without checking that the payment belongs to the caller's workspace. Any authenticated user who knew a payment intent ID from another workspace could retrieve that workspace's Stripe receipt URL (billing email, card last-4, amount, date) — a classic IDOR. Fix: look up the payment in PaymentTable scoped to Actor.workspace() before calling Stripe. Throw if not found. --- packages/console/core/src/billing.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/console/core/src/billing.ts b/packages/console/core/src/billing.ts index 82307658d777..9ad03cd5f64f 100644 --- a/packages/console/core/src/billing.ts +++ b/packages/console/core/src/billing.ts @@ -457,6 +457,21 @@ export namespace Billing { async (input) => { const { paymentID } = input + const payment = await Database.use((tx) => + tx + .select() + .from(PaymentTable) + .where( + and( + eq(PaymentTable.workspaceID, Actor.workspace()), + eq(PaymentTable.paymentID, paymentID), + isNull(PaymentTable.timeDeleted), + ), + ) + .then((rows) => rows[0]), + ) + if (!payment) throw new Error("Payment not found") + const intent = await Billing.stripe().paymentIntents.retrieve(paymentID) if (!intent.latest_charge) throw new Error("No charge found") From 07b96c5f5fb7e17ca0f42850759fcde31506efd1 Mon Sep 17 00:00:00 2001 From: PanAchy Date: Tue, 19 May 2026 17:15:11 -0500 Subject: [PATCH 2/3] fix(console): activate lite subscription in invoice.payment_succeeded to handle 3DS/SCA In 3DS/SCA payment flows Stripe attaches the payment method asynchronously after the checkout redirect, so default_payment_method is null at the time customer.subscription.created fires. The previous guard if (!paymentMethodID) throw new Error('Payment method ID not found') caused the entire activation block to be skipped. Stripe retries the webhook for 72 hours and then gives up, leaving the user permanently locked out of Lite with no recovery path. Fix: - Move lite subscription activation (BillingTable.lite, LiteTable insert, coupon redemption) from customer.subscription.created to invoice.payment_succeeded with billing_reason === 'subscription_create'. Payment confirmation is the correct trigger; it fires for both immediate- payment and 3DS/SCA flows. - Look up workspaceID, userID, userEmail, and coupon directly from the Stripe subscription metadata instead of BillingTable, making activation independent of whether customer.subscription.created succeeded. - Split subscription_create and subscription_cycle branches in invoice.payment_succeeded so activation logic is isolated. - Add an idempotency guard (billing.lite check) so Stripe retries are safe. - customer.subscription.created now only records customerID and liteSubscriptionID; activation is deferred to payment confirmation. --- .../console/app/src/routes/stripe/webhook.ts | 167 ++++++++++++------ 1 file changed, 109 insertions(+), 58 deletions(-) diff --git a/packages/console/app/src/routes/stripe/webhook.ts b/packages/console/app/src/routes/stripe/webhook.ts index dfff3bd809b4..c3a0361e5cc0 100644 --- a/packages/console/app/src/routes/stripe/webhook.ts +++ b/packages/console/app/src/routes/stripe/webhook.ts @@ -109,31 +109,20 @@ export async function POST(input: APIEvent) { const type = body.data.object.metadata?.type if (type === "lite") { const workspaceID = body.data.object.metadata?.workspaceID - const userID = body.data.object.metadata?.userID - const userEmail = body.data.object.metadata?.userEmail - const coupon = body.data.object.metadata?.coupon const customerID = body.data.object.customer as string - const invoiceID = body.data.object.latest_invoice as string const subscriptionID = body.data.object.id as string - const paymentMethodID = body.data.object.default_payment_method as string if (!workspaceID) throw new Error("Workspace ID not found") - if (!userID) throw new Error("User ID not found") if (!customerID) throw new Error("Customer ID not found") - if (!invoiceID) throw new Error("Invoice ID not found") if (!subscriptionID) throw new Error("Subscription ID not found") - if (!paymentMethodID) throw new Error("Payment method ID not found") - // get payment method for the payment intent - const paymentMethod = await Billing.stripe().paymentMethods.retrieve(paymentMethodID) await Actor.provide("system", { workspaceID }, async () => { - // look up current billing const billing = await Billing.get() if (!billing) throw new Error(`Workspace with ID ${workspaceID} not found`) if (billing.customerID && billing.customerID !== customerID) throw new Error("Customer ID mismatch") // set customer metadata - if (!billing?.customerID) { + if (!billing.customerID) { await Billing.stripe().customers.update(customerID, { metadata: { workspaceID, @@ -141,46 +130,12 @@ export async function POST(input: APIEvent) { }) } - await Database.transaction(async (tx) => { - await tx + await Database.use((tx) => + tx .update(BillingTable) - .set({ - customerID, - liteSubscriptionID: subscriptionID, - lite: {}, - paymentMethodID: paymentMethod.id, - paymentMethodLast4: paymentMethod.card?.last4 ?? null, - paymentMethodType: paymentMethod.type, - }) - .where(eq(BillingTable.workspaceID, workspaceID)) - - await tx.insert(LiteTable).values({ - workspaceID, - id: Identifier.create("lite"), - userID: userID, - }) - - if (userEmail) { - if (coupon === LiteData.firstMonth50Coupon) { - await Billing.redeemCoupon(userEmail, "GO1MONTH50") - } else if (coupon === LiteData.firstMonth100Coupon) { - await Billing.redeemCoupon(userEmail, "GOFREEMONTH") - } else if (coupon === LiteData.threeMonths100Coupon) { - await Billing.redeemCoupon(userEmail, "GO3MONTHS100") - } else if (coupon === LiteData.sixMonths100Coupon) { - await Billing.redeemCoupon(userEmail, "GO6MONTHS100") - } else if (coupon === LiteData.twelveMonths100Coupon) { - await Billing.redeemCoupon(userEmail, "GO12MONTHS100") - } - } - }) - - await Referral.completeFromLiteSubscription({ - workspaceID, - userID, - }).catch((error) => { - console.error("Referral sync failed", error) - }) + .set({ customerID, liteSubscriptionID: subscriptionID }) + .where(eq(BillingTable.workspaceID, workspaceID)), + ) }) } } @@ -207,10 +162,108 @@ export async function POST(input: APIEvent) { } } if (body.type === "invoice.payment_succeeded") { - if ( - body.data.object.billing_reason === "subscription_create" || - body.data.object.billing_reason === "subscription_cycle" - ) { + if (body.data.object.billing_reason === "subscription_create") { + const invoiceID = body.data.object.id as string + const amountInCents = body.data.object.amount_paid + const customerID = body.data.object.customer as string + const subscriptionID = body.data.object.parent?.subscription_details?.subscription as string + const productID = body.data.object.lines?.data[0].pricing?.price_details?.product as string + + if (!customerID) throw new Error("Customer ID not found") + if (!invoiceID) throw new Error("Invoice ID not found") + if (!subscriptionID) throw new Error("Subscription ID not found") + + const invoice = await Billing.stripe().invoices.retrieve(invoiceID, { + expand: ["discounts", "payments"], + }) + const paymentID = invoice.payments?.data[0]?.payment.payment_intent as string + const couponID = (invoice.discounts[0] as Stripe.Discount)?.coupon?.id as string + if (!paymentID) { + if (!couponID) throw new Error("Payment ID not found") + } + + const subscription = await Billing.stripe().subscriptions.retrieve(subscriptionID) + const workspaceID = subscription.metadata?.workspaceID + const userID = subscription.metadata?.userID + const userEmail = subscription.metadata?.userEmail + const coupon = subscription.metadata?.coupon + + if (!workspaceID) throw new Error("Workspace ID not found in subscription metadata") + + await Actor.provide("system", { workspaceID }, async () => { + const billing = await Billing.get() + if (!billing) throw new Error(`Workspace with ID ${workspaceID} not found`) + + await Database.use((tx) => + tx.insert(PaymentTable).values({ + workspaceID, + id: Identifier.create("payment"), + amount: centsToMicroCents(amountInCents), + paymentID, + invoiceID, + customerID, + enrichment: { + type: productID === LiteData.productID() ? "lite" : "subscription", + currency: body.data.object.currency === "inr" ? "inr" : undefined, + couponID, + }, + }), + ) + + if (productID === LiteData.productID() && !billing.lite) { + const paymentMethod = paymentID + ? await Billing.stripe() + .paymentIntents.retrieve(paymentID, { expand: ["payment_method"] }) + .then((pi) => (typeof pi.payment_method !== "string" ? pi.payment_method : null)) + : null + + await Database.transaction(async (tx) => { + await tx + .update(BillingTable) + .set({ + customerID, + liteSubscriptionID: subscriptionID, + lite: {}, + ...(paymentMethod + ? { + paymentMethodID: paymentMethod.id, + paymentMethodLast4: paymentMethod.card?.last4 ?? null, + paymentMethodType: paymentMethod.type, + } + : {}), + }) + .where(eq(BillingTable.workspaceID, workspaceID)) + + await tx.insert(LiteTable).values({ + workspaceID, + id: Identifier.create("lite"), + userID, + }) + }) + + if (userEmail) { + if (coupon === LiteData.firstMonth50Coupon) { + await Billing.redeemCoupon(userEmail, "GO1MONTH50") + } else if (coupon === LiteData.firstMonth100Coupon) { + await Billing.redeemCoupon(userEmail, "GOFREEMONTH") + } else if (coupon === LiteData.threeMonths100Coupon) { + await Billing.redeemCoupon(userEmail, "GO3MONTHS100") + } else if (coupon === LiteData.sixMonths100Coupon) { + await Billing.redeemCoupon(userEmail, "GO6MONTHS100") + } else if (coupon === LiteData.twelveMonths100Coupon) { + await Billing.redeemCoupon(userEmail, "GO12MONTHS100") + } + } + + await Referral.completeFromLiteSubscription({ + workspaceID, + userID: userID!, + }).catch((error) => { + console.error("Referral sync failed", error) + }) + } + }) + } else if (body.data.object.billing_reason === "subscription_cycle") { const invoiceID = body.data.object.id as string const amountInCents = body.data.object.amount_paid const customerID = body.data.object.customer as string @@ -221,14 +274,12 @@ export async function POST(input: APIEvent) { if (!invoiceID) throw new Error("Invoice ID not found") if (!subscriptionID) throw new Error("Subscription ID not found") - // get coupon id from subscription const invoice = await Billing.stripe().invoices.retrieve(invoiceID, { expand: ["discounts", "payments"], }) const paymentID = invoice.payments?.data[0]?.payment.payment_intent as string - const couponID = (invoice.discounts[0] as Stripe.Discount).coupon?.id as string + const couponID = (invoice.discounts[0] as Stripe.Discount)?.coupon?.id as string if (!paymentID) { - // payment id can be undefined when using coupon if (!couponID) throw new Error("Payment ID not found") } From 2bcd0a24bc0400d2a7412d9ccb6cb07502e0f11f Mon Sep 17 00:00:00 2001 From: PanAchy Date: Tue, 19 May 2026 18:18:32 -0500 Subject: [PATCH 3/3] fix(console): validate userID before payment insert and decouple coupon redemption from activation guard --- .../console/app/src/routes/stripe/webhook.ts | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/packages/console/app/src/routes/stripe/webhook.ts b/packages/console/app/src/routes/stripe/webhook.ts index c3a0361e5cc0..1e2af0bf6a6d 100644 --- a/packages/console/app/src/routes/stripe/webhook.ts +++ b/packages/console/app/src/routes/stripe/webhook.ts @@ -189,6 +189,7 @@ export async function POST(input: APIEvent) { const coupon = subscription.metadata?.coupon if (!workspaceID) throw new Error("Workspace ID not found in subscription metadata") + if (productID === LiteData.productID() && !userID) throw new Error("User ID not found in subscription metadata") await Actor.provide("system", { workspaceID }, async () => { const billing = await Billing.get() @@ -237,22 +238,22 @@ export async function POST(input: APIEvent) { await tx.insert(LiteTable).values({ workspaceID, id: Identifier.create("lite"), - userID, + userID: userID!, }) }) + } - if (userEmail) { - if (coupon === LiteData.firstMonth50Coupon) { - await Billing.redeemCoupon(userEmail, "GO1MONTH50") - } else if (coupon === LiteData.firstMonth100Coupon) { - await Billing.redeemCoupon(userEmail, "GOFREEMONTH") - } else if (coupon === LiteData.threeMonths100Coupon) { - await Billing.redeemCoupon(userEmail, "GO3MONTHS100") - } else if (coupon === LiteData.sixMonths100Coupon) { - await Billing.redeemCoupon(userEmail, "GO6MONTHS100") - } else if (coupon === LiteData.twelveMonths100Coupon) { - await Billing.redeemCoupon(userEmail, "GO12MONTHS100") - } + if (productID === LiteData.productID() && userEmail) { + if (coupon === LiteData.firstMonth50Coupon) { + await Billing.redeemCoupon(userEmail, "GO1MONTH50") + } else if (coupon === LiteData.firstMonth100Coupon) { + await Billing.redeemCoupon(userEmail, "GOFREEMONTH") + } else if (coupon === LiteData.threeMonths100Coupon) { + await Billing.redeemCoupon(userEmail, "GO3MONTHS100") + } else if (coupon === LiteData.sixMonths100Coupon) { + await Billing.redeemCoupon(userEmail, "GO6MONTHS100") + } else if (coupon === LiteData.twelveMonths100Coupon) { + await Billing.redeemCoupon(userEmail, "GO12MONTHS100") } await Referral.completeFromLiteSubscription({