Basic Stripe integration. new pages for stripe handshake and new Account page. use query instead of useQuery for trpc fetches from store

This commit is contained in:
Michael Dausmann
2023-03-18 20:37:51 +11:00
parent 2ef98d0d98
commit 7311c13db2
21 changed files with 503 additions and 34 deletions

View File

@@ -103,7 +103,9 @@ npx prisma generate
- actions which mutate the current user account should update the context... (done) - actions which mutate the current user account should update the context... (done)
- integration with stripe including web hooks. - integration with stripe including web hooks.
-- add email to user record... capture from login same as user name -- add email to user record... capture from login same as user name
-- 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 -- 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
# Admin Functions Scenario (shitty test) # Admin Functions Scenario (shitty test)
Pre-condition Pre-condition

View File

@@ -1,20 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import { MembershipWithAccount } from '~~/lib/services/user.account.service'; import { MembershipWithAccount } from '~~/lib/services/user.account.service';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
const supabase = useSupabaseAuthClient(); const supabase = useSupabaseAuthClient();
const user = useSupabaseUser(); const user = useSupabaseUser();
const store = useAppStore() const store = useAppStore()
const { activeMembership } = storeToRefs(store); const { activeMembership } = storeToRefs(store);
const { $client } = useNuxtApp(); const { $client } = useNuxtApp();
const { data: dbUser } = await $client.userAccount.getDBUser.useQuery(); const { data: dbUser } = await $client.userAccount.getDBUser.useQuery();
async function signout() { onMounted(async () => {
await supabase.auth.signOut(); await store.initUser();
navigateTo('/', {replace: true}); });
}
async function signout() {
await supabase.auth.signOut();
navigateTo('/', {replace: true});
}
</script> </script>
<template> <template>
@@ -23,6 +27,12 @@ async function signout() {
<!-- logged in & sign out --> <!-- logged in & sign out -->
<div v-if="user">logged in as: {{ user.email }}: <button @click="signout()">Sign Out</button></div> <div v-if="user">logged in as: {{ user.email }}: <button @click="signout()">Sign Out</button></div>
<div v-if="!user">Not Logged in</div> <div v-if="!user">Not Logged in</div>
<div>
<NuxtLink to="/">Boilerplate</NuxtLink>&nbsp;
|&nbsp;<NuxtLink to="/dashboard">Dashboard</NuxtLink>&nbsp;
|&nbsp;<NuxtLink to="/pricing">Pricing</NuxtLink>&nbsp;
|&nbsp;<NuxtLink to="/account">Account</NuxtLink>
</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 > 0)">

View File

@@ -40,6 +40,34 @@ export default class UserAccountService {
}); });
} }
async getAccountById(account_id: number): Promise<Account> {
return this.prisma.account.findFirstOrThrow({
where: { id: account_id },
});
}
async updateAccountStipeCustomerId (account_id: number, stripe_customer_id: string){
return await this.prisma.account.update({
where: { id: account_id },
data: {
stripe_customer_id,
}
})
}
async updateStripeSubscriptionDetailsForAccount (stripe_customer_id: string, stripe_subscription_id: string, current_period_ends: Date){
const account = await this.prisma.account.findFirstOrThrow({
where: {stripe_customer_id}
});
return await this.prisma.account.update({
where: { id: account.id },
data: {
stripe_subscription_id,
current_period_ends
}
})
}
async createUser( supabase_uid: string, display_name: string ): Promise<FullDBUser | null> { async createUser( supabase_uid: string, display_name: string ): Promise<FullDBUser | null> {
const trialPlan = await this.prisma.plan.findFirstOrThrow({ where: { name: TRIAL_PLAN_NAME}}); const trialPlan = await this.prisma.plan.findFirstOrThrow({ where: { name: TRIAL_PLAN_NAME}});
return this.prisma.user.create({ return this.prisma.user.create({

View File

@@ -7,4 +7,9 @@ export class UtilService {
} }
return date; return date;
} }
public static getErrorMessage(error: unknown) {
if (error instanceof Error) return error.message
return String(error)
}
} }

View File

@@ -1,5 +1,6 @@
// https://nuxt.com/docs/api/configuration/nuxt-config // https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({ export default defineNuxtConfig({
debug: true,
build: { build: {
transpile: ['trpc-nuxt'] transpile: ['trpc-nuxt']
}, },
@@ -10,4 +11,12 @@ export default defineNuxtConfig({
imports: { imports: {
dirs: ['./stores'], dirs: ['./stores'],
}, },
runtimeConfig:{
stripeSecretKey: process.env.STRIPE_SECRET_KEY,
stripeEndpointSecret: process.env.STRIPE_ENDPOINT_SECRET,
subscriptionGraceDays: 3,
public: {
debugMode: true,
}
}
}) })

