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
This commit is contained in:
KM Koushik
2025-03-23 07:06:56 +11:00
committed by GitHub
parent 6cfe41cd86
commit 403ad8b93e
34 changed files with 1352 additions and 238 deletions

13
.cursorrules Normal file
View File

@@ -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 users 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 todos, 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.

21
CLAUDE.md Normal file
View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Team" ADD COLUMN "billingEmail" TEXT,
ADD COLUMN "isActive" BOOLEAN NOT NULL DEFAULT true;

View File

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

View File

@@ -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 }) {
<Code className="h-4 w-4" />
Developer settings
</NavButton>
{!env.NEXT_PUBLIC_IS_CLOUD || session?.user.isAdmin ? (
{isCloud() ? (
<NavButton href="/settings">
<Cog className="h-4 w-4" />
Settings
</NavButton>
) : null}
{isCloud() || session?.user.isAdmin ? (
<NavButton href="/admin">
<Server className="h-4 w-4" />
Admin

View File

@@ -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({
<div>
<h1 className="font-bold text-lg">Developer settings</h1>
<div className="flex gap-4 mt-4">
<SettingsNavButton href="/dev-settings/api-keys">
API Keys
</SettingsNavButton>
<SettingsNavButton href="/dev-settings">API Keys</SettingsNavButton>
<SettingsNavButton href="/dev-settings/smtp">SMTP</SettingsNavButton>
</div>
<div className="mt-8">{children}</div>

View File

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

View File

@@ -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 (
<Card className="mt-9 max-w-xl">
<CardHeader>

View File

@@ -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 (
<div className="container mx-auto py-10">
<h1 className="text-2xl font-semibold mb-8">
Payment {success ? "Success" : canceled ? "Canceled" : "Unknown"}
</h1>
{canceled ? (
<Link href="/settings/billing">
<Button>Go to billing</Button>
</Link>
) : null}
{success ? <VerifySuccess /> : null}
</div>
);
}
function VerifySuccess() {
const { data: teams, isLoading } = api.team.getTeams.useQuery(undefined, {
refetchInterval: 3000,
});
if (teams?.[0]?.plan !== "FREE") {
return (
<div>
<div className="flex gap-2 items-center">
<CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" />
<p>Your account has been upgraded to the paid plan.</p>
</div>
<Link href="/settings/billing" className="mt-8">
<Button className="mt-8">Go to billing</Button>
</Link>
</div>
);
}
return (
<div className="flex gap-2 items-center">
<Spinner
className="h-5 w-5 stroke-muted-foreground"
innerSvgClass=" stroke-muted-foreground"
/>
<p className="text-muted-foreground">Verifying payment</p>
</div>
);
}

View File

@@ -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 (
<div className="flex justify-center items-center h-full">
<Spinner className="w-4 h-4" />
</div>
);
}
return (
<div className="space-y-8">
<Card className=" rounded-xl mt-10 p-8 px-8">
<PlanDetails />
<div className="mt-4">
{currentTeam?.plan !== "FREE" ? (
<Button
onClick={onManageClick}
className="mt-4 w-[120px]"
disabled={manageSessionUrl.isPending}
>
{manageSessionUrl.isPending ? (
<Spinner className="w-4 h-4" />
) : (
"Manage"
)}
</Button>
) : (
<UpgradeButton />
)}
</div>
</Card>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mt-8">
<Card className="p-6">
<div>
<div className="text-sm text-muted-foreground">Payment Method</div>
{subscription ? (
<div className="mt-2">
<div className="text-lg font-mono uppercase flex items-center gap-2">
{subscription.paymentMethod ? (
<>
<span>💳</span>
<span className="capitalize">
{paymentMethod.card?.brand || ""} {" "}
{paymentMethod.card?.last4 || ""}
</span>
{paymentMethod.card && (
<span className="text-sm text-muted-foreground lowercase">
(Expires: {paymentMethod.card.exp_month}/
{paymentMethod.card.exp_year})
</span>
)}
</>
) : (
"No Payment Method"
)}
</div>
<div className="text-sm text-muted-foreground mt-1">
Next billing date:{" "}
{subscription.currentPeriodEnd
? format(
new Date(subscription.currentPeriodEnd),
"MMM dd, yyyy"
)
: "N/A"}
</div>
</div>
) : (
<div className="text-sm text-muted-foreground mt-2">
No active subscription
</div>
)}
</div>
</Card>
<Card className="p-6">
<div>
<div className="text-sm text-muted-foreground">Billing Email</div>
{isEditingEmail ? (
<div className="mt-2">
<div className="flex items-center gap-2">
<input
type="email"
value={billingEmail}
onChange={(e) => 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"
/>
<Button
onClick={handleSaveEmail}
disabled={updateBillingEmailMutation.isPending}
size="sm"
>
{updateBillingEmailMutation.isPending ? (
<Spinner className="w-4 h-4" />
) : (
"Save"
)}
</Button>
<Button
onClick={() => setIsEditingEmail(false)}
variant="outline"
size="sm"
>
Cancel
</Button>
</div>
</div>
) : (
<div className="mt-2">
<div className="flex items-center gap-2">
<div className="font-mono">
{currentTeam?.billingEmail || "No billing email set"}
</div>
<Button onClick={handleEditEmail} variant="ghost" size="sm">
Edit
</Button>
</div>
</div>
)}
</div>
</Card>
</div>
</div>
);
}

View File

@@ -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 (
<div>
<h1 className="font-bold text-lg">Settings</h1>
<div className="flex gap-4 mt-4">
<SettingsNavButton href="/settings">Usage</SettingsNavButton>
<SettingsNavButton href="/settings/billing">Billing</SettingsNavButton>
</div>
<div className="mt-8">{children}</div>
</div>
);
}

View File

@@ -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 (
<div>
<UsagePage />
</div>
);
}

View File

@@ -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 (
<Card className="p-6">
<div className="flex w-full">
<div className="space-y-4 w-full">
{usage?.map((item) => (
<div
key={item.type}
className="flex justify-between items-center border-b pb-3 last:border-0 last:pb-0"
>
<div>
<div className="font-medium capitalize">
{item.type.toLowerCase()}
</div>
<div className="text-sm text-muted-foreground mt-1">
{item.type === "TRANSACTIONAL"
? "Mails sent using the send api or SMTP"
: "Mails designed sent from unsend editor"}
</div>
</div>
<div className="font-mono font-medium">
{item.sent.toLocaleString()} emails
</div>
</div>
))}
<div className="flex justify-between items-center pt-3 ">
<div className="font-medium">Total</div>
<div className="font-mono font-medium">
{usage
?.reduce((acc, item) => acc + item.sent, 0)
.toLocaleString()}{" "}
emails
</div>
</div>
</div>
<div className="w-full flex justify-center items-center">
<div className="w-[300px] space-y-8">
<div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<div className="">Monthly Limit</div>
<div className="font-mono font-medium">
{totalSent.toLocaleString()}/
{FREE_PLAN_LIMIT.toLocaleString()}
</div>
</div>
<div className="h-2 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300 ease-in-out"
style={{
width: `${Math.min(monthlyPercentageUsed, 100)}%`,
}}
/>
</div>
</div>
</div>
<div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<div className="">Daily Limit</div>
<div className="font-mono">
{dailyUsage.toLocaleString()}/{DAILY_LIMIT.toLocaleString()}
</div>
</div>
<div className="h-2 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300 ease-in-out"
style={{ width: `${Math.min(dailyPercentageUsed, 100)}%` }}
/>
</div>
</div>
</div>
</div>
</div>
</div>
</Card>
);
}
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 (
<Card className="p-6">
<div className="flex w-full">
<div className="space-y-4 w-full">
{usage?.map((item) => (
<div
key={item.type}
className="flex justify-between items-center border-b pb-3 last:border-0 last:pb-0"
>
<div>
<div className="font-medium capitalize">
{item.type.toLowerCase()}
</div>
<div className="text-sm text-muted-foreground mt-1">
<span className="font-mono">
{item.sent.toLocaleString()}
</span>{" "}
emails at{" "}
<span className="font-mono">
${USAGE_UNIT_PRICE[item.type]}
</span>{" "}
each
</div>
</div>
<div className="font-mono font-medium">
${getCost(item.sent, item.type).toFixed(2)}
</div>
</div>
))}
<div className="flex justify-between items-center border-b pb-3 last:border-0 last:pb-0">
<div>
<div className="font-medium capitalize">Minimum spend</div>
<div className="text-sm text-muted-foreground mt-1">
{currentTeam?.plan}
</div>
</div>
<div className="font-mono font-medium">
${planCreditCost.toFixed(2)}
</div>
</div>
</div>
<div className="w-full flex justify-center items-center">
<div>
<div className="font-medium">Amount Due</div>
<div className="">
<div className="text-2xl font-mono">
{planCreditCost < totalCost
? `$${totalCost.toFixed(2)}`
: `$${planCreditCost.toFixed(2)}`}
</div>
</div>
</div>
</div>
</div>
</Card>
);
}
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 (
<div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold">Usage</h1>
<div className="text-sm text-muted-foreground mt-1">
<span className="font-medium">{billingPeriod}</span>
</div>
</div>
</div>
{isLoading ? (
<div className="flex justify-center py-8">
<Spinner className="w-8 h-8" innerSvgClass="stroke-primary" />
</div>
) : usage?.month.length === 0 ? (
<Card className="p-6 text-center text-muted-foreground">
No usage data available
</Card>
) : currentTeam?.plan === "FREE" ? (
<FreePlanUsage
usage={usage?.month ?? []}
dayUsage={usage?.day ?? []}
/>
) : (
<PaidPlanUsage usage={usage?.month ?? []} />
)}
</div>
{currentTeam?.plan ? (
<Card className=" rounded-xl mt-10 p-4 px-8">
<PlanDetails />
<div className="mt-4">
{currentTeam?.plan === "FREE" ? <UpgradeButton /> : null}
</div>
</Card>
) : null}
</div>
);
}

View File

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

View File

@@ -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 (
<div>
<div className="capitalize text-lg">
{subscriptionQuery.data?.status === "active"
? planKey.toLowerCase()
: "free"}
</div>
<div className="flex items-center gap-2">
<div className="text-muted-foreground text-sm">Current plan</div>
{subscriptionQuery.data?.cancelAtPeriodEnd && (
<Badge variant="secondary">
Cancels {format(subscriptionQuery.data.cancelAtPeriodEnd, "MMM dd")}
</Badge>
)}
</div>
<ul className="mt-4 space-y-3">
{perks.map((perk, index) => (
<li key={index} className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" />
<span className="text-sm">{perk}</span>
</li>
))}
</ul>
</div>
);
};

