fix: update stripe team member billing

This commit is contained in:
David Nguyen
2026-06-16 18:04:25 +10:00
parent 0aa84cecc8
commit d8e8f36e45
11 changed files with 445 additions and 305 deletions
@@ -1,98 +1,15 @@
import { prisma } from '@documenso/prisma';
import { Button } from '@documenso/ui/primitives/button';
import { Trans } from '@lingui/react/macro';
import { OrganisationMemberInviteStatus } from '@prisma/client';
import { Link } from 'react-router';
import { redirect } from 'react-router';
import type { Route } from './+types/organisation.decline.$token';
export async function loader({ params }: Route.LoaderArgs) {
export function loader({ params }: Route.LoaderArgs) {
const { token } = params;
if (!token) {
return {
state: 'InvalidLink',
} as const;
throw redirect('/');
}
const organisationMemberInvite = await prisma.organisationMemberInvite.findUnique({
where: {
token,
},
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>
);
// Declining now happens on the invite page via tRPC. Redirect there with the
// `action=decline` flag so it renders the decline-only view (no accept).
throw redirect(`/organisation/invite/${token}?action=decline`);
}
@@ -1,9 +1,15 @@
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 { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Trans } from '@lingui/react/macro';
import { Link } from 'react-router';
import { useToast } from '@documenso/ui/primitives/use-toast';
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';
@@ -37,6 +43,22 @@ export async function loader({ params, request }: Route.LoaderArgs) {
} 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({
where: {
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 {
state: 'Success',
state: 'Pending',
token: organisationMemberInvite.token,
email: organisationMemberInvite.email,
organisationName: organisationMemberInvite.organisation.name,
isSessionUserTheInvitedUser,
organisationName,
userExists: user !== null,
isSessionUserTheInvitedUser: user !== null && user.id === session.user?.id,
} as const;
}
@@ -97,57 +106,253 @@ export default function AcceptInvitationPage({ loaderData }: Route.ComponentProp
);
}
if (data.state === 'LoginRequired') {
if (data.state === 'AlreadyAccepted') {
return (
<div>
<h1 className="font-semibold text-4xl">
<Trans>Organisation invitation</Trans>
</h1>
<div className="w-screen max-w-lg px-4">
<div className="w-full">
<h1 className="font-semibold text-4xl">
<Trans>Invitation already accepted</Trans>
</h1>
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
You have been invited by <strong>{data.organisationName}</strong> to join their organisation.
</Trans>
</p>
<p className="mt-2 mb-4 text-muted-foreground text-sm">
<Trans>
You are already a member of <strong>{data.organisationName}</strong>.
</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(data.email)}`}>
<Trans>Create account</Trans>
</Link>
</Button>
<Button asChild>
<Link to="/">
<Trans>Continue</Trans>
</Link>
</Button>
</div>
</div>
);
}
if (data.state === 'AlreadyDeclined') {
return <InvitationDeclined organisationName={data.organisationName} />;
}
return (
<div>
<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>{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>
<PendingInvitation
token={data.token}
email={data.email}
organisationName={data.organisationName}
userExists={data.userExists}
isSessionUserTheInvitedUser={data.isSessionUserTheInvitedUser}
/>
);
}
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>
);
};
@@ -6,6 +6,8 @@ import { prisma } from '@documenso/prisma';
import { OrganisationType, type Prisma, SubscriptionStatus } from '@prisma/client';
import { match } from 'ts-pattern';
import { reconcileSeatsWithMemberCount } from './update-subscription-item-quantity';
const LIVE_SUBSCRIPTION_STATUSES: Stripe.Subscription.Status[] = ['active', 'trialing', 'past_due'];
export type SyncStripeCustomerSubscriptionOptions = {
@@ -229,6 +231,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 non-ACTIVE subscriptions.
const previousPeriodEnd = organisation.subscription?.periodEnd ?? null;
const hasPeriodAdvanced = previousPeriodEnd !== null && periodEnd.getTime() > previousPeriodEnd.getTime();
if (hasPeriodAdvanced && !bypassClaimUpdate) {
await reconcileSeatsWithMemberCount(organisation.id);
}
};
/**
@@ -3,6 +3,7 @@ import { stripe } from '@documenso/lib/server-only/stripe';
import { appLog } from '@documenso/lib/utils/debugger';
import { prisma } from '@documenso/prisma';
import type { OrganisationClaim, Subscription } from '@prisma/client';
import { SubscriptionStatus } from '@prisma/client';
import type Stripe from 'stripe';
import { isPriceSeatsBased } from './is-price-seats-based';
@@ -11,12 +12,14 @@ export type UpdateSubscriptionItemQuantityOptions = {
subscriptionId: string;
quantity: number;
priceId: string;
prorationBehaviour: 'always_invoice' | 'none';
};
export const updateSubscriptionItemQuantity = async ({
subscriptionId,
quantity,
priceId,
prorationBehaviour,
}: UpdateSubscriptionItemQuantityOptions) => {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
@@ -26,7 +29,6 @@ export const updateSubscriptionItemQuantity = async ({
throw new Error('Subscription does not contain required item');
}
const hasYearlyItem = items.find((item) => item.price.recurring?.interval === 'year');
const oldQuantity = items[0].quantity;
if (oldQuantity === quantity) {
@@ -38,13 +40,12 @@ export const updateSubscriptionItemQuantity = async ({
id: item.id,
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);
};
@@ -55,15 +56,19 @@ export const updateSubscriptionItemQuantity = async ({
* via Stripe rather than enforcing a hard cap. A `memberCount` of `0` on the
* 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
* 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 quantity - The proposed total member + pending invite count.
* @param quantity - The proposed total member count.
*/
export const assertMemberCountWithinCap = async (
subscription: Subscription,
subscription: Subscription | null,
organisationClaim: OrganisationClaim,
quantity: number,
) => {
@@ -75,10 +80,12 @@ export const assertMemberCountWithinCap = async (
}
// 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) {
return;
if (isSeatsBased) {
return;
}
}
if (quantity > maximumMemberCount) {
@@ -91,40 +98,60 @@ export const assertMemberCountWithinCap = async (
/**
* Syncs the organisation's member count with the Stripe subscription quantity.
*
* No-ops for plans that are not seats-based, and for organisations with
* unlimited seats (`organisationClaim.memberCount === 0`). Safe to call from
* both grow and shrink paths.
* For seat-based plans, `organisationClaim.memberCount` is the paid seat
* high-water mark for the current billing period and always mirrors the
* Stripe quantity.
*
* - Mode `grow`: will skip if the new count is within the paid
* high-water mark (the seat is already paid for); anything above the mark
* is invoiced immediately.
* - Mode `reconcile`: writes the actual member count with no prorations in
* either direction (renewal-time true-up).
*
* No-ops for plans that are not seats-based and for organisations with
* unlimited seats (`organisationClaim.memberCount === 0`).
*
* @param subscription - The subscription to sync the member count with.
* @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 - Whether this is a grow operation or a renewal reconcile.
*/
export const syncMemberCountWithStripeSeatPlan = async (
subscription: Subscription,
organisationClaim: OrganisationClaim,
quantity: number,
mode: 'grow' | 'reconcile',
) => {
// Infinite seats means no sync needed.
// Early return if the organisation has unlimited seats.
if (organisationClaim.memberCount === 0) {
return;
}
// Early return if the new count is less than the paid high-water mark for grow mode.
if (mode === 'grow' && quantity <= organisationClaim.memberCount) {
return;
}
const isSeatsBased = await isPriceSeatsBased(subscription.priceId);
// Only seat-based plans support seat syncing.
if (!isSeatsBased) {
return;
}
appLog('BILLING', 'Updating seat based plan');
appLog('BILLING', `Updating seat based plan (${mode})`);
await updateSubscriptionItemQuantity({
priceId: subscription.priceId,
subscriptionId: subscription.planId,
quantity,
prorationBehaviour: mode === 'grow' ? 'always_invoice' : '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.
// The claim mirrors the Stripe quantity (the paid seat high-water mark).
// This write is the only place the mark advances on grow — the
// subscription webhook's claim overwrite preserves the already-billed
// Stripe quantity but never advances it.
await prisma.organisationClaim.update({
where: {
id: organisationClaim.id,
@@ -134,3 +161,67 @@ export const syncMemberCountWithStripeSeatPlan = async (
},
});
};
/**
* Reconciles the Stripe seat quantity and organisation claim with the actual
* member count at the start of a new billing period.
*
* Called from the `customer.subscription.updated` webhook when the billing
* period advances. The renewal invoice has already been generated at the
* previous (high-water) quantity by then — the reconciled count takes effect
* on the next invoice (accepted trade-off: removed seats bill for exactly
* one extra period).
*
* Runs with no prorations in either direction: no credits when shrinking,
* no retroactive charges when healing upward drift (e.g. unbilled SSO
* portal joins or lost grow races).
*/
export const reconcileSeatsWithMemberCount = async (organisationId: string) => {
const organisation = await prisma.organisation.findUnique({
where: {
id: organisationId,
},
include: {
subscription: true,
organisationClaim: true,
},
});
if (!organisation || !organisation.subscription) {
appLog('BILLING', 'Reconcile skipped: organisation or subscription not found');
return;
}
// Only ACTIVE subscriptions reconcile. INACTIVE (canceled) subscriptions
// cannot have their quantity updated in Stripe, and skipping PAST_DUE is
// deliberate: drift heals at the first renewal after recovery.
if (organisation.subscription.status !== SubscriptionStatus.ACTIVE) {
appLog('BILLING', 'Reconcile skipped: subscription not active');
return;
}
const memberCount = await prisma.organisationMember.count({
where: {
organisationId,
},
});
// An organisation always has at least its owner. Guarding zero protects
// more than the Stripe quantity: writing 0 to the claim would flip
// memberCount to the unlimited sentinel and permanently exempt the
// organisation from seat billing.
if (memberCount === 0) {
appLog('BILLING', 'Reconcile skipped: organisation has no members');
return;
}
await syncMemberCountWithStripeSeatPlan(
organisation.subscription,
organisation.organisationClaim,
memberCount,
'reconcile',
);
};
+1
View File
@@ -36,6 +36,7 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
[AppErrorCode.INVALID_BODY]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.INVALID_REQUEST]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.INVALID_CAPTCHA]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.LIMIT_EXCEEDED]: { code: 'TOO_MANY_REQUESTS', status: 429 },
[AppErrorCode.NOT_FOUND]: { code: 'NOT_FOUND', status: 404 },
[AppErrorCode.NOT_IMPLEMENTED]: { code: 'INTERNAL_SERVER_ERROR', status: 501 },
[AppErrorCode.NOT_SETUP]: { code: 'BAD_REQUEST', status: 400 },
@@ -1,7 +1,12 @@
import {
assertMemberCountWithinCap,
syncMemberCountWithStripeSeatPlan,
} from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
import { prisma } from '@documenso/prisma';
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 { jobs } from '../../jobs/client';
import { generateDatabaseId } from '../../universal/id';
@@ -22,6 +27,13 @@ export const acceptOrganisationInvitation = async ({ token }: AcceptOrganisation
organisation: {
include: {
groups: true,
organisationClaim: true,
subscription: true,
members: {
select: {
id: true,
},
},
},
},
},
@@ -66,6 +78,35 @@ export const acceptOrganisationInvitation = async ({ token }: AcceptOrganisation
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({
userId: user.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 { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
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 { generateDatabaseId } from '../../universal/id';
import { validateIfSubscriptionIsRequired } from '../../utils/billing';
import { buildOrganisationWhereQuery } from '../../utils/organisations';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
@@ -62,8 +57,6 @@ export const createOrganisationMemberInvites = async ({
},
},
organisationGlobalSettings: true,
organisationClaim: true,
subscription: true,
},
});
@@ -71,10 +64,6 @@ export const createOrganisationMemberInvites = async ({
throw new AppError(AppErrorCode.NOT_FOUND);
}
const { organisationClaim } = organisation;
const subscription = validateIfSubscriptionIsRequired(organisation.subscription);
const currentOrganisationMemberRole = await getMemberOrganisationRole({
organisationId: organisation.id,
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({
data: organisationMemberInvites,
});
@@ -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 { jobs } from '@documenso/lib/jobs/client';
import { prisma } from '@documenso/prisma';
import { OrganisationMemberInviteStatus } from '@prisma/client';
import { adminProcedure } from '../trpc';
import {
@@ -28,8 +26,6 @@ export const deleteAdminOrganisationMemberRoute = adminProcedure
id: organisationId,
},
include: {
subscription: true,
organisationClaim: true,
teams: {
select: {
id: true,
@@ -41,14 +37,6 @@ export const deleteAdminOrganisationMemberRoute = adminProcedure
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);
await prisma.$transaction(async (tx) => {
@@ -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 { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getMemberOrganisationRole } from '@documenso/lib/server-only/team/get-member-roles';
@@ -32,20 +31,6 @@ export const deleteOrganisationMemberInvitesRoute = authenticatedProcedure
userId,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
}),
include: {
organisationClaim: true,
subscription: true,
members: {
select: {
id: true,
},
},
invites: {
select: {
id: true,
},
},
},
});
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({
where: {
id: {
@@ -1,10 +1,8 @@
import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { jobs } from '@documenso/lib/jobs/client';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { OrganisationMemberInviteStatus } from '@documenso/prisma/client';
import { authenticatedProcedure } from '../trpc';
import {
@@ -52,8 +50,6 @@ export const deleteOrganisationMembers = async ({
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
}),
include: {
subscription: true,
organisationClaim: true,
teams: {
select: {
id: true,
@@ -65,14 +61,6 @@ export const deleteOrganisationMembers = async ({
userId: true,
},
},
invites: {
where: {
status: OrganisationMemberInviteStatus.PENDING,
},
select: {
id: true,
},
},
},
});
@@ -80,19 +68,8 @@ export const deleteOrganisationMembers = async ({
throw new AppError(AppErrorCode.UNAUTHORIZED);
}
const { organisationClaim } = organisation;
const membersToDelete = organisation.members.filter((member) => organisationMemberIds.includes(member.id));
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 teamIds = organisation.teams.map((team) => team.id);
@@ -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 { jobs } from '@documenso/lib/jobs/client';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { OrganisationMemberInviteStatus } from '@documenso/prisma/client';
import { authenticatedProcedure } from '../trpc';
import { ZLeaveOrganisationRequestSchema, ZLeaveOrganisationResponseSchema } from './leave-organisation.types';
@@ -24,26 +22,11 @@ export const leaveOrganisationRoute = authenticatedProcedure
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({ organisationId, userId }),
include: {
organisationClaim: true,
subscription: true,
teams: {
select: {
id: true,
},
},
invites: {
where: {
status: OrganisationMemberInviteStatus.PENDING,
},
select: {
id: true,
},
},
members: {
select: {
id: true,
},
},
},
});
@@ -51,17 +34,6 @@ export const leaveOrganisationRoute = authenticatedProcedure
throw new AppError(AppErrorCode.NOT_FOUND);
}
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);
await prisma.$transaction(async (tx) => {