feat: add organisations (#1820)

This commit is contained in:
David Nguyen
2025-06-10 11:49:52 +10:00
committed by GitHub
parent 0b37f19641
commit e6dc237ad2
631 changed files with 37616 additions and 25695 deletions

View File

@ -0,0 +1,81 @@
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 { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import { ZCreateSubscriptionRequestSchema } from './create-subscription.types';
export const createSubscriptionRoute = authenticatedProcedure
.input(ZCreateSubscriptionRequestSchema)
.mutation(async ({ ctx, input }) => {
const { organisationId, priceId } = input;
const userId = ctx.user.id;
if (!IS_BILLING_ENABLED()) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Billing is not enabled',
});
}
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
userId,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_BILLING'],
}),
include: {
subscription: true,
owner: {
select: {
email: true,
name: true,
},
},
},
});
if (!organisation) {
throw new AppError(AppErrorCode.UNAUTHORIZED);
}
let customerId = organisation.customerId;
if (!customerId) {
const customer = await createCustomer({
name: organisation.name,
email: organisation.owner.email,
});
customerId = customer.id;
await prisma.organisation.update({
where: {
id: organisationId,
},
data: {
customerId: customer.id,
},
});
}
const redirectUrl = await createCheckoutSession({
customerId,
priceId,
returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/o/${organisation.url}/settings/billing`,
});
if (!redirectUrl) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Failed to create checkout session',
});
}
return {
redirectUrl,
};
});

View File

@ -0,0 +1,6 @@
import { z } from 'zod';
export const ZCreateSubscriptionRequestSchema = z.object({
organisationId: z.string().describe('The organisation to create the subscription for'),
priceId: z.string().describe('The price to create the subscription for'),
});

View File

@ -0,0 +1,58 @@
import { getInvoices } from '@documenso/ee/server-only/stripe/get-invoices';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import { ZGetInvoicesRequestSchema } from './get-invoices.types';
export const getInvoicesRoute = authenticatedProcedure
.input(ZGetInvoicesRequestSchema)
.query(async ({ ctx, input }) => {
const { organisationId } = input;
const userId = ctx.user.id;
if (!IS_BILLING_ENABLED()) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Billing is not enabled',
});
}
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
userId,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
}),
include: {
subscription: true,
},
});
if (!organisation) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You are not authorized to access this organisation',
});
}
if (!organisation.customerId) {
return null;
}
const invoices = await getInvoices({
customerId: organisation.customerId,
});
return invoices.data.map((invoice) => ({
id: invoice.id,
status: invoice.status,
created: invoice.created,
currency: invoice.currency,
total: invoice.total,
hosted_invoice_url: invoice.hosted_invoice_url,
invoice_pdf: invoice.invoice_pdf,
}));
});

View File

@ -0,0 +1,5 @@
import { z } from 'zod';
export const ZGetInvoicesRequestSchema = z.object({
organisationId: z.string().describe('The organisation to get the invoices for'),
});

View File

@ -0,0 +1,31 @@
import { getInternalClaimPlans } from '@documenso/ee/server-only/stripe/get-internal-claim-plans';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
export const getPlansRoute = authenticatedProcedure.query(async ({ ctx }) => {
const userId = ctx.user.id;
const plans = await getInternalClaimPlans();
let canCreateFreeOrganisation = false;
if (IS_BILLING_ENABLED()) {
const numberOfFreeOrganisations = await prisma.organisation.count({
where: {
ownerUserId: userId,
subscription: {
is: null,
},
},
});
canCreateFreeOrganisation = numberOfFreeOrganisations === 0;
}
return {
plans,
canCreateFreeOrganisation,
};
});

View File

@ -0,0 +1,34 @@
import { getInternalClaimPlans } from '@documenso/ee/server-only/stripe/get-internal-claim-plans';
import { getSubscription } from '@documenso/ee/server-only/stripe/get-subscription';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { authenticatedProcedure } from '../trpc';
import { ZGetSubscriptionRequestSchema } from './get-subscription.types';
export const getSubscriptionRoute = authenticatedProcedure
.input(ZGetSubscriptionRequestSchema)
.query(async ({ ctx, input }) => {
const { organisationId } = input;
const userId = ctx.user.id;
if (!IS_BILLING_ENABLED()) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Billing is not enabled',
});
}
const [subscription, plans] = await Promise.all([
getSubscription({
organisationId,
userId,
}),
getInternalClaimPlans(),
]);
return {
subscription,
plans,
};
});

View File

@ -0,0 +1,5 @@
import { z } from 'zod';
export const ZGetSubscriptionRequestSchema = z.object({
organisationId: z.string().describe('The organisation to get the subscription for'),
});

View File

@ -0,0 +1,98 @@
import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer';
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
import { IS_BILLING_ENABLED, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import { ZManageSubscriptionRequestSchema } from './manage-subscription.types';
export const manageSubscriptionRoute = authenticatedProcedure
.input(ZManageSubscriptionRequestSchema)
.mutation(async ({ ctx, input }) => {
const { organisationId } = input;
const userId = ctx.user.id;
if (!IS_BILLING_ENABLED()) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Billing is not enabled',
});
}
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
userId,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_BILLING'],
}),
include: {
subscription: true,
owner: {
select: {
email: true,
name: true,
},
},
},
});
if (!organisation) {
throw new AppError(AppErrorCode.UNAUTHORIZED);
}
let customerId = organisation.customerId;
// If for some reason customer ID is missing in the organisation but
// exists in the subscription take it from the subscription.
if (!customerId && organisation.subscription?.customerId) {
customerId = organisation.subscription.customerId;
await prisma.organisation
.update({
where: {
id: organisationId,
},
data: {
customerId,
},
})
.catch((err) => {
// Todo: Logger
console.error('Critical error, potential conflicting data');
console.error(err.message);
throw err;
});
}
// If the customer ID is still missing create a new customer.
if (!customerId) {
const customer = await createCustomer({
name: organisation.name,
email: organisation.owner.email,
});
customerId = customer.id;
await prisma.organisation.update({
where: {
id: organisationId,
},
data: {
customerId: customer.id,
},
});
}
const redirectUrl = await getPortalSession({
customerId,
returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/o/${organisation.url}/settings/billing`,
});
return {
redirectUrl,
};
});

View File

@ -0,0 +1,5 @@
import { z } from 'zod';
export const ZManageSubscriptionRequestSchema = z.object({
organisationId: z.string().describe('The organisation to manage the subscription for'),
});

View File

@ -0,0 +1,20 @@
import { router } from '../trpc';
import { createSubscriptionRoute } from './create-subscription';
import { getInvoicesRoute } from './get-invoices';
import { getPlansRoute } from './get-plans';
import { getSubscriptionRoute } from './get-subscription';
import { manageSubscriptionRoute } from './manage-subscription';
export const billingRouter = router({
plans: {
get: getPlansRoute,
},
subscription: {
get: getSubscriptionRoute,
create: createSubscriptionRoute,
manage: manageSubscriptionRoute,
},
invoices: {
get: getInvoicesRoute,
},
});