diff --git a/apps/remix/app/routes/_unauthenticated+/organisation.decline.$token.tsx b/apps/remix/app/routes/_unauthenticated+/organisation.decline.$token.tsx index fc7c90fac..b95b3df2a 100644 --- a/apps/remix/app/routes/_unauthenticated+/organisation.decline.$token.tsx +++ b/apps/remix/app/routes/_unauthenticated+/organisation.decline.$token.tsx @@ -1,98 +1,15 @@ -import { prisma } from '@documenso/prisma'; -import { Button } from '@documenso/ui/primitives/button'; -import { Trans } from '@lingui/react/macro'; -import { OrganisationMemberInviteStatus } from '@prisma/client'; -import { Link } from 'react-router'; +import { redirect } from 'react-router'; import type { Route } from './+types/organisation.decline.$token'; -export async function loader({ params }: Route.LoaderArgs) { +export function loader({ params }: Route.LoaderArgs) { const { token } = params; if (!token) { - return { - state: 'InvalidLink', - } as const; + throw redirect('/'); } - const organisationMemberInvite = await prisma.organisationMemberInvite.findUnique({ - where: { - token, - }, - include: { - organisation: { - select: { - name: true, - }, - }, - }, - }); - - if (!organisationMemberInvite) { - return { - state: 'InvalidLink', - } as const; - } - - if (organisationMemberInvite.status !== OrganisationMemberInviteStatus.DECLINED) { - await prisma.organisationMemberInvite.update({ - where: { - id: organisationMemberInvite.id, - }, - data: { - status: OrganisationMemberInviteStatus.DECLINED, - }, - }); - } - - return { - state: 'Success', - organisationName: organisationMemberInvite.organisation.name, - } as const; -} - -export default function DeclineInvitationPage({ loaderData }: Route.ComponentProps) { - const data = loaderData; - - if (data.state === 'InvalidLink') { - return ( -
-
-

- Invalid token -

- -

- This token is invalid or has expired. No action is needed. -

- - -
-
- ); - } - - return ( -
-

- Invitation declined -

- -

- - You have declined the invitation from {data.organisationName} to join their organisation. - -

- - -
- ); + // Declining now happens on the invite page via tRPC. Redirect there with the + // `action=decline` flag so it renders the decline-only view (no accept). + throw redirect(`/organisation/invite/${token}?action=decline`); } diff --git a/apps/remix/app/routes/_unauthenticated+/organisation.invite.$token.tsx b/apps/remix/app/routes/_unauthenticated+/organisation.invite.$token.tsx index a95970774..5f6609d85 100644 --- a/apps/remix/app/routes/_unauthenticated+/organisation.invite.$token.tsx +++ b/apps/remix/app/routes/_unauthenticated+/organisation.invite.$token.tsx @@ -1,9 +1,15 @@ import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session'; -import { acceptOrganisationInvitation } from '@documenso/lib/server-only/organisation/accept-organisation-invitation'; +import { useOptionalSession } from '@documenso/lib/client-only/providers/session'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { prisma } from '@documenso/prisma'; +import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; -import { Trans } from '@lingui/react/macro'; -import { Link } from 'react-router'; +import { useToast } from '@documenso/ui/primitives/use-toast'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { OrganisationMemberInviteStatus } from '@prisma/client'; +import { useState } from 'react'; +import { Link, useSearchParams } from 'react-router'; +import { match } from 'ts-pattern'; import type { Route } from './+types/organisation.invite.$token'; @@ -37,6 +43,22 @@ export async function loader({ params, request }: Route.LoaderArgs) { } as const; } + const organisationName = organisationMemberInvite.organisation.name; + + if (organisationMemberInvite.status === OrganisationMemberInviteStatus.ACCEPTED) { + return { + state: 'AlreadyAccepted', + organisationName, + } as const; + } + + if (organisationMemberInvite.status === OrganisationMemberInviteStatus.DECLINED) { + return { + state: 'AlreadyDeclined', + organisationName, + } as const; + } + const user = await prisma.user.findFirst({ where: { email: { @@ -49,26 +71,13 @@ export async function loader({ params, request }: Route.LoaderArgs) { }, }); - // Directly convert the team member invite to a team member if they already have an account. - if (user) { - await acceptOrganisationInvitation({ token: organisationMemberInvite.token }); - } - - if (!user) { - return { - state: 'LoginRequired', - email: organisationMemberInvite.email, - organisationName: organisationMemberInvite.organisation.name, - } as const; - } - - const isSessionUserTheInvitedUser = user.id === session.user?.id; - return { - state: 'Success', + state: 'Pending', + token: organisationMemberInvite.token, email: organisationMemberInvite.email, - organisationName: organisationMemberInvite.organisation.name, - isSessionUserTheInvitedUser, + organisationName, + userExists: user !== null, + isSessionUserTheInvitedUser: user !== null && user.id === session.user?.id, } as const; } @@ -97,57 +106,253 @@ export default function AcceptInvitationPage({ loaderData }: Route.ComponentProp ); } - if (data.state === 'LoginRequired') { + if (data.state === 'AlreadyAccepted') { return ( -
-

- Organisation invitation -

+
+
+

+ Invitation already accepted +

-

- - You have been invited by {data.organisationName} to join their organisation. - -

+

+ + You are already a member of {data.organisationName}. + +

-

- To accept this invitation you must create an account. -

- - + +
); } + if (data.state === 'AlreadyDeclined') { + return ; + } + return ( -
-

- Invitation accepted! -

- -

- - You have accepted an invitation from {data.organisationName} to join their organisation. - -

- - {data.isSessionUserTheInvitedUser ? ( - - ) : ( - - )} -
+ ); } + +type PendingInvitationProps = { + token: string; + email: string; + organisationName: string; + userExists: boolean; + isSessionUserTheInvitedUser: boolean; +}; + +type InvitationResult = 'idle' | 'accepted' | 'declined'; + +type AcceptFailureReason = 'CapExceeded' | 'SubscriptionInactive' | 'Unknown'; + +const PendingInvitation = ({ + token, + email, + organisationName, + userExists, + isSessionUserTheInvitedUser, +}: PendingInvitationProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + const { refreshSession } = useOptionalSession(); + + const [searchParams] = useSearchParams(); + const actionIsDecline = searchParams.get('action') === 'decline'; + + const [result, setResult] = useState('idle'); + const [acceptFailureReason, setAcceptFailureReason] = useState(null); + + const acceptInvitation = trpc.organisation.member.invite.accept.useMutation({ + onSuccess: async () => { + await refreshSession(); + + setResult('accepted'); + }, + onError: (err) => { + const error = AppError.parseError(err); + + const failureReason = match(error.code) + .with(AppErrorCode.LIMIT_EXCEEDED, () => 'CapExceeded' as const) + .with('SUBSCRIPTION_INACTIVE', () => 'SubscriptionInactive' as const) + .otherwise(() => 'Unknown' as const); + + setAcceptFailureReason(failureReason); + }, + }); + + const declineInvitation = trpc.organisation.member.invite.decline.useMutation({ + onSuccess: async () => { + await refreshSession(); + + setResult('declined'); + }, + onError: () => { + toast({ + title: t`Something went wrong`, + description: t`Unable to decline this invitation at this time.`, + variant: 'destructive', + duration: 10000, + }); + }, + }); + + if (result === 'accepted') { + return ( +
+
+

+ Invitation accepted! +

+ +

+ + You have accepted an invitation from {organisationName} to join their organisation. + +

+ + {isSessionUserTheInvitedUser ? ( + + ) : ( + + )} +
+
+ ); + } + + if (result === 'declined') { + return ; + } + + // Accepting requires an account (acceptance keys off the invited email). + // Declining does not, so we only gate account creation on the accept flow. + if (!actionIsDecline && !userExists) { + return ( +
+
+

+ Organisation invitation +

+ +

+ + You have been invited by {organisationName} to join their organisation. + +

+ +

+ To accept this invitation you must create an account. +

+ + +
+
+ ); + } + + const isPending = acceptInvitation.isPending || declineInvitation.isPending; + + return ( +
+
+

+ Organisation invitation +

+ +

+ + You have been invited to join {organisationName} on Documenso. + +

+ + {acceptFailureReason && ( +

+ {match(acceptFailureReason) + .with('CapExceeded', () => ( + + {organisationName} has reached its member limit. Please contact the organisation + administrator to upgrade their plan before accepting this invitation. + + )) + .with('SubscriptionInactive', () => ( + + {organisationName} does not have an active subscription. Please contact the + organisation administrator to renew their plan before accepting this invitation. + + )) + .with('Unknown', () => ( + + We were unable to add you to {organisationName} at this time. Please try again later, + or contact the organisation administrator. + + )) + .exhaustive()} +

+ )} + +
+ + + {!actionIsDecline && ( + + )} +
+
+
+ ); +}; + +const InvitationDeclined = ({ organisationName }: { organisationName: string }) => { + return ( +
+
+

+ Invitation declined +

+ +

+ + You have declined the invitation from {organisationName} to join their organisation. + +

+
+
+ ); +}; diff --git a/packages/ee/server-only/stripe/sync-stripe-customer-subscription.ts b/packages/ee/server-only/stripe/sync-stripe-customer-subscription.ts index 29a4d7b7f..d0aece3af 100644 --- a/packages/ee/server-only/stripe/sync-stripe-customer-subscription.ts +++ b/packages/ee/server-only/stripe/sync-stripe-customer-subscription.ts @@ -6,6 +6,8 @@ import { prisma } from '@documenso/prisma'; import { OrganisationType, type Prisma, SubscriptionStatus } from '@prisma/client'; import { match } from 'ts-pattern'; +import { reconcileSeatsWithMemberCount } from './update-subscription-item-quantity'; + const LIVE_SUBSCRIPTION_STATUSES: Stripe.Subscription.Status[] = ['active', 'trialing', 'past_due']; export type SyncStripeCustomerSubscriptionOptions = { @@ -229,6 +231,19 @@ const handleLiveSubscription = async ({ }); } }); + + // Detect a billing-period roll by comparing the persisted period end with + // the freshly-fetched one — the convergent equivalent of the old + // `previous_attributes.current_period_start` signal. On renewal, reconcile + // the seat quantity and claim down to the actual member count. The reconcile + // itself no-ops for non-seat/unlimited plans and non-ACTIVE subscriptions. + const previousPeriodEnd = organisation.subscription?.periodEnd ?? null; + + const hasPeriodAdvanced = previousPeriodEnd !== null && periodEnd.getTime() > previousPeriodEnd.getTime(); + + if (hasPeriodAdvanced && !bypassClaimUpdate) { + await reconcileSeatsWithMemberCount(organisation.id); + } }; /** 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 084f1b1f8..4cc1397a9 100644 --- a/packages/ee/server-only/stripe/update-subscription-item-quantity.ts +++ b/packages/ee/server-only/stripe/update-subscription-item-quantity.ts @@ -3,6 +3,7 @@ import { stripe } from '@documenso/lib/server-only/stripe'; import { appLog } from '@documenso/lib/utils/debugger'; import { prisma } from '@documenso/prisma'; import type { OrganisationClaim, Subscription } from '@prisma/client'; +import { SubscriptionStatus } from '@prisma/client'; import type Stripe from 'stripe'; import { isPriceSeatsBased } from './is-price-seats-based'; @@ -11,12 +12,14 @@ export type UpdateSubscriptionItemQuantityOptions = { subscriptionId: string; quantity: number; priceId: string; + prorationBehaviour: 'always_invoice' | 'none'; }; export const updateSubscriptionItemQuantity = async ({ subscriptionId, quantity, priceId, + prorationBehaviour, }: UpdateSubscriptionItemQuantityOptions) => { const subscription = await stripe.subscriptions.retrieve(subscriptionId); @@ -26,7 +29,6 @@ export const updateSubscriptionItemQuantity = async ({ throw new Error('Subscription does not contain required item'); } - const hasYearlyItem = items.find((item) => item.price.recurring?.interval === 'year'); const oldQuantity = items[0].quantity; if (oldQuantity === quantity) { @@ -38,13 +40,12 @@ export const updateSubscriptionItemQuantity = async ({ id: item.id, quantity, })), + proration_behavior: prorationBehaviour, + // Need to "off_session" updates since adding 3DS will have payments + // not pass through for these immediate invoices. + off_session: true, }; - // Only invoice immediately when changing the quantity of yearly item. - if (hasYearlyItem) { - subscriptionUpdatePayload.proration_behavior = 'always_invoice'; - } - await stripe.subscriptions.update(subscriptionId, subscriptionUpdatePayload); }; @@ -55,15 +56,19 @@ export const updateSubscriptionItemQuantity = async ({ * via Stripe rather than enforcing a hard cap. A `memberCount` of `0` on the * organisation claim represents unlimited seats. * + * Organisations without a subscription (e.g. after being downgraded to the + * free plan) can pass `null`, in which case the claim cap is enforced + * directly without the seats-based exemption. + * * 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 subscription - The organisation's Stripe subscription, if any. * @param organisationClaim - The organisation claim. - * @param quantity - The proposed total member + pending invite count. + * @param quantity - The proposed total member count. */ export const assertMemberCountWithinCap = async ( - subscription: Subscription, + subscription: Subscription | null, organisationClaim: OrganisationClaim, quantity: number, ) => { @@ -75,10 +80,12 @@ export const assertMemberCountWithinCap = async ( } // Seats-based plans don't have a hard cap; Stripe meters the usage. - const isSeatsBased = await isPriceSeatsBased(subscription.priceId); + if (subscription) { + const isSeatsBased = await isPriceSeatsBased(subscription.priceId); - if (isSeatsBased) { - return; + if (isSeatsBased) { + return; + } } if (quantity > maximumMemberCount) { @@ -91,40 +98,60 @@ export const assertMemberCountWithinCap = async ( /** * 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. + * For seat-based plans, `organisationClaim.memberCount` is the paid seat + * high-water mark for the current billing period and always mirrors the + * Stripe quantity. + * + * - Mode `grow`: will skip if the new count is within the paid + * high-water mark (the seat is already paid for); anything above the mark + * is invoiced immediately. + * - Mode `reconcile`: writes the actual member count with no prorations in + * either direction (renewal-time true-up). + * + * No-ops for plans that are not seats-based and for organisations with + * unlimited seats (`organisationClaim.memberCount === 0`). * * @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. + * @param quantity - The new total member count to sync. + * @param mode - Whether this is a grow operation or a renewal reconcile. */ export const syncMemberCountWithStripeSeatPlan = async ( subscription: Subscription, organisationClaim: OrganisationClaim, quantity: number, + mode: 'grow' | 'reconcile', ) => { - // Infinite seats means no sync needed. + // Early return if the organisation has unlimited seats. if (organisationClaim.memberCount === 0) { return; } + // Early return if the new count is less than the paid high-water mark for grow mode. + if (mode === 'grow' && quantity <= organisationClaim.memberCount) { + return; + } + const isSeatsBased = await isPriceSeatsBased(subscription.priceId); + // Only seat-based plans support seat syncing. if (!isSeatsBased) { return; } - appLog('BILLING', 'Updating seat based plan'); + appLog('BILLING', `Updating seat based plan (${mode})`); await updateSubscriptionItemQuantity({ priceId: subscription.priceId, subscriptionId: subscription.planId, quantity, + prorationBehaviour: mode === 'grow' ? 'always_invoice' : 'none', }); - // 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. + // The claim mirrors the Stripe quantity (the paid seat high-water mark). + // This write is the only place the mark advances on grow — the + // subscription webhook's claim overwrite preserves the already-billed + // Stripe quantity but never advances it. await prisma.organisationClaim.update({ where: { id: organisationClaim.id, @@ -134,3 +161,67 @@ export const syncMemberCountWithStripeSeatPlan = async ( }, }); }; + +/** + * Reconciles the Stripe seat quantity and organisation claim with the actual + * member count at the start of a new billing period. + * + * Called from the `customer.subscription.updated` webhook when the billing + * period advances. The renewal invoice has already been generated at the + * previous (high-water) quantity by then — the reconciled count takes effect + * on the next invoice (accepted trade-off: removed seats bill for exactly + * one extra period). + * + * Runs with no prorations in either direction: no credits when shrinking, + * no retroactive charges when healing upward drift (e.g. unbilled SSO + * portal joins or lost grow races). + */ +export const reconcileSeatsWithMemberCount = async (organisationId: string) => { + const organisation = await prisma.organisation.findUnique({ + where: { + id: organisationId, + }, + include: { + subscription: true, + organisationClaim: true, + }, + }); + + if (!organisation || !organisation.subscription) { + appLog('BILLING', 'Reconcile skipped: organisation or subscription not found'); + + return; + } + + // Only ACTIVE subscriptions reconcile. INACTIVE (canceled) subscriptions + // cannot have their quantity updated in Stripe, and skipping PAST_DUE is + // deliberate: drift heals at the first renewal after recovery. + if (organisation.subscription.status !== SubscriptionStatus.ACTIVE) { + appLog('BILLING', 'Reconcile skipped: subscription not active'); + + return; + } + + const memberCount = await prisma.organisationMember.count({ + where: { + organisationId, + }, + }); + + // An organisation always has at least its owner. Guarding zero protects + // more than the Stripe quantity: writing 0 to the claim would flip + // memberCount to the unlimited sentinel and permanently exempt the + // organisation from seat billing. + if (memberCount === 0) { + appLog('BILLING', 'Reconcile skipped: organisation has no members'); + + return; + } + + await syncMemberCountWithStripeSeatPlan( + organisation.subscription, + organisation.organisationClaim, + memberCount, + 'reconcile', + ); +}; diff --git a/packages/lib/errors/app-error.ts b/packages/lib/errors/app-error.ts index 5a64814b6..f993465d7 100644 --- a/packages/lib/errors/app-error.ts +++ b/packages/lib/errors/app-error.ts @@ -36,6 +36,7 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record team.id); await prisma.$transaction(async (tx) => { 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 9d12d2ca7..834239270 100644 --- a/packages/trpc/server/organisation-router/delete-organisation-member-invites.ts +++ b/packages/trpc/server/organisation-router/delete-organisation-member-invites.ts @@ -1,4 +1,3 @@ -import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity'; 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'; @@ -32,20 +31,6 @@ export const deleteOrganisationMemberInvitesRoute = authenticatedProcedure userId, roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'], }), - include: { - organisationClaim: true, - subscription: true, - members: { - select: { - id: true, - }, - }, - invites: { - select: { - id: true, - }, - }, - }, }); if (!organisation) { @@ -83,22 +68,6 @@ export const deleteOrganisationMemberInvitesRoute = authenticatedProcedure }); } - const { organisationClaim } = organisation; - - const numberOfCurrentMembers = organisation.members.length; - const numberOfCurrentInvites = organisation.invites.length; - const totalMemberCountWithInvites = numberOfCurrentMembers + numberOfCurrentInvites - 1; - - // 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( - organisation.subscription, - organisationClaim, - totalMemberCountWithInvites, - ); - } - await prisma.organisationMemberInvite.deleteMany({ where: { id: { diff --git a/packages/trpc/server/organisation-router/delete-organisation-members.ts b/packages/trpc/server/organisation-router/delete-organisation-members.ts index dd5822265..7d411955a 100644 --- a/packages/trpc/server/organisation-router/delete-organisation-members.ts +++ b/packages/trpc/server/organisation-router/delete-organisation-members.ts @@ -1,10 +1,8 @@ -import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity'; 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 { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations'; import { prisma } from '@documenso/prisma'; -import { OrganisationMemberInviteStatus } from '@documenso/prisma/client'; import { authenticatedProcedure } from '../trpc'; import { @@ -52,8 +50,6 @@ export const deleteOrganisationMembers = async ({ roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'], }), include: { - subscription: true, - organisationClaim: true, teams: { select: { id: true, @@ -65,14 +61,6 @@ export const deleteOrganisationMembers = async ({ userId: true, }, }, - invites: { - where: { - status: OrganisationMemberInviteStatus.PENDING, - }, - select: { - id: true, - }, - }, }, }); @@ -80,19 +68,8 @@ export const deleteOrganisationMembers = async ({ throw new AppError(AppErrorCode.UNAUTHORIZED); } - const { organisationClaim } = organisation; - const membersToDelete = organisation.members.filter((member) => organisationMemberIds.includes(member.id)); - const inviteCount = organisation.invites.length; - const newMemberCount = organisation.members.length + inviteCount - membersToDelete.length; - - // 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); diff --git a/packages/trpc/server/organisation-router/leave-organisation.ts b/packages/trpc/server/organisation-router/leave-organisation.ts index e04f5d26f..2eb9a6e97 100644 --- a/packages/trpc/server/organisation-router/leave-organisation.ts +++ b/packages/trpc/server/organisation-router/leave-organisation.ts @@ -1,9 +1,7 @@ -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 { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations'; import { prisma } from '@documenso/prisma'; -import { OrganisationMemberInviteStatus } from '@documenso/prisma/client'; import { authenticatedProcedure } from '../trpc'; import { ZLeaveOrganisationRequestSchema, ZLeaveOrganisationResponseSchema } from './leave-organisation.types'; @@ -24,26 +22,11 @@ export const leaveOrganisationRoute = authenticatedProcedure const organisation = await prisma.organisation.findFirst({ where: buildOrganisationWhereQuery({ organisationId, userId }), include: { - organisationClaim: true, - subscription: true, teams: { select: { id: true, }, }, - invites: { - where: { - status: OrganisationMemberInviteStatus.PENDING, - }, - select: { - id: true, - }, - }, - members: { - select: { - id: true, - }, - }, }, }); @@ -51,17 +34,6 @@ export const leaveOrganisationRoute = authenticatedProcedure throw new AppError(AppErrorCode.NOT_FOUND); } - const { organisationClaim } = organisation; - - const inviteCount = organisation.invites.length; - const newMemberCount = organisation.members.length + inviteCount - 1; - - // 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); - } - const teamIds = organisation.teams.map((team) => team.id); await prisma.$transaction(async (tx) => {