max user limits + check plan active on webhook + flow tweaks

This commit is contained in:
Michael Dausmann
2023-03-25 16:03:28 +11:00
parent 45fb639fcf
commit f2fa72b2a1
12 changed files with 86 additions and 28 deletions

1
.gitignore vendored
View File

@@ -6,3 +6,4 @@ node_modules
.output .output
.env .env
dist dist
junk

View File

@@ -138,7 +138,9 @@ I set up a Stripe account with a couple of 'Products' with a single price each t
-- 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) -- 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) -- 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 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? -- figure out when/how plan changes.. is it triggered by webhook? (Done, webhook looks up product info on plan record and updates plan info)
-- Plan info is all over the place... product id is on the plan record in the db, pricing id's are on the pricing page template. would it be too crazy to have an admin page to administer pricing and plan/product info?
-- What to do with pricing page? should probably change depending on current account information i.e. buttons say 'upgrade' for plans > current and maybe 'downgrade' for plans < current?
# Admin Functions Scenario (shitty test) # Admin Functions Scenario (shitty test)
Pre-condition Pre-condition
User 3 (encumbent id=3) - Owner of own single user account. Admin of Team account User 3 (encumbent id=3) - Owner of own single user account. Admin of Team account

View File

@@ -35,7 +35,7 @@
</div> </div>
<!-- Account Switching --> <!-- Account Switching -->
<p v-if="(dbUser?.dbUser?.memberships) && (dbUser.dbUser.memberships.length > 0)"> <p v-if="(dbUser?.dbUser?.memberships) && (dbUser.dbUser.memberships.length > 1)">
<span>Switch Account.. </span> <span>Switch Account.. </span>
<button v-for="membership in dbUser?.dbUser.memberships" @click="store.changeActiveMembership(((membership as unknown) as MembershipWithAccount))"> <!-- This cast is infuriating --> <button v-for="membership in dbUser?.dbUser.memberships" @click="store.changeActiveMembership(((membership as unknown) as MembershipWithAccount))"> <!-- This cast is infuriating -->
{{ membership.account.name }} {{ membership.account.name }}

View File

