From 7610b1dd4a845f58266c6b086abadc507a407c71 Mon Sep 17 00:00:00 2001 From: 0xanshu Date: Mon, 6 Jul 2026 05:45:54 +0530 Subject: [PATCH 1/7] fix(products): products are now created automatically --- src/routes/http/api/onboarding.ts | 113 +++++++++++++++++++++++++----- src/zod/internals.ts | 16 ++++- 2 files changed, 107 insertions(+), 22 deletions(-) diff --git a/src/routes/http/api/onboarding.ts b/src/routes/http/api/onboarding.ts index 8213d45..ca18854 100644 --- a/src/routes/http/api/onboarding.ts +++ b/src/routes/http/api/onboarding.ts @@ -2,7 +2,11 @@ import { randomUUID } from "crypto"; import type { FastifyRequest, FastifyReply } from "fastify"; import * as Sentry from "@sentry/bun"; import { ZodError } from "zod"; -import DodoPayments from "dodopayments"; +import DodoPayments, { + AuthenticationError, + BadRequestError, +} from "dodopayments"; +import type { Currency } from "dodopayments/resources/misc"; import { onboardingSchema } from "../../../zod/internals.ts"; import { createWideEventBuilder, @@ -85,24 +89,72 @@ export async function handleOnboarding( let testSecret: string; let liveWebhookId: string | undefined; let testWebhookId: string | undefined; + let liveProductId: string; + let testProductId: string; try { - const liveWebhook = await liveClient.webhooks.create({ - url: `${appUrl}/webhooks/payment/createdCheckout?mode=production&projectId=${projectId}`, - description: "Scrawn live payment webhook", - filter_types: ["payment.succeeded", "payment.failed"], - }); - liveWebhookId = liveWebhook.id; + const [liveWebhook, testWebhook, liveProduct, testProduct] = + await Promise.all([ + liveClient.webhooks + .create({ + url: `${appUrl}/webhooks/payment/createCheckout?mode=production`, + description: "Scrawn live payment webhook", + filter_types: [ + "payment.succeeded", + "payment.processing", + "payment.failed", + ], + }) + .then((w) => { + liveWebhookId = w.id; + return w; + }), + testClient.webhooks + .create({ + url: `${appUrl}/webhooks/payment/createCheckout?mode=test`, + description: "Scrawn test payment webhook", + filter_types: [ + "payment.succeeded", + "payment.processing", + "payment.failed", + ], + }) + .then((w) => { + testWebhookId = w.id; + return w; + }), + liveClient.products.create({ + name: "Scrawn Billing", + price: { + type: "one_time_price", + currency: validated.currency as Currency, + price: 0, + pay_what_you_want: true, + purchasing_power_parity: false, + discount: 0, + }, + tax_category: "saas", + }), + testClient.products.create({ + name: "Scrawn Billing", + price: { + type: "one_time_price", + currency: validated.currency as Currency, + price: 0, + pay_what_you_want: true, + purchasing_power_parity: false, + discount: 0, + }, + tax_category: "saas", + }), + ]); + liveSecret = (await liveClient.webhooks.retrieveSecret(liveWebhook.id)) .secret; - - const testWebhook = await testClient.webhooks.create({ - url: `${appUrl}/webhooks/payment/createdCheckout?mode=test&projectId=${projectId}`, - description: "Scrawn test payment webhook", - filter_types: ["payment.succeeded", "payment.failed"], - }); - testWebhookId = testWebhook.id; testSecret = (await testClient.webhooks.retrieveSecret(testWebhook.id)) .secret; + + liveProductId = liveProduct.product_id; + testProductId = testProduct.product_id; } catch (error) { if (liveWebhookId) { await liveClient.webhooks.delete(liveWebhookId).catch((e) => @@ -118,13 +170,36 @@ export async function handleOnboarding( }) ); } - const errMsg = error instanceof Error ? error.message : String(error); + Sentry.captureException(error, { - extra: { context: "dodo webhook registration during onboarding" }, + extra: { + context: "dodo webhook/product registration during onboarding", + }, }); + + if (error instanceof AuthenticationError) { + builder.setError(400, { + type: "DodoAuthError", + message: + "Invalid Dodo Payments API key(s) provided. Please ensure both live and test keys are correct.", + }); + reply.code(400); + return {}; + } + + if (error instanceof BadRequestError) { + builder.setError(400, { + type: "DodoConfigError", + message: `Invalid configuration for Dodo Payments: ${error.message}`, + }); + reply.code(400); + return {}; + } + + const errMsg = error instanceof Error ? error.message : String(error); builder.setError(400, { type: "DodoApiError", - message: `Failed to register webhook with Dodo: ${errMsg}`, + message: `Failed to register with Dodo: ${errMsg}`, }); reply.code(400); return {}; @@ -146,8 +221,8 @@ export async function handleOnboarding( projectId, dodo_live_api_key: encrypt(validated.dodoLiveApiKey), dodo_test_api_key: encrypt(validated.dodoTestApiKey), - dodo_live_product_id: validated.dodoLiveProductId, - dodo_test_product_id: validated.dodoTestProductId, + dodo_live_product_id: validated.liveProductId, + dodo_test_product_id: validated.testProductId, dodo_live_webhook_secret: encrypt(liveSecret), dodo_test_webhook_secret: encrypt(testSecret), currency: validated.currency, diff --git a/src/zod/internals.ts b/src/zod/internals.ts index 525b71d..82ea408 100644 --- a/src/zod/internals.ts +++ b/src/zod/internals.ts @@ -1,5 +1,13 @@ import { z } from "zod"; +const currencyMap = { + usd: "USD", + eur: "EUR", + gbp: "GBP", + inr: "INR", + jpy: "JPY", +} as const; + export interface FilterGroupOutput { logical: "AND" | "OR"; conditions: C[]; @@ -36,8 +44,10 @@ export const onboardingSchema = z.object({ name: z.string().min(1, "Project name is required").max(255), dodoLiveApiKey: z.string().min(1, "Dodo live API key is required"), dodoTestApiKey: z.string().min(1, "Dodo test API key is required"), - dodoLiveProductId: z.string().min(1, "Dodo live product ID is required"), - dodoTestProductId: z.string().min(1, "Dodo test product ID is required"), - currency: z.string().min(1, "Currency is required"), + liveProductId: z.string().min(1, "Dodo live product ID is required"), + testProductId: z.string().min(1, "Dodo test product ID is required"), + currency: z + .enum(["usd", "eur", "gbp", "inr", "jpy"]) + .transform((c) => currencyMap[c]), redirectUrl: z.url("Redirect URL must be a valid URL"), }); From 9bde46f0691b5fa01869bc30c9346382aa4bc399 Mon Sep 17 00:00:00 2001 From: 0xanshu Date: Mon, 6 Jul 2026 05:51:48 +0530 Subject: [PATCH 2/7] fix(products): removed product_id from zod --- src/routes/http/api/onboarding.ts | 4 ++-- src/zod/internals.ts | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/routes/http/api/onboarding.ts b/src/routes/http/api/onboarding.ts index ca18854..e9ce0e7 100644 --- a/src/routes/http/api/onboarding.ts +++ b/src/routes/http/api/onboarding.ts @@ -221,8 +221,8 @@ export async function handleOnboarding( projectId, dodo_live_api_key: encrypt(validated.dodoLiveApiKey), dodo_test_api_key: encrypt(validated.dodoTestApiKey), - dodo_live_product_id: validated.liveProductId, - dodo_test_product_id: validated.testProductId, + dodo_live_product_id: liveProductId, + dodo_test_product_id: testProductId, dodo_live_webhook_secret: encrypt(liveSecret), dodo_test_webhook_secret: encrypt(testSecret), currency: validated.currency, diff --git a/src/zod/internals.ts b/src/zod/internals.ts index 82ea408..5fca269 100644 --- a/src/zod/internals.ts +++ b/src/zod/internals.ts @@ -44,8 +44,6 @@ export const onboardingSchema = z.object({ name: z.string().min(1, "Project name is required").max(255), dodoLiveApiKey: z.string().min(1, "Dodo live API key is required"), dodoTestApiKey: z.string().min(1, "Dodo test API key is required"), - liveProductId: z.string().min(1, "Dodo live product ID is required"), - testProductId: z.string().min(1, "Dodo test product ID is required"), currency: z .enum(["usd", "eur", "gbp", "inr", "jpy"]) .transform((c) => currencyMap[c]), From 6f55b52be6a05599cf7b5102d8138d0391a5a3bb Mon Sep 17 00:00:00 2001 From: 0xanshu Date: Mon, 6 Jul 2026 06:15:27 +0530 Subject: [PATCH 3/7] fix(products): webhook url now contains product id --- src/routes/http/api/onboarding.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routes/http/api/onboarding.ts b/src/routes/http/api/onboarding.ts index e9ce0e7..3a5fa7d 100644 --- a/src/routes/http/api/onboarding.ts +++ b/src/routes/http/api/onboarding.ts @@ -96,7 +96,7 @@ export async function handleOnboarding( await Promise.all([ liveClient.webhooks .create({ - url: `${appUrl}/webhooks/payment/createCheckout?mode=production`, + url: `${appUrl}/webhooks/payment/createdCheckout?mode=production&projectId=${projectId}`, description: "Scrawn live payment webhook", filter_types: [ "payment.succeeded", @@ -110,7 +110,7 @@ export async function handleOnboarding( }), testClient.webhooks .create({ - url: `${appUrl}/webhooks/payment/createCheckout?mode=test`, + url: `${appUrl}/webhooks/payment/createdCheckout?mode=test&projectId=${projectId}`, description: "Scrawn test payment webhook", filter_types: [ "payment.succeeded", From ed81030b63ec37304fbd8523d03999770af625e6 Mon Sep 17 00:00:00 2001 From: 0xanshu Date: Mon, 6 Jul 2026 06:32:38 +0530 Subject: [PATCH 4/7] fix(products): handling rejected promise better now --- src/routes/http/api/onboarding.ts | 125 +++++++++++++++++++++--------- 1 file changed, 90 insertions(+), 35 deletions(-) diff --git a/src/routes/http/api/onboarding.ts b/src/routes/http/api/onboarding.ts index 3a5fa7d..f1c8554 100644 --- a/src/routes/http/api/onboarding.ts +++ b/src/routes/http/api/onboarding.ts @@ -89,40 +89,40 @@ export async function handleOnboarding( let testSecret: string; let liveWebhookId: string | undefined; let testWebhookId: string | undefined; - let liveProductId: string; - let testProductId: string; + let liveProductId: string | undefined; + let testProductId: string | undefined; try { - const [liveWebhook, testWebhook, liveProduct, testProduct] = - await Promise.all([ - liveClient.webhooks - .create({ - url: `${appUrl}/webhooks/payment/createdCheckout?mode=production&projectId=${projectId}`, - description: "Scrawn live payment webhook", - filter_types: [ - "payment.succeeded", - "payment.processing", - "payment.failed", - ], - }) - .then((w) => { - liveWebhookId = w.id; - return w; - }), - testClient.webhooks - .create({ - url: `${appUrl}/webhooks/payment/createdCheckout?mode=test&projectId=${projectId}`, - description: "Scrawn test payment webhook", - filter_types: [ - "payment.succeeded", - "payment.processing", - "payment.failed", - ], - }) - .then((w) => { - testWebhookId = w.id; - return w; - }), - liveClient.products.create({ + const results = await Promise.allSettled([ + liveClient.webhooks + .create({ + url: `${appUrl}/webhooks/payment/createdCheckout?mode=production&projectId=${projectId}`, + description: "Scrawn live payment webhook", + filter_types: [ + "payment.succeeded", + "payment.processing", + "payment.failed", + ], + }) + .then((w) => { + liveWebhookId = w.id; + return w; + }), + testClient.webhooks + .create({ + url: `${appUrl}/webhooks/payment/createdCheckout?mode=test&projectId=${projectId}`, + description: "Scrawn test payment webhook", + filter_types: [ + "payment.succeeded", + "payment.processing", + "payment.failed", + ], + }) + .then((w) => { + testWebhookId = w.id; + return w; + }), + liveClient.products + .create({ name: "Scrawn Billing", price: { type: "one_time_price", @@ -133,8 +133,13 @@ export async function handleOnboarding( discount: 0, }, tax_category: "saas", + }) + .then((p) => { + liveProductId = p.product_id; + return p; }), - testClient.products.create({ + testClient.products + .create({ name: "Scrawn Billing", price: { type: "one_time_price", @@ -145,8 +150,24 @@ export async function handleOnboarding( discount: 0, }, tax_category: "saas", + }) + .then((p) => { + testProductId = p.product_id; + return p; }), - ]); + ]); + + const rejections = results.filter( + (r) => r.status === "rejected" + ) as PromiseRejectedResult[]; + if (rejections.length > 0) { + throw rejections[0]!.reason; + } + + const liveWebhook = (results[0] as PromiseFulfilledResult).value; + const testWebhook = (results[1] as PromiseFulfilledResult).value; + const liveProduct = (results[2] as PromiseFulfilledResult).value; + const testProduct = (results[3] as PromiseFulfilledResult).value; liveSecret = (await liveClient.webhooks.retrieveSecret(liveWebhook.id)) .secret; @@ -170,6 +191,20 @@ export async function handleOnboarding( }) ); } + if (liveProductId) { + await liveClient.products.archive(liveProductId).catch((e: unknown) => + Sentry.captureException(e, { + extra: { context: "rollback: failed to archive live product" }, + }) + ); + } + if (testProductId) { + await testClient.products.archive(testProductId).catch((e: unknown) => + Sentry.captureException(e, { + extra: { context: "rollback: failed to archive test product" }, + }) + ); + } Sentry.captureException(error, { extra: { @@ -258,6 +293,26 @@ export async function handleOnboarding( }) ); } + if (liveProductId) { + await liveClient.products.archive(liveProductId).catch((e: unknown) => + Sentry.captureException(e, { + extra: { + context: + "rollback: failed to archive live product after DB failure", + }, + }) + ); + } + if (testProductId) { + await testClient.products.archive(testProductId).catch((e: unknown) => + Sentry.captureException(e, { + extra: { + context: + "rollback: failed to archive test product after DB failure", + }, + }) + ); + } throw txnError; } From 2595e009837ec866faa20c7fbdb4f05555c9a805 Mon Sep 17 00:00:00 2001 From: 0xanshu Date: Mon, 6 Jul 2026 13:23:15 +0530 Subject: [PATCH 5/7] fix(products): removed typecasting from promise results --- src/routes/http/api/onboarding.ts | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/routes/http/api/onboarding.ts b/src/routes/http/api/onboarding.ts index f1c8554..5f285c9 100644 --- a/src/routes/http/api/onboarding.ts +++ b/src/routes/http/api/onboarding.ts @@ -157,17 +157,20 @@ export async function handleOnboarding( }), ]); - const rejections = results.filter( - (r) => r.status === "rejected" - ) as PromiseRejectedResult[]; - if (rejections.length > 0) { - throw rejections[0]!.reason; + if ( + results[0].status === "rejected" || + results[1].status === "rejected" || + results[2].status === "rejected" || + results[3].status === "rejected" + ) { + const rejected = results.find((r) => r.status === "rejected"); + throw rejected!.reason; } - const liveWebhook = (results[0] as PromiseFulfilledResult).value; - const testWebhook = (results[1] as PromiseFulfilledResult).value; - const liveProduct = (results[2] as PromiseFulfilledResult).value; - const testProduct = (results[3] as PromiseFulfilledResult).value; + const liveWebhook = results[0].value; + const testWebhook = results[1].value; + const liveProduct = results[2].value; + const testProduct = results[3].value; liveSecret = (await liveClient.webhooks.retrieveSecret(liveWebhook.id)) .secret; From f9be0c57329a7c88ffbf3ed02a755f5398ef1c73 Mon Sep 17 00:00:00 2001 From: 0xanshu Date: Mon, 6 Jul 2026 13:52:48 +0530 Subject: [PATCH 6/7] fix(products): changed to Currency type from dodo --- src/zod/internals.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/zod/internals.ts b/src/zod/internals.ts index 5fca269..22c2cae 100644 --- a/src/zod/internals.ts +++ b/src/zod/internals.ts @@ -1,12 +1,5 @@ import { z } from "zod"; - -const currencyMap = { - usd: "USD", - eur: "EUR", - gbp: "GBP", - inr: "INR", - jpy: "JPY", -} as const; +import type { Currency } from "dodopayments/resources"; export interface FilterGroupOutput { logical: "AND" | "OR"; @@ -46,6 +39,6 @@ export const onboardingSchema = z.object({ dodoTestApiKey: z.string().min(1, "Dodo test API key is required"), currency: z .enum(["usd", "eur", "gbp", "inr", "jpy"]) - .transform((c) => currencyMap[c]), + .transform((c) => c.toUpperCase() as Currency), redirectUrl: z.url("Redirect URL must be a valid URL"), }); From e2cb64abb1de33c9825468aa9f71c795f01c6122 Mon Sep 17 00:00:00 2001 From: 0xanshu Date: Mon, 6 Jul 2026 14:43:13 +0530 Subject: [PATCH 7/7] fix(products): correct path to import Currency type now --- src/zod/internals.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/zod/internals.ts b/src/zod/internals.ts index 22c2cae..3c97688 100644 --- a/src/zod/internals.ts +++ b/src/zod/internals.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import type { Currency } from "dodopayments/resources"; +import type { Currency } from "dodopayments/resources/misc"; export interface FilterGroupOutput { logical: "AND" | "OR";