Phase F
This commit is contained in:
330
apps/api/src/integrations/whmcs/whmcs-usage.service.ts
Normal file
330
apps/api/src/integrations/whmcs/whmcs-usage.service.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user