Files
HexaHost-GameCloud/apps/api/src/integrations/whmcs/whmcs-integration.service.ts
smueller ed8334328e
Some checks failed
CI / Node — lint, typecheck, test, build (push) Failing after 9s
CI / Go — node-agent tests (push) Failing after 8s
CI / Go — edge-gateway build (push) Successful in 17s
Phase9
2026-06-26 13:46:25 +02:00

510 lines
13 KiB
TypeScript

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<WhmcsPlanCatalogResponse> {
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<WhmcsUpsertClientResponse> {
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<WhmcsServiceResponse> {
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<WhmcsServiceResponse> {
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<WhmcsServiceResponse> {
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<WhmcsServiceResponse> {
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<WhmcsServiceResponse> {
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<WhmcsServiceResponse> {
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<void> {
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(),
},
};
}
}