mirror of
https://github.com/documenso/documenso.git
synced 2025-11-11 04:52:41 +10:00
fix: restrict individual plans to upgrade only (#1900)
Prevent users from creating a separate organisation for individual plans. Only applies to users who have 1 personal organisation and are subscribing to the "Individual" plan. The reason for this change is to keep the layout in the "Personal" mode which means it doesn't show a bunch of unusable "organisation" related UI.
This commit is contained in:
@ -19,6 +19,7 @@ import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
|||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
||||||
import { parseMessageDescriptorMacro } from '@documenso/lib/utils/i18n';
|
import { parseMessageDescriptorMacro } from '@documenso/lib/utils/i18n';
|
||||||
|
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { ZCreateOrganisationRequestSchema } from '@documenso/trpc/server/organisation-router/create-organisation.types';
|
import { ZCreateOrganisationRequestSchema } from '@documenso/trpc/server/organisation-router/create-organisation.types';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -46,6 +47,8 @@ import { SpinnerBox } from '@documenso/ui/primitives/spinner';
|
|||||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { IndividualPersonalLayoutCheckoutButton } from '../general/billing-plans';
|
||||||
|
|
||||||
export type OrganisationCreateDialogProps = {
|
export type OrganisationCreateDialogProps = {
|
||||||
trigger?: React.ReactNode;
|
trigger?: React.ReactNode;
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
@ -59,10 +62,11 @@ export type TCreateOrganisationFormSchema = z.infer<typeof ZCreateOrganisationFo
|
|||||||
export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCreateDialogProps) => {
|
export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCreateDialogProps) => {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { refreshSession } = useSession();
|
const { refreshSession, organisations } = useSession();
|
||||||
|
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
||||||
|
|
||||||
const actionSearchParam = searchParams?.get('action');
|
const actionSearchParam = searchParams?.get('action');
|
||||||
|
|
||||||
@ -133,6 +137,13 @@ export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCrea
|
|||||||
form.reset();
|
form.reset();
|
||||||
}, [open, form]);
|
}, [open, form]);
|
||||||
|
|
||||||
|
const isIndividualPlan = (priceId: string) => {
|
||||||
|
return (
|
||||||
|
plansData?.plans[INTERNAL_CLAIM_ID.INDIVIDUAL]?.monthlyPrice?.id === priceId ||
|
||||||
|
plansData?.plans[INTERNAL_CLAIM_ID.INDIVIDUAL]?.yearlyPrice?.id === priceId
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
{...props}
|
{...props}
|
||||||
@ -177,9 +188,15 @@ export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCrea
|
|||||||
<Trans>Cancel</Trans>
|
<Trans>Cancel</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button type="submit" onClick={() => setStep('create')}>
|
{isIndividualPlan(selectedPriceId) && isPersonalLayoutMode ? (
|
||||||
<Trans>Continue</Trans>
|
<IndividualPersonalLayoutCheckoutButton priceId={selectedPriceId}>
|
||||||
</Button>
|
<Trans>Checkout</Trans>
|
||||||
|
</IndividualPersonalLayoutCheckoutButton>
|
||||||
|
) : (
|
||||||
|
<Button type="submit" onClick={() => setStep('create')}>
|
||||||
|
<Trans>Continue</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</>
|
</>
|
||||||
@ -306,7 +323,11 @@ const BillingPlanForm = ({
|
|||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
const onBillingPeriodChange = (billingPeriod: 'monthlyPrice' | 'yearlyPrice') => {
|
const onBillingPeriodChange = (billingPeriod: 'monthlyPrice' | 'yearlyPrice') => {
|
||||||
const plan = dynamicPlans.find((plan) => plan[billingPeriod]?.id === value);
|
const plan = dynamicPlans.find(
|
||||||
|
(plan) =>
|
||||||
|
// Purposely using the opposite billing period to get the correct plan.
|
||||||
|
plan[billingPeriod === 'monthlyPrice' ? 'yearlyPrice' : 'monthlyPrice']?.id === value,
|
||||||
|
);
|
||||||
|
|
||||||
setBillingPeriod(billingPeriod);
|
setBillingPeriod(billingPeriod);
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,9 @@ import { useForm } from 'react-hook-form';
|
|||||||
import type { InternalClaimPlans } from '@documenso/ee/server-only/stripe/get-internal-claim-plans';
|
import type { InternalClaimPlans } from '@documenso/ee/server-only/stripe/get-internal-claim-plans';
|
||||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
||||||
|
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Card, CardContent, CardTitle } from '@documenso/ui/primitives/card';
|
import { Card, CardContent, CardTitle } from '@documenso/ui/primitives/card';
|
||||||
@ -49,8 +51,12 @@ export type BillingPlansProps = {
|
|||||||
export const BillingPlans = ({ plans }: BillingPlansProps) => {
|
export const BillingPlans = ({ plans }: BillingPlansProps) => {
|
||||||
const isMounted = useIsMounted();
|
const isMounted = useIsMounted();
|
||||||
|
|
||||||
|
const { organisations } = useSession();
|
||||||
|
|
||||||
const [interval, setInterval] = useState<'monthlyPrice' | 'yearlyPrice'>('yearlyPrice');
|
const [interval, setInterval] = useState<'monthlyPrice' | 'yearlyPrice'>('yearlyPrice');
|
||||||
|
|
||||||
|
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
||||||
|
|
||||||
const pricesToDisplay = useMemo(() => {
|
const pricesToDisplay = useMemo(() => {
|
||||||
const prices = [];
|
const prices = [];
|
||||||
|
|
||||||
@ -126,12 +132,18 @@ export const BillingPlans = ({ plans }: BillingPlansProps) => {
|
|||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
<BillingDialog
|
{isPersonalLayoutMode && price.claim === INTERNAL_CLAIM_ID.INDIVIDUAL ? (
|
||||||
priceId={price.id}
|
<IndividualPersonalLayoutCheckoutButton priceId={price.id}>
|
||||||
planName={price.product.name}
|
<Trans>Subscribe</Trans>
|
||||||
memberCount={price.memberCount}
|
</IndividualPersonalLayoutCheckoutButton>
|
||||||
claim={price.claim}
|
) : (
|
||||||
/>
|
<BillingDialog
|
||||||
|
priceId={price.id}
|
||||||
|
planName={price.product.name}
|
||||||
|
memberCount={price.memberCount}
|
||||||
|
claim={price.claim}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</MotionCard>
|
</MotionCard>
|
||||||
))}
|
))}
|
||||||
@ -315,3 +327,48 @@ const BillingDialog = ({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom checkout button for individual organisations in personal layout mode.
|
||||||
|
*
|
||||||
|
* This is so they don't create an additional organisation which is not needed since
|
||||||
|
* it will clutter up the UI for them with unnecessary organisations.
|
||||||
|
*/
|
||||||
|
export const IndividualPersonalLayoutCheckoutButton = ({
|
||||||
|
priceId,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
priceId: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { organisations } = useSession();
|
||||||
|
|
||||||
|
const { mutateAsync: createSubscription, isPending } =
|
||||||
|
trpc.billing.subscription.create.useMutation();
|
||||||
|
|
||||||
|
const onSubscribeClick = async () => {
|
||||||
|
try {
|
||||||
|
const createSubscriptionResponse = await createSubscription({
|
||||||
|
organisationId: organisations[0].id,
|
||||||
|
priceId,
|
||||||
|
isPersonalLayoutMode: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
window.location.href = createSubscriptionResponse.redirectUrl;
|
||||||
|
} catch (_err) {
|
||||||
|
toast({
|
||||||
|
title: t`Something went wrong`,
|
||||||
|
description: t`An error occurred while trying to create a checkout session.`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button loading={isPending} onClick={() => void onSubscribeClick()}>
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import { SubscriptionStatus } from '@prisma/client';
|
import { OrganisationType, SubscriptionStatus } from '@prisma/client';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { createOrganisationClaimUpsertData } from '@documenso/lib/server-only/organisation/create-organisation';
|
import { createOrganisationClaimUpsertData } from '@documenso/lib/server-only/organisation/create-organisation';
|
||||||
import { type Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
import { type Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
||||||
|
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
export type OnSubscriptionUpdatedOptions = {
|
export type OnSubscriptionUpdatedOptions = {
|
||||||
@ -92,6 +93,22 @@ export const onSubscriptionUpdated = async ({
|
|||||||
? new Date(subscription.trial_end * 1000)
|
? new Date(subscription.trial_end * 1000)
|
||||||
: new Date(subscription.current_period_end * 1000);
|
: new Date(subscription.current_period_end * 1000);
|
||||||
|
|
||||||
|
// Migrate the organisation type if it is no longer an individual plan.
|
||||||
|
if (
|
||||||
|
updatedSubscriptionClaim.id !== INTERNAL_CLAIM_ID.INDIVIDUAL &&
|
||||||
|
updatedSubscriptionClaim.id !== INTERNAL_CLAIM_ID.FREE &&
|
||||||
|
organisation.type === OrganisationType.PERSONAL
|
||||||
|
) {
|
||||||
|
await prisma.organisation.update({
|
||||||
|
where: {
|
||||||
|
id: organisation.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
type: OrganisationType.ORGANISATION,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
await tx.subscription.update({
|
await tx.subscription.update({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import { ZCreateSubscriptionRequestSchema } from './create-subscription.types';
|
|||||||
export const createSubscriptionRoute = authenticatedProcedure
|
export const createSubscriptionRoute = authenticatedProcedure
|
||||||
.input(ZCreateSubscriptionRequestSchema)
|
.input(ZCreateSubscriptionRequestSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const { organisationId, priceId } = input;
|
const { organisationId, priceId, isPersonalLayoutMode } = input;
|
||||||
|
|
||||||
ctx.logger.info({
|
ctx.logger.info({
|
||||||
input: {
|
input: {
|
||||||
@ -70,10 +70,14 @@ export const createSubscriptionRoute = authenticatedProcedure
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const returnUrl = isPersonalLayoutMode
|
||||||
|
? `${NEXT_PUBLIC_WEBAPP_URL()}/settings/billing`
|
||||||
|
: `${NEXT_PUBLIC_WEBAPP_URL()}/o/${organisation.url}/settings/billing`;
|
||||||
|
|
||||||
const redirectUrl = await createCheckoutSession({
|
const redirectUrl = await createCheckoutSession({
|
||||||
customerId,
|
customerId,
|
||||||
priceId,
|
priceId,
|
||||||
returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/o/${organisation.url}/settings/billing`,
|
returnUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!redirectUrl) {
|
if (!redirectUrl) {
|
||||||
|
|||||||
@ -3,4 +3,5 @@ import { z } from 'zod';
|
|||||||
export const ZCreateSubscriptionRequestSchema = z.object({
|
export const ZCreateSubscriptionRequestSchema = z.object({
|
||||||
organisationId: z.string().describe('The organisation to create the subscription for'),
|
organisationId: z.string().describe('The organisation to create the subscription for'),
|
||||||
priceId: z.string().describe('The price to create the subscription for'),
|
priceId: z.string().describe('The price to create the subscription for'),
|
||||||
|
isPersonalLayoutMode: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user