mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 12:25:22 +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 {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
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 { 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"
|
||||
|
|
|
|||
|
|
@ -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 />;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue