feat: cloud and ee (#805)

* stripe init
git submodules for enterprise modules

* * Cloud billing UI - WIP
* Proxy websockets in dev mode
* Separate workspace login and creation for cloud
* Other fixes

* feat: billing (cloud)

* * add domain service
* prepare links from workspace hostname

* WIP

* Add exchange token generation
* Validate JWT token type during verification

* domain service

* add SkipTransform decorator

* * updates (server)
* add new packages
* new sso migration file

* WIP

* Fix hostname generation

* WIP

* WIP

* Reduce input error font-size
* set max password length

* jwt package

* license page - WIP

* * License management UI
* Move license key store to db

* add reflector

* SSO enforcement

* * Add default plan
* Add usePlan hook

* * Fix auth container margin in mobile
* Redirect login and home to select page in cloud

* update .gitignore

* Default to yearly

* * Trial messaging
* Handle ended trials

* Don't set to readonly on collab disconnect (Cloud)

* Refine trial (UI)
* Fix bug caused by using jotai optics atom in AppHeader component

* configurable database maximum pool

* Close SSO form on save

* wip

* sync

* Only show sign-in in cloud

* exclude base api part from workspaceId check

* close db connection beforeApplicationShutdown

* Add health/live endpoint

* clear cookie on hostname change

* reset currentUser atom

* Change text

* return 401 if workspace does not match

* feat: show user workspace list in cloud login page

* sync

* Add home path

* Prefetch to speed up queries

* * Add robots.txt
* Disallow login and forgot password routes

* wildcard user-agent

* Fix space query cache

* fix

* fix

* use space uuid for recent pages

* prefetch billing plans

* enhance license page

* sync
This commit is contained in:
Philip Okugbe
2025-03-06 13:38:37 +00:00
committed by GitHub
parent 91596be70e
commit b81c9ee10c
148 changed files with 8947 additions and 3458 deletions

View File

@ -0,0 +1,130 @@
import {
useBillingPlans,
useBillingQuery,
} from "@/ee/billing/queries/billing-query.ts";
import { Group, Text, SimpleGrid, Paper } from "@mantine/core";
import classes from "./billing.module.css";
import { format } from "date-fns";
import { formatInterval } from "@/ee/billing/utils.ts";
export default function BillingDetails() {
const { data: billing } = useBillingQuery();
const { data: plans } = useBillingPlans();
if (!billing || !plans) {
return null;
}
return (
<div className={classes.root}>
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3 }}>
<Paper p="md" radius="md">
<Group justify="apart">
<div>
<Text
c="dimmed"
tt="uppercase"
fw={700}
fz="xs"
className={classes.label}
>
Plan
</Text>
<Text fw={700} fz="lg">
{
plans.find(
(plan) => plan.productId === billing.stripeProductId,
)?.name
}
</Text>
</div>
</Group>
</Paper>
<Paper p="md" radius="md">
<Group justify="apart">
<div>
<Text
c="dimmed"
tt="uppercase"
fw={700}
fz="xs"
className={classes.label}
>
Billing Period
</Text>
<Text fw={700} fz="lg" tt="capitalize">
{formatInterval(billing.interval)}
</Text>
</div>
</Group>
</Paper>
<Paper p="md" radius="md">
<Group justify="apart">
<div>
<Text
c="dimmed"
tt="uppercase"
fw={700}
fz="xs"
className={classes.label}
>
{billing.cancelAtPeriodEnd
? "Cancellation date"
: "Renewal date"}
</Text>
<Text fw={700} fz="lg">
{format(billing.periodEndAt, "dd MMM, yyyy")}
</Text>
</div>
</Group>
</Paper>
</SimpleGrid>
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3 }}>
<Paper p="md" radius="md">
<Group justify="apart">
<div>
<Text
c="dimmed"
tt="uppercase"
fw={700}
fz="xs"
className={classes.label}
>
Seat count
</Text>
<Text fw={700} fz="lg">
{billing.quantity}
</Text>
</div>
</Group>
</Paper>
<Paper p="md" radius="md">
<Group justify="apart">
<div>
<Text
c="dimmed"
tt="uppercase"
fw={700}
fz="xs"
className={classes.label}
>
Total
</Text>
<Text fw={700} fz="lg">
{(billing.amount / 100) * billing.quantity}{" "}
{billing.currency.toUpperCase()}
</Text>
<Text c="dimmed" fz="sm">
${billing.amount / 100} /user/{billing.interval}
</Text>
</div>
</Group>
</Paper>
</SimpleGrid>
</div>
);
}

