From 403ad8b93e041f8d413f21046fc1d167f165f0d5 Mon Sep 17 00:00:00 2001 From: KM Koushik Date: Sun, 23 Mar 2025 07:06:56 +1100 Subject: [PATCH] add stripe (#121) * add some stripe stuff * more stripe stuff * more stripe things * more stripr stuff * more stripe stuff * more stripe stuff * add more stuff * add more stripe stuff * more stuff * fix types --- .cursorrules | 13 + CLAUDE.md | 21 ++ apps/web/package.json | 5 +- .../20250202115917_add_payments/migration.sql | 34 +++ .../migration.sql | 3 + apps/web/prisma/schema.prisma | 25 ++ .../src/app/(dashboard)/dasboard-layout.tsx | 13 +- .../app/(dashboard)/dev-settings/layout.tsx | 6 +- .../dev-settings/settings-nav-button.tsx | 2 +- .../(dashboard)/dev-settings/smtp/page.tsx | 10 - .../web/src/app/(dashboard)/payments/page.tsx | 59 ++++ .../app/(dashboard)/settings/billing/page.tsx | 173 ++++++++++++ .../src/app/(dashboard)/settings/layout.tsx | 22 ++ .../web/src/app/(dashboard)/settings/page.tsx | 13 + .../app/(dashboard)/settings/usage/usage.tsx | 237 +++++++++++++++++ apps/web/src/app/api/webhook/stripe/route.ts | 77 ++++++ .../src/components/payments/PlanDetails.tsx | 45 ++++ .../src/components/payments/UpgradeButton.tsx | 24 ++ apps/web/src/env.js | 6 + apps/web/src/instrumentation.ts | 13 +- apps/web/src/lib/constants/payments.ts | 13 + apps/web/src/lib/usage.ts | 66 +++++ apps/web/src/providers/dashboard-provider.tsx | 3 +- apps/web/src/providers/team-context.tsx | 43 +++ apps/web/src/server/api/root.ts | 2 + apps/web/src/server/api/routers/billing.ts | 82 ++++++ apps/web/src/server/api/routers/email.ts | 2 - apps/web/src/server/billing/payments.ts | 162 +++++++++++ apps/web/src/server/billing/usage.ts | 22 ++ apps/web/src/server/jobs/usage-job.ts | 90 +++++++ apps/web/src/utils/common.ts | 9 + packages/ui/src/badge.tsx | 2 +- packages/ui/src/card.tsx | 42 +-- pnpm-lock.yaml | 251 ++++-------------- 34 files changed, 1352 insertions(+), 238 deletions(-) create mode 100644 .cursorrules create mode 100644 CLAUDE.md create mode 100644 apps/web/prisma/migrations/20250202115917_add_payments/migration.sql create mode 100644 apps/web/prisma/migrations/20250321093643_add_is_active/migration.sql create mode 100644 apps/web/src/app/(dashboard)/payments/page.tsx create mode 100644 apps/web/src/app/(dashboard)/settings/billing/page.tsx create mode 100644 apps/web/src/app/(dashboard)/settings/layout.tsx create mode 100644 apps/web/src/app/(dashboard)/settings/page.tsx create mode 100644 apps/web/src/app/(dashboard)/settings/usage/usage.tsx create mode 100644 apps/web/src/app/api/webhook/stripe/route.ts create mode 100644 apps/web/src/components/payments/PlanDetails.tsx create mode 100644 apps/web/src/components/payments/UpgradeButton.tsx create mode 100644 apps/web/src/lib/constants/payments.ts create mode 100644 apps/web/src/lib/usage.ts create mode 100644 apps/web/src/providers/team-context.tsx create mode 100644 apps/web/src/server/api/routers/billing.ts create mode 100644 apps/web/src/server/billing/payments.ts create mode 100644 apps/web/src/server/billing/usage.ts create mode 100644 apps/web/src/server/jobs/usage-job.ts create mode 100644 apps/web/src/utils/common.ts diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..2117545 --- /dev/null +++ b/.cursorrules @@ -0,0 +1,13 @@ +You are a Staff Engineer and an Expert in ReactJS, NextJS, JavaScript, TypeScript, HTML, CSS, NodeJS, Prisma, Postgres, and modern UI/UX frameworks (e.g., TailwindCSS, Shadcn, Radix). You are also great at scalling things. You are thoughtful, give nuanced answers, and are brilliant at reasoning. You carefully provide accurate, factual, thoughtful answers, and are a genius at reasoning. + +- Follow the user’s requirements carefully & to the letter. +- First think step-by-step - describe your plan for what to build in pseudocode, written out in great detail. +- Always write correct, best practice, bug free, fully functional and working code also it should be aligned to listed rules down below at Code Implementation Guidelines . +- Focus on easy and readability code, over being performant. +- Fully implement all requested functionality. +- Leave NO todo’s, placeholders or missing pieces. +- Ensure code is complete! Verify thoroughly finalised. +- Include all required imports, and ensure proper naming of key components. +- Be concise Minimize any other prose. +- If you think there might not be a correct answer, you say so. +- If you do not know the answer, say so, instead of guessing. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..dbff780 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,21 @@ +# Unsend Project Guidelines + +## Commands +- **Build**: `pnpm build` (specific: `pnpm build:web`, `pnpm build:editor`) +- **Lint**: `pnpm lint` +- **Dev**: `pnpm dev` (or `pnpm d` for setup + dev) +- **DB**: `pnpm db:migrate-dev`, `pnpm db:studio`, `pnpm db:push` +- **Test**: Run single test with `pnpm test --filter=web -- -t "test name"` +- **Format**: `pnpm format` + +## Code Style +- **Formatting**: Prettier with tailwind plugin +- **Imports**: Group by source (internal/external), alphabetize +- **TypeScript**: Strong typing, avoid `any`, use Zod for validation +- **Naming**: camelCase for variables/functions, PascalCase for components/classes +- **React**: Functional components with hooks, group related hooks +- **Component Structure**: Props at top, hooks next, helper functions, then JSX +- **Error Handling**: Use try/catch with specific error types +- **API**: Use tRPC for internal, Hono for public API endpoints + +Follow Vercel style guides with strict TypeScript. Be thoughtful, write readable code over premature optimization. \ No newline at end of file diff --git a/apps/web/package.json b/apps/web/package.json index 09accae..5216c54 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -35,21 +35,19 @@ "@trpc/server": "next", "@unsend/email-editor": "workspace:*", "@unsend/ui": "workspace:*", - "bullmq": "^5.8.2", + "bullmq": "^5.41.0", "chrono-node": "^2.7.6", "date-fns": "^3.6.0", "emoji-picker-react": "^4.12.0", "framer-motion": "^11.0.24", "hono": "^4.2.2", "html-to-text": "^9.0.5", - "install": "^0.13.0", "ioredis": "^5.4.1", "lucide-react": "^0.359.0", "mime-types": "^2.1.35", "nanoid": "^5.0.7", "next": "^14.2.1", "next-auth": "^4.24.6", - "pg-boss": "^9.0.3", "pnpm": "^8.15.5", "prisma": "^6.3.1", "query-string": "^9.0.0", @@ -58,6 +56,7 @@ "react-hook-form": "^7.51.3", "recharts": "^2.12.5", "server-only": "^0.0.1", + "stripe": "^17.6.0", "superjson": "^2.2.1", "tldts": "^6.1.16", "ua-parser-js": "^1.0.38", diff --git a/apps/web/prisma/migrations/20250202115917_add_payments/migration.sql b/apps/web/prisma/migrations/20250202115917_add_payments/migration.sql new file mode 100644 index 0000000..4e2c378 --- /dev/null +++ b/apps/web/prisma/migrations/20250202115917_add_payments/migration.sql @@ -0,0 +1,34 @@ +/* + Warnings: + + - A unique constraint covering the columns `[stripeCustomerId]` on the table `Team` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateEnum +CREATE TYPE "Plan" AS ENUM ('FREE', 'BASIC'); + +-- AlterTable +ALTER TABLE "Team" ADD COLUMN "plan" "Plan" NOT NULL DEFAULT 'FREE', +ADD COLUMN "stripeCustomerId" TEXT; + +-- CreateTable +CREATE TABLE "Subscription" ( + "id" TEXT NOT NULL, + "teamId" INTEGER NOT NULL, + "status" TEXT NOT NULL, + "priceId" TEXT NOT NULL, + "currentPeriodEnd" TIMESTAMP(3), + "currentPeriodStart" TIMESTAMP(3), + "cancelAtPeriodEnd" TIMESTAMP(3), + "paymentMethod" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Subscription_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Team_stripeCustomerId_key" ON "Team"("stripeCustomerId"); + +-- AddForeignKey +ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/web/prisma/migrations/20250321093643_add_is_active/migration.sql b/apps/web/prisma/migrations/20250321093643_add_is_active/migration.sql new file mode 100644 index 0000000..fb6847d --- /dev/null +++ b/apps/web/prisma/migrations/20250321093643_add_is_active/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Team" ADD COLUMN "billingEmail" TEXT, +ADD COLUMN "isActive" BOOLEAN NOT NULL DEFAULT true; diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 44c6d6f..a68a10f 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -90,11 +90,20 @@ model User { teamUsers TeamUser[] } +enum Plan { + FREE + BASIC +} + model Team { id Int @id @default(autoincrement()) name String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + plan Plan @default(FREE) + stripeCustomerId String? @unique + isActive Boolean @default(true) + billingEmail String? teamUsers TeamUser[] domains Domain[] apiKeys ApiKey[] @@ -103,6 +112,22 @@ model Team { campaigns Campaign[] templates Template[] dailyEmailUsages DailyEmailUsage[] + Subscription Subscription[] +} + +model Subscription { + id String @id + teamId Int + status String + priceId String + currentPeriodEnd DateTime? + currentPeriodStart DateTime? + cancelAtPeriodEnd DateTime? + paymentMethod String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) } enum Role { diff --git a/apps/web/src/app/(dashboard)/dasboard-layout.tsx b/apps/web/src/app/(dashboard)/dasboard-layout.tsx index 13e9d49..7dd0a28 100644 --- a/apps/web/src/app/(dashboard)/dasboard-layout.tsx +++ b/apps/web/src/app/(dashboard)/dasboard-layout.tsx @@ -8,6 +8,7 @@ import { BookUser, CircleUser, Code, + Cog, Globe, Home, LayoutDashboard, @@ -18,6 +19,7 @@ import { Package, Package2, Server, + Settings, ShoppingCart, Users, Volume2, @@ -34,6 +36,7 @@ import { DropdownMenuTrigger, } from "@unsend/ui/src/dropdown-menu"; import { ThemeSwitcher } from "~/components/theme/ThemeSwitcher"; +import { isCloud } from "~/utils/common"; export function DashboardLayout({ children }: { children: React.ReactNode }) { const { data: session } = useSession(); @@ -87,7 +90,15 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) { Developer settings - {!env.NEXT_PUBLIC_IS_CLOUD || session?.user.isAdmin ? ( + + {isCloud() ? ( + + + Settings + + ) : null} + + {isCloud() || session?.user.isAdmin ? ( Admin diff --git a/apps/web/src/app/(dashboard)/dev-settings/layout.tsx b/apps/web/src/app/(dashboard)/dev-settings/layout.tsx index f128e43..ae2c3b5 100644 --- a/apps/web/src/app/(dashboard)/dev-settings/layout.tsx +++ b/apps/web/src/app/(dashboard)/dev-settings/layout.tsx @@ -1,7 +1,5 @@ "use client"; -import { Tabs, TabsList, TabsTrigger, TabsContent } from "@unsend/ui/src/tabs"; -import { useState } from "react"; import { SettingsNavButton } from "./settings-nav-button"; export const dynamic = "force-static"; @@ -15,9 +13,7 @@ export default function ApiKeysPage({

Developer settings

- - API Keys - + API Keys SMTP
{children}
diff --git a/apps/web/src/app/(dashboard)/dev-settings/settings-nav-button.tsx b/apps/web/src/app/(dashboard)/dev-settings/settings-nav-button.tsx index 49130fe..aa41063 100644 --- a/apps/web/src/app/(dashboard)/dev-settings/settings-nav-button.tsx +++ b/apps/web/src/app/(dashboard)/dev-settings/settings-nav-button.tsx @@ -11,7 +11,7 @@ export const SettingsNavButton: React.FC<{ }> = ({ href, children, comingSoon }) => { const pathname = usePathname(); - const isActive = pathname?.startsWith(href); + const isActive = pathname === href; if (comingSoon) { return ( diff --git a/apps/web/src/app/(dashboard)/dev-settings/smtp/page.tsx b/apps/web/src/app/(dashboard)/dev-settings/smtp/page.tsx index 26fe258..65dd799 100644 --- a/apps/web/src/app/(dashboard)/dev-settings/smtp/page.tsx +++ b/apps/web/src/app/(dashboard)/dev-settings/smtp/page.tsx @@ -1,25 +1,15 @@ "use client"; import * as React from "react"; -import { Code } from "@unsend/ui/src/code"; -import { Button } from "@unsend/ui/src/button"; import { Card, CardContent, CardDescription, - CardFooter, CardHeader, CardTitle, } from "@unsend/ui/src/card"; import { TextWithCopyButton } from "@unsend/ui/src/text-with-copy"; export default function ExampleCard() { - const smtpDetails = { - smtp: "smtp.example.com", - port: "587", - user: "user@example.com", - password: "supersecretpassword", - }; - return ( diff --git a/apps/web/src/app/(dashboard)/payments/page.tsx b/apps/web/src/app/(dashboard)/payments/page.tsx new file mode 100644 index 0000000..9f9408b --- /dev/null +++ b/apps/web/src/app/(dashboard)/payments/page.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { Button } from "@unsend/ui/src/button"; +import Spinner from "@unsend/ui/src/spinner"; +import { CheckCircle2 } from "lucide-react"; +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { api } from "~/trpc/react"; + +export default function PaymentsPage() { + const searchParams = useSearchParams(); + + const success = searchParams.get("success"); + const canceled = searchParams.get("canceled"); + + return ( +
+

+ Payment {success ? "Success" : canceled ? "Canceled" : "Unknown"} +

+ {canceled ? ( + + + + ) : null} + {success ? : null} +
+ ); +} + +function VerifySuccess() { + const { data: teams, isLoading } = api.team.getTeams.useQuery(undefined, { + refetchInterval: 3000, + }); + + if (teams?.[0]?.plan !== "FREE") { + return ( +
+
+ +

Your account has been upgraded to the paid plan.

+
+ + + +
+ ); + } + + return ( +
+ +

Verifying payment

+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/settings/billing/page.tsx b/apps/web/src/app/(dashboard)/settings/billing/page.tsx new file mode 100644 index 0000000..c72ac9c --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/billing/page.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@unsend/ui/src/button"; +import { Card } from "@unsend/ui/src/card"; +import { Spinner } from "@unsend/ui/src/spinner"; +import { format } from "date-fns"; +import { useTeam } from "~/providers/team-context"; +import { api } from "~/trpc/react"; +import { PlanDetails } from "~/components/payments/PlanDetails"; +import { UpgradeButton } from "~/components/payments/UpgradeButton"; +export default function SettingsPage() { + const { currentTeam } = useTeam(); + const manageSessionUrl = api.billing.getManageSessionUrl.useMutation(); + const updateBillingEmailMutation = + api.billing.updateBillingEmail.useMutation(); + + const { data: subscription } = api.billing.getSubscriptionDetails.useQuery(); + const [isEditingEmail, setIsEditingEmail] = useState(false); + const [billingEmail, setBillingEmail] = useState( + currentTeam?.billingEmail || "" + ); + + const apiUtils = api.useUtils(); + + const onManageClick = async () => { + const url = await manageSessionUrl.mutateAsync(); + if (url) { + window.location.href = url; + } + }; + + const handleEditEmail = () => { + setBillingEmail(currentTeam?.billingEmail || ""); + setIsEditingEmail(true); + }; + + const handleSaveEmail = async () => { + try { + await updateBillingEmailMutation.mutateAsync({ billingEmail }); + await apiUtils.team.getTeams.invalidate(); + setIsEditingEmail(false); + } catch (error) { + console.error("Failed to update billing email:", error); + } + }; + + const paymentMethod = JSON.parse(subscription?.paymentMethod || "{}"); + + if (!currentTeam?.plan) { + return ( +
+ +
+ ); + } + + return ( +
+ + +
+ {currentTeam?.plan !== "FREE" ? ( + + ) : ( + + )} +
+
+
+ +
+
Payment Method
+ {subscription ? ( +
+
+ {subscription.paymentMethod ? ( + <> + 💳 + + {paymentMethod.card?.brand || ""} ••••{" "} + {paymentMethod.card?.last4 || ""} + + {paymentMethod.card && ( + + (Expires: {paymentMethod.card.exp_month}/ + {paymentMethod.card.exp_year}) + + )} + + ) : ( + "No Payment Method" + )} +
+
+ Next billing date:{" "} + {subscription.currentPeriodEnd + ? format( + new Date(subscription.currentPeriodEnd), + "MMM dd, yyyy" + ) + : "N/A"} +
+
+ ) : ( +
+ No active subscription +
+ )} +
+
+ + +
+
Billing Email
+ {isEditingEmail ? ( +
+
+ setBillingEmail(e.target.value)} + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" + placeholder="Enter billing email" + /> + + +
+
+ ) : ( +
+
+
+ {currentTeam?.billingEmail || "No billing email set"} +
+ +
+
+ )} +
+
+
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/settings/layout.tsx b/apps/web/src/app/(dashboard)/settings/layout.tsx new file mode 100644 index 0000000..221c972 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/layout.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { SettingsNavButton } from "../dev-settings/settings-nav-button"; + +export const dynamic = "force-static"; + +export default function ApiKeysPage({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+

Settings

+
+ Usage + Billing +
+
{children}
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/settings/page.tsx b/apps/web/src/app/(dashboard)/settings/page.tsx new file mode 100644 index 0000000..b20513c --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/page.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { Button } from "@unsend/ui/src/button"; +import { api } from "~/trpc/react"; +import UsagePage from "./usage/usage"; + +export default function SettingsPage() { + return ( +
+ +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/settings/usage/usage.tsx b/apps/web/src/app/(dashboard)/settings/usage/usage.tsx new file mode 100644 index 0000000..4612d37 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/usage/usage.tsx @@ -0,0 +1,237 @@ +"use client"; + +import { api } from "~/trpc/react"; +import { Card } from "@unsend/ui/src/card"; +import Spinner from "@unsend/ui/src/spinner"; +import { format } from "date-fns"; +import { + getCost, + PLAN_CREDIT_UNITS, + UNIT_PRICE, + USAGE_UNIT_PRICE, +} from "~/lib/usage"; +import { useTeam } from "~/providers/team-context"; +import { EmailUsageType } from "@prisma/client"; +import { PlanDetails } from "~/components/payments/PlanDetails"; +import { UpgradeButton } from "~/components/payments/UpgradeButton"; +const FREE_PLAN_LIMIT = 3000; + +function FreePlanUsage({ + usage, + dayUsage, +}: { + usage: { type: EmailUsageType; sent: number }[]; + dayUsage: { type: EmailUsageType; sent: number }[]; +}) { + const DAILY_LIMIT = 100; + const totalSent = usage?.reduce((acc, item) => acc + item.sent, 0) || 0; + const monthlyPercentageUsed = (totalSent / FREE_PLAN_LIMIT) * 100; + + // Calculate daily usage - this is a simplified version, you might want to adjust based on actual daily tracking + const dailyUsage = dayUsage?.reduce((acc, item) => acc + item.sent, 0) || 0; + const dailyPercentageUsed = (dailyUsage / DAILY_LIMIT) * 100; + + return ( + +
+
+ {usage?.map((item) => ( +
+
+
+ {item.type.toLowerCase()} +
+
+ {item.type === "TRANSACTIONAL" + ? "Mails sent using the send api or SMTP" + : "Mails designed sent from unsend editor"} +
+
+
+ {item.sent.toLocaleString()} emails +
+
+ ))} +
+
Total
+
+ {usage + ?.reduce((acc, item) => acc + item.sent, 0) + .toLocaleString()}{" "} + emails +
+
+
+
+
+
+
+
+
Monthly Limit
+
+ {totalSent.toLocaleString()}/ + {FREE_PLAN_LIMIT.toLocaleString()} +
+
+
+
+
+
+
+ +
+
+
+
Daily Limit
+
+ {dailyUsage.toLocaleString()}/{DAILY_LIMIT.toLocaleString()} +
+
+
+
+
+
+
+
+
+
+ + ); +} + +function PaidPlanUsage({ + usage, +}: { + usage: { type: EmailUsageType; sent: number }[]; +}) { + const { currentTeam } = useTeam(); + + if (currentTeam?.plan === "FREE") return null; + + const totalCost = + usage?.reduce((acc, item) => acc + getCost(item.sent, item.type), 0) || 0; + const planCreditCost = PLAN_CREDIT_UNITS[currentTeam?.plan!] * UNIT_PRICE; + + return ( + +
+
+ {usage?.map((item) => ( +
+
+
+ {item.type.toLowerCase()} +
+
+ + {item.sent.toLocaleString()} + {" "} + emails at{" "} + + ${USAGE_UNIT_PRICE[item.type]} + {" "} + each +
+
+
+ ${getCost(item.sent, item.type).toFixed(2)} +
+
+ ))} +
+
+
Minimum spend
+
+ {currentTeam?.plan} +
+
+
+ ${planCreditCost.toFixed(2)} +
+
+
+
+
+
Amount Due
+
+
+ {planCreditCost < totalCost + ? `$${totalCost.toFixed(2)}` + : `$${planCreditCost.toFixed(2)}`} +
+
+
+
+
+
+ ); +} + +export default function UsagePage() { + const { data: usage, isLoading } = api.billing.getThisMonthUsage.useQuery(); + const { currentTeam } = useTeam(); + + // Calculate the current billing period + const today = new Date(); + const firstDayOfMonth = new Date(today.getFullYear(), today.getMonth(), 1); + const firstDayOfNextMonth = new Date( + today.getFullYear(), + today.getMonth() + 1, + 1 + ); + const billingPeriod = `${format(firstDayOfMonth, "MMM dd")} - ${format(firstDayOfNextMonth, "MMM dd")}`; + + return ( +
+
+
+
+

Usage

+
+ {billingPeriod} +
+
+
+ + {isLoading ? ( +
+ +
+ ) : usage?.month.length === 0 ? ( + + No usage data available + + ) : currentTeam?.plan === "FREE" ? ( + + ) : ( + + )} +
+ {currentTeam?.plan ? ( + + +
+ {currentTeam?.plan === "FREE" ? : null} +
+
+ ) : null} +
+ ); +} diff --git a/apps/web/src/app/api/webhook/stripe/route.ts b/apps/web/src/app/api/webhook/stripe/route.ts new file mode 100644 index 0000000..02c7282 --- /dev/null +++ b/apps/web/src/app/api/webhook/stripe/route.ts @@ -0,0 +1,77 @@ +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; +import Stripe from "stripe"; +import { env } from "~/env"; +import { syncStripeData } from "~/server/billing/payments"; +import { db } from "~/server/db"; + +const stripe = new Stripe(env.STRIPE_SECRET_KEY!, { + apiVersion: "2025-01-27.acacia", +}); + +const allowedEvents: Stripe.Event.Type[] = [ + "checkout.session.completed", + "customer.subscription.created", + "customer.subscription.updated", + "customer.subscription.deleted", + "customer.subscription.paused", + "customer.subscription.resumed", + "customer.subscription.pending_update_applied", + "customer.subscription.pending_update_expired", + "customer.subscription.trial_will_end", + "invoice.paid", + "invoice.payment_failed", + "invoice.payment_action_required", + "invoice.upcoming", + "invoice.marked_uncollectible", + "invoice.payment_succeeded", + "payment_intent.succeeded", + "payment_intent.payment_failed", + "payment_intent.canceled", +]; + +export async function POST(req: Request) { + const body = await req.text(); + const signature = headers().get("Stripe-Signature"); + + if (!signature) { + console.error("No signature"); + return new NextResponse("No signature", { status: 400 }); + } + + if (!env.STRIPE_WEBHOOK_SECRET) { + console.error("No webhook secret"); + return new NextResponse("No webhook secret", { status: 400 }); + } + + try { + const event = stripe.webhooks.constructEvent( + body, + signature, + env.STRIPE_WEBHOOK_SECRET + ); + + if (!allowedEvents.includes(event.type)) { + return new NextResponse("OK", { status: 200 }); + } + + // All the events I track have a customerId + const { customer: customerId } = event?.data?.object as { + customer: string; // Sadly TypeScript does not know this + }; + + // This helps make it typesafe and also lets me know if my assumption is wrong + if (typeof customerId !== "string") { + throw new Error( + `[STRIPE HOOK][CANCER] ID isn't string.\nEvent type: ${event.type}` + ); + } + + await syncStripeData(customerId); + + return new NextResponse("OK", { status: 200 }); + } catch (err) { + console.error("Error processing webhook:", err); + return new NextResponse("Webhook error", { status: 400 }); + } +} diff --git a/apps/web/src/components/payments/PlanDetails.tsx b/apps/web/src/components/payments/PlanDetails.tsx new file mode 100644 index 0000000..3bf58a7 --- /dev/null +++ b/apps/web/src/components/payments/PlanDetails.tsx @@ -0,0 +1,45 @@ +import { Plan } from "@prisma/client"; +import { PLAN_PERKS } from "~/lib/constants/payments"; +import { CheckCircle2 } from "lucide-react"; +import { api } from "~/trpc/react"; +import Spinner from "@unsend/ui/src/spinner"; +import { useTeam } from "~/providers/team-context"; +import { Badge } from "@unsend/ui/src/badge"; +import { format } from "date-fns"; +export const PlanDetails = () => { + const subscriptionQuery = api.billing.getSubscriptionDetails.useQuery(); + const { currentTeam } = useTeam(); + + if (subscriptionQuery.isLoading || !currentTeam) { + return null; + } + + const planKey = currentTeam.plan as keyof typeof PLAN_PERKS; + const perks = PLAN_PERKS[planKey] || []; + + return ( +
+
+ {subscriptionQuery.data?.status === "active" + ? planKey.toLowerCase() + : "free"} +
+
+
Current plan
+ {subscriptionQuery.data?.cancelAtPeriodEnd && ( + + Cancels {format(subscriptionQuery.data.cancelAtPeriodEnd, "MMM dd")} + + )} +
+
    + {perks.map((perk, index) => ( +
  • + + {perk} +
  • + ))} +
+
+ ); +}; diff --git a/apps/web/src/components/payments/UpgradeButton.tsx b/apps/web/src/components/payments/UpgradeButton.tsx new file mode 100644 index 0000000..c9b8bca --- /dev/null +++ b/apps/web/src/components/payments/UpgradeButton.tsx @@ -0,0 +1,24 @@ +import { Button } from "@unsend/ui/src/button"; +import Spinner from "@unsend/ui/src/spinner"; +import { api } from "~/trpc/react"; + +export const UpgradeButton = () => { + const checkoutMutation = api.billing.createCheckoutSession.useMutation(); + + const onClick = async () => { + const url = await checkoutMutation.mutateAsync(); + if (url) { + window.location.href = url; + } + }; + + return ( + + ); +}; diff --git a/apps/web/src/env.js b/apps/web/src/env.js index 5c9f475..47ccb57 100644 --- a/apps/web/src/env.js +++ b/apps/web/src/env.js @@ -51,6 +51,9 @@ export const env = createEnv({ S3_COMPATIBLE_SECRET_KEY: z.string().optional(), S3_COMPATIBLE_API_URL: z.string().optional(), S3_COMPATIBLE_PUBLIC_URL: z.string().optional(), + STRIPE_SECRET_KEY: z.string().optional(), + STRIPE_BASIC_PRICE_ID: z.string().optional(), + STRIPE_WEBHOOK_SECRET: z.string().optional(), }, /** @@ -95,6 +98,9 @@ export const env = createEnv({ S3_COMPATIBLE_SECRET_KEY: process.env.S3_COMPATIBLE_SECRET_KEY, S3_COMPATIBLE_API_URL: process.env.S3_COMPATIBLE_API_URL, S3_COMPATIBLE_PUBLIC_URL: process.env.S3_COMPATIBLE_PUBLIC_URL, + STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY, + STRIPE_BASIC_PRICE_ID: process.env.STRIPE_BASIC_PRICE_ID, + STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET, }, /** * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially diff --git a/apps/web/src/instrumentation.ts b/apps/web/src/instrumentation.ts index 1d7893c..81f1e66 100644 --- a/apps/web/src/instrumentation.ts +++ b/apps/web/src/instrumentation.ts @@ -1,3 +1,6 @@ +import { env } from "./env"; +import { isCloud } from "./utils/common"; + let initialized = false; /** @@ -9,11 +12,19 @@ export async function register() { // eslint-disable-next-line turbo/no-undeclared-env-vars if (process.env.NEXT_RUNTIME === "nodejs" && !initialized) { console.log("Registering instrumentation"); + const { EmailQueueService } = await import( "~/server/service/email-queue-service" ); - await EmailQueueService.init(); + + /** + * Send usage data to Stripe + */ + if (isCloud()) { + await import("~/server/jobs/usage-job"); + } + initialized = true; } } diff --git a/apps/web/src/lib/constants/payments.ts b/apps/web/src/lib/constants/payments.ts new file mode 100644 index 0000000..3ef5efa --- /dev/null +++ b/apps/web/src/lib/constants/payments.ts @@ -0,0 +1,13 @@ +export const PLAN_PERKS = { + FREE: [ + "Send up to 3000 emails per month", + "Send up to 100 emails per day", + "Can have 1 contact book", + ], + BASIC: [ + "Includes $10 of usage monthly", + "Send transactional emails at $0.0004 per email", + "Send marketing emails at $0.001 per email", + "Can have unlimited contact books", + ], +}; diff --git a/apps/web/src/lib/usage.ts b/apps/web/src/lib/usage.ts new file mode 100644 index 0000000..7db8407 --- /dev/null +++ b/apps/web/src/lib/usage.ts @@ -0,0 +1,66 @@ +import { EmailUsageType, Plan } from "@prisma/client"; + +/** + * Unit price for unsend + * 1 marketing email = 1 unit + * 4 transaction emails = 1 unit + */ +export const UNIT_PRICE = 0.001; + +/** + * Number of credits per plan + * Credits are the units of measurement for email sending capacity. + * Each plan comes with a specific number of credits that can be used for sending emails. + * Marketing emails consume 1 credit per email, while transactional emails consume 0.25 credits per email. + */ +export const PLAN_CREDIT_UNITS = { + [Plan.BASIC]: 10_000, +}; + +export const USAGE_UNIT_PRICE: Record = { + [EmailUsageType.MARKETING]: 0.001, + [EmailUsageType.TRANSACTIONAL]: 0.0004, +}; + +/** + * Gets timestamp for usage reporting, set to yesterday's date + * Converts to Unix timestamp (seconds since epoch) + * @returns Unix timestamp in seconds for yesterday's date + */ +export function getUsageTimestamp() { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + return Math.floor(yesterday.getTime() / 1000); +} + +/** + * Gets yesterday's date in YYYY-MM-DD format + * @returns Yesterday's date string in YYYY-MM-DD format + */ +export function getUsageDate(): string { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + const isoString = yesterday.toISOString(); + return isoString.split("T")[0] as string; +} + +/** + * Calculates total usage units based on marketing and transaction emails + + * @param marketingUsage Number of marketing emails sent + * @param transactionUsage Number of transaction emails sent + * @returns Total usage units rounded down to nearest integer + */ +export function getUsageUinits( + marketingUsage: number, + transactionUsage: number +) { + return marketingUsage + Math.floor(transactionUsage / 4); +} + +export function getCost(usage: number, type: EmailUsageType) { + const calculatedUsage = + type === EmailUsageType.MARKETING ? usage : Math.floor(usage / 4); + + return calculatedUsage * UNIT_PRICE; +} diff --git a/apps/web/src/providers/dashboard-provider.tsx b/apps/web/src/providers/dashboard-provider.tsx index 00f93cd..8d0ea34 100644 --- a/apps/web/src/providers/dashboard-provider.tsx +++ b/apps/web/src/providers/dashboard-provider.tsx @@ -6,6 +6,7 @@ import { AddSesSettings } from "~/components/settings/AddSesSettings"; import CreateTeam from "~/components/team/CreateTeam"; import { env } from "~/env"; import { api } from "~/trpc/react"; +import { TeamProvider } from "./team-context"; export const DashboardProvider = ({ children, @@ -37,5 +38,5 @@ export const DashboardProvider = ({ return ; } - return <>{children}; + return {children}; }; diff --git a/apps/web/src/providers/team-context.tsx b/apps/web/src/providers/team-context.tsx new file mode 100644 index 0000000..5513181 --- /dev/null +++ b/apps/web/src/providers/team-context.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { createContext, useContext, useState, useEffect } from "react"; +import { api } from "~/trpc/react"; + +// Define the Team type based on the Prisma schema +type Team = { + id: number; + name: string; + createdAt: Date; + updatedAt: Date; + plan: "FREE" | "BASIC"; + stripeCustomerId?: string | null; + billingEmail?: string | null; +}; + +interface TeamContextType { + currentTeam: Team | null; + teams: Team[]; + isLoading: boolean; +} + +const TeamContext = createContext(undefined); + +export function TeamProvider({ children }: { children: React.ReactNode }) { + const { data: teams, status } = api.team.getTeams.useQuery(); + + const value = { + currentTeam: teams?.[0] ?? null, + teams: teams || [], + isLoading: status === "pending", + }; + + return {children}; +} + +export function useTeam() { + const context = useContext(TeamContext); + if (context === undefined) { + throw new Error("useTeam must be used within a TeamProvider"); + } + return context; +} diff --git a/apps/web/src/server/api/root.ts b/apps/web/src/server/api/root.ts index 2f1cd2c..399cbc0 100644 --- a/apps/web/src/server/api/root.ts +++ b/apps/web/src/server/api/root.ts @@ -7,6 +7,7 @@ import { adminRouter } from "./routers/admin"; import { contactsRouter } from "./routers/contacts"; import { campaignRouter } from "./routers/campaign"; import { templateRouter } from "./routers/template"; +import { billingRouter } from "./routers/billing"; /** * This is the primary router for your server. @@ -22,6 +23,7 @@ export const appRouter = createTRPCRouter({ contacts: contactsRouter, campaign: campaignRouter, template: templateRouter, + billing: billingRouter, }); // export type definition of API diff --git a/apps/web/src/server/api/routers/billing.ts b/apps/web/src/server/api/routers/billing.ts new file mode 100644 index 0000000..c389ec4 --- /dev/null +++ b/apps/web/src/server/api/routers/billing.ts @@ -0,0 +1,82 @@ +import { DailyEmailUsage, EmailUsageType } from "@prisma/client"; +import { TRPCError } from "@trpc/server"; +import { format } from "date-fns"; +import { z } from "zod"; + +import { + apiKeyProcedure, + createTRPCRouter, + teamProcedure, +} from "~/server/api/trpc"; +import { + createCheckoutSessionForTeam, + getManageSessionUrl, +} from "~/server/billing/payments"; +import { db } from "~/server/db"; + +export const billingRouter = createTRPCRouter({ + createCheckoutSession: teamProcedure.mutation(async ({ ctx }) => { + return (await createCheckoutSessionForTeam(ctx.team.id)).url; + }), + + getManageSessionUrl: teamProcedure.mutation(async ({ ctx }) => { + return await getManageSessionUrl(ctx.team.id); + }), + + getThisMonthUsage: teamProcedure.query(async ({ ctx }) => { + const isoStartDate = format(new Date(), "yyyy-MM-01"); // First day of current month + const today = format(new Date(), "yyyy-MM-dd"); + + const [monthUsage, dayUsage] = await Promise.all([ + // Get month usage + db.$queryRaw>` + SELECT + type, + SUM(sent)::integer AS sent + FROM "DailyEmailUsage" + WHERE "teamId" = ${ctx.team.id} + AND "date" >= ${isoStartDate} + GROUP BY "type" + `, + // Get today's usage + db.$queryRaw>` + SELECT + type, + SUM(sent)::integer AS sent + FROM "DailyEmailUsage" + WHERE "teamId" = ${ctx.team.id} + AND "date" = ${today} + GROUP BY "type" + `, + ]); + + return { + month: monthUsage, + day: dayUsage, + }; + }), + + getSubscriptionDetails: teamProcedure.query(async ({ ctx }) => { + const subscription = await db.subscription.findFirst({ + where: { teamId: ctx.team.id }, + orderBy: { status: "asc" }, + }); + + return subscription; + }), + + updateBillingEmail: teamProcedure + .input( + z.object({ + billingEmail: z.string().email(), + }) + ) + .mutation(async ({ ctx, input }) => { + const { billingEmail } = input; + + await db.team.update({ + where: { id: ctx.team.id }, + data: { billingEmail }, + }); + }), +}); diff --git a/apps/web/src/server/api/routers/email.ts b/apps/web/src/server/api/routers/email.ts index 6e070f3..c5ca8f7 100644 --- a/apps/web/src/server/api/routers/email.ts +++ b/apps/web/src/server/api/routers/email.ts @@ -101,8 +101,6 @@ export const emailRouter = createTRPCRouter({ ORDER BY "date" ASC `; - console.log({ result }); - // Fill in any missing dates with 0 values const filledResult: DailyEmailUsage[] = []; const endDateObj = new Date(); diff --git a/apps/web/src/server/billing/payments.ts b/apps/web/src/server/billing/payments.ts new file mode 100644 index 0000000..66ec850 --- /dev/null +++ b/apps/web/src/server/billing/payments.ts @@ -0,0 +1,162 @@ +import Stripe from "stripe"; +import { env } from "~/env"; +import { db } from "../db"; + +function getStripe() { + if (!env.STRIPE_SECRET_KEY) { + throw new Error("STRIPE_SECRET_KEY is not set"); + } + + return new Stripe(env.STRIPE_SECRET_KEY); +} + +async function createCustomerForTeam(teamId: number) { + const stripe = getStripe(); + const customer = await stripe.customers.create({ metadata: { teamId } }); + + await db.team.update({ + where: { id: teamId }, + data: { + stripeCustomerId: customer.id, + billingEmail: customer.email, + }, + }); + + return customer; +} + +export async function createCheckoutSessionForTeam(teamId: number) { + const team = await db.team.findUnique({ + where: { id: teamId }, + }); + + if (!team) { + throw new Error("Team not found"); + } + + if (team.isActive && team.plan !== "FREE") { + throw new Error("Team is already active"); + } + + const stripe = getStripe(); + + let customerId = team.stripeCustomerId; + + if (!customerId) { + const customer = await createCustomerForTeam(teamId); + customerId = customer.id; + } + + if (!env.STRIPE_BASIC_PRICE_ID || !customerId) { + throw new Error("Stripe prices are not set"); + } + + const session = await stripe.checkout.sessions.create({ + mode: "subscription", + customer: customerId, + line_items: [ + { + price: env.STRIPE_BASIC_PRICE_ID, + }, + ], + success_url: `${env.NEXTAUTH_URL}/payments?success=true&session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${env.NEXTAUTH_URL}/settings/billing`, + metadata: { + teamId, + }, + client_reference_id: teamId.toString(), + }); + + return session; +} + +function getPlanFromPriceId(priceId: string) { + if (priceId === env.STRIPE_BASIC_PRICE_ID) { + return "BASIC"; + } + + return "FREE"; +} + +export async function getManageSessionUrl(teamId: number) { + const team = await db.team.findUnique({ + where: { id: teamId }, + }); + + if (!team) { + throw new Error("Team not found"); + } + + if (!team.stripeCustomerId) { + throw new Error("Team has no Stripe customer ID"); + } + + const stripe = getStripe(); + + const subscriptions = await stripe.billingPortal.sessions.create({ + customer: team.stripeCustomerId, + return_url: `${env.NEXTAUTH_URL}`, + }); + + return subscriptions.url; +} + +export async function syncStripeData(customerId: string) { + const stripe = getStripe(); + + const team = await db.team.findUnique({ + where: { stripeCustomerId: customerId }, + }); + + if (!team) { + return; + } + + const subscriptions = await stripe.subscriptions.list({ + customer: customerId, + limit: 1, + status: "all", + expand: ["data.default_payment_method"], + }); + + const subscription = subscriptions.data[0]; + + if (!subscription) { + return; + } + + await db.subscription.upsert({ + where: { id: subscription.id }, + update: { + status: subscription.status, + priceId: subscription.items.data[0]?.price?.id || "", + currentPeriodEnd: new Date(subscription.current_period_end * 1000), + currentPeriodStart: new Date(subscription.current_period_start * 1000), + cancelAtPeriodEnd: subscription.cancel_at + ? new Date(subscription.cancel_at * 1000) + : null, + paymentMethod: JSON.stringify(subscription.default_payment_method), + teamId: team.id, + }, + create: { + id: subscription.id, + status: subscription.status, + priceId: subscription.items.data[0]?.price?.id || "", + currentPeriodEnd: new Date(subscription.current_period_end * 1000), + currentPeriodStart: new Date(subscription.current_period_start * 1000), + cancelAtPeriodEnd: subscription.cancel_at + ? new Date(subscription.cancel_at * 1000) + : null, + paymentMethod: JSON.stringify(subscription.default_payment_method), + teamId: team.id, + }, + }); + + await db.team.update({ + where: { id: team.id }, + data: { + plan: getPlanFromPriceId(subscription.items.data[0]?.price?.id || ""), + isActive: subscription.status === "active", + }, + }); +} diff --git a/apps/web/src/server/billing/usage.ts b/apps/web/src/server/billing/usage.ts new file mode 100644 index 0000000..4f74858 --- /dev/null +++ b/apps/web/src/server/billing/usage.ts @@ -0,0 +1,22 @@ +import Stripe from "stripe"; +import { env } from "~/env"; +import { getUsageTimestamp } from "~/lib/usage"; + +const METER_EVENT_NAME = "unsend_usage"; + +export async function sendUsageToStripe(customerId: string, usage: number) { + const stripe = new Stripe(env.STRIPE_SECRET_KEY!, { + apiVersion: "2025-01-27.acacia", + }); + + const meterEvent = await stripe.billing.meterEvents.create({ + event_name: METER_EVENT_NAME, + payload: { + value: usage.toString(), + stripe_customer_id: customerId, + }, + timestamp: getUsageTimestamp(), + }); + + return meterEvent; +} diff --git a/apps/web/src/server/jobs/usage-job.ts b/apps/web/src/server/jobs/usage-job.ts new file mode 100644 index 0000000..45a2115 --- /dev/null +++ b/apps/web/src/server/jobs/usage-job.ts @@ -0,0 +1,90 @@ +import { Queue, Worker } from "bullmq"; +import { db } from "~/server/db"; +import { env } from "~/env"; +import { getUsageDate, getUsageUinits } from "~/lib/usage"; +import { sendUsageToStripe } from "~/server/billing/usage"; +import { getRedis } from "~/server/redis"; +import { DEFAULT_QUEUE_OPTIONS } from "../queue/queue-constants"; + +const USAGE_QUEUE_NAME = "usage-reporting"; + +const usageQueue = new Queue(USAGE_QUEUE_NAME, { + connection: getRedis(), +}); + +// Process usage reporting jobs +const worker = new Worker( + USAGE_QUEUE_NAME, + async () => { + // Get all teams with stripe customer IDs + const teams = await db.team.findMany({ + where: { + stripeCustomerId: { + not: null, + }, + }, + include: { + dailyEmailUsages: { + where: { + // Get yesterday's date by subtracting 1 day from today + date: { + equals: getUsageDate(), + }, + }, + }, + }, + }); + + // Process each team + for (const team of teams) { + if (!team.stripeCustomerId) continue; + + const transactionUsage = team.dailyEmailUsages + .filter((usage) => usage.type === "TRANSACTIONAL") + .reduce((sum, usage) => sum + usage.sent, 0); + + const marketingUsage = team.dailyEmailUsages + .filter((usage) => usage.type === "MARKETING") + .reduce((sum, usage) => sum + usage.sent, 0); + + const totalUsage = getUsageUinits(marketingUsage, transactionUsage); + + try { + await sendUsageToStripe(team.stripeCustomerId, totalUsage); + console.log( + `[Usage Reporting] Reported usage for team ${team.id}, date: ${getUsageDate()}, usage: ${totalUsage}` + ); + } catch (error) { + console.error( + `[Usage Reporting] Failed to report usage for team ${team.id}:`, + error instanceof Error ? error.message : error + ); + } + } + }, + { + connection: getRedis(), + } +); + +// Schedule job to run daily +await usageQueue.upsertJobScheduler( + "daily-usage-report", + { + pattern: "0 0 * * *", // Run every day at 12 AM + tz: "UTC", + }, + { + opts: { + ...DEFAULT_QUEUE_OPTIONS, + }, + } +); + +worker.on("completed", (job) => { + console.log(`[Usage Reporting] Job ${job.id} completed`); +}); + +worker.on("failed", (job, err) => { + console.error(`[Usage Reporting] Job ${job?.id} failed:`, err); +}); diff --git a/apps/web/src/utils/common.ts b/apps/web/src/utils/common.ts new file mode 100644 index 0000000..d4da6d1 --- /dev/null +++ b/apps/web/src/utils/common.ts @@ -0,0 +1,9 @@ +import { env } from "~/env"; + +export function isCloud() { + return env.NEXT_PUBLIC_IS_CLOUD; +} + +export function isSelfHosted() { + return !isCloud(); +} diff --git a/packages/ui/src/badge.tsx b/packages/ui/src/badge.tsx index adb1108..6b3134f 100644 --- a/packages/ui/src/badge.tsx +++ b/packages/ui/src/badge.tsx @@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "../lib/utils"; const badgeVariants = cva( - "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + "inline-flex items-center rounded border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", { variants: { variant: { diff --git a/packages/ui/src/card.tsx b/packages/ui/src/card.tsx index ba8f736..f7468cf 100644 --- a/packages/ui/src/card.tsx +++ b/packages/ui/src/card.tsx @@ -1,6 +1,6 @@ -import * as React from "react" +import * as React from "react"; -import { cn } from "../lib/utils" +import { cn } from "../lib/utils"; const Card = React.forwardRef< HTMLDivElement, @@ -8,14 +8,11 @@ const Card = React.forwardRef< >(({ className, ...props }, ref) => (
-)) -Card.displayName = "Card" +)); +Card.displayName = "Card"; const CardHeader = React.forwardRef< HTMLDivElement, @@ -26,8 +23,8 @@ const CardHeader = React.forwardRef< className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} /> -)) -CardHeader.displayName = "CardHeader" +)); +CardHeader.displayName = "CardHeader"; const CardTitle = React.forwardRef< HTMLParagraphElement, @@ -38,8 +35,8 @@ const CardTitle = React.forwardRef< className={cn("font-semibold leading-none tracking-tight", className)} {...props} /> -)) -CardTitle.displayName = "CardTitle" +)); +CardTitle.displayName = "CardTitle"; const CardDescription = React.forwardRef< HTMLParagraphElement, @@ -50,16 +47,16 @@ const CardDescription = React.forwardRef< className={cn("text-sm text-muted-foreground", className)} {...props} /> -)) -CardDescription.displayName = "CardDescription" +)); +CardDescription.displayName = "CardDescription"; const CardContent = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => (
-)) -CardContent.displayName = "CardContent" +)); +CardContent.displayName = "CardContent"; const CardFooter = React.forwardRef< HTMLDivElement, @@ -70,7 +67,14 @@ const CardFooter = React.forwardRef< className={cn("flex items-center p-6 pt-0", className)} {...props} /> -)) -CardFooter.displayName = "CardFooter" +)); +CardFooter.displayName = "CardFooter"; -export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardDescription, + CardContent, +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7bf1fd3..78aa6d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -186,8 +186,8 @@ importers: specifier: workspace:* version: link:../../packages/ui bullmq: - specifier: ^5.8.2 - version: 5.8.2 + specifier: ^5.41.0 + version: 5.41.0 chrono-node: specifier: ^2.7.6 version: 2.7.6 @@ -206,9 +206,6 @@ importers: html-to-text: specifier: ^9.0.5 version: 9.0.5 - install: - specifier: ^0.13.0 - version: 0.13.0 ioredis: specifier: ^5.4.1 version: 5.4.1 @@ -227,9 +224,6 @@ importers: next-auth: specifier: ^4.24.6 version: 4.24.7(next@14.2.1)(react-dom@18.2.0)(react@18.2.0) - pg-boss: - specifier: ^9.0.3 - version: 9.0.3 pnpm: specifier: ^8.15.5 version: 8.15.5 @@ -254,6 +248,9 @@ importers: server-only: specifier: ^0.0.1 version: 0.0.1 + stripe: + specifier: ^17.6.0 + version: 17.6.0 superjson: specifier: ^2.2.1 version: 2.2.1 @@ -8279,14 +8276,6 @@ packages: engines: {node: '>= 10.0.0'} dev: true - /aggregate-error@3.1.0: - resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} - engines: {node: '>=8'} - dependencies: - clean-stack: 2.2.0 - indent-string: 4.0.0 - dev: false - /aggregate-error@4.0.1: resolution: {integrity: sha512-0poP0T7el6Vq3rstR8Mn4V/IQrpBLO6POkUSrN7RhyY+GF/InCFShQzsQ39T25gkHhLgSLByyAz+Kjb+c2L98w==} engines: {node: '>=12'} @@ -8825,12 +8814,12 @@ packages: resolution: {integrity: sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==} dev: false - /bullmq@5.8.2: - resolution: {integrity: sha512-V64+Nz28FO9YEEUiDonG5KFhjihedML/OxuHpB0D5vV8aWcF1ui/5nmjDcCIyx4EXiUUDDypSUotjzcYu8gkeg==} + /bullmq@5.41.0: + resolution: {integrity: sha512-GGfKu2DHGIvbnMtQjR/82wvWsdCaGxN5JGR3pvKd1mkDI9DsWn8r0+pAzZ6Y4ImWXFaetaAqywOhv2Ik0R2m3g==} dependencies: cron-parser: 4.9.0 ioredis: 5.4.1 - msgpackr: 1.10.2 + msgpackr: 1.11.2 node-abort-controller: 3.1.1 semver: 7.6.0 tslib: 2.6.2 @@ -9024,11 +9013,6 @@ packages: escape-string-regexp: 1.0.5 dev: true - /clean-stack@2.2.0: - resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} - engines: {node: '>=6'} - dev: false - /clean-stack@4.2.0: resolution: {integrity: sha512-LYv6XPxoyODi36Dp976riBtSY27VmFo+MKqEU9QCCWyTrdEPDog+RWA7xQWHi6Vbp61j5c4cdzzX1NidnwtUWg==} engines: {node: '>=12'} @@ -9525,11 +9509,6 @@ packages: has-property-descriptors: 1.0.2 object-keys: 1.1.1 - /delay@5.0.0: - resolution: {integrity: sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==} - engines: {node: '>=10'} - dev: false - /delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -10073,7 +10052,7 @@ packages: eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0) - eslint-plugin-import: 2.29.1(eslint@8.57.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) eslint-plugin-jsx-a11y: 6.8.0(eslint@8.57.0) eslint-plugin-react: 7.34.0(eslint@8.57.0) eslint-plugin-react-hooks: 4.6.0(eslint@8.57.0) @@ -10131,7 +10110,7 @@ packages: enhanced-resolve: 5.16.0 eslint: 8.57.0 eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.2.0)(eslint@8.57.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) fast-glob: 3.3.2 get-tsconfig: 4.7.3 is-core-module: 2.13.1 @@ -10265,6 +10244,41 @@ packages: ignore: 5.3.1 dev: true + /eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0): + resolution: {integrity: sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + dependencies: + '@typescript-eslint/parser': 6.21.0(eslint@8.57.0)(typescript@5.4.2) + array-includes: 3.1.7 + array.prototype.findlastindex: 1.2.4 + array.prototype.flat: 1.3.2 + array.prototype.flatmap: 1.3.2 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 8.57.0 + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) + hasown: 2.0.2 + is-core-module: 2.13.1 + is-glob: 4.0.3 + minimatch: 3.1.2 + object.fromentries: 2.0.7 + object.groupby: 1.0.2 + object.values: 1.1.7 + semver: 6.3.1 + tsconfig-paths: 3.15.0 + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + dev: true + /eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.2.0)(eslint@8.57.0): resolution: {integrity: sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==} engines: {node: '>=4'} @@ -10300,40 +10314,6 @@ packages: - supports-color dev: true - /eslint-plugin-import@2.29.1(eslint@8.57.0): - resolution: {integrity: sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==} - engines: {node: '>=4'} - peerDependencies: - '@typescript-eslint/parser': '*' - eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 - peerDependenciesMeta: - '@typescript-eslint/parser': - optional: true - dependencies: - array-includes: 3.1.7 - array.prototype.findlastindex: 1.2.4 - array.prototype.flat: 1.3.2 - array.prototype.flatmap: 1.3.2 - debug: 3.2.7 - doctrine: 2.1.0 - eslint: 8.57.0 - eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) - hasown: 2.0.2 - is-core-module: 2.13.1 - is-glob: 4.0.3 - minimatch: 3.1.2 - object.fromentries: 2.0.7 - object.groupby: 1.0.2 - object.values: 1.1.7 - semver: 6.3.1 - tsconfig-paths: 3.15.0 - transitivePeerDependencies: - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - supports-color - dev: true - /eslint-plugin-jest@27.9.0(@typescript-eslint/eslint-plugin@6.21.0)(eslint@8.57.0)(typescript@5.4.2): resolution: {integrity: sha512-QIT7FH7fNmd9n4se7FFKHbsLKGQiw885Ds6Y/sxKgCZ6natwCsXdgPOADnYVxN2QrRweF0FZWbJ6S7Rsn7llug==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -11872,6 +11852,7 @@ packages: /indent-string@4.0.0: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + dev: true /indent-string@5.0.0: resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} @@ -11910,11 +11891,6 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false - /install@0.13.0: - resolution: {integrity: sha512-zDml/jzr2PKU9I8J/xyZBQn8rPCAY//UOYNmR01XwNwyfhEWObo2SWfSl1+0tm1u6PhxLwDnfsT/6jB7OUxqFA==} - engines: {node: '>= 0.10'} - dev: false - /internal-slot@1.0.7: resolution: {integrity: sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==} engines: {node: '>= 0.4'} @@ -12630,10 +12606,6 @@ packages: dependencies: p-locate: 5.0.0 - /lodash.debounce@4.0.8: - resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} - dev: false - /lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} dev: false @@ -13597,8 +13569,8 @@ packages: dev: false optional: true - /msgpackr@1.10.2: - resolution: {integrity: sha512-L60rsPynBvNE+8BWipKKZ9jHcSGbtyJYIwjRq0VrIvQ08cRjntGXJYW/tmciZ2IHWIY8WEW32Qa2xbh5+SKBZA==} + /msgpackr@1.11.2: + resolution: {integrity: sha512-F9UngXRlPyWCDEASDpTf6c9uNhGPTqnTeLVt7bN+bU1eajoR/8V9ys2BRaV5C/e5ihE6sJ9uPIKaYt6bFuO32g==} optionalDependencies: msgpackr-extract: 3.0.3 dev: false @@ -14127,13 +14099,6 @@ packages: dependencies: p-limit: 3.1.0 - /p-map@4.0.0: - resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} - engines: {node: '>=10'} - dependencies: - aggregate-error: 3.1.0 - dev: false - /p-memoize@4.0.4: resolution: {integrity: sha512-ijdh0DP4Mk6J4FXlOM6vPPoCjPytcEseW8p/k5SDTSSfGV3E9bpt9Yzfifvzp6iohIieoLTkXRb32OWV0fB2Lw==} engines: {node: '>=10'} @@ -14324,83 +14289,6 @@ packages: is-reference: 3.0.2 dev: true - /pg-boss@9.0.3: - resolution: {integrity: sha512-cUWUiv3sr563yNy0nCZ25Tv5U0m59Y9MhX/flm0vTR012yeVCrqpfboaZP4xFOQPdWipMJpuu4g94HR0SncTgw==} - engines: {node: '>=16'} - dependencies: - cron-parser: 4.9.0 - delay: 5.0.0 - lodash.debounce: 4.0.8 - p-map: 4.0.0 - pg: 8.11.5 - serialize-error: 8.1.0 - uuid: 9.0.1 - transitivePeerDependencies: - - pg-native - dev: false - - /pg-cloudflare@1.1.1: - resolution: {integrity: sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==} - requiresBuild: true - dev: false - optional: true - - /pg-connection-string@2.6.4: - resolution: {integrity: sha512-v+Z7W/0EO707aNMaAEfiGnGL9sxxumwLl2fJvCQtMn9Fxsg+lPpPkdcyBSv/KFgpGdYkMfn+EI1Or2EHjpgLCA==} - dev: false - - /pg-int8@1.0.1: - resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} - engines: {node: '>=4.0.0'} - dev: false - - /pg-pool@3.6.2(pg@8.11.5): - resolution: {integrity: sha512-Htjbg8BlwXqSBQ9V8Vjtc+vzf/6fVUuak/3/XXKA9oxZprwW3IMDQTGHP+KDmVL7rtd+R1QjbnCFPuTHm3G4hg==} - peerDependencies: - pg: '>=8.0' - dependencies: - pg: 8.11.5 - dev: false - - /pg-protocol@1.6.1: - resolution: {integrity: sha512-jPIlvgoD63hrEuihvIg+tJhoGjUsLPn6poJY9N5CnlPd91c2T18T/9zBtLxZSb1EhYxBRoZJtzScCaWlYLtktg==} - dev: false - - /pg-types@2.2.0: - resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} - engines: {node: '>=4'} - dependencies: - pg-int8: 1.0.1 - postgres-array: 2.0.0 - postgres-bytea: 1.0.0 - postgres-date: 1.0.7 - postgres-interval: 1.2.0 - dev: false - - /pg@8.11.5: - resolution: {integrity: sha512-jqgNHSKL5cbDjFlHyYsCXmQDrfIX/3RsNwYqpd4N0Kt8niLuNoRNH+aazv6cOd43gPh9Y4DjQCtb+X0MH0Hvnw==} - engines: {node: '>= 8.0.0'} - peerDependencies: - pg-native: '>=3.0.1' - peerDependenciesMeta: - pg-native: - optional: true - dependencies: - pg-connection-string: 2.6.4 - pg-pool: 3.6.2(pg@8.11.5) - pg-protocol: 1.6.1 - pg-types: 2.2.0 - pgpass: 1.0.5 - optionalDependencies: - pg-cloudflare: 1.1.1 - dev: false - - /pgpass@1.0.5: - resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} - dependencies: - split2: 4.2.0 - dev: false - /picocolors@1.0.0: resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} @@ -14546,28 +14434,6 @@ packages: picocolors: 1.0.0 source-map-js: 1.2.0 - /postgres-array@2.0.0: - resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} - engines: {node: '>=4'} - dev: false - - /postgres-bytea@1.0.0: - resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==} - engines: {node: '>=0.10.0'} - dev: false - - /postgres-date@1.0.7: - resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} - engines: {node: '>=0.10.0'} - dev: false - - /postgres-interval@1.2.0: - resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} - engines: {node: '>=0.10.0'} - dependencies: - xtend: 4.0.2 - dev: false - /preact-render-to-string@5.2.3(preact@10.11.3): resolution: {integrity: sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==} peerDependencies: @@ -15982,13 +15848,6 @@ packages: - supports-color dev: true - /serialize-error@8.1.0: - resolution: {integrity: sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==} - engines: {node: '>=10'} - dependencies: - type-fest: 0.20.2 - dev: false - /serve-static@1.15.0: resolution: {integrity: sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==} engines: {node: '>= 0.8.0'} @@ -16285,11 +16144,6 @@ packages: engines: {node: '>=12'} dev: false - /split2@4.2.0: - resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} - engines: {node: '>= 10.x'} - dev: false - /sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} dev: true @@ -16445,6 +16299,14 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + /stripe@17.6.0: + resolution: {integrity: sha512-+HB6+SManp0gSRB0dlPmXO+io18krlAe0uimXhhIkL/RG/VIRigkfoM3QDJPkqbuSW0XsA6uzsivNCJU1ELEDA==} + engines: {node: '>=12.*'} + dependencies: + '@types/node': 20.12.12 + qs: 6.12.2 + dev: false + /strnum@1.0.5: resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==} dev: false @@ -16974,6 +16836,7 @@ packages: /type-fest@0.20.2: resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} engines: {node: '>=10'} + dev: true /type-fest@0.6.0: resolution: {integrity: sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==}