From 45fb639fcfda59511a089b13484267d88d100ed4 Mon Sep 17 00:00:00 2001 From: Michael Dausmann Date: Sun, 19 Mar 2023 15:48:08 +1100 Subject: [PATCH] user.email account.plan_name - cleanup context and service construction - config for stripe callback - initial plan --- .env_example | 1 + README.md | 15 +++-- lib/services/notes.service.ts | 20 +++--- lib/services/user.account.service.ts | 61 ++++++++++--------- nuxt.config.ts | 4 +- prisma/schema.prisma | 2 + server/api/trpc/[trpc].ts | 2 +- server/routes/create-checkout-session.post.ts | 24 ++++---- server/routes/webhook.post.ts | 9 ++- server/trpc/context.ts | 17 ++---- server/trpc/routers/notes.router.ts | 4 +- server/trpc/routers/user.account.router.ts | 8 +-- 12 files changed, 81 insertions(+), 86 deletions(-) diff --git a/.env_example b/.env_example index 64174eb..6dd1777 100644 --- a/.env_example +++ b/.env_example @@ -3,6 +3,7 @@ SUPABASE_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxx.xxxxxx-xxxxx STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx STRIPE_ENDPOINT_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +STRIPE_CALLBACK_URL=http://localhost:3000 # This was inserted by `prisma init`: # Environment variables declared in this file are automatically made available to Prisma. diff --git a/README.md b/README.md index b0984b2..44c500f 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,13 @@ npm run preview Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information. +## Config +### .env +Most of the .env settings are self explanatory and are usually secrets. + +### Trial Plan +If you want a 'free trial period' set initialPlanName to an appropriate plan name in the DB and initialPlanActiveMonths to a positive value. If you don't want a free trial, set initialPlanName to an appropriate 'No Plan' plan in the DB and set the initialPlanActiveMonths to -1. + # Steps to Create This is what I did to create the project including all the extra fiddly stuff. Putting this here so I don't forget. @@ -127,10 +134,10 @@ I set up a Stripe account with a couple of 'Products' with a single price each t - team invitation thingy (not required, admins can add new members to team) - actions which mutate the current user account should update the context... (done) - integration with stripe including web hooks (basics done). --- add email to user record... capture from login same as user name --- initial user should be created with an expired plan --- add a pricing page....should be the default redirect from signup if the user has no active plan.. not sure whether to use a 'blank' plan or make plan nullable (basic pricing page is done) --- figure out what to do with Plan Name. Could add Plan Name to account record and copy over at time of account creation or updation. could pull from the Plan record for display.... but makes it difficult to change... should be loosely coupled, maybe use first approach +-- add email to user record... capture from login same as user name (done) +-- initial user should be created with an expired plan (done, initial plan and plan period now controled via config to allow either a trial plan or a 'No Plan' for initial users) +-- add a pricing page....should be the default redirect from signup if the user has no active plan.. not sure whether to use a 'blank' plan or make plan nullable (basic pricing page is done - decided on 'no plan' plan) +-- figure out what to do with Plan Name. Could add Plan Name to account record and copy over at time of account creation or updation. could pull from the Plan record for display.... but makes it difficult to change... should be loosely coupled, maybe use first approach (done) -- figure out when/how plan changes.. is it triggered by webhook? # Admin Functions Scenario (shitty test) Pre-condition diff --git a/lib/services/notes.service.ts b/lib/services/notes.service.ts index 4652cbb..d635dd7 100644 --- a/lib/services/notes.service.ts +++ b/lib/services/notes.service.ts @@ -1,33 +1,27 @@ -import { PrismaClient } from '@prisma/client'; +import prisma_client from '~~/prisma/prisma.client'; export default class NotesService { - private prisma: PrismaClient; - - constructor( prisma: PrismaClient) { - this.prisma = prisma; - } - async getAllNotes() { - return this.prisma.note.findMany(); + return prisma_client.note.findMany(); } async getNoteById(id: number) { - return this.prisma.note.findUniqueOrThrow({ where: { id } }); + return prisma_client.note.findUniqueOrThrow({ where: { id } }); } async getNotesForAccountId(account_id: number) { - return this.prisma.note.findMany({ where: { account_id } }); + return prisma_client.note.findMany({ where: { account_id } }); } async createNote( account_id: number, note_text: string ) { - return this.prisma.note.create({ data: { account_id, note_text }}); + return prisma_client.note.create({ data: { account_id, note_text }}); } async updateNote(id: number, note_text: string) { - return this.prisma.note.update({ where: { id }, data: { note_text } }); + return prisma_client.note.update({ where: { id }, data: { note_text } }); } async deleteNote(id: number) { - return this.prisma.note.delete({ where: { id } }); + return prisma_client.note.delete({ where: { id } }); } } diff --git a/lib/services/user.account.service.ts b/lib/services/user.account.service.ts index 0302da2..9233bd4 100644 --- a/lib/services/user.account.service.ts +++ b/lib/services/user.account.service.ts @@ -1,20 +1,16 @@ -import { ACCOUNT_ACCESS, PrismaClient, User, Membership, Account } from '@prisma/client'; +import { ACCOUNT_ACCESS, User, Membership, Account } from '@prisma/client'; +import prisma_client from '~~/prisma/prisma.client'; import { UtilService } from './util.service'; const config = useRuntimeConfig(); export type MembershipWithAccount = (Membership & {account: Account}); export type FullDBUser = (User & { memberships: MembershipWithAccount[]; }); - +export type MembershipWithUser = (Membership & { user: User}); +export type AccountWithMembers = (Account & {members: MembershipWithUser[]}); export default class UserAccountService { - private prisma: PrismaClient; - - constructor( prisma: PrismaClient) { - this.prisma = prisma; - } - async getUserBySupabaseId(supabase_uid: string): Promise { - return this.prisma.user.findFirst({ + return prisma_client.user.findFirst({ where: { supabase_uid }, include: { memberships: {include: { account: true @@ -23,7 +19,7 @@ export default class UserAccountService { } async getFullUserBySupabaseId(supabase_uid: string): Promise { - return this.prisma.user.findFirst({ + return prisma_client.user.findFirst({ where: { supabase_uid }, include: { memberships: {include: { account: true @@ -32,7 +28,7 @@ export default class UserAccountService { } async getUserById(user_id: number): Promise { - return this.prisma.user.findFirstOrThrow({ + return prisma_client.user.findFirstOrThrow({ where: { id: user_id }, include: { memberships: {include: { account: true @@ -40,14 +36,17 @@ export default class UserAccountService { }); } - async getAccountById(account_id: number): Promise { - return this.prisma.account.findFirstOrThrow({ + async getAccountById(account_id: number): Promise { + return prisma_client.account.findFirstOrThrow({ where: { id: account_id }, + include: { members: {include: { + user: true + }} } }); } async updateAccountStipeCustomerId (account_id: number, stripe_customer_id: string){ - return await this.prisma.account.update({ + return await prisma_client.account.update({ where: { id: account_id }, data: { stripe_customer_id, @@ -56,10 +55,10 @@ export default class UserAccountService { } async updateStripeSubscriptionDetailsForAccount (stripe_customer_id: string, stripe_subscription_id: string, current_period_ends: Date){ - const account = await this.prisma.account.findFirstOrThrow({ + const account = await prisma_client.account.findFirstOrThrow({ where: {stripe_customer_id} }); - return await this.prisma.account.update({ + return await prisma_client.account.update({ where: { id: account.id }, data: { stripe_subscription_id, @@ -68,12 +67,13 @@ export default class UserAccountService { }) } - async createUser( supabase_uid: string, display_name: string ): Promise { - const trialPlan = await this.prisma.plan.findFirstOrThrow({ where: { name: config.trialPlanName}}); - return this.prisma.user.create({ + async createUser( supabase_uid: string, display_name: string, email: string ): Promise { + const trialPlan = await prisma_client.plan.findFirstOrThrow({ where: { name: config.initialPlanName}}); + return prisma_client.user.create({ data:{ supabase_uid: supabase_uid, display_name: display_name, + email: email, memberships: { create: { account: { @@ -81,8 +81,9 @@ export default class UserAccountService { plan_id: trialPlan.id, name: display_name, features: trialPlan.features, //copy in features from the plan, plan_id is a loose association and settings can change independently - current_period_ends: UtilService.addMonths(new Date(),3), + current_period_ends: UtilService.addMonths(new Date(), config.initialPlanActiveMonths), max_notes: trialPlan.max_notes, + plan_name: trialPlan.name, } }, access: ACCOUNT_ACCESS.OWNER @@ -96,11 +97,11 @@ export default class UserAccountService { } async deleteUser(user_id: number) { - return this.prisma.user.delete({ where: { id: user_id } }); + return prisma_client.user.delete({ where: { id: user_id } }); } async joinUserToAccount(user_id: number, account_id: number): Promise { - return this.prisma.membership.create({ + return prisma_client.membership.create({ data: { user_id: user_id, account_id: account_id, @@ -113,8 +114,8 @@ export default class UserAccountService { } async changeAccountPlan(account_id: number, plan_id: number) { - const plan = await this.prisma.plan.findFirstOrThrow({ where: {id: plan_id}}); - return this.prisma.account.update({ + const plan = await prisma_client.plan.findFirstOrThrow({ where: {id: plan_id}}); + return prisma_client.account.update({ where: { id: account_id}, data: { plan_id: plan_id, @@ -130,7 +131,7 @@ export default class UserAccountService { // Existing OWNER memberships are downgraded to ADMIN // In future, some sort of Billing/Stripe tie in here e.g. changing email details on the Account, not sure. async claimOwnershipOfAccount(user_id: number, account_id: number) { - const membership = await this.prisma.membership.findUniqueOrThrow({ + const membership = await prisma_client.membership.findUniqueOrThrow({ where: { user_id_account_id: { user_id: user_id, @@ -145,7 +146,7 @@ export default class UserAccountService { throw new Error('UNAUTHORISED: only Admins can claim ownership'); } - const existing_owner_memberships = await this.prisma.membership.findMany({ + const existing_owner_memberships = await prisma_client.membership.findMany({ where: { account_id: account_id, access: ACCOUNT_ACCESS.OWNER, @@ -153,7 +154,7 @@ export default class UserAccountService { }); for(const existing_owner_membership of existing_owner_memberships) { - await this.prisma.membership.update({ + await prisma_client.membership.update({ where: { user_id_account_id: { user_id: existing_owner_membership.user_id, @@ -167,7 +168,7 @@ export default class UserAccountService { } // finally update the ADMIN member to OWNER - return this.prisma.membership.update({ + return prisma_client.membership.update({ where: { user_id_account_id: { user_id: user_id, @@ -189,7 +190,7 @@ export default class UserAccountService { throw new Error('UNABLE TO UPDATE MEMBERSHIP: use claimOwnershipOfAccount method to change ownership'); } - const membership = await this.prisma.membership.findUniqueOrThrow({ + const membership = await prisma_client.membership.findUniqueOrThrow({ where: { user_id_account_id: { user_id: user_id, @@ -202,7 +203,7 @@ export default class UserAccountService { throw new Error('UNABLE TO UPDATE MEMBERSHIP: use claimOwnershipOfAccount method to change ownership'); } - return this.prisma.membership.update({ + return prisma_client.membership.update({ where: { user_id_account_id: { user_id: user_id, diff --git a/nuxt.config.ts b/nuxt.config.ts index 78a4bf0..02dc7e0 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -14,8 +14,10 @@ export default defineNuxtConfig({ runtimeConfig:{ stripeSecretKey: process.env.STRIPE_SECRET_KEY, stripeEndpointSecret: process.env.STRIPE_ENDPOINT_SECRET, + stripeCallbackUrl: process.env.STRIPE_CALLBACK_URL, subscriptionGraceDays: 3, - trialPlanName: '3 Month Trial', + initialPlanName: '3 Month Trial', + initialPlanActiveMonths: 3, public: { debugMode: true, } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 57ffeb8..dc1056b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -13,6 +13,7 @@ datasource db { model User { id Int @id @default(autoincrement()) supabase_uid String + email String display_name String? memberships Membership[] @@ -46,6 +47,7 @@ model Account { features String[] plan_id Int plan Plan @relation(fields: [plan_id], references: [id]) + plan_name String members Membership[] notes Note[] max_notes Int @default(100) diff --git a/server/api/trpc/[trpc].ts b/server/api/trpc/[trpc].ts index 61a1c5e..909f81f 100644 --- a/server/api/trpc/[trpc].ts +++ b/server/api/trpc/[trpc].ts @@ -22,5 +22,5 @@ export type AppRouter = typeof appRouter; export default createNuxtApiHandler({ router: appRouter, createContext: createContext, - onError({ error}) { console.error(error)}, // TODO - logging and reporting + onError({ error}) { console.error(error)}, }) diff --git a/server/routes/create-checkout-session.post.ts b/server/routes/create-checkout-session.post.ts index c24ab71..c3da7ce 100644 --- a/server/routes/create-checkout-session.post.ts +++ b/server/routes/create-checkout-session.post.ts @@ -1,26 +1,24 @@ -import { Account } from '@prisma/client'; +import { ACCOUNT_ACCESS } from '@prisma/client'; import Stripe from 'stripe'; -import UserAccountService from '~~/lib/services/user.account.service'; -import prisma_client from '~~/prisma/prisma.client'; +import UserAccountService, { AccountWithMembers } from '~~/lib/services/user.account.service'; const config = useRuntimeConfig(); const stripe = new Stripe(config.stripeSecretKey, { apiVersion: '2022-11-15' }); export default defineEventHandler(async (event) => { - const YOUR_DOMAIN = 'http://localhost:3000'; // TODO - pull from somewhere, this is shit - const body = await readBody(event) let { price_id, account_id} = body; account_id = +account_id console.log(`session.post.ts recieved price_id:${price_id}, account_id:${account_id}`); - const userService = new UserAccountService(prisma_client); - const account: Account = await userService.getAccountById(account_id); + const userService = new UserAccountService(); + const account: AccountWithMembers = await userService.getAccountById(account_id); let customer_id: string if(!account.stripe_customer_id){ - // need to pre-emptively create a Stripe user for this account (use name for now, just so is visible on dashboard) TODO - include Email - console.log(`Creating account with name ${account.name}`); - const customer = await stripe.customers.create({ name: account.name }); + // need to pre-emptively create a Stripe user for this account so we know who they are when the webhook comes back + const owner = account.members.find(member => (member.access == ACCOUNT_ACCESS.OWNER)) + console.log(`Creating account with name ${account.name} and email ${owner?.user.email}`); + const customer = await stripe.customers.create({ name: account.name, email: owner?.user.email }); customer_id = customer.id; userService.updateAccountStipeCustomerId(account_id, customer.id); } else { @@ -39,15 +37,15 @@ export default defineEventHandler(async (event) => { // {CHECKOUT_SESSION_ID} is a string literal; do not change it! // the actual Session ID is returned in the query parameter when your customer // is redirected to the success page. - success_url: `${YOUR_DOMAIN}/success?session_id={CHECKOUT_SESSION_ID}`, - cancel_url: `${YOUR_DOMAIN}/cancel`, + success_url: `${config.stripeCallbackUrl}/success?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${config.stripeCallbackUrl}/cancel`, customer: customer_id }); if(session?.url){ return sendRedirect(event, session.url, 303); } else { - return sendRedirect(event, `${YOUR_DOMAIN}/fail`, 303); + return sendRedirect(event, `${config.stripeCallbackUrl}/fail`, 303); } }); diff --git a/server/routes/webhook.post.ts b/server/routes/webhook.post.ts index cdefa6a..665b80e 100644 --- a/server/routes/webhook.post.ts +++ b/server/routes/webhook.post.ts @@ -1,6 +1,5 @@ import Stripe from 'stripe'; import UserAccountService from '~~/lib/services/user.account.service'; -import prisma_client from '~~/prisma/prisma.client'; const config = useRuntimeConfig(); const stripe = new Stripe(config.stripeSecretKey, { apiVersion: '2022-11-15' }); @@ -22,15 +21,15 @@ export default defineEventHandler(async (event) => { } catch (err) { console.log(err); - throw createError({ statusCode: 400, statusMessage: `Webhook Error` }); // ${(err as Error).message} + throw createError({ statusCode: 400, statusMessage: `Error validating Webhook Event` }); } - console.log(`****** Web Hook Recieved (${stripeEvent.type}) ******`); - if(stripeEvent.type && stripeEvent.type.startsWith('customer.subscription')){ + console.log(`****** Web Hook Recieved (${stripeEvent.type}) ******`); + let subscription = stripeEvent.data.object as Stripe.Subscription; - const userService = new UserAccountService(prisma_client); + const userService = new UserAccountService(); let current_period_ends: Date = new Date(subscription.current_period_end * 1000); current_period_ends.setDate(current_period_ends.getDate() + config.subscriptionGraceDays); diff --git a/server/trpc/context.ts b/server/trpc/context.ts index 57b2f67..e434dc7 100644 --- a/server/trpc/context.ts +++ b/server/trpc/context.ts @@ -1,4 +1,3 @@ -import { PrismaClient } from '@prisma/client'; import { inferAsyncReturnType, TRPCError } from '@trpc/server' import { H3Event } from 'h3'; import { serverSupabaseClient } from '#supabase/server'; @@ -6,7 +5,6 @@ import SupabaseClient from '@supabase/supabase-js/dist/module/SupabaseClient'; import { User } from '@supabase/supabase-js'; import UserAccountService, { FullDBUser } from '~~/lib/services/user.account.service'; -let prisma: PrismaClient | undefined let supabase: SupabaseClient | undefined export async function createContext(event: H3Event){ @@ -19,32 +17,25 @@ export async function createContext(event: H3Event){ if (!user) { ({data: { user }} = await supabase.auth.getUser()); } - if (!prisma) { - prisma = new PrismaClient() - } if (!dbUser && user) { - const userService = new UserAccountService(prisma); + const userService = new UserAccountService(); dbUser = await userService.getFullUserBySupabaseId(user.id); if (!dbUser && user) { - dbUser = await userService.createUser( user.id, user.user_metadata.full_name ); + dbUser = await userService.createUser( user.id, user.user_metadata.full_name, user.email?user.email:"no@email.supplied" ); console.log(`\n Created user \n ${JSON.stringify(dbUser)}\n`); } } - if(!supabase || !user || !prisma || !dbUser) { - + if(!user || !dbUser) { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', - message: `Unable to fetch user data, please try again later. Missing ->[supabase:${(!supabase)},user:${(!user)},prisma:${(!prisma)},dbUser:${(!dbUser)}, ]`, + message: `Unable to fetch user data, please try again later. Missing ->[user:${(!user)},dbUser:${(!dbUser)}]`, }); } - // TODO - This seems excessive, trim context when I have figured out what I actually need return { - supabase, user, - prisma, dbUser, } }; diff --git a/server/trpc/routers/notes.router.ts b/server/trpc/routers/notes.router.ts index 05a1b15..b5fc007 100644 --- a/server/trpc/routers/notes.router.ts +++ b/server/trpc/routers/notes.router.ts @@ -6,7 +6,7 @@ export const notesRouter = router({ getForCurrentUser: protectedProcedure .input(z.object({ account_id: z.number() })) .query(async ({ ctx, input }) => { - const notesService = new NotesService(ctx.prisma); + const notesService = new NotesService(); const notes = await notesService.getNotesForAccountId(input.account_id); return { notes, @@ -15,7 +15,7 @@ export const notesRouter = router({ getById: publicProcedure .input(z.object({ note_id: z.number() })) .query(async ({ ctx, input }) => { - const notesService = new NotesService(ctx.prisma); + const notesService = new NotesService(); const note = await notesService.getNoteById(input.note_id); return { note, diff --git a/server/trpc/routers/user.account.router.ts b/server/trpc/routers/user.account.router.ts index d5480b7..632fcf6 100644 --- a/server/trpc/routers/user.account.router.ts +++ b/server/trpc/routers/user.account.router.ts @@ -13,7 +13,7 @@ export const userAccountRouter = router({ changeAccountPlan: adminProcedure .input(z.object({ account_id: z.number(), plan_id: z.number() })) .query(async ({ ctx, input }) => { - const uaService = new UserAccountService(ctx.prisma); + const uaService = new UserAccountService(); const account = await uaService.changeAccountPlan(input.account_id, input.plan_id); return { @@ -23,7 +23,7 @@ export const userAccountRouter = router({ joinUserToAccount: adminProcedure .input(z.object({ account_id: z.number(), user_id: z.number() })) .query(async ({ ctx, input }) => { - const uaService = new UserAccountService(ctx.prisma); + const uaService = new UserAccountService(); const membership = (ctx.dbUser?.id)?await uaService.joinUserToAccount(input.user_id, input.account_id):null; return { membership, @@ -32,7 +32,7 @@ export const userAccountRouter = router({ changeUserAccessWithinAccount: adminProcedure .input(z.object({ user_id: z.number(), account_id: z.number(), access: z.enum([ACCOUNT_ACCESS.ADMIN, ACCOUNT_ACCESS.OWNER, ACCOUNT_ACCESS.READ_ONLY, ACCOUNT_ACCESS.READ_WRITE]) })) .query(async ({ ctx, input }) => { - const uaService = new UserAccountService(ctx.prisma); + const uaService = new UserAccountService(); const membership = await uaService.changeUserAccessWithinAccount(input.user_id, input.account_id, input.access); return { @@ -42,7 +42,7 @@ export const userAccountRouter = router({ claimOwnershipOfAccount: adminProcedure .input(z.object({ account_id: z.number() })) .query(async ({ ctx, input }) => { - const uaService = new UserAccountService(ctx.prisma); + const uaService = new UserAccountService(); const membership = await uaService.claimOwnershipOfAccount(ctx.dbUser.id, input.account_id); return {