mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 20:35:24 +00:00
wip add paywall
This commit is contained in:
parent
3ad6c2de48
commit
0a79f7ca80
10 changed files with 273 additions and 30 deletions
|
|
@ -1,2 +0,0 @@
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "Org" ADD COLUMN "stripeSessionId" TEXT;
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Org" ADD COLUMN "stripeCustomerId" TEXT;
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
}
|
}
|
||||||
25
packages/web/src/app/components/payWall/checkoutButton.tsx
Normal file
25
packages/web/src/app/components/payWall/checkoutButton.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
71
packages/web/src/app/components/payWall/paywallCard.tsx
Normal file
71
packages/web/src/app/components/payWall/paywallCard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 />;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue