Compare commits

...

4 Commits

Author SHA1 Message Date
David Nguyen d92aa6ee93 fix: stuff 2026-06-19 14:39:08 +10:00
David Nguyen 70cdef5a6d fix: make better 2026-06-18 14:28:41 +10:00
David Nguyen d1d94f5e46 fix: nitpicks 2026-06-16 22:01:28 +10:00
David Nguyen d8e8f36e45 fix: update stripe team member billing 2026-06-16 18:04:25 +10:00
16 changed files with 644 additions and 308 deletions
@@ -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 (
<div className="w-screen max-w-lg px-4">
<div className="w-full">
<h1 className="font-semibold text-4xl">
<Trans>Invalid token</Trans>
</h1>
<p className="mt-2 mb-4 text-muted-foreground text-sm">
<Trans>This token is invalid or has expired. No action is needed.</Trans>
</p>
<Button asChild>
<Link to="/">
<Trans>Return</Trans>
</Link>
</Button>
</div>
</div>
);
}
return (
<div className="w-screen max-w-lg px-4">
<h1 className="font-semibold text-4xl">
<Trans>Invitation declined</Trans>
</h1>
<p className="mt-2 mb-4 text-muted-foreground text-sm">
<Trans>
You have declined the invitation from <strong>{data.organisationName}</strong> to join their organisation.
</Trans>
</p>
<Button asChild>
<Link to="/">
<Trans>Return to Home</Trans>
</Link>
</Button>
</div>
);
// 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`);
}
@@ -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 (
<div>
<h1 className="font-semibold text-4xl">
<Trans>Organisation invitation</Trans>
</h1>
<div className="w-screen max-w-lg px-4">
<div className="w-full">
<h1 className="font-semibold text-4xl">
<Trans>Invitation already accepted</Trans>
</h1>
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
You have been invited by <strong>{data.organisationName}</strong> to join their organisation.
</Trans>
</p>
<p className="mt-2 mb-4 text-muted-foreground text-sm">
<Trans>
You are already a member of <strong>{data.organisationName}</strong>.
</Trans>
</p>
<p className="mt-1 mb-4 text-muted-foreground text-sm">
<Trans>To accept this invitation you must create an account.</Trans>
</p>
<Button asChild>
<Link to={`/signup#email=${encodeURIComponent(data.email)}`}>
<Trans>Create account</Trans>
</Link>
</Button>
<Button asChild>
<Link to="/">
<Trans>Continue</Trans>
</Link>
</Button>
</div>
</div>
);
}
if (data.state === 'AlreadyDeclined') {
return <InvitationDeclined organisationName={data.organisationName} />;
}
return (
<div>
<h1 className="font-semibold text-4xl">
<Trans>Invitation accepted!</Trans>
</h1>
<p className="mt-2 mb-4 text-muted-foreground text-sm">
<Trans>
You have accepted an invitation from <strong>{data.organisationName}</strong> to join their organisation.
</Trans>
</p>
{data.isSessionUserTheInvitedUser ? (
<Button asChild>
<Link to="/">
<Trans>Continue</Trans>
</Link>
</Button>
) : (
<Button asChild>
<Link to={`/signin#email=${encodeURIComponent(data.email)}`}>
<Trans>Continue to login</Trans>
</Link>
</Button>
)}
</div>
<PendingInvitation
token={data.token}
email={data.email}
organisationName={data.organisationName}
userExists={data.userExists}
isSessionUserTheInvitedUser={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<InvitationResult>('idle');
const [acceptFailureReason, setAcceptFailureReason] = useState<AcceptFailureReason | null>(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 (
<div className="w-screen max-w-lg px-4">
<div className="w-full">
<h1 className="font-semibold text-4xl">
<Trans>Invitation accepted!</Trans>
</h1>
<p className="mt-2 mb-4 text-muted-foreground text-sm">
<Trans>
You have accepted an invitation from <strong>{organisationName}</strong> to join their organisation.
</Trans>
</p>
{isSessionUserTheInvitedUser ? (
<Button asChild>
<Link to="/">
<Trans>Continue</Trans>
</Link>
</Button>
) : (
<Button asChild>
<Link to={`/signin#email=${encodeURIComponent(email)}`}>
<Trans>Continue to login</Trans>
</Link>
</Button>
)}
</div>
</div>
);
}
if (result === 'declined') {
return <InvitationDeclined organisationName={organisationName} />;
}
// 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 (
<div className="w-screen max-w-lg px-4">
<div className="w-full">
<h1 className="font-semibold text-4xl">
<Trans>Organisation invitation</Trans>
</h1>
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
You have been invited by <strong>{organisationName}</strong> to join their organisation.
</Trans>
</p>
<p className="mt-1 mb-4 text-muted-foreground text-sm">
<Trans>To accept this invitation you must create an account.</Trans>
</p>
<Button asChild>
<Link to={`/signup#email=${encodeURIComponent(email)}`}>
<Trans>Create account</Trans>
</Link>
</Button>
</div>
</div>
);
}
const isPending = acceptInvitation.isPending || declineInvitation.isPending;
return (
<div className="w-screen max-w-lg px-4">
<div className="w-full">
<h1 className="font-semibold text-4xl">
<Trans>Organisation invitation</Trans>
</h1>
<p className="mt-2 mb-4 text-muted-foreground text-sm">
<Trans>
You have been invited to join <strong>{organisationName}</strong> on Documenso.
</Trans>
</p>
{acceptFailureReason && (
<p className="mt-2 mb-4 text-destructive text-sm">
{match(acceptFailureReason)
.with('CapExceeded', () => (
<Trans>
<strong>{organisationName}</strong> has reached its member limit. Please contact the organisation
administrator to upgrade their plan before accepting this invitation.
</Trans>
))
.with('SubscriptionInactive', () => (
<Trans>
<strong>{organisationName}</strong> does not have an active subscription. Please contact the
organisation administrator to renew their plan before accepting this invitation.
</Trans>
))
.with('Unknown', () => (
<Trans>
We were unable to add you to <strong>{organisationName}</strong> at this time. Please try again later,
or contact the organisation administrator.
</Trans>
))
.exhaustive()}
</p>
)}
<div className="flex items-center gap-x-4">
<Button
variant="destructive"
onClick={async () => declineInvitation.mutateAsync({ token })}
loading={declineInvitation.isPending}
disabled={isPending}
>
<Trans>Decline</Trans>
</Button>
{!actionIsDecline && (
<Button
onClick={async () => acceptInvitation.mutateAsync({ token })}
loading={acceptInvitation.isPending}
disabled={isPending}
>
<Trans>Accept</Trans>
</Button>
)}
</div>
</div>
</div>
);
};
const InvitationDeclined = ({ organisationName }: { organisationName: string }) => {
return (
<div className="w-screen max-w-lg px-4">
<div className="w-full">
<h1 className="font-semibold text-4xl">
<Trans>Invitation declined</Trans>
</h1>
<p className="mt-2 mb-4 text-muted-foreground text-sm">
<Trans>
You have declined the invitation from <strong>{organisationName}</strong> to join their organisation.
</Trans>
</p>
</div>
</div>
);
};
@@ -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 non-ACTIVE subscriptions.
const previousPeriodEnd = organisation.subscription?.periodEnd ?? null;
const hasPeriodAdvanced = previousPeriodEnd !== null && periodEnd.getTime() > previousPeriodEnd.getTime();
if (hasPeriodAdvanced && !bypassClaimUpdate) {
await reconcileSeatBasedPlans(organisation.id);
}
};
/**
@@ -11,12 +11,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 +28,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 +39,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 +55,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 +79,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 +95,127 @@ 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 reset it to the
// actual count on reconcile. Re-adds and shrinks deliberately leave it 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;
// 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,
},
});
};
@@ -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 = {
+4
View File
@@ -14,6 +14,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';
@@ -26,6 +27,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
@@ -58,7 +60,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;
@@ -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'),
});
};
@@ -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
>;
@@ -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',
);
};
@@ -0,0 +1,29 @@
import { z } from 'zod';
import type { JobDefinition } from '../../client/_internal/job';
const SYNC_ORGANISATION_SEATS_JOB_DEFINITION_ID = 'sync.organisation-seats';
const SYNC_ORGANISATION_SEATS_JOB_DEFINITION_SCHEMA = z.object({
organisationId: z.string(),
});
export type TSyncOrganisationSeatsJobDefinition = z.infer<typeof SYNC_ORGANISATION_SEATS_JOB_DEFINITION_SCHEMA>;
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
>;
@@ -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,
@@ -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,
});
@@ -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: 'sync.organisation-seats',
payload: { organisationId },
});
await jobs.triggerJob({
name: 'send.organisation-member-left.email',
payload: {
@@ -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: {
@@ -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);
@@ -127,6 +104,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: 'sync.organisation-seats',
payload: { organisationId },
});
for (const member of membersToDelete) {
await jobs.triggerJob({
name: 'send.organisation-member-left.email',
@@ -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) => {
@@ -93,6 +65,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: 'sync.organisation-seats',
payload: { organisationId },
});
await jobs.triggerJob({
name: 'send.organisation-member-left.email',
payload: {