216
package-lock.json generated
View File

@@ -10,6 +10,8 @@
"@trpc/client": "^10.9.0", "@trpc/client": "^10.9.0",
"@trpc/server": "^10.9.0", "@trpc/server": "^10.9.0",
"pinia": "^2.0.30", "pinia": "^2.0.30",
"stripe": "^11.12.0",
"superjson": "^1.12.2",
"trpc-nuxt": "^0.5.0", "trpc-nuxt": "^0.5.0",
"zod": "^3.20.2" "zod": "^3.20.2"
}, },
@@ -1587,6 +1589,11 @@
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.0.tgz",
"integrity": "sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==" "integrity": "sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ=="
}, },
"node_modules/@types/node": {
"version": "18.14.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.14.1.tgz",
"integrity": "sha512-QH+37Qds3E0eDlReeboBxfHbX9omAcBCXEzswCu6jySP642jiM3cYSIkU/REqwhCUqXdonHFuBfJDiAJxMNhaQ=="
},
"node_modules/@types/phoenix": { "node_modules/@types/phoenix": {
"version": "1.5.4", "version": "1.5.4",
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.5.4.tgz", "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.5.4.tgz",
@@ -2439,6 +2446,18 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/call-bind": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
"integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==",
"dependencies": {
"function-bind": "^1.1.1",
"get-intrinsic": "^1.0.2"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/camelcase": { "node_modules/camelcase": {
"version": "6.3.0", "version": "6.3.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
@@ -2811,6 +2830,20 @@
"resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-0.5.0.tgz", "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-0.5.0.tgz",
"integrity": "sha512-RyZrFi6PNpBFbIaQjXDlFIhFVqV42QeKSZX1yQIl6ihImq6vcHNGMtqQ/QzY3RMPuYSkvsRwtnt5M9NeYxKt0g==" "integrity": "sha512-RyZrFi6PNpBFbIaQjXDlFIhFVqV42QeKSZX1yQIl6ihImq6vcHNGMtqQ/QzY3RMPuYSkvsRwtnt5M9NeYxKt0g=="
}, },
"node_modules/copy-anything": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.3.tgz",
"integrity": "sha512-fpW2W/BqEzqPp29QS+MwwfisHCQZtiduTe/m8idFo0xbti9fIZ2WVhAsCv4ggFVH3AgCkVdpoOCtQC6gBrdhjw==",
"dependencies": {
"is-what": "^4.1.8"
},
"engines": {
"node": ">=12.13"
},
"funding": {
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/core-util-is": { "node_modules/core-util-is": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
@@ -3756,8 +3789,7 @@
"node_modules/function-bind": { "node_modules/function-bind": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
"dev": true
}, },
"node_modules/gauge": { "node_modules/gauge": {
"version": "3.0.2", "version": "3.0.2",
@@ -3837,6 +3869,19 @@
"node": "6.* || 8.* || >= 10.*" "node": "6.* || 8.* || >= 10.*"
} }
}, },
"node_modules/get-intrinsic": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.0.tgz",
"integrity": "sha512-L049y6nFOuom5wGyRc3/gdTLO94dySVKRACj1RmJZBQXlbTMhtNIgkWkUHq+jYmZvKf14EW1EoJnnjbmoHij0Q==",
"dependencies": {
"function-bind": "^1.1.1",
"has": "^1.0.3",
"has-symbols": "^1.0.3"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-port-please": { "node_modules/get-port-please": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.0.1.tgz", "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.0.1.tgz",
@@ -3991,7 +4036,6 @@
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
"integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
"dev": true,
"dependencies": { "dependencies": {
"function-bind": "^1.1.1" "function-bind": "^1.1.1"
}, },
@@ -4007,6 +4051,17 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/has-symbols": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
"integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-unicode": { "node_modules/has-unicode": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
@@ -4389,6 +4444,17 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/is-what": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.8.tgz",
"integrity": "sha512-yq8gMao5upkPoGEU9LsB2P+K3Kt8Q3fQFCGyNCWOAnJAMzEXVV9drYb0TXr42TTliLLhKIBvulgAXgtLLnwzGA==",
"engines": {
"node": ">=12.13"
},
"funding": {
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/is-wsl": { "node_modules/is-wsl": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
@@ -5250,6 +5316,14 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/object-inspect": {
"version": "1.12.3",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz",
"integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/ofetch": { "node_modules/ofetch": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.0.0.tgz", "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.0.0.tgz",
@@ -6109,6 +6183,20 @@
"resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz",
"integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==" "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw=="
}, },
"node_modules/qs": {
"version": "6.11.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz",
"integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==",
"dependencies": {
"side-channel": "^1.0.4"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -6614,6 +6702,19 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/side-channel": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
"integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==",
"dependencies": {
"call-bind": "^1.0.0",
"get-intrinsic": "^1.0.2",
"object-inspect": "^1.9.0"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/signal-exit": { "node_modules/signal-exit": {
"version": "3.0.7", "version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
@@ -6767,6 +6868,18 @@
"url": "https://github.com/sponsors/antfu" "url": "https://github.com/sponsors/antfu"
} }
}, },
"node_modules/stripe": {
"version": "11.12.0",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-11.12.0.tgz",
"integrity": "sha512-7yzFyVV/eYpYalfjnw1f9sh/N3r5QVdx5MFtmpOg2QikKVAW4AptXC8P0wj1KNCd/LIo23nTDo0+m9788jHswg==",
"dependencies": {
"@types/node": ">=8.1.0",
"qs": "^6.11.0"
},
"engines": {
"node": ">=12.*"
}
},
"node_modules/stylehacks": { "node_modules/stylehacks": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.1.1.tgz", "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.1.1.tgz",
@@ -6783,6 +6896,17 @@
"postcss": "^8.2.15" "postcss": "^8.2.15"
} }
}, },
"node_modules/superjson": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/superjson/-/superjson-1.12.2.tgz",
"integrity": "sha512-ugvUo9/WmvWOjstornQhsN/sR9mnGtWGYeTxFuqLb4AiT4QdUavjGFRALCPKWWnAiUJ4HTpytj5e0t5HoMRkXg==",
"dependencies": {
"copy-anything": "^3.0.2"
},
"engines": {
"node": ">=10"
}
},
"node_modules/supports-color": { "node_modules/supports-color": {
"version": "5.5.0", "version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
@@ -9419,6 +9543,11 @@
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.0.tgz",
"integrity": "sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==" "integrity": "sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ=="
}, },
"@types/node": {
"version": "18.14.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.14.1.tgz",
"integrity": "sha512-QH+37Qds3E0eDlReeboBxfHbX9omAcBCXEzswCu6jySP642jiM3cYSIkU/REqwhCUqXdonHFuBfJDiAJxMNhaQ=="
},
"@types/phoenix": { "@types/phoenix": {
"version": "1.5.4", "version": "1.5.4",
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.5.4.tgz", "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.5.4.tgz",
@@ -10073,6 +10202,15 @@
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"dev": true "dev": true
}, },
"call-bind": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
"integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==",
"requires": {
"function-bind": "^1.1.1",
"get-intrinsic": "^1.0.2"
}
},
"camelcase": { "camelcase": {
"version": "6.3.0", "version": "6.3.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
@@ -10339,6 +10477,14 @@
"resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-0.5.0.tgz", "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-0.5.0.tgz",
"integrity": "sha512-RyZrFi6PNpBFbIaQjXDlFIhFVqV42QeKSZX1yQIl6ihImq6vcHNGMtqQ/QzY3RMPuYSkvsRwtnt5M9NeYxKt0g==" "integrity": "sha512-RyZrFi6PNpBFbIaQjXDlFIhFVqV42QeKSZX1yQIl6ihImq6vcHNGMtqQ/QzY3RMPuYSkvsRwtnt5M9NeYxKt0g=="
}, },
"copy-anything": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.3.tgz",
"integrity": "sha512-fpW2W/BqEzqPp29QS+MwwfisHCQZtiduTe/m8idFo0xbti9fIZ2WVhAsCv4ggFVH3AgCkVdpoOCtQC6gBrdhjw==",
"requires": {
"is-what": "^4.1.8"
}
},
"core-util-is": { "core-util-is": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
@@ -11037,8 +11183,7 @@
"function-bind": { "function-bind": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
"dev": true
}, },
"gauge": { "gauge": {
"version": "3.0.2", "version": "3.0.2",
@@ -11102,6 +11247,16 @@
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true "dev": true
}, },
"get-intrinsic": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.0.tgz",
"integrity": "sha512-L049y6nFOuom5wGyRc3/gdTLO94dySVKRACj1RmJZBQXlbTMhtNIgkWkUHq+jYmZvKf14EW1EoJnnjbmoHij0Q==",
"requires": {
"function-bind": "^1.1.1",
"has": "^1.0.3",
"has-symbols": "^1.0.3"
}
},
"get-port-please": { "get-port-please": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.0.1.tgz", "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.0.1.tgz",
@@ -11220,7 +11375,6 @@
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
"integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
"dev": true,
"requires": { "requires": {
"function-bind": "^1.1.1" "function-bind": "^1.1.1"
} }
@@ -11230,6 +11384,11 @@
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
"integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==" "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="
}, },
"has-symbols": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
"integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A=="
},
"has-unicode": { "has-unicode": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
@@ -11500,6 +11659,11 @@
"integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==",
"dev": true "dev": true
}, },
"is-what": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.8.tgz",
"integrity": "sha512-yq8gMao5upkPoGEU9LsB2P+K3Kt8Q3fQFCGyNCWOAnJAMzEXVV9drYb0TXr42TTliLLhKIBvulgAXgtLLnwzGA=="
},
"is-wsl": { "is-wsl": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
@@ -12187,6 +12351,11 @@
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true "dev": true
}, },
"object-inspect": {
"version": "1.12.3",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz",
"integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g=="
},
"ofetch": { "ofetch": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.0.0.tgz", "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.0.0.tgz",
@@ -12764,6 +12933,14 @@
"resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz",
"integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==" "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw=="
}, },
"qs": {
"version": "6.11.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz",
"integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==",
"requires": {
"side-channel": "^1.0.4"
}
},
"queue-microtask": { "queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -13132,6 +13309,16 @@
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true "dev": true
}, },
"side-channel": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
"integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==",
"requires": {
"call-bind": "^1.0.0",
"get-intrinsic": "^1.0.2",
"object-inspect": "^1.9.0"
}
},
"signal-exit": { "signal-exit": {
"version": "3.0.7", "version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
@@ -13249,6 +13436,15 @@
"acorn": "^8.8.1" "acorn": "^8.8.1"
} }
}, },
"stripe": {
"version": "11.12.0",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-11.12.0.tgz",
"integrity": "sha512-7yzFyVV/eYpYalfjnw1f9sh/N3r5QVdx5MFtmpOg2QikKVAW4AptXC8P0wj1KNCd/LIo23nTDo0+m9788jHswg==",
"requires": {
"@types/node": ">=8.1.0",
"qs": "^6.11.0"
}
},
"stylehacks": { "stylehacks": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.1.1.tgz", "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.1.1.tgz",
@@ -13259,6 +13455,14 @@
"postcss-selector-parser": "^6.0.4" "postcss-selector-parser": "^6.0.4"
} }
}, },
"superjson": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/superjson/-/superjson-1.12.2.tgz",
"integrity": "sha512-ugvUo9/WmvWOjstornQhsN/sR9mnGtWGYeTxFuqLb4AiT4QdUavjGFRALCPKWWnAiUJ4HTpytj5e0t5HoMRkXg==",
"requires": {
"copy-anything": "^3.0.2"
}
},
"supports-color": { "supports-color": {
"version": "5.5.0", "version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",

View File

@@ -18,6 +18,8 @@
"@trpc/client": "^10.9.0", "@trpc/client": "^10.9.0",
"@trpc/server": "^10.9.0", "@trpc/server": "^10.9.0",
"pinia": "^2.0.30", "pinia": "^2.0.30",
"stripe": "^11.12.0",
"superjson": "^1.12.2",
"trpc-nuxt": "^0.5.0", "trpc-nuxt": "^0.5.0",
"zod": "^3.20.2" "zod": "^3.20.2"
}, },

