mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-15 20:25:23 +02:00
feat: add invoice notification settings and email notifications for payments
- Introduced a new feature allowing users to enable or disable invoice email notifications in the billing settings. - Implemented email notifications for successful invoice payments and payment failures, enhancing user communication regarding billing. - Updated the database schema to include a new column for storing user preferences on invoice notifications. - Added corresponding email templates for invoice notifications and payment failure alerts. These changes improve user experience by keeping users informed about their billing status and actions required.
This commit is contained in:
@@ -2,6 +2,7 @@ import { loadStripe } from "@stripe/stripe-js";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bell,
|
||||
CheckIcon,
|
||||
CreditCard,
|
||||
FileText,
|
||||
@@ -25,7 +26,17 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { NumberInput } from "@/components/ui/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
@@ -90,6 +101,8 @@ export const ShowBilling = () => {
|
||||
api.stripe.createCustomerPortalSession.useMutation();
|
||||
const { mutateAsync: upgradeSubscription, isPending: isUpgrading } =
|
||||
api.stripe.upgradeSubscription.useMutation();
|
||||
const { mutateAsync: updateInvoiceNotifications } =
|
||||
api.stripe.updateInvoiceNotifications.useMutation();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const [hobbyServerQuantity, setHobbyServerQuantity] = useState(1);
|
||||
@@ -151,14 +164,68 @@ export const ShowBilling = () => {
|
||||
<div className="w-full">
|
||||
<Card className="bg-sidebar p-2.5 rounded-xl max-w-6xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<CreditCard className="size-6 text-muted-foreground self-center" />
|
||||
Billing
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your subscription and invoices
|
||||
</CardDescription>
|
||||
<CardHeader className="flex flex-row items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<CreditCard className="size-6 text-muted-foreground self-center" />
|
||||
Billing
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your subscription and invoices
|
||||
</CardDescription>
|
||||
</div>
|
||||
{(admin?.user.stripeSubscriptionId || isEnterpriseCloud) && (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<Bell className="size-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Notification Settings</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure your billing email notifications.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="invoice-notifications">
|
||||
Invoice Notifications
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Receive email notifications for payments and failed
|
||||
charges.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="invoice-notifications"
|
||||
checked={
|
||||
admin?.user.sendInvoiceNotifications ?? false
|
||||
}
|
||||
onCheckedChange={async (checked) => {
|
||||
await updateInvoiceNotifications({
|
||||
enabled: checked,
|
||||
})
|
||||
.then(() => {
|
||||
utils.user.get.invalidate();
|
||||
toast.success(
|
||||
checked
|
||||
? "Invoice notifications enabled"
|
||||
: "Invoice notifications disabled",
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Failed to update invoice notifications",
|
||||
);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 py-4 border-t">
|
||||
<nav className="flex space-x-2 border-b">
|
||||
|
||||
@@ -55,7 +55,8 @@ export const WelcomeSubscription = () => {
|
||||
const [showConfetti, setShowConfetti] = useState(false);
|
||||
const stepper = useStepper();
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const { push } = useRouter();
|
||||
const router = useRouter();
|
||||
const { push } = router;
|
||||
|
||||
useEffect(() => {
|
||||
const confettiShown = localStorage.getItem("hasShownConfetti");
|
||||
@@ -66,7 +67,18 @@ export const WelcomeSubscription = () => {
|
||||
}, [showConfetti]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen}>
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsOpen(open);
|
||||
if (!open) {
|
||||
const { success, ...rest } = router.query;
|
||||
router.replace({ pathname: router.pathname, query: rest }, undefined, {
|
||||
shallow: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-7xl min-h-[75vh]">
|
||||
{showConfetti ?? "Flaso"}
|
||||
<div className="flex justify-center items-center w-full">
|
||||
|
||||
1
apps/dokploy/drizzle/0165_abnormal_greymalkin.sql
Normal file
1
apps/dokploy/drizzle/0165_abnormal_greymalkin.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "user" ADD COLUMN "sendInvoiceNotifications" boolean DEFAULT false NOT NULL;
|
||||
8312
apps/dokploy/drizzle/meta/0165_snapshot.json
Normal file
8312
apps/dokploy/drizzle/meta/0165_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1156,6 +1156,13 @@
|
||||
"when": 1775369858244,
|
||||
"tag": "0164_slippery_sasquatch",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 165,
|
||||
"version": "7",
|
||||
"when": 1775845419261,
|
||||
"tag": "0165_abnormal_greymalkin",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -5,6 +5,10 @@ import { and, asc, eq } from "drizzle-orm";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import Stripe from "stripe";
|
||||
import { organization, server, user } from "@/server/db/schema";
|
||||
import {
|
||||
sendInvoiceEmail,
|
||||
sendPaymentFailedEmail,
|
||||
} from "@/server/utils/stripe-notifications";
|
||||
|
||||
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
|
||||
|
||||
@@ -241,6 +245,11 @@ export default async function handler(
|
||||
}
|
||||
const newServersQuantity = admin.serversQuantity;
|
||||
await updateServersBasedOnQuantity(admin.id, newServersQuantity);
|
||||
|
||||
if (admin.sendInvoiceNotifications) {
|
||||
await sendInvoiceEmail(newInvoice, admin);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "invoice.payment_failed": {
|
||||
@@ -249,7 +258,6 @@ export default async function handler(
|
||||
const subscription = await stripe.subscriptions.retrieve(
|
||||
newInvoice.subscription as string,
|
||||
);
|
||||
|
||||
if (subscription.status !== "active") {
|
||||
const admin = await findUserByStripeCustomerId(
|
||||
newInvoice.customer as string,
|
||||
@@ -263,6 +271,10 @@ export default async function handler(
|
||||
break;
|
||||
}
|
||||
|
||||
if (admin.sendInvoiceNotifications) {
|
||||
await sendPaymentFailedEmail(newInvoice, admin);
|
||||
}
|
||||
|
||||
await db
|
||||
.update(user)
|
||||
.set({
|
||||
|
||||
@@ -332,6 +332,22 @@ export const stripeRouter = createTRPCRouter({
|
||||
},
|
||||
),
|
||||
|
||||
updateInvoiceNotifications: adminProcedure
|
||||
.input(z.object({ enabled: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (!IS_CLOUD) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "This feature is only available in Dokploy Cloud",
|
||||
});
|
||||
}
|
||||
const owner = await findUserById(ctx.user.ownerId);
|
||||
await updateUser(owner.id, {
|
||||
sendInvoiceNotifications: input.enabled,
|
||||
});
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
getInvoices: adminProcedure.query(async ({ ctx }) => {
|
||||
const user = await findUserById(ctx.user.ownerId);
|
||||
const stripeCustomerId = user.stripeCustomerId;
|
||||
|
||||
119
apps/dokploy/server/utils/stripe-notifications.ts
Normal file
119
apps/dokploy/server/utils/stripe-notifications.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import InvoiceNotificationEmail from "@dokploy/server/emails/emails/invoice-notification";
|
||||
import PaymentFailedEmail from "@dokploy/server/emails/emails/payment-failed";
|
||||
import { sendEmail } from "@dokploy/server/verification/send-verification-email";
|
||||
import { renderAsync } from "@react-email/components";
|
||||
import { format } from "date-fns";
|
||||
import type Stripe from "stripe";
|
||||
|
||||
function formatAmount(amountInCents: number, currency: string): string {
|
||||
const amount = amountInCents / 100;
|
||||
const formatter = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: currency.toUpperCase(),
|
||||
});
|
||||
return formatter.format(amount);
|
||||
}
|
||||
|
||||
const downloadPdf = async (url: string): Promise<Buffer | null> => {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) return null;
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
return Buffer.from(arrayBuffer);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const sendInvoiceEmail = async (
|
||||
invoice: Stripe.Invoice,
|
||||
admin: { email: string; firstName: string },
|
||||
) => {
|
||||
if (!invoice.hosted_invoice_url) return;
|
||||
|
||||
try {
|
||||
const amountFormatted = formatAmount(
|
||||
invoice.amount_paid,
|
||||
invoice.currency,
|
||||
);
|
||||
|
||||
const htmlContent = await renderAsync(
|
||||
InvoiceNotificationEmail({
|
||||
userName: admin.firstName || "User",
|
||||
invoiceNumber: invoice.number || invoice.id,
|
||||
amountPaid: amountFormatted,
|
||||
currency: invoice.currency,
|
||||
date: format(new Date(invoice.created * 1000), "MMM dd, yyyy"),
|
||||
hostedInvoiceUrl: invoice.hosted_invoice_url,
|
||||
}),
|
||||
);
|
||||
|
||||
const attachments: { filename: string; content: Buffer }[] = [];
|
||||
|
||||
if (invoice.invoice_pdf) {
|
||||
const pdfBuffer = await downloadPdf(invoice.invoice_pdf);
|
||||
if (pdfBuffer) {
|
||||
attachments.push({
|
||||
filename: `dokploy-invoice-${invoice.number || invoice.id}.pdf`,
|
||||
content: pdfBuffer,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await sendEmail({
|
||||
email: admin.email,
|
||||
subject: `Dokploy Invoice ${invoice.number || ""} - ${amountFormatted}`,
|
||||
text: htmlContent,
|
||||
attachments,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Invoice email sent to ${admin.email} for invoice ${invoice.number}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to send invoice email to ${admin.email}:`,
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const sendPaymentFailedEmail = async (
|
||||
invoice: Stripe.Invoice,
|
||||
admin: { email: string; firstName: string },
|
||||
) => {
|
||||
if (!invoice.hosted_invoice_url) return;
|
||||
|
||||
try {
|
||||
const amountFormatted = formatAmount(
|
||||
invoice.amount_due,
|
||||
invoice.currency,
|
||||
);
|
||||
|
||||
const htmlContent = await renderAsync(
|
||||
PaymentFailedEmail({
|
||||
userName: admin.firstName || "User",
|
||||
invoiceNumber: invoice.number || invoice.id,
|
||||
amountDue: amountFormatted,
|
||||
currency: invoice.currency,
|
||||
date: format(new Date(invoice.created * 1000), "MMM dd, yyyy"),
|
||||
hostedInvoiceUrl: invoice.hosted_invoice_url,
|
||||
}),
|
||||
);
|
||||
|
||||
await sendEmail({
|
||||
email: admin.email,
|
||||
subject: `Action required: Dokploy payment failed - ${amountFormatted}`,
|
||||
text: htmlContent,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Payment failed email sent to ${admin.email} for invoice ${invoice.number}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to send payment failed email to ${admin.email}:`,
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user