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:
13
.cursorrules
Normal file
13
.cursorrules
Normal 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 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.
|
21
CLAUDE.md
Normal file
21
CLAUDE.md
Normal 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.
|
@@ -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",
|
||||
|
@@ -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;
|
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Team" ADD COLUMN "billingEmail" TEXT,
|
||||
ADD COLUMN "isActive" BOOLEAN NOT NULL DEFAULT true;
|
@@ -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 {
|
||||
|
@@ -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
|
||||
|
@@ -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>
|
||||
|
@@ -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 (
|
||||
|
@@ -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>
|
||||
|
59
apps/web/src/app/(dashboard)/payments/page.tsx
Normal file
59
apps/web/src/app/(dashboard)/payments/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
173
apps/web/src/app/(dashboard)/settings/billing/page.tsx
Normal file
173
apps/web/src/app/(dashboard)/settings/billing/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
22
apps/web/src/app/(dashboard)/settings/layout.tsx
Normal file
22
apps/web/src/app/(dashboard)/settings/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
13
apps/web/src/app/(dashboard)/settings/page.tsx
Normal file
13
apps/web/src/app/(dashboard)/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
237
apps/web/src/app/(dashboard)/settings/usage/usage.tsx
Normal file
237
apps/web/src/app/(dashboard)/settings/usage/usage.tsx
Normal 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>
|
||||
);
|
||||
}
|
77
apps/web/src/app/api/webhook/stripe/route.ts
Normal file
77
apps/web/src/app/api/webhook/stripe/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
45
apps/web/src/components/payments/PlanDetails.tsx
Normal file
45
apps/web/src/components/payments/PlanDetails.tsx
Normal 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>
|
||||
);
|
||||
};
|
24
apps/web/src/components/payments/UpgradeButton.tsx
Normal file
24
apps/web/src/components/payments/UpgradeButton.tsx
Normal 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>
|
||||
);
|
||||
};
|
@@ -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
|
||||
|
@@ -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;
|
||||
}
|
||||
}
|
||||
|
13
apps/web/src/lib/constants/payments.ts
Normal file
13
apps/web/src/lib/constants/payments.ts
Normal 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
66
apps/web/src/lib/usage.ts
Normal 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;
|
||||
}
|
@@ -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>;
|
||||
};
|
||||
|
43
apps/web/src/providers/team-context.tsx
Normal file
43
apps/web/src/providers/team-context.tsx
Normal 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;
|
||||
}
|
@@ -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
|
||||
|
82
apps/web/src/server/api/routers/billing.ts
Normal file
82
apps/web/src/server/api/routers/billing.ts
Normal 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 },
|
||||
});
|
||||
}),
|
||||
});
|
@@ -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();
|
||||
|
162
apps/web/src/server/billing/payments.ts
Normal file
162
apps/web/src/server/billing/payments.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
}
|
22
apps/web/src/server/billing/usage.ts
Normal file
22
apps/web/src/server/billing/usage.ts
Normal 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;
|
||||
}
|
90
apps/web/src/server/jobs/usage-job.ts
Normal file
90
apps/web/src/server/jobs/usage-job.ts
Normal 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);
|
||||
});
|
9
apps/web/src/utils/common.ts
Normal file
9
apps/web/src/utils/common.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { env } from "~/env";
|
||||
|
||||
export function isCloud() {
|
||||
return env.NEXT_PUBLIC_IS_CLOUD;
|
||||
}
|
||||
|
||||
export function isSelfHosted() {
|
||||
return !isCloud();
|
||||
}
|
@@ -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: {
|
||||
|
@@ -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
251
pnpm-lock.yaml
generated
@@ -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==}
|
||||
|
Reference in New Issue
Block a user