31
pages/account.vue Normal file
View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia';
const store = useAppStore();
const { activeMembership } = storeToRefs(store);
const config = useRuntimeConfig();
function formatDate(date: Date | undefined){
if(!date){ return ""; }
return new Intl.DateTimeFormat('default', {dateStyle: 'long'}).format(date);
}
</script>
<template>
<div>
<h3>Account</h3>
<p>Name: {{ activeMembership?.account.name }}</p>
<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>Access Level: {{ activeMembership?.access }}</p>
<template v-if="config.public.debugMode">
<p>******* Debug *******</p>
<p>Account ID: {{ activeMembership?.account.id }}</p>
<p>Plan Id: {{ activeMembership?.account.plan_id }}</p>
<p>Stripe Subscription Id: {{ activeMembership?.account.stripe_subscription_id }}</p>
<p>Stripe Customer Id: {{ activeMembership?.account.stripe_customer_id }}</p>
<p>Membership Id: {{ activeMembership?.id }}</p>
<p>User Id: {{ activeMembership?.user_id }}</p>
</template>
</div>
</template>

9
pages/cancel.vue Normal file
View File

@@ -0,0 +1,9 @@
<template>
<div>
<p>
We are sorry that you canceled your transaction!
<NuxtLink to="/pricing">Pricing</NuxtLink>
<NuxtLink to="/dashboard">To Your Dashboard</NuxtLink>
</p>
</div>
</template>

