max user limits + check plan active on webhook + flow tweaks
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,3 +6,4 @@ node_modules
|
||||
.output
|
||||
.env
|
||||
dist
|
||||
junk
|
||||
|
||||
@@ -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)
|
||||
-- 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?
|
||||
-- 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)
|
||||
Pre-condition
|
||||
User 3 (encumbent id=3) - Owner of own single user account. Admin of Team account
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<button v-for="membership in dbUser?.dbUser.memberships" @click="store.changeActiveMembership(((membership as unknown) as MembershipWithAccount))"> <!-- This cast is infuriating -->
|
||||
{{ membership.account.name }}
|
||||
|
||||
@@ -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 { UtilService } from './util.service';
|
||||
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({
|
||||
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({
|
||||
where: { id: account.id },
|
||||
data: {
|
||||
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> {
|
||||
@@ -78,11 +101,12 @@ export default class UserAccountService {
|
||||
create: {
|
||||
account: {
|
||||
create: {
|
||||
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(), config.initialPlanActiveMonths),
|
||||
plan_id: trialPlan.id,
|
||||
features: trialPlan.features,
|
||||
max_notes: trialPlan.max_notes,
|
||||
max_members: trialPlan.max_members,
|
||||
plan_name: trialPlan.name,
|
||||
}
|
||||
},
|
||||
@@ -101,6 +125,20 @@ export default class UserAccountService {
|
||||
}
|
||||
|
||||
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({
|
||||
data: {
|
||||
user_id: user_id,
|
||||
|
||||
@@ -16,8 +16,8 @@ export default defineNuxtConfig({
|
||||
stripeEndpointSecret: process.env.STRIPE_ENDPOINT_SECRET,
|
||||
stripeCallbackUrl: process.env.STRIPE_CALLBACK_URL,
|
||||
subscriptionGraceDays: 3,
|
||||
initialPlanName: '3 Month Trial',
|
||||
initialPlanActiveMonths: 3,
|
||||
initialPlanName: 'Free Trial',
|
||||
initialPlanActiveMonths: 1,
|
||||
public: {
|
||||
debugMode: true,
|
||||
}
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
<p>Current Period Ends: {{ formatDate(activeMembership?.account.current_period_ends) }}</p>
|
||||
<p>Permitted Features: {{ activeMembership?.account.features }}</p>
|
||||
<p>Maximum Notes: {{ activeMembership?.account.max_notes }}</p>
|
||||
<p>Maximum Members: {{ activeMembership?.account.max_members }}</p>
|
||||
<p>Access Level: {{ activeMembership?.access }}</p>
|
||||
<p>Plan: {{ activeMembership?.account.plan_name }}</p>
|
||||
|
||||
<template v-if="config.public.debugMode">
|
||||
<p>******* Debug *******</p>
|
||||
|
||||
@@ -10,6 +10,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
@@ -9,9 +9,14 @@
|
||||
<template>
|
||||
<div>
|
||||
<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">
|
||||
<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" />
|
||||
|
||||
<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">
|
||||
<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" />
|
||||
|
||||
<button type="submit" :disabled="!activeMembership || (activeMembership.access !== ACCOUNT_ACCESS.OWNER && activeMembership.access !== ACCOUNT_ACCESS.ADMIN)">Checkout</button>
|
||||
|
||||
@@ -53,6 +53,7 @@ model Account {
|
||||
max_notes Int @default(100)
|
||||
stripe_subscription_id String?
|
||||
stripe_customer_id String?
|
||||
max_members Int @default(1)
|
||||
|
||||
@@map("account")
|
||||
}
|
||||
@@ -63,6 +64,8 @@ model Plan {
|
||||
features String[]
|
||||
accounts Account[]
|
||||
max_notes Int @default(100)
|
||||
stripe_product_id String?
|
||||
max_members Int @default(1)
|
||||
|
||||
@@map("plan")
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ export default defineEventHandler(async (event) => {
|
||||
line_items: [
|
||||
{
|
||||
price: price_id,
|
||||
// For metered billing, do not pass quantity
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -28,14 +28,22 @@ export default defineEventHandler(async (event) => {
|
||||
console.log(`****** Web Hook Recieved (${stripeEvent.type}) ******`);
|
||||
|
||||
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();
|
||||
|
||||
let current_period_ends: Date = new Date(subscription.current_period_end * 1000);
|
||||
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}`);
|
||||
userService.updateStripeSubscriptionDetailsForAccount(subscription.customer.toString(), subscription.id, current_period_ends)
|
||||
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, stripe_product_id);
|
||||
}
|
||||
}
|
||||
return `handled ${stripeEvent.type}.`;
|
||||
});
|
||||
@@ -30,7 +30,7 @@ export async function createContext(event: H3Event){
|
||||
if(!user || !dbUser) {
|
||||
throw new TRPCError({
|
||||
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)}]`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user