From d8bbb6df60dafd5f872ff418c2826ff8382c3749 Mon Sep 17 00:00:00 2001 From: Frank Date: Sat, 24 Jan 2026 08:33:47 -0500 Subject: [PATCH] zen: disable reload when reload fails --- infra/console.ts | 2 + .../console/app/src/routes/stripe/webhook.ts | 70 +++++++++++++++++-- packages/console/core/src/billing.ts | 31 ++------ 3 files changed, 74 insertions(+), 29 deletions(-) diff --git a/infra/console.ts b/infra/console.ts index 3d482160d0..5b08e9ceaa 100644 --- a/infra/console.ts +++ b/infra/console.ts @@ -77,6 +77,8 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint", "checkout.session.expired", "charge.refunded", "invoice.payment_succeeded", + "invoice.payment_failed", + "invoice.payment_action_required", "customer.created", "customer.deleted", "customer.updated", diff --git a/packages/console/app/src/routes/stripe/webhook.ts b/packages/console/app/src/routes/stripe/webhook.ts index 34f83b4460..9e310dc07d 100644 --- a/packages/console/app/src/routes/stripe/webhook.ts +++ b/packages/console/app/src/routes/stripe/webhook.ts @@ -141,8 +141,6 @@ export async function POST(input: APIEvent) { return couponID })() - // get user - await Actor.provide("system", { workspaceID }, async () => { // look up current billing const billing = await Billing.get() @@ -422,8 +420,8 @@ export async function POST(input: APIEvent) { } if (body.type === "invoice.payment_succeeded") { if ( - body.data.object.billing_reason === "subscription_cycle" || - body.data.object.billing_reason === "subscription_create" + body.data.object.billing_reason === "subscription_create" || + body.data.object.billing_reason === "subscription_cycle" ) { const invoiceID = body.data.object.id as string const amountInCents = body.data.object.amount_paid @@ -476,6 +474,70 @@ export async function POST(input: APIEvent) { }, }), ) + } else if (body.data.object.billing_reason === "manual") { + const workspaceID = body.data.object.metadata?.workspaceID + const amountInCents = body.data.object.metadata?.amount && parseInt(body.data.object.metadata?.amount) + const invoiceID = body.data.object.id as string + const customerID = body.data.object.customer as string + + if (!workspaceID) throw new Error("Workspace ID not found") + if (!customerID) throw new Error("Customer ID not found") + if (!amountInCents) throw new Error("Amount not found") + if (!invoiceID) throw new Error("Invoice ID not found") + + await Actor.provide("system", { workspaceID }, async () => { + // get payment id from invoice + const invoice = await Billing.stripe().invoices.retrieve(invoiceID, { + expand: ["payments"], + }) + await Database.transaction(async (tx) => { + await tx + .update(BillingTable) + .set({ + balance: sql`${BillingTable.balance} + ${centsToMicroCents(amountInCents)}`, + reloadError: null, + timeReloadError: null, + }) + .where(eq(BillingTable.workspaceID, Actor.workspace())) + await tx.insert(PaymentTable).values({ + workspaceID: Actor.workspace(), + id: Identifier.create("payment"), + amount: centsToMicroCents(amountInCents), + invoiceID, + paymentID: invoice.payments?.data[0].payment.payment_intent as string, + customerID, + }) + }) + }) + } + } + if (body.type === "invoice.payment_failed" || body.type === "invoice.payment_action_required") { + if (body.data.object.billing_reason === "manual") { + const workspaceID = body.data.object.metadata?.workspaceID + const invoiceID = body.data.object.id + + if (!workspaceID) throw new Error("Workspace ID not found") + if (!invoiceID) throw new Error("Invoice ID not found") + + const paymentIntent = await Billing.stripe().paymentIntents.retrieve(invoiceID); + console.log(JSON.stringify(paymentIntent)) + const errorMessage = + typeof paymentIntent === "object" && paymentIntent !== null + ? paymentIntent.last_payment_error?.message + : undefined + + await Actor.provide("system", { workspaceID }, async () => { + await Database.use((tx) => + tx + .update(BillingTable) + .set({ + reload: false, + reloadError: errorMessage ?? "Payment failed.", + timeReloadError: sql`now()`, + }) + .where(eq(BillingTable.workspaceID, Actor.workspace())), + ) + }) } } if (body.type === "charge.refunded") { diff --git a/packages/console/core/src/billing.ts b/packages/console/core/src/billing.ts index 7031a384b5..44f12db9e3 100644 --- a/packages/console/core/src/billing.ts +++ b/packages/console/core/src/billing.ts @@ -78,8 +78,6 @@ export namespace Billing { const customerID = billing.customerID const paymentMethodID = billing.paymentMethodID const amountInCents = (billing.reloadAmount ?? Billing.RELOAD_AMOUNT) * 100 - const paymentID = Identifier.create("payment") - let invoice try { const draft = await Billing.stripe().invoices.create({ customer: customerID!, @@ -87,6 +85,10 @@ export namespace Billing { default_payment_method: paymentMethodID!, collection_method: "charge_automatically", currency: "usd", + metadata: { + workspaceID: Actor.workspace(), + amount: amountInCents.toString(), + }, }) await Billing.stripe().invoiceItems.create({ amount: amountInCents, @@ -103,19 +105,17 @@ export namespace Billing { description: ITEM_FEE_NAME, }) await Billing.stripe().invoices.finalizeInvoice(draft.id!) - invoice = await Billing.stripe().invoices.pay(draft.id!, { + await Billing.stripe().invoices.pay(draft.id!, { off_session: true, payment_method: paymentMethodID!, - expand: ["payments"], }) - if (invoice.status !== "paid" || invoice.payments?.data.length !== 1) - throw new Error(invoice.last_finalization_error?.message) } catch (e: any) { console.error(e) await Database.use((tx) => tx .update(BillingTable) .set({ + reload: false, reloadError: e.message ?? "Payment failed.", timeReloadError: sql`now()`, }) @@ -123,25 +123,6 @@ export namespace Billing { ) return } - - await Database.transaction(async (tx) => { - await tx - .update(BillingTable) - .set({ - balance: sql`${BillingTable.balance} + ${centsToMicroCents(amountInCents)}`, - reloadError: null, - timeReloadError: null, - }) - .where(eq(BillingTable.workspaceID, Actor.workspace())) - await tx.insert(PaymentTable).values({ - workspaceID: Actor.workspace(), - id: paymentID, - amount: centsToMicroCents(amountInCents), - invoiceID: invoice.id!, - paymentID: invoice.payments?.data[0].payment.payment_intent as string, - customerID, - }) - }) } export const grantCredit = async (workspaceID: string, dollarAmount: number) => {