mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 20:35:24 +00:00
Make billing optional (#232)
This commit is contained in:
parent
ad60c5f1e0
commit
e8667da1ca
13 changed files with 84 additions and 80 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
Loading…
Reference in a new issue