Skip to content
Merged
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
2 changes: 2 additions & 0 deletions apps/dashboard/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,5 @@ ANALYTICS_SERVICE_URL=""

# required for billing parts of the dashboard (team -> settings -> billing / invoices)
STRIPE_SECRET_KEY=""
GROWTH_PLAN_SKU=""
PAYMENT_METHOD_CONFIGURATION=""
2 changes: 2 additions & 0 deletions apps/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
"@radix-ui/react-tooltip": "1.2.7",
"@sentry/nextjs": "9.34.0",
"@shazow/whatsabi": "0.22.2",
"@stripe/react-stripe-js": "4.0.2",
"@stripe/stripe-js": "7.9.0",
"@tanstack/react-query": "5.81.5",
"@tanstack/react-table": "^8.21.3",
"@thirdweb-dev/api": "workspace:*",
Expand Down
63 changes: 62 additions & 1 deletion apps/dashboard/src/@/actions/stripe-actions.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import "server-only";

import { headers } from "next/headers";
import Stripe from "stripe";
import type { Team } from "@/api/team/get-team";
import { STRIPE_SECRET_KEY } from "@/constants/server-envs";
import {
GROWTH_PLAN_SKU,
PAYMENT_METHOD_CONFIGURATION,
STRIPE_SECRET_KEY,
} from "@/constants/server-envs";

let existingStripe: Stripe | undefined;

Expand Down Expand Up @@ -72,3 +77,59 @@ export async function getStripeBalance(customerId: string) {
// Stripe returns a positive balance for credits, so we need to divide by -100 to get the actual balance (as long as the balance is not 0)
return customer.balance === 0 ? 0 : customer.balance / -100;
}

