mirror of
https://github.com/documenso/documenso.git
synced 2026-07-26 01:45:08 +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.
|
* 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({
|
||||||
|
|||||||
Reference in New Issue
Block a user