feat: billing

This commit is contained in:
David Nguyen
2025-05-19 12:38:50 +10:00
parent 7abfc9e271
commit 2805478e0d
221 changed files with 8436 additions and 5847 deletions

View File

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

View File

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

View File

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