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,7 +455,9 @@ 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) =>
withAuth((session) =>
withOrgMembership(session, domain, async (orgId) => {
const org = await prisma.org.findUnique({ const org = await prisma.org.findUnique({
where: { where: {
id: orgId, id: orgId,
@ -431,7 +468,15 @@ export async function getSubscriptionCheckoutRedirect(orgId: number) {
return notFound(); return notFound();
} }
const existingStripeSubscription = await fetchSubscription(orgId); const orgMembers = await prisma.userToOrg.findMany({
where: {
orgId,
},
select: {
userId: true,
}
});
const numOrgMembers = orgMembers.length;
const origin = (await headers()).get('origin') const origin = (await headers()).get('origin')
const prices = await stripe.prices.list({ const prices = await stripe.prices.list({
@ -446,37 +491,22 @@ export async function getSubscriptionCheckoutRedirect(orgId: number) {
line_items: [ line_items: [
{ {
price: prices.data[0].id, price: prices.data[0].id,
quantity: 1 quantity: numOrgMembers
} }
], ],
mode: 'subscription', mode: 'subscription',
payment_method_collection: 'always', payment_method_collection: 'always',
success_url: `${origin}/settings/billing`, success_url: `${origin}/${domain}/settings/billing`,
cancel_url: `${origin}`, cancel_url: `${origin}/${domain}`,
}); });
return stripeSession.url; return stripeSession.url;
} }
const newSubscriptionUrl = await createNewSubscription();
// If we don't have an existing stripe subscription return newSubscriptionUrl;
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({
customer: org.stripeCustomerId as string,
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

@ -5,9 +5,9 @@ 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")
@ -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,62 +1,84 @@
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"
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> </CardHeader>
<CardContent> <CardContent className="flex-grow">
<p className="text-3xl font-bold">$10</p> <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> <p className="text-sm text-muted-foreground">per user / month</p>
<ul className="mt-4 space-y-2"> </div>
{proFeatures.map((feature, index) => ( <ul className="space-y-3">
{teamFeatures.map((feature, index) => (
<li key={index} className="flex items-center"> <li key={index} className="flex items-center">
<Check className="mr-2 h-4 w-4 text-green-500" /> <Check className="mr-3 h-5 w-5 text-green-500 flex-shrink-0" />
{feature} <span>{feature}</span>
</li> </li>
))} ))}
</ul> </ul>
</CardContent> </CardContent>
<CardFooter> <CardFooter>
<CheckoutButton orgId={orgId} /> <CheckoutButton domain={domain} />
</CardFooter> </CardFooter>
</Card> </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> <CardHeader className="space-y-1">
<CardTitle>Enterprise</CardTitle> <CardTitle className="text-2xl font-bold text-primary">Enterprise</CardTitle>
<CardDescription>For large organizations with custom needs</CardDescription> <CardDescription className="text-base">For large organizations with custom needs</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="flex-grow">
<p className="text-3xl font-bold">Custom</p> <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> <p className="text-sm text-muted-foreground">tailored to your needs</p>
<ul className="mt-4 space-y-2"> </div>
<ul className="space-y-3">
{enterpriseFeatures.map((feature, index) => ( {enterpriseFeatures.map((feature, index) => (
<li key={index} className="flex items-center"> <li key={index} className="flex items-center">
<Check className="mr-2 h-4 w-4 text-green-500" /> <Check className="mr-3 h-5 w-5 text-green-500 flex-shrink-0" />
{feature} <span>{feature}</span>
</li> </li>
))} ))}
</ul> </ul>
@ -66,6 +88,6 @@ export function PaywallCard({ orgId }: { orgId: number }) {
</CardFooter> </CardFooter>
</Card> </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?: {
@ -80,12 +82,11 @@ export default async function RedeemPage({ searchParams }: RedeemPageProps) {
</div> </div>
) )
} else { } else {
const orgName = await prisma.org.findUnique({ const org = await prisma.org.findUnique({
where: { id: invite.orgId }, where: { id: invite.orgId },
select: { name: true },
}); });
if (!orgName) { if (!org) {
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"> <div className="max-h-44 w-auto mb-4">
@ -109,10 +110,54 @@ export default async function RedeemPage({ searchParams }: RedeemPageProps) {
) )
} }
const stripeCustomerId = org.stripeCustomerId;
if (stripeCustomerId) {
const subscription = await fetchSubscription(org.id);
console.log(org);
console.log(subscription);
if (isServiceError(subscription)) {
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-center items-center">
<h1>This organization's subscription has expired. Please renew the subscription and try again.</h1>
</div>
</div>
)
}
}
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-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",
}
}