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'; 'use server';
import Ajv from "ajv"; import Ajv from "ajv";
import { auth, getCurrentUserOrg } from "./auth"; import { auth } from "./auth";
import { notAuthenticated, notFound, ServiceError, unexpectedError, orgDomainExists } from "@/lib/serviceError"; import { notAuthenticated, notFound, ServiceError, unexpectedError, orgInvalidSubscription } from "@/lib/serviceError";
import { prisma } from "@/prisma"; import { prisma } from "@/prisma";
import { StatusCodes } from "http-status-codes"; import { StatusCodes } from "http-status-codes";
import { ErrorCode } from "@/lib/errorCodes"; import { ErrorCode } from "@/lib/errorCodes";
@ -17,6 +17,7 @@ import { headers } from "next/headers"
import { stripe } from "@/lib/stripe" import { stripe } from "@/lib/stripe"
import { getUser } from "@/data/user"; import { getUser } from "@/data/user";
import { Session } from "next-auth"; import { Session } from "next-auth";
import Stripe from "stripe";
const ajv = new Ajv({ const ajv = new Ajv({
validateFormats: false, validateFormats: false,
@ -309,6 +310,35 @@ export const redeemInvite = async (invite: Invite, userId: string): Promise<{ su
withAuth(async () => { withAuth(async () => {
try { try {
await prisma.$transaction(async (tx) => { 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({ await tx.userToOrg.create({
data: { data: {
userId, userId,
@ -412,6 +442,11 @@ export async function fetchStripeClientSecret(name: string, domain: string) {
mode: 'subscription', mode: 'subscription',
subscription_data: { subscription_data: {
trial_period_days: 7, trial_period_days: 7,
trial_settings: {
end_behavior: {
missing_payment_method: 'cancel',
},
},
}, },
payment_method_collection: 'if_required', payment_method_collection: 'if_required',
return_url: `${origin}/onboard/complete?session_id={CHECKOUT_SESSION_ID}&org_name=${name}&org_domain=${domain}`, 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!; return stripeSession.client_secret!;
} }
export async function getSubscriptionCheckoutRedirect(orgId: number) { export const getSubscriptionCheckoutRedirect = async (domain: string) =>
const org = await prisma.org.findUnique({ withAuth((session) =>
where: { withOrgMembership(session, domain, async (orgId) => {
id: orgId, const org = await prisma.org.findUnique({
}, where: {
}); id: orgId,
},
});
if (!org || !org.stripeCustomerId) { if (!org || !org.stripeCustomerId) {
return notFound(); return notFound();
} }
const existingStripeSubscription = await fetchSubscription(orgId); const orgMembers = await prisma.userToOrg.findMany({
where: {
const origin = (await headers()).get('origin') orgId,
const prices = await stripe.prices.list({ },
product: 'prod_RkeYDKNFsZJROd', select: {
expand: ['data.product'], userId: true,
});
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
} }
], });
mode: 'subscription', const numOrgMembers = orgMembers.length;
payment_method_collection: 'always',
success_url: `${origin}/settings/billing`,
cancel_url: `${origin}`,
});
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 return stripeSession.url;
if (isServiceError(existingStripeSubscription)) { }
const checkoutUrl = await createNewSubscription();
return checkoutUrl;
} else {
if (existingStripeSubscription.status === "cancelled") {
const checkoutUrl = await createNewSubscription();
return checkoutUrl;
}
const portalSession = await stripe.billingPortal.sessions.create({ const newSubscriptionUrl = await createNewSubscription();
customer: org.stripeCustomerId as string, return newSubscriptionUrl;
return_url: `${origin}/settings/billing`, })
}); )
return portalSession.url;
}
}
export async function fetchStripeSession(sessionId: string) { export async function fetchStripeSession(sessionId: string) {
const stripeSession = await stripe.checkout.sessions.retrieve(sessionId); const stripeSession = await stripe.checkout.sessions.retrieve(sessionId);
@ -521,10 +551,7 @@ export async function fetchSubscription(orgId: number) {
}) })
if (subscriptions.data.length === 0) { if (subscriptions.data.length === 0) {
return { return notFound();
status: "no_subscription",
message: "No subscription found for this organization"
}
} }
return subscriptions.data[0]; return subscriptions.data[0];
} }

View file

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

View file

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

View file

@ -2,6 +2,11 @@ import { prisma } from "@/prisma";
import { PageNotFound } from "./components/pageNotFound"; import { PageNotFound } from "./components/pageNotFound";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { getOrgFromDomain } from "@/data/org"; 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 { interface LayoutProps {
children: React.ReactNode, children: React.ReactNode,
@ -38,5 +43,18 @@ export default async function Layout({
return <PageNotFound /> 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; return children;
} }

View file

@ -13,6 +13,7 @@ import { UpgradeToast } from "./components/upgradeToast";
import Link from "next/link"; import Link from "next/link";
import { getOrgFromDomain } from "@/data/org"; import { getOrgFromDomain } from "@/data/org";
import { PageNotFound } from "./components/pageNotFound"; import { PageNotFound } from "./components/pageNotFound";
import { Footer } from "./components/footer";
export default async function Home({ params: { domain } }: { params: { domain: string } }) { 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> </div>
</div> </div>
<footer className="w-full mt-auto py-4 flex flex-row justify-center items-center gap-4"> <Footer />
<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>
</div> </div>
) )
} }

View file

@ -6,6 +6,8 @@ import { AcceptInviteButton } from "./components/acceptInviteButton"
import Image from "next/image"; import Image from "next/image";
import logoDark from "@/public/sb_logo_dark_large.png"; import logoDark from "@/public/sb_logo_dark_large.png";
import logoLight from "@/public/sb_logo_light_large.png"; import logoLight from "@/public/sb_logo_light_large.png";
import { fetchSubscription } from "@/actions";
import { isServiceError } from "@/lib/utils";
interface RedeemPageProps { interface RedeemPageProps {
searchParams?: { searchParams?: {
@ -60,34 +62,6 @@ export default async function RedeemPage({ searchParams }: RedeemPageProps) {
if (user.email !== invite.recipientEmail) { if (user.email !== invite.recipientEmail) {
return ( return (
<div className="flex flex-col justify-center items-center mt-8 mb-8 md:mt-18 w-full px-5"> <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"> <div className="max-h-44 w-auto mb-4">
<Image <Image
src={logoDark} src={logoDark}
@ -103,16 +77,87 @@ export default async function RedeemPage({ searchParams }: RedeemPageProps) {
/> />
</div> </div>
<div className="flex justify-center items-center"> <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>
</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 ( return (
<div> <div className="flex flex-col justify-center items-center mt-8 mb-8 md:mt-18 w-full px-5">
<div className="flex justify-between items-center h-screen px-6"> <div className="max-h-44 w-auto mb-4">
<h1 className="text-2xl font-bold">You have been invited to org {orgName.name}</h1> <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} /> <AcceptInviteButton invite={invite} userId={user.id} />
</div> </div>
</div> </div>

View file

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

View file

@ -92,3 +92,11 @@ export const orgDomainExists = (): ServiceError => {
message: "Organization domain already exists, please try a different one.", 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",
}
}