export async function fetchClientSecret(team: Team) {
"use server";
const origin = (await headers()).get("origin");
const stripe = getStripe();
const customerId = team.stripeCustomerId;

if (!customerId) {
throw new Error("No customer ID found");
}

// Create Checkout Sessions from body params.
const session = await stripe.checkout.sessions.create({
ui_mode: "embedded",
line_items: [
{
// Provide the exact Price ID (for example, price_1234) of
// the product you want to sell
price: GROWTH_PLAN_SKU,
quantity: 1,
},
],
mode: "subscription",

return_url: `${origin}/get-started/team/${team.slug}/select-plan?session_id={CHECKOUT_SESSION_ID}`,
automatic_tax: { enabled: true },
allow_promotion_codes: true,
customer: customerId,
customer_update: {
address: "auto",
},
payment_method_collection: "always",
payment_method_configuration: PAYMENT_METHOD_CONFIGURATION,
subscription_data: {
trial_period_days: 14,
trial_settings: {
end_behavior: {
missing_payment_method: "cancel",
},
},
},
});

if (!session.client_secret) {
throw new Error("No client secret found");
}

return session.client_secret;
}
Comment on lines +81 to +128
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Bug: headers() is synchronous and origin can be null; invalid return_url breaks Embedded Checkout. Add robust base URL derivation and env guards.

  • Remove unnecessary await headers().
  • Derive a trusted base URL from x-forwarded-proto + x-forwarded-host (fallback to host), and error if unavailable.
  • Guard GROWTH_PLAN_SKU; don’t send empty payment_method_configuration.
  • Consider attaching metadata for traceability.
 export async function fetchClientSecret(team: Team) {
   "use server";
-  const origin = (await headers()).get("origin");
   const stripe = getStripe();
   const customerId = team.stripeCustomerId;
 
   if (!customerId) {
     throw new Error("No customer ID found");
   }
+  if (!GROWTH_PLAN_SKU) {
+    throw new Error("GROWTH_PLAN_SKU is not configured");
+  }
+
+  const hdrs = headers();
+  const proto = hdrs.get("x-forwarded-proto") ?? "https";
+  const host = hdrs.get("x-forwarded-host") ?? hdrs.get("host");
+  const baseUrl = host ? `${proto}://${host}` : undefined;
+  if (!baseUrl) {
+    throw new Error("Unable to determine base URL from headers");
+  }
 
   // Create Checkout Sessions from body params.
   const session = await stripe.checkout.sessions.create({
     ui_mode: "embedded",
     line_items: [
       {
         // Provide the exact Price ID (for example, price_1234) of
         // the product you want to sell
         price: GROWTH_PLAN_SKU,
         quantity: 1,
       },
     ],
     mode: "subscription",
-
-    return_url: `${origin}/get-started/team/${team.slug}/select-plan?session_id={CHECKOUT_SESSION_ID}`,
+    return_url: `${baseUrl}/get-started/team/${team.slug}/select-plan?session_id={CHECKOUT_SESSION_ID}`,
     automatic_tax: { enabled: true },
     allow_promotion_codes: true,
     customer: customerId,
     customer_update: {
       address: "auto",
     },
     payment_method_collection: "always",
-    payment_method_configuration: PAYMENT_METHOD_CONFIGURATION,
+    payment_method_configuration: PAYMENT_METHOD_CONFIGURATION || undefined,
     subscription_data: {
       trial_period_days: 14,
       trial_settings: {
         end_behavior: {
           missing_payment_method: "cancel",
         },
       },
     },
+    metadata: {
+      teamId: team.id,
+      teamSlug: team.slug,
+      source: "onboarding",
+    },
   });
 
   if (!session.client_secret) {
     throw new Error("No client secret found");
   }
 
   return session.client_secret;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function fetchClientSecret(team: Team) {
"use server";
const origin = (await headers()).get("origin");
const stripe = getStripe();
const customerId = team.stripeCustomerId;
if (!customerId) {
throw new Error("No customer ID found");
}
// Create Checkout Sessions from body params.
const session = await stripe.checkout.sessions.create({
ui_mode: "embedded",
line_items: [
{
// Provide the exact Price ID (for example, price_1234) of
// the product you want to sell
price: GROWTH_PLAN_SKU,
quantity: 1,
},
],
mode: "subscription",
return_url: `${origin}/get-started/team/${team.slug}/select-plan?session_id={CHECKOUT_SESSION_ID}`,
automatic_tax: { enabled: true },
allow_promotion_codes: true,
customer: customerId,
customer_update: {
address: "auto",
},
payment_method_collection: "always",
payment_method_configuration: PAYMENT_METHOD_CONFIGURATION,
subscription_data: {
trial_period_days: 14,
trial_settings: {
end_behavior: {
missing_payment_method: "cancel",
},
},
},
});
if (!session.client_secret) {
throw new Error("No client secret found");
}
return session.client_secret;
}
export async function fetchClientSecret(team: Team) {
"use server";
const stripe = getStripe();
const customerId = team.stripeCustomerId;
if (!customerId) {
throw new Error("No customer ID found");
}
if (!GROWTH_PLAN_SKU) {
throw new Error("GROWTH_PLAN_SKU is not configured");
}
const hdrs = headers();
const proto = hdrs.get("x-forwarded-proto") ?? "https";
const host = hdrs.get("x-forwarded-host") ?? hdrs.get("host");
const baseUrl = host ? `${proto}://${host}` : undefined;
if (!baseUrl) {
throw new Error("Unable to determine base URL from headers");
}
// Create Checkout Sessions from body params.
const session = await stripe.checkout.sessions.create({
ui_mode: "embedded",
line_items: [
{
// Provide the exact Price ID (for example, price_1234) of
// the product you want to sell
price: GROWTH_PLAN_SKU,
quantity: 1,
},
],
mode: "subscription",
return_url: `${baseUrl}/get-started/team/${team.slug}/select-plan?session_id={CHECKOUT_SESSION_ID}`,
automatic_tax: { enabled: true },
allow_promotion_codes: true,
customer: customerId,
customer_update: {
address: "auto",
},
payment_method_collection: "always",
payment_method_configuration: PAYMENT_METHOD_CONFIGURATION || undefined,
subscription_data: {
trial_period_days: 14,
trial_settings: {
end_behavior: {
missing_payment_method: "cancel",
},
},
},
metadata: {
teamId: team.id,
teamSlug: team.slug,
source: "onboarding",
},
});
if (!session.client_secret) {
throw new Error("No client secret found");
}
return session.client_secret;
}


export async function getStripeSessionById(sessionId: string) {
const session = await getStripe().checkout.sessions.retrieve(sessionId, {
expand: ["line_items", "payment_intent"],
});
return session;
}
39 changes: 12 additions & 27 deletions apps/dashboard/src/@/analytics/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,33 +81,6 @@ export function reportOnboardingStarted() {
posthog.capture("onboarding started");
}

/**
* ### Why do we need to report this event?
* - To track the number of teams that select a paid plan during onboarding
* - To know **which** plan was selected
*
* ### Who is responsible for this event?
* @jnsdls
*
*/
export function reportOnboardingPlanSelected(properties: {
plan: Team["billingPlan"];
}) {
posthog.capture("onboarding plan selected", properties);
}

/**
* ### Why do we need to report this event?
* - To track the number of teams that skip the plan-selection step during onboarding
*
* ### Who is responsible for this event?
* @jnsdls
*
*/
export function reportOnboardingPlanSelectionSkipped() {
posthog.capture("onboarding plan selection skipped");
}

/**
* ### Why do we need to report this event?
* - To track the number of teams that invite members during onboarding
Expand Down Expand Up @@ -161,6 +134,18 @@ export function reportOnboardingMembersUpsellPlanSelected(properties: {
posthog.capture("onboarding members upsell plan selected", properties);
}

/**
* ### Why do we need to report this event?
* - To track the number of teams that completed the team member step during onboarding
*
* ### Who is responsible for this event?
* @jnsdls
*
*/
export function reportTeamMemberStepCompleted() {
posthog.capture("onboarding members completed");
}

/**
* ### Why do we need to report this event?
* - To track the number of teams that completed onboarding
Expand Down
21 changes: 21 additions & 0 deletions apps/dashboard/src/@/constants/server-envs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,24 @@ if (REDIS_URL) {
REDIS_URL,
);
}

export const GROWTH_PLAN_SKU = process.env.GROWTH_PLAN_SKU || "";

if (GROWTH_PLAN_SKU) {
experimental_taintUniqueValue(
"Do not pass GROWTH_PLAN_SKU to the client",
process,
GROWTH_PLAN_SKU,
);
}

export const PAYMENT_METHOD_CONFIGURATION =
process.env.PAYMENT_METHOD_CONFIGURATION || "";

if (PAYMENT_METHOD_CONFIGURATION) {
experimental_taintUniqueValue(
"Do not pass PAYMENT_METHOD_CONFIGURATION to the client",
process,
PAYMENT_METHOD_CONFIGURATION,
);
}
Comment on lines +95 to +114
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Fail fast in prod when required billing envs are missing.

GROWTH_PLAN_SKU is required to create the Checkout Session; missing it will fail at runtime. Add a prod guard; treat PAYMENT_METHOD_CONFIGURATION as optional and avoid sending an empty string to Stripe.

 export const GROWTH_PLAN_SKU = process.env.GROWTH_PLAN_SKU || "";
 
 if (GROWTH_PLAN_SKU) {
   experimental_taintUniqueValue(
     "Do not pass GROWTH_PLAN_SKU to the client",
     process,
     GROWTH_PLAN_SKU,
   );
 }
 
 export const PAYMENT_METHOD_CONFIGURATION =
   process.env.PAYMENT_METHOD_CONFIGURATION || "";
 
 if (PAYMENT_METHOD_CONFIGURATION) {
   experimental_taintUniqueValue(
     "Do not pass PAYMENT_METHOD_CONFIGURATION to the client",
     process,
     PAYMENT_METHOD_CONFIGURATION,
   );
 }
+
+// Guard required billing envs in production to avoid runtime failures
+if (isProd && !GROWTH_PLAN_SKU) {
+  throw new Error("GROWTH_PLAN_SKU must be set in production");
+}

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In apps/dashboard/src/@/constants/server-envs.ts around lines 95 to 114, enforce
a production fail-fast for the required GROWTH_PLAN_SKU and make
PAYMENT_METHOD_CONFIGURATION optional: check NODE_ENV or a similar prod flag and
throw an error (or process.exit) if GROWTH_PLAN_SKU is empty in production, keep
the experimental_taintUniqueValue call when present; for
PAYMENT_METHOD_CONFIGURATION, do not default to an empty string—export it as
undefined when missing (so callers send undefined to Stripe instead of "") and
only call experimental_taintUniqueValue when a non-empty value exists.

Loading
Loading