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:
@@ -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 });
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user