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", "version": "0.0.1",
"type": "module", "type": "module",
"scripts": { "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", "build": "tsc --project tsconfig.json",
"start": "node dist/index.js", "start": "node dist/index.js",
"typecheck": "tsc --noEmit", "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 { type PostgresJsDatabase, drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres"; import postgres from "postgres";
import * as schema from "./schema"; import * as schema from "./schema";

View File

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

View File

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

View File

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

View File

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

View File

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