mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +10:00
fix: don't block organisation member removal on billing checks (#2706)
This commit is contained in:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user