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
-
-
-
-
- );
- }
-
- return (
-
-
- Invitation declined
-
-
-
-
- You have declined the invitation from {data.organisationName} to join their organisation.
-
-
-
-
-
- Return to Home
-
-
-
- );
+ // 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.
-
-
-
-
- Create account
-
-
+
+
+ Continue
+
+
+
);
}
+ if (data.state === 'AlreadyDeclined') {
+ return
;
+ }
+
return (
-
-
- Invitation accepted!
-
-
-
-
- You have accepted an invitation from {data.organisationName} to join their organisation.
-
-
-
- {data.isSessionUserTheInvitedUser ? (
-
-
- Continue
-
-
- ) : (
-
-
- Continue to login
-
-
- )}
-
+
);
}
+
+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 ? (
+
+
+ Continue
+
+
+ ) : (
+
+
+ Continue to login
+
+
+ )}
+
+
+ );
+ }
+
+ 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.
+
+
+
+
+ Create 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()}
+
+ )}
+
+
+ declineInvitation.mutateAsync({ token })}
+ loading={declineInvitation.isPending}
+ disabled={isPending}
+ >
+ Decline
+
+
+ {!actionIsDecline && (
+ acceptInvitation.mutateAsync({ token })}
+ loading={acceptInvitation.isPending}
+ disabled={isPending}
+ >
+ Accept
+
+ )}
+
+
+
+ );
+};
+
+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) => {