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

@ -255,7 +255,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
};
}
const { remaining } = await getServerLimits({ email: user.email, teamId: team?.id });
const { remaining } = await getServerLimits({ teamId: team.id });
if (remaining.documents <= 0) {
return {
@ -464,7 +464,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
createDocumentFromTemplate: authenticatedMiddleware(async (args, user, team, { metadata }) => {
const { body, params } = args;
const { remaining } = await getServerLimits({ email: user.email, teamId: team?.id });
const { remaining } = await getServerLimits({ teamId: team?.id });
if (remaining.documents <= 0) {
return {
@ -562,7 +562,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
generateDocumentFromTemplate: authenticatedMiddleware(async (args, user, team, { metadata }) => {
const { body, params } = args;
const { remaining } = await getServerLimits({ email: user.email, teamId: team?.id });
const { remaining } = await getServerLimits({ teamId: team?.id });
if (remaining.documents <= 0) {
return {

View File

@ -48,7 +48,7 @@ test('[TEAMS]: accept team invitation without account', async ({ page }) => {
teamId: team.id,
});
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/team/invite/${teamInvite.token}`);
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/organisation/invite/${teamInvite.token}`);
await expect(page.getByRole('heading')).toContainText('Team invitation');
});
@ -61,7 +61,7 @@ test('[TEAMS]: accept team invitation with account', async ({ page }) => {
teamId: team.id,
});
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/team/invite/${teamInvite.token}`);
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/organisation/invite/${teamInvite.token}`);
await expect(page.getByRole('heading')).toContainText('Invitation accepted!');
});

View File

@ -22,7 +22,6 @@ export type SessionUser = Pick<
| 'twoFactorEnabled'
| 'roles'
| 'signature'
| 'customerId'
>;
export type SessionValidationResult =
@ -98,7 +97,6 @@ export const validateSessionToken = async (token: string): Promise<SessionValida
twoFactorEnabled: true,
roles: true,
signature: true,
customerId: true,
},
},
},

View File

@ -5,7 +5,6 @@ import { Hono } from 'hono';
import { DateTime } from 'luxon';
import { z } from 'zod';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { EMAIL_VERIFICATION_STATE } from '@documenso/lib/constants/email';
import { AppError } from '@documenso/lib/errors/app-error';
import { jobsClient } from '@documenso/lib/jobs/client';
@ -21,7 +20,6 @@ import { getMostRecentVerificationTokenByUserId } from '@documenso/lib/server-on
import { resetPassword } from '@documenso/lib/server-only/user/reset-password';
import { updatePassword } from '@documenso/lib/server-only/user/update-password';
import { verifyEmail } from '@documenso/lib/server-only/user/verify-email';
import { alphaid } from '@documenso/lib/universal/id';
import { env } from '@documenso/lib/utils/env';
import { prisma } from '@documenso/prisma';
@ -149,17 +147,9 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
});
}
const { name, email, password, signature, url } = c.req.valid('json');
const { name, email, password, signature } = c.req.valid('json');
if (IS_BILLING_ENABLED() && url && url.length < 6) {
throw new AppError('PREMIUM_PROFILE_URL', {
message: 'Only subscribers can have a username shorter than 6 characters',
});
}
const orgUrl = url || alphaid(12);
const user = await createUser({ name, email, password, signature, orgUrl }).catch((err) => {
const user = await createUser({ name, email, password, signature }).catch((err) => {
console.error(err);
throw err;
});

View File

@ -37,15 +37,6 @@ export const ZSignUpSchema = z.object({
email: z.string().email(),
password: ZPasswordSchema,
signature: z.string().nullish(),
url: z
.string()
.trim()
.toLowerCase()
.min(1)
.regex(/^[a-z0-9-]+$/, {
message: 'Username can only container alphanumeric characters and dashes.',
})
.optional(),
});
export type TSignUpSchema = z.infer<typeof ZSignUpSchema>;

View File

@ -6,7 +6,13 @@ export const FREE_PLAN_LIMITS: TLimitsSchema = {
directTemplates: 3,
};
export const TEAM_PLAN_LIMITS: TLimitsSchema = {
export const INACTIVE_PLAN_LIMITS: TLimitsSchema = {
documents: 0,
recipients: 0,
directTemplates: 0,
};
export const PAID_PLAN_LIMITS: TLimitsSchema = {
documents: Infinity,
recipients: Infinity,
directTemplates: Infinity,

View File

@ -17,11 +17,11 @@ export const limitsHandler = async (req: Request) => {
teamId = parseInt(rawTeamId, 10);
}
if (!teamId && rawTeamId) {
if (!teamId) {
throw new Error(ERROR_CODES.INVALID_TEAM_ID);
}
const limits = await getServerLimits({ email: user.email, teamId });
const limits = await getServerLimits({ userId: user.id, teamId });
return Response.json(limits, {
status: 200,

View File

@ -2,21 +2,25 @@ import { DocumentSource, SubscriptionStatus } from '@prisma/client';
import { DateTime } from 'luxon';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
import { prisma } from '@documenso/prisma';
import { getDocumentRelatedPrices } from '../stripe/get-document-related-prices.ts';
import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS, TEAM_PLAN_LIMITS } from './constants';
import {
FREE_PLAN_LIMITS,
INACTIVE_PLAN_LIMITS,
PAID_PLAN_LIMITS,
SELFHOSTED_PLAN_LIMITS,
} from './constants';
import { ERROR_CODES } from './errors';
import type { TLimitsResponseSchema } from './schema';
import { ZLimitsSchema } from './schema';
export type GetServerLimitsOptions = {
email: string;
teamId: number | null;
userId: number;
teamId: number;
};
export const getServerLimits = async ({
email,
userId,
teamId,
}: GetServerLimitsOptions): Promise<TLimitsResponseSchema> => {
if (!IS_BILLING_ENABLED()) {
@ -26,141 +30,89 @@ export const getServerLimits = async ({
};
}
if (!email) {
throw new Error(ERROR_CODES.UNAUTHORIZED);
}
return teamId ? handleTeamLimits({ email, teamId }) : handleUserLimits({ email });
};
type HandleUserLimitsOptions = {
email: string;
};
const handleUserLimits = async ({ email }: HandleUserLimitsOptions) => {
const user = await prisma.user.findFirst({
const organisation = await prisma.organisation.findFirst({
where: {
email,
},
include: {
subscriptions: true,
},
});
if (!user) {
throw new Error(ERROR_CODES.USER_FETCH_FAILED);
}
let quota = structuredClone(FREE_PLAN_LIMITS);
let remaining = structuredClone(FREE_PLAN_LIMITS);
const activeSubscriptions = user.subscriptions.filter(
({ status }) => status === SubscriptionStatus.ACTIVE,
);
if (activeSubscriptions.length > 0) {
const documentPlanPrices = await getDocumentRelatedPrices();
for (const subscription of activeSubscriptions) {
const price = documentPlanPrices.find((price) => price.id === subscription.priceId);
if (!price || typeof price.product === 'string' || price.product.deleted) {
continue;
}
const currentQuota = ZLimitsSchema.parse(
'metadata' in price.product ? price.product.metadata : {},
);
// Use the subscription with the highest quota.
if (currentQuota.documents > quota.documents && currentQuota.recipients > quota.recipients) {
quota = currentQuota;
remaining = structuredClone(quota);
}
}
// Assume all active subscriptions provide unlimited direct templates.
remaining.directTemplates = Infinity;
}
const [documents, directTemplates] = await Promise.all([
prisma.document.count({
where: {
userId: user.id,
teamId: null,
createdAt: {
gte: DateTime.utc().startOf('month').toJSDate(),
},
source: {
not: DocumentSource.TEMPLATE_DIRECT_LINK,
teams: {
some: {
id: teamId,
},
},
}),
prisma.template.count({
where: {
userId: user.id,
teamId: null,
directLink: {
isNot: null,
},
},
}),
]);
remaining.documents = Math.max(remaining.documents - documents, 0);
remaining.directTemplates = Math.max(remaining.directTemplates - directTemplates, 0);
return {
quota,
remaining,
};
};
type HandleTeamLimitsOptions = {
email: string;
teamId: number;
};
const handleTeamLimits = async ({ email, teamId }: HandleTeamLimitsOptions) => {
const team = await prisma.team.findFirst({
where: {
id: teamId,
members: {
some: {
user: {
email,
},
userId,
},
},
},
include: {
subscription: true,
organisationClaim: true,
},
});
if (!team) {
throw new Error('Team not found');
if (!organisation) {
throw new Error(ERROR_CODES.USER_FETCH_FAILED);
}
const { subscription } = team;
const quota = structuredClone(FREE_PLAN_LIMITS);
const remaining = structuredClone(FREE_PLAN_LIMITS);
if (subscription && subscription.status === SubscriptionStatus.INACTIVE) {
const subscription = organisation.subscription;
// Bypass all limits even if plan expired for ENTERPRISE.
if (organisation.organisationClaimId === INTERNAL_CLAIM_ID.ENTERPRISE) {
return {
quota: {
documents: 0,
recipients: 0,
directTemplates: 0,
},
remaining: {
documents: 0,
recipients: 0,
directTemplates: 0,
},
quota: PAID_PLAN_LIMITS,
remaining: PAID_PLAN_LIMITS,
};
}
// If free tier or plan does not have unlimited documents.
if (!subscription || !organisation.organisationClaim.flags.unlimitedDocuments) {
const [documents, directTemplates] = await Promise.all([
prisma.document.count({
where: {
team: {
organisationId: organisation.id,
},
createdAt: {
gte: DateTime.utc().startOf('month').toJSDate(),
},
source: {
not: DocumentSource.TEMPLATE_DIRECT_LINK,
},
},
}),
prisma.template.count({
where: {
team: {
organisationId: organisation.id,
},
directLink: {
isNot: null,
},
},
}),
]);
remaining.documents = Math.max(remaining.documents - documents, 0);
remaining.directTemplates = Math.max(remaining.directTemplates - directTemplates, 0);
return {
quota,
remaining,
};
}
// If plan expired.
if (subscription.status !== SubscriptionStatus.ACTIVE) {
return {
quota: INACTIVE_PLAN_LIMITS,
remaining: INACTIVE_PLAN_LIMITS,
};
}
return {
quota: structuredClone(TEAM_PLAN_LIMITS),
remaining: structuredClone(TEAM_PLAN_LIMITS),
quota: PAID_PLAN_LIMITS,
remaining: PAID_PLAN_LIMITS,
};
};

View File

@ -1,20 +1,23 @@
import type Stripe from 'stripe';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { stripe } from '@documenso/lib/server-only/stripe';
export type GetCheckoutSessionOptions = {
export type CreateCheckoutSessionOptions = {
customerId: string;
priceId: string;
returnUrl: string;
subscriptionMetadata?: Stripe.Metadata;
};
export const getCheckoutSession = async ({
// Todo: orgs validate priceId to ensure it's only ones we allow
export const createCheckoutSession = async ({
customerId,
priceId,
returnUrl,
subscriptionMetadata,
}: GetCheckoutSessionOptions) => {
}: CreateCheckoutSessionOptions) => {
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
@ -31,5 +34,11 @@ export const getCheckoutSession = async ({
},
});
if (!session.url) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Failed to create checkout session',
});
}
return session.url;
};

View File

@ -0,0 +1,13 @@
import { stripe } from '@documenso/lib/server-only/stripe';
type CreateCustomerOptions = {
name: string;
email: string;
};
export const createCustomer = async ({ name, email }: CreateCustomerOptions) => {
return await stripe.customers.create({
name,
email,
});
};

View File

@ -1,23 +0,0 @@
import { STRIPE_CUSTOMER_TYPE } from '@documenso/lib/constants/billing';
import { stripe } from '@documenso/lib/server-only/stripe';
type CreateOrganisationCustomerOptions = {
name: string;
email: string;
};
/**
* Create a Stripe customer for a given Organisation.
*/
export const createOrganisationCustomer = async ({
name,
email,
}: CreateOrganisationCustomerOptions) => {
return await stripe.customers.create({
name,
email,
metadata: {
type: STRIPE_CUSTOMER_TYPE.ORGANISATION,
},
});
};

View File

@ -1,22 +0,0 @@
import { stripe } from '@documenso/lib/server-only/stripe';
type DeleteCustomerPaymentMethodsOptions = {
customerId: string;
};
/**
* Delete all attached payment methods for a given customer.
*/
export const deleteCustomerPaymentMethods = async ({
customerId,
}: DeleteCustomerPaymentMethodsOptions) => {
const paymentMethods = await stripe.paymentMethods.list({
customer: customerId,
});
await Promise.all(
paymentMethods.data.map(async (paymentMethod) =>
stripe.paymentMethods.detach(paymentMethod.id),
),
);
};

View File

@ -1,13 +0,0 @@
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { getPricesByPlan } from './get-prices-by-plan';
export const getCommunityPlanPrices = async () => {
return await getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY);
};
export const getCommunityPlanPriceIds = async () => {
const prices = await getCommunityPlanPrices();
return prices.map((price) => price.id);
};

View File

@ -1,21 +1,4 @@
import type { User } from '@prisma/client';
import { STRIPE_CUSTOMER_TYPE } from '@documenso/lib/constants/billing';
import { stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
import { onSubscriptionUpdated } from './webhook/on-subscription-updated';
/**
* Get a non team Stripe customer by email.
*/
export const getStripeCustomerByEmail = async (email: string) => {
const foundStripeCustomers = await stripe.customers.list({
email,
});
return foundStripeCustomers.data.find((customer) => customer.metadata.type !== 'team') ?? null;
};
export const getStripeCustomerById = async (stripeCustomerId: string) => {
try {
@ -26,86 +9,3 @@ export const getStripeCustomerById = async (stripeCustomerId: string) => {
return null;
}
};
// Todo: (orgs)
/**
* Get a stripe customer by user.
*
* Will create a Stripe customer and update the relevant user if one does not exist.
*/
export const getStripeCustomerByUser = async (
user: Pick<User, 'id' | 'customerId' | 'email' | 'name'>,
) => {
if (user.customerId) {
const stripeCustomer = await getStripeCustomerById(user.customerId);
if (!stripeCustomer) {
throw new Error('Missing Stripe customer');
}
return {
user,
stripeCustomer,
};
}
let stripeCustomer = await getStripeCustomerByEmail(user.email);
const isSyncRequired = Boolean(stripeCustomer && !stripeCustomer.deleted);
if (!stripeCustomer) {
stripeCustomer = await stripe.customers.create({
name: user.name ?? undefined,
email: user.email,
metadata: {
userId: user.id,
type: STRIPE_CUSTOMER_TYPE.INDIVIDUAL,
},
});
}
const updatedUser = await prisma.user.update({
where: {
id: user.id,
},
data: {
customerId: stripeCustomer.id,
},
});
// Sync subscriptions if the customer already exists for back filling the DB
// and local development.
if (isSyncRequired) {
await syncStripeCustomerSubscriptions(user.id, stripeCustomer.id).catch((e) => {
console.error(e);
});
}
return {
user: updatedUser,
stripeCustomer,
};
};
export const getStripeCustomerIdByUser = async (user: User) => {
if (user.customerId !== null) {
return user.customerId;
}
return await getStripeCustomerByUser(user).then((session) => session.stripeCustomer.id);
};
const syncStripeCustomerSubscriptions = async (userId: number, stripeCustomerId: string) => {
const stripeSubscriptions = await stripe.subscriptions.list({
customer: stripeCustomerId,
});
await Promise.all(
stripeSubscriptions.data.map(async (subscription) =>
onSubscriptionUpdated({
userId,
subscription,
}),
),
);
};

View File

@ -1,15 +0,0 @@
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { getPricesByPlan } from './get-prices-by-plan';
/**
* Returns the Stripe prices of items that affect the amount of documents a user can create.
*/
export const getDocumentRelatedPrices = async () => {
return await getPricesByPlan([
STRIPE_PLAN_TYPE.REGULAR,
STRIPE_PLAN_TYPE.COMMUNITY,
STRIPE_PLAN_TYPE.PLATFORM,
STRIPE_PLAN_TYPE.ENTERPRISE,
]);
};

View File

@ -1,13 +0,0 @@
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { getPricesByPlan } from './get-prices-by-plan';
export const getEnterprisePlanPrices = async () => {
return await getPricesByPlan(STRIPE_PLAN_TYPE.ENTERPRISE);
};
export const getEnterprisePlanPriceIds = async () => {
const prices = await getEnterprisePlanPrices();
return prices.map((price) => price.id);
};

View File

@ -0,0 +1,88 @@
import { clone } from 'remeda';
import type Stripe from 'stripe';
import { stripe } from '@documenso/lib/server-only/stripe';
import {
INTERNAL_CLAIM_ID,
type InternalClaim,
internalClaims,
} from '@documenso/lib/types/subscription';
import { toHumanPrice } from '@documenso/lib/universal/stripe/to-human-price';
export type InternalClaimPlans = {
[key in INTERNAL_CLAIM_ID]: InternalClaim & {
monthlyPrice?: Stripe.Price & {
product: Stripe.Product;
isVisibleInApp: boolean;
friendlyPrice: string;
};
yearlyPrice?: Stripe.Price & {
product: Stripe.Product;
isVisibleInApp: boolean;
friendlyPrice: string;
};
};
};
/**
* Returns the main Documenso plans from Stripe.
*/
export const getInternalClaimPlans = async (): Promise<InternalClaimPlans> => {
const { data: prices } = await stripe.prices.search({
query: `active:'true' type:'recurring'`,
expand: ['data.product'],
limit: 100,
});
const plans: InternalClaimPlans = clone(internalClaims);
prices.forEach((price) => {
// We use `expand` to get the product, but it's not typed as part of the Price type.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const product = price.product as Stripe.Product;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const productClaimId = product.metadata.claimId as INTERNAL_CLAIM_ID | undefined;
const isVisibleInApp = price.metadata.visibleInApp === 'true';
if (!productClaimId || !Object.values(INTERNAL_CLAIM_ID).includes(productClaimId)) {
return;
}
if (product.name.includes('Team')) {
console.log(JSON.stringify(price, null, 2));
}
let usdPrice = toHumanPrice(price.unit_amount ?? 0);
if (price.recurring?.interval === 'month') {
if (product.metadata['isSeatBased'] === 'true') {
usdPrice = '50';
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
plans[productClaimId].monthlyPrice = {
...price,
isVisibleInApp,
product,
friendlyPrice: `$${usdPrice} ${price.currency.toUpperCase()}`.replace('.00', ''),
};
}
if (price.recurring?.interval === 'year') {
if (product.metadata['isSeatBased'] === 'true') {
usdPrice = '480';
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
plans[productClaimId].yearlyPrice = {
...price,
isVisibleInApp,
product,
friendlyPrice: `$${usdPrice} ${price.currency.toUpperCase()}`.replace('.00', ''),
};
}
});
return plans;
};

View File

@ -1,13 +0,0 @@
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { getPricesByPlan } from './get-prices-by-plan';
export const getPlatformPlanPrices = async () => {
return await getPricesByPlan(STRIPE_PLAN_TYPE.PLATFORM);
};
export const getPlatformPlanPriceIds = async () => {
const prices = await getPlatformPlanPrices();
return prices.map((price) => price.id);
};

View File

@ -1,60 +0,0 @@
import type Stripe from 'stripe';
import type { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { stripe } from '@documenso/lib/server-only/stripe';
// Utility type to handle usage of the `expand` option.
type PriceWithProduct = Stripe.Price & { product: Stripe.Product };
export type PriceIntervals = Record<Stripe.Price.Recurring.Interval, PriceWithProduct[]>;
export type GetPricesByIntervalOptions = {
/**
* Filter products by their meta 'plan' attribute.
*/
plans?: STRIPE_PLAN_TYPE[];
};
export const getPricesByInterval = async ({ plans }: GetPricesByIntervalOptions = {}) => {
let { data: prices } = await stripe.prices.search({
query: `active:'true' type:'recurring'`,
expand: ['data.product'],
limit: 100,
});
prices = prices.filter((price) => {
// We use `expand` to get the product, but it's not typed as part of the Price type.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const product = price.product as Stripe.Product;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const filter = !plans || plans.includes(product.metadata?.plan as STRIPE_PLAN_TYPE);
// Filter out prices for products that are not active.
return product.active && filter;
});
const intervals: PriceIntervals = {
day: [],
week: [],
month: [],
year: [],
};
// Add each price to the correct interval.
for (const price of prices) {
if (price.recurring?.interval) {
// We use `expand` to get the product, but it's not typed as part of the Price type.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
intervals[price.recurring.interval].push(price as PriceWithProduct);
}
}
// Order all prices by unit_amount.
intervals.day.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
intervals.week.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
intervals.month.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
intervals.year.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
return intervals;
};

View File

@ -1,17 +0,0 @@
import type { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { stripe } from '@documenso/lib/server-only/stripe';
type PlanType = (typeof STRIPE_PLAN_TYPE)[keyof typeof STRIPE_PLAN_TYPE];
export const getPricesByPlan = async (plan: PlanType | PlanType[]) => {
const planTypes: string[] = typeof plan === 'string' ? [plan] : plan;
const prices = await stripe.prices.list({
expand: ['data.product'],
limit: 100,
});
return prices.data.filter(
(price) => price.type === 'recurring' && planTypes.includes(price.metadata.plan),
);
};

View File

@ -1,15 +0,0 @@
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { getPricesByPlan } from './get-prices-by-plan';
/**
* Returns the prices of items that count as the account's primary plan.
*/
export const getPrimaryAccountPlanPrices = async () => {
return await getPricesByPlan([
STRIPE_PLAN_TYPE.REGULAR,
STRIPE_PLAN_TYPE.COMMUNITY,
STRIPE_PLAN_TYPE.PLATFORM,
STRIPE_PLAN_TYPE.ENTERPRISE,
]);
};

View File

@ -1,17 +0,0 @@
import { stripe } from '@documenso/lib/server-only/stripe';
export type GetProductByPriceIdOptions = {
priceId: string;
};
export const getProductByPriceId = async ({ priceId }: GetProductByPriceIdOptions) => {
const { product } = await stripe.prices.retrieve(priceId, {
expand: ['product'],
});
if (typeof product === 'string' || 'deleted' in product) {
throw new Error('Product not found');
}
return product;
};

View File

@ -0,0 +1,42 @@
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { stripe } from '@documenso/lib/server-only/stripe';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
export type GetSubscriptionOptions = {
userId: number;
organisationId: string;
};
export const getSubscription = async ({ organisationId, userId }: GetSubscriptionOptions) => {
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery(
organisationId,
userId,
ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
),
include: {
subscription: true,
},
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Organisation not found',
});
}
if (!organisation.subscription) {
return null;
}
const stripeSubscription = await stripe.subscriptions.retrieve(organisation.subscription.planId, {
expand: ['items.data.price.product'],
});
return {
organisationSubscription: organisation.subscription,
stripeSubscription,
};
};

View File

@ -1,45 +0,0 @@
import type Stripe from 'stripe';
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { AppError } from '@documenso/lib/errors/app-error';
import { getPricesByPlan } from './get-prices-by-plan';
export const getTeamPrices = async () => {
const prices = (await getPricesByPlan(STRIPE_PLAN_TYPE.TEAM)).filter((price) => price.active);
const monthlyPrice = prices.find((price) => price.recurring?.interval === 'month');
const yearlyPrice = prices.find((price) => price.recurring?.interval === 'year');
const priceIds = prices.map((price) => price.id);
if (!monthlyPrice || !yearlyPrice) {
throw new AppError('INVALID_CONFIG', {
message: 'Missing monthly or yearly price',
});
}
return {
monthly: {
friendlyInterval: 'Monthly',
interval: 'monthly',
...extractPriceData(monthlyPrice),
},
yearly: {
friendlyInterval: 'Yearly',
interval: 'yearly',
...extractPriceData(yearlyPrice),
},
priceIds,
} as const;
};
const extractPriceData = (price: Stripe.Price) => {
const product =
typeof price.product !== 'string' && !price.product.deleted ? price.product : null;
return {
priceId: price.id,
description: product?.description ?? '',
features: product?.features ?? [],
};
};

View File

@ -1,21 +0,0 @@
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { getPricesByPlan } from './get-prices-by-plan';
/**
* Returns the Stripe prices of items that affect the amount of teams a user can create.
*/
export const getTeamRelatedPrices = async () => {
return await getPricesByPlan([
STRIPE_PLAN_TYPE.COMMUNITY,
STRIPE_PLAN_TYPE.PLATFORM,
STRIPE_PLAN_TYPE.ENTERPRISE,
]);
};
/**
* Returns the Stripe price IDs of items that affect the amount of teams a user can create.
*/
export const getTeamRelatedPriceIds = async () => {
return await getTeamRelatedPrices().then((prices) => prices.map((price) => price.id));
};

View File

@ -0,0 +1,13 @@
import type { Stripe } from '@documenso/lib/server-only/stripe';
import { stripe } from '@documenso/lib/server-only/stripe';
export const isPriceSeatsBased = async (priceId: string) => {
const foundStripePrice = await stripe.prices.retrieve(priceId, {
expand: ['product'],
});
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const product = foundStripePrice.product as Stripe.Product;
return product.metadata.isSeatBased === 'true';
};

View File

@ -1,6 +1,12 @@
import type { OrganisationClaim, Subscription } from '@prisma/client';
import type Stripe from 'stripe';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { stripe } from '@documenso/lib/server-only/stripe';
import { appLog } from '@documenso/lib/utils/debugger';
import { prisma } from '@documenso/prisma';
import { isPriceSeatsBased } from './is-price-seats-based';
export type UpdateSubscriptionItemQuantityOptions = {
subscriptionId: string;
@ -42,3 +48,57 @@ export const updateSubscriptionItemQuantity = async ({
await stripe.subscriptions.update(subscriptionId, subscriptionUpdatePayload);
};
/**
* Checks whether the member count should be synced with a given Stripe subscription.
*
* If the subscription is not "seat" based, it will be ignored.
*
* @param subscription - The subscription to sync the member count with.
* @param organisationClaim - The organisation claim
* @param quantity - The amount to sync the Stripe item with
* @returns
*/
export const syncMemberCountWithStripeSeatPlan = async (
subscription: Subscription,
organisationClaim: OrganisationClaim,
quantity: number,
) => {
const maximumMemberCount = organisationClaim.memberCount;
// Infinite seats means no sync needed.
if (maximumMemberCount === 0) {
return;
}
const syncMemberCountWithStripe = await isPriceSeatsBased(subscription.priceId);
// Throw error if quantity exceeds maximum member count and the subscription is not seats based.
if (quantity > maximumMemberCount && !syncMemberCountWithStripe) {
throw new AppError(AppErrorCode.LIMIT_EXCEEDED, {
message: 'Maximum member count reached',
});
}
// Bill the user with the new quantity.
if (syncMemberCountWithStripe) {
appLog('BILLING', 'Updating seat based plan');
await updateSubscriptionItemQuantity({
priceId: subscription.priceId,
subscriptionId: subscription.planId,
quantity,
});
// This should be automatically updated after the Stripe webhook is fired
// but we just manually adjust it here as well to avoid any race conditions.
await prisma.organisationClaim.update({
where: {
id: organisationClaim.id,
},
data: {
memberCount: quantity,
},
});
}
};

View File

@ -1,13 +1,11 @@
import { match } from 'ts-pattern';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import type { Stripe } from '@documenso/lib/server-only/stripe';
import { stripe } from '@documenso/lib/server-only/stripe';
import { createTeamFromPendingTeam } from '@documenso/lib/server-only/team/create-team';
import { env } from '@documenso/lib/utils/env';
import { prisma } from '@documenso/prisma';
import { onSubscriptionCreated } from './on-subscription-created';
import { onSubscriptionDeleted } from './on-subscription-deleted';
import { onSubscriptionUpdated } from './on-subscription-updated';
@ -65,78 +63,18 @@ export const stripeWebhookHandler = async (req: Request): Promise<Response> => {
const event = stripe.webhooks.constructEvent(payload, signature, webhookSecret);
/**
* Notes:
* - Dropped invoice.payment_succeeded
* - Dropped invoice.payment_failed
* - Dropped checkout-session.completed
*/
return await match(event.type)
.with('checkout.session.completed', async () => {
.with('customer.subscription.created', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const session = event.data.object as Stripe.Checkout.Session;
const subscription = event.data.object as Stripe.Subscription;
const customerId =
typeof session.customer === 'string' ? session.customer : session.customer?.id;
// Attempt to get the user ID from the client reference id.
let userId = Number(session.client_reference_id);
// If the user ID is not found, attempt to get it from the Stripe customer metadata.
if (!userId && customerId) {
const customer = await stripe.customers.retrieve(customerId);
if (!customer.deleted) {
userId = Number(customer.metadata.userId);
}
}
// Finally, attempt to get the user ID from the subscription within the database.
if (!userId && customerId) {
const result = await prisma.user.findFirst({
select: {
id: true,
},
where: {
customerId,
},
});
if (result?.id) {
userId = result.id;
}
}
const subscriptionId =
typeof session.subscription === 'string'
? session.subscription
: session.subscription?.id;
if (!subscriptionId) {
return Response.json(
{ success: false, message: 'Invalid session' } satisfies StripeWebhookResponse,
{ status: 500 },
);
}
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
// Handle team creation after seat checkout.
if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) {
await handleTeamSeatCheckout({ subscription });
return Response.json(
{ success: true, message: 'Webhook received' } satisfies StripeWebhookResponse,
{ status: 200 },
);
}
// Validate user ID.
if (!userId || Number.isNaN(userId)) {
return Response.json(
{
success: false,
message: 'Invalid session or missing user ID',
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
await onSubscriptionUpdated({ userId, subscription });
await onSubscriptionCreated({ subscription });
return Response.json(
{ success: true, message: 'Webhook received' } satisfies StripeWebhookResponse,
@ -147,254 +85,14 @@ export const stripeWebhookHandler = async (req: Request): Promise<Response> => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const subscription = event.data.object as Stripe.Subscription;
const customerId =
typeof subscription.customer === 'string'
? subscription.customer
: subscription.customer.id;
if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) {
const team = await prisma.team.findFirst({
where: {
customerId,
},
});
if (!team) {
return Response.json(
{
success: false,
message: 'No team associated with subscription found',
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
await onSubscriptionUpdated({ teamId: team.id, subscription });
return Response.json(
{ success: true, message: 'Webhook received' } satisfies StripeWebhookResponse,
{ status: 200 },
);
}
const result = await prisma.user.findFirst({
select: {
id: true,
},
where: {
customerId,
},
});
if (!result?.id) {
return Response.json(
{
success: false,
message: 'User not found',
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
await onSubscriptionUpdated({ userId: result.id, subscription });
return Response.json(
{
success: true,
message: 'Webhook received',
} satisfies StripeWebhookResponse,
{ status: 200 },
);
})
.with('invoice.payment_succeeded', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const invoice = event.data.object as Stripe.Invoice;
const previousAttributes = event.data
.previous_attributes as Partial<Stripe.Subscription> | null;
if (invoice.billing_reason !== 'subscription_cycle') {
return Response.json(
{
success: true,
message: 'Webhook received',
} satisfies StripeWebhookResponse,
{ status: 200 },
);
}
const customerId =
typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id;
const subscriptionId =
typeof invoice.subscription === 'string'
? invoice.subscription
: invoice.subscription?.id;
if (!customerId || !subscriptionId) {
return Response.json(
{
success: false,
message: 'Invalid invoice',
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
if (subscription.status === 'incomplete_expired') {
return Response.json(
{
success: true,
message: 'Webhook received',
} satisfies StripeWebhookResponse,
{ status: 200 },
);
}
if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) {
const team = await prisma.team.findFirst({
where: {
customerId,
},
});
if (!team) {
return Response.json(
{
success: false,
message: 'No team associated with subscription found',
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
await onSubscriptionUpdated({ teamId: team.id, subscription });
return Response.json(
{
success: true,
message: 'Webhook received',
} satisfies StripeWebhookResponse,
{ status: 200 },
);
}
const result = await prisma.user.findFirst({
select: {
id: true,
},
where: {
customerId,
},
});
if (!result?.id) {
return Response.json(
{
success: false,
message: 'User not found',
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
await onSubscriptionUpdated({ userId: result.id, subscription });
await onSubscriptionUpdated({ subscription, previousAttributes });
return Response.json(
{
success: true,
message: 'Webhook received',
} satisfies StripeWebhookResponse,
{ status: 200 },
);
})
.with('invoice.payment_failed', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const invoice = event.data.object as Stripe.Invoice;
const customerId =
typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id;
const subscriptionId =
typeof invoice.subscription === 'string'
? invoice.subscription
: invoice.subscription?.id;
if (!customerId || !subscriptionId) {
return Response.json(
{
success: false,
message: 'Invalid invoice',
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
if (subscription.status === 'incomplete_expired') {
return Response.json(
{
success: true,
message: 'Webhook received',
} satisfies StripeWebhookResponse,
{ status: 200 },
);
}
if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) {
const team = await prisma.team.findFirst({
where: {
customerId,
},
});
if (!team) {
return Response.json(
{
success: false,
message: 'No team associated with subscription found',
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
await onSubscriptionUpdated({ teamId: team.id, subscription });
return Response.json(
{
success: true,
message: 'Webhook received',
} satisfies StripeWebhookResponse,
{ status: 200 },
);
}
const result = await prisma.user.findFirst({
select: {
id: true,
},
where: {
customerId,
},
});
if (!result?.id) {
return Response.json(
{
success: false,
message: 'User not found',
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
await onSubscriptionUpdated({ userId: result.id, subscription });
return Response.json(
{
success: true,
message: 'Webhook received',
} satisfies StripeWebhookResponse,
{ success: true, message: 'Webhook received' } satisfies StripeWebhookResponse,
{ status: 200 },
);
})
@ -424,6 +122,13 @@ export const stripeWebhookHandler = async (req: Request): Promise<Response> => {
} catch (err) {
console.error(err);
if (err instanceof Response) {
const message = await err.json();
console.error(message);
return err;
}
return Response.json(
{
success: false,
@ -433,21 +138,3 @@ export const stripeWebhookHandler = async (req: Request): Promise<Response> => {
);
}
};
export type HandleTeamSeatCheckoutOptions = {
subscription: Stripe.Subscription;
};
const handleTeamSeatCheckout = async ({ subscription }: HandleTeamSeatCheckoutOptions) => {
if (subscription.metadata?.pendingTeamId === undefined) {
throw new Error('Missing pending team ID');
}
const pendingTeamId = Number(subscription.metadata.pendingTeamId);
if (Number.isNaN(pendingTeamId)) {
throw new Error('Invalid pending team ID');
}
return await createTeamFromPendingTeam({ pendingTeamId, subscription }).then((team) => team.id);
};

View File

@ -0,0 +1,185 @@
import type { SubscriptionClaim } from '@prisma/client';
import { SubscriptionStatus } from '@prisma/client';
import { match } from 'ts-pattern';
import { createOrganisation } from '@documenso/lib/server-only/organisation/create-organisation';
import { type Stripe } from '@documenso/lib/server-only/stripe';
import type { StripeOrganisationCreateMetadata } from '@documenso/lib/types/subscription';
import { ZStripeOrganisationCreateMetadataSchema } from '@documenso/lib/types/subscription';
import { prisma } from '@documenso/prisma';
import { createOrganisationClaimUpsertData, extractStripeClaim } from './on-subscription-updated';
export type OnSubscriptionCreatedOptions = {
subscription: Stripe.Subscription;
};
type StripeWebhookResponse = {
success: boolean;
message: string;
};
export const onSubscriptionCreated = async ({ subscription }: OnSubscriptionCreatedOptions) => {
const customerId =
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
// Todo: logging
if (subscription.items.data.length !== 1) {
console.error('No support for multiple items');
throw Response.json(
{
success: false,
message: 'No support for multiple items',
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
const organisationId = await handleOrganisationCreateOrGet({
subscription,
customerId,
});
const subscriptionItem = subscription.items.data[0];
const subscriptionClaim = await extractStripeClaim(subscriptionItem.price);
// Todo: logging
if (!subscriptionClaim) {
console.error(`Subscription claim on ${subscriptionItem.price.id} not found`);
throw Response.json(
{
success: false,
message: `Subscription claim on ${subscriptionItem.price.id} not found`,
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
await handleSubscriptionCreate({
subscription,
customerId,
organisationId,
subscriptionClaim,
});
};
type HandleSubscriptionCreateOptions = {
subscription: Stripe.Subscription;
customerId: string;
organisationId: string;
subscriptionClaim: SubscriptionClaim;
};
const handleSubscriptionCreate = async ({
subscription,
customerId,
organisationId,
subscriptionClaim,
}: HandleSubscriptionCreateOptions) => {
const status = match(subscription.status)
.with('active', () => SubscriptionStatus.ACTIVE)
.with('past_due', () => SubscriptionStatus.PAST_DUE)
.otherwise(() => SubscriptionStatus.INACTIVE);
await prisma.$transaction(async (tx) => {
await tx.subscription.create({
data: {
organisationId,
status: status,
planId: subscription.id,
priceId: subscription.items.data[0].price.id,
periodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
customerId,
},
});
await tx.organisationClaim.create({
data: {
organisation: {
connect: {
id: organisationId,
},
},
originalSubscriptionClaimId: subscriptionClaim.id,
...createOrganisationClaimUpsertData(subscriptionClaim),
},
});
});
};
type HandleOrganisationCreateOrGetOptions = {
subscription: Stripe.Subscription;
customerId: string;
};
const handleOrganisationCreateOrGet = async ({
subscription,
customerId,
}: HandleOrganisationCreateOrGetOptions) => {
let organisationCreateFlowData: StripeOrganisationCreateMetadata | null = null;
if (subscription.metadata?.organisationCreateData) {
const parseResult = ZStripeOrganisationCreateMetadataSchema.safeParse(
JSON.parse(subscription.metadata.organisationCreateData),
);
if (!parseResult.success) {
console.error('Invalid organisation create flow data');
throw Response.json(
{
success: false,
message: 'Invalid organisation create flow data',
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
organisationCreateFlowData = parseResult.data;
const createdOrganisation = await createOrganisation({
name: organisationCreateFlowData.organisationName,
userId: organisationCreateFlowData.userId,
});
return createdOrganisation.id;
}
const organisation = await prisma.organisation.findFirst({
where: {
customerId,
},
include: {
subscription: true,
},
});
if (!organisation) {
throw Response.json(
{
success: false,
message: `Organisation not found`,
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
// Todo: logging
if (organisation.subscription) {
console.error('Organisation already has a subscription');
// This should never happen
throw Response.json(
{
success: false,
message: `Organisation already has a subscription`,
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
return organisation.id;
};

View File

@ -1,59 +1,178 @@
import type { Prisma } from '@prisma/client';
import type { Prisma, SubscriptionClaim } from '@prisma/client';
import { SubscriptionStatus } from '@prisma/client';
import { match } from 'ts-pattern';
import type { Stripe } from '@documenso/lib/server-only/stripe';
import { type Stripe, stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
export type OnSubscriptionUpdatedOptions = {
userId?: number;
teamId: number;
subscription: Stripe.Subscription;
previousAttributes: Partial<Stripe.Subscription> | null;
};
type StripeWebhookResponse = {
success: boolean;
message: string;
};
// Todo: orgs check if the subscription matches since it's possible to have more than one (via stripe)
export const onSubscriptionUpdated = async ({
userId,
teamId,
subscription,
previousAttributes,
}: OnSubscriptionUpdatedOptions) => {
await prisma.subscription.upsert(
mapStripeSubscriptionToPrismaUpsertAction(subscription, userId, teamId),
);
};
const customerId =
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
export const mapStripeSubscriptionToPrismaUpsertAction = (
subscription: Stripe.Subscription,
userId?: number,
teamId?: number,
): Prisma.SubscriptionUpsertArgs => {
if ((!userId && !teamId) || (userId && teamId)) {
throw new Error('Either userId or teamId must be provided.');
// Todo: logging
if (subscription.items.data.length !== 1) {
console.error('No support for multiple items');
throw Response.json(
{
success: false,
message: 'No support for multiple items',
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
const organisation = await prisma.organisation.findFirst({
where: {
customerId,
},
include: {
organisationClaim: true,
},
});
if (!organisation) {
throw Response.json(
{
success: false,
message: `Organisation not found`,
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
const previousItem = previousAttributes?.items?.data[0];
const updatedItem = subscription.items.data[0];
const previousSubscriptionClaimId = previousItem
? await extractStripeClaimId(previousItem.price)
: null;
const updatedSubscriptionClaim = await extractStripeClaim(updatedItem.price);
if (!updatedSubscriptionClaim) {
console.error(`Subscription claim on ${updatedItem.price.id} not found`);
throw Response.json(
{
success: false,
message: `Subscription claim on ${updatedItem.price.id} not found`,
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
const newClaimFound = previousSubscriptionClaimId !== updatedSubscriptionClaim.id;
const status = match(subscription.status)
.with('active', () => SubscriptionStatus.ACTIVE)
.with('past_due', () => SubscriptionStatus.PAST_DUE)
.otherwise(() => SubscriptionStatus.INACTIVE);
await prisma.$transaction(async (tx) => {
await tx.subscription.update({
where: {
planId: subscription.id,
},
data: {
organisationId: organisation.id,
status: status,
planId: subscription.id,
priceId: subscription.items.data[0].price.id,
periodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
customerId,
},
});
// Override current organisation claim if new one is found.
if (newClaimFound) {
await tx.organisationClaim.update({
where: {
id: organisation.organisationClaim.id,
},
data: {
originalSubscriptionClaimId: updatedSubscriptionClaim.id,
...createOrganisationClaimUpsertData(updatedSubscriptionClaim),
},
});
}
});
};
export const createOrganisationClaimUpsertData = (subscriptionClaim: SubscriptionClaim) => {
// Done like this to ensure type errors are thrown if items are added.
const data: Omit<
Prisma.SubscriptionClaimCreateInput,
'id' | 'createdAt' | 'updatedAt' | 'locked' | 'name'
> = {
flags: {
...subscriptionClaim.flags,
},
teamCount: subscriptionClaim.teamCount,
memberCount: subscriptionClaim.memberCount,
};
return {
where: {
planId: subscription.id,
},
create: {
status: status,
planId: subscription.id,
priceId: subscription.items.data[0].price.id,
periodEnd: new Date(subscription.current_period_end * 1000),
userId: userId ?? null,
teamId: teamId ?? null,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
update: {
status: status,
planId: subscription.id,
priceId: subscription.items.data[0].price.id,
periodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
...data,
};
};
/**
* Checks the price metadata for a claimId, if it is missing it will fetch
* and check the product metadata for a claimId.
*
* The order of priority is:
* 1. Price metadata
* 2. Product metadata
*
* @returns The claimId or null if no claimId is found.
*/
export const extractStripeClaimId = async (priceId: Stripe.Price) => {
if (priceId.metadata.claimId) {
return priceId.metadata.claimId;
}
const productId = typeof priceId.product === 'string' ? priceId.product : priceId.product.id;
const product = await stripe.products.retrieve(productId);
return product.metadata.claimId || null;
};
/**
* Checks the price metadata for a claimId, if it is missing it will fetch
* and check the product metadata for a claimId.
*
*/
export const extractStripeClaim = async (priceId: Stripe.Price) => {
const claimId = await extractStripeClaimId(priceId);
if (!claimId) {
return null;
}
const subscriptionClaim = await prisma.subscriptionClaim.findFirst({
where: { id: claimId },
});
if (!subscriptionClaim) {
console.error(`Subscription claim ${claimId} not found`);
return null;
}
return subscriptionClaim;
};

View File

@ -1,57 +0,0 @@
import type { Subscription } from '@prisma/client';
import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
import { prisma } from '@documenso/prisma';
import { getCommunityPlanPriceIds } from '../stripe/get-community-plan-prices';
export type IsCommunityPlanOptions = {
userId: number;
teamId: number;
};
/**
* Whether the user or team is on the community plan.
*/
export const isCommunityPlan = async ({
userId,
teamId,
}: IsCommunityPlanOptions): Promise<boolean> => {
let subscriptions: Subscription[] = [];
if (teamId) {
subscriptions = await prisma.team
.findFirstOrThrow({
where: {
id: teamId,
},
select: {
owner: {
include: {
subscriptions: true,
},
},
},
})
.then((team) => team.owner.subscriptions);
} else {
subscriptions = await prisma.user
.findFirstOrThrow({
where: {
id: userId,
},
select: {
subscriptions: true,
},
})
.then((user) => user.subscriptions);
}
if (subscriptions.length === 0) {
return false;
}
const communityPlanPriceIds = await getCommunityPlanPriceIds();
return subscriptionsContainsActivePlan(subscriptions, communityPlanPriceIds);
};

View File

@ -1,65 +0,0 @@
import type { Subscription } from '@prisma/client';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
import { prisma } from '@documenso/prisma';
import { getEnterprisePlanPriceIds } from '../stripe/get-enterprise-plan-prices';
export type IsUserEnterpriseOptions = {
userId: number;
teamId: number;
};
/**
* Whether the user is enterprise, or has permission to use enterprise features on
* behalf of their team.
*
* It is assumed that the provided user is part of the provided team.
*/
export const isUserEnterprise = async ({
userId,
teamId,
}: IsUserEnterpriseOptions): Promise<boolean> => {
let subscriptions: Subscription[] = [];
if (!IS_BILLING_ENABLED()) {
return false;
}
if (teamId) {
subscriptions = await prisma.team
.findFirstOrThrow({
where: {
id: teamId,
},
select: {
owner: {
include: {
subscriptions: true,
},
},
},
})
.then((team) => team.owner.subscriptions);
} else {
subscriptions = await prisma.user
.findFirstOrThrow({
where: {
id: userId,
},
select: {
subscriptions: true,
},
})
.then((user) => user.subscriptions);
}
if (subscriptions.length === 0) {
return false;
}
const enterprisePlanPriceIds = await getEnterprisePlanPriceIds();
return subscriptionsContainsActivePlan(subscriptions, enterprisePlanPriceIds, true);
};

View File

@ -1,62 +0,0 @@
import type { Document, Subscription } from '@prisma/client';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
import { prisma } from '@documenso/prisma';
import { getPlatformPlanPriceIds } from '../stripe/get-platform-plan-prices';
export type IsDocumentPlatformOptions = Pick<Document, 'id' | 'userId' | 'teamId'>;
/**
* Whether the user is platform, or has permission to use platform features on
* behalf of their team.
*
* It is assumed that the provided user is part of the provided team.
*/
export const isDocumentPlatform = async ({
userId,
teamId,
}: IsDocumentPlatformOptions): Promise<boolean> => {
let subscriptions: Subscription[] = [];
if (!IS_BILLING_ENABLED()) {
return true;
}
if (teamId) {
subscriptions = await prisma.team
.findFirstOrThrow({
where: {
id: teamId,
},
select: {
owner: {
include: {
subscriptions: true,
},
},
},
})
.then((team) => team.owner.subscriptions);
} else {
subscriptions = await prisma.user
.findFirstOrThrow({
where: {
id: userId,
},
select: {
subscriptions: true,
},
})
.then((user) => user.subscriptions);
}
if (subscriptions.length === 0) {
return false;
}
const platformPlanPriceIds = await getPlatformPlanPriceIds();
return subscriptionsContainsActivePlan(subscriptions, platformPlanPriceIds);
};

View File

@ -23,7 +23,6 @@ export type OrganisationInviteEmailProps = {
baseUrl: string;
senderName: string;
organisationName: string;
teamName?: string;
token: string;
};
@ -32,13 +31,12 @@ export const OrganisationInviteEmailTemplate = ({
baseUrl = 'https://documenso.com',
senderName = 'John Doe',
organisationName = 'Organisation Name',
teamName = 'Team Name',
token = '',
}: OrganisationInviteEmailProps) => {
const { _ } = useLingui();
const branding = useBranding();
const previewText = msg`Accept invitation to join a team on Documenso`;
const previewText = msg`Accept invitation to join an organisation on Documenso`;
return (
<Html>
@ -72,15 +70,11 @@ export const OrganisationInviteEmailTemplate = ({
</Text>
<Text className="my-1 text-center text-base">
{teamName ? (
<Trans>You have been invited to join the following team</Trans>
) : (
<Trans>You have been invited to join the following organisation</Trans>
)}
<Trans>You have been invited to join the following organisation</Trans>
</Text>
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 text-base font-medium text-slate-600">
{teamName || organisationName}
{organisationName}
</div>
<Text className="my-1 text-center text-base">
@ -92,13 +86,13 @@ export const OrganisationInviteEmailTemplate = ({
<Section className="mb-6 mt-6 text-center">
<Button
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
href={`${baseUrl}/team/invite/${token}`}
href={`${baseUrl}/organisation/invite/${token}`}
>
<Trans>Accept</Trans>
</Button>
<Button
className="ml-4 inline-flex items-center justify-center rounded-lg bg-gray-50 px-6 py-3 text-center text-sm font-medium text-slate-600 no-underline"
href={`${baseUrl}/team/decline/${token}`}
href={`${baseUrl}/organisation/decline/${token}`}
>
<Trans>Decline</Trans>
</Button>

View File

@ -2,34 +2,32 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { formatTeamUrl } from '@documenso/lib/utils/teams';
import { Body, Container, Head, Hr, Html, Img, Preview, Section, Text } from '../components';
import { useBranding } from '../providers/branding';
import { TemplateFooter } from '../template-components/template-footer';
import TemplateImage from '../template-components/template-image';
export type TeamJoinEmailProps = {
export type OrganisationJoinEmailProps = {
assetBaseUrl: string;
baseUrl: string;
memberName: string;
memberEmail: string;
teamName: string;
teamUrl: string;
organisationName: string;
organisationUrl: string;
};
export const TeamJoinEmailTemplate = ({
export const OrganisationJoinEmailTemplate = ({
assetBaseUrl = 'http://localhost:3002',
baseUrl = 'https://documenso.com',
memberName = 'John Doe',
memberEmail = 'johndoe@documenso.com',
teamName = 'Team Name',
teamUrl = 'demo',
}: TeamJoinEmailProps) => {
organisationName = 'Organisation Name',
organisationUrl = 'demo',
}: OrganisationJoinEmailProps) => {
const { _ } = useLingui();
const branding = useBranding();
const previewText = msg`A team member has joined a team on Documenso`;
const previewText = msg`A member has joined your organisation on Documenso`;
return (
<Html>
@ -59,17 +57,11 @@ export const TeamJoinEmailTemplate = ({
<Section className="p-2 text-slate-500">
<Text className="text-center text-lg font-medium text-black">
<Trans>
{memberName || memberEmail} joined the team {teamName} on Documenso
</Trans>
</Text>
<Text className="my-1 text-center text-base">
<Trans>{memberEmail} joined the following team</Trans>
<Trans>A new member has joined your organisation {organisationName}</Trans>
</Text>
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 text-base font-medium text-slate-600">
{formatTeamUrl(teamUrl, baseUrl)}
{memberName || memberEmail}
</div>
</Section>
</Container>
@ -85,4 +77,4 @@ export const TeamJoinEmailTemplate = ({
);
};
export default TeamJoinEmailTemplate;
export default OrganisationJoinEmailTemplate;

View File

@ -2,34 +2,32 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { formatTeamUrl } from '@documenso/lib/utils/teams';
import { Body, Container, Head, Hr, Html, Img, Preview, Section, Text } from '../components';
import { useBranding } from '../providers/branding';
import { TemplateFooter } from '../template-components/template-footer';
import TemplateImage from '../template-components/template-image';
export type TeamLeaveEmailProps = {
export type OrganisationLeaveEmailProps = {
assetBaseUrl: string;
baseUrl: string;
memberName: string;
memberEmail: string;
teamName: string;
teamUrl: string;
organisationName: string;
organisationUrl: string;
};
export const TeamLeaveEmailTemplate = ({
export const OrganisationLeaveEmailTemplate = ({
assetBaseUrl = 'http://localhost:3002',
baseUrl = 'https://documenso.com',
memberName = 'John Doe',
memberEmail = 'johndoe@documenso.com',
teamName = 'Team Name',
teamUrl = 'demo',
}: TeamLeaveEmailProps) => {
organisationName = 'Organisation Name',
organisationUrl = 'demo',
}: OrganisationLeaveEmailProps) => {
const { _ } = useLingui();
const branding = useBranding();
const previewText = msg`A team member has left a team on Documenso`;
const previewText = msg`A member has left your organisation on Documenso`;
return (
<Html>
@ -59,17 +57,11 @@ export const TeamLeaveEmailTemplate = ({
<Section className="p-2 text-slate-500">
<Text className="text-center text-lg font-medium text-black">
<Trans>
{memberName || memberEmail} left the team {teamName} on Documenso
</Trans>
</Text>
<Text className="my-1 text-center text-base">
<Trans>{memberEmail} left the following team</Trans>
<Trans>A member has left your organisation {organisationName}</Trans>
</Text>
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 text-base font-medium text-slate-600">
{formatTeamUrl(teamUrl, baseUrl)}
{memberName || memberEmail}
</div>
</Section>
</Container>
@ -85,4 +77,4 @@ export const TeamLeaveEmailTemplate = ({
);
};
export default TeamLeaveEmailTemplate;
export default OrganisationLeaveEmailTemplate;

View File

@ -0,0 +1,33 @@
import { createContext, useContext } from 'react';
import React from 'react';
import type { OrganisationSession } from '@documenso/trpc/server/organisation-router/get-organisation-session.types';
type OrganisationProviderValue = OrganisationSession;
interface OrganisationProviderProps {
children: React.ReactNode;
organisation: OrganisationProviderValue | null;
}
const OrganisationContext = createContext<OrganisationProviderValue | null>(null);
export const useCurrentOrganisation = () => {
const context = useContext(OrganisationContext);
if (!context) {
throw new Error('useCurrentOrganisation must be used within a OrganisationProvider');
}
return context;
};
export const useOptionalCurrentOrganisation = () => {
return useContext(OrganisationContext);
};
export const OrganisationProvider = ({ children, organisation }: OrganisationProviderProps) => {
return (
<OrganisationContext.Provider value={organisation}>{children}</OrganisationContext.Provider>
);
};

View File

@ -1,12 +1,18 @@
export enum STRIPE_CUSTOMER_TYPE {
INDIVIDUAL = 'individual',
ORGANISATION = 'organisation',
}
import { SubscriptionStatus } from '@prisma/client';
export enum STRIPE_PLAN_TYPE {
REGULAR = 'regular',
TEAM = 'team',
COMMUNITY = 'community',
FREE = 'free',
INDIVIDUAL = 'individual',
PRO = 'pro',
EARLY_ADOPTER = 'earlyAdopter',
PLATFORM = 'platform',
ENTERPRISE = 'enterprise',
}
export const FREE_TIER_DOCUMENT_QUOTA = 5;
export const SUBSCRIPTION_STATUS_MAP = {
[SubscriptionStatus.ACTIVE]: 'Active',
[SubscriptionStatus.INACTIVE]: 'Inactive',
[SubscriptionStatus.PAST_DUE]: 'Past Due',
};

View File

@ -2,7 +2,6 @@ import { env } from '@documenso/lib/utils/env';
import { NEXT_PUBLIC_WEBAPP_URL } from './app';
const NEXT_PUBLIC_FEATURE_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED');
const NEXT_PUBLIC_POSTHOG_KEY = () => env('NEXT_PUBLIC_POSTHOG_KEY');
/**
@ -10,26 +9,6 @@ const NEXT_PUBLIC_POSTHOG_KEY = () => env('NEXT_PUBLIC_POSTHOG_KEY');
*/
export const FEATURE_FLAG_GLOBAL_SESSION_RECORDING = 'global_session_recording';
/**
* How frequent to poll for new feature flags in milliseconds.
*/
export const FEATURE_FLAG_POLL_INTERVAL = 30000;
/**
* Feature flags that will be used when PostHog is disabled.
*
* Does not take any person or group properties into account.
*/
export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
app_allow_encrypted_documents: false,
app_billing: NEXT_PUBLIC_FEATURE_BILLING_ENABLED() === 'true',
app_document_page_view_history_sheet: false,
app_passkey: true,
app_public_profile: true,
marketing_header_single_player_mode: false,
marketing_profiles_announcement_bar: true,
} as const;
/**
* Extract the PostHog configuration from the environment.
*/
@ -46,10 +25,3 @@ export function extractPostHogConfig(): { key: string; host: string } | null {
host: postHogHost,
};
}
/**
* Whether feature flags are enabled for the current instance.
*/
export function isFeatureFlagEnabled(): boolean {
return extractPostHogConfig() !== null;
}

View File

@ -1,13 +1,13 @@
import { JobClient } from './client/client';
import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/emails/send-confirmation-email';
import { SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-cancelled-emails';
import { SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-member-joined-email';
import { SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-member-left-email';
import { SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION } from './definitions/emails/send-password-reset-success-email';
import { SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-recipient-signed-email';
import { SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION } from './definitions/emails/send-rejection-emails';
import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-signing-email';
import { SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-deleted-email';
import { SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-member-joined-email';
import { SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-member-left-email';
import { BULK_SEND_TEMPLATE_JOB_DEFINITION } from './definitions/internal/bulk-send-template';
import { SEAL_DOCUMENT_JOB_DEFINITION } from './definitions/internal/seal-document';
@ -18,8 +18,8 @@ import { SEAL_DOCUMENT_JOB_DEFINITION } from './definitions/internal/seal-docume
export const jobsClient = new JobClient([
SEND_SIGNING_EMAIL_JOB_DEFINITION,
SEND_CONFIRMATION_EMAIL_JOB_DEFINITION,
SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION,
SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION,
SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION,
SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION,
SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION,
SEAL_DOCUMENT_JOB_DEFINITION,
SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION,

View File

@ -1,80 +1,85 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { TeamMemberRole } from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import TeamJoinEmailTemplate from '@documenso/email/templates/team-join';
import OrganisationJoinEmailTemplate from '@documenso/email/templates/organisation-join';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import { getTeamSettings } from '../../../server-only/team/get-team-settings';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '../../../constants/organisations';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding';
import { organisationGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendTeamMemberJoinedEmailJobDefinition } from './send-team-member-joined-email';
import type { TSendOrganisationMemberJoinedEmailJobDefinition } from './send-organisation-member-joined-email';
export const run = async ({
payload,
io,
}: {
payload: TSendTeamMemberJoinedEmailJobDefinition;
payload: TSendOrganisationMemberJoinedEmailJobDefinition;
io: JobRunIO;
}) => {
const team = await prisma.team.findFirstOrThrow({
const organisation = await prisma.organisation.findFirstOrThrow({
where: {
id: payload.teamId,
id: payload.organisationId,
},
include: {
members: {
where: {
role: {
in: [TeamMemberRole.ADMIN, TeamMemberRole.MANAGER],
organisationGroupMembers: {
some: {
group: {
organisationRole: {
in: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
},
},
},
},
},
include: {
user: true,
},
},
organisationGlobalSettings: true,
},
});
const settings = await getTeamSettings({
userId: payload.userId,
teamId: payload.teamId,
});
const invitedMember = await prisma.teamMember.findFirstOrThrow({
const invitedMember = await prisma.organisationMember.findFirstOrThrow({
where: {
id: payload.memberId,
teamId: payload.teamId,
userId: payload.memberUserId,
organisationId: payload.organisationId,
},
include: {
user: true,
},
});
for (const member of team.members) {
for (const member of organisation.members) {
if (member.id === invitedMember.id) {
continue;
}
await io.runTask(
`send-team-member-joined-email--${invitedMember.id}_${member.id}`,
`send-organisation-member-joined-email--${invitedMember.id}_${member.id}`,
async () => {
const emailContent = createElement(TeamJoinEmailTemplate, {
const emailContent = createElement(OrganisationJoinEmailTemplate, {
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
baseUrl: NEXT_PUBLIC_WEBAPP_URL(),
memberName: invitedMember.user.name || '',
memberEmail: invitedMember.user.email,
teamName: team.name,
teamUrl: team.url,
organisationName: organisation.name,
organisationUrl: organisation.url,
});
const branding = teamGlobalSettingsToBranding(settings, team.id);
const lang = settings.documentLanguage;
const branding = organisationGlobalSettingsToBranding(
organisation.organisationGlobalSettings,
organisation.id,
);
const lang = organisation.organisationGlobalSettings.documentLanguage;
// !: Replace with the actual language of the recipient later
const [html, text] = await Promise.all([
@ -97,7 +102,7 @@ export const run = async ({
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: i18n._(msg`A new member has joined your team`),
subject: i18n._(msg`A new member has joined your organisation`),
html,
text,
});

View File

@ -0,0 +1,33 @@
import { z } from 'zod';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION_ID =
'send.organisation-member-joined.email';
const SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
organisationId: z.string(),
memberUserId: z.number(),
});
export type TSendOrganisationMemberJoinedEmailJobDefinition = z.infer<
typeof SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION = {
id: SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION_ID,
name: 'Send Organisation Member Joined Email',
version: '1.0.0',
trigger: {
name: SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION_ID,
schema: SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-organisation-member-joined-email.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION_ID,
TSendOrganisationMemberJoinedEmailJobDefinition
>;

View File

@ -0,0 +1,107 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { mailer } from '@documenso/email/mailer';
import OrganisationLeaveEmailTemplate from '@documenso/email/templates/organisation-leave';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '../../../constants/organisations';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import { organisationGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendOrganisationMemberLeftEmailJobDefinition } from './send-organisation-member-left-email';
export const run = async ({
payload,
io,
}: {
payload: TSendOrganisationMemberLeftEmailJobDefinition;
io: JobRunIO;
}) => {
const organisation = await prisma.organisation.findFirstOrThrow({
where: {
id: payload.organisationId,
},
include: {
members: {
where: {
organisationGroupMembers: {
some: {
group: {
organisationRole: {
in: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
},
},
},
},
},
include: {
user: true,
},
},
organisationGlobalSettings: true,
},
});
const oldMember = await prisma.user.findFirstOrThrow({
where: {
id: payload.memberUserId,
},
});
for (const member of organisation.members) {
if (member.userId === oldMember.id) {
continue;
}
await io.runTask(
`send-organisation-member-left-email--${oldMember.id}_${member.id}`,
async () => {
const emailContent = createElement(OrganisationLeaveEmailTemplate, {
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
baseUrl: NEXT_PUBLIC_WEBAPP_URL(),
memberName: oldMember.name || '',
memberEmail: oldMember.email,
organisationName: organisation.name,
organisationUrl: organisation.url,
});
const branding = organisationGlobalSettingsToBranding(
organisation.organisationGlobalSettings,
organisation.id,
);
const lang = organisation.organisationGlobalSettings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(emailContent, {
lang,
branding,
}),
renderEmailWithI18N(emailContent, {
lang,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(lang);
await mailer.sendMail({
to: member.user.email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: i18n._(msg`A member has left your organisation`),
html,
text,
});
},
);
}
};

View File

@ -0,0 +1,32 @@
import { z } from 'zod';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION_ID = 'send.organisation-member-left.email';
const SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
organisationId: z.string(),
memberUserId: z.number(),
});
export type TSendOrganisationMemberLeftEmailJobDefinition = z.infer<
typeof SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION = {
id: SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION_ID,
name: 'Send Organisation Member Left Email',
version: '1.0.0',
trigger: {
name: SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION_ID,
schema: SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-organisation-member-left-email.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION_ID,
TSendOrganisationMemberLeftEmailJobDefinition
>;

View File

@ -1,4 +1,3 @@
import { DocumentVisibility } from '@prisma/client';
import { z } from 'zod';
import type { JobDefinition } from '../../client/_internal/job';
@ -9,23 +8,24 @@ const SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
team: z.object({
name: z.string(),
url: z.string(),
teamGlobalSettings: z
.object({
documentVisibility: z.nativeEnum(DocumentVisibility),
documentLanguage: z.string(),
includeSenderDetails: z.boolean(),
includeSigningCertificate: z.boolean(),
brandingEnabled: z.boolean(),
brandingLogo: z.string(),
brandingUrl: z.string(),
brandingCompanyDetails: z.string(),
brandingHidePoweredBy: z.boolean(),
teamId: z.number(),
typedSignatureEnabled: z.boolean(),
uploadSignatureEnabled: z.boolean(),
drawSignatureEnabled: z.boolean(),
})
.nullish(),
// This is never passed along for some reason so commenting it out.
// teamGlobalSettings: z
// .object({
// documentVisibility: z.nativeEnum(DocumentVisibility),
// documentLanguage: z.string(),
// includeSenderDetails: z.boolean(),
// includeSigningCertificate: z.boolean(),
// brandingEnabled: z.boolean(),
// brandingLogo: z.string(),
// brandingUrl: z.string(),
// brandingCompanyDetails: z.string(),
// brandingHidePoweredBy: z.boolean(),
// teamId: z.number(),
// typedSignatureEnabled: z.boolean(),
// uploadSignatureEnabled: z.boolean(),
// drawSignatureEnabled: z.boolean(),
// })
// .nullish(),
}),
members: z.array(
z.object({

View File

@ -1,32 +0,0 @@
import { z } from 'zod';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_ID = 'send.team-member-joined.email';
const SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
teamId: z.number(),
memberId: z.number(),
});
export type TSendTeamMemberJoinedEmailJobDefinition = z.infer<
typeof SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION = {
id: SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_ID,
name: 'Send Team Member Joined Email',
version: '1.0.0',
trigger: {
name: SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_ID,
schema: SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-team-member-joined-email.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_ID,
TSendTeamMemberJoinedEmailJobDefinition
>;

View File

@ -1,94 +0,0 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { TeamMemberRole } from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import TeamJoinEmailTemplate from '@documenso/email/templates/team-join';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import { getTeamSettings } from '../../../server-only/team/get-team-settings';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendTeamMemberLeftEmailJobDefinition } from './send-team-member-left-email';
export const run = async ({
payload,
io,
}: {
payload: TSendTeamMemberLeftEmailJobDefinition;
io: JobRunIO;
}) => {
const team = await prisma.team.findFirstOrThrow({
where: {
id: payload.teamId,
},
include: {
members: {
where: {
role: {
in: [TeamMemberRole.ADMIN, TeamMemberRole.MANAGER],
},
},
include: {
user: true,
},
},
},
});
const settings = await getTeamSettings({
teamId: payload.teamId,
});
const oldMember = await prisma.user.findFirstOrThrow({
where: {
id: payload.memberUserId,
},
});
for (const member of team.members) {
await io.runTask(`send-team-member-left-email--${oldMember.id}_${member.id}`, async () => {
const emailContent = createElement(TeamJoinEmailTemplate, {
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
baseUrl: NEXT_PUBLIC_WEBAPP_URL(),
memberName: oldMember.name || '',
memberEmail: oldMember.email,
teamName: team.name,
teamUrl: team.url,
});
const branding = teamGlobalSettingsToBranding(settings, team.id);
const lang = settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(emailContent, {
lang,
branding,
}),
renderEmailWithI18N(emailContent, {
lang,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(lang);
await mailer.sendMail({
to: member.user.email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: i18n._(msg`A team member has left ${team.name}`),
html,
text,
});
});
}
};

View File

@ -1,32 +0,0 @@
import { z } from 'zod';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_ID = 'send.team-member-left.email';
const SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
teamId: z.number(),
memberUserId: z.number(),
});
export type TSendTeamMemberLeftEmailJobDefinition = z.infer<
typeof SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION = {
id: SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_ID,
name: 'Send Team Member Left Email',
version: '1.0.0',
trigger: {
name: SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_ID,
schema: SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-team-member-left-email.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_ID,
TSendTeamMemberLeftEmailJobDefinition
>;

View File

@ -1,13 +0,0 @@
import { prisma } from '@documenso/prisma';
export const findSubscriptions = async () => {
return prisma.subscription.findMany({
select: {
id: true,
status: true,
createdAt: true,
periodEnd: true,
userId: true,
},
});
};

View File

@ -29,37 +29,26 @@ export async function getSigningVolume({
let findQuery = kyselyPrisma.$kysely
.selectFrom('Subscription as s')
.leftJoin('User as u', 's.userId', 'u.id')
.leftJoin('Team as t', 's.teamId', 't.id')
.leftJoin('Document as ud', (join) =>
.innerJoin('Organisation as o', 's.organisationId', 'o.id')
.leftJoin('Team as t', 'o.id', 't.organisationId')
.leftJoin('Document as d', (join) =>
join
.onRef('u.id', '=', 'ud.userId')
.on('ud.status', '=', sql.lit(DocumentStatus.COMPLETED))
.on('ud.deletedAt', 'is', null)
.on('ud.teamId', 'is', null),
)
.leftJoin('Document as td', (join) =>
join
.onRef('t.id', '=', 'td.teamId')
.on('td.status', '=', sql.lit(DocumentStatus.COMPLETED))
.on('td.deletedAt', 'is', null),
.onRef('t.id', '=', 'd.teamId')
.on('d.status', '=', sql.lit(DocumentStatus.COMPLETED))
.on('d.deletedAt', 'is', null),
)
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
.where((eb) =>
eb.or([
eb('u.name', 'ilike', `%${search}%`),
eb('u.email', 'ilike', `%${search}%`),
eb('t.name', 'ilike', `%${search}%`),
]),
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
)
.select([
's.id as id',
's.createdAt as createdAt',
's.planId as planId',
sql<string>`COALESCE(u.name, t.name, u.email, 'Unknown')`.as('name'),
sql<number>`COUNT(DISTINCT ud.id) + COUNT(DISTINCT td.id)`.as('signingVolume'),
sql<string>`COALESCE(o.name, 'Unknown')`.as('name'),
sql<number>`COUNT(DISTINCT d.id)`.as('signingVolume'),
])
.groupBy(['s.id', 'u.name', 't.name', 'u.email']);
.groupBy(['s.id', 'o.name']);
switch (sortBy) {
case 'name':
@ -79,15 +68,11 @@ export async function getSigningVolume({
const countQuery = kyselyPrisma.$kysely
.selectFrom('Subscription as s')
.leftJoin('User as u', 's.userId', 'u.id')
.leftJoin('Team as t', 's.teamId', 't.id')
.innerJoin('Organisation as o', 's.organisationId', 'o.id')
.leftJoin('Team as t', 'o.id', 't.organisationId')
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
.where((eb) =>
eb.or([
eb('u.name', 'ilike', `%${search}%`),
eb('u.email', 'ilike', `%${search}%`),
eb('t.name', 'ilike', `%${search}%`),
]),
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
)
.select(({ fn }) => [fn.countAll().as('count')]);

View File

@ -7,13 +7,11 @@ export const getUsersCount = async () => {
return await prisma.user.count();
};
export const getUsersWithSubscriptionsCount = async () => {
return await prisma.user.count({
export const getOrganisationsWithSubscriptionsCount = async () => {
return await prisma.organisation.count({
where: {
subscriptions: {
some: {
status: SubscriptionStatus.ACTIVE,
},
subscription: {
status: SubscriptionStatus.ACTIVE,
},
},
});

View File

@ -7,7 +7,6 @@ import {
WebhookTriggerEvents,
} from '@prisma/client';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
@ -27,6 +26,7 @@ import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../utils/document-auth';
import { determineDocumentVisibility } from '../../utils/document-visibility';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getMemberRoles } from '../team/get-member-roles';
import { getTeamSettings } from '../team/get-team-settings';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@ -60,6 +60,23 @@ export const createDocumentV2 = async ({
}: CreateDocumentOptions) => {
const { title, formValues } = data;
const team = await prisma.team.findFirst({
where: buildTeamWhereQuery(userId, teamId),
include: {
organisation: {
select: {
organisationClaim: true,
},
},
},
});
if (!team) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Team not found',
});
}
const settings = await getTeamSettings({
userId,
teamId,
@ -96,17 +113,13 @@ export const createDocumentV2 = async ({
const recipientsHaveActionAuth = data.recipients?.some((recipient) => recipient.actionAuth);
// Check if user has permission to set the global action auth.
if (authOptions.globalActionAuth || recipientsHaveActionAuth) {
const isDocumentEnterprise = await isUserEnterprise({
userId,
teamId,
if (
(authOptions.globalActionAuth || recipientsHaveActionAuth) &&
!team.organisation.organisationClaim.flags.cfr21
) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
if (!isDocumentEnterprise) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
}
const { teamRole } = await getMemberRoles({

View File

@ -2,7 +2,6 @@ import { DocumentVisibility } from '@prisma/client';
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import type { CreateDocumentAuditLogDataResponse } from '@documenso/lib/utils/document-audit-logs';
@ -43,6 +42,17 @@ export const updateDocument = async ({
const document = await prisma.document.findFirst({
where: documentWhereInput,
include: {
team: {
select: {
organisation: {
select: {
organisationClaim: true,
},
},
},
},
},
});
if (!document) {
@ -108,17 +118,10 @@ export const updateDocument = async ({
data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth;
// Check if user has permission to set the global action auth.
if (newGlobalActionAuth) {
const isDocumentEnterprise = await isUserEnterprise({
userId,
teamId,
if (newGlobalActionAuth && !document.team.organisation.organisationClaim.flags.cfr21) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
if (!isDocumentEnterprise) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
}
const isTitleSame = data.title === undefined || data.title === document.title;

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;
};

View File

@ -1,13 +1,14 @@
import { TeamMemberRole } from '@prisma/client';
import type { Duration } from 'luxon';
import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
// temporary choice for testing only
import * as timeConstants from '../../constants/time';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { alphaid } from '../../universal/id';
import { buildTeamWhereQuery } from '../../utils/teams';
import { hashString } from '../auth/hash';
type TimeConstants = typeof timeConstants & {
@ -33,20 +34,14 @@ export const createApiToken = async ({
const timeConstantsRecords: TimeConstants = timeConstants;
if (teamId) {
const member = await prisma.teamMember.findFirst({
where: {
userId,
teamId,
role: TeamMemberRole.ADMIN,
},
});
const team = await prisma.team.findFirst({
where: buildTeamWhereQuery(teamId, userId, TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM']),
});
if (!member) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to create a token for this team',
});
}
if (!team) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to create a token for this team',
});
}
const storedToken = await prisma.apiToken.create({

View File

@ -1,7 +1,9 @@
import { TeamMemberRole } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { buildTeamWhereQuery } from '../../utils/teams';
export type DeleteTokenByIdOptions = {
id: number;
userId: number;
@ -9,24 +11,20 @@ export type DeleteTokenByIdOptions = {
};
export const deleteTokenById = async ({ id, userId, teamId }: DeleteTokenByIdOptions) => {
if (teamId) {
const member = await prisma.teamMember.findFirst({
where: {
userId,
teamId,
role: TeamMemberRole.ADMIN,
},
});
const team = await prisma.team.findFirst({
where: buildTeamWhereQuery(teamId, userId, TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM']),
});
if (!member) {
throw new Error('You do not have permission to delete this token');
}
if (!team) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to delete this token',
});
}
return await prisma.apiToken.delete({
where: {
id,
teamId: teamId ?? null,
teamId,
},
});
};

View File

@ -1,7 +1,6 @@
import { RecipientRole } from '@prisma/client';
import { SendStatus, SigningStatus } from '@prisma/client';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
import { type TRecipientActionAuthTypes } from '@documenso/lib/types/document-auth';
@ -46,6 +45,15 @@ export const createDocumentRecipients = async ({
where: documentWhereInput,
include: {
recipients: true,
team: {
select: {
organisation: {
select: {
organisationClaim: true,
},
},
},
},
},
});
@ -64,17 +72,10 @@ export const createDocumentRecipients = async ({
const recipientsHaveActionAuth = recipientsToCreate.some((recipient) => recipient.actionAuth);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth) {
const isEnterprise = await isUserEnterprise({
userId,
teamId,
if (recipientsHaveActionAuth && !document.team.organisation.organisationClaim.flags.cfr21) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
if (!isEnterprise) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
}
const normalizedRecipients = recipientsToCreate.map((recipient) => ({

View File

@ -1,7 +1,6 @@
import { RecipientRole } from '@prisma/client';
import { SendStatus, SigningStatus } from '@prisma/client';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
import { type TRecipientActionAuthTypes } from '@documenso/lib/types/document-auth';
import { nanoid } from '@documenso/lib/universal/id';
@ -38,6 +37,15 @@ export const createTemplateRecipients = async ({
},
include: {
recipients: true,
team: {
select: {
organisation: {
select: {
organisationClaim: true,
},
},
},
},
},
});
@ -50,17 +58,10 @@ export const createTemplateRecipients = async ({
const recipientsHaveActionAuth = recipientsToCreate.some((recipient) => recipient.actionAuth);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth) {
const isEnterprise = await isUserEnterprise({
userId,
teamId,
if (recipientsHaveActionAuth && !template.team.organisation.organisationClaim.flags.cfr21) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
if (!isEnterprise) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
}
const normalizedRecipients = recipientsToCreate.map((recipient) => ({

View File

@ -5,7 +5,6 @@ import type { Recipient } from '@prisma/client';
import { RecipientRole } from '@prisma/client';
import { SendStatus, SigningStatus } from '@prisma/client';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { mailer } from '@documenso/email/mailer';
import RecipientRemovedFromDocumentTemplate from '@documenso/email/templates/recipient-removed-from-document';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
@ -60,6 +59,15 @@ export const setDocumentRecipients = async ({
include: {
fields: true,
documentMeta: true,
team: {
select: {
organisation: {
select: {
organisationClaim: true,
},
},
},
},
},
});
@ -90,17 +98,10 @@ export const setDocumentRecipients = async ({
const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth) {
const isDocumentEnterprise = await isUserEnterprise({
userId,
teamId,
if (recipientsHaveActionAuth && !document.team.organisation.organisationClaim.flags.cfr21) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
if (!isDocumentEnterprise) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
}
const normalizedRecipients = recipients.map((recipient) => ({

View File

@ -1,7 +1,6 @@
import type { Recipient } from '@prisma/client';
import { RecipientRole } from '@prisma/client';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import {
DIRECT_TEMPLATE_RECIPIENT_EMAIL,
DIRECT_TEMPLATE_RECIPIENT_NAME,
@ -44,6 +43,15 @@ export const setTemplateRecipients = async ({
},
include: {
directLink: true,
team: {
select: {
organisation: {
select: {
organisationClaim: true,
},
},
},
},
},
});
@ -54,17 +62,10 @@ export const setTemplateRecipients = async ({
const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth) {
const isDocumentEnterprise = await isUserEnterprise({
userId,
teamId,
if (recipientsHaveActionAuth && !template.team.organisation.organisationClaim.flags.cfr21) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
if (!isDocumentEnterprise) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
}
const normalizedRecipients = recipients.map((recipient) => {

View File

@ -2,7 +2,6 @@ import type { Recipient } from '@prisma/client';
import { RecipientRole } from '@prisma/client';
import { SendStatus, SigningStatus } from '@prisma/client';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
import {
@ -47,6 +46,15 @@ export const updateDocumentRecipients = async ({
include: {
fields: true,
recipients: true,
team: {
select: {
organisation: {
select: {
organisationClaim: true,
},
},
},
},
},
});
@ -65,17 +73,10 @@ export const updateDocumentRecipients = async ({
const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth) {
const isEnterprise = await isUserEnterprise({
userId,
teamId,
if (recipientsHaveActionAuth && !document.team.organisation.organisationClaim.flags.cfr21) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
if (!isEnterprise) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
}
const recipientsToUpdate = recipients.map((recipient) => {

View File

@ -1,6 +1,5 @@
import type { RecipientRole, Team } from '@prisma/client';
import type { RecipientRole } from '@prisma/client';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
@ -12,6 +11,7 @@ import {
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData, diffRecipientChanges } from '../../utils/document-audit-logs';
import { createRecipientAuthOptions } from '../../utils/document-auth';
import { buildTeamWhereQuery } from '../../utils/teams';
export type UpdateRecipientOptions = {
documentId: number;
@ -44,57 +44,43 @@ export const updateRecipient = async ({
document: {
id: documentId,
userId,
team: {
id: teamId,
members: {
some: {
userId,
team: buildTeamWhereQuery(teamId, userId), // Todo: orgs i know i messed the orders of some of these up somewhere
},
},
include: {
document: {
include: {
team: {
include: {
organisation: {
select: {
organisationClaim: true,
},
},
},
},
},
},
},
include: {
document: true,
},
});
let team: Team | null = null;
if (teamId) {
team = await prisma.team.findFirst({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
});
}
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
if (!recipient) {
// Todo: orgs check if this is supposed to only be documents
if (!recipient || !recipient.document) {
throw new Error('Recipient not found');
}
if (actionAuth) {
const isDocumentEnterprise = await isUserEnterprise({
userId,
teamId,
});
const team = recipient.document.team;
if (!isDocumentEnterprise) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
if (actionAuth && !recipient.document.team.organisation.organisationClaim.flags.cfr21) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
const recipientAuthOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);

View File

@ -1,7 +1,6 @@
import { RecipientRole } from '@prisma/client';
import { SendStatus, SigningStatus } from '@prisma/client';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
import {
type TRecipientActionAuthTypes,
@ -41,6 +40,15 @@ export const updateTemplateRecipients = async ({
},
include: {
recipients: true,
team: {
select: {
organisation: {
select: {
organisationClaim: true,
},
},
},
},
},
});
@ -53,17 +61,10 @@ export const updateTemplateRecipients = async ({
const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth) {
const isEnterprise = await isUserEnterprise({
userId,
teamId,
if (recipientsHaveActionAuth && !template.team.organisation.organisationClaim.flags.cfr21) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
if (!isEnterprise) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
}
const recipientsToUpdate = recipients.map((recipient) => {

View File

@ -1,13 +0,0 @@
import { prisma } from '@documenso/prisma';
export type GetSubscriptionsByUserIdOptions = {
userId: number;
};
export const getSubscriptionsByUserId = async ({ userId }: GetSubscriptionsByUserIdOptions) => {
return await prisma.subscription.findMany({
where: {
userId,
},
});
};

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);

View File

@ -1,6 +1,5 @@
import type { DocumentVisibility, Template, TemplateMeta } from '@prisma/client';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
@ -39,6 +38,15 @@ export const updateTemplate = async ({
},
include: {
templateMeta: true,
team: {
select: {
organisation: {
select: {
organisationClaim: true,
},
},
},
},
},
});
@ -66,17 +74,10 @@ export const updateTemplate = async ({
data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth;
// Check if user has permission to set the global action auth.
if (newGlobalActionAuth) {
const isDocumentEnterprise = await isUserEnterprise({
userId,
teamId,
if (newGlobalActionAuth && !template.team.organisation.organisationClaim.flags.cfr21) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
if (!isDocumentEnterprise) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
}
const authOptions = createDocumentAuthOptions({

View File

@ -1,22 +0,0 @@
import type { User } from '@prisma/client';
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-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';
export type CreateBillingPortalOptions = {
user: Pick<User, 'id' | 'customerId' | 'email' | 'name'>;
};
export const createBillingPortal = async ({ user }: CreateBillingPortalOptions) => {
if (!IS_BILLING_ENABLED()) {
throw new Error('Billing is not enabled');
}
const { stripeCustomer } = await getStripeCustomerByUser(user);
return getPortalSession({
customerId: stripeCustomer.id,
returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/settings/billing`,
});
};

View File

@ -1,39 +0,0 @@
import type { User } from '@prisma/client';
import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session';
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getSubscriptionsByUserId } from '../subscription/get-subscriptions-by-user-id';
export type CreateCheckoutSession = {
user: Pick<User, 'id' | 'customerId' | 'email' | 'name'>;
priceId: string;
};
export const createCheckoutSession = async ({ user, priceId }: CreateCheckoutSession) => {
const { stripeCustomer } = await getStripeCustomerByUser(user);
const existingSubscriptions = await getSubscriptionsByUserId({ userId: user.id });
const foundSubscription = existingSubscriptions.find(
(subscription) =>
subscription.priceId === priceId &&
subscription.periodEnd &&
subscription.periodEnd >= new Date(),
);
if (foundSubscription) {
return getPortalSession({
customerId: stripeCustomer.id,
returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/settings/billing`,
});
}
return getCheckoutSession({
customerId: stripeCustomer.id,
priceId,
returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/settings/billing`,
});
};

View File

@ -1,10 +1,8 @@
import { hash } from '@node-rs/bcrypt';
import type { User } from '@prisma/client';
import { OrganisationGroupType, OrganisationMemberInviteStatus } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { IS_BILLING_ENABLED } from '../../constants/app';
import { SALT_ROUNDS } from '../../constants/auth';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { createPersonalOrganisation } from '../organisation/create-organisation';
@ -14,16 +12,9 @@ export interface CreateUserOptions {
email: string;
password: string;
signature?: string | null;
orgUrl: string;
}
export const createUser = async ({
name,
email,
password,
signature,
orgUrl,
}: CreateUserOptions) => {
export const createUser = async ({ name, email, password, signature }: CreateUserOptions) => {
const hashedPassword = await hash(password, SALT_ROUNDS);
const userExists = await prisma.user.findFirst({
@ -36,22 +27,6 @@ export const createUser = async ({
throw new AppError(AppErrorCode.ALREADY_EXISTS);
}
// Todo: orgs handle htis
if (orgUrl) {
const urlExists = await prisma.team.findFirst({
where: {
url: orgUrl,
},
});
if (urlExists) {
throw new AppError('PROFILE_URL_TAKEN', {
message: 'Profile username is taken',
userMessage: 'The profile username is already taken',
});
}
}
const user = await prisma.$transaction(async (tx) => {
const user = await tx.user.create({
data: {
@ -76,8 +51,7 @@ export const createUser = async ({
return user;
});
await createPersonalOrganisation({ userId: user.id, orgUrl });
// Not used at the moment, uncomment if required.
await onCreateUserHook(user).catch((err) => {
// Todo: (RR7) Add logging.
console.error(err);
@ -87,119 +61,12 @@ export const createUser = async ({
};
/**
* Should be run after a user is created.
* Should be run after a user is created, example during email password signup or google sign in.
*
* @returns User
*/
export const onCreateUserHook = async (user: User) => {
const { email } = user;
const acceptedOrganisationInvites = await prisma.organisationMemberInvite.findMany({
where: {
status: OrganisationMemberInviteStatus.ACCEPTED,
email: {
equals: email,
mode: 'insensitive',
},
},
include: {
organisation: {
include: {
groups: {
where: {
type: OrganisationGroupType.INTERNAL_ORGANISATION,
},
},
},
},
},
});
// For each team invite, add the user to the organisation and team, then delete the team invite.
// If an error occurs, reset the invitation to not accepted.
await Promise.allSettled(
acceptedOrganisationInvites.map(async (invite) =>
prisma
.$transaction(
async (tx) => {
const organisationGroupToUse = invite.organisation.groups.find(
(group) =>
group.type === OrganisationGroupType.INTERNAL_ORGANISATION &&
group.organisationRole === invite.organisationRole,
);
if (!organisationGroupToUse) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Organisation group not found',
});
}
await tx.organisationMember.create({
data: {
organisationId: invite.organisationId,
userId: user.id,
organisationGroupMembers: {
create: {
groupId: organisationGroupToUse.id,
},
},
},
});
await tx.organisationMemberInvite.delete({
where: {
id: invite.id,
},
});
if (!IS_BILLING_ENABLED()) {
return;
}
const organisation = await tx.organisation.findFirstOrThrow({
where: {
id: invite.organisationId,
},
include: {
members: {
select: {
id: true,
},
},
subscriptions: {
select: {
id: true,
priceId: true,
planId: true,
},
},
},
});
// const organisationSeatSubscription = // TODO
// if (organisation.subscriptions) {
// await updateSubscriptionItemQuantity({
// priceId: team.subscription.priceId,
// subscriptionId: team.subscription.planId,
// quantity: team.members.length,
// });
// }
},
{ timeout: 30_000 },
)
.catch(async () => {
await prisma.organisationMemberInvite.update({
where: {
id: invite.id,
},
data: {
status: OrganisationMemberInviteStatus.PENDING,
},
});
}),
),
);
await createPersonalOrganisation({ userId: user.id });
return user;
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,8 @@
import type { z } from 'zod';
import OrganisationClaimSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationClaimSchema';
import { OrganisationSchema } from '@documenso/prisma/generated/zod/modelSchema/OrganisationSchema';
/**
* The full document response schema.
*
* Mainly used for returning a single document from the API.
*/
export const ZOrganisationSchema = OrganisationSchema.pick({
id: true,
createdAt: true,
@ -17,54 +13,20 @@ export const ZOrganisationSchema = OrganisationSchema.pick({
customerId: true,
ownerUserId: true,
}).extend({
// // Todo: Maybe we want to alter this a bit since this returns a lot of data.
// documentData: OrganisationDataSchema.pick({
// type: true,
// id: true,
// data: true,
// initialData: true,
// }),
// documentMeta: OrganisationMetaSchema.pick({
// signingOrder: true,
// distributionMethod: true,
// id: true,
// subject: true,
// message: true,
// timezone: true,
// password: true,
// dateFormat: true,
// documentId: true,
// redirectUrl: true,
// typedSignatureEnabled: true,
// uploadSignatureEnabled: true,
// drawSignatureEnabled: true,
// allowDictateNextSigner: true,
// language: true,
// emailSettings: true,
// }).nullable(),
// recipients: ZRecipientLiteSchema.array(),
// fields: ZFieldSchema.array(),
organisationClaim: OrganisationClaimSchema.pick({
id: true,
createdAt: true,
updatedAt: true,
originalSubscriptionClaimId: true,
teamCount: true,
memberCount: true,
flags: true,
}),
});
export type TOrganisation = z.infer<typeof ZOrganisationSchema>;
/**
* A lite version of the document response schema without relations.
*/
export const ZOrganisationLiteSchema = OrganisationSchema.pick({
id: true,
createdAt: true,
updatedAt: true,
name: true,
avatarImageId: true,
customerId: true,
ownerUserId: true,
});
/**
* A version of the document response schema when returning multiple documents at once from a single API endpoint.
*/
export const ZOrganisationManySchema = OrganisationSchema.pick({
id: true,
createdAt: true,
updatedAt: true,
@ -73,15 +35,9 @@ export const ZOrganisationManySchema = OrganisationSchema.pick({
avatarImageId: true,
customerId: true,
ownerUserId: true,
}).extend({
// user: UserSchema.pick({
// id: true,
// name: true,
// email: true,
// }),
// recipients: ZRecipientLiteSchema.array(),
// team: TeamSchema.pick({
// id: true,
// url: true,
// }).nullable(),
});
/**
* A version of the organisation response schema when returning multiple organisations at once from a single API endpoint.
*/
export const ZOrganisationManySchema = ZOrganisationLiteSchema;

View File

@ -0,0 +1,177 @@
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import type { SubscriptionClaim } from '@prisma/client';
import { z } from 'zod';
import { ZOrganisationNameSchema } from '@documenso/trpc/server/organisation-router/create-organisation.types';
export const ZClaimFlagsSchema = z.object({
unlimitedDocuments: z.boolean().optional(),
/**
* Allows disabling of Documenso branding for:
* - Certificates
* - Emails
* - Todo: orgs
*
* Rename to allowCustomBranding
*/
branding: z.boolean().optional(),
embedAuthoring: z.boolean().optional(),
embedAuthoringWhiteLabel: z.boolean().optional(),
embedSigning: z.boolean().optional(),
embedSigningWhiteLabel: z.boolean().optional(),
cfr21: z.boolean().optional(),
});
export type TClaimFlags = z.infer<typeof ZClaimFlagsSchema>;
// When adding keys, update internal documentation with this.
export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record<
keyof TClaimFlags,
{
label: string;
key: keyof TClaimFlags;
}
> = {
unlimitedDocuments: {
key: 'unlimitedDocuments',
label: 'Unlimited documents',
},
branding: {
key: 'branding',
label: 'Branding',
},
embedAuthoring: {
key: 'embedAuthoring',
label: 'Embed authoring',
},
embedSigning: {
key: 'embedSigning',
label: 'Embed signing',
},
embedAuthoringWhiteLabel: {
key: 'embedAuthoringWhiteLabel',
label: 'White label for embed authoring',
},
embedSigningWhiteLabel: {
key: 'embedSigningWhiteLabel',
label: 'White label for embed signing',
},
cfr21: {
key: 'cfr21',
label: '21 CFR',
},
};
export enum INTERNAL_CLAIM_ID {
FREE = 'free',
INDIVIDUAL = 'individual',
PRO = 'pro',
EARLY_ADOPTER = 'earlyAdopter',
PLATFORM = 'platform',
ENTERPRISE = 'enterprise',
}
export type InternalClaim = Omit<SubscriptionClaim, 'createdAt' | 'updatedAt'> & {
description: MessageDescriptor | string;
};
export type InternalClaims = {
[key in INTERNAL_CLAIM_ID]: InternalClaim;
};
export const internalClaims: InternalClaims = {
[INTERNAL_CLAIM_ID.FREE]: {
id: INTERNAL_CLAIM_ID.FREE,
name: 'Free',
description: msg`5 Documents a month`,
teamCount: 1,
memberCount: 1,
locked: true,
flags: {},
},
[INTERNAL_CLAIM_ID.INDIVIDUAL]: {
id: INTERNAL_CLAIM_ID.INDIVIDUAL,
name: 'Individual',
description: msg`Unlimited documents, API and more`,
teamCount: 1,
memberCount: 1,
locked: true,
flags: {
unlimitedDocuments: true,
},
},
[INTERNAL_CLAIM_ID.PRO]: {
id: INTERNAL_CLAIM_ID.PRO, // Team -> Pro
name: 'Teams',
description: msg`Embedding, 5 members included and more`,
teamCount: 1,
memberCount: 5,
locked: true,
flags: {
unlimitedDocuments: true,
branding: true,
embedSigning: true, // Pro (team) plan only gets embedSigning right?
},
},
[INTERNAL_CLAIM_ID.PLATFORM]: {
id: INTERNAL_CLAIM_ID.PLATFORM,
name: 'Platform',
description: msg`Whitelabeling, unlimited members and more`,
teamCount: 1,
memberCount: 0,
locked: true,
flags: {
unlimitedDocuments: true,
branding: true,
embedAuthoring: false,
embedAuthoringWhiteLabel: true,
embedSigning: false,
embedSigningWhiteLabel: true,
},
},
[INTERNAL_CLAIM_ID.ENTERPRISE]: {
id: INTERNAL_CLAIM_ID.ENTERPRISE,
name: 'Enterprise',
description: '',
teamCount: 0,
memberCount: 0,
locked: true,
flags: {
unlimitedDocuments: true,
branding: true,
embedAuthoring: true,
embedAuthoringWhiteLabel: true,
embedSigning: true,
embedSigningWhiteLabel: true,
cfr21: true,
},
},
[INTERNAL_CLAIM_ID.EARLY_ADOPTER]: {
id: INTERNAL_CLAIM_ID.EARLY_ADOPTER,
name: 'Early Adopter',
description: '',
teamCount: 0,
memberCount: 0,
locked: true,
flags: {
unlimitedDocuments: true,
branding: true,
embedSigning: true,
embedSigningWhiteLabel: true,
},
},
} as const;
export const ZStripeOrganisationCreateMetadataSchema = z.object({
organisationName: ZOrganisationNameSchema,
userId: z.number(),
});
export type StripeOrganisationCreateMetadata = z.infer<
typeof ZStripeOrganisationCreateMetadataSchema
>;

View File

@ -3,3 +3,7 @@ import { customAlphabet } from 'nanoid';
export const alphaid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 21);
export { nanoid } from 'nanoid';
export const generatePrefixedId = (prefix: string, length = 8) => {
return `${prefix}_${alphaid(length)}`;
};

View File

@ -1,36 +1,39 @@
import type { Subscription } from '@prisma/client';
import { SubscriptionStatus } from '@prisma/client';
import type { Subscription } from '@documenso/prisma/generated/zod/modelSchema/SubscriptionSchema';
import { IS_BILLING_ENABLED } from '../constants/app';
import { AppErrorCode } from '../errors/app-error';
import { AppError } from '../errors/app-error';
import type { StripeOrganisationCreateMetadata } from '../types/subscription';
export const generateStripeOrganisationCreateMetadata = (
organisationName: string,
userId: number,
) => {
const metadata: StripeOrganisationCreateMetadata = {
organisationName,
userId,
};
return {
organisationCreateData: JSON.stringify(metadata),
};
};
/**
* Returns true if there is a subscription that is active and is one of the provided price IDs.
* Throws an error if billing is enabled and no subscription is found.
*/
export const subscriptionsContainsActivePlan = (
subscriptions: Subscription[],
priceIds: string[],
allowPastDue?: boolean,
) => {
const allowedSubscriptionStatuses: SubscriptionStatus[] = [SubscriptionStatus.ACTIVE];
export const validateIfSubscriptionIsRequired = (subscription?: Subscription | null) => {
const isBillingEnabled = IS_BILLING_ENABLED();
if (allowPastDue) {
allowedSubscriptionStatuses.push(SubscriptionStatus.PAST_DUE);
if (!isBillingEnabled) {
return;
}
return subscriptions.some(
(subscription) =>
allowedSubscriptionStatuses.includes(subscription.status) &&
priceIds.includes(subscription.priceId),
);
};
if (isBillingEnabled && !subscription) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Subscription not found',
});
}
/**
* Returns true if there is a subscription that is active and is one of the provided product IDs.
*/
export const subscriptionsContainsActiveProductId = (
subscriptions: Subscription[],
productId: string[],
) => {
return subscriptions.some(
(subscription) =>
subscription.status === SubscriptionStatus.ACTIVE && productId.includes(subscription.planId),
);
return subscription;
};

View File

@ -1,5 +1,6 @@
import type { I18n, MessageDescriptor } from '@lingui/core';
import { i18n } from '@lingui/core';
import type { MacroMessageDescriptor } from '@lingui/core/macro';
import type { I18nLocaleData, SupportedLanguageCodes } from '../constants/i18n';
import { APP_I18N_OPTIONS } from '../constants/i18n';
@ -84,3 +85,10 @@ export const extractLocaleData = ({ headers }: ExtractLocaleDataOptions): I18nLo
export const parseMessageDescriptor = (_: I18n['_'], value: string | MessageDescriptor) => {
return typeof value === 'string' ? value : _(value);
};
export const parseMessageDescriptorMacro = (
t: (descriptor: MacroMessageDescriptor) => string,
value: string | MessageDescriptor,
) => {
return typeof value === 'string' ? value : t(value);
};

View File

@ -0,0 +1,25 @@
import type { OrganisationClaim, SubscriptionClaim } from '@prisma/client';
export const generateDefaultOrganisationClaims = (): Omit<
OrganisationClaim,
'id' | 'organisation' | 'createdAt' | 'updatedAt' | 'originalSubscriptionClaimId'
> => {
return {
teamCount: 1,
memberCount: 1,
flags: {},
};
};
export const generateDefaultSubscriptionClaim = (): Omit<
SubscriptionClaim,
'id' | 'organisation' | 'createdAt' | 'updatedAt' | 'originalSubscriptionClaimId'
> => {
return {
name: '',
teamCount: 1,
memberCount: 1,
locked: false,
flags: {},
};
};

View File

@ -0,0 +1,69 @@
/*
Warnings:
- You are about to drop the column `customerId` on the `User` table. All the data in the column will be lost.
- You are about to drop the `TeamPending` table. If the table is not empty, all the data it contains will be lost.
- A unique constraint covering the columns `[organisationClaimId]` on the table `Organisation` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[organisationId]` on the table `Subscription` will be added. If there are existing duplicate values, this will fail.
- Added the required column `organisationClaimId` to the `Organisation` table without a default value. This is not possible if the table is not empty.
*/
-- DropForeignKey
ALTER TABLE "Subscription" DROP CONSTRAINT "Subscription_organisationId_fkey";
-- DropForeignKey
ALTER TABLE "TeamPending" DROP CONSTRAINT "TeamPending_ownerUserId_fkey";
-- DropIndex
DROP INDEX "User_customerId_key";
-- AlterTable
ALTER TABLE "Organisation" ADD COLUMN "organisationClaimId" TEXT NOT NULL;
-- AlterTable
ALTER TABLE "Subscription" ADD COLUMN "customerId" TEXT;
-- AlterTable
ALTER TABLE "User" DROP COLUMN "customerId";
-- DropTable
DROP TABLE "TeamPending";
-- CreateTable
CREATE TABLE "SubscriptionClaim" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"name" TEXT NOT NULL,
"locked" BOOLEAN NOT NULL DEFAULT false,
"teamCount" INTEGER NOT NULL,
"memberCount" INTEGER NOT NULL,
"flags" JSONB NOT NULL,
CONSTRAINT "SubscriptionClaim_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OrganisationClaim" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"originalSubscriptionClaimId" TEXT,
"teamCount" INTEGER NOT NULL,
"memberCount" INTEGER NOT NULL,
"flags" JSONB NOT NULL,
CONSTRAINT "OrganisationClaim_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Organisation_organisationClaimId_key" ON "Organisation"("organisationClaimId");
-- CreateIndex
CREATE UNIQUE INDEX "Subscription_organisationId_key" ON "Subscription"("organisationId");
-- AddForeignKey
ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "Organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Organisation" ADD CONSTRAINT "Organisation_organisationClaimId_fkey" FOREIGN KEY ("organisationClaimId") REFERENCES "OrganisationClaim"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,9 @@
-- Insert default claims
INSERT INTO "SubscriptionClaim" (id, name, "teamCount", "memberCount", locked, flags, "createdAt", "updatedAt")
VALUES
('free', 'Free', 1, 1, true, '{}', NOW(), NOW()),
('individual', 'Individual', 1, 1, true, '{"unlimitedDocuments": true}', NOW(), NOW()),
('pro', 'Teams', 1, 5, true, '{"memberStripeSync": true, "unlimitedDocuments": true, "embedSigning": true}', NOW(), NOW()),
('platform', 'Platform', 1, 0, true, '{"unlimitedDocuments": true, "embedAuthoring": false, "embedAuthoringWhiteLabel": true, "embedSigning": false, "embedSigningWhiteLabel": true}', NOW(), NOW()),
('enterprise', 'Enterprise', 0, 0, true, '{"unlimitedDocuments": true, "embedAuthoring": true, "embedAuthoringWhiteLabel": true, "embedSigning": true, "embedSigningWhiteLabel": true, "cfr21": true}', NOW(), NOW()),
('early-adopter', 'Early Adopter', 0, 0, true, '{"unlimitedDocuments": true, "embedAuthoring": false, "embedAuthoringWhiteLabel": true, "embedSigning": false, "embedSigningWhiteLabel": true}', NOW(), NOW());

View File

@ -1,3 +1,4 @@
// Todo: orgs remember to add custom migration to apply default claims
generator kysely {
provider = "prisma-kysely"
}
@ -39,7 +40,6 @@ enum Role {
model User {
id Int @id @default(autoincrement())
name String?
customerId String? @unique
email String @unique
emailVerified DateTime?
password String? // Todo: (RR7) Remove after RR7 migration.
@ -60,8 +60,6 @@ model User {
ownedOrganisations Organisation[]
organisationMember OrganisationMember[]
ownedPendingTeams TeamPending[]
twoFactorSecret String?
twoFactorEnabled Boolean @default(false)
twoFactorBackupCodes String?
@ -255,12 +253,44 @@ model Subscription {
updatedAt DateTime @updatedAt
cancelAtPeriodEnd Boolean @default(false)
organisationId String
organisation Organisation @relation(fields: [organisationId], references: [id])
customerId String? // Todo: orgs
organisationId String @unique
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
@@index([organisationId])
}
/// @zod.import(["import { ZClaimFlagsSchema } from '@documenso/lib/types/subscription';"])
model SubscriptionClaim {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
name String
locked Boolean @default(false)
teamCount Int
memberCount Int
flags Json /// [ClaimFlags] @zod.custom.use(ZClaimFlagsSchema)
}
/// @zod.import(["import { ZClaimFlagsSchema } from '@documenso/lib/types/subscription';"])
model OrganisationClaim {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
originalSubscriptionClaimId String?
organisation Organisation?
teamCount Int
memberCount Int
flags Json /// [ClaimFlags] @zod.custom.use(ZClaimFlagsSchema)
}
model Account {
id String @id @default(cuid())
userId Int
@ -550,8 +580,11 @@ model Organisation {
url String @unique // todo: constrain
avatarImageId String?
customerId String? @unique
subscriptions Subscription[]
customerId String? @unique // Todo: orgs
subscription Subscription?
organisationClaimId String @unique
organisationClaim OrganisationClaim @relation(fields: [organisationClaimId], references: [id], onDelete: Cascade)
members OrganisationMember[]
invites OrganisationMemberInvite[]
@ -683,7 +716,7 @@ model OrganisationGlobalSettings {
brandingLogo String @default("")
brandingUrl String @default("")
brandingCompanyDetails String @default("")
brandingHidePoweredBy Boolean @default(false)
brandingHidePoweredBy Boolean @default(false) // Todo: orgs this doesn't seem to be used?
}
model TeamGlobalSettings {
@ -732,17 +765,6 @@ model Team {
teamGlobalSettings TeamGlobalSettings @relation(fields: [teamGlobalSettingsId], references: [id], onDelete: Cascade)
}
model TeamPending {
id Int @id @default(autoincrement())
name String
url String @unique
createdAt DateTime @default(now())
customerId String @unique
ownerUserId Int
owner User @relation(fields: [ownerUserId], references: [id], onDelete: Cascade)
}
model TeamEmail {
teamId Int @id @unique
createdAt DateTime @default(now())

View File

@ -6,12 +6,15 @@ import type {
import type { TDocumentEmailSettings } from '@documenso/lib/types/document-email';
import type { TDocumentFormValues } from '@documenso/lib/types/document-form-values';
import type { TFieldMetaNotOptionalSchema } from '@documenso/lib/types/field-meta';
import type { TClaimFlags } from '@documenso/lib/types/subscription';
/**
* Global types for Prisma.Json instances.
*/
declare global {
namespace PrismaJson {
type ClaimFlags = TClaimFlags;
type DocumentFormValues = TDocumentFormValues;
type DocumentAuthOptions = TDocumentAuthOptions;
type DocumentEmailSettings = TDocumentEmailSettings;

View File

@ -0,0 +1,50 @@
import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { adminProcedure } from '../trpc';
import {
ZCreateStripeCustomerRequestSchema,
ZCreateStripeCustomerResponseSchema,
} from './create-stripe-customer.types';
export const createStripeCustomerRoute = adminProcedure
.input(ZCreateStripeCustomerRequestSchema)
.output(ZCreateStripeCustomerResponseSchema)
.mutation(async ({ input }) => {
const { organisationId } = input;
const organisation = await prisma.organisation.findUnique({
where: {
id: organisationId,
},
include: {
owner: {
select: {
email: true,
name: true,
},
},
},
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
await prisma.$transaction(async (tx) => {
const stripeCustomer = await createCustomer({
name: organisation.name,
email: organisation.owner.email,
});
await tx.organisation.update({
where: {
id: organisationId,
},
data: {
customerId: stripeCustomer.id,
},
});
});
});

View File

@ -0,0 +1,9 @@
import { z } from 'zod';
export const ZCreateStripeCustomerRequestSchema = z.object({
organisationId: z.string().describe('The organisation to attach the customer to'),
});
export const ZCreateStripeCustomerResponseSchema = z.void();
export type TCreateStripeCustomerRequest = z.infer<typeof ZCreateStripeCustomerRequestSchema>;

View File

@ -0,0 +1,23 @@
import { prisma } from '@documenso/prisma';
import { adminProcedure } from '../trpc';
import {
ZCreateSubscriptionClaimRequestSchema,
ZCreateSubscriptionClaimResponseSchema,
} from './create-subscription-claim.types';
export const createSubscriptionClaimRoute = adminProcedure
.input(ZCreateSubscriptionClaimRequestSchema)
.output(ZCreateSubscriptionClaimResponseSchema)
.mutation(async ({ input }) => {
const { name, teamCount, memberCount, flags } = input;
await prisma.subscriptionClaim.create({
data: {
name,
teamCount,
memberCount,
flags,
},
});
});

View File

@ -0,0 +1,14 @@
import { z } from 'zod';
import { ZClaimFlagsSchema } from '@documenso/lib/types/subscription';
export const ZCreateSubscriptionClaimRequestSchema = z.object({
name: z.string().min(1),
teamCount: z.number().int().min(0),
memberCount: z.number().int().min(0),
flags: ZClaimFlagsSchema,
});
export const ZCreateSubscriptionClaimResponseSchema = z.void();
export type TCreateSubscriptionClaimRequest = z.infer<typeof ZCreateSubscriptionClaimRequestSchema>;

View File

@ -0,0 +1,37 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { adminProcedure } from '../trpc';
import {
ZDeleteSubscriptionClaimRequestSchema,
ZDeleteSubscriptionClaimResponseSchema,
} from './delete-subscription-claim.types';
export const deleteSubscriptionClaimRoute = adminProcedure
.input(ZDeleteSubscriptionClaimRequestSchema)
.output(ZDeleteSubscriptionClaimResponseSchema)
.mutation(async ({ input }) => {
const { id } = input;
const existingClaim = await prisma.subscriptionClaim.findFirst({
where: {
id,
},
});
if (!existingClaim) {
throw new AppError(AppErrorCode.NOT_FOUND, { message: 'Subscription claim not found' });
}
if (existingClaim.locked) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Cannot delete locked subscription claim',
});
}
await prisma.subscriptionClaim.delete({
where: {
id,
},
});
});

View File

@ -0,0 +1,9 @@
import { z } from 'zod';
export const ZDeleteSubscriptionClaimRequestSchema = z.object({
id: z.string().cuid(),
});
export const ZDeleteSubscriptionClaimResponseSchema = z.void();
export type TDeleteSubscriptionClaimRequest = z.infer<typeof ZDeleteSubscriptionClaimRequestSchema>;

View File

@ -0,0 +1,128 @@
import { Prisma } from '@prisma/client';
import type { FindResultResponse } from '@documenso/lib/types/search-params';
import { prisma } from '@documenso/prisma';
import { adminProcedure } from '../trpc';
import {
ZFindAdminOrganisationsRequestSchema,
ZFindAdminOrganisationsResponseSchema,
} from './find-admin-organisations.types';
export const findAdminOrganisationsRoute = adminProcedure
.input(ZFindAdminOrganisationsRequestSchema)
.output(ZFindAdminOrganisationsResponseSchema)
.query(async ({ input }) => {
const { query, page, perPage } = input;
return await findAdminOrganisations({
query,
page,
perPage,
});
});
type FindAdminOrganisationsOptions = {
query?: string;
page?: number;
perPage?: number;
};
export const findAdminOrganisations = async ({
query,
page = 1,
perPage = 10,
}: FindAdminOrganisationsOptions) => {
let whereClause: Prisma.OrganisationWhereInput = {};
if (query) {
whereClause = {
OR: [
{
id: {
contains: query,
mode: Prisma.QueryMode.insensitive,
},
},
{
owner: {
email: {
contains: query,
mode: Prisma.QueryMode.insensitive,
},
},
},
{
customerId: {
contains: query,
mode: Prisma.QueryMode.insensitive,
},
},
{
name: {
contains: query,
mode: Prisma.QueryMode.insensitive,
},
},
],
};
}
if (query && query.startsWith('claim:')) {
whereClause = {
organisationClaim: {
originalSubscriptionClaimId: {
contains: query.slice(6),
mode: Prisma.QueryMode.insensitive,
},
},
};
}
if (query && query.startsWith('org_')) {
whereClause = {
url: {
equals: query,
mode: Prisma.QueryMode.insensitive,
},
};
}
const [data, count] = await Promise.all([
prisma.organisation.findMany({
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
createdAt: 'desc',
},
select: {
id: true,
createdAt: true,
updatedAt: true,
name: true,
url: true,
customerId: true,
owner: {
select: {
id: true,
email: true,
name: true,
},
},
subscription: true,
},
}),
prisma.organisation.count({
where: whereClause,
}),
]);
return {
data,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultResponse<typeof data>;
};

Some files were not shown because too many files have changed in this diff Show More