View File

@@ -8,9 +8,6 @@
const store = useAppStore(); const store = useAppStore();
const { notes } = storeToRefs(store); // ensure the notes list is reactive const { notes } = storeToRefs(store); // ensure the notes list is reactive
onMounted(async () => {
await store.initUser();
})
</script> </script>
<template> <template>
<div> <div>

9
pages/fail.vue Normal file
View File

@@ -0,0 +1,9 @@
<template>
<div>
<p>
We are sorry that you were unable to subscribe.
<NuxtLink to="/pricing">Pricing</NuxtLink>
<NuxtLink to="/dashboard">To Your Dashboard</NuxtLink>
</p>
</div>
</template>

28
pages/pricing.vue Normal file
View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia';
import { ACCOUNT_ACCESS } from '@prisma/client';
const store = useAppStore()
const { activeMembership } = storeToRefs(store);
</script>
<template>
<div>
<h3>Pricing</h3>
<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="account_id" :value="activeMembership?.account_id" />
<button type="submit" :disabled="!activeMembership || (activeMembership.access !== ACCOUNT_ACCESS.OWNER && activeMembership.access !== ACCOUNT_ACCESS.ADMIN)">Checkout</button>
</form>
<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="account_id" :value="activeMembership?.account_id" />
<button type="submit" :disabled="!activeMembership || (activeMembership.access !== ACCOUNT_ACCESS.OWNER && activeMembership.access !== ACCOUNT_ACCESS.ADMIN)">Checkout</button>
</form>
</div>
</template>

