mirror of
https://github.com/documenso/documenso.git
synced 2026-07-27 10:25:00 +10:00
fix: update stripe team member billing (#2991)
This commit is contained in:
@@ -1,98 +1,15 @@
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { redirect } from 'react-router';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { OrganisationMemberInviteStatus } from '@prisma/client';
|
|
||||||
import { Link } from 'react-router';
|
|
||||||
|
|
||||||
import type { Route } from './+types/organisation.decline.$token';
|
import type { Route } from './+types/organisation.decline.$token';
|
||||||
|
|
||||||
export async function loader({ params }: Route.LoaderArgs) {
|
export function loader({ params }: Route.LoaderArgs) {
|
||||||
const { token } = params;
|
const { token } = params;
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return {
|
throw redirect('/');
|
||||||
state: 'InvalidLink',
|
|
||||||
} as const;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const organisationMemberInvite = await prisma.organisationMemberInvite.findUnique({
|
// Declining now happens on the invite page via tRPC. Redirect there with the
|
||||||
where: {
|
// `action=decline` flag so it renders the decline-only view (no accept).
|
||||||
token,
|
throw redirect(`/organisation/invite/${token}?action=decline`);
|
||||||
},
|
|
||||||
include: {
|
|
||||||
organisation: {
|
|
||||||
select: {
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!organisationMemberInvite) {
|
|
||||||
return {
|
|
||||||
state: 'InvalidLink',
|
|
||||||
} as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (organisationMemberInvite.status !== OrganisationMemberInviteStatus.DECLINED) {
|
|
||||||
await prisma.organisationMemberInvite.update({
|
|
||||||
where: {
|
|
||||||
id: organisationMemberInvite.id,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
status: OrganisationMemberInviteStatus.DECLINED,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
state: 'Success',
|
|
||||||
organisationName: organisationMemberInvite.organisation.name,
|
|
||||||
} as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DeclineInvitationPage({ loaderData }: Route.ComponentProps) {
|
|
||||||
const data = loaderData;
|
|
||||||
|
|
||||||
if (data.state === 'InvalidLink') {
|
|
||||||
return (
|
|
||||||
<div className="w-screen max-w-lg px-4">
|
|
||||||
<div className="w-full">
|
|
||||||
<h1 className="font-semibold text-4xl">
|
|
||||||
<Trans>Invalid token</Trans>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p className="mt-2 mb-4 text-muted-foreground text-sm">
|
|
||||||
<Trans>This token is invalid or has expired. No action is needed.</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button asChild>
|
|
||||||
<Link to="/">
|
|
||||||
<Trans>Return</Trans>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-screen max-w-lg px-4">
|
|
||||||
<h1 className="font-semibold text-4xl">
|
|
||||||
<Trans>Invitation declined</Trans>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p className="mt-2 mb-4 text-muted-foreground text-sm">
|
|
||||||
<Trans>
|
|
||||||
You have declined the invitation from <strong>{data.organisationName}</strong> to join their organisation.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button asChild>
|
|
||||||
<Link to="/">
|
|
||||||
<Trans>Return to Home</Trans>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
import { acceptOrganisationInvitation } from '@documenso/lib/server-only/organisation/accept-organisation-invitation';
|
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
import { Link } from 'react-router';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
|
import { OrganisationMemberInviteStatus } from '@prisma/client';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Link, useSearchParams } from 'react-router';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import type { Route } from './+types/organisation.invite.$token';
|
import type { Route } from './+types/organisation.invite.$token';
|
||||||
|
|
||||||
@@ -37,6 +43,22 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const organisationName = organisationMemberInvite.organisation.name;
|
||||||
|
|
||||||
|
if (organisationMemberInvite.status === OrganisationMemberInviteStatus.ACCEPTED) {
|
||||||
|
return {
|
||||||
|
state: 'AlreadyAccepted',
|
||||||
|
organisationName,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (organisationMemberInvite.status === OrganisationMemberInviteStatus.DECLINED) {
|
||||||
|
return {
|
||||||
|
state: 'AlreadyDeclined',
|
||||||
|
organisationName,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
const user = await prisma.user.findFirst({
|
const user = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
email: {
|
email: {
|
||||||
@@ -49,26 +71,13 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Directly convert the team member invite to a team member if they already have an account.
|
|
||||||
if (user) {
|
|
||||||
await acceptOrganisationInvitation({ token: organisationMemberInvite.token });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return {
|
|
||||||
state: 'LoginRequired',
|
|
||||||
email: organisationMemberInvite.email,
|
|
||||||
organisationName: organisationMemberInvite.organisation.name,
|
|
||||||
} as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSessionUserTheInvitedUser = user.id === session.user?.id;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
state: 'Success',
|
state: 'Pending',
|
||||||
|
token: organisationMemberInvite.token,
|
||||||
email: organisationMemberInvite.email,
|
email: organisationMemberInvite.email,
|
||||||
organisationName: organisationMemberInvite.organisation.name,
|
organisationName,
|
||||||
isSessionUserTheInvitedUser,
|
userExists: user !== null,
|
||||||
|
isSessionUserTheInvitedUser: user !== null && user.id === session.user?.id,
|
||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,57 +106,253 @@ export default function AcceptInvitationPage({ loaderData }: Route.ComponentProp
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.state === 'LoginRequired') {
|
if (data.state === 'AlreadyAccepted') {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="w-screen max-w-lg px-4">
|
||||||
<h1 className="font-semibold text-4xl">
|
<div className="w-full">
|
||||||
<Trans>Organisation invitation</Trans>
|
<h1 className="font-semibold text-4xl">
|
||||||
</h1>
|
<Trans>Invitation already accepted</Trans>
|
||||||
|
</h1>
|
||||||
|
|
||||||
<p className="mt-2 text-muted-foreground text-sm">
|
<p className="mt-2 mb-4 text-muted-foreground text-sm">
|
||||||
<Trans>
|
<Trans>
|
||||||
You have been invited by <strong>{data.organisationName}</strong> to join their organisation.
|
You are already a member of <strong>{data.organisationName}</strong>.
|
||||||
</Trans>
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="mt-1 mb-4 text-muted-foreground text-sm">
|
<Button asChild>
|
||||||
<Trans>To accept this invitation you must create an account.</Trans>
|
<Link to="/">
|
||||||
</p>
|
<Trans>Continue</Trans>
|
||||||
|
</Link>
|
||||||
<Button asChild>
|
</Button>
|
||||||
<Link to={`/signup#email=${encodeURIComponent(data.email)}`}>
|
</div>
|
||||||
<Trans>Create account</Trans>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.state === 'AlreadyDeclined') {
|
||||||
|
return <InvitationDeclined organisationName={data.organisationName} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<PendingInvitation
|
||||||
<h1 className="font-semibold text-4xl">
|
token={data.token}
|
||||||
<Trans>Invitation accepted!</Trans>
|
email={data.email}
|
||||||
</h1>
|
organisationName={data.organisationName}
|
||||||
|
userExists={data.userExists}
|
||||||
<p className="mt-2 mb-4 text-muted-foreground text-sm">
|
isSessionUserTheInvitedUser={data.isSessionUserTheInvitedUser}
|
||||||
<Trans>
|
/>
|
||||||
You have accepted an invitation from <strong>{data.organisationName}</strong> to join their organisation.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{data.isSessionUserTheInvitedUser ? (
|
|
||||||
<Button asChild>
|
|
||||||
<Link to="/">
|
|
||||||
<Trans>Continue</Trans>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button asChild>
|
|
||||||
<Link to={`/signin#email=${encodeURIComponent(data.email)}`}>
|
|
||||||
<Trans>Continue to login</Trans>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PendingInvitationProps = {
|
||||||
|
token: string;
|
||||||
|
email: string;
|
||||||
|
organisationName: string;
|
||||||
|
userExists: boolean;
|
||||||
|
isSessionUserTheInvitedUser: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type InvitationResult = 'idle' | 'accepted' | 'declined';
|
||||||
|
|
||||||
|
type AcceptFailureReason = 'CapExceeded' | 'SubscriptionInactive' | 'Unknown';
|
||||||
|
|
||||||
|
const PendingInvitation = ({
|
||||||
|
token,
|
||||||
|
email,
|
||||||
|
organisationName,
|
||||||
|
userExists,
|
||||||
|
isSessionUserTheInvitedUser,
|
||||||
|
}: PendingInvitationProps) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { refreshSession } = useOptionalSession();
|
||||||
|
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const actionIsDecline = searchParams.get('action') === 'decline';
|
||||||
|
|
||||||
|
const [result, setResult] = useState<InvitationResult>('idle');
|
||||||
|
const [acceptFailureReason, setAcceptFailureReason] = useState<AcceptFailureReason | null>(null);
|
||||||
|
|
||||||
|
const acceptInvitation = trpc.organisation.member.invite.accept.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
await refreshSession();
|
||||||
|
|
||||||
|
setResult('accepted');
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
const failureReason = match(error.code)
|
||||||
|
.with(AppErrorCode.LIMIT_EXCEEDED, () => 'CapExceeded' as const)
|
||||||
|
.with('SUBSCRIPTION_INACTIVE', () => 'SubscriptionInactive' as const)
|
||||||
|
.otherwise(() => 'Unknown' as const);
|
||||||
|
|
||||||
|
setAcceptFailureReason(failureReason);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const declineInvitation = trpc.organisation.member.invite.decline.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
await refreshSession();
|
||||||
|
|
||||||
|
setResult('declined');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({
|
||||||
|
title: t`Something went wrong`,
|
||||||
|
description: t`Unable to decline this invitation at this time.`,
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 10000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result === 'accepted') {
|
||||||
|
return (
|
||||||
|
<div className="w-screen max-w-lg px-4">
|
||||||
|
<div className="w-full">
|
||||||
|
<h1 className="font-semibold text-4xl">
|
||||||
|
<Trans>Invitation accepted!</Trans>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="mt-2 mb-4 text-muted-foreground text-sm">
|
||||||
|
<Trans>
|
||||||
|
You have accepted an invitation from <strong>{organisationName}</strong> to join their organisation.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{isSessionUserTheInvitedUser ? (
|
||||||
|
<Button asChild>
|
||||||
|
<Link to="/">
|
||||||
|
<Trans>Continue</Trans>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button asChild>
|
||||||
|
<Link to={`/signin#email=${encodeURIComponent(email)}`}>
|
||||||
|
<Trans>Continue to login</Trans>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result === 'declined') {
|
||||||
|
return <InvitationDeclined organisationName={organisationName} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accepting requires an account (acceptance keys off the invited email).
|
||||||
|
// Declining does not, so we only gate account creation on the accept flow.
|
||||||
|
if (!actionIsDecline && !userExists) {
|
||||||
|
return (
|
||||||
|
<div className="w-screen max-w-lg px-4">
|
||||||
|
<div className="w-full">
|
||||||
|
<h1 className="font-semibold text-4xl">
|
||||||
|
<Trans>Organisation invitation</Trans>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="mt-2 text-muted-foreground text-sm">
|
||||||
|
<Trans>
|
||||||
|
You have been invited by <strong>{organisationName}</strong> to join their organisation.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="mt-1 mb-4 text-muted-foreground text-sm">
|
||||||
|
<Trans>To accept this invitation you must create an account.</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button asChild>
|
||||||
|
<Link to={`/signup#email=${encodeURIComponent(email)}`}>
|
||||||
|
<Trans>Create account</Trans>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPending = acceptInvitation.isPending || declineInvitation.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-screen max-w-lg px-4">
|
||||||
|
<div className="w-full">
|
||||||
|
<h1 className="font-semibold text-4xl">
|
||||||
|
<Trans>Organisation invitation</Trans>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="mt-2 mb-4 text-muted-foreground text-sm">
|
||||||
|
<Trans>
|
||||||
|
You have been invited to join <strong>{organisationName}</strong> on Documenso.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{acceptFailureReason && (
|
||||||
|
<p className="mt-2 mb-4 text-destructive text-sm">
|
||||||
|
{match(acceptFailureReason)
|
||||||
|
.with('CapExceeded', () => (
|
||||||
|
<Trans>
|
||||||
|
<strong>{organisationName}</strong> has reached its member limit. Please contact the organisation
|
||||||
|
administrator to upgrade their plan before accepting this invitation.
|
||||||
|
</Trans>
|
||||||
|
))
|
||||||
|
.with('SubscriptionInactive', () => (
|
||||||
|
<Trans>
|
||||||
|
<strong>{organisationName}</strong> does not have an active subscription. Please contact the
|
||||||
|
organisation administrator to renew their plan before accepting this invitation.
|
||||||
|
</Trans>
|
||||||
|
))
|
||||||
|
.with('Unknown', () => (
|
||||||
|
<Trans>
|
||||||
|
We were unable to add you to <strong>{organisationName}</strong> at this time. Please try again later,
|
||||||
|
or contact the organisation administrator.
|
||||||
|
</Trans>
|
||||||
|
))
|
||||||
|
.exhaustive()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-x-4">
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={async () => declineInvitation.mutateAsync({ token })}
|
||||||
|
loading={declineInvitation.isPending}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<Trans>Decline</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{!actionIsDecline && (
|
||||||
|
<Button
|
||||||
|
onClick={async () => acceptInvitation.mutateAsync({ token })}
|
||||||
|
loading={acceptInvitation.isPending}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<Trans>Accept</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const InvitationDeclined = ({ organisationName }: { organisationName: string }) => {
|
||||||
|
return (
|
||||||
|
<div className="w-screen max-w-lg px-4">
|
||||||
|
<div className="w-full">
|
||||||
|
<h1 className="font-semibold text-4xl">
|
||||||
|
<Trans>Invitation declined</Trans>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="mt-2 mb-4 text-muted-foreground text-sm">
|
||||||
|
<Trans>
|
||||||
|
You have declined the invitation from <strong>{organisationName}</strong> to join their organisation.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { OrganisationType, type Prisma, SubscriptionStatus } from '@prisma/client';
|
import { OrganisationType, type Prisma, SubscriptionStatus } from '@prisma/client';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
import { reconcileSeatBasedPlans } from './update-subscription-item-quantity';
|
||||||
|
|
||||||
const LIVE_SUBSCRIPTION_STATUSES: Stripe.Subscription.Status[] = ['active', 'trialing', 'past_due'];
|
const LIVE_SUBSCRIPTION_STATUSES: Stripe.Subscription.Status[] = ['active', 'trialing', 'past_due'];
|
||||||
|
|
||||||
@@ -229,6 +230,19 @@ const handleLiveSubscription = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Detect a billing-period roll by comparing the persisted period end with
|
||||||
|
// the freshly-fetched one — the convergent equivalent of the old
|
||||||
|
// `previous_attributes.current_period_start` signal. On renewal, reconcile
|
||||||
|
// the seat quantity and claim down to the actual member count. The reconcile
|
||||||
|
// itself no-ops for non-seat/unlimited plans and inactive subscriptions.
|
||||||
|
const previousPeriodEnd = organisation.subscription?.periodEnd ?? null;
|
||||||
|
|
||||||
|
const hasPeriodAdvanced = previousPeriodEnd !== null && periodEnd.getTime() > previousPeriodEnd.getTime();
|
||||||
|
|
||||||
|
if (hasPeriodAdvanced && !bypassClaimUpdate) {
|
||||||
|
await reconcileSeatBasedPlans(organisation.id);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { stripe } from '@documenso/lib/server-only/stripe';
|
|||||||
import { appLog } from '@documenso/lib/utils/debugger';
|
import { appLog } from '@documenso/lib/utils/debugger';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import type { OrganisationClaim, Subscription } from '@prisma/client';
|
import type { OrganisationClaim, Subscription } from '@prisma/client';
|
||||||
|
import { SubscriptionStatus } from '@prisma/client';
|
||||||
import type Stripe from 'stripe';
|
import type Stripe from 'stripe';
|
||||||
|
|
||||||
import { isPriceSeatsBased } from './is-price-seats-based';
|
import { isPriceSeatsBased } from './is-price-seats-based';
|
||||||
@@ -11,12 +12,14 @@ export type UpdateSubscriptionItemQuantityOptions = {
|
|||||||
subscriptionId: string;
|
subscriptionId: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
priceId: string;
|
priceId: string;
|
||||||
|
prorationBehaviour: 'always_invoice' | 'none';
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateSubscriptionItemQuantity = async ({
|
export const updateSubscriptionItemQuantity = async ({
|
||||||
subscriptionId,
|
subscriptionId,
|
||||||
quantity,
|
quantity,
|
||||||
priceId,
|
priceId,
|
||||||
|
prorationBehaviour,
|
||||||
}: UpdateSubscriptionItemQuantityOptions) => {
|
}: UpdateSubscriptionItemQuantityOptions) => {
|
||||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||||
|
|
||||||
@@ -26,7 +29,6 @@ export const updateSubscriptionItemQuantity = async ({
|
|||||||
throw new Error('Subscription does not contain required item');
|
throw new Error('Subscription does not contain required item');
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasYearlyItem = items.find((item) => item.price.recurring?.interval === 'year');
|
|
||||||
const oldQuantity = items[0].quantity;
|
const oldQuantity = items[0].quantity;
|
||||||
|
|
||||||
if (oldQuantity === quantity) {
|
if (oldQuantity === quantity) {
|
||||||
@@ -38,13 +40,12 @@ export const updateSubscriptionItemQuantity = async ({
|
|||||||
id: item.id,
|
id: item.id,
|
||||||
quantity,
|
quantity,
|
||||||
})),
|
})),
|
||||||
|
proration_behavior: prorationBehaviour,
|
||||||
|
// Need to "off_session" updates since adding 3DS will have payments
|
||||||
|
// not pass through for these immediate invoices.
|
||||||
|
off_session: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only invoice immediately when changing the quantity of yearly item.
|
|
||||||
if (hasYearlyItem) {
|
|
||||||
subscriptionUpdatePayload.proration_behavior = 'always_invoice';
|
|
||||||
}
|
|
||||||
|
|
||||||
await stripe.subscriptions.update(subscriptionId, subscriptionUpdatePayload);
|
await stripe.subscriptions.update(subscriptionId, subscriptionUpdatePayload);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -55,15 +56,19 @@ export const updateSubscriptionItemQuantity = async ({
|
|||||||
* via Stripe rather than enforcing a hard cap. A `memberCount` of `0` on the
|
* via Stripe rather than enforcing a hard cap. A `memberCount` of `0` on the
|
||||||
* organisation claim represents unlimited seats.
|
* organisation claim represents unlimited seats.
|
||||||
*
|
*
|
||||||
|
* Organisations without a subscription (e.g. after being downgraded to the
|
||||||
|
* free plan) can pass `null`, in which case the claim cap is enforced
|
||||||
|
* directly without the seats-based exemption.
|
||||||
|
*
|
||||||
* Should only be called from grow paths (invite/add). Reducing operations
|
* Should only be called from grow paths (invite/add). Reducing operations
|
||||||
* must never be gated by this check.
|
* must never be gated by this check.
|
||||||
*
|
*
|
||||||
* @param subscription - The organisation's Stripe subscription.
|
* @param subscription - The organisation's Stripe subscription, if any.
|
||||||
* @param organisationClaim - The organisation claim.
|
* @param organisationClaim - The organisation claim.
|
||||||
* @param quantity - The proposed total member + pending invite count.
|
* @param quantity - The proposed total member count.
|
||||||
*/
|
*/
|
||||||
export const assertMemberCountWithinCap = async (
|
export const assertMemberCountWithinCap = async (
|
||||||
subscription: Subscription,
|
subscription: Subscription | null,
|
||||||
organisationClaim: OrganisationClaim,
|
organisationClaim: OrganisationClaim,
|
||||||
quantity: number,
|
quantity: number,
|
||||||
) => {
|
) => {
|
||||||
@@ -75,10 +80,12 @@ export const assertMemberCountWithinCap = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Seats-based plans don't have a hard cap; Stripe meters the usage.
|
// Seats-based plans don't have a hard cap; Stripe meters the usage.
|
||||||
const isSeatsBased = await isPriceSeatsBased(subscription.priceId);
|
if (subscription) {
|
||||||
|
const isSeatsBased = await isPriceSeatsBased(subscription.priceId);
|
||||||
|
|
||||||
if (isSeatsBased) {
|
if (isSeatsBased) {
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (quantity > maximumMemberCount) {
|
if (quantity > maximumMemberCount) {
|
||||||
@@ -89,48 +96,134 @@ export const assertMemberCountWithinCap = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Syncs the organisation's member count with the Stripe subscription quantity.
|
* Syncs the Stripe subscription quantity with the organisation's member count.
|
||||||
*
|
*
|
||||||
* No-ops for plans that are not seats-based, and for organisations with
|
* This is a Stripe <-> Database sync operation.
|
||||||
* unlimited seats (`organisationClaim.memberCount === 0`). Safe to call from
|
*
|
||||||
* both grow and shrink paths.
|
* Note: `organisationClaim.memberCount` is the paid seat high-water mark for the
|
||||||
|
* current billing period — the highest count we've already billed for.
|
||||||
*
|
*
|
||||||
* @param subscription - The subscription to sync the member count with.
|
* @param subscription - The subscription to sync the member count with.
|
||||||
* @param organisationClaim - The organisation claim.
|
* @param organisationClaim - The organisation claim.
|
||||||
* @param quantity - The new total member + pending invite count to sync.
|
* @param quantity - The new total member count to sync.
|
||||||
|
* @param mode - The member-count change that triggered the sync.
|
||||||
*/
|
*/
|
||||||
export const syncMemberCountWithStripeSeatPlan = async (
|
export const syncMemberCountWithStripeSeatPlan = async (
|
||||||
subscription: Subscription,
|
subscription: Subscription,
|
||||||
organisationClaim: OrganisationClaim,
|
organisationClaim: OrganisationClaim,
|
||||||
quantity: number,
|
quantity: number,
|
||||||
|
mode: 'grow' | 'shrink',
|
||||||
) => {
|
) => {
|
||||||
// Infinite seats means no sync needed.
|
// Unlimited seats — nothing to meter.
|
||||||
if (organisationClaim.memberCount === 0) {
|
if (organisationClaim.memberCount === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSeatsBased = await isPriceSeatsBased(subscription.priceId);
|
const isSeatsBased = await isPriceSeatsBased(subscription.priceId);
|
||||||
|
|
||||||
|
// Only seat-based plans support seat syncing.
|
||||||
if (!isSeatsBased) {
|
if (!isSeatsBased) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
appLog('BILLING', 'Updating seat based plan');
|
// Whether to immediately invoice for new seats if the quantity is greater than
|
||||||
|
// the high-water mark.
|
||||||
|
const billsForNewSeats = mode === 'grow' && quantity > organisationClaim.memberCount;
|
||||||
|
|
||||||
|
appLog('BILLING', `Syncing seat based plan (${mode}, quantity ${quantity})`);
|
||||||
|
|
||||||
await updateSubscriptionItemQuantity({
|
await updateSubscriptionItemQuantity({
|
||||||
priceId: subscription.priceId,
|
priceId: subscription.priceId,
|
||||||
subscriptionId: subscription.planId,
|
subscriptionId: subscription.planId,
|
||||||
quantity,
|
quantity,
|
||||||
|
prorationBehaviour: billsForNewSeats ? 'always_invoice' : 'none',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Advance the high-water mark when billing for new seats; it is reset to the
|
||||||
|
// actual member count when the billing period rolls over. Re-adds and shrinks
|
||||||
|
// deliberately leave it untouched so a seat already paid for this period is
|
||||||
|
// never re-charged.
|
||||||
|
if (billsForNewSeats) {
|
||||||
|
await prisma.organisationClaim.update({
|
||||||
|
where: {
|
||||||
|
id: organisationClaim.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
memberCount: quantity,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconciles the organisation claim seat counter, and the stripe quantity with the
|
||||||
|
* actual member count.
|
||||||
|
*
|
||||||
|
* Uses the member count as the authoritative source of truth. Meaning:
|
||||||
|
* - Update the organisation claim with the member count
|
||||||
|
* - Update the Stripe subscription quantity to the member count
|
||||||
|
*
|
||||||
|
* This should only be called when the billing period rolls over.
|
||||||
|
*/
|
||||||
|
export const reconcileSeatBasedPlans = async (organisationId: string) => {
|
||||||
|
const organisation = await prisma.organisation.findFirst({
|
||||||
|
where: {
|
||||||
|
id: organisationId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
organisationClaim: true,
|
||||||
|
subscription: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!organisation || !organisation.subscription) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { subscription, organisationClaim } = organisation;
|
||||||
|
|
||||||
|
// Stripe rejects quantity updates on canceled subscriptions. PAST_DUE is
|
||||||
|
// still live and a no-proration sync is safe, so it's allowed through.
|
||||||
|
if (subscription.status === SubscriptionStatus.INACTIVE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlimited seats — nothing to meter.
|
||||||
|
if (organisationClaim.memberCount === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSeatsBased = await isPriceSeatsBased(subscription.priceId);
|
||||||
|
|
||||||
|
// Only seat-based plans support seat syncing.
|
||||||
|
if (!isSeatsBased) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberCount = await prisma.organisationMember.count({
|
||||||
|
where: {
|
||||||
|
organisationId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// An organisation always retains its owner; never write the unlimited sentinel.
|
||||||
|
if (memberCount === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateSubscriptionItemQuantity({
|
||||||
|
priceId: subscription.priceId,
|
||||||
|
subscriptionId: subscription.planId,
|
||||||
|
quantity: memberCount,
|
||||||
|
prorationBehaviour: 'none',
|
||||||
});
|
});
|
||||||
|
|
||||||
// This should be automatically updated after the Stripe webhook is fired
|
|
||||||
// but we just manually adjust it here as well to avoid any race conditions.
|
|
||||||
await prisma.organisationClaim.update({
|
await prisma.organisationClaim.update({
|
||||||
where: {
|
where: {
|
||||||
id: organisationClaim.id,
|
id: organisationClaim.id,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
memberCount: quantity,
|
memberCount,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
|||||||
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||||
import { env } from '@documenso/lib/utils/env';
|
import { env } from '@documenso/lib/utils/env';
|
||||||
|
|
||||||
import { syncStripeCustomerSubscription } from '../sync-stripe-customer-subscription';
|
import { syncStripeCustomerSubscription } from '../sync-stripe-customer-subscription';
|
||||||
|
|
||||||
type StripeWebhookResponse = {
|
type StripeWebhookResponse = {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION } from './definitions/emai
|
|||||||
import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-signing-email';
|
import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-signing-email';
|
||||||
import { SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-deleted-email';
|
import { SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-deleted-email';
|
||||||
import { ADMIN_DELETE_ORGANISATION_JOB_DEFINITION } from './definitions/internal/admin-delete-organisation';
|
import { ADMIN_DELETE_ORGANISATION_JOB_DEFINITION } from './definitions/internal/admin-delete-organisation';
|
||||||
|
import { ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION } from './definitions/internal/alert-organisation-seat-drift';
|
||||||
import { BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION } from './definitions/internal/backport-subscription-claims';
|
import { BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION } from './definitions/internal/backport-subscription-claims';
|
||||||
import { BULK_SEND_TEMPLATE_JOB_DEFINITION } from './definitions/internal/bulk-send-template';
|
import { BULK_SEND_TEMPLATE_JOB_DEFINITION } from './definitions/internal/bulk-send-template';
|
||||||
import { CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION } from './definitions/internal/cancel-organisation-subscription';
|
import { CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION } from './definitions/internal/cancel-organisation-subscription';
|
||||||
@@ -29,6 +30,7 @@ import { SEAL_DOCUMENT_JOB_DEFINITION } from './definitions/internal/seal-docume
|
|||||||
import { SEAL_DOCUMENT_SWEEP_JOB_DEFINITION } from './definitions/internal/seal-document-sweep';
|
import { SEAL_DOCUMENT_SWEEP_JOB_DEFINITION } from './definitions/internal/seal-document-sweep';
|
||||||
import { SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION } from './definitions/internal/send-signing-reminders-sweep';
|
import { SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION } from './definitions/internal/send-signing-reminders-sweep';
|
||||||
import { SYNC_EMAIL_DOMAINS_JOB_DEFINITION } from './definitions/internal/sync-email-domains';
|
import { SYNC_EMAIL_DOMAINS_JOB_DEFINITION } from './definitions/internal/sync-email-domains';
|
||||||
|
import { SYNC_ORGANISATION_SEATS_JOB_DEFINITION } from './definitions/internal/sync-organisation-seats';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `as const` assertion is load bearing as it provides the correct level of type inference for
|
* The `as const` assertion is load bearing as it provides the correct level of type inference for
|
||||||
@@ -64,7 +66,9 @@ export const jobsClient = new JobClient([
|
|||||||
CLEANUP_RATE_LIMITS_JOB_DEFINITION,
|
CLEANUP_RATE_LIMITS_JOB_DEFINITION,
|
||||||
SYNC_EMAIL_DOMAINS_JOB_DEFINITION,
|
SYNC_EMAIL_DOMAINS_JOB_DEFINITION,
|
||||||
ADMIN_DELETE_ORGANISATION_JOB_DEFINITION,
|
ADMIN_DELETE_ORGANISATION_JOB_DEFINITION,
|
||||||
|
ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION,
|
||||||
CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION,
|
CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION,
|
||||||
|
SYNC_ORGANISATION_SEATS_JOB_DEFINITION,
|
||||||
] as const);
|
] as const);
|
||||||
|
|
||||||
export const jobs = jobsClient;
|
export const jobs = jobsClient;
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { mailer } from '@documenso/email/mailer';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { IS_BILLING_ENABLED, SUPPORT_EMAIL } from '../../../constants/app';
|
||||||
|
import { DOCUMENSO_INTERNAL_EMAIL } from '../../../constants/email';
|
||||||
|
import type { JobRunIO } from '../../client/_internal/job';
|
||||||
|
import type { TAlertOrganisationSeatDriftJobDefinition } from './alert-organisation-seat-drift';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Daily check for organisations whose member count exceeds their paid seat
|
||||||
|
* count (`organisationClaim.memberCount`, where `0` means unlimited).
|
||||||
|
*/
|
||||||
|
export const run = async ({ io }: { payload: TAlertOrganisationSeatDriftJobDefinition; io: JobRunIO }) => {
|
||||||
|
if (!IS_BILLING_ENABLED()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const organisations = await prisma.organisation.findMany({
|
||||||
|
where: {
|
||||||
|
// Exclude unlimited-seat plans (memberCount === 0).
|
||||||
|
organisationClaim: {
|
||||||
|
memberCount: {
|
||||||
|
not: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
organisationClaim: {
|
||||||
|
select: {
|
||||||
|
memberCount: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
members: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const driftedOrganisations = organisations.filter(
|
||||||
|
(organisation) =>
|
||||||
|
organisation.organisationClaim !== null &&
|
||||||
|
organisation._count.members > organisation.organisationClaim.memberCount,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (driftedOrganisations.length === 0) {
|
||||||
|
io.logger.info('No organisations exceed their paid seat count');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await mailer.sendMail({
|
||||||
|
to: SUPPORT_EMAIL,
|
||||||
|
from: DOCUMENSO_INTERNAL_EMAIL,
|
||||||
|
subject: `[Billing] ${driftedOrganisations.length} organisation(s) exceed their paid seat count`,
|
||||||
|
text: [
|
||||||
|
`${driftedOrganisations.length} organisation(s) have more members than their paid seat count:`,
|
||||||
|
'',
|
||||||
|
...driftedOrganisations.map(
|
||||||
|
(organisation) =>
|
||||||
|
`- ${organisation.name} (${organisation.id}): ${organisation._count.members} members vs ${organisation.organisationClaim?.memberCount ?? 0} paid seats`,
|
||||||
|
),
|
||||||
|
].join('\n'),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import type { JobDefinition } from '../../client/_internal/job';
|
||||||
|
|
||||||
|
const ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_ID = 'internal.alert-organisation-seat-drift';
|
||||||
|
|
||||||
|
const ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_SCHEMA = z.object({});
|
||||||
|
|
||||||
|
export type TAlertOrganisationSeatDriftJobDefinition = z.infer<
|
||||||
|
typeof ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_SCHEMA
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION = {
|
||||||
|
id: ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_ID,
|
||||||
|
name: 'Alert Organisation Seat Drift',
|
||||||
|
version: '1.0.0',
|
||||||
|
trigger: {
|
||||||
|
name: ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_ID,
|
||||||
|
schema: ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_SCHEMA,
|
||||||
|
cron: '0 0 * * *', // Once a day at midnight.
|
||||||
|
},
|
||||||
|
handler: async ({ payload, io }) => {
|
||||||
|
const handler = await import('./alert-organisation-seat-drift.handler');
|
||||||
|
|
||||||
|
await handler.run({ payload, io });
|
||||||
|
},
|
||||||
|
} as const satisfies JobDefinition<
|
||||||
|
typeof ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_ID,
|
||||||
|
TAlertOrganisationSeatDriftJobDefinition
|
||||||
|
>;
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { SubscriptionStatus } from '@prisma/client';
|
||||||
|
import { IS_BILLING_ENABLED } from '../../../constants/app';
|
||||||
|
import type { JobRunIO } from '../../client/_internal/job';
|
||||||
|
import type { TSyncOrganisationSeatsJobDefinition } from './sync-organisation-seats';
|
||||||
|
|
||||||
|
export const run = async ({ payload }: { payload: TSyncOrganisationSeatsJobDefinition; io: JobRunIO }) => {
|
||||||
|
const { organisationId } = payload;
|
||||||
|
|
||||||
|
if (!IS_BILLING_ENABLED()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const organisation = await prisma.organisation.findUnique({
|
||||||
|
where: {
|
||||||
|
id: organisationId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
subscription: true,
|
||||||
|
organisationClaim: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!organisation || !organisation.subscription) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip canceled/terminal subscriptions — Stripe rejects quantity updates on a
|
||||||
|
// canceled subscription. PAST_DUE is still live and a no-proration shrink is
|
||||||
|
// safe, so it's allowed through.
|
||||||
|
if (organisation.subscription.status === SubscriptionStatus.INACTIVE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberCount = await prisma.organisationMember.count({
|
||||||
|
where: {
|
||||||
|
organisationId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// An organisation always retains its owner; guarding zero avoids writing the
|
||||||
|
// unlimited sentinel to the claim.
|
||||||
|
if (memberCount === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await syncMemberCountWithStripeSeatPlan(
|
||||||
|
organisation.subscription,
|
||||||
|
organisation.organisationClaim,
|
||||||
|
memberCount,
|
||||||
|
'shrink',
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import type { JobDefinition } from '../../client/_internal/job';
|
||||||
|
|
||||||
|
const SYNC_ORGANISATION_SEATS_JOB_DEFINITION_ID = 'internal.sync-organisation-seats';
|
||||||
|
|
||||||
|
const SYNC_ORGANISATION_SEATS_JOB_DEFINITION_SCHEMA = z.object({
|
||||||
|
organisationId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TSyncOrganisationSeatsJobDefinition = z.infer<typeof SYNC_ORGANISATION_SEATS_JOB_DEFINITION_SCHEMA>;
|
||||||
|
|
||||||
|
export const SYNC_ORGANISATION_SEATS_JOB_DEFINITION = {
|
||||||
|
id: SYNC_ORGANISATION_SEATS_JOB_DEFINITION_ID,
|
||||||
|
name: 'Sync Organisation Seats',
|
||||||
|
version: '1.0.0',
|
||||||
|
trigger: {
|
||||||
|
name: SYNC_ORGANISATION_SEATS_JOB_DEFINITION_ID,
|
||||||
|
schema: SYNC_ORGANISATION_SEATS_JOB_DEFINITION_SCHEMA,
|
||||||
|
},
|
||||||
|
handler: async ({ payload, io }) => {
|
||||||
|
const handler = await import('./sync-organisation-seats.handler');
|
||||||
|
|
||||||
|
await handler.run({ payload, io });
|
||||||
|
},
|
||||||
|
} as const satisfies JobDefinition<
|
||||||
|
typeof SYNC_ORGANISATION_SEATS_JOB_DEFINITION_ID,
|
||||||
|
TSyncOrganisationSeatsJobDefinition
|
||||||
|
>;
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
|
import {
|
||||||
|
assertMemberCountWithinCap,
|
||||||
|
syncMemberCountWithStripeSeatPlan,
|
||||||
|
} from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import type { OrganisationGroup, OrganisationMemberRole } from '@prisma/client';
|
import type { OrganisationGroup, OrganisationMemberRole } from '@prisma/client';
|
||||||
import { OrganisationGroupType, OrganisationMemberInviteStatus } from '@prisma/client';
|
import { OrganisationGroupType, OrganisationMemberInviteStatus, SubscriptionStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
import { IS_BILLING_ENABLED } from '../../constants/app';
|
||||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
import { jobs } from '../../jobs/client';
|
import { jobs } from '../../jobs/client';
|
||||||
import { generateDatabaseId } from '../../universal/id';
|
import { generateDatabaseId } from '../../universal/id';
|
||||||
@@ -22,6 +27,13 @@ export const acceptOrganisationInvitation = async ({ token }: AcceptOrganisation
|
|||||||
organisation: {
|
organisation: {
|
||||||
include: {
|
include: {
|
||||||
groups: true,
|
groups: true,
|
||||||
|
organisationClaim: true,
|
||||||
|
subscription: true,
|
||||||
|
members: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -66,6 +78,35 @@ export const acceptOrganisationInvitation = async ({ token }: AcceptOrganisation
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const newMemberCount = organisation.members.length + 1;
|
||||||
|
|
||||||
|
// Billing occurs when a user accepts an invite.
|
||||||
|
// Assert that the new member count is within the cap and sync the seat plan with Stripe.
|
||||||
|
if (IS_BILLING_ENABLED()) {
|
||||||
|
const { subscription, organisationClaim } = organisation;
|
||||||
|
|
||||||
|
// A canceled subscription cannot have its seat quantity updated in Stripe,
|
||||||
|
// and an organisation with lapsed billing should not gain new members.
|
||||||
|
// Throw a deliberate error so the invite page can render an accurate
|
||||||
|
// message instead of an opaque Stripe failure.
|
||||||
|
if (subscription && subscription.status === SubscriptionStatus.INACTIVE) {
|
||||||
|
throw new AppError('SUBSCRIPTION_INACTIVE', {
|
||||||
|
message: 'The organisation subscription is inactive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Organisations can exist without a subscription (e.g. after being
|
||||||
|
// downgraded to the free plan). The claim cap remains authoritative in
|
||||||
|
// that case, surfacing LIMIT_EXCEEDED instead of an opaque "subscription
|
||||||
|
// not found" error.
|
||||||
|
await assertMemberCountWithinCap(subscription, organisationClaim, newMemberCount);
|
||||||
|
|
||||||
|
if (subscription) {
|
||||||
|
await syncMemberCountWithStripeSeatPlan(subscription, organisationClaim, newMemberCount, 'grow');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Todo: Logging
|
||||||
await addUserToOrganisation({
|
await addUserToOrganisation({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
organisationId: organisation.id,
|
organisationId: organisation.id,
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
import {
|
|
||||||
assertMemberCountWithinCap,
|
|
||||||
syncMemberCountWithStripeSeatPlan,
|
|
||||||
} from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
|
|
||||||
import { OrganisationInviteEmailTemplate } from '@documenso/email/templates/organisation-invite';
|
import { OrganisationInviteEmailTemplate } from '@documenso/email/templates/organisation-invite';
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
||||||
@@ -17,7 +13,6 @@ import { createElement } from 'react';
|
|||||||
|
|
||||||
import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
||||||
import { generateDatabaseId } from '../../universal/id';
|
import { generateDatabaseId } from '../../universal/id';
|
||||||
import { validateIfSubscriptionIsRequired } from '../../utils/billing';
|
|
||||||
import { buildOrganisationWhereQuery } from '../../utils/organisations';
|
import { buildOrganisationWhereQuery } from '../../utils/organisations';
|
||||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||||
import { getEmailContext } from '../email/get-email-context';
|
import { getEmailContext } from '../email/get-email-context';
|
||||||
@@ -62,8 +57,6 @@ export const createOrganisationMemberInvites = async ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
organisationGlobalSettings: true,
|
organisationGlobalSettings: true,
|
||||||
organisationClaim: true,
|
|
||||||
subscription: true,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -71,10 +64,6 @@ export const createOrganisationMemberInvites = async ({
|
|||||||
throw new AppError(AppErrorCode.NOT_FOUND);
|
throw new AppError(AppErrorCode.NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { organisationClaim } = organisation;
|
|
||||||
|
|
||||||
const subscription = validateIfSubscriptionIsRequired(organisation.subscription);
|
|
||||||
|
|
||||||
const currentOrganisationMemberRole = await getMemberOrganisationRole({
|
const currentOrganisationMemberRole = await getMemberOrganisationRole({
|
||||||
organisationId: organisation.id,
|
organisationId: organisation.id,
|
||||||
reference: {
|
reference: {
|
||||||
@@ -120,19 +109,6 @@ export const createOrganisationMemberInvites = async ({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const numberOfCurrentMembers = organisation.members.length;
|
|
||||||
const numberOfCurrentInvites = organisation.invites.length;
|
|
||||||
const numberOfNewInvites = organisationMemberInvites.length;
|
|
||||||
|
|
||||||
const totalMemberCountWithInvites = numberOfCurrentMembers + numberOfCurrentInvites + numberOfNewInvites;
|
|
||||||
|
|
||||||
// Enforce the seat cap and sync billing for seat based plans.
|
|
||||||
if (subscription) {
|
|
||||||
await assertMemberCountWithinCap(subscription, organisationClaim, totalMemberCountWithInvites);
|
|
||||||
|
|
||||||
await syncMemberCountWithStripeSeatPlan(subscription, organisationClaim, totalMemberCountWithInvites);
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.organisationMemberInvite.createMany({
|
await prisma.organisationMemberInvite.createMany({
|
||||||
data: organisationMemberInvites,
|
data: organisationMemberInvites,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
|
import { jobs } from '../../jobs/client';
|
||||||
import { deleteOrganisation } from '../organisation/delete-organisation';
|
import { deleteOrganisation } from '../organisation/delete-organisation';
|
||||||
|
|
||||||
export type DeleteUserOptions = {
|
export type DeleteUserOptions = {
|
||||||
@@ -59,6 +60,13 @@ export const deleteUser = async ({ id }: DeleteUserOptions) => {
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Organisations the user is a member of (but not owner). Owned organisations
|
||||||
|
// are fully torn down below (including subscription cancellation), so only
|
||||||
|
// these need a seat sync after the user's memberships cascade away.
|
||||||
|
const memberOrganisationIds = user.organisationMember
|
||||||
|
.filter((member) => member.organisation.ownerUserId !== user.id)
|
||||||
|
.map((member) => member.organisationId);
|
||||||
|
|
||||||
// For organisations the user owns - fully tear them down (orphan envelopes,
|
// For organisations the user owns - fully tear them down (orphan envelopes,
|
||||||
// delete the organisation, and cancel any Stripe subscription). Without this
|
// delete the organisation, and cancel any Stripe subscription). Without this
|
||||||
// the organisations would only cascade away when the user row is deleted,
|
// the organisations would only cascade away when the user row is deleted,
|
||||||
@@ -82,9 +90,21 @@ export const deleteUser = async ({ id }: DeleteUserOptions) => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return await prisma.user.delete({
|
const deletedUser = await prisma.user.delete({
|
||||||
where: {
|
where: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The user's memberships were cascade-deleted with the user row — queue a
|
||||||
|
// seat sync for each organisation they belonged to so the Stripe quantity
|
||||||
|
// trues down to the new member count (no proration, no credit).
|
||||||
|
for (const organisationId of memberOrganisationIds) {
|
||||||
|
await jobs.triggerJob({
|
||||||
|
name: 'internal.sync-organisation-seats',
|
||||||
|
payload: { organisationId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return deletedUser;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { jobs } from '@documenso/lib/jobs/client';
|
import { jobs } from '@documenso/lib/jobs/client';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { OrganisationMemberInviteStatus } from '@prisma/client';
|
|
||||||
|
|
||||||
import { adminProcedure } from '../trpc';
|
import { adminProcedure } from '../trpc';
|
||||||
import {
|
import {
|
||||||
@@ -28,8 +26,6 @@ export const deleteAdminOrganisationMemberRoute = adminProcedure
|
|||||||
id: organisationId,
|
id: organisationId,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
subscription: true,
|
|
||||||
organisationClaim: true,
|
|
||||||
teams: {
|
teams: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -41,14 +37,6 @@ export const deleteAdminOrganisationMemberRoute = adminProcedure
|
|||||||
userId: true,
|
userId: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
invites: {
|
|
||||||
where: {
|
|
||||||
status: OrganisationMemberInviteStatus.PENDING,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -72,18 +60,6 @@ export const deleteAdminOrganisationMemberRoute = adminProcedure
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const newMemberCount = organisation.members.length + organisation.invites.length - 1;
|
|
||||||
|
|
||||||
// Removing a member is a reducing operation, so we don't gate it on the
|
|
||||||
// subscription being present. Sync Stripe only when one exists.
|
|
||||||
if (organisation.subscription) {
|
|
||||||
await syncMemberCountWithStripeSeatPlan(
|
|
||||||
organisation.subscription,
|
|
||||||
organisation.organisationClaim,
|
|
||||||
newMemberCount,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const teamIds = organisation.teams.map((team) => team.id);
|
const teamIds = organisation.teams.map((team) => team.id);
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
@@ -113,6 +89,13 @@ export const deleteAdminOrganisationMemberRoute = adminProcedure
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A member was removed — queue a seat sync to true the Stripe quantity down
|
||||||
|
// to the new count (no proration, no credit).
|
||||||
|
await jobs.triggerJob({
|
||||||
|
name: 'internal.sync-organisation-seats',
|
||||||
|
payload: { organisationId },
|
||||||
|
});
|
||||||
|
|
||||||
await jobs.triggerJob({
|
await jobs.triggerJob({
|
||||||
name: 'send.organisation-member-left.email',
|
name: 'send.organisation-member-left.email',
|
||||||
payload: {
|
payload: {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
|
|
||||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { getMemberOrganisationRole } from '@documenso/lib/server-only/team/get-member-roles';
|
import { getMemberOrganisationRole } from '@documenso/lib/server-only/team/get-member-roles';
|
||||||
@@ -32,20 +31,6 @@ export const deleteOrganisationMemberInvitesRoute = authenticatedProcedure
|
|||||||
userId,
|
userId,
|
||||||
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
|
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
|
||||||
}),
|
}),
|
||||||
include: {
|
|
||||||
organisationClaim: true,
|
|
||||||
subscription: true,
|
|
||||||
members: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
invites: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!organisation) {
|
if (!organisation) {
|
||||||
@@ -83,22 +68,6 @@ export const deleteOrganisationMemberInvitesRoute = authenticatedProcedure
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { organisationClaim } = organisation;
|
|
||||||
|
|
||||||
const numberOfCurrentMembers = organisation.members.length;
|
|
||||||
const numberOfCurrentInvites = organisation.invites.length;
|
|
||||||
const totalMemberCountWithInvites = numberOfCurrentMembers + numberOfCurrentInvites - 1;
|
|
||||||
|
|
||||||
// Removing pending invites is a reducing operation, so we don't gate it on
|
|
||||||
// the subscription being present. Sync Stripe only when one exists.
|
|
||||||
if (organisation.subscription) {
|
|
||||||
await syncMemberCountWithStripeSeatPlan(
|
|
||||||
organisation.subscription,
|
|
||||||
organisationClaim,
|
|
||||||
totalMemberCountWithInvites,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.organisationMemberInvite.deleteMany({
|
await prisma.organisationMemberInvite.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
id: {
|
id: {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
|
|
||||||
import {
|
import {
|
||||||
ORGANISATION_MEMBER_ROLE_HIERARCHY,
|
ORGANISATION_MEMBER_ROLE_HIERARCHY,
|
||||||
ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP,
|
ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP,
|
||||||
} from '@documenso/lib/constants/organisations';
|
} from '@documenso/lib/constants/organisations';
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { jobs } from '@documenso/lib/jobs/client';
|
import { jobs } from '@documenso/lib/jobs/client';
|
||||||
import {
|
import {
|
||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
isOrganisationRoleWithinUserHierarchy,
|
isOrganisationRoleWithinUserHierarchy,
|
||||||
} from '@documenso/lib/utils/organisations';
|
} from '@documenso/lib/utils/organisations';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { OrganisationMemberInviteStatus } from '@documenso/prisma/client';
|
|
||||||
|
|
||||||
import { authenticatedProcedure } from '../trpc';
|
import { authenticatedProcedure } from '../trpc';
|
||||||
import {
|
import {
|
||||||
@@ -59,8 +58,6 @@ export const deleteOrganisationMembers = async ({
|
|||||||
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
|
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
|
||||||
}),
|
}),
|
||||||
include: {
|
include: {
|
||||||
subscription: true,
|
|
||||||
organisationClaim: true,
|
|
||||||
teams: {
|
teams: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -75,14 +72,6 @@ export const deleteOrganisationMembers = async ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
invites: {
|
|
||||||
where: {
|
|
||||||
status: OrganisationMemberInviteStatus.PENDING,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -90,8 +79,6 @@ export const deleteOrganisationMembers = async ({
|
|||||||
throw new AppError(AppErrorCode.UNAUTHORIZED);
|
throw new AppError(AppErrorCode.UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { organisationClaim } = organisation;
|
|
||||||
|
|
||||||
const membersToDelete = organisation.members.filter((member) => organisationMemberIds.includes(member.id));
|
const membersToDelete = organisation.members.filter((member) => organisationMemberIds.includes(member.id));
|
||||||
|
|
||||||
const currentUserMember = organisation.members.find((member) => member.userId === userId);
|
const currentUserMember = organisation.members.find((member) => member.userId === userId);
|
||||||
@@ -129,15 +116,6 @@ export const deleteOrganisationMembers = async ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const inviteCount = organisation.invites.length;
|
|
||||||
const newMemberCount = organisation.members.length + inviteCount - membersToDelete.length;
|
|
||||||
|
|
||||||
// Removing members is a reducing operation, so we don't gate it on the
|
|
||||||
// subscription being present. Sync Stripe only when one exists.
|
|
||||||
if (organisation.subscription) {
|
|
||||||
await syncMemberCountWithStripeSeatPlan(organisation.subscription, organisationClaim, newMemberCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
const removedUserIds = membersToDelete.map((member) => member.userId);
|
const removedUserIds = membersToDelete.map((member) => member.userId);
|
||||||
const teamIds = organisation.teams.map((team) => team.id);
|
const teamIds = organisation.teams.map((team) => team.id);
|
||||||
|
|
||||||
@@ -184,6 +162,13 @@ export const deleteOrganisationMembers = async ({
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Members were removed — queue a seat sync to true the Stripe quantity down to
|
||||||
|
// the new count (no proration, no credit).
|
||||||
|
await jobs.triggerJob({
|
||||||
|
name: 'internal.sync-organisation-seats',
|
||||||
|
payload: { organisationId },
|
||||||
|
});
|
||||||
|
|
||||||
for (const member of membersToDelete) {
|
for (const member of membersToDelete) {
|
||||||
await jobs.triggerJob({
|
await jobs.triggerJob({
|
||||||
name: 'send.organisation-member-left.email',
|
name: 'send.organisation-member-left.email',
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { jobs } from '@documenso/lib/jobs/client';
|
import { jobs } from '@documenso/lib/jobs/client';
|
||||||
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
|
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { OrganisationMemberInviteStatus } from '@documenso/prisma/client';
|
|
||||||
|
|
||||||
import { authenticatedProcedure } from '../trpc';
|
import { authenticatedProcedure } from '../trpc';
|
||||||
import { ZLeaveOrganisationRequestSchema, ZLeaveOrganisationResponseSchema } from './leave-organisation.types';
|
import { ZLeaveOrganisationRequestSchema, ZLeaveOrganisationResponseSchema } from './leave-organisation.types';
|
||||||
@@ -24,26 +22,11 @@ export const leaveOrganisationRoute = authenticatedProcedure
|
|||||||
const organisation = await prisma.organisation.findFirst({
|
const organisation = await prisma.organisation.findFirst({
|
||||||
where: buildOrganisationWhereQuery({ organisationId, userId }),
|
where: buildOrganisationWhereQuery({ organisationId, userId }),
|
||||||
include: {
|
include: {
|
||||||
organisationClaim: true,
|
|
||||||
subscription: true,
|
|
||||||
teams: {
|
teams: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
invites: {
|
|
||||||
where: {
|
|
||||||
status: OrganisationMemberInviteStatus.PENDING,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
members: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -59,17 +42,6 @@ export const leaveOrganisationRoute = authenticatedProcedure
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { organisationClaim } = organisation;
|
|
||||||
|
|
||||||
const inviteCount = organisation.invites.length;
|
|
||||||
const newMemberCount = organisation.members.length + inviteCount - 1;
|
|
||||||
|
|
||||||
// Leaving is a reducing operation, so we don't gate it on the subscription
|
|
||||||
// being present. Sync Stripe only when one exists.
|
|
||||||
if (organisation.subscription) {
|
|
||||||
await syncMemberCountWithStripeSeatPlan(organisation.subscription, organisationClaim, newMemberCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
const teamIds = organisation.teams.map((team) => team.id);
|
const teamIds = organisation.teams.map((team) => team.id);
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
@@ -101,6 +73,13 @@ export const leaveOrganisationRoute = authenticatedProcedure
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A member was removed — queue a seat sync to true the Stripe quantity down
|
||||||
|
// to the new count (no proration, no credit).
|
||||||
|
await jobs.triggerJob({
|
||||||
|
name: 'internal.sync-organisation-seats',
|
||||||
|
payload: { organisationId },
|
||||||
|
});
|
||||||
|
|
||||||
await jobs.triggerJob({
|
await jobs.triggerJob({
|
||||||
name: 'send.organisation-member-left.email',
|
name: 'send.organisation-member-left.email',
|
||||||
payload: {
|
payload: {
|
||||||
|
|||||||
Reference in New Issue
Block a user