feat: add teams (#848)

## Description

Add support for teams which will allow users to collaborate on
documents.

Teams features allows users to:

- Create, manage and transfer teams
- Manage team members
- Manage team emails
- Manage a shared team inbox and documents

These changes do NOT include the following, which are planned for a
future release:

- Team templates
- Team API
- Search menu integration

## Testing Performed

- Added E2E tests for general team management
- Added E2E tests to validate document counts

## Checklist

- [X] I have tested these changes locally and they work as expected.
- [X] I have added/updated tests that prove the effectiveness of these
changes.
- [ ] I have updated the documentation to reflect these changes, if
applicable.
- [X] I have followed the project's coding style guidelines.
This commit is contained in:
David Nguyen
2024-02-06 16:16:10 +11:00
committed by GitHub
parent c6457e75e0
commit 0c339b78b6
200 changed files with 12916 additions and 968 deletions

View File

@ -1,17 +1,23 @@
import { APP_BASE_URL } from '@documenso/lib/constants/app';
import { FREE_PLAN_LIMITS } from './constants';
import { TLimitsResponseSchema, ZLimitsResponseSchema } from './schema';
import type { TLimitsResponseSchema } from './schema';
import { ZLimitsResponseSchema } from './schema';
export type GetLimitsOptions = {
headers?: Record<string, string>;
teamId?: number | null;
};
export const getLimits = async ({ headers }: GetLimitsOptions = {}) => {
export const getLimits = async ({ headers, teamId }: GetLimitsOptions = {}) => {
const requestHeaders = headers ?? {};
const url = new URL(`${APP_BASE_URL}/api/limits`);
if (teamId) {
requestHeaders['team-id'] = teamId.toString();
}
return fetch(url, {
headers: {
...requestHeaders,

View File

@ -1,10 +1,15 @@
import { TLimitsSchema } from './schema';
import type { TLimitsSchema } from './schema';
export const FREE_PLAN_LIMITS: TLimitsSchema = {
documents: 5,
recipients: 10,
};
export const TEAM_PLAN_LIMITS: TLimitsSchema = {
documents: Infinity,
recipients: Infinity,
};
export const SELFHOSTED_PLAN_LIMITS: TLimitsSchema = {
documents: Infinity,
recipients: Infinity,

View File

@ -1,10 +1,10 @@
import { NextApiRequest, NextApiResponse } from 'next';
import type { NextApiRequest, NextApiResponse } from 'next';
import { getToken } from 'next-auth/jwt';
import { match } from 'ts-pattern';
import { ERROR_CODES } from './errors';
import { TLimitsErrorResponseSchema, TLimitsResponseSchema } from './schema';
import type { TLimitsErrorResponseSchema, TLimitsResponseSchema } from './schema';
import { getServerLimits } from './server';
export const limitsHandler = async (
@ -14,7 +14,19 @@ export const limitsHandler = async (
try {
const token = await getToken({ req });
const limits = await getServerLimits({ email: token?.email });
const rawTeamId = req.headers['team-id'];
let teamId: number | null = null;
if (typeof rawTeamId === 'string' && !isNaN(parseInt(rawTeamId, 10))) {
teamId = parseInt(rawTeamId, 10);
}
if (!teamId && rawTeamId) {
throw new Error(ERROR_CODES.INVALID_TEAM_ID);
}
const limits = await getServerLimits({ email: token?.email, teamId });
return res.status(200).json(limits);
} catch (err) {

View File

@ -6,7 +6,7 @@ import { equals } from 'remeda';
import { getLimits } from '../client';
import { FREE_PLAN_LIMITS } from '../constants';
import { TLimitsResponseSchema } from '../schema';
import type { TLimitsResponseSchema } from '../schema';
export type LimitsContextValue = TLimitsResponseSchema;
@ -24,19 +24,22 @@ export const useLimits = () => {
export type LimitsProviderProps = {
initialValue?: LimitsContextValue;
teamId?: number;
children?: React.ReactNode;
};
export const LimitsProvider = ({ initialValue, children }: LimitsProviderProps) => {
const defaultValue: TLimitsResponseSchema = {
export const LimitsProvider = ({
initialValue = {
quota: FREE_PLAN_LIMITS,
remaining: FREE_PLAN_LIMITS,
};
const [limits, setLimits] = useState(() => initialValue ?? defaultValue);
},
teamId,
children,
}: LimitsProviderProps) => {
const [limits, setLimits] = useState(() => initialValue);
const refreshLimits = async () => {
const newLimits = await getLimits();
const newLimits = await getLimits({ teamId });
setLimits((oldLimits) => {
if (equals(oldLimits, newLimits)) {

View File

@ -3,16 +3,22 @@
import { headers } from 'next/headers';
import { getLimits } from '../client';
import type { LimitsContextValue } from './client';
import { LimitsProvider as ClientLimitsProvider } from './client';
export type LimitsProviderProps = {
children?: React.ReactNode;
teamId?: number;
};
export const LimitsProvider = async ({ children }: LimitsProviderProps) => {
export const LimitsProvider = async ({ children, teamId }: LimitsProviderProps) => {
const requestHeaders = Object.fromEntries(headers().entries());
const limits = await getLimits({ headers: requestHeaders });
const limits: LimitsContextValue = await getLimits({ headers: requestHeaders, teamId });
return <ClientLimitsProvider initialValue={limits}>{children}</ClientLimitsProvider>;
return (
<ClientLimitsProvider initialValue={limits} teamId={teamId}>
{children}
</ClientLimitsProvider>
);
};

View File

@ -1,22 +1,22 @@
import { DateTime } from 'luxon';
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { prisma } from '@documenso/prisma';
import { SubscriptionStatus } from '@documenso/prisma/client';
import { getPricesByType } from '../stripe/get-prices-by-type';
import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS } from './constants';
import { getPricesByPlan } from '../stripe/get-prices-by-plan';
import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS, TEAM_PLAN_LIMITS } from './constants';
import { ERROR_CODES } from './errors';
import { ZLimitsSchema } from './schema';
export type GetServerLimitsOptions = {
email?: string | null;
teamId?: number | null;
};
export const getServerLimits = async ({ email }: GetServerLimitsOptions) => {
const isBillingEnabled = await getFlag('app_billing');
if (!isBillingEnabled) {
export const getServerLimits = async ({ email, teamId }: GetServerLimitsOptions) => {
if (!IS_BILLING_ENABLED) {
return {
quota: SELFHOSTED_PLAN_LIMITS,
remaining: SELFHOSTED_PLAN_LIMITS,
@ -27,6 +27,14 @@ export const getServerLimits = async ({ email }: GetServerLimitsOptions) => {
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({
where: {
email,
@ -48,10 +56,10 @@ export const getServerLimits = async ({ email }: GetServerLimitsOptions) => {
);
if (activeSubscriptions.length > 0) {
const individualPrices = await getPricesByType('individual');
const communityPlanPrices = await getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY);
for (const subscription of activeSubscriptions) {
const price = individualPrices.find((price) => price.id === subscription.priceId);
const price = communityPlanPrices.find((price) => price.id === subscription.priceId);
if (!price || typeof price.product === 'string' || price.product.deleted) {
continue;
}
@ -71,6 +79,7 @@ export const getServerLimits = async ({ email }: GetServerLimitsOptions) => {
const documents = await prisma.document.count({
where: {
userId: user.id,
teamId: null,
createdAt: {
gte: DateTime.utc().startOf('month').toJSDate(),
},
@ -84,3 +93,50 @@ export const getServerLimits = async ({ email }: GetServerLimitsOptions) => {
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,
},
},
},
},
include: {
subscription: true,
},
});
if (!team) {
throw new Error('Team not found');
}
const { subscription } = team;
if (subscription && subscription.status === SubscriptionStatus.INACTIVE) {
return {
quota: {
documents: 0,
recipients: 0,
},
remaining: {
documents: 0,
recipients: 0,
},
};
}
return {
quota: structuredClone(TEAM_PLAN_LIMITS),
remaining: structuredClone(TEAM_PLAN_LIMITS),
};
};

View File

@ -0,0 +1,20 @@
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

@ -0,0 +1,22 @@
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,17 +1,21 @@
'use server';
import type Stripe from 'stripe';
import { stripe } from '@documenso/lib/server-only/stripe';
export type GetCheckoutSessionOptions = {
customerId: string;
priceId: string;
returnUrl: string;
subscriptionMetadata?: Stripe.Metadata;
};
export const getCheckoutSession = async ({
customerId,
priceId,
returnUrl,
subscriptionMetadata,
}: GetCheckoutSessionOptions) => {
'use server';
@ -26,6 +30,9 @@ export const getCheckoutSession = async ({
],
success_url: `${returnUrl}?success=true`,
cancel_url: `${returnUrl}?canceled=true`,
subscription_data: {
metadata: subscriptionMetadata,
},
});
return session.url;

View File

@ -0,0 +1,13 @@
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,15 +1,19 @@
import { STRIPE_CUSTOMER_TYPE } from '@documenso/lib/constants/billing';
import { stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
import type { User } from '@documenso/prisma/client';
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[0] ?? null;
return foundStripeCustomers.data.find((customer) => customer.metadata.type !== 'team') ?? null;
};
export const getStripeCustomerById = async (stripeCustomerId: string) => {
@ -51,6 +55,7 @@ export const getStripeCustomerByUser = async (user: User) => {
email: user.email,
metadata: {
userId: user.id,
type: STRIPE_CUSTOMER_TYPE.INDIVIDUAL,
},
});
}
@ -78,6 +83,14 @@ export const getStripeCustomerByUser = async (user: User) => {
};
};
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,

View File

@ -0,0 +1,11 @@
import { stripe } from '@documenso/lib/server-only/stripe';
export type GetInvoicesOptions = {
customerId: string;
};
export const getInvoices = async ({ customerId }: GetInvoicesOptions) => {
return await stripe.invoices.list({
customer: customerId,
});
};

View File

@ -4,7 +4,7 @@ import { stripe } from '@documenso/lib/server-only/stripe';
export type GetPortalSessionOptions = {
customerId: string;
returnUrl: string;
returnUrl?: string;
};
export const getPortalSession = async ({ customerId, returnUrl }: GetPortalSessionOptions) => {

View File

@ -9,12 +9,12 @@ export type PriceIntervals = Record<Stripe.Price.Recurring.Interval, PriceWithPr
export type GetPricesByIntervalOptions = {
/**
* Filter products by their meta 'type' attribute.
* Filter products by their meta 'plan' attribute.
*/
type?: 'individual';
plan?: 'community';
};
export const getPricesByInterval = async ({ type }: GetPricesByIntervalOptions = {}) => {
export const getPricesByInterval = async ({ plan }: GetPricesByIntervalOptions = {}) => {
let { data: prices } = await stripe.prices.search({
query: `active:'true' type:'recurring'`,
expand: ['data.product'],
@ -26,7 +26,7 @@ export const getPricesByInterval = async ({ type }: GetPricesByIntervalOptions =
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const product = price.product as Stripe.Product;
const filter = !type || product.metadata?.type === type;
const filter = !plan || product.metadata?.plan === plan;
// Filter out prices for products that are not active.
return product.active && filter;

View File

@ -0,0 +1,14 @@
import type { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { stripe } from '@documenso/lib/server-only/stripe';
export const getPricesByPlan = async (
plan: (typeof STRIPE_PLAN_TYPE)[keyof typeof STRIPE_PLAN_TYPE],
) => {
const { data: prices } = await stripe.prices.search({
query: `metadata['plan']:'${plan}' type:'recurring'`,
expand: ['data.product'],
limit: 100,
});
return prices;
};

View File

@ -1,11 +0,0 @@
import { stripe } from '@documenso/lib/server-only/stripe';
export const getPricesByType = async (type: 'individual') => {
const { data: prices } = await stripe.prices.search({
query: `metadata['type']:'${type}' type:'recurring'`,
expand: ['data.product'],
limit: 100,
});
return prices;
};

View File

@ -0,0 +1,43 @@
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', '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

@ -0,0 +1,126 @@
import type Stripe from 'stripe';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { stripe } from '@documenso/lib/server-only/stripe';
import { subscriptionsContainsActiveCommunityPlan } from '@documenso/lib/utils/billing';
import { prisma } from '@documenso/prisma';
import { type Subscription, type Team, type User } from '@documenso/prisma/client';
import { deleteCustomerPaymentMethods } from './delete-customer-payment-methods';
import { getCommunityPlanPriceIds } from './get-community-plan-prices';
import { getTeamPrices } from './get-team-prices';
type TransferStripeSubscriptionOptions = {
/**
* The user to transfer the subscription to.
*/
user: User & { Subscription: 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 community plan).
*/
export const transferTeamSubscription = async ({
user,
team,
clearPaymentMethods,
}: TransferStripeSubscriptionOptions) => {
const teamCustomerId = team.customerId;
if (!teamCustomerId) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Missing customer ID.');
}
const [communityPlanIds, teamSeatPrices] = await Promise.all([
getCommunityPlanPriceIds(),
getTeamPrices(),
]);
const teamSubscriptionRequired = !subscriptionsContainsActiveCommunityPlan(
user.Subscription,
communityPlanIds,
);
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

@ -0,0 +1,18 @@
import { stripe } from '@documenso/lib/server-only/stripe';
type UpdateCustomerOptions = {
customerId: string;
name?: string;
email?: string;
};
export const updateCustomer = async ({ customerId, name, email }: UpdateCustomerOptions) => {
if (!name && !email) {
return;
}
return await stripe.customers.update(customerId, {
name,
email,
});
};

View File

@ -0,0 +1,44 @@
import type Stripe from 'stripe';
import { stripe } from '@documenso/lib/server-only/stripe';
export type UpdateSubscriptionItemQuantityOptions = {
subscriptionId: string;
quantity: number;
priceId: string;
};
export const updateSubscriptionItemQuantity = async ({
subscriptionId,
quantity,
priceId,
}: UpdateSubscriptionItemQuantityOptions) => {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const items = subscription.items.data.filter((item) => item.price.id === priceId);
if (items.length !== 1) {
throw new Error('Subscription does not contain required item');
}
const hasYearlyItem = items.find((item) => item.price.recurring?.interval === 'year');
const oldQuantity = items[0].quantity;
if (oldQuantity === quantity) {
return;
}
const subscriptionUpdatePayload: Stripe.SubscriptionUpdateParams = {
items: items.map((item) => ({
id: item.id,
quantity,
})),
};
// Only invoice immediately when changing the quantity of yearly item.
if (hasYearlyItem) {
subscriptionUpdatePayload.proration_behavior = 'always_invoice';
}
await stripe.subscriptions.update(subscriptionId, subscriptionUpdatePayload);
};

View File

@ -3,8 +3,10 @@ import type { NextApiRequest, NextApiResponse } from 'next';
import { buffer } from 'micro';
import { match } from 'ts-pattern';
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 { getFlag } from '@documenso/lib/universal/get-feature-flag';
import { prisma } from '@documenso/prisma';
@ -84,14 +86,9 @@ export const stripeWebhookHandler = async (
},
});
if (!result?.id) {
return res.status(500).json({
success: false,
message: 'User not found',
});
if (result?.id) {
userId = result.id;
}
userId = result.id;
}
const subscriptionId =
@ -99,7 +96,7 @@ export const stripeWebhookHandler = async (
? session.subscription
: session.subscription?.id;
if (!subscriptionId || Number.isNaN(userId)) {
if (!subscriptionId) {
return res.status(500).json({
success: false,
message: 'Invalid session',
@ -108,6 +105,24 @@ export const stripeWebhookHandler = async (
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 res.status(200).json({
success: true,
message: 'Webhook received',
});
}
// Validate user ID.
if (!userId || Number.isNaN(userId)) {
return res.status(500).json({
success: false,
message: 'Invalid session or missing user ID',
});
}
await onSubscriptionUpdated({ userId, subscription });
return res.status(200).json({
@ -124,6 +139,28 @@ export const stripeWebhookHandler = async (
? 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 res.status(500).json({
success: false,
message: 'No team associated with subscription found',
});
}
await onSubscriptionUpdated({ teamId: team.id, subscription });
return res.status(200).json({
success: true,
message: 'Webhook received',
});
}
const result = await prisma.user.findFirst({
select: {
id: true,
@ -182,6 +219,28 @@ export const stripeWebhookHandler = async (
});
}
if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) {
const team = await prisma.team.findFirst({
where: {
customerId,
},
});
if (!team) {
return res.status(500).json({
success: false,
message: 'No team associated with subscription found',
});
}
await onSubscriptionUpdated({ teamId: team.id, subscription });
return res.status(200).json({
success: true,
message: 'Webhook received',
});
}
const result = await prisma.user.findFirst({
select: {
id: true,
@ -233,6 +292,28 @@ export const stripeWebhookHandler = async (
});
}
if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) {
const team = await prisma.team.findFirst({
where: {
customerId,
},
});
if (!team) {
return res.status(500).json({
success: false,
message: 'No team associated with subscription found',
});
}
await onSubscriptionUpdated({ teamId: team.id, subscription });
return res.status(200).json({
success: true,
message: 'Webhook received',
});
}
const result = await prisma.user.findFirst({
select: {
id: true,
@ -282,3 +363,21 @@ export const stripeWebhookHandler = async (
});
}
};
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

@ -2,23 +2,40 @@ import { match } from 'ts-pattern';
import type { Stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
import type { Prisma } from '@documenso/prisma/client';
import { SubscriptionStatus } from '@documenso/prisma/client';
export type OnSubscriptionUpdatedOptions = {
userId: number;
userId?: number;
teamId?: number;
subscription: Stripe.Subscription;
};
export const onSubscriptionUpdated = async ({
userId,
teamId,
subscription,
}: OnSubscriptionUpdatedOptions) => {
await prisma.subscription.upsert(
mapStripeSubscriptionToPrismaUpsertAction(subscription, userId, teamId),
);
};
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.');
}
const status = match(subscription.status)
.with('active', () => SubscriptionStatus.ACTIVE)
.with('past_due', () => SubscriptionStatus.PAST_DUE)
.otherwise(() => SubscriptionStatus.INACTIVE);
await prisma.subscription.upsert({
return {
where: {
planId: subscription.id,
},
@ -27,7 +44,8 @@ export const onSubscriptionUpdated = async ({
planId: subscription.id,
priceId: subscription.items.data[0].price.id,
periodEnd: new Date(subscription.current_period_end * 1000),
userId,
userId: userId ?? null,
teamId: teamId ?? null,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
update: {
@ -37,5 +55,5 @@ export const onSubscriptionUpdated = async ({
periodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
});
};
};