💳 Support payments with Stripe (#822)

This commit is contained in:
Luke Vella 2023-08-23 15:29:40 +01:00 committed by GitHub
parent 969ae35971
commit 6f425edeaa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 712 additions and 229 deletions

View file

@ -48,6 +48,7 @@
"iron-session": "^6.3.1",
"js-cookie": "^3.0.1",
"lodash": "^4.17.21",
"micro": "^10.0.1",
"nanoid": "^4.0.0",
"next-i18next": "^13.0.3",
"next-seo": "^5.15.0",

View file

@ -211,8 +211,6 @@
"duplicateDescription": "Create a new poll based on this one",
"duplicateTitleLabel": "Title",
"duplicateTitleDescription": "Hint: Give your new poll a unique title",
"thankYou": "Thank you!",
"pleaseWait": "Your account is being upgraded. This should only take a few seconds.",
"proFeature": "Pro Feature",
"upgradeOverlaySubtitle2": "Please upgrade to a paid plan to use this feature. This is how we keep the lights on :)",
"savePercent": "Save {percent}%",
@ -227,5 +225,10 @@
"scrollLeft": "Scroll Left",
"scrollRight": "Scroll Right",
"shrink": "Shrink",
"expand": "Expand"
"expand": "Expand",
"activeSubscription": "Thank you for subscribing to Rallly Pro. You can manage your subscription and billing details from the billing portal.",
"billingPortal": "Billing Portal",
"supportDescription": "Need help with anything?",
"supportBilling": "Please reach out if you need any assistance.",
"contactSupport": "Contact Support"
}

View file

