mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 08:13:56 +10:00
Merge branch 'main' into feat/webhook-implementation
This commit is contained in:
@ -5,7 +5,7 @@ import { match } from 'ts-pattern';
|
||||
|
||||
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
||||
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
||||
import { getPricesByPlan } from '@documenso/ee/server-only/stripe/get-prices-by-plan';
|
||||
import { getPrimaryAccountPlanPrices } from '@documenso/ee/server-only/stripe/get-primary-account-plan-prices';
|
||||
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
|
||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
@ -37,23 +37,23 @@ export default async function BillingSettingsPage() {
|
||||
user = await getStripeCustomerByUser(user).then((result) => result.user);
|
||||
}
|
||||
|
||||
const [subscriptions, prices, communityPlanPrices] = await Promise.all([
|
||||
const [subscriptions, prices, primaryAccountPlanPrices] = await Promise.all([
|
||||
getSubscriptionsByUserId({ userId: user.id }),
|
||||
getPricesByInterval({ plan: STRIPE_PLAN_TYPE.COMMUNITY }),
|
||||
getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY),
|
||||
getPrimaryAccountPlanPrices(),
|
||||
]);
|
||||
|
||||
const communityPlanPriceIds = communityPlanPrices.map(({ id }) => id);
|
||||
const primaryAccountPlanPriceIds = primaryAccountPlanPrices.map(({ id }) => id);
|
||||
|
||||
let subscriptionProduct: Stripe.Product | null = null;
|
||||
|
||||
const communityPlanUserSubscriptions = subscriptions.filter(({ priceId }) =>
|
||||
communityPlanPriceIds.includes(priceId),
|
||||
const primaryAccountPlanSubscriptions = subscriptions.filter(({ priceId }) =>
|
||||
primaryAccountPlanPriceIds.includes(priceId),
|
||||
);
|
||||
|
||||
const subscription =
|
||||
communityPlanUserSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ??
|
||||
communityPlanUserSubscriptions[0];
|
||||
primaryAccountPlanSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ??
|
||||
primaryAccountPlanSubscriptions[0];
|
||||
|
||||
if (subscription?.priceId) {
|
||||
subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch(
|
||||
|
||||
@ -67,7 +67,7 @@ export const TeamsMemberPageDataTable = ({
|
||||
<Tabs value={currentTab} className="flex-shrink-0 overflow-x-auto">
|
||||
<TabsList>
|
||||
<TabsTrigger className="min-w-[60px]" value="members" asChild>
|
||||
<Link href={pathname ?? '/'}>All</Link>
|
||||
<Link href={pathname ?? '/'}>Active</Link>
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger className="min-w-[60px]" value="invites" asChild>
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { SubscriptionStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { getPricesByPlan } from '../stripe/get-prices-by-plan';
|
||||
import { getDocumentRelatedPrices } from '../stripe/get-document-related-prices.ts';
|
||||
import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS, TEAM_PLAN_LIMITS } from './constants';
|
||||
import { ERROR_CODES } from './errors';
|
||||
import { ZLimitsSchema } from './schema';
|
||||
@ -56,10 +55,11 @@ const handleUserLimits = async ({ email }: HandleUserLimitsOptions) => {
|
||||
);
|
||||
|
||||
if (activeSubscriptions.length > 0) {
|
||||
const communityPlanPrices = await getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY);
|
||||
const documentPlanPrices = await getDocumentRelatedPrices();
|
||||
|
||||
for (const subscription of activeSubscriptions) {
|
||||
const price = communityPlanPrices.find((price) => price.id === subscription.priceId);
|
||||
const price = documentPlanPrices.find((price) => price.id === subscription.priceId);
|
||||
|
||||
if (!price || typeof price.product === 'string' || price.product.deleted) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||
|
||||
import { getPricesByPlan } from './get-prices-by-plan';
|
||||
|
||||
/**
|
||||
* Returns the Stripe prices of items that affect the amount of documents a user can create.
|
||||
*/
|
||||
export const getDocumentRelatedPrices = async () => {
|
||||
return await getPricesByPlan([STRIPE_PLAN_TYPE.COMMUNITY, STRIPE_PLAN_TYPE.ENTERPRISE]);
|
||||
};
|
||||
13
packages/ee/server-only/stripe/get-enterprise-plan-prices.ts
Normal file
13
packages/ee/server-only/stripe/get-enterprise-plan-prices.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||
|
||||
import { getPricesByPlan } from './get-prices-by-plan';
|
||||
|
||||
export const getEnterprisePlanPrices = async () => {
|
||||
return await getPricesByPlan(STRIPE_PLAN_TYPE.ENTERPRISE);
|
||||
};
|
||||
|
||||
export const getEnterprisePlanPriceIds = async () => {
|
||||
const prices = await getEnterprisePlanPrices();
|
||||
|
||||
return prices.map((price) => price.id);
|
||||
};
|
||||
@ -1,14 +1,18 @@
|
||||
import type { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
|
||||
export const getPricesByPlan = async (
|
||||
plan: (typeof STRIPE_PLAN_TYPE)[keyof typeof STRIPE_PLAN_TYPE],
|
||||
) => {
|
||||
type PlanType = (typeof STRIPE_PLAN_TYPE)[keyof typeof STRIPE_PLAN_TYPE];
|
||||
|
||||
export const getPricesByPlan = async (plan: PlanType | PlanType[]) => {
|
||||
const planTypes = typeof plan === 'string' ? [plan] : plan;
|
||||
|
||||
const query = planTypes.map((planType) => `metadata['plan']:'${planType}'`).join(' OR ');
|
||||
|
||||
const { data: prices } = await stripe.prices.search({
|
||||
query: `metadata['plan']:'${plan}' type:'recurring'`,
|
||||
query,
|
||||
expand: ['data.product'],
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
return prices;
|
||||
return prices.filter((price) => price.type === 'recurring');
|
||||
};
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||
|
||||
import { getPricesByPlan } from './get-prices-by-plan';
|
||||
|
||||
/**
|
||||
* Returns the prices of items that count as the account's primary plan.
|
||||
*/
|
||||
export const getPrimaryAccountPlanPrices = async () => {
|
||||
return await getPricesByPlan([STRIPE_PLAN_TYPE.COMMUNITY, STRIPE_PLAN_TYPE.ENTERPRISE]);
|
||||
};
|
||||
17
packages/ee/server-only/stripe/get-team-related-prices.ts
Normal file
17
packages/ee/server-only/stripe/get-team-related-prices.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||
|
||||
import { getPricesByPlan } from './get-prices-by-plan';
|
||||
|
||||
/**
|
||||
* Returns the Stripe prices of items that affect the amount of teams a user can create.
|
||||
*/
|
||||
export const getTeamRelatedPrices = async () => {
|
||||
return await getPricesByPlan([STRIPE_PLAN_TYPE.COMMUNITY, STRIPE_PLAN_TYPE.ENTERPRISE]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the Stripe price IDs of items that affect the amount of teams a user can create.
|
||||
*/
|
||||
export const getTeamRelatedPriceIds = async () => {
|
||||
return await getTeamRelatedPrices().then((prices) => prices.map((price) => price.id));
|
||||
};
|
||||
@ -2,13 +2,13 @@ import type Stripe from 'stripe';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { subscriptionsContainsActiveCommunityPlan } from '@documenso/lib/utils/billing';
|
||||
import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { type Subscription, type Team, type User } from '@documenso/prisma/client';
|
||||
|
||||
import { deleteCustomerPaymentMethods } from './delete-customer-payment-methods';
|
||||
import { getCommunityPlanPriceIds } from './get-community-plan-prices';
|
||||
import { getTeamPrices } from './get-team-prices';
|
||||
import { getTeamRelatedPriceIds } from './get-team-related-prices';
|
||||
|
||||
type TransferStripeSubscriptionOptions = {
|
||||
/**
|
||||
@ -46,14 +46,14 @@ export const transferTeamSubscription = async ({
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, 'Missing customer ID.');
|
||||
}
|
||||
|
||||
const [communityPlanIds, teamSeatPrices] = await Promise.all([
|
||||
getCommunityPlanPriceIds(),
|
||||
const [teamRelatedPlanPriceIds, teamSeatPrices] = await Promise.all([
|
||||
getTeamRelatedPriceIds(),
|
||||
getTeamPrices(),
|
||||
]);
|
||||
|
||||
const teamSubscriptionRequired = !subscriptionsContainsActiveCommunityPlan(
|
||||
const teamSubscriptionRequired = !subscriptionsContainsActivePlan(
|
||||
user.Subscription,
|
||||
communityPlanIds,
|
||||
teamRelatedPlanPriceIds,
|
||||
);
|
||||
|
||||
let teamSubscription: Stripe.Subscription | null = null;
|
||||
|
||||
13
packages/lib/client-only/hooks/use-effect-once.ts
Normal file
13
packages/lib/client-only/hooks/use-effect-once.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import type { EffectCallback } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Dangerously runs an effect "once" by ignoring the depedencies of a given effect.
|
||||
*
|
||||
* DANGER: The effect will run twice in concurrent react and development environments.
|
||||
*/
|
||||
export const unsafe_useEffectOnce = (callback: EffectCallback) => {
|
||||
// Intentionally avoiding exhaustive deps and rule of hooks here
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps, react-hooks/rules-of-hooks
|
||||
return useEffect(callback, []);
|
||||
};
|
||||
@ -3,18 +3,17 @@ import { env } from 'next-runtime-env';
|
||||
export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT =
|
||||
Number(process.env.NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT) || 50;
|
||||
|
||||
export const NEXT_PUBLIC_PROJECT = () => env('NEXT_PUBLIC_PROJECT');
|
||||
export const NEXT_PUBLIC_WEBAPP_URL = () => env('NEXT_PUBLIC_WEBAPP_URL');
|
||||
export const NEXT_PUBLIC_MARKETING_URL = () => env('NEXT_PUBLIC_MARKETING_URL');
|
||||
|
||||
export const IS_APP_MARKETING = () => NEXT_PUBLIC_PROJECT() === 'marketing';
|
||||
export const IS_APP_WEB = () => NEXT_PUBLIC_PROJECT() === 'web';
|
||||
export const IS_APP_MARKETING = process.env.NEXT_PUBLIC_PROJECT === 'marketing';
|
||||
export const IS_APP_WEB = process.env.NEXT_PUBLIC_PROJECT === 'web';
|
||||
export const IS_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED') === 'true';
|
||||
|
||||
export const APP_FOLDER = () => (IS_APP_MARKETING() ? 'marketing' : 'web');
|
||||
export const APP_FOLDER = () => (IS_APP_MARKETING ? 'marketing' : 'web');
|
||||
|
||||
export const APP_BASE_URL = () =>
|
||||
IS_APP_WEB() ? NEXT_PUBLIC_WEBAPP_URL() : NEXT_PUBLIC_MARKETING_URL();
|
||||
IS_APP_WEB ? NEXT_PUBLIC_WEBAPP_URL() : NEXT_PUBLIC_MARKETING_URL();
|
||||
|
||||
export const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000';
|
||||
export const MARKETING_BASE_URL = NEXT_PUBLIC_MARKETING_URL() ?? 'http://localhost:3001';
|
||||
|
||||
@ -6,6 +6,5 @@ export enum STRIPE_CUSTOMER_TYPE {
|
||||
export enum STRIPE_PLAN_TYPE {
|
||||
TEAM = 'team',
|
||||
COMMUNITY = 'community',
|
||||
ENTERPRISE = 'enterprise',
|
||||
}
|
||||
|
||||
export const TEAM_BILLING_DOMAIN = 'billing.team.documenso.com';
|
||||
|
||||
@ -2,11 +2,11 @@ import type Stripe from 'stripe';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { createTeamCustomer } from '@documenso/ee/server-only/stripe/create-team-customer';
|
||||
import { getCommunityPlanPriceIds } from '@documenso/ee/server-only/stripe/get-community-plan-prices';
|
||||
import { getTeamRelatedPrices } from '@documenso/ee/server-only/stripe/get-team-related-prices';
|
||||
import { mapStripeSubscriptionToPrismaUpsertAction } from '@documenso/ee/server-only/stripe/webhook/on-subscription-updated';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { subscriptionsContainsActiveCommunityPlan } from '@documenso/lib/utils/billing';
|
||||
import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { Prisma, TeamMemberRole } from '@documenso/prisma/client';
|
||||
|
||||
@ -61,13 +61,12 @@ export const createTeam = async ({
|
||||
let customerId: string | null = null;
|
||||
|
||||
if (IS_BILLING_ENABLED()) {
|
||||
const communityPlanPriceIds = await getCommunityPlanPriceIds();
|
||||
|
||||
isPaymentRequired = !subscriptionsContainsActiveCommunityPlan(
|
||||
user.Subscription,
|
||||
communityPlanPriceIds,
|
||||
const teamRelatedPriceIds = await getTeamRelatedPrices().then((prices) =>
|
||||
prices.map((price) => price.id),
|
||||
);
|
||||
|
||||
isPaymentRequired = !subscriptionsContainsActivePlan(user.Subscription, teamRelatedPriceIds);
|
||||
|
||||
customerId = await createTeamCustomer({
|
||||
name: user.name ?? teamName,
|
||||
email: user.email,
|
||||
|
||||
@ -2,15 +2,14 @@ import type { Subscription } from '.prisma/client';
|
||||
import { SubscriptionStatus } from '.prisma/client';
|
||||
|
||||
/**
|
||||
* Returns true if there is a subscription that is active and is a community plan.
|
||||
* Returns true if there is a subscription that is active and is one of the provided price IDs.
|
||||
*/
|
||||
export const subscriptionsContainsActiveCommunityPlan = (
|
||||
export const subscriptionsContainsActivePlan = (
|
||||
subscriptions: Subscription[],
|
||||
communityPlanPriceIds: string[],
|
||||
priceIds: string[],
|
||||
) => {
|
||||
return subscriptions.some(
|
||||
(subscription) =>
|
||||
subscription.status === SubscriptionStatus.ACTIVE &&
|
||||
communityPlanPriceIds.includes(subscription.priceId),
|
||||
subscription.status === SubscriptionStatus.ACTIVE && priceIds.includes(subscription.priceId),
|
||||
);
|
||||
};
|
||||
|
||||
@ -7,6 +7,8 @@ import { Undo2 } from 'lucide-react';
|
||||
import type { StrokeOptions } from 'perfect-freehand';
|
||||
import { getStroke } from 'perfect-freehand';
|
||||
|
||||
import { unsafe_useEffectOnce } from '@documenso/lib/client-only/hooks/use-effect-once';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { getSvgPathFromStroke } from './helper';
|
||||
import { Point } from './point';
|
||||
@ -28,6 +30,7 @@ export const SignaturePad = ({
|
||||
...props
|
||||
}: SignaturePadProps) => {
|
||||
const $el = useRef<HTMLCanvasElement>(null);
|
||||
const $imageData = useRef<ImageData | null>(null);
|
||||
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const [lines, setLines] = useState<Point[][]>([]);
|
||||
@ -134,7 +137,6 @@ export const SignaturePad = ({
|
||||
});
|
||||
|
||||
onChange?.($el.current.toDataURL());
|
||||
|
||||
ctx.save();
|
||||
}
|
||||
}
|
||||
@ -163,6 +165,7 @@ export const SignaturePad = ({
|
||||
const ctx = $el.current.getContext('2d');
|
||||
|
||||
ctx?.clearRect(0, 0, $el.current.width, $el.current.height);
|
||||
$imageData.current = null;
|
||||
}
|
||||
|
||||
onChange?.(null);
|
||||
@ -176,19 +179,25 @@ export const SignaturePad = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const newLines = [...lines];
|
||||
newLines.pop(); // Remove the last line
|
||||
const newLines = lines.slice(0, -1);
|
||||
setLines(newLines);
|
||||
|
||||
// Clear the canvas
|
||||
if ($el.current) {
|
||||
const ctx = $el.current.getContext('2d');
|
||||
ctx?.clearRect(0, 0, $el.current.width, $el.current.height);
|
||||
const { width, height } = $el.current;
|
||||
ctx?.clearRect(0, 0, width, height);
|
||||
|
||||
if (typeof defaultValue === 'string' && $imageData.current) {
|
||||
ctx?.putImageData($imageData.current, 0, 0);
|
||||
}
|
||||
|
||||
newLines.forEach((line) => {
|
||||
const pathData = new Path2D(getSvgPathFromStroke(getStroke(line, perfectFreehandOptions)));
|
||||
ctx?.fill(pathData);
|
||||
});
|
||||
|
||||
onChange?.($el.current.toDataURL());
|
||||
}
|
||||
};
|
||||
|
||||
@ -199,7 +208,7 @@ export const SignaturePad = ({
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
unsafe_useEffectOnce(() => {
|
||||
if ($el.current && typeof defaultValue === 'string') {
|
||||
const ctx = $el.current.getContext('2d');
|
||||
|
||||
@ -209,11 +218,15 @@ export const SignaturePad = ({
|
||||
|
||||
img.onload = () => {
|
||||
ctx?.drawImage(img, 0, 0, Math.min(width, img.width), Math.min(height, img.height));
|
||||
|
||||
const defaultImageData = ctx?.getImageData(0, 0, width, height) || null;
|
||||
|
||||
$imageData.current = defaultImageData;
|
||||
};
|
||||
|
||||
img.src = defaultValue;
|
||||
}
|
||||
}, [defaultValue]);
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user