wip add paywall

This commit is contained in:
msukkari 2025-02-12 13:03:31 -08:00
parent 3ad6c2de48
commit 0a79f7ca80
10 changed files with 273 additions and 30 deletions

View file

@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "Org" ADD COLUMN "stripeSessionId" TEXT;

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Org" ADD COLUMN "stripeCustomerId" TEXT;

View file

@ -105,20 +105,20 @@ model Invite {
} }
model Org { model Org {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
domain String @unique domain String @unique
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
members UserToOrg[] members UserToOrg[]
connections Connection[] connections Connection[]
repos Repo[] repos Repo[]
secrets Secret[] secrets Secret[]
stripeSessionId String? stripeCustomerId String?
/// List of pending invites to this organization /// List of pending invites to this organization
invites Invite[] invites Invite[]
} }
enum OrgRole { enum OrgRole {

View file

@ -98,7 +98,7 @@ export const checkIfOrgDomainExists = async (domain: string): Promise<boolean> =
return !!org; return !!org;
} }
export const createOrg = async (name: string, domain: string, stripeSessionId?: string): Promise<{ id: number } | ServiceError> => { export const createOrg = async (name: string, domain: string, stripeCustomerId?: string): Promise<{ id: number } | ServiceError> => {
const session = await auth(); const session = await auth();
if (!session) { if (!session) {
return notAuthenticated(); return notAuthenticated();
@ -119,7 +119,7 @@ export const createOrg = async (name: string, domain: string, stripeSessionId?:
data: { data: {
name, name,
domain, domain,
stripeSessionId, stripeCustomerId,
members: { members: {
create: { create: {
userId: session.user.id, userId: session.user.id,
@ -393,14 +393,23 @@ export async function fetchStripeClientSecret(name: string, domain: string) {
const origin = (await headers()).get('origin') const origin = (await headers()).get('origin')
// Create Checkout Sessions from body params. const test_clock = await stripe.testHelpers.testClocks.create({
frozen_time: Math.floor(Date.now() / 1000)
})
const customer = await stripe.customers.create({
name: user.name!,
email: user.email!,
test_clock: test_clock.id
})
const prices = await stripe.prices.list({ const prices = await stripe.prices.list({
product: 'prod_RkeYDKNFsZJROd', product: 'prod_RkeYDKNFsZJROd',
expand: ['data.product'], expand: ['data.product'],
}); });
const stripeSession = await stripe.checkout.sessions.create({ const stripeSession = await stripe.checkout.sessions.create({
ui_mode: 'embedded', ui_mode: 'embedded',
customer: customer.id,
line_items: [ line_items: [
{ {
price: prices.data[0].id, price: prices.data[0].id,
@ -411,7 +420,6 @@ export async function fetchStripeClientSecret(name: string, domain: string) {
subscription_data: { subscription_data: {
trial_period_days: 7, trial_period_days: 7,
}, },
customer_email: user.email!,
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}`,
}) })
@ -419,6 +427,64 @@ export async function fetchStripeClientSecret(name: string, domain: string) {
return stripeSession.client_secret!; return stripeSession.client_secret!;
} }
export async function getSubscriptionCheckoutRedirect(orgId: number) {
const org = await prisma.org.findUnique({
where: {
id: orgId,
},
});
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
}
],
mode: 'subscription',
payment_method_collection: 'always',
success_url: `${origin}/settings/billing`,
cancel_url: `${origin}`,
});
return stripeSession.url;
}
// 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;
}
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);
return stripeSession; return stripeSession;
@ -436,16 +502,39 @@ export async function createCustomerPortalSession() {
}, },
}); });
if (!org || !org.stripeSessionId) { if (!org || !org.stripeCustomerId) {
return notFound(); return notFound();
} }
const origin = (await headers()).get('origin') const origin = (await headers()).get('origin')
const stripeSession = await fetchStripeSession(org.stripeSessionId);
const portalSession = await stripe.billingPortal.sessions.create({ const portalSession = await stripe.billingPortal.sessions.create({
customer: stripeSession.customer as string, customer: org.stripeCustomerId as string,
return_url: `${origin}/settings/billing`, return_url: `${origin}/settings/billing`,
}); });
return portalSession; return portalSession;
}
export async function fetchSubscription(orgId: number) {
const org = await prisma.org.findUnique({
where: {
id: orgId,
},
});
if (!org || !org.stripeCustomerId) {
return notFound();
}
const subscriptions = await stripe.subscriptions.list({
customer: org.stripeCustomerId!
})
if (subscriptions.data.length === 0) {
return {
status: "no_subscription",
message: "No subscription found for this organization"
}
}
return subscriptions.data[0];
} }

View file

@ -0,0 +1,25 @@
"use client"
import { Button } from "@/components/ui/button"
import { getSubscriptionCheckoutRedirect} from "@/actions"
import { isServiceError } from "@/lib/utils"
export function CheckoutButton({ orgId }: { orgId: number }) {
const redirectToCheckout = async () => {
const redirectUrl = await getSubscriptionCheckoutRedirect(orgId)
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>
)
}

View file

@ -0,0 +1,15 @@
"use client"
import { Button } from "@/components/ui/button"
export function EnterpriseContactUsButton() {
const handleContactUs = () => {
window.location.href = "mailto:team@sourcebot.dev?subject=Enterprise%20Pricing%20Inquiry"
}
return (
<Button className="w-full" onClick={handleContactUs}>
Contact Us
</Button>
)
}

View file

@ -0,0 +1,71 @@
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"
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",
]
export function PaywallCard({ orgId }: { orgId: number }) {
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>
)
}

View file

@ -10,8 +10,10 @@ import { getCurrentUserOrg } from "@/auth";
import { isServiceError } from "@/lib/utils"; import { isServiceError } from "@/lib/utils";
import { NavigationMenu } from "./components/navigationMenu"; import { NavigationMenu } from "./components/navigationMenu";
import { NoOrganizationCard } from "./components/noOrganizationCard"; import { NoOrganizationCard } from "./components/noOrganizationCard";
import { PaywallCard } from "./components/payWall/paywallCard";
import { Footer } from "./components/footer"; import { Footer } from "./components/footer";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { fetchSubscription } from "@/actions";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Sourcebot", title: "Sourcebot",
@ -24,7 +26,10 @@ export default async function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
const orgId = await getCurrentUserOrg(); const orgId = await getCurrentUserOrg();
console.log(`orgId: ${orgId}`);
const byPassOrgCheck = (await headers()).get("x-bypass-org-check")! == "true"; const byPassOrgCheck = (await headers()).get("x-bypass-org-check")! == "true";
console.log(`bypassOrgCheck: ${byPassOrgCheck}`);
if (isServiceError(orgId) && !byPassOrgCheck) { if (isServiceError(orgId) && !byPassOrgCheck) {
return ( return (
<html <html
@ -33,19 +38,54 @@ export default async function RootLayout({
suppressHydrationWarning suppressHydrationWarning
> >
<body> <body>
<div className="flex flex-col items-center overflow-hidden min-h-screen"> <div className="flex flex-col items-center overflow-hidden min-h-screen">
<SessionProvider> <SessionProvider>
<NavigationMenu /> <NavigationMenu />
<NoOrganizationCard /> <NoOrganizationCard />
<Footer /> <Footer />
</SessionProvider> </SessionProvider>
</div> </div>
) )
</body> </body>
</html> </html>
) )
} }
const bypassPaywall = (await headers()).get("x-bypass-paywall")! == "true";
console.log(bypassPaywall);
if (!isServiceError(orgId) && !bypassPaywall) {
const subscription = await fetchSubscription(orgId as number);
if (isServiceError(subscription)) {
// TODO: display something better here
return (
<div className="mt-8 text-red-500">
Error: {subscription.message}
</div>
)
}
console.log(subscription.status);
if(subscription.status !== "active" && subscription.status !== "trialing") {
return (
<html
lang="en"
// @see : https://github.com/pacocoursey/next-themes?tab=readme-ov-file#with-app
suppressHydrationWarning
>
<body>
<div className="flex flex-col items-center overflow-hidden min-h-screen">
<SessionProvider>
<NavigationMenu />
<PaywallCard orgId={orgId}/>
<Footer />
</SessionProvider>
</div>
</body>
</html>
)
}
}
return ( return (
<html <html
lang="en" lang="en"

View file

@ -40,7 +40,8 @@ export default async function OnboardComplete({ searchParams }: OnboardCompleteP
return <ErrorPage />; return <ErrorPage />;
} }
const res = await createOrg(orgName, orgDomain, stripeSession.id); const stripeCustomerId = stripeSession.customer as string;
const res = await createOrg(orgName, orgDomain, stripeCustomerId);
if (isServiceError(res)) { if (isServiceError(res)) {
console.error("Failed to create org"); console.error("Failed to create org");
return <ErrorPage />; return <ErrorPage />;

View file

@ -28,8 +28,10 @@ const defaultMiddleware = (req: NextAuthRequest) => {
// belong to an org. It seems like the easiest way to do this is to check for these paths here and pass in a flag to the root layout using the headers // belong to an org. It seems like the easiest way to do this is to check for these paths here and pass in a flag to the root layout using the headers
// https://github.com/vercel/next.js/discussions/43657#discussioncomment-5981981 // https://github.com/vercel/next.js/discussions/43657#discussioncomment-5981981
const bypassOrgCheck = req.nextUrl.pathname === "/login" || req.nextUrl.pathname === "/redeem" || req.nextUrl.pathname.includes("onboard"); const bypassOrgCheck = req.nextUrl.pathname === "/login" || req.nextUrl.pathname === "/redeem" || req.nextUrl.pathname.includes("onboard");
const bypassPaywall = req.nextUrl.pathname === "/login" || req.nextUrl.pathname === "/redeem" || req.nextUrl.pathname.includes("onboard") || req.nextUrl.pathname.includes("settings");
const requestheaders = new Headers(req.headers); const requestheaders = new Headers(req.headers);
requestheaders.set("x-bypass-org-check", bypassOrgCheck.toString()); requestheaders.set("x-bypass-org-check", bypassOrgCheck.toString());
requestheaders.set("x-bypass-paywall", bypassPaywall.toString());
// if we're trying to redeem an invite while not authed we continue to the redeem page so // if we're trying to redeem an invite while not authed we continue to the redeem page so
// that we can pipe the invite_id to the login page // that we can pipe the invite_id to the login page