diff --git a/apps/licenses/drizzle/0000_famous_vermin.sql b/apps/licenses/drizzle/0000_famous_vermin.sql new file mode 100644 index 000000000..99e65021a --- /dev/null +++ b/apps/licenses/drizzle/0000_famous_vermin.sql @@ -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; \ No newline at end of file diff --git a/apps/licenses/drizzle/meta/0000_snapshot.json b/apps/licenses/drizzle/meta/0000_snapshot.json new file mode 100644 index 000000000..743fc412b --- /dev/null +++ b/apps/licenses/drizzle/meta/0000_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/apps/licenses/drizzle/meta/_journal.json b/apps/licenses/drizzle/meta/_journal.json index eaa8fcf3b..fada2e646 100644 --- a/apps/licenses/drizzle/meta/_journal.json +++ b/apps/licenses/drizzle/meta/_journal.json @@ -1,5 +1,13 @@ { "version": "7", "dialect": "postgresql", - "entries": [] + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1742773711509, + "tag": "0000_famous_vermin", + "breakpoints": true + } + ] } \ No newline at end of file diff --git a/apps/licenses/package.json b/apps/licenses/package.json index ce2083ba6..c3715e3a2 100644 --- a/apps/licenses/package.json +++ b/apps/licenses/package.json @@ -16,6 +16,7 @@ "studio": "drizzle-kit studio" }, "dependencies": { + "nanoid": "5.1.5", "@react-email/components": "^0.0.21", "@hono/node-server": "^1.12.1", "@hono/zod-validator": "0.3.0", diff --git a/apps/licenses/src/api/license.ts b/apps/licenses/src/api/license.ts new file mode 100644 index 000000000..548a5d56b --- /dev/null +++ b/apps/licenses/src/api/license.ts @@ -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 }); + }, +); diff --git a/apps/licenses/src/api/stripe.ts b/apps/licenses/src/api/stripe.ts new file mode 100644 index 000000000..463336c93 --- /dev/null +++ b/apps/licenses/src/api/stripe.ts @@ -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); + } + }, +); diff --git a/apps/licenses/src/index.ts b/apps/licenses/src/index.ts index 12d29dc3f..6d72be2a0 100644 --- a/apps/licenses/src/index.ts +++ b/apps/licenses/src/index.ts @@ -1,29 +1,12 @@ import { serve } from "@hono/node-server"; import { Hono } from "hono"; import { cors } from "hono/cors"; -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 { - createLicense, - validateLicense, - activateLicense, - deactivateLicense, - getStripeItems, -} from "./utils/license"; import { db } from "./db"; -import { eq, sql } from "drizzle-orm"; -import { licenses } from "./schema"; +import { sql } from "drizzle-orm"; import "dotenv/config"; -import { getLicenseFeatures, getLicenseTypeFromPriceId } from "./utils"; -import { transporter } from "./email"; -import type Stripe from "stripe"; -import { stripe } from "./stripe"; -import { WEBSITE_URL } from "./constants"; -import { createCheckoutSessionSchema } from "./validators/stripe"; +import { licenseRouter } from "./api/license"; +import { stripeRouter } from "./api/stripe"; const app = 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) => { try { 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/license", licenseRouter); +app.route("/api/stripe", stripeRouter); const port = process.env.PORT || 4002; console.log(`Server is running on port http://localhost:${port}`); diff --git a/apps/licenses/src/schema.ts b/apps/licenses/src/schema.ts index 0c6efcd1d..5c4530694 100644 --- a/apps/licenses/src/schema.ts +++ b/apps/licenses/src/schema.ts @@ -1,38 +1,43 @@ 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", [ - "active", - "expired", - "cancelled", - "payment_pending", -]); - -export const licenseTypeEnum = pgEnum("license_type", [ - "basic", - "premium", - "business", -]); - -export const billingTypeEnum = pgEnum("billing_type", ["monthly", "annual"]); +export const usersRelations = relations(users, ({ many }) => ({ + licenses: many(licenses), +})); export const licenses = pgTable("licenses", { id: uuid("id").defaultRandom().primaryKey(), productId: text("product_id").notNull(), licenseKey: text("license_key").notNull().unique(), - status: licenseStatusEnum("status").notNull().default("active"), - type: licenseTypeEnum("type").notNull(), - billingType: billingTypeEnum("billing_type").notNull(), - serverIp: text("server_ip"), + serverIps: text("server_ips").array(), activatedAt: timestamp("activated_at"), lastVerifiedAt: timestamp("last_verified_at"), 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"), - 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 NewLicense = typeof licenses.$inferInsert; diff --git a/apps/licenses/src/utils/license.ts b/apps/licenses/src/utils/license.ts index 6cb5c1a59..d30b009a2 100644 --- a/apps/licenses/src/utils/license.ts +++ b/apps/licenses/src/utils/license.ts @@ -1,6 +1,6 @@ import { randomBytes } from "node:crypto"; import { db } from "../db"; -import { licenses } from "../schema"; +import { licenses, users } from "../schema"; import { eq } from "drizzle-orm"; import { stripe } from "../stripe"; import type Stripe from "stripe"; @@ -11,8 +11,6 @@ export const generateLicenseKey = () => { interface CreateLicenseProps { productId: string; - type: "basic" | "premium" | "business"; - billingType: "monthly" | "annual"; email: string; stripeCustomerId: string; stripeSubscriptionId: string; @@ -20,34 +18,54 @@ interface CreateLicenseProps { export const createLicense = async ({ productId, - type, - billingType, email, stripeCustomerId, stripeSubscriptionId, }: CreateLicenseProps) => { const licenseKey = `dokploy-${generateLicenseKey()}`; - const license = await db - .insert(licenses) - .values({ - productId, - licenseKey, - type, - billingType, - email, - stripeCustomerId, - stripeSubscriptionId, - }) - .returning(); + return await db.transaction(async (tx) => { + let user = await tx + .insert(users) + .values({ email }) + .onConflictDoNothing() + .returning() + .then((res) => res[0]); - 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 ( - licenseKey: string, - serverIp?: string, -) => { +export const validateLicense = async (licenseKey: string, serverIp: string) => { const license = await db.query.licenses.findFirst({ where: eq(licenses.licenseKey, licenseKey), }); @@ -60,8 +78,6 @@ export const validateLicense = async ( license.stripeSubscriptionId, ); - console.log("Suscription", suscription); - if (suscription.status !== "active") { return { 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" }; } @@ -97,8 +113,16 @@ export const activateLicense = async (licenseKey: string, serverIp: string) => { if (suscription.status !== "active") { 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"); } @@ -106,7 +130,7 @@ export const activateLicense = async (licenseKey: string, serverIp: string) => { const updatedLicense = await db .update(licenses) .set({ - serverIp, + serverIps: [...(license.serverIps || []), serverIp], activatedAt: new Date(), lastVerifiedAt: new Date(), }) @@ -116,21 +140,6 @@ 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 = async (license: Stripe.Subscription) => { if (license.status === "active") { return "active"; diff --git a/apps/licenses/templates/emails/resend-license-email.tsx b/apps/licenses/templates/emails/resend-license-email.tsx index 940a343e6..c874e8a5d 100644 --- a/apps/licenses/templates/emails/resend-license-email.tsx +++ b/apps/licenses/templates/emails/resend-license-email.tsx @@ -18,7 +18,6 @@ interface ResendLicenseEmailProps { customerName: string; licenseKey: string; productName: string; - expirationDate: Date; requestDate?: Date; } @@ -28,15 +27,8 @@ export const ResendLicenseEmail = ({ customerName = "John Doe", licenseKey = "1234567890", productName = "Dokploy", - expirationDate = new Date(), requestDate = new Date(), }: ResendLicenseEmailProps): React.ReactElement => { - const formattedDate = expirationDate.toLocaleDateString("en-US", { - year: "numeric", - month: "long", - day: "numeric", - }); - const formattedRequestDate = requestDate.toLocaleDateString("en-US", { year: "numeric", month: "long", @@ -75,12 +67,6 @@ export const ResendLicenseEmail = ({ {licenseKey} -
- - 🗓️ Valid until: {formattedDate} - -
-
Quick Activation Guide diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc721ebb7..cda13a187 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -554,6 +554,9 @@ importers: hono: specifier: ^4.5.8 version: 4.5.8 + nanoid: + specifier: 5.1.5 + version: 5.1.5 nodemailer: specifier: 6.9.14 version: 6.9.14 @@ -6059,6 +6062,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@5.1.5: + resolution: {integrity: sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==} + engines: {node: ^18 || >=20} + hasBin: true + nanostores@0.11.3: resolution: {integrity: sha512-TUes3xKIX33re4QzdxwZ6tdbodjmn3tWXCEc1uokiEmo14sI1EaGYNs2k3bU2pyyGNmBqFGAVl6jAGWd06AVIg==} engines: {node: ^18.0.0 || >=20.0.0} @@ -13247,6 +13255,8 @@ snapshots: nanoid@3.3.8: {} + nanoid@5.1.5: {} + nanostores@0.11.3: {} napi-build-utils@1.0.2: @@ -13718,13 +13728,13 @@ snapshots: postcss@8.4.31: dependencies: - nanoid: 3.3.7 + nanoid: 3.3.8 picocolors: 1.0.1 source-map-js: 1.2.0 postcss@8.4.40: dependencies: - nanoid: 3.3.7 + nanoid: 3.3.8 picocolors: 1.0.1 source-map-js: 1.2.0