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..2a9502e4e 100644
--- a/packages/ee/server-only/stripe/sync-stripe-customer-subscription.ts
+++ b/packages/ee/server-only/stripe/sync-stripe-customer-subscription.ts
@@ -5,6 +5,7 @@ import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
import { prisma } from '@documenso/prisma';
import { OrganisationType, type Prisma, SubscriptionStatus } from '@prisma/client';
import { match } from 'ts-pattern';
+import { reconcileSeatBasedPlans } from './update-subscription-item-quantity';
const LIVE_SUBSCRIPTION_STATUSES: Stripe.Subscription.Status[] = ['active', 'trialing', 'past_due'];
@@ -229,6 +230,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 inactive subscriptions.
+ const previousPeriodEnd = organisation.subscription?.periodEnd ?? null;
+
+ const hasPeriodAdvanced = previousPeriodEnd !== null && periodEnd.getTime() > previousPeriodEnd.getTime();
+
+ if (hasPeriodAdvanced && !bypassClaimUpdate) {
+ await reconcileSeatBasedPlans(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..1183e0d35 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) {
@@ -89,48 +96,134 @@ export const assertMemberCountWithinCap = async (
};
/**
- * Syncs the organisation's member count with the Stripe subscription quantity.
+ * Syncs the Stripe subscription quantity with the organisation's member count.
*
- * 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.
+ * This is a Stripe <-> Database sync operation.
+ *
+ * Note: `organisationClaim.memberCount` is the paid seat high-water mark for the
+ * current billing period — the highest count we've already billed for.
*
* @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 - The member-count change that triggered the sync.
*/
export const syncMemberCountWithStripeSeatPlan = async (
subscription: Subscription,
organisationClaim: OrganisationClaim,
quantity: number,
+ mode: 'grow' | 'shrink',
) => {
- // Infinite seats means no sync needed.
+ // Unlimited seats — nothing to meter.
if (organisationClaim.memberCount === 0) {
return;
}
const isSeatsBased = await isPriceSeatsBased(subscription.priceId);
+ // Only seat-based plans support seat syncing.
if (!isSeatsBased) {
return;
}
- appLog('BILLING', 'Updating seat based plan');
+ // Whether to immediately invoice for new seats if the quantity is greater than
+ // the high-water mark.
+ const billsForNewSeats = mode === 'grow' && quantity > organisationClaim.memberCount;
+
+ appLog('BILLING', `Syncing seat based plan (${mode}, quantity ${quantity})`);
await updateSubscriptionItemQuantity({
priceId: subscription.priceId,
subscriptionId: subscription.planId,
quantity,
+ prorationBehaviour: billsForNewSeats ? 'always_invoice' : 'none',
+ });
+
+ // Advance the high-water mark when billing for new seats; it is reset to the
+ // actual member count when the billing period rolls over. Re-adds and shrinks
+ // deliberately leave it untouched so a seat already paid for this period is
+ // never re-charged.
+ if (billsForNewSeats) {
+ await prisma.organisationClaim.update({
+ where: {
+ id: organisationClaim.id,
+ },
+ data: {
+ memberCount: quantity,
+ },
+ });
+ }
+};
+
+/**
+ * Reconciles the organisation claim seat counter, and the stripe quantity with the
+ * actual member count.
+ *
+ * Uses the member count as the authoritative source of truth. Meaning:
+ * - Update the organisation claim with the member count
+ * - Update the Stripe subscription quantity to the member count
+ *
+ * This should only be called when the billing period rolls over.
+ */
+export const reconcileSeatBasedPlans = async (organisationId: string) => {
+ const organisation = await prisma.organisation.findFirst({
+ where: {
+ id: organisationId,
+ },
+ include: {
+ organisationClaim: true,
+ subscription: true,
+ },
+ });
+
+ if (!organisation || !organisation.subscription) {
+ return;
+ }
+
+ const { subscription, organisationClaim } = organisation;
+
+ // Stripe rejects quantity updates on canceled subscriptions. PAST_DUE is
+ // still live and a no-proration sync is safe, so it's allowed through.
+ if (subscription.status === SubscriptionStatus.INACTIVE) {
+ return;
+ }
+
+ // Unlimited seats — nothing to meter.
+ if (organisationClaim.memberCount === 0) {
+ return;
+ }
+
+ const isSeatsBased = await isPriceSeatsBased(subscription.priceId);
+
+ // Only seat-based plans support seat syncing.
+ if (!isSeatsBased) {
+ return;
+ }
+
+ const memberCount = await prisma.organisationMember.count({
+ where: {
+ organisationId,
+ },
+ });
+
+ // An organisation always retains its owner; never write the unlimited sentinel.
+ if (memberCount === 0) {
+ return;
+ }
+
+ await updateSubscriptionItemQuantity({
+ priceId: subscription.priceId,
+ subscriptionId: subscription.planId,
+ quantity: memberCount,
+ prorationBehaviour: '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.
await prisma.organisationClaim.update({
where: {
id: organisationClaim.id,
},
data: {
- memberCount: quantity,
+ memberCount,
},
});
};
diff --git a/packages/ee/server-only/stripe/webhook/handler.ts b/packages/ee/server-only/stripe/webhook/handler.ts
index 03f07daf3..538187c0f 100644
--- a/packages/ee/server-only/stripe/webhook/handler.ts
+++ b/packages/ee/server-only/stripe/webhook/handler.ts
@@ -2,7 +2,6 @@ import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import type { Stripe } from '@documenso/lib/server-only/stripe';
import { stripe } from '@documenso/lib/server-only/stripe';
import { env } from '@documenso/lib/utils/env';
-
import { syncStripeCustomerSubscription } from '../sync-stripe-customer-subscription';
type StripeWebhookResponse = {
diff --git a/packages/lib/jobs/client.ts b/packages/lib/jobs/client.ts
index 397108f61..5309bd510 100644
--- a/packages/lib/jobs/client.ts
+++ b/packages/lib/jobs/client.ts
@@ -17,6 +17,7 @@ import { SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION } from './definitions/emai
import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-signing-email';
import { SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-deleted-email';
import { ADMIN_DELETE_ORGANISATION_JOB_DEFINITION } from './definitions/internal/admin-delete-organisation';
+import { ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION } from './definitions/internal/alert-organisation-seat-drift';
import { BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION } from './definitions/internal/backport-subscription-claims';
import { BULK_SEND_TEMPLATE_JOB_DEFINITION } from './definitions/internal/bulk-send-template';
import { CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION } from './definitions/internal/cancel-organisation-subscription';
@@ -29,6 +30,7 @@ import { SEAL_DOCUMENT_JOB_DEFINITION } from './definitions/internal/seal-docume
import { SEAL_DOCUMENT_SWEEP_JOB_DEFINITION } from './definitions/internal/seal-document-sweep';
import { SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION } from './definitions/internal/send-signing-reminders-sweep';
import { SYNC_EMAIL_DOMAINS_JOB_DEFINITION } from './definitions/internal/sync-email-domains';
+import { SYNC_ORGANISATION_SEATS_JOB_DEFINITION } from './definitions/internal/sync-organisation-seats';
/**
* The `as const` assertion is load bearing as it provides the correct level of type inference for
@@ -64,7 +66,9 @@ export const jobsClient = new JobClient([
CLEANUP_RATE_LIMITS_JOB_DEFINITION,
SYNC_EMAIL_DOMAINS_JOB_DEFINITION,
ADMIN_DELETE_ORGANISATION_JOB_DEFINITION,
+ ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION,
CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION,
+ SYNC_ORGANISATION_SEATS_JOB_DEFINITION,
] as const);
export const jobs = jobsClient;
diff --git a/packages/lib/jobs/definitions/internal/alert-organisation-seat-drift.handler.ts b/packages/lib/jobs/definitions/internal/alert-organisation-seat-drift.handler.ts
new file mode 100644
index 000000000..e8cd9f0e8
--- /dev/null
+++ b/packages/lib/jobs/definitions/internal/alert-organisation-seat-drift.handler.ts
@@ -0,0 +1,67 @@
+import { mailer } from '@documenso/email/mailer';
+import { prisma } from '@documenso/prisma';
+import { IS_BILLING_ENABLED, SUPPORT_EMAIL } from '../../../constants/app';
+import { DOCUMENSO_INTERNAL_EMAIL } from '../../../constants/email';
+import type { JobRunIO } from '../../client/_internal/job';
+import type { TAlertOrganisationSeatDriftJobDefinition } from './alert-organisation-seat-drift';
+
+/**
+ * Daily check for organisations whose member count exceeds their paid seat
+ * count (`organisationClaim.memberCount`, where `0` means unlimited).
+ */
+export const run = async ({ io }: { payload: TAlertOrganisationSeatDriftJobDefinition; io: JobRunIO }) => {
+ if (!IS_BILLING_ENABLED()) {
+ return;
+ }
+
+ const organisations = await prisma.organisation.findMany({
+ where: {
+ // Exclude unlimited-seat plans (memberCount === 0).
+ organisationClaim: {
+ memberCount: {
+ not: 0,
+ },
+ },
+ },
+ select: {
+ id: true,
+ name: true,
+ organisationClaim: {
+ select: {
+ memberCount: true,
+ },
+ },
+ _count: {
+ select: {
+ members: true,
+ },
+ },
+ },
+ });
+
+ const driftedOrganisations = organisations.filter(
+ (organisation) =>
+ organisation.organisationClaim !== null &&
+ organisation._count.members > organisation.organisationClaim.memberCount,
+ );
+
+ if (driftedOrganisations.length === 0) {
+ io.logger.info('No organisations exceed their paid seat count');
+
+ return;
+ }
+
+ await mailer.sendMail({
+ to: SUPPORT_EMAIL,
+ from: DOCUMENSO_INTERNAL_EMAIL,
+ subject: `[Billing] ${driftedOrganisations.length} organisation(s) exceed their paid seat count`,
+ text: [
+ `${driftedOrganisations.length} organisation(s) have more members than their paid seat count:`,
+ '',
+ ...driftedOrganisations.map(
+ (organisation) =>
+ `- ${organisation.name} (${organisation.id}): ${organisation._count.members} members vs ${organisation.organisationClaim?.memberCount ?? 0} paid seats`,
+ ),
+ ].join('\n'),
+ });
+};
diff --git a/packages/lib/jobs/definitions/internal/alert-organisation-seat-drift.ts b/packages/lib/jobs/definitions/internal/alert-organisation-seat-drift.ts
new file mode 100644
index 000000000..7cf5a3a5d
--- /dev/null
+++ b/packages/lib/jobs/definitions/internal/alert-organisation-seat-drift.ts
@@ -0,0 +1,30 @@
+import { z } from 'zod';
+
+import type { JobDefinition } from '../../client/_internal/job';
+
+const ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_ID = 'internal.alert-organisation-seat-drift';
+
+const ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_SCHEMA = z.object({});
+
+export type TAlertOrganisationSeatDriftJobDefinition = z.infer<
+ typeof ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_SCHEMA
+>;
+
+export const ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION = {
+ id: ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_ID,
+ name: 'Alert Organisation Seat Drift',
+ version: '1.0.0',
+ trigger: {
+ name: ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_ID,
+ schema: ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_SCHEMA,
+ cron: '0 0 * * *', // Once a day at midnight.
+ },
+ handler: async ({ payload, io }) => {
+ const handler = await import('./alert-organisation-seat-drift.handler');
+
+ await handler.run({ payload, io });
+ },
+} as const satisfies JobDefinition<
+ typeof ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_ID,
+ TAlertOrganisationSeatDriftJobDefinition
+>;
diff --git a/packages/lib/jobs/definitions/internal/sync-organisation-seats.handler.ts b/packages/lib/jobs/definitions/internal/sync-organisation-seats.handler.ts
new file mode 100644
index 000000000..9010e4d86
--- /dev/null
+++ b/packages/lib/jobs/definitions/internal/sync-organisation-seats.handler.ts
@@ -0,0 +1,54 @@
+import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
+import { prisma } from '@documenso/prisma';
+import { SubscriptionStatus } from '@prisma/client';
+import { IS_BILLING_ENABLED } from '../../../constants/app';
+import type { JobRunIO } from '../../client/_internal/job';
+import type { TSyncOrganisationSeatsJobDefinition } from './sync-organisation-seats';
+
+export const run = async ({ payload }: { payload: TSyncOrganisationSeatsJobDefinition; io: JobRunIO }) => {
+ const { organisationId } = payload;
+
+ if (!IS_BILLING_ENABLED()) {
+ return;
+ }
+
+ const organisation = await prisma.organisation.findUnique({
+ where: {
+ id: organisationId,
+ },
+ include: {
+ subscription: true,
+ organisationClaim: true,
+ },
+ });
+
+ if (!organisation || !organisation.subscription) {
+ return;
+ }
+
+ // Skip canceled/terminal subscriptions — Stripe rejects quantity updates on a
+ // canceled subscription. PAST_DUE is still live and a no-proration shrink is
+ // safe, so it's allowed through.
+ if (organisation.subscription.status === SubscriptionStatus.INACTIVE) {
+ return;
+ }
+
+ const memberCount = await prisma.organisationMember.count({
+ where: {
+ organisationId,
+ },
+ });
+
+ // An organisation always retains its owner; guarding zero avoids writing the
+ // unlimited sentinel to the claim.
+ if (memberCount === 0) {
+ return;
+ }
+
+ await syncMemberCountWithStripeSeatPlan(
+ organisation.subscription,
+ organisation.organisationClaim,
+ memberCount,
+ 'shrink',
+ );
+};
diff --git a/packages/lib/jobs/definitions/internal/sync-organisation-seats.ts b/packages/lib/jobs/definitions/internal/sync-organisation-seats.ts
new file mode 100644
index 000000000..7ff4b6629
--- /dev/null
+++ b/packages/lib/jobs/definitions/internal/sync-organisation-seats.ts
@@ -0,0 +1,29 @@
+import { z } from 'zod';
+
+import type { JobDefinition } from '../../client/_internal/job';
+
+const SYNC_ORGANISATION_SEATS_JOB_DEFINITION_ID = 'internal.sync-organisation-seats';
+
+const SYNC_ORGANISATION_SEATS_JOB_DEFINITION_SCHEMA = z.object({
+ organisationId: z.string(),
+});
+
+export type TSyncOrganisationSeatsJobDefinition = z.infer;
+
+export const SYNC_ORGANISATION_SEATS_JOB_DEFINITION = {
+ id: SYNC_ORGANISATION_SEATS_JOB_DEFINITION_ID,
+ name: 'Sync Organisation Seats',
+ version: '1.0.0',
+ trigger: {
+ name: SYNC_ORGANISATION_SEATS_JOB_DEFINITION_ID,
+ schema: SYNC_ORGANISATION_SEATS_JOB_DEFINITION_SCHEMA,
+ },
+ handler: async ({ payload, io }) => {
+ const handler = await import('./sync-organisation-seats.handler');
+
+ await handler.run({ payload, io });
+ },
+} as const satisfies JobDefinition<
+ typeof SYNC_ORGANISATION_SEATS_JOB_DEFINITION_ID,
+ TSyncOrganisationSeatsJobDefinition
+>;
diff --git a/packages/lib/server-only/organisation/accept-organisation-invitation.ts b/packages/lib/server-only/organisation/accept-organisation-invitation.ts
index 67b8f7643..130b5bbec 100644
--- a/packages/lib/server-only/organisation/accept-organisation-invitation.ts
+++ b/packages/lib/server-only/organisation/accept-organisation-invitation.ts
@@ -1,7 +1,12 @@
+import {
+ assertMemberCountWithinCap,
+ syncMemberCountWithStripeSeatPlan,
+} from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
import { prisma } from '@documenso/prisma';
import type { OrganisationGroup, OrganisationMemberRole } from '@prisma/client';
-import { OrganisationGroupType, OrganisationMemberInviteStatus } from '@prisma/client';
+import { OrganisationGroupType, OrganisationMemberInviteStatus, SubscriptionStatus } from '@prisma/client';
+import { IS_BILLING_ENABLED } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import { generateDatabaseId } from '../../universal/id';
@@ -22,6 +27,13 @@ export const acceptOrganisationInvitation = async ({ token }: AcceptOrganisation
organisation: {
include: {
groups: true,
+ organisationClaim: true,
+ subscription: true,
+ members: {
+ select: {
+ id: true,
+ },
+ },
},
},
},
@@ -66,6 +78,35 @@ export const acceptOrganisationInvitation = async ({ token }: AcceptOrganisation
return;
}
+ const newMemberCount = organisation.members.length + 1;
+
+ // Billing occurs when a user accepts an invite.
+ // Assert that the new member count is within the cap and sync the seat plan with Stripe.
+ if (IS_BILLING_ENABLED()) {
+ const { subscription, organisationClaim } = organisation;
+
+ // A canceled subscription cannot have its seat quantity updated in Stripe,
+ // and an organisation with lapsed billing should not gain new members.
+ // Throw a deliberate error so the invite page can render an accurate
+ // message instead of an opaque Stripe failure.
+ if (subscription && subscription.status === SubscriptionStatus.INACTIVE) {
+ throw new AppError('SUBSCRIPTION_INACTIVE', {
+ message: 'The organisation subscription is inactive',
+ });
+ }
+
+ // Organisations can exist without a subscription (e.g. after being
+ // downgraded to the free plan). The claim cap remains authoritative in
+ // that case, surfacing LIMIT_EXCEEDED instead of an opaque "subscription
+ // not found" error.
+ await assertMemberCountWithinCap(subscription, organisationClaim, newMemberCount);
+
+ if (subscription) {
+ await syncMemberCountWithStripeSeatPlan(subscription, organisationClaim, newMemberCount, 'grow');
+ }
+ }
+
+ // Todo: Logging
await addUserToOrganisation({
userId: user.id,
organisationId: organisation.id,
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 5e813e5b0..a31aafe6a 100644
--- a/packages/lib/server-only/organisation/create-organisation-member-invites.ts
+++ b/packages/lib/server-only/organisation/create-organisation-member-invites.ts
@@ -1,7 +1,3 @@
-import {
- assertMemberCountWithinCap,
- syncMemberCountWithStripeSeatPlan,
-} from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
import { OrganisationInviteEmailTemplate } from '@documenso/email/templates/organisation-invite';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
@@ -17,7 +13,6 @@ import { createElement } from 'react';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { generateDatabaseId } from '../../universal/id';
-import { validateIfSubscriptionIsRequired } from '../../utils/billing';
import { buildOrganisationWhereQuery } from '../../utils/organisations';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
@@ -62,8 +57,6 @@ export const createOrganisationMemberInvites = async ({
},
},
organisationGlobalSettings: true,
- organisationClaim: true,
- subscription: true,
},
});
@@ -71,10 +64,6 @@ export const createOrganisationMemberInvites = async ({
throw new AppError(AppErrorCode.NOT_FOUND);
}
- const { organisationClaim } = organisation;
-
- const subscription = validateIfSubscriptionIsRequired(organisation.subscription);
-
const currentOrganisationMemberRole = await getMemberOrganisationRole({
organisationId: organisation.id,
reference: {
@@ -120,19 +109,6 @@ export const createOrganisationMemberInvites = async ({
}),
);
- const numberOfCurrentMembers = organisation.members.length;
- const numberOfCurrentInvites = organisation.invites.length;
- const numberOfNewInvites = organisationMemberInvites.length;
-
- const totalMemberCountWithInvites = numberOfCurrentMembers + numberOfCurrentInvites + numberOfNewInvites;
-
- // Enforce the seat cap and sync billing for seat based plans.
- if (subscription) {
- await assertMemberCountWithinCap(subscription, organisationClaim, totalMemberCountWithInvites);
-
- await syncMemberCountWithStripeSeatPlan(subscription, organisationClaim, totalMemberCountWithInvites);
- }
-
await prisma.organisationMemberInvite.createMany({
data: organisationMemberInvites,
});
diff --git a/packages/lib/server-only/user/delete-user.ts b/packages/lib/server-only/user/delete-user.ts
index 2ad28d42c..03d97a0e3 100644
--- a/packages/lib/server-only/user/delete-user.ts
+++ b/packages/lib/server-only/user/delete-user.ts
@@ -1,6 +1,7 @@
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
+import { jobs } from '../../jobs/client';
import { deleteOrganisation } from '../organisation/delete-organisation';
export type DeleteUserOptions = {
@@ -59,6 +60,13 @@ export const deleteUser = async ({ id }: DeleteUserOptions) => {
})),
);
+ // Organisations the user is a member of (but not owner). Owned organisations
+ // are fully torn down below (including subscription cancellation), so only
+ // these need a seat sync after the user's memberships cascade away.
+ const memberOrganisationIds = user.organisationMember
+ .filter((member) => member.organisation.ownerUserId !== user.id)
+ .map((member) => member.organisationId);
+
// For organisations the user owns - fully tear them down (orphan envelopes,
// delete the organisation, and cancel any Stripe subscription). Without this
// the organisations would only cascade away when the user row is deleted,
@@ -82,9 +90,21 @@ export const deleteUser = async ({ id }: DeleteUserOptions) => {
}),
);
- return await prisma.user.delete({
+ const deletedUser = await prisma.user.delete({
where: {
id: user.id,
},
});
+
+ // The user's memberships were cascade-deleted with the user row — queue a
+ // seat sync for each organisation they belonged to so the Stripe quantity
+ // trues down to the new member count (no proration, no credit).
+ for (const organisationId of memberOrganisationIds) {
+ await jobs.triggerJob({
+ name: 'internal.sync-organisation-seats',
+ payload: { organisationId },
+ });
+ }
+
+ return deletedUser;
};
diff --git a/packages/trpc/server/admin-router/delete-organisation-member.ts b/packages/trpc/server/admin-router/delete-organisation-member.ts
index 07991d2d3..df66ac024 100644
--- a/packages/trpc/server/admin-router/delete-organisation-member.ts
+++ b/packages/trpc/server/admin-router/delete-organisation-member.ts
@@ -1,8 +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 { prisma } from '@documenso/prisma';
-import { OrganisationMemberInviteStatus } from '@prisma/client';
import { adminProcedure } from '../trpc';
import {
@@ -28,8 +26,6 @@ export const deleteAdminOrganisationMemberRoute = adminProcedure
id: organisationId,
},
include: {
- subscription: true,
- organisationClaim: true,
teams: {
select: {
id: true,
@@ -41,14 +37,6 @@ export const deleteAdminOrganisationMemberRoute = adminProcedure
userId: true,
},
},
- invites: {
- where: {
- status: OrganisationMemberInviteStatus.PENDING,
- },
- select: {
- id: true,
- },
- },
},
});
@@ -72,18 +60,6 @@ export const deleteAdminOrganisationMemberRoute = adminProcedure
});
}
- const newMemberCount = organisation.members.length + organisation.invites.length - 1;
-
- // Removing a member 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,
- organisation.organisationClaim,
- newMemberCount,
- );
- }
-
const teamIds = organisation.teams.map((team) => team.id);
await prisma.$transaction(async (tx) => {
@@ -113,6 +89,13 @@ export const deleteAdminOrganisationMemberRoute = adminProcedure
});
});
+ // A member was removed — queue a seat sync to true the Stripe quantity down
+ // to the new count (no proration, no credit).
+ await jobs.triggerJob({
+ name: 'internal.sync-organisation-seats',
+ payload: { organisationId },
+ });
+
await jobs.triggerJob({
name: 'send.organisation-member-left.email',
payload: {
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 d19e3b71b..6a436ddff 100644
--- a/packages/trpc/server/organisation-router/delete-organisation-members.ts
+++ b/packages/trpc/server/organisation-router/delete-organisation-members.ts
@@ -1,8 +1,8 @@
-import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
import {
ORGANISATION_MEMBER_ROLE_HIERARCHY,
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 {
@@ -11,7 +11,6 @@ import {
isOrganisationRoleWithinUserHierarchy,
} from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
-import { OrganisationMemberInviteStatus } from '@documenso/prisma/client';
import { authenticatedProcedure } from '../trpc';
import {
@@ -59,8 +58,6 @@ export const deleteOrganisationMembers = async ({
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
}),
include: {
- subscription: true,
- organisationClaim: true,
teams: {
select: {
id: true,
@@ -75,14 +72,6 @@ export const deleteOrganisationMembers = async ({
},
},
},
- invites: {
- where: {
- status: OrganisationMemberInviteStatus.PENDING,
- },
- select: {
- id: true,
- },
- },
},
});
@@ -90,8 +79,6 @@ export const deleteOrganisationMembers = async ({
throw new AppError(AppErrorCode.UNAUTHORIZED);
}
- const { organisationClaim } = organisation;
-
const membersToDelete = organisation.members.filter((member) => organisationMemberIds.includes(member.id));
const currentUserMember = organisation.members.find((member) => member.userId === userId);
@@ -129,15 +116,6 @@ export const deleteOrganisationMembers = async ({
}
}
- 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);
@@ -184,6 +162,13 @@ export const deleteOrganisationMembers = async ({
});
});
+ // Members were removed — queue a seat sync to true the Stripe quantity down to
+ // the new count (no proration, no credit).
+ await jobs.triggerJob({
+ name: 'internal.sync-organisation-seats',
+ payload: { organisationId },
+ });
+
for (const member of membersToDelete) {
await jobs.triggerJob({
name: 'send.organisation-member-left.email',
diff --git a/packages/trpc/server/organisation-router/leave-organisation.ts b/packages/trpc/server/organisation-router/leave-organisation.ts
index 8e2b8b775..466870f41 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,
- },
- },
},
});
@@ -59,17 +42,6 @@ export const leaveOrganisationRoute = authenticatedProcedure
});
}
- 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) => {
@@ -101,6 +73,13 @@ export const leaveOrganisationRoute = authenticatedProcedure
});
});
+ // A member was removed — queue a seat sync to true the Stripe quantity down
+ // to the new count (no proration, no credit).
+ await jobs.triggerJob({
+ name: 'internal.sync-organisation-seats',
+ payload: { organisationId },
+ });
+
await jobs.triggerJob({
name: 'send.organisation-member-left.email',
payload: {