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 { 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 { 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 { 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; }