From 6caed350d3e9fe6828a6b05d377ff137c49470ab Mon Sep 17 00:00:00 2001 From: msukkari Date: Wed, 12 Feb 2025 16:48:13 -0800 Subject: [PATCH] add back paywall and also add support for incrememnting seat count on invite redemption --- packages/web/src/actions.ts | 139 ++++++++++------- .../components/payWall/checkoutButton.tsx | 16 +- .../components/payWall/paywallCard.tsx | 144 ++++++++++-------- packages/web/src/app/[domain]/layout.tsx | 18 +++ packages/web/src/app/[domain]/page.tsx | 9 +- packages/web/src/app/redeem/page.tsx | 109 +++++++++---- packages/web/src/lib/errorCodes.ts | 1 + packages/web/src/lib/serviceError.ts | 8 + 8 files changed, 279 insertions(+), 165 deletions(-) diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 41583ab6..3edfa4c2 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -1,8 +1,8 @@ 'use server'; import Ajv from "ajv"; -import { auth, getCurrentUserOrg } from "./auth"; -import { notAuthenticated, notFound, ServiceError, unexpectedError, orgDomainExists } from "@/lib/serviceError"; +import { auth } from "./auth"; +import { notAuthenticated, notFound, ServiceError, unexpectedError, orgInvalidSubscription } from "@/lib/serviceError"; import { prisma } from "@/prisma"; import { StatusCodes } from "http-status-codes"; import { ErrorCode } from "@/lib/errorCodes"; @@ -17,6 +17,7 @@ import { headers } from "next/headers" import { stripe } from "@/lib/stripe" import { getUser } from "@/data/user"; import { Session } from "next-auth"; +import Stripe from "stripe"; const ajv = new Ajv({ validateFormats: false, @@ -309,6 +310,35 @@ export const redeemInvite = async (invite: Invite, userId: string): Promise<{ su withAuth(async () => { try { await prisma.$transaction(async (tx) => { + const org = await tx.org.findUnique({ + where: { + id: invite.orgId, + } + }); + + if (!org) { + return notFound(); + } + + // Incrememnt the seat count. We check if the subscription is valid in the redeem page so we return an error if that's not the case here + if (org.stripeCustomerId) { + const subscription = await fetchSubscription(org.id); + if (isServiceError(subscription)) { + return orgInvalidSubscription(); + } + + const existingSeatCount = subscription.items.data[0].quantity; + const newSeatCount = (existingSeatCount || 1) + 1 + + await stripe.subscriptionItems.update( + subscription.items.data[0].id, + { + quantity: newSeatCount, + proration_behavior: 'create_prorations', + } + ) + } + await tx.userToOrg.create({ data: { userId, @@ -412,6 +442,11 @@ export async function fetchStripeClientSecret(name: string, domain: string) { mode: 'subscription', subscription_data: { trial_period_days: 7, + trial_settings: { + end_behavior: { + missing_payment_method: 'cancel', + }, + }, }, payment_method_collection: 'if_required', return_url: `${origin}/onboard/complete?session_id={CHECKOUT_SESSION_ID}&org_name=${name}&org_domain=${domain}`, @@ -420,63 +455,58 @@ 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, - }, - }); +export const getSubscriptionCheckoutRedirect = async (domain: string) => + withAuth((session) => + withOrgMembership(session, domain, async (orgId) => { + const org = await prisma.org.findUnique({ + where: { + id: orgId, + }, + }); - if (!org || !org.stripeCustomerId) { - return notFound(); - } + 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 + const orgMembers = await prisma.userToOrg.findMany({ + where: { + orgId, + }, + select: { + userId: true, } - ], - mode: 'subscription', - payment_method_collection: 'always', - success_url: `${origin}/settings/billing`, - cancel_url: `${origin}`, - }); + }); + const numOrgMembers = orgMembers.length; - return stripeSession.url; - } + 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: numOrgMembers + } + ], + mode: 'subscription', + payment_method_collection: 'always', + success_url: `${origin}/${domain}/settings/billing`, + cancel_url: `${origin}/${domain}`, + }); - // 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; - } + return stripeSession.url; + } - const portalSession = await stripe.billingPortal.sessions.create({ - customer: org.stripeCustomerId as string, - return_url: `${origin}/settings/billing`, - }); - - return portalSession.url; - } -} + const newSubscriptionUrl = await createNewSubscription(); + return newSubscriptionUrl; + }) + ) export async function fetchStripeSession(sessionId: string) { const stripeSession = await stripe.checkout.sessions.retrieve(sessionId); @@ -521,10 +551,7 @@ export async function fetchSubscription(orgId: number) { }) if (subscriptions.data.length === 0) { - return { - status: "no_subscription", - message: "No subscription found for this organization" - } + return notFound(); } return subscriptions.data[0]; } diff --git a/packages/web/src/app/[domain]/components/payWall/checkoutButton.tsx b/packages/web/src/app/[domain]/components/payWall/checkoutButton.tsx index db343f23..00e49f7e 100644 --- a/packages/web/src/app/[domain]/components/payWall/checkoutButton.tsx +++ b/packages/web/src/app/[domain]/components/payWall/checkoutButton.tsx @@ -1,25 +1,23 @@ "use client" import { Button } from "@/components/ui/button" -import { getSubscriptionCheckoutRedirect} from "@/actions" +import { getSubscriptionCheckoutRedirect } from "@/actions" import { isServiceError } from "@/lib/utils" -export function CheckoutButton({ orgId }: { orgId: number }) { +export function CheckoutButton({ domain }: { domain: string }) { const redirectToCheckout = async () => { - const redirectUrl = await getSubscriptionCheckoutRedirect(orgId) - - if(isServiceError(redirectUrl)) { + const redirectUrl = await getSubscriptionCheckoutRedirect(domain) + + 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/[domain]/components/payWall/paywallCard.tsx b/packages/web/src/app/[domain]/components/payWall/paywallCard.tsx index c222f3a4..3e1f31df 100644 --- a/packages/web/src/app/[domain]/components/payWall/paywallCard.tsx +++ b/packages/web/src/app/[domain]/components/payWall/paywallCard.tsx @@ -1,71 +1,93 @@ -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" +import Image from "next/image"; +import logoDark from "@/public/sb_logo_dark_large.png"; +import logoLight from "@/public/sb_logo_light_large.png"; -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", - ] +const teamFeatures = [ + "Index hundreds of repos from multiple code hosts (GitHub, GitLab, Gerrit, Gitea, etc.). Self-hosted code sources supported", + "Public and private repos supported", + "Create sharable links to code snippets", + "9x5 email support team@sourcebot.dev", +] -export function PaywallCard({ orgId }: { orgId: number }) { +const enterpriseFeatures = [ + "All Team features", + "Dedicated Slack support channel", + "Single tenant deployment", + "Advanced security features", +] + +export async function PaywallCard({ domain }: { domain: string }) { 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} -
  • - ))} -
-
- - - -
+
+
+ {"Sourcebot + {"Sourcebot +
+

+ Your subscription has expired. +

+
+ + + Team + For professional developers and small teams + + +
+

$10

+

per user / month

+
+
    + {teamFeatures.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/[domain]/layout.tsx b/packages/web/src/app/[domain]/layout.tsx index 25f7f97c..740a640d 100644 --- a/packages/web/src/app/[domain]/layout.tsx +++ b/packages/web/src/app/[domain]/layout.tsx @@ -2,6 +2,11 @@ import { prisma } from "@/prisma"; import { PageNotFound } from "./components/pageNotFound"; import { auth } from "@/auth"; import { getOrgFromDomain } from "@/data/org"; +import { fetchSubscription } from "@/actions"; +import { isServiceError } from "@/lib/utils"; +import { PaywallCard } from "./components/payWall/paywallCard"; +import { NavigationMenu } from "./components/navigationMenu"; +import { Footer } from "./components/footer"; interface LayoutProps { children: React.ReactNode, @@ -38,5 +43,18 @@ export default async function Layout({ return } + const subscription = await fetchSubscription(org.id); + if (isServiceError(subscription) || (subscription.status !== "active" && subscription.status !== "trialing")) { + return ( +
+ + +
+
+ ) + } + return children; } \ No newline at end of file diff --git a/packages/web/src/app/[domain]/page.tsx b/packages/web/src/app/[domain]/page.tsx index 0d0244c5..127a2329 100644 --- a/packages/web/src/app/[domain]/page.tsx +++ b/packages/web/src/app/[domain]/page.tsx @@ -13,6 +13,7 @@ import { UpgradeToast } from "./components/upgradeToast"; import Link from "next/link"; import { getOrgFromDomain } from "@/data/org"; import { PageNotFound } from "./components/pageNotFound"; +import { Footer } from "./components/footer"; export default async function Home({ params: { domain } }: { params: { domain: string } }) { @@ -109,13 +110,7 @@ export default async function Home({ params: { domain } }: { params: { domain: s
- +