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

@ -1,54 +0,0 @@
import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session';
import { getTeamPrices } from '@documenso/ee/server-only/stripe/get-team-prices';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
export type CreateTeamPendingCheckoutSession = {
userId: number;
pendingTeamId: number;
interval: 'monthly' | 'yearly';
};
export const createTeamPendingCheckoutSession = async ({
userId,
pendingTeamId,
interval,
}: CreateTeamPendingCheckoutSession) => {
const teamPendingCreation = await prisma.teamPending.findFirstOrThrow({
where: {
id: pendingTeamId,
ownerUserId: userId,
},
include: {
owner: true,
},
});
const prices = await getTeamPrices();
const priceId = prices[interval].priceId;
try {
const stripeCheckoutSession = await getCheckoutSession({
customerId: teamPendingCreation.customerId,
priceId,
returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/settings/teams`,
subscriptionMetadata: {
pendingTeamId: pendingTeamId.toString(),
},
});
if (!stripeCheckoutSession) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR);
}
return stripeCheckoutSession;
} catch (e) {
console.error(e);
// Absorb all the errors incase Stripe throws something sensitive.
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Something went wrong.',
});
}
};

View File

@ -1,19 +1,7 @@
import {
OrganisationGroupType,
OrganisationMemberRole,
Prisma,
TeamMemberRole,
} from '@prisma/client';
import type Stripe from 'stripe';
import { OrganisationGroupType, OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { createOrganisationCustomer } from '@documenso/ee/server-only/stripe/create-team-customer';
import { getTeamRelatedPrices } from '@documenso/ee/server-only/stripe/get-team-related-prices';
import { mapStripeSubscriptionToPrismaUpsertAction } from '@documenso/ee/server-only/stripe/webhook/on-subscription-updated';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
import { prisma } from '@documenso/prisma';
import {
@ -23,7 +11,6 @@ import {
import { TEAM_INTERNAL_GROUPS } from '../../constants/teams';
import { buildOrganisationWhereQuery } from '../../utils/organisations';
import { generateDefaultTeamSettings } from '../../utils/teams';
import { stripe } from '../stripe';
export type CreateTeamOptions = {
/**
@ -41,7 +28,7 @@ export type CreateTeamOptions = {
*
* Used as the URL path, example: https://documenso.com/t/{teamUrl}/settings
*/
teamUrl: string;
teamUrl: string; // Todo: orgs make unique
/**
* ID of the organisation the team belongs to.
@ -62,28 +49,13 @@ export type CreateTeamOptions = {
}[];
};
export const ZCreateTeamResponseSchema = z.union([
z.object({
paymentRequired: z.literal(false),
}),
z.object({
paymentRequired: z.literal(true),
pendingTeamId: z.number(),
}),
]);
export type TCreateTeamResponse = z.infer<typeof ZCreateTeamResponseSchema>;
/**
* Create a team or pending team depending on the user's subscription or application's billing settings.
*/
export const createTeam = async ({
userId,
teamName,
teamUrl,
organisationId,
inheritMembers,
}: CreateTeamOptions): Promise<TCreateTeamResponse> => {
}: CreateTeamOptions) => {
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery(
organisationId,
@ -91,8 +63,9 @@ export const createTeam = async ({
ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
),
include: {
groups: true, // Todo: (orgs)
subscriptions: true,
groups: true,
subscription: true,
organisationClaim: true,
owner: {
select: {
id: true,
@ -109,6 +82,21 @@ export const createTeam = async ({
});
}
// Validate they have enough team slots. 0 means they can create unlimited teams.
if (organisation.organisationClaim.teamCount !== 0) {
const teamCount = await prisma.team.count({
where: {
organisationId,
},
});
if (teamCount >= organisation.organisationClaim.teamCount) {
throw new AppError(AppErrorCode.LIMIT_EXCEEDED, {
message: 'You have reached the maximum number of teams for your plan.',
});
}
}
// Inherit internal organisation groups to the team.
// Organisation Admins/Mangers get assigned as team admins, members get assigned as team members.
const internalOrganisationGroups = organisation.groups
@ -141,254 +129,46 @@ export const createTeam = async ({
.exhaustive(),
);
console.log({
internalOrganisationGroups,
});
if (Date.now() > 0) {
await prisma.$transaction(async (tx) => {
const teamSettings = await tx.teamGlobalSettings.create({
data: generateDefaultTeamSettings(),
});
const team = await tx.team.create({
data: {
name: teamName,
url: teamUrl,
organisationId,
teamGlobalSettingsId: teamSettings.id,
teamGroups: {
createMany: {
// Attach the internal organisation groups to the team.
data: internalOrganisationGroups,
},
},
},
include: {
teamGroups: true,
},
});
// Create the internal team groups.
await Promise.all(
TEAM_INTERNAL_GROUPS.map(async (teamGroup) =>
tx.organisationGroup.create({
data: {
type: teamGroup.type,
organisationRole: LOWEST_ORGANISATION_ROLE,
organisationId,
teamGroups: {
create: {
teamId: team.id,
teamRole: teamGroup.teamRole,
},
},
},
}),
),
);
});
return {
paymentRequired: false,
};
}
if (Date.now() > 0) {
throw new Error('Todo: Orgs');
}
let isPaymentRequired = IS_BILLING_ENABLED();
let customerId: string | null = null;
if (IS_BILLING_ENABLED()) {
const teamRelatedPriceIds = await getTeamRelatedPrices().then((prices) =>
prices.map((price) => price.id),
);
isPaymentRequired = !subscriptionsContainsActivePlan(
organisation.subscriptions,
teamRelatedPriceIds, // Todo: (orgs)
);
customerId = await createOrganisationCustomer({
name: organisation.owner.name ?? teamName,
email: organisation.owner.email,
}).then((customer) => customer.id);
await prisma.organisation.update({
where: {
id: organisationId,
},
data: {
customerId,
},
});
}
try {
// Create the team directly if no payment is required.
if (!isPaymentRequired) {
await prisma.team.create({
data: {
name: teamName,
url: teamUrl,
organisationId,
members: {
create: [
{
userId,
role: TeamMemberRole.ADMIN, // Todo: (orgs)
},
],
},
teamGlobalSettings: {
create: {},
},
},
});
return {
paymentRequired: false,
};
}
// Create a pending team if payment is required.
const pendingTeam = await prisma.$transaction(async (tx) => {
const existingTeamWithUrl = await tx.team.findUnique({
where: {
url: teamUrl,
},
});
const existingUserProfileWithUrl = await tx.user.findUnique({
where: {
url: teamUrl,
},
select: {
id: true,
},
});
if (existingUserProfileWithUrl) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
message: 'URL already taken.',
});
}
if (existingTeamWithUrl) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
message: 'Team URL already exists.',
});
}
if (!customerId) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Missing customer ID for pending teams.',
});
}
return await tx.teamPending.create({
data: {
name: teamName,
url: teamUrl,
ownerUserId: user.id,
customerId,
},
});
});
return {
paymentRequired: true,
pendingTeamId: pendingTeam.id,
};
} catch (err) {
console.error(err);
if (!(err instanceof Prisma.PrismaClientKnownRequestError)) {
throw err;
}
const target = z.array(z.string()).safeParse(err.meta?.target);
if (err.code === 'P2002' && target.success && target.data.includes('url')) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
message: 'Team URL already exists.',
});
}
throw err;
}
};
export type CreateTeamFromPendingTeamOptions = {
pendingTeamId: number;
subscription: Stripe.Subscription;
};
export const createTeamFromPendingTeam = async ({
pendingTeamId,
subscription,
}: CreateTeamFromPendingTeamOptions) => {
const createdTeam = await prisma.$transaction(async (tx) => {
const pendingTeam = await tx.teamPending.findUniqueOrThrow({
where: {
id: pendingTeamId,
},
});
await tx.teamPending.delete({
where: {
id: pendingTeamId,
},
await prisma.$transaction(async (tx) => {
const teamSettings = await tx.teamGlobalSettings.create({
data: generateDefaultTeamSettings(),
});
const team = await tx.team.create({
data: {
name: pendingTeam.name,
url: pendingTeam.url,
ownerUserId: pendingTeam.ownerUserId,
customerId: pendingTeam.customerId,
members: {
create: [
{
userId: pendingTeam.ownerUserId,
role: TeamMemberRole.ADMIN,
},
],
name: teamName,
url: teamUrl,
organisationId,
teamGlobalSettingsId: teamSettings.id,
teamGroups: {
createMany: {
// Attach the internal organisation groups to the team.
data: internalOrganisationGroups,
},
},
},
});
await tx.teamGlobalSettings.upsert({
where: {
teamId: team.id,
},
update: {},
create: {
teamId: team.id,
include: {
teamGroups: true,
},
});
await tx.subscription.upsert(
mapStripeSubscriptionToPrismaUpsertAction(subscription, undefined, team.id),
// Create the internal team groups.
await Promise.all(
TEAM_INTERNAL_GROUPS.map(async (teamGroup) =>
tx.organisationGroup.create({
data: {
type: teamGroup.type,
organisationRole: LOWEST_ORGANISATION_ROLE,
organisationId,
teamGroups: {
create: {
teamId: team.id,
teamRole: teamGroup.teamRole,
},
},
},
}),
),
);
return team;
});
// Attach the team ID to the subscription metadata for sanity reasons.
await stripe.subscriptions
.update(subscription.id, {
metadata: {
teamId: createdTeam.id.toString(),
},
})
.catch((e) => {
console.error(e);
// Non-critical error, but we want to log it so we can rectify it.
// Todo: Teams - Alert us.
});
return createdTeam;
};

View File

@ -1,15 +0,0 @@
import { prisma } from '@documenso/prisma';
export type DeleteTeamPendingOptions = {
userId: number;
pendingTeamId: number;
};
export const deleteTeamPending = async ({ userId, pendingTeamId }: DeleteTeamPendingOptions) => {
await prisma.teamPending.delete({
where: {
id: pendingTeamId,
ownerUserId: userId,
},
});
};

View File

@ -1,8 +1,8 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import type { OrganisationGlobalSettings } from '@prisma/client';
import { OrganisationGroupType, type Team } from '@prisma/client';
import { uniqueBy } from 'remeda';
import { mailer } from '@documenso/email/mailer';
import { TeamDeleteEmailTemplate } from '@documenso/email/templates/team-delete';
@ -13,8 +13,8 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
import { jobs } from '../../jobs/client';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getTeamSettings } from './get-team-settings';
@ -28,6 +28,11 @@ export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => {
const team = await prisma.team.findFirst({
where: buildTeamWhereQuery(teamId, userId, TEAM_MEMBER_ROLE_PERMISSIONS_MAP['DELETE_TEAM']),
include: {
organisation: {
select: {
organisationGlobalSettings: true,
},
},
teamGroups: {
include: {
organisationGroup: {
@ -65,21 +70,19 @@ export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => {
teamId,
});
const membersToNotify = uniqueBy(
team.teamGroups.flatMap((group) =>
group.organisationGroup.organisationGroupMembers.map((member) => ({
id: member.organisationMember.user.id,
name: member.organisationMember.user.name || '',
email: member.organisationMember.user.email,
})),
),
(member) => member.id,
);
await prisma.$transaction(
async (tx) => {
// Todo: orgs handle any subs?
// if (team.subscription) {
// await stripe.subscriptions
// .cancel(team.subscription.planId, {
// prorate: false,
// invoice_now: true,
// })
// .catch((err) => {
// console.error(err);
// throw AppError.parseError(err);
// });
// }
await tx.team.delete({
where: {
id: teamId,
@ -96,25 +99,20 @@ export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => {
},
});
// const members = team.teamGroups.flatMap((group) =>
// group.organisationGroup.organisationMembers.map((member) => ({
// id: member.user.id,
// name: member.user.name || '',
// email: member.user.email,
// })),
// );
// await jobs.triggerJob({
// name: 'send.team-deleted.email',
// payload: {
// team: {
// name: team.name,
// url: team.url,
// teamGlobalSettings: team.teamGlobalSettings, // Todo: orgs
// },
// members,
// },
// });
await jobs.triggerJob({
name: 'send.team-deleted.email',
payload: {
team: {
name: team.name,
url: team.url,
// teamGlobalSettings: {
// ...settings,
// teamId: team.id,
// },
},
members: membersToNotify,
},
});
},
{ timeout: 30_000 },
);
@ -122,14 +120,14 @@ export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => {
type SendTeamDeleteEmailOptions = {
email: string;
team: Pick<Team, 'id' | 'url' | 'name'>;
settings: Omit<OrganisationGlobalSettings, 'id'>;
team: Pick<Team, 'url' | 'name'>;
// settings: Omit<OrganisationGlobalSettings, 'id'>;
};
export const sendTeamDeleteEmail = async ({
email,
team,
settings,
// settings,
}: SendTeamDeleteEmailOptions) => {
const template = createElement(TeamDeleteEmailTemplate, {
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
@ -137,9 +135,12 @@ export const sendTeamDeleteEmail = async ({
teamUrl: team.url,
});
const branding = teamGlobalSettingsToBranding(settings, team.id);
// This is never actually passed on so commenting it out.
// const branding = teamGlobalSettingsToBranding(settings, team.id);
// const lang = settings.documentLanguage;
const lang = settings.documentLanguage;
const branding = undefined;
const lang = undefined;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),

View File

@ -1,69 +0,0 @@
import type { Team } from '@prisma/client';
import { Prisma } from '@prisma/client';
import type { z } from 'zod';
import { prisma } from '@documenso/prisma';
import { TeamPendingSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamPendingSchema';
import { type FindResultResponse, ZFindResultResponse } from '../../types/search-params';
export interface FindTeamsPendingOptions {
userId: number;
query?: string;
page?: number;
perPage?: number;
orderBy?: {
column: keyof Team;
direction: 'asc' | 'desc';
};
}
export const ZFindTeamsPendingResponseSchema = ZFindResultResponse.extend({
data: TeamPendingSchema.array(),
});
export type TFindTeamsPendingResponse = z.infer<typeof ZFindTeamsPendingResponseSchema>;
export const findTeamsPending = async ({
userId,
query,
page = 1,
perPage = 10,
orderBy,
}: FindTeamsPendingOptions): Promise<TFindTeamsPendingResponse> => {
const orderByColumn = orderBy?.column ?? 'name';
const orderByDirection = orderBy?.direction ?? 'desc';
const whereClause: Prisma.TeamPendingWhereInput = {
ownerUserId: userId,
};
if (query && query.length > 0) {
whereClause.name = {
contains: query,
mode: Prisma.QueryMode.insensitive,
};
}
const [data, count] = await Promise.all([
prisma.teamPending.findMany({
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
[orderByColumn]: orderByDirection,
},
}),
prisma.teamPending.count({
where: whereClause,
}),
]);
return {
data,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultResponse<typeof data>;
};

View File

@ -19,7 +19,7 @@ export type UpdateTeamOptions = {
export const updateTeam = async ({ userId, teamId, data }: UpdateTeamOptions): Promise<void> => {
try {
await prisma.$transaction(async (tx) => {
const foundPendingTeamWithUrl = await tx.teamPending.findFirst({
const foundTeamWithUrl = await tx.team.findFirst({
where: {
url: data.url,
},
@ -31,21 +31,19 @@ export const updateTeam = async ({ userId, teamId, data }: UpdateTeamOptions): P
},
});
if (foundPendingTeamWithUrl || foundOrganisationWithUrl) {
if (foundTeamWithUrl || foundOrganisationWithUrl) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
message: 'Team URL already exists.',
});
}
const team = await tx.team.update({
return await tx.team.update({
where: buildTeamWhereQuery(teamId, userId, TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM']),
data: {
url: data.url,
name: data.name,
},
});
return team;
});
} catch (err) {
console.error(err);