View File

@ -0,0 +1,13 @@
import { Alert } from "@mantine/core";
import React from "react";
export default function BillingIncomplete() {
return (
<>
<Alert variant="light" color="blue">
Your subscription is in an incomplete state. Please refresh this page if
you recently made your payment.
</Alert>
</>
);
}

View File

@ -0,0 +1,115 @@
import {
Button,
Card,
List,
SegmentedControl,
ThemeIcon,
Title,
Text,
Group,
} from "@mantine/core";
import { useState } from "react";
import { IconCheck } from "@tabler/icons-react";
import { useBillingPlans } from "@/ee/billing/queries/billing-query.ts";
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts";
export default function BillingPlans() {
const { data: plans } = useBillingPlans();
const [interval, setInterval] = useState("yearly");
if (!plans) {
return null;
}
const handleCheckout = async (priceId: string) => {
try {
const checkoutLink = await getCheckoutLink({
priceId: priceId,
});
window.location.href = checkoutLink.url;
} catch (err) {
console.error("Failed to get checkout link", err);
}
};
return (
<Group justify="center" p="xl">
{plans.map((plan) => {
const price =
interval === "monthly" ? plan.price.monthly : plan.price.yearly;
const priceId = interval === "monthly" ? plan.monthlyId : plan.yearlyId;
const yearlyMonthPrice = parseInt(plan.price.yearly) / 12;
return (
<Card
key={plan.name}
withBorder
radius="md"
shadow="sm"
p="xl"
w={300}
>
<SegmentedControl
value={interval}
onChange={setInterval}
fullWidth
data={[
{ label: "Monthly", value: "monthly" },
{ label: "Yearly (25% OFF)", value: "yearly" },
]}
/>
<Title order={3} ta="center" mt="sm" mb="xs">
{plan.name}
</Title>
<Text ta="center" size="lg" fw={700}>
{interval === "monthly" && (
<>
${price}{" "}
<Text span size="sm" fw={500} c="dimmed">
/user/month
</Text>
</>
)}
{interval === "yearly" && (
<>
${yearlyMonthPrice}{" "}
<Text span size="sm" fw={500} c="dimmed">
/user/month
</Text>
</>
)}
<br/>
<Text span ta="center" size="md" fw={500} c="dimmed">
billed {interval}
</Text>
</Text>
<Card.Section mt="lg">
<Button onClick={() => handleCheckout(priceId)} fullWidth>
Subscribe
</Button>
</Card.Section>
<Card.Section mt="md">
<List
spacing="xs"
size="sm"
center
icon={
<ThemeIcon variant="light" size={24} radius="xl">
<IconCheck size={16} />
</ThemeIcon>
}
>
{plan.features.map((feature, index) => (
<List.Item key={index}>{feature}</List.Item>
))}
</List>
</Card.Section>
</Card>
);
})}
</Group>
);
}

View File

@ -0,0 +1,32 @@
import { Alert } from "@mantine/core";
import { useBillingQuery } from "@/ee/billing/queries/billing-query.ts";
import useTrial from "@/ee/hooks/use-trial.tsx";
export default function BillingTrial() {
const { data: billing, isLoading } = useBillingQuery();
const { trialDaysLeft } = useTrial();
if (isLoading) {
return null;
}
return (
<>
{trialDaysLeft > 0 && !billing && (
<Alert title="Your Trial is Active 🎉" color="blue" radius="md">
You have {trialDaysLeft} {trialDaysLeft === 1 ? "day" : "days"} left
in your 14-day trial. Please subscribe to a plan before your trial
ends.
</Alert>
)}
{trialDaysLeft === 0 ||
(trialDaysLeft === null && !billing && (
<Alert title="Your Trial has ended" color="red" radius="md">
Your 14-day trial has come to an end. Please subscribe to a plan to
continue using this service.
</Alert>
))}
</>
);
}

View File

@ -0,0 +1,10 @@
.root {
padding-top: var(--mantine-spacing-xs);
padding-bottom: var(--mantine-spacing-xs);
}
.label {
font-family:
Greycliff CF,
var(--mantine-font-family);
}

View File

