diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts index c6245178..df4c9297 100644 --- a/packages/backend/src/main.ts +++ b/packages/backend/src/main.ts @@ -26,5 +26,5 @@ export const main = async (db: PrismaClient, context: AppContext) => { connectionManager.registerPollingCallback(); const repoManager = new RepoManager(db, DEFAULT_SETTINGS, redis, context); - repoManager.blockingPollLoop(); + await repoManager.blockingPollLoop(); } 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 8e6ce9fc..8c7e421d 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -105,18 +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[] + + stripeCustomerId String? /// List of pending invites to this organization - invites Invite[] + invites Invite[] } enum OrgRole { diff --git a/packages/web/package.json b/packages/web/package.json index ca239c3a..a4f614f6 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -64,6 +64,8 @@ "@sourcebot/db": "^0.1.0", "@sourcebot/schemas": "^0.1.0", "@ssddanbrown/codemirror-lang-twig": "^1.0.0", + "@stripe/react-stripe-js": "^3.1.1", + "@stripe/stripe-js": "^5.6.0", "@tanstack/react-query": "^5.53.3", "@tanstack/react-table": "^8.20.5", "@tanstack/react-virtual": "^3.10.8", @@ -113,6 +115,7 @@ "react-resizable-panels": "^2.1.1", "server-only": "^0.0.1", "sharp": "^0.33.5", + "stripe": "^17.6.0", "tailwind-merge": "^2.5.2", "tailwind-scrollbar-hide": "^1.1.7", "tailwindcss-animate": "^1.0.7", diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 6fa059a9..d7641343 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -2,7 +2,7 @@ import Ajv from "ajv"; import { auth } from "./auth"; -import { notAuthenticated, notFound, ServiceError, unexpectedError } from "@/lib/serviceError"; +import { notAuthenticated, notFound, ServiceError, unexpectedError, orgInvalidSubscription } from "@/lib/serviceError"; import { prisma } from "@/prisma"; import { StatusCodes } from "http-status-codes"; import { ErrorCode } from "@/lib/errorCodes"; @@ -12,8 +12,12 @@ import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema"; import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type"; import { encrypt } from "@sourcebot/crypto" import { getConnection } from "./data/connection"; -import { ConnectionSyncStatus, Invite, Prisma } from "@sourcebot/db"; +import { ConnectionSyncStatus, Prisma, Invite } from "@sourcebot/db"; +import { headers } from "next/headers" +import { stripe } from "@/lib/stripe" +import { getUser } from "@/data/user"; import { Session } from "next-auth"; +import { STRIPE_PRODUCT_ID } from "@/lib/environment"; const ajv = new Ajv({ validateFormats: false, @@ -54,12 +58,18 @@ export const withOrgMembership = async (session: Session, domain: string, fn: return fn(org.id); } -export const createOrg = (name: string, domain: string): Promise<{ id: number } | ServiceError> => +export const isAuthed = async () => { + const session = await auth(); + return session != null; +} + +export const createOrg = (name: string, domain: string, stripeCustomerId?: string): Promise<{ id: number } | ServiceError> => withAuth(async (session) => { const org = await prisma.org.create({ data: { name, domain, + stripeCustomerId, members: { create: { role: "OWNER", @@ -277,6 +287,15 @@ export const createInvite = async (email: string, userId: string, domain: string withOrgMembership(session, domain, async (orgId) => { console.log("Creating invite for", email, userId, orgId); + if (email === session.user.email) { + console.error("User tried to invite themselves"); + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.SELF_INVITE, + message: "❌ You can't invite yourself to an org", + } satisfies ServiceError; + } + try { await prisma.invite.create({ data: { @@ -299,7 +318,36 @@ export const createInvite = async (email: string, userId: string, domain: string export const redeemInvite = async (invite: Invite, userId: string): Promise<{ success: boolean } | ServiceError> => withAuth(async () => { try { - await prisma.$transaction(async (tx) => { + const res = await prisma.$transaction(async (tx) => { + const org = await tx.org.findUnique({ + where: { + id: invite.orgId, + } + }); + + if (!org) { + return notFound(); + } + + // Incrememnt the seat count + if (org.stripeCustomerId) { + const subscription = await fetchSubscription(org.domain); + if (isServiceError(subscription)) { + throw 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, @@ -315,6 +363,10 @@ export const redeemInvite = async (invite: Invite, userId: string): Promise<{ su }); }); + if (isServiceError(res)) { + return res; + } + return { success: true, } @@ -364,3 +416,252 @@ const parseConnectionConfig = (connectionType: string, config: string) => { return parsedConfig; } + +export const setupInitialStripeCustomer = async (name: string, domain: string) => + withAuth(async (session) => { + const user = await getUser(session.user.id); + if (!user) { + return ""; + } + + const origin = (await headers()).get('origin') + + // @nocheckin + 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: STRIPE_PRODUCT_ID, + expand: ['data.product'], + }); + const stripeSession = await stripe.checkout.sessions.create({ + ui_mode: 'embedded', + customer: customer.id, + line_items: [ + { + price: prices.data[0].id, + quantity: 1 + } + ], + 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}`, + }) + + return stripeSession.client_secret!; + }); + +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(); + } + + const orgMembers = await prisma.userToOrg.findMany({ + where: { + orgId, + }, + select: { + userId: true, + } + }); + const numOrgMembers = orgMembers.length; + + const origin = (await headers()).get('origin') + const prices = await stripe.prices.list({ + product: STRIPE_PRODUCT_ID, + 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}`, + }); + + return stripeSession.url; + } + + const newSubscriptionUrl = await createNewSubscription(); + return newSubscriptionUrl; + }) + ) + +export async function fetchStripeSession(sessionId: string) { + const stripeSession = await stripe.checkout.sessions.retrieve(sessionId); + return stripeSession; +} + +export const getCustomerPortalSessionLink = async (domain: string): Promise => + withAuth((session) => + withOrgMembership(session, domain, async (orgId) => { + const org = await prisma.org.findUnique({ + where: { + id: orgId, + }, + }); + + if (!org || !org.stripeCustomerId) { + return notFound(); + } + + const origin = (await headers()).get('origin') + const portalSession = await stripe.billingPortal.sessions.create({ + customer: org.stripeCustomerId as string, + return_url: `${origin}/${domain}/settings/billing`, + }); + + return portalSession.url; + })); + +export const fetchSubscription = (domain: string): Promise => + withAuth(async () => { + const org = await prisma.org.findUnique({ + where: { + domain, + }, + }); + + if (!org || !org.stripeCustomerId) { + return notFound(); + } + + const subscriptions = await stripe.subscriptions.list({ + customer: org.stripeCustomerId + }); + + if (subscriptions.data.length === 0) { + return notFound(); + } + return subscriptions.data[0]; + }); + +export const checkIfUserHasOrg = async (userId: string): Promise => { + const orgs = await prisma.userToOrg.findMany({ + where: { + userId, + }, + }); + + return orgs.length > 0; +} + +export const checkIfOrgDomainExists = async (domain: string): Promise => + withAuth(async () => { + const org = await prisma.org.findFirst({ + where: { + domain, + } + }); + + return !!org; + }); + +export const removeMember = async (memberId: string, domain: string): Promise<{ success: boolean } | ServiceError> => + withAuth(async (session) => + withOrgMembership(session, domain, async (orgId) => { + const targetMember = await prisma.userToOrg.findUnique({ + where: { + orgId_userId: { + orgId, + userId: memberId, + } + } + }); + + if (!targetMember) { + return notFound(); + } + + const org = await prisma.org.findUnique({ + where: { + id: orgId, + }, + }); + + if (!org) { + return notFound(); + } + + if (org.stripeCustomerId) { + const subscription = await fetchSubscription(domain); + 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 prisma.userToOrg.delete({ + where: { + orgId_userId: { + orgId, + userId: memberId, + } + } + }); + + return { + success: true, + } + }) + ); + +export const getSubscriptionData = async (domain: string) => + withAuth(async (session) => + withOrgMembership(session, domain, async (orgId) => { + const subscription = await fetchSubscription(domain); + if (isServiceError(subscription)) { + return orgInvalidSubscription(); + } + + return { + plan: "Team", + seats: subscription.items.data[0].quantity!, + perSeatPrice: subscription.items.data[0].price.unit_amount! / 100, + nextBillingDate: subscription.current_period_end!, + status: subscription.status, + } + }) + ); diff --git a/packages/web/src/app/[domain]/components/footer.tsx b/packages/web/src/app/[domain]/components/footer.tsx new file mode 100644 index 00000000..8f084150 --- /dev/null +++ b/packages/web/src/app/[domain]/components/footer.tsx @@ -0,0 +1,14 @@ +import Link from "next/link"; +import { Separator } from "@/components/ui/separator"; + +export function Footer() { + return ( +
+ About + + Support + + Contact Us +
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/navigationMenu.tsx b/packages/web/src/app/[domain]/components/navigationMenu.tsx index bedaa4b9..dfad9c7b 100644 --- a/packages/web/src/app/[domain]/components/navigationMenu.tsx +++ b/packages/web/src/app/[domain]/components/navigationMenu.tsx @@ -9,7 +9,8 @@ import { SettingsDropdown } from "./settingsDropdown"; import { GitHubLogoIcon, DiscordLogoIcon } from "@radix-ui/react-icons"; import { redirect } from "next/navigation"; import { OrgSelector } from "./orgSelector"; - +import { getSubscriptionData } from "@/actions"; +import { isServiceError } from "@/lib/utils"; const SOURCEBOT_DISCORD_URL = "https://discord.gg/6Fhp27x7Pb"; const SOURCEBOT_GITHUB_URL = "https://github.com/sourcebot-dev/sourcebot"; @@ -20,6 +21,8 @@ interface NavigationMenuProps { export const NavigationMenu = async ({ domain, }: NavigationMenuProps) => { + const subscription = await getSubscriptionData(domain); + return (
@@ -66,14 +69,14 @@ export const NavigationMenu = async ({ - Secrets + Secrets - Connections + Connections @@ -89,6 +92,17 @@ export const NavigationMenu = async ({
+ {!isServiceError(subscription) && subscription.status === "trialing" && ( + +
+ + + {Math.ceil((subscription.nextBillingDate * 1000 - Date.now()) / (1000 * 60 * 60 * 24))} days left in + trial + +
+ + )}
{ "use server"; diff --git a/packages/web/src/app/[domain]/components/payWall/checkoutButton.tsx b/packages/web/src/app/[domain]/components/payWall/checkoutButton.tsx new file mode 100644 index 00000000..00e49f7e --- /dev/null +++ b/packages/web/src/app/[domain]/components/payWall/checkoutButton.tsx @@ -0,0 +1,23 @@ +"use client" + +import { Button } from "@/components/ui/button" +import { getSubscriptionCheckoutRedirect } from "@/actions" +import { isServiceError } from "@/lib/utils" + + +export function CheckoutButton({ domain }: { domain: string }) { + const redirectToCheckout = async () => { + 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/enterpriseContactUsButton.tsx b/packages/web/src/app/[domain]/components/payWall/enterpriseContactUsButton.tsx new file mode 100644 index 00000000..1fd6bf08 --- /dev/null +++ b/packages/web/src/app/[domain]/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/[domain]/components/payWall/paywallCard.tsx b/packages/web/src/app/[domain]/components/payWall/paywallCard.tsx new file mode 100644 index 00000000..3e1f31df --- /dev/null +++ b/packages/web/src/app/[domain]/components/payWall/paywallCard.tsx @@ -0,0 +1,93 @@ +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 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", +] + +const enterpriseFeatures = [ + "All Team features", + "Dedicated Slack support channel", + "Single tenant deployment", + "Advanced security features", +] + +export async function PaywallCard({ domain }: { domain: string }) { + return ( +
+
+ {"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..7b665670 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,16 @@ export default async function Layout({ return } + const subscription = await fetchSubscription(domain); + 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
-
- About - - Support - - Contact Us -
+