feat: billing

This commit is contained in:
David Nguyen
2025-05-19 12:38:50 +10:00
parent 7abfc9e271
commit 2805478e0d
221 changed files with 8436 additions and 5847 deletions

View File

@ -3,6 +3,7 @@ import { OrganisationGroupType, OrganisationMemberInviteStatus } from '@prisma/c
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
export type AcceptOrganisationInvitationOptions = {
token: string;
@ -21,7 +22,8 @@ export const acceptOrganisationInvitation = async ({
include: {
organisation: {
include: {
subscriptions: true,
subscription: true,
organisationClaim: true,
groups: {
include: {
teamGroups: true,
@ -46,19 +48,10 @@ export const acceptOrganisationInvitation = async ({
},
});
// If no user exists for the invitation, accept the invitation and create the organisation
// user when the user signs up.
if (!user) {
await prisma.organisationMemberInvite.update({
where: {
id: organisationMemberInvite.id,
},
data: {
status: OrganisationMemberInviteStatus.ACCEPTED,
},
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'User must exist to accept an organisation invitation',
});
return;
}
const { organisation } = organisationMemberInvite;
@ -98,28 +91,13 @@ export const acceptOrganisationInvitation = async ({
},
});
// Todo: Orgs
// if (IS_BILLING_ENABLED() && team.subscription) {
// const numberOfSeats = await tx.teamMember.count({
// where: {
// teamId: organisationMemberInvite.teamId,
// },
// });
// await updateSubscriptionItemQuantity({
// priceId: team.subscription.priceId,
// subscriptionId: team.subscription.planId,
// quantity: numberOfSeats,
// });
// }
// await jobs.triggerJob({
// name: 'send.team-member-joined.email',
// payload: {
// teamId: teamMember.teamId,
// memberId: teamMember.id,
// },
// });
await jobs.triggerJob({
name: 'send.organisation-member-joined.email',
payload: {
organisationId: organisation.id,
memberUserId: user.id,
},
});
},
{ timeout: 30_000 },
);

View File

@ -5,6 +5,7 @@ import type { Organisation, OrganisationGlobalSettings, Prisma } from '@prisma/c
import { OrganisationMemberInviteStatus } from '@prisma/client';
import { nanoid } from 'nanoid';
import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
import { mailer } from '@documenso/email/mailer';
import { OrganisationInviteEmailTemplate } from '@documenso/email/templates/organisation-invite';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
@ -16,12 +17,11 @@ import { prisma } from '@documenso/prisma';
import type { TCreateOrganisationMemberInvitesRequestSchema } from '@documenso/trpc/server/organisation-router/create-organisation-member-invites.types';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import {
buildOrganisationWhereQuery,
getHighestOrganisationRoleInGroup,
} from '../../utils/organisations';
import { validateIfSubscriptionIsRequired } from '../../utils/billing';
import { buildOrganisationWhereQuery } from '../../utils/organisations';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { organisationGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
import { getMemberOrganisationRole } from '../team/get-member-roles';
export type CreateOrganisationMemberInvitesOptions = {
userId: number;
@ -56,8 +56,14 @@ export const createOrganisationMemberInvites = async ({
},
},
},
invites: true,
invites: {
where: {
status: OrganisationMemberInviteStatus.PENDING,
},
},
organisationGlobalSettings: true,
organisationClaim: true,
subscription: true,
},
});
@ -65,38 +71,20 @@ export const createOrganisationMemberInvites = async ({
throw new AppError(AppErrorCode.NOT_FOUND);
}
const currentOrganisationMember = await prisma.organisationMember.findFirst({
where: {
userId,
organisationId,
},
include: {
organisationGroupMembers: {
include: {
group: true,
},
},
const { organisationClaim } = organisation;
const subscription = validateIfSubscriptionIsRequired(organisation.subscription);
const currentOrganisationMemberRole = await getMemberOrganisationRole({
organisationId: organisation.id,
reference: {
type: 'User',
id: userId,
},
});
if (!currentOrganisationMember) {
throw new AppError(AppErrorCode.UNAUTHORIZED);
}
const currentOrganisationMemberRole = getHighestOrganisationRoleInGroup(
currentOrganisationMember.organisationGroupMembers.map((member) => member.group),
);
const organisationMemberEmails = organisation.members.map((member) => member.user.email);
const organisationMemberInviteEmails = organisation.invites
.filter((invite) => invite.status === OrganisationMemberInviteStatus.PENDING)
.map((invite) => invite.email);
if (!currentOrganisationMember) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'User not part of organisation.',
});
}
const organisationMemberInviteEmails = organisation.invites.map((invite) => invite.email);
const usersToInvite = invitations.filter((invitation) => {
// Filter out users that are already members of the organisation.
@ -123,7 +111,6 @@ export const createOrganisationMemberInvites = async ({
});
}
// Todo: (orgs)
const organisationMemberInvites: Prisma.OrganisationMemberInviteCreateManyInput[] =
usersToInvite.map(({ email, organisationRole }) => ({
email,
@ -132,9 +119,21 @@ export const createOrganisationMemberInvites = async ({
token: nanoid(32),
}));
console.log({
organisationMemberInvites,
});
const numberOfCurrentMembers = organisation.members.length;
const numberOfCurrentInvites = organisation.invites.length;
const numberOfNewInvites = organisationMemberInvites.length;
const totalMemberCountWithInvites =
numberOfCurrentMembers + numberOfCurrentInvites + numberOfNewInvites;
// Handle billing for seat based plans.
if (subscription) {
await syncMemberCountWithStripeSeatPlan(
subscription,
organisationClaim,
totalMemberCountWithInvites,
);
}
await prisma.organisationMemberInvite.createMany({
data: organisationMemberInvites,

View File

@ -5,32 +5,45 @@ import { prisma } from '@documenso/prisma';
import { ORGANISATION_INTERNAL_GROUPS } from '../../constants/organisations';
import { AppErrorCode } from '../../errors/app-error';
import { AppError } from '../../errors/app-error';
import { alphaid } from '../../universal/id';
import { alphaid, generatePrefixedId } from '../../universal/id';
import { generateDefaultOrganisationSettings } from '../../utils/organisations';
import { generateDefaultOrganisationClaims } from '../../utils/organisations-claims';
import { createTeam } from '../team/create-team';
type CreateOrganisationOptions = {
userId: number;
name: string;
url: string;
url?: string;
customerId?: string;
};
export const createOrganisation = async ({ name, url, userId }: CreateOrganisationOptions) => {
export const createOrganisation = async ({
name,
url,
userId,
customerId,
}: CreateOrganisationOptions) => {
return await prisma.$transaction(async (tx) => {
const organisationSetting = await tx.organisationGlobalSettings.create({
data: generateDefaultOrganisationSettings(),
});
const organisationClaim = await tx.organisationClaim.create({
data: generateDefaultOrganisationClaims(),
});
const organisation = await tx.organisation
.create({
data: {
name,
url, // Todo: orgs constraint this
url: url || generatePrefixedId('org'),
ownerUserId: userId,
organisationGlobalSettingsId: organisationSetting.id,
organisationClaimId: organisationClaim.id,
groups: {
create: ORGANISATION_INTERNAL_GROUPS,
},
customerId,
},
include: {
groups: true,
@ -86,7 +99,7 @@ export const createPersonalOrganisation = async ({
const organisation = await createOrganisation({
name: 'Personal Organisation',
userId,
url: orgUrl || `org_${alphaid(8)}`,
url: orgUrl,
}).catch((err) => {
console.error(err);
@ -94,7 +107,7 @@ export const createPersonalOrganisation = async ({
throw err;
}
// Todo: (orgs) Add logging.
// Todo: (LOGS)
});
if (organisation) {
@ -106,7 +119,8 @@ export const createPersonalOrganisation = async ({
inheritMembers: true,
}).catch((err) => {
console.error(err);
// Todo: (orgs) Add logging.
// Todo: (LOGS)
});
}

View File

@ -0,0 +1,39 @@
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
export const getOrganisationClaim = async ({ organisationId }: { organisationId: string }) => {
const organisationClaim = await prisma.organisationClaim.findFirst({
where: {
organisation: {
id: organisationId,
},
},
});
if (!organisationClaim) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
return organisationClaim;
};
export const getOrganisationClaimByTeamId = async ({ teamId }: { teamId: number }) => {
const organisationClaim = await prisma.organisationClaim.findFirst({
where: {
organisation: {
teams: {
some: {
id: teamId,
},
},
},
},
});
if (!organisationClaim) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
return organisationClaim;
};