mirror of
https://github.com/documenso/documenso.git
synced 2025-11-15 17:21:41 +10:00
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:
20
packages/ee/server-only/stripe/create-team-customer.ts
Normal file
20
packages/ee/server-only/stripe/create-team-customer.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -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),
|
||||
),
|
||||
);
|
||||
};
|
||||
@ -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;
|
||||
|
||||
13
packages/ee/server-only/stripe/get-community-plan-prices.ts
Normal file
13
packages/ee/server-only/stripe/get-community-plan-prices.ts
Normal 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);
|
||||
};
|
||||
@ -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,
|
||||
|
||||
11
packages/ee/server-only/stripe/get-invoices.ts
Normal file
11
packages/ee/server-only/stripe/get-invoices.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
@ -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) => {
|
||||
|
||||
@ -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;
|
||||
|
||||
14
packages/ee/server-only/stripe/get-prices-by-plan.ts
Normal file
14
packages/ee/server-only/stripe/get-prices-by-plan.ts
Normal 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;
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
43
packages/ee/server-only/stripe/get-team-prices.ts
Normal file
43
packages/ee/server-only/stripe/get-team-prices.ts
Normal 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 ?? [],
|
||||
};
|
||||
};
|
||||
126
packages/ee/server-only/stripe/transfer-team-subscription.ts
Normal file
126
packages/ee/server-only/stripe/transfer-team-subscription.ts
Normal 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;
|
||||
};
|
||||
18
packages/ee/server-only/stripe/update-customer.ts
Normal file
18
packages/ee/server-only/stripe/update-customer.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
@ -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);
|
||||
};
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user