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

- Invalid token -

- -

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

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

- Invitation declined -

- -

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

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

- Organisation invitation -

+
+
+

+ Invitation already accepted +

-

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

+

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

-

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

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

- Invitation accepted! -

- -

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

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

+ Invitation accepted! +

+ +

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

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

+ Organisation invitation +

+ +

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

+ +

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

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

+ Organisation invitation +

+ +

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

+ + {acceptFailureReason && ( +

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

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

+ Invitation declined +

+ +

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

+
+
+ ); +}; diff --git a/packages/ee/server-only/stripe/sync-stripe-customer-subscription.ts b/packages/ee/server-only/stripe/sync-stripe-customer-subscription.ts index 29a4d7b7f..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: {