mirror of
https://github.com/documenso/documenso.git
synced 2025-11-22 12:41:36 +10:00
feat: billing
This commit is contained in:
@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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,
|
||||
// },
|
||||
// });
|
||||
});
|
||||
};
|
||||
|
||||
@ -25,6 +25,7 @@ export const getOrganisationMemberInvitesRoute = authenticatedProcedure
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
url: true,
|
||||
avatarImageId: true,
|
||||
},
|
||||
},
|
||||
|
||||
@ -19,6 +19,7 @@ export const ZGetOrganisationMemberInvitesResponseSchema = OrganisationMemberInv
|
||||
organisation: OrganisationSchema.pick({
|
||||
id: true,
|
||||
name: true,
|
||||
url: true,
|
||||
avatarImageId: true,
|
||||
}),
|
||||
})
|
||||
|
||||
@ -29,6 +29,7 @@ export const getOrganisationSession = async ({
|
||||
},
|
||||
},
|
||||
include: {
|
||||
organisationClaim: true,
|
||||
groups: {
|
||||
where: {
|
||||
organisationGroupMembers: {
|
||||
|
||||
@ -44,6 +44,8 @@ export const getOrganisation = async ({
|
||||
},
|
||||
include: {
|
||||
organisationGlobalSettings: true,
|
||||
subscription: true,
|
||||
organisationClaim: true,
|
||||
teams: {
|
||||
where: {
|
||||
teamGroups: {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZLeaveOrganisationRequestSchema = z.object({
|
||||
organisationId: z.string(),
|
||||
});
|
||||
|
||||
export const ZLeaveOrganisationResponseSchema = z.void();
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@ -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(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user