331 lines
9.1 KiB
TypeScript
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;
|
|
}
|