feat: implement stripe billing

This commit is contained in:
shadcn 2022-11-21 12:40:10 +04:00
parent 61df1bec71
commit b30ac75581
31 changed files with 579 additions and 64 deletions

View file

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

View 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>
)
}

View 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>
)
}

View 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>
)
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,4 @@
import { DocsConfig } from "types/config"
import { DocsConfig } from "types"
export const docsConfig: DocsConfig = {
mainNav: [

View file

@ -1,4 +1,4 @@
import { MarketingConfig } from "types/config"
import { MarketingConfig } from "types"
export const marketingConfig: MarketingConfig = {
mainNav: [

View file

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

View file

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

View file

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

View file

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

View 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({})
}

View file

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

View file

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

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

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

View file

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

View file

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