feat(licenses): improve development setup and refactor email handling

- Updated development script in package.json to load environment variables from dotenv.
- Refactored email imports to use named exports for better clarity.
- Removed unused database expiration logic and adjusted license validation to rely on Stripe subscription status.
- Enhanced error logging in webhook processing and improved console output for server status.
- Updated SMTP configuration to use more descriptive environment variable names.
- Removed the expiresAt field from the database schema to streamline license management.
This commit is contained in:
Mauricio Siu
2025-03-23 12:15:25 -06:00
parent b7874f053f
commit 9e30525569
7 changed files with 83 additions and 87 deletions

View File

@@ -3,7 +3,7 @@
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "PORT=4002 tsx watch src/index.ts",
"dev": "PORT=4002 tsx watch -r dotenv/config src/index.ts",
"build": "tsc --project tsconfig.json",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit",

View File

@@ -1,13 +1,3 @@
// import { drizzle } from "drizzle-orm/node-postgres";
// import { Pool } from "pg";
// import * as schema from "./schema";
// const pool = new Pool({
// connectionString: process.env.DATABASE_URL,
// });
// export const db = drizzle(pool, { schema });
import { type PostgresJsDatabase, drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";

View File

@@ -1,11 +1,10 @@
import { createTransport } from "nodemailer";
export const transporter = createTransport({
host: process.env.SMTP_HOST,
host: process.env.SMTP_SERVER,
port: Number(process.env.SMTP_PORT),
secure: true,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
user: process.env.SMTP_USERNAME,
pass: process.env.SMTP_PASSWORD,
},
});

View File

