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. * Should only be called from grow paths (invite/add). Reducing operations
* @param organisationClaim - The organisation claim * must never be gated by this check.
* @param quantity - The amount to sync the Stripe item with *
* @returns * @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, subscription: Subscription,
organisationClaim: OrganisationClaim, organisationClaim: OrganisationClaim,
quantity: number, quantity: number,
) => { ) => {
const maximumMemberCount = organisationClaim.memberCount; const maximumMemberCount = organisationClaim.memberCount;
// Infinite seats means no sync needed. // 0 = unlimited.
if (maximumMemberCount === 0) { if (maximumMemberCount === 0) {
return; 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 (isSeatsBased) {
if (quantity > maximumMemberCount && !syncMemberCountWithStripe) { return;
}
if (quantity > maximumMemberCount) {
throw new AppError(AppErrorCode.LIMIT_EXCEEDED, { throw new AppError(AppErrorCode.LIMIT_EXCEEDED, {
message: 'Maximum member count reached', message: 'Maximum member count reached',
}); });
} }
};
// Bill the user with the new quantity.
if (syncMemberCountWithStripe) { /**
appLog('BILLING', 'Updating seat based plan'); * Syncs the organisation's member count with the Stripe subscription quantity.
*
await updateSubscriptionItemQuantity({ * No-ops for plans that are not seats-based, and for organisations with
priceId: subscription.priceId, * unlimited seats (`organisationClaim.memberCount === 0`). Safe to call from
subscriptionId: subscription.planId, * both grow and shrink paths.
quantity, *
}); * @param subscription - The subscription to sync the member count with.
* @param organisationClaim - The organisation claim.
// This should be automatically updated after the Stripe webhook is fired * @param quantity - The new total member + pending invite count to sync.
// but we just manually adjust it here as well to avoid any race conditions. */
await prisma.organisationClaim.update({ export const syncMemberCountWithStripeSeatPlan = async (
where: { subscription: Subscription,
id: organisationClaim.id, organisationClaim: OrganisationClaim,
}, quantity: number,
data: { ) => {
memberCount: quantity, // 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 { OrganisationMemberInviteStatus } from '@prisma/client';
import { nanoid } from 'nanoid'; 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 { mailer } from '@documenso/email/mailer';
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';
@@ -127,8 +130,10 @@ export const createOrganisationMemberInvites = async ({
const totalMemberCountWithInvites = const totalMemberCountWithInvites =
numberOfCurrentMembers + numberOfCurrentInvites + numberOfNewInvites; numberOfCurrentMembers + numberOfCurrentInvites + numberOfNewInvites;
// Handle billing for seat based plans. // Enforce the seat cap and sync billing for seat based plans.
if (subscription) { if (subscription) {
await assertMemberCountWithinCap(subscription, organisationClaim, totalMemberCountWithInvites);
await syncMemberCountWithStripeSeatPlan( await syncMemberCountWithStripeSeatPlan(
subscription, subscription,
organisationClaim, 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 { 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';
import { validateIfSubscriptionIsRequired } from '@documenso/lib/utils/billing';
import { import {
buildOrganisationWhereQuery, buildOrganisationWhereQuery,
isOrganisationRoleWithinUserHierarchy, isOrganisationRoleWithinUserHierarchy,
@@ -93,15 +92,15 @@ export const deleteOrganisationMemberInvitesRoute = authenticatedProcedure
const { organisationClaim } = organisation; const { organisationClaim } = organisation;
const subscription = validateIfSubscriptionIsRequired(organisation.subscription);
const numberOfCurrentMembers = organisation.members.length; const numberOfCurrentMembers = organisation.members.length;
const numberOfCurrentInvites = organisation.invites.length; const numberOfCurrentInvites = organisation.invites.length;
const totalMemberCountWithInvites = numberOfCurrentMembers + numberOfCurrentInvites - 1; 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( await syncMemberCountWithStripeSeatPlan(
subscription, organisation.subscription,
organisationClaim, organisationClaim,
totalMemberCountWithInvites, 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 { 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 { jobs } from '@documenso/lib/jobs/client'; import { jobs } from '@documenso/lib/jobs/client';
import { validateIfSubscriptionIsRequired } from '@documenso/lib/utils/billing';
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 { OrganisationMemberInviteStatus } from '@documenso/prisma/client';
@@ -55,6 +54,11 @@ export const deleteOrganisationMembers = async ({
include: { include: {
subscription: true, subscription: true,
organisationClaim: true, organisationClaim: true,
teams: {
select: {
id: true,
},
},
members: { members: {
select: { select: {
id: true, id: true,
@@ -82,16 +86,43 @@ export const deleteOrganisationMembers = async ({
organisationMemberIds.includes(member.id), organisationMemberIds.includes(member.id),
); );
const subscription = validateIfSubscriptionIsRequired(organisation.subscription);
const inviteCount = organisation.invites.length; const inviteCount = organisation.invites.length;
const newMemberCount = organisation.members.length + inviteCount - membersToDelete.length; const newMemberCount = organisation.members.length + inviteCount - membersToDelete.length;
if (subscription) { // Removing members is a reducing operation, so we don't gate it on the
await syncMemberCountWithStripeSeatPlan(subscription, organisationClaim, newMemberCount); // 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) => { 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({ await tx.organisationMember.deleteMany({
where: { where: {
id: { id: {
@@ -1,7 +1,6 @@
import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity'; 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 { validateIfSubscriptionIsRequired } from '@documenso/lib/utils/billing';
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 { OrganisationMemberInviteStatus } from '@documenso/prisma/client';
@@ -30,6 +29,11 @@ export const leaveOrganisationRoute = authenticatedProcedure
include: { include: {
organisationClaim: true, organisationClaim: true,
subscription: true, subscription: true,
teams: {
select: {
id: true,
},
},
invites: { invites: {
where: { where: {
status: OrganisationMemberInviteStatus.PENDING, status: OrganisationMemberInviteStatus.PENDING,
@@ -52,22 +56,48 @@ export const leaveOrganisationRoute = authenticatedProcedure
const { organisationClaim } = organisation; const { organisationClaim } = organisation;
const subscription = validateIfSubscriptionIsRequired(organisation.subscription);
const inviteCount = organisation.invites.length; const inviteCount = organisation.invites.length;
const newMemberCount = organisation.members.length + inviteCount - 1; const newMemberCount = organisation.members.length + inviteCount - 1;
if (subscription) { // Leaving is a reducing operation, so we don't gate it on the subscription
await syncMemberCountWithStripeSeatPlan(subscription, organisationClaim, newMemberCount); // being present. Sync Stripe only when one exists.
if (organisation.subscription) {
await syncMemberCountWithStripeSeatPlan(
organisation.subscription,
organisationClaim,
newMemberCount,
);
} }
await prisma.organisationMember.delete({ const teamIds = organisation.teams.map((team) => team.id);
where: {
userId_organisationId: { await prisma.$transaction(async (tx) => {
userId, // Leaving the org cascades the user out of every team via
organisationId, // 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({ await jobs.triggerJob({