add stripe (#121)

* add some stripe stuff

* more stripe stuff

* more stripe things

* more stripr stuff

* more stripe stuff

* more stripe stuff

* add more stuff

* add more stripe stuff

* more stuff

* fix types
This commit is contained in:
KM Koushik
2025-03-23 07:06:56 +11:00
committed by GitHub
parent 6cfe41cd86
commit 403ad8b93e
34 changed files with 1352 additions and 238 deletions

View File

@@ -35,21 +35,19 @@
"@trpc/server": "next",
"@unsend/email-editor": "workspace:*",
"@unsend/ui": "workspace:*",
"bullmq": "^5.8.2",
"bullmq": "^5.41.0",
"chrono-node": "^2.7.6",
"date-fns": "^3.6.0",
"emoji-picker-react": "^4.12.0",
"framer-motion": "^11.0.24",
"hono": "^4.2.2",
"html-to-text": "^9.0.5",
"install": "^0.13.0",
"ioredis": "^5.4.1",
"lucide-react": "^0.359.0",
"mime-types": "^2.1.35",
"nanoid": "^5.0.7",
"next": "^14.2.1",
"next-auth": "^4.24.6",
"pg-boss": "^9.0.3",
"pnpm": "^8.15.5",
"prisma": "^6.3.1",
"query-string": "^9.0.0",
@@ -58,6 +56,7 @@
"react-hook-form": "^7.51.3",
"recharts": "^2.12.5",
"server-only": "^0.0.1",
"stripe": "^17.6.0",
"superjson": "^2.2.1",
"tldts": "^6.1.16",
"ua-parser-js": "^1.0.38",

View File

@@ -0,0 +1,34 @@
/*
Warnings:
- A unique constraint covering the columns `[stripeCustomerId]` on the table `Team` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateEnum
CREATE TYPE "Plan" AS ENUM ('FREE', 'BASIC');
-- AlterTable
ALTER TABLE "Team" ADD COLUMN "plan" "Plan" NOT NULL DEFAULT 'FREE',
ADD COLUMN "stripeCustomerId" TEXT;
-- CreateTable
CREATE TABLE "Subscription" (
"id" TEXT NOT NULL,
"teamId" INTEGER NOT NULL,
"status" TEXT NOT NULL,
"priceId" TEXT NOT NULL,
"currentPeriodEnd" TIMESTAMP(3),
"currentPeriodStart" TIMESTAMP(3),
"cancelAtPeriodEnd" TIMESTAMP(3),
"paymentMethod" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Subscription_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Team_stripeCustomerId_key" ON "Team"("stripeCustomerId");
-- AddForeignKey
ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

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

View File

@@ -90,11 +90,20 @@ model User {
teamUsers TeamUser[]
}
enum Plan {
FREE
BASIC
}
model Team {
id Int @id @default(autoincrement())
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
plan Plan @default(FREE)
stripeCustomerId String? @unique
isActive Boolean @default(true)
billingEmail String?
teamUsers TeamUser[]
domains Domain[]
apiKeys ApiKey[]
@@ -103,6 +112,22 @@ model Team {
campaigns Campaign[]
templates Template[]
dailyEmailUsages DailyEmailUsage[]
Subscription Subscription[]
}
model Subscription {
id String @id
teamId Int
status String
priceId String
currentPeriodEnd DateTime?
currentPeriodStart DateTime?
cancelAtPeriodEnd DateTime?
paymentMethod String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
}
enum Role {

View File

@@ -8,6 +8,7 @@ import {
BookUser,
CircleUser,
Code,
Cog,
Globe,
Home,
LayoutDashboard,
@@ -18,6 +19,7 @@ import {
Package,
Package2,
Server,
Settings,
ShoppingCart,
Users,
Volume2,
@@ -34,6 +36,7 @@ import {
DropdownMenuTrigger,
} from "@unsend/ui/src/dropdown-menu";
import { ThemeSwitcher } from "~/components/theme/ThemeSwitcher";
import { isCloud } from "~/utils/common";
export function DashboardLayout({ children }: { children: React.ReactNode }) {
const { data: session } = useSession();
@@ -87,7 +90,15 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) {
<Code className="h-4 w-4" />
Developer settings
</NavButton>
{!env.NEXT_PUBLIC_IS_CLOUD || session?.user.isAdmin ? (
{isCloud() ? (
<NavButton href="/settings">
<Cog className="h-4 w-4" />
Settings
</NavButton>
) : null}
{isCloud() || session?.user.isAdmin ? (
<NavButton href="/admin">
<Server className="h-4 w-4" />
Admin

View File

@@ -1,7 +1,5 @@
"use client";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@unsend/ui/src/tabs";
import { useState } from "react";
import { SettingsNavButton } from "./settings-nav-button";
export const dynamic = "force-static";
@@ -15,9 +13,7 @@ export default function ApiKeysPage({
<div>
<h1 className="font-bold text-lg">Developer settings</h1>
<div className="flex gap-4 mt-4">
<SettingsNavButton href="/dev-settings/api-keys">
API Keys
</SettingsNavButton>
<SettingsNavButton href="/dev-settings">API Keys</SettingsNavButton>
<SettingsNavButton href="/dev-settings/smtp">SMTP</SettingsNavButton>
</div>
<div className="mt-8">{children}</div>

View File

@@ -11,7 +11,7 @@ export const SettingsNavButton: React.FC<{
}> = ({ href, children, comingSoon }) => {
const pathname = usePathname();
const isActive = pathname?.startsWith(href);
const isActive = pathname === href;
if (comingSoon) {
return (

View File

@@ -1,25 +1,15 @@
"use client";
import * as React from "react";
import { Code } from "@unsend/ui/src/code";
import { Button } from "@unsend/ui/src/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@unsend/ui/src/card";
import { TextWithCopyButton } from "@unsend/ui/src/text-with-copy";
export default function ExampleCard() {
const smtpDetails = {
smtp: "smtp.example.com",
port: "587",
user: "user@example.com",
password: "supersecretpassword",
};
return (
<Card className="mt-9 max-w-xl">
<CardHeader>

View File

@@ -0,0 +1,59 @@
"use client";
import { Button } from "@unsend/ui/src/button";
import Spinner from "@unsend/ui/src/spinner";
import { CheckCircle2 } from "lucide-react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { api } from "~/trpc/react";
export default function PaymentsPage() {
const searchParams = useSearchParams();
const success = searchParams.get("success");
const canceled = searchParams.get("canceled");
return (
<div className="container mx-auto py-10">
<h1 className="text-2xl font-semibold mb-8">
Payment {success ? "Success" : canceled ? "Canceled" : "Unknown"}
</h1>
{canceled ? (
<Link href="/settings/billing">
<Button>Go to billing</Button>
</Link>
) : null}
{success ? <VerifySuccess /> : null}
</div>
);
}
function VerifySuccess() {
const { data: teams, isLoading } = api.team.getTeams.useQuery(undefined, {
refetchInterval: 3000,
});
if (teams?.[0]?.plan !== "FREE") {
return (
<div>
<div className="flex gap-2 items-center">
<CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" />
<p>Your account has been upgraded to the paid plan.</p>
</div>
<Link href="/settings/billing" className="mt-8">
<Button className="mt-8">Go to billing</Button>
</Link>
</div>
);
}
return (
<div className="flex gap-2 items-center">
<Spinner
className="h-5 w-5 stroke-muted-foreground"
innerSvgClass=" stroke-muted-foreground"
/>
<p className="text-muted-foreground">Verifying payment</p>
</div>
);
}

View File

@@ -0,0 +1,173 @@
"use client";
import { useState } from "react";
import { Button } from "@unsend/ui/src/button";
import { Card } from "@unsend/ui/src/card";
import { Spinner } from "@unsend/ui/src/spinner";
import { format } from "date-fns";
import { useTeam } from "~/providers/team-context";
import { api } from "~/trpc/react";
import { PlanDetails } from "~/components/payments/PlanDetails";
import { UpgradeButton } from "~/components/payments/UpgradeButton";
export default function SettingsPage() {
const { currentTeam } = useTeam();
const manageSessionUrl = api.billing.getManageSessionUrl.useMutation();
const updateBillingEmailMutation =
api.billing.updateBillingEmail.useMutation();
const { data: subscription } = api.billing.getSubscriptionDetails.useQuery();
const [isEditingEmail, setIsEditingEmail] = useState(false);
const [billingEmail, setBillingEmail] = useState(
currentTeam?.billingEmail || ""
);
const apiUtils = api.useUtils();
const onManageClick = async () => {
const url = await manageSessionUrl.mutateAsync();
if (url) {
window.location.href = url;
}
};
const handleEditEmail = () => {
setBillingEmail(currentTeam?.billingEmail || "");
setIsEditingEmail(true);
};
const handleSaveEmail = async () => {
try {
await updateBillingEmailMutation.mutateAsync({ billingEmail });
await apiUtils.team.getTeams.invalidate();
setIsEditingEmail(false);
} catch (error) {
console.error("Failed to update billing email:", error);
}
};
const paymentMethod = JSON.parse(subscription?.paymentMethod || "{}");
if (!currentTeam?.plan) {
return (
<div className="flex justify-center items-center h-full">
<Spinner className="w-4 h-4" />
</div>
);
}
return (
<div className="space-y-8">
<Card className=" rounded-xl mt-10 p-8 px-8">
<PlanDetails />
<div className="mt-4">
{currentTeam?.plan !== "FREE" ? (
<Button
onClick={onManageClick}
className="mt-4 w-[120px]"
disabled={manageSessionUrl.isPending}
>
{manageSessionUrl.isPending ? (
<Spinner className="w-4 h-4" />
) : (
"Manage"
)}
</Button>
) : (
<UpgradeButton />
)}
</div>
</Card>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mt-8">
<Card className="p-6">
<div>
<div className="text-sm text-muted-foreground">Payment Method</div>
{subscription ? (
<div className="mt-2">
<div className="text-lg font-mono uppercase flex items-center gap-2">
{subscription.paymentMethod ? (
<>
<span>💳</span>
<span className="capitalize">
{paymentMethod.card?.brand || ""} {" "}
{paymentMethod.card?.last4 || ""}
</span>
{paymentMethod.card && (
<span className="text-sm text-muted-foreground lowercase">
(Expires: {paymentMethod.card.exp_month}/
{paymentMethod.card.exp_year})
</span>
)}
</>
) : (
"No Payment Method"
)}
</div>
<div className="text-sm text-muted-foreground mt-1">
Next billing date:{" "}
{subscription.currentPeriodEnd
? format(
new Date(subscription.currentPeriodEnd),
"MMM dd, yyyy"
)
: "N/A"}
</div>
</div>
) : (
<div className="text-sm text-muted-foreground mt-2">
No active subscription
</div>
)}
</div>
</Card>
<Card className="p-6">
<div>
<div className="text-sm text-muted-foreground">Billing Email</div>
{isEditingEmail ? (
<div className="mt-2">
<div className="flex items-center gap-2">
<input
type="email"
value={billingEmail}
onChange={(e) => setBillingEmail(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Enter billing email"
/>
<Button
onClick={handleSaveEmail}
disabled={updateBillingEmailMutation.isPending}
size="sm"
>
{updateBillingEmailMutation.isPending ? (
<Spinner className="w-4 h-4" />
) : (
"Save"
)}
</Button>
<Button
onClick={() => setIsEditingEmail(false)}
variant="outline"
size="sm"
>
Cancel
</Button>
</div>
</div>
) : (
<div className="mt-2">
<div className="flex items-center gap-2">
<div className="font-mono">
{currentTeam?.billingEmail || "No billing email set"}
</div>
<Button onClick={handleEditEmail} variant="ghost" size="sm">
Edit
</Button>
</div>
</div>
)}
</div>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
"use client";
import { SettingsNavButton } from "../dev-settings/settings-nav-button";
export const dynamic = "force-static";
export default function ApiKeysPage({
children,
}: {
children: React.ReactNode;
}) {
return (
<div>
<h1 className="font-bold text-lg">Settings</h1>
<div className="flex gap-4 mt-4">
<SettingsNavButton href="/settings">Usage</SettingsNavButton>
<SettingsNavButton href="/settings/billing">Billing</SettingsNavButton>
</div>
<div className="mt-8">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,13 @@
"use client";
import { Button } from "@unsend/ui/src/button";
import { api } from "~/trpc/react";
import UsagePage from "./usage/usage";
export default function SettingsPage() {
return (
<div>
<UsagePage />
</div>
);
}

View File

@@ -0,0 +1,237 @@
"use client";
import { api } from "~/trpc/react";
import { Card } from "@unsend/ui/src/card";
import Spinner from "@unsend/ui/src/spinner";
import { format } from "date-fns";
import {
getCost,
PLAN_CREDIT_UNITS,
UNIT_PRICE,
USAGE_UNIT_PRICE,
} from "~/lib/usage";
import { useTeam } from "~/providers/team-context";
import { EmailUsageType } from "@prisma/client";
import { PlanDetails } from "~/components/payments/PlanDetails";
import { UpgradeButton } from "~/components/payments/UpgradeButton";
const FREE_PLAN_LIMIT = 3000;
function FreePlanUsage({
usage,
dayUsage,
}: {
usage: { type: EmailUsageType; sent: number }[];
dayUsage: { type: EmailUsageType; sent: number }[];
}) {
const DAILY_LIMIT = 100;
const totalSent = usage?.reduce((acc, item) => acc + item.sent, 0) || 0;
const monthlyPercentageUsed = (totalSent / FREE_PLAN_LIMIT) * 100;
// Calculate daily usage - this is a simplified version, you might want to adjust based on actual daily tracking
const dailyUsage = dayUsage?.reduce((acc, item) => acc + item.sent, 0) || 0;
const dailyPercentageUsed = (dailyUsage / DAILY_LIMIT) * 100;
return (
<Card className="p-6">
<div className="flex w-full">
<div className="space-y-4 w-full">
{usage?.map((item) => (
<div
key={item.type}
className="flex justify-between items-center border-b pb-3 last:border-0 last:pb-0"
>
<div>
<div className="font-medium capitalize">
{item.type.toLowerCase()}
</div>
<div className="text-sm text-muted-foreground mt-1">
{item.type === "TRANSACTIONAL"
? "Mails sent using the send api or SMTP"
: "Mails designed sent from unsend editor"}
</div>
</div>
<div className="font-mono font-medium">
{item.sent.toLocaleString()} emails
</div>
</div>
))}
<div className="flex justify-between items-center pt-3 ">
<div className="font-medium">Total</div>
<div className="font-mono font-medium">
{usage
?.reduce((acc, item) => acc + item.sent, 0)
.toLocaleString()}{" "}
emails
</div>
</div>
</div>
<div className="w-full flex justify-center items-center">
<div className="w-[300px] space-y-8">
<div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<div className="">Monthly Limit</div>
<div className="font-mono font-medium">
{totalSent.toLocaleString()}/
{FREE_PLAN_LIMIT.toLocaleString()}
</div>
</div>
<div className="h-2 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300 ease-in-out"
style={{
width: `${Math.min(monthlyPercentageUsed, 100)}%`,
}}
/>
</div>
</div>
</div>
<div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<div className="">Daily Limit</div>
<div className="font-mono">
{dailyUsage.toLocaleString()}/{DAILY_LIMIT.toLocaleString()}
</div>
</div>
<div className="h-2 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300 ease-in-out"
style={{ width: `${Math.min(dailyPercentageUsed, 100)}%` }}
/>
</div>
</div>
</div>
</div>
</div>
</div>
</Card>
);
}
function PaidPlanUsage({
usage,
}: {
usage: { type: EmailUsageType; sent: number }[];
}) {
const { currentTeam } = useTeam();
if (currentTeam?.plan === "FREE") return null;
const totalCost =
usage?.reduce((acc, item) => acc + getCost(item.sent, item.type), 0) || 0;
const planCreditCost = PLAN_CREDIT_UNITS[currentTeam?.plan!] * UNIT_PRICE;
return (
<Card className="p-6">
<div className="flex w-full">
<div className="space-y-4 w-full">
{usage?.map((item) => (
<div
key={item.type}
className="flex justify-between items-center border-b pb-3 last:border-0 last:pb-0"
>
<div>
<div className="font-medium capitalize">
{item.type.toLowerCase()}
</div>
<div className="text-sm text-muted-foreground mt-1">
<span className="font-mono">
{item.sent.toLocaleString()}
</span>{" "}
emails at{" "}
<span className="font-mono">
${USAGE_UNIT_PRICE[item.type]}
</span>{" "}
each
</div>
</div>
<div className="font-mono font-medium">
${getCost(item.sent, item.type).toFixed(2)}
</div>
</div>
))}
<div className="flex justify-between items-center border-b pb-3 last:border-0 last:pb-0">
<div>
<div className="font-medium capitalize">Minimum spend</div>
<div className="text-sm text-muted-foreground mt-1">
{currentTeam?.plan}
</div>
</div>
<div className="font-mono font-medium">
${planCreditCost.toFixed(2)}
</div>
</div>
</div>
<div className="w-full flex justify-center items-center">
<div>
<div className="font-medium">Amount Due</div>
<div className="">
<div className="text-2xl font-mono">
{planCreditCost < totalCost
? `$${totalCost.toFixed(2)}`
: `$${planCreditCost.toFixed(2)}`}
</div>
</div>
</div>
</div>
</div>
</Card>
);
}
export default function UsagePage() {
const { data: usage, isLoading } = api.billing.getThisMonthUsage.useQuery();
const { currentTeam } = useTeam();
// Calculate the current billing period
const today = new Date();
const firstDayOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
const firstDayOfNextMonth = new Date(
today.getFullYear(),
today.getMonth() + 1,
1
);
const billingPeriod = `${format(firstDayOfMonth, "MMM dd")} - ${format(firstDayOfNextMonth, "MMM dd")}`;
return (
<div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold">Usage</h1>
<div className="text-sm text-muted-foreground mt-1">
<span className="font-medium">{billingPeriod}</span>
</div>
</div>
</div>
{isLoading ? (
<div className="flex justify-center py-8">
<Spinner className="w-8 h-8" innerSvgClass="stroke-primary" />
</div>
) : usage?.month.length === 0 ? (
<Card className="p-6 text-center text-muted-foreground">
No usage data available
</Card>
) : currentTeam?.plan === "FREE" ? (
<FreePlanUsage
usage={usage?.month ?? []}
dayUsage={usage?.day ?? []}
/>
) : (
<PaidPlanUsage usage={usage?.month ?? []} />
)}
</div>
{currentTeam?.plan ? (
<Card className=" rounded-xl mt-10 p-4 px-8">
<PlanDetails />
<div className="mt-4">
{currentTeam?.plan === "FREE" ? <UpgradeButton /> : null}
</div>
</Card>
) : null}
</div>
);
}

View File

@@ -0,0 +1,77 @@
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import Stripe from "stripe";
import { env } from "~/env";
import { syncStripeData } from "~/server/billing/payments";
import { db } from "~/server/db";
const stripe = new Stripe(env.STRIPE_SECRET_KEY!, {
apiVersion: "2025-01-27.acacia",
});
const allowedEvents: Stripe.Event.Type[] = [
"checkout.session.completed",
"customer.subscription.created",
"customer.subscription.updated",
"customer.subscription.deleted",
"customer.subscription.paused",
"customer.subscription.resumed",
"customer.subscription.pending_update_applied",
"customer.subscription.pending_update_expired",
"customer.subscription.trial_will_end",
"invoice.paid",
"invoice.payment_failed",
"invoice.payment_action_required",
"invoice.upcoming",
"invoice.marked_uncollectible",
"invoice.payment_succeeded",
"payment_intent.succeeded",
"payment_intent.payment_failed",
"payment_intent.canceled",
];
export async function POST(req: Request) {
const body = await req.text();
const signature = headers().get("Stripe-Signature");
if (!signature) {
console.error("No signature");
return new NextResponse("No signature", { status: 400 });
}
if (!env.STRIPE_WEBHOOK_SECRET) {
console.error("No webhook secret");
return new NextResponse("No webhook secret", { status: 400 });
}
try {
const event = stripe.webhooks.constructEvent(
body,
signature,
env.STRIPE_WEBHOOK_SECRET
);
if (!allowedEvents.includes(event.type)) {
return new NextResponse("OK", { status: 200 });
}
// All the events I track have a customerId
const { customer: customerId } = event?.data?.object as {
customer: string; // Sadly TypeScript does not know this
};
// This helps make it typesafe and also lets me know if my assumption is wrong
if (typeof customerId !== "string") {
throw new Error(
`[STRIPE HOOK][CANCER] ID isn't string.\nEvent type: ${event.type}`
);
}
await syncStripeData(customerId);
return new NextResponse("OK", { status: 200 });
} catch (err) {
console.error("Error processing webhook:", err);
return new NextResponse("Webhook error", { status: 400 });
}
}

View File

@@ -0,0 +1,45 @@
import { Plan } from "@prisma/client";
import { PLAN_PERKS } from "~/lib/constants/payments";
import { CheckCircle2 } from "lucide-react";
import { api } from "~/trpc/react";
import Spinner from "@unsend/ui/src/spinner";
import { useTeam } from "~/providers/team-context";
import { Badge } from "@unsend/ui/src/badge";
import { format } from "date-fns";
export const PlanDetails = () => {
const subscriptionQuery = api.billing.getSubscriptionDetails.useQuery();
const { currentTeam } = useTeam();
if (subscriptionQuery.isLoading || !currentTeam) {
return null;
}
const planKey = currentTeam.plan as keyof typeof PLAN_PERKS;
const perks = PLAN_PERKS[planKey] || [];
return (
<div>
<div className="capitalize text-lg">
{subscriptionQuery.data?.status === "active"
? planKey.toLowerCase()
: "free"}
</div>
<div className="flex items-center gap-2">
<div className="text-muted-foreground text-sm">Current plan</div>
{subscriptionQuery.data?.cancelAtPeriodEnd && (
<Badge variant="secondary">
Cancels {format(subscriptionQuery.data.cancelAtPeriodEnd, "MMM dd")}
</Badge>
)}
</div>
<ul className="mt-4 space-y-3">
{perks.map((perk, index) => (
<li key={index} className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" />
<span className="text-sm">{perk}</span>
</li>
))}
</ul>
</div>
);
};

View File

@@ -0,0 +1,24 @@
import { Button } from "@unsend/ui/src/button";
import Spinner from "@unsend/ui/src/spinner";
import { api } from "~/trpc/react";
export const UpgradeButton = () => {
const checkoutMutation = api.billing.createCheckoutSession.useMutation();
const onClick = async () => {
const url = await checkoutMutation.mutateAsync();
if (url) {
window.location.href = url;
}
};
return (
<Button
onClick={onClick}
className="mt-4 w-[120px]"
disabled={checkoutMutation.isPending}
>
{checkoutMutation.isPending ? <Spinner className="w-4 h-4" /> : "Upgrade"}
</Button>
);
};

View File

@@ -51,6 +51,9 @@ export const env = createEnv({
S3_COMPATIBLE_SECRET_KEY: z.string().optional(),
S3_COMPATIBLE_API_URL: z.string().optional(),
S3_COMPATIBLE_PUBLIC_URL: z.string().optional(),
STRIPE_SECRET_KEY: z.string().optional(),
STRIPE_BASIC_PRICE_ID: z.string().optional(),
STRIPE_WEBHOOK_SECRET: z.string().optional(),
},
/**
@@ -95,6 +98,9 @@ export const env = createEnv({
S3_COMPATIBLE_SECRET_KEY: process.env.S3_COMPATIBLE_SECRET_KEY,
S3_COMPATIBLE_API_URL: process.env.S3_COMPATIBLE_API_URL,
S3_COMPATIBLE_PUBLIC_URL: process.env.S3_COMPATIBLE_PUBLIC_URL,
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
STRIPE_BASIC_PRICE_ID: process.env.STRIPE_BASIC_PRICE_ID,
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
},
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially

View File

@@ -1,3 +1,6 @@
import { env } from "./env";
import { isCloud } from "./utils/common";
let initialized = false;
/**
@@ -9,11 +12,19 @@ export async function register() {
// eslint-disable-next-line turbo/no-undeclared-env-vars
if (process.env.NEXT_RUNTIME === "nodejs" && !initialized) {
console.log("Registering instrumentation");
const { EmailQueueService } = await import(
"~/server/service/email-queue-service"
);
await EmailQueueService.init();
/**
* Send usage data to Stripe
*/
if (isCloud()) {
await import("~/server/jobs/usage-job");
}
initialized = true;
}
}