23
pages/success.vue Normal file
View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import Stripe from 'stripe';
const config = useRuntimeConfig();
const stripe = new Stripe(config.stripeSecretKey, { apiVersion: '2022-11-15' });
const route = useRoute();
let customer: Stripe.Response<Stripe.Customer | Stripe.DeletedCustomer>
try{
const session = await stripe.checkout.sessions.retrieve(route?.query?.session_id as string);
customer = await stripe.customers.retrieve(session?.customer as string);
} catch(e) {
console.log(`Error ${e}`)
}
</script>
<template>
<div>
<p>
<span v-if="!customer.deleted">We appreciate your business {{customer.name}}!</span>
<span v-if="customer.deleted">It appears your stripe customer information has been deleted!</span>
</p>
<p><NuxtLink to="/dashboard">To Your Dashboard</NuxtLink></p>
</div>
</template>

View File

@@ -1,5 +1,6 @@
import { createTRPCNuxtClient, httpBatchLink } from 'trpc-nuxt/client' import { createTRPCNuxtClient, httpBatchLink } from 'trpc-nuxt/client'
import type { AppRouter } from '~/server/api/trpc/[trpc]' import type { AppRouter } from '~/server/api/trpc/[trpc]'
import superjson from 'superjson';
export default defineNuxtPlugin(() => { export default defineNuxtPlugin(() => {
/** /**
@@ -12,6 +13,7 @@ export default defineNuxtPlugin(() => {
url: '/api/trpc', url: '/api/trpc',
}), }),
], ],
transformer: superjson,
}) })
return { return {

5
prisma/prisma.client.ts Normal file
View File

@@ -0,0 +1,5 @@
import pkg from "@prisma/client";
const { PrismaClient } = pkg;
const prisma_client = new PrismaClient()
export default prisma_client

View File

@@ -49,6 +49,8 @@ model Account {
members Membership[] members Membership[]
notes Note[] notes Note[]
max_notes Int @default(100) max_notes Int @default(100)
stripe_subscription_id String?
stripe_customer_id String?
@@map("account") @@map("account")
} }

View File

@@ -0,0 +1,54 @@
import { Account } from '@prisma/client';
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' });
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);
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 });
customer_id = customer.id;
userService.updateAccountStipeCustomerId(account_id, customer.id);
} else {
customer_id = account.stripe_customer_id;
}
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [
{
price: price_id,
// For metered billing, do not pass quantity
quantity: 1,
},
],
// {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`,
customer: customer_id
});
if(session?.url){
return sendRedirect(event, session.url, 303);
} else {
return sendRedirect(event, `${YOUR_DOMAIN}/fail`, 303);
}
});

View File

@@ -0,0 +1,42 @@
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' });
export default defineEventHandler(async (event) => {
const stripeSignature = getRequestHeader(event, 'stripe-signature');
if(!stripeSignature){
throw createError({ statusCode: 400, statusMessage: 'Webhook Error: No stripe signature in header' });
}
const rawBody = await readRawBody(event)
if(!rawBody){
throw createError({ statusCode: 400, statusMessage: 'Webhook Error: No body' });
}
let stripeEvent: Stripe.Event;
try {
stripeEvent = stripe.webhooks.constructEvent(rawBody, stripeSignature, config.stripeEndpointSecret);
}
catch (err) {
console.log(err);
throw createError({ statusCode: 400, statusMessage: `Webhook Error` }); // ${(err as Error).message}
}
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);
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)
}
return `handled ${stripeEvent.type}.`;
});

View File

@@ -33,9 +33,10 @@ export async function createContext(event: H3Event){
} }
if(!supabase || !user || !prisma || !dbUser) { if(!supabase || !user || !prisma || !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.', message: `Unable to fetch user data, please try again later. Missing ->[supabase:${(!supabase)},user:${(!user)},prisma:${(!prisma)},dbUser:${(!dbUser)}, ]`,
}); });
} }

View File

@@ -11,8 +11,11 @@ import { initTRPC, TRPCError } from '@trpc/server'
import { Context } from './context'; import { Context } from './context';
import { z } from 'zod'; import { z } from 'zod';
import { ACCOUNT_ACCESS } from '@prisma/client'; import { ACCOUNT_ACCESS } from '@prisma/client';
import superjson from 'superjson';
const t = initTRPC.context<Context>().create() const t = initTRPC.context<Context>().create({
transformer: superjson,
})
/** /**
* auth middlewares * auth middlewares

View File

@@ -17,13 +17,16 @@ export const useAppStore = defineStore('app', {
}, },
actions: { actions: {
async initUser() { async initUser() {
const { $client } = useNuxtApp(); if(!this.dbUser || !this.activeMembership){
const { data: dbUser } = await $client.userAccount.getDBUser.useQuery(); const { $client } = useNuxtApp();
if(dbUser.value?.dbUser){ const { dbUser } = await $client.userAccount.getDBUser.query();
this.dbUser = dbUser.value.dbUser;
if(dbUser.value.dbUser.memberships.length > 0){ if(dbUser){
this.activeMembership = dbUser.value.dbUser.memberships[0]; this.dbUser = dbUser;
await this.fetchNotesForCurrentUser(); if(dbUser.memberships.length > 0){
this.activeMembership = dbUser.memberships[0];
await this.fetchNotesForCurrentUser();
}
} }
} }
}, },
@@ -31,9 +34,9 @@ export const useAppStore = defineStore('app', {
if(!this.activeMembership) { return; } if(!this.activeMembership) { return; }
const { $client } = useNuxtApp(); const { $client } = useNuxtApp();
const { data: foundNotes } = await $client.notes.getForCurrentUser.useQuery({account_id: this.activeMembership.account_id}); const { notes } = await $client.notes.getForCurrentUser.query({account_id: this.activeMembership.account_id});
if(foundNotes.value?.notes){ if(notes){
this.notes = foundNotes.value.notes; this.notes = notes;
} }
}, },
async changeActiveMembership(membership: MembershipWithAccount) { async changeActiveMembership(membership: MembershipWithAccount) {