diff --git a/apps/web/package.json b/apps/web/package.json
index 9a04b9421..2405befd8 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -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",
diff --git a/apps/web/public/locales/en/app.json b/apps/web/public/locales/en/app.json
index f3a2c8e07..3c50e26e4 100644
--- a/apps/web/public/locales/en/app.json
+++ b/apps/web/public/locales/en/app.json
@@ -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"
}
diff --git a/apps/web/src/components/pay-wall.tsx b/apps/web/src/components/pay-wall.tsx
index 4df76b8c5..78b7ede11 100644
--- a/apps/web/src/components/pay-wall.tsx
+++ b/apps/web/src/components/pay-wall.tsx
@@ -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 (
-
- );
-};
-
const Teaser = () => {
const router = useRouter();
- const [didUpgrade, setDidUpgrade] = React.useState(false);
const [tab, setTab] = React.useState("yearly");
@@ -106,140 +80,132 @@ const Teaser = () => {
className="text-center"
aria-hidden="true"
>
-
+
- {didUpgrade ? (
-
- ) : (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- setDidUpgrade(true)}
- >
-
-
-
-
-
-
-
-
+
- )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
};
diff --git a/apps/web/src/components/upgrade-button.tsx b/apps/web/src/components/upgrade-button.tsx
index 9fd9f4827..5ac5cc4f1 100644
--- a/apps/web/src/components/upgrade-button.tsx
+++ b/apps/web/src/components/upgrade-button.tsx
@@ -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 (
<>
{
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);
- },
- });
- }
}}
>
- {children}
+
+ {children}
+
>
);
diff --git a/apps/web/src/components/user-dropdown.tsx b/apps/web/src/components/user-dropdown.tsx
index ff441f8dd..46a44a9d4 100644
--- a/apps/web/src/components/user-dropdown.tsx
+++ b/apps/web/src/components/user-dropdown.tsx
@@ -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 (
diff --git a/apps/web/src/components/user-provider.tsx b/apps/web/src/components/user-provider.tsx
index f7dd7b558..dd7bc8d64 100644
--- a/apps/web/src/components/user-provider.tsx
+++ b/apps/web/src/components/user-provider.tsx
@@ -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;
}
diff --git a/apps/web/src/contexts/plan.tsx b/apps/web/src/contexts/plan.tsx
index 4258521ca..9687cfde2 100644
--- a/apps/web/src/contexts/plan.tsx
+++ b/apps/web/src/contexts/plan.tsx
@@ -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";
};
diff --git a/apps/web/src/pages/api/stripe/checkout.ts b/apps/web/src/pages/api/stripe/checkout.ts
new file mode 100644
index 000000000..a453a17c2
--- /dev/null
+++ b/apps/web/src/pages/api/stripe/checkout.ts
@@ -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" });
+}
diff --git a/apps/web/src/pages/api/stripe/portal.ts b/apps/web/src/pages/api/stripe/portal.ts
new file mode 100644
index 000000000..0e184fe9d
--- /dev/null
+++ b/apps/web/src/pages/api/stripe/portal.ts
@@ -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);
+}
diff --git a/apps/web/src/pages/api/stripe/webhook.ts b/apps/web/src/pages/api/stripe/webhook.ts
new file mode 100644
index 000000000..d955bc0d3
--- /dev/null
+++ b/apps/web/src/pages/api/stripe/webhook.ts
@@ -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();
+}
diff --git a/apps/web/src/pages/settings/billing.tsx b/apps/web/src/pages/settings/billing.tsx
index 9eb7dac18..43b8a9aaa 100644
--- a/apps/web/src/pages/settings/billing.tsx
+++ b/apps/web/src/pages/settings/billing.tsx
@@ -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 (
+
+ );
+};
+
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 ;
+ if (data.legacy) {
+ // User is on the old billing system
+ return ;
+ } else if (data.active) {
+ return ;
} else {
return ;
}
};
-const BillingStatus = () => {
+const LegacyBilling = () => {
const { data: userPaymentData } = trpc.user.getBilling.useQuery();
if (!userPaymentData) {
- return Something when wrong. Missing user payment data.
;
+ return null;
}
const { status, endDate, planId } = userPaymentData;
@@ -181,6 +219,29 @@ const Page: NextPageWithLayout = () => {
>
+ }
+ description={
+
+ }
+ >
+
+
);
};
diff --git a/packages/backend/next/edge.ts b/packages/backend/next/edge.ts
index c1d41fe83..57397898e 100644
--- a/packages/backend/next/edge.ts
+++ b/packages/backend/next/edge.ts
@@ -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);
};
diff --git a/packages/backend/next/session.ts b/packages/backend/next/session.ts
index 5ebd76d0c..7c9037649 100644
--- a/packages/backend/next/session.ts
+++ b/packages/backend/next/session.ts
@@ -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?: {
diff --git a/packages/backend/package.json b/packages/backend/package.json
index 6e9a6373f..cefb62472 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -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"
}
diff --git a/packages/backend/stripe.ts b/packages/backend/stripe.ts
new file mode 100644
index 000000000..cb669ca53
--- /dev/null
+++ b/packages/backend/stripe.ts
@@ -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,
+});
diff --git a/packages/backend/trpc/routers/user.ts b/packages/backend/trpc/routers/user.ts
index 400d69f6a..e29a93677 100644
--- a/packages/backend/trpc/routers/user.ts
+++ b/packages/backend/trpc/routers/user.ts
@@ -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),
diff --git a/packages/database/prisma/migrations/20230823084154_stripe_subscriptions/migration.sql b/packages/database/prisma/migrations/20230823084154_stripe_subscriptions/migration.sql
new file mode 100644
index 000000000..872cb5d00
--- /dev/null
+++ b/packages/database/prisma/migrations/20230823084154_stripe_subscriptions/migration.sql
@@ -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");
diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma
index 737699512..7c3671534 100644
--- a/packages/database/prisma/schema.prisma
+++ b/packages/database/prisma/schema.prisma
@@ -17,15 +17,18 @@ enum TimeFormat {
}
model User {
- id String @id @default(cuid())
- name String
- email String @unique() @db.Citext
- createdAt DateTime @default(now()) @map("created_at")
- updatedAt DateTime? @updatedAt @map("updated_at")
- comments Comment[]
- polls Poll[]
- watcher Watcher[]
- events Event[]
+ id String @id @default(cuid())
+ name String
+ email String @unique() @db.Citext
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime? @updatedAt @map("updated_at")
+ comments Comment[]
+ 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")
diff --git a/turbo.json b/turbo.json
index c95cec1a2..23b445ef0 100644
--- a/turbo.json
+++ b/turbo.json
@@ -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"
]
}
diff --git a/yarn.lock b/yarn.lock
index 7098e81b4..6be446f05 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"