mirror of
https://github.com/documenso/documenso.git
synced 2026-07-26 09:54:51 +10:00
fix: stuff
This commit is contained in:
@@ -5,7 +5,7 @@ import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { OrganisationType, type Prisma, SubscriptionStatus } from '@prisma/client';
|
import { OrganisationType, type Prisma, SubscriptionStatus } from '@prisma/client';
|
||||||
import { match } from 'ts-pattern';
|
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'];
|
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();
|
const hasPeriodAdvanced = previousPeriodEnd !== null && periodEnd.getTime() > previousPeriodEnd.getTime();
|
||||||
|
|
||||||
if (hasPeriodAdvanced && !bypassClaimUpdate) {
|
if (hasPeriodAdvanced && !bypassClaimUpdate) {
|
||||||
await reconcileOrganisationClaimWithStripeSubscriptionQuantity(organisation.id);
|
await reconcileSeatBasedPlans(organisation.id);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
const organisation = await prisma.organisation.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: organisationId,
|
id: organisationId,
|
||||||
@@ -169,43 +174,48 @@ export const reconcileOrganisationClaimWithStripeSubscriptionQuantity = async (o
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Reconciling organisation claim with stripe subscription quantity');
|
|
||||||
|
|
||||||
if (!organisation || !organisation.subscription) {
|
if (!organisation || !organisation.subscription) {
|
||||||
console.log('No organisation or subscription found');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { subscription, organisationClaim } = organisation;
|
const { subscription, organisationClaim } = organisation;
|
||||||
|
|
||||||
|
// Unlimited seats — nothing to meter.
|
||||||
|
if (organisationClaim.memberCount === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const isSeatsBased = await isPriceSeatsBased(subscription.priceId);
|
const isSeatsBased = await isPriceSeatsBased(subscription.priceId);
|
||||||
|
|
||||||
// Only seat-based plans support seat syncing.
|
// Only seat-based plans support seat syncing.
|
||||||
if (!isSeatsBased) {
|
if (!isSeatsBased) {
|
||||||
console.log('Not a seats-based plan');
|
|
||||||
return;
|
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
|
// An organisation always retains its owner; never write the unlimited sentinel.
|
||||||
// but this is a safeguard.
|
if (memberCount === 0) {
|
||||||
const stripeSeatsQuantity =
|
return;
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await updateSubscriptionItemQuantity({
|
||||||
|
priceId: subscription.priceId,
|
||||||
|
subscriptionId: subscription.planId,
|
||||||
|
quantity: memberCount,
|
||||||
|
prorationBehaviour: 'none',
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.organisationClaim.update({
|
||||||
|
where: {
|
||||||
|
id: organisationClaim.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
memberCount,
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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_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 { 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 { 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 { BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION } from './definitions/internal/backport-subscription-claims';
|
||||||
import { BULK_SEND_TEMPLATE_JOB_DEFINITION } from './definitions/internal/bulk-send-template';
|
import { BULK_SEND_TEMPLATE_JOB_DEFINITION } from './definitions/internal/bulk-send-template';
|
||||||
import { CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION } from './definitions/internal/cancel-organisation-subscription';
|
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,
|
CLEANUP_RATE_LIMITS_JOB_DEFINITION,
|
||||||
SYNC_EMAIL_DOMAINS_JOB_DEFINITION,
|
SYNC_EMAIL_DOMAINS_JOB_DEFINITION,
|
||||||
ADMIN_DELETE_ORGANISATION_JOB_DEFINITION,
|
ADMIN_DELETE_ORGANISATION_JOB_DEFINITION,
|
||||||
|
ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION,
|
||||||
CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION,
|
CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION,
|
||||||
SYNC_ORGANISATION_SEATS_JOB_DEFINITION,
|
SYNC_ORGANISATION_SEATS_JOB_DEFINITION,
|
||||||
] as const);
|
] as const);
|
||||||
|
|||||||
@@ -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
|
||||||
|
>;
|
||||||
Reference in New Issue
Block a user