View File

@@ -0,0 +1,13 @@
export const PLAN_PERKS = {
FREE: [
"Send up to 3000 emails per month",
"Send up to 100 emails per day",
"Can have 1 contact book",
],
BASIC: [
"Includes $10 of usage monthly",
"Send transactional emails at $0.0004 per email",
"Send marketing emails at $0.001 per email",
"Can have unlimited contact books",
],
};

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

@@ -0,0 +1,66 @@
import { EmailUsageType, Plan } from "@prisma/client";
/**
* Unit price for unsend
* 1 marketing email = 1 unit
* 4 transaction emails = 1 unit
*/
export const UNIT_PRICE = 0.001;
/**
* Number of credits per plan
* Credits are the units of measurement for email sending capacity.
* Each plan comes with a specific number of credits that can be used for sending emails.
* Marketing emails consume 1 credit per email, while transactional emails consume 0.25 credits per email.
*/
export const PLAN_CREDIT_UNITS = {
[Plan.BASIC]: 10_000,
};
export const USAGE_UNIT_PRICE: Record<EmailUsageType, number> = {
[EmailUsageType.MARKETING]: 0.001,
[EmailUsageType.TRANSACTIONAL]: 0.0004,
};
/**
* Gets timestamp for usage reporting, set to yesterday's date
* Converts to Unix timestamp (seconds since epoch)
* @returns Unix timestamp in seconds for yesterday's date
*/
export function getUsageTimestamp() {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
return Math.floor(yesterday.getTime() / 1000);
}
/**
* Gets yesterday's date in YYYY-MM-DD format
* @returns Yesterday's date string in YYYY-MM-DD format
*/
export function getUsageDate(): string {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const isoString = yesterday.toISOString();
return isoString.split("T")[0] as string;
}
/**
* Calculates total usage units based on marketing and transaction emails
* @param marketingUsage Number of marketing emails sent
* @param transactionUsage Number of transaction emails sent
* @returns Total usage units rounded down to nearest integer
*/
export function getUsageUinits(
marketingUsage: number,
transactionUsage: number
) {
return marketingUsage + Math.floor(transactionUsage / 4);
}
export function getCost(usage: number, type: EmailUsageType) {
const calculatedUsage =
type === EmailUsageType.MARKETING ? usage : Math.floor(usage / 4);
return calculatedUsage * UNIT_PRICE;
}

