Make billing optional (#232)

This commit is contained in:
Brendan Kellam 2025-03-19 10:24:50 -07:00 committed by GitHub
parent ad60c5f1e0
commit e8667da1ca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 84 additions and 80 deletions

View file

@ -49,6 +49,7 @@ REDIS_URL="redis://localhost:6379"
# STRIPE_SECRET_KEY: z.string().optional(), # STRIPE_SECRET_KEY: z.string().optional(),
# STRIPE_PRODUCT_ID: z.string().optional(), # STRIPE_PRODUCT_ID: z.string().optional(),
# STRIPE_WEBHOOK_SECRET: z.string().optional(), # STRIPE_WEBHOOK_SECRET: z.string().optional(),
# STRIPE_ENABLE_TEST_CLOCKS=false
# Misc # Misc

View file

@ -27,6 +27,7 @@ import { orgDomainSchema, orgNameSchema, repositoryQuerySchema } from "./lib/sch
import { RepositoryQuery } from "./lib/types"; import { RepositoryQuery } from "./lib/types";
import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME } from "./lib/constants"; import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME } from "./lib/constants";
import { stripeClient } from "./lib/stripe"; import { stripeClient } from "./lib/stripe";
import { IS_BILLING_ENABLED } from "./lib/stripe";
const ajv = new Ajv({ const ajv = new Ajv({
validateFormats: false, validateFormats: false,
@ -174,19 +175,31 @@ export const completeOnboarding = async (domain: string): Promise<{ success: boo
return notFound(); return notFound();
} }
const subscription = await fetchSubscription(domain); // If billing is not enabled, we can just mark the org as onboarded.
if (isServiceError(subscription)) { if (!IS_BILLING_ENABLED) {
return subscription; await prisma.org.update({
} where: { id: orgId },
data: {
isOnboarded: true,
}
});
await prisma.org.update({ // Else, validate that the org has an active subscription.
where: { id: orgId }, } else {
data: { const subscriptionOrError = await fetchSubscription(domain);
isOnboarded: true, if (isServiceError(subscriptionOrError)) {
stripeSubscriptionStatus: StripeSubscriptionStatus.ACTIVE, return subscriptionOrError;
stripeLastUpdatedAt: new Date(),
} }
});
await prisma.org.update({
where: { id: orgId },
data: {
isOnboarded: true,
stripeSubscriptionStatus: StripeSubscriptionStatus.ACTIVE,
stripeLastUpdatedAt: new Date(),
}
});
}
return { return {
success: true, success: true,
@ -708,9 +721,9 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean
} }
const res = await prisma.$transaction(async (tx) => { 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. if (IS_BILLING_ENABLED) {
const subscription = await _fetchSubscriptionForOrg(invite.orgId, tx); // @note: we need to use the internal subscription fetch here since we would otherwise fail the `withOrgMembership` check.
if (subscription) { const subscription = await _fetchSubscriptionForOrg(invite.orgId, tx);
if (isServiceError(subscription)) { if (isServiceError(subscription)) {
return subscription; return subscription;
} }
@ -880,8 +893,7 @@ export const createOnboardingSubscription = async (domain: string) =>
} satisfies ServiceError; } satisfies ServiceError;
} }
// @nocheckin const test_clock = env.STRIPE_ENABLE_TEST_CLOCKS === 'true' ? await stripeClient.testHelpers.testClocks.create({
const test_clock = env.AUTH_URL !== "https://app.sourcebot.dev" ? await stripeClient.testHelpers.testClocks.create({
frozen_time: Math.floor(Date.now() / 1000) frozen_time: Math.floor(Date.now() / 1000)
}) : null; }) : null;
@ -911,7 +923,7 @@ export const createOnboardingSubscription = async (domain: string) =>
})(); })();
const existingSubscription = await fetchSubscription(domain); const existingSubscription = await fetchSubscription(domain);
if (existingSubscription && !isServiceError(existingSubscription)) { if (!isServiceError(existingSubscription)) {
return { return {
statusCode: StatusCodes.BAD_REQUEST, statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.SUBSCRIPTION_ALREADY_EXISTS, errorCode: ErrorCode.SUBSCRIPTION_ALREADY_EXISTS,
@ -1063,7 +1075,7 @@ export const getCustomerPortalSessionLink = async (domain: string): Promise<stri
}, /* minRequiredRole = */ OrgRole.OWNER) }, /* minRequiredRole = */ OrgRole.OWNER)
); );
export const fetchSubscription = (domain: string): Promise<Stripe.Subscription | null | ServiceError> => export const fetchSubscription = (domain: string): Promise<Stripe.Subscription | ServiceError> =>
withAuth(async (session) => withAuth(async (session) =>
withOrgMembership(session, domain, async ({ orgId }) => { withOrgMembership(session, domain, async ({ orgId }) => {
return _fetchSubscriptionForOrg(orgId, prisma); return _fetchSubscriptionForOrg(orgId, prisma);
@ -1167,8 +1179,8 @@ export const removeMemberFromOrg = async (memberId: string, domain: string): Pro
return notFound(); return notFound();
} }
const subscription = await fetchSubscription(domain); if (IS_BILLING_ENABLED) {
if (subscription) { const subscription = await fetchSubscription(domain);
if (isServiceError(subscription)) { if (isServiceError(subscription)) {
return subscription; return subscription;
} }
@ -1221,8 +1233,8 @@ export const leaveOrg = async (domain: string): Promise<{ success: boolean } | S
return notFound(); return notFound();
} }
const subscription = await fetchSubscription(domain); if (IS_BILLING_ENABLED) {
if (subscription) { const subscription = await fetchSubscription(domain);
if (isServiceError(subscription)) { if (isServiceError(subscription)) {
return subscription; return subscription;
} }
@ -1323,7 +1335,7 @@ export const dismissMobileUnsupportedSplashScreen = async () => {
////// Helpers /////// ////// Helpers ///////
const _fetchSubscriptionForOrg = async (orgId: number, prisma: Prisma.TransactionClient): Promise<Stripe.Subscription | null | ServiceError> => { const _fetchSubscriptionForOrg = async (orgId: number, prisma: Prisma.TransactionClient): Promise<Stripe.Subscription | ServiceError> => {
const org = await prisma.org.findUnique({ const org = await prisma.org.findUnique({
where: { where: {
id: orgId, id: orgId,

View file

@ -12,7 +12,7 @@ import { WarningNavIndicator } from "./warningNavIndicator";
import { ProgressNavIndicator } from "./progressNavIndicator"; import { ProgressNavIndicator } from "./progressNavIndicator";
import { SourcebotLogo } from "@/app/components/sourcebotLogo"; import { SourcebotLogo } from "@/app/components/sourcebotLogo";
import { TrialNavIndicator } from "./trialNavIndicator"; import { TrialNavIndicator } from "./trialNavIndicator";
import { IS_BILLING_ENABLED } from "@/lib/stripe";
const SOURCEBOT_DISCORD_URL = "https://discord.gg/6Fhp27x7Pb"; const SOURCEBOT_DISCORD_URL = "https://discord.gg/6Fhp27x7Pb";
const SOURCEBOT_GITHUB_URL = "https://github.com/sourcebot-dev/sourcebot"; const SOURCEBOT_GITHUB_URL = "https://github.com/sourcebot-dev/sourcebot";
@ -23,7 +23,7 @@ interface NavigationMenuProps {
export const NavigationMenu = async ({ export const NavigationMenu = async ({
domain, domain,
}: NavigationMenuProps) => { }: NavigationMenuProps) => {
const subscription = await getSubscriptionData(domain); const subscription = IS_BILLING_ENABLED ? await getSubscriptionData(domain) : null;
return ( return (
<div className="flex flex-col w-screen h-fit"> <div className="flex flex-col w-screen h-fit">

View file

@ -12,6 +12,7 @@ import { MobileUnsupportedSplashScreen } from "./components/mobileUnsupportedSpl
import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME } from "@/lib/constants"; import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME } from "@/lib/constants";
import { SyntaxReferenceGuide } from "./components/syntaxReferenceGuide"; import { SyntaxReferenceGuide } from "./components/syntaxReferenceGuide";
import { SyntaxGuideProvider } from "./components/syntaxGuideProvider"; import { SyntaxGuideProvider } from "./components/syntaxGuideProvider";
import { IS_BILLING_ENABLED } from "@/lib/stripe";
interface LayoutProps { interface LayoutProps {
children: React.ReactNode, children: React.ReactNode,
@ -56,19 +57,21 @@ export default async function Layout({
) )
} }
const subscription = await fetchSubscription(domain); if (IS_BILLING_ENABLED) {
if ( const subscription = await fetchSubscription(domain);
subscription && if (
( subscription &&
isServiceError(subscription) || (
(subscription.status !== "active" && subscription.status !== "trialing") isServiceError(subscription) ||
) (subscription.status !== "active" && subscription.status !== "trialing")
) { )
return ( ) {
<UpgradeGuard> return (
{children} <UpgradeGuard>
</UpgradeGuard> {children}
) </UpgradeGuard>
)
}
} }
const headersList = await headers(); const headersList = await headers();

View file

@ -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 (
<Link
className="text-sm text-muted-foreground underline cursor-pointer mt-12"
href={`?step=${lastRequiredStep}`}
onClick={handleClick}
>
Skip onboarding
</Link>
);
};

View file

@ -7,7 +7,7 @@ import { InviteTeam } from "./components/inviteTeam";
import { CompleteOnboarding } from "./components/completeOnboarding"; import { CompleteOnboarding } from "./components/completeOnboarding";
import { Checkout } from "./components/checkout"; import { Checkout } from "./components/checkout";
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch"; import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
import SecurityCard from "@/app/components/securityCard"; import { IS_BILLING_ENABLED } from "@/lib/stripe";
interface OnboardProps { interface OnboardProps {
params: { params: {
@ -34,13 +34,14 @@ export default async function Onboard({ params, searchParams }: OnboardProps) {
if ( if (
!Object.values(OnboardingSteps) !Object.values(OnboardingSteps)
.filter(s => s !== OnboardingSteps.CreateOrg) .filter(s => s !== OnboardingSteps.CreateOrg)
.filter(s => !IS_BILLING_ENABLED ? s !== OnboardingSteps.Checkout : true)
.map(s => s.toString()) .map(s => s.toString())
.includes(step) .includes(step)
) { ) {
redirect(`/${params.domain}/onboard?step=${OnboardingSteps.ConnectCodeHost}`); redirect(`/${params.domain}/onboard?step=${OnboardingSteps.ConnectCodeHost}`);
} }
const lastRequiredStep = OnboardingSteps.Checkout; const lastRequiredStep = IS_BILLING_ENABLED ? OnboardingSteps.Checkout : OnboardingSteps.Complete;
return ( return (
<div className="flex flex-col items-center py-12 px-4 sm:px-12 min-h-screen bg-backgroundSecondary relative"> <div className="flex flex-col items-center py-12 px-4 sm:px-12 min-h-screen bg-backgroundSecondary relative">

View file

@ -5,6 +5,8 @@ import { ManageSubscriptionButton } from "./manageSubscriptionButton"
import { getSubscriptionData, getCurrentUserRole, getSubscriptionBillingEmail } from "@/actions" import { getSubscriptionData, getCurrentUserRole, getSubscriptionBillingEmail } from "@/actions"
import { isServiceError } from "@/lib/utils" import { isServiceError } from "@/lib/utils"
import { ChangeBillingEmailCard } from "./changeBillingEmailCard" import { ChangeBillingEmailCard } from "./changeBillingEmailCard"
import { notFound } from "next/navigation"
import { IS_BILLING_ENABLED } from "@/lib/stripe"
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Billing | Settings", title: "Billing | Settings",
@ -20,6 +22,10 @@ interface BillingPageProps {
export default async function BillingPage({ export default async function BillingPage({
params: { domain }, params: { domain },
}: BillingPageProps) { }: BillingPageProps) {
if (!IS_BILLING_ENABLED) {
notFound();
}
const subscription = await getSubscriptionData(domain) const subscription = await getSubscriptionData(domain)
if (isServiceError(subscription)) { if (isServiceError(subscription)) {

View file

@ -2,6 +2,7 @@ import { Metadata } from "next"
import { SidebarNav } from "./components/sidebar-nav" import { SidebarNav } from "./components/sidebar-nav"
import { NavigationMenu } from "../components/navigationMenu" import { NavigationMenu } from "../components/navigationMenu"
import { Header } from "./components/header"; import { Header } from "./components/header";
import { IS_BILLING_ENABLED } from "@/lib/stripe";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Settings", title: "Settings",
} }
@ -19,10 +20,12 @@ export default function SettingsLayout({
title: "General", title: "General",
href: `/${domain}/settings`, href: `/${domain}/settings`,
}, },
{ ...(IS_BILLING_ENABLED ? [
title: "Billing", {
href: `/${domain}/settings/billing`, title: "Billing",
}, href: `/${domain}/settings/billing`,
}
] : []),
{ {
title: "Members", title: "Members",
href: `/${domain}/settings/members`, href: `/${domain}/settings/members`,

View file

@ -29,9 +29,10 @@ export const inviteMemberFormSchema = z.object({
interface InviteMemberCardProps { interface InviteMemberCardProps {
currentUserRole: OrgRole; currentUserRole: OrgRole;
isBillingEnabled: boolean;
} }
export const InviteMemberCard = ({ currentUserRole }: InviteMemberCardProps) => { export const InviteMemberCard = ({ currentUserRole, isBillingEnabled }: InviteMemberCardProps) => {
const [isInviteDialogOpen, setIsInviteDialogOpen] = useState(false); const [isInviteDialogOpen, setIsInviteDialogOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const domain = useDomain(); const domain = useDomain();
@ -144,7 +145,7 @@ export const InviteMemberCard = ({ currentUserRole }: InviteMemberCardProps) =>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Invite Team Members</AlertDialogTitle> <AlertDialogTitle>Invite Team Members</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
{`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." : ""}`}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<div className="border rounded-lg overflow-hidden"> <div className="border rounded-lg overflow-hidden">

View file

@ -9,7 +9,7 @@ import { Tabs, TabsContent } from "@/components/ui/tabs";
import { TabSwitcher } from "@/components/ui/tab-switcher"; import { TabSwitcher } from "@/components/ui/tab-switcher";
import { InvitesList } from "./components/invitesList"; import { InvitesList } from "./components/invitesList";
import { getOrgInvites } from "@/actions"; import { getOrgInvites } from "@/actions";
import { IS_BILLING_ENABLED } from "@/lib/stripe";
interface MembersSettingsPageProps { interface MembersSettingsPageProps {
params: { params: {
domain: string domain: string
@ -61,6 +61,7 @@ export default async function MembersSettingsPage({ params: { domain }, searchPa
<InviteMemberCard <InviteMemberCard
currentUserRole={userRoleInOrg} currentUserRole={userRoleInOrg}
isBillingEnabled={IS_BILLING_ENABLED}
/> />
<Tabs value={currentTab}> <Tabs value={currentTab}>

View file

@ -1,5 +1,6 @@
import { SourcebotLogo } from "@/app/components/sourcebotLogo" import { SourcebotLogo } from "@/app/components/sourcebotLogo"
import { OnboardingSteps } from "@/lib/constants"; import { OnboardingSteps } from "@/lib/constants";
import { IS_BILLING_ENABLED } from "@/lib/stripe";
interface OnboardHeaderProps { interface OnboardHeaderProps {
title: string title: string
@ -8,7 +9,9 @@ interface OnboardHeaderProps {
} }
export const OnboardHeader = ({ title, description, step: currentStep }: 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 ( return (
<div className="flex flex-col items-center text-center mb-10"> <div className="flex flex-col items-center text-center mb-10">

View file

@ -28,6 +28,7 @@ export const env = createEnv({
STRIPE_SECRET_KEY: z.string().optional(), STRIPE_SECRET_KEY: z.string().optional(),
STRIPE_PRODUCT_ID: z.string().optional(), STRIPE_PRODUCT_ID: z.string().optional(),
STRIPE_WEBHOOK_SECRET: z.string().optional(), STRIPE_WEBHOOK_SECRET: z.string().optional(),
STRIPE_ENABLE_TEST_CLOCKS: booleanSchema.default('false'),
// Misc // Misc
CONFIG_MAX_REPOS_NO_TOKEN: z.number().default(500), CONFIG_MAX_REPOS_NO_TOKEN: z.number().default(500),

View file

@ -2,7 +2,9 @@ import 'server-only';
import { env } from '@/env.mjs' import { env } from '@/env.mjs'
import Stripe from "stripe"; import Stripe from "stripe";
export const IS_BILLING_ENABLED = env.STRIPE_SECRET_KEY !== undefined;
export const stripeClient = export const stripeClient =
env.STRIPE_SECRET_KEY IS_BILLING_ENABLED
? new Stripe(env.STRIPE_SECRET_KEY) ? new Stripe(env.STRIPE_SECRET_KEY!)
: undefined; : undefined;