mirror of
https://github.com/docmost/docmost.git
synced 2025-11-25 21:21:09 +10:00
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:
130
apps/client/src/ee/billing/components/billing-details.tsx
Normal file
130
apps/client/src/ee/billing/components/billing-details.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
apps/client/src/ee/billing/components/billing-incomplete.tsx
Normal file
13
apps/client/src/ee/billing/components/billing-incomplete.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
115
apps/client/src/ee/billing/components/billing-plans.tsx
Normal file
115
apps/client/src/ee/billing/components/billing-plans.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
apps/client/src/ee/billing/components/billing-trial.tsx
Normal file
32
apps/client/src/ee/billing/components/billing-trial.tsx
Normal 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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
10
apps/client/src/ee/billing/components/billing.module.css
Normal file
10
apps/client/src/ee/billing/components/billing.module.css
Normal 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);
|
||||
}
|
||||
34
apps/client/src/ee/billing/components/manage-billing.tsx
Normal file
34
apps/client/src/ee/billing/components/manage-billing.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
41
apps/client/src/ee/billing/pages/billing.tsx
Normal file
41
apps/client/src/ee/billing/pages/billing.tsx
Normal 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 />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
20
apps/client/src/ee/billing/queries/billing-query.ts
Normal file
20
apps/client/src/ee/billing/queries/billing-query.ts
Normal 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(),
|
||||
});
|
||||
}
|
||||
29
apps/client/src/ee/billing/services/billing-service.ts
Normal file
29
apps/client/src/ee/billing/services/billing-service.ts
Normal 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;
|
||||
}
|
||||
49
apps/client/src/ee/billing/types/billing.types.ts
Normal file
49
apps/client/src/ee/billing/types/billing.types.ts
Normal 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[];
|
||||
}
|
||||
17
apps/client/src/ee/billing/utils.ts
Normal file
17
apps/client/src/ee/billing/utils.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user