Files
HexaHost-GameCloud/apps/api/src/integrations/whmcs/whmcs-usage.service.ts
smueller 15cf302afc
Some checks failed
CI / Node — lint, typecheck, test, build (push) Failing after 10s
CI / Go — node-agent tests (push) Failing after 8s
CI / Go — edge-gateway build (push) Successful in 18s
Phase F
2026-06-26 15:05:01 +02:00

331 lines
9.1 KiB
TypeScript

import { createHash, randomUUID } from 'node:crypto';
import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
WhmcsUsageAdjustmentRequest,
WhmcsUsageResponse,
} from '@hexahost/contracts';
import type { Prisma } from '@hexahost/database';
import { PrismaService } from '../../prisma/prisma.service';
import { WhmcsEventsService } from './whmcs-events.service';
import { resolveBillingPeriod } from './whmcs-period.util';
type InstallationRef = {
id: string;
};
export type WhmcsUsageMetrics = {
runtime_minutes: number;
memory_gib_minutes: number;
cpu_core_minutes: number;
storage_gib_days: number;
backup_storage_gib_days: number;
egress_gib: number;
extra_port_days: number;
priority_queue_minutes: number;
managed_service_units: number;
};
@Injectable()
export class WhmcsUsageService {
constructor(
private readonly prisma: PrismaService,
private readonly events: WhmcsEventsService,
) {}
async getServiceUsage(
installation: InstallationRef,
externalServiceId: string,
periodStart?: string,
periodEnd?: string,
testMode = false,
): Promise<WhmcsUsageResponse> {
const link = await this.prisma.whmcsServiceLink.findUnique({
where: {
installationId_externalServiceId: {
installationId: installation.id,
externalServiceId,
},
},
include: {
server: { include: { plan: true } },
},
});
if (!link) {
throw new NotFoundException(`Unknown WHMCS service ${externalServiceId}`);
}
const period = resolveBillingPeriod(periodStart, periodEnd);
const existing = await this.prisma.whmcsUsageExport.findUnique({
where: {
installationId_externalServiceId_periodStart_periodEnd_isTestMode: {
installationId: installation.id,
externalServiceId,
periodStart: period.start,
periodEnd: period.end,
isTestMode: testMode,
},
},
});
if (existing) {
return this.toUsageResponse(existing, externalServiceId);
}
const metrics = await this.aggregateMetrics(
link.serverId,
period.start,
period.end,
link.server.plan?.maxCpuCores ?? 1,
link.server.plan?.maxStorageMb ?? 5120,
link.server.plan?.queuePriority ?? 0,
);
const contentHash = this.hashMetrics(metrics);
const usageUpdate = this.toUsageUpdate(metrics);
const created = await this.prisma.whmcsUsageExport.create({
data: {
installationId: installation.id,
externalServiceId,
serverId: link.serverId,
periodStart: period.start,
periodEnd: period.end,
metrics: metrics as Prisma.InputJsonValue,
contentHash,
status: testMode ? 'DRAFT' : 'EXPORTED',
isTestMode: testMode,
exportedAt: testMode ? null : new Date(),
},
});
if (!testMode) {
await this.events.emitEvent(
installation.id,
'usage_period_closed',
{
exportId: created.id,
externalServiceId,
periodStart: period.start.toISOString(),
periodEnd: period.end.toISOString(),
contentHash,
},
`usage:${installation.id}:${externalServiceId}:${period.start.toISOString()}:${period.end.toISOString()}`,
);
}
return this.toUsageResponse(created, externalServiceId, usageUpdate);
}
async createAdjustment(
installation: InstallationRef,
externalServiceId: string,
input: WhmcsUsageAdjustmentRequest,
idempotencyKey: string,
): Promise<WhmcsUsageResponse> {
const existingOp = await this.prisma.integrationOperation.findUnique({
where: { idempotencyKey },
});
if (existingOp?.result) {
return existingOp.result as WhmcsUsageResponse;
}
const link = await this.prisma.whmcsServiceLink.findUnique({
where: {
installationId_externalServiceId: {
installationId: installation.id,
externalServiceId,
},
},
});
if (!link) {
throw new NotFoundException(`Unknown WHMCS service ${externalServiceId}`);
}
const periodStart = new Date(input.periodStart);
const periodEnd = new Date(input.periodEnd);
const baseExport = await this.prisma.whmcsUsageExport.findUnique({
where: {
installationId_externalServiceId_periodStart_periodEnd_isTestMode: {
installationId: installation.id,
externalServiceId,
periodStart,
periodEnd,
isTestMode: input.testMode,
},
},
});
if (!baseExport) {
throw new NotFoundException('No usage export exists for this period');
}
const baseMetrics = baseExport.metrics as WhmcsUsageMetrics;
const adjustedMetrics = { ...baseMetrics };
for (const adjustment of input.adjustments) {
const key = adjustment.metricKey as keyof WhmcsUsageMetrics;
if (key in adjustedMetrics) {
adjustedMetrics[key] = roundMetric(adjustedMetrics[key] + adjustment.delta);
}
}
const contentHash = this.hashMetrics(adjustedMetrics);
const usageUpdate = this.toUsageUpdate(adjustedMetrics);
const adjustmentExport = await this.prisma.whmcsUsageExport.update({
where: { id: baseExport.id },
data: {
metrics: adjustedMetrics as Prisma.InputJsonValue,
contentHash,
status: 'ADJUSTED',
adjustmentOfId: baseExport.adjustmentOfId ?? baseExport.id,
exportedAt: new Date(),
},
});
const response = this.toUsageResponse(
adjustmentExport,
externalServiceId,
usageUpdate,
input.adjustments,
);
await this.prisma.integrationOperation.create({
data: {
installationId: installation.id,
idempotencyKey,
operation: 'usage:adjustment',
externalRef: externalServiceId,
status: 'COMPLETED',
result: response as object,
},
});
return response;
}
private async aggregateMetrics(
serverId: string,
periodStart: Date,
periodEnd: Date,
cpuCores: number,
storageMb: number,
queuePriority: number,
): Promise<WhmcsUsageMetrics> {
const usageRecords = await this.prisma.usageRecord.findMany({
where: {
serverId,
periodStart: { gte: periodStart },
periodEnd: { lte: periodEnd },
},
});
let runtimeSeconds = 0;
let memoryMbSeconds = 0;
for (const record of usageRecords) {
runtimeSeconds += record.durationSeconds;
memoryMbSeconds += record.ramMb * record.durationSeconds;
}
const periodDays = Math.max(
(periodEnd.getTime() - periodStart.getTime()) / (24 * 60 * 60 * 1000),
1,
);
const backups = await this.prisma.serverBackup.findMany({
where: {
serverId,
status: 'AVAILABLE',
completedAt: {
gte: periodStart,
lt: periodEnd,
},
},
select: { sizeBytes: true },
});
let backupStorageGiBDays = 0;
for (const backup of backups) {
const sizeGiB = Number(backup.sizeBytes ?? 0n) / (1024 ** 3);
backupStorageGiBDays += sizeGiB * periodDays;
}
const runtimeMinutes = roundMetric(runtimeSeconds / 60);
const memoryGibMinutes = roundMetric(memoryMbSeconds / 1024 / 60);
const cpuCoreMinutes = roundMetric((runtimeSeconds / 60) * cpuCores);
const storageGibDays = roundMetric((storageMb / 1024) * periodDays);
const priorityQueueMinutes =
queuePriority > 0 ? roundMetric(runtimeMinutes * (queuePriority / 10)) : 0;
return {
runtime_minutes: runtimeMinutes,
memory_gib_minutes: memoryGibMinutes,
cpu_core_minutes: cpuCoreMinutes,
storage_gib_days: storageGibDays,
backup_storage_gib_days: roundMetric(backupStorageGiBDays),
egress_gib: 0,
extra_port_days: 0,
priority_queue_minutes: priorityQueueMinutes,
managed_service_units: 0,
};
}
private hashMetrics(metrics: WhmcsUsageMetrics): string {
return createHash('sha256').update(JSON.stringify(metrics)).digest('hex');
}
private toUsageUpdate(metrics: WhmcsUsageMetrics) {
return {
diskUsageGiB: metrics.storage_gib_days,
bandwidthGiB: metrics.egress_gib,
};
}
private toUsageResponse(
exportRow: {
id: string;
serverId: string;
periodStart: Date;
periodEnd: Date;
metrics: unknown;
contentHash: string;
status: 'DRAFT' | 'EXPORTED' | 'ADJUSTED';
isTestMode: boolean;
},
externalServiceId: string,
usageUpdate?: { diskUsageGiB: number; bandwidthGiB: number },
adjustments: WhmcsUsageResponse['adjustments'] = [],
): WhmcsUsageResponse {
const metrics = exportRow.metrics as WhmcsUsageMetrics;
return {
exportId: exportRow.id,
externalServiceId,
serverId: exportRow.serverId,
periodStart: exportRow.periodStart.toISOString(),
periodEnd: exportRow.periodEnd.toISOString(),
metrics,
usageUpdate: usageUpdate ?? this.toUsageUpdate(metrics),
contentHash: exportRow.contentHash,
status: exportRow.status,
isTestMode: exportRow.isTestMode,
adjustments,
};
}
}
function roundMetric(value: number): number {
return Math.round(value * 1000) / 1000;
}