View File

@@ -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 (
<Button
onClick={onClick}
className="mt-4 w-[120px]"
disabled={checkoutMutation.isPending}
>
{checkoutMutation.isPending ? <Spinner className="w-4 h-4" /> : "Upgrade"}
</Button>
);
};

View File

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

View File

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

View File

@@ -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",
],
};

66
apps/web/src/lib/usage.ts Normal file
View File

@@ -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, number> = {
[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;
}

View File

@@ -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 <CreateTeam />;
}
return <>{children}</>;
return <TeamProvider>{children}</TeamProvider>;
};

View File

@@ -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<TeamContextType | undefined>(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 <TeamContext.Provider value={value}>{children}</TeamContext.Provider>;
}
export function useTeam() {
const context = useContext(TeamContext);
if (context === undefined) {
throw new Error("useTeam must be used within a TeamProvider");
}
return context;
}

View File

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

View File

@@ -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<Array<{ type: EmailUsageType; sent: number }>>`
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<Array<{ type: EmailUsageType; sent: number }>>`
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 },
});
}),
});

View File

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

View File

@@ -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",
},
});
}

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
import { env } from "~/env";
export function isCloud() {
return env.NEXT_PUBLIC_IS_CLOUD;
}
export function isSelfHosted() {
return !isCloud();
}

View File

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

View File

@@ -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) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className
)}
className={cn("rounded-xl border text-card-foreground shadow", className)}
{...props}
/>
))
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<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
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,
};

251
pnpm-lock.yaml generated
View File

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