@ -1,11 +1,9 @@
import { trpc } from "@rallly/backend";
import {
CalendarCheck2Icon,
CopyIcon,
DatabaseIcon,
HeartIcon,
ImageOffIcon,
Loader2Icon,
LockIcon,
Settings2Icon,
TrendingUpIcon,
@ -51,32 +49,8 @@ const Feature = ({
);
};
const ThankYou = () => {
trpc.user.getBilling.useQuery(undefined, {
refetchInterval: 1000,
});
return (
<div className="space-y-2 text-center">
<h2>
<Trans i18nKey="thankYou" defaults="Thank you!" />
</h2>
<p className="text-muted-foreground mx-auto max-w-xs text-sm">
<Trans
i18nKey="pleaseWait"
defaults="Your account is being upgraded. This should only take a few seconds."
/>
</p>
<div className="p-4 text-gray-500">
<Loader2Icon className="inline-block h-7 w-7 animate-spin" />
</div>
</div>
);
};
const Teaser = () => {
const router = useRouter();
const [didUpgrade, setDidUpgrade] = React.useState(false);
const [tab, setTab] = React.useState("yearly");
@ -106,14 +80,12 @@ const Teaser = () => {
className="text-center"
aria-hidden="true"
>
<Badge className="translate-y-0 py-0.5 px-4 text-lg">
<Badge className="translate-y-0 px-4 py-0.5 text-lg">
<Trans i18nKey="planPro" />
</Badge>
</m.div>
</div>
{didUpgrade ? (
<ThankYou />
) : (
<div className="space-y-6">
<div className="space-y-2 text-center">
<h2 className="text-center">
@ -156,9 +128,7 @@ const Teaser = () => {
<div className="text-center">
<div className="flex items-start justify-center gap-2.5">
<div className="flex items-end gap-2">
<div className="font-bold text-gray-500 line-through">
$5
</div>
<div className="font-bold text-gray-500 line-through">$5</div>
<div className=" text-4xl font-bold">$2.50</div>
</div>
<div>
@ -226,10 +196,7 @@ const Teaser = () => {
</Feature>
</ul>
<div className="grid gap-2.5">
<UpgradeButton
annual={tab === "yearly"}
onUpgrade={() => setDidUpgrade(true)}
>
<UpgradeButton annual={tab === "yearly"}>
<Trans i18nKey="upgrade" defaults="Upgrade" />
</UpgradeButton>
<Button asChild className="w-full">
@ -239,7 +206,6 @@ const Teaser = () => {
</Button>
</div>
</div>
)}
</m.div>
);
};

View file

@ -1,56 +1,32 @@
import { trpc } from "@rallly/backend";
import { Button } from "@rallly/ui/button";
import { useRouter } from "next/router";
import Link from "next/link";
import React from "react";
import { useUser } from "@/components/user-provider";
import { planIdMonthly, planIdYearly } from "@/utils/constants";
import { usePostHog } from "@/utils/posthog";
export const UpgradeButton = ({
children,
onUpgrade,
annual,
}: React.PropsWithChildren<{ annual?: boolean; onUpgrade?: () => void }>) => {
}: React.PropsWithChildren<{ annual?: boolean }>) => {
const posthog = usePostHog();
const [isPendingSubscription, setPendingSubscription] = React.useState(false);
const { user } = useUser();
const router = useRouter();
trpc.user.getBilling.useQuery(undefined, {
refetchInterval: isPendingSubscription ? 1000 : 0,
});
return (
<>
<Button
className="w-full"
loading={isPendingSubscription}
variant="primary"
asChild
onClick={() => {
posthog?.capture("click upgrade button");
if (user.isGuest) {
router.push("/login");
} else {
window.Paddle.Checkout.open({
allowQuantity: false,
product: annual ? planIdYearly : planIdMonthly,
email: user.email,
disableLogout: true,
passthrough: JSON.stringify({ userId: user.id }),
successCallback: () => {
posthog?.capture("upgrade", {
period: annual ? "yearly" : "monthly",
});
onUpgrade?.();
// fetch user till we get the new plan
setPendingSubscription(true);
},
});
}
}}
>
<Link
href={`/api/stripe/checkout?period=${
annual ? "yearly" : "monthly"
}&return_path=${encodeURIComponent(window.location.pathname)}`}
>
{children}
</Link>
</Button>
</>
);

View file

@ -1,4 +1,3 @@
import { trpc } from "@rallly/backend";
import {
ChevronDown,
CreditCardIcon,
@ -26,19 +25,15 @@ import Link from "next/link";
import { Trans } from "@/components/trans";
import { CurrentUserAvatar } from "@/components/user";
import { usePlan } from "@/contexts/plan";
import { isFeedbackEnabled } from "@/utils/constants";
import { IfAuthenticated, IfGuest, useUser } from "./user-provider";
const Plan = () => {
const { isFetched, data } = trpc.user.getBilling.useQuery();
if (!isFetched) {
return null;
}
const plan = usePlan();
const isPlus = data && data.endDate.getTime() > Date.now();
if (isPlus) {
if (plan === "paid") {
return (
<Badge>
<Trans i18nKey="planPro" defaults="Pro" />

View file

@ -50,7 +50,7 @@ export const UserProvider = (props: { children?: React.ReactNode }) => {
const queryClient = trpc.useContext();
const user = useWhoAmI();
const billingQuery = trpc.user.getBilling.useQuery();
const subscriptionQuery = trpc.user.subscription.useQuery();
const { data: userPreferences } = trpc.userPreferences.get.useQuery();
const shortName = user
@ -59,7 +59,7 @@ export const UserProvider = (props: { children?: React.ReactNode }) => {
: user.id.substring(0, 10)
: t("guest");
if (!user || userPreferences === undefined || !billingQuery.isFetched) {
if (!user || userPreferences === undefined || !subscriptionQuery.isFetched) {
return null;
}

View file

@ -1,11 +1,9 @@
import { trpc } from "@rallly/backend";
export const usePlan = () => {
const { data } = trpc.user.getBilling.useQuery(undefined, {
staleTime: 10 * 1000,
});
const { data } = trpc.user.subscription.useQuery();
const isPaid = Boolean(data && data.endDate.getTime() > Date.now());
const isPaid = data?.active === true;
return isPaid ? "paid" : "free";
};

View file

@ -0,0 +1,105 @@
import { getSession } from "@rallly/backend/next/session";
import { stripe } from "@rallly/backend/stripe";
import { prisma } from "@rallly/database";
import { absoluteUrl } from "@rallly/utils";
import { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
export const config = {
edge: true,
};
const inputSchema = z.object({
period: z.enum(["monthly", "yearly"]).optional(),
success_path: z.string().optional(),
return_path: z.string().optional(),
});
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const userSession = await getSession(req, res);
if (userSession.user?.isGuest !== false) {
// You need to be logged in to subscribe
return res
.status(403)
.redirect(
`/login${req.url ? `?redirect=${encodeURIComponent(req.url)}` : ""}`,
);
}
const { period = "monthly", return_path } = inputSchema.parse(req.query);
const user = await prisma.user.findUnique({
where: {
id: userSession.user.id,
},
select: {
email: true,
customerId: true,
subscription: {
select: {
active: true,
},
},
},
});
if (!user) {
return res.status(403).redirect("/logout");
}
if (user.subscription?.active === true) {
// User already has an active subscription. Take them to customer portal
return res.redirect("/api/stripe/portal");
}
const session = await stripe.checkout.sessions.create({
success_url: absoluteUrl(
return_path ?? "/api/stripe/portal?session_id={CHECKOUT_SESSION_ID}",
),
cancel_url: absoluteUrl(return_path),
...(user.customerId
? {
// use existing customer if available to reuse payment details
customer: user.customerId,
customer_update: {
// needed for tax id collection
name: "auto",
},
}
: {
// supply email if user is not a customer yet
customer_email: user.email,
}),
mode: "subscription",
allow_promotion_codes: true,
billing_address_collection: "auto",
tax_id_collection: {
enabled: true,
},
metadata: {
userId: userSession.user.id,
},
line_items: [
{
price:
period === "yearly"
? (process.env.STRIPE_YEARLY_PRICE as string)
: (process.env.STRIPE_MONTHLY_PRICE as string),
quantity: 1,
},
],
});
if (session.url) {
// redirect to checkout session
return res.status(303).redirect(session.url);
}
return res
.status(500)
.json({ error: "Something went wrong while creating a checkout session" });
}

View file

@ -0,0 +1,58 @@
import { getSession } from "@rallly/backend/next/session";
import { stripe } from "@rallly/backend/stripe";
import { absoluteUrl } from "@rallly/utils";
import { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
const inputSchema = z.object({
session_id: z.string().optional(),
return_path: z.string().optional(),
});
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const userSession = await getSession(req, res);
if (userSession.user?.isGuest !== false) {
// You need to be logged in to subscribe
return res
.status(403)
.redirect(
`/login${req.url ? `?redirect=${encodeURIComponent(req.url)}` : ""}`,
);
}
const user = await prisma?.user.findUnique({
where: {
id: userSession.user.id,
},
select: {
email: true,
customerId: true,
},
});
if (!user) {
return res.status(403).redirect("/logout");
}
const { session_id: sessionId, return_path } = inputSchema.parse(req.query);
let customerId: string;
if (sessionId) {
const session = await stripe.checkout.sessions.retrieve(sessionId);
customerId = session.customer as string;
} else {
customerId = user.customerId as string;
}
const portalSession = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: absoluteUrl(return_path),
});
res.status(303).redirect(portalSession.url);
}

View file

@ -0,0 +1,110 @@
import type { Stripe } from "@rallly/backend/stripe";
import { stripe } from "@rallly/backend/stripe";
import { prisma } from "@rallly/database";
import { buffer } from "micro";
import { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
export const config = {
api: {
bodyParser: false,
},
};
const toDate = (date: number) => new Date(date * 1000);
const endpointSecret = process.env.STRIPE_SIGNING_SECRET as string;
const validatedWebhook = async (req: NextApiRequest) => {
const signature = req.headers["stripe-signature"] as string;
const buf = await buffer(req);
try {
return stripe.webhooks.constructEvent(buf, signature, endpointSecret);
} catch (err) {
return null;
}
};
const metadataSchema = z.object({
userId: z.string(),
});
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method !== "POST") {
return res.status(405).end();
}
if (!endpointSecret) {
return res.status(400).send("No endpoint secret");
}
const event = await validatedWebhook(req);
if (!event) {
return res.status(400).send("Invalid signature");
}
switch (event.type) {
case "checkout.session.completed":
const checkoutSession = event.data.object as Stripe.Checkout.Session;
const { userId } = metadataSchema.parse(checkoutSession.metadata);
if (!userId) {
return res.status(400).send("Missing client reference ID");
}
await prisma.user.update({
where: {
id: userId,
},
data: {
customerId: checkoutSession.customer as string,
subscriptionId: checkoutSession.subscription as string,
},
});
break;
case "customer.subscription.deleted":
case "customer.subscription.updated":
case "customer.subscription.created":
const subscription = event.data.object as Stripe.Subscription;
// check if the subscription is active
const isActive = subscription.status === "active";
// get the subscription price details
const lineItem = subscription.items.data[0];
// update/create the subscription in the database
const { price } = lineItem;
await prisma.subscription.upsert({
where: {
id: subscription.id,
},
create: {
id: subscription.id,
active: isActive,
priceId: price.id,
currency: price.currency ?? null,
createdAt: toDate(subscription.created),
periodStart: toDate(subscription.current_period_start),
periodEnd: toDate(subscription.current_period_end),
},
update: {
active: isActive,
priceId: price.id,
currency: price.currency ?? null,
createdAt: toDate(subscription.created),
periodStart: toDate(subscription.current_period_start),
periodEnd: toDate(subscription.current_period_end),
},
});
break;
default:
// Unexpected event type
console.error(`Unhandled event type ${event.type}.`);
}
res.end();
}

View file

@ -1,5 +1,6 @@
import { trpc } from "@rallly/backend";
import { CreditCardIcon } from "@rallly/icons";
import { ArrowUpRight, CreditCardIcon } from "@rallly/icons";
import { Badge } from "@rallly/ui/badge";
import { Button } from "@rallly/ui/button";
import { Card } from "@rallly/ui/card";
import { Label } from "@rallly/ui/label";
@ -13,7 +14,6 @@ import { getProfileLayout } from "@/components/layouts/profile-layout";
import { SettingsSection } from "@/components/settings/settings-section";
import { Trans } from "@/components/trans";
import { useUser } from "@/components/user-provider";
import { usePlan } from "@/contexts/plan";
import { NextPageWithLayout } from "../../types";
import { getStaticTranslations } from "../../utils/with-page-translations";
@ -25,6 +25,38 @@ declare global {
}
}
const BillingPortal = () => {
return (
<div>
<div className="mb-4 flex items-center justify-between">
<Badge>
<Trans i18nKey="planPro" />
</Badge>
</div>
<p>
<Trans
i18nKey="activeSubscription"
defaults="Thank you for subscribing to Rallly Pro. You can manage your subscription and billing details from the billing portal."
/>
</p>
<div className="mt-6">
<Button asChild>
<Link
href={`/api/stripe/portal?return_path=${encodeURIComponent(
window.location.pathname,
)}`}
>
<span>
<Trans i18nKey="billingPortal" defaults="Billing Portal" />
</span>
<ArrowUpRight className="h-4 w-4" />
</Link>
</Button>
</div>
</div>
);
};
export const proPlanIdMonthly = process.env
.NEXT_PUBLIC_PRO_PLAN_ID_MONTHLY as string;
@ -34,25 +66,31 @@ export const proPlanIdYearly = process.env
const SubscriptionStatus = () => {
const { user } = useUser();
const plan = usePlan();
const isPlus = plan === "paid";
const { data } = trpc.user.subscription.useQuery();
if (!data) {
return null;
}
if (user.isGuest) {
return <>You need to be logged in.</>;
}
if (isPlus) {
return <BillingStatus />;
if (data.legacy) {
// User is on the old billing system
return <LegacyBilling />;
} else if (data.active) {
return <BillingPortal />;
} else {
return <BillingPlans />;
}
};
const BillingStatus = () => {
const LegacyBilling = () => {
const { data: userPaymentData } = trpc.user.getBilling.useQuery();
if (!userPaymentData) {
return <p>Something when wrong. Missing user payment data.</p>;
return null;
}
const { status, endDate, planId } = userPaymentData;
@ -181,6 +219,29 @@ const Page: NextPageWithLayout = () => {
>
<SubscriptionStatus />
</SettingsSection>
<SettingsSection
title={<Trans i18nKey="support" defaults="Support" />}
description={
<Trans
i18nKey="supportDescription"
defaults="Need help with anything?"
/>
}
>
<div className="space-y-6">
<p>
<Trans
i18nKey="supportBilling"
defaults="Please reach out if you need any assistance."
/>
</p>
<Button asChild>
<Link href="mailto:support@rallly.co">
<Trans i18nKey="contactSupport" defaults="Contact Support" />
</Link>
</Button>
</div>
</SettingsSection>
</div>
);
};

View file

@ -1,8 +1,11 @@
import type { IncomingMessage, ServerResponse } from "http";
import { getIronSession } from "iron-session/edge";
import { NextRequest, NextResponse } from "next/server";
import { sessionConfig } from "../session-config";
export const getSession = async (req: NextRequest, res: NextResponse) => {
export const getSession = async (
req: Request | IncomingMessage,
res: Response | ServerResponse,
) => {
return getIronSession(req, res, sessionConfig);
};

View file

@ -1,3 +1,5 @@
import type { IncomingMessage, ServerResponse } from "http";
import { getIronSession } from "iron-session";
import { withIronSessionApiRoute, withIronSessionSsr } from "iron-session/next";
import {
GetServerSideProps,
@ -13,6 +15,13 @@ export function withSessionRoute(handler: NextApiHandler) {
return withIronSessionApiRoute(handler, sessionConfig);
}
export const getSession = async (
req: Request | IncomingMessage,
res: Response | ServerResponse,
) => {
return getIronSession(req, res, sessionConfig);
};
export function withSessionSsr(
handler: GetServerSideProps | GetServerSideProps[],
options?: {

View file

@ -19,6 +19,7 @@
"@trpc/server": "^10.13.0",
"iron-session": "^6.3.1",
"spacetime": "^7.4.4",
"stripe": "^13.2.0",
"superjson": "^1.12.2",
"timezone-soft": "^1.4.1"
}

View file

@ -0,0 +1,8 @@
import Stripe from "stripe";
export type { Stripe } from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, {
apiVersion: "2023-08-16",
typescript: true,
});

View file

@ -1,10 +1,10 @@
import { prisma } from "@rallly/database";
import { z } from "zod";
import { publicProcedure, router } from "../trpc";
import { privateProcedure, router } from "../trpc";
export const user = router({
getBilling: publicProcedure.query(async ({ ctx }) => {
getBilling: privateProcedure.query(async ({ ctx }) => {
return await prisma.userPaymentData.findUnique({
select: {
subscriptionId: true,
@ -19,7 +19,50 @@ export const user = router({
},
});
}),
changeName: publicProcedure
subscription: privateProcedure.query(async ({ ctx }) => {
const user = await prisma.user.findUnique({
where: {
id: ctx.user.id,
},
select: {
subscription: {
select: {
active: true,
},
},
},
});
if (user?.subscription?.active === true) {
return {
active: true,
};
}
const userPaymentData = await prisma.userPaymentData.findUnique({
where: {
userId: ctx.user.id,
},
select: {
endDate: true,
},
});
if (
userPaymentData?.endDate &&
userPaymentData.endDate.getTime() > Date.now()
) {
return {
active: true,
legacy: true,
};
}
return {
active: false,
};
}),
changeName: privateProcedure
.input(
z.object({
name: z.string().min(1).max(100),

View file

@ -0,0 +1,27 @@
/*
Warnings:
- A unique constraint covering the columns `[subscription_id]` on the table `users` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "users" ADD COLUMN "customer_id" TEXT,
ADD COLUMN "subscription_id" TEXT;
-- CreateTable
CREATE TABLE "subscriptions" (
"id" TEXT NOT NULL,
"price_id" TEXT NOT NULL,
"active" BOOLEAN NOT NULL,
"currency" TEXT,
"interval" TEXT,
"interval_count" INTEGER,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"period_start" TIMESTAMP(3) NOT NULL,
"period_end" TIMESTAMP(3) NOT NULL,
CONSTRAINT "subscriptions_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_subscription_id_key" ON "users"("subscription_id");

View file

@ -26,6 +26,9 @@ model User {
polls Poll[]
watcher Watcher[]
events Event[]
customerId String? @map("customer_id")
subscription Subscription? @relation(fields: [subscriptionId], references: [id])
subscriptionId String? @unique @map("subscription_id")
@@map("users")
}
@ -52,6 +55,21 @@ model UserPaymentData {
@@map("user_payment_data")
}
model Subscription {
id String @id
priceId String @map("price_id")
active Boolean
currency String?
interval String?
intervalCount Int? @map("interval_count")
createdAt DateTime @default(now()) @map("created_at")
periodStart DateTime @map("period_start")
periodEnd DateTime @map("period_end")
user User?
@@map("subscriptions")
}
model UserPreferences {
userId String @id @map("user_id")
timeZone String? @map("time_zone")

View file

@ -59,7 +59,6 @@
"EMAIL_PROVIDER",
"MAINTENANCE_MODE",
"NEXT_PUBLIC_APP_BASE_URL",
"NEXT_PUBLIC_SHORT_BASE_URL",
"NEXT_PUBLIC_APP_VERSION",
"NEXT_PUBLIC_BASE_URL",
"NEXT_PUBLIC_BETA",
@ -76,6 +75,7 @@
"NEXT_PUBLIC_PRO_PLAN_ID_MONTHLY",
"NEXT_PUBLIC_PRO_PLAN_ID_YEARLY",
"NEXT_PUBLIC_SENTRY_DSN",
"NEXT_PUBLIC_SHORT_BASE_URL",
"NEXT_PUBLIC_VERCEL_URL",
"NODE_ENV",
"NOREPLY_EMAIL",
@ -90,6 +90,10 @@
"SMTP_SECURE",
"SMTP_TLS_ENABLED",
"SMTP_USER",
"STRIPE_MONTHLY_PRICE",
"STRIPE_SECRET_KEY",
"STRIPE_SIGNING_SECRET",
"STRIPE_YEARLY_PRICE",
"SUPPORT_EMAIL"
]
}

101
yarn.lock
View file

@ -3839,6 +3839,11 @@
resolved "https://registry.npmjs.org/@types/node/-/node-18.14.2.tgz"
integrity sha512-1uEQxww3DaghA0RxqHx0O0ppVlo43pJhepY51OxuQIKHpjbnYLA7vcdwioNPzIqmC2u3I/dmylcqjlh0e7AyUA==
"@types/node@>=8.1.0":
version "20.5.0"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.5.0.tgz#7fc8636d5f1aaa3b21e6245e97d56b7f56702313"
integrity sha512-Mgq7eCtoTjT89FqNoTzzXg2XvCi5VMhRV6+I2aYanc6kQCBImeNaAYRs/DyoVqk1YEUJK5gN9VO7HRIdz4Wo3Q==
"@types/node@^12.7.1":
version "12.20.55"
resolved "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz"
@ -4216,6 +4221,11 @@ append-buffer@^1.0.2:
dependencies:
buffer-equal "^1.0.0"
arg@4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.0.tgz#583c518199419e0037abb74062c37f8519e575f0"
integrity sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg==
arg@^4.1.0:
version "4.1.3"
resolved "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz"
@ -4548,6 +4558,11 @@ buffers@~0.1.1:
resolved "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz"
integrity sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==
bytes@3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6"
integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==
call-bind@^1.0.0, call-bind@^1.0.2:
version "1.0.2"
resolved "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz"
@ -4857,6 +4872,11 @@ config-chain@^1.1.13:
ini "^1.3.4"
proto-list "~1.2.1"
content-type@1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b"
integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==
convert-source-map@^1.5.0, convert-source-map@^1.7.0:
version "1.9.0"
resolved "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz"
@ -5122,6 +5142,11 @@ define-properties@^1.1.3, define-properties@^1.1.4:
has-property-descriptors "^1.0.0"
object-keys "^1.1.1"
depd@~1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
integrity sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==
deprecation@^2.0.0, deprecation@^2.3.1:
version "2.3.1"
resolved "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz"
@ -6489,6 +6514,17 @@ htmlparser2@^8.0.1:
domutils "^3.0.1"
entities "^4.3.0"
http-errors@1.7.3:
version "1.7.3"
resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.3.tgz#6c619e4f9c60308c38519498c14fbb10aacebb06"
integrity sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==
dependencies:
depd "~1.1.2"
inherits "2.0.4"
setprototypeof "1.1.1"
statuses ">= 1.5.0 < 2"
toidentifier "1.0.0"
https-proxy-agent@^5.0.0:
version "5.0.1"
resolved "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz"
@ -6564,6 +6600,13 @@ i18next@^22.4.9:
dependencies:
"@babel/runtime" "^7.20.6"
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"
iconv-lite@0.6.3:
version "0.6.3"
resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz"
@ -6620,7 +6663,7 @@ inflight@^1.0.4:
once "^1.3.0"
wrappy "1"
inherits@2, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.0, inherits@~2.0.3:
inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.0, inherits@~2.0.3:
version "2.0.4"
resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
@ -7540,6 +7583,15 @@ merge2@^1.3.0, merge2@^1.4.1:
resolved "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz"
integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
micro@^10.0.1:
version "10.0.1"
resolved "https://registry.yarnpkg.com/micro/-/micro-10.0.1.tgz#2601e02b0dacd2eaee77e9de18f12b2e595c5951"
integrity sha512-9uwZSsUrqf6+4FLLpiPj5TRWQv5w5uJrJwsx1LR/TjqvQmKC1XnGQ9OHrFwR3cbZ46YqPqxO/XJCOpWnqMPw2Q==
dependencies:
arg "4.1.0"
content-type "1.0.4"
raw-body "2.4.1"
micromark-core-commonmark@^1.0.1:
version "1.1.0"
resolved "https://registry.yarnpkg.com/micromark-core-commonmark/-/micromark-core-commonmark-1.1.0.tgz#1386628df59946b2d39fb2edfd10f3e8e0a75bb8"
@ -8509,11 +8561,28 @@ pvutils@^1.1.3:
resolved "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz"
integrity sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==
qs@^6.11.0:
version "6.11.2"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.2.tgz#64bea51f12c1f5da1bc01496f48ffcff7c69d7d9"
integrity sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==
dependencies:
side-channel "^1.0.4"
queue-microtask@^1.2.2:
version "1.2.3"
resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz"
integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
raw-body@2.4.1:
version "2.4.1"
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.1.tgz#30ac82f98bb5ae8c152e67149dac8d55153b168c"
integrity sha512-9WmIKF6mkvA0SLmA2Knm9+qj89e+j1zqgyn8aXGd7+nAduPoqgI9lO57SAZNn/Byzo5P7JhXTyg9PzaJbH73bA==
dependencies:
bytes "3.1.0"
http-errors "1.7.3"
iconv-lite "0.4.24"
unpipe "1.0.0"
react-big-calendar@^1.8.1:
version "1.8.1"
resolved "https://registry.yarnpkg.com/react-big-calendar/-/react-big-calendar-1.8.1.tgz#07886a66086fcae16934572c5ace8c4c433dbbed"
@ -9041,7 +9110,7 @@ safe-regex-test@^1.0.0:
get-intrinsic "^1.1.3"
is-regex "^1.1.4"
"safer-buffer@>= 2.1.2 < 3.0.0":
"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0":
version "2.1.2"
resolved "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz"
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
@ -9116,6 +9185,11 @@ setimmediate@~1.0.4:
resolved "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz"
integrity sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==
setprototypeof@1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.1.tgz#7e95acb24aa92f5885e0abef5ba131330d4ae683"
integrity sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==
shallow-clone@^3.0.0:
version "3.0.1"
resolved "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz"
@ -9325,6 +9399,11 @@ stacktrace-parser@^0.1.10:
dependencies:
type-fest "^0.7.1"
"statuses@>= 1.5.0 < 2":
version "1.5.0"
resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==
stop-iteration-iterator@^1.0.0:
version "1.0.0"
resolved "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz"
@ -9432,6 +9511,14 @@ strip-json-comments@^3.1.0, strip-json-comments@^3.1.1:
resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz"
integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
stripe@^13.2.0:
version "13.2.0"
resolved "https://registry.yarnpkg.com/stripe/-/stripe-13.2.0.tgz#feb10555d55c871188b0e9bc9bdf0f8e52c42e5d"
integrity sha512-4a2UHpe/tyxP3sxSGhuKMgbW8hQnqSQIPMigXC8kW3P0+BpsITpKDP+xxriTMDkRAP0xTQwzxcqhfqB+/404Mg==
dependencies:
"@types/node" ">=8.1.0"
qs "^6.11.0"
striptags@^2.0.3:
version "2.2.1"
resolved "https://registry.npmjs.org/striptags/-/striptags-2.2.1.tgz"
@ -9685,6 +9772,11 @@ toggle-selection@^1.0.6:
resolved "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz"
integrity sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==
toidentifier@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553"
integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==
toposort@^2.0.2:
version "2.0.2"
resolved "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz"
@ -10008,6 +10100,11 @@ universalify@^2.0.0:
resolved "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz"
integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==
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==
unzipper@^0.10.11:
version "0.10.11"
resolved "https://registry.npmjs.org/unzipper/-/unzipper-0.10.11.tgz"