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,4 +1,10 @@
import { createCheckoutSession } from '@documenso/ee/server-only/stripe/create-checkout-session';
import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer';
import { IS_BILLING_ENABLED, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { createOrganisation } from '@documenso/lib/server-only/organisation/create-organisation';
import { generateStripeOrganisationCreateMetadata } from '@documenso/lib/utils/billing';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
@ -11,12 +17,60 @@ export const createOrganisationRoute = authenticatedProcedure
.input(ZCreateOrganisationRequestSchema)
.output(ZCreateOrganisationResponseSchema)
.mutation(async ({ input, ctx }) => {
const { name, url } = input;
const { name, priceId } = input;
const { user } = ctx;
// Todo: orgs
// Todo: Check if user has reached limit.
if (IS_BILLING_ENABLED() && !priceId) {
const userOrganisations = await prisma.organisation.findMany({
where: {
ownerUserId: user.id,
subscription: {
is: null,
},
},
});
if (userOrganisations.length >= 1) {
throw new AppError(AppErrorCode.LIMIT_EXCEEDED, {
message: 'You have reached the maximum number of free organisations.',
});
}
}
// Create checkout session for payment.
if (IS_BILLING_ENABLED() && priceId) {
// if (!claimId) {
// throw new AppError(AppErrorCode.INVALID_REQUEST, {
// message: 'Claim ID is required',
// });
// }
const customer = await createCustomer({
email: user.email,
name: user.name || user.email,
});
const checkoutUrl = await createCheckoutSession({
priceId,
customerId: customer.id,
returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/settings/organisations`,
subscriptionMetadata: generateStripeOrganisationCreateMetadata(name, user.id),
});
return {
paymentRequired: true,
checkoutUrl,
};
}
await createOrganisation({
userId: user.id,
name,
url,
});
return {
paymentRequired: false,
};
});

View File

@ -1,7 +1,5 @@
import { z } from 'zod';
import { ZTeamUrlSchema } from '../team-router/schema';
// export const createOrganisationMeta: TrpcOpenApiMeta = {
// openapi: {
// method: 'POST',
@ -19,7 +17,17 @@ export const ZOrganisationNameSchema = z
export const ZCreateOrganisationRequestSchema = z.object({
name: ZOrganisationNameSchema,
url: ZTeamUrlSchema,
priceId: z.string().optional(),
});
export const ZCreateOrganisationResponseSchema = z.void();
export const ZCreateOrganisationResponseSchema = z.union([
z.object({
paymentRequired: z.literal(false),
}),
z.object({
paymentRequired: z.literal(true),
checkoutUrl: z.string(),
}),
]);
export type TCreateOrganisationResponse = z.infer<typeof ZCreateOrganisationResponseSchema>;

View File

@ -1,5 +1,7 @@
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 { validateIfSubscriptionIsRequired } from '@documenso/lib/utils/billing';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
@ -23,12 +25,42 @@ export const deleteOrganisationMemberInvitesRoute = authenticatedProcedure
userId,
ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
),
include: {
organisationClaim: true,
subscription: true,
members: {
select: {
id: true,
},
},
invites: {
select: {
id: true,
},
},
},
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
const { organisationClaim } = organisation;
const subscription = validateIfSubscriptionIsRequired(organisation.subscription);
const numberOfCurrentMembers = organisation.members.length;
const numberOfCurrentInvites = organisation.invites.length;
const totalMemberCountWithInvites = numberOfCurrentMembers + numberOfCurrentInvites - 1;
if (subscription) {
await syncMemberCountWithStripeSeatPlan(
subscription,
organisationClaim,
totalMemberCountWithInvites,
);
}
await prisma.organisationMemberInvite.deleteMany({
where: {
id: {

View File

@ -1,7 +1,10 @@
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 { validateIfSubscriptionIsRequired } from '@documenso/lib/utils/billing';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { OrganisationMemberInviteStatus } from '@documenso/prisma/client';
import { authenticatedProcedure } from '../trpc';
import {
@ -30,76 +33,73 @@ type DeleteOrganisationMembersProps = {
organisationMemberIds: string[];
};
/**
* Deletes multiple organisation members.
*
* This logic is also used to leave a team (hence strange logic).
*/
export const deleteOrganisationMembers = async ({
userId,
organisationId,
organisationMemberIds,
}: DeleteOrganisationMembersProps) => {
const membersToDelete = await prisma.organisationMember.findMany({
where: {
id: {
in: organisationMemberIds,
},
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery(
organisationId,
userId,
ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
),
include: {
subscription: true,
organisationClaim: true,
members: {
select: {
id: true,
userId: true,
},
},
invites: {
where: {
status: OrganisationMemberInviteStatus.PENDING,
},
select: {
id: true,
},
},
},
});
// Prevent the user from deleting other users if they do not have permission.
if (membersToDelete.some((member) => member.userId !== userId)) {
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery(
organisationId,
userId,
ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
),
});
if (!organisation) {
throw new AppError(AppErrorCode.UNAUTHORIZED);
}
if (!organisation) {
throw new AppError(AppErrorCode.UNAUTHORIZED);
}
// Todo: Orgs - Handle seats.
await prisma.$transaction(
async (tx) => {
await tx.organisationMember.deleteMany({
where: {
id: {
in: organisationMemberIds,
},
organisationId,
},
});
const { organisationClaim } = organisation;
// Todo: orgs handle removing groups
// if (IS_BILLING_ENABLED() && team.subscription) {
// const numberOfSeats = await tx.teamMember.count({
// where: {
// teamId,
// },
// });
// await updateSubscriptionItemQuantity({
// priceId: team.subscription.priceId,
// subscriptionId: team.subscription.planId,
// quantity: numberOfSeats,
// });
// }
// await jobs.triggerJob({
// name: 'send.team-member-left.email',
// payload: {
// teamId,
// memberUserId: leavingUser.id,
// },
// });
},
{ timeout: 30_000 },
const membersToDelete = organisation.members.filter((member) =>
organisationMemberIds.includes(member.id),
);
const subscription = validateIfSubscriptionIsRequired(organisation.subscription);
const inviteCount = organisation.invites.length;
const newMemberCount = organisation.members.length + inviteCount - membersToDelete.length;
if (subscription) {
await syncMemberCountWithStripeSeatPlan(subscription, organisationClaim, newMemberCount);
}
await prisma.$transaction(async (tx) => {
await tx.organisationMember.deleteMany({
where: {
id: {
in: organisationMemberIds,
},
organisationId,
},
});
// Todo: orgs
// await jobs.triggerJob({
// name: 'send.team-member-left.email',
// payload: {
// teamId,
// memberUserId: leavingUser.id,
// },
// });
});
};

View File

@ -25,6 +25,7 @@ export const getOrganisationMemberInvitesRoute = authenticatedProcedure
select: {
id: true,
name: true,
url: true,
avatarImageId: true,
},
},

View File

@ -19,6 +19,7 @@ export const ZGetOrganisationMemberInvitesResponseSchema = OrganisationMemberInv
organisation: OrganisationSchema.pick({
id: true,
name: true,
url: true,
avatarImageId: true,
}),
})

View File

@ -29,6 +29,7 @@ export const getOrganisationSession = async ({
},
},
include: {
organisationClaim: true,
groups: {
where: {
organisationGroupMembers: {

View File

@ -44,6 +44,8 @@ export const getOrganisation = async ({
},
include: {
organisationGlobalSettings: true,
subscription: true,
organisationClaim: true,
teams: {
where: {
teamGroups: {

View File

@ -1,7 +1,9 @@
import { z } from 'zod';
import { ZOrganisationSchema } from '@documenso/lib/types/organisation';
import OrganisationClaimSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationClaimSchema';
import OrganisationGlobalSettingsSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationGlobalSettingsSchema';
import SubscriptionSchema from '@documenso/prisma/generated/zod/modelSchema/SubscriptionSchema';
import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
// export const getOrganisationMeta: TrpcOpenApiMeta = {
@ -20,6 +22,8 @@ export const ZGetOrganisationRequestSchema = z.object({
export const ZGetOrganisationResponseSchema = ZOrganisationSchema.extend({
organisationGlobalSettings: OrganisationGlobalSettingsSchema,
organisationClaim: OrganisationClaimSchema,
subscription: SubscriptionSchema.nullable(),
teams: z.array(
TeamSchema.pick({
id: true,

View File

@ -0,0 +1,75 @@
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 { validateIfSubscriptionIsRequired } from '@documenso/lib/utils/billing';
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';
export const leaveOrganisationRoute = authenticatedProcedure
.input(ZLeaveOrganisationRequestSchema)
.output(ZLeaveOrganisationResponseSchema)
.mutation(async ({ ctx, input }) => {
const { organisationId } = input;
const userId = ctx.user.id;
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery(organisationId, userId),
include: {
organisationClaim: true,
subscription: true,
invites: {
where: {
status: OrganisationMemberInviteStatus.PENDING,
},
select: {
id: true,
},
},
members: {
select: {
id: true,
},
},
},
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
const { organisationClaim } = organisation;
const subscription = validateIfSubscriptionIsRequired(organisation.subscription);
const inviteCount = organisation.invites.length;
const newMemberCount = organisation.members.length + inviteCount - 1;
if (subscription) {
await syncMemberCountWithStripeSeatPlan(subscription, organisationClaim, newMemberCount);
}
await prisma.organisationMember.delete({
where: {
userId_organisationId: {
userId,
organisationId,
},
},
});
await jobs.triggerJob({
name: 'send.organisation-member-left.email',
payload: {
organisationId: organisation.id,
memberUserId: userId,
},
});
});

View File

@ -0,0 +1,7 @@
import { z } from 'zod';
export const ZLeaveOrganisationRequestSchema = z.object({
organisationId: z.string(),
});
export const ZLeaveOrganisationResponseSchema = z.void();

View File

@ -16,6 +16,7 @@ import { getOrganisationRoute } from './get-organisation';
import { getOrganisationMemberInvitesRoute } from './get-organisation-member-invites';
import { getOrganisationSessionRoute } from './get-organisation-session';
import { getOrganisationsRoute } from './get-organisations';
import { leaveOrganisationRoute } from './leave-organisation';
import { resendOrganisationMemberInviteRoute } from './resend-organisation-member-invite';
import { updateOrganisationRoute } from './update-organisation';
import { updateOrganisationGroupRoute } from './update-organisation-group';
@ -28,6 +29,7 @@ export const organisationRouter = router({
create: createOrganisationRoute,
update: updateOrganisationRoute,
delete: deleteOrganisationRoute,
leave: leaveOrganisationRoute,
member: {
find: findOrganisationMembersRoute,
update: updateOrganisationMemberRoute,

View File

@ -39,7 +39,7 @@ export const updateOrganisationRoute = authenticatedProcedure
},
data: {
name: data.name,
url: data.url, // Todo: (orgs) check url unique
url: data.url, // Todo: orgs check url unique
},
});
});

View File

@ -1,5 +1,6 @@
import { z } from 'zod';
import { ZTeamUrlSchema } from '../team-router/schema';
import { ZCreateOrganisationRequestSchema } from './create-organisation.types';
// export const updateOrganisationMeta: TrpcOpenApiMeta = {
@ -15,7 +16,8 @@ import { ZCreateOrganisationRequestSchema } from './create-organisation.types';
export const ZUpdateOrganisationRequestSchema = z.object({
data: ZCreateOrganisationRequestSchema.pick({
name: true,
url: true,
}).extend({
url: ZTeamUrlSchema,
}),
organisationId: z.string(),
});