From 0a79f7ca80c175c5634bd4c0654f188568fe7d37 Mon Sep 17 00:00:00 2001 From: msukkari Date: Wed, 12 Feb 2025 13:03:31 -0800 Subject: [PATCH] wip add paywall --- .../migration.sql | 2 - .../migration.sql | 2 + packages/db/prisma/schema.prisma | 22 ++-- packages/web/src/actions.ts | 105 ++++++++++++++++-- .../app/components/payWall/checkoutButton.tsx | 25 +++++ .../payWall/enterpriseContactUsButton.tsx | 15 +++ .../app/components/payWall/paywallCard.tsx | 71 ++++++++++++ packages/web/src/app/layout.tsx | 56 ++++++++-- .../web/src/app/onboard/complete/page.tsx | 3 +- packages/web/src/middleware.ts | 2 + 10 files changed, 273 insertions(+), 30 deletions(-) delete mode 100644 packages/db/prisma/migrations/20250212024846_add_stripe_session_id_to_org/migration.sql create mode 100644 packages/db/prisma/migrations/20250212185343_add_stripe_customer_id_to_org/migration.sql create mode 100644 packages/web/src/app/components/payWall/checkoutButton.tsx create mode 100644 packages/web/src/app/components/payWall/enterpriseContactUsButton.tsx create mode 100644 packages/web/src/app/components/payWall/paywallCard.tsx diff --git a/packages/db/prisma/migrations/20250212024846_add_stripe_session_id_to_org/migration.sql b/packages/db/prisma/migrations/20250212024846_add_stripe_session_id_to_org/migration.sql deleted file mode 100644 index 9bea1db1..00000000 --- a/packages/db/prisma/migrations/20250212024846_add_stripe_session_id_to_org/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "Org" ADD COLUMN "stripeSessionId" TEXT; diff --git a/packages/db/prisma/migrations/20250212185343_add_stripe_customer_id_to_org/migration.sql b/packages/db/prisma/migrations/20250212185343_add_stripe_customer_id_to_org/migration.sql new file mode 100644 index 00000000..e475caf3 --- /dev/null +++ b/packages/db/prisma/migrations/20250212185343_add_stripe_customer_id_to_org/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Org" ADD COLUMN "stripeCustomerId" TEXT; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index d01d86af..03fdd39a 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -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 { diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 89d0d58a..d303857f 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -98,7 +98,7 @@ export const checkIfOrgDomainExists = async (domain: string): Promise = 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]; } \ No newline at end of file diff --git a/packages/web/src/app/components/payWall/checkoutButton.tsx b/packages/web/src/app/components/payWall/checkoutButton.tsx new file mode 100644 index 00000000..db343f23 --- /dev/null +++ b/packages/web/src/app/components/payWall/checkoutButton.tsx @@ -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 ( +
+ +
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/components/payWall/enterpriseContactUsButton.tsx b/packages/web/src/app/components/payWall/enterpriseContactUsButton.tsx new file mode 100644 index 00000000..1fd6bf08 --- /dev/null +++ b/packages/web/src/app/components/payWall/enterpriseContactUsButton.tsx @@ -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 ( + + ) +} \ No newline at end of file diff --git a/packages/web/src/app/components/payWall/paywallCard.tsx b/packages/web/src/app/components/payWall/paywallCard.tsx new file mode 100644 index 00000000..c222f3a4 --- /dev/null +++ b/packages/web/src/app/components/payWall/paywallCard.tsx @@ -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 ( +
+ + + Team + For professional developers and small teams + + +

$10

+

per user / month

+
    + {proFeatures.map((feature, index) => ( +
  • + + {feature} +
  • + ))} +
+
+ + + +
+ + + Enterprise + For large organizations with custom needs + + +

Custom

+

tailored to your needs

+
    + {enterpriseFeatures.map((feature, index) => ( +
  • + + {feature} +
  • + ))} +
+
+ + + +
+
+ ) +} + diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx index 886aaa7f..c9c82221 100644 --- a/packages/web/src/app/layout.tsx +++ b/packages/web/src/app/layout.tsx @@ -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 ( -
- - - -
- -
- ) +
+ + + +
+ +
+ ) ) } + 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 ( +
+ Error: {subscription.message} +
+ ) + } + console.log(subscription.status); + + if(subscription.status !== "active" && subscription.status !== "trialing") { + return ( + + +
+ + + +
+ +
+ + + ) + } + } + return ( ; } - 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 ; diff --git a/packages/web/src/middleware.ts b/packages/web/src/middleware.ts index bc6c77f7..88f7bd74 100644 --- a/packages/web/src/middleware.ts +++ b/packages/web/src/middleware.ts @@ -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