diff --git a/.env.development b/.env.development index f095626b..aae06e86 100644 --- a/.env.development +++ b/.env.development @@ -49,6 +49,7 @@ REDIS_URL="redis://localhost:6379" # STRIPE_SECRET_KEY: z.string().optional(), # STRIPE_PRODUCT_ID: z.string().optional(), # STRIPE_WEBHOOK_SECRET: z.string().optional(), +# STRIPE_ENABLE_TEST_CLOCKS=false # Misc diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 3f747390..122675c7 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -27,6 +27,7 @@ import { orgDomainSchema, orgNameSchema, repositoryQuerySchema } from "./lib/sch import { RepositoryQuery } from "./lib/types"; import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME } from "./lib/constants"; import { stripeClient } from "./lib/stripe"; +import { IS_BILLING_ENABLED } from "./lib/stripe"; const ajv = new Ajv({ validateFormats: false, @@ -174,19 +175,31 @@ export const completeOnboarding = async (domain: string): Promise<{ success: boo return notFound(); } - const subscription = await fetchSubscription(domain); - if (isServiceError(subscription)) { - return subscription; - } + // If billing is not enabled, we can just mark the org as onboarded. + if (!IS_BILLING_ENABLED) { + await prisma.org.update({ + where: { id: orgId }, + data: { + isOnboarded: true, + } + }); - await prisma.org.update({ - where: { id: orgId }, - data: { - isOnboarded: true, - stripeSubscriptionStatus: StripeSubscriptionStatus.ACTIVE, - stripeLastUpdatedAt: new Date(), + // Else, validate that the org has an active subscription. + } else { + const subscriptionOrError = await fetchSubscription(domain); + if (isServiceError(subscriptionOrError)) { + return subscriptionOrError; } - }); + + await prisma.org.update({ + where: { id: orgId }, + data: { + isOnboarded: true, + stripeSubscriptionStatus: StripeSubscriptionStatus.ACTIVE, + stripeLastUpdatedAt: new Date(), + } + }); + } return { success: true, @@ -708,9 +721,9 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean } const res = await prisma.$transaction(async (tx) => { - // @note: we need to use the internal subscription fetch here since we would otherwise fail the `withOrgMembership` check. - const subscription = await _fetchSubscriptionForOrg(invite.orgId, tx); - if (subscription) { + if (IS_BILLING_ENABLED) { + // @note: we need to use the internal subscription fetch here since we would otherwise fail the `withOrgMembership` check. + const subscription = await _fetchSubscriptionForOrg(invite.orgId, tx); if (isServiceError(subscription)) { return subscription; } @@ -880,8 +893,7 @@ export const createOnboardingSubscription = async (domain: string) => } satisfies ServiceError; } - // @nocheckin - const test_clock = env.AUTH_URL !== "https://app.sourcebot.dev" ? await stripeClient.testHelpers.testClocks.create({ + const test_clock = env.STRIPE_ENABLE_TEST_CLOCKS === 'true' ? await stripeClient.testHelpers.testClocks.create({ frozen_time: Math.floor(Date.now() / 1000) }) : null; @@ -911,7 +923,7 @@ export const createOnboardingSubscription = async (domain: string) => })(); const existingSubscription = await fetchSubscription(domain); - if (existingSubscription && !isServiceError(existingSubscription)) { + if (!isServiceError(existingSubscription)) { return { statusCode: StatusCodes.BAD_REQUEST, errorCode: ErrorCode.SUBSCRIPTION_ALREADY_EXISTS, @@ -1063,7 +1075,7 @@ export const getCustomerPortalSessionLink = async (domain: string): Promise => +export const fetchSubscription = (domain: string): Promise => withAuth(async (session) => withOrgMembership(session, domain, async ({ orgId }) => { return _fetchSubscriptionForOrg(orgId, prisma); @@ -1167,8 +1179,8 @@ export const removeMemberFromOrg = async (memberId: string, domain: string): Pro return notFound(); } - const subscription = await fetchSubscription(domain); - if (subscription) { + if (IS_BILLING_ENABLED) { + const subscription = await fetchSubscription(domain); if (isServiceError(subscription)) { return subscription; } @@ -1221,8 +1233,8 @@ export const leaveOrg = async (domain: string): Promise<{ success: boolean } | S return notFound(); } - const subscription = await fetchSubscription(domain); - if (subscription) { + if (IS_BILLING_ENABLED) { + const subscription = await fetchSubscription(domain); if (isServiceError(subscription)) { return subscription; } @@ -1323,7 +1335,7 @@ export const dismissMobileUnsupportedSplashScreen = async () => { ////// Helpers /////// -const _fetchSubscriptionForOrg = async (orgId: number, prisma: Prisma.TransactionClient): Promise => { +const _fetchSubscriptionForOrg = async (orgId: number, prisma: Prisma.TransactionClient): Promise => { const org = await prisma.org.findUnique({ where: { id: orgId, diff --git a/packages/web/src/app/[domain]/components/navigationMenu.tsx b/packages/web/src/app/[domain]/components/navigationMenu.tsx index 00344d35..be3954dd 100644 --- a/packages/web/src/app/[domain]/components/navigationMenu.tsx +++ b/packages/web/src/app/[domain]/components/navigationMenu.tsx @@ -12,7 +12,7 @@ import { WarningNavIndicator } from "./warningNavIndicator"; import { ProgressNavIndicator } from "./progressNavIndicator"; import { SourcebotLogo } from "@/app/components/sourcebotLogo"; import { TrialNavIndicator } from "./trialNavIndicator"; - +import { IS_BILLING_ENABLED } from "@/lib/stripe"; const SOURCEBOT_DISCORD_URL = "https://discord.gg/6Fhp27x7Pb"; const SOURCEBOT_GITHUB_URL = "https://github.com/sourcebot-dev/sourcebot"; @@ -23,7 +23,7 @@ interface NavigationMenuProps { export const NavigationMenu = async ({ domain, }: NavigationMenuProps) => { - const subscription = await getSubscriptionData(domain); + const subscription = IS_BILLING_ENABLED ? await getSubscriptionData(domain) : null; return (
diff --git a/packages/web/src/app/[domain]/layout.tsx b/packages/web/src/app/[domain]/layout.tsx index e04a0dd7..b1b66794 100644 --- a/packages/web/src/app/[domain]/layout.tsx +++ b/packages/web/src/app/[domain]/layout.tsx @@ -12,6 +12,7 @@ import { MobileUnsupportedSplashScreen } from "./components/mobileUnsupportedSpl import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME } from "@/lib/constants"; import { SyntaxReferenceGuide } from "./components/syntaxReferenceGuide"; import { SyntaxGuideProvider } from "./components/syntaxGuideProvider"; +import { IS_BILLING_ENABLED } from "@/lib/stripe"; interface LayoutProps { children: React.ReactNode, @@ -56,19 +57,21 @@ export default async function Layout({ ) } - const subscription = await fetchSubscription(domain); - if ( - subscription && - ( - isServiceError(subscription) || - (subscription.status !== "active" && subscription.status !== "trialing") - ) - ) { - return ( - - {children} - - ) + if (IS_BILLING_ENABLED) { + const subscription = await fetchSubscription(domain); + if ( + subscription && + ( + isServiceError(subscription) || + (subscription.status !== "active" && subscription.status !== "trialing") + ) + ) { + return ( + + {children} + + ) + } } const headersList = await headers(); diff --git a/packages/web/src/app/[domain]/onboard/components/skipOnboardingButton.tsx b/packages/web/src/app/[domain]/onboard/components/skipOnboardingButton.tsx deleted file mode 100644 index 610ff679..00000000 --- a/packages/web/src/app/[domain]/onboard/components/skipOnboardingButton.tsx +++ /dev/null @@ -1,30 +0,0 @@ -'use client'; - -import Link from "next/link"; -import { OnboardingSteps } from "@/lib/constants"; -import useCaptureEvent from "@/hooks/useCaptureEvent"; - -interface SkipOnboardingButtonProps { - currentStep: OnboardingSteps; - lastRequiredStep: OnboardingSteps; -} - -export const SkipOnboardingButton = ({ currentStep, lastRequiredStep }: SkipOnboardingButtonProps) => { - const captureEvent = useCaptureEvent(); - - const handleClick = () => { - captureEvent('wa_onboard_skip_onboarding', { - step: currentStep - }); - }; - - return ( - - Skip onboarding - - ); -}; diff --git a/packages/web/src/app/[domain]/onboard/page.tsx b/packages/web/src/app/[domain]/onboard/page.tsx index 989a4220..586a6f91 100644 --- a/packages/web/src/app/[domain]/onboard/page.tsx +++ b/packages/web/src/app/[domain]/onboard/page.tsx @@ -7,7 +7,7 @@ import { InviteTeam } from "./components/inviteTeam"; import { CompleteOnboarding } from "./components/completeOnboarding"; import { Checkout } from "./components/checkout"; import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch"; -import SecurityCard from "@/app/components/securityCard"; +import { IS_BILLING_ENABLED } from "@/lib/stripe"; interface OnboardProps { params: { @@ -34,13 +34,14 @@ export default async function Onboard({ params, searchParams }: OnboardProps) { if ( !Object.values(OnboardingSteps) .filter(s => s !== OnboardingSteps.CreateOrg) + .filter(s => !IS_BILLING_ENABLED ? s !== OnboardingSteps.Checkout : true) .map(s => s.toString()) .includes(step) ) { redirect(`/${params.domain}/onboard?step=${OnboardingSteps.ConnectCodeHost}`); } - const lastRequiredStep = OnboardingSteps.Checkout; + const lastRequiredStep = IS_BILLING_ENABLED ? OnboardingSteps.Checkout : OnboardingSteps.Complete; return (
diff --git a/packages/web/src/app/[domain]/settings/billing/page.tsx b/packages/web/src/app/[domain]/settings/billing/page.tsx index dd35af44..e7214018 100644 --- a/packages/web/src/app/[domain]/settings/billing/page.tsx +++ b/packages/web/src/app/[domain]/settings/billing/page.tsx @@ -5,6 +5,8 @@ import { ManageSubscriptionButton } from "./manageSubscriptionButton" import { getSubscriptionData, getCurrentUserRole, getSubscriptionBillingEmail } from "@/actions" import { isServiceError } from "@/lib/utils" import { ChangeBillingEmailCard } from "./changeBillingEmailCard" +import { notFound } from "next/navigation" +import { IS_BILLING_ENABLED } from "@/lib/stripe" export const metadata: Metadata = { title: "Billing | Settings", @@ -20,6 +22,10 @@ interface BillingPageProps { export default async function BillingPage({ params: { domain }, }: BillingPageProps) { + if (!IS_BILLING_ENABLED) { + notFound(); + } + const subscription = await getSubscriptionData(domain) if (isServiceError(subscription)) { diff --git a/packages/web/src/app/[domain]/settings/layout.tsx b/packages/web/src/app/[domain]/settings/layout.tsx index 8a0a577a..89a659e4 100644 --- a/packages/web/src/app/[domain]/settings/layout.tsx +++ b/packages/web/src/app/[domain]/settings/layout.tsx @@ -2,6 +2,7 @@ import { Metadata } from "next" import { SidebarNav } from "./components/sidebar-nav" import { NavigationMenu } from "../components/navigationMenu" import { Header } from "./components/header"; +import { IS_BILLING_ENABLED } from "@/lib/stripe"; export const metadata: Metadata = { title: "Settings", } @@ -19,10 +20,12 @@ export default function SettingsLayout({ title: "General", href: `/${domain}/settings`, }, - { - title: "Billing", - href: `/${domain}/settings/billing`, - }, + ...(IS_BILLING_ENABLED ? [ + { + title: "Billing", + href: `/${domain}/settings/billing`, + } + ] : []), { title: "Members", href: `/${domain}/settings/members`, diff --git a/packages/web/src/app/[domain]/settings/members/components/inviteMemberCard.tsx b/packages/web/src/app/[domain]/settings/members/components/inviteMemberCard.tsx index 4b1c73c8..1c454df6 100644 --- a/packages/web/src/app/[domain]/settings/members/components/inviteMemberCard.tsx +++ b/packages/web/src/app/[domain]/settings/members/components/inviteMemberCard.tsx @@ -29,9 +29,10 @@ export const inviteMemberFormSchema = z.object({ interface InviteMemberCardProps { currentUserRole: OrgRole; + isBillingEnabled: boolean; } -export const InviteMemberCard = ({ currentUserRole }: InviteMemberCardProps) => { +export const InviteMemberCard = ({ currentUserRole, isBillingEnabled }: InviteMemberCardProps) => { const [isInviteDialogOpen, setIsInviteDialogOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); const domain = useDomain(); @@ -144,7 +145,7 @@ export const InviteMemberCard = ({ currentUserRole }: InviteMemberCardProps) => Invite Team Members - {`Your team is growing! By confirming, you will be inviting ${form.getValues().emails.length} new members to your organization. Your subscription's seat count will be adjusted when a member accepts their invitation.`} + {`Your team is growing! By confirming, you will be inviting ${form.getValues().emails.length} new members to your organization. ${isBillingEnabled ? "Your subscription's seat count will be adjusted when a member accepts their invitation." : ""}`}
diff --git a/packages/web/src/app/[domain]/settings/members/page.tsx b/packages/web/src/app/[domain]/settings/members/page.tsx index bca7ce10..a1227420 100644 --- a/packages/web/src/app/[domain]/settings/members/page.tsx +++ b/packages/web/src/app/[domain]/settings/members/page.tsx @@ -9,7 +9,7 @@ import { Tabs, TabsContent } from "@/components/ui/tabs"; import { TabSwitcher } from "@/components/ui/tab-switcher"; import { InvitesList } from "./components/invitesList"; import { getOrgInvites } from "@/actions"; - +import { IS_BILLING_ENABLED } from "@/lib/stripe"; interface MembersSettingsPageProps { params: { domain: string @@ -61,6 +61,7 @@ export default async function MembersSettingsPage({ params: { domain }, searchPa diff --git a/packages/web/src/app/onboard/components/onboardHeader.tsx b/packages/web/src/app/onboard/components/onboardHeader.tsx index a9d95004..b40d60c7 100644 --- a/packages/web/src/app/onboard/components/onboardHeader.tsx +++ b/packages/web/src/app/onboard/components/onboardHeader.tsx @@ -1,5 +1,6 @@ import { SourcebotLogo } from "@/app/components/sourcebotLogo" import { OnboardingSteps } from "@/lib/constants"; +import { IS_BILLING_ENABLED } from "@/lib/stripe"; interface OnboardHeaderProps { title: string @@ -8,7 +9,9 @@ interface OnboardHeaderProps { } export const OnboardHeader = ({ title, description, step: currentStep }: OnboardHeaderProps) => { - const steps = Object.values(OnboardingSteps).filter(s => s !== OnboardingSteps.Complete); + const steps = Object.values(OnboardingSteps) + .filter(s => s !== OnboardingSteps.Complete) + .filter(s => !IS_BILLING_ENABLED ? s !== OnboardingSteps.Checkout : true); return (
diff --git a/packages/web/src/env.mjs b/packages/web/src/env.mjs index 702be186..e5e81f56 100644 --- a/packages/web/src/env.mjs +++ b/packages/web/src/env.mjs @@ -28,6 +28,7 @@ export const env = createEnv({ STRIPE_SECRET_KEY: z.string().optional(), STRIPE_PRODUCT_ID: z.string().optional(), STRIPE_WEBHOOK_SECRET: z.string().optional(), + STRIPE_ENABLE_TEST_CLOCKS: booleanSchema.default('false'), // Misc CONFIG_MAX_REPOS_NO_TOKEN: z.number().default(500), diff --git a/packages/web/src/lib/stripe.ts b/packages/web/src/lib/stripe.ts index 3588e958..fd65253d 100644 --- a/packages/web/src/lib/stripe.ts +++ b/packages/web/src/lib/stripe.ts @@ -2,7 +2,9 @@ import 'server-only'; import { env } from '@/env.mjs' import Stripe from "stripe"; +export const IS_BILLING_ENABLED = env.STRIPE_SECRET_KEY !== undefined; + export const stripeClient = - env.STRIPE_SECRET_KEY - ? new Stripe(env.STRIPE_SECRET_KEY) + IS_BILLING_ENABLED + ? new Stripe(env.STRIPE_SECRET_KEY!) : undefined; \ No newline at end of file