diff --git a/packages/ee/server-only/stripe/update-subscription-item-quantity.ts b/packages/ee/server-only/stripe/update-subscription-item-quantity.ts index a089c8ce6..7612aebed 100644 --- a/packages/ee/server-only/stripe/update-subscription-item-quantity.ts +++ b/packages/ee/server-only/stripe/update-subscription-item-quantity.ts @@ -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, + }, + }); }; diff --git a/packages/lib/server-only/organisation/create-organisation-member-invites.ts b/packages/lib/server-only/organisation/create-organisation-member-invites.ts index 209b1aeca..7f743b7d6 100644 --- a/packages/lib/server-only/organisation/create-organisation-member-invites.ts +++ b/packages/lib/server-only/organisation/create-organisation-member-invites.ts @@ -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, diff --git a/packages/trpc/server/organisation-router/delete-organisation-member-invites.ts b/packages/trpc/server/organisation-router/delete-organisation-member-invites.ts index b65b01504..f575010ef 100644 --- a/packages/trpc/server/organisation-router/delete-organisation-member-invites.ts +++ b/packages/trpc/server/organisation-router/delete-organisation-member-invites.ts @@ -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, ); diff --git a/packages/trpc/server/organisation-router/delete-organisation-members.ts b/packages/trpc/server/organisation-router/delete-organisation-members.ts index 9725ecca7..e7e2a5ee5 100644 --- a/packages/trpc/server/organisation-router/delete-organisation-members.ts +++ b/packages/trpc/server/organisation-router/delete-organisation-members.ts @@ -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'; @@ -82,13 +81,17 @@ 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, + ); } await prisma.$transaction(async (tx) => { diff --git a/packages/trpc/server/organisation-router/leave-organisation.ts b/packages/trpc/server/organisation-router/leave-organisation.ts index f203c61bb..48a528048 100644 --- a/packages/trpc/server/organisation-router/leave-organisation.ts +++ b/packages/trpc/server/organisation-router/leave-organisation.ts @@ -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'; @@ -52,13 +51,17 @@ 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({