feat: wip

This commit is contained in:
David Nguyen
2023-12-27 13:04:24 +11:00
parent f7cf33c61b
commit 9d626473c8
140 changed files with 9604 additions and 536 deletions

View File

@ -71,6 +71,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(),
},

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

@ -78,6 +78,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,23 @@
import { stripe } from '@documenso/lib/server-only/stripe';
export type GetTeamInvoicesOptions = {
teamId: number;
};
export const getTeamInvoices = async ({ teamId }: GetTeamInvoicesOptions) => {
const teamSubscriptions = await stripe.subscriptions.search({
limit: 100,
query: `metadata["teamId"]:"${teamId}"`,
});
const subscriptionIds = teamSubscriptions.data.map((subscription) => subscription.id);
if (subscriptionIds.length === 0) {
return null;
}
return await stripe.invoices.search({
query: subscriptionIds.map((id) => `subscription:"${id}"`).join(' OR '),
limit: 100,
});
};

View File

@ -0,0 +1,96 @@
import type Stripe from 'stripe';
import { stripe } from '@documenso/lib/server-only/stripe';
import {
getTeamSeatPriceId,
isSomeSubscriptionsActiveAndCommunityPlan,
} from '@documenso/lib/utils/billing';
import { prisma } from '@documenso/prisma';
import type { Subscription, Team, User } from '@documenso/prisma/client';
import { getStripeCustomerByUser } from './get-customer';
type TransferStripeSubscriptionOptions = {
user: User & { Subscription: Subscription[] };
team: Team;
};
/**
* 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 new subscription, null if no subscription is needed (for community plan).
*/
export const transferTeamSubscription = async ({
user,
team,
}: TransferStripeSubscriptionOptions) => {
const teamSeatPriceId = getTeamSeatPriceId();
const { stripeCustomer } = await getStripeCustomerByUser(user);
const newOwnerHasCommunityPlan = isSomeSubscriptionsActiveAndCommunityPlan(user.Subscription);
const currentTeamSubscriptionId = team.subscriptionId;
let oldSubscription: Stripe.Subscription | null = null;
let newSubscription: Stripe.Subscription | null = null;
if (currentTeamSubscriptionId) {
oldSubscription = await stripe.subscriptions.retrieve(currentTeamSubscriptionId);
}
const numberOfSeats = await prisma.teamMember.count({
where: {
teamId: team.id,
},
});
if (!newOwnerHasCommunityPlan) {
let stripeCreateSubscriptionPayload: Stripe.SubscriptionCreateParams = {
customer: stripeCustomer.id,
items: [
{
price: teamSeatPriceId,
quantity: numberOfSeats,
},
],
metadata: {
teamId: team.id.toString(),
},
};
// If no payment method is attached to the new owner Stripe customer account, send an
// invoice instead.
if (!stripeCustomer.invoice_settings.default_payment_method) {
stripeCreateSubscriptionPayload = {
...stripeCreateSubscriptionPayload,
collection_method: 'send_invoice',
days_until_due: 7,
};
}
newSubscription = await stripe.subscriptions.create(stripeCreateSubscriptionPayload);
}
if (oldSubscription) {
try {
// Set the quantity to 0 so we can refund/charge the old Stripe customer the prorated amount.
await stripe.subscriptions.update(oldSubscription.id, {
items: oldSubscription.items.data.map((item) => ({
id: item.id,
quantity: 0,
})),
});
await stripe.subscriptions.cancel(oldSubscription.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 newSubscription;
};

View File

@ -0,0 +1,28 @@
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 === 0) {
return;
}
await stripe.subscriptions.update(subscriptionId, {
items: items.map((item) => ({
id: item.id,
quantity,
})),
});
};

View File

@ -5,6 +5,7 @@ import { match } from 'ts-pattern';
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';
@ -110,6 +111,12 @@ export const stripeWebhookHandler = async (
await onSubscriptionUpdated({ userId, subscription });
if (
subscription.items.data[0].price.id === process.env.NEXT_PUBLIC_STRIPE_TEAM_SEAT_PRICE_ID
) {
await handleTeamSeatCheckout({ subscription });
}
return res.status(200).json({
success: true,
message: 'Webhook received',
@ -282,3 +289,21 @@ export const stripeWebhookHandler = async (
});
}
};
export type HandleTeamSeatCheckoutOptions = {
subscription: Stripe.Subscription;
};
const handleTeamSeatCheckout = async ({ subscription }: HandleTeamSeatCheckoutOptions) => {
if (subscription.metadata?.pendingTeamId === undefined) {
return;
}
const pendingTeamId = Number(subscription.metadata.pendingTeamId);
if (Number.isNaN(pendingTeamId)) {
throw new Error('Invalid pending team ID');
}
await createTeamFromPendingTeam({ pendingTeamId, subscriptionId: subscription.id });
};

View File

@ -13,12 +13,19 @@ export const onSubscriptionUpdated = async ({
userId,
subscription,
}: OnSubscriptionUpdatedOptions) => {
await prisma.subscription.upsert(mapStripeSubscriptionToPrismaUpsertAction(userId, subscription));
};
export const mapStripeSubscriptionToPrismaUpsertAction = (
userId: number,
subscription: Stripe.Subscription,
) => {
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,
},
@ -37,5 +44,5 @@ export const onSubscriptionUpdated = async ({
periodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
});
};
};