user.email account.plan_name - cleanup context and service construction - config for stripe callback - initial plan

This commit is contained in:
Michael Dausmann
2023-03-19 15:48:08 +11:00
parent 4959475dcc
commit 45fb639fcf
12 changed files with 81 additions and 86 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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 } });
}
}

View File

@@ -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<FullDBUser | null> {
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<FullDBUser | null> {
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<FullDBUser | null> {
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<Account> {
return this.prisma.account.findFirstOrThrow({
async getAccountById(account_id: number): Promise<AccountWithMembers> {
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<FullDBUser | null> {
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<FullDBUser | null> {
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<MembershipWithAccount> {
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,

View File

@@ -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,
}

View File

@@ -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)

View File

@@ -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)},
})

View File

@@ -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);
}
});

View File

@@ -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` });
}
if(stripeEvent.type && stripeEvent.type.startsWith('customer.subscription')){
console.log(`****** Web Hook Recieved (${stripeEvent.type}) ******`);
if(stripeEvent.type && stripeEvent.type.startsWith('customer.subscription')){
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);

View File

@@ -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,
}
};

View File

@@ -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,

View File

@@ -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 {