@ -0,0 +1,34 @@
import { Button, Group, Text } from "@mantine/core";
import React from "react";
import { getBillingPortalLink } from "@/ee/billing/services/billing-service.ts";
export default function ManageBilling() {
const handleBillingPortal = async () => {
try {
const portalLink = await getBillingPortalLink();
window.location.href = portalLink.url;
} catch (err) {
console.error("Failed to get billing portal link", err);
}
};
return (
<>
<Group justify="space-between" wrap="wrap" gap="xl">
<div style={{ flex: 1, minWidth: "200px" }}>
<Text size="md" fw={500}>
Manage subscription
</Text>
<Text size="sm" c="dimmed">
Manage your your subscription, invoices, update payment details, and
more.
</Text>
</div>
<Button style={{ flexShrink: 0 }} onClick={handleBillingPortal}>
Manage
</Button>
</Group>
</>
);
}

View File

@ -0,0 +1,41 @@
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import BillingPlans from "@/ee/billing/components/billing-plans.tsx";
import BillingTrial from "@/ee/billing/components/billing-trial.tsx";
import ManageBilling from "@/ee/billing/components/manage-billing.tsx";
import { Divider } from "@mantine/core";
import React from "react";
import BillingDetails from "@/ee/billing/components/billing-details.tsx";
import { useBillingQuery } from "@/ee/billing/queries/billing-query.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
export default function Billing() {
const { data: billing, isError: isBillingError } = useBillingQuery();
const { isAdmin } = useUserRole();
if (!isAdmin) {
return null;
}
return (
<>
<Helmet>
<title>Billing - {getAppName()}</title>
</Helmet>
<SettingsTitle title="Billing" />
<BillingTrial />
<BillingDetails />
{isBillingError && <BillingPlans />}
{billing && (
<>
<Divider my="lg" />
<ManageBilling />
</>
)}
</>
);
}

View File

@ -0,0 +1,20 @@
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import {
getBilling,
getBillingPlans,
} from "@/ee/billing/services/billing-service.ts";
import { IBilling, IBillingPlan } from "@/ee/billing/types/billing.types.ts";
export function useBillingQuery(): UseQueryResult<IBilling, Error> {
return useQuery({
queryKey: ["billing"],
queryFn: () => getBilling(),
});
}
export function useBillingPlans(): UseQueryResult<IBillingPlan[], Error> {
return useQuery({
queryKey: ["billing-plans"],
queryFn: () => getBillingPlans(),
});
}

View File

@ -0,0 +1,29 @@
import api from "@/lib/api-client.ts";
import {
IBilling,
IBillingPlan,
IBillingPortal,
ICheckoutLink,
} from "@/ee/billing/types/billing.types.ts";
export async function getBilling(): Promise<IBilling> {
const req = await api.post<IBilling>("/billing/info");
return req.data;
}
export async function getBillingPlans(): Promise<IBillingPlan[]> {
const req = await api.post<IBillingPlan[]>("/billing/plans");
return req.data;
}
export async function getCheckoutLink(data: {
priceId: string;
}): Promise<ICheckoutLink> {
const req = await api.post<ICheckoutLink>("/billing/checkout", data);
return req.data;
}
export async function getBillingPortalLink(): Promise<IBillingPortal> {
const req = await api.post<IBillingPortal>("/billing/portal");
return req.data;
}

View File

@ -0,0 +1,49 @@
export enum BillingPlan {
STANDARD = "standard",
}
export interface IBilling {
id: string;
stripeSubscriptionId: string;
stripeCustomerId: string;
status: string;
quantity: number;
amount: number;
interval: string;
currency: string;
metadata: Record<string, any>;
stripePriceId: string;
stripeItemId: string;
stripeProductId: string;
periodStartAt: Date;
periodEndAt: Date;
cancelAtPeriodEnd: boolean;
cancelAt: Date;
canceledAt: Date;
workspaceId: string;
createdAt: Date;
updatedAt: Date;
deletedAt: Date;
}
export interface ICheckoutLink {
url: string;
}
export interface IBillingPortal {
url: string;
}
export interface IBillingPlan {
name: string;
description: string;
productId: string;
monthlyId: string;
yearlyId: string;
currency: string;
price: {
monthly: string;
yearly: string;
};
features: string[];
}

View File

@ -0,0 +1,17 @@
import { differenceInCalendarDays } from "date-fns";
export function formatInterval(interval: string): string {
if (interval === "month") {
return "monthly";
}
if (interval === "year") {
return "yearly";
}
}
export function getTrialDaysLeft(trialEndAt: Date) {
if (!trialEndAt) return null;
const daysLeft = differenceInCalendarDays(trialEndAt, new Date());
return daysLeft > 0 ? daysLeft : 0;
}