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