@@ -1,4 +1,4 @@
import { ACCOUNT_ACCESS, User, Membership, Account } from '@prisma/client'; import { ACCOUNT_ACCESS, User, Membership, Account, Plan } from '@prisma/client';
import prisma_client from '~~/prisma/prisma.client'; import prisma_client from '~~/prisma/prisma.client';
import { UtilService } from './util.service'; import { UtilService } from './util.service';
const config = useRuntimeConfig(); const config = useRuntimeConfig();
@@ -54,17 +54,40 @@ export default class UserAccountService {
}) })
} }
async updateStripeSubscriptionDetailsForAccount (stripe_customer_id: string, stripe_subscription_id: string, current_period_ends: Date){ async updateStripeSubscriptionDetailsForAccount (stripe_customer_id: string, stripe_subscription_id: string, current_period_ends: Date, stripe_product_id: string){
const account = await prisma_client.account.findFirstOrThrow({ const account = await prisma_client.account.findFirstOrThrow({
where: {stripe_customer_id} where: {stripe_customer_id}
}); });
const paid_plan = await prisma_client.plan.findFirstOrThrow({
where: { stripe_product_id },
});
if(paid_plan.id == account.plan_id){
// only update sub and period info
return await prisma_client.account.update({ return await prisma_client.account.update({
where: { id: account.id }, where: { id: account.id },
data: { data: {
stripe_subscription_id, stripe_subscription_id,
current_period_ends current_period_ends,
} }
}) });
} else {
// plan upgrade/downgrade... update everything, copying over plan features and perks
return await prisma_client.account.update({
where: { id: account.id },
data: {
stripe_subscription_id,
current_period_ends,
plan_id: paid_plan.id,
features: paid_plan.features,
max_notes: paid_plan.max_notes,
max_members: paid_plan.max_members,
plan_name: paid_plan.name,
}
});
}
} }
async createUser( supabase_uid: string, display_name: string, email: string ): Promise<FullDBUser | null> { async createUser( supabase_uid: string, display_name: string, email: string ): Promise<FullDBUser | null> {
@@ -78,11 +101,12 @@ export default class UserAccountService {
create: { create: {
account: { account: {
create: { create: {
plan_id: trialPlan.id,
name: display_name, 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(), config.initialPlanActiveMonths), current_period_ends: UtilService.addMonths(new Date(), config.initialPlanActiveMonths),
plan_id: trialPlan.id,
features: trialPlan.features,
max_notes: trialPlan.max_notes, max_notes: trialPlan.max_notes,
max_members: trialPlan.max_members,
plan_name: trialPlan.name, plan_name: trialPlan.name,
} }
}, },
@@ -101,6 +125,20 @@ export default class UserAccountService {
} }
async joinUserToAccount(user_id: number, account_id: number): Promise<MembershipWithAccount> { async joinUserToAccount(user_id: number, account_id: number): Promise<MembershipWithAccount> {
const account = await prisma_client.account.findUnique({
where: {
id: account_id,
},
include:{
members: true,
}
}
)
if(account?.members && account?.members?.length >= account?.max_members){
throw new Error(`Too Many Members, Account only permits ${account?.max_members} members.`);
}
return prisma_client.membership.create({ return prisma_client.membership.create({
data: { data: {
user_id: user_id, user_id: user_id,

View File

@@ -16,8 +16,8 @@ export default defineNuxtConfig({
stripeEndpointSecret: process.env.STRIPE_ENDPOINT_SECRET, stripeEndpointSecret: process.env.STRIPE_ENDPOINT_SECRET,
stripeCallbackUrl: process.env.STRIPE_CALLBACK_URL, stripeCallbackUrl: process.env.STRIPE_CALLBACK_URL,
subscriptionGraceDays: 3, subscriptionGraceDays: 3,
initialPlanName: '3 Month Trial', initialPlanName: 'Free Trial',
initialPlanActiveMonths: 3, initialPlanActiveMonths: 1,
public: { public: {
debugMode: true, debugMode: true,
} }

View File

@@ -16,7 +16,9 @@
<p>Current Period Ends: {{ formatDate(activeMembership?.account.current_period_ends) }}</p> <p>Current Period Ends: {{ formatDate(activeMembership?.account.current_period_ends) }}</p>
<p>Permitted Features: {{ activeMembership?.account.features }}</p> <p>Permitted Features: {{ activeMembership?.account.features }}</p>
<p>Maximum Notes: {{ activeMembership?.account.max_notes }}</p> <p>Maximum Notes: {{ activeMembership?.account.max_notes }}</p>
<p>Maximum Members: {{ activeMembership?.account.max_members }}</p>
<p>Access Level: {{ activeMembership?.access }}</p> <p>Access Level: {{ activeMembership?.access }}</p>
<p>Plan: {{ activeMembership?.account.plan_name }}</p>
<template v-if="config.public.debugMode"> <template v-if="config.public.debugMode">
<p>******* Debug *******</p> <p>******* Debug *******</p>

View File

@@ -10,6 +10,6 @@
<template> <template>
<div> <div>
<h3>Index</h3> <h3>Index</h3>
<button @click="supabase.auth.signInWithOAuth({provider: 'google'})">Sign In with Google</button> <button @click="supabase.auth.signInWithOAuth({provider: 'google'})">Connect with Google</button>
</div> </div>
</template> </template>

View File

@@ -9,9 +9,14 @@
<template> <template>
<div> <div>
<h3>Pricing</h3> <h3>Pricing</h3>
<div>
<label for="submit">Free Trial (1 Month)</label>
<NuxtLink to="/dashboard">Continue to Dashboard</NuxtLink>
</div>
<form action="/create-checkout-session" method="POST"> <form action="/create-checkout-session" method="POST">
<label for="submit">Individual Plan, Normal Price</label> <label for="submit">Individual Plan, Normal Price</label>
<input type="hidden" name="price_id" value="price_1MfaKVJfLn4RhYiLgruKo89E" /> <input type="hidden" name="price_id" value="price_1MpOiwJfLn4RhYiLqfy6U8ZR" />
<input type="hidden" name="account_id" :value="activeMembership?.account_id" /> <input type="hidden" name="account_id" :value="activeMembership?.account_id" />
<button type="submit" :disabled="!activeMembership || (activeMembership.access !== ACCOUNT_ACCESS.OWNER && activeMembership.access !== ACCOUNT_ACCESS.ADMIN)">Checkout</button> <button type="submit" :disabled="!activeMembership || (activeMembership.access !== ACCOUNT_ACCESS.OWNER && activeMembership.access !== ACCOUNT_ACCESS.ADMIN)">Checkout</button>
@@ -19,7 +24,7 @@
<form action="/create-checkout-session" method="POST"> <form action="/create-checkout-session" method="POST">
<label for="submit">Team Plan, Normal Price</label> <label for="submit">Team Plan, Normal Price</label>
<input type="hidden" name="price_id" value="price_1MfaM6JfLn4RhYiLPdr1OTDS" /> <input type="hidden" name="price_id" value="price_1MpOjtJfLn4RhYiLsjzAso90" />
<input type="hidden" name="account_id" :value="activeMembership?.account_id" /> <input type="hidden" name="account_id" :value="activeMembership?.account_id" />
<button type="submit" :disabled="!activeMembership || (activeMembership.access !== ACCOUNT_ACCESS.OWNER && activeMembership.access !== ACCOUNT_ACCESS.ADMIN)">Checkout</button> <button type="submit" :disabled="!activeMembership || (activeMembership.access !== ACCOUNT_ACCESS.OWNER && activeMembership.access !== ACCOUNT_ACCESS.ADMIN)">Checkout</button>

View File

@@ -53,6 +53,7 @@ model Account {
max_notes Int @default(100) max_notes Int @default(100)
stripe_subscription_id String? stripe_subscription_id String?
stripe_customer_id String? stripe_customer_id String?
max_members Int @default(1)
@@map("account") @@map("account")
} }
@@ -63,6 +64,8 @@ model Plan {
features String[] features String[]
accounts Account[] accounts Account[]
max_notes Int @default(100) max_notes Int @default(100)
stripe_product_id String?
max_members Int @default(1)
@@map("plan") @@map("plan")
} }

View File

@@ -30,7 +30,6 @@ export default defineEventHandler(async (event) => {
line_items: [ line_items: [
{ {
price: price_id, price: price_id,
// For metered billing, do not pass quantity
quantity: 1, quantity: 1,
}, },
], ],

View File

@@ -28,14 +28,22 @@ export default defineEventHandler(async (event) => {
console.log(`****** Web Hook Recieved (${stripeEvent.type}) ******`); console.log(`****** Web Hook Recieved (${stripeEvent.type}) ******`);
let subscription = stripeEvent.data.object as Stripe.Subscription; let subscription = stripeEvent.data.object as Stripe.Subscription;
if(subscription.status == 'active'){
const sub_item = subscription.items.data.find(item => item?.object && item?.object == 'subscription_item')
const stripe_product_id = sub_item?.plan.product?.toString(); // TODO - is the product ever a product object and in that case should I check for deleted?
if(!stripe_product_id){
throw createError({ statusCode: 400, statusMessage: `Error validating Webhook Event` });
}
const userService = new UserAccountService(); const userService = new UserAccountService();
let current_period_ends: Date = new Date(subscription.current_period_end * 1000); let current_period_ends: Date = new Date(subscription.current_period_end * 1000);
current_period_ends.setDate(current_period_ends.getDate() + config.subscriptionGraceDays); current_period_ends.setDate(current_period_ends.getDate() + config.subscriptionGraceDays);
console.log(`updating stripe sub details subscription.current_period_end:${subscription.current_period_end}, subscription.id:${subscription.id}`); console.log(`updating stripe sub details subscription.current_period_end:${subscription.current_period_end}, subscription.id:${subscription.id}, stripe_product_id:${stripe_product_id}`);
userService.updateStripeSubscriptionDetailsForAccount(subscription.customer.toString(), subscription.id, current_period_ends) userService.updateStripeSubscriptionDetailsForAccount(subscription.customer.toString(), subscription.id, current_period_ends, stripe_product_id);
}
} }
return `handled ${stripeEvent.type}.`; return `handled ${stripeEvent.type}.`;
}); });

View File

@@ -30,7 +30,7 @@ export async function createContext(event: H3Event){
if(!user || !dbUser) { if(!user || !dbUser) {
throw new TRPCError({ throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR', code: 'INTERNAL_SERVER_ERROR',
message: `Unable to fetch user data, please try again later. Missing ->[user:${(!user)},dbUser:${(!dbUser)}]`, message: `Unable to fetch user data, please try again later. Missing ->[supabase:${(!supabase)},user:${(!user)},dbUser:${(!dbUser)}]`,
}); });
} }