@@ -5,8 +5,8 @@ import { z } from "zod";
import { zValidator } from "@hono/zod-validator";
import { logger } from "./logger";
import { render } from "@react-email/render";
import LicenseEmail from "../templates/emails/license-email";
import ResendLicenseEmail from "../templates/emails/resend-license-email";
import { LicenseEmail } from "../templates/emails/license-email";
import { ResendLicenseEmail } from "../templates/emails/resend-license-email";
import {
createLicense,
validateLicense,
@@ -34,6 +34,8 @@ router.use(
}),
);
console.log(process.env.DATABASE_URL);
const validateSchema = z.object({
licenseKey: z.string(),
serverIp: z.string(),
@@ -117,14 +119,20 @@ router.post("/resend-license", zValidator("json", resendSchema), async (c) => {
ResendLicenseEmail({
licenseKey: license.licenseKey,
productName: `Dokploy Self Hosted ${license.type}`,
expirationDate: new Date(license.expiresAt),
// TODO: Add expiration date
expirationDate: new Date(),
requestDate: new Date(),
customerName: license.email,
}),
);
// await transporter.sendMail({
// from: fromAddress,
// to: toAddresses.join(", "),
// subject,
// html: htmlContent,
// });
await transporter.sendMail({
from: process.env.SMTP_FROM,
from: process.env.SMTP_FROM_ADDRESS,
to: license.email,
subject: "Your Dokploy License Key",
html: emailHtml,
@@ -136,16 +144,15 @@ router.post("/resend-license", zValidator("json", resendSchema), async (c) => {
return c.json({ success: false, error: "Error resending license" }, 500);
}
});
router.post("/stripe/webhook", async (c) => {
const rawBody = await c.req.raw.text();
const sig = c.req.header("stripe-signature");
const body = await c.req.json();
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
JSON.stringify(body),
rawBody,
sig!,
process.env.STRIPE_WEBHOOK_SECRET!,
);
@@ -154,6 +161,18 @@ router.post("/stripe/webhook", async (c) => {
return c.json({ error: "Webhook signature verification failed" }, 400);
}
const allowedEvents = [
"checkout.session.completed",
"customer.subscription.updated",
"invoice.payment_succeeded",
"invoice.payment_failed",
"customer.subscription.deleted",
];
if (!allowedEvents.includes(event.type)) {
return c.json({ error: "Event not allowed" }, 400);
}
try {
switch (event.type) {
case "checkout.session.completed": {
@@ -183,19 +202,23 @@ router.post("/stripe/webhook", async (c) => {
stripeSubscriptionId: session.id,
});
console.log("License created", license);
const features = getLicenseFeatures(type);
console.log("Features", features);
const emailHtml = await render(
LicenseEmail({
customerName: customerResponse.name || "Customer",
licenseKey: license.licenseKey,
productName: `Dokploy Self Hosted ${type}`,
expirationDate: new Date(license.expiresAt),
// TODO: Add expiration date
expirationDate: new Date(),
features: features,
}),
);
await transporter.sendMail({
from: process.env.SMTP_FROM,
from: process.env.SMTP_FROM_ADDRESS,
to: license.email,
subject: "Your Dokploy License Key",
html: emailHtml,
@@ -230,7 +253,10 @@ router.post("/stripe/webhook", async (c) => {
const customerResponse = await stripe.customers.retrieve(
invoice.customer as string,
);
if (suscription.status !== "active" || customerResponse.deleted) break;
if (suscription.status !== "active" || customerResponse.deleted) {
await deactivateLicense(invoice.subscription as string);
break;
}
const existingLicense = await db.query.licenses.findFirst({
where: eq(licenses.stripeCustomerId, invoice.customer as string),
@@ -238,38 +264,13 @@ router.post("/stripe/webhook", async (c) => {
if (!existingLicense) break;
const newExpirationDate = new Date();
newExpirationDate.setMonth(
newExpirationDate.getMonth() +
(existingLicense.billingType === "annual" ? 12 : 1),
);
await db
.update(licenses)
.set({
expiresAt: newExpirationDate,
status: "active",
})
.where(eq(licenses.id, existingLicense.id));
const features = getLicenseFeatures(existingLicense.type);
const emailHtml = await render(
LicenseEmail({
customerName: customerResponse.name || "Customer",
licenseKey: existingLicense.licenseKey,
productName: `Dokploy Self Hosted ${existingLicense.type}`,
expirationDate: new Date(newExpirationDate),
features: features,
}),
);
await transporter.sendMail({
from: process.env.SMTP_FROM,
to: existingLicense.email,
subject: "Your Dokploy License Has Been Renewed",
html: emailHtml,
});
break;
}
@@ -311,7 +312,7 @@ router.post("/stripe/webhook", async (c) => {
return c.json({ received: true });
} catch (error) {
logger.error("Error processing webhook:", error);
console.error("Error processing webhook:", error);
if (error instanceof Error) {
return c.json({ error: error.message }, 500);
}
@@ -321,7 +322,7 @@ router.post("/stripe/webhook", async (c) => {
app.route("/api", router);
const port = process.env.PORT || 4002;
console.log(`Server is running on port ${port}`);
console.log(`Server is running on port http://localhost:${port}`);
serve({
fetch: app.fetch,

View File

@@ -26,7 +26,6 @@ export const licenses = pgTable("licenses", {
serverIp: text("server_ip"),
activatedAt: timestamp("activated_at"),
lastVerifiedAt: timestamp("last_verified_at"),
expiresAt: timestamp("expires_at").notNull(),
stripeCustomerId: text("stripeCustomerId").notNull(),
stripeSubscriptionId: text("stripeSubscriptionId").notNull(),
createdAt: timestamp("created_at").default(sql`CURRENT_TIMESTAMP`),

View File

@@ -1,7 +1,9 @@
import { randomBytes } from "node:crypto";
import { db } from "../db";
import { type License, licenses } from "../schema";
import { licenses } from "../schema";
import { eq } from "drizzle-orm";
import { stripe } from "../stripe";
import type Stripe from "stripe";
export const generateLicenseKey = () => {
return randomBytes(32).toString("hex");
@@ -25,10 +27,6 @@ export const createLicense = async ({
stripeSubscriptionId,
}: CreateLicenseProps) => {
const licenseKey = `dokploy-${generateLicenseKey()}`;
const expiresAt = new Date();
expiresAt.setMonth(
expiresAt.getMonth() + (billingType === "annual" ? 12 : 1),
);
const license = await db
.insert(licenses)
@@ -37,7 +35,6 @@ export const createLicense = async ({
licenseKey,
type,
billingType,
expiresAt,
email,
stripeCustomerId,
stripeSubscriptionId,
@@ -59,26 +56,21 @@ export const validateLicense = async (
return { isValid: false, error: "License not found" };
}
if (license.status !== "active") {
const suscription = await stripe.subscriptions.retrieve(
license.stripeSubscriptionId,
);
if (suscription.status !== "active") {
return {
isValid: false,
error: `License is ${getLicenseStatus(license)}`,
error: `License is ${getLicenseStatus(suscription)}`,
};
}
if (new Date() > license.expiresAt) {
await db
.update(licenses)
.set({ status: "expired" })
.where(eq(licenses.id, license.id));
return { isValid: false, error: "License has expired" };
}
if (license.serverIp && serverIp && license.serverIp !== serverIp) {
return { isValid: false, error: "Invalid server IP" };
}
// Update last verified timestamp
await db
.update(licenses)
.set({ lastVerifiedAt: new Date() })
@@ -96,16 +88,12 @@ export const activateLicense = async (licenseKey: string, serverIp: string) => {
throw new Error("License not found");
}
if (license.status !== "active") {
throw new Error("License is not active");
}
const suscription = await stripe.subscriptions.retrieve(
license.stripeSubscriptionId,
);
if (new Date() > license.expiresAt) {
await db
.update(licenses)
.set({ status: "expired" })
.where(eq(licenses.id, license.id));
throw new Error("License has expired");
if (suscription.status !== "active") {
throw new Error(`License is ${getLicenseStatus(suscription)}`);
}
if (license.serverIp && license.serverIp !== serverIp) {
@@ -141,22 +129,40 @@ export const deactivateLicense = async (stripeSubscriptionId: string) => {
.where(eq(licenses.id, license.id));
};
export const getLicenseStatus = (license: License) => {
export const getLicenseStatus = async (license: Stripe.Subscription) => {
if (license.status === "active") {
return "active";
}
if (license.status === "expired") {
return "expired";
if (license.status === "canceled") {
return "canceled";
}
if (license.status === "cancelled") {
return "cancelled";
if (license.status === "incomplete") {
return "incomplete";
}
if (license.status === "payment_pending") {
return "pending payment";
if (license.status === "incomplete_expired") {
return "incomplete expired";
}
if (license.status === "past_due") {
return "past due";
}
if (license.status === "paused") {
return "paused";
}
if (license.status === "trialing") {
return "trialing";
}
if (license.status === "unpaid") {
return "unpaid";
}
return "unknown";
};
export const getStripeItems = (

View File

@@ -2,6 +2,7 @@
"name": "react-email-starter",
"version": "0.1.10",
"private": true,
"type": "module",
"scripts": {
"build": "email build",
"dev": "email dev",