- {formatNumber(current)} / {formatNumber(max)} ({Math.round((current / max) * 100)}%)
+ {formatNumber(current)} / {formatNumber(max)} ({percent}%)
@@ -34,7 +118,7 @@ export const ProgressCircle: React.FC<{
const strokeWidth = 3;
const normalizedRadius = radius - strokeWidth / 2;
const circumference = normalizedRadius * 2 * Math.PI;
- const offset = circumference - (safeValue / max) * circumference;
+ const offset = max > 0 ? circumference - (safeValue / max) * circumference : circumference;
return (
<>
diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/page.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/page.tsx
index ad1f83bcb1..8f98c000b2 100644
--- a/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/page.tsx
+++ b/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/page.tsx
@@ -1,76 +1,54 @@
-import { getAuth } from "@/lib/auth";
-import { clickhouse } from "@/lib/clickhouse";
-import { db } from "@/lib/db";
-import { stripeEnv } from "@/lib/env";
+"use client";
+import { PageLoading } from "@/components/dashboard/page-loading";
import { formatNumber } from "@/lib/fmt";
+import { trpc } from "@/lib/trpc/client";
+import { useWorkspace } from "@/providers/workspace-provider";
import { Button, Empty, Input, SettingCard } from "@unkey/ui";
import Link from "next/link";
-import { redirect } from "next/navigation";
-import { Suspense } from "react";
-import Stripe from "stripe";
import { WorkspaceNavbar } from "../workspace-navbar";
import { Client } from "./client";
import { Shell } from "./components/shell";
-export const dynamic = "force-dynamic";
+export default function BillingPage() {
+ const { workspace, isLoading: isWorkspaceLoading } = useWorkspace();
-export default async function BillingPage() {
- const { orgId } = await getAuth();
+ // Derive isLegacy from workspace data
+ const isLegacy = workspace?.subscriptions && Object.keys(workspace.subscriptions).length > 0;
- const workspace = await db.query.workspaces.findFirst({
- where: (table, { and, eq, isNull }) => and(eq(table.orgId, orgId), isNull(table.deletedAtM)),
- with: {
- quotas: true,
- },
+ const {
+ data: usage,
+ isLoading: usageLoading,
+ isError,
+ error,
+ } = trpc.billing.queryUsage.useQuery(undefined, {
+ // Only enable query when workspace is loaded AND it's a legacy subscription
+ enabled: Boolean(workspace && isLegacy),
});
- if (!workspace) {
- return redirect("/new");
+ // Derive loading state: loading if workspace is loading OR (if legacy, usage is loading)
+ const isLoading = isWorkspaceLoading || !workspace || (isLegacy && usageLoading);
+
+ // Wait for workspace to load before proceeding
+ if (isLoading) {
+ return
;
}
- const e = stripeEnv();
- if (!e) {
+
+ if (isError) {
return (
-
-
-
- Stripe is not configured
-
- If you are selfhosting Unkey, you need to configure Stripe in your environment
- variables.
-
-
-
+
+ Failed to load usage data
+
+ {error?.message ||
+ "There was an error loading your usage information. Please try again later."}
+
+
);
}
-
- const stripe = new Stripe(e.STRIPE_SECRET_KEY, {
- apiVersion: "2023-10-16",
- typescript: true,
- });
-
- const startOfMonth = new Date();
- startOfMonth.setUTCDate(1);
- startOfMonth.setUTCHours(0, 0, 0, 0);
-
- const year = startOfMonth.getUTCFullYear();
- const month = startOfMonth.getUTCMonth() + 1;
-
- const [usedVerifications, usedRatelimits] = await Promise.all([
- clickhouse.billing.billableVerifications({
- workspaceId: workspace.id,
- year,
- month,
- }),
- clickhouse.billing.billableRatelimits({
- workspaceId: workspace.id,
- year,
- month,
- }),
- ]);
-
- const isLegacy = workspace.subscriptions && Object.keys(workspace.subscriptions).length > 0;
-
if (isLegacy) {
+ // Fetch usage data for legacy display
+ const verifications = usage?.billableVerifications || 0;
+ const ratelimits = usage?.billableRatelimits || 0;
+
return (
@@ -81,7 +59,7 @@ export default async function BillingPage() {
border="top"
>
-
+
-
+
@@ -124,87 +102,6 @@ export default async function BillingPage() {
);
}
- const [products, subscription, hasPreviousSubscriptions] = await Promise.all([
- stripe.products
- .list({
- active: true,
- ids: e.STRIPE_PRODUCT_IDS_PRO,
- limit: 100,
- expand: ["data.default_price"],
- })
- .then((res) => res.data.map(mapProduct).sort((a, b) => a.dollar - b.dollar)),
- workspace.stripeSubscriptionId
- ? await stripe.subscriptions.retrieve(workspace.stripeSubscriptionId)
- : undefined,
-
- workspace.stripeCustomerId
- ? await stripe.subscriptions
- .list({
- customer: workspace.stripeCustomerId,
- status: "canceled",
- })
- .then((res) => res.data.length > 0)
- : false,
- ]);
-
- return (
-
-
-
-
-
-
- }
- >
-
-
- );
+ // For non-legacy workspaces, use the Client component with live data
+ return
;
}
-
-const mapProduct = (p: Stripe.Product) => {
- if (!p.default_price) {
- throw new Error(`Product ${p.id} is missing default_price`);
- }
-
- const price = typeof p.default_price === "string" ? null : (p.default_price as Stripe.Price);
-
- if (!price) {
- throw new Error(`Product ${p.id} default_price must be expanded`);
- }
-
- if (price.unit_amount === null || price.unit_amount === undefined) {
- throw new Error(`Product ${p.id} price is missing unit_amount`);
- }
-
- const quotaValue = Number.parseInt(p.metadata.quota_requests_per_month, 10);
-
- return {
- id: p.id,
- name: p.name,
- priceId: price.id,
- dollar: price.unit_amount / 100,
- quotas: {
- requestsPerMonth: quotaValue,
- },
- };
-};
diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/stripe/checkout/page.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/stripe/checkout/page.tsx
index 7986bd4a69..a797f4a3ed 100644
--- a/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/stripe/checkout/page.tsx
+++ b/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/stripe/checkout/page.tsx
@@ -1,9 +1,9 @@
import { getAuth } from "@/lib/auth";
import { db } from "@/lib/db";
-import { stripeEnv } from "@/lib/env";
+import { getStripeClient } from "@/lib/stripe";
import { Code, Empty } from "@unkey/ui";
import { redirect } from "next/navigation";
-import Stripe from "stripe";
+import type Stripe from "stripe";
export const dynamic = "force-dynamic";
@@ -16,8 +16,11 @@ export default async function StripeRedirect() {
if (!ws) {
return redirect("/new");
}
- const e = stripeEnv();
- if (!e) {
+
+ let stripe: Stripe;
+ try {
+ stripe = getStripeClient();
+ } catch (_error) {
return (
Stripe is not configured
@@ -28,11 +31,6 @@ export default async function StripeRedirect() {
);
}
- const stripe = new Stripe(e.STRIPE_SECRET_KEY, {
- apiVersion: "2023-10-16",
- typescript: true,
- });
-
const baseUrl = process.env.VERCEL
? process.env.VERCEL_TARGET_ENV === "production"
? "https://app.unkey.com"
diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/stripe/portal/page.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/stripe/portal/page.tsx
index e0b76ce1a8..79c65b49b8 100644
--- a/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/stripe/portal/page.tsx
+++ b/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/stripe/portal/page.tsx
@@ -1,9 +1,9 @@
import { getAuth } from "@/lib/auth";
import { db } from "@/lib/db";
-import { stripeEnv } from "@/lib/env";
+import { getStripeClient } from "@/lib/stripe";
import { Code, Empty } from "@unkey/ui";
import { redirect } from "next/navigation";
-import Stripe from "stripe";
+import type Stripe from "stripe";
export const dynamic = "force-dynamic";
@@ -22,9 +22,10 @@ export default async function StripeRedirect() {
return redirect("/new");
}
- const e = stripeEnv();
-
- if (!e) {
+ let stripe: Stripe;
+ try {
+ stripe = getStripeClient();
+ } catch (_error) {
return (
Stripe is not configured
@@ -35,11 +36,6 @@ export default async function StripeRedirect() {
);
}
- const stripe = new Stripe(e.STRIPE_SECRET_KEY, {
- apiVersion: "2023-10-16",
- typescript: true,
- });
-
const baseUrl = process.env.VERCEL
? process.env.VERCEL_TARGET_ENV === "production"
? "https://app.unkey.com"
diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/settings/general/page.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/settings/general/page.tsx
index d7b6fa82ce..11cf15f778 100644
--- a/apps/dashboard/app/(app)/[workspaceSlug]/settings/general/page.tsx
+++ b/apps/dashboard/app/(app)/[workspaceSlug]/settings/general/page.tsx
@@ -16,10 +16,7 @@ export default function SettingsPage() {
-
-
- Workspace Settings
-
+
{/*
*/}
diff --git a/apps/dashboard/app/api/webhooks/stripe/route.ts b/apps/dashboard/app/api/webhooks/stripe/route.ts
index b4a77cc088..ed329fef1c 100644
--- a/apps/dashboard/app/api/webhooks/stripe/route.ts
+++ b/apps/dashboard/app/api/webhooks/stripe/route.ts
@@ -2,7 +2,8 @@ import { insertAuditLogs } from "@/lib/audit";
import { db, eq, schema } from "@/lib/db";
import { stripeEnv } from "@/lib/env";
import { freeTierQuotas } from "@/lib/quotas";
-import Stripe from "stripe";
+import { getStripeClient } from "@/lib/stripe";
+import type Stripe from "stripe";
export const runtime = "nodejs";
@@ -20,17 +21,7 @@ export const POST = async (req: Request): Promise
=> {
);
}
- const stripeSecretKey = stripeEnv()?.STRIPE_SECRET_KEY;
- if (!stripeSecretKey) {
- throw new Error(
- "STRIPE_SECRET_KEY environment variable is not set. This is required for Stripe API operations.",
- );
- }
-
- const stripe = new Stripe(stripeSecretKey, {
- apiVersion: "2023-10-16",
- typescript: true,
- });
+ const stripe: Stripe = getStripeClient();
const event = stripe.webhooks.constructEvent(
await req.text(),
diff --git a/apps/dashboard/app/new/hooks/use-workspace-step.tsx b/apps/dashboard/app/new/hooks/use-workspace-step.tsx
index 3aa41e51ea..455a27bf51 100644
--- a/apps/dashboard/app/new/hooks/use-workspace-step.tsx
+++ b/apps/dashboard/app/new/hooks/use-workspace-step.tsx
@@ -71,6 +71,7 @@ export const useWorkspaceStep = (props: Props): OnboardingStep => {
await utils.workspace.getCurrent.invalidate();
await utils.api.invalidate();
await utils.ratelimit.invalidate();
+ await utils.stripe.invalidate();
// Force a router refresh to ensure the server-side layout
// re-renders with the new session context and fresh workspace data
router.refresh();
diff --git a/apps/dashboard/app/success/client.tsx b/apps/dashboard/app/success/client.tsx
index ab25dcfd00..1575d75485 100644
--- a/apps/dashboard/app/success/client.tsx
+++ b/apps/dashboard/app/success/client.tsx
@@ -1,19 +1,63 @@
"use client";
+import dynamic from "next/dynamic";
import { useRouter } from "next/navigation";
-import { useEffect } from "react";
+import { useEffect, useState } from "react";
-export function SuccessClient({ workSpaceSlug }: { workSpaceSlug?: string }) {
+const PlanSelectionModal = dynamic(
+ () =>
+ import("../(app)/[workspaceSlug]/settings/billing/components/plan-selection-modal").then(
+ (mod) => ({
+ default: mod.PlanSelectionModal,
+ }),
+ ),
+ {
+ ssr: false,
+ loading: () => null,
+ },
+);
+
+type Props = {
+ workSpaceSlug?: string;
+ showPlanSelection?: boolean;
+ products?: Array<{
+ id: string;
+ name: string;
+ priceId: string;
+ dollar: number;
+ quotas: {
+ requestsPerMonth: number;
+ };
+ }>;
+};
+
+export function SuccessClient({ workSpaceSlug, showPlanSelection, products }: Props) {
const router = useRouter();
+ const [showModal, setShowModal] = useState(!!(showPlanSelection && products && workSpaceSlug));
useEffect(() => {
+ // If showing modal, don't redirect
+ if (showPlanSelection && products && workSpaceSlug) {
+ return;
+ }
+
+ // Redirect based on workspace availability
if (workSpaceSlug) {
router.push(`/${workSpaceSlug}/settings/billing`);
} else {
- // Redirect to root when no workspace slug is available
- // This will typically redirect to workspace selection or onboarding
router.push("/");
}
- }, [router, workSpaceSlug]);
+ }, [router, workSpaceSlug, showPlanSelection, products]);
+
+ if (showPlanSelection && products && workSpaceSlug) {
+ return (
+
+ );
+ }
return <>>;
}
diff --git a/apps/dashboard/app/success/page.tsx b/apps/dashboard/app/success/page.tsx
index c0e6fd23a7..fd4022417e 100644
--- a/apps/dashboard/app/success/page.tsx
+++ b/apps/dashboard/app/success/page.tsx
@@ -1,119 +1,275 @@
-import { db, eq, schema } from "@/lib/db";
-import { stripeEnv } from "@/lib/env";
+"use client";
+
+import { PageLoading } from "@/components/dashboard/page-loading";
+import { trpc } from "@/lib/trpc/client";
import { Empty } from "@unkey/ui";
-import { redirect } from "next/navigation";
-import Stripe from "stripe";
+import { useSearchParams } from "next/navigation";
+import { Suspense, useEffect, useState } from "react";
import { SuccessClient } from "./client";
-type Props = {
- searchParams: {
- session_id?: string;
- };
+type ProcessedData = {
+ workspaceSlug?: string;
+ showPlanSelection?: boolean;
+ products?: Array<{
+ id: string;
+ name: string;
+ priceId: string;
+ dollar: number;
+ quotas: {
+ requestsPerMonth: number;
+ };
+ }>;
};
-export default async function SuccessPage(props: Props) {
- // If no session_id, just redirect back to billing
- // This will make a user login if they are not logged in
- // This will also redirect to the billing page if the user is logged in
- if (!props.searchParams.session_id) {
- return ;
- }
+function SuccessContent() {
+ const searchParams = useSearchParams();
+ const sessionId = searchParams?.get("session_id") ?? null;
- // Process the Stripe session and update workspace
- const e = stripeEnv();
- if (!e) {
- return (
-
- Stripe is not configured
-
- If you are selfhosting Unkey, you need to configure Stripe in your environment variables.
-
-
- );
- }
+ const [processedData, setProcessedData] = useState({});
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
- const stripe = new Stripe(e.STRIPE_SECRET_KEY, {
- apiVersion: "2023-10-16",
- typescript: true,
- });
+ const updateCustomerMutation = trpc.stripe.updateCustomer.useMutation();
+ const updateWorkspaceStripeCustomerMutation =
+ trpc.stripe.updateWorkspaceStripeCustomer.useMutation();
- try {
- const session = await stripe.checkout.sessions.retrieve(props.searchParams.session_id);
+ const trpcUtils = trpc.useUtils();
- if (!session) {
- console.warn("Stripe session not found");
- return redirect("/auth/sign-in");
- }
+ useEffect(() => {
+ // Track if component is still mounted to prevent state updates after unmount
+ let isMounted = true;
- const workspaceReference = session.client_reference_id;
- if (!workspaceReference) {
- console.warn("Stripe session client_reference_id not found");
- return ;
+ if (!sessionId) {
+ setProcessedData({});
+ setLoading(false);
+ return;
}
- const ws = await db.query.workspaces.findFirst({
- where: (table, { and, eq, isNull }) =>
- and(eq(table.id, workspaceReference), isNull(table.deletedAtM)),
- });
+ const processStripeSession = async (
+ updateCustomerFn: typeof updateCustomerMutation.mutateAsync,
+ updateWorkspaceFn: typeof updateWorkspaceStripeCustomerMutation.mutateAsync,
+ ) => {
+ try {
+ if (!isMounted) {
+ return;
+ }
+ setLoading(true);
- if (!ws) {
- console.warn("Workspace not found");
- return ;
- }
+ // Get checkout session
+ const sessionResponse = await trpcUtils.stripe.getCheckoutSession.fetch({
+ sessionId: sessionId,
+ });
- const customer = await stripe.customers.retrieve(session.customer as string);
- if (!customer || !session.setup_intent) {
- console.warn("Stripe customer not found");
- return ;
- }
+ if (!sessionResponse) {
+ console.warn("Stripe session not found");
+ if (!isMounted) {
+ return;
+ }
+ setProcessedData({});
+ setLoading(false);
+ return;
+ }
- const setupIntent = await stripe.setupIntents.retrieve(session.setup_intent.toString());
+ const workspaceId = sessionResponse.client_reference_id;
+ if (!workspaceId) {
+ console.warn("Stripe session client_reference_id not found");
+ if (!isMounted) {
+ return;
+ }
+ setProcessedData({});
+ setLoading(false);
+ return;
+ }
- if (!setupIntent.payment_method) {
- console.warn("Stripe payment method not found");
- return ;
- }
+ // Get workspace details to get the slug
+ const workspace = await trpcUtils.workspace.getById.fetch({
+ workspaceId: workspaceId,
+ });
- // Update customer with default payment method
- await stripe.customers.update(customer.id, {
- invoice_settings: {
- default_payment_method: setupIntent.payment_method.toString(),
- },
- });
-
- // Update workspace with stripe customer ID
- try {
- await db
- .update(schema.workspaces)
- .set({
- stripeCustomerId: customer.id,
- })
- .where(eq(schema.workspaces.id, ws.id));
- } catch (error) {
- console.error("Failed to update workspace:", error);
- return (
-
- Failed to update workspace
-
- There was an error updating your workspace with payment information. Please contact
- support@unkey.dev.
-
-
- );
- }
+ if (!isMounted) {
+ return;
+ }
+
+ // Check if we have customer and setup intent
+ if (!sessionResponse.customer || !sessionResponse.setup_intent) {
+ console.warn("Stripe customer or setup intent not found");
+ if (!isMounted) {
+ return;
+ }
+ setProcessedData({ workspaceSlug: workspace.slug });
+ setLoading(false);
+ return;
+ }
+
+ // Get customer details
+ const customer = await trpcUtils.stripe.getCustomer.fetch({
+ customerId: sessionResponse.customer,
+ });
+
+ if (!isMounted) {
+ return;
+ }
+
+ // Get setup intent details
+ const setupIntent = await trpcUtils.stripe.getSetupIntent.fetch({
+ setupIntentId: sessionResponse.setup_intent,
+ });
+
+ if (!isMounted) {
+ return;
+ }
- // Success - redirect to billing page with workspace slug
- return ;
- } catch (error) {
- console.error("Error processing Stripe session:", error);
+ if (!customer || !setupIntent?.payment_method) {
+ console.warn("Customer or payment method not found");
+ if (!isMounted) {
+ return;
+ }
+ setProcessedData({ workspaceSlug: workspace.slug });
+ setLoading(false);
+ return;
+ }
+
+ // Update customer with default payment method
+ try {
+ await updateCustomerFn({
+ customerId: customer.id,
+ paymentMethod: setupIntent.payment_method,
+ });
+
+ if (!isMounted) {
+ return;
+ }
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : "Unknown error";
+ console.error("Failed to update customer with payment method:", {
+ error: errorMessage,
+ customerId: "redacted", // Don't log PII
+ hasPaymentMethod: !!setupIntent.payment_method,
+ });
+ if (!isMounted) {
+ return;
+ }
+ setError(`Failed to set up payment method: ${errorMessage}`);
+ setLoading(false);
+ return;
+ }
+
+ // Update workspace with stripe customer ID
+ try {
+ await updateWorkspaceFn({
+ stripeCustomerId: customer.id,
+ });
+
+ if (!isMounted) {
+ return;
+ }
+
+ await trpcUtils.workspace.invalidate();
+ await trpcUtils.stripe.invalidate();
+ await trpcUtils.billing.invalidate();
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : "Unknown error";
+ console.error("Failed to update workspace with payment method:", { error: errorMessage });
+ if (!isMounted) {
+ return;
+ }
+ setError("Failed to update workspace with payment information");
+ setLoading(false);
+ return;
+ }
+
+ // Check if this is a first-time user by getting billing info
+ try {
+ const billingInfo = await trpcUtils.stripe.getBillingInfo.fetch();
+
+ if (!isMounted) {
+ return;
+ }
+
+ const isFirstTimeUser = !billingInfo.hasPreviousSubscriptions;
+
+ if (isFirstTimeUser) {
+ // Use products from billingInfo instead of making a redundant fetch
+ if (billingInfo.products && billingInfo.products.length > 0) {
+ setProcessedData({
+ workspaceSlug: workspace.slug,
+ showPlanSelection: true,
+ products: billingInfo.products,
+ });
+ } else {
+ // Fall back to regular billing page if products are empty or undefined
+ setProcessedData({ workspaceSlug: workspace.slug });
+ }
+ } else {
+ setProcessedData({ workspaceSlug: workspace.slug });
+ }
+ } catch (error) {
+ console.error("Failed to get billing info:", error);
+ // Fall back to regular billing page
+ if (!isMounted) {
+ return;
+ }
+ setProcessedData({ workspaceSlug: workspace.slug });
+ }
+
+ if (!isMounted) {
+ return;
+ }
+ setLoading(false);
+ } catch (error) {
+ console.error("Error processing Stripe session:", error);
+ if (!isMounted) {
+ return;
+ }
+ setError("Failed to process payment session");
+ setLoading(false);
+ }
+ };
+
+ processStripeSession(
+ updateCustomerMutation.mutateAsync,
+ updateWorkspaceStripeCustomerMutation.mutateAsync,
+ );
+
+ // Cleanup function to prevent state updates after unmount
+ return () => {
+ isMounted = false;
+ };
+ }, [
+ sessionId,
+ trpcUtils,
+ updateCustomerMutation.mutateAsync,
+ updateWorkspaceStripeCustomerMutation.mutateAsync,
+ ]);
+
+ if (loading) {
+ return ;
+ }
+
+ if (error) {
return (
- Failed to update workspace
+ Payment Processing Error
- There was an error updating your workspace with payment information. Please contact
- support@unkey.dev.
+ {error}. Please contact support@unkey.dev if this issue persists.
);
}
+
+ return (
+
+ );
+}
+
+export default function SuccessPage() {
+ return (
+ }>
+
+
+ );
}
diff --git a/apps/dashboard/components/dashboard/page-loading.tsx b/apps/dashboard/components/dashboard/page-loading.tsx
new file mode 100644
index 0000000000..551a6064f2
--- /dev/null
+++ b/apps/dashboard/components/dashboard/page-loading.tsx
@@ -0,0 +1,26 @@
+import { Loading } from "@unkey/ui";
+
+type Props = {
+ message?: string;
+ spinnerType?: "dots" | "spinner";
+ size?: number;
+ className?: string;
+};
+
+const PageLoading = ({
+ message = "Loading...",
+ spinnerType = "spinner",
+ size = 24,
+ className,
+}: Props) => {
+ return (
+
+ );
+};
+
+export { PageLoading };
diff --git a/apps/dashboard/lib/stripe.ts b/apps/dashboard/lib/stripe.ts
new file mode 100644
index 0000000000..106d06cbd2
--- /dev/null
+++ b/apps/dashboard/lib/stripe.ts
@@ -0,0 +1,31 @@
+import { TRPCError } from "@trpc/server";
+import Stripe from "stripe";
+import { stripeEnv } from "./env";
+
+let stripeClient: Stripe | null = null;
+
+/**
+ * Get a singleton Stripe client instance.
+ * Throws an error if Stripe is not configured.
+ * The client is cached and reused across requests.
+ */
+export function getStripeClient(): Stripe {
+ if (stripeClient) {
+ return stripeClient;
+ }
+
+ const e = stripeEnv();
+ if (!e) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Stripe is not configured",
+ });
+ }
+
+ stripeClient = new Stripe(e.STRIPE_SECRET_KEY, {
+ apiVersion: "2023-10-16",
+ typescript: true,
+ });
+
+ return stripeClient;
+}
diff --git a/apps/dashboard/lib/trpc/routers/billing/query-usage/index.ts b/apps/dashboard/lib/trpc/routers/billing/query-usage/index.ts
index e0cd9711e2..f9b5387b16 100644
--- a/apps/dashboard/lib/trpc/routers/billing/query-usage/index.ts
+++ b/apps/dashboard/lib/trpc/routers/billing/query-usage/index.ts
@@ -33,8 +33,8 @@ export const queryUsage = t.procedure
}
return {
- billableRatelimits,
- billableVerifications,
+ billableRatelimits: billableRatelimits,
+ billableVerifications: billableVerifications,
billableTotal: billableRatelimits + billableVerifications,
};
});
diff --git a/apps/dashboard/lib/trpc/routers/index.ts b/apps/dashboard/lib/trpc/routers/index.ts
index 5427d128d4..65edc1eb38 100644
--- a/apps/dashboard/lib/trpc/routers/index.ts
+++ b/apps/dashboard/lib/trpc/routers/index.ts
@@ -116,12 +116,20 @@ import { rootKeysLlmSearch } from "./settings/root-keys/llm-search";
import { queryRootKeys } from "./settings/root-keys/query";
import { cancelSubscription } from "./stripe/cancelSubscription";
import { createSubscription } from "./stripe/createSubscription";
+import { getBillingInfo } from "./stripe/getBillingInfo";
+import { getCheckoutSession } from "./stripe/getCheckoutSession";
+import { getCustomer } from "./stripe/getCustomer";
+import { getProducts } from "./stripe/getProducts";
+import { getSetupIntent } from "./stripe/getSetupIntent";
import { uncancelSubscription } from "./stripe/uncancelSubscription";
+import { updateCustomer } from "./stripe/updateCustomer";
import { updateSubscription } from "./stripe/updateSubscription";
+import { updateWorkspaceStripeCustomer } from "./stripe/updateWorkspace";
import { getCurrentUser, listMemberships, switchOrg } from "./user";
import { vercelRouter } from "./vercel";
import { changeWorkspaceName } from "./workspace/changeName";
import { createWorkspace } from "./workspace/create";
+import { getWorkspaceById } from "./workspace/getById";
import { getCurrentWorkspace } from "./workspace/getCurrent";
import { onboardingKeyCreation } from "./workspace/onboarding";
import { optWorkspaceIntoBeta } from "./workspace/optIntoBeta";
@@ -203,6 +211,7 @@ export const router = t.router({
workspace: t.router({
create: createWorkspace,
getCurrent: getCurrentWorkspace,
+ getById: getWorkspaceById,
updateName: changeWorkspaceName,
optIntoBeta: optWorkspaceIntoBeta,
onboarding: onboardingKeyCreation,
@@ -212,6 +221,13 @@ export const router = t.router({
updateSubscription,
cancelSubscription,
uncancelSubscription,
+ getBillingInfo,
+ updateCustomer,
+ getCheckoutSession,
+ getCustomer,
+ getProducts,
+ getSetupIntent,
+ updateWorkspaceStripeCustomer,
}),
vercel: vercelRouter,
plain: t.router({
diff --git a/apps/dashboard/lib/trpc/routers/stripe/cancelSubscription.ts b/apps/dashboard/lib/trpc/routers/stripe/cancelSubscription.ts
index 7504d87396..c7de050020 100644
--- a/apps/dashboard/lib/trpc/routers/stripe/cancelSubscription.ts
+++ b/apps/dashboard/lib/trpc/routers/stripe/cancelSubscription.ts
@@ -1,17 +1,11 @@
import { auth } from "@/lib/auth/server";
-import { stripeEnv } from "@/lib/env";
+import { getStripeClient } from "@/lib/stripe";
import { TRPCError } from "@trpc/server";
-import Stripe from "stripe";
import { requireUser, requireWorkspace, t } from "../../trpc";
export const cancelSubscription = t.procedure
.use(requireUser)
.use(requireWorkspace)
.mutation(async ({ ctx }) => {
- const e = stripeEnv();
- if (!e) {
- throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Stripe is not set up" });
- }
-
const memberships = await auth.getOrganizationMemberList(ctx.workspace.orgId).catch((err) => {
console.error(err);
throw new TRPCError({
@@ -27,10 +21,7 @@ export const cancelSubscription = t.procedure
});
}
- const stripe = new Stripe(e.STRIPE_SECRET_KEY, {
- apiVersion: "2023-10-16",
- typescript: true,
- });
+ const stripe = getStripeClient();
if (!ctx.workspace.stripeCustomerId) {
throw new TRPCError({
diff --git a/apps/dashboard/lib/trpc/routers/stripe/createSubscription.ts b/apps/dashboard/lib/trpc/routers/stripe/createSubscription.ts
index a31967229e..2b8766d72d 100644
--- a/apps/dashboard/lib/trpc/routers/stripe/createSubscription.ts
+++ b/apps/dashboard/lib/trpc/routers/stripe/createSubscription.ts
@@ -1,11 +1,12 @@
import { insertAuditLogs } from "@/lib/audit";
import { db, eq, schema } from "@/lib/db";
import { stripeEnv } from "@/lib/env";
+import { getStripeClient } from "@/lib/stripe";
import { invalidateWorkspaceCache } from "@/lib/workspace-cache";
import { TRPCError } from "@trpc/server";
-import Stripe from "stripe";
import { z } from "zod";
import { requireUser, requireWorkspace, t } from "../../trpc";
+import { clearWorkspaceCache } from "../workspace/getCurrent";
export const createSubscription = t.procedure
.use(requireUser)
.use(requireWorkspace)
@@ -15,6 +16,7 @@ export const createSubscription = t.procedure
}),
)
.mutation(async ({ ctx, input }) => {
+ const stripe = getStripeClient();
const e = stripeEnv();
if (!e) {
throw new TRPCError({
@@ -23,11 +25,6 @@ export const createSubscription = t.procedure
});
}
- const stripe = new Stripe(e.STRIPE_SECRET_KEY, {
- apiVersion: "2023-10-16",
- typescript: true,
- });
-
const product = await stripe.products.retrieve(input.productId);
if (!product) {
@@ -119,4 +116,7 @@ export const createSubscription = t.procedure
// Invalidate workspace cache after subscription creation
await invalidateWorkspaceCache(ctx.tenant.id);
+
+ // Also clear the tRPC workspace cache to ensure fresh data on next request
+ clearWorkspaceCache(ctx.tenant.id);
});
diff --git a/apps/dashboard/lib/trpc/routers/stripe/getBillingInfo.ts b/apps/dashboard/lib/trpc/routers/stripe/getBillingInfo.ts
new file mode 100644
index 0000000000..1c19c9aa57
--- /dev/null
+++ b/apps/dashboard/lib/trpc/routers/stripe/getBillingInfo.ts
@@ -0,0 +1,82 @@
+import { stripeEnv } from "@/lib/env";
+import { getStripeClient } from "@/lib/stripe";
+import { ratelimit, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc";
+import { TRPCError } from "@trpc/server";
+import { z } from "zod";
+import { mapProduct } from "../utils/stripe";
+
+const productSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ priceId: z.string(),
+ dollar: z.number(),
+ quotas: z.object({
+ requestsPerMonth: z.number(),
+ }),
+});
+
+const subscriptionSchema = z
+ .object({
+ id: z.string(),
+ status: z.string(),
+ cancelAt: z.number().optional(),
+ })
+ .optional();
+
+const billingInfoSchema = z.object({
+ products: z.array(productSchema),
+ subscription: subscriptionSchema,
+ hasPreviousSubscriptions: z.boolean(),
+ currentProductId: z.string().optional(),
+});
+
+export const getBillingInfo = t.procedure
+ .use(requireWorkspace)
+ .use(withRatelimit(ratelimit.read))
+ .output(billingInfoSchema)
+ .query(async ({ ctx }) => {
+ const stripe = getStripeClient();
+ const e = stripeEnv();
+ if (!e) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Stripe is not configured",
+ });
+ }
+
+ const [products, subscription, hasPreviousSubscriptions] = await Promise.all([
+ stripe.products
+ .list({
+ active: true,
+ ids: e.STRIPE_PRODUCT_IDS_PRO,
+ limit: 100,
+ expand: ["data.default_price"],
+ })
+ .then((res) => res.data.map(mapProduct).sort((a, b) => a.dollar - b.dollar)),
+ ctx.workspace.stripeSubscriptionId
+ ? await stripe.subscriptions.retrieve(ctx.workspace.stripeSubscriptionId)
+ : undefined,
+
+ ctx.workspace.stripeCustomerId
+ ? await stripe.subscriptions
+ .list({
+ customer: ctx.workspace.stripeCustomerId,
+ status: "canceled",
+ })
+ .then((res) => res.data.length > 0)
+ : false,
+ ]);
+
+ return {
+ products,
+ subscription: subscription
+ ? {
+ id: subscription.id,
+ status: subscription.status,
+ cancelAt: subscription.cancel_at ? subscription.cancel_at * 1000 : undefined,
+ }
+ : undefined,
+ hasPreviousSubscriptions,
+ currentProductId: subscription?.items.data.at(0)?.plan.product?.toString() ?? undefined,
+ };
+ });
diff --git a/apps/dashboard/lib/trpc/routers/stripe/getCheckoutSession.ts b/apps/dashboard/lib/trpc/routers/stripe/getCheckoutSession.ts
new file mode 100644
index 0000000000..e5101b2288
--- /dev/null
+++ b/apps/dashboard/lib/trpc/routers/stripe/getCheckoutSession.ts
@@ -0,0 +1,58 @@
+import { getStripeClient } from "@/lib/stripe";
+import { ratelimit, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc";
+import { TRPCError } from "@trpc/server";
+import Stripe from "stripe";
+import { z } from "zod";
+
+const checkoutSessionSchema = z.object({
+ id: z.string(),
+ customer: z.string().nullable(),
+ client_reference_id: z.string().nullable(),
+ setup_intent: z.string().nullable(),
+ payment_status: z.string(),
+ status: z.string(),
+});
+
+export const getCheckoutSession = t.procedure
+ .use(requireWorkspace)
+ .use(withRatelimit(ratelimit.read))
+ .input(
+ z.object({
+ sessionId: z.string(),
+ }),
+ )
+ .output(checkoutSessionSchema)
+ .query(async ({ input }) => {
+ const stripe = getStripeClient();
+
+ try {
+ const session = await stripe.checkout.sessions.retrieve(input.sessionId);
+
+ if (!session) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Checkout session not found",
+ });
+ }
+
+ return {
+ id: session.id,
+ customer: session.customer ? session.customer.toString() : null,
+ client_reference_id: session.client_reference_id,
+ setup_intent: session.setup_intent ? session.setup_intent.toString() : null,
+ payment_status: session.payment_status,
+ status: session.status || "",
+ };
+ } catch (error) {
+ if (error instanceof Stripe.errors.StripeError) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: `Stripe error: ${error.message}`,
+ });
+ }
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to retrieve checkout session",
+ });
+ }
+ });
diff --git a/apps/dashboard/lib/trpc/routers/stripe/getCustomer.ts b/apps/dashboard/lib/trpc/routers/stripe/getCustomer.ts
new file mode 100644
index 0000000000..3ffce3d857
--- /dev/null
+++ b/apps/dashboard/lib/trpc/routers/stripe/getCustomer.ts
@@ -0,0 +1,80 @@
+import { getStripeClient } from "@/lib/stripe";
+import { handleStripeError } from "@/lib/trpc/routers/utils/stripe";
+import { ratelimit, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc";
+import { TRPCError } from "@trpc/server";
+import Stripe from "stripe";
+import { z } from "zod";
+
+const customerSchema = z.object({
+ id: z.string(),
+ email: z.string().nullable(),
+ name: z.string().nullable(),
+ invoice_settings: z
+ .object({
+ default_payment_method: z.string().nullable(),
+ })
+ .nullable(),
+});
+
+export const getCustomer = t.procedure
+ .use(requireWorkspace)
+ .use(withRatelimit(ratelimit.read))
+ .input(
+ z.object({
+ customerId: z.string(),
+ }),
+ )
+ .output(customerSchema)
+ .query(async ({ input }) => {
+ const stripe = getStripeClient();
+
+ try {
+ const customer = await stripe.customers.retrieve(input.customerId);
+
+ if (customer.deleted) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Customer has been deleted",
+ });
+ }
+
+ // Extract default payment method ID, handling both string and expanded object
+ let defaultPaymentMethodId: string | null = null;
+ if (customer.invoice_settings?.default_payment_method) {
+ const paymentMethod = customer.invoice_settings.default_payment_method;
+ if (typeof paymentMethod === "string") {
+ defaultPaymentMethodId = paymentMethod;
+ } else if (typeof paymentMethod === "object" && paymentMethod.id) {
+ // Expanded PaymentMethod object
+ defaultPaymentMethodId = paymentMethod.id;
+ }
+ }
+
+ return {
+ id: customer.id,
+ email: customer.email,
+ name: customer.name ?? null,
+ invoice_settings: customer.invoice_settings
+ ? {
+ default_payment_method: defaultPaymentMethodId,
+ }
+ : null,
+ };
+ } catch (error) {
+ // If error is already a TRPCError, rethrow unchanged
+ if (error instanceof TRPCError) {
+ throw error;
+ }
+
+ // Map Stripe errors to appropriate TRPC error codes
+ if (error instanceof Stripe.errors.StripeError) {
+ handleStripeError(error);
+ }
+
+ // Handle unknown errors
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to retrieve customer",
+ });
+ }
+ });
diff --git a/apps/dashboard/lib/trpc/routers/stripe/getProducts.ts b/apps/dashboard/lib/trpc/routers/stripe/getProducts.ts
new file mode 100644
index 0000000000..59df3eec99
--- /dev/null
+++ b/apps/dashboard/lib/trpc/routers/stripe/getProducts.ts
@@ -0,0 +1,42 @@
+import { stripeEnv } from "@/lib/env";
+import { getStripeClient } from "@/lib/stripe";
+import { ratelimit, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc";
+import { TRPCError } from "@trpc/server";
+import { z } from "zod";
+import { mapProduct } from "../utils/stripe";
+
+const productSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ priceId: z.string(),
+ dollar: z.number(),
+ quotas: z.object({
+ requestsPerMonth: z.number(),
+ }),
+});
+
+export const getProducts = t.procedure
+ .use(requireWorkspace)
+ .use(withRatelimit(ratelimit.read))
+ .output(z.array(productSchema))
+ .query(async () => {
+ const stripe = getStripeClient();
+ const e = stripeEnv();
+ if (!e) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Stripe is not configured",
+ });
+ }
+
+ const products = await stripe.products
+ .list({
+ active: true,
+ ids: e.STRIPE_PRODUCT_IDS_PRO,
+ limit: 100,
+ expand: ["data.default_price"],
+ })
+ .then((res) => res.data.map(mapProduct).sort((a, b) => a.dollar - b.dollar));
+
+ return products;
+ });
diff --git a/apps/dashboard/lib/trpc/routers/stripe/getSetupIntent.ts b/apps/dashboard/lib/trpc/routers/stripe/getSetupIntent.ts
new file mode 100644
index 0000000000..182b08bc8e
--- /dev/null
+++ b/apps/dashboard/lib/trpc/routers/stripe/getSetupIntent.ts
@@ -0,0 +1,69 @@
+import { getStripeClient } from "@/lib/stripe";
+import { handleStripeError } from "@/lib/trpc/routers/utils/stripe";
+import { ratelimit, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc";
+import { TRPCError } from "@trpc/server";
+import Stripe from "stripe";
+import { z } from "zod";
+
+const setupIntentSchema = z.object({
+ id: z.string(),
+ client_secret: z.string().nullable(),
+ payment_method: z.string().nullable(),
+ status: z.string(),
+ usage: z.string(),
+});
+
+export const getSetupIntent = t.procedure
+ .use(requireWorkspace)
+ .use(withRatelimit(ratelimit.read))
+ .input(
+ z.object({
+ setupIntentId: z.string(),
+ }),
+ )
+ .output(setupIntentSchema)
+ .query(async ({ input }) => {
+ const stripe = getStripeClient();
+
+ try {
+ const setupIntent = await stripe.setupIntents.retrieve(input.setupIntentId);
+
+ // Extract payment method ID, handling both string and expanded object
+ let paymentMethodId: string | null = null;
+ if (setupIntent.payment_method) {
+ if (typeof setupIntent.payment_method === "string") {
+ paymentMethodId = setupIntent.payment_method;
+ } else if (
+ typeof setupIntent.payment_method === "object" &&
+ setupIntent.payment_method.id
+ ) {
+ // Expanded PaymentMethod object
+ paymentMethodId = setupIntent.payment_method.id;
+ }
+ }
+
+ return {
+ id: setupIntent.id,
+ client_secret: setupIntent.client_secret,
+ payment_method: paymentMethodId,
+ status: setupIntent.status,
+ usage: setupIntent.usage,
+ };
+ } catch (error) {
+ // If error is already a TRPCError, rethrow unchanged
+ if (error instanceof TRPCError) {
+ throw error;
+ }
+
+ // Map Stripe errors to appropriate TRPC error codes
+ if (error instanceof Stripe.errors.StripeError) {
+ handleStripeError(error);
+ }
+
+ // Handle unknown errors
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to retrieve setup intent",
+ });
+ }
+ });
diff --git a/apps/dashboard/lib/trpc/routers/stripe/uncancelSubscription.ts b/apps/dashboard/lib/trpc/routers/stripe/uncancelSubscription.ts
index 8f9f12ba04..1e36545b2f 100644
--- a/apps/dashboard/lib/trpc/routers/stripe/uncancelSubscription.ts
+++ b/apps/dashboard/lib/trpc/routers/stripe/uncancelSubscription.ts
@@ -1,20 +1,11 @@
-import { stripeEnv } from "@/lib/env";
+import { getStripeClient } from "@/lib/stripe";
import { TRPCError } from "@trpc/server";
-import Stripe from "stripe";
import { requireUser, requireWorkspace, t } from "../../trpc";
export const uncancelSubscription = t.procedure
.use(requireUser)
.use(requireWorkspace)
.mutation(async ({ ctx }) => {
- const e = stripeEnv();
- if (!e) {
- throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Stripe is not set up" });
- }
-
- const stripe = new Stripe(e.STRIPE_SECRET_KEY, {
- apiVersion: "2023-10-16",
- typescript: true,
- });
+ const stripe = getStripeClient();
if (!ctx.workspace.stripeCustomerId) {
throw new TRPCError({
diff --git a/apps/dashboard/lib/trpc/routers/stripe/updateCustomer.ts b/apps/dashboard/lib/trpc/routers/stripe/updateCustomer.ts
new file mode 100644
index 0000000000..ae38c4fe73
--- /dev/null
+++ b/apps/dashboard/lib/trpc/routers/stripe/updateCustomer.ts
@@ -0,0 +1,59 @@
+import { getStripeClient } from "@/lib/stripe";
+import { handleStripeError } from "@/lib/trpc/routers/utils/stripe";
+import { ratelimit, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc";
+import { TRPCError } from "@trpc/server";
+import Stripe from "stripe";
+import { z } from "zod";
+
+const updateCustomerInputSchema = z.object({
+ customerId: z.string(),
+ paymentMethod: z.string(),
+});
+
+const customerSchema = z.object({
+ id: z.string(),
+});
+
+export const updateCustomer = t.procedure
+ .use(requireWorkspace)
+ .use(withRatelimit(ratelimit.update))
+ .input(updateCustomerInputSchema)
+ .output(customerSchema)
+ .mutation(async ({ input }) => {
+ const stripe = getStripeClient();
+
+ try {
+ const customer = await stripe.customers.update(input.customerId, {
+ invoice_settings: {
+ default_payment_method: input.paymentMethod,
+ },
+ });
+
+ if (!customer) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Customer not found or has been deleted",
+ });
+ }
+
+ return {
+ id: customer.id,
+ };
+ } catch (error) {
+ // If error is already a TRPCError, rethrow unchanged
+ if (error instanceof TRPCError) {
+ throw error;
+ }
+
+ // Handle Stripe errors
+ if (error instanceof Stripe.errors.StripeError) {
+ handleStripeError(error);
+ }
+
+ // Handle unknown errors
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to update customer",
+ });
+ }
+ });
diff --git a/apps/dashboard/lib/trpc/routers/stripe/updateSubscription.ts b/apps/dashboard/lib/trpc/routers/stripe/updateSubscription.ts
index 82a3b3faf4..d96d200aab 100644
--- a/apps/dashboard/lib/trpc/routers/stripe/updateSubscription.ts
+++ b/apps/dashboard/lib/trpc/routers/stripe/updateSubscription.ts
@@ -1,9 +1,8 @@
import { insertAuditLogs } from "@/lib/audit";
import { db, eq, schema } from "@/lib/db";
-import { stripeEnv } from "@/lib/env";
+import { getStripeClient } from "@/lib/stripe";
import { invalidateWorkspaceCache } from "@/lib/workspace-cache";
import { TRPCError } from "@trpc/server";
-import Stripe from "stripe";
import { z } from "zod";
import { requireUser, requireWorkspace, t } from "../../trpc";
export const updateSubscription = t.procedure
@@ -16,18 +15,7 @@ export const updateSubscription = t.procedure
}),
)
.mutation(async ({ ctx, input }) => {
- const e = stripeEnv();
- if (!e) {
- throw new TRPCError({
- code: "INTERNAL_SERVER_ERROR",
- message: "Stripe is not set up",
- });
- }
-
- const stripe = new Stripe(e.STRIPE_SECRET_KEY, {
- apiVersion: "2023-10-16",
- typescript: true,
- });
+ const stripe = getStripeClient();
if (!ctx.workspace.stripeCustomerId) {
throw new TRPCError({
diff --git a/apps/dashboard/lib/trpc/routers/stripe/updateWorkspace.ts b/apps/dashboard/lib/trpc/routers/stripe/updateWorkspace.ts
new file mode 100644
index 0000000000..44545188c6
--- /dev/null
+++ b/apps/dashboard/lib/trpc/routers/stripe/updateWorkspace.ts
@@ -0,0 +1,60 @@
+import { insertAuditLogs } from "@/lib/audit";
+import { db, eq, schema } from "@/lib/db";
+import { invalidateWorkspaceCache } from "@/lib/workspace-cache";
+import { TRPCError } from "@trpc/server";
+import { z } from "zod";
+import { requireUser, requireWorkspace, t } from "../../trpc";
+import { clearWorkspaceCache } from "../workspace/getCurrent";
+
+export const updateWorkspaceStripeCustomer = t.procedure
+ .use(requireUser)
+ .use(requireWorkspace)
+ .input(
+ z.object({
+ stripeCustomerId: z.string().min(1, "Stripe customer ID is required"),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ await db
+ .transaction(async (tx) => {
+ await tx
+ .update(schema.workspaces)
+ .set({
+ stripeCustomerId: input.stripeCustomerId,
+ })
+ .where(eq(schema.workspaces.id, ctx.workspace.id));
+
+ await insertAuditLogs(tx, {
+ workspaceId: ctx.workspace.id,
+ actor: { type: "user", id: ctx.user.id },
+ event: "workspace.update",
+ description: "Updated Stripe customer ID",
+ resources: [
+ {
+ type: "workspace",
+ id: ctx.workspace.id,
+ name: ctx.workspace.name,
+ },
+ ],
+ context: {
+ location: ctx.audit.location,
+ userAgent: ctx.audit.userAgent,
+ },
+ });
+ })
+ .catch((_err) => {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message:
+ "We are unable to update the workspace Stripe customer. Please try again or contact support@unkey.dev",
+ });
+ });
+ // Invalidate workspace cache after successful update
+ await invalidateWorkspaceCache(ctx.tenant.id);
+
+ // Also clear the tRPC workspace cache to ensure fresh data on next request
+ clearWorkspaceCache(ctx.tenant.id);
+ return {
+ success: true,
+ };
+ });
diff --git a/apps/dashboard/lib/trpc/routers/utils/stripe.ts b/apps/dashboard/lib/trpc/routers/utils/stripe.ts
new file mode 100644
index 0000000000..0042cda86e
--- /dev/null
+++ b/apps/dashboard/lib/trpc/routers/utils/stripe.ts
@@ -0,0 +1,77 @@
+import { TRPCError } from "@trpc/server";
+import Stripe from "stripe";
+
+export const mapProduct = (p: Stripe.Product) => {
+ if (!p.default_price) {
+ throw new Error(`Product ${p.id} is missing default_price`);
+ }
+
+ const price = typeof p.default_price === "string" ? null : (p.default_price as Stripe.Price);
+
+ if (!price) {
+ throw new Error(`Product ${p.id} default_price must be expanded`);
+ }
+
+ if (price.unit_amount === null || price.unit_amount === undefined) {
+ throw new Error(`Product ${p.id} price is missing unit_amount`);
+ }
+
+ const quotaRaw = p.metadata?.quota_requests_per_month;
+
+ // Validate that the metadata value is a non-empty string of digits
+ if (!quotaRaw || typeof quotaRaw !== "string" || !/^\d+$/.test(quotaRaw)) {
+ throw new Error(
+ `Product ${p.id} metadata.quota_requests_per_month must be a non-empty string of digits, got: ${quotaRaw}`,
+ );
+ }
+
+ // Parse into integer only after regex validation
+ const quotaValue = Number.parseInt(quotaRaw, 10);
+
+ // Ensure the parsed integer is >= 0
+ if (quotaValue < 0) {
+ throw new Error(
+ `Product ${p.id} metadata.quota_requests_per_month must be >= 0, got: ${quotaRaw}`,
+ );
+ }
+
+ return {
+ id: p.id,
+ name: p.name,
+ priceId: price.id,
+ dollar: price.unit_amount / 100,
+ quotas: {
+ requestsPerMonth: quotaValue,
+ },
+ };
+};
+
+export const handleStripeError = (error: Stripe.errors.StripeError) => {
+ const stripeError = error;
+ let code: TRPCError["code"];
+
+ // Map Stripe error types to TRPC error codes
+ if (error instanceof Stripe.errors.StripeAuthenticationError) {
+ code = "UNAUTHORIZED";
+ } else if (error instanceof Stripe.errors.StripeRateLimitError) {
+ code = "TOO_MANY_REQUESTS";
+ } else if (error instanceof Stripe.errors.StripeInvalidRequestError) {
+ code = "BAD_REQUEST";
+ } else if (error instanceof Stripe.errors.StripePermissionError) {
+ code = "FORBIDDEN";
+ } else if (stripeError.statusCode === 404 || stripeError.code === "resource_missing") {
+ code = "NOT_FOUND";
+ } else if (error instanceof Stripe.errors.StripeAPIError) {
+ code = "INTERNAL_SERVER_ERROR";
+ } else if (error instanceof Stripe.errors.StripeConnectionError) {
+ code = "INTERNAL_SERVER_ERROR";
+ } else {
+ // Default for other Stripe errors
+ code = "INTERNAL_SERVER_ERROR";
+ }
+
+ throw new TRPCError({
+ code,
+ message: `Stripe error: ${stripeError.message}`,
+ });
+};
diff --git a/apps/dashboard/lib/trpc/routers/workspace/getById.ts b/apps/dashboard/lib/trpc/routers/workspace/getById.ts
new file mode 100644
index 0000000000..d0b0abaeda
--- /dev/null
+++ b/apps/dashboard/lib/trpc/routers/workspace/getById.ts
@@ -0,0 +1,44 @@
+import { db } from "@/lib/db";
+import { TRPCError } from "@trpc/server";
+import { z } from "zod";
+import { requireUser, t } from "../../trpc";
+
+export const getWorkspaceById = t.procedure
+ .use(requireUser)
+ .input(
+ z.object({
+ workspaceId: z.string().min(1, "Workspace ID is required"),
+ }),
+ )
+ .query(async ({ input }) => {
+ try {
+ const workspace = await db.query.workspaces.findFirst({
+ where: (table, { eq, and, isNull }) =>
+ and(eq(table.id, input.workspaceId), isNull(table.deletedAtM)),
+ with: {
+ quotas: true,
+ },
+ });
+
+ if (!workspace) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Workspace not found",
+ });
+ }
+
+ return workspace;
+ } catch (error) {
+ // If it's already a TRPCError, re-throw it
+ if (error instanceof TRPCError) {
+ throw error;
+ }
+
+ console.error("Error fetching workspace by ID:", error);
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to fetch workspace data",
+ cause: error,
+ });
+ }
+ });
diff --git a/internal/ui/src/components/dialog/dialog-container.tsx b/internal/ui/src/components/dialog/dialog-container.tsx
index 31ba80c829..8163ea36a1 100644
--- a/internal/ui/src/components/dialog/dialog-container.tsx
+++ b/internal/ui/src/components/dialog/dialog-container.tsx
@@ -19,6 +19,8 @@ type DialogContainerProps = PropsWithChildren<{
contentClassName?: string;
preventAutoFocus?: boolean;
subTitle?: string;
+ showCloseWarning?: boolean;
+ onAttemptClose?: () => void;
}>;
const DialogContainer = ({
@@ -31,6 +33,8 @@ const DialogContainer = ({
footer,
contentClassName,
preventAutoFocus = true,
+ showCloseWarning = false,
+ onAttemptClose,
}: DialogContainerProps) => {
return (