Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 48 additions & 15 deletions src/routes/http/api/onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,30 +55,63 @@ export async function handleOnboarding(

let liveSecret: string;
let testSecret: string;
let liveProductId: string;
let testProductId: string;
try {
const liveWebhook = await liveClient.webhooks.create({
url: `${appUrl}/webhooks/payment/createdCheckout?mode=production`,
description: "Scrawn live payment webhook",
filter_types: ["payment.succeeded", "payment.failed"],
});
const [liveWebhook, testWebhook, liveProduct, testProduct] =
await Promise.all([
liveClient.webhooks.create({
url: `${appUrl}/webhooks/payment/createdCheckout?mode=production`,
description: "Scrawn live payment webhook",
filter_types: ["payment.succeeded", "payment.failed"],
}),
testClient.webhooks.create({
url: `${appUrl}/webhooks/payment/createdCheckout?mode=test`,
description: "Scrawn test payment webhook",
filter_types: ["payment.succeeded", "payment.failed"],
}),
liveClient.products.create({
name: "Scrawn Billing",
price: {
type: "one_time_price",
currency: validated.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,
price: 0,
pay_what_you_want: true,
purchasing_power_parity: false,
discount: 0,
},
tax_category: "saas",
}),
Comment on lines +73 to +96

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Documentation in related repo is now stale

The scrawndotdev/documentation repo's dashboard-setup.mdx still walks users through manually creating Dodo products and pasting their Product IDs into the onboarding form. With this PR, product creation is automatic and the dodoLiveProductId/dodoTestProductId fields have been removed from the schema. Any user following the current documentation will look for input fields that no longer exist, making the setup guide actively misleading.

]);

liveSecret = (await liveClient.webhooks.retrieveSecret(liveWebhook.id))
.secret;

const testWebhook = await testClient.webhooks.create({
url: `${appUrl}/webhooks/payment/createdCheckout?mode=test`,
description: "Scrawn test payment webhook",
filter_types: ["payment.succeeded", "payment.failed"],
});
testSecret = (await testClient.webhooks.retrieveSecret(testWebhook.id))
.secret;
liveProductId = liveProduct.product_id;
testProductId = testProduct.product_id;
Comment on lines 99 to +104

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Orphaned Dodo resources on retrieveSecret failure

If Promise.all completes successfully (creating 2 webhooks + 2 products) but either retrieveSecret call then throws, the error handler returns a 400 with no cleanup — leaving all four resources orphaned in Dodo. A subsequent retry will create a fresh set of four resources without removing the previous ones. Previously, only webhook objects were at risk; this PR doubles the surface area by adding product creation inside the same unguarded transaction boundary.

Consider using separate try/catch scopes for the retrieveSecret calls, or deleting already-created resources in the catch block, so at least partial-failure retries don't accumulate dangling objects in Dodo.

} catch (error) {
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",
},
});
builder.setError(400, {
type: "DodoApiError",
message: `Failed to register webhook with Dodo: ${errMsg}`,
message: `Failed to configure Dodo resources: ${errMsg}`,
});
reply.code(400);
return {};
Expand All @@ -87,8 +120,8 @@ export async function handleOnboarding(
await upsertMetadata({
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: liveProductId,
dodo_test_product_id: testProductId,
dodo_live_webhook_secret: encrypt(liveSecret),
dodo_test_webhook_secret: encrypt(testSecret),
currency: validated.currency,
Expand Down
14 changes: 11 additions & 3 deletions src/zod/internals.ts
Original file line number Diff line number Diff line change
@@ -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<C> {
logical: "AND" | "OR";
conditions: C[];
Expand Down Expand Up @@ -35,8 +43,8 @@ export function createFilterGroupSchema<C extends z.ZodTypeAny>(
export const onboardingSchema = z.object({
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"),
currency: z
.enum(["usd", "eur", "gbp", "inr", "jpy"])
.transform((c) => currencyMap[c]),
redirectUrl: z.url("Redirect URL must be a valid URL"),
});
Loading