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_PRODUCT_ID: z.string().optional(),
# STRIPE_WEBHOOK_SECRET: z.string().optional(),
# STRIPE_ENABLE_TEST_CLOCKS=false
# Misc

View file

@ -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<stri
}, /* 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) =>
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<Stripe.Subscription | null | ServiceError> => {
const _fetchSubscriptionForOrg = async (orgId: number, prisma: Prisma.TransactionClient): Promise<Stripe.Subscription | ServiceError> => {
const org = await prisma.org.findUnique({
where: {
id: orgId,

View file

@ -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 (
<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 { 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 (
<UpgradeGuard>
{children}
</UpgradeGuard>
)
if (IS_BILLING_ENABLED) {
const subscription = await fetchSubscription(domain);
if (
subscription &&
(
isServiceError(subscription) ||
(subscription.status !== "active" && subscription.status !== "trialing")
)
) {
return (
<UpgradeGuard>
{children}
</UpgradeGuard>
)
}
}
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 { 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 (
<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 { 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)) {

View file

@ -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`,

View file

@ -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) =>
<AlertDialogHeader>
<AlertDialogTitle>Invite Team Members</AlertDialogTitle>
<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>
</AlertDialogHeader>
<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 { 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
<InviteMemberCard
currentUserRole={userRoleInOrg}
isBillingEnabled={IS_BILLING_ENABLED}
/>
<Tabs value={currentTab}>

View file

@ -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 (
<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_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),

View file

@ -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;