Skip to content

Optional billing #232

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Mar 19, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.development
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
58 changes: 35 additions & 23 deletions packages/web/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions packages/web/src/app/[domain]/components/navigationMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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">
Expand Down
29 changes: 16 additions & 13 deletions packages/web/src/app/[domain]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down

This file was deleted.

5 changes: 3 additions & 2 deletions packages/web/src/app/[domain]/onboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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">
Expand Down
6 changes: 6 additions & 0 deletions packages/web/src/app/[domain]/settings/billing/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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)) {
Expand Down
11 changes: 7 additions & 4 deletions packages/web/src/app/[domain]/settings/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
Expand All @@ -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`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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">
Expand Down
3 changes: 2 additions & 1 deletion packages/web/src/app/[domain]/settings/members/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -61,6 +61,7 @@ export default async function MembersSettingsPage({ params: { domain }, searchPa

<InviteMemberCard
currentUserRole={userRoleInOrg}
isBillingEnabled={IS_BILLING_ENABLED}
/>

<Tabs value={currentTab}>
Expand Down
5 changes: 4 additions & 1 deletion packages/web/src/app/onboard/components/onboardHeader.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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">
Expand Down
1 change: 1 addition & 0 deletions packages/web/src/env.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
6 changes: 4 additions & 2 deletions packages/web/src/lib/stripe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;