mirror of
https://github.com/shadcn-ui/taxonomy
synced 2026-05-23 09:18:30 +00:00
feat: implement stripe billing
This commit is contained in:
parent
61df1bec71
commit
b30ac75581
31 changed files with 579 additions and 64 deletions
|
|
@ -22,4 +22,11 @@ SMTP_HOST=smtp.postmarkapp.com
|
|||
SMTP_PORT=25
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=Taxonomy <taxonomy@example.com>
|
||||
SMTP_FROM=Taxonomy <taxonomy@example.com>
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Subscriptions (Stripe)
|
||||
# -----------------------------------------------------------------------------
|
||||
STRIPE_API_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
SUBSCRIPTION_PLAN_PRICE_ID_PRO=
|
||||
17
app/(dashboard)/dashboard/billing/loading.tsx
Normal file
17
app/(dashboard)/dashboard/billing/loading.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { DashboardHeader } from "@/components/dashboard/header"
|
||||
import { DashboardShell } from "@/components/dashboard/shell"
|
||||
import { Card } from "@/ui/card"
|
||||
|
||||
export default function DashboardBillingLoading() {
|
||||
return (
|
||||
<DashboardShell>
|
||||
<DashboardHeader
|
||||
heading="Billing"
|
||||
text="Manage billing and your subscription plan."
|
||||
/>
|
||||
<div className="grid gap-10">
|
||||
<Card.Skeleton />
|
||||
</div>
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
30
app/(dashboard)/dashboard/billing/page.tsx
Normal file
30
app/(dashboard)/dashboard/billing/page.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { redirect } from "next/navigation"
|
||||
|
||||
import { getCurrentUser } from "@/lib/session"
|
||||
import { authOptions } from "@/lib/auth"
|
||||
import { getUserSubscriptionPlan as getUserSubscriptionPlan } from "@/lib/subscription"
|
||||
import { DashboardHeader } from "@/components/dashboard/header"
|
||||
import { DashboardShell } from "@/components/dashboard/shell"
|
||||
import { BillingForm } from "@/components/dashboard/billing-form"
|
||||
|
||||
export default async function BillingPage() {
|
||||
const user = await getCurrentUser()
|
||||
|
||||
if (!user) {
|
||||
redirect(authOptions.pages.signIn)
|
||||
}
|
||||
|
||||
const subscriptionPlan = await getUserSubscriptionPlan(user.id)
|
||||
|
||||
return (
|
||||
<DashboardShell>
|
||||
<DashboardHeader
|
||||
heading="Billing"
|
||||
text="Manage billing and your subscription plan."
|
||||
/>
|
||||
<div className="grid gap-10">
|
||||
<BillingForm subscriptionPlan={subscriptionPlan} />
|
||||
</div>
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
85
components/dashboard/billing-form.tsx
Normal file
85
components/dashboard/billing-form.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { UserSubscriptionPlan } from "types"
|
||||
import { cn, formatDate } from "@/lib/utils"
|
||||
import { Card } from "@/ui/card"
|
||||
import { toast } from "@/ui/toast"
|
||||
import { Icons } from "@/components/icons"
|
||||
|
||||
interface BillingFormProps extends React.HTMLAttributes<HTMLFormElement> {
|
||||
subscriptionPlan: UserSubscriptionPlan
|
||||
}
|
||||
|
||||
export function BillingForm({
|
||||
subscriptionPlan,
|
||||
className,
|
||||
...props
|
||||
}: BillingFormProps) {
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(false)
|
||||
|
||||
async function onSubmit(event) {
|
||||
event.preventDefault()
|
||||
setIsLoading(!isLoading)
|
||||
|
||||
// Get a Stripe session URL.
|
||||
const response = await fetch("/api/users/stripe")
|
||||
|
||||
if (!response?.ok) {
|
||||
return toast({
|
||||
title: "Something went wrong.",
|
||||
message: "Please refresh the page and try again.",
|
||||
type: "error",
|
||||
})
|
||||
}
|
||||
|
||||
// Redirect to the Stripe session.
|
||||
// This could be a checkout page for initial upgrade.
|
||||
// Or portal to manage existing subscription.
|
||||
const session = await response.json()
|
||||
if (session) {
|
||||
window.location.href = session.url
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className={cn(className)} onSubmit={onSubmit} {...props}>
|
||||
<Card>
|
||||
<Card.Header>
|
||||
<Card.Title>Plan</Card.Title>
|
||||
<Card.Description>
|
||||
You are currently on the <strong>{subscriptionPlan.name}</strong>{" "}
|
||||
plan.
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>{subscriptionPlan.description}</Card.Content>
|
||||
<Card.Footer className="flex items-center justify-between">
|
||||
<button
|
||||
type="submit"
|
||||
className={cn(
|
||||
"relative inline-flex h-9 items-center rounded-md border border-transparent bg-brand-500 px-4 py-2 text-sm font-medium text-white hover:bg-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2",
|
||||
{
|
||||
"cursor-not-allowed opacity-60": isLoading,
|
||||
}
|
||||
)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading && (
|
||||
<Icons.spinner className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{subscriptionPlan.isPro ? "Manage Subscription" : "Upgrade to PRO"}
|
||||
</button>
|
||||
{subscriptionPlan.isPro ? (
|
||||
<p className="rounded-full text-xs font-medium">
|
||||
{subscriptionPlan.isCanceled
|
||||
? "Your plan will be canceled on "
|
||||
: "Your plan renews on "}
|
||||
{formatDate(subscriptionPlan.stripeCurrentPeriodEnd)}.
|
||||
</p>
|
||||
) : null}
|
||||
</Card.Footer>
|
||||
</Card>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ import { useRouter } from "next/navigation"
|
|||
|
||||
import { Icons } from "@/components/icons"
|
||||
import { postPatchSchema } from "@/lib/validations/post"
|
||||
import toast from "@/ui/toast"
|
||||
import { toast } from "@/ui/toast"
|
||||
|
||||
interface EditorProps {
|
||||
post: Pick<Post, "id" | "title" | "content" | "published">
|
||||
|
|
|
|||
|
|
@ -25,6 +25,11 @@ export const navigationItems: NavItem[] = [
|
|||
icon: Icons.media,
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
title: "Billing",
|
||||
href: "/dashboard/billing",
|
||||
icon: Icons.billing,
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
href: "/dashboard/settings",
|
||||
|
|
|
|||
|
|
@ -2,30 +2,10 @@
|
|||
|
||||
import * as React from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Post } from "@prisma/client"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Icons } from "@/components/icons"
|
||||
import toast from "@/ui/toast"
|
||||
|
||||
async function createPost(): Promise<Pick<Post, "id">> {
|
||||
const response = await fetch("/api/posts", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
title: "Untitled Post",
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response?.ok) {
|
||||
toast({
|
||||
title: "Something went wrong.",
|
||||
message: "Your post was not created. Please try again.",
|
||||
type: "error",
|
||||
})
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
import { toast } from "@/ui/toast"
|
||||
|
||||
interface PostCreateButtonProps
|
||||
extends React.HTMLAttributes<HTMLButtonElement> {}
|
||||
|
|
@ -38,9 +18,34 @@ export function PostCreateButton({
|
|||
const [isLoading, setIsLoading] = React.useState<boolean>(false)
|
||||
|
||||
async function onClick() {
|
||||
setIsLoading(!isLoading)
|
||||
setIsLoading(true)
|
||||
|
||||
const post = await createPost()
|
||||
const response = await fetch("/api/posts", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
title: "Untitled Post",
|
||||
}),
|
||||
})
|
||||
|
||||
setIsLoading(false)
|
||||
|
||||
if (!response?.ok) {
|
||||
if (response.status === 402) {
|
||||
return toast({
|
||||
title: "Limit of 3 posts reached.",
|
||||
message: "Please upgrade to the PRO plan.",
|
||||
type: "error",
|
||||
})
|
||||
}
|
||||
|
||||
return toast({
|
||||
title: "Something went wrong.",
|
||||
message: "Your post was not created. Please try again.",
|
||||
type: "error",
|
||||
})
|
||||
}
|
||||
|
||||
const post = await response.json()
|
||||
|
||||
// This forces a cache invalidation.
|
||||
router.refresh()
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { Post } from "@prisma/client"
|
|||
import { DropdownMenu } from "@/ui/dropdown"
|
||||
import { Icons } from "@/components/icons"
|
||||
import { Alert } from "@/ui/alert"
|
||||
import toast from "@/ui/toast"
|
||||
import { toast } from "@/ui/toast"
|
||||
|
||||
async function deletePost(postId: string) {
|
||||
const response = await fetch(`/api/posts/${postId}`, {
|
||||
|
|
|
|||
|
|
@ -36,12 +36,22 @@ export function UserAccountNav({ user }: UserAccountNavProps) {
|
|||
Dashboard
|
||||
</Link>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item>
|
||||
<Link href="/dashboard/billing" className="w-full">
|
||||
Billing
|
||||
</Link>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item>
|
||||
<Link href="/dashboard/settings" className="w-full">
|
||||
Settings
|
||||
</Link>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item>
|
||||
<Link href="/docs" target="_blank" className="w-full">
|
||||
Documentation
|
||||
</Link>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item>
|
||||
<Link
|
||||
href={siteConfig.links.github}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { zodResolver } from "@hookform/resolvers/zod"
|
|||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { userAuthSchema } from "@/lib/validations/auth"
|
||||
import toast from "@/ui/toast"
|
||||
import { toast } from "@/ui/toast"
|
||||
import { Icons } from "@/components/icons"
|
||||
|
||||
interface UserAuthFormProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
|
@ -25,7 +25,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
|
|||
resolver: zodResolver(userAuthSchema),
|
||||
})
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(false)
|
||||
const searchParams = useSearchParams();
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
async function onSubmit(data: FormData) {
|
||||
setIsLoading(true)
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ import { useRouter } from "next/navigation"
|
|||
import { cn } from "@/lib/utils"
|
||||
import { userNameSchema } from "@/lib/validations/user"
|
||||
import { Card } from "@/ui/card"
|
||||
import { toast } from "@/ui/toast"
|
||||
import { Icons } from "@/components/icons"
|
||||
import toast from "@/ui/toast"
|
||||
|
||||
interface UserNameFormProps extends React.HTMLAttributes<HTMLFormElement> {
|
||||
user: Pick<User, "id" | "name">
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import toast from "@/ui/toast"
|
||||
import { toast } from "@/ui/toast"
|
||||
|
||||
interface DocsSearchProps extends React.HTMLAttributes<HTMLFormElement> {}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Command,
|
||||
CreditCard,
|
||||
File,
|
||||
FileText,
|
||||
Github,
|
||||
|
|
@ -34,6 +35,7 @@ export const Icons = {
|
|||
page: File,
|
||||
media: Image,
|
||||
settings: Settings,
|
||||
billing: CreditCard,
|
||||
ellipsis: MoreVertical,
|
||||
add: Plus,
|
||||
warning: AlertTriangle,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { DocsConfig } from "types/config"
|
||||
import { DocsConfig } from "types"
|
||||
|
||||
export const docsConfig: DocsConfig = {
|
||||
mainNav: [
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { MarketingConfig } from "types/config"
|
||||
import { MarketingConfig } from "types"
|
||||
|
||||
export const marketingConfig: MarketingConfig = {
|
||||
mainNav: [
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { SiteConfig } from "types/config"
|
||||
import { SiteConfig } from "types"
|
||||
|
||||
export const siteConfig: SiteConfig = {
|
||||
name: "Taxonomy",
|
||||
|
|
|
|||
14
config/subscriptions.ts
Normal file
14
config/subscriptions.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { SubscriptionPlan } from "types"
|
||||
|
||||
export const freePlan: SubscriptionPlan = {
|
||||
name: "Free",
|
||||
description:
|
||||
"The free plan is limited to 3 posts. Upgrade to the PRO plan for unlimited posts.",
|
||||
stripePriceId: null,
|
||||
}
|
||||
|
||||
export const proPlan: SubscriptionPlan = {
|
||||
name: "PRO",
|
||||
description: "The PRO plan has unlimited posts.",
|
||||
stripePriceId: process.env.SUBSCRIPTION_PLAN_PRICE_ID_PRO,
|
||||
}
|
||||
5
lib/exceptions.ts
Normal file
5
lib/exceptions.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export class RequiresProPlanError extends Error {
|
||||
constructor(message = "This action requires a pro plan") {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
6
lib/stripe.ts
Normal file
6
lib/stripe.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import Stripe from "stripe"
|
||||
|
||||
export const stripe = new Stripe(process.env.STRIPE_API_KEY, {
|
||||
apiVersion: "2022-11-15",
|
||||
typescript: true,
|
||||
})
|
||||
44
lib/subscription.ts
Normal file
44
lib/subscription.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { UserSubscriptionPlan } from "types"
|
||||
import { freePlan, proPlan } from "@/config/subscriptions"
|
||||
import { db } from "@/lib/db"
|
||||
import { stripe } from "@/lib/stripe"
|
||||
|
||||
export async function getUserSubscriptionPlan(
|
||||
userId: string
|
||||
): Promise<UserSubscriptionPlan> {
|
||||
const user = await db.user.findFirst({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
stripeSubscriptionId: true,
|
||||
stripeCurrentPeriodEnd: true,
|
||||
stripeCustomerId: true,
|
||||
stripePriceId: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Check if user is on a pro plan.
|
||||
const isPro =
|
||||
user.stripePriceId &&
|
||||
user.stripeCurrentPeriodEnd?.getTime() + 86_400_000 > Date.now()
|
||||
|
||||
const plan = isPro ? proPlan : freePlan
|
||||
|
||||
// If user has a pro plan, check cancel status on Stripe.
|
||||
let isCanceled = false
|
||||
if (isPro && user.stripeSubscriptionId) {
|
||||
const stripePlan = await stripe.subscriptions.retrieve(
|
||||
user.stripeSubscriptionId
|
||||
)
|
||||
isCanceled = stripePlan.cancel_at_period_end
|
||||
}
|
||||
|
||||
return {
|
||||
...plan,
|
||||
...user,
|
||||
stripeCurrentPeriodEnd: user.stripeCurrentPeriodEnd?.getTime(),
|
||||
isCanceled,
|
||||
isPro,
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ export function cn(...inputs: ClassValue[]) {
|
|||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function formatDate(input: string): string {
|
||||
export function formatDate(input: string | number): string {
|
||||
const date = new Date(input)
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
|
|
@ -13,3 +13,8 @@ export function formatDate(input: string): string {
|
|||
year: "numeric",
|
||||
})
|
||||
}
|
||||
|
||||
// Since we're already setting NEXTAUTH_URL, we can use same to create absolute URLs.
|
||||
export function absoluteUrl(path: string) {
|
||||
return `${process.env.NEXTAUTH_URL}${path}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@
|
|||
"nodemailer": "^6.8.0",
|
||||
"postmark": "^3.0.14",
|
||||
"prop-types": "^15.8.1",
|
||||
"raw-body": "^2.5.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-editor-js": "^2.1.0",
|
||||
|
|
@ -53,13 +54,14 @@
|
|||
"react-textarea-autosize": "^8.3.4",
|
||||
"sharp": "^0.31.1",
|
||||
"shiki": "^0.11.1",
|
||||
"stripe": "^11.1.0",
|
||||
"tailwind-merge": "^1.6.2",
|
||||
"tailwindcss-animate": "^1.0.5",
|
||||
"zod": "^3.19.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.7",
|
||||
"@types/node": "18.6.4",
|
||||
"@types/node": "^18.11.9",
|
||||
"@types/react": "18.0.15",
|
||||
"@types/react-dom": "18.0.6",
|
||||
"autoprefixer": "^10.4.8",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import * as z from "zod"
|
|||
import { db } from "@/lib/db"
|
||||
import { withMethods } from "@/lib/api-middlewares/with-methods"
|
||||
import { withAuthentication } from "@/lib/api-middlewares/with-authentication"
|
||||
import { getUserSubscriptionPlan } from "@/lib/subscription"
|
||||
import { RequiresProPlanError } from "@/lib/exceptions"
|
||||
|
||||
const postCreateSchema = z.object({
|
||||
title: z.string().optional(),
|
||||
|
|
@ -13,6 +15,7 @@ const postCreateSchema = z.object({
|
|||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const session = await getSession({ req })
|
||||
const user = session?.user
|
||||
|
||||
if (req.method === "GET") {
|
||||
try {
|
||||
|
|
@ -24,7 +27,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||
createdAt: true,
|
||||
},
|
||||
where: {
|
||||
authorId: session.user.id,
|
||||
authorId: user.id,
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -36,6 +39,22 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||
|
||||
if (req.method === "POST") {
|
||||
try {
|
||||
const subscriptionPlan = await getUserSubscriptionPlan(user.id)
|
||||
|
||||
// If user is on a free plan.
|
||||
// Check if user has reached limit of 3 posts.
|
||||
if (!subscriptionPlan?.isPro) {
|
||||
const count = await db.post.count({
|
||||
where: {
|
||||
authorId: user.id,
|
||||
},
|
||||
})
|
||||
|
||||
if (count >= 3) {
|
||||
throw new RequiresProPlanError()
|
||||
}
|
||||
}
|
||||
|
||||
const body = postCreateSchema.parse(JSON.parse(req.body))
|
||||
|
||||
const post = await db.post.create({
|
||||
|
|
@ -55,6 +74,10 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||
return res.status(422).json(error.issues)
|
||||
}
|
||||
|
||||
if (error instanceof RequiresProPlanError) {
|
||||
return res.status(402).end()
|
||||
}
|
||||
|
||||
return res.status(500).end()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
58
pages/api/users/stripe.ts
Normal file
58
pages/api/users/stripe.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { NextApiRequest, NextApiResponse } from "next"
|
||||
import { getSession } from "next-auth/react"
|
||||
|
||||
import { proPlan } from "@/config/subscriptions"
|
||||
import { withMethods } from "@/lib/api-middlewares/with-methods"
|
||||
import { getUserSubscriptionPlan } from "@/lib/subscription"
|
||||
import { stripe } from "@/lib/stripe"
|
||||
import { withAuthentication } from "@/lib/api-middlewares/with-authentication"
|
||||
import { absoluteUrl } from "@/lib/utils"
|
||||
|
||||
const billingUrl = absoluteUrl("/dashboard/billing")
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "GET") {
|
||||
try {
|
||||
const session = await getSession({ req })
|
||||
const user = session.user
|
||||
const subscriptionPlan = await getUserSubscriptionPlan(user.id)
|
||||
|
||||
// The user is on the pro plan.
|
||||
// Create a portal session to manage subscription.
|
||||
if (subscriptionPlan.isPro) {
|
||||
const stripeSession = await stripe.billingPortal.sessions.create({
|
||||
customer: subscriptionPlan.stripeCustomerId,
|
||||
return_url: billingUrl,
|
||||
})
|
||||
|
||||
return res.json({ url: stripeSession.url })
|
||||
}
|
||||
|
||||
// The user is on the free plan.
|
||||
// Create a checkout session to upgrade.
|
||||
const stripeSession = await stripe.checkout.sessions.create({
|
||||
success_url: billingUrl,
|
||||
cancel_url: billingUrl,
|
||||
payment_method_types: ["card"],
|
||||
mode: "subscription",
|
||||
billing_address_collection: "auto",
|
||||
customer_email: user.email,
|
||||
line_items: [
|
||||
{
|
||||
price: proPlan.stripePriceId,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
userId: user.id,
|
||||
},
|
||||
})
|
||||
|
||||
return res.json({ url: stripeSession.url })
|
||||
} catch (error) {
|
||||
return res.status(500).end()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default withMethods(["GET"], withAuthentication(handler))
|
||||
81
pages/api/webhooks/stripe.ts
Normal file
81
pages/api/webhooks/stripe.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { NextApiRequest, NextApiResponse } from "next"
|
||||
import Stripe from "stripe"
|
||||
import rawBody from "raw-body"
|
||||
|
||||
import { stripe } from "@/lib/stripe"
|
||||
import { db } from "@/lib/db"
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
// Turn off the body parser so we can access raw body for verification.
|
||||
bodyParser: false,
|
||||
},
|
||||
}
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
const body = await rawBody(req)
|
||||
const signature = req.headers["stripe-signature"]
|
||||
|
||||
let event: Stripe.Event
|
||||
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(
|
||||
body,
|
||||
signature,
|
||||
process.env.STRIPE_WEBHOOK_SECRET
|
||||
)
|
||||
} catch (error) {
|
||||
return res.status(400).send(`Webhook Error: ${error.message}`)
|
||||
}
|
||||
|
||||
const session = event.data.object as Stripe.Checkout.Session
|
||||
|
||||
if (event.type === "checkout.session.completed") {
|
||||
// Retrieve the subscription details from Stripe.
|
||||
const subscription = await stripe.subscriptions.retrieve(
|
||||
session.subscription as string
|
||||
)
|
||||
|
||||
// Update the user stripe into in our database.
|
||||
// Since this is the initial subscription, we need to update
|
||||
// the subscription id and customer id.
|
||||
await db.user.update({
|
||||
where: {
|
||||
id: session.metadata.userId,
|
||||
},
|
||||
data: {
|
||||
stripeSubscriptionId: subscription.id,
|
||||
stripeCustomerId: subscription.customer as string,
|
||||
stripePriceId: subscription.items.data[0].price.id,
|
||||
stripeCurrentPeriodEnd: new Date(
|
||||
subscription.current_period_end * 1000
|
||||
),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (event.type === "invoice.payment_succeeded") {
|
||||
// Retrieve the subscription details from Stripe.
|
||||
const subscription = await stripe.subscriptions.retrieve(
|
||||
session.subscription as string
|
||||
)
|
||||
|
||||
// Update the price id and set the new period end.
|
||||
await db.user.update({
|
||||
where: {
|
||||
stripeSubscriptionId: subscription.id,
|
||||
},
|
||||
data: {
|
||||
stripePriceId: subscription.items.data[0].price.id,
|
||||
stripeCurrentPeriodEnd: new Date(
|
||||
subscription.current_period_end * 1000
|
||||
),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return res.json({})
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[stripe_customer_id]` on the table `users` will be added. If there are existing duplicate values, this will fail.
|
||||
- A unique constraint covering the columns `[stripe_subscription_id]` on the table `users` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE `accounts` DROP FOREIGN KEY `accounts_userId_fkey`;
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE `posts` DROP FOREIGN KEY `posts_authorId_fkey`;
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE `sessions` DROP FOREIGN KEY `sessions_userId_fkey`;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `users` ADD COLUMN `stripe_current_period_end` DATETIME(3) NULL,
|
||||
ADD COLUMN `stripe_customer_id` VARCHAR(191) NULL,
|
||||
ADD COLUMN `stripe_price_id` VARCHAR(191) NULL,
|
||||
ADD COLUMN `stripe_subscription_id` VARCHAR(191) NULL;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX `users_stripe_customer_id_key` ON `users`(`stripe_customer_id`);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX `users_stripe_subscription_id_key` ON `users`(`stripe_subscription_id`);
|
||||
|
|
@ -57,6 +57,11 @@ model User {
|
|||
sessions Session[]
|
||||
Post Post[]
|
||||
|
||||
stripeCustomerId String? @unique @map(name: "stripe_customer_id")
|
||||
stripeSubscriptionId String? @unique @map(name: "stripe_subscription_id")
|
||||
stripePriceId String? @map(name: "stripe_price_id")
|
||||
stripeCurrentPeriodEnd DateTime? @map(name: "stripe_current_period_end")
|
||||
|
||||
@@map(name: "users")
|
||||
}
|
||||
|
||||
|
|
|
|||
18
types/config.d.ts
vendored
18
types/config.d.ts
vendored
|
|
@ -1,18 +0,0 @@
|
|||
import { MainNavItem, SidebarNavItem } from "types"
|
||||
|
||||
export type SiteConfig = {
|
||||
name: string
|
||||
links: {
|
||||
twitter: string
|
||||
github: string
|
||||
}
|
||||
}
|
||||
|
||||
export type DocsConfig = {
|
||||
mainNav: MainNavItem[]
|
||||
sidebarNav: SidebarNavItem[]
|
||||
}
|
||||
|
||||
export type MarketingConfig = {
|
||||
mainNav: MainNavItem[]
|
||||
}
|
||||
31
types/index.d.ts
vendored
31
types/index.d.ts
vendored
|
|
@ -1,3 +1,4 @@
|
|||
import { User } from "@prisma/client"
|
||||
import type { Icon } from "lucide-react"
|
||||
|
||||
export type NavItem = {
|
||||
|
|
@ -23,3 +24,33 @@ export type SidebarNavItem = {
|
|||
items: NavLink[]
|
||||
}
|
||||
)
|
||||
|
||||
export type SiteConfig = {
|
||||
name: string
|
||||
links: {
|
||||
twitter: string
|
||||
github: string
|
||||
}
|
||||
}
|
||||
|
||||
export type DocsConfig = {
|
||||
mainNav: MainNavItem[]
|
||||
sidebarNav: SidebarNavItem[]
|
||||
}
|
||||
|
||||
export type MarketingConfig = {
|
||||
mainNav: MainNavItem[]
|
||||
}
|
||||
|
||||
export type SubscriptionPlan = {
|
||||
name: string
|
||||
description: string
|
||||
stripePriceId: string
|
||||
}
|
||||
|
||||
export type UserSubscriptionPlan = SubscriptionPlan &
|
||||
Pick<User, "stripeCustomerId" | "stripeSubscriptionId"> & {
|
||||
stripeCurrentPeriodEnd: number
|
||||
isPro: boolean
|
||||
isCanceled: boolean
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,11 +66,9 @@ interface ToastOpts {
|
|||
duration?: number
|
||||
}
|
||||
|
||||
export default function toast(opts: ToastOpts) {
|
||||
export function toast(opts: ToastOpts) {
|
||||
const { title, message, type = "default", duration = 3000 } = opts
|
||||
|
||||
// alert(message)
|
||||
|
||||
return hotToast.custom(
|
||||
({ visible }) => (
|
||||
<Toast
|
||||
|
|
|
|||
87
yarn.lock
87
yarn.lock
|
|
@ -1070,12 +1070,7 @@
|
|||
resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.31.tgz#31b7ca6407128a3d2bbc27fe2d21b345397f6197"
|
||||
integrity sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==
|
||||
|
||||
"@types/node@18.6.4":
|
||||
version "18.6.4"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.6.4.tgz#fd26723a8a3f8f46729812a7f9b4fc2d1608ed39"
|
||||
integrity sha512-I4BD3L+6AWiUobfxZ49DlU43gtI+FTHSv9pE2Zekg6KjMpre4ByusaljW3vYSLJrvQ1ck1hUaeVu8HVlY3vzHg==
|
||||
|
||||
"@types/node@>=12.12.47", "@types/node@>=13.7.0":
|
||||
"@types/node@>=12.12.47", "@types/node@>=13.7.0", "@types/node@>=8.1.0", "@types/node@^18.11.9":
|
||||
version "18.11.9"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.11.9.tgz#02d013de7058cea16d36168ef2fc653464cfbad4"
|
||||
integrity sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg==
|
||||
|
|
@ -1417,6 +1412,11 @@ buffer@^5.5.0:
|
|||
base64-js "^1.3.1"
|
||||
ieee754 "^1.1.13"
|
||||
|
||||
bytes@3.1.2:
|
||||
version "3.1.2"
|
||||
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5"
|
||||
integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==
|
||||
|
||||
call-bind@^1.0.0, call-bind@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c"
|
||||
|
|
@ -1704,6 +1704,11 @@ defined@^1.0.0:
|
|||
resolved "https://registry.yarnpkg.com/defined/-/defined-1.0.1.tgz#c0b9db27bfaffd95d6f61399419b893df0f91ebf"
|
||||
integrity sha512-hsBd2qSVCRE+5PmNdHt1uzyrFu5d3RwmFDKzyNZMFq/EwDNJF7Ee5+D5oEKF0hU6LhtoUF1macFvOe4AskQC1Q==
|
||||
|
||||
depd@2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"
|
||||
integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==
|
||||
|
||||
dequal@^2.0.0:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be"
|
||||
|
|
@ -2675,6 +2680,24 @@ html-void-elements@^2.0.0:
|
|||
resolved "https://registry.yarnpkg.com/html-void-elements/-/html-void-elements-2.0.1.tgz#29459b8b05c200b6c5ee98743c41b979d577549f"
|
||||
integrity sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A==
|
||||
|
||||
http-errors@2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3"
|
||||
integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==
|
||||
dependencies:
|
||||
depd "2.0.0"
|
||||
inherits "2.0.4"
|
||||
setprototypeof "1.2.0"
|
||||
statuses "2.0.1"
|
||||
toidentifier "1.0.1"
|
||||
|
||||
iconv-lite@0.4.24:
|
||||
version "0.4.24"
|
||||
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
|
||||
integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
|
||||
dependencies:
|
||||
safer-buffer ">= 2.1.2 < 3"
|
||||
|
||||
ieee754@^1.1.13:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
|
||||
|
|
@ -2716,7 +2739,7 @@ inflight@^1.0.4:
|
|||
once "^1.3.0"
|
||||
wrappy "1"
|
||||
|
||||
inherits@2, inherits@^2.0.3, inherits@^2.0.4:
|
||||
inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4:
|
||||
version "2.0.4"
|
||||
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
|
||||
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
|
||||
|
|
@ -4252,6 +4275,13 @@ punycode@^2.1.0:
|
|||
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
|
||||
integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
|
||||
|
||||
qs@^6.11.0:
|
||||
version "6.11.0"
|
||||
resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a"
|
||||
integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==
|
||||
dependencies:
|
||||
side-channel "^1.0.4"
|
||||
|
||||
queue-microtask@^1.2.2:
|
||||
version "1.2.3"
|
||||
resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
|
||||
|
|
@ -4262,6 +4292,16 @@ quick-lru@^5.1.1:
|
|||
resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932"
|
||||
integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==
|
||||
|
||||
raw-body@^2.5.1:
|
||||
version "2.5.1"
|
||||
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.1.tgz#fe1b1628b181b700215e5fd42389f98b71392857"
|
||||
integrity sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==
|
||||
dependencies:
|
||||
bytes "3.1.2"
|
||||
http-errors "2.0.0"
|
||||
iconv-lite "0.4.24"
|
||||
unpipe "1.0.0"
|
||||
|
||||
rc@^1.2.7:
|
||||
version "1.2.8"
|
||||
resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"
|
||||
|
|
@ -4603,6 +4643,11 @@ safe-regex-test@^1.0.0:
|
|||
get-intrinsic "^1.1.3"
|
||||
is-regex "^1.1.4"
|
||||
|
||||
"safer-buffer@>= 2.1.2 < 3":
|
||||
version "2.1.2"
|
||||
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
|
||||
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
|
||||
|
||||
scheduler@^0.23.0:
|
||||
version "0.23.0"
|
||||
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe"
|
||||
|
|
@ -4630,6 +4675,11 @@ semver@^7.3.5, semver@^7.3.7:
|
|||
dependencies:
|
||||
lru-cache "^6.0.0"
|
||||
|
||||
setprototypeof@1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424"
|
||||
integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==
|
||||
|
||||
sharp@^0.31.1:
|
||||
version "0.31.1"
|
||||
resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.31.1.tgz#b2f7076d381a120761aa93700cadefcf90a22458"
|
||||
|
|
@ -4733,6 +4783,11 @@ sprintf-js@~1.0.2:
|
|||
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
|
||||
integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==
|
||||
|
||||
statuses@2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63"
|
||||
integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==
|
||||
|
||||
string-width@^4.1.0, string-width@^4.2.0:
|
||||
version "4.2.3"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||
|
|
@ -4816,6 +4871,14 @@ strip-json-comments@~2.0.1:
|
|||
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
|
||||
integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==
|
||||
|
||||
stripe@^11.1.0:
|
||||
version "11.1.0"
|
||||
resolved "https://registry.yarnpkg.com/stripe/-/stripe-11.1.0.tgz#28931a1136ccc2dffa44ef27027275a7b346a266"
|
||||
integrity sha512-erOslPQZSYKOotQjmKRy4eBon/tdhzLIYzBdPSNVWDdatSQozkkPlh8mVeXNwubYYZYx61/yS23eWiGDF93z2w==
|
||||
dependencies:
|
||||
"@types/node" ">=8.1.0"
|
||||
qs "^6.11.0"
|
||||
|
||||
style-to-object@^0.3.0:
|
||||
version "0.3.0"
|
||||
resolved "https://registry.yarnpkg.com/style-to-object/-/style-to-object-0.3.0.tgz#b1b790d205991cc783801967214979ee19a76e46"
|
||||
|
|
@ -4914,6 +4977,11 @@ to-regex-range@^5.0.1:
|
|||
dependencies:
|
||||
is-number "^7.0.0"
|
||||
|
||||
toidentifier@1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35"
|
||||
integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==
|
||||
|
||||
toml@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/toml/-/toml-3.0.0.tgz#342160f1af1904ec9d204d03a5d61222d762c5ee"
|
||||
|
|
@ -5103,6 +5171,11 @@ unist-util-visit@^4.0.0, unist-util-visit@^4.1.1:
|
|||
unist-util-is "^5.0.0"
|
||||
unist-util-visit-parents "^5.1.1"
|
||||
|
||||
unpipe@1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
|
||||
integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==
|
||||
|
||||
update-browserslist-db@^1.0.9:
|
||||
version "1.0.10"
|
||||
resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz#0f54b876545726f17d00cd9a2561e6dade943ff3"
|
||||
|
|
|
|||
Loading…
Reference in a new issue