mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-15 20:25:23 +02:00
Merge pull request #4198 from Dokploy/feat/billing-cloud-improvements
Feat/billing cloud improvements
This commit is contained in:
@@ -2,6 +2,7 @@ import { loadStripe } from "@stripe/stripe-js";
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
Bell,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
FileText,
|
FileText,
|
||||||
@@ -25,7 +26,17 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { NumberInput } from "@/components/ui/input";
|
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 { Progress } from "@/components/ui/progress";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
@@ -90,6 +101,8 @@ export const ShowBilling = () => {
|
|||||||
api.stripe.createCustomerPortalSession.useMutation();
|
api.stripe.createCustomerPortalSession.useMutation();
|
||||||
const { mutateAsync: upgradeSubscription, isPending: isUpgrading } =
|
const { mutateAsync: upgradeSubscription, isPending: isUpgrading } =
|
||||||
api.stripe.upgradeSubscription.useMutation();
|
api.stripe.upgradeSubscription.useMutation();
|
||||||
|
const { mutateAsync: updateInvoiceNotifications } =
|
||||||
|
api.stripe.updateInvoiceNotifications.useMutation();
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|
||||||
const [hobbyServerQuantity, setHobbyServerQuantity] = useState(1);
|
const [hobbyServerQuantity, setHobbyServerQuantity] = useState(1);
|
||||||
@@ -151,14 +164,68 @@ export const ShowBilling = () => {
|
|||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Card className="bg-sidebar p-2.5 rounded-xl max-w-6xl mx-auto">
|
<Card className="bg-sidebar p-2.5 rounded-xl max-w-6xl mx-auto">
|
||||||
<div className="rounded-xl bg-background shadow-md">
|
<div className="rounded-xl bg-background shadow-md">
|
||||||
<CardHeader>
|
<CardHeader className="flex flex-row items-start justify-between">
|
||||||
<CardTitle className="text-xl flex flex-row gap-2">
|
<div>
|
||||||
<CreditCard className="size-6 text-muted-foreground self-center" />
|
<CardTitle className="text-xl flex flex-row gap-2">
|
||||||
Billing
|
<CreditCard className="size-6 text-muted-foreground self-center" />
|
||||||
</CardTitle>
|
Billing
|
||||||
<CardDescription>
|
</CardTitle>
|
||||||
Manage your subscription and invoices
|
<CardDescription>
|
||||||
</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>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4 py-4 border-t">
|
<CardContent className="space-y-4 py-4 border-t">
|
||||||
<nav className="flex space-x-2 border-b">
|
<nav className="flex space-x-2 border-b">
|
||||||
|
|||||||
@@ -55,7 +55,8 @@ export const WelcomeSubscription = () => {
|
|||||||
const [showConfetti, setShowConfetti] = useState(false);
|
const [showConfetti, setShowConfetti] = useState(false);
|
||||||
const stepper = useStepper();
|
const stepper = useStepper();
|
||||||
const [isOpen, setIsOpen] = useState(true);
|
const [isOpen, setIsOpen] = useState(true);
|
||||||
const { push } = useRouter();
|
const router = useRouter();
|
||||||
|
const { push } = router;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const confettiShown = localStorage.getItem("hasShownConfetti");
|
const confettiShown = localStorage.getItem("hasShownConfetti");
|
||||||
@@ -66,7 +67,18 @@ export const WelcomeSubscription = () => {
|
|||||||
}, [showConfetti]);
|
}, [showConfetti]);
|
||||||
|
|
||||||
return (
|
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]">
|
<DialogContent className="sm:max-w-7xl min-h-[75vh]">
|
||||||
{showConfetti ?? "Flaso"}
|
{showConfetti ?? "Flaso"}
|
||||||
<div className="flex justify-center items-center w-full">
|
<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,
|
"when": 1775369858244,
|
||||||
"tag": "0164_slippery_sasquatch",
|
"tag": "0164_slippery_sasquatch",
|
||||||
"breakpoints": true
|
"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 type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
import { organization, server, user } from "@/server/db/schema";
|
import { organization, server, user } from "@/server/db/schema";
|
||||||
|
import {
|
||||||
|
sendInvoiceEmail,
|
||||||
|
sendPaymentFailedEmail,
|
||||||
|
} from "@/server/utils/stripe-notifications";
|
||||||
|
|
||||||
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
|
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
|
||||||
|
|
||||||
@@ -241,6 +245,11 @@ export default async function handler(
|
|||||||
}
|
}
|
||||||
const newServersQuantity = admin.serversQuantity;
|
const newServersQuantity = admin.serversQuantity;
|
||||||
await updateServersBasedOnQuantity(admin.id, newServersQuantity);
|
await updateServersBasedOnQuantity(admin.id, newServersQuantity);
|
||||||
|
|
||||||
|
if (admin.sendInvoiceNotifications) {
|
||||||
|
await sendInvoiceEmail(newInvoice, admin);
|
||||||
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "invoice.payment_failed": {
|
case "invoice.payment_failed": {
|
||||||
@@ -249,7 +258,6 @@ export default async function handler(
|
|||||||
const subscription = await stripe.subscriptions.retrieve(
|
const subscription = await stripe.subscriptions.retrieve(
|
||||||
newInvoice.subscription as string,
|
newInvoice.subscription as string,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (subscription.status !== "active") {
|
if (subscription.status !== "active") {
|
||||||
const admin = await findUserByStripeCustomerId(
|
const admin = await findUserByStripeCustomerId(
|
||||||
newInvoice.customer as string,
|
newInvoice.customer as string,
|
||||||
@@ -263,6 +271,10 @@ export default async function handler(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (admin.sendInvoiceNotifications) {
|
||||||
|
await sendPaymentFailedEmail(newInvoice, admin);
|
||||||
|
}
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(user)
|
.update(user)
|
||||||
.set({
|
.set({
|
||||||
|
|||||||
@@ -205,11 +205,13 @@ export const stripeRouter = createTRPCRouter({
|
|||||||
mode: "subscription",
|
mode: "subscription",
|
||||||
line_items: items,
|
line_items: items,
|
||||||
...(stripeCustomerId
|
...(stripeCustomerId
|
||||||
? { customer: stripeCustomerId }
|
? { customer: stripeCustomerId, customer_update: { name: "auto", address: "auto" } }
|
||||||
: { customer_email: owner.email }),
|
: { customer_email: owner.email }),
|
||||||
metadata: {
|
metadata: {
|
||||||
adminId: owner.id,
|
adminId: owner.id,
|
||||||
},
|
},
|
||||||
|
billing_address_collection: "required",
|
||||||
|
tax_id_collection: { enabled: true },
|
||||||
allow_promotion_codes: true,
|
allow_promotion_codes: true,
|
||||||
success_url: `${WEBSITE_URL}/dashboard/settings/servers?success=true`,
|
success_url: `${WEBSITE_URL}/dashboard/settings/servers?success=true`,
|
||||||
cancel_url: `${WEBSITE_URL}/dashboard/settings/billing`,
|
cancel_url: `${WEBSITE_URL}/dashboard/settings/billing`,
|
||||||
@@ -332,6 +334,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 }) => {
|
getInvoices: adminProcedure.query(async ({ ctx }) => {
|
||||||
const user = await findUserById(ctx.user.ownerId);
|
const user = await findUserById(ctx.user.ownerId);
|
||||||
const stripeCustomerId = user.stripeCustomerId;
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -65,6 +65,9 @@ export const user = pgTable("user", {
|
|||||||
stripeCustomerId: text("stripeCustomerId"),
|
stripeCustomerId: text("stripeCustomerId"),
|
||||||
stripeSubscriptionId: text("stripeSubscriptionId"),
|
stripeSubscriptionId: text("stripeSubscriptionId"),
|
||||||
serversQuantity: integer("serversQuantity").notNull().default(0),
|
serversQuantity: integer("serversQuantity").notNull().default(0),
|
||||||
|
sendInvoiceNotifications: boolean("sendInvoiceNotifications")
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
isEnterpriseCloud: boolean("isEnterpriseCloud").notNull().default(false),
|
isEnterpriseCloud: boolean("isEnterpriseCloud").notNull().default(false),
|
||||||
trustedOrigins: text("trustedOrigins").array(),
|
trustedOrigins: text("trustedOrigins").array(),
|
||||||
bookmarkedTemplates: text("bookmarkedTemplates")
|
bookmarkedTemplates: text("bookmarkedTemplates")
|
||||||
|
|||||||
171
packages/server/src/emails/emails/invoice-notification.tsx
Normal file
171
packages/server/src/emails/emails/invoice-notification.tsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Button,
|
||||||
|
Column,
|
||||||
|
Container,
|
||||||
|
Head,
|
||||||
|
Heading,
|
||||||
|
Hr,
|
||||||
|
Html,
|
||||||
|
Img,
|
||||||
|
Link,
|
||||||
|
Preview,
|
||||||
|
Row,
|
||||||
|
Section,
|
||||||
|
Tailwind,
|
||||||
|
Text,
|
||||||
|
} from "@react-email/components";
|
||||||
|
|
||||||
|
export type TemplateProps = {
|
||||||
|
userName: string;
|
||||||
|
invoiceNumber: string;
|
||||||
|
amountPaid: string;
|
||||||
|
currency: string;
|
||||||
|
date: string;
|
||||||
|
hostedInvoiceUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const InvoiceNotificationEmail = ({
|
||||||
|
userName = "User",
|
||||||
|
invoiceNumber = "INV-0001",
|
||||||
|
amountPaid = "$4.50",
|
||||||
|
currency = "usd",
|
||||||
|
date = "2024-01-01",
|
||||||
|
hostedInvoiceUrl = "https://invoice.stripe.com/example",
|
||||||
|
}: TemplateProps) => {
|
||||||
|
const previewText = `Your Dokploy invoice ${invoiceNumber} for ${amountPaid} is ready`;
|
||||||
|
return (
|
||||||
|
<Html>
|
||||||
|
<Head />
|
||||||
|
<Preview>{previewText}</Preview>
|
||||||
|
<Tailwind
|
||||||
|
config={{
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
brand: "#007291",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Body className="bg-[#f4f4f5] my-auto mx-auto font-sans">
|
||||||
|
<Container className="my-[40px] mx-auto max-w-[520px]">
|
||||||
|
{/* Header */}
|
||||||
|
<Section className="bg-[#09090b] rounded-t-xl px-[40px] py-[32px] text-center">
|
||||||
|
<Img
|
||||||
|
src="https://raw.githubusercontent.com/Dokploy/website/refs/heads/main/apps/docs/public/logo-dokploy-blackpng.png"
|
||||||
|
width="190"
|
||||||
|
height="120"
|
||||||
|
alt="Dokploy"
|
||||||
|
className="my-0 mx-auto"
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<Section className="bg-white px-[40px] py-[32px]">
|
||||||
|
<Heading className="text-[#09090b] text-[22px] font-semibold m-0 mb-[8px]">
|
||||||
|
Invoice Payment Confirmed
|
||||||
|
</Heading>
|
||||||
|
<Text className="text-[#71717a] text-[14px] leading-[22px] m-0 mb-[24px]">
|
||||||
|
Hello {userName}, thank you for your payment. Here's a summary
|
||||||
|
of your invoice.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Invoice Details Card */}
|
||||||
|
<Section className="border border-solid border-[#e4e4e7] rounded-lg overflow-hidden mb-[24px]">
|
||||||
|
<Row className="bg-[#fafafa]">
|
||||||
|
<Column className="px-[20px] py-[14px] w-[50%]">
|
||||||
|
<Text className="text-[#71717a] text-[12px] font-medium uppercase tracking-wider m-0">
|
||||||
|
Invoice No.
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[#09090b] text-[14px] font-semibold m-0 mt-[4px]">
|
||||||
|
{invoiceNumber}
|
||||||
|
</Text>
|
||||||
|
</Column>
|
||||||
|
<Column className="px-[20px] py-[14px] w-[50%]">
|
||||||
|
<Text className="text-[#71717a] text-[12px] font-medium uppercase tracking-wider m-0">
|
||||||
|
Date
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[#09090b] text-[14px] font-semibold m-0 mt-[4px]">
|
||||||
|
{date}
|
||||||
|
</Text>
|
||||||
|
</Column>
|
||||||
|
</Row>
|
||||||
|
<Hr className="border-[#e4e4e7] m-0" />
|
||||||
|
<Row>
|
||||||
|
<Column className="px-[20px] py-[14px]">
|
||||||
|
<Text className="text-[#71717a] text-[12px] font-medium uppercase tracking-wider m-0">
|
||||||
|
Amount Paid
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[#09090b] text-[20px] font-bold m-0 mt-[4px]">
|
||||||
|
{amountPaid}{" "}
|
||||||
|
<span className="text-[#71717a] text-[12px] font-normal uppercase">
|
||||||
|
{currency}
|
||||||
|
</span>
|
||||||
|
</Text>
|
||||||
|
</Column>
|
||||||
|
</Row>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Status Badge */}
|
||||||
|
<Section className="mb-[24px]">
|
||||||
|
<Row>
|
||||||
|
<Column>
|
||||||
|
<div
|
||||||
|
className="inline-block rounded-full bg-[#dcfce7] px-[12px] py-[6px]"
|
||||||
|
style={{ display: "inline-block" }}
|
||||||
|
>
|
||||||
|
<Text className="text-[#15803d] text-[12px] font-semibold m-0">
|
||||||
|
Payment Successful
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Column>
|
||||||
|
</Row>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* CTA Button */}
|
||||||
|
<Section className="text-center mb-[24px]">
|
||||||
|
<Button
|
||||||
|
href={hostedInvoiceUrl}
|
||||||
|
className="bg-[#09090b] rounded-lg text-white text-[14px] font-semibold no-underline text-center px-[24px] py-[12px]"
|
||||||
|
>
|
||||||
|
View Invoice Online
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Text className="text-[#a1a1aa] text-[13px] leading-[20px] m-0 text-center">
|
||||||
|
A PDF copy of this invoice is attached to this email for your
|
||||||
|
records.
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<Section className="bg-[#fafafa] rounded-b-xl px-[40px] py-[24px] text-center border-t border-solid border-[#e4e4e7]">
|
||||||
|
<Text className="text-[#a1a1aa] text-[12px] leading-[18px] m-0">
|
||||||
|
This is an automated email from{" "}
|
||||||
|
<Link
|
||||||
|
href="https://dokploy.com"
|
||||||
|
className="text-[#71717a] underline"
|
||||||
|
>
|
||||||
|
Dokploy Cloud
|
||||||
|
</Link>
|
||||||
|
. If you have any questions about your billing, please contact
|
||||||
|
our{" "}
|
||||||
|
<Link
|
||||||
|
href="https://discord.gg/2tBnJ3jDJc"
|
||||||
|
className="text-[#71717a] underline"
|
||||||
|
>
|
||||||
|
support team
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Tailwind>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InvoiceNotificationEmail;
|
||||||
175
packages/server/src/emails/emails/payment-failed.tsx
Normal file
175
packages/server/src/emails/emails/payment-failed.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Button,
|
||||||
|
Column,
|
||||||
|
Container,
|
||||||
|
Head,
|
||||||
|
Heading,
|
||||||
|
Hr,
|
||||||
|
Html,
|
||||||
|
Img,
|
||||||
|
Link,
|
||||||
|
Preview,
|
||||||
|
Row,
|
||||||
|
Section,
|
||||||
|
Tailwind,
|
||||||
|
Text,
|
||||||
|
} from "@react-email/components";
|
||||||
|
|
||||||
|
export type TemplateProps = {
|
||||||
|
userName: string;
|
||||||
|
invoiceNumber: string;
|
||||||
|
amountDue: string;
|
||||||
|
currency: string;
|
||||||
|
date: string;
|
||||||
|
hostedInvoiceUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PaymentFailedEmail = ({
|
||||||
|
userName = "User",
|
||||||
|
invoiceNumber = "INV-0001",
|
||||||
|
amountDue = "$4.50",
|
||||||
|
currency = "usd",
|
||||||
|
date = "2024-01-01",
|
||||||
|
hostedInvoiceUrl = "https://invoice.stripe.com/example",
|
||||||
|
}: TemplateProps) => {
|
||||||
|
const previewText = `Action required: Your Dokploy payment for ${amountDue} failed`;
|
||||||
|
return (
|
||||||
|
<Html>
|
||||||
|
<Head />
|
||||||
|
<Preview>{previewText}</Preview>
|
||||||
|
<Tailwind
|
||||||
|
config={{
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
brand: "#007291",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Body className="bg-[#f4f4f5] my-auto mx-auto font-sans">
|
||||||
|
<Container className="my-[40px] mx-auto max-w-[520px]">
|
||||||
|
{/* Header */}
|
||||||
|
<Section className="bg-[#09090b] rounded-t-xl px-[40px] py-[32px] text-center">
|
||||||
|
<Img
|
||||||
|
src="https://raw.githubusercontent.com/Dokploy/website/refs/heads/main/apps/docs/public/logo-dokploy-blackpng.png"
|
||||||
|
width="190"
|
||||||
|
height="120"
|
||||||
|
alt="Dokploy"
|
||||||
|
className="my-0 mx-auto"
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<Section className="bg-white px-[40px] py-[32px]">
|
||||||
|
<Heading className="text-[#09090b] text-[22px] font-semibold m-0 mb-[8px]">
|
||||||
|
Payment Failed
|
||||||
|
</Heading>
|
||||||
|
<Text className="text-[#71717a] text-[14px] leading-[22px] m-0 mb-[24px]">
|
||||||
|
Hello {userName}, we were unable to process your payment. Please
|
||||||
|
update your payment method to avoid service interruption.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Invoice Details Card */}
|
||||||
|
<Section className="border border-solid border-[#e4e4e7] rounded-lg overflow-hidden mb-[24px]">
|
||||||
|
<Row className="bg-[#fafafa]">
|
||||||
|
<Column className="px-[20px] py-[14px] w-[50%]">
|
||||||
|
<Text className="text-[#71717a] text-[12px] font-medium uppercase tracking-wider m-0">
|
||||||
|
Invoice No.
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[#09090b] text-[14px] font-semibold m-0 mt-[4px]">
|
||||||
|
{invoiceNumber}
|
||||||
|
</Text>
|
||||||
|
</Column>
|
||||||
|
<Column className="px-[20px] py-[14px] w-[50%]">
|
||||||
|
<Text className="text-[#71717a] text-[12px] font-medium uppercase tracking-wider m-0">
|
||||||
|
Date
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[#09090b] text-[14px] font-semibold m-0 mt-[4px]">
|
||||||
|
{date}
|
||||||
|
</Text>
|
||||||
|
</Column>
|
||||||
|
</Row>
|
||||||
|
<Hr className="border-[#e4e4e7] m-0" />
|
||||||
|
<Row>
|
||||||
|
<Column className="px-[20px] py-[14px]">
|
||||||
|
<Text className="text-[#71717a] text-[12px] font-medium uppercase tracking-wider m-0">
|
||||||
|
Amount Due
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[#09090b] text-[20px] font-bold m-0 mt-[4px]">
|
||||||
|
{amountDue}{" "}
|
||||||
|
<span className="text-[#71717a] text-[12px] font-normal uppercase">
|
||||||
|
{currency}
|
||||||
|
</span>
|
||||||
|
</Text>
|
||||||
|
</Column>
|
||||||
|
</Row>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Status Badge */}
|
||||||
|
<Section className="mb-[24px]">
|
||||||
|
<Row>
|
||||||
|
<Column>
|
||||||
|
<div
|
||||||
|
className="inline-block rounded-full bg-[#fee2e2] px-[12px] py-[6px]"
|
||||||
|
style={{ display: "inline-block" }}
|
||||||
|
>
|
||||||
|
<Text className="text-[#dc2626] text-[12px] font-semibold m-0">
|
||||||
|
Payment Failed
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Column>
|
||||||
|
</Row>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Warning */}
|
||||||
|
<Section className="bg-[#fefce8] border border-solid border-[#fef08a] rounded-lg px-[20px] py-[16px] mb-[24px]">
|
||||||
|
<Text className="text-[#854d0e] text-[13px] leading-[20px] m-0">
|
||||||
|
If the payment issue is not resolved, your servers will be
|
||||||
|
deactivated. Please update your payment method as soon as
|
||||||
|
possible.
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* CTA Button */}
|
||||||
|
<Section className="text-center mb-[24px]">
|
||||||
|
<Button
|
||||||
|
href={hostedInvoiceUrl}
|
||||||
|
className="bg-[#dc2626] rounded-lg text-white text-[14px] font-semibold no-underline text-center px-[24px] py-[12px]"
|
||||||
|
>
|
||||||
|
Update Payment Method
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<Section className="bg-[#fafafa] rounded-b-xl px-[40px] py-[24px] text-center border-t border-solid border-[#e4e4e7]">
|
||||||
|
<Text className="text-[#a1a1aa] text-[12px] leading-[18px] m-0">
|
||||||
|
This is an automated email from{" "}
|
||||||
|
<Link
|
||||||
|
href="https://dokploy.com"
|
||||||
|
className="text-[#71717a] underline"
|
||||||
|
>
|
||||||
|
Dokploy Cloud
|
||||||
|
</Link>
|
||||||
|
. If you have any questions about your billing, please contact
|
||||||
|
our{" "}
|
||||||
|
<Link
|
||||||
|
href="https://discord.gg/2tBnJ3jDJc"
|
||||||
|
className="text-[#71717a] underline"
|
||||||
|
>
|
||||||
|
support team
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Tailwind>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PaymentFailedEmail;
|
||||||
@@ -19,6 +19,7 @@ export const sendEmailNotification = async (
|
|||||||
connection: typeof email.$inferInsert,
|
connection: typeof email.$inferInsert,
|
||||||
subject: string,
|
subject: string,
|
||||||
htmlContent: string,
|
htmlContent: string,
|
||||||
|
attachments?: { filename: string; content: Buffer }[],
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
@@ -41,6 +42,7 @@ export const sendEmailNotification = async (
|
|||||||
subject,
|
subject,
|
||||||
html: htmlContent,
|
html: htmlContent,
|
||||||
textEncoding: "base64",
|
textEncoding: "base64",
|
||||||
|
attachments,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err);
|
console.log(err);
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ export const sendEmail = async ({
|
|||||||
email,
|
email,
|
||||||
subject,
|
subject,
|
||||||
text,
|
text,
|
||||||
|
attachments,
|
||||||
}: {
|
}: {
|
||||||
email: string;
|
email: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
text: string;
|
text: string;
|
||||||
|
attachments?: { filename: string; content: Buffer }[];
|
||||||
}) => {
|
}) => {
|
||||||
await sendEmailNotification(
|
await sendEmailNotification(
|
||||||
{
|
{
|
||||||
@@ -19,6 +21,7 @@ export const sendEmail = async ({
|
|||||||
},
|
},
|
||||||
subject,
|
subject,
|
||||||
text,
|
text,
|
||||||
|
attachments,
|
||||||
);
|
);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
Reference in New Issue
Block a user