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 {
id Int @id @default(autoincrement())
name String
domain String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
members UserToOrg[]
connections Connection[]
repos Repo[]
secrets Secret[]
id Int @id @default(autoincrement())
name String
domain String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
members UserToOrg[]
connections Connection[]
repos Repo[]
secrets Secret[]
stripeSessionId String?
stripeCustomerId String?
/// List of pending invites to this organization
invites Invite[]
invites Invite[]
}
enum OrgRole {

View file

@ -98,7 +98,7 @@ export const checkIfOrgDomainExists = async (domain: string): Promise<boolean> =
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();
if (!session) {
return notAuthenticated();
@ -119,7 +119,7 @@ export const createOrg = async (name: string, domain: string, stripeSessionId?:
data: {
name,
domain,
stripeSessionId,
stripeCustomerId,
members: {
create: {
userId: session.user.id,
@ -393,14 +393,23 @@ export async function fetchStripeClientSecret(name: string, domain: string) {
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({
product: 'prod_RkeYDKNFsZJROd',
expand: ['data.product'],
});
const stripeSession = await stripe.checkout.sessions.create({
ui_mode: 'embedded',
customer: customer.id,
line_items: [
{
price: prices.data[0].id,
@ -411,7 +420,6 @@ export async function fetchStripeClientSecret(name: string, domain: string) {
subscription_data: {
trial_period_days: 7,
},
customer_email: user.email!,
payment_method_collection: 'if_required',
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!;
}
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) {
const stripeSession = await stripe.checkout.sessions.retrieve(sessionId);
return stripeSession;
@ -436,16 +502,39 @@ export async function createCustomerPortalSession() {
},
});
if (!org || !org.stripeSessionId) {
if (!org || !org.stripeCustomerId) {
return notFound();
}
const origin = (await headers()).get('origin')
const stripeSession = await fetchStripeSession(org.stripeSessionId);
const portalSession = await stripe.billingPortal.sessions.create({
customer: stripeSession.customer as string,
customer: org.stripeCustomerId as string,
return_url: `${origin}/settings/billing`,
});
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 { NavigationMenu } from "./components/navigationMenu";
import { NoOrganizationCard } from "./components/noOrganizationCard";
import { PaywallCard } from "./components/payWall/paywallCard";
import { Footer } from "./components/footer";
import { headers } from "next/headers";
import { fetchSubscription } from "@/actions";
export const metadata: Metadata = {
title: "Sourcebot",
@ -24,7 +26,10 @@ export default async function RootLayout({
children: React.ReactNode;
}>) {
const orgId = await getCurrentUserOrg();
console.log(`orgId: ${orgId}`);
const byPassOrgCheck = (await headers()).get("x-bypass-org-check")! == "true";
console.log(`bypassOrgCheck: ${byPassOrgCheck}`);
if (isServiceError(orgId) && !byPassOrgCheck) {
return (
<html
@ -33,19 +38,54 @@ export default async function RootLayout({
suppressHydrationWarning
>
<body>
<div className="flex flex-col items-center overflow-hidden min-h-screen">
<SessionProvider>
<NavigationMenu />
<NoOrganizationCard />
<Footer />
</SessionProvider>
</div>
)
<div className="flex flex-col items-center overflow-hidden min-h-screen">
<SessionProvider>
<NavigationMenu />
<NoOrganizationCard />
<Footer />
</SessionProvider>
</div>
)
</body>
</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 (
<html
lang="en"

View file

@ -40,7 +40,8 @@ export default async function OnboardComplete({ searchParams }: OnboardCompleteP
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)) {
console.error("Failed to create org");
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
// 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 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);
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
// that we can pipe the invite_id to the login page