diff --git a/apps/licenses/drizzle/0000_furry_nico_minoru.sql b/apps/licenses/drizzle/0000_noisy_epoch.sql similarity index 86% rename from apps/licenses/drizzle/0000_furry_nico_minoru.sql rename to apps/licenses/drizzle/0000_noisy_epoch.sql index df07f73a..3e41be2c 100644 --- a/apps/licenses/drizzle/0000_furry_nico_minoru.sql +++ b/apps/licenses/drizzle/0000_noisy_epoch.sql @@ -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, diff --git a/apps/licenses/drizzle/meta/0000_snapshot.json b/apps/licenses/drizzle/meta/0000_snapshot.json index 0ce98f04..5bf2b432 100644 --- a/apps/licenses/drizzle/meta/0000_snapshot.json +++ b/apps/licenses/drizzle/meta/0000_snapshot.json @@ -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": { diff --git a/apps/licenses/drizzle/meta/_journal.json b/apps/licenses/drizzle/meta/_journal.json index 7ff74d39..ff4b16fb 100644 --- a/apps/licenses/drizzle/meta/_journal.json +++ b/apps/licenses/drizzle/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "7", - "when": 1742364501431, - "tag": "0000_furry_nico_minoru", + "when": 1742369437742, + "tag": "0000_noisy_epoch", "breakpoints": true } ] diff --git a/apps/licenses/migrate.ts b/apps/licenses/migrate.ts index 1b7752d3..4fad0e0a 100644 --- a/apps/licenses/migrate.ts +++ b/apps/licenses/migrate.ts @@ -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 }); diff --git a/apps/licenses/package.json b/apps/licenses/package.json index 83213975..2e903285 100644 --- a/apps/licenses/package.json +++ b/apps/licenses/package.json @@ -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": { diff --git a/apps/licenses/src/index.ts b/apps/licenses/src/index.ts index 0638674b..0b6cc21e 100644 --- a/apps/licenses/src/index.ts +++ b/apps/licenses/src/index.ts @@ -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 }); diff --git a/apps/licenses/src/schema.ts b/apps/licenses/src/schema.ts index fae66d66..7cffc8cf 100644 --- a/apps/licenses/src/schema.ts +++ b/apps/licenses/src/schema.ts @@ -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"), diff --git a/apps/licenses/src/utils/license.ts b/apps/licenses/src/utils/license.ts index 2f87e173..d9ee8140 100644 --- a/apps/licenses/src/utils/license.ts +++ b/apps/licenses/src/utils/license.ts @@ -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"; + } +};