mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
feat(licenses): add license management and user authentication features
- Introduced a new SQL schema for managing licenses and users, including foreign key relationships. - Implemented API routes for license validation, activation, and OTP-based user authentication. - Updated the license creation process to associate licenses with users and handle server IPs. - Added support for the nanoid package to generate unique license keys. - Refactored existing code to improve modularity and maintainability, including the separation of license and stripe-related logic into dedicated API routes. - Enhanced error handling and logging for better debugging and user feedback.
This commit is contained in:
28
apps/licenses/drizzle/0000_famous_vermin.sql
Normal file
28
apps/licenses/drizzle/0000_famous_vermin.sql
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
CREATE TABLE "licenses" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"product_id" text NOT NULL,
|
||||||
|
"license_key" text NOT NULL,
|
||||||
|
"server_ips" text[],
|
||||||
|
"activated_at" timestamp,
|
||||||
|
"last_verified_at" timestamp,
|
||||||
|
"stripeCustomerId" text NOT NULL,
|
||||||
|
"stripeSubscriptionId" text NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" timestamp DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"metadata" text,
|
||||||
|
"user_id" uuid,
|
||||||
|
CONSTRAINT "licenses_license_key_unique" UNIQUE("license_key")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "user" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"email" text NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" timestamp DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"otp_code" text,
|
||||||
|
"otp_code_expires_at" timestamp,
|
||||||
|
"temporal_id" uuid DEFAULT gen_random_uuid(),
|
||||||
|
CONSTRAINT "user_email_unique" UNIQUE("email")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "licenses" ADD CONSTRAINT "licenses_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;
|
||||||
196
apps/licenses/drizzle/meta/0000_snapshot.json
Normal file
196
apps/licenses/drizzle/meta/0000_snapshot.json
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
{
|
||||||
|
"id": "553c7c08-f9c6-4090-8372-8d27a389eaa7",
|
||||||
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.licenses": {
|
||||||
|
"name": "licenses",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"product_id": {
|
||||||
|
"name": "product_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"license_key": {
|
||||||
|
"name": "license_key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"server_ips": {
|
||||||
|
"name": "server_ips",
|
||||||
|
"type": "text[]",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"activated_at": {
|
||||||
|
"name": "activated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"last_verified_at": {
|
||||||
|
"name": "last_verified_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"name": "metadata",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"licenses_user_id_user_id_fk": {
|
||||||
|
"name": "licenses_user_id_user_id_fk",
|
||||||
|
"tableFrom": "licenses",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"licenses_license_key_unique": {
|
||||||
|
"name": "licenses_license_key_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"license_key"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.user": {
|
||||||
|
"name": "user",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
},
|
||||||
|
"otp_code": {
|
||||||
|
"name": "otp_code",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"otp_code_expires_at": {
|
||||||
|
"name": "otp_code_expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"temporal_id": {
|
||||||
|
"name": "temporal_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"user_email_unique": {
|
||||||
|
"name": "user_email_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
{
|
{
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "postgresql",
|
"dialect": "postgresql",
|
||||||
"entries": []
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1742773711509,
|
||||||
|
"tag": "0000_famous_vermin",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
"studio": "drizzle-kit studio"
|
"studio": "drizzle-kit studio"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"nanoid": "5.1.5",
|
||||||
"@react-email/components": "^0.0.21",
|
"@react-email/components": "^0.0.21",
|
||||||
"@hono/node-server": "^1.12.1",
|
"@hono/node-server": "^1.12.1",
|
||||||
"@hono/zod-validator": "0.3.0",
|
"@hono/zod-validator": "0.3.0",
|
||||||
|
|||||||
217
apps/licenses/src/api/license.ts
Normal file
217
apps/licenses/src/api/license.ts
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { activateLicense, validateLicense } from "../utils/license";
|
||||||
|
import { logger } from "../logger";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { users } from "../schema";
|
||||||
|
import { db } from "../db";
|
||||||
|
import { transporter } from "../email";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
import { stripe } from "../stripe";
|
||||||
|
import type Stripe from "stripe";
|
||||||
|
const validateSchema = z.object({
|
||||||
|
licenseKey: z.string(),
|
||||||
|
serverIp: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const licenseRouter = new Hono();
|
||||||
|
|
||||||
|
licenseRouter.post(
|
||||||
|
"/validate",
|
||||||
|
zValidator("json", validateSchema),
|
||||||
|
async (c) => {
|
||||||
|
const { licenseKey, serverIp } = c.req.valid("json");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await validateLicense(licenseKey, serverIp);
|
||||||
|
console.log("Result", result);
|
||||||
|
return c.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error validating license:", { error });
|
||||||
|
return c.json({ isValid: false, error: "Error validating license" }, 500);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
licenseRouter.post(
|
||||||
|
"/activate",
|
||||||
|
zValidator("json", validateSchema),
|
||||||
|
async (c) => {
|
||||||
|
const { licenseKey, serverIp } = c.req.valid("json");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const license = await activateLicense(licenseKey, serverIp);
|
||||||
|
return c.json({ success: true, license });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error activating license:", error);
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return c.json({ success: false, error: error.message }, 400);
|
||||||
|
}
|
||||||
|
return c.json({ success: false, error: "Unknown error occurred" }, 400);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// router.post("/resend-license", zValidator("json", resendSchema), async (c) => {
|
||||||
|
// const { licenseKey } = c.req.valid("json");
|
||||||
|
|
||||||
|
// try {
|
||||||
|
// const license = await db.query.licenses.findFirst({
|
||||||
|
// where: eq(licenses.licenseKey, licenseKey),
|
||||||
|
// });
|
||||||
|
|
||||||
|
// if (!license) {
|
||||||
|
// return c.json({ success: false, error: "License not found" }, 404);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const suscription = await stripe.subscriptions.retrieve(
|
||||||
|
// license.stripeSubscriptionId,
|
||||||
|
// );
|
||||||
|
|
||||||
|
// const priceId = suscription.items.data[0].price.id;
|
||||||
|
// const { type } = getLicenseTypeFromPriceId(priceId);
|
||||||
|
|
||||||
|
// const emailHtml = await render(
|
||||||
|
// ResendLicenseEmail({
|
||||||
|
// licenseKey: license.licenseKey,
|
||||||
|
// productName: `Dokploy Self Hosted ${type}`,
|
||||||
|
// requestDate: new Date(),
|
||||||
|
// customerName: license.email,
|
||||||
|
// }),
|
||||||
|
// );
|
||||||
|
|
||||||
|
// await transporter.sendMail({
|
||||||
|
// from: process.env.SMTP_FROM_ADDRESS,
|
||||||
|
// to: license.email,
|
||||||
|
// subject: "Your Dokploy License Key",
|
||||||
|
// html: emailHtml,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// return c.json({ success: true });
|
||||||
|
// } catch (error) {
|
||||||
|
// logger.error("Error resending license:", error);
|
||||||
|
// return c.json({ success: false, error: "Error resending license" }, 500);
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
|
licenseRouter.post(
|
||||||
|
"/send-otp",
|
||||||
|
zValidator("json", z.object({ email: z.string().email() })),
|
||||||
|
async (c) => {
|
||||||
|
const { email } = c.req.valid("json");
|
||||||
|
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: eq(users.email, email.toLowerCase()),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return c.json({ success: false, error: "User not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateOtpCode = Math.floor(100000 + Math.random() * 900000);
|
||||||
|
const otpCodeExpiresAt = new Date(Date.now() + 10 * 60 * 1000);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({ otpCode: generateOtpCode.toString(), otpCodeExpiresAt })
|
||||||
|
.where(eq(users.id, user.id));
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: process.env.SMTP_FROM_ADDRESS,
|
||||||
|
to: user.email,
|
||||||
|
subject: "Your Dokploy License Key ",
|
||||||
|
html: `Your OTP code is ${generateOtpCode}, it will expire in 10 minutes`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({ success: true });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
licenseRouter.post(
|
||||||
|
"/verify-otp",
|
||||||
|
zValidator(
|
||||||
|
"json",
|
||||||
|
z.object({ email: z.string().email(), otpCode: z.string().length(6) }),
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const { email, otpCode } = c.req.valid("json");
|
||||||
|
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: eq(users.email, email.toLowerCase()),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return c.json({ success: false, error: "User not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.otpCode !== otpCode) {
|
||||||
|
return c.json({ success: false, error: "Invalid code" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.otpCodeExpiresAt && user.otpCodeExpiresAt < new Date()) {
|
||||||
|
return c.json({ success: false, error: "Code expired" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
otpCode: null,
|
||||||
|
otpCodeExpiresAt: null,
|
||||||
|
temporalId: nanoid(),
|
||||||
|
temporalIdExpiresAt: new Date(Date.now() + 20 * 60 * 1000),
|
||||||
|
})
|
||||||
|
.where(eq(users.id, user.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return c.json({ success: true, temporalId: result[0].temporalId });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
licenseRouter.get(
|
||||||
|
"/all",
|
||||||
|
zValidator("query", z.object({ temporalId: z.string() })),
|
||||||
|
async (c) => {
|
||||||
|
const { temporalId } = c.req.valid("query");
|
||||||
|
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: eq(users.temporalId, temporalId),
|
||||||
|
with: {
|
||||||
|
licenses: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return c.json({ success: false, error: "User not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.temporalIdExpiresAt && user.temporalIdExpiresAt < new Date()) {
|
||||||
|
return c.json({ success: false, error: "Session expired" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const suscriptions: Stripe.Subscription[] = [];
|
||||||
|
for (const license of user.licenses) {
|
||||||
|
const suscription = await stripe.subscriptions.retrieve(
|
||||||
|
license.stripeSubscriptionId,
|
||||||
|
);
|
||||||
|
|
||||||
|
suscriptions.push(suscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formated = user.licenses.map((license) => {
|
||||||
|
const suscription = suscriptions.find(
|
||||||
|
(suscription) => suscription.id === license.stripeSubscriptionId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
license: license,
|
||||||
|
stripeSuscription: {
|
||||||
|
quantity: suscription?.items.data[0].quantity,
|
||||||
|
billingType: suscription?.items.data[0].price.recurring?.interval,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({ success: true, licenses: formated });
|
||||||
|
},
|
||||||
|
);
|
||||||
180
apps/licenses/src/api/stripe.ts
Normal file
180
apps/licenses/src/api/stripe.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { createCheckoutSessionSchema } from "../validators/stripe";
|
||||||
|
import { getStripeItems } from "../utils/license";
|
||||||
|
import { stripe } from "../stripe";
|
||||||
|
import { WEBSITE_URL } from "../constants";
|
||||||
|
import { logger } from "../logger";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { licenses } from "../schema";
|
||||||
|
import { db } from "../db";
|
||||||
|
import { getLicenseFeatures, getLicenseTypeFromPriceId } from "../utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import type Stripe from "stripe";
|
||||||
|
import { createLicense } from "../utils/license";
|
||||||
|
import { render } from "@react-email/render";
|
||||||
|
import { LicenseEmail } from "../../templates/emails/license-email";
|
||||||
|
import { transporter } from "../email";
|
||||||
|
|
||||||
|
export const stripeRouter = new Hono();
|
||||||
|
|
||||||
|
stripeRouter.post(
|
||||||
|
"/create-checkout-session",
|
||||||
|
zValidator("json", createCheckoutSessionSchema),
|
||||||
|
async (c) => {
|
||||||
|
const { type, serverQuantity, isAnnual } = c.req.valid("json");
|
||||||
|
|
||||||
|
const items = getStripeItems(type, serverQuantity, isAnnual);
|
||||||
|
const session = await stripe.checkout.sessions.create({
|
||||||
|
mode: "subscription",
|
||||||
|
line_items: items,
|
||||||
|
allow_promotion_codes: true,
|
||||||
|
success_url: `${WEBSITE_URL}/license/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||||
|
cancel_url: `${WEBSITE_URL}#pricing`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({ sessionId: session.id });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
stripeRouter.get(
|
||||||
|
"/get-license-from-session",
|
||||||
|
zValidator("query", z.object({ sessionId: z.string().min(1) })),
|
||||||
|
async (c) => {
|
||||||
|
const { sessionId } = c.req.valid("query");
|
||||||
|
console.log("Session ID", sessionId);
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
return c.json({ error: "Session ID is required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await stripe.checkout.sessions.retrieve(sessionId);
|
||||||
|
if (session.status !== "complete") {
|
||||||
|
return c.json({ error: "Session is not complete" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = await stripe.subscriptions.retrieve(
|
||||||
|
session.subscription as string,
|
||||||
|
);
|
||||||
|
|
||||||
|
const license = await db.query.licenses.findFirst({
|
||||||
|
where: eq(licenses.stripeSubscriptionId, subscription.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
const priceId = subscription.items.data[0].price.id;
|
||||||
|
const { type, billingType } = getLicenseTypeFromPriceId(priceId);
|
||||||
|
|
||||||
|
return c.json({ type, billingType, key: license?.licenseKey });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error retrieving session:", error);
|
||||||
|
return c.json({ error: "Error retrieving session" }, 400);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
stripeRouter.post("/webhook", async (c) => {
|
||||||
|
const rawBody = await c.req.raw.text();
|
||||||
|
const sig = c.req.header("stripe-signature");
|
||||||
|
|
||||||
|
let event: Stripe.Event;
|
||||||
|
|
||||||
|
try {
|
||||||
|
event = stripe.webhooks.constructEvent(
|
||||||
|
rawBody,
|
||||||
|
sig!,
|
||||||
|
process.env.STRIPE_WEBHOOK_SECRET!,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("Webhook signature verification failed:", err);
|
||||||
|
return c.json({ error: "Webhook signature verification failed" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedEvents = ["invoice.paid"];
|
||||||
|
|
||||||
|
if (!allowedEvents.includes(event.type)) {
|
||||||
|
return c.json({ error: "Event not allowed" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (event.type) {
|
||||||
|
case "invoice.paid": {
|
||||||
|
const invoice = event.data.object as Stripe.Invoice;
|
||||||
|
|
||||||
|
if (!invoice.subscription) break;
|
||||||
|
|
||||||
|
if (invoice.billing_reason === "subscription_create") {
|
||||||
|
const customerResponse = await stripe.customers.retrieve(
|
||||||
|
invoice.customer as string,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (customerResponse.deleted) {
|
||||||
|
throw new Error("Customer was deleted");
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriptionId = invoice.subscription as string;
|
||||||
|
const subscription =
|
||||||
|
await stripe.subscriptions.retrieve(subscriptionId);
|
||||||
|
const priceId = subscription.items.data[0].price.id;
|
||||||
|
const { type } = getLicenseTypeFromPriceId(priceId);
|
||||||
|
|
||||||
|
const { license, user } = await createLicense({
|
||||||
|
productId: subscriptionId,
|
||||||
|
email: customerResponse.email!.toLowerCase(),
|
||||||
|
stripeCustomerId: customerResponse.id,
|
||||||
|
stripeSubscriptionId: subscriptionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const features = getLicenseFeatures(type);
|
||||||
|
const emailHtml = await render(
|
||||||
|
LicenseEmail({
|
||||||
|
customerName: customerResponse.name || "Customer",
|
||||||
|
licenseKey: license.licenseKey,
|
||||||
|
productName: `Dokploy Self Hosted ${type}`,
|
||||||
|
features: features,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: process.env.SMTP_FROM_ADDRESS,
|
||||||
|
to: user.email,
|
||||||
|
subject: "Your Dokploy License Key ",
|
||||||
|
html: emailHtml,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ received: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error processing webhook:", error);
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return c.json({ error: error.message }, 500);
|
||||||
|
}
|
||||||
|
return c.json({ error: "Unknown error occurred" }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
stripeRouter.post(
|
||||||
|
"/create-customer-portal-session",
|
||||||
|
zValidator("json", z.object({ customerId: z.string().min(1) })),
|
||||||
|
async (c) => {
|
||||||
|
try {
|
||||||
|
const { customerId } = c.req.valid("json");
|
||||||
|
|
||||||
|
console.log("Customer ID", customerId);
|
||||||
|
|
||||||
|
const session = await stripe.billingPortal.sessions.create({
|
||||||
|
customer: customerId,
|
||||||
|
return_url: `${WEBSITE_URL}/dashboard/settings/billing`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({ url: session.url });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error creating customer portal session:", error);
|
||||||
|
return c.json({ error: "Error creating customer portal session" }, 500);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -1,29 +1,12 @@
|
|||||||
import { serve } from "@hono/node-server";
|
import { serve } from "@hono/node-server";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { cors } from "hono/cors";
|
import { cors } from "hono/cors";
|
||||||
import { z } from "zod";
|
|
||||||
import { zValidator } from "@hono/zod-validator";
|
|
||||||
import { logger } from "./logger";
|
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 {
|
|
||||||
createLicense,
|
|
||||||
validateLicense,
|
|
||||||
activateLicense,
|
|
||||||
deactivateLicense,
|
|
||||||
getStripeItems,
|
|
||||||
} from "./utils/license";
|
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
import { eq, sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import { licenses } from "./schema";
|
|
||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import { getLicenseFeatures, getLicenseTypeFromPriceId } from "./utils";
|
import { licenseRouter } from "./api/license";
|
||||||
import { transporter } from "./email";
|
import { stripeRouter } from "./api/stripe";
|
||||||
import type Stripe from "stripe";
|
|
||||||
import { stripe } from "./stripe";
|
|
||||||
import { WEBSITE_URL } from "./constants";
|
|
||||||
import { createCheckoutSessionSchema } from "./validators/stripe";
|
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
const router = new Hono();
|
const router = new Hono();
|
||||||
@@ -34,15 +17,6 @@ router.use(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const validateSchema = z.object({
|
|
||||||
licenseKey: z.string(),
|
|
||||||
serverIp: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const resendSchema = z.object({
|
|
||||||
licenseKey: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get("/health", async (c) => {
|
router.get("/health", async (c) => {
|
||||||
try {
|
try {
|
||||||
await db.execute(sql`SELECT 1`);
|
await db.execute(sql`SELECT 1`);
|
||||||
@@ -53,264 +27,9 @@ router.get("/health", async (c) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/validate", zValidator("json", validateSchema), async (c) => {
|
|
||||||
const { licenseKey, serverIp } = c.req.valid("json");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await validateLicense(licenseKey, serverIp);
|
|
||||||
console.log("Result", result);
|
|
||||||
return c.json(result);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Error validating license:", { error });
|
|
||||||
return c.json({ isValid: false, error: "Error validating license" }, 500);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("/activate", zValidator("json", validateSchema), async (c) => {
|
|
||||||
const { licenseKey, serverIp } = c.req.valid("json");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const license = await activateLicense(licenseKey, serverIp);
|
|
||||||
return c.json({ success: true, license });
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Error activating license:", error);
|
|
||||||
if (error instanceof Error) {
|
|
||||||
return c.json({ success: false, error: error.message }, 400);
|
|
||||||
}
|
|
||||||
return c.json({ success: false, error: "Unknown error occurred" }, 400);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/create-checkout-session",
|
|
||||||
zValidator("json", createCheckoutSessionSchema),
|
|
||||||
async (c) => {
|
|
||||||
const { type, serverQuantity, isAnnual } = c.req.valid("json");
|
|
||||||
|
|
||||||
const items = getStripeItems(type, serverQuantity, isAnnual);
|
|
||||||
const session = await stripe.checkout.sessions.create({
|
|
||||||
mode: "subscription",
|
|
||||||
line_items: items,
|
|
||||||
allow_promotion_codes: true,
|
|
||||||
success_url: `${WEBSITE_URL}/license/success`,
|
|
||||||
cancel_url: `${WEBSITE_URL}#pricing`,
|
|
||||||
});
|
|
||||||
|
|
||||||
return c.json({ sessionId: session.id });
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post("/resend-license", zValidator("json", resendSchema), async (c) => {
|
|
||||||
const { licenseKey } = c.req.valid("json");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const license = await db.query.licenses.findFirst({
|
|
||||||
where: eq(licenses.licenseKey, licenseKey),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!license) {
|
|
||||||
return c.json({ success: false, error: "License not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const emailHtml = await render(
|
|
||||||
ResendLicenseEmail({
|
|
||||||
licenseKey: license.licenseKey,
|
|
||||||
productName: `Dokploy Self Hosted ${license.type}`,
|
|
||||||
// TODO: Add expiration date
|
|
||||||
expirationDate: new Date(),
|
|
||||||
requestDate: new Date(),
|
|
||||||
customerName: license.email,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await transporter.sendMail({
|
|
||||||
from: process.env.SMTP_FROM_ADDRESS,
|
|
||||||
to: license.email,
|
|
||||||
subject: "Your Dokploy License Key",
|
|
||||||
html: emailHtml,
|
|
||||||
});
|
|
||||||
|
|
||||||
return c.json({ success: true });
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Error resending license:", error);
|
|
||||||
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");
|
|
||||||
|
|
||||||
let event: Stripe.Event;
|
|
||||||
|
|
||||||
try {
|
|
||||||
event = stripe.webhooks.constructEvent(
|
|
||||||
rawBody,
|
|
||||||
sig!,
|
|
||||||
process.env.STRIPE_WEBHOOK_SECRET!,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error("Webhook signature verification failed:", err);
|
|
||||||
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",
|
|
||||||
"invoice.paid",
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!allowedEvents.includes(event.type)) {
|
|
||||||
return c.json({ error: "Event not allowed" }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
switch (event.type) {
|
|
||||||
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.paid": {
|
|
||||||
const invoice = event.data.object as Stripe.Invoice;
|
|
||||||
|
|
||||||
if (!invoice.subscription) break;
|
|
||||||
|
|
||||||
if (invoice.billing_reason === "subscription_create") {
|
|
||||||
const customerResponse = await stripe.customers.retrieve(
|
|
||||||
invoice.customer as string,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (customerResponse.deleted) {
|
|
||||||
throw new Error("Customer was deleted");
|
|
||||||
}
|
|
||||||
|
|
||||||
const subscriptionId = invoice.subscription as string;
|
|
||||||
const subscription =
|
|
||||||
await stripe.subscriptions.retrieve(subscriptionId);
|
|
||||||
const priceId = subscription.items.data[0].price.id;
|
|
||||||
const { type, billingType } = getLicenseTypeFromPriceId(priceId);
|
|
||||||
|
|
||||||
const license = await createLicense({
|
|
||||||
productId: subscriptionId,
|
|
||||||
type,
|
|
||||||
billingType,
|
|
||||||
email: customerResponse.email!,
|
|
||||||
stripeCustomerId: customerResponse.id,
|
|
||||||
stripeSubscriptionId: subscriptionId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const features = getLicenseFeatures(type);
|
|
||||||
const emailHtml = await render(
|
|
||||||
LicenseEmail({
|
|
||||||
customerName: customerResponse.name || "Customer",
|
|
||||||
licenseKey: license.licenseKey,
|
|
||||||
productName: `Dokploy Self Hosted ${type}`,
|
|
||||||
features: features,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await transporter.sendMail({
|
|
||||||
from: process.env.SMTP_FROM_ADDRESS,
|
|
||||||
to: license.email,
|
|
||||||
subject: "Your Dokploy License Key ",
|
|
||||||
html: emailHtml,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
await deactivateLicense(invoice.subscription as string);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingLicense = await db.query.licenses.findFirst({
|
|
||||||
where: eq(licenses.stripeCustomerId, invoice.customer as string),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!existingLicense) break;
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(licenses)
|
|
||||||
.set({
|
|
||||||
status: "active",
|
|
||||||
})
|
|
||||||
.where(eq(licenses.id, existingLicense.id));
|
|
||||||
|
|
||||||
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 });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error processing webhook:", error);
|
|
||||||
if (error instanceof Error) {
|
|
||||||
return c.json({ error: error.message }, 500);
|
|
||||||
}
|
|
||||||
return c.json({ error: "Unknown error occurred" }, 500);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.route("/api", router);
|
app.route("/api", router);
|
||||||
|
app.route("/api/license", licenseRouter);
|
||||||
|
app.route("/api/stripe", stripeRouter);
|
||||||
const port = process.env.PORT || 4002;
|
const port = process.env.PORT || 4002;
|
||||||
console.log(`Server is running on port http://localhost:${port}`);
|
console.log(`Server is running on port http://localhost:${port}`);
|
||||||
|
|
||||||
|
|||||||
@@ -1,38 +1,43 @@
|
|||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import { pgEnum, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||||
|
import { relations } from "drizzle-orm";
|
||||||
|
export const users = pgTable("user", {
|
||||||
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
|
email: text("email").notNull().unique(),
|
||||||
|
createdAt: timestamp("created_at").default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
updatedAt: timestamp("updated_at").default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
otpCode: text("otp_code"),
|
||||||
|
otpCodeExpiresAt: timestamp("otp_code_expires_at"),
|
||||||
|
temporalId: text("temporal_id"),
|
||||||
|
temporalIdExpiresAt: timestamp("temporal_id_expires_at"),
|
||||||
|
});
|
||||||
|
|
||||||
export const licenseStatusEnum = pgEnum("license_status", [
|
export const usersRelations = relations(users, ({ many }) => ({
|
||||||
"active",
|
licenses: many(licenses),
|
||||||
"expired",
|
}));
|
||||||
"cancelled",
|
|
||||||
"payment_pending",
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const licenseTypeEnum = pgEnum("license_type", [
|
|
||||||
"basic",
|
|
||||||
"premium",
|
|
||||||
"business",
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const billingTypeEnum = pgEnum("billing_type", ["monthly", "annual"]);
|
|
||||||
|
|
||||||
export const licenses = pgTable("licenses", {
|
export const licenses = pgTable("licenses", {
|
||||||
id: uuid("id").defaultRandom().primaryKey(),
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
productId: text("product_id").notNull(),
|
productId: text("product_id").notNull(),
|
||||||
licenseKey: text("license_key").notNull().unique(),
|
licenseKey: text("license_key").notNull().unique(),
|
||||||
status: licenseStatusEnum("status").notNull().default("active"),
|
serverIps: text("server_ips").array(),
|
||||||
type: licenseTypeEnum("type").notNull(),
|
|
||||||
billingType: billingTypeEnum("billing_type").notNull(),
|
|
||||||
serverIp: text("server_ip"),
|
|
||||||
activatedAt: timestamp("activated_at"),
|
activatedAt: timestamp("activated_at"),
|
||||||
lastVerifiedAt: timestamp("last_verified_at"),
|
lastVerifiedAt: timestamp("last_verified_at"),
|
||||||
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`),
|
||||||
updatedAt: timestamp("updated_at").default(sql`CURRENT_TIMESTAMP`),
|
updatedAt: timestamp("updated_at").default(sql`CURRENT_TIMESTAMP`),
|
||||||
metadata: text("metadata"),
|
metadata: text("metadata"),
|
||||||
email: text("email").notNull(),
|
userId: uuid("user_id").references(() => users.id),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const licensesRelations = relations(licenses, ({ one }) => ({
|
||||||
|
user: one(users, {
|
||||||
|
fields: [licenses.userId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
export type License = typeof licenses.$inferSelect;
|
export type License = typeof licenses.$inferSelect;
|
||||||
export type NewLicense = typeof licenses.$inferInsert;
|
export type NewLicense = typeof licenses.$inferInsert;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import { licenses } from "../schema";
|
import { licenses, users } from "../schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { stripe } from "../stripe";
|
import { stripe } from "../stripe";
|
||||||
import type Stripe from "stripe";
|
import type Stripe from "stripe";
|
||||||
@@ -11,8 +11,6 @@ export const generateLicenseKey = () => {
|
|||||||
|
|
||||||
interface CreateLicenseProps {
|
interface CreateLicenseProps {
|
||||||
productId: string;
|
productId: string;
|
||||||
type: "basic" | "premium" | "business";
|
|
||||||
billingType: "monthly" | "annual";
|
|
||||||
email: string;
|
email: string;
|
||||||
stripeCustomerId: string;
|
stripeCustomerId: string;
|
||||||
stripeSubscriptionId: string;
|
stripeSubscriptionId: string;
|
||||||
@@ -20,34 +18,54 @@ interface CreateLicenseProps {
|
|||||||
|
|
||||||
export const createLicense = async ({
|
export const createLicense = async ({
|
||||||
productId,
|
productId,
|
||||||
type,
|
|
||||||
billingType,
|
|
||||||
email,
|
email,
|
||||||
stripeCustomerId,
|
stripeCustomerId,
|
||||||
stripeSubscriptionId,
|
stripeSubscriptionId,
|
||||||
}: CreateLicenseProps) => {
|
}: CreateLicenseProps) => {
|
||||||
const licenseKey = `dokploy-${generateLicenseKey()}`;
|
const licenseKey = `dokploy-${generateLicenseKey()}`;
|
||||||
|
|
||||||
const license = await db
|
return await db.transaction(async (tx) => {
|
||||||
.insert(licenses)
|
let user = await tx
|
||||||
.values({
|
.insert(users)
|
||||||
productId,
|
.values({ email })
|
||||||
licenseKey,
|
.onConflictDoNothing()
|
||||||
type,
|
.returning()
|
||||||
billingType,
|
.then((res) => res[0]);
|
||||||
email,
|
|
||||||
stripeCustomerId,
|
|
||||||
stripeSubscriptionId,
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
return license[0];
|
if (!user) {
|
||||||
|
const result = await tx.query.users.findFirst({
|
||||||
|
where: eq(users.email, email),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new Error("User not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
user = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("User", user);
|
||||||
|
|
||||||
|
const license = await tx
|
||||||
|
.insert(licenses)
|
||||||
|
.values({
|
||||||
|
productId,
|
||||||
|
licenseKey,
|
||||||
|
stripeCustomerId,
|
||||||
|
stripeSubscriptionId,
|
||||||
|
userId: user.id,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
.then((res) => res[0]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
license,
|
||||||
|
user,
|
||||||
|
};
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const validateLicense = async (
|
export const validateLicense = async (licenseKey: string, serverIp: string) => {
|
||||||
licenseKey: string,
|
|
||||||
serverIp?: string,
|
|
||||||
) => {
|
|
||||||
const license = await db.query.licenses.findFirst({
|
const license = await db.query.licenses.findFirst({
|
||||||
where: eq(licenses.licenseKey, licenseKey),
|
where: eq(licenses.licenseKey, licenseKey),
|
||||||
});
|
});
|
||||||
@@ -60,8 +78,6 @@ export const validateLicense = async (
|
|||||||
license.stripeSubscriptionId,
|
license.stripeSubscriptionId,
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log("Suscription", suscription);
|
|
||||||
|
|
||||||
if (suscription.status !== "active") {
|
if (suscription.status !== "active") {
|
||||||
return {
|
return {
|
||||||
isValid: false,
|
isValid: false,
|
||||||
@@ -69,7 +85,7 @@ export const validateLicense = async (
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (license.serverIp && serverIp && license.serverIp !== serverIp) {
|
if (license.serverIps && !license.serverIps.includes(serverIp)) {
|
||||||
return { isValid: false, error: "Invalid server IP" };
|
return { isValid: false, error: "Invalid server IP" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,8 +113,16 @@ export const activateLicense = async (licenseKey: string, serverIp: string) => {
|
|||||||
if (suscription.status !== "active") {
|
if (suscription.status !== "active") {
|
||||||
throw new Error(`License is ${getLicenseStatus(suscription)}`);
|
throw new Error(`License is ${getLicenseStatus(suscription)}`);
|
||||||
}
|
}
|
||||||
|
const currentServerQuantity = license.serverIps?.length || 0;
|
||||||
|
const serversQuantity = suscription.items.data[0].quantity || 0;
|
||||||
|
|
||||||
if (license.serverIp && license.serverIp !== serverIp) {
|
if (currentServerQuantity >= serversQuantity) {
|
||||||
|
throw new Error(
|
||||||
|
"You have reached the maximum number of servers, please upgrade your license to add more servers",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (license.serverIps && !license.serverIps.includes(serverIp)) {
|
||||||
throw new Error("License is already activated on a different server");
|
throw new Error("License is already activated on a different server");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +130,7 @@ export const activateLicense = async (licenseKey: string, serverIp: string) => {
|
|||||||
const updatedLicense = await db
|
const updatedLicense = await db
|
||||||
.update(licenses)
|
.update(licenses)
|
||||||
.set({
|
.set({
|
||||||
serverIp,
|
serverIps: [...(license.serverIps || []), serverIp],
|
||||||
activatedAt: new Date(),
|
activatedAt: new Date(),
|
||||||
lastVerifiedAt: new Date(),
|
lastVerifiedAt: new Date(),
|
||||||
})
|
})
|
||||||
@@ -116,21 +140,6 @@ export const activateLicense = async (licenseKey: string, serverIp: string) => {
|
|||||||
return updatedLicense[0];
|
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 = async (license: Stripe.Subscription) => {
|
export const getLicenseStatus = async (license: Stripe.Subscription) => {
|
||||||
if (license.status === "active") {
|
if (license.status === "active") {
|
||||||
return "active";
|
return "active";
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ interface ResendLicenseEmailProps {
|
|||||||
customerName: string;
|
customerName: string;
|
||||||
licenseKey: string;
|
licenseKey: string;
|
||||||
productName: string;
|
productName: string;
|
||||||
expirationDate: Date;
|
|
||||||
requestDate?: Date;
|
requestDate?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,15 +27,8 @@ export const ResendLicenseEmail = ({
|
|||||||
customerName = "John Doe",
|
customerName = "John Doe",
|
||||||
licenseKey = "1234567890",
|
licenseKey = "1234567890",
|
||||||
productName = "Dokploy",
|
productName = "Dokploy",
|
||||||
expirationDate = new Date(),
|
|
||||||
requestDate = new Date(),
|
requestDate = new Date(),
|
||||||
}: ResendLicenseEmailProps): React.ReactElement => {
|
}: ResendLicenseEmailProps): React.ReactElement => {
|
||||||
const formattedDate = expirationDate.toLocaleDateString("en-US", {
|
|
||||||
year: "numeric",
|
|
||||||
month: "long",
|
|
||||||
day: "numeric",
|
|
||||||
});
|
|
||||||
|
|
||||||
const formattedRequestDate = requestDate.toLocaleDateString("en-US", {
|
const formattedRequestDate = requestDate.toLocaleDateString("en-US", {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
@@ -75,12 +67,6 @@ export const ResendLicenseEmail = ({
|
|||||||
<Text style={licenseKeyStyle}>{licenseKey}</Text>
|
<Text style={licenseKeyStyle}>{licenseKey}</Text>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section style={validitySection}>
|
|
||||||
<Text style={validityText}>
|
|
||||||
🗓️ Valid until: <strong>{formattedDate}</strong>
|
|
||||||
</Text>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section style={activationSection}>
|
<Section style={activationSection}>
|
||||||
<Heading as="h2" style={h2}>
|
<Heading as="h2" style={h2}>
|
||||||
Quick Activation Guide
|
Quick Activation Guide
|
||||||
|
|||||||
14
pnpm-lock.yaml
generated
14
pnpm-lock.yaml
generated
@@ -554,6 +554,9 @@ importers:
|
|||||||
hono:
|
hono:
|
||||||
specifier: ^4.5.8
|
specifier: ^4.5.8
|
||||||
version: 4.5.8
|
version: 4.5.8
|
||||||
|
nanoid:
|
||||||
|
specifier: 5.1.5
|
||||||
|
version: 5.1.5
|
||||||
nodemailer:
|
nodemailer:
|
||||||
specifier: 6.9.14
|
specifier: 6.9.14
|
||||||
version: 6.9.14
|
version: 6.9.14
|
||||||
@@ -6059,6 +6062,11 @@ packages:
|
|||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
nanoid@5.1.5:
|
||||||
|
resolution: {integrity: sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==}
|
||||||
|
engines: {node: ^18 || >=20}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
nanostores@0.11.3:
|
nanostores@0.11.3:
|
||||||
resolution: {integrity: sha512-TUes3xKIX33re4QzdxwZ6tdbodjmn3tWXCEc1uokiEmo14sI1EaGYNs2k3bU2pyyGNmBqFGAVl6jAGWd06AVIg==}
|
resolution: {integrity: sha512-TUes3xKIX33re4QzdxwZ6tdbodjmn3tWXCEc1uokiEmo14sI1EaGYNs2k3bU2pyyGNmBqFGAVl6jAGWd06AVIg==}
|
||||||
engines: {node: ^18.0.0 || >=20.0.0}
|
engines: {node: ^18.0.0 || >=20.0.0}
|
||||||
@@ -13247,6 +13255,8 @@ snapshots:
|
|||||||
|
|
||||||
nanoid@3.3.8: {}
|
nanoid@3.3.8: {}
|
||||||
|
|
||||||
|
nanoid@5.1.5: {}
|
||||||
|
|
||||||
nanostores@0.11.3: {}
|
nanostores@0.11.3: {}
|
||||||
|
|
||||||
napi-build-utils@1.0.2:
|
napi-build-utils@1.0.2:
|
||||||
@@ -13718,13 +13728,13 @@ snapshots:
|
|||||||
|
|
||||||
postcss@8.4.31:
|
postcss@8.4.31:
|
||||||
dependencies:
|
dependencies:
|
||||||
nanoid: 3.3.7
|
nanoid: 3.3.8
|
||||||
picocolors: 1.0.1
|
picocolors: 1.0.1
|
||||||
source-map-js: 1.2.0
|
source-map-js: 1.2.0
|
||||||
|
|
||||||
postcss@8.4.40:
|
postcss@8.4.40:
|
||||||
dependencies:
|
dependencies:
|
||||||
nanoid: 3.3.7
|
nanoid: 3.3.8
|
||||||
picocolors: 1.0.1
|
picocolors: 1.0.1
|
||||||
source-map-js: 1.2.0
|
source-map-js: 1.2.0
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user