feat(licenses): update database schema and enhance license management

- Refactored migration script to import schema from the correct path.
- Updated package.json scripts for improved execution of migration and truncation tasks.
- Added new SQL file to define license-related types and table structure.
- Enhanced license management with new fields for Stripe customer and subscription IDs.
- Implemented license deactivation logic and improved error handling in license validation.
- Introduced health check endpoint for database connectivity verification.
This commit is contained in:
Mauricio Siu
2025-03-19 01:31:38 -06:00
parent 473d729416
commit 78682fa359
8 changed files with 199 additions and 34 deletions

View File

@@ -11,9 +11,10 @@ import {
createLicense,
validateLicense,
activateLicense,
deactivateLicense,
} from "./utils/license";
import { db } from "./db";
import { eq } from "drizzle-orm";
import { eq, sql } from "drizzle-orm";
import { licenses } from "./schema";
import "dotenv/config";
import { getLicenseFeatures, getLicenseTypeFromPriceId } from "./utils";
@@ -33,6 +34,16 @@ const resendSchema = z.object({
licenseKey: z.string(),
});
app.get("/health", async (c) => {
try {
await db.execute(sql`SELECT 1`);
return c.json({ status: "ok" });
} catch (error) {
logger.error("Database connection error:", error);
return c.json({ status: "error" }, 500);
}
});
app.post("/validate", zValidator("json", validateSchema), async (c) => {
const { licenseKey, serverIp } = c.req.valid("json");
@@ -72,18 +83,16 @@ app.post("/resend-license", zValidator("json", resendSchema), async (c) => {
return c.json({ success: false, error: "License not found" }, 404);
}
// Generar el email
const emailHtml = await render(
ResendLicenseEmail({
customerName: license.customerId,
licenseKey: license.licenseKey,
productName: `Dokploy Self Hosted ${license.type}`,
expirationDate: new Date(license.expiresAt),
requestDate: new Date(),
customerName: license.email,
}),
);
// Enviar el email
await transporter.sendMail({
from: process.env.SMTP_FROM,
to: license.email,
@@ -136,11 +145,12 @@ app.post("/stripe/webhook", async (c) => {
const { type, billingType } = getLicenseTypeFromPriceId(priceId!);
const license = await createLicense({
customerId: customerResponse.id,
productId: session.id,
type,
billingType,
email: session.customer_details?.email!,
stripeCustomerId: customerResponse.id,
stripeSubscriptionId: session.id,
});
const features = getLicenseFeatures(type);
@@ -163,6 +173,110 @@ app.post("/stripe/webhook", async (c) => {
break;
}
case "customer.subscription.updated": {
const subscription = event.data.object as Stripe.Subscription;
const customerResponse = await stripe.customers.retrieve(
subscription.customer as string,
);
if (subscription.status !== "active" || customerResponse.deleted) {
await deactivateLicense(subscription.id);
}
break;
}
case "invoice.payment_succeeded": {
const invoice = event.data.object as Stripe.Invoice;
if (!invoice.subscription) break;
const suscription = await stripe.subscriptions.retrieve(
invoice.subscription as string,
);
const customerResponse = await stripe.customers.retrieve(
invoice.customer as string,
);
if (suscription.status !== "active" || customerResponse.deleted) break;
const existingLicense = await db.query.licenses.findFirst({
where: eq(licenses.stripeCustomerId, invoice.customer as string),
});
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;
}
case "invoice.payment_failed": {
const invoice = event.data.object as Stripe.Invoice;
if (!invoice.subscription) break;
const subscription = await stripe.subscriptions.retrieve(
invoice.subscription as string,
);
if (subscription.status !== "active") {
await deactivateLicense(subscription.id);
}
break;
}
case "customer.subscription.deleted": {
const subscription = event.data.object as Stripe.Subscription;
const existingLicense = await db.query.licenses.findFirst({
where: eq(licenses.stripeCustomerId, subscription.customer as string),
});
if (!existingLicense) break;
await db
.update(licenses)
.set({
status: "cancelled",
})
.where(eq(licenses.id, existingLicense.id));
break;
}
}
return c.json({ received: true });

View File

@@ -5,6 +5,7 @@ export const licenseStatusEnum = pgEnum("license_status", [
"active",
"expired",
"cancelled",
"payment_pending",
]);
export const licenseTypeEnum = pgEnum("license_type", [
@@ -17,7 +18,6 @@ export const billingTypeEnum = pgEnum("billing_type", ["monthly", "annual"]);
export const licenses = pgTable("licenses", {
id: uuid("id").defaultRandom().primaryKey(),
customerId: text("customer_id").notNull(),
productId: text("product_id").notNull(),
licenseKey: text("license_key").notNull().unique(),
status: licenseStatusEnum("status").notNull().default("active"),
@@ -27,6 +27,8 @@ export const licenses = pgTable("licenses", {
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`),
updatedAt: timestamp("updated_at").default(sql`CURRENT_TIMESTAMP`),
metadata: text("metadata"),

View File

@@ -1,25 +1,29 @@
import { randomBytes } from "node:crypto";
import { db } from "../db";
import { licenses } from "../schema";
import { type License, licenses } from "../schema";
import { eq } from "drizzle-orm";
export const generateLicenseKey = () => {
return randomBytes(32).toString("hex");
};
export const createLicense = async ({
customerId,
productId,
type,
billingType,
email,
}: {
customerId: string;
interface CreateLicenseProps {
productId: string;
type: "basic" | "premium" | "business";
billingType: "monthly" | "annual";
email: string;
}) => {
stripeCustomerId: string;
stripeSubscriptionId: string;
}
export const createLicense = async ({
productId,
type,
billingType,
email,
stripeCustomerId,
stripeSubscriptionId,
}: CreateLicenseProps) => {
const licenseKey = `dokploy-${generateLicenseKey()}`;
const expiresAt = new Date();
expiresAt.setMonth(
@@ -29,13 +33,14 @@ export const createLicense = async ({
const license = await db
.insert(licenses)
.values({
customerId,
productId,
licenseKey,
type,
billingType,
expiresAt,
email,
stripeCustomerId,
stripeSubscriptionId,
})
.returning();
@@ -55,7 +60,10 @@ export const validateLicense = async (
}
if (license.status !== "active") {
return { isValid: false, error: "License is not active" };
return {
isValid: false,
error: `License is ${getLicenseStatus(license)}`,
};
}
if (new Date() > license.expiresAt) {
@@ -117,3 +125,36 @@ export const activateLicense = async (licenseKey: string, serverIp: string) => {
return updatedLicense[0];
};
export const deactivateLicense = async (stripeSubscriptionId: string) => {
const license = await db.query.licenses.findFirst({
where: eq(licenses.stripeSubscriptionId, stripeSubscriptionId),
});
if (!license) {
throw new Error("License not found");
}
await db
.update(licenses)
.set({ status: "cancelled" })
.where(eq(licenses.id, license.id));
};
export const getLicenseStatus = (license: License) => {
if (license.status === "active") {
return "active";
}
if (license.status === "expired") {
return "expired";
}
if (license.status === "cancelled") {
return "cancelled";
}
if (license.status === "payment_pending") {
return "pending payment";
}
};