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 9bc4dbb3f..4bf327282 100644 --- a/packages/ee/server-only/stripe/sync-stripe-customer-subscription.ts +++ b/packages/ee/server-only/stripe/sync-stripe-customer-subscription.ts @@ -5,7 +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 { reconcileOrganisationClaimWithStripeSubscriptionQuantity } from './update-subscription-item-quantity'; +import { reconcileSeatBasedPlans } from './update-subscription-item-quantity'; const LIVE_SUBSCRIPTION_STATUSES: Stripe.Subscription.Status[] = ['active', 'trialing', 'past_due']; @@ -241,7 +241,7 @@ const handleLiveSubscription = async ({ const hasPeriodAdvanced = previousPeriodEnd !== null && periodEnd.getTime() > previousPeriodEnd.getTime(); if (hasPeriodAdvanced && !bypassClaimUpdate) { - await reconcileOrganisationClaimWithStripeSubscriptionQuantity(organisation.id); + 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 eab39982f..0880a3a5a 100644 --- a/packages/ee/server-only/stripe/update-subscription-item-quantity.ts +++ b/packages/ee/server-only/stripe/update-subscription-item-quantity.ts @@ -154,11 +154,16 @@ export const syncMemberCountWithStripeSeatPlan = async ( }; /** - * Pulls stripe subscription quantity and reconciles it with the organisation claim. + * Reconciles the organisation claim seat counter, and the stripe quantity with the + * actual member count. * - * This is a Stripe -> Database sync operation. + * 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 reconcileOrganisationClaimWithStripeSubscriptionQuantity = async (organisationId: string) => { +export const reconcileSeatBasedPlans = async (organisationId: string) => { const organisation = await prisma.organisation.findFirst({ where: { id: organisationId, @@ -169,43 +174,48 @@ export const reconcileOrganisationClaimWithStripeSubscriptionQuantity = async (o }, }); - console.log('Reconciling organisation claim with stripe subscription quantity'); - if (!organisation || !organisation.subscription) { - console.log('No organisation or subscription found'); 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) { - console.log('Not a seats-based plan'); return; } - const stripeSubscription = await stripe.subscriptions.retrieve(subscription.planId); + const memberCount = await prisma.organisationMember.count({ + where: { + organisationId, + }, + }); - // Get the highest quantity item in the subscription. There should only ever be one - // but this is a safeguard. - const stripeSeatsQuantity = - stripeSubscription.items.data.length > 0 - ? Math.max(...stripeSubscription.items.data.map((item) => item.quantity ?? 0)) - : 0; - - console.log('Stripe seats quantity', stripeSeatsQuantity); - - // Never sync a quantity of 0 since that gives unlimited seats. - if (stripeSeatsQuantity > 0) { - await prisma.organisationClaim.update({ - where: { - id: organisationClaim.id, - }, - data: { - memberCount: stripeSeatsQuantity, - }, - }); + // 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', + }); + + await prisma.organisationClaim.update({ + where: { + id: organisationClaim.id, + }, + data: { + memberCount, + }, + }); }; diff --git a/packages/lib/jobs/client.ts b/packages/lib/jobs/client.ts index 2b1c249b2..e9e374b89 100644 --- a/packages/lib/jobs/client.ts +++ b/packages/lib/jobs/client.ts @@ -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'; @@ -59,6 +60,7 @@ 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); 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 +>;