import { randomUUID } from 'node:crypto'; import { Injectable, NotFoundException, } from '@nestjs/common'; import type { WhmcsChangePackageRequest, WhmcsPlanCatalogResponse, WhmcsProvisionServiceRequest, WhmcsServiceResponse, WhmcsUpsertClientRequest, WhmcsUpsertClientResponse, } from '@hexahost/contracts'; import { formatJoinAddress } from '@hexahost/dns'; import type { GameServer } from '@hexahost/database'; import { BillingService } from '../../billing/billing.service'; import { PrismaService } from '../../prisma/prisma.service'; import { ServersService } from '../../servers/servers.service'; import type { WhmcsInstallationContext } from './whmcs-integration.guard'; type InstallationRef = { id: string; integrationId: string; }; @Injectable() export class WhmcsIntegrationService { constructor( private readonly prisma: PrismaService, private readonly serversService: ServersService, private readonly billing: BillingService, ) {} getHealth(installation: WhmcsInstallationContext) { return { status: 'ok' as const, integrationId: installation.integrationId, billingProvider: process.env['BILLING_PROVIDER'] ?? 'internal', }; } async listPlans(): Promise { const plans = await this.prisma.plan.findMany({ where: { isActive: true }, orderBy: { name: 'asc' }, }); return { plans: plans.map((plan) => ({ id: plan.id, slug: plan.slug, name: plan.name, maxRamMb: plan.maxRamMb, isFree: plan.isFree, })), }; } async upsertClient( installation: InstallationRef, externalClientId: string, input: WhmcsUpsertClientRequest, idempotencyKey: string, ): Promise { const existingOp = await this.findCompletedOperation(installation.id, idempotencyKey); if (existingOp?.result) { return existingOp.result as WhmcsUpsertClientResponse; } const existingLink = await this.prisma.whmcsClientLink.findUnique({ where: { installationId_externalClientId: { installationId: installation.id, externalClientId, }, }, }); if (existingLink) { const user = await this.prisma.user.update({ where: { id: existingLink.userId }, data: { displayName: input.displayName, }, }); const result = { userId: user.id, externalClientId, created: false, }; await this.recordOperation(installation.id, idempotencyKey, 'client:upsert', result); return result; } const user = await this.prisma.user.create({ data: { email: input.email, username: input.username, displayName: input.displayName, status: 'ACTIVE', }, }); await this.billing.ensureWalletForUser(user.id); await this.prisma.whmcsClientLink.create({ data: { installationId: installation.id, externalClientId, userId: user.id, }, }); const result = { userId: user.id, externalClientId, created: true, }; await this.recordOperation(installation.id, idempotencyKey, 'client:upsert', result); return result; } async provisionService( installation: InstallationRef, input: WhmcsProvisionServiceRequest, idempotencyKey: string, ): Promise { const existingOp = await this.findCompletedOperation(installation.id, idempotencyKey); if (existingOp?.result) { return existingOp.result as WhmcsServiceResponse; } const existing = await this.prisma.whmcsServiceLink.findUnique({ where: { installationId_externalServiceId: { installationId: installation.id, externalServiceId: input.externalServiceId, }, }, include: { server: true }, }); if (existing) { return this.toServiceResponse(existing.externalServiceId, existing.status, existing.server); } const clientLink = await this.prisma.whmcsClientLink.findUnique({ where: { installationId_externalClientId: { installationId: installation.id, externalClientId: input.externalClientId, }, }, }); if (!clientLink) { throw new NotFoundException(`Unknown WHMCS client ${input.externalClientId}`); } const plan = await this.prisma.plan.findFirst({ where: { slug: input.planSlug, isActive: true }, }); if (!plan) { throw new NotFoundException(`Unknown plan slug ${input.planSlug}`); } await this.billing.assertCanCreateServer(clientLink.userId, plan.id); const server = await this.serversService.createServer(clientLink.userId, { name: input.serverName, edition: input.edition, softwareFamily: input.softwareFamily, minecraftVersion: input.minecraftVersion, eulaAccepted: true, planId: plan.id, ramMb: plan.maxRamMb, }); await this.prisma.whmcsServiceLink.create({ data: { installationId: installation.id, externalServiceId: input.externalServiceId, externalProductId: input.externalProductId, serverId: server.id, status: 'PENDING', }, }); const started = await this.serversService.startServerInternal( server.id, `whmcs:provision:${input.externalServiceId}`, ); await this.prisma.whmcsServiceLink.update({ where: { installationId_externalServiceId: { installationId: installation.id, externalServiceId: input.externalServiceId, }, }, data: { status: 'ACTIVE' }, }); const response = this.toServiceResponse( input.externalServiceId, 'ACTIVE', started.server as unknown as GameServer, ); await this.recordOperation( installation.id, idempotencyKey, 'service:provision', response, input.externalServiceId, ); return response; } async getService( installation: InstallationRef, externalServiceId: string, ): Promise { const link = await this.findServiceLink(installation.id, externalServiceId); return this.toServiceResponse(externalServiceId, link.status, link.server); } async suspendService( installation: InstallationRef, externalServiceId: string, reason: string | undefined, idempotencyKey: string, ): Promise { const existingOp = await this.findCompletedOperation(installation.id, idempotencyKey); if (existingOp?.result) { return existingOp.result as WhmcsServiceResponse; } const link = await this.findServiceLink(installation.id, externalServiceId); const server = link.server; if (server.status === 'RUNNING') { await this.serversService.stopServer(server.userId, server.id); } else if (server.status === 'QUEUED') { await this.serversService.cancelQueuedStart(server.userId, server.id); } const updatedServer = await this.prisma.gameServer.update({ where: { id: server.id }, data: { billingSuspendedAt: new Date(), billingSuspendReason: reason ?? 'whmcs-suspend', }, }); const updatedLink = await this.prisma.whmcsServiceLink.update({ where: { id: link.id }, data: { status: 'SUSPENDED', suspendedAt: new Date(), }, }); const response = this.toServiceResponse( externalServiceId, updatedLink.status, updatedServer, ); await this.recordOperation( installation.id, idempotencyKey, 'service:suspend', response, externalServiceId, ); return response; } async unsuspendService( installation: InstallationRef, externalServiceId: string, idempotencyKey: string, ): Promise { const existingOp = await this.findCompletedOperation(installation.id, idempotencyKey); if (existingOp?.result) { return existingOp.result as WhmcsServiceResponse; } const link = await this.findServiceLink(installation.id, externalServiceId); const updatedServer = await this.prisma.gameServer.update({ where: { id: link.serverId }, data: { billingSuspendedAt: null, billingSuspendReason: null, }, }); const updatedLink = await this.prisma.whmcsServiceLink.update({ where: { id: link.id }, data: { status: 'ACTIVE', suspendedAt: null, }, }); const response = this.toServiceResponse( externalServiceId, updatedLink.status, updatedServer, ); await this.recordOperation( installation.id, idempotencyKey, 'service:unsuspend', response, externalServiceId, ); return response; } async terminateService( installation: InstallationRef, externalServiceId: string, idempotencyKey: string, ): Promise { const existingOp = await this.findCompletedOperation(installation.id, idempotencyKey); if (existingOp?.result) { return existingOp.result as WhmcsServiceResponse; } const link = await this.findServiceLink(installation.id, externalServiceId); const server = link.server; if (server.status === 'RUNNING') { await this.serversService.stopServer(server.userId, server.id); } const updatedServer = await this.prisma.gameServer.update({ where: { id: server.id }, data: { status: 'DELETED', billingSuspendedAt: new Date(), billingSuspendReason: 'whmcs-terminate', }, }); const updatedLink = await this.prisma.whmcsServiceLink.update({ where: { id: link.id }, data: { status: 'TERMINATED', terminatedAt: new Date(), }, }); const response = this.toServiceResponse( externalServiceId, updatedLink.status, updatedServer, ); await this.recordOperation( installation.id, idempotencyKey, 'service:terminate', response, externalServiceId, ); return response; } async changePackage( installation: InstallationRef, externalServiceId: string, input: WhmcsChangePackageRequest, idempotencyKey: string, ): Promise { const existingOp = await this.findCompletedOperation(installation.id, idempotencyKey); if (existingOp?.result) { return existingOp.result as WhmcsServiceResponse; } const link = await this.findServiceLink(installation.id, externalServiceId); const plan = await this.prisma.plan.findFirst({ where: { slug: input.planSlug, isActive: true }, }); if (!plan) { throw new NotFoundException(`Unknown plan slug ${input.planSlug}`); } const updatedServer = await this.prisma.gameServer.update({ where: { id: link.serverId }, data: { planId: plan.id, ramMb: input.ramMb ?? plan.maxRamMb, }, }); const response = this.toServiceResponse( externalServiceId, link.status, updatedServer, ); await this.recordOperation( installation.id, idempotencyKey, 'service:change-package', response, externalServiceId, ); return response; } private async findServiceLink(installationId: string, externalServiceId: string) { const link = await this.prisma.whmcsServiceLink.findUnique({ where: { installationId_externalServiceId: { installationId, externalServiceId, }, }, include: { server: true }, }); if (!link) { throw new NotFoundException(`Unknown WHMCS service ${externalServiceId}`); } return link; } private async findCompletedOperation(installationId: string, idempotencyKey: string) { return this.prisma.integrationOperation.findUnique({ where: { idempotencyKey }, }); } private async recordOperation( installationId: string, idempotencyKey: string, operation: string, result: unknown, externalRef?: string, ): Promise { await this.prisma.integrationOperation.create({ data: { installationId, idempotencyKey, operation, externalRef, status: 'COMPLETED', result: result as object, }, }); } private toServiceResponse( externalServiceId: string, status: 'PENDING' | 'ACTIVE' | 'SUSPENDED' | 'TERMINATED', server: GameServer, ): WhmcsServiceResponse { return { externalServiceId, status, server: { id: server.id, userId: server.userId, planId: server.planId, nodeId: server.nodeId, name: server.name, status: server.status, version: server.version, edition: server.edition, softwareFamily: server.softwareFamily, minecraftVersion: server.minecraftVersion, eulaAccepted: server.eulaAccepted, hostPort: server.hostPort, containerId: server.containerId, dataPath: server.dataPath, activeWorldName: server.activeWorldName, ramMb: server.ramMb, idleShutdownEnabled: server.idleShutdownEnabled, idleStopAt: server.idleStopAt?.toISOString() ?? null, joinSlug: server.joinSlug, joinHostname: formatJoinAddress(server.joinSlug, server.edition), joinToStartEnabled: server.joinToStartEnabled, createdAt: server.createdAt.toISOString(), updatedAt: server.updatedAt.toISOString(), }, }; } }