feat: add organisations (#1820)

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

View File

@ -1,20 +1,21 @@
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 ({
export const createCheckoutSession = async ({
customerId,
priceId,
returnUrl,
subscriptionMetadata,
}: GetCheckoutSessionOptions) => {
}: CreateCheckoutSessionOptions) => {
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
@ -31,5 +32,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,20 +0,0 @@
import { STRIPE_CUSTOMER_TYPE } from '@documenso/lib/constants/billing';
import { stripe } from '@documenso/lib/server-only/stripe';
type CreateTeamCustomerOptions = {
name: string;
email: string;
};
/**
* Create a Stripe customer for a given team.
*/
export const createTeamCustomer = async ({ name, email }: CreateTeamCustomerOptions) => {
return await stripe.customers.create({
name,
email,
metadata: {
type: STRIPE_CUSTOMER_TYPE.TEAM,
},
});
};

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,85 +9,3 @@ export const getStripeCustomerById = async (stripeCustomerId: string) => {
return null;
}
};
/**
* 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,84 @@
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;
}
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,
roles: 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,128 +0,0 @@
import { type Subscription, type Team, type User } 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 { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
import { prisma } from '@documenso/prisma';
import { deleteCustomerPaymentMethods } from './delete-customer-payment-methods';
import { getTeamPrices } from './get-team-prices';
import { getTeamRelatedPriceIds } from './get-team-related-prices';
type TransferStripeSubscriptionOptions = {
/**
* The user to transfer the subscription to.
*/
user: User & { subscriptions: Subscription[] };
/**
* The team the subscription is associated with.
*/
team: Team & { subscription?: Subscription | null };
/**
* Whether to clear any current payment methods attached to the team.
*/
clearPaymentMethods: boolean;
};
/**
* Transfer the Stripe Team seats subscription from one user to another.
*
* Will create a new subscription for the new owner and cancel the old one.
*
* Returns the subscription that should be associated with the team, null if
* no subscription is needed (for early adopter plan).
*/
export const transferTeamSubscription = async ({
user,
team,
clearPaymentMethods,
}: TransferStripeSubscriptionOptions) => {
const teamCustomerId = team.customerId;
if (!teamCustomerId) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Missing customer ID.',
});
}
const [teamRelatedPlanPriceIds, teamSeatPrices] = await Promise.all([
getTeamRelatedPriceIds(),
getTeamPrices(),
]);
const teamSubscriptionRequired = !subscriptionsContainsActivePlan(
user.subscriptions,
teamRelatedPlanPriceIds,
);
let teamSubscription: Stripe.Subscription | null = null;
if (team.subscription) {
teamSubscription = await stripe.subscriptions.retrieve(team.subscription.planId);
if (!teamSubscription) {
throw new Error('Could not find the current subscription.');
}
if (clearPaymentMethods) {
await deleteCustomerPaymentMethods({ customerId: teamCustomerId });
}
}
await stripe.customers.update(teamCustomerId, {
name: user.name ?? team.name,
email: user.email,
});
// If team subscription is required and the team does not have a subscription, create one.
if (teamSubscriptionRequired && !teamSubscription) {
const numberOfSeats = await prisma.teamMember.count({
where: {
teamId: team.id,
},
});
const teamSeatPriceId = teamSeatPrices.monthly.priceId;
teamSubscription = await stripe.subscriptions.create({
customer: teamCustomerId,
items: [
{
price: teamSeatPriceId,
quantity: numberOfSeats,
},
],
metadata: {
teamId: team.id.toString(),
},
});
}
// If no team subscription is required, cancel the current team subscription if it exists.
if (!teamSubscriptionRequired && teamSubscription) {
try {
// Set the quantity to 0 so we can refund/charge the old Stripe customer the prorated amount.
await stripe.subscriptions.update(teamSubscription.id, {
items: teamSubscription.items.data.map((item) => ({
id: item.id,
quantity: 0,
})),
});
await stripe.subscriptions.cancel(teamSubscription.id, {
invoice_now: true,
prorate: false,
});
} catch (e) {
// Do not error out since we can't easily undo the transfer.
// Todo: Teams - Alert us.
}
return null;
}
return teamSubscription;
};

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,214 @@
import { OrganisationType, SubscriptionStatus } from '@prisma/client';
import { match } from 'ts-pattern';
import {
createOrganisation,
createOrganisationClaimUpsertData,
} from '@documenso/lib/server-only/organisation/create-organisation';
import { type Stripe } from '@documenso/lib/server-only/stripe';
import type {
InternalClaim,
StripeOrganisationCreateMetadata,
} from '@documenso/lib/types/subscription';
import {
INTERNAL_CLAIM_ID,
ZStripeOrganisationCreateMetadataSchema,
} from '@documenso/lib/types/subscription';
import { prisma } from '@documenso/prisma';
import { extractStripeClaim } from './on-subscription-updated';
export type OnSubscriptionCreatedOptions = {
subscription: Stripe.Subscription;
};
type StripeWebhookResponse = {
success: boolean;
message: string;
};
/**
* Todo: We might want to pull this into a job so we can do steps. Since if organisation creation passes but
* fails after this would be automatically rerun by Stripe, which means duplicate organisations can be
* potentially created.
*/
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 subscriptionItem = subscription.items.data[0];
const claim = await extractStripeClaim(subscriptionItem.price);
// Todo: logging
if (!claim) {
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 },
);
}
const organisationCreateData = subscription.metadata?.organisationCreateData;
// A new subscription can be for an existing organisation or a new one.
const organisationId = organisationCreateData
? await handleOrganisationCreate({
customerId,
claim,
unknownCreateData: organisationCreateData,
})
: await handleOrganisationUpdate({
customerId,
claim,
});
const status = match(subscription.status)
.with('active', () => SubscriptionStatus.ACTIVE)
.with('past_due', () => SubscriptionStatus.PAST_DUE)
.otherwise(() => SubscriptionStatus.INACTIVE);
await prisma.subscription.create({
data: {
organisationId,
status,
customerId,
planId: subscription.id,
priceId: subscription.items.data[0].price.id,
periodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
});
};
type HandleOrganisationCreateOptions = {
customerId: string;
claim: InternalClaim;
unknownCreateData: string;
};
/**
* Handles the creation of an organisation.
*/
const handleOrganisationCreate = async ({
customerId,
claim,
unknownCreateData,
}: HandleOrganisationCreateOptions) => {
let organisationCreateFlowData: StripeOrganisationCreateMetadata | null = null;
const parseResult = ZStripeOrganisationCreateMetadataSchema.safeParse(
JSON.parse(unknownCreateData),
);
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,
type: OrganisationType.ORGANISATION,
customerId,
claim,
});
return createdOrganisation.id;
};
type HandleOrganisationUpdateOptions = {
customerId: string;
claim: InternalClaim;
};
/**
* Handles the updating an exist organisation claims.
*/
const handleOrganisationUpdate = async ({ customerId, claim }: HandleOrganisationUpdateOptions) => {
const organisation = await prisma.organisation.findFirst({
where: {
customerId,
},
include: {
subscription: true,
organisationClaim: 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 },
);
}
let newOrganisationType: OrganisationType = OrganisationType.ORGANISATION;
// Keep the organisation as personal if the claim is for an individual.
if (
organisation.type === OrganisationType.PERSONAL &&
claim.id === INTERNAL_CLAIM_ID.INDIVIDUAL
) {
newOrganisationType = OrganisationType.PERSONAL;
}
await prisma.organisation.update({
where: {
id: organisation.id,
},
data: {
type: newOrganisationType,
organisationClaim: {
update: {
originalSubscriptionClaimId: claim.id,
...createOrganisationClaimUpsertData(claim),
},
},
},
});
return organisation.id;
};

View File

@ -1,59 +1,163 @@
import type { Prisma } from '@prisma/client';
import { SubscriptionStatus } from '@prisma/client';
import { match } from 'ts-pattern';
import type { Stripe } from '@documenso/lib/server-only/stripe';
import { createOrganisationClaimUpsertData } from '@documenso/lib/server-only/organisation/create-organisation';
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;
};
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,
subscription: true,
},
});
if (!organisation) {
throw Response.json(
{
success: false,
message: `Organisation not found`,
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
if (organisation.subscription?.planId !== subscription.id) {
console.error('[WARNING]: Organisation has two subscriptions');
}
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);
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,
},
};
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,
},
});
// 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),
},
});
}
});
};
/**
* 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;
};