add back paywall and also add support for incrememnting seat count on invite redemption

This commit is contained in:
msukkari 2025-02-12 16:48:13 -08:00
parent 53dac384af
commit 6caed350d3
8 changed files with 279 additions and 165 deletions

View file

@ -1,8 +1,8 @@
'use server';
import Ajv from "ajv";
import { auth, getCurrentUserOrg } from "./auth";
import { notAuthenticated, notFound, ServiceError, unexpectedError, orgDomainExists } from "@/lib/serviceError";
import { auth } from "./auth";
import { notAuthenticated, notFound, ServiceError, unexpectedError, orgInvalidSubscription } from "@/lib/serviceError";
import { prisma } from "@/prisma";
import { StatusCodes } from "http-status-codes";
import { ErrorCode } from "@/lib/errorCodes";
@ -17,6 +17,7 @@ import { headers } from "next/headers"
import { stripe } from "@/lib/stripe"
import { getUser } from "@/data/user";
import { Session } from "next-auth";
import Stripe from "stripe";
const ajv = new Ajv({
validateFormats: false,
@ -309,6 +310,35 @@ export const redeemInvite = async (invite: Invite, userId: string): Promise<{ su
withAuth(async () => {
try {
await prisma.$transaction(async (tx) => {
const org = await tx.org.findUnique({
where: {
id: invite.orgId,
}
});
if (!org) {
return notFound();
}
// Incrememnt the seat count. We check if the subscription is valid in the redeem page so we return an error if that's not the case here
if (org.stripeCustomerId) {
const subscription = await fetchSubscription(org.id);
if (isServiceError(subscription)) {
return orgInvalidSubscription();
}
const existingSeatCount = subscription.items.data[0].quantity;
const newSeatCount = (existingSeatCount || 1) + 1
await stripe.subscriptionItems.update(
subscription.items.data[0].id,
{
quantity: newSeatCount,
proration_behavior: 'create_prorations',
}
)
}
await tx.userToOrg.create({
data: {
userId,
@ -412,6 +442,11 @@ export async function fetchStripeClientSecret(name: string, domain: string) {
mode: 'subscription',
subscription_data: {
trial_period_days: 7,
trial_settings: {
end_behavior: {
missing_payment_method: 'cancel',
},
},
},
payment_method_collection: 'if_required',
return_url: `${origin}/onboard/complete?session_id={CHECKOUT_SESSION_ID}&org_name=${name}&org_domain=${domain}`,
@ -420,63 +455,58 @@ export async function fetchStripeClientSecret(name: string, domain: string) {
return stripeSession.client_secret!;
}
export async function getSubscriptionCheckoutRedirect(orgId: number) {
const org = await prisma.org.findUnique({
where: {
id: orgId,
},
});
export const getSubscriptionCheckoutRedirect = async (domain: string) =>
withAuth((session) =>
withOrgMembership(session, domain, async (orgId) => {
const org = await prisma.org.findUnique({
where: {
id: orgId,
},
});
if (!org || !org.stripeCustomerId) {
return notFound();
}
if (!org || !org.stripeCustomerId) {
return notFound();
}
const existingStripeSubscription = await fetchSubscription(orgId);
const origin = (await headers()).get('origin')
const prices = await stripe.prices.list({
product: 'prod_RkeYDKNFsZJROd',
expand: ['data.product'],
});
const createNewSubscription = async () => {
const stripeSession = await stripe.checkout.sessions.create({
customer: org.stripeCustomerId as string,
payment_method_types: ['card'],
line_items: [
{
price: prices.data[0].id,
quantity: 1
const orgMembers = await prisma.userToOrg.findMany({
where: {
orgId,
},
select: {
userId: true,
}
],
mode: 'subscription',
payment_method_collection: 'always',
success_url: `${origin}/settings/billing`,
cancel_url: `${origin}`,
});
});
const numOrgMembers = orgMembers.length;
return stripeSession.url;
}
const origin = (await headers()).get('origin')
const prices = await stripe.prices.list({
product: 'prod_RkeYDKNFsZJROd',
expand: ['data.product'],
});
const createNewSubscription = async () => {
const stripeSession = await stripe.checkout.sessions.create({
customer: org.stripeCustomerId as string,
payment_method_types: ['card'],
line_items: [
{
price: prices.data[0].id,
quantity: numOrgMembers
}
],
mode: 'subscription',
payment_method_collection: 'always',
success_url: `${origin}/${domain}/settings/billing`,
cancel_url: `${origin}/${domain}`,
});
// If we don't have an existing stripe subscription
if (isServiceError(existingStripeSubscription)) {
const checkoutUrl = await createNewSubscription();
return checkoutUrl;
} else {
if (existingStripeSubscription.status === "cancelled") {
const checkoutUrl = await createNewSubscription();
return checkoutUrl;
}
return stripeSession.url;
}
const portalSession = await stripe.billingPortal.sessions.create({
customer: org.stripeCustomerId as string,
return_url: `${origin}/settings/billing`,
});
return portalSession.url;
}
}
const newSubscriptionUrl = await createNewSubscription();
return newSubscriptionUrl;
})
)
export async function fetchStripeSession(sessionId: string) {
const stripeSession = await stripe.checkout.sessions.retrieve(sessionId);
@ -521,10 +551,7 @@ export async function fetchSubscription(orgId: number) {
})
if (subscriptions.data.length === 0) {
return {
status: "no_subscription",
message: "No subscription found for this organization"
}
return notFound();
}
return subscriptions.data[0];
}

View file

@ -1,25 +1,23 @@
"use client"
import { Button } from "@/components/ui/button"
import { getSubscriptionCheckoutRedirect} from "@/actions"
import { getSubscriptionCheckoutRedirect } from "@/actions"
import { isServiceError } from "@/lib/utils"
export function CheckoutButton({ orgId }: { orgId: number }) {
export function CheckoutButton({ domain }: { domain: string }) {
const redirectToCheckout = async () => {
const redirectUrl = await getSubscriptionCheckoutRedirect(orgId)
if(isServiceError(redirectUrl)) {
const redirectUrl = await getSubscriptionCheckoutRedirect(domain)
if (isServiceError(redirectUrl)) {
console.error("Failed to create checkout session")
return
}
window.location.href = redirectUrl!;
}
return (
<div>
<Button className="w-full" onClick={redirectToCheckout}>Choose Pqweqwro</Button>
</div>
<Button className="w-full" onClick={redirectToCheckout}>Renew Membership</Button>
)
}

View file

@ -1,71 +1,93 @@
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Check } from "lucide-react"
import { EnterpriseContactUsButton } from "./enterpriseContactUsButton"
import { CheckoutButton } from "./checkoutButton"
import Image from "next/image";
import logoDark from "@/public/sb_logo_dark_large.png";
import logoLight from "@/public/sb_logo_light_large.png";
const proFeatures = [
"Unlimited projects",
"Priority support",
"Advanced analytics",
"Custom integrations",
"Team collaboration tools",
]
const enterpriseFeatures = [
"All Pro features",
"Dedicated account manager",
"Custom SLA",
"On-premise deployment option",
"Advanced security features",
]
const teamFeatures = [
"Index hundreds of repos from multiple code hosts (GitHub, GitLab, Gerrit, Gitea, etc.). Self-hosted code sources supported",
"Public and private repos supported",
"Create sharable links to code snippets",
"9x5 email support team@sourcebot.dev",
]
export function PaywallCard({ orgId }: { orgId: number }) {
const enterpriseFeatures = [
"All Team features",
"Dedicated Slack support channel",
"Single tenant deployment",
"Advanced security features",
]
export async function PaywallCard({ domain }: { domain: string }) {
return (
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Team</CardTitle>
<CardDescription>For professional developers and small teams</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">$10</p>
<p className="text-sm text-muted-foreground">per user / month</p>
<ul className="mt-4 space-y-2">
{proFeatures.map((feature, index) => (
<li key={index} className="flex items-center">
<Check className="mr-2 h-4 w-4 text-green-500" />
{feature}
</li>
))}
</ul>
</CardContent>
<CardFooter>
<CheckoutButton orgId={orgId} />
</CardFooter>
</Card>
<Card>
<CardHeader>
<CardTitle>Enterprise</CardTitle>
<CardDescription>For large organizations with custom needs</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">Custom</p>
<p className="text-sm text-muted-foreground">tailored to your needs</p>
<ul className="mt-4 space-y-2">
{enterpriseFeatures.map((feature, index) => (
<li key={index} className="flex items-center">
<Check className="mr-2 h-4 w-4 text-green-500" />
{feature}
</li>
))}
</ul>
</CardContent>
<CardFooter>
<EnterpriseContactUsButton />
</CardFooter>
</Card>
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="max-h-44 w-auto mb-4 flex justify-center">
<Image
src={logoDark}
className="h-18 md:h-40 w-auto hidden dark:block"
alt={"Sourcebot logo"}
priority={true}
/>
<Image
src={logoLight}
className="h-18 md:h-40 w-auto block dark:hidden"
alt={"Sourcebot logo"}
priority={true}
/>
</div>
<h2 className="text-3xl font-bold text-center mb-8 text-primary">
Your subscription has expired.
</h2>
<div className="grid gap-8 md:grid-cols-2">
<Card className="border-2 border-primary/20 shadow-lg transition-all duration-300 hover:shadow-xl hover:border-primary/50 flex flex-col">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-primary">Team</CardTitle>
<CardDescription className="text-base">For professional developers and small teams</CardDescription>
</CardHeader>
<CardContent className="flex-grow">
<div className="mb-4">
<p className="text-4xl font-bold text-primary">$10</p>
<p className="text-sm text-muted-foreground">per user / month</p>
</div>
<ul className="space-y-3">
{teamFeatures.map((feature, index) => (
<li key={index} className="flex items-center">
<Check className="mr-3 h-5 w-5 text-green-500 flex-shrink-0" />
<span>{feature}</span>
</li>
))}
</ul>
</CardContent>
<CardFooter>
<CheckoutButton domain={domain} />
</CardFooter>
</Card>
<Card className="border-2 border-primary/20 shadow-lg transition-all duration-300 hover:shadow-xl hover:border-primary/50 flex flex-col">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-primary">Enterprise</CardTitle>
<CardDescription className="text-base">For large organizations with custom needs</CardDescription>
</CardHeader>
<CardContent className="flex-grow">
<div className="mb-4">
<p className="text-4xl font-bold text-primary">Custom</p>
<p className="text-sm text-muted-foreground">tailored to your needs</p>
</div>
<ul className="space-y-3">
{enterpriseFeatures.map((feature, index) => (
<li key={index} className="flex items-center">
<Check className="mr-3 h-5 w-5 text-green-500 flex-shrink-0" />
<span>{feature}</span>
</li>
))}
</ul>
</CardContent>
<CardFooter>
<EnterpriseContactUsButton />
</CardFooter>
</Card>
</div>
</div>
)
}

View file

@ -2,6 +2,11 @@ import { prisma } from "@/prisma";
import { PageNotFound } from "./components/pageNotFound";
import { auth } from "@/auth";
import { getOrgFromDomain } from "@/data/org";
import { fetchSubscription } from "@/actions";
import { isServiceError } from "@/lib/utils";
import { PaywallCard } from "./components/payWall/paywallCard";
import { NavigationMenu } from "./components/navigationMenu";
import { Footer } from "./components/footer";
interface LayoutProps {
children: React.ReactNode,
@ -38,5 +43,18 @@ export default async function Layout({
return <PageNotFound />
}
const subscription = await fetchSubscription(org.id);
if (isServiceError(subscription) || (subscription.status !== "active" && subscription.status !== "trialing")) {
return (
<div className="flex flex-col items-center overflow-hidden min-h-screen">
<NavigationMenu
domain={domain}
/>
<PaywallCard domain={domain} />
<Footer />
</div>
)
}
return children;
}

View file

@ -13,6 +13,7 @@ import { UpgradeToast } from "./components/upgradeToast";
import Link from "next/link";
import { getOrgFromDomain } from "@/data/org";
import { PageNotFound } from "./components/pageNotFound";
import { Footer } from "./components/footer";
export default async function Home({ params: { domain } }: { params: { domain: string } }) {
@ -109,13 +110,7 @@ export default async function Home({ params: { domain } }: { params: { domain: s
</div>
</div>
</div>
<footer className="w-full mt-auto py-4 flex flex-row justify-center items-center gap-4">
<Link href="https://sourcebot.dev" className="text-gray-400 text-sm hover:underline">About</Link>
<Separator orientation="vertical" className="h-4" />
<Link href="https://github.com/sourcebot-dev/sourcebot/issues/new" className="text-gray-400 text-sm hover:underline">Support</Link>
<Separator orientation="vertical" className="h-4" />
<Link href="mailto:team@sourcebot.dev" className="text-gray-400 text-sm hover:underline">Contact Us</Link>
</footer>
<Footer />
</div>
)
}

View file

@ -6,6 +6,8 @@ import { AcceptInviteButton } from "./components/acceptInviteButton"
import Image from "next/image";
import logoDark from "@/public/sb_logo_dark_large.png";
import logoLight from "@/public/sb_logo_light_large.png";
import { fetchSubscription } from "@/actions";
import { isServiceError } from "@/lib/utils";
interface RedeemPageProps {
searchParams?: {
@ -60,34 +62,6 @@ export default async function RedeemPage({ searchParams }: RedeemPageProps) {
if (user.email !== invite.recipientEmail) {
return (
<div className="flex flex-col justify-center items-center mt-8 mb-8 md:mt-18 w-full px-5">
<div className="max-h-44 w-auto mb-4">
<Image
src={logoDark}
className="h-18 md:h-40 w-auto hidden dark:block"
alt={"Sourcebot logo"}
priority={true}
/>
<Image
src={logoLight}
className="h-18 md:h-40 w-auto block dark:hidden"
alt={"Sourcebot logo"}
priority={true}
/>
</div>
<div className="flex justify-center items-center">
<h1>This invite doesn't belong to you. You're currenly signed in with ${user.email}</h1>
</div>
</div>
)
} else {
const orgName = await prisma.org.findUnique({
where: { id: invite.orgId },
select: { name: true },
});
if (!orgName) {
return (
<div className="flex flex-col justify-center items-center mt-8 mb-8 md:mt-18 w-full px-5">
<div className="max-h-44 w-auto mb-4">
<Image
src={logoDark}
@ -103,16 +77,87 @@ export default async function RedeemPage({ searchParams }: RedeemPageProps) {
/>
</div>
<div className="flex justify-center items-center">
<h1>This organization wasn't found. Please contact your organization owner.</h1>
<h1>This invite doesn't belong to you. You're currenly signed in with ${user.email}</h1>
</div>
</div>
)
} else {
const org = await prisma.org.findUnique({
where: { id: invite.orgId },
});
if (!org) {
return (
<div className="flex flex-col justify-center items-center mt-8 mb-8 md:mt-18 w-full px-5">
<div className="max-h-44 w-auto mb-4">
<Image
src={logoDark}
className="h-18 md:h-40 w-auto hidden dark:block"
alt={"Sourcebot logo"}
priority={true}
/>
<Image
src={logoLight}
className="h-18 md:h-40 w-auto block dark:hidden"
alt={"Sourcebot logo"}
priority={true}
/>
</div>
<div className="flex justify-center items-center">
<h1>This organization wasn't found. Please contact your organization owner.</h1>
</div>
</div>
)
}
const stripeCustomerId = org.stripeCustomerId;
if (stripeCustomerId) {
const subscription = await fetchSubscription(org.id);
console.log(org);
console.log(subscription);
if (isServiceError(subscription)) {
return (
<div className="flex flex-col justify-center items-center mt-8 mb-8 md:mt-18 w-full px-5">
<div className="max-h-44 w-auto mb-4">
<Image
src={logoDark}
className="h-18 md:h-40 w-auto hidden dark:block"
alt={"Sourcebot logo"}
priority={true}
/>
<Image
src={logoLight}
className="h-18 md:h-40 w-auto block dark:hidden"
alt={"Sourcebot logo"}
priority={true}
/>
</div>
<div className="flex justify-center items-center">
<h1>This organization's subscription has expired. Please renew the subscription and try again.</h1>
</div>
</div>
)
}
}
return (
<div>
<div className="flex justify-between items-center h-screen px-6">
<h1 className="text-2xl font-bold">You have been invited to org {orgName.name}</h1>
<div className="flex flex-col justify-center items-center mt-8 mb-8 md:mt-18 w-full px-5">
<div className="max-h-44 w-auto mb-4">
<Image
src={logoDark}
className="h-18 md:h-40 w-auto hidden dark:block"
alt={"Sourcebot logo"}
priority={true}
/>
<Image
src={logoLight}
className="h-18 md:h-40 w-auto block dark:hidden"
alt={"Sourcebot logo"}
priority={true}
/>
</div>
<div className="flex justify-between items-center w-full max-w-2xl">
<h1 className="text-2xl font-bold">You have been invited to org {org.name}</h1>
<AcceptInviteButton invite={invite} userId={user.id} />
</div>
</div>

View file

@ -9,4 +9,5 @@ export enum ErrorCode {
NOT_FOUND = 'NOT_FOUND',
CONNECTION_SYNC_ALREADY_SCHEDULED = 'CONNECTION_SYNC_ALREADY_SCHEDULED',
ORG_DOMAIN_ALREADY_EXISTS = 'ORG_DOMAIN_ALREADY_EXISTS',
ORG_INVALID_SUBSCRIPTION = 'ORG_INVALID_SUBSCRIPTION',
}

View file

@ -91,4 +91,12 @@ export const orgDomainExists = (): ServiceError => {
errorCode: ErrorCode.ORG_DOMAIN_ALREADY_EXISTS,
message: "Organization domain already exists, please try a different one.",
}
}
export const orgInvalidSubscription = (): ServiceError => {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.ORG_INVALID_SUBSCRIPTION,
message: "Invalid subscription",
}
}