fix: don't block organisation member removal on billing checks (#2706)

This commit is contained in:
Lucas Smith
2026-04-22 21:59:22 +10:00
committed by GitHub
parent e5da5bca38
commit dc575f5c80
5 changed files with 154 additions and 56 deletions
@@ -50,55 +50,88 @@ export const updateSubscriptionItemQuantity = async ({
};
/**
* Checks whether the member count should be synced with a given Stripe subscription.
* Asserts that a proposed member count does not exceed the organisation's cap.
*
* If the subscription is not "seat" based, it will be ignored.
* Only enforced for non-seats-based plans, since seats-based plans meter usage
* via Stripe rather than enforcing a hard cap. A `memberCount` of `0` on the
* organisation claim represents unlimited seats.
*
* @param subscription - The subscription to sync the member count with.
* @param organisationClaim - The organisation claim
* @param quantity - The amount to sync the Stripe item with
* @returns
* 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 organisationClaim - The organisation claim.
* @param quantity - The proposed total member + pending invite count.
*/
export const syncMemberCountWithStripeSeatPlan = async (
export const assertMemberCountWithinCap = async (
subscription: Subscription,
organisationClaim: OrganisationClaim,
quantity: number,
) => {
const maximumMemberCount = organisationClaim.memberCount;
// Infinite seats means no sync needed.
// 0 = unlimited.
if (maximumMemberCount === 0) {
return;
}
const syncMemberCountWithStripe = await isPriceSeatsBased(subscription.priceId);
// Seats-based plans don't have a hard cap; Stripe meters the usage.
const isSeatsBased = await isPriceSeatsBased(subscription.priceId);
// Throw error if quantity exceeds maximum member count and the subscription is not seats based.
if (quantity > maximumMemberCount && !syncMemberCountWithStripe) {
if (isSeatsBased) {
return;
}
if (quantity > maximumMemberCount) {
throw new AppError(AppErrorCode.LIMIT_EXCEEDED, {
message: 'Maximum member count reached',
});
}
// Bill the user with the new quantity.
if (syncMemberCountWithStripe) {
appLog('BILLING', 'Updating seat based plan');
await updateSubscriptionItemQuantity({
priceId: subscription.priceId,
subscriptionId: subscription.planId,
quantity,
});
// 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({
where: {
id: organisationClaim.id,
},
data: {
memberCount: quantity,
},
});
}
};
/**
* 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.
*
* @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.
*/
export const syncMemberCountWithStripeSeatPlan = async (
subscription: Subscription,
organisationClaim: OrganisationClaim,
quantity: number,
) => {
// Infinite seats means no sync needed.
if (organisationClaim.memberCount === 0) {
return;
}
const isSeatsBased = await isPriceSeatsBased(subscription.priceId);
if (!isSeatsBased) {
return;
}
appLog('BILLING', 'Updating seat based plan');
await updateSubscriptionItemQuantity({
priceId: subscription.priceId,
subscriptionId: subscription.planId,
quantity,
});
// 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({
where: {
id: organisationClaim.id,
},
data: {
memberCount: quantity,
},
});
};
@@ -5,7 +5,10 @@ import type { Organisation, Prisma } from '@prisma/client';
import { OrganisationMemberInviteStatus } from '@prisma/client';
import { nanoid } from 'nanoid';
import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
import {
assertMemberCountWithinCap,
syncMemberCountWithStripeSeatPlan,
} from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
import { mailer } from '@documenso/email/mailer';
import { OrganisationInviteEmailTemplate } from '@documenso/email/templates/organisation-invite';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
@@ -127,8 +130,10 @@ export const createOrganisationMemberInvites = async ({
const totalMemberCountWithInvites =
numberOfCurrentMembers + numberOfCurrentInvites + numberOfNewInvites;
// Handle billing for seat based plans.
// Enforce the seat cap and sync billing for seat based plans.
if (subscription) {
await assertMemberCountWithinCap(subscription, organisationClaim, totalMemberCountWithInvites);
await syncMemberCountWithStripeSeatPlan(
subscription,
organisationClaim,
@@ -2,7 +2,6 @@ import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/str
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';
import { validateIfSubscriptionIsRequired } from '@documenso/lib/utils/billing';
import {
buildOrganisationWhereQuery,
isOrganisationRoleWithinUserHierarchy,
@@ -93,15 +92,15 @@ export const deleteOrganisationMemberInvitesRoute = authenticatedProcedure
const { organisationClaim } = organisation;
const subscription = validateIfSubscriptionIsRequired(organisation.subscription);
const numberOfCurrentMembers = organisation.members.length;
const numberOfCurrentInvites = organisation.invites.length;
const totalMemberCountWithInvites = numberOfCurrentMembers + numberOfCurrentInvites - 1;
if (subscription) {
// 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(
subscription,
organisation.subscription,
organisationClaim,
totalMemberCountWithInvites,
);
@@ -2,7 +2,6 @@ import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/str
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 { validateIfSubscriptionIsRequired } from '@documenso/lib/utils/billing';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { OrganisationMemberInviteStatus } from '@documenso/prisma/client';
@@ -55,6 +54,11 @@ export const deleteOrganisationMembers = async ({
include: {
subscription: true,
organisationClaim: true,
teams: {
select: {
id: true,
},
},
members: {
select: {
id: true,
@@ -82,16 +86,43 @@ export const deleteOrganisationMembers = async ({
organisationMemberIds.includes(member.id),
);
const subscription = validateIfSubscriptionIsRequired(organisation.subscription);
const inviteCount = organisation.invites.length;
const newMemberCount = organisation.members.length + inviteCount - membersToDelete.length;
if (subscription) {
await syncMemberCountWithStripeSeatPlan(subscription, organisationClaim, newMemberCount);
// 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);
await prisma.$transaction(async (tx) => {
// Removing an OrganisationMember cascades the user out of every team in
// the org via OrganisationGroupMember, but their authored Envelope rows
// still reference them. Reassign those to the org owner so they remain
// reachable after the member loses access (mirrors delete-user.ts).
if (removedUserIds.length > 0 && teamIds.length > 0) {
await tx.envelope.updateMany({
where: {
userId: {
in: removedUserIds,
},
teamId: {
in: teamIds,
},
},
data: {
userId: organisation.ownerUserId,
},
});
}
await tx.organisationMember.deleteMany({
where: {
id: {
@@ -1,7 +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 { validateIfSubscriptionIsRequired } from '@documenso/lib/utils/billing';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { OrganisationMemberInviteStatus } from '@documenso/prisma/client';
@@ -30,6 +29,11 @@ export const leaveOrganisationRoute = authenticatedProcedure
include: {
organisationClaim: true,
subscription: true,
teams: {
select: {
id: true,
},
},
invites: {
where: {
status: OrganisationMemberInviteStatus.PENDING,
@@ -52,22 +56,48 @@ export const leaveOrganisationRoute = authenticatedProcedure
const { organisationClaim } = organisation;
const subscription = validateIfSubscriptionIsRequired(organisation.subscription);
const inviteCount = organisation.invites.length;
const newMemberCount = organisation.members.length + inviteCount - 1;
if (subscription) {
await syncMemberCountWithStripeSeatPlan(subscription, organisationClaim, newMemberCount);
// 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,
);
}
await prisma.organisationMember.delete({
where: {
userId_organisationId: {
userId,
organisationId,
const teamIds = organisation.teams.map((team) => team.id);
await prisma.$transaction(async (tx) => {
// Leaving the org cascades the user out of every team via
// OrganisationGroupMember, but their authored Envelope rows still
// reference them. Reassign those to the org owner so they remain
// reachable after the member loses access (mirrors delete-user.ts).
if (teamIds.length > 0) {
await tx.envelope.updateMany({
where: {
userId,
teamId: {
in: teamIds,
},
},
data: {
userId: organisation.ownerUserId,
},
});
}
await tx.organisationMember.delete({
where: {
userId_organisationId: {
userId,
organisationId,
},
},
},
});
});
await jobs.triggerJob({