View File

@@ -6,6 +6,7 @@ import { AddSesSettings } from "~/components/settings/AddSesSettings";
import CreateTeam from "~/components/team/CreateTeam";
import { env } from "~/env";
import { api } from "~/trpc/react";
import { TeamProvider } from "./team-context";
export const DashboardProvider = ({
children,
@@ -37,5 +38,5 @@ export const DashboardProvider = ({
return <CreateTeam />;
}
return <>{children}</>;
return <TeamProvider>{children}</TeamProvider>;
};

View File

@@ -0,0 +1,43 @@
"use client";
import { createContext, useContext, useState, useEffect } from "react";
import { api } from "~/trpc/react";
// Define the Team type based on the Prisma schema
type Team = {
id: number;
name: string;
createdAt: Date;
updatedAt: Date;
plan: "FREE" | "BASIC";
stripeCustomerId?: string | null;
billingEmail?: string | null;
};
interface TeamContextType {
currentTeam: Team | null;
teams: Team[];
isLoading: boolean;
}
const TeamContext = createContext<TeamContextType | undefined>(undefined);
export function TeamProvider({ children }: { children: React.ReactNode }) {
const { data: teams, status } = api.team.getTeams.useQuery();
const value = {
currentTeam: teams?.[0] ?? null,
teams: teams || [],
isLoading: status === "pending",
};
return <TeamContext.Provider value={value}>{children}</TeamContext.Provider>;
}
export function useTeam() {
const context = useContext(TeamContext);
if (context === undefined) {
throw new Error("useTeam must be used within a TeamProvider");
}
return context;
}

View File

@@ -7,6 +7,7 @@ import { adminRouter } from "./routers/admin";
import { contactsRouter } from "./routers/contacts";
import { campaignRouter } from "./routers/campaign";
import { templateRouter } from "./routers/template";
import { billingRouter } from "./routers/billing";
/**
* This is the primary router for your server.
@@ -22,6 +23,7 @@ export const appRouter = createTRPCRouter({
contacts: contactsRouter,
campaign: campaignRouter,
template: templateRouter,
billing: billingRouter,
});
// export type definition of API

View File

@@ -0,0 +1,82 @@
import { DailyEmailUsage, EmailUsageType } from "@prisma/client";
import { TRPCError } from "@trpc/server";
import { format } from "date-fns";
import { z } from "zod";
import {
apiKeyProcedure,
createTRPCRouter,
teamProcedure,
} from "~/server/api/trpc";
import {
createCheckoutSessionForTeam,
getManageSessionUrl,
} from "~/server/billing/payments";
import { db } from "~/server/db";
export const billingRouter = createTRPCRouter({
createCheckoutSession: teamProcedure.mutation(async ({ ctx }) => {
return (await createCheckoutSessionForTeam(ctx.team.id)).url;
}),
getManageSessionUrl: teamProcedure.mutation(async ({ ctx }) => {
return await getManageSessionUrl(ctx.team.id);
}),
getThisMonthUsage: teamProcedure.query(async ({ ctx }) => {
const isoStartDate = format(new Date(), "yyyy-MM-01"); // First day of current month
const today = format(new Date(), "yyyy-MM-dd");
const [monthUsage, dayUsage] = await Promise.all([
// Get month usage
db.$queryRaw<Array<{ type: EmailUsageType; sent: number }>>`
SELECT
type,
SUM(sent)::integer AS sent
FROM "DailyEmailUsage"
WHERE "teamId" = ${ctx.team.id}
AND "date" >= ${isoStartDate}
GROUP BY "type"
`,
// Get today's usage
db.$queryRaw<Array<{ type: EmailUsageType; sent: number }>>`
SELECT
type,
SUM(sent)::integer AS sent
FROM "DailyEmailUsage"
WHERE "teamId" = ${ctx.team.id}
AND "date" = ${today}
GROUP BY "type"
`,
]);
return {
month: monthUsage,
day: dayUsage,
};
}),
getSubscriptionDetails: teamProcedure.query(async ({ ctx }) => {
const subscription = await db.subscription.findFirst({
where: { teamId: ctx.team.id },
orderBy: { status: "asc" },
});
return subscription;
}),
updateBillingEmail: teamProcedure
.input(
z.object({
billingEmail: z.string().email(),
})
)
.mutation(async ({ ctx, input }) => {
const { billingEmail } = input;
await db.team.update({
where: { id: ctx.team.id },
data: { billingEmail },
});
}),
});

View File

@@ -101,8 +101,6 @@ export const emailRouter = createTRPCRouter({
ORDER BY "date" ASC
`;
console.log({ result });
// Fill in any missing dates with 0 values
const filledResult: DailyEmailUsage[] = [];
const endDateObj = new Date();

View File

@@ -0,0 +1,162 @@
import Stripe from "stripe";
import { env } from "~/env";
import { db } from "../db";
function getStripe() {
if (!env.STRIPE_SECRET_KEY) {
throw new Error("STRIPE_SECRET_KEY is not set");
}
return new Stripe(env.STRIPE_SECRET_KEY);
}
async function createCustomerForTeam(teamId: number) {
const stripe = getStripe();
const customer = await stripe.customers.create({ metadata: { teamId } });
await db.team.update({
where: { id: teamId },
data: {
stripeCustomerId: customer.id,
billingEmail: customer.email,
},
});
return customer;
}
export async function createCheckoutSessionForTeam(teamId: number) {
const team = await db.team.findUnique({
where: { id: teamId },
});
if (!team) {
throw new Error("Team not found");
}
if (team.isActive && team.plan !== "FREE") {
throw new Error("Team is already active");
}
const stripe = getStripe();
let customerId = team.stripeCustomerId;
if (!customerId) {
const customer = await createCustomerForTeam(teamId);
customerId = customer.id;
}
if (!env.STRIPE_BASIC_PRICE_ID || !customerId) {
throw new Error("Stripe prices are not set");
}
const session = await stripe.checkout.sessions.create({
mode: "subscription",
customer: customerId,
line_items: [
{
price: env.STRIPE_BASIC_PRICE_ID,
},
],
success_url: `${env.NEXTAUTH_URL}/payments?success=true&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${env.NEXTAUTH_URL}/settings/billing`,
metadata: {
teamId,
},
client_reference_id: teamId.toString(),
});
return session;
}
function getPlanFromPriceId(priceId: string) {
if (priceId === env.STRIPE_BASIC_PRICE_ID) {
return "BASIC";
}
return "FREE";
}
export async function getManageSessionUrl(teamId: number) {
const team = await db.team.findUnique({
where: { id: teamId },
});
if (!team) {
throw new Error("Team not found");
}
if (!team.stripeCustomerId) {
throw new Error("Team has no Stripe customer ID");
}
const stripe = getStripe();
const subscriptions = await stripe.billingPortal.sessions.create({
customer: team.stripeCustomerId,
return_url: `${env.NEXTAUTH_URL}`,
});
return subscriptions.url;
}
export async function syncStripeData(customerId: string) {
const stripe = getStripe();
const team = await db.team.findUnique({
where: { stripeCustomerId: customerId },
});
if (!team) {
return;
}
const subscriptions = await stripe.subscriptions.list({
customer: customerId,
limit: 1,
status: "all",
expand: ["data.default_payment_method"],
});
const subscription = subscriptions.data[0];
if (!subscription) {
return;
}
await db.subscription.upsert({
where: { id: subscription.id },
update: {
status: subscription.status,
priceId: subscription.items.data[0]?.price?.id || "",
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
currentPeriodStart: new Date(subscription.current_period_start * 1000),
cancelAtPeriodEnd: subscription.cancel_at
? new Date(subscription.cancel_at * 1000)
: null,
paymentMethod: JSON.stringify(subscription.default_payment_method),
teamId: team.id,
},
create: {
id: subscription.id,
status: subscription.status,
priceId: subscription.items.data[0]?.price?.id || "",
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
currentPeriodStart: new Date(subscription.current_period_start * 1000),
cancelAtPeriodEnd: subscription.cancel_at
? new Date(subscription.cancel_at * 1000)
: null,
paymentMethod: JSON.stringify(subscription.default_payment_method),
teamId: team.id,
},
});
await db.team.update({
where: { id: team.id },
data: {
plan: getPlanFromPriceId(subscription.items.data[0]?.price?.id || ""),
isActive: subscription.status === "active",
},
});
}

View File

@@ -0,0 +1,22 @@
import Stripe from "stripe";
import { env } from "~/env";
import { getUsageTimestamp } from "~/lib/usage";
const METER_EVENT_NAME = "unsend_usage";
export async function sendUsageToStripe(customerId: string, usage: number) {
const stripe = new Stripe(env.STRIPE_SECRET_KEY!, {
apiVersion: "2025-01-27.acacia",
});
const meterEvent = await stripe.billing.meterEvents.create({
event_name: METER_EVENT_NAME,
payload: {
value: usage.toString(),
stripe_customer_id: customerId,
},
timestamp: getUsageTimestamp(),
});
return meterEvent;
}

View File

@@ -0,0 +1,90 @@
import { Queue, Worker } from "bullmq";
import { db } from "~/server/db";
import { env } from "~/env";
import { getUsageDate, getUsageUinits } from "~/lib/usage";
import { sendUsageToStripe } from "~/server/billing/usage";
import { getRedis } from "~/server/redis";
import { DEFAULT_QUEUE_OPTIONS } from "../queue/queue-constants";
const USAGE_QUEUE_NAME = "usage-reporting";
const usageQueue = new Queue(USAGE_QUEUE_NAME, {
connection: getRedis(),
});
// Process usage reporting jobs
const worker = new Worker(
USAGE_QUEUE_NAME,
async () => {
// Get all teams with stripe customer IDs
const teams = await db.team.findMany({
where: {
stripeCustomerId: {
not: null,
},
},
include: {
dailyEmailUsages: {
where: {
// Get yesterday's date by subtracting 1 day from today
date: {
equals: getUsageDate(),
},
},
},
},
});
// Process each team
for (const team of teams) {
if (!team.stripeCustomerId) continue;
const transactionUsage = team.dailyEmailUsages
.filter((usage) => usage.type === "TRANSACTIONAL")
.reduce((sum, usage) => sum + usage.sent, 0);
const marketingUsage = team.dailyEmailUsages
.filter((usage) => usage.type === "MARKETING")
.reduce((sum, usage) => sum + usage.sent, 0);
const totalUsage = getUsageUinits(marketingUsage, transactionUsage);
try {
await sendUsageToStripe(team.stripeCustomerId, totalUsage);
console.log(
`[Usage Reporting] Reported usage for team ${team.id}, date: ${getUsageDate()}, usage: ${totalUsage}`
);
} catch (error) {
console.error(
`[Usage Reporting] Failed to report usage for team ${team.id}:`,
error instanceof Error ? error.message : error
);
}
}
},
{
connection: getRedis(),
}
);
// Schedule job to run daily
await usageQueue.upsertJobScheduler(
"daily-usage-report",
{
pattern: "0 0 * * *", // Run every day at 12 AM
tz: "UTC",
},
{
opts: {
...DEFAULT_QUEUE_OPTIONS,
},
}
);
worker.on("completed", (job) => {
console.log(`[Usage Reporting] Job ${job.id} completed`);
});
worker.on("failed", (job, err) => {
console.error(`[Usage Reporting] Job ${job?.id} failed:`, err);
});

View File

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