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

@@ -1,9 +1,8 @@
CREATE TYPE "public"."billing_type" AS ENUM('monthly', 'annual');--> statement-breakpoint
CREATE TYPE "public"."license_status" AS ENUM('active', 'expired', 'cancelled');--> statement-breakpoint
CREATE TYPE "public"."license_status" AS ENUM('active', 'expired', 'cancelled', 'payment_pending');--> statement-breakpoint
CREATE TYPE "public"."license_type" AS ENUM('basic', 'premium', 'business');--> statement-breakpoint
CREATE TABLE "licenses" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"customer_id" text NOT NULL,
"product_id" text NOT NULL,
"license_key" text NOT NULL,
"status" "license_status" DEFAULT 'active' NOT NULL,
@@ -13,6 +12,8 @@ CREATE TABLE "licenses" (
"activated_at" timestamp,
"last_verified_at" timestamp,
"expires_at" timestamp NOT NULL,
"stripeCustomerId" text NOT NULL,
"stripeSubscriptionId" text NOT NULL,
"created_at" timestamp DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamp DEFAULT CURRENT_TIMESTAMP,
"metadata" text,

View File

@@ -1,5 +1,5 @@
{
"id": "41745f43-6627-49f6-afa3-ab192559b5a7",
"id": "5a996744-b11f-4f1a-b4b0-91f6bf5c2bed",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
@@ -15,12 +15,6 @@
"notNull": true,
"default": "gen_random_uuid()"
},
"customer_id": {
"name": "customer_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"product_id": {
"name": "product_id",
"type": "text",
@@ -79,6 +73,18 @@
"primaryKey": false,
"notNull": true
},
"stripeCustomerId": {
"name": "stripeCustomerId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"stripeSubscriptionId": {
"name": "stripeSubscriptionId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
@@ -138,7 +144,8 @@
"values": [
"active",
"expired",
"cancelled"
"cancelled",
"payment_pending"
]
},
"public.license_type": {

View File

@@ -5,8 +5,8 @@
{
"idx": 0,
"version": "7",
"when": 1742364501431,
"tag": "0000_furry_nico_minoru",
"when": 1742369437742,
"tag": "0000_noisy_epoch",
"breakpoints": true
}
]

View File

@@ -2,7 +2,7 @@ import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import postgres from "postgres";
import "dotenv/config";
import * as schema from "./schema";
import * as schema from "./src/schema";
const connectionString = process.env.DATABASE_URL!;
const sql = postgres(connectionString, { max: 1 });

View File

@@ -9,9 +9,9 @@
"typecheck": "tsc --noEmit",
"generate": "drizzle-kit generate",
"drop": "drizzle-kit drop",
"migrate": "tsx migrate.ts",
"truncate": "tsx truncate.ts",
"reset:all": "tsx truncate.ts && tsx migrate.ts",
"migrate": "tsx ./migrate.ts",
"truncate": "tsx ./truncate.ts",
"reset:all": "tsx ./truncate.ts && tsx ./migrate.ts",
"studio": "drizzle-kit studio"
},
"dependencies": {

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";
}
};