Merge branch 'main' into refactor-forms

This commit is contained in:
Lucas Smith
2023-12-16 13:26:33 +11:00
committed by GitHub
48 changed files with 412 additions and 401 deletions

View File

@ -1,10 +1,10 @@
import { DateTime } from 'luxon';
import { stripe } from '@documenso/lib/server-only/stripe';
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
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 { ERROR_CODES } from './errors';
import { ZLimitsSchema } from './schema';
@ -43,23 +43,29 @@ export const getServerLimits = async ({ email }: GetServerLimitsOptions) => {
let quota = structuredClone(FREE_PLAN_LIMITS);
let remaining = structuredClone(FREE_PLAN_LIMITS);
// Since we store details and allow for past due plans we need to check if the subscription is active.
if (user.Subscription?.status !== SubscriptionStatus.INACTIVE && user.Subscription?.priceId) {
const { product } = await stripe.prices
.retrieve(user.Subscription.priceId, {
expand: ['product'],
})
.catch((err) => {
console.error(err);
throw err;
});
const activeSubscriptions = user.Subscription.filter(
({ status }) => status === SubscriptionStatus.ACTIVE,
);
if (typeof product === 'string') {
throw new Error(ERROR_CODES.SUBSCRIPTION_FETCH_FAILED);
if (activeSubscriptions.length > 0) {
const individualPrices = await getPricesByType('individual');
for (const subscription of activeSubscriptions) {
const price = individualPrices.find((price) => price.id === subscription.priceId);
if (!price || typeof price.product === 'string' || price.product.deleted) {
continue;
}
const currentQuota = ZLimitsSchema.parse(
'metadata' in price.product ? price.product.metadata : {},
);
// Use the subscription with the highest quota.
if (currentQuota.documents > quota.documents && currentQuota.recipients > quota.recipients) {
quota = currentQuota;
remaining = structuredClone(quota);
}
}
quota = ZLimitsSchema.parse('metadata' in product ? product.metadata : {});
remaining = structuredClone(quota);
}
const documents = await prisma.document.count({

View File

@ -1,31 +0,0 @@
import { stripe } from '@documenso/lib/server-only/stripe';
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
import { prisma } from '@documenso/prisma';
import { User } from '@documenso/prisma/client';
export type CreateCustomerOptions = {
user: User;
};
export const createCustomer = async ({ user }: CreateCustomerOptions) => {
const existingSubscription = await getSubscriptionByUserId({ userId: user.id });
if (existingSubscription) {
throw new Error('User already has a subscription');
}
const customer = await stripe.customers.create({
name: user.name ?? undefined,
email: user.email,
metadata: {
userId: user.id,
},
});
return await prisma.subscription.create({
data: {
userId: user.id,
customerId: customer.id,
},
});
};

View File

@ -1,4 +1,8 @@
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';
export const getStripeCustomerByEmail = async (email: string) => {
const foundStripeCustomers = await stripe.customers.list({
@ -17,3 +21,74 @@ 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: User) => {
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,
},
});
}
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,
};
};
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,4 +1,4 @@
import Stripe from 'stripe';
import type Stripe from 'stripe';
import { stripe } from '@documenso/lib/server-only/stripe';
@ -7,7 +7,14 @@ type PriceWithProduct = Stripe.Price & { product: Stripe.Product };
export type PriceIntervals = Record<Stripe.Price.Recurring.Interval, PriceWithProduct[]>;
export const getPricesByInterval = async () => {
export type GetPricesByIntervalOptions = {
/**
* Filter products by their meta 'type' attribute.
*/
type?: 'individual';
};
export const getPricesByInterval = async ({ type }: GetPricesByIntervalOptions = {}) => {
let { data: prices } = await stripe.prices.search({
query: `active:'true' type:'recurring'`,
expand: ['data.product'],
@ -19,8 +26,10 @@ export const getPricesByInterval = async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const product = price.product as Stripe.Product;
const filter = !type || product.metadata?.type === type;
// Filter out prices for products that are not active.
return product.active;
return product.active && filter;
});
const intervals: PriceIntervals = {

View File

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

@ -75,23 +75,23 @@ export const stripeWebhookHandler = async (
// Finally, attempt to get the user ID from the subscription within the database.
if (!userId && customerId) {
const result = await prisma.subscription.findFirst({
const result = await prisma.user.findFirst({
select: {
userId: true,
id: true,
},
where: {
customerId,
},
});
if (!result?.userId) {
if (!result?.id) {
return res.status(500).json({
success: false,
message: 'User not found',
});
}
userId = result.userId;
userId = result.id;
}
const subscriptionId =
@ -124,23 +124,23 @@ export const stripeWebhookHandler = async (
? subscription.customer
: subscription.customer.id;
const result = await prisma.subscription.findFirst({
const result = await prisma.user.findFirst({
select: {
userId: true,
id: true,
},
where: {
customerId,
},
});
if (!result?.userId) {
if (!result?.id) {
return res.status(500).json({
success: false,
message: 'User not found',
});
}
await onSubscriptionUpdated({ userId: result.userId, subscription });
await onSubscriptionUpdated({ userId: result.id, subscription });
return res.status(200).json({
success: true,
@ -182,23 +182,23 @@ export const stripeWebhookHandler = async (
});
}
const result = await prisma.subscription.findFirst({
const result = await prisma.user.findFirst({
select: {
userId: true,
id: true,
},
where: {
customerId,
},
});
if (!result?.userId) {
if (!result?.id) {
return res.status(500).json({
success: false,
message: 'User not found',
});
}
await onSubscriptionUpdated({ userId: result.userId, subscription });
await onSubscriptionUpdated({ userId: result.id, subscription });
return res.status(200).json({
success: true,
@ -233,23 +233,23 @@ export const stripeWebhookHandler = async (
});
}
const result = await prisma.subscription.findFirst({
const result = await prisma.user.findFirst({
select: {
userId: true,
id: true,
},
where: {
customerId,
},
});
if (!result?.userId) {
if (!result?.id) {
return res.status(500).json({
success: false,
message: 'User not found',
});
}
await onSubscriptionUpdated({ userId: result.userId, subscription });
await onSubscriptionUpdated({ userId: result.id, subscription });
return res.status(200).json({
success: true,

View File

@ -1,4 +1,4 @@
import { Stripe } from '@documenso/lib/server-only/stripe';
import type { Stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
import { SubscriptionStatus } from '@documenso/prisma/client';
@ -7,12 +7,9 @@ export type OnSubscriptionDeletedOptions = {
};
export const onSubscriptionDeleted = async ({ subscription }: OnSubscriptionDeletedOptions) => {
const customerId =
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer?.id;
await prisma.subscription.update({
where: {
customerId,
planId: subscription.id,
},
data: {
status: SubscriptionStatus.INACTIVE,

View File

@ -1,6 +1,6 @@
import { match } from 'ts-pattern';
import { Stripe } from '@documenso/lib/server-only/stripe';
import type { Stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
import { SubscriptionStatus } from '@documenso/prisma/client';
@ -13,9 +13,6 @@ export const onSubscriptionUpdated = async ({
userId,
subscription,
}: OnSubscriptionUpdatedOptions) => {
const customerId =
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer?.id;
const status = match(subscription.status)
.with('active', () => SubscriptionStatus.ACTIVE)
.with('past_due', () => SubscriptionStatus.PAST_DUE)
@ -23,22 +20,22 @@ export const onSubscriptionUpdated = async ({
await prisma.subscription.upsert({
where: {
customerId,
planId: subscription.id,
},
create: {
customerId,
status: status,
planId: subscription.id,
priceId: subscription.items.data[0].price.id,
periodEnd: new Date(subscription.current_period_end * 1000),
userId,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
update: {
customerId,
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,
},
});
};

View File

@ -162,5 +162,17 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
return session;
},
async signIn({ user }) {
// We do this to stop OAuth providers from creating an account
// when signups are disabled
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
const userData = await getUserByEmail({ email: user.email! });
return !!userData;
}
return true;
},
},
};

View File

@ -9,7 +9,9 @@ export const getUsersWithSubscriptionsCount = async () => {
return await prisma.user.count({
where: {
Subscription: {
status: SubscriptionStatus.ACTIVE,
some: {
status: SubscriptionStatus.ACTIVE,
},
},
},
});

View File

@ -1,15 +0,0 @@
'use server';
import { prisma } from '@documenso/prisma';
export type GetSubscriptionByUserIdOptions = {
userId: number;
};
export const getSubscriptionByUserId = async ({ userId }: GetSubscriptionByUserIdOptions) => {
return await prisma.subscription.findFirst({
where: {
userId,
},
});
};

View File

@ -0,0 +1,15 @@
'use server';
import { prisma } from '@documenso/prisma';
export type GetSubscriptionsByUserIdOptions = {
userId: number;
};
export const getSubscriptionsByUserId = async ({ userId }: GetSubscriptionsByUserIdOptions) => {
return await prisma.subscription.findMany({
where: {
userId,
},
});
};

View File

@ -1,9 +1,11 @@
import { hash } from 'bcrypt';
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
import { prisma } from '@documenso/prisma';
import { IdentityProvider } from '@documenso/prisma/client';
import { SALT_ROUNDS } from '../../constants/auth';
import { getFlag } from '../../universal/get-feature-flag';
export interface CreateUserOptions {
name: string;
@ -13,6 +15,8 @@ export interface CreateUserOptions {
}
export const createUser = async ({ name, email, password, signature }: CreateUserOptions) => {
const isBillingEnabled = await getFlag('app_billing');
const hashedPassword = await hash(password, SALT_ROUNDS);
const userExists = await prisma.user.findFirst({
@ -25,7 +29,7 @@ export const createUser = async ({ name, email, password, signature }: CreateUse
throw new Error('User already exists');
}
return await prisma.user.create({
let user = await prisma.user.create({
data: {
name,
email: email.toLowerCase(),
@ -34,4 +38,15 @@ export const createUser = async ({ name, email, password, signature }: CreateUse
identityProvider: IdentityProvider.DOCUMENSO,
},
});
if (isBillingEnabled) {
try {
const stripeSession = await getStripeCustomerByUser(user);
user = stripeSession.user;
} catch (e) {
console.error(e);
}
}
return user;
};

View File

@ -0,0 +1,31 @@
/*
Warnings:
- A unique constraint covering the columns `[planId]` on the table `Subscription` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[customerId]` on the table `User` will be added. If there are existing duplicate values, this will fail.
- Made the column `planId` on table `Subscription` required. This step will fail if there are existing NULL values in that column.
- Made the column `priceId` on table `Subscription` required. This step will fail if there are existing NULL values in that column.
*/
-- Custom migration statement
DELETE FROM "Subscription" WHERE "planId" IS NULL OR "priceId" IS NULL;
-- DropIndex
DROP INDEX "Subscription_customerId_key";
-- DropIndex
DROP INDEX "Subscription_userId_key";
-- AlterTable
ALTER TABLE "Subscription" ALTER COLUMN "planId" SET NOT NULL,
ALTER COLUMN "priceId" SET NOT NULL;
-- AlterTable
ALTER TABLE "User" ADD COLUMN "customerId" TEXT;
ALTER TABLE "Subscription" DROP COLUMN "customerId";
-- CreateIndex
CREATE UNIQUE INDEX "Subscription_planId_key" ON "Subscription"("planId");
-- CreateIndex
CREATE UNIQUE INDEX "User_customerId_key" ON "User"("customerId");

View File

@ -21,6 +21,7 @@ enum Role {
model User {
id Int @id @default(autoincrement())
name String?
customerId String? @unique
email String @unique
emailVerified DateTime?
password String?
@ -34,7 +35,7 @@ model User {
accounts Account[]
sessions Session[]
Document Document[]
Subscription Subscription?
Subscription Subscription[]
PasswordResetToken PasswordResetToken[]
twoFactorSecret String?
twoFactorEnabled Boolean @default(false)
@ -72,18 +73,16 @@ enum SubscriptionStatus {
model Subscription {
id Int @id @default(autoincrement())
status SubscriptionStatus @default(INACTIVE)
planId String?
priceId String?
customerId String
planId String @unique
priceId String
periodEnd DateTime?
userId Int @unique
userId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
cancelAtPeriodEnd Boolean @default(false)
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([customerId])
@@index([userId])
}

View File

@ -11,6 +11,13 @@ import { ZSignUpMutationSchema, ZVerifyPasswordMutationSchema } from './schema';
export const authRouter = router({
signup: procedure.input(ZSignUpMutationSchema).mutation(async ({ input }) => {
try {
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Signups are disabled.',
});
}
const { name, email, password, signature } = input;
const user = await createUser({ name, email, password, signature });

View File

@ -10,8 +10,6 @@ declare namespace NodeJS {
NEXT_PRIVATE_ENCRYPTION_KEY: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string;
NEXT_PUBLIC_STRIPE_FREE_PLAN_ID?: string;
NEXT_PRIVATE_STRIPE_API_KEY: string;
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
@ -55,6 +53,8 @@ declare namespace NodeJS {
NEXT_PRIVATE_SMTP_FROM_NAME?: string;
NEXT_PRIVATE_SMTP_FROM_ADDRESS?: string;
NEXT_PUBLIC_DISABLE_SIGNUP?: string;
/**
* Vercel environment variables
*/

View File

@ -15,7 +15,7 @@ const ToastViewport = React.forwardRef<
<ToastPrimitives.Viewport
ref={ref}
className={cn(
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
'fixed top-0 z-[9999] